specrails-desktop 2.53.0 → 2.55.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.
Files changed (32) hide show
  1. package/client/dist/assets/{ActivityFeedPage-CEWktcV8.js → ActivityFeedPage-ILXMZXcb.js} +1 -1
  2. package/client/dist/assets/{AgentBrowserCapture-Bnu8aoF2.js → AgentBrowserCapture-UcsupCy7.js} +1 -1
  3. package/client/dist/assets/{AgentModeAnalyticsPane-N0k6nlj0.js → AgentModeAnalyticsPane-CQuxYh8B.js} +2 -2
  4. package/client/dist/assets/{AgentModeCodePane-7s6TnZGe.js → AgentModeCodePane-zPWbW7ei.js} +2 -2
  5. package/client/dist/assets/{AgentModeJobsPane-Btsb5Sq4.js → AgentModeJobsPane-CInByIdl.js} +1 -1
  6. package/client/dist/assets/{AgentsPage-BXgiO5qZ.js → AgentsPage-BWvgxaBv.js} +1 -1
  7. package/client/dist/assets/{AnalyticsPage-CO8-ON4i.js → AnalyticsPage-DP3jakv-.js} +1 -1
  8. package/client/dist/assets/{CodePage-CMSInNsE.js → CodePage-BKXiwE5a.js} +1 -1
  9. package/client/dist/assets/{DesktopAnalyticsPage-1ekD8Gob.js → DesktopAnalyticsPage-DryJOaMz.js} +1 -1
  10. package/client/dist/assets/{DocsDialog-CZ1QztVB.js → DocsDialog-BsCG9ksG.js} +1 -1
  11. package/client/dist/assets/{DocsPage-DHdvuLeh.js → DocsPage-Cc-GeKYl.js} +1 -1
  12. package/client/dist/assets/{InteractiveJobComposer-DDYgxV10.js → InteractiveJobComposer-DfLF6N0f.js} +1 -1
  13. package/client/dist/assets/{JobDetailModal-DSsX48Vu.js → JobDetailModal-DHeur9YK.js} +1 -1
  14. package/client/dist/assets/{JobDetailPage-DbzshFhr.js → JobDetailPage-CraDKzeG.js} +1 -1
  15. package/client/dist/assets/{JobsPage-DY4qRt9_.js → JobsPage-CcbuSwj7.js} +1 -1
  16. package/client/dist/assets/{LoopBuilderPage-DTqzBtuf.js → LoopBuilderPage-BKtNp494.js} +1 -1
  17. package/client/dist/assets/{LoopPreviewModal-C-NbAvIZ.js → LoopPreviewModal-DbZd9isy.js} +1 -1
  18. package/client/dist/assets/{LoopsPage-C-RjiAxo.js → LoopsPage-DEg6sIkG.js} +1 -1
  19. package/client/dist/assets/{PluginsPage-BUdxGBro.js → PluginsPage-BVqSsPzL.js} +1 -1
  20. package/client/dist/assets/{ProjectSettingsDialog-e7y_uZIz.js → ProjectSettingsDialog-CAjLgJAZ.js} +1 -1
  21. package/client/dist/assets/{ReviewPacketPage-Bs_jaLbJ.js → ReviewPacketPage-D-UjTef3.js} +1 -1
  22. package/client/dist/assets/{TemplatePreviewModal-DoeJuvcY.js → TemplatePreviewModal-Bqs-9fhn.js} +1 -1
  23. package/client/dist/assets/{index-Cn-T8bgL.js → index-BfAZg5DG.js} +6 -6
  24. package/client/dist/index.html +1 -1
  25. package/docs/ci-cd.md +4 -0
  26. package/package.json +1 -1
  27. package/server/dist/agent-chat-manager.js +17 -7
  28. package/server/dist/agent-chat-router.js +1 -0
  29. package/server/dist/mobile/mobile-code.js +100 -0
  30. package/server/dist/mobile/mobile-missions.js +26 -4
  31. package/server/dist/mobile/mobile-router.js +60 -6
  32. package/server/dist/mobile/mobile-signal-reconnect.js +17 -3
@@ -204,7 +204,7 @@
204
204
  .specrails-splash__mark .pill { opacity: 1; }
205
205
  }
206
206
  </style>
207
- <script type="module" crossorigin src="/assets/index-Cn-T8bgL.js"></script>
207
+ <script type="module" crossorigin src="/assets/index-BfAZg5DG.js"></script>
208
208
  <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-CNC7AqOf.js">
209
209
  <link rel="modulepreload" crossorigin href="/assets/jsx-runtime-zu2_FqZY.js">
