gentle-pi 2.4.0 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (227) hide show
  1. package/README.md +292 -25
  2. package/assets/agents/gentle-ai-worker.md +13 -0
  3. package/assets/agents/jd-fix-agent.md +18 -0
  4. package/assets/agents/jd-judge-a.md +1 -1
  5. package/assets/agents/jd-judge-b.md +1 -1
  6. package/assets/agents/sdd-apply.md +7 -5
  7. package/assets/agents/sdd-archive.md +5 -3
  8. package/assets/agents/sdd-design.md +4 -0
  9. package/assets/agents/sdd-explore.md +4 -0
  10. package/assets/agents/sdd-init.md +4 -0
  11. package/assets/agents/sdd-onboard.md +4 -0
  12. package/assets/agents/sdd-proposal.md +4 -0
  13. package/assets/agents/sdd-remediate.md +37 -0
  14. package/assets/agents/sdd-research.md +26 -3
  15. package/assets/agents/sdd-spec.md +4 -0
  16. package/assets/agents/sdd-status.md +9 -75
  17. package/assets/agents/sdd-sync.md +4 -0
  18. package/assets/agents/sdd-tasks.md +4 -0
  19. package/assets/agents/sdd-verify.md +5 -3
  20. package/assets/chains/sdd-full.chain.md +4 -0
  21. package/assets/chains/sdd-plan.chain.md +4 -0
  22. package/assets/chains/sdd-verify.chain.md +4 -0
  23. package/assets/migrations/managed-assets-v2.5.0.json +7 -0
  24. package/assets/orchestrator-delegation.md +39 -11
  25. package/assets/orchestrator.md +5 -5
  26. package/assets/sdd-orchestrator-workflow.md +54 -21
  27. package/assets/support/sdd-status-contract.md +34 -90
  28. package/contracts/telemetry/runtime-aggregate-v1.schema.json +65 -0
  29. package/docs/delegated-verification.md +25 -0
  30. package/docs/telemetry.md +94 -0
  31. package/docs/windows-startup-console-visibility.md +18 -0
  32. package/extensions/ask-user-choice.ts +159 -25
  33. package/extensions/codegraph-tools.ts +95 -5
  34. package/extensions/gentle-agents.ts +1337 -0
  35. package/extensions/gentle-ai.ts +2916 -386
  36. package/extensions/gentle-shell.ts +650 -0
  37. package/extensions/gentle-todo.ts +234 -0
  38. package/extensions/quiet-tools.ts +2 -1
  39. package/extensions/runtime-metrics.ts +130 -0
  40. package/extensions/sdd-init.ts +2 -2
  41. package/extensions/startup-banner.ts +52 -75
  42. package/lib/agent-profiles.ts +550 -0
  43. package/lib/agents-completion-delivery.ts +72 -0
  44. package/lib/agents-config.ts +315 -0
  45. package/lib/agents-history.ts +88 -0
  46. package/lib/agents-messaging.ts +187 -0
  47. package/lib/agents-protocol.ts +501 -0
  48. package/lib/agents-runner.ts +1012 -0
  49. package/lib/agents-thread-view.ts +57 -0
  50. package/lib/agents-transcript.ts +87 -0
  51. package/lib/agents-view-layout.ts +40 -0
  52. package/lib/agents-view.ts +914 -0
  53. package/lib/agents-widget.ts +241 -0
  54. package/lib/gentle-ai-binary.ts +3 -1
  55. package/lib/gentle-ai-renderer.ts +143 -25
  56. package/lib/native-choice-list.ts +194 -0
  57. package/lib/native-fullscreen-interaction.ts +47 -0
  58. package/lib/native-pointer-region.ts +164 -0
  59. package/lib/native-review-cli.ts +371 -13
  60. package/lib/orchestrator-presence.ts +337 -0
  61. package/lib/profiles-orchestrator.ts +203 -0
  62. package/lib/review-candidate-view-owner.ts +427 -0
  63. package/lib/review-candidate-view.ts +150 -48
  64. package/lib/review-consent-component.ts +247 -0
  65. package/lib/review-consent-ui.ts +110 -0
  66. package/lib/review-host-relay.ts +28 -0
  67. package/lib/review-integration-v2.ts +243 -11
  68. package/lib/review-last-event-controller.ts +8 -4
  69. package/lib/review-relay-contract.ts +11 -0
  70. package/lib/review-reminder-receipt.ts +74 -0
  71. package/lib/review-repository.ts +2 -2
  72. package/lib/review-risk-assessment.ts +339 -0
  73. package/lib/review-session-standing-permission-ipc.ts +309 -0
  74. package/lib/review-session-standing-permission.ts +240 -0
  75. package/lib/runtime-metrics-children.ts +199 -0
  76. package/lib/runtime-metrics-delivery.ts +68 -0
  77. package/lib/runtime-metrics-native.ts +166 -0
  78. package/lib/runtime-metrics-pi-identity.ts +113 -0
  79. package/lib/runtime-metrics-policy.ts +51 -0
  80. package/lib/runtime-metrics.ts +255 -0
  81. package/lib/sdd-preflight.ts +362 -81
  82. package/lib/sdd-research-capabilities.ts +228 -0
  83. package/lib/sdd-status.ts +29 -7
  84. package/lib/session-worktree-registry.ts +118 -0
  85. package/lib/shell-bar.ts +184 -0
  86. package/lib/shell-card.ts +133 -0
  87. package/lib/shell-changes-view.ts +530 -0
  88. package/lib/shell-changes.ts +290 -0
  89. package/lib/shell-gauge.ts +40 -0
  90. package/lib/shell-prompt.ts +115 -0
  91. package/lib/shell-sidebar-banner.ts +11 -0
  92. package/lib/shell-sidebar-layout.ts +213 -0
  93. package/lib/shell-sidebar.ts +41 -0
  94. package/lib/shell-todo.ts +297 -0
  95. package/lib/shell-usage-view.ts +76 -0
  96. package/lib/shell-usage.ts +246 -0
  97. package/lib/telemetry-trigger.ts +153 -0
  98. package/package.json +8 -5
  99. package/runtime/gentle-ai-binary.mjs +3 -1
  100. package/runtime/native-review-cli.mjs +370 -12
  101. package/runtime/review-integration-v2.mjs +243 -11
  102. package/runtime/review-relay-contract.mjs +11 -0
  103. package/runtime/review-risk-assessment.mjs +340 -0
  104. package/runtime/telemetry-trigger.mjs +154 -0
  105. package/scripts/build-runtime-modules.mjs +11 -1
  106. package/scripts/check-types.mjs +125 -0
  107. package/scripts/gentle-ai-installer.mjs +10 -10
  108. package/scripts/install-gentle-ai.mjs +12 -0
  109. package/scripts/install-tui-mode-setting.mjs +114 -0
  110. package/scripts/test-packed-runner.mjs +38 -2
  111. package/scripts/types-baseline.json +99 -0
  112. package/scripts/verify-package-files.mjs +8 -2
  113. package/skills/_shared/review-ledger-contract.md +20 -2
  114. package/skills/issue-creation/SKILL.md +3 -3
  115. package/skills/judgment-day/SKILL.md +17 -3
  116. package/skills/judgment-day/references/prompts-and-formats.md +14 -3
  117. package/tests/agent-profiles.test.ts +722 -0
  118. package/tests/agents-completion-delivery.test.ts +94 -0
  119. package/tests/agents-config.test.ts +205 -0
  120. package/tests/agents-fake-child.ts +66 -0
  121. package/tests/agents-grouping.test.ts +179 -0
  122. package/tests/agents-history.test.ts +54 -0
  123. package/tests/agents-integration.test.ts +100 -0
  124. package/tests/agents-messaging.test.ts +94 -0
  125. package/tests/agents-protocol.test.ts +198 -0
  126. package/tests/agents-queries.test.ts +190 -0
  127. package/tests/agents-responsive.test.ts +43 -0
  128. package/tests/agents-runner-process.test.ts +111 -0
  129. package/tests/agents-runner.test.ts +959 -0
  130. package/tests/agents-thread-view.test.ts +45 -0
  131. package/tests/agents-transcript.test.ts +30 -0
  132. package/tests/agents-view.test.ts +685 -0
  133. package/tests/agents-widget.test.ts +141 -0
  134. package/tests/artifact-language.test.ts +25 -2
  135. package/tests/ask-user-choice.test.ts +325 -5
  136. package/tests/asset-installation-runtime.test.ts +108 -0
  137. package/tests/autonomous-guard.test.ts +116 -1
  138. package/tests/codegraph-tools.test.ts +112 -2
  139. package/tests/delegated-key-learnings-contract.test.ts +1 -1
  140. package/tests/devbinary/native-review-parity.devtest.ts +110 -0
  141. package/tests/feature-request-form.test.ts +67 -0
  142. package/tests/fixtures/agents-messaging-child.mjs +5 -0
  143. package/tests/fixtures/agents-process-child.mjs +23 -0
  144. package/tests/fixtures/runtime-metrics-native-batches.json +6 -0
  145. package/tests/gentle-agents.test.ts +2168 -0
  146. package/tests/gentle-ai-binary.test.ts +7 -2
  147. package/tests/gentle-ai-installer.test.ts +47 -47
  148. package/tests/gentle-ai-renderer.test.ts +103 -0
  149. package/tests/gentle-ai.test.ts +971 -15
  150. package/tests/gentle-card-text.ts +35 -0
  151. package/tests/gentle-shell.test.ts +818 -0
  152. package/tests/gentle-todo.test.ts +226 -0
  153. package/tests/install-tui-mode-setting.test.ts +324 -0
  154. package/tests/issue-creation-skill.test.ts +22 -0
  155. package/tests/model-routing-authority.test.ts +12 -0
  156. package/tests/native-choice-list.test.ts +202 -0
  157. package/tests/native-fullscreen-interaction.test.ts +125 -0
  158. package/tests/native-pointer-region.test.ts +245 -0
  159. package/tests/native-review-capability-contract.test.ts +27 -1
  160. package/tests/native-review-cli.test.ts +317 -3
  161. package/tests/native-review-consent.test.ts +91 -0
  162. package/tests/native-review-parity-runtime.test.ts +8 -2
  163. package/tests/native-review-parity.test.ts +43 -29
  164. package/tests/native-sdd-attempt-authority.test.ts +7 -2
  165. package/tests/orchestrator-budget.test.ts +69 -0
  166. package/tests/orchestrator-presence.test.ts +389 -0
  167. package/tests/orchestrator-rdd-ownership.test.ts +9 -0
  168. package/tests/package-manifest.test.ts +243 -7
  169. package/tests/profiles-orchestrator.test.ts +208 -0
  170. package/tests/quiet-tool-rendering.test.ts +97 -37
  171. package/tests/rdd-aware-verification-contract.test.ts +226 -0
  172. package/tests/rdd-status-line.test.ts +286 -0
  173. package/tests/review-agent-end-preflight.test.ts +332 -24
  174. package/tests/review-candidate-view.test.ts +751 -7
  175. package/tests/review-consent-ui.test.ts +352 -0
  176. package/tests/review-contract-prompt.test.ts +17 -0
  177. package/tests/review-controller-native-recovery.test.ts +29 -4
  178. package/tests/review-controller-native-routing.test.ts +884 -7
  179. package/tests/review-controller-workspace-root.test.ts +45 -2
  180. package/tests/review-controller.test.ts +26 -1
  181. package/tests/review-host-relay-restart-parity.test.ts +142 -1
  182. package/tests/review-host-relay-routing.test.ts +384 -8
  183. package/tests/review-host-relay.test.ts +29 -0
  184. package/tests/review-integration-v2-forward.test.ts +44 -0
  185. package/tests/review-integration-v2.test.ts +276 -0
  186. package/tests/review-last-event-closure.test.ts +112 -3
  187. package/tests/review-ledger-contract.test.ts +61 -6
  188. package/tests/review-relay-contract.test.ts +26 -0
  189. package/tests/review-reminder-receipt.test.ts +62 -0
  190. package/tests/review-repository.test.ts +28 -1
  191. package/tests/review-risk-assessment.test.ts +626 -0
  192. package/tests/review-session-standing-permission-controller.test.ts +656 -0
  193. package/tests/review-session-standing-permission-ipc.test.ts +233 -0
  194. package/tests/review-session-standing-permission-runtime.test.ts +212 -0
  195. package/tests/review-session-standing-permission.test.ts +156 -0
  196. package/tests/runtime-harness.mjs +447 -39
  197. package/tests/runtime-metrics-children.test.ts +206 -0
  198. package/tests/runtime-metrics-delivery.test.ts +85 -0
  199. package/tests/runtime-metrics-extension.test.ts +187 -0
  200. package/tests/runtime-metrics-native.test.ts +209 -0
  201. package/tests/runtime-metrics-pi-identity.test.ts +113 -0
  202. package/tests/runtime-metrics-policy.test.ts +62 -0
  203. package/tests/runtime-metrics.test.ts +184 -0
  204. package/tests/sdd-agent-tools.test.ts +10 -1
  205. package/tests/sdd-execution-routing-contract.test.ts +28 -0
  206. package/tests/sdd-managed-runtime-settlement.test.ts +331 -0
  207. package/tests/sdd-native-managed-uptake.test.ts +253 -0
  208. package/tests/sdd-planning-routing-contract.test.ts +45 -0
  209. package/tests/sdd-preflight.test.ts +252 -8
  210. package/tests/sdd-research-capabilities.test.ts +256 -0
  211. package/tests/sdd-research-live.test.ts +241 -0
  212. package/tests/sdd-selection-transport.test.ts +504 -0
  213. package/tests/sdd-status.test.ts +51 -0
  214. package/tests/session-worktree-registry.test.ts +135 -0
  215. package/tests/shell-bar.test.ts +176 -0
  216. package/tests/shell-card.test.ts +139 -0
  217. package/tests/shell-changes-view.test.ts +609 -0
  218. package/tests/shell-changes.test.ts +350 -0
  219. package/tests/shell-prompt.test.ts +140 -0
  220. package/tests/shell-sidebar-banner.test.ts +23 -0
  221. package/tests/shell-sidebar-layout.test.ts +387 -0
  222. package/tests/shell-sidebar.test.ts +50 -0
  223. package/tests/shell-todo.test.ts +259 -0
  224. package/tests/shell-usage-view.test.ts +62 -0
  225. package/tests/shell-usage.test.ts +197 -0
  226. package/tests/startup-banner.test.ts +126 -0
  227. package/tests/telemetry-trigger.test.ts +351 -0
