praxis-agent 0.60.0 → 0.60.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -102,6 +102,7 @@ Common non-interactive operations:
102
102
 
103
103
  ```sh
104
104
  praxis -p "Inspect this project"
105
+ printf 'Inspect this project\n' | praxis -p
105
106
  praxis -p --output-format json "Summarize the test failures"
106
107
  praxis --resume
107
108
  praxis sessions --json
@@ -213,7 +214,9 @@ troubleshooting. Run `praxis --help` for the authoritative command surface.
213
214
  Claude-compatible main-thread agent definitions with native prompt, model,
214
215
  tool, memory, first-turn, and resume behavior. Agent execution uses one
215
216
  durable lifecycle vocabulary with bounded cancellation and drain,
216
- continuation, notifications, and single-owner orphan recovery. Experimental
217
+ continuation, notifications, and single-owner orphan recovery. Isolated
218
+ Workflow turns use ownership-recorded repo-local temporary worktrees and
219
+ retain dirty or committed results for inspection. Experimental
217
220
  local Teams (`PRAXIS_ENABLE_TEAMS=true`) stay absent from ordinary startup by
218
221
  default and add durable task ownership plus one ordered mailbox with stable
219
222
  identities, fixed broadcast recipients, durable cursors, bounded retention,
@@ -6,6 +6,16 @@ export interface ManagedWorktree {
6
6
  cwd: string;
7
7
  cleanup(): Promise<ManagedWorktreeCleanup>;
8
8
  }
9
+ export interface OwnedManagedWorktreeOptions {
10
+ cwd: string;
11
+ stateRoot: string;
12
+ directoryName: string;
13
+ ownerId: string;
14
+ label: 'Agent' | 'Workflow' | 'Team';
15
+ kind: 'workflow' | 'agent' | 'team';
16
+ policy: 'ephemeral' | 'durable';
17
+ }
18
+ export declare function createOwnedManagedWorktree(options: OwnedManagedWorktreeOptions): Promise<ManagedWorktree>;
9
19
  export declare function createManagedWorktree(options: {
10
20
  cwd: string;
11
21
  parentDirectory: string;
@@ -1,17 +1,567 @@
1
1
  import { execFile } from 'node:child_process';
2
- import { lstat, mkdir, realpath } from 'node:fs/promises';
3
- import { isAbsolute, join, resolve } from 'node:path';
2
+ import { createHash } from 'node:crypto';
3
+ import { constants } from 'node:fs';
4
+ import { lstat, mkdir, open, realpath } from 'node:fs/promises';
5
+ import { isAbsolute, join, relative, resolve, sep } from 'node:path';
6
+ import { setTimeout as sleep } from 'node:timers/promises';
4
7
  import { promisify } from 'node:util';
8
+ import { ManagedWorktreeStore, } from '../persistence/managed-worktree-store.js';
9
+ import { resolveProjectIdentity } from '../platform/project-identity.js';
10
+ import { writeFileAtomically } from '../platform/atomic-write.js';
11
+ import { ExclusiveFileLease, } from '../platform/exclusive-file-lease.js';
5
12
  const execFileAsync = promisify(execFile);
13
+ async function gitRaw(cwd, args) {
14
+ return (await execFileAsync('git', ['-C', cwd, ...args], { encoding: 'utf8' })).stdout;
15
+ }
6
16
  async function git(cwd, args) {
7
- return (await execFileAsync('git', ['-C', cwd, ...args], { encoding: 'utf8' })).stdout.trim();
17
+ return (await gitRaw(cwd, args)).trim();
18
+ }
19
+ async function gitOptional(cwd, args) {
20
+ try {
21
+ return await git(cwd, args);
22
+ }
23
+ catch (error) {
24
+ if (error.code === 1)
25
+ return null;
26
+ throw error;
27
+ }
8
28
  }
9
29
  async function registeredWorktrees(root) {
10
- const output = await git(root, ['worktree', 'list', '--porcelain']);
30
+ const output = await gitRaw(root, ['worktree', 'list', '--porcelain', '-z']);
11
31
  return new Set(output
12
- .split('\n')
32
+ .split('\0')
13
33
  .filter((line) => line.startsWith('worktree '))
14
- .map((line) => resolve(line.slice('worktree '.length))));
34
+ .map((line) => resolve(root, line.slice('worktree '.length))));
35
+ }
36
+ const COMPONENT_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,191}$/u;
37
+ const MARKER_FIELDS = new Set(['version', 'worktreeId', 'repositoryRoot']);
38
+ const LOCAL_EXCLUDE_PATTERN = '/.praxis/worktrees/';
39
+ function managedWorktreeId(repositoryRoot, kind, ownerId) {
40
+ return createHash('sha256')
41
+ .update(`${repositoryRoot}\0${kind}\0${ownerId}`)
42
+ .digest('hex');
43
+ }
44
+ function validOwnerId(ownerId) {
45
+ return (ownerId.length > 0 &&
46
+ ownerId.length <= 256 &&
47
+ !Array.from(ownerId).some((character) => {
48
+ const code = character.codePointAt(0) ?? 0;
49
+ return code <= 0x1f || code === 0x7f;
50
+ }));
51
+ }
52
+ function worktreeError(options, message) {
53
+ return new Error(`${options.label} worktree ${message}`);
54
+ }
55
+ async function assertRealDirectory(path, description) {
56
+ let current = resolve(path);
57
+ const parts = [];
58
+ while (true) {
59
+ parts.unshift(current);
60
+ const parent = resolve(current, '..');
61
+ if (parent === current)
62
+ break;
63
+ current = parent;
64
+ }
65
+ for (const part of parts) {
66
+ try {
67
+ const entry = await lstat(part);
68
+ if (entry.isSymbolicLink())
69
+ throw new Error(`${description} must not be a symlink`);
70
+ if (!entry.isDirectory())
71
+ throw new Error(`${description} must be a directory`);
72
+ }
73
+ catch (error) {
74
+ if (error.code === 'ENOENT')
75
+ continue;
76
+ throw error;
77
+ }
78
+ }
79
+ }
80
+ async function acquireLease(lease, description) {
81
+ for (let attempt = 0; attempt < 400; attempt += 1) {
82
+ const handle = await lease.tryAcquire();
83
+ if (handle)
84
+ return handle;
85
+ await sleep(5);
86
+ }
87
+ throw new Error(`Timed out acquiring ${description}`);
88
+ }
89
+ async function readRegularFile(path, description) {
90
+ let handle;
91
+ try {
92
+ handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
93
+ }
94
+ catch (error) {
95
+ if (error.code === 'ELOOP') {
96
+ throw new Error(`${description} must be a regular file`);
97
+ }
98
+ throw error;
99
+ }
100
+ try {
101
+ if (!(await handle.stat()).isFile()) {
102
+ throw new Error(`${description} must be a regular file`);
103
+ }
104
+ return await handle.readFile({ encoding: 'utf8' });
105
+ }
106
+ finally {
107
+ await handle.close();
108
+ }
109
+ }
110
+ async function ensureManagedRootIgnored(repositoryRoot) {
111
+ const commonValue = await git(repositoryRoot, [
112
+ 'rev-parse',
113
+ '--git-common-dir',
114
+ ]);
115
+ const commonDirectory = await realpath(isAbsolute(commonValue)
116
+ ? commonValue
117
+ : resolve(repositoryRoot, commonValue));
118
+ const infoDirectory = join(commonDirectory, 'info');
119
+ await assertRealDirectory(commonDirectory, 'Git common directory');
120
+ await assertRealDirectory(infoDirectory, 'Git info directory');
121
+ await mkdir(infoDirectory, { recursive: true, mode: 0o700 });
122
+ await assertRealDirectory(infoDirectory, 'Git info directory');
123
+ const excludePath = join(infoDirectory, 'exclude');
124
+ const lease = await acquireLease(new ExclusiveFileLease(`${excludePath}.praxis.lock`), 'managed worktree ignore lock');
125
+ try {
126
+ let source = '';
127
+ try {
128
+ source = await readRegularFile(excludePath, 'Git info exclude');
129
+ }
130
+ catch (error) {
131
+ if (error.code !== 'ENOENT')
132
+ throw error;
133
+ }
134
+ const lines = source.split(/\r?\n/u);
135
+ if (lines.includes(LOCAL_EXCLUDE_PATTERN))
136
+ return;
137
+ const prefix = source.length > 0 && !source.endsWith('\n') ? '\n' : '';
138
+ const next = `${source}${prefix}${LOCAL_EXCLUDE_PATTERN}\n`;
139
+ const committed = await writeFileAtomically(excludePath, next, {
140
+ mode: 0o600,
141
+ beforeCommit: async () => {
142
+ try {
143
+ return ((await readRegularFile(excludePath, 'Git info exclude')) === source);
144
+ }
145
+ catch (error) {
146
+ return error.code === 'ENOENT' && !source;
147
+ }
148
+ },
149
+ });
150
+ if (!committed) {
151
+ throw new Error('Git info exclude changed during managed worktree setup');
152
+ }
153
+ }
154
+ finally {
155
+ await lease.release();
156
+ }
157
+ }
158
+ async function linkedGitDirectory(worktreePath) {
159
+ const value = await git(worktreePath, ['rev-parse', '--git-dir']);
160
+ return realpath(isAbsolute(value) ? value : resolve(worktreePath, value));
161
+ }
162
+ async function writeMarker(path, record) {
163
+ const gitDirectory = await linkedGitDirectory(path);
164
+ const marker = join(gitDirectory, 'PRAXIS_WORKTREE');
165
+ try {
166
+ await lstat(marker);
167
+ throw new Error('Managed worktree marker already exists');
168
+ }
169
+ catch (error) {
170
+ if (error.code !== 'ENOENT')
171
+ throw error;
172
+ }
173
+ const committed = await writeFileAtomically(marker, `${JSON.stringify({ version: 1, worktreeId: record.worktreeId, repositoryRoot: record.repositoryRoot })}\n`, {
174
+ mode: 0o600,
175
+ beforeCommit: async () => {
176
+ try {
177
+ await lstat(marker);
178
+ return false;
179
+ }
180
+ catch (error) {
181
+ return error.code === 'ENOENT';
182
+ }
183
+ },
184
+ });
185
+ if (!committed)
186
+ throw new Error('Managed worktree marker already exists');
187
+ }
188
+ async function readMarker(worktreePath, record) {
189
+ const markerPath = join(await linkedGitDirectory(worktreePath), 'PRAXIS_WORKTREE');
190
+ let marker;
191
+ try {
192
+ marker = JSON.parse(await readRegularFile(markerPath, 'worktree marker'));
193
+ }
194
+ catch {
195
+ throw new Error('worktree marker is invalid');
196
+ }
197
+ if (typeof marker !== 'object' ||
198
+ marker === null ||
199
+ Array.isArray(marker) ||
200
+ Object.keys(marker).length !== MARKER_FIELDS.size ||
201
+ Object.keys(marker).some((key) => !MARKER_FIELDS.has(key)) ||
202
+ marker.version !== 1 ||
203
+ marker.worktreeId !== record.worktreeId ||
204
+ marker.repositoryRoot !== record.repositoryRoot) {
205
+ throw new Error('worktree marker does not match ownership record');
206
+ }
207
+ }
208
+ async function inspectOwnedCheckout(record, registered) {
209
+ const registrations = registered ?? (await registeredWorktrees(record.repositoryRoot));
210
+ const pathEntry = await lstat(record.worktreePath);
211
+ if (pathEntry.isSymbolicLink() || !pathEntry.isDirectory()) {
212
+ throw new Error('worktree path is not a real directory');
213
+ }
214
+ await assertRealDirectory(record.worktreePath, 'managed worktree path');
215
+ if ((await realpath(record.worktreePath)) !== resolve(record.worktreePath)) {
216
+ throw new Error('worktree path is not canonical');
217
+ }
218
+ if (!registrations.has(resolve(record.worktreePath))) {
219
+ throw new Error('worktree is not registered');
220
+ }
221
+ const [status, head, topLevel] = await Promise.all([
222
+ git(record.worktreePath, ['status', '--porcelain']),
223
+ git(record.worktreePath, ['rev-parse', 'HEAD']),
224
+ git(record.worktreePath, ['rev-parse', '--show-toplevel']),
225
+ ]);
226
+ const worktreeRoot = await realpath(resolve(record.worktreePath, topLevel));
227
+ if (worktreeRoot !== resolve(record.worktreePath)) {
228
+ throw new Error('registered worktree root does not match');
229
+ }
230
+ const root = await resolveProjectIdentity(record.worktreePath);
231
+ if (root !== record.repositoryRoot) {
232
+ throw new Error('repository identity does not match');
233
+ }
234
+ await readMarker(record.worktreePath, record);
235
+ const branch = await gitOptional(record.worktreePath, [
236
+ 'symbolic-ref',
237
+ '--quiet',
238
+ '--short',
239
+ 'HEAD',
240
+ ]);
241
+ if (branch)
242
+ throw new Error('worktree is not detached');
243
+ return { status, head };
244
+ }
245
+ function sameOwnership(left, right) {
246
+ return (left.worktreeId === right.worktreeId &&
247
+ left.kind === right.kind &&
248
+ left.policy === right.policy &&
249
+ left.ownerId === right.ownerId &&
250
+ left.repositoryRoot === right.repositoryRoot &&
251
+ left.worktreePath === right.worktreePath &&
252
+ left.branch === right.branch &&
253
+ left.baseCommit === right.baseCommit &&
254
+ left.createdAt === right.createdAt);
255
+ }
256
+ function nextRecord(record, state, retentionReason) {
257
+ const base = { ...record };
258
+ delete base.retentionReason;
259
+ return {
260
+ ...base,
261
+ state,
262
+ updatedAt: new Date().toISOString(),
263
+ ...(retentionReason === undefined ? {} : { retentionReason }),
264
+ };
265
+ }
266
+ async function retain(store, record, reason) {
267
+ try {
268
+ await store.update(nextRecord(record, 'retained', reason));
269
+ return { retained: true, reason };
270
+ }
271
+ catch (error) {
272
+ return {
273
+ retained: true,
274
+ reason: `${reason}; could not persist retention state: ${error instanceof Error ? error.message : String(error)}`,
275
+ };
276
+ }
277
+ }
278
+ export async function createOwnedManagedWorktree(options) {
279
+ if (!COMPONENT_PATTERN.test(options.directoryName)) {
280
+ throw worktreeError(options, 'name is invalid');
281
+ }
282
+ if (!validOwnerId(options.ownerId)) {
283
+ throw worktreeError(options, 'owner ID is invalid');
284
+ }
285
+ if (!['workflow', 'agent', 'team'].includes(options.kind)) {
286
+ throw worktreeError(options, 'kind is invalid');
287
+ }
288
+ let repositoryRoot;
289
+ let baseCommit;
290
+ try {
291
+ ;
292
+ [repositoryRoot, baseCommit] = await Promise.all([
293
+ resolveProjectIdentity(options.cwd),
294
+ git(options.cwd, ['rev-parse', 'HEAD']),
295
+ ]);
296
+ }
297
+ catch {
298
+ throw worktreeError(options, 'isolation requires a Git repository');
299
+ }
300
+ const kindRoot = join(repositoryRoot, '.praxis', 'worktrees', options.kind);
301
+ const worktreePath = join(kindRoot, options.directoryName);
302
+ const rootRelative = relative(kindRoot, worktreePath);
303
+ if (!rootRelative ||
304
+ rootRelative === '..' ||
305
+ rootRelative.startsWith(`..${sep}`) ||
306
+ isAbsolute(rootRelative)) {
307
+ throw worktreeError(options, 'path escapes its kind root');
308
+ }
309
+ await assertRealDirectory(join(repositoryRoot, '.praxis'), 'managed worktree parent');
310
+ await assertRealDirectory(join(repositoryRoot, '.praxis', 'worktrees'), 'managed worktree parent');
311
+ await assertRealDirectory(kindRoot, 'managed worktree kind root');
312
+ await ensureManagedRootIgnored(repositoryRoot);
313
+ const worktreeId = managedWorktreeId(repositoryRoot, options.kind, options.ownerId);
314
+ const store = new ManagedWorktreeStore(options.stateRoot, repositoryRoot, worktreeId);
315
+ const lease = await store.acquireLease();
316
+ if (!lease)
317
+ throw worktreeError(options, `is already owned: ${worktreePath}`);
318
+ const now = new Date().toISOString();
319
+ const record = {
320
+ version: 1,
321
+ worktreeId,
322
+ kind: options.kind,
323
+ policy: options.policy,
324
+ ownerId: options.ownerId,
325
+ repositoryRoot,
326
+ worktreePath,
327
+ branch: null,
328
+ baseCommit,
329
+ state: 'creating',
330
+ createdAt: now,
331
+ updatedAt: now,
332
+ };
333
+ let recordCreated = false;
334
+ let gitCreated = false;
335
+ let markerCreated = false;
336
+ let created = false;
337
+ try {
338
+ await store.create(record);
339
+ recordCreated = true;
340
+ try {
341
+ const entry = await lstat(worktreePath);
342
+ if (entry.isSymbolicLink()) {
343
+ throw worktreeError(options, 'path must not be a symlink');
344
+ }
345
+ throw worktreeError(options, `path already exists: ${worktreePath}`);
346
+ }
347
+ catch (error) {
348
+ if (error.code !== 'ENOENT')
349
+ throw error;
350
+ }
351
+ await mkdir(kindRoot, { recursive: true });
352
+ await assertRealDirectory(kindRoot, 'managed worktree kind root');
353
+ await git(repositoryRoot, [
354
+ 'worktree',
355
+ 'add',
356
+ '--detach',
357
+ worktreePath,
358
+ baseCommit,
359
+ ]);
360
+ gitCreated = true;
361
+ await writeMarker(worktreePath, record);
362
+ markerCreated = true;
363
+ await store.update(nextRecord(record, 'active'));
364
+ created = true;
365
+ }
366
+ catch (error) {
367
+ let rollbackError;
368
+ let uncertainArtifact = false;
369
+ if (gitCreated) {
370
+ try {
371
+ if (!markerCreated) {
372
+ throw new Error('ownership marker was not published');
373
+ }
374
+ const inspection = await inspectOwnedCheckout(record);
375
+ if (inspection.status.length > 0) {
376
+ throw new Error('created worktree is no longer clean');
377
+ }
378
+ if (inspection.head !== record.baseCommit) {
379
+ throw new Error('created worktree HEAD no longer matches its base');
380
+ }
381
+ await git(repositoryRoot, ['worktree', 'remove', worktreePath]);
382
+ }
383
+ catch (removeError) {
384
+ rollbackError = removeError;
385
+ }
386
+ }
387
+ else {
388
+ try {
389
+ const registered = await registeredWorktrees(repositoryRoot);
390
+ const pathExists = await lstat(worktreePath)
391
+ .then(() => true)
392
+ .catch((pathError) => {
393
+ if (pathError.code === 'ENOENT') {
394
+ return false;
395
+ }
396
+ throw pathError;
397
+ });
398
+ uncertainArtifact = pathExists || registered.has(resolve(worktreePath));
399
+ }
400
+ catch {
401
+ uncertainArtifact = true;
402
+ }
403
+ }
404
+ if (recordCreated) {
405
+ try {
406
+ const retentionReason = rollbackError
407
+ ? `Could not roll back ${worktreePath}: ${rollbackError.message}`
408
+ : uncertainArtifact
409
+ ? `Creation failed with an unverified artifact at ${worktreePath}`
410
+ : undefined;
411
+ const failed = retentionReason
412
+ ? nextRecord(record, 'retained', retentionReason)
413
+ : nextRecord(record, 'released');
414
+ await store.update(failed);
415
+ }
416
+ catch (stateError) {
417
+ rollbackError ??= stateError;
418
+ }
419
+ }
420
+ const primary = error instanceof Error ? error.message : String(error);
421
+ throw worktreeError(options, `could not be created: ${primary}${rollbackError ? `; cleanup warning: ${rollbackError.message}` : ''}`);
422
+ }
423
+ finally {
424
+ if (!created)
425
+ await lease.release();
426
+ }
427
+ let executionLease = lease;
428
+ let removedResult;
429
+ let cleanupInFlight;
430
+ return {
431
+ cwd: worktreePath,
432
+ cleanup: async () => {
433
+ if (removedResult)
434
+ return removedResult;
435
+ if (cleanupInFlight)
436
+ return cleanupInFlight;
437
+ const heldLease = executionLease;
438
+ executionLease = undefined;
439
+ cleanupInFlight = cleanupOwnedManagedWorktree(store, options, record, heldLease);
440
+ try {
441
+ const result = await cleanupInFlight;
442
+ if (!result.retained)
443
+ removedResult = result;
444
+ return result;
445
+ }
446
+ finally {
447
+ cleanupInFlight = undefined;
448
+ }
449
+ },
450
+ };
451
+ }
452
+ async function cleanupOwnedManagedWorktree(store, options, original, heldLease) {
453
+ const lease = heldLease ?? (await store.acquireLease());
454
+ if (!lease)
455
+ return {
456
+ retained: true,
457
+ reason: `${options.label} worktree cleanup is already in progress`,
458
+ };
459
+ try {
460
+ let record;
461
+ try {
462
+ record = await store.read();
463
+ }
464
+ catch (error) {
465
+ return {
466
+ retained: true,
467
+ reason: `Could not inspect ${options.label.toLowerCase()} worktree record: ${error.message}`,
468
+ };
469
+ }
470
+ if (!sameOwnership(record, original)) {
471
+ return {
472
+ retained: true,
473
+ reason: `${options.label} worktree ownership record does not match`,
474
+ };
475
+ }
476
+ if (record.state === 'released')
477
+ return { retained: false };
478
+ if (record.state !== 'active' &&
479
+ record.state !== 'retained' &&
480
+ record.state !== 'releasing') {
481
+ return {
482
+ retained: true,
483
+ reason: `${options.label} worktree is not releasable`,
484
+ };
485
+ }
486
+ if (record.policy === 'durable') {
487
+ return retain(store, record, `${options.label} worktree uses durable retention policy`);
488
+ }
489
+ const releasing = nextRecord(record, 'releasing');
490
+ try {
491
+ await store.update(releasing);
492
+ }
493
+ catch (error) {
494
+ return {
495
+ retained: true,
496
+ reason: `Could not update ${options.label.toLowerCase()} worktree record: ${error.message}`,
497
+ };
498
+ }
499
+ const registered = await registeredWorktrees(record.repositoryRoot).catch(() => null);
500
+ if (!registered) {
501
+ return retain(store, releasing, `Could not inspect registered ${options.label.toLowerCase()} worktrees`);
502
+ }
503
+ try {
504
+ await lstat(record.worktreePath);
505
+ }
506
+ catch (error) {
507
+ if (error.code !== 'ENOENT') {
508
+ return retain(store, releasing, `Could not inspect ${options.label.toLowerCase()} worktree path: ${error instanceof Error ? error.message : String(error)}`);
509
+ }
510
+ if (registered.has(resolve(record.worktreePath))) {
511
+ return retain(store, releasing, `${options.label} worktree is missing but remains registered at ${record.worktreePath}`);
512
+ }
513
+ try {
514
+ await store.update(nextRecord(releasing, 'released'));
515
+ return { retained: false };
516
+ }
517
+ catch (stateError) {
518
+ return {
519
+ retained: false,
520
+ reason: `${options.label} worktree is already absent but release state could not be persisted: ${stateError instanceof Error ? stateError.message : String(stateError)}`,
521
+ };
522
+ }
523
+ }
524
+ let inspection;
525
+ try {
526
+ inspection = await inspectOwnedCheckout(record, registered);
527
+ }
528
+ catch (error) {
529
+ const reason = `Could not verify ${options.label.toLowerCase()} worktree ${record.worktreePath}: ${error.message}`;
530
+ return retain(store, releasing, reason);
531
+ }
532
+ if (inspection.status.length > 0) {
533
+ const reason = `${options.label} worktree has uncommitted changes and was retained at ${record.worktreePath}`;
534
+ return retain(store, releasing, reason);
535
+ }
536
+ if (inspection.head !== record.baseCommit) {
537
+ const reason = `${options.label} worktree has commits and was retained at ${record.worktreePath}`;
538
+ return retain(store, releasing, reason);
539
+ }
540
+ try {
541
+ await git(record.repositoryRoot, [
542
+ 'worktree',
543
+ 'remove',
544
+ record.worktreePath,
545
+ ]);
546
+ }
547
+ catch (error) {
548
+ const reason = `Could not remove ${options.label.toLowerCase()} worktree ${record.worktreePath}: ${error.message}`;
549
+ return retain(store, releasing, reason);
550
+ }
551
+ try {
552
+ await store.update(nextRecord(releasing, 'released'));
553
+ }
554
+ catch (error) {
555
+ return {
556
+ retained: false,
557
+ reason: `Could not update ${options.label.toLowerCase()} worktree record after removal: ${error.message}`,
558
+ };
559
+ }
560
+ return { retained: false };
561
+ }
562
+ finally {
563
+ await lease.release();
564
+ }
15
565
  }
16
566
  export async function createManagedWorktree(options) {
17
567
  if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,191}$/u.test(options.directoryName)) {
@@ -1,11 +1,13 @@
1
- import { join } from 'node:path';
2
- import { createManagedWorktree, } from './managed-worktree.js';
1
+ import { createOwnedManagedWorktree, } from './managed-worktree.js';
3
2
  export async function createWorkflowWorktree(options) {
4
- return createManagedWorktree({
3
+ return createOwnedManagedWorktree({
5
4
  cwd: options.cwd,
6
- parentDirectory: join(options.praxisRoot, 'workflow-worktrees'),
5
+ stateRoot: options.praxisRoot,
7
6
  directoryName: `${options.runId}-${options.agentId}`,
7
+ ownerId: `workflow:${options.runId}:${options.agentId}`,
8
8
  label: 'Workflow',
9
+ kind: 'workflow',
10
+ policy: 'ephemeral',
9
11
  });
10
12
  }
11
13
  //# sourceMappingURL=workflow-worktree.js.map
@@ -243,6 +243,7 @@ export interface ProtocolTimingProjection {
243
243
  }
244
244
  export declare function projectProtocolTimings(startedAt: number, requestAt?: number, outputAt?: number): ProtocolTimingProjection;
245
245
  export declare function createSuccessResult(result: ProtocolResult, info: CliRuntimeInfo, startedAt: number, modelTurns: number, context?: ProtocolSuccessProjectionContext): Record<string, unknown>;
246
+ export declare function formatPrintTextError(message: string, context?: ProtocolErrorProjectionContext): string;
246
247
  export declare function createErrorResult(message: string, sessionId: string, startedAt: number, modelTurns: number, context?: ProtocolErrorProjectionContext): Record<string, unknown>;
247
248
  /**
248
249
  * Returns the argument text of a headless `/color` prompt, or undefined when
@@ -94,14 +94,44 @@ export function createSuccessResult(result, info, startedAt, modelTurns, context
94
94
  uuid: randomUUID(),
95
95
  };
96
96
  }
97
+ function classifyErrorResult(message) {
98
+ const maxBudget = /Maximum budget of \$([0-9]+(?:\.[0-9]+)?) exceeded/iu.exec(message);
99
+ if (maxBudget || /maximum budget|budget .* exceeded/iu.test(message)) {
100
+ return {
101
+ subtype: 'error_max_budget_usd',
102
+ ...(maxBudget?.[1] === undefined ? {} : { maxBudgetUsd: maxBudget[1] }),
103
+ };
104
+ }
105
+ const maxTurns = /Maximum model turns of (\d+) exceeded/iu.exec(message);
106
+ if (maxTurns || /maximum model turns|model turn limit/iu.test(message)) {
107
+ return {
108
+ subtype: 'error_max_turns',
109
+ ...(maxTurns?.[1] === undefined ? {} : { maxTurns: maxTurns[1] }),
110
+ };
111
+ }
112
+ if (/StructuredOutput/iu.test(message)) {
113
+ return { subtype: 'error_max_structured_output_retries' };
114
+ }
115
+ return { subtype: 'error_during_execution' };
116
+ }
97
117
  function errorResultSubtype(message) {
98
- if (/maximum budget|budget .* exceeded/iu.test(message))
99
- return 'error_max_budget_usd';
100
- if (/maximum model turns|model turn limit/iu.test(message))
101
- return 'error_max_turns';
102
- if (/StructuredOutput/iu.test(message))
103
- return 'error_max_structured_output_retries';
104
- return 'error_during_execution';
118
+ return classifyErrorResult(message).subtype;
119
+ }
120
+ export function formatPrintTextError(message, context = {}) {
121
+ if (context.providerApiError === true) {
122
+ const normalized = typeof context.apiErrorStatus === 'number'
123
+ ? normalizeApiError(message, context.apiErrorStatus)
124
+ : message;
125
+ return `${normalized}\n`;
126
+ }
127
+ const classification = classifyErrorResult(message);
128
+ if (classification.maxTurns !== undefined)
129
+ return `Error: Reached max turns (${classification.maxTurns})`;
130
+ if (classification.maxBudgetUsd !== undefined)
131
+ return `Error: Exceeded USD budget (${classification.maxBudgetUsd})`;
132
+ if (classification.subtype === 'error_max_structured_output_retries')
133
+ return 'Error: Failed to provide valid structured output after maximum retries';
134
+ return 'Execution error';
105
135
  }
106
136
  export function createErrorResult(message, sessionId, startedAt, modelTurns, context = {}) {
107
137
  const duration = Date.now() - startedAt;
@@ -34,6 +34,7 @@ export interface CliIO {
34
34
  stdout(message: string | Uint8Array): void;
35
35
  stderr(message: string): void;
36
36
  isTTY?: boolean;
37
+ stdinIsTTY?: boolean;
37
38
  readStdinLines?: () => AsyncIterable<string | Uint8Array>;
38
39
  readSecret?: (prompt: string, signal?: AbortSignal) => Promise<string>;
39
40
  }
@@ -65,7 +65,7 @@ import { WorkspaceContext } from './application/session-worktree.js';
65
65
  import { launchTmuxWorktree } from './platform/tmux-worktree.js';
66
66
  import { claudeSandboxRuntime } from './sandbox/claude-sandbox-runtime.js';
67
67
  import { nativeSandboxTempDirectory, loadClaudeSandboxSettings, } from './sandbox/claude-sandbox-settings.js';
68
- import { createErrorResult, createSuccessResult, isHeadlessCostCommand, matchHeadlessColorCommand, parseCliInvocation, projectProtocolTimings, readStreamJsonMessages, StreamJsonOutput, } from './cli/protocol.js';
68
+ import { createErrorResult, createSuccessResult, isHeadlessCostCommand, matchHeadlessColorCommand, formatPrintTextError, parseCliInvocation, projectProtocolTimings, readStreamJsonMessages, StreamJsonOutput, } from './cli/protocol.js';
69
69
  import { isDirectProcessSigint } from './cli/process-signal.js';
70
70
  import { executeProviderAuthCommand } from './cli/provider-auth-command.js';
71
71
  import { describeClaudePlugin, initClaudePlugin, installClaudePlugin, loadClaudePlugins, readPluginRegistry, setClaudePluginEnabled, uninstallClaudePlugin, updateClaudePlugin, validateClaudePlugin, } from './plugins/claude-plugin-runtime.js';
@@ -893,6 +893,7 @@ const consoleIO = {
893
893
  stdout: (message) => process.stdout.write(message),
894
894
  stderr: (message) => process.stderr.write(message),
895
895
  isTTY: Boolean(process.stdin.isTTY && process.stdout.isTTY),
896
+ stdinIsTTY: Boolean(process.stdin.isTTY),
896
897
  readStdinLines: () => process.stdin,
897
898
  readSecret: readConsoleSecret,
898
899
  };
@@ -4243,7 +4244,7 @@ async function executeMcpCommand(args, invocation, io, dependencies, signal) {
4243
4244
  }
4244
4245
  throw new Error(`Unknown mcp command: ${action}`);
4245
4246
  }
4246
- function eventSink(io, outputFormat, legacyJson = false) {
4247
+ function eventSink(io, outputFormat, legacyJson = false, printText = false) {
4247
4248
  const sensitiveValues = sensitiveEnvironmentValues(process.env);
4248
4249
  if (legacyJson) {
4249
4250
  return (event) => {
@@ -4261,6 +4262,12 @@ function eventSink(io, outputFormat, legacyJson = false) {
4261
4262
  }
4262
4263
  if (outputFormat !== 'text')
4263
4264
  return () => undefined;
4265
+ if (printText)
4266
+ return (event) => {
4267
+ if (event.type === 'warning') {
4268
+ io.stderr(`Warning: ${redactSensitiveText(event.message, sensitiveValues)}\n`);
4269
+ }
4270
+ };
4264
4271
  let turnBuffered = false;
4265
4272
  let bufferedText = '';
4266
4273
  const flushBufferedText = () => {
@@ -4313,6 +4320,29 @@ function promptFrom(values) {
4313
4320
  throw new Error('Prompt is required');
4314
4321
  return prompt;
4315
4322
  }
4323
+ async function resolvePrintTextPrompt(io, argvPrompt) {
4324
+ const missingInput = () => new Error('Error: Input must be provided either through stdin or as a prompt argument when using --print');
4325
+ if (io.stdinIsTTY !== false) {
4326
+ if (argvPrompt !== undefined)
4327
+ return argvPrompt;
4328
+ throw missingInput();
4329
+ }
4330
+ const input = io.readStdinLines?.();
4331
+ if (!input)
4332
+ throw new Error('print text input requires stdin support');
4333
+ const chunks = [];
4334
+ for await (const chunk of input) {
4335
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
4336
+ }
4337
+ const stdinText = Buffer.concat(chunks).toString('utf8');
4338
+ if (argvPrompt !== undefined && stdinText.length > 0)
4339
+ return `${argvPrompt}\n${stdinText}`;
4340
+ if (argvPrompt !== undefined)
4341
+ return argvPrompt;
4342
+ if (stdinText.length > 0)
4343
+ return stdinText;
4344
+ throw missingInput();
4345
+ }
4316
4346
  function backgroundWorkerArgv(argv) {
4317
4347
  const filtered = [];
4318
4348
  let optionsEnded = false;
@@ -5578,9 +5608,14 @@ async function execute(argv, io, dependencies, signal) {
5578
5608
  };
5579
5609
  };
5580
5610
  const headlessPromptArgs = command === 'resume' ? args.slice(2) : knownCommand ? args.slice(1) : args;
5581
- const headlessPrompt = inputFormat === 'text' && headlessPromptArgs.length > 0
5611
+ const headlessTurnReached = invocation.rewindFiles === undefined &&
5612
+ !['sessions', 'fork', 'inspect', 'export'].includes(command ?? 'run');
5613
+ const argvHeadlessPrompt = inputFormat === 'text' && headlessPromptArgs.length > 0
5582
5614
  ? promptFrom(headlessPromptArgs)
5583
5615
  : undefined;
5616
+ const headlessPrompt = inputFormat === 'text' && headlessTurnReached && invocation.print
5617
+ ? await resolvePrintTextPrompt(io, argvHeadlessPrompt)
5618
+ : argvHeadlessPrompt;
5584
5619
  if (headlessPrompt === '/release-notes' && !invocation.disableSlashCommands) {
5585
5620
  const startedAt = Date.now();
5586
5621
  const sessionId = invocation.sessionId ?? randomUUID();
@@ -5629,8 +5664,6 @@ async function execute(argv, io, dependencies, signal) {
5629
5664
  }
5630
5665
  return 0;
5631
5666
  }
5632
- const headlessTurnReached = invocation.rewindFiles === undefined &&
5633
- !['sessions', 'fork', 'inspect', 'export'].includes(command ?? 'run');
5634
5667
  if (streamIterator && headlessTurnReached) {
5635
5668
  const first = await nextStreamUser();
5636
5669
  if (first)
@@ -5686,7 +5719,7 @@ async function execute(argv, io, dependencies, signal) {
5686
5719
  io.stderr(`Warning: ${redactSensitiveText(event.message, sensitiveEnvironmentValues(process.env))}\n`);
5687
5720
  }
5688
5721
  }
5689
- : eventSink(io, outputFormat, invocation.legacyJson),
5722
+ : eventSink(io, outputFormat, invocation.legacyJson, invocation.print && inputFormat === 'text'),
5690
5723
  requireProvider: !streamInputExhausted &&
5691
5724
  !firstTurnIsLocalColor &&
5692
5725
  !firstTurnIsLocalCost &&
@@ -5818,6 +5851,7 @@ async function execute(argv, io, dependencies, signal) {
5818
5851
  if (streamInputExhausted)
5819
5852
  return 0;
5820
5853
  const initialPrompt = firstStreamMessage?.prompt ??
5854
+ headlessPrompt ??
5821
5855
  promptFrom(command === 'resume'
5822
5856
  ? args.slice(2)
5823
5857
  : knownCommand
@@ -5833,7 +5867,6 @@ async function execute(argv, io, dependencies, signal) {
5833
5867
  signal?.addEventListener('abort', forwardAbort, { once: true });
5834
5868
  currentTurnAbort = streamIterator ? turnAbort : undefined;
5835
5869
  const runSignal = streamIterator ? turnAbort.signal : signal;
5836
- await ensureCostBaseline(activeSessionId);
5837
5870
  jsonModelTurns = 0;
5838
5871
  jsonRequestAt = undefined;
5839
5872
  jsonOutputAt = undefined;
@@ -5851,6 +5884,7 @@ async function execute(argv, io, dependencies, signal) {
5851
5884
  let colorArgs;
5852
5885
  let costTurn = false;
5853
5886
  try {
5887
+ await ensureCostBaseline(activeSessionId);
5854
5888
  costTurn =
5855
5889
  !invocation.disableSlashCommands && isHeadlessCostCommand(prompt);
5856
5890
  colorArgs = invocation.disableSlashCommands
@@ -5939,6 +5973,11 @@ async function execute(argv, io, dependencies, signal) {
5939
5973
  else if (outputFormat === 'json') {
5940
5974
  writeJson(io, createErrorResult(message, activeSessionId, startedAt, jsonModelTurns, errorContext));
5941
5975
  }
5976
+ else if (invocation.print &&
5977
+ inputFormat === 'text' &&
5978
+ outputFormat === 'text') {
5979
+ io.stdout(formatPrintTextError(message, errorContext));
5980
+ }
5942
5981
  else {
5943
5982
  if (currentTurnAbort === turnAbort)
5944
5983
  currentTurnAbort = undefined;
@@ -5971,6 +6010,8 @@ async function execute(argv, io, dependencies, signal) {
5971
6010
  }
5972
6011
  else if (outputFormat !== 'text')
5973
6012
  writeJson(io, { type: 'result', ...result });
6013
+ else if (invocation.print && inputFormat === 'text')
6014
+ io.stdout(`${result.text}\n`);
5974
6015
  else
5975
6016
  io.stdout(localCommand ? `${result.text}\n` : '\n');
5976
6017
  if (streamOutput && invocation.promptSuggestions) {
@@ -0,0 +1,41 @@
1
+ import { type ExclusiveFileLeaseHandle } from '../platform/exclusive-file-lease.js';
2
+ type ManagedWorktreeKind = 'workflow' | 'agent' | 'team';
3
+ type ManagedWorktreePolicy = 'ephemeral' | 'durable';
4
+ type ManagedWorktreeState = 'creating' | 'active' | 'releasing' | 'retained' | 'released';
5
+ export interface ManagedWorktreeRecord {
6
+ version: 1;
7
+ worktreeId: string;
8
+ kind: ManagedWorktreeKind;
9
+ policy: ManagedWorktreePolicy;
10
+ ownerId: string;
11
+ repositoryRoot: string;
12
+ worktreePath: string;
13
+ branch: string | null;
14
+ baseCommit: string;
15
+ state: ManagedWorktreeState;
16
+ createdAt: string;
17
+ updatedAt: string;
18
+ retentionReason?: string;
19
+ }
20
+ export declare class ManagedWorktreeStore {
21
+ readonly path: string;
22
+ readonly lockPath: string;
23
+ readonly projectLockPath: string;
24
+ private readonly directory;
25
+ private readonly stateRoot;
26
+ private readonly repositoryRoot;
27
+ private readonly worktreeId;
28
+ private readonly worktreeLease;
29
+ private readonly projectLease;
30
+ constructor(stateRoot: string, repositoryRoot: string, worktreeId: string);
31
+ acquireLease(): Promise<ExclusiveFileLeaseHandle | null>;
32
+ read(): Promise<ManagedWorktreeRecord>;
33
+ create(record: ManagedWorktreeRecord): Promise<void>;
34
+ update(record: ManagedWorktreeRecord): Promise<void>;
35
+ private withProjectLease;
36
+ private assertUnreservedPath;
37
+ private assertStoreIdentity;
38
+ private assertSameIdentity;
39
+ }
40
+ export {};
41
+ //# sourceMappingURL=managed-worktree-store.d.ts.map
@@ -0,0 +1,341 @@
1
+ import { constants } from 'node:fs';
2
+ import { lstat, mkdir, open, opendir } from 'node:fs/promises';
3
+ import { isAbsolute, join, relative, resolve, sep } from 'node:path';
4
+ import { setTimeout as sleep } from 'node:timers/promises';
5
+ import { writeFileAtomically } from '../platform/atomic-write.js';
6
+ import { ExclusiveFileLease, } from '../platform/exclusive-file-lease.js';
7
+ import { sanitizeProjectPath } from '../platform/project-path-key.js';
8
+ const RECORD_FIELDS = new Set([
9
+ 'version',
10
+ 'worktreeId',
11
+ 'kind',
12
+ 'policy',
13
+ 'ownerId',
14
+ 'repositoryRoot',
15
+ 'worktreePath',
16
+ 'branch',
17
+ 'baseCommit',
18
+ 'state',
19
+ 'createdAt',
20
+ 'updatedAt',
21
+ 'retentionReason',
22
+ ]);
23
+ const REQUIRED_FIELDS = [...RECORD_FIELDS].filter((field) => field !== 'retentionReason');
24
+ const ID_PATTERN = /^[a-f0-9]{32,64}$/u;
25
+ const COMMIT_PATTERN = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/u;
26
+ const PROJECT_LEASE_ATTEMPTS = 400;
27
+ const PROJECT_LEASE_DELAY_MS = 5;
28
+ const ALLOWED_TRANSITIONS = {
29
+ creating: new Set(['active', 'retained', 'released']),
30
+ active: new Set(['releasing', 'retained']),
31
+ releasing: new Set(['releasing', 'retained', 'released']),
32
+ retained: new Set(['releasing', 'retained']),
33
+ released: new Set(['released']),
34
+ };
35
+ function invalidRecord(detail) {
36
+ return new Error(detail
37
+ ? `Invalid managed worktree record: ${detail}`
38
+ : 'Invalid managed worktree record');
39
+ }
40
+ function exactIsoTimestamp(value) {
41
+ if (typeof value !== 'string')
42
+ return false;
43
+ const milliseconds = Date.parse(value);
44
+ return (!Number.isNaN(milliseconds) &&
45
+ new Date(milliseconds).toISOString() === value);
46
+ }
47
+ function validText(value, maximumLength, allowedControls = new Set()) {
48
+ if (typeof value !== 'string' ||
49
+ value.length === 0 ||
50
+ value.length > maximumLength) {
51
+ return false;
52
+ }
53
+ return !Array.from(value).some((character) => {
54
+ const code = character.codePointAt(0) ?? 0;
55
+ return (code <= 0x1f || code === 0x7f) && !allowedControls.has(code);
56
+ });
57
+ }
58
+ function isPathWithin(root, candidate) {
59
+ const path = relative(resolve(root), resolve(candidate));
60
+ return (path !== '' &&
61
+ path !== '..' &&
62
+ !path.startsWith(`..${sep}`) &&
63
+ !isAbsolute(path));
64
+ }
65
+ function validateRecord(value) {
66
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
67
+ throw invalidRecord();
68
+ }
69
+ const record = value;
70
+ if (Object.keys(record).some((key) => !RECORD_FIELDS.has(key)) ||
71
+ REQUIRED_FIELDS.some((key) => !(key in record))) {
72
+ throw invalidRecord('unexpected or missing field');
73
+ }
74
+ if (record.version !== 1 ||
75
+ typeof record.worktreeId !== 'string' ||
76
+ !ID_PATTERN.test(record.worktreeId) ||
77
+ (record.kind !== 'workflow' &&
78
+ record.kind !== 'agent' &&
79
+ record.kind !== 'team') ||
80
+ (record.policy !== 'ephemeral' && record.policy !== 'durable') ||
81
+ !validText(record.ownerId, 256) ||
82
+ typeof record.repositoryRoot !== 'string' ||
83
+ !isAbsolute(record.repositoryRoot) ||
84
+ resolve(record.repositoryRoot) !== record.repositoryRoot ||
85
+ typeof record.worktreePath !== 'string' ||
86
+ !isAbsolute(record.worktreePath) ||
87
+ resolve(record.worktreePath) !== record.worktreePath ||
88
+ (record.branch !== null && !validText(record.branch, 256)) ||
89
+ typeof record.baseCommit !== 'string' ||
90
+ !COMMIT_PATTERN.test(record.baseCommit) ||
91
+ !['creating', 'active', 'releasing', 'retained', 'released'].includes(String(record.state)) ||
92
+ !exactIsoTimestamp(record.createdAt) ||
93
+ !exactIsoTimestamp(record.updatedAt) ||
94
+ Date.parse(record.updatedAt) < Date.parse(record.createdAt) ||
95
+ (record.retentionReason !== undefined &&
96
+ !validText(record.retentionReason, 2048, new Set([0x09, 0x0a, 0x0d]))) ||
97
+ (record.state === 'retained' && record.retentionReason === undefined) ||
98
+ (record.state !== 'retained' && record.retentionReason !== undefined)) {
99
+ throw invalidRecord();
100
+ }
101
+ const kindRoot = join(record.repositoryRoot, '.praxis', 'worktrees', record.kind);
102
+ if (!isPathWithin(kindRoot, record.worktreePath)) {
103
+ throw invalidRecord('worktree path is outside its kind root');
104
+ }
105
+ return record;
106
+ }
107
+ async function assertDirectoryChain(path, root) {
108
+ const chain = [];
109
+ let current = resolve(path);
110
+ const boundary = resolve(root);
111
+ for (;;) {
112
+ chain.unshift(current);
113
+ if (current === boundary)
114
+ break;
115
+ const parent = resolve(current, '..');
116
+ if (parent === current) {
117
+ throw new Error('Managed worktree state path escapes its root');
118
+ }
119
+ current = parent;
120
+ }
121
+ for (const candidate of chain) {
122
+ try {
123
+ const entry = await lstat(candidate);
124
+ if (entry.isSymbolicLink()) {
125
+ throw new Error(`Managed worktree state path must not contain a symlink: ${candidate}`);
126
+ }
127
+ if (!entry.isDirectory()) {
128
+ throw new Error(`Managed worktree state path must contain only directories: ${candidate}`);
129
+ }
130
+ }
131
+ catch (error) {
132
+ if (error.code !== 'ENOENT')
133
+ throw error;
134
+ }
135
+ }
136
+ }
137
+ async function prepareDirectory(path, root) {
138
+ await assertDirectoryChain(path, root);
139
+ await mkdir(path, { recursive: true, mode: 0o700 });
140
+ await assertDirectoryChain(path, root);
141
+ }
142
+ async function readRegularFile(path, description) {
143
+ let handle;
144
+ try {
145
+ handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
146
+ }
147
+ catch (error) {
148
+ if (error.code === 'ENOENT') {
149
+ throw new Error(`${description} is missing`);
150
+ }
151
+ if (error.code === 'ELOOP') {
152
+ throw new Error(`${description} must be a regular file`);
153
+ }
154
+ throw error;
155
+ }
156
+ try {
157
+ if (!(await handle.stat()).isFile()) {
158
+ throw new Error(`${description} must be a regular file`);
159
+ }
160
+ return await handle.readFile({ encoding: 'utf8' });
161
+ }
162
+ finally {
163
+ await handle.close();
164
+ }
165
+ }
166
+ async function readRecord(path) {
167
+ let value;
168
+ try {
169
+ value = JSON.parse(await readRegularFile(path, 'Managed worktree record'));
170
+ }
171
+ catch (error) {
172
+ throw invalidRecord(error instanceof Error ? error.message : String(error));
173
+ }
174
+ return validateRecord(value);
175
+ }
176
+ function managedWorktreeRecordPath(stateRoot, repositoryRoot, worktreeId) {
177
+ if (typeof stateRoot !== 'string' || stateRoot.trim() === '') {
178
+ throw invalidRecord('state root must be non-empty');
179
+ }
180
+ if (!isAbsolute(repositoryRoot) ||
181
+ resolve(repositoryRoot) !== repositoryRoot ||
182
+ !ID_PATTERN.test(worktreeId)) {
183
+ throw invalidRecord();
184
+ }
185
+ return resolve(stateRoot, 'managed-worktrees', sanitizeProjectPath(repositoryRoot), `${worktreeId}.json`);
186
+ }
187
+ export class ManagedWorktreeStore {
188
+ path;
189
+ lockPath;
190
+ projectLockPath;
191
+ directory;
192
+ stateRoot;
193
+ repositoryRoot;
194
+ worktreeId;
195
+ worktreeLease;
196
+ projectLease;
197
+ constructor(stateRoot, repositoryRoot, worktreeId) {
198
+ this.stateRoot = resolve(stateRoot);
199
+ this.repositoryRoot = repositoryRoot;
200
+ this.worktreeId = worktreeId;
201
+ this.path = managedWorktreeRecordPath(stateRoot, repositoryRoot, worktreeId);
202
+ this.directory = resolve(this.path, '..');
203
+ this.lockPath = join(this.directory, `${worktreeId}.lock`);
204
+ this.projectLockPath = join(this.directory, '.registry.lock');
205
+ this.worktreeLease = new ExclusiveFileLease(this.lockPath);
206
+ this.projectLease = new ExclusiveFileLease(this.projectLockPath);
207
+ }
208
+ async acquireLease() {
209
+ await prepareDirectory(this.directory, this.stateRoot);
210
+ return this.worktreeLease.tryAcquire();
211
+ }
212
+ async read() {
213
+ const record = await readRecord(this.path);
214
+ this.assertStoreIdentity(record);
215
+ return record;
216
+ }
217
+ async create(record) {
218
+ this.assertStoreIdentity(validateRecord(record));
219
+ if (record.state !== 'creating') {
220
+ throw invalidRecord('new records must start in creating state');
221
+ }
222
+ await this.withProjectLease(async () => {
223
+ try {
224
+ await lstat(this.path);
225
+ throw new Error('Managed worktree record already exists');
226
+ }
227
+ catch (error) {
228
+ if (error.code !== 'ENOENT')
229
+ throw error;
230
+ }
231
+ await this.assertUnreservedPath(record.worktreePath);
232
+ const committed = await writeFileAtomically(this.path, `${JSON.stringify(record)}\n`, {
233
+ mode: 0o600,
234
+ beforeCommit: async () => {
235
+ try {
236
+ await lstat(this.path);
237
+ return false;
238
+ }
239
+ catch (error) {
240
+ if (error.code === 'ENOENT') {
241
+ return true;
242
+ }
243
+ throw error;
244
+ }
245
+ },
246
+ });
247
+ if (!committed)
248
+ throw new Error('Could not write managed worktree record');
249
+ });
250
+ }
251
+ async update(record) {
252
+ this.assertStoreIdentity(validateRecord(record));
253
+ await this.withProjectLease(async () => {
254
+ const current = await readRecord(this.path);
255
+ this.assertStoreIdentity(current);
256
+ this.assertSameIdentity(current, record);
257
+ if (!ALLOWED_TRANSITIONS[current.state].has(record.state)) {
258
+ throw new Error(`Invalid managed worktree transition: ${current.state} -> ${record.state}`);
259
+ }
260
+ if (Date.parse(record.updatedAt) < Date.parse(current.updatedAt)) {
261
+ throw invalidRecord('updatedAt must not move backwards');
262
+ }
263
+ const committed = await writeFileAtomically(this.path, `${JSON.stringify(record)}\n`, {
264
+ mode: 0o600,
265
+ beforeCommit: async () => {
266
+ try {
267
+ const entry = await lstat(this.path);
268
+ return entry.isFile() && !entry.isSymbolicLink();
269
+ }
270
+ catch (error) {
271
+ if (error.code === 'ENOENT') {
272
+ return false;
273
+ }
274
+ throw error;
275
+ }
276
+ },
277
+ });
278
+ if (!committed)
279
+ throw new Error('Could not write managed worktree record');
280
+ });
281
+ }
282
+ async withProjectLease(operation) {
283
+ await prepareDirectory(this.directory, this.stateRoot);
284
+ let lease = null;
285
+ for (let attempt = 0; attempt < PROJECT_LEASE_ATTEMPTS; attempt += 1) {
286
+ lease = await this.projectLease.tryAcquire();
287
+ if (lease)
288
+ break;
289
+ await sleep(PROJECT_LEASE_DELAY_MS);
290
+ }
291
+ if (!lease) {
292
+ throw new Error(`Timed out acquiring managed worktree registry lock: ${this.projectLockPath}`);
293
+ }
294
+ try {
295
+ return await operation();
296
+ }
297
+ finally {
298
+ await lease.release();
299
+ }
300
+ }
301
+ async assertUnreservedPath(worktreePath) {
302
+ const directory = await opendir(this.directory);
303
+ for await (const entry of directory) {
304
+ if (!entry.name.endsWith('.json'))
305
+ continue;
306
+ const candidate = await readRecord(join(this.directory, entry.name));
307
+ if (candidate.repositoryRoot !== this.repositoryRoot) {
308
+ throw new Error('Managed worktree registry project-key collision');
309
+ }
310
+ if (candidate.worktreePath === worktreePath &&
311
+ candidate.state !== 'released') {
312
+ throw new Error(`Managed worktree path is already owned: ${worktreePath}`);
313
+ }
314
+ }
315
+ }
316
+ assertStoreIdentity(record) {
317
+ if (record.repositoryRoot !== this.repositoryRoot ||
318
+ record.worktreeId !== this.worktreeId) {
319
+ throw invalidRecord('record identity does not match its store');
320
+ }
321
+ }
322
+ assertSameIdentity(current, next) {
323
+ for (const field of [
324
+ 'version',
325
+ 'worktreeId',
326
+ 'kind',
327
+ 'policy',
328
+ 'ownerId',
329
+ 'repositoryRoot',
330
+ 'worktreePath',
331
+ 'branch',
332
+ 'baseCommit',
333
+ 'createdAt',
334
+ ]) {
335
+ if (current[field] !== next[field]) {
336
+ throw invalidRecord(`immutable field changed: ${field}`);
337
+ }
338
+ }
339
+ }
340
+ }
341
+ //# sourceMappingURL=managed-worktree-store.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "praxis-agent",
3
- "version": "0.60.0",
3
+ "version": "0.60.1",
4
4
  "description": "Local-first, single-user general agent for the command line.",
5
5
  "license": "MIT",
6
6
  "author": "wuqisen",