gentle-pi 4.3.0 → 4.4.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.
- package/README.md +8 -5
- package/assets/agents/gentle-ai-worker.md +1 -1
- package/assets/design-canvas/canvas.css +648 -0
- package/assets/design-canvas/canvas.js +1497 -0
- package/assets/design-canvas/index.html +88 -0
- package/assets/design-canvas/inspector.js +290 -0
- package/assets/orchestrator-delegation-reference.md +2 -2
- package/assets/orchestrator-verification.md +1 -1
- package/assets/orchestrator.md +2 -2
- package/bin/engram-guard +1 -1
- package/bin/gentle-shell.mjs +101 -151
- package/docs/bundled-install.md +47 -42
- package/docs/delegated-verification.md +6 -0
- package/docs/gentle-agents-activity.md +5 -1
- package/docs/gentle-music.md +61 -0
- package/docs/gentle-shell.md +77 -7
- package/docs/install-wizard.md +30 -6
- package/docs/permission-modes.md +7 -2
- package/docs/readme-reference.md +19 -15
- package/docs/session-profile-format.md +26 -0
- package/docs/voice.md +259 -0
- package/docs/yolo-mode.md +17 -2
- package/extensions/ask-user-choice.ts +4 -1
- package/extensions/gentle-agents.ts +173 -38
- package/extensions/gentle-ai.ts +214 -69
- package/extensions/gentle-design.ts +158 -0
- package/extensions/gentle-music.ts +289 -0
- package/extensions/gentle-shell.ts +332 -73
- package/extensions/gentle-voice.ts +11 -0
- package/extensions/session-scratch.ts +41 -22
- package/extensions/skill-registry.ts +63 -8
- package/lib/agent-profiles.ts +26 -0
- package/lib/agents-config.ts +35 -0
- package/lib/agents-protocol.ts +57 -1
- package/lib/agents-runner.ts +52 -3
- package/lib/agents-session-transport.ts +101 -6
- package/lib/agents-widget.ts +14 -1
- package/lib/auto-permission-memory.ts +48 -18
- package/lib/auto-permission-runtime.ts +21 -6
- package/lib/clipboard-image.ts +147 -0
- package/lib/command-palette-catalog.ts +2 -0
- package/lib/commit-slice-signal.ts +1 -1
- package/lib/context-backstop.ts +36 -0
- package/lib/design-canvas-server.ts +947 -0
- package/lib/design-comments.ts +437 -0
- package/lib/design-handoffs.ts +313 -0
- package/lib/design-open-browser.ts +58 -0
- package/lib/design-sources.ts +120 -0
- package/lib/external-editor.ts +31 -0
- package/lib/gentle-design.ts +513 -0
- package/lib/gentle-shell-bundled.ts +123 -49
- package/lib/gentle-shell-launcher.ts +9 -4
- package/lib/gentle-shell-startup-update.ts +61 -9
- package/lib/gentle-shell-uninstall.ts +33 -1
- package/lib/image-attachments.ts +150 -0
- package/lib/inprocess-reviewer.ts +33 -2
- package/lib/nan-provider.ts +3 -0
- package/lib/native-review-cli.ts +11 -4
- package/lib/notification-audio-native.ts +149 -13
- package/lib/notification-audio.ts +4 -0
- package/lib/notification-pulse-worker.ts +82 -18
- package/lib/notification-service.ts +5 -1
- package/lib/orchestrator-presence.ts +78 -8
- package/lib/orchestrator-scope.ts +10 -0
- package/lib/orchestrator-state.ts +11 -3
- package/lib/permission-mode-policy.ts +2 -2
- package/lib/review-host-relay.ts +11 -2
- package/lib/review-reminder-receipt.ts +24 -1
- package/lib/session-profile-append-controller.ts +713 -0
- package/lib/session-profile-disk-reader.ts +182 -0
- package/lib/session-profile-persistence.ts +15 -7
- package/lib/session-scratch.ts +52 -3
- package/lib/session-worktree-registry.ts +7 -0
- package/lib/shell-bar.ts +4 -3
- package/lib/shell-changes.ts +81 -5
- package/lib/shell-prompt.ts +4 -4
- package/lib/shell-sidebar-layout.ts +26 -6
- package/lib/shell-todo.ts +1 -1
- package/lib/verifier-admission.ts +5 -4
- package/lib/vim-editor-adapter.ts +11 -8
- package/lib/voice-artifacts.ts +188 -0
- package/lib/voice-command.ts +576 -0
- package/lib/voice-install.ts +455 -0
- package/lib/voice-platform.ts +99 -0
- package/lib/voice-push-to-talk.ts +262 -0
- package/lib/voice-session.ts +460 -0
- package/lib/voice-settings.ts +50 -0
- package/lib/voice-ui.ts +158 -0
- package/lib/yolo-session-policy.ts +58 -22
- package/package.json +1 -1
- package/runtime/gentle-shell-bundled.mjs +122 -48
- package/runtime/gentle-shell-launcher.mjs +9 -4
- package/runtime/gentle-shell-startup-update.mjs +61 -9
- package/runtime/gentle-shell-uninstall.mjs +33 -1
- package/runtime/native-review-cli.mjs +11 -4
- package/scripts/bundled-engram-migration.mjs +0 -6
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/installer-downloads.mjs +7 -7
- package/scripts/installer-preflight.mjs +5 -3
- package/scripts/installer-windows.mjs +37 -7
- package/scripts/main-channel.mjs +91 -17
- package/scripts/pi-update.mjs +34 -6
- package/scripts/verify-package-files.mjs +26 -2
- package/scripts/voice-worker.cjs +476 -0
- package/skills/design/SKILL.md +110 -0
- package/tests/agent-profiles.test.ts +24 -0
- package/tests/agents-config.test.ts +61 -0
- package/tests/agents-fake-child.ts +8 -1
- package/tests/agents-protocol.test.ts +32 -0
- package/tests/agents-runner.test.ts +226 -5
- package/tests/agents-session-transport.test.ts +53 -3
- package/tests/ask-user-choice.test.ts +30 -0
- package/tests/auto-mode.test.ts +17 -0
- package/tests/auto-permission-memory-diagnostics.test.ts +56 -0
- package/tests/auto-permission-runtime.test.ts +85 -1
- package/tests/autonomous-guard.test.ts +20 -0
- package/tests/bundled-install.test.ts +3 -3
- package/tests/clipboard-image.test.ts +114 -0
- package/tests/command-palette.test.ts +1 -1
- package/tests/context-backstop.test.ts +70 -4
- package/tests/delegate-for-reason-contract.test.ts +2 -1
- package/tests/design-canvas-assets.test.ts +710 -0
- package/tests/design-canvas-server.test.ts +1096 -0
- package/tests/design-comments.test.ts +201 -0
- package/tests/design-handoffs.test.ts +282 -0
- package/tests/design-open-browser.test.ts +70 -0
- package/tests/design-sources.test.ts +132 -0
- package/tests/engram-safety.test.ts +132 -57
- package/tests/external-editor.test.ts +84 -0
- package/tests/fixtures/primary-session-prompt/gentleman.golden.md +3 -3
- package/tests/gentle-agents.test.ts +365 -20
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-installer.test.ts +51 -51
- package/tests/gentle-ai.test.ts +292 -13
- package/tests/gentle-design.test.ts +1313 -0
- package/tests/gentle-music.test.ts +335 -0
- package/tests/gentle-shell-bundled.test.ts +301 -248
- package/tests/gentle-shell-launcher.test.ts +28 -2
- package/tests/gentle-shell-startup-update.test.ts +77 -1
- package/tests/gentle-shell-uninstall.test.ts +59 -0
- package/tests/gentle-shell.test.ts +183 -8
- package/tests/gentle-todo.test.ts +11 -0
- package/tests/image-attachments.test.ts +119 -0
- package/tests/image-paste-chips.test.ts +236 -0
- package/tests/inprocess-reviewer.test.ts +55 -2
- package/tests/installer-bundles.test.ts +12 -6
- package/tests/installer-posix-bootstrap.test.ts +8 -8
- package/tests/installer-preflight.test.ts +16 -0
- package/tests/installer-server.test.ts +2 -2
- package/tests/installer-windows-bootstrap.test.ts +61 -3
- package/tests/installer-windows-diagnostics.test.ts +111 -0
- package/tests/main-channel-upgrade.test.ts +232 -2
- package/tests/main-channel.test.ts +140 -21
- package/tests/nan-provider.test.ts +36 -0
- package/tests/native-review-capability-contract.test.ts +11 -4
- package/tests/native-review-parity.test.ts +1 -1
- package/tests/notification-audio-native.test.ts +128 -9
- package/tests/notification-customize.test.ts +17 -5
- package/tests/odd-routing-canonical-ratchet.test.ts +2 -1
- package/tests/orchestrator-budget.test.ts +5 -2
- package/tests/orchestrator-presence.test.ts +100 -2
- package/tests/orchestrator-rdd-ownership.test.ts +2 -2
- package/tests/orchestrator-scope.test.ts +14 -0
- package/tests/orchestrator-state.test.ts +29 -0
- package/tests/package-files.test.ts +34 -0
- package/tests/package-manifest.test.ts +6 -6
- package/tests/permission-mode-policy.test.ts +10 -1
- package/tests/pi-update.test.ts +99 -4
- package/tests/rdd-aware-verification-contract.test.ts +1 -1
- package/tests/review-host-relay-routing.test.ts +88 -1
- package/tests/review-host-relay.test.ts +9 -2
- package/tests/review-json-arguments.test.ts +20 -3
- package/tests/review-loadout-rdd-off-sdk.test.ts +23 -2
- package/tests/review-relay-transport-agent.test.ts +26 -0
- package/tests/review-reminder-receipt.test.ts +31 -1
- package/tests/session-changes.test.ts +2 -1
- package/tests/session-profile-append-controller-fork.test.ts +458 -0
- package/tests/session-profile-append-controller-pi.test.ts +290 -0
- package/tests/session-profile-append-controller.test.ts +731 -0
- package/tests/session-profile-disk-reader-pi.test.ts +183 -0
- package/tests/session-profile-disk-reader.test.ts +311 -0
- package/tests/session-scratch.test.ts +68 -2
- package/tests/session-worktree-registry.test.ts +13 -1
- package/tests/shell-bar.test.ts +14 -2
- package/tests/shell-changes.test.ts +148 -1
- package/tests/shell-prompt.test.ts +3 -3
- package/tests/shell-render-cache.test.ts +134 -1
- package/tests/shell-sidebar-layout.test.ts +50 -0
- package/tests/short-check-routing-contract.test.ts +1 -1
- package/tests/skill-collision-prefixes.test.ts +3 -1
- package/tests/skill-registry-watcher.test.ts +17 -5
- package/tests/skill-registry.test.ts +47 -3
- package/tests/task-size-routing-contract.test.ts +1 -1
- package/tests/test-strength-contract.test.ts +1 -1
- package/tests/verifier-admission.test.ts +18 -1
- package/tests/voice-command.test.ts +773 -0
- package/tests/voice-install.test.ts +323 -0
- package/tests/voice-platform.test.ts +176 -0
- package/tests/voice-push-to-talk.test.ts +444 -0
- package/tests/voice-session.test.ts +434 -0
- package/tests/voice-settings.test.ts +49 -0
- package/tests/voice-ui.test.ts +97 -0
- package/tests/voice-worker.test.ts +445 -0
- package/tests/weak-model-economy-prompt.test.ts +32 -0
- package/tests/windows-hidden-processes.test.ts +25 -2
- package/tests/yolo-customize.test.ts +5 -1
- package/tests/yolo-mode.test.ts +1 -1
- package/tests/yolo-session-policy-capture.test.ts +104 -0
|
@@ -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(
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
|
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())
|
|
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
|
-
|
|
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
|
|
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)
|
|
599
|
-
|
|
600
|
-
|
|
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,
|
package/lib/agent-profiles.ts
CHANGED
|
@@ -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
|
package/lib/agents-config.ts
CHANGED
|
@@ -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>([
|
package/lib/agents-protocol.ts
CHANGED
|
@@ -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)
|
|
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,
|
package/lib/agents-runner.ts
CHANGED
|
@@ -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.
|
|
910
|
-
else if (terminal?.result) this.
|
|
911
|
-
else this.
|
|
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;
|