@openchambery/web 1.19.13 → 1.19.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/dist/assets/{AgentAvatar-mbvX7YlW.js → AgentAvatar-m1jNq4bX.js} +1 -1
  2. package/dist/assets/AssistantView-Dncbu_Z2.js +2 -0
  3. package/dist/assets/{DiagramView-BdI4PT2H.js → DiagramView-CovEl1BY.js} +1 -1
  4. package/dist/assets/{FadeInOnReveal-moBbL8Ll.js → FadeInOnReveal-UGwClbL_.js} +1 -1
  5. package/dist/assets/Icon-D1BcR8Tz.js +1 -0
  6. package/dist/assets/{MarkdownRenderer-CLQ4Z8Z0.js → MarkdownRenderer-CZqok-Cf.js} +2 -2
  7. package/dist/assets/{MarkdownRendererImpl-2ewbRZE2.js → MarkdownRendererImpl-CCfHY-86.js} +3 -3
  8. package/dist/assets/{MarkstreamRendererImpl-DXewO7S_.js → MarkstreamRendererImpl-y6QgIUZA.js} +2 -2
  9. package/dist/assets/MobileDetailNavigation-Ca-d5eZ9.js +1 -0
  10. package/dist/assets/MobileShareBridge-B4S7BaUj.js +4 -0
  11. package/dist/assets/MobileSurface-xL5Jby4U.js +2 -0
  12. package/dist/assets/{MultiRunWindow-C8ukIdLA.js → MultiRunWindow-BeHa-CG6.js} +1 -1
  13. package/dist/assets/{OnboardingScreen-j0iCyH7f.js → OnboardingScreen-bIWeMLVx.js} +1 -1
  14. package/dist/assets/{OverlayScrollbar-CDC5B3RH.js → OverlayScrollbar-Bahg_P3r.js} +1 -1
  15. package/dist/assets/SettingsView-Dn7odtQz.js +25 -0
  16. package/dist/assets/{SettingsWindow-CSePZ_V8.js → SettingsWindow-C0tWPS5f.js} +1 -1
  17. package/dist/assets/{TerminalView-BmSIrZ81.js → TerminalView-BnJgC7Qo.js} +1 -1
  18. package/dist/assets/{ToolOutputDialog-2BYaWLo1.js → ToolOutputDialog-D9S-X8hM.js} +1 -1
  19. package/dist/assets/{appThemeRegistry-Dk4gBaOu.js → appThemeRegistry-DBrvaJnq.js} +1 -1
  20. package/dist/assets/{button-xx1UOBA5.js → button-9R-WgVMl.js} +1 -1
  21. package/dist/assets/{clipboard-CUasgPiF.js → clipboard-GUUNlWIv.js} +1 -1
  22. package/dist/assets/{collapsible-CACHBRYZ.js → collapsible-Bbz9KiJf.js} +1 -1
  23. package/dist/assets/{context-menu-CYbm4wBZ.js → context-menu-CSyOhRz8.js} +1 -1
  24. package/dist/assets/{desktopHosts-DILvrAok.js → desktopHosts-B052a4UK.js} +1 -1
  25. package/dist/assets/{desktopRelayRestore-t8asCr3m.js → desktopRelayRestore-OjKNXr10.js} +1 -1
  26. package/dist/assets/{diagram-u0BC9uuY.js → diagram-NdsZih8P.js} +1 -1
  27. package/dist/assets/{dialog-BwQ3rY_x.js → dialog-DhMGbF_B.js} +1 -1
  28. package/dist/assets/{es-CRjOwvYM.js → es-BQb6I8gl.js} +2 -2
  29. package/dist/assets/fileMentionTouchSelection-Dc004xOh.js +4 -0
  30. package/dist/assets/{fileQueries-Bgly-E8f.js → fileQueries-B_s_B-fH.js} +1 -1
  31. package/dist/assets/{fr-D9taEN9L.js → fr-BjGouC_l.js} +2 -2
  32. package/dist/assets/{gitApi-DtPf-h-O.js → gitApi-Cm3JLQqh.js} +3 -3
  33. package/dist/assets/{input-CI730njs.js → input-Cd_1HyQv.js} +1 -1
  34. package/dist/assets/insertionBoundaries-CTWYFsbH.js +1 -0
  35. package/dist/assets/{ja-CYQ5aoWp.js → ja-Cw9zliYY.js} +2 -2
  36. package/dist/assets/{ko-BwASV0Q0.js → ko-Dyz0Dk9b.js} +2 -2
  37. package/dist/assets/main-CSqu3MB4.js +169 -0
  38. package/dist/assets/{main-Ci3JmtpW.js → main-DwMprEyr.js} +2 -2
  39. package/dist/assets/{markdownHeightCache-DEjGKLv8.js → markdownHeightCache-BL8CUQ0M.js} +1 -1
  40. package/dist/assets/miniChat-C8bzY_Ye.js +2 -0
  41. package/dist/assets/mobile-Oy-SRKkr.js +2 -0
  42. package/dist/assets/{multirun-CmaTeZON.js → multirun-ClcuaiKU.js} +1 -1
  43. package/dist/assets/{persistence-YbQKzHUQ.js → persistence-DwRBC4zl.js} +2 -2
  44. package/dist/assets/{pl-DUI1vPil.js → pl-C7EY-dTH.js} +1 -1
  45. package/dist/assets/platform-DQ5_LNKO.js +2 -0
  46. package/dist/assets/{projectMeta-cw9nrwpr.js → projectMeta-F93wE2Ye.js} +2 -2
  47. package/dist/assets/{pt-BR-CZQFJllP.js → pt-BR-B1uHpjFW.js} +2 -2
  48. package/dist/assets/renderElectronMiniChatApp-rOOhMVlQ.js +2 -0
  49. package/dist/assets/renderMobileApp-BP_hGwHP.js +28 -0
  50. package/dist/assets/{runtimeConfig-DI8l0VyZ.js → runtimeConfig-iJ2mTBVN.js} +1 -1
  51. package/dist/assets/runtimeEndpointReset-ISnhAS3q.js +147 -0
  52. package/dist/assets/sessionChangeSummary-BAzU2J1H.js +2 -0
  53. package/dist/assets/sessionLookup-DQQ9-DuP.js +1 -0
  54. package/dist/assets/{sortable-tabs-strip-Dom6Sthu.js → sortable-tabs-strip-dYW6SH62.js} +1 -1
  55. package/dist/assets/src-DYeppLfP.css +2 -0
  56. package/dist/assets/{store-CSnH_QXM.js → store-ClnJdJSj.js} +3 -3
  57. package/dist/assets/{terminalApi-noa4Cdbh.js → terminalApi-CfgOfnZk.js} +1 -1
  58. package/dist/assets/{textarea-BfNtUdJE.js → textarea-B9CmN13Z.js} +1 -1
  59. package/dist/assets/{tooltip-DPxumadF.js → tooltip-DSjr26A_.js} +1 -1
  60. package/dist/assets/{uk-C1FYZlmA.js → uk-DgG8v2md.js} +2 -2
  61. package/dist/assets/{url-CVhsI9kR.js → url-CMQwHq4k.js} +1 -1
  62. package/dist/assets/{useAppFontEffects-BDFUND4v.js → useAppFontEffects--IcU7jyv.js} +43 -44
  63. package/dist/assets/useDesktopWindowControlsLayout-BqJ5R_T2.js +1 -0
  64. package/dist/assets/{useEffectiveDirectory-e9HjIqGz.js → useEffectiveDirectory-ChWXV8xM.js} +1 -1
  65. package/dist/assets/{useFontPreferences-CB-cGo3I.js → useFontPreferences-DCaY8NbU.js} +1 -1
  66. package/dist/assets/useMobileNavigationStore-DQkgxuzg.js +413 -0
  67. package/dist/assets/useSessionAutoCleanup-DWWAvQQ0.js +2 -0
  68. package/dist/assets/{useWorkerHighlightedLines-DTGE0CDs.js → useWorkerHighlightedLines-D2oQszsk.js} +1 -1
  69. package/dist/assets/{utils-BHTVlVov.js → utils-2ZK8yboQ.js} +1 -1
  70. package/dist/assets/{vendor-reactuses-core-DDoDDyM9.js → vendor-reactuses-core-BnW6GxJt.js} +1 -1
  71. package/dist/assets/{zh-CN-C96qP1ip.js → zh-CN-C6OFKjeT.js} +2 -2
  72. package/dist/assets/{zh-TW-BsRk1Rbb.js → zh-TW-BVP99aMr.js} +2 -2
  73. package/dist/index.html +8 -8
  74. package/dist/mini-chat.html +8 -8
  75. package/dist/mobile.html +8 -8
  76. package/package.json +1 -1
  77. package/server/index.js +1 -0
  78. package/server/lib/assistants/DOCUMENTATION.md +93 -16
  79. package/server/lib/assistants/assign.js +117 -12
  80. package/server/lib/assistants/assign.test.js +79 -0
  81. package/server/lib/assistants/bubbles.js +1 -1
  82. package/server/lib/assistants/contact-store.js +496 -11
  83. package/server/lib/assistants/contact-store.test.js +323 -7
  84. package/server/lib/assistants/contact-tools.js +303 -52
  85. package/server/lib/assistants/contact-tools.test.js +239 -7
  86. package/server/lib/assistants/contracts.js +31 -0
  87. package/server/lib/assistants/harness.js +178 -50
  88. package/server/lib/assistants/harness.test.js +428 -3
  89. package/server/lib/assistants/routes.js +3 -0
  90. package/server/lib/assistants/service.js +586 -43
  91. package/server/lib/assistants/service.test.js +696 -7
  92. package/server/lib/llm/DOCUMENTATION.md +2 -0
  93. package/server/lib/llm/completions.js +4 -0
  94. package/server/lib/llm/completions.test.js +3 -0
  95. package/server/lib/llm/generate.js +12 -5
  96. package/server/lib/llm/generate.test.js +40 -0
  97. package/server/lib/opencode/feature-routes-runtime.js +5 -0
  98. package/dist/assets/AssistantView-CKmIS1cS.js +0 -2
  99. package/dist/assets/Icon-CkzzhaLr.js +0 -1
  100. package/dist/assets/MobileDetailNavigation-BGaAyuBH.js +0 -1
  101. package/dist/assets/MobileShareBridge-BAZoyeIP.js +0 -4
  102. package/dist/assets/MobileSurface-BVb3xNWt.js +0 -2
  103. package/dist/assets/SettingsView-CkQ61pbr.js +0 -25
  104. package/dist/assets/insertionBoundaries-BMhzPFft.js +0 -4
  105. package/dist/assets/main-BjeOhdcG.js +0 -169
  106. package/dist/assets/miniChat-BYrjYEXJ.js +0 -2
  107. package/dist/assets/mobile-BVMNnPK2.js +0 -2
  108. package/dist/assets/platform-DzsGqlGW.js +0 -2
  109. package/dist/assets/renderElectronMiniChatApp-COwia8Cs.js +0 -2
  110. package/dist/assets/renderMobileApp-CXwwpY1t.js +0 -28
  111. package/dist/assets/runtimeEndpointReset-BkAQXmXJ.js +0 -147
  112. package/dist/assets/sessionChangeSummary-DQDZ5HW-.js +0 -1
  113. package/dist/assets/sessionLookup-Imc995rW.js +0 -1
  114. package/dist/assets/src-AA-5nXsF.css +0 -2
  115. package/dist/assets/useDesktopWindowControlsLayout-C5hZD1co.js +0 -1
  116. package/dist/assets/useMobileNavigationStore-CYbDxRTM.js +0 -413
  117. package/dist/assets/useSessionAutoCleanup-BGy9_AhN.js +0 -2
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-Ci3JmtpW.js"></script>
555
+ <script type="module" crossorigin src="/assets/main-DwMprEyr.js"></script>
556
556
  <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-C0FnF6B9.js">
