teamshare-bridge 0.21.76 → 0.21.78

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 (88) hide show
  1. package/dist/bridge/daemon-inbox.js.map +1 -1
  2. package/dist/bridge/daemon.js +150 -20
  3. package/dist/bridge/daemon.js.map +1 -1
  4. package/dist/bridge/local-server.js +6 -1
  5. package/dist/bridge/local-server.js.map +1 -1
  6. package/dist/bridge/spawn.d.ts +19 -2
  7. package/dist/bridge/spawn.js +17 -4
  8. package/dist/bridge/spawn.js.map +1 -1
  9. package/dist/bridge/wake-handler.d.ts +1 -0
  10. package/dist/bridge/wake-handler.js +253 -100
  11. package/dist/bridge/wake-handler.js.map +1 -1
  12. package/dist/bridge/wake-queue-store.d.ts +20 -0
  13. package/dist/bridge/wake-queue-store.js +107 -0
  14. package/dist/bridge/wake-queue-store.js.map +1 -0
  15. package/dist/bridge/wake-routing.d.ts +60 -6
  16. package/dist/bridge/wake-routing.js +155 -6
  17. package/dist/bridge/wake-routing.js.map +1 -1
  18. package/dist/cli/commands/board.js +7 -0
  19. package/dist/cli/commands/board.js.map +1 -1
  20. package/dist/cli/commands/browse.js +5 -1
  21. package/dist/cli/commands/browse.js.map +1 -1
  22. package/dist/cli/commands/browser.js +53 -77
  23. package/dist/cli/commands/browser.js.map +1 -1
  24. package/dist/cli/commands/build.js +4 -2
  25. package/dist/cli/commands/build.js.map +1 -1
  26. package/dist/cli/commands/chat.js +3 -1
  27. package/dist/cli/commands/chat.js.map +1 -1
  28. package/dist/cli/commands/cleanup.js +4 -2
  29. package/dist/cli/commands/cleanup.js.map +1 -1
  30. package/dist/cli/commands/doc.js +8 -0
  31. package/dist/cli/commands/doc.js.map +1 -1
  32. package/dist/cli/commands/plan.js +6 -3
  33. package/dist/cli/commands/plan.js.map +1 -1
  34. package/dist/cli/commands/run.js +38 -17
  35. package/dist/cli/commands/run.js.map +1 -1
  36. package/dist/cli/commands/worker.js +4 -2
  37. package/dist/cli/commands/worker.js.map +1 -1
  38. package/dist/cli/index.js +5 -4
  39. package/dist/cli/index.js.map +1 -1
  40. package/dist/lib/api-agents.d.ts +27 -0
  41. package/dist/lib/api-agents.js +39 -0
  42. package/dist/lib/api-agents.js.map +1 -1
  43. package/dist/lib/api-content.d.ts +1 -1
  44. package/dist/lib/api-content.js +7 -1
  45. package/dist/lib/api-content.js.map +1 -1
  46. package/dist/lib/api-types.d.ts +18 -0
  47. package/dist/lib/brief.d.ts +8 -0
  48. package/dist/lib/brief.js +49 -0
  49. package/dist/lib/brief.js.map +1 -1
  50. package/dist/lib/browser/launch.d.ts +20 -7
  51. package/dist/lib/browser/launch.js +21 -9
  52. package/dist/lib/browser/launch.js.map +1 -1
  53. package/dist/lib/browser/manager.d.ts +43 -9
  54. package/dist/lib/browser/manager.js +64 -11
  55. package/dist/lib/browser/manager.js.map +1 -1
  56. package/dist/lib/browser/owned-registry.d.ts +19 -0
  57. package/dist/lib/browser/owned-registry.js +121 -0
  58. package/dist/lib/browser/owned-registry.js.map +1 -0
  59. package/dist/lib/browser/process.d.ts +14 -0
  60. package/dist/lib/browser/process.js +100 -0
  61. package/dist/lib/browser/process.js.map +1 -0
  62. package/dist/lib/browser/session.d.ts +74 -15
  63. package/dist/lib/browser/session.js +134 -32
  64. package/dist/lib/browser/session.js.map +1 -1
  65. package/dist/lib/cache-cleaner.d.ts +36 -0
  66. package/dist/lib/cache-cleaner.js +101 -5
  67. package/dist/lib/cache-cleaner.js.map +1 -1
  68. package/dist/lib/chat/chat-reply.d.ts +3 -2
  69. package/dist/lib/chat/chat-reply.js +117 -9
  70. package/dist/lib/chat/chat-reply.js.map +1 -1
  71. package/dist/lib/config.d.ts +2 -0
  72. package/dist/lib/config.js.map +1 -1
  73. package/dist/lib/orchestrator/client.js +13 -2
  74. package/dist/lib/orchestrator/client.js.map +1 -1
  75. package/dist/lib/orchestrator/handlers/browser.js +35 -8
  76. package/dist/lib/orchestrator/handlers/browser.js.map +1 -1
  77. package/dist/lib/tools/browser-tool.js +19 -5
  78. package/dist/lib/tools/browser-tool.js.map +1 -1
  79. package/dist/lib/workspace/mirror-lease.d.ts +62 -0
  80. package/dist/lib/workspace/mirror-lease.js +183 -0
  81. package/dist/lib/workspace/mirror-lease.js.map +1 -0
  82. package/dist/lib/workspace/mirror.d.ts +76 -1
  83. package/dist/lib/workspace/mirror.js +296 -13
  84. package/dist/lib/workspace/mirror.js.map +1 -1
  85. package/dist/lib/workspace/session-mention.d.ts +31 -0
  86. package/dist/lib/workspace/session-mention.js +30 -0
  87. package/dist/lib/workspace/session-mention.js.map +1 -0
  88. package/package.json +1 -1
@@ -19,12 +19,23 @@ const wake_state_1 = require("../lib/wake-state");
19
19
  const presence_1 = require("../lib/workspace/presence");
20
20
  const repo_lock_1 = require("../lib/workspace/repo-lock");
21
21
  const mirror_1 = require("../lib/workspace/mirror");
22
+ const wake_queue_store_1 = require("./wake-queue-store");
23
+ const git_branch_1 = require("../lib/git/git-branch");
22
24
  const git_presence_1 = require("../lib/git/git-presence");
