@openchambery/web 1.19.7-beta.13 → 1.19.7-beta.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 (118) hide show
  1. package/dist/assets/{AgentAvatar-Dp_hbVcD.js → AgentAvatar-BsU82CEP.js} +1 -1
  2. package/dist/assets/AssistantView-CKNR7FFf.js +2 -0
  3. package/dist/assets/{DiagramView-iX3nhWaN.js → DiagramView-Db1VvSIR.js} +1 -1
  4. package/dist/assets/{MarkdownRendererImpl-tDry9J76.js → MarkdownRendererImpl-NBG7O4aX.js} +1 -1
  5. package/dist/assets/{MarkstreamRendererImpl-BDVGi72n.js → MarkstreamRendererImpl-Bs829ng-.js} +1 -1
  6. package/dist/assets/MobileDetailNavigation-CooM1BN7.js +1 -0
  7. package/dist/assets/{MobileShareBridge-BQ79cuPX.js → MobileShareBridge-CcG4lLaD.js} +3 -3
  8. package/dist/assets/MobileSurface-c_7RkYa2.js +2 -0
  9. package/dist/assets/{MultiRunWindow-CzMbzYD3.js → MultiRunWindow-Ds8nyikm.js} +1 -1
  10. package/dist/assets/{OnboardingScreen-CuvjeXSo.js → OnboardingScreen-BNEtPmvw.js} +1 -1
  11. package/dist/assets/SettingsView-Dac1dDOs.js +25 -0
  12. package/dist/assets/{SettingsWindow-DqwoBYvU.js → SettingsWindow-CHnMwzd6.js} +1 -1
  13. package/dist/assets/{TerminalView-4pFqXfvj.js → TerminalView-DfGueSOI.js} +1 -1
  14. package/dist/assets/{ToolOutputDialog-GmD4kIoH.js → ToolOutputDialog-BrR9iqiW.js} +1 -1
  15. package/dist/assets/{appThemeRegistry-BlEYFhXM.js → appThemeRegistry-BEG7NRFm.js} +2 -2
  16. package/dist/assets/{clipboard-Bu0lpyX1.js → clipboard-BXxLshVq.js} +1 -1
  17. package/dist/assets/context-menu-Dpjla5p4.js +1 -0
  18. package/dist/assets/{dialog-D21YndEx.js → dialog-BkhHBTd4.js} +1 -1
  19. package/dist/assets/dropdown-menu.styles-Bzo6L3FH.js +1 -0
  20. package/dist/assets/{es-C8Sqjsia.js → es-BHqnSjoZ.js} +1 -1
  21. package/dist/assets/{fr-p-Zwm_j2.js → fr-BfrlJY9i.js} +1 -1
  22. package/dist/assets/{gitApi-CciB055B.js → gitApi-Br5EdS29.js} +3 -3
  23. package/dist/assets/insertionBoundaries-ncyYkgWy.js +4 -0
  24. package/dist/assets/{ja-B6REu1qP.js → ja-DP-7qt-x.js} +1 -1
  25. package/dist/assets/{ko-CJBNH9IZ.js → ko-E2B5vGQP.js} +1 -1
  26. package/dist/assets/main-DX_9iX64.js +169 -0
  27. package/dist/assets/{main-DYFky6Uj.js → main-Dl-XWJxN.js} +2 -2
  28. package/dist/assets/miniChat-Cho-Pywx.js +2 -0
  29. package/dist/assets/mobile-1_JD1EGE.js +2 -0
  30. package/dist/assets/multirun-ByFn3xhO.js +4 -0
  31. package/dist/assets/{pl-B2cBI_7K.js → pl-r8X5gRM5.js} +1 -1
  32. package/dist/assets/projectMeta-BHw8kRhQ.js +3 -0
  33. package/dist/assets/{pt-BR-CrCc9hmt.js → pt-BR-DcpdXCf5.js} +1 -1
  34. package/dist/assets/renderElectronMiniChatApp-CvIxnUQY.js +2 -0
  35. package/dist/assets/renderMobileApp-DscicBzZ.js +28 -0
  36. package/dist/assets/{runtimeConfig-DNsnUkyp.js → runtimeConfig-Dkt3p8CZ.js} +1 -1
  37. package/dist/assets/runtimeEndpointReset-hBFsO83_.js +147 -0
  38. package/dist/assets/sessionChangeSummary-CX2MJIkc.js +1 -0
  39. package/dist/assets/sessionLookup-jCEot_fX.js +1 -0
  40. package/dist/assets/{sortable-tabs-strip-Biow4Fqu.js → sortable-tabs-strip-C5gGnikj.js} +1 -1
  41. package/dist/assets/src-KcdsQIuP.css +2 -0
  42. package/dist/assets/{store-CL3WP48T.js → store-BDFdnk6l.js} +2 -2
  43. package/dist/assets/textarea-CIO34TZz.js +1 -0
  44. package/dist/assets/{uk-C8YKAz0x.js → uk-Di0vl9V-.js} +1 -1
  45. package/dist/assets/{ChatContainer-C04WuLo8.js → useAppFontEffects-kJR0NlNH.js} +44 -39
  46. package/dist/assets/useDesktopWindowControlsLayout-BmbOASBQ.js +1 -0
  47. package/dist/assets/useEffectiveDirectory-DOToiRZd.js +1 -0
  48. package/dist/assets/useMobileNavigationStore-CEmn-d1X.js +412 -0
  49. package/dist/assets/useSessionAutoCleanup-DcmXMo-p.js +1 -0
  50. package/dist/assets/{useWorkerHighlightedLines-BaNK-wuY.js → useWorkerHighlightedLines-r5Ww4sY7.js} +1 -1
  51. package/dist/assets/{zh-CN-CCYwUnmB.js → zh-CN-coFCfeQE.js} +1 -1
  52. package/dist/assets/{zh-TW-D8Y6py08.js → zh-TW-oDRZ1-yb.js} +1 -1
  53. package/dist/index.html +3 -3
  54. package/dist/mini-chat.html +3 -3
  55. package/dist/mobile.html +3 -3
  56. package/package.json +2 -1
  57. package/server/index.js +2 -0
  58. package/server/lib/assistant-delivery-parts.js +3 -1
  59. package/server/lib/assistant-delivery-parts.test.js +11 -0
  60. package/server/lib/assistants/DOCUMENTATION.md +76 -3
  61. package/server/lib/assistants/assign.js +225 -0
  62. package/server/lib/assistants/assign.test.js +118 -0
  63. package/server/lib/assistants/bubbles.js +45 -0
  64. package/server/lib/assistants/bubbles.test.js +36 -0
  65. package/server/lib/assistants/cards.js +148 -0
  66. package/server/lib/assistants/cards.test.js +116 -0
  67. package/server/lib/assistants/contact-store.js +284 -0
  68. package/server/lib/assistants/contact-store.test.js +131 -0
  69. package/server/lib/assistants/contact-tools.js +761 -0
  70. package/server/lib/assistants/contact-tools.test.js +328 -0
  71. package/server/lib/assistants/contracts.js +20 -1
  72. package/server/lib/assistants/harness.js +551 -0
  73. package/server/lib/assistants/harness.test.js +607 -0
  74. package/server/lib/assistants/routes.js +44 -4
  75. package/server/lib/assistants/service.js +930 -17
  76. package/server/lib/assistants/service.test.js +1163 -45
  77. package/server/lib/llm/DOCUMENTATION.md +84 -0
  78. package/server/lib/llm/catalog.js +100 -0
  79. package/server/lib/llm/catalog.test.js +78 -0
  80. package/server/lib/llm/completions.js +151 -0
  81. package/server/lib/llm/completions.test.js +127 -0
  82. package/server/lib/llm/generate.js +570 -0
  83. package/server/lib/llm/generate.test.js +459 -0
  84. package/server/lib/llm/routes.js +47 -0
  85. package/server/lib/llm/temp-directory.js +37 -0
  86. package/server/lib/notifications/runtime.js +52 -0
  87. package/server/lib/notifications/runtime.test.js +39 -0
  88. package/server/lib/opencode/feature-routes-runtime.js +30 -0
  89. package/server/lib/opencode/feature-routes-runtime.test.js +20 -0
  90. package/server/lib/session-goal/DOCUMENTATION.md +8 -1
  91. package/server/lib/session-goal/runtime.js +20 -0
  92. package/server/lib/session-index/DOCUMENTATION.md +3 -1
  93. package/server/lib/session-index/service.js +10 -4
  94. package/server/lib/session-index/service.test.js +37 -0
  95. package/server/lib/session-title/runtime.js +1 -0
  96. package/server/lib/session-title/runtime.test.js +11 -0
  97. package/dist/assets/AssistantView-CQHey6je.js +0 -1
  98. package/dist/assets/ContextPanel-BJEFzgz7.js +0 -285
  99. package/dist/assets/MobileDetailNavigation-Dcl7Upvc.js +0 -1
  100. package/dist/assets/MobileSurface-CpAbBAQ5.js +0 -2
  101. package/dist/assets/SettingsView-DRiEDi23.js +0 -25
  102. package/dist/assets/insertionBoundaries-DcS10A9Q.js +0 -4
  103. package/dist/assets/main-Coj1Z4LN.js +0 -29
  104. package/dist/assets/miniChat-DK-d_3-7.js +0 -2
  105. package/dist/assets/mobile-8QzXy5wz.js +0 -2
  106. package/dist/assets/multirun-BFHWrDOR.js +0 -4
  107. package/dist/assets/projectMeta-CEGa4Czs.js +0 -3
  108. package/dist/assets/radio-Bw11T40N.js +0 -1
  109. package/dist/assets/renderElectronMiniChatApp-_fchgm-8.js +0 -2
  110. package/dist/assets/renderMobileApp-B1j1BUXB.js +0 -28
  111. package/dist/assets/runtimeEndpointReset-C-Sm4wpj.js +0 -4
  112. package/dist/assets/shikiHighlight-GaNLBDUk.js +0 -1
  113. package/dist/assets/src-BGao3JmJ.css +0 -2
  114. package/dist/assets/useAppFontEffects-D0UWTJui.js +0 -7
  115. package/dist/assets/useDesktopWindowControlsLayout-_5M7NHJ8.js +0 -1
  116. package/dist/assets/useEffectiveDirectory-DqOLKISv.js +0 -1
  117. package/dist/assets/useMobileNavigationStore-DxH1DjU-.js +0 -412
  118. package/dist/assets/useSessionAutoCleanup-Dpp0-zw2.js +0 -1
