@schoolai/shipyard 3.2.2 → 3.2.3-nightly.20260423.0

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 (32) hide show
  1. package/dist/{auth-5RJ4YKPC.js → auth-LS3NBD42.js} +3 -3
  2. package/dist/{chunk-NZFMJMJN.js → chunk-67VJDX7G.js} +3 -3
  3. package/dist/{chunk-HF5GUPSU.js → chunk-CCW5QAUH.js} +2 -2
  4. package/dist/{chunk-N4ZTO3K3.js → chunk-CUK27MYV.js} +12 -4
  5. package/dist/chunk-CUK27MYV.js.map +1 -0
  6. package/dist/{chunk-DB5R6SKT.js → chunk-DNIC3FOH.js} +2 -2
  7. package/dist/{chunk-5RH5JP3R.js → chunk-GLH3V7NG.js} +2 -2
  8. package/dist/{chunk-KWRAP2UK.js → chunk-GSTE3IXM.js} +5 -5
  9. package/dist/{chunk-KUV4J6NI.js → chunk-M5M6VC5F.js} +1 -2
  10. package/dist/{chunk-KUV4J6NI.js.map → chunk-M5M6VC5F.js.map} +1 -1
  11. package/dist/index.js +8 -8
  12. package/dist/login-O2AKBGIS.js +19 -0
  13. package/dist/{logout-432UPTCL.js → logout-M7F7HXUU.js} +5 -5
  14. package/dist/{mcp-servers-YT5BADYE.js → mcp-servers-MUVTAMDT.js} +4 -4
  15. package/dist/{roi-MVOOANFC.js → roi-ZCVNBSTO.js} +3 -3
  16. package/dist/{serve-O4AHOTL4.js → serve-IMSVRV3B.js} +1049 -333
  17. package/dist/{serve-O4AHOTL4.js.map → serve-IMSVRV3B.js.map} +1 -1
  18. package/dist/{start-G2VIRDQI.js → start-XGODMVQQ.js} +8 -8
  19. package/package.json +3 -3
  20. package/dist/chunk-N4ZTO3K3.js.map +0 -1
  21. package/dist/login-ZPYQLQ52.js +0 -19
  22. /package/dist/{auth-5RJ4YKPC.js.map → auth-LS3NBD42.js.map} +0 -0
  23. /package/dist/{chunk-NZFMJMJN.js.map → chunk-67VJDX7G.js.map} +0 -0
  24. /package/dist/{chunk-HF5GUPSU.js.map → chunk-CCW5QAUH.js.map} +0 -0
  25. /package/dist/{chunk-DB5R6SKT.js.map → chunk-DNIC3FOH.js.map} +0 -0
  26. /package/dist/{chunk-5RH5JP3R.js.map → chunk-GLH3V7NG.js.map} +0 -0
  27. /package/dist/{chunk-KWRAP2UK.js.map → chunk-GSTE3IXM.js.map} +0 -0
  28. /package/dist/{login-ZPYQLQ52.js.map → login-O2AKBGIS.js.map} +0 -0
  29. /package/dist/{logout-432UPTCL.js.map → logout-M7F7HXUU.js.map} +0 -0
  30. /package/dist/{mcp-servers-YT5BADYE.js.map → mcp-servers-MUVTAMDT.js.map} +0 -0
  31. /package/dist/{roi-MVOOANFC.js.map → roi-ZCVNBSTO.js.map} +0 -0
  32. /package/dist/{start-G2VIRDQI.js.map → start-XGODMVQQ.js.map} +0 -0
@@ -47,11 +47,11 @@ import {
47
47
  VaultKeyPutRequestSchema,
48
48
  VaultKeyPutResponseSchema,
49
49
  classifyClaudeCodeCompatibility
50
- } from "./chunk-N4ZTO3K3.js";
50
+ } from "./chunk-CUK27MYV.js";
51
51
  import "./chunk-EHQITHQX.js";
52
52
  import {
53
53
  loadAuthToken
54
- } from "./chunk-5RH5JP3R.js";
54
+ } from "./chunk-GLH3V7NG.js";
55
55
  import {
56
56
  DiscoveryStateSchema,
57
57
  detectMCPServers,
@@ -62,19 +62,19 @@ import {
62
62
  redactEnv,
63
63
  resolveEnabledMcpServers,
64
64
  resolveStdioEnv
65
- } from "./chunk-NZFMJMJN.js";
65
+ } from "./chunk-67VJDX7G.js";
66
66
  import {
67
67
  createChildLogger,
68
68
  flushLogger,
69
69
  logger
70
- } from "./chunk-DB5R6SKT.js";
70
+ } from "./chunk-DNIC3FOH.js";
71
71
  import {
72
72
  external_exports,
73
73
  getShipyardHome,
74
74
  isVanillaAgentMode,
75
75
  toJSONSchema,
76
76
  validateEnv
77
- } from "./chunk-KUV4J6NI.js";
77
+ } from "./chunk-M5M6VC5F.js";
78
78
  import {
79
79
  detectSkills
80
80
  } from "./chunk-DPMRSLYJ.js";
@@ -83,8 +83,8 @@ import {
83
83
  } from "./chunk-2H7UOFLK.js";
84
84
 
85
85
  // src/services/serve.ts
86
- import { mkdir as mkdir25, realpath as realpath2 } from "fs/promises";
87
- import { join as join56 } from "path";
86
+ import { mkdir as mkdir25, realpath as realpath3 } from "fs/promises";
87
+ import { join as join57 } from "path";
88
88
  import { query as query2 } from "@anthropic-ai/claude-agent-sdk";
89
89
 
90
90
  // ../../node_modules/.pnpm/@loro-extended+change@6.0.0-beta.0_loro-crdt@1.11.1/node_modules/@loro-extended/change/dist/index.js
@@ -27270,7 +27270,8 @@ var PlanDetectionSchema = external_exports.object({
27270
27270
  filePath: external_exports.string(),
27271
27271
  planDocId: external_exports.string(),
27272
27272
  allowedPrompts: external_exports.array(AllowedPromptSchema).optional(),
27273
- originalMarkdown: external_exports.string().optional()
27273
+ originalMarkdown: external_exports.string().optional(),
27274
+ approved: external_exports.boolean().optional()
27274
27275
  });
27275
27276
  var TaskRecordSchema = external_exports.object({
27276
27277
  taskId: external_exports.string(),
@@ -27307,9 +27308,10 @@ var TaskRecordSchema = external_exports.object({
27307
27308
  totalTurnCount: external_exports.number().int().nonnegative().default(0),
27308
27309
  mergedAt: external_exports.number().int().positive().nullable().default(null),
27309
27310
  attributedCommitShas: external_exports.array(external_exports.string()).max(50).default([]),
27310
- lastCommitScanSha: external_exports.string().nullable().default(null)
27311
+ lastCommitScanSha: external_exports.string().nullable().default(null),
27312
+ originalCwd: external_exports.string().nullable().default(null)
27311
27313
  });
27312
- var TASK_STORE_VERSION = 11;
27314
+ var TASK_STORE_VERSION = 12;
27313
27315
  var TaskStoreSchema = external_exports.object({
27314
27316
  schemaVersion: external_exports.number(),
27315
27317
  tasks: external_exports.record(external_exports.string(), TaskRecordSchema)
@@ -28220,7 +28222,8 @@ var DaemonToBrowserControlMessageSchema = external_exports.discriminatedUnion("t
28220
28222
  tool: external_exports.string(),
28221
28223
  prompt: external_exports.string()
28222
28224
  })).optional(),
28223
- isRevision: external_exports.boolean().optional()
28225
+ isRevision: external_exports.boolean().optional(),
28226
+ approved: external_exports.boolean().optional()
28224
28227
  }),
28225
28228
  external_exports.object({
28226
28229
  type: external_exports.literal("plan_continuation_timeout"),
@@ -28267,7 +28270,8 @@ var DaemonToBrowserControlMessageSchema = external_exports.discriminatedUnion("t
28267
28270
  userId: external_exports.string(),
28268
28271
  username: external_exports.string(),
28269
28272
  avatarUrl: external_exports.string().nullable().optional(),
28270
- role: CollabRoleSchema
28273
+ role: CollabRoleSchema,
28274
+ connectionId: external_exports.string()
28271
28275
  })),
28272
28276
  expiresAt: external_exports.number()
28273
28277
  })
@@ -28633,7 +28637,12 @@ var HunkRangeSchema = external_exports.object({
28633
28637
  modifiedEnd: external_exports.number()
28634
28638
  });
