gsd-pi 2.37.0 → 2.37.1-dev.3bbb0a9

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 (103) 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-dispatch.js +67 -1
  5. package/dist/resources/extensions/gsd/auto-loop.js +18 -4
  6. package/dist/resources/extensions/gsd/auto-post-unit.js +14 -0
  7. package/dist/resources/extensions/gsd/auto-prompts.js +91 -2
  8. package/dist/resources/extensions/gsd/auto-recovery.js +37 -1
  9. package/dist/resources/extensions/gsd/auto.js +42 -5
  10. package/dist/resources/extensions/gsd/commands.js +80 -33
  11. package/dist/resources/extensions/gsd/doctor-providers.js +35 -1
  12. package/dist/resources/extensions/gsd/files.js +41 -0
  13. package/dist/resources/extensions/gsd/git-service.js +9 -1
  14. package/dist/resources/extensions/gsd/history.js +2 -1
  15. package/dist/resources/extensions/gsd/metrics.js +4 -2
  16. package/dist/resources/extensions/gsd/observability-validator.js +24 -0
  17. package/dist/resources/extensions/gsd/preferences-types.js +2 -1
  18. package/dist/resources/extensions/gsd/preferences-validation.js +42 -0
  19. package/dist/resources/extensions/gsd/prompts/plan-slice.md +2 -1
  20. package/dist/resources/extensions/gsd/prompts/reactive-execute.md +41 -0
  21. package/dist/resources/extensions/gsd/reactive-graph.js +227 -0
  22. package/dist/resources/extensions/gsd/session-lock.js +26 -6
  23. package/dist/resources/extensions/gsd/templates/task-plan.md +11 -3
  24. package/dist/resources/extensions/shared/format-utils.js +5 -41
  25. package/dist/resources/extensions/shared/layout-utils.js +46 -0
  26. package/dist/resources/extensions/shared/mod.js +2 -1
  27. package/package.json +2 -1
  28. package/packages/pi-ai/dist/env-api-keys.js +13 -0
  29. package/packages/pi-ai/dist/env-api-keys.js.map +1 -1
  30. package/packages/pi-ai/dist/models.generated.d.ts +172 -0
  31. package/packages/pi-ai/dist/models.generated.d.ts.map +1 -1
  32. package/packages/pi-ai/dist/models.generated.js +172 -0
  33. package/packages/pi-ai/dist/models.generated.js.map +1 -1
  34. package/packages/pi-ai/dist/providers/anthropic-shared.d.ts +64 -0
  35. package/packages/pi-ai/dist/providers/anthropic-shared.d.ts.map +1 -0
  36. package/packages/pi-ai/dist/providers/anthropic-shared.js +668 -0
  37. package/packages/pi-ai/dist/providers/anthropic-shared.js.map +1 -0
  38. package/packages/pi-ai/dist/providers/anthropic-vertex.d.ts +5 -0
  39. package/packages/pi-ai/dist/providers/anthropic-vertex.d.ts.map +1 -0
  40. package/packages/pi-ai/dist/providers/anthropic-vertex.js +85 -0
  41. package/packages/pi-ai/dist/providers/anthropic-vertex.js.map +1 -0
  42. package/packages/pi-ai/dist/providers/anthropic.d.ts +4 -30
  43. package/packages/pi-ai/dist/providers/anthropic.d.ts.map +1 -1
  44. package/packages/pi-ai/dist/providers/anthropic.js +47 -764
  45. package/packages/pi-ai/dist/providers/anthropic.js.map +1 -1
  46. package/packages/pi-ai/dist/providers/register-builtins.d.ts.map +1 -1
  47. package/packages/pi-ai/dist/providers/register-builtins.js +6 -0
  48. package/packages/pi-ai/dist/providers/register-builtins.js.map +1 -1
  49. package/packages/pi-ai/dist/types.d.ts +2 -2
  50. package/packages/pi-ai/dist/types.d.ts.map +1 -1
  51. package/packages/pi-ai/dist/types.js.map +1 -1
  52. package/packages/pi-ai/package.json +1 -0
  53. package/packages/pi-ai/src/env-api-keys.ts +14 -0
  54. package/packages/pi-ai/src/models.generated.ts +172 -0
  55. package/packages/pi-ai/src/providers/anthropic-shared.ts +761 -0
  56. package/packages/pi-ai/src/providers/anthropic-vertex.ts +130 -0
  57. package/packages/pi-ai/src/providers/anthropic.ts +76 -868
  58. package/packages/pi-ai/src/providers/register-builtins.ts +7 -0
  59. package/packages/pi-ai/src/types.ts +2 -0
  60. package/packages/pi-coding-agent/dist/core/extensions/loader.d.ts.map +1 -1
  61. package/packages/pi-coding-agent/dist/core/extensions/loader.js +8 -4
  62. package/packages/pi-coding-agent/dist/core/extensions/loader.js.map +1 -1
  63. package/packages/pi-coding-agent/dist/core/model-resolver.d.ts.map +1 -1
  64. package/packages/pi-coding-agent/dist/core/model-resolver.js +1 -0
  65. package/packages/pi-coding-agent/dist/core/model-resolver.js.map +1 -1
  66. package/packages/pi-coding-agent/package.json +1 -1
  67. package/packages/pi-coding-agent/src/core/extensions/loader.ts +8 -4
  68. package/packages/pi-coding-agent/src/core/model-resolver.ts +1 -0
  69. package/pkg/package.json +1 -1
  70. package/src/resources/extensions/cmux/package.json +7 -0
  71. package/src/resources/extensions/gsd/auto-dispatch.ts +93 -0
  72. package/src/resources/extensions/gsd/auto-loop.ts +24 -6
  73. package/src/resources/extensions/gsd/auto-post-unit.ts +14 -0
  74. package/src/resources/extensions/gsd/auto-prompts.ts +125 -3
  75. package/src/resources/extensions/gsd/auto-recovery.ts +42 -0
  76. package/src/resources/extensions/gsd/auto.ts +56 -5
  77. package/src/resources/extensions/gsd/commands.ts +85 -31
  78. package/src/resources/extensions/gsd/doctor-providers.ts +38 -1
  79. package/src/resources/extensions/gsd/files.ts +45 -0
  80. package/src/resources/extensions/gsd/git-service.ts +12 -1
  81. package/src/resources/extensions/gsd/history.ts +2 -1
  82. package/src/resources/extensions/gsd/metrics.ts +4 -2
  83. package/src/resources/extensions/gsd/observability-validator.ts +27 -0
  84. package/src/resources/extensions/gsd/preferences-types.ts +5 -1
  85. package/src/resources/extensions/gsd/preferences-validation.ts +41 -0
  86. package/src/resources/extensions/gsd/prompts/plan-slice.md +2 -1
  87. package/src/resources/extensions/gsd/prompts/reactive-execute.md +41 -0
  88. package/src/resources/extensions/gsd/reactive-graph.ts +289 -0
  89. package/src/resources/extensions/gsd/session-lock.ts +41 -6
  90. package/src/resources/extensions/gsd/templates/task-plan.md +11 -3
  91. package/src/resources/extensions/gsd/tests/auto-loop.test.ts +37 -1
  92. package/src/resources/extensions/gsd/tests/auto-worktree.test.ts +19 -0
  93. package/src/resources/extensions/gsd/tests/cmux.test.ts +25 -1
  94. package/src/resources/extensions/gsd/tests/doctor-providers.test.ts +108 -3
  95. package/src/resources/extensions/gsd/tests/plan-quality-validator.test.ts +111 -0
  96. package/src/resources/extensions/gsd/tests/reactive-executor.test.ts +511 -0
  97. package/src/resources/extensions/gsd/tests/reactive-graph.test.ts +299 -0
  98. package/src/resources/extensions/gsd/tests/session-lock-regression.test.ts +45 -0
  99. package/src/resources/extensions/gsd/types.ts +43 -0
  100. package/src/resources/extensions/shared/format-utils.ts +5 -44
  101. package/src/resources/extensions/shared/layout-utils.ts +49 -0
  102. package/src/resources/extensions/shared/mod.ts +7 -4
  103. package/src/resources/extensions/shared/tests/format-utils.test.ts +5 -3
