specrails-desktop 2.53.0 → 2.54.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "specrails-desktop",
3
- "version": "2.53.0",
3
+ "version": "2.54.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -4,6 +4,7 @@ exports.MISSION_ACCESS_REASON = exports.MOBILE_PROTOCOL_VERSION = void 0;
4
4
  exports.mobileCapabilities = mobileCapabilities;
5
5
  exports.missionSnapshot = missionSnapshot;
6
6
  exports.createMobileMissionsRouter = createMobileMissionsRouter;
7
+ const feature_flags_1 = require("../feature-flags");
7
8
  const node_string_decoder_1 = require("node:string_decoder");
8
9
  const express_1 = require("express");
9
10
  const agent_store_1 = require("../agent-store");
@@ -17,7 +18,7 @@ exports.MOBILE_PROTOCOL_VERSION = 2;
17
18
  exports.MISSION_ACCESS_REASON = 'Mission agents can access more than their pinned project. Missions require an all-projects device grant; your permitted Specs and Rails remain available.';
18
19
  function mobileCapabilities(db, deviceId) {
19
20
  const missions = process.env.SPECRAILS_AGENT_CHAT !== 'false' && (0, mobile_devices_1.getAllowedProjects)(db, deviceId) === null;
20
- return { protocolVersion: exports.MOBILE_PROTOCOL_VERSION, features: { missions, missionControl: missions, missionQueue: missions, missionProcesses: missions, repositories: true },
21
+ return { protocolVersion: exports.MOBILE_PROTOCOL_VERSION, features: { missions, missionControl: missions, missionQueue: missions, missionProcesses: missions, repositories: true, missionRailCards: missions && (0, feature_flags_1.isMissionRailCardsEnabled)() },
21
22
  ...(!missions ? { missionsUnavailableReason: process.env.SPECRAILS_AGENT_CHAT === 'false' ? 'Missions are disabled on Desktop.' : exports.MISSION_ACCESS_REASON } : {}) };
22
23
  }
23
24
  function conversationView(value) {
@@ -56,7 +57,7 @@ function missionSnapshot(value) {
56
57
  try {
57
58
  const parsed = record(JSON.parse(raw));
58
59
  if (parsed.kind === 'pr_decision')
59
- card = { ...pick(parsed, ['kind', 'railIndex', 'ticketIds', 'decision', 'implementationOutcome', 'deliveryOutcome', 'prNumber']), repositoryDeliveries: (Array.isArray(parsed.repositoryDeliveries) ? parsed.repositoryDeliveries : []).slice(0, 30).map(value => pick(value, ['repositoryId', 'repositoryName', 'decision'])) };
60
+ card = { ...pick(parsed, ['kind', 'railIndex', 'ticketIds', 'decision', 'implementationOutcome', 'deliveryOutcome', 'prNumber', 'railName', 'phase', 'statusDetail', 'baseBranch']), runIds: (Array.isArray(parsed.runIds) ? parsed.runIds : []).filter(id => typeof id === 'string' && ID.test(id)).slice(0, 30), runtime: pick(parsed.runtime, ['status', 'currentStep', 'pendingApproval']), repositoryDeliveries: (Array.isArray(parsed.repositoryDeliveries) ? parsed.repositoryDeliveries : []).slice(0, 30).map(value => pick(value, ['repositoryId', 'repositoryName', 'decision'])) };
60
61
  }
61
62
  catch { /* Non-card system rows do not expose internal provider payloads. */ }
62
63
  }
@@ -65,7 +66,7 @@ function missionSnapshot(value) {
65
66
  break;
66
67
  }
67
68
  const content = record(message).role === 'system' ? '' : take(raw, 40_000);
68
- bounded.unshift({ ...pick(message, ['id', 'role', 'created_at', 'delivery_status', 'delivery_receipt']), content, ...(card ? { card } : {}) });
69
+ bounded.unshift({ ...pick(message, ['id', 'role', 'created_at', 'delivery_status', 'delivery_receipt']), content, intents: (Array.isArray(record(message).intents) ? record(message).intents : []).slice(0, 30).map(value => pick(value, ['kind', 'proposalIndex', 'status', 'railIndex', 'runIds'])), ...(card ? { card } : {}) });
69
70
  }
70
71
  return { conversation: conversationView(data.conversation), messages: bounded, truncated,
71
72
  pendingMessages, pendingTruncated: pending.length > 50,
@@ -90,7 +91,12 @@ function createMobileMissionsRouter({ db, upstream }) {
90
91
  res.status(400).json({ error: 'A valid provider is required' });
91
92
  return;
92
93
  }
93
- void send(res, 'GET', `/api/agent/models?provider=${encodeURIComponent(provider)}`);
94
+ const model = segment(req.query.model);
95
+ if (model.length > 200) {
96
+ res.status(400).json({ error: 'Invalid model' });
97
+ return;
98
+ }
99
+ void send(res, 'GET', `/api/agent/models?provider=${encodeURIComponent(provider)}${model ? `&model=${encodeURIComponent(model)}` : ''}`);
94
100
  });
