@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
@@ -309,4 +309,32 @@ describe("finalizeSingleOutput", () => {
309
309
 
310
310
  assert.equal(result.displayOutput, "truncated output");
311
311
  });
312
+
313
+ it("preserves the saved-output reference for acceptance-only failures", () => {
314
+ const result = finalizeSingleOutput({
315
+ fullOutput: "useful output",
316
+ outputPath: "/tmp/review.md",
317
+ outputMode: "file-only",
318
+ exitCode: 1,
319
+ preserveSavedOutput: true,
320
+ savedPath: "/tmp/review.md",
321
+ });
322
+
323
+ assert.match(result.displayOutput, /^Output saved to:/);
324
+ assert.doesNotMatch(result.displayOutput, /useful output/);
325
+ });
326
+
327
+ it("does not duplicate an existing saved-output reference", () => {
328
+ const outputReference = formatSavedOutputReference("/tmp/review.md", "useful output");
329
+ const result = finalizeSingleOutput({
330
+ fullOutput: `useful output\n\n${outputReference.message}`,
331
+ outputPath: "/tmp/review.md",
332
+ exitCode: 1,
333
+ preserveSavedOutput: true,
334
+ savedPath: "/tmp/review.md",
335
+ outputReference,
336
+ });
337
+
338
+ assert.equal(result.displayOutput.match(/Output saved to:/g)?.length, 1);
339
+ });
312
340
  });
@@ -2,11 +2,12 @@ import assert from "node:assert/strict";
2
2
  import * as fs from "node:fs";
3
3
  import * as os from "node:os";
4
4
  import * as path from "node:path";
5
- import { describe, it } from "node:test";
5
+ import { afterEach, describe, it } from "node:test";
6
6
  import { writeAtomicJson, writePrivateAtomicJson } from "../../src/shared/atomic-json.ts";
7
7
  import { closeSteerInbox, interruptRequestPath, steerRequestsDir, writeSteerAck, type SteerRequest } from "../../src/runs/background/control-channel.ts";
8
8
  import { steerAsyncRun } from "../../src/runs/foreground/async-steering-action.ts";
9
9
  import { createSteeringStatus, recordSteeringRequest, updateSteeringTarget } from "../../src/runs/background/steering.ts";
10
+ import { createRunFanoutBudget } from "../../src/runs/shared/run-fanout-budget.ts";
10
11
  import { ASYNC_DIR, type AsyncStatus, type Details, type SteeringRecoveryDescriptor, type SteeringTargetState, type SubagentState } from "../../src/shared/types.ts";
11
12
 
