@detergent-software/conductor 2.2.0-dev.3 → 3.0.0-dev.10

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 (97) hide show
  1. package/CHANGELOG.md +98 -0
  2. package/README.md +86 -37
  3. package/build/broker-cli.js +19 -21
  4. package/build/broker-cli.js.map +1 -1
  5. package/build/broker.js +66 -92
  6. package/build/broker.js.map +1 -1
  7. package/build/checkout-sync.js +4 -21
  8. package/build/checkout-sync.js.map +1 -1
  9. package/build/claude.js +56 -9
  10. package/build/claude.js.map +1 -1
  11. package/build/cli.js +43 -46
  12. package/build/cli.js.map +1 -1
  13. package/build/code-host.js +10 -8
  14. package/build/code-host.js.map +1 -1
  15. package/build/config.js +10 -0
  16. package/build/config.js.map +1 -1
  17. package/build/console.js +39 -29
  18. package/build/console.js.map +1 -1
  19. package/build/daemon.js +55 -6
  20. package/build/daemon.js.map +1 -1
  21. package/build/help.js +19 -33
  22. package/build/help.js.map +1 -1
  23. package/build/in-process.js +3 -3
  24. package/build/in-process.js.map +1 -1
  25. package/build/inbox.js +47 -4
  26. package/build/inbox.js.map +1 -1
  27. package/build/lib/providers/index.js +1 -1
  28. package/build/lib/providers/index.js.map +1 -1
  29. package/build/lib/providers/npm/stage.js +14 -4
  30. package/build/lib/providers/npm/stage.js.map +1 -1
  31. package/build/local-files.js +95 -0
  32. package/build/local-files.js.map +1 -0
  33. package/build/manager.js +215 -27
  34. package/build/manager.js.map +1 -1
  35. package/build/npm-reads.js +194 -0
  36. package/build/npm-reads.js.map +1 -0
  37. package/build/paths.js +2 -0
  38. package/build/paths.js.map +1 -1
  39. package/build/plugin.js +146 -0
  40. package/build/plugin.js.map +1 -0
  41. package/build/profile.js +60 -21
  42. package/build/profile.js.map +1 -1
  43. package/build/project-check-cli.js +4 -1
  44. package/build/project-check-cli.js.map +1 -1
  45. package/build/project-check.js +89 -30
  46. package/build/project-check.js.map +1 -1
  47. package/build/rules-service.js +22 -4
  48. package/build/rules-service.js.map +1 -1
  49. package/build/scheduler.js +11 -1
  50. package/build/scheduler.js.map +1 -1
  51. package/build/server.js +8 -16
  52. package/build/server.js.map +1 -1
  53. package/build/spend.js +4 -0
  54. package/build/spend.js.map +1 -1
  55. package/build/store.js +11 -10
  56. package/build/store.js.map +1 -1
  57. package/build/tracker-reads-cli.js +2 -4
  58. package/build/tracker-reads-cli.js.map +1 -1
  59. package/build/upgrade.js +25 -3
  60. package/build/upgrade.js.map +1 -1
  61. package/build/watchers/ado.js +3 -2
  62. package/build/watchers/ado.js.map +1 -1
  63. package/build/watchers/github.js +3 -2
  64. package/build/watchers/github.js.map +1 -1
  65. package/build/watchers/npm.js +6 -4
  66. package/build/watchers/npm.js.map +1 -1
  67. package/build/worktrees.js +182 -38
  68. package/build/worktrees.js.map +1 -1
  69. package/package.json +9 -9
  70. package/plugin/.claude-plugin/plugin.json +7 -0
  71. package/plugin/README.md +18 -0
  72. package/{mod → plugin}/hooks/model.ts +22 -4
  73. package/{mod → plugin}/hooks/register.tsx +63 -17
  74. package/plugin/skills/conductor-console/SKILL.md +94 -0
  75. package/plugin/skills/create-verification-skill/LICENSE +21 -0
  76. package/plugin/skills/create-verification-skill/README.md +9 -0
  77. package/plugin/skills/create-verification-skill/SKILL.md +44 -0
  78. package/plugin/skills/create-verification-skill/manifest.json +27 -0
  79. package/plugin/skills/create-verification-skill/references/feature-map-example/README.md +47 -0
  80. package/plugin/skills/create-verification-skill/references/feature-map-example/create-note.md +39 -0
  81. package/plugin/skills/create-verification-skill/references/feature-map-example/search.md +45 -0
  82. package/plugin/skills/maintain-verification-skill/LICENSE +21 -0
  83. package/plugin/skills/maintain-verification-skill/README.md +9 -0
  84. package/plugin/skills/maintain-verification-skill/SKILL.md +39 -0
  85. package/plugin/skills/maintain-verification-skill/manifest.json +23 -0
  86. package/plugin/skills/work-item/SKILL.md +253 -0
  87. package/plugin/skills/work-item/trackers/ado-boards.md +23 -0
  88. package/plugin/skills/work-item/trackers/github-issues.md +23 -0
  89. package/plugin/skills/work-item/trackers/linear.md +21 -0
  90. package/protocol/PROTOCOL.md +1 -1
  91. package/build/mod.js +0 -53
  92. package/build/mod.js.map +0 -1
  93. package/mod/.claude-plugin/marketplace.json +0 -6
  94. package/mod/.claude-plugin/plugin.json +0 -7
  95. package/mod/README.md +0 -19
  96. /package/{mod → plugin}/hooks/hooks.json +0 -0
  97. /package/{mod → plugin}/types/index.d.ts +0 -0
