@openchambery/web 1.19.13 → 1.19.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/dist/assets/{AgentAvatar-mbvX7YlW.js → AgentAvatar-m1jNq4bX.js} +1 -1
  2. package/dist/assets/AssistantView-Dncbu_Z2.js +2 -0
  3. package/dist/assets/{DiagramView-BdI4PT2H.js → DiagramView-CovEl1BY.js} +1 -1
  4. package/dist/assets/{FadeInOnReveal-moBbL8Ll.js → FadeInOnReveal-UGwClbL_.js} +1 -1
  5. package/dist/assets/Icon-D1BcR8Tz.js +1 -0
  6. package/dist/assets/{MarkdownRenderer-CLQ4Z8Z0.js → MarkdownRenderer-CZqok-Cf.js} +2 -2
  7. package/dist/assets/{MarkdownRendererImpl-2ewbRZE2.js → MarkdownRendererImpl-CCfHY-86.js} +3 -3
  8. package/dist/assets/{MarkstreamRendererImpl-DXewO7S_.js → MarkstreamRendererImpl-y6QgIUZA.js} +2 -2
  9. package/dist/assets/MobileDetailNavigation-Ca-d5eZ9.js +1 -0
  10. package/dist/assets/MobileShareBridge-B4S7BaUj.js +4 -0
  11. package/dist/assets/MobileSurface-xL5Jby4U.js +2 -0
  12. package/dist/assets/{MultiRunWindow-C8ukIdLA.js → MultiRunWindow-BeHa-CG6.js} +1 -1
  13. package/dist/assets/{OnboardingScreen-j0iCyH7f.js → OnboardingScreen-bIWeMLVx.js} +1 -1
  14. package/dist/assets/{OverlayScrollbar-CDC5B3RH.js → OverlayScrollbar-Bahg_P3r.js} +1 -1
  15. package/dist/assets/SettingsView-Dn7odtQz.js +25 -0
  16. package/dist/assets/{SettingsWindow-CSePZ_V8.js → SettingsWindow-C0tWPS5f.js} +1 -1
  17. package/dist/assets/{TerminalView-BmSIrZ81.js → TerminalView-BnJgC7Qo.js} +1 -1
  18. package/dist/assets/{ToolOutputDialog-2BYaWLo1.js → ToolOutputDialog-D9S-X8hM.js} +1 -1
  19. package/dist/assets/{appThemeRegistry-Dk4gBaOu.js → appThemeRegistry-DBrvaJnq.js} +1 -1
  20. package/dist/assets/{button-xx1UOBA5.js → button-9R-WgVMl.js} +1 -1
  21. package/dist/assets/{clipboard-CUasgPiF.js → clipboard-GUUNlWIv.js} +1 -1
  22. package/dist/assets/{collapsible-CACHBRYZ.js → collapsible-Bbz9KiJf.js} +1 -1
  23. package/dist/assets/{context-menu-CYbm4wBZ.js → context-menu-CSyOhRz8.js} +1 -1
  24. package/dist/assets/{desktopHosts-DILvrAok.js → desktopHosts-B052a4UK.js} +1 -1
  25. package/dist/assets/{desktopRelayRestore-t8asCr3m.js → desktopRelayRestore-OjKNXr10.js} +1 -1
  26. package/dist/assets/{diagram-u0BC9uuY.js → diagram-NdsZih8P.js} +1 -1
  27. package/dist/assets/{dialog-BwQ3rY_x.js → dialog-DhMGbF_B.js} +1 -1
  28. package/dist/assets/{es-CRjOwvYM.js → es-BQb6I8gl.js} +2 -2
  29. package/dist/assets/fileMentionTouchSelection-Dc004xOh.js +4 -0
  30. package/dist/assets/{fileQueries-Bgly-E8f.js → fileQueries-B_s_B-fH.js} +1 -1
  31. package/dist/assets/{fr-D9taEN9L.js → fr-BjGouC_l.js} +2 -2
  32. package/dist/assets/{gitApi-DtPf-h-O.js → gitApi-Cm3JLQqh.js} +3 -3
  33. package/dist/assets/{input-CI730njs.js → input-Cd_1HyQv.js} +1 -1
  34. package/dist/assets/insertionBoundaries-CTWYFsbH.js +1 -0
  35. package/dist/assets/{ja-CYQ5aoWp.js → ja-Cw9zliYY.js} +2 -2
  36. package/dist/assets/{ko-BwASV0Q0.js → ko-Dyz0Dk9b.js} +2 -2
  37. package/dist/assets/main-CSqu3MB4.js +169 -0
  38. package/dist/assets/{main-Ci3JmtpW.js → main-DwMprEyr.js} +2 -2
  39. package/dist/assets/{markdownHeightCache-DEjGKLv8.js → markdownHeightCache-BL8CUQ0M.js} +1 -1
  40. package/dist/assets/miniChat-C8bzY_Ye.js +2 -0
  41. package/dist/assets/mobile-Oy-SRKkr.js +2 -0
  42. package/dist/assets/{multirun-CmaTeZON.js → multirun-ClcuaiKU.js} +1 -1
  43. package/dist/assets/{persistence-YbQKzHUQ.js → persistence-DwRBC4zl.js} +2 -2
  44. package/dist/assets/{pl-DUI1vPil.js → pl-C7EY-dTH.js} +1 -1
  45. package/dist/assets/platform-DQ5_LNKO.js +2 -0
  46. package/dist/assets/{projectMeta-cw9nrwpr.js → projectMeta-F93wE2Ye.js} +2 -2
  47. package/dist/assets/{pt-BR-CZQFJllP.js → pt-BR-B1uHpjFW.js} +2 -2
  48. package/dist/assets/renderElectronMiniChatApp-rOOhMVlQ.js +2 -0
  49. package/dist/assets/renderMobileApp-BP_hGwHP.js +28 -0
  50. package/dist/assets/{runtimeConfig-DI8l0VyZ.js → runtimeConfig-iJ2mTBVN.js} +1 -1
  51. package/dist/assets/runtimeEndpointReset-ISnhAS3q.js +147 -0
  52. package/dist/assets/sessionChangeSummary-BAzU2J1H.js +2 -0
  53. package/dist/assets/sessionLookup-DQQ9-DuP.js +1 -0
  54. package/dist/assets/{sortable-tabs-strip-Dom6Sthu.js → sortable-tabs-strip-dYW6SH62.js} +1 -1
  55. package/dist/assets/src-DYeppLfP.css +2 -0
  56. package/dist/assets/{store-CSnH_QXM.js → store-ClnJdJSj.js} +3 -3
  57. package/dist/assets/{terminalApi-noa4Cdbh.js → terminalApi-CfgOfnZk.js} +1 -1
  58. package/dist/assets/{textarea-BfNtUdJE.js → textarea-B9CmN13Z.js} +1 -1
  59. package/dist/assets/{tooltip-DPxumadF.js → tooltip-DSjr26A_.js} +1 -1
  60. package/dist/assets/{uk-C1FYZlmA.js → uk-DgG8v2md.js} +2 -2
  61. package/dist/assets/{url-CVhsI9kR.js → url-CMQwHq4k.js} +1 -1
  62. package/dist/assets/{useAppFontEffects-BDFUND4v.js → useAppFontEffects--IcU7jyv.js} +43 -44
  63. package/dist/assets/useDesktopWindowControlsLayout-BqJ5R_T2.js +1 -0
  64. package/dist/assets/{useEffectiveDirectory-e9HjIqGz.js → useEffectiveDirectory-ChWXV8xM.js} +1 -1
  65. package/dist/assets/{useFontPreferences-CB-cGo3I.js → useFontPreferences-DCaY8NbU.js} +1 -1
  66. package/dist/assets/useMobileNavigationStore-DQkgxuzg.js +413 -0
  67. package/dist/assets/useSessionAutoCleanup-DWWAvQQ0.js +2 -0
  68. package/dist/assets/{useWorkerHighlightedLines-DTGE0CDs.js → useWorkerHighlightedLines-D2oQszsk.js} +1 -1
  69. package/dist/assets/{utils-BHTVlVov.js → utils-2ZK8yboQ.js} +1 -1
  70. package/dist/assets/{vendor-reactuses-core-DDoDDyM9.js → vendor-reactuses-core-BnW6GxJt.js} +1 -1
  71. package/dist/assets/{zh-CN-C96qP1ip.js → zh-CN-C6OFKjeT.js} +2 -2
  72. package/dist/assets/{zh-TW-BsRk1Rbb.js → zh-TW-BVP99aMr.js} +2 -2
  73. package/dist/index.html +8 -8
  74. package/dist/mini-chat.html +8 -8
  75. package/dist/mobile.html +8 -8
  76. package/package.json +1 -1
  77. package/server/index.js +1 -0
  78. package/server/lib/assistants/DOCUMENTATION.md +93 -16
  79. package/server/lib/assistants/assign.js +117 -12
  80. package/server/lib/assistants/assign.test.js +79 -0
  81. package/server/lib/assistants/bubbles.js +1 -1
  82. package/server/lib/assistants/contact-store.js +496 -11
  83. package/server/lib/assistants/contact-store.test.js +323 -7
  84. package/server/lib/assistants/contact-tools.js +303 -52
  85. package/server/lib/assistants/contact-tools.test.js +239 -7
  86. package/server/lib/assistants/contracts.js +31 -0
  87. package/server/lib/assistants/harness.js +178 -50
  88. package/server/lib/assistants/harness.test.js +428 -3
  89. package/server/lib/assistants/routes.js +3 -0
  90. package/server/lib/assistants/service.js +586 -43
  91. package/server/lib/assistants/service.test.js +696 -7
  92. package/server/lib/llm/DOCUMENTATION.md +2 -0
  93. package/server/lib/llm/completions.js +4 -0
  94. package/server/lib/llm/completions.test.js +3 -0
  95. package/server/lib/llm/generate.js +12 -5
  96. package/server/lib/llm/generate.test.js +40 -0
  97. package/server/lib/opencode/feature-routes-runtime.js +5 -0
  98. package/dist/assets/AssistantView-CKmIS1cS.js +0 -2
  99. package/dist/assets/Icon-CkzzhaLr.js +0 -1
  100. package/dist/assets/MobileDetailNavigation-BGaAyuBH.js +0 -1
  101. package/dist/assets/MobileShareBridge-BAZoyeIP.js +0 -4
  102. package/dist/assets/MobileSurface-BVb3xNWt.js +0 -2
  103. package/dist/assets/SettingsView-CkQ61pbr.js +0 -25
  104. package/dist/assets/insertionBoundaries-BMhzPFft.js +0 -4
  105. package/dist/assets/main-BjeOhdcG.js +0 -169
  106. package/dist/assets/miniChat-BYrjYEXJ.js +0 -2
  107. package/dist/assets/mobile-BVMNnPK2.js +0 -2
  108. package/dist/assets/platform-DzsGqlGW.js +0 -2
  109. package/dist/assets/renderElectronMiniChatApp-COwia8Cs.js +0 -2
  110. package/dist/assets/renderMobileApp-CXwwpY1t.js +0 -28
  111. package/dist/assets/runtimeEndpointReset-BkAQXmXJ.js +0 -147
  112. package/dist/assets/sessionChangeSummary-DQDZ5HW-.js +0 -1
  113. package/dist/assets/sessionLookup-Imc995rW.js +0 -1
  114. package/dist/assets/src-AA-5nXsF.css +0 -2
  115. package/dist/assets/useDesktopWindowControlsLayout-C5hZD1co.js +0 -1
  116. package/dist/assets/useMobileNavigationStore-CYbDxRTM.js +0 -413
  117. package/dist/assets/useSessionAutoCleanup-BGy9_AhN.js +0 -2
