gsd-pi 2.37.0 → 2.37.1-dev.193bd3d

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 (35) hide show
  1. package/README.md +20 -19
  2. package/dist/resources/extensions/cmux/package.json +7 -0
  3. package/dist/resources/extensions/gsd/auto-loop.js +18 -4
  4. package/dist/resources/extensions/gsd/auto.js +42 -5
  5. package/dist/resources/extensions/gsd/commands.js +80 -33
  6. package/dist/resources/extensions/gsd/git-service.js +9 -1
  7. package/dist/resources/extensions/gsd/history.js +2 -1
  8. package/dist/resources/extensions/gsd/metrics.js +4 -2
  9. package/dist/resources/extensions/gsd/session-lock.js +26 -6
  10. package/dist/resources/extensions/shared/format-utils.js +5 -41
  11. package/dist/resources/extensions/shared/layout-utils.js +46 -0
  12. package/dist/resources/extensions/shared/mod.js +2 -1
  13. package/package.json +1 -1
  14. package/packages/pi-coding-agent/dist/core/extensions/loader.d.ts.map +1 -1
  15. package/packages/pi-coding-agent/dist/core/extensions/loader.js +8 -4
  16. package/packages/pi-coding-agent/dist/core/extensions/loader.js.map +1 -1
  17. package/packages/pi-coding-agent/package.json +1 -1
  18. package/packages/pi-coding-agent/src/core/extensions/loader.ts +8 -4
  19. package/pkg/package.json +1 -1
  20. package/src/resources/extensions/cmux/package.json +7 -0
  21. package/src/resources/extensions/gsd/auto-loop.ts +24 -6
  22. package/src/resources/extensions/gsd/auto.ts +56 -5
  23. package/src/resources/extensions/gsd/commands.ts +85 -31
  24. package/src/resources/extensions/gsd/git-service.ts +12 -1
  25. package/src/resources/extensions/gsd/history.ts +2 -1
  26. package/src/resources/extensions/gsd/metrics.ts +4 -2
  27. package/src/resources/extensions/gsd/session-lock.ts +41 -6
  28. package/src/resources/extensions/gsd/tests/auto-loop.test.ts +37 -1
  29. package/src/resources/extensions/gsd/tests/auto-worktree.test.ts +19 -0
  30. package/src/resources/extensions/gsd/tests/cmux.test.ts +25 -1
  31. package/src/resources/extensions/gsd/tests/session-lock-regression.test.ts +45 -0
  32. package/src/resources/extensions/shared/format-utils.ts +5 -44
  33. package/src/resources/extensions/shared/layout-utils.ts +49 -0
  34. package/src/resources/extensions/shared/mod.ts +7 -4
  35. package/src/resources/extensions/shared/tests/format-utils.test.ts +5 -3
@@ -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. */
@@ -0,0 +1,49 @@
1
+ /**
2
+ * ANSI-aware TUI layout utilities that depend on @gsd/pi-tui.
3
+ *
4
+ * Separated from format-utils.ts so that modules needing only pure
5
+ * formatting (e.g. HTML report generation) can import format-utils
6
+ * without pulling in the @gsd/pi-tui dependency — which fails when
7
+ * loaded outside jiti's alias resolution context.
8
+ */
9
+
10
+ import { truncateToWidth, visibleWidth } from "@gsd/pi-tui";
11
+
12
+ // ─── Layout Helpers ───────────────────────────────────────────────────────────
13
+
14
+ /** Pad a string with trailing spaces to fill `width` (ANSI-aware). */
15
+ export function padRight(content: string, width: number): string {
16
+ const vis = visibleWidth(content);
17
+ return content + " ".repeat(Math.max(0, width - vis));
18
+ }
19
+
20
+ /** Build a line with left-aligned and right-aligned content. */
21
+ export function joinColumns(left: string, right: string, width: number): string {
22
+ const leftW = visibleWidth(left);
23
+ const rightW = visibleWidth(right);
24
+ if (leftW + rightW + 2 > width) {
25
+ return truncateToWidth(`${left} ${right}`, width);
26
+ }
27
+ return left + " ".repeat(width - leftW - rightW) + right;
28
+ }
29
+
30
+ /** Center content within `width` (ANSI-aware). */
31
+ export function centerLine(content: string, width: number): string {
32
+ const vis = visibleWidth(content);
33
+ if (vis >= width) return truncateToWidth(content, width);
34
+ const leftPad = Math.floor((width - vis) / 2);
35
+ return " ".repeat(leftPad) + content;
36
+ }
37
+
38
+ /** Join as many parts as fit within `width`, separated by `separator`. */
39
+ export function fitColumns(parts: string[], width: number, separator = " "): string {
40
+ const filtered = parts.filter(Boolean);
41
+ if (filtered.length === 0) return "";
42
+ let result = filtered[0];
43
+ for (let i = 1; i < filtered.length; i++) {
44
+ const candidate = `${result}${separator}${filtered[i]}`;
45
+ if (visibleWidth(candidate) > width) break;
46
+ result = candidate;
47
+ }
48
+ return truncateToWidth(result, width);
49
+ }
@@ -13,15 +13,18 @@ export {
13
13
  stripAnsi,
14
14
  formatTokenCount,
15
15
  formatDuration,
16
- padRight,
17
- joinColumns,
18
- centerLine,
19
- fitColumns,
20
16
  sparkline,
21
17
  normalizeStringArray,
22
18
  fileLink,
23
19
  } from "./format-utils.js";
24
20
 
21
+ export {
22
+ padRight,
23
+ joinColumns,
24
+ centerLine,
25
+ fitColumns,
26
+ } from "./layout-utils.js";
27
+
25
28
  export { shortcutDesc } from "./terminal.js";
26
29
  export { toPosixPath } from "./path-display.js";
27
30
  export { showInterviewRound } from "./interview-ui.js";
@@ -2,13 +2,15 @@ import { describe, it } from "node:test";
2
2
  import assert from "node:assert/strict";
3
3
  import {
4
4
  formatDuration,
5
+ sparkline,
6
+ stripAnsi,
7
+ } from "../format-utils.js";
8
+ import {
5
9
  padRight,
6
10
  joinColumns,
7
11
  centerLine,
8
12
  fitColumns,
9
- sparkline,
10
- stripAnsi,
11
- } from "../format-utils.js";
13
+ } from "../layout-utils.js";
12
14
 
13
15
  describe("formatDuration", () => {
14
16
  it("formats seconds", () => {