gentle-pi 2.3.0 → 2.5.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 (153) hide show
  1. package/README.md +195 -11
  2. package/assets/agents/gentle-ai-worker.md +9 -0
  3. package/assets/agents/sdd-explore.md +1 -0
  4. package/assets/orchestrator-delegation.md +21 -10
  5. package/assets/orchestrator.md +8 -12
  6. package/contracts/review-provider-contract-mirror/provider-contract.lock.json +8 -7
  7. package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/README.md +10 -0
  8. package/contracts/review-provider-contract-mirror/v1.2.0/bundle/manifest.json +74 -0
  9. package/contracts/review-provider-contract-mirror/v1.2.0/bundle/orchestration/pi.md +53 -0
  10. package/contracts/review-provider-contract-mirror/v1.2.0/bundle/schemas/targeted-validator.schema.json +1 -0
  11. package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/generated/provider-capabilities.baseline.json +9 -2
  12. package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/generated/provider-roles.baseline.json +2 -2
  13. package/docs/delegated-verification.md +25 -0
  14. package/docs/review-integration.md +1 -1
  15. package/docs/telemetry.md +38 -0
  16. package/extensions/ask-user-choice.ts +26 -20
  17. package/extensions/codegraph-tools.ts +94 -5
  18. package/extensions/gentle-agents.ts +588 -0
  19. package/extensions/gentle-ai.ts +1421 -143
  20. package/extensions/gentle-shell.ts +547 -0
  21. package/extensions/gentle-todo.ts +199 -0
  22. package/extensions/quiet-tools.ts +1 -1
  23. package/lib/agent-home.ts +8 -0
  24. package/lib/agents-config.ts +318 -0
  25. package/lib/agents-history.ts +80 -0
  26. package/lib/agents-protocol.ts +429 -0
  27. package/lib/agents-runner.ts +490 -0
  28. package/lib/agents-transcript.ts +87 -0
  29. package/lib/agents-view.ts +557 -0
  30. package/lib/agents-widget.ts +222 -0
  31. package/lib/gentle-ai-renderer.ts +142 -26
  32. package/lib/native-choice-list.ts +194 -0
  33. package/lib/native-fullscreen-interaction.ts +47 -0
  34. package/lib/native-pointer-region.ts +164 -0
  35. package/lib/native-review-cli.ts +103 -12
  36. package/lib/provider-contract-bundle.ts +88 -6
  37. package/lib/review-candidate-view-owner.ts +177 -0
  38. package/lib/review-candidate-view.ts +127 -35
  39. package/lib/review-consent-ui.ts +65 -0
  40. package/lib/review-host-relay.ts +146 -60
  41. package/lib/review-integration-v2.ts +92 -13
  42. package/lib/review-last-event-controller.ts +1 -0
  43. package/lib/review-relay-contract.ts +11 -0
  44. package/lib/review-repository.ts +2 -2
  45. package/lib/review-risk-assessment.ts +339 -0
  46. package/lib/review-session-standing-permission-ipc.ts +309 -0
  47. package/lib/review-session-standing-permission.ts +219 -0
  48. package/lib/sdd-preflight.ts +2 -2
  49. package/lib/shell-bar.ts +138 -0
  50. package/lib/shell-card.ts +136 -0
  51. package/lib/shell-changes-view.ts +205 -0
  52. package/lib/shell-changes.ts +210 -0
  53. package/lib/shell-gauge.ts +40 -0
  54. package/lib/shell-prompt.ts +119 -0
  55. package/lib/shell-todo.ts +280 -0
  56. package/lib/shell-usage-view.ts +76 -0
  57. package/lib/shell-usage.ts +246 -0
  58. package/lib/telemetry-trigger.ts +151 -0
  59. package/package.json +4 -4
  60. package/runtime/native-review-cli.mjs +102 -11
  61. package/runtime/review-integration-v2.mjs +92 -13
  62. package/runtime/review-relay-contract.mjs +11 -0
  63. package/runtime/review-risk-assessment.mjs +340 -0
  64. package/runtime/telemetry-trigger.mjs +152 -0
  65. package/scripts/build-runtime-modules.mjs +2 -0
  66. package/scripts/gentle-ai-installer.mjs +10 -10
  67. package/scripts/test-packed-runner.mjs +22 -0
  68. package/scripts/verify-package-files.mjs +18 -13
  69. package/skills/_shared/review-ledger-contract.md +9 -1
  70. package/skills/issue-creation/SKILL.md +53 -93
  71. package/tests/agents-config.test.ts +143 -0
  72. package/tests/agents-fake-child.ts +52 -0
  73. package/tests/agents-history.test.ts +54 -0
  74. package/tests/agents-protocol.test.ts +153 -0
  75. package/tests/agents-runner-process.test.ts +111 -0
  76. package/tests/agents-runner.test.ts +402 -0
  77. package/tests/agents-transcript.test.ts +30 -0
  78. package/tests/agents-view.test.ts +274 -0
  79. package/tests/agents-widget.test.ts +111 -0
  80. package/tests/ask-user-choice.test.ts +157 -3
  81. package/tests/codegraph-tools.test.ts +110 -1
  82. package/tests/devbinary/native-review-parity.devtest.ts +108 -0
  83. package/tests/fixtures/agents-process-child.mjs +23 -0
  84. package/tests/fixtures/provider-contract-bundle/v1.2.0/README.md +22 -0
  85. package/{contracts/review-provider-contract-mirror/v1.1.0/bundle → tests/fixtures/provider-contract-bundle/v1.2.0}/manifest.json +11 -2
  86. package/tests/fixtures/provider-contract-bundle/v1.2.0/orchestration/pi.md +97 -0
  87. package/tests/fixtures/provider-contract-bundle/v1.2.0/schemas/lens.schema.json +16 -0
  88. package/tests/fixtures/provider-contract-bundle/v1.2.0/schemas/refuter.schema.json +1 -0
  89. package/tests/fixtures/provider-contract-bundle/v1.2.0/vectors/lens.json +1 -0
  90. package/tests/fixtures/provider-contract-bundle/v1.2.0/vectors/refuter.json +1 -0
  91. package/tests/fixtures/provider-contract-bundle/v1.2.0/vectors/targeted-validator.json +1 -0
  92. package/tests/gentle-agents.test.ts +741 -0
  93. package/tests/gentle-ai-binary.test.ts +1 -1
  94. package/tests/gentle-ai-installer.test.ts +47 -47
  95. package/tests/gentle-ai-renderer.test.ts +65 -0
  96. package/tests/gentle-ai.test.ts +31 -14
  97. package/tests/gentle-card-text.ts +35 -0
  98. package/tests/gentle-shell.test.ts +527 -0
  99. package/tests/gentle-todo.test.ts +182 -0
  100. package/tests/issue-creation-skill.test.ts +103 -0
  101. package/tests/native-choice-list.test.ts +202 -0
  102. package/tests/native-fullscreen-interaction.test.ts +125 -0
  103. package/tests/native-pointer-region.test.ts +245 -0
  104. package/tests/native-review-capability-contract.test.ts +33 -1
  105. package/tests/native-review-cli.test.ts +40 -0
  106. package/tests/native-review-consent.test.ts +91 -0
  107. package/tests/native-review-parity-runtime.test.ts +8 -2
  108. package/tests/native-review-parity.test.ts +29 -22
  109. package/tests/orchestrator-budget.test.ts +71 -2
  110. package/tests/orchestrator-rdd-ownership.test.ts +10 -1
  111. package/tests/package-manifest.test.ts +134 -9
  112. package/tests/provider-contract-bundle.test.ts +76 -0
  113. package/tests/provider-contract-mirror.test.ts +19 -0
  114. package/tests/quiet-tool-rendering.test.ts +96 -37
  115. package/tests/rdd-aware-verification-contract.test.ts +216 -0
  116. package/tests/rdd-status-line.test.ts +286 -0
  117. package/tests/review-agent-end-preflight.test.ts +408 -0
  118. package/tests/review-candidate-view.test.ts +452 -6
  119. package/tests/review-contract-prompt.test.ts +142 -0
  120. package/tests/review-controller-native-recovery.test.ts +29 -4
  121. package/tests/review-controller-native-routing.test.ts +321 -4
  122. package/tests/review-controller-workspace-root.test.ts +45 -2
  123. package/tests/review-controller.test.ts +26 -1
  124. package/tests/review-host-relay-routing.test.ts +229 -11
  125. package/tests/review-host-relay.test.ts +195 -7
  126. package/tests/review-integration-v2-forward.test.ts +47 -0
  127. package/tests/review-integration-v2.test.ts +112 -0
  128. package/tests/review-last-event-closure.test.ts +7 -2
  129. package/tests/review-ledger-contract.test.ts +1 -1
  130. package/tests/review-relay-contract.test.ts +26 -0
  131. package/tests/review-repository.test.ts +28 -1
  132. package/tests/review-risk-assessment.test.ts +626 -0
  133. package/tests/review-session-standing-permission-controller.test.ts +608 -0
  134. package/tests/review-session-standing-permission-ipc.test.ts +233 -0
  135. package/tests/review-session-standing-permission-runtime.test.ts +212 -0
  136. package/tests/review-session-standing-permission.test.ts +126 -0
  137. package/tests/runtime-harness.mjs +1 -0
  138. package/tests/shell-bar.test.ts +176 -0
  139. package/tests/shell-card.test.ts +118 -0
  140. package/tests/shell-changes-view.test.ts +146 -0
  141. package/tests/shell-changes.test.ts +182 -0
  142. package/tests/shell-prompt.test.ts +118 -0
  143. package/tests/shell-todo.test.ts +170 -0
  144. package/tests/shell-usage-view.test.ts +62 -0
  145. package/tests/shell-usage.test.ts +197 -0
  146. package/tests/telemetry-trigger.test.ts +349 -0
  147. package/tests/writer-edit-surface-scope.test.ts +153 -17
  148. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/schemas/lens.schema.json +0 -0
  149. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/schemas/refuter.schema.json +0 -0
  150. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/vectors/lens.json +0 -0
  151. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/vectors/refuter.json +0 -0
  152. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/vectors/targeted-validator.json +0 -0
  153. /package/{contracts/review-provider-contract-mirror/v1.1.0/bundle → tests/fixtures/provider-contract-bundle/v1.2.0}/schemas/targeted-validator.schema.json +0 -0