@@ -15,6 +15,7 @@ import type { ExtensionAPI, ExtensionContext } from "@gsd/pi-coding-agent";
15
15
  import type { AutoSession } from "./auto/session.js";
16
16
  import { NEW_SESSION_TIMEOUT_MS } from "./auto/session.js";
17
17
  import type { GSDPreferences } from "./preferences.js";
18
+ import type { SessionLockStatus } from "./session-lock.js";
18
19
  import type { GSDState } from "./types.js";
19
20
  import type { CloseoutOptions } from "./auto-unit-closeout.js";
20
21
  import type { PostUnitContext } from "./auto-post-unit.js";
@@ -307,7 +308,7 @@ export interface LoopDeps {
307
308
  checkResourcesStale: (version: string | null) => string | null;
308
309
 
309
310
  // Session lock
310
- validateSessionLock: (basePath: string) => boolean;
311
+ validateSessionLock: (basePath: string) => SessionLockStatus;
311
312
  updateSessionLock: (
312
313
  basePath: string,
313
314
  unitType: string,
@@ -315,7 +316,10 @@ export interface LoopDeps {
315
316
  completedUnits: number,
316
317
  sessionFile?: string,
317
318
  ) => void;
318
- handleLostSessionLock: (ctx?: ExtensionContext) => void;
319
+ handleLostSessionLock: (
320
+ ctx?: ExtensionContext,
321
+ lockStatus?: SessionLockStatus,
322
+ ) => void;
319
323
 
320
324
  // Milestone transition functions
321
325
  sendDesktopNotification: (
@@ -559,10 +563,24 @@ export async function autoLoop(
559
563
  try {
560
564
  // ── Blanket try/catch: one bad iteration must not kill the session
561
565
 
562
- if (deps.lockBase() && !deps.validateSessionLock(deps.lockBase())) {
563
- deps.handleLostSessionLock(ctx);
564
- debugLog("autoLoop", { phase: "exit", reason: "session-lock-lost" });
565
- break;
566
+ const sessionLockBase = deps.lockBase();
567
+ if (sessionLockBase) {
568
+ const lockStatus = deps.validateSessionLock(sessionLockBase);
569
+ if (!lockStatus.valid) {
570
+ debugLog("autoLoop", {
571
+ phase: "session-lock-invalid",
572
+ reason: lockStatus.failureReason ?? "unknown",
573
+ existingPid: lockStatus.existingPid,
574
+ expectedPid: lockStatus.expectedPid,
575
+ });
576
+ deps.handleLostSessionLock(ctx, lockStatus);
577
+ debugLog("autoLoop", {
578
+ phase: "exit",
579
+ reason: "session-lock-lost",
580
+ detail: lockStatus.failureReason ?? "unknown",
581
+ });
582
+ break;
583
+ }
566
584
  }
567
585
 
568
586
  // ── Phase 1: Pre-dispatch ───────────────────────────────────────────
@@ -217,6 +217,20 @@ export async function postUnitPreVerification(pctx: PostUnitContext): Promise<"d
217
217
  }
218
218
  }
219
219
 
220
+ // Reactive state cleanup on slice completion
221
+ if (s.currentUnit.type === "complete-slice") {
222
+ try {
223
+ const parts = s.currentUnit.id.split("/");
224
+ const [mid, sid] = parts;
225
+ if (mid && sid) {
226
+ const { clearReactiveState } = await import("./reactive-graph.js");
227
+ clearReactiveState(s.basePath, mid, sid);
228
+ }
229
+ } catch {
230
+ // Non-fatal
231
+ }
232
+ }
233
+
220
234
  // Post-triage: execute actionable resolutions
221
235
  if (s.currentUnit.type === "triage-captures") {
222
236
  try {
@@ -485,6 +485,41 @@ export async function getPriorTaskSummaryPaths(
485
485
  .map(f => `${sRel}/tasks/${f}`);
486
486
  }
487
487
 
488
+ /**
489
+ * Get carry-forward summary paths scoped to a task's derived dependencies.
490
+ *
491
+ * Instead of all prior tasks (order-based), returns only summaries for task
492
+ * IDs in `dependsOn`. Used by reactive-execute to give each subagent only
493
+ * the context it actually needs — not sibling tasks from a parallel batch.
494
+ *
495
+ * Falls back to order-based when dependsOn is empty (root tasks still get
496
+ * any available prior summaries for continuity).
497
+ */
498
+ export async function getDependencyTaskSummaryPaths(
499
+ mid: string, sid: string, currentTid: string,
500
+ dependsOn: string[], base: string,
501
+ ): Promise<string[]> {
502
+ // If no dependencies, fall back to order-based for root tasks
503
+ if (dependsOn.length === 0) {
504
+ return getPriorTaskSummaryPaths(mid, sid, currentTid, base);
505
+ }
506
+
507
+ const tDir = resolveTasksDir(base, mid, sid);
508
+ if (!tDir) return [];
509
+
510
+ const summaryFiles = resolveTaskFiles(tDir, "SUMMARY");
511
+ const sRel = relSlicePath(base, mid, sid);
512
+ const depSet = new Set(dependsOn.map((d) => d.toUpperCase()));
513
+
514
+ return summaryFiles
515
+ .filter((f) => {
516
+ // Extract task ID from filename: "T02-SUMMARY.md" → "T02"
517
+ const tid = f.replace(/-SUMMARY\.md$/i, "").toUpperCase();
518
+ return depSet.has(tid);
519
+ })
520
+ .map((f) => `${sRel}/tasks/${f}`);
521
+ }
522
+
488
523
  // ─── Adaptive Replanning Checks ────────────────────────────────────────────
489
524
 
490
525
  /**
@@ -772,13 +807,24 @@ export async function buildPlanSlicePrompt(
772
807
  });
773
808
  }
774
809
 
810
+ /** Options for customizing execute-task prompt construction. */
811
+ export interface ExecuteTaskPromptOptions {
812
+ level?: InlineLevel;
813
+ /** Override carry-forward paths (dependency-based instead of order-based). */
814
+ carryForwardPaths?: string[];
815
+ }
816
+
775
817
  export async function buildExecuteTaskPrompt(
776
818
  mid: string, sid: string, sTitle: string,
777
- tid: string, tTitle: string, base: string, level?: InlineLevel,
819
+ tid: string, tTitle: string, base: string,
820
+ level?: InlineLevel | ExecuteTaskPromptOptions,
778
821
  ): Promise<string> {
779
- const inlineLevel = level ?? resolveInlineLevel();
822
+ const opts: ExecuteTaskPromptOptions = typeof level === "object" && level !== null && !Array.isArray(level)
823
+ ? level
824
+ : { level: level as InlineLevel | undefined };
825
+ const inlineLevel = opts.level ?? resolveInlineLevel();
780
826
 
781
- const priorSummaries = await getPriorTaskSummaryPaths(mid, sid, tid, base);
827
+ const priorSummaries = opts.carryForwardPaths ?? await getPriorTaskSummaryPaths(mid, sid, tid, base);
782
828
  const priorLines = priorSummaries.length > 0
783
829
  ? priorSummaries.map(p => `- \`${p}\``).join("\n")
784
830
  : "- (no prior tasks)";
@@ -1234,6 +1280,82 @@ export async function buildReassessRoadmapPrompt(
1234
1280
  });
1235
1281
  }
1236
1282
 
1283
+ // ─── Reactive Execute Prompt ──────────────────────────────────────────────
1284
+
1285
+ export async function buildReactiveExecutePrompt(
1286
+ mid: string, midTitle: string, sid: string, sTitle: string,
1287
+ readyTaskIds: string[], base: string,
1288
+ ): Promise<string> {
1289
+ const { loadSliceTaskIO, deriveTaskGraph, graphMetrics } = await import("./reactive-graph.js");
1290
+
1291
+ // Build graph for context
1292
+ const taskIO = await loadSliceTaskIO(base, mid, sid);
1293
+ const graph = deriveTaskGraph(taskIO);
1294
+ const metrics = graphMetrics(graph);
1295
+
1296
+ // Build graph context section
1297
+ const graphLines: string[] = [];
1298
+ for (const node of graph) {
1299
+ const status = node.done ? "✅ done" : readyTaskIds.includes(node.id) ? "🟢 ready" : "⏳ waiting";
1300
+ const deps = node.dependsOn.length > 0 ? ` (depends on: ${node.dependsOn.join(", ")})` : "";
1301
+ graphLines.push(`- **${node.id}: ${node.title}** — ${status}${deps}`);
1302
+ if (node.outputFiles.length > 0) {
1303
+ graphLines.push(` - Outputs: ${node.outputFiles.map(f => `\`${f}\``).join(", ")}`);
1304
+ }
1305
+ }
1306
+ const graphContext = [
1307
+ `Tasks: ${metrics.taskCount}, Edges: ${metrics.edgeCount}, Ready: ${metrics.readySetSize}`,
1308
+ "",
1309
+ ...graphLines,
1310
+ ].join("\n");
1311
+
1312
+ // Build individual subagent prompts for each ready task
1313
+ const subagentSections: string[] = [];
1314
+ const readyTaskListLines: string[] = [];
1315
+
1316
+ for (const tid of readyTaskIds) {
1317
+ const node = graph.find((n) => n.id === tid);
1318
+ const tTitle = node?.title ?? tid;
1319
+ readyTaskListLines.push(`- **${tid}: ${tTitle}**`);
1320
+
1321
+ // Build dependency-scoped carry-forward paths for this task
1322
+ const depPaths = await getDependencyTaskSummaryPaths(
1323
+ mid, sid, tid, node?.dependsOn ?? [], base,
1324
+ );
1325
+
1326
+ // Build a full execute-task prompt with dependency-based carry-forward
1327
+ const taskPrompt = await buildExecuteTaskPrompt(
1328
+ mid, sid, sTitle, tid, tTitle, base,
1329
+ { carryForwardPaths: depPaths },
1330
+ );
1331
+
1332
+ subagentSections.push([
1333
+ `### ${tid}: ${tTitle}`,
1334
+ "",
1335
+ "Use this as the prompt for a `subagent` call:",
1336
+ "",
1337
+ "```",
1338
+ taskPrompt,
1339
+ "```",
1340
+ ].join("\n"));
1341
+ }
1342
+
1343
+ const inlinedTemplates = inlineTemplate("task-summary", "Task Summary");
1344
+
1345
+ return loadPrompt("reactive-execute", {
1346
+ workingDirectory: base,
1347
+ milestoneId: mid,
1348
+ milestoneTitle: midTitle,
1349
+ sliceId: sid,
1350
+ sliceTitle: sTitle,
1351
+ graphContext,
1352
+ readyTaskCount: String(readyTaskIds.length),
1353
+ readyTaskList: readyTaskListLines.join("\n"),
1354
+ subagentPrompts: subagentSections.join("\n\n---\n\n"),
1355
+ inlinedTemplates,
1356
+ });
1357
+ }
1358
+
1237
1359
  export async function buildRewriteDocsPrompt(
1238
1360
  mid: string, midTitle: string,
1239
1361
  activeSlice: { id: string; title: string } | null,
@@ -26,6 +26,7 @@ import {
26
26
  resolveSlicePath,
27
27
  resolveSliceFile,
28
28
  resolveTasksDir,
29
+ resolveTaskFiles,
29
30
  relMilestoneFile,
30
31
  relSliceFile,
31
32
  relSlicePath,
@@ -110,6 +111,9 @@ export function resolveExpectedArtifactPath(
110
111
  }
111
112
  case "rewrite-docs":
112
113
  return null;
114
+ case "reactive-execute":
115
+ // Reactive execute produces multiple task summaries — verified separately
116
+ return null;
113
117
  default:
114
118
  return null;
115
119
  }
@@ -148,6 +152,44 @@ export function verifyExpectedArtifact(
148
152
  return !content.includes("**Scope:** active");
149
153
  }
150
154
 
155
+ // Reactive-execute: verify that each dispatched task's summary exists.
156
+ // The unitId encodes the batch: "{mid}/{sid}/reactive+T02,T03"
157
+ if (unitType === "reactive-execute") {
158
+ const parts = unitId.split("/");
159
+ const mid = parts[0];
160
+ const sidAndBatch = parts[1];
161
+ const batchPart = parts[2]; // "reactive+T02,T03"
162
+ if (!mid || !sidAndBatch || !batchPart) return false;
163
+
164
+ const sid = sidAndBatch;
165
+ const plusIdx = batchPart.indexOf("+");
166
+ if (plusIdx === -1) {
167
+ // Legacy format "reactive" without batch IDs — fall back to "any summary"
168
+ const tDir = resolveTasksDir(base, mid, sid);
169
+ if (!tDir) return false;
170
+ const summaryFiles = resolveTaskFiles(tDir, "SUMMARY");
171
+ return summaryFiles.length > 0;
172
+ }
173
+
174
+ const batchIds = batchPart.slice(plusIdx + 1).split(",").filter(Boolean);
175
+ if (batchIds.length === 0) return false;
176
+
177
+ const tDir = resolveTasksDir(base, mid, sid);
178
+ if (!tDir) return false;
179
+
180
+ const existingSummaries = new Set(
181
+ resolveTaskFiles(tDir, "SUMMARY").map((f) =>
182
+ f.replace(/-SUMMARY\.md$/i, "").toUpperCase(),
183
+ ),
184
+ );
185
+
186
+ // Every dispatched task must have a summary file
187
+ for (const tid of batchIds) {
188
+ if (!existingSummaries.has(tid.toUpperCase())) return false;
189
+ }
190
+ return true;
191
+ }
192
+
151
193
  const absPath = resolveExpectedArtifactPath(unitType, unitId, base);
152
194
  // For unit types with no verifiable artifact (null path), the parent directory
153
195
  // is missing on disk — treat as stale completion state so the key gets evicted (#313).
@@ -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
  }
@@ -14,6 +14,7 @@
14
14
  import { existsSync } from "node:fs";
15
15
  import { join } from "node:path";
16
16
  import { AuthStorage } from "@gsd/pi-coding-agent";
17
+ import { getEnvApiKey } from "@gsd/pi-ai";
17
18
  import { loadEffectiveGSDPreferences } from "./preferences.js";
18
19
  import { getAuthPath, PROVIDER_REGISTRY, type ProviderCategory } from "./key-manager.js";
19
20
 
@@ -56,6 +57,7 @@ function modelToProviderId(model: string): string | null {
56
57
  google: "google",
57
58
  anthropic: "anthropic",
58
59
  openai: "openai",
60
+ "github-copilot": "github-copilot",
59
61
  };
60
62
  if (prefixMap[prefix]) return prefixMap[prefix];
61
63
  }
@@ -139,7 +141,15 @@ function resolveKey(providerId: string): KeyLookup {
139
141
  }
140
142
  }
141
143
 
142
- // Check environment variable
144
+ // Check environment variable using the authoritative env var resolution
145
+ // (handles multi-var lookups like ANTHROPIC_OAUTH_TOKEN || ANTHROPIC_API_KEY,
146
+ // COPILOT_GITHUB_TOKEN || GH_TOKEN || GITHUB_TOKEN, Vertex ADC, Bedrock, etc.)
147
+ if (getEnvApiKey(providerId)) {
148
+ return { found: true, source: "env", backedOff: false };
149
+ }
150
+
151
+ // Fall back to PROVIDER_REGISTRY env var for providers not covered by getEnvApiKey
152
+ // (e.g., search providers like Brave, Tavily; tool providers like Jina, Context7)
143
153
  if (info?.envVar && process.env[info.envVar]) {
144
154
  return { found: true, source: "env", backedOff: false };
145
155
  }
@@ -149,6 +159,16 @@ function resolveKey(providerId: string): KeyLookup {
149
159
 
150
160
  // ── Individual check groups ────────────────────────────────────────────────────
151
161
 
162
+ /**
163
+ * Providers that can serve models normally associated with another provider.
164
+ * Key = the provider whose models can be served, Value = alternative providers to check.
165
+ * e.g. GitHub Copilot subscriptions can access Claude and GPT models.
166
+ */
167
+ const PROVIDER_ROUTES: Record<string, string[]> = {
168
+ anthropic: ["github-copilot"],
169
+ openai: ["github-copilot"],
170
+ };
171
+
152
172
  function checkLlmProviders(): ProviderCheckResult[] {
153
173
  const required = collectConfiguredModelProviders();
154
174
  const results: ProviderCheckResult[] = [];
@@ -159,6 +179,23 @@ function checkLlmProviders(): ProviderCheckResult[] {
159
179
  const lookup = resolveKey(providerId);
160
180
 
161
181
  if (!lookup.found) {
182
+ // Check if a cross-provider can serve this provider's models
183
+ const routes = PROVIDER_ROUTES[providerId];
184
+ const routeProvider = routes?.find(routeId => resolveKey(routeId).found);
185
+ if (routeProvider) {
186
+ const routeInfo = PROVIDER_REGISTRY.find(p => p.id === routeProvider);
187
+ const routeLabel = routeInfo?.label ?? routeProvider;
188
+ results.push({
189
+ name: providerId,
190
+ label,
191
+ category: "llm",
192
+ status: "ok",
193
+ message: `${label} — available via ${routeLabel}`,
194
+ required: true,
195
+ });
196
+ continue;
197
+ }
198
+
162
199
  const envVar = info?.envVar ?? `${providerId.toUpperCase()}_API_KEY`;
163
200
  results.push({
164
201
  name: providerId,