557
557
  <link rel="modulepreload" crossorigin href="/assets/runtime-auth-D66yq6ie.js">
558
558
  <link rel="modulepreload" crossorigin href="/assets/runtime-switch-LvWDU-SL.js">
@@ -563,17 +563,17 @@
563
563
  <link rel="modulepreload" crossorigin href="/assets/vendor-pathe-vBmvQE8f.js">
564
564
  <link rel="modulepreload" crossorigin href="/assets/vendor-react-DLPElpoQ.js">
565
565
  <link rel="modulepreload" crossorigin href="/assets/vendor-tanstack-query-core-BWz0ose6.js">
566
- <link rel="modulepreload" crossorigin href="/assets/persistence-YbQKzHUQ.js">
567
- <link rel="modulepreload" crossorigin href="/assets/desktopHosts-DILvrAok.js">
568
- <link rel="modulepreload" crossorigin href="/assets/desktopRelayRestore-t8asCr3m.js">
566
+ <link rel="modulepreload" crossorigin href="/assets/persistence-DwRBC4zl.js">
567
+ <link rel="modulepreload" crossorigin href="/assets/desktopHosts-B052a4UK.js">
568
+ <link rel="modulepreload" crossorigin href="/assets/desktopRelayRestore-OjKNXr10.js">
569
569
  <link rel="modulepreload" crossorigin href="/assets/vendor-opencode-sdk-C07HQypa.js">
570
570
  <link rel="modulepreload" crossorigin href="/assets/vendor-aparajita-capacitor-secure-storage-CjQdwfjS.js">
571
- <link rel="modulepreload" crossorigin href="/assets/platform-DzsGqlGW.js">
572
- <link rel="modulepreload" crossorigin href="/assets/terminalApi-noa4Cdbh.js">
571
+ <link rel="modulepreload" crossorigin href="/assets/platform-DQ5_LNKO.js">
572
+ <link rel="modulepreload" crossorigin href="/assets/terminalApi-CfgOfnZk.js">
573
573
  <link rel="modulepreload" crossorigin href="/assets/types-BuiqMmh_.js">
574
574
  <link rel="modulepreload" crossorigin href="/assets/capgoAdapter-DCowZ2ab.js">
575
- <link rel="modulepreload" crossorigin href="/assets/runtimeConfig-DI8l0VyZ.js">
576
- <link rel="stylesheet" crossorigin href="/assets/src-AA-5nXsF.css">
575
+ <link rel="modulepreload" crossorigin href="/assets/runtimeConfig-iJ2mTBVN.js">
576
+ <link rel="stylesheet" crossorigin href="/assets/src-DYeppLfP.css">
577
577
  </head>
578
578
  <body class="h-full bg-background text-foreground">
579
579
  <!-- 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-BYrjYEXJ.js"></script>
77
+ <script type="module" crossorigin src="/assets/miniChat-C8bzY_Ye.js"></script>
78
78
  <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-C0FnF6B9.js">
79
79
  <link rel="modulepreload" crossorigin href="/assets/runtime-auth-D66yq6ie.js">
80
80
  <link rel="modulepreload" crossorigin href="/assets/runtime-switch-LvWDU-SL.js">
@@ -85,17 +85,17 @@
85
85
  <link rel="modulepreload" crossorigin href="/assets/vendor-pathe-vBmvQE8f.js">
86
86
  <link rel="modulepreload" crossorigin href="/assets/vendor-react-DLPElpoQ.js">
87
87
  <link rel="modulepreload" crossorigin href="/assets/vendor-tanstack-query-core-BWz0ose6.js">
88
- <link rel="modulepreload" crossorigin href="/assets/persistence-YbQKzHUQ.js">
89
- <link rel="modulepreload" crossorigin href="/assets/desktopHosts-DILvrAok.js">
90
- <link rel="modulepreload" crossorigin href="/assets/desktopRelayRestore-t8asCr3m.js">
88
+ <link rel="modulepreload" crossorigin href="/assets/persistence-DwRBC4zl.js">
89
+ <link rel="modulepreload" crossorigin href="/assets/desktopHosts-B052a4UK.js">
90
+ <link rel="modulepreload" crossorigin href="/assets/desktopRelayRestore-OjKNXr10.js">
91
91
  <link rel="modulepreload" crossorigin href="/assets/vendor-opencode-sdk-C07HQypa.js">
92
92
  <link rel="modulepreload" crossorigin href="/assets/vendor-aparajita-capacitor-secure-storage-CjQdwfjS.js">
93
- <link rel="modulepreload" crossorigin href="/assets/platform-DzsGqlGW.js">
94
- <link rel="modulepreload" crossorigin href="/assets/terminalApi-noa4Cdbh.js">
93
+ <link rel="modulepreload" crossorigin href="/assets/platform-DQ5_LNKO.js">
94
+ <link rel="modulepreload" crossorigin href="/assets/terminalApi-CfgOfnZk.js">
95
95
  <link rel="modulepreload" crossorigin href="/assets/types-BuiqMmh_.js">
96
96
  <link rel="modulepreload" crossorigin href="/assets/capgoAdapter-DCowZ2ab.js">
97
- <link rel="modulepreload" crossorigin href="/assets/runtimeConfig-DI8l0VyZ.js">
98
- <link rel="stylesheet" crossorigin href="/assets/src-AA-5nXsF.css">
97
+ <link rel="modulepreload" crossorigin href="/assets/runtimeConfig-iJ2mTBVN.js">
98
+ <link rel="stylesheet" crossorigin href="/assets/src-DYeppLfP.css">
99
99
  </head>
