gsd-pi 2.37.0 → 2.37.1-dev.7352347

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.
Files changed (72) hide show
  1. package/README.md +21 -20
  2. package/dist/onboarding.js +1 -0
  3. package/dist/resources/extensions/cmux/package.json +7 -0
  4. package/dist/resources/extensions/gsd/auto-loop.js +18 -4
  5. package/dist/resources/extensions/gsd/auto.js +42 -5
  6. package/dist/resources/extensions/gsd/commands.js +80 -33
  7. package/dist/resources/extensions/gsd/git-service.js +9 -1
  8. package/dist/resources/extensions/gsd/history.js +2 -1
  9. package/dist/resources/extensions/gsd/metrics.js +4 -2
  10. package/dist/resources/extensions/gsd/session-lock.js +26 -6
  11. package/dist/resources/extensions/shared/format-utils.js +5 -41
  12. package/dist/resources/extensions/shared/layout-utils.js +46 -0
  13. package/dist/resources/extensions/shared/mod.js +2 -1
  14. package/package.json +2 -1
  15. package/packages/pi-ai/dist/env-api-keys.js +13 -0
  16. package/packages/pi-ai/dist/env-api-keys.js.map +1 -1
  17. package/packages/pi-ai/dist/models.generated.d.ts +172 -0
  18. package/packages/pi-ai/dist/models.generated.d.ts.map +1 -1
  19. package/packages/pi-ai/dist/models.generated.js +172 -0
  20. package/packages/pi-ai/dist/models.generated.js.map +1 -1
  21. package/packages/pi-ai/dist/providers/anthropic-shared.d.ts +64 -0
  22. package/packages/pi-ai/dist/providers/anthropic-shared.d.ts.map +1 -0
  23. package/packages/pi-ai/dist/providers/anthropic-shared.js +668 -0
  24. package/packages/pi-ai/dist/providers/anthropic-shared.js.map +1 -0
  25. package/packages/pi-ai/dist/providers/anthropic-vertex.d.ts +5 -0
  26. package/packages/pi-ai/dist/providers/anthropic-vertex.d.ts.map +1 -0
  27. package/packages/pi-ai/dist/providers/anthropic-vertex.js +85 -0
  28. package/packages/pi-ai/dist/providers/anthropic-vertex.js.map +1 -0
  29. package/packages/pi-ai/dist/providers/anthropic.d.ts +4 -30
  30. package/packages/pi-ai/dist/providers/anthropic.d.ts.map +1 -1
  31. package/packages/pi-ai/dist/providers/anthropic.js +47 -764
  32. package/packages/pi-ai/dist/providers/anthropic.js.map +1 -1
  33. package/packages/pi-ai/dist/providers/register-builtins.d.ts.map +1 -1
  34. package/packages/pi-ai/dist/providers/register-builtins.js +6 -0
  35. package/packages/pi-ai/dist/providers/register-builtins.js.map +1 -1
  36. package/packages/pi-ai/dist/types.d.ts +2 -2
  37. package/packages/pi-ai/dist/types.d.ts.map +1 -1
  38. package/packages/pi-ai/dist/types.js.map +1 -1
  39. package/packages/pi-ai/package.json +1 -0
  40. package/packages/pi-ai/src/env-api-keys.ts +14 -0
  41. package/packages/pi-ai/src/models.generated.ts +172 -0
  42. package/packages/pi-ai/src/providers/anthropic-shared.ts +761 -0
  43. package/packages/pi-ai/src/providers/anthropic-vertex.ts +130 -0
  44. package/packages/pi-ai/src/providers/anthropic.ts +76 -868
  45. package/packages/pi-ai/src/providers/register-builtins.ts +7 -0
  46. package/packages/pi-ai/src/types.ts +2 -0
  47. package/packages/pi-coding-agent/dist/core/extensions/loader.d.ts.map +1 -1
  48. package/packages/pi-coding-agent/dist/core/extensions/loader.js +8 -4
  49. package/packages/pi-coding-agent/dist/core/extensions/loader.js.map +1 -1
  50. package/packages/pi-coding-agent/dist/core/model-resolver.d.ts.map +1 -1
  51. package/packages/pi-coding-agent/dist/core/model-resolver.js +1 -0
  52. package/packages/pi-coding-agent/dist/core/model-resolver.js.map +1 -1
  53. package/packages/pi-coding-agent/package.json +1 -1
  54. package/packages/pi-coding-agent/src/core/extensions/loader.ts +8 -4
  55. package/packages/pi-coding-agent/src/core/model-resolver.ts +1 -0
  56. package/pkg/package.json +1 -1
  57. package/src/resources/extensions/cmux/package.json +7 -0
  58. package/src/resources/extensions/gsd/auto-loop.ts +24 -6
  59. package/src/resources/extensions/gsd/auto.ts +56 -5
  60. package/src/resources/extensions/gsd/commands.ts +85 -31
  61. package/src/resources/extensions/gsd/git-service.ts +12 -1
  62. package/src/resources/extensions/gsd/history.ts +2 -1
  63. package/src/resources/extensions/gsd/metrics.ts +4 -2
  64. package/src/resources/extensions/gsd/session-lock.ts +41 -6
  65. package/src/resources/extensions/gsd/tests/auto-loop.test.ts +37 -1
  66. package/src/resources/extensions/gsd/tests/auto-worktree.test.ts +19 -0
  67. package/src/resources/extensions/gsd/tests/cmux.test.ts +25 -1
  68. package/src/resources/extensions/gsd/tests/session-lock-regression.test.ts +45 -0
  69. package/src/resources/extensions/shared/format-utils.ts +5 -44
  70. package/src/resources/extensions/shared/layout-utils.ts +49 -0
  71. package/src/resources/extensions/shared/mod.ts +7 -4
  72. package/src/resources/extensions/shared/tests/format-utils.test.ts +5 -3