28635
28639
  var BrowserToFileIOMessageSchema = external_exports.discriminatedUnion("type", [
28636
- external_exports.object({ type: external_exports.literal("read_file"), requestId: external_exports.string(), path: external_exports.string() }),
28640
+ external_exports.object({
28641
+ type: external_exports.literal("read_file"),
28642
+ requestId: external_exports.string(),
28643
+ path: external_exports.string(),
28644
+ external: external_exports.boolean().optional()
28645
+ }),
28637
28646
  external_exports.object({
28638
28647
  type: external_exports.literal("write_file"),
28639
28648
  requestId: external_exports.string(),
@@ -28641,7 +28650,12 @@ var BrowserToFileIOMessageSchema = external_exports.discriminatedUnion("type", [
28641
28650
  content: external_exports.string()
28642
28651
  }),
28643
28652
  external_exports.object({ type: external_exports.literal("readdir"), requestId: external_exports.string(), path: external_exports.string() }),
28644
- external_exports.object({ type: external_exports.literal("stat"), requestId: external_exports.string(), path: external_exports.string() }),
28653
+ external_exports.object({
28654
+ type: external_exports.literal("stat"),
28655
+ requestId: external_exports.string(),
28656
+ path: external_exports.string(),
28657
+ external: external_exports.boolean().optional()
28658
+ }),
28645
28659
  external_exports.object({ type: external_exports.literal("git_status"), requestId: external_exports.string() }),
28646
28660
  external_exports.object({
28647
28661
  type: external_exports.literal("git_diff_file"),
@@ -31283,8 +31297,11 @@ function isProcessAlive(pid) {
31283
31297
  try {
31284
31298
  process.kill(pid, 0);
31285
31299
  return true;
31286
- } catch {
31287
- return false;
31300
+ } catch (err) {
31301
+ if (err instanceof Error && "code" in err && err.code === "ESRCH") {
31302
+ return false;
31303
+ }
31304
+ throw err;
31288
31305
  }
31289
31306
  }
31290
31307
  var LifecycleManager = class {
@@ -31362,15 +31379,36 @@ var LifecycleManager = class {
31362
31379
  const pidFilePath = join9(shipyardHome, "daemon.pid");
31363
31380
  try {
31364
31381
  const existing = await readFile5(pidFilePath, "utf-8");
31365
- const pid = Number.parseInt(existing.trim(), 10);
31366
- if (!Number.isNaN(pid) && isProcessAlive(pid)) {
31367
- this.#log.error(
31368
- { pid, pidFile: pidFilePath },
31369
- "Another daemon is already running. Stop it first or remove the stale PID file."
31382
+ const raw = existing.trim();
31383
+ const pid = Number.parseInt(raw, 10);
31384
+ if (!Number.isInteger(pid) || pid <= 0) {
31385
+ this.#log.warn(
31386
+ { event: "pid_file_corrupt_reclaimed", rawContent: raw, pidFile: pidFilePath },
31387
+ "PID file contains invalid content \u2014 treating as corrupt and reclaiming"
31388
+ );
31389
+ } else {
31390
+ let alive;
31391
+ try {
31392
+ alive = isProcessAlive(pid);
31393
+ } catch (killErr) {
31394
+ this.#log.error(
31395
+ { event: "pid_kill_check_failed", err: killErr, pid, pidFile: pidFilePath },
31396
+ "Cannot determine if existing daemon is alive (EPERM) \u2014 refusing to overwrite PID file"
31397
+ );
31398
+ process.exit(1);
31399
+ }
31400
+ if (alive) {
31401
+ this.#log.error(
31402
+ { pid, pidFile: pidFilePath },
31403
+ "Another daemon is already running. Stop it first or remove the stale PID file."
31404
+ );
31405
+ process.exit(1);
31406
+ }
31407
+ this.#log.info(
31408
+ { event: "pid_reclaimed_stale", stalePid: pid, pidFile: pidFilePath },
31409
+ "Stale PID file found (process no longer running) \u2014 reclaiming"
31370
31410
  );
31371
- process.exit(1);
31372
31411
  }
31373
- this.#log.info({ stalePid: pid, pidFile: pidFilePath }, "Removing stale PID file");
31374
31412
  } catch (err) {
31375
31413
  if (!isEnoent(err)) throw err;
31376
31414
  }
@@ -31690,7 +31728,7 @@ function nanoid(size2 = 21) {
31690
31728
  }
31691
31729
 
31692
31730
  // src/services/bootstrap/signaling.ts
31693
- var DAEMON_NPM_VERSION = true ? "3.2.2" : "unknown";
31731
+ var DAEMON_NPM_VERSION = true ? "3.2.3" : "unknown";
31694
31732
  function createDaemonSignaling(config2) {
31695
31733
  const agentId = config2.agentId ?? nanoid();
31696
31734
  function send(msg) {
@@ -34005,7 +34043,6 @@ async function deleteAdvertisement(shipyardHome) {
34005
34043
 
34006
34044
  // src/services/local-direct/local-direct-wiring.ts
34007
34045
  async function setupLocalDirect(deps) {
34008
- if (!deps.env.SHIPYARD_LOCAL_DIRECT) return null;
34009
34046
  const token = generateLocalDirectToken();
34010
34047
  const server = await createLocalDirectServer({
34011
34048
  token,
@@ -36984,36 +37021,7 @@ function createPRPoller(callbacks, log, resolveTopLevel = getGitTopLevel) {
36984
37021
  resolveTopLevel(cwd).then((topLevel) => {
36985
37022
  if (disposed) return;
36986
37023
  removeFromPreviousRepo(taskId);
36987
- taskToRepo.set(taskId, { topLevel, cwd });
36988
- const existing = repos.get(topLevel);
36989
- if (existing) {
36990
- existing.subscribers.add(taskId);
36991
- if (existing.lastPayload) {
36992
- callbacks.onPRState({ ...existing.lastPayload, taskId });
36993
- }
36994
- log.info({ taskId, topLevel, cwd }, "PR polling joined existing repo");
36995
- return;
36996
- }
36997
- const entry = {
36998
- timer: null,
36999
- burstTimers: [],
37000
- lastContent: "",
37001
- lastBranch: "",
37002
- lastPayload: null,
37003
- cwd,
37004
- subscribers: /* @__PURE__ */ new Set([taskId]),
37005
- isPolling: false
37006
- };
37007
- poll(topLevel, entry).catch((err) => {
37008
- log.warn({ err, topLevel }, "Initial PR poll failed");
37009
- });
37010
- entry.timer = setInterval(() => {
37011
- poll(topLevel, entry).catch((err) => {
37012
- log.warn({ err, topLevel }, "PR poll tick failed");
37013
- });
37014
- }, PR_POLL_INTERVAL_MS);
37015
- repos.set(topLevel, entry);
37016
- log.info({ taskId, topLevel, cwd }, "PR polling started");
37024
+ subscribeToRepo(taskId, topLevel, cwd);
37017
37025
  }).catch((err) => {
37018
37026
  log.warn({ err, taskId, cwd }, "Failed to resolve git toplevel for PR polling");
37019
37027
  });
@@ -37021,6 +37029,64 @@ function createPRPoller(callbacks, log, resolveTopLevel = getGitTopLevel) {
37021
37029
  function getCwd(taskId) {
37022
37030
  return taskToRepo.get(taskId)?.cwd ?? null;
37023
37031
  }
37032
+ function subscribeToRepo(taskId, topLevel, cwd) {
37033
+ taskToRepo.set(taskId, { topLevel, cwd });
37034
+ const existing = repos.get(topLevel);
37035
+ if (existing) {
37036
+ existing.subscribers.add(taskId);
37037
+ if (existing.lastPayload) {
37038
+ callbacks.onPRState({ ...existing.lastPayload, taskId });
37039
+ }
37040
+ log.info({ taskId, topLevel, cwd }, "PR polling joined existing repo");
37041
+ return;
37042
+ }
37043
+ const repoEntry = {
37044
+ timer: null,
37045
+ burstTimers: [],
37046
+ lastContent: "",
37047
+ lastBranch: "",
37048
+ lastPayload: null,
37049
+ cwd,
37050
+ subscribers: /* @__PURE__ */ new Set([taskId]),
37051
+ isPolling: false
37052
+ };
37053
+ poll(topLevel, repoEntry).catch((err) => {
37054
+ log.warn({ err, topLevel }, "Initial PR poll failed");
37055
+ });
37056
+ repoEntry.timer = setInterval(() => {
37057
+ poll(topLevel, repoEntry).catch((err) => {
37058
+ log.warn({ err, topLevel }, "PR poll tick failed");
37059
+ });
37060
+ }, PR_POLL_INTERVAL_MS);
37061
+ repos.set(topLevel, repoEntry);
37062
+ log.info({ taskId, topLevel, cwd }, "PR polling started");
37063
+ }
37064
+ function applyResolvedCwd(taskId, newCwd, newTopLevel, currentTopLevel) {
37065
+ if (newTopLevel === currentTopLevel) {
37066
+ const repoEntry = repos.get(newTopLevel);
37067
+ if (repoEntry) repoEntry.cwd = newCwd;
37068
+ taskToRepo.set(taskId, { topLevel: newTopLevel, cwd: newCwd });
37069
+ log.info({ taskId, topLevel: newTopLevel, cwd: newCwd }, "PR polling cwd updated");
37070
+ return;
37071
+ }
37072
+ removeFromPreviousRepo(taskId);
37073
+ subscribeToRepo(taskId, newTopLevel, newCwd);
37074
+ log.info({ taskId, topLevel: newTopLevel, cwd: newCwd }, "PR polling migrated to new repo");
37075
+ }
37076
+ async function updateCwd(taskId, newCwd) {
37077
+ if (disposed) return;
37078
+ const entry = taskToRepo.get(taskId);
37079
+ if (!entry || entry.cwd === newCwd) return;
37080
+ try {
37081
+ const newTopLevel = await resolveTopLevel(newCwd);
37082
+ if (disposed) return;
37083
+ const currentEntry = taskToRepo.get(taskId);
37084
+ if (!currentEntry || currentEntry.cwd === newCwd) return;
37085
+ applyResolvedCwd(taskId, newCwd, newTopLevel, currentEntry.topLevel);
37086
+ } catch (err) {
37087
+ log.warn({ err, taskId, newCwd }, "Failed to resolve git toplevel for updateCwd");
37088
+ }
37089
+ }
37024
37090
  function stopPolling(taskId) {
37025
37091
  removeFromPreviousRepo(taskId);
37026
37092
  }
@@ -37056,7 +37122,7 @@ function createPRPoller(callbacks, log, resolveTopLevel = getGitTopLevel) {
37056
37122
  repos.clear();
37057
37123
  taskToRepo.clear();
37058
37124
  }
37059
- return { startPolling, stopPolling, forceRefresh, getCwd, dispose };
37125
+ return { startPolling, stopPolling, forceRefresh, getCwd, updateCwd, dispose };
37060
37126
  }
37061
37127
 
37062
37128
  // src/services/serve-factory.ts
@@ -37367,7 +37433,8 @@ async function rehydrateFromPersistence(persistence, taskManager, log, taskState
37367
37433
  mode: record?.mode,
37368
37434
  initialRoiStartedEmitted: record?.roiStartedEmitted ?? false,
37369
37435
  taskCreatedAt: record?.createdAt ?? Date.now(),
37370
- initialTurnCount: record?.totalTurnCount ?? 0
37436
+ initialTurnCount: record?.totalTurnCount ?? 0,
37437
+ initialOriginalCwd: record?.originalCwd ?? null
37371
37438
  });
37372
37439
  log({ event: "task_restored", taskId: action.taskId, kind: "resumable" });
37373
37440
  break;
@@ -66609,22 +66676,22 @@ function isTextSelectionAcrossCells({ $from, $to }) {
66609
66676
  function normalizeSelection(state, tr2, allowTableNodeSelection) {
66610
66677
  const sel = (tr2 || state).selection;
66611
66678
  const doc3 = (tr2 || state).doc;
66612
- let normalize7;
66679
+ let normalize8;
66613
66680
  let role;
66614
66681
  if (sel instanceof NodeSelection && (role = sel.node.type.spec.tableRole)) {
66615
- if (role == "cell" || role == "header_cell") normalize7 = CellSelection.create(doc3, sel.from);
66682
+ if (role == "cell" || role == "header_cell") normalize8 = CellSelection.create(doc3, sel.from);
66616
66683
  else if (role == "row") {
66617
66684
  const $cell = doc3.resolve(sel.from + 1);
66618
- normalize7 = CellSelection.rowSelection($cell, $cell);
66685
+ normalize8 = CellSelection.rowSelection($cell, $cell);
66619
66686
  } else if (!allowTableNodeSelection) {
66620
66687
  const map3 = TableMap.get(sel.node);
66621
66688
  const start = sel.from + 1;
66622
66689
  const lastCell = start + map3.map[map3.width * map3.height - 1];
66623
- normalize7 = CellSelection.create(doc3, start + 1, lastCell);
66690
+ normalize8 = CellSelection.create(doc3, start + 1, lastCell);
66624
66691
  }
66625
- } else if (sel instanceof TextSelection && isCellBoundarySelection(sel)) normalize7 = TextSelection.create(doc3, sel.from);
66626
- else if (sel instanceof TextSelection && isTextSelectionAcrossCells(sel)) normalize7 = TextSelection.create(doc3, sel.$from.start(), sel.$from.end());
66627
- if (normalize7) (tr2 || (tr2 = state.tr)).setSelection(normalize7);
66692
+ } else if (sel instanceof TextSelection && isCellBoundarySelection(sel)) normalize8 = TextSelection.create(doc3, sel.from);
66693
+ else if (sel instanceof TextSelection && isTextSelectionAcrossCells(sel)) normalize8 = TextSelection.create(doc3, sel.$from.start(), sel.$from.end());
66694
+ if (normalize8) (tr2 || (tr2 = state.tr)).setSelection(normalize8);
66628
66695
  return tr2;
66629
66696
  }
66630
66697
  var fixTablesKey = new PluginKey("fix-tables");
@@ -75659,6 +75726,14 @@ var AnthropicAgentSubprocess = class _AnthropicAgentSubprocess {
75659
75726
  #onEvent;
75660
75727
  #spawnMcpServers;
75661
75728
  #harnessTaskIdSetter = null;
75729
+ /**
75730
+ * Mutable slot for the CwdChanged handler. The SDK hook is registered
75731
+ * unconditionally at spawn time and reads through this slot on each
75732
+ * event, so pre-warm (no handler at spawn) → claim (handler installed
75733
+ * via `setOnCwdChanged`) works without re-spawning. Matches the
75734
+ * `#harnessTaskIdSetter` slot pattern.
75735
+ */
75736
+ #onCwdChangedHandler = null;
75662
75737
  /**
75663
75738
  * Set to `true` when the message loop finishes (subprocess exited or errored).
75664
75739
  * Guards all SDK write operations to prevent "ProcessTransport not ready" errors.
@@ -75667,13 +75742,28 @@ var AnthropicAgentSubprocess = class _AnthropicAgentSubprocess {
75667
75742
  #forceKilled = false;
75668
75743
  #pidRef;
75669
75744
  #exitRef;
75670
- constructor(query3, controller, onEvent, spawnMcpServers, pidRef, exitRef) {
75745
+ constructor(query3, controller, onEvent, spawnMcpServers, pidRef, exitRef, initialOnCwdChanged) {
75671
75746
  this.#query = query3;
75672
75747
  this.#controller = controller;
75673
75748
  this.#onEvent = onEvent;
75674
75749
  this.#spawnMcpServers = spawnMcpServers;
75675
75750
  this.#pidRef = pidRef;
75676
75751
  this.#exitRef = exitRef;
75752
+ this.#onCwdChangedHandler = initialOnCwdChanged;
75753
+ }
75754
+ /**
75755
+ * Install or replace the CwdChanged handler. Pre-warm spawns register no
75756
+ * handler at startup (no Task yet); when the subprocess is claimed and
75757
+ * adopted by a Thread, the Task wires its `#applyCwdChange` funnel via
75758
+ * this method so subsequent SDK `CwdChanged` events (including
75759
+ * ExitWorktree, which the regex fallback misses) reach the funnel.
75760
+ */
75761
+ setOnCwdChanged(handler) {
75762
+ this.#onCwdChangedHandler = handler;
75763
+ }
75764
+ /** Dispatch a CwdChanged event to the current handler. Invoked by the SDK hook. */
75765
+ dispatchCwdChanged(newCwd) {
75766
+ this.#onCwdChangedHandler?.(newCwd);
75677
75767
  }
75678
75768
  /** True once the subprocess has exited and SDK writes will throw. */
75679
75769
  get isClosed() {
@@ -75729,6 +75819,7 @@ var AnthropicAgentSubprocess = class _AnthropicAgentSubprocess {
75729
75819
  exitRef.resolveReady();
75730
75820
  }
75731
75821
  const sdkEffort = resolveSupportedEffort(options.effort, log);
75822
+ const subprocessRef = { current: null };
75732
75823
  const queryInstance = queryFn({
75733
75824
  prompt: controller.iterable(),
75734
75825
  options: {
@@ -75751,7 +75842,21 @@ var AnthropicAgentSubprocess = class _AnthropicAgentSubprocess {
75751
75842
  spawnClaudeCodeProcess,
75752
75843
  resumeSessionAt: options.resumeSessionAt,
75753
75844
  forkSession: options.forkSession,
75754
- pathToClaudeCodeExecutable: options.pathToClaudeCodeExecutable
75845
+ pathToClaudeCodeExecutable: options.pathToClaudeCodeExecutable,
75846
+ hooks: {
75847
+ CwdChanged: [
75848
+ {
75849
+ hooks: [
75850
+ async (input) => {
75851
+ if (input.hook_event_name === "CwdChanged") {
75852
+ subprocessRef.current?.dispatchCwdChanged(input.new_cwd);
75853
+ }
75854
+ return {};
75855
+ }
75856
+ ]
75857
+ }
75858
+ ]
75859
+ }
75755
75860
  }
75756
75861
  });
75757
75862
  const spawnMcpServers = options.mcpServers ?? {};
@@ -75761,8 +75866,10 @@ var AnthropicAgentSubprocess = class _AnthropicAgentSubprocess {
75761
75866
  onEvent,
75762
75867
  spawnMcpServers,
75763
75868
  pidRef,
75764
- exitRef
75869
+ exitRef,
75870
+ options.onCwdChanged ?? null
75765
75871
  );
75872
+ subprocessRef.current = subprocess;
75766
75873
  if (initialContent.length > 0) {
75767
75874
  controller.push(toSdkContent(initialContent));
75768
75875
  }
@@ -76071,7 +76178,8 @@ function buildSpawnOptions(args) {
76071
76178
  log,
76072
76179
  metricsCollector,
76073
76180
  claudeCodePath,
76074
- disallowedTools
76181
+ disallowedTools,
76182
+ onCwdChanged
76075
76183
  } = args;
76076
76184
  const vanillaMode = isVanillaAgentMode();
76077
76185
  const mcpServers = {
@@ -76128,7 +76236,8 @@ ${additionalSystemPrompt}` : basePrompt;
76128
76236
  limitMB: sample.limitMB,
76129
76237
  thresholdExceeded: sample.thresholdExceeded
76130
76238
  });
76131
- }
76239
+ },
76240
+ onCwdChanged
76132
76241
  };
76133
76242
  }
76134
76243
  async function detectInitialCapabilities(tokenStore, settings) {
@@ -83101,6 +83210,9 @@ var DirectApiSubprocess = class _DirectApiSubprocess {
83101
83210
  /** No-op in Phase 1 -- harness task context not used by direct API mode. */
83102
83211
  setHarnessTaskId(_taskId) {
83103
83212
  }
83213
+ /** No-op: direct API mode does not surface SDK CwdChanged events. */
83214
+ setOnCwdChanged(_handler) {
83215
+ }
83104
83216
  async #stream() {
83105
83217
  this.#abortController = new AbortController();
83106
83218
  const startTime = Date.now();
@@ -84572,16 +84684,18 @@ async function buildRateLimitStore(dataDir, opts) {
84572
84684
  }
84573
84685
  };
84574
84686
  }
84687
+ function deriveWindowFromEvent(incoming, eventTime) {
84688
+ if (!isWindowKey(incoming.rateLimitType)) return void 0;
84689
+ const utilization = typeof incoming.utilization === "number" ? incoming.utilization : incoming.status === "rejected" ? 1 : void 0;
84690
+ if (utilization === void 0) return void 0;
84691
+ return { utilization, resetsAt: incoming.resetsAt, updatedAt: eventTime };
84692
+ }
84575
84693
  function mergeEvent(prev, incoming, eventTime) {
84576
84694
  const prevByWindow = prev?.byWindow ?? {};
84577
84695
  const byWindow = { ...prevByWindow };
84578
- if (isWindowKey(incoming.rateLimitType) && typeof incoming.utilization === "number") {
84579
- const window2 = {
84580
- utilization: incoming.utilization,
84581
- resetsAt: incoming.resetsAt,
84582
- updatedAt: eventTime
84583
- };
84584
- byWindow[incoming.rateLimitType] = window2;
84696
+ const derived = deriveWindowFromEvent(incoming, eventTime);
84697
+ if (derived && isWindowKey(incoming.rateLimitType)) {
84698
+ byWindow[incoming.rateLimitType] = derived;
84585
84699
  }
84586
84700
  const nowInOverage = incoming.isUsingOverage === true;
84587
84701
  const wasInOverage = prev?.overageStartedAt !== void 0;
@@ -84635,12 +84749,15 @@ function migrateV1toV2(v1) {
84635
84749
  for (const [accountKey, record] of Object.entries(v1.records)) {
84636
84750
  const { info, updatedAt } = record;
84637
84751
  const byWindow = {};
84638
- if (isWindowKey(info.rateLimitType) && typeof info.utilization === "number") {
84639
- byWindow[info.rateLimitType] = {
84640
- utilization: info.utilization,
84641
- resetsAt: info.resetsAt,
84642
- updatedAt
84643
- };
84752
+ if (isWindowKey(info.rateLimitType)) {
84753
+ const utilization = typeof info.utilization === "number" ? info.utilization : info.status === "rejected" ? 1 : void 0;
84754
+ if (utilization !== void 0) {
84755
+ byWindow[info.rateLimitType] = {
84756
+ utilization,
84757
+ resetsAt: info.resetsAt,
84758
+ updatedAt
84759
+ };
84760
+ }
84644
84761
  }
84645
84762
  const nowInOverage = info.isUsingOverage === true;
84646
84763
  records[accountKey] = {
@@ -86967,6 +87084,9 @@ var Thread = class {
86967
87084
  this.#subprocess = config2.adoptedSubprocess.subprocess;
86968
87085
  this.#effectiveSpawnMode = { kind: "fresh" };
86969
87086
  config2.adoptedSubprocess.redirectEvents((event) => this.#enqueueSubprocessEvent(event));
87087
+ if (config2.onCwdChanged) {
87088
+ this.#subprocess.setOnCwdChanged(config2.onCwdChanged);
87089
+ }
86970
87090
  }
86971
87091
  const warmState = "warm_idle";
86972
87092
  const initialSnapshot = config2.adoptedSubprocess ? { state: warmState, sessionId: null } : config2.initialState && config2.initialState !== "cold_idle" ? {
@@ -87129,6 +87249,7 @@ var Thread = class {
87129
87249
  this.#stateChangeListeners.clear();
87130
87250
  this.#manager.dispose();
87131
87251
  await this.#asyncQueue;
87252
+ this.#subprocess?.setOnCwdChanged(null);
87132
87253
  this.#subprocess?.close();
87133
87254
  this.#subprocess = null;
87134
87255
  }
@@ -87293,7 +87414,8 @@ ${conversationReplay}` : conversationReplay;
87293
87414
  this.#config.threadId,
87294
87415
  systemPrompt,
87295
87416
  this.#config.disallowedTools,
87296
- this.#config.mode
87417
+ this.#config.mode,
87418
+ this.#config.onCwdChanged
87297
87419
  );
87298
87420
  };
87299
87421
  doSpawn().catch((err) => {
@@ -87369,6 +87491,7 @@ ${conversationReplay}` : conversationReplay;
87369
87491
  });
87370
87492
  }
87371
87493
  #handleClose() {
87494
+ this.#subprocess?.setOnCwdChanged(null);
87372
87495
  this.#subprocess?.close();
87373
87496
  this.#subprocess = null;
87374
87497
  }
@@ -87585,6 +87708,47 @@ ${conversationReplay}` : conversationReplay;
87585
87708
  }
87586
87709
  });
87587
87710
  }
87711
+ /**
87712
+ * Deduped append for echoed user messages. Browser-originated writes carry
87713
+ * a correlationId so dedup catches the twin row via `corr:<id>`. Synthetic
87714
+ * pushes (plan injection, side threads, model-set) have no correlationId —
87715
+ * we pass `event.sdkUuid` so the `sdk:<uuid>` pass catches SDK replays
87716
+ * instead of falling back to the 60s fingerprint window, which would
87717
+ * wrongly collapse two distinct synthetic pushes with identical content.
87718
+ */
87719
+ async #handleUserMessageEcho(event) {
87720
+ const echoMsgId = crypto.randomUUID();
87721
+ const meta = this.#callbacks.onBeforeStoreUserMessage?.(event);
87722
+ const result = await this.#config.store.appendMessageDeduped(
87723
+ {
87724
+ channelId: this.#config.channelId,
87725
+ messageId: echoMsgId,
87726
+ participantId: meta?.participantId ?? this.#config.humanParticipantId ?? PENDING_AGENT_PARTICIPANT_ID,
87727
+ senderKind: "human",
87728
+ content: meta?.content ?? event.content,
87729
+ timestamp: Date.now(),
87730
+ model: meta?.model ?? null,
87731
+ reasoningEffort: meta?.reasoningEffort ?? null,
87732
+ permissionMode: meta?.permissionMode ?? null,
87733
+ ...meta?.isSynthetic && { isSynthetic: true },
87734
+ ...meta?.correlationId && { correlationId: meta.correlationId },
87735
+ ...event.sdkUuid && { sdkUuid: event.sdkUuid }
87736
+ },
87737
+ {}
87738
+ );
87739
+ if (result.isDuplicate) {
87740
+ this.#config.log({
87741
+ event: "thread_echo_dedup_hit",
87742
+ threadId: this.#config.threadId,
87743
+ dedupKey: result.dedupKey,
87744
+ seqNo: result.seqNo
87745
+ });
87746
+ }
87747
+ this.#callbacks.onMessageStored?.(result.seqNo, echoMsgId, "human", event.sdkUuid);
87748
+ if (meta?.correlationId) {
87749
+ this.#callbacks.onUserMessageConfirmed?.(meta.correlationId, event.sdkUuid);
87750
+ }
87751
+ }
87588
87752
  async #handleSubprocessEvent(event) {
87589
87753
  switch (event.type) {
87590
87754
  case "init_received":
@@ -87639,25 +87803,7 @@ ${conversationReplay}` : conversationReplay;
87639
87803
  case "rate_limit_error":
87640
87804
  break;
87641
87805
  case "user_message_echo": {
87642
- const echoMsgId = crypto.randomUUID();
87643
- const meta = this.#callbacks.onBeforeStoreUserMessage?.(event);
87644
- const echoSeqNo = await this.#config.store.appendMessage({
87645
- channelId: this.#config.channelId,
87646
- messageId: echoMsgId,
87647
- participantId: meta?.participantId ?? this.#config.humanParticipantId ?? PENDING_AGENT_PARTICIPANT_ID,
87648
- senderKind: "human",
87649
- content: meta?.content ?? event.content,
87650
- timestamp: Date.now(),
87651
- model: meta?.model ?? null,
87652
- reasoningEffort: meta?.reasoningEffort ?? null,
87653
- permissionMode: meta?.permissionMode ?? null,
87654
- ...meta?.isSynthetic && { isSynthetic: true },
87655
- ...meta?.correlationId && { correlationId: meta.correlationId }
87656
- });
87657
- this.#callbacks.onMessageStored?.(echoSeqNo, echoMsgId, "human", event.sdkUuid);
87658
- if (meta?.correlationId) {
87659
- this.#callbacks.onUserMessageConfirmed?.(meta.correlationId, event.sdkUuid);
87660
- }
87806
+ await this.#handleUserMessageEcho(event);
87661
87807
  break;
87662
87808
  }
87663
87809
  case "assistant_message": {
@@ -87735,6 +87881,7 @@ function skipForMainChannel(content) {
87735
87881
 
87736
87882
  // src/services/plan/plan-handler.ts
87737
87883
  import { existsSync as existsSync7, readdirSync as readdirSync2, statSync as statSync2 } from "fs";
87884
+ import { readFile as readFile30 } from "fs/promises";
87738
87885
  import { homedir as homedir4 } from "os";
87739
87886
  import { join as join39 } from "path";
87740
87887
 
@@ -87812,6 +87959,80 @@ var PlanFileBridge = class _PlanFileBridge {
87812
87959
  });
87813
87960
  }, PERIODIC_REREAD_MS);
87814
87961
  }
87962
+ /**
87963
+ * Re-initialize the bridge for a plan file known from a prior daemon session.
87964
+ *
87965
+ * Differs from `attach()` in that the caller explicitly decides whether to
87966
+ * preserve CRDT content. This lets PlanHandler do a three-way compare
87967
+ * (file vs CRDT vs last stored PlanVersion) and tell the bridge which side
87968
+ * is authoritative:
87969
+ *
87970
+ * - `preserveCrdt: false` — file is truth (fresh daemon start, or Claude
87971
+ * rewrote the plan while the daemon was down). Overwrite CRDT with file
87972
+ * content, same as `attach()`.
87973
+ * - `preserveCrdt: true` — CRDT has edits not yet written to disk (browser
87974
+ * edited before the daemon's debounced write-back fired). Keep CRDT
87975
+ * content; the subscription below will flush it on the next loro change.
87976
+ *
87977
+ * Returns `{ fileContent }` so callers can emit catch-up versions, or
87978
+ * `null` if the file is unreadable.
87979
+ */
87980
+ async reattach(filePath, options) {
87981
+ if (this.#disposed) return null;
87982
+ this.#dirWatcher?.close();
87983
+ this.#dirWatcher = null;
87984
+ this.#crdtUnsub?.();
87985
+ this.#crdtUnsub = null;
87986
+ if (this.#periodicRereadTimer) {
87987
+ clearInterval(this.#periodicRereadTimer);
87988
+ this.#periodicRereadTimer = null;
87989
+ }
87990
+ let fileContent;
87991
+ try {
87992
+ fileContent = await this.#readFileWithRetry(filePath);
87993
+ } catch (err) {
87994
+ this.#config.log({
87995
+ event: "plan_bridge_reattach_read_failed",
87996
+ taskId: this.#config.taskId,
87997
+ filePath,
87998
+ error: err instanceof Error ? err.message : String(err)
87999
+ });
88000
+ return null;
88001
+ }
88002
+ this.#filePath = filePath;
88003
+ this.#config.planRepo.setMetadata(this.#config.taskId, filePath);
88004
+ this.#lastWrittenHash = contentHash(fileContent);
88005
+ if (!options.preserveCrdt) {
88006
+ this.#config.planRepo.updateContent(this.#config.taskId, fileContent);
88007
+ }
88008
+ this.#hasAttachedContent = true;
88009
+ this.#config.log({
88010
+ event: "plan_bridge_reattached",
88011
+ taskId: this.#config.taskId,
88012
+ filePath,
88013
+ crdtPreserved: options.preserveCrdt,
88014
+ fileLength: fileContent.length
88015
+ });
88016
+ this.#startDirectoryWatcher(filePath);
88017
+ this.#startCrdtSubscription();
88018
+ this.#periodicRereadTimer = setInterval(() => {
88019
+ this.#handleFileChanged().catch(() => {
88020
+ });
88021
+ }, PERIODIC_REREAD_MS);
88022
+ if (options.preserveCrdt) {
88023
+ try {
88024
+ await this.flushWriteBack();
88025
+ } catch (err) {
88026
+ this.#config.log({
88027
+ event: "plan_bridge_reattach_flush_failed",
88028
+ taskId: this.#config.taskId,
88029
+ filePath,
88030
+ error: err instanceof Error ? err.message : String(err)
88031
+ });
88032
+ }
88033
+ }
88034
+ return { fileContent };
88035
+ }
87815
88036
  /**
87816
88037
  * Watch the plans DIRECTORY (not the file) for changes.
87817
88038
  * This survives inode changes from atomic writes (tmp + rename).
@@ -88234,8 +88455,177 @@ var PlanHandler = class {
88234
88455
  this.#lastPersistedPlanDetection = deps.initialPlanDetection;
88235
88456
  this.#planPublished = true;
88236
88457
  this.#processedPlanToolUseIds.add(deps.initialPlanDetection.toolUseId);
88458
+ const seedFilePath = deps.initialPlanDetection.filePath;
88459
+ const seedToolUseId = deps.initialPlanDetection.toolUseId;
88460
+ deps.enqueueAsync(() => this.#rehydratePlanFileBridge(seedFilePath, seedToolUseId));
88461
+ }
88462
+ }
88463
+ async #rehydratePlanFileBridge(filePath, toolUseId) {
88464
+ if (this.#deps.isDisposed()) return;
88465
+ if (!existsSync7(filePath)) {
88466
+ this.#deps.log({
88467
+ event: "plan_bridge_rehydrate_file_missing",
88468
+ taskId: this.#deps.taskId,
88469
+ filePath
88470
+ });
88471
+ return;
88472
+ }
88473
+ const fileContent = await this.#readFileForRehydrate(filePath);
88474
+ if (fileContent === null || this.#deps.isDisposed()) return;
88475
+ const versions = await this.#deps.annotationStore.getPlanVersions(this.#deps.taskId);
88476
+ if (this.#deps.isDisposed()) return;
88477
+ const lastVersionMarkdown = versions.at(-1)?.markdown ?? "";
88478
+ const preserveCrdt = this.#shouldPreserveCrdt(fileContent, lastVersionMarkdown);
88479
+ const result = await this.#ensureBridgeAttached(filePath, toolUseId, {
88480
+ mode: "rehydrate",
88481
+ preserveCrdt
88482
+ });
88483
+ if (!result || this.#deps.isDisposed()) return;
88484
+ await this.#maybeEmitRehydrateCatchup(lastVersionMarkdown, preserveCrdt, toolUseId);
88485
+ }
88486
+ /**
88487
+ * Three-way compare of {file on disk, CRDT content, last stored PlanVersion}:
88488
+ *
88489
+ * Case 1 (all equal) → preserveCrdt=false (no-op attach)
88490
+ * Case 2 (file drifted) → preserveCrdt=false (file wins)
88491
+ * Case 3 (browser-edited CRDT) → preserveCrdt=true (keep in-flight edits)
88492
+ * Case 4 (both drifted) → preserveCrdt=false (file wins)
88493
+ *
88494
+ * Case 3 also requires a non-empty lastVersionMarkdown — otherwise a
88495
+ * brand-new task with no prior stored versions would preserve whatever
88496
+ * happens to be in CRDT (e.g. a stale browser session's edits on a fresh
88497
+ * epoch) over an authoritative empty file.
88498
+ */
88499
+ #shouldPreserveCrdt(fileContent, lastVersionMarkdown) {
88500
+ if (lastVersionMarkdown === "") return false;
88501
+ const crdtContent = this.#deps.planRepo.getContent(this.#deps.taskId);
88502
+ const fileMatchesLast = fileContent === lastVersionMarkdown;
88503
+ const crdtHasUnsavedEdits = crdtContent.length > 0 && crdtContent !== lastVersionMarkdown;
88504
+ return fileMatchesLast && crdtHasUnsavedEdits;
88505
+ }
88506
+ /**
88507
+ * Post-attach catch-up: read whatever CRDT now holds (file content or
88508
+ * preserved CRDT content) and emit a single version if it drifted from
88509
+ * `lastVersionMarkdown`. Covers file-drifted and browser-edited cases.
88510
+ *
88511
+ * Source labeling follows authoritative side: preserved CRDT = 'human',
88512
+ * file-overwritten CRDT = 'agent'. Mislabeling would corrupt
88513
+ * `findLastAgentVersion`'s baseline for plan-review diffs.
88514
+ */
88515
+ async #maybeEmitRehydrateCatchup(lastVersionMarkdown, preserveCrdt, toolUseId) {
88516
+ const postAttachMarkdown = this.#deps.planRepo.getContent(this.#deps.taskId);
88517
+ if (postAttachMarkdown.length === 0 || postAttachMarkdown === lastVersionMarkdown) return;
88518
+ const source = preserveCrdt ? "human" : "agent";
88519
+ await this.#emitCatchupVersion(postAttachMarkdown, toolUseId, source);
88520
+ }
88521
+ async #readFileForRehydrate(filePath) {
88522
+ try {
88523
+ return await readFile30(filePath, "utf-8");
88524
+ } catch (err) {
88525
+ this.#deps.log({
88526
+ event: "plan_bridge_rehydrate_read_failed",
88527
+ taskId: this.#deps.taskId,
88528
+ filePath,
88529
+ error: err instanceof Error ? err.message : String(err)
88530
+ });
88531
+ return null;
88532
+ }
88533
+ }
88534
+ async #emitCatchupVersion(markdown, toolUseId, source) {
88535
+ const version = {
88536
+ markdown,
88537
+ timestamp: Date.now(),
88538
+ source,
88539
+ toolUseId
88540
+ };
88541
+ try {
88542
+ await this.#deps.annotationStore.addPlanVersion(this.#deps.taskId, version);
88543
+ const msg = {
88544
+ type: "annotation_version_added",
88545
+ taskId: this.#deps.taskId,
88546
+ version
88547
+ };
88548
+ const send = this.#deps.getSendControlMessage();
88549
+ if (send) {
88550
+ send(msg);
88551
+ } else {
88552
+ this.#deps.enqueueControlMessage(msg);
88553
+ }
88554
+ } catch (err) {
88555
+ this.#deps.log({
88556
+ event: "plan_version_add_failed",
88557
+ taskId: this.#deps.taskId,
88558
+ error: err instanceof Error ? err.message : String(err)
88559
+ });
88237
88560
  }
88238
88561
  }
88562
+ /**
88563
+ * Ensure a PlanFileBridge is attached for this task at the given file path.
88564
+ * Creates the bridge lazily and routes attach vs reattach based on options:
88565
+ *
88566
+ * - `mode: 'fresh'` — new ExitPlanMode cycle, no prior context. Uses
88567
+ * `attach()` which overwrites CRDT with file content.
88568
+ * - `mode: 'rehydrate'` — daemon restart. Uses `reattach()` with an
88569
+ * explicit `preserveCrdt` flag so the caller's three-way compare picks
88570
+ * the authoritative side.
88571
+ *
88572
+ * Noop if the bridge is already attached to the same filePath. Re-attaches
88573
+ * if the bridge points at a different filePath (Scenario A: a 2nd
88574
+ * ExitPlanMode cycle writes to a new UUID).
88575
+ */
88576
+ async #ensureBridgeAttached(filePath, toolUseId, options) {
88577
+ if (this.#deps.isDisposed()) return null;
88578
+ const isFirstAttach = !this.#planFileBridge;
88579
+ if (!this.#planFileBridge) {
88580
+ this.#planFileBridge = this.#createPlanFileBridge(toolUseId);
88581
+ } else if (this.#planFileBridge.filePath === filePath) {
88582
+ return { isFirstAttach: false };
88583
+ }
88584
+ if (options.mode === "rehydrate") {
88585
+ const result = await this.#planFileBridge.reattach(filePath, {
88586
+ preserveCrdt: options.preserveCrdt
88587
+ });
88588
+ if (!result) return null;
88589
+ } else {
88590
+ await this.#planFileBridge.attach(filePath);
88591
+ }
88592
+ if (this.#deps.isDisposed()) return null;
88593
+ return { isFirstAttach };
88594
+ }
88595
+ #createPlanFileBridge(fallbackToolUseId) {
88596
+ return new PlanFileBridge({
88597
+ taskId: this.#deps.taskId,
88598
+ planRepo: this.#deps.planRepo,
88599
+ log: this.#deps.log,
88600
+ onBeforeContentUpdate: (currentMarkdown) => {
88601
+ const version = {
88602
+ markdown: currentMarkdown,
88603
+ timestamp: Date.now(),
88604
+ source: "agent",
88605
+ toolUseId: this.#lastPlanDetection?.toolUseId ?? fallbackToolUseId
88606
+ };
88607
+ this.#deps.annotationStore.addPlanVersion(this.#deps.taskId, version).then(() => {
88608
+ const msg = {
88609
+ type: "annotation_version_added",
88610
+ taskId: this.#deps.taskId,
88611
+ version
88612
+ };
88613
+ const send = this.#deps.getSendControlMessage();
88614
+ if (send) {
88615
+ send(msg);
88616
+ } else {
88617
+ this.#deps.enqueueControlMessage(msg);
88618
+ }
88619
+ }).catch((err) => {
88620
+ this.#deps.log({
88621
+ event: "plan_version_add_failed",
88622
+ taskId: this.#deps.taskId,
88623
+ error: err instanceof Error ? err.message : String(err)
88624
+ });
88625
+ });
88626
+ }
88627
+ });
88628
+ }
88239
88629
  /**
88240
88630
  * Persist a detection (or null clear) with shadow-pending semantics:
88241
88631
  * optimistically update #lastPlanDetection so concurrent reads see the new
@@ -88292,14 +88682,17 @@ var PlanHandler = class {
88292
88682
  });
88293
88683
  if (this.#approvalReceived) {
88294
88684
  this.#approvalReceived = false;
88295
- this.#persistDetection(null).catch((err) => {
88296
- this.#deps.log({
88297
- event: "plan_persist_clear_failed",
88298
- taskId: this.#deps.taskId,
88299
- toolUseId,
88300
- error: err instanceof Error ? err.message : String(err)
88685
+ const previous = this.#lastPlanDetection;
88686
+ if (previous) {
88687
+ this.#persistDetection({ ...previous, approved: true }).catch((err) => {
88688
+ this.#deps.log({
88689
+ event: "plan_persist_approve_failed",
88690
+ taskId: this.#deps.taskId,
88691
+ toolUseId,
88692
+ error: err instanceof Error ? err.message : String(err)
88693
+ });
88301
88694
  });
88302
- });
88695
+ }
88303
88696
  this.#deps.log({
88304
88697
  event: "exit_plan_mode_approved",
88305
88698
  taskId: this.#deps.taskId,
@@ -88368,9 +88761,10 @@ var PlanHandler = class {
88368
88761
  });
88369
88762
  }
88370
88763
  detectPlanEvents(content) {
88371
- if (this.#approvalReceived || this.#planPublished) return;
88764
+ if (this.#approvalReceived) return;
88372
88765
  for (const block2 of content) {
88373
88766
  if (block2.type !== "tool_use" || block2.toolName !== "ExitPlanMode") continue;
88767
+ if (this.#processedPlanToolUseIds.has(block2.toolUseId)) continue;
88374
88768
  this.#deps.log({
88375
88769
  event: "exit_plan_mode_detected",
88376
88770
  taskId: this.#deps.taskId,
@@ -88402,7 +88796,7 @@ var PlanHandler = class {
88402
88796
  */
88403
88797
  replayPlanDetection(send) {
88404
88798
  if (!this.#lastPlanDetection) return;
88405
- const { toolUseId, filePath, planDocId, allowedPrompts } = this.#lastPlanDetection;
88799
+ const { toolUseId, filePath, planDocId, allowedPrompts, approved } = this.#lastPlanDetection;
88406
88800
  const markdown = this.#deps.planRepo.getContent(this.#deps.taskId);
88407
88801
  send({
88408
88802
  type: "plan_detected",
@@ -88411,7 +88805,8 @@ var PlanHandler = class {
88411
88805
  filePath,
88412
88806
  planDocId,
88413
88807
  markdown,
88414
- allowedPrompts
88808
+ allowedPrompts,
88809
+ approved
88415
88810
  });
88416
88811
  }
88417
88812
  /**
@@ -88534,51 +88929,32 @@ var PlanHandler = class {
88534
88929
  this.#pendingPlanAllowedPrompts.delete(toolUseId);
88535
88930
  this.#deps.enqueueAsync(async () => {
88536
88931
  if (this.#deps.isDisposed()) return;
88537
- const isFirstAttach = !this.#planFileBridge;
88538
- if (!this.#planFileBridge) {
88539
- this.#planFileBridge = new PlanFileBridge({
88540
- taskId: this.#deps.taskId,
88541
- planRepo: this.#deps.planRepo,
88542
- log: this.#deps.log,
88543
- onBeforeContentUpdate: (currentMarkdown) => {
88544
- const version = {
88545
- markdown: currentMarkdown,
88546
- timestamp: Date.now(),
88547
- source: "agent",
88548
- toolUseId: this.#lastPlanDetection?.toolUseId ?? toolUseId
88549
- };
88550
- this.#deps.annotationStore.addPlanVersion(this.#deps.taskId, version).then(() => {
88551
- this.#deps.getSendControlMessage()?.({
88552
- type: "annotation_version_added",
88553
- taskId: this.#deps.taskId,
88554
- version
88555
- });
88556
- }).catch((err) => {
88557
- this.#deps.log({
88558
- event: "plan_version_add_failed",
88559
- taskId: this.#deps.taskId,
88560
- error: err instanceof Error ? err.message : String(err)
88561
- });
88562
- });
88563
- }
88564
- });
88565
- }
88566
- await this.#planFileBridge.attach(filePath);
88932
+ const attachResult = await this.#ensureBridgeAttached(filePath, toolUseId, {
88933
+ mode: "fresh"
88934
+ });
88935
+ if (!attachResult) return;
88567
88936
  const markdown = this.#deps.planRepo.getContent(this.#deps.taskId);
88568
- if (isFirstAttach && markdown.length > 0) {
88569
- const initialVersion = {
88570
- markdown,
88571
- timestamp: Date.now(),
88572
- source: "agent",
88573
- toolUseId
88574
- };
88575
- await this.#deps.annotationStore.addPlanVersion(this.#deps.taskId, initialVersion);
88576
- this.#deps.getSendControlMessage()?.({
88577
- type: "annotation_version_added",
88578
- taskId: this.#deps.taskId,
88579
- version: initialVersion
88580
- });
88937
+ if (markdown.length > 0) {
88938
+ const existingVersions = await this.#deps.annotationStore.getPlanVersions(
88939
+ this.#deps.taskId
88940
+ );
88941
+ const lastVersion = existingVersions.at(-1);
88942
+ if (lastVersion?.markdown !== markdown) {
88943
+ const initialVersion = {
88944
+ markdown,
88945
+ timestamp: Date.now(),
88946
+ source: "agent",
88947
+ toolUseId
88948
+ };
88949
+ await this.#deps.annotationStore.addPlanVersion(this.#deps.taskId, initialVersion);
88950
+ this.#deps.getSendControlMessage()?.({
88951
+ type: "annotation_version_added",
88952
+ taskId: this.#deps.taskId,
88953
+ version: initialVersion
88954
+ });
88955
+ }
88581
88956
  }
88957
+ const isFirstAttach = attachResult.isFirstAttach;
88582
88958
  const pendingDetection = {
88583
88959
  toolUseId,
88584
88960
  filePath,
@@ -89230,6 +89606,16 @@ var ResourcePushManager = class {
89230
89606
  #batchTimer;
89231
89607
  #pushCountThisTurn = 0;
89232
89608
  #pushedThisTurn = /* @__PURE__ */ new Set();
89609
+ /**
89610
+ * Ref-counted session-lifetime claims. Each `markPushed` increments;
89611
+ * each `unmarkPushed` decrements. Entries are deleted when the count
89612
+ * hits zero. Used for "inject exactly once per subprocess session"
89613
+ * callers (the task-list prepend) where two concurrent paths (sync
89614
+ * prepend + async push pipeline) may both legitimately claim the URI.
89615
+ * A plain Set would let an optimistic claim's rollback erase another
89616
+ * caller's successful mark; the ref count prevents that.
89617
+ */
89618
+ #pushedEverCount = /* @__PURE__ */ new Map();
89233
89619
  #disposed = false;
89234
89620
  #epoch = 0;
89235
89621
  #flushInProgress = Promise.resolve();
@@ -89282,12 +89668,46 @@ var ResourcePushManager = class {
89282
89668
  return this.#pushedThisTurn;
89283
89669
  }
89284
89670
  /**
89285
- * Mark a URI as already pushed this turn (e.g. when injected
89286
- * synchronously in handleBeforeSpawn rather than via the async
89287
- * push pipeline).
89671
+ * Whether a URI has been pushed at least once this session. For one-shot
89672
+ * callers (task-list prepend) to decide whether to inject again.
89673
+ */
89674
+ isSessionPushed(uri) {
89675
+ return this.#pushedEverCount.has(uri);
89676
+ }
89677
+ /**
89678
+ * Seed the session-lifetime claim for URIs already pushed in a prior
89679
+ * daemon run (resumable_idle tasks whose JSONL already contains a
89680
+ * prepend synthetic). Without this, restart re-injects the task-list
89681
+ * because the fresh push manager has an empty count map.
89682
+ */
89683
+ seedSessionPushed(uri) {
89684
+ if (!this.#pushedEverCount.has(uri)) this.#pushedEverCount.set(uri, 1);
89685
+ }
89686
+ /**
89687
+ * Mark a URI as pushed this turn AND record a session-lifetime claim.
89688
+ * Call from both the sync prepend (before awaiting resolve) and the
89689
+ * async push pipeline (after a successful write). Pair each call with
89690
+ * `unmarkPushed` only if the operation did NOT ultimately write.
89288
89691
  */
89289
89692
  markPushed(uri) {
89290
89693
  this.#pushedThisTurn.add(uri);
89694
+ this.#pushedEverCount.set(uri, (this.#pushedEverCount.get(uri) ?? 0) + 1);
89695
+ }
89696
+ /**
89697
+ * Release a `markPushed` claim. Decrements the session-lifetime count —
89698
+ * the entry is only removed when the count hits zero, so a concurrent
89699
+ * caller's successful write stays recorded even if this caller rolls
89700
+ * back. Also clears the per-turn set unconditionally (a successful
89701
+ * parallel caller re-adds it as needed before its own write).
89702
+ */
89703
+ unmarkPushed(uri) {
89704
+ this.#pushedThisTurn.delete(uri);
89705
+ const current2 = this.#pushedEverCount.get(uri) ?? 0;
89706
+ if (current2 <= 1) {
89707
+ this.#pushedEverCount.delete(uri);
89708
+ } else {
89709
+ this.#pushedEverCount.set(uri, current2 - 1);
89710
+ }
89291
89711
  }
89292
89712
  /** Permanently shut down. Use reset() for clearSession. */
89293
89713
  dispose() {
@@ -89309,6 +89729,7 @@ var ResourcePushManager = class {
89309
89729
  this.#subscriptions.clear();
89310
89730
  this.#dirtyUris.clear();
89311
89731
  this.#pushedThisTurn.clear();
89732
+ this.#pushedEverCount.clear();
89312
89733
  this.#pushCountThisTurn = 0;
89313
89734
  }
89314
89735
  #markDirty(uri) {
@@ -89419,35 +89840,11 @@ var ResourcePushManager = class {
89419
89840
  this.#batchTimer.reset();
89420
89841
  const batch = this.#drainDirtyBatch();
89421
89842
  if (!batch) return;
89843
+ const markedUris = /* @__PURE__ */ new Set();
89422
89844
  try {
89423
- const resolved = await this.#resolveBatch(batch);
89424
- if (this.#disposed || this.#epoch !== startEpoch) return;
89425
- if (resolved.size === 0) {
89426
- if (this.#dirtyUris.size > 0) this.#batchTimer.schedule();
89427
- return;
89428
- }
89429
- const plan = await this.#planPush(resolved);
89430
- if (this.#disposed || this.#epoch !== startEpoch || plan.syntheticMessages.length === 0)
89431
- return;
89432
- const allContent = await this.#writeSynthetics(plan.syntheticMessages);
89433
- if (this.#disposed || this.#epoch !== startEpoch) return;
89434
- this.#deliverToSubprocess(allContent, resolved.size);
89435
- for (const uri of resolved.keys()) {
89436
- this.#pushedThisTurn.add(uri);
89437
- }
89438
- this.#deps.log({
89439
- event: "resource_push_delivered",
89440
- taskId: this.#deps.taskId,
89441
- uriCount: resolved.size
89442
- });
89443
- this.#deps.log({
89444
- event: "resource_push_flushed",
89445
- taskId: this.#deps.taskId,
89446
- uriCount: batch.size,
89447
- syntheticCount: plan.syntheticMessages.length,
89448
- pushCountThisTurn: this.#pushCountThisTurn
89449
- });
89845
+ await this.#executeFlush(batch, startEpoch, markedUris);
89450
89846
  } catch (err) {
89847
+ for (const uri of markedUris) this.unmarkPushed(uri);
89451
89848
  this.#requeueBatch(batch);
89452
89849
  this.#deps.log({
89453
89850
  event: "resource_push_flush_error",
@@ -89457,6 +89854,30 @@ var ResourcePushManager = class {
89457
89854
  });
89458
89855
  }
89459
89856
  }
89857
+ async #executeFlush(batch, startEpoch, markedUris) {
89858
+ const resolved = await this.#resolveBatch(batch);
89859
+ if (this.#disposed || this.#epoch !== startEpoch) return;
89860
+ if (resolved.size === 0) {
89861
+ if (this.#dirtyUris.size > 0) this.#batchTimer.schedule();
89862
+ return;
89863
+ }
89864
+ const plan = await this.#planPush(resolved);
89865
+ if (this.#disposed || this.#epoch !== startEpoch || plan.syntheticMessages.length === 0) return;
89866
+ for (const uri of resolved.keys()) {
89867
+ this.markPushed(uri);
89868
+ markedUris.add(uri);
89869
+ }
89870
+ const allContent = await this.#writeSynthetics(plan.syntheticMessages);
89871
+ if (this.#disposed || this.#epoch !== startEpoch) return;
89872
+ this.#deliverToSubprocess(allContent, resolved.size);
89873
+ this.#deps.log({
89874
+ event: "resource_push_flushed",
89875
+ taskId: this.#deps.taskId,
89876
+ uriCount: batch.size,
89877
+ syntheticCount: plan.syntheticMessages.length,
89878
+ pushCountThisTurn: this.#pushCountThisTurn
89879
+ });
89880
+ }
89460
89881
  };
89461
89882
 
89462
89883
  // src/services/rewind.ts
@@ -89605,10 +90026,10 @@ var RewindCheckpointHandler = class {
89605
90026
  this.#allCheckpointTurnNos = [...allTurnNos];
89606
90027
  }
89607
90028
  recordSeqNo(seqNo) {
89608
- this.#latestSeqNo = seqNo;
90029
+ if (seqNo > this.#latestSeqNo) this.#latestSeqNo = seqNo;
89609
90030
  }
89610
90031
  recordSdkUuid(seqNo, sdkUuid) {
89611
- this.#seqNoToSdkUuid.set(seqNo, sdkUuid);
90032
+ if (!this.#seqNoToSdkUuid.has(seqNo)) this.#seqNoToSdkUuid.set(seqNo, sdkUuid);
89612
90033
  }
89613
90034
  findSdkUuidForSeqNo(targetSeqNo) {
89614
90035
  let bestUuid = null;
@@ -90021,7 +90442,7 @@ var RewindCheckpointHandler = class {
90021
90442
  };
90022
90443
 
90023
90444
  // src/services/task/side-thread-registry.ts
90024
- import { mkdir as mkdir17, readFile as readFile30, rename as rename17, writeFile as writeFile23 } from "fs/promises";
90445
+ import { mkdir as mkdir17, readFile as readFile31, rename as rename17, writeFile as writeFile23 } from "fs/promises";
90025
90446
  import { dirname as dirname15, join as join40 } from "path";
90026
90447
  var ThreadFileSchema = external_exports.object({
90027
90448
  threads: external_exports.record(external_exports.string(), ThreadMetadataSchema)
@@ -90355,7 +90776,7 @@ var SideThreadRegistry = class {
90355
90776
  const filePath = this.#filePath();
90356
90777
  let raw;
90357
90778
  try {
90358
- raw = await readFile30(filePath, "utf-8");
90779
+ raw = await readFile31(filePath, "utf-8");
90359
90780
  } catch (err) {
90360
90781
  if (isEnoent(err)) return;
90361
90782
  throw err;
@@ -90794,7 +91215,7 @@ async function resolveResources(ctx, content, history2, excludeUris) {
90794
91215
 
90795
91216
  // src/services/task/cc-task-file-store.ts
90796
91217
  import { watch as watch4 } from "fs";
90797
- import { readdir as readdir9, readFile as readFile31 } from "fs/promises";
91218
+ import { readdir as readdir9, readFile as readFile32 } from "fs/promises";
90798
91219
  import { homedir as homedir6 } from "os";
90799
91220
  import { basename as basename4, dirname as dirname16, join as join41 } from "path";
90800
91221
  var VALID_STATUSES2 = /* @__PURE__ */ new Set(["pending", "in_progress", "completed"]);
@@ -90830,7 +91251,7 @@ function createCCTaskFileWatcher(listId, log) {
90830
91251
  async function readTask(taskId) {
90831
91252
  const filePath = join41(dir, `${taskId}.json`);
90832
91253
  try {
90833
- const raw = await readFile31(filePath, "utf-8");
91254
+ const raw = await readFile32(filePath, "utf-8");
90834
91255
  const parsed = JSON.parse(raw);
90835
91256
  if (isCCTaskFile(parsed)) return parsed;
90836
91257
  log?.({
@@ -91117,11 +91538,24 @@ var StructuredTaskTracker = class {
91117
91538
  #ccTaskWatcherDispose = null;
91118
91539
  #suppressWriteThrough = false;
91119
91540
  #ccTaskFileWriter;
91120
- #restoreInProgress = false;
91541
+ /**
91542
+ * Gate the first flush until an overlay has either been applied or the
91543
+ * caller has explicitly signaled that none is coming. Both flags below
91544
+ * must be false for the flush to proceed during restore.
91545
+ */
91546
+ #restoreNeedsOverlay = false;
91547
+ /**
91548
+ * Gate the first flush until the file watcher's initial disk reconcile
91549
+ * has fired. Only set during restore for tasks with a session id.
91550
+ */
91551
+ #restoreNeedsDisk = false;
91121
91552
  constructor(deps) {
91122
91553
  this.#deps = deps;
91123
91554
  this.#ccTaskFileWriter = createCCTaskFileWriter(null, deps.log);
91124
- this.#restoreInProgress = deps.restoreInProgress ?? false;
91555
+ if (deps.restoreInProgress) {
91556
+ this.#restoreNeedsOverlay = true;
91557
+ this.#restoreNeedsDisk = deps.restoreExpectDisk ?? false;
91558
+ }
91125
91559
  }
91126
91560
  get currentOverlay() {
91127
91561
  return this.#currentOverlay;
@@ -91165,24 +91599,37 @@ var StructuredTaskTracker = class {
91165
91599
  }
91166
91600
  /**
91167
91601
  * Apply an overlay on top of CC tasks. Stores the overlay and re-flushes.
91168
- * Exits restore mode applyOverlay is the terminal piece of restoration
91169
- * (disk reconcile is idempotent with the seeded overlay, so we flush once
91170
- * here with full inputs instead of producing a partial pre-overlay flush).
91602
+ * Clears the overlay-pending gate. The flush still waits if a disk reconcile
91603
+ * is expected (restoreExpectDisk=true at construction): without that second
91604
+ * gate, the applyOverlay-first restart ordering would emit an overlay-only
91605
+ * map to the store on every boot, bumping TaskRecord.lastActivityAt for
91606
+ * every restored task that has an overlay.
91171
91607
  */
91172
91608
  applyOverlay(overlay) {
91173
91609
  this.#currentOverlay = overlay;
91174
- this.#restoreInProgress = false;
91610
+ this.#restoreNeedsOverlay = false;
91611
+ this.#flushStructuredTasks();
91612
+ }
91613
+ /**
91614
+ * Signal that no overlay will be applied during this restore. Clears the
91615
+ * overlay-pending gate but NOT the disk gate — if the restored task has a
91616
+ * session id, the file watcher's first reconcile remains the terminal
91617
+ * signal before the store receives a flush.
91618
+ */
91619
+ markNoOverlay() {
91620
+ if (!this.#restoreNeedsOverlay) return;
91621
+ this.#restoreNeedsOverlay = false;
91175
91622
  this.#flushStructuredTasks();
91176
91623
  }
91177
91624
  /**
91178
- * Signal that restoration has finished for tasks that have no persisted
91179
- * overlay to apply. Unblocks #flushStructuredTasks and runs one flush with
91180
- * the current state (disk-only, no overlay). Must be called by the
91181
- * restoration caller whenever applyOverlay will not be invoked.
91625
+ * Clear BOTH restore gates. Used in error paths (hydration promise rejected)
91626
+ * and by restorers that know neither an overlay nor a disk reconcile will
91627
+ * arrive. If either signal fires after this, the natural flush handles it.
91182
91628
  */
91183
91629
  markRestoreComplete() {
91184
- if (!this.#restoreInProgress) return;
91185
- this.#restoreInProgress = false;
91630
+ if (!this.#restoreNeedsOverlay && !this.#restoreNeedsDisk) return;
91631
+ this.#restoreNeedsOverlay = false;
91632
+ this.#restoreNeedsDisk = false;
91186
91633
  this.#flushStructuredTasks();
91187
91634
  }
91188
91635
  processStructuredTaskEvents(content) {
@@ -91292,7 +91739,7 @@ var StructuredTaskTracker = class {
91292
91739
  * 7. Push to updateStructuredTasks
91293
91740
  */
91294
91741
  #flushStructuredTasks() {
91295
- if (this.#restoreInProgress) return;
91742
+ if (this.#restoreNeedsOverlay || this.#restoreNeedsDisk) return;
91296
91743
  const overlay = this.#currentOverlay ?? DEFAULT_TASK_OVERLAY;
91297
91744
  const merged = applyOverlayToMap(this.#structuredTasks, overlay);
91298
91745
  const todoProgress = computeTodoProgress(merged);
@@ -91314,6 +91761,7 @@ var StructuredTaskTracker = class {
91314
91761
  * - Re-flush with overlay applied
91315
91762
  */
91316
91763
  #reconcileFromDisk(ccTasks) {
91764
+ this.#restoreNeedsDisk = false;
91317
91765
  const currentDiskIds = /* @__PURE__ */ new Set();
91318
91766
  for (const file of ccTasks) {
91319
91767
  currentDiskIds.add(file.id);
@@ -92013,31 +92461,20 @@ function trackWorktreeToolUse(content, pendingIds) {
92013
92461
  }
92014
92462
  }
92015
92463
  function handleWorktreeToolResults(content, params) {
92016
- let newCwd;
92017
- let newOriginalCwd;
92018
- let currentCwd = params.currentCwd;
92019
- let originalCwd = params.originalCwd;
92020
92464
  for (const block2 of content) {
92021
92465
  if (block2.type !== "tool_result") continue;
92022
92466
  if (!params.pendingIds.delete(block2.toolUseId)) continue;
92023
92467
  if (block2.isError) continue;
92024
92468
  const extracted = extractWorktreePath(block2.content);
92025
92469
  if (!extracted) continue;
92026
- if (!originalCwd) {
92027
- originalCwd = currentCwd;
92028
- newOriginalCwd = currentCwd;
92029
- }
92030
- currentCwd = extracted;
92031
- newCwd = extracted;
92470
+ params.onCwdChanged(extracted);
92032
92471
  params.log({
92033
92472
  event: "worktree_cwd_changed",
92034
92473
  taskId: params.taskId,
92035
92474
  newCwd: extracted,
92036
- originalCwd
92475
+ originalCwd: params.getOriginalCwd() ?? null
92037
92476
  });
92038
- params.onCwdChanged(params.taskId, extracted);
92039
92477
  }
92040
- return { newCwd, newOriginalCwd };
92041
92478
  }
92042
92479
  function trackPlanFileFromToolUse(content, onPlanFile) {
92043
92480
  for (const block2 of content) {
@@ -92412,6 +92849,12 @@ function classifyRoiTransition(nextStatus, roiEverRan) {
92412
92849
  if (nextStatus === "in_progress") return "reset-cycle";
92413
92850
  return "nothing";
92414
92851
  }
92852
+ function isTaskListSynthetic(msg, taskUri) {
92853
+ if (!msg.isSynthetic || msg.content.length !== 1) return false;
92854
+ const block2 = msg.content[0];
92855
+ if (!block2 || block2.type !== "resource") return false;
92856
+ return "uri" in block2.resource && block2.resource.uri === taskUri;
92857
+ }
92415
92858
  var Task = class {
92416
92859
  #deps;
92417
92860
  #mainThread;
@@ -92481,6 +92924,7 @@ var Task = class {
92481
92924
  constructor(deps) {
92482
92925
  this.#deps = deps;
92483
92926
  this.#cwd = deps.cwd;
92927
+ this.#originalCwd = deps.initialOriginalCwd ?? void 0;
92484
92928
  this.#broadcastToAllPeers = deps.sendControlMessage ?? null;
92485
92929
  this.#costBaseline = deps.costBaseline;
92486
92930
  this.#lastTurnStats = deps.initialTurnStats ?? null;
@@ -92497,7 +92941,12 @@ var Task = class {
92497
92941
  taskId: deps.taskId,
92498
92942
  log: deps.log,
92499
92943
  updateStructuredTasks: deps.updateStructuredTasks,
92500
- restoreInProgress: deps.restoreInProgress ?? false
92944
+ restoreInProgress: deps.restoreInProgress ?? false,
92945
+ /**
92946
+ * Only wait for a disk reconcile if a CC session exists — else the file
92947
+ * watcher never attaches and the gate would never clear.
92948
+ */
92949
+ restoreExpectDisk: Boolean(deps.restoreInProgress && deps.existingSessionId)
92501
92950
  });
92502
92951
  this.#pushManager = new ResourcePushManager({
92503
92952
  taskId: deps.taskId,
@@ -92542,6 +92991,8 @@ var Task = class {
92542
92991
  error: err instanceof Error ? err.message : String(err)
92543
92992
  });
92544
92993
  });
92994
+ const seedPromise = this.#seedPushManagerFromHistory();
92995
+ this.#hydrationPromise = this.#hydrationPromise ? this.#hydrationPromise.then(() => seedPromise) : seedPromise;
92545
92996
  }
92546
92997
  this.#subagentManager = new SubagentManager({
92547
92998
  taskId: deps.taskId,
@@ -92681,7 +93132,7 @@ var Task = class {
92681
93132
  });
92682
93133
  const spawnMode = deps.existingSessionId ? { kind: "resume", sessionId: deps.existingSessionId } : { kind: "fresh" };
92683
93134
  const initialCwd = deps.cwd;
92684
- const wrappedSpawn = (reason, content, onEvent, canUseTool, settings, _cwd, taskId, additionalPrompt, disallowedTools, mode) => {
93135
+ const wrappedSpawn = (reason, content, onEvent, canUseTool, settings, _cwd, taskId, additionalPrompt, disallowedTools, mode, onCwdChanged) => {
92685
93136
  const effectiveCwd = _cwd && _cwd !== initialCwd ? _cwd : this.#cwd;
92686
93137
  return deps.spawnSubprocess(
92687
93138
  reason,
@@ -92693,7 +93144,8 @@ var Task = class {
92693
93144
  taskId,
92694
93145
  additionalPrompt,
92695
93146
  disallowedTools,
92696
- mode
93147
+ mode,
93148
+ onCwdChanged
92697
93149
  );
92698
93150
  };
92699
93151
  this.#mainThread = new Thread(
@@ -92717,7 +93169,8 @@ var Task = class {
92717
93169
  initialState: deps.initialState,
92718
93170
  existingSessionId: deps.existingSessionId,
92719
93171
  metricsCollector: deps.metricsCollector,
92720
- adoptedSubprocess: deps.adoptedSubprocess
93172
+ adoptedSubprocess: deps.adoptedSubprocess,
93173
+ onCwdChanged: (newCwd) => this.#applyCwdChange(newCwd)
92721
93174
  },
92722
93175
  {
92723
93176
  onStatusChange: (status) => this.#handleStatusChange(status),
@@ -92844,6 +93297,10 @@ var Task = class {
92844
93297
  get cwd() {
92845
93298
  return this.#cwd;
92846
93299
  }
93300
+ /** The cwd at the time of the first directory switch; undefined if the agent never switched. */
93301
+ get originalCwd() {
93302
+ return this.#originalCwd;
93303
+ }
92847
93304
  get latestSettings() {
92848
93305
  return this.#latestSettings;
92849
93306
  }
@@ -93119,6 +93576,16 @@ var Task = class {
93119
93576
  setCwd(cwd) {
93120
93577
  this.#cwd = cwd;
93121
93578
  }
93579
+ /**
93580
+ * Public funnel entry for cwd changes originating outside the SDK/tool
93581
+ * signal path (browser `send_message.cwd`, schedule). Delegates to
93582
+ * `#applyCwdChange` so originalCwd capture, rewind-checkpoint reset,
93583
+ * prev-cwd commit scan, and downstream listener fan-out run once per
93584
+ * change regardless of source.
93585
+ */
93586
+ applyCwdChange(cwd) {
93587
+ this.#applyCwdChange(cwd);
93588
+ }
93122
93589
  setMode(mode) {
93123
93590
  const previousMode = this.#deps.mode;
93124
93591
  this.#deps.mode = mode;
@@ -93558,6 +94025,33 @@ var Task = class {
93558
94025
  if (!arr || arr.length === 0) return void 0;
93559
94026
  return arr.shift();
93560
94027
  }
94028
+ /**
94029
+ * Resume path: if a prior daemon run already injected the task-list for
94030
+ * this channel, its synthetic row is in the JSONL. Seed the push manager
94031
+ * so `#resolveTaskListPrepend`'s `isSessionPushed` guard matches on the
94032
+ * first spawn — otherwise restart re-prepends, duplicating the chip and
94033
+ * dumping the full resource text back into the agent's context.
94034
+ *
94035
+ * Chained into `#hydrationPromise` (which `#awaitHydration` blocks on at
94036
+ * every spawn/flush entry point) so the seed completes before any
94037
+ * caller reaches `#resolveTaskListPrepend`. A fire-and-forget scan would
94038
+ * race against a fast-arriving first user message.
94039
+ */
94040
+ async #seedPushManagerFromHistory() {
94041
+ const taskUri = buildTaskResourceUri(this.#deps.taskId);
94042
+ try {
94043
+ const msgs = await this.#deps.store.getMessages(this.#deps.channelId);
94044
+ if (msgs.some((msg) => isTaskListSynthetic(msg, taskUri))) {
94045
+ this.#pushManager.seedSessionPushed(taskUri);
94046
+ }
94047
+ } catch (err) {
94048
+ this.#deps.log({
94049
+ event: "push_manager_seed_failed",
94050
+ taskId: this.#deps.taskId,
94051
+ error: err instanceof Error ? err.message : String(err)
94052
+ });
94053
+ }
94054
+ }
93561
94055
  async #awaitHydration() {
93562
94056
  if (this.#hydrationPromise) {
93563
94057
  await this.#hydrationPromise;
@@ -93678,10 +94172,12 @@ Use this context to maintain continuity. You have already done this work \u2014
93678
94172
  const registry = this.#deps.resourceRegistry;
93679
94173
  if (!registry) return null;
93680
94174
  const taskUri = buildTaskResourceUri(this.#deps.taskId);
93681
- if (this.#pushManager.getPushedUris().has(taskUri)) return null;
94175
+ if (this.#pushManager.isSessionPushed(taskUri)) return null;
94176
+ this.#pushManager.markPushed(taskUri);
94177
+ let injected = false;
93682
94178
  try {
93683
94179
  const taskResource = await registry.resolve(taskUri);
93684
- if ("text" in taskResource && !taskResource.text.includes('task-count="0"')) {
94180
+ if ("text" in taskResource && !taskResource.text.includes('<task-list task-count="0"')) {
93685
94181
  const rootMsg = buildRootMessage(
93686
94182
  taskUri,
93687
94183
  taskResource,
@@ -93696,10 +94192,12 @@ Use this context to maintain continuity. You have already done this work \u2014
93696
94192
  },
93697
94193
  [rootMsg]
93698
94194
  );
93699
- this.#pushManager.markPushed(taskUri);
94195
+ injected = true;
93700
94196
  return rootMsg.content;
93701
94197
  }
93702
94198
  } catch {
94199
+ } finally {
94200
+ if (!injected) this.#pushManager.unmarkPushed(taskUri);
93703
94201
  }
93704
94202
  return null;
93705
94203
  }
@@ -94107,16 +94605,37 @@ Use this context to maintain continuity. You have already done this work \u2014
94107
94605
  trackWorktreeToolUse(content, this.#pendingWorktreeToolUseIds);
94108
94606
  }
94109
94607
  #handleWorktreeToolResults(content) {
94110
- const { newCwd, newOriginalCwd } = handleWorktreeToolResults(content, {
94608
+ handleWorktreeToolResults(content, {
94111
94609
  pendingIds: this.#pendingWorktreeToolUseIds,
94112
- currentCwd: this.#cwd,
94113
- originalCwd: this.#originalCwd,
94114
- onCwdChanged: this.#deps.onCwdChanged,
94610
+ onCwdChanged: (newCwd) => this.#applyCwdChange(newCwd),
94115
94611
  taskId: this.#deps.taskId,
94612
+ getOriginalCwd: () => this.#originalCwd,
94116
94613
  log: this.#deps.log
94117
94614
  });
94118
- if (newOriginalCwd !== void 0) this.#originalCwd = newOriginalCwd;
94119
- if (newCwd !== void 0) this.#cwd = newCwd;
94615
+ }
94616
+ /**
94617
+ * Unified funnel for cwd changes — both the SDK's native `CwdChanged`
94618
+ * hook and the regex-based EnterWorktree tool-result fallback converge
94619
+ * here. Dedups by value, captures `originalCwd` on first change, resets
94620
+ * rewind-checkpoint state, fires a one-shot commit scan against the
94621
+ * previous cwd to retain attribution, and notifies downstream listeners.
94622
+ */
94623
+ #applyCwdChange(newCwd) {
94624
+ if (this.#disposed) return;
94625
+ if (newCwd === this.#cwd) return;
94626
+ const prevCwd = this.#cwd;
94627
+ if (!this.#originalCwd) this.#originalCwd = prevCwd;
94628
+ this.#cwd = newCwd;
94629
+ this.#rewindCheckpoint.resetForCwdChange();
94630
+ if (prevCwd) this.#triggerCommitScanForCwd(prevCwd);
94631
+ this.#deps.onCwdChanged(this.#deps.taskId, newCwd);
94632
+ this.#deps.log({
94633
+ event: "task_cwd_changed_via_apply",
94634
+ taskId: this.#deps.taskId,
94635
+ newCwd,
94636
+ prevCwd: prevCwd ?? null,
94637
+ originalCwd: this.#originalCwd ?? null
94638
+ });
94120
94639
  }
94121
94640
  #trackPlanFileFromToolUse(content) {
94122
94641
  trackPlanFileFromToolUse(content, (path2) => this.#planHandler.trackCreatedFile(path2));
@@ -94145,12 +94664,12 @@ Use this context to maintain continuity. You have already done this work \u2014
94145
94664
  this.#deps.persistAbandonedAt(Date.now());
94146
94665
  }
94147
94666
  }
94148
- #triggerCommitScan() {
94149
- const cwd = this.#cwd;
94150
- if (!cwd) return;
94667
+ #chainCommitScan(resolveCwd, failEvent) {
94151
94668
  this.#trailerInjectionChain = this.#trailerInjectionChain.then(async () => {
94152
94669
  try {
94153
94670
  const state = await this.#deps.getCommitScanState();
94671
+ const cwd = resolveCwd();
94672
+ if (!cwd) return;
94154
94673
  await scanAndAttributeCommits(
94155
94674
  {
94156
94675
  cwd,
@@ -94177,7 +94696,7 @@ Use this context to maintain continuity. You have already done this work \u2014
94177
94696
  );
94178
94697
  } catch (err) {
94179
94698
  this.#deps.log({
94180
- event: "roi_commit_scan_failed",
94699
+ event: failEvent,
94181
94700
  taskId: this.#deps.taskId,
94182
94701
  error: err instanceof Error ? err.message : String(err)
94183
94702
  });
@@ -94185,6 +94704,17 @@ Use this context to maintain continuity. You have already done this work \u2014
94185
94704
  }).catch(() => {
94186
94705
  });
94187
94706
  }
94707
+ #triggerCommitScan() {
94708
+ this.#chainCommitScan(() => this.#cwd, "roi_commit_scan_failed");
94709
+ }
94710
+ /**
94711
+ * Trigger a one-shot commit scan against an explicit cwd. Used at
94712
+ * cwd-switch time to retain attribution for commits made in the
94713
+ * outgoing directory before the agent's next Bash turn moves us on.
94714
+ */
94715
+ #triggerCommitScanForCwd(cwd) {
94716
+ this.#chainCommitScan(() => cwd, "roi_commit_scan_for_cwd_failed");
94717
+ }
94188
94718
  applyOverlay(overlay) {
94189
94719
  this.#structuredTaskTracker.applyOverlay(overlay);
94190
94720
  }
@@ -94192,6 +94722,10 @@ Use this context to maintain continuity. You have already done this work \u2014
94192
94722
  markStructuredTaskRestoreComplete() {
94193
94723
  this.#structuredTaskTracker.markRestoreComplete();
94194
94724
  }
94725
+ /** See StructuredTaskTracker.markNoOverlay. */
94726
+ markStructuredTaskNoOverlay() {
94727
+ this.#structuredTaskTracker.markNoOverlay();
94728
+ }
94195
94729
  /** ---------------------------------------------------------------- */
94196
94730
  /** Resource resolution */
94197
94731
  /** ---------------------------------------------------------------- */
@@ -94668,14 +95202,23 @@ function findSdkUuidForSeqNoInTask(tasks, taskId, seqNo) {
94668
95202
  // src/services/task/manager/task-manager-template.ts
94669
95203
  function buildInitialOverlayFromTemplate(template, now) {
94670
95204
  if (!template || template.items.length === 0) return void 0;
94671
- const userTasks = template.items.map((item2) => ({
94672
- id: item2.id,
95205
+ const idRemap = new Map(template.items.map((item2, index) => [item2.id, String(index + 1)]));
95206
+ const userTasks = template.items.map((item2, index) => ({
95207
+ id: String(index + 1),
94673
95208
  subject: item2.content,
94674
95209
  description: item2.description,
94675
95210
  status: "pending",
94676
95211
  owner: "user",
94677
95212
  blocks: [],
94678
- blockedBy: item2.deps,
95213
+ /**
95214
+ * Drop deps pointing outside the template. Falling through the
95215
+ * original colon-bearing ID would leave a dangling `blockedBy` that
95216
+ * CC's TaskUpdate can't resolve — silently stuck in the UI.
95217
+ */
95218
+ blockedBy: item2.deps.flatMap((dep) => {
95219
+ const mapped = idRemap.get(dep);
95220
+ return mapped ? [mapped] : [];
95221
+ }),
94679
95222
  createdAt: now,
94680
95223
  updatedAt: now
94681
95224
  }));
@@ -94706,6 +95249,13 @@ var TaskManager = class {
94706
95249
  #pendingStreamSubs = /* @__PURE__ */ new Map();
94707
95250
  #onAuthNotLoggedIn = null;
94708
95251
  #onCwdChanged = null;
95252
+ /**
95253
+ * Multi-listener fan-out for cwd changes. Populated by per-peer wirings
95254
+ * (e.g. PR poller) that need to react to cwd switches without clobbering
95255
+ * the primary daemon-level listener set via `setOnCwdChanged`. Each
95256
+ * registration returns an unsubscribe function.
95257
+ */
95258
+ #cwdChangedListeners = /* @__PURE__ */ new Set();
94709
95259
  #onCheckpointReady = null;
94710
95260
  #onRateLimitEvent = null;
94711
95261
  #onTurnCostDelta = null;
@@ -94779,6 +95329,17 @@ var TaskManager = class {
94779
95329
  setOnCwdChanged(handler) {
94780
95330
  this.#onCwdChanged = handler;
94781
95331
  }
95332
+ /**
95333
+ * Register an additional cwd-change listener. Unlike `setOnCwdChanged`
95334
+ * this is multi-listener and intended for per-peer wirings (PR poller,
95335
+ * etc). Returns an unsubscribe function.
95336
+ */
95337
+ addOnCwdChanged(handler) {
95338
+ this.#cwdChangedListeners.add(handler);
95339
+ return () => {
95340
+ this.#cwdChangedListeners.delete(handler);
95341
+ };
95342
+ }
94782
95343
  setOnCheckpointReady(handler) {
94783
95344
  this.#onCheckpointReady = handler;
94784
95345
  }
@@ -94806,7 +95367,7 @@ var TaskManager = class {
94806
95367
  }
94807
95368
  if (task.cwd === cwd) return;
94808
95369
  task.cwd = cwd;
94809
- task.orchestrator.setCwd(cwd);
95370
+ task.orchestrator.applyCwdChange(cwd);
94810
95371
  this.#deps.taskStateStore.updateCwd(taskId, cwd).catch((err) => {
94811
95372
  this.#deps.log({
94812
95373
  event: "task_state_store_update_cwd_failed",
@@ -94814,8 +95375,37 @@ var TaskManager = class {
94814
95375
  error: err instanceof Error ? err.message : String(err)
94815
95376
  });
94816
95377
  });
95378
+ const originalCwd = task.orchestrator.originalCwd;
95379
+ if (originalCwd) {
95380
+ this.#deps.taskStateStore.setOriginalCwd(taskId, originalCwd).catch((err) => {
95381
+ this.#deps.log({
95382
+ event: "task_state_store_set_original_cwd_failed",
95383
+ taskId,
95384
+ error: err instanceof Error ? err.message : String(err)
95385
+ });
95386
+ });
95387
+ }
94817
95388
  this.#deps.log({ event: "task_cwd_updated", taskId, cwd });
94818
- this.#onCwdChanged?.(taskId, cwd);
95389
+ try {
95390
+ this.#onCwdChanged?.(taskId, cwd);
95391
+ } catch (err) {
95392
+ this.#deps.log({
95393
+ event: "cwd_changed_primary_listener_error",
95394
+ taskId,
95395
+ error: err instanceof Error ? err.message : String(err)
95396
+ });
95397
+ }
95398
+ for (const listener of this.#cwdChangedListeners) {
95399
+ try {
95400
+ listener(taskId, cwd);
95401
+ } catch (err) {
95402
+ this.#deps.log({
95403
+ event: "cwd_changed_listener_error",
95404
+ taskId,
95405
+ error: err instanceof Error ? err.message : String(err)
95406
+ });
95407
+ }
95408
+ }
94819
95409
  }