100
100
  <body class="h-full bg-background text-foreground">
101
101
  <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-BVMNnPK2.js"></script>
66
+ <script type="module" crossorigin src="/assets/mobile-Oy-SRKkr.js"></script>
67
67
  <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-C0FnF6B9.js">
68
68
  <link rel="modulepreload" crossorigin href="/assets/runtime-auth-D66yq6ie.js">
69
69
  <link rel="modulepreload" crossorigin href="/assets/runtime-switch-LvWDU-SL.js">
@@ -74,17 +74,17 @@
74
74
  <link rel="modulepreload" crossorigin href="/assets/vendor-pathe-vBmvQE8f.js">
75
75
  <link rel="modulepreload" crossorigin href="/assets/vendor-react-DLPElpoQ.js">
76
76
  <link rel="modulepreload" crossorigin href="/assets/vendor-tanstack-query-core-BWz0ose6.js">
77
- <link rel="modulepreload" crossorigin href="/assets/persistence-YbQKzHUQ.js">
78
- <link rel="modulepreload" crossorigin href="/assets/desktopHosts-DILvrAok.js">
79
- <link rel="modulepreload" crossorigin href="/assets/desktopRelayRestore-t8asCr3m.js">
77
+ <link rel="modulepreload" crossorigin href="/assets/persistence-DwRBC4zl.js">
78
+ <link rel="modulepreload" crossorigin href="/assets/desktopHosts-B052a4UK.js">
79
+ <link rel="modulepreload" crossorigin href="/assets/desktopRelayRestore-OjKNXr10.js">
80
80
  <link rel="modulepreload" crossorigin href="/assets/vendor-opencode-sdk-C07HQypa.js">
81
81
  <link rel="modulepreload" crossorigin href="/assets/vendor-aparajita-capacitor-secure-storage-CjQdwfjS.js">
82
- <link rel="modulepreload" crossorigin href="/assets/platform-DzsGqlGW.js">
83
- <link rel="modulepreload" crossorigin href="/assets/terminalApi-noa4Cdbh.js">
82
+ <link rel="modulepreload" crossorigin href="/assets/platform-DQ5_LNKO.js">
83
+ <link rel="modulepreload" crossorigin href="/assets/terminalApi-CfgOfnZk.js">
84
84
  <link rel="modulepreload" crossorigin href="/assets/types-BuiqMmh_.js">
85
85
  <link rel="modulepreload" crossorigin href="/assets/capgoAdapter-DCowZ2ab.js">
86
- <link rel="modulepreload" crossorigin href="/assets/runtimeConfig-DI8l0VyZ.js">
87
- <link rel="stylesheet" crossorigin href="/assets/src-AA-5nXsF.css">
86
+ <link rel="modulepreload" crossorigin href="/assets/runtimeConfig-iJ2mTBVN.js">
87
+ <link rel="stylesheet" crossorigin href="/assets/src-DYeppLfP.css">
88
88
  </head>
89
89
  <body class="h-full bg-background text-foreground">
90
90
  <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.13",
3
+ "version": "1.19.14",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "main": "./server/index.js",
package/server/index.js CHANGED
@@ -1596,6 +1596,7 @@ async function main(options = {}) {
1596
1596
  formatSettingsResponse,
1597
1597
  readSettingsFromDisk,
1598
1598
  readSettingsFromDiskMigrated,
1599
+ readSettingsFromDiskStrict,
1599
1600
  persistSettings,
1600
1601
  sanitizeProjects,
1601
1602
  sanitizeSkillCatalogs,
@@ -8,39 +8,61 @@ admission keeps the worker and confirms the exact message ID before reporting
8
8
  success. Attachment deduplication uses ordered content digests; reordering
9
9
  attachments is a different request.
10
10
 
11
- 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'`. When the assistant has an effective workspace, the harness attaches pi's four coding tools (`read`, `write`, `edit`, `bash`) via `NodeExecutionEnv` in that directory, applies `defaultPrompt`, and merges skills from `~/.claude/skills`, `~/.agents/skills`, plus the project's `.claude/skills` and `.agents/skills` (project wins on name). OpenChamber API tools remain (`new_conversation`, `clear_chat_history`, `list_projects`, `list_sessions`, `get_assistant_settings`, `update_default_prompt`, `create_assistant`, `schedule_task`, `message_assistant`, `assign_session`). It does not use `SessionPrompt` / `promptAsync` as the conversation engine. The LLM gateway throwaway session still denies OpenCode tools (`session.create` PermissionRuleset deny-all + `promptAsync` tools map + hidden `openchamber-llm` agent `permission: { "*": deny }`); file and shell work runs in-process through pi. The contact system prompt includes **full argument schemas** for both pi coding tools and OpenChamber contact tools. Skill directory entries are instructions loaded via `read`, not native/MCP tool names.
11
+ 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'`. When the assistant has an effective workspace, the harness attaches pi's four coding tools (`read`, `write`, `edit`, `bash`) via `NodeExecutionEnv` in that directory, applies `defaultPrompt`, and merges skills from `~/.claude/skills`, `~/.agents/skills`, plus the project's `.claude/skills` and `.agents/skills` (project wins on name). OpenChamber API tools remain (`new_conversation`, `clear_chat_history`, `list_projects`, `list_sessions`, `get_assistant_settings`, `update_default_prompt`, `create_assistant`, `schedule_task`, `message_assistant`, `assign_session`, `watch_session`, `read_session`, `steer_session`, `stop_session`, `archive_session`, `delete_session`). It does not use `SessionPrompt` / `promptAsync` as the conversation engine. The LLM gateway throwaway session still denies OpenCode tools (`session.create` PermissionRuleset deny-all + `promptAsync` tools map + hidden `openchamber-llm` agent `permission: { "*": deny }`); file and shell work runs in-process through pi. The contact system prompt includes **full argument schemas** for both pi coding tools and OpenChamber contact tools. Skill directory entries are instructions loaded via `read`, not native/MCP tool names.
12
12
 
13
- Contact turns retain all ten OpenChamber operation tools alongside the four
13
+ User contact turns retain all sixteen OpenChamber operation tools alongside the four
14
14
  pi tools. Skill discovery contributes instructions to this combined tool set.
15
- Assignment intent includes “建个会话” and “再建个会话”. The single missed-call
15
+ Assignment intent includes “建个会话” and “再建个会话”. Watch intent includes
16
+ “监听会话”; stop intent includes “停止会话”. The single missed-call
16
17
  retry checks for a result from a requested operation tool; a `read` result alone
17
18
  leaves an assignment request eligible for retry. A successful contact reset
18
19
  retains its terminal semantics. Tool failures count as attempted operations
19
20
  and keep their existing error result handling.
20
21
 
22
+ **User interruption is terminal for the current watch**
23
+
24
+ - Authoritative OpenCode `MessageAbortedError` on `session.error` or assistant `message.updated` settles the watched card as `cancelled`, not a retryable failure. Reconciliation and the pre-continuation worker-message check recognize the same error. Successful `stop_session` also cancels the watch when upstream emits no event.
25
+ - `cancelled` is durable in the existing watch/card records and excluded from in-flight reconciliation. Later idle, busy, and error events cannot rearm it. Only an explicit assign/watch admission overwrites the cancelled watch for a new run. No schema migration is needed: watch/card status is an open string.
26
+ - Pending/running automatic continuations own cancellation controllers. A worker interruption aborts those continuations, including already queued ones; a newly admitted user message supersedes that assistant's existing automatic continuations. Other assistants/workers remain independent. Cancelled continuations clear activity and settle with `cancelled` without adding a retry, error bubble, or late response.
27
+ - The signal reaches pi tools and model generation. The stream adapter checks before/after model requests and before protocol retries, preventing a late tool response from executing. An already completed side effect is not rolled back. Cancellation is authoritative runtime state, not a language-model interpretation of “stop”.
28
+
29
+ **Workspace response and loop protocol**
30
+
31
+ - Protocol correction never replays rejected assistant prose into model history: simulated User/Assistant turns and tool-result records are rejected as fabricated evidence. Known rejected coding calls cannot be skipped with a subsequent `complete` declaration; the model must issue actual calls or report a blocker. Corrections remain bounded and exhaustion is an error, not a completion.
32
+
33
+ - A workspace-backed contact keeps running until it emits coding calls or an explicit `openchamber-final` JSON fence with `{ "status": "complete" | "blocked", "text": "..." }`. Final declarations contain the user-facing answer only; missing input/questions use `blocked`. Ordinary conversation can finish without tools using the same declaration. Reading a skill loads instructions and does not itself complete the user's task. Contacts without coding tools retain their plain-text reply contract.
34
+ - Plain progress text is not completion. The stream adapter requests at most two protocol corrections for a rejected response, retaining actual prior calls/results. It emits no bubbles and executes no calls from the rejected response. Exhaustion becomes an upstream error, not a successful reply. This guard enforces the response protocol; a model's explicit completion claim is not independent verification of arbitrary task semantics.
35
+ - `parseContactToolCalls` returns an ordered `toolCalls` array. It accepts application fences, complete generic JSON fences, and bare/embedded call objects. Nested argument objects and quoted markdown fences remain data. An invalid/unavailable/unclosed explicit application call rejects the entire response before any prefix can execute.
36
+ - Multiple coding calls run sequentially through pi (`toolExecution: 'sequential'`), preserving dependencies such as write then read. OpenChamber operation tools must be sent one at a time: mixed/multiple-operation batches are rejected before execution so terminal assign/reset/watch semantics and side-effect gates remain intact.
37
+ - Completion history retains every assistant tool call (name, arguments, generated call ID) and its real tool result, including empty content and `isError`. Results still use gateway-supported user messages labeled `OpenChamber tool result name=... call=...` with a JSON body. Model-written result lookalikes are never executed or admitted as actual tool results. Final JSON text is opaque: quoted tool examples in a final answer cannot execute.
38
+ - Malformed calls or mixed final declarations use the same bounded correction path. Only a validated tool response or final reply reaches the pi stream; protocol corrections do not replay tools already executed. Runtime file/shell cleanup remains in `finally`.
39
+
21
40
  **Session cards (this PR)**
22
41
 
23
42
  - First-class contact parts reuse one slot: `cardType` `session` | `assistant` | `schedule`.
24
43
  - Persist in `assistant_contact_message` / `assistant_contact_part`. Survive reload.
25
- - 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.
44
+ - Cards are **assistant-emitted UI**, not user slash commands. The user never types `/card`. Insert: tool auto-cards (`assign_session`, `watch_session`, `create_assistant`, `schedule_task`), and API `POST /:id/contact/cards` for harness/tests. Later project/worktree/PR, inbound pushes, and summon **must** reuse this card slot.
26
45
 
27
46
  **Assign work (this PR)**
28
47
 
29
48
  - `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`).