@@ -47,10 +47,11 @@ import {
47
47
  } from "./crash-recovery.js";
48
48
  import {
49
49
  acquireSessionLock,
50
- validateSessionLock,
50
+ getSessionLockStatus,
51
51
  releaseSessionLock,
52
52
  updateSessionLock,
53
53
  } from "./session-lock.js";
54
+ import type { SessionLockStatus } from "./session-lock.js";
54
55
  import {
55
56
  clearUnitRuntimeRecord,
56
57
  inspectExecuteTaskDurability,
@@ -417,6 +418,38 @@ export function stopAutoRemote(projectRoot: string): {
417
418
  }
418
419
  }
419
420
 
421
+ /**
422
+ * Check if a remote auto-mode session is running (from a different process).
423
+ * Reads the crash lock, checks PID liveness, and returns session details.
424
+ * Used by the guard in commands.ts to prevent bare /gsd, /gsd next, and
425
+ * /gsd auto from stealing the session lock.
426
+ */
427
+ export function checkRemoteAutoSession(projectRoot: string): {
428
+ running: boolean;
429
+ pid?: number;
430
+ unitType?: string;
431
+ unitId?: string;
432
+ startedAt?: string;
433
+ completedUnits?: number;
434
+ } {
435
+ const lock = readCrashLock(projectRoot);
436
+ if (!lock) return { running: false };
437
+
438
+ if (!isLockProcessAlive(lock)) {
439
+ // Stale lock from a dead process — not a live remote session
440
+ return { running: false };
441
+ }
442
+
443
+ return {
444
+ running: true,
445
+ pid: lock.pid,
446
+ unitType: lock.unitType,
447
+ unitId: lock.unitId,
448
+ startedAt: lock.startedAt,
449
+ completedUnits: lock.completedUnits,
450
+ };
451
+ }
452
+
420
453
  export function isStepMode(): boolean {
421
454
  return s.stepMode;
422
455
  }
@@ -461,15 +494,33 @@ function buildSnapshotOpts(
461
494
  };
462
495
  }
463
496
 