@@ -0,0 +1,199 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { Text } from "@earendil-works/pi-tui";
3
+ import {
4
+ applyTodo,
5
+ emptyTodo,
6
+ renderTodoCard,
7
+ replayTodo,
8
+ staleTurns,
9
+ TODO_DETAILS_KEY,
10
+ TODO_GLYPH,
11
+ TODO_TOOL_NAME,
12
+ todoPromptBlock,
13
+ todoSummary,
14
+ type TodoParams,
15
+ type TodoState,
16
+ } from "../lib/shell-todo.ts";
17
+
18
+ // Gentle Todo: the task list the model keeps while it works, drawn as a
19
+ // Gentle Shell card above the editor. Three things keep it current that a
20
+ // static tool description cannot: `write` replaces the whole list in one
21
+ // call, every turn's system prompt carries the open tasks and the rules, and
22
+ // a list that goes untouched while tasks stay open is marked stale for both
23
+ // the human and the model.
24
+
25
+ const WIDGET_KEY = "gentle-todo";
26
+ const COLLAPSE_KEY_DEFAULT = "ctrl+shift+t";
27
+ const TOOL_PARAMETERS = {
28
+ type: "object",
29
+ additionalProperties: false,
30
+ required: ["action"],
31
+ properties: {
32
+ action: { type: "string", enum: ["write", "add", "update", "clear", "list"], description: "write replaces the whole list; add appends one task; update changes one task by id; clear empties the list; list reports it." },
33
+ tasks: {
34
+ type: "array",
35
+ description: "For write: the complete ordered list. Keep the id of tasks that already exist so their history survives; omit it for new ones.",
36
+ items: {
37
+ type: "object",
38
+ additionalProperties: false,
39
+ required: ["title"],
40
+ properties: {
41
+ id: { type: "integer", description: "Existing task id to keep." },
42
+ title: { type: "string", description: "Short imperative title, e.g. 'Write the parser'." },
43
+ status: { type: "string", enum: ["pending", "in_progress", "done"], description: "Defaults to pending." },
44
+ note: { type: "string", description: "What is happening right now, shown while in_progress, e.g. 'writing tests'." },
45
+ },
46
+ },
47
+ },
48
+ id: { type: "integer", description: "Task id for update." },
49
+ title: { type: "string", description: "Title for add, or a new title for update." },
50
+ status: { type: "string", enum: ["pending", "in_progress", "done"], description: "Status for add or update." },
51
+ note: { type: "string", description: "Note for add or update." },
52
+ },
53
+ } as const;
54
+
55
+ export function todoEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
56
+ if (env.GENTLE_PI_AGENTS_CHILD === "1") return false;
57
+ const value = env.GENTLE_PI_TODO?.trim().toLowerCase();
58
+ return !(value === "0" || value === "false" || value === "off");
59
+ }
60
+
61
+ export function todoCollapseKey(env: NodeJS.ProcessEnv = process.env): string | undefined {
62
+ const value = env.GENTLE_PI_TODO_KEY?.trim();
63
+ if (value === undefined) return COLLAPSE_KEY_DEFAULT;
64
+ return value === "" || value.toLowerCase() === "off" ? undefined : value;
65
+ }
66
+
67
+ interface TodoSession {
68
+ state: TodoState;
69
+ turn: number;
70
+ collapsed: boolean;
71
+ /** A finished list stays on screen for the turn it finished in, then clears. */
72
+ clearOnNextTurn: boolean;
73
+ ui: ExtensionContext["ui"] | undefined;
74
+ host: { requestRender(): void } | undefined;
75
+ }
76
+
77
+ function sessionKey(ctx: ExtensionContext): string {
78
+ return ctx.sessionManager.getSessionId() ?? "";
79
+ }
80
+
81
+ export default function gentleTodo(pi: ExtensionAPI, env: NodeJS.ProcessEnv = process.env): void {
82
+ if (!todoEnabled(env)) return;
83
+ const sessions = new Map<string, TodoSession>();
84
+ const collapseKey = todoCollapseKey(env);
85
+
86
+ const session = (ctx: ExtensionContext): TodoSession => {
87
+ const key = sessionKey(ctx);
88
+ let current = sessions.get(key);
89
+ if (!current) {
90
+ current = { state: emptyTodo(), turn: 0, collapsed: false, clearOnNextTurn: false, ui: undefined, host: undefined };
91
+ sessions.set(key, current);
92
+ }
93
+ return current;
94
+ };
95
+
96
+ const show = (current: TodoSession) => {
97
+ if (!current.ui) return;
98
+ if (current.state.tasks.length === 0) {
99
+ current.ui.setWidget(WIDGET_KEY, undefined);
100
+ return;
101
+ }
102
+ const snapshot = current;
103
+ current.ui.setWidget(WIDGET_KEY, (tui, theme) => {
104
+ snapshot.host = tui;
105
+ return {
106
+ render(width: number) {
107
+ const lines = renderTodoCard(snapshot.state, theme, width, { collapsed: snapshot.collapsed, staleTurns: staleTurns(snapshot.state, snapshot.turn), collapseKey });
108
+ return lines.length === 0 ? [] : [...lines, ""];
109
+ },
110
+ invalidate() {},
111
+ };
112
+ });
113
+ };
114
+
115
+ pi.registerTool({
116
+ name: TODO_TOOL_NAME,
117
+ label: "Todo",
118
+ description: "Plan and track multi-step work. Use write to set the whole list, update to move one task, add for a new one, clear to reset, list to read it back.",
119
+ promptSnippet: "Track multi-step work; rewrite the whole list as the plan changes",
120
+ promptGuidelines: [
121
+ "Use todo for work with three or more steps or when the user hands you a list. Skip it for single trivial requests.",
122
+ "Mark a task in_progress before starting it and done right after finishing it; keep exactly one task in_progress.",
123
+ "Prefer write with the complete list whenever the plan changes; keep ids of tasks that already exist.",
124
+ "Never mark a task done while tests fail or the work is partial; add a task for the blocker instead.",
125
+ ],
126
+ parameters: TOOL_PARAMETERS,
127
+ executionMode: "sequential",
128
+ renderCall(args, theme) {
129
+ const params = args as TodoParams;
130
+ return new Text(theme.fg("toolTitle", `${TODO_GLYPH} todo · ${params.action}`), 0, 0);
131
+ },
132
+ renderResult(result, options, theme) {
133
+ const text = result.content.map((part) => (part.type === "text" ? part.text : "")).join("\n");
134
+ return new Text(options.expanded ? text : theme.fg("muted", text.split("\n")[0] ?? ""), 0, 0);
135
+ },
136
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
137
+ const current = session(ctx);
138
+ const result = applyTodo(current.state, params as TodoParams, current.turn);
139
+ if (!result.error) {
140
+ current.state = result.state;
141
+ current.clearOnNextTurn = false;
142
+ }
143
+ return {
144
+ content: [{ type: "text", text: result.text }],
145
+ details: { [TODO_DETAILS_KEY]: result.state, ...(result.error ? { error: result.error } : {}) },
146
+ };
147
+ },
148
+ });
149
+
150
+ if (collapseKey) {
151
+ pi.registerShortcut(collapseKey as Parameters<ExtensionAPI["registerShortcut"]>[0], {
152
+ description: "Collapse or expand the todo list",
153
+ handler: async (ctx) => {
154
+ const current = session(ctx);
155
+ current.collapsed = !current.collapsed;
156
+ current.host?.requestRender();
157
+ },
158
+ });
159
+ }
160
+
161
+ pi.on("session_start", (_event, ctx) => {
162
+ const current = session(ctx);
163
+ // A list that was already finished when the session was left is history,
164
+ // not work: it would otherwise sit on screen until two more turns pass.
165
+ const replayed = replayTodo(ctx.sessionManager.getBranch());
166
+ current.state = replayed.tasks.length > 0 && todoSummary(replayed).open === 0 ? { ...replayed, tasks: [] } : replayed;
167
+ current.turn = ctx.sessionManager.getBranch().filter((entry) => (entry as { type?: string }).type === "message" && (entry as { message?: { role?: string } }).message?.role === "user").length;
168
+ current.ui = ctx.hasUI ? ctx.ui : undefined;
169
+ show(current);
170
+ });
171
+
172
+ pi.on("session_shutdown", (_event, ctx) => {
173
+ sessions.delete(sessionKey(ctx));
174
+ });
175
+
176
+ pi.on("before_agent_start", (event, ctx) => {
177
+ const current = session(ctx);
178
+ current.turn += 1;
179
+ if (current.clearOnNextTurn) {
180
+ current.state = { ...current.state, tasks: [] };
181
+ current.clearOnNextTurn = false;
182
+ show(current);
183
+ }
184
+ const block = todoPromptBlock(current.state, staleTurns(current.state, current.turn));
185
+ if (!block) return undefined;
186
+ return { systemPrompt: `${event.systemPrompt}\n\n${block}` };
187
+ });
188
+
189
+ pi.on("tool_execution_end", (event, ctx) => {
190
+ if (event.toolName !== TODO_TOOL_NAME) return;
191
+ show(session(ctx));
192
+ });
193
+
194
+ pi.on("agent_end", (_event, ctx) => {
195
+ const current = session(ctx);
196
+ if (current.state.tasks.length > 0 && todoSummary(current.state).open === 0) current.clearOnNextTurn = true;
197
+ show(current);
198
+ });
199
+ }
@@ -653,7 +653,7 @@ function registerQuietTool(pi: ExtensionAPI, toolName: QuietToolName, commandArg
653
653
  { result: true, isPartial: options.isPartial },
654
654
  ).directResult;