12
13
  function createState(): SubagentState {
@@ -29,6 +30,11 @@ function createState(): SubagentState {
29
30
  }
30
31
 
31
32
  let statusWriteMtimeMs = Date.now();
33
+ const budgetDirectories: string[] = [];
34
+
35
+ afterEach(() => {
36
+ for (const directory of budgetDirectories.splice(0)) fs.rmSync(directory, { recursive: true, force: true });
37
+ });
32
38
 
33
39
  function writeStatus(asyncDir: string, status: AsyncStatus): void {
34
40
  fs.mkdirSync(asyncDir, { recursive: true });
@@ -91,8 +97,11 @@ async function readRequest(asyncDir: string): Promise<SteerRequest> {
91
97
  }
92
98
 
93
99
  function recoveryDescriptor(runId: string): SteeringRecoveryDescriptor {
100
+ const runFanoutBudget = createRunFanoutBudget(runId, 64);
101
+ budgetDirectories.push(runFanoutBudget.directory);
94
102
  return {
95
103
  version: 1,
104
+ runFanoutBudget,
96
105
  sourceRunId: runId,
97
106
  agent: "worker-0",
98
107
  cwd: os.tmpdir(),
@@ -61,6 +61,45 @@ describe("classifyTaskMutationIntent", () => {
61
61
  assert.equal(classifyTaskMutationIntent("worker", "Create a summary").kind, "unknown");
62
62
  });
63
63
 
64
+ it("does not read hyphenated fix adjectives as the fix verb", () => {
65
+ // Core regression: "must-fix items" in review output specs (fails on old code).
66
+ assert.equal(classifyTaskMutationIntent("worker", "Return a review with the top 2-3 must-fix items").kind, "unknown");
67
+ assert.equal(classifyTaskMutationIntent("worker", "Return a review with the top 2-3 must-fix items.").kind, "unknown");
68
+ assert.equal(classifyTaskMutationIntent("worker", "Review and report MUST-FIX items").kind, "unknown");
69
+ // should-fix with a noun from the pattern's alternation (also fails on old code).
70
+ assert.equal(classifyTaskMutationIntent("worker", "List should-fix tests with severity labels").kind, "unknown");
71
+ // Same class for other verbs: hyphenated adjective + verb.
72
+ assert.equal(classifyTaskMutationIntent("worker", "Return must-edit findings").kind, "unknown");
73
+ assert.equal(classifyTaskMutationIntent("worker", "Return must-update items").kind, "unknown");
74
+ assert.equal(classifyTaskMutationIntent("worker", "List should-add files").kind, "unknown");
75
+ assert.equal(classifyTaskMutationIntent("worker", "Return must-apply changes").kind, "unknown");
76
+ assert.equal(classifyTaskMutationIntent("worker", "List should-make changes").kind, "unknown");
77
+ assert.equal(classifyTaskMutationIntent("worker", "must-do those fixes").kind, "unknown");
78
+ // Dash coverage: U+2010 hyphen, U+2011 non-breaking hyphen, en/em dashes.
79
+ assert.equal(classifyTaskMutationIntent("worker", "Return the must\u2010fix items").kind, "unknown");
80
+ assert.equal(classifyTaskMutationIntent("worker", "Return the must\u2011fix items").kind, "unknown");
81
+ assert.equal(classifyTaskMutationIntent("worker", "Return the must\u2013fix items").kind, "unknown");
82
+ assert.equal(classifyTaskMutationIntent("worker", "Return the must\u2014fix items").kind, "unknown");
83
+ // Clause-level em/en dashes are punctuation, not compounds: "branch—fix it".
84
+ assert.equal(classifyTaskMutationIntent("worker", "Inspect the failing branch\u2014fix it.").kind, "implementation");
85
+ assert.equal(classifyTaskMutationIntent("worker", "Inspect the failing branch\u2013fix it.").kind, "implementation");
86
+ assert.equal(taskMayMutate("Inspect the failing branch\u2014fix it."), true);
87
+ // Hyphenated genuine imperatives stay write-capable.
88
+ assert.equal(taskMayMutate("hot-fix the bug"), true);
89
+ assert.equal(taskMayMutate("quick-fix that bug"), true);
90
+ // Genuine imperative usage keeps classifying as implementation.
91
+ assert.equal(classifyTaskMutationIntent("worker", "Go through the list and fix items one by one").kind, "implementation");
92
+ assert.equal(classifyTaskMutationIntent("worker", "fix items on the sprint board").kind, "implementation");
93
+ // CLI flags keep write intent: "--fix" / "-w" are not hyphenated adjectives.
94
+ assert.equal(classifyTaskMutationIntent("worker", "Run eslint --fix code").kind, "implementation");
95
+ assert.equal(taskMayMutate("Run eslint --fix code"), true);
96
+ // "--write" was never a classifier verb (pre-existing unknown); the
97
+ // regression guard is that write-capability survives for acceptance.
98
+ assert.equal(taskMayMutate("Run prettier --write files"), true);
99
+ // Guard-facing mirror: the completion guard must not expect mutation.
100
+ assert.equal(expectsImplementationMutation("worker", "Return a review with the top 2-3 must-fix items"), false);
101
+ });
102
+
64
103
  it("expectsImplementationMutation mirrors the classifier", () => {
65
104
  assert.equal(expectsImplementationMutation("worker", "Do not modify tests; implement the fix"), true);
66
105
  assert.equal(expectsImplementationMutation("worker", "Review the diff and suggest fixes only. Do not edit files."), false);
@@ -3,6 +3,7 @@ import { describe, it } from "node:test";
3
3
  import {
4
4
  DEFAULT_ASYNC_TIMEOUT_MS,
5
5
  DEFAULT_FOREGROUND_TIMEOUT_MS,
6
+ resolveConfigDefaultTimeoutMs,
6
7
  resolveSingleAgentLaunchTimeout,
7
8
  } from "../../src/runs/foreground/subagent-executor.ts";
8
9
 
@@ -70,4 +71,68 @@ describe("single-agent launch timeout wiring", () => {
70
71
  { timeoutMs: 5_000 },
71
72
  );
72
73
  });
74
+
75
+ const NINETY_MIN = 90 * 60 * 1000;
76
+
77
+ it("applies the global config default in place of the foreground backstop", () => {
78
+ assert.deepEqual(resolveSingleAgentLaunchTimeout({}, false, NINETY_MIN), {
79
+ timeoutMs: NINETY_MIN,
80
+ });
81
+ });
82
+
83
+ it("applies the global config default to composite foreground runs (parallel/chain)", () => {
84
+ assert.deepEqual(
85
+ resolveSingleAgentLaunchTimeout({ agent: "worker", task: "x", tasks: [{ agent: "worker", task: "y" }] }, false, NINETY_MIN),
86
+ { timeoutMs: NINETY_MIN },
87
+ );
88
+ assert.deepEqual(
89
+ resolveSingleAgentLaunchTimeout({ agent: "worker", task: "x", chain: [{ agent: "worker", task: "y" }] }, false, NINETY_MIN),
90
+ { timeoutMs: NINETY_MIN },
91
+ );
92
+ });
93
+
94
+ it("applies the global config default in place of the single-agent async backstop", () => {
95
+ assert.deepEqual(resolveSingleAgentLaunchTimeout({}, true, NINETY_MIN), {
96
+ timeoutMs: NINETY_MIN,
97
+ });
98
+ });
99
+
100
+ it("lets an explicit call timeout win over the global config default", () => {
101
+ assert.deepEqual(resolveSingleAgentLaunchTimeout({ timeoutMs: 5_000 }, false, NINETY_MIN), {
102
+ timeoutMs: 5_000,
103
+ });
104
+ });
105
+
106
+ it("keeps composite async runs unbounded even with a global config default", () => {
107
+ assert.deepEqual(
108
+ resolveSingleAgentLaunchTimeout({ agent: "worker", task: "x", tasks: [{ agent: "worker", task: "y" }] }, true, NINETY_MIN),
109
+ {},
110
+ );
111
+ });
112
+ });
113
+
114
+ describe("resolveConfigDefaultTimeoutMs", () => {
115
+ it("returns a positive integer unchanged", () => {
116
+ assert.equal(resolveConfigDefaultTimeoutMs(90 * 60 * 1000), 90 * 60 * 1000);
117
+ });
118
+
119
+ it("treats unset config as no default", () => {
120
+ assert.equal(resolveConfigDefaultTimeoutMs(undefined), undefined);
121
+ });
122
+
123
+ it("ignores non-positive, non-integer, and non-numeric values", () => {
124
+ assert.equal(resolveConfigDefaultTimeoutMs(0), undefined);
125
+ assert.equal(resolveConfigDefaultTimeoutMs(-1), undefined);
126
+ assert.equal(resolveConfigDefaultTimeoutMs(1.5), undefined);
127
+ assert.equal(resolveConfigDefaultTimeoutMs("600000" as unknown), undefined);
128
+ assert.equal(resolveConfigDefaultTimeoutMs(Number.NaN), undefined);
129
+ });
130
+
131
+ it("accepts the maximum schedulable timer delay but ignores anything larger", () => {
132
+ // 2_147_483_647 is the largest delay a Node.js timer can honor; above it
133
+ // setTimeout overflows to ~1ms and the run would expire almost immediately.
134
+ assert.equal(resolveConfigDefaultTimeoutMs(2_147_483_647), 2_147_483_647);
135
+ assert.equal(resolveConfigDefaultTimeoutMs(2_147_483_648), undefined);
136
+ assert.equal(resolveConfigDefaultTimeoutMs(Number.MAX_SAFE_INTEGER), undefined);
137
+ });
73
138
  });
@@ -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
+ });
@@ -125,6 +125,67 @@ describe("worktree", () => {
125
125
  }
126
126
  });
