lsd-pi 1.3.7 → 1.3.10

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 (92) hide show
  1. package/README.md +82 -0
  2. package/dist/resources/extensions/mcp-client/index.js +230 -54
  3. package/dist/resources/extensions/mcp-client/mcp-manager-component.js +220 -0
  4. package/dist/resources/extensions/slash-commands/plan.js +72 -18
  5. package/dist/resources/extensions/subagent/agents.js +7 -0
  6. package/dist/resources/extensions/subagent/index.js +25 -8
  7. package/dist/resources/extensions/subagent/model-resolution.js +1 -0
  8. package/dist/resources/extensions/usage/index.js +34 -2
  9. package/dist/resources/extensions/voice/index.js +1 -0
  10. package/dist/resources/extensions/voice/push-to-talk.js +2 -0
  11. package/package.json +1 -1
  12. package/packages/pi-coding-agent/dist/core/agent-session.context-usage.test.d.ts +2 -0
  13. package/packages/pi-coding-agent/dist/core/agent-session.context-usage.test.d.ts.map +1 -0
  14. package/packages/pi-coding-agent/dist/core/agent-session.context-usage.test.js +72 -0
  15. package/packages/pi-coding-agent/dist/core/agent-session.context-usage.test.js.map +1 -0
  16. package/packages/pi-coding-agent/dist/core/agent-session.d.ts +4 -0
  17. package/packages/pi-coding-agent/dist/core/agent-session.d.ts.map +1 -1
  18. package/packages/pi-coding-agent/dist/core/agent-session.js +29 -2
  19. package/packages/pi-coding-agent/dist/core/agent-session.js.map +1 -1
  20. package/packages/pi-coding-agent/dist/core/extensions/runner.d.ts.map +1 -1
  21. package/packages/pi-coding-agent/dist/core/extensions/runner.js +1 -0
  22. package/packages/pi-coding-agent/dist/core/extensions/runner.js.map +1 -1
  23. package/packages/pi-coding-agent/dist/core/extensions/types.d.ts +2 -0
  24. package/packages/pi-coding-agent/dist/core/extensions/types.d.ts.map +1 -1
  25. package/packages/pi-coding-agent/dist/core/extensions/types.js.map +1 -1
  26. package/packages/pi-coding-agent/dist/core/tool-priority.js +1 -1
  27. package/packages/pi-coding-agent/dist/core/tool-priority.js.map +1 -1
  28. package/packages/pi-coding-agent/dist/main.d.ts.map +1 -1
  29. package/packages/pi-coding-agent/dist/main.js +1 -0
  30. package/packages/pi-coding-agent/dist/main.js.map +1 -1
  31. package/packages/pi-coding-agent/dist/modes/interactive/components/__tests__/tool-summary-line.test.js +104 -2
  32. package/packages/pi-coding-agent/dist/modes/interactive/components/__tests__/tool-summary-line.test.js.map +1 -1
  33. package/packages/pi-coding-agent/dist/modes/interactive/components/assistant-message.d.ts +39 -2
  34. package/packages/pi-coding-agent/dist/modes/interactive/components/assistant-message.d.ts.map +1 -1
  35. package/packages/pi-coding-agent/dist/modes/interactive/components/assistant-message.js +135 -18
  36. package/packages/pi-coding-agent/dist/modes/interactive/components/assistant-message.js.map +1 -1
  37. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-execution.d.ts +2 -0
  38. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-execution.d.ts.map +1 -1
  39. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-execution.js +17 -1
  40. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-execution.js.map +1 -1
  41. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-summary-line.d.ts +21 -2
  42. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-summary-line.d.ts.map +1 -1
  43. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-summary-line.js +147 -9
  44. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-summary-line.js.map +1 -1
  45. package/packages/pi-coding-agent/dist/modes/interactive/controllers/__tests__/chat-controller.collapsed-tool-summary.test.js +51 -13
  46. package/packages/pi-coding-agent/dist/modes/interactive/controllers/__tests__/chat-controller.collapsed-tool-summary.test.js.map +1 -1
  47. package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.d.ts.map +1 -1
  48. package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.js +112 -18
  49. package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.js.map +1 -1
  50. package/packages/pi-coding-agent/dist/modes/interactive/controllers/extension-ui-controller.d.ts.map +1 -1
  51. package/packages/pi-coding-agent/dist/modes/interactive/controllers/extension-ui-controller.js +1 -0
  52. package/packages/pi-coding-agent/dist/modes/interactive/controllers/extension-ui-controller.js.map +1 -1
  53. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode-state.d.ts +4 -0
  54. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode-state.d.ts.map +1 -1
  55. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode-state.js.map +1 -1
  56. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts +4 -0
  57. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  58. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js +34 -4
  59. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js.map +1 -1
  60. package/packages/pi-coding-agent/dist/modes/rpc/rpc-mode.d.ts.map +1 -1
  61. package/packages/pi-coding-agent/dist/modes/rpc/rpc-mode.js +3 -0
  62. package/packages/pi-coding-agent/dist/modes/rpc/rpc-mode.js.map +1 -1
  63. package/packages/pi-coding-agent/package.json +1 -1
  64. package/packages/pi-coding-agent/src/core/agent-session.context-usage.test.ts +87 -0
  65. package/packages/pi-coding-agent/src/core/agent-session.ts +40 -2
  66. package/packages/pi-coding-agent/src/core/extensions/runner.ts +1 -0
  67. package/packages/pi-coding-agent/src/core/extensions/types.ts +3 -0
  68. package/packages/pi-coding-agent/src/core/tool-priority.ts +1 -1
  69. package/packages/pi-coding-agent/src/main.ts +1 -0
  70. package/packages/pi-coding-agent/src/modes/interactive/components/__tests__/tool-summary-line.test.ts +129 -2
  71. package/packages/pi-coding-agent/src/modes/interactive/components/assistant-message.ts +158 -18
  72. package/packages/pi-coding-agent/src/modes/interactive/components/tool-execution.ts +18 -1
  73. package/packages/pi-coding-agent/src/modes/interactive/components/tool-summary-line.ts +164 -10
  74. package/packages/pi-coding-agent/src/modes/interactive/controllers/__tests__/chat-controller.collapsed-tool-summary.test.ts +60 -13
  75. package/packages/pi-coding-agent/src/modes/interactive/controllers/chat-controller.ts +123 -20
  76. package/packages/pi-coding-agent/src/modes/interactive/controllers/extension-ui-controller.ts +1 -0
  77. package/packages/pi-coding-agent/src/modes/interactive/interactive-mode-state.ts +1 -0
  78. package/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +34 -4
  79. package/packages/pi-coding-agent/src/modes/rpc/rpc-mode.ts +4 -0
  80. package/pkg/package.json +1 -1
  81. package/src/resources/extensions/mcp-client/index.ts +259 -58
  82. package/src/resources/extensions/mcp-client/mcp-manager-component.ts +256 -0
  83. package/src/resources/extensions/mcp-client/tests/mcp-manager-component.test.ts +141 -0
  84. package/src/resources/extensions/mcp-client/tests/server-name-spaces.test.ts +32 -0
  85. package/src/resources/extensions/slash-commands/plan.ts +76 -19
  86. package/src/resources/extensions/subagent/agents.ts +9 -0
  87. package/src/resources/extensions/subagent/index.ts +30 -8
  88. package/src/resources/extensions/subagent/model-resolution.ts +1 -0
  89. package/src/resources/extensions/usage/index.ts +40 -2
  90. package/src/resources/extensions/voice/index.ts +1 -0
  91. package/src/resources/extensions/voice/push-to-talk.ts +3 -0
  92. package/src/resources/extensions/voice/tests/push-to-talk.test.ts +6 -0
