@selesai/code 0.8.5 → 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 (62) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/defaults/models.json +5 -0
  3. package/dist/extensions/agent-browser.test.ts +178 -2
  4. package/dist/extensions/agent-browser.ts +2 -0
  5. package/dist/extensions/copy-turn.test.ts +229 -0
  6. package/dist/extensions/copy-turn.ts +11 -0
  7. package/dist/extensions/grep-app/index.test.ts +356 -1
  8. package/dist/extensions/grep-app/index.ts +6 -1
  9. package/dist/extensions/handoff-new.test.ts +351 -161
  10. package/dist/extensions/handoff-new.ts +3 -0
  11. package/dist/extensions/inline-skills.test.ts +93 -0
  12. package/dist/extensions/inline-skills.ts +3 -0
  13. package/dist/extensions/pi-subagents/agents/builder.md +5 -2
  14. package/dist/extensions/pi-subagents/agents/commentator.md +1 -1
  15. package/dist/extensions/pi-subagents/docs/configuration.md +8 -0
  16. package/dist/extensions/pi-subagents/docs/workflows.md +1 -1
  17. package/dist/extensions/pi-subagents/skills/pi-subagents/references/execution-controls.md +1 -1
  18. package/dist/extensions/pi-subagents/src/extension/config.ts +6 -0
  19. package/dist/extensions/pi-subagents/src/extension/index.ts +83 -14
  20. package/dist/extensions/pi-subagents/src/runs/background/async-job-tracker.ts +12 -6
  21. package/dist/extensions/pi-subagents/src/runs/background/async-status.ts +3 -0
  22. package/dist/extensions/pi-subagents/src/runs/background/notify.ts +5 -2
  23. package/dist/extensions/pi-subagents/src/runs/background/result-watcher.ts +19 -1
  24. package/dist/extensions/pi-subagents/src/runs/background/retained-children.ts +2 -2
  25. package/dist/extensions/pi-subagents/src/runs/background/run-id-resolver.ts +5 -2
  26. package/dist/extensions/pi-subagents/src/runs/background/run-status.ts +7 -5
  27. package/dist/extensions/pi-subagents/src/runs/background/wait-subscriptions.ts +2 -1
  28. package/dist/extensions/pi-subagents/src/runs/foreground/foreground-history.ts +7 -4
  29. package/dist/extensions/pi-subagents/src/runs/foreground/subagent-executor.ts +39 -9
  30. package/dist/extensions/pi-subagents/src/shared/session-lineage.ts +71 -0
  31. package/dist/extensions/pi-subagents/src/shared/types.ts +16 -0
  32. package/dist/extensions/pi-subagents/src/tui/fleet.ts +1 -1
  33. package/dist/extensions/pi-subagents/src/workflows/workflow-auto-relaunch.ts +28 -0
  34. package/dist/extensions/pi-subagents/test/unit/handoff-adoption.test.ts +103 -0
  35. package/dist/extensions/pi-subagents/test/unit/index-child-registration.test.ts +22 -0
  36. package/dist/extensions/pi-subagents/test/unit/session-lineage.test.ts +73 -0
  37. package/dist/extensions/pi-subagents/test/unit/workflow-auto-relaunch.test.ts +45 -0
  38. package/dist/extensions/ponytail/index.js +11 -0
  39. package/dist/extensions/ponytail/ponytail-config.cjs +2 -0
  40. package/dist/extensions/ponytail/ponytail-instructions.cjs +6 -0
  41. package/dist/extensions/ponytail/test/extension.test.js +274 -140
  42. package/dist/extensions/ponytail/test/helpers.test.js +280 -92
  43. package/dist/extensions/question/index.ts +18 -0
  44. package/dist/extensions/question/question-list.ts +22 -0
  45. package/dist/extensions/question/tests/batch.test.ts +103 -65
  46. package/dist/extensions/question/tests/helpers.test.ts +68 -35
  47. package/dist/extensions/question/tests/question-list.test.ts +506 -204
  48. package/dist/extensions/question/tests/row-layout.test.ts +180 -114
  49. package/dist/extensions/question/tests/schemas.test.ts +42 -19
  50. package/dist/extensions/question/tests/shortcuts.test.ts +106 -84
  51. package/dist/extensions/question/tests/wizard.test.ts +771 -50
  52. package/dist/extensions/rtk.test.ts +180 -1
  53. package/dist/extensions/tokenin-onboarding.ts +3 -0
  54. package/dist/extensions/undo.test.ts +720 -0
  55. package/dist/extensions/undo.ts +6 -0
  56. package/dist/extensions/web-agent-onboarding.test.ts +415 -0
  57. package/dist/extensions/workflow/extension.ts +3 -3
  58. package/dist/extensions/workflow/modes.ts +41 -19
  59. package/dist/skills/pi-subagents/references/execution-controls.md +1 -1
  60. package/docs/plans/workflow-autoloop-reference.md +1 -2
  61. package/docs/plans/workflow-handoff-carryover.md +108 -0
  62. package/package.json +4 -1
