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
@@ -79,7 +79,7 @@ class JiraClient {
79
79
  const code = classify(res.status);
80
80
  const retryAfter = res.headers.get('Retry-After');
81
81
  const retryAfterMs = retryAfter ? parseRetryAfter(retryAfter) : undefined;
82
- return { ok: false, status: res.status, code, error: truncate(errText), ...(retryAfterMs ? { retryAfterMs } : {}) };
82
+ return { ok: false, status: res.status, code, error: truncate(errText), ...(retryAfterMs !== undefined ? { retryAfterMs } : {}) };
83
83
  }
84
84
  catch (err) {
85
85
  const msg = err instanceof Error ? err.message : String(err);
@@ -142,15 +142,27 @@ class JiraClient {
142
142
  * removed Oct 2025). Always pass an explicit `fields` array; page via
143
143
  * `nextPageToken`. On DC v2 this falls back to the classic /search.
144
144
  */
145
- searchJql(args) {
145
+ async searchJql(args) {
146
146
  if (this.cfg.apiVersion === '2') {
147
- // DC/Server: classic search with startAt/maxResults.
148
- return this.request('POST', '/search', {
147
+ // DC/Server: classic search with startAt/maxResults. The classic response
148
+ // carries {issues,total,startAt,maxResults} — NOT nextPageToken — so
149
+ // SYNTHESIZE a nextPageToken (= next startAt) while more pages remain.
150
+ // Without this the poll loop only ever read the first 100 issues.
151
+ const startAt = args.nextPageToken ? parseInt(args.nextPageToken, 10) || 0 : 0;
152
+ const maxResults = args.maxResults ?? 100;
153
+ const res = await this.request('POST', '/search', {
149
154
  jql: args.jql,
150
155
  fields: args.fields,
151
- maxResults: args.maxResults ?? 100,
152
- startAt: args.nextPageToken ? parseInt(args.nextPageToken, 10) || 0 : 0,
156
+ maxResults,
157
+ startAt,
153
158
  });
159
+ if (!res.ok)
160
+ return res;
161
+ const issues = res.data.issues ?? [];
162
+ const total = res.data.total ?? issues.length;
163
+ const nextStart = startAt + issues.length;
164
+ const nextPageToken = issues.length > 0 && nextStart < total ? String(nextStart) : undefined;
165
+ return { ok: true, status: res.status, data: { issues, nextPageToken, isLast: nextPageToken === undefined } };
154
166
  }
155
167
  return this.request('POST', '/search/jql', {
156
168
  jql: args.jql,
@@ -216,8 +228,23 @@ class JiraClient {
216
228
  * GET /issue/{id}/comment?expand=properties — used to dedup comments via the
217
229
  * self-marker (invisible comment property, with a legacy body fallback).
218
230
  */
219
- getComments(issueIdOrKey) {
220
- return this.request('GET', `/issue/${encodeURIComponent(issueIdOrKey)}/comment?expand=properties`);
231
+ async getComments(issueIdOrKey) {
232
+ const all = [];
233
+ let startAt = 0;
234
+ const maxResults = 100;
235
+ for (let page = 0; page < 50; page++) {
236
+ const enc = encodeURIComponent(issueIdOrKey);
237
+ const res = await this.request('GET', `/issue/${enc}/comment?expand=properties&startAt=${startAt}&maxResults=${maxResults}`);
238
+ if (!res.ok)
239
+ return res;
240
+ const batch = res.data.comments ?? [];
241
+ all.push(...batch);
242
+ const total = res.data.total ?? all.length;
243
+ startAt += batch.length;
244
+ if (batch.length === 0 || startAt >= total)
245
+ break;
246
+ }
247
+ return { ok: true, status: 200, data: { comments: all } };
221
248
  }
222
249
  // ─── Read-only details panel (issue fields + Development) ────────────────────
223
250
  /** GET /issue/{id}?fields=*all — the full system + custom field map (untyped). */
@@ -38,9 +38,18 @@ function getOrCreateKey(keyfilePath = defaultKeyfilePath()) {
38
38
  const key = Buffer.from(raw, 'base64');
39
39
  if (key.length === 32)
40
40
  return key;
41
+ // File exists but the decoded key is the wrong length (truncated/corrupt) —
42
+ // fall through to regenerate. A WRONG-LENGTH existing key is unrecoverable
43
+ // anyway, so rotating is the only option.
41
44
  }
42
- catch {
43
- // Missing or corrupt — (re)create below.
45
+ catch (err) {
46
+ // ONLY (re)create when the file genuinely doesn't exist. A transient FS
47
+ // error (EACCES/EBUSY/EMFILE/momentary unreadability) must NOT rotate the
48
+ // key — doing so makes every previously-stored Jira token across all
49
+ // projects permanently undecryptable. Rethrow so the caller sees the real
50
+ // error instead of silently destroying credentials.
51
+ if (err.code !== 'ENOENT')
52
+ throw err;
44
53
  }
45
54
  const key = crypto_1.default.randomBytes(32);
46
55
  const dir = path_1.default.dirname(keyfilePath);
@@ -186,9 +186,15 @@ function upsertIssuesIntoStore(db, projectPath, conn, issues, frozenLocalIds = n
186
186
  let anyChange = false;
187
187
  for (const issue of issues) {
188
188
  const u = issue.fields.updated ? Date.parse(issue.fields.updated) : 0;
189
- if (!Number.isNaN(u) && u > maxUpdatedMs)
190
- maxUpdatedMs = u;
191
189
  const link = (0, jira_db_1.getLinkByIssueId)(db, issue.id);
190
+ // Do NOT advance the high-water past a FROZEN issue (one with a pending
191
+ // outbox op whose content we suppress): if the op later dead-letters without
192
+ // the Jira `updated` timestamp changing, a high-water moved past it would
193
+ // never re-fetch the suppressed remote change → permanently stale cache.
194
+ // Only count issues whose content we actually reconcile here.
195
+ const isFrozen = link ? frozenLocalIds.has(link.localId) : false;
196
+ if (!Number.isNaN(u) && u > maxUpdatedMs && !isFrozen)
197
+ maxUpdatedMs = u;
192
198
  if (!link) {
193
199
  anyChange = true;
194
200
  continue;
@@ -444,8 +444,17 @@ class JiraSyncManager {
444
444
  const frozen = this.frozenLocalIds();
445
445
  let jql = `project = "${conn.jiraProjectKey}" ORDER BY updated ASC`;
446
446
  if (!full && conn.highWaterMs && conn.highWaterMs > 0) {
447
- const since = formatJqlDate(conn.highWaterMs - POLL_OVERLAP_MS);
448
- jql = `project = "${conn.jiraProjectKey}" AND updated >= "${since}" ORDER BY updated ASC`;
447
+ // RELATIVE JQL date (`-Nm`) instead of an absolute wall-clock string: an
448
+ // absolute `formatJqlDate(...)` is emitted in UTC but Jira interprets a
449
+ // bare JQL datetime in the ACCOUNT timezone, so on a behind-UTC instance
450
+ // the floor rose above the high-water and silently skipped hours of
451
+ // updates. Relative dates evaluate against Jira's own clock with no TZ
452
+ // wall-clock interpretation, eliminating the gap. The high-water is derived
453
+ // from Jira's own `updated` values, so minutes-ago from the server clock
454
+ // (within the 2-min overlap for skew) maps to the right Jira floor.
455
+ const sinceFloorMs = conn.highWaterMs - POLL_OVERLAP_MS;
456
+ const minutesAgo = Math.max(1, Math.ceil((Date.now() - sinceFloorMs) / 60_000));
457
+ jql = `project = "${conn.jiraProjectKey}" AND updated >= "-${minutesAgo}m" ORDER BY updated ASC`;
449
458
  }
450
459
  let nextPageToken;
451
460
  let totalUpserted = 0;
@@ -42,6 +42,11 @@ function framedSend(ch, data) {
42
42
  * group is complete. Non-chunk frames pass straight through. */
43
43
  class ChunkReassembler {
44
44
  _groups = new Map();
45
+ // Bounds against a malicious/buggy peer: an unvalidated `n` would
46
+ // `new Array(n).fill('')` (RangeError crash for huge/negative n, or a huge
47
+ // allocation), and never-completing groups would leak forever.
48
+ static MAX_CHUNKS = 1024;
49
+ static MAX_GROUPS = 64;
45
50
  /** Returns the string to handle (the frame itself, or a completed
46
51
  * reassembly), or null when more chunks of the group are still pending. */
47
52
  push(data) {
@@ -56,12 +61,25 @@ class ChunkReassembler {
56
61
  }
57
62
  if (!p || p.n == null)
58
63
  return data;
64
+ // Validate counts/index BEFORE allocating: positive integer n within the
65
+ // ceiling, and i a valid index in [0, n).
66
+ if (!Number.isInteger(p.n) || p.n <= 0 || p.n > ChunkReassembler.MAX_CHUNKS)
67
+ return null;
68
+ if (!Number.isInteger(p.i) || p.i < 0 || p.i >= p.n)
69
+ return null;
59
70
  let grp = this._groups.get(p.g);
60
71
  if (!grp) {
72
+ // Cap concurrent groups; drop the oldest incomplete one when over the cap
73
+ // so a flood of never-completed groups can't grow memory unbounded.
74
+ if (this._groups.size >= ChunkReassembler.MAX_GROUPS) {
75
+ const oldest = this._groups.keys().next().value;
76
+ if (oldest !== undefined)
77
+ this._groups.delete(oldest);
78
+ }
61
79
  grp = { n: p.n, parts: new Array(p.n).fill(''), got: 0 };
62
80
  this._groups.set(p.g, grp);
63
81
  }
64
- if (grp.parts[p.i] === '' && grp.got < grp.n) {
82
+ if (p.i < grp.parts.length && grp.parts[p.i] === '' && grp.got < grp.n) {
65
83
  grp.parts[p.i] = p.d;
66
84
  grp.got++;
67
85
  }
@@ -198,8 +198,8 @@ class MobileGateway {
198
198
  signalBase,
199
199
  doFetch: (url, init) => fetch(url, init),
200
200
  rooms: () => (0, mobile_devices_1.listDevices)(this._db).filter((d) => !d.revoked).map((d) => d.id),
201
- makeOffer: () => this.webrtcOffer(),
202
- acceptAnswer: (sdp) => this.webrtcAnswer(sdp),
201
+ makeOffer: (room) => this.webrtcOffer(room),
202
+ acceptAnswer: (room, sdp) => this.webrtcAnswer(sdp, room),
203
203
  });
204
204
  this._signal.start();
205
205
  }
@@ -242,18 +242,21 @@ class MobileGateway {
242
242
  /** Open a serverless WebRTC pairing offer (for the first QR). Returns the offer
243
243
  * SDP + a single-use secret + the desktop identity; the webview encodes these
244
244
  * into the QR the companion scans. */
245
- async webrtcOffer() {
245
+ async webrtcOffer(room) {
246
246
  if (!this._webrtc)
247
247
  return null;
248
248
  const secret = (0, crypto_1.randomBytes)(16).toString('base64url');
249
- const { sdp } = await this._webrtc.createOffer(secret);
249
+ // Per-room slot for reconnect (room = device id); undefined ⇒ the QR slot.
250
+ const { sdp } = room !== undefined ? await this._webrtc.createOffer(secret, room) : await this._webrtc.createOffer(secret);
250
251
  return { sdp, secret, hubName: this.desktopName(), hubInstanceId: this.instanceId() };
251
252
  }
252
- /** Apply the companion's scanned answer SDP to the open offer. */
253
- async webrtcAnswer(sdp) {
253
+ /** Apply the companion's scanned answer SDP to the open offer (per-room slot
254
+ * for reconnect; the QR slot when no room given). */
255
+ async webrtcAnswer(sdp, room) {
254
256
  if (!this._webrtc)
255
257
  return false;
256
- return (await this._webrtc.acceptAnswer(sdp)).ok;
258
+ const res = room !== undefined ? await this._webrtc.acceptAnswer(sdp, room) : await this._webrtc.acceptAnswer(sdp);
259
+ return res.ok;
257
260
  }
258
261
  }
259
262
  exports.MobileGateway = MobileGateway;
@@ -41,7 +41,7 @@ class MobileSignalReconnect {
41
41
  // A phone asking to reconnect? → answer with a fresh offer.
42
42
  const req = await this._get(room, 'req');
43
43
  if (req !== null) {
44
- const offer = await this._deps.makeOffer();
44
+ const offer = await this._deps.makeOffer(room);
45
45
  if (offer) {
46
46
  await this._post(room, 'offer', JSON.stringify({ sdp: offer.sdp, sec: offer.secret, hub: offer.hubInstanceId, name: offer.hubName }));
47
47
  }
@@ -52,7 +52,7 @@ class MobileSignalReconnect {
52
52
  try {
53
53
  const parsed = JSON.parse(ans);
54
54
  if (typeof parsed.sdp === 'string')
55
- await this._deps.acceptAnswer(parsed.sdp);
55
+ await this._deps.acceptAnswer(room, parsed.sdp);
56
56
  }
57
57
  catch {
58
58
  /* malformed answer — ignore */
@@ -4,24 +4,32 @@ exports.MobileWebrtcGateway = void 0;
4
4
  const werift_1 = require("werift");
5
5
  const auth_1 = require("../auth");
6
6
  const mobile_datachannel_1 = require("./mobile-datachannel");
7
+ /** Default slot for the QR-pairing flow (a single un-keyed offer at a time). */
8
+ const QR_KEY = '__qr__';
7
9
  class MobileWebrtcGateway {
8
10
  _deps;
9
- _pending = null;
11
+ // Pending (un-answered) offers keyed by room/device id. A single slot would be
12
+ // CLOBBERED when multiple devices reconnect in one poll cycle: device B's
13
+ // createOffer closed device A's still-open offer, and acceptAnswer applied A's
14
+ // answer to whatever happened to be pending. Per-room keying isolates them.
15
+ _pending = new Map();
10
16
  _active = new Set();
11
17
  constructor(_deps) {
12
18
  this._deps = _deps;
13
19
  }
14
- /** Drop the open (un-answered) offer. */
15
- clearOffer() {
16
- const p = this._pending;
17
- this._pending = null;
18
- if (p)
20
+ /** Drop the open (un-answered) offer for one room (default = QR slot). */
21
+ clearOffer(key = QR_KEY) {
22
+ const p = this._pending.get(key);
23
+ if (p) {
24
+ this._pending.delete(key);
19
25
  this._close(p.pc);
26
+ }
20
27
  }
21
28
  /** Create a fresh pairing offer (peer + DataChannel + gathered offer SDP).
22
- * Replaces any previously-open offer. Returns the SDP to embed in the QR. */
23
- async createOffer(secret) {
24
- this.clearOffer();
29
+ * Replaces any previously-open offer FOR THE SAME ROOM. Returns the SDP to
30
+ * embed in the QR / post to the room's mailbox. */
31
+ async createOffer(secret, key = QR_KEY) {
32
+ this.clearOffer(key);
25
33
  const pc = new werift_1.RTCPeerConnection({ iceServers: [] });
26
34
  const dc = pc.createDataChannel('mobile');
27
35
  // The companion peer activates when the channel opens. It validates `hello`
@@ -38,8 +46,9 @@ class MobileWebrtcGateway {
38
46
  console.log('[webrtc] pc connectionState:', s);
39
47
  if (s === 'failed' || s === 'closed' || s === 'disconnected') {
40
48
  this._active.delete(pc);
41
- if (this._pending?.pc === pc)
42
- this._pending = null;
49
+ // Drop this pc from whichever room slot still holds it.
50
+ if (this._pending.get(key)?.pc === pc)
51
+ this._pending.delete(key);
43
52
  }
44
53
  });
45
54
  const offer = await pc.createOffer();
@@ -51,20 +60,20 @@ class MobileWebrtcGateway {
51
60
  throw new Error('no local description after ICE gathering');
52
61
  }
53
62
  console.log(`[webrtc] offer ready: ${(sdp.match(/a=candidate/g) ?? []).length} ICE candidates`);
54
- this._pending = { pc, secret };
63
+ this._pending.set(key, { pc, secret });
55
64
  return { sdp };
56
65
  }
57
- /** Apply the companion's scanned answer SDP to the open offer and keep the
58
- * connection alive for the session. */
59
- async acceptAnswer(sdp) {
60
- const p = this._pending;
66
+ /** Apply the companion's scanned answer SDP to the open offer FOR THE SAME ROOM
67
+ * and keep the connection alive for the session. */
68
+ async acceptAnswer(sdp, key = QR_KEY) {
69
+ const p = this._pending.get(key);
61
70
  if (!p)
62
71
  return { ok: false };
63
72
  try {
64
73
  console.log(`[webrtc] applying answer: ${(sdp.match(/a=candidate/g) ?? []).length} ICE candidates`);
65
74
  await p.pc.setRemoteDescription({ type: 'answer', sdp });
66
75
  this._active.add(p.pc);
67
- this._pending = null;
76
+ this._pending.delete(key);
68
77
  return { ok: true };
69
78
  }
70
79
  catch {
@@ -73,7 +82,8 @@ class MobileWebrtcGateway {
73
82
  }
74
83
  /** Tear down every peer (gateway stopping / disabled). */
75
84
  stop() {
76
- this.clearOffer();
85
+ for (const key of [...this._pending.keys()])
86
+ this.clearOffer(key);
77
87
  for (const pc of this._active)
78
88
  this._close(pc);
79
89
  this._active.clear();
@@ -202,6 +202,13 @@ class MobileWsBridge {
202
202
  this.send(s, (0, mobile_redact_1.redact)(toMobileWire(msg)));
203
203
  continue;
204
204
  }
205
+ // App-level desktop budget alert: deliver to EVERY subscriber holding the
206
+ // `alerts` topic regardless of which project tripped it (boundBroadcast
207
+ // stamps it with one project id, but it's an app-wide alert).
208
+ if (topic === 'alerts' && msg.type === 'desktop_daily_budget_exceeded') {
209
+ this.send(s, (0, mobile_redact_1.redact)(toMobileWire(msg)));
210
+ continue;
211
+ }
205
212
  const projectId = msg.projectId;
206
213
  if (!projectId || !s.projects.has(projectId))
207
214
  continue;
@@ -120,6 +120,25 @@ function fastPathDirectories() {
120
120
  }
121
121
  return [];
122
122
  }
123
+ /**
124
+ * Well-known Windows directories that hold globally-installed CLI shims
125
+ * (`claude.cmd` / `codex.cmd` / `gemini.cmd`) which a GUI-launched (Explorer/
126
+ * Tauri) process may not have on its inherited PATH. The per-user npm prefix
127
+ * places `.cmd` shims DIRECTLY in the prefix root (`%APPDATA%\npm`), not a `bin`
128
+ * subdir. `npm prefix -g` is the authoritative location for a custom prefix.
129
+ */
130
+ function windowsGlobalBinDirs() {
131
+ if (process.platform !== 'win32')
132
+ return [];
133
+ const dirs = [];
134
+ // Default per-user npm prefix: shims (`claude.cmd` …) live in the prefix ROOT.
135
+ if (process.env.APPDATA)
136
+ dirs.push(path_1.default.join(process.env.APPDATA, 'npm'));
137
+ // Machine-wide Node install (also where a machine-scope npm prefix points).
138
+ if (process.env.ProgramFiles)
139
+ dirs.push(path_1.default.join(process.env.ProgramFiles, 'nodejs'));
140
+ return dirs;
141
+ }
123
142
  /**
124
143
  * Returns the absolute path to the bundled runtimes directory.
125
144
  * Only valid when SPECRAILS_IS_DESKTOP=1 and SPECRAILS_BUNDLED_RUNTIMES_PATH is set.
@@ -200,9 +219,26 @@ function resolveStartupPath() {
200
219
  const inherited = splitPath(process.env.PATH);
201
220
  const inheritedSet = new Set(inherited);
202
221
  if (process.platform === 'win32') {
222
+ // Prepend well-known global-CLI dirs (npm prefix, Program Files\nodejs) that
223
+ // a GUI-launched process may lack, so provider shims (claude/codex/gemini
224
+ // .cmd) — and any RECURSIVE bare-name invocation by a spawned CLI — resolve.
225
+ // Existence-gated + deduped; no-op when already present (the common case).
226
+ const winPrepend = [];
227
+ for (const dir of windowsGlobalBinDirs()) {
228
+ if (dir && fileExists(dir) && !inheritedSet.has(dir)) {
229
+ winPrepend.push(dir);
230
+ inheritedSet.add(dir);
231
+ }
232
+ }
233
+ const winMerged = [...winPrepend, ...inherited];
234
+ if (winPrepend.length > 0)
235
+ process.env.PATH = joinPath(winMerged);
203
236
  diagnostic = {
204
- pathSegments: inherited,
205
- pathSources: inherited.map(() => 'inherited'),
237
+ pathSegments: winMerged,
238
+ pathSources: [
239
+ ...winPrepend.map(() => 'fast-path'),
240
+ ...inherited.map(() => 'inherited'),
241
+ ],
206
242
  loginShellStatus: 'skipped',
207
243
  };
208
244
  return;
@@ -252,7 +252,7 @@ class PluginManager {
252
252
  return out;
253
253
  }
254
254
  // ─── Install ───────────────────────────────────────────────────────────────
255
- async install(projectPath, projectId, name, broadcast, providerId) {
255
+ async install(projectPath, projectId, name, broadcast, providerId, slug) {
256
256
  const plugin = this.registry.byName.get(name);
257
257
  if (!plugin)
258
258
  throw new PluginNotFoundError(name);
@@ -327,6 +327,7 @@ class PluginManager {
327
327
  const ctx = {
328
328
  projectPath,
329
329
  projectId,
330
+ slug,
330
331
  providerId,
331
332
  recordInstalledFile: (rel) => { installedFiles.push(rel); },
332
333
  log: onLog,
@@ -383,6 +384,19 @@ class PluginManager {
383
384
  // the next install attempt.
384
385
  }
385
386
  }
387
+ // For cli-add providers (codex), a successful `codex mcp add` lives in
388
+ // CODEX_HOME, NOT the snapshotted filesystem — the file rollback above
389
+ // can't undo it. Run the plugin's uninstall so a verify-failed codex
390
+ // install leaves no orphaned MCP registration (best-effort).
391
+ if (providerId && (0, providers_1.hasAdapter)(providerId) && (0, providers_1.getAdapter)(providerId).mcpRegistration === 'cli-add') {
392
+ try {
393
+ await plugin.uninstall({
394
+ projectPath, projectId, slug, providerId,
395
+ recordInstalledFile: () => { }, log: onLog,
396
+ });
397
+ }
398
+ catch { /* best-effort rollback of the codex registration */ }
399
+ }
386
400
  throw err instanceof PluginInstallError ? err : new PluginInstallError(`install of '${name}' failed: ${err?.message ?? String(err)}`, err);
387
401
  }
388
402
  const msg = {
@@ -395,7 +409,7 @@ class PluginManager {
395
409
  broadcast(msg);
396
410
  }
397
411
  // ─── Uninstall ─────────────────────────────────────────────────────────────
398
- async uninstall(projectPath, projectId, name, broadcast, providerId) {
412
+ async uninstall(projectPath, projectId, name, broadcast, providerId, slug) {
399
413
  const state = this.getProjectState(projectPath);
400
414
  const entry = state.plugins[name];
401
415
  if (!entry)
@@ -417,6 +431,7 @@ class PluginManager {
417
431
  await plugin.uninstall({
418
432
  projectPath,
419
433
  projectId,
434
+ slug,
420
435
  providerId,
421
436
  recordInstalledFile: () => { },
422
437
  log: onLog,
@@ -24,12 +24,13 @@ function isCodex(ctx) {
24
24
  return false;
25
25
  return (0, providers_1.getAdapter)(ctx.providerId).mcpRegistration === 'cli-add';
26
26
  }
27
- /** Project slug used by codex-mcp helpers — derived from the project path's
28
- * basename. The app maintains the canonical slug elsewhere (ProjectRegistry);
29
- * for the install context we don't have the registry, but the basename is a
30
- * stable per-project identifier sufficient for CODEX_HOME isolation. */
31
- function slugFromProjectPath(projectPath) {
32
- return path_1.default.basename(projectPath);
27
+ /** Resolve the canonical per-project slug for codex-mcp CODEX_HOME isolation.
28
+ * PREFER the registry slug threaded through the lifecycle context (the SAME
29
+ * slug used by ~/.specrails/projects/<slug>/ everywhere else). Fall back to the
30
+ * path basename only when absent — basenames collide between same-named repos
31
+ * and would point CODEX_HOME at a shared/wrong dir. */
32
+ function resolveCodexSlug(ctx) {
33
+ return ctx.slug && ctx.slug.trim().length > 0 ? ctx.slug : path_1.default.basename(ctx.projectPath);
33
34
  }
34
35
  async function installSerena(ctx) {
35
36
  if (isCodex(ctx)) {
@@ -40,7 +41,7 @@ async function installSerena(ctx) {
40
41
  if (!entry) {
41
42
  throw new Error('serena manifest is missing providerSupport.codex.mcpEntry');
42
43
  }
43
- const slug = slugFromProjectPath(ctx.projectPath);
44
+ const slug = resolveCodexSlug(ctx);
44
45
  ctx.log(`Registering serena MCP via 'codex mcp add' (CODEX_HOME=~/.specrails/projects/${slug}/codex-home/)`);
45
46
  const result = (0, codex_mcp_1.codexMcpAdd)(slug, 'serena', entry);
46
47
  if (!result.ok) {
@@ -61,7 +62,7 @@ async function installSerena(ctx) {
61
62
  }
62
63
  async function uninstallSerena(ctx) {
63
64
  if (isCodex(ctx)) {
64
- const slug = slugFromProjectPath(ctx.projectPath);
65
+ const slug = resolveCodexSlug(ctx);
65
66
  // Probe first so removing an already-removed server doesn't surface as an
66
67
  // error (e.g. the user uninstalled via terminal then via the app).
67
68
  const listing = (0, codex_mcp_1.codexMcpList)(slug);
@@ -79,7 +79,7 @@ function createPluginsRouter() {
79
79
  router.post('/:name/install', async (req, res) => {
80
80
  try {
81
81
  const { project, broadcast } = ctx(req);
82
- await (0, manager_1.getPluginManager)().install(pluginRoot(project), project.id, req.params.name, broadcast, project.provider);
82
+ await (0, manager_1.getPluginManager)().install(pluginRoot(project), project.id, req.params.name, broadcast, project.provider, project.slug);
83
83
  res.status(200).json({ ok: true });
84
84
  }
85
85
  catch (err) {
@@ -90,7 +90,7 @@ function createPluginsRouter() {
90
90
  router.delete('/:name', async (req, res) => {
91
91
  try {
92
92
  const { project, broadcast } = ctx(req);
93
- await (0, manager_1.getPluginManager)().uninstall(pluginRoot(project), project.id, req.params.name, broadcast, project.provider);
93
+ await (0, manager_1.getPluginManager)().uninstall(pluginRoot(project), project.id, req.params.name, broadcast, project.provider, project.slug);
94
94
  res.status(200).json({ ok: true });
95
95
  }
96
96
  catch (err) {
@@ -7,6 +7,7 @@ exports.ProfileConflictError = exports.ProfileNotFoundError = exports.ProfileVal
7
7
  exports.validateProfile = validateProfile;
8
8
  exports.listProfiles = listProfiles;
9
9
  exports.getProfile = getProfile;
10
+ exports.getProfileRaw = getProfileRaw;
10
11
  exports.createProfile = createProfile;
11
12
  exports.updateProfile = updateProfile;
12
13
  exports.deleteProfile = deleteProfile;
@@ -198,6 +199,30 @@ function getProfile(projectPath, name, expectedProvider = 'claude') {
198
199
  const raw = JSON.parse(fs_1.default.readFileSync(full, 'utf8'));
199
200
  return validateProfile(raw, expectedProvider);
200
201
  }
202
+ /**
203
+ * Non-validating read for the editor: returns the parsed profile body plus any
204
+ * schema/structural validation errors (instead of throwing). A profile that
205
+ * became invalid against the current agent catalog must still be openable so the
206
+ * user can repair it — `getProfile` would throw and lock it out. JSON-parse
207
+ * errors still throw (unrepairable in the editor). `validateProfile` remains the
208
+ * gate on create/update/launch.
209
+ */
210
+ function getProfileRaw(projectPath, name, expectedProvider = 'claude') {
211
+ assertValidName(name);
212
+ const full = profilePath(projectPath, name);
213
+ if (!fs_1.default.existsSync(full)) {
214
+ throw new ProfileNotFoundError(name);
215
+ }
216
+ const raw = JSON.parse(fs_1.default.readFileSync(full, 'utf8'));
217
+ try {
218
+ validateProfile(raw, expectedProvider);
219
+ return { profile: raw, valid: true, errors: [] };
220
+ }
221
+ catch (err) {
222
+ const errors = err instanceof ProfileValidationError ? err.errors : [err.message];
223
+ return { profile: raw, valid: false, errors };
224
+ }
225
+ }
201
226
  function createProfile(projectPath, profile, expectedProvider = 'claude') {
202
227
  assertValidName(profile.name);
203
228
  validateProfile(profile, expectedProvider);
@@ -235,6 +260,11 @@ function duplicateProfile(projectPath, sourceName, newName, expectedProvider = '
235
260
  return copy;
236
261
  }
237
262
  function renameProfile(projectPath, fromName, toName, expectedProvider = 'claude') {
263
+ // Renaming the default away would leave no default → resolveProfile returns
264
+ // null and rails silently drop to legacy/no-profile mode. Mirror deleteProfile.
265
+ if (fromName === 'default' || fromName === 'project-default') {
266
+ throw new ProfileValidationError(['cannot rename the default profile']);
267
+ }
238
268
  const source = getProfile(projectPath, fromName, expectedProvider);
239
269
  assertValidName(toName);
240
270
  if (fs_1.default.existsSync(profilePath(projectPath, toName))) {
@@ -742,7 +742,11 @@ function createProfilesRouter() {
742
742
  router.get('/:name', (req, res) => {
743
743
  try {
744
744
  const { project } = ctx(req);
745
- res.json({ profile: (0, profile_manager_1.getProfile)(specRoot(project), req.params.name, project.provider ?? 'claude') });
745
+ // Non-validating read so a profile that drifted invalid against the current
746
+ // catalog can still be opened + repaired in the editor (getProfile threw and
747
+ // locked it out). The body + validation errors are both returned.
748
+ const { profile, valid, errors } = (0, profile_manager_1.getProfileRaw)(specRoot(project), req.params.name, project.provider ?? 'claude');
749
+ res.json({ profile, valid, validationErrors: errors });
746
750
  }
747
751
  catch (err) {
748
752
  handleError(res, err);
@@ -23,6 +23,9 @@ const ticket_watcher_1 = require("./ticket-watcher");
23
23
  const terminal_manager_1 = require("./terminal-manager");
24
24
  const browser_capture_manager_1 = require("./browser-capture-manager");
25
25
  const explore_cwd_manager_1 = require("./explore-cwd-manager");
26
+ const hooks_1 = require("./hooks");
27
+ const transient_children_1 = require("./transient-children");
28
+ const telemetry_receiver_1 = require("./telemetry-receiver");
26
29
  const artifact_registry_1 = require("./artifact-registry");
27
30
  const workspace_resolution_1 = require("./workspace-resolution");
28
31
  const workspace_manager_1 = require("./workspace-manager");
@@ -153,6 +156,11 @@ class ProjectRegistry {
153
156
  ctx.setupManager.abort(id);
154
157
  }
155
158
  catch { /* ignore */ }
159
+ // Kill untracked fire-and-forget children (Quick spec-gen) for this project.
160
+ try {
161
+ (0, transient_children_1.killTransientChildren)(id);
162
+ }
163
+ catch { /* ignore */ }
156
164
  // M12: these three also spawn children that outlive removeProject. Proposal
157
165
  // and AgentRefine write to the per-project DB in their close handlers — if
158
166
  // not disposed before db.close() they throw on the closed connection and
@@ -196,6 +204,16 @@ class ProjectRegistry {
196
204
  (0, explore_cwd_manager_1.removeExploreCwd)(ctx.project.slug);
197
205
  }
198
206
  catch { /* ignore — non-fatal */ }
207
+ // Drop this project's per-project phase-tracking scope (avoid a leak).
208
+ try {
209
+ (0, hooks_1.dropPhaseScope)(id);
210
+ }
211
+ catch { /* ignore */ }
212
+ // Drop any in-memory telemetry BlobState entries for this project.
213
+ try {
214
+ (0, telemetry_receiver_1.dropBlobStatesForProject)(id);
215
+ }
216
+ catch { /* ignore */ }
199
217
  // Close the DB connection BEFORE removing the project's data dir below.
200
218
  try {
201
219
  ctx.db.close();
@@ -282,6 +300,17 @@ class ProjectRegistry {
282
300
  ctx.chatManager.shutdown();
283
301
  }
284
302
  catch { /* ignore */ }
303
+ // Install/enrich wizard children + the 3s install poll interval are NOT
304
+ // torn down by the spawner shutdowns above — mirror removeProject()'s
305
+ // setupManager.abort() so a quit mid-wizard doesn't orphan them.
306
+ try {
307
+ ctx.setupManager.abort(ctx.project.id);
308
+ }
309
+ catch { /* ignore */ }
310
+ try {
311
+ (0, transient_children_1.killTransientChildren)(ctx.project.id);
312
+ }
313
+ catch { /* ignore */ }
285
314
  try {
286
315
  ctx.proposalManager.shutdown();
287
316
  }
@@ -376,6 +405,16 @@ class ProjectRegistry {
376
405
  }
377
406
  return { budget, totalSpend };
378
407
  },
408
+ onBudgetExceeded: (event, data) => {
409
+ // Deliver the budget event to this project's subscribed webhooks — the
410
+ // WS broadcast alone never reached webhook subscribers, so a
411
+ // daily_budget_exceeded / desktop_daily_budget_exceeded subscription was
412
+ // dead. (Event names are in the webhook validEvents allow-list.)
413
+ try {
414
+ webhookManager.deliver(project.id, event, data);
415
+ }
416
+ catch { /* best-effort */ }
417
+ },
379
418
  onJobFinished: (jobId, status, costUsd) => {
380
419
  const jobRow = db.prepare('SELECT command, duration_ms FROM jobs WHERE id = ?').get(jobId);
381
420
  const event = status === 'completed' ? 'job.completed' : status === 'canceled' ? 'job.canceled' : 'job.failed';