gentle-pi 4.3.0 → 4.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (218) hide show
  1. package/README.md +10 -7
  2. package/assets/agents/gentle-ai-worker.md +1 -1
  3. package/assets/design-canvas/canvas.css +648 -0
  4. package/assets/design-canvas/canvas.js +1497 -0
  5. package/assets/design-canvas/index.html +88 -0
  6. package/assets/design-canvas/inspector.js +290 -0
  7. package/assets/orchestrator-delegation-reference.md +2 -2
  8. package/assets/orchestrator-verification.md +1 -1
  9. package/assets/orchestrator.md +2 -2
  10. package/bin/engram-guard +1 -1
  11. package/bin/gentle-shell.mjs +138 -155
  12. package/docs/bundled-install.md +47 -42
  13. package/docs/delegated-verification.md +6 -0
  14. package/docs/gentle-agents-activity.md +5 -1
  15. package/docs/gentle-music.md +82 -0
  16. package/docs/gentle-shell.md +80 -10
  17. package/docs/install-wizard.md +30 -6
  18. package/docs/permission-modes.md +7 -2
  19. package/docs/readme-reference.md +72 -55
  20. package/docs/session-profile-format.md +26 -0
  21. package/docs/voice.md +259 -0
  22. package/docs/yolo-mode.md +17 -2
  23. package/extensions/ask-user-choice.ts +4 -1
  24. package/extensions/gentle-agents.ts +187 -40
  25. package/extensions/gentle-ai.ts +1765 -1185
  26. package/extensions/gentle-design.ts +158 -0
  27. package/extensions/gentle-music.ts +354 -0
  28. package/extensions/gentle-shell.ts +418 -79
  29. package/extensions/gentle-voice.ts +11 -0
  30. package/extensions/session-scratch.ts +41 -22
  31. package/extensions/skill-registry.ts +63 -8
  32. package/lib/agent-profiles.ts +26 -0
  33. package/lib/agents-config.ts +35 -0
  34. package/lib/agents-protocol.ts +57 -1
  35. package/lib/agents-runner.ts +52 -3
  36. package/lib/agents-session-transport.ts +101 -6
  37. package/lib/agents-widget.ts +14 -1
  38. package/lib/auto-permission-memory.ts +48 -18
  39. package/lib/auto-permission-runtime.ts +21 -6
  40. package/lib/clipboard-image.ts +147 -0
  41. package/lib/command-palette-catalog.ts +3 -2
  42. package/lib/commit-slice-signal.ts +1 -1
  43. package/lib/context-backstop.ts +36 -0
  44. package/lib/design-canvas-server.ts +947 -0
  45. package/lib/design-comments.ts +437 -0
  46. package/lib/design-handoffs.ts +313 -0
  47. package/lib/design-open-browser.ts +58 -0
  48. package/lib/design-sources.ts +120 -0
  49. package/lib/external-editor.ts +31 -0
  50. package/lib/gentle-design.ts +513 -0
  51. package/lib/gentle-shell-bundled.ts +141 -57
  52. package/lib/gentle-shell-launcher.ts +11 -4
  53. package/lib/gentle-shell-startup-update.ts +61 -9
  54. package/lib/gentle-shell-uninstall.ts +33 -1
  55. package/lib/image-attachments.ts +150 -0
  56. package/lib/inprocess-reviewer.ts +33 -2
  57. package/lib/jobs-sidebar-state.ts +22 -3
  58. package/lib/jobs-widget.ts +112 -0
  59. package/lib/nan-provider.ts +3 -0
  60. package/lib/native-review-cli.ts +11 -4
  61. package/lib/notification-audio-native.ts +149 -13
  62. package/lib/notification-audio.ts +4 -0
  63. package/lib/notification-pulse-worker.ts +82 -18
  64. package/lib/notification-service.ts +5 -1
  65. package/lib/orchestrator-presence.ts +78 -8
  66. package/lib/orchestrator-scope.ts +10 -0
  67. package/lib/orchestrator-state.ts +11 -3
  68. package/lib/permission-mode-policy.ts +2 -2
  69. package/lib/review-host-relay.ts +11 -2
  70. package/lib/review-reminder-receipt.ts +24 -1
  71. package/lib/session-profile-append-controller.ts +713 -0
  72. package/lib/session-profile-disk-reader.ts +182 -0
  73. package/lib/session-profile-freeze.ts +2 -2
  74. package/lib/session-profile-persistence.ts +15 -7
  75. package/lib/session-scratch.ts +52 -3
  76. package/lib/session-worktree-registry.ts +7 -0
  77. package/lib/shell-bar.ts +6 -7
  78. package/lib/shell-changes.ts +81 -5
  79. package/lib/shell-prompt.ts +4 -4
  80. package/lib/shell-sidebar-layout.ts +27 -7
  81. package/lib/shell-todo.ts +1 -1
  82. package/lib/verifier-admission.ts +5 -4
  83. package/lib/vim-editor-adapter.ts +14 -10
  84. package/lib/voice-artifacts.ts +188 -0
  85. package/lib/voice-command.ts +576 -0
  86. package/lib/voice-install.ts +455 -0
  87. package/lib/voice-platform.ts +99 -0
  88. package/lib/voice-push-to-talk.ts +262 -0
  89. package/lib/voice-session.ts +460 -0
  90. package/lib/voice-settings.ts +50 -0
  91. package/lib/voice-ui.ts +158 -0
  92. package/lib/yolo-session-policy.ts +58 -22
  93. package/package.json +1 -1
  94. package/runtime/gentle-shell-bundled.mjs +140 -56
  95. package/runtime/gentle-shell-launcher.mjs +11 -4
  96. package/runtime/gentle-shell-startup-update.mjs +61 -9
  97. package/runtime/gentle-shell-uninstall.mjs +33 -1
  98. package/runtime/native-review-cli.mjs +11 -4
  99. package/scripts/bundled-engram-migration.mjs +0 -6
  100. package/scripts/bundled-install.mjs +209 -13
  101. package/scripts/gentle-ai-installer.mjs +10 -10
  102. package/scripts/installer-downloads.mjs +31 -7
  103. package/scripts/installer-preflight.mjs +6 -4
  104. package/scripts/installer-windows.mjs +37 -7
  105. package/scripts/main-channel.mjs +91 -17
  106. package/scripts/pi-update.mjs +37 -7
  107. package/scripts/verify-package-files.mjs +26 -2
  108. package/scripts/voice-worker.cjs +476 -0
  109. package/skills/design/SKILL.md +110 -0
  110. package/tests/agent-profiles.test.ts +24 -0
  111. package/tests/agents-config.test.ts +61 -0
  112. package/tests/agents-fake-child.ts +8 -1
  113. package/tests/agents-protocol.test.ts +32 -0
  114. package/tests/agents-runner.test.ts +226 -5
  115. package/tests/agents-session-transport.test.ts +53 -3
  116. package/tests/ask-user-choice.test.ts +30 -0
  117. package/tests/auto-mode.test.ts +17 -0
  118. package/tests/auto-permission-memory-diagnostics.test.ts +56 -0
  119. package/tests/auto-permission-runtime.test.ts +85 -1
  120. package/tests/autonomous-guard.test.ts +20 -0
  121. package/tests/bundled-install.test.ts +276 -5
  122. package/tests/clipboard-image.test.ts +114 -0
  123. package/tests/command-palette.test.ts +4 -4
  124. package/tests/context-backstop.test.ts +70 -4
  125. package/tests/delegate-for-reason-contract.test.ts +2 -1
  126. package/tests/design-canvas-assets.test.ts +710 -0
  127. package/tests/design-canvas-server.test.ts +1096 -0
  128. package/tests/design-comments.test.ts +201 -0
  129. package/tests/design-handoffs.test.ts +282 -0
  130. package/tests/design-open-browser.test.ts +70 -0
  131. package/tests/design-sources.test.ts +132 -0
  132. package/tests/engram-safety.test.ts +132 -57
  133. package/tests/external-editor.test.ts +84 -0
  134. package/tests/fixtures/primary-session-prompt/gentleman.golden.md +3 -3
  135. package/tests/gentle-agents.test.ts +369 -24
  136. package/tests/gentle-ai-binary.test.ts +1 -1
  137. package/tests/gentle-ai-installer.test.ts +51 -51
  138. package/tests/gentle-ai.test.ts +928 -448
  139. package/tests/gentle-design.test.ts +1313 -0
  140. package/tests/gentle-music.test.ts +452 -0
  141. package/tests/gentle-shell-bundled.test.ts +321 -248
  142. package/tests/gentle-shell-launcher.test.ts +29 -2
  143. package/tests/gentle-shell-startup-update.test.ts +77 -1
  144. package/tests/gentle-shell-uninstall.test.ts +59 -0
  145. package/tests/gentle-shell.test.ts +227 -27
  146. package/tests/gentle-todo.test.ts +11 -0
  147. package/tests/image-attachments.test.ts +119 -0
  148. package/tests/image-paste-chips.test.ts +236 -0
  149. package/tests/inprocess-reviewer.test.ts +55 -2
  150. package/tests/installer-bundles.test.ts +12 -6
  151. package/tests/installer-downloads.test.ts +32 -0
  152. package/tests/installer-posix-bootstrap.test.ts +8 -8
  153. package/tests/installer-preflight.test.ts +16 -0
  154. package/tests/installer-server.test.ts +2 -2
  155. package/tests/installer-windows-bootstrap.test.ts +61 -3
  156. package/tests/installer-windows-diagnostics.test.ts +111 -0
  157. package/tests/jobs-widget.test.ts +138 -0
  158. package/tests/main-channel-upgrade.test.ts +232 -2
  159. package/tests/main-channel.test.ts +140 -21
  160. package/tests/model-profiles-preservation.test.ts +2 -2
  161. package/tests/nan-provider.test.ts +36 -0
  162. package/tests/native-review-capability-contract.test.ts +11 -4
  163. package/tests/native-review-parity.test.ts +1 -1
  164. package/tests/notification-audio-native.test.ts +128 -9
  165. package/tests/notification-customize.test.ts +17 -5
  166. package/tests/odd-routing-canonical-ratchet.test.ts +2 -1
  167. package/tests/orchestrator-budget.test.ts +5 -2
  168. package/tests/orchestrator-presence.test.ts +100 -2
  169. package/tests/orchestrator-rdd-ownership.test.ts +2 -2
  170. package/tests/orchestrator-scope.test.ts +14 -0
  171. package/tests/orchestrator-state.test.ts +29 -0
  172. package/tests/package-files.test.ts +34 -0
  173. package/tests/package-manifest.test.ts +6 -6
  174. package/tests/permission-mode-policy.test.ts +10 -1
  175. package/tests/pi-update.test.ts +104 -4
  176. package/tests/rdd-aware-verification-contract.test.ts +1 -1
  177. package/tests/review-host-relay-routing.test.ts +88 -1
  178. package/tests/review-host-relay.test.ts +9 -2
  179. package/tests/review-json-arguments.test.ts +20 -3
  180. package/tests/review-loadout-rdd-off-sdk.test.ts +23 -2
  181. package/tests/review-relay-transport-agent.test.ts +26 -0
  182. package/tests/review-reminder-receipt.test.ts +31 -1
  183. package/tests/runtime-harness.mjs +188 -211
  184. package/tests/session-changes.test.ts +2 -1
  185. package/tests/session-profile-append-controller-fork.test.ts +458 -0
  186. package/tests/session-profile-append-controller-pi.test.ts +290 -0
  187. package/tests/session-profile-append-controller.test.ts +731 -0
  188. package/tests/session-profile-disk-reader-pi.test.ts +183 -0
  189. package/tests/session-profile-disk-reader.test.ts +311 -0
  190. package/tests/session-profile-freeze.test.ts +6 -4
  191. package/tests/session-scratch.test.ts +68 -2
  192. package/tests/session-worktree-registry.test.ts +13 -1
  193. package/tests/shell-bar.test.ts +18 -6
  194. package/tests/shell-changes.test.ts +148 -1
  195. package/tests/shell-prompt.test.ts +3 -3
  196. package/tests/shell-render-cache.test.ts +134 -1
  197. package/tests/shell-sidebar-layout.test.ts +67 -0
  198. package/tests/short-check-routing-contract.test.ts +1 -1
  199. package/tests/skill-collision-prefixes.test.ts +3 -1
  200. package/tests/skill-registry-watcher.test.ts +17 -5
  201. package/tests/skill-registry.test.ts +47 -3
  202. package/tests/task-size-routing-contract.test.ts +1 -1
  203. package/tests/test-strength-contract.test.ts +1 -1
  204. package/tests/verifier-admission.test.ts +18 -1
  205. package/tests/vim-editor-adapter.test.ts +2 -2
  206. package/tests/voice-command.test.ts +773 -0
  207. package/tests/voice-install.test.ts +323 -0
  208. package/tests/voice-platform.test.ts +176 -0
  209. package/tests/voice-push-to-talk.test.ts +444 -0
  210. package/tests/voice-session.test.ts +434 -0
  211. package/tests/voice-settings.test.ts +49 -0
  212. package/tests/voice-ui.test.ts +97 -0
  213. package/tests/voice-worker.test.ts +445 -0
  214. package/tests/weak-model-economy-prompt.test.ts +32 -0
  215. package/tests/windows-hidden-processes.test.ts +25 -2
  216. package/tests/yolo-customize.test.ts +5 -1
  217. package/tests/yolo-mode.test.ts +1 -1
  218. package/tests/yolo-session-policy-capture.test.ts +104 -0