94820
95410
  get taskStateStore() {
94821
95411
  return this.#deps.taskStateStore;
@@ -95132,11 +95722,14 @@ var TaskManager = class {
95132
95722
  const cwd = opts.cwd;
95133
95723
  const mode = opts.mode ?? "task";
95134
95724
  let orchestratorRef = null;
95725
+ const hasSession = Boolean(opts.existingSessionId);
95135
95726
  const restoreHydration = async () => {
95136
95727
  const record = await this.#deps.taskStateStore.getTask(taskId);
95137
95728
  if (!orchestratorRef || !this.#tasks.has(taskId)) return;
95138
95729
  if (record?.taskOverlay) {
95139
95730
  orchestratorRef.applyOverlay(record.taskOverlay);
95731
+ } else if (hasSession) {
95732
+ orchestratorRef.markStructuredTaskNoOverlay();
95140
95733
  } else {
95141
95734
  orchestratorRef.markStructuredTaskRestoreComplete();
95142
95735
  }
@@ -95165,6 +95758,7 @@ var TaskManager = class {
95165
95758
  initialRoiStartedEmitted: opts.initialRoiStartedEmitted,
95166
95759
  taskCreatedAt: opts.taskCreatedAt,
95167
95760
  initialTurnCount: opts.initialTurnCount,
95761
+ initialOriginalCwd: opts.initialOriginalCwd,
95168
95762
  restoreInProgress: true
95169
95763
  });
95170
95764
  orchestratorRef = orchestrator;
@@ -95224,7 +95818,8 @@ var TaskManager = class {
95224
95818
  initialPlanDetection: record.lastPlanDetection,
95225
95819
  initialRoiStartedEmitted: record.roiStartedEmitted,
95226
95820
  taskCreatedAt: record.createdAt,
95227
- initialTurnCount: record.totalTurnCount
95821
+ initialTurnCount: record.totalTurnCount,
95822
+ initialOriginalCwd: record.originalCwd ?? null
95228
95823
  });
95229
95824
  task = this.#tasks.get(taskId);
95230
95825
  if (!task) {
@@ -95233,15 +95828,7 @@ var TaskManager = class {
95233
95828
  this.#deps.log({ event: "task_lazy_restored", taskId, channelId: record.channelId });
95234
95829
  }
95235
95830
  if (cwd && cwd !== task.cwd) {
95236
- task.cwd = cwd;
95237
- task.orchestrator.setCwd(cwd);
95238
- this.#deps.taskStateStore.updateCwd(taskId, cwd).catch((err) => {
95239
- this.#deps.log({
95240
- event: "task_state_store_update_cwd_failed",
95241
- taskId,
95242
- error: err instanceof Error ? err.message : String(err)
95243
- });
95244
- });
95831
+ this.updateTaskCwd(taskId, cwd);
95245
95832
  }