@@ -21,9 +21,15 @@ import {
21
21
  NEW_CONVERSATION_CONFIRM_BUBBLE,
22
22
  NEW_CONVERSATION_TOOL_NAME,
23
23
  parseContactToolCalls,
24
+ READ_SESSION_TOOL_NAME,
24
25
  SCHEDULE_TASK_TOOL_NAME,
26
+ STOP_SESSION_TOOL_NAME,
27
+ STEER_SESSION_TOOL_NAME,
28
+ ARCHIVE_SESSION_TOOL_NAME,
29
+ DELETE_SESSION_TOOL_NAME,
25
30
  stripContactToolFences,
26
31
  UPDATE_DEFAULT_PROMPT_TOOL_NAME,
32
+ WATCH_SESSION_TOOL_NAME,
27
33
  } from './contact-tools.js';
28
34
  import {
29
35
  createPiCodingRuntime,
@@ -79,19 +85,72 @@ function createAssistantMessageEventStream() {
79
85
  };
80
86
  }
81
87
 
88
+ const CONTACT_LANGUAGE_NAMES = {
89
+ en: 'English',
90
+ fr: 'French',
91
+ 'zh-CN': 'Simplified Chinese',
92
+ 'zh-TW': 'Traditional Chinese',
93
+ uk: 'Ukrainian',
94
+ es: 'Spanish',
95
+ 'pt-BR': 'Brazilian Portuguese',
96
+ ko: 'Korean',
97
+ pl: 'Polish',
98
+ ja: 'Japanese',
99
+ };
100
+
101
+ const CONTACT_LANGUAGE_FALLBACK = "the user's interface language";
102
+
103
+ /**
104
+ * Map a UI locale code to a display name for the system prompt. Unknown or
105
+ * missing values fall back to a neutral phrase so `{{LANGUAGE}}` never leaks.
106
+ * Whitelisted only: arbitrary client text must not reach the system prompt.
107
+ */
108
+ export const resolveContactLanguage = (value) => {
109
+ if (typeof value !== 'string') return CONTACT_LANGUAGE_FALLBACK;
110
+ const code = value.trim();
111
+ if (!code) return CONTACT_LANGUAGE_FALLBACK;
112
+ if (CONTACT_LANGUAGE_NAMES[code]) return CONTACT_LANGUAGE_NAMES[code];
113
+ const match = Object.keys(CONTACT_LANGUAGE_NAMES).find((key) => key.toLowerCase() === code.toLowerCase());
114
+ return match ? CONTACT_LANGUAGE_NAMES[match] : CONTACT_LANGUAGE_FALLBACK;
115
+ };
116
+
82
117
  export const CONTACT_SYSTEM_PROMPT = [
83
118
  "You are OpenChamber's in-app assistant — a personable contact who can also work in the user's configured project directory.",
84
119
  'Reply in short chat bubbles: a few sentences each, separated by a blank line.',
85
120
  'Talk like a person in the user\'s language. One short spoken bubble at a time — never a wall of paragraphs.',
121
+ 'Always reply in {{LANGUAGE}} — the user\'s current interface language — even when the user writes in another language, unless they explicitly ask for a different one.',
86
122
  'Never write chain-of-thought, plans, tool names, or English narration of what you will do. The user never sees thinking.',
87
123
  'Do not expose tool traces, Activity, or editor actions.',
88
124
  'You have bash, read, write, and edit in the working directory. Use them for pwd, files, and shell. Never say you have no terminal or cannot read files. Ignore any temporary generator workspace in the environment.',
89
- 'Understand natural language in any language, including Chinese: 开新对话 / 清除记忆 means new_conversation (LLM memory only, chat history stays), 清空聊天记录 means clear_chat_history (delete transcript), 找项目 means list_projects, 现有对话 means list_sessions, 查看助手设定 / 默认提示词 means get_assistant_settings (pass to="Name" for another assistant), 改默认提示词 / 设置人设 / 改某助手的默认提示词 means update_default_prompt (persists that assistant\'s settings, later turns only; pass to="OpenCode 配置助手" to edit another contact without changing this one), 建助理 means create_assistant, 建会话 / 开个新会话 means assign_session, 排定时任务 means schedule_task, 给 X 说一声 means message_assistant, 发卡片 means emit a card via those tools — never ask the user to type /card or /dm.',
125
+ 'Understand natural language in any language, including Chinese: 开新对话 / 清除记忆 means new_conversation (LLM memory only, chat history stays), 清空聊天记录 means clear_chat_history (delete transcript), 找项目 means list_projects, 现有对话 means list_sessions, 查看助手设定 / 默认提示词 means get_assistant_settings (pass to="Name" for another assistant), 改默认提示词 / 设置人设 / 改某助手的默认提示词 means update_default_prompt (persists that assistant\'s settings, later turns only; pass to="OpenCode 配置助手" to edit another contact without changing this one), 建助理 means create_assistant, 建会话 / 开个新会话 / 继续会话 means assign_session, 监听会话 means watch_session, 停止/取消/打断会话 means stop_session, 插话 means steer_session, 归档会话 means archive_session, 删除会话 means delete_session, 排定时任务 means schedule_task, 给 X 说一声 means message_assistant, 发卡片 means emit a card via those tools — never ask the user to type /card or /dm.',
90
126
  'You receive the registered project catalog every turn. You CAN see those projects. Look them up yourself (fuzzy match label/name/path). Never say you cannot see the registered project list. Never ask for a raw filesystem path when a name matches. If the catalog is empty, tell the user to add a project in Settings.',
91
- 'File and shell work in this working directory uses read, write, edit, and bash. To open a separate Chat coding session: match the project, optionally list_sessions for existing chats, then assign_session with projectPath or sessionID. Optional worker model via providerID/modelID/model from the connected catalog — that does not change this contact. Current-turn user attachments are server-forwarded on assign. One successful assign_session ends the turn — do not call it again.',
92
- 'A reply without the tool call does nothing. Never say 已创建, created, scheduled, or opened unless the tool already returned success.',
127
+ 'File and shell work in this working directory uses read, write, edit, and bash. To open a separate Chat coding session: match the project, optionally list_sessions for existing chats, then assign_session with projectPath or sessionID. To continue an existing chat, assign_session with sessionID plus a coding prompt (omit model args to keep the prior worker model). To only listen without prompting when asked (监听/watch/monitor), watch_session with sessionID — a plain @session:id mention alone is not a watch request. To abort a running session, stop_session with sessionID. Optional worker model via providerID/modelID/model from the connected catalog — that does not change this contact. Current-turn user attachments are server-forwarded on assign. One successful assign_session or watch_session ends the turn — do not call it again.',
128
+ 'A reply without the tool call does nothing. Never say 已创建, created, scheduled, opened, watched, or stopped unless the tool already returned success.',
93
129
  ].join(' ');