464
- function handleLostSessionLock(ctx?: ExtensionContext): void {
465
- debugLog("session-lock-lost", { lockBase: lockBase() });
497
+ function handleLostSessionLock(
498
+ ctx?: ExtensionContext,
499
+ lockStatus?: SessionLockStatus,
500
+ ): void {
501
+ debugLog("session-lock-lost", {
502
+ lockBase: lockBase(),
503
+ reason: lockStatus?.failureReason,
504
+ existingPid: lockStatus?.existingPid,
505
+ expectedPid: lockStatus?.expectedPid,
506
+ });
466
507
  s.active = false;
467
508
  s.paused = false;
468
509
  clearUnitTimeout();
469
510
  deregisterSigtermHandler();
470
511
  clearCmuxSidebar(loadEffectiveGSDPreferences()?.preferences);
512
+ const message =
513
+ lockStatus?.failureReason === "pid-mismatch"
514
+ ? lockStatus.existingPid
515
+ ? `Session lock moved to PID ${lockStatus.existingPid} — another GSD process appears to have taken over. Stopping gracefully.`
516
+ : "Session lock moved to a different process — another GSD process appears to have taken over. Stopping gracefully."
517
+ : lockStatus?.failureReason === "missing-metadata"
518
+ ? "Session lock metadata disappeared, so ownership could not be confirmed. Stopping gracefully."
519
+ : lockStatus?.failureReason === "compromised"
520
+ ? "Session lock was compromised or invalidated during heartbeat checks; takeover was not confirmed. Stopping gracefully."
521
+ : "Session lock lost. Stopping gracefully.";
471
522
  ctx?.ui.notify(
472
- "Session lock lost — another GSD process appears to have taken over. Stopping gracefully.",
523
+ message,
473
524
  "error",
474
525
  );
475
526
  ctx?.ui.setStatus("gsd-auto", undefined);
@@ -736,7 +787,7 @@ function buildLoopDeps(): LoopDeps {
736
787
  checkResourcesStale,
737
788
 
738
789
  // Session lock
739
- validateSessionLock,
790
+ validateSessionLock: getSessionLockStatus,
740
791
  updateSessionLock,
741
792
  handleLostSessionLock,
742
793
 
@@ -15,7 +15,7 @@ import { deriveState } from "./state.js";
15
15
  import { GSDDashboardOverlay } from "./dashboard-overlay.js";
16
16
  import { GSDVisualizerOverlay } from "./visualizer-overlay.js";
17
17
  import { showQueue, showDiscuss, showHeadlessMilestoneCreation } from "./guided-flow.js";
18
- import { startAuto, stopAuto, pauseAuto, isAutoActive, isAutoPaused, isStepMode, stopAutoRemote } from "./auto.js";
18
+ import { startAuto, stopAuto, pauseAuto, isAutoActive, isAutoPaused, isStepMode, stopAutoRemote, checkRemoteAutoSession } from "./auto.js";
19
19
  import { dispatchDirectPhase } from "./auto-direct-dispatch.js";
20
20
  import { resolveProjectRoot } from "./worktree.js";
21
21
  import { assertSafeDirectory } from "./validate-directory.js";
@@ -50,6 +50,7 @@ import { handleLogs } from "./commands-logs.js";
50
50
  import { handleStart, handleTemplates, getTemplateCompletions } from "./commands-workflow-templates.js";
51
51
  import { readSessionLockData, isSessionLockProcessAlive } from "./session-lock.js";
52
52
  import { handleCmux } from "./commands-cmux.js";
53
+ import { showNextAction } from "../shared/mod.js";
53
54
 
54
55
 
55
56
  /** Resolve the effective project root, accounting for worktree paths. */
@@ -72,36 +73,88 @@ export function projectRoot(): string {
72
73
  }
73
74
 
74
75
  /**
75
- * Check if another process holds the auto-mode session lock.
76
- * Returns the lock data if a remote session is alive, null otherwise.
76
+ * Guard against starting auto-mode when a remote session is already running.
77
+ * Returns true if the caller should proceed with startAuto, false if handled.
77
78
  */
78
- function getRemoteAutoSession(basePath: string): { pid: number } | null {
79
- const lockData = readSessionLockData(basePath);
80
- if (!lockData) return null;
81
- if (lockData.pid === process.pid) return null;
82
- if (!isSessionLockProcessAlive(lockData)) return null;
83
- return { pid: lockData.pid };
84
- }
79
+ async function guardRemoteSession(
80
+ ctx: ExtensionCommandContext,
81
+ pi: ExtensionAPI,
82
+ ): Promise<boolean> {
83
+ // Local session already active — proceed (startAuto handles re-entrant calls)
84
+ if (isAutoActive() || isAutoPaused()) return true;
85
+
86
+ const remote = checkRemoteAutoSession(projectRoot());
87
+ if (!remote.running || !remote.pid) return true;
88
+
89
+ const unitLabel = remote.unitType && remote.unitId
90
+ ? `${remote.unitType} (${remote.unitId})`
91
+ : "unknown unit";
92
+ const unitsMsg = remote.completedUnits != null
93
+ ? `${remote.completedUnits} units completed`
94
+ : "";
95
+
96
+ const choice = await showNextAction(ctx, {
97
+ title: `Auto-mode is running in another terminal (PID ${remote.pid})`,
98
+ summary: [
99
+ `Currently executing: ${unitLabel}`,
100
+ ...(unitsMsg ? [unitsMsg] : []),
101
+ ...(remote.startedAt ? [`Started: ${remote.startedAt}`] : []),
102
+ ],
103
+ actions: [
104
+ {
105
+ id: "status",
106
+ label: "View status",
107
+ description: "Show the current GSD progress dashboard.",
108
+ recommended: true,
109
+ },
110
+ {
111
+ id: "steer",
112
+ label: "Steer the session",
113
+ description: "Use /gsd steer <instruction> to redirect the running session.",
114
+ },
115
+ {
116
+ id: "stop",
117
+ label: "Stop remote session",
118
+ description: `Send SIGTERM to PID ${remote.pid} to stop it gracefully.`,
119
+ },
120
+ {
121
+ id: "force",
122
+ label: "Force start (steal lock)",
123
+ description: "Start a new session, terminating the existing one.",
124
+ },
125
+ ],
126
+ notYetMessage: "Run /gsd when ready.",
127
+ });
85
128
 
86
- /**
87
- * Show a steering menu when auto-mode is running in another process.
88
- * Returns true if a remote session was detected (caller should return early).
89
- */
90
- function notifyRemoteAutoActive(ctx: ExtensionCommandContext, basePath: string): boolean {
91
- const remote = getRemoteAutoSession(basePath);
92
- if (!remote) return false;
93
- ctx.ui.notify(
94
- `Auto-mode is running in another process (PID ${remote.pid}).\n` +
95
- `Use these commands to interact with it:\n` +
96
- ` /gsd status — check progress\n` +
97
- ` /gsd discuss — discuss architecture decisions\n` +
98
- ` /gsd queue — queue the next milestone\n` +
99
- ` /gsd steer — apply an override to active work\n` +
100
- ` /gsd capture — fire-and-forget thought\n` +
101
- ` /gsd stop stop auto-mode`,
102
- "warning",
103
- );
104
- return true;
129
+ if (choice === "status") {
130
+ await handleStatus(ctx);
131
+ return false;
132
+ }
133
+ if (choice === "steer") {
134
+ ctx.ui.notify(
135
+ "Use /gsd steer <instruction> to redirect the running auto-mode session.\n" +
136
+ "Example: /gsd steer Use Postgres instead of SQLite",
137
+ "info",
138
+ );
139
+ return false;
140
+ }
141
+ if (choice === "stop") {
142
+ const result = stopAutoRemote(projectRoot());
143
+ if (result.found) {
144
+ ctx.ui.notify(`Sent stop signal to auto-mode session (PID ${result.pid}). It will shut down gracefully.`, "info");
145
+ } else if (result.error) {
146
+ ctx.ui.notify(`Failed to stop remote auto-mode: ${result.error}`, "error");
147
+ } else {
148
+ ctx.ui.notify("Remote session is no longer running.", "info");
149
+ }
150
+ return false;
151
+ }
152
+ if (choice === "force") {
153
+ return true; // Proceed — startAuto will steal the lock
154
+ }
155
+
156
+ // "not_yet" or escape
157
+ return false;
105
158
  }
106
159
 
107
160
  export function registerGSDCommand(pi: ExtensionAPI): void {
@@ -598,10 +651,10 @@ export async function handleGSDCommand(
598
651
  await handleDryRun(ctx, projectRoot());
599
652
  return;
600
653
  }
601
- if (notifyRemoteAutoActive(ctx, projectRoot())) return;
602
654
  const verboseMode = trimmed.includes("--verbose");
603
655
  const debugMode = trimmed.includes("--debug");
604
656
  if (debugMode) enableDebug(projectRoot());
657
+ if (!(await guardRemoteSession(ctx, pi))) return;
605
658
  await startAuto(ctx, pi, projectRoot(), verboseMode, { step: true });
606
659
  return;
607
660
  }
@@ -610,6 +663,7 @@ export async function handleGSDCommand(
610
663
  const verboseMode = trimmed.includes("--verbose");
611
664
  const debugMode = trimmed.includes("--debug");
612
665
  if (debugMode) enableDebug(projectRoot());
666
+ if (!(await guardRemoteSession(ctx, pi))) return;
613
667
  await startAuto(ctx, pi, projectRoot(), verboseMode);
614
668
  return;
615
669
  }
@@ -993,7 +1047,7 @@ Examples:
993
1047
  }
994
1048
 
995
1049
  if (trimmed === "") {
996
- if (notifyRemoteAutoActive(ctx, projectRoot())) return;
1050
+ if (!(await guardRemoteSession(ctx, pi))) return;
997
1051
  await startAuto(ctx, pi, projectRoot(), false, { step: true });
998
1052
  return;
999
1053
  }
@@ -479,9 +479,20 @@ export class GitServiceImpl {
479
479
 
480
480
  const wtName = detectWorktreeName(this.basePath);
481
481
  if (wtName) {
482
+ // Auto-mode worktrees use milestone/<MID> branches (wtName = milestone ID)
483
+ const milestoneBranch = `milestone/${wtName}`;
484
+ const currentBranch = nativeGetCurrentBranch(this.basePath);
485
+
486
+ // If we're on a milestone/<MID> branch, use it (auto-mode case)
487
+ if (currentBranch.startsWith("milestone/")) {
488
+ return currentBranch;
489
+ }
490
+
491
+ // Otherwise check for manual worktree branch (worktree/<name>)
482
492
  const wtBranch = `worktree/${wtName}`;
483
493
  if (nativeBranchExists(this.basePath, wtBranch)) return wtBranch;
484
- return nativeGetCurrentBranch(this.basePath);
494
+
495
+ return currentBranch;
485
496
  }
486
497
 
487
498
  // Repo-level default detection: origin/HEAD → main → master → current branch.
@@ -2,7 +2,8 @@
2
2
  // Human-readable display of past auto-mode unit executions.
3
3
 
4
4
  import type { ExtensionCommandContext } from "@gsd/pi-coding-agent";
5
- import { formatDuration, padRight, truncateWithEllipsis } from "../shared/format-utils.js";
5
+ import { formatDuration, truncateWithEllipsis } from "../shared/format-utils.js";
6
+ import { padRight } from "../shared/layout-utils.js";
6
7
  import {
7
8
  getLedger, getProjectTotals, formatCost, formatTokenCount,
8
9
  aggregateBySlice, aggregateByPhase, aggregateByModel, loadLedgerFromDisk,
@@ -20,8 +20,10 @@ import { getAndClearSkills } from "./skill-telemetry.js";
20
20
  import { loadJsonFile, loadJsonFileOrNull, saveJsonFile } from "./json-persistence.js";
21
21
  import { parseUnitId } from "./unit-id.js";
22
22
 
23
- // Re-export from shared — canonical implementation lives in format-utils.
24
- export { formatTokenCount } from "../shared/mod.js";
23
+ // Re-export from shared — import directly from format-utils to avoid pulling
24
+ // in the full barrel (mod.js → ui.js → @gsd/pi-tui) which breaks when loaded
25
+ // outside jiti's alias resolution (e.g. dynamic import in auto-loop reports).
26
+ export { formatTokenCount } from "../shared/format-utils.js";
25
27
 
26
28
  // ─── Types ────────────────────────────────────────────────────────────────────
27
29
 
@@ -40,6 +40,19 @@ export type SessionLockResult =
40
40
  | { acquired: true }
41
41
  | { acquired: false; reason: string; existingPid?: number };
42
42
 
43
+ export type SessionLockFailureReason =
44
+ | "compromised"
45
+ | "missing-metadata"
46
+ | "pid-mismatch";
47
+
48
+ export interface SessionLockStatus {
49
+ valid: boolean;
50
+ failureReason?: SessionLockFailureReason;
51
+ existingPid?: number;
52
+ expectedPid?: number;
53
+ recovered?: boolean;
54
+ }
55
+
43
56
  // ─── Module State ───────────────────────────────────────────────────────────
44
57
 
45
58
  /** Release function from proper-lockfile — calling it releases the OS lock. */
@@ -368,7 +381,7 @@ export function updateSessionLock(
368
381
  *
369
382
  * This is called periodically during the dispatch loop.
370
383
  */
371
- export function validateSessionLock(basePath: string): boolean {
384
+ export function getSessionLockStatus(basePath: string): SessionLockStatus {
372
385
  // Lock was compromised by proper-lockfile (mtime drift from sleep, stall, etc.)
373
386
  if (_lockCompromised) {
374
387
  // Recovery gate (#1512): Before declaring the lock lost, check if the lock
@@ -385,18 +398,23 @@ export function validateSessionLock(basePath: string): boolean {
385
398
  process.stderr.write(
386
399
  `[gsd] Lock recovered after onCompromised — lock file PID matched, re-acquired.\n`,
387
400
  );
388
- return true;
401
+ return { valid: true, recovered: true };
389
402
  }
390
403
  } catch {
391
404
  // Re-acquisition failed — fall through to return false
392
405
  }
393
406
  }
394
- return false;
407
+ return {
408
+ valid: false,
409
+ failureReason: "compromised",
410
+ existingPid: existing?.pid,
411
+ expectedPid: process.pid,
412
+ };
395
413
  }
396
414
 
397
415
  // If we have an OS-level lock, we're still the owner
398
416
  if (_releaseFunction && _lockedPath === basePath) {
399
- return true;
417
+ return { valid: true };
400
418
  }
401
419
 
402
420
  // Fallback: check the lock file PID
@@ -404,10 +422,27 @@ export function validateSessionLock(basePath: string): boolean {
404
422
  const existing = readExistingLockData(lp);
405
423
  if (!existing) {
406
424
  // Lock file was deleted — we lost ownership
407
- return false;
425
+ return {
426
+ valid: false,
427
+ failureReason: "missing-metadata",
428
+ expectedPid: process.pid,
429
+ };
430
+ }
431
+
432
+ if (existing.pid !== process.pid) {
433
+ return {
434
+ valid: false,
435
+ failureReason: "pid-mismatch",
436
+ existingPid: existing.pid,
437
+ expectedPid: process.pid,
438
+ };
408
439
  }
409
440
 
410
- return existing.pid === process.pid;
441
+ return { valid: true };
442
+ }
443
+
444
+ export function validateSessionLock(basePath: string): boolean {
445
+ return getSessionLockStatus(basePath).valid;
411
446
  }
412
447
 
413
448
  /**
@@ -14,6 +14,7 @@ import {
14
14
  type AgentEndEvent,
15
15
  type LoopDeps,
16
16
  } from "../auto-loop.js";
17
+ import type { SessionLockStatus } from "../session-lock.js";
17
18
 
18
19
  // ─── Helpers ─────────────────────────────────────────────────────────────────
19
20
 
@@ -341,7 +342,7 @@ function makeMockDeps(
341
342
  preDispatchHealthGate: async () => ({ proceed: true, fixesApplied: [] }),
342
343
  syncProjectRootToWorktree: () => {},
343
344
  checkResourcesStale: () => null,
344
- validateSessionLock: () => true,
345
+ validateSessionLock: () => ({ valid: true } as SessionLockStatus),
345
346
  updateSessionLock: () => {
346
347
  callLog.push("updateSessionLock");
347
348
  },
@@ -532,6 +533,41 @@ test("autoLoop exits on terminal complete state", async (t) => {
532
533
  );
533
534
  });
534
535
 
536
+ test("autoLoop passes structured session-lock failure details to the handler", async () => {
537
+ _resetPendingResolve();
538
+
539
+ const ctx = makeMockCtx();
540
+ ctx.ui.setStatus = () => {};
541
+ const pi = makeMockPi();
542
+ const s = makeLoopSession();
543
+ let observedLockStatus: SessionLockStatus | undefined;
544
+
545
+ const deps = makeMockDeps({
546
+ validateSessionLock: () =>
547
+ ({
548
+ valid: false,
549
+ failureReason: "compromised",
550
+ expectedPid: process.pid,
551
+ }) as SessionLockStatus,
552
+ handleLostSessionLock: (_ctx, lockStatus) => {
553
+ observedLockStatus = lockStatus;
554
+ deps.callLog.push("handleLostSessionLock");
555
+ },
556
+ });
557
+
558
+ await autoLoop(ctx, pi, s, deps);
559
+
560
+ assert.deepEqual(observedLockStatus, {
561
+ valid: false,
562
+ failureReason: "compromised",
563
+ expectedPid: process.pid,
564
+ });
565
+ assert.ok(
566
+ !deps.callLog.includes("resolveDispatch"),
567
+ "should stop before dispatch after lock validation fails",
568
+ );
569
+ });
570
+
535
571
  test("autoLoop exits on terminal blocked state", async (t) => {
536
572
  _resetPendingResolve();
537
573
 
@@ -153,6 +153,25 @@ async function main(): Promise<void> {
153
153
  // After teardown, originalBase should be null
154
154
  assertEq(getAutoWorktreeOriginalBase(), null, "no split-brain: originalBase cleared");
155
155
 
156
+ // ─── #1526: getMainBranch returns milestone branch in auto-worktree ──
157
+ console.log("\n=== #1526: getMainBranch() returns milestone/<MID> in auto-worktree ===");
158
+ {
159
+ const { GitServiceImpl } = await import("../git-service.ts");
160
+
161
+ // Create worktree
162
+ const wtPath = createAutoWorktree(tempDir, "M005");
163
+ // Don't set main_branch pref so getMainBranch falls through to worktree detection
164
+ const gitService = new GitServiceImpl(wtPath);
165
+ gitService.setMilestoneId("M005");
166
+
167
+ // Verify getMainBranch returns the milestone branch
168
+ const mainBranch = gitService.getMainBranch();
169
+ assertEq(mainBranch, "milestone/M005", "getMainBranch returns milestone/<MID> in auto-worktree");
170
+
171
+ // Cleanup
172
+ teardownAutoWorktree(tempDir, "M005");
173
+ }
174
+
156
175
  // ─── #778: reconcile plan checkboxes on re-attach ─────────────────
157
176
  console.log("\n=== #778: reconcile plan checkboxes on re-attach ===");
158
177
  {
@@ -1,5 +1,8 @@
1
- import test from "node:test";
1
+ import test, { describe } from "node:test";
2
2
  import assert from "node:assert/strict";
3
+ import * as fs from "node:fs";
4
+ import * as path from "node:path";
5
+ import { fileURLToPath } from "node:url";
3
6
  import {
4
7
  buildCmuxProgress,
5
8
  buildCmuxStatusLabel,
@@ -96,3 +99,24 @@ test("buildCmuxStatusLabel and progress prefer deepest active unit", () => {
96
99
  assert.equal(buildCmuxStatusLabel(state), "M001 S02/T03 · executing");
97
100
  assert.deepEqual(buildCmuxProgress(state), { value: 0.4, label: "2/5 tasks" });
98
101
  });
102
+
103
+ describe("cmux extension discovery opt-out", () => {
104
+ test("cmux directory has package.json with pi manifest to prevent auto-discovery as extension", () => {
105
+ const cmuxDir = path.resolve(
106
+ path.dirname(fileURLToPath(import.meta.url)),
107
+ "../../cmux",
108
+ );
109
+ const pkgPath = path.join(cmuxDir, "package.json");
110
+ assert.ok(fs.existsSync(pkgPath), `${pkgPath} must exist`);
111
+
112
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
113
+ assert.ok(
114
+ pkg.pi !== undefined && typeof pkg.pi === "object",
115
+ 'package.json must have a "pi" field to opt out of extension auto-discovery',
116
+ );
117
+ assert.ok(
118
+ !pkg.pi.extensions?.length,
119
+ "pi.extensions must be empty or absent — cmux is a library, not an extension",
120
+ );
121
+ });
122
+ });
@@ -17,6 +17,7 @@ import { tmpdir } from 'node:os';
17
17
 
18
18
  import {
19
19
  acquireSessionLock,
20
+ getSessionLockStatus,
20
21
  validateSessionLock,
21
22
  releaseSessionLock,
22
23
  readSessionLockData,
@@ -201,6 +202,50 @@ async function main(): Promise<void> {
201
202
  }
202
203
  }
203
204
 
205
+ // ─── 7b. getSessionLockStatus with missing metadata → reason surfaced ──
206
+ console.log('\n=== 7b. missing lock metadata → structured reason ===');
207
+ {
208
+ const base = mkdtempSync(join(tmpdir(), 'gsd-session-lock-'));
209
+ mkdirSync(join(base, '.gsd'), { recursive: true });
210
+
211
+ try {
212
+ const status = getSessionLockStatus(base);
213
+ assertEq(status.valid, false, 'missing lock metadata is invalid');
214
+ assertEq(status.failureReason, 'missing-metadata', 'missing metadata reason is surfaced');
215
+ assertEq(status.expectedPid, process.pid, 'expected PID is included');
216
+ } finally {
217
+ rmSync(base, { recursive: true, force: true });
218
+ }
219
+ }
220
+
221
+ // ─── 7c. getSessionLockStatus with foreign PID → reason surfaced ───────
222
+ console.log('\n=== 7c. foreign PID in lock file → structured reason ===');
223
+ {
224
+ const base = mkdtempSync(join(tmpdir(), 'gsd-session-lock-'));
225
+ mkdirSync(join(base, '.gsd'), { recursive: true });
226
+
227
+ try {
228
+ const foreignPid = process.pid + 1000;
229
+ const lockFile = join(gsdRoot(base), 'auto.lock');
230
+ writeFileSync(lockFile, JSON.stringify({
231
+ pid: foreignPid,
232
+ startedAt: new Date().toISOString(),
233
+ unitType: 'execute-task',
234
+ unitId: 'M001/S01/T01',
235
+ unitStartedAt: new Date().toISOString(),
236
+ completedUnits: 0,
237
+ }, null, 2));
238
+
239
+ const status = getSessionLockStatus(base);
240
+ assertEq(status.valid, false, 'foreign PID lock is invalid');
241
+ assertEq(status.failureReason, 'pid-mismatch', 'PID mismatch reason is surfaced');
242
+ assertEq(status.existingPid, foreignPid, 'existing PID is included');
243
+ assertEq(status.expectedPid, process.pid, 'expected PID is included');
244
+ } finally {
245
+ rmSync(base, { recursive: true, force: true });
246
+ }
247
+ }
248
+
204
249
  // ─── 8. Acquire after release is possible ─────────────────────────────
205
250
  console.log('\n=== 8. acquire after release → re-acquirable ===');
206
251
  {
@@ -1,12 +1,12 @@
1
1
  /**
2
- * Shared formatting and layout utilities for TUI dashboard components.
2
+ * Shared pure formatting utilities no @gsd/pi-tui dependency.
3
3
  *
4
- * Consolidates helpers that were previously duplicated across
5
- * auto-dashboard.ts, dashboard-overlay.ts, and visualizer-views.ts.
4
+ * ANSI-aware layout helpers (padRight, joinColumns, centerLine, fitColumns)
5
+ * live in layout-utils.ts to avoid pulling @gsd/pi-tui into modules that
6
+ * run outside jiti's alias resolution (e.g. HTML report generation via
7
+ * dynamic import in auto-loop).
6
8
  */
7
9
 
8
- import { truncateToWidth, visibleWidth } from "@gsd/pi-tui";
9
-
10
10
  // ─── Duration Formatting ──────────────────────────────────────────────────────
11
11
 
12
12
  /** Format a millisecond duration as a compact human-readable string. */
@@ -31,45 +31,6 @@ export function formatTokenCount(count: number): string {
31
31
  return `${(count / 1_000_000).toFixed(2)}M`;
32
32
  }
33
33
 
34
- // ─── Layout Helpers ───────────────────────────────────────────────────────────
35
-
36
- /** Pad a string with trailing spaces to fill `width` (ANSI-aware). */
37
- export function padRight(content: string, width: number): string {
38
- const vis = visibleWidth(content);
39
- return content + " ".repeat(Math.max(0, width - vis));
40
- }
41
-
42
- /** Build a line with left-aligned and right-aligned content. */
43
- export function joinColumns(left: string, right: string, width: number): string {
44
- const leftW = visibleWidth(left);
45
- const rightW = visibleWidth(right);
46
- if (leftW + rightW + 2 > width) {
47
- return truncateToWidth(`${left} ${right}`, width);
48
- }
49
- return left + " ".repeat(width - leftW - rightW) + right;
50
- }
51
-
52
- /** Center content within `width` (ANSI-aware). */
53
- export function centerLine(content: string, width: number): string {
54
- const vis = visibleWidth(content);
55
- if (vis >= width) return truncateToWidth(content, width);
56
- const leftPad = Math.floor((width - vis) / 2);
57
- return " ".repeat(leftPad) + content;
58
- }
59
-
60
- /** Join as many parts as fit within `width`, separated by `separator`. */
61
- export function fitColumns(parts: string[], width: number, separator = " "): string {
62
- const filtered = parts.filter(Boolean);
63
- if (filtered.length === 0) return "";
64
- let result = filtered[0];
65
- for (let i = 1; i < filtered.length; i++) {
66
- const candidate = `${result}${separator}${filtered[i]}`;
67
- if (visibleWidth(candidate) > width) break;
68
- result = candidate;
69
- }
70
- return truncateToWidth(result, width);
71
- }
72
-
73
34
  // ─── Text Truncation ─────────────────────────────────────────────────────────
74
35
 
75
36
  /** Truncate a string to `maxLength` characters, replacing the last character with an ellipsis if needed. */