gentle-pi 4.2.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 +340 -138
- package/assets/agents/gentle-ai-explore.md +1 -0
- package/assets/agents/gentle-ai-verify.md +6 -4
- package/assets/agents/gentle-ai-worker.md +7 -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 +163 -0
- package/assets/orchestrator-delegation.md +25 -176
- package/assets/orchestrator-tracking.md +1 -1
- package/assets/orchestrator-verification.md +14 -8
- package/assets/orchestrator-writer.md +5 -1
- package/assets/orchestrator.md +11 -11
- package/bin/engram-guard +1 -1
- package/bin/gentle-shell.mjs +237 -139
- package/contracts/review-provider-contract-mirror/provider-contract.lock.json +8 -7
- package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/README.md +7 -0
- package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/manifest.json +11 -2
- package/contracts/review-provider-contract-mirror/v1.4.0/bundle/routing/pi.md +92 -0
- package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/generated/provider-capabilities.baseline.json +8 -1
- package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/generated/provider-roles.baseline.json +1 -1
- package/docs/assets/diagrams/agent-orchestration.svg +54 -15
- package/docs/assets/diagrams/gentleman-workflow.svg +69 -11
- package/docs/assets/diagrams/measured-routing.svg +49 -0
- package/docs/assets/diagrams/native-review.svg +52 -12
- package/docs/assets/diagrams/odd-workflow.svg +128 -66
- package/docs/assets/diagrams/permission-modes.svg +51 -0
- package/docs/assets/features/audio-notifications.png +0 -0
- package/docs/assets/features/command-palette.png +0 -0
- package/docs/bundled-install.md +65 -30
- package/docs/delegated-verification.md +16 -8
- package/docs/gentle-agents-activity.md +16 -6
- package/docs/gentle-music.md +61 -0
- package/docs/gentle-shell-uninstall.md +8 -1
- package/docs/gentle-shell.md +80 -9
- package/docs/install-wizard.md +37 -6
- package/docs/permission-modes.md +8 -3
- package/docs/readme-reference.md +120 -28
- package/docs/session-profile-format.md +26 -0
- package/docs/voice.md +259 -0
- package/docs/yolo-mode.md +38 -10
- package/extensions/ask-user-choice.ts +4 -1
- package/extensions/codegraph-tools.ts +68 -6
- package/extensions/gentle-agents.ts +270 -48
- package/extensions/gentle-ai.ts +490 -113
- package/extensions/gentle-design.ts +158 -0
- package/extensions/gentle-herdr-activity.ts +2 -3
- package/extensions/gentle-music.ts +289 -0
- package/extensions/gentle-shell.ts +359 -85
- package/extensions/gentle-todo.ts +43 -9
- package/extensions/gentle-voice.ts +11 -0
- package/extensions/quiet-tools.ts +1 -1
- package/extensions/session-scratch.ts +384 -0
- package/extensions/skill-registry.ts +63 -8
- package/extensions/startup-banner.ts +2 -3
- package/lib/agent-home.ts +8 -3
- package/lib/agent-profiles.ts +26 -0
- package/lib/agents-config.ts +35 -0
- package/lib/agents-protocol.ts +60 -1
- package/lib/agents-runner.ts +107 -9
- package/lib/agents-session-transport.ts +101 -6
- package/lib/agents-view.ts +3 -1
- 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/bounded-writer-admission.ts +51 -0
- package/lib/clipboard-image.ts +147 -0
- package/lib/command-palette-catalog.ts +2 -4
- package/lib/commit-slice-signal.ts +100 -0
- package/lib/context-backstop.ts +120 -0
- package/lib/delivered-test-lint.ts +80 -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/destructive-command-guard.ts +120 -9
- package/lib/external-editor.ts +31 -0
- package/lib/gauge-theme.ts +146 -0
- package/lib/gentle-ai-binary.ts +28 -7
- package/lib/gentle-design.ts +513 -0
- package/lib/gentle-shell-bundled.ts +146 -36
- package/lib/gentle-shell-import-pi.ts +233 -0
- package/lib/gentle-shell-launcher.ts +154 -15
- package/lib/gentle-shell-startup-update.ts +61 -9
- package/lib/gentle-shell-uninstall.ts +51 -2
- package/lib/herdr-activity.ts +24 -0
- 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 +20 -5
- 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-discovery.ts +5 -1
- package/lib/orchestrator-presence.ts +87 -8
- package/lib/orchestrator-scope.ts +31 -3
- package/lib/orchestrator-state.ts +11 -3
- package/lib/permission-mode-policy.ts +2 -2
- package/lib/provider-contract-bundle.ts +99 -53
- package/lib/review-candidate-view-owner.ts +29 -19
- package/lib/review-candidate-view.ts +36 -2
- package/lib/review-host-relay.ts +11 -2
- package/lib/review-reminder-receipt.ts +24 -1
- package/lib/review-risk-assessment.ts +43 -38
- package/lib/review-session-standing-permission.ts +3 -1
- package/lib/session-profile-append-controller.ts +713 -0
- package/lib/session-profile-disk-reader.ts +182 -0
- package/lib/session-profile-freeze.ts +30 -1
- package/lib/session-profile-persistence.ts +15 -7
- package/lib/session-scratch.ts +523 -0
- package/lib/session-worktree-registry.ts +7 -0
- package/lib/shell-bar.ts +29 -28
- package/lib/shell-changes-view.ts +80 -13
- package/lib/shell-changes.ts +81 -5
- package/lib/shell-gauge.ts +49 -6
- package/lib/shell-prompt.ts +4 -4
- package/lib/shell-sidebar-layout.ts +26 -6
- package/lib/shell-todo.ts +24 -5
- package/lib/shell-usage-view.ts +5 -1
- package/lib/shell-usage.ts +7 -7
- package/lib/verifier-admission.ts +30 -0
- 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/lib/yolo-temp-scratch.ts +69 -0
- package/package.json +1 -2
- package/runtime/gentle-ai-binary.mjs +28 -7
- package/runtime/gentle-shell-bundled.mjs +145 -35
- package/runtime/gentle-shell-import-pi.mjs +234 -0
- package/runtime/gentle-shell-launcher.mjs +152 -13
- package/runtime/gentle-shell-startup-update.mjs +61 -9
- package/runtime/gentle-shell-uninstall.mjs +51 -2
- package/runtime/native-review-cli.mjs +19 -4
- package/runtime/review-risk-assessment.mjs +43 -38
- package/scripts/build-runtime-modules.mjs +1 -0
- package/scripts/bundled-engram-migration.mjs +119 -0
- 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-server.mjs +14 -4
- package/scripts/installer-windows.mjs +37 -7
- package/scripts/main-channel.mjs +94 -19
- package/scripts/pi-update.mjs +34 -6
- package/scripts/run-test-suite.mjs +5 -0
- package/scripts/verify-package-files.mjs +43 -13
- package/scripts/voice-worker.cjs +476 -0
- package/skills/design/SKILL.md +110 -0
- package/skills/gentle-ai/SKILL.md +3 -3
- package/skills/work-unit-commits/SKILL.md +5 -3
- package/tests/agent-profiles.test.ts +24 -0
- package/tests/agent-risk-escalation.test.ts +37 -10
- 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 +485 -7
- 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 +142 -0
- package/tests/bounded-writer-admission.test.ts +11 -3
- package/tests/bundled-install.test.ts +3 -3
- package/tests/card-content-policy.test.ts +19 -9
- package/tests/child-safety.test.ts +39 -0
- package/tests/clipboard-image.test.ts +114 -0
- package/tests/codegraph-tools.test.ts +123 -2
- package/tests/command-palette.test.ts +15 -5
- package/tests/commit-slice-signal.test.ts +162 -0
- package/tests/context-backstop.test.ts +225 -0
- package/tests/delegate-for-reason-contract.test.ts +92 -24
- package/tests/delivered-test-lint.test.ts +105 -0
- 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/destructive-command-guard.test.ts +85 -1
- 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 +143 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/README.md +29 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/manifest.json +83 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/orchestration/pi.md +70 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/routing/pi.md +92 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/lens.schema.json +16 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/refuter.schema.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/targeted-validator.schema.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/lens.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/refuter.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/targeted-validator.json +1 -0
- package/tests/gauge-theme.test.ts +120 -0
- package/tests/gentle-agents.test.ts +645 -21
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-child-guards.test.ts +61 -0
- package/tests/gentle-ai-doctor-herdr.test.ts +94 -0
- 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-bin.test.ts +163 -0
- package/tests/gentle-shell-bundled.test.ts +392 -176
- package/tests/gentle-shell-import-pi.test.ts +262 -0
- package/tests/gentle-shell-launcher.test.ts +107 -2
- package/tests/gentle-shell-startup-update.test.ts +77 -1
- package/tests/gentle-shell-uninstall.test.ts +89 -0
- package/tests/gentle-shell.test.ts +225 -17
- package/tests/gentle-todo.test.ts +190 -0
- package/tests/headless-delivery-question.test.ts +58 -0
- package/tests/herdr-activity.test.ts +34 -1
- 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 +10 -2
- package/tests/installer-windows-bootstrap.test.ts +94 -7
- 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 +17 -4
- package/tests/native-review-parity.test.ts +23 -2
- package/tests/no-self-pause-contract.test.ts +5 -2
- package/tests/notification-audio-native.test.ts +128 -9
- package/tests/notification-customize.test.ts +17 -5
- package/tests/odd-integration.test.ts +5 -1
- package/tests/odd-routing-canonical-ratchet.test.ts +128 -162
- package/tests/odd-routing-contract.test.ts +115 -30
- package/tests/orchestrator-budget.test.ts +22 -8
- package/tests/orchestrator-discovery.test.ts +29 -0
- package/tests/orchestrator-presence.test.ts +100 -2
- package/tests/orchestrator-rdd-ownership.test.ts +3 -1
- package/tests/orchestrator-scope.test.ts +80 -0
- package/tests/orchestrator-state.test.ts +29 -0
- package/tests/orchestrator-status-publication-contract.test.ts +12 -8
- package/tests/package-files.test.ts +34 -0
- package/tests/package-manifest.test.ts +24 -20
- package/tests/permission-mode-policy.test.ts +10 -1
- package/tests/pi-update-launcher.test.ts +21 -2
- package/tests/pi-update.test.ts +99 -4
- package/tests/primary-session-prompt.test.ts +101 -0
- package/tests/provider-contract-bundle.test.ts +118 -0
- package/tests/provider-contract-mirror.test.ts +24 -0
- package/tests/quiet-tool-rendering.test.ts +23 -0
- package/tests/rdd-aware-verification-contract.test.ts +29 -11
- package/tests/rdd-status-line.test.ts +79 -0
- package/tests/review-agent-end-preflight.test.ts +6 -0
- package/tests/review-candidate-view.test.ts +49 -0
- package/tests/review-contract-prompt.test.ts +5 -2
- package/tests/review-controller-native-routing.test.ts +14 -1
- package/tests/review-controller.test.ts +1 -1
- package/tests/review-host-relay-routing.test.ts +147 -3
- 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 +258 -0
- package/tests/review-owner-identity.test.ts +132 -0
- package/tests/review-relay-transport-agent.test.ts +26 -0
- package/tests/review-reminder-receipt.test.ts +31 -1
- package/tests/review-risk-assessment.test.ts +163 -76
- package/tests/review-session-standing-permission-controller.test.ts +14 -1
- package/tests/review-session-standing-permission.test.ts +27 -1
- package/tests/run-test-suite.test.ts +15 -0
- package/tests/runtime-harness.mjs +16 -6
- package/tests/session-changes-shell.test.ts +1 -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-profile-freeze.test.ts +23 -0
- package/tests/session-scratch.test.ts +1011 -0
- package/tests/session-subject-routing-contract.test.ts +2 -1
- package/tests/session-worktree-registry.test.ts +13 -1
- package/tests/shell-bar.test.ts +21 -2
- package/tests/shell-changes-view.test.ts +137 -1
- package/tests/shell-changes.test.ts +148 -1
- package/tests/shell-gauge.test.ts +42 -0
- 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/small-task-inline-contract.test.ts +37 -0
- package/tests/standalone-config-home.test.ts +324 -0
- package/tests/startup-banner.test.ts +5 -4
- package/tests/startup-home-repo.test.ts +34 -0
- package/tests/support/orchestrator-modules.ts +1 -0
- package/tests/task-size-routing-contract.test.ts +70 -10
- package/tests/test-scratch-contract.test.ts +102 -0
- package/tests/test-strength-contract.test.ts +36 -1
- package/tests/tool-batching-contract.test.ts +64 -0
- package/tests/verification-proportionality.test.ts +46 -0
- package/tests/verifier-admission.test.ts +154 -0
- package/tests/verify-agent-contract.test.ts +8 -8
- 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 +30 -2
- package/tests/writer-edit-surface-scope.test.ts +3 -0
- package/tests/writer-feature-document-admission.test.ts +156 -0
- package/tests/yolo-customize.test.ts +5 -1
- package/tests/yolo-mode.test.ts +19 -1
- package/tests/yolo-session-policy-capture.test.ts +104 -0
- package/tests/yolo-temp-scratch.test.ts +71 -0
- package/scripts/mirror-odd-routing.mjs +0 -242
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/orchestration/pi.md +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/lens.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/refuter.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/targeted-validator.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/lens.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/refuter.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/targeted-validator.json +0 -0
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { getAgentDir, VERSION } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
4
|
-
import * as os from "node:os";
|
|
5
4
|
import { execFile } from "node:child_process";
|
|
6
5
|
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
|
|
7
6
|
import { join } from "node:path";
|
|
8
7
|
import { resolveAnimationPolicy } from "../lib/animation-policy.ts";
|
|
9
8
|
import { PI_SUBCOMMANDS } from "../lib/gentle-shell-launcher.ts";
|
|
10
|
-
import {
|
|
9
|
+
import { gentlePiConfigHome } from "../lib/agent-home.ts";
|
|
11
10
|
|
|
12
11
|
|
|
13
12
|
export type BannerColor = "pink" | "cyan" | "yellow" | "green";
|
|
@@ -68,7 +67,7 @@ function rgb(r: number, g: number, b: number, text: string): string {
|
|
|
68
67
|
}
|
|
69
68
|
|
|
70
69
|
function gentleAiConfigHome(): string {
|
|
71
|
-
return
|
|
70
|
+
return gentlePiConfigHome(process.env);
|
|
72
71
|
}
|
|
73
72
|
|
|
74
73
|
function bannerConfigPath(configHome = gentleAiConfigHome()): string {
|
package/lib/agent-home.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { homedir } from "node:os";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import {
|
|
3
|
+
import { resolveGentlePiConfigHome, runningBundledPrefix } from "./gentle-shell-bundled.ts";
|
|
4
|
+
|
|
5
|
+
export { GENTLE_SHELL_CONFIG_HOME_ENV } from "./gentle-shell-bundled.ts";
|
|
4
6
|
|
|
5
7
|
// Pi Subagents resolves its global directory as `PI_CODING_AGENT_DIR || ~/.pi/agent`,
|
|
6
8
|
// so an empty value must fall through here too or the two homes diverge again.
|
|
@@ -12,9 +14,12 @@ export function resolveGentlePiAgentHome(env: NodeJS.ProcessEnv = process.env):
|
|
|
12
14
|
// commands own lives here. It is defined once because the launch-time profile pin
|
|
13
15
|
// resolver reads the same profiles store the `/gentle:profiles` panel writes, and
|
|
14
16
|
// two spellings of the override would silently read two different stores.
|
|
15
|
-
// A bundled Gentle Shell keeps it inside its prefix (S9, bundledGentlePiConfigHome)
|
|
17
|
+
// A bundled Gentle Shell keeps it inside its prefix (S9, bundledGentlePiConfigHome),
|
|
18
|
+
// the standalone launcher's isolated home next to its agent dir (#2201). The rule
|
|
19
|
+
// itself lives in resolveGentlePiConfigHome, a generated runtime module, so the
|
|
20
|
+
// launcher and the Gentle AI process environment share it too.
|
|
16
21
|
export function gentlePiConfigHome(env: NodeJS.ProcessEnv = process.env): string {
|
|
17
|
-
return
|
|
22
|
+
return resolveGentlePiConfigHome(env, homedir());
|
|
18
23
|
}
|
|
19
24
|
|
|
20
25
|
// The agent home Pi runs with, only in a bundled Gentle Shell (S9): there the
|
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
|
@@ -127,6 +127,9 @@ export interface TaskRecord {
|
|
|
127
127
|
startedAt: number | null;
|
|
128
128
|
endedAt: number | null;
|
|
129
129
|
model: string;
|
|
130
|
+
/** Launch-layer token recorded next to `model` (#2180): which profile layer won.
|
|
131
|
+
* Host-computed at the launch seam; absent for requests and records that never named one. */
|
|
132
|
+
modelSource?: string;
|
|
130
133
|
thinking: string | undefined;
|
|
131
134
|
sessionPath: string | null;
|
|
132
135
|
error: string | null;
|
|
@@ -161,6 +164,12 @@ export interface TaskStatusChange {
|
|
|
161
164
|
export type StatusChangeListener = (change: TaskStatusChange) => void | Promise<void>;
|
|
162
165
|
|
|
163
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;
|
|
164
173
|
/** Child UI requests that block on an answer; everything else (notify, setStatus, setWidget) is noise here. */
|
|
165
174
|
export const DIALOG_METHODS: ReadonlySet<string> = new Set(["select", "confirm", "input", "editor"]);
|
|
166
175
|
/** The one child notify that reaches the task thread: requested --tools the child does not have (#1690). */
|
|
@@ -478,6 +487,9 @@ export class TaskStore {
|
|
|
478
487
|
private sequence = 0;
|
|
479
488
|
private readonly runIds = new Map<string, string>();
|
|
480
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>();
|
|
481
493
|
|
|
482
494
|
constructor(limits: Partial<ThreadLimits> = {}) {
|
|
483
495
|
this.limits = limits;
|
|
@@ -489,6 +501,11 @@ export class TaskStore {
|
|
|
489
501
|
this.tasks.set(task.id, task);
|
|
490
502
|
this.threads.set(task.id, emptyThread(this.limits));
|
|
491
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
|
+
}
|
|
492
509
|
this.notifySummary();
|
|
493
510
|
}
|
|
494
511
|
|
|
@@ -499,6 +516,11 @@ export class TaskStore {
|
|
|
499
516
|
this.tasks.set(task.id, task);
|
|
500
517
|
this.runIds.set(task.id, randomUUID());
|
|
501
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
|
+
}
|
|
502
524
|
this.notifySummary();
|
|
503
525
|
return true;
|
|
504
526
|
}
|
|
@@ -530,7 +552,16 @@ export class TaskStore {
|
|
|
530
552
|
this.tasks.set(id, next);
|
|
531
553
|
this.notifyStatus(next, current.status);
|
|
532
554
|
this.notifyTask(next);
|
|
533
|
-
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
|
+
}
|
|
534
565
|
return next;
|
|
535
566
|
}
|
|
536
567
|
|
|
@@ -562,6 +593,34 @@ export class TaskStore {
|
|
|
562
593
|
return () => { this.statusListeners.delete(listener); };
|
|
563
594
|
}
|
|
564
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
|
+
|
|
565
624
|
private notifyStatus(task: TaskRecord, previousStatus?: TaskStatus): void {
|
|
566
625
|
if (task.status === previousStatus) return;
|
|
567
626
|
const change: TaskStatusChange = Object.freeze({ producerId: this.producerId,
|
package/lib/agents-runner.ts
CHANGED
|
@@ -2,16 +2,19 @@ import { isSessionChangeEvidence, type SessionChangeEvidence } from "./session-c
|
|
|
2
2
|
import { chmodSync, existsSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { DefaultPackageManager, SettingsManager } from "@earendil-works/pi-coding-agent";
|
|
4
4
|
import { tmpdir, userInfo } from "node:os";
|
|
5
|
-
import { join, resolve } from "node:path";
|
|
5
|
+
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";
|
|
12
14
|
import { ParentStandingReviewPermissionBroker } from "./review-session-standing-permission-ipc.ts";
|
|
13
15
|
import { isFinished, normalizeRpcEvent, ToolArgumentProgress, TASK_EVENT, TASK_STATUS, taskLabel, type AskRequest, type ChildResponseObservation, type TaskRecord, type TaskStore } from "./agents-protocol.ts";
|
|
14
16
|
import { WriterSurfaceRegistry, writerSurfaceConflictMessage } from "./writer-surfaces.ts";
|
|
17
|
+
import { holdSubagentScratch, releaseSubagentScratch, SUBAGENT_SCRATCH_PREFIX } from "./session-scratch.ts";
|
|
15
18
|
|
|
16
19
|
// Gentle Agents runner. Every subagent is its own `pi --mode rpc` process:
|
|
17
20
|
// the host never runs subagent work on the TUI thread. It writes JSON
|
|
@@ -69,6 +72,8 @@ export interface RunnerLimits {
|
|
|
69
72
|
// Longer ceiling used while an announced tool call is in flight. Optional so
|
|
70
73
|
// callers that only bound silence keep the idle budget as the tool ceiling.
|
|
71
74
|
toolStallTimeoutMs?: number;
|
|
75
|
+
// Bounded asynchronous cleanup window allowed after settlement before escalating to SIGTERM/SIGKILL.
|
|
76
|
+
cleanupTimeoutMs?: number;
|
|
72
77
|
}
|
|
73
78
|
|
|
74
79
|
export interface AskAnswer {
|
|
@@ -133,6 +138,11 @@ export interface TaskRequest {
|
|
|
133
138
|
// Host-provided only: the launcher's package injection signal (#1690) or
|
|
134
139
|
// the curated fallback. Never derived from tool input or agent definitions.
|
|
135
140
|
extensionPaths?: string[];
|
|
141
|
+
// Host-computed launch-layer token for the card (#2180): which profile layer
|
|
142
|
+
// supplied `model` (`binding`, `frozen:*`, `pin:*`, `declaration`, `profile`,
|
|
143
|
+
// `definition`, `default`). Same host-only provenance as `extensionPaths`:
|
|
144
|
+
// never derived from tool input or agent definitions.
|
|
145
|
+
modelSource?: string;
|
|
136
146
|
// Same host-only provenance as extensionPaths (#1690).
|
|
137
147
|
noExtensions?: boolean;
|
|
138
148
|
// Parsed `## Allowed edit surfaces` of a bounded writer. While the task is
|
|
@@ -202,6 +212,8 @@ interface LiveTask {
|
|
|
202
212
|
childExit: number | null | undefined;
|
|
203
213
|
childExitSignal?: string | null;
|
|
204
214
|
instructionsTransportDir?: string;
|
|
215
|
+
/** The child's own TMPDIR, removed when the child ends unless its exit is unconfirmed. */
|
|
216
|
+
scratchDir?: string;
|
|
205
217
|
cleanupDeadlineAt: number | undefined;
|
|
206
218
|
quarantined: boolean;
|
|
207
219
|
nextId: number;
|
|
@@ -236,6 +248,8 @@ const DEFAULT_TOOLS: readonly string[] = [];
|
|
|
236
248
|
const TERMINATION_GRACE_MS = 250;
|
|
237
249
|
const GROUP_CONFIRM_MS = 25;
|
|
238
250
|
const GROUP_CONFIRM_DEADLINE_MS = 1_000;
|
|
251
|
+
const QUARANTINE_PROBE_MS = 1_000;
|
|
252
|
+
export const DEFAULT_CLEANUP_TIMEOUT_MS = 5_000;
|
|
239
253
|
const QUERY_REJECTION_ERRORS = new Set([
|
|
240
254
|
"invalid child IPC frame",
|
|
241
255
|
"invalid child IPC correlation",
|
|
@@ -407,6 +421,8 @@ export class AgentRunner {
|
|
|
407
421
|
startedAt: null,
|
|
408
422
|
endedAt: null,
|
|
409
423
|
model: formatModelRef(request.model),
|
|
424
|
+
// Copied, never recomputed: the layer token was resolved once at the seam (#2180).
|
|
425
|
+
...(request.modelSource === undefined ? {} : { modelSource: request.modelSource }),
|
|
410
426
|
thinking: request.thinking,
|
|
411
427
|
sessionPath: request.resumeSessionPath ?? null,
|
|
412
428
|
error: null,
|
|
@@ -541,6 +557,15 @@ export class AgentRunner {
|
|
|
541
557
|
delete env.GENTLE_PI_SDD_REMEDIATION_PLAN;
|
|
542
558
|
delete env.GENTLE_PI_RESEARCH_SELECTION;
|
|
543
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
|
+
}
|
|
544
569
|
if (tools !== undefined) env[REQUESTED_TOOLS_ENV] = tools;
|
|
545
570
|
else delete env[REQUESTED_TOOLS_ENV];
|
|
546
571
|
let instructionsTransportDir: string | undefined;
|
|
@@ -562,6 +587,22 @@ export class AgentRunner {
|
|
|
562
587
|
return;
|
|
563
588
|
}
|
|
564
589
|
}
|
|
590
|
+
// Harness roadmap T3: scratch the child makes with mktemp lands in its own
|
|
591
|
+
// TMPDIR, under the inherited one, so the runtime removes it at the end
|
|
592
|
+
// instead of asking the parent. Best effort: without it the child keeps
|
|
593
|
+
// the inherited TMPDIR.
|
|
594
|
+
let scratchDir: string | undefined;
|
|
595
|
+
try {
|
|
596
|
+
const inherited = request.env.TMPDIR?.trim();
|
|
597
|
+
// A relative TMPDIR would resolve against this process, not the child cwd.
|
|
598
|
+
// A short name and no real path: socket paths under TMPDIR are capped near 104 bytes.
|
|
599
|
+
scratchDir = mkdtempSync(join(inherited && isAbsolute(inherited) ? inherited : tmpdir(), SUBAGENT_SCRATCH_PREFIX));
|
|
600
|
+
// Session shutdown keeps it while it is held (extensions/session-scratch.ts).
|
|
601
|
+
holdSubagentScratch(scratchDir);
|
|
602
|
+
try { chmodSync(scratchDir, DIR_MODE); } catch { /* best effort */ }
|
|
603
|
+
env.TMPDIR = scratchDir;
|
|
604
|
+
if (this.processControl.platform === "win32") { env.TMP = scratchDir; env.TEMP = scratchDir; }
|
|
605
|
+
} catch { scratchDir = undefined; }
|
|
565
606
|
let child: ChildLike;
|
|
566
607
|
try {
|
|
567
608
|
const pi = this.deps.resolvePi?.() ?? this.deps.pi;
|
|
@@ -573,15 +614,16 @@ export class AgentRunner {
|
|
|
573
614
|
stdio: hasParentPermissionChannel ? ["pipe", "pipe", "pipe", permissionChannelStdio, "ipc"] : ["pipe", "pipe", "pipe", "ipc"],
|
|
574
615
|
});
|
|
575
616
|
} catch (error) {
|
|
576
|
-
|
|
577
|
-
try { rmSync(
|
|
617
|
+
for (const dir of [instructionsTransportDir, scratchDir]) {
|
|
618
|
+
if (dir) try { rmSync(dir, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
578
619
|
}
|
|
620
|
+
if (scratchDir) releaseSubagentScratch(scratchDir);
|
|
579
621
|
this.store.update(id, { status: TASK_STATUS.RUNNING, startedAt: this.deps.now(), lastStep: "starting" });
|
|
580
622
|
this.finish(id, TASK_STATUS.FAILED, `could not start pi: ${error instanceof Error ? error.message : String(error)}`);
|
|
581
623
|
return;
|
|
582
624
|
}
|
|
583
625
|
const processGroup = detached && typeof child.pid === "number" && child.pid > 0 ? child.pid : undefined;
|
|
584
|
-
const live: LiveTask = { child, sawRunEvent: false, mutationStarts: new Map(), inFlightTools: new Map(), argumentProgress: new ToolArgumentProgress(), pending: new Map(), queries: new Map(), replies: new Map(), cancelStall: () => {}, cancelGrace: () => {}, processGroup, terminal: undefined, childExit: undefined, childExitSignal: undefined, instructionsTransportDir, cleanupDeadlineAt: undefined, quarantined: false, nextId: 0, ipcClosed: false, acknowledgedIpcIds: new Set(), acknowledgedIpcOrder: [], stderrTail: "" };
|
|
626
|
+
const live: LiveTask = { child, sawRunEvent: false, mutationStarts: new Map(), inFlightTools: new Map(), argumentProgress: new ToolArgumentProgress(), pending: new Map(), queries: new Map(), replies: new Map(), cancelStall: () => {}, cancelGrace: () => {}, processGroup, terminal: undefined, childExit: undefined, childExitSignal: undefined, instructionsTransportDir, scratchDir, cleanupDeadlineAt: undefined, quarantined: false, nextId: 0, ipcClosed: false, acknowledgedIpcIds: new Set(), acknowledgedIpcOrder: [], stderrTail: "" };
|
|
585
627
|
if (request.prepareResponseObservations) {
|
|
586
628
|
let ready = false;
|
|
587
629
|
live.observationPreparation = () => ready;
|
|
@@ -878,9 +920,9 @@ export class AgentRunner {
|
|
|
878
920
|
if (event.type === TASK_EVENT.AGENT_SETTLED) {
|
|
879
921
|
if (live.observations) live.observations.agentSettled = true;
|
|
880
922
|
const terminal = this.store.get(id);
|
|
881
|
-
if (terminal?.error) this.
|
|
882
|
-
else if (terminal?.result) this.
|
|
883
|
-
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");
|
|
884
926
|
}
|
|
885
927
|
}
|
|
886
928
|
if (!live.terminal && progress) this.armStall(id, live);
|
|
@@ -921,6 +963,41 @@ export class AgentRunner {
|
|
|
921
963
|
}
|
|
922
964
|
}
|
|
923
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
|
+
|
|
924
1001
|
private requestStop(id: string, status: TaskRecord["status"], error: string | null, abort = false): void {
|
|
925
1002
|
const live = this.live.get(id);
|
|
926
1003
|
if (!live || live.terminal) return;
|
|
@@ -997,7 +1074,8 @@ export class AgentRunner {
|
|
|
997
1074
|
live.cancelGrace = this.deps.schedule(() => this.confirmGroupExit(id, live), GROUP_CONFIRM_MS);
|
|
998
1075
|
}
|
|
999
1076
|
|
|
1000
|
-
|
|
1077
|
+
/** `exitProven` is true only in completeExit, after the child's exit was observed or its group is gone. */
|
|
1078
|
+
private cleanupLive(live: LiveTask, exitProven = false): void {
|
|
1001
1079
|
live.permissionBroker?.close();
|
|
1002
1080
|
this.closeIpc(live);
|
|
1003
1081
|
live.cancelStall();
|
|
@@ -1006,6 +1084,13 @@ export class AgentRunner {
|
|
|
1006
1084
|
try { rmSync(live.instructionsTransportDir, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
1007
1085
|
live.instructionsTransportDir = undefined;
|
|
1008
1086
|
}
|
|
1087
|
+
// A quarantined child's exit is unconfirmed: a live descendant may still
|
|
1088
|
+
// hold files there, so its scratch stays until the exit is proven.
|
|
1089
|
+
if (live.scratchDir && (!live.quarantined || exitProven)) {
|
|
1090
|
+
try { rmSync(live.scratchDir, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
1091
|
+
releaseSubagentScratch(live.scratchDir);
|
|
1092
|
+
live.scratchDir = undefined;
|
|
1093
|
+
}
|
|
1009
1094
|
}
|
|
1010
1095
|
|
|
1011
1096
|
private childError(id: string, error: Error): void {
|
|
@@ -1030,13 +1115,26 @@ export class AgentRunner {
|
|
|
1030
1115
|
const exitDesc = formatChildExit(code, signal);
|
|
1031
1116
|
if (this.groupExists(live)) {
|
|
1032
1117
|
if (!live.terminal) this.requestStop(id, TASK_STATUS.FAILED, `pi exited with ${exitDesc} before agent_settled${this.stderrSuffix(live)}`);
|
|
1118
|
+
else if (live.quarantined) live.cancelGrace = this.deps.schedule(() => this.awaitQuarantinedGroup(id, live), QUARANTINE_PROBE_MS);
|
|
1119
|
+
return;
|
|
1120
|
+
}
|
|
1121
|
+
this.completeExit(id, live);
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
// Quarantine stopped probing, and a descendant outlives the child's observed
|
|
1125
|
+
// exit: no later event reports the group's end, so keep probing until it is
|
|
1126
|
+
// gone, then release the slot and scratch.
|
|
1127
|
+
private awaitQuarantinedGroup(id: string, live: LiveTask): void {
|
|
1128
|
+
if (this.live.get(id) !== live) return;
|
|
1129
|
+
if (this.groupExists(live)) {
|
|
1130
|
+
live.cancelGrace = this.deps.schedule(() => this.awaitQuarantinedGroup(id, live), QUARANTINE_PROBE_MS);
|
|
1033
1131
|
return;
|
|
1034
1132
|
}
|
|
1035
1133
|
this.completeExit(id, live);
|
|
1036
1134
|
}
|
|
1037
1135
|
|
|
1038
1136
|
private completeExit(id: string, live: LiveTask): void {
|
|
1039
|
-
this.cleanupLive(live);
|
|
1137
|
+
this.cleanupLive(live, true);
|
|
1040
1138
|
this.live.delete(id);
|
|
1041
1139
|
// Quarantine already notified completion, but its retained slot is now free.
|
|
1042
1140
|
if (live.quarantined) {
|
|
@@ -1,11 +1,17 @@
|
|
|
1
|
-
import { chmod, constants, link, lstat, mkdir, open, opendir, realpath, unlink, writeFile } from "node:fs/promises";
|
|
1
|
+
import { chmod, constants, link, lstat, mkdir, open, opendir, readdir, realpath, unlink, writeFile } from "node:fs/promises";
|
|
2
2
|
import { basename, dirname, join, resolve } from "node:path";
|
|
3
3
|
import { createHash, randomBytes } from "node:crypto";
|
|
4
4
|
import { createConnection, createServer, type Server, type Socket } from "node:net";
|
|
5
5
|
|
|
6
6
|
const DIR_MODE = 0o700, FILE_MODE = 0o600, MAX_RECORD_BYTES = 8192, MAX_ENTRIES = 64, MAX_SOCKET_PATH_BYTES = 100;
|
|
7
|
+
// Over-cap debris collection bounds: a presence directory may hold at most
|
|
8
|
+
// this many schema-matching records before sweeping is refused, and each
|
|
9
|
+
// liveness probe gets a short bounded connect attempt. A genuine flood of
|
|
10
|
+
// live listeners stays over the cap and keeps failing busy, unchanged.
|
|
11
|
+
const SWEEP_MAX_FILES = 4096, SWEEP_PROBE_TIMEOUT_MS = 1_000, SWEEP_PROBE_CONCURRENCY = 8;
|
|
7
12
|
const RUNTIME_TMP = "/tmp", RUNTIME_PREFIX = "gentle-pi-", PROFILE_HASH_HEX_LENGTH = 32, SOCKET_NAME_BYTES = 27;
|
|
8
13
|
const SESSION = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/, TOKEN = /^[A-Za-z0-9_-]{22}$/;
|
|
14
|
+
const PRESENCE_FILE = /^([A-Za-z0-9][A-Za-z0-9_-]{0,127})\.([A-Za-z0-9_-]{22})\.json$/;
|
|
9
15
|
const OPEN_FLAGS = constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0);
|
|
10
16
|
type Code = "invalid_session" | "not_found" | "invalid_presence" | "unsafe_path" | "io_error" | "busy";
|
|
11
17
|
|
|
@@ -131,7 +137,25 @@ export class SessionPresenceRegistry {
|
|
|
131
137
|
return (await this.listActivations(excludeSessionId)).map(({ sessionId }) => Object.freeze({ sessionId, reachability: "unknown" as const }));
|
|
132
138
|
}
|
|
133
139
|
|
|
134
|
-
|
|
140
|
+
// An over-cap failure is stale debris until proven otherwise: sessions that
|
|
141
|
+
// exit uncleanly never remove their record, so a long-lived profile accrues
|
|
142
|
+
// them until every scan fails busy (gentle-shell#2501: 133 records, oldest
|
|
143
|
+
// four weeks old, 126 provably dead). Sweep once — bounded, liveness-based —
|
|
144
|
+
// and retry; a flood of live listeners stays over the cap and still fails.
|
|
145
|
+
private async withStaleSweep<T>(scan: () => Promise<T>): Promise<T> {
|
|
146
|
+
try { return await scan(); }
|
|
147
|
+
catch (error) {
|
|
148
|
+
if (!(error instanceof SessionPresenceError) || error.code !== "busy") throw error;
|
|
149
|
+
if (!await this.sweepStaleRecords().catch(() => false)) throw error;
|
|
150
|
+
return scan();
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
listActivations(excludeSessionId?: string): Promise<readonly PresenceRecord[]> {
|
|
155
|
+
return this.withStaleSweep(() => this.scanActivations(excludeSessionId));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private async scanActivations(excludeSessionId?: string): Promise<readonly PresenceRecord[]> {
|
|
135
159
|
const excluded = excludeSessionId === undefined ? undefined : sessionId(excludeSessionId);
|
|
136
160
|
await privateDirectory(this.paths.root);
|
|
137
161
|
await privateDirectory(this.paths.presence);
|
|
@@ -142,9 +166,12 @@ export class SessionPresenceRegistry {
|
|
|
142
166
|
try {
|
|
143
167
|
dir = await opendir(this.paths.presence);
|
|
144
168
|
for await (const entry of dir) {
|
|
145
|
-
|
|
146
|
-
|
|
169
|
+
const match = PRESENCE_FILE.exec(entry.name);
|
|
170
|
+
// Foreign-schema records (orchestrator headers, activity, and discovery
|
|
171
|
+
// sidecars) share this directory without ever being activations, so
|
|
172
|
+
// only schema-matching entries count toward the spam guard.
|
|
147
173
|
if (!match) continue;
|
|
174
|
+
if (++count > MAX_ENTRIES) fail("busy", "presence registry is busy");
|
|
148
175
|
try { await this.beforeCandidateOpen?.(); } catch { fail("io_error", "transport I/O failed"); }
|
|
149
176
|
try {
|
|
150
177
|
const loaded = await this.read(join(this.paths.presence, entry.name), match[1], match[2]);
|
|
@@ -160,7 +187,11 @@ export class SessionPresenceRegistry {
|
|
|
160
187
|
return [...newest.values()].sort((left, right) => left.sessionId.localeCompare(right.sessionId));
|
|
161
188
|
}
|
|
162
189
|
|
|
163
|
-
|
|
190
|
+
resolve(id: string): Promise<PresenceRecord> {
|
|
191
|
+
return this.withStaleSweep(() => this.scanResolve(id));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
private async scanResolve(id: string): Promise<PresenceRecord> {
|
|
164
195
|
id = sessionId(id);
|
|
165
196
|
await privateDirectory(this.paths.root);
|
|
166
197
|
await privateDirectory(this.paths.presence);
|
|
@@ -170,9 +201,11 @@ export class SessionPresenceRegistry {
|
|
|
170
201
|
try {
|
|
171
202
|
dir = await opendir(this.paths.presence);
|
|
172
203
|
for await (const entry of dir) {
|
|
173
|
-
if (++count > MAX_ENTRIES) fail("busy", "presence registry is busy");
|
|
174
204
|
const match = new RegExp(`^${id}\\.([A-Za-z0-9_-]{22})\\.json$`).exec(entry.name);
|
|
205
|
+
// Same shared-directory rule as listActivations: foreign-schema
|
|
206
|
+
// records are skipped before counting toward the cap.
|
|
175
207
|
if (!match) continue;
|
|
208
|
+
if (++count > MAX_ENTRIES) fail("busy", "presence registry is busy");
|
|
176
209
|
try { await this.beforeCandidateOpen?.(); } catch { fail("io_error", "transport I/O failed"); }
|
|
177
210
|
try {
|
|
178
211
|
const loaded = await this.read(join(this.paths.presence, entry.name), id, match[1]);
|
|
@@ -215,6 +248,68 @@ export class SessionPresenceRegistry {
|
|
|
215
248
|
}
|
|
216
249
|
}
|
|
217
250
|
|
|
251
|
+
// One bounded connect attempt against a candidate's own socket endpoint.
|
|
252
|
+
// Endpoints reaching this probe were validated into this registry's sockets
|
|
253
|
+
// directory and lstat-verified as same-user sockets by advertises(), so the
|
|
254
|
+
// probe cannot reach foreign services. Only an active refusal proves the
|
|
255
|
+
// record dead: an ambiguous outcome (timeout, unexpected error) keeps it.
|
|
256
|
+
private liveness(endpoint: string): Promise<"live" | "dead" | "unclear"> {
|
|
257
|
+
return new Promise((resolveProbe) => {
|
|
258
|
+
const socket = createConnection(endpoint);
|
|
259
|
+
const settle = (verdict: "live" | "dead" | "unclear") => {
|
|
260
|
+
clearTimeout(timer);
|
|
261
|
+
socket.destroy();
|
|
262
|
+
resolveProbe(verdict);
|
|
263
|
+
};
|
|
264
|
+
const timer = setTimeout(() => settle("unclear"), SWEEP_PROBE_TIMEOUT_MS);
|
|
265
|
+
socket.once("connect", () => settle("live"));
|
|
266
|
+
socket.once("error", (error: NodeJS.ErrnoException) => settle(error.code === "ETIMEDOUT" ? "unclear" : "dead"));
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Garbage-collects provably dead records once the directory is over cap:
|
|
271
|
+
// no live endpoint (advertises false, i.e. the socket is gone) or a refused
|
|
272
|
+
// connect. Removes the record files only — socket files belong to their
|
|
273
|
+
// owning session. Returns whether anything was removed, so the caller
|
|
274
|
+
// retries the scan exactly once; nothing here runs on a healthy directory.
|
|
275
|
+
private async sweepStaleRecords(): Promise<boolean> {
|
|
276
|
+
const names = (await readdir(this.paths.presence)).filter((name) => PRESENCE_FILE.test(name));
|
|
277
|
+
if (names.length === 0 || names.length > SWEEP_MAX_FILES) return false;
|
|
278
|
+
let removed = 0;
|
|
279
|
+
const remove = async (file: string) => {
|
|
280
|
+
try { await unlink(file); removed += 1; }
|
|
281
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") removed += 1; }
|
|
282
|
+
};
|
|
283
|
+
const live: PresenceRecord[] = [];
|
|
284
|
+
for (const name of names) {
|
|
285
|
+
const match = PRESENCE_FILE.exec(name);
|
|
286
|
+
if (!match) continue;
|
|
287
|
+
try {
|
|
288
|
+
const loaded = await this.read(join(this.paths.presence, name), match[1], match[2]);
|
|
289
|
+
const record = loaded?.record;
|
|
290
|
+
// A record that no longer validates (malformed, pruned endpoint) is
|
|
291
|
+
// debris; an unsafe_path one is left for its owner, never touched.
|
|
292
|
+
if (!record) { await remove(join(this.paths.presence, name)); continue; }
|
|
293
|
+
if (await this.advertises(record)) live.push(record);
|
|
294
|
+
else await remove(join(this.paths.presence, name));
|
|
295
|
+
} catch (error) {
|
|
296
|
+
// Malformed content is debris from an interrupted write and is
|
|
297
|
+
// collected; unsafe-path records are left for their owner, never
|
|
298
|
+
// touched by the sweep.
|
|
299
|
+
if (error instanceof SessionPresenceError && error.code === "invalid_presence") await remove(join(this.paths.presence, name));
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
let next = 0;
|
|
303
|
+
const worker = async () => {
|
|
304
|
+
while (next < live.length) {
|
|
305
|
+
const record = live[next++];
|
|
306
|
+
if (await this.liveness(record.endpoint) === "dead") await remove(this.presencePath(record));
|
|
307
|
+
}
|
|
308
|
+
};
|
|
309
|
+
await Promise.all(Array.from({ length: Math.min(SWEEP_PROBE_CONCURRENCY, live.length) }, worker));
|
|
310
|
+
return removed > 0;
|
|
311
|
+
}
|
|
312
|
+
|
|
218
313
|
private token(endpoint: unknown) {
|
|
219
314
|
if (typeof endpoint !== "string" || dirname(endpoint) !== this.paths.sockets || resolve(endpoint) !== endpoint || Buffer.byteLength(endpoint) > MAX_SOCKET_PATH_BYTES) fail("unsafe_path", "unsafe transport path");
|
|
220
315
|
const name = basename(endpoint), token = name.endsWith(".sock") ? name.slice(0, -5) : "";
|
package/lib/agents-view.ts
CHANGED
|
@@ -143,7 +143,9 @@ function sameItemValue(a: unknown, b: unknown): boolean {
|
|
|
143
143
|
}
|
|
144
144
|
|
|
145
145
|
export function taskHeader(task: TaskRecord, now: number): string {
|
|
146
|
-
|
|
146
|
+
// #2180: the winning profile layer is shown right after the model it supplied;
|
|
147
|
+
// records without one render exactly as before.
|
|
148
|
+
const parts = [task.agent, task.status, task.model, ...(task.modelSource === undefined ? [] : [task.modelSource]), task.tokens > 0 ? formatTokens(task.tokens) : "", task.cost > 0 ? formatCost(task.cost) : "", task.startedAt === null ? "" : formatElapsed((task.endedAt ?? now) - task.startedAt)];
|
|
147
149
|
return parts.filter((part) => part.length > 0).join(" · ");
|
|
148
150
|
}
|
|
149
151
|
|