specrails-desktop 2.11.4 → 2.11.6

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 (61) hide show
  1. package/client/dist/assets/ActivityFeedPage-B4O_62Cd.js +1 -0
  2. package/client/dist/assets/{AgentsPage-5UiKCp_t.js → AgentsPage-CzaYXMfF.js} +1 -1
  3. package/client/dist/assets/{AnalyticsPage-BwxD58MO.js → AnalyticsPage-CAVRiHEZ.js} +1 -1
  4. package/client/dist/assets/{BarChart-BaJJVLw4.js → BarChart-Cxmjvzk6.js} +1 -1
  5. package/client/dist/assets/{CodePage-3tgS4fU2.js → CodePage-3YuHn6oB.js} +1 -1
  6. package/client/dist/assets/{DesktopAnalyticsPage-BWJxqa4T.js → DesktopAnalyticsPage-dn0BKpBG.js} +1 -1
  7. package/client/dist/assets/{DocsDialog-DF5TF81L.js → DocsDialog-CjT3NrG-.js} +1 -1
  8. package/client/dist/assets/{DocsPage-DZDZPRLe.js → DocsPage-DB6K87CL.js} +1 -1
  9. package/client/dist/assets/{ExportDropdown-DFEuLgiK.js → ExportDropdown-DXGqkszG.js} +1 -1
  10. package/client/dist/assets/{IntegrationsPage-CuAShwlJ.js → IntegrationsPage-B6qF0tqT.js} +1 -1
  11. package/client/dist/assets/{JobDetailPage-Bx0wRTry.js → JobDetailPage-Dhk31VAB.js} +1 -1
  12. package/client/dist/assets/{JobsPage-COcbVVcd.js → JobsPage-B6Qn1z5m.js} +1 -1
  13. package/client/dist/assets/{dist-js-Cbm7C856.js → dist-js-CQtuspP9.js} +1 -1
  14. package/client/dist/assets/{dist-js-CgFhhWyd.js → dist-js-SJkboL9H.js} +1 -1
  15. package/client/dist/assets/{index-Wgxn2zBM.js → index-vNp6-zmg.js} +7 -7
  16. package/client/dist/assets/{lib-BhY8Pq4z.js → lib-C7QskAbA.js} +1 -1
  17. package/client/dist/assets/{useProjectCache-CDaO4kHu.js → useProjectCache-D3d_Np5F.js} +1 -1
  18. package/client/dist/index.html +1 -1
  19. package/package.json +1 -1
  20. package/server/dist/agent-generator.js +32 -12
  21. package/server/dist/artifact-registry.js +24 -4
  22. package/server/dist/chat-manager.js +13 -0
  23. package/server/dist/desktop-db.js +11 -2
  24. package/server/dist/desktop-router.js +22 -9
  25. package/server/dist/explore-stdin-session.js +14 -2
  26. package/server/dist/hooks.js +40 -30
  27. package/server/dist/index.js +5 -0
  28. package/server/dist/jira/jira-client.js +35 -8
  29. package/server/dist/jira/jira-credential-store.js +11 -2
  30. package/server/dist/jira/jira-materializer.js +8 -2
  31. package/server/dist/jira/jira-sync-manager.js +11 -2
  32. package/server/dist/mobile/mobile-datachannel.js +19 -1
  33. package/server/dist/mobile/mobile-gateway.js +10 -7
  34. package/server/dist/mobile/mobile-signal-reconnect.js +2 -2
  35. package/server/dist/mobile/mobile-webrtc-peer.js +28 -18
  36. package/server/dist/mobile/mobile-ws.js +7 -0
  37. package/server/dist/path-resolver.js +38 -2
  38. package/server/dist/plugin-manager.js +17 -2
  39. package/server/dist/plugins/serena/install.js +9 -8
  40. package/server/dist/plugins-router.js +2 -2
  41. package/server/dist/profile-manager.js +30 -0
  42. package/server/dist/profiles-router.js +5 -1
  43. package/server/dist/project-registry.js +39 -0
  44. package/server/dist/project-router-chat.js +18 -2
  45. package/server/dist/project-router-jobs.js +1 -1
  46. package/server/dist/project-router-spending.js +7 -3
  47. package/server/dist/project-router-tickets.js +34 -7
  48. package/server/dist/project-router.js +1 -1
  49. package/server/dist/providers/claude-adapter.js +3 -1
  50. package/server/dist/providers/gemini-adapter.js +7 -2
  51. package/server/dist/queue-manager.js +68 -3
  52. package/server/dist/result-event.js +2 -1
  53. package/server/dist/setup-manager.js +58 -10
  54. package/server/dist/setup-prerequisites.js +9 -3
  55. package/server/dist/spawn-lifecycle.js +25 -3
  56. package/server/dist/spending.js +8 -4
  57. package/server/dist/telemetry-compactor.js +37 -4
  58. package/server/dist/telemetry-receiver.js +15 -0
  59. package/server/dist/terminal-manager.js +10 -4
  60. package/server/dist/transient-children.js +53 -0
  61. package/client/dist/assets/ActivityFeedPage-CbOQSZ4L.js +0 -1
@@ -124,10 +124,26 @@ function registerChatRoutes(deps) {
124
124
  }
125
125
  const { title, model } = req.body ?? {};
126
126
  const patch = {};