95246
95833
  task.orchestrator.handleUserMessage(
95247
95834
  content,
@@ -95661,7 +96248,8 @@ var TaskManager = class {
95661
96248
  onForwardedAck: (correlationId) => {
95662
96249
  this.#forwardedAckEmitters.get(taskId)?.(correlationId);
95663
96250
  },
95664
- restoreInProgress: opts?.restoreInProgress ?? false
96251
+ restoreInProgress: opts?.restoreInProgress ?? false,
96252
+ initialOriginalCwd: opts?.initialOriginalCwd
95665
96253
  });
95666
96254
  }
95667
96255
  };
@@ -95803,6 +96391,7 @@ function buildTaskStateStore(dataDir) {
95803
96391
  mergedAt: null,
95804
96392
  attributedCommitShas: [],
95805
96393
  lastCommitScanSha: null,
96394
+ originalCwd: null,
95806
96395
  mode: mode ?? "task",
95807
96396
  ...scheduleId ? { scheduleId } : {},
95808
96397
  ...scheduleName ? { scheduleName } : {},
@@ -96022,6 +96611,13 @@ function buildTaskStateStore(dataDir) {
96022
96611
  { ...options, broadcast: false }
96023
96612
  );
96024
96613
  },
96614
+ async setOriginalCwd(taskId, originalCwd, options) {
96615
+ await safeUpdate(
96616
+ taskId,
96617
+ (task) => task.originalCwd != null ? task : { ...task, originalCwd },
96618
+ { ...options, broadcast: false }
96619
+ );
96620
+ },
96025
96621
  async sweepToInputRequired(taskId, options) {
96026
96622
  await safeUpdate(
96027
96623
  taskId,
@@ -97003,7 +97599,7 @@ function isPlainObject2(v2) {
97003
97599
  }
97004
97600
 
97005
97601
  // src/services/themes/theme-store.ts
97006
- import { mkdir as mkdir19, readdir as readdir11, readFile as readFile32, rename as rename19, stat as stat8, unlink as unlink9, writeFile as writeFile26 } from "fs/promises";
97602
+ import { mkdir as mkdir19, readdir as readdir11, readFile as readFile33, rename as rename19, stat as stat8, unlink as unlink9, writeFile as writeFile26 } from "fs/promises";
97007
97603
  import { join as join47 } from "path";
97008
97604
  function planSeed(input) {
97009
97605
  if (input.configExists) {
@@ -97083,7 +97679,7 @@ function buildThemeStore(dataDir) {
97083
97679
  }
97084
97680
  async function readConfigFile() {
97085
97681
  try {
97086
- const raw = await readFile32(configPath, "utf-8");
97682
+ const raw = await readFile33(configPath, "utf-8");
97087
97683
  const parsed = ThemeConfigSchema.safeParse(JSON.parse(raw));
97088
97684
  if (!parsed.success) return null;
97089
97685
  return parsed.data;
@@ -97102,7 +97698,7 @@ function buildThemeStore(dataDir) {
97102
97698
  await atomicWrite3(themePath(id), serialized);
97103
97699
  }
97104
97700
  async function readThemeFile(id) {
97105
- const raw = await readFile32(themePath(id), "utf-8");
97701
+ const raw = await readFile33(themePath(id), "utf-8");
97106
97702
  const parsed = VSCodeThemeSchema.safeParse(JSON.parse(raw));
97107
97703
  if (!parsed.success) {
97108
97704
  throw new Error(
@@ -97254,7 +97850,7 @@ function buildThemeStore(dataDir) {
97254
97850
  }
97255
97851
 
97256
97852
  // src/services/themes/vscode-scanner.ts
97257
- import { readdir as readdir12, readFile as readFile33, stat as stat9 } from "fs/promises";
97853
+ import { readdir as readdir12, readFile as readFile34, stat as stat9 } from "fs/promises";
97258
97854
  import { homedir as homedir8 } from "os";
97259
97855
  import { dirname as dirname17, join as join48, normalize as normalize5, resolve } from "path";
97260
97856
  var VSCodeThemeEntrySchema2 = external_exports.object({
@@ -97313,7 +97909,7 @@ var PackageJsonContribSchema = external_exports.object({
97313
97909
  async function tryReadPackageJson(extDir) {
97314
97910
  let pkgRaw;
97315
97911
  try {
97316
- pkgRaw = await readFile33(join48(extDir, "package.json"), "utf-8");
97912
+ pkgRaw = await readFile34(join48(extDir, "package.json"), "utf-8");
97317
97913
  } catch {
97318
97914
  return null;
97319
97915
  }
@@ -97383,7 +97979,7 @@ async function readVSCodeThemeWithIncludes(absolutePath) {
97383
97979
  if (!validateScanPath(filePath)) {
97384
97980
  throw new Error(`Include path outside allowed directories: ${filePath}`);
97385
97981
  }
97386
- const raw = await readFile33(filePath, "utf-8");
97982
+ const raw = await readFile34(filePath, "utf-8");
97387
97983
  const errors2 = [];
97388
97984
  const parsed = parse3(raw, errors2, {
97389
97985
  allowTrailingComma: true,
@@ -97784,7 +98380,7 @@ async function createDaemon(deps) {
97784
98380
  previewLifecycleSubs.set(taskId, unsub);
97785
98381
  reconcilePreviewRefs(taskId);
97786
98382
  }
97787
- const spawnSubprocessFn = (reason, initialContent, onEvent, canUseTool, settings, cwd, taskId, additionalSystemPrompt, disallowedTools, mode) => {
98383
+ const spawnSubprocessFn = (reason, initialContent, onEvent, canUseTool, settings, cwd, taskId, additionalSystemPrompt, disallowedTools, mode, onCwdChanged) => {
97788
98384
  const resolvedTaskId = taskId ?? "unknown";
97789
98385
  const { watcher: vizWatcher } = getOrCreateVizWatcher(resolvedTaskId);
97790
98386
  deps.log({ event: "spawn_subprocess", mode, taskId: resolvedTaskId, hasMode: mode != null });
@@ -97821,7 +98417,8 @@ async function createDaemon(deps) {
97821
98417
  log: deps.log,
97822
98418
  metricsCollector: deps.metricsCollector,
97823
98419
  claudeCodePath: deps.claudeCodePath,
97824
- disallowedTools
98420
+ disallowedTools,
98421
+ onCwdChanged
97825
98422
  });
97826
98423
  const subprocess = AnthropicAgentSubprocess.spawn(
97827
98424
  deps.queryFn,
@@ -99787,7 +100384,7 @@ function friendlyExecError(err) {
99787
100384
  async function refreshPluginCapabilities(daemon, tokenStore) {
99788
100385
  const [updatedMarketplace, updatedMcp, updatedSkills] = await Promise.all([
99789
100386
  detectMarketplacePlugins(),
99790
- import("./mcp-servers-YT5BADYE.js").then(
100387
+ import("./mcp-servers-MUVTAMDT.js").then(
99791
100388
  (m2) => m2.detectMCPServers(daemon.capabilities.environments, tokenStore)
99792
100389
  ),
99793
100390
  import("./skills-NCKYNLUS.js").then(
@@ -100456,6 +101053,15 @@ async function emitTaskCreatedAck(controlHandler, daemon, taskId, templateId, lo
100456
101053
  });
100457
101054
  }
100458
101055
  }
101056
+ var controlChannelEpochs = /* @__PURE__ */ new Map();
101057
+ function isLiveControlChannelEpoch(controlPeerId, channelEpoch, registry = controlChannelEpochs) {
101058
+ return registry.get(controlPeerId) === channelEpoch;
101059
+ }
101060
+ function registerControlChannelEpoch(controlPeerId, registry = controlChannelEpochs) {
101061
+ const epoch = crypto.randomUUID();
101062
+ registry.set(controlPeerId, epoch);
101063
+ return epoch;
101064
+ }
100459
101065
  function wireControlChannel(rawChannel, daemon, logAdapter, deps) {
100460
101066
  const dc = narrow(rawChannel);
100461
101067
  const wsRoot = deps?.workspaceRoot ?? findProjectRoot(process.cwd());
@@ -100657,6 +101263,9 @@ function wireControlChannel(rawChannel, daemon, logAdapter, deps) {
100657
101263
  },
100658
101264
  prPollerLog
100659
101265
  );
101266
+ const unsubscribeCwdChanged = daemon.taskManager.addOnCwdChanged((taskId, newCwd) => {
101267
+ void prPoller.updateCwd(taskId, newCwd);
101268
+ });
100660
101269
  function attributePrIfFirstDiscovery(payload) {
100661
101270
  const { taskId } = payload;
100662
101271
  const pr = payload.currentBranchPR;
@@ -101489,6 +102098,7 @@ function wireControlChannel(rawChannel, daemon, logAdapter, deps) {
101489
102098
  }
101490
102099
  controlHandler = handler;
101491
102100
  const controlPeerId = deps?.machineId ?? `_control_${crypto.randomUUID()}`;
102101
+ const channelEpoch = registerControlChannelEpoch(controlPeerId);
101492
102102
  daemon.taskManager.registerControlChannel(controlPeerId, handler.sendControl);
101493
102103
  daemon.taskManager.setOnAuthNotLoggedIn(() => {
101494
102104
  const preferred = daemon.capabilities?.anthropicAuth?.method !== "none" ? daemon.capabilities?.anthropicAuth?.method : void 0;
@@ -101590,6 +102200,15 @@ function wireControlChannel(rawChannel, daemon, logAdapter, deps) {
101590
102200
  handler.onMessage(raw);
101591
102201
  };
101592
102202
  dc.onclose = () => {
102203
+ if (!isLiveControlChannelEpoch(controlPeerId, channelEpoch)) {
102204
+ logAdapter({
102205
+ event: "control_channel_onclose_stale",
102206
+ peerId: controlPeerId,
102207
+ staleEpoch: channelEpoch,
102208
+ currentEpoch: controlChannelEpochs.get(controlPeerId) ?? null
102209
+ });
102210
+ return;
102211
+ }
101593
102212
  reassembler.clear();
101594
102213
  if (loginChild) {
101595
102214
  loginChild.kill();
@@ -101598,6 +102217,7 @@ function wireControlChannel(rawChannel, daemon, logAdapter, deps) {
101598
102217
  for (const unsub of resourceUnsubs.values()) unsub();
101599
102218
  resourceUnsubs.clear();
101600
102219
  userSettingsUnsub();
102220
+ unsubscribeCwdChanged();
101601
102221
  prPoller.dispose();
101602
102222
  handler.dispose();
101603
102223
  guardedChannel.dispose();
@@ -101609,6 +102229,7 @@ function wireControlChannel(rawChannel, daemon, logAdapter, deps) {
101609
102229
  ref.pool = removePresence(ref.pool, controlPeerId);
101610
102230
  daemon.taskManager.broadcastControl({ type: "presence_state", peers: ref.pool });
101611
102231
  }
102232
+ controlChannelEpochs.delete(controlPeerId);
101612
102233
  };
101613
102234
  logAdapter({
101614
102235
  event: "control_channel_wired",
@@ -101746,8 +102367,12 @@ function handleParticipantsList(room, participants, deps) {
101746
102367
  userId: p2.userId,
101747
102368
  username: p2.username,
101748
102369
  avatarUrl: p2.avatarUrl,
101749
- role: p2.role
102370
+ role: p2.role,
102371
+ connectionId: p2.connectionId
101750
102372
  }));
102373
+ for (const p2 of participants) {
102374
+ room.connectionIdByUser.set(p2.userId, p2.connectionId);
102375
+ }
101751
102376
  bumpAndNotify(room, deps);
101752
102377
  for (const participant of participants) {
101753
102378
  if (participant.userId === room.myUserId) continue;
@@ -101765,6 +102390,7 @@ function handleParticipantsList(room, participants, deps) {
101765
102390
  function handleParticipantJoined(room, participant, deps) {
101766
102391
  if (!room.myUserId) return;
101767
102392
  if (participant.userId === room.myUserId) return;
102393
+ room.connectionIdByUser.set(participant.userId, participant.connectionId);
101768
102394
  if (room.knownPeers.has(participant.userId)) return;
101769
102395
  room.participants = [
101770
102396
  ...room.participants,
@@ -101772,31 +102398,38 @@ function handleParticipantJoined(room, participant, deps) {
101772
102398
  userId: participant.userId,
101773
102399
  username: participant.username,
101774
102400
  avatarUrl: participant.avatarUrl,
101775
- role: participant.role
102401
+ role: participant.role,
102402
+ connectionId: participant.connectionId
101776
102403
  }
101777
102404
  ];
101778
102405
  bumpAndNotify(room, deps);
101779
102406
  registerCollabParticipant(room, participant.userId, participant.role, participant.username, deps);
101780
102407
  maybeInitiateOffer(room, participant.userId, deps);
101781
102408
  }
101782
- function handleParticipantLeft(room, userId, deps) {
102409
+ function handleParticipantLeft(room, msg, deps) {
102410
+ const { userId, connectionId } = msg;
102411
+ const tracked = room.connectionIdByUser.get(userId);
102412
+ if (tracked !== void 0 && tracked !== connectionId) {
102413
+ deps.log({
102414
+ event: "participant_left_stale_dropped",
102415
+ roomId: room.roomId,
102416
+ userId,
102417
+ incomingConnectionId: connectionId,
102418
+ trackedConnectionId: tracked
102419
+ });
102420
+ return;
102421
+ }
101783
102422
  room.knownPeers.delete(userId);
101784
102423
  room.participants = room.participants.filter((p2) => p2.userId !== userId);
102424
+ room.connectionIdByUser.delete(userId);
101785
102425
  bumpAndNotify(room, deps);
101786
- deps.peerRoleRegistry.unregisterPeer(namespacePeerId(room.roomId, userId));
101787
- room.peerManager.closePeer(namespacePeerId(room.roomId, userId));
102426
+ const peerId = namespacePeerId(room.roomId, userId);
102427
+ deps.peerRoleRegistry.unregisterPeer(peerId);
102428
+ room.peerManager.closePeer(peerId);
101788
102429
  deps.log({ event: "collab_participant_left", roomId: room.roomId, userId });
101789
102430
  }
101790
- function handleWebrtcSignaling(room, type, targetUserId, payload, deps) {
102431
+ function handleWebrtcSignaling(room, type, targetUserId, generationId, payload, deps) {
101791
102432
  const peerId = namespacePeerId(room.roomId, targetUserId);
101792
- const generationId = `collab:${peerId}`;
101793
- deps.log({
101794
- event: "collab_generationid_placeholder",
101795
- roomId: room.roomId,
101796
- targetUserId,
101797
- type,
101798
- generationId
101799
- });
101800
102433
  const actions = {
101801
102434
  // eslint-disable-next-line no-restricted-syntax -- SDP is opaque z.unknown() from signaling
101802
102435
  offer: () => room.peerManager.handleOffer(peerId, payload, generationId),
@@ -101806,7 +102439,12 @@ function handleWebrtcSignaling(room, type, targetUserId, payload, deps) {
101806
102439
  ice: () => room.peerManager.handleIce(peerId, payload, generationId)
101807
102440
  };
101808
102441
  actions[type]().catch((err) => {
101809
- deps.log({ event: `collab_${type}_failed`, roomId: room.roomId, error: String(err) });
102442
+ deps.log({
102443
+ event: `collab_${type}_failed`,
102444
+ roomId: room.roomId,
102445
+ generationId,
102446
+ error: String(err)
102447
+ });
101810
102448
  });
101811
102449
  }
101812
102450
  function handleCollabRoomMessage(room, msg, deps) {
@@ -101822,16 +102460,16 @@ function handleCollabRoomMessage(room, msg, deps) {
101822
102460
  handleParticipantJoined(room, msg.participant, deps);
101823
102461
  break;
101824
102462
  case "participant-left":
101825
- handleParticipantLeft(room, msg.userId, deps);
102463
+ handleParticipantLeft(room, { userId: msg.userId, connectionId: msg.connectionId }, deps);
101826
102464
  break;
101827
102465
  case "webrtc-offer":
101828
- handleWebrtcSignaling(room, "offer", msg.targetUserId, msg.offer, deps);
102466
+ handleWebrtcSignaling(room, "offer", msg.targetUserId, msg.generationId, msg.offer, deps);
101829
102467
  break;
101830
102468
  case "webrtc-answer":
101831
- handleWebrtcSignaling(room, "answer", msg.targetUserId, msg.answer, deps);
102469
+ handleWebrtcSignaling(room, "answer", msg.targetUserId, msg.generationId, msg.answer, deps);
101832
102470
  break;
101833
102471
  case "webrtc-ice":
101834
- handleWebrtcSignaling(room, "ice", msg.targetUserId, msg.candidate, deps);
102472
+ handleWebrtcSignaling(room, "ice", msg.targetUserId, msg.generationId, msg.candidate, deps);
101835
102473
  break;
101836
102474
  case "ice-servers":
101837
102475
  room.peerManager.updateIceServers(msg.iceServers);
@@ -101967,6 +102605,7 @@ function createCollabRoomManager(deps) {
101967
102605
  myUserId: null,
101968
102606
  knownPeers: /* @__PURE__ */ new Set(),
101969
102607
  participants: [],
102608
+ connectionIdByUser: /* @__PURE__ */ new Map(),
101970
102609
  expiresAt,
101971
102610
  generation: 0,
101972
102611
  expiryTimer: setTimeout(() => {
@@ -102000,6 +102639,7 @@ function createCollabRoomManager(deps) {
102000
102639
  );
102001
102640
  room.myUserId = null;
102002
102641
  room.knownPeers.clear();
102642
+ room.connectionIdByUser.clear();
102003
102643
  }
102004
102644
  });
102005
102645
  connection.connect();
@@ -102046,10 +102686,53 @@ function createCollabRoomManager(deps) {
102046
102686
 
102047
102687
  // src/services/file-io-handler.ts
102048
102688
  import { execFile as execFile10, spawn as spawn6 } from "child_process";
102049
- import { readdir as readdir14, readFile as readFile34, realpath, stat as stat10, unlink as unlink10, writeFile as writeFile28 } from "fs/promises";
102050
- import { normalize as normalize6, relative as relative3, resolve as resolve2 } from "path";
102689
+ import { readdir as readdir14, readFile as readFile35, realpath as realpath2, stat as stat10, unlink as unlink10, writeFile as writeFile28 } from "fs/promises";
102690
+ import { normalize as normalize7, relative as relative3, resolve as resolve2 } from "path";
102051
102691
  import { promisify as promisify7 } from "util";
102052
102692
 
102693
+ // src/shared/file-io-path-safety.ts
102694
+ import { realpath } from "fs/promises";
102695
+ import { basename as basename5, dirname as dirname19, join as join52, normalize as normalize6 } from "path";
102696
+ async function safeAbsolutePath(userPath, isHidden2, allowedHiddenNames) {
102697
+ const normalized = prepareAbsolutePath(userPath, isHidden2, allowedHiddenNames);
102698
+ if (normalized === null) return null;
102699
+ const canonical = await canonicalizeOrRecombine(normalized);
102700
+ if (canonical === null) return null;
102701
+ if (!checkSegmentsAllowed(canonical, isHidden2, allowedHiddenNames)) return null;
102702
+ return canonical;
102703
+ }
102704
+ function prepareAbsolutePath(userPath, isHidden2, allowedHiddenNames) {
102705
+ if (!userPath.startsWith("/")) return null;
102706
+ const normalized = normalize6(userPath);
102707
+ if (normalized.startsWith("..") || normalized.includes("/..") || normalized.includes("\\..")) {
102708
+ return null;
102709
+ }
102710
+ if (!checkSegmentsAllowed(normalized, isHidden2, allowedHiddenNames)) return null;
102711
+ return normalized;
102712
+ }
102713
+ async function canonicalizeOrRecombine(path2) {
102714
+ try {
102715
+ return await realpath(path2);
102716
+ } catch (err) {
102717
+ if (!isEnoent2(err)) return null;
102718
+ const parentCanonical = await realpath(dirname19(path2)).catch(() => null);
102719
+ if (parentCanonical === null) return null;
102720
+ return join52(parentCanonical, basename5(path2));
102721
+ }
102722
+ }
102723
+ function isEnoent2(err) {
102724
+ return typeof err === "object" && err !== null && "code" in err && err.code === "ENOENT";
102725
+ }
102726
+ function checkSegmentsAllowed(path2, isHidden2, allowedHiddenNames) {
102727
+ const segments = path2.split(/[/\\]/).filter((s2) => s2.length > 0 && s2 !== ".");
102728
+ for (const seg of segments) {
102729
+ if (!isHidden2(seg)) continue;
102730
+ if (allowedHiddenNames?.has(seg)) continue;
102731
+ return false;
102732
+ }
102733
+ return true;
102734
+ }
102735
+
102053
102736
  // src/services/channels/handlers/snapshot-diff-handler.ts
102054
102737
  function planSnapshotDiff(input) {
102055
102738
  const { fromRef, toRef, fromRefExists, toRefExists, autoFetch, parentIsMerged } = input;
@@ -102280,7 +102963,7 @@ function handleFileIOChannel(initialCwd, send, log, deps) {
102280
102963
  return safePathWithOverrides(userPath, /* @__PURE__ */ new Set(["node_modules"]));
102281
102964
  }
102282
102965
  function safePathWithOverrides(userPath, allowedHiddenNames) {
102283
- const normalized = normalize6(userPath);
102966
+ const normalized = normalize7(userPath);
102284
102967
  if (normalized.startsWith("..") || normalized.includes("/..") || normalized.includes("\\..")) {
102285
102968
  return null;
102286
102969
  }
@@ -102295,6 +102978,9 @@ function handleFileIOChannel(initialCwd, send, log, deps) {
102295
102978
  if (rel.startsWith("..") || rel.startsWith("/")) return null;
102296
102979
  return abs;
102297
102980
  }
102981
+ function safeAbsolutePath2(userPath, allowedHiddenNames) {
102982
+ return safeAbsolutePath(userPath, isHidden, allowedHiddenNames);
102983
+ }
102298
102984
  function onMessage(data) {
102299
102985
  let parsed;
102300
102986
  try {
@@ -102347,26 +103033,38 @@ function handleFileIOChannel(initialCwd, send, log, deps) {
102347
103033
  assertNever3(msg);
102348
103034
  }
102349
103035
  }
102350
- function dispatchFileOp(msg) {
103036
+ async function dispatchFileOp(msg) {
102351
103037
  const isReadOnlyOp = msg.type === "read_file" || msg.type === "stat";
102352
- const abs = isReadOnlyOp ? safePathForRead(msg.path) : safePath(msg.path);
103038
+ const isExternal = isReadOnlyOp && msg.external === true;
103039
+ if (isExternal && !deps.allowExternalReads) {
103040
+ log({ event: "file_io_external_denied", path: msg.path });
103041
+ respondError(msg.requestId, `external_not_allowed:${msg.path}`);
103042
+ return;
103043
+ }
103044
+ let abs;
103045
+ if (isExternal) {
103046
+ abs = await safeAbsolutePath2(msg.path, /* @__PURE__ */ new Set(["node_modules"]));
103047
+ } else {
103048
+ abs = isReadOnlyOp ? safePathForRead(msg.path) : safePath(msg.path);
103049
+ }
102353
103050
  if (!abs) {
102354
- log({ event: "file_io_path_rejected", path: msg.path });
102355
- respondError(msg.requestId, `hidden_path:${msg.path}`);
103051
+ log({ event: "file_io_path_rejected", path: msg.path, external: isExternal });
103052
+ const code2 = isExternal ? "external_blocked" : "hidden_path";
103053
+ respondError(msg.requestId, `${code2}:${msg.path}`);
102356
103054
  return;
102357
103055
  }
102358
103056
  switch (msg.type) {
102359
103057
  case "read_file":
102360
- handleReadFile(msg.requestId, abs);
103058
+ await handleReadFile(msg.requestId, abs);
102361
103059
  break;
102362
103060
  case "write_file":
102363
- handleWriteFile(msg.requestId, abs, msg.content);
103061
+ await handleWriteFile(msg.requestId, abs, msg.content);
102364
103062
  break;
102365
103063
  case "readdir":
102366
- handleReaddir(msg.requestId, abs);
103064
+ await handleReaddir(msg.requestId, abs);
102367
103065
  break;
102368
103066
  case "stat":
102369
- handleStat(msg.requestId, abs);
103067
+ await handleStat(msg.requestId, abs);
102370
103068
  break;
102371
103069
  default:
102372
103070
  assertNever3(msg);
@@ -102389,7 +103087,7 @@ function handleFileIOChannel(initialCwd, send, log, deps) {
102389
103087
  case "write_file":
102390
103088
  case "readdir":
102391
103089
  case "stat":
102392
- dispatchFileOp(msg);
103090
+ void dispatchFileOp(msg);
102393
103091
  break;
102394
103092
  case "set_cwd":
102395
103093
  handleSetCwd(msg.requestId, msg.path);
@@ -102456,7 +103154,7 @@ function handleFileIOChannel(initialCwd, send, log, deps) {
102456
103154
  respondError(requestId, "File too large (>10MB)");
102457
103155
  return;
102458
103156
  }
102459
- const content = await readFile34(absPath, "utf-8");
103157
+ const content = await readFile35(absPath, "utf-8");
102460
103158
  respond({ type: "file_content", requestId, content });
102461
103159
  } catch (err) {
102462
103160
  respondError(requestId, formatError(err));
@@ -102505,7 +103203,7 @@ function handleFileIOChannel(initialCwd, send, log, deps) {
102505
103203
  async function handleSetCwd(requestId, newPath) {
102506
103204
  try {
102507
103205
  const resolved = resolve2(newPath);
102508
- const canonical = await realpath(resolved);
103206
+ const canonical = await realpath2(resolved);
102509
103207
  if (!deps.isAllowedCwd(canonical)) {
102510
103208
  respondError(requestId, "cwd not allowed");
102511
103209
  log({ event: "file_io_cwd_rejected", cwd: canonical });
@@ -102544,7 +103242,7 @@ function handleFileIOChannel(initialCwd, send, log, deps) {
102544
103242
  const { stdout } = await execFileAsync3(
102545
103243
  "git",
102546
103244
  ["ls-files", "--cached", "--others", "--exclude-standard", "-z"],
102547
- { cwd, maxBuffer: 10 * 1024 * 1024 }
103245
+ { cwd, maxBuffer: 100 * 1024 * 1024 }
102548
103246
  );
102549
103247
  const files = stdout.split("\0").filter((f2) => f2.length > 0);
102550
103248
  respond({ type: "git_ls_files_result", requestId, files });
@@ -102650,7 +103348,7 @@ function handleFileIOChannel(initialCwd, send, log, deps) {
102650
103348
  const originalContent = await readGitObject(`${mergeBase}:${filePath}`) ?? "";
102651
103349
  let modifiedContent;
102652
103350
  try {
102653
- modifiedContent = await readFile34(resolve2(cwd, filePath), "utf-8");
103351
+ modifiedContent = await readFile35(resolve2(cwd, filePath), "utf-8");
102654
103352
  } catch {
102655
103353
  modifiedContent = "";
102656
103354
  }
@@ -102707,7 +103405,7 @@ function handleFileIOChannel(initialCwd, send, log, deps) {
102707
103405
  const indexContent = await readGitObject(`:${safeRelPath}`);
102708
103406
  originalContent = indexContent ?? await readGitObject(`HEAD:${safeRelPath}`) ?? "";
102709
103407
  try {
102710
- modifiedContent = await readFile34(absPath, "utf-8");
103408
+ modifiedContent = await readFile35(absPath, "utf-8");
102711
103409
  } catch {
102712
103410
  modifiedContent = "";
102713
103411
  }
@@ -103332,13 +104030,13 @@ function wireThreadErrorFallback(daemon, dc, taskId, threadId, channelId, log) {
103332
104030
  // src/shared/pty-manager.ts
103333
104031
  import { accessSync, chmodSync, constants as constants2 } from "fs";
103334
104032
  import { createRequire as createRequire4 } from "module";
103335
- import { dirname as dirname19, resolve as resolve3 } from "path";
104033
+ import { dirname as dirname20, resolve as resolve3 } from "path";
103336
104034
  import * as pty from "node-pty";
103337
104035
  function ensureSpawnHelperExecutable() {
103338
104036
  if (globalThis.process.platform === "win32") return;
103339
104037
  try {
103340
104038
  const req = createRequire4(import.meta.url);
103341
- const nodePtyDir = dirname19(req.resolve("node-pty/package.json"));
104039
+ const nodePtyDir = dirname20(req.resolve("node-pty/package.json"));
103342
104040
  const spawnHelper = resolve3(
103343
104041
  nodePtyDir,
103344
104042
  "prebuilds",
@@ -103753,32 +104451,35 @@ function buildCollabRoomManager(deps) {
103753
104451
  webrtcAdapter: collabWebrtcAdapter,
103754
104452
  log: createChildLogger({ mode: `collab-peer:${roomId}` }),
103755
104453
  logAdapter,
103756
- onAnswer(namespacedId, answer) {
104454
+ onAnswer(namespacedId, answer, generationId) {
103757
104455
  const prefix = `collab:${roomId}:`;
103758
104456
  const targetUserId = namespacedId.startsWith(prefix) ? namespacedId.slice(prefix.length) : namespacedId;
103759
104457
  connection.send({
103760
104458
  type: "webrtc-answer",
103761
104459
  targetUserId,
104460
+ generationId,
103762
104461
  // eslint-disable-next-line no-restricted-syntax -- SDP is opaque over signaling
103763
104462
  answer
103764
104463
  });
103765
104464
  },
103766
- onOffer(namespacedId, offer) {
104465
+ onOffer(namespacedId, offer, generationId) {
103767
104466
  const prefix = `collab:${roomId}:`;
103768
104467
  const targetUserId = namespacedId.startsWith(prefix) ? namespacedId.slice(prefix.length) : namespacedId;
103769
104468
  connection.send({
103770
104469
  type: "webrtc-offer",
103771
104470
  targetUserId,
104471
+ generationId,
103772
104472
  // eslint-disable-next-line no-restricted-syntax -- SDP is opaque over signaling
103773
104473
  offer
103774
104474
  });
103775
104475
  },
103776
- onIceCandidate(namespacedId, candidate) {
104476
+ onIceCandidate(namespacedId, candidate, generationId) {
103777
104477
  const prefix = `collab:${roomId}:`;
103778
104478
  const targetUserId = namespacedId.startsWith(prefix) ? namespacedId.slice(prefix.length) : namespacedId;
103779
104479
  connection.send({
103780
104480
  type: "webrtc-ice",
103781
104481
  targetUserId,
104482
+ generationId,
103782
104483
  // eslint-disable-next-line no-restricted-syntax -- ICE candidate is opaque over signaling
103783
104484
  candidate
103784
104485
  });
@@ -103933,7 +104634,16 @@ function buildCollabRoomManager(deps) {
103933
104634
  const taskCwd = daemon.taskManager.getTaskCwd(collabTaskId);
103934
104635
  if (!taskCwd) return false;
103935
104636
  return isUnderAllowedRoot2(abs, [taskCwd]);
103936
- }
104637
+ },
104638
+ /**
104639
+ * Collab peers must NOT get external-path reads — a peer
104640
+ * could otherwise send `{external: true, path: '/etc/hosts'}`
104641
+ * or walk to another task's cwd via the abs-path flag, which
104642
+ * would defeat the entire task-isolation allowlist above.
104643
+ * The daemon rejects external:true with
104644
+ * `external_not_allowed:...` when this flag is false.
104645
+ */
104646
+ allowExternalReads: false
103937
104647
  }
103938
104648
  });
103939
104649
  log.info("Collab file I/O channel wired");
@@ -104065,13 +104775,13 @@ function buildCollabRoomManager(deps) {
104065
104775
  import { execSync } from "child_process";
104066
104776
  import { existsSync as existsSync8 } from "fs";
104067
104777
  import { createRequire as createRequire5 } from "module";
104068
- import { dirname as dirname20, join as join53 } from "path";
104778
+ import { dirname as dirname21, join as join54 } from "path";
104069
104779
 
104070
104780
  // src/services/bootstrap/self-update.ts
104071
104781
  import { execFile as execFile11, spawn as spawn8 } from "child_process";
104072
104782
  import { createHash as createHash6 } from "crypto";
104073
- import { chmod as chmod3, mkdir as mkdir22, readFile as readFile35, rename as rename20, unlink as unlink11, writeFile as writeFile29 } from "fs/promises";
104074
- import { join as join52 } from "path";
104783
+ import { chmod as chmod3, mkdir as mkdir22, readFile as readFile36, rename as rename20, unlink as unlink11, writeFile as writeFile29 } from "fs/promises";
104784
+ import { join as join53 } from "path";
104075
104785
 
104076
104786
  // src/services/bootstrap/self-update-installer-scripts.ts
104077
104787
  function buildPosixInstallerScript(params) {
@@ -104473,7 +105183,7 @@ async function downloadTarball(url, destPath, fetchFn) {
104473
105183
  throw new Error(`download failed: HTTP ${response.status} ${response.statusText}`);
104474
105184
  }
104475
105185
  const bytes = new Uint8Array(await response.arrayBuffer());
104476
- await mkdir22(join52(destPath, ".."), { recursive: true });
105186
+ await mkdir22(join53(destPath, ".."), { recursive: true });
104477
105187
  try {
104478
105188
  await writeFile29(tmpPath, bytes);
104479
105189
  await rename20(tmpPath, destPath);
@@ -104492,7 +105202,7 @@ async function downloadTarball(url, destPath, fetchFn) {
104492
105202
  }
104493
105203
  async function verifyChecksum(path2, expectedHash) {
104494
105204
  const algo = expectedHash.length === 40 ? "sha1" : "sha256";
104495
- const raw = await readFile35(path2);
105205
+ const raw = await readFile36(path2);
104496
105206
  const actual = createHash6(algo).update(raw).digest("hex");
104497
105207
  if (actual !== expectedHash) {
104498
105208
  try {
@@ -104503,7 +105213,7 @@ async function verifyChecksum(path2, expectedHash) {
104503
105213
  }
104504
105214
  }
104505
105215
  async function stageInstallerScript(scriptPath, params) {
104506
- await mkdir22(join52(scriptPath, ".."), { recursive: true });
105216
+ await mkdir22(join53(scriptPath, ".."), { recursive: true });
104507
105217
  const body = process.platform === "win32" ? buildWindowsInstallerScript(params) : buildPosixInstallerScript(params);
104508
105218
  await writeFile29(scriptPath, body);
104509
105219
  await chmod3(scriptPath, 493);
@@ -104553,16 +105263,16 @@ function buildInstallerParams(state, deps) {
104553
105263
  targetVersion: state.resolved.version,
104554
105264
  previousVersion: deps.currentVersion,
104555
105265
  parentPid: deps.pid,
104556
- statusFilePath: join52(deps.shipyardHome, "update-status.json"),
104557
- pidFilePath: join52(deps.shipyardHome, "daemon.pid"),
104558
- logPath: join52(deps.shipyardHome, "updates", `install-${deps.pid}.log`),
104559
- snapshotPath: join52(deps.shipyardHome, "updates", `rollback-${deps.currentVersion}`),
105266
+ statusFilePath: join53(deps.shipyardHome, "update-status.json"),
105267
+ pidFilePath: join53(deps.shipyardHome, "daemon.pid"),
105268
+ logPath: join53(deps.shipyardHome, "updates", `install-${deps.pid}.log`),
105269
+ snapshotPath: join53(deps.shipyardHome, "updates", `rollback-${deps.currentVersion}`),
104560
105270
  npmBin: "npm"
104561
105271
  };
104562
105272
  }
104563
105273
  function tarballPathFor(shipyardHome, version, shasum) {
104564
105274
  const shaPrefix = shasum.slice(0, 12);
104565
- return join52(shipyardHome, "updates", `${version}-${shaPrefix}.tgz`);
105275
+ return join53(shipyardHome, "updates", `${version}-${shaPrefix}.tgz`);
104566
105276
  }
104567
105277
  async function runResolveStep(step, state, deps) {
104568
105278
  const resolver = deps.resolveVersion ?? defaultResolveVersion;
@@ -104728,7 +105438,7 @@ function resolveClaudeCodePath(log) {
104728
105438
  try {
104729
105439
  const req = createRequire5(import.meta.url);
104730
105440
  const sdkMain = req.resolve("@anthropic-ai/claude-agent-sdk");
104731
- const p2 = join53(dirname20(sdkMain), "cli.js");
105441
+ const p2 = join54(dirname21(sdkMain), "cli.js");
104732
105442
  if (existsSync8(p2)) return ok("sdk_bundled", p2);
104733
105443
  } catch {
104734
105444
  }
@@ -104738,7 +105448,7 @@ function resolveClaudeCodePath(log) {
104738
105448
  } catch {
104739
105449
  }
104740
105450
  for (const c of [
104741
- join53(process.env.HOME ?? "", ".local", "bin", "claude"),
105451
+ join54(process.env.HOME ?? "", ".local", "bin", "claude"),
104742
105452
  "/usr/local/bin/claude"
104743
105453
  ])
104744
105454
  if (existsSync8(c)) return ok("well_known", c);
@@ -105069,7 +105779,14 @@ function buildSharedChannelCallbacks(deps) {
105069
105779
  diffTurn: (taskId, turnIndex) => daemon.taskManager.diffTurn(taskId, turnIndex),
105070
105780
  diffTurnFile: (taskId, turnIndex, path2) => daemon.taskManager.diffTurnFile(taskId, turnIndex, path2),
105071
105781
  revertTurn: (taskId, turnIndex, mode, filePath) => daemon.taskManager.revertTurn(taskId, turnIndex, mode, filePath),
105072
- isAllowedCwd: (abs) => isUnderAllowedRoot2(abs, daemon.taskManager.getAllowedCwds())
105782
+ isAllowedCwd: (abs) => isUnderAllowedRoot2(abs, daemon.taskManager.getAllowedCwds()),
105783
+ /**
105784
+ * Personal-mode: owner is operating on their own host, so reading
105785
+ * arbitrary files outside the workspace (subject to HIDDEN_PATTERNS)
105786
+ * is the feature that lets them open ~/.claude/plans/*.md and
105787
+ * similar. Collab wiring must pass `false` here.
105788
+ */
105789
+ allowExternalReads: true
105073
105790
  },
105074
105791
  onCwdChange: (newCwd) => {
105075
105792
  portDetectorRef.current?.setCwd(newCwd);
@@ -105236,8 +105953,8 @@ function buildLocalDirectChannelCallbacks(deps) {
105236
105953
 
105237
105954
  // src/services/storage/daemon-settings-store.ts
105238
105955
  import { createHash as createHash7 } from "crypto";
105239
- import { mkdir as mkdir23, readFile as readFile36, rename as rename21, writeFile as writeFile30 } from "fs/promises";
105240
- import { join as join54 } from "path";
105956
+ import { mkdir as mkdir23, readFile as readFile37, rename as rename21, writeFile as writeFile30 } from "fs/promises";
105957
+ import { join as join55 } from "path";
105241
105958
  var ProjectSettingsSchema = external_exports.object({
105242
105959
  disabledMcpServers: external_exports.array(external_exports.string()).optional()
105243
105960
  });
@@ -105245,9 +105962,9 @@ function hashProjectPath(projectPath) {
105245
105962
  return createHash7("sha256").update(projectPath).digest("hex").slice(0, 16);
105246
105963
  }
105247
105964
  function buildDaemonSettingsStore(dataDir) {
105248
- const settingsDir = join54(dataDir, "settings");
105965
+ const settingsDir = join55(dataDir, "settings");
105249
105966
  function settingsPath(projectPath) {
105250
- return join54(settingsDir, `${hashProjectPath(projectPath)}.json`);
105967
+ return join55(settingsDir, `${hashProjectPath(projectPath)}.json`);
105251
105968
  }
105252
105969
  async function ensureDir() {
105253
105970
  await mkdir23(settingsDir, { recursive: true });
@@ -105255,7 +105972,7 @@ function buildDaemonSettingsStore(dataDir) {
105255
105972
  return {
105256
105973
  async load(projectPath) {
105257
105974
  try {
105258
- const raw = await readFile36(settingsPath(projectPath), "utf-8");
105975
+ const raw = await readFile37(settingsPath(projectPath), "utf-8");
105259
105976
  return ProjectSettingsSchema.parse(JSON.parse(raw));
105260
105977
  } catch (err) {
105261
105978
  if (isEnoent(err)) return {};
@@ -105273,19 +105990,19 @@ function buildDaemonSettingsStore(dataDir) {
105273
105990
  }
105274
105991
 
105275
105992
  // src/services/storage/plugin-config-store.ts
105276
- import { mkdir as mkdir24, readFile as readFile37, rename as rename22, writeFile as writeFile31 } from "fs/promises";
105277
- import { join as join55 } from "path";
105993
+ import { mkdir as mkdir24, readFile as readFile38, rename as rename22, writeFile as writeFile31 } from "fs/promises";
105994
+ import { join as join56 } from "path";
105278
105995
  function buildPluginConfigStore(pluginsDir) {
105279
105996
  const cache2 = /* @__PURE__ */ new Map();
105280
105997
  const writeQueues = /* @__PURE__ */ new Map();
105281
105998
  function configPath(pluginId) {
105282
- return join55(pluginsDir, pluginId, "config.json");
105999
+ return join56(pluginsDir, pluginId, "config.json");
105283
106000
  }
105284
106001
  async function ensureLoaded(pluginId) {
105285
106002
  const cached2 = cache2.get(pluginId);
105286
106003
  if (cached2) return cached2;
105287
106004
  try {
105288
- const raw = await readFile37(configPath(pluginId), "utf-8");
106005
+ const raw = await readFile38(configPath(pluginId), "utf-8");
105289
106006
  const parsed = JSON.parse(raw);
105290
106007
  if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
105291
106008
  const record = Object.fromEntries(Object.entries(parsed));
@@ -105310,7 +106027,7 @@ function buildPluginConfigStore(pluginsDir) {
105310
106027
  const next = prev.then(async () => {
105311
106028
  const filePath = configPath(pluginId);
105312
106029
  const tmpPath = `${filePath}.tmp`;
105313
- await mkdir24(join55(pluginsDir, pluginId), { recursive: true });
106030
+ await mkdir24(join56(pluginsDir, pluginId), { recursive: true });
105314
106031
  await writeFile31(tmpPath, JSON.stringify(config2, null, 2), "utf-8");
105315
106032
  await rename22(tmpPath, filePath);
105316
106033
  });
@@ -105331,13 +106048,13 @@ function buildPluginConfigStore(pluginsDir) {
105331
106048
  var LEGACY_EPOCH = 5;
105332
106049
  async function serve(options = {}) {
105333
106050
  const shipyardHome = options.shipyardHome ?? getShipyardHome();
105334
- const dataDir = join56(shipyardHome, options.isDev ? "data-dev" : "data");
106051
+ const dataDir = join57(shipyardHome, options.isDev ? "data-dev" : "data");
105335
106052
  const log = createChildLogger({ mode: "serve" });
105336
- const workspaceRoot = await realpath2(findProjectRoot(process.cwd())).catch(
106053
+ const workspaceRoot = await realpath3(findProjectRoot(process.cwd())).catch(
105337
106054
  () => findProjectRoot(process.cwd())
105338
106055
  );
105339
106056
  registerBuiltinPlugins();
105340
- const pluginConfigStore = buildPluginConfigStore(join56(dataDir, "plugins"));
106057
+ const pluginConfigStore = buildPluginConfigStore(join57(dataDir, "plugins"));
105341
106058
  await mkdir25(dataDir, { recursive: true });
105342
106059
  log.info({ shipyardHome, dataDir, workspaceRoot }, "Starting daemon");
105343
106060
  function logAdapter(entry) {
@@ -105363,8 +106080,8 @@ async function serve(options = {}) {
105363
106080
  await lifecycle.acquirePidFile(shipyardHome);
105364
106081
  const pidTracker = buildPidTracker(shipyardHome);
105365
106082
  await pidTracker.sweepOrphans(logAdapter);
105366
- await pruneOldEpochData(join56(dataDir, "loro"), LEGACY_EPOCH, logAdapter);
105367
- const storage = new FileStorageAdapter(join56(dataDir, "loro"));
106083
+ await pruneOldEpochData(join57(dataDir, "loro"), LEGACY_EPOCH, logAdapter);
106084
+ const storage = new FileStorageAdapter(join57(dataDir, "loro"));
105368
106085
  const personalWebrtcAdapter = new WebRtcDataChannelAdapter();
105369
106086
  const peerRoleRegistry = createPeerRoleRegistry();
105370
106087
  const presencePoolRef = { pool: {} };
@@ -105425,7 +106142,7 @@ async function serve(options = {}) {
105425
106142
  previewProxy
105426
106143
  });
105427
106144
  daemon.healthMetrics.start();
105428
- const pluginsDir = join56(shipyardHome, "plugins");
106145
+ const pluginsDir = join57(shipyardHome, "plugins");
105429
106146
  await mkdir25(pluginsDir, { recursive: true });
105430
106147
  let loadedPlugins = [];
105431
106148
  const loadedPluginsRef = {
@@ -105558,7 +106275,6 @@ async function serve(options = {}) {
105558
106275
  const peerManager = peerSetupDeps ? buildPeerManager(peerSetupDeps) : null;
105559
106276
  if (peerSetupDeps && !localDirectRef.current) {
105560
106277
  const handle = await setupLocalDirect({
105561
- env,
105562
106278
  shipyardHome,
105563
106279
  daemonId: daemonPeerId,
105564
106280
  userId: auth3.userId,
@@ -105701,4 +106417,4 @@ export {
105701
106417
  _testing,
105702
106418
  serve
105703
106419
  };
105704
- //# sourceMappingURL=serve-O4AHOTL4.js.map
106420
+ //# sourceMappingURL=serve-IMSVRV3B.js.map