210
210
  <link rel="modulepreload" crossorigin href="/assets/react-dom-DiWzBDP9.js">
package/docs/ci-cd.md CHANGED
@@ -41,6 +41,10 @@ The macOS job retains `artifacts/chromium-signing/` as the `chromium-signing-mac
41
41
 
42
42
  ## Credentials and external requirements
43
43
 
44
+ ### Recover an already published Desktop download channel
45
+
46
+ If native builds and GitHub publication succeed but Hostinger installer uploads fail, run **Recover Desktop download channel** on `main` with the existing stable version (for example `2.54.0`). It requires successful main CI and successful native builds for that exact release commit, then streams all three published installers and verifies their sizes and GitHub SHA-256 digests. It atomically replaces only `latest/manifest.json` over verified FTPS, pointing downloads to the existing version-specific GitHub assets. It refuses version downgrades, verifies the public manifest after publication, and does not rebuild, delete installers, or alter the signed Tauri updater. Normal releases continue to use the regular distribution workflow.
47
+
44
48
  - `RELEASE_PAT` must be authorized for release-please's pull requests and releases. It also permits tag pushes to trigger publication workflows; events created with the repository's ordinary `GITHUB_TOKEN` generally do not trigger another workflow.
45
49
  - Publication uses one of two credentials. When the `NPM_TOKEN` secret is set, it must authenticate an account with publishing permission for `specrails-desktop` (npm now requires such tokens to be granular with *bypass 2FA*, and they expire within 90 days); the workflow checks authentication and gives an explicit error before publishing. When the secret is empty, the job publishes through npm **trusted publishing**: it installs npm 11, removes the empty token line that `setup-node` writes, and lets npm exchange the job's OIDC identity (`id-token: write`) for a short-lived publish grant. That path only works once the package's *Trusted publisher* on npmjs.com names this repository (`fjpulidop/specrails-desktop`) and workflow file (`npm-publish.yml`, no environment); registry configuration is never changed from here. Provenance does **not** grant publishing permission in either mode. An npm `E404` during publication is npm's answer to an unauthorized publish and does not identify which token or permission failed; an `E403` mentioning two-factor authentication means the stored token lacks *bypass 2FA*. [npm publishing and provenance](https://docs.github.com/en/actions/tutorials/publish-packages/publish-nodejs-packages).
46
50
  - Existing Apple signing/notarization and Tauri updater signing secrets remain required. Installer build steps retain their platform-specific checks.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "specrails-desktop",
3
- "version": "2.53.0",
3
+ "version": "2.55.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -194,6 +194,9 @@ class AgentChatManager {
194
194
  })),
195
195
  };
196
196
  }
197
+ notifyConversationCreated(conversationId) {
198
+ this._broadcast({ type: 'agent_conversation_created', conversationId, timestamp: new Date().toISOString() });
199
+ }
197
200
  _startLifecycle(conversationId) {
198
201
  const startedAt = new Date().toISOString();
199
202
  this._turnStartedAt.set(conversationId, startedAt);
@@ -400,13 +403,20 @@ class AgentChatManager {
400
403
  const currentMessageId = options.queueId ? (0, agent_input_store_1.getAgentInput)(this._db, conversationId, options.queueId)?.messageId : null;
401
404
  const historyBlock = (0, agent_context_resolver_1.buildAgentHistoryBlock)((0, agent_input_store_1.decorateAgentInputMessages)(this._db, (0, agent_store_1.listAgentMessages)(this._db, conversationId))
402
405
  .filter((message) => message.id !== currentMessageId && message.delivery_status !== 'cancelled' && message.delivery_status !== 'interrupted'));
403
- if (options.queueId) {
404
- if (!(0, agent_input_store_1.deliverAgentInput)(this._db, conversationId, options.queueId, 'sent'))
405
- return;
406
- }
407
- else {
408
- (0, agent_store_1.addAgentMessage)(this._db, { conversationId, role: 'user', content: userText, attachmentIds, contextRefs: options.contextRefs });
409
- }
406
+ const message = options.queueId
407
+ ? (0, agent_input_store_1.deliverAgentInput)(this._db, conversationId, options.queueId, 'sent')
408
+ : (0, agent_store_1.addAgentMessage)(this._db, { conversationId, role: 'user', content: userText, attachmentIds, contextRefs: options.contextRefs });
409
+ if (!message)
410
+ return;
411
+ // Queued turns were announced by the drain loop. Direct turns must also
412
+ // reach every open client, including Desktop when Companion sent the input.
413
+ if (!currentMessageId)
414
+ this._broadcast({
415
+ type: 'agent_input_started', conversationId, queueId: options.queueId ?? null,
416
+ messageId: message.id, text: message.content, timestamp: message.created_at,
417
+ contextRefs: options.contextRefs ?? [], attachmentIds: attachmentIds ?? [],
418
+ deliveryReceipt: 'sent',
419
+ });
410
420
  this._autoTitle(conversationId, conversation.title);
411
421
  // Providers WITHOUT a --system-prompt flag (codex, gemini) drop opts.systemPrompt
412
422
  // for chat turns, so the attachment prompt-injection note would never reach them —
@@ -199,6 +199,7 @@ function createAgentChatRouter(deps) {
199
199
  // Off-catalog values fall back to null (= no provider-specific override).
200
200
  reasoningEffort: validEffort(provider, effectiveModel, body.reasoningEffort) ?? null,
201
201
  });
202
+ manager.notifyConversationCreated(conversation.id);
202
203
  res.status(201).json({ conversation });
203
204
  });
204
205
  router.get('/conversations/:id', (req, res) => {
@@ -0,0 +1,100 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.relativeCodePath = relativeCodePath;
4
+ exports.createMobileCodeRouter = createMobileCodeRouter;
5
+ const express_1 = require("express");
6
+ const mobile_redact_1 = require("./mobile-redact");
7
+ const id = /^[A-Za-z0-9_-]{1,160}$/;
8
+ function relativeCodePath(value) {
9
+ return typeof value === 'string' && value.length > 0 && value.length <= 4096
10
+ && !/^[\\/]|^[A-Za-z]:|[\x00-\x1f\\]/.test(value)
11
+ && !value.split('/').some(part => part === '..' || part === '.' || part === '');
12
+ }
13
+ const record = (value) => value && typeof value === 'object' && !Array.isArray(value) ? value : {};
14
+ /** Project ACL/auth run on the parent router. Desktop remains authoritative for
15
+ * repository membership, realpath confinement, deny-list and gitignore rules.
16
+ * Only this explicit source DTO preserves source text verbatim: applying the
17
+ * generic absolute-path scrubber to code would silently corrupt its contents.
18
+ */
19
+ function createMobileCodeRouter(upstream) {
20
+ const router = (0, express_1.Router)();
21
+ for (const operation of ['tree', 'find', 'file']) {
22
+ router.get(`/projects/:pid/repositories/:rid/code/${operation}`, async (req, res) => {
23
+ const pid = req.params.pid, rid = req.params.rid;
24
+ if (!id.test(pid) || !id.test(rid)) {
25
+ res.status(400).json({ error: 'Invalid parameter' });
26
+ return;
27
+ }
28
+ const query = new URLSearchParams();
29
+ if (operation === 'tree') {
30
+ query.set('filter', 'all');
31
+ query.set('limit', '200');
32
+ if (req.query.cursor !== undefined) {
33
+ if (typeof req.query.cursor !== 'string' || req.query.cursor.length > 4096) {
34
+ res.status(400).json({ error: 'Invalid cursor' });
35
+ return;
36
+ }
37
+ query.set('cursor', req.query.cursor);
38
+ }
39
+ }
40
+ else if (operation === 'find') {
41
+ if (typeof req.query.q !== 'string' || !req.query.q.trim() || req.query.q.length > 256) {
42
+ res.status(400).json({ error: 'Invalid query' });
43
+ return;
44
+ }
45
+ query.set('q', req.query.q);
46
+ query.set('limit', '50');
47
+ }
48
+ else {
49
+ if (!relativeCodePath(req.query.path)) {
50
+ res.status(400).json({ error: 'Invalid relative path' });
51
+ return;
52
+ }
53
+ query.set('path', req.query.path);
54
+ for (const key of ['startLine', 'startColumn']) {
55
+ const value = req.query[key] ?? '1';
56
+ if (typeof value !== 'string' || !/^[1-9]\d{0,8}$/.test(value)) {
57
+ res.status(400).json({ error: 'Invalid range' });
58
+ return;
59
+ }
60
+ query.set(key, value);
61
+ }
62
+ query.set('endLine', String(Number(query.get('startLine')) + 199));
63
+ if (req.query.expectedHash !== undefined) {
64
+ if (typeof req.query.expectedHash !== 'string' || !/^[a-f0-9]{64}$/i.test(req.query.expectedHash)) {
65
+ res.status(400).json({ error: 'Invalid hash' });
66
+ return;
67
+ }
68
+ query.set('expectedHash', req.query.expectedHash);
69
+ }
70
+ }
71
+ try {
72
+ const response = await upstream('GET', `/api/projects/${encodeURIComponent(pid)}/repositories/${encodeURIComponent(rid)}/code/${operation}?${query}`);
73
+ if (response.status < 200 || response.status >= 300) {
74
+ res.status(response.status).json((0, mobile_redact_1.redact)(response.json));
75
+ return;
76
+ }
77
+ const data = record(response.json);
78
+ if (operation === 'file') {
79
+ if (!relativeCodePath(data.path) || data.path !== req.query.path || typeof data.content !== 'string') {
80
+ res.status(502).json({ error: 'Invalid source response' });
81
+ return;
82
+ }
83
+ res.json({ relativePath: data.path, content: data.content, language: data.language,
84
+ fileHash: data.fileHash, startLine: data.startLine, startColumn: data.startColumn,
85
+ endLine: data.endLine, totalLines: data.totalLines, nextLine: data.nextLine, nextColumn: data.nextColumn });
86
+ }
87
+ else {
88
+ const rows = data[operation === 'tree' ? 'entries' : 'matches'];
89
+ res.json({ entries: (Array.isArray(rows) ? rows : []).map(record).filter(row => relativeCodePath(row.path)).map(row => ({
90
+ relativePath: row.path, kind: row.kind === 'dir' ? 'dir' : 'file', sizeBytes: row.sizeBytes,
91
+ })), nextCursor: operation === 'tree' ? data.nextCursor : null, truncated: data.truncated === true });
92
+ }
93
+ }
94
+ catch {
95
+ res.status(502).json({ error: 'Code explorer unavailable' });
96
+ }
97
+ });
98
+ }
99
+ return router;
100
+ }
@@ -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, codeExplorer: (0, feature_flags_1.isCodeExplorerEnabled)(), 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']) {
@@ -8,6 +8,7 @@ const mobile_redact_1 = require("./mobile-redact");
8
8
  const mobile_auth_1 = require("./mobile-auth");
9
9
  const mobile_devices_1 = require("./mobile-devices");
10
10
  const mobile_missions_1 = require("./mobile-missions");
11
+ const mobile_code_1 = require("./mobile-code");
11
12
  // The gateway's authenticated REST surface: the `/v1/*` allow-list. Each route
12
13
  // forwards, in-process, via a REAL loopback HTTP request to
13
14
  // http://127.0.0.1:<desktopPort> with the master token injected server-side as
@@ -187,6 +188,23 @@ function createMobileRouter(deps) {
187
188
  return;
188
189
  void forward(res, 'DELETE', `/api/projects/${encodeURIComponent(pid)}/tickets/${encodeURIComponent(tid)}`, '');
189
190
  });
191
+ v1.post('/projects/:pid/rails', (req, res) => {
192
+ const pid = seg(req.params.pid);
193
+ if (!validate(res, [pid, PID_RE]))
194
+ return;
195
+ const name = req.body?.name;
196
+ if (name != null && (typeof name !== 'string' || name.length > 60)) {
197
+ res.status(400).json({ error: 'Invalid rail name' });
198
+ return;
199
+ }
200
+ void forward(res, 'POST', `/api/projects/${encodeURIComponent(pid)}/rails`, '', { name: name ?? null });
201
+ });
202
+ v1.get('/projects/:pid/profiles', (req, res) => {
203
+ const pid = seg(req.params.pid), provider = seg(req.query.provider);
204
+ if (!validate(res, [pid, PID_RE], [provider, PID_RE]))
205
+ return;
206
+ void forward(res, 'GET', `/api/projects/${encodeURIComponent(pid)}/profiles`, `?provider=${encodeURIComponent(provider)}`);
207
+ });
190
208
  v1.put('/projects/:pid/rails/:i/tickets', (req, res) => {
191
209
  const pid = seg(req.params.pid), i = seg(req.params.i);
192
210
  if (!validate(res, [pid, PID_RE], [i, NUM_RE]))
@@ -219,6 +237,33 @@ function createMobileRouter(deps) {
219
237
  // provider. This gateway only narrows to the cross-provider enum.
220
238
  narrowed.reasoning_effort = b.reasoning_effort;
221
239
  }
240
+ if (b.targetPrNumber !== undefined) {
241
+ if (!Number.isSafeInteger(b.targetPrNumber) || Number(b.targetPrNumber) < 1) {
242
+ res.status(400).json({ error: 'Invalid PR number' });
243
+ return;
244
+ }
245
+ narrowed.targetPrNumber = b.targetPrNumber;
246
+ }
247
+ if (b.baseBranch !== undefined) {
248
+ if (typeof b.baseBranch !== 'string' || !b.baseBranch.trim() || b.baseBranch.length > 200) {
249
+ res.status(400).json({ error: 'Invalid base branch' });
250
+ return;
251
+ }
252
+ narrowed.baseBranch = b.baseBranch;
253
+ }
254
+ if (b.originConversationId !== undefined) {
255
+ if (typeof b.originConversationId !== 'string' || !PID_RE.test(b.originConversationId)) {
256
+ res.status(400).json({ error: 'Invalid mission' });
257
+ return;
258
+ }
259
+ const conversation = deps.db.prepare('SELECT pinned_project_id FROM agent_conversations WHERE id = ?').get(b.originConversationId);
260
+ if (!conversation || conversation.pinned_project_id !== pid || (0, mobile_devices_1.getAllowedProjects)(deps.db, req.mobileDevice.id) !== null) {
261
+ res.status(403).json({ error: 'Mission is not accessible' });
262
+ return;
263
+ }
264
+ narrowed.originConversationId = b.originConversationId;
265
+ narrowed.originSurface = 'agent-chat';
266
+ }
222
267
  if (typeof b.interactive === 'boolean')
223
268
  narrowed.interactive = b.interactive;
224
269
  if (b.repositoryIds !== undefined) {
@@ -404,12 +449,14 @@ function createMobileRouter(deps) {
404
449
  narrowed.acceptanceCriteria = b.acceptanceCriteria.filter((x) => typeof x === 'string');
405
450
  void forward(res, 'POST', `/api/projects/${encodeURIComponent(pid)}/tickets/from-draft`, '', narrowed);
406
451
  });
407
- v1.use((0, mobile_missions_1.createMobileMissionsRouter)({ db: deps.db, upstream: async (method, path, body) => {
408
- const response = await fetch(internalBase + path, { method, signal: AbortSignal.timeout(30_000),
409
- headers: { 'x-desktop-token': (0, auth_1.loadOrGenerateToken)(), ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}) },
410
- ...(body !== undefined ? { body: JSON.stringify(body) } : {}) });
411
- return { status: response.status, json: await response.json() };
412
- } }));
452
+ const mobileUpstream = async (method, path, body) => {
453
+ const response = await fetch(internalBase + path, { method, signal: AbortSignal.timeout(30_000),
454
+ headers: { 'x-desktop-token': (0, auth_1.loadOrGenerateToken)(), ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}) },
455
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}) });
456
+ return { status: response.status, json: await response.json() };
457
+ };
458
+ v1.use((0, mobile_missions_1.createMobileMissionsRouter)({ db: deps.db, upstream: mobileUpstream }));
459
+ v1.use((0, mobile_code_1.createMobileCodeRouter)(mobileUpstream));
413
460
  router.use('/v1', v1);
414
461
  // Any unmatched gateway path → 404 JSON (NEVER falls through to a SPA handler;
415
462
  // the gateway serves JSON/WS only, no static client).
@@ -421,8 +468,12 @@ function createMobileRouter(deps) {
421
468
  /** The allow-list, exported for the CI drift test (asserts each entry resolves
422
469
  * against a real internal route, and that no traversal param is accepted). */
