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.
Files changed (122) hide show
  1. package/README.md +24 -6
  2. package/assets/agents/gentle-ai-worker.md +5 -1
  3. package/assets/agents/sdd-apply.md +9 -7
  4. package/assets/agents/sdd-archive.md +42 -23
  5. package/assets/agents/sdd-proposal.md +2 -2
  6. package/assets/agents/sdd-remediate.md +4 -4
  7. package/assets/agents/sdd-research.md +20 -48
  8. package/assets/agents/sdd-tasks.md +5 -5
  9. package/assets/agents/sdd-verify.md +6 -28
  10. package/assets/chains/sdd-full.chain.md +4 -22
  11. package/assets/chains/sdd-verify.chain.md +3 -12
  12. package/assets/orchestrator-delegation.md +33 -3
  13. package/assets/orchestrator-memory.md +20 -7
  14. package/assets/orchestrator.md +5 -3
  15. package/assets/sdd-orchestrator-workflow.md +25 -58
  16. package/assets/support/sdd-status-contract.md +9 -12
  17. package/docs/gentle-shell.md +14 -4
  18. package/docs/readme-reference.md +171 -32
  19. package/extensions/codegraph-tools.ts +2 -0
  20. package/extensions/gentle-agents.ts +281 -361
  21. package/extensions/gentle-ai.ts +588 -117
  22. package/extensions/gentle-shell.ts +74 -30
  23. package/extensions/pi-pretty.ts +63 -14
  24. package/extensions/quiet-tools.ts +1 -2
  25. package/extensions/startup-banner.ts +10 -9
  26. package/lib/agent-home.ts +8 -0
  27. package/lib/agent-profile-pin.ts +336 -0
  28. package/lib/agent-profiles.ts +28 -8
  29. package/lib/agents-config.ts +24 -2
  30. package/lib/agents-history.ts +3 -97
  31. package/lib/agents-keys.ts +27 -0
  32. package/lib/agents-protocol.ts +2 -15
  33. package/lib/agents-runner.ts +67 -111
  34. package/lib/agents-session-transport.ts +691 -0
  35. package/lib/command-palette-catalog.ts +87 -0
  36. package/lib/command-palette.ts +346 -0
  37. package/lib/native-choice-list.ts +5 -0
  38. package/lib/native-review-cli.ts +10 -97
  39. package/lib/review-publication-gate.ts +11 -1
  40. package/lib/review-repository.ts +1 -1
  41. package/lib/review-snapshot.ts +1 -0
  42. package/lib/review-transaction.ts +4 -2
  43. package/lib/sdd-preflight.ts +2 -1
  44. package/lib/sdd-research-capabilities.ts +18 -152
  45. package/lib/sdd-status.ts +7 -779
  46. package/lib/session-change-capture.ts +2 -1
  47. package/lib/session-changes.ts +8 -1
  48. package/lib/shell-bar.ts +21 -12
  49. package/lib/shell-card.ts +8 -12
  50. package/lib/shell-changes.ts +3 -2
  51. package/lib/shell-prompt.ts +25 -8
  52. package/lib/shell-sidebar-banner.ts +2 -2
  53. package/lib/shell-sidebar-layout.ts +5 -2
  54. package/lib/windows-session-transport.ts +877 -0
  55. package/package.json +3 -3
  56. package/runtime/native-review-cli.mjs +9 -96
  57. package/runtime/windows-session-transport.ps1 +791 -0
  58. package/scripts/test-packed-runner.mjs +1668 -20
  59. package/scripts/verify-package-files.mjs +0 -1
  60. package/tests/agent-home.test.ts +52 -0
  61. package/tests/agent-profiles.test.ts +30 -1
  62. package/tests/agents-config.test.ts +44 -0
  63. package/tests/agents-history.test.ts +12 -24
  64. package/tests/agents-runner.test.ts +307 -58
  65. package/tests/agents-session-transport-process.test.ts +249 -0
  66. package/tests/agents-session-transport.test.ts +823 -0
  67. package/tests/artifact-language.test.ts +10 -7
  68. package/tests/command-palette.test.ts +378 -0
  69. package/tests/delegated-key-learnings-contract.test.ts +2 -2
  70. package/tests/fixtures/agents-session-transport-process.mjs +108 -0
  71. package/tests/fixtures/legacy/sdd-research-v2.5.0.md +54 -0
  72. package/tests/fixtures/windows-session-bootstrap.ps1 +129 -0
  73. package/tests/fixtures/windows-session-compile.ps1 +110 -0
  74. package/tests/gentle-agents.test.ts +849 -356
  75. package/tests/gentle-ai.test.ts +472 -4
  76. package/tests/gentle-shell.test.ts +201 -8
  77. package/tests/native-choice-list.test.ts +13 -0
  78. package/tests/native-review-cli.test.ts +0 -33
  79. package/tests/odd-routing-contract.test.ts +208 -0
  80. package/tests/orchestrator-budget.test.ts +17 -2
  81. package/tests/package-manifest.test.ts +115 -27
  82. package/tests/persona-single-channel.test.ts +3 -3
  83. package/tests/pi-pretty.test.ts +45 -0
  84. package/tests/profile-pin.test.ts +370 -0
  85. package/tests/quiet-tool-rendering.test.ts +32 -5
  86. package/tests/review-contract-prompt.test.ts +9 -0
  87. package/tests/review-controller.test.ts +0 -44
  88. package/tests/review-session-standing-permission-ipc.test.ts +427 -13
  89. package/tests/runtime-harness.mjs +4 -4
  90. package/tests/sdd-agent-tools.test.ts +15 -36
  91. package/tests/sdd-archive-replay.test.ts +82 -0
  92. package/tests/sdd-classical-continuation.test.ts +74 -0
  93. package/tests/sdd-execution-routing-contract.test.ts +18 -2
  94. package/tests/sdd-managed-runtime-settlement.test.ts +42 -330
  95. package/tests/sdd-native-managed-uptake.test.ts +11 -21
  96. package/tests/sdd-no-attempts-contract.test.ts +15 -0
  97. package/tests/sdd-odd-integration.test.ts +33 -0
  98. package/tests/sdd-optional-research.test.ts +124 -0
  99. package/tests/sdd-planning-routing-contract.test.ts +1 -1
  100. package/tests/sdd-preflight-rpc-input.test.ts +125 -0
  101. package/tests/sdd-preflight.test.ts +1 -1
  102. package/tests/sdd-research-capabilities.test.ts +20 -162
  103. package/tests/sdd-selection-transport.test.ts +180 -88
  104. package/tests/sdd-status.test.ts +5 -778
  105. package/tests/sdd-task-truth.test.ts +43 -0
  106. package/tests/session-change-capture.test.ts +20 -2
  107. package/tests/session-changes.test.ts +11 -0
  108. package/tests/shell-bar.test.ts +21 -0
  109. package/tests/shell-card.test.ts +8 -6
  110. package/tests/shell-changes.test.ts +8 -0
  111. package/tests/shell-prompt.test.ts +41 -7
  112. package/tests/shell-sidebar-banner.test.ts +4 -4
  113. package/tests/shell-sidebar-layout.test.ts +97 -13
  114. package/tests/startup-banner.test.ts +55 -2
  115. package/tests/windows-hidden-processes.test.ts +303 -0
  116. package/tests/windows-session-bootstrap.test.ts +1772 -0
  117. package/tests/windows-session-compile.test.ts +170 -0
  118. package/tests/windows-session-transport.test.ts +754 -0
  119. package/assets/agents/sdd-sync.md +0 -146
  120. package/lib/openspec-guardrails.ts +0 -99
  121. package/tests/native-sdd-attempt-authority.test.ts +0 -240
  122. 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
+ }
@@ -406,12 +406,18 @@ export interface ProfileListItem {
406
406
  description: string;
407
407
  }
408
408
 
409
- export function buildProfileListItems(file: AgentProfilesFile): ProfileListItem[] {
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
- label: name === file.active ? `${name} (active)` : name,
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 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.
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 writeProfilesFileSync(path: string, file: AgentProfilesFile): void {
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, serializeProfilesFile(file));
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
+ }
@@ -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: positiveInteger(merged.stall_timeout_ms, DEFAULT_STALL_TIMEOUT_MS),
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
  }
@@ -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 os from "node:os";
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
- const extra = stored.filter(({ task }) => !remediationUnresolved(task)).slice(Math.max(0, maxTasks));
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
+ }
@@ -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
- sddRemediation?: RemediationTaskState;
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;