@openchambery/web 1.19.13-beta.4 → 1.19.13-beta.6

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 (113) hide show
  1. package/dist/assets/{AgentAvatar-mbvX7YlW.js → AgentAvatar-Bk-m9q6w.js} +1 -1
  2. package/dist/assets/AssistantView-7NzDL0Ft.js +2 -0
  3. package/dist/assets/{DiagramView-qyOlOyET.js → DiagramView-CAbNIt3t.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-DJORbaAh.js → MarkdownRenderer-BPYs05iK.js} +2 -2
  7. package/dist/assets/{MarkdownRendererImpl-Baw1_E_b.js → MarkdownRendererImpl-DMOxz3F2.js} +3 -3
  8. package/dist/assets/{MarkstreamRendererImpl-C3y7cRux.js → MarkstreamRendererImpl-igQBbbc_.js} +2 -2
  9. package/dist/assets/MobileDetailNavigation-BqXkyZTu.js +1 -0
  10. package/dist/assets/MobileShareBridge-XVaxj2S6.js +4 -0
  11. package/dist/assets/MobileSurface-6zONMJbZ.js +2 -0
  12. package/dist/assets/{MultiRunWindow-CnzvP7OW.js → MultiRunWindow-DK4IMgXD.js} +1 -1
  13. package/dist/assets/{OnboardingScreen-j0iCyH7f.js → OnboardingScreen-Ql6mH4XV.js} +1 -1
  14. package/dist/assets/{OverlayScrollbar-CDC5B3RH.js → OverlayScrollbar-Bahg_P3r.js} +1 -1
  15. package/dist/assets/{SettingsView-Gerbi-eT.js → SettingsView-DyV0qt_2.js} +8 -8
  16. package/dist/assets/{SettingsWindow-Czkt-v7v.js → SettingsWindow-D6BMtTR9.js} +1 -1
  17. package/dist/assets/{TerminalView-C3PKUnFu.js → TerminalView-D24PhNyX.js} +1 -1
  18. package/dist/assets/{ToolOutputDialog-Nv1AVjeU.js → ToolOutputDialog-uKzrTqq_.js} +1 -1
  19. package/dist/assets/{appThemeRegistry-eohB6OfO.js → appThemeRegistry-CvGPIE7-.js} +1 -1
  20. package/dist/assets/{button-xx1UOBA5.js → button-9R-WgVMl.js} +1 -1
  21. package/dist/assets/{clipboard-CUasgPiF.js → clipboard-CzXos-TI.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-gtFLyacK.js} +1 -1
  28. package/dist/assets/{es-CRjOwvYM.js → es-BtC17YQH.js} +1 -1
  29. package/dist/assets/fileMentionTouchSelection-DstJbYAw.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-DPohCCUO.js} +1 -1
  32. package/dist/assets/{gitApi-CrP1R39C.js → gitApi-ClM_B3fS.js} +3 -3
  33. package/dist/assets/{input-CI730njs.js → input-Cd_1HyQv.js} +1 -1
  34. package/dist/assets/insertionBoundaries-RilpwKtQ.js +1 -0
  35. package/dist/assets/{ja-CYQ5aoWp.js → ja-CflPxUNi.js} +1 -1
  36. package/dist/assets/{ko-BwASV0Q0.js → ko-CuSU80xd.js} +1 -1
  37. package/dist/assets/main-C2wx8T4t.js +169 -0
  38. package/dist/assets/{main-Bjgj-x5l.js → main-uBrYU2q0.js} +2 -2
  39. package/dist/assets/{markdownHeightCache-DEjGKLv8.js → markdownHeightCache-BL8CUQ0M.js} +1 -1
  40. package/dist/assets/miniChat-Bt2HBpWq.js +2 -0
  41. package/dist/assets/mobile-CpcbfIT1.js +2 -0
  42. package/dist/assets/{multirun-IUfGqjJa.js → multirun-FNfO4hCT.js} +1 -1
  43. package/dist/assets/{persistence-YbQKzHUQ.js → persistence-DwRBC4zl.js} +2 -2
  44. package/dist/assets/{pl-DUI1vPil.js → pl-eJvVbQ74.js} +1 -1
  45. package/dist/assets/platform-DQ5_LNKO.js +2 -0
  46. package/dist/assets/{projectMeta-B94Ha79_.js → projectMeta-Cfq8wYwb.js} +2 -2
  47. package/dist/assets/{pt-BR-CZQFJllP.js → pt-BR-DSejAksY.js} +1 -1
  48. package/dist/assets/{renderElectronMiniChatApp-DwWmaqPt.js → renderElectronMiniChatApp-CA5VKfGo.js} +2 -2
  49. package/dist/assets/renderMobileApp-De8AWiA6.js +28 -0
  50. package/dist/assets/{runtimeConfig-DhAd_g4W.js → runtimeConfig-BpsTIe2G.js} +1 -1
  51. package/dist/assets/runtimeEndpointReset-CjT4jAvZ.js +147 -0
  52. package/dist/assets/sessionChangeSummary-DKJxz5er.js +2 -0
  53. package/dist/assets/sessionLookup-CBT9qysS.js +1 -0
  54. package/dist/assets/{sortable-tabs-strip-Dom6Sthu.js → sortable-tabs-strip-BSzFYDAO.js} +1 -1
  55. package/dist/assets/src-BdO1hC7q.css +2 -0
  56. package/dist/assets/{store-CSnH_QXM.js → store-DHdMj3y4.js} +2 -2
  57. package/dist/assets/{terminalApi-noa4Cdbh.js → terminalApi-CfgOfnZk.js} +1 -1
  58. package/dist/assets/{textarea-BfNtUdJE.js → textarea-DE1UrdtK.js} +1 -1
  59. package/dist/assets/{tooltip-DPxumadF.js → tooltip-DSjr26A_.js} +1 -1
  60. package/dist/assets/{uk-C1FYZlmA.js → uk-DV9OgOAg.js} +1 -1
  61. package/dist/assets/{url-CVhsI9kR.js → url-CMQwHq4k.js} +1 -1
  62. package/dist/assets/{useAppFontEffects-BO7HWYlJ.js → useAppFontEffects-BD0BG-np.js} +43 -44
  63. package/dist/assets/{useDesktopWindowControlsLayout-BAuvqFxV.js → useDesktopWindowControlsLayout-CF2GHw9J.js} +1 -1
  64. package/dist/assets/{useEffectiveDirectory-B9GzC3CE.js → useEffectiveDirectory-Tz4YgZHt.js} +1 -1
  65. package/dist/assets/{useFontPreferences-CB-cGo3I.js → useFontPreferences-DCaY8NbU.js} +1 -1
  66. package/dist/assets/useMobileNavigationStore-BlYOTQfq.js +413 -0
  67. package/dist/assets/{useSessionAutoCleanup-DSAkV_JD.js → useSessionAutoCleanup-VgkhRtrf.js} +1 -1
  68. package/dist/assets/{useWorkerHighlightedLines-BW-HxZ9_.js → useWorkerHighlightedLines-DT0cJd4V.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-92n_rGTt.js} +1 -1
  72. package/dist/assets/{zh-TW-BsRk1Rbb.js → zh-TW-CwVxwJCj.js} +1 -1
  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 +169 -50
  88. package/server/lib/assistants/harness.test.js +355 -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-4rOhnCbj.js +0 -2
  99. package/dist/assets/Icon-CkzzhaLr.js +0 -1
  100. package/dist/assets/MobileDetailNavigation-Cy-LwJ5M.js +0 -1
  101. package/dist/assets/MobileShareBridge-CTai3NEM.js +0 -4
  102. package/dist/assets/MobileSurface-CYg5Flgz.js +0 -2
  103. package/dist/assets/insertionBoundaries-C8jXd3NT.js +0 -4
  104. package/dist/assets/main-DZg9T7tk.js +0 -169
  105. package/dist/assets/miniChat-DUFEC4jl.js +0 -2
  106. package/dist/assets/mobile-BqOSxf04.js +0 -2
  107. package/dist/assets/platform-DzsGqlGW.js +0 -2
  108. package/dist/assets/renderMobileApp-ByS1wjSm.js +0 -28
  109. package/dist/assets/runtimeEndpointReset-Dk_Xpe6y.js +0 -147
  110. package/dist/assets/sessionChangeSummary-DQDZ5HW-.js +0 -1
  111. package/dist/assets/sessionLookup-DZcwnQYW.js +0 -1
  112. package/dist/assets/src-AA-5nXsF.css +0 -2
  113. package/dist/assets/useMobileNavigationStore-Dqe-sz0u.js +0 -413
