@dungle-scrubs/tallow 0.8.26 → 0.8.28

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 (114) hide show
  1. package/README.md +42 -1
  2. package/dist/cli.js +7 -1
  3. package/dist/cli.js.map +1 -1
  4. package/dist/config.d.ts +1 -1
  5. package/dist/config.js +1 -1
  6. package/dist/interactive-mode-patch.d.ts +1 -0
  7. package/dist/interactive-mode-patch.d.ts.map +1 -1
  8. package/dist/interactive-mode-patch.js +40 -1
  9. package/dist/interactive-mode-patch.js.map +1 -1
  10. package/dist/model-metadata-overrides.d.ts +2 -5
  11. package/dist/model-metadata-overrides.d.ts.map +1 -1
  12. package/dist/model-metadata-overrides.js +23 -12
  13. package/dist/model-metadata-overrides.js.map +1 -1
  14. package/dist/pid-manager.d.ts +2 -9
  15. package/dist/pid-manager.d.ts.map +1 -1
  16. package/dist/pid-manager.js +1 -58
  17. package/dist/pid-manager.js.map +1 -1
  18. package/dist/pid-schema.d.ts +51 -0
  19. package/dist/pid-schema.d.ts.map +1 -0
  20. package/dist/pid-schema.js +70 -0
  21. package/dist/pid-schema.js.map +1 -0
  22. package/dist/sdk.d.ts.map +1 -1
  23. package/dist/sdk.js +24 -17
  24. package/dist/sdk.js.map +1 -1
  25. package/dist/workspace-transition-interactive.d.ts.map +1 -1
  26. package/dist/workspace-transition-interactive.js +53 -3
  27. package/dist/workspace-transition-interactive.js.map +1 -1
  28. package/dist/workspace-transition.d.ts +2 -1
  29. package/dist/workspace-transition.d.ts.map +1 -1
  30. package/dist/workspace-transition.js +16 -4
  31. package/dist/workspace-transition.js.map +1 -1
  32. package/extensions/__integration__/audit-findings.test.ts +309 -0
  33. package/extensions/__integration__/cd-tool-guidelines.test.ts +46 -0
  34. package/extensions/__integration__/tasks-runtime.test.ts +63 -12
  35. package/extensions/__integration__/welcome-screen.test.ts +240 -0
  36. package/extensions/_shared/lazy-init.ts +88 -3
  37. package/extensions/_shared/pid-registry.ts +8 -82
  38. package/extensions/background-task-tool/index.ts +1 -1
  39. package/extensions/cd-tool/index.ts +4 -1
  40. package/extensions/cheatsheet/__tests__/cheatsheet.test.ts +47 -0
  41. package/extensions/clear/__tests__/clear.test.ts +38 -0
  42. package/extensions/edit-tool-enhanced/index.ts +3 -1
  43. package/extensions/git-status/__tests__/git-status.test.ts +32 -0
  44. package/extensions/health/__tests__/diagnostics.test.ts +25 -0
  45. package/extensions/health/index.ts +61 -0
  46. package/extensions/loop/__tests__/loop.test.ts +365 -1
  47. package/extensions/loop/index.ts +213 -3
  48. package/extensions/mcp-adapter-tool/index.ts +1 -1
  49. package/extensions/minimal-skill-display/__tests__/minimal-skill-display.test.ts +20 -0
  50. package/extensions/permissions/__tests__/permissions.test.ts +213 -0
  51. package/extensions/progress-indicator/__tests__/progress-indicator.test.ts +104 -0
  52. package/extensions/prompt-suggestions/__tests__/autocomplete.test.ts +111 -3
  53. package/extensions/prompt-suggestions/autocomplete.ts +23 -5
  54. package/extensions/prompt-suggestions/index.ts +62 -3
  55. package/extensions/random-spinner/__tests__/random-spinner.test.ts +35 -0
  56. package/extensions/read-tool-enhanced/index.ts +5 -1
  57. package/extensions/session-memory/index.ts +1 -1
  58. package/extensions/session-namer/index.ts +1 -1
  59. package/extensions/show-system-prompt/__tests__/show-system-prompt.test.ts +51 -0
  60. package/extensions/subagent-tool/__tests__/presentation-rendering.test.ts +9 -8
  61. package/extensions/subagent-tool/__tests__/process-liveness.test.ts +51 -0
  62. package/extensions/subagent-tool/__tests__/subprocess-args.test.ts +120 -0
  63. package/extensions/subagent-tool/formatting.ts +2 -0
  64. package/extensions/subagent-tool/index.ts +160 -97
  65. package/extensions/subagent-tool/process.ts +152 -40
  66. package/extensions/tasks/commands/register-tasks-extension.ts +64 -20
  67. package/extensions/tasks/extension.json +1 -0
  68. package/extensions/tasks/index.ts +2 -12
  69. package/extensions/tasks/state/index.ts +26 -0
  70. package/extensions/teams-tool/dashboard.ts +13 -1
  71. package/extensions/teams-tool/sessions/spawn.ts +2 -2
  72. package/extensions/teams-tool/tools/register-extension.ts +10 -2
  73. package/extensions/upstream-check/__tests__/upstream-check.test.ts +49 -0
  74. package/extensions/welcome-screen/__tests__/welcome-screen.test.ts +35 -0
  75. package/extensions/welcome-screen/extension.json +20 -0
  76. package/extensions/welcome-screen/index.ts +189 -0
  77. package/extensions/wezterm-notify/__tests__/index.test.ts +49 -11
  78. package/extensions/wezterm-notify/index.ts +5 -3
  79. package/extensions/write-tool-enhanced/__tests__/write-tool-enhanced.test.ts +296 -0
  80. package/node_modules/@mariozechner/pi-tui/dist/index.d.ts +2 -2
  81. package/node_modules/@mariozechner/pi-tui/dist/index.d.ts.map +1 -1
  82. package/node_modules/@mariozechner/pi-tui/dist/index.js +2 -2
  83. package/node_modules/@mariozechner/pi-tui/dist/index.js.map +1 -1
  84. package/node_modules/@mariozechner/pi-tui/dist/keybindings.d.ts +309 -25
  85. package/node_modules/@mariozechner/pi-tui/dist/keybindings.d.ts.map +1 -1
  86. package/node_modules/@mariozechner/pi-tui/dist/keybindings.js +392 -72
  87. package/node_modules/@mariozechner/pi-tui/dist/keybindings.js.map +1 -1
  88. package/node_modules/@mariozechner/pi-tui/dist/keys.d.ts +30 -0
  89. package/node_modules/@mariozechner/pi-tui/dist/keys.d.ts.map +1 -1
  90. package/node_modules/@mariozechner/pi-tui/dist/keys.js +50 -6
  91. package/node_modules/@mariozechner/pi-tui/dist/keys.js.map +1 -1
  92. package/node_modules/@mariozechner/pi-tui/dist/terminal.d.ts +27 -0
  93. package/node_modules/@mariozechner/pi-tui/dist/terminal.d.ts.map +1 -1
  94. package/node_modules/@mariozechner/pi-tui/dist/terminal.js +59 -4
  95. package/node_modules/@mariozechner/pi-tui/dist/terminal.js.map +1 -1
  96. package/node_modules/@mariozechner/pi-tui/dist/tui.d.ts +9 -0
  97. package/node_modules/@mariozechner/pi-tui/dist/tui.d.ts.map +1 -1
  98. package/node_modules/@mariozechner/pi-tui/dist/tui.js +50 -1
  99. package/node_modules/@mariozechner/pi-tui/dist/tui.js.map +1 -1
  100. package/node_modules/@mariozechner/pi-tui/package.json +1 -1
  101. package/node_modules/@mariozechner/pi-tui/src/__tests__/mouse-events.test.ts +134 -0
  102. package/node_modules/@mariozechner/pi-tui/src/__tests__/tmux-compat.test.ts +204 -0
  103. package/node_modules/@mariozechner/pi-tui/src/__tests__/tui-diff-regression.test.ts +49 -0
  104. package/node_modules/@mariozechner/pi-tui/src/__tests__/tui-render-scheduling.test.ts +2 -0
  105. package/node_modules/@mariozechner/pi-tui/src/index.ts +11 -0
  106. package/node_modules/@mariozechner/pi-tui/src/keybindings.ts +478 -140
  107. package/node_modules/@mariozechner/pi-tui/src/keys.ts +84 -6
  108. package/node_modules/@mariozechner/pi-tui/src/terminal.ts +69 -4
  109. package/node_modules/@mariozechner/pi-tui/src/tui.ts +64 -1
  110. package/package.json +11 -10
  111. package/runtime/config.ts +7 -0
  112. package/runtime/model-metadata-overrides.ts +7 -0
  113. package/runtime/pid-schema.ts +13 -0
  114. package/skills/tallow-expert/SKILL.md +7 -5
