pi-crew 0.9.28 → 0.9.29

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.
@@ -9,6 +9,9 @@ export interface ChildWithPipedStdio {
9
9
  stdout: ChildProcess["stdout"];
10
10
  stderr: ChildProcess["stderr"];
11
11
  on: ChildProcess["on"];
12
+ /** Set by Node after the child exits. Used to detect a post-exit attach. */
13
+ exitCode?: number | null;
14
+ signalCode?: NodeJS.Signals | null;
12
15
  }
13
16
 
14
17
  export interface ChildWithKill {
@@ -72,13 +75,28 @@ export function attachPostExitStdioGuard(child: ChildWithPipedStdio, options: Po
72
75
  stderrEnded = true;
73
76
  if (stdoutEnded && stderrEnded) clearTimers();
74
77
  });
75
- child.on("exit", () => {
76
- exited = true;
77
- armIdleTimer();
78
+
79
+ const armHardTimer = (): void => {
78
80
  if (hardTimer) return;
79
81
  hardTimer = setTimeout(destroyUnendedStdio, hardMs);
80
82
  hardTimer.unref();
81
- });
83
+ };
84
+ const onExit = (): void => {
85
+ exited = true;
86
+ armIdleTimer();
87
+ armHardTimer();
88
+ };
89
+ // The guard is normally attached from INSIDE the child's 'exit' handler
90
+ // (child-pi.ts), i.e. AFTER the child has already exited. A listener
91
+ // registered with child.on('exit') during the in-flight 'exit' dispatch
92
+ // will NOT fire (Node clones the listener array before iterating), so
93
+ // relying on it alone makes this guard a no-op and can hang the run if a
94
+ // descendant process holds the stdio pipes open. When a prior exit is
95
+ // detectable (exitCode/signalCode already set by Node), arm immediately.
96
+ if (child.exitCode != null || child.signalCode != null) {
97
+ onExit();
98
+ }
99
+ child.on("exit", onExit);
82
100
  child.on("close", clearTimers);
83
101
  child.on("error", clearTimers);
84
102
 
@@ -2,6 +2,7 @@ import * as fs from "node:fs";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
4
  import { errors } from "../errors.ts";
5
+ import { atomicWriteJson } from "../state/atomic-write.ts";
5
6
  import { saveRunManifest } from "../state/state-store.ts";
6
7
  import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
7
8
  import { recordFromTask, upsertCrewAgent } from "./crew-agent-records.ts";
@@ -67,8 +68,12 @@ function checkResultFile(manifest: TeamRunManifest, tasks: TeamTaskState[]): { f
67
68
  );
