@opengsd/gsd-pi 1.2.0-dev.d6c5343c → 1.2.0-dev.e8563f58

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 (132) hide show
  1. package/dist/mcp-server.js +2 -1
  2. package/dist/resources/.managed-resources-content-hash +1 -1
  3. package/dist/resources/extensions/gsd/auto/orchestrator.js +28 -10
  4. package/dist/resources/extensions/gsd/auto-model-selection.js +11 -7
  5. package/dist/resources/extensions/gsd/auto.js +7 -0
  6. package/dist/resources/extensions/gsd/blocked-models.js +28 -0
  7. package/dist/resources/extensions/gsd/bootstrap/agent-end-recovery.js +26 -6
  8. package/dist/resources/extensions/gsd/bootstrap/exec-tools.js +2 -2
  9. package/dist/resources/extensions/gsd/closeout-wizard.js +92 -0
  10. package/dist/resources/extensions/gsd/commands-handlers.js +46 -3
  11. package/dist/resources/extensions/gsd/consent-question.js +16 -0
  12. package/dist/resources/extensions/gsd/doctor-git-checks.js +2 -18
  13. package/dist/resources/extensions/gsd/gsd-command-home.js +22 -12
  14. package/dist/resources/extensions/gsd/gsd-db.js +2 -1
  15. package/dist/resources/extensions/gsd/milestone-closeout.js +73 -2
  16. package/dist/resources/extensions/gsd/tools/workflow-tool-executors.js +67 -2
  17. package/dist/resources/extensions/shared/gsd-browser-cli.js +21 -2
  18. package/dist/resources/shared/gsd-browser-path-sync.js +214 -0
  19. package/dist/resources/shared/package-manager-detection.js +1 -1
  20. package/dist/tsconfig.extensions.tsbuildinfo +1 -1
  21. package/dist/update-check.d.ts +2 -0
  22. package/dist/update-check.js +24 -1
  23. package/dist/update-cmd.js +20 -3
  24. package/dist/web/standalone/.next/BUILD_ID +1 -1
  25. package/dist/web/standalone/.next/app-path-routes-manifest.json +8 -8
  26. package/dist/web/standalone/.next/build-manifest.json +2 -2
  27. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  28. package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
  29. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  30. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  31. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  32. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  33. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  34. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  35. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  36. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  37. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  38. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  39. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  40. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  41. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  42. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  43. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  44. package/dist/web/standalone/.next/server/app/index.html +1 -1
  45. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  46. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  47. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  48. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  49. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  50. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  51. package/dist/web/standalone/.next/server/app-paths-manifest.json +8 -8
  52. package/dist/web/standalone/.next/server/chunks/8357.js +2 -2
  53. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  54. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  55. package/dist/web/standalone/.next/server/pages/500.html +1 -1
  56. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  57. package/dist/web/standalone/node_modules/node-pty/build/Makefile +1 -1
  58. package/package.json +1 -1
  59. package/packages/cloud-mcp-gateway/package.json +2 -2
  60. package/packages/contracts/package.json +1 -1
  61. package/packages/daemon/package.json +4 -4
  62. package/packages/gsd-agent-core/package.json +5 -5
  63. package/packages/gsd-agent-modes/package.json +7 -7
  64. package/packages/mcp-server/dist/moonshot-tool-schema.d.ts +29 -0
  65. package/packages/mcp-server/dist/moonshot-tool-schema.d.ts.map +1 -0
  66. package/packages/mcp-server/dist/moonshot-tool-schema.js +50 -0
  67. package/packages/mcp-server/dist/moonshot-tool-schema.js.map +1 -0
  68. package/packages/mcp-server/dist/server.d.ts.map +1 -1
  69. package/packages/mcp-server/dist/server.js +4 -0
  70. package/packages/mcp-server/dist/server.js.map +1 -1
  71. package/packages/mcp-server/dist/workflow-tools.d.ts +18 -18
  72. package/packages/mcp-server/dist/workflow-tools.d.ts.map +1 -1
  73. package/packages/mcp-server/dist/workflow-tools.js +99 -38
  74. package/packages/mcp-server/dist/workflow-tools.js.map +1 -1
  75. package/packages/mcp-server/package.json +5 -4
  76. package/packages/native/package.json +1 -1
  77. package/packages/pi-agent-core/package.json +1 -1
  78. package/packages/pi-ai/dist/index.d.ts +2 -0
  79. package/packages/pi-ai/dist/index.d.ts.map +1 -1
  80. package/packages/pi-ai/dist/index.js +2 -0
  81. package/packages/pi-ai/dist/index.js.map +1 -1
  82. package/packages/pi-ai/dist/providers/anthropic.d.ts.map +1 -1
  83. package/packages/pi-ai/dist/providers/anthropic.js +12 -7
  84. package/packages/pi-ai/dist/providers/anthropic.js.map +1 -1
  85. package/packages/pi-ai/dist/providers/google-shared.d.ts +5 -0
  86. package/packages/pi-ai/dist/providers/google-shared.d.ts.map +1 -1
  87. package/packages/pi-ai/dist/providers/google-shared.js +12 -3
  88. package/packages/pi-ai/dist/providers/google-shared.js.map +1 -1
  89. package/packages/pi-ai/dist/providers/openai-completions.d.ts.map +1 -1
  90. package/packages/pi-ai/dist/providers/openai-completions.js +7 -3
  91. package/packages/pi-ai/dist/providers/openai-completions.js.map +1 -1
  92. package/packages/pi-ai/dist/utils/moonshot-tool-schema.d.ts +9 -0
  93. package/packages/pi-ai/dist/utils/moonshot-tool-schema.d.ts.map +1 -0
  94. package/packages/pi-ai/dist/utils/moonshot-tool-schema.js +34 -0
  95. package/packages/pi-ai/dist/utils/moonshot-tool-schema.js.map +1 -0
  96. package/packages/pi-ai/dist/utils/oauth/github-copilot.d.ts.map +1 -1
  97. package/packages/pi-ai/dist/utils/oauth/github-copilot.js +6 -2
  98. package/packages/pi-ai/dist/utils/oauth/github-copilot.js.map +1 -1
  99. package/packages/pi-ai/package.json +1 -1
  100. package/packages/pi-coding-agent/package.json +7 -7
  101. package/packages/pi-tui/package.json +2 -2
  102. package/packages/rpc-client/package.json +2 -2
  103. package/pkg/package.json +1 -1
  104. package/src/resources/extensions/browser-tools/tests/gsd-browser-launch-config.test.mjs +11 -0
  105. package/src/resources/extensions/gsd/auto/orchestrator.ts +28 -10
  106. package/src/resources/extensions/gsd/auto-model-selection.ts +16 -7
  107. package/src/resources/extensions/gsd/auto.ts +8 -0
  108. package/src/resources/extensions/gsd/blocked-models.ts +49 -0
  109. package/src/resources/extensions/gsd/bootstrap/agent-end-recovery.ts +34 -5
  110. package/src/resources/extensions/gsd/bootstrap/exec-tools.ts +2 -2
  111. package/src/resources/extensions/gsd/closeout-wizard.ts +102 -0
  112. package/src/resources/extensions/gsd/commands-handlers.ts +46 -3
  113. package/src/resources/extensions/gsd/consent-question.ts +15 -0
  114. package/src/resources/extensions/gsd/doctor-git-checks.ts +2 -19
  115. package/src/resources/extensions/gsd/gsd-command-home.ts +13 -3
  116. package/src/resources/extensions/gsd/gsd-db.ts +4 -3
  117. package/src/resources/extensions/gsd/milestone-closeout.ts +97 -2
  118. package/src/resources/extensions/gsd/tests/auto-model-selection.test.ts +69 -0
  119. package/src/resources/extensions/gsd/tests/auto-orchestrator.test.ts +97 -0
  120. package/src/resources/extensions/gsd/tests/blocked-models.test.ts +19 -0
  121. package/src/resources/extensions/gsd/tests/consent-question.test.ts +15 -0
  122. package/src/resources/extensions/gsd/tests/doctor-git-checks-terminal.test.ts +73 -0
  123. package/src/resources/extensions/gsd/tests/gsd-command-home.test.ts +120 -0
  124. package/src/resources/extensions/gsd/tests/milestone-closeout.test.ts +95 -4
  125. package/src/resources/extensions/gsd/tests/parsers-legacy-importers.test.ts +0 -1
  126. package/src/resources/extensions/gsd/tests/workflow-tool-executors.test.ts +273 -38
  127. package/src/resources/extensions/gsd/tools/workflow-tool-executors.ts +81 -2
  128. package/src/resources/extensions/shared/gsd-browser-cli.ts +23 -2
  129. package/src/resources/shared/gsd-browser-path-sync.ts +273 -0
  130. package/src/resources/shared/package-manager-detection.ts +1 -1
  131. /package/dist/web/standalone/.next/static/{jmTLg6xZmAuq_LIqKOxrH → LDHRKiRBIVZmiuMjrL1Vy}/_buildManifest.js +0 -0
  132. /package/dist/web/standalone/.next/static/{jmTLg6xZmAuq_LIqKOxrH → LDHRKiRBIVZmiuMjrL1Vy}/_ssgManifest.js +0 -0
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gsd/pi-ai",
3
- "version": "1.2.0-dev.d6c5343c",
3
+ "version": "1.2.0-dev.e8563f58",
4
4
  "description": "Unified LLM API with automatic model discovery and provider configuration",