95
101
  router.get('/projects/:pid/repositories', (req, res) => {
96
102
  const pid = segment(req.params.pid);
@@ -175,6 +181,22 @@ function createMobileMissionsRouter({ db, upstream }) {
175
181
  }
176
182
  void send(res, 'POST', `${id.base}/send`, { text: b.text, queueId: b.queueId, deliveryMode: b.deliveryMode ?? 'queue' }, data => pick(data, ['accepted', 'queued', 'duplicate', 'removed', 'deliveryMode']));
177
183
  });
184
+ router.patch('/projects/:pid/missions/:cid/messages/:mid/intent', (req, res) => {
185
+ const id = identity(req, res);
186
+ if (!id)
187
+ return;
188
+ const mid = segment(req.params.mid), b = record(req.body);
189
+ if (!ID.test(mid) || b.kind !== 'rail-launch' || !Number.isSafeInteger(b.proposalIndex) || Number(b.proposalIndex) < 0 || Number(b.proposalIndex) > 100) {
190
+ res.status(400).json({ error: 'Invalid proposal' });
191
+ return;
192
+ }
193
+ const message = db.prepare('SELECT conversation_id FROM agent_messages WHERE id = ?').get(mid);
194
+ if (!message || message.conversation_id !== segment(req.params.cid)) {
195
+ res.status(404).json({ error: 'Message not found in this mission' });
196
+ return;
197
+ }
198
+ void send(res, 'PATCH', `${id.base}/messages/${encodeURIComponent(mid)}/intent`, pick(b, ['kind', 'proposalIndex', 'status', 'railIndex', 'runIds', 'prDeliveryId']), data => ({ message: pick(record(data).message, ['id', 'intents']) }));
199
+ });
178
200
  router.post('/projects/:pid/missions/:cid/abort', (req, res) => { const id = identity(req, res); if (id)
179
201
  void send(res, 'POST', `${id.base}/abort`, {}); });
180
202
  for (const action of ['edit', 'steer', 'remove']) {
@@ -187,6 +187,23 @@ function createMobileRouter(deps) {
187
187
  return;
188
188
  void forward(res, 'DELETE', `/api/projects/${encodeURIComponent(pid)}/tickets/${encodeURIComponent(tid)}`, '');
189
189
  });