127
- if (title !== undefined)
127
+ if (title !== undefined) {
128
+ if (typeof title !== 'string') {
129
+ res.status(400).json({ error: 'title must be a string' });
130
+ return;
131
+ }
128
132
  patch.title = title;
129
- if (model !== undefined)
133
+ }
134
+ if (model !== undefined) {
135
+ // Validate against the conversation's own provider so a bad value can't be
136
+ // persisted and break the next turn's spawn. (Mirrors the POST handler.)
137
+ const convProvider = conversation.provider ?? 'claude';
138
+ if (typeof model !== 'string' || !(0, spec_models_1.isValidModelForProvider)(model, convProvider)) {
139
+ res.status(400).json({
140
+ error: `Invalid model "${String(model)}" for provider "${convProvider}"`,
141
+ allowed: (0, spec_models_1.getModelsForProvider)(convProvider).map((m) => m.value),
142
+ });
143
+ return;
144
+ }
130
145
  patch.model = model;
146
+ }
131
147
  (0, db_1.updateConversation)(db, req.params.id, patch);
132
148
  const updated = (0, db_1.getConversation)(db, req.params.id);
133
149
  res.json({ ok: true, conversation: updated });
@@ -77,7 +77,7 @@ function registerJobsRoutes(deps) {
77
77
  res.json({
78
78
  projectName: project.name,
79
79
  projectId: project.id,
80
- phases: (0, hooks_1.getPhaseStates)(),
80
+ phases: (0, hooks_1.getPhaseStates)(project.id),
81
81
  busy: queueManager.getActiveJobId() !== null,
82
82
  currentJobId: queueManager.getActiveJobId(),
83
83
  featureFlags: {
@@ -125,9 +125,9 @@ function registerSpendingRoutes(deps) {
125
125
  ].join(','));
126
126
  lines.push('');
127
127
  lines.push('# Daily timeline');
128
- lines.push('date,jobsCostUsd,quickCostUsd,exploreCostUsd,aiEditCostUsd,totalCostUsd');
128
+ lines.push('date,jobsCostUsd,quickCostUsd,exploreCostUsd,aiEditCostUsd,smashCostUsd,fileSummaryCostUsd,totalCostUsd');
129
129
  for (const d of data.dailyTimeline) {
130
- lines.push(`${d.date},${d.jobsCostUsd},${d.quickCostUsd},${d.exploreCostUsd},${d.aiEditCostUsd},${d.totalCostUsd}`);
130
+ lines.push(`${d.date},${d.jobsCostUsd},${d.quickCostUsd},${d.exploreCostUsd},${d.aiEditCostUsd},${d.smashCostUsd},${d.fileSummaryCostUsd},${d.totalCostUsd}`);
131
131
  }
132
132
  lines.push('');
133
133
  lines.push('# By surface');
@@ -198,7 +198,11 @@ function registerSpendingRoutes(deps) {
198
198
  }
199
199
  });
200
200
  function csvEscape(v) {
201
- const s = v == null ? '' : String(v);
201
+ let s = v == null ? '' : String(v);
202
+ // CSV formula injection: a value starting with = + - @ (or tab/CR) is run as
203
+ // a formula by Excel/Sheets. Neutralize by prefixing a single quote.
204
+ if (/^[=+\-@\t\r]/.test(s))
205
+ s = `'${s}`;
202
206
  return s.includes(',') || s.includes('"') || s.includes('\n')
203
207
  ? `"${s.replace(/"/g, '""')}"`
204
208
  : s;
@@ -56,6 +56,7 @@ const provider_selection_1 = require("./provider-selection");
56
56
  const ticket_store_1 = require("./ticket-store");
57
57
  const explore_draft_title_1 = require("./explore-draft-title");
58
58
  const cli_prompt_1 = require("./util/cli-prompt");
59
+ const transient_children_1 = require("./transient-children");
59
60
  const readline_1 = require("readline");
60
61
  const tree_kill_1 = __importDefault(require("tree-kill"));
61
62
  const workspace_resolution_1 = require("./workspace-resolution");
@@ -307,6 +308,9 @@ function registerTicketsRoutes(deps) {
307
308
  stdio: ['ignore', 'pipe', 'pipe'],
308
309
  cwd: specGenExec.cwd,
309
310
  });
311
+ // Register so removeProject()/shutdown() can tree-kill this otherwise-untracked
312
+ // fire-and-forget child (auto-unregisters on close/error).
313
+ (0, transient_children_1.trackTransientChild)(projectId, child);
310
314
  // Watchdog: unlike ai-edit, generate-spec keeps no cancellable handle, so a
311
315
  // hung CLI (network stall, model never emitting a terminating event) would
312
316
  // otherwise leak this child + its readline for the app's lifetime. Cap is
@@ -696,9 +700,18 @@ function registerTicketsRoutes(deps) {
696
700
  flippedInPlace = true;
697
701
  return;
698
702
  }
699
- // Idempotent on conversationId: if a draft ticket already references this
700
- // conversation, update in place rather than create a second one.
701
- const existing = Object.values(s.tickets).find((t) => t.origin_conversation_id === conversationId && t.status === 'draft');
703
+ // Idempotent on conversationId. A ticket from this conversation may
704
+ // already exist as a DRAFT (update in place) OR as a COMMITTED ticket
705
+ // (it was already turned into a real spec) — in the latter case do NOT
706
+ // insert a duplicate draft sharing the same origin_conversation_id
707
+ // (which would also break the delete→conversation cascade). Return the
708
+ // committed ticket unchanged instead.
709
+ const anyExisting = Object.values(s.tickets).find((t) => t.origin_conversation_id === conversationId);
710
+ if (anyExisting && anyExisting.status !== 'draft') {
711
+ saved = anyExisting;
712
+ return;
713
+ }
714
+ const existing = anyExisting && anyExisting.status === 'draft' ? anyExisting : undefined;
702
715
  const title = providedTitle || existing?.title || (0, explore_draft_title_1.generateAutoTitle)(messages.map((m) => ({ role: m.role, content: m.content ?? '' })));
703
716
  if (existing) {
704
717
  existing.title = title;
@@ -829,10 +842,18 @@ function registerTicketsRoutes(deps) {
829
842
  let flipTarget;
830
843
  if (draftTicketId !== null) {
831
844
  flipTarget = s.tickets[String(draftTicketId)];
832
- if (!flipTarget || flipTarget.status !== 'draft') {
845
+ if (!flipTarget) {
833
846
  explicitDraftMissing = true;
834
847
  return;
835
848
  }
849
+ if (flipTarget.status !== 'draft') {
850
+ // Already committed (lost-response retry / second tab / watchdog
851
+ // re-fire): return the committed ticket idempotently instead of a
852
+ // spurious 404. The draft was flipped on the first call.
853
+ created = flipTarget;
854
+ wasFlip = true;
855
+ return;
856
+ }
836
857
  }
837
858
  else if (conversationId) {
838
859
  flipTarget = Object.values(s.tickets).find((t) => t.origin_conversation_id === conversationId && t.status === 'draft');
@@ -953,7 +974,13 @@ function registerTicketsRoutes(deps) {
953
974
  // are checked inside runContractRefine. Claude-only today — codex
954
975
  // contract refine isn't wired (the spawn hardcodes the `claude`
955
976
  // binary). Skip silently on codex projects.
956
- if (conversationId && created && project.provider === 'claude') {
977
+ // Gate on the CONVERSATION's own engine, not the project's PRIMARY
978
+ // provider: a multi-provider project with a non-claude primary can still
979
+ // run an Explore on the claude engine + opt into Contract Refine.
980
+ const convoProvider = conversationId
981
+ ? ((0, db_1.getConversation)(ctx(req).db, conversationId)?.provider ?? 'claude')
982
+ : null;
983
+ if (conversationId && created && convoProvider === 'claude') {
957
984
  const createdTicketId = created.id;
958
985
  const convoId = conversationId;
959
986
  console.log(`[project-router] from-draft hook: scheduling refine ticket=${createdTicketId} conv=${convoId}`);
@@ -970,8 +997,8 @@ function registerTicketsRoutes(deps) {
970
997
  });
971
998
  });
972
999
  }
973
- else if (conversationId && created && project.provider === 'codex') {
974
- console.log(`[project-router] from-draft contract refine skipped for codex project (ticket #${created.id})`);
1000
+ else if (conversationId && created && convoProvider && convoProvider !== 'claude') {
1001
+ console.log(`[project-router] from-draft contract refine skipped for ${convoProvider} conversation (ticket #${created.id})`);
975
1002
  }
976
1003
  }
977
1004
  catch (err) {
@@ -61,7 +61,7 @@ function createProjectRouter(registry) {
61
61
  const hooksRouterByCtx = new WeakMap();
62
62
  router.use('/:projectId/hooks', (req, res, next) => {
63
63
  const projectCtx = ctx(req);
64
- const hooksRouter = memoizedSubRouter(hooksRouterByCtx, projectCtx, () => (0, hooks_1.createHooksRouter)(projectCtx.broadcast, projectCtx.db, {
64
+ const hooksRouter = memoizedSubRouter(hooksRouterByCtx, projectCtx, () => (0, hooks_1.createHooksRouter)(projectCtx.project.id, projectCtx.broadcast, projectCtx.db, {
65
65
  get current() { return projectCtx.queueManager.getActiveJobId(); },
66
66
  set current(_) { },
67
67
  }));
@@ -263,7 +263,9 @@ function extractClaudeResult(events) {
263
263
  num_turns: resultPayload.num_turns,
264
264
  model: resultPayload.model,
265
265
  duration_ms: resultPayload.duration_ms,
266
- duration_api_ms: resultPayload.api_duration_ms,
266
+ // The Claude Code CLI emits `duration_api_ms`; the old `api_duration_ms`
267
+ // lookup always resolved undefined (persisted NULL). Read both, real first.
268
+ duration_api_ms: (resultPayload.duration_api_ms ?? resultPayload.api_duration_ms),
267
269
  session_id: finalSessionId,
268
270
  };
269
271
  }
@@ -29,6 +29,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
29
29
  exports._GEMINI_MIN_VERSION = exports.geminiAdapter = void 0;
30
30
  exports._compareSemver = compareSemver;
31
31
  const child_process_1 = require("child_process");
32
+ const win_spawn_1 = require("../util/win-spawn");
32
33
  const gemini_agent_ack_1 = require("./gemini-agent-ack");
33
34
  const WHICH_CMD = process.platform === 'win32' ? 'where' : 'which';
34
35
  // Floor where `--output-format stream-json` + headless `--resume` are available.
@@ -180,16 +181,20 @@ function compareSemver(a, b) {
180
181
  }
181
182
  async function detectGeminiInstalled() {
182
183
  try {
183
- (0, child_process_1.execSync)(`${WHICH_CMD} gemini`, { stdio: 'ignore' });
184
+ (0, child_process_1.execSync)(`${WHICH_CMD} gemini`, { stdio: 'ignore', env: (0, win_spawn_1.windowsSpawnEnv)() });
184
185
  }
185
186
  catch {
186
187
  return { installed: false, executable: false };
187
188
  }
188
189
  try {
190
+ // gemini is a Node CLI; its cold `--version` (cmd.exe → node → load bundle,
191
+ // Defender scanning) can exceed a few seconds on Windows. Give it a generous
192
+ // budget + SystemRoot env so it isn't wrongly reported not-executable.
189
193
  const raw = (0, child_process_1.execSync)('gemini --version', {
190
194
  encoding: 'utf-8',
191
195
  stdio: ['pipe', 'pipe', 'ignore'],
192
- timeout: 3000,
196
+ timeout: process.platform === 'win32' ? 20_000 : 8_000,
197
+ env: (0, win_spawn_1.windowsSpawnEnv)(),
193
198
  }).trim();
194
199
  const match = raw.match(/\d+\.\d+\.\d+/);
195
200
  const version = match ? match[0] : raw;
@@ -142,6 +142,7 @@ class QueueManager {
142
142
  * fallback model name stamped onto the ai_invocations row. */
143
143
  _resolvedModel;
144
144
  _onJobFinished;
145
+ _onBudgetExceeded;
145
146
  /** Project ID used for OTEL resource attributes (Super mode only) */
146
147
  _projectId;
147
148
  /** Server port used to construct the OTLP endpoint URL for env injection */
@@ -194,6 +195,7 @@ class QueueManager {
194
195
  this._adapter = (0, providers_1.getAdapter)(options?.provider ?? 'claude');
195
196
  this._resolvedModel = options?.resolvedModel ?? null;
196
197
  this._onJobFinished = options?.onJobFinished ?? null;
198
+ this._onBudgetExceeded = options?.onBudgetExceeded ?? null;
197
199
  this._projectId = options?.projectId ?? null;
198
200
  this._desktopPort = options?.desktopPort ?? 4200;
199
201
  this._projectSlug = options?.projectSlug ?? null;
@@ -625,11 +627,51 @@ class QueueManager {
625
627
  console.error(`[QueueManager] _startJob(${nextJobId}) threw before spawn: ${err?.message}`);
626
628
  // Only release if we never established a child (else _onJobExit owns cleanup).
627
629
  if (this._activeJobId === nextJobId && this._activeProcess === null) {
630
+ // Stamp the job terminal — _onJobExit never runs without a child, so the
631
+ // job would otherwise wedge 'running' forever and never fire
632
+ // onJobFinished (rail/webhook never settle) and leak its per-job maps.
633
+ this._failWedgedJob(nextJobId, err?.message ?? 'startup failure');
628
634
  this._activeJobId = null;
629
635
  this._drainQueue();
630
636
  }
631
637
  });
632
638
  }
639
+ /**
640
+ * Stamp a job terminal-failed when `_startJob` threw BEFORE a child was ever
641
+ * established (so no `_onJobExit` will run). Mirrors _onJobExit's terminal
642
+ * bookkeeping: in-memory + DB status, per-job map cleanup, onJobFinished, and
643
+ * a queue-state broadcast. Best-effort and never throws.
644
+ */
645
+ _failWedgedJob(jobId, reason) {
646
+ const job = this._jobs.get(jobId);
647
+ if (job && job.status === 'running') {
648
+ job.status = 'failed';
649
+ job.finishedAt = new Date().toISOString();
650
+ }
651
+ // Clear per-job selection/snapshot maps (none were consumed by a spawn).
652
+ this._jobExecution.delete(jobId);
653
+ this._snapshotRefs.delete(jobId);
654
+ this._jobModelSelection.delete(jobId);
655
+ this._jobProfileSelection.delete(jobId);
656
+ this._jobInteractiveSelection.delete(jobId);
657
+ if (this._db) {
658
+ try {
659
+ (0, db_1.finishJob)(this._db, jobId, { exit_code: -1, status: 'failed' });
660
+ }
661
+ catch {
662
+ /* DB may be closed mid-shutdown — never throw from the drain catch */
663
+ }
664
+ }
665
+ try {
666
+ this._onJobFinished?.(jobId, 'failed', undefined);
667
+ }
668
+ catch {
669
+ /* onJobFinished is best-effort */
670
+ }
671
+ this._persistQueueState();
672
+ this._broadcastQueueState();
673
+ console.error(`[QueueManager] job ${jobId} failed before spawn: ${reason}`);
674
+ }
633
675
  /**
634
676
  * Resolve the adapter for a job at spawn time: the per-job provider override
635
677
  * (consumed from `_jobProviderSelection`) when present and registered, else
@@ -858,12 +900,13 @@ class QueueManager {
858
900
  job.queuePosition = null;
859
901
  this._recomputePositions();
860
902
  this._persistJob(job);
903
+ const phaseScopeId = this._projectId ?? this._cwd ?? 'default';
861
904
  const commandPhases = this._phasesForCommand(job.command);
862
905
  if (commandPhases.length > 0) {
863
- (0, hooks_1.setActivePhases)(commandPhases, this._broadcast);
906
+ (0, hooks_1.setActivePhases)(phaseScopeId, commandPhases, this._broadcast);
864
907
  }
865
908
  else {
866
- (0, hooks_1.resetPhases)(this._broadcast);
909
+ (0, hooks_1.resetPhases)(phaseScopeId, this._broadcast);
867
910
  }
868
911
  const commandToRun = job.command.trim();
869
912
  // Build supplementary context (output chaining + headless mode) that goes
@@ -1177,6 +1220,14 @@ class QueueManager {
1177
1220
  });
1178
1221
  this._activeProcess = child;
1179
1222
  this._activeJobId = jobId;
1223
+ // Honour a cancel that arrived during the async pre-spawn window (it recorded
1224
+ // intent in _cancelingJobs via _kill but found no child yet). SIGTERM the
1225
+ // just-spawned child now; the close handler wired below fires _onJobExit,
1226
+ // which reads _cancelingJobs and stamps 'canceled'. No early-return — the
1227
+ // exit handler MUST still be wired so the job settles.
1228
+ if (this._cancelingJobs.has(jobId)) {
1229
+ this._kill(jobId);
1230
+ }
1180
1231
  // Without this listener, an ENOENT (e.g. claude not on PATH) propagates
1181
1232
  // as an unhandled 'error' event and crashes the entire app. Node still
1182
1233
  // emits 'close' afterwards, so the existing close handler fails the job
@@ -1529,6 +1580,10 @@ class QueueManager {
1529
1580
  this._db.prepare(`INSERT OR REPLACE INTO queue_state (key, value) VALUES ('paused', 'true')`).run();
1530
1581
  }
1531
1582
  this._broadcast({ type: 'daily_budget_exceeded', projectId: '', dailySpend, budget: dailyBudget, queuePaused: true });
1583
+ try {
1584
+ this._onBudgetExceeded?.('daily_budget_exceeded', { dailySpend, budget: dailyBudget, queuePaused: true });
1585
+ }
1586
+ catch { /* webhook delivery is best-effort */ }
1532
1587
  }
1533
1588
  }
1534
1589
  }
@@ -1542,6 +1597,10 @@ class QueueManager {
1542
1597
  this._db.prepare(`INSERT OR REPLACE INTO queue_state (key, value) VALUES ('paused', 'true')`).run();
1543
1598
  }
1544
1599
  this._broadcast({ type: 'desktop_daily_budget_exceeded', projectId: '', desktopDailySpend: desktopTotalSpend, desktopBudget, queuePaused: true });
1600
+ try {
1601
+ this._onBudgetExceeded?.('desktop_daily_budget_exceeded', { desktopDailySpend: desktopTotalSpend, desktopBudget, queuePaused: true });
1602
+ }
1603
+ catch { /* webhook delivery is best-effort */ }
1545
1604
  }
1546
1605
  }
1547
1606
  }
@@ -1626,6 +1685,13 @@ class QueueManager {
1626
1685
  this._kill(jobId);
1627
1686
  }
1628
1687
  _kill(jobId) {
1688
+ // Record the cancel intent UP FRONT — even when no child exists yet. A
1689
+ // cancel arriving during the async pre-spawn window (plugin verify / profile
1690
+ // snapshot) would otherwise early-return below before recording intent, the
1691
+ // child would later spawn unobserved, and _onJobExit would stamp 'completed'.
1692
+ // With the intent recorded, the post-spawn check in _startJob SIGTERMs the
1693
+ // child once it exists, and _onJobExit reads it to stamp 'canceled'.
1694
+ this._cancelingJobs.add(jobId);
1629
1695
  if (!this._activeProcess || !this._activeProcess.pid)
1630
1696
  return;
1631
1697
  this._clearZombieTimer();
@@ -1636,7 +1702,6 @@ class QueueManager {
1636
1702
  clearTimeout(this._killTimer);
1637
1703
  this._killTimer = null;
1638
1704
  }
1639
- this._cancelingJobs.add(jobId);
1640
1705
  (0, tree_kill_1.default)(this._activeProcess.pid, 'SIGTERM');
1641
1706
  const pid = this._activeProcess.pid;
1642
1707
  this._killTimer = setTimeout(() => {
@@ -80,7 +80,8 @@ function normaliseResultEvent(event, provider = 'claude') {
80
80
  num_turns: event.num_turns,
81
81
  model: event.model,
82
82
  duration_ms: event.duration_ms,
83
- duration_api_ms: event.api_duration_ms,
83
+ // CLI emits `duration_api_ms`; keep the legacy alias as a fallback.
84
+ duration_api_ms: (event.duration_api_ms ?? event.api_duration_ms),
84
85
  session_id: event.session_id,
85
86
  };
86
87
  }
@@ -26,6 +26,7 @@ const bundled_core_1 = require("./bundled-core");
26
26
  const bundled_openspec_1 = require("./bundled-openspec");
27
27
  const path_resolver_1 = require("./path-resolver");
28
28
  const framework_manager_1 = require("./framework-manager");
29
+ const workspace_resolution_1 = require("./workspace-resolution");
29
30
  /**
30
31
  * specrails-core's installer (Node-native from v4.2.0 onward, bash
31
32
  * prior) always scaffolds into `.claude/` regardless of which AI
@@ -668,6 +669,14 @@ class SetupManager {
668
669
  _checkpoints;
669
670
  // Track checkpoint start times
670
671
  _checkpointStart;
672
+ // Project ids whose install/enrich was explicitly aborted — the close handler
673
+ // checks this to suppress a spurious setup_error / double onSetupDone when the
674
+ // SIGTERM'd child closes with a null exit code.
675
+ _abortedProjects = new Set();
676
+ // projectId → registry slug (captured at startInstall) so post-install
677
+ // validation/summary/checkpoint reads resolve the RELOCATED workspace artifact
678
+ // root rather than the (pristine) repo path.
679
+ _projectSlugs = new Map();
671
680
  // Ring buffer for install log lines — allows clients to recover log on reconnect
672
681
  _installLogBuffer;
673
682
  // Track each project's chosen AI provider for binary selection
@@ -692,6 +701,9 @@ class SetupManager {
692
701
  }
693
702
  // ─── Full Install: TUI installer (npx specrails-core) ────────────────────────
694
703
  startInstall(projectId, projectPath, projectSlug, provider) {
704
+ this._abortedProjects.delete(projectId); // fresh run — clear any prior abort flag
705
+ if (projectSlug)
706
+ this._projectSlugs.set(projectId, projectSlug);
695
707
  if (this._installProcesses.has(projectId)) {
696
708
  console.warn(`[SetupManager] install already running for ${projectId}`);
697
709
  return;
@@ -874,8 +886,15 @@ class SetupManager {
874
886
  catch { /* non-fatal */ }
875
887
  }
876
888
  this._installProcesses.delete(projectId);
889
+ // Explicit abort already tore down + fired onSetupDone — don't surface a
890
+ // spurious setup_error for the SIGTERM'd child's null exit.
891
+ if (this._abortedProjects.has(projectId))
892
+ return;
877
893
  if (code === 0) {
878
- const validation = validateInstalledCore(projectPath);
894
+ // Relocated installs write artifacts to the workspace, not the repo —
895
+ // validate/sweep/summarize against the resolved artifact root.
896
+ const artifactRoot = this._artifactRoot(projectId, projectPath);
897
+ const validation = validateInstalledCore(artifactRoot);
879
898
  if (!validation.ok) {
880
899
  this._broadcast({
881
900
  type: 'setup_error',
@@ -886,8 +905,8 @@ class SetupManager {
886
905
  }
887
906
  this._advanceCheckpoint(projectId, 'base_install');
888
907
  this._completeCheckpoint(projectId, 'base_install');
889
- const legacySrRemoved = sweepLegacySrCommands(projectPath);
890
- const summary = { ...computeSummary(projectPath, tier, this._projectProviders.get(projectId) ?? 'claude'), legacySrRemoved };
908
+ const legacySrRemoved = sweepLegacySrCommands(artifactRoot);
909
+ const summary = { ...computeSummary(artifactRoot, tier, this._projectProviders.get(projectId) ?? 'claude'), legacySrRemoved };
891
910
  this._broadcast({
892
911
  type: 'setup_install_done',
893
912
  projectId,
@@ -909,6 +928,7 @@ class SetupManager {
909
928
  }
910
929
  // ─── Enrich: claude -p "/specrails:enrich --from-config" ────────────────────
911
930
  startEnrich(projectId, projectPath, provider, projectName) {
931
+ this._abortedProjects.delete(projectId); // fresh run — clear any prior abort flag
912
932
  if (this._setupProcesses.has(projectId)) {
913
933
  console.warn(`[SetupManager] enrich already running for ${projectId}`);
914
934
  return;
@@ -1132,21 +1152,28 @@ class SetupManager {
1132
1152
  child.on('close', (code) => {
1133
1153
  this._setupProcesses.delete(projectId);
1134
1154
  this._stopFilesystemPoll(projectId);
1155
+ // Explicit abort already tore down + fired onSetupDone — suppress the
1156
+ // failure branch (spurious error + a second onSetupDone) here.
1157
+ if (this._abortedProjects.has(projectId))
1158
+ return;
1135
1159
  // Final filesystem sync
1136
1160
  this._syncFilesystemCheckpoints(projectId, projectPath);
1137
1161
  if (code === 0) {
1138
1162
  // Sync filesystem checkpoints
1139
1163
  this._syncFilesystemCheckpoints(projectId, projectPath);
1164
+ // Relocated installs write artifacts to the workspace, not the repo —
1165
+ // resolve the has-agents/has-commands gate + summary against it.
1166
+ const artifactRoot = this._artifactRoot(projectId, projectPath);
1140
1167
  // Check if setup is truly complete — real artifacts must exist
1141
- const hasAgents = (0, fs_1.existsSync)((0, path_1.join)(projectPath, SPECRAILS_DIR, 'agents')) &&
1142
- hasFiles((0, path_1.join)(projectPath, SPECRAILS_DIR, 'agents'), /^sr-.*\.md$/);
1143
- const hasCommands = (((0, fs_1.existsSync)((0, path_1.join)(projectPath, SPECRAILS_DIR, 'commands', 'sr')) && hasFiles((0, path_1.join)(projectPath, SPECRAILS_DIR, 'commands', 'sr'), /\.md$/)) ||
1144
- ((0, fs_1.existsSync)((0, path_1.join)(projectPath, SPECRAILS_DIR, 'commands', 'specrails')) && hasFiles((0, path_1.join)(projectPath, SPECRAILS_DIR, 'commands', 'specrails'), /\.md$/)));
1168
+ const hasAgents = (0, fs_1.existsSync)((0, path_1.join)(artifactRoot, SPECRAILS_DIR, 'agents')) &&
1169
+ hasFiles((0, path_1.join)(artifactRoot, SPECRAILS_DIR, 'agents'), /^sr-.*\.md$/);
1170
+ const hasCommands = (((0, fs_1.existsSync)((0, path_1.join)(artifactRoot, SPECRAILS_DIR, 'commands', 'sr')) && hasFiles((0, path_1.join)(artifactRoot, SPECRAILS_DIR, 'commands', 'sr'), /\.md$/)) ||
1171
+ ((0, fs_1.existsSync)((0, path_1.join)(artifactRoot, SPECRAILS_DIR, 'commands', 'specrails')) && hasFiles((0, path_1.join)(artifactRoot, SPECRAILS_DIR, 'commands', 'specrails'), /\.md$/)));
1145
1172
  const isComplete = hasAgents && hasCommands;
1146
1173
  if (isComplete) {
1147
- const legacySrRemoved = sweepLegacySrCommands(projectPath);
1174
+ const legacySrRemoved = sweepLegacySrCommands(artifactRoot);
1148
1175
  const tier = this._projectTiers.get(projectId) ?? 'full';
1149
- const summary = { ...computeSummary(projectPath, tier, this._projectProviders.get(projectId) ?? 'claude'), legacySrRemoved };
1176
+ const summary = { ...computeSummary(artifactRoot, tier, this._projectProviders.get(projectId) ?? 'claude'), legacySrRemoved };
1150
1177
  this._onSetupDone?.(projectId);
1151
1178
  this._broadcast({
1152
1179
  type: 'setup_complete',
@@ -1229,11 +1256,27 @@ class SetupManager {
1229
1256
  checkpoint.duration_ms = duration_ms;
1230
1257
  this._broadcast({ type: 'setup_checkpoint', projectId, checkpoint: key, status: 'done', duration_ms });
1231
1258
  }
1259
+ /** Resolve the artifact root for post-install reads. For a RELOCATED project
1260
+ * (registry entry + populated workspace) the `.specrails`/provider dirs live
1261
+ * in the workspace, NOT the pristine repo path — `resolveProjectExecution`
1262
+ * returns that cwd. Falls back to the repo path (legacy) when no slug is
1263
+ * known or the project isn't relocated. */
1264
+ _artifactRoot(projectId, projectPath) {
1265
+ const slug = this._projectSlugs.get(projectId);
1266
+ if (!slug)
1267
+ return projectPath;
1268
+ try {
1269
+ return (0, workspace_resolution_1.resolveProjectExecution)({ slug, path: projectPath }).cwd;
1270
+ }
1271
+ catch {
1272
+ return projectPath;
1273
+ }
1274
+ }
1232
1275
  _syncFilesystemCheckpoints(projectId, projectPath) {
1233
1276
  const statuses = this._checkpoints.get(projectId);
1234
1277
  if (!statuses)
1235
1278
  return;
1236
- const fsChecks = checkFilesystem(projectPath);
1279
+ const fsChecks = checkFilesystem(this._artifactRoot(projectId, projectPath));
1237
1280
  for (const [key, exists] of Object.entries(fsChecks)) {
1238
1281
  if (!exists)
1239
1282
  continue;
@@ -1268,9 +1311,14 @@ class SetupManager {
1268
1311
  }
1269
1312
  // ─── Abort ────────────────────────────────────────────────────────────────────
1270
1313
  abort(projectId) {
1314
+ // Mark aborted so the still-registered close handlers suppress their failure
1315
+ // branch (spurious setup_error + a second onSetupDone) when the SIGTERM'd
1316
+ // child closes with a null code. Cleared on the next startInstall/startEnrich.
1317
+ this._abortedProjects.add(projectId);
1271
1318
  this._stopFilesystemPoll(projectId);
1272
1319
  this._projectProviders.delete(projectId);
1273
1320
  this._projectTiers.delete(projectId);
1321
+ this._projectSlugs.delete(projectId);
1274
1322
  this._onSetupDone?.(projectId);
1275
1323
  const installChild = this._installProcesses.get(projectId);
1276
1324
  if (installChild?.pid) {
@@ -28,13 +28,19 @@ const WHICH_CMD = process.platform === 'win32' ? 'where' : 'which';
28
28
  * inherited a stripped env) — without it every bundled probe failed with a bogus
29
29
  * "bundle corrupted — reinstall the app".
30
30
  */
31
- function runVersionSpawn(cmd, args) {
32
- const opts = { env: (0, win_spawn_1.windowsSpawnEnv)(), encoding: 'utf-8', timeout: 5_000 };
31
+ function runVersionSpawn(cmd, args, timeoutMs = 5_000) {
32
+ const opts = { env: (0, win_spawn_1.windowsSpawnEnv)(), encoding: 'utf-8', timeout: timeoutMs };
33
33
  if (process.platform === 'win32') {
34
34
  return cross_spawn_1.default.sync(cmd, args, opts);
35
35
  }
36
36
  return (0, child_process_1.spawnSync)(cmd, args, opts);
37
37
  }
38
+ // A bundled/system CLI's `--version` cold start can be slow on Windows: a npm
39
+ // `.cmd` shim goes cmd.exe → node.exe → load the CLI bundle, while Defender
40
+ // scans it. The Node-based gemini CLI in particular exceeded the old 5s cap and
41
+ // was wrongly reported not-executable. Give version probes a generous Windows
42
+ // budget; the `where`/`which` LOCATE probe stays fast (5s) since it's a builtin.
43
+ const VERSION_PROBE_TIMEOUT_MS = process.platform === 'win32' ? 20_000 : 8_000;
38
44
  exports.MIN_VERSIONS = {
39
45
  node: '18.0.0',
40
46
  npm: '9.0.0',
@@ -53,7 +59,7 @@ function locateCommand(command) {
53
59
  }
54
60
  /** Run `<bin> <args>` and interpret the result as a `--version` probe. */
55
61
  function runProbe(bin, args) {
56
- const result = runVersionSpawn(bin, args);
62
+ const result = runVersionSpawn(bin, args, VERSION_PROBE_TIMEOUT_MS);
57
63
  if (result.error) {
58
64
  const err = result.error;
59
65
  return { executed: false, error: `${err.code ?? 'ERR'}: ${err.message}` };
@@ -11,9 +11,13 @@
11
11
  // manages crash-respawn / idle-kill) stay with the caller via hooks + the
12
12
  // returned result. Adopting it is behaviour-preserving: the caller keeps every
13
13
  // post-close decision it had, just without re-implementing the plumbing.
14
+ var __importDefault = (this && this.__importDefault) || function (mod) {
15
+ return (mod && mod.__esModule) ? mod : { "default": mod };
16
+ };
14
17
  Object.defineProperty(exports, "__esModule", { value: true });
15
18
  exports.runAiCliInvocation = runAiCliInvocation;
16
19
  const node_readline_1 = require("node:readline");
20
+ const tree_kill_1 = __importDefault(require("tree-kill"));
17
21
  const cli_prompt_1 = require("./util/cli-prompt");
18
22
  const STDERR_TAIL_CAP = 64 * 1024;
19
23
  /**
@@ -61,10 +65,28 @@ function runAiCliInvocation(hooks) {
61
65
  if (hooks.timeoutMs != null) {
62
66
  timer = setTimeout(() => {
63
67
  hooks.onTimeout?.();
64
- try {
65
- child.kill('SIGTERM');
68
+ // treeKill the whole subtree (a bare child.kill leaves grandchildren),
69
+ // then escalate to SIGKILL after a grace window if the child ignores
70
+ // SIGTERM — otherwise a hung, signal-swallowing CLI is orphaned for the
71
+ // host's lifetime. (Mirrors QueueManager._kill.)
72
+ if (child.pid) {
73
+ (0, tree_kill_1.default)(child.pid, 'SIGTERM');
74
+ const pid = child.pid;
75
+ const killTimer = setTimeout(() => {
76
+ try {
77
+ (0, tree_kill_1.default)(pid, 'SIGKILL', () => { });
78
+ }
79
+ catch { /* gone */ }
80
+ }, 2000);
81
+ killTimer.unref?.();
82
+ child.once('close', () => clearTimeout(killTimer));
83
+ }
84
+ else {
85
+ try {
86
+ child.kill('SIGTERM');
87
+ }
88
+ catch { /* already gone */ }
66
89
  }
67
- catch { /* already gone */ }
68
90
  settle({ code: null, timedOut: true, spawnFailed: false, child });
69
91
  }, hooks.timeoutMs);
70
92
  }
@@ -7,10 +7,14 @@ exports.parseSpendingFilters = parseSpendingFilters;
7
7
  const ALL_SURFACES = ['job', 'quick-spec', 'explore-spec', 'ai-edit', 'smash', 'file-summary'];
8
8
  function resolveRange(filters, now = new Date()) {
9
9
  const period = filters.period ?? '30d';
10
- if (period === 'custom' && filters.from && filters.to) {
11
- const fromMs = new Date(filters.from).getTime();
12
- const toMs = new Date(filters.to).getTime();
13
- const span = toMs - fromMs;
10
+ // Only take the custom branch when BOTH dates parse — otherwise
11
+ // `new Date(NaN).toISOString()` throws a RangeError (surfaced as a 500).
12
+ // Unparseable custom dates fall through to the default 30d range below.
13
+ const customFromMs = filters.from ? new Date(filters.from).getTime() : NaN;
14
+ const customToMs = filters.to ? new Date(filters.to).getTime() : NaN;
15
+ if (period === 'custom' && !Number.isNaN(customFromMs) && !Number.isNaN(customToMs)) {
16
+ const fromMs = customFromMs;
17
+ const span = customToMs - fromMs;
14
18
  return {
15
19
  from: filters.from,
16
20
  to: filters.to,