@@ -0,0 +1,71 @@
1
+ import * as fs from "node:fs";
2
+
3
+ /**
4
+ * Resolve the chain of session identities whose workflow estate the current
5
+ * session should adopt. Session files record their parent in the header's
6
+ * `parentSession` field, so a fresh session created by handoff/new/fork can
7
+ * walk back to every predecessor and re-attach its persisted async runs,
8
+ * results, retained children, and wait subscriptions.
9
+ */
10
+
11
+ export interface SessionLineageInput {
12
+ sessionManager: {
13
+ getSessionFile(): string | null | undefined;
14
+ getHeader?(): { parentSession?: string | null } | null;
15
+ };
16
+ }
17
+
18
+ export interface SessionLineageOptions {
19
+ /** Maximum number of session identities retained in the lineage. Defaults to 8. */
20
+ depth?: number;
21
+ /** Explicit parent session file (e.g. SessionStartEvent.previousSessionFile). */
22
+ parentHint?: string | null;
23
+ }
24
+
25
+ const MAX_HEADER_BYTES = 2048;
26
+
27
+ function readParentSessionHeader(sessionFile: string): string | undefined {
28
+ let text: string;
29
+ try {
30
+ const fd = fs.openSync(sessionFile, "r");
31
+ try {
32
+ const buffer = Buffer.alloc(MAX_HEADER_BYTES);
33
+ const read = fs.readSync(fd, buffer, 0, buffer.length, 0);
34
+ text = buffer.subarray(0, read).toString("utf-8");
35
+ } finally {
36
+ fs.closeSync(fd);
37
+ }
38
+ } catch {
39
+ return undefined;
40
+ }
41
+ const newline = text.indexOf("\n");
42
+ const line = newline >= 0 ? text.slice(0, newline) : text;
43
+ if (!line.trim()) return undefined;
44
+ try {
45
+ const header = JSON.parse(line) as { type?: unknown; parentSession?: unknown };
46
+ if (header.type !== "session") return undefined;
47
+ return typeof header.parentSession === "string" && header.parentSession ? header.parentSession : undefined;
48
+ } catch {
49
+ return undefined;
50
+ }
51
+ }
52
+
53
+ export function resolveSessionLineage(input: SessionLineageInput, options: SessionLineageOptions = {}): string[] {
54
+ const depth = Math.max(1, Math.floor(options.depth ?? 8));
55
+ const lineage: string[] = [];
56
+ const seen = new Set<string>();
57
+ const current = input.sessionManager.getSessionFile();
58
+ if (current) {
59
+ lineage.push(current);
60
+ seen.add(current);
61
+ }
62
+ let parent = options.parentHint ?? input.sessionManager.getHeader?.()?.parentSession;
63
+ if (!parent && current) parent = readParentSessionHeader(current);
64
+ while (parent && lineage.length < depth && !seen.has(parent)) {
65
+ if (!fs.existsSync(parent)) break;
66
+ lineage.push(parent);
67
+ seen.add(parent);
68
+ parent = readParentSessionHeader(parent);
69
+ }
70
+ return lineage;
71
+ }
@@ -1361,6 +1361,8 @@ export interface AsyncStatus {
1361
1361
  pendingAppends?: number;
1362
1362
  parallelGroups?: AsyncParallelGroupStatus[];
1363
1363
  workflowGraph?: WorkflowGraphSnapshot;
1364
+ /** Auto-relaunch count when a workflow budget result triggered a fresh top-level run. */
1365
+ workflowRelaunchCount?: number;
1364
1366
  checkpoint?: ChainCheckpointState;
1365
1367
  processTerminal?: ProcessTerminalV1;
1366
1368
  runFanoutBudget?: RunFanoutBudgetSnapshot;
@@ -1460,6 +1462,8 @@ export type AsyncJobStep = NonNullable<AsyncStatus["steps"]>[number] & {
1460
1462
  export interface AsyncJobState {
1461
1463
  asyncId: string;
1462
1464
  asyncDir: string;
1465
+ /** True when the job was adopted from a previous session via lineage carry-over. */
1466
+ adopted?: boolean;
1463
1467
  /** Parent-resolved launch directory retained for trusted live artifact lookup. */
1464
1468
  cwd?: string;
1465
1469
  status: "queued" | "running" | "complete" | "failed" | "paused" | "stopped" | "rejected";
@@ -1654,6 +1658,12 @@ export interface ActiveAsyncCapacitySnapshot {
1654
1658
  export interface SubagentState {
1655
1659
  baseCwd: string;
1656
1660
  currentSessionId: string | null;
1661
+ /** Ordered session identities (session file paths) whose workflow estate this session adopts, self first. */
1662
+ sessionLineage?: string[];
1663
+ /** Earliest result-file mtime eligible for adoption from lineage sessions (replay guard). */
1664
+ adoptedResultWindowStart?: number;
1665
+ /** Set when a session switch adopted live workflows; consumed on the first settled turn to inject a carry-over summary. */
1666
+ handoffResumePending?: boolean;
1657
1667
  /** Runtime-owned artifact resolution inputs used by Fleet transcript targeting. */
1658
1668
  artifactDirPreference?: ArtifactDirPreference;
1659
1669
  /** Runtime authority snapshot used by optional inspector controls. */
@@ -1907,6 +1917,12 @@ export interface ExtensionConfig {
1907
1917
  maxSubagentSpawnsPerSession?: number;
1908
1918
  /** Cumulative logical-child cap for one top-level run tree. Defaults to 64. */
1909
1919
  maxSubagentSpawnsPerRun?: number;
1920
+ /**
1921
+ * Max auto-relaunches when a scripted workflow ends with a fan-out budget
1922
+ * result before the goal is clean. Each relaunch is a fresh top-level run
1923
+ * with a new fan-out budget. Defaults to 12; 0 means unlimited.
1924
+ */
1925
+ maxWorkflowAutoRelaunches?: number;
1910
1926
  /** Optional active top-level async run cap per parent session. Unset or 0 means unlimited. */
1911
1927
  maxActiveAsyncRunsPerSession?: number;
1912
1928
  /** Global cap on simultaneously-running subagent tasks within a single run. Defaults to 20. */
@@ -239,7 +239,7 @@ export function collectFleetSnapshot(
239
239
  if (options.asyncDirRoot !== undefined) {
240
240
  const trackedIds = new Set(trackedRuns.map((run) => run.id));
241
241
  const history = listAsyncRuns(options.asyncDirRoot, {
242
- ...(state.currentSessionId ? { sessionId: state.currentSessionId } : {}),
242
+ ...(state.sessionLineage?.length ? { sessionIds: state.sessionLineage } : state.currentSessionId ? { sessionId: state.currentSessionId } : {}),
243
243
  entryLimit: MAX_FLEET_HISTORY_CANDIDATES,
244
244
  resultsDir: options.resultsDir ?? DIRS.results,
245
245
  reconcile: false,
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Auto-relaunch policy for scripted workflows that end before the goal is clean
3
+ * because the per-run fan-out budget was exhausted.
4
+ *
5
+ * A `budget` result means the goal was demonstrably not reached, so the only
6
+ * decision is whether to relaunch. `clean` and failures always surface to the
7
+ * parent — the parent remains the authority on whether the goal is met.
8
+ */
9
+
10
+ export const DEFAULT_MAX_WORKFLOW_AUTO_RELAUNCHES = 12;
11
+
12
+ export interface AutoRelaunchDecision {
13
+ relaunch: boolean;
14
+ capped: boolean;
15
+ }
16
+
17
+ export function resolveMaxWorkflowAutoRelaunches(raw: unknown): number | undefined {
18
+ if (raw === undefined) return DEFAULT_MAX_WORKFLOW_AUTO_RELAUNCHES;
19
+ if (typeof raw !== "number" || !Number.isInteger(raw) || raw < 0) return DEFAULT_MAX_WORKFLOW_AUTO_RELAUNCHES;
20
+ return raw === 0 ? undefined : raw;
21
+ }
22
+
23
+ export function resolveAutoRelaunchDecision(value: unknown, relaunched: number, cap: number | undefined): AutoRelaunchDecision {
24
+ const budgetExhausted = !!value && typeof value === "object" && !Array.isArray(value) && (value as Record<string, unknown>).result === "budget";
25
+ if (!budgetExhausted) return { relaunch: false, capped: false };
26
+ if (cap === undefined) return { relaunch: true, capped: false };
27
+ return relaunched >= cap ? { relaunch: false, capped: true } : { relaunch: true, capped: false };
28
+ }
@@ -0,0 +1,103 @@
1
+ import assert from "node:assert/strict";
2
+ import { execFileSync } from "node:child_process";
3
+ import { fileURLToPath } from "node:url";
4
+ import path from "node:path";
5
+ import { describe, it } from "node:test";
6
+
7
+ const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
8
+
9
+ describe("handoff workflow carry-over", () => {
10
+ it("adopts the previous session's active async workflows and tells the agent", () => {
11
+ const script = String.raw`
12
+ import os from "node:os";
13
+ import path from "node:path";
14
+ import fs from "node:fs";
15
+ import { DIRS } from "./src/shared/types.ts";
16
+ import registerSubagentExtension from "./index.ts";
17
+ const handlers = new Map();
18
+ const events = { listeners: new Map(), on(name, handler) { this.listeners.set(name, handler); return () => this.listeners.delete(name); }, emit(name, payload) { this.listeners.get(name)?.(payload); } };
19
+ const sent = [];
20
+ const widgets = [];
21
+ const pi = new Proxy({
22
+ events,
23
+ on(name, handler) { handlers.set(name, handler); },
24
+ registerTool() {}, registerCommand() {}, registerShortcut() {}, registerMessageRenderer() {},
25
+ sendMessage(message, options) { sent.push({ message, options }); }, getSessionName() { return undefined; },
26
+ }, { get(target, prop) { return prop in target ? target[prop] : () => undefined; } });
27
+
28
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "selesai-handoff-"));
29
+ const oldSession = path.join(tmp, "old-session.jsonl");
30
+ const newSession = path.join(tmp, "new-session.jsonl");
31
+ fs.writeFileSync(oldSession, JSON.stringify({ type: "session", version: 1, id: "old-session", timestamp: new Date().toISOString(), cwd: process.cwd() }) + "\n");
32
+ fs.writeFileSync(newSession, JSON.stringify({ type: "session", version: 1, id: "new-session", timestamp: new Date().toISOString(), cwd: process.cwd(), parentSession: oldSession }) + "\n");
33
+
34
+ // One active async workflow owned by the previous session.
35
+ const runId = "adopted-workflow-run";
36
+ const asyncDir = path.join(DIRS.async, runId);
37
+ fs.mkdirSync(asyncDir, { recursive: true });
38
+ fs.writeFileSync(path.join(asyncDir, "status.json"), JSON.stringify({
39
+ runId,
40
+ state: "running",
41
+ sessionId: oldSession,
42
+ mode: "workflow",
43
+ workflowKey: "task",
44
+ agent: "builder",
45
+ startedAt: Date.now(),
46
+ steps: [{ agent: "builder", status: "running", phase: "build-1", description: "Implement the carry-over plan" }],
47
+ }));
48
+ fs.mkdirSync(path.join(DIRS.async, ".active-runs"), { recursive: true });
49
+ fs.writeFileSync(path.join(DIRS.async, ".active-runs", runId), "");
50
+
51
+ // A result file for an old-session run written during the switch window.
52
+ const resultRunId = "adopted-completed-run";
53
+ const resultPath = path.join(DIRS.results, resultRunId + ".json");
54
+ fs.mkdirSync(DIRS.results, { recursive: true });
55
+ fs.writeFileSync(resultPath, JSON.stringify({ runId: resultRunId, sessionId: oldSession, agent: "explorer", summary: "done", success: true }));
56
+
57
+ const ctx = { cwd: process.cwd(), hasUI: true, ui: { setWidget(key, value) { widgets.push([key, value]); }, requestRender() {}, onTerminalInput() { return () => {}; }, getEditorText() { return ""; }, notify() {}, theme: { fg(_name, text) { return text; }, bg(_name, text) { return text; }, bold(text) { return text; } } }, sessionManager: { getSessionId() { return "new-session"; }, getSessionFile() { return newSession; }, getHeader() { return { parentSession: oldSession }; }, getEntries() { return []; } }, modelRegistry: { getAvailable() { return []; } } };
58
+
59
+ registerSubagentExtension(pi);
60
+ handlers.get("session_start")({ reason: "new", previousSessionFile: oldSession }, ctx);
61
+ sent.length = 0;
62
+
63
+ // No handoff notice until a turn settles.
64
+ if (sent.length !== 0) throw new Error("handoff notice sent before the first settled turn");
65
+
66
+ handlers.get("agent_settled")();
67
+ const handoff = sent.filter((entry) => entry.message?.customType === "subagent-handoff-resume");
68
+ if (handoff.length !== 1) throw new Error(JSON.stringify(sent.map((entry) => entry.message)));
69
+ if (handoff[0].options?.triggerTurn !== true) throw new Error("handoff notice did not trigger a turn");
70
+ if (typeof handoff[0].message?.content !== "string" || !handoff[0].message.content.includes(runId) || !handoff[0].message.content.includes("carried over")) {
71
+ throw new Error("handoff notice lacks the adopted workflow: " + JSON.stringify(handoff[0].message));
72
+ }
73
+
74
+ // Only once per adoption.
75
+ handlers.get("agent_settled")();
76
+ if (sent.filter((entry) => entry.message?.customType === "subagent-handoff-resume").length !== 1) throw new Error("handoff notice repeated");
77
+
78
+ // Compaction resume message now lists the live workflows.
79
+ handlers.get("session_before_compact")({ reason: "threshold", signal: new AbortController().signal });
80
+ handlers.get("session_compact")({ reason: "threshold" });
81
+ const compaction = sent.filter((entry) => entry.message?.customType === "subagent-compaction-resume");
82
+ if (compaction.length !== 1) throw new Error("compaction resume missing: " + JSON.stringify(sent.map((entry) => entry.message?.customType)));
83
+ if (typeof compaction[0].message?.content !== "string" || !compaction[0].message.content.includes(runId)) {
84
+ throw new Error("compaction resume lacks the workflow manifest: " + JSON.stringify(compaction[0].message));
85
+ }
86
+
87
+ // Adopted old-session result gets delivered and cleaned up (batched
88
+ // notification debounces, so poll with a generous bound).
89
+ let delivered = false;
90
+ for (let attempt = 0; attempt < 40 && !delivered; attempt++) {
91
+ await new Promise((resolve) => setTimeout(resolve, 250));
92
+ delivered = !fs.existsSync(resultPath);
93
+ }
94
+ if (!delivered) throw new Error("adopted result file was not delivered");
95
+
96
+ await handlers.get("session_shutdown")();
97
+ `;
98
+ const env = { ...process.env };
99
+ delete env.SELESAI_SUBAGENT_CHILD;
100
+ execFileSync(process.execPath, ["--experimental-strip-types", "--import", "./test/support/register-loader.mjs", "--input-type=module", "--eval", script], { cwd: projectRoot, env, stdio: "pipe" });
101
+ assert.ok(true);
102
+ });
103
+ });
@@ -103,6 +103,28 @@ describe("subagent extension child mode", () => {
103
103
  execFileSync(process.execPath, ["--experimental-strip-types", "--import", "./test/support/register-loader.mjs", "--input-type=module", "--eval", script], { cwd: projectRoot, env: parentToolEnv(), stdio: "pipe" });
104
104
  });
105
105
 
106
+ it("declares packaged prompts through resources_discover", () => {
107
+ const script = String.raw`
108
+ import registerSubagentExtension from "./index.ts";
109
+ import * as fs from "node:fs";
110
+ const handlers = new Map();
111
+ const fakePi = new Proxy({
112
+ events: { on() { return () => {}; }, emit() {} },
113
+ registerTool() {}, registerCommand() {}, registerShortcut() {}, registerMessageRenderer() {}, sendMessage() {}, getSessionName() {},
114
+ on(event, handler) { handlers.set(event, handler); },
115
+ }, { get(target, prop) { return prop in target ? target[prop] : () => undefined; } });
116
+ registerSubagentExtension(fakePi);
117
+ const handler = handlers.get("resources_discover");
118
+ if (!handler) throw new Error("resources_discover handler not registered");
119
+ const result = handler({ type: "resources_discover", cwd: process.cwd(), reason: "startup" });
120
+ if (!result || !Array.isArray(result.promptPaths) || result.promptPaths.length !== 1) throw new Error("expected one promptPath, got " + JSON.stringify(result));
121
+ const promptDir = result.promptPaths[0];
122
+ if (!fs.existsSync(promptDir + "/review-loop.md")) throw new Error("review-loop.md not found under " + promptDir);
123
+ if (!fs.existsSync(promptDir + "/parallel-review.md")) throw new Error("parallel-review.md not found under " + promptDir);
124
+ `;
125
+ execFileSync(process.execPath, ["--experimental-strip-types", "--import", "./test/support/register-loader.mjs", "--input-type=module", "--eval", script], { cwd: projectRoot, env: parentToolEnv(), stdio: "pipe" });
126
+ });
127
+
106
128
  it("shows omitted workflow async as background even when asyncByDefault is false", () => {
107
129
  const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-subagents-workflow-manifest-config-"));
108
130
  try {
@@ -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
+ });
@@ -0,0 +1,45 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import {
4
+ DEFAULT_MAX_WORKFLOW_AUTO_RELAUNCHES,
5
+ resolveAutoRelaunchDecision,
6
+ resolveMaxWorkflowAutoRelaunches,
7
+ } from "../../src/workflows/workflow-auto-relaunch.ts";
8
+
9
+ describe("workflow auto-relaunch policy", () => {
10
+ it("relaunches on a budget result while under the cap", () => {
11
+ const decision = resolveAutoRelaunchDecision({ result: "budget", rounds: 3 }, 2, 12);
12
+ assert.deepEqual(decision, { relaunch: true, capped: false });
13
+ });
14
+
15
+ it("does not relaunch a clean result", () => {
16
+ const decision = resolveAutoRelaunchDecision({ result: "clean", rounds: 3 }, 0, 12);
17
+ assert.deepEqual(decision, { relaunch: false, capped: false });
18
+ });
19
+
20
+ it("does not relaunch non-workflow values", () => {
21
+ for (const value of [undefined, null, 5, "budget", ["budget"], { result: "failed" }]) {
22
+ const decision = resolveAutoRelaunchDecision(value, 0, 12);
23
+ assert.deepEqual(decision, { relaunch: false, capped: false });
24
+ }
25
+ });
26
+
27
+ it("marks the cap as reached when the relaunch count equals the cap", () => {
28
+ const decision = resolveAutoRelaunchDecision({ result: "budget" }, 12, 12);
29
+ assert.deepEqual(decision, { relaunch: false, capped: true });
30
+ });
31
+
32
+ it("relaunches indefinitely when the cap is unlimited", () => {
33
+ const decision = resolveAutoRelaunchDecision({ result: "budget" }, 50, undefined);
34
+ assert.deepEqual(decision, { relaunch: true, capped: false });
35
+ });
36
+
37
+ it("resolves the configured cap with a safe default and 0 as unlimited", () => {
38
+ assert.equal(resolveMaxWorkflowAutoRelaunches(undefined), DEFAULT_MAX_WORKFLOW_AUTO_RELAUNCHES);
39
+ assert.equal(resolveMaxWorkflowAutoRelaunches(5), 5);
40
+ assert.equal(resolveMaxWorkflowAutoRelaunches(0), undefined);
41
+ assert.equal(resolveMaxWorkflowAutoRelaunches(-3), DEFAULT_MAX_WORKFLOW_AUTO_RELAUNCHES);
42
+ assert.equal(resolveMaxWorkflowAutoRelaunches("12"), DEFAULT_MAX_WORKFLOW_AUTO_RELAUNCHES);
43
+ assert.equal(resolveMaxWorkflowAutoRelaunches(2.5), DEFAULT_MAX_WORKFLOW_AUTO_RELAUNCHES);
44
+ });
45
+ });
@@ -16,6 +16,9 @@ export { filterSkillBodyForMode };
16
16
  export const readDefaultMode = getDefaultMode;
17
17
 
18
18
  export function resolveSessionMode(entries, fallbackMode = DEFAULT_MODE) {
19
+ // Unreachable: normalizePersistedMode always returns a valid mode or null,
20
+ // and the caller passes a valid default.
21
+ /* v8 ignore next 1 */
19
22
  const fallback = normalizePersistedMode(fallbackMode) || DEFAULT_MODE;
20
23
  if (!Array.isArray(entries)) return fallback;
21
24
 
@@ -59,6 +62,9 @@ export default function ponytailExtension(pi) {
59
62
 
60
63
  const setMode = (mode, ctx) => {
61
64
  const normalized = normalizePersistedMode(mode);
65
+ // Unreachable: setMode is only called with validated modes from the
66
+ // command handler and the deactivation input handler.
67
+ /* v8 ignore next 1 */
62
68
  if (!normalized) return;
63
69
 
64
70
  currentMode = normalized;
@@ -68,6 +74,8 @@ export default function ponytailExtension(pi) {
68
74
 
69
75
  const sendAlias = (skillName, args, ctx) => {
70
76
  const normalized = String(args || "").trim();
77
+ // Unreachable: the alias command handlers always pass empty args.
78
+ /* v8 ignore next 1 */
71
79
  const message = normalized ? `${skillName} ${normalized}` : skillName;
72
80
 
73
81
  if (ctx?.isIdle?.() === false) {
@@ -91,6 +99,9 @@ export default function ponytailExtension(pi) {
91
99
 
92
100
  if (parsed.type === "set-default") {
93
101
  const written = writeDefaultMode(parsed.mode);
102
+ // Unreachable: parsePonytailCommand only returns set-default for
103
+ // valid config modes, so writeDefaultMode always succeeds.
104
+ /* v8 ignore next 1 */
94
105
  if (written) {
95
106
  configuredDefaultMode = getDefaultMode();
96
107
  const message = configuredDefaultMode === written
@@ -57,6 +57,8 @@ function getConfigDir() {
57
57
  }
58
58
  if (process.platform === 'win32') {
59
59
  return path.join(
60
+ // Unreachable: APPDATA is always set on Windows.
61
+ /* v8 ignore next 1 */
60
62
  process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'),
61
63
  'ponytail'
62
64
  );
@@ -14,6 +14,9 @@ const COMPACT_MODE_RULES = {
14
14
  };
15
15
 
16
16
  function filterSkillBodyForMode(body, mode) {
17
+ // Unreachable: normalizeMode returns a valid mode or null, and the caller
18
+ // passes a valid mode.
19
+ /* v8 ignore next 1 */
17
20
  const effectiveMode = normalizeMode(mode) || DEFAULT_MODE;
18
21
  const withoutFrontmatter = String(body || '').replace(/^---[\s\S]*?---\s*/, '');
19
22
 
@@ -90,6 +93,9 @@ function getPonytailInstructions(mode, options) {
90
93
  return 'PONYTAIL MODE ACTIVE — level: ' + configuredMode + '. Behavior defined by /ponytail-' + configuredMode + ' skill.';
91
94
  }
92
95
 
96
+ // Unreachable: normalizePersistedMode above already falls back to
97
+ // DEFAULT_MODE, so normalizeMode always returns a valid mode here.
98
+ /* v8 ignore next 1 */
93
99
  const effectiveMode = normalizeMode(configuredMode) || DEFAULT_MODE;
94
100
  if (options?.compact === true) return getCompactInstructions(effectiveMode);
95
101