@justjoshn/pi-pstack 0.15.36-pi.12 → 0.15.36-pi.13
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/CHANGELOG.md +13 -0
- package/README.md +8 -8
- package/automations/benny/FOR_AGENTS.md +1 -1
- package/automations/benny/skills/reproduce-and-fix-issues/SKILL.md +3 -3
- package/automations/benny/skills/setup-benny/SKILL.md +3 -3
- package/automations/benny/skills/triage-issue-reports/SKILL.md +2 -2
- package/docs/parity-status.md +31 -2
- package/package.json +3 -2
- package/skills/pr-review-canvas/SKILL.md +1 -1
- package/skills/statechart/scripts/test/cli.test.ts +10 -1
- package/src/child-env.ts +4 -0
- package/src/index.ts +6 -2
- package/src/loop.ts +92 -47
- package/src/plan.ts +142 -20
- package/src/process-lock.ts +76 -0
- package/src/readonly-shell.ts +6 -8
- package/src/subscriptions/durable-store.ts +2 -75
- package/src/subscriptions/index.ts +2 -17
- package/src/subscriptions/supervisor.ts +1 -1
- package/src/task/records.ts +37 -32
- package/src/task/tool.ts +10 -2
- package/src/todo.ts +29 -6
package/src/plan.ts
CHANGED
|
@@ -1,17 +1,28 @@
|
|
|
1
1
|
import { execFileSync } from 'node:child_process';
|
|
2
2
|
import { createHash } from 'node:crypto';
|
|
3
3
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { createRequire } from 'node:module';
|
|
4
5
|
import { join, resolve, sep } from 'node:path';
|
|
5
6
|
|
|
6
|
-
import type
|
|
7
|
+
import { type ExtensionAPI, type ExtensionCommandContext, type ExtensionContext, stripFrontmatter } from '@earendil-works/pi-coding-agent';
|
|
7
8
|
import { Type } from 'typebox';
|
|
9
|
+
import type * as YAML from 'yaml';
|
|
8
10
|
import { canAsk } from './modes.ts';
|
|
9
11
|
import { plansDir } from './paths.ts';
|
|
10
12
|
import { createReadonlyBashTool, releaseReadonlySandbox } from './readonly-shell.ts';
|
|
13
|
+
import type { TodoItem } from './todo.ts';
|
|
11
14
|
|
|
12
15
|
export const PLAN_ENTRY = 'pstack/plan';
|
|
13
16
|
const STATUS_KEY = 'pstack-plan';
|
|
14
17
|
|
|
18
|
+
// Pi's local package installer does not install dependencies, so missing YAML must not block unrelated tools.
|
|
19
|
+
let yaml: typeof YAML | undefined;
|
|
20
|
+
try {
|
|
21
|
+
yaml = createRequire(import.meta.url)('yaml') as typeof YAML;
|
|
22
|
+
} catch (error) {
|
|
23
|
+
if ((error as NodeJS.ErrnoException).code !== 'MODULE_NOT_FOUND') throw error;
|
|
24
|
+
}
|
|
25
|
+
|
|
15
26
|
/**
|
|
16
27
|
* Pi's `bash` cannot be swapped for the sandboxed shell by name without replacing it for the whole session, so plan
|
|
17
28
|
* mode offers the sandboxed shell under its own name and makes it the only shell while plan mode is on.
|
|
@@ -120,6 +131,12 @@ export function planSection(planFile: string | undefined): string {
|
|
|
120
131
|
export interface PlanTodo {
|
|
121
132
|
id: string;
|
|
122
133
|
content: string;
|
|
134
|
+
dependencies?: string[];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export interface PlanProject {
|
|
138
|
+
is_project?: boolean;
|
|
139
|
+
phases?: { name: string; todos?: PlanTodo[] }[];
|
|
123
140
|
}
|
|
124
141
|
|
|
125
142
|
/** The plan's title as Cursor finds it: the first level-1 heading, else the first line of text. */
|
|
@@ -141,13 +158,24 @@ export function planFileName(name: string, chatKey: string): string {
|
|
|
141
158
|
|
|
142
159
|
const yamlString = (value: string) => `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\t/g, '\\t')}"`;
|
|
143
160
|
|
|
161
|
+
function planTodoLines(todo: PlanTodo): string[] {
|
|
162
|
+
const lines = [` - id: ${yamlString(todo.id)}`, ` content: ${yamlString(todo.content)}`, ' status: pending'];
|
|
163
|
+
if (todo.dependencies !== undefined && todo.dependencies.length > 0) lines.push(' dependencies:', ...todo.dependencies.map((id) => ` - ${yamlString(id)}`));
|
|
164
|
+
return lines;
|
|
165
|
+
}
|
|
166
|
+
|
|
144
167
|
/** The plan file as Cursor writes it: the chat id in a comment, the todos as front matter, then the plan. */
|
|
145
|
-
export function planFileContent(sessionId: string, plan: string, todos: readonly PlanTodo[]): string {
|
|
168
|
+
export function planFileContent(sessionId: string, plan: string, todos: readonly PlanTodo[], project: PlanProject = {}): string {
|
|
146
169
|
const lines = [`<!-- ${sessionId} -->`];
|
|
147
|
-
if (todos.length > 0) {
|
|
148
|
-
lines.push('---'
|
|
149
|
-
|
|
150
|
-
lines.push(
|
|
170
|
+
if (todos.length > 0 || project.is_project === true) {
|
|
171
|
+
lines.push('---');
|
|
172
|
+
if (todos.length > 0) lines.push('todos:', ...todos.flatMap(planTodoLines));
|
|
173
|
+
lines.push(`isProject: ${project.is_project === true}`);
|
|
174
|
+
if (project.is_project === true && project.phases !== undefined && project.phases.length > 0) {
|
|
175
|
+
lines.push('phases:');
|
|
176
|
+
for (const phase of project.phases) lines.push(` - name: ${yamlString(phase.name)}`, ' todos:', ...(phase.todos ?? []).flatMap((todo) => planTodoLines(todo).map((line) => ` ${line}`)));
|
|
177
|
+
}
|
|
178
|
+
lines.push('---');
|
|
151
179
|
}
|
|
152
180
|
lines.push(plan);
|
|
153
181
|
return lines.join('\n');
|
|
@@ -157,29 +185,94 @@ export function planFileContent(sessionId: string, plan: string, todos: readonly
|
|
|
157
185
|
* Writes the plan under the plans folder and returns its path. A chat keeps one file per plan name, as Cursor keys the
|
|
158
186
|
* name by its conversation; Pi's session ids start with a timestamp, so the 8 characters come from a hash of the id.
|
|
159
187
|
*/
|
|
160
|
-
export function savePlan(sessionId: string, plan: string, name: string | undefined, todos: readonly PlanTodo[], dir = plansDir()): string {
|
|
188
|
+
export function savePlan(sessionId: string, plan: string, name: string | undefined, todos: readonly PlanTodo[], dir = plansDir(), project: PlanProject = {}): string {
|
|
161
189
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
162
190
|
const chatKey = createHash('sha256').update(sessionId).digest('hex');
|
|
163
191
|
const path = join(dir, planFileName(name === undefined || name.trim() === '' ? planTitle(plan) : name, chatKey));
|
|
164
|
-
writeFileSync(path, planFileContent(sessionId, plan, todos), { mode: 0o600 });
|
|
192
|
+
writeFileSync(path, planFileContent(sessionId, plan, todos, project), { mode: 0o600 });
|
|
165
193
|
return path;
|
|
166
194
|
}
|
|
167
195
|
|
|
168
196
|
/** The plan text the build turn carries: the file without Cursor's chat comment and todo front matter. */
|
|
169
197
|
export function planBody(file: string): string {
|
|
170
|
-
return readFileSync(file, 'utf8')
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
198
|
+
return stripFrontmatter(readFileSync(file, 'utf8').replace(/^<!-- [^\n]*-->\r?\n/, '')).trim();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function planMetadata(file: string) {
|
|
202
|
+
const source = readFileSync(file, 'utf8');
|
|
203
|
+
const comment = /^<!-- [^\n]*-->\r?\n/.exec(source)?.[0] ?? '';
|
|
204
|
+
const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(source.slice(comment.length));
|
|
205
|
+
if (frontmatter === null) return undefined;
|
|
206
|
+
if (yaml === undefined) throw new Error('Plan metadata needs the yaml dependency. Run bun install in the pi-pstack package directory, then reload Pi.');
|
|
207
|
+
const document = yaml.parseDocument(frontmatter[1] ?? '');
|
|
208
|
+
if (document.errors.length > 0) throw new Error(`Invalid plan metadata in ${file}: ${document.errors[0]?.message}`);
|
|
209
|
+
return { source, offset: comment.length + frontmatter[0].indexOf('\n') + 1, document, yaml };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function planTodos(file: string): TodoItem[] {
|
|
213
|
+
const metadata = planMetadata(file);
|
|
214
|
+
if (metadata === undefined) return [];
|
|
215
|
+
const { isSeq, isMap, isScalar } = metadata.yaml;
|
|
216
|
+
const field = metadata.document.get('todos');
|
|
217
|
+
if (field === undefined || field === null) return [];
|
|
218
|
+
if (!isSeq(field)) throw new Error(`Plan todos in ${file} must be a list.`);
|
|
219
|
+
return field.items.map((todo) => {
|
|
220
|
+
if (!isMap(todo)) throw new Error(`Each plan todo in ${file} must have an id, content, and status.`);
|
|
221
|
+
const id = todo.get('id');
|
|
222
|
+
const content = todo.get('content');
|
|
223
|
+
const status = todo.get('status') ?? 'pending';
|
|
224
|
+
const dependencies = todo.get('dependencies');
|
|
225
|
+
if (typeof id !== 'string' || typeof content !== 'string' || !(status === 'pending' || status === 'in_progress' || status === 'completed' || status === 'cancelled')) throw new Error(`Invalid plan todo in ${file}.`);
|
|
226
|
+
if (dependencies !== undefined && (!isSeq(dependencies) || dependencies.items.some((dependency) => !isScalar(dependency) || typeof dependency.value !== 'string'))) throw new Error(`Invalid todo dependencies in ${file}.`);
|
|
227
|
+
return { id, content, status, ...(isSeq(dependencies) ? { dependencies: dependencies.toJSON() as string[] } : {}) };
|
|
228
|
+
});
|
|
174
229
|
}
|
|
175
230
|
|
|
231
|
+
export function syncPlanTodoStatuses(file: string, todos: readonly TodoItem[]): void {
|
|
232
|
+
const metadata = planMetadata(file);
|
|
233
|
+
if (metadata === undefined) return;
|
|
234
|
+
const { source, offset, document, yaml: parser } = metadata;
|
|
235
|
+
const { isSeq, isMap, isScalar } = parser;
|
|
236
|
+
const field = document.get('todos');
|
|
237
|
+
if (!isSeq(field)) return;
|
|
238
|
+
const statuses = new Map(todos.map((todo) => [todo.id, todo.status]));
|
|
239
|
+
const edits: { start: number; end: number; text: string }[] = [];
|
|
240
|
+
for (const todo of field.items) {
|
|
241
|
+
if (!isMap(todo)) continue;
|
|
242
|
+
const status = statuses.get(String(todo.get('id')));
|
|
243
|
+
const node = todo.get('status', true);
|
|
244
|
+
if (status === undefined || (node === undefined && status === 'pending')) continue;
|
|
245
|
+
if (node === undefined && todo.range != null) {
|
|
246
|
+
const start = offset + todo.range[0] + (todo.flow ? 1 : 0);
|
|
247
|
+
const indent = start - source.lastIndexOf('\n', start) - 1;
|
|
248
|
+
const newline = source.includes('\r\n') ? '\r\n' : '\n';
|
|
249
|
+
const text = todo.flow ? `status: ${status}, ` : `status: ${status}${newline}${' '.repeat(indent)}`;
|
|
250
|
+
edits.push({ start, end: start, text });
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
if (!isScalar(node) || node.range == null || node.value === status) continue;
|
|
254
|
+
const value = node.type === 'QUOTE_DOUBLE' ? JSON.stringify(status) : node.type === 'QUOTE_SINGLE' ? `'${status}'` : status;
|
|
255
|
+
edits.push({ start: offset + node.range[0], end: offset + node.range[1], text: value });
|
|
256
|
+
}
|
|
257
|
+
if (edits.length === 0) return;
|
|
258
|
+
let updated = source;
|
|
259
|
+
for (const edit of edits.sort((a, b) => b.start - a.start)) updated = updated.slice(0, edit.start) + edit.text + updated.slice(edit.end);
|
|
260
|
+
writeFileSync(file, updated);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const PlanTodoParams = Type.Object({
|
|
264
|
+
id: Type.String({ description: 'Unique identifier for the todo' }),
|
|
265
|
+
content: Type.String({ description: 'Description of the todo task' }),
|
|
266
|
+
dependencies: Type.Optional(Type.Array(Type.String(), { description: 'IDs of todos that must finish before this todo' })),
|
|
267
|
+
});
|
|
268
|
+
|
|
176
269
|
const CreatePlanParams = Type.Object({
|
|
177
270
|
plan: Type.String({ description: "A detailed, concrete plan for accomplishing the user's request" }),
|
|
178
271
|
name: Type.Optional(Type.String({ description: 'A short 3-4 word name for the plan. Give it only on the first CreatePlan call, so the plan file name stays stable.' })),
|
|
179
272
|
overview: Type.Optional(Type.String({ description: 'A 1-2 sentence high-level description of what the plan accomplishes' })),
|
|
180
|
-
todos: Type.Optional(
|
|
181
|
-
|
|
182
|
-
),
|
|
273
|
+
todos: Type.Optional(Type.Array(PlanTodoParams, { description: 'Array of implementation todos' })),
|
|
274
|
+
is_project: Type.Optional(Type.Boolean({ description: 'Whether the plan is a project organized into phases' })),
|
|
275
|
+
phases: Type.Optional(Type.Array(Type.Object({ name: Type.String(), todos: Type.Optional(Type.Array(PlanTodoParams)) }), { description: 'Named project phases and their todos, saved when is_project is true' })),
|
|
183
276
|
});
|
|
184
277
|
|
|
185
278
|
// Cursor's CreatePlan rules: a glanceable markdown plan with a title, todos, no tables, and edits in the file afterwards.
|
|
@@ -236,12 +329,15 @@ const isGitRepository = (cwd: string): boolean => {
|
|
|
236
329
|
};
|
|
237
330
|
|
|
238
331
|
export interface PlanModeOptions {
|
|
332
|
+
buildPlan?: string;
|
|
333
|
+
setTodos?: (todos: TodoItem[], ctx: ExtensionContext) => void;
|
|
239
334
|
/** Starts the build in an isolated worktree worker, Pi's counterpart to Cursor's "build in cloud". Returns the agent ID. */
|
|
240
335
|
buildInWorker?: (planFile: string, request: string) => Promise<string>;
|
|
241
336
|
}
|
|
242
337
|
|
|
243
|
-
export function registerPlanMode(pi: ExtensionAPI, options: PlanModeOptions = {})
|
|
338
|
+
export function registerPlanMode(pi: ExtensionAPI, options: PlanModeOptions = {}) {
|
|
244
339
|
let state: PlanState = { enabled: false };
|
|
340
|
+
let buildPlan = options.buildPlan;
|
|
245
341
|
// A plan saved during the current run, which the user reviews once the run settles.
|
|
246
342
|
let review: string | undefined;
|
|
247
343
|
|
|
@@ -259,7 +355,7 @@ export function registerPlanMode(pi: ExtensionAPI, options: PlanModeOptions = {}
|
|
|
259
355
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
260
356
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
261
357
|
if (!state.enabled) throw new Error(`${PLAN_SAVE} is available only in plan mode. Enter it with /plan.`);
|
|
262
|
-
const planFile = savePlan(ctx.sessionManager.getSessionId(), params.plan, params.name, params.todos ?? []);
|
|
358
|
+
const planFile = savePlan(ctx.sessionManager.getSessionId(), params.plan, params.name, params.todos ?? [], undefined, params);
|
|
263
359
|
state = { ...state, planFile };
|
|
264
360
|
review = planFile;
|
|
265
361
|
pi.appendEntry(PLAN_ENTRY, state);
|
|
@@ -284,7 +380,10 @@ export function registerPlanMode(pi: ExtensionAPI, options: PlanModeOptions = {}
|
|
|
284
380
|
};
|
|
285
381
|
|
|
286
382
|
const restore = (ctx: ExtensionContext) => {
|
|
287
|
-
const
|
|
383
|
+
const branch = ctx.sessionManager.getBranch();
|
|
384
|
+
const stored = planFromBranch(branch);
|
|
385
|
+
// Pi replays tools from system messages before session_tree. A branch without one has no loadout to restore.
|
|
386
|
+
if (!stored.enabled && state.enabled && !branch.some((entry) => entry.type === 'message' && entry.message.role === 'system')) disable();
|
|
288
387
|
state = { ...stored };
|
|
289
388
|
review = undefined;
|
|
290
389
|
if (stored.enabled) enable(stored.toolsBefore ?? state.toolsBefore ?? pi.getActiveTools());
|
|
@@ -293,7 +392,17 @@ export function registerPlanMode(pi: ExtensionAPI, options: PlanModeOptions = {}
|
|
|
293
392
|
if (!canAsk(ctx) && pi.getActiveTools().includes(SWITCH_MODE)) pi.setActiveTools(pi.getActiveTools().filter((name) => name !== SWITCH_MODE));
|
|
294
393
|
show(ctx);
|
|
295
394
|
};
|
|
296
|
-
pi.on('session_start', (_event, ctx) =>
|
|
395
|
+
pi.on('session_start', (_event, ctx) => {
|
|
396
|
+
restore(ctx);
|
|
397
|
+
const initialPlan = buildPlan;
|
|
398
|
+
buildPlan = undefined;
|
|
399
|
+
if (initialPlan === undefined) return;
|
|
400
|
+
const planFile = storedPlanFile(initialPlan, plansDir());
|
|
401
|
+
if (planFile === undefined) throw new Error('The plan build must use a file inside the pi-pstack plans directory.');
|
|
402
|
+
prepareBuild(ctx, planFile);
|
|
403
|
+
state = { enabled: false, planFile };
|
|
404
|
+
pi.appendEntry(PLAN_ENTRY, state);
|
|
405
|
+
});
|
|
297
406
|
pi.on('session_tree', (_event, ctx) => restore(ctx));
|
|
298
407
|
|
|
299
408
|
// Active tools decide what the model is offered; this decides what may run, including names the model
|
|
@@ -305,7 +414,13 @@ export function registerPlanMode(pi: ExtensionAPI, options: PlanModeOptions = {}
|
|
|
305
414
|
return reason === undefined ? undefined : { block: true, reason };
|
|
306
415
|
});
|
|
307
416
|
|
|
417
|
+
const prepareBuild = (ctx: ExtensionContext, planFile: string) => {
|
|
418
|
+
const todos = planTodos(planFile).filter((todo) => todo.status !== 'completed' && todo.status !== 'cancelled');
|
|
419
|
+
options.setTodos?.(todos, ctx);
|
|
420
|
+
};
|
|
421
|
+
|
|
308
422
|
const build = (ctx: ExtensionContext, planFile: string) => {
|
|
423
|
+
prepareBuild(ctx, planFile);
|
|
309
424
|
if (state.enabled) disable();
|
|
310
425
|
pi.appendEntry(PLAN_ENTRY, state);
|
|
311
426
|
show(ctx);
|
|
@@ -319,6 +434,7 @@ export function registerPlanMode(pi: ExtensionAPI, options: PlanModeOptions = {}
|
|
|
319
434
|
if (!state.enabled || state.planFile !== planFile) return;
|
|
320
435
|
if (choice === BUILD_LOCALLY) build(ctx, planFile);
|
|
321
436
|
else if (choice === BUILD_IN_WORKER && options.buildInWorker !== undefined) {
|
|
437
|
+
prepareBuild(ctx, planFile);
|
|
322
438
|
disable();
|
|
323
439
|
pi.appendEntry(PLAN_ENTRY, state);
|
|
324
440
|
show(ctx);
|
|
@@ -392,5 +508,11 @@ export function registerPlanMode(pi: ExtensionAPI, options: PlanModeOptions = {}
|
|
|
392
508
|
},
|
|
393
509
|
});
|
|
394
510
|
|
|
395
|
-
return {
|
|
511
|
+
return {
|
|
512
|
+
section: () => (state.enabled ? planSection(state.planFile) : undefined),
|
|
513
|
+
enabled: () => state.enabled,
|
|
514
|
+
syncTodos: (todos: readonly TodoItem[]) => {
|
|
515
|
+
if (state.planFile !== undefined && existsSync(state.planFile)) syncPlanTodoStatuses(state.planFile, todos);
|
|
516
|
+
},
|
|
517
|
+
};
|
|
396
518
|
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdirSync, readdirSync, readFileSync, renameSync, rmdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
function zombie(pid: number): boolean {
|
|
6
|
+
if (process.platform !== 'linux') return false;
|
|
7
|
+
try {
|
|
8
|
+
const stat = readFileSync(`/proc/${pid}/stat`, 'utf8');
|
|
9
|
+
const close = stat.lastIndexOf(')');
|
|
10
|
+
return close !== -1 && stat.slice(close + 2, close + 3) === 'Z';
|
|
11
|
+
} catch {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function alive(pid: number): boolean {
|
|
17
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
18
|
+
try {
|
|
19
|
+
process.kill(pid, 0);
|
|
20
|
+
return !zombie(pid);
|
|
21
|
+
} catch (error) {
|
|
22
|
+
return (error as NodeJS.ErrnoException).code === 'EPERM';
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type Lease = { token: string; release: () => void };
|
|
27
|
+
|
|
28
|
+
/** A holder token keeps a stale reaper from unlinking a replacement owner's lease. */
|
|
29
|
+
export function acquireLease(path: string): Lease | undefined {
|
|
30
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
31
|
+
const token = `${process.pid}-${randomUUID()}`;
|
|
32
|
+
const temporary = `${path}.${token}.tmp`;
|
|
33
|
+
mkdirSync(temporary, { mode: 0o700 });
|
|
34
|
+
try {
|
|
35
|
+
writeFileSync(join(temporary, token), '', { mode: 0o600 });
|
|
36
|
+
for (let attempt = 0; attempt < 4; attempt += 1) {
|
|
37
|
+
try {
|
|
38
|
+
renameSync(temporary, path);
|
|
39
|
+
return {
|
|
40
|
+
token,
|
|
41
|
+
release: () => {
|
|
42
|
+
rmSync(join(path, token), { force: true });
|
|
43
|
+
try {
|
|
44
|
+
rmdirSync(path);
|
|
45
|
+
} catch (error) {
|
|
46
|
+
if (!['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes((error as NodeJS.ErrnoException).code ?? '')) throw error;
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
} catch (error) {
|
|
51
|
+
if (!['ENOTEMPTY', 'EEXIST'].includes((error as NodeJS.ErrnoException).code ?? '')) throw error;
|
|
52
|
+
}
|
|
53
|
+
let holders: string[];
|
|
54
|
+
try {
|
|
55
|
+
holders = readdirSync(path);
|
|
56
|
+
} catch (error) {
|
|
57
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue;
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
for (const holder of holders) {
|
|
61
|
+
const pid = Number(/^(\d+)-[a-f0-9-]+$/.exec(holder)?.[1]);
|
|
62
|
+
if (!Number.isInteger(pid)) throw new Error(`Invalid process lock ${path}. Retain it for diagnosis.`);
|
|
63
|
+
if (alive(pid)) return undefined;
|
|
64
|
+
rmSync(join(path, holder), { force: true });
|
|
65
|
+
}
|
|
66
|
+
try {
|
|
67
|
+
rmdirSync(path);
|
|
68
|
+
} catch (error) {
|
|
69
|
+
if (!['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes((error as NodeJS.ErrnoException).code ?? '')) throw error;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return undefined;
|
|
73
|
+
} finally {
|
|
74
|
+
rmSync(temporary, { recursive: true, force: true });
|
|
75
|
+
}
|
|
76
|
+
}
|
package/src/readonly-shell.ts
CHANGED
|
@@ -8,11 +8,9 @@ import { type BashSpawnContext, createBashTool, type ExtensionAPI, type Extensio
|
|
|
8
8
|
// denyWrite names the paths the sandbox makes writable for itself (/tmp/claude is a dependency default), and
|
|
9
9
|
// denyRead drops the dependency's writable home defaults (~/.npm/_logs, ~/.claude/debug). Seatbelt is
|
|
10
10
|
// last-match-wins with deny emitted after allow; on Linux bubblewrap binds deny last the same way.
|
|
11
|
-
//
|
|
12
|
-
// helper fails writing /proc/self/setgroups even with setuid bwrap. Write and network denial still come
|
|
13
|
-
// from bubblewrap / sandbox-exec; only AF_UNIX blocking is dropped.
|
|
11
|
+
// Unix sockets must stay blocked too: a host service can mutate files on a command's behalf.
|
|
14
12
|
const READ_ONLY_POLICY: SandboxRuntimeConfig = {
|
|
15
|
-
network: { allowedDomains: [], deniedDomains: [], allowAllUnixSockets:
|
|
13
|
+
network: { allowedDomains: [], deniedDomains: [], allowAllUnixSockets: false },
|
|
16
14
|
filesystem: {
|
|
17
15
|
denyRead: ['~/.npm/_logs', '~/.claude/debug'],
|
|
18
16
|
allowWrite: [],
|
|
@@ -40,10 +38,10 @@ function sandboxReady(): Promise<Sandbox> {
|
|
|
40
38
|
try {
|
|
41
39
|
const { SandboxManager } = await import('@anthropic-ai/sandbox-runtime');
|
|
42
40
|
if (!SandboxManager.isSupportedPlatform()) throw new Error(`${process.platform} is not supported`);
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
//
|
|
46
|
-
//
|
|
41
|
+
const socketWarning = SandboxManager.checkDependencies().warnings.find((warning) => warning.includes('unix socket access not restricted'));
|
|
42
|
+
if (socketWarning !== undefined) throw new Error(socketWarning);
|
|
43
|
+
// initialize is a no-op if another consumer started the manager. Linux socket isolation also
|
|
44
|
+
// reads the shared config, so both it and the per-command override must carry this policy.
|
|
47
45
|
await SandboxManager.initialize(READ_ONLY_POLICY);
|
|
48
46
|
SandboxManager.updateConfig(READ_ONLY_POLICY);
|
|
49
47
|
return SandboxManager;
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
-
import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync,
|
|
2
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { dirname, join, resolve } from 'node:path';
|
|
4
4
|
import { performance } from 'node:perf_hooks';
|
|
5
5
|
|
|
6
6
|
import { durableRegistryPath, durableRuntimeDir } from '../paths.ts';
|
|
7
|
+
import { acquireLease, alive } from '../process-lock.ts';
|
|
7
8
|
import { parseCron } from './cron.ts';
|
|
8
9
|
import type { ArmStatus, DurableRegistry, DurableSubscription, EventAdmission, PendingEvent, StoredSubscription, SupervisorLock } from './durable-types.ts';
|
|
9
10
|
import { routineSlug } from './webhook.ts';
|
|
@@ -18,28 +19,6 @@ export function canonicalPath(path: string): string {
|
|
|
18
19
|
}
|
|
19
20
|
}
|
|
20
21
|
|
|
21
|
-
function zombie(pid: number): boolean {
|
|
22
|
-
if (process.platform !== 'linux') return false;
|
|
23
|
-
try {
|
|
24
|
-
const stat = readFileSync(`/proc/${pid}/stat`, 'utf8');
|
|
25
|
-
const close = stat.lastIndexOf(')');
|
|
26
|
-
return close !== -1 && stat.slice(close + 2, close + 3) === 'Z';
|
|
27
|
-
} catch {
|
|
28
|
-
return false;
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export function alive(pid: number): boolean {
|
|
33
|
-
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
34
|
-
try {
|
|
35
|
-
process.kill(pid, 0);
|
|
36
|
-
// A zombie still answers kill(pid, 0) until its parent reaps it, but it holds no lock and runs nothing.
|
|
37
|
-
return !zombie(pid);
|
|
38
|
-
} catch (error) {
|
|
39
|
-
return (error as NodeJS.ErrnoException).code === 'EPERM';
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
|
|
43
22
|
function writeAtomic(path: string, value: unknown): void {
|
|
44
23
|
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
45
24
|
const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
@@ -77,58 +56,6 @@ export function writeRoutinesPort(cwd: string, port: number): void {
|
|
|
77
56
|
}
|
|
78
57
|
}
|
|
79
58
|
|
|
80
|
-
export type Lease = { token: string; release: () => void };
|
|
81
|
-
|
|
82
|
-
/** Publish a nonempty directory atomically. Recovery removes only the dead holder's token. */
|
|
83
|
-
export function acquireLease(path: string): Lease | undefined {
|
|
84
|
-
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
85
|
-
const token = `${process.pid}-${randomUUID()}`;
|
|
86
|
-
const temporary = `${path}.${token}.tmp`;
|
|
87
|
-
mkdirSync(temporary, { mode: 0o700 });
|
|
88
|
-
try {
|
|
89
|
-
writeFileSync(join(temporary, token), '', { mode: 0o600 });
|
|
90
|
-
for (let attempt = 0; attempt < 4; attempt += 1) {
|
|
91
|
-
try {
|
|
92
|
-
renameSync(temporary, path);
|
|
93
|
-
return {
|
|
94
|
-
token,
|
|
95
|
-
release: () => {
|
|
96
|
-
rmSync(join(path, token), { force: true });
|
|
97
|
-
try {
|
|
98
|
-
rmdirSync(path);
|
|
99
|
-
} catch (error) {
|
|
100
|
-
if (!['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes((error as NodeJS.ErrnoException).code ?? '')) throw error;
|
|
101
|
-
}
|
|
102
|
-
},
|
|
103
|
-
};
|
|
104
|
-
} catch (error) {
|
|
105
|
-
if (!['ENOTEMPTY', 'EEXIST'].includes((error as NodeJS.ErrnoException).code ?? '')) throw error;
|
|
106
|
-
}
|
|
107
|
-
let holders: string[];
|
|
108
|
-
try {
|
|
109
|
-
holders = readdirSync(path);
|
|
110
|
-
} catch (error) {
|
|
111
|
-
if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue;
|
|
112
|
-
throw error;
|
|
113
|
-
}
|
|
114
|
-
for (const holder of holders) {
|
|
115
|
-
const pid = Number(/^(\d+)-[a-f0-9-]+$/.exec(holder)?.[1]);
|
|
116
|
-
if (!Number.isInteger(pid)) throw new Error(`Invalid subscription lock ${path}. Retain it for diagnosis.`);
|
|
117
|
-
if (alive(pid)) return undefined;
|
|
118
|
-
rmSync(join(path, holder), { force: true });
|
|
119
|
-
}
|
|
120
|
-
try {
|
|
121
|
-
rmdirSync(path);
|
|
122
|
-
} catch (error) {
|
|
123
|
-
if (!['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes((error as NodeJS.ErrnoException).code ?? '')) throw error;
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
return undefined;
|
|
127
|
-
} finally {
|
|
128
|
-
rmSync(temporary, { recursive: true, force: true });
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
|
|
132
59
|
export function ownerIsLive(cwd: string, sessionId: string): boolean {
|
|
133
60
|
const path = ownerLockPath(cwd, sessionId);
|
|
134
61
|
try {
|
|
@@ -6,24 +6,9 @@ import { type Static, Type } from 'typebox';
|
|
|
6
6
|
import { Value } from 'typebox/value';
|
|
7
7
|
import { isOneShot } from '../modes.ts';
|
|
8
8
|
import { routinesDir } from '../paths.ts';
|
|
9
|
+
import { acquireLease, type Lease } from '../process-lock.ts';
|
|
9
10
|
import { parseCron } from './cron.ts';
|
|
10
|
-
import {
|
|
11
|
-
acquireLease,
|
|
12
|
-
cancelDesired,
|
|
13
|
-
canonicalPath,
|
|
14
|
-
createDesired,
|
|
15
|
-
deliverOwner,
|
|
16
|
-
finishFresh,
|
|
17
|
-
isDesired,
|
|
18
|
-
isSupervisorLive,
|
|
19
|
-
type Lease,
|
|
20
|
-
ownerLockPath,
|
|
21
|
-
readArm,
|
|
22
|
-
readRegistry,
|
|
23
|
-
runsDetached,
|
|
24
|
-
startFresh,
|
|
25
|
-
visibleSubscriptions,
|
|
26
|
-
} from './durable-store.ts';
|
|
11
|
+
import { cancelDesired, canonicalPath, createDesired, deliverOwner, finishFresh, isDesired, isSupervisorLive, ownerLockPath, readArm, readRegistry, runsDetached, startFresh, visibleSubscriptions } from './durable-store.ts';
|
|
27
12
|
import type { StoredSubscription } from './durable-types.ts';
|
|
28
13
|
import { defaultEnsureSupervisor, waitForArm, waitForSupervisor } from './ensure-supervisor.ts';
|
|
29
14
|
import { type GhRunner, targetText } from './github.ts';
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
|
|
3
3
|
import { routinesDir } from '../paths.ts';
|
|
4
|
+
import { alive } from '../process-lock.ts';
|
|
4
5
|
import { cronMatches, parseCron } from './cron.ts';
|
|
5
6
|
import {
|
|
6
7
|
acceptEvent,
|
|
7
8
|
acquireSupervisorLock,
|
|
8
|
-
alive,
|
|
9
9
|
canonicalPath,
|
|
10
10
|
clearError,
|
|
11
11
|
expireDesired,
|
package/src/task/records.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, w
|
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
|
|
4
4
|
import { parseSessionEntries, SessionManager } from '@earendil-works/pi-coding-agent';
|
|
5
|
+
import { acquireLease, alive } from '../process-lock.ts';
|
|
5
6
|
import type { ModelSpec } from './models.ts';
|
|
6
7
|
import type { Worktree } from './worktree.ts';
|
|
7
8
|
|
|
@@ -142,15 +143,6 @@ export function recordIds(dir: string): string[] {
|
|
|
142
143
|
.map((name) => name.slice(0, -'.json'.length));
|
|
143
144
|
}
|
|
144
145
|
|
|
145
|
-
function alive(pid: number): boolean {
|
|
146
|
-
try {
|
|
147
|
-
process.kill(pid, 0);
|
|
148
|
-
return true;
|
|
149
|
-
} catch (error) {
|
|
150
|
-
return (error as NodeJS.ErrnoException).code === 'EPERM';
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
|
|
154
146
|
const isPid = (value: number) => Number.isInteger(value) && value > 0;
|
|
155
147
|
|
|
156
148
|
/** The live pid the agent's run lock names, or undefined when no live process holds it. */
|
|
@@ -198,33 +190,46 @@ export interface RunLock {
|
|
|
198
190
|
/**
|
|
199
191
|
* Pi appends to a session file without a lock, so two processes on one agent ID would interleave its transcript.
|
|
200
192
|
* The lock file holds the pid of the process writing the transcript. A lock without a readable pid counts as held.
|
|
201
|
-
* A
|
|
202
|
-
* processes taking over the same stale lock in the narrow window between that re-read and the removal can both succeed.
|
|
193
|
+
* A token-directory lease serializes acquisition and stale recovery without changing the PID file read by stop/status.
|
|
203
194
|
*/
|
|
204
195
|
export function acquireRunLock(dir: string, agentId: string): RunLock {
|
|
205
196
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
206
197
|
const path = join(dir, `${agentId}.lock`);
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
198
|
+
const lease = acquireLease(`${path}.owner`);
|
|
199
|
+
if (lease === undefined) throw stillRunning(agentId, runningPid(dir, agentId), path);
|
|
200
|
+
let acquired = false;
|
|
201
|
+
try {
|
|
202
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
203
|
+
if (createExclusive(path)) {
|
|
204
|
+
acquired = true;
|
|
205
|
+
let owner = process.pid;
|
|
206
|
+
return {
|
|
207
|
+
holdFor: (pid) => {
|
|
208
|
+
writeAtomically(path, String(pid));
|
|
209
|
+
owner = pid;
|
|
210
|
+
},
|
|
211
|
+
release: () => {
|
|
212
|
+
for (const release of [
|
|
213
|
+
() => {
|
|
214
|
+
if (holderOf(path) === owner) rmSync(path, { force: true });
|
|
215
|
+
},
|
|
216
|
+
lease.release,
|
|
217
|
+
]) {
|
|
218
|
+
try {
|
|
219
|
+
release();
|
|
220
|
+
} catch (error) {
|
|
221
|
+
if (!['EACCES', 'EPERM', 'EROFS', 'EBUSY', 'EIO'].includes((error as NodeJS.ErrnoException).code ?? '')) throw error;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
const holder = holderOf(path);
|
|
228
|
+
if (holder !== undefined && (!isPid(holder) || alive(holder))) throw stillRunning(agentId, holder, path);
|
|
229
|
+
if (holder !== undefined && holderOf(path) === holder) rmSync(path, { force: true });
|
|
224
230
|
}
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
if (
|
|
231
|
+
throw stillRunning(agentId, holderOf(path), path);
|
|
232
|
+
} finally {
|
|
233
|
+
if (!acquired) lease.release();
|
|
228
234
|
}
|
|
229
|
-
throw stillRunning(agentId, holderOf(path), path);
|
|
230
235
|
}
|
package/src/task/tool.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { isAbsolute, join, relative } from 'node:path';
|
|
|
5
5
|
import { getSupportedThinkingLevels } from '@earendil-works/pi-ai';
|
|
6
6
|
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
7
7
|
import { type Static, Type } from 'typebox';
|
|
8
|
-
import { childIdentity, childVariables } from '../child-env.ts';
|
|
8
|
+
import { BUILD_PLAN_ENV, childIdentity, childVariables } from '../child-env.ts';
|
|
9
9
|
import { rootStoreDir, subagentDir, workspaceStateDir, worktreesDir } from '../paths.ts';
|
|
10
10
|
import { piCommand } from '../pi-command.ts';
|
|
11
11
|
import { type AgentDefinition, findAgent } from './agents.ts';
|
|
@@ -111,6 +111,7 @@ export interface Launch {
|
|
|
111
111
|
attachments: string[];
|
|
112
112
|
notes: string[];
|
|
113
113
|
description: string;
|
|
114
|
+
buildPlan?: string;
|
|
114
115
|
}
|
|
115
116
|
|
|
116
117
|
const runsReadonly = (plan: Launch) => plan.readonly || plan.planMode;
|
|
@@ -269,7 +270,11 @@ export function planFork(params: TaskInput, ctx: ExtensionContext, agents: reado
|
|
|
269
270
|
|
|
270
271
|
function launchChild(plan: Launch, input: ChildInput, signal: AbortSignal | undefined, onEvent?: (event: { type: string } & Record<string, unknown>) => void) {
|
|
271
272
|
const readonly = runsReadonly(plan);
|
|
272
|
-
const env = {
|
|
273
|
+
const env = {
|
|
274
|
+
...process.env,
|
|
275
|
+
...childVariables(plan.agent.type, plan.depth, readonly, plan.rootStore, workspaceStateDir(plan.workspace), plan.rootWorkspace),
|
|
276
|
+
...(plan.buildPlan === undefined ? {} : { [BUILD_PLAN_ENV]: plan.buildPlan }),
|
|
277
|
+
};
|
|
273
278
|
const transcript = plan.kind === 'fresh' || readonly ? undefined : transcriptFor(subagentDir(plan.workspace), plan.agentId);
|
|
274
279
|
const restoreTools = transcript === undefined ? [] : readonlyLaunchRestores(transcript);
|
|
275
280
|
const args = childArgs({
|
|
@@ -501,6 +506,8 @@ export interface StartBackgroundOptions {
|
|
|
501
506
|
environment?: 'cloud';
|
|
502
507
|
/** The owner's project-trust decision, used when this subscription fire starts away from its chat. */
|
|
503
508
|
trusted?: boolean;
|
|
509
|
+
/** Imports the reviewed plan's todos into the worker before its first model request. */
|
|
510
|
+
buildPlan?: string;
|
|
504
511
|
}
|
|
505
512
|
|
|
506
513
|
export interface TaskHandle {
|
|
@@ -582,6 +589,7 @@ export function registerTask(pi: ExtensionAPI, options: TaskToolOptions): TaskHa
|
|
|
582
589
|
taskPreflight(params, options.depth);
|
|
583
590
|
const { plan, lock } = prepare(params, ctx, options, registry.isRunning);
|
|
584
591
|
if (startOptions?.trusted !== undefined) plan.trusted = startOptions.trusted;
|
|
592
|
+
if (startOptions?.buildPlan !== undefined) plan.buildPlan = startOptions.buildPlan;
|
|
585
593
|
await createWorktree(plan, lock, undefined);
|
|
586
594
|
const handle = start(plan, { text: prompt.trim(), images: [] }, undefined, lock);
|
|
587
595
|
registry.track(plan.agentId, handle, lock.release, runsReadonly(plan), backgroundNotice(plan, prompt, description, ctx, `pstack-${plan.agentId}`));
|