@detergent-software/conductor 3.0.0-dev.8 → 3.0.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 (210) hide show
  1. package/CHANGELOG.md +482 -0
  2. package/README.md +275 -125
  3. package/build/brief-cli.js +5 -12
  4. package/build/brief-cli.js.map +1 -1
  5. package/build/brief.js +4 -3
  6. package/build/brief.js.map +1 -1
  7. package/build/broker-cli.js +42 -15
  8. package/build/broker-cli.js.map +1 -1
  9. package/build/broker.js +1 -1
  10. package/build/broker.js.map +1 -1
  11. package/build/cli.js +221 -63
  12. package/build/cli.js.map +1 -1
  13. package/build/clones.js +92 -0
  14. package/build/clones.js.map +1 -0
  15. package/build/code-host-cli.js +172 -40
  16. package/build/code-host-cli.js.map +1 -1
  17. package/build/code-host-service.js +35 -2
  18. package/build/code-host-service.js.map +1 -1
  19. package/build/code-host.js +138 -16
  20. package/build/code-host.js.map +1 -1
  21. package/build/conditions.js +33 -16
  22. package/build/conditions.js.map +1 -1
  23. package/build/config.js +58 -28
  24. package/build/config.js.map +1 -1
  25. package/build/console.js +17 -27
  26. package/build/console.js.map +1 -1
  27. package/build/credentials.js +86 -54
  28. package/build/credentials.js.map +1 -1
  29. package/build/daemon.js +54 -65
  30. package/build/daemon.js.map +1 -1
  31. package/build/file-lock.js +155 -40
  32. package/build/file-lock.js.map +1 -1
  33. package/build/follow-ups.js +2 -2
  34. package/build/follow-ups.js.map +1 -1
  35. package/build/help.js +241 -83
  36. package/build/help.js.map +1 -1
  37. package/build/history.js +10 -4
  38. package/build/history.js.map +1 -1
  39. package/build/host/credential-manager.js +109 -0
  40. package/build/host/credential-manager.js.map +1 -0
  41. package/build/host/keychain.js +44 -0
  42. package/build/host/keychain.js.map +1 -0
  43. package/build/host/launchd.js +118 -0
  44. package/build/host/launchd.js.map +1 -0
  45. package/build/host/libsecret.js +40 -0
  46. package/build/host/libsecret.js.map +1 -0
  47. package/build/host/macos-terminal.js +63 -0
  48. package/build/host/macos-terminal.js.map +1 -0
  49. package/build/host/platform.js +4 -0
  50. package/build/host/platform.js.map +1 -0
  51. package/build/host/secrets.js +46 -0
  52. package/build/host/secrets.js.map +1 -0
  53. package/build/host/shell.js +26 -0
  54. package/build/host/shell.js.map +1 -0
  55. package/build/host/startup.js +26 -0
  56. package/build/host/startup.js.map +1 -0
  57. package/build/host/systemd.js +91 -0
  58. package/build/host/systemd.js.map +1 -0
  59. package/build/host/task-scheduler.js +78 -0
  60. package/build/host/task-scheduler.js.map +1 -0
  61. package/build/host/terminal.js +46 -0
  62. package/build/host/terminal.js.map +1 -0
  63. package/build/host/tmux.js +39 -0
  64. package/build/host/tmux.js.map +1 -0
  65. package/build/host/windows-terminal.js +46 -0
  66. package/build/host/windows-terminal.js.map +1 -0
  67. package/build/in-process.js +6 -5
  68. package/build/in-process.js.map +1 -1
  69. package/build/inbox.js +57 -39
  70. package/build/inbox.js.map +1 -1
  71. package/build/init.js +509 -0
  72. package/build/init.js.map +1 -0
  73. package/build/landing.js +107 -10
  74. package/build/landing.js.map +1 -1
  75. package/build/lib/providers/ado/code-host.js +5 -1
  76. package/build/lib/providers/ado/code-host.js.map +1 -1
  77. package/build/lib/providers/ado/pipelines.js +138 -26
  78. package/build/lib/providers/ado/pipelines.js.map +1 -1
  79. package/build/lib/providers/ado/trigger.js +5 -0
  80. package/build/lib/providers/ado/trigger.js.map +1 -1
  81. package/build/lib/providers/ado/writes.js +17 -2
  82. package/build/lib/providers/ado/writes.js.map +1 -1
  83. package/build/lib/providers/github/actions.js +90 -3
  84. package/build/lib/providers/github/actions.js.map +1 -1
  85. package/build/lib/providers/github/code-host.js +2 -0
  86. package/build/lib/providers/github/code-host.js.map +1 -1
  87. package/build/lib/providers/github/writes.js +25 -2
  88. package/build/lib/providers/github/writes.js.map +1 -1
  89. package/build/lib/providers/index.js +3 -2
  90. package/build/lib/providers/index.js.map +1 -1
  91. package/build/lib/providers/npm/stage.js +14 -1
  92. package/build/lib/providers/npm/stage.js.map +1 -1
  93. package/build/lib/providers/timing.js +10 -0
  94. package/build/lib/providers/timing.js.map +1 -0
  95. package/build/lib/providers/verdicts/pr.js +47 -6
  96. package/build/lib/providers/verdicts/pr.js.map +1 -1
  97. package/build/lib/providers/verdicts/runs.js +12 -2
  98. package/build/lib/providers/verdicts/runs.js.map +1 -1
  99. package/build/logins.js +13 -6
  100. package/build/logins.js.map +1 -1
  101. package/build/manager.js +121 -55
  102. package/build/manager.js.map +1 -1
  103. package/build/note.js +5 -15
  104. package/build/note.js.map +1 -1
  105. package/build/notes.js +130 -23
  106. package/build/notes.js.map +1 -1
  107. package/build/notify.js +104 -42
  108. package/build/notify.js.map +1 -1
  109. package/build/npm-reads.js +151 -22
  110. package/build/npm-reads.js.map +1 -1
  111. package/build/ownership.js +2 -2
  112. package/build/paths.js +44 -1
  113. package/build/paths.js.map +1 -1
  114. package/build/plugin.js +4 -2
  115. package/build/plugin.js.map +1 -1
  116. package/build/profile-fields.js +153 -0
  117. package/build/profile-fields.js.map +1 -0
  118. package/build/profile-layers.js +264 -0
  119. package/build/profile-layers.js.map +1 -0
  120. package/build/profile.js +107 -51
  121. package/build/profile.js.map +1 -1
  122. package/build/project-check-cli.js +17 -19
  123. package/build/project-check-cli.js.map +1 -1
  124. package/build/project-check.js +166 -104
  125. package/build/project-check.js.map +1 -1
  126. package/build/protocol.js +5 -21
  127. package/build/protocol.js.map +1 -1
  128. package/build/pushover.js +5 -5
  129. package/build/pushover.js.map +1 -1
  130. package/build/queue.js +4 -4
  131. package/build/queue.js.map +1 -1
  132. package/build/registry.js +195 -36
  133. package/build/registry.js.map +1 -1
  134. package/build/release-kinds.js +34 -0
  135. package/build/release-kinds.js.map +1 -0
  136. package/build/rule-actions.js +8 -6
  137. package/build/rule-actions.js.map +1 -1
  138. package/build/rules-service.js +104 -47
  139. package/build/rules-service.js.map +1 -1
  140. package/build/rules.js +23 -23
  141. package/build/rules.js.map +1 -1
  142. package/build/run.js +20 -7
  143. package/build/run.js.map +1 -1
  144. package/build/runs.js +218 -68
  145. package/build/runs.js.map +1 -1
  146. package/build/scheduler.js +89 -13
  147. package/build/scheduler.js.map +1 -1
  148. package/build/search.js +2 -1
  149. package/build/search.js.map +1 -1
  150. package/build/server.js +51 -14
  151. package/build/server.js.map +1 -1
  152. package/build/spend.js +1 -1
  153. package/build/spend.js.map +1 -1
  154. package/build/store.js +79 -20
  155. package/build/store.js.map +1 -1
  156. package/build/supervise.js +1 -1
  157. package/build/supervise.js.map +1 -1
  158. package/build/timeline.js +104 -0
  159. package/build/timeline.js.map +1 -0
  160. package/build/tracker-reads-cli.js +4 -5
  161. package/build/tracker-reads-cli.js.map +1 -1
  162. package/build/trackers.js +56 -10
  163. package/build/trackers.js.map +1 -1
  164. package/build/train-cli.js +6 -5
  165. package/build/train-cli.js.map +1 -1
  166. package/build/train.js +2 -2
  167. package/build/train.js.map +1 -1
  168. package/build/upgrade.js +8 -9
  169. package/build/upgrade.js.map +1 -1
  170. package/build/wait.js +10 -10
  171. package/build/wait.js.map +1 -1
  172. package/build/watchers/ado.js +42 -19
  173. package/build/watchers/ado.js.map +1 -1
  174. package/build/watchers/github.js +18 -4
  175. package/build/watchers/github.js.map +1 -1
  176. package/build/watchers/index.js +2 -2
  177. package/build/watchers/index.js.map +1 -1
  178. package/build/watchers/linear-queue.js +47 -14
  179. package/build/watchers/linear-queue.js.map +1 -1
  180. package/build/watchers/linear-writes.js +26 -12
  181. package/build/watchers/linear-writes.js.map +1 -1
  182. package/build/watchers/linear.js +2 -2
  183. package/build/watchers/linear.js.map +1 -1
  184. package/build/watchers/source.js +5 -1
  185. package/build/watchers/source.js.map +1 -1
  186. package/build/webhook.js +80 -0
  187. package/build/webhook.js.map +1 -0
  188. package/build/why.js +1 -1
  189. package/build/why.js.map +1 -1
  190. package/build/worktrees.js +56 -93
  191. package/build/worktrees.js.map +1 -1
  192. package/package.json +61 -61
  193. package/plugin/.claude-plugin/plugin.json +1 -1
  194. package/plugin/hooks/model.ts +46 -6
  195. package/plugin/hooks/register.tsx +41 -2
  196. package/plugin/skills/conductor-console/SKILL.md +26 -27
  197. package/plugin/skills/work-item/SKILL.md +58 -54
  198. package/plugin/skills/work-item/trackers/ado-boards.md +2 -2
  199. package/plugin/skills/work-item/trackers/github-issues.md +1 -1
  200. package/plugin/skills/work-item/trackers/linear.md +2 -2
  201. package/plugin/types/index.d.ts +1 -1
  202. package/protocol/PROTOCOL.md +3 -9
  203. package/build/checkout-sync.js +0 -249
  204. package/build/checkout-sync.js.map +0 -1
  205. package/build/credential.js +0 -66
  206. package/build/credential.js.map +0 -1
  207. package/build/startup.js +0 -92
  208. package/build/startup.js.map +0 -1
  209. package/build/tab.js +0 -32
  210. package/build/tab.js.map +0 -1