@@ -22,8 +22,13 @@ import {
22
22
  NEW_CONVERSATION_TOOL_NAME,
23
23
  parseContactToolCalls,
24
24
  SCHEDULE_TASK_TOOL_NAME,
25
+ STOP_SESSION_TOOL_NAME,
26
+ STEER_SESSION_TOOL_NAME,
27
+ ARCHIVE_SESSION_TOOL_NAME,
28
+ DELETE_SESSION_TOOL_NAME,
25
29
  stripContactToolFences,
26
30
  UPDATE_DEFAULT_PROMPT_TOOL_NAME,
31
+ WATCH_SESSION_TOOL_NAME,
27
32
  } from './contact-tools.js';
28
33
  import {
29
34
  createPiCodingRuntime,
@@ -79,19 +84,72 @@ function createAssistantMessageEventStream() {
79
84
  };
80
85
  }
81
86
 
87
+ const CONTACT_LANGUAGE_NAMES = {
88
+ en: 'English',
89
+ fr: 'French',
90
+ 'zh-CN': 'Simplified Chinese',
91
+ 'zh-TW': 'Traditional Chinese',
92
+ uk: 'Ukrainian',
93
+ es: 'Spanish',
94
+ 'pt-BR': 'Brazilian Portuguese',
95
+ ko: 'Korean',
96
+ pl: 'Polish',
97
+ ja: 'Japanese',
98
+ };
99
+
100
+ const CONTACT_LANGUAGE_FALLBACK = "the user's interface language";
101
+
102
+ /**
103
+ * Map a UI locale code to a display name for the system prompt. Unknown or
104
+ * missing values fall back to a neutral phrase so `{{LANGUAGE}}` never leaks.
105
+ * Whitelisted only: arbitrary client text must not reach the system prompt.
106
+ */
107
+ export const resolveContactLanguage = (value) => {
108
+ if (typeof value !== 'string') return CONTACT_LANGUAGE_FALLBACK;
109
+ const code = value.trim();
110
+ if (!code) return CONTACT_LANGUAGE_FALLBACK;
111
+ if (CONTACT_LANGUAGE_NAMES[code]) return CONTACT_LANGUAGE_NAMES[code];
112
+ const match = Object.keys(CONTACT_LANGUAGE_NAMES).find((key) => key.toLowerCase() === code.toLowerCase());
113
+ return match ? CONTACT_LANGUAGE_NAMES[match] : CONTACT_LANGUAGE_FALLBACK;
114
+ };
115
+
82
116
  export const CONTACT_SYSTEM_PROMPT = [
83
117
  "You are OpenChamber's in-app assistant — a personable contact who can also work in the user's configured project directory.",
84
118
  'Reply in short chat bubbles: a few sentences each, separated by a blank line.',
85
119
  'Talk like a person in the user\'s language. One short spoken bubble at a time — never a wall of paragraphs.',
120
+ '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
121
  'Never write chain-of-thought, plans, tool names, or English narration of what you will do. The user never sees thinking.',
87
122
  'Do not expose tool traces, Activity, or editor actions.',
88
123
  '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.',
124
+ '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
125
  '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.',
126
+ '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.',
127
+ 'A reply without the tool call does nothing. Never say 已创建, created, scheduled, opened, watched, or stopped unless the tool already returned success.',
93
128
  ].join(' ');
