@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.
- package/README.md +42 -1
- package/dist/cli.js +7 -1
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +1 -1
- package/dist/config.js +1 -1
- package/dist/interactive-mode-patch.d.ts +1 -0
- package/dist/interactive-mode-patch.d.ts.map +1 -1
- package/dist/interactive-mode-patch.js +40 -1
- package/dist/interactive-mode-patch.js.map +1 -1
- package/dist/model-metadata-overrides.d.ts +2 -5
- package/dist/model-metadata-overrides.d.ts.map +1 -1
- package/dist/model-metadata-overrides.js +23 -12
- package/dist/model-metadata-overrides.js.map +1 -1
- package/dist/pid-manager.d.ts +2 -9
- package/dist/pid-manager.d.ts.map +1 -1
- package/dist/pid-manager.js +1 -58
- package/dist/pid-manager.js.map +1 -1
- package/dist/pid-schema.d.ts +51 -0
- package/dist/pid-schema.d.ts.map +1 -0
- package/dist/pid-schema.js +70 -0
- package/dist/pid-schema.js.map +1 -0
- package/dist/sdk.d.ts.map +1 -1
- package/dist/sdk.js +24 -17
- package/dist/sdk.js.map +1 -1
- package/dist/workspace-transition-interactive.d.ts.map +1 -1
- package/dist/workspace-transition-interactive.js +53 -3
- package/dist/workspace-transition-interactive.js.map +1 -1
- package/dist/workspace-transition.d.ts +2 -1
- package/dist/workspace-transition.d.ts.map +1 -1
- package/dist/workspace-transition.js +16 -4
- package/dist/workspace-transition.js.map +1 -1
- package/extensions/__integration__/audit-findings.test.ts +309 -0
- package/extensions/__integration__/cd-tool-guidelines.test.ts +46 -0
- package/extensions/__integration__/tasks-runtime.test.ts +63 -12
- package/extensions/__integration__/welcome-screen.test.ts +240 -0
- package/extensions/_shared/lazy-init.ts +88 -3
- package/extensions/_shared/pid-registry.ts +8 -82
- package/extensions/background-task-tool/index.ts +1 -1
- package/extensions/cd-tool/index.ts +4 -1
- package/extensions/cheatsheet/__tests__/cheatsheet.test.ts +47 -0
- package/extensions/clear/__tests__/clear.test.ts +38 -0
- package/extensions/edit-tool-enhanced/index.ts +3 -1
- package/extensions/git-status/__tests__/git-status.test.ts +32 -0
- package/extensions/health/__tests__/diagnostics.test.ts +25 -0
- package/extensions/health/index.ts +61 -0
- package/extensions/loop/__tests__/loop.test.ts +365 -1
- package/extensions/loop/index.ts +213 -3
- package/extensions/mcp-adapter-tool/index.ts +1 -1
- package/extensions/minimal-skill-display/__tests__/minimal-skill-display.test.ts +20 -0
- package/extensions/permissions/__tests__/permissions.test.ts +213 -0
- package/extensions/progress-indicator/__tests__/progress-indicator.test.ts +104 -0
- package/extensions/prompt-suggestions/__tests__/autocomplete.test.ts +111 -3
- package/extensions/prompt-suggestions/autocomplete.ts +23 -5
- package/extensions/prompt-suggestions/index.ts +62 -3
- package/extensions/random-spinner/__tests__/random-spinner.test.ts +35 -0
- package/extensions/read-tool-enhanced/index.ts +5 -1
- package/extensions/session-memory/index.ts +1 -1
- package/extensions/session-namer/index.ts +1 -1
- package/extensions/show-system-prompt/__tests__/show-system-prompt.test.ts +51 -0
- package/extensions/subagent-tool/__tests__/presentation-rendering.test.ts +9 -8
- package/extensions/subagent-tool/__tests__/process-liveness.test.ts +51 -0
- package/extensions/subagent-tool/__tests__/subprocess-args.test.ts +120 -0
- package/extensions/subagent-tool/formatting.ts +2 -0
- package/extensions/subagent-tool/index.ts +160 -97
- package/extensions/subagent-tool/process.ts +152 -40
- package/extensions/tasks/commands/register-tasks-extension.ts +64 -20
- package/extensions/tasks/extension.json +1 -0
- package/extensions/tasks/index.ts +2 -12
- package/extensions/tasks/state/index.ts +26 -0
- package/extensions/teams-tool/dashboard.ts +13 -1
- package/extensions/teams-tool/sessions/spawn.ts +2 -2
- package/extensions/teams-tool/tools/register-extension.ts +10 -2
- package/extensions/upstream-check/__tests__/upstream-check.test.ts +49 -0
- package/extensions/welcome-screen/__tests__/welcome-screen.test.ts +35 -0
- package/extensions/welcome-screen/extension.json +20 -0
- package/extensions/welcome-screen/index.ts +189 -0
- package/extensions/wezterm-notify/__tests__/index.test.ts +49 -11
- package/extensions/wezterm-notify/index.ts +5 -3
- package/extensions/write-tool-enhanced/__tests__/write-tool-enhanced.test.ts +296 -0
- package/node_modules/@mariozechner/pi-tui/dist/index.d.ts +2 -2
- package/node_modules/@mariozechner/pi-tui/dist/index.d.ts.map +1 -1
- package/node_modules/@mariozechner/pi-tui/dist/index.js +2 -2
- package/node_modules/@mariozechner/pi-tui/dist/index.js.map +1 -1
- package/node_modules/@mariozechner/pi-tui/dist/keybindings.d.ts +309 -25
- package/node_modules/@mariozechner/pi-tui/dist/keybindings.d.ts.map +1 -1
- package/node_modules/@mariozechner/pi-tui/dist/keybindings.js +392 -72
- package/node_modules/@mariozechner/pi-tui/dist/keybindings.js.map +1 -1
- package/node_modules/@mariozechner/pi-tui/dist/keys.d.ts +30 -0
- package/node_modules/@mariozechner/pi-tui/dist/keys.d.ts.map +1 -1
- package/node_modules/@mariozechner/pi-tui/dist/keys.js +50 -6
- package/node_modules/@mariozechner/pi-tui/dist/keys.js.map +1 -1
- package/node_modules/@mariozechner/pi-tui/dist/terminal.d.ts +27 -0
- package/node_modules/@mariozechner/pi-tui/dist/terminal.d.ts.map +1 -1
- package/node_modules/@mariozechner/pi-tui/dist/terminal.js +59 -4
- package/node_modules/@mariozechner/pi-tui/dist/terminal.js.map +1 -1
- package/node_modules/@mariozechner/pi-tui/dist/tui.d.ts +9 -0
- package/node_modules/@mariozechner/pi-tui/dist/tui.d.ts.map +1 -1
- package/node_modules/@mariozechner/pi-tui/dist/tui.js +50 -1
- package/node_modules/@mariozechner/pi-tui/dist/tui.js.map +1 -1
- package/node_modules/@mariozechner/pi-tui/package.json +1 -1
- package/node_modules/@mariozechner/pi-tui/src/__tests__/mouse-events.test.ts +134 -0
- package/node_modules/@mariozechner/pi-tui/src/__tests__/tmux-compat.test.ts +204 -0
- package/node_modules/@mariozechner/pi-tui/src/__tests__/tui-diff-regression.test.ts +49 -0
- package/node_modules/@mariozechner/pi-tui/src/__tests__/tui-render-scheduling.test.ts +2 -0
- package/node_modules/@mariozechner/pi-tui/src/index.ts +11 -0
- package/node_modules/@mariozechner/pi-tui/src/keybindings.ts +478 -140
- package/node_modules/@mariozechner/pi-tui/src/keys.ts +84 -6
- package/node_modules/@mariozechner/pi-tui/src/terminal.ts +69 -4
- package/node_modules/@mariozechner/pi-tui/src/tui.ts +64 -1
- package/package.json +11 -10
- package/runtime/config.ts +7 -0
- package/runtime/model-metadata-overrides.ts +7 -0
- package/runtime/pid-schema.ts +13 -0
- 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("
|
|
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
|
|
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
|
+
});
|