specrails-desktop 2.29.4 → 2.29.5

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.
@@ -161,7 +161,7 @@ Most settings live in the UI. A few app-level switches are env-only because they
161
161
  | `SPECRAILS_GEMINI_BETA=0` | Emergency rollback — disable the Gemini provider (default unset = enabled; only the exact string `0` disables; **no legacy fallback name**) |
162
162
  | `SPECRAILS_SMASH=0` | Kill switch for SMASH spec decomposition (`0` / `false` / `off`; endpoints return 409) |
163
163
  | `SPECRAILS_EXPLORE_CONTRACT_REFINE=0` | App-wide kill switch for Contract Refine (auto-fire + retry endpoint; accepts `0` / `false` / `off`) |
164
- | `SPECRAILS_EXPLORE_LEGACY_CWD=1` | Force Explore spawns to use the project root instead of the app-managed `explore-cwd/` |
164
+ | `SPECRAILS_EXPLORE_LEGACY_CWD=1` | Force every Explore spawn to use the project root instead of the app-managed `explore-cwd/` or a relocated workspace |
165
165
  | `SPECRAILS_FILE_SUMMARY_MODEL` | Override the model used for Code-section file summaries. Per-provider overrides take precedence: `SPECRAILS_FILE_SUMMARY_MODEL_CLAUDE` (Claude) and `SPECRAILS_FILE_SUMMARY_MODEL_CODEX` (Codex); the generic var is the fallback |
166
166
  | `SPECRAILS_ALLOW_LOCAL_WEBHOOKS=1` | Allow outbound webhooks to target loopback / private-network addresses |
167
167
 
@@ -38,7 +38,7 @@ loads** for gemini:
38
38
  |---|---|---|
39
39
  | Plugins on rails (`server/plugin-manager.ts`, e.g. Serena) | surgical merge into `<project>/.mcp.json` for `project-json` providers | plugin installs "successfully", MCP server never loads in gemini rail spawns |
40
40
  | Workspace MCP (`server/agent-mcp-config.ts` `mergeSpecrailsIntoWorkspaceMcp`, via `workspace-manager.ts`) | writes `<workspace>/.mcp.json` | not read by gemini |
41
- | Explore `context_scope.mcp` (`server/chat-manager.ts`) | spawns from `<project.path>` so `.mcp.json` loads | loads nothing on gemini |
41
+ | Explore `context_scope.mcp` (`server/chat-manager.ts`) | spawns from the relocation-aware artifact cwd (workspace when relocated, `<project.path>` when legacy) so `.mcp.json` loads | loads nothing on gemini |
42
42
 
43
43
  Fixing these means writing into the repo's / workspace's `.gemini/settings.json`, which