package/dist/index.html CHANGED
@@ -552,7 +552,7 @@
552
552
  opacity: 0.72;
553
553
  }
554
554
  </style>
555
- <script type="module" crossorigin src="/assets/main-DYFky6Uj.js"></script>
555
+ <script type="module" crossorigin src="/assets/main-Dl-XWJxN.js"></script>
556
556
  <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-C0FnF6B9.js">
557
557
  <link rel="modulepreload" crossorigin href="/assets/vendor-base-ui-CnHfUmDN.js">
558
558
  <link rel="modulepreload" crossorigin href="/assets/vendor-zustand-B4dlGEM2.js">
@@ -569,8 +569,8 @@
569
569
  <link rel="modulepreload" crossorigin href="/assets/terminalApi-CIIs9vFx.js">
570
570
  <link rel="modulepreload" crossorigin href="/assets/types-BuiqMmh_.js">
571
571
  <link rel="modulepreload" crossorigin href="/assets/capgoAdapter-DCowZ2ab.js">
572
- <link rel="modulepreload" crossorigin href="/assets/runtimeConfig-DNsnUkyp.js">
573
- <link rel="stylesheet" crossorigin href="/assets/src-BGao3JmJ.css">
572
+ <link rel="modulepreload" crossorigin href="/assets/runtimeConfig-Dkt3p8CZ.js">
573
+ <link rel="stylesheet" crossorigin href="/assets/src-KcdsQIuP.css">
574
574
  </head>
575
575
  <body class="h-full bg-background text-foreground">
576
576
  <!-- Keep the startup layer outside #root. React replaces every child of
@@ -74,7 +74,7 @@
74
74
  }
75
75
  </style>
76
76
 
77
- <script type="module" crossorigin src="/assets/miniChat-DK-d_3-7.js"></script>
77
+ <script type="module" crossorigin src="/assets/miniChat-Cho-Pywx.js"></script>
78
78
  <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-C0FnF6B9.js">
79
79
  <link rel="modulepreload" crossorigin href="/assets/vendor-base-ui-CnHfUmDN.js">
80
80
  <link rel="modulepreload" crossorigin href="/assets/vendor-zustand-B4dlGEM2.js">
@@ -91,8 +91,8 @@
91
91
  <link rel="modulepreload" crossorigin href="/assets/terminalApi-CIIs9vFx.js">
92
92
  <link rel="modulepreload" crossorigin href="/assets/types-BuiqMmh_.js">
93
93
  <link rel="modulepreload" crossorigin href="/assets/capgoAdapter-DCowZ2ab.js">
94
- <link rel="modulepreload" crossorigin href="/assets/runtimeConfig-DNsnUkyp.js">
95
- <link rel="stylesheet" crossorigin href="/assets/src-BGao3JmJ.css">
94
+ <link rel="modulepreload" crossorigin href="/assets/runtimeConfig-Dkt3p8CZ.js">
95
+ <link rel="stylesheet" crossorigin href="/assets/src-KcdsQIuP.css">
96
96
  </head>
97
97
  <body class="h-full bg-background text-foreground">
98
98
  <div id="root" class="h-full">
package/dist/mobile.html CHANGED
@@ -63,7 +63,7 @@
63
63
  })();
64
64
  </script>
65
65
 
66
- <script type="module" crossorigin src="/assets/mobile-8QzXy5wz.js"></script>
66
+ <script type="module" crossorigin src="/assets/mobile-1_JD1EGE.js"></script>
67
67
  <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-C0FnF6B9.js">
68
68
  <link rel="modulepreload" crossorigin href="/assets/vendor-base-ui-CnHfUmDN.js">
69
69
  <link rel="modulepreload" crossorigin href="/assets/vendor-zustand-B4dlGEM2.js">
@@ -80,8 +80,8 @@
80
80
  <link rel="modulepreload" crossorigin href="/assets/terminalApi-CIIs9vFx.js">
81
81
  <link rel="modulepreload" crossorigin href="/assets/types-BuiqMmh_.js">
82
82
  <link rel="modulepreload" crossorigin href="/assets/capgoAdapter-DCowZ2ab.js">
83
- <link rel="modulepreload" crossorigin href="/assets/runtimeConfig-DNsnUkyp.js">
84
- <link rel="stylesheet" crossorigin href="/assets/src-BGao3JmJ.css">
83
+ <link rel="modulepreload" crossorigin href="/assets/runtimeConfig-Dkt3p8CZ.js">
84
+ <link rel="stylesheet" crossorigin href="/assets/src-KcdsQIuP.css">
85
85
  </head>
86
86
  <body class="h-full bg-background text-foreground">
87
87
  <div id="root" class="h-full"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openchambery/web",
3
- "version": "1.19.7-beta.13",
3
+ "version": "1.19.7-beta.14",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "main": "./server/index.js",
@@ -25,6 +25,7 @@
25
25
  },