30
49
  - That worker session is the coder. The contact does not code. Assign sessions are **not** archived Assistant bindings and must not use `assistant-workspaces`.
31
- - **Worker model selection:** optional tool args `providerID` / `modelID` / `model` resolve against the live connected OpenCode catalog (`loadConnectedCatalog` / `GET /provider` + `GET /config/providers`). Explicit selection must match uniquely missing or ambiguous names fail closed (`model_not_found` / `model_ambiguous`) with **no silent fallback**. Omitting model args on a **new** worker keeps the contact assistant's default provider/model. **Reusing `sessionID` without an explicit model** follows that session's last model (`session.get` → `session.model` `{ providerID, id|modelID }`, else newest user/assistant `info.model`) when it is still an exact providerID+modelID match in the connected catalog (`source: 'session'`); missing session model, catalog miss, or lookup failure **degrades** to the assistant default (`source: 'assistant'`) and never fails assign. Selecting a worker model **never** mutates the contact assistant row.
32
- - Every contact turn injects a **Connected OpenCode models** block (providerID, modelID, name, acceptsImages) so the model can discover worker targets without another contact tool. Still ten OpenChamber contact tools + four pi coding tools.
50
+ - **Reuse / continue existing session:** `assign_session` with `sessionID` + coding prompt continues that worker. Before prompt, the service resolves directory/project from **authoritative** session metadata (session index directory → `session.get` payload registered project match). User-supplied `projectPath`/`directory` are lookup **hints only** and never override scope. Missing/unregistered/failed session lookup **fails closed** (not empty success). Prior worker model/config semantics stay: omit model args to follow the session model when still connected.
51
+ - **`watch_session`:** listen-only attach for an existing session (`sessionID` required). No `promptAsync`. Emits the same session card slot and reuses `assistant_contact_watch` + `reportAssignedSession` / reconcile / resume. Baseline card/watch status comes from live `session.get` (+ messages when needed) via `mapSessionToWatchStatus` so an already-terminal idle is **not** treated as a fresh complete transition (no false resume). In-flight baseline (`busy`/`question`) keeps the watch until real settle. A plain user `@session:id` (or JSON title/sessionID/directory) reference is **context only** — it does not auto-call `watch_session`; the model must hear an explicit listen/watch/monitor intent.
52
+ - **`stop_session`:** calls real OpenCode `session.abort` for `sessionID` after the same authoritative directory resolve. Returns the true abort result — transport/`not_found`/upstream errors stay errors (never claim stopped on failure). On successful abort the service marks the watch/card `cancelled` even when upstream emits no idle/error event. Idle/error events still flow through the existing watch reporter when a watch exists.
53
+ - **Worker model selection:** optional tool args `providerID` / `modelID` / `model` resolve against the live connected OpenCode catalog (`loadConnectedCatalog` / `GET /provider` + `GET /config/providers`). Explicit selection must match uniquely — missing or ambiguous names fail closed (`model_not_found` / `model_ambiguous`) with **no silent fallback**. Omitting model args on a **new** worker keeps the contact assistant's default provider/model. **Reusing `sessionID` without an explicit model** follows that session's last model (`session.get` → `session.model` `{ providerID, id|modelID }`, else newest user/assistant `info.model`) when it is still an exact providerID+modelID match in the connected catalog (`source: 'session'`); missing session model, catalog miss, or lookup failure **degrades** to the assistant default (`source: 'assistant'`) and never fails assign once the session itself resolved. Selecting a worker model **never** mutates the contact assistant row.
54
+ - Every user or automatic contact turn reloads the current instance's **Connected OpenCode models** block (exact providerID, modelID, providerName, name, acceptsImages), plus settings-backed favorite models in saved order and recent models newest first. Only connected catalog entries qualify as preference candidates; variants remain separate from model IDs. Catalog and preference read failures remain explicit unknown states, distinct from successful empty lists; either read can succeed independently. The route projects only favoriteModels/recentModels from the strict settings reader, never arbitrary settings or credentials. No catalog is cached across turns or runtime switches. Informal `model` aliases normalize punctuation/spacing and require a unique catalog match; explicit `modelID` stays exact. Ambiguous aliases never silently prefer a favorite/provider. This server-owned context is shared by web, desktop, and mobile clients of that instance.
33
55
  - **Current-turn attachments:** server-authoritative `userParts` file parts from this contact turn are forwarded into worker `promptAsync` parts by default (including “just open a session” intents). The model must not base64-encode or invent local paths. History images are **not** unbounded-forwarded. Image parts require a vision-capable catalog entry (`acceptsImages`); otherwise assign fails with `image_not_supported` before create. Sensitive file bodies are never logged.
34
56
  - **Create-then-prompt failure:** if `session.create` succeeds and `promptAsync` fails, assign best-effort `session.delete`s the new worker so empty sessions are not left behind. Reused `sessionID` paths never delete on prompt failure.
35
- - Successful `assign_session` returns `terminate: true` so the pi agent loop does **not** auto-follow-up the LLM (prevents a looping model from re-assigning dozens of times in one turn). Prerequisite tools (`list_projects` / `list_sessions`) may still run before assign in the same turn; multi-operation user intents remain ordered **across turns** after a successful assign.
57
+ - Successful `assign_session` and `watch_session` return `terminate: true` so the pi agent loop does **not** auto-follow-up the LLM (prevents a looping model from re-assigning dozens of times in one turn). Prerequisite tools (`list_projects` / `list_sessions`) may still run before assign/watch/stop in the same turn; multi-operation user intents remain ordered **across turns** after a successful assign/watch.
36
58
  - Same contact-turn assign gate (tools instance lifetime only, not cross-turn): identical args after success replay the cached success result (no second worker); different args after success, or parallel mismatched args, fail closed with a validation error. The idempotent key includes worker model selection and current-turn attachment scope. Failure clears the gate so a corrected retry may run. Parallel identical args share one in-flight promise.