package/build/manager.js CHANGED
@@ -7,14 +7,16 @@ import { formatCondition, parseCondition, prRef, uncheckable } from "./condition
7
7
  import { ConductorError } from "./errors.js";
8
8
  import { UNWATCHED_HANDOFFS } from "./inbox.js";
9
9
  import { processAlive as defaultProcessAlive } from "./lock.js";
10
+ import { projectPaths } from "./paths.js";
10
11
  import { missingPluginSkills, projectSkills } from "./plugin.js";
11
12
  import { WAKE_PREFIX } from "./protocol.js";
13
+ import { registeredByCheckout } from "./registry.js";
12
14
  import { resolveOpenWait } from "./resolve.js";
13
15
  import { findTranscript, formatTokens, lastTurnAt, localDay, projectSlug, readUsage, transcriptFiles, } from "./spend.js";
14
16
  import { ACTIVE_STATUSES, openStore, waitSchema, workerSchema, } from "./store.js";
15
17
  import { linkedPr } from "./tracker-writes.js";
16
18
  import { pendingCall } from "./transcript.js";
17
- import { BRANCH_PREFIX, worktreePath } from "./worktrees.js";
19
+ import { BRANCH_PREFIX } from "./worktrees.js";
18
20
  // A worker as GET /fleet shows it.