26
26
  "dependencies": {
27
27
  "@clack/prompts": "^1.1.0",
28
+ "@earendil-works/pi-agent-core": "0.84.4",
28
29
  "@octokit/rest": "^22.0.1",
29
30
  "@opencode-ai/sdk": "1.18.4",
30
31
  "@simplewebauthn/server": "13.3.1",
package/server/index.js CHANGED
@@ -1602,6 +1602,8 @@ async function main(options = {}) {
1602
1602
  messageQueueRuntime,
1603
1603
  globalMessageStreamHub,
1604
1604
  getServerId: () => relayService.getServerId(),
1605
+ sessionIndexService,
1606
+ notifyContactTurnComplete: (event) => notificationTriggerRuntime.sendContactTurnNotification(event),
1605
1607
  });
1606
1608
 
1607
1609
  const previewProxyRuntime = createPreviewProxyRuntime({
@@ -20,5 +20,7 @@ export const validAssistantDeliveryParts = (parts, { allowAttachmentRefs = false
20
20
  && part.text.length <= MAX_TEXT_LENGTH
21
21
  && (part.synthetic === undefined || typeof part.synthetic === 'boolean');
22
22
  }
23
- return part.type === 'file' && typeof part.mime === 'string' && part.mime.length > 0 && part.mime.length <= MAX_STRING_LENGTH && ((allowAttachmentRefs && Object.keys(part).every((key) => key === 'type' || key === 'mime' || key === 'attachmentID') && typeof part.attachmentID === 'string' && part.attachmentID.length > 0 && part.attachmentID.length <= MAX_STRING_LENGTH) || (Object.keys(part).every((key) => key === 'type' || key === 'mime' || key === 'url') && typeof part.url === 'string' && part.url.length > 0 && part.url.length <= MAX_FILE_URL_LENGTH));
23
+ const filenameOk = part.filename === undefined
24
+ || (typeof part.filename === 'string' && part.filename.length > 0 && part.filename.length <= MAX_STRING_LENGTH);
25
+ return part.type === 'file' && typeof part.mime === 'string' && part.mime.length > 0 && part.mime.length <= MAX_STRING_LENGTH && filenameOk && ((allowAttachmentRefs && Object.keys(part).every((key) => key === 'type' || key === 'mime' || key === 'attachmentID') && typeof part.attachmentID === 'string' && part.attachmentID.length > 0 && part.attachmentID.length <= MAX_STRING_LENGTH) || (Object.keys(part).every((key) => key === 'type' || key === 'mime' || key === 'url' || key === 'filename') && typeof part.url === 'string' && part.url.length > 0 && part.url.length <= MAX_FILE_URL_LENGTH));
24
26
  });
@@ -13,4 +13,15 @@ describe('validAssistantDeliveryParts', () => {
13
13
  expect(validAssistantDeliveryParts([{ type: 'text', text: 'hello', id: 'part_1' }])).toBe(false);
14
14
  expect(validAssistantDeliveryParts([{ type: 'text', text: 'hello', synthetic: 'yes' }])).toBe(false);
15
15
  });
16
+
17
+ it('accepts file parts with mime+url and optional filename', () => {
18
+ expect(validAssistantDeliveryParts([
19
+ { type: 'text', text: 'look' },
20
+ { type: 'file', mime: 'image/png', url: 'data:image/png;base64,aa', filename: 'shot.png' },
21
+ { type: 'file', mime: 'text/plain', url: 'data:text/plain;base64,eA==' },
22
+ ])).toBe(true);
23
+ expect(validAssistantDeliveryParts([
24
+ { type: 'file', mime: 'image/png', url: 'data:image/png;base64,aa', id: 'part_1' },
25
+ ])).toBe(false);
26
+ });
16
27
  });
@@ -1,16 +1,89 @@
1
1
  # Assistants Module
2
2
 