@@ -0,0 +1,11 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { registerVoiceCommand, type VoiceCommandDeps } from "../lib/voice-command.ts";
3
+
4
+ // Gentle Voice: /gentle:voice owns optional local dictation (docs/voice.md).
5
+ // Loading this extension registers the command and, when the push-to-talk
6
+ // toggle is on, a hold-Space listener at session start. Nothing is probed,
7
+ // downloaded, loaded or recorded until the user runs the command or holds Space.
8
+
9
+ export default function gentleVoice(pi: ExtensionAPI, deps: VoiceCommandDeps = {}): void {
10
+ registerVoiceCommand(pi, deps);
11
+ }
@@ -15,9 +15,11 @@ import {
15
15
  isProtectedPath,
16
16
  isSessionScratchName,
17
17
  isWithin,
18
+ LEDGER_ARTIFACTS_CAP,
18
19
  LEDGER_VERSION,
19
20
  ledgerPath,
20
21
  listLedgers,
22
+ listLedgersSync,
21
23
  liveSubagentScratch,
22
24
  processStartToken,
23
25
  readLedger,
@@ -32,6 +34,7 @@ import {
32
34
  type ScratchLedger,
33
35
  type StoredLedger,
34
36
  writeLedger,
37
+ writeLedgerSync,
35
38
  } from "../lib/session-scratch.ts";
36
39
 
37
40
  // Session scratch and artifact ledger (odd/tasks/session-scratch-ledger.md):
@@ -92,10 +95,18 @@ export function createSessionScratchExtension(options: SessionScratchOptions = {
92
95
  const tempVariables = (options.platform ?? process.platform) === "win32" ? ["TMPDIR", "TMP", "TEMP"] : ["TMPDIR"];
93
96
  const configHome = () => options.configHome ?? gentlePiConfigHome(env);
94
97
 
95
- const persist = (path: string, ledger: ScratchLedger) => {
98
+ const persist = async (path: string, ledger: ScratchLedger) => {
96
99
  try {
97
100
  if (ledger.scratchDir === null && ledger.artifacts.length === 0) deleteLedger(path);
98
- else writeLedger(path, ledger);
101
+ else await writeLedger(path, ledger);
102
+ } catch { /* Best effort: an unwritable config home keeps the session running. */ }
103
+ };
104
+ // The shutdown path stays synchronous: its callers (tests/agents-runner.ts, and any
105
+ // handler consumer that does not await) read its post-conditions right after it runs.
106
+ const persistSync = (path: string, ledger: ScratchLedger) => {
107
+ try {
108
+ if (ledger.scratchDir === null && ledger.artifacts.length === 0) deleteLedger(path);
109
+ else writeLedgerSync(path, ledger);
99
110
  } catch { /* Best effort: an unwritable config home keeps the session running. */ }
100
111
  };
101
112
  // Paths the runtime never records or removes, whatever a ledger says.
@@ -121,26 +132,28 @@ export function createSessionScratchExtension(options: SessionScratchOptions = {
121
132
  const endSession = (session: ActiveSession) => {
122
133
  const { ledger } = session;
123
134
  // A running or quarantined subagent's TMPDIR stays; the scratch then stays in the ledger for a later sweep.
124
- const removal = removeSessionScratch(ledger, heldBy(listLedgers(configHome()))(session.path, ledger), { ownRoot: ledger.tempRoot ?? undefined, liveSubagentDirs: subagentDirs() });
135
+ const removal = removeSessionScratch(ledger, heldBy(listLedgersSync(configHome()))(session.path, ledger), { ownRoot: ledger.tempRoot ?? undefined, liveSubagentDirs: subagentDirs() });
125
136
  if (removal === "removed" || removal === "absent") ledger.scratchDir = null;
126
137
  for (const [key, value] of Object.entries(ledger.inherited)) {
127
138
  if (value === null) delete env[key];
128
139
  else env[key] = value;
129
140
  }
130
- persist(session.path, ledger);
141
+ persistSync(session.path, ledger);
131
142
  activeSessions.delete(env);
132
143
  pending.clear();
133
144
  };
134
145
 
135
146
  // S5(b): scratch of ended sessions past the grace period; their artifacts stay for the command.
147
+ // The startup sweep stays synchronous: session_start's callers (tests/agents-runner.ts) read
148
+ // its post-conditions right after it runs, without awaiting the handler.
136
149
  const sweep = (ownPath: string) => {
137
- const ledgers = listLedgers(configHome());
150
+ const ledgers = listLedgersSync(configHome());
138
151
  const held = heldBy(ledgers);
139
152
  for (const { path, ledger, mtimeMs } of ledgers) {
140
153
  if (path === ownPath || isLive(ledger) || now() - mtimeMs < graceMs) continue;
141
154
  const artifacts = ledger.artifacts.filter((artifact) => { try { lstatSync(artifact.path); return true; } catch { return false; } });
142
155
  const removal = ledger.scratchDir === null ? "absent" : removeSessionScratch(ledger, held(path, { ...ledger, artifacts }), { ownRoot: ownRoot(), liveSubagentDirs: subagentDirs() });
143
- persist(path, { ...ledger, scratchDir: removal === "removed" || removal === "absent" ? null : ledger.scratchDir, artifacts });
156
+ persistSync(path, { ...ledger, scratchDir: removal === "removed" || removal === "absent" ? null : ledger.scratchDir, artifacts });
144
157
  }
145
158
  };
146
159
 
@@ -152,6 +165,8 @@ export function createSessionScratchExtension(options: SessionScratchOptions = {
152
165
  return !!current && [resolve(current), realTempRoot(current)].some((dir) => dir !== undefined && isWithin(dir, ledger.scratchDir!));
153
166
  };
154
167
 
168
+ // The startup path stays synchronous, like the shutdown path: its callers
169
+ // (tests/agents-runner.ts) read its post-conditions without awaiting the handler.
155
170
  const startSession = (ctx: ExtensionContext, reason: string) => {
156
171
  const sessionId = ctx.sessionManager.getSessionId?.();
157
172
  if (!sessionId) return;
@@ -162,7 +177,7 @@ export function createSessionScratchExtension(options: SessionScratchOptions = {
162
177
  const previous = readLedger(path);
163
178
  if (previous && isLiveScratch(previous)) {
164
179
  activeSessions.set(env, { sessionId, path, ledger: previous });
165
- persist(path, previous);
180
+ persistSync(path, previous);
166
181
  sweep(path);
167
182
  return;
168
183
  }
@@ -202,11 +217,11 @@ export function createSessionScratchExtension(options: SessionScratchOptions = {
202
217
  };
203
218
  // A scratch of this process is never removed at startup: it may still be in use.
204
219
  if (previous?.scratchDir && previous.scratchDir !== scratchDir && previous.pid !== process.pid && !isLive(previous)) {
205
- const held = heldBy(listLedgers(configHome()))(path, ledger);
220
+ const held = heldBy(listLedgersSync(configHome()))(path, ledger);
206
221
  removeSessionScratch(previous, scratchDir ? [...held, scratchDir] : held, { ownRoot: ownRoot(), liveSubagentDirs: subagentDirs() });
207
222
  }
208
223
  activeSessions.set(env, { sessionId, path, ledger });
209
- persist(path, ledger);
224
+ persistSync(path, ledger);
210
225
  sweep(path);
211
226
  };
212
227
 
@@ -222,8 +237,10 @@ export function createSessionScratchExtension(options: SessionScratchOptions = {
222
237
  if (absent.length > 0) pending.set(toolCallId, absent);
223
238
  };
224
239
 
225
- // S4: the noted destinations that a successful command created.
226
- const capture = (targets: readonly ArtifactTarget[], command: string) => {
240
+ // S4: the noted destinations that a successful command created. The ledger keeps
241
+ // only the most recent artifacts, evicting the oldest first, so a long session's
242
+ // ledger file stays bounded.
243
+ const capture = async (targets: readonly ArtifactTarget[], command: string) => {
227
244
  const session = activeSessions.get(env);
228
245
  if (!session) return;
229
246
  const { ledger } = session;
@@ -238,22 +255,24 @@ export function createSessionScratchExtension(options: SessionScratchOptions = {
238
255
  ledger.artifacts.push({ kind: target.kind, path, command, recordedAt: new Date(now()).toISOString() });
239
256
  changed = true;
240
257
  }
241
- if (changed) persist(session.path, ledger);
258
+ if (!changed) return;
259
+ if (ledger.artifacts.length > LEDGER_ARTIFACTS_CAP) ledger.artifacts = ledger.artifacts.slice(-LEDGER_ARTIFACTS_CAP);
260
+ await persist(session.path, ledger);
242
261
  };
243
262
 
244
- const endedLedgers = (ownPath: string | undefined) =>
245
- listLedgers(configHome()).filter(({ path, ledger }) => path !== ownPath && !isLive(ledger));
263
+ const endedLedgers = async (ownPath: string | undefined) =>
264
+ (await listLedgers(configHome())).filter(({ path, ledger }) => path !== ownPath && !isLive(ledger));
246
265
 
247
266
  const describe = (artifact: ScratchArtifact) => `${artifact.kind} ${artifact.path}`;
248
267
 
249
- const list = (): string => {
268
+ const list = async (): Promise<string> => {
250
269
  const session = activeSessions.get(env);
251
270
  const lines: string[] = [];
252
271
  const own = session?.ledger;
253
272
  lines.push(own?.scratchDir
254
273
  ? `Session scratch: ${own.scratchDir} (${formatSize(directorySize(own.scratchDir))})`
255
274
  : `Session scratch: none (TMPDIR ${env.TMPDIR ?? tmpdir()})`);
256
- const ended = endedLedgers(session?.path);
275
+ const ended = await endedLedgers(session?.path);
257
276
  for (const { ledger } of ended) if (ledger.scratchDir) lines.push(`Ended-session scratch: ${ledger.scratchDir} (session ${ledger.sessionId})`);
258
277
  const artifacts = [
259
278
  ...(own?.artifacts ?? []).map((artifact) => ` ${describe(artifact)} (this session)`),
@@ -269,7 +288,7 @@ export function createSessionScratchExtension(options: SessionScratchOptions = {
269
288
  const session = activeSessions.get(env);
270
289
  const groups = [
271
290
  ...(session ? [{ path: session.path, ledger: session.ledger, own: true }] : []),
272
- ...endedLedgers(session?.path).map(({ path, ledger }) => ({ path, ledger, own: false })),
291
+ ...(await endedLedgers(session?.path)).map(({ path, ledger }) => ({ path, ledger, own: false })),
273
292
  ];
274
293
  const lines: string[] = [];
275
294
  const entries = groups.flatMap((group) => group.ledger.artifacts.map((artifact) => ({ group, artifact })));
@@ -305,7 +324,7 @@ export function createSessionScratchExtension(options: SessionScratchOptions = {
305
324
  }
306
325
  }
307
326
  // Scratch goes without asking; the live session keeps its TMPDIR and empties it.
308
- const held = heldBy(listLedgers(configHome()));
327
+ const held = heldBy(await listLedgers(configHome()));
309
328
  for (const group of groups) {
310
329
  const { ledger } = group;
311
330
  if (!ledger.scratchDir) continue;
@@ -316,7 +335,7 @@ export function createSessionScratchExtension(options: SessionScratchOptions = {
316
335
  else if (removal === "kept") lines.push(`${group.own ? "Emptied" : "Kept"} scratch ${dir}${group.own ? " except subagent scratch, live sessions' scratch, recorded artifacts, and entries changed in the last minute" : ": holds a recorded artifact, a live session's scratch, or a live subagent's scratch"}`);
317
336
  else lines.push(`${group.own ? "Emptied" : "Removed"} scratch ${dir}`);
318
337
  }
319
- for (const group of groups) persist(group.path, group.ledger);
338
+ for (const group of groups) await persist(group.path, group.ledger);
320
339
  return lines.length > 0 ? lines.join("\n") : "Nothing to clean.";
321
340
  };
322
341
 
@@ -338,13 +357,13 @@ export function createSessionScratchExtension(options: SessionScratchOptions = {
338
357
  try { note(event.toolCallId, command, ctx); } catch { /* Capture never blocks a tool call. */ }
339
358
  return undefined;
340
359
  });
341
- pi.on("tool_result", (event) => {
360
+ pi.on("tool_result", async (event) => {
342
361
  const targets = pending.get(event.toolCallId);
343
362
  if (!targets) return undefined;
344
363
  pending.delete(event.toolCallId);
345
364
  const command = (event.input as { command?: unknown } | undefined)?.command;
346
365
  if (event.toolName !== "bash" || event.isError !== false || typeof command !== "string") return undefined;
347
- try { capture(targets, command); } catch { /* Capture never changes a tool result. */ }
366
+ try { await capture(targets, command); } catch { /* Capture never changes a tool result. */ }
348
367
  return undefined;
349
368
  });
350
369
  pi.registerCommand("gentle:scratch", {
@@ -352,7 +371,7 @@ export function createSessionScratchExtension(options: SessionScratchOptions = {
352
371
  getArgumentCompletions: (prefix) => ["list", "clean"].filter((action) => action.startsWith(prefix.trim())).map((action) => ({ value: action, label: action })),
353
372
  handler: async (args, ctx) => {
354
373
  const action = args.trim() || "list";
355
- if (action === "list") ctx.ui.notify(list(), "info");
374
+ if (action === "list") ctx.ui.notify(await list(), "info");
356
375
  else if (action === "clean") ctx.ui.notify(await clean(ctx), "info");
357
376
  else ctx.ui.notify(SCRATCH_COMMAND_USAGE, "warning");
358
377
  },
@@ -25,6 +25,11 @@ const SECTION_MARKER = "## Skills";
25
25
  const EXCLUDE_NAMES = new Set(["_shared", "skill-registry"]);
26
26
  const EXCLUDE_PREFIXES = ["sdd-"];
27
27
  const WATCH_DEBOUNCE_MS = 500;
28
+ // inotify budgets are finite (ENOSPC/EMFILE) and recursive watches degrade
29
+ // silently on exhausted systems -- WSL2 hits this constantly -- so a slow
30
+ // poll is the floor that keeps regeneration working when the OS cannot
31
+ // deliver change events. The fingerprint gate makes a redundant poll cheap.
32
+ const WATCH_FALLBACK_POLL_MS = 2_000;
28
33
  const REGISTRY_SCHEMA_VERSION = 7;
29
34
  const NO_SKILL_REGISTRY_FLAG = "no-skill-registry";
30
35
  const NO_SKILL_REGISTRY_ENV = "GENTLE_PI_NO_SKILL_REGISTRY";
@@ -98,6 +103,11 @@ function projectSkillDirs(cwd: string): string[] {
98
103
  ];
99
104
  }
100
105
 
106
+ function isMissingSkillFileError(error: unknown): boolean {
107
+ if (typeof error !== "object" || error === null || !("code" in error)) return false;
108
+ return error.code === "ENOENT" || error.code === "ENOTDIR";
109
+ }
110
+
101
111
  async function findSkillFiles(root: string): Promise<string[]> {
102
112
  if (!(await pathExists(root))) return [];
103
113
  let entries;
@@ -107,7 +117,8 @@ async function findSkillFiles(root: string): Promise<string[]> {
107
117
  return [];
108
118
  }
109
119
 
110
- const out: string[] = [];
120
+ const directSkills: string[] = [];
121
+ const nestedSkills: string[] = [];
111
122
  for (const entry of entries) {
112
123
  const skillDir = join(root, entry.name);
113
124
  let dirInfo;
@@ -121,12 +132,40 @@ async function findSkillFiles(root: string): Promise<string[]> {
121
132
  const candidate = join(skillDir, "SKILL.md");
122
133
  try {
123
134
  const skillInfo = await stat(candidate);
124
- if (skillInfo.isFile()) out.push(candidate);
135
+ if (skillInfo.isFile()) directSkills.push(candidate);
136
+ continue;
137
+ } catch (error) {
138
+ if (!isMissingSkillFileError(error)) continue;
139
+ }
140
+ if (entry.isSymbolicLink() || isExcluded(entry.name)) continue;
141
+
142
+ let nestedEntries;
143
+ try {
144
+ nestedEntries = await readdir(skillDir, { withFileTypes: true });
125
145
  } catch {
126
- // Missing or unreadable skill files are ignored; the registry is best-effort.
146
+ continue;
147
+ }
148
+ for (const nestedEntry of nestedEntries) {
149
+ if (nestedEntry.isSymbolicLink()) continue;
150
+ const nestedSkillDir = join(skillDir, nestedEntry.name);
151
+ let nestedDirInfo;
152
+ try {
153
+ nestedDirInfo = await stat(nestedSkillDir);
154
+ } catch {
155
+ continue;
156
+ }
157
+ if (!nestedDirInfo.isDirectory()) continue;
158
+
159
+ const nestedCandidate = join(nestedSkillDir, "SKILL.md");
160
+ try {
161
+ const nestedSkillInfo = await stat(nestedCandidate);
162
+ if (nestedSkillInfo.isFile()) nestedSkills.push(nestedCandidate);
163
+ } catch {
164
+ // Missing or unreadable skill files are ignored; the registry is best-effort.
165
+ }
127
166
  }
128
167
  }
129
- return out.sort();
168
+ return [...directSkills.sort(), ...nestedSkills.sort()];
130
169
  }
131
170
 
132
171
  function parseFrontmatter(source: string): { name?: string; description?: string; body: string } {
@@ -346,7 +385,7 @@ async function hasNoGitMetadata(cwd: string): Promise<boolean> {
346
385
  }
347
386
 
348
387
  // Freshness (including forced self-heal) never grants automatic write permission.
349
- async function automaticWriteWarning(
388
+ export async function automaticWriteWarning(
350
389
  cwd: string,
351
390
  targets: string[],
352
391
  remedy = "Run /skill-registry:refresh to deliberately regenerate runtime files.",
@@ -357,6 +396,7 @@ async function automaticWriteWarning(
357
396
  cwd,
358
397
  env: { ...process.env, LC_ALL: "C" },
359
398
  timeout: gitTimeoutMs,
399
+ windowsHide: true,
360
400
  });
361
401
  tracked = stdout.split("\0").filter(Boolean);
362
402
  } catch (error) {
@@ -496,6 +536,7 @@ async function regenerateRegistry(
496
536
  }
497
537
 
498
538
  const watchedCwds = new Set<string>();
539
+ const watcherPolls = new Map<string, ReturnType<typeof setInterval>>();
499
540
  // Startup regeneration runs after session_start returns; watcher refreshes
500
541
  // wait for it so an older startup scan never overwrites a newer refresh.
501
542
  const pendingStartups = new Map<string, Promise<void>>();
@@ -574,6 +615,8 @@ function closeSkillRegistryWatchers(): void {
574
615
  }
575
616
  }
576
617
  activeWatchers.clear();
618
+ for (const poll of watcherPolls.values()) clearInterval(poll);
619
+ watcherPolls.clear();
577
620
  watchedCwds.clear();
578
621
  }
579
622
 
@@ -588,6 +631,9 @@ async function startSkillRegistryWatcher(
588
631
  ...userSkillDirs(),
589
632
  ]);
590
633
  let timer: ReturnType<typeof setTimeout> | undefined;
634
+ // The poll re-asks a state the watchers already answered: without this,
635
+ // an unchanged tracked-target warning would repeat every poll interval.
636
+ let lastWarning: string | undefined;
591
637
  const refresh = () => {
592
638
  if (timer) clearTimeout(timer);
593
639
  timer = setTimeout(() => {
@@ -595,9 +641,14 @@ async function startSkillRegistryWatcher(
595
641
  try {
596
642
  await pendingStartups.get(cwd);
597
643
  const result = await regenerateRegistry(cwd, false);
598
- if (result.warning) notify(result.warning, "warning");
599
- if (result.regenerated) {
600
- notify(`Skill registry refreshed (${result.skillCount} skills)`, "info");
644
+ if (result.warning) {
645
+ if (result.warning !== lastWarning) notify(result.warning, "warning");
646
+ lastWarning = result.warning;
647
+ } else {
648
+ lastWarning = undefined;
649
+ if (result.regenerated) {
650
+ notify(`Skill registry refreshed (${result.skillCount} skills)`, "info");
651
+ }
601
652
  }
602
653
  } catch (error) {
603
654
  notify(`Skill registry refresh failed: ${error instanceof Error ? error.message : String(error)}`, "warning");
@@ -620,12 +671,16 @@ async function startSkillRegistryWatcher(
620
671
  // Some filesystems do not support recursive watches; session_start/manual refresh still work.
621
672
  }
622
673
  }
674
+ // Watchers may be absent (unsupported filesystem) or silently eventless
675
+ // (exhausted inotify budget): the poll keeps regeneration alive either way.
676
+ if (!watcherPolls.has(cwd)) watcherPolls.set(cwd, setInterval(refresh, WATCH_FALLBACK_POLL_MS));
623
677
  }
624
678
 
625
679
  export const __testing = {
626
680
  projectSkillDirs,
627
681
  userSkillDirs,
628
682
  findSkillFiles,
683
+ isMissingSkillFileError,
629
684
  uniqueExistingDirs,
630
685
  dedupeBySkillName,
631
686
  scopeForPath,
@@ -8,6 +8,9 @@
8
8
  import { randomUUID } from "node:crypto";
9
9
  import { closeSync, constants, existsSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
10
10
  import { basename, dirname, join } from "node:path";
11
+ import {
12
+ unmatchedProfileKeys,
13
+ } from "./agents-config.ts";
11
14
  import {
12
15
  normalizeModelConfig,
13
16
  type AgentModelConfig,
@@ -364,6 +367,29 @@ export function profileRoutingRows(config: AgentModelConfig): ProfileRoutingRow[
364
367
  .sort((left, right) => left.agent.localeCompare(right.agent));
365
368
  }
366
369
 
370
+ /**
371
+ * Issue #2196 (PR #2233 maintainer decision): a routing row whose key names no
372
+ * discovered agent was rendered as a valid-looking row while its setting was
373
+ * silently ignored at launch. Each unmatched row gets one note: a did-you-mean
374
+ * suggestion when the packaged short name maps to a discovered agent, and a
375
+ * plain "no matching agent" otherwise. Matched rows get no note.
376
+ */
377
+ export function unmatchedRoutingNotes(
378
+ rows: ProfileRoutingRow[],
379
+ agentNames: string[],
380
+ ): Map<string, string> {
381
+ const unmatched = unmatchedProfileKeys(
382
+ Object.fromEntries(rows.map((row) => [row.agent, { model: undefined, thinking: undefined }])),
383
+ agentNames.map((name) => ({ name })),
384
+ );
385
+ return new Map(
386
+ unmatched.map(({ key, suggestion }) => [
387
+ key,
388
+ suggestion === undefined ? "no matching agent" : `did you mean ${suggestion}?`,
389
+ ]),
390
+ );
391
+ }
392
+
367
393
  /**
368
394
  * Column widths shared by every routing table in one panel, so the profile's
369
395
  * routing and the effective routing align column for column instead of each
@@ -316,6 +316,41 @@ function pick<T>(candidates: Array<[T | undefined, ProfileSource]>): [T | undefi
316
316
  return candidates.find(([value]) => value !== undefined) ?? [undefined, PROFILE_SOURCE.UNRESOLVED];
317
317
  }
318
318
 
319
+ export interface UnmatchedProfileKey {
320
+ key: string;
321
+ suggestion: string | undefined;
322
+ }
323
+
324
+ // Issue #2196: a role key that names no discovered agent was silently ignored,
325
+ // so the child inherited the orchestrator model with no warning anywhere. The
326
+ // alias approach (PR #2233) was rejected because applying a bundled agent's
327
+ // profile to a user-owned same-short-name agent is the exact silence the
328
+ // prefix exists to prevent; reporting the unmatched key keeps resolution
329
+ // exact-match and makes the misconfiguration visible instead.
330
+ export function unmatchedProfileKeys(
331
+ modelProfiles: Record<string, ModelProfile>,
332
+ agents: ReadonlyArray<{ name: string }>,
333
+ ): UnmatchedProfileKey[] {
334
+ const names = new Set(agents.map((agent) => agent.name));
335
+ const lowerNames = new Set(agents.map((agent) => agent.name.toLowerCase()));
336
+ const suggestionFor = (key: string): string | undefined => {
337
+ const lowered = key.toLowerCase();
338
+ if (lowerNames.has(lowered)) return agents.find((agent) => agent.name.toLowerCase() === lowered)?.name;
339
+ const stripped = lowered.replace(/^gentle-ai-/, "");
340
+ for (const candidate of [lowered, stripped]) {
341
+ for (const name of lowerNames) {
342
+ if (name === candidate || name === `gentle-ai-${candidate}`) {
343
+ return agents.find((agent) => agent.name.toLowerCase() === name)?.name;
344
+ }
345
+ }
346
+ }
347
+ return undefined;
348
+ };
349
+ return Object.keys(modelProfiles)
350
+ .filter((key) => !names.has(key))
351
+ .map((key) => ({ key, suggestion: suggestionFor(key) }));
352
+ }
353
+
319
354
  export function resolveAgentProfile(agent: AgentDefinition, config: AgentsConfig): ResolvedProfile {
320
355
  const profile = config.modelProfiles[agent.name];
321
356
  const [model, modelSource] = pick<ModelRef>([
@@ -164,6 +164,12 @@ export interface TaskStatusChange {
164
164
  export type StatusChangeListener = (change: TaskStatusChange) => void | Promise<void>;
165
165
 
166
166
  const DEFAULT_LIMITS: ThreadLimits = { maxItems: 400, maxOutputChars: 16_000 };
167
+ /** Finished tasks keep their thread weight only for this many most-recent
168
+ * records per parent session, matching the agents view's visible history cap
169
+ * (MAX_FINISHED_PER_SESSION in agents-view.ts, pinned by tests); older
170
+ * terminal tasks (record, thread, run id, listeners) leave the store
171
+ * entirely. Running tasks are never evicted. */
172
+ export const TERMINAL_RETENTION = 200;
167
173
  /** Child UI requests that block on an answer; everything else (notify, setStatus, setWidget) is noise here. */
168
174
  export const DIALOG_METHODS: ReadonlySet<string> = new Set(["select", "confirm", "input", "editor"]);
169
175
  /** The one child notify that reaches the task thread: requested --tools the child does not have (#1690). */
@@ -481,6 +487,9 @@ export class TaskStore {
481
487
  private sequence = 0;
482
488
  private readonly runIds = new Map<string, string>();
483
489
  private readonly statusListeners = new Set<StatusChangeListener>();
490
+ // Insertion-ordered recency of finished tasks, oldest first (Set keeps
491
+ // insertion order); prune evicts from the front.
492
+ private readonly terminalOrder = new Set<string>();
484
493
 
485
494
  constructor(limits: Partial<ThreadLimits> = {}) {
486
495
  this.limits = limits;
@@ -492,6 +501,11 @@ export class TaskStore {
492
501
  this.tasks.set(task.id, task);
493
502
  this.threads.set(task.id, emptyThread(this.limits));
494
503
  this.notifyStatus(task, previous);
504
+ if (isFinished(task.status)) {
505
+ this.markTerminal(task.id);
506
+ this.listeners.delete(task.id);
507
+ this.pruneTerminal();
508
+ }
495
509
  this.notifySummary();
496
510
  }
497
511
 
@@ -502,6 +516,11 @@ export class TaskStore {
502
516
  this.tasks.set(task.id, task);
503
517
  this.runIds.set(task.id, randomUUID());
504
518
  this.threads.set(task.id, { ...thread, limits: { ...DEFAULT_LIMITS, ...this.limits } });
519
+ if (isFinished(task.status)) {
520
+ this.markTerminal(task.id);
521
+ this.listeners.delete(task.id);
522
+ this.pruneTerminal();
523
+ }
505
524
  this.notifySummary();
506
525
  return true;
507
526
  }
@@ -533,7 +552,16 @@ export class TaskStore {
533
552
  this.tasks.set(id, next);
534
553
  this.notifyStatus(next, current.status);
535
554
  this.notifyTask(next);
536
- if (next.status !== current.status) this.notifySummary();
555
+ if (next.status !== current.status) {
556
+ // The terminal transition itself is the final notification; after it
557
+ // the task never changes, so its listeners stop costing anything.
558
+ if (isFinished(next.status)) {
559
+ this.listeners.delete(next.id);
560
+ this.markTerminal(next.id);
561
+ }
562
+ this.pruneTerminal();
563
+ this.notifySummary();
564
+ }
537
565
  return next;
538
566
  }
539
567
 
@@ -565,6 +593,34 @@ export class TaskStore {
565
593
  return () => { this.statusListeners.delete(listener); };
566
594
  }
567
595
 
596
+ private markTerminal(id: string): void {
597
+ if (!this.terminalOrder.has(id)) this.terminalOrder.add(id);
598
+ }
599
+
600
+ /** Evict the oldest finished tasks beyond TERMINAL_RETENTION per parent
601
+ * session, with their threads, run ids and listeners. Running tasks are
602
+ * never evicted; a task that left the terminal state is skipped as stale. */
603
+ private pruneTerminal(): void {
604
+ const finished = new Map<string, number>();
605
+ for (const task of this.tasks.values()) {
606
+ if (!isFinished(task.status)) continue;
607
+ finished.set(task.parentSessionId, (finished.get(task.parentSessionId) ?? 0) + 1);
608
+ }
609
+ if (finished.size === 0) return;
610
+ for (const id of this.terminalOrder) {
611
+ const task = this.tasks.get(id);
612
+ if (!task || !isFinished(task.status)) { this.terminalOrder.delete(id); continue; }
613
+ const count = finished.get(task.parentSessionId) ?? 0;
614
+ if (count <= TERMINAL_RETENTION) continue;
615
+ this.tasks.delete(id);
616
+ this.threads.delete(id);
617
+ this.listeners.delete(id);
618
+ this.runIds.delete(id);
619
+ this.terminalOrder.delete(id);
620
+ finished.set(task.parentSessionId, count - 1);
621
+ }
622
+ }
623
+
568
624
  private notifyStatus(task: TaskRecord, previousStatus?: TaskStatus): void {
569
625
  if (task.status === previousStatus) return;
570
626
  const change: TaskStatusChange = Object.freeze({ producerId: this.producerId,
@@ -6,6 +6,8 @@ import { isAbsolute, join, resolve } from "node:path";
6
6
  import { Duplex, type Readable, type Writable } from "node:stream";
7
7
  import { stripVTControlCharacters } from "node:util";
8
8
  import { withoutInteractiveHost } from "./rpc-host.ts";
9
+ import { gentlePiConfigHome } from "./agent-home.ts";
10
+ import { resolveBackgroundSubagentsPolicy } from "./background-subagents-policy.ts";
9
11
  import { childPackageExtensionArgs } from "./child-package-injection.ts";
10
12
  import { AGENT_MODE, formatModelRef, type AgentDefinition, type AgentMode, type ModelRef } from "./agents-config.ts";
11
13
  import { CHILD_QUERY_MAX_INFLIGHT, CHILD_QUERY_TIMEOUT_MS, parseChildFrame, validChildMessage, validChildQueryId } from "./agents-messaging.ts";
@@ -70,6 +72,8 @@ export interface RunnerLimits {
70
72
  // Longer ceiling used while an announced tool call is in flight. Optional so
71
73
  // callers that only bound silence keep the idle budget as the tool ceiling.
72
74
  toolStallTimeoutMs?: number;
75
+ // Bounded asynchronous cleanup window allowed after settlement before escalating to SIGTERM/SIGKILL.
76
+ cleanupTimeoutMs?: number;
73
77
  }
74
78
 
75
79
  export interface AskAnswer {
@@ -245,6 +249,7 @@ const TERMINATION_GRACE_MS = 250;
245
249
  const GROUP_CONFIRM_MS = 25;
246
250
  const GROUP_CONFIRM_DEADLINE_MS = 1_000;
247
251
  const QUARANTINE_PROBE_MS = 1_000;
252
+ export const DEFAULT_CLEANUP_TIMEOUT_MS = 5_000;
248
253
  const QUERY_REJECTION_ERRORS = new Set([
249
254
  "invalid child IPC frame",
250
255
  "invalid child IPC correlation",
@@ -552,6 +557,15 @@ export class AgentRunner {
552
557
  delete env.GENTLE_PI_SDD_REMEDIATION_PLAN;
553
558
  delete env.GENTLE_PI_RESEARCH_SELECTION;
554
559
  const tools = requestedTools(request);
560
+ if (request.agent.tools.includes("bash_background")) {
561
+ const policy = resolveBackgroundSubagentsPolicy(request.cwd, { env: request.env, gentlePiConfigHome: gentlePiConfigHome(request.env) });
562
+ if (policy.policy === "off") {
563
+ const file = policy.source === "global_file" ? policy.globalFile : policy.projectFile;
564
+ this.store.update(id, { status: TASK_STATUS.RUNNING, startedAt: this.deps.now(), lastStep: "starting" });
565
+ this.finish(id, TASK_STATUS.FAILED, `could not start pi: explicit tool bash_background requires background-subagents policy on. Set ${file} to {"schema":"gentle-pi.background-subagents/v1","policy":"on"}; inspect or change policy with /gentle:background-subagents enable|disable|status.`);
566
+ return;
567
+ }
568
+ }
555
569
  if (tools !== undefined) env[REQUESTED_TOOLS_ENV] = tools;
556
570
  else delete env[REQUESTED_TOOLS_ENV];
557
571
  let instructionsTransportDir: string | undefined;
@@ -906,9 +920,9 @@ export class AgentRunner {
906
920
  if (event.type === TASK_EVENT.AGENT_SETTLED) {
907
921
  if (live.observations) live.observations.agentSettled = true;
908
922
  const terminal = this.store.get(id);
909
- if (terminal?.error) this.requestStop(id, TASK_STATUS.FAILED, terminal.error);
910
- else if (terminal?.result) this.requestStop(id, TASK_STATUS.COMPLETED, null);
911
- else this.requestStop(id, TASK_STATUS.FAILED, "assistant settled without a final report");
923
+ if (terminal?.error) this.settleOrderly(id, TASK_STATUS.FAILED, terminal.error);
924
+ else if (terminal?.result) this.settleOrderly(id, TASK_STATUS.COMPLETED, null);
925
+ else this.settleOrderly(id, TASK_STATUS.FAILED, "assistant settled without a final report");
912
926
  }
913
927
  }
914
928
  if (!live.terminal && progress) this.armStall(id, live);
@@ -949,6 +963,41 @@ export class AgentRunner {
949
963
  }
950
964
  }
951
965
 
966
+ /**
967
+ * Requests orderly child shutdown via stdin upon settlement and schedules
968
+ * bounded signal escalation if the child process does not exit within cleanupTimeoutMs.
969
+ */
970
+ private settleOrderly(id: string, status: TaskRecord["status"], error: string | null): void {
971
+ const live = this.live.get(id);
972
+ if (!live || live.terminal) return;
973
+ live.terminal = { status, error };
974
+ live.mutationStarts.clear();
975
+ live.inFlightTools.clear();
976
+ live.permissionBroker?.close();
977
+ this.closeIpc(live);
978
+ live.cancelStall();
979
+ const current = this.store.get(id);
980
+ if (current && !isFinished(current.status)) {
981
+ this.store.update(id, { lastStep: "cleaning up" });
982
+ }
983
+ try {
984
+ live.child.stdin.end();
985
+ } catch {
986
+ this.signal(live, "SIGTERM");
987
+ }
988
+ const cleanupTimeoutMs = this.limits.cleanupTimeoutMs ?? DEFAULT_CLEANUP_TIMEOUT_MS;
989
+ live.cancelGrace = this.deps.schedule(() => {
990
+ if (this.live.get(id) !== live) return;
991
+ this.signal(live, "SIGTERM");
992
+ live.cancelGrace = this.deps.schedule(() => {
993
+ if (this.live.get(id) !== live) return;
994
+ live.cleanupDeadlineAt = this.deps.now() + GROUP_CONFIRM_DEADLINE_MS;
995
+ this.signal(live, "SIGKILL");
996
+ this.confirmGroupExit(id, live);
997
+ }, TERMINATION_GRACE_MS);
998
+ }, cleanupTimeoutMs);
999
+ }
1000
+
952
1001
  private requestStop(id: string, status: TaskRecord["status"], error: string | null, abort = false): void {
953
1002
  const live = this.live.get(id);
954
1003
  if (!live || live.terminal) return;