@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
@@ -0,0 +1,240 @@
1
+ /**
2
+ * E2E integration test for the welcome-screen extension.
3
+ *
4
+ * Boots a real tallow session with the bundled welcome-screen extension,
5
+ * binds extensions with a mock UI, and verifies:
6
+ * - setHeader IS called on fresh sessions
7
+ * - The rendered output contains the ASCII logo and version
8
+ * - setHeader is NOT called on resumed sessions with conversation history
9
+ */
10
+ import { afterEach, describe, expect, it } from "bun:test";
11
+ import { mkdtempSync, rmSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+ import { visibleWidth } from "@mariozechner/pi-tui";
15
+ import { TALLOW_VERSION } from "../../src/config.js";
16
+ import { createTallowSession, type TallowSession } from "../../src/sdk.js";
17
+ import { withExclusiveTallowHome } from "../../test-utils/tallow-home-env.js";
18
+ import welcomeScreenExtension from "../welcome-screen/index.js";
19
+
20
+ // ── Helpers ──────────────────────────────────────────────────────────────────
21
+
22
+ /** Lines the ASCII logo must contain (stripped of ANSI). */
23
+ const LOGO_FRAGMENTS = ["▐████████████▌", "████", "▐█▌"];
24
+
25
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI escape stripping requires matching \x1b
26
+ const ANSI_RE = /\x1b\[[0-9;]*m/g;
27
+
28
+ /** Strip ANSI escape sequences for content assertions. */
29
+ function stripAnsi(s: string): string {
30
+ return s.replace(ANSI_RE, "");
31
+ }
32
+
33
+ /**
34
+ * Create a session with only the welcome-screen extension loaded.
35
+ *
36
+ * @param cwd - Working directory for the session
37
+ * @returns TallowSession promise
38
+ */
39
+ function createWelcomeSession(cwd: string): Promise<TallowSession> {
40
+ return withExclusiveTallowHome(cwd, () =>
41
+ createTallowSession({
42
+ cwd,
43
+ provider: "anthropic",
44
+ apiKey: "test-key",
45
+ session: { type: "memory" },
46
+ noBundledExtensions: true,
47
+ noBundledSkills: true,
48
+ extensionFactories: [welcomeScreenExtension],
49
+ })
50
+ );
51
+ }
52
+
53
+ /**
54
+ * Build a mock UI context that captures setHeader calls.
55
+ *
56
+ * @returns Object with the UI context and captured state
57
+ */
58
+ function createCapturingUI(): {
59
+ uiContext: Record<string, unknown>;
60
+ capture: {
61
+ setHeaderCalled: boolean;
62
+ headerFactory: ((tui: unknown, theme: unknown) => { render(w: number): string[] }) | null;
63
+ };
64
+ } {
65
+ const capture = {
66
+ setHeaderCalled: false,
67
+ headerFactory: null as
68
+ | ((tui: unknown, theme: unknown) => { render(w: number): string[] })
69
+ | null,
70
+ };
71
+
72
+ const uiContext: Record<string, unknown> = {
73
+ notify: () => {},
74
+ confirm: async () => true,
75
+ input: async () => null,
76
+ select: async () => null,
77
+ custom: async () => null,
78
+ setWorkingMessage: () => {},
79
+ setHeader: (factory: typeof capture.headerFactory) => {
80
+ capture.setHeaderCalled = true;
81
+ capture.headerFactory = factory;
82
+ },
83
+ setFooter: () => {},
84
+ setToolsExpanded: () => {},
85
+ setEditorComponent: () => {},
86
+ addTerminalInputListener: () => () => {},
87
+ setStatus: () => {},
88
+ setWidget: () => {},
89
+ setTitle: () => {},
90
+ pasteToEditor: () => {},
91
+ setEditorText: () => {},
92
+ getEditorText: () => "",
93
+ editor: async () => undefined,
94
+ hasUI: true,
95
+ };
96
+ return { uiContext, capture };
97
+ }
98
+
99
+ let tmpDir: string | undefined;
100
+ let session: TallowSession | undefined;
101
+
102
+ afterEach(() => {
103
+ if (tmpDir) {
104
+ try {
105
+ rmSync(tmpDir, { recursive: true, force: true });
106
+ } catch {
107
+ // best-effort
108
+ }
109
+ tmpDir = undefined;
110
+ }
111
+ session = undefined;
112
+ });
113
+
114
+ // ── Tests ────────────────────────────────────────────────────────────────────
115
+
116
+ describe("welcome-screen E2E", () => {
117
+ it("calls setHeader with the ASCII logo on a fresh session", async () => {
118
+ tmpDir = mkdtempSync(join(tmpdir(), "tallow-welcome-e2e-"));
119
+ session = await createWelcomeSession(tmpDir);
120
+
121
+ const { uiContext, capture } = createCapturingUI();
122
+ await session.session.bindExtensions({ uiContext });
123
+
124
+ // ── setHeader must have been invoked ──
125
+ expect(capture.setHeaderCalled).toBe(true);
126
+ expect(capture.headerFactory).not.toBeNull();
127
+
128
+ // ── Render the header and validate content ──
129
+ const component = capture.headerFactory?.(null, null);
130
+ expect(component).toBeDefined();
131
+ expect(typeof component?.render).toBe("function");
132
+
133
+ const lines = component?.render(80) ?? [];
134
+ const plainLines = lines.map(stripAnsi);
135
+ const joined = plainLines.join("\n");
136
+
137
+ // Logo fragments must be present
138
+ for (const frag of LOGO_FRAGMENTS) {
139
+ expect(joined).toContain(frag);
140
+ }
141
+
142
+ // Version string must be present
143
+ expect(joined).toContain(`tallow v${TALLOW_VERSION}`);
144
+ }, 30_000);
145
+
146
+ it("renders lines centered within the given width", async () => {
147
+ tmpDir = mkdtempSync(join(tmpdir(), "tallow-welcome-e2e-"));
148
+ session = await createWelcomeSession(tmpDir);
149
+
150
+ const { uiContext, capture } = createCapturingUI();
151
+ await session.session.bindExtensions({ uiContext });
152
+
153
+ const component = capture.headerFactory?.(null, null);
154
+ expect(component).toBeDefined();
155
+
156
+ const width = 120;
157
+ const lines = component?.render(width) ?? [];
158
+
159
+ // Every line should fit within the width
160
+ for (const line of lines) {
161
+ expect(visibleWidth(line)).toBeLessThanOrEqual(width);
162
+ }
163
+
164
+ // Logo lines should be approximately centered (left padding > 0)
165
+ const logoLine = lines[0]; // first line = logo top bar
166
+ const leading = logoLine.length - logoLine.trimStart().length;
167
+ expect(leading).toBeGreaterThan(0);
168
+ }, 30_000);
169
+
170
+ it("does NOT call setHeader on a resumed session with conversation entries", async () => {
171
+ tmpDir = mkdtempSync(join(tmpdir(), "tallow-welcome-e2e-"));
172
+ session = await createWelcomeSession(tmpDir);
173
+
174
+ // Inject conversation entries to simulate a resumed session
175
+ const sm = session.session.sessionManager;
176
+ sm.appendMessage({
177
+ role: "user",
178
+ content: [{ type: "text", text: "hello" }],
179
+ });
180
+ sm.appendMessage({
181
+ role: "assistant",
182
+ content: [{ type: "text", text: "hi" }],
183
+ });
184
+
185
+ const { uiContext, capture } = createCapturingUI();
186
+ await session.session.bindExtensions({ uiContext });
187
+
188
+ // setHeader must NOT be called for resumed sessions
189
+ expect(capture.setHeaderCalled).toBe(false);
190
+ }, 30_000);
191
+
192
+ it("does NOT skip fresh sessions that only have metadata entries", async () => {
193
+ tmpDir = mkdtempSync(join(tmpdir(), "tallow-welcome-e2e-"));
194
+ session = await createWelcomeSession(tmpDir);
195
+
196
+ // Verify metadata entries exist but don't prevent the welcome screen
197
+ const entries = session.session.sessionManager.getEntries();
198
+ const metadataOnly = entries.every(
199
+ (e) => !("role" in e) || !["user", "assistant"].includes(String(e.role))
200
+ );
201
+ expect(metadataOnly).toBe(true);
202
+ expect(entries.length).toBeGreaterThan(0); // model_change, thinking_level_change
203
+
204
+ const { uiContext, capture } = createCapturingUI();
205
+ await session.session.bindExtensions({ uiContext });
206
+
207
+ // setHeader MUST be called even though metadata entries exist
208
+ expect(capture.setHeaderCalled).toBe(true);
209
+ }, 30_000);
210
+
211
+ it("defaults quietStartup to true so resource listing is suppressed", async () => {
212
+ tmpDir = mkdtempSync(join(tmpdir(), "tallow-welcome-e2e-"));
213
+ session = await createWelcomeSession(tmpDir);
214
+
215
+ // The settingsManager should have quietStartup=true by default,
216
+ // which suppresses the keybinding hints and [Context]/[Skills] listing.
217
+ const quiet = session.session.settingsManager.getQuietStartup();
218
+ expect(quiet).toBe(true);
219
+ }, 30_000);
220
+
221
+ it("respects explicit quietStartup=false override", async () => {
222
+ tmpDir = mkdtempSync(join(tmpdir(), "tallow-welcome-e2e-"));
223
+ session = await withExclusiveTallowHome(tmpDir, () =>
224
+ createTallowSession({
225
+ cwd: tmpDir ?? tmpdir(),
226
+ provider: "anthropic",
227
+ apiKey: "test-key",
228
+ session: { type: "memory" },
229
+ noBundledExtensions: true,
230
+ noBundledSkills: true,
231
+ extensionFactories: [welcomeScreenExtension],
232
+ settings: { quietStartup: false },
233
+ })
234
+ );
235
+
236
+ // User explicitly opted out — resource listing should be visible
237
+ const quiet = session.session.settingsManager.getQuietStartup();
238
+ expect(quiet).toBe(false);
239
+ }, 30_000);
240
+ });
@@ -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
  }
@@ -12,6 +12,14 @@
12
12
  import { spawnSync } from "node:child_process";
13
13
  import { existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs";
14
14
  import { dirname, join } from "node:path";
15
+ import {
16
+ isPidEntry,
17
+ isSessionOwner,
18
+ type PidEntry,
19
+ type SessionOwner,
20
+ type SessionPidFile,
21
+ toOwnerKey,
22
+ } from "../../runtime/pid-schema.js";
15
23
  import {
16
24
  createRuntimePathProvider,
17
25
  type RuntimePathProvider,
@@ -19,31 +27,6 @@ import {
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
  *
@@ -1076,7 +1076,7 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
1076
1076
 
1077
1077
  if (!expanded && truncated) {
1078
1078
  lines.push(
1079
- formatPresentationText(theme, "hint", keyHint("expandTools", "to show more"))
1079
+ formatPresentationText(theme, "hint", keyHint("app.tools.expand", "to show more"))
1080
1080
  );
1081
1081
  }
1082
1082
  } else {
@@ -283,7 +283,10 @@ export default function (pi: ExtensionAPI): void {
283
283
  name: "cd",
284
284
  label: "cd",
285
285
  description:
286
- "Request an interactive workspace transition to another directory. Requires explicit user approval and restarts the turn in the new workspace.",
286
+ "Request an interactive workspace transition to another directory. Requires explicit user approval and restarts the turn in the new workspace. IMPORTANT: cd must be the ONLY tool call in your response — never combine it with other tools (edit, bash, write, etc.). The transition restarts the turn and discards sibling tool results.",
287
+ promptGuidelines: [
288
+ "The cd tool triggers an interactive workspace transition that restarts the current turn. When you need to cd, emit it as the SOLE tool call in your response — do not pair it with edit, bash, write, read, or any other tool. Sibling tool calls will race against the transition and their results will be lost when the turn restarts.",
289
+ ],
287
290
  parameters: Type.Object({
288
291
  path: Type.String({
289
292
  description: "Directory path to open via the interactive workspace transition flow",
@@ -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
+ });
@@ -93,6 +93,7 @@ export default function editLive(pi: ExtensionAPI): void {
93
93
  label: baseEditTool.label,
94
94
  description: baseEditTool.description,
95
95
  parameters: baseEditTool.parameters,
96
+ prepareArguments: baseEditTool.prepareArguments,
96
97
 
97
98
  renderCall(args, theme) {
98
99
  const path = args.path ?? "file";
@@ -109,7 +110,8 @@ export default function editLive(pi: ExtensionAPI): void {
109
110
  const filePath = params.path ?? "file";
110
111
  const effectiveCwd = ctx?.cwd ?? process.cwd();
111
112
  const scopedEditTool = createEditTool(effectiveCwd);
112
- const result = await scopedEditTool.execute(toolCallId, params, signal, onUpdate);
113
+ const prepared = scopedEditTool.prepareArguments?.(params) ?? params;
114
+ const result = await scopedEditTool.execute(toolCallId, prepared, signal, onUpdate);
113
115
  const details = result.details as EditToolDetails | undefined;
114
116
  const diff = details?.diff ?? "";
115
117
  const absoluteFilename = path.isAbsolute(filePath)
@@ -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
+ });