@@ -0,0 +1,220 @@
1
+ import { Key, SelectList, matchesKey, truncateToWidth } from "@gsd/pi-tui";
2
+ function getSelectListTheme(theme) {
3
+ return {
4
+ selectedPrefix: (text) => theme.fg("accent", text),
5
+ selectedText: (text) => theme.fg("accent", text),
6
+ description: (text) => theme.fg("muted", text),
7
+ scrollInfo: (text) => theme.fg("dim", text),
8
+ noMatch: (text) => theme.fg("warning", text),
9
+ };
10
+ }
11
+ function serversToItems(servers) {
12
+ return servers.map((server) => ({
13
+ value: server.name,
14
+ label: server.name,
15
+ description: [
16
+ server.enabled ? "enabled" : "disabled",
17
+ server.transport,
18
+ server.connected ? "● connected" : "○ offline",
19
+ `${server.toolCount} tools`,
20
+ server.sourceLabel || undefined,
21
+ ].filter(Boolean).join(" "),
22
+ }));
23
+ }
24
+ export class McpManagerComponent {
25
+ theme;
26
+ callbacks;
27
+ selectList;
28
+ mode = "list";
29
+ inspectServerName = "";
30
+ inspectLines = [];
31
+ inspectScrollOffset = 0;
32
+ statusMessage = "";
33
+ busy = false;
34
+ statusTimeout = null;
35
+ constructor(callbacks, theme) {
36
+ this.callbacks = callbacks;
37
+ this.theme = theme;
38
+ this.selectList = new SelectList([], 8, getSelectListTheme(theme));
39
+ this.bindSelectList();
40
+ this.refreshList();
41
+ }
42
+ invalidate() {
43
+ this.selectList.invalidate();
44
+ }
45
+ dispose() {
46
+ if (this.statusTimeout) {
47
+ clearTimeout(this.statusTimeout);
48
+ this.statusTimeout = null;
49
+ }
50
+ }
51
+ getMode() {
52
+ return this.mode;
53
+ }
54
+ handleInput(data) {
55
+ if (this.mode === "inspect") {
56
+ this.handleInspectInput(data);
57
+ return;
58
+ }
59
+ if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
60
+ this.callbacks.onClose();
61
+ return;
62
+ }
63
+ if (data === "i") {
64
+ void this.handleInspect();
65
+ return;
66
+ }
67
+ if (data === "r") {
68
+ void this.handleReconnect();
69
+ return;
70
+ }
71
+ this.selectList.handleInput(data);
72
+ this.callbacks.requestRender();
73
+ }
74
+ render(width) {
75
+ const lines = [];
76
+ const add = (line = "") => lines.push(truncateToWidth(line, width));
77
+ const divider = this.theme.fg("border", "─".repeat(Math.max(width, 1)));
78
+ add(divider);
79
+ if (this.mode === "inspect") {
80
+ add(this.theme.bold(this.theme.fg("toolTitle", ` MCP Tools · ${this.inspectServerName}`)) +
81
+ this.theme.fg("dim", " esc/q: back ↑↓/pgup/pgdn/home/end: scroll"));
82
+ add("");
83
+ const bodyHeight = Math.max(8, width > 0 ? 18 : 8);
84
+ const maxOffset = Math.max(0, this.inspectLines.length - bodyHeight);
85
+ this.inspectScrollOffset = Math.max(0, Math.min(this.inspectScrollOffset, maxOffset));
86
+ const visibleLines = this.inspectLines.slice(this.inspectScrollOffset, this.inspectScrollOffset + bodyHeight);
87
+ for (const line of visibleLines)
88
+ add(line);
89
+ if (visibleLines.length === 0)
90
+ add(this.theme.fg("dim", " No tool information"));
91
+ add("");
92
+ add(divider);
93
+ add(this.theme.fg("dim", ` ${this.inspectLines.length} lines`));
94
+ return lines;
95
+ }
96
+ add(this.theme.bold(this.theme.fg("toolTitle", " MCP Servers")) +
97
+ this.theme.fg("dim", " ↑↓ navigate enter: toggle i: inspect r: reconnect esc: close"));
98
+ add("");
99
+ lines.push(...this.selectList.render(width));
100
+ add("");
101
+ add(divider);
102
+ const servers = this.callbacks.getServers();
103
+ const enabled = servers.filter((server) => server.enabled).length;
104
+ let footer = this.theme.fg("dim", ` ${servers.length} servers · ${enabled} enabled`);
105
+ if (this.busy)
106
+ footer += this.theme.fg("accent", " · working…");
107
+ if (this.statusMessage)
108
+ footer += this.theme.fg("accent", ` — ${this.statusMessage}`);
109
+ add(footer);
110
+ return lines;
111
+ }
112
+ bindSelectList() {
113
+ this.selectList.onSelect = () => {
114
+ void this.handleToggle();
115
+ };
116
+ this.selectList.onCancel = () => {
117
+ this.callbacks.onClose();
118
+ };
119
+ }
120
+ refreshList(preferredName) {
121
+ const currentSelected = preferredName ?? this.selectList.getSelectedItem()?.value;
122
+ this.selectList = new SelectList(serversToItems(this.callbacks.getServers()), 8, getSelectListTheme(this.theme));
123
+ this.bindSelectList();
124
+ if (currentSelected) {
125
+ const items = this.callbacks.getServers();
126
+ const index = items.findIndex((item) => item.name === currentSelected);
127
+ if (index >= 0)
128
+ this.selectList.setSelectedIndex(index);
129
+ }
130
+ this.callbacks.requestRender();
131
+ }
132
+ setStatus(message) {
133
+ this.statusMessage = message;
134
+ this.callbacks.requestRender();
135
+ if (this.statusTimeout)
136
+ clearTimeout(this.statusTimeout);
137
+ if (!message)
138
+ return;
139
+ this.statusTimeout = setTimeout(() => {
140
+ this.statusMessage = "";
141
+ this.callbacks.requestRender();
142
+ }, 3000);
143
+ this.statusTimeout.unref?.();
144
+ }
145
+ getSelectedName() {
146
+ return this.selectList.getSelectedItem()?.value;
147
+ }
148
+ async runBusy(task) {
149
+ if (this.busy)
150
+ return;
151
+ this.busy = true;
152
+ this.callbacks.requestRender();
153
+ try {
154
+ await task();
155
+ }
156
+ finally {
157
+ this.busy = false;
158
+ this.callbacks.requestRender();
159
+ }
160
+ }
161
+ async handleToggle() {
162
+ const name = this.getSelectedName();
163
+ if (!name)
164
+ return;
165
+ await this.runBusy(async () => {
166
+ this.setStatus(`Toggling ${name}...`);
167
+ const updated = await this.callbacks.onToggle(name);
168
+ this.refreshList(updated?.name ?? name);
169
+ if (updated) {
170
+ this.setStatus(`${updated.name}: ${updated.enabled ? "enabled" : "disabled"}`);
171
+ }
172
+ });
173
+ }
174
+ async handleInspect() {
175
+ const name = this.getSelectedName();
176
+ if (!name)
177
+ return;
178
+ await this.runBusy(async () => {
179
+ this.setStatus(`Loading tools for ${name}...`);
180
+ const text = await this.callbacks.onInspect(name);
181
+ this.inspectServerName = name;
182
+ this.inspectLines = text.split("\n");
183
+ this.inspectScrollOffset = 0;
184
+ this.mode = "inspect";
185
+ this.setStatus("");
186
+ });
187
+ }
188
+ async handleReconnect() {
189
+ const name = this.getSelectedName();
190
+ if (!name)
191
+ return;
192
+ await this.runBusy(async () => {
193
+ this.setStatus(`Reconnecting ${name}...`);
194
+ const updated = await this.callbacks.onReconnect(name);
195
+ this.refreshList(updated?.name ?? name);
196
+ this.setStatus(updated ? `${updated.name}: reconnected` : `${name}: reconnect failed`);
197
+ });
198
+ }
199
+ handleInspectInput(data) {
200
+ if (matchesKey(data, Key.escape) || data === "q") {
201
+ this.mode = "list";
202
+ this.callbacks.requestRender();
203
+ return;
204
+ }
205
+ const page = 12;
206
+ if (matchesKey(data, Key.up))
207
+ this.inspectScrollOffset -= 1;
208
+ else if (matchesKey(data, Key.down))
209
+ this.inspectScrollOffset += 1;
210
+ else if (matchesKey(data, Key.pageUp))
211
+ this.inspectScrollOffset -= page;
212
+ else if (matchesKey(data, Key.pageDown))
213
+ this.inspectScrollOffset += page;
214
+ else if (matchesKey(data, Key.home))
215
+ this.inspectScrollOffset = 0;
216
+ else if (matchesKey(data, Key.end))
217
+ this.inspectScrollOffset = Number.MAX_SAFE_INTEGER;
218
+ this.callbacks.requestRender();
219
+ }
220
+ }
@@ -155,7 +155,7 @@ function readAutoSwitchPlanModelSetting() {
155
155
  return false;
156
156
  const raw = readFileSync(settingsPath, "utf-8");
157
157
  const parsed = JSON.parse(raw);
158
- return parsed.autoSwitchPlanModel === true;
158
+ return parsed.planModeAutoSwitchModel === true;
159
159
  }