44
44
  intersects the **pristine-repo / artifact-relocation policy** (the app must not mutate the
@@ -239,7 +239,7 @@ $HOME/.specrails/
239
239
  ├── jira-secret.key # keyfile AES-256-GCM (0600), desktop-only
240
240
  ├── doctor.log # log append-only, core-only (única lectura $HOME de core hoy)
241
241
  └── projects/<slug>/
242
- ├── workspace/ # = artifactRoot. cwd de spawn de todas las AI-CLI
242
+ ├── workspace/ # = artifactRoot. cwd de jobs y Explore mcp=true relocalizados
243
243
  │ ├── .specrails/
244
244
  │ │ ├── local-tickets.json (+ .lock) ← SPECRAILS_TICKETS_PATH [core escribe/lee · desktop escribe]
245
245
  │ │ ├── backlog-config.json ← SPECRAILS_BACKLOG_CONFIG_PATH [desktop escribe · core lee]
@@ -365,9 +365,9 @@ El lado desktop ya tiene el precedente load-bearing en producción (`explore-cwd
365
365
 
366
366
  ### 6.1 cwd de spawn + env por provider
367
367
 
368
- `ExploreCwdManager` se generaliza a un `WorkspaceManager` que materializa `~/.specrails/projects/<slug>/workspace/` con `./project -> <project.path>` (symlink/junction, fallback `project-path.txt` — la lógica existente `ensureProjectLink`). **Todas las AI-CLI mueven cwd al workspace.**
368
+ `ExploreCwdManager` se generaliza a un `WorkspaceManager` que materializa `~/.specrails/projects/<slug>/workspace/` con `./project -> <project.path>` (symlink/junction, fallback `project-path.txt` — la lógica existente `ensureProjectLink`). **Las AI-CLI relocalizadas usan el workspace salvo los procesos deliberadamente repo-bound y Explore con `mcp=false`, que conserva su `explore-cwd`.**
369
369
 
370
- **Managers que MUEVEN cwd → workspace:** `QueueManager._startJob` (rails — el grande), `ChatManager` (Explore + sidebar), `AgentRefineManager` (ai-edit), `ContractRefineRunner`, `project-router /tickets/generate-spec` (quick-spec), `FileSummaryManager`, `SetupManager.startInstall` (corre `npx specrails-core init --root-dir <project.path> --workspace-dir <workspace>`).
370
+ **Managers que MUEVEN cwd → workspace:** `QueueManager._startJob` (rails — el grande), `ChatManager` (sidebar + Explore con `mcp=true`), `AgentRefineManager` (ai-edit), `ContractRefineRunner` cuando refleja un Explore `mcp=true`, `project-router /tickets/generate-spec` (quick-spec), `FileSummaryManager`, `SetupManager.startInstall` (corre `npx specrails-core init --root-dir <project.path> --workspace-dir <workspace>`). Explore y Contract Refine con `mcp=false` permanecen en el app-managed `explore-cwd`.
371
371
 
372
372
  **Managers que CONSERVAN cwd = `project.path`:** `TerminalManager` (shell repo-bound), `file-provenance.ts` (git repo-bound), `code-explorer-router` (lee fuente), lecturas de fuente de `FileSummaryManager`, `metrics.ts` (coverage).
373
373
 
@@ -380,7 +380,7 @@ El lado desktop ya tiene el precedente load-bearing en producción (`explore-cwd
380
380
  - **Smoke check (manual):** en un proyecto relocalizado con provider=codex, lanzar un loop "Implement" cuyo paso de fix deba editar una fuente del repo; confirmar en el transcript que (a) `apply_patch`/edit a `<repoDir>/...` **NO** devuelve `Operation not permitted`, y (b) `npm run build`/`tsc -b` puede escribir `node_modules/.tmp/*.tsbuildinfo`. Reproducción del fallo: revertir el `extraArgs` de codex → la edición del repo en la iteración ≥2 vuelve a fallar con `Operation not permitted`. Cubierto por `server/loop-executors.test.ts` (assert del arg `writable_roots`).
381
381
  - **gemini**: inyecta `GEMINI_CLI_TRUST_WORKSPACE=true` (ancla el root confiable al workspace). **Re-keya** `~/.gemini/acknowledgments/agents.json` al workspace.
382
382
  - Los tres: el workspace **estrictamente fuera del repo** y el discovery no debe seguir `./project` (mitiga la fuga por ancestor-walk de codex/gemini).
383
- - **Caso especial Explore `mcp=true`**: hoy `chat-manager.ts:326` conmuta cwd a `project.path` para cargar el `.mcp.json` del repo tras relocalizar, `.mcp.json` está en el workspace, así que este special-case se **ELIMINA**, no se re-apunta.
383
+ - **Caso especial Explore `mcp=true` (implementado):** `ChatManager` resuelve el cwd mediante la misma puerta de relocalización: workspace cuando el proyecto está relocalizado, `project.path` en legacy. Las rutas persistent-stdin, crash-respawn y Contract Refine conservan esa misma pareja cwd/env. Una sesión anterior creada bajo el cwd del repo que devuelve exactamente `No conversation found with session ID` se invalida y reintenta fresh una sola vez desde el workspace (transcript persistido acotado para Explore; ticket sembrado y tools deshabilitadas para Contract Refine), nunca volviendo al repo. `SPECRAILS_EXPLORE_LEGACY_CWD=1` sigue forzando `project.path`.
384
384
 
385
385
  ### 6.2 Sitios de re-apuntado de path (`project.path` → workspace)
386
386
 
@@ -193,25 +193,35 @@ seeded instruction file), which lives in `~/.specrails/projects/<slug>/workspace
193
193
  absent from the worktree: the claude CLI reported `Unknown command: /specrails:implement`
194
194
  and the loop "succeeded" through verify/fix without implementing (live evidence: run
195
195
  01f41203). Legacy projects have the sibling gap for their UNTRACKED on-disk `.claude`
196
- entries. Each allocated worktree therefore gets `applyWorktreeOverlay(...)` at launch:
197
-
198
- - **Source root** = the project's effective artifact root, resolved once per launch via
199
- `resolveProjectExecution`: the workspace when relocated, else the repo itself.
200
- - **Merge-only symlinks** under `<worktree>/<providerDir>/`: dir links where a dir is
201
- wholly absent, per-file/per-child where the checkout is partially present; checkout
202
- content is NEVER overwritten. `agent-memory` is linked (all runs share memory — the
203
- pre-isolation shared-cwd semantics, deliberate). The providerDir root and the source's
204
- `worktrees/` entry are never linked (nested pipeline worktrees stay local). `.mcp.json`
205
- and the instruction file (CLAUDE.md/AGENTS.md/GEMINI.md per provider) are COPIED when
206
- the checkout lacks them. Windows: junction dereferencing-copy fallback.
207
- - **Never on the PR**: every allocator-created overlay path is excluded from staging with a literal
208
- top-level pathspec. The writable worktree manifest is not cleanup authority; settlement persists
209
- fingerprints captured from the trusted source/created entries. Cleanup atomically renames each
210
- authenticated root into a unique sibling quarantine, revalidates it after the move, discloses
211
- raced content there, and never deletes or restores quarantined paths automatically over a
212
- possibly recreated source. A modified or
213
- concurrently extended overlay is preserved as user data, while a no-op
214
- overlay keeps the byte-identical ordinary staging behavior.
196
+ entries. Relocated projects also need repo-resident carve-outs that are intentionally not
197
+ part of the workspace framework, notably OpenSpec's untracked `/opsx:*` commands and
198
+ `openspec-*` skills. Each allocated worktree therefore gets `applyWorktreeOverlay(...)`
199
+ at launch:
200
+
201
+ - **Ordered source roots** are resolved once per launch. The primary root is the effective
202
+ artifact root (`resolveProjectExecution`: workspace when relocated, repo when legacy).
203
+ A relocated launch additionally passes the repo as a lower-priority fallback so its
204
+ untracked provider entries reach the worktree. Earlier roots win per entry; legacy stays
205
+ on its byte-identical single-root path.
206
+ - **Merge-only links/copies** under `<worktree>/<providerDir>/`: one contributing directory
207
+ stays a whole-dir link; when several roots contribute children, the destination is a REAL
208
+ directory of per-child links. Checkout content is NEVER overwritten, including source
209
+ file/directory type conflicts. On resume, only an authenticated prior-manifest whole-dir
210
+ link/copy may be rebuilt into recorded leaves; a foreign symlink is untouched even when it
211
+ targets a configured source. Converted parents are revoked from the manifest. `agent-memory`
212
+ remains linked (shared pre-isolation semantics). The providerDir root and every source's
213
+ `worktrees/` entry are never linked. `.mcp.json` and the provider instruction file are COPIED
214
+ from the first root containing them when checkout lacks them. Windows: junction →
215
+ dereferencing-copy fallback.
216
+ - **Never on the PR**: the resume-safe manifest records authenticated overlay leaves against
217
+ any configured root; settlement persists immutable fingerprints. Commit delivery deliberately
218
+ runs plain `git add -A`, audits the index, resets forbidden literal top-level paths, re-audits,
219
+ and makes those exclusions authoritative with `git commit --only`. The writable manifest alone
220
+ never grants cleanup authority. Cleanup atomically renames each authenticated root into a unique
221
+ sibling quarantine, revalidates it after the move, discloses raced content there, and never
222
+ deletes or restores quarantined paths automatically over a possibly recreated source. A modified
223
+ or concurrently extended overlay is preserved as user data, while a no-op overlay keeps the
224
+ byte-identical ordinary staging behavior.
215
225
  Successful quarantine batches deliberately remain beside the former worktree; automatic GC is
216
226
  out of scope because deleting a path still writable through an open descriptor would reintroduce
217
227
  the data-loss race. A future cleanup flow must be explicit and inspectable.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "specrails-desktop",
3
- "version": "2.29.4",
3
+ "version": "2.29.5",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -25,6 +25,92 @@ const workspace_resolution_1 = require("./workspace-resolution");
25
25
  const COMMAND_INSTRUCTION = 'When you want to suggest a SpecRails command for the user to execute, wrap it in a command block like this: ' +
26
26
  ':::command\n/specrails:implement #42\n::: ' +
27
27
  'The user will be prompted to confirm before the command runs.';
28
+ /** Claude stores resumable sessions under the spawn cwd. Relocating an existing
29
+ * Explore conversation from the repo to the workspace therefore makes its old
30
+ * session id unresolvable. Match ONLY Claude's exact diagnostic so auth, quota,
31
+ * model, and generic crash failures keep their existing no-retry semantics. */
32
+ const CLAUDE_MISSING_SESSION_DIAGNOSTIC = 'No conversation found with session ID';
33
+ /** Historical context folded into the one-time fresh-session recovery. This is
34
+ * deliberately byte-bounded independently of the current turn, whose existing
35
+ * attachment/scoped-context payload must remain intact. */
36
+ const RESUME_RECOVERY_TRANSCRIPT_MAX_BYTES = 48 * 1024;
37
+ function containsMissingClaudeSessionDiagnostic(value) {
38
+ if (typeof value === 'string') {
39
+ return value.includes(CLAUDE_MISSING_SESSION_DIAGNOSTIC);
40
+ }
41
+ if (value == null)
42
+ return false;
43
+ try {
44
+ return JSON.stringify(value).includes(CLAUDE_MISSING_SESSION_DIAGNOSTIC);
45
+ }
46
+ catch {
47
+ return false;
48
+ }
49
+ }
50
+ function isMissingClaudeSessionErrorResult(value) {
51
+ if (!value || typeof value !== 'object')
52
+ return false;
53
+ const payload = value;
54
+ const markedAsError = payload.is_error === true ||
55
+ (typeof payload.subtype === 'string' && payload.subtype.startsWith('error'));
56
+ return markedAsError && containsMissingClaudeSessionDiagnostic(value);
57
+ }
58
+ function takeUtf8Tail(text, maxBytes) {
59
+ if (maxBytes <= 0)
60
+ return '';
61
+ if (Buffer.byteLength(text) <= maxBytes)
62
+ return text;
63
+ let low = 0;
64
+ let high = text.length;
65
+ while (low < high) {
66
+ const mid = Math.floor((low + high) / 2);
67
+ if (Buffer.byteLength(text.slice(mid)) > maxBytes)
68
+ low = mid + 1;
69
+ else
70
+ high = mid;
71
+ }
72
+ // Never start on the trailing half of a UTF-16 surrogate pair.
73
+ if (low < text.length && text.charCodeAt(low) >= 0xdc00 && text.charCodeAt(low) <= 0xdfff)
74
+ low++;
75
+ return text.slice(low);
76
+ }
77
+ function buildResumeRecoveryPrompt(messages, currentPrompt) {
78
+ const entries = messages.map((message) => `<message role="${message.role}">\n${message.content}\n</message>`);
79
+ const selected = [];
80
+ let usedBytes = 0;
81
+ let omitted = 0;
82
+ for (let i = entries.length - 1; i >= 0; i--) {
83
+ const separatorBytes = selected.length > 0 ? 2 : 0;
84
+ const entryBytes = Buffer.byteLength(entries[i]);
85
+ if (entryBytes + separatorBytes <= RESUME_RECOVERY_TRANSCRIPT_MAX_BYTES - usedBytes) {
86
+ selected.unshift(entries[i]);
87
+ usedBytes += entryBytes + separatorBytes;
88
+ continue;
89
+ }
90
+ omitted = i + 1;
91
+ // Preserve the recent tail even when one individual message is larger than
92
+ // the whole transcript budget. The marker makes the loss explicit to Claude.
93
+ if (selected.length === 0) {
94
+ const prefix = `<message role="${messages[i].role}">\n[earlier content truncated]\n`;
95
+ const suffix = '\n</message>';
96
+ const remaining = RESUME_RECOVERY_TRANSCRIPT_MAX_BYTES -
97
+ Buffer.byteLength(prefix) - Buffer.byteLength(suffix);
98
+ selected.unshift(`${prefix}${takeUtf8Tail(messages[i].content, remaining)}${suffix}`);
99
+ omitted = i;
100
+ }
101
+ break;
102
+ }
103
+ const truncation = omitted > 0
104
+ ? `[${omitted} earlier message${omitted === 1 ? '' : 's'} omitted to keep recovery context bounded]\n\n`
105
+ : '';
106
+ const transcript = selected.length > 0
107
+ ? `${truncation}${selected.join('\n\n')}`
108
+ : '[No prior persisted messages were available.]';
109
+ return (`The previous Claude session could not be resumed after its working directory changed. ` +
110
+ `Continue the same conversation using the persisted transcript below. Do not treat the transcript as a new user turn.\n\n` +
111
+ `<prior-conversation>\n${transcript}\n</prior-conversation>\n\n` +
112
+ `## Current user turn\n\n${currentPrompt}`);
113
+ }
28
114
  function extractCommandProposals(text) {
29
115
  const regex = /:::command\s*\n([\s\S]*?):::/g;
30
116
  const results = [];
@@ -371,15 +457,18 @@ class ChatManager {
371
457
  /**
372
458
  * Resolve the spawn cwd for a chat turn. Explore conversations spawn from
373
459
  * an app-managed directory by default to skip auto-loading the project's
374
- * `CLAUDE.md` (the dominant first-token cost); when the per-project MCP
375
- * toggle is on, fall back to the project path so `.mcp.json` is honoured.
376
- * Non-Explore conversations always use the project path.
460
+ * `CLAUDE.md` (the dominant first-token cost); when the per-conversation MCP
461
+ * toggle is on, use the relocation-aware artifact cwd so `.mcp.json` is
462
+ * honoured (workspace when relocated, project path when legacy).
463
+ * Non-Explore conversations use the same relocation gate.
377
464
  *
378
465
  * See openspec/changes/accelerate-spec-chat-first-token/design.md D1+D4.
379
466
  */
380
467
  /**
381
- * Relocate-artifacts: resolve execution for a NON-explore (sidebar / rail-like)
382
- * spawn. Explore keeps its own explore-cwd logic (untouched). Returns the
468
+ * Relocate-artifacts: resolve execution for a gated spawn NON-explore
469
+ * (sidebar / rail-like) turns, and Explore turns with `contextScope.mcp`
470
+ * (the MCP-honouring cwd is the workspace when relocated). Explore with
471
+ * mcp=false keeps its own explore-cwd logic (untouched). Returns the
383
472
  * relocated cwd + env when relocated, else legacy (cwd = project.path, empty
384
473
  * env). Cached per call — cheap registry read.
385
474
  */
@@ -409,8 +498,19 @@ class ChatManager {
409
498
  // Per-conversation scope.mcp is the only source of truth. Legacy null
410
499
  // scope is treated as mcp=false (spawn from app-managed cwd).
411
500
  const mcpEnabled = scope ? !!scope.mcp : false;
412
- if (mcpEnabled)
413
- return this._cwd;
501
+ if (mcpEnabled) {
502
+ // The escape hatch keeps its documented force-`<project.path>` semantics.
503
+ if (process.env.SPECRAILS_EXPLORE_LEGACY_CWD === '1')
504
+ return this._cwd;
505
+ // MCP-honouring cwd through the relocate-artifacts gate: for a RELOCATED
506
+ // project `.mcp.json` AND `.specrails/` live in the WORKSPACE — spawning
507
+ // from the repo made the system prompt's cwd-relative ticket-store
508
+ // instruction create `<repo>/.specrails/local-tickets.json` (a store the
509
+ // app never reads), breaking the pristine-repo guarantee. Legacy
510
+ // projects resolve to project.path — byte-identical.
511
+ const exec = this._resolveNonExploreExecution();
512
+ return exec ? exec.cwd : this._cwd;
513
+ }
414
514
  try {
415
515
  const cwd = (0, explore_cwd_manager_1.ensureExploreCwd)({
416
516
  slug: this._projectSlug,
@@ -613,8 +713,14 @@ class ChatManager {
613
713
  }
614
714
  // Check if this is turn 1 (session_id was null before this message)
615
715
  const isFirstTurn = conversation.session_id === null;
616
- // Persist user message
617
- (0, db_1.addMessage)(this._db, { conversation_id: conversationId, role: 'user', content: userText });
716
+ // Persist the user message exactly once. Its row id is retained so a
717
+ // stale-session recovery can fold prior history into a fresh prompt without
718
+ // duplicating this current turn (getMessages already includes it by then).
719
+ const persistedUserMessage = (0, db_1.addMessage)(this._db, {
720
+ conversation_id: conversationId,
721
+ role: 'user',
722
+ content: userText,
723
+ });
618
724
  // Resolve slash commands (e.g. /specrails:propose-spec → prompt content)
619
725
  let resolvedText = (0, command_resolver_1.resolveCommand)(userText, this._cwd ?? process.cwd());
620
726
  // Fold attachments into the prompt as <user-attachment> text blocks under
@@ -713,6 +819,9 @@ class ChatManager {
713
819
  promptForAdapter = `${dashboardContext}\n\n## User turn\n\n${promptForAdapter}`;
714
820
  }
715
821
  }
822
+ const buildRecoveryPrompt = () => buildResumeRecoveryPrompt((0, db_1.getMessages)(this._db, conversationId)
823
+ .filter((message) => message.id !== persistedUserMessage.id)
824
+ .map((message) => ({ role: message.role, content: message.content })), promptForAdapter);
716
825
  let args = adapter.buildArgs(action, {
717
826
  prompt: promptForAdapter,
718
827
  systemPrompt,
@@ -732,11 +841,18 @@ class ChatManager {
732
841
  // not pipeline jobs. Telemetry is scoped to QueueManager pipeline runs only.
733
842
  // spawnAiCli reroutes multi-line argv values through stdin on Windows.
734
843
  const spawnCwd = this._resolveSpawnCwd(conversation.kind, conversationScope, adapter.id);
735
- // Relocate-artifacts env for NON-explore (sidebar) spawns. Explore keeps its
736
- // own explore-cwd path (no relocation env — it reaches the repo via the
737
- // explore-cwd `./project` link). Legacy process.env (byte-identical).
844
+ // Relocate-artifacts env for gated spawns: NON-explore (sidebar) turns, and
845
+ // Explore turns with `contextScope.mcp` (they spawn from the workspace when
846
+ // relocated see `_resolveSpawnCwd`; the env makes the workspace's
847
+ // `${SPECRAILS_REPO_DIR:-.}` indirection resolve to the repo). Explore with
848
+ // mcp=false keeps its explore-cwd path (no relocation env — it reaches the
849
+ // repo via the explore-cwd `./project` link). Legacy ⇒ process.env
850
+ // (byte-identical).
738
851
  let spawnEnv = process.env;
739
- if (conversation.kind !== 'explore') {
852
+ const exploreMcp = conversation.kind === 'explore' &&
853
+ !!conversationScope?.mcp &&
854
+ process.env.SPECRAILS_EXPLORE_LEGACY_CWD !== '1';
855
+ if (conversation.kind !== 'explore' || exploreMcp) {
740
856
  const exec = this._resolveNonExploreExecution();
741
857
  if (exec?.relocated) {
742
858
  spawnEnv = { ...process.env, ...exec.env };
@@ -744,6 +860,11 @@ class ChatManager {
744
860
  spawnEnv = { ...spawnEnv, GEMINI_CLI_TRUST_WORKSPACE: 'true' };
745
861
  }
746
862
  }
863
+ const allowMissingSessionRecovery = adapter.id === 'claude' &&
864
+ conversation.kind === 'explore' &&
865
+ exploreMcp &&
866
+ spawnCwd !== this._cwd &&
867
+ spawnEnv.SPECRAILS_WORKSPACE_DIR === spawnCwd;
747
868
  // Big bet #3 fast-path: persistent-stdin multi-turn for Explore (claude
748
869
  // only, flag-gated default OFF). Reuses a single long-lived child across
749
870
  // turns so turns 2+ skip spawn + session rehydration. Full fallback to the
@@ -753,8 +874,9 @@ class ChatManager {
753
874
  adapter.capabilities.persistentStdin === true) {
754
875
  return await this._streamPersistentExploreTurn({
755
876
  conversationId, conversation, adapter, binary, model, systemPrompt,
756
- scopeFlags, spawnCwd, promptForAdapter, isFirstTurn, userText,
757
- lightweight, conversationScope,
877
+ scopeFlags, spawnCwd, spawnEnv, promptForAdapter, isFirstTurn, userText,
878
+ lightweight, conversationScope, buildRecoveryPrompt,
879
+ allowMissingSessionRecovery,
758
880
  });
759
881
  }
760
882
  const child = this._spawnOwned(binary, args, {
@@ -802,6 +924,7 @@ class ChatManager {
802
924
  * `error`). When set, the turn failed for a concrete reason (usage limit,
803
925
  * auth, model) that a respawn cannot fix — surface it instead of retrying. */
804
926
  let capturedErrorMessage = null;
927
+ let resumeRecoveryAttempted = false;
805
928
  const turnStartedAt = new Date().toISOString();
806
929
  const stdoutReader = (0, readline_1.createInterface)({ input: child.stdout, crlfDelay: Infinity });
807
930
  const emitDelta = (newText) => {
@@ -924,6 +1047,124 @@ class ChatManager {
924
1047
  console.log(`[chat-manager] ${adapter.id} exited code=${code} conv=${conversationId}`);
925
1048
  const fullText = this._buffers.get(conversationId) ?? '';
926
1049
  const wasAborting = this._abortingConversations.has(conversationId);
1050
+ // A session created before relocated MCP Explore moved from repo cwd to
1051
+ // workspace cwd cannot be found in Claude's per-cwd session store. This
1052
+ // is not a generic crash retry: recover ONLY the exact provider
1053
+ // diagnostic, only before any text/tool side effect, and only once.
1054
+ const missingPriorSession = allowMissingSessionRecovery &&
1055
+ action === 'chat-resume' &&
1056
+ !resumeRecoveryAttempted &&
1057
+ !wasAborting &&
1058
+ fullText.length === 0 &&
1059
+ !adapterEvents.some((event) => event.kind === 'tool-use') &&
1060
+ (containsMissingClaudeSessionDiagnostic(capturedErrorMessage) ||
1061
+ containsMissingClaudeSessionDiagnostic(stderrBuf) ||
1062
+ adapterEvents.some((event) => event.kind === 'result' &&
1063
+ isMissingClaudeSessionErrorResult(event.payload)));
1064
+ if (missingPriorSession) {
1065
+ resumeRecoveryAttempted = true;
1066
+ // The old id is provably unusable from the corrected cwd. Clearing it
1067
+ // before the fresh attempt prevents every later turn from paying the
1068
+ // same failed lookup if this recovery spawn itself cannot complete.
1069
+ (0, db_1.updateConversation)(this._db, conversationId, { session_id: null });
1070
+ const recoveryPrompt = buildRecoveryPrompt();
1071
+ const recoveryArgs = adapter.buildArgs('chat-turn', {
1072
+ prompt: recoveryPrompt,
1073
+ systemPrompt,
1074
+ model,
1075
+ maxTurns: options?.maxTurns,
1076
+ extraArgs: scopeFlags,
1077
+ loadUserEnv: adapter.id === 'claude' && !!conversationScope?.userMcp,
1078
+ });
1079
+ console.warn(`[chat-manager] stale Explore session; retrying fresh for ${conversationId}`);
1080
+ try {
1081
+ const newChild = this._spawnOwned(binary, recoveryArgs, {
1082
+ env: spawnEnv,
1083
+ stdio: ['ignore', 'pipe', 'pipe'],
1084
+ cwd: spawnCwd,
1085
+ });
1086
+ currentChild = newChild;
1087
+ closeChildren.add(newChild);
1088
+ args = recoveryArgs;
1089
+ // The failed lookup reached a terminal result but burned no model
1090
+ // turn. Keep it visible in analytics, then isolate the fresh turn's
1091
+ // canonical result/session id from the stale frame.
1092
+ this._recordChatInvocation({
1093
+ conversationId,
1094
+ kind: conversation.kind,
1095
+ adapter,
1096
+ events: adapterEvents,
1097
+ model,
1098
+ status: 'failed',
1099
+ startedAt: turnStartedAt,
1100
+ });
1101
+ this._buffers.set(conversationId, '');
1102
+ this._streamFilters.set(conversationId, { inBlock: false, pendingTail: '' });
1103
+ adapterEvents.length = 0;
1104
+ capturedSessionId = null;
1105
+ capturedErrorMessage = null;
1106
+ sawResult = false;
1107
+ stderrBuf = '';
1108
+ // Consume the normal Explore crash budget so a failed fresh retry is
1109
+ // surfaced rather than replaying the same user turn a third time.
1110
+ const life = this._exploreLifecycle.get(conversationId);
1111
+ if (life)
1112
+ life.crashCount = 1;
1113
+ this._activeProcesses.set(conversationId, newChild);
1114
+ newChild.stderr?.on('data', (chunk) => {
1115
+ if (this._disposed)
1116
+ return;
1117
+ const text = chunk.toString();
1118
+ stderrBuf += text;
1119
+ console.error(`[chat-manager] ${binary} stderr (${conversationId}):`, text.trim());
1120
+ });
1121
+ newChild.on('error', (err) => {
1122
+ if (this._disposed) {
1123
+ cancelForShutdown();
1124
+ return;
1125
+ }
1126
+ console.error(`[chat-manager] stale-session recovery spawn failed for ${conversationId}: ${err.message}`);
1127
+ this._recordChatInvocation({
1128
+ conversationId,
1129
+ kind: conversation.kind,
1130
+ adapter,
1131
+ events: adapterEvents,
1132
+ model,
1133
+ status: 'failed',
1134
+ startedAt: turnStartedAt,
1135
+ });
1136
+ this._activeProcesses.delete(conversationId);
1137
+ this._buffers.delete(conversationId);
1138
+ this._emittedProposals.delete(conversationId);
1139
+ this._abortingConversations.delete(conversationId);
1140
+ this._streamFilters.delete(conversationId);
1141
+ const activeLife = this._exploreLifecycle.get(conversationId);
1142
+ if (activeLife) {
1143
+ activeLife.isStreaming = false;
1144
+ activeLife.lastActivityAt = Date.now();
1145
+ if (activeLife.isMinimized)
1146
+ this._startIdleTimer(conversationId);
1147
+ }
1148
+ this._drainExploreQueue();
1149
+ this._broadcast({
1150
+ type: 'chat_error',
1151
+ conversationId,
1152
+ error: `Failed to launch ${binary}: ${err.message}`,
1153
+ timestamp: new Date().toISOString(),
1154
+ });
1155
+ complete();
1156
+ });
1157
+ const newReader = (0, readline_1.createInterface)({ input: newChild.stdout, crlfDelay: Infinity });
1158
+ readers.add(newReader);
1159
+ newReader.on('line', readerHandler);
1160
+ newChild.on('close', onClose);
1161
+ return;
1162
+ }
1163
+ catch (err) {
1164
+ console.error('[chat-manager] stale-session recovery spawn failed:', err);
1165
+ // Fall through and surface the original missing-session error.
1166
+ }
1167
+ }
927
1168
  // Crash auto-respawn for Explore: if the child exited non-zero before
928
1169
  // emitting a `result` event, the user did not explicitly abort, and
929
1170
  // we have not yet retried, respawn the same turn once via chat-resume
@@ -931,6 +1172,7 @@ class ChatManager {
931
1172
  // See design.md D7.
932
1173
  if (conversation.kind === 'explore' &&
933
1174
  !wasAborting &&
1175
+ !resumeRecoveryAttempted &&
934
1176
  code !== 0 &&
935
1177
  !sawResult &&
936
1178
  // A provider-reported failure (usage limit, auth, unsupported model)
@@ -958,7 +1200,7 @@ class ChatManager {
958
1200
  console.warn(`[chat-manager] explore crash respawn for ${conversationId}`);
959
1201
  try {
960
1202
  const newChild = this._spawnOwned(binary, respawnArgs, {
961
- env: process.env,
1203
+ env: spawnEnv,
962
1204
  stdio: ['ignore', 'pipe', 'pipe'],
963
1205
  cwd: spawnCwd,
964
1206
  });
@@ -1180,7 +1422,7 @@ class ChatManager {
1180
1422
  async _streamPersistentExploreTurn(p) {
1181
1423
  if (this._disposed)
1182
1424
  return;
1183
- const { conversationId, conversation, adapter, binary, model, systemPrompt, scopeFlags, spawnCwd, promptForAdapter, isFirstTurn, userText, lightweight, conversationScope, } = p;
1425
+ const { conversationId, conversation, adapter, binary, model, systemPrompt, scopeFlags, spawnCwd, spawnEnv, promptForAdapter, isFirstTurn, userText, lightweight, conversationScope, buildRecoveryPrompt, } = p;
1184
1426
  const sessionArgs = adapter.buildArgs('chat-stream', {
1185
1427
  prompt: '',
1186
1428
  systemPrompt,
@@ -1190,7 +1432,7 @@ class ChatManager {
1190
1432
  loadUserEnv: adapter.id === 'claude' && !!conversationScope?.userMcp,
1191
1433
  });
1192
1434
  const { child, isNew } = this._stdinSessions.getOrSpawn(conversationId, {
1193
- binary, args: sessionArgs, cwd: spawnCwd, env: process.env, spawn: this._spawnOwned.bind(this),
1435
+ binary, args: sessionArgs, cwd: spawnCwd, env: spawnEnv, spawn: this._spawnOwned.bind(this),
1194
1436
  });
1195
1437
  this._trackPersistentProcess(conversationId, child);
1196
1438
  // MED-1: a fresh child restarts claude's session-cumulative counters at 0,
@@ -1344,6 +1586,51 @@ class ChatManager {
1344
1586
  // turn; flip to 'failed' (the cost is still kept — the result carries real
1345
1587
  // usage). Set at result time, read by finishTurn.
1346
1588
  let resultIsError = false;
1589
+ const recoverMissingSession = (diagnostic) => {
1590
+ if (settled ||
1591
+ !p.allowMissingSessionRecovery ||
1592
+ !conversation.session_id ||
1593
+ p.resumeRecoveryAttempted ||
1594
+ this._abortingConversations.has(conversationId) ||
1595
+ (this._buffers.get(conversationId) ?? '').length > 0 ||
1596
+ adapterEvents.some((event) => event.kind === 'tool-use') ||
1597
+ !(containsMissingClaudeSessionDiagnostic(diagnostic) ||
1598
+ containsMissingClaudeSessionDiagnostic(stderrBuf))) {
1599
+ return false;
1600
+ }
1601
+ // The persistent child was launched with the stale --resume before its
1602
+ // first stdin turn. Evict it, but keep the Explore lifecycle slot held
1603
+ // while a brand-new stream child receives the same turn exactly once.
1604
+ settled = true;
1605
+ // Persist the invalidation before replacing the old stream child. A
1606
+ // failed fresh attempt must not leave the known-bad repo-cwd id behind.
1607
+ (0, db_1.updateConversation)(this._db, conversationId, { session_id: null });
1608
+ this._stdinSessions.clearHandlers(conversationId);
1609
+ cleanupTurnState();
1610
+ recordInv('failed');
1611
+ this._stdinSessions.kill(conversationId);
1612
+ console.warn(`[chat-manager] stale persistent Explore session; retrying fresh for ${conversationId}`);
1613
+ void this._streamPersistentExploreTurn({
1614
+ ...p,
1615
+ conversation: { ...conversation, session_id: null },
1616
+ promptForAdapter: buildRecoveryPrompt(),
1617
+ isFirstTurn: false,
1618
+ resumeRecoveryAttempted: true,
1619
+ }).then(complete).catch((err) => {
1620
+ if (!this._disposed) {
1621
+ console.error(`[chat-manager] persistent stale-session recovery failed for ${conversationId}:`, err);
1622
+ markStreamingEnded(false);
1623
+ this._broadcast({
1624
+ type: 'chat_error',
1625
+ conversationId,
1626
+ error: err instanceof Error ? err.message : String(err),
1627
+ timestamp: new Date().toISOString(),
1628
+ });
1629
+ }
1630
+ complete();
1631
+ });
1632
+ return true;
1633
+ };
1347
1634
  const finishTurn = () => {
1348
1635
  if (settled)
1349
1636
  return;
@@ -1410,6 +1697,8 @@ class ChatManager {
1410
1697
  cancelForShutdown();
1411
1698
  return;
1412
1699
  }
1700
+ if (recoverMissingSession(stderrBuf))
1701
+ return;
1413
1702
  settled = true;
1414
1703
  this._stdinSessions.clearHandlers(conversationId);
1415
1704
  const wasAborting = this._abortingConversations.has(conversationId);
@@ -1449,6 +1738,8 @@ class ChatManager {
1449
1738
  break;
1450
1739
  case 'result': {
1451
1740
  const payload = ev.payload;
1741
+ if (recoverMissingSession(isMissingClaudeSessionErrorResult(payload) ? payload : null))
1742
+ break;
1452
1743
  if (payload.session_id)
1453
1744
  capturedSessionId = payload.session_id;
1454
1745
  // LOW-7: detect a failed turn reported through the result frame.
@@ -2,12 +2,15 @@
2
2
  // Context Scope — per-spec configuration that controls what context is fed
3
3
  // into the Add Spec flow (Quick `generate-spec` and Explore chat turns).
4
4
  //
5
- // Four orthogonal toggles:
5
+ // Six orthogonal toggles:
6
6
  // - specrails: concat <project>/.specrails/local-tickets.json (all tickets)
7
7
  // into the system prompt
8
8
  // - openspec: concat <project>/openspec/specs/**/spec.md into the system prompt
9
- // - full: allow Read/Grep/Glob (Bash is never auto-allowed)
10
- // - mcp: Explore-only spawn from <project> so .mcp.json is honored
9
+ // - full: enable source inspection; with an MCP toggle the high tier
10
+ // retains Bash/MCP tools while denying GUI file-writer tools
11
+ // - mcp: Explore-only — spawn from the relocation-aware artifact cwd so
12
+ // .mcp.json is honored (workspace when relocated, repo when legacy)
13
+ // - contractRefine: run the post-commit structural refinement turn
11
14
  // - userMcp: Explore-only — inject the user's own already-approved MCP
12
15
  // servers (claude: ~/.claude.json user+local scope, fed via
13
16
  // `--mcp-config`; codex: ~/.codex is read natively). Independent