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.
- package/client/dist/assets/ActivityFeedPage-B4O_62Cd.js +1 -0
- package/client/dist/assets/{AgentsPage-5UiKCp_t.js → AgentsPage-CzaYXMfF.js} +1 -1
- package/client/dist/assets/{AnalyticsPage-BwxD58MO.js → AnalyticsPage-CAVRiHEZ.js} +1 -1
- package/client/dist/assets/{BarChart-BaJJVLw4.js → BarChart-Cxmjvzk6.js} +1 -1
- package/client/dist/assets/{CodePage-3tgS4fU2.js → CodePage-3YuHn6oB.js} +1 -1
- package/client/dist/assets/{DesktopAnalyticsPage-BWJxqa4T.js → DesktopAnalyticsPage-dn0BKpBG.js} +1 -1
- package/client/dist/assets/{DocsDialog-DF5TF81L.js → DocsDialog-CjT3NrG-.js} +1 -1
- package/client/dist/assets/{DocsPage-DZDZPRLe.js → DocsPage-DB6K87CL.js} +1 -1
- package/client/dist/assets/{ExportDropdown-DFEuLgiK.js → ExportDropdown-DXGqkszG.js} +1 -1
- package/client/dist/assets/{IntegrationsPage-CuAShwlJ.js → IntegrationsPage-B6qF0tqT.js} +1 -1
- package/client/dist/assets/{JobDetailPage-Bx0wRTry.js → JobDetailPage-Dhk31VAB.js} +1 -1
- package/client/dist/assets/{JobsPage-COcbVVcd.js → JobsPage-B6Qn1z5m.js} +1 -1
- package/client/dist/assets/{dist-js-Cbm7C856.js → dist-js-CQtuspP9.js} +1 -1
- package/client/dist/assets/{dist-js-CgFhhWyd.js → dist-js-SJkboL9H.js} +1 -1
- package/client/dist/assets/{index-Wgxn2zBM.js → index-vNp6-zmg.js} +7 -7
- package/client/dist/assets/{lib-BhY8Pq4z.js → lib-C7QskAbA.js} +1 -1
- package/client/dist/assets/{useProjectCache-CDaO4kHu.js → useProjectCache-D3d_Np5F.js} +1 -1
- package/client/dist/index.html +1 -1
- package/package.json +1 -1
- package/server/dist/agent-generator.js +32 -12
- package/server/dist/artifact-registry.js +24 -4
- package/server/dist/chat-manager.js +13 -0
- package/server/dist/desktop-db.js +11 -2
- package/server/dist/desktop-router.js +22 -9
- package/server/dist/explore-stdin-session.js +14 -2
- package/server/dist/hooks.js +40 -30
- package/server/dist/index.js +5 -0
- package/server/dist/jira/jira-client.js +35 -8
- package/server/dist/jira/jira-credential-store.js +11 -2
- package/server/dist/jira/jira-materializer.js +8 -2
- package/server/dist/jira/jira-sync-manager.js +11 -2
- package/server/dist/mobile/mobile-datachannel.js +19 -1
- package/server/dist/mobile/mobile-gateway.js +10 -7
- package/server/dist/mobile/mobile-signal-reconnect.js +2 -2
- package/server/dist/mobile/mobile-webrtc-peer.js +28 -18
- package/server/dist/mobile/mobile-ws.js +7 -0
- package/server/dist/path-resolver.js +38 -2
- package/server/dist/plugin-manager.js +17 -2
- package/server/dist/plugins/serena/install.js +9 -8
- package/server/dist/plugins-router.js +2 -2
- package/server/dist/profile-manager.js +30 -0
- package/server/dist/profiles-router.js +5 -1
- package/server/dist/project-registry.js +39 -0
- package/server/dist/project-router-chat.js +18 -2
- package/server/dist/project-router-jobs.js +1 -1
- package/server/dist/project-router-spending.js +7 -3
- package/server/dist/project-router-tickets.js +34 -7
- package/server/dist/project-router.js +1 -1
- package/server/dist/providers/claude-adapter.js +3 -1
- package/server/dist/providers/gemini-adapter.js +7 -2
- package/server/dist/queue-manager.js +68 -3
- package/server/dist/result-event.js +2 -1
- package/server/dist/setup-manager.js +58 -10
- package/server/dist/setup-prerequisites.js +9 -3
- package/server/dist/spawn-lifecycle.js +25 -3
- package/server/dist/spending.js +8 -4
- package/server/dist/telemetry-compactor.js +37 -4
- package/server/dist/telemetry-receiver.js +15 -0
- package/server/dist/terminal-manager.js +10 -4
- package/server/dist/transient-children.js +53 -0
- 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
|
-
|
|
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
|
|
152
|
-
startAt
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
448
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
18
|
-
|
|
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
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
42
|
-
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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:
|
|
205
|
-
pathSources:
|
|
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
|
-
/**
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
|
|
32
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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';
|