94
130
 
131
+ // A workspace response must explicitly finish or ask for missing input. Plain
132
+ // progress prose is not a final answer and must not silently end the pi loop.
133
+ const WORKSPACE_RESPONSE_PROTOCOL = [
134
+ 'Workspace response protocol: continue using openchamber-tool calls until the requested work and verification are done.',
135
+ 'You may batch independent coding calls, but they execute sequentially in the order supplied. Wait for results before using their output. Send OpenChamber operation tools one at a time.',
136
+ 'Reading a skill only loads instructions; execute its applicable steps before completing the task.',
137
+ 'To finish, reply with only an openchamber-final JSON fence: ```openchamber-final\n{"status":"complete","text":"Your final answer in the user language"}\n```.',
138
+ 'Use status "blocked" with the missing information or real blocker when you cannot proceed. Questions and ordinary conversation also use this final format; they do not require a tool call.',
139
+ 'Do not use a final declaration for a progress update or promise to act later. Never invent tool results, User messages, or future assistant turns. Only supplied tool-result records are execution evidence.',
140
+ ].join('\n');
141
+
142
+ const parseFinalResponse = (text) => {
143
+ const match = text.trim().match(/^```openchamber-final\s*([\s\S]*?)```$/u);
144
+ if (!match) return null;
145
+ try {
146
+ const value = JSON.parse(match[1]);
147
+ if (!value || !['complete', 'blocked'].includes(value.status) || typeof value.text !== 'string' || !value.text.trim()) return null;
148
+ return { status: value.status, text: value.text.trim() };
149
+ } catch {
150
+ return null;
151
+ }
152
+ };
153
+
95
154
  const emptyUsage = () => ({
96
155
  input: 0,
97
156
  output: 0,
@@ -237,7 +296,9 @@ export function createContactStreamFn(createChatCompletion, {
237
296
  onBubbleDelta = null,
238
297
  globalEventHub = null,
239
298
  bubbleGapMs = 0,
299
+ signal = null,
240
300
  } = {}) {
301
+ let callSequence = 0;
241
302
  return (model, context) => {
242
303
  const stream = createAssistantMessageEventStream();
243
304
  const run = async () => {
@@ -257,10 +318,13 @@ export function createContactStreamFn(createChatCompletion, {
257
318
  }];
258
319
  }
259
320
  if (message.role === 'assistant') {
260
- const content = (message.content || [])
261
- .filter((part) => part?.type === 'text')
262
- .map((part) => part.text)
263
- .join('');
321
+ const content = (message.content || []).map((part) => {
322
+ if (part?.type === 'text') return part.text;
323
+ if (part?.type !== 'toolCall') return '';
324
+ return '```openchamber-tool\n' + JSON.stringify({
325
+ id: part.id, name: part.name, arguments: part.arguments,
326
+ }) + '\n```';
327
+ }).filter(Boolean).join('\n\n');
264
328
  return content ? [{ role: 'assistant', content }] : [];
265
329
  }
266
330
  if (message.role === 'toolResult') {
@@ -268,7 +332,6 @@ export function createContactStreamFn(createChatCompletion, {
268
332
  .filter((part) => part?.type === 'text')
269
333
  .map((part) => part.text)
270
334
  .join('');
271
- if (!content) return [];
272
335
  const toolName = typeof message.toolName === 'string' && message.toolName.trim()
273
336
  ? message.toolName.trim()
274
337
  : 'result';
@@ -278,7 +341,7 @@ export function createContactStreamFn(createChatCompletion, {
278
341
  const label = callId
279
342
  ? `OpenChamber tool result name=${toolName} call=${callId}`
280
343
  : `OpenChamber tool result name=${toolName}`;
281
- return [{ role: 'user', content: `${label}: ${content}` }];
344
+ return [{ role: 'user', content: `${label}: ${JSON.stringify({ isError: message.isError === true, content })}` }];
282
345
  }
283
346
  return [];
284
347
  });
@@ -290,40 +353,68 @@ export function createContactStreamFn(createChatCompletion, {
290
353
  if (lastUser && fallbackFiles.length > 0 && !Array.isArray(lastUser.parts)) {
291
354
  lastUser.parts = fallbackFiles;
292
355
  }
293
- const bubbleTracker = createContactBubbleDeltaTracker(onBubbleDelta);
294
- const result = await createChatCompletion({
295
- body: {
296
- model: `${model.provider === 'openchamber' ? '' : `${model.provider}/`}${model.id}`.replace(/^\//, '') || model.name,
297
- providerID: typeof model.name === 'string' && model.name.includes('/')
298
- ? model.name.split('/')[0]
299
- : undefined,
300
- modelID: model.id,
301
- messages,
302
- },
303
- onTextDelta: (delta) => {
304
- if (typeof delta !== 'string' || !delta) return;
305
- // Do not live-paint raw tokens: they are often chain-of-thought
306
- // and would appear then vanish after parse/persist.
307
- if (typeof onTextDelta === 'function') onTextDelta(delta);
308
- },
309
- globalEventHub,
310
- });
311
- const text = result?.completion?.choices?.[0]?.message?.content
312
- ?? result?.text
313
- ?? '';
314
356
  const allowedNames = (context.tools || []).map((tool) => tool?.name).filter(Boolean);
315
- const parsed = parseContactToolCalls(text, allowedNames);
316
- if (parsed.toolCall) {
317
- const toolCall = {
357
+ const requiresFinal = allowedNames.some(isPiCodingToolName);
358
+ let text;
359
+ let parsed;
360
+ let finalText;
361
+ let rejectedCodingCalls = [];
362
+ // Repair only the rejected response: no tools have executed from it.
363
+ // Real results stay in messages, so already completed work is not replayed.
364
+ for (let attempt = 0; ; attempt += 1) {
365
+ signal?.throwIfAborted();
366
+ const result = await createChatCompletion({
367
+ signal,
368
+ body: {
369
+ model: `${model.provider === 'openchamber' ? '' : `${model.provider}/`}${model.id}`.replace(/^\//, '') || model.name,
370
+ providerID: typeof model.name === 'string' && model.name.includes('/')
371
+ ? model.name.split('/')[0]
372
+ : undefined,
373
+ modelID: model.id,
374
+ messages: [...messages],
375
+ },
376
+ onTextDelta: (delta) => {
377
+ if (typeof delta === 'string' && delta && typeof onTextDelta === 'function') onTextDelta(delta);
378
+ },
379
+ globalEventHub,
380
+ });
381
+ signal?.throwIfAborted();
382
+ text = result?.completion?.choices?.[0]?.message?.content ?? result?.text ?? '';
383
+ const finalResponse = parseFinalResponse(text);
384
+ finalText = finalResponse?.text ?? null;
385
+ // A final answer is opaque text; quoted tool examples in it cannot run.
386
+ parsed = finalText === null ? parseContactToolCalls(text, allowedNames) : { toolCalls: [], chatText: finalText };
387
+ const mixedBatch = parsed.toolCalls.length > 1 && parsed.toolCalls.some((call) => !isPiCodingToolName(call.name));
388
+ const malformedFinal = finalText === null && /```openchamber-final/u.test(parsed.chatText);
389
+ const skippedRejectedWork = rejectedCodingCalls.length > 0 && finalResponse?.status === 'complete';
390
+ const forgedTranscript = finalText === null && /(?:^|\n)\s*(?:(?:User|Assistant|Tool):|OpenChamber tool result name=)/iu.test(parsed.chatText);
391
+ const protocolError = parsed.protocolError
392
+ || (forgedTranscript ? 'Do not simulate user messages, assistant turns, or tool results. Output only your next real tool call and wait for execution.' : '')
393
+ || (skippedRejectedWork ? 'Previously rejected coding calls have not executed. Issue the remaining tool calls, or report a blocked outcome; do not declare them complete.' : '')
394
+ || (mixedBatch ? 'Send OpenChamber operation tools one at a time, without other calls in the same response.' : '')
395
+ || (malformedFinal ? 'The final declaration must be a single valid openchamber-final JSON fence with status and text, without other content.' : '')
396
+ || (requiresFinal && parsed.toolCalls.length === 0 && finalText === null ? 'A workspace response needs an actual tool call or an explicit final declaration.' : '');
397
+ if (!protocolError) break;
398
+ if (attempt >= 2) throw new Error('Assistant response protocol failed after two corrections; the task was not confirmed complete.');
399
+ const rejected = parsed.toolCalls.filter((call) => isPiCodingToolName(call.name));
400
+ if (rejected.length > 0) rejectedCodingCalls = rejected.map(({ name, arguments: args }) => ({ name, arguments: args }));
401
+ // Never replay rejected prose: it may contain fabricated user/tool results.
402
+ messages.push(
403
+ { role: 'user', content: `OpenChamber response protocol correction: ${protocolError} No tool calls from the rejected response executed. ${rejectedCodingCalls.length ? `Unexecuted proposed calls (not results): ${JSON.stringify(rejectedCodingCalls)}.` : ''} Continue the remaining work using the supplied real results; do not repeat completed operations. ${requiresFinal ? WORKSPACE_RESPONSE_PROTOCOL : 'Emit the corrected openchamber-tool call.'}` },
404
+ );
405
+ }
406
+ const bubbleTracker = createContactBubbleDeltaTracker(onBubbleDelta);
407
+ if (parsed.toolCalls.length > 0) {
408
+ const toolCalls = parsed.toolCalls.map((call) => ({
318
409
  type: 'toolCall',
319
- id: `call_${Date.now().toString(36)}`,
320
- name: parsed.toolCall.name,
321
- arguments: parsed.toolCall.arguments,
322
- };
410
+ id: `call_${Date.now().toString(36)}_${++callSequence}`,
411
+ name: call.name,
412
+ arguments: call.arguments,
413
+ }));
323
414
  const spoken = isContactSpokenPreamble(parsed.chatText) ? parsed.chatText.trim() : '';
324
415
  const content = spoken
325
- ? [{ type: 'text', text: spoken }, toolCall]
326
- : [toolCall];
416
+ ? [{ type: 'text', text: spoken }, ...toolCalls]
417
+ : toolCalls;
327
418
  const partial = {
328
419
  ...assistantMessage(model, spoken, 'toolUse'),
329
420
  content,
@@ -335,15 +426,17 @@ export function createContactStreamFn(createChatCompletion, {
335
426
  stream.push({ type: 'text_delta', contentIndex: 0, delta: spoken, partial });
336
427
  stream.push({ type: 'text_end', contentIndex: 0, content: spoken, partial });
337
428
  }
338
- const toolIndex = spoken ? 1 : 0;
339
- stream.push({ type: 'toolcall_start', contentIndex: toolIndex, partial });
340
- stream.push({ type: 'toolcall_delta', contentIndex: toolIndex, delta: JSON.stringify(toolCall.arguments), partial });
341
- stream.push({ type: 'toolcall_end', contentIndex: toolIndex, toolCall, partial });
429
+ toolCalls.forEach((toolCall, index) => {
430
+ const toolIndex = index + (spoken ? 1 : 0);
431
+ stream.push({ type: 'toolcall_start', contentIndex: toolIndex, partial });
432
+ stream.push({ type: 'toolcall_delta', contentIndex: toolIndex, delta: JSON.stringify(toolCall.arguments), partial });
433
+ stream.push({ type: 'toolcall_end', contentIndex: toolIndex, toolCall, partial });
434
+ });
342
435
  stream.push({ type: 'done', reason: 'toolUse', message: partial });
343
436
  stream.end(partial);
344
437
  return;
345
438
  }
346
- const chatText = stripContactToolFences(text);
439
+ const chatText = finalText ?? parsed.chatText;
347
440
  const replyBubbles = splitContactBubbles(chatText);
348
441
  if (bubbleGapMs > 0 && replyBubbles.length > 1) {
349
442
  for (let index = 0; index < replyBubbles.length; index += 1) {
@@ -363,7 +456,7 @@ export function createContactStreamFn(createChatCompletion, {
363
456
  stream.push({ type: 'done', reason: 'stop', message: partial });
364
457
  stream.end(partial);
365
458
  } catch (error) {
366
- const failed = assistantMessage(model, '', 'error', error?.message || 'upstream_error');
459
+ const failed = assistantMessage(model, '', signal?.aborted ? 'aborted' : 'error', error?.message || 'upstream_error');
367
460
  stream.push({ type: 'error', reason: 'error', error: failed });
368
461
  stream.end(failed);
369
462
  }
@@ -427,6 +520,10 @@ const extractToolResultText = (messages) => {
427
520
 
428
521
  /** Tool confirms that are themselves the user-facing bubble (no card). */
429
522
  const TOOL_TEXT_BUBBLE_TOOLS = new Set([
523
+ STOP_SESSION_TOOL_NAME,
524
+ STEER_SESSION_TOOL_NAME,
525
+ ARCHIVE_SESSION_TOOL_NAME,
526
+ DELETE_SESSION_TOOL_NAME,
430
527
  NEW_CONVERSATION_TOOL_NAME,
431
528
  CLEAR_CHAT_HISTORY_TOOL_NAME,
432
529
  UPDATE_DEFAULT_PROMPT_TOOL_NAME,
@@ -436,11 +533,18 @@ const TOOL_TEXT_BUBBLE_TOOLS = new Set([
436
533
  /** Card / side-effect tools: never paint English toolText into the transcript. */
437
534
  const CARD_SIDE_EFFECT_TOOLS = new Set([
438
535
  ASSIGN_SESSION_TOOL_NAME,
536
+ WATCH_SESSION_TOOL_NAME,
537
+ STOP_SESSION_TOOL_NAME,
439
538
  CREATE_ASSISTANT_TOOL_NAME,
440
539
  SCHEDULE_TASK_TOOL_NAME,
441
540
  MESSAGE_ASSISTANT_TOOL_NAME,
442
541
  ]);
443
542
 
543
+ /** Lookup payloads for the model only — never paint quoted JSON as a user bubble. */
544
+ const MODEL_ONLY_LOOKUP_TOOLS = new Set([
545
+ READ_SESSION_TOOL_NAME,
546
+ ]);
547
+
444
548
  const extractContactTurnOutcome = (messages, retried) => {
445
549
  const list = Array.isArray(messages) ? messages : [];
446
550
  let start = 0;
@@ -473,6 +577,9 @@ const extractContactTurnOutcome = (messages, retried) => {
473
577
  // Spoken preamble only. Card is enough when present; never English toolText.
474
578
  // Post-tool assistant text (terminate:false tools) may still surface when no spoken.
475
579
  confirm = spoken ? '' : (assistant || '');
580
+ } else if (MODEL_ONLY_LOOKUP_TOOLS.has(lastToolName)) {
581
+ // Quoted transcript stays in the tool result. Keep spoken + post-read reply.
582
+ confirm = assistant || '';
476
583
  } else {
477
584
  // list_projects / list_sessions and other app tools keep toolText.
478
585
  confirm = toolText || assistant;
@@ -504,34 +611,45 @@ export async function runContactTurn({
504
611
  tools = [],
505
612
  projects = [],
506
613
  connectedModels = [],
614
+ modelPreferences = null,
615
+ modelCatalogAvailable = true,
507
616
  onTextDelta = null,
508
617
  onBubbleDelta = null,
509
618
  globalEventHub = null,
510
619
  skillHomeDir,
620
+ language = '',
511
621
  AgentImpl = Agent,
622
+ signal = null,
623
+ readOnly = false,
512
624
  }) {
513
625
  const providerID = assistant.providerID;
514
626
  const modelID = assistant.modelID;
515
627
  const cwd = resolveAssistantCwd(assistant);
516
628
  const contactTools = Array.isArray(tools)
517
- ? tools.filter((tool) => tool && typeof tool.name === 'string' && !isPiCodingToolName(tool.name))
629
+ ? tools.filter((tool) => tool && typeof tool.name === 'string' && !isPiCodingToolName(tool.name) && (!readOnly || ['list_projects', 'list_sessions', 'read_session', 'get_assistant_settings'].includes(tool.name)))
518
630
  : [];
519
631
  let runtime = null;
632
+ let removeAbortListener = null;
520
633
  try {
521
- if (cwd) runtime = await createPiCodingRuntime(cwd, { homeDir: skillHomeDir });
634
+ signal?.throwIfAborted();
635
+ if (cwd && !readOnly) runtime = await createPiCodingRuntime(cwd, { homeDir: skillHomeDir });
522
636
  const codingTools = Array.isArray(runtime?.tools) ? runtime.tools : [];
523
637
  const systemPrompt = [
524
638
  CONTACT_SYSTEM_PROMPT,
639
+ 'Conversation history is a bounded recent window, not guaranteed complete memory. OpenChamber card context records contain actual session identifiers and status; use them to resolve references to earlier work. Never claim to remember omitted details or repeat completed work because older context is missing. Ask for the missing detail when needed.',
640
+ readOnly ? 'This turn is a background result notification. Only read-only lookup tools are available. Never create or continue work; follow the latest user constraints and report the result briefly.' : '',
525
641
  formatPiCodingPrompt({
526
642
  cwd: runtime?.cwd,
527
643
  skillsPrompt: runtime?.skillsPrompt,
528
644
  tools: codingTools,
529
645
  }),
530
646
  formatRegisteredProjectsPrompt(projects),
531
- formatConnectedModelsPrompt(connectedModels),
647
+ formatConnectedModelsPrompt(connectedModels, { preferences: modelPreferences, catalogAvailable: modelCatalogAvailable }),
532
648
  formatContactToolsPrompt(contactTools),
533
649
  assistant.defaultPrompt,
534
- ].filter((value) => typeof value === 'string' && value.trim()).join('\n\n');
650
+ codingTools.length > 0 ? WORKSPACE_RESPONSE_PROTOCOL : '',
651
+ ].filter((value) => typeof value === 'string' && value.trim()).join('\n\n')
652
+ .replaceAll('{{LANGUAGE}}', resolveContactLanguage(language));
535
653
  const model = createContactModel(providerID, modelID);
536
654
  // streamFn receives the model; completions needs providerID/modelID.
537
655
  model.name = `${providerID}/${modelID}`;
@@ -554,6 +672,7 @@ export async function runContactTurn({
554
672
  : [];
555
673
 
556
674
  const agent = new AgentImpl({
675
+ toolExecution: 'sequential',
557
676
  initialState: {
558
677
  systemPrompt,
559
678
  model,
@@ -567,10 +686,16 @@ export async function runContactTurn({
567
686
  onBubbleDelta,
568
687
  globalEventHub,
569
688
  bubbleGapMs: 280,
689
+ signal,
570
690
  }),
571
691
  });
572
692
 
693
+ const onAbort = () => agent.abort();
694
+ signal?.addEventListener('abort', onAbort, { once: true });
695
+ removeAbortListener = () => signal?.removeEventListener('abort', onAbort);
696
+ signal?.throwIfAborted();
573
697
  await agent.prompt(userText);
698
+ signal?.throwIfAborted();
574
699
  if (agent.state.errorMessage) {
575
700
  const error = new Error(agent.state.errorMessage);
576
701
  error.code = 'upstream_error';
@@ -583,7 +708,9 @@ export async function runContactTurn({
583
708
  let retried = false;
584
709
  if (requested.length > 0 && !hasRequestedResult() && !contactTurnHasSuccessfulReset(agent.state.messages)) {
585
710
  retried = true;
711
+ signal?.throwIfAborted();
586
712
  await agent.prompt(MISSED_FENCE_RETRY_USER_TEXT);
713
+ signal?.throwIfAborted();
587
714
  if (agent.state.errorMessage) {
588
715
  const error = new Error(agent.state.errorMessage);
589
716
  error.code = 'upstream_error';
@@ -613,7 +740,7 @@ export async function runContactTurn({
613
740
  };
614
741
  }
615
742
  const outcome = extractContactTurnOutcome(agent.state.messages, retried);
616
- const text = stripContactToolFences(outcome.text);
743
+ const text = outcome.text;
617
744
  if (contactTurnHasSuccessfulReset(agent.state.messages)) {
618
745
  const historyCleared = contactTurnClearedChatHistory(agent.state.messages);
619
746
  const preferredConfirm = historyCleared
@@ -644,6 +771,7 @@ export async function runContactTurn({
644
771
  tools: [...agent.state.tools],
645
772
  };
646
773
  } finally {
774
+ removeAbortListener?.();
647
775
  try {
648
776
  await runtime?.close?.();
649
777
  } catch {