@dungle-scrubs/tallow 0.8.26 → 0.8.27

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 (48) hide show
  1. package/dist/config.d.ts +1 -1
  2. package/dist/config.js +1 -1
  3. package/dist/interactive-mode-patch.d.ts +1 -0
  4. package/dist/interactive-mode-patch.d.ts.map +1 -1
  5. package/dist/interactive-mode-patch.js +40 -1
  6. package/dist/interactive-mode-patch.js.map +1 -1
  7. package/dist/pid-manager.d.ts +2 -9
  8. package/dist/pid-manager.d.ts.map +1 -1
  9. package/dist/pid-manager.js +1 -58
  10. package/dist/pid-manager.js.map +1 -1
  11. package/dist/pid-schema.d.ts +51 -0
  12. package/dist/pid-schema.d.ts.map +1 -0
  13. package/dist/pid-schema.js +70 -0
  14. package/dist/pid-schema.js.map +1 -0
  15. package/dist/sdk.js +4 -8
  16. package/dist/sdk.js.map +1 -1
  17. package/extensions/__integration__/audit-findings.test.ts +309 -0
  18. package/extensions/__integration__/tasks-runtime.test.ts +63 -12
  19. package/extensions/_shared/lazy-init.ts +88 -3
  20. package/extensions/_shared/pid-registry.ts +8 -82
  21. package/extensions/cheatsheet/__tests__/cheatsheet.test.ts +47 -0
  22. package/extensions/clear/__tests__/clear.test.ts +38 -0
  23. package/extensions/git-status/__tests__/git-status.test.ts +32 -0
  24. package/extensions/mcp-adapter-tool/index.ts +1 -1
  25. package/extensions/minimal-skill-display/__tests__/minimal-skill-display.test.ts +20 -0
  26. package/extensions/permissions/__tests__/permissions.test.ts +213 -0
  27. package/extensions/progress-indicator/__tests__/progress-indicator.test.ts +104 -0
  28. package/extensions/random-spinner/__tests__/random-spinner.test.ts +35 -0
  29. package/extensions/show-system-prompt/__tests__/show-system-prompt.test.ts +51 -0
  30. package/extensions/subagent-tool/__tests__/presentation-rendering.test.ts +5 -4
  31. package/extensions/subagent-tool/__tests__/process-liveness.test.ts +51 -0
  32. package/extensions/subagent-tool/__tests__/subprocess-args.test.ts +120 -0
  33. package/extensions/subagent-tool/formatting.ts +2 -0
  34. package/extensions/subagent-tool/index.ts +156 -95
  35. package/extensions/subagent-tool/process.ts +126 -32
  36. package/extensions/tasks/commands/register-tasks-extension.ts +64 -20
  37. package/extensions/tasks/extension.json +1 -0
  38. package/extensions/tasks/index.ts +2 -12
  39. package/extensions/tasks/state/index.ts +26 -0
  40. package/extensions/teams-tool/dashboard.ts +13 -1
  41. package/extensions/teams-tool/tools/register-extension.ts +10 -2
  42. package/extensions/upstream-check/__tests__/upstream-check.test.ts +49 -0
  43. package/extensions/wezterm-notify/__tests__/index.test.ts +49 -11
  44. package/extensions/wezterm-notify/index.ts +5 -3
  45. package/extensions/write-tool-enhanced/__tests__/write-tool-enhanced.test.ts +296 -0
  46. package/package.json +3 -2
  47. package/runtime/pid-schema.ts +13 -0
  48. package/skills/tallow-expert/SKILL.md +1 -1
@@ -1,18 +1,26 @@
1
1
  import { afterEach, beforeEach, describe, expect, it } from "bun:test";
2
+ import { rmSync } from "node:fs";
3
+ import { join } from "node:path";
2
4
  import type { ExtensionContext, ToolDefinition } from "@mariozechner/pi-coding-agent";
3
5
  import { ExtensionHarness } from "../../test-utils/extension-harness.js";
4
6
  import { registerTasksExtension } from "../tasks/commands/register-tasks-extension.js";