3
+ ## Acceptance criteria
4
+
5
+ The in-app Assistant is an OpenChamber-owned **contact**. OpenCode is only the LLM gateway (`POST /api/openchamber/llm/chat/completions`) using already-connected `{providerID, modelID}`. Composer send runs `@earendil-works/pi-agent-core` with `thinkingLevel: 'off'` and OpenChamber API tools only (`new_conversation`, `list_projects`, `list_sessions`, `create_assistant`, `schedule_task`, `message_assistant`, `assign_session`). It does not use `SessionPrompt` / `promptAsync` as the conversation engine, and it never attaches bash/edit/read/write or other OpenCode coding tools.
6
+
7
+ **Session cards (this PR)**
8
+
9
+ - First-class contact parts reuse one slot: `cardType` `session` | `assistant` | `schedule`.
10
+ - Persist in `assistant_contact_message` / `assistant_contact_part`. Survive reload.
11
+ - Cards are **assistant-emitted UI**, not user slash commands. The user never types `/card`. Insert: tool auto-cards (`assign_session`, `create_assistant`, `schedule_task`), and API `POST /:id/contact/cards` for harness/tests. Later project/worktree/watch/PR, inbound pushes, and summon **must** reuse this card slot.
12
+
13
+ **Assign work (this PR)**
14
+
15
+ - `assign_session` is an OpenChamber AgentTool on the contact harness. It creates or reuses a visible OpenCode/OpenChamber session on a **registered project path** (Settings projects / `getAllowedRoots()`), optionally an **existing** Chat worktree/branch via `getWorktrees`, then kicks the coding prompt into that session with `session.create` + `session.promptAsync` (same worker path as `POST /api/openchamber/conversations`).
16
+ - That worker session is the coder. The contact does not code. Assign sessions are **not** archived Assistant bindings and must not use `assistant-workspaces`.
17
+ - After a successful assign, the service persists the existing session card into the contact transcript (user bubbles first, then the card) and watches that worker session in `assistant_contact_watch`.
18
+ - Existing OpenCode SSE on the same `globalEventHub` as notifications (`session.idle`, `session.error`, `session.status`, `question.asked`, `permission.asked`) updates **the same card** (`busy` → `complete` / `error` / `question`) and appends one idempotent settle bubble (`oc.settle.*`). Session-goal settle (`emitGoalNotification`) is wired into that same reporter — not a second scheduler. Idle after `session.error` must not rewrite 失败 as 完成.
19
+ - Restart or a missed `session.idle` cannot leave the watch stuck: boot and the existing 60s reconcile timer `listInFlightWatches` and poll OpenCode `session.get` / `session.messages`. Idle, `time.completed`, or a missing session settles the same card (`busy` → 完成) and appends the existing `oc.settle.complete` bubble. `session.error` or last-assistant `info.error` settles 失败. Transport / non-404 get errors leave the watch in flight (failure is not empty success). One failed watch does not block unrelated watches. Reconcile reuses `reportAssignedSession`, so an error watch is never rewritten as complete.
20
+ - Worker sessions stamp `metadata.openchamber.assigned` (`from: 'contact'`, `assistantID`, `name`). They must **not** use `openchamber.assistant.assistantID` — that marker hides archived Assistant bindings from Chat.
21
+ - `AssistantDTO.assignedSessionIDs` lists in-flight (`busy`/`question`) watches. `AssistantDTO.working` is true only while a watch is `busy` (the Grok-Bot green avatar dot). Question/complete/error settle the running indicator.
22
+ - No registered project → clear `project_required` (ask the user to configure a project). Never fall back to managed assistant-workspaces.
23
+ - Assign must not go through the peer DM channel.
24
+ - Share/queue may still use `promptAsync` on Assistant bindings.
25
+
26
+ **Registered projects + sessions (this PR)**
27
+
28
+ - Every contact turn injects a **Registered projects** block (`id`, `path`, `label`) from `listProjects()` into the system prompt via `formatRegisteredProjectsPrompt`. The model can fuzzy-match names like "openchamber yee" without a tool call first. It must never claim it cannot see the list; empty catalog → tell the user to add a project in Settings.
29
+ - `list_projects` refreshes/filters that catalog (`query` optional). `list_sessions` searches the existing session-index (`sessionIndexService.snapshot()`) for sessions in a project (`projectPath` / `projectID`, optional title query). Results are bounded (`sessionID`, `title`, `directory`, `updatedAt`). Index/catalog failure is an error, never an empty success.
30
+ - After matching a project, open work with `assign_session` (`projectPath` and/or existing `sessionID`).
31
+
32
+ **Create assistant / schedule task (this PR)**
33
+
34
+ - `create_assistant` calls the same in-process `createAssistant` (`name`, connected OpenCode `providerID`/`modelID`, `mode: 'continuous'`). It emits an `assistant` card that opens that contact.
35
+ - `schedule_task` calls in-process `projectConfigRuntime.upsertScheduledTask` with the same payload as `PUT /api/projects/:id/scheduled-tasks` (`name`, `schedule.kind/time/timezone`, `execution.prompt` + provider/model) on a registered project, then best-effort `scheduledTasksRuntime.syncProject`. It emits a `schedule` card.
36
+ - Successful `schedule_task` also writes an assistants-owned mapping row in `assistants.sqlite` (`assistant_scheduled_task`: assistantID + projectID + taskID). Project-config execution strips unknown fields, so ownership stays out of the task payload. `GET /api/openchamber/assistants/:id/scheduled-tasks` returns those mappings joined with live `listScheduledTasks` records when possible; failed project lookups keep the mapping with `task: null`.
37
+ - `CONTACT_SYSTEM_PROMPT` + `formatContactToolsPrompt` tell the model to call these from natural language (开新对话 / 找项目 / 现有对话 / 建助理 / 建会话 / 排定时任务 / 给 X 说一声). A reply without the tool call does nothing; never say 已创建 / 已发送 unless the tool returned. Completions are messages-only (no native tools), so `parseContactToolCalls` accepts a fence, a whole-message JSON object, or a bare `{name, arguments}` object anywhere in the reply.
38
+ - If the user asked for `new_conversation` / `list_projects` / `list_sessions` / `create_assistant` / `schedule_task` / `message_assistant` / `assign_session` and the first completion has no tool call, the harness runs **one** follow-up completion whose user content is only `emit the fence now, do not claim success.` If still no tool, the turn replies that it could not complete and does not fake a card. No slash commands. No second store.
39
+
40
+ **New conversation (this PR)**
41
+
42
+ - `new_conversation` clears this assistant's OpenChamber contact transcript via `deleteContactMessages` (messages, parts, watches), bumps revision / `assistants-changed`, and confirms with a short bubble. Shared API: `POST /api/openchamber/assistants/:id/contact/reset`.
43
+ - The tool terminates the contact turn. Persist only `NEW_CONVERSATION_CONFIRM_BUBBLE`. Discard leftover assistant text and cards from the same pre-reset generation — do not re-feed that history after reset.
44
+ - It must **not** call OpenCode `session/new` / `createNew`. That path is worker binding, not contact context reset.
45
+ - Natural language: 开新对话 / new conversation / clear chat.
46
+
47
+ **Contact LLM window (this PR)**
48
+
49
+ - `contactHistoryForLlm` is a short algorithmic window documented in `contact-store.js`: `CONTACT_LLM_MAX_TURNS` (8), `CONTACT_LLM_MAX_CHARS` (6000), `CONTACT_LLM_FETCH_LIMIT` (40), `CONTACT_LLM_FILE_CHAR_WEIGHT` (80). Newest user+assistant text pairs are kept; peer / error / pure-card rows are dropped. There is no summarizer and no user-facing compress or continuous/stateless memory control. SQLite and the transcript UI may still scroll older bubbles.
50
+
51
+ **Contact attachments (this PR)**
52
+
53
+ - Attach is required on the contact composer: images and non-image files via picker, paste, and drag-drop. Same OpenCode file-part convention (`mime` + data `url`, optional `filename`) stored in `assistant_contact_part`. Do not invent a second store or route uploads through SessionPrompt / ChatInput / the message-queue attachment store.
54
+ - User bubbles persist file parts. Images render inline; other files render as a named chip. Refresh hydrates the same parts.
55
+ - The contact harness turn receives those file parts so the OpenChamber Chat Completions / OpenCode provider gateway can show images to the model.
56
+
57
+ **Inter-assistant DM (this PR, read-only)**
58
+
59
+ - `message_assistant` is the contact tool for natural language (给 PeerQA 说一声 / tell PeerQA). It calls in-process `deliverPeerMessage` and confirms with a short bubble. No card required. The user never types `/dm`.
60
+ - `POST /:fromAssistantID/contact/dm` `{ toAssistantID, text, parts? }` delivers into B’s contact transcript as `role: 'peer'` with `fromAssistantID` / `fromAssistantName`. The harness/API sends peer messages.
61
+ - Read-only: no `session.promptAsync`, no session/file/worktree mutation, no tools on the recipient. Assign is not delivered here.
62
+ - Peer rows are excluded from `contactHistoryForLlm` so they cannot become harness turns.
63
+ - This is the inbox primitive for later summon. Do not invent a second inbox.
64
+
65
+ `GET /api/openchamber/assistants/capability` remains the navigation gate.
66
+
3
67
  ## History coverage
4
68
 
5
69
  Snapshots expose the latest 50 `historySessionIDs` in chronological order and `historySessionCount` for the full count. The messages pagination endpoint owns complete history, including the current binding. A successful direct, shared, or queued OpenCode admission writes its user message and provisional parts to the Assistant SQLite mirror before the HTTP operation settles; this covered admission row survives binding replacement, restart, and an unavailable OpenCode history endpoint. Matching live parts replace provisional parts by message ID. Other event mirrors retain raw JSON as uncovered (`covered=0`) provisional fallback rows: they remain visible in archived history until an explicit shrink signal. Authoritative OpenCode backfill upserts covered rows and reconciles authoritative parts, rejects entries whose embedded session ID belongs to another session, and never deletes provisional mirrors merely because a successful page omitted them. Pagination `complete` only records that the OpenCode cursor was exhausted; it does not authorize deleting uncovered event mirrors. The only shrink whitelist is explicit `message.removed`, an authoritative `session.messages` 404 (`session-missing`), or assistant deletion. Late archived `message.updated` / `message.part.updated` and session lifecycle events (`session.idle`, `session.error`, `session.status` busy/retry/idle) call `invalidateBackfill` to reopen that session's cursor/complete without clearing covered flags. `history-state.js` owns the pure backfill reducer (`invalidate` → preserve; `page` → complete iff next cursor empty, always preserve; `session-missing` → discard provisional). Backfill persists the OpenCode `x-next-cursor` header verbatim and derives completion from its presence. A demand request commits up to three upstream pages and returns committed archived mirror rows (covered and provisional) with a stable scan-boundary cursor when more work remains. Startup begins one non-blocking current-binding backfill page. Archived rows preserve their stored directory; a legacy null directory triggers an authoritative session lookup of `data.directory` and `data.project.worktree`, with containment validation before persistence. Unresolved legacy directories remain `null` in persisted and historical response data. Ordinary mirror upserts preserve covered rows and never blanket-invalidate a session; explicit authoritative upserts elevate coverage; session-wide `invalidateCoverage` preserves every already-covered user row, including admitted rows whose local marker was replaced by live OpenCode info, while resetting other coverage on archive/re-archive; message-scoped coverage invalidation runs only after structural part deletes that require re-backfill. Removing an assistant tombstones the row and deletes its session history, message/part mirrors, and backfill state in one transaction. Schema v11 adds the mirror coverage marker.
