@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.
- 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/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.js +4 -8
- package/dist/sdk.js.map +1 -1
- package/extensions/__integration__/audit-findings.test.ts +309 -0
- package/extensions/__integration__/tasks-runtime.test.ts +63 -12
- package/extensions/_shared/lazy-init.ts +88 -3
- package/extensions/_shared/pid-registry.ts +8 -82
- package/extensions/cheatsheet/__tests__/cheatsheet.test.ts +47 -0
- package/extensions/clear/__tests__/clear.test.ts +38 -0
- package/extensions/git-status/__tests__/git-status.test.ts +32 -0
- 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/random-spinner/__tests__/random-spinner.test.ts +35 -0
- package/extensions/show-system-prompt/__tests__/show-system-prompt.test.ts +51 -0
- package/extensions/subagent-tool/__tests__/presentation-rendering.test.ts +5 -4
- 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 +156 -95
- package/extensions/subagent-tool/process.ts +126 -32
- 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/tools/register-extension.ts +10 -2
- package/extensions/upstream-check/__tests__/upstream-check.test.ts +49 -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/package.json +3 -2
- package/runtime/pid-schema.ts +13 -0
- 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(
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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"
|
|
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
|
+
});
|