5
5
  "type": "module",
6
6
  "gsd": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gsd/pi-coding-agent",
3
- "version": "1.2.0-dev.d6c5343c",
3
+ "version": "1.2.0-dev.e8563f58",
4
4
  "description": "Coding agent CLI (vendored from earendil-works/pi)",
5
5
  "type": "module",
6
6
  "gsd": {
@@ -33,7 +33,7 @@
33
33
  "copy-assets": "node scripts/copy-assets.cjs"
34
34
  },
35
35
  "dependencies": {
36
- "@opengsd/contracts": "^1.2.0-dev.d6c5343c",
36
+ "@opengsd/contracts": "^1.2.0-dev.e8563f58",
37
37
  "@mariozechner/jiti": "^2.6.2",
38
38
  "@silvia-odwyer/photon-node": "0.3.4",
39
39
  "chalk": "5.6.2",
@@ -53,11 +53,11 @@
53
53
  "typebox": "1.1.38",
54
54
  "undici": "7.26.0",
55
55
  "yaml": "2.9.0",
56
- "@gsd/agent-core": "^1.2.0-dev.d6c5343c",
57
- "@gsd/native": "^1.2.0-dev.d6c5343c",
58
- "@gsd/pi-agent-core": "^1.2.0-dev.d6c5343c",
59
- "@gsd/pi-ai": "^1.2.0-dev.d6c5343c",
60
- "@gsd/pi-tui": "^1.2.0-dev.d6c5343c",
56
+ "@gsd/agent-core": "^1.2.0-dev.e8563f58",
57
+ "@gsd/native": "^1.2.0-dev.e8563f58",
58
+ "@gsd/pi-agent-core": "^1.2.0-dev.e8563f58",
59
+ "@gsd/pi-ai": "^1.2.0-dev.e8563f58",
60
+ "@gsd/pi-tui": "^1.2.0-dev.e8563f58",
61
61
  "@sinclair/typebox": "^0.34.41"
62
62
  },