6
70
 
7
71
  `assistants` owns Assistant configuration, session bindings, and a durable assistant-owned OpenCode message/part mirror in `assistants.sqlite`. Routes register before the generic OpenCode proxy.
8
72
 
9
- `GET /api/openchamber/assistants/capability` returns `supported`, the persisted `enabled` switch, domain revision, and relay-derived stable `serverInstanceID`. Fresh databases default `enabled` to off; Settings flips the switch, and product navigation entries stay hidden until it is on. `PUT /api/openchamber/assistants/settings` changes the global switch with `expectedRevision`. CRUD and snapshots live under `/api/openchamber/assistants`. Session routes are `POST /:id/session/ensure`, `/:id/session/new`, and `/:id/session/compact`; composer sends use `POST /:id/messages`; shares use `POST /:id/share` and `GET /share-operations/:operationID`.
73
+ `GET /api/openchamber/assistants/capability` returns `supported`, the persisted `enabled` switch, domain revision, and relay-derived stable `serverInstanceID`. Fresh databases default `enabled` to off; Settings flips the switch, and product navigation entries stay hidden until it is on. `PUT /api/openchamber/assistants/settings` changes the global switch with `expectedRevision`. CRUD and snapshots live under `/api/openchamber/assistants`. Session routes are `POST /:id/session/ensure`, `/:id/session/new`, and `/:id/session/compact`; composer sends use `POST /:id/messages` (contact harness, not `promptAsync`); contact history is `GET /:id/contact/messages`; contact reset is `POST /:id/contact/reset`; session cards are `POST /:id/contact/cards`; read-only inter-assistant DMs are `POST /:fromID/contact/dm`; assistant-owned scheduled tasks are `GET /:id/scheduled-tasks`. Shares still use `POST /:id/share` and `GET /share-operations/:operationID`.
74
+
75
+ `AssistantDTO.mode` remains `continuous` or `stateless` (default `continuous`) for share/queue OpenCode session binding. It is **not** a user-facing memory setting: Settings no longer exposes the continuous/stateless toggle, and new assistants stay continuous contact history with server-side LLM trim. `historySessionIDs` lists prior OpenCode session bindings in chronological order (oldest first, capped). Continuous Assistants keep one long-lived current session across composer turns. Stateless Assistants create and bind a fresh OpenCode session for every composer send, share admission, and queued delivery so the model executes without prior transcript context; prior sessions remain available in OpenCode history. The Assistant surface follows the current binding, so each stateless turn rematerializes onto that fresh session. `AssistantDTO.workspacePath` retains the configuration value: `null` selects the managed workspace and a string selects a registered project path. `AssistantDTO.managedWorkspacePath` always carries the canonical managed target at `dataDir/assistant-workspaces/<assistantID>`, including for project-configured Assistants. `AssistantDTO.effectiveWorkspacePath` carries the resolved OpenCode session directory. `AssistantDTO.variant` is nullable and reaches OpenCode `promptAsync` with provider, model, agent, and system prompt. `SessionBinding` is `{ sessionID, directory, sessionGeneration }` with the effective directory. `ensure` and `new` return `SessionBinding`; `compact` returns `{ binding, summarized: true }`; messages return `{ binding, messageID, admitted: true }`; shares return top-level `{ operationID, assistantID, sessionID, messageID, state, phase, attempt, leaseExpiresAt, errorCode }`. `contracts.js` exports serialization fixtures for these DTOs. Binding replacement uses generation CAS, so concurrent creation retains one winner while orphan sessions stay available in OpenCode history. Workspace changes create and bind a session in the new directory. Session metadata carries `openchamber.assistant.assistantID` and `name` as the authoritative ownership marker. New OpenCode sessions use the fixed human title `[Assistant] ${name}` and are archived via `session.update({ time: { archived } })` immediately after create and before binding is returned, so ordinary active lists never flash them; metadata isolation still applies if archive fails after create. The session index and global session lists hide system sessions by this metadata (never by title prefix). Assistant history and direct source open remain the entry points.
76
+
77
+ **Contact send admission + SSE (this PR)**
10
78
 
11
- `AssistantDTO.mode` is `continuous` or `stateless` (default `continuous`). `historySessionIDs` lists prior OpenCode session bindings in chronological order (oldest first, capped) so the UI can stitch a read-only transcript with “new conversation” dividers while the live binding stays the only writable session. Continuous Assistants keep one long-lived current session across composer turns. Stateless Assistants create and bind a fresh OpenCode session for every composer send, share admission, and queued delivery so the model executes without prior transcript context; prior sessions remain available in OpenCode history. The Assistant surface follows the current binding, so each stateless turn rematerializes onto that fresh session. `AssistantDTO.workspacePath` retains the configuration value: `null` selects the managed workspace and a string selects a registered project path. `AssistantDTO.managedWorkspacePath` always carries the canonical managed target at `dataDir/assistant-workspaces/<assistantID>`, including for project-configured Assistants. `AssistantDTO.effectiveWorkspacePath` carries the resolved OpenCode session directory. `AssistantDTO.variant` is nullable and reaches OpenCode `promptAsync` with provider, model, agent, and system prompt. `SessionBinding` is `{ sessionID, directory, sessionGeneration }` with the effective directory. `ensure` and `new` return `SessionBinding`; `compact` returns `{ binding, summarized: true }`; messages return `{ binding, messageID, admitted: true }`; shares return top-level `{ operationID, assistantID, sessionID, messageID, state, phase, attempt, leaseExpiresAt, errorCode }`. `contracts.js` exports serialization fixtures for these DTOs. Binding replacement uses generation CAS, so concurrent creation retains one winner while orphan sessions stay available in OpenCode history. Workspace changes create and bind a session in the new directory. Session metadata carries `openchamber.assistant.assistantID` and `name` as the authoritative ownership marker. New OpenCode sessions use the fixed human title `[Assistant] ${name}` and are archived via `session.update({ time: { archived } })` immediately after create and before binding is returned, so ordinary active lists never flash them; metadata isolation still applies if archive fails after create. The session index and global session lists hide system sessions by this metadata (never by title prefix). Assistant history and direct source open remain the entry points.
79
+ - `POST /api/openchamber/assistants/:id/messages` returns **202** `{ messageID, admitted: true, binding }` as soon as the **user row** is persisted (`status: complete`). It does **not** wait for the LLM turn. Validation errors still fail synchronously with 4xx before admit.
80
+ - After admit: bump revision + `openchamber:assistants-changed`, broadcast `openchamber:contact-turn-start` `{ assistantID, turnID, messageID, occurredAt }` (`turnID` = client `messageID`), then kick `runContactTurn` asynchronously (serialized per assistant).
81
+ - Contact SSE reuses `/api/openchamber/events` (no new endpoint): `openchamber:contact-bubble-delta` `{ assistantID, turnID, bubbleIndex, delta, done, occurredAt }`. Raw completion tokens are **not** painted live — they are often chain-of-thought and would appear then vanish. A short spoken preamble (e.g. 我去找一下) may appear before a tool and is kept. Planning/CoT is stripped. No-tool replies emit each stripped bubble as `{ done:true }` after parse, with a short gap between bubbles so they arrive one beat at a time.
82
+ - Turn end: strip/parse/tools (existing), persist **assistant** bubbles + cards only (never re-insert the user row), bump + assistants-changed, broadcast `openchamber:contact-turn-end` `{ assistantID, turnID, status: 'complete'|'error', error?, occurredAt }`. On failure the admitted user row stays; clients must not spin forever. Completing a contact turn also sends the same completion notification path as a normal contact: SMS-style title (assistant name) + body (spoken bubbles), via desktop / UI SSE / push, gated by `notifyOnCompletion`.
83
+ - In-process tests use `whenContactTurnSettled(messageID)` (not part of the HTTP JSON body).
84
+ - Completions: public `POST /llm/chat/completions` still rejects `stream:true`. In-process contact harness passes `onTextDelta` + `globalEventHub` through `boundCompletion` → `createChatCompletion` → throwaway generate for real tokens only.
12
85
 
