@selesai/code 0.8.4 → 0.8.6

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 (147) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +7 -1
  3. package/dist/core/slash-commands.js +1 -0
  4. package/dist/defaults/models.json +46 -55
  5. package/dist/defaults/settings.json +7 -8
  6. package/dist/extensions/agent-browser.test.ts +178 -2
  7. package/dist/extensions/agent-browser.ts +2 -0
  8. package/dist/extensions/copy-turn.test.ts +229 -0
  9. package/dist/extensions/copy-turn.ts +11 -0
  10. package/dist/extensions/grep-app/index.test.ts +356 -1
  11. package/dist/extensions/grep-app/index.ts +6 -1
  12. package/dist/extensions/handoff-new.test.ts +351 -161
  13. package/dist/extensions/handoff-new.ts +3 -0
  14. package/dist/extensions/inline-skills.test.ts +93 -0
  15. package/dist/extensions/inline-skills.ts +3 -0
  16. package/dist/extensions/pi-subagents/CHANGELOG.md +26 -3
  17. package/dist/extensions/pi-subagents/README.md +2 -0
  18. package/dist/extensions/pi-subagents/agents/builder.md +5 -2
  19. package/dist/extensions/pi-subagents/agents/commentator.md +1 -1
  20. package/dist/extensions/pi-subagents/docs/configuration.md +54 -2
  21. package/dist/extensions/pi-subagents/docs/observability.md +1 -1
  22. package/dist/extensions/pi-subagents/docs/tool-reference.md +1 -1
  23. package/dist/extensions/pi-subagents/docs/workflows.md +1 -1
  24. package/dist/extensions/pi-subagents/package-lock.json +2 -2
  25. package/dist/extensions/pi-subagents/package.json +1 -1
  26. package/dist/extensions/pi-subagents/skills/pi-subagents/references/execution-controls.md +1 -1
  27. package/dist/extensions/pi-subagents/src/agents/agents.ts +9 -3
  28. package/dist/extensions/pi-subagents/src/extension/config.ts +12 -0
  29. package/dist/extensions/pi-subagents/src/extension/doctor.ts +40 -0
  30. package/dist/extensions/pi-subagents/src/extension/index.ts +104 -14
  31. package/dist/extensions/pi-subagents/src/extension/public-execution.ts +5 -0
  32. package/dist/extensions/pi-subagents/src/extension/rpc.ts +3 -9
  33. package/dist/extensions/pi-subagents/src/extension/schemas.ts +2 -2
  34. package/dist/extensions/pi-subagents/src/intercom/intercom-bridge.ts +4 -1
  35. package/dist/extensions/pi-subagents/src/missions/lifecycle.ts +4 -7
  36. package/dist/extensions/pi-subagents/src/missions/store.ts +4 -4
  37. package/dist/extensions/pi-subagents/src/missions/workflow-state.ts +6 -2
  38. package/dist/extensions/pi-subagents/src/runs/background/active-async-capacity.ts +374 -0
  39. package/dist/extensions/pi-subagents/src/runs/background/active-run-index.ts +9 -5
  40. package/dist/extensions/pi-subagents/src/runs/background/async-execution.ts +117 -29
  41. package/dist/extensions/pi-subagents/src/runs/background/async-job-tracker.ts +12 -6
  42. package/dist/extensions/pi-subagents/src/runs/background/async-resume.ts +7 -1
  43. package/dist/extensions/pi-subagents/src/runs/background/async-status.ts +14 -5
  44. package/dist/extensions/pi-subagents/src/runs/background/chain-append.ts +33 -15
  45. package/dist/extensions/pi-subagents/src/runs/background/notify.ts +5 -2
  46. package/dist/extensions/pi-subagents/src/runs/background/owned-process-tree.ts +104 -0
  47. package/dist/extensions/pi-subagents/src/runs/background/process-terminal.ts +17 -3
  48. package/dist/extensions/pi-subagents/src/runs/background/result-watcher.ts +19 -1
  49. package/dist/extensions/pi-subagents/src/runs/background/retained-children.ts +2 -2
  50. package/dist/extensions/pi-subagents/src/runs/background/run-id-resolver.ts +5 -2
  51. package/dist/extensions/pi-subagents/src/runs/background/run-status.ts +12 -6
  52. package/dist/extensions/pi-subagents/src/runs/background/stale-run-reconciler.ts +3 -3
  53. package/dist/extensions/pi-subagents/src/runs/background/subagent-runner.ts +84 -36
  54. package/dist/extensions/pi-subagents/src/runs/background/wait-subscriptions.ts +2 -1
  55. package/dist/extensions/pi-subagents/src/runs/foreground/chain-execution.ts +37 -2
  56. package/dist/extensions/pi-subagents/src/runs/foreground/execution.ts +135 -22
  57. package/dist/extensions/pi-subagents/src/runs/foreground/foreground-control.ts +12 -0
  58. package/dist/extensions/pi-subagents/src/runs/foreground/foreground-history.ts +7 -4
  59. package/dist/extensions/pi-subagents/src/runs/foreground/prompt-audit.ts +171 -0
  60. package/dist/extensions/pi-subagents/src/runs/foreground/subagent-executor.ts +653 -196
  61. package/dist/extensions/pi-subagents/src/runs/shared/acceptance.ts +13 -4
  62. package/dist/extensions/pi-subagents/src/runs/shared/llm-intent-arbiter.ts +286 -0
  63. package/dist/extensions/pi-subagents/src/runs/shared/model-fallback.ts +23 -0
  64. package/dist/extensions/pi-subagents/src/runs/shared/parallel-utils.ts +2 -0
  65. package/dist/extensions/pi-subagents/src/runs/shared/pi-args.ts +44 -1
  66. package/dist/extensions/pi-subagents/src/runs/shared/run-fanout-budget.ts +280 -0
  67. package/dist/extensions/pi-subagents/src/runs/shared/single-output.ts +4 -2
  68. package/dist/extensions/pi-subagents/src/runs/shared/task-intent.ts +19 -3
  69. package/dist/extensions/pi-subagents/src/runs/shared/worktree.ts +17 -5
  70. package/dist/extensions/pi-subagents/src/shared/session-lineage.ts +71 -0
  71. package/dist/extensions/pi-subagents/src/shared/types.ts +108 -0
  72. package/dist/extensions/pi-subagents/src/shared/utils.ts +3 -1
  73. package/dist/extensions/pi-subagents/src/tui/fleet-status.ts +7 -5
  74. package/dist/extensions/pi-subagents/src/tui/fleet.ts +226 -13
  75. package/dist/extensions/pi-subagents/src/workflows/scripted-workflow.ts +22 -5
  76. package/dist/extensions/pi-subagents/src/workflows/workflow-auto-relaunch.ts +28 -0
  77. package/dist/extensions/pi-subagents/test/integration/acceptance-file-report.test.ts +87 -0
  78. package/dist/extensions/pi-subagents/test/integration/async-execution.test.ts +96 -6
  79. package/dist/extensions/pi-subagents/test/integration/async-status.test.ts +28 -5
  80. package/dist/extensions/pi-subagents/test/integration/chain-execution.test.ts +8 -5
  81. package/dist/extensions/pi-subagents/test/integration/fork-context-execution.test.ts +83 -2
  82. package/dist/extensions/pi-subagents/test/integration/intercom-result-delivery.test.ts +211 -15
  83. package/dist/extensions/pi-subagents/test/integration/parallel-execution.test.ts +41 -3
  84. package/dist/extensions/pi-subagents/test/integration/single-execution.test.ts +314 -10
  85. package/dist/extensions/pi-subagents/test/unit/acceptance.test.ts +31 -1
  86. package/dist/extensions/pi-subagents/test/unit/active-async-capacity.test.ts +317 -0
  87. package/dist/extensions/pi-subagents/test/unit/agent-overrides.test.ts +21 -2
  88. package/dist/extensions/pi-subagents/test/unit/async-recovery-descriptor.test.ts +16 -1
  89. package/dist/extensions/pi-subagents/test/unit/async-resume.test.ts +6 -1
  90. package/dist/extensions/pi-subagents/test/unit/chain-append.test.ts +75 -4
  91. package/dist/extensions/pi-subagents/test/unit/completion-guard.test.ts +18 -0
  92. package/dist/extensions/pi-subagents/test/unit/doctor.test.ts +29 -1
  93. package/dist/extensions/pi-subagents/test/unit/fleet-status.test.ts +31 -5
  94. package/dist/extensions/pi-subagents/test/unit/fleet.test.ts +139 -1
  95. package/dist/extensions/pi-subagents/test/unit/foreground-control.test.ts +10 -0
  96. package/dist/extensions/pi-subagents/test/unit/handoff-adoption.test.ts +103 -0
  97. package/dist/extensions/pi-subagents/test/unit/index-child-registration.test.ts +22 -0
  98. package/dist/extensions/pi-subagents/test/unit/intercom-bridge.test.ts +2 -1
  99. package/dist/extensions/pi-subagents/test/unit/llm-intent-arbiter.test.ts +171 -0
  100. package/dist/extensions/pi-subagents/test/unit/mission-lifecycle.test.ts +30 -4
  101. package/dist/extensions/pi-subagents/test/unit/model-fallback.test.ts +14 -0
  102. package/dist/extensions/pi-subagents/test/unit/owned-process-tree.test.ts +69 -0
  103. package/dist/extensions/pi-subagents/test/unit/pi-args.test.ts +83 -0
  104. package/dist/extensions/pi-subagents/test/unit/pi-coding-agent-dir.test.ts +2 -2
  105. package/dist/extensions/pi-subagents/test/unit/process-terminal.test.ts +55 -2
  106. package/dist/extensions/pi-subagents/test/unit/public-execution.test.ts +12 -1
  107. package/dist/extensions/pi-subagents/test/unit/rpc.test.ts +7 -7
  108. package/dist/extensions/pi-subagents/test/unit/run-fanout-budget.test.ts +121 -0
  109. package/dist/extensions/pi-subagents/test/unit/run-status.test.ts +9 -0
  110. package/dist/extensions/pi-subagents/test/unit/schemas.test.ts +4 -3
  111. package/dist/extensions/pi-subagents/test/unit/session-lineage.test.ts +73 -0
  112. package/dist/extensions/pi-subagents/test/unit/single-output.test.ts +28 -0
  113. package/dist/extensions/pi-subagents/test/unit/steering-action.test.ts +10 -1
  114. package/dist/extensions/pi-subagents/test/unit/task-intent.test.ts +39 -0
  115. package/dist/extensions/pi-subagents/test/unit/timeout-defaults.test.ts +65 -0
  116. package/dist/extensions/pi-subagents/test/unit/workflow-auto-relaunch.test.ts +45 -0
  117. package/dist/extensions/pi-subagents/test/unit/worktree.test.ts +61 -0
  118. package/dist/extensions/ponytail/index.js +11 -0
  119. package/dist/extensions/ponytail/ponytail-config.cjs +2 -0
  120. package/dist/extensions/ponytail/ponytail-instructions.cjs +6 -0
  121. package/dist/extensions/ponytail/test/extension.test.js +274 -140
  122. package/dist/extensions/ponytail/test/helpers.test.js +280 -92
  123. package/dist/extensions/question/index.ts +18 -0
  124. package/dist/extensions/question/question-list.ts +22 -0
  125. package/dist/extensions/question/tests/batch.test.ts +103 -65
  126. package/dist/extensions/question/tests/helpers.test.ts +68 -35
  127. package/dist/extensions/question/tests/question-list.test.ts +506 -204
  128. package/dist/extensions/question/tests/row-layout.test.ts +180 -114
  129. package/dist/extensions/question/tests/schemas.test.ts +42 -19
  130. package/dist/extensions/question/tests/shortcuts.test.ts +106 -84
  131. package/dist/extensions/question/tests/wizard.test.ts +771 -50
  132. package/dist/extensions/rtk.test.ts +180 -1
  133. package/dist/extensions/tokenin-onboarding.ts +3 -0
  134. package/dist/extensions/undo.test.ts +720 -0
  135. package/dist/extensions/undo.ts +6 -0
  136. package/dist/extensions/web-agent-onboarding.test.ts +415 -0
  137. package/dist/extensions/workflow/extension.ts +3 -3
  138. package/dist/extensions/workflow/modes.ts +41 -19
  139. package/dist/modes/interactive/interactive-mode.d.ts +1 -0
  140. package/dist/modes/interactive/interactive-mode.js +48 -1
  141. package/dist/skills/pi-subagents/references/execution-controls.md +1 -1
  142. package/docs/plans/workflow-autoloop-reference.md +1 -2
  143. package/docs/plans/workflow-handoff-carryover.md +108 -0
  144. package/docs/quickstart.md +27 -43
  145. package/docs/settings.md +4 -0
  146. package/docs/usage.md +1 -0
  147. package/package.json +5 -1