5
- import { TaskListStore } from "../tasks/state/index.js";
7
+ import { buildSessionTaskGroupName, TASK_GROUPS_DIR, TaskListStore } from "../tasks/state/index.js";
6
8
 
7
9
  const ORIGINAL_PI_IS_SUBAGENT = process.env.PI_IS_SUBAGENT;
8
10
  const ORIGINAL_PI_TEAM_NAME = process.env.PI_TEAM_NAME;
11
+ const TEST_TASK_GROUPS = new Set<string>();
9
12
 
10
13
  /**
11
14
  * Build a minimal extension context for direct tool execution in tests.
12
15
  *
16
+ * @param options - Optional session/cwd overrides
13
17
  * @returns Stub extension context
14
18
  */
15
- function createContext(): ExtensionContext {
19
+ function createContext(options?: {
20
+ cwd?: string;
21
+ entries?: Array<{ type: string; customType?: string; data?: unknown }>;
22
+ sessionId?: string;
23
+ }): ExtensionContext {
16
24
  return {
17
25
  ui: {
18
26
  async select() {
@@ -61,10 +69,11 @@ function createContext(): ExtensionContext {
61
69
  setToolsExpanded() {},
62
70
  } as ExtensionContext["ui"],
63
71
  hasUI: false,
64
- cwd: process.cwd(),
72
+ cwd: options?.cwd ?? process.cwd(),
65
73
  sessionManager: {
66
- getEntries: () => [],
74
+ getEntries: () => options?.entries ?? [],
67
75
  appendEntry: () => {},
76
+ getSessionId: () => options?.sessionId ?? "test-session",
68
77
  } as never,
69
78
  modelRegistry: {
70
79
  getApiKeyForProvider: async () => undefined,
@@ -110,19 +119,18 @@ function getTool(harness: ExtensionHarness, name: string): ToolDefinition {
110
119
  *
111
120
  * @param tool - Registered manage_tasks tool
112
121
  * @param params - Tool parameters
122
+ * @param ctx - Optional extension context override
113
123
  * @returns Tool execution result
114
124
  */
115
125
  async function execManage(
116
126
  tool: ToolDefinition,
117
- params: Record<string, unknown>
127
+ params: Record<string, unknown>,
128
+ ctx = createContext()
118
129
  ): Promise<{ content: Array<{ type: string; text?: string }>; details: unknown }> {
119
- return (await tool.execute(
120
- "test-tool-call",
121
- params as never,
122
- undefined,
123
- undefined,
124
- createContext()
125
- )) as { content: Array<{ type: string; text?: string }>; details: unknown };
130
+ return (await tool.execute("test-tool-call", params as never, undefined, undefined, ctx)) as {
131
+ content: Array<{ type: string; text?: string }>;
132
+ details: unknown;
133
+ };
126
134
  }
127
135
 
128
136
  beforeEach(() => {
@@ -131,6 +139,10 @@ beforeEach(() => {
131
139
  });
132
140
 
133
141
  afterEach(() => {
142
+ for (const groupName of TEST_TASK_GROUPS) {
143
+ rmSync(join(TASK_GROUPS_DIR, groupName), { force: true, recursive: true });
144
+ }
145
+ TEST_TASK_GROUPS.clear();
134
146
  if (ORIGINAL_PI_IS_SUBAGENT === undefined) delete process.env.PI_IS_SUBAGENT;
135
147
  else process.env.PI_IS_SUBAGENT = ORIGINAL_PI_IS_SUBAGENT;
136
148
  if (ORIGINAL_PI_TEAM_NAME === undefined) delete process.env.PI_TEAM_NAME;
@@ -205,6 +217,45 @@ describe("Tasks runtime wiring", () => {
205
217
  expect(listText).toContain("2. [in_progress] Deploy fix");
206
218
  });
207
219
 
220
+ it("isolates file-backed task groups per session and reloads them on session_switch", async () => {
221
+ process.env.PI_IS_SUBAGENT = "0";
222
+ process.env.PI_TEAM_NAME = "foreign-shared-group";
223
+ const harness = ExtensionHarness.create();
224
+ registerTasksExtension(harness.api, new TaskListStore(null), null);
225
+ const manage = getTool(harness, "manage_tasks");
226
+ const ctxA = createContext({
227
+ cwd: "/tmp/workspace-a",
228
+ sessionId: "session-a",
229
+ });
230
+ const ctxB = createContext({
231
+ cwd: "/tmp/workspace-b",
232
+ sessionId: "session-b",
233
+ });
234
+ const groupA = buildSessionTaskGroupName("session-a", "/tmp/workspace-a");
235
+ const groupB = buildSessionTaskGroupName("session-b", "/tmp/workspace-b");
236
+ TEST_TASK_GROUPS.add(groupA);
237
+ TEST_TASK_GROUPS.add(groupB);
238
+
239
+ await harness.fireEvent("session_start", {}, ctxA);
240
+ expect(process.env.PI_TEAM_NAME).toBe(groupA);
241
+ await execManage(manage, { action: "add", task: "Task from session A" }, ctxA);
242
+ await execManage(manage, { action: "update", index: 1, status: "pending" }, ctxA);
243
+ expect(firstText(await execManage(manage, { action: "list" }, ctxA))).toContain(
244
+ "Task from session A"
245
+ );
246
+
247
+ await harness.fireEvent("session_switch", {}, ctxB);
248
+ expect(process.env.PI_TEAM_NAME).toBe(groupB);
249
+ expect(firstText(await execManage(manage, { action: "list" }, ctxB))).toBe("No tasks.");
250
+ await execManage(manage, { action: "add", task: "Task from session B" }, ctxB);
251
+ await execManage(manage, { action: "update", index: 1, status: "pending" }, ctxB);
252
+
253
+ await harness.fireEvent("session_switch", {}, ctxA);
254
+ const listA = firstText(await execManage(manage, { action: "list" }, ctxA));
255
+ expect(listA).toContain("Task from session A");
256
+ expect(listA).not.toContain("Task from session B");
257
+ });
258
+
208
259
  it("injects active-task context before agent start and clears orphaned in-progress tasks on agent_end", async () => {
209
260
  const harness = ExtensionHarness.create();
210
261
  registerTasksExtension(harness.api, new TaskListStore(null), null);
@@ -7,6 +7,9 @@ const STARTUP_TIMING_ENV = "TALLOW_STARTUP_TIMING";
7
7
  /** String values that disable startup timing when set in the env var. */
8
8
  const DISABLED_TIMING_VALUES = new Set(["0", "false", "off", "no"]);
9
9
 
10
+ /** Maximum backoff delay in milliseconds regardless of failure count. */
11
+ const MAX_BACKOFF_MS = 30_000;
12
+
10
13
  /** Initialization outcome used in timing metadata. */
11
14
  type LazyInitStatus = "ok" | "error";
12
15
 
@@ -22,9 +25,21 @@ export interface LazyInitializerOptions<TContext> {
22
25
  readonly name: string;
23
26
  /** One-time async initializer invoked on first use. */
24
27
  readonly initialize: (input: LazyInitInput<TContext>) => Promise<void>;
28
+ /**
29
+ * Maximum number of consecutive failures before the initializer is permanently
30
+ * failed. Once exhausted, `ensureInitialized()` rejects immediately without
31
+ * attempting initialization again. Defaults to 3.
32
+ */
33
+ readonly maxRetries?: number;
34
+ /**
35
+ * Base delay in milliseconds used for exponential backoff between retries.
36
+ * When a retry is attempted, the remaining portion of `retryBackoffMs * 2^(failureCount - 1)`
37
+ * (capped at 30 seconds) is waited before running `initialize`. Defaults to 1000.
38
+ */
39
+ readonly retryBackoffMs?: number;
25
40
  }
26
41
 
27
- /** One-time lazy initializer with in-flight dedupe. */
42
+ /** One-time lazy initializer with in-flight dedupe and circuit-breaker. */
28
43
  export interface LazyInitializer<TContext> {
29
44
  /**
30
45
  * Run initialization if needed.
@@ -32,7 +47,9 @@ export interface LazyInitializer<TContext> {
32
47
  * - First caller executes initialize().
33
48
  * - Concurrent callers await the same in-flight promise.
34
49
  * - After success, all callers resolve immediately.
35
- * - After failure, future callers retry.
50
+ * - After failure, future callers retry (respecting backoff).
51
+ * - After `maxRetries` consecutive failures, rejects immediately with the last
52
+ * error and makes no further attempts until `reset()` is called.
36
53
  *
37
54
  * @param input - Trigger + context payload for initialization
38
55
  * @returns Promise resolved when initialization is complete
@@ -40,6 +57,8 @@ export interface LazyInitializer<TContext> {
40
57
  ensureInitialized(input: LazyInitInput<TContext>): Promise<void>;
41
58
  /**
42
59
  * Reset completion state so the next ensureInitialized() call reruns init.
60
+ * Also resets the consecutive failure counter, the backoff clock, and any
61
+ * permanent failure state, allowing retries to begin again from scratch.
43
62
  * Does not cancel an in-flight initialization.
44
63
  *
45
64
  * @returns Nothing
@@ -51,6 +70,14 @@ export interface LazyInitializer<TContext> {
51
70
  * @returns True when initialized
52
71
  */
53
72
  isInitialized(): boolean;
73
+ /**
74
+ * Check whether the initializer has permanently failed after exhausting all
75
+ * retries. When true, `ensureInitialized()` rejects immediately without making
76
+ * any further initialization attempts. Call `reset()` to clear this state.
77
+ *
78
+ * @returns True when permanently failed
79
+ */
80
+ isPermanentlyFailed(): boolean;
54
81
  }
55
82
 
56
83
  /**
@@ -122,7 +149,13 @@ function emitLazyInitTiming(
122
149
  }
123
150
 
124
151
  /**
125
- * Create a race-safe lazy initializer with one-time execution semantics.
152
+ * Create a race-safe lazy initializer with one-time execution semantics,
153
+ * exponential backoff between retries, and a circuit-breaker that permanently
154
+ * fails after `maxRetries` consecutive failures.
155
+ *
156
+ * Each call to `ensureInitialized` is a single attempt. On failure the promise
157
+ * rejects immediately, but the next caller will wait out the remaining backoff
158
+ * window before running `initialize` again.
126
159
  *
127
160
  * @param options - Initializer configuration
128
161
  * @returns Lazy initializer controller
@@ -130,18 +163,58 @@ function emitLazyInitTiming(
130
163
  export function createLazyInitializer<TContext>(
131
164
  options: LazyInitializerOptions<TContext>
132
165
  ): LazyInitializer<TContext> {
166
+ const maxRetries = options.maxRetries ?? 3;
167
+ const retryBackoffMs = options.retryBackoffMs ?? 1000;
168
+
133
169
  let initialized = false;
134
170
  let inFlight: Promise<void> | null = null;
171
+ let failureCount = 0;
172
+ let permanentError: Error | null = null;
173
+ /** Timestamp (via performance.now) of the most recent failure, or null. */
174
+ let lastFailureTimeMs: number | null = null;
135
175
 
176
+ /**
177
+ * Compute how many milliseconds remain in the current backoff window.
178
+ * Returns 0 when no backoff is needed (first call or backoff already elapsed).
179
+ *
180
+ * @returns Remaining backoff delay in milliseconds
181
+ */
182
+ const remainingBackoffMs = (): number => {
183
+ if (failureCount === 0 || lastFailureTimeMs === null) return 0;
184
+ const totalBackoff = Math.min(retryBackoffMs * 2 ** (failureCount - 1), MAX_BACKOFF_MS);
185
+ const elapsed = performance.now() - lastFailureTimeMs;
186
+ return Math.max(0, totalBackoff - elapsed);
187
+ };
188
+
189
+ /**
190
+ * Execute one initialization attempt. Waits out any remaining backoff from
191
+ * the previous failure before calling `initialize`. Timing is measured over
192
+ * the `initialize` call only (backoff wait is excluded). On success, resets
193
+ * the failure counter. On failure, increments it and sets `permanentError`
194
+ * once `maxRetries` is exhausted.
195
+ *
196
+ * @param input - Trigger + context payload
197
+ * @returns Promise that resolves on success or rejects with a normalized Error
198
+ */
136
199
  const runInitialization = async (input: LazyInitInput<TContext>): Promise<void> => {
200
+ // Wait out remaining backoff from the previous failure before attempting.
201
+ const backoff = remainingBackoffMs();
202
+ if (backoff > 0) {
203
+ await new Promise<void>((resolve) => setTimeout(resolve, backoff));
204
+ }
205
+
137
206
  const startedAtMs = performance.now();
138
207
  try {
139
208
  await options.initialize(input);
140
209
  initialized = true;
210
+ failureCount = 0;
211
+ lastFailureTimeMs = null;
141
212
  emitLazyInitTiming(options.name, input.trigger, performance.now() - startedAtMs, "ok");
142
213
  } catch (error) {
143
214
  const normalized = toError(error);
144
215
  initialized = false;
216
+ failureCount++;
217
+ lastFailureTimeMs = performance.now();
145
218
  emitLazyInitTiming(
146
219
  options.name,
147
220
  input.trigger,
@@ -149,12 +222,18 @@ export function createLazyInitializer<TContext>(
149
222
  "error",
150
223
  normalized
151
224
  );
225
+ if (failureCount >= maxRetries) {
226
+ permanentError = normalized;
227
+ }
152
228
  throw normalized;
153
229
  }
154
230
  };
155
231
 
156
232
  return {
157
233
  ensureInitialized(input: LazyInitInput<TContext>): Promise<void> {
234
+ if (permanentError) {
235
+ return Promise.reject(permanentError);
236
+ }
158
237
  if (initialized) {
159
238
  return Promise.resolve();
160
239
  }
@@ -169,9 +248,15 @@ export function createLazyInitializer<TContext>(
169
248
  },
170
249
  reset(): void {
171
250
  initialized = false;
251
+ failureCount = 0;
252
+ permanentError = null;
253
+ lastFailureTimeMs = null;
172
254
  },
173
255
  isInitialized(): boolean {
174
256
  return initialized;
175
257
  },
258
+ isPermanentlyFailed(): boolean {
259
+ return permanentError !== null;
260
+ },
176
261
  };
177
262
  }
@@ -16,34 +16,17 @@ import {
16
16
  createRuntimePathProvider,
17
17
  type RuntimePathProvider,
18
18
  } from "../../runtime/runtime-path-provider.js";
19
+ import {
20
+ isPidEntry,
21
+ isSessionOwner,
22
+ type PidEntry,
23
+ type SessionOwner,
24
+ type SessionPidFile,
25
+ toOwnerKey,
26
+ } from "../../src/pid-schema.js";
19
27
  import { atomicWriteFileSync } from "./atomic-write.js";
20
28
  import { acquireFileLock } from "./file-lock.js";
21
29
 
22
- // ─── Types (mirror src/pid-manager.ts) ──────────────────────────────────────
23
-
24
- /** Session owner identity used for per-session PID files. */
25
- interface SessionOwner {
26
- pid: number;
27
- startedAt?: string;
28
- }
29
-
30
- /** A single tracked child process entry. */
31
- interface PidEntry {
32
- pid: number;
33
- command: string;
34
- ownerPid?: number;
35
- ownerStartedAt?: string;
36
- processStartedAt?: string;
37
- startedAt: number;
38
- }
39
-
40
- /** On-disk session PID file schema (version 2). */
41
- interface SessionPidFile {
42
- version: 2;
43
- owner: SessionOwner;
44
- entries: PidEntry[];
45
- }
46
-
47
30
  // ─── Owner/session path helpers ─────────────────────────────────────────────
48
31
 
49
32
  /** Cached owner identity for this process. */
@@ -89,21 +72,6 @@ function getSessionPidDir(): string {
89
72
  return pidRegistryPathProvider.getSessionPidDir();
90
73
  }
91
74
 
92
- /**
93
- * Convert owner metadata into a filesystem-safe key.
94
- *
95
- * @param owner - Session owner identity
96
- * @returns Filename-safe owner key
97
- */
98
- function toOwnerKey(owner: SessionOwner): string {
99
- const startedAtSlug = (owner.startedAt ?? "unknown")
100
- .replace(/[^A-Za-z0-9._-]+/g, "-")
101
- .replace(/-+/g, "-")
102
- .replace(/^-+|-+$/g, "");
103
- const normalizedStartedAt = startedAtSlug.length > 0 ? startedAtSlug : "unknown";
104
- return `${owner.pid}-${normalizedStartedAt}`;
105
- }
106
-
107
75
  /**
108
76
  * Resolve the current session PID file path.
109
77
  *
@@ -150,48 +118,6 @@ function getCurrentOwnerIdentity(): SessionOwner {
150
118
 
151
119
  // ─── Validation ──────────────────────────────────────────────────────────────
152
120
 
153
- /**
154
- * Check whether a value matches the session-owner schema.
155
- *
156
- * @param value - Unknown JSON value to validate
157
- * @returns True when value is a valid session owner
158
- */
159
- function isSessionOwner(value: unknown): value is SessionOwner {
160
- if (!value || typeof value !== "object") return false;
161
- const candidate = value as Record<string, unknown>;
162
- if (typeof candidate.pid !== "number") return false;
163
- if (candidate.startedAt != null && typeof candidate.startedAt !== "string") {
164
- return false;
165
- }
166
- return true;
167
- }
168
-
169
- /**
170
- * Check whether a value matches the PID entry schema.
171
- *
172
- * Supports legacy entries without owner/process identity metadata.
173
- *
174
- * @param value - Unknown JSON value to validate
175
- * @returns True when the value is a supported PID entry
176
- */
177
- function isPidEntry(value: unknown): value is PidEntry {
178
- if (!value || typeof value !== "object") return false;
179
- const candidate = value as Record<string, unknown>;
180
- if (typeof candidate.pid !== "number") return false;
181
- if (typeof candidate.command !== "string") return false;
182
- if (typeof candidate.startedAt !== "number") return false;
183
- if (candidate.ownerPid != null && typeof candidate.ownerPid !== "number") {
184
- return false;
185
- }
186
- if (candidate.ownerStartedAt != null && typeof candidate.ownerStartedAt !== "string") {
187
- return false;
188
- }
189
- if (candidate.processStartedAt != null && typeof candidate.processStartedAt !== "string") {
190
- return false;
191
- }
192
- return true;
193
- }
194
-
195
121
  /**
196
122
  * Validate and normalize raw session PID file JSON.
197
123
  *
@@ -0,0 +1,47 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
3
+ import cheatsheet from "../index.js";
4
+
5
+ describe("cheatsheet extension", () => {
6
+ function collectRegistrations() {
7
+ const commands: Array<{ name: string; description: string }> = [];
8
+ const pi = {
9
+ registerCommand: (name: string, opts: { description: string }) => {
10
+ commands.push({ name, description: opts.description });
11
+ },
12
+ registerMessageRenderer: () => {},
13
+ registerShortcut: () => {},
14
+ on: () => {},
15
+ } as unknown as ExtensionAPI;
16
+
17
+ cheatsheet(pi);
18
+ return { commands };
19
+ }
20
+
21
+ test("registers /cheatsheet command", () => {
22
+ const { commands } = collectRegistrations();
23
+ expect(commands.some((c) => c.name === "cheatsheet")).toBe(true);
24
+ });
25
+
26
+ test("registers /keys alias", () => {
27
+ const { commands } = collectRegistrations();
28
+ expect(commands.some((c) => c.name === "keys")).toBe(true);
29
+ });
30
+
31
+ test("registers /keymap alias", () => {
32
+ const { commands } = collectRegistrations();
33
+ expect(commands.some((c) => c.name === "keymap")).toBe(true);
34
+ });
35
+
36
+ test("registers /keybindings alias", () => {
37
+ const { commands } = collectRegistrations();
38
+ expect(commands.some((c) => c.name === "keybindings")).toBe(true);
39
+ });
40
+
41
+ test("all aliases have descriptions", () => {
42
+ const { commands } = collectRegistrations();
43
+ for (const cmd of commands) {
44
+ expect(cmd.description.length).toBeGreaterThan(0);
45
+ }
46
+ });
47
+ });
@@ -0,0 +1,38 @@
1
+ import { describe, expect, mock, test } from "bun:test";
2
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
3
+ import registerClear from "../index.js";
4
+
5
+ describe("clear extension", () => {
6
+ test("registers /clear command", () => {
7
+ const commands: Array<{ name: string; description: string }> = [];
8
+ const pi = {
9
+ registerCommand: (name: string, opts: { description: string }) => {
10
+ commands.push({ name, description: opts.description });
11
+ },
12
+ } as unknown as ExtensionAPI;
13
+
14
+ registerClear(pi);
15
+
16
+ expect(commands).toHaveLength(1);
17
+ expect(commands[0].name).toBe("clear");
18
+ expect(commands[0].description).toContain("new session");
19
+ });
20
+
21
+ test("handler calls ctx.newSession()", async () => {
22
+ let handler: ((args: string, ctx: unknown) => Promise<void>) | undefined;
23
+ const pi = {
24
+ registerCommand: (
25
+ _name: string,
26
+ opts: { handler: (args: string, ctx: unknown) => Promise<void> }
27
+ ) => {
28
+ handler = opts.handler;
29
+ },
30
+ } as unknown as ExtensionAPI;
31
+
32
+ registerClear(pi);
33
+
34
+ const newSession = mock(() => Promise.resolve());
35
+ await handler!("", { newSession });
36
+ expect(newSession).toHaveBeenCalledTimes(1);
37
+ });
38
+ });
@@ -0,0 +1,32 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
3
+ import gitStatus from "../index.js";
4
+
5
+ describe("git-status extension", () => {
6
+ test("registers session_start, tool_result, and session_shutdown handlers", () => {
7
+ const events: string[] = [];
8
+ const pi = {
9
+ on: (event: string) => {
10
+ events.push(event);
11
+ },
12
+ } as unknown as ExtensionAPI;
13
+
14
+ gitStatus(pi);
15
+ expect(events).toContain("session_start");
16
+ expect(events).toContain("tool_result");
17
+ expect(events).toContain("session_shutdown");
18
+ });
19
+
20
+ test("does not register any commands", () => {
21
+ const commands: string[] = [];
22
+ const pi = {
23
+ on: () => {},
24
+ registerCommand: (name: string) => {
25
+ commands.push(name);
26
+ },
27
+ } as unknown as ExtensionAPI;
28
+
29
+ gitStatus(pi);
30
+ expect(commands).toHaveLength(0);
31
+ });
32
+ });
@@ -1752,7 +1752,7 @@ export default function mcpAdapter(pi: ExtensionAPI) {
1752
1752
  return { systemPrompt: event.systemPrompt + lines.join("\n") };
1753
1753
  });
1754
1754
 
1755
- pi.on("session_shutdown" as never, async () => {
1755
+ pi.on("session_shutdown", async () => {
1756
1756
  resetRuntimeState();
1757
1757
  });
1758
1758
 
@@ -0,0 +1,20 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
3
+ import minimalSkillDisplay from "../index.js";
4
+
5
+ describe("minimal-skill-display extension", () => {
6
+ test("registers session_start and input handlers", () => {
7
+ const events: string[] = [];
8
+ const pi = {
9
+ on: (event: string) => {
10
+ events.push(event);
11
+ },
12
+ registerCommand: () => {},
13
+ registerMessageRenderer: () => {},
14
+ } as unknown as ExtensionAPI;
15
+
16
+ minimalSkillDisplay(pi);
17
+ expect(events).toContain("session_start");
18
+ expect(events).toContain("input");
19
+ });
20
+ });