@astralyn/pi 0.84.4 → 0.84.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 (263) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/dist/cli/args.d.ts +1 -0
  3. package/dist/cli/args.d.ts.map +1 -1
  4. package/dist/cli/args.js +11 -0
  5. package/dist/cli/args.js.map +1 -1
  6. package/dist/cli/auth-command.d.ts.map +1 -1
  7. package/dist/cli/auth-command.js +5 -4
  8. package/dist/cli/auth-command.js.map +1 -1
  9. package/dist/core/agent-session.d.ts +6 -1
  10. package/dist/core/agent-session.d.ts.map +1 -1
  11. package/dist/core/agent-session.js +11 -6
  12. package/dist/core/agent-session.js.map +1 -1
  13. package/dist/core/experimental.d.ts +4 -0
  14. package/dist/core/experimental.d.ts.map +1 -1
  15. package/dist/core/experimental.js +4 -0
  16. package/dist/core/experimental.js.map +1 -1
  17. package/dist/core/extensions/index.d.ts +1 -1
  18. package/dist/core/extensions/index.d.ts.map +1 -1
  19. package/dist/core/extensions/index.js.map +1 -1
  20. package/dist/core/extensions/runner.d.ts +1 -0
  21. package/dist/core/extensions/runner.d.ts.map +1 -1
  22. package/dist/core/extensions/runner.js +10 -0
  23. package/dist/core/extensions/runner.js.map +1 -1
  24. package/dist/core/extensions/types.d.ts +19 -1
  25. package/dist/core/extensions/types.d.ts.map +1 -1
  26. package/dist/core/extensions/types.js.map +1 -1
  27. package/dist/core/keybindings.d.ts +29 -0
  28. package/dist/core/keybindings.d.ts.map +1 -1
  29. package/dist/core/keybindings.js +4 -0
  30. package/dist/core/keybindings.js.map +1 -1
  31. package/dist/core/model-config.d.ts +5 -0
  32. package/dist/core/model-config.d.ts.map +1 -1
  33. package/dist/core/model-config.js +1 -0
  34. package/dist/core/model-config.js.map +1 -1
  35. package/dist/core/model-registry.d.ts +2 -0
  36. package/dist/core/model-registry.d.ts.map +1 -1
  37. package/dist/core/model-registry.js +4 -0
  38. package/dist/core/model-registry.js.map +1 -1
  39. package/dist/core/project-trust.js +2 -2
  40. package/dist/core/project-trust.js.map +1 -1
  41. package/dist/core/sdk.d.ts +5 -3
  42. package/dist/core/sdk.d.ts.map +1 -1
  43. package/dist/core/sdk.js +2 -1
  44. package/dist/core/sdk.js.map +1 -1
  45. package/dist/core/session-manager.js +2 -2
  46. package/dist/core/session-manager.js.map +1 -1
  47. package/dist/core/settings-manager.d.ts +6 -0
  48. package/dist/core/settings-manager.d.ts.map +1 -1
  49. package/dist/core/settings-manager.js +12 -0
  50. package/dist/core/settings-manager.js.map +1 -1
  51. package/dist/core/system-prompt.js +1 -1
  52. package/dist/core/system-prompt.js.map +1 -1
  53. package/dist/core/tools/bash.d.ts +7 -1
  54. package/dist/core/tools/bash.d.ts.map +1 -1
  55. package/dist/core/tools/bash.js +4 -2
  56. package/dist/core/tools/bash.js.map +1 -1
  57. package/dist/core/tools/edit.d.ts.map +1 -1
  58. package/dist/core/tools/edit.js +2 -0
  59. package/dist/core/tools/edit.js.map +1 -1
  60. package/dist/core/tools/find.js +1 -1
  61. package/dist/core/tools/find.js.map +1 -1
  62. package/dist/core/tools/grep.js +1 -1
  63. package/dist/core/tools/grep.js.map +1 -1
  64. package/dist/core/tools/read.d.ts.map +1 -1
  65. package/dist/core/tools/read.js +2 -0
  66. package/dist/core/tools/read.js.map +1 -1
  67. package/dist/core/tools/write.d.ts.map +1 -1
  68. package/dist/core/tools/write.js +2 -0
  69. package/dist/core/tools/write.js.map +1 -1
  70. package/dist/extensions/background/index.d.ts +149 -0
  71. package/dist/extensions/background/index.d.ts.map +1 -0
  72. package/dist/extensions/background/index.js +666 -0
  73. package/dist/extensions/background/index.js.map +1 -0
  74. package/dist/extensions/background/manager.d.ts +95 -0
  75. package/dist/extensions/background/manager.d.ts.map +1 -0
  76. package/dist/extensions/background/manager.js +421 -0
  77. package/dist/extensions/background/manager.js.map +1 -0
  78. package/dist/extensions/background/registry.d.ts +220 -0
  79. package/dist/extensions/background/registry.d.ts.map +1 -0
  80. package/dist/extensions/background/registry.js +589 -0
  81. package/dist/extensions/background/registry.js.map +1 -0
  82. package/dist/extensions/background/render.d.ts +50 -0
  83. package/dist/extensions/background/render.d.ts.map +1 -0
  84. package/dist/extensions/background/render.js +260 -0
  85. package/dist/extensions/background/render.js.map +1 -0
  86. package/dist/extensions/index.d.ts.map +1 -1
  87. package/dist/extensions/index.js +2 -0
  88. package/dist/extensions/index.js.map +1 -1
  89. package/dist/extensions/subagent/activity.d.ts +3 -6
  90. package/dist/extensions/subagent/activity.d.ts.map +1 -1
  91. package/dist/extensions/subagent/activity.js +9 -93
  92. package/dist/extensions/subagent/activity.js.map +1 -1
  93. package/dist/extensions/subagent/agents-command.d.ts.map +1 -1
  94. package/dist/extensions/subagent/agents-command.js +270 -169
  95. package/dist/extensions/subagent/agents-command.js.map +1 -1
  96. package/dist/extensions/subagent/agents.d.ts +5 -6
  97. package/dist/extensions/subagent/agents.d.ts.map +1 -1
  98. package/dist/extensions/subagent/agents.js +28 -192
  99. package/dist/extensions/subagent/agents.js.map +1 -1
  100. package/dist/extensions/subagent/budget.d.ts +15 -0
  101. package/dist/extensions/subagent/budget.d.ts.map +1 -0
  102. package/dist/extensions/subagent/budget.js +96 -0
  103. package/dist/extensions/subagent/budget.js.map +1 -0
  104. package/dist/extensions/subagent/cancellation.d.ts +29 -0
  105. package/dist/extensions/subagent/cancellation.d.ts.map +1 -0
  106. package/dist/extensions/subagent/cancellation.js +73 -0
  107. package/dist/extensions/subagent/cancellation.js.map +1 -0
  108. package/dist/extensions/subagent/constants.d.ts +4 -5
  109. package/dist/extensions/subagent/constants.d.ts.map +1 -1
  110. package/dist/extensions/subagent/constants.js +11 -7
  111. package/dist/extensions/subagent/constants.js.map +1 -1
  112. package/dist/extensions/subagent/index.d.ts +0 -3
  113. package/dist/extensions/subagent/index.d.ts.map +1 -1
  114. package/dist/extensions/subagent/index.js +59 -208
  115. package/dist/extensions/subagent/index.js.map +1 -1
  116. package/dist/extensions/subagent/model-selection.d.ts +16 -0
  117. package/dist/extensions/subagent/model-selection.d.ts.map +1 -0
  118. package/dist/extensions/subagent/model-selection.js +51 -0
  119. package/dist/extensions/subagent/model-selection.js.map +1 -0
  120. package/dist/extensions/subagent/render.d.ts +7 -2
  121. package/dist/extensions/subagent/render.d.ts.map +1 -1
  122. package/dist/extensions/subagent/render.js +219 -576
  123. package/dist/extensions/subagent/render.js.map +1 -1
  124. package/dist/extensions/subagent/resolve.d.ts +2 -2
  125. package/dist/extensions/subagent/resolve.d.ts.map +1 -1
  126. package/dist/extensions/subagent/resolve.js +24 -35
  127. package/dist/extensions/subagent/resolve.js.map +1 -1
  128. package/dist/extensions/subagent/runner.d.ts +4 -6
  129. package/dist/extensions/subagent/runner.d.ts.map +1 -1
  130. package/dist/extensions/subagent/runner.js +101 -316
  131. package/dist/extensions/subagent/runner.js.map +1 -1
  132. package/dist/extensions/subagent/schema.d.ts +4 -10
  133. package/dist/extensions/subagent/schema.d.ts.map +1 -1
  134. package/dist/extensions/subagent/schema.js +8 -13
  135. package/dist/extensions/subagent/schema.js.map +1 -1
  136. package/dist/extensions/subagent/sdk-runner.d.ts +13 -11
  137. package/dist/extensions/subagent/sdk-runner.d.ts.map +1 -1
  138. package/dist/extensions/subagent/sdk-runner.js +169 -161
  139. package/dist/extensions/subagent/sdk-runner.js.map +1 -1
  140. package/dist/extensions/subagent/settings.d.ts +2 -2
  141. package/dist/extensions/subagent/settings.d.ts.map +1 -1
  142. package/dist/extensions/subagent/settings.js +27 -14
  143. package/dist/extensions/subagent/settings.js.map +1 -1
  144. package/dist/extensions/subagent/state.d.ts +89 -0
  145. package/dist/extensions/subagent/state.d.ts.map +1 -0
  146. package/dist/extensions/subagent/state.js +294 -0
  147. package/dist/extensions/subagent/state.js.map +1 -0
  148. package/dist/extensions/subagent/text.d.ts +7 -0
  149. package/dist/extensions/subagent/text.d.ts.map +1 -0
  150. package/dist/extensions/subagent/text.js +91 -0
  151. package/dist/extensions/subagent/text.js.map +1 -0
  152. package/dist/extensions/subagent/types.d.ts +16 -43
  153. package/dist/extensions/subagent/types.d.ts.map +1 -1
  154. package/dist/extensions/subagent/types.js.map +1 -1
  155. package/dist/extensions/subagent/ui/choices.d.ts +36 -0
  156. package/dist/extensions/subagent/ui/choices.d.ts.map +1 -0
  157. package/dist/extensions/subagent/ui/choices.js +54 -0
  158. package/dist/extensions/subagent/ui/choices.js.map +1 -0
  159. package/dist/extensions/subagent/ui/model-list.d.ts +4 -3
  160. package/dist/extensions/subagent/ui/model-list.d.ts.map +1 -1
  161. package/dist/extensions/subagent/ui/model-list.js +20 -22
  162. package/dist/extensions/subagent/ui/model-list.js.map +1 -1
  163. package/dist/index.d.ts +2 -2
  164. package/dist/index.d.ts.map +1 -1
  165. package/dist/index.js.map +1 -1
  166. package/dist/main.d.ts.map +1 -1
  167. package/dist/main.js +5 -1
  168. package/dist/main.js.map +1 -1
  169. package/dist/modes/interactive/components/model-selector.d.ts.map +1 -1
  170. package/dist/modes/interactive/components/model-selector.js +2 -1
  171. package/dist/modes/interactive/components/model-selector.js.map +1 -1
  172. package/dist/modes/interactive/components/settings-selector.d.ts +3 -1
  173. package/dist/modes/interactive/components/settings-selector.d.ts.map +1 -1
  174. package/dist/modes/interactive/components/settings-selector.js +10 -0
  175. package/dist/modes/interactive/components/settings-selector.js.map +1 -1
  176. package/dist/modes/interactive/interactive-mode.d.ts +8 -2
  177. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  178. package/dist/modes/interactive/interactive-mode.js +77 -25
  179. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  180. package/dist/modes/interactive/model-catalog-refresh.d.ts +7 -0
  181. package/dist/modes/interactive/model-catalog-refresh.d.ts.map +1 -0
  182. package/dist/modes/interactive/model-catalog-refresh.js +36 -0
  183. package/dist/modes/interactive/model-catalog-refresh.js.map +1 -0
  184. package/dist/modes/interactive/theme/dark.json +2 -0
  185. package/dist/modes/interactive/theme/light.json +2 -0
  186. package/dist/modes/interactive/theme/theme-controller.d.ts +10 -2
  187. package/dist/modes/interactive/theme/theme-controller.d.ts.map +1 -1
  188. package/dist/modes/interactive/theme/theme-controller.js +23 -10
  189. package/dist/modes/interactive/theme/theme-controller.js.map +1 -1
  190. package/dist/modes/interactive/theme/theme-schema.json +9 -1
  191. package/dist/modes/interactive/theme/theme.d.ts +5 -3
  192. package/dist/modes/interactive/theme/theme.d.ts.map +1 -1
  193. package/dist/modes/interactive/theme/theme.js +12 -2
  194. package/dist/modes/interactive/theme/theme.js.map +1 -1
  195. package/dist/modes/json-event.d.ts +4 -1
  196. package/dist/modes/json-event.d.ts.map +1 -1
  197. package/dist/modes/json-event.js +5 -2
  198. package/dist/modes/json-event.js.map +1 -1
  199. package/dist/package-manager-cli.js +1 -1
  200. package/dist/package-manager-cli.js.map +1 -1
  201. package/dist/server/create-harness.d.ts.map +1 -1
  202. package/dist/server/create-harness.js +2 -0
  203. package/dist/server/create-harness.js.map +1 -1
  204. package/dist/utils/tools-manager.d.ts +10 -1
  205. package/dist/utils/tools-manager.d.ts.map +1 -1
  206. package/dist/utils/tools-manager.js +14 -19
  207. package/dist/utils/tools-manager.js.map +1 -1
  208. package/docs/bundled/README.md +1 -0
  209. package/docs/bundled/extensions/background.md +99 -0
  210. package/docs/bundled/extensions/subagent.md +83 -71
  211. package/docs/compaction.md +6 -6
  212. package/docs/custom-provider.md +1 -1
  213. package/docs/docs.json +4 -0
  214. package/docs/environment-variables.md +8 -2
  215. package/docs/extensions.md +19 -2
  216. package/docs/json.md +6 -4
  217. package/docs/keybindings.md +9 -2
  218. package/docs/rpc.md +15 -4
  219. package/docs/sdk.md +9 -0
  220. package/docs/settings.md +31 -0
  221. package/docs/terminal-setup.md +23 -2
  222. package/docs/themes.md +24 -3
  223. package/docs/tui.md +2 -2
  224. package/docs/usage.md +4 -3
  225. package/examples/README.md +1 -1
  226. package/examples/extensions/README.md +0 -1
  227. package/examples/extensions/custom-provider-anthropic/package-lock.json +2 -2
  228. package/examples/extensions/custom-provider-anthropic/package.json +1 -1
  229. package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
  230. package/examples/extensions/gondolin/package-lock.json +2 -2
  231. package/examples/extensions/gondolin/package.json +1 -1
  232. package/examples/extensions/sandbox/package-lock.json +2 -2
  233. package/examples/extensions/sandbox/package.json +1 -1
  234. package/examples/extensions/with-deps/package-lock.json +2 -2
  235. package/examples/extensions/with-deps/package.json +1 -1
  236. package/npm-shrinkwrap.json +170 -221
  237. package/package.json +6 -6
  238. package/dist/extensions/subagent/display-name.d.ts +0 -6
  239. package/dist/extensions/subagent/display-name.d.ts.map +0 -1
  240. package/dist/extensions/subagent/display-name.js +0 -13
  241. package/dist/extensions/subagent/display-name.js.map +0 -1
  242. package/dist/extensions/subagent/retry.d.ts +0 -17
  243. package/dist/extensions/subagent/retry.d.ts.map +0 -1
  244. package/dist/extensions/subagent/retry.js +0 -41
  245. package/dist/extensions/subagent/retry.js.map +0 -1
  246. package/dist/extensions/subagent/ui/profile-editor.d.ts +0 -54
  247. package/dist/extensions/subagent/ui/profile-editor.d.ts.map +0 -1
  248. package/dist/extensions/subagent/ui/profile-editor.js +0 -154
  249. package/dist/extensions/subagent/ui/profile-editor.js.map +0 -1
  250. package/dist/extensions/subagent/ui/profile-picker.d.ts +0 -32
  251. package/dist/extensions/subagent/ui/profile-picker.d.ts.map +0 -1
  252. package/dist/extensions/subagent/ui/profile-picker.js +0 -149
  253. package/dist/extensions/subagent/ui/profile-picker.js.map +0 -1
  254. package/examples/extensions/subagent/README.md +0 -175
  255. package/examples/extensions/subagent/agents/planner.md +0 -37
  256. package/examples/extensions/subagent/agents/reviewer.md +0 -35
  257. package/examples/extensions/subagent/agents/scout.md +0 -50
  258. package/examples/extensions/subagent/agents/worker.md +0 -24
  259. package/examples/extensions/subagent/agents.ts +0 -126
  260. package/examples/extensions/subagent/index.ts +0 -1009
  261. package/examples/extensions/subagent/prompts/implement-and-review.md +0 -10
  262. package/examples/extensions/subagent/prompts/implement.md +0 -10
  263. package/examples/extensions/subagent/prompts/scout-and-plan.md +0 -9