@@ -5,6 +5,7 @@
5
5
  import { afterEach, describe, expect, test } from "bun:test";
6
6
  import type { Api, Model } from "@mariozechner/pi-ai";
7
7
  import {
8
+ AUTOCOMPLETE_COMMAND_PREFIXES,
8
9
  AUTOCOMPLETE_FALLBACKS,
9
10
  type AutocompleteConfig,
10
11
  AutocompleteEngine,
@@ -15,6 +16,7 @@ import {
15
16
  resolveAutocompleteModel,
16
17
  tryResolveModel,
17
18
  } from "../autocomplete.js";
19
+ import { buildLoopAutocompletePrompt, selectAutocompletePrompt } from "../index.js";
18
20
 
19
21
  // ─── Test helpers ────────────────────────────────────────────────────────────
20
22
 
@@ -42,8 +44,9 @@ function createMockRegistry(
42
44
  find(provider: string, modelId: string) {
43
45
  return models.find((m) => m.provider === provider && m.id === modelId);
44
46
  },
45
- async getApiKey(model: Model<Api>) {
46
- return keys.get(`${model.provider}/${model.id}`);
47
+ async getApiKeyForProvider(provider: string) {
48
+ const entry = [...keys.entries()].find(([k]) => k.startsWith(`${provider}/`));
49
+ return entry?.[1];
47
50
  },
48
51
  getAvailable() {
49
52
  return models.filter((m) => keys.has(`${m.provider}/${m.id}`));
@@ -189,7 +192,7 @@ describe("resolveAutocompleteModel", () => {
189
192
  findCalls.push(`${provider}/${modelId}`);
190
193
  return undefined;
191
194
  },
192
- async getApiKey() {
195
+ async getApiKeyForProvider() {
193
196
  return undefined;
194
197
  },
195
198
  getAvailable() {
@@ -224,6 +227,24 @@ describe("AutocompleteEngine.shouldTrigger", () => {
224
227
  test("rejects slash commands", () => {
225
228
  const { engine } = createTestEngine({});
226
229
  expect(engine.shouldTrigger("/help")).toBe(false);
230
+ expect(engine.shouldTrigger("/clear")).toBe(false);
231
+ expect(engine.shouldTrigger("/debug")).toBe(false);
232
+ });
233
+
234
+ test("allows /loop with content after prefix", () => {
235
+ const { engine } = createTestEngine({});
236
+ expect(engine.shouldTrigger("/loop check ci")).toBe(true);
237
+ expect(engine.shouldTrigger("/loop run tests every 30s")).toBe(true);
238
+ });
239
+
240
+ test("rejects bare /loop without trailing content", () => {
241
+ const { engine } = createTestEngine({});
242
+ expect(engine.shouldTrigger("/loop")).toBe(false);
243
+ expect(engine.shouldTrigger("/loop ")).toBe(false);
244
+ });
245
+
246
+ test("AUTOCOMPLETE_COMMAND_PREFIXES includes /loop", () => {
247
+ expect(AUTOCOMPLETE_COMMAND_PREFIXES).toContain("/loop ");
227
248
  });
228
249
 
229
250
  test("rejects when disabled", () => {
@@ -504,3 +525,90 @@ describe("AutocompleteEngine conversation context", () => {
504
525
  expect(receivedContext).toBeNull();
505
526
  });
506
527
  });
528
+
529
+ // ─── /loop autocomplete integration ─────────────────────────────────────────
530
+
531
+ describe("/loop autocomplete", () => {
532
+ let engine: AutocompleteEngine;
533
+
534
+ afterEach(() => {
535
+ engine?.dispose();
536
+ });
537
+
538
+ test("triggers autocomplete for /loop commands", async () => {
539
+ const calls: string[] = [];
540
+ const result = createTestEngine({
541
+ config: { debounceMs: 5 },
542
+ completionFn: async (_m, _k, input) => {
543
+ calls.push(input);
544
+ return " every 2m until CI is green";
545
+ },
546
+ currentText: "/loop check ci",
547
+ });
548
+ engine = result.engine;
549
+
550
+ engine.trigger("/loop check ci");
551
+ await waitForDebounce();
552
+ expect(calls).toEqual(["/loop check ci"]);
553
+ expect(result.ghostTexts).toEqual([" every 2m until CI is green"]);
554
+ });
555
+
556
+ test("does not trigger for non-allowlisted slash commands", async () => {
557
+ const calls: string[] = [];
558
+ const result = createTestEngine({
559
+ config: { debounceMs: 5 },
560
+ completionFn: async (_m, _k, input) => {
561
+ calls.push(input);
562
+ return "completion";
563
+ },
564
+ });
565
+ engine = result.engine;
566
+
567
+ engine.trigger("/help something");
568
+ await waitForDebounce();
569
+ expect(calls.length).toBe(0);
570
+ });
571
+ });
572
+
573
+ // ─── Prompt selection ────────────────────────────────────────────────────────
574
+
575
+ describe("selectAutocompletePrompt", () => {
576
+ test("returns loop prompt for /loop prefix", () => {
577
+ const prompt = selectAutocompletePrompt("/loop check ci", null);
578
+ expect(prompt).toContain("/loop");
579
+ expect(prompt).toContain("natural language");
580
+ expect(prompt).not.toContain("developer message");
581
+ });
582
+
583
+ test("returns general prompt for non-loop input", () => {
584
+ const prompt = selectAutocompletePrompt("refactor the auth", null);
585
+ expect(prompt).toContain("developer");
586
+ expect(prompt).not.toContain("/loop");
587
+ });
588
+
589
+ test("loop prompt includes conversation context when available", () => {
590
+ const ctx = { recentExchanges: "User: checking CI\n\nAssistant: it failed" };
591
+ const prompt = selectAutocompletePrompt("/loop check", ctx);
592
+ expect(prompt).toContain("checking CI");
593
+ });
594
+
595
+ test("general prompt includes conversation context when available", () => {
596
+ const ctx = { recentExchanges: "User: fix auth\n\nAssistant: done" };
597
+ const prompt = selectAutocompletePrompt("now also", ctx);
598
+ expect(prompt).toContain("fix auth");
599
+ });
600
+ });
601
+
602
+ describe("buildLoopAutocompletePrompt", () => {
603
+ test("includes example loop patterns", () => {
604
+ const prompt = buildLoopAutocompletePrompt(null);
605
+ expect(prompt).toContain("every 2m");
606
+ expect(prompt).toContain("until");
607
+ expect(prompt).toContain("observable");
608
+ });
609
+
610
+ test("warns against vague conditions", () => {
611
+ const prompt = buildLoopAutocompletePrompt(null);
612
+ expect(prompt).toContain("vague");
613
+ });
614
+ });
@@ -14,8 +14,8 @@ import type { Api, Model } from "@mariozechner/pi-ai";
14
14
  export interface ModelRegistryLike {
15
15
  /** Find a model by provider and ID. */
16
16
  find(provider: string, modelId: string): Model<Api> | undefined;
17
- /** Get API key for a model. */
18
- getApiKey(model: Model<Api>): Promise<string | undefined>;
17
+ /** Get API key for a provider. */
18
+ getApiKeyForProvider(provider: string): Promise<string | undefined>;
19
19
  /** Get all models that have auth configured. */
20
20
  getAvailable(): Model<Api>[];
21
21
  /** Register a provider dynamically. */
@@ -71,6 +71,15 @@ export const AUTOCOMPLETE_FALLBACKS = [
71
71
  /** Minimum characters before autocomplete triggers. */
72
72
  export const MIN_CHARS = 4;
73
73
 
74
+ /**
75
+ * Command prefixes that opt into autocomplete despite starting with `/`.
76
+ *
77
+ * Most slash commands are short and syntactically rigid — autocomplete adds
78
+ * noise. But commands with complex, hard-to-remember syntax (like `/loop`)
79
+ * benefit from inline suggestions.
80
+ */
81
+ export const AUTOCOMPLETE_COMMAND_PREFIXES = ["/loop "] as const;
82
+
74
83
  // ─── Model resolution ────────────────────────────────────────────────────────
75
84
 
76
85
  /**
@@ -93,7 +102,7 @@ export async function tryResolveModel(
93
102
  const model = registry.find(provider, modelId);
94
103
  if (!model) return null;
95
104
 
96
- const apiKey = await registry.getApiKey(model);
105
+ const apiKey = await registry.getApiKeyForProvider(model.provider);
97
106
  if (!apiKey) return null;
98
107
 
99
108
  return { model, apiKey };
@@ -126,7 +135,7 @@ export async function resolveAutocompleteModel(
126
135
  const available = registry.getAvailable();
127
136
  const sorted = [...available].sort((a, b) => (a.cost?.input ?? 0) - (b.cost?.input ?? 0));
128
137
  for (const model of sorted) {
129
- const apiKey = await registry.getApiKey(model);
138
+ const apiKey = await registry.getApiKeyForProvider(model.provider);
130
139
  if (apiKey) return { model, apiKey };
131
140
  }
132
141
 
@@ -196,6 +205,10 @@ export class AutocompleteEngine {
196
205
  /**
197
206
  * Determine whether a partial input should trigger autocomplete.
198
207
  *
208
+ * Most `/` prefixed input is rejected (slash commands are short and rigid).
209
+ * Commands listed in {@link AUTOCOMPLETE_COMMAND_PREFIXES} are allowed
210
+ * because their syntax is complex enough to benefit from inline suggestions.
211
+ *
199
212
  * @param input - Current editor text
200
213
  * @returns true if autocomplete should be scheduled
201
214
  */
@@ -203,7 +216,12 @@ export class AutocompleteEngine {
203
216
  if (!this.config.enabled) return false;
204
217
  if (this._busy) return false;
205
218
  if (this._callCount >= this.config.maxCalls) return false;
206
- if (input.startsWith("/")) return false;
219
+ if (input.startsWith("/")) {
220
+ const isAllowed = AUTOCOMPLETE_COMMAND_PREFIXES.some(
221
+ (prefix) => input.startsWith(prefix) && input.length > prefix.length
222
+ );
223
+ if (!isAllowed) return false;
224
+ }
207
225
  if (input.trim().length < MIN_CHARS) return false;
208
226
  return true;
209
227
  }
@@ -154,7 +154,8 @@ export function buildConversationContext(
154
154
  // ─── LLM completion ──────────────────────────────────────────────────────────
155
155
 
156
156
  /**
157
- * Build the system prompt for autocomplete, optionally including conversation context.
157
+ * Build the system prompt for general autocomplete, optionally including
158
+ * conversation context.
158
159
  *
159
160
  * @param context - Recent conversation context, or null
160
161
  * @returns System prompt string
@@ -175,6 +176,60 @@ export function buildAutocompleteSystemPrompt(context: ConversationContext | nul
175
176
  return `${base}\n\nHere is the recent conversation for context:\n\n${context.recentExchanges}`;
176
177
  }
177
178
 
179
+ /**
180
+ * Build a system prompt for `/loop` command autocomplete.
181
+ *
182
+ * The prompt teaches the model about natural-language loop syntax so it
183
+ * suggests good intervals, specific conditions, and actionable tasks.
184
+ * The user writes in natural language; a separate NL parser converts the
185
+ * final command into strict `/loop` syntax.
186
+ *
187
+ * @param context - Recent conversation context, or null
188
+ * @returns System prompt string
189
+ */
190
+ export function buildLoopAutocompletePrompt(context: ConversationContext | null): string {
191
+ const base =
192
+ "You are completing a /loop command in a coding CLI. " +
193
+ "The developer writes loops in natural language. The system parses it automatically.\n\n" +
194
+ "A loop needs: a task to perform, how often, and optionally when to stop.\n\n" +
195
+ "Good patterns:\n" +
196
+ '- "check the latest GitHub Actions run every 2m until CI is green"\n' +
197
+ '- "run the test suite every 30s until all tests pass"\n' +
198
+ '- "check deploy health every 1m until all pods are healthy"\n' +
199
+ '- "monitor build progress every 5m until the build completes"\n' +
200
+ '- "check disk usage every 10m, max 20 times"\n\n' +
201
+ "Reply with ONLY the completion text — the part that comes after what they typed. " +
202
+ "Keep suggestions specific and actionable — avoid vague conditions like 'it works' or 'it's done'. " +
203
+ "Suggest observable facts the agent can check. " +
204
+ "If they wrote a task, suggest the interval and condition. " +
205
+ "If they wrote an interval, suggest a clear task. " +
206
+ "One line only. Do not repeat what they typed. Do not add formatting or explanations.";
207
+
208
+ if (!context) return base;
209
+
210
+ return `${base}\n\nRecent conversation for context:\n\n${context.recentExchanges}`;
211
+ }
212
+
213
+ /**
214
+ * Select the appropriate system prompt based on the user's partial input.
215
+ *
216
+ * Commands with complex syntax (like `/loop`) get specialized prompts that
217
+ * understand their structure and suggest valid, high-quality completions.
218
+ *
219
+ * @param partialInput - Current editor text
220
+ * @param context - Recent conversation context, or null
221
+ * @returns System prompt string
222
+ */
223
+ export function selectAutocompletePrompt(
224
+ partialInput: string,
225
+ context: ConversationContext | null
226
+ ): string {
227
+ if (partialInput.startsWith("/loop ")) {
228
+ return buildLoopAutocompletePrompt(context);
229
+ }
230
+ return buildAutocompleteSystemPrompt(context);
231
+ }
232
+
178
233
  /**
179
234
  * Call the autocomplete model with the user's partial input and conversation context.
180
235
  *
@@ -193,14 +248,18 @@ async function getCompletion(
193
248
  context: ConversationContext | null
194
249
  ): Promise<string | null> {
195
250
  try {
251
+ const userMessage = partialInput.startsWith("/loop ")
252
+ ? `Complete this /loop command:\n${partialInput}`
253
+ : `Complete this developer message:\n${partialInput}`;
254
+
196
255
  const result = await completeSimple(
197
256
  model,
198
257
  {
199
- systemPrompt: buildAutocompleteSystemPrompt(context),
258
+ systemPrompt: selectAutocompletePrompt(partialInput, context),
200
259
  messages: [
201
260
  {
202
261
  role: "user",
203
- content: `Complete this developer message:\n${partialInput}`,
262
+ content: userMessage,
204
263
  timestamp: Date.now(),
205
264
  },
206
265
  ],
@@ -0,0 +1,35 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
3
+ import randomSpinner from "../index.js";
4
+
5
+ describe("random-spinner extension", () => {
6
+ test("registers session_start handler", () => {
7
+ const events: string[] = [];
8
+ const pi = {
9
+ on: (event: string) => {
10
+ events.push(event);
11
+ },
12
+ } as unknown as ExtensionAPI;
13
+
14
+ randomSpinner(pi);
15
+ expect(events).toContain("session_start");
16
+ });
17
+
18
+ test("does not register any commands or tools", () => {
19
+ const commands: string[] = [];
20
+ const tools: string[] = [];
21
+ const pi = {
22
+ on: () => {},
23
+ registerCommand: (name: string) => {
24
+ commands.push(name);
25
+ },
26
+ registerTool: (opts: { name: string }) => {
27
+ tools.push(opts.name);
28
+ },
29
+ } as unknown as ExtensionAPI;
30
+
31
+ randomSpinner(pi);
32
+ expect(commands).toHaveLength(0);
33
+ expect(tools).toHaveLength(0);
34
+ });
35
+ });
@@ -489,7 +489,11 @@ export default function readSummary(pi: ExtensionAPI): void {
489
489
  const left =
490
490
  formatPresentationText(theme, "identity", icon) +
491
491
  ` ${formatPresentationText(theme, "identity", `skill: ${skillName}`)}`;
492
- const right = formatPresentationText(theme, "hint", keyHint("expandTools", "to expand"));
492
+ const right = formatPresentationText(
493
+ theme,
494
+ "hint",
495
+ keyHint("app.tools.expand", "to expand")
496
+ );
493
497
 
494
498
  return {
495
499
  render(width: number): string[] {
@@ -381,7 +381,7 @@ export default function (pi: ExtensionAPI) {
381
381
  };
382
382
  }
383
383
 
384
- const apiKey = await ctx.modelRegistry.getApiKey(model);
384
+ const apiKey = await ctx.modelRegistry.getApiKeyForProvider(model.provider);
385
385
  if (!apiKey) {
386
386
  ctx.ui.setWorkingMessage();
387
387
  const sc = new Set(results.map((r) => r.sessionId)).size;
@@ -188,7 +188,7 @@ export default function (pi: ExtensionAPI): void {
188
188
  const model = findNamingModel(ctx);
189
189
  if (!model) return;
190
190
 
191
- const apiKey = await ctx.modelRegistry.getApiKey(model);
191
+ const apiKey = await ctx.modelRegistry.getApiKeyForProvider(model.provider);
192
192
  if (!apiKey) return;
193
193
 
194
194
  const prompt = buildNamingPrompt(userText, assistantText);
@@ -0,0 +1,51 @@
1
+ import { describe, expect, mock, test } from "bun:test";
2
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
3
+ import showPrompt from "../index.js";
4
+
5
+ describe("show-system-prompt extension", () => {
6
+ test("registers show-system-prompt command", () => {
7
+ const commands: string[] = [];
8
+ const pi = {
9
+ registerCommand: (name: string) => {
10
+ commands.push(name);
11
+ },
12
+ } as unknown as ExtensionAPI;
13
+
14
+ showPrompt(pi);
15
+ expect(commands).toContain("show-system-prompt");
16
+ });
17
+
18
+ test("handler logs system prompt and notifies", async () => {
19
+ let handler: ((args: string, ctx: unknown) => Promise<void>) | undefined;
20
+ const pi = {
21
+ registerCommand: (
22
+ _name: string,
23
+ opts: { handler: (args: string, ctx: unknown) => Promise<void> }
24
+ ) => {
25
+ handler = opts.handler;
26
+ },
27
+ } as unknown as ExtensionAPI;
28
+
29
+ showPrompt(pi);
30
+
31
+ const notify = mock(() => {});
32
+ const ctx = {
33
+ getSystemPrompt: () => "You are a test prompt",
34
+ ui: { notify },
35
+ };
36
+
37
+ const origLog = console.log;
38
+ const logged: string[] = [];
39
+ console.log = (...args: unknown[]) => {
40
+ logged.push(args.join(" "));
41
+ };
42
+ try {
43
+ await handler!("", ctx);
44
+ } finally {
45
+ console.log = origLog;
46
+ }
47
+
48
+ expect(logged.some((l) => l.includes("You are a test prompt"))).toBe(true);
49
+ expect(notify).toHaveBeenCalledWith("System prompt logged to terminal", "info");
50
+ });
51
+ });
@@ -113,7 +113,7 @@ describe("subagent presentation rendering", () => {
113
113
  else process.env.PI_IS_SUBAGENT = originalSubagentFlag;
114
114
  });
115
115
 
116
- it("renders single-call hierarchy with prominent title/action/identity and muted metadata", () => {
116
+ it("renders single-call with task preview and metadata (no redundant header)", () => {
117
117
  const component = tool.renderCall?.(
118
118
  {
119
119
  agent: "worker",
@@ -126,14 +126,14 @@ describe("subagent presentation rendering", () => {
126
126
  if (!component) throw new Error("subagent.renderCall returned undefined");
127
127
 
128
128
  const rendered = renderComponent(component);
129
- expect(rendered).toContain("<b><toolTitle>subagent</toolTitle></b>");
130
- expect(rendered).toContain("<accent>single</accent>");
131
- expect(rendered).toContain("worker");
129
+ // Single mode no longer renders "subagent single <agent>" header —
130
+ // the result renderer already shows "subagent running <duration> <agent>"
131
+ expect(rendered).not.toContain("<accent>single</accent>");
132
132
  expect(rendered).toContain("<muted>scope:both • model:claude-sonnet</muted>");
133
133
  expect(rendered).toContain("<dim>Implement authentication flow with retry handling</dim>");
134
134
  });
135
135
 
136
- it("keeps long parallel call previews informative without dumping the full tail", () => {
136
+ it("renders parallel call header without inlining task text", () => {
137
137
  const component = tool.renderCall?.(
138
138
  {
139
139
  tasks: [
@@ -152,7 +152,8 @@ describe("subagent presentation rendering", () => {
152
152
 
153
153
  const rendered = renderComponent(component);
154
154
  expect(rendered).toContain("<accent>parallel (1 tasks)</accent>");
155
- expect(rendered).toContain("KEEP_THIS_SEGMENT_VISIBLE");
155
+ // Parallel renderCall shows header + metadata only — no inline task preview
156
+ expect(rendered).not.toContain("KEEP_THIS_SEGMENT_VISIBLE");
156
157
  expect(rendered).not.toContain("END_MARKER_SHOULD_TRUNCATE");
157
158
  });
158
159
 
@@ -260,8 +261,8 @@ describe("subagent presentation rendering", () => {
260
261
  if (!component) throw new Error("subagent.renderResult returned undefined");
261
262
 
262
263
  const rendered = renderComponent(component);
263
- expect(rendered).toContain("<b><toolTitle>subagent</toolTitle></b>");
264
- expect(rendered).toContain("<accent>parallel</accent>");
264
+ // Result no longer repeats the "subagent parallel" header (call header already shows it)
265
+ expect(rendered).not.toContain("<toolTitle>subagent</toolTitle>");
265
266
  expect(rendered).toContain("├─");
266
267
  expect(rendered).toContain("└─");
267
268
  expect(rendered).toContain("<dim>(gpt-5.1)</dim>");
@@ -2,6 +2,7 @@ import { describe, expect, it } from "bun:test";
2
2
  import type { SingleResult } from "../formatting.js";
3
3
  import {
4
4
  applyStalledClassification,
5
+ createStalledSubagentErrorMessage,
5
6
  createWatchdogHeartbeatState,
6
7
  evaluateWatchdogStatus,
7
8
  type ForegroundWatchdogThresholds,
@@ -13,6 +14,7 @@ import {
13
14
  SUBAGENT_INACTIVITY_TIMEOUT_MS_ENV,
14
15
  SUBAGENT_STARTUP_TIMEOUT_MS_ENV,
15
16
  SUBAGENT_TOOL_EXECUTION_TIMEOUT_MS_ENV,
17
+ SUBAGENT_WALL_CLOCK_TIMEOUT_MS_ENV,
16
18
  terminateProcessWithGrace,
17
19
  } from "../process.js";
18
20
 
@@ -21,6 +23,7 @@ const TEST_THRESHOLDS: ForegroundWatchdogThresholds = {
21
23
  killGraceMs: 50,
22
24
  startupTimeoutMs: 1_000,
23
25
  toolExecutionTimeoutMs: 8_000,
26
+ wallClockTimeoutMs: 20_000,
24
27
  };
25
28
 
26
29
  interface ManualTimer {
@@ -124,6 +127,52 @@ describe("foreground subagent liveness watchdog", () => {
124
127
  expect(state.activeToolCalls).toBe(0);
125
128
  });
126
129
 
130
+ it("terminates active agents that exceed wall-clock timeout", () => {
131
+ let state = createWatchdogHeartbeatState(0);
132
+ // Agent is actively producing heartbeats — not stalled by liveness checks
133
+ state = recordWatchdogHeartbeat(state, 5_000);
134
+ state = recordWatchdogHeartbeat(state, 10_000);
135
+ state = recordWatchdogHeartbeat(state, 15_000);
136
+ state = recordWatchdogHeartbeat(state, 19_000);
137
+ expect(evaluateWatchdogStatus(state, 19_500, TEST_THRESHOLDS).kind).toBe("healthy");
138
+
139
+ // Wall-clock timeout (20s) fires even though last heartbeat was recent
140
+ const status = evaluateWatchdogStatus(state, 20_100, TEST_THRESHOLDS);
141
+ expect(status.kind).toBe("stalled");
142
+ if (status.kind !== "stalled") return;
143
+ expect(status.phase).toBe("wall_clock");
144
+ });
145
+
146
+ it("wall-clock timeout takes precedence over other stall phases", () => {
147
+ // Agent started and never sent a heartbeat — would normally be startup stall
148
+ const state = createWatchdogHeartbeatState(0);
149
+ // But wall-clock fires first when both thresholds are exceeded
150
+ const status = evaluateWatchdogStatus(state, 25_000, TEST_THRESHOLDS);
151
+ expect(status.kind).toBe("stalled");
152
+ if (status.kind !== "stalled") return;
153
+ expect(status.phase).toBe("wall_clock");
154
+ });
155
+
156
+ it("wall-clock error message differs from liveness stall messages", () => {
157
+ const wallClockMsg = createStalledSubagentErrorMessage({
158
+ elapsedMs: 900_000,
159
+ kind: "stalled",
160
+ phase: "wall_clock",
161
+ timeoutMs: 900_000,
162
+ });
163
+ expect(wallClockMsg).toContain("wall-clock timeout");
164
+ expect(wallClockMsg).toContain("TALLOW_SUBAGENT_WALL_CLOCK_TIMEOUT_MS");
165
+ expect(wallClockMsg).not.toContain("slow provider startup");
166
+
167
+ const startupMsg = createStalledSubagentErrorMessage({
168
+ elapsedMs: 60_000,
169
+ kind: "stalled",
170
+ phase: "startup",
171
+ timeoutMs: 60_000,
172
+ });
173
+ expect(startupMsg).not.toContain("wall-clock");
174
+ });
175
+
127
176
  it("treats message updates and tool execution events as heartbeats", () => {
128
177
  expect(isWatchdogHeartbeatEventType("message_update")).toBe(true);
129
178
  expect(isWatchdogHeartbeatEventType("tool_execution_start")).toBe(true);
@@ -136,10 +185,12 @@ describe("foreground subagent liveness watchdog", () => {
136
185
  [SUBAGENT_INACTIVITY_TIMEOUT_MS_ENV]: "7000",
137
186
  [SUBAGENT_STARTUP_TIMEOUT_MS_ENV]: "3000",
138
187
  [SUBAGENT_TOOL_EXECUTION_TIMEOUT_MS_ENV]: "11000",
188
+ [SUBAGENT_WALL_CLOCK_TIMEOUT_MS_ENV]: "1800000",
139
189
  });
140
190
  expect(thresholds.inactivityTimeoutMs).toBe(7_000);
141
191
  expect(thresholds.startupTimeoutMs).toBe(3_000);
142
192
  expect(thresholds.toolExecutionTimeoutMs).toBe(11_000);
193
+ expect(thresholds.wallClockTimeoutMs).toBe(1_800_000);
143
194
  });
144
195
 
145
196
  it("stalled termination escalates and resolves without hanging", async () => {