160
160
  catch {
161
161
  return false;
@@ -277,7 +277,7 @@ async function setModelIfNeeded(pi, ctx, modelRef) {
277
277
  return true;
278
278
  }
279
279
  function buildExecutionKickoffMessage(options) {
280
- const { permissionMode, executeWithSubagent = false } = options;
280
+ const { permissionMode, executeWithSubagent = false, executionNote } = options;
281
281
  const task = state.task.trim();
282
282
  if (!executeWithSubagent) {
283
283
  const details = [
@@ -287,6 +287,9 @@ function buildExecutionKickoffMessage(options) {
287
287
  details.push(`Original task: ${task}`);
288
288
  if (state.latestPlanPath)
289
289
  details.push(`Use the approved plan artifact at ${state.latestPlanPath} as the execution plan.`);
290
+ if (executionNote)
291
+ details.push(`User execution note: ${executionNote}`);
292
+ details.push("After implementation: guide the user through verification by presenting a concise checklist based on the plan's Acceptance Criteria and Verification Plan. Run applicable checks (build, lint, tests) and report results.");
290
293
  return details.join(" ");
291
294
  }
292
295
  const codingModel = readPlanModeCodingModel();
@@ -303,13 +306,24 @@ function buildExecutionKickoffMessage(options) {
303
306
  details.push(`Original task: ${task}`);
304
307
  if (state.latestPlanPath)
305
308
  details.push(`Primary plan artifact: ${state.latestPlanPath}`);
309
+ if (executionNote) {
310
+ details.push(`User execution note: ${executionNote}`);
311
+ // If the note contains a model request, surface it explicitly so the agent
312
+ // doesn't silently drop it. The model override will be resolved by
313
+ // normalizeSubagentModel when the subagent tool is invoked.
314
+ const modelMatch = executionNote.match(/\b(?:model|use\s+model)\s+["']?([\w.-]+(?:\/[\w.-]+)?)["']?\b/i);
315
+ if (modelMatch?.[1]) {
316
+ details.push(`The user explicitly requested model "${modelMatch[1]}". You MUST pass model="${modelMatch[1]}" in the subagent tool call. If normalizeSubagentModel cannot resolve this model, report the error to the user instead of silently falling back.`);
317
+ }
318
+ }
306
319
  details.push("Important: if the plan is large and you estimate it would exceed a single subagent's context window (~200k tokens), " +
307
320
  "split execution across multiple sequential subagents instead of one. " +
308
321
  "Use the subagent tool's chain mode: pass a \"chain\" array where each entry covers one self-contained phase or group of steps from the plan. " +
309
322
  "Each chain entry should include the agent name, a focused task description for that phase, and may reference {previous} to receive the prior phase's output as handoff context. " +
310
323
  "Only split when genuinely needed — prefer a single subagent for plans that fit comfortably.");
311
324
  details.push("After all subagents complete: (1) do a quick review of the implementation — check that the plan steps were actually carried out, spot obvious issues or missed pieces, and verify the code compiles/passes lint if applicable. " +
312
- "(2) Then summarize what was done, what (if anything) needs follow-up, and flag any concerns found during review.");
325
+ "(2) Then guide the user through verification: present a concise checklist based on the plan's Acceptance Criteria and Verification Plan sections. Run applicable checks (build, lint, tests) and report results. " +
326
+ "(3) Summarize what was done, what (if anything) needs follow-up, and flag any concerns found during review.");
313
327
  return details.join(" ");
314
328
  }
315
329
  let pendingNewSession = null;
@@ -331,11 +345,11 @@ function scheduleNewSession(pi, ctx) {
331
345
  // Must use the /prefix so tryExecuteExtensionCommand parses the name correctly.
332
346
  pi.executeSlashCommand("/plan-execute-new-session");
333
347
  }
334
- async function approvePlan(pi, ctx, permissionMode, executeWithSubagent = false) {
335
- const reasoningModel = parseQualifiedModelRef(readPlanModeReasoningModel());
336
- if (reasoningModel) {
337
- await setModelIfNeeded(pi, ctx, reasoningModel);
338
- }
348
+ async function approvePlan(pi, ctx, permissionMode, executeWithSubagent = false, executionNote) {
349
+ // Do NOT switch to reasoning model during execution.
350
+ // The reasoning model is only for plan-mode investigation, not execution.
351
+ // If a coding model is configured and we're using a subagent, the explicit
352
+ // model="<planModeCodingModel>" in the kickoff message will handle it.
339
353
  state = {
340
354
  ...state,
341
355
  targetPermissionMode: permissionMode,
@@ -347,7 +361,7 @@ async function approvePlan(pi, ctx, permissionMode, executeWithSubagent = false)
347
361
  // subagent tool with the default session model BEFORE it ever sees the
348
362
  // explicit model="<planModeCodingModel>" instruction. Steering ensures the
349
363
  // configured plan-mode coding model reaches the subagent invocation.
350
- await pi.sendUserMessage(buildExecutionKickoffMessage({ permissionMode, executeWithSubagent }), { deliverAs: "steer" });
364
+ await pi.sendUserMessage(buildExecutionKickoffMessage({ permissionMode, executeWithSubagent, executionNote }), { deliverAs: "steer" });
351
365
  }
352
366
  async function cancelPlan(pi, ctx, clearTask = true) {
353
367
  const restoreMode = state.previousMode ?? "accept-on-edit";
@@ -359,10 +373,12 @@ async function cancelPlan(pi, ctx, clearTask = true) {
359
373
  function buildPlanModeSystemPrompt() {
360
374
  const details = [
361
375
  "You are currently in plan mode.",
376
+ "Terse output. All technical substance stays. Only fluff dies. Fragments OK.",
362
377
  "Investigate, clarify scope, and produce a persisted execution plan before making source changes.",
363
378
  "If requirements are ambiguous or constraints are missing, ask concise clarifying questions before drafting or saving a plan.",
364
379
  `Before writing or updating a plan artifact, make sure your confidence is at least ${MIN_PLAN_CONFIDENCE}/10. If confidence is lower, investigate more or ask clarifying questions first.`,
365
380
  "Include an explicit confidence line in every saved plan, for example: \"Confidence: 8/10\" or higher.",
381
+ "Every saved plan MUST include explicit \"Acceptance Criteria\" and \"Verification Plan\" sections. Plans missing these sections will be rejected for approval.",
366
382
  "When adjusting an existing saved plan, prefer the edit tool for targeted changes. Rewrite the whole file only when the structure changes substantially or an exact edit is impractical.",
367
383
  "Do not modify source files or run side-effect commands while plan mode is active.",
368
384
  "Persist plan artifacts under .lsd/plan/.",
@@ -411,6 +427,29 @@ function buildApprovalActionInstructions() {
411
427
  function buildApprovalDialogInstructions() {
412
428
  return buildApprovalActionInstructions();
413
429
  }
430
+ /** Required heading patterns for plan artifacts. Matches common variants. */
431
+ const REQUIRED_PLAN_SECTIONS = [
432
+ { pattern: /\b(acceptance\s*criteria|success\s*criteria|done\s*criteria)\b/i, label: "Acceptance Criteria" },
433
+ { pattern: /\b(verification\s*(plan|steps|strategy)|how\s+to\s+verify|testing\s*plan)\b/i, label: "Verification Plan" },
434
+ ];
435
+ function validatePlanArtifact(markdown) {
436
+ const missing = [];
437
+ for (const section of REQUIRED_PLAN_SECTIONS) {
438
+ if (!section.pattern.test(markdown)) {
439
+ missing.push(section.label);
440
+ }
441
+ }
442
+ return { valid: missing.length === 0, missing };
443
+ }
444
+ function buildPlanValidationSteeringMessage(planPath, missing) {
445
+ const missingList = missing.map((m) => `- **${m}**`).join("\n");
446
+ return [
447
+ `Plan artifact saved at ${planPath}.`,
448
+ `The plan is missing required sections before it can be approved for implementation:`,
449
+ missingList,
450
+ `Please revise the plan to include these sections, then re-save. Do not ask for approval until all required sections are present.`,
451
+ ].join("\n\n");
452
+ }
414
453
  function buildApprovalSteeringMessage(planPath) {
415
454
  return [
416
455
  `Plan artifact saved at ${planPath}.`,
@@ -621,13 +660,26 @@ export default function planCommand(pi) {
621
660
  return;
622
661
  }
623
662
  const planMarkdown = readPlanArtifact(path);
624
- pi.sendMessage({
625
- customType: "plan-mode-preview",
626
- content: buildPlanPreviewMessage(path, planMarkdown),
627
- display: true,
628
- });
629
- ctx.ui?.notify?.("/plan to show plan", "info");
630
- pi.sendUserMessage(buildApprovalSteeringMessage(path), { deliverAs: "steer" });
663
+ if (!planMarkdown) {
664
+ ctx.ui?.notify?.("Plan artifact could not be read for validation", "warning");
665
+ pi.sendUserMessage(buildApprovalSteeringMessage(path), { deliverAs: "steer" });
666
+ }
667
+ else {
668
+ const validation = validatePlanArtifact(planMarkdown);
669
+ if (!validation.valid) {
670
+ ctx.ui?.notify?.("Plan missing required sections — see guidance below", "warning");
671
+ pi.sendUserMessage(buildPlanValidationSteeringMessage(path, validation.missing), { deliverAs: "steer" });
672
+ }
673
+ else {
674
+ pi.sendMessage({
675
+ customType: "plan-mode-preview",
676
+ content: buildPlanPreviewMessage(path, planMarkdown),
677
+ display: true,
678
+ });
679
+ ctx.ui?.notify?.("/plan to show plan", "info");
680
+ pi.sendUserMessage(buildApprovalSteeringMessage(path), { deliverAs: "steer" });
681
+ }
682
+ }
631
683
  }
632
684
  return;
633
685
  }
@@ -676,12 +728,13 @@ export default function planCommand(pi) {
676
728
  permissionMode: DEFAULT_APPROVAL_PERMISSION_MODE,
677
729
  executeWithSubagent: false,
678
730
  };
731
+ const permissionNote = getAnswerNote(permissionAnswer);
679
732
  state = { ...state, targetPermissionMode: executionMode.permissionMode };
680
733
  if (executionMode.executeWithSubagent) {
681
734
  const modeLabel = executionMode.permissionMode === "danger-full-access" ? "bypass" : "auto";
682
735
  ctx.ui?.notify?.(`Plan approved: subagent(${modeLabel})`, "info");
683
736
  }
684
- await approvePlan(pi, ctx, executionMode.permissionMode, executionMode.executeWithSubagent);
737
+ await approvePlan(pi, ctx, executionMode.permissionMode, executionMode.executeWithSubagent, permissionNote);
685
738
  return;
686
739
  }
687
740
  // ── First question answered (action) ──────────────────────────────────
@@ -699,12 +752,13 @@ export default function planCommand(pi) {
699
752
  permissionMode: DEFAULT_APPROVAL_PERMISSION_MODE,
700
753
  executeWithSubagent: false,
701
754
  };
755
+ const actionNote = getAnswerNote(actionAnswer);
702
756
  state = { ...state, targetPermissionMode: executionMode.permissionMode };
703
757
  if (executionMode.executeWithSubagent) {
704
758
  const modeLabel = executionMode.permissionMode === "danger-full-access" ? "bypass" : "auto";
705
759
  ctx.ui?.notify?.(`Plan approved: subagent(${modeLabel})`, "info");
706
760
  }
707
- await approvePlan(pi, ctx, executionMode.permissionMode, executionMode.executeWithSubagent);
761
+ await approvePlan(pi, ctx, executionMode.permissionMode, executionMode.executeWithSubagent, actionNote);
708
762
  return;
709
763
  }
710
764
  if (actionSelection.includes(REVIEW_LABEL)) {
@@ -6,6 +6,8 @@ import * as path from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { getAgentDir, parseFrontmatter } from "@gsd/pi-coding-agent";
8
8
  const PROJECT_AGENT_DIR_CANDIDATES = [".lsd", ".gsd", ".pi"];
9
+ /** Fixed read-only tool set for the reserved `scout` agent. */
10
+ const SCOUT_ALLOWED_TOOLS = ["read", "lsp", "grep", "find", "ls"];
9
11
  function normalizeAgentModel(model) {
10
12
  const trimmed = model?.trim();
11
13
  if (!trimmed)
@@ -117,6 +119,11 @@ export function discoverAgents(cwd, scope) {
117
119
  else {
118
120
  addAgents(projectAgents);
119
121
  }
122
+ // Enforce reserved agent tool policies — scout is always read-only
123
+ const scout = agentMap.get("scout");
124
+ if (scout) {
125
+ scout.tools = [...SCOUT_ALLOWED_TOOLS];
126
+ }
120
127
  return { agents: Array.from(agentMap.values()), projectAgentsDir };
121
128
  }
122
129
  export function formatAgentList(agents, maxItems) {
@@ -997,11 +997,13 @@ export default function (pi) {
997
997
  const hasTasks = (params.tasks?.length ?? 0) > 0;
998
998
  const hasSingle = Boolean(params.agent && params.task);
999
999
  const modeCount = Number(hasChain) + Number(hasTasks) + Number(hasSingle);
1000
- const makeDetails = (mode) => (results) => ({
1000
+ const makeDetails = (mode) => (results, opts) => ({
1001
1001
  mode,
1002
1002
  agentScope,
1003
1003
  projectAgentsDir: discovery.projectAgentsDir,
1004
1004
  results,
1005
+ totalChainSteps: opts?.totalSteps,
1006
+ currentChainStep: opts?.currentStep,
1005
1007
  });
1006
1008
  const trackInProcessDepth = (started, depth, ancestry) => {
1007
1009
  const sessionId = started.handle.sessionId;
@@ -1090,7 +1092,8 @@ export default function (pi) {
1090
1092
  for (let i = 0; i < params.chain.length; i++) {
1091
1093
  const step = params.chain[i];
1092
1094
  const taskWithContext = step.task.replace(/\{previous\}/g, previousOutput);
1093
- // Create update callback that includes all previous results
1095
+ // Create update callback that includes all previous results + chain progress
1096
+ const chainTotalSteps = params.chain.length;
1094
1097
  const chainUpdate = onUpdate
1095
1098
  ? (partial) => {
1096
1099
  // Combine completed results with current streaming result
@@ -1099,7 +1102,10 @@ export default function (pi) {
1099
1102
  const allResults = [...results, currentResult];
1100
1103
  onUpdate({
1101
1104
  content: partial.content,
1102
- details: makeDetails("chain")(allResults),
1105
+ details: makeDetails("chain")(allResults, {
1106
+ totalSteps: chainTotalSteps,
1107
+ currentStep: i + 1,
1108
+ }),
1103
1109
  });
1104
1110
  }
1105
1111
  }
@@ -1117,7 +1123,7 @@ export default function (pi) {
1117
1123
  const errorMsg = result.errorMessage || result.stderr || getFinalOutput(result.messages) || "(no output)";
1118
1124
  return {
1119
1125
  content: [{ type: "text", text: `Chain stopped at step ${i + 1} (${step.agent}): ${errorMsg}` }],
1120
- details: makeDetails("chain")(results),
1126
+ details: makeDetails("chain")(results, { totalSteps: params.chain.length }),
1121
1127
  isError: true,
1122
1128
  };
1123
1129
  }
@@ -1125,7 +1131,7 @@ export default function (pi) {
1125
1131
  }
1126
1132
  return {
1127
1133
  content: [{ type: "text", text: getFinalOutput(results[results.length - 1].messages) || "(no output)" }],
1128
- details: makeDetails("chain")(results),
1134
+ details: makeDetails("chain")(results, { totalSteps: params.chain.length }),
1129
1135
  };
1130
1136
  }
1131
1137
  if (params.tasks && params.tasks.length > 0) {
@@ -1653,13 +1659,24 @@ export default function (pi) {
1653
1659
  };
1654
1660
  if (details.mode === "chain") {
1655
1661
  const successCount = details.results.filter((r) => r.exitCode === 0).length;
1656
- const icon = successCount === details.results.length ? theme.fg("success", "✓") : theme.fg("error", "✗");
1662
+ const isRunning = details.currentChainStep != null;
1663
+ const icon = !isRunning && successCount === details.results.length
1664
+ ? theme.fg("success", "✓")
1665
+ : isRunning
1666
+ ? theme.fg("accent", "◉")
1667
+ : theme.fg("error", "✗");
1668
+ const totalLabel = details.totalChainSteps != null
1669
+ ? `${details.results.length}/${details.totalChainSteps} steps`
1670
+ : `${details.results.length} steps`;
1671
+ const progressLabel = isRunning
1672
+ ? ` (step ${details.currentChainStep} running)`
1673
+ : "";
1657
1674
  if (expanded) {
1658
1675
  const container = new Container();
1659
1676
  container.addChild(new Text(icon +
1660
1677
  " " +
1661
1678
  theme.fg("toolTitle", theme.bold("chain ")) +
1662
- theme.fg("accent", `${successCount}/${details.results.length} steps`), 0, 0));
1679
+ theme.fg("accent", `${totalLabel}${progressLabel}`), 0, 0));
1663
1680
  for (const r of details.results) {
1664
1681
  const rIcon = r.exitCode === 0 ? theme.fg("success", "✓") : theme.fg("error", "✗");
1665
1682
  const displayItems = getDisplayItems(r.messages);
@@ -1693,7 +1710,7 @@ export default function (pi) {
1693
1710
  let text = icon +
1694
1711
  " " +
1695
1712
  theme.fg("toolTitle", theme.bold("chain ")) +
1696
- theme.fg("accent", `${successCount}/${details.results.length} steps`);
1713
+ theme.fg("accent", `${totalLabel}${progressLabel}`);
1697
1714
  for (const r of details.results) {
1698
1715
  const rIcon = r.exitCode === 0 ? theme.fg("success", "✓") : theme.fg("error", "✗");
1699
1716
  const displayItems = getDisplayItems(r.messages);
@@ -11,6 +11,7 @@ const BARE_MODEL_PROVIDER_RULES = [
11
11
  matches: (modelId) => /^(mistral-|ministral-|codestral-)/.test(modelId),
12
12
  },
13
13
  { provider: "groq", matches: (modelId) => modelId.startsWith("llama-") || modelId.startsWith("mixtral-") },
14
+ { provider: "zhipu", matches: (modelId) => modelId.startsWith("glm-") },
14
15
  ];
15
16
  export function inferProviderForBareModel(modelId) {
16
17
  const normalizedModelId = modelId.trim().toLowerCase();
@@ -231,6 +231,7 @@ function collectUsage(sessionFiles, startMs, endMs, scope, groupBy) {
231
231
  let projectLabel = basename(file);
232
232
  let headerResolved = false;
233
233
  let currentModel = "";
234
+ let lastUserTimestamp = 0;
234
235
  const raw = readFileSync(file, "utf-8");
235
236
  for (const line of raw.split("\n")) {
236
237
  if (!line.trim())
@@ -282,6 +283,8 @@ function collectUsage(sessionFiles, startMs, endMs, scope, groupBy) {
282
283
  cacheWrite: 0,
283
284
  total: 0,
284
285
  cost: 0,
286
+ totalDurationMs: 0,
287
+ totalOutputForSpeed: 0,
285
288
  };
286
289
  existing.messages += 1;
287
290
  existing.input += input;
@@ -290,11 +293,25 @@ function collectUsage(sessionFiles, startMs, endMs, scope, groupBy) {
290
293
  existing.cacheWrite += cacheWrite;
291
294
  existing.total += total;
292
295
  existing.cost += cost;
296
+ // Track tok/sec: use preceding user message timestamp
297
+ if (output > 0 && lastUserTimestamp > 0) {
298
+ const durationMs = timestamp - lastUserTimestamp;
299
+ if (durationMs > 0) {
300
+ existing.totalDurationMs += durationMs;
301
+ existing.totalOutputForSpeed += output;
302
+ }
303
+ }
293
304
  rows.set(key, existing);
294
305
  }
295
306
  else if (message.role === "user") {
296
307
  const timestamp = Number(message.timestamp ?? 0);
297
- if (!timestamp || timestamp < startMs || timestamp >= endMs)
308
+ if (!timestamp)
309
+ continue;
310
+ // Always track last user timestamp for tok/sec calculation,
311
+ // even if this user message is outside the time range
312
+ // (the assistant response may still be within range).
313
+ lastUserTimestamp = timestamp;
314
+ if (timestamp < startMs || timestamp >= endMs)
298
315
  continue;
299
316
  matchedUserPrompts++;
300
317
  const model = currentModel;
@@ -311,6 +328,8 @@ function collectUsage(sessionFiles, startMs, endMs, scope, groupBy) {
311
328
  cacheWrite: 0,
312
329
  total: 0,
313
330
  cost: 0,
331
+ totalDurationMs: 0,
332
+ totalOutputForSpeed: 0,
314
333
  };
315
334
  existing.userPrompts += 1;
316
335
  userPromptRows.set(key, existing);
@@ -342,8 +361,10 @@ function collectUsage(sessionFiles, startMs, endMs, scope, groupBy) {
342
361
  acc.cacheWrite += row.cacheWrite;
343
362
  acc.total += row.total;
344
363
  acc.cost += row.cost;
364
+ acc.totalDurationMs += row.totalDurationMs;
365
+ acc.totalOutputForSpeed += row.totalOutputForSpeed;
345
366
  return acc;
346
- }, { messages: 0, userPrompts: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0, cost: 0 });
367
+ }, { messages: 0, userPrompts: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0, cost: 0, totalDurationMs: 0, totalOutputForSpeed: 0 });
347
368
  return {
348
369
  label: "",
349
370
  scope,
@@ -355,6 +376,11 @@ function collectUsage(sessionFiles, startMs, endMs, scope, groupBy) {
355
376
  totals,
356
377
  };
357
378
  }
379
+ function formatSpeed(totalDurationMs, totalOutputForSpeed) {
380
+ if (totalDurationMs <= 0)
381
+ return "—";
382
+ return Math.round(totalOutputForSpeed / (totalDurationMs / 1000)).toLocaleString();
383
+ }
358
384
  function renderTable(report) {
359
385
  const firstColumnHeader = report.groupBy === "project"
360
386
  ? "project"
@@ -371,7 +397,9 @@ function renderTable(report) {
371
397
  write: formatInt(row.cacheWrite),
372
398
  total: formatInt(row.total),
373
399
  cost: formatCost(row.cost),
400
+ speed: formatSpeed(row.totalDurationMs, row.totalOutputForSpeed),
374
401
  }));
402
+ const totalsSpeed = formatSpeed(report.totals.totalDurationMs, report.totals.totalOutputForSpeed);
375
403
  const widths = {
376
404
  label: Math.max(firstColumnHeader.length, ...displayRows.map((row) => row.label.length), 5),
377
405
  userPrompts: Math.max(11, ...displayRows.map((row) => row.userPrompts.length), String(report.totals.userPrompts).length),
@@ -382,6 +410,7 @@ function renderTable(report) {
382
410
  write: Math.max(5, ...displayRows.map((row) => row.write.length), formatInt(report.totals.cacheWrite).length),
383
411
  total: Math.max(5, ...displayRows.map((row) => row.total.length), formatInt(report.totals.total).length),
384
412
  cost: Math.max(7, ...displayRows.map((row) => row.cost.length), formatCost(report.totals.cost).length),
413
+ speed: Math.max(5, ...displayRows.map((row) => row.speed.length), totalsSpeed.length),
385
414
  };
386
415
  const header = [
387
416
  firstColumnHeader.padEnd(widths.label),
@@ -393,6 +422,7 @@ function renderTable(report) {
393
422
  "write".padStart(widths.write),
394
423
  "total".padStart(widths.total),
395
424
  "cost".padStart(widths.cost),
425
+ "tok/s".padStart(widths.speed),
396
426
  ].join(" ");
397
427
  const divider = "-".repeat(header.length);
398
428
  const body = displayRows.map((row) => [
@@ -405,6 +435,7 @@ function renderTable(report) {
405
435
  row.write.padStart(widths.write),
406
436
  row.total.padStart(widths.total),
407
437
  row.cost.padStart(widths.cost),
438
+ row.speed.padStart(widths.speed),
408
439
  ].join(" "));
409
440
  const totalsLine = [
410
441
  "TOTAL".padEnd(widths.label),
@@ -416,6 +447,7 @@ function renderTable(report) {
416
447
  formatInt(report.totals.cacheWrite).padStart(widths.write),
417
448
  formatInt(report.totals.total).padStart(widths.total),
418
449
  formatCost(report.totals.cost).padStart(widths.cost),
450
+ totalsSpeed.padStart(widths.speed),
419
451
  ].join(" ");
420
452
  return [header, divider, ...body, divider, totalsLine].join("\n");
421
453
  }
@@ -275,6 +275,7 @@ export default function (pi) {
275
275
  activationMode,
276
276
  editorText: ctx.ui.getEditorText(),
277
277
  holdToTalkSupported: isKittyProtocolActive(),
278
+ isEditorFocused: ctx.ui.isEditorFocused(),
278
279
  onUnsupported: () => {
279
280
  if (holdToTalkUnsupportedNotified)
280
281
  return;
@@ -2,6 +2,8 @@ import { isKeyRelease, Key, matchesKey } from "@gsd/pi-tui";
2
2
  export function handlePushToTalkInput(data, state) {
3
3
  if (!matchesKey(data, Key.space))
4
4
  return undefined;
5
+ if (!state.isEditorFocused)
6
+ return undefined;
5
7
  if (isKeyRelease(data)) {
6
8
  if (state.activationMode === "push-to-talk") {
7
9
  void Promise.resolve(state.stopVoice());