423
470
  exports.MOBILE_ALLOWLIST = [
471
+ { method: 'GET', path: '/v1/projects/:pid/repositories/:rid/code/tree' },
472
+ { method: 'GET', path: '/v1/projects/:pid/repositories/:rid/code/find' },
473
+ { method: 'GET', path: '/v1/projects/:pid/repositories/:rid/code/file' },
424
474
  { method: 'GET', path: '/v1/capabilities' },
425
475
  { method: 'GET', path: '/v1/mission-models' },
476
+ { method: 'PATCH', path: '/v1/projects/:pid/missions/:cid/messages/:mid/intent' },
426
477
  { method: 'GET', path: '/v1/projects/:pid/repositories' },
427
478
  { method: 'GET', path: '/v1/projects/:pid/missions' },
428
479
  { method: 'POST', path: '/v1/projects/:pid/missions' },
@@ -445,6 +496,9 @@ exports.MOBILE_ALLOWLIST = [
445
496
  { method: 'GET', path: '/v1/projects/:pid/loop-runs/:id' },
446
497
  { method: 'GET', path: '/v1/projects/:pid/queue' },
447
498
  { method: 'GET', path: '/v1/projects/:pid/rails' },
499
+ { method: 'POST', path: '/v1/projects/:pid/rails' },
500
+ { method: 'GET', path: '/v1/projects/:pid/profiles' },
501
+ { method: 'PUT', path: '/v1/projects/:pid/rails/:i/name' },
448
502
  { method: 'GET', path: '/v1/projects/:pid/activity' },
449
503
  { method: 'GET', path: '/v1/projects/:pid/stats' },
450
504
  { 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 */