@@ -0,0 +1,95 @@
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync } from 'node:fs';
2
+ import { cp } from 'node:fs/promises';
3
+ import { dirname, join, matchesGlob, relative } from 'node:path';
4
+ import { ConductorError } from "./errors.js";
5
+ import { outputOf } from "./run.js";
6
+ export const INCLUDE_FILE = '.worktreeinclude';
7
+ // Copies the source checkout's included files into `target`; returns their paths, relative and with
8
+ // forward slashes. Git lists ignored paths with whole ignored directories collapsed, which keeps
9
+ // node_modules out of the walk. A pattern without a slash (".env*") matches at any depth but not
10
+ // inside an ignored directory; an anchored one ("apps/api/secrets/*.json") also reaches into one.
11
+ export async function copyLocalFiles(git, source, target) {
12
+ if (!existsSync(join(source, '.git')))
13
+ throw new ConductorError(`localFilesFrom ${source} is not a git checkout`, [
14
+ "set the profile's localFilesFrom to a checkout of the project you keep",
15
+ ]);
16
+ const patterns = includePatterns(join(source, INCLUDE_FILE));
17
+ if (patterns.length === 0)
18
+ return [];
19
+ const listed = await git(['ls-files', '--others', '--ignored', '--exclude-standard', '--directory', '-z'], source);
20
+ if (listed.code !== 0)
21
+ throw new ConductorError(`git couldn't list the ignored files in ${source}`, [outputOf(listed, 500)]);
22
+ const copied = [];
23
+ // Asynchronous, so an included directory of any size doesn't hold up the daemon.
24
+ const copy = async (rel) => {
25
+ const to = join(target, rel);
26
+ mkdirSync(dirname(to), { recursive: true });
27
+ await cp(join(source, rel), to, { recursive: true });
28
+ copied.push(rel);
29
+ };
30
+ // Git can list a directory and also entries inside it (apps/, apps/api/, ...), parents first, so
31
+ // anything under a directory already copied or walked is skipped.
32
+ const handled = ['.claude/worktrees'];
33
+ for (const entry of listed.stdout.split('\0').filter(Boolean)) {
34
+ const isDir = entry.endsWith('/');
35
+ const rel = isDir ? entry.slice(0, -1) : entry;
36
+ if (handled.some((dir) => rel === dir || rel.startsWith(`${dir}/`)))
37
+ continue;
38
+ if (isIncluded(patterns, rel, isDir))
39
+ await copy(rel);
40
+ else if (isDir)
41
+ for (const file of walkAnchored(patterns, source, rel))
42
+ await copy(file);
43
+ if (isDir)
44
+ handled.push(rel);
45
+ }
46
+ return copied;
47
+ }
48
+ export function includePatterns(file) {
49
+ if (!existsSync(file))
50
+ return [];
51
+ return readFileSync(file, 'utf8')
52
+ .split(/\r?\n/)
53
+ .map((line) => line.trim())
54
+ .filter((line) => line && !line.startsWith('#'))
55
+ .map((line) => {
56
+ const negated = line.startsWith('!');
57
+ let pattern = negated ? line.slice(1) : line;
58
+ const dirOnly = pattern.endsWith('/');
59
+ if (dirOnly)
60
+ pattern = pattern.slice(0, -1);
61
+ const anchored = pattern.includes('/');
62
+ if (pattern.startsWith('/'))
63
+ pattern = pattern.slice(1);
64
+ return { anchored, dirOnly, glob: anchored ? pattern : `**/${pattern}`, negated };
65
+ });
66
+ }
67
+ export function isIncluded(patterns, rel, isDir) {
68
+ const parts = rel.split('/');
69
+ for (let i = 1; i < parts.length; i++)
70
+ if (matchesLast(patterns, parts.slice(0, i).join('/'), true))
71
+ return true;
72
+ return matchesLast(patterns, rel, isDir);
73
+ }
74
+ function matchesLast(patterns, rel, isDir) {
75
+ let result = false;
76
+ for (const p of patterns) {
77
+ if (p.dirOnly && !isDir)
78
+ continue;
79
+ if (matchesGlob(rel, p.glob))
80
+ result = !p.negated;
81
+ }
82
+ return result;
83
+ }
84
+ function* walkAnchored(patterns, source, dir) {
85
+ if (!patterns.some((p) => p.anchored && !p.negated && p.glob.startsWith(`${dir}/`)))
86
+ return;
87
+ for (const entry of readdirSync(join(source, dir), { recursive: true, withFileTypes: true })) {
88
+ if (!entry.isFile())
89
+ continue;
90
+ const rel = relative(source, join(entry.parentPath, entry.name)).split('\\').join('/');
91
+ if (isIncluded(patterns, rel, false))
92
+ yield rel;
93
+ }
94
+ }
95
+ //# sourceMappingURL=local-files.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"local-files.js","sourceRoot":"","sources":["../src/local-files.ts"],"names":[],"mappings":"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"}
package/build/manager.js CHANGED
@@ -1,17 +1,20 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { z } from 'zod';
4
- import { freePorts, releaseLocks } from "./broker.js";
5
- import { isLive, isStarting, permissionPrompt, SessionGone, turnEnded, } from "./claude.js";
4
+ import { freePorts, portEnv, releaseLocks } from "./broker.js";
5
+ import { isLive, isStarting, permissionPrompt, SessionGone, trustStep, turnEnded, WorkspaceUntrusted, } from "./claude.js";
6
6
  import { formatCondition, parseCondition, prRef, uncheckable } from "./conditions.js";
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 { missingPluginSkills, projectSkills } from "./plugin.js";
10
11
  import { WAKE_PREFIX } from "./protocol.js";