23
25
  const session_store_1 = require("../lib/harness/session-store");
24
- const plan_gate_1 = require("../lib/harness/plan-gate");
25
26
  const spawn_1 = require("./spawn");
26
27
  const daemon_support_1 = require("./daemon-support");
27
28
  const wake_routing_1 = require("./wake-routing");
29
+ /**
30
+ * Seed recorded in a mirror marker. Task-scoped runs are keyed by the task id
31
+ * (the PATH is deterministic too, so a retry can adopt the previous tree);
32
+ * wakes with no task id get a type+random seed and are never adopted.
33
+ */
34
+ function mirrorSeed(event) {
35
+ if (event.taskId && wake_routing_1.UUID_RE.test(event.taskId))
36
+ return event.taskId;
37
+ return `${event.type.replace(/[^a-z.]/gi, '')}-${Math.random().toString(16).slice(2, 8)}`;
38
+ }
28
39
  /** IMP-917: wakes already deferred once for a rate-limit pause (bounded). */
29
40
  const rateDeferred = new Set();
30
41
  /**
@@ -36,6 +47,8 @@ const rateDeferred = new Set();
36
47
  * Covers every delivery path because they all funnel through `handleWake`.
37
48
  */
38
49
  const inFlightWakes = new Set();
50
+ /** Quiet-hours deferrals (2026-09) - bounded by one timer per wake identity. */
51
+ const quietDeferred = new Set();
39
52
  exports.folderWaitQueue = new Map();
40
53
  /**
41
54
  * Multi-repo: name -> path map of a project's linked git repos (undefined
@@ -77,6 +90,25 @@ async function handleWake(api, agentId, apiKey, event, configInput, extra) {
77
90
  }
78
91
  }
79
92
  }
93
+ // DUPLICATE AUTOMATED WAKE (2026-09): the hub re-sends a `task.assigned`
94
+ // wake it never saw acknowledged (empty room during a socket flap, or the
95
+ // 15s ack watchdog) and delivers the replay on the next reconnect. The 60s
96
+ // spawn cooldown above does not cover a replay 10 minutes later, and the
97
+ // 15-minute processed-wake ledger was only ever consulted by the re-sync and
98
+ // stranded-sweep pumps - never on this live path. So the duplicate sailed
99
+ // through, queued behind the busy lock, and on flush built a whole mirror for
100
+ // a task that was already being worked.
101
+ //
102
+ // Only AUTOMATED assignment wakes are filtered. `task.run` is a human
103
+ // pressing "Run task" and must always work, and a resume/continuation wake
104
+ // is an explicit instruction to pick the task back up.
105
+ if (event.type === 'task.assigned' &&
106
+ !event.resume &&
107
+ event.taskId &&
108
+ (0, wake_state_1.isRecentlyProcessed)(agentId, 'task.assigned', event.taskId)) {
109
+ (0, daemon_support_1.log)(`agent ${agentId}: wake task.assigned task=${event.taskId} skipped (already processed recently - duplicate wake)`);
110
+ return 'skipped';
111
+ }
80
112
  // Usage guard: skip wakes for the same task/message/document within the
81
113
  // cooldown window - a reconnect storm must never respawn the same work.
82
114
  // The per-agent setting (seconds) overrides the bridge defaults.
@@ -100,8 +132,28 @@ async function handleWake(api, agentId, apiKey, event, configInput, extra) {
100
132
  // Quiet hours: the per-agent setting wins; the bridge config is the
101
133
  // machine-wide fallback for agents without one.
102
134
  if ((0, daemon_support_1.inQuietHours)(settings?.quietHours ?? config.quietHours)) {
103
- (0, daemon_support_1.log)(`agent ${agentId}: wake ${event.type} skipped (quiet hours)`);
104
- return 'skipped';
135
+ // DEFERRED, never skipped (2026-09). This used to return 'skipped', and the
136
+ // inbox pumps stamp anything that is not queued/deferred/blocked as
137
+ // "processed" - so a chat mention that arrived during quiet hours was
138
+ // acked by the hub, skipped here, written to the 15-minute ledger, and then
139
+ // never retried by anyone. It was simply lost.
140
+ const deferKey = `${agentId}:${event.type}:${(0, wake_routing_1.wakeDedupeKey)(event) ?? `${event.projectId ?? ''}:${event.messageId ?? event.taskId ?? Math.random()}`}`;
141
+ if (!quietDeferred.has(deferKey)) {
142
+ quietDeferred.add(deferKey);
143
+ // Re-check in 30 minutes; a mention waits out quiet hours instead of
144
+ // disappearing.
145
+ setTimeout(() => {
146
+ quietDeferred.delete(deferKey);
147
+ if ((0, daemon_support_1.inQuietHours)(settings?.quietHours ?? config.quietHours)) {
148
+ // Still quiet - re-arm for the next window.
149
+ void handleWake(api, agentId, apiKey, event, config, extra);
150
+ return;
151
+ }
152
+ void handleWake(api, agentId, apiKey, event, config, extra);
153
+ }, 30 * 60_000);
154
+ (0, daemon_support_1.log)(`agent ${agentId}: wake ${event.type} deferred (quiet hours) - will retry in 30m until quiet hours end`);
155
+ }
156
+ return 'deferred';
105
157
  }
106
158
  // IN-FLIGHT GUARD (2026-09): the same wake delivered twice while the
107
159
  // first copy is still resolving (WS live wake vs stranded-task sweep vs
@@ -138,10 +190,15 @@ async function handleWake(api, agentId, apiKey, event, configInput, extra) {
138
190
  const queue = wakeQueue.get(agentId) ?? [];
139
191
  queue.push(event);
140
192
  wakeQueue.set(agentId, queue);
193
+ // Persisted (2026-09): a queued wake is often a chat mention or a task
194
+ // comment - a message addressed to the agent. It must survive a daemon
195
+ // restart/upgrade; the hub has already been told it was delivered, so there
196
+ // is no server-side copy to fall back on.
197
+ (0, wake_queue_store_1.writeWakeQueueFile)(agentId, queue);
141
198
  (0, daemon_support_1.log)(`agent ${agentId}: wake ${event.type} queued (busy since ${lock.startedAt}, ${lock.kind}) - ${queue.length} waiting`);
142
199
  // Stuck-queue visibility: only a lock with NO live session after 10+
143
200
  // minutes is a problem (PID reuse / hung process / orphan). A running
144
- // session (sessionRelay has it) is a normal long run — the queue keeps
201
+ // session (sessionRelay has it) is a normal long run —” the queue keeps
145
202
  // waiting (flushInterval) and we never warn. Fingerprint includes the
146
203
  // lock's startedAt so all wakes behind the SAME bad lock coalesce.
147
204
  const lockAgeMs = Date.now() - new Date(lock.startedAt).getTime();
@@ -156,7 +213,7 @@ async function handleWake(api, agentId, apiKey, event, configInput, extra) {
156
213
  message: `wake ${event.type} queued behind a ${lock.kind} lock held over 10 minutes with no live session (since ${lock.startedAt}, pid ${lock.pid}) - if the pid is orphaned: teamshare-bridge clear-lock --agent ${agentId}`,
157
214
  });
158
215
  }
159
- // Session running: stay quiet — the wake is queued and will spawn on free.
216
+ // Session running: stay quiet —” the wake is queued and will spawn on free.
160
217
  }
161
218
  return 'queued';
162
219
  }
@@ -180,14 +237,14 @@ async function handleWake(api, agentId, apiKey, event, configInput, extra) {
180
237
  }
181
238
  /**
182
239
  * NO-GIT NOTICE (2026-09): tell the user, ON THE TASK, why this run is not
183
- * isolated. Without this the only symptom is "the agent behaves differently" —
240
+ * isolated. Without this the only symptom is "the agent behaves differently" —”
184
241
  * a silent downgrade is indistinguishable from a bug. Deduped per
185
242
  * (agent, project, task) so a retried task is not spammed, and best-effort:
186
243
  * a failed notice must never block the run.
187
244
  */
