@openchambery/web 1.19.13 → 1.19.15-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/dist/assets/{AgentAvatar-mbvX7YlW.js → AgentAvatar-m1jNq4bX.js} +1 -1
  2. package/dist/assets/AssistantView-D5rEZ-Ss.js +2 -0
  3. package/dist/assets/{DiagramView-BdI4PT2H.js → DiagramView-DaB9Sb7S.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-emLeoNjI.js +2 -0
  7. package/dist/assets/{MarkdownRendererImpl-2ewbRZE2.js → MarkdownRendererImpl-BWCAPlqn.js} +3 -3
  8. package/dist/assets/{MarkstreamRendererImpl-DXewO7S_.js → MarkstreamRendererImpl-BD_NFKUv.js} +2 -2
  9. package/dist/assets/MobileDetailNavigation-DqXVAIap.js +1 -0
  10. package/dist/assets/MobileShareBridge-Bk2fdkJk.js +4 -0
  11. package/dist/assets/MobileSurface-C4mpcOCh.js +2 -0
  12. package/dist/assets/{MultiRunWindow-C8ukIdLA.js → MultiRunWindow-rs49_K1E.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-b8LUjoad.js +25 -0
  16. package/dist/assets/{SettingsWindow-CSePZ_V8.js → SettingsWindow-D8JFlPdA.js} +1 -1
  17. package/dist/assets/{TerminalView-BmSIrZ81.js → TerminalView-DCd-Godi.js} +1 -1
  18. package/dist/assets/{ToolOutputDialog-2BYaWLo1.js → ToolOutputDialog-B50L_nUP.js} +1 -1
  19. package/dist/assets/{appThemeRegistry-Dk4gBaOu.js → appThemeRegistry-BWtCaTMe.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-DKYkoypI.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-D4PyYgLZ.js} +3 -3
  33. package/dist/assets/{input-CI730njs.js → input-Cd_1HyQv.js} +1 -1
  34. package/dist/assets/insertionBoundaries-Bnnx03Wj.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-BRiQTNFX.js +169 -0
  38. package/dist/assets/{main-Ci3JmtpW.js → main-CnY0LWNc.js} +2 -2
  39. package/dist/assets/{markdownHeightCache-DEjGKLv8.js → markdownHeightCache-BL8CUQ0M.js} +1 -1
  40. package/dist/assets/miniChat-B0pw_mUs.js +2 -0
  41. package/dist/assets/mobile-BKHmxwXz.js +2 -0
  42. package/dist/assets/{multirun-CmaTeZON.js → multirun-BQKqVfFT.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-BvG8sdtC.js} +2 -2
  47. package/dist/assets/{pt-BR-CZQFJllP.js → pt-BR-B1uHpjFW.js} +2 -2
  48. package/dist/assets/renderElectronMiniChatApp-E5a0yy7E.js +2 -0
  49. package/dist/assets/renderMobileApp-DT0L4Ah6.js +28 -0
  50. package/dist/assets/{runtimeConfig-DI8l0VyZ.js → runtimeConfig-utWzrDwa.js} +1 -1
  51. package/dist/assets/runtimeEndpointReset-DE6lwHyg.js +147 -0
  52. package/dist/assets/sessionChangeSummary-BG7l2heP.js +2 -0
  53. package/dist/assets/sessionLookup-12zVOR0J.js +1 -0
  54. package/dist/assets/{sortable-tabs-strip-Dom6Sthu.js → sortable-tabs-strip-dYW6SH62.js} +1 -1
  55. package/dist/assets/src-CFUi4riW.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-BhYuP4L2.js} +43 -44
  63. package/dist/assets/useDesktopWindowControlsLayout-DPNMRkrI.js +1 -0
  64. package/dist/assets/{useEffectiveDirectory-e9HjIqGz.js → useEffectiveDirectory-Ji8AKgzY.js} +1 -1
  65. package/dist/assets/{useFontPreferences-CB-cGo3I.js → useFontPreferences-DCaY8NbU.js} +1 -1
  66. package/dist/assets/useMobileNavigationStore-BYdpuwL7.js +413 -0
  67. package/dist/assets/useSessionAutoCleanup-CADZCvZL.js +2 -0
  68. package/dist/assets/{useWorkerHighlightedLines-DTGE0CDs.js → useWorkerHighlightedLines-CuG88Lsj.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 +120 -22
  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 +520 -11
  83. package/server/lib/assistants/contact-store.test.js +362 -12
  84. package/server/lib/assistants/contact-tools.js +305 -54
  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 +295 -132
  88. package/server/lib/assistants/harness.test.js +662 -10
  89. package/server/lib/assistants/pi-tools.js +2 -0
  90. package/server/lib/assistants/pi-tools.test.js +8 -0
  91. package/server/lib/assistants/routes.js +3 -0
  92. package/server/lib/assistants/service.js +861 -79
  93. package/server/lib/assistants/service.test.js +1234 -8
  94. package/server/lib/llm/DOCUMENTATION.md +10 -3
  95. package/server/lib/llm/completions.js +4 -0
  96. package/server/lib/llm/completions.test.js +3 -0
  97. package/server/lib/llm/generate.js +57 -11
  98. package/server/lib/llm/generate.test.js +92 -0
  99. package/server/lib/opencode/feature-routes-runtime.js +5 -0
  100. package/dist/assets/AssistantView-CKmIS1cS.js +0 -2
  101. package/dist/assets/Icon-CkzzhaLr.js +0 -1
  102. package/dist/assets/MarkdownRenderer-CLQ4Z8Z0.js +0 -2
  103. package/dist/assets/MobileDetailNavigation-BGaAyuBH.js +0 -1
  104. package/dist/assets/MobileShareBridge-BAZoyeIP.js +0 -4
  105. package/dist/assets/MobileSurface-BVb3xNWt.js +0 -2
  106. package/dist/assets/SettingsView-CkQ61pbr.js +0 -25
  107. package/dist/assets/insertionBoundaries-BMhzPFft.js +0 -4
  108. package/dist/assets/main-BjeOhdcG.js +0 -169
  109. package/dist/assets/miniChat-BYrjYEXJ.js +0 -2
  110. package/dist/assets/mobile-BVMNnPK2.js +0 -2
  111. package/dist/assets/platform-DzsGqlGW.js +0 -2
  112. package/dist/assets/renderElectronMiniChatApp-COwia8Cs.js +0 -2
  113. package/dist/assets/renderMobileApp-CXwwpY1t.js +0 -28
  114. package/dist/assets/runtimeEndpointReset-BkAQXmXJ.js +0 -147
  115. package/dist/assets/sessionChangeSummary-DQDZ5HW-.js +0 -1
  116. package/dist/assets/sessionLookup-Imc995rW.js +0 -1
  117. package/dist/assets/src-AA-5nXsF.css +0 -2
  118. package/dist/assets/useDesktopWindowControlsLayout-C5hZD1co.js +0 -1
  119. package/dist/assets/useMobileNavigationStore-CYbDxRTM.js +0 -413
  120. package/dist/assets/useSessionAutoCleanup-BGy9_AhN.js +0 -2
@@ -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 {
@@ -9,9 +9,11 @@ import {
9
9
  attachmentScopeKey,
10
10
  buildAssignParts,
11
11
  extractAssignSessionModel,
12
+ extractSessionDirectory,
12
13
  hasAssignImageParts,
13
14
  isAmbiguousPromptFailure,
14
15
  isManagedAssistantWorkspace,
16
+ mapSessionToWatchStatus,
15
17
  normalizeAssignSessionModel,
16
18
  requireProvidedString,
17
19
  resolveAssignDirectory,
@@ -65,6 +67,22 @@ describe('resolveAssignDirectory', () => {
65
67
  });
66
68
 
67
69
  describe('requireProvidedString / resolveAssignWorkerModel', () => {
70
+ it('resolves a unique informal alias while keeping explicit ids exact and ambiguity closed', () => {
71
+ const catalog = { models: [
72
+ { providerID: 'a', modelID: 'claude-sonnet-4-6', name: 'Claude Sonnet 4.6' },
73
+ { providerID: 'a', modelID: 'gpt-5.4', name: 'GPT 5.4' },
74
+ { providerID: 'b', modelID: 'gpt-5.4-mini', name: 'GPT 5.4 Mini' },
75
+ ] }
76
+ expect(resolveAssignWorkerModel({ model: 'sonnet 4.6', catalog }).modelID).toBe('claude-sonnet-4-6')
77
+ expect(resolveAssignWorkerModel({ model: 'gpt54', catalog }).modelID).toBe('gpt-5.4')
78
+ expect(() => resolveAssignWorkerModel({ model: 'gpt', catalog })).toThrow(/Several connected models/)
79
+ expect(() => resolveAssignWorkerModel({ providerID: 'a', modelID: 'sonnet 4.6', catalog })).toThrow(/No connected/)
80
+ expect(() => resolveAssignWorkerModel({ model: 'made-up-model', catalog })).toThrow(/No connected/)
81
+ expect(() => resolveAssignWorkerModel({ modelID: 'GPT 5.4', catalog })).toThrow(/No connected/)
82
+ expect(resolveAssignWorkerModel({ providerID: 'a', model: 'gpt', catalog }).modelID).toBe('gpt-5.4')
83
+ expect(() => resolveAssignWorkerModel({ model: 'sonnet', catalog: { models: [catalog.models[0], { ...catalog.models[0], providerID: 'b' }] } })).toThrow(/Several connected models/)
84
+ })
85
+
68
86
  const catalog = {
69
87
  models: [
70
88
  { providerID: 'openai', modelID: 'gpt-4o', name: 'GPT-4o', acceptsImages: true },
@@ -209,6 +227,25 @@ describe('requireProvidedString / resolveAssignWorkerModel', () => {
209
227
  });
210
228
 
211
229
  describe('resolveAssignWorkerVariant', () => {
230
+ it('does not submit or delete a session whose create completes after cancellation', async () => {
231
+ const directory = root();
232
+ const controller = new AbortController();
233
+ const promptExisting = vi.fn();
234
+ const deleteSession = vi.fn();
235
+ const createSession = vi.fn(async () => {
236
+ controller.abort();
237
+ return { data: { id: 'ses_created' } };
238
+ });
239
+ await expect(assignSession({
240
+ prompt: 'Fix', directory, assistant: { providerID: 'p', modelID: 'm' },
241
+ allowedRoots: [directory], managedWorkspaceRoot: path.join(directory, 'assistant-workspaces'),
242
+ createSession, promptExisting, deleteSession, signal: controller.signal,
243
+ })).rejects.toMatchObject({ name: 'AbortError' });
244
+ expect(createSession).toHaveBeenCalledTimes(1);
245
+ expect(promptExisting).not.toHaveBeenCalled();
246
+ expect(deleteSession).not.toHaveBeenCalled();
247
+ });
248
+
212
249
  it('reuses assistant variant only for same/default model; drops it cross-model', () => {
213
250
  expect(resolveAssignWorkerVariant({
214
251
  source: 'assistant',
@@ -526,4 +563,46 @@ describe('assignSession', () => {
526
563
  expect(deleteSession).not.toHaveBeenCalled();
527
564
  expect(promptExisting).toHaveBeenCalledWith(expect.objectContaining({ sessionID: 'ses_existing' }));
528
565
  });
566
+
567
+ it('mapSessionToWatchStatus baselines terminal idle without inventing busy', () => {
568
+ expect(mapSessionToWatchStatus({
569
+ session: { id: 'ses_1', status: { type: 'idle' }, time: { completed: 1 } },
570
+ messages: [{ info: { role: 'assistant', time: { completed: 1 } } }],
571
+ })).toBe('complete');
572
+ expect(mapSessionToWatchStatus({
573
+ session: { id: 'ses_2', status: { type: 'busy' } },
574
+ })).toBe('busy');
575
+ expect(mapSessionToWatchStatus({
576
+ session: { id: 'ses_3', error: { message: 'boom' } },
577
+ })).toBe('error');
578
+ expect(mapSessionToWatchStatus({ missing: true })).toBe('complete');
579
+ expect(extractSessionDirectory({
580
+ directory: '/repo/app',
581
+ project: { worktree: '/other' },
582
+ })).toBe('/repo/app');
583
+ expect(extractSessionDirectory({
584
+ project: { worktree: '/repo/wt' },
585
+ })).toBe('/repo/wt');
586
+ });
587
+
588
+ it('resolveAssignDirectory accepts a realpath candidate against non-realpath allowed roots', () => {
589
+ const directory = root();
590
+ const project = path.join(directory, 'app');
591
+ fs.mkdirSync(project, { recursive: true });
592
+ const realProject = fs.realpathSync(project);
593
+ // First resolve returns realpath; second call with the same realpath must still pass.
594
+ const first = resolveAssignDirectory({
595
+ directory: project,
596
+ allowedRoots: [directory],
597
+ managedWorkspaceRoot: path.join(directory, 'assistant-workspaces'),
598
+ });
599
+ expect(first).toBe(realProject);
600
+ const second = resolveAssignDirectory({
601
+ directory: realProject,
602
+ projectPath: realProject,
603
+ allowedRoots: [directory],
604
+ managedWorkspaceRoot: path.join(directory, 'assistant-workspaces'),
605
+ });
606
+ expect(second).toBe(realProject);
607
+ });
529
608
  });
@@ -1,5 +1,5 @@
1
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;
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|watch_session|stop_session|list_projects|list_sessions|create_assistant|schedule_task|tool call|projectPath)\b/i;
3
3
 
4
4
  /**
5
5
  * A short spoken contact line (not chain-of-thought). Shown before a tool and kept.