gentle-pi 2.4.0 → 2.6.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 +292 -25
- package/assets/agents/gentle-ai-worker.md +13 -0
- package/assets/agents/jd-fix-agent.md +18 -0
- package/assets/agents/jd-judge-a.md +1 -1
- package/assets/agents/jd-judge-b.md +1 -1
- package/assets/agents/sdd-apply.md +7 -5
- package/assets/agents/sdd-archive.md +5 -3
- package/assets/agents/sdd-design.md +4 -0
- package/assets/agents/sdd-explore.md +4 -0
- package/assets/agents/sdd-init.md +4 -0
- package/assets/agents/sdd-onboard.md +4 -0
- package/assets/agents/sdd-proposal.md +4 -0
- package/assets/agents/sdd-remediate.md +37 -0
- package/assets/agents/sdd-research.md +26 -3
- package/assets/agents/sdd-spec.md +4 -0
- package/assets/agents/sdd-status.md +9 -75
- package/assets/agents/sdd-sync.md +4 -0
- package/assets/agents/sdd-tasks.md +4 -0
- package/assets/agents/sdd-verify.md +5 -3
- package/assets/chains/sdd-full.chain.md +4 -0
- package/assets/chains/sdd-plan.chain.md +4 -0
- package/assets/chains/sdd-verify.chain.md +4 -0
- package/assets/migrations/managed-assets-v2.5.0.json +7 -0
- package/assets/orchestrator-delegation.md +39 -11
- package/assets/orchestrator.md +5 -5
- package/assets/sdd-orchestrator-workflow.md +54 -21
- package/assets/support/sdd-status-contract.md +34 -90
- package/contracts/telemetry/runtime-aggregate-v1.schema.json +65 -0
- package/docs/delegated-verification.md +25 -0
- package/docs/telemetry.md +94 -0
- package/docs/windows-startup-console-visibility.md +18 -0
- package/extensions/ask-user-choice.ts +159 -25
- package/extensions/codegraph-tools.ts +95 -5
- package/extensions/gentle-agents.ts +1337 -0
- package/extensions/gentle-ai.ts +2916 -386
- package/extensions/gentle-shell.ts +650 -0
- package/extensions/gentle-todo.ts +234 -0
- package/extensions/quiet-tools.ts +2 -1
- package/extensions/runtime-metrics.ts +130 -0
- package/extensions/sdd-init.ts +2 -2
- package/extensions/startup-banner.ts +52 -75
- package/lib/agent-profiles.ts +550 -0
- package/lib/agents-completion-delivery.ts +72 -0
- package/lib/agents-config.ts +315 -0
- package/lib/agents-history.ts +88 -0
- package/lib/agents-messaging.ts +187 -0
- package/lib/agents-protocol.ts +501 -0
- package/lib/agents-runner.ts +1012 -0
- package/lib/agents-thread-view.ts +57 -0
- package/lib/agents-transcript.ts +87 -0
- package/lib/agents-view-layout.ts +40 -0
- package/lib/agents-view.ts +914 -0
- package/lib/agents-widget.ts +241 -0
- package/lib/gentle-ai-binary.ts +3 -1
- package/lib/gentle-ai-renderer.ts +143 -25
- package/lib/native-choice-list.ts +194 -0
- package/lib/native-fullscreen-interaction.ts +47 -0
- package/lib/native-pointer-region.ts +164 -0
- package/lib/native-review-cli.ts +371 -13
- package/lib/orchestrator-presence.ts +337 -0
- package/lib/profiles-orchestrator.ts +203 -0
- package/lib/review-candidate-view-owner.ts +427 -0
- package/lib/review-candidate-view.ts +150 -48
- package/lib/review-consent-component.ts +247 -0
- package/lib/review-consent-ui.ts +110 -0
- package/lib/review-host-relay.ts +28 -0
- package/lib/review-integration-v2.ts +243 -11
- package/lib/review-last-event-controller.ts +8 -4
- package/lib/review-relay-contract.ts +11 -0
- package/lib/review-reminder-receipt.ts +74 -0
- package/lib/review-repository.ts +2 -2
- package/lib/review-risk-assessment.ts +339 -0
- package/lib/review-session-standing-permission-ipc.ts +309 -0
- package/lib/review-session-standing-permission.ts +240 -0
- package/lib/runtime-metrics-children.ts +199 -0
- package/lib/runtime-metrics-delivery.ts +68 -0
- package/lib/runtime-metrics-native.ts +166 -0
- package/lib/runtime-metrics-pi-identity.ts +113 -0
- package/lib/runtime-metrics-policy.ts +51 -0
- package/lib/runtime-metrics.ts +255 -0
- package/lib/sdd-preflight.ts +362 -81
- package/lib/sdd-research-capabilities.ts +228 -0
- package/lib/sdd-status.ts +29 -7
- package/lib/session-worktree-registry.ts +118 -0
- package/lib/shell-bar.ts +184 -0
- package/lib/shell-card.ts +133 -0
- package/lib/shell-changes-view.ts +530 -0
- package/lib/shell-changes.ts +290 -0
- package/lib/shell-gauge.ts +40 -0
- package/lib/shell-prompt.ts +115 -0
- package/lib/shell-sidebar-banner.ts +11 -0
- package/lib/shell-sidebar-layout.ts +213 -0
- package/lib/shell-sidebar.ts +41 -0
- package/lib/shell-todo.ts +297 -0
- package/lib/shell-usage-view.ts +76 -0
- package/lib/shell-usage.ts +246 -0
- package/lib/telemetry-trigger.ts +153 -0
- package/package.json +8 -5
- package/runtime/gentle-ai-binary.mjs +3 -1
- package/runtime/native-review-cli.mjs +370 -12
- package/runtime/review-integration-v2.mjs +243 -11
- package/runtime/review-relay-contract.mjs +11 -0
- package/runtime/review-risk-assessment.mjs +340 -0
- package/runtime/telemetry-trigger.mjs +154 -0
- package/scripts/build-runtime-modules.mjs +11 -1
- package/scripts/check-types.mjs +125 -0
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/install-gentle-ai.mjs +12 -0
- package/scripts/install-tui-mode-setting.mjs +114 -0
- package/scripts/test-packed-runner.mjs +38 -2
- package/scripts/types-baseline.json +99 -0
- package/scripts/verify-package-files.mjs +8 -2
- package/skills/_shared/review-ledger-contract.md +20 -2
- package/skills/issue-creation/SKILL.md +3 -3
- package/skills/judgment-day/SKILL.md +17 -3
- package/skills/judgment-day/references/prompts-and-formats.md +14 -3
- package/tests/agent-profiles.test.ts +722 -0
- package/tests/agents-completion-delivery.test.ts +94 -0
- package/tests/agents-config.test.ts +205 -0
- package/tests/agents-fake-child.ts +66 -0
- package/tests/agents-grouping.test.ts +179 -0
- package/tests/agents-history.test.ts +54 -0
- package/tests/agents-integration.test.ts +100 -0
- package/tests/agents-messaging.test.ts +94 -0
- package/tests/agents-protocol.test.ts +198 -0
- package/tests/agents-queries.test.ts +190 -0
- package/tests/agents-responsive.test.ts +43 -0
- package/tests/agents-runner-process.test.ts +111 -0
- package/tests/agents-runner.test.ts +959 -0
- package/tests/agents-thread-view.test.ts +45 -0
- package/tests/agents-transcript.test.ts +30 -0
- package/tests/agents-view.test.ts +685 -0
- package/tests/agents-widget.test.ts +141 -0
- package/tests/artifact-language.test.ts +25 -2
- package/tests/ask-user-choice.test.ts +325 -5
- package/tests/asset-installation-runtime.test.ts +108 -0
- package/tests/autonomous-guard.test.ts +116 -1
- package/tests/codegraph-tools.test.ts +112 -2
- package/tests/delegated-key-learnings-contract.test.ts +1 -1
- package/tests/devbinary/native-review-parity.devtest.ts +110 -0
- package/tests/feature-request-form.test.ts +67 -0
- package/tests/fixtures/agents-messaging-child.mjs +5 -0
- package/tests/fixtures/agents-process-child.mjs +23 -0
- package/tests/fixtures/runtime-metrics-native-batches.json +6 -0
- package/tests/gentle-agents.test.ts +2168 -0
- package/tests/gentle-ai-binary.test.ts +7 -2
- package/tests/gentle-ai-installer.test.ts +47 -47
- package/tests/gentle-ai-renderer.test.ts +103 -0
- package/tests/gentle-ai.test.ts +971 -15
- package/tests/gentle-card-text.ts +35 -0
- package/tests/gentle-shell.test.ts +818 -0
- package/tests/gentle-todo.test.ts +226 -0
- package/tests/install-tui-mode-setting.test.ts +324 -0
- package/tests/issue-creation-skill.test.ts +22 -0
- package/tests/model-routing-authority.test.ts +12 -0
- package/tests/native-choice-list.test.ts +202 -0
- package/tests/native-fullscreen-interaction.test.ts +125 -0
- package/tests/native-pointer-region.test.ts +245 -0
- package/tests/native-review-capability-contract.test.ts +27 -1
- package/tests/native-review-cli.test.ts +317 -3
- package/tests/native-review-consent.test.ts +91 -0
- package/tests/native-review-parity-runtime.test.ts +8 -2
- package/tests/native-review-parity.test.ts +43 -29
- package/tests/native-sdd-attempt-authority.test.ts +7 -2
- package/tests/orchestrator-budget.test.ts +69 -0
- package/tests/orchestrator-presence.test.ts +389 -0
- package/tests/orchestrator-rdd-ownership.test.ts +9 -0
- package/tests/package-manifest.test.ts +243 -7
- package/tests/profiles-orchestrator.test.ts +208 -0
- package/tests/quiet-tool-rendering.test.ts +97 -37
- package/tests/rdd-aware-verification-contract.test.ts +226 -0
- package/tests/rdd-status-line.test.ts +286 -0
- package/tests/review-agent-end-preflight.test.ts +332 -24
- package/tests/review-candidate-view.test.ts +751 -7
- package/tests/review-consent-ui.test.ts +352 -0
- package/tests/review-contract-prompt.test.ts +17 -0
- package/tests/review-controller-native-recovery.test.ts +29 -4
- package/tests/review-controller-native-routing.test.ts +884 -7
- package/tests/review-controller-workspace-root.test.ts +45 -2
- package/tests/review-controller.test.ts +26 -1
- package/tests/review-host-relay-restart-parity.test.ts +142 -1
- package/tests/review-host-relay-routing.test.ts +384 -8
- package/tests/review-host-relay.test.ts +29 -0
- package/tests/review-integration-v2-forward.test.ts +44 -0
- package/tests/review-integration-v2.test.ts +276 -0
- package/tests/review-last-event-closure.test.ts +112 -3
- package/tests/review-ledger-contract.test.ts +61 -6
- package/tests/review-relay-contract.test.ts +26 -0
- package/tests/review-reminder-receipt.test.ts +62 -0
- package/tests/review-repository.test.ts +28 -1
- package/tests/review-risk-assessment.test.ts +626 -0
- package/tests/review-session-standing-permission-controller.test.ts +656 -0
- package/tests/review-session-standing-permission-ipc.test.ts +233 -0
- package/tests/review-session-standing-permission-runtime.test.ts +212 -0
- package/tests/review-session-standing-permission.test.ts +156 -0
- package/tests/runtime-harness.mjs +447 -39
- package/tests/runtime-metrics-children.test.ts +206 -0
- package/tests/runtime-metrics-delivery.test.ts +85 -0
- package/tests/runtime-metrics-extension.test.ts +187 -0
- package/tests/runtime-metrics-native.test.ts +209 -0
- package/tests/runtime-metrics-pi-identity.test.ts +113 -0
- package/tests/runtime-metrics-policy.test.ts +62 -0
- package/tests/runtime-metrics.test.ts +184 -0
- package/tests/sdd-agent-tools.test.ts +10 -1
- package/tests/sdd-execution-routing-contract.test.ts +28 -0
- package/tests/sdd-managed-runtime-settlement.test.ts +331 -0
- package/tests/sdd-native-managed-uptake.test.ts +253 -0
- package/tests/sdd-planning-routing-contract.test.ts +45 -0
- package/tests/sdd-preflight.test.ts +252 -8
- package/tests/sdd-research-capabilities.test.ts +256 -0
- package/tests/sdd-research-live.test.ts +241 -0
- package/tests/sdd-selection-transport.test.ts +504 -0
- package/tests/sdd-status.test.ts +51 -0
- package/tests/session-worktree-registry.test.ts +135 -0
- package/tests/shell-bar.test.ts +176 -0
- package/tests/shell-card.test.ts +139 -0
- package/tests/shell-changes-view.test.ts +609 -0
- package/tests/shell-changes.test.ts +350 -0
- package/tests/shell-prompt.test.ts +140 -0
- package/tests/shell-sidebar-banner.test.ts +23 -0
- package/tests/shell-sidebar-layout.test.ts +387 -0
- package/tests/shell-sidebar.test.ts +50 -0
- package/tests/shell-todo.test.ts +259 -0
- package/tests/shell-usage-view.test.ts +62 -0
- package/tests/shell-usage.test.ts +197 -0
- package/tests/startup-banner.test.ts +126 -0
- package/tests/telemetry-trigger.test.ts +351 -0
|
@@ -0,0 +1,550 @@
|
|
|
1
|
+
// Agent-model profiles: named, switchable snapshots of the global
|
|
2
|
+
// `models.json` routing behind `/gentle:profiles`. The store lives at
|
|
3
|
+
// `<configHome>/profiles.json` and single-profile exports at
|
|
4
|
+
// `<configHome>/profiles.export.json`. Everything here is pure except the two
|
|
5
|
+
// path helpers and the thin read/write wrappers at the bottom; the extension
|
|
6
|
+
// panel owns all TUI and orchestration concerns.
|
|
7
|
+
|
|
8
|
+
import { randomUUID } from "node:crypto";
|
|
9
|
+
import { closeSync, constants, existsSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { basename, dirname, join } from "node:path";
|
|
11
|
+
import {
|
|
12
|
+
normalizeModelConfig,
|
|
13
|
+
type AgentModelConfig,
|
|
14
|
+
type AgentRoutingEntry,
|
|
15
|
+
} from "./model-routing-authority.ts";
|
|
16
|
+
|
|
17
|
+
export const PROFILES_KIND = "gentle-pi.agent_model_profiles";
|
|
18
|
+
export const PROFILES_VERSION = 1;
|
|
19
|
+
export const PROFILE_EXPORT_KIND = "gentle-pi.agent_model_profile";
|
|
20
|
+
export const PROFILE_EXPORT_VERSION = 1;
|
|
21
|
+
|
|
22
|
+
export interface AgentProfilesFile {
|
|
23
|
+
kind: typeof PROFILES_KIND;
|
|
24
|
+
version: typeof PROFILES_VERSION;
|
|
25
|
+
active?: string;
|
|
26
|
+
profiles: Record<string, AgentModelConfig>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type AgentProfileErrorCode =
|
|
30
|
+
| "invalid_name"
|
|
31
|
+
| "duplicate_name"
|
|
32
|
+
| "missing_profile"
|
|
33
|
+
| "active_profile";
|
|
34
|
+
|
|
35
|
+
export class AgentProfileError extends Error {
|
|
36
|
+
readonly code: AgentProfileErrorCode;
|
|
37
|
+
|
|
38
|
+
constructor(code: AgentProfileErrorCode, message: string) {
|
|
39
|
+
super(message);
|
|
40
|
+
this.name = "AgentProfileError";
|
|
41
|
+
this.code = code;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const PROFILE_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
46
|
+
// Names that pass the slug pattern would still collide with object prototype
|
|
47
|
+
// members when used as record keys, so they are rejected explicitly.
|
|
48
|
+
const RESERVED_PROFILE_NAMES = new Set(["__proto__", "constructor", "prototype"]);
|
|
49
|
+
|
|
50
|
+
const INHERIT_MODEL_LABEL = "inherit";
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Reserved routing key for the orchestrator selection.
|
|
54
|
+
*
|
|
55
|
+
* A profile is a complete snapshot of the routing, and the orchestrator is part
|
|
56
|
+
* of it, but the orchestrator is not an agent: it is never written to
|
|
57
|
+
* `subagents.json`, never has an agent file, and its model lives in Pi's global
|
|
58
|
+
* `settings.json`. The key is reserved so the routing map can carry it while
|
|
59
|
+
* every agent-facing path skips it.
|
|
60
|
+
*/
|
|
61
|
+
export const PROFILE_ORCHESTRATOR_KEY = "orchestrator";
|
|
62
|
+
|
|
63
|
+
export function isProfileOrchestratorKey(key: string): boolean {
|
|
64
|
+
return key === PROFILE_ORCHESTRATOR_KEY;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The profile's own orchestrator entry, when the profile defines one. */
|
|
68
|
+
export function readProfileOrchestrator(
|
|
69
|
+
config: AgentModelConfig,
|
|
70
|
+
): AgentRoutingEntry | undefined {
|
|
71
|
+
if (!Object.prototype.hasOwnProperty.call(config, PROFILE_ORCHESTRATOR_KEY)) return undefined;
|
|
72
|
+
const entry = config[PROFILE_ORCHESTRATOR_KEY];
|
|
73
|
+
return entry && Object.keys(entry).length > 0 ? entry : undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The routing map without its reserved orchestrator key, for every caller that
|
|
78
|
+
* counts or lists roles: the orchestrator is not a role.
|
|
79
|
+
*/
|
|
80
|
+
export function profileRoleEntries(config: AgentModelConfig): Array<[string, AgentRoutingEntry]> {
|
|
81
|
+
return Object.entries(config).filter(([agent]) => !isProfileOrchestratorKey(agent));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function isValidProfileName(value: unknown): value is string {
|
|
85
|
+
return (
|
|
86
|
+
typeof value === "string" &&
|
|
87
|
+
PROFILE_NAME_PATTERN.test(value) &&
|
|
88
|
+
!RESERVED_PROFILE_NAMES.has(value)
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function emptyProfilesFile(): AgentProfilesFile {
|
|
93
|
+
return { kind: PROFILES_KIND, version: PROFILES_VERSION, active: undefined, profiles: {} };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ---- Parse and normalize ----
|
|
97
|
+
|
|
98
|
+
export interface ProfilesParseDrops {
|
|
99
|
+
droppedProfiles: string[];
|
|
100
|
+
droppedAgents: Array<{ profile: string; agent: string }>;
|
|
101
|
+
/** An `active` marker that did not name a normalized profile, when present. */
|
|
102
|
+
droppedActive?: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface NormalizedProfilesFile {
|
|
106
|
+
file: AgentProfilesFile;
|
|
107
|
+
drops: ProfilesParseDrops;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
111
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function hasOwn(record: Record<string, unknown>, key: string): boolean {
|
|
115
|
+
return Object.prototype.hasOwnProperty.call(record, key);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function requireValidName(name: string): string {
|
|
119
|
+
if (!isValidProfileName(name)) {
|
|
120
|
+
throw new AgentProfileError(
|
|
121
|
+
"invalid_name",
|
|
122
|
+
`Invalid profile name: ${JSON.stringify(name)}. Use 1-64 characters (letters, numbers, '.', '_', '-') starting with a letter or number.`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
return name;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function normalizeProfilesFile(value: unknown): NormalizedProfilesFile | undefined {
|
|
129
|
+
if (!isRecord(value)) return undefined;
|
|
130
|
+
if (value.kind !== PROFILES_KIND || value.version !== PROFILES_VERSION) return undefined;
|
|
131
|
+
const rawProfiles = value.profiles;
|
|
132
|
+
if (!isRecord(rawProfiles)) return undefined;
|
|
133
|
+
const profiles: Record<string, AgentModelConfig> = {};
|
|
134
|
+
const drops: ProfilesParseDrops = { droppedProfiles: [], droppedAgents: [] };
|
|
135
|
+
for (const [name, rawConfig] of Object.entries(rawProfiles)) {
|
|
136
|
+
if (!isValidProfileName(name)) {
|
|
137
|
+
drops.droppedProfiles.push(name);
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
const config = normalizeModelConfig(rawConfig);
|
|
141
|
+
if (!config) {
|
|
142
|
+
drops.droppedProfiles.push(name);
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
for (const agent of Object.keys(isRecord(rawConfig) ? rawConfig : {})) {
|
|
146
|
+
if (!hasOwn(config, agent)) drops.droppedAgents.push({ profile: name, agent });
|
|
147
|
+
}
|
|
148
|
+
profiles[name] = config;
|
|
149
|
+
}
|
|
150
|
+
let active: string | undefined;
|
|
151
|
+
if (value.active !== undefined) {
|
|
152
|
+
if (
|
|
153
|
+
typeof value.active === "string" &&
|
|
154
|
+
isValidProfileName(value.active) &&
|
|
155
|
+
hasOwn(profiles, value.active)
|
|
156
|
+
) {
|
|
157
|
+
active = value.active;
|
|
158
|
+
} else {
|
|
159
|
+
// Never clear the marker silently: a stale `active` is exactly the state
|
|
160
|
+
// an operator needs to see, not have disappear.
|
|
161
|
+
drops.droppedActive =
|
|
162
|
+
typeof value.active === "string" ? value.active : JSON.stringify(value.active);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return { file: { kind: PROFILES_KIND, version: PROFILES_VERSION, active, profiles }, drops };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export type ProfilesFileParseResult =
|
|
169
|
+
| { status: "invalid" }
|
|
170
|
+
| { status: "valid"; file: AgentProfilesFile };
|
|
171
|
+
|
|
172
|
+
export function parseProfilesFileText(text: string): ProfilesFileParseResult {
|
|
173
|
+
let value: unknown;
|
|
174
|
+
try {
|
|
175
|
+
value = JSON.parse(text);
|
|
176
|
+
} catch {
|
|
177
|
+
return { status: "invalid" };
|
|
178
|
+
}
|
|
179
|
+
const file = normalizeProfilesFile(value)?.file;
|
|
180
|
+
return file ? { status: "valid", file } : { status: "invalid" };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function cloneProfileConfig(config: AgentModelConfig): AgentModelConfig {
|
|
184
|
+
return Object.fromEntries(
|
|
185
|
+
Object.entries(config).map(([agent, entry]) => [agent, { ...entry }]),
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function cloneProfiles(
|
|
190
|
+
profiles: Record<string, AgentModelConfig>,
|
|
191
|
+
): Record<string, AgentModelConfig> {
|
|
192
|
+
return Object.fromEntries(
|
|
193
|
+
Object.entries(profiles).map(([name, config]) => [name, cloneProfileConfig(config)]),
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function serializeProfilesFile(file: AgentProfilesFile): string {
|
|
198
|
+
const payload: Record<string, unknown> = {
|
|
199
|
+
kind: file.kind,
|
|
200
|
+
version: file.version,
|
|
201
|
+
profiles: cloneProfiles(file.profiles),
|
|
202
|
+
};
|
|
203
|
+
if (file.active !== undefined) payload.active = file.active;
|
|
204
|
+
return `${JSON.stringify(payload, null, 2)}\n`;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ---- Store mutations (each returns a new file and never mutates the input) ----
|
|
208
|
+
|
|
209
|
+
export function createProfile(
|
|
210
|
+
file: AgentProfilesFile,
|
|
211
|
+
name: string,
|
|
212
|
+
config: AgentModelConfig,
|
|
213
|
+
): AgentProfilesFile {
|
|
214
|
+
requireValidName(name);
|
|
215
|
+
if (hasOwn(file.profiles, name)) {
|
|
216
|
+
throw new AgentProfileError("duplicate_name", `Profile already exists: ${name}.`);
|
|
217
|
+
}
|
|
218
|
+
return { ...file, profiles: { ...file.profiles, [name]: cloneProfileConfig(config) } };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function updateProfile(
|
|
222
|
+
file: AgentProfilesFile,
|
|
223
|
+
name: string,
|
|
224
|
+
config: AgentModelConfig,
|
|
225
|
+
): AgentProfilesFile {
|
|
226
|
+
requireValidName(name);
|
|
227
|
+
if (!hasOwn(file.profiles, name)) {
|
|
228
|
+
throw new AgentProfileError("missing_profile", `Profile does not exist: ${name}.`);
|
|
229
|
+
}
|
|
230
|
+
return { ...file, profiles: { ...file.profiles, [name]: cloneProfileConfig(config) } };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function duplicateProfile(
|
|
234
|
+
file: AgentProfilesFile,
|
|
235
|
+
from: string,
|
|
236
|
+
to: string,
|
|
237
|
+
): AgentProfilesFile {
|
|
238
|
+
requireValidName(from);
|
|
239
|
+
if (!hasOwn(file.profiles, from)) {
|
|
240
|
+
throw new AgentProfileError("missing_profile", `Profile does not exist: ${from}.`);
|
|
241
|
+
}
|
|
242
|
+
requireValidName(to);
|
|
243
|
+
if (hasOwn(file.profiles, to)) {
|
|
244
|
+
throw new AgentProfileError("duplicate_name", `Profile already exists: ${to}.`);
|
|
245
|
+
}
|
|
246
|
+
return createProfile(file, to, file.profiles[from]);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function renameProfile(
|
|
250
|
+
file: AgentProfilesFile,
|
|
251
|
+
from: string,
|
|
252
|
+
to: string,
|
|
253
|
+
): AgentProfilesFile {
|
|
254
|
+
requireValidName(from);
|
|
255
|
+
if (!hasOwn(file.profiles, from)) {
|
|
256
|
+
throw new AgentProfileError("missing_profile", `Profile does not exist: ${from}.`);
|
|
257
|
+
}
|
|
258
|
+
requireValidName(to);
|
|
259
|
+
if (hasOwn(file.profiles, to)) {
|
|
260
|
+
throw new AgentProfileError("duplicate_name", `Profile already exists: ${to}.`);
|
|
261
|
+
}
|
|
262
|
+
const profiles = Object.fromEntries(
|
|
263
|
+
Object.entries(file.profiles).map(([name, config]) => [
|
|
264
|
+
name === from ? to : name,
|
|
265
|
+
cloneProfileConfig(config),
|
|
266
|
+
]),
|
|
267
|
+
);
|
|
268
|
+
return {
|
|
269
|
+
...file,
|
|
270
|
+
active: file.active === from ? to : file.active,
|
|
271
|
+
profiles,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export function deleteProfile(file: AgentProfilesFile, name: string): AgentProfilesFile {
|
|
276
|
+
requireValidName(name);
|
|
277
|
+
if (!hasOwn(file.profiles, name)) {
|
|
278
|
+
throw new AgentProfileError("missing_profile", `Profile does not exist: ${name}.`);
|
|
279
|
+
}
|
|
280
|
+
if (file.active === name) {
|
|
281
|
+
throw new AgentProfileError(
|
|
282
|
+
"active_profile",
|
|
283
|
+
`Profile ${name} is active. Apply another profile before deleting it.`,
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
return {
|
|
287
|
+
...file,
|
|
288
|
+
profiles: Object.fromEntries(
|
|
289
|
+
Object.entries(file.profiles).filter(([profileName]) => profileName !== name),
|
|
290
|
+
),
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export function setActiveProfile(file: AgentProfilesFile, name: string): AgentProfilesFile {
|
|
295
|
+
requireValidName(name);
|
|
296
|
+
if (!hasOwn(file.profiles, name)) {
|
|
297
|
+
throw new AgentProfileError("missing_profile", `Profile does not exist: ${name}.`);
|
|
298
|
+
}
|
|
299
|
+
return { ...file, active: name };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ---- Summaries and panel decision helpers ----
|
|
303
|
+
|
|
304
|
+
export interface ProfileModelSummary {
|
|
305
|
+
model: string;
|
|
306
|
+
count: number;
|
|
307
|
+
roles: string[];
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export interface ProfileSummary {
|
|
311
|
+
models: ProfileModelSummary[];
|
|
312
|
+
total: number;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export function summarizeProfile(config: AgentModelConfig): ProfileSummary {
|
|
316
|
+
const byModel = new Map<string, string[]>();
|
|
317
|
+
for (const [agent, entry] of profileRoleEntries(config)) {
|
|
318
|
+
const model = entry?.model ?? INHERIT_MODEL_LABEL;
|
|
319
|
+
const roles = byModel.get(model);
|
|
320
|
+
if (roles) roles.push(agent);
|
|
321
|
+
else byModel.set(model, [agent]);
|
|
322
|
+
}
|
|
323
|
+
const models = [...byModel.entries()]
|
|
324
|
+
.map(([model, roles]) => ({
|
|
325
|
+
model,
|
|
326
|
+
count: roles.length,
|
|
327
|
+
roles: [...roles].sort((left, right) => left.localeCompare(right)),
|
|
328
|
+
}))
|
|
329
|
+
.sort(
|
|
330
|
+
(left, right) =>
|
|
331
|
+
right.count - left.count || left.model.localeCompare(right.model),
|
|
332
|
+
);
|
|
333
|
+
return { models, total: models.reduce((sum, model) => sum + model.count, 0) };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
export function formatProfileSummaryLines(summary: ProfileSummary): string[] {
|
|
337
|
+
if (summary.total === 0) {
|
|
338
|
+
return ["No routing entries — every agent inherits its default model."];
|
|
339
|
+
}
|
|
340
|
+
return summary.models.map(
|
|
341
|
+
(model) =>
|
|
342
|
+
`${model.count} ${model.count === 1 ? "agent" : "agents"} → ${model.model}: ${model.roles.join(", ")}`,
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export interface ProfileRoutingRow {
|
|
347
|
+
agent: string;
|
|
348
|
+
model: string;
|
|
349
|
+
thinking: string;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* One row per agent, in a stable alphabetical order so the profile's routing and
|
|
354
|
+
* the effective routing can be compared line by line, and with the reserved
|
|
355
|
+
* orchestrator key excluded because it is rendered on its own line.
|
|
356
|
+
*/
|
|
357
|
+
export function profileRoutingRows(config: AgentModelConfig): ProfileRoutingRow[] {
|
|
358
|
+
return profileRoleEntries(config)
|
|
359
|
+
.map(([agent, entry]) => ({
|
|
360
|
+
agent,
|
|
361
|
+
model: entry?.model ?? INHERIT_MODEL_LABEL,
|
|
362
|
+
thinking: entry?.thinking ?? INHERIT_MODEL_LABEL,
|
|
363
|
+
}))
|
|
364
|
+
.sort((left, right) => left.agent.localeCompare(right.agent));
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Column widths shared by every routing table in one panel, so the profile's
|
|
369
|
+
* routing and the effective routing align column for column instead of each
|
|
370
|
+
* section sizing itself.
|
|
371
|
+
*/
|
|
372
|
+
export function routingColumnWidths(
|
|
373
|
+
...groups: ProfileRoutingRow[][]
|
|
374
|
+
): { agent: number; model: number } {
|
|
375
|
+
const rows = groups.flat();
|
|
376
|
+
return {
|
|
377
|
+
agent: rows.reduce((width, row) => Math.max(width, row.agent.length), 0),
|
|
378
|
+
model: rows.reduce((width, row) => Math.max(width, row.model.length), 0),
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
export function formatRoutingRow(
|
|
383
|
+
row: ProfileRoutingRow,
|
|
384
|
+
widths: { agent: number; model: number },
|
|
385
|
+
): string {
|
|
386
|
+
return `${row.agent.padEnd(widths.agent)} ${row.model.padEnd(widths.model)} ${row.thinking}`;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* A single line describing an orchestrator selection, for the panel header. The
|
|
391
|
+
* label distinguishes "this profile sets nothing" from "settings.json cannot be
|
|
392
|
+
* read", which are different operator states.
|
|
393
|
+
*/
|
|
394
|
+
export function formatOrchestratorSelection(entry: AgentRoutingEntry | undefined): string {
|
|
395
|
+
if (entry?.model === undefined) {
|
|
396
|
+
return entry?.thinking === undefined
|
|
397
|
+
? INHERIT_MODEL_LABEL
|
|
398
|
+
: `${INHERIT_MODEL_LABEL} · ${entry.thinking}`;
|
|
399
|
+
}
|
|
400
|
+
return entry.thinking === undefined ? entry.model : `${entry.model} · ${entry.thinking}`;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
export interface ProfileListItem {
|
|
404
|
+
id: string;
|
|
405
|
+
label: string;
|
|
406
|
+
description: string;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
export function buildProfileListItems(file: AgentProfilesFile): ProfileListItem[] {
|
|
410
|
+
return Object.entries(file.profiles).map(([name, config]) => {
|
|
411
|
+
const roles = profileRoleEntries(config).length;
|
|
412
|
+
return {
|
|
413
|
+
id: name,
|
|
414
|
+
label: name === file.active ? `${name} (active)` : name,
|
|
415
|
+
description: `${roles} ${roles === 1 ? "role" : "roles"}`,
|
|
416
|
+
};
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
export function bootstrapProfilesFile(currentConfig: AgentModelConfig): AgentProfilesFile {
|
|
421
|
+
const config = normalizeModelConfig(currentConfig) ?? {};
|
|
422
|
+
const file = createProfile(emptyProfilesFile(), "current", config);
|
|
423
|
+
return Object.keys(config).length > 0 ? setActiveProfile(file, "current") : file;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// ---- Single-profile export ----
|
|
427
|
+
|
|
428
|
+
export interface ProfileExport {
|
|
429
|
+
name: string;
|
|
430
|
+
config: AgentModelConfig;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
export interface ProfileExportParseResult extends ProfileExport {
|
|
434
|
+
droppedAgents: string[];
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
export function serializeProfileExport(name: string, config: AgentModelConfig): string {
|
|
438
|
+
requireValidName(name);
|
|
439
|
+
return `${JSON.stringify(
|
|
440
|
+
{
|
|
441
|
+
kind: PROFILE_EXPORT_KIND,
|
|
442
|
+
version: PROFILE_EXPORT_VERSION,
|
|
443
|
+
name,
|
|
444
|
+
config: cloneProfileConfig(config),
|
|
445
|
+
},
|
|
446
|
+
null,
|
|
447
|
+
2,
|
|
448
|
+
)}\n`;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
export function parseProfileExportTextWithDrops(
|
|
452
|
+
text: string,
|
|
453
|
+
): ProfileExportParseResult | undefined {
|
|
454
|
+
let value: unknown;
|
|
455
|
+
try {
|
|
456
|
+
value = JSON.parse(text);
|
|
457
|
+
} catch {
|
|
458
|
+
return undefined;
|
|
459
|
+
}
|
|
460
|
+
if (!isRecord(value)) return undefined;
|
|
461
|
+
if (value.kind !== PROFILE_EXPORT_KIND || value.version !== PROFILE_EXPORT_VERSION) {
|
|
462
|
+
return undefined;
|
|
463
|
+
}
|
|
464
|
+
if (!isValidProfileName(value.name) || !isRecord(value.config)) return undefined;
|
|
465
|
+
const config = normalizeModelConfig(value.config);
|
|
466
|
+
if (!config) return undefined;
|
|
467
|
+
const droppedAgents = Object.keys(value.config).filter(
|
|
468
|
+
(agent) => !hasOwn(config, agent),
|
|
469
|
+
);
|
|
470
|
+
return { name: value.name, config, droppedAgents };
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
export function parseProfileExportText(text: string): ProfileExport | undefined {
|
|
474
|
+
const result = parseProfileExportTextWithDrops(text);
|
|
475
|
+
return result ? { name: result.name, config: result.config } : undefined;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// ---- Path helpers and file wrappers ----
|
|
479
|
+
|
|
480
|
+
export type ProfilesFileReadResult =
|
|
481
|
+
| { status: "missing" }
|
|
482
|
+
| { status: "invalid" }
|
|
483
|
+
| { status: "valid"; file: AgentProfilesFile; drops: ProfilesParseDrops };
|
|
484
|
+
|
|
485
|
+
export function profilesFilePath(configHome: string): string {
|
|
486
|
+
return join(configHome, "profiles.json");
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
export function profileExportPath(configHome: string): string {
|
|
490
|
+
return join(configHome, "profiles.export.json");
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
export function readProfilesFileResult(path: string): ProfilesFileReadResult {
|
|
494
|
+
if (!existsSync(path)) return { status: "missing" };
|
|
495
|
+
let value: unknown;
|
|
496
|
+
try {
|
|
497
|
+
value = JSON.parse(readFileSync(path, "utf8"));
|
|
498
|
+
} catch {
|
|
499
|
+
return { status: "invalid" };
|
|
500
|
+
}
|
|
501
|
+
// Parse through normalizeProfilesFile so the caller can name every entry
|
|
502
|
+
// dropped by normalization instead of losing it silently.
|
|
503
|
+
const normalized = normalizeProfilesFile(value);
|
|
504
|
+
if (!normalized) return { status: "invalid" };
|
|
505
|
+
return { status: "valid", file: normalized.file, drops: normalized.drops };
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Replace the store through a sibling temp file and a rename. A direct write that
|
|
510
|
+
* is interrupted leaves truncated JSON, which `readProfilesFileResult` must then
|
|
511
|
+
* reject as unreadable, so the destination is only ever swapped for a complete
|
|
512
|
+
* file and the temp file is removed on every failure path.
|
|
513
|
+
*/
|
|
514
|
+
export function writeProfilesFileSync(path: string, file: AgentProfilesFile): void {
|
|
515
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
516
|
+
const temporary = join(dirname(path), `.${basename(path)}.${randomUUID()}.tmp`);
|
|
517
|
+
const descriptor = openSync(
|
|
518
|
+
temporary,
|
|
519
|
+
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL,
|
|
520
|
+
0o600,
|
|
521
|
+
);
|
|
522
|
+
// Every step records its own failure and cleanup never throws, so the error
|
|
523
|
+
// that actually broke the write is the one reported: a failing close or unlink
|
|
524
|
+
// must not mask a failed write or rename, and the temp file is removed on every
|
|
525
|
+
// path.
|
|
526
|
+
const failures: unknown[] = [];
|
|
527
|
+
try {
|
|
528
|
+
writeFileSync(descriptor, serializeProfilesFile(file));
|
|
529
|
+
} catch (error) {
|
|
530
|
+
failures.push(error);
|
|
531
|
+
}
|
|
532
|
+
try {
|
|
533
|
+
closeSync(descriptor);
|
|
534
|
+
} catch (error) {
|
|
535
|
+
failures.push(error);
|
|
536
|
+
}
|
|
537
|
+
if (failures.length === 0) {
|
|
538
|
+
try {
|
|
539
|
+
renameSync(temporary, path);
|
|
540
|
+
} catch (error) {
|
|
541
|
+
failures.push(error);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
try {
|
|
545
|
+
unlinkSync(temporary);
|
|
546
|
+
} catch (error) {
|
|
547
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") failures.push(error);
|
|
548
|
+
}
|
|
549
|
+
if (failures.length > 0) throw failures[0];
|
|
550
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Gentle Agents completion delivery queue (issue #867).
|
|
2
|
+
//
|
|
3
|
+
// A background subagent completion used to be handed straight to the host as a
|
|
4
|
+
// `deliverAs: "followUp"` message, but the host only flushes that queue when
|
|
5
|
+
// the parent agent stops calling tools entirely. In a long orchestrator run
|
|
6
|
+
// the notification could therefore land tens of minutes after the parent
|
|
7
|
+
// already pulled the same result through subagent_result. This module owns the
|
|
8
|
+
// pending completions instead: the extension enqueues at settle time, decides
|
|
9
|
+
// freshness at flush time, and drops whatever the parent already consumed. It
|
|
10
|
+
// is dependency-free and clock-agnostic: every timestamp is passed in, so the
|
|
11
|
+
// behavior is unit-testable against a fake clock.
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A completion older than this at flush time is stale: the parent had many
|
|
15
|
+
* turns to pull the result with subagent_result, so replaying it into the
|
|
16
|
+
* model context would re-enter state the conversation may already have used.
|
|
17
|
+
* Ninety seconds comfortably covers one slow orchestrator turn (model latency
|
|
18
|
+
* plus a few tool calls) while staying orders of magnitude below the 50-58
|
|
19
|
+
* minute followUp delays measured in issue #867.
|
|
20
|
+
*/
|
|
21
|
+
export const STALE_COMPLETION_MS = 90_000;
|
|
22
|
+
|
|
23
|
+
export interface DeliverableCompletion<T> {
|
|
24
|
+
/** The settled task exactly as enqueued. */
|
|
25
|
+
task: T;
|
|
26
|
+
/** Clock value passed to enqueue when the task settled. */
|
|
27
|
+
settledAt: number;
|
|
28
|
+
/** True once now - settledAt has reached STALE_COMPLETION_MS. */
|
|
29
|
+
stale: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface CompletionQueue<T extends { id: string }> {
|
|
33
|
+
/** Record a settled completion. At most one record per task id; a duplicate enqueue for a delivered, pending, or consumed id is a no-op. */
|
|
34
|
+
enqueue(task: T, settledAt: number): void;
|
|
35
|
+
/** Mark a task's result as already consumed by the parent. Idempotent; unknown ids are remembered so a later completion is suppressed. */
|
|
36
|
+
consume(taskId: string): void;
|
|
37
|
+
/** Remove and return the still-pending completions in settle order, each with its staleness decision at `now`. Consumed entries are dropped and never returned. */
|
|
38
|
+
takeDeliverable(now: number): Array<DeliverableCompletion<T>>;
|
|
39
|
+
/** Discard everything: pending completions and consumed/delivered bookkeeping. Used on session start and shutdown so nothing replays after a resume. */
|
|
40
|
+
dropAll(): void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function createCompletionQueue<T extends { id: string }>(): CompletionQueue<T> {
|
|
44
|
+
let pending: Array<{ task: T; settledAt: number }> = [];
|
|
45
|
+
const consumed = new Set<string>();
|
|
46
|
+
const delivered = new Set<string>();
|
|
47
|
+
return {
|
|
48
|
+
enqueue(task, settledAt) {
|
|
49
|
+
if (consumed.has(task.id) || delivered.has(task.id) || pending.some((entry) => entry.task.id === task.id)) return;
|
|
50
|
+
pending.push({ task, settledAt });
|
|
51
|
+
},
|
|
52
|
+
consume(taskId) {
|
|
53
|
+
consumed.add(taskId);
|
|
54
|
+
},
|
|
55
|
+
takeDeliverable(now) {
|
|
56
|
+
const taken = pending;
|
|
57
|
+
pending = [];
|
|
58
|
+
const deliverable: Array<DeliverableCompletion<T>> = [];
|
|
59
|
+
for (const entry of taken) {
|
|
60
|
+
if (consumed.has(entry.task.id)) continue;
|
|
61
|
+
delivered.add(entry.task.id);
|
|
62
|
+
deliverable.push({ task: entry.task, settledAt: entry.settledAt, stale: now - entry.settledAt >= STALE_COMPLETION_MS });
|
|
63
|
+
}
|
|
64
|
+
return deliverable;
|
|
65
|
+
},
|
|
66
|
+
dropAll() {
|
|
67
|
+
pending = [];
|
|
68
|
+
consumed.clear();
|
|
69
|
+
delivered.clear();
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|