@@ -0,0 +1,234 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { Text, type Component, type TUI } from "@earendil-works/pi-tui";
3
+ import { NativePointerRegion } from "../lib/native-pointer-region.ts";
4
+ import { sidebarPart } from "../lib/shell-sidebar.ts";
5
+ import { invalidateSidebar } from "../lib/shell-sidebar-layout.ts";
6
+ import {
7
+ applyTodo,
8
+ emptyTodo,
9
+ renderTodoCard,
10
+ replayTodo,
11
+ staleTurns,
12
+ TODO_DETAILS_KEY,
13
+ TODO_GLYPH,
14
+ TODO_TOOL_NAME,
15
+ todoPromptBlock,
16
+ todoSummary,
17
+ type TodoParams,
18
+ type TodoState,
19
+ } from "../lib/shell-todo.ts";
20
+
21
+ // Gentle Todo: the task list the model keeps while it works, drawn as a
22
+ // Gentle Shell card above the editor. Three things keep it current that a
23
+ // static tool description cannot: `write` replaces the whole list in one
24
+ // call, every turn's system prompt carries the open tasks and the rules, and
25
+ // a list that goes untouched while tasks stay open is marked stale for both
26
+ // the human and the model.
27
+
28
+ const WIDGET_KEY = "gentle-todo";
29
+ const COLLAPSE_KEY_DEFAULT = "ctrl+shift+t";
30
+ const TOOL_PARAMETERS = {
31
+ type: "object",
32
+ additionalProperties: false,
33
+ required: ["action"],
34
+ properties: {
35
+ 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." },
36
+ tasks: {
37
+ type: "array",
38
+ description: "For write: the complete ordered list. Keep the id of tasks that already exist so their history survives; omit it for new ones.",
39
+ items: {
40
+ type: "object",
41
+ additionalProperties: false,
42
+ required: ["title"],
43
+ properties: {
44
+ id: { type: "integer", description: "Existing task id to keep." },
45
+ title: { type: "string", description: "Short imperative title, e.g. 'Write the parser'." },
46
+ status: { type: "string", enum: ["pending", "in_progress", "done"], description: "Defaults to pending." },
47
+ note: { type: "string", description: "What is happening right now, shown while in_progress, e.g. 'writing tests'." },
48
+ },
49
+ },
50
+ },
51
+ id: { type: "integer", description: "Task id for update." },
52
+ title: { type: "string", description: "Title for add, or a new title for update." },
53
+ status: { type: "string", enum: ["pending", "in_progress", "done"], description: "Status for add or update." },
54
+ note: { type: "string", description: "Note for add or update." },
55
+ },
56
+ } as const;
57
+
58
+ export function todoEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
59
+ if (env.GENTLE_PI_AGENTS_CHILD === "1") return false;
60
+ const value = env.GENTLE_PI_TODO?.trim().toLowerCase();
61
+ return !(value === "0" || value === "false" || value === "off");
62
+ }
63
+
64
+ export function todoCollapseKey(env: NodeJS.ProcessEnv = process.env): string | undefined {
65
+ const value = env.GENTLE_PI_TODO_KEY?.trim();
66
+ if (value === undefined) return COLLAPSE_KEY_DEFAULT;
67
+ return value === "" || value.toLowerCase() === "off" ? undefined : value;
68
+ }
69
+
70
+ interface TodoSession {
71
+ state: TodoState;
72
+ turn: number;
73
+ collapsed: boolean;
74
+ /** A finished list stays on screen for the turn it finished in, then clears. */
75
+ clearOnNextTurn: boolean;
76
+ ui: ExtensionContext["ui"] | undefined;
77
+ host: { requestRender(): void } | undefined;
78
+ tui: TUI | undefined;
79
+ }
80
+
81
+ function sessionKey(ctx: ExtensionContext): string {
82
+ return ctx.sessionManager.getSessionId() ?? "";
83
+ }
84
+
85
+ export default function gentleTodo(pi: ExtensionAPI, env: NodeJS.ProcessEnv = process.env): void {
86
+ if (!todoEnabled(env)) return;
87
+ const sessions = new Map<string, TodoSession>();
88
+ const collapseKey = todoCollapseKey(env);
89
+
90
+ const session = (ctx: ExtensionContext): TodoSession => {
91
+ const key = sessionKey(ctx);
92
+ let current = sessions.get(key);
93
+ if (!current) {
94
+ current = { state: emptyTodo(), turn: 0, collapsed: false, clearOnNextTurn: false, ui: undefined, host: undefined, tui: undefined };
95
+ sessions.set(key, current);
96
+ }
97
+ return current;
98
+ };
99
+
100
+ const toggle = (current: TodoSession) => {
101
+ current.collapsed = !current.collapsed;
102
+ if (current.tui) invalidateSidebar(current.tui);
103
+ current.host?.requestRender();
104
+ };
105
+
106
+ const todoCard = (current: TodoSession, theme: Parameters<typeof renderTodoCard>[1], scrollable: boolean, spacer: boolean): Component & { dispose(): void } => {
107
+ const card: Component = {
108
+ render(width: number) {
109
+ const lines = renderTodoCard(current.state, theme, width, {
110
+ collapsed: current.collapsed,
111
+ staleTurns: staleTurns(current.state, current.turn),
112
+ collapseKey,
113
+ ...(scrollable ? { scrollable: true } : {}),
114
+ });
115
+ return spacer && lines.length > 0 ? [...lines, ""] : lines;
116
+ },
117
+ invalidate() {},
118
+ };
119
+ const region = new NativePointerRegion(card, {
120
+ onClick(event) {
121
+ if (event.button !== "left" || event.y !== 0) return undefined;
122
+ toggle(current);
123
+ return { handled: true, render: true };
124
+ },
125
+ });
126
+ return {
127
+ render: (width) => region.render(width),
128
+ handleMouse: (event) => region.handleMouse(event),
129
+ invalidate: () => region.invalidate(),
130
+ dispose: () => region.dispose(),
131
+ };
132
+ };
133
+
134
+ const show = (current: TodoSession) => {
135
+ if (current.tui) invalidateSidebar(current.tui);
136
+ if (!current.ui) return;
137
+ if (current.state.tasks.length === 0) {
138
+ current.ui.setWidget(WIDGET_KEY, undefined);
139
+ return;
140
+ }
141
+ const snapshot = current;
142
+ current.ui.setWidget(WIDGET_KEY, (tui, theme) => {
143
+ snapshot.host = tui;
144
+ snapshot.tui = tui;
145
+ return sidebarPart(tui, "todo", todoCard(snapshot, theme, false, true), todoCard(snapshot, theme, true, false));
146
+ });
147
+ };
148
+
149
+ pi.registerTool({
150
+ name: TODO_TOOL_NAME,
151
+ renderShell: "self",
152
+ label: "Todo",
153
+ 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.",
154
+ promptSnippet: "Track multi-step work; rewrite the whole list as the plan changes",
155
+ promptGuidelines: [
156
+ "Use todo for work with three or more steps or when the user hands you a list. Skip it for single trivial requests.",
157
+ "Mark a task in_progress before starting it and done right after finishing it; keep exactly one task in_progress.",
158
+ "Prefer write with the complete list whenever the plan changes; keep ids of tasks that already exist.",
159
+ "Never mark a task done while tests fail or the work is partial; add a task for the blocker instead.",
160
+ ],
161
+ parameters: TOOL_PARAMETERS,
162
+ executionMode: "sequential",
163
+ renderCall(args, theme) {
164
+ const params = args as TodoParams;
165
+ return new Text(theme.fg("toolTitle", `${TODO_GLYPH} todo · ${params.action}`), 0, 0);
166
+ },
167
+ renderResult(result, options, theme) {
168
+ const text = result.content.map((part) => (part.type === "text" ? part.text : "")).join("\n");
169
+ return new Text(options.expanded ? text : theme.fg("muted", text.split("\n")[0] ?? ""), 0, 0);
170
+ },
171
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
172
+ const current = session(ctx);
173
+ const result = applyTodo(current.state, params as TodoParams, current.turn);
174
+ if (!result.error) {
175
+ current.state = result.state;
176
+ current.clearOnNextTurn = false;
177
+ if (current.tui) invalidateSidebar(current.tui);
178
+ }
179
+ return {
180
+ content: [{ type: "text", text: result.text }],
181
+ details: { [TODO_DETAILS_KEY]: result.state, ...(result.error ? { error: result.error } : {}) },
182
+ };
183
+ },
184
+ });
185
+
186
+ if (collapseKey) {
187
+ pi.registerShortcut(collapseKey as Parameters<ExtensionAPI["registerShortcut"]>[0], {
188
+ description: "Collapse or expand the todo list",
189
+ handler: async (ctx) => {
190
+ toggle(session(ctx));
191
+ },
192
+ });
193
+ }
194
+
195
+ pi.on("session_start", (_event, ctx) => {
196
+ const current = session(ctx);
197
+ // A list that was already finished when the session was left is history,
198
+ // not work: it would otherwise sit on screen until two more turns pass.
199
+ const replayed = replayTodo(ctx.sessionManager.getBranch());
200
+ current.state = replayed.tasks.length > 0 && todoSummary(replayed).open === 0 ? { ...replayed, tasks: [] } : replayed;
201
+ current.turn = ctx.sessionManager.getBranch().filter((entry) => (entry as { type?: string }).type === "message" && (entry as { message?: { role?: string } }).message?.role === "user").length;
202
+ current.ui = ctx.hasUI ? ctx.ui : undefined;
203
+ show(current);
204
+ });
205
+
206
+ pi.on("session_shutdown", (_event, ctx) => {
207
+ sessions.delete(sessionKey(ctx));
208
+ });
209
+
210
+ pi.on("before_agent_start", (event, ctx) => {
211
+ const current = session(ctx);
212
+ current.turn += 1;
213
+ if (current.tui) invalidateSidebar(current.tui);
214
+ if (current.clearOnNextTurn) {
215
+ current.state = { ...current.state, tasks: [] };
216
+ current.clearOnNextTurn = false;
217
+ show(current);
218
+ }
219
+ const block = todoPromptBlock(current.state, staleTurns(current.state, current.turn));
220
+ if (!block) return undefined;
221
+ return { systemPrompt: `${event.systemPrompt}\n\n${block}` };
222
+ });
223
+
224
+ pi.on("tool_execution_end", (event, ctx) => {
225
+ if (event.toolName !== TODO_TOOL_NAME) return;
226
+ show(session(ctx));
227
+ });
228
+
229
+ pi.on("agent_end", (_event, ctx) => {
230
+ const current = session(ctx);
231
+ if (current.state.tasks.length > 0 && todoSummary(current.state).open === 0) current.clearOnNextTurn = true;
232
+ show(current);
233
+ });
234
+ }
@@ -625,6 +625,7 @@ function registerQuietTool(pi: ExtensionAPI, toolName: QuietToolName, commandArg
625
625
 
626
626
  pi.registerTool({
627
627
  ...registrationTool,
628
+ renderShell: "self",
628
629
  async execute(toolCallId, params, signal, onUpdate, ctx) {
629
630
  const runtimeTool = getBuiltInTools(ctx.cwd)[toolName];
630
631
  return runtimeTool.execute(toolCallId, params, signal, onUpdate, ctx);
@@ -653,7 +654,7 @@ function registerQuietTool(pi: ExtensionAPI, toolName: QuietToolName, commandArg
653
654
  { result: true, isPartial: options.isPartial },
654
655
  ).directResult;
655
656
  if (directResult) {
656
- return renderGentleAiResult(safeResult, { expanded: options.expanded });
657
+ return renderGentleAiResult(safeResult, { expanded: options.expanded, isPartial: options.isPartial, isError }, theme, renderContext as GentleAiRenderContext | undefined);
657
658
  }
658
659
  if (options.isPartial) {
659
660
  if (options.expanded) return new Text(`${theme.fg("warning", partialLabel(toolName, text))}\n${theme.fg("muted", text)}`, 0, 0);
@@ -0,0 +1,130 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { EFFORTS, ORCHESTRATOR_AGENT_CLASS, RuntimeMetrics, UNKNOWN_AGENT_CLASS, type FinalResponse, type TokenMeasurement } from "../lib/runtime-metrics.ts";
3
+ import { classifyPiCatalogName, lookupPiCatalogName } from "../lib/runtime-metrics-pi-identity.ts";
4
+ import { CHILD_METRICS_EVENT, CHILD_METRICS_REVOKED, snapshotChildEvent, type ChildLaunchBucket } from "../lib/runtime-metrics-children.ts";
5
+ import { RuntimeMetricsAttempt } from "../lib/runtime-metrics-delivery.ts";
6
+ import { sendNativeRuntimeEvent, type NativeRuntimeTransportDeps } from "../lib/runtime-metrics-native.ts";
7
+ import { runtimeMetricsEnvAllows } from "../lib/runtime-metrics-policy.ts";
8
+
9
+ /** Available final usage -> one deferred attempt -> discard. No history reads,
10
+ * cumulative session accounting, policy leases, delivery queue or retry. Print
11
+ * mode alone joins its accepted attempt for at most 1.5 seconds at shutdown.
12
+ * Pi hooks lack request correlation: latency and SDK-zero presence are unknown.
13
+ */
14
+ export default function runtimeMetrics(pi: ExtensionAPI, env = process.env,
15
+ { lookup = lookupPiCatalogName, classify = classifyPiCatalogName, native, send = sendNativeRuntimeEvent,
16
+ now = () => performance.now(), shutdownWaitMs = 1500 }:
17
+ { lookup?: typeof lookupPiCatalogName; classify?: typeof classifyPiCatalogName; native?: NativeRuntimeTransportDeps;
18
+ send?: typeof sendNativeRuntimeEvent; now?: () => number; shutdownWaitMs?: number } = {}): void {
19
+ const allows = () => env.GENTLE_PI_AGENTS_CHILD !== "1" && runtimeMetricsEnvAllows(env);
20
+ if (!allows()) return;
21
+ type Selection = Pick<FinalResponse, "selectedModelId" | "selectedProvider" | "effort">;
22
+ let selection: Selection | undefined;
23
+ let active = false;
24
+ let requestSeen = false;
25
+ let ambiguous = false;
26
+ let live: { id: string; started: number; ctx: ExtensionContext; attempt: RuntimeMetricsAttempt;
27
+ seen: WeakSet<object>; children: Set<string>; catalog: boolean } | undefined;
28
+ const missing = { state: "unavailable" } as const;
29
+ function invalidate() { selection = undefined; ambiguous = true; }
30
+ function dispose() { live?.attempt.dispose(); live = undefined; active = false; invalidate(); }
31
+ function current(ctx: ExtensionContext) {
32
+ if (!allows() || live?.id !== ctx.sessionManager.getSessionId()) dispose();
33
+ return live;
34
+ }
35
+ function refreshCatalog(owner: NonNullable<typeof live>, input: { provider: unknown; modelId: unknown }) {
36
+ void lookup(input).then(result => {
37
+ if (live === owner && result.classification === "catalog_public") owner.catalog = true;
38
+ }, () => { /* A later classification callback may retry the bounded load. */ });
39
+ }
40
+ function submit(owner: NonNullable<typeof live>, responses: FinalResponse[], launches?: ChildLaunchBucket[]) {
41
+ if (!responses.length || live !== owner || !current(owner.ctx)) return;
42
+ // Ephemeral event-local accounting only; source IDs never enter these rows.
43
+ const metrics = new RuntimeMetrics({ classifyModel: classify });
44
+ for (const [index, row] of responses.entries()) metrics.record({ ...row, responseId: String(index) });
45
+ const rows = metrics.snapshot();
46
+ if (!rows.length) return;
47
+ const cwd = owner.ctx.cwd;
48
+ owner.attempt.offer(async signal => {
49
+ if (live !== owner || !current(owner.ctx) || signal.aborted) return;
50
+ await send(rows, cwd, { ...native, launches, env, signal,
51
+ current: () => live === owner && current(owner.ctx) === owner });
52
+ });
53
+ }
54
+ const offChild = pi.events.on(CHILD_METRICS_EVENT, value => {
55
+ try {
56
+ const owner = live && current(live.ctx);
57
+ const event = snapshotChildEvent(value);
58
+ if (!owner || !event || event.parentSessionId !== owner.id || event.launchedAt < owner.started
59
+ || owner.children.has(event.taskId) || owner.children.size >= 256) return;
60
+ owner.children.add(event.taskId); // Busy/failed completions stay consumed.
61
+ submit(owner, event.responses, [{ ...event.launch, evidence: "launch_configuration", launches: 1 }]);
62
+ } catch { /* No telemetry error enters the shared event bus. */ }
63
+ });
64
+ const offRevoke = pi.events.on(CHILD_METRICS_REVOKED, id => {
65
+ if (live?.id === id) {
66
+ live.attempt.dispose();
67
+ live.attempt = new RuntimeMetricsAttempt();
68
+ invalidate();
69
+ }
70
+ });
71
+ pi.on("session_start", (_event, ctx) => {
72
+ dispose();
73
+ if (!allows()) return;
74
+ const owner = { id: ctx.sessionManager.getSessionId(), started: now(), ctx,
75
+ attempt: new RuntimeMetricsAttempt(), seen: new WeakSet<object>(), children: new Set<string>(), catalog: false };
76
+ live = owner;
77
+ refreshCatalog(owner, { provider: "openai", modelId: "gpt-4o" });
78
+ });
79
+ pi.on("session_shutdown", (_event, ctx) => {
80
+ const teardown = () => { dispose(); offChild(); offRevoke(); };
81
+ const owner = live;
82
+ if (ctx.mode !== "print" || !owner) { teardown(); return; }
83
+ return owner.attempt.waitForSettled(shutdownWaitMs).finally(teardown);
84
+ });
85
+ pi.on("turn_start", () => { ambiguous = active; active = true; requestSeen = false; selection = undefined; });
86
+ pi.on("turn_end", () => { active = false; invalidate(); });
87
+ pi.on("agent_end", () => { active = false; invalidate(); });
88
+ pi.on("session_before_compact", invalidate);
89
+ pi.on("session_before_tree", invalidate);
90
+ pi.on("before_provider_request", (_event, ctx) => {
91
+ const owner = current(ctx);
92
+ if (!owner) return;
93
+ if (requestSeen) invalidate();
94
+ requestSeen = true;
95
+ if (!active || ambiguous) return;
96
+ let effort: FinalResponse["effort"] = "unavailable";
97
+ try { const value = pi.getThinkingLevel(); if (EFFORTS.includes(value)) effort = value; } catch { /* No evidence. */ }
98
+ selection = { selectedProvider: owner.catalog ? ctx.model?.provider as FinalResponse["provider"] ?? "unknown" : "unknown",
99
+ selectedModelId: owner.catalog && typeof ctx.model?.id === "string" && ctx.model.id.length <= 128 ? ctx.model.id : undefined, effort };
100
+ });
101
+ function token(value: unknown): TokenMeasurement {
102
+ return typeof value === "number" && Number.isSafeInteger(value) && value > 0 && value <= 1_000_000_000
103
+ ? { state: "reported", value } : missing;
104
+ }
105
+ pi.on("message_end", (event, ctx) => {
106
+ try {
107
+ const owner = current(ctx);
108
+ const message = event.message;
109
+ if (!owner || message.role !== "assistant" || message.stopReason === "pending" || message.stopReason === "deferred"
110
+ || owner.seen.has(message)) return;
111
+ owner.seen.add(message);
112
+ refreshCatalog(owner, { provider: "openai", modelId: "gpt-4o" });
113
+ const selected = active && !ambiguous ? selection : undefined;
114
+ submit(owner, [{ kind: "final_assistant_response", responseId: "0",
115
+ selectedProvider: selected?.selectedProvider ?? "unknown", selectedModelId: selected?.selectedModelId,
116
+ effort: selected?.effort ?? "unavailable",
117
+ executor: env.GENTLE_PI_AGENTS_CHILD === undefined ? "orchestrator" : "unknown",
118
+ agentClass: env.GENTLE_PI_AGENTS_CHILD === undefined ? ORCHESTRATOR_AGENT_CLASS : UNKNOWN_AGENT_CLASS,
119
+ observedModelId: message.model, responseModelId: message.responseModel,
120
+ providerThinkingLevel: EFFORTS.includes(message.providerThinkingLevel as FinalResponse["effort"])
121
+ ? message.providerThinkingLevel as FinalResponse["effort"] : "unavailable",
122
+ provider: message.provider as FinalResponse["provider"], modelFamily: "unknown",
123
+ error: message.stopReason === "aborted" ? "aborted" : message.stopReason === "error" ? "unknown" : "none",
124
+ tokens: { input: token(message.usage?.input), output: token(message.usage?.output), cacheRead: token(message.usage?.cacheRead),
125
+ cacheWrite: token(message.usage?.cacheWrite), reasoning: token(message.usage?.reasoning), totalTokens: token(message.usage?.totalTokens) },
126
+ responseHeadersMs: missing, fullResponseMs: missing }]);
127
+ invalidate();
128
+ } catch { /* Provider hooks never expose telemetry failures. */ }
129
+ });
130
+ }
@@ -7,7 +7,7 @@ import {
7
7
  } from "node:fs";
8
8
  import { basename, dirname, join, relative } from "node:path";
9
9
  import { applySavedModelConfig } from "./gentle-ai.ts";
10
- import { ensureSddPreflight, installSddAssets } from "../lib/sdd-preflight.ts";
10
+ import { ensureSddPreflight, installPackageAssets } from "../lib/sdd-preflight.ts";
11
11
  type ExtensionAPI = any;
12
12
 
13
13
  const CONFIG_REL_PATH = "openspec/config.yaml";
@@ -775,7 +775,7 @@ export default function (pi: ExtensionAPI) {
775
775
  description:
776
776
  "Auto-detect project stack and bootstrap openspec/config.yaml for SDD.",
777
777
  handler: async (_args: unknown, ctx: any) => {
778
- const prefs = await ensureSddPreflight(ctx, { pi, installAssets: (cwd) => installSddAssets(cwd, false), applyModelConfig: () => applySavedModelConfig(ctx) }, { promptFields: [] });
778
+ const prefs = await ensureSddPreflight(ctx, { pi, installAssets: (cwd) => installPackageAssets(cwd, true, ["sdd"]), applyModelConfig: () => applySavedModelConfig(ctx) }, { promptFields: [] });
779
779
 
780
780
  const detection = detectProject(ctx.cwd);
781
781
  const testSummary = detection.testCommand
@@ -2,12 +2,10 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { VERSION } from "@earendil-works/pi-coding-agent";
3
3
  import { truncateToWidth } from "@earendil-works/pi-tui";
4
4
  import * as os from "node:os";
5
- import { exec } from "node:child_process";
6
- import { promisify } from "node:util";
5
+ import { execFile } from "node:child_process";
7
6
  import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
8
7
  import { join } from "node:path";
9
8
 
10
- const execAsync = promisify(exec);
11
9
  const PI_AGENT_DIR = join(os.homedir(), ".pi", "agent");
12
10
  const PI_NPM_DIR = join(PI_AGENT_DIR, "npm", "node_modules");
13
11
 
@@ -527,7 +525,26 @@ async function countPackageExtensions(packages: unknown[]): Promise<number> {
527
525
  return count;
528
526
  }
529
527
 
528
+ export function readGitBranch(cwd: string, run: typeof execFile = execFile): Promise<string> {
529
+ return new Promise((resolve) => {
530
+ run("git", ["-C", cwd, "branch", "--show-current"], {
531
+ encoding: "utf8",
532
+ shell: false,
533
+ windowsHide: true,
534
+ }, (error, stdout) => {
535
+ if (error) {
536
+ resolve("Not a git repo");
537
+ return;
538
+ }
539
+ const branch = String(stdout).trim();
540
+ resolve(branch ? `On branch ${branch}` : "Detached HEAD");
541
+ });
542
+ });
543
+ }
544
+
530
545
  export default function (pi: ExtensionAPI) {
546
+ let disposeHeader = () => {};
547
+ pi.on("session_shutdown", () => disposeHeader());
531
548
  const notifyBannerConfig = (ctx: any, config: BannerConfig) => {
532
549
  ctx.ui.notify(
533
550
  [
@@ -597,6 +614,7 @@ export default function (pi: ExtensionAPI) {
597
614
  registerColorCommand("gentle:banner-color");
598
615
 
599
616
  pi.on("session_start", async (_event, ctx) => {
617
+ disposeHeader();
600
618
  if (!ctx.hasUI) return;
601
619
 
602
620
  // Si se está ejecutando un comando de CLI como "pi update" o "pi install", no mostramos la intro animada.
@@ -607,16 +625,13 @@ export default function (pi: ExtensionAPI) {
607
625
 
608
626
  if (currentIntroMode() === "skip") return;
609
627
 
610
- // Fire-and-forget: el setup geométrico de cada letra corre en background
611
- // para que la animación arranque en el primer frame sin bloquear el event loop.
612
- void warmupLetterStrokes();
613
-
614
- process.stdout.write("\x1b[2J\x1b[3J\x1b[H");
615
-
628
+ // Pi has already started its renderer. Let setHeader schedule the paint;
629
+ // clearing stdout here would leave its previous-frame cache out of sync.
616
630
  const bannerConfig = await readBannerConfig();
617
631
  const palette = BANNER_PALETTES[bannerConfig.color];
618
632
  const roseBase = padLines(normalizeAscii(ROSE_LARGE_RAW));
619
633
  const logoBase = padLines(TEXT_LOGO);
634
+ void warmupLetterStrokes();
620
635
 
621
636
  let gitBranch = "Not a git repo";
622
637
  let mcpServersCount = 0;
@@ -632,12 +647,9 @@ export default function (pi: ExtensionAPI) {
632
647
  );
633
648
 
634
649
  setTimeout(() => {
635
- execAsync(`git -C "${ctx.cwd}" branch --show-current`)
636
- .then(({ stdout }) => {
637
- const b = stdout.trim();
638
- gitBranch = b ? `On branch ${b}` : "Detached HEAD";
639
- })
640
- .catch(() => {});
650
+ readGitBranch(ctx.cwd)
651
+ .then((branch) => { gitBranch = branch; })
652
+ .finally(() => refreshStats());
641
653
  }, 100);
642
654
 
643
655
  setTimeout(() => {
@@ -652,6 +664,7 @@ export default function (pi: ExtensionAPI) {
652
664
  } catch {
653
665
  mcpServersCount = 0;
654
666
  }
667
+ refreshStats();
655
668
  })();
656
669
  }, 150);
657
670
 
@@ -671,10 +684,12 @@ export default function (pi: ExtensionAPI) {
671
684
  extensionsCount = 0;
672
685
  packagesCount = 0;
673
686
  }
687
+ refreshStats();
674
688
  })();
675
689
  }, 200);
676
690
 
677
691
  let tick = 0;
692
+ let refreshStats = () => {};
678
693
  const state = {
679
694
  timer: null as NodeJS.Timeout | null,
680
695
  mode: currentIntroMode() as IntroMode,
@@ -683,6 +698,7 @@ export default function (pi: ExtensionAPI) {
683
698
  };
684
699
 
685
700
  const cleanup = () => {
701
+ refreshStats = () => {};
686
702
  if (state.timer) {
687
703
  clearInterval(state.timer);
688
704
  state.timer = null;
@@ -697,27 +713,21 @@ export default function (pi: ExtensionAPI) {
697
713
  }
698
714
  };
699
715
 
716
+ disposeHeader = cleanup;
700
717
  setTimeout(() => {
701
718
  ctx.ui.setHeader((tui, theme) => {
702
719
  if (state.timer) clearInterval(state.timer);
703
720
 
721
+ refreshStats = () => tui.requestRender();
704
722
  const animStart = Date.now();
705
- const HARD_TIMEOUT_MS = 5000;
706
-
707
723
  state.timer = setInterval(() => {
708
724
  tick++;
709
- const elapsed = Date.now() - animStart;
710
- const finishedAnimation =
711
- allStrokesReady() && tick > WRITING_END_TICK + 22;
712
- if (finishedAnimation || elapsed > HARD_TIMEOUT_MS) {
713
- cleanup();
714
- return;
715
- }
716
- try {
717
- tui.requestRender();
718
- } catch {
719
- cleanup();
725
+ const finished = allStrokesReady() && tick > WRITING_END_TICK + 22;
726
+ if (finished || Date.now() - animStart > 5000) {
727
+ clearInterval(state.timer!);
728
+ state.timer = null;
720
729
  }
730
+ try { tui.requestRender(); } catch { cleanup(); }
721
731
  }, 25);
722
732
 
723
733
  // Grace period: pi-tui emite resizes transitorios mientras compone su layout inicial.
@@ -730,11 +740,7 @@ export default function (pi: ExtensionAPI) {
730
740
  const next = currentIntroMode();
731
741
  if (next === state.mode) return;
732
742
  state.mode = next;
733
- if (next === "skip") {
734
- cleanup();
735
- process.stdout.write("\x1b[2J\x1b[3J\x1b[H");
736
- return;
737
- }
743
+ // Pi owns removal in skip mode; keep listening for later expansion.
738
744
  try {
739
745
  tui.requestRender();
740
746
  } catch {
@@ -758,9 +764,9 @@ export default function (pi: ExtensionAPI) {
758
764
  const frame = Math.floor(tick / 2);
759
765
 
760
766
  const sideBySideMinWidth = roseBase.width + 3 + logoBase.width + 4;
761
- const wideStatsMinWidth = 122;
762
767
  const horizontal =
763
768
  state.mode === "full" && bannerConfig.showRose && bannerConfig.showTextLogo && width >= sideBySideMinWidth;
769
+ const wideStatsMinWidth = 122;
764
770
  const wideStats = width >= wideStatsMinWidth;
765
771
 
766
772
  const b = new LayoutBuilder();
@@ -772,52 +778,27 @@ export default function (pi: ExtensionAPI) {
772
778
  const logoLine = logoBase.lines[logoI];
773
779
  b.addRow();
774
780
  b.lines[b.lines.length - 1].push(
775
- ...buildPenLogoLine(
776
- logoLine,
777
- logoI,
778
- logoBase.lines.length,
779
- tick,
780
- ),
781
+ ...buildPenLogoLine(logoLine, logoI, logoBase.lines.length, tick),
781
782
  );
782
783
  b.center(width);
783
784
  }
784
785
  } else if (horizontal) {
785
- const rowCount = Math.max(
786
- roseBase.lines.length,
787
- logoBase.lines.length,
788
- );
789
- const roseOffset = Math.max(
790
- 0,
791
- Math.floor((rowCount - roseBase.lines.length) / 2),
792
- );
793
- const logoOffset = Math.max(
794
- 0,
795
- Math.floor((rowCount - logoBase.lines.length) / 2),
796
- );
797
-
786
+ const rowCount = Math.max(roseBase.lines.length, logoBase.lines.length);
787
+ const roseOffset = Math.max(0, Math.floor((rowCount - roseBase.lines.length) / 2));
788
+ const logoOffset = Math.max(0, Math.floor((rowCount - logoBase.lines.length) / 2));
798
789
  for (let i = 0; i < rowCount; i++) {
799
790
  const roseI = i - roseOffset;
800
791
  const logoI = i - logoOffset;
801
- const roseLine =
802
- roseI >= 0 && roseI < roseBase.lines.length
803
- ? roseBase.lines[roseI]
804
- : " ".repeat(roseBase.width);
805
- const logoLine =
806
- logoI >= 0 && logoI < logoBase.lines.length
807
- ? logoBase.lines[logoI]
808
- : " ".repeat(logoBase.width);
809
-
792
+ const roseLine = roseI >= 0 && roseI < roseBase.lines.length
793
+ ? roseBase.lines[roseI] : " ".repeat(roseBase.width);
794
+ const logoLine = logoI >= 0 && logoI < logoBase.lines.length
795
+ ? logoBase.lines[logoI] : " ".repeat(logoBase.width);
810
796
  b.addRow();
811
797
  b.add("rose", roseLine);
812
798
  b.add("none", " ");
813
799
  if (logoI >= 0 && logoI < logoBase.lines.length) {
814
800
  b.lines[b.lines.length - 1].push(
815
- ...buildPenLogoLine(
816
- logoLine,
817
- logoI,
818
- logoBase.lines.length,
819
- tick,
820
- ),
801
+ ...buildPenLogoLine(logoLine, logoI, logoBase.lines.length, tick),
821
802
  );
822
803
  } else {
823
804
  b.add("none", " ".repeat(logoBase.width));
@@ -832,12 +813,7 @@ export default function (pi: ExtensionAPI) {
832
813
  const logoLine = logoBase.lines[logoI];
833
814
  b.addRow();
834
815
  b.lines[b.lines.length - 1].push(
835
- ...buildPenLogoLine(
836
- logoLine,
837
- logoI,
838
- logoBase.lines.length,
839
- tick,
840
- ),
816
+ ...buildPenLogoLine(logoLine, logoI, logoBase.lines.length, tick),
841
817
  );
842
818
  b.center(width);
843
819
  }
@@ -1075,7 +1051,8 @@ export default function (pi: ExtensionAPI) {
1075
1051
 
1076
1052
  return out;
1077
1053
  },
1078
- invalidate() {
1054
+ invalidate() {},
1055
+ dispose() {
1079
1056
  cleanup();
1080
1057
  },
1081
1058
  };