37
- - 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`. The user-facing outcome is the session card plus any short **spoken** preamble; English tool-result confirms (`Opened a coding session.`, `Created assistant…`, `Sent to…`) for card/side-effect tools are **not** written into the transcript.
38
- - 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`). **No canned settle transcript bubbles** (`oc.settle.complete` / `error` / `question` are not written). On **complete or error** when the watch status actually changed, the service schedules an async contact-lane **continuation** (`inContactTurnLane` + `runContactTurn`) per related assistant: no admitted user row (internal `userText` only), active contact turn (green dot / 3-dot), stable resume id `resume_${assistantID}_${sessionID}_${status}_${updatedAt}`, bounded last worker assistant text (~2000 chars; fetch failure still resumes with status only). The contact Agent may re-`assign_session` the same sessionID or summarize in the user's language — the user sees that Agent summary, not 「会话已完成/会话失败」. `question` updates the card only (worker waiting on the user). Session-goal settle (`emitGoalNotification`) uses the same reporter — not a second scheduler. Idle after `session.error` must not rewrite 失败 as 完成 (`changed=false` → no second resume). Same complete status re-reported is idempotent.
59
+ - After a successful assign or watch, 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` (watch baselines the live status). The user-facing outcome is the session card plus any short **spoken** preamble; English tool-result confirms (`Opened a coding session.`, `Watching that coding session.`, `Created assistant…`, `Sent to…`) for card/side-effect tools are **not** written into the transcript.
60
+ - 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`). **No canned settle transcript bubbles** (`oc.settle.complete` / `error` / `question` are not written). On **complete or error** when the watch status actually changed, the service schedules an async contact-lane **continuation** (`inContactTurnLane` + `runContactTurn`) per related assistant: no admitted user row (internal `userText` only), active contact turn (green dot / 3-dot), stable resume id `resume_${assistantID}_${sessionID}_${status}_${updatedAt}`, bounded last worker assistant text (~2000 chars; fetch failure still resumes with status only). The contact Agent can perform read-only lookups and summarize in the user's language, but cannot reassign or mutate a session during this notification — the user sees that Agent summary, not 「会话已完成/会话失败」. `question` updates the card only (worker waiting on the user). Session-goal settle (`emitGoalNotification`) uses the same reporter — not a second scheduler. Idle after `session.error` must not rewrite 失败 as 完成 (`changed=false` → no second resume). Same complete status re-reported is idempotent.
39
61
  - 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 triggers the same contact continuation (no `oc.settle.complete` bubble). `session.error` or last-assistant `info.error` settles 失败 + resume once. 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. Tombstoned assistants skip resume.
40
62
  - 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.
41
63
  - `AssistantDTO.assignedSessionIDs` lists in-flight (`busy`/`question`) watches for assigned worker sessions. Session-card busy stays on the card; it does **not** drive the list green dot.
42
64
  - **Contact active turn (server authoritative):** process-local metadata tracks admission → `queued` → `running` → settled (removed). `AssistantDTO.working` is true only while that assistant has an unsettled contact turn. `AssistantDTO.activeContactTurn` is `{ turnID, messageID, status: 'queued'|'running', admittedAt }` or `null` so APP restart / reconnect can rehydrate list green dots and the contact 3-dot row from the snapshot HTTP GET — local optimistic and SSE preview are temporary only. A server process restart clears in-memory activity (no permanent green; tools are not auto-rerun). Per-assistant lanes isolate turns across assistants/runtimes. Stale `contact-turn-end` for an older `turnID` must not clear a newer active turn.
43
- - **List latest-message preview (server authoritative):** every `AssistantDTO` path (`snapshot` / `create` / `update` / other `output` rows) carries `latestMessagePreview: { messageID, ordinal, role: 'user'|'assistant', text, fallbackKind: 'image'|'file'|'session'|'assistant'|'schedule'|null } | null`. Source is the contact transcript only (not `defaultPrompt` / `updatedAt`). Order is the same keyset as contact pages: newest `(ordinal DESC, message_id DESC)`. Visible roles are `user`, `assistant`, and `peer` (peer maps to `role: 'user'` because inbound DMs are user-visible). Internal assigned-session settle markers (`oc.settle.*`) are excluded in the SQL candidate probe (and again in `buildContactMessagePreview`). Durable error bubbles keep their real body text. Plain text is sanitized and capped at `CONTACT_PREVIEW_MAX_CHARS` (500) with no data-URL/base64 bodies. File-only rows set `fallbackKind` `image`/`file`; pure cards set `session`/`assistant`/`schedule`. Empty transcript → `null`. **Snapshot cost:** one prepared statement reused per assistant (`assistant_id` + indexed `ORDER BY ordinal DESC, message_id DESC LIMIT candidateLimit`, catalog ≤100) collecting only those bounded candidates, then **one** parts `IN` query — not a window/`ROW_NUMBER` over full multi-assistant history and not HTTP N+1. SQL already drops pure settle tails so they cannot exhaust the LIMIT and hide an older real bubble; remaining non-preview empty rows may still consume a candidate slot within that same LIMIT. Admission, assistant completion, peer DM, card insert, wipe, and clear-memory already bump domain revision / `openchamber:assistants-changed`; clients re-GET snapshot for the new preview. Clear-memory keeps transcript rows so the preview still reflects the last bubble; clear-history / reset remove rows so the preview becomes the wipe confirm (or `null` after a full unbounded wipe with no remaining user).
65
+ - **List latest-message preview (server authoritative):** every `AssistantDTO` path (`snapshot` / `create` / `update` / other `output` rows) carries `latestMessagePreview: { messageID, ordinal, createdAt, role: 'user'|'assistant', text, fallbackKind: 'image'|'file'|'session'|'assistant'|'schedule'|null } | null`. Source is the contact transcript only (not `defaultPrompt` / `updatedAt`). Order is the same keyset as contact pages: newest `(ordinal DESC, message_id DESC)`. Visible roles are `user`, `assistant`, and `peer` (peer maps to `role: 'user'` because inbound DMs are user-visible). Internal assigned-session settle markers (`oc.settle.*`) are excluded in the SQL candidate probe (and again in `buildContactMessagePreview`). Durable error bubbles keep their real body text. Plain text is sanitized and capped at `CONTACT_PREVIEW_MAX_CHARS` (500) with no data-URL/base64 bodies. File-only rows set `fallbackKind` `image`/`file`; pure cards set `session`/`assistant`/`schedule`. Empty transcript → `null`. **Snapshot cost:** one prepared statement reused per assistant (`assistant_id` + indexed `ORDER BY ordinal DESC, message_id DESC LIMIT candidateLimit`, catalog ≤100) collecting only those bounded candidates, then **one** parts `IN` query — not a window/`ROW_NUMBER` over full multi-assistant history and not HTTP N+1. SQL already drops pure settle tails so they cannot exhaust the LIMIT and hide an older real bubble; remaining non-preview empty rows may still consume a candidate slot within that same LIMIT. Admission, assistant completion, peer DM, card insert, wipe, and clear-memory already bump domain revision / `openchamber:assistants-changed`; clients re-GET snapshot for the new preview. Clear-memory keeps transcript rows so the preview still reflects the last bubble; clear-history / reset remove rows so the preview becomes the wipe confirm (or `null` after a full unbounded wipe with no remaining user).
44
66
  - No registered project → clear `project_required` (ask the user to configure a project). Never fall back to managed assistant-workspaces.
45
67
  - Assign must not go through the peer DM channel.
46
68
  - `assignWork` (service) resolves the worker model via the connected catalog (`capabilities.input.image` preferred), forwards tool `variant` (cross-model drops contact variant), passes AbortSignal into create/prompt/delete/catalog, and supplies `lookupMessage` over real SDK exact lookup (`v2.session.message` → `session.message` → bounded `session.messages`). Ambiguous prompt admission returns `prompt_ambiguous` with `sessionID`/`messageID` and does **not** delete the worker; the contact-turn assign gate retains that identity so a same-args retry reuses the session and cannot blind-create another worker this turn.
@@ -67,8 +89,8 @@ and keep their existing error result handling.
67
89
  - `create_assistant` calls the same in-process `createAssistant` (`name`, connected OpenCode `providerID`/`modelID`, `mode: 'continuous'`). It emits an `assistant` card that opens that contact.
68
90
  - `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.
69
91
  - 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`.