188
245
  async function announceGitUnavailable(api, agentId, projectId, taskId, detail) {
189
246
  if (!taskId) {
190
- // No task to comment on (e.g. a document/board mention) — the daemon log
247
+ // No task to comment on (e.g. a document/board mention) —” the daemon log
191
248
  // already carries the reason and `doctor` reports it.
192
249
  return;
193
250
  }
@@ -329,7 +386,7 @@ async function spawnWake(api, agentId, apiKey, event, config, extra = {}) {
329
386
  (0, daemon_support_1.log)(`agent ${agentId}: wake ${event.type} -> working folder ${cwd}`);
330
387
  // Plain chat (mention with NO task reference) is a READ-ONLY plan-mode
331
388
  // session: it never edits local files and runs no git, so it needs no
332
- // isolation — it runs in the linked folder IMMEDIATELY (no mirror, no
389
+ // isolation —” it runs in the linked folder IMMEDIATELY (no mirror, no
333
390
  // folder queue) even while other runs are active. Task-linked chat DOES run
334
391
  // the git flow and gets a mirror. Computed here because the isolation gate
335
392
  // and the folder gate both need it.
@@ -337,7 +394,7 @@ async function spawnWake(api, agentId, apiKey, event, config, extra = {}) {
337
394
  const plainChat = event.type === 'chat.mention' && !linkedTaskId;
338
395
  // Task wake PRE-CHECKS (2026-09): fetch once and apply the SKIP guards
339
396
  // BEFORE isolation, so a wake that will be skipped (part of a combined run,
340
- // suspended, or a closed task.assigned) never builds a mirror — that used to
397
+ // suspended, or a closed task.assigned) never builds a mirror —” that used to
341
398
  // leave an orphan mirror + 5 worktrees. The fetched task is reused by the
342
399
  // plan/build routing below (no second GET).
343
400
  let routedTask = null;
@@ -348,7 +405,7 @@ async function spawnWake(api, agentId, apiKey, event, config, extra = {}) {
348
405
  try {
349
406
  const task = await api.getTask(event.taskId);
350
407
  // Combined-run lock: a member folded into a combined run is never spawned
351
- // on its own — the combined (group) task IS its run.
408
+ // on its own —” the combined (group) task IS its run.
352
409
  if (task.combinedIntoId) {
353
410
  (0, daemon_support_1.log)(`agent ${agentId}: wake ${event.type} task=${event.taskId} SKIPPED - part of a combined run`);
354
411
  return 'skipped';
@@ -367,12 +424,94 @@ async function spawnWake(api, agentId, apiKey, event, config, extra = {}) {
367
424
  routedTask = task;
368
425
  }
369
426
  catch {
370
- // fetch failed — fall through; claim will fail if truly non-open
427
+ // fetch failed —” fall through; claim will fail if truly non-open
428
+ }
429
+ }
430
+ // PRE-FLIGHT (2026-09): decide whether this wake can spawn, and whether it
431
+ // needs a private mirror, BEFORE anything is created. Every skip decision
432
+ // used to live BELOW the isolation block, so a wake that decided not to run
433
+ // had already built 5 worktrees (~45 MB) and left them behind for hours.
434
+ //
435
+ // `preflightWake` relocates those decisions - it adds no new skip reason, so
436
+ // it can never strand work that would previously have run. The
437
+ // folder-serialization gate deliberately stays BELOW isolation (it must keep
438
+ // testing the EFFECTIVE cwd, where a successful mirror has no presence).
439
+ const preflight = (0, wake_routing_1.preflightWake)({
440
+ event,
441
+ task: routedTask,
442
+ hasLocalPlanSession: event.taskId
443
+ ? (0, session_store_1.hasHarnessSession)(agentId, event.taskId, 'plan')
444
+ : false,
445
+ hasLocalRunSession: event.taskId
446
+ ? (0, session_store_1.hasHarnessSession)(agentId, event.taskId, 'run')
447
+ : false,
448
+ hasLocalBuildSession: event.taskId
449
+ ? (0, session_store_1.hasHarnessSession)(agentId, event.taskId, 'build')
450
+ : false,
451
+ hasBuildState: false,
452
+ plainChat,
453
+ });
454
+ if (preflight.outcome === 'stop') {
455
+ // Defensive: handleWake normally intercepts stop signals before here.
456
+ if (projectId) {
457
+ try {
458
+ (0, node_fs_1.mkdirSync)(daemon_support_1.STOP_DIR, { recursive: true });
459
+ (0, node_fs_1.writeFileSync)((0, node_path_1.join)(daemon_support_1.STOP_DIR, `${agentId}.stop`), new Date().toISOString(), 'utf8');
460
+ (0, daemon_support_1.log)(`agent ${agentId}: ${event.type} -> stop file written (pre-flight)`);
461
+ }
462
+ catch (err) {
463
+ (0, daemon_support_1.log)(`agent ${agentId}: ${event.type} stop file failed: ${(0, config_1.msg)(err)}`);
464
+ }
371
465
  }
466
+ return 'spawned';
467
+ }
468
+ if (preflight.outcome === 'skip') {
469
+ (0, daemon_support_1.log)(`agent ${agentId}: ${event.type} task=${event.taskId ?? ''} -> skipped (${preflight.reason}${preflight.detail ? `, ${preflight.detail}` : ''})`);
470
+ if (event.type === 'task.assigned' && event.taskId) {
471
+ (0, wake_state_1.recordProcessed)(agentId, 'task.assigned', event.taskId);
472
+ }
473
+ return 'skipped';
372
474
  }
373
- // ALWAYS-ISOLATE (2026-09): every code run gets its OWN private mirror —
475
+ // DETERMINISTIC MENTION CLAIM (2026-09).
476
+ //
477
+ // Every mention wake (chat, task comment, document comment, board comment, AI
478
+ // edit) now carries the hub's AgentMention id. Claiming it HERE - once, in the
479
+ // one place every mention passes through - is what makes "answered" a fact
480
+ // rather than a guess. Previously the bridge decided with a name-scan behind a
481
+ // moving cursor and a "is my reply in the newest 50?" window, so a replayed
482
+ // wake (a PendingWake redelivery, a reconnect re-sync, a stranded sweep) could
483
+ // start a SECOND session on a question that was already answered.
484
+ //
485
+ // `claimed: false` means another session already has it, or it is answered ->
486
+ // do not spawn. An older hub without the endpoint logs and proceeds, so this
487
+ // is additive, never a new way to drop a mention.
488
+ if (event.mentionId) {
489
+ try {
490
+ const claim = await api.triggerMention(agentId, event.mentionId);
491
+ if (claim && claim.claimed === false) {
492
+ const state = claim.mention?.answeredAt
493
+ ? 'already answered'
494
+ : 'already being handled';
495
+ (0, daemon_support_1.log)(`agent ${agentId}: mention ${event.mentionId} (${event.type}) is ${state} - not starting a second session`);
496
+ if (event.type === 'task.assigned') {
497
+ (0, wake_state_1.recordProcessed)(agentId, 'task.assigned', event.taskId ?? event.mentionId);
498
+ }
499
+ return 'skipped';
500
+ }
501
+ if (claim?.claimed) {
502
+ (0, daemon_support_1.log)(`agent ${agentId}: claimed mention ${event.mentionId} (${event.type})`);
503
+ }
504
+ }
505
+ catch (err) {
506
+ (0, daemon_support_1.log)(`agent ${agentId}: could not claim mention ${event.mentionId} (${(0, config_1.msg)(err)}) - proceeding`);
507
+ }
508
+ }
509
+ // Past the folder guard `cwd` is always a real path. Keep a non-null alias
510
+ // for the dispatch chain below - it is reassigned by the mirror block.
511
+ let runDir = cwd;
512
+ // ALWAYS-ISOLATE (2026-09): every code run gets its OWN private mirror —”
374
513
  // a container-root worktree plus one worktree per pre-existing nested repo
375
- // — so concurrent agents never share a working tree and EACH repo commits
514
+ // —” so concurrent agents never share a working tree and EACH repo commits
376
515
  // its own changes on its own branch (never a mixed commit or a gitlink
377
516
  // instead of the nested repo's work). The container root repo is
378
517
  // initialized on demand (never nested repos). Exempt: orchestrator-owned
@@ -382,29 +521,12 @@ async function spawnWake(api, agentId, apiKey, event, config, extra = {}) {
382
521
  // Opt out per project: `teamshare-agent link --project <id> --auto-worktree
383
522
  // false`.
384
523
  //
385
- // PLAN RUNS ARE NOT ISOLATED (2026-09): a planning pass never runs the git
386
- // pipeline — `cmdPlan` creates no branch and no commit, it only enriches the
387
- // task and writes documents via MCP — so a mirror/worktree buys it nothing
388
- // while costing 5 worktrees per pass. Planning therefore works in the linked
389
- // folder, serialized by presence (cmdPlan registers it) so it still never
390
- // overlaps another session. The decision is computed HERE, before the gate,
391
- // because the routing gate below runs after it.
392
- const planRunWake = event.type === 'plan.trigger' ||
393
- (routedTask ? (0, wake_routing_1.planModeRoute)(event, routedTask) === 'plan' : false);
394
- const isolationExempt = plainChat ||
395
- Boolean(event.worktreePath) ||
396
- event.type === 'question.answered' ||
397
- event.type === 'question.expired' ||
398
- event.type === 'tool.trigger' ||
399
- event.type === 'document.job' ||
400
- planRunWake;
401
- if (!isolationExempt && cwd && projectId &&
402
- config.projects[projectId]?.autoWorktree !== false) {
403
- const seedBase = (event.taskId ?? `${event.type}-${Date.now()}`)
404
- .replace(/-/g, '')
405
- .slice(0, 8);
406
- // Unique per run so a retry never tears down an earlier run's mirror.
407
- const seed = `${seedBase}-${Math.random().toString(16).slice(2, 6)}`;
524
+ // The exemption decision now comes from the pre-flight (`needsMirror`), so
525
+ // plan runs are excluded here for the same reason as before: a planning
526
+ // pass never runs the git pipeline, so a mirror buys it nothing while
527
+ // costing 5 worktrees per pass. `cmdPlan` registers presence instead, so
528
+ // the folder-serialization gate below still keeps it to one at a time.
529
+ if (preflight.needsMirror && !event.worktreePath && projectId && config.projects[projectId]?.autoWorktree !== false) {
408
530
  // NO-GIT FALLBACK (2026-09): a machine without Git cannot have a mirror at
409
531
  // all (no container-root `git init`, no `git worktree add`). Don't burn a
410
532
  // doomed attempt on it: run in the linked folder and let the folder
@@ -418,17 +540,60 @@ async function spawnWake(api, agentId, apiKey, event, config, extra = {}) {
418
540
  }
419
541
  else {
420
542
  try {
421
- const info = await (0, mirror_1.createIsolationMirror)(cwd, seed, agentId, projectId);
422
- if (info) {
423
- cwd = info.path;
424
- (0, daemon_support_1.log)(`agent ${agentId}: isolated in ${info.path} (${info.repos.length} repo(s): ${info.repos
543
+ // Base the tree on the task's own branch when it exists, so a retry
544
+ // CONTINUES the previous attempt instead of landing on a clean
545
+ // default-branch checkout and doing the same job twice.
546
+ //
547
+ // The inputs MUST mirror `cmdRun`'s `prepareRunGitAsync` call exactly
548
+ // (taskId + project.name + title, no branchName): if the two derive
549
+ // different names, the mirror bases on a branch the run never commits
550
+ // to and the continuation silently misses.
551
+ const result = await (0, mirror_1.createIsolationMirror)(cwd, mirrorSeed(event), agentId, projectId, {
552
+ taskId: routedTask?.id ?? event.taskId,
553
+ baseBranch: routedTask
554
+ ? (0, git_branch_1.desiredBranch)({
555
+ taskId: routedTask.id,
556
+ projectSlug: routedTask.project?.name,
557
+ taskTitle: routedTask.title,
558
+ })
559
+ : null,
560
+ });
561
+ if (result.ok) {
562
+ runDir = result.mirror.path;
563
+ const adopted = result.mirror.adopted
564
+ ? ` (adopted previous tree, ${result.mirror.dirtyFiles ?? 0} uncommitted file(s) carried over)`
565
+ : '';
566
+ (0, daemon_support_1.log)(`agent ${agentId}: isolated in ${runDir} (${result.mirror.repos.length} repo(s): ${result.mirror.repos
425
567
  .map((r) => r.name)
426
- .join(', ')})`);
568
+ .join(', ')})${adopted}`);
569
+ }
570
+ else if (result.reason === 'blocked') {
571
+ // Someone is genuinely using this task's tree. Refuse rather than
572
+ // touch it, and make the refusal visible - a same-task double wake is
573
+ // a duplicate-wake bug, not a normal state.
574
+ // A same-task double wake reached the mirror guard: this task's tree
575
+ // is genuinely in use. Queue for real (the pre-2026 code returned
576
+ // 'queued' WITHOUT queueing, so the wake was referenced nowhere: no
577
+ // retry, no server copy, no reply) - and say so loudly.
578
+ const queue = extra.wakeQueue?.get(agentId) ?? [];
579
+ queue.push(event);
580
+ extra.wakeQueue?.set(agentId, queue);
581
+ (0, wake_queue_store_1.writeWakeQueueFile)(agentId, queue);
582
+ (0, daemon_support_1.log)(`agent ${agentId}: mirror for ${event.type} task=${event.taskId ?? ''} is owned by a live session (${result.detail ?? 'busy'}) - queued ${queue.length} waiting`);
583
+ (0, diagnostics_1.reportDiagnosticSafe)({
584
+ kind: 'wake_failure',
585
+ severity: 'warning',
586
+ agentId,
587
+ errorCode: 'TASK_MIRROR_OWNED',
588
+ message: `wake ${event.type} for task ${event.taskId ?? '(no task)'} found its isolation mirror already owned by a live session (${result.detail ?? 'busy'}) - queued for retry`,
589
+ includeDoctor: false,
590
+ });
591
+ return 'queued';
427
592
  }
428
593
  else {
429
- // Isolation unavailable (no base branch / init failed) — the
594
+ // Isolation unavailable (no base branch / init failed) —” the
430
595
  // serialization gate below queues instead of sharing the folder.
431
- (0, daemon_support_1.log)(`agent ${agentId}: isolation unavailable - will queue if the linked folder is occupied`);
596
+ (0, daemon_support_1.log)(`agent ${agentId}: isolation unavailable (${result.detail ?? 'unknown'}) - will queue if the linked folder is occupied`);
432
597
  }
433
598
  }
434
599
  catch (err) {
@@ -436,59 +601,47 @@ async function spawnWake(api, agentId, apiKey, event, config, extra = {}) {
436
601
  }
437
602
  }
438
603
  }
439
- // Task-scoped routing gate (POST-isolation): the task was fetched and the
440
- // skip guards applied BEFORE the mirror was created, so nothing here can
441
- // leave an orphan mirror. Only tool/research + plan routing remain, using
442
- // the ISOLATED cwd.
443
- if (routedTask) {
604
+ // Task-scoped routing dispatch. The SKIP half of this decision was made in
605
+ // the pre-flight above the mirror block, so reaching here can only spawn.
606
+ if (routedTask && (preflight.kind === 'tool' || preflight.kind === 'plan')) {
444
607
  const task = routedTask;
445
608
  // Tools-as-tasks: a research/tool task must run through the spec-driven
446
609
  // research session (recipe + column contract), NOT the code agent loop.
447
- if ((event.type === 'task.assigned' || event.type === 'task.run') &&
448
- (task.kind === 'tool' || task.kind === 'research') &&
449
- task.toolRunId) {
610
+ if (preflight.kind === 'tool') {
611
+ // The pre-flight only returns 'tool' when toolRunId is set; String()
612
+ // keeps the spawn signature honest without a cast.
613
+ const toolRunId = String(task.toolRunId ?? '');
450
614
  (0, daemon_support_1.log)(`agent ${agentId}: task.assigned task=${event.taskId} -> research session (${task.toolKey ?? task.kind})`);
451
- (0, spawn_1.spawnResearchSession)(agentId, apiKey, projectId ?? task.projectId, task.toolRunId, task.toolKey ?? 'tool', extra.settings, undefined, cwd);
615
+ (0, spawn_1.spawnResearchSession)(agentId, apiKey, projectId ?? task.projectId, toolRunId, task.toolKey ?? 'tool', extra.settings, undefined, runDir);
452
616
  (0, wake_state_1.recordProcessed)(agentId, 'tool.trigger', String(task.toolRunId));
453
617
  return 'spawned';
454
618
  }
455
- // Plan mode: a PLANNING pass (enrich, never execute), not the code loop.
456
- if ((0, wake_routing_1.planModeRoute)(event, task) === 'plan') {
457
- const planGate = (0, plan_gate_1.automatedPlanGate)(task, (0, session_store_1.hasHarnessSession)(agentId, task.id, 'plan'));
458
- if (planGate.action === 'skip') {
459
- (0, daemon_support_1.log)(`agent ${agentId}: ${event.type} task=${event.taskId} -> planning skipped (${planGate.reason}, planRuns=${planGate.planRuns})`);
460
- if (event.type === 'task.assigned') {
461
- (0, wake_state_1.recordProcessed)(agentId, 'task.assigned', task.id);
462
- }
463
- return 'skipped';
464
- }
465
- (0, daemon_support_1.log)(`agent ${agentId}: ${event.type} task=${event.taskId} -> planning session (plan mode)`);
466
- const planDedupeKey = (0, wake_routing_1.wakeDedupeKey)(event);
467
- if (planDedupeKey)
468
- extra.recentSpawns?.set(planDedupeKey, Date.now());
469
- (0, spawn_1.spawnPlanSession)(agentId, apiKey, task.id, projectId ?? task.projectId, extra.settings, undefined, cwd, { auto: true });
470
- if (event.type === 'task.assigned') {
471
- (0, wake_state_1.recordProcessed)(agentId, 'task.assigned', task.id);
472
- }
473
- return 'spawned';
619
+ (0, daemon_support_1.log)(`agent ${agentId}: ${event.type} task=${event.taskId} -> planning session (plan mode)`);
620
+ const planDedupeKey = (0, wake_routing_1.wakeDedupeKey)(event);
621
+ if (planDedupeKey)
622
+ extra.recentSpawns?.set(planDedupeKey, Date.now());
623
+ (0, spawn_1.spawnPlanSession)(agentId, apiKey, task.id, projectId ?? task.projectId, extra.settings, undefined, runDir, { auto: true });
624
+ if (event.type === 'task.assigned') {
625
+ (0, wake_state_1.recordProcessed)(agentId, 'task.assigned', task.id);
474
626
  }
627
+ return 'spawned';
475
628
  }
476
629
  // FOLDER SERIALIZATION (safety net): when this wake could not get its own
477
630
  // worktree (creation refused/failed) and another session occupies the
478
631
  // linked folder, defer the wake until the folder becomes free. Covers
479
632
  // runs, build/plan/tool triggers, document mentions and TASK-linked chat.
480
- // Excluded: plain chat (read-only — never edits files, runs immediately),
633
+ // Excluded: plain chat (read-only —” never edits files, runs immediately),
481
634
  // resume wakes queue here by design, stop signals return earlier in
482
635
  // handleWake, and worktree-scoped wakes bypass it (they are isolated).
483
636
  if (!event.worktreePath && !plainChat) {
484
- const folderKey = (0, repo_lock_1.canonicalDir)(cwd);
485
- const occupants = (0, presence_1.listPresent)(cwd).filter((p) => p.agentId !== agentId || p.pid !== process.pid);
637
+ const folderKey = (0, repo_lock_1.canonicalDir)(runDir);
638
+ const occupants = (0, presence_1.listPresent)(runDir).filter((p) => p.agentId !== agentId || p.pid !== process.pid);
486
639
  if (occupants.length > 0) {
487
640
  const queue = exports.folderWaitQueue.get(folderKey) ?? [];
488
641
  queue.push({ agentId, apiKey, event, enqueuedAt: Date.now() });
489
642
  exports.folderWaitQueue.set(folderKey, queue);
490
643
  const blocking = occupants.map((o) => `${o.agentId ?? 'pid:' + o.pid}`).join(', ');
491
- (0, daemon_support_1.log)(`agent ${agentId}: wake ${event.type} queued behind ${blocking} in folder ${folderKey} — ${queue.length} waiting`);
644
+ (0, daemon_support_1.log)(`agent ${agentId}: wake ${event.type} queued behind ${blocking} in folder ${folderKey} —” ${queue.length} waiting`);
492
645
  return 'queued';
493
646
  }
494
647
  }
@@ -518,10 +671,10 @@ async function spawnWake(api, agentId, apiKey, event, config, extra = {}) {
518
671
  const repoMap = buildRepoMap(event.projectId);
519
672
  (0, daemon_support_1.log)(`agent ${agentId}: chat.mention task-linked -> ${linkedTaskId}` +
520
673
  `${repoMap ? ` (${Object.keys(repoMap).length} repos)` : ''}`);
521
- (0, spawn_1.spawnChatSession)(agentId, apiKey, event.projectId, event.mention ?? '', extra.settings, undefined, cwd, event.messageId, linkedTaskId, repoMap);
674
+ (0, spawn_1.spawnChatSession)(agentId, apiKey, event.projectId, event.mention ?? '', extra.settings, undefined, runDir, event.messageId, event.mentionId ?? undefined, linkedTaskId, repoMap);
522
675
  return 'spawned';
523
676
  }
524
- (0, spawn_1.spawnChatSession)(agentId, apiKey, event.projectId, event.mention ?? '', extra.settings, undefined, cwd, event.messageId);
677
+ (0, spawn_1.spawnChatSession)(agentId, apiKey, event.projectId, event.mention ?? '', extra.settings, undefined, runDir, event.messageId, event.mentionId ?? undefined);
525
678
  return 'spawned';
526
679
  }
527
680
  if (event.type === 'document.mention') {
@@ -533,7 +686,7 @@ async function spawnWake(api, agentId, apiKey, event, config, extra = {}) {
533
686
  // IMP-9xx: pass the waking comment id so the loop answers ONLY that
534
687
  // comment (answer-once guard still applies) instead of rescanning
535
688
  // every historical mention in the thread.
536
- (0, spawn_1.spawnDocumentSession)(agentId, apiKey, event.documentId, event.mention ?? '', extra.settings, undefined, cwd, event.commentId);
689
+ (0, spawn_1.spawnDocumentSession)(agentId, apiKey, event.documentId, event.mention ?? '', extra.settings, undefined, runDir, event.commentId);
537
690
  return 'spawned';
538
691
  }
539
692
  if (event.type === 'document.edit_request') {
@@ -545,10 +698,10 @@ async function spawnWake(api, agentId, apiKey, event, config, extra = {}) {
545
698
  // The agent reads the document, applies the edit instruction, and
546
699
  // replies with what changed.
547
700
  const editCtx = event.editRequest
548
- ? ` [EDIT REQUEST: "${event.editRequest.instruction}" on page ${event.editRequest.pageNumber ?? 'N/A'} — quoted: "${event.editRequest.quote.slice(0, 200)}"]`
701
+ ? ` [EDIT REQUEST: "${event.editRequest.instruction}" on page ${event.editRequest.pageNumber ?? 'N/A'} —” quoted: "${event.editRequest.quote.slice(0, 200)}"]`
549
702
  : '';
550
703
  (0, daemon_support_1.log)(`agent ${agentId}: document.edit_request doc=${event.documentId} comment=${event.commentId ?? 'none'}${editCtx}`);
551
- (0, spawn_1.spawnDocumentSession)(agentId, apiKey, event.documentId, event.mention ?? '', extra.settings, undefined, cwd, event.commentId);
704
+ (0, spawn_1.spawnDocumentSession)(agentId, apiKey, event.documentId, event.mention ?? '', extra.settings, undefined, runDir, event.commentId);
552
705
  return 'spawned';
553
706
  }
554
707
  if (event.type === 'board.mention') {
@@ -558,7 +711,7 @@ async function spawnWake(api, agentId, apiKey, event, config, extra = {}) {
558
711
  }
559
712
  // Board assistant: read the board, draw/edit with the board MCP tools,
560
713
  // reply as a board comment (mirrors the document assistant).
561
- (0, spawn_1.spawnBoardSession)(agentId, apiKey, event.whiteboardId, event.mention ?? '', extra.settings, undefined, cwd, event.commentId);
714
+ (0, spawn_1.spawnBoardSession)(agentId, apiKey, event.whiteboardId, event.mention ?? '', extra.settings, undefined, runDir, event.commentId);
562
715
  return 'spawned';
563
716
  }
564
717
  if (event.type === 'build.trigger') {
@@ -567,7 +720,7 @@ async function spawnWake(api, agentId, apiKey, event, config, extra = {}) {
567
720
  return 'skipped';
568
721
  }
569
722
  // Build mode: start (or restart) the project's build run.
570
- (0, spawn_1.spawnBuildSession)(agentId, apiKey, event.projectId, extra.settings, undefined, cwd);
723
+ (0, spawn_1.spawnBuildSession)(agentId, apiKey, event.projectId, extra.settings, undefined, runDir);
571
724
  return 'spawned';
572
725
  }
573
726
  if (event.type === 'build.stop') {
@@ -594,7 +747,7 @@ async function spawnWake(api, agentId, apiKey, event, config, extra = {}) {
594
747
  return 'skipped';
595
748
  }
596
749
  (0, daemon_support_1.log)(`agent ${agentId}: plan.trigger task=${event.taskId} -> planning session`);
597
- (0, spawn_1.spawnPlanSession)(agentId, apiKey, event.taskId, event.projectId ?? undefined, extra.settings, undefined, cwd);
750
+ (0, spawn_1.spawnPlanSession)(agentId, apiKey, event.taskId, event.projectId ?? undefined, extra.settings, undefined, runDir);
598
751
  return 'spawned';
599
752
  }
600
753
  if (event.type === 'tool.trigger') {
@@ -603,7 +756,7 @@ async function spawnWake(api, agentId, apiKey, event, config, extra = {}) {
603
756
  return 'skipped';
604
757
  }
605
758
  // Tools-as-tasks: spawn the research/data-collection session for this run.
606
- (0, spawn_1.spawnResearchSession)(agentId, apiKey, event.projectId, event.runId, event.toolKey ?? 'tool', extra.settings, undefined, cwd);
759
+ (0, spawn_1.spawnResearchSession)(agentId, apiKey, event.projectId, event.runId, event.toolKey ?? 'tool', extra.settings, undefined, runDir);
607
760
  // Mark this run as worked so a later reconnect re-sync does not spawn a
608
761
  // duplicate research session for the same run.
609
762
  (0, wake_state_1.recordProcessed)(agentId, 'tool.trigger', String(event.runId));
@@ -657,7 +810,7 @@ async function spawnWake(api, agentId, apiKey, event, config, extra = {}) {
657
810
  catch {
658
811
  /* best-effort */
659
812
  }
660
- (0, spawn_1.spawnBuildSession)(agentId, apiKey, event.projectId, extra.settings, undefined, cwd);
813
+ (0, spawn_1.spawnBuildSession)(agentId, apiKey, event.projectId, extra.settings, undefined, runDir);
661
814
  spawned = true;
662
815
  }
663
816
  else if (event.taskId) {
@@ -667,7 +820,7 @@ async function spawnWake(api, agentId, apiKey, event, config, extra = {}) {
667
820
  // has no record here, so nothing is spawned - the next run is fresh.
668
821
  //
669
822
  // workMode-aware (2026-09): a task in build mode must NEVER resume a
670
- // plan session (the plan conversation opens with "PLAN MODE … must NOT
823
+ // plan session (the plan conversation opens with "PLAN MODE —¦ must NOT
671
824
  // execute" and the model inherits that read-only claim). Tool/research
672
825
  // tasks are always build-mode and skip the plan branch entirely.
673
826
  let workMode;
@@ -677,21 +830,21 @@ async function spawnWake(api, agentId, apiKey, event, config, extra = {}) {
677
830
  workMode = t.workMode;
678
831
  taskKind = t.kind;
679
832
  }
680
- catch { /* best-effort — fall through to session-only logic */ }
833
+ catch { /* best-effort —” fall through to session-only logic */ }
681
834
  const isToolTask = taskKind === 'research' || taskKind === 'tool';
682
835
  const planAllowed = !isToolTask && workMode !== 'build';
683
836
  if ((0, session_store_1.hasHarnessSession)(agentId, event.taskId, 'run')) {
684
837
  (0, daemon_support_1.log)(`agent ${agentId}: question.answered -> resuming run session for task ${event.taskId}`);
685
- (0, spawn_1.spawnAgentSession)(agentId, apiKey, event.taskId, extra.settings, undefined, cwd);
838
+ (0, spawn_1.spawnAgentSession)(agentId, apiKey, event.taskId, extra.settings, undefined, runDir);
686
839
  spawned = true;
687
840
  }
688
841
  else if (planAllowed && (0, session_store_1.hasHarnessSession)(agentId, event.taskId, 'plan')) {
689
842
  (0, daemon_support_1.log)(`agent ${agentId}: question.answered -> resuming plan session for task ${event.taskId}`);
690
- (0, spawn_1.spawnPlanSession)(agentId, apiKey, event.taskId, event.projectId ?? undefined, extra.settings, undefined, cwd);
843
+ (0, spawn_1.spawnPlanSession)(agentId, apiKey, event.taskId, event.projectId ?? undefined, extra.settings, undefined, runDir);
691
844
  spawned = true;
692
845
  }
693
846
  else if ((0, session_store_1.hasHarnessSession)(agentId, event.taskId, 'build')) {
694
- (0, spawn_1.spawnBuildSession)(agentId, apiKey, event.projectId, extra.settings, undefined, cwd);
847
+ (0, spawn_1.spawnBuildSession)(agentId, apiKey, event.projectId, extra.settings, undefined, runDir);
695
848
  spawned = true;
696
849
  }
697
850
  else {
@@ -701,7 +854,7 @@ async function spawnWake(api, agentId, apiKey, event, config, extra = {}) {
701
854
  return spawned ? 'spawned' : 'skipped';
702
855
  }
703
856
  if (event.type === 'question.expired') {
704
- // Expired-on-timeout: same wake path as question.answered — the agent
857
+ // Expired-on-timeout: same wake path as question.answered —” the agent
705
858
  // needs to know the question timed out so it can retry, escalate, or
706
859
  // skip the task. The backend already marked the question expired; we
707
860
  // just wake the agent loop so it picks up the new state.
@@ -726,7 +879,7 @@ async function spawnWake(api, agentId, apiKey, event, config, extra = {}) {
726
879
  catch {
727
880
  /* best-effort */
728
881
  }
729
- (0, spawn_1.spawnBuildSession)(agentId, apiKey, event.projectId, extra.settings, undefined, cwd);
882
+ (0, spawn_1.spawnBuildSession)(agentId, apiKey, event.projectId, extra.settings, undefined, runDir);
730
883
  spawned = true;
731
884
  }
732
885
  else if (event.taskId) {
@@ -743,15 +896,15 @@ async function spawnWake(api, agentId, apiKey, event, config, extra = {}) {
743
896
  const isToolTask = taskKind === 'research' || taskKind === 'tool';
744
897
  const planAllowed = !isToolTask && workMode !== 'build';
745
898
  if ((0, session_store_1.hasHarnessSession)(agentId, event.taskId, 'run')) {
746
- (0, spawn_1.spawnAgentSession)(agentId, apiKey, event.taskId, extra.settings, undefined, cwd);
899
+ (0, spawn_1.spawnAgentSession)(agentId, apiKey, event.taskId, extra.settings, undefined, runDir);
747
900
  spawned = true;
748
901
  }
749
902
  else if (planAllowed && (0, session_store_1.hasHarnessSession)(agentId, event.taskId, 'plan')) {
750
- (0, spawn_1.spawnPlanSession)(agentId, apiKey, event.taskId, event.projectId ?? undefined, extra.settings, undefined, cwd);
903
+ (0, spawn_1.spawnPlanSession)(agentId, apiKey, event.taskId, event.projectId ?? undefined, extra.settings, undefined, runDir);
751
904
  spawned = true;
752
905
  }
753
906
  else if ((0, session_store_1.hasHarnessSession)(agentId, event.taskId, 'build')) {
754
- (0, spawn_1.spawnBuildSession)(agentId, apiKey, event.projectId, extra.settings, undefined, cwd);
907
+ (0, spawn_1.spawnBuildSession)(agentId, apiKey, event.projectId, extra.settings, undefined, runDir);
755
908
  spawned = true;
756
909
  }
757
910
  }
@@ -761,8 +914,8 @@ async function spawnWake(api, agentId, apiKey, event, config, extra = {}) {
761
914
  // Comment mentions on tasks spawn a task session (agent responds in
762
915
  // the task thread). Document comment mentions spawn the doc assistant.
763
916
  if (event.documentId) {
764
- if (projectId && cwd) {
765
- (0, spawn_1.spawnDocumentSession)(agentId, apiKey, event.documentId, event.mention ?? '', extra.settings, undefined, cwd, event.commentId);
917
+ if (projectId && runDir) {
918
+ (0, spawn_1.spawnDocumentSession)(agentId, apiKey, event.documentId, event.mention ?? '', extra.settings, undefined, runDir, event.commentId, event.mentionId ?? undefined, projectId ?? undefined);
766
919
  return 'spawned';
767
920
  }
768
921
  return 'skipped';
@@ -776,16 +929,16 @@ async function spawnWake(api, agentId, apiKey, event, config, extra = {}) {
776
929
  }
777
930
  // Multi-repo: build repoMap from config for spawned sessions.
778
931
  const repoMap = buildRepoMap(projectId);
779
- // IMP-810: Worker mode — spawn the worker loop for orchestrator subtasks
932
+ // IMP-810: Worker mode —” spawn the worker loop for orchestrator subtasks
780
933
  if (event.role === 'worker') {
781
934
  (0, daemon_support_1.log)(`agent ${agentId}: spawning worker session for task ${taskId}`);
782
- (0, spawn_1.spawnWorkerSession)(agentId, apiKey, taskId, event.orchestratorTaskId, extra.settings, undefined, cwd, repoMap);
935
+ (0, spawn_1.spawnWorkerSession)(agentId, apiKey, taskId, event.orchestratorTaskId, extra.settings, undefined, runDir, repoMap);
783
936
  return 'spawned';
784
937
  }
785
938
  // `task.run` is an explicit human "Run task": it must run exactly like an
786
939
  // open task (status bypass) AND, on success, end in `in_review` regardless
787
940
  // of the prior status (including closed/resolved). Flag it for the CLI.
788
- (0, spawn_1.spawnAgentSession)(agentId, apiKey, taskId, extra.settings, undefined, cwd, repoMap, event.type === 'task.run');
941
+ (0, spawn_1.spawnAgentSession)(agentId, apiKey, taskId, extra.settings, undefined, runDir, repoMap, event.type === 'task.run');
789
942
  return 'spawned';
790
943
  }
791
944
  //# sourceMappingURL=wake-handler.js.map