127
127
 
128
+ it("rejects worktree base directories inside Selesai extensions", () => {
129
+ const repoDir = createRepo("selesai-worktree-extension-dir-");
130
+ const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "selesai-worktree-home-"));
131
+ const previousAgentDir = process.env.SELESAI_CODING_AGENT_DIR;
132
+ const previousHome = process.env.HOME;
133
+ const previousUserProfile = process.env.USERPROFILE;
134
+ const extensionsDir = path.join(tempHome, ".selesai", "agent", "extensions");
135
+ try {
136
+ process.env.HOME = tempHome;
137
+ process.env.USERPROFILE = tempHome;
138
+ delete process.env.SELESAI_CODING_AGENT_DIR;
139
+ assert.throws(
140
+ () => createWorktrees(repoDir, "extension-dir", 1, { baseDir: extensionsDir }),
141
+ /worktree base directory cannot be inside Pi extensions directory/i,
142
+ );
143
+ assert.throws(
144
+ () => createWorktrees(repoDir, "extension-subdir", 1, { baseDir: path.join(extensionsDir, "checkout") }),
145
+ /worktree base directory cannot be inside Pi extensions directory/i,
146
+ );
147
+ } finally {
148
+ if (previousAgentDir === undefined) delete process.env.SELESAI_CODING_AGENT_DIR;
149
+ else process.env.SELESAI_CODING_AGENT_DIR = previousAgentDir;
150
+ if (previousHome === undefined) delete process.env.HOME;
151
+ else process.env.HOME = previousHome;
152
+ if (previousUserProfile === undefined) delete process.env.USERPROFILE;
153
+ else process.env.USERPROFILE = previousUserProfile;
154
+ cleanupRepo(repoDir);
155
+ fs.rmSync(tempHome, { recursive: true, force: true });
156
+ }
157
+ });
158
+
159
+ it("rejects symlinked worktree base directories inside Selesai extensions", () => {
160
+ const repoDir = createRepo("selesai-worktree-extension-symlink-");
161
+ const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "selesai-worktree-home-"));
162
+ const previousAgentDir = process.env.SELESAI_CODING_AGENT_DIR;
163
+ const previousHome = process.env.HOME;
164
+ const previousUserProfile = process.env.USERPROFILE;
165
+ const extensionsDir = path.join(tempHome, ".selesai", "agent", "extensions");
166
+ const aliasDir = path.join(tempHome, "extension-alias");
167
+ try {
168
+ process.env.HOME = tempHome;
169
+ process.env.USERPROFILE = tempHome;
170
+ delete process.env.SELESAI_CODING_AGENT_DIR;
171
+ fs.mkdirSync(extensionsDir, { recursive: true });
172
+ fs.symlinkSync(extensionsDir, aliasDir, process.platform === "win32" ? "junction" : "dir");
173
+ assert.throws(
174
+ () => createWorktrees(repoDir, "extension-symlink", 1, { baseDir: path.join(aliasDir, "checkout") }),
175
+ /worktree base directory cannot be inside Pi extensions directory/i,
176
+ );
177
+ } finally {
178
+ if (previousAgentDir === undefined) delete process.env.SELESAI_CODING_AGENT_DIR;
179
+ else process.env.SELESAI_CODING_AGENT_DIR = previousAgentDir;
180
+ if (previousHome === undefined) delete process.env.HOME;
181
+ else process.env.HOME = previousHome;
182
+ if (previousUserProfile === undefined) delete process.env.USERPROFILE;
183
+ else process.env.USERPROFILE = previousUserProfile;
184
+ cleanupRepo(repoDir);
185
+ fs.rmSync(tempHome, { recursive: true, force: true });
186
+ }
187
+ });
188
+
128
189
  it("uses SELESAI_SUBAGENTS_WORKTREE_DIR when no base directory is configured", () => {
129
190
  const repoDir = createRepo("pi-worktree-env-base-dir-");
130
191
  const previous = process.env.SELESAI_SUBAGENTS_WORKTREE_DIR;
@@ -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