@@ -2,8 +2,10 @@ import assert from "node:assert/strict";
2
2
  import { describe, it } from "node:test";
3
3
  import {
4
4
  buildModelCandidates,
5
+ formatModelRetryNote,
5
6
  fuzzyResolveModel,
6
7
  isRetryableModelFailure,
8
+ MODEL_RETRY_DELAYS_MS,
7
9
  normalizeModelSegment,
8
10
  resolveEffectiveSubagentModel,
9
11
  resolveModelCandidate,
@@ -74,6 +76,18 @@ describe("model fallback helpers", () => {
74
76
  assert.equal(isRetryableModelFailure("model load failed"), true);
75
77
  assert.equal(isRetryableModelFailure("Stream ended without finish_reason"), true);
76
78
  assert.equal(isRetryableModelFailure("Request timed out."), true);
79
+ assert.equal(isRetryableModelFailure("503 Service Unavailable"), true);
80
+ assert.equal(isRetryableModelFailure("No body returned for response"), true);
81
+ assert.equal(isRetryableModelFailure("no response body"), true);
82
+ assert.equal(isRetryableModelFailure("ECONNRESET"), true);
83
+ });
84
+
85
+ it("ships a bounded same-model retry schedule and note", () => {
86
+ assert.deepEqual(MODEL_RETRY_DELAYS_MS, [500, 1500, 4000]);
87
+ assert.match(
88
+ formatModelRetryNote({ model: "anthropic/claude-sonnet-4", attempt: 1, maxAttempts: 4, delayMs: 500 }),
89
+ /\[model-retry\] anthropic\/claude-sonnet-4.*attempt 1\/4.*500ms/,
90
+ );
77
91
  });
78
92
 
79
93
  it("does not treat ordinary task/tool failures as retryable model failures", () => {
@@ -0,0 +1,69 @@
1
+ import assert from "node:assert/strict";
2
+ import { spawn, spawnSync } from "node:child_process";
3
+ import * as fs from "node:fs";
4
+ import * as os from "node:os";
5
+ import * as path from "node:path";
6
+ import test from "node:test";
7
+ import { createOwnedProcessTreeController } from "../../src/runs/background/owned-process-tree.ts";
8
+
9
+ function processIsActive(pid: number): boolean {
10
+ const result = spawnSync("ps", ["-o", "stat=", "-p", String(pid)], { encoding: "utf-8" });
11
+ return result.status === 0 && Boolean(result.stdout.trim()) && !result.stdout.trim().startsWith("Z");
12
+ }
13
+
14
+ test("owned process tree fails closed when process-group ownership is unsupported", { skip: process.platform !== "win32" }, async () => {
15
+ const proof = await createOwnedProcessTreeController(999_999).terminate();
16
+ assert.deepEqual(proof, { state: "unknown", reason: "unsupported-platform" });
17
+ });
18
+
19
+ test("owned process tree kills descendants and verifies a TERM-resistant POSIX group", { skip: process.platform === "win32" }, async () => {
20
+ const writer = spawn(process.execPath, ["-e", `
21
+ const { spawn } = require("node:child_process");
22
+ const child = spawn(process.execPath, ["-e", "process.on('SIGTERM', () => {}); setInterval(() => {}, 1000)"], { stdio: "ignore" });
23
+ process.on("SIGTERM", () => {});
24
+ process.stdout.write(String(child.pid) + "\\n");
25
+ setInterval(() => {}, 1000);
26
+ `], { detached: true, stdio: ["ignore", "pipe", "ignore"] });
27
+ assert.ok(writer.pid);
28
+ const grandchildPid = await new Promise<number>((resolve, reject) => {
29
+ writer.once("error", reject);
30
+ writer.stdout!.once("data", (chunk) => resolve(Number(String(chunk).trim())));
31
+ });
32
+ const proof = await createOwnedProcessTreeController(writer.pid, { termGraceMs: 50, killVerifyMs: 1000 }).terminate();
33
+ assert.equal(proof.state, "observed", JSON.stringify(proof));
34
+ assert.equal(processIsActive(writer.pid), false);
35
+ assert.equal(processIsActive(grandchildPid), false);
36
+
37
+ const fixtureDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-process-tree-ps-"));
38
+ const realPs = spawnSync("sh", ["-c", "command -v ps"], { encoding: "utf-8" }).stdout.trim();
39
+ const marker = path.join(fixtureDir, "failed-once");
40
+ const originalPath = process.env.PATH;
41
+ try {
42
+ fs.writeFileSync(path.join(fixtureDir, "ps"), `#!/bin/sh\nif [ ! -e "$PI_TEST_PS_MARKER" ]; then\n : > "$PI_TEST_PS_MARKER"\n exit 1\nfi\nexec "$PI_TEST_REAL_PS" "$@"\n`);
43
+ fs.chmodSync(path.join(fixtureDir, "ps"), 0o755);
44
+ process.env.PATH = `${fixtureDir}${path.delimiter}${originalPath ?? ""}`;
45
+ process.env.PI_TEST_PS_MARKER = marker;
46
+ process.env.PI_TEST_REAL_PS = realPs;
47
+ const enumerationFailureWriter = spawn(process.execPath, ["-e", `
48
+ process.on("SIGTERM", () => {});
49
+ process.stdout.write("ready\\n");
50
+ setInterval(() => {}, 1000);
51
+ `], { detached: true, stdio: ["ignore", "pipe", "ignore"] });
52
+ assert.ok(enumerationFailureWriter.pid);
53
+ await new Promise<void>((resolve, reject) => {
54
+ enumerationFailureWriter.once("error", reject);
55
+ enumerationFailureWriter.stdout!.once("data", () => resolve());
56
+ });
57
+ const termGraceMs = 50;
58
+ const startedAt = Date.now();
59
+ const failedProof = await createOwnedProcessTreeController(enumerationFailureWriter.pid, { termGraceMs, killVerifyMs: 1000 }).terminate();
60
+ assert.ok(Date.now() - startedAt >= termGraceMs, "enumeration failure still waits through the TERM grace before SIGKILL");
61
+ assert.equal(failedProof.state, "observed", JSON.stringify(failedProof));
62
+ assert.equal(processIsActive(enumerationFailureWriter.pid), false);
63
+ } finally {
64
+ process.env.PATH = originalPath;
65
+ delete process.env.PI_TEST_PS_MARKER;
66
+ delete process.env.PI_TEST_REAL_PS;
67
+ fs.rmSync(fixtureDir, { recursive: true, force: true });
68
+ }
69
+ });
@@ -68,6 +68,7 @@ const originalEnv = {
68
68
  [PI_INTERCOM_SESSION_ID_ENV]: process.env[PI_INTERCOM_SESSION_ID_ENV],
69
69
  MCP_HASH_ROOT: process.env.MCP_HASH_ROOT,
70
70
  MCP_HASH_TOKEN: process.env.MCP_HASH_TOKEN,
71
+ SELESAI_SUBAGENT_TASK_DELIVERY: process.env.SELESAI_SUBAGENT_TASK_DELIVERY,
71
72
  };
72
73
  const originalCwd = process.cwd();
73
74
  const tempRoots: string[] = [];
@@ -481,6 +482,88 @@ describe("buildPiArgs model wiring", () => {
481
482
  });
482
483
  });
483
484
 
485
+ describe("buildPiArgs task delivery", () => {
486
+ const longTask = "x".repeat(8001);
487
+
488
+ function taskFileFromArgs(args: string[]): string | undefined {
489
+ const ref = args.find((arg) => arg.startsWith("@") && arg.endsWith("task.md"));
490
+ return ref ? ref.slice(1) : undefined;
491
+ }
492
+
493
+ it("delivers short tasks inline by default", () => {
494
+ const { args } = buildPiArgs({
495
+ baseArgs: ["-p"],
496
+ task: "hello",
497
+ sessionEnabled: false,
498
+ inheritProjectContext: false,
499
+ inheritSkills: false,
500
+ });
501
+
502
+ assert.ok(args.includes("Task: hello"));
503
+ assert.equal(taskFileFromArgs(args), undefined);
504
+ });
505
+
506
+ it("delivers tasks over the argv limit via a temp file by default", () => {
507
+ const { args } = buildPiArgs({
508
+ baseArgs: ["-p"],
509
+ task: longTask,
510
+ sessionEnabled: false,
511
+ inheritProjectContext: false,
512
+ inheritSkills: false,
513
+ });
514
+
515
+ const taskFile = taskFileFromArgs(args);
516
+ assert.ok(taskFile, "expected an @task.md argv reference");
517
+ assert.equal(fs.readFileSync(taskFile, "utf-8"), `Task: ${longTask}`);
518
+ assert.ok(!args.includes(`Task: ${longTask}`));
519
+ });
520
+
521
+ it("delivers short tasks via file when SELESAI_SUBAGENT_TASK_DELIVERY=file", () => {
522
+ process.env.SELESAI_SUBAGENT_TASK_DELIVERY = "file";
523
+ const { args } = buildPiArgs({
524
+ baseArgs: ["-p"],
525
+ task: "hello",
526
+ sessionEnabled: false,
527
+ inheritProjectContext: false,
528
+ inheritSkills: false,
529
+ });
530
+
531
+ const taskFile = taskFileFromArgs(args);
532
+ assert.ok(taskFile, "expected an @task.md argv reference");
533
+ assert.equal(fs.readFileSync(taskFile, "utf-8"), "Task: hello");
534
+ assert.ok(!args.includes("Task: hello"));
535
+ });
536
+
537
+
538
+ it("falls back to auto when SELESAI_SUBAGENT_TASK_DELIVERY is invalid", () => {
539
+ process.env.SELESAI_SUBAGENT_TASK_DELIVERY = "carrier-pigeon";
540
+ const { args } = buildPiArgs({
541
+ baseArgs: ["-p"],
542
+ task: longTask,
543
+ sessionEnabled: false,
544
+ inheritProjectContext: false,
545
+ inheritSkills: false,
546
+ });
547
+
548
+ assert.ok(taskFileFromArgs(args), "expected file delivery for over-limit task");
549
+ });
550
+
551
+ it("lets the per-launch taskDelivery override beat the env setting", () => {
552
+ delete process.env.SELESAI_SUBAGENT_TASK_DELIVERY;
553
+ const { args } = buildPiArgs({
554
+ baseArgs: ["-p"],
555
+ task: "hello",
556
+ taskDelivery: "file",
557
+ sessionEnabled: false,
558
+ inheritProjectContext: false,
559
+ inheritSkills: false,
560
+ });
561
+
562
+ assert.ok(taskFileFromArgs(args), "expected an @task.md argv reference");
563
+ assert.ok(!args.includes("Task: hello"));
564
+ });
565
+ });
566
+
484
567
  describe("buildPiArgs system prompt mode wiring", () => {
485
568
  it("uses --append-system-prompt by default", () => {
486
569
  const { args } = buildPiArgs({
@@ -182,14 +182,14 @@ Package skill content.
182
182
  });
183
183
 
184
184
  it("records private redacted run history and cleans session artifacts under the configured agent dir", () => {
185
- const task = "Inspect customer ACME token=SECRET";
185
+ const task = "PROMPT_AUDIT_SENTINEL_1021 Inspect customer ACME token=SECRET";
186
186
  recordRun("env-agent", task, 0, 42);
187
187
  const historyPath = path.join(agentDir, "run-history.jsonl");
188
188
  assert.equal(fs.existsSync(historyPath), true);
189
189
  assertPrivateHistoryModes(historyPath);
190
190
 
191
191
  const rawHistory = fs.readFileSync(historyPath, "utf-8");
192
- assert.doesNotMatch(rawHistory, /Inspect customer|ACME|SECRET/);
192
+ assert.doesNotMatch(rawHistory, /PROMPT_AUDIT_SENTINEL_1021|Inspect customer|ACME|SECRET/);
193
193
  assert.match(rawHistory, /"task":"\[redacted\]"/);
194
194
  assert.match(rawHistory, /"taskHash":"[a-f0-9]{64}"/);
195
195
 
@@ -3,6 +3,7 @@ import * as fs from "node:fs";
3
3
  import * as os from "node:os";
4
4
  import * as path from "node:path";
5
5
  import test from "node:test";
6
+ import { ACTIVE_RUN_INDEX_DIR, updateActiveRunIndex } from "../../src/runs/background/active-run-index.ts";
6
7
  import {
7
8
  finalizeProcessTerminal,
8
9
  processTerminalPath,
@@ -27,6 +28,7 @@ test("process-terminal proof requires the matching runner instance and writer cl
27
28
  closeObservedAt: 10,
28
29
  exitCode: 0,
29
30
  signal: null,
31
+ processTree: { state: "observed", mechanism: "posix-process-group", processGroupId: 123, verifiedAt: 11 },
30
32
  }],
31
33
  },
32
34
  });
@@ -82,6 +84,57 @@ test("process-terminal rejects missing writer close evidence", () => {
82
84
  }
83
85
  });
84
86
 
87
+ test("process-terminal fails closed when process-tree verification is unknown", () => {
88
+ const asyncDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-process-terminal-"));
89
+ try {
90
+ writeProcessTerminalCandidate(asyncDir, {
91
+ version: 1,
92
+ runId: "run-tree-unknown",
93
+ runnerProcessInstanceId: "runner-tree-unknown",
94
+ expectedWriters: { "0": 1 },
95
+ writers: {
96
+ "0": [{
97
+ processInstanceId: "writer-tree-unknown",
98
+ kind: "pi-writer",
99
+ attempt: 0,
100
+ closeObservedAt: 10,
101
+ exitCode: 1,
102
+ signal: "SIGKILL",
103
+ processTree: { state: "unknown", reason: "verification-failed", diagnostic: "still live" },
104
+ }],
105
+ },
106
+ });
107
+ fs.writeFileSync(path.join(asyncDir, "status.json"), JSON.stringify({ runId: "run-tree-unknown", state: "failed", lifecycleArtifactVersion: 3, steps: [{ agent: "worker", status: "failed" }] }));
108
+ fs.writeFileSync(path.join(asyncDir, "events.jsonl"), "");
109
+ updateActiveRunIndex(asyncDir, "running");
110
+ const markerPath = path.join(path.dirname(asyncDir), ACTIVE_RUN_INDEX_DIR, path.basename(asyncDir));
111
+ const proof = finalizeProcessTerminal(asyncDir, "run-tree-unknown", {
112
+ processInstanceId: "runner-tree-unknown",
113
+ closeObservedAt: 20,
114
+ exitCode: 1,
115
+ signal: null,
116
+ });
117
+ assert.equal(proof.state, "unknown");
118
+ assert.equal(proof.reason, "process-tree-unverified");
119
+ assert.equal(fs.existsSync(markerPath), true);
120
+ fs.rmSync(processTerminalPath(asyncDir), { force: true });
121
+ const candidatePath = path.join(asyncDir, "process-terminal-candidate.json");
122
+ const candidate = JSON.parse(fs.readFileSync(candidatePath, "utf-8"));
123
+ candidate.writers["0"][0].processTree = { state: "observed", mechanism: "posix-process-group", processGroupId: 126, verifiedAt: 21 };
124
+ fs.writeFileSync(candidatePath, JSON.stringify(candidate));
125
+ const observed = finalizeProcessTerminal(asyncDir, "run-tree-unknown", {
126
+ processInstanceId: "runner-tree-unknown",
127
+ closeObservedAt: 30,
128
+ exitCode: 1,
129
+ signal: null,
130
+ });
131
+ assert.equal(observed.state, "observed");
132
+ assert.equal(fs.existsSync(markerPath), false);
133
+ } finally {
134
+ fs.rmSync(asyncDir, { recursive: true, force: true });
135
+ }
136
+ });
137
+
85
138
  test("malformed process-terminal sidecars project unknown instead of throwing", () => {
86
139
  const asyncDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-process-terminal-"));
87
140
  try {
@@ -112,7 +165,7 @@ test("process-terminal preserves stopped non-resumability and requires lease rel
112
165
  runnerProcessInstanceId: "stopped-runner",
113
166
  expectedWriters: { "0": 1 },
114
167
  writers: {
115
- "0": [{ processInstanceId: "stopped-writer", kind: "pi-writer", attempt: 0, closeObservedAt: 10, exitCode: 0, signal: null }],
168
+ "0": [{ processInstanceId: "stopped-writer", kind: "pi-writer", attempt: 0, closeObservedAt: 10, exitCode: 0, signal: null, processTree: { state: "observed", mechanism: "posix-process-group", processGroupId: 124, verifiedAt: 11 } }],
116
169
  },
117
170
  revivalLeaseToken: "lease-token",
118
171
  revivalLeaseReleaseAcknowledged: false,
@@ -161,7 +214,7 @@ test("process-terminal rejects cross-run observed sidecars and inconsistent expe
161
214
  runnerProcessInstanceId: "runner-1",
162
215
  expectedWriters: { "0": 0 },
163
216
  writers: {
164
- "0": [{ processInstanceId: "unexpected-writer", kind: "pi-writer", attempt: 0, closeObservedAt: 10, exitCode: 0, signal: null }],
217
+ "0": [{ processInstanceId: "unexpected-writer", kind: "pi-writer", attempt: 0, closeObservedAt: 10, exitCode: 0, signal: null, processTree: { state: "observed", mechanism: "posix-process-group", processGroupId: 125, verifiedAt: 11 } }],
165
218
  },
166
219
  });
167
220
  fs.writeFileSync(path.join(asyncDir, "status.json"), JSON.stringify({ runId: "actual-run", state: "complete", lifecycleArtifactVersion: 3, steps: [{ agent: "worker", status: "complete" }] }));
@@ -19,7 +19,18 @@ describe("public subagent execution normalization", () => {
19
19
  );
20
20
  });
21
21
 
22
- it("rejects malformed public execution shapes", () => {
22
+ it("rejects private run fan-out fields at the public boundary", () => {
23
+ for (const params of [
24
+ { workflowScript: "return 1", runFanoutBudget: { version: 1 } },
25
+ { workflowScript: "return 1", runFanoutAdmitted: true },
26
+ ] as const) {
27
+ const result = normalizePublicSubagentExecution(params);
28
+ assert.equal(result.ok, false);
29
+ if (!result.ok) assert.match(result.error, /does not accept internal run fan-out fields/);
30
+ }
31
+ });
32
+
33
+ it("rejects removed public execution shapes", () => {
23
34
  for (const params of [
24
35
  { action: " " },
25
36
  { action: "single" },
@@ -146,7 +146,7 @@ describe("subagent extension RPC bridge", () => {
146
146
  assert.deepEqual(executedParams, { action: "status", id: "abc123" });
147
147
  assert.equal((reply as { data: { text?: string } }).data.text, "Run: abc123");
148
148
  assert.deepEqual((reply as { data: { fleet?: unknown } }).data.fleet, {
149
- version: 1, entries: [], totalActive: 0, omitted: 0,
149
+ version: 1, entries: [], totalActive: 0, topLevelAsyncCapacity: { used: 0, limit: 0 }, omitted: 0,
150
150
  });
151
151
 
152
152
  bridge.dispose();
@@ -174,8 +174,9 @@ describe("subagent extension RPC bridge", () => {
174
174
  assert.equal((fleet as { omitted?: number }).omitted, 0);
175
175
  assert.deepEqual(fleet.entries[0], {
176
176
  key: "fleet-1", agent: "reviewer", role: "opaque label", model: "anthropic/claude-opus-4-8:high", effort: "high",
177
- startedAt: 120, tokens: { input: 12, output: 34, total: 46 }, goal: "Review the diff",
177
+ startedAt: 120, tokens: { input: 12, output: 34, total: 46 },
178
178
  });
179
+ assert.equal(JSON.stringify(fleet).includes("Review the diff"), false);
179
180
  assert.equal(JSON.stringify(fleet).includes("async-private-id"), false);
180
181
  bridge.dispose();
181
182
  });
@@ -200,15 +201,13 @@ describe("subagent extension RPC bridge", () => {
200
201
  const malformedSurrogate = /[\ud800-\udbff](?![\udc00-\udfff])|(?<![\ud800-\udbff])[\udc00-\udfff]/u;
201
202
 
202
203
  assert.doesNotMatch(entry.agent, malformedSurrogate);
203
- assert.doesNotMatch(entry.goal, malformedSurrogate);
204
- assert.doesNotMatch(entry.goal, /[\r\n]/);
204
+ assert.equal(entry.goal, undefined);
205
205
  assert.ok(entry.agent.length <= 96);
206
- assert.ok(entry.goal.length <= 512);
207
206
  assert.match(entry.agent, /^worker broken/);
208
207
  bridge.dispose();
209
208
  });
210
209
 
211
- it("projects resolved foreground model, effort, split usage, and goal", async () => {
210
+ it("projects resolved foreground model, effort, and split usage without prompt goals", async () => {
212
211
  const events = new FakeEvents();
213
212
  const state = {
214
213
  currentSessionId: "session-123",
@@ -231,6 +230,7 @@ describe("subagent extension RPC bridge", () => {
231
230
  }]]),
232
231
  }]]),
233
232
  asyncJobs: new Map(),
233
+ activeAsyncCapacity: { used: 2, limit: 4 },
234
234
  } as any;
235
235
  state.foregroundControls.set("private-old", {
236
236
  runId: "private-old",
@@ -250,6 +250,7 @@ describe("subagent extension RPC bridge", () => {
250
250
  assert.deepEqual((reply as any).data.fleet, {
251
251
  version: 1,
252
252
  totalActive: 1,
253
+ topLevelAsyncCapacity: { used: 2, limit: 4 },
253
254
  omitted: 0,
254
255
  entries: [{
255
256
  key: "fleet-1",
@@ -258,7 +259,6 @@ describe("subagent extension RPC bridge", () => {
258
259
  effort: "high",
259
260
  startedAt: 100,
260
261
  tokens: { input: 321, output: 45, total: 366 },
261
- goal: "Implement the fix",
262
262
  }],
263
263
  });
264
264
  assert.equal(JSON.stringify((reply as any).data.fleet).includes("private-run"), false);
@@ -0,0 +1,121 @@
1
+ import assert from "node:assert/strict";
2
+ import * as fs from "node:fs";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
5
+ import { spawn } from "node:child_process";
6
+ import { afterEach, describe, it } from "node:test";
7
+ import {
8
+ claimRunFanoutBatch,
9
+ claimRunFanoutBatchWithCommit,
10
+ createRunFanoutBudget,
11
+ decodeRunFanoutBudgetDescriptor,
12
+ encodeRunFanoutBudgetDescriptor,
13
+ getRunFanoutBudgetSnapshot,
14
+ RunFanoutLimitError,
15
+ validateRunFanoutBudgetDescriptor,
16
+ } from "../../src/runs/shared/run-fanout-budget.ts";
17
+ import { resolveMaxSubagentSpawnsPerRun } from "../../src/shared/types.ts";
18
+
19
+ const directories: string[] = [];
20
+ const externalDirectories: string[] = [];
21
+ const previousEnv = process.env.SELESAI_SUBAGENT_MAX_SPAWNS_PER_RUN;
22
+
23
+ afterEach(() => {
24
+ for (const directory of directories.splice(0)) fs.rmSync(directory, { recursive: true, force: true });
25
+ for (const directory of externalDirectories.splice(0)) fs.rmSync(directory, { recursive: true, force: true });
26
+ if (previousEnv === undefined) delete process.env.SELESAI_SUBAGENT_MAX_SPAWNS_PER_RUN;
27
+ else process.env.SELESAI_SUBAGENT_MAX_SPAWNS_PER_RUN = previousEnv;
28
+ });
29
+
30
+ function budget(limit: number) {
31
+ const descriptor = createRunFanoutBudget(`test-${Date.now()}-${Math.random()}`, limit);
32
+ directories.push(descriptor.directory);
33
+ return descriptor;
34
+ }
35
+
36
+ describe("run fan-out budget", () => {
37
+ it("resolves environment over config and falls back to 64", () => {
38
+ delete process.env.SELESAI_SUBAGENT_MAX_SPAWNS_PER_RUN;
39
+ assert.equal(resolveMaxSubagentSpawnsPerRun(undefined), 64);
40
+ assert.equal(resolveMaxSubagentSpawnsPerRun(12), 12);
41
+ for (const invalid of [0, -1, 1.5, "bad"]) assert.equal(resolveMaxSubagentSpawnsPerRun(invalid as number), 64);
42
+ process.env.SELESAI_SUBAGENT_MAX_SPAWNS_PER_RUN = "7";
43
+ assert.equal(resolveMaxSubagentSpawnsPerRun(12), 7);
44
+ for (const invalid of ["0", "-1", "1.5", "bad"]) {
45
+ process.env.SELESAI_SUBAGENT_MAX_SPAWNS_PER_RUN = invalid;
46
+ assert.equal(resolveMaxSubagentSpawnsPerRun(undefined), 64);
47
+ }
48
+ });
49
+
50
+ it("persists claims and rejects the responsible next path at the exact cap", () => {
51
+ const descriptor = budget(2);
52
+ assert.deepEqual(claimRunFanoutBatch(descriptor, ["tasks[0]", "tasks[1]"]), { used: 2, limit: 2, remaining: 0 });
53
+ const restored = decodeRunFanoutBudgetDescriptor(encodeRunFanoutBudgetDescriptor(descriptor));
54
+ assert.deepEqual(getRunFanoutBudgetSnapshot(restored!), { used: 2, limit: 2, remaining: 0 });
55
+ assert.throws(() => claimRunFanoutBatch(descriptor, ["tasks[2]"]), (error: unknown) => {
56
+ assert.ok(error instanceof RunFanoutLimitError);
57
+ assert.equal(error.rejection.path, "tasks[2]");
58
+ assert.equal(error.rejection.used, 2);
59
+ assert.equal(error.rejection.remaining, 0);
60
+ return true;
61
+ });
62
+ });
63
+
64
+ it("rolls back only the failed admission batch", () => {
65
+ const descriptor = budget(2);
66
+ claimRunFanoutBatch(descriptor, ["single"]);
67
+ assert.throws(() => claimRunFanoutBatch(descriptor, ["chain[0]", "chain[1]"]), RunFanoutLimitError);
68
+ assert.deepEqual(getRunFanoutBudgetSnapshot(descriptor), { used: 1, limit: 2, remaining: 1 });
69
+ });
70
+
71
+ it("rolls back a batch when its commit fails", () => {
72
+ const descriptor = budget(2);
73
+ claimRunFanoutBatch(descriptor, ["existing"]);
74
+ assert.throws(
75
+ () => claimRunFanoutBatchWithCommit(descriptor, ["append"], () => { throw new Error("enqueue failed"); }),
76
+ /enqueue failed/,
77
+ );
78
+ assert.deepEqual(getRunFanoutBudgetSnapshot(descriptor), { used: 1, limit: 2, remaining: 1 });
79
+ });
80
+
81
+ it("reports claims-directory I/O errors instead of ordinary exhaustion", () => {
82
+ const descriptor = budget(2);
83
+ fs.rmSync(path.join(descriptor.directory, "claims"), { recursive: true });
84
+ fs.writeFileSync(path.join(descriptor.directory, "claims"), "not a directory", "utf-8");
85
+ assert.throws(() => getRunFanoutBudgetSnapshot(descriptor), /claims directory is unreadable.*ENOTDIR/);
86
+ });
87
+
88
+ it("admits exactly one simultaneous two-slot batch at limit two", async () => {
89
+ const moduleUrl = new URL("../../src/runs/shared/run-fanout-budget.ts", import.meta.url).href;
90
+ const script = `import { claimRunFanoutBatch } from ${JSON.stringify(moduleUrl)}; const descriptor = JSON.parse(process.argv[1]); try { claimRunFanoutBatch(descriptor, [process.argv[2] + "[0]", process.argv[2] + "[1]"]); process.stdout.write("admitted"); } catch { process.stdout.write("rejected"); }`;
91
+ for (let iteration = 0; iteration < 12; iteration++) {
92
+ const descriptor = budget(2);
93
+ const launch = (claimPath: string) => new Promise<string>((resolve, reject) => {
94
+ const child = spawn(process.execPath, ["--experimental-strip-types", "--input-type=module", "--eval", script, JSON.stringify(descriptor), claimPath], { stdio: ["ignore", "pipe", "pipe"] });
95
+ let stdout = "";
96
+ let stderr = "";
97
+ child.stdout.on("data", (chunk) => { stdout += chunk; });
98
+ child.stderr.on("data", (chunk) => { stderr += chunk; });
99
+ child.on("error", reject);
100
+ child.on("close", (code) => code === 0 ? resolve(stdout) : reject(new Error(stderr)));
101
+ });
102
+
103
+ assert.deepEqual((await Promise.all([launch("group-a"), launch("group-b")])).sort(), ["admitted", "rejected"], `iteration ${iteration}`);
104
+ assert.deepEqual(getRunFanoutBudgetSnapshot(descriptor), { used: 2, limit: 2, remaining: 0 });
105
+ }
106
+ });
107
+
108
+ it("fails closed when descriptor identity or managed-root containment is invalid", () => {
109
+ const descriptor = budget(2);
110
+ assert.throws(() => validateRunFanoutBudgetDescriptor({ ...descriptor, limit: 3 }), /does not match/);
111
+
112
+ const outside = fs.mkdtempSync(path.join(os.tmpdir(), "pi-run-fanout-outside-"));
113
+ externalDirectories.push(outside);
114
+ fs.mkdirSync(path.join(outside, "claims"));
115
+ fs.writeFileSync(path.join(outside, "manifest.json"), JSON.stringify({ version: 1, rootRunId: descriptor.rootRunId, limit: 2, createdAt: Date.now() }));
116
+ const linked = path.join(path.dirname(descriptor.directory), `linked-${Date.now()}`);
117
+ fs.symlinkSync(outside, linked, process.platform === "win32" ? "junction" : "dir");
118
+ directories.push(linked);
119
+ assert.throws(() => validateRunFanoutBudgetDescriptor({ ...descriptor, directory: linked }), /resolves outside/);
120
+ });
121
+ });
@@ -6,6 +6,7 @@ import { describe, it } from "node:test";
6
6
  import { updateActiveRunIndex } from "../../src/runs/background/active-run-index.ts";
7
7
  import { inspectSubagentStatus } from "../../src/runs/background/run-status.ts";
8
8
  import { createNestedRoute, writeNestedEvent } from "../../src/runs/shared/nested-events.ts";
9
+ import { claimRunFanoutBatch, createRunFanoutBudget, writeRunFanoutBudgetDescriptor } from "../../src/runs/shared/run-fanout-budget.ts";
9
10
  import { TEMP_ROOT_DIR, type SubagentState } from "../../src/shared/types.ts";
10
11
 
11
12
  function errno(code: string): NodeJS.ErrnoException {
@@ -22,6 +23,7 @@ function textContent(result: ReturnType<typeof inspectSubagentStatus>): string {
22
23
  describe("async run status inspection", () => {
23
24
  it("repairs stale running status and reports diagnosis plus result path", () => {
24
25
  const root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-run-status-stale-"));
26
+ let budgetDirectory: string | undefined;
25
27
  try {
26
28
  const asyncRoot = path.join(root, "runs");
27
29
  const resultsDir = path.join(root, "results");
@@ -40,6 +42,10 @@ describe("async run status inspection", () => {
40
42
  sessionFile,
41
43
  steps: [{ agent: "scout", status: "running", startedAt: 100, sessionFile }],
42
44
  }, null, 2), "utf-8");
45
+ const descriptor = createRunFanoutBudget("run-stale", 64);
46
+ budgetDirectory = descriptor.directory;
47
+ writeRunFanoutBudgetDescriptor(asyncDir, descriptor);
48
+ claimRunFanoutBatch(descriptor, ["single"]);
43
49
 
44
50
  const result = inspectSubagentStatus({ id: "run-stale" }, {
45
51
  asyncDirRoot: asyncRoot,
@@ -51,6 +57,8 @@ describe("async run status inspection", () => {
51
57
  const text = textContent(result);
52
58
  assert.equal(result.isError, undefined);
53
59
  assert.match(text, /State: failed/);
60
+ assert.match(text, /Run fan-out: 1\/64 used, 63 remaining/);
61
+ assert.deepEqual(result.details.runFanoutBudget, { used: 1, limit: 64, remaining: 63 });
54
62
  assert.match(text, /Diagnosis: Async runner process 12345 exited or disappeared/);
55
63
  assert.match(text, new RegExp(`Result: ${path.join(resultsDir, "run-stale.json").replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`));
56
64
  assert.match(text, /Step 1: scout failed, error: Async runner process 12345 exited or disappeared/);
@@ -60,6 +68,7 @@ describe("async run status inspection", () => {
60
68
  assert.equal(resultJson.results[0].sessionFile, sessionFile);
61
69
  } finally {
62
70
  fs.rmSync(root, { recursive: true, force: true });
71
+ if (budgetDirectory) fs.rmSync(budgetDirectory, { recursive: true, force: true });
63
72
  }
64
73
  });
65
74
 
@@ -236,12 +236,13 @@ describe("SubagentParams schema", { skip: !schemasAvailable ? "typebox not avail
236
236
  assert.ok(maxRuntimeSchema, "maxRuntimeMs schema should exist");
237
237
  assert.equal(timeoutSchema.minimum, 1);
238
238
  assert.equal(maxRuntimeSchema.minimum, 1);
239
- assert.match(String(timeoutSchema.description ?? ""), /foreground runs and async children/i);
240
- assert.match(String(timeoutSchema.description ?? ""), /async children default to 30m/i);
239
+ assert.match(String(timeoutSchema.description ?? ""), /foreground and single async runs/i);
240
+ assert.match(String(timeoutSchema.description ?? ""), /use config timeoutMs, else 30m/i);
241
241
  assert.match(String(timeoutSchema.description ?? ""), /async composites have no default parent deadline/i);
242
242
  assert.doesNotMatch(String(timeoutSchema.description ?? ""), /foreground-only/i);
243
243
  assert.match(String(maxRuntimeSchema.description ?? ""), /timeoutMs/i);
244
- assert.match(String(maxRuntimeSchema.description ?? ""), /async children default to 30m/i);
244
+ assert.match(String(maxRuntimeSchema.description ?? ""), /foreground and single async runs/i);
245
+ assert.match(String(maxRuntimeSchema.description ?? ""), /use config timeoutMs, else 30m/i);
245
246
  assert.match(String(maxRuntimeSchema.description ?? ""), /async composites have no default parent deadline/i);
246
247
  assert.equal(turnBudgetSchema?.properties?.maxTurns?.minimum, 1);
247
248
  assert.equal(turnBudgetSchema?.properties?.graceTurns?.minimum, 0);
@@ -0,0 +1,73 @@
1
+ import assert from "node:assert/strict";
2
+ import * as fs from "node:fs";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
5
+ import { describe, it } from "node:test";
6
+ import { resolveSessionLineage } from "../../src/shared/session-lineage.ts";
7
+
8
+ function sessionFile(dir: string, name: string, parentSession?: string): string {
9
+ const file = path.join(dir, name);
10
+ const header = {
11
+ type: "session",
12
+ version: 1,
13
+ id: name,
14
+ timestamp: new Date().toISOString(),
15
+ cwd: dir,
16
+ ...(parentSession ? { parentSession } : {}),
17
+ };
18
+ fs.writeFileSync(file, `${JSON.stringify(header)}\n`);
19
+ return file;
20
+ }
21
+
22
+ describe("session lineage", () => {
23
+ it("resolves self plus the parent chain from session headers", () => {
24
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "selesai-lineage-"));
25
+ const grandparent = sessionFile(dir, "a.jsonl");
26
+ const parent = sessionFile(dir, "b.jsonl", grandparent);
27
+ const current = sessionFile(dir, "c.jsonl", parent);
28
+ const lineage = resolveSessionLineage({ sessionManager: { getSessionFile: () => current, getHeader: () => ({ parentSession: parent }) } });
29
+ assert.deepEqual(lineage, [current, parent, grandparent]);
30
+ });
31
+
32
+ it("prefers an explicit parent hint over the header", () => {
33
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "selesai-lineage-"));
34
+ const hinted = sessionFile(dir, "hinted.jsonl");
35
+ const headed = sessionFile(dir, "headed.jsonl");
36
+ const current = sessionFile(dir, "current.jsonl", headed);
37
+ const lineage = resolveSessionLineage({ sessionManager: { getSessionFile: () => current, getHeader: () => ({ parentSession: headed }) } }, { parentHint: hinted });
38
+ assert.deepEqual(lineage, [current, hinted]);
39
+ });
40
+
41
+ it("bounds the walk depth", () => {
42
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "selesai-lineage-"));
43
+ const files: string[] = [];
44
+ for (let i = 0; i < 12; i++) files.push(sessionFile(dir, `s${i}.jsonl`, i > 0 ? files[i - 1]! : undefined));
45
+ const current = sessionFile(dir, "current.jsonl", files[11]);
46
+ const lineage = resolveSessionLineage({ sessionManager: { getSessionFile: () => current } }, { depth: 3 });
47
+ assert.equal(lineage.length, 3);
48
+ assert.equal(lineage[0], current);
49
+ assert.equal(lineage[1], files[11]);
50
+ assert.equal(lineage[2], files[10]);
51
+ });
52
+
53
+ it("stops at missing parent files", () => {
54
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "selesai-lineage-"));
55
+ const missing = path.join(dir, "missing.jsonl");
56
+ const current = sessionFile(dir, "current.jsonl", missing);
57
+ const lineage = resolveSessionLineage({ sessionManager: { getSessionFile: () => current, getHeader: () => ({ parentSession: missing }) } });
58
+ assert.deepEqual(lineage, [current]);
59
+ });
60
+
61
+ it("does not loop on cyclic parent chains", () => {
62
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "selesai-lineage-"));
63
+ const a = sessionFile(dir, "a.jsonl");
64
+ const b = sessionFile(dir, "b.jsonl", a);
65
+ fs.writeFileSync(a, `${JSON.stringify({ type: "session", version: 1, id: "a", timestamp: new Date().toISOString(), cwd: dir, parentSession: b })}\n`);
66
+ const lineage = resolveSessionLineage({ sessionManager: { getSessionFile: () => b, getHeader: () => ({ parentSession: a }) } });
67
+ assert.deepEqual(lineage, [b, a]);
68
+ });
69
+
70
+ it("handles session managers without getHeader or session file", () => {
71
+ assert.deepEqual(resolveSessionLineage({ sessionManager: { getSessionFile: () => null } }, { parentHint: "/nonexistent/parent.jsonl" }), []);
72
+ });
73
+ });