@roodriigoooo/pi-docket 0.4.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 (43) hide show
  1. package/CHANGELOG.md +132 -0
  2. package/LICENSE +21 -0
  3. package/README.md +241 -0
  4. package/assets/docket_logo.jpeg +0 -0
  5. package/docs/adr/0001-bundle-first-checkpoints.md +21 -0
  6. package/docs/adr/0002-rename-to-docket.md +44 -0
  7. package/docs/architecture.md +101 -0
  8. package/docs/bundle-guidelines.md +39 -0
  9. package/docs/configuration.md +191 -0
  10. package/docs/releases/0.4.0.md +93 -0
  11. package/extensions/artifact-catalog.ts +467 -0
  12. package/extensions/background-work.ts +510 -0
  13. package/extensions/checkpoint-commands.ts +147 -0
  14. package/extensions/checkpoint-lifecycle.ts +195 -0
  15. package/extensions/checkpoint-selector.ts +162 -0
  16. package/extensions/checkpoint-store.ts +230 -0
  17. package/extensions/checkpoint-summarizer.ts +141 -0
  18. package/extensions/docket-command-grammar.ts +319 -0
  19. package/extensions/docket-command-router.ts +626 -0
  20. package/extensions/docket-config.ts +88 -0
  21. package/extensions/docket-extension-surface.ts +43 -0
  22. package/extensions/docket-navigator.ts +585 -0
  23. package/extensions/docket.README.md +46 -0
  24. package/extensions/docket.ts +2965 -0
  25. package/extensions/event-log.ts +121 -0
  26. package/extensions/git-context.ts +44 -0
  27. package/extensions/loaded-artifact-context.ts +228 -0
  28. package/extensions/search-index.ts +140 -0
  29. package/extensions/types.ts +40 -0
  30. package/extensions/worker-activity.ts +402 -0
  31. package/extensions/worker-changes.ts +180 -0
  32. package/extensions/worker-commands.ts +251 -0
  33. package/extensions/worker-dock-cache.ts +147 -0
  34. package/extensions/worker-events.ts +87 -0
  35. package/extensions/worker-eviction.ts +55 -0
  36. package/extensions/worker-guardrails.md +125 -0
  37. package/extensions/worker-kinds/patcher.md +23 -0
  38. package/extensions/worker-kinds/scout.md +17 -0
  39. package/extensions/worker-kinds.ts +280 -0
  40. package/extensions/worker-result.ts +193 -0
  41. package/extensions/worker-store.ts +621 -0
  42. package/extensions/worker-summary-embed.ts +98 -0
  43. package/package.json +53 -0