190
+ v1.post('/projects/:pid/rails', (req, res) => {
191
+ const pid = seg(req.params.pid);
192
+ if (!validate(res, [pid, PID_RE]))
193
+ return;
194
+ const name = req.body?.name;
195
+ if (name != null && (typeof name !== 'string' || name.length > 60)) {
196
+ res.status(400).json({ error: 'Invalid rail name' });
197
+ return;
198
+ }
199
+ void forward(res, 'POST', `/api/projects/${encodeURIComponent(pid)}/rails`, '', { name: name ?? null });
200
+ });
201
+ v1.get('/projects/:pid/profiles', (req, res) => {
202
+ const pid = seg(req.params.pid), provider = seg(req.query.provider);
203
+ if (!validate(res, [pid, PID_RE], [provider, PID_RE]))
204
+ return;
205
+ void forward(res, 'GET', `/api/projects/${encodeURIComponent(pid)}/profiles`, `?provider=${encodeURIComponent(provider)}`);
206
+ });
190
207
  v1.put('/projects/:pid/rails/:i/tickets', (req, res) => {
191
208
  const pid = seg(req.params.pid), i = seg(req.params.i);
192
209
  if (!validate(res, [pid, PID_RE], [i, NUM_RE]))
@@ -219,6 +236,33 @@ function createMobileRouter(deps) {
219
236
  // provider. This gateway only narrows to the cross-provider enum.
220
237
  narrowed.reasoning_effort = b.reasoning_effort;
221
238
  }
239
+ if (b.targetPrNumber !== undefined) {
240
+ if (!Number.isSafeInteger(b.targetPrNumber) || Number(b.targetPrNumber) < 1) {
241
+ res.status(400).json({ error: 'Invalid PR number' });
242
+ return;
243
+ }
244
+ narrowed.targetPrNumber = b.targetPrNumber;
245
+ }
246
+ if (b.baseBranch !== undefined) {
247
+ if (typeof b.baseBranch !== 'string' || !b.baseBranch.trim() || b.baseBranch.length > 200) {
248
+ res.status(400).json({ error: 'Invalid base branch' });
249
+ return;
250
+ }
251
+ narrowed.baseBranch = b.baseBranch;
252
+ }
253
+ if (b.originConversationId !== undefined) {
254
+ if (typeof b.originConversationId !== 'string' || !PID_RE.test(b.originConversationId)) {
255
+ res.status(400).json({ error: 'Invalid mission' });
256
+ return;
257
+ }
258
+ const conversation = deps.db.prepare('SELECT pinned_project_id FROM agent_conversations WHERE id = ?').get(b.originConversationId);
259
+ if (!conversation || conversation.pinned_project_id !== pid || (0, mobile_devices_1.getAllowedProjects)(deps.db, req.mobileDevice.id) !== null) {
260
+ res.status(403).json({ error: 'Mission is not accessible' });
261
+ return;
262
+ }
263
+ narrowed.originConversationId = b.originConversationId;
264
+ narrowed.originSurface = 'agent-chat';
265
+ }
222
266
  if (typeof b.interactive === 'boolean')
223
267
  narrowed.interactive = b.interactive;
224
268
  if (b.repositoryIds !== undefined) {
@@ -423,6 +467,7 @@ function createMobileRouter(deps) {
423
467
  exports.MOBILE_ALLOWLIST = [
424
468
  { method: 'GET', path: '/v1/capabilities' },
425
469
  { method: 'GET', path: '/v1/mission-models' },
470
+ { method: 'PATCH', path: '/v1/projects/:pid/missions/:cid/messages/:mid/intent' },
426
471
  { method: 'GET', path: '/v1/projects/:pid/repositories' },
427
472
  { method: 'GET', path: '/v1/projects/:pid/missions' },
428
473
  { method: 'POST', path: '/v1/projects/:pid/missions' },
@@ -445,6 +490,9 @@ exports.MOBILE_ALLOWLIST = [
445
490
  { method: 'GET', path: '/v1/projects/:pid/loop-runs/:id' },
446
491
  { method: 'GET', path: '/v1/projects/:pid/queue' },
447
492
  { method: 'GET', path: '/v1/projects/:pid/rails' },
493
+ { method: 'POST', path: '/v1/projects/:pid/rails' },
494
+ { method: 'GET', path: '/v1/projects/:pid/profiles' },
495
+ { method: 'PUT', path: '/v1/projects/:pid/rails/:i/name' },
448
496
  { method: 'GET', path: '/v1/projects/:pid/activity' },
449
497
  { method: 'GET', path: '/v1/projects/:pid/stats' },
450
498
  { method: 'GET', path: '/v1/projects/:pid/state' },
@@ -22,6 +22,7 @@ class MobileSignalReconnect {
22
22
  _deps;
23
23
  _timeoutMs;
24
24
  _timer = null;
25
+ _attempts = new Map();
25
26
  _busy = false;
26
27
  _epoch = 0;
27
28
  _requests = new Set();
@@ -34,9 +35,11 @@ class MobileSignalReconnect {
34
35
  return;
35
36
  this._timer = setInterval(() => void this.poll(), intervalMs);
36
37
  this._timer.unref?.();
38
+ void this.poll();
37
39
  }
38
40
  stop() {
39
41
  this._epoch++;
42
+ this._attempts.clear();
40
43
  for (const controller of this._requests)
41
44
  controller.abort();
42
45
  this._requests.clear();
@@ -61,6 +64,14 @@ class MobileSignalReconnect {
61
64
  if (epoch !== this._epoch)
62
65
  return;
63
66
  if (req !== null) {
67
+ let requestId = null;
68
+ try {
69
+ const value = JSON.parse(req);
70
+ if (typeof value?.requestId === 'string' && /^[a-zA-Z0-9_-]{8,80}$/.test(value.requestId))
71
+ requestId = value.requestId;
72
+ }
73
+ catch { /* legacy request */ }
74
+ this._attempts.set(room, requestId);
64
75
  const offer = await this._deps.makeOffer(room);
65
76
  if (epoch !== this._epoch)
66
77
  return;
@@ -68,7 +79,7 @@ class MobileSignalReconnect {
68
79
  // BUG-AUTH-01: never publish `offer.secret` to the public mailbox.
69
80
  // The reconnecting companion authenticates with its existing device
70
81
  // token; the secret would only help an eavesdropper.
71
- await this._post(room, 'offer', JSON.stringify({ sdp: offer.sdp, hub: offer.hubInstanceId, name: offer.hubName }));
82
+ await this._post(room, 'offer', JSON.stringify({ sdp: offer.sdp, hub: offer.hubInstanceId, name: offer.hubName, ...(requestId ? { requestId } : {}) }));
72
83
  }
73
84
  }
74
85
  // A phone's answer waiting? → complete the connection.
@@ -80,8 +91,11 @@ class MobileSignalReconnect {
80
91
  if (ans) {
81
92
  try {
82
93
  const parsed = JSON.parse(ans);
83
- if (typeof parsed.sdp === 'string')
84
- await this._deps.acceptAnswer(room, parsed.sdp);
94
+ const expected = this._attempts.get(room);
95
+ if (expected && parsed.requestId !== expected)
96
+ continue;
97
+ if (typeof parsed.sdp === 'string' && await this._deps.acceptAnswer(room, parsed.sdp))
98
+ this._attempts.delete(room);
85
99
  }
86
100
  catch {
87
101
  /* malformed answer — ignore */