13
- Composer messages carry the expected binding and client message ID. The service confirms the binding, sends through OpenCode `promptAsync`, persists the admitted user row in Assistant SQLite, and returns the binding, client message ID, and admission. OpenCode `204` is an admitted empty response. A 404 restores one generation-scoped binding before retrying. Ambiguous network errors preserve the client message ID without another send. Every committed Assistant revision publishes `openchamber:assistants-changed` only after its SQLite transaction completes; clients use the tip to reload the authoritative snapshot after worker-driven stateless binding changes. `POST /:id/session/abort` requires the same `{ sessionID, sessionGeneration }`, returns the binding and `aborted: true`, reports a changed binding as `revision_conflict`, and preserves upstream missing-session `not_found` semantics.
86
+ Composer messages carry a client message ID. The service accepts the existing OpenCode delivery parts (`{ type: 'text' }` and `{ type: 'file', mime, url, filename? }` data URLs — not a second attachment store), extracts text (`[attachment]` when the send is file-only), admits the user at 202, then runs the contact harness asynchronously (`runContactTurn` / pi-agent-core, thinking off, OpenChamber tools `new_conversation` / `list_projects` / `list_sessions` / `create_assistant` / `schedule_task` / `message_assistant` / `assign_session` only, plus the registered-projects catalog each turn) through the OpenChamber completions gateway (public HTTP non-streaming; in-process `onTextDelta` for contact SSE). Assistant bubbles and tool cards persist at turn end. File parts survive `GET /:id/contact/messages`. The harness forwards those file parts to the completions gateway. Image data URLs reach `promptAsync` only when the connected catalog marks the model as vision-capable; otherwise generate keeps the `[image: …]` description and text-file bytes so a non-vision model cannot stall the contact turn. Gateway 502 responses include `{ error, message }` so the composer can show the OpenCode error string. OpenCode `promptAsync` is not the contact conversation engine; assign uses it only on the **worker** session. Share and queued delivery still use it on Assistant bindings. OpenCode `204` is an admitted empty response. A 404 restores one generation-scoped binding before retrying. Ambiguous network errors preserve the client message ID without another send. Every committed Assistant revision publishes `openchamber:assistants-changed` only after its SQLite transaction completes; clients use the tip to reload the authoritative snapshot after worker-driven stateless binding changes. `POST /:id/session/abort` requires the same `{ sessionID, sessionGeneration }`, returns the binding and `aborted: true`, reports a changed binding as `revision_conflict`, and preserves upstream missing-session `not_found` semantics.
14
87
 
15
88
  `GET /api/openchamber/assistants/:assistantID/messages?before=&limit=` returns archived and current bindings as `{ entries: [{ sessionID, directory, info, parts }], nextCursor, complete }`. It reads assistant-owned mirror rows in chronological session and message order, pages toward older rows with an opaque stable cursor, and preserves the raw OpenCode `info` and `part` JSON. `directory` is the effective workspace for that source session and is nullable for legacy archived rows with an unknown workspace. `complete === (nextCursor === null)` marks arrival at the oldest available history. The service reads the latest persisted `limit + 1` rows first, then demand-backfills one page at a time from the newest incomplete archived session covered by the cursor; a request performs at most three upstream pages and reuses persisted pages without upstream access. Backfill page commits are independent. Each `session.messages` page attempt retries only transient 5xx/network-class SDK failures up to three times with short backoff; persistent failures still throw `upstream_error`. An authoritative OpenCode 404 for an archived or old binding marks that session's backfill complete via the `session-missing` reducer path, deletes only its uncovered event mirrors, preserves covered/admitted rows, and continues scanning other incomplete sessions so one deleted session cannot permanently block Assistant history. Successful backfill pages upsert authoritative rows without clearing provisional mirrors missing from that snapshot. Concurrent ensure that replaces a missing current binding archives the old ID and creates a new one; 404 completion never deletes covered history under either identity. A required-page failure surfaces as an upstream error only when the current request's `pageRows()` is empty; when the current range already has covered or provisional rows, the service returns that partial result and keeps the incomplete cursor retryable. Provisional event mirrors stay readable as `covered=0` fallbacks until a shrink-whitelist signal or a later authoritative upsert elevates them. Current-session live sync overrides the matching SQLite message identity in the UI.
16
89
 