19
21
  export const fleetWorkerSchema = workerSchema.extend({
20
22
  // launching, starting, working, idle, waiting on <x>, parked, stopped, no session
@@ -86,7 +88,7 @@ export function workerLine(w) {
86
88
  }
87
89
  export const PROBE_PREFIX = 'probe-';
88
90
  export const PROBE_PROMPT = 'This is a conductor probe session started by a test run. It works no issue: reply "probe ready" and wait.';
89
- // conduct-atk starts workers this way; a resume keeps both (ATK-309's open check).
91
+ // Workers start in auto mode; a resume keeps it (ATK-309's open check).
90
92
  export const PERMISSION_MODE = 'auto';
91
93
  // How long a started or woken worker's session has to report its plugin. A start runs the create
92
94
  // hook first only for a launch, which is before the clock starts.
@@ -96,6 +98,9 @@ const ITEM = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
96
98
  const STOP_WAIT_MS = 30_000;
97
99
  // A park that failed (the process outlived `claude stop`) is tried again this much later.
98
100
  const PARK_RETRY_MS = 5 * 60_000;
101
+ // How long a new session id has to stay listed before the worker follows it. A resuming session is
102
+ // listed for under a second under the id its process started with, before it loads its own (ATK-574).
103
+ const SESSION_ID_SETTLE_MS = 5_000;
99
104
  const TRANSCRIPT_SCAN_MS = 60_000;
100
105
  // A compaction with no boundary in the transcript this long after it started, its turn over, failed.
101
106
  const COMPACT_MINUTES = 10;
@@ -118,7 +123,7 @@ export function eventText(waits) {
118
123
  return `${WAKE_PREFIX}${[...facts].map(([fact, at]) => `${fact} ${clock(new Date(at))}`).join('; ')}`;
119
124
  }
120
125
  export function sessionManager(deps) {
121
- const { broker, claude, config, log, openTab, pluginDir, prs, store, worktrees } = deps;
126
+ const { broker, claude, config, log, pluginDir, prs, store, tabs, worktrees } = deps;
122
127
  const now = deps.now ?? (() => new Date());
123
128
  const alive = deps.processAlive ?? defaultProcessAlive;
124
129
  const sleep = deps.sleep ?? ((ms) => new Promise((done) => setTimeout(done, ms)));
@@ -128,6 +133,8 @@ export function sessionManager(deps) {
128
133
  const stalls = new Map();
129
134
  const transcripts = new Map();
130
135
  const transcriptScans = new Map();
136
+ // worker id → a session id the listing shows for it that it hasn't followed yet, since when
137
+ const newSessionIds = new Map();
131
138
  let sessions = [];
132
139
  // Until the first sweep, nothing is known about any session.
133
140
  let listed = false;
@@ -138,6 +145,8 @@ export function sessionManager(deps) {
138
145
  const held = new Set();
139
146
  // Workers whose compaction failed: not tried again while the daemon runs (their inbox item says so).
140
147
  const uncompactable = new Set();
148
+ // worker id → the attach (its attachedAt) a retire was last refused for, so each is logged once.
149
+ const attachRefusals = new Map();
141
150
  // Every session the conductor starts loads its plugin, and a resume whose saved --plugin-dir went
142
151
  // missing runs on without it, silently (ATK-522). The mod reports each session start (pluginSeen),
143
152
  // so a worker started or woken that doesn't report within PLUGIN_REPORT_MS is a
@@ -228,10 +237,15 @@ export function sessionManager(deps) {
228
237
  throw new ConductorError(`project "${found.name}" has a problem: ${found.problem ?? 'no profile'}`);
229
238
  if (!found.enabled)
230
239
  throw new ConductorError(`project "${found.name}" is disabled`);
240
+ if (registeredByCheckout(deps.paths, found)) {
241
+ throw new ConductorError(`project "${found.name}" is registered by the checkout ${found.clone}`, [
242
+ `the conductor works only in a clone of its own: run \`conduct project add "${found.clone}"\` first`,
243
+ ]);
244
+ }
231
245
  return { ...found, profile: found.profile };
232
246
  };
233
- // Where a worker's commands run: its project's main checkout, where conduct-atk starts them too.
234
- const rootOf = (worker) => store.listProjects().find((p) => p.name === worker.project)?.root ?? worker.worktree ?? process.cwd();
247
+ // Where a worker's commands run when it has no worktree: its project's clone.
248
+ const cloneOf = (worker) => projectPaths(deps.paths, worker.project).clone;
235
249
  const sessionOf = (worker) => sessions.find((s) => (worker.sessionId !== null && s.sessionId === worker.sessionId) ||
236
250
  (worker.handle !== null && s.id === worker.handle));
237
251
  const minutesSince = (iso) => (now().getTime() - Date.parse(iso)) / 60_000;
@@ -269,13 +283,14 @@ export function sessionManager(deps) {
269
283
  };
270
284
  // ---------- launch ----------
271
285
  // The worktree first (design note v2, section 2): its ports from the broker, then `git worktree add`
272
- // from origin/<base>, the local files and the profile's setup. The session starts in it.
273
- const launchWorker = async (worker, root, profile, prompt) => {
286
+ // in the project's clone from origin/<base>, the local files and the profile's setup. The session
287
+ // starts in it.
288
+ const launchWorker = async (worker, profile, prompt) => {
274
289
  let current = worker;
275
290
  let stage = 'assign ports';
276
- const path = worktreePath(root, worker.name);
277
- // A worktree there already (a kept one, or one a person made with `claude --worktree`) isn't
278
- // this launch's to remove when it fails.
291
+ const { clone, worktrees: dir } = projectPaths(deps.paths, worker.project);
292
+ const path = join(dir, worker.name);
293
+ // A worktree there already (a kept one) isn't this launch's to remove when it fails.
279
294
  const reused = existsSync(join(path, '.git'));
280
295
  try {
281
296
  // Recorded before it exists: the broker keeps an in-flight worker's ports while its directory
@@ -286,10 +301,11 @@ export function sessionManager(deps) {
286
301
  const env = portEnv(ports);
287
302
  const worktree = await worktrees.create({
288
303
  base: profile.baseBranch,
304
+ clone,
289
305
  env: { ...env, CONDUCTOR_ITEM: worker.item, CONDUCTOR_PROJECT: worker.project, CONDUCTOR_WORKTREE: path },
290
306
  ...(profile.localFilesFrom === undefined ? {} : { localFilesFrom: profile.localFilesFrom }),
291
307
  name: worker.name,
292
- root,
308
+ path,
293
309
  sessionEnv: env,
294
310
  setup: profile.setup,
295
311
  });
@@ -314,8 +330,8 @@ export function sessionManager(deps) {
314
330
  // Claude Code's own message names the worktree, which this failed launch removes.
315
331
  if (!(error instanceof WorkspaceUntrusted))
316
332
  throw error;
317
- throw new ConductorError(`Claude Code doesn't trust ${root}`, [
318
- `${trustStep(root)}, then \`conduct project check ${worker.project}\``,
333
+ throw new ConductorError(`Claude Code doesn't trust ${clone}`, [
334
+ `${trustStep(clone)}, then \`conduct project check ${worker.project}\``,
319
335
  ...error.details,
320
336
  ]);
321
337
  });
@@ -330,8 +346,8 @@ export function sessionManager(deps) {
330
346
  current = update(current, { handle, sessionId: session?.sessionId ?? null, status: 'running' });
331
347
  event(current, 'worker.launched', { handle, pluginDir, sessionId: current.sessionId, worktree });
332
348
  expectPlugin(current, started);
333
- if (profile.workers.tab) {
334
- const tab = await openTab({
349
+ if (tabs.onLaunch) {
350
+ const tab = await tabs.open({
335
351
  cwd: worktree,
336
352
  handle,
337
353
  title: worker.name,
@@ -346,7 +362,7 @@ export function sessionManager(deps) {
346
362
  const cleanup = reused
347
363
  ? { kept: path, keptBecause: 'it was there before the launch' }
348
364
  : current.worktree && existsSync(current.worktree)
349
- ? await removeIfClean(root, current.worktree)
365
+ ? await removeIfClean(clone, current.worktree)
350
366
  : {};
351
367
  freePorts(store, path, 'launch failed');
352
368
  current = update(current, { status: 'failed' });
@@ -356,13 +372,13 @@ export function sessionManager(deps) {
356
372
  };
357
373
  // A failed launch's worktree, unless it holds work. Its branch goes with it while it still has the
358
374
  // name the launch gave it; a renamed one was the worker's.
359
- const removeIfClean = async (root, worktree) => {
375
+ const removeIfClean = async (clone, worktree) => {
360
376
  try {
361
377
  const work = await worktrees.unfinishedWork(worktree);
362
378
  if (work)
363
379
  return { kept: worktree, ...work };
364
380
  const branch = await worktrees.branchOf(worktree);
365
- await worktrees.remove(root, worktree, { deleteBranch: branch?.startsWith(BRANCH_PREFIX) === true });
381
+ await worktrees.remove(worktree, { deleteBranch: branch?.startsWith(BRANCH_PREFIX) === true, repo: clone });
366
382
  return { removed: worktree };
367
383
  }
368
384
  catch (error) {
@@ -452,7 +468,7 @@ export function sessionManager(deps) {
452
468
  const text = eventText(resolved);
453
469
  const started = now().getTime();
454
470
  const { copy } = await claude.resume({
455
- cwd: session?.cwd ?? worker.worktree ?? rootOf(worker),
471
+ cwd: session?.cwd ?? worker.worktree ?? cloneOf(worker),
456
472
  sessionId: worker.sessionId,
457
473
  text,
458
474
  });
@@ -501,7 +517,7 @@ export function sessionManager(deps) {
501
517
  let copy;
502
518
  try {
503
519
  ({ copy } = await claude.resume({
504
- cwd: session?.cwd ?? worker.worktree ?? rootOf(worker),
520
+ cwd: session?.cwd ?? worker.worktree ?? cloneOf(worker),
505
521
  sessionId: worker.sessionId,
506
522
  text: COMPACT_TEXT,
507
523
  }));
@@ -578,7 +594,7 @@ export function sessionManager(deps) {
578
594
  });
579
595
  }
580
596
  }
581
- // An attached worker is Jason's while he has it open. One whose Handoff is open waits on him,
597
+ // An attached worker is the operator's while they have it open. One whose Handoff is open waits on them,
582
598
  // though no wait says so: its note raised the inbox item already (ATK-416).
583
599
  if (!turnEnded(session) || waiting || worker.attachedAt !== null || handedOff(worker)) {
584
600
  cleared('idle', state.idle?.reported);
@@ -594,13 +610,13 @@ export function sessionManager(deps) {
594
610
  }
595
611
  stalls.set(worker.id, state);
596
612
  };
597
- // The daemon can't see the tab, so attached means "Jason may be in it" only while the session is
613
+ // The daemon can't see the tab, so attached means "the operator may be in it" only while the session is
598
614
  // in use. Once its turn has been over `idleMinutes`, the worker is the daemon's again, and its
599
615
  // waits park and resume it as usual. A resolved wait takes it back at once when the transcript
600
616
  // shows no turn since the attach: nobody is in it, and the park and resume deliver the wait
601
617
  // (ATK-516). A turn, or no transcript to tell, holds it, and the hold is logged once.
602
618
  // Only a wait that resolved after the attach releases it: one resolved before was there when
603
- // Jason chose to attach, so taking the session back would undo the attach.
619
+ // The operator chose to attach, so taking the session back would undo the attach.
604
620
  // A new attach, or the end of one, starts its spell afresh.
605
621
  const forgetAttach = (workerId) => {
606
622
  const state = stalls.get(workerId);
@@ -647,14 +663,29 @@ export function sessionManager(deps) {
647
663
  let current = worker;
648
664
  const session = sessionOf(current);
649
665
  // The listing is the truth for ids: fill them in after a launch, and follow a changed short id.
650
- if (session && (current.sessionId !== session.sessionId || current.handle !== session.id)) {
651
- // A new session id (a /clear) writes a new transcript.
652
- if (current.sessionId !== session.sessionId) {
666
+ // A changed session id is followed once it has stayed listed SESSION_ID_SETTLE_MS.
667
+ if (session) {
668
+ let sessionId = current.sessionId;
669
+ const pending = newSessionIds.get(current.id);
670
+ if (sessionId === null || session.sessionId === sessionId)
671
+ newSessionIds.delete(current.id);
672
+ else if (pending?.sessionId !== session.sessionId) {
673
+ newSessionIds.set(current.id, { sessionId: session.sessionId, since: now().getTime() });
674
+ }
675
+ else if (now().getTime() - pending.since >= SESSION_ID_SETTLE_MS) {
676
+ newSessionIds.delete(current.id);
677
+ // A new session id (a /clear) writes a new transcript.
653
678
  transcripts.delete(current.id);
654
679
  transcriptScans.delete(current.id);
680
+ sessionId = session.sessionId;
681
+ }
682
+ sessionId ??= session.sessionId;
683
+ if (sessionId !== current.sessionId || session.id !== current.handle) {
684
+ current = update(current, { handle: session.id, sessionId });
655
685
  }
656
- current = update(current, { handle: session.id, sessionId: session.sessionId });
657
686
  }
687
+ else
688
+ newSessionIds.delete(current.id);
658
689
  const waits = store.waitsOf(current.id);
659
690
  const resolved = waits.filter((w) => w.resolvedAt !== null);
660
691
  // A listed pid can outlive its process for a moment after a stop.
@@ -702,6 +733,10 @@ export function sessionManager(deps) {
702
733
  }
703
734
  if (current.status !== 'parked')
704
735
  return;
736
+ // A park needs a wait, so a parked worker with none was stopped by a retire that didn't finish
737
+ // (ATK-519): the retire, tried again, takes it on.
738
+ if (waits.length === 0)
739
+ return;
705
740
  if (resolved.length > 0)
706
741
  await resume(current, resolved, session);
707
742
  else
@@ -842,8 +877,8 @@ export function sessionManager(deps) {
842
877
  });
843
878
  continue;
844
879
  }
845
- const root = rootOf(worker);
846
- const worktree = worker.worktree ?? worktreePath(root, worker.name);
880
+ const root = cloneOf(worker);
881
+ const worktree = worker.worktree ?? join(projectPaths(deps.paths, worker.project).worktrees, worker.name);
847
882
  const reused = store
848
883
  .workerEvents(worker.id, 1000)
849
884
  .some((e) => e.kind === 'worker.launching' && e.data.reused === true);
@@ -882,7 +917,7 @@ export function sessionManager(deps) {
882
917
  const problem = uncheckable(parsed, project?.profile ?? null);
883
918
  if (problem) {
884
919
  throw new ConductorError(problem, [
885
- `the conductor takes ${worker.project}'s profile from ${project?.profilePath ?? 'its .claude/conductor.json'} when it starts or the file is saved; \`conduct rules\` lists one it refused`,
920
+ `the conductor takes ${worker.project}'s profile again when it starts, when ${project?.profilePath ?? 'its machine profile'} is saved, and after each fetch of its clone; \`conduct profile ${worker.project}\` shows it, and \`conduct rules\` lists one it refused`,
886
921
  ]);
887
922
  }
888
923
  // Stored in its normal full form, so the watchers and `conduct status` read one spelling. A
@@ -924,7 +959,7 @@ export function sessionManager(deps) {
924
959
  // No event text: the session wakes idle and keeps running when the tab closes.
925
960
  started = now().getTime();
926
961
  const { copy } = await claude.resume({
927
- cwd: sessionOf(current)?.cwd ?? current.worktree ?? rootOf(current),
962
+ cwd: sessionOf(current)?.cwd ?? current.worktree ?? cloneOf(current),
928
963
  sessionId,
929
964
  text: null,
930
965
  });
@@ -936,16 +971,12 @@ export function sessionManager(deps) {
936
971
  }
937
972
  current = update(current, { attachedAt: now().toISOString(), status: 'running', stoppedAt: null });
938
973
  forgetAttach(current.id);
939
- const tab = await openTab({
940
- cwd: current.worktree ?? rootOf(current),
941
- handle,
942
- title: current.name,
943
- window: `${current.probe ? 'probe' : 'Conductor'} · ${current.project}`,
944
- });
974
+ const window = `${current.probe ? 'probe' : 'Conductor'} · ${current.project}`;
975
+ const tab = await tabs.open({ cwd: current.worktree ?? cloneOf(current), handle, title: current.name, window });
945
976
  event(current, 'worker.attached', { resumed, ...(tab ? { tabError: tab } : {}) });
946
977
  if (started !== null)
947
978
  expectPlugin(current, started);
948
- return { resumed, tab, worker: current };
979
+ return { hint: tab === null ? tabs.hint(window) : null, resumed, tab, worker: current };
949
980
  }
950
981
  finally {
951
982
  busy.delete(worker.id);
@@ -974,19 +1005,22 @@ export function sessionManager(deps) {
974
1005
  project: target.name,
975
1006
  });
976
1007
  // A worktree there already isn't the launch's to remove if it fails, after a restart too.
977
- const reused = existsSync(join(worktreePath(target.root, name), '.git'));
1008
+ const reused = existsSync(join(projectPaths(deps.paths, target.name).worktrees, name, '.git'));
978
1009
  event(worker, 'worker.launching', {
979
1010
  probe: worker.probe,
980
1011
  ...(reused ? { reused } : {}),
981
1012
  ...(request.cause === undefined ? {} : { cause: request.cause }),
982
1013
  });
983
1014
  busy.add(worker.id);
984
- void track(launchWorker(worker, target.root, target.profile, prompt).finally(() => busy.delete(worker.id)));
1015
+ void track(launchWorker(worker, target.profile, prompt).finally(() => busy.delete(worker.id)));
985
1016
  return worker;
986
1017
  },
987
1018
  pluginSeen: (sessionId) => {
988
1019
  pluginReports.set(sessionId, now().getTime());
989
- return store.listWorkers(ACTIVE_STATUSES).find((w) => w.sessionId === sessionId) ?? null;
1020
+ // A /clear's session reports before the worker follows its new id.
1021
+ return (store
1022
+ .listWorkers(ACTIVE_STATUSES)
1023
+ .find((w) => w.sessionId === sessionId || newSessionIds.get(w.id)?.sessionId === sessionId) ?? null);
990
1024
  },
991
1025
  retire: async (workerId, { cause, force = false } = {}) => {
992
1026
  const worker = activeWorker(workerId);
@@ -1000,8 +1034,6 @@ export function sessionManager(deps) {
1000
1034
  }
1001
1035
  busy.add(worker.id);
1002
1036
  try {
1003
- // Fresh: the last sweep's listing can be ten seconds old.
1004
- const session = (await claude.agents().catch(() => sessions)).find((s) => s.sessionId === worker.sessionId || s.id === worker.handle);
1005
1037
  const refuse = (reason, transient, work) => {
1006
1038
  event(worker, 'worker.retire-refused', {
1007
1039
  reason,
@@ -1011,6 +1043,27 @@ export function sessionManager(deps) {
1011
1043
  });
1012
1044
  return { outcome: 'refused', reason, transient, ...(work ? { work } : {}), worker };
1013
1045
  };
1046
+ // The operator has its session open in a tab (ATK-519). The scheduler tries again every tick,
1047
+ // so it is logged once per attach. Only a running worker is attached: one whose session
1048
+ // stopped unexpectedly keeps the attach it had.
1049
+ const attachedAt = worker.status === 'running' ? worker.attachedAt : null;
1050
+ if (attachedAt !== null && !force) {
1051
+ const reason = 'attached: retire once its tab is released, or retire --force';
1052
+ if (attachRefusals.get(worker.id) !== attachedAt) {
1053
+ attachRefusals.set(worker.id, attachedAt);
1054
+ event(worker, 'worker.retire-refused', {
1055
+ attached: true,
1056
+ reason,
1057
+ transient: true,
1058
+ ...(cause === undefined ? {} : { cause }),
1059
+ });
1060
+ }
1061
+ return { attached: true, outcome: 'refused', reason, transient: true, worker };
1062
+ }
1063
+ attachRefusals.delete(worker.id);
1064
+ // Fresh: the last sweep's listing can be ten seconds old. Null when it couldn't be read.
1065
+ const listing = await claude.agents().catch(() => null);
1066
+ const session = (listing ?? sessions).find((s) => s.sessionId === worker.sessionId || s.id === worker.handle);
1014
1067
  // Removing the worktree while `claude --bg` still sets the session up there strands it.
1015
1068
  if (session && isStarting(session))
1016
1069
  return refuse('its session is still starting: try again shortly', true);
@@ -1027,7 +1080,6 @@ export function sessionManager(deps) {
1027
1080
  const work = fate.remove && worker.worktree ? await worktrees.unfinishedWork(worker.worktree) : null;
1028
1081
  if (work && !force)
1029
1082
  return refuse('unfinished work: commit and push it, or retire --force to throw it away', false, work);
1030
- const root = store.listProjects().find((p) => p.name === worker.project)?.root;
1031
1083
  const steps = [];
1032
1084
  const handle = worker.handle;
1033
1085
  if (handle !== null && session?.pid !== undefined) {
@@ -1049,14 +1101,17 @@ export function sessionManager(deps) {
1049
1101
  if (worker.worktree !== null) {
1050
1102
  const worktree = worker.worktree;
1051
1103
  if (fate.remove) {
1052
- await step(steps, 'remove worktree', async () => {
1053
- if (!root)
1054
- throw new Error(`project ${worker.project} is not registered`);
1055
- await worktrees.remove(root, worktree, { deleteBranch: true });
1056
- });
1104
+ // The repository is the worktree's own: one launched before clones is in a person's
1105
+ // checkout, and retires from there.
1106
+ await step(steps, 'remove worktree', () => worktrees.remove(worktree, { deleteBranch: true, repo: cloneOf(worker) }));
1057
1107
  }
1058
1108
  else if (existsSync(worktree)) {
1059
- kept = { branch: await worktrees.branchOf(worktree), reason: fate.reason, worktree };
1109
+ kept = {
1110
+ branch: await worktrees.branchOf(worktree),
1111
+ reason: fate.reason,
1112
+ repo: await worktrees.repoOf(worktree),
1113
+ worktree,
1114
+ };
1060
1115
  }
1061
1116
  // No session serves on them any more, kept worktree or not.
1062
1117
  freePorts(store, worktree, 'worker retired');
@@ -1070,11 +1125,21 @@ export function sessionManager(deps) {
1070
1125
  if (handle !== null)
1071
1126
  await step(steps, 'rm', () => claude.rm(handle));
1072
1127
  const ok = steps.every((s) => s.ok);
1073
- const current = ok ? update(worker, { status: 'exited', stoppedAt: now().toISOString() }) : worker;
1074
- if (ok)
1128
+ // Its session no longer runs once the stop succeeded, or when a fresh listing showed it wasn't
1129
+ // live, even when a later step failed. A retire that didn't finish then parks the worker with no
1130
+ // wait, so no sweep reads it as stopped unexpectedly or wakes it, and the retire is tried again
1131
+ // (ATK-519). With no fresh listing to say, the worker stays as it was.
1132
+ const down = steps.some((s) => s.step === 'stop' && s.ok) || (listing !== null && !(session && isLive(session)));
1133
+ const stoppedAt = now().toISOString();
1134
+ const parked = !ok && down;
1135
+ const current = ok
1136
+ ? update(worker, { status: 'exited', stoppedAt })
1137
+ : parked
1138
+ ? update(worker, { attachedAt: null, status: 'parked', stoppedAt })
1139
+ : worker;
1140
+ if (ok || parked)
1075
1141
  store.closeWaits(worker.id, 'worker retired');
1076
- // Its session is stopped by now, even when a later step failed. A probe shares its item with
1077
- // the live worker, whose locks those are.
1142
+ // A probe shares its item with the live worker, whose locks those are.
1078
1143
  if (!worker.probe)
1079
1144
  releaseLocks(store, worker.item, 'worker retired');
1080
1145
  stalls.delete(worker.id);
@@ -1082,6 +1147,7 @@ export function sessionManager(deps) {
1082
1147
  forced: force && work !== null,
1083
1148
  steps,
1084
1149
  ...(kept === null ? {} : { kept }),
1150
+ ...(parked ? { parked } : {}),
1085
1151
  ...(cause === undefined ? {} : { cause }),
1086
1152
  });
1087
1153
  return { outcome: ok ? 'retired' : 'partial', steps, worker: current };
@@ -1102,7 +1168,7 @@ export function sessionManager(deps) {
1102
1168
  try {
1103
1169
  const started = now().getTime();
1104
1170
  const { copy } = await claude.resume({
1105
- cwd: worker.worktree ?? rootOf(worker),
1171
+ cwd: worker.worktree ?? cloneOf(worker),
1106
1172
  sessionId: worker.sessionId,
1107
1173
  text,
1108
1174
  });