68
69
  if (allTerminal) {
69
70
  // All tasks are terminal but manifest status was not updated — repair it.
71
+ // Derive the run status from task outcomes instead of blindly marking
72
+ // "completed": a run where every task failed/cancelled is NOT a success.
73
+ const hasFailed = tasks.some((t) => t.status === "failed");
74
+ const onlyCancelledOrSkipped = tasks.every((t) => t.status === "cancelled" || t.status === "skipped");
75
+ manifest.status = hasFailed ? "failed" : onlyCancelledOrSkipped ? "cancelled" : "completed";
70
76
  // Persist manifest status change immediately to make checkResultFile self-contained.
71
- manifest.status = "completed";
72
77
  saveRunManifest(manifest);
73
78
  // Sync agent records even when tasks are already terminal
74
79
  // (e.g., a previous reconcile fixed tasks but crashed before updating agents)
@@ -492,8 +497,8 @@ export function reconcileOrphanedTempWorkspaces(
492
497
  const tasks: TeamTaskState[] = JSON.parse(fs.readFileSync(tasksPath, "utf-8"));
493
498
  const result = reconcileStaleRun(manifest, tasks, now);
494
499
  if (result.repaired && result.repairedTasks) {
495
- // Persist repaired tasks
496
- fs.writeFileSync(tasksPath, JSON.stringify(result.repairedTasks, null, 2));
500
+ // Persist repaired tasks (atomic — temp+rename to survive mid-write crash)
501
+ atomicWriteJson(tasksPath, result.repairedTasks);
497
502
  // Update manifest status
498
503
  const updated = {
499
504
  ...manifest,
@@ -501,7 +506,7 @@ export function reconcileOrphanedTempWorkspaces(
501
506
  updatedAt: new Date(now).toISOString(),
502
507
  summary: `Stale run reconciled: ${result.detail}`,
503
508
  };
504
- fs.writeFileSync(manifestPath, JSON.stringify(updated, null, 2));
509
+ atomicWriteJson(manifestPath, updated);
505
510
  // Update agent records
506
511
  for (const task of result.repairedTasks) {
507
512
  try {
@@ -757,22 +757,17 @@ export async function executeTeamRun(input: ExecuteTeamRunInput): Promise<{ mani
757
757
  stopTeamHeartbeat();
758
758
  // P1: Catch unhandled errors — ensure manifest/tasks/agents are terminal so they don't stay "running" forever.
759
759
  const message = error instanceof Error ? error.message : String(error);
760
- // FIX (2026-07-02): drop withRunLock here entirely.
761
- // Previously this catch path acquired withRunLock to reload manifest+tasks
762
- // from disk (best-effort with in-memory fallback). But the closeout path at
763
- // line ~1596 ALSO holds the same run lock for its final save — when a late
764
- // failure fires during closeout (e.g. async hook error after run.completed),
765
- // this catch path can hit `Run 'run.lock' is locked by another operation.`
766
- // and the error propagates as "Unhandled error in team runner" after the
767
- // run has actually completed. Lock acquisition here is unnecessary
768
- // the closeout writes through to disk before this catch fires (or after —
769
- // either is fine), and the in-memory state already contains the latest
770
- // post-completion manifest/tasks. We use in-memory state unconditionally.
771
- // The stale-data concern is mitigated by the dispatcher having re-read disk
772
- // state under lock at line ~1364 for each iteration; the manifest+task
773
- // objects in the calling scope are already post-merge.
774
- const freshManifest = manifest;
775
- const freshTasks = refreshTaskGraphQueues(input.tasks);
760
+ // Re-read the latest persisted state from disk instead of trusting
761
+ // input.tasks (the ORIGINAL start snapshot, still all "queued" — it is never
762
+ // mutated by executeTeamRunCore). A late failure during closeout would
763
+ // otherwise map every task to "failed", overwriting tasks that already
764
+ // completed during the run. loadRunManifestById is the established
765
+ // fresh-read pattern in this file (see ~line 1269); it is best-effort with
766
+ // no lock, consistent with the lock-drop decision below. If the disk read
767
+ // fails, fall back to input.tasks so the run is still marked terminal.
768
+ const fresh = loadRunManifestById(manifest.cwd, manifest.runId);
769
+ const freshManifest = fresh?.manifest ?? manifest;
770
+ const freshTasks = refreshTaskGraphQueues(fresh?.tasks ?? input.tasks);
776
771
  const failedAt = new Date().toISOString();
777
772
  const tasks = freshTasks.map((task) =>
778
773
  task.status === "running" || task.status === "queued" || task.status === "waiting"
@@ -260,32 +260,43 @@ function renameWithLinkSync(tempPath: string, filePath: string, retries = 8): vo
260
260
  let lastError: unknown;
261
261
  for (let attempt = 0; attempt <= retries; attempt++) {
262
262
  try {
263
- // Windows: hard links fail with ENOENT when short-name and long-name
264
- // path aliases are mixed (e.g. RUNNER~1 vs runneradmin). Use
265
- // renameSync which handles this correctly via MoveFileEx.
266
263
  if (process.platform === "win32") {
267
- try {
268
- fs.unlinkSync(filePath);
269
- } catch {
270
- /* destination may not exist */
271
- }
264
+ // B9: MoveFileEx (Node's renameSync on Windows) replaces the destination atomically
265
+ // with MOVEFILE_REPLACE_EXISTING, so no prior unlink is needed. Unlinking first
266
+ // opened a window where concurrent readers saw ENOENT between the unlink and
267
+ // the rename. Try rename directly; only fall back to unlink-then-rename for
268
+ // edge cases (read-only dest / type mismatch / short-long-name alias).
272
269
  try {
273
270
  fs.renameSync(tempPath, filePath);
274
271
  return;
275
272
  } catch (renameError) {
276
273
  lastError = renameError;
277
- if (!isRetryableLinkError(renameError) || attempt === retries) break;
274
+ if (isRetryableLinkError(renameError) && attempt !== retries) {
275
+ // retryable — loop again after sleep
276
+ } else {
277
+ try {
278
+ fs.unlinkSync(filePath);
279
+ } catch {
280
+ /* destination may not exist */
281
+ }
282
+ try {
283
+ fs.renameSync(tempPath, filePath);
284
+ return;
285
+ } catch (renameError2) {
286
+ lastError = renameError2;
287
+ if (!isRetryableLinkError(renameError2) || attempt === retries) break;
288
+ }
289
+ }
278
290
  }
279
291
  } else {
280
- // First try to unlink any existing file at destination (hard links don't follow symlinks)
281
292
  try {
282
293
  fs.unlinkSync(filePath);
283
294
  } catch {
284
295
  /* destination may not exist */
285
296
  }
286
- // Create hard link - does NOT follow symlinks at filePath
297
+ // Create hard link does NOT follow symlinks at filePath
287
298
  fs.linkSync(tempPath, filePath);
288
- // Successfully linked - now unlink the temp file
299
+ // Successfully linked now unlink the temp file
289
300
  fs.unlinkSync(tempPath);
290
301
  return;
291
302
  }
@@ -305,17 +316,32 @@ async function renameWithLinkAsync(tempPath: string, filePath: string, retries =
305
316
  for (let attempt = 0; attempt <= retries; attempt++) {
306
317
  try {
307
318
  if (process.platform === "win32") {
308
- try {
309
- await fs.promises.unlink(filePath);
310
- } catch {
311
- /* destination may not exist */
312
- }
319
+ // B9: MoveFileEx (Node's rename on Windows) replaces the destination atomically
320
+ // with MOVEFILE_REPLACE_EXISTING, so no prior unlink is needed. Unlinking first
321
+ // opened a window where concurrent readers saw ENOENT between the unlink and
322
+ // the rename. Try rename directly; only fall back to unlink-then-rename for
323
+ // edge cases (read-only dest / type mismatch / short-long-name alias).
313
324
  try {
314
325
  await fs.promises.rename(tempPath, filePath);
315
326
  return;
316
327
  } catch (renameError) {
317
328
  lastError = renameError;
318
- if (!isRetryableLinkError(renameError) || attempt === retries) break;
329
+ if (isRetryableLinkError(renameError) && attempt !== retries) {
330
+ // retryable — loop again after sleep
331
+ } else {
332
+ try {
333
+ await fs.promises.unlink(filePath);
334
+ } catch {
335
+ /* destination may not exist */
336
+ }
337
+ try {
338
+ await fs.promises.rename(tempPath, filePath);
339
+ return;
340
+ } catch (renameError2) {
341
+ lastError = renameError2;
342
+ if (!isRetryableLinkError(renameError2) || attempt === retries) break;
343
+ }
344
+ }
319
345
  }
320
346
  } else {
321
347
  try {
@@ -323,7 +349,9 @@ async function renameWithLinkAsync(tempPath: string, filePath: string, retries =
323
349
  } catch {
324
350
  /* destination may not exist */
325
351
  }
352
+ // Create hard link — does NOT follow symlinks at filePath
326
353
  await fs.promises.link(tempPath, filePath);
354
+ // Successfully linked — now unlink the temp file
327
355
  await fs.promises.unlink(tempPath);
328
356
  return;
329
357
  }
@@ -337,7 +365,6 @@ async function renameWithLinkAsync(tempPath: string, filePath: string, retries =
337
365
  }
338
366
  throw lastError;
339
367
  }
340
-
341
368
  export function renameWithRetry(
342
369
  tempPath: string,
343
370
  filePath: string,
@@ -304,6 +304,37 @@ function persistSequence(eventsPath: string, seq: number): void {
304
304
  }
305
305
  }
306
306
 
307
+ // B7: single in-process monotonic sequence counter per eventsPath. The three
308
+ // append paths — sync appendEvent (withEventLogLockSync file lock), buffered
309
+ // flush (asyncLocks promise chain), and direct appendEventAsync (asyncQueues
310
+ // promise chain) — use DIFFERENT locks, so the old read-sidecar / compute /
311
+ // persist-sidecar sequence logic in nextSequence() raced ACROSS paths and
312
+ // produced duplicate sequence numbers (observed live: distinct events sharing
313
+ // a seq; no data loss — only the counter collided). A single in-process counter
314
+ // makes assignment atomic (JS is single-threaded); persistSequence() keeps the
315
+ // sidecar durable for crash recovery across restarts.
316
+ const seqCounters = new Map<string, number>();
317
+
318
+ /** Atomically reserve the next sequence number for `eventsPath`. */
319
+ function reserveSequence(eventsPath: string): number {
320
+ let last = seqCounters.get(eventsPath);
321
+ if (last === undefined) {
322
+ // Seed once from the authoritative source (sidecar / cache / file scan).
323
+ // nextSequence() returns the NEXT seq to assign, so the last assigned is one less.
324
+ last = nextSequence(eventsPath) - 1;
325
+ }
326
+ const next = last + 1;
327
+ seqCounters.set(eventsPath, next);
328
+ return next;
329
+ }
330
+
331
+ /** Keep the in-process counter monotonic w.r.t. an explicitly-provided seq
332
+ * (e.g. baseMetadata.seq) so a later auto-assigned seq never collides with it. */
333
+ function advanceSequenceCounter(eventsPath: string, seq: number): void {
334
+ const last = seqCounters.get(eventsPath);
335
+ if (last === undefined || seq > last) seqCounters.set(eventsPath, seq);
336
+ }
337
+
307
338
  export function computeEventFingerprint(event: Pick<TeamEvent, "type" | "runId" | "taskId" | "data">): string {
308
339
  return createHash("sha256")
309
340
  .update(
@@ -390,13 +421,22 @@ async function withEventLogLockAsync(eventsPath: string, fn: () => Promise<void>
390
421
  try {
391
422
  await next;
392
423
  } finally {
393
- asyncLocks.delete(queueKey);
424
+ // Compare-and-delete: only remove our entry if it still points at our
425
+ // promise. With 3+ overlapping callers, an earlier caller's finally would
426
+ // otherwise delete a later caller's promise, letting the next caller start
427
+ // immediately (in parallel) -> broken mutual exclusion, duplicate seqs.
428
+ if (asyncLocks.get(queueKey) === next) {
429
+ asyncLocks.delete(queueKey);
430
+ }
394
431
  }
395
432
  }
396
433
 
397
434
  /** Reset event log mode (for testing only). */
398
435
  export function resetEventLogMode(): void {
399
436
  asyncQueues.clear();
437
+ // B7: clear in-process sequence counters alongside async state so tests
438
+ // don't leak seq state between runs.
439
+ seqCounters.clear();
400
440
  }
401
441
 
402
442
  /**
@@ -440,8 +480,9 @@ export async function appendEventAsync(eventsPath: string, event: AppendTeamEven
440
480
  let seq: number;
441
481
  if (baseMetadata?.seq !== undefined) {
442
482
  seq = baseMetadata.seq;
483
+ advanceSequenceCounter(eventsPath, seq);
443
484
  } else {
444
- seq = nextSequence(eventsPath);
485
+ seq = reserveSequence(eventsPath);
445
486
  // NOTE: We do NOT call persistSequence here. It will be called AFTER
446
487
  // successful appendFile below to ensure sidecar is only updated when
447
488
  // the event is actually written.
@@ -588,27 +629,31 @@ export async function appendEventAsync(eventsPath: string, event: AppendTeamEven
588
629
  }
589
630
  return fullEvent;
590
631
  });
591
- asyncQueues.set(
592
- queueKey,
593
- next.then(
594
- () => {
632
+ const tail = next.then(
633
+ () => {
634
+ // Compare-and-delete: only remove our entry if it still points at our
635
+ // tail promise. An older caller deleting unconditionally would wipe a
636
+ // newer caller's promise, letting the next caller bypass serialization
637
+ // -> duplicate seqs / interleaved appends.
638
+ if (asyncQueues.get(queueKey) === tail) {
595
639
  asyncQueues.delete(queueKey);
596
- },
597
- (error) => {
598
- // FIX: Wrap error handler in try-catch to ensure asyncQueues.delete
599
- // always runs, even if logging itself throws.
600
- try {
601
- logInternalError("event-log.async-queue", error, eventsPath);
602
- } catch {
603
- // logging failed — ensure queue is still cleaned up
604
- }
605
- // FIX: Reset queue to a resolved state instead of deleting it.
606
- // This prevents cascading failures where a single transient error
607
- // (e.g., ENOSPC) causes all subsequent events on the same path to fail.
608
- asyncQueues.set(queueKey, Promise.resolve());
609
- },
610
- ),
640
+ }
641
+ },
642
+ (error) => {
643
+ // FIX: Wrap error handler in try-catch to ensure asyncQueues.delete
644
+ // always runs, even if logging itself throws.
645
+ try {
646
+ logInternalError("event-log.async-queue", error, eventsPath);
647
+ } catch {
648
+ // logging failed — ensure queue is still cleaned up
649
+ }
650
+ // FIX: Reset queue to a resolved state instead of deleting it.
651
+ // This prevents cascading failures where a single transient error
652
+ // (e.g., ENOSPC) causes all subsequent events on the same path to fail.
653
+ asyncQueues.set(queueKey, Promise.resolve());
654
+ },
611
655
  );
656
+ asyncQueues.set(queueKey, tail);
612
657
  return next;
613
658
  }
614
659
 
@@ -658,7 +703,8 @@ async function appendEventBatchInsideLock(eventsPath: string, queue: BufferedApp
658
703
  // in between — every call would see the same file state and return the same
659
704
  // seq, breaking the "unique monotonic seq" contract. The cache update +
660
705
  // persistSequence at the end refreshes the sidecar to the last assigned seq.
661
- const startingSeq = queue[0]?.event.metadata?.seq ?? nextSequence(eventsPath);
706
+ // B7: use reserveSequence for atomic seq assignment across all paths.
707
+ const startingSeq = queue[0]?.event.metadata?.seq ?? reserveSequence(eventsPath);
662
708
  let nextSeq = startingSeq;
663
709
  const finalized: { item: BufferedAppend; line: string; fullEvent: TeamEvent }[] = [];
664
710
  let lastSeq = 0;
@@ -693,6 +739,8 @@ async function appendEventBatchInsideLock(eventsPath: string, queue: BufferedApp
693
739
  finalized.push({ item, line: `${JSON.stringify(redactSecrets(fullEvent))}\n`, fullEvent });
694
740
  lastSeq = seq;
695
741
  }
742
+ // B7: advance counter past the entire batch so next reserveSequence returns the correct value.
743
+ advanceSequenceCounter(eventsPath, lastSeq);
696
744
 
697
745
  // Phase 2: single appendFileSync + single fsync + single persistSequence.
698
746
  // Before this fix, each event in the batch triggered its own fsync, which
@@ -745,8 +793,12 @@ async function appendEventBatchInsideLock(eventsPath: string, queue: BufferedApp
745
793
  function appendEventInsideLock(eventsPath: string, event: AppendTeamEvent): TeamEvent {
746
794
  fs.mkdirSync(path.dirname(eventsPath), { recursive: true });
747
795
  const baseMetadata = event.metadata;
796
+ // B7: use reserveSequence for atomic seq assignment across all paths.
797
+ const explicitSeq = baseMetadata?.seq;
798
+ const seq = explicitSeq ?? reserveSequence(eventsPath);
799
+ if (explicitSeq !== undefined) advanceSequenceCounter(eventsPath, seq);
748
800
  let metadata: TeamEventMetadata = {
749
- seq: baseMetadata?.seq ?? nextSequence(eventsPath),
801
+ seq,
750
802
  provenance: baseMetadata?.provenance ?? "team_runner",
751
803
  ...(baseMetadata?.parentEventId ? { parentEventId: baseMetadata.parentEventId } : {}),
752
804
  ...(baseMetadata?.attemptId ? { attemptId: baseMetadata.attemptId } : {}),
@@ -813,7 +865,8 @@ function appendEventInsideLock(eventsPath: string, event: AppendTeamEvent): Team
813
865
  } catch (error) {
814
866
  logInternalError("event-log.size-check", error, `eventsPath=${eventsPath}`);
815
867
  }
816
- const seq = fullEvent.metadata?.seq ?? 0;
868
+ // seq is already computed above via reserveSequence — reuse it for persist/cache.
869
+ // const seq declaration removed (B7: seq is now computed before metadata object).
817
870
  if (!skippedDueToSize) {
818
871
  fs.appendFileSync(eventsPath, `${JSON.stringify(redactSecrets(fullEvent))}\n`, "utf-8");
819
872
  // F3a: skip data fsync for non-terminal events. We still call `persistSequence`
@@ -463,16 +463,19 @@ export function appendMailboxMessage(
463
463
  replyContent: message.replyContent,
464
464
  };
465
465
  // H2 fix: wrap append in cross-process lock to prevent interleaving on Windows.
466
+ // B8: rotation (rename + recreate) also runs INSIDE the lock so it is serialized
467
+ // with appends — otherwise a concurrent append between rename and recreate can
468
+ // be truncated to an empty file.
466
469
  withEventLogLockSync(mailboxFile(manifest, complete.direction, complete.taskId), () => {
467
470
  fs.appendFileSync(
468
471
  mailboxFile(manifest, complete.direction, complete.taskId),
469
472
  `${JSON.stringify(redactSecrets(complete))}\n`,
470
473
  "utf-8",
471
474
  );
475
+ // 3.3 — rotate mailbox file if it has grown past 10 MB. Cheap stat check;
476
+ // rotates at most once per append.
477
+ rotateMailboxFileIfNeeded(mailboxFile(manifest, complete.direction, complete.taskId));
472
478
  });
473
- // 3.3 — rotate mailbox file if it has grown past 10 MB. Cheap stat
474
- // check; rotates at most once per append.
475
- rotateMailboxFileIfNeeded(mailboxFile(manifest, complete.direction, complete.taskId));
476
479
  // BUGFIX (Round 12 C3): the delivery.json read-modify-write below was
477
480
  // UNLOCKED, so concurrent appendMailboxMessage calls could interleave and
478
481
  // clobber each other's delivery entries (lost-update race). FIX: wrap the
@@ -46,6 +46,17 @@ export function setExtensionWidget(
46
46
  ctx.ui.setWidget(key, content as never, widgetOptions as WidgetOptions);
47
47
  }
48
48
 
49
+ type FooterFactory = (tui: unknown, theme: unknown, footerData: unknown) => unknown;
50
+
51
+ /** Install a custom footer component, or pass `undefined` to restore pi's built-in footer.
52
+ * No-op when the host UI predates the `setFooter` API. */
53
+ export function setFooter(ctx: UiContext | undefined, factory: FooterFactory | undefined): void {
54
+ if (!ctx) return;
55
+ const record = maybeRecord(ctx.ui);
56
+ const fn = record?.setFooter;
57
+ if (typeof fn === "function") fn.call(ctx.ui, factory as never);
58
+ }
59
+
49
60
  export function showCustom<T>(ctx: UiContext, factory: CustomFactory<T>, options?: CustomOptions): Promise<T> {
50
61
  const custom = ctx.ui.custom as unknown as GenericCustom;
51
62
  return custom<T>(factory, options);
@@ -68,7 +68,11 @@ export function scanConflictLines(lines: readonly string[], firstLineNumber: num
68
68
  } | null = null;
69
69
 
70
70
  for (let i = 0; i < lines.length; i++) {
71
- const line = lines[i];
71
+ // Strip a trailing CR so CRLF files (Windows) match markers/separators.
72
+ // The raw line (with CR) is never needed here; content is collected from
73
+ // the normalized line so storage stays consistent across LF/CRLF inputs.
74
+ const rawLine = lines[i];
75
+ const line = rawLine.charCodeAt(rawLine.length - 1) === 13 /* \r */ ? rawLine.slice(0, -1) : rawLine;
72
76
  const ln = firstLineNumber + i;
73
77
 
74
78
  const oursLabel = matchMarker(line, OURS_PREFIX);
@@ -339,7 +343,9 @@ export function parseConflictUri(raw: string): ParsedConflictUri | null {
339
343
  * in the file that shift line numbers don't break resolution.
340
344
  */
341
345
  export function spliceConflict(originalText: string, entry: ConflictEntry, replacement: string): string {
342
- const lines = originalText.split("\n");
346
+ // Preserve the original line ending (CRLF or LF) for a byte-accurate round-trip.
347
+ const eol = originalText.includes("\r\n") ? "\r\n" : "\n";
348
+ const lines = originalText.split(/\r?\n/);
343
349
  const expected = buildRecordedRegion(entry);
344
350
  const match = locateRegion(lines, expected, entry.startLine - 1);
345
351
  if (!match) {
@@ -351,7 +357,7 @@ export function spliceConflict(originalText: string, entry: ConflictEntry, repla
351
357
  const trimmed = normalizeTrailingNewline(replacement);
352
358
  const replacementLines = trimmed.split("\n");
353
359
  const next = [...lines.slice(0, match.startIdx), ...replacementLines, ...lines.slice(match.endIdx + 1)];
354
- return next.join("\n");
360
+ return next.join(eol);
355
361
  }
356
362
 
357
363
  /** Reconstruct the recorded marker block as it should appear in the file. */
@@ -39,7 +39,13 @@ export function packageRoot(): string {
39
39
  }
40
40
 
41
41
  export function userPiRoot(): string {
42
- const home = process.env.PI_TEAMS_HOME?.trim() || os.homedir();
42
+ // F4: a misconfigured PI_TEAMS_HOME can be the literal string "undefined"
43
+ // (e.g. PI_TEAMS_HOME=$UNSET_VAR in a shell), which would build paths like
44
+ // "undefined/.pi/agent" relative to cwd and silently create a junk "undefined/"
45
+ // tree. Treat the literal "undefined" (and empty) as unset and fall back to
46
+ // os.homedir().
47
+ const rawHome = process.env.PI_TEAMS_HOME?.trim();
48
+ const home = rawHome && rawHome !== "undefined" ? rawHome : os.homedir();
43
49
  const resolved = path.join(home, ".pi", "agent");
44
50
 
45
51
  // Reject symlinks to prevent confusion attacks where PI_TEAMS_HOME points to
@@ -131,6 +131,25 @@ export function cleanupRunWorktrees(
131
131
  const branchName = `pi-crew/${manifest.runId}/${sanitizeBranchPart(entry.name)}`;
132
132
  const safeBranchName = sanitizeBranchPart(entry.name);
133
133
  if (dirty) {
134
+ // C9: preserve dirty worktrees unless explicitly forced. Previously the
135
+ // dirty branch ALWAYS ran 'git add -A' + commit + remove, staging every
136
+ // untracked file (incl. potential secrets/build artifacts) into a
137
+ // recovery branch without consent. force=true keeps the old behavior.
138
+ if (!options.force) {
139
+ const safePreserveName = sanitizeFilename(entry.name);
140
+ const artifact = writeArtifact(manifest.artifactsRoot, {
141
+ kind: "diff",
142
+ relativePath: `cleanup/${safePreserveName}.diff`,
143
+ content: captureDiff(worktreePath),
144
+ producer: "worktree-cleanup",
145
+ });
146
+ result.artifactPaths.push(artifact.path);
147
+ result.preserved.push({
148
+ path: worktreePath,
149
+ reason: "dirty worktree preserved \u2014 pass force=true to auto-commit and remove",
150
+ });
151
+ continue;
152
+ }
134
153
  // Issue 1 fix: check signal before git operations
135
154
  if (options.signal?.aborted) break;
136
155
  // Commit changes to a branch instead of just preserving the worktree