gentle-pi 2.7.0 → 3.0.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 +24 -6
- package/assets/agents/gentle-ai-worker.md +5 -1
- package/assets/agents/sdd-apply.md +9 -7
- package/assets/agents/sdd-archive.md +42 -23
- package/assets/agents/sdd-proposal.md +2 -2
- package/assets/agents/sdd-remediate.md +4 -4
- package/assets/agents/sdd-research.md +20 -48
- package/assets/agents/sdd-tasks.md +5 -5
- package/assets/agents/sdd-verify.md +6 -28
- package/assets/chains/sdd-full.chain.md +4 -22
- package/assets/chains/sdd-verify.chain.md +3 -12
- package/assets/orchestrator-delegation.md +33 -3
- package/assets/orchestrator-memory.md +20 -7
- package/assets/orchestrator.md +5 -3
- package/assets/sdd-orchestrator-workflow.md +25 -58
- package/assets/support/sdd-status-contract.md +9 -12
- package/docs/gentle-shell.md +14 -4
- package/docs/readme-reference.md +171 -32
- package/extensions/codegraph-tools.ts +2 -0
- package/extensions/gentle-agents.ts +281 -361
- package/extensions/gentle-ai.ts +588 -117
- package/extensions/gentle-shell.ts +74 -30
- package/extensions/pi-pretty.ts +63 -14
- package/extensions/quiet-tools.ts +1 -2
- package/extensions/startup-banner.ts +10 -9
- package/lib/agent-home.ts +8 -0
- package/lib/agent-profile-pin.ts +336 -0
- package/lib/agent-profiles.ts +28 -8
- package/lib/agents-config.ts +24 -2
- package/lib/agents-history.ts +3 -97
- package/lib/agents-keys.ts +27 -0
- package/lib/agents-protocol.ts +2 -15
- package/lib/agents-runner.ts +67 -111
- package/lib/agents-session-transport.ts +691 -0
- package/lib/command-palette-catalog.ts +87 -0
- package/lib/command-palette.ts +346 -0
- package/lib/native-choice-list.ts +5 -0
- package/lib/native-review-cli.ts +10 -97
- package/lib/review-publication-gate.ts +11 -1
- package/lib/review-repository.ts +1 -1
- package/lib/review-snapshot.ts +1 -0
- package/lib/review-transaction.ts +4 -2
- package/lib/sdd-preflight.ts +2 -1
- package/lib/sdd-research-capabilities.ts +18 -152
- package/lib/sdd-status.ts +7 -779
- package/lib/session-change-capture.ts +2 -1
- package/lib/session-changes.ts +8 -1
- package/lib/shell-bar.ts +21 -12
- package/lib/shell-card.ts +8 -12
- package/lib/shell-changes.ts +3 -2
- package/lib/shell-prompt.ts +25 -8
- package/lib/shell-sidebar-banner.ts +2 -2
- package/lib/shell-sidebar-layout.ts +5 -2
- package/lib/windows-session-transport.ts +877 -0
- package/package.json +3 -3
- package/runtime/native-review-cli.mjs +9 -96
- package/runtime/windows-session-transport.ps1 +791 -0
- package/scripts/test-packed-runner.mjs +1668 -20
- package/scripts/verify-package-files.mjs +0 -1
- package/tests/agent-home.test.ts +52 -0
- package/tests/agent-profiles.test.ts +30 -1
- package/tests/agents-config.test.ts +44 -0
- package/tests/agents-history.test.ts +12 -24
- package/tests/agents-runner.test.ts +307 -58
- package/tests/agents-session-transport-process.test.ts +249 -0
- package/tests/agents-session-transport.test.ts +823 -0
- package/tests/artifact-language.test.ts +10 -7
- package/tests/command-palette.test.ts +378 -0
- package/tests/delegated-key-learnings-contract.test.ts +2 -2
- package/tests/fixtures/agents-session-transport-process.mjs +108 -0
- package/tests/fixtures/legacy/sdd-research-v2.5.0.md +54 -0
- package/tests/fixtures/windows-session-bootstrap.ps1 +129 -0
- package/tests/fixtures/windows-session-compile.ps1 +110 -0
- package/tests/gentle-agents.test.ts +849 -356
- package/tests/gentle-ai.test.ts +472 -4
- package/tests/gentle-shell.test.ts +201 -8
- package/tests/native-choice-list.test.ts +13 -0
- package/tests/native-review-cli.test.ts +0 -33
- package/tests/odd-routing-contract.test.ts +208 -0
- package/tests/orchestrator-budget.test.ts +17 -2
- package/tests/package-manifest.test.ts +115 -27
- package/tests/persona-single-channel.test.ts +3 -3
- package/tests/pi-pretty.test.ts +45 -0
- package/tests/profile-pin.test.ts +370 -0
- package/tests/quiet-tool-rendering.test.ts +32 -5
- package/tests/review-contract-prompt.test.ts +9 -0
- package/tests/review-controller.test.ts +0 -44
- package/tests/review-session-standing-permission-ipc.test.ts +427 -13
- package/tests/runtime-harness.mjs +4 -4
- package/tests/sdd-agent-tools.test.ts +15 -36
- package/tests/sdd-archive-replay.test.ts +82 -0
- package/tests/sdd-classical-continuation.test.ts +74 -0
- package/tests/sdd-execution-routing-contract.test.ts +18 -2
- package/tests/sdd-managed-runtime-settlement.test.ts +42 -330
- package/tests/sdd-native-managed-uptake.test.ts +11 -21
- package/tests/sdd-no-attempts-contract.test.ts +15 -0
- package/tests/sdd-odd-integration.test.ts +33 -0
- package/tests/sdd-optional-research.test.ts +124 -0
- package/tests/sdd-planning-routing-contract.test.ts +1 -1
- package/tests/sdd-preflight-rpc-input.test.ts +125 -0
- package/tests/sdd-preflight.test.ts +1 -1
- package/tests/sdd-research-capabilities.test.ts +20 -162
- package/tests/sdd-selection-transport.test.ts +180 -88
- package/tests/sdd-status.test.ts +5 -778
- package/tests/sdd-task-truth.test.ts +43 -0
- package/tests/session-change-capture.test.ts +20 -2
- package/tests/session-changes.test.ts +11 -0
- package/tests/shell-bar.test.ts +21 -0
- package/tests/shell-card.test.ts +8 -6
- package/tests/shell-changes.test.ts +8 -0
- package/tests/shell-prompt.test.ts +41 -7
- package/tests/shell-sidebar-banner.test.ts +4 -4
- package/tests/shell-sidebar-layout.test.ts +97 -13
- package/tests/startup-banner.test.ts +55 -2
- package/tests/windows-hidden-processes.test.ts +303 -0
- package/tests/windows-session-bootstrap.test.ts +1772 -0
- package/tests/windows-session-compile.test.ts +170 -0
- package/tests/windows-session-transport.test.ts +754 -0
- package/assets/agents/sdd-sync.md +0 -146
- package/lib/openspec-guardrails.ts +0 -99
- package/tests/native-sdd-attempt-authority.test.ts +0 -240
- package/tests/openspec-guardrails.test.ts +0 -71
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
// Per-repository agent-model profile pins.
|
|
2
|
+
//
|
|
3
|
+
// `/gentle:profiles` applies a profile globally: it rewrites `models.json`, the
|
|
4
|
+
// agent frontmatter, `subagents.json`, and the orchestrator in `settings.json`.
|
|
5
|
+
// Two repositories worked in parallel therefore fight over one global routing.
|
|
6
|
+
// A pin re-anchors only the subagent routing of one repository to a named profile
|
|
7
|
+
// from the global store, at launch time, without materialising anything:
|
|
8
|
+
//
|
|
9
|
+
// local pin <git-common-dir>/gentle-ai/profile-pin.json
|
|
10
|
+
// repo declaration <worktree-root>/.pi/gentle-ai/profile.json
|
|
11
|
+
//
|
|
12
|
+
// The local pin sits inside the Git common directory, so it is invisible to Git
|
|
13
|
+
// and shared by every worktree of the clone; the repo declaration is an ordinary
|
|
14
|
+
// tracked file, so a team can commit the routing a repository expects. Precedence
|
|
15
|
+
// is local, then repo, then no pin — and "no pin" is exactly today's behaviour, so
|
|
16
|
+
// an unreadable, invalid, or stale layer is skipped instead of aborting a launch.
|
|
17
|
+
//
|
|
18
|
+
// The orchestrator is deliberately out of scope: `profileRoleEntries` drops the
|
|
19
|
+
// reserved orchestrator key, so a pin never moves the orchestrator model.
|
|
20
|
+
//
|
|
21
|
+
// `evaluateProfilePin` is the single precedence rule. The launch resolver below and
|
|
22
|
+
// the `/gentle:profiles` panel both go through it, so the layer a launch uses and the
|
|
23
|
+
// layer the panel reports can never disagree.
|
|
24
|
+
|
|
25
|
+
import { existsSync, readFileSync, unlinkSync } from "node:fs";
|
|
26
|
+
import { join } from "node:path";
|
|
27
|
+
import { isValidProfileName, profileRoleEntries, profilesFilePath, readProfilesFileResult, writeJsonFileAtomicallySync } from "./agent-profiles.ts";
|
|
28
|
+
import type { AgentModelConfig } from "./model-routing-authority.ts";
|
|
29
|
+
import { resolveSessionWorktreeWithGit, type WorktreeIdentity, type WorktreeResolver } from "./session-worktree-registry.ts";
|
|
30
|
+
|
|
31
|
+
export const PROFILE_PIN_KIND = "gentle-pi.agent_model_profile_pin";
|
|
32
|
+
export const PROFILE_PIN_VERSION = 1;
|
|
33
|
+
// Ordered root .gitignore rules that expose only the committable declaration.
|
|
34
|
+
// Git cannot re-include a file while an excluded parent directory remains hidden,
|
|
35
|
+
// so each parent is reopened and its unrelated children are ignored again.
|
|
36
|
+
export const REPO_PROFILE_DECLARATION_GITIGNORE_RULES = [
|
|
37
|
+
"!.pi/",
|
|
38
|
+
".pi/*",
|
|
39
|
+
"!.pi/gentle-ai/",
|
|
40
|
+
".pi/gentle-ai/*",
|
|
41
|
+
"!.pi/gentle-ai/profile.json",
|
|
42
|
+
] as const;
|
|
43
|
+
|
|
44
|
+
export interface AgentProfilePinFile {
|
|
45
|
+
kind: typeof PROFILE_PIN_KIND;
|
|
46
|
+
version: typeof PROFILE_PIN_VERSION;
|
|
47
|
+
profile: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type ProfilePinSource = "local" | "repo";
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* What one pin file holds. `missing` is an absent file; `invalid` is a file that
|
|
54
|
+
* exists but does not hold a pin this version understands. Collapsing the two hides
|
|
55
|
+
* a broken pin behind "no pin", so the reader reports which one it found and the
|
|
56
|
+
* panel can say so instead of silently changing nothing.
|
|
57
|
+
*/
|
|
58
|
+
export type ProfilePinReadResult =
|
|
59
|
+
| { status: "missing" }
|
|
60
|
+
| { status: "invalid" }
|
|
61
|
+
| { status: "valid"; profile: string };
|
|
62
|
+
|
|
63
|
+
/** Which pin files exist for a directory, and what each of them holds. */
|
|
64
|
+
export interface ProfilePinStatus {
|
|
65
|
+
root: string;
|
|
66
|
+
commonDir: string;
|
|
67
|
+
localPath: string;
|
|
68
|
+
repoPath: string;
|
|
69
|
+
local: ProfilePinReadResult;
|
|
70
|
+
repo: ProfilePinReadResult;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** One pin layer, named by its path, without the name it stores. */
|
|
74
|
+
export interface ProfilePinLayerIssue {
|
|
75
|
+
source: ProfilePinSource;
|
|
76
|
+
path: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** One pin layer whose stored name the profiles store no longer defines. */
|
|
80
|
+
export interface ProfilePinStaleIssue extends ProfilePinLayerIssue {
|
|
81
|
+
profile: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The layer the launch resolves, when one does. */
|
|
85
|
+
export interface ProfilePinSelection {
|
|
86
|
+
source: ProfilePinSource;
|
|
87
|
+
profile: string;
|
|
88
|
+
path: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* What both layers amount to: the layer that wins (if any), every layer that is not
|
|
93
|
+
* a pin file, and every layer that names a profile the store dropped.
|
|
94
|
+
*/
|
|
95
|
+
export interface ProfilePinEvaluation {
|
|
96
|
+
winner?: ProfilePinSelection;
|
|
97
|
+
invalid: ProfilePinLayerIssue[];
|
|
98
|
+
stale: ProfilePinStaleIssue[];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** A pin that resolved to routing the launch will actually use. */
|
|
102
|
+
export interface ProfilePinResolution extends ProfilePinSelection {
|
|
103
|
+
modelProfiles: AgentModelConfig;
|
|
104
|
+
/** Both layers as they were read, so a caller can report what was skipped and why. */
|
|
105
|
+
status: ProfilePinStatus;
|
|
106
|
+
invalidLayers: ProfilePinLayerIssue[];
|
|
107
|
+
staleLayers: ProfilePinStaleIssue[];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function localProfilePinPath(commonDir: string): string {
|
|
111
|
+
return join(commonDir, "gentle-ai", "profile-pin.json");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function repoProfileDeclarationPath(worktreeRoot: string): string {
|
|
115
|
+
return join(worktreeRoot, ".pi", "gentle-ai", "profile.json");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Fixed key order plus a trailing newline keeps the artifact byte-identical for the
|
|
119
|
+
// same profile, so a committed declaration does not churn on every write.
|
|
120
|
+
export function serializeProfilePin(profile: string): string {
|
|
121
|
+
const file: AgentProfilePinFile = {
|
|
122
|
+
kind: PROFILE_PIN_KIND,
|
|
123
|
+
version: PROFILE_PIN_VERSION,
|
|
124
|
+
profile,
|
|
125
|
+
};
|
|
126
|
+
return `${JSON.stringify(file, null, 2)}\n`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Value-level normalizer: the profile name a parsed pin file holds, or `undefined`
|
|
131
|
+
* when the value is not a pin this version understands. The text parser below reuses
|
|
132
|
+
* it, so a value and its serialized text can never disagree about what is a pin.
|
|
133
|
+
*/
|
|
134
|
+
export function normalizeProfilePin(value: unknown): string | undefined {
|
|
135
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
|
|
136
|
+
const record = value as Record<string, unknown>;
|
|
137
|
+
if (record.kind !== PROFILE_PIN_KIND || record.version !== PROFILE_PIN_VERSION) {
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
// Only a store-legal profile name can ever resolve, so an unparseable name is
|
|
141
|
+
// rejected here rather than reaching the store lookup.
|
|
142
|
+
if (!isValidProfileName(record.profile)) return undefined;
|
|
143
|
+
return record.profile;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export type ProfilePinParseResult =
|
|
147
|
+
| { status: "valid"; profile: string }
|
|
148
|
+
| { status: "invalid" };
|
|
149
|
+
|
|
150
|
+
export function parseProfilePinText(text: string): ProfilePinParseResult {
|
|
151
|
+
let value: unknown;
|
|
152
|
+
try {
|
|
153
|
+
value = JSON.parse(text);
|
|
154
|
+
} catch {
|
|
155
|
+
return { status: "invalid" };
|
|
156
|
+
}
|
|
157
|
+
const profile = normalizeProfilePin(value);
|
|
158
|
+
return profile === undefined ? { status: "invalid" } : { status: "valid", profile };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function readProfilePinResult(path: string): ProfilePinReadResult {
|
|
162
|
+
if (!existsSync(path)) return { status: "missing" };
|
|
163
|
+
try {
|
|
164
|
+
return parseProfilePinText(readFileSync(path, "utf8"));
|
|
165
|
+
} catch {
|
|
166
|
+
// A file that exists but cannot be read is not "no pin": it is a broken pin.
|
|
167
|
+
return { status: "invalid" };
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The stored name, or `undefined` for both "missing" and "invalid". Prefer
|
|
173
|
+
* `readProfilePinResult` where the difference matters; this stays for callers that
|
|
174
|
+
* only want the name.
|
|
175
|
+
*/
|
|
176
|
+
export function readProfilePin(path: string): string | undefined {
|
|
177
|
+
const result = readProfilePinResult(path);
|
|
178
|
+
return result.status === "valid" ? result.profile : undefined;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
let profilePinWorktreeResolver: WorktreeResolver = resolveSessionWorktreeWithGit;
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Test seam, mirroring the other injectable seams in `lib/`: the `/gentle:profiles`
|
|
185
|
+
* panel resolves the pin through the ambient resolver, and a test must not depend
|
|
186
|
+
* on where the test runner's working directory happens to sit. The launch path
|
|
187
|
+
* passes its own resolver instead, so it needs no seam.
|
|
188
|
+
*/
|
|
189
|
+
export function setProfilePinWorktreeResolverForTesting(resolver?: WorktreeResolver): void {
|
|
190
|
+
profilePinWorktreeResolver = resolver ?? resolveSessionWorktreeWithGit;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function gentlePiWorktreeIdentity(
|
|
194
|
+
cwd: string,
|
|
195
|
+
resolveWorktree: WorktreeResolver,
|
|
196
|
+
): WorktreeIdentity | undefined {
|
|
197
|
+
let identity: WorktreeIdentity | undefined;
|
|
198
|
+
try {
|
|
199
|
+
identity = resolveWorktree(cwd, cwd);
|
|
200
|
+
} catch {
|
|
201
|
+
identity = undefined;
|
|
202
|
+
}
|
|
203
|
+
return identity;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Read both pin layers for a directory. This never throws and never writes: an
|
|
208
|
+
* absent, unreadable, or invalid pin file is reported as such, so a broken pin
|
|
209
|
+
* cannot block the panel or a launch.
|
|
210
|
+
*/
|
|
211
|
+
export function readProfilePinStatus(
|
|
212
|
+
cwd: string,
|
|
213
|
+
resolveWorktree: WorktreeResolver = profilePinWorktreeResolver,
|
|
214
|
+
): ProfilePinStatus | undefined {
|
|
215
|
+
const identity = gentlePiWorktreeIdentity(cwd, resolveWorktree);
|
|
216
|
+
if (!identity) return undefined;
|
|
217
|
+
const localPath = localProfilePinPath(identity.commonDir);
|
|
218
|
+
const repoPath = repoProfileDeclarationPath(identity.root);
|
|
219
|
+
return {
|
|
220
|
+
root: identity.root,
|
|
221
|
+
commonDir: identity.commonDir,
|
|
222
|
+
localPath,
|
|
223
|
+
repoPath,
|
|
224
|
+
local: readProfilePinResult(localPath),
|
|
225
|
+
repo: readProfilePinResult(repoPath),
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function writeProfilePinSync(path: string, profile: string): void {
|
|
230
|
+
if (!isValidProfileName(profile)) {
|
|
231
|
+
throw new Error(`Invalid profile name: ${JSON.stringify(profile)}.`);
|
|
232
|
+
}
|
|
233
|
+
writeJsonFileAtomicallySync(path, serializeProfilePin(profile));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Remove one pin layer. Pinning is a toggle: the key that sets a layer clears the
|
|
238
|
+
* same layer, and a missing file is already the desired state.
|
|
239
|
+
*/
|
|
240
|
+
export function clearProfilePinSync(path: string): void {
|
|
241
|
+
try {
|
|
242
|
+
unlinkSync(path);
|
|
243
|
+
} catch (error) {
|
|
244
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function hasProfile(profiles: Record<string, unknown>, name: string): boolean {
|
|
249
|
+
return Object.prototype.hasOwnProperty.call(profiles, name);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function profilePinLayers(
|
|
253
|
+
status: ProfilePinStatus,
|
|
254
|
+
): Array<{ source: ProfilePinSource; path: string; read: ProfilePinReadResult }> {
|
|
255
|
+
return [
|
|
256
|
+
{ source: "local", path: status.localPath, read: status.local },
|
|
257
|
+
{ source: "repo", path: status.repoPath, read: status.repo },
|
|
258
|
+
];
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* The single precedence rule, shared by the launch resolver and the panel: the first
|
|
263
|
+
* layer, in local-then-repo order, whose file holds a legal name the store still
|
|
264
|
+
* defines wins. Everything else is reported instead of hidden, so the panel can name
|
|
265
|
+
* a file that is not a pin (`invalid`) separately from a name the store dropped
|
|
266
|
+
* (`stale`), and neither masks a lower layer that does resolve.
|
|
267
|
+
*/
|
|
268
|
+
export function evaluateProfilePin(
|
|
269
|
+
status: ProfilePinStatus | undefined,
|
|
270
|
+
profiles: Record<string, unknown>,
|
|
271
|
+
): ProfilePinEvaluation {
|
|
272
|
+
const evaluation: ProfilePinEvaluation = { invalid: [], stale: [] };
|
|
273
|
+
if (!status) return evaluation;
|
|
274
|
+
for (const layer of profilePinLayers(status)) {
|
|
275
|
+
if (layer.read.status === "missing") continue;
|
|
276
|
+
if (layer.read.status === "invalid") {
|
|
277
|
+
evaluation.invalid.push({ source: layer.source, path: layer.path });
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
if (evaluation.winner !== undefined) {
|
|
281
|
+
// Precedence is already decided; the lower layer is only reported.
|
|
282
|
+
if (!hasProfile(profiles, layer.read.profile)) {
|
|
283
|
+
evaluation.stale.push({ source: layer.source, path: layer.path, profile: layer.read.profile });
|
|
284
|
+
}
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
if (hasProfile(profiles, layer.read.profile)) {
|
|
288
|
+
evaluation.winner = { source: layer.source, profile: layer.read.profile, path: layer.path };
|
|
289
|
+
} else {
|
|
290
|
+
evaluation.stale.push({ source: layer.source, path: layer.path, profile: layer.read.profile });
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return evaluation;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export interface ProfilePinResolveOptions {
|
|
297
|
+
cwd: string;
|
|
298
|
+
configHome: string;
|
|
299
|
+
resolveWorktree?: WorktreeResolver;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* The routing a launch should use, or `undefined` when no pin applies.
|
|
304
|
+
*
|
|
305
|
+
* Layers are tried in precedence order by `evaluateProfilePin`, and the first usable
|
|
306
|
+
* one wins: the pin file holds a legal name AND the global store still defines that
|
|
307
|
+
* profile. Everything else — no pin file, invalid JSON, a name the store no longer
|
|
308
|
+
* defines — is skipped, so a stale local pin cannot mask a valid repository
|
|
309
|
+
* declaration and a stale chain degrades to today's global routing. The resolution
|
|
310
|
+
* also carries both layers as read, so a caller can report what was skipped.
|
|
311
|
+
*/
|
|
312
|
+
export function resolveProfilePin(
|
|
313
|
+
options: ProfilePinResolveOptions,
|
|
314
|
+
): ProfilePinResolution | undefined {
|
|
315
|
+
const status = readProfilePinStatus(options.cwd, options.resolveWorktree);
|
|
316
|
+
if (!status) return undefined;
|
|
317
|
+
if (status.local.status === "missing" && status.repo.status === "missing") return undefined;
|
|
318
|
+
const store = readProfilesFileResult(profilesFilePath(options.configHome));
|
|
319
|
+
const profiles: Record<string, AgentModelConfig> = store.status === "valid" ? store.file.profiles : {};
|
|
320
|
+
const evaluation = evaluateProfilePin(status, profiles);
|
|
321
|
+
const winner = evaluation.winner;
|
|
322
|
+
if (winner === undefined) return undefined;
|
|
323
|
+
const config = profiles[winner.profile];
|
|
324
|
+
if (config === undefined) return undefined;
|
|
325
|
+
return {
|
|
326
|
+
source: winner.source,
|
|
327
|
+
profile: winner.profile,
|
|
328
|
+
path: winner.path,
|
|
329
|
+
modelProfiles: Object.fromEntries(
|
|
330
|
+
profileRoleEntries(config).map(([agent, entry]) => [agent, { ...entry }]),
|
|
331
|
+
),
|
|
332
|
+
status,
|
|
333
|
+
invalidLayers: evaluation.invalid,
|
|
334
|
+
staleLayers: evaluation.stale,
|
|
335
|
+
};
|
|
336
|
+
}
|
package/lib/agent-profiles.ts
CHANGED
|
@@ -406,12 +406,18 @@ export interface ProfileListItem {
|
|
|
406
406
|
description: string;
|
|
407
407
|
}
|
|
408
408
|
|
|
409
|
-
export function buildProfileListItems(
|
|
409
|
+
export function buildProfileListItems(
|
|
410
|
+
file: AgentProfilesFile,
|
|
411
|
+
pinned?: string,
|
|
412
|
+
): ProfileListItem[] {
|
|
410
413
|
return Object.entries(file.profiles).map(([name, config]) => {
|
|
411
414
|
const roles = profileRoleEntries(config).length;
|
|
415
|
+
const active = name === file.active ? `${name} (active)` : name;
|
|
412
416
|
return {
|
|
413
417
|
id: name,
|
|
414
|
-
|
|
418
|
+
// A pinned profile is the one this repository launches with, which is not the
|
|
419
|
+
// same thing as the globally active profile, so both are named.
|
|
420
|
+
label: name === pinned ? `${active} (pinned)` : active,
|
|
415
421
|
description: `${roles} ${roles === 1 ? "role" : "roles"}`,
|
|
416
422
|
};
|
|
417
423
|
});
|
|
@@ -506,12 +512,22 @@ export function readProfilesFileResult(path: string): ProfilesFileReadResult {
|
|
|
506
512
|
}
|
|
507
513
|
|
|
508
514
|
/**
|
|
509
|
-
* Replace
|
|
510
|
-
* is interrupted leaves truncated JSON, which
|
|
511
|
-
*
|
|
512
|
-
*
|
|
515
|
+
* Replace a JSON store through a sibling temp file and a rename. A direct write
|
|
516
|
+
* that is interrupted leaves truncated JSON, which the readers must then reject as
|
|
517
|
+
* unreadable, so the destination is only ever swapped for a complete file and the
|
|
518
|
+
* temp file is removed on every failure path. Shared by the profiles store and the
|
|
519
|
+
* per-repository profile pin so both stores keep the same atomicity guarantee.
|
|
513
520
|
*/
|
|
514
|
-
export function
|
|
521
|
+
export function writeJsonFileAtomicallySync(path: string, text: string): void {
|
|
522
|
+
// Replacing a file with identical bytes is not a change, so it is skipped instead
|
|
523
|
+
// of churning the mtime. Pin writes are the reason this matters: re-applying the
|
|
524
|
+
// profile a repository already pins must not touch the pin file. An unreadable or
|
|
525
|
+
// missing destination simply falls through to the atomic write below.
|
|
526
|
+
try {
|
|
527
|
+
if (readFileSync(path, "utf8") === text) return;
|
|
528
|
+
} catch {
|
|
529
|
+
// Fall through: the file is absent or not readable as text.
|
|
530
|
+
}
|
|
515
531
|
mkdirSync(dirname(path), { recursive: true });
|
|
516
532
|
const temporary = join(dirname(path), `.${basename(path)}.${randomUUID()}.tmp`);
|
|
517
533
|
const descriptor = openSync(
|
|
@@ -525,7 +541,7 @@ export function writeProfilesFileSync(path: string, file: AgentProfilesFile): vo
|
|
|
525
541
|
// path.
|
|
526
542
|
const failures: unknown[] = [];
|
|
527
543
|
try {
|
|
528
|
-
writeFileSync(descriptor,
|
|
544
|
+
writeFileSync(descriptor, text);
|
|
529
545
|
} catch (error) {
|
|
530
546
|
failures.push(error);
|
|
531
547
|
}
|
|
@@ -548,3 +564,7 @@ export function writeProfilesFileSync(path: string, file: AgentProfilesFile): vo
|
|
|
548
564
|
}
|
|
549
565
|
if (failures.length > 0) throw failures[0];
|
|
550
566
|
}
|
|
567
|
+
|
|
568
|
+
export function writeProfilesFileSync(path: string, file: AgentProfilesFile): void {
|
|
569
|
+
writeJsonFileAtomicallySync(path, serializeProfilesFile(file));
|
|
570
|
+
}
|
package/lib/agents-config.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
2
2
|
import { basename, join } from "node:path";
|
|
3
|
-
import { THINKING_LEVELS as ROUTING_THINKING_LEVELS, type ThinkingLevel as RoutingThinkingLevel } from "./model-routing-authority.ts";
|
|
3
|
+
import { THINKING_LEVELS as ROUTING_THINKING_LEVELS, type AgentModelConfig, type ThinkingLevel as RoutingThinkingLevel } from "./model-routing-authority.ts";
|
|
4
4
|
|
|
5
5
|
// Gentle Agents configuration. Agent definitions are markdown files with YAML
|
|
6
6
|
// frontmatter (the format gentle-ai installs) and runtime settings come from
|
|
@@ -70,6 +70,7 @@ export interface AgentsConfig {
|
|
|
70
70
|
defaultMode: AgentMode;
|
|
71
71
|
modelProfiles: Record<string, ModelProfile>;
|
|
72
72
|
stallTimeoutMs: number;
|
|
73
|
+
toolStallTimeoutMs: number;
|
|
73
74
|
maxConcurrency: number;
|
|
74
75
|
historyMaxTasks: number;
|
|
75
76
|
}
|
|
@@ -104,6 +105,7 @@ export interface Frontmatter {
|
|
|
104
105
|
}
|
|
105
106
|
|
|
106
107
|
const DEFAULT_STALL_TIMEOUT_MS = 4 * 60_000;
|
|
108
|
+
const DEFAULT_TOOL_STALL_TIMEOUT_MS = 30 * 60_000;
|
|
107
109
|
const DEFAULT_MAX_CONCURRENCY = 5;
|
|
108
110
|
const DEFAULT_HISTORY_MAX_TASKS = 200;
|
|
109
111
|
const THINKING_LEVELS: readonly string[] = ROUTING_THINKING_LEVELS;
|
|
@@ -263,6 +265,7 @@ export function parseAgentsConfig(global: RawConfig, project: RawConfig): Agents
|
|
|
263
265
|
const merged: Record<string, unknown> = { ...(global ?? {}), ...(project ?? {}) };
|
|
264
266
|
const thinking = parseThinking(merged.default_effort ?? merged.default_thinking_level ?? merged.default_thinking);
|
|
265
267
|
const mode = parseMode(merged.default_mode);
|
|
268
|
+
const stallTimeoutMs = positiveInteger(merged.stall_timeout_ms, DEFAULT_STALL_TIMEOUT_MS);
|
|
266
269
|
return {
|
|
267
270
|
defaultModel: parseModelRef(merged.default_model),
|
|
268
271
|
defaultThinking: thinking !== undefined && THINKING_LEVELS.includes(thinking) ? (thinking as ThinkingLevel) : undefined,
|
|
@@ -270,7 +273,11 @@ export function parseAgentsConfig(global: RawConfig, project: RawConfig): Agents
|
|
|
270
273
|
modelProfiles: mergeProfiles(parseProfiles(global?.model_profiles), parseProfiles(project?.model_profiles)),
|
|
271
274
|
// `timeout_ms` remains accepted as an inert legacy key so existing JSON
|
|
272
275
|
// files load normally; only silence is bounded by `stall_timeout_ms`.
|
|
273
|
-
stallTimeoutMs
|
|
276
|
+
stallTimeoutMs,
|
|
277
|
+
// An announced tool call in flight is live work, not silence, so it gets a
|
|
278
|
+
// longer ceiling; the idle budget still bounds a genuinely quiet child and
|
|
279
|
+
// remains the hard floor for this one.
|
|
280
|
+
toolStallTimeoutMs: Math.max(positiveInteger(merged.tool_stall_timeout_ms, DEFAULT_TOOL_STALL_TIMEOUT_MS), stallTimeoutMs),
|
|
274
281
|
maxConcurrency: positiveInteger(merged.max_concurrency, DEFAULT_MAX_CONCURRENCY),
|
|
275
282
|
historyMaxTasks: positiveInteger(merged.history_max_tasks, DEFAULT_HISTORY_MAX_TASKS),
|
|
276
283
|
};
|
|
@@ -290,6 +297,21 @@ export function loadAgentsConfig(roots: DiscoveryRoots): AgentsConfig {
|
|
|
290
297
|
return parseAgentsConfig(readJson(join(profileRoot(roots), "subagents.json")), readJson(join(roots.cwd, ".pi", "subagents.json")));
|
|
291
298
|
}
|
|
292
299
|
|
|
300
|
+
export function withPinnedModelProfiles(
|
|
301
|
+
config: AgentsConfig,
|
|
302
|
+
pinned: AgentModelConfig | undefined,
|
|
303
|
+
): AgentsConfig {
|
|
304
|
+
// No pin, and no pin-shaped input, both mean today's routing: a repository that
|
|
305
|
+
// never opted in must not observe any difference.
|
|
306
|
+
if (pinned === undefined) return config;
|
|
307
|
+
// The profile replaces subagent routing wholesale. Merging would let routing
|
|
308
|
+
// materialised in `subagents.json` by a previous global profile leak into a
|
|
309
|
+
// repository that pinned a different one, which is the exact conflict a pin
|
|
310
|
+
// exists to remove. Only `modelProfiles` moves: the orchestrator routing and
|
|
311
|
+
// every operational default stay global.
|
|
312
|
+
return { ...config, modelProfiles: parseProfiles(pinned) };
|
|
313
|
+
}
|
|
314
|
+
|
|
293
315
|
function pick<T>(candidates: Array<[T | undefined, ProfileSource]>): [T | undefined, ProfileSource] {
|
|
294
316
|
return candidates.find(([value]) => value !== undefined) ?? [undefined, PROFILE_SOURCE.UNRESOLVED];
|
|
295
317
|
}
|
package/lib/agents-history.ts
CHANGED
|
@@ -1,9 +1,5 @@
|
|
|
1
|
-
import { execFileSync } from "node:child_process";
|
|
2
|
-
import { randomUUID } from "node:crypto";
|
|
3
|
-
import { closeSync, fsyncSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, readlinkSync, readdirSync, unlinkSync, writeFileSync } from "node:fs";
|
|
4
1
|
import { mkdir, readdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
5
|
-
import
|
|
6
|
-
import { dirname, join } from "node:path";
|
|
2
|
+
import { join } from "node:path";
|
|
7
3
|
import { emptyThread, type TaskRecord, type TaskThread } from "./agents-protocol.ts";
|
|
8
4
|
|
|
9
5
|
// Gentle Agents history: one JSON file per finished task, written by the
|
|
@@ -16,20 +12,6 @@ export interface StoredTask {
|
|
|
16
12
|
thread: TaskThread;
|
|
17
13
|
}
|
|
18
14
|
|
|
19
|
-
export function remediationUnresolved(task: TaskRecord): boolean {
|
|
20
|
-
const state = task.sddRemediation;
|
|
21
|
-
if (!state) return false;
|
|
22
|
-
if (state.acquireUncertain || state.settlementUncertain) return true;
|
|
23
|
-
// A received settlement is a definite native outcome, whatever its state
|
|
24
|
-
// (including "blocked"): it is terminal task history, never local
|
|
25
|
-
// ambiguity. Native admission is the sole authority over any later
|
|
26
|
-
// attempt for the same cwd/change; only genuinely uncertain outcomes, or
|
|
27
|
-
// no settlement at all with a still-retained token/claimed actor, are
|
|
28
|
-
// unresolved.
|
|
29
|
-
if (state.settlement) return false;
|
|
30
|
-
return !!state.token || !!state.actorClaimed || !["blocked", "complete"].includes(state.acquireResult?.state ?? "");
|
|
31
|
-
}
|
|
32
|
-
|
|
33
15
|
const FILE_SUFFIX = ".json";
|
|
34
16
|
const SAFE_ID = /^[a-z0-9-]+$/i;
|
|
35
17
|
|
|
@@ -42,83 +24,6 @@ function fileFor(dir: string, id: string): string {
|
|
|
42
24
|
return join(dir, `${id}${FILE_SUFFIX}`);
|
|
43
25
|
}
|
|
44
26
|
|
|
45
|
-
const TASK_LOCK_SCHEMA = "gentle-pi.task-reconciliation-lock/v1" as const;
|
|
46
|
-
const UUID = /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i;
|
|
47
|
-
type TaskLockOwner = { schema: typeof TASK_LOCK_SCHEMA; taskId: string; token: string; pid: number; host: string | null };
|
|
48
|
-
export interface TaskLock { readonly path: string; readonly taskId: string; readonly token: string; release(): void; }
|
|
49
|
-
function taskLockHost(): string | null {
|
|
50
|
-
try {
|
|
51
|
-
const hostname = os.hostname().trim();
|
|
52
|
-
if (process.platform === "linux") { const boot = readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim(), namespace = readlinkSync("/proc/self/ns/pid"); return UUID.test(boot) && /^pid:\[\d+\]$/.test(namespace) ? `linux:${hostname}:${boot}:${namespace}` : null; }
|
|
53
|
-
if (process.platform === "darwin") { const boot = execFileSync("/usr/sbin/sysctl", ["-n", "kern.bootsessionuuid"], { encoding: "utf8", timeout: 1000, maxBuffer: 4096, stdio: ["ignore", "pipe", "pipe"] }).trim(); return UUID.test(boot) ? `darwin:${hostname}:${boot.toLowerCase()}` : null; }
|
|
54
|
-
return hostname ? `${process.platform}:${hostname}` : null;
|
|
55
|
-
} catch { return null; }
|
|
56
|
-
}
|
|
57
|
-
function errorCode(error: unknown): string | undefined {
|
|
58
|
-
return typeof error === "object" && error !== null && "code" in error && typeof (error as { code?: unknown }).code === "string" ? (error as { code: string }).code : undefined;
|
|
59
|
-
}
|
|
60
|
-
function lockBusy(path: string, reason: string): Error { return new Error(`Task reconciliation lock is busy or ambiguous at ${path}: ${reason}`); }
|
|
61
|
-
function validTaskLockOwner(value: unknown, taskId: string): value is TaskLockOwner {
|
|
62
|
-
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
63
|
-
const owner = value as Partial<TaskLockOwner>;
|
|
64
|
-
return Object.keys(value).sort().join(",") === "host,pid,schema,taskId,token" && owner.schema === TASK_LOCK_SCHEMA && owner.taskId === taskId && typeof owner.token === "string" && UUID.test(owner.token) && Number.isSafeInteger(owner.pid) && owner.pid > 0 && (owner.host === null || typeof owner.host === "string" && owner.host.length > 0 && !owner.host.includes("\0"));
|
|
65
|
-
}
|
|
66
|
-
function ownerAt(path: string, taskId: string, token: string): TaskLockOwner {
|
|
67
|
-
if (!path.endsWith(`${taskId}.reconcile.${token}`)) throw lockBusy(path, "candidate filename is malformed");
|
|
68
|
-
const stat = lstatSync(path);
|
|
69
|
-
if (!stat.isFile() || stat.isSymbolicLink()) throw lockBusy(path, "candidate is not an expected regular file");
|
|
70
|
-
try {
|
|
71
|
-
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
|
|
72
|
-
if (!validTaskLockOwner(parsed, taskId) || parsed.token !== token) throw new Error("filename and owner metadata disagree");
|
|
73
|
-
return parsed;
|
|
74
|
-
} catch (error) { throw lockBusy(path, `candidate owner metadata is malformed${error instanceof Error ? `: ${error.message}` : ""}`); }
|
|
75
|
-
}
|
|
76
|
-
function ownerDead(owner: TaskLockOwner): boolean {
|
|
77
|
-
const host = taskLockHost();
|
|
78
|
-
if (!owner.host || !host || owner.host !== host || owner.pid === process.pid) return false;
|
|
79
|
-
try { process.kill(owner.pid, 0); return false; } catch (error) { return errorCode(error) === "ESRCH"; }
|
|
80
|
-
}
|
|
81
|
-
function syncTaskLock(path: string, directory = false): void {
|
|
82
|
-
if (directory && process.platform === "win32") return;
|
|
83
|
-
const descriptor = openSync(path, directory ? "r" : "r+");
|
|
84
|
-
try { fsyncSync(descriptor); } finally { closeSync(descriptor); }
|
|
85
|
-
}
|
|
86
|
-
function publishExclusive(path: string, owner: TaskLockOwner): void {
|
|
87
|
-
const temporary = `${path}.tmp`;
|
|
88
|
-
try { writeFileSync(temporary, JSON.stringify(owner), { encoding: "utf8", flag: "wx", mode: 0o600 }); syncTaskLock(temporary); linkSync(temporary, path); syncTaskLock(dirname(path), true); }
|
|
89
|
-
finally { try { unlinkSync(temporary); } catch {} }
|
|
90
|
-
}
|
|
91
|
-
function scanTaskCandidates(dir: string, id: string, ownPath: string): void {
|
|
92
|
-
const prefix = `${id}.reconcile.`;
|
|
93
|
-
for (const name of readdirSync(dir)) {
|
|
94
|
-
if (!name.startsWith(prefix) || name.endsWith(".tmp")) continue;
|
|
95
|
-
const path = join(dir, name), token = name.slice(prefix.length);
|
|
96
|
-
if (!UUID.test(token)) throw lockBusy(path, "candidate filename is malformed");
|
|
97
|
-
let owner: TaskLockOwner;
|
|
98
|
-
try { owner = ownerAt(path, id, token); } catch (error) { if (errorCode(error) === "ENOENT") throw lockBusy(path, "candidate disappeared during election"); throw error; }
|
|
99
|
-
if (path === ownPath) continue;
|
|
100
|
-
if (ownerDead(owner)) { try { unlinkSync(path); } catch (error) { if (errorCode(error) !== "ENOENT") throw error; } continue; }
|
|
101
|
-
throw lockBusy(path, "owner is live, foreign, or its death is inconclusive");
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
function releaseTaskLock(path: string, owner: TaskLockOwner): void {
|
|
105
|
-
let current: TaskLockOwner;
|
|
106
|
-
try { current = ownerAt(path, owner.taskId, owner.token); } catch (error) { if (errorCode(error) === "ENOENT") return; throw error; }
|
|
107
|
-
if (current.pid !== owner.pid || current.host !== owner.host) throw new Error("Task reconciliation lock owner token does not match");
|
|
108
|
-
unlinkSync(path); syncTaskLock(dirname(path), true);
|
|
109
|
-
}
|
|
110
|
-
export function acquireTaskLock(dir: string, id: string): TaskLock {
|
|
111
|
-
if (!SAFE_ID.test(id)) throw new Error(`invalid task id: ${id}`);
|
|
112
|
-
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
113
|
-
const owner: TaskLockOwner = { schema: TASK_LOCK_SCHEMA, taskId: id, token: randomUUID(), pid: process.pid, host: taskLockHost() };
|
|
114
|
-
const path = join(dir, `${id}.reconcile.${owner.token}`);
|
|
115
|
-
publishExclusive(path, owner);
|
|
116
|
-
try { scanTaskCandidates(dir, id, path); }
|
|
117
|
-
catch (error) { try { releaseTaskLock(path, owner); } catch {} throw error; }
|
|
118
|
-
let released = false;
|
|
119
|
-
return { path, taskId: id, token: owner.token, release() { if (!released) { releaseTaskLock(path, owner); released = true; } } };
|
|
120
|
-
}
|
|
121
|
-
|
|
122
27
|
function isRecord(value: unknown): value is TaskRecord {
|
|
123
28
|
const task = value as Partial<TaskRecord> | undefined;
|
|
124
29
|
return typeof task?.id === "string" && typeof task.agent === "string" && typeof task.status === "string" && typeof task.createdAt === "number";
|
|
@@ -169,7 +74,8 @@ export async function loadHistory(dir: string): Promise<StoredTask[]> {
|
|
|
169
74
|
// Keep the newest `maxTasks` files; the rest go. Returns how many were removed.
|
|
170
75
|
export async function pruneHistory(dir: string, maxTasks: number): Promise<number> {
|
|
171
76
|
const stored = await loadHistory(dir);
|
|
172
|
-
|
|
77
|
+
// Preserve historical remediation payloads without interpreting or replaying their retired ledger.
|
|
78
|
+
const extra = stored.filter(({ task }) => task.sddRemediation === undefined).slice(Math.max(0, maxTasks));
|
|
173
79
|
await Promise.all(extra.map((entry) => rm(fileFor(dir, entry.task.id), { force: true })));
|
|
174
80
|
return extra.length;
|
|
175
81
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Shortcut helpers for the Gentle Agents view. Pure (no Pi API), so both
|
|
2
|
+
// extensions/gentle-agents.ts (which owns the view) and other extensions
|
|
3
|
+
// that only need the key mapping (extensions/gentle-shell.ts, for the
|
|
4
|
+
// command palette's shortcut hints) can depend on it without importing one
|
|
5
|
+
// another.
|
|
6
|
+
|
|
7
|
+
const COLLAPSE_KEY_DEFAULT = "ctrl+shift+a";
|
|
8
|
+
const VIEW_KEY_DEFAULT = "alt+a";
|
|
9
|
+
const STOP_KEY_DEFAULT = "alt+s";
|
|
10
|
+
|
|
11
|
+
export function agentsViewKey(env: NodeJS.ProcessEnv = process.env): string | undefined {
|
|
12
|
+
const value = env.GENTLE_PI_AGENTS_VIEW_KEY?.trim();
|
|
13
|
+
if (value === undefined) return VIEW_KEY_DEFAULT;
|
|
14
|
+
return value === "" || value.toLowerCase() === "off" ? undefined : value;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function agentsCollapseKey(env: NodeJS.ProcessEnv = process.env): string | undefined {
|
|
18
|
+
const value = env.GENTLE_PI_AGENTS_KEY?.trim();
|
|
19
|
+
if (value === undefined) return COLLAPSE_KEY_DEFAULT;
|
|
20
|
+
return value === "" || value.toLowerCase() === "off" ? undefined : value;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function agentsStopKey(env: NodeJS.ProcessEnv = process.env): string | undefined {
|
|
24
|
+
const value = env.GENTLE_PI_AGENTS_STOP_KEY?.trim();
|
|
25
|
+
if (value === undefined) return STOP_KEY_DEFAULT;
|
|
26
|
+
return value === "" || value.toLowerCase() === "off" ? undefined : value;
|
|
27
|
+
}
|
package/lib/agents-protocol.ts
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import type { RemediationObservations, RemediationScope } from "./agents-runner.ts";
|
|
2
|
-
import type { NativeSddAcquireRequest, NativeSddSettleRequest, NativeSddAttemptResult } from "./native-review-cli.ts";
|
|
3
1
|
import { sanitizeTerminalText } from "./terminal-theme.ts";
|
|
4
2
|
|
|
5
3
|
// Gentle Agents protocol. A child pi process streams RPC events; the host
|
|
@@ -110,20 +108,9 @@ export interface TaskThread {
|
|
|
110
108
|
limits: ThreadLimits;
|
|
111
109
|
}
|
|
112
110
|
|
|
113
|
-
export interface RemediationTaskState extends RemediationObservations {
|
|
114
|
-
scope?: RemediationScope;
|
|
115
|
-
acquire: NativeSddAcquireRequest;
|
|
116
|
-
token?: string;
|
|
117
|
-
acquireResult?: NativeSddAttemptResult;
|
|
118
|
-
acquireUncertain?: boolean;
|
|
119
|
-
actorClaimed?: boolean;
|
|
120
|
-
settle?: NativeSddSettleRequest;
|
|
121
|
-
settlement?: NativeSddAttemptResult;
|
|
122
|
-
settlementUncertain?: boolean;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
111
|
export interface TaskRecord {
|
|
126
|
-
|
|
112
|
+
/** Retained legacy payload, never interpreted or replayed as launch authority. */
|
|
113
|
+
sddRemediation?: unknown;
|
|
127
114
|
/** Exact runtime-generated SDD preflight block retained only for continuation transport. */
|
|
128
115
|
sddPreflightContext?: string;
|
|
129
116
|
id: string;
|