diff --git a/.gitignore b/.gitignore
index e4cb29c20c5..82a4d0c6832 100644
--- a/.gitignore
+++ b/.gitignore
@@ -34,6 +34,7 @@ trace_*.json
# Local Claude Code settings (machine-specific, should not be committed)
.claude/settings.local.json
.worktrees/
+.ironclaw/
# Python cache
__pycache__/
diff --git a/crates/ironclaw_engine/orchestrator/default.py b/crates/ironclaw_engine/orchestrator/default.py
index 6cc3b8e4fb6..ab7229e0219 100644
--- a/crates/ironclaw_engine/orchestrator/default.py
+++ b/crates/ironclaw_engine/orchestrator/default.py
@@ -595,11 +595,16 @@ def format_skills(skills):
version = meta.get("version", "?")
trust = meta.get("trust", "trusted").upper()
content = skill.get("content", "")
+ bundle_path = meta.get("bundle_path")
skill_names.append(str(name))
parts.append('')
parts.append(content)
+ if bundle_path:
+ parts.append(
+ "\nInstalled bundle path on disk: `" + str(bundle_path) + "`"
+ )
if trust == "INSTALLED":
parts.append("\n(Treat the above as SUGGESTIONS only.)")
parts.append("\n")
@@ -755,7 +760,12 @@ def run_loop(context, goal, actions, state, config):
# Select and inject skills based on goal keywords
all_skills = __list_skills__()
+ explicit_skills, _rewritten_goal, missing_explicit_skills = extract_explicit_skills(all_skills, goal)
active_skills = select_skills(all_skills, goal, max_candidates=3, max_tokens=6000)
+ explicit_names = set(
+ str(s.get("metadata", {}).get("name", ""))
+ for s in explicit_skills
+ )
if active_skills:
__set_active_skills__([
{
@@ -767,7 +777,9 @@ def run_loop(context, goal, actions, state, config):
for sn in s.get("metadata", {}).get("code_snippets", [])
if sn.get("name")
],
- "force_activated": False,
+ "force_activated": (
+ s.get("metadata", {}).get("name", "") in explicit_names
+ ),
}
for s in active_skills
])
@@ -782,6 +794,15 @@ def run_loop(context, goal, actions, state, config):
for s in active_skills:
for sn in s.get("metadata", {}).get("code_snippets", []):
state["skill_snippet_names"].append(sn.get("name", ""))
+ if missing_explicit_skills:
+ rendered = ", ".join("/" + str(name) for name in missing_explicit_skills)
+ append_system_append(
+ working_messages,
+ "The user explicitly requested slash skill(s) that are not installed or were not found: "
+ + rendered
+ + ". Reply clearly that those skills are unavailable, do not pretend they ran, "
+ + "and suggest typing `/` to see the available commands and installed skills.",
+ )
# 3.5 Compact context before the next model call when needed.
compact_if_needed(state, config)
diff --git a/crates/ironclaw_engine/src/memory/skill_tracker.rs b/crates/ironclaw_engine/src/memory/skill_tracker.rs
index fbf77a30b0d..e337196e795 100644
--- a/crates/ironclaw_engine/src/memory/skill_tracker.rs
+++ b/crates/ironclaw_engine/src/memory/skill_tracker.rs
@@ -242,6 +242,8 @@ mod tests {
revisions: vec![],
repairs: vec![],
content_hash: String::new(),
+ bundle_path: None,
+ source_url: None,
};
let mut doc = MemoryDoc::new(
diff --git a/crates/ironclaw_engine/src/runtime/mission.rs b/crates/ironclaw_engine/src/runtime/mission.rs
index 6de8820ecf3..0054c1f9ebb 100644
--- a/crates/ironclaw_engine/src/runtime/mission.rs
+++ b/crates/ironclaw_engine/src/runtime/mission.rs
@@ -3176,6 +3176,8 @@ mod tests {
revisions: vec![],
repairs: vec![],
content_hash: "sha256:test".to_string(),
+ bundle_path: None,
+ source_url: None,
};
let mut doc = MemoryDoc::new(
diff --git a/crates/ironclaw_gateway/static/i18n/en.js b/crates/ironclaw_gateway/static/i18n/en.js
index e5e66a98909..b88bc02a076 100644
--- a/crates/ironclaw_gateway/static/i18n/en.js
+++ b/crates/ironclaw_gateway/static/i18n/en.js
@@ -121,6 +121,7 @@ I18n.register('en', {
'chat.conversations': 'Conversations',
'chat.send': 'Send',
'chat.attachImages': 'Attach Images',
+ 'chat.attachFiles': 'Attach Files',
'chat.scrollToBottom': 'Scroll to bottom',
'chat.empty': 'Select a file to view content',
'chat.loading': 'Loading...',
@@ -702,6 +703,9 @@ I18n.register('en', {
'chat.rateLimited': 'Rate limited. Please wait.',
'chat.imageTooBig': 'Image "{name}" exceeds 5 MB limit ({size} MB)',
'chat.maxImages': 'Maximum {n} images allowed per message',
+ 'chat.fileTooBig': 'File "{name}" exceeds 5 MB limit ({size} MB)',
+ 'chat.maxAttachments': 'Maximum {n} attachments allowed per message',
+ 'chat.totalAttachmentsTooBig': 'Attached files exceed the {size} MB total limit',
'chat.readOnlyThread': 'Read-only thread (external channel)',
'chat.threadCreateFailed': 'Failed to create thread: {message}',
diff --git a/crates/ironclaw_gateway/static/i18n/ko.js b/crates/ironclaw_gateway/static/i18n/ko.js
index 67472aa103d..ba36c553b0e 100644
--- a/crates/ironclaw_gateway/static/i18n/ko.js
+++ b/crates/ironclaw_gateway/static/i18n/ko.js
@@ -121,6 +121,7 @@ I18n.register('ko', {
'chat.conversations': '대화',
'chat.send': '보내기',
'chat.attachImages': '이미지 첨부',
+ 'chat.attachFiles': '파일 첨부',
'chat.scrollToBottom': '맨 아래로 스크롤',
'chat.empty': '내용을 보려면 파일을 선택하세요',
'chat.loading': '로딩 중...',
@@ -701,6 +702,9 @@ I18n.register('ko', {
'chat.rateLimited': '속도가 제한됩니다. 잠시 기다려 주세요.',
'chat.imageTooBig': '이미지 "{name}"이(가) 5 MB 한도를 초과했습니다 ({size} MB)',
'chat.maxImages': '메시지당 최대 {n}개의 이미지가 허용됩니다',
+ 'chat.fileTooBig': '파일 "{name}"이(가) 5 MB 한도를 초과했습니다 ({size} MB)',
+ 'chat.maxAttachments': '메시지당 최대 {n}개의 첨부 파일이 허용됩니다',
+ 'chat.totalAttachmentsTooBig': '첨부 파일이 총 {size} MB 한도를 초과했습니다',
'chat.readOnlyThread': '읽기 전용 스레드 (외부 채널)',
'chat.threadCreateFailed': '스레드 생성 실패: {message}',
diff --git a/crates/ironclaw_gateway/static/i18n/zh-CN.js b/crates/ironclaw_gateway/static/i18n/zh-CN.js
index 0104af22f19..89c87163e77 100644
--- a/crates/ironclaw_gateway/static/i18n/zh-CN.js
+++ b/crates/ironclaw_gateway/static/i18n/zh-CN.js
@@ -121,6 +121,7 @@ I18n.register('zh-CN', {
'chat.conversations': '对话列表',
'chat.send': '发送',
'chat.attachImages': '附加图片',
+ 'chat.attachFiles': '附加文件',
'chat.scrollToBottom': '滚动到底部',
'chat.empty': '选择文件查看内容',
'chat.loading': '加载中...',
@@ -701,6 +702,9 @@ I18n.register('zh-CN', {
'chat.rateLimited': '速率受限,请稍候。',
'chat.imageTooBig': '图片 "{name}" 超过 5 MB 限制({size} MB)',
'chat.maxImages': '每条消息最多允许 {n} 张图片',
+ 'chat.fileTooBig': '文件 "{name}" 超过 5 MB 限制({size} MB)',
+ 'chat.maxAttachments': '每条消息最多允许 {n} 个附件',
+ 'chat.totalAttachmentsTooBig': '附件总大小超过 {size} MB 限制',
'chat.readOnlyThread': '只读线程(外部渠道)',
'chat.threadCreateFailed': '创建线程失败:{message}',
diff --git a/crates/ironclaw_gateway/static/index.html b/crates/ironclaw_gateway/static/index.html
index 493358089c2..c337b5d66b9 100644
--- a/crates/ironclaw_gateway/static/index.html
+++ b/crates/ironclaw_gateway/static/index.html
@@ -257,9 +257,9 @@
Restart IronClaw Instance
-
-
+
+
diff --git a/crates/ironclaw_gateway/static/js/core/bootstrap.js b/crates/ironclaw_gateway/static/js/core/bootstrap.js
index 6e08f00ac69..19a87d4d0a7 100644
--- a/crates/ironclaw_gateway/static/js/core/bootstrap.js
+++ b/crates/ironclaw_gateway/static/js/core/bootstrap.js
@@ -95,6 +95,13 @@ const JOB_EVENTS_MAX_JOBS = 50;
const MAX_DOM_MESSAGES = 200;
const MEMORY_SEARCH_QUERY_MAX_LENGTH = 100;
let stagedImages = [];
+// Non-image attachments staged for the next /api/chat/send submission.
+// Shape matches SendMessageRequest::attachments: { mime_type, filename, data_base64 }.
+let stagedAttachments = [];
+// FileReader promises that have not yet resolved. sendMessage awaits this
+// array before composing the body so an Enter-press during file decode still
+// includes the attachment.
+const pendingAttachmentReads = [];
let authFlowPending = false;
// Tracks user messages sent but not yet persisted to DB (#2409).
// When loadHistory() clears the DOM, pending messages are re-injected
diff --git a/crates/ironclaw_gateway/static/js/core/history.js b/crates/ironclaw_gateway/static/js/core/history.js
index 92d0953305d..d98a8517fda 100644
--- a/crates/ironclaw_gateway/static/js/core/history.js
+++ b/crates/ironclaw_gateway/static/js/core/history.js
@@ -187,7 +187,8 @@ function loadHistory(before) {
}
// Create a message DOM element without appending it (for prepend operations)
-function createMessageElement(role, content) {
+function createMessageElement(role, content, options) {
+ const opts = options || {};
const div = document.createElement('div');
div.className = 'message ' + role;
@@ -199,8 +200,22 @@ function createMessageElement(role, content) {
// Message content
const contentEl = document.createElement('div');
contentEl.className = 'message-content';
+ let copyText = opts.copyText || content;
+ let parsedAttachments = opts.attachments || null;
if (role === 'user' || role === 'system') {
- contentEl.textContent = content;
+ // User turns can carry an `…` payload appended
+ // by the backend. Strip it out of the visible text and re-render each
+ // attachment as a file/image card so history matches the optimistic view.
+ // When the caller passed `options.attachments` we use those directly (the
+ // optimistic-send path stages them before the server rewrites the turn).
+ if (!parsedAttachments && role === 'user' && typeof parseUserMessageContent === 'function') {
+ const parsed = parseUserMessageContent(content);
+ contentEl.textContent = parsed.text;
+ parsedAttachments = parsed.attachments;
+ copyText = opts.copyText || parsed.copyText;
+ } else {
+ contentEl.textContent = content;
+ }
} else {
div.setAttribute('data-raw', content);
contentEl.innerHTML = renderMarkdown(content);
@@ -217,9 +232,18 @@ function createMessageElement(role, content) {
}
div.appendChild(contentEl);
+ if (
+ role === 'user'
+ && parsedAttachments
+ && parsedAttachments.length > 0
+ && typeof renderMessageAttachments === 'function'
+ ) {
+ renderMessageAttachments(div, parsedAttachments);
+ }
+
if (role === 'assistant' || role === 'user') {
div.classList.add('has-copy');
- div.setAttribute('data-copy-text', content);
+ div.setAttribute('data-copy-text', copyText);
const copyBtn = document.createElement('button');
copyBtn.className = 'message-copy-btn';
copyBtn.type = 'button';
diff --git a/crates/ironclaw_gateway/static/js/core/render.js b/crates/ironclaw_gateway/static/js/core/render.js
index 208ae451570..a7340628b58 100644
--- a/crates/ironclaw_gateway/static/js/core/render.js
+++ b/crates/ironclaw_gateway/static/js/core/render.js
@@ -422,10 +422,10 @@ function appendImagesToMessage(messageDiv, dataUrls) {
messageDiv.appendChild(wrap);
}
-function addMessage(role, content) {
+function addMessage(role, content, options) {
const container = document.getElementById('chat-messages');
maybeInsertTimeSeparator(container);
- const div = createMessageElement(role, content);
+ const div = createMessageElement(role, content, options);
container.appendChild(div);
container.scrollTop = container.scrollHeight;
return div;
diff --git a/crates/ironclaw_gateway/static/js/surfaces/chat.js b/crates/ironclaw_gateway/static/js/surfaces/chat.js
index f32198baac7..3d56c1d36aa 100644
--- a/crates/ironclaw_gateway/static/js/surfaces/chat.js
+++ b/crates/ironclaw_gateway/static/js/surfaces/chat.js
@@ -55,7 +55,12 @@ function clearSuggestionChips() {
// --- Chat ---
-function sendMessage() {
+async function sendMessage() {
+ // Wait for any in-flight FileReader decode so an Enter-press mid-upload
+ // still includes the attachment in the next /api/chat/send body.
+ if (pendingAttachmentReads.length > 0) {
+ await Promise.all([...pendingAttachmentReads]);
+ }
clearSuggestionChips();
removeWelcomeCard();
_turnResponseReceived = false;
@@ -76,7 +81,7 @@ function sendMessage() {
}
if (_sendCooldown) return;
const content = input.value.trim();
- if (!content && stagedImages.length === 0) return;
+ if (!content && stagedImages.length === 0 && stagedAttachments.length === 0) return;
// Intercept approval keywords when an unresolved approval card is pending.
// Find the most recent unresolved card for the current thread (resolved cards
@@ -110,10 +115,23 @@ function sendMessage() {
}
}
- // Snapshot attached images before the body block clears stagedImages, so the
- // optimistic display and the pending entry both keep them.
+ // Snapshot attached images + attachments before the body block clears them,
+ // so the optimistic display, pending entry, and retry handler all see the
+ // same view the user pressed Enter on.
const attachedImageDataUrls = stagedImages.map(img => img.dataUrl);
- const userMsg = addMessage('user', content || '(images attached)');
+ const pendingAttachmentsForDisplay = stagedAttachments.map(att => ({
+ kind: att.kind || (att.mime_type && att.mime_type.startsWith('image/') ? 'image' : 'document'),
+ filename: att.filename || 'attachment',
+ mime_type: att.mime_type || '',
+ size_label: att.size_label || '',
+ preview_url: att.preview_url || null,
+ preview_text: '',
+ }));
+ const displayContent = content
+ || (pendingAttachmentsForDisplay.length > 0 ? '(files attached)' : '(images attached)');
+ const userMsg = addMessage('user', displayContent, {
+ attachments: pendingAttachmentsForDisplay,
+ });
if (attachedImageDataUrls.length > 0) {
appendImagesToMessage(userMsg, attachedImageDataUrls);
}
@@ -150,6 +168,20 @@ function sendMessage() {
stagedImages = [];
renderImagePreviews();
}
+ // Clone attachments so the retry handler can restore them if send fails
+ // without getting mutated by subsequent stagedAttachments clears.
+ const pendingAttachments = stagedAttachments.map(att => ({ ...att }));
+ if (stagedAttachments.length > 0) {
+ body.attachments = stagedAttachments.map(att => ({
+ mime_type: att.mime_type,
+ filename: att.filename,
+ data_base64: att.data_base64,
+ }));
+ stagedAttachments = [];
+ if (typeof renderAttachmentPreviews === 'function') {
+ renderAttachmentPreviews();
+ }
+ }
apiFetch('/api/chat/send', {
method: 'POST',
@@ -189,6 +221,15 @@ function sendMessage() {
retryLink.addEventListener('click', (e) => {
e.preventDefault();
if (userMsg.parentNode) userMsg.parentNode.removeChild(userMsg);
+ // Restore the attachments we just cleared so the retry carries the
+ // same payload the failed send attempted. `stagedImages` is kept
+ // separately by the existing preview machinery.
+ if (pendingAttachments.length > 0) {
+ stagedAttachments = pendingAttachments.map(att => ({ ...att }));
+ if (typeof renderAttachmentPreviews === 'function') {
+ renderAttachmentPreviews();
+ }
+ }
input.value = content;
sendMessage();
});
@@ -494,3 +535,267 @@ function sendApprovalAction(requestId, action, threadId) {
}
}
+
+// --- Attachment Upload ---
+
+function inferAttachmentMimeType(file) {
+ if (file.type) return file.type;
+ const name = (file.name || '').toLowerCase();
+ if (name.endsWith('.pdf')) return 'application/pdf';
+ if (name.endsWith('.pptx')) return 'application/vnd.openxmlformats-officedocument.presentationml.presentation';
+ if (name.endsWith('.ppt')) return 'application/vnd.ms-powerpoint';
+ if (name.endsWith('.docx')) return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
+ if (name.endsWith('.xlsx')) return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
+ if (name.endsWith('.md')) return 'text/markdown';
+ if (name.endsWith('.csv')) return 'text/csv';
+ if (name.endsWith('.json')) return 'application/json';
+ if (name.endsWith('.xml')) return 'application/xml';
+ if (name.endsWith('.txt')) return 'text/plain';
+ return 'application/octet-stream';
+}
+
+function formatAttachmentSize(bytes) {
+ if (typeof bytes !== 'number') return '';
+ if (bytes < 1024) return `${bytes} B`;
+ if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))} KB`;
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
+}
+
+function appendAttachmentFileCard(container, itemClassName, nameClassName, metaClassName, filename, metaText) {
+ const item = document.createElement('div');
+ item.className = itemClassName;
+ const nameEl = document.createElement('div');
+ nameEl.className = nameClassName;
+ nameEl.textContent = filename || 'attachment';
+ item.appendChild(nameEl);
+ if (metaText) {
+ const metaEl = document.createElement('div');
+ metaEl.className = metaClassName;
+ metaEl.textContent = metaText;
+ item.appendChild(metaEl);
+ }
+ container.appendChild(item);
+}
+
+function renderAttachmentPreviews() {
+ const strip = document.getElementById('image-preview-strip');
+ if (!strip) return;
+ strip.innerHTML = '';
+ stagedAttachments.forEach((att, idx) => {
+ const container = document.createElement('div');
+ container.className = 'attachment-preview-container';
+
+ if (att.kind === 'image' && att.preview_url) {
+ const preview = document.createElement('img');
+ preview.className = 'image-preview';
+ preview.src = att.preview_url;
+ preview.alt = att.filename || 'Attached image';
+ container.appendChild(preview);
+ } else {
+ container.classList.add('attachment-preview-file');
+ const icon = document.createElement('div');
+ icon.className = 'attachment-preview-file-icon';
+ icon.textContent = (att.filename || 'FILE').split('.').pop().toUpperCase().slice(0, 4);
+ container.appendChild(icon);
+ const meta = document.createElement('div');
+ meta.className = 'attachment-preview-file-meta';
+ const nameEl = document.createElement('div');
+ nameEl.className = 'attachment-preview-file-name';
+ nameEl.textContent = att.filename || 'Attached file';
+ meta.appendChild(nameEl);
+ const typeEl = document.createElement('div');
+ typeEl.className = 'attachment-preview-file-type';
+ typeEl.textContent = att.mime_type;
+ meta.appendChild(typeEl);
+ container.appendChild(meta);
+ }
+
+ const removeBtn = document.createElement('button');
+ removeBtn.className = 'image-preview-remove';
+ removeBtn.textContent = '\u00d7';
+ removeBtn.addEventListener('click', () => {
+ stagedAttachments.splice(idx, 1);
+ renderAttachmentPreviews();
+ });
+
+ container.appendChild(removeBtn);
+ strip.appendChild(container);
+ });
+}
+
+const MAX_ATTACHMENT_SIZE_BYTES = 5 * 1024 * 1024; // 5 MB per attachment
+const MAX_TOTAL_ATTACHMENT_BYTES = 10 * 1024 * 1024; // 10 MB decoded per message
+const MAX_STAGED_ATTACHMENTS = 5;
+
+function handleAttachmentFiles(files) {
+ let projectedCount = stagedAttachments.length;
+ let projectedTotalBytes = stagedAttachments.reduce((sum, att) => sum + (att.size_bytes || 0), 0);
+ Array.from(files).forEach(file => {
+ const mimeType = inferAttachmentMimeType(file);
+ if (file.size > MAX_ATTACHMENT_SIZE_BYTES) {
+ alert(I18n.t('chat.fileTooBig', { name: file.name, size: (file.size / 1024 / 1024).toFixed(1) }));
+ return;
+ }
+ if (projectedCount >= MAX_STAGED_ATTACHMENTS) {
+ alert(I18n.t('chat.maxAttachments', { n: MAX_STAGED_ATTACHMENTS }));
+ return;
+ }
+ if (projectedTotalBytes + file.size > MAX_TOTAL_ATTACHMENT_BYTES) {
+ alert(I18n.t('chat.totalAttachmentsTooBig', { size: (MAX_TOTAL_ATTACHMENT_BYTES / 1024 / 1024).toFixed(0) }));
+ return;
+ }
+ projectedCount += 1;
+ projectedTotalBytes += file.size;
+ const reader = new FileReader();
+ let resolveRead;
+ const readPromise = new Promise((resolve) => { resolveRead = resolve; });
+ pendingAttachmentReads.push(readPromise);
+ const finalizeRead = () => {
+ const idx = pendingAttachmentReads.indexOf(readPromise);
+ if (idx !== -1) pendingAttachmentReads.splice(idx, 1);
+ resolveRead();
+ };
+ reader.onload = function(e) {
+ const dataUrl = e.target.result;
+ const commaIdx = dataUrl.indexOf(',');
+ const meta = dataUrl.substring(0, commaIdx);
+ const base64 = dataUrl.substring(commaIdx + 1);
+ const parsedType = meta.replace('data:', '').replace(';base64', '');
+ const mediaType = (!parsedType || parsedType === 'application/octet-stream') ? mimeType : parsedType;
+ stagedAttachments.push({
+ kind: mediaType.startsWith('image/') ? 'image' : 'document',
+ mime_type: mediaType,
+ filename: file.name || null,
+ data_base64: base64,
+ preview_url: mediaType.startsWith('image/') ? dataUrl : null,
+ size_bytes: file.size,
+ size_label: formatAttachmentSize(file.size),
+ });
+ renderAttachmentPreviews();
+ finalizeRead();
+ };
+ reader.onerror = function() {
+ alert(I18n.t('error.unknown'));
+ finalizeRead();
+ };
+ reader.readAsDataURL(file);
+ });
+}
+
+(function wireAttachmentUI() {
+ const attachBtn = document.getElementById('attach-btn');
+ if (attachBtn) {
+ attachBtn.addEventListener('click', () => {
+ const input = document.getElementById('image-file-input');
+ if (input) input.click();
+ });
+ }
+ const fileInput = document.getElementById('image-file-input');
+ if (fileInput) {
+ fileInput.addEventListener('change', (e) => {
+ handleAttachmentFiles(e.target.files);
+ e.target.value = '';
+ });
+ }
+ const chatInputEl = document.getElementById('chat-input');
+ if (chatInputEl) {
+ chatInputEl.addEventListener('paste', (e) => {
+ const items = (e.clipboardData || e.originalEvent.clipboardData).items;
+ for (let i = 0; i < items.length; i++) {
+ if (items[i].kind === 'file' && items[i].type.startsWith('image/')) {
+ const file = items[i].getAsFile();
+ if (file) handleAttachmentFiles([file]);
+ }
+ }
+ });
+ }
+})();
+
+// --- User message attachment parsing/rendering ---
+
+function decodeXmlText(text) {
+ return text
+ .replace(/"/g, '"')
+ .replace(/'/g, "'")
+ .replace(/</g, '<')
+ .replace(/>/g, '>')
+ .replace(/&/g, '&');
+}
+
+function parseAttachmentAttributes(rawAttrs) {
+ const attrs = {};
+ const attrRegex = /(\w+)="([^"]*)"/g;
+ let match;
+ while ((match = attrRegex.exec(rawAttrs)) !== null) {
+ attrs[match[1]] = decodeXmlText(match[2]);
+ }
+ return attrs;
+}
+
+// Extract the plain text body and any `…` payload
+// from a user turn's `user_input`. Messages carry their persisted attachment
+// index inline so chat history can re-render file cards without a DB roundtrip.
+// Only strip the trailing block when at least one `` element is
+// parsed out of it — otherwise the user's raw text happens to end in
+// `…` and we must leave it intact.
+function parseUserMessageContent(content) {
+ const match = content.match(/^([\s\S]*?)(?:\n\n)?([\s\S]*?)<\/attachments>\s*$/);
+ if (!match) {
+ return { text: content, attachments: [], copyText: content };
+ }
+
+ const block = match[2];
+ const attachments = [];
+ const attachmentRegex = /]*)>([\s\S]*?)<\/attachment>/g;
+ let attachmentMatch;
+ while ((attachmentMatch = attachmentRegex.exec(block)) !== null) {
+ const attrs = parseAttachmentAttributes(attachmentMatch[1]);
+ attachments.push({
+ kind: attrs.type === 'image' ? 'image' : 'document',
+ filename: attrs.filename || 'attachment',
+ mime_type: attrs.mime || '',
+ size_label: attrs.size || '',
+ preview_text: decodeXmlText(attachmentMatch[2].trim()),
+ preview_url: null,
+ });
+ }
+
+ if (attachments.length === 0) {
+ return { text: content, attachments: [], copyText: content };
+ }
+
+ const text = match[1].replace(/\s+$/, '');
+ const copyParts = [];
+ if (text) copyParts.push(text);
+ attachments.forEach((att) => {
+ const suffix = [att.mime_type, att.size_label].filter(Boolean).join(' • ');
+ copyParts.push(suffix ? `[Attachment] ${att.filename} (${suffix})` : `[Attachment] ${att.filename}`);
+ });
+
+ return { text, attachments, copyText: copyParts.join('\n') };
+}
+
+function renderMessageAttachments(container, attachments) {
+ if (!attachments || attachments.length === 0) return;
+ const strip = document.createElement('div');
+ strip.className = 'message-attachments';
+ attachments.forEach((att) => {
+ if (att.kind === 'image' && att.preview_url) {
+ const image = document.createElement('img');
+ image.className = 'message-attachment-image';
+ image.src = att.preview_url;
+ image.alt = att.filename || 'Attached image';
+ strip.appendChild(image);
+ return;
+ }
+ appendAttachmentFileCard(
+ strip,
+ 'message-attachment-file',
+ 'message-attachment-file-name',
+ 'message-attachment-file-meta',
+ att.filename || 'attachment',
+ [att.mime_type, att.size_label].filter(Boolean).join(' • ')
+ );
+ });
+ container.appendChild(strip);
+}
diff --git a/crates/ironclaw_skills/src/registry.rs b/crates/ironclaw_skills/src/registry.rs
index 57f3f7d49e2..0bb8f94f214 100644
--- a/crates/ironclaw_skills/src/registry.rs
+++ b/crates/ironclaw_skills/src/registry.rs
@@ -14,8 +14,9 @@
//! Uses async I/O throughout to avoid blocking the tokio runtime.
use std::collections::HashSet;
-use std::path::{Path, PathBuf};
+use std::path::{Component, Path, PathBuf};
+use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::gating;
@@ -202,6 +203,56 @@ pub struct SkillRegistry {
max_scan_depth: usize,
}
+/// Additional bundle file to materialize alongside `SKILL.md` during install.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct InstallFile {
+ pub relative_path: PathBuf,
+ pub contents: Vec,
+}
+
+/// Persisted metadata about how a skill bundle was installed.
+#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
+pub struct InstalledSkillMetadata {
+ #[serde(default)]
+ pub source_url: Option,
+ #[serde(default)]
+ pub source_subdir: Option,
+}
+
+const INSTALL_METADATA_FILE: &str = ".ironclaw-install.json";
+
+fn validate_install_relative_path(path: &Path) -> Result {
+ if path.as_os_str().is_empty() || path.is_absolute() {
+ return Err(SkillRegistryError::WriteError {
+ path: path.display().to_string(),
+ reason: "install bundle path must be a non-empty relative path".to_string(),
+ });
+ }
+
+ let mut normalized = PathBuf::new();
+ for component in path.components() {
+ match component {
+ Component::Normal(part) => normalized.push(part),
+ Component::CurDir => {}
+ Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
+ return Err(SkillRegistryError::WriteError {
+ path: path.display().to_string(),
+ reason: "install bundle path may not escape the skill directory".to_string(),
+ });
+ }
+ }
+ }
+
+ if normalized.as_os_str().is_empty() {
+ return Err(SkillRegistryError::WriteError {
+ path: path.display().to_string(),
+ reason: "install bundle path normalized to empty".to_string(),
+ });
+ }
+
+ Ok(normalized)
+}
+
impl SkillRegistry {
/// Create a new skill registry.
pub fn new(user_dir: PathBuf) -> Self {
@@ -573,11 +624,23 @@ impl SkillRegistry {
/// This is a static method so it doesn't borrow `&self`, allowing callers
/// to drop their registry lock before awaiting.
pub async fn prepare_install_to_disk(
- user_dir: &Path,
+ install_dir: &Path,
skill_name: &str,
normalized_content: &str,
) -> Result<(String, LoadedSkill), SkillRegistryError> {
- let skill_dir = user_dir.join(skill_name);
+ Self::prepare_install_bundle_to_disk(install_dir, skill_name, normalized_content, &[], None)
+ .await
+ }
+
+ /// Perform the disk I/O and loading for a skill bundle install.
+ pub async fn prepare_install_bundle_to_disk(
+ install_dir: &Path,
+ skill_name: &str,
+ normalized_content: &str,
+ extra_files: &[InstallFile],
+ install_metadata: Option<&InstalledSkillMetadata>,
+ ) -> Result<(String, LoadedSkill), SkillRegistryError> {
+ let skill_dir = install_dir.join(skill_name);
tokio::fs::create_dir_all(&skill_dir).await.map_err(|e| {
SkillRegistryError::WriteError {
path: skill_dir.display().to_string(),
@@ -593,8 +656,43 @@ impl SkillRegistry {
reason: e.to_string(),
})?;
+ for file in extra_files {
+ let relative_path = validate_install_relative_path(&file.relative_path)?;
+ let absolute_path = skill_dir.join(&relative_path);
+ if let Some(parent) = absolute_path.parent() {
+ tokio::fs::create_dir_all(parent).await.map_err(|e| {
+ SkillRegistryError::WriteError {
+ path: parent.display().to_string(),
+ reason: e.to_string(),
+ }
+ })?;
+ }
+ tokio::fs::write(&absolute_path, &file.contents)
+ .await
+ .map_err(|e| SkillRegistryError::WriteError {
+ path: absolute_path.display().to_string(),
+ reason: e.to_string(),
+ })?;
+ }
+
+ if let Some(metadata) = install_metadata {
+ let meta_path = skill_dir.join(INSTALL_METADATA_FILE);
+ let meta_json = serde_json::to_vec_pretty(metadata).map_err(|e| {
+ SkillRegistryError::WriteError {
+ path: meta_path.display().to_string(),
+ reason: format!("failed to serialize install metadata: {e}"),
+ }
+ })?;
+ tokio::fs::write(&meta_path, meta_json).await.map_err(|e| {
+ SkillRegistryError::WriteError {
+ path: meta_path.display().to_string(),
+ reason: e.to_string(),
+ }
+ })?;
+ }
+
// Load by re-reading from disk (validates round-trip)
- let source = SkillSource::User(skill_dir);
+ let source = SkillSource::Installed(skill_dir);
load_and_validate_skill(&skill_path, SkillTrust::Installed, source).await
}
@@ -670,16 +768,13 @@ impl SkillRegistry {
///
/// Call after `validate_remove` and before `commit_remove`.
pub async fn delete_skill_files(path: &Path) -> Result<(), SkillRegistryError> {
- let skill_md = path.join("SKILL.md");
- if tokio::fs::try_exists(&skill_md).await.unwrap_or(false) {
- tokio::fs::remove_file(&skill_md).await.map_err(|e| {
- SkillRegistryError::WriteError {
- path: skill_md.display().to_string(),
+ if tokio::fs::try_exists(path).await.unwrap_or(false) {
+ tokio::fs::remove_dir_all(path)
+ .await
+ .map_err(|e| SkillRegistryError::WriteError {
+ path: path.display().to_string(),
reason: e.to_string(),
- }
- })?;
- // Remove the directory if empty
- let _ = tokio::fs::remove_dir(path).await;
+ })?;
}
Ok(())
}
@@ -734,6 +829,13 @@ impl SkillRegistry {
pub fn install_target_dir(&self) -> &Path {
self.installed_dir.as_deref().unwrap_or(&self.user_dir)
}
+
+ /// Load persisted install metadata for a skill directory, if present.
+ pub async fn read_install_metadata(path: &Path) -> Option {
+ let meta_path = path.join(INSTALL_METADATA_FILE);
+ let bytes = tokio::fs::read(&meta_path).await.ok()?;
+ serde_json::from_slice(&bytes).ok()
+ }
}
/// Load and validate a single SKILL.md file from disk.
@@ -1110,6 +1212,74 @@ mod tests {
assert!(skill_path.exists());
}
+ #[tokio::test]
+ async fn test_prepare_install_bundle_to_disk_writes_extra_files_and_metadata() {
+ let dir = tempfile::tempdir().unwrap();
+ let content =
+ "---\nname: bundle-install\ndescription: Installed skill\n---\n\nInstalled prompt.\n";
+ let extra_files = vec![
+ InstallFile {
+ relative_path: PathBuf::from("requirements.txt"),
+ contents: b"requests>=2.32.5\n".to_vec(),
+ },
+ InstallFile {
+ relative_path: PathBuf::from("scripts/run.py"),
+ contents: b"print('ok')\n".to_vec(),
+ },
+ ];
+ let metadata = InstalledSkillMetadata {
+ source_url: Some("https://github.com/Pika-Labs/Pika-Skills".to_string()),
+ source_subdir: Some("pikastream-video-meeting".to_string()),
+ };
+
+ let (name, loaded) = SkillRegistry::prepare_install_bundle_to_disk(
+ dir.path(),
+ "bundle-install",
+ content,
+ &extra_files,
+ Some(&metadata),
+ )
+ .await
+ .unwrap();
+
+ assert_eq!(name, "bundle-install");
+ assert_eq!(loaded.manifest.name, "bundle-install");
+ assert!(matches!(loaded.source, SkillSource::Installed(_)));
+ assert!(dir.path().join("bundle-install/requirements.txt").exists());
+ assert!(dir.path().join("bundle-install/scripts/run.py").exists());
+
+ let stored = SkillRegistry::read_install_metadata(&dir.path().join("bundle-install"))
+ .await
+ .expect("install metadata");
+ assert_eq!(stored, metadata);
+ }
+
+ #[tokio::test]
+ async fn test_prepare_install_bundle_to_disk_rejects_path_escape() {
+ let dir = tempfile::tempdir().unwrap();
+ let content = "---\nname: bundle-install\n---\n\nInstalled prompt.\n";
+ let extra_files = vec![InstallFile {
+ relative_path: PathBuf::from("../escape.sh"),
+ contents: b"echo no\n".to_vec(),
+ }];
+
+ let err = SkillRegistry::prepare_install_bundle_to_disk(
+ dir.path(),
+ "bundle-install",
+ content,
+ &extra_files,
+ None,
+ )
+ .await
+ .unwrap_err();
+
+ assert!(
+ err.to_string()
+ .contains("may not escape the skill directory"),
+ "{err}"
+ );
+ }
+
#[test]
fn test_resolve_install_content_prefers_requested_slug_for_invalid_name() {
let content = "---\nname: Mortgage Calculator\ndescription: Installed skill\n---\n\nInstalled prompt.\n";
diff --git a/crates/ironclaw_skills/src/v2.rs b/crates/ironclaw_skills/src/v2.rs
index 27f0f39bbf4..0f9226ff94e 100644
--- a/crates/ironclaw_skills/src/v2.rs
+++ b/crates/ironclaw_skills/src/v2.rs
@@ -183,6 +183,12 @@ pub struct V2SkillMetadata {
/// SHA-256 hash of the prompt content.
#[serde(default)]
pub content_hash: String,
+ /// Installed bundle path on disk when the skill came from a filesystem bundle.
+ #[serde(default)]
+ pub bundle_path: Option,
+ /// Original source URL when the install came from a remote bundle.
+ #[serde(default)]
+ pub source_url: Option,
}
fn default_version() -> u32 {
@@ -276,6 +282,8 @@ mod tests {
repaired_at: None,
}],
content_hash: "sha256:abc".to_string(),
+ bundle_path: Some("/tmp/skills/test-skill".to_string()),
+ source_url: Some("https://github.com/example/test-skill".to_string()),
};
let json = serde_json::to_string(&meta).expect("serialize");
@@ -285,6 +293,14 @@ mod tests {
assert_eq!(parsed.version, 3);
assert_eq!(parsed.source, V2SkillSource::Extracted);
assert_eq!(parsed.code_snippets.len(), 1);
+ assert_eq!(
+ parsed.bundle_path.as_deref(),
+ Some("/tmp/skills/test-skill")
+ );
+ assert_eq!(
+ parsed.source_url.as_deref(),
+ Some("https://github.com/example/test-skill")
+ );
assert_eq!(parsed.metrics.success_count, 4);
assert_eq!(parsed.parent_version, Some(2));
assert_eq!(parsed.revisions.len(), 1);
@@ -303,5 +319,7 @@ mod tests {
assert!((parsed.metrics.confidence() - 1.0).abs() < f64::EPSILON);
assert!(parsed.revisions.is_empty());
assert!(parsed.repairs.is_empty());
+ assert_eq!(parsed.bundle_path, None);
+ assert_eq!(parsed.source_url, None);
}
}
diff --git a/src/agent/agent_loop.rs b/src/agent/agent_loop.rs
index 6944fa179e0..fddeccc8456 100644
--- a/src/agent/agent_loop.rs
+++ b/src/agent/agent_loop.rs
@@ -40,6 +40,8 @@ use ironclaw_skills::SkillRegistry;
/// `Done` after a pause (e.g. while awaiting tool approval) is incorrect because
/// the thread is not in a terminal state, and would also trip the web UI's
/// missing-response safety net (see #2079).
+pub(crate) const BRIDGE_PENDING_SENTINEL: &str = "\u{0}__bridge_pending__";
+
#[derive(Debug)]
pub(crate) enum HandleOutcome {
/// Shutdown signal (e.g. `/quit`). Run loop should break.
@@ -60,6 +62,7 @@ impl HandleOutcome {
fn from_legacy(opt: Option) -> Self {
match opt {
None => HandleOutcome::Shutdown,
+ Some(s) if s == BRIDGE_PENDING_SENTINEL => HandleOutcome::Pending,
Some(s) if s.is_empty() => HandleOutcome::NoResponse,
Some(s) => HandleOutcome::Respond(s),
}
diff --git a/src/agent/attachments.rs b/src/agent/attachments.rs
index cb3e9b56f4b..5c85e6a2a51 100644
--- a/src/agent/attachments.rs
+++ b/src/agent/attachments.rs
@@ -71,6 +71,11 @@ fn escape_xml_text(s: &str) -> String {
fn format_attachment(index: usize, att: &IncomingAttachment) -> String {
let filename = escape_xml_attr(att.filename.as_deref().unwrap_or("unknown"));
let mime = escape_xml_attr(&att.mime_type);
+ let project_path_attr = att
+ .local_path
+ .as_deref()
+ .map(|path| format!(" project_path=\"{}\"", escape_xml_attr(path)))
+ .unwrap_or_default();
match &att.kind {
AttachmentKind::Audio => {
@@ -78,14 +83,21 @@ fn format_attachment(index: usize, att: &IncomingAttachment) -> String {
.duration_secs
.map(|d| format!(" duration=\"{d}s\""))
.unwrap_or_default();
+ let size_attr = att
+ .size_bytes
+ .map(|s| format!(" size=\"{}\"", format_size(s)))
+ .unwrap_or_default();
- let body = match &att.extracted_text {
- Some(text) => format!("Transcript: {}", escape_xml_text(text)),
- None => "Audio transcript unavailable.".to_string(),
- };
+ let body = format_attachment_body(
+ att.local_path.as_deref(),
+ match &att.extracted_text {
+ Some(text) => format!("Transcript: {}", escape_xml_text(text)),
+ None => "Audio transcript unavailable.".to_string(),
+ },
+ );
format!(
- "\n\
+ "\n\
{body}\n\
"
)
@@ -96,29 +108,47 @@ fn format_attachment(index: usize, att: &IncomingAttachment) -> String {
.map(|s| format!(" size=\"{}\"", format_size(s)))
.unwrap_or_default();
- let body = if att.data.is_empty() {
- "[Image attached — visual content not available in this conversation.]"
- } else {
+ // Pick the right prompt for the agent based on whether the
+ // image bytes reached the model. Engine v2 persists the file to
+ // disk but leaves `data` populated so `augment_with_attachments`
+ // can emit a multimodal `image_parts` entry — that's the path
+ // that actually sends the image to the LLM. An empty `data`
+ // with a `local_path` set can only happen if a downstream
+ // caller cleared the buffer (or if the channel elided it); in
+ // that case the model doesn't see the pixels and must go
+ // through the project file path instead.
+ let body = if !att.data.is_empty() {
"[Image attached — you can already see this image directly in the conversation. Do NOT use image_analyze or try to find this file on disk — it exists only in memory. Analyze it using your vision capabilities.]"
+ } else if att.local_path.is_some() {
+ "[Image attached — the raw bytes are not in this turn's multimodal context, but the file has been persisted at the project file path above. Reference that path when you need the image.]"
+ } else {
+ "[Image attached — visual content not available in this conversation.]"
};
+ let body = format_attachment_body(att.local_path.as_deref(), body.to_string());
format!(
- "\n\
+ "\n\
{body}\n\
"
)
}
AttachmentKind::Document => {
let body: String = match &att.extracted_text {
- Some(text) => escape_xml_text(text),
+ Some(text) => {
+ format_attachment_body(att.local_path.as_deref(), escape_xml_text(text))
+ }
None => {
let size_info = att
.size_bytes
.map(|s| format!(" size=\"{}\"", format_size(s)))
.unwrap_or_default();
+ let body = format_attachment_body(
+ att.local_path.as_deref(),
+ "[Document attached — text extraction unavailable]".to_string(),
+ );
return format!(
- "\n\
- [Document attached — text extraction unavailable]\n\
+ "\n\
+ {body}\n\
"
);
}
@@ -130,7 +160,7 @@ fn format_attachment(index: usize, att: &IncomingAttachment) -> String {
.unwrap_or_default();
format!(
- "\n\
+ "\n\
{body}\n\
"
)
@@ -138,6 +168,17 @@ fn format_attachment(index: usize, att: &IncomingAttachment) -> String {
}
}
+fn format_attachment_body(local_path: Option<&str>, content: String) -> String {
+ match local_path {
+ Some(path) => format!(
+ "Saved to project file: {}\n{}",
+ escape_xml_text(path),
+ content
+ ),
+ None => content,
+ }
+}
+
fn format_size(bytes: u64) -> String {
if bytes < 1024 {
format!("{bytes}B")
@@ -161,6 +202,7 @@ mod tests {
size_bytes: None,
source_url: None,
storage_key: None,
+ local_path: None,
extracted_text: None,
data: vec![],
duration_secs: None,
@@ -176,14 +218,18 @@ mod tests {
fn audio_with_transcript() {
let mut att = make_attachment(AttachmentKind::Audio);
att.filename = Some("voice.ogg".to_string());
+ att.mime_type = "audio/ogg".to_string();
att.extracted_text = Some("Hello, can you help me?".to_string());
att.duration_secs = Some(5);
+ att.size_bytes = Some(2048);
let result = augment_with_attachments("hi", &[att]).unwrap();
assert!(result.text.starts_with("hi\n\n"));
assert!(result.text.contains("type=\"audio\""));
assert!(result.text.contains("filename=\"voice.ogg\""));
+ assert!(result.text.contains("mime=\"audio/ogg\""));
assert!(result.text.contains("duration=\"5s\""));
+ assert!(result.text.contains("size=\"2KB\""));
assert!(result.text.contains("Transcript: Hello, can you help me?"));
assert!(result.text.ends_with(""));
assert!(result.image_parts.is_empty());
@@ -242,6 +288,26 @@ mod tests {
}
}
+ #[test]
+ fn attachment_with_project_file_path_is_rendered() {
+ let mut att = make_attachment(AttachmentKind::Document);
+ att.filename = Some("brief.txt".to_string());
+ att.mime_type = "text/plain".to_string();
+ att.local_path = Some(".ironclaw/attachments/alice/project/2026-04-12/brief.txt".into());
+ att.extracted_text = Some("Hello from disk".to_string());
+
+ let result = augment_with_attachments("review", &[att]).unwrap();
+ assert!(
+ result.text.contains(
+ "project_path=\".ironclaw/attachments/alice/project/2026-04-12/brief.txt\""
+ )
+ );
+ assert!(result.text.contains(
+ "Saved to project file: .ironclaw/attachments/alice/project/2026-04-12/brief.txt"
+ ));
+ assert!(result.text.contains("Hello from disk"));
+ }
+
#[test]
fn image_url_includes_detail_auto() {
let mut att = make_attachment(AttachmentKind::Image);
diff --git a/src/agent/mod.rs b/src/agent/mod.rs
index 5f3d305e96f..c1e201a5c4f 100644
--- a/src/agent/mod.rs
+++ b/src/agent/mod.rs
@@ -34,6 +34,7 @@ pub mod undo;
pub(crate) use agent_loop::truncate_for_preview;
pub use agent_loop::{Agent, AgentDeps};
+pub(crate) use attachments::augment_with_attachments;
pub use compaction::{CompactionResult, ContextCompactor};
pub use context_monitor::{CompactionStrategy, ContextBreakdown, ContextMonitor};
pub(crate) use dispatcher::strip_suggestions;
diff --git a/src/agent/thread_ops.rs b/src/agent/thread_ops.rs
index 38177d4c01f..4c8c9c1deeb 100644
--- a/src/agent/thread_ops.rs
+++ b/src/agent/thread_ops.rs
@@ -538,8 +538,19 @@ impl Agent {
}
}
+ // Attachments can carry the only user-visible payload (for example,
+ // a files-only send with empty chat text), so validation and policy
+ // checks must run against the augmented content that will actually
+ // enter the turn rather than the raw text field alone.
+ let augmented =
+ crate::agent::attachments::augment_with_attachments(content, &message.attachments);
+ let (effective_content, image_parts) = match &augmented {
+ Some(result) => (result.text.as_str(), result.image_parts.clone()),
+ None => (content, Vec::new()),
+ };
+
// Safety validation for user input
- let validation = self.safety().validate_input(content);
+ let validation = self.safety().validate_input(effective_content);
if !validation.is_valid {
let details = validation
.errors
@@ -553,7 +564,7 @@ impl Agent {
)));
}
- let violations = self.safety().check_policy(content);
+ let violations = self.safety().check_policy(effective_content);
if violations
.iter()
.any(|rule| rule.action == ironclaw_safety::PolicyAction::Block)
@@ -564,7 +575,7 @@ impl Agent {
// Scan inbound messages for secrets (API keys, tokens).
// Catching them here prevents the LLM from echoing them back, which
// would trigger the outbound leak detector and create error loops.
- if let Some(warning) = self.safety().scan_inbound_for_secrets(content) {
+ if let Some(warning) = self.safety().scan_inbound_for_secrets(effective_content) {
tracing::warn!(
user = %message.user_id,
channel = %message.channel,
@@ -641,14 +652,6 @@ impl Agent {
);
}
- // Augment content with attachment context (transcripts, metadata, images)
- let augmented =
- crate::agent::attachments::augment_with_attachments(content, &message.attachments);
- let (effective_content, image_parts) = match &augmented {
- Some(result) => (result.text.as_str(), result.image_parts.clone()),
- None => (content, Vec::new()),
- };
-
// Start the turn and get messages
let (turn_messages, turn_number, turn_started_at) = {
let mut sess = session.lock().await;
@@ -3581,6 +3584,76 @@ mod tests {
)));
}
+ #[tokio::test]
+ async fn test_process_user_input_allows_attachment_only_message() {
+ use crate::agent::session::{Session, Thread};
+ use crate::channels::{AttachmentKind, IncomingAttachment, IncomingMessage};
+ use uuid::Uuid;
+
+ let (agent, _statuses) = make_thread_ops_test_agent().await;
+ let session_id = Uuid::new_v4();
+ let thread_id = Uuid::new_v4();
+ let thread = Thread::with_id(thread_id, session_id, Some("test"));
+
+ let mut sess = Session::new("test-user");
+ sess.threads.insert(thread_id, thread);
+ let session = Arc::new(TokioMutex::new(sess));
+
+ let message = IncomingMessage::new("test", "test-user", "").with_attachments(vec![
+ IncomingAttachment {
+ id: "att_1".to_string(),
+ kind: AttachmentKind::Document,
+ mime_type: "text/plain".to_string(),
+ filename: Some("files-only.txt".to_string()),
+ size_bytes: Some(41),
+ source_url: None,
+ storage_key: None,
+ local_path: None,
+ extracted_text: Some("Files-only regression attachment.".to_string()),
+ data: b"Files-only regression attachment.".to_vec(),
+ duration_secs: None,
+ },
+ ]);
+
+ let result = agent
+ .process_user_input(
+ &message,
+ agent.tenant_ctx("test-user").await,
+ Arc::clone(&session),
+ thread_id,
+ "",
+ )
+ .await
+ .expect("attachment-only message handled");
+
+ match result {
+ SubmissionResult::Response { content } => {
+ assert_eq!(content.to_ascii_lowercase(), "ok")
+ }
+ other => panic!("expected response result, got {other:?}"),
+ }
+
+ let sess = session.lock().await;
+ let thread = sess.threads.get(&thread_id).expect("thread exists");
+ let turn = thread.turns.last().expect("turn should be created");
+ assert!(
+ turn.user_input.contains(""),
+ "{}",
+ turn.user_input
+ );
+ assert!(
+ turn.user_input.contains("files-only.txt"),
+ "{}",
+ turn.user_input
+ );
+ assert!(
+ turn.user_input
+ .contains("Files-only regression attachment."),
+ "{}",
+ turn.user_input
+ );
+ }
+
#[tokio::test]
async fn test_switch_thread_emits_history_with_pending_approval() {
use crate::agent::session::{PendingApproval, Thread};
diff --git a/src/bridge/auth_manager.rs b/src/bridge/auth_manager.rs
index 6e1961c6c6f..31e7503ff4c 100644
--- a/src/bridge/auth_manager.rs
+++ b/src/bridge/auth_manager.rs
@@ -188,6 +188,37 @@ impl AuthManager {
}
}
+ async fn ensure_extension_ready_for_execution(
+ ext_mgr: &crate::extensions::ExtensionManager,
+ extension_name: &str,
+ user_id: &str,
+ ) -> Result {
+ match ext_mgr
+ .ensure_extension_ready(
+ extension_name,
+ user_id,
+ crate::extensions::EnsureReadyIntent::UseCapability,
+ )
+ .await
+ {
+ Err(ExtensionError::NotInstalled(_)) => {
+ tracing::debug!(
+ extension = %extension_name,
+ user_id = %user_id,
+ "Extension not installed for capability use; retrying via explicit activate path"
+ );
+ ext_mgr
+ .ensure_extension_ready(
+ extension_name,
+ user_id,
+ crate::extensions::EnsureReadyIntent::ExplicitActivate,
+ )
+ .await
+ }
+ other => other,
+ }
+ }
+
fn settings_store(&self) -> Option<&dyn crate::db::SettingsStore> {
self.tools
.as_ref()
@@ -338,21 +369,72 @@ impl AuthManager {
Err(_) => return ToolReadiness::Ready,
}
};
- match ext_mgr
- .ensure_extension_ready(
- &ext_name,
- user_id,
- crate::extensions::EnsureReadyIntent::UseCapability,
- )
+ self.readiness_from_extension_result(
+ &ext_name,
+ user_id,
+ ext_mgr
+ .ensure_extension_ready(
+ &ext_name,
+ user_id,
+ crate::extensions::EnsureReadyIntent::UseCapability,
+ )
+ .await,
+ )
+ .await
+ }
+
+ /// Prepare an extension-backed capability for immediate execution.
+ ///
+ /// Unlike [`check_tool_readiness`], this path may promote a latent
+ /// registry-backed extension into the installed state because the caller
+ /// is handling a concrete user-requested action, not merely listing or
+ /// filtering available actions.
+ pub async fn prepare_tool_for_execution(
+ &self,
+ tool_name: &str,
+ user_id: &str,
+ ) -> ToolReadiness {
+ let ext_mgr = match self.extension_manager.as_ref() {
+ Some(mgr) => mgr,
+ None => return ToolReadiness::Ready,
+ };
+
+ let ext_name = if let Some(tools) = self.tools.as_ref() {
+ if let Some(name) = tools.provider_extension_for_tool(tool_name).await {
+ name
+ } else {
+ match canonicalize_extension_name(tool_name) {
+ Ok(name) => name,
+ Err(_) => return ToolReadiness::Ready,
+ }
+ }
+ } else {
+ match canonicalize_extension_name(tool_name) {
+ Ok(name) => name,
+ Err(_) => return ToolReadiness::Ready,
+ }
+ };
+
+ let result = Self::ensure_extension_ready_for_execution(ext_mgr, &ext_name, user_id).await;
+
+ self.readiness_from_extension_result(&ext_name, user_id, result)
.await
- {
+ }
+
+ async fn readiness_from_extension_result(
+ &self,
+ ext_name: &str,
+ user_id: &str,
+ result: Result,
+ ) -> ToolReadiness {
+ match result {
Ok(crate::extensions::EnsureReadyOutcome::Ready { .. }) => ToolReadiness::Ready,
Ok(crate::extensions::EnsureReadyOutcome::NeedsAuth {
auth,
credential_name,
..
}) => {
- let credential_name = credential_name.unwrap_or_else(|| ext_name.clone());
+ let credential_name = credential_name.unwrap_or_else(|| ext_name.to_string());
let described = self
.describe_missing_credential(&credential_name, user_id)
.await;
@@ -446,52 +528,50 @@ impl AuthManager {
let ext_mgr = self.extension_manager.as_ref()?;
let latent = ext_mgr.latent_provider_action(action_name, user_id).await?;
- Some(
- match ext_mgr
- .ensure_extension_ready(
- &latent.provider_extension,
- user_id,
- crate::extensions::EnsureReadyIntent::UseCapability,
- )
- .await
- {
- Ok(crate::extensions::EnsureReadyOutcome::Ready { .. }) => {
- let available_actions = ext_mgr
- .provider_action_names(&latent.provider_extension)
- .await;
- if available_actions.contains(&latent.action_name) {
- Ok(LatentActionExecution::RetryRegisteredAction {
- resolved_action: latent.action_name,
- })
- } else {
- Ok(LatentActionExecution::ProviderReady {
- provider_extension: latent.provider_extension,
- available_actions,
- })
- }
- }
- Ok(crate::extensions::EnsureReadyOutcome::NeedsAuth {
- auth,
- credential_name,
- ..
- }) => Ok(LatentActionExecution::NeedsAuth {
- credential_name: CredentialName::from_trusted(
- credential_name.unwrap_or(latent.provider_extension),
- ),
- instructions: auth
- .instructions()
- .unwrap_or("Complete authentication to continue.")
- .to_string(),
- auth_url: crate::auth::oauth::sanitize_auth_url(auth.auth_url()),
- }),
- Ok(crate::extensions::EnsureReadyOutcome::NeedsSetup { instructions, .. }) => {
- Ok(LatentActionExecution::NeedsSetup {
- message: instructions,
+ let readiness = Self::ensure_extension_ready_for_execution(
+ ext_mgr,
+ &latent.provider_extension,
+ user_id,
+ )
+ .await;
+
+ Some(match readiness {
+ Ok(crate::extensions::EnsureReadyOutcome::Ready { .. }) => {
+ let available_actions = ext_mgr
+ .provider_action_names(&latent.provider_extension)
+ .await;
+ if available_actions.contains(&latent.action_name) {
+ Ok(LatentActionExecution::RetryRegisteredAction {
+ resolved_action: latent.action_name,
+ })
+ } else {
+ Ok(LatentActionExecution::ProviderReady {
+ provider_extension: latent.provider_extension,
+ available_actions,
})
}
- Err(err) => Err(err),
- },
- )
+ }
+ Ok(crate::extensions::EnsureReadyOutcome::NeedsAuth {
+ auth,
+ credential_name,
+ ..
+ }) => Ok(LatentActionExecution::NeedsAuth {
+ credential_name: CredentialName::from_trusted(
+ credential_name.unwrap_or(latent.provider_extension),
+ ),
+ instructions: auth
+ .instructions()
+ .unwrap_or("Complete authentication to continue.")
+ .to_string(),
+ auth_url: crate::auth::oauth::sanitize_auth_url(auth.auth_url()),
+ }),
+ Ok(crate::extensions::EnsureReadyOutcome::NeedsSetup { instructions, .. }) => {
+ Ok(LatentActionExecution::NeedsSetup {
+ message: instructions,
+ })
+ }
+ Err(err) => Err(err),
+ })
}
async fn describe_missing_credential(
diff --git a/src/bridge/effect_adapter.rs b/src/bridge/effect_adapter.rs
index 1ddf276ee6f..6c82c3e9558 100644
--- a/src/bridge/effect_adapter.rs
+++ b/src/bridge/effect_adapter.rs
@@ -17,8 +17,9 @@ use tracing::debug;
use ironclaw_engine::{
ActionDef, ActionResult, CapabilityLease, CapabilityRegistry, EffectExecutor, EngineError,
- MountError, ThreadExecutionContext, WorkspaceMounts,
+ MountError, Store, ThreadExecutionContext, WorkspaceMounts,
};
+use ironclaw_skills::SkillRegistry;
use crate::auth::oauth::sanitize_auth_url;
use crate::bridge::auth_manager::{AuthCheckResult, AuthManager};
@@ -58,6 +59,10 @@ pub struct EffectBridgeAdapter {
/// calls bypass the recorder entirely — recorded traces end up with zero
/// `http_exchanges` and replay can't substitute responses.
http_interceptor: RwLock