70
- - `CONTACT_SYSTEM_PROMPT` + `formatContactToolsPrompt` + `formatPiCodingPrompt` tell the model to call these from natural language (开新对话 / 找项目 / 现有对话 / 查看助手设定·默认提示词 / 改默认提示词·设置人设 / 建助理 / 建会话 / 排定时任务 / 给 X 说一声). Catalog lines include each tool's JSON argument schema (pi `write`/`edit` require `path`+`content` / `path`+`edits[{oldText,newText}]`). 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. Tool results are re-injected as user turns labeled `OpenChamber tool result name=<tool> call=<id>` so multi-step turns keep call/result association.
71
- - If the user asked for `new_conversation` / `clear_chat_history` / `list_projects` / `list_sessions` / `get_assistant_settings` / `update_default_prompt` / `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.
92
+ - `CONTACT_SYSTEM_PROMPT` + `formatContactToolsPrompt` + `formatPiCodingPrompt` tell the model to call these from natural language (开新对话 / 找项目 / 现有对话 / 查看助手设定·默认提示词 / 改默认提示词·设置人设 / 建助理 / 建会话·继续会话 / 监听会话 / 停止会话 / 排定时任务 / 给 X 说一声). Catalog lines include each tool's JSON argument schema (pi `write`/`edit` require `path`+`content` / `path`+`edits[{oldText,newText}]`). 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. Tool results are re-injected as user turns labeled `OpenChamber tool result name=<tool> call=<id>` with JSON `{ isError, content }`; assistant calls and their arguments/IDs are also retained so multi-step turns keep call/result association. The harness replaces every `{{LANGUAGE}}` token — in the built-in prompt and in `defaultPrompt` — with the UI locale sent on the composer payload (`language` field, whitelisted locale code → display name). Background/settle turns recall the last locale seen for that assistant; unknown or missing locales resolve to `the user's interface language`, so the token never leaks into the prompt.
93
+ - If the user asked for `new_conversation` / `clear_chat_history` / `list_projects` / `list_sessions` / `get_assistant_settings` / `update_default_prompt` / `create_assistant` / `schedule_task` / `message_assistant` / `watch_session` / `stop_session` / `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.
72
94
 
73
95
  **Clear memory vs clear chat history (this PR)**
74
96
 
@@ -82,7 +104,7 @@ and keep their existing error result handling.
82
104
 
83
105
  **Contact LLM window (this PR)**
84
106
 
85
- - `contactHistoryForLlm` is a short algorithmic window documented in `contact-store.js`: durable context boundary first, then `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 after the watermark 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, including rows before the boundary.
107
+ - `contactHistoryForLlm` is a short algorithmic window documented in `contact-store.js`: durable context boundary first, then `CONTACT_LLM_MAX_TURNS` (32), `CONTACT_LLM_MAX_CHARS` (48000), `CONTACT_LLM_FETCH_LIMIT` (100), `CONTACT_LLM_FILE_CHAR_WEIGHT` (80). Newest user+assistant text pairs after the watermark are kept; peer / error rows are dropped; cards retain structured ID/status context. There is no summarizer and no user-facing compress or continuous/stateless memory control. SQLite and the transcript UI may still scroll older bubbles, including rows before the boundary.
86
108
 
87
109
  **Contact attachments (this PR)**
88
110
 
@@ -131,13 +153,47 @@ and keep their existing error result handling.
131
153
  `complete: true`. Missing or other-assistant IDs yield `messages: []`.
132
154
  - `revision` is the assistants domain tip (same counter as snapshot).
133
155
 
156
+ **Contact unread / shared read watermark (this PR)**
157
+
158
+ - Server-authoritative **per-assistant shared** read watermark in `assistant_contact_read_state`
159
+ (`last_read_ordinal`, `last_read_message_id`, `generation`). Multi-client; not per-device.
160
+ - **Countable unread:** only complete/error `role=assistant` replies and user-facing `role=peer`
161
+ DMs after the watermark keyset `(ordinal, message_id)` that have ≥1 **visible part** (file,
162
+ card, or non-empty text that is not an internal `oc.settle.*` marker). Visibility is
163
+ **per-part** (same rule as the contact UI): a settle marker beside real body/file/card still
164
+ counts; blank/whitespace-only rows do **not**. User rows and non-visible roles do **not**
165
+ count. Streaming tokens never create rows — one persisted bubble/card/peer row ≡ one unread unit.
166
+ - **Schema v13 migrate:** existing transcript assistants seed watermark to the current tip so
167
+ legacy history is default-read (`unreadCount: 0`). Messages written after the seed count as unread.
168
+ - **Monotonic mark-read:** `POST /api/openchamber/assistants/:id/contact/read`
169
+ body `{ generation, ordinal, messageID }` advances only forward in keyset order; late/stale
170
+ lower cursors are no-ops (`changed: false`, **no** revision bump / no `assistants-changed`).
171
+ Generation must match live transcript generation or → `contact_generation_conflict` (409).
172
+ Incoming cursor clamps to the current transcript tip (cannot invent future ids).
173
+ - **Wipe/reset:** `clear_chat_history` / `POST …/contact/reset` bumps generation and resets the
174
+ read watermark to empty on the new generation so post-wipe confirm bubbles can surface as unread.
175
+ Stale-generation mark-read fails closed. Clear-memory does **not** touch generation or read state.
176
+ - **AssistantDTO** (every `output` / snapshot path):
177
+ - `unreadCount: number`
178
+ - `readWatermark: { generation, ordinal, messageID }` — persisted shared cursor
179
+ - `readTip: { generation, ordinal, messageID }` — highest safe reportable tip (transcript head)
180
+ Clients open a contact, observe messages through `readTip`, then POST that tip.
181
+ - **Snapshot cost:** `getContactUnreadSnapshots` batches watermarks + tips + counts for the
182
+ catalog (≤100) — no per-assistant HTTP N+1. Each assistant's full `(ordinal, message_id)`
183
+ read watermark is pushed into SQL (VALUES marks CTE + keyset predicate on
184
+ `assistant_contact_message_page`); `COUNT` aggregates only post-watermark visible candidates
185
+ so fully-read history does not scan parts. Uses the same domain revision /
186
+ `openchamber:assistants-changed` tip after a **changed** mark-read (and after new countable
187
+ messages via existing admit/persist bumps).
188
+ - Response of mark-read: `{ assistantID, changed, unreadCount, readWatermark, readTip, revision }`.
189
+
134
190
  ## History coverage
135
191
 
136
192
  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.
137
193
 
138
194
  `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.
139
195
 
140
- `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` (full transcript, including rows before a clear-memory boundary); contact reset wipe 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`.
196
+ `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` (full transcript, including rows before a clear-memory boundary); contact reset wipe is `POST /:id/contact/reset`; shared contact read watermark is `POST /:id/contact/read`; 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`.
141
197
 
142
198
  `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.
143
199
 
@@ -145,7 +201,7 @@ Snapshots expose the latest 50 `historySessionIDs` in chronological order and `h
145
201
 
146
202
  - `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.
147
203
  - After admit: bump revision + `openchamber:assistants-changed`, register active contact turn (`queued`), broadcast `openchamber:contact-turn-start` `{ assistantID, turnID, messageID, occurredAt }` (`turnID` = client `messageID`), then kick `runContactTurn` asynchronously (serialized per assistant). Lane start marks the turn `running`. Lane start marks the turn `running` and bumps revision. Same `messageID`+payload replays admission once (`replayed: true`) without a second lane run; payload mismatch → `idempotency_conflict`. Failures persist a durable assistant `status: error` bubble for query recovery. Catalog/project list loads use a bounded deadline so the lane cannot hang forever.