63
63
  "devDependencies": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gsd/pi-tui",
3
- "version": "1.2.0-dev.d6c5343c",
3
+ "version": "1.2.0-dev.e8563f58",
4
4
  "description": "Terminal UI library (vendored from earendil-works/pi)",
5
5
  "type": "module",
6
6
  "gsd": {
@@ -21,7 +21,7 @@
21
21
  "build": "node ../../scripts/clean-package-dist.cjs && tsc -p tsconfig.json --incremental false"
22
22
  },
23
23
  "dependencies": {
24
- "@gsd/native": "^1.2.0-dev.d6c5343c",
24
+ "@gsd/native": "^1.2.0-dev.e8563f58",
25
25
  "get-east-asian-width": "1.6.0",
26
26
  "marked": "15.0.12",
27
27
  "@sinclair/typebox": "^0.34.41"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opengsd/rpc-client",
3
- "version": "1.2.0-dev.d6c5343c",
3
+ "version": "1.2.0-dev.e8563f58",
4
4
  "description": "Standalone RPC client SDK for GSD — zero internal dependencies",
5
5
  "license": "MIT",
6
6
  "gsd": {
@@ -34,7 +34,7 @@
34
34
  "test": "node --test dist/rpc-client.test.js"
35
35
  },
36
36
  "dependencies": {
37
- "@opengsd/contracts": "^1.2.0-dev.d6c5343c"
37
+ "@opengsd/contracts": "^1.2.0-dev.e8563f58"
38
38
  },
39
39
  "engines": {
40
40
  "node": ">=22.0.0"
package/pkg/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glittercowboy/gsd",
3
- "version": "1.2.0-dev.d6c5343c",
3
+ "version": "1.2.0-dev.e8563f58",
4
4
  "piConfig": {
5
5
  "name": "gsd",
6
6
  "configDir": ".gsd"
@@ -38,6 +38,17 @@ describe("resolveGsdBrowserMcpLaunchConfig identity flags", () => {
38
38
  assert.equal(args[args.indexOf("--identity-key") + 1], "custom-key");
39
39
  });
40
40
 
41
+ it("splits GSD_BROWSER_MCP_COMMAND command lines before spawning", () => {
42
+ const commandLine = '"C:\\Program Files\\nodejs\\node.exe" "C:\\Users\\Test User\\AppData\\Roaming\\npm\\node_modules\\@opengsd\\gsd-browser\\bin\\gsd-browser"';
43
+ const { command, args } = resolveGsdBrowserMcpLaunchConfig("C:\\Users\\Test User\\project", {
44
+ GSD_BROWSER_MCP_COMMAND: commandLine,
45
+ });
46
+
47
+ assert.equal(command, "C:\\Program Files\\nodejs\\node.exe");
48
+ assert.equal(args[0], "C:\\Users\\Test User\\AppData\\Roaming\\npm\\node_modules\\@opengsd\\gsd-browser\\bin\\gsd-browser");
49
+ assert.equal(args[1], "mcp");
50
+ });
51
+
41
52
  it("uses a path-safe identity-project identifier", () => {
42
53
  const { args } = resolveGsdBrowserMcpLaunchConfig("/tmp/example/project", {});
43
54
  const projectId = args[args.indexOf("--identity-project") + 1];
@@ -22,7 +22,7 @@ type BlockedAdvanceResult = Extract<AutoAdvanceResult, { kind: "blocked" }>;
22
22
  import { debugCount, debugLog, debugTime } from "../debug-logger.js";
23
23
  import { reconcileBeforeDispatch } from "../state-reconciliation.js";
24
24
  import { isLegalEdge, IllegalPhaseTransitionError } from "../state-transition-matrix.js";
25
- import { resolveDispatch } from "../auto-dispatch.js";
25
+ import { hasPendingDeepStage, resolveDispatch } from "../auto-dispatch.js";
26
26
  import { classifyFailure } from "../recovery-classification.js";
27
27
  import { verifyExpectedArtifact, refreshRecoveryDbForArtifact } from "../auto-recovery.js";
28
28
  import { invalidateAllCaches } from "../cache.js";
@@ -193,14 +193,25 @@ export async function decideOrchestratorDispatch(
193
193
  ): Promise<DispatchDecision> {
194
194
  const state = input.stateSnapshot;
195
195
  const active = state.activeMilestone;
196
- if (!active) return null;
197
-
198
196
  const activeSession = input.session ?? session;
199
197
  const activeDispatchBasePath = activeSession?.basePath || dispatchBasePath;
200
- if (activeSession && shouldAdoptActiveMilestone(state, activeSession, activeDispatchBasePath)) {
198
+ const prefs = loadEffectiveGSDPreferences(activeDispatchBasePath)?.preferences;
199
+ if (!active) {
200
+ if (state.phase !== "pre-planning") return null;
201
+ if (!hasPendingDeepStage(prefs, activeDispatchBasePath)) {
202
+ return {
203
+ kind: "blocked",
204
+ reason: state.nextAction || "No active milestone. Run /gsd unpark <id> or create a new milestone.",
205
+ action: "stop",
206
+ };
207
+ }
208
+ }
209
+
210
+ if (active && activeSession && shouldAdoptActiveMilestone(state, activeSession, activeDispatchBasePath)) {
201
211
  activeSession.currentMilestoneId = active.id;
202
212
  }
203
- const prefs = loadEffectiveGSDPreferences(activeDispatchBasePath)?.preferences;
213
+ const dispatchMid = active?.id ?? activeSession?.currentMilestoneId ?? "";
214
+ const dispatchMidTitle = active?.title ?? "";
204
215
 
205
216
  // Derive session-derived dispatch inputs the same way phases.ts:runDispatch does
206
217
  // (#5789). Prefer caller-supplied values when present so test harnesses and
@@ -232,8 +243,15 @@ export async function decideOrchestratorDispatch(
232
243
  ? "true"
233
244
  : "false");
234
245
 
246
+ // Only replay a milestone-scoped verification retry when a milestone is
247
+ // active. Pre-PR (#712 fix), `!active` returned null before reaching this
248
+ // block, so the retry was preserved for a future tick. The new
249
+ // pre-planning + deep-pending fall-through must keep that contract:
250
+ // otherwise a stale execute-task / complete-slice / complete-milestone
251
+ // retry whose target milestone has since been parked would preempt
252
+ // project-level deep rules like `discuss-project`.
235
253
  const pendingRetry = session?.pendingVerificationRetryDispatch;
236
- if (session && pendingRetry) {
254
+ if (session && pendingRetry && active) {
237
255
  session.pendingVerificationRetryDispatch = null;
238
256
  const alreadyClosedReason = getAlreadyClosedDispatchReason(
239
257
  pendingRetry.unitType,
@@ -255,8 +273,8 @@ export async function decideOrchestratorDispatch(
255
273
 
256
274
  const action = await resolveDispatch({
257
275
  basePath: activeDispatchBasePath,
258
- mid: active.id,
259
- midTitle: active.title,
276
+ mid: dispatchMid,
277
+ midTitle: dispatchMidTitle,
260
278
  state,
261
279
  prefs,
262
280
  session: activeSession,
@@ -300,8 +318,8 @@ export async function decideOrchestratorDispatch(
300
318
  prompt: action.prompt,
301
319
  pauseAfterUatDispatch: action.pauseAfterDispatch ?? false,
302
320
  state,
303
- mid: active.id,
304
- midTitle: active.title,
321
+ mid: dispatchMid,
322
+ midTitle: dispatchMidTitle,
305
323
  };
306
324
  session.pendingOrchestrationDispatch = pending;
307
325
  }
@@ -18,7 +18,7 @@ import { getSessionModelOverride } from "./session-model-override.js";
18
18
  import { logWarning } from "./workflow-logger.js";
19
19
  import { resolveUokFlags } from "./uok/flags.js";
20
20
  import { applyModelPolicyFilter } from "./uok/model-policy.js";
21
- import { isModelBlocked } from "./blocked-models.js";
21
+ import { isModelBlocked, isModelTemporarilyUnavailable } from "./blocked-models.js";
22
22
  import { getRequiredWorkflowToolsForAutoUnit, isWorkflowMcpSurfaceTool } from "./workflow-mcp.js";
23
23
 
24
24
  /**
@@ -272,6 +272,15 @@ function buildModelPolicyBlockReasons(
272
272
  }];
273
273
  }
274
274
 
275
+ function isModelUnavailable(
276
+ basePath: string,
277
+ provider: string | undefined,
278
+ id: string | undefined,
279
+ ): boolean {
280
+ return isModelBlocked(basePath, provider, id) ||
281
+ isModelTemporarilyUnavailable(basePath, provider, id);
282
+ }
283
+
275
284
  function restoreToolBaseline(pi: ExtensionAPI): void {
276
285
  const key = pi as unknown as object;
277
286
  const baseline = TOOL_BASELINE.get(key);
@@ -817,9 +826,9 @@ export async function selectAndApplyModel(
817
826
  // (issue #4513). The block is persisted in .gsd/runtime/blocked-models.json
818
827
  // so it survives /gsd auto restarts — without this, the same dead model
819
828
  // gets reselected after every restart.
820
- if (isModelBlocked(basePath, model.provider, model.id)) {
829
+ if (isModelUnavailable(basePath, model.provider, model.id)) {
821
830
  ctx.ui.notify(
822
- `Skipping blocked model ${model.provider}/${model.id} (provider rejected it for this account).`,
831
+ `Skipping unavailable model ${model.provider}/${model.id}.`,
823
832
  "warning",
824
833
  );
825
834
  continue;
@@ -896,7 +905,7 @@ export async function selectAndApplyModel(
896
905
  for (const model of buildPolicyEligibleFallbackOrder(ctx, routingEligibleModels, autoModeStartModel)) {
897
906
  const key = `${model.provider.toLowerCase()}/${model.id.toLowerCase()}`;
898
907
  if (!policyAllowedModelKeys.has(key)) continue;
899
- if (isModelBlocked(basePath, model.provider, model.id)) continue;
908
+ if (isModelUnavailable(basePath, model.provider, model.id)) continue;
900
909
  const ok = await pi.setModel(model, { persist: false });
901
910
  if (!ok) continue;
902
911
  appliedModel = model;
@@ -926,10 +935,10 @@ export async function selectAndApplyModel(
926
935
  autoModeStartModel,
927
936
  effectiveSessionModelOverride,
928
937
  );
929
- const startBlocked = isModelBlocked(basePath, autoModeStartModel.provider, autoModeStartModel.id);
938
+ const startBlocked = isModelUnavailable(basePath, autoModeStartModel.provider, autoModeStartModel.id);
930
939
  if (startBlocked) {
931
940
  ctx.ui.notify(
932
- `Auto-mode start model ${autoModeStartModel.provider}/${autoModeStartModel.id} is blocked for this account. Using current session model instead.`,
941
+ `Auto-mode start model ${autoModeStartModel.provider}/${autoModeStartModel.id} is unavailable. Using current session model instead.`,
933
942
  "warning",
934
943
  );
935
944
  } else {
@@ -940,7 +949,7 @@ export async function selectAndApplyModel(
940
949
  const ok = await pi.setModel(startModel, { persist: false });
941
950
  if (!ok) {
942
951
  const byId = availableModels.find(
943
- m => m.id === autoModeStartModel.id && !isModelBlocked(basePath, m.provider, m.id),
952
+ m => m.id === autoModeStartModel.id && !isModelUnavailable(basePath, m.provider, m.id),
944
953
  );
945
954
  if (byId) {
946
955
  const fallbackOk = await pi.setModel(byId, { persist: false });
@@ -924,6 +924,14 @@ export function setCurrentDispatchedModelId(model: { provider: string; id: strin
924
924
  s.currentDispatchedModelId = model ? `${model.provider}/${model.id}` : null;
925
925
  }
926
926
 
927
+ /**
928
+ * Update the active unit model after runtime recovery switches models mid-unit.
929
+ * The next session restore path reads this field before dispatching again.
930
+ */
931
+ export function setCurrentUnitModelForRecovery(model: any | null): void {
932
+ s.currentUnitModel = model;
933
+ }
934
+
927
935
  // Tool tracking — delegates to auto-tool-tracking.ts
928
936
  export function markToolStart(toolCallId: string, toolName?: string): void {
929
937
  _markToolStart(toolCallId, s.active, toolName);
@@ -23,6 +23,15 @@ interface BlockedModelsFile {
23
23
  blocked: BlockedModelEntry[];
24
24
  }
25
25
 
26
+ interface TemporaryBlockedModelEntry {
27
+ provider: string;
28
+ id: string;
29
+ reason: string;
30
+ blockedUntil: number;
31
+ }
32
+
33
+ const temporaryBlockedModels = new Map<string, TemporaryBlockedModelEntry>();
34
+
26
35
  function blockedModelsPath(basePath: string): string {
27
36
  return join(gsdRoot(basePath), "runtime", "blocked-models.json");
28
37
  }
@@ -31,6 +40,10 @@ function modelKey(provider: string, id: string): string {
31
40
  return `${provider.toLowerCase()}/${id.toLowerCase()}`;
32
41
  }
33
42
 
43
+ function temporaryModelKey(basePath: string, provider: string, id: string): string {
44
+ return `${basePath}:${modelKey(provider, id)}`;
45
+ }
46
+
34
47
  function readFileSafe(path: string): BlockedModelsFile {
35
48
  if (!existsSync(path)) return { version: 1, blocked: [] };
36
49
  try {
@@ -66,6 +79,42 @@ export function isModelBlocked(
66
79
  );
67
80
  }
68
81
 
82
+ export function blockModelUntil(
83
+ basePath: string,
84
+ provider: string,
85
+ id: string,
86
+ blockedUntil: number,
87
+ reason: string,
88
+ ): void {
89
+ const key = temporaryModelKey(basePath, provider, id);
90
+ if (blockedUntil <= Date.now()) {
91
+ temporaryBlockedModels.delete(key);
92
+ return;
93
+ }
94
+ temporaryBlockedModels.set(key, { provider, id, reason, blockedUntil });
95
+ }
96
+
97
+ export function isModelTemporarilyUnavailable(
98
+ basePath: string,
99
+ provider: string | undefined,
100
+ id: string | undefined,
101
+ now = Date.now(),
102
+ ): boolean {
103
+ if (!provider || !id) return false;
104
+ const key = temporaryModelKey(basePath, provider, id);
105
+ const entry = temporaryBlockedModels.get(key);
106
+ if (!entry) return false;
107
+ if (entry.blockedUntil <= now) {
108
+ temporaryBlockedModels.delete(key);
109
+ return false;
110
+ }
111
+ return true;
112
+ }
113
+
114
+ export function clearTemporaryModelBlocksForTest(): void {
115
+ temporaryBlockedModels.clear();
116
+ }
117
+
69
118
  export function blockModel(
70
119
  basePath: string,
71
120
  provider: string,
@@ -19,6 +19,7 @@ import {
19
19
  isAutoCompletionStopInProgress,
20
20
  pauseAuto,
21
21
  setCurrentDispatchedModelId,
22
+ setCurrentUnitModelForRecovery,
22
23
  } from "../auto.js";
23
24
  import { getNextFallbackModel, resolveModelWithFallbacksForUnit } from "../preferences.js";
24
25
  import { pauseAutoForProviderError } from "../provider-error-pause.js";
@@ -41,7 +42,7 @@ import {
41
42
  isTransient,
42
43
  type ErrorClass,
43
44
  } from "../error-classifier.js";
44
- import { blockModel, isModelBlocked } from "../blocked-models.js";
45
+ import { blockModel, blockModelUntil, isModelBlocked, isModelTemporarilyUnavailable } from "../blocked-models.js";
45
46
  import { getProjectGSDPreferencesPath } from "../preferences.js";
46
47
  import { resolveProviderErrorGuidance } from "../provider-error-guidance.js";
47
48
  import { formatGuidance } from "../guidance.js";
@@ -143,9 +144,14 @@ async function tryProviderModelFallback(params: ProviderModelFallbackParams): Pr
143
144
  const nextModelId = getNextFallbackModel(cursorModelId, modelConfig);
144
145
  if (!nextModelId) break;
145
146
  const candidate = resolveModelId(nextModelId, availableModels, rejectedProvider);
146
- if (candidate && !isModelBlocked(basePath, candidate.provider, candidate.id)) {
147
+ if (
148
+ candidate &&
149
+ !isModelBlocked(basePath, candidate.provider, candidate.id) &&
150
+ !isModelTemporarilyUnavailable(basePath, candidate.provider, candidate.id)
151
+ ) {
147
152
  const ok = await pi.setModel(candidate, { persist: false });
148
153
  if (ok) {
154
+ setCurrentUnitModelForRecovery(candidate);
149
155
  setCurrentDispatchedModelId({ provider: candidate.provider, id: candidate.id });
150
156
  switchedNotify(`${candidate.provider}/${candidate.id}`);
151
157
  pi.sendMessage(
@@ -163,7 +169,8 @@ async function tryProviderModelFallback(params: ProviderModelFallbackParams): Pr
163
169
  if (
164
170
  sessionModel &&
165
171
  !(sessionModel.provider === rejectedProvider && sessionModel.id === rejectedId) &&
166
- !isModelBlocked(basePath, sessionModel.provider, sessionModel.id)
172
+ !isModelBlocked(basePath, sessionModel.provider, sessionModel.id) &&
173
+ !isModelTemporarilyUnavailable(basePath, sessionModel.provider, sessionModel.id)
167
174
  ) {
168
175
  const startModel = availableModels.find(
169
176
  (m) => m.provider === sessionModel.provider && m.id === sessionModel.id,
@@ -171,6 +178,7 @@ async function tryProviderModelFallback(params: ProviderModelFallbackParams): Pr
171
178
  if (startModel) {
172
179
  const ok = await pi.setModel(startModel, { persist: false });
173
180
  if (ok) {
181
+ setCurrentUnitModelForRecovery(startModel);
174
182
  setCurrentDispatchedModelId({ provider: startModel.provider, id: startModel.id });
175
183
  switchedNotify(`${startModel.provider}/${startModel.id}`);
176
184
  pi.sendMessage(
@@ -676,6 +684,16 @@ export async function handleAgentEnd(
676
684
  if (currentProvider === "openai-codex" || currentProvider === "google-gemini-cli") {
677
685
  cls.retryAfterMs = Math.min(cls.retryAfterMs, 30_000);
678
686
  }
687
+ const dash = getAutoDashboardData();
688
+ if (dash.basePath && ctx.model?.provider && ctx.model?.id) {
689
+ blockModelUntil(
690
+ dash.basePath,
691
+ ctx.model.provider,
692
+ ctx.model.id,
693
+ Date.now() + cls.retryAfterMs,
694
+ rawErrorMsg || displayMsg || "rate limit",
695
+ );
696
+ }
679
697
  }
680
698
 
681
699
  // ── 2. Decide & Act ──────────────────────────────────────────────────
@@ -721,9 +739,14 @@ export async function handleAgentEnd(
721
739
  retryState.networkRetryCount = 0;
722
740
  retryState.currentRetryModelId = undefined;
723
741
  const modelToSet = resolveModelId(nextModelId, availableModels, ctx.model?.provider);
724
- if (modelToSet) {
742
+ const modelUnavailable = dash.basePath && modelToSet
743
+ ? isModelBlocked(dash.basePath, modelToSet.provider, modelToSet.id) ||
744
+ isModelTemporarilyUnavailable(dash.basePath, modelToSet.provider, modelToSet.id)
745
+ : false;
746
+ if (modelToSet && !modelUnavailable) {
725
747
  const ok = await pi.setModel(modelToSet, { persist: false });
726
748
  if (ok) {
749
+ setCurrentUnitModelForRecovery(modelToSet);
727
750
  setCurrentDispatchedModelId({ provider: modelToSet.provider, id: modelToSet.id });
728
751
  ctx.ui.notify(`Model error${errorDetail}. Switched to fallback: ${nextModelId} and resuming.`, "warning");
729
752
  pi.sendMessage({ customType: "gsd-auto-timeout-recovery", content: "Continue execution.", display: false }, { triggerTurn: true });
@@ -737,11 +760,17 @@ export async function handleAgentEnd(
737
760
  // Try restoring session model
738
761
  const sessionModel = getAutoModeStartModel();
739
762
  if (sessionModel) {
740
- if (ctx.model?.id !== sessionModel.id || ctx.model?.provider !== sessionModel.provider) {
763
+ const dash = getAutoDashboardData();
764
+ const sessionModelUnavailable = dash.basePath
765
+ ? isModelBlocked(dash.basePath, sessionModel.provider, sessionModel.id) ||
766
+ isModelTemporarilyUnavailable(dash.basePath, sessionModel.provider, sessionModel.id)
767
+ : false;
768
+ if (!sessionModelUnavailable && (ctx.model?.id !== sessionModel.id || ctx.model?.provider !== sessionModel.provider)) {
741
769
  const startModel = ctx.modelRegistry.getAvailable().find((m) => m.provider === sessionModel.provider && m.id === sessionModel.id);
742
770
  if (startModel) {
743
771
  const ok = await pi.setModel(startModel, { persist: false });
744
772
  if (ok) {
773
+ setCurrentUnitModelForRecovery(startModel);
745
774
  setCurrentDispatchedModelId({ provider: startModel.provider, id: startModel.id });
746
775
  retryState.networkRetryCount = 0;
747
776
  retryState.currentRetryModelId = undefined;
@@ -137,8 +137,8 @@ export function registerExecTools(pi: ExtensionAPI): void {
137
137
  parameters: Type.Object({
138
138
  query: Type.Optional(Type.String({ description: "Substring matched against id and purpose (case-insensitive)." })),
139
139
  runtime: Type.Optional(
140
- Type.Union([Type.Literal("bash"), Type.Literal("node"), Type.Literal("python")], {
141
- description: "Restrict to one runtime.",
140
+ Type.String({
141
+ description: "Restrict to one runtime: bash, node, or python.",
142
142
  }),
143
143
  ),
144
144
  failing_only: Type.Optional(Type.Boolean({ description: "Only non-zero exit codes and timeouts." })),
@@ -1,25 +1,40 @@
1
1
  // Project/App: gsd-pi
2
2
  // File Purpose: Shared closeout detection and merge actions for /gsd home and smart entry.
3
3
 
4
+ import { existsSync, readdirSync, statSync } from "node:fs";
5
+ import { join } from "node:path";
6
+
4
7
  import type { ExtensionCommandContext } from "@gsd/pi-coding-agent";
5
8
 
6
9
  import type { NextAction } from "../shared/next-action-ui.js";
7
10
  import type { GSDState } from "./types.js";
8
11
  import { setAutoOutcomeWidget } from "./auto-dashboard.js";
9
12
  import { invalidateAllCaches } from "./cache.js";
13
+ import { isDbAvailable } from "./db/engine.js";
14
+ import { getMilestone } from "./db/queries.js";
15
+ import { MILESTONE_ID_RE } from "./milestone-ids.js";
10
16
  import { mergeCompletedMilestone } from "./parallel-merge.js";
11
17
  import { cleanupQuickBranch, detectStrandedQuickBranch, type StrandedQuickBranch } from "./quick.js";
18
+ import { isClosedStatus } from "./status-guards.js";
12
19
  import {
13
20
  findUnmergedCompletedMilestones,
14
21
  type UnmergedMilestoneBlocker,
15
22
  } from "./unmerged-milestone-guard.js";
16
23
  import { appendRequirementsBacklogToSummary } from "./requirements-backlog.js";
24
+ import { nativeBranchList, nativeIsRepo } from "./native-git-bridge.js";
25
+ import { allWorktreesDirs } from "./worktree-manager.js";
17
26
 
18
27
  export type CloseoutActionId = "finish_quick" | "finish_milestone";
19
28
 
29
+ export interface IdleMilestoneResidueHint {
30
+ message: string;
31
+ milestoneIds: string[];
32
+ }
33
+
20
34
  export interface CloseoutContext {
21
35
  strandedQuick: StrandedQuickBranch | null;
22
36
  unmergedMilestones: UnmergedMilestoneBlocker[];
37
+ idleResidueHint?: IdleMilestoneResidueHint | null;
23
38
  }
24
39
 
25
40
  const MILESTONE_MERGE_CLOSEOUT_COMMANDS = [
@@ -29,11 +44,90 @@ const MILESTONE_MERGE_CLOSEOUT_COMMANDS = [
29
44
  "/gsd start for new work",
30
45
  ];
31
46
 
47
+ function listMilestoneWorktreeIds(basePath: string): string[] {
48
+ const ids = new Set<string>();
49
+ for (const wtDir of allWorktreesDirs(basePath)) {
50
+ if (!existsSync(wtDir)) continue;
51
+ for (const entry of readdirSync(wtDir)) {
52
+ if (!MILESTONE_ID_RE.test(entry)) continue;
53
+ try {
54
+ if (statSync(join(wtDir, entry)).isDirectory()) ids.add(entry);
55
+ } catch {
56
+ // skip unreadable entries
57
+ }
58
+ }
59
+ }
60
+ return [...ids].sort();
61
+ }
62
+
63
+ function listMilestoneBranchIds(basePath: string): string[] {
64
+ try {
65
+ return nativeBranchList(basePath, "milestone/*")
66
+ .map((branch) => branch.replace(/^milestone\//, ""))
67
+ .filter((id) => MILESTONE_ID_RE.test(id))
68
+ .sort();
69
+ } catch {
70
+ return [];
71
+ }
72
+ }
73
+
74
+ /**
75
+ * A milestone ID is "stranded residue" only when its worktree/branch artifacts
76
+ * exist for a milestone the DB does not consider currently in flight — i.e. the
77
+ * row is closed (complete/done/skipped/closed) or absent. Active, pending,
78
+ * blocked, parked, queued, and deferred rows describe normal in-flight or
79
+ * intentionally-preserved state, never residue. Returning `false` skips the ID;
80
+ * returning `true` keeps it in the hint.
81
+ */
82
+ function isStrandedMilestoneId(milestoneId: string): boolean {
83
+ if (!isDbAvailable()) return true;
84
+ const row = getMilestone(milestoneId);
85
+ if (!row) return true;
86
+ return isClosedStatus(row.status);
87
+ }
88
+
89
+ /** Surface stranded milestone git residue when closeout guards did not classify it. */
90
+ export function detectIdleMilestoneResidueHint(basePath: string): IdleMilestoneResidueHint | null {
91
+ if (!nativeIsRepo(basePath)) return null;
92
+
93
+ const gsdDir = join(basePath, ".gsd");
94
+ const dbPath = join(gsdDir, "gsd.db");
95
+ if (!existsSync(gsdDir) || !existsSync(dbPath)) {
96
+ return {
97
+ milestoneIds: [],
98
+ message:
99
+ "This git repo has no local GSD workflow database (.gsd/gsd.db). " +
100
+ "Workflow state may live in an external worktree, or run /gsd new-project to initialize here.",
101
+ };
102
+ }
103
+
104
+ const worktreeIds = listMilestoneWorktreeIds(basePath);
105
+ const branchIds = listMilestoneBranchIds(basePath);
106
+ const candidateIds = [...new Set([...worktreeIds, ...branchIds])].sort();
107
+ const milestoneIds = candidateIds.filter(isStrandedMilestoneId);
108
+ if (milestoneIds.length === 0) return null;
109
+
110
+ const listed = milestoneIds.join(", ");
111
+ const recovery =
112
+ milestoneIds.length === 1
113
+ ? `/gsd dispatch complete-milestone ${milestoneIds[0]}`
114
+ : "/gsd doctor --fix";
115
+ return {
116
+ milestoneIds,
117
+ message:
118
+ `Stranded milestone git residue detected (${listed}: worktree dir and/or milestone/* branch). ` +
119
+ `Run ${recovery} or /gsd status to recover closeout before starting new work.`,
120
+ };
121
+ }
122
+
32
123
  export async function loadCloseoutContext(basePath: string): Promise<CloseoutContext> {
33
124
  const unmergedMilestones = await findUnmergedCompletedMilestones(basePath);
125
+ const idleResidueHint =
126
+ unmergedMilestones.length === 0 ? detectIdleMilestoneResidueHint(basePath) : null;
34
127
  return {
35
128
  strandedQuick: detectStrandedQuickBranch(basePath),
36
129
  unmergedMilestones,
130
+ idleResidueHint,
37
131
  };
38
132
  }
39
133
 
@@ -87,6 +181,14 @@ export function buildIdleMenuSummary(state: GSDState, closeout: CloseoutContext)
87
181
  ];
88
182
  }
89
183
 
184
+ // Surface idle residue before the completion summary so smart entry shows
185
+ // the same recovery text /gsd home would: a closed/unknown milestone with
186
+ // lingering worktree/branch artifacts must not be hidden behind the
187
+ // "all milestones complete" message.
188
+ if (closeout.idleResidueHint) {
189
+ return [closeout.idleResidueHint.message];
190
+ }
191
+
90
192
  if (state.phase === "complete") {
91
193
  const last = state.lastCompletedMilestone;
92
194
  return appendRequirementsBacklogToSummary(state, [