@openchambery/web 1.19.12 → 1.19.13-beta.1

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 (115) hide show
  1. package/dist/assets/{AgentAvatar-CX0a1rKE.js → AgentAvatar-CiQIIM0d.js} +1 -1
  2. package/dist/assets/AssistantView-YmeyxehG.js +2 -0
  3. package/dist/assets/{DiagramView-BeUgp_Y0.js → DiagramView-BpZb-G9P.js} +1 -1
  4. package/dist/assets/{FadeInOnReveal-CTlNE4Hg.js → FadeInOnReveal-nf6-ZCrs.js} +1 -1
  5. package/dist/assets/{Icon-BYM02QyU.js → Icon-CeKHYyKZ.js} +1 -1
  6. package/dist/assets/MarkdownRenderer-wpeGdciL.js +2 -0
  7. package/dist/assets/{MarkdownRendererImpl-DB4Q3kGa.js → MarkdownRendererImpl-Peu0GVSQ.js} +1 -1
  8. package/dist/assets/{MarkstreamRendererImpl-BFdJzxia.js → MarkstreamRendererImpl-B-odd2Gz.js} +1 -1
  9. package/dist/assets/MobileDetailNavigation-CtYke_1C.js +1 -0
  10. package/dist/assets/{MobileShareBridge-UiOLx0-2.js → MobileShareBridge-DIfOl8EZ.js} +3 -3
  11. package/dist/assets/MobileSurface-_KoX75Ff.js +2 -0
  12. package/dist/assets/{MultiRunWindow-C3p3lj3U.js → MultiRunWindow-BT5Dwgzu.js} +1 -1
  13. package/dist/assets/{OnboardingScreen-DS2nugLX.js → OnboardingScreen-BPusUgFG.js} +1 -1
  14. package/dist/assets/{OverlayScrollbar-KUpvYHdv.js → OverlayScrollbar-DUghqFGg.js} +1 -1
  15. package/dist/assets/{SettingsView-BXfKsz0J.js → SettingsView-BwQpbIZl.js} +11 -11
  16. package/dist/assets/{SettingsWindow-8IDuPZA5.js → SettingsWindow-DfEGfyr8.js} +1 -1
  17. package/dist/assets/{TerminalView-BiKCueth.js → TerminalView-8402vpcO.js} +1 -1
  18. package/dist/assets/{ToolOutputDialog-BC2F_Fdl.js → ToolOutputDialog-B5RdkhsG.js} +1 -1
  19. package/dist/assets/{appThemeRegistry-B87OLIgc.js → appThemeRegistry-CIZ-shMD.js} +1 -1
  20. package/dist/assets/{button-C0cth9tl.js → button-DFPOdZ0g.js} +1 -1
  21. package/dist/assets/{clipboard-DJ4nQEuY.js → clipboard-C05anBwU.js} +1 -1
  22. package/dist/assets/{collapsible-DFT9cE80.js → collapsible-oGbEvbe1.js} +1 -1
  23. package/dist/assets/{context-menu-CAp74NfV.js → context-menu-DGz4_2hs.js} +1 -1
  24. package/dist/assets/{desktopHosts-Dc2PS66M.js → desktopHosts-BVJXQq1S.js} +1 -1
  25. package/dist/assets/{desktopRelayRestore-T9y0orqa.js → desktopRelayRestore-RDfuj1M7.js} +1 -1
  26. package/dist/assets/{diagram-BryzMuta.js → diagram-Csk5GpDC.js} +1 -1
  27. package/dist/assets/{dialog-CgbV5ljg.js → dialog-rrqB-H6q.js} +1 -1
  28. package/dist/assets/{es-BHqnSjoZ.js → es-FE0sJ9EC.js} +1 -1
  29. package/dist/assets/{fileQueries-BY-9OWzp.js → fileQueries-BcdWOPlD.js} +1 -1
  30. package/dist/assets/{fr-BfrlJY9i.js → fr-DNDCSfGt.js} +1 -1
  31. package/dist/assets/{gitApi-tTtHQVA3.js → gitApi-_P0FsoXe.js} +1 -1
  32. package/dist/assets/{input-DFLpqjGR.js → input-BO3r20op.js} +1 -1
  33. package/dist/assets/{insertionBoundaries-CCQpZ5zn.js → insertionBoundaries-CWoioTdE.js} +1 -1
  34. package/dist/assets/{ja-DP-7qt-x.js → ja-BDwrk0cM.js} +1 -1
  35. package/dist/assets/{ko-E2B5vGQP.js → ko-BeeU2o4I.js} +1 -1
  36. package/dist/assets/{main-Dkg31fRY.js → main-CLj_WS1o.js} +2 -2
  37. package/dist/assets/{main-C_PUoT3E.js → main-CnSZ89pD.js} +4 -4
  38. package/dist/assets/{markdown-worker-CTsrtpPh.js → markdown-worker-CZWE1F1M.js} +1 -1
  39. package/dist/assets/{markdownHeightCache-CYDlisxr.js → markdownHeightCache-Cdd3hkMl.js} +1 -1
  40. package/dist/assets/{markdownTheme-DyJ4Gfcj.js → markdownTheme-DIB8WVmz.js} +1 -1
  41. package/dist/assets/miniChat-B9c3y2pD.js +2 -0
  42. package/dist/assets/{mobile-Z_X7CbzB.js → mobile-DvbXUfNS.js} +2 -2
  43. package/dist/assets/{multirun-B0V3hdnD.js → multirun-CxhTsmRq.js} +1 -1
  44. package/dist/assets/{persistence-DdUac14I.js → persistence-CVTWFaqe.js} +2 -2
  45. package/dist/assets/{pl-r8X5gRM5.js → pl-BYuzpcI5.js} +1 -1
  46. package/dist/assets/{platform-B4KKLnZ5.js → platform-BY_Gtsqr.js} +1 -1
  47. package/dist/assets/{projectMeta-DRW9MQ4z.js → projectMeta-Dl5iPCB6.js} +1 -1
  48. package/dist/assets/{pt-BR-DcpdXCf5.js → pt-BR-Bn7cwEzv.js} +1 -1
  49. package/dist/assets/{renderElectronMiniChatApp-Dn1Su1dd.js → renderElectronMiniChatApp-CkJ51qCh.js} +2 -2
  50. package/dist/assets/renderMobileApp-DcjQPhWw.js +28 -0
  51. package/dist/assets/{runtimeConfig-C6matIEB.js → runtimeConfig-Bsk2kWsW.js} +1 -1
  52. package/dist/assets/runtimeEndpointReset-BBLFgl85.js +147 -0
  53. package/dist/assets/{sessionChangeSummary-hVby3sJk.js → sessionChangeSummary-DDVT7O5J.js} +1 -1
  54. package/dist/assets/{sessionLookup-B6P_aCun.js → sessionLookup-C8VZn0gJ.js} +1 -1
  55. package/dist/assets/{sortable-tabs-strip-C8_9kHqu.js → sortable-tabs-strip-s7UUxsa6.js} +1 -1
  56. package/dist/assets/src-BpHDMgud.css +2 -0
  57. package/dist/assets/{store-BDFdnk6l.js → store-n94dSb3M.js} +2 -2
  58. package/dist/assets/{terminalApi-947l7Ra3.js → terminalApi-C54_rKEK.js} +1 -1
  59. package/dist/assets/{textarea-Cgo_nWUU.js → textarea-BjX-KhMN.js} +1 -1
  60. package/dist/assets/{tooltip-BifLJ8o0.js → tooltip-CQGqGAG_.js} +1 -1
  61. package/dist/assets/{uk-Di0vl9V-.js → uk-CwpbHEa3.js} +1 -1
  62. package/dist/assets/{url-Pk6tQ1EZ.js → url-BMjqccCo.js} +1 -1
  63. package/dist/assets/{useAppFontEffects-Dmes1chr.js → useAppFontEffects-BIFw36SA.js} +5 -5
  64. package/dist/assets/{useAssistantUIStore-me6s63b2.js → useAssistantUIStore-Cg9bsT4b.js} +1 -1
  65. package/dist/assets/{useDesktopWindowControlsLayout-DuZ6URPR.js → useDesktopWindowControlsLayout-DoygEcN4.js} +1 -1
  66. package/dist/assets/{useEffectiveDirectory-CU2ChP0J.js → useEffectiveDirectory-DJSYYkVp.js} +1 -1
  67. package/dist/assets/{useFontPreferences-BCOBlaAd.js → useFontPreferences-B5_apbBC.js} +1 -1
  68. package/dist/assets/{useMobileNavigationStore-C8_J-qTp.js → useMobileNavigationStore-DuTu0Mzh.js} +5 -5
  69. package/dist/assets/{useSessionAutoCleanup-FOQeu1_T.js → useSessionAutoCleanup-C-L9teva.js} +1 -1
  70. package/dist/assets/{useUIStore-CuaA8-Hp.js → useUIStore-DwpQTjLO.js} +1 -1
  71. package/dist/assets/{useWorkerHighlightedLines-GBqKnki1.js → useWorkerHighlightedLines-DxTfgdEi.js} +1 -1
  72. package/dist/assets/{utils-BiK3tBGM.js → utils-81nUI_xp.js} +1 -1
  73. package/dist/assets/{zh-CN-coFCfeQE.js → zh-CN-BpzFZ-LU.js} +1 -1
  74. package/dist/assets/{zh-TW-oDRZ1-yb.js → zh-TW-Bopgc5Eo.js} +1 -1
  75. package/dist/index.html +9 -9
  76. package/dist/mini-chat.html +9 -9
  77. package/dist/mobile.html +9 -9
  78. package/package.json +1 -1
  79. package/server/index.js +19 -0
  80. package/server/lib/assistants/DOCUMENTATION.md +17 -4
  81. package/server/lib/assistants/assign.js +455 -30
  82. package/server/lib/assistants/assign.test.js +351 -0
  83. package/server/lib/assistants/contact-active-turn.http.test.js +134 -0
  84. package/server/lib/assistants/contact-store.js +64 -0
  85. package/server/lib/assistants/contact-store.test.js +12 -0
  86. package/server/lib/assistants/contact-tools.js +108 -9
  87. package/server/lib/assistants/contact-tools.test.js +111 -0
  88. package/server/lib/assistants/contracts.js +2 -2
  89. package/server/lib/assistants/harness.js +4 -1
  90. package/server/lib/assistants/service.js +320 -27
  91. package/server/lib/assistants/service.test.js +538 -9
  92. package/server/lib/llm/catalog.js +91 -16
  93. package/server/lib/llm/catalog.test.js +152 -11
  94. package/server/lib/notifications/APNS.md +20 -4
  95. package/server/lib/notifications/DOCUMENTATION.md +5 -0
  96. package/server/lib/notifications/apns-runtime.js +136 -18
  97. package/server/lib/notifications/apns-runtime.test.js +128 -0
  98. package/server/lib/notifications/apns-titles.js +21 -1
  99. package/server/lib/notifications/live-activity-refresh-runtime.js +185 -0
  100. package/server/lib/notifications/live-activity-refresh-runtime.test.js +242 -0
  101. package/server/lib/notifications/runtime.js +93 -2
  102. package/server/lib/notifications/runtime.test.js +167 -4
  103. package/server/lib/opencode/settings-helpers.js +12 -0
  104. package/server/lib/opencode/settings-runtime.js +16 -0
  105. package/server/lib/opencode/settings-runtime.test.js +21 -0
  106. package/server/lib/scheduled-tasks/runtime.js +21 -0
  107. package/server/lib/scheduled-tasks/runtime.test.js +24 -0
  108. package/dist/assets/AssistantView-DxIghtun.js +0 -2
  109. package/dist/assets/MarkdownRenderer-C4MGa_kQ.js +0 -2
  110. package/dist/assets/MobileDetailNavigation-B_RwWOwv.js +0 -1
  111. package/dist/assets/MobileSurface-CNChj_dX.js +0 -2
  112. package/dist/assets/miniChat-C9QMj8T8.js +0 -2
  113. package/dist/assets/renderMobileApp-BG1JJt1Q.js +0 -28
  114. package/dist/assets/runtimeEndpointReset-kgwmUJDb.js +0 -147
  115. package/dist/assets/src-B-P5qIBc.css +0 -2