@@ -0,0 +1,225 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+
5
+ export class AssignError extends Error {
6
+ constructor(code, message) {
7
+ super(message);
8
+ this.name = 'AssignError';
9
+ this.code = code;
10
+ }
11
+ }
12
+
13
+ export const ASSIGN_CODES = Object.freeze({
14
+ PROJECT_REQUIRED: 'project_required',
15
+ WORKSPACE_FORBIDDEN: 'workspace_forbidden',
16
+ WORKTREE_NOT_FOUND: 'worktree_not_found',
17
+ VALIDATION: 'validation_error',
18
+ UPSTREAM: 'upstream_error',
19
+ });
20
+
21
+ export const PROJECT_REQUIRED_MESSAGE = 'No registered project is configured. Add a project in Settings before assigning work. Do not use assistant-workspaces.';
22
+
23
+ const contained = (candidate, root) => candidate === root || candidate.startsWith(`${root}${path.sep}`);
24
+
25
+ const trim = (value, max = 10_000) => {
26
+ if (typeof value !== 'string') return null;
27
+ const next = value.trim();
28
+ if (!next || next.length > max) return null;
29
+ return next;
30
+ };
31
+
32
+ const promptAdmitted = (result) => (
33
+ !result?.error
34
+ && (result?.response?.status === 204 || result?.status === 204 || result?.data !== undefined || result?.response?.ok === true)
35
+ );
36
+
37
+ function normalizeProjectRoots(allowedRoots = []) {
38
+ const seen = new Set();
39
+ const roots = [];
40
+ for (const root of allowedRoots) {
41
+ if (typeof root !== 'string' || !root.trim()) continue;
42
+ const resolved = path.resolve(root.trim());
43
+ if (seen.has(resolved)) continue;
44
+ seen.add(resolved);
45
+ roots.push(resolved);
46
+ }
47
+ return roots;
48
+ }
49
+
50
+ export function isManagedAssistantWorkspace(candidate, managedWorkspaceRoot) {
51
+ if (!candidate || !managedWorkspaceRoot) return false;
52
+ return contained(path.resolve(candidate), path.resolve(managedWorkspaceRoot));
53
+ }
54
+
55
+ export function resolveAssignDirectory({
56
+ projectPath,
57
+ directory,
58
+ branch,
59
+ allowedRoots,
60
+ managedWorkspaceRoot,
61
+ defaultProjectPath = null,
62
+ worktrees = [],
63
+ }) {
64
+ const roots = normalizeProjectRoots(allowedRoots);
65
+ if (roots.length === 0) {
66
+ throw new AssignError(ASSIGN_CODES.PROJECT_REQUIRED, PROJECT_REQUIRED_MESSAGE);
67
+ }
68
+
69
+ const rejectManaged = (resolved) => {
70
+ if (isManagedAssistantWorkspace(resolved, managedWorkspaceRoot)) {
71
+ throw new AssignError(
72
+ ASSIGN_CODES.WORKSPACE_FORBIDDEN,
73
+ 'Assign cannot use assistant-workspaces. Choose a registered project path.',
74
+ );
75
+ }
76
+ };
77
+
78
+ const underRoots = (resolved) => roots.some((root) => contained(resolved, root));
79
+
80
+ const resolveExisting = (candidate) => {
81
+ const raw = trim(candidate, 4096);
82
+ if (!raw) return null;
83
+ const resolved = path.resolve(raw);
84
+ rejectManaged(resolved);
85
+ if (!underRoots(resolved)) {
86
+ throw new AssignError(
87
+ ASSIGN_CODES.WORKSPACE_FORBIDDEN,
88
+ 'That path is not a registered project. Add it in Settings, then assign again.',
89
+ );
90
+ }
91
+ if (!fs.existsSync(resolved) || !fs.statSync(resolved).isDirectory()) {
92
+ throw new AssignError(ASSIGN_CODES.VALIDATION, 'The project directory does not exist.');
93
+ }
94
+ try {
95
+ return fs.realpathSync(resolved);
96
+ } catch {
97
+ throw new AssignError(ASSIGN_CODES.VALIDATION, 'The project directory does not exist.');
98
+ }
99
+ };
100
+
101
+ let target = resolveExisting(directory) || resolveExisting(projectPath) || resolveExisting(defaultProjectPath);
102
+
103
+ if (!target && roots.length === 1) {
104
+ rejectManaged(roots[0]);
105
+ if (fs.existsSync(roots[0]) && fs.statSync(roots[0]).isDirectory()) {
106
+ target = fs.realpathSync(roots[0]);
107
+ }
108
+ }
109
+
110
+ if (!target) {
111
+ throw new AssignError(
112
+ ASSIGN_CODES.PROJECT_REQUIRED,
113
+ 'Choose a registered project path. Several projects are configured; assign cannot guess.',
114
+ );
115
+ }
116
+
117
+ const branchName = trim(branch, 256);
118
+ if (!branchName) return target;
119
+
120
+ const match = (Array.isArray(worktrees) ? worktrees : []).find((entry) => {
121
+ const entryBranch = trim(entry?.branch, 256);
122
+ if (!entryBranch) return false;
123
+ return entryBranch === branchName || entryBranch === `refs/heads/${branchName}`;
124
+ });
125
+ if (!match?.path) {
126
+ throw new AssignError(
127
+ ASSIGN_CODES.WORKTREE_NOT_FOUND,
128
+ `No existing worktree is checked out on ${branchName}. Create that worktree in Chat first, then assign again.`,
129
+ );
130
+ }
131
+ return resolveExisting(match.path);
132
+ }
133
+
134
+ export async function assignSession(input = {}) {
135
+ const prompt = trim(input.prompt, 200_000);
136
+ if (!prompt) {
137
+ throw new AssignError(ASSIGN_CODES.VALIDATION, 'assign_session requires a coding prompt for the worker session.');
138
+ }
139
+
140
+ const projectDirectory = resolveAssignDirectory({
141
+ projectPath: input.projectPath,
142
+ directory: input.directory,
143
+ branch: null,
144
+ allowedRoots: input.allowedRoots,
145
+ managedWorkspaceRoot: input.managedWorkspaceRoot,
146
+ defaultProjectPath: input.defaultProjectPath,
147
+ worktrees: [],
148
+ });
149
+
150
+ let directory = projectDirectory;
151
+ const branchName = trim(input.branch, 256);
152
+ if (branchName && !trim(input.directory, 4096)) {
153
+ const worktrees = typeof input.listWorktrees === 'function'
154
+ ? await input.listWorktrees(projectDirectory)
155
+ : (input.worktrees || []);
156
+ directory = resolveAssignDirectory({
157
+ projectPath: projectDirectory,
158
+ directory: projectDirectory,
159
+ branch: branchName,
160
+ allowedRoots: input.allowedRoots,
161
+ managedWorkspaceRoot: input.managedWorkspaceRoot,
162
+ worktrees,
163
+ });
164
+ }
165
+
166
+ const assistant = input.assistant && typeof input.assistant === 'object' ? input.assistant : {};
167
+ const providerID = trim(assistant.providerID, 256);
168
+ const modelID = trim(assistant.modelID, 256);
169
+ if (!providerID || !modelID) {
170
+ throw new AssignError(ASSIGN_CODES.VALIDATION, 'Assistant is missing a connected provider/model.');
171
+ }
172
+
173
+ const sessionID = trim(input.sessionID, 256);
174
+ const title = trim(input.title, 256) || prompt.slice(0, 80);
175
+ const messageID = trim(input.messageID, 256) || `msg_assign_${crypto.randomUUID()}`;
176
+ const model = { providerID, modelID };
177
+ const parts = [{ type: 'text', text: prompt }];
178
+ // Subscriber identity lives on `assigned`, not `assistant`.
179
+ // `openchamber.assistant.assistantID` marks a hidden system binding.
180
+ const metadata = {
181
+ openchamber: {
182
+ assigned: {
183
+ from: 'contact',
184
+ assistantID: assistant.id || assistant.assistantID || null,
185
+ name: assistant.name || null,
186
+ },
187
+ },
188
+ };
189
+ const promptInput = {
190
+ directory,
191
+ messageID,
192
+ model,
193
+ parts,
194
+ ...(trim(assistant.agent, 256) ? { agent: trim(assistant.agent, 256) } : {}),
195
+ ...(trim(assistant.variant, 256) ? { variant: trim(assistant.variant, 256) } : {}),
196
+ };
197
+
198
+ if (sessionID) {
199
+ if (typeof input.promptExisting !== 'function') {
200
+ throw new AssignError(ASSIGN_CODES.UPSTREAM, 'Worker prompt is unavailable.');
201
+ }
202
+ const prompted = await input.promptExisting({ ...promptInput, sessionID });
203
+ if (!promptAdmitted(prompted)) {
204
+ throw new AssignError(ASSIGN_CODES.UPSTREAM, 'Failed to submit the prompt to the existing session.');
205
+ }
206
+ return { sessionID, directory, title, status: 'busy', reused: true, messageID, branch: branchName };
207
+ }
208
+
209
+ if (typeof input.createSession !== 'function' || typeof input.promptExisting !== 'function') {
210
+ throw new AssignError(ASSIGN_CODES.UPSTREAM, 'Worker session APIs are unavailable.');
211
+ }
212
+
213
+ const created = await input.createSession({ directory, title, metadata });
214
+ const createdID = created?.data?.id || created?.id;
215
+ if (created?.error || !createdID) {
216
+ throw new AssignError(ASSIGN_CODES.UPSTREAM, 'Failed to create a coding session.');
217
+ }
218
+
219
+ const prompted = await input.promptExisting({ ...promptInput, sessionID: createdID });
220
+ if (!promptAdmitted(prompted)) {
221
+ throw new AssignError(ASSIGN_CODES.UPSTREAM, 'Failed to submit the prompt to the new session.');
222
+ }
223
+
224
+ return { sessionID: createdID, directory, title, status: 'busy', reused: false, messageID, branch: branchName };
225
+ }
@@ -0,0 +1,118 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { describe, expect, it, vi } from 'vitest';
5
+ import {
6
+ ASSIGN_CODES,
7
+ PROJECT_REQUIRED_MESSAGE,
8
+ assignSession,
9
+ isManagedAssistantWorkspace,
10
+ resolveAssignDirectory,
11
+ } from './assign.js';
12
+
13
+ const root = () => fs.mkdtempSync(path.join(os.tmpdir(), 'assign-'));
14
+
15
+ describe('resolveAssignDirectory', () => {
16
+ it('requires a registered project root', () => {
17
+ expect(() => resolveAssignDirectory({ allowedRoots: [] })).toThrowError(
18
+ expect.objectContaining({ code: ASSIGN_CODES.PROJECT_REQUIRED, message: PROJECT_REQUIRED_MESSAGE }),
19
+ );
20
+ });
21
+
22
+ it('rejects managed assistant-workspaces even when they sit under an allowed root', () => {
23
+ const directory = root();
24
+ const managed = path.join(directory, 'assistant-workspaces', 'asst_1');
25
+ fs.mkdirSync(managed, { recursive: true });
26
+ expect(isManagedAssistantWorkspace(managed, path.join(directory, 'assistant-workspaces'))).toBe(true);
27
+ expect(() => resolveAssignDirectory({
28
+ directory: managed,
29
+ allowedRoots: [directory],
30
+ managedWorkspaceRoot: path.join(directory, 'assistant-workspaces'),
31
+ })).toThrowError(expect.objectContaining({ code: ASSIGN_CODES.WORKSPACE_FORBIDDEN }));
32
+ });
33
+
34
+ it('resolves an existing worktree for a branch and refuses a missing one', () => {
35
+ const directory = root();
36
+ const project = path.join(directory, 'project');
37
+ const worktree = path.join(directory, 'worktrees', 'login');
38
+ fs.mkdirSync(project, { recursive: true });
39
+ fs.mkdirSync(worktree, { recursive: true });
40
+ expect(resolveAssignDirectory({
41
+ projectPath: project,
42
+ branch: 'feat-login',
43
+ allowedRoots: [directory],
44
+ managedWorkspaceRoot: path.join(directory, 'assistant-workspaces'),
45
+ worktrees: [{ branch: 'feat-login', path: worktree }],
46
+ })).toBe(fs.realpathSync(worktree));
47
+ expect(() => resolveAssignDirectory({
48
+ projectPath: project,
49
+ branch: 'missing',
50
+ allowedRoots: [directory],
51
+ managedWorkspaceRoot: path.join(directory, 'assistant-workspaces'),
52
+ worktrees: [{ branch: 'feat-login', path: worktree }],
53
+ })).toThrowError(expect.objectContaining({ code: ASSIGN_CODES.WORKTREE_NOT_FOUND }));
54
+ });
55
+ });
56
+
57
+ describe('assignSession', () => {
58
+ it('creates a visible worker session and prompts it', async () => {
59
+ const directory = root();
60
+ const project = path.join(directory, 'app');
61
+ fs.mkdirSync(project, { recursive: true });
62
+ const createSession = vi.fn(async (input) => ({ data: { id: 'ses_worker' }, input }));
63
+ const promptExisting = vi.fn(async () => ({ response: { status: 204 } }));
64
+ const assigned = await assignSession({
65
+ prompt: 'Fix login',
66
+ projectPath: project,
67
+ title: 'Login',
68
+ assistant: { id: 'asst_1', name: 'Ada', providerID: 'openai', modelID: 'gpt-5.2' },
69
+ allowedRoots: [directory],
70
+ managedWorkspaceRoot: path.join(directory, 'assistant-workspaces'),
71
+ createSession,
72
+ promptExisting,
73
+ });
74
+ expect(assigned).toMatchObject({
75
+ sessionID: 'ses_worker',
76
+ directory: fs.realpathSync(project),
77
+ title: 'Login',
78
+ status: 'busy',
79
+ reused: false,
80
+ });
81
+ expect(createSession).toHaveBeenCalledWith(expect.objectContaining({
82
+ directory: fs.realpathSync(project),
83
+ title: 'Login',
84
+ metadata: {
85
+ openchamber: {
86
+ assigned: { from: 'contact', assistantID: 'asst_1', name: 'Ada' },
87
+ },
88
+ },
89
+ }));
90
+ expect(createSession.mock.calls[0][0].metadata.openchamber.assistant).toBeUndefined();
91
+ expect(promptExisting).toHaveBeenCalledWith(expect.objectContaining({
92
+ sessionID: 'ses_worker',
93
+ directory: fs.realpathSync(project),
94
+ parts: [{ type: 'text', text: 'Fix login' }],
95
+ model: { providerID: 'openai', modelID: 'gpt-5.2' },
96
+ }));
97
+ });
98
+
99
+ it('reuses an existing session without creating another', async () => {
100
+ const directory = root();
101
+ fs.mkdirSync(directory, { recursive: true });
102
+ const createSession = vi.fn();
103
+ const promptExisting = vi.fn(async () => ({ response: { status: 204 } }));
104
+ const assigned = await assignSession({
105
+ prompt: 'Continue login',
106
+ sessionID: 'ses_existing',
107
+ directory,
108
+ assistant: { providerID: 'p', modelID: 'm' },
109
+ allowedRoots: [directory],
110
+ managedWorkspaceRoot: path.join(directory, 'assistant-workspaces'),
111
+ createSession,
112
+ promptExisting,
113
+ });
114
+ expect(assigned).toMatchObject({ sessionID: 'ses_existing', reused: true, status: 'busy' });
115
+ expect(createSession).not.toHaveBeenCalled();
116
+ expect(promptExisting).toHaveBeenCalledWith(expect.objectContaining({ sessionID: 'ses_existing' }));
117
+ });
118
+ });
@@ -0,0 +1,45 @@
1
+ const SENTENCE = /(?<=[.!?])\s+(?=[A-Z“"‘])/u;
2
+ const CONTACT_PLANNING = /\b(let me think|i should|i need to|i'll call|i will call|the user (said|wants|asked)|actually,?|hmm\.?|assign_session|list_projects|list_sessions|create_assistant|schedule_task|tool call|projectPath)\b/i;
3
+
4
+ /**
5
+ * A short spoken contact line (not chain-of-thought). Shown before a tool and kept.
6
+ */
7
+ export function isContactSpokenPreamble(text, { maxChars = 80 } = {}) {
8
+ if (typeof text !== 'string') return false;
9
+ const next = text.replace(/\r\n/g, '\n').trim();
10
+ if (!next || next.length > maxChars) return false;
11
+ if (/\n{2,}/u.test(next)) return false;
12
+ if (/```/.test(next)) return false;
13
+ if (CONTACT_PLANNING.test(next)) return false;
14
+ return true;
15
+ }
16
+
17
+ /**
18
+ * Split model output into short contact bubbles.
19
+ * Blank-line paragraphs first; long paragraphs split on sentence boundaries.
20
+ */
21
+ export function splitContactBubbles(text, { maxChars = 280 } = {}) {
22
+ const raw = typeof text === 'string' ? text.replace(/\r\n/g, '\n').trim() : '';
23
+ if (!raw) return [];
24
+ const paragraphs = raw.split(/\n{2,}/u).map((part) => part.trim()).filter(Boolean);
25
+ const bubbles = [];
26
+ for (const paragraph of paragraphs) {
27
+ if (paragraph.length <= maxChars) {
28
+ bubbles.push(paragraph);
29
+ continue;
30
+ }
31
+ const sentences = paragraph.split(SENTENCE).map((part) => part.trim()).filter(Boolean);
32
+ let current = '';
33
+ for (const sentence of sentences) {
34
+ const next = current ? `${current} ${sentence}` : sentence;
35
+ if (next.length > maxChars && current) {
36
+ bubbles.push(current);
37
+ current = sentence;
38
+ } else {
39
+ current = next;
40
+ }
41
+ }
42
+ if (current) bubbles.push(current);
43
+ }
44
+ return bubbles.length > 0 ? bubbles : [raw];
45
+ }