11
12
  import { resolveOpenWait } from "./resolve.js";
12
13
  import { findTranscript, formatTokens, lastTurnAt, localDay, projectSlug, readUsage, transcriptFiles, } from "./spend.js";
13
14
  import { ACTIVE_STATUSES, openStore, waitSchema, workerSchema, } from "./store.js";
15
+ import { linkedPr } from "./tracker-writes.js";
14
16
  import { pendingCall } from "./transcript.js";
17
+ import { BRANCH_PREFIX, worktreePath } from "./worktrees.js";
15
18
  // A worker as GET /fleet shows it.
16
19
  export const fleetWorkerSchema = workerSchema.extend({
17
20
  // launching, starting, working, idle, waiting on <x>, parked, stopped, no session
@@ -85,11 +88,17 @@ export const PROBE_PREFIX = 'probe-';
85
88
  export const PROBE_PROMPT = 'This is a conductor probe session started by a test run. It works no issue: reply "probe ready" and wait.';
86
89
  // conduct-atk starts workers this way; a resume keeps both (ATK-309's open check).
87
90
  export const PERMISSION_MODE = 'auto';
91
+ // How long a started or woken worker's session has to report its plugin. A start runs the create
92
+ // hook first only for a launch, which is before the clock starts.
93
+ export const PLUGIN_REPORT_MS = 3 * 60_000;
88
94
  export const DISALLOWED_TOOLS = ['EnterPlanMode', 'ExitPlanMode'];
89
95
  const ITEM = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
90
96
  const STOP_WAIT_MS = 30_000;
91
97
  // A park that failed (the process outlived `claude stop`) is tried again this much later.
92
98
  const PARK_RETRY_MS = 5 * 60_000;
99
+ // How long a new session id has to stay listed before the worker follows it. A resuming session is
100
+ // listed for under a second under the id its process started with, before it loads its own (ATK-574).
101
+ const SESSION_ID_SETTLE_MS = 5_000;
93
102
  const TRANSCRIPT_SCAN_MS = 60_000;
94
103
  // A compaction with no boundary in the transcript this long after it started, its turn over, failed.
95
104
  const COMPACT_MINUTES = 10;
@@ -112,7 +121,7 @@ export function eventText(waits) {
112
121
  return `${WAKE_PREFIX}${[...facts].map(([fact, at]) => `${fact} ${clock(new Date(at))}`).join('; ')}`;
113
122
  }
114
123
  export function sessionManager(deps) {
115
- const { claude, config, log, openTab, store, worktrees } = deps;
124
+ const { broker, claude, config, log, openTab, pluginDir, prs, store, worktrees } = deps;
116
125
  const now = deps.now ?? (() => new Date());
117
126
  const alive = deps.processAlive ?? defaultProcessAlive;
118
127
  const sleep = deps.sleep ?? ((ms) => new Promise((done) => setTimeout(done, ms)));
@@ -122,6 +131,8 @@ export function sessionManager(deps) {
122
131
  const stalls = new Map();
123
132
  const transcripts = new Map();
124
133
  const transcriptScans = new Map();
134
+ // worker id → a session id the listing shows for it that it hasn't followed yet, since when
135
+ const newSessionIds = new Map();
125
136
  let sessions = [];
126
137
  // Until the first sweep, nothing is known about any session.
127
138
  let listed = false;
@@ -132,6 +143,12 @@ export function sessionManager(deps) {
132
143
  const held = new Set();
133
144
  // Workers whose compaction failed: not tried again while the daemon runs (their inbox item says so).
134
145
  const uncompactable = new Set();
146
+ // Every session the conductor starts loads its plugin, and a resume whose saved --plugin-dir went
147
+ // missing runs on without it, silently (ATK-522). The mod reports each session start (pluginSeen),
148
+ // so a worker started or woken that doesn't report within PLUGIN_REPORT_MS is a
149
+ // `worker.plugin-missing`. In memory: a daemon restart forgets the starts it didn't see.
150
+ const pluginDue = new Map(); // worker id → when its session was last (re)started
151
+ const pluginReports = new Map(); // session id → its plugin's newest report
135
152
  const event = (worker, kind, data = {}, rule = null) => {
136
153
  const entry = {
137
154
  data: { name: worker.name, worker: worker.id, ...data },
@@ -142,6 +159,48 @@ export function sessionManager(deps) {
142
159
  };
143
160
  store.appendEvent(entry);
144
161
  };
162
+ // Its session was (re)started at `since`, before the claude call: its report must come after.
163
+ // Only a worker launched with the plugin keeps it in its saved options: one launched before it (by
164
+ // an older daemon, with workers in flight across an upgrade) never loads it and works on without.
165
+ const expectPlugin = (worker, since) => {
166
+ if (launchedWithPlugin(worker))
167
+ pluginDue.set(worker.id, since);
168
+ };
169
+ const withPlugin = new Map();
170
+ const launchedWithPlugin = (worker) => {
171
+ let known = withPlugin.get(worker.id);
172
+ if (known === undefined) {
173
+ known = store
174
+ .workerEvents(worker.id, 1000)
175
+ .some((e) => e.kind === 'worker.launched' && typeof e.data.pluginDir === 'string');
176
+ withPlugin.set(worker.id, known);
177
+ }
178
+ return known;
179
+ };
180
+ // A worker whose session hasn't reported its plugin within PLUGIN_REPORT_MS of its start, once per
181
+ // start. One with no session id yet waits for it: reports are matched by session.
182
+ const checkPlugins = () => {
183
+ const at = now().getTime();
184
+ for (const [id, since] of pluginDue) {
185
+ const worker = store.getWorker(id);
186
+ if (!worker || !ACTIVE_STATUSES.includes(worker.status)) {
187
+ pluginDue.delete(id);
188
+ continue;
189
+ }
190
+ if (worker.sessionId === null)
191
+ continue;
192
+ if ((pluginReports.get(worker.sessionId) ?? -1) >= since) {
193
+ pluginDue.delete(id);
194
+ }
195
+ else if (at - since >= PLUGIN_REPORT_MS) {
196
+ pluginDue.delete(id);
197
+ event(worker, 'worker.plugin-missing', { minutes: Math.round((at - since) / 60_000), plugin: pluginDir });
198
+ }
199
+ }
200
+ for (const [session, reported] of pluginReports)
201
+ if (at - reported > PLUGIN_REPORT_MS * 10)
202
+ pluginReports.delete(session);
203
+ };
145
204
  // Whether the rules let it park or resume now; a refusal is logged once until the rules allow it.
146
205
  const decide = (verb, worker, waits) => {
147
206
  const decision = policy(verb, worker, waits);
@@ -214,24 +273,56 @@ export function sessionManager(deps) {
214
273
  }
215
274
  };
216
275
  // ---------- launch ----------
276
+ // The worktree first (design note v2, section 2): its ports from the broker, then `git worktree add`
277
+ // from origin/<base>, the local files and the profile's setup. The session starts in it.
217
278
  const launchWorker = async (worker, root, profile, prompt) => {
218
279
  let current = worker;
219
- let stage = 'create worktree';
280
+ let stage = 'assign ports';
281
+ const path = worktreePath(root, worker.name);
282
+ // A worktree there already (a kept one, or one a person made with `claude --worktree`) isn't
283
+ // this launch's to remove when it fails.
284
+ const reused = existsSync(join(path, '.git'));
220
285
  try {
221
- const worktree = await worktrees.create(root, worker.name);
286
+ // Recorded before it exists: the broker keeps an in-flight worker's ports while its directory
287
+ // isn't there yet, and a create that fails part way is cleaned up.
288
+ current = update(current, { worktree: path });
289
+ const { ports } = await broker.assignPorts(path, worker.project, profile.ports);
290
+ stage = 'create worktree';
291
+ const env = portEnv(ports);
292
+ const worktree = await worktrees.create({
293
+ base: profile.baseBranch,
294
+ env: { ...env, CONDUCTOR_ITEM: worker.item, CONDUCTOR_PROJECT: worker.project, CONDUCTOR_WORKTREE: path },
295
+ ...(profile.localFilesFrom === undefined ? {} : { localFilesFrom: profile.localFilesFrom }),
296
+ name: worker.name,
297
+ root,
298
+ sessionEnv: env,
299
+ setup: profile.setup,
300
+ });
222
301
  current = update(current, { branch: await worktrees.branchOf(worktree), worktree });
223
302
  stage = 'check skills';
224
- const problem = await worktrees.checkSkills(worktree, [profile.skills.work, profile.skills.verify]);
303
+ const skills = [profile.skills.work, profile.skills.verify];
304
+ const problem = worktrees.missingSkills(worktree, projectSkills(skills)) ?? missingPluginSkills(pluginDir, skills);
225
305
  if (problem)
226
306
  throw new ConductorError(problem);
227
307
  stage = 'start session';
228
- const handle = await claude.launch({
229
- cwd: root,
308
+ const started = now().getTime();
309
+ const handle = await claude
310
+ .launch({
311
+ cwd: worktree,
230
312
  disallowedTools: DISALLOWED_TOOLS,
231
313
  name: worker.name,
232
314
  permissionMode: PERMISSION_MODE,
315
+ pluginDir,
233
316
  prompt,
234
- worktree: worker.name,
317
+ })
318
+ .catch((error) => {
319
+ // Claude Code's own message names the worktree, which this failed launch removes.
320
+ if (!(error instanceof WorkspaceUntrusted))
321
+ throw error;
322
+ throw new ConductorError(`Claude Code doesn't trust ${root}`, [
323
+ `${trustStep(root)}, then \`conduct project check ${worker.project}\``,
324
+ ...error.details,
325
+ ]);
235
326
  });
236
327
  try {
237
328
  sessions = await claude.agents();
@@ -242,10 +333,11 @@ export function sessionManager(deps) {
242
333
  }
243
334
  const session = sessions.find((s) => s.id === handle);
244
335
  current = update(current, { handle, sessionId: session?.sessionId ?? null, status: 'running' });
245
- event(current, 'worker.launched', { handle, sessionId: current.sessionId, worktree });
336
+ event(current, 'worker.launched', { handle, pluginDir, sessionId: current.sessionId, worktree });
337
+ expectPlugin(current, started);
246
338
  if (profile.workers.tab) {
247
339
  const tab = await openTab({
248
- cwd: root,
340
+ cwd: worktree,
249
341
  handle,
250
342
  title: worker.name,
251
343
  window: `${worker.probe ? 'probe' : 'Conductor'} · ${worker.project}`,
@@ -256,24 +348,62 @@ export function sessionManager(deps) {
256
348
  }
257
349
  catch (error) {
258
350
  // A worktree with no session would read as in flight; one with work in it is kept.
259
- const cleanup = current.worktree ? await removeIfClean(root, current.worktree) : {};
351
+ const cleanup = reused
352
+ ? { kept: path, keptBecause: 'it was there before the launch' }
353
+ : current.worktree && existsSync(current.worktree)
354
+ ? await removeIfClean(root, current.worktree)
355
+ : {};
356
+ freePorts(store, path, 'launch failed');
260
357
  current = update(current, { status: 'failed' });
261
358
  event(current, 'worker.launch-failed', { error: messageOf(error), step: stage, ...cleanup });
262
359
  log.log('warn', 'launch failed', { error: messageOf(error), step: stage, worker: worker.id });
263
360
  }
264
361
  };
362
+ // A failed launch's worktree, unless it holds work. Its branch goes with it while it still has the
363
+ // name the launch gave it; a renamed one was the worker's.
265
364
  const removeIfClean = async (root, worktree) => {
266
365
  try {
267
366
  const work = await worktrees.unfinishedWork(worktree);
268
367
  if (work)
269
368
  return { kept: worktree, ...work };
270
- await worktrees.remove(root, worktree);
369
+ const branch = await worktrees.branchOf(worktree);
370
+ await worktrees.remove(root, worktree, { deleteBranch: branch?.startsWith(BRANCH_PREFIX) === true });
271
371
  return { removed: worktree };
272
372
  }
273
373
  catch (error) {
274
374
  return { removeError: messageOf(error) };
275
375
  }
276
376
  };
377
+ // Whether retiring the worker removes its worktree and branch: once its PR has merged, and for a
378
+ // probe or a forced retire. `unread`: its PR couldn't be read, so retire waits.
379
+ const worktreeFate = async (worker, force) => {
380
+ const decided = (remove, reason) => ({ reason, remove, unread: null });
381
+ if (worker.worktree === null || !existsSync(worker.worktree))
382
+ return decided(true, 'no worktree');
383
+ if (worker.probe)
384
+ return decided(true, 'a probe');
385
+ if (force)
386
+ return decided(true, 'retire --force');
387
+ // With no PR and nothing uncommitted or unpushed, nothing in it is lost (a canceled item, a worker
388
+ // retired before its first commit): it goes, with no inbox item.
389
+ if (worker.pr === null)
390
+ return (await worktrees.unfinishedWork(worker.worktree)) === null
391
+ ? decided(true, 'no PR and nothing unpushed')
392
+ : decided(false, 'it has no PR');
393
+ const profile = store.listProjects().find((p) => p.name === worker.project)?.profile;
394
+ if (!profile)
395
+ return { reason: '', remove: false, unread: `project ${worker.project} has no readable profile` };
396
+ // No read would ever answer for it, so waiting would refuse the retire for good.
397
+ if (linkedPr(worker.pr, profile) === null)
398
+ return decided(false, `its PR ${worker.pr} isn't one ${worker.project}'s code host reads`);
399
+ try {
400
+ const state = await prs(profile, worker.pr);
401
+ return decided(state === 'merged', `its PR ${worker.pr} is ${state}`);
402
+ }
403
+ catch (error) {
404
+ return { reason: '', remove: false, unread: messageOf(error) };
405
+ }
406
+ };
277
407
  // ---------- the sweep ----------
278
408
  // The clock: the sweep is its timer. The other kinds are the watchers'.
279
409
  const resolveTimeWaits = () => {
@@ -325,6 +455,7 @@ export function sessionManager(deps) {
325
455
  if (!decision.allowed)
326
456
  return;
327
457
  const text = eventText(resolved);
458
+ const started = now().getTime();
328
459
  const { copy } = await claude.resume({
329
460
  cwd: session?.cwd ?? worker.worktree ?? rootOf(worker),
330
461
  sessionId: worker.sessionId,
@@ -343,6 +474,7 @@ export function sessionManager(deps) {
343
474
  store.deliverWaits(resolved.map((w) => w.id));
344
475
  const running = update(worker, { attachedAt: null, status: 'running', stoppedAt: null });
345
476
  event(running, 'worker.resumed', { text, waits: resolved.map((w) => w.id) }, decision.rule);
477
+ expectPlugin(running, started);
346
478
  };
347
479
  // A resume of a session that still ran started a copy: the copy goes.
348
480
  const discardCopy = async (copy) => {
@@ -370,6 +502,7 @@ export function sessionManager(deps) {
370
502
  const before = worker.context;
371
503
  // Before the resume: a small context can be compacted before it returns.
372
504
  const since = now().toISOString();
505
+ const started = now().getTime();
373
506
  let copy;
374
507
  try {
375
508
  ({ copy } = await claude.resume({
@@ -394,6 +527,7 @@ export function sessionManager(deps) {
394
527
  const running = update(worker, { status: 'running', stoppedAt: null });
395
528
  stalls.set(worker.id, { compacting: { before, since } });
396
529
  event(running, 'worker.compacting', { tokens: before }, decision.rule);
530
+ expectPlugin(running, started);
397
531
  };
398
532
  const compactFailed = (worker, error, tokens, rule = null) => {
399
533
  uncompactable.add(worker.id);
@@ -518,14 +652,29 @@ export function sessionManager(deps) {
518
652
  let current = worker;
519
653
  const session = sessionOf(current);
520
654
  // The listing is the truth for ids: fill them in after a launch, and follow a changed short id.
521
- if (session && (current.sessionId !== session.sessionId || current.handle !== session.id)) {
522
- // A new session id (a /clear) writes a new transcript.
523
- if (current.sessionId !== session.sessionId) {
655
+ // A changed session id is followed once it has stayed listed SESSION_ID_SETTLE_MS.
656
+ if (session) {
657
+ let sessionId = current.sessionId;
658
+ const pending = newSessionIds.get(current.id);
659
+ if (sessionId === null || session.sessionId === sessionId)
660
+ newSessionIds.delete(current.id);
661
+ else if (pending?.sessionId !== session.sessionId) {
662
+ newSessionIds.set(current.id, { sessionId: session.sessionId, since: now().getTime() });
663
+ }
664
+ else if (now().getTime() - pending.since >= SESSION_ID_SETTLE_MS) {
665
+ newSessionIds.delete(current.id);
666
+ // A new session id (a /clear) writes a new transcript.
524
667
  transcripts.delete(current.id);
525
668
  transcriptScans.delete(current.id);
669
+ sessionId = session.sessionId;
670
+ }
671
+ sessionId ??= session.sessionId;
672
+ if (sessionId !== current.sessionId || session.id !== current.handle) {
673
+ current = update(current, { handle: session.id, sessionId });
526
674
  }
527
- current = update(current, { handle: session.id, sessionId: session.sessionId });
528
675
  }
676
+ else
677
+ newSessionIds.delete(current.id);
529
678
  const waits = store.waitsOf(current.id);
530
679
  const resolved = waits.filter((w) => w.resolvedAt !== null);
531
680
  // A listed pid can outlive its process for a moment after a stop.
@@ -691,6 +840,7 @@ export function sessionManager(deps) {
691
840
  busy.delete(id);
692
841
  }
693
842
  }
843
+ checkPlugins();
694
844
  }
695
845
  catch (error) {
696
846
  log.log('error', 'sweep failed', { error: messageOf(error) });
@@ -713,8 +863,16 @@ export function sessionManager(deps) {
713
863
  continue;
714
864
  }
715
865
  const root = rootOf(worker);
716
- const worktree = worker.worktree ?? join(root, '.claude', 'worktrees', worker.name);
717
- const cleanup = listing !== null && existsSync(worktree) ? await removeIfClean(root, worktree) : {};
866
+ const worktree = worker.worktree ?? worktreePath(root, worker.name);
867
+ const reused = store
868
+ .workerEvents(worker.id, 1000)
869
+ .some((e) => e.kind === 'worker.launching' && e.data.reused === true);
870
+ const cleanup = reused
871
+ ? { kept: worktree, keptBecause: 'it was there before the launch' }
872
+ : listing !== null && existsSync(worktree)
873
+ ? await removeIfClean(root, worktree)
874
+ : {};
875
+ freePorts(store, worktree, 'launch failed');
718
876
  store.closeWaits(worker.id, 'launch failed');
719
877
  event(update(worker, { status: 'failed' }), 'worker.launch-failed', {
720
878
  error: 'the daemon stopped during the launch',
@@ -776,6 +934,7 @@ export function sessionManager(deps) {
776
934
  try {
777
935
  let current = worker;
778
936
  const resumed = current.status !== 'running';
937
+ let started = null;
779
938
  if (resumed) {
780
939
  const settle = current.stoppedAt === null
781
940
  ? 0
@@ -783,6 +942,7 @@ export function sessionManager(deps) {
783
942
  if (settle > 0)
784
943
  await sleep(settle);
785
944
  // No event text: the session wakes idle and keeps running when the tab closes.
945
+ started = now().getTime();
786
946
  const { copy } = await claude.resume({
787
947
  cwd: sessionOf(current)?.cwd ?? current.worktree ?? rootOf(current),
788
948
  sessionId,
@@ -797,12 +957,14 @@ export function sessionManager(deps) {
797
957
  current = update(current, { attachedAt: now().toISOString(), status: 'running', stoppedAt: null });
798
958
  forgetAttach(current.id);
799
959
  const tab = await openTab({
800
- cwd: rootOf(current),
960
+ cwd: current.worktree ?? rootOf(current),
801
961
  handle,
802
962
  title: current.name,
803
963
  window: `${current.probe ? 'probe' : 'Conductor'} · ${current.project}`,
804
964
  });
805
965
  event(current, 'worker.attached', { resumed, ...(tab ? { tabError: tab } : {}) });
966
+ if (started !== null)
967
+ expectPlugin(current, started);
806
968
  return { resumed, tab, worker: current };
807
969
  }
808
970
  finally {
@@ -831,14 +993,24 @@ export function sessionManager(deps) {
831
993
  probe: request.probe === true,
832
994
  project: target.name,
833
995
  });
996
+ // A worktree there already isn't the launch's to remove if it fails, after a restart too.
997
+ const reused = existsSync(join(worktreePath(target.root, name), '.git'));
834
998
  event(worker, 'worker.launching', {
835
999
  probe: worker.probe,
1000
+ ...(reused ? { reused } : {}),
836
1001
  ...(request.cause === undefined ? {} : { cause: request.cause }),
837
1002
  });
838
1003
  busy.add(worker.id);
839
1004
  void track(launchWorker(worker, target.root, target.profile, prompt).finally(() => busy.delete(worker.id)));
840
1005
  return worker;
841
1006
  },
1007
+ pluginSeen: (sessionId) => {
1008
+ pluginReports.set(sessionId, now().getTime());
1009
+ // A /clear's session reports before the worker follows its new id.
1010
+ return (store
1011
+ .listWorkers(ACTIVE_STATUSES)
1012
+ .find((w) => w.sessionId === sessionId || newSessionIds.get(w.id)?.sessionId === sessionId) ?? null);
1013
+ },
842
1014
  retire: async (workerId, { cause, force = false } = {}) => {
843
1015
  const worker = activeWorker(workerId);
844
1016
  if (busy.has(worker.id) || worker.status === 'launching') {
@@ -869,7 +1041,13 @@ export function sessionManager(deps) {
869
1041
  if (session && isLive(session) && !turnEnded(session) && !force) {
870
1042
  return refuse('its turn is still running: retire once it ends, or retire --force', true);
871
1043
  }
872
- const work = worker.worktree ? await worktrees.unfinishedWork(worker.worktree) : null;
1044
+ // The worktree and its branch go once the PR has merged; a probe's and a forced retire's go
1045
+ // anyway. Otherwise both are kept, so nothing the PR didn't land is lost (design note v2,
1046
+ // section 2), and the inbox says where they are.
1047
+ const fate = await worktreeFate(worker, force);
1048
+ if (fate.unread)
1049
+ return refuse(`its PR couldn't be read: ${fate.unread}`, true);
1050
+ const work = fate.remove && worker.worktree ? await worktrees.unfinishedWork(worker.worktree) : null;
873
1051
  if (work && !force)
874
1052
  return refuse('unfinished work: commit and push it, or retire --force to throw it away', false, work);
875
1053
  const root = store.listProjects().find((p) => p.name === worker.project)?.root;
@@ -890,14 +1068,21 @@ export function sessionManager(deps) {
890
1068
  return { outcome: 'partial', steps, worker };
891
1069
  }
892
1070
  }
1071
+ let kept = null;
893
1072
  if (worker.worktree !== null) {
894
1073
  const worktree = worker.worktree;
895
- await step(steps, 'remove worktree', async () => {
896
- if (!root)
897
- throw new Error(`project ${worker.project} is not registered`);
898
- await worktrees.remove(root, worktree);
899
- freePorts(store, worktree, 'worker retired');
900
- });
1074
+ if (fate.remove) {
1075
+ await step(steps, 'remove worktree', async () => {
1076
+ if (!root)
1077
+ throw new Error(`project ${worker.project} is not registered`);
1078
+ await worktrees.remove(root, worktree, { deleteBranch: true });
1079
+ });
1080
+ }
1081
+ else if (existsSync(worktree)) {
1082
+ kept = { branch: await worktrees.branchOf(worktree), reason: fate.reason, worktree };
1083
+ }
1084
+ // No session serves on them any more, kept worktree or not.
1085
+ freePorts(store, worktree, 'worker retired');
901
1086
  }
902
1087
  try {
903
1088
  readSpend(worker); // the last of it, before the session goes
@@ -919,6 +1104,7 @@ export function sessionManager(deps) {
919
1104
  event(current, ok ? 'worker.retired' : 'worker.retire-partial', {
920
1105
  forced: force && work !== null,
921
1106
  steps,
1107
+ ...(kept === null ? {} : { kept }),
922
1108
  ...(cause === undefined ? {} : { cause }),
923
1109
  });
924
1110
  return { outcome: ok ? 'retired' : 'partial', steps, worker: current };
@@ -937,6 +1123,7 @@ export function sessionManager(deps) {
937
1123
  throw new ConductorError(`${worker.name} has no session`);
938
1124
  busy.add(worker.id);
939
1125
  try {
1126
+ const started = now().getTime();
940
1127
  const { copy } = await claude.resume({
941
1128
  cwd: worker.worktree ?? rootOf(worker),
942
1129
  sessionId: worker.sessionId,
@@ -949,6 +1136,7 @@ export function sessionManager(deps) {
949
1136
  }
950
1137
  const running = update(worker, { status: 'running', stoppedAt: null });
951
1138
  event(running, 'worker.revived', { text }, rule);
1139
+ expectPlugin(running, started);
952
1140
  return running;
953
1141
  }
954
1142
  finally {