148
- - 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. **Transcript bubbles:** confirm-only tools (`new_conversation` / `clear_chat_history` / `get_assistant_settings` / `update_default_prompt`) may use tool result text as the bubble; card/side-effect tools (`assign_session` / `create_assistant` / `schedule_task` / `message_assistant`) keep spoken only — never stack spoken + English toolText, and never paint card-tool English confirms when a card is enough. Missed-tool turns keep any existing spoken/assistant text; the English `MISSED_TOOL_FAILURE_BUBBLE` is only when there is no text at all. 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.
204
+ - 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. **Transcript bubbles:** confirm-only tools (`new_conversation` / `clear_chat_history` / `get_assistant_settings` / `update_default_prompt`) may use tool result text as the bubble; card/side-effect tools (`assign_session` / `watch_session` / `stop_session` / `create_assistant` / `schedule_task` / `message_assistant`) keep spoken only — never stack spoken + English toolText, and never paint card-tool English confirms when a card is enough; `read_session` is model-only lookup — keep spoken preamble plus the post-read reply, never the quoted transcript JSON. Missed-tool turns keep any existing spoken/assistant text; the English `MISSED_TOOL_FAILURE_BUBBLE` is only when there is no text at all. 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.
149
205
  - Turn end: strip/parse/tools (existing), persist **assistant** bubbles + cards only (never re-insert the user row), clear active contact turn, bump + assistants-changed (error paths bump even without assistant rows), 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`.
150
206
  - In-process tests use `whenContactTurnSettled(messageID)` (not part of the HTTP JSON body).
151
207
  - 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.
@@ -158,6 +214,27 @@ Queue admission accepts `{ kind: 'assistant', assistantID }`, captures a structu
158
214
 
159
215
  Share operations persist operation identity, Assistant identity, payload hash, phase, session ID, message ID, state, attempt, lease expiry, and error for seven days. `ShareOperationDTO` exposes top-level `sessionID` and `messageID` with `state` in `submitting`, `running`, `completed`, `failed`, or `unresolved`; it carries no binding. Admission and claims use SQLite transactions and compare-and-swap updates. A `reserving` phase is inserted or read under `BEGIN IMMEDIATE` before any session ensure or creation side effect. Its unique owner chooses and records the session and message IDs before claim and submit; duplicate matching requests reuse and poll that operation, while payload conflicts return `idempotency_conflict` without session work. Reservation-owner session creation failures delete the still-reserving record so a retry can reserve and recover. `running` work has a lease; `submitted` phase reconciles through message-ID lookup; expired submitted work becomes `unresolved`; failed work permits one CAS claimant to retry. `ShareOperationDTO` is available from the share response and `GET /share-operations/:operationID`. Repeated shares reuse that record. Ordinary composer messages create no operation record, but every accepted message creates or refreshes its Assistant message-mirror row.
160
216
 
161
- Schema v2 migrates legacy Inbox bindings once. It prefers the legacy Inbox session, then the latest successful operation or turn session. Schema v5 makes `workspace_path` nullable and migrates stored managed paths under `dataDir/assistant-workspaces/<assistantID>` to `NULL`. Schema v7 adds `mode` (`continuous` | `stateless`, default `continuous`) and preserves legacy `assistant.mode` during Inbox migration. Schema v10 establishes demand-driven history backfill semantics; existing mirror rows remain partial until a complete backfill state is persisted. New records use `assistant_v2` and carry no legacy topic field. Legacy topic, turn, operation, and assistant tables remain migration sources and compatibility backups. Runtime code never reads or writes them. Migration retains OpenCode sessions and messages.
217
+ Schema v2 migrates legacy Inbox bindings once. It prefers the legacy Inbox session, then the latest successful operation or turn session. Schema v5 makes `workspace_path` nullable and migrates stored managed paths under `dataDir/assistant-workspaces/<assistantID>` to `NULL`. Schema v7 adds `mode` (`continuous` | `stateless`, default `continuous`) and preserves legacy `assistant.mode` during Inbox migration. Schema v10 establishes demand-driven history backfill semantics; existing mirror rows remain partial until a complete backfill state is persisted. Schema v13 adds `assistant_contact_read_state` and seeds existing contact transcripts to the current tip (default-read). New records use `assistant_v2` and carry no legacy topic field. Legacy topic, turn, operation, and assistant tables remain migration sources and compatibility backups. Runtime code never reads or writes them. Migration retains OpenCode sessions and messages.
162
218
 
163
219
  Assistant workspaces resolve only under the Assistant workspace root or configured project roots. DTO serialization resolves and creates the managed target through the same containment checks, so project-configured Assistants expose a ready canonical destination for a future managed switch. Managed workspace creation resolves and creates `dataDir/assistant-workspaces/<assistantID>` before a session switch creates its OpenCode session and commits the configuration CAS. Route registration refreshes the authoritative project roots for each request. Each OpenCode session, prompt, message lookup, and skill discovery uses the effective workspace directory, so OpenCode applies its standard workspace skill discovery and configuration. Contact turns additionally merge `~/.claude/skills`, `~/.agents/skills`, and the workspace `.claude/skills` plus `.agents/skills` into the pi system prompt (project wins on name) and append `defaultPrompt`. `skill_roots` is a legacy schema field; migration removes it from `assistant_v2`, and runtime DTOs, input payloads, and writes omit it. `close()` clears the share-retention scheduler and closes SQLite.
220
+
221
+ ### Session operations from a contact
222
+
223
+ Contact tools expose `assign_session` (new or continued session), `steer_session` (insert a user instruction using SDK `promptAsync` with `delivery: steer`, without a model override), `stop_session` (real SDK abort), `archive_session` (SDK update `time.archived`), and `delete_session` (SDK delete). All existing-session mutations resolve directory from authoritative session metadata and apply the same registered-project scope checks. They accept exact session IDs from references, history, or `list_sessions`; ambiguous targets require clarification. Delete is permanent and is only offered for an explicit deletion request. SDK failures remain tool errors, never success confirmations; a failed mutation does not clear its watch. Successful archive/delete cancel assistant watches and pending automatic resumes, just as explicit stop does. Archive itself does not abort OpenCode execution: stopping and archiving are separate operations. Steering preserves the upstream session's model and does not create a second session. Terminal operation tools execute once per successful contact turn. All surfaces use the same server contact tool implementation.
224
+
225
+ ### Contact context and background notification boundary
226
+
227
+ - The model history is bounded to 32 recent turns, 48,000 estimated characters and 100 transcript rows (newest turn remains intact). It is not unlimited memory or a durable summary. Clear-memory still applies its persisted ordinal boundary. User instructions and assistant replies retain chronological ordering; card-only rows now contribute structured card context with exact session ID, directory and current status, instead of disappearing from the next model request. Peer inbox and failed rows remain excluded.
228
+ - Assigned-session settlement is a read-only result notification. The harness filters its tools to project/session/settings lookups and does not create a coding runtime. It cannot restart, assign, steer, archive, delete or write files even if the model attempts to do so. The latest user constraints remain in history.
229
+ - A new admitted user turn cancels running background notifications and durably disables notifications from existing watches (`resume_allowed`). Live card status continues updating truthfully. Explicit assign/watch admission rearms only that watch. This prevents a delayed completion after a stop/new instruction from restarting an obsolete background turn, including after server restart.
230
+ - Contact turns own AbortControllers. The existing session-abort route validates the current binding/generation, cancels active/queued contact turns and background notifications even without a legacy OpenCode binding, propagates the signal into the harness, and discards late generated output. With no active contact work it retains the legacy binding abort behavior. Already committed side effects are not rolled back.
231
+
232
+ Assign/watch tools forward the contact AbortSignal into their service calls and discard late cards after cancellation. Assign checks cancellation before creation and again before prompt submission. A session whose create completes after cancellation remains available but receives no prompt; cancellation never implicitly deletes it. Once prompt admission has begun, the existing ambiguous-admission identity and lookup rules remain authoritative.
233
+
234
+ ### Referenced conversation content
235
+
236
+ `read_session` is a read-only contact tool available on user turns and background notifications. An `@session:<id>` chip supplies identity only. The model reads contents through this tool before answering about the reference, and treats returned messages as quoted data, never as new user instructions or authorization to resume/watch/mutate. The quoted payload stays on the tool transcript for the model; contact bubbles keep only the spoken preamble and the post-read reply.
237
+
238
+ The service resolves the exact session and registered project through the existing authoritative session resolver, then calls SDK `session.messages` with `limit` (default 20, maximum 50), optional opaque `before`, and the turn AbortSignal. The upstream `x-next-cursor` is returned unchanged as `nextCursor`. Missing sessions, transport errors, malformed message lists and cross-session message identity mismatches fail closed. Empty successful lists remain distinguishable from failures.
239
+
240
+ Results retain message IDs, roles, text and bounded tool-result/file metadata. Text is limited to 4,000 characters per field and 24,000 total; each message contributes at most 20 parts. File URLs/bodies, reasoning and tool inputs are omitted. `partial` explicitly marks truncation, omissions or older pages. Reading creates no watch, prompt or other mutation.
@@ -24,6 +24,7 @@ export const ASSIGN_CODES = Object.freeze({
24
24
  IMAGE_NOT_SUPPORTED: 'image_not_supported',
25
25
  NO_PROVIDER: 'no_provider',
26
26
  PROMPT_AMBIGUOUS: 'prompt_ambiguous',
27
+ NOT_FOUND: 'not_found',
27
28
  });
28
29
 
29
30
  export const PROJECT_REQUIRED_MESSAGE = 'No registered project is configured. Add a project in Settings before assigning work. Do not use assistant-workspaces.';
@@ -136,7 +137,13 @@ function normalizeProjectRoots(allowedRoots = []) {
136
137
  const roots = [];
137
138
  for (const root of allowedRoots) {
138
139
  if (typeof root !== 'string' || !root.trim()) continue;
139
- const resolved = path.resolve(root.trim());
140
+ // realpath so /var vs /private/var (and prior resolveAssignDirectory results) still match.
141
+ let resolved = path.resolve(root.trim());
142
+ try {
143
+ resolved = fs.realpathSync(resolved);
144
+ } catch {
145
+ // Keep path.resolve when the root is not yet on disk.
146
+ }
140
147
  if (seen.has(resolved)) continue;
141
148
  seen.add(resolved);
142
149
  roots.push(resolved);
@@ -146,7 +153,15 @@ function normalizeProjectRoots(allowedRoots = []) {
146
153
 
147
154
  export function isManagedAssistantWorkspace(candidate, managedWorkspaceRoot) {
148
155
  if (!candidate || !managedWorkspaceRoot) return false;
149
- return contained(path.resolve(candidate), path.resolve(managedWorkspaceRoot));
156
+ const realpathOrResolve = (value) => {
157
+ const resolved = path.resolve(value);
158
+ try {
159
+ return fs.realpathSync(resolved);
160
+ } catch {
161
+ return resolved;
162
+ }
163
+ };
164
+ return contained(realpathOrResolve(candidate), realpathOrResolve(managedWorkspaceRoot));
150
165
  }
151
166
 
152
167
  export function resolveAssignDirectory({
@@ -177,22 +192,24 @@ export function resolveAssignDirectory({
177
192
  const resolveExisting = (candidate) => {
178
193
  const raw = trim(candidate, 4096);
179
194
  if (!raw) return null;
180
- const resolved = path.resolve(raw);
181
- rejectManaged(resolved);
182
- if (!underRoots(resolved)) {
183
- throw new AssignError(
184
- ASSIGN_CODES.WORKSPACE_FORBIDDEN,
185
- 'That path is not a registered project. Add it in Settings, then assign again.',
186
- );
187
- }
195
+ let resolved = path.resolve(raw);
188
196
  if (!fs.existsSync(resolved) || !fs.statSync(resolved).isDirectory()) {
189
197
  throw new AssignError(ASSIGN_CODES.VALIDATION, 'The project directory does not exist.');
190
198
  }
191
199
  try {
192
- return fs.realpathSync(resolved);
200
+ resolved = fs.realpathSync(resolved);
193
201
  } catch {
194
202
  throw new AssignError(ASSIGN_CODES.VALIDATION, 'The project directory does not exist.');
195
203
  }
204
+ // Compare realpaths so a prior authoritative resolve cannot fail against non-real roots.
205
+ rejectManaged(resolved);
206
+ if (!underRoots(resolved)) {
207
+ throw new AssignError(
208
+ ASSIGN_CODES.WORKSPACE_FORBIDDEN,
209
+ 'That path is not a registered project. Add it in Settings, then assign again.',
210
+ );
211
+ }
212
+ return resolved;
196
213
  };
197
214
 
198
215
  let target = resolveExisting(directory) || resolveExisting(projectPath) || resolveExisting(defaultProjectPath);
@@ -262,6 +279,76 @@ export function hasAssignImageParts(parts = []) {
262
279
  return sanitizeAssignFileParts(parts).some((part) => String(part.mime).toLowerCase().startsWith('image/'));
263
280
  }
264
281
 
282
+ /**
283
+ * Directory from authoritative OpenCode session payload.
284
+ * Prefer session.directory / path / project.worktree — never invent paths.
285
+ */
286
+ export function extractSessionDirectory(session) {
287
+ const payload = session?.data && typeof session.data === 'object' && !Array.isArray(session.data)
288
+ ? session.data
289
+ : session;
290
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return null;
291
+ return trim(payload.directory, 4096)
292
+ || trim(payload.path, 4096)
293
+ || trim(payload.project?.worktree, 4096)
294
+ || trim(payload.project?.directory, 4096)
295
+ || trim(payload.project?.path, 4096)
296
+ || null;
297
+ }
298
+
299
+ /** Title from session payload or index row. */
300
+ export function extractSessionTitle(session) {
301
+ const payload = session?.data && typeof session.data === 'object' && !Array.isArray(session.data)
302
+ ? session.data
303
+ : session;
304
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return null;
305
+ return trim(payload.title, 256) || null;
306
+ }
307
+
308
+ /**
309
+ * Map OpenCode session + messages into contact watch/card status.
310
+ * Used as the baseline when watching an existing session so an already-terminal
311
+ * idle is not treated as a fresh complete transition.
312
+ * - busy/retry → busy (in-flight)
313
+ * - session.error / last assistant error → error
314
+ * - idle / time.completed / missing session → complete
315
+ * - otherwise busy (keep watching; never invent complete from silence)
316
+ */
317
+ export function mapSessionToWatchStatus({
318
+ session = null,
319
+ messages = null,
320
+ missing = false,
321
+ } = {}) {
322
+ if (missing) return 'complete';
323
+ const payload = session?.data && typeof session.data === 'object' && !Array.isArray(session.data)
324
+ ? session.data
325
+ : session;
326
+ if (payload?.error) return 'error';
327
+
328
+ const statusType = typeof payload?.status?.type === 'string'
329
+ ? payload.status.type
330
+ : (typeof payload?.status === 'string'
331
+ ? payload.status
332
+ : (typeof payload?.type === 'string' ? payload.type : ''));
333
+ if (statusType === 'busy' || statusType === 'retry') return 'busy';
334
+ if (statusType === 'question') return 'question';
335
+
336
+ const rows = Array.isArray(messages)
337
+ ? messages
338
+ : (Array.isArray(messages?.data) ? messages.data : []);
339
+ for (let index = rows.length - 1; index >= 0; index -= 1) {
340
+ const info = rows[index]?.info ?? rows[index];
341
+ if (info?.role !== 'assistant') continue;
342
+ if (info?.error) return 'error';
343
+ if (info?.time?.completed) return 'complete';
344
+ break;
345
+ }
346
+
347
+ if (statusType === 'idle' || payload?.time?.completed) return 'complete';
348
+ // No terminal signal — keep in-flight so later idle can settle once.
349
+ return 'busy';
350
+ }
351
+
265
352
  /**
266
353
  * Normalize a session/message model object to { providerID, modelID }.
267
354
  * Accepts `{ providerID, id }` or `{ providerID, modelID }`. Lookup failures stay null.
@@ -408,7 +495,7 @@ export function resolveAssignWorkerModel({
408
495
  }
409
496
 
410
497
  const models = catalog.models.filter((entry) => entry && typeof entry === 'object');
411
- const matches = models.filter((entry) => {
498
+ let matches = models.filter((entry) => {
412
499
  const entryProvider = trim(entry.providerID, 256);
413
500
  const entryModel = trim(entry.modelID, 256);
414
501
  const entryName = trim(entry.name, 512);
@@ -420,6 +507,7 @@ export function resolveAssignWorkerModel({
420
507
  return entryProvider === wantedProvider;
421
508
  }
422
509
  if (!wantedProvider && wantedModel) {
510
+ if (explicitModel) return entryModel === wantedModel;
423
511
  const needle = wantedModel.toLowerCase();
424
512
  return entryModel.toLowerCase() === needle
425
513
  || (entryName && entryName.toLowerCase() === needle)
@@ -428,6 +516,19 @@ export function resolveAssignWorkerModel({
428
516
  return false;
429
517
  });
430
518
 
519
+ // Only the informal `model` alias gets fuzzy matching. Explicit IDs remain exact.
520
+ if (matches.length === 0 && combined && !combined.includes('/') && !explicitModel) {
521
+ const normalize = (value) => trim(value, 512).toLowerCase().replace(/[^\p{L}\p{N}]/gu, '');
522
+ const needle = normalize(combined);
523
+ const eligible = models.filter((entry) => !wantedProvider || entry.providerID === wantedProvider);
524
+ if (needle.length >= 2) {
525
+ const exact = eligible.filter((entry) => normalize(entry.modelID) === needle || normalize(entry.name) === needle);
526
+ matches = exact.length > 0 ? exact : eligible.filter((entry) => (
527
+ normalize(entry.modelID).includes(needle) || normalize(entry.name).includes(needle)
528
+ ));
529
+ }
530
+ }
531
+
431
532
  if (matches.length === 0) {
432
533
  const label = wantedProvider && wantedModel
433
534
  ? `${wantedProvider}/${wantedModel}`
@@ -638,6 +739,7 @@ export async function assignSession(input = {}) {
638
739
  throw new AssignError(ASSIGN_CODES.UPSTREAM, 'Worker session APIs are unavailable.');
639
740
  }
640
741
 
742
+ input.signal?.throwIfAborted();
641
743
  let targetSessionID = sessionID;
642
744
  if (!targetSessionID) {
643
745
  let created;
@@ -658,6 +760,9 @@ export async function assignSession(input = {}) {
658
760
  }
659
761
  }
660
762
 
763
+ // A cancelled contact must not submit work after a late create response.
764
+ // Retain that session: creation succeeded, and cancellation is not deletion.
765
+ input.signal?.throwIfAborted();
661
766
  let prompted;
662
767
  let promptError;
663
768
  try {