655
655
  if (directResult) {
656
- return renderGentleAiResult(safeResult, { expanded: options.expanded });
656
+ return renderGentleAiResult(safeResult, { expanded: options.expanded, isPartial: options.isPartial, isError }, theme, renderContext as GentleAiRenderContext | undefined);
657
657
  }
658
658
  if (options.isPartial) {
659
659
  if (options.expanded) return new Text(`${theme.fg("warning", partialLabel(toolName, text))}\n${theme.fg("muted", text)}`, 0, 0);
@@ -0,0 +1,8 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+
4
+ // Pi Subagents resolves its global directory as `PI_CODING_AGENT_DIR || ~/.pi/agent`,
5
+ // so an empty value must fall through here too or the two homes diverge again.
6
+ export function resolveGentlePiAgentHome(env: NodeJS.ProcessEnv = process.env): string {
7
+ return env.GENTLE_PI_AGENT_HOME || env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent");
8
+ }
@@ -0,0 +1,318 @@
1
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
2
+ import { basename, join } from "node:path";
3
+
4
+ // Gentle Agents configuration. Agent definitions are markdown files with YAML
5
+ // frontmatter (the format gentle-ai installs) and runtime settings come from
6
+ // subagents.json at the global and project level. Everything here is pure
7
+ // apart from the discovery helpers, which take their roots as arguments.
8
+
9
+ export const AGENT_MODE = {
10
+ TASK: "task",
11
+ BACKGROUND: "background",
12
+ } as const;
13
+
14
+ export type AgentMode = (typeof AGENT_MODE)[keyof typeof AGENT_MODE];
15
+
16
+ export const THINKING_LEVEL = {
17
+ OFF: "off",
18
+ MINIMAL: "minimal",
19
+ LOW: "low",
20
+ MEDIUM: "medium",
21
+ HIGH: "high",
22
+ XHIGH: "xhigh",
23
+ } as const;
24
+
25
+ export type ThinkingLevel = (typeof THINKING_LEVEL)[keyof typeof THINKING_LEVEL];
26
+
27
+ export const AGENT_SCOPE = {
28
+ GLOBAL: "global",
29
+ PROJECT: "project",
30
+ } as const;
31
+
32
+ export type AgentScope = (typeof AGENT_SCOPE)[keyof typeof AGENT_SCOPE];
33
+
34
+ export const PROFILE_SOURCE = {
35
+ PROFILE: "profile",
36
+ DEFINITION: "definition",
37
+ DEFAULT: "default",
38
+ UNRESOLVED: "unresolved",
39
+ } as const;
40
+
41
+ export type ProfileSource = (typeof PROFILE_SOURCE)[keyof typeof PROFILE_SOURCE];
42
+
43
+ export interface ModelRef {
44
+ provider: string | undefined;
45
+ id: string;
46
+ }
47
+
48
+ export interface AgentDefinition {
49
+ name: string;
50
+ description: string;
51
+ filePath: string;
52
+ scope: AgentScope;
53
+ instructions: string;
54
+ model: ModelRef | undefined;
55
+ thinking: ThinkingLevel | undefined;
56
+ mode: AgentMode | undefined;
57
+ tools: string[];
58
+ }
59
+
60
+ export interface AgentDefinitionError {
61
+ filePath: string;
62
+ error: string;
63
+ }
64
+
65
+ export interface ModelProfile {
66
+ model: ModelRef | undefined;
67
+ thinking: ThinkingLevel | undefined;
68
+ }
69
+
70
+ export interface AgentsConfig {
71
+ defaultModel: ModelRef | undefined;
72
+ defaultThinking: ThinkingLevel | undefined;
73
+ defaultMode: AgentMode;
74
+ modelProfiles: Record<string, ModelProfile>;
75
+ stallTimeoutMs: number;
76
+ maxConcurrency: number;
77
+ historyMaxTasks: number;
78
+ }
79
+
80
+ export interface ProfileSources {
81
+ model: ProfileSource;
82
+ thinking: ProfileSource;
83
+ }
84
+
85
+ export interface ResolvedProfile {
86
+ model: ModelRef | undefined;
87
+ thinking: ThinkingLevel | undefined;
88
+ source: ProfileSources;
89
+ }
90
+
91
+ export interface DiscoveryRoots {
92
+ cwd: string;
93
+ home: string;
94
+ agentHome?: string;
95
+ }
96
+
97
+ export interface DiscoveryResult {
98
+ agents: AgentDefinition[];
99
+ errors: string[];
100
+ }
101
+
102
+ export type FrontmatterValue = string | string[];
103
+
104
+ export interface Frontmatter {
105
+ data: Record<string, FrontmatterValue>;
106
+ body: string;
107
+ }
108
+
109
+ const DEFAULT_STALL_TIMEOUT_MS = 4 * 60_000;
110
+ const DEFAULT_MAX_CONCURRENCY = 5;
111
+ const DEFAULT_HISTORY_MAX_TASKS = 200;
112
+ const THINKING_LEVELS = Object.values(THINKING_LEVEL) as string[];
113
+ const AGENT_MODES = Object.values(AGENT_MODE) as string[];
114
+
115
+ function unquote(value: string): string {
116
+ const trimmed = value.trim();
117
+ const quoted = (trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"));
118
+ return quoted && trimmed.length >= 2 ? trimmed.slice(1, -1) : trimmed;
119
+ }
120
+
121
+ function parseInlineList(value: string): string[] {
122
+ return value.slice(1, -1).split(",").map(unquote).filter((item) => item.length > 0);
123
+ }
124
+
125
+ // Just enough YAML for agent frontmatter: `key: scalar`, `key: [a, b]`, and
126
+ // `key:` followed by `- item` lines. Anything else stays a plain string.
127
+ export function parseFrontmatter(text: string): Frontmatter {
128
+ const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(text);
129
+ if (!match) return { data: {}, body: text.trim() };
130
+ const data: Record<string, FrontmatterValue> = {};
131
+ let listKey: string | undefined;
132
+ for (const raw of match[1].split(/\r?\n/)) {
133
+ const item = /^\s*-\s+(.*)$/.exec(raw);
134
+ if (item && listKey) {
135
+ (data[listKey] as string[]).push(unquote(item[1]));
136
+ continue;
137
+ }
138
+ const pair = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(raw);
139
+ if (!pair) continue;
140
+ const [, key, value] = pair;
141
+ const trimmed = value.trim();
142
+ if (trimmed.length === 0) {
143
+ data[key] = [];
144
+ listKey = key;
145
+ continue;
146
+ }
147
+ listKey = undefined;
148
+ data[key] = trimmed.startsWith("[") && trimmed.endsWith("]") ? parseInlineList(trimmed) : unquote(trimmed);
149
+ }
150
+ return { data, body: match[2].trim() };
151
+ }
152
+
153
+ export function parseModelRef(value: unknown): ModelRef | undefined {
154
+ if (typeof value !== "string") return undefined;
155
+ const trimmed = value.trim();
156
+ if (trimmed.length === 0) return undefined;
157
+ const slash = trimmed.indexOf("/");
158
+ if (slash <= 0) return { provider: undefined, id: trimmed };
159
+ return { provider: trimmed.slice(0, slash), id: trimmed.slice(slash + 1) };
160
+ }
161
+
162
+ function parseThinking(value: unknown): ThinkingLevel | undefined | string {
163
+ if (value === undefined) return undefined;
164
+ const normalized = String(value).trim().toLowerCase();
165
+ return THINKING_LEVELS.includes(normalized) ? (normalized as ThinkingLevel) : `thinking "${value}" is not one of ${THINKING_LEVELS.join(", ")}`;
166
+ }
167
+
168
+ function parseMode(value: unknown): AgentMode | undefined | string {
169
+ if (value === undefined) return undefined;
170
+ const normalized = String(value).trim().toLowerCase();
171
+ return AGENT_MODES.includes(normalized) ? (normalized as AgentMode) : `mode "${value}" is not one of ${AGENT_MODES.join(", ")}`;
172
+ }
173
+
174
+ function parseTools(value: FrontmatterValue | undefined): string[] {
175
+ if (value === undefined) return [];
176
+ const items = Array.isArray(value) ? value : value.split(",");
177
+ return items.map((item) => item.trim()).filter((item) => item.length > 0);
178
+ }
179
+
180
+ function scalar(value: FrontmatterValue | undefined): string | undefined {
181
+ return typeof value === "string" ? value : undefined;
182
+ }
183
+
184
+ export function parseAgentDefinition(text: string, filePath: string, scope: AgentScope): AgentDefinition | AgentDefinitionError {
185
+ const { data, body } = parseFrontmatter(text);
186
+ const thinking = parseThinking(scalar(data.thinking) ?? scalar(data.effort) ?? scalar(data.thinking_level));
187
+ if (thinking !== undefined && !THINKING_LEVELS.includes(thinking)) return { filePath, error: thinking };
188
+ const mode = parseMode(scalar(data.subagent_mode) ?? scalar(data.mode));
189
+ if (mode !== undefined && !AGENT_MODES.includes(mode)) return { filePath, error: mode };
190
+ if (body.length === 0) return { filePath, error: "no instructions after the frontmatter" };
191
+ const name = scalar(data.name)?.trim() || basename(filePath).replace(/\.md$/i, "");
192
+ return {
193
+ name,
194
+ description: scalar(data.description)?.trim() ?? "",
195
+ filePath,
196
+ scope,
197
+ instructions: body,
198
+ model: parseModelRef(data.model),
199
+ thinking: thinking as ThinkingLevel | undefined,
200
+ mode: mode as AgentMode | undefined,
201
+ tools: parseTools(data.tools),
202
+ };
203
+ }
204
+
205
+ // Discovery order is precedence order: a later directory replaces an earlier
206
+ // definition with the same name, so project beats global and `subagents/`
207
+ // beats `agents/` within each scope.
208
+ function profileRoot(roots: DiscoveryRoots): string {
209
+ return roots.agentHome ?? join(roots.home, ".pi", "agent");
210
+ }
211
+
212
+ export function agentDirectories(roots: DiscoveryRoots): Array<{ dir: string; scope: AgentScope }> {
213
+ const agentHome = profileRoot(roots);
214
+ return [
215
+ { dir: join(agentHome, "agents"), scope: AGENT_SCOPE.GLOBAL },
216
+ { dir: join(agentHome, "subagents"), scope: AGENT_SCOPE.GLOBAL },
217
+ { dir: join(roots.cwd, ".pi", "agents"), scope: AGENT_SCOPE.PROJECT },
218
+ { dir: join(roots.cwd, ".pi", "subagents"), scope: AGENT_SCOPE.PROJECT },
219
+ ];
220
+ }
221
+
222
+ export function discoverAgents(roots: DiscoveryRoots): DiscoveryResult {
223
+ const agents = new Map<string, AgentDefinition>();
224
+ const errors: string[] = [];
225
+ for (const { dir, scope } of agentDirectories(roots)) {
226
+ if (!existsSync(dir)) continue;
227
+ for (const file of readdirSync(dir).filter((entry) => entry.toLowerCase().endsWith(".md")).sort()) {
228
+ const filePath = join(dir, file);
229
+ const parsed = parseAgentDefinition(readFileSync(filePath, "utf8"), filePath, scope);
230
+ if ("error" in parsed) errors.push(`${filePath}: ${parsed.error}`);
231
+ else agents.set(parsed.name, parsed);
232
+ }
233
+ }
234
+ return { agents: [...agents.values()].sort((a, b) => a.name.localeCompare(b.name)), errors };
235
+ }
236
+
237
+ type RawConfig = Record<string, unknown> | undefined;
238
+
239
+ function positiveInteger(value: unknown, fallback: number): number {
240
+ return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : fallback;
241
+ }
242
+
243
+ function parseProfiles(value: unknown): Record<string, ModelProfile> {
244
+ const profiles: Record<string, ModelProfile> = {};
245
+ if (!value || typeof value !== "object") return profiles;
246
+ for (const [name, raw] of Object.entries(value as Record<string, unknown>)) {
247
+ if (!raw || typeof raw !== "object") continue;
248
+ const entry = raw as Record<string, unknown>;
249
+ const thinking = parseThinking(entry.effort ?? entry.thinking);
250
+ profiles[name] = { model: parseModelRef(entry.model), thinking: thinking !== undefined && THINKING_LEVELS.includes(thinking) ? (thinking as ThinkingLevel) : undefined };
251
+ }
252
+ return profiles;
253
+ }
254
+
255
+ function mergeProfiles(base: Record<string, ModelProfile>, override: Record<string, ModelProfile>): Record<string, ModelProfile> {
256
+ const merged = { ...base };
257
+ for (const [name, profile] of Object.entries(override)) {
258
+ merged[name] = { model: profile.model ?? base[name]?.model, thinking: profile.thinking ?? base[name]?.thinking };
259
+ }
260
+ return merged;
261
+ }
262
+
263
+ // Project settings override global ones field by field; model profiles merge
264
+ // per agent so a project can change one effort without repeating the model.
265
+ export function parseAgentsConfig(global: RawConfig, project: RawConfig): AgentsConfig {
266
+ const merged: Record<string, unknown> = { ...(global ?? {}), ...(project ?? {}) };
267
+ const thinking = parseThinking(merged.default_effort ?? merged.default_thinking_level ?? merged.default_thinking);
268
+ const mode = parseMode(merged.default_mode);
269
+ return {
270
+ defaultModel: parseModelRef(merged.default_model),
271
+ defaultThinking: thinking !== undefined && THINKING_LEVELS.includes(thinking) ? (thinking as ThinkingLevel) : undefined,
272
+ defaultMode: mode !== undefined && AGENT_MODES.includes(mode) ? (mode as AgentMode) : AGENT_MODE.TASK,
273
+ modelProfiles: mergeProfiles(parseProfiles(global?.model_profiles), parseProfiles(project?.model_profiles)),
274
+ // `timeout_ms` remains accepted as an inert legacy key so existing JSON
275
+ // files load normally; only silence is bounded by `stall_timeout_ms`.
276
+ stallTimeoutMs: positiveInteger(merged.stall_timeout_ms, DEFAULT_STALL_TIMEOUT_MS),
277
+ maxConcurrency: positiveInteger(merged.max_concurrency, DEFAULT_MAX_CONCURRENCY),
278
+ historyMaxTasks: positiveInteger(merged.history_max_tasks, DEFAULT_HISTORY_MAX_TASKS),
279
+ };
280
+ }
281
+
282
+ function readJson(path: string): RawConfig {
283
+ if (!existsSync(path)) return undefined;
284
+ try {
285
+ const parsed = JSON.parse(readFileSync(path, "utf8")) as unknown;
286
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : undefined;
287
+ } catch {
288
+ return undefined;
289
+ }
290
+ }
291
+
292
+ export function loadAgentsConfig(roots: DiscoveryRoots): AgentsConfig {
293
+ return parseAgentsConfig(readJson(join(profileRoot(roots), "subagents.json")), readJson(join(roots.cwd, ".pi", "subagents.json")));
294
+ }
295
+
296
+ function pick<T>(candidates: Array<[T | undefined, ProfileSource]>): [T | undefined, ProfileSource] {
297
+ return candidates.find(([value]) => value !== undefined) ?? [undefined, PROFILE_SOURCE.UNRESOLVED];
298
+ }
299
+
300
+ export function resolveAgentProfile(agent: AgentDefinition, config: AgentsConfig): ResolvedProfile {
301
+ const profile = config.modelProfiles[agent.name];
302
+ const [model, modelSource] = pick<ModelRef>([
303
+ [profile?.model, PROFILE_SOURCE.PROFILE],
304
+ [agent.model, PROFILE_SOURCE.DEFINITION],
305
+ [config.defaultModel, PROFILE_SOURCE.DEFAULT],
306
+ ]);
307
+ const [thinking, thinkingSource] = pick<ThinkingLevel>([
308
+ [profile?.thinking, PROFILE_SOURCE.PROFILE],
309
+ [agent.thinking, PROFILE_SOURCE.DEFINITION],
310
+ [config.defaultThinking, PROFILE_SOURCE.DEFAULT],
311
+ ]);
312
+ return { model, thinking, source: { model: modelSource, thinking: thinkingSource } };
313
+ }
314
+
315
+ export function formatModelRef(model: ModelRef | undefined): string {
316
+ if (!model) return "default";
317
+ return model.provider ? `${model.provider}/${model.id}` : model.id;
318
+ }
@@ -0,0 +1,80 @@
1
+ import { mkdir, readdir, readFile, rename, rm, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { emptyThread, type TaskRecord, type TaskThread } from "./agents-protocol.ts";
4
+
5
+ // Gentle Agents history: one JSON file per finished task, written by the
6
+ // host after the child is gone and read back lazily when the overlay opens
7
+ // or a tool asks for a task from an earlier session. Everything is async so
8
+ // the terminal never waits on disk.
9
+
10
+ export interface StoredTask {
11
+ task: TaskRecord;
12
+ thread: TaskThread;
13
+ }
14
+
15
+ const FILE_SUFFIX = ".json";
16
+ const SAFE_ID = /^[a-z0-9-]+$/i;
17
+
18
+ export function historyDir(home: string, agentHome = join(home, ".pi", "agent")): string {
19
+ return join(agentHome, "gentle-agents", "tasks");
20
+ }
21
+
22
+ function fileFor(dir: string, id: string): string {
23
+ if (!SAFE_ID.test(id)) throw new Error(`invalid task id: ${id}`);
24
+ return join(dir, `${id}${FILE_SUFFIX}`);
25
+ }
26
+
27
+ function isRecord(value: unknown): value is TaskRecord {
28
+ const task = value as Partial<TaskRecord> | undefined;
29
+ return typeof task?.id === "string" && typeof task.agent === "string" && typeof task.status === "string" && typeof task.createdAt === "number";
30
+ }
31
+
32
+ function parseStored(text: string): StoredTask | undefined {
33
+ try {
34
+ const parsed = JSON.parse(text) as Partial<StoredTask>;
35
+ if (!isRecord(parsed.task)) return undefined;
36
+ const thread = parsed.thread && Array.isArray(parsed.thread.items) ? parsed.thread : emptyThread();
37
+ return { task: parsed.task, thread: { ...emptyThread(), ...thread } };
38
+ } catch {
39
+ return undefined;
40
+ }
41
+ }
42
+
43
+ export async function saveTask(dir: string, task: TaskRecord, thread: TaskThread): Promise<void> {
44
+ await mkdir(dir, { recursive: true });
45
+ const target = fileFor(dir, task.id);
46
+ const temp = `${target}.${process.pid}-${Math.random().toString(36).slice(2, 8)}.tmp`;
47
+ await writeFile(temp, JSON.stringify({ task, thread }), "utf8");
48
+ await rename(temp, target);
49
+ }
50
+
51
+ export async function loadStoredTask(dir: string, id: string): Promise<StoredTask | undefined> {
52
+ if (!SAFE_ID.test(id)) return undefined;
53
+ try {
54
+ return parseStored(await readFile(fileFor(dir, id), "utf8"));
55
+ } catch {
56
+ return undefined;
57
+ }
58
+ }
59
+
60
+ async function listFiles(dir: string): Promise<string[]> {
61
+ try {
62
+ return (await readdir(dir)).filter((name) => name.endsWith(FILE_SUFFIX));
63
+ } catch {
64
+ return [];
65
+ }
66
+ }
67
+
68
+ export async function loadHistory(dir: string): Promise<StoredTask[]> {
69
+ const files = await listFiles(dir);
70
+ const stored = await Promise.all(files.map(async (name) => parseStored(await readFile(join(dir, name), "utf8").catch(() => ""))));
71
+ return stored.filter((entry): entry is StoredTask => entry !== undefined).sort((a, b) => b.task.createdAt - a.task.createdAt);
72
+ }
73
+
74
+ // Keep the newest `maxTasks` files; the rest go. Returns how many were removed.
75
+ export async function pruneHistory(dir: string, maxTasks: number): Promise<number> {
76
+ const stored = await loadHistory(dir);
77
+ const extra = stored.slice(Math.max(0, maxTasks));
78
+ await Promise.all(extra.map((entry) => rm(fileFor(dir, entry.task.id), { force: true })));
79
+ return extra.length;
80
+ }