@@ -0,0 +1,589 @@
1
+ /**
2
+ * Background task registry: process lifecycle, output capture, completion
3
+ * notification. Pure logic — no UI imports, dependencies injected for tests.
4
+ *
5
+ * Discipline (mirrors the reference implementation this extension distills):
6
+ * - Output streams straight to a file; memory holds only a byte counter.
7
+ * - Finalization is idempotent and strictly ordered: flush stream, set
8
+ * terminal state, then notify. A notification is only sent once the output
9
+ * file holds the complete truth.
10
+ * - Kill goes through AbortController; the shared bash operations kill the
11
+ * whole process tree cross-platform.
12
+ */
13
+ import { randomBytes } from "node:crypto";
14
+ import { closeSync, createWriteStream, openSync } from "node:fs";
15
+ import { open } from "node:fs/promises";
16
+ import { tmpdir } from "node:os";
17
+ import { join } from "node:path";
18
+ import { formatSize } from "../../core/tools/truncate.js";
19
+ import { sanitizeBinaryOutput } from "../../utils/shell.js";
20
+ export const DEFAULT_MAX_OUTPUT_BYTES = 20 * 1024 * 1024;
21
+ export const DEFAULT_MAX_RUNNING_TASKS = 8;
22
+ export const DEFAULT_NOTIFY_TAIL_BYTES = 4 * 1024;
23
+ /** Wait-window bounds shared by execution and the pending-call renderer. */
24
+ export const BG_WAIT_DEFAULT_MS = 20_000;
25
+ export const BG_WAIT_MIN_MS = 1_000;
26
+ export const BG_WAIT_MAX_MS = 60_000;
27
+ const SHUTDOWN_GRACE_MS = 2_000;
28
+ /** Stall watchdog defaults, matching Claude Code's CC-1175 tuning. */
29
+ export const STALL_POLL_INTERVAL_MS = 5_000;
30
+ export const STALL_THRESHOLD_MS = 45_000;
31
+ export const STALL_TAIL_BYTES = 1_024;
32
+ /**
33
+ * Last-line patterns that suggest a command is blocked waiting for keyboard
34
+ * input. Used to gate the stall notification: a task that is merely slow
35
+ * (git log -S, long silent builds) stays silent — only a tail that looks like
36
+ * a prompt the model can act on triggers a notification.
37
+ */
38
+ const STALL_PROMPT_PATTERNS = [
39
+ /\(y\/n\)/i, // (Y/n), (y/N)
40
+ /\[y\/n\]/i, // [Y/n], [y/N]
41
+ /\(yes\/no\)/i,
42
+ /\b(?:Do you|Would you|Shall I|Are you sure|Ready to)\b.*\? *$/i, // directed questions
43
+ /Press (any key|Enter)/i,
44
+ /Continue\?/i,
45
+ /Overwrite\?/i,
46
+ ];
47
+ export function looksLikePrompt(tail) {
48
+ const lastLine = tail.trimEnd().split("\n").pop() ?? "";
49
+ return STALL_PROMPT_PATTERNS.some((pattern) => pattern.test(lastLine));
50
+ }
51
+ /**
52
+ * Read a bounded slice from the head or tail of a file via positioned reads —
53
+ * never the whole file. Tail reads skip UTF-8 continuation bytes so the text
54
+ * starts on a codepoint boundary.
55
+ */
56
+ export async function readOutputSlice(filePath, options) {
57
+ const file = await open(filePath, "r");
58
+ try {
59
+ const { size } = await file.stat();
60
+ let length = Math.min(size, Math.max(0, Math.floor(options.maxBytes)));
61
+ let position = options.mode === "tail" ? size - length : 0;
62
+ let startsMidLine = false;
63
+ if (options.mode === "tail" && position > 0 && length > 0) {
64
+ // Probe from one byte before the slice: probe[0] tells whether the
65
+ // slice starts at a line boundary, the rest lets us skip UTF-8
66
+ // continuation bytes (0b10xxxxxx) so we never split a codepoint.
67
+ const probeStart = position - 1;
68
+ const probe = Buffer.alloc(Math.min(5, size - probeStart));
69
+ await file.read(probe, 0, probe.length, probeStart);
70
+ let skip = 0;
71
+ while (1 + skip < probe.length && ((probe[1 + skip] ?? 0) & 0b1100_0000) === 0b1000_0000) {
72
+ skip++;
73
+ }
74
+ position += skip;
75
+ length -= skip;
76
+ startsMidLine = probe[skip] !== 0x0a;
77
+ }
78
+ const buffer = Buffer.alloc(Math.max(0, length));
79
+ const bytesRead = buffer.length > 0 ? (await file.read(buffer, 0, buffer.length, position)).bytesRead : 0;
80
+ return {
81
+ text: buffer.subarray(0, bytesRead).toString("utf8"),
82
+ sliceBytes: bytesRead,
83
+ totalBytes: size,
84
+ truncated: size > bytesRead,
85
+ startsMidLine,
86
+ };
87
+ }
88
+ finally {
89
+ await file.close();
90
+ }
91
+ }
92
+ /**
93
+ * Read the last `maxBytes` of a file starting no earlier than `fromByte` — the
94
+ * bounded delta since an offset, tail-aligned so the outcome (at the end of
95
+ * the output) is always included. `fromByte` is clamped into the file; the
96
+ * returned `fromByte` is where the slice actually starts.
97
+ */
98
+ export async function readOutputSince(filePath, fromByte, maxBytes) {
99
+ const file = await open(filePath, "r");
100
+ try {
101
+ const { size } = await file.stat();
102
+ const floor = Math.min(Math.max(0, Math.floor(fromByte)), size);
103
+ let length = Math.min(size - floor, Math.max(0, Math.floor(maxBytes)));
104
+ let position = size - length;
105
+ let startsMidLine = false;
106
+ if (position > 0 && length > 0) {
107
+ // Same boundary probe as tail reads: probe[0] tells whether the slice
108
+ // starts at a line boundary, the rest skips UTF-8 continuation bytes.
109
+ const probeStart = position - 1;
110
+ const probe = Buffer.alloc(Math.min(5, size - probeStart));
111
+ await file.read(probe, 0, probe.length, probeStart);
112
+ let skip = 0;
113
+ while (1 + skip < probe.length && ((probe[1 + skip] ?? 0) & 0b1100_0000) === 0b1000_0000) {
114
+ skip++;
115
+ }
116
+ position += skip;
117
+ length -= skip;
118
+ startsMidLine = probe[skip] !== 0x0a;
119
+ }
120
+ const buffer = Buffer.alloc(Math.max(0, length));
121
+ const bytesRead = buffer.length > 0 ? (await file.read(buffer, 0, buffer.length, position)).bytesRead : 0;
122
+ return {
123
+ text: buffer.subarray(0, bytesRead).toString("utf8"),
124
+ sliceBytes: bytesRead,
125
+ totalBytes: size,
126
+ truncated: position > floor,
127
+ startsMidLine,
128
+ fromByte: position,
129
+ };
130
+ }
131
+ finally {
132
+ await file.close();
133
+ }
134
+ }
135
+ export class BackgroundTaskRegistry {
136
+ constructor(options) {
137
+ this.tasks = new Map();
138
+ this.runtimes = new Map();
139
+ this.shuttingDown = false;
140
+ this.operations = options.operations;
141
+ this.outputDir = options.outputDir ?? tmpdir();
142
+ this.maxOutputBytes = options.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
143
+ this.maxRunningTasks = options.maxRunningTasks ?? DEFAULT_MAX_RUNNING_TASKS;
144
+ this.notifyTailBytes = options.notifyTailBytes ?? DEFAULT_NOTIFY_TAIL_BYTES;
145
+ this.onNotify = options.onNotify;
146
+ this.onStall = options.onStall;
147
+ this.stallPollIntervalMs = options.stall?.pollIntervalMs ?? STALL_POLL_INTERVAL_MS;
148
+ this.stallThresholdMs = options.stall?.thresholdMs ?? STALL_THRESHOLD_MS;
149
+ this.stallTailBytes = options.stall?.tailBytes ?? STALL_TAIL_BYTES;
150
+ this.onChange = options.onChange;
151
+ this.now = options.now ?? Date.now;
152
+ }
153
+ get isShuttingDown() {
154
+ return this.shuttingDown;
155
+ }
156
+ startTask(input) {
157
+ if (this.shuttingDown) {
158
+ throw new Error("Pi is shutting down; background task not started.");
159
+ }
160
+ const running = this.listTasks().filter((task) => task.status === "running");
161
+ if (running.length >= this.maxRunningTasks) {
162
+ const list = running.map((task) => ` ${task.id} ${firstCommandLine(task.command)}`).join("\n");
163
+ throw new Error(`Too many running background tasks (limit ${this.maxRunningTasks}). Kill one first (bg action kill):\n${list}`);
164
+ }
165
+ const id = this.newTaskId();
166
+ const task = {
167
+ id,
168
+ command: input.command,
169
+ description: input.description,
170
+ cwd: input.cwd,
171
+ status: "running",
172
+ startedAt: this.now(),
173
+ exitCode: undefined,
174
+ outputPath: join(this.outputDir, `pi-${id}.log`),
175
+ outputBytes: 0,
176
+ outputTruncated: false,
177
+ timeoutSeconds: input.timeoutSeconds,
178
+ notified: false,
179
+ stalled: false,
180
+ };
181
+ let resolveDone;
182
+ const done = new Promise((resolve) => {
183
+ resolveDone = resolve;
184
+ });
185
+ // Create the file synchronously so read/wait and the /bg viewer never see
186
+ // ENOENT between task start and the stream's async open. The exclusive
187
+ // 'wx' flag fails on any existing path — including a symlink — so creation
188
+ // can never truncate a file a stale id (or an attacker-planted link) points at.
189
+ createOutputFileExclusively(task.outputPath);
190
+ const runtime = {
191
+ controller: new AbortController(),
192
+ stream: createWriteStream(task.outputPath, { flags: "a" }),
193
+ killRequested: false,
194
+ overflow: false,
195
+ finalized: false,
196
+ waiters: 0,
197
+ stallTimer: undefined,
198
+ stallLastSize: 0,
199
+ stallLastGrowthAt: this.now(),
200
+ stallNotified: false,
201
+ done,
202
+ resolveDone,
203
+ };
204
+ runtime.stream.on("error", (error) => {
205
+ if (runtime.finalized)
206
+ return;
207
+ runtime.streamError = error.message;
208
+ runtime.controller.abort();
209
+ });
210
+ this.tasks.set(id, task);
211
+ this.runtimes.set(id, runtime);
212
+ this.armStallWatchdog(task, runtime);
213
+ void this.run(task, runtime, input.env);
214
+ this.onChange();
215
+ return task;
216
+ }
217
+ getTask(id) {
218
+ return this.tasks.get(id);
219
+ }
220
+ /** Running tasks first (oldest first), then finished tasks (newest first). */
221
+ listTasks() {
222
+ const all = [...this.tasks.values()];
223
+ const running = all.filter((task) => task.status === "running");
224
+ running.sort((a, b) => a.startedAt - b.startedAt);
225
+ const ended = all.filter((task) => task.status !== "running");
226
+ ended.sort((a, b) => (b.endedAt ?? b.startedAt) - (a.endedAt ?? a.startedAt));
227
+ return [...running, ...ended];
228
+ }
229
+ /** Resolve a task by id or unique id prefix; the "bg-" prefix may be omitted. */
230
+ resolveTask(idPrefix) {
231
+ const needle = idPrefix.trim();
232
+ if (!needle)
233
+ return { ok: false, reason: "not-found", candidates: [] };
234
+ const exact = this.tasks.get(needle) ?? this.tasks.get(`bg-${needle}`);
235
+ if (exact)
236
+ return { ok: true, task: exact };
237
+ const candidates = [...this.tasks.values()].filter((task) => task.id.startsWith(needle) || task.id.startsWith(`bg-${needle}`));
238
+ const first = candidates[0];
239
+ if (candidates.length === 1 && first)
240
+ return { ok: true, task: first };
241
+ if (candidates.length === 0)
242
+ return { ok: false, reason: "not-found", candidates: [] };
243
+ return { ok: false, reason: "ambiguous", candidates };
244
+ }
245
+ killTask(id) {
246
+ const task = this.tasks.get(id);
247
+ const runtime = this.runtimes.get(id);
248
+ if (!task || !runtime)
249
+ return { killed: false, reason: "not-found" };
250
+ if (task.status !== "running" || runtime.finalized)
251
+ return { killed: false, reason: "not-running" };
252
+ runtime.killRequested = true;
253
+ runtime.controller.abort();
254
+ return { killed: true };
255
+ }
256
+ counts() {
257
+ const counts = { running: 0, completed: 0, failed: 0, killed: 0, timeout: 0, total: 0, stalled: 0 };
258
+ for (const task of this.tasks.values()) {
259
+ counts[task.status]++;
260
+ counts.total++;
261
+ if (task.stalled)
262
+ counts.stalled++;
263
+ }
264
+ return counts;
265
+ }
266
+ /** Resolves once the task has finalized, including its notification attempt. */
267
+ async waitForTask(id) {
268
+ const task = this.tasks.get(id);
269
+ if (!task)
270
+ throw new Error(`Unknown background task: ${id}`);
271
+ await this.runtimes.get(id)?.done;
272
+ return task;
273
+ }
274
+ /**
275
+ * Bounded model-facing wait with the claim protocol: a waiter registered
276
+ * when the task finalizes takes over delivery — finalize sees waiters > 0
277
+ * and suppresses the followUp notification, so the completion is delivered
278
+ * exactly once, inline here. If the window expires first, the followUp
279
+ * notification is left untouched and fires as usual.
280
+ *
281
+ * All interleavings are deterministic on JS's single thread: a waiter that
282
+ * registers before finalize's synchronous claim check claims delivery; one
283
+ * that arrives after sees a terminal status and returns it immediately
284
+ * (the followUp may also arrive — an idempotent repeat, not a lie).
285
+ */
286
+ async waitForResult(id, timeoutMs) {
287
+ const task = this.tasks.get(id);
288
+ if (!task)
289
+ throw new Error(`Unknown background task: ${id}`);
290
+ const runtime = this.runtimes.get(id);
291
+ if (!runtime)
292
+ throw new Error(`Unknown background task: ${id}`);
293
+ if (task.status !== "running")
294
+ return { outcome: "terminal", task };
295
+ runtime.waiters++;
296
+ let timer;
297
+ try {
298
+ const timedOut = await new Promise((resolve) => {
299
+ timer = setTimeout(() => resolve(true), timeoutMs);
300
+ void runtime.done.then(() => resolve(false));
301
+ });
302
+ // done may resolve right after the timer fired (race window); a task
303
+ // that reached a terminal state during the wait is delivered here.
304
+ if (!timedOut || task.status !== "running")
305
+ return { outcome: "terminal", task };
306
+ return { outcome: "timeout", task };
307
+ }
308
+ finally {
309
+ runtime.waiters--;
310
+ if (timer !== undefined)
311
+ clearTimeout(timer);
312
+ }
313
+ }
314
+ /**
315
+ * Mute notifications, refuse new tasks, abort every running task, and wait
316
+ * (bounded) for their finalization.
317
+ */
318
+ async shutdown() {
319
+ this.shuttingDown = true;
320
+ const pending = [];
321
+ for (const [id, runtime] of this.runtimes) {
322
+ const task = this.tasks.get(id);
323
+ if (task?.status === "running" && !runtime.finalized) {
324
+ runtime.killRequested = true;
325
+ runtime.controller.abort();
326
+ }
327
+ pending.push(runtime.done);
328
+ }
329
+ let timer;
330
+ const grace = new Promise((resolve) => {
331
+ timer = setTimeout(resolve, SHUTDOWN_GRACE_MS);
332
+ });
333
+ try {
334
+ await Promise.race([Promise.allSettled(pending).then(() => undefined), grace]);
335
+ }
336
+ finally {
337
+ if (timer)
338
+ clearTimeout(timer);
339
+ }
340
+ }
341
+ /**
342
+ * Stall watchdog (Claude Code CC-1175): poll the output file; when output
343
+ * stops growing past the threshold AND the tail looks like an interactive
344
+ * prompt, flag the task and fire a one-shot notification. Merely-slow tasks
345
+ * never match; recovery clears the flag (the notification never re-fires).
346
+ */
347
+ armStallWatchdog(task, runtime) {
348
+ if (!this.onStall)
349
+ return;
350
+ runtime.stallTimer = setInterval(() => this.checkStall(task, runtime), this.stallPollIntervalMs);
351
+ runtime.stallTimer.unref?.();
352
+ }
353
+ clearStallWatchdog(runtime) {
354
+ if (runtime.stallTimer !== undefined) {
355
+ clearInterval(runtime.stallTimer);
356
+ runtime.stallTimer = undefined;
357
+ }
358
+ }
359
+ checkStall(task, runtime) {
360
+ if (runtime.finalized || this.shuttingDown)
361
+ return;
362
+ const size = task.outputBytes;
363
+ if (size > runtime.stallLastSize) {
364
+ runtime.stallLastSize = size;
365
+ runtime.stallLastGrowthAt = this.now();
366
+ if (task.stalled) {
367
+ // Output resumed: reflect reality in listings, never re-notify.
368
+ task.stalled = false;
369
+ this.onChange();
370
+ }
371
+ return;
372
+ }
373
+ if (this.now() - runtime.stallLastGrowthAt < this.stallThresholdMs)
374
+ return;
375
+ void this.probeStallTail(task, runtime);
376
+ }
377
+ async probeStallTail(task, runtime) {
378
+ // Re-check inside the async boundary: finalize may have raced the probe.
379
+ if (runtime.finalized || this.shuttingDown)
380
+ return;
381
+ let tailText = "";
382
+ let tailError;
383
+ try {
384
+ const slice = await readOutputSlice(task.outputPath, { mode: "tail", maxBytes: this.stallTailBytes });
385
+ tailText = slice.text;
386
+ }
387
+ catch (error) {
388
+ tailError = error instanceof Error ? error.message : String(error);
389
+ }
390
+ if (runtime.finalized || this.shuttingDown)
391
+ return;
392
+ // Merely slow: not prompt-shaped. Reset so the next check is a full
393
+ // threshold window out instead of re-probing on every tick.
394
+ if (!tailError && !looksLikePrompt(tailText)) {
395
+ runtime.stallLastGrowthAt = this.now();
396
+ return;
397
+ }
398
+ task.stalled = true;
399
+ this.onChange();
400
+ if (runtime.stallNotified)
401
+ return;
402
+ runtime.stallNotified = true;
403
+ let tailBytes = 0;
404
+ let totalBytes = task.outputBytes;
405
+ let tailTruncated = false;
406
+ let tailStartsMidLine = false;
407
+ if (!tailError) {
408
+ try {
409
+ const slice = await readOutputSlice(task.outputPath, { mode: "tail", maxBytes: this.stallTailBytes });
410
+ tailText = slice.text;
411
+ tailBytes = slice.sliceBytes;
412
+ totalBytes = slice.totalBytes;
413
+ tailTruncated = slice.truncated;
414
+ tailStartsMidLine = slice.startsMidLine;
415
+ }
416
+ catch (error) {
417
+ tailError = error instanceof Error ? error.message : String(error);
418
+ }
419
+ }
420
+ this.onStall?.({
421
+ task,
422
+ tailText: sanitizeBinaryOutput(tailText),
423
+ tailBytes,
424
+ totalBytes,
425
+ tailTruncated,
426
+ tailStartsMidLine,
427
+ tailError,
428
+ });
429
+ }
430
+ newTaskId() {
431
+ while (true) {
432
+ const id = `bg-${randomBytes(3).toString("hex")}`;
433
+ if (!this.tasks.has(id))
434
+ return id;
435
+ }
436
+ }
437
+ async run(task, runtime, env) {
438
+ let outcome;
439
+ try {
440
+ const result = await this.operations.exec(task.command, task.cwd, {
441
+ onData: (data) => this.handleData(task, runtime, data),
442
+ signal: runtime.controller.signal,
443
+ timeout: task.timeoutSeconds,
444
+ env,
445
+ });
446
+ outcome = { exitCode: result.exitCode };
447
+ }
448
+ catch (error) {
449
+ outcome = { error };
450
+ }
451
+ await this.finalize(task, runtime, outcome);
452
+ }
453
+ handleData(task, runtime, data) {
454
+ if (runtime.finalized || runtime.overflow || runtime.streamError !== undefined)
455
+ return;
456
+ const next = task.outputBytes + data.length;
457
+ if (next <= this.maxOutputBytes) {
458
+ runtime.stream.write(data);
459
+ task.outputBytes = next;
460
+ return;
461
+ }
462
+ const budget = this.maxOutputBytes - task.outputBytes;
463
+ if (budget > 0) {
464
+ runtime.stream.write(data.subarray(0, budget));
465
+ task.outputBytes = this.maxOutputBytes;
466
+ }
467
+ runtime.stream.write(`\n[output limit ${formatSize(this.maxOutputBytes)} exceeded; task killed]\n`);
468
+ task.outputTruncated = true;
469
+ runtime.overflow = true;
470
+ runtime.controller.abort();
471
+ }
472
+ async finalize(task, runtime, outcome) {
473
+ if (runtime.finalized)
474
+ return;
475
+ runtime.finalized = true;
476
+ this.clearStallWatchdog(runtime);
477
+ task.stalled = false;
478
+ // Flush before setting terminal state: the output file must hold the
479
+ // complete truth by the time anyone is told the task ended.
480
+ await new Promise((resolve) => {
481
+ const stream = runtime.stream;
482
+ if (stream.destroyed) {
483
+ resolve();
484
+ return;
485
+ }
486
+ stream.on("close", resolve);
487
+ stream.end(() => resolve());
488
+ });
489
+ const verdict = this.classifyOutcome(runtime, outcome);
490
+ task.status = verdict.status;
491
+ if (verdict.error !== undefined)
492
+ task.error = verdict.error;
493
+ task.exitCode = outcome.exitCode;
494
+ task.endedAt = this.now();
495
+ this.onChange();
496
+ // Claim protocol: at least one waitForResult caller is waiting on this
497
+ // completion, so it delivers the result inline. Mark notified to skip the
498
+ // followUp — the completion must be delivered exactly once.
499
+ if (runtime.waiters > 0) {
500
+ task.notified = true;
501
+ }
502
+ await this.notifyCompletion(task);
503
+ runtime.resolveDone();
504
+ }
505
+ classifyOutcome(runtime, outcome) {
506
+ if (runtime.overflow) {
507
+ return {
508
+ status: "failed",
509
+ error: `Output exceeded the ${formatSize(this.maxOutputBytes)} limit; the task was killed.`,
510
+ };
511
+ }
512
+ if (runtime.streamError !== undefined) {
513
+ return { status: "failed", error: `Output file error: ${runtime.streamError}` };
514
+ }
515
+ if (runtime.killRequested)
516
+ return { status: "killed" };
517
+ const error = outcome.error;
518
+ if (error instanceof Error) {
519
+ if (error.message === "aborted")
520
+ return { status: "killed" };
521
+ if (error.message.startsWith("timeout:")) {
522
+ return { status: "timeout", error: `Timed out after ${error.message.slice("timeout:".length)}s.` };
523
+ }
524
+ return { status: "failed", error: error.message };
525
+ }
526
+ if (error !== undefined)
527
+ return { status: "failed", error: String(error) };
528
+ if (outcome.exitCode === 0)
529
+ return { status: "completed" };
530
+ if (outcome.exitCode === null)
531
+ return { status: "failed", error: "Command was terminated by a signal." };
532
+ return { status: "failed", error: `Command exited with code ${outcome.exitCode}` };
533
+ }
534
+ async notifyCompletion(task) {
535
+ if (this.shuttingDown || task.notified)
536
+ return;
537
+ task.notified = true;
538
+ let tailText = "";
539
+ let tailBytes = 0;
540
+ let totalBytes = task.outputBytes;
541
+ let tailTruncated = false;
542
+ let tailStartsMidLine = false;
543
+ let tailError;
544
+ try {
545
+ const slice = await readOutputSlice(task.outputPath, { mode: "tail", maxBytes: this.notifyTailBytes });
546
+ tailText = sanitizeBinaryOutput(slice.text);
547
+ tailBytes = slice.sliceBytes;
548
+ totalBytes = slice.totalBytes;
549
+ tailTruncated = slice.truncated;
550
+ tailStartsMidLine = slice.startsMidLine;
551
+ }
552
+ catch (error) {
553
+ tailError = error instanceof Error ? error.message : String(error);
554
+ }
555
+ try {
556
+ this.onNotify({ task, tailText, tailBytes, totalBytes, tailTruncated, tailStartsMidLine, tailError });
557
+ }
558
+ catch {
559
+ // The send failed synchronously (e.g. stale extension runtime after a
560
+ // session replacement); do not pretend the notification was delivered.
561
+ task.notified = false;
562
+ }
563
+ }
564
+ }
565
+ /**
566
+ * Create an empty output file exclusively ('wx'): fails on any existing path,
567
+ * including a symlink, so creation can never truncate what the path points at.
568
+ */
569
+ export function createOutputFileExclusively(path) {
570
+ const handle = openSync(path, "wx");
571
+ closeSync(handle);
572
+ }
573
+ /** First non-empty line of a command, trimmed — for one-line task labels. */
574
+ export function firstCommandLine(command) {
575
+ const line = command.split(/\r?\n/).find((candidate) => candidate.trim().length > 0) ?? command;
576
+ return line.trim();
577
+ }
578
+ /** Compact duration like Pi's own timers: 12s, 3m05s, 1h02m. */
579
+ export function formatDuration(ms) {
580
+ const totalSeconds = Math.max(0, Math.round(ms / 1000));
581
+ if (totalSeconds < 60)
582
+ return `${totalSeconds}s`;
583
+ const minutes = Math.floor(totalSeconds / 60);
584
+ const seconds = totalSeconds % 60;
585
+ if (minutes < 60)
586
+ return `${minutes}m${String(seconds).padStart(2, "0")}s`;
587
+ return `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, "0")}m`;
588
+ }
589
+ //# sourceMappingURL=registry.js.map