@@ -41,13 +41,16 @@ export const CLEAR_CHAT_HISTORY_CONFIRM_BUBBLE = 'Chat history cleared.';
41
41
  const LIST_SESSIONS_LIMIT_DEFAULT = 20;
42
42
  const LIST_SESSIONS_LIMIT_MAX = 50;
43
43
 
44
- /** Stable key for same-turn assign dedup (not cross-turn). */
44
+ /** Stable key for same-turn assign dedup (not cross-turn). Includes worker model + attachment scope. */
45
45
  export function normalizeAssignRequestKey(params = {}) {
46
46
  const pick = (value) => {
47
47
  if (typeof value === 'string') return value.trim();
48
48
  if (value == null) return '';
49
49
  return String(value).trim();
50
50
  };
51
+ const attachments = Array.isArray(params?.attachmentScope)
52
+ ? params.attachmentScope
53
+ : (Array.isArray(params?.attachments) ? params.attachments : []);
51
54
  return JSON.stringify({
52
55
  prompt: pick(params?.prompt),
53
56
  projectPath: pick(params?.projectPath),
@@ -55,6 +58,10 @@ export function normalizeAssignRequestKey(params = {}) {
55
58
  branch: pick(params?.branch),
56
59
  sessionID: pick(params?.sessionID),
57
60
  title: pick(params?.title),
61
+ providerID: pick(params?.providerID),
62
+ modelID: pick(params?.modelID),
63
+ model: pick(params?.model),
64
+ attachments,
58
65
  });
59
66
  }
60
67
 
@@ -80,12 +87,16 @@ const newConversationParameters = typeboxObject({});
80
87
  const clearChatHistoryParameters = typeboxObject({});
81
88
 
82
89
  const assignParameters = typeboxObject({
83
- prompt: typeboxString('Coding prompt to kick into the worker OpenCode session.'),
90
+ prompt: typeboxString('Coding prompt to kick into the worker OpenCode session. Current-turn user attachments (images/files) are forwarded by the server automatically — do not base64-encode or invent local paths.'),
84
91
  projectPath: typeboxOptional(typeboxString('Registered project path. Required when more than one project exists.')),
85
92
  directory: typeboxOptional(typeboxString('Existing project or worktree directory to reuse.')),
86
93
  branch: typeboxOptional(typeboxString('Existing Chat worktree branch to reuse. Do not create a new worktree.')),
87
94
  sessionID: typeboxOptional(typeboxString('Existing OpenCode session to reuse instead of creating one.')),
88
95
  title: typeboxOptional(typeboxString('Optional title for the worker session and contact card.')),
96
+ providerID: typeboxOptional(typeboxString('Optional worker OpenCode provider ID from the connected catalog. Does not change this contact\'s model. Illegal/blank values fail closed.')),
97
+ modelID: typeboxOptional(typeboxString('Optional worker OpenCode model ID from the connected catalog. Does not change this contact\'s model. Illegal/blank values fail closed.')),
98
+ model: typeboxOptional(typeboxString('Optional worker provider/model string such as provider/model-id from the connected catalog. Must not conflict with providerID/modelID. Does not change this contact\'s model.')),
99
+ variant: typeboxOptional(typeboxString('Optional worker variant for this assign only. Cross-model assign never reuses this contact\'s variant.')),
89
100
  });
90
101
 
91
102
  const createAssistantParameters = typeboxObject({
@@ -200,6 +211,47 @@ export function formatRegisteredProjectsPrompt(projects) {
200
211
  ].join('\n');
201
212
  }
202
213
 
214
+ /** Sanitize connected catalog rows for prompt/tool consumption (no secrets). */
215
+ function sanitizeConnectedModel(entry) {
216
+ if (!entry || typeof entry !== 'object') return null;
217
+ const providerID = typeof entry.providerID === 'string' ? entry.providerID.trim() : '';
218
+ const modelID = typeof entry.modelID === 'string' ? entry.modelID.trim() : '';
219
+ if (!providerID || !modelID) return null;
220
+ const name = typeof entry.name === 'string' && entry.name.trim() ? entry.name.trim() : modelID;
221
+ return {
222
+ providerID,
223
+ modelID,
224
+ name,
225
+ acceptsImages: entry.acceptsImages === true,
226
+ };
227
+ }
228
+
229
+ export function normalizeConnectedModels(models) {
230
+ return (Array.isArray(models) ? models : [])
231
+ .map(sanitizeConnectedModel)
232
+ .filter(Boolean);
233
+ }
234
+
235
+ /** Injected each turn so assign_session can pick an explicit worker model from the live catalog. */
236
+ export function formatConnectedModelsPrompt(models) {
237
+ const list = normalizeConnectedModels(models);
238
+ if (list.length === 0) {
239
+ return [
240
+ 'Connected OpenCode models: none discoverable this turn.',
241
+ 'assign_session without providerID/modelID/model still uses this contact\'s default model.',
242
+ 'If the user names a specific worker model, only use ids from a successful catalog load — never invent provider/model strings.',
243
+ ].join(' ');
244
+ }
245
+ return [
246
+ 'Connected OpenCode models (worker targets for assign_session only — choosing one does NOT change this contact\'s own model):',
247
+ ...list.map((entry) => (
248
+ `- providerID=${JSON.stringify(entry.providerID)} modelID=${JSON.stringify(entry.modelID)} name=${JSON.stringify(entry.name)} acceptsImages=${entry.acceptsImages ? 'true' : 'false'}`
249
+ )),
250
+ 'When the user names a worker model, pass providerID+modelID or model="provider/modelID" on assign_session. Ambiguous or unknown names must fail — never silent-fallback.',
251
+ 'Current-turn user images/files are forwarded by the server on assign_session. Prefer a acceptsImages=true model when the user attached images.',
252
+ ].join('\n');
253
+ }
254
+
203
255
  export function boundSessionListLimit(value) {
204
256
  if (value == null || value === '') return LIST_SESSIONS_LIMIT_DEFAULT;
205
257
  const parsed = typeof value === 'number' ? value : Number(String(value).trim());
@@ -500,7 +552,7 @@ export function formatContactToolsPrompt(tools) {
500
552
  `{"name":"${MESSAGE_ASSISTANT_TOOL_NAME}","arguments":{"to":"PeerQA","text":"hello-from-assistant 写好了"}}`,
501
553
  '```',
502
554
  `\`\`\`${CONTACT_TOOL_FENCE}`,
503
- `{"name":"${ASSIGN_SESSION_TOOL_NAME}","arguments":{"prompt":"...","projectPath":"..."}}`,
555
+ `{"name":"${ASSIGN_SESSION_TOOL_NAME}","arguments":{"prompt":"...","projectPath":"...","model":"provider/model-id"}}`,
504
556
  '```',
505
557
  'If the user asked for more than one of these, do them in that order across turns: new_conversation or clear_chat_history, then list_projects, then list_sessions, then create_assistant, then schedule_task, then message_assistant, then assign_session.',
506
558
  'Prerequisite lookups (list_projects / list_sessions) may run before assign_session in the same turn. After a successful assign_session the turn ends — never call assign_session again, and do not keep looping tools.',
@@ -508,7 +560,7 @@ export function formatContactToolsPrompt(tools) {
508
560
  'clear_chat_history deletes this contact\'s stored messages, parts, and watches. Use only for explicit wipe intent.',
509
561
  'You already receive the registered project catalog each turn. Prefer matching label/path yourself; list_projects refreshes or filters. Never claim you cannot see projects; never ask for a raw path when a name matches.',
510
562
  'list_sessions searches the OpenChamber session index for existing chats in a project. A failure is not an empty list — surface the error.',
511
- 'assign_session opens a real OpenChamber/OpenCode session on a registered project (or reuses sessionID). You are not the worker. One successful assign ends this turn.',
563
+ 'assign_session opens a real OpenChamber/OpenCode session on a registered project (or reuses sessionID). You are not the worker. Optional providerID/modelID/model select the worker only from the connected catalog and never change this contact. Current-turn user attachments are server-forwarded — do not embed base64 or local paths. One successful assign ends this turn.',
512
564
  'create_assistant reuses already-connected OpenCode providers (providerID/modelID). Mode is continuous.',
513
565
  'schedule_task writes the same payload as PUT /api/projects/:id/scheduled-tasks onto a registered project.',
514
566
  'message_assistant is read-only: it inserts into the other contact transcript. It never runs promptAsync or mutates sessions or files. Never assign through a peer message.',
@@ -579,9 +631,17 @@ const toolFailure = (error, fallbackCode, fallbackMessage) => {
579
631
  const message = typeof error?.message === 'string' && error.message.trim()
580
632
  ? error.message.trim()
581
633
  : fallbackMessage;
634
+ const details = { error: code };
635
+ // Recoverable prompt admission identity — model must reuse sessionID/messageID,
636
+ // never blind-create another worker on the same contact turn.
637
+ if (error instanceof AssignError || (error && typeof error === 'object')) {
638
+ if (error.ambiguous === true) details.ambiguous = true;
639
+ if (typeof error.sessionID === 'string' && error.sessionID) details.sessionID = error.sessionID;
640
+ if (typeof error.messageID === 'string' && error.messageID) details.messageID = error.messageID;
641
+ }
582
642
  return {
583
643
  content: [{ type: 'text', text: message }],
584
- details: { error: code },
644
+ details,
585
645
  terminate: true,
586
646
  };
587
647
  };
@@ -598,6 +658,10 @@ export function createContactTools({
598
658
  listSessions,
599
659
  currentAssistant,
600
660
  onCard,
661
+ /** Authoritative current-turn user file parts (server-owned). Forwarded on assign by default. */
662
+ turnFileParts = [],
663
+ /** Precomputed attachment fingerprint for same-turn assign dedup. */
664
+ turnAttachmentScope = [],
601
665
  } = {}) {
602
666
  const emitCard = (card) => {
603
667
  if (typeof onCard === 'function') onCard(card);
@@ -607,13 +671,19 @@ export function createContactTools({
607
671
  * Same contact-turn assign gate only (tools instance = one harness turn).
608
672
  * - Same args after success → cached success (no second worker).
609
673
  * - Different args after success / parallel mismatch → reject.
610
- * - Failure clears the gate so a corrected retry may run.
674
+ * - Definitive failure clears the gate so a corrected retry may run.
675
+ * - prompt_ambiguous keeps the gate with sessionID/messageID so a retry must
676
+ * reuse that worker — never blind-create a second session this turn.
611
677
  * Not cross-turn.
612
678
  */
613
679
  let assignTurnGate = null;
614
680
 
615
681
  const executeAssignOnce = async (params) => {
616
- const key = normalizeAssignRequestKey(params);
682
+ let request = { ...(params || {}) };
683
+ const key = normalizeAssignRequestKey({
684
+ ...request,
685
+ attachmentScope: turnAttachmentScope,
686
+ });
617
687
  if (assignTurnGate?.status === 'done') {
618
688
  if (assignTurnGate.key === key) return assignTurnGate.result;
619
689
  throw new AssignError(ASSIGN_CODES.VALIDATION, ASSIGN_DUPLICATE_TURN_MESSAGE);
@@ -622,6 +692,14 @@ export function createContactTools({
622
692
  if (assignTurnGate.key === key) return assignTurnGate.promise;
623
693
  throw new AssignError(ASSIGN_CODES.VALIDATION, ASSIGN_DUPLICATE_TURN_MESSAGE);
624
694
  }
695
+ if (assignTurnGate?.status === 'ambiguous') {
696
+ if (assignTurnGate.key !== key) {
697
+ throw new AssignError(ASSIGN_CODES.VALIDATION, ASSIGN_DUPLICATE_TURN_MESSAGE);
698
+ }
699
+ // Same request after ambiguous admission: reuse worker identity only.
700
+ if (assignTurnGate.sessionID) request = { ...request, sessionID: assignTurnGate.sessionID };
701
+ if (assignTurnGate.messageID) request = { ...request, messageID: assignTurnGate.messageID };
702
+ }
625
703
 
626
704
  let settle;
627
705
  const promise = new Promise((resolve, reject) => {
@@ -634,7 +712,12 @@ export function createContactTools({
634
712
  if (typeof assignWork !== 'function') {
635
713
  throw new AssignError('upstream_error', 'Assign is unavailable.');
636
714
  }
637
- const assigned = await assignWork(params || {});
715
+ // Server owns current-turn attachments model never supplies base64/paths.
716
+ const assigned = await assignWork({
717
+ ...request,
718
+ fileParts: turnFileParts,
719
+ attachmentScope: turnAttachmentScope,
720
+ });
638
721
  const card = createSessionCardPart({
639
722
  sessionID: assigned.sessionID,
640
723
  directory: assigned.directory,
@@ -653,7 +736,21 @@ export function createContactTools({
653
736
  assignTurnGate = { status: 'done', key, result };
654
737
  settle.resolve(result);
655
738
  } catch (error) {
656
- assignTurnGate = null;
739
+ if (
740
+ error instanceof AssignError
741
+ && error.code === ASSIGN_CODES.PROMPT_AMBIGUOUS
742
+ && typeof error.sessionID === 'string'
743
+ && error.sessionID
744
+ ) {
745
+ assignTurnGate = {
746
+ status: 'ambiguous',
747
+ key,
748
+ sessionID: error.sessionID,
749
+ messageID: typeof error.messageID === 'string' ? error.messageID : null,
750
+ };
751
+ } else {
752
+ assignTurnGate = null;
753
+ }
657
754
  settle.reject(error);
658
755
  }
659
756
  })();
@@ -924,6 +1021,8 @@ export function createContactTools({
924
1021
  description: [
925
1022
  'Open or reuse a real OpenChamber coding session on a registered project path',
926
1023
  'and kick the prompt into that session. Optional existing worktree branch or sessionID.',
1024
+ 'Optional providerID/modelID/model select the worker from the connected catalog only (does not change this contact).',
1025
+ 'Current-turn user attachments are forwarded by the server automatically.',
927
1026
  'Successful assign ends this contact turn (terminate). Never codes here. Never uses assistant-workspaces.',
928
1027
  ].join(' '),
929
1028
  parameters: assignParameters,
@@ -16,6 +16,7 @@ import {
16
16
  createContactTools,
17
17
  detectRequestedContactTools,
18
18
  filterRegisteredProjects,
19
+ formatConnectedModelsPrompt,
19
20
  formatContactToolsPrompt,
20
21
  formatRegisteredProjectsPrompt,
21
22
  matchesProjectQuery,
@@ -26,6 +27,7 @@ import {
26
27
  stripContactToolFences,
27
28
  userTextAuthorizesClearChatHistory,
28
29
  } from './contact-tools.js';
30
+ import { attachmentScopeKey } from './assign.js';
29
31
 
30
32
  describe('contact tool protocol', () => {
31
33
  it('parses an assign_session fence and strips it from chat text', () => {
@@ -167,10 +169,40 @@ describe('contact tool protocol', () => {
167
169
  expect(prompt).toContain('arguments schema:');
168
170
  expect(prompt).toContain('"prompt"');
169
171
  expect(prompt).toContain('"projectPath"');
172
+ expect(prompt).toContain('"providerID"');
173
+ expect(prompt).toContain('"modelID"');
174
+ expect(prompt).toContain('"model"');
175
+ expect(prompt).toContain('connected catalog');
170
176
  expect(prompt).toContain('"to"');
171
177
  expect(prompt).toContain('"text"');
172
178
  });
173
179
 
180
+ it('exposes connected models for assign worker selection without inventing ids', () => {
181
+ const prompt = formatConnectedModelsPrompt([
182
+ { providerID: 'xai', modelID: 'grok-4.6', name: 'Grok 4.6', acceptsImages: true },
183
+ { providerID: 'opencode-go', modelID: 'deepseek-v4-flash', acceptsImages: false },
184
+ ]);
185
+ expect(prompt).toContain('xai');
186
+ expect(prompt).toContain('grok-4.6');
187
+ expect(prompt).toContain('acceptsImages=true');
188
+ expect(prompt).toContain('does NOT change this contact');
189
+ expect(formatConnectedModelsPrompt([])).toContain('none discoverable');
190
+ });
191
+
192
+ it('includes worker model and attachment scope in same-turn assign keys', () => {
193
+ const scope = attachmentScopeKey([
194
+ { type: 'file', mime: 'image/png', url: 'data:image/png;base64,aa', filename: 'a.png' },
195
+ ]);
196
+ const base = { prompt: 'Fix', projectPath: '/repo' };
197
+ expect(normalizeAssignRequestKey(base)).not.toBe(normalizeAssignRequestKey({
198
+ ...base,
199
+ model: 'xai/grok-4.6',
200
+ }));
201
+ expect(normalizeAssignRequestKey({ ...base, attachmentScope: scope })).not.toBe(
202
+ normalizeAssignRequestKey({ ...base, attachmentScope: [] }),
203
+ );
204
+ });
205
+
174
206
  it('fuzzy-matches project labels like openchamber yee / openchamer yee', () => {
175
207
  const project = { id: 'p1', path: '/Users/me/Code/sample-app', label: 'OpenChamber Yee' };
176
208
  expect(matchesProjectQuery(project, 'openchamber yee')).toBe(true);
@@ -305,6 +337,35 @@ describe('createContactTools', () => {
305
337
  expect(onCard).toHaveBeenCalledTimes(3);
306
338
  });
307
339
 
340
+ it('forwards server turn file parts on assign and keeps model selection in the tool args', async () => {
341
+ const image = { type: 'file', mime: 'image/png', url: 'data:image/png;base64,aa', filename: 'shot.png' };
342
+ const assignWork = vi.fn(async (params) => ({
343
+ sessionID: 'ses_img',
344
+ directory: '/repo',
345
+ title: 'Shot',
346
+ status: 'busy',
347
+ model: { providerID: params.providerID, modelID: params.modelID },
348
+ }));
349
+ const tools = createContactTools({
350
+ assignWork,
351
+ turnFileParts: [image],
352
+ turnAttachmentScope: attachmentScopeKey([image]),
353
+ });
354
+ const result = await tools.find((tool) => tool.name === ASSIGN_SESSION_TOOL_NAME).execute('call_img', {
355
+ prompt: 'Fix card width',
356
+ projectPath: '/repo',
357
+ providerID: 'xai',
358
+ modelID: 'grok-4.6',
359
+ });
360
+ expect(assignWork).toHaveBeenCalledWith(expect.objectContaining({
361
+ prompt: 'Fix card width',
362
+ providerID: 'xai',
363
+ modelID: 'grok-4.6',
364
+ fileParts: [image],
365
+ }));
366
+ expect(result.terminate).toBe(true);
367
+ });
368
+
308
369
  it('same-turn assign gate caches identical success and rejects a different second assign', async () => {
309
370
  const assignWork = vi.fn(async () => ({
310
371
  sessionID: 'ses_once',
@@ -328,6 +389,56 @@ describe('createContactTools', () => {
328
389
  expect(assignWork).toHaveBeenCalledTimes(1);
329
390
  });
330
391
 
392
+ it('same-turn assign gate treats different worker models as distinct requests after success', async () => {
393
+ const assignWork = vi.fn(async () => ({
394
+ sessionID: 'ses_model',
395
+ directory: '/repo',
396
+ title: 'M',
397
+ status: 'busy',
398
+ }));
399
+ const tools = createContactTools({ assignWork });
400
+ const assign = tools.find((tool) => tool.name === ASSIGN_SESSION_TOOL_NAME);
401
+ await assign.execute('call_1', { prompt: 'Fix', projectPath: '/repo', model: 'xai/grok-4.6' });
402
+ const second = await assign.execute('call_2', { prompt: 'Fix', projectPath: '/repo', model: 'openai/gpt-4o' });
403
+ expect(second.details.error).toBe('validation_error');
404
+ expect(assignWork).toHaveBeenCalledTimes(1);
405
+ });
406
+
407
+ it('prompt_ambiguous keeps recoverable sessionID/messageID and blocks a different create this turn', async () => {
408
+ let calls = 0;
409
+ const assignWork = vi.fn(async (params) => {
410
+ calls += 1;
411
+ if (calls === 1) {
412
+ throw new AssignError(
413
+ ASSIGN_CODES.PROMPT_AMBIGUOUS,
414
+ 'admission unknown',
415
+ { ambiguous: true, sessionID: 'ses_amb', messageID: 'msg_amb' },
416
+ );
417
+ }
418
+ expect(params.sessionID).toBe('ses_amb');
419
+ expect(params.messageID).toBe('msg_amb');
420
+ return { sessionID: 'ses_amb', directory: '/repo', title: 'A', status: 'busy', messageID: 'msg_amb' };
421
+ });
422
+ const tools = createContactTools({ assignWork });
423
+ const assign = tools.find((tool) => tool.name === ASSIGN_SESSION_TOOL_NAME);
424
+ const args = { prompt: 'Fix', projectPath: '/repo' };
425
+ const failed = await assign.execute('call_a', args);
426
+ expect(failed.details).toMatchObject({
427
+ error: 'prompt_ambiguous',
428
+ ambiguous: true,
429
+ sessionID: 'ses_amb',
430
+ messageID: 'msg_amb',
431
+ });
432
+ expect(failed.terminate).toBe(true);
433
+ const other = await assign.execute('call_b', { prompt: 'Other', projectPath: '/repo' });
434
+ expect(other.details.error).toBe('validation_error');
435
+ expect(assignWork).toHaveBeenCalledTimes(1);
436
+ const recovered = await assign.execute('call_c', args);
437
+ expect(assignWork).toHaveBeenCalledTimes(2);
438
+ expect(recovered.details.assigned.sessionID).toBe('ses_amb');
439
+ expect(recovered.terminate).toBe(true);
440
+ });
441
+
331
442
  it('same-turn parallel identical assigns share one worker create', async () => {
332
443
  let release;
333
444
  const barrier = new Promise((resolve) => { release = resolve; });
@@ -1,8 +1,8 @@
1
1
  export const assistantContractFixtures = Object.freeze({
2
- assistant: Object.freeze({ id: 'assistant_fixture', revision: 4, enabled: true, name: 'Fixture', defaultPrompt: '', workspacePath: null, managedWorkspacePath: '/data/assistant-workspaces/assistant_fixture', effectiveWorkspacePath: '/workspace', providerID: 'provider_fixture', modelID: 'model_fixture', agent: null, variant: null, mode: 'continuous', sessionID: 'ses_fixture', sessionGeneration: 4, historySessionIDs: Object.freeze([]), historySessionCount: 0, assignedSessionIDs: Object.freeze([]), working: false, createdAt: 1234, updatedAt: 1234, tombstoneAt: null }),
2
+ assistant: Object.freeze({ id: 'assistant_fixture', revision: 4, enabled: true, name: 'Fixture', defaultPrompt: '', workspacePath: null, managedWorkspacePath: '/data/assistant-workspaces/assistant_fixture', effectiveWorkspacePath: '/workspace', providerID: 'provider_fixture', modelID: 'model_fixture', agent: null, variant: null, mode: 'continuous', sessionID: 'ses_fixture', sessionGeneration: 4, historySessionIDs: Object.freeze([]), historySessionCount: 0, assignedSessionIDs: Object.freeze([]), working: false, activeContactTurn: null, createdAt: 1234, updatedAt: 1234, tombstoneAt: null }),
3
3
  sessionBinding: Object.freeze({ sessionID: 'ses_fixture', directory: '/workspace', sessionGeneration: 4 }),
4
4
  compactResponse: Object.freeze({ binding: Object.freeze({ sessionID: 'ses_fixture', directory: '/workspace', sessionGeneration: 4 }), summarized: true }),
5
- messageAdmission: Object.freeze({ binding: Object.freeze({ sessionID: 'ses_fixture', directory: '/workspace', sessionGeneration: 4 }), messageID: 'msg_fixture', admitted: true }),
5
+ messageAdmission: Object.freeze({ binding: Object.freeze({ sessionID: 'ses_fixture', directory: '/workspace', sessionGeneration: 4 }), messageID: 'msg_fixture', admitted: true, revision: 4 }),
6
6
  abortResponse: Object.freeze({ binding: Object.freeze({ sessionID: 'ses_fixture', directory: '/workspace', sessionGeneration: 4 }), aborted: true }),
7
7
  shareOperation: Object.freeze({ operationID: 'share_fixture', assistantID: 'assistant_fixture', sessionID: 'ses_fixture', messageID: 'msg_fixture', state: 'running', phase: 'submitted', attempt: 1, leaseExpiresAt: 1234, errorCode: null }),
8
8
  historicalMessages: Object.freeze({ entries: Object.freeze([Object.freeze({ sessionID: 'ses_fixture', directory: null, info: Object.freeze({ id: 'msg_fixture', sessionID: 'ses_fixture', role: 'assistant', time: Object.freeze({ created: 1234 }) }), parts: Object.freeze([]) })]), nextCursor: null, complete: true }),
@@ -9,6 +9,7 @@ import {
9
9
  detectRequestedContactTools,
10
10
  extractContactCardsFromMessages,
11
11
  formatContactToolsPrompt,
12
+ formatConnectedModelsPrompt,
12
13
  formatRegisteredProjectsPrompt,
13
14
  MISSED_FENCE_RETRY_USER_TEXT,
14
15
  MISSED_TOOL_FAILURE_BUBBLE,
@@ -79,7 +80,7 @@ export const CONTACT_SYSTEM_PROMPT = [
79
80
  '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.',
80
81
  '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 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.',
81
82
  '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.',
82
- '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. One successful assign_session ends the turn — do not call it again.',
83
+ '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.',
83
84
  'A reply without the tool call does nothing. Never say 已创建, created, scheduled, or opened unless the tool already returned success.',
84
85
  ].join(' ');
85
86
 
@@ -465,6 +466,7 @@ export async function runContactTurn({
465
466
  createChatCompletion,
466
467
  tools = [],
467
468
  projects = [],
469
+ connectedModels = [],
468
470
  onTextDelta = null,
469
471
  onBubbleDelta = null,
470
472
  globalEventHub = null,
@@ -489,6 +491,7 @@ export async function runContactTurn({
489
491
  tools: codingTools,
490
492
  }),
491
493
  formatRegisteredProjectsPrompt(projects),
494
+ formatConnectedModelsPrompt(connectedModels),
492
495
  formatContactToolsPrompt(contactTools),
493
496
  assistant.defaultPrompt,
494
497
  ].filter((value) => typeof value === 'string' && value.trim()).join('\n\n');