@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/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 { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from '@earendil-works/pi-coding-agent';
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('---', 'todos:');
149
- for (const todo of todos) lines.push(` - id: ${yamlString(todo.id)}`, ` content: ${yamlString(todo.content)}`, ' status: pending');
150
- lines.push('isProject: false', '---');
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
- .replace(/^<!-- [^\n]*-->\n/, '')
172
- .replace(/^---\n[\s\S]*?\n---\n/, '')
173
- .trim();
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
- Type.Array(Type.Object({ id: Type.String({ description: 'Unique identifier for the todo' }), content: Type.String({ description: 'Description of the todo task' }) }), { description: 'Array of implementation todos' }),
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 = {}): { section: () => string | undefined; enabled: () => boolean } {
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 stored = planFromBranch(ctx.sessionManager.getBranch());
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) => restore(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 { section: () => (state.enabled ? planSection(state.planFile) : undefined), enabled: () => state.enabled };
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
+ }
@@ -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
- // allowAllUnixSockets skips apply-seccomp: on GitHub-hosted runners (and other nested-userns hosts) that
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: true },
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
- // initialize verifies the sandbox dependencies (bubblewrap, socat, sandbox-exec) before it starts.
44
- // A second initialize is a no-op once another consumer has started the manager, and several
45
- // Linux flags (notably allowAllUnixSockets) are read from the manager config, not the per-call
46
- // wrap override — so updateConfig installs our policy even when we lost the initialize race.
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, rmdirSync, rmSync, writeFileSync } from 'node:fs';
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,
@@ -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 dead holder's lock is removed only if it still names that holder, then created again exclusively; two
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
- for (let attempt = 0; attempt < 2; attempt += 1) {
208
- if (createExclusive(path)) {
209
- let owner = process.pid;
210
- return {
211
- holdFor: (pid) => {
212
- writeAtomically(path, String(pid));
213
- owner = pid;
214
- },
215
- // Best effort: a lock left behind names a pid that will be dead, so the next acquire takes it over.
216
- release: () => {
217
- try {
218
- if (holderOf(path) === owner) rmSync(path, { force: true });
219
- } catch (error) {
220
- if (!['EACCES', 'EPERM', 'EROFS', 'EBUSY', 'EIO'].includes((error as NodeJS.ErrnoException).code ?? '')) throw error;
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
- const holder = holderOf(path);
226
- if (holder !== undefined && (!isPid(holder) || alive(holder))) throw stillRunning(agentId, holder, path);
227
- if (holder !== undefined && holderOf(path) === holder) rmSync(path, { force: true });
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 = { ...process.env, ...childVariables(plan.agent.type, plan.depth, readonly, plan.rootStore, workspaceStateDir(plan.workspace), plan.rootWorkspace) };
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}`));