94
129
 
130
+ // A workspace response must explicitly finish or ask for missing input. Plain
131
+ // progress prose is not a final answer and must not silently end the pi loop.
132
+ const WORKSPACE_RESPONSE_PROTOCOL = [
133
+ 'Workspace response protocol: continue using openchamber-tool calls until the requested work and verification are done.',
134
+ '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.',
135
+ 'Reading a skill only loads instructions; execute its applicable steps before completing the task.',
136
+ 'To finish, reply with only an openchamber-final JSON fence: ```openchamber-final\n{"status":"complete","text":"Your final answer in the user language"}\n```.',
137
+ '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.',
138
+ '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.',
139
+ ].join('\n');
140
+
141
+ const parseFinalResponse = (text) => {
142
+ const match = text.trim().match(/^```openchamber-final\s*([\s\S]*?)```$/u);
143
+ if (!match) return null;
144
+ try {
145
+ const value = JSON.parse(match[1]);
146
+ if (!value || !['complete', 'blocked'].includes(value.status) || typeof value.text !== 'string' || !value.text.trim()) return null;
147
+ return { status: value.status, text: value.text.trim() };
148
+ } catch {
149
+ return null;
150
+ }
151
+ };
152
+
95
153
  const emptyUsage = () => ({
96
154
  input: 0,
97
155
  output: 0,
@@ -237,7 +295,9 @@ export function createContactStreamFn(createChatCompletion, {
237
295
  onBubbleDelta = null,
238
296
  globalEventHub = null,
239
297
  bubbleGapMs = 0,
298
+ signal = null,
240
299
  } = {}) {
300
+ let callSequence = 0;
241
301
  return (model, context) => {
242
302
  const stream = createAssistantMessageEventStream();
243
303
  const run = async () => {
@@ -257,10 +317,13 @@ export function createContactStreamFn(createChatCompletion, {
257
317
  }];
258
318
  }
259
319
  if (message.role === 'assistant') {
260
- const content = (message.content || [])
261
- .filter((part) => part?.type === 'text')
262
- .map((part) => part.text)
263
- .join('');
320
+ const content = (message.content || []).map((part) => {
321
+ if (part?.type === 'text') return part.text;
322
+ if (part?.type !== 'toolCall') return '';
323
+ return '```openchamber-tool\n' + JSON.stringify({
324
+ id: part.id, name: part.name, arguments: part.arguments,
325
+ }) + '\n```';
326
+ }).filter(Boolean).join('\n\n');
264
327
  return content ? [{ role: 'assistant', content }] : [];
265
328
  }
266
329
  if (message.role === 'toolResult') {
@@ -268,7 +331,6 @@ export function createContactStreamFn(createChatCompletion, {
268
331
  .filter((part) => part?.type === 'text')
269
332
  .map((part) => part.text)
270
333
  .join('');
271
- if (!content) return [];
272
334
  const toolName = typeof message.toolName === 'string' && message.toolName.trim()
273
335
  ? message.toolName.trim()
274
336
  : 'result';
@@ -278,7 +340,7 @@ export function createContactStreamFn(createChatCompletion, {
278
340
  const label = callId
279
341
  ? `OpenChamber tool result name=${toolName} call=${callId}`
280
342
  : `OpenChamber tool result name=${toolName}`;
281
- return [{ role: 'user', content: `${label}: ${content}` }];
343
+ return [{ role: 'user', content: `${label}: ${JSON.stringify({ isError: message.isError === true, content })}` }];
282
344
  }
283
345
  return [];
284
346
  });
@@ -290,40 +352,68 @@ export function createContactStreamFn(createChatCompletion, {
290
352
  if (lastUser && fallbackFiles.length > 0 && !Array.isArray(lastUser.parts)) {
291
353
  lastUser.parts = fallbackFiles;
292
354
  }
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
355
  const allowedNames = (context.tools || []).map((tool) => tool?.name).filter(Boolean);
315
- const parsed = parseContactToolCalls(text, allowedNames);
316
- if (parsed.toolCall) {
317
- const toolCall = {
356
+ const requiresFinal = allowedNames.some(isPiCodingToolName);
357
+ let text;
358
+ let parsed;
359
+ let finalText;
360
+ let rejectedCodingCalls = [];
361
+ // Repair only the rejected response: no tools have executed from it.
362
+ // Real results stay in messages, so already completed work is not replayed.
363
+ for (let attempt = 0; ; attempt += 1) {
364
+ signal?.throwIfAborted();
365
+ const result = await createChatCompletion({
366
+ signal,
367
+ body: {
368
+ model: `${model.provider === 'openchamber' ? '' : `${model.provider}/`}${model.id}`.replace(/^\//, '') || model.name,
369
+ providerID: typeof model.name === 'string' && model.name.includes('/')
370
+ ? model.name.split('/')[0]
371
+ : undefined,
372
+ modelID: model.id,
373
+ messages: [...messages],
374
+ },
375
+ onTextDelta: (delta) => {
376
+ if (typeof delta === 'string' && delta && typeof onTextDelta === 'function') onTextDelta(delta);
377
+ },
378
+ globalEventHub,
379
+ });
380
+ signal?.throwIfAborted();
381
+ text = result?.completion?.choices?.[0]?.message?.content ?? result?.text ?? '';
382
+ const finalResponse = parseFinalResponse(text);
383
+ finalText = finalResponse?.text ?? null;
384
+ // A final answer is opaque text; quoted tool examples in it cannot run.
385
+ parsed = finalText === null ? parseContactToolCalls(text, allowedNames) : { toolCalls: [], chatText: finalText };
386
+ const mixedBatch = parsed.toolCalls.length > 1 && parsed.toolCalls.some((call) => !isPiCodingToolName(call.name));
387
+ const malformedFinal = finalText === null && /```openchamber-final/u.test(parsed.chatText);
388
+ const skippedRejectedWork = rejectedCodingCalls.length > 0 && finalResponse?.status === 'complete';
389
+ const forgedTranscript = finalText === null && /(?:^|\n)\s*(?:(?:User|Assistant|Tool):|OpenChamber tool result name=)/iu.test(parsed.chatText);
390
+ const protocolError = parsed.protocolError
391
+ || (forgedTranscript ? 'Do not simulate user messages, assistant turns, or tool results. Output only your next real tool call and wait for execution.' : '')
392
+ || (skippedRejectedWork ? 'Previously rejected coding calls have not executed. Issue the remaining tool calls, or report a blocked outcome; do not declare them complete.' : '')
393
+ || (mixedBatch ? 'Send OpenChamber operation tools one at a time, without other calls in the same response.' : '')
394
+ || (malformedFinal ? 'The final declaration must be a single valid openchamber-final JSON fence with status and text, without other content.' : '')
395
+ || (requiresFinal && parsed.toolCalls.length === 0 && finalText === null ? 'A workspace response needs an actual tool call or an explicit final declaration.' : '');
396
+ if (!protocolError) break;
397
+ if (attempt >= 2) throw new Error('Assistant response protocol failed after two corrections; the task was not confirmed complete.');
398
+ const rejected = parsed.toolCalls.filter((call) => isPiCodingToolName(call.name));
399
+ if (rejected.length > 0) rejectedCodingCalls = rejected.map(({ name, arguments: args }) => ({ name, arguments: args }));
400
+ // Never replay rejected prose: it may contain fabricated user/tool results.
401
+ messages.push(
402
+ { 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.'}` },
403
+ );
404
+ }
405
+ const bubbleTracker = createContactBubbleDeltaTracker(onBubbleDelta);
406
+ if (parsed.toolCalls.length > 0) {
407
+ const toolCalls = parsed.toolCalls.map((call) => ({
318
408
  type: 'toolCall',
319
- id: `call_${Date.now().toString(36)}`,
320
- name: parsed.toolCall.name,
321
- arguments: parsed.toolCall.arguments,
322
- };
409
+ id: `call_${Date.now().toString(36)}_${++callSequence}`,
410
+ name: call.name,
411
+ arguments: call.arguments,
412
+ }));
323
413
  const spoken = isContactSpokenPreamble(parsed.chatText) ? parsed.chatText.trim() : '';
324
414
  const content = spoken
325
- ? [{ type: 'text', text: spoken }, toolCall]
326
- : [toolCall];
415
+ ? [{ type: 'text', text: spoken }, ...toolCalls]
416
+ : toolCalls;
327
417
  const partial = {
328
418
  ...assistantMessage(model, spoken, 'toolUse'),
329
419
  content,
@@ -335,15 +425,17 @@ export function createContactStreamFn(createChatCompletion, {
335
425
  stream.push({ type: 'text_delta', contentIndex: 0, delta: spoken, partial });
336
426
  stream.push({ type: 'text_end', contentIndex: 0, content: spoken, partial });
337
427
  }
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 });
428
+ toolCalls.forEach((toolCall, index) => {
429
+ const toolIndex = index + (spoken ? 1 : 0);
430
+ stream.push({ type: 'toolcall_start', contentIndex: toolIndex, partial });
431
+ stream.push({ type: 'toolcall_delta', contentIndex: toolIndex, delta: JSON.stringify(toolCall.arguments), partial });
432
+ stream.push({ type: 'toolcall_end', contentIndex: toolIndex, toolCall, partial });
433
+ });
342
434
  stream.push({ type: 'done', reason: 'toolUse', message: partial });
343
435
  stream.end(partial);
344
436
  return;
345
437
  }
346
- const chatText = stripContactToolFences(text);
438
+ const chatText = finalText ?? parsed.chatText;
347
439
  const replyBubbles = splitContactBubbles(chatText);
348
440
  if (bubbleGapMs > 0 && replyBubbles.length > 1) {
349
441
  for (let index = 0; index < replyBubbles.length; index += 1) {
@@ -363,7 +455,7 @@ export function createContactStreamFn(createChatCompletion, {
363
455
  stream.push({ type: 'done', reason: 'stop', message: partial });
364
456
  stream.end(partial);
365
457
  } catch (error) {
366
- const failed = assistantMessage(model, '', 'error', error?.message || 'upstream_error');
458
+ const failed = assistantMessage(model, '', signal?.aborted ? 'aborted' : 'error', error?.message || 'upstream_error');
367
459
  stream.push({ type: 'error', reason: 'error', error: failed });
368
460
  stream.end(failed);
369
461
  }
@@ -427,6 +519,10 @@ const extractToolResultText = (messages) => {
427
519
 
428
520
  /** Tool confirms that are themselves the user-facing bubble (no card). */
429
521
  const TOOL_TEXT_BUBBLE_TOOLS = new Set([
522
+ STOP_SESSION_TOOL_NAME,
523
+ STEER_SESSION_TOOL_NAME,
524
+ ARCHIVE_SESSION_TOOL_NAME,
525
+ DELETE_SESSION_TOOL_NAME,
430
526
  NEW_CONVERSATION_TOOL_NAME,
431
527
  CLEAR_CHAT_HISTORY_TOOL_NAME,
432
528
  UPDATE_DEFAULT_PROMPT_TOOL_NAME,
@@ -436,6 +532,8 @@ const TOOL_TEXT_BUBBLE_TOOLS = new Set([
436
532
  /** Card / side-effect tools: never paint English toolText into the transcript. */
437
533
  const CARD_SIDE_EFFECT_TOOLS = new Set([
438
534
  ASSIGN_SESSION_TOOL_NAME,
535
+ WATCH_SESSION_TOOL_NAME,
536
+ STOP_SESSION_TOOL_NAME,
439
537
  CREATE_ASSISTANT_TOOL_NAME,
440
538
  SCHEDULE_TASK_TOOL_NAME,
441
539
  MESSAGE_ASSISTANT_TOOL_NAME,
@@ -504,34 +602,45 @@ export async function runContactTurn({
504
602
  tools = [],
505
603
  projects = [],
506
604
  connectedModels = [],
605
+ modelPreferences = null,
606
+ modelCatalogAvailable = true,
507
607
  onTextDelta = null,
508
608
  onBubbleDelta = null,
509
609
  globalEventHub = null,
510
610
  skillHomeDir,
611
+ language = '',
511
612
  AgentImpl = Agent,
613
+ signal = null,
614
+ readOnly = false,
512
615
  }) {
513
616
  const providerID = assistant.providerID;
514
617
  const modelID = assistant.modelID;
515
618
  const cwd = resolveAssistantCwd(assistant);
516
619
  const contactTools = Array.isArray(tools)
517
- ? tools.filter((tool) => tool && typeof tool.name === 'string' && !isPiCodingToolName(tool.name))
620
+ ? 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
621
  : [];
519
622
  let runtime = null;
623
+ let removeAbortListener = null;
520
624
  try {
521
- if (cwd) runtime = await createPiCodingRuntime(cwd, { homeDir: skillHomeDir });
625
+ signal?.throwIfAborted();
626
+ if (cwd && !readOnly) runtime = await createPiCodingRuntime(cwd, { homeDir: skillHomeDir });
522
627
  const codingTools = Array.isArray(runtime?.tools) ? runtime.tools : [];
523
628
  const systemPrompt = [
524
629
  CONTACT_SYSTEM_PROMPT,
630
+ '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.',
631
+ 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
632
  formatPiCodingPrompt({
526
633
  cwd: runtime?.cwd,
527
634
  skillsPrompt: runtime?.skillsPrompt,
528
635
  tools: codingTools,
529
636
  }),
530
637
  formatRegisteredProjectsPrompt(projects),
531
- formatConnectedModelsPrompt(connectedModels),
638
+ formatConnectedModelsPrompt(connectedModels, { preferences: modelPreferences, catalogAvailable: modelCatalogAvailable }),
532
639
  formatContactToolsPrompt(contactTools),
533
640
  assistant.defaultPrompt,
534
- ].filter((value) => typeof value === 'string' && value.trim()).join('\n\n');
641
+ codingTools.length > 0 ? WORKSPACE_RESPONSE_PROTOCOL : '',
642
+ ].filter((value) => typeof value === 'string' && value.trim()).join('\n\n')
643
+ .replaceAll('{{LANGUAGE}}', resolveContactLanguage(language));
535
644
  const model = createContactModel(providerID, modelID);
536
645
  // streamFn receives the model; completions needs providerID/modelID.
537
646
  model.name = `${providerID}/${modelID}`;
@@ -554,6 +663,7 @@ export async function runContactTurn({
554
663
  : [];
555
664
 
556
665
  const agent = new AgentImpl({
666
+ toolExecution: 'sequential',
557
667
  initialState: {
558
668
  systemPrompt,
559
669
  model,
@@ -567,10 +677,16 @@ export async function runContactTurn({
567
677
  onBubbleDelta,
568
678
  globalEventHub,
569
679
  bubbleGapMs: 280,
680
+ signal,
570
681
  }),
571
682
  });
572
683
 
684
+ const onAbort = () => agent.abort();
685
+ signal?.addEventListener('abort', onAbort, { once: true });
686
+ removeAbortListener = () => signal?.removeEventListener('abort', onAbort);
687
+ signal?.throwIfAborted();
573
688
  await agent.prompt(userText);
689
+ signal?.throwIfAborted();
574
690
  if (agent.state.errorMessage) {
575
691
  const error = new Error(agent.state.errorMessage);
576
692
  error.code = 'upstream_error';
@@ -583,7 +699,9 @@ export async function runContactTurn({
583
699
  let retried = false;
584
700
  if (requested.length > 0 && !hasRequestedResult() && !contactTurnHasSuccessfulReset(agent.state.messages)) {
585
701
  retried = true;
702
+ signal?.throwIfAborted();
586
703
  await agent.prompt(MISSED_FENCE_RETRY_USER_TEXT);
704
+ signal?.throwIfAborted();
587
705
  if (agent.state.errorMessage) {
588
706
  const error = new Error(agent.state.errorMessage);
589
707
  error.code = 'upstream_error';
@@ -613,7 +731,7 @@ export async function runContactTurn({
613
731
  };
614
732
  }
615
733
  const outcome = extractContactTurnOutcome(agent.state.messages, retried);
616
- const text = stripContactToolFences(outcome.text);
734
+ const text = outcome.text;
617
735
  if (contactTurnHasSuccessfulReset(agent.state.messages)) {
618
736
  const historyCleared = contactTurnClearedChatHistory(agent.state.messages);
619
737
  const preferredConfirm = historyCleared
@@ -644,6 +762,7 @@ export async function runContactTurn({
644
762
  tools: [...agent.state.tools],
645
763
  };
646
764
  } finally {
765
+ removeAbortListener?.();
647
766
  try {
648
767
  await runtime?.close?.();
649
768
  } catch {