@@ -0,0 +1,621 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { randomBytes } from "node:crypto";
3
+ import fsSync from "node:fs";
4
+ import fs from "node:fs/promises";
5
+ import path from "node:path";
6
+ import { getAgentDir, SessionManager } from "@mariozechner/pi-coding-agent";
7
+ import { appendWorkerQuestionPatch, buildWorkerInitialPrompt as buildBackgroundWorkerInitialPrompt, workerInputAcceptedPatch, workerShortLabel, type WorkerQuestion, type WorkerStatus, type WorkerWorktree } from "./background-work.js";
8
+ import type { Artifact, GitSnapshot } from "./types.js";
9
+
10
+ export { workerShortLabel, workerSummaryName, type WorkerQuestion, type WorkerState, type WorkerStatus } from "./background-work.js";
11
+
12
+ export const WORKER_TMUX_PREFIX = "docket-worker-";
13
+ export const DOCKET_WORKER_ENV = "DOCKET_WORKER_ID";
14
+ export const WORKER_DASHBOARD_TMUX = "docket-workers";
15
+ /** Single tmux session that hosts every worker window. */
16
+ export const SHARED_TMUX_SESSION = "docket-workers";
17
+
18
+ export function workerWindowTarget(index: number): string {
19
+ return `${SHARED_TMUX_SESSION}:w${index}`;
20
+ }
21
+
22
+ export function isSharedSessionTarget(target: string | undefined): boolean {
23
+ return typeof target === "string" && target.startsWith(`${SHARED_TMUX_SESSION}:`);
24
+ }
25
+
26
+ export type WorkerStore = {
27
+ root(): string;
28
+ dirFor(id: string): string;
29
+ statusFile(id: string): string;
30
+ artifactsFile(id: string): string;
31
+ taskFile(id: string): string;
32
+ list(options?: { projectRoot?: string }): Promise<WorkerStatus[]>;
33
+ find(id: string): Promise<WorkerStatus | undefined>;
34
+ readArtifacts(id: string): Promise<Artifact[]>;
35
+ writeStatus(snapshot: WorkerStatus): Promise<void>;
36
+ patchStatus(id: string, patch: Partial<WorkerStatus>): Promise<WorkerStatus | undefined>;
37
+ writeArtifacts(id: string, artifacts: Artifact[]): Promise<void>;
38
+ addQuestion(id: string, text: string): Promise<WorkerStatus | undefined>;
39
+ sendInput(id: string, text: string): Promise<boolean>;
40
+ spawn(input: SpawnInput): Promise<WorkerStatus>;
41
+ kill(id: string): Promise<boolean>;
42
+ purge(id: string, options?: { cascade?: boolean }): Promise<string[]>;
43
+ countActive(): Promise<number>;
44
+ /** Re-launch a worker whose tmux window died. Reuses the worker dir + seeded session. */
45
+ respawn(id: string): Promise<WorkerStatus | undefined>;
46
+ };
47
+
48
+ export type SpawnInput = {
49
+ task: string;
50
+ cwd: string;
51
+ git?: GitSnapshot;
52
+ worktree?: boolean;
53
+ parentSession?: string;
54
+ extensionArgs?: string[];
55
+ idHint?: string;
56
+ /** Skip parent-session JSONL seeding. Worker starts with a blank session. */
57
+ fresh?: boolean;
58
+ /** Worker kind name; resolved by registry. */
59
+ kind?: string;
60
+ /** Kind-specific system-prompt body to append after universal guardrails. */
61
+ kindSystemPrompt?: string;
62
+ /** Child workers this worker is allowed to spawn (resolved kind names). */
63
+ canSpawn?: string[];
64
+ /** Parent worker id when this is a child spawn. */
65
+ parentWorkerId?: string;
66
+ /** Spawn depth. Top-level (parent assistant) = 0. */
67
+ depth?: number;
68
+ /** Optional tmux layout. */
69
+ layout?: "single" | "split-events";
70
+ /** When true, run tmux pipe-pane to capture terminal output to pane.log. */
71
+ captureTerminal?: boolean;
72
+ };
73
+
74
+ function workersRoot(): string {
75
+ return path.join(getAgentDir(), "docket", "workers");
76
+ }
77
+
78
+ function workerDir(id: string): string {
79
+ return path.join(workersRoot(), id);
80
+ }
81
+
82
+ function tmuxSafeId(value: string): string {
83
+ const safe = value.replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-+|-+$/g, "").toLowerCase() || "worker";
84
+ return safe.slice(0, 64);
85
+ }
86
+
87
+ function shellQuote(value: string): string {
88
+ return `'${value.replace(/'/g, `'"'"'`)}'`;
89
+ }
90
+
91
+ const WORKER_EXIT_PATCH_SCRIPT = [
92
+ `const fs = require("fs");`,
93
+ `const file = process.argv[1];`,
94
+ `const rawCode = process.argv[2] ?? "";`,
95
+ `let status;`,
96
+ `try { status = JSON.parse(fs.readFileSync(file, "utf8")); } catch { process.exit(0); }`,
97
+ `if (!status || ["needs_input", "ready", "failed", "error", "ended"].includes(status.state)) process.exit(0);`,
98
+ `const code = Number(rawCode);`,
99
+ `status.updatedAt = new Date().toISOString();`,
100
+ `if (code === 0) status.state = "ended";`,
101
+ `else { status.state = "failed"; const label = Number.isFinite(code) ? String(code) : rawCode; status.lastError = "worker process exited before reporting ready (exit " + label + ")"; }`,
102
+ `fs.writeFileSync(file, JSON.stringify(status, null, 2) + "\\n", "utf8");`,
103
+ ].join("");
104
+
105
+ export function currentPiCommandParts(argv: string[] = process.argv, execPath = process.execPath): string[] {
106
+ const script = argv[1];
107
+ if (script && path.isAbsolute(script) && (path.basename(script) === "pi" || script.includes("pi-coding-agent"))) return [execPath, script];
108
+ return ["pi"];
109
+ }
110
+
111
+ function workerExitPatchCommand(statusFile: string): string {
112
+ return `${shellQuote(process.execPath)} -e ${shellQuote(WORKER_EXIT_PATCH_SCRIPT)} ${shellQuote(statusFile)} "$code"`;
113
+ }
114
+
115
+ export function buildWorkerLaunchCommand(input: { id: string; sessionDir: string; statusFile: string; initialPrompt: string; extensionArgs?: string[]; piCommandParts?: string[]; resumeSeeded?: boolean }): string {
116
+ const piParts = [`${DOCKET_WORKER_ENV}=${shellQuote(input.id)}`, ...(input.piCommandParts ?? currentPiCommandParts()).map(shellQuote), "--session-dir", shellQuote(input.sessionDir)];
117
+ if (input.resumeSeeded) piParts.push("--continue");
118
+ for (const arg of input.extensionArgs ?? []) piParts.push(shellQuote(arg));
119
+ piParts.push(shellQuote(input.initialPrompt));
120
+ return `${piParts.join(" ")}; code=$?; ${workerExitPatchCommand(input.statusFile)}`;
121
+ }
122
+
123
+ function ensureTmux(): void {
124
+ const result = spawnSync("tmux", ["-V"], { encoding: "utf8" });
125
+ if (result.error || result.status !== 0) throw new Error("tmux not found. Install tmux and try again.");
126
+ }
127
+
128
+ function tmuxSessionExists(name: string): boolean {
129
+ return spawnSync("tmux", ["has-session", "-t", name], { stdio: "ignore" }).status === 0;
130
+ }
131
+
132
+ function killTmux(target: string, windowId?: string): boolean {
133
+ if (isSharedSessionTarget(target) || windowId) {
134
+ if (!tmuxSessionExists(SHARED_TMUX_SESSION)) return false;
135
+ // Prefer stable window id (e.g. "@7") when present so a renamed window still resolves.
136
+ const primary = windowId ? ["kill-window", "-t", windowId] : ["kill-window", "-t", target];
137
+ const result = spawnSync("tmux", primary, { stdio: "ignore" });
138
+ if (result.status === 0) return true;
139
+ if (windowId) {
140
+ const fallback = spawnSync("tmux", ["kill-window", "-t", target], { stdio: "ignore" });
141
+ return fallback.status === 0;
142
+ }
143
+ return false;
144
+ }
145
+ if (!tmuxSessionExists(target)) return false;
146
+ const result = spawnSync("tmux", ["kill-session", "-t", target], { stdio: "ignore" });
147
+ return result.status === 0;
148
+ }
149
+
150
+ function readWindowId(target: string): string | undefined {
151
+ const result = spawnSync("tmux", ["display-message", "-p", "-t", target, "#{window_id}"], { encoding: "utf8" });
152
+ if (result.error || result.status !== 0) return undefined;
153
+ const trimmed = result.stdout.trim();
154
+ return trimmed.startsWith("@") ? trimmed : undefined;
155
+ }
156
+
157
+ export function sharedSessionExists(): boolean {
158
+ return tmuxSessionExists(SHARED_TMUX_SESSION);
159
+ }
160
+
161
+ function gitOutput(cwd: string, args: string[], options: { input?: string; env?: NodeJS.ProcessEnv } = {}): string | undefined {
162
+ const result = spawnSync("git", args, { cwd, encoding: "utf8", input: options.input, env: options.env ? { ...process.env, ...options.env } : undefined, maxBuffer: 20 * 1024 * 1024 });
163
+ if (result.error || result.status !== 0) return undefined;
164
+ return result.stdout.trim() || undefined;
165
+ }
166
+
167
+ function realpathKey(value: string): string {
168
+ const resolved = path.resolve(value);
169
+ try {
170
+ return fsSync.realpathSync.native(resolved);
171
+ } catch {
172
+ try { return fsSync.realpathSync(resolved); } catch { return resolved; }
173
+ }
174
+ }
175
+
176
+ export function projectKey(cwd: string): string {
177
+ const root = gitOutput(cwd, ["rev-parse", "--show-toplevel"]);
178
+ return realpathKey(root ?? cwd);
179
+ }
180
+
181
+ export function workerProjectKey(worker: WorkerStatus): string {
182
+ return worker.projectRoot ? realpathKey(worker.projectRoot) : projectKey(worker.worktree?.baseRoot ?? worker.worktree?.parentCwd ?? worker.cwd);
183
+ }
184
+
185
+ export function workerInProject(worker: WorkerStatus, key: string): boolean {
186
+ return workerProjectKey(worker) === realpathKey(key);
187
+ }
188
+
189
+ function gitStatus(cwd: string, args: string[], options: { input?: string; env?: NodeJS.ProcessEnv } = {}): { status: number | null; stderr: string; error?: Error } {
190
+ const result = spawnSync("git", args, { cwd, encoding: "utf8", input: options.input, env: options.env ? { ...process.env, ...options.env } : undefined, maxBuffer: 20 * 1024 * 1024 });
191
+ return { status: result.status, stderr: result.stderr.trim(), ...(result.error ? { error: result.error } : {}) };
192
+ }
193
+
194
+ function gitRawOutput(cwd: string, args: string[]): string | undefined {
195
+ const result = spawnSync("git", args, { cwd, encoding: "utf8", maxBuffer: 20 * 1024 * 1024 });
196
+ if (result.error || result.status !== 0 || result.stdout.length === 0) return undefined;
197
+ return result.stdout;
198
+ }
199
+
200
+ function copyUntrackedFiles(baseRoot: string, targetRoot: string): void {
201
+ const raw = gitRawOutput(baseRoot, ["ls-files", "--others", "--exclude-standard", "-z"]);
202
+ if (!raw) return;
203
+ for (const rel of raw.split("\0").filter(Boolean)) {
204
+ const from = path.join(baseRoot, rel);
205
+ const to = path.join(targetRoot, rel);
206
+ fsSync.mkdirSync(path.dirname(to), { recursive: true });
207
+ fsSync.copyFileSync(from, to);
208
+ }
209
+ }
210
+
211
+ function copyWorkspaceFiles(sourceRoot: string, targetRoot: string): void {
212
+ fsSync.rmSync(targetRoot, { recursive: true, force: true });
213
+ fsSync.mkdirSync(path.dirname(targetRoot), { recursive: true });
214
+ fsSync.cpSync(sourceRoot, targetRoot, {
215
+ recursive: true,
216
+ errorOnExist: false,
217
+ filter: (source) => !source.split(path.sep).includes(".git"),
218
+ });
219
+ }
220
+
221
+ function createBaselineCommit(worktreePath: string, parent: string | undefined): string | undefined {
222
+ gitStatus(worktreePath, ["add", "-A"]);
223
+ const changed = gitStatus(worktreePath, ["diff", "--cached", "--quiet", parent ?? "HEAD"]).status !== 0;
224
+ if (!changed) return parent;
225
+ const tree = gitOutput(worktreePath, ["write-tree"]);
226
+ if (!tree) return parent;
227
+ const commit = gitOutput(worktreePath, ["commit-tree", tree, ...(parent ? ["-p", parent] : []), "-m", "Docket worker baseline"], {
228
+ env: {
229
+ GIT_AUTHOR_NAME: "Docket",
230
+ GIT_AUTHOR_EMAIL: "docket@example.invalid",
231
+ GIT_COMMITTER_NAME: "Docket",
232
+ GIT_COMMITTER_EMAIL: "docket@example.invalid",
233
+ },
234
+ });
235
+ if (!commit) return parent;
236
+ gitStatus(worktreePath, ["reset", "--hard", commit]);
237
+ return commit;
238
+ }
239
+
240
+ function createCopiedWorkspace(baseCwd: string, sourceRoot: string, target: string): WorkerWorktree {
241
+ copyWorkspaceFiles(sourceRoot, target);
242
+ gitStatus(target, ["init"]);
243
+ const snapshotHead = createBaselineCommit(target, undefined);
244
+ return { kind: "copy", path: target, baseCwd, baseRoot: sourceRoot, parentCwd: baseCwd, ...(snapshotHead ? { baseHead: snapshotHead, snapshotHead } : {}) };
245
+ }
246
+
247
+ export function createWorkerWorkspace(baseCwd: string, target: string): WorkerWorktree {
248
+ const inRepo = gitOutput(baseCwd, ["rev-parse", "--is-inside-work-tree"]) === "true";
249
+ const baseRoot = inRepo ? gitOutput(baseCwd, ["rev-parse", "--show-toplevel"]) : undefined;
250
+ const root = baseRoot ?? baseCwd;
251
+ const baseHead = inRepo ? gitOutput(baseCwd, ["rev-parse", "--verify", "HEAD"]) : undefined;
252
+ if (!inRepo || !baseHead) return createCopiedWorkspace(baseCwd, root, target);
253
+
254
+ const result = spawnSync("git", ["worktree", "add", "--detach", target, baseHead], { cwd: baseCwd, encoding: "utf8" });
255
+ if (result.error || result.status !== 0) throw new Error(result.stderr.trim() || result.error?.message || "git worktree add failed");
256
+ try {
257
+ const dirtyPatch = gitRawOutput(root, ["diff", "--binary", "HEAD"]);
258
+ if (dirtyPatch) {
259
+ const applied = gitStatus(target, ["apply", "--binary", "--whitespace=nowarn"], { input: dirtyPatch });
260
+ if (applied.status !== 0) throw new Error(applied.stderr || "git apply parent changes failed");
261
+ }
262
+ copyUntrackedFiles(root, target);
263
+ const snapshotHead = createBaselineCommit(target, baseHead);
264
+ return { kind: "git", path: target, baseCwd, baseRoot: root, parentCwd: baseCwd, baseHead, ...(snapshotHead ? { snapshotHead } : {}) };
265
+ } catch (err) {
266
+ removeWorkerWorkspace({ kind: "git", path: target, baseCwd });
267
+ throw err;
268
+ }
269
+ }
270
+
271
+ function removeWorkerWorkspace(worktree: Pick<WorkerWorktree, "path" | "baseCwd" | "kind">): void {
272
+ if (worktree.kind === "copy") {
273
+ fsSync.rmSync(worktree.path, { recursive: true, force: true });
274
+ return;
275
+ }
276
+ spawnSync("git", ["worktree", "remove", "--force", worktree.path], { cwd: worktree.baseCwd, stdio: "ignore" });
277
+ }
278
+
279
+ async function readJson<T>(file: string, fallback: T): Promise<T> {
280
+ try {
281
+ return JSON.parse(await fs.readFile(file, "utf8")) as T;
282
+ } catch {
283
+ return fallback;
284
+ }
285
+ }
286
+
287
+ async function writeJsonAtomic(file: string, payload: unknown): Promise<void> {
288
+ await fs.mkdir(path.dirname(file), { recursive: true });
289
+ const suffix = `${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}`;
290
+ const tmp = path.join(path.dirname(file), `.${path.basename(file)}.${suffix}.tmp`);
291
+ try {
292
+ await fs.writeFile(tmp, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
293
+ await fs.rename(tmp, file);
294
+ } catch (err) {
295
+ await fs.rm(tmp, { force: true });
296
+ throw err;
297
+ }
298
+ }
299
+
300
+ export function readWorkerStatusSync(id: string): WorkerStatus | undefined {
301
+ if (!/^[a-z0-9_-]+$/i.test(id)) return undefined;
302
+ try {
303
+ const status = JSON.parse(fsSync.readFileSync(path.join(workerDir(id), "status.json"), "utf8")) as WorkerStatus;
304
+ return status?.id ? status : undefined;
305
+ } catch {
306
+ return undefined;
307
+ }
308
+ }
309
+
310
+ function makeWorkerId(task: string, hint?: string): string {
311
+ const base = hint ?? task.split(/\s+/).slice(0, 4).join("-");
312
+ const slug = tmuxSafeId(base);
313
+ const suffix = randomBytes(2).toString("hex");
314
+ return `${slug}-${suffix}`.slice(0, 80);
315
+ }
316
+
317
+ /**
318
+ * Seed the worker's session dir with a fork of the parent's JSONL so the worker
319
+ * starts with the parent's context (faster TTFT + reused prompt cache prefix).
320
+ * Returns true when seeding succeeded and `--continue` should be passed to pi.
321
+ */
322
+ export function seedWorkerSession(parentSessionFile: string, workerCwd: string, workerSessionDir: string): boolean {
323
+ try {
324
+ if (!fsSync.existsSync(parentSessionFile)) return false;
325
+ fsSync.mkdirSync(workerCwd, { recursive: true });
326
+ fsSync.mkdirSync(workerSessionDir, { recursive: true });
327
+ SessionManager.forkFrom(parentSessionFile, workerCwd, workerSessionDir);
328
+ return true;
329
+ } catch {
330
+ return false;
331
+ }
332
+ }
333
+
334
+ export function buildWorkerInitialPrompt(input: { index: number; id: string; dir: string; worktreePath?: string; kind?: string; depth?: number; parentWorkerLabel?: string }): string {
335
+ return buildBackgroundWorkerInitialPrompt({
336
+ label: workerShortLabel(input.index),
337
+ id: input.id,
338
+ taskFile: path.join(input.dir, "task.md"),
339
+ artifactsFile: path.join(input.dir, "artifacts.json"),
340
+ worktreePath: input.worktreePath,
341
+ kind: input.kind,
342
+ depth: input.depth,
343
+ parentWorkerLabel: input.parentWorkerLabel,
344
+ });
345
+ }
346
+
347
+ export function explicitExtensionArgs(): string[] {
348
+ const out: string[] = [];
349
+ for (let i = 0; i < process.argv.length; i++) {
350
+ const arg = process.argv[i] ?? "";
351
+ if (arg === "--no-extensions") {
352
+ out.push(arg);
353
+ } else if ((arg === "-e" || arg === "--extension") && process.argv[i + 1]) {
354
+ out.push(arg, process.argv[++i]!);
355
+ } else if (arg.startsWith("--extension=")) {
356
+ out.push("--extension", arg.slice("--extension=".length));
357
+ }
358
+ }
359
+ return out;
360
+ }
361
+
362
+ export function createWorkerStore(): WorkerStore {
363
+ return {
364
+ root() {
365
+ return workersRoot();
366
+ },
367
+ dirFor(id: string) {
368
+ return workerDir(id);
369
+ },
370
+ statusFile(id: string) {
371
+ return path.join(workerDir(id), "status.json");
372
+ },
373
+ artifactsFile(id: string) {
374
+ return path.join(workerDir(id), "artifacts.json");
375
+ },
376
+ taskFile(id: string) {
377
+ return path.join(workerDir(id), "task.md");
378
+ },
379
+
380
+ async list(options: { projectRoot?: string } = {}): Promise<WorkerStatus[]> {
381
+ const root = workersRoot();
382
+ let entries: string[];
383
+ try {
384
+ entries = await fs.readdir(root);
385
+ } catch {
386
+ return [];
387
+ }
388
+ const out: WorkerStatus[] = [];
389
+ for (const name of entries) {
390
+ const status = await readJson<WorkerStatus | undefined>(path.join(root, name, "status.json"), undefined);
391
+ if (status?.id) out.push(status);
392
+ }
393
+ const projectRoot = options.projectRoot ? projectKey(options.projectRoot) : undefined;
394
+ const scoped = projectRoot ? out.filter((worker) => workerInProject(worker, projectRoot)) : out;
395
+ return scoped.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
396
+ },
397
+
398
+ async find(id: string): Promise<WorkerStatus | undefined> {
399
+ const all = await this.list();
400
+ const trimmed = id.trim();
401
+ const shortMatch = trimmed.match(/^w?(\d+)$/i);
402
+ if (shortMatch) {
403
+ const target = Number(shortMatch[1]);
404
+ const byIndex = all.find((entry) => entry.index === target);
405
+ if (byIndex) return byIndex;
406
+ }
407
+ const exact = await readJson<WorkerStatus | undefined>(this.statusFile(trimmed), undefined);
408
+ if (exact) return exact;
409
+ return all.find((entry) => entry.id === trimmed || entry.id.startsWith(trimmed));
410
+ },
411
+
412
+ async readArtifacts(id: string): Promise<Artifact[]> {
413
+ return readJson<Artifact[]>(this.artifactsFile(id), []);
414
+ },
415
+
416
+ async writeStatus(snapshot: WorkerStatus): Promise<void> {
417
+ await writeJsonAtomic(this.statusFile(snapshot.id), { ...snapshot, updatedAt: new Date().toISOString() });
418
+ },
419
+
420
+ async patchStatus(id: string, patch: Partial<WorkerStatus>): Promise<WorkerStatus | undefined> {
421
+ const current = await readJson<WorkerStatus | undefined>(this.statusFile(id), undefined);
422
+ if (!current) return undefined;
423
+ const next: WorkerStatus = { ...current, ...patch, id: current.id, updatedAt: new Date().toISOString() };
424
+ await writeJsonAtomic(this.statusFile(id), next);
425
+ return next;
426
+ },
427
+
428
+ async writeArtifacts(id: string, artifacts: Artifact[]): Promise<void> {
429
+ await writeJsonAtomic(this.artifactsFile(id), artifacts);
430
+ },
431
+
432
+ async addQuestion(id: string, text: string): Promise<WorkerStatus | undefined> {
433
+ const current = await this.find(id);
434
+ if (!current) return undefined;
435
+ const question: WorkerQuestion = { id: `${Date.now().toString(36)}-${randomBytes(2).toString("hex")}`, text: text.trim(), createdAt: new Date().toISOString() };
436
+ const patch = appendWorkerQuestionPatch(current, text, question);
437
+ return patch ? this.patchStatus(current.id, patch) : current;
438
+ },
439
+
440
+ async sendInput(id: string, text: string): Promise<boolean> {
441
+ const status = await this.find(id);
442
+ if (!status) return false;
443
+ const safeText = text.replace(/\s+/g, " ").trim();
444
+ if (!safeText) return false;
445
+ const ok = sendKeysToWindow(status.tmuxSession, safeText, status.tmuxWindowId);
446
+ if (!ok) return false;
447
+ await this.patchStatus(status.id, workerInputAcceptedPatch());
448
+ return true;
449
+ },
450
+
451
+ async countActive(): Promise<number> {
452
+ const ACTIVE: Array<WorkerStatus["state"]> = ["starting", "active", "idle", "needs_input"];
453
+ const workers = await this.list();
454
+ return workers.filter((w) => ACTIVE.includes(w.state)).length;
455
+ },
456
+
457
+ async spawn(input: SpawnInput): Promise<WorkerStatus> {
458
+ ensureTmux();
459
+ const projectRoot = projectKey(input.cwd);
460
+ const id = makeWorkerId(input.task, input.idHint);
461
+ const dir = workerDir(id);
462
+ await fs.mkdir(dir, { recursive: true });
463
+ await fs.writeFile(path.join(dir, "task.md"), `${input.task.trim()}\n`, "utf8");
464
+
465
+ const worktree = input.worktree === false ? undefined : createWorkerWorkspace(input.cwd, path.join(dir, "workspace"));
466
+ const workerCwd = worktree ? path.join(worktree.path, path.relative(worktree.baseRoot ?? worktree.baseCwd, input.cwd)) : input.cwd;
467
+ if (worktree) await fs.mkdir(workerCwd, { recursive: true });
468
+
469
+ const sessionDir = path.join(dir, "session");
470
+ await fs.mkdir(sessionDir, { recursive: true });
471
+
472
+ const resumeSeeded = input.fresh !== true && typeof input.parentSession === "string" && input.parentSession.length > 0
473
+ ? seedWorkerSession(input.parentSession, workerCwd, sessionDir)
474
+ : false;
475
+
476
+ const existing = await this.list();
477
+ const index = existing.reduce((max, entry) => Math.max(max, entry.index ?? 0), 0) + 1;
478
+ const target = workerWindowTarget(index);
479
+ const windowName = `w${index}`;
480
+ const parentWorker = input.parentWorkerId ? await this.find(input.parentWorkerId) : undefined;
481
+ const parentLabel = parentWorker ? workerShortLabel(parentWorker.index) : undefined;
482
+ const depth = typeof input.depth === "number" ? input.depth : (parentWorker?.depth ?? 0) + (parentWorker ? 1 : 0);
483
+
484
+ const initialPrompt = buildWorkerInitialPrompt({ index, id, dir, worktreePath: worktree?.path, kind: input.kind, depth, parentWorkerLabel: parentLabel });
485
+
486
+ const now = new Date().toISOString();
487
+ const status: WorkerStatus = {
488
+ id,
489
+ index,
490
+ tmuxSession: target,
491
+ task: input.task,
492
+ cwd: workerCwd,
493
+ projectRoot,
494
+ git: input.git,
495
+ worktree,
496
+ createdAt: now,
497
+ updatedAt: now,
498
+ state: "starting",
499
+ ...(input.kind ? { kind: input.kind } : {}),
500
+ ...(input.parentWorkerId ? { parentWorkerId: input.parentWorkerId } : {}),
501
+ ...(depth ? { depth } : {}),
502
+ ...(input.canSpawn && input.canSpawn.length > 0 ? { canSpawn: input.canSpawn } : {}),
503
+ };
504
+ await this.writeStatus(status);
505
+
506
+ const command = buildWorkerLaunchCommand({ id, sessionDir, statusFile: this.statusFile(id), initialPrompt, extensionArgs: input.extensionArgs ?? explicitExtensionArgs(), resumeSeeded });
507
+ const result = launchSharedWindow({ windowName, cwd: workerCwd, command });
508
+ if (!result.ok) {
509
+ if (worktree) removeWorkerWorkspace(worktree);
510
+ await fs.rm(dir, { recursive: true, force: true });
511
+ throw new Error(result.error || `tmux failed for ${id}`);
512
+ }
513
+
514
+ const windowId = readWindowId(target);
515
+ if (windowId) await this.patchStatus(id, { tmuxWindowId: windowId });
516
+
517
+ if (input.captureTerminal) {
518
+ const log = path.join(dir, "pane.log");
519
+ spawnSync("tmux", ["pipe-pane", "-o", "-t", windowId ?? target, `cat > ${shellQuote(log)}`], { stdio: "ignore" });
520
+ }
521
+
522
+ if (input.layout === "split-events") {
523
+ const eventsPath = path.join(dir, "events.ndjson");
524
+ const splitCmd = `touch ${shellQuote(eventsPath)} && tail -F ${shellQuote(eventsPath)}`;
525
+ spawnSync("tmux", ["split-window", "-h", "-d", "-l", "30%", "-t", windowId ?? target, splitCmd], { stdio: "ignore" });
526
+ }
527
+
528
+ return windowId ? { ...status, tmuxWindowId: windowId } : status;
529
+ },
530
+
531
+ async kill(id: string): Promise<boolean> {
532
+ const status = await this.find(id);
533
+ if (!status) return false;
534
+ killTmux(status.tmuxSession, status.tmuxWindowId);
535
+ await this.patchStatus(status.id, { state: "ended" });
536
+ return true;
537
+ },
538
+
539
+ async respawn(id: string): Promise<WorkerStatus | undefined> {
540
+ ensureTmux();
541
+ const status = await this.find(id);
542
+ if (!status) return undefined;
543
+ const dir = workerDir(status.id);
544
+ const sessionDir = path.join(dir, "session");
545
+ const target = workerWindowTarget(status.index);
546
+ const windowName = `w${status.index}`;
547
+ const seeded = fsSync.existsSync(sessionDir) && fsSync.readdirSync(sessionDir).length > 0;
548
+ const parent = status.parentWorkerId ? await this.find(status.parentWorkerId) : undefined;
549
+ const parentLabel = parent ? workerShortLabel(parent.index) : undefined;
550
+ const prompt = buildWorkerInitialPrompt({ index: status.index, id: status.id, dir, ...(status.worktree?.path ? { worktreePath: status.worktree.path } : {}), ...(status.kind ? { kind: status.kind } : {}), ...(typeof status.depth === "number" ? { depth: status.depth } : {}), ...(parentLabel ? { parentWorkerLabel: parentLabel } : {}) });
551
+ const command = buildWorkerLaunchCommand({ id: status.id, sessionDir, statusFile: this.statusFile(status.id), initialPrompt: prompt, extensionArgs: explicitExtensionArgs(), resumeSeeded: seeded });
552
+ const launch = launchSharedWindow({ windowName, cwd: status.cwd, command });
553
+ if (!launch.ok) throw new Error(launch.error);
554
+ const windowId = readWindowId(target);
555
+ const patch: Partial<WorkerStatus> = { state: "starting", tmuxSession: target, ...(windowId ? { tmuxWindowId: windowId } : {}) };
556
+ return await this.patchStatus(status.id, patch);
557
+ },
558
+
559
+ async purge(id: string, options: { cascade?: boolean } = {}): Promise<string[]> {
560
+ const status = await this.find(id);
561
+ if (!status) return [];
562
+ const purged: string[] = [];
563
+ if (options.cascade !== false) {
564
+ const all = await this.list();
565
+ const children = all.filter((entry) => entry.parentWorkerId === status.id);
566
+ for (const child of children) {
567
+ const childPurged = await this.purge(child.id, { cascade: true });
568
+ purged.push(...childPurged);
569
+ }
570
+ }
571
+ killTmux(status.tmuxSession, status.tmuxWindowId);
572
+ if (status.worktree) removeWorkerWorkspace(status.worktree);
573
+ await fs.rm(workerDir(status.id), { recursive: true, force: true });
574
+ purged.push(status.id);
575
+ return purged;
576
+ },
577
+ };
578
+ }
579
+
580
+ const DOCKET_INJECT_MARK = "[docket] ";
581
+
582
+ function sendKeysToWindow(target: string, text: string, windowId?: string): boolean {
583
+ if (!target) return false;
584
+ const sendTarget = windowId ?? target;
585
+ const shared = isSharedSessionTarget(target) || Boolean(windowId);
586
+ const literal = shared ? `${DOCKET_INJECT_MARK}${text}` : text;
587
+ const literalResult = shared
588
+ ? spawnSync("tmux", ["send-keys", "-t", sendTarget, "-l", literal], { stdio: "ignore" })
589
+ : spawnSync("tmux", ["send-keys", "-t", sendTarget, literal], { stdio: "ignore" });
590
+ if (literalResult.status !== 0) {
591
+ if (windowId) {
592
+ // Fall back to name target if id resolution failed (e.g. window was renamed and id stale).
593
+ const retry = spawnSync("tmux", ["send-keys", "-t", target, "-l", literal], { stdio: "ignore" });
594
+ if (retry.status !== 0) return false;
595
+ } else {
596
+ return false;
597
+ }
598
+ }
599
+ const enterResult = spawnSync("tmux", ["send-keys", "-t", sendTarget, "Enter"], { stdio: "ignore" });
600
+ if (enterResult.status !== 0 && windowId) {
601
+ const retry = spawnSync("tmux", ["send-keys", "-t", target, "Enter"], { stdio: "ignore" });
602
+ return retry.status === 0;
603
+ }
604
+ return enterResult.status === 0;
605
+ }
606
+
607
+ function launchSharedWindow(input: { windowName: string; cwd: string; command: string }): { ok: true } | { ok: false; error: string } {
608
+ if (!tmuxSessionExists(SHARED_TMUX_SESSION)) {
609
+ const created = spawnSync("tmux", ["new-session", "-d", "-s", SHARED_TMUX_SESSION, "-n", input.windowName, "-c", input.cwd, input.command], { encoding: "utf8" });
610
+ if (created.error || created.status !== 0) {
611
+ return { ok: false, error: created.stderr?.trim() || created.error?.message || "tmux new-session failed" };
612
+ }
613
+ spawnSync("tmux", ["set-option", "-t", SHARED_TMUX_SESSION, "remain-on-exit", "off"], { stdio: "ignore" });
614
+ return { ok: true };
615
+ }
616
+ const added = spawnSync("tmux", ["new-window", "-d", "-t", `${SHARED_TMUX_SESSION}:`, "-n", input.windowName, "-c", input.cwd, input.command], { encoding: "utf8" });
617
+ if (added.error || added.status !== 0) {
618
+ return { ok: false, error: added.stderr?.trim() || added.error?.message || "tmux new-window failed" };
619
+ }
620
+ return { ok: true };
621
+ }