conductor-remote 1.85.0 → 1.86.1

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/dist/index.html CHANGED
@@ -13,7 +13,7 @@
13
13
  <title>Conductor Remote</title>
14
14
  <!-- Runs before the module bundle so it can catch a stale shell that fails to boot. -->
15
15
  <script src="/self-heal.js"></script>
16
- <script type="module" crossorigin src="/assets/index-BoZE-Dzs.js"></script>
16
+ <script type="module" crossorigin src="/assets/index-D_QsGlJg.js"></script>
17
17
  <link rel="stylesheet" crossorigin href="/assets/index-YApUyC0Y.css">
18
18
  <link rel="manifest" href="/manifest.webmanifest"></head>
19
19
  <body>
package/dist/sw.js CHANGED
@@ -1 +1 @@
1
- if(!self.define){let e,s={};const i=(i,n)=>(i=new URL(i+".js",n).href,s[i]||new Promise(s=>{if("document"in self){const e=document.createElement("script");e.src=i,e.onload=s,document.head.appendChild(e)}else e=i,importScripts(i),s()}).then(()=>{let e=s[i];if(!e)throw new Error(`Module ${i} didn’t register its module`);return e}));self.define=(n,r)=>{const o=e||("document"in self?document.currentScript.src:"")||location.href;if(s[o])return;let l={};const c=e=>i(e,o),t={module:{uri:o},exports:l,require:c};s[o]=Promise.all(n.map(e=>t[e]||c(e))).then(e=>(r(...e),l))}}define(["./workbox-9c191d2f"],function(e){"use strict";importScripts("/push-sw.js"),self.addEventListener("message",e=>{e.data&&"SKIP_WAITING"===e.data.type&&self.skipWaiting()}),e.clientsClaim(),e.precacheAndRoute([{url:"self-heal.js",revision:"49bd63adb25a09341f8d2610e8bd3c76"},{url:"push-sw.js",revision:"e7ef44deca46c0539e6ff7bba5eb815e"},{url:"index.html",revision:"97890d4e3f92d2ee1df8cfa801097483"},{url:"assets/workbox-window.prod.es5-BBnX5xw4.js",revision:null},{url:"assets/index-YApUyC0Y.css",revision:null},{url:"assets/index-BoZE-Dzs.js",revision:null},{url:"apple-touch-icon.png",revision:"1127bb396b4648add53dce3f22c92aee"},{url:"icon-192.png",revision:"c5e01ac58768627e18ee7b8b6a9239ef"},{url:"icon-512.png",revision:"a40638c55e310312457a621c9a0002c8"},{url:"icon-maskable-512.png",revision:"a9b0d962686287452216492cd2247499"},{url:"icon.svg",revision:"c1aee186821798733dd477e69a0ef243"},{url:"manifest.webmanifest",revision:"cf88fbc5755108a7fe0616fa160a8a15"}],{}),e.cleanupOutdatedCaches(),e.registerRoute(new e.NavigationRoute(e.createHandlerBoundToURL("/index.html"),{denylist:[/^\/api\//]}))});
1
+ if(!self.define){let e,s={};const i=(i,n)=>(i=new URL(i+".js",n).href,s[i]||new Promise(s=>{if("document"in self){const e=document.createElement("script");e.src=i,e.onload=s,document.head.appendChild(e)}else e=i,importScripts(i),s()}).then(()=>{let e=s[i];if(!e)throw new Error(`Module ${i} didn’t register its module`);return e}));self.define=(n,r)=>{const o=e||("document"in self?document.currentScript.src:"")||location.href;if(s[o])return;let l={};const c=e=>i(e,o),t={module:{uri:o},exports:l,require:c};s[o]=Promise.all(n.map(e=>t[e]||c(e))).then(e=>(r(...e),l))}}define(["./workbox-9c191d2f"],function(e){"use strict";importScripts("/push-sw.js"),self.addEventListener("message",e=>{e.data&&"SKIP_WAITING"===e.data.type&&self.skipWaiting()}),e.clientsClaim(),e.precacheAndRoute([{url:"self-heal.js",revision:"49bd63adb25a09341f8d2610e8bd3c76"},{url:"push-sw.js",revision:"e7ef44deca46c0539e6ff7bba5eb815e"},{url:"index.html",revision:"0ec954ffb2c5de85c62b8911a4238feb"},{url:"assets/workbox-window.prod.es5-BBnX5xw4.js",revision:null},{url:"assets/index-YApUyC0Y.css",revision:null},{url:"assets/index-D_QsGlJg.js",revision:null},{url:"apple-touch-icon.png",revision:"1127bb396b4648add53dce3f22c92aee"},{url:"icon-192.png",revision:"c5e01ac58768627e18ee7b8b6a9239ef"},{url:"icon-512.png",revision:"a40638c55e310312457a621c9a0002c8"},{url:"icon-maskable-512.png",revision:"a9b0d962686287452216492cd2247499"},{url:"icon.svg",revision:"c1aee186821798733dd477e69a0ef243"},{url:"manifest.webmanifest",revision:"cf88fbc5755108a7fe0616fa160a8a15"}],{}),e.cleanupOutdatedCaches(),e.registerRoute(new e.NavigationRoute(e.createHandlerBoundToURL("/index.html"),{denylist:[/^\/api\//]}))});
@@ -294,9 +294,9 @@ function oneLine(text) {
294
294
  /**
295
295
  * Everything this chat records about a person having asked for something, as one
296
296
  * comparable string — the turn head *and* the last user message, because steering a
297
- * running turn moves only the second (`queue_order` is null on a steering message, see
298
- * reads.ts). Empty when the chat records neither, which is what `selfScheduled` reads
299
- * as "no evidence" rather than as "nobody asked".
297
+ * running turn moves only the second (`turn_started_at` stays at the first message in
298
+ * that `turn_id`; see reads.ts). Empty when the chat records neither, which is what
299
+ * `selfScheduled` reads as "no evidence" rather than as "nobody asked".
300
300
  */
301
301
  function askedBy(state) {
302
302
  if (!state.turnStartedAt && !state.lastUserMessageAt)
@@ -330,16 +330,17 @@ export class TurnWatcher {
330
330
  * midnight, and again all morning, which was most of the notifications the phone got
331
331
  * at all.
332
332
  *
333
- * The tell is in the data rather than in a guess about intent. A turn is headed by a
334
- * `session_messages` row with `queue_order` set and a loop's next lap writes nothing
335
- * at all, so `askedBy` below sits still while `status` cycles. The first lap after you
336
- * type something notifies. Every lap the agent gave itself is quiet, until you say the
337
- * next thing. An `error` is exempt — a loop that breaks is worth hearing about however
338
- * it started.
333
+ * The tell is in the data rather than in a guess about intent. User messages in one
334
+ * turn share a `turn_id`, and a loop's next lap writes no user message at all, so
335
+ * `askedBy` below sits still while `status` cycles. The first lap after you type
336
+ * something notifies. Every lap the agent gave itself is quiet, until you say the next
337
+ * thing. An `error` is exempt — a loop that breaks is worth hearing about however it
338
+ * started.
339
339
  *
340
- * A chat with nothing to compare (no turn head *and* no user message — dormant since
341
- * before `queue_order` landed in May 2026) notifies every time, which is the old
342
- * behaviour: with no evidence either way, silence is the dangerous default.
340
+ * A chat with nothing to compare (no dispatched user row carrying either a current
341
+ * `turn_id` or legacy `queue_order`, and no session timestamp) notifies every time,
342
+ * which is the old behaviour: with no evidence either way, silence is the dangerous
343
+ * default.
343
344
  */
344
345
  notifiedTurn = new Map();
345
346
  /**
@@ -8,12 +8,18 @@
8
8
  */
9
9
  import fs from 'node:fs';
10
10
  import path from 'node:path';
11
+ import { attachmentName, attachmentToken } from "./attachments.js";
11
12
  import { stateDir } from "./config.js";
12
13
  const MAX_KEYS = 50_000;
13
14
  const MAX_KEY_LENGTH = 256;
14
15
  const MAX_MARK_LENGTH = 128;
15
16
  const MAX_DRAFT_LENGTH = 1_000_000;
16
17
  const MAX_AGENT_LABEL_LENGTH = 256;
18
+ const MAX_ATTACHMENTS = 100;
19
+ const MAX_ATTACHMENT_NAME_LENGTH = 256;
20
+ const MAX_ATTACHMENT_PATH_LENGTH = 1024;
21
+ const MAX_ATTACHMENT_TOKEN_LENGTH = 4096;
22
+ const MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024;
17
23
  function object(raw) {
18
24
  return raw !== null && typeof raw === 'object' && !Array.isArray(raw) ? raw : null;
19
25
  }
@@ -48,6 +54,49 @@ function sanitizeReadMarks(raw) {
48
54
  .slice(0, MAX_KEYS);
49
55
  return Object.fromEntries(entries);
50
56
  }
57
+ function sanitizeAttachment(raw) {
58
+ const value = object(raw);
59
+ if (!value)
60
+ return null;
61
+ const name = value.name;
62
+ const attachmentPath = value.path;
63
+ const token = value.token;
64
+ const bytes = Number(value.bytes);
65
+ if (typeof name !== 'string' ||
66
+ !name ||
67
+ name.length > MAX_ATTACHMENT_NAME_LENGTH ||
68
+ typeof attachmentPath !== 'string' ||
69
+ attachmentPath.length > MAX_ATTACHMENT_PATH_LENGTH ||
70
+ typeof token !== 'string' ||
71
+ token.length > MAX_ATTACHMENT_TOKEN_LENGTH ||
72
+ !Number.isSafeInteger(bytes) ||
73
+ bytes < 0 ||
74
+ bytes > MAX_ATTACHMENT_BYTES)
75
+ return null;
76
+ const match = attachmentPath.match(/^\.context\/attachments\/([A-Za-z0-9]{6})\/([^/]+)$/);
77
+ if (!match || match[2] !== name || attachmentName(name) !== name || token !== attachmentToken(name, attachmentPath))
78
+ return null;
79
+ const stageId = value.stageId;
80
+ if (stageId !== undefined && (typeof stageId !== 'string' || stageId !== match[1]))
81
+ return null;
82
+ return { name, path: attachmentPath, bytes, token, ...(stageId ? { stageId } : {}) };
83
+ }
84
+ function sanitizeAttachments(raw) {
85
+ if (!Array.isArray(raw))
86
+ return [];
87
+ const attachments = [];
88
+ const seen = new Set();
89
+ for (const candidate of raw) {
90
+ const attachment = sanitizeAttachment(candidate);
91
+ if (!attachment || seen.has(attachment.path))
92
+ continue;
93
+ seen.add(attachment.path);
94
+ attachments.push(attachment);
95
+ if (attachments.length === MAX_ATTACHMENTS)
96
+ break;
97
+ }
98
+ return attachments;
99
+ }
51
100
  function sanitizeDraft(raw) {
52
101
  const value = object(raw);
53
102
  if (!value)
@@ -57,10 +106,16 @@ function sanitizeDraft(raw) {
57
106
  return null;
58
107
  const deleted = value.deleted === true;
59
108
  if (deleted)
60
- return { text: '', agent: {}, updatedAt, deleted: true };
109
+ return { text: '', agent: {}, attachments: [], updatedAt, deleted: true };
61
110
  if (typeof value.text !== 'string' || value.text.length > MAX_DRAFT_LENGTH)
62
111
  return null;
63
- return { text: value.text, agent: sanitizeAgent(value.agent), updatedAt, deleted: false };
112
+ return {
113
+ text: value.text,
114
+ agent: sanitizeAgent(value.agent),
115
+ attachments: sanitizeAttachments(value.attachments),
116
+ updatedAt,
117
+ deleted: false
118
+ };
64
119
  }
65
120
  function sanitizeDrafts(raw) {
66
121
  const value = object(raw);
@@ -88,6 +143,15 @@ function sameDraft(a, b) {
88
143
  return (a.text === b.text &&
89
144
  a.updatedAt === b.updatedAt &&
90
145
  a.deleted === b.deleted &&
146
+ a.attachments.length === b.attachments.length &&
147
+ a.attachments.every((attachment, index) => {
148
+ const other = b.attachments[index];
149
+ return (attachment.name === other?.name &&
150
+ attachment.path === other.path &&
151
+ attachment.bytes === other.bytes &&
152
+ attachment.token === other.token &&
153
+ attachment.stageId === other.stageId);
154
+ }) &&
91
155
  a.agent.model === b.agent.model &&
92
156
  a.agent.effort === b.agent.effort &&
93
157
  a.agent.plan === b.agent.plan &&
@@ -128,8 +192,16 @@ export class PrefsStore {
128
192
  }
129
193
  }
130
194
  if (Object.hasOwn(input, 'drafts')) {
195
+ const rawDrafts = object(input.drafts);
131
196
  for (const [key, draft] of Object.entries(sanitizeDrafts(input.drafts))) {
132
197
  const previous = next.drafts[key];
198
+ const rawDraft = object(rawDrafts?.[key]);
199
+ // A service-worker-cached client from before attachment sync does not know
200
+ // this field. Preserve what it cannot represent while still letting its newer
201
+ // text/settings edit win. Tombstones always clear the whole intent.
202
+ if (previous && !draft.deleted && rawDraft && !Object.hasOwn(rawDraft, 'attachments')) {
203
+ draft.attachments = previous.attachments;
204
+ }
133
205
  const wins = !previous ||
134
206
  draft.updatedAt > previous.updatedAt ||
135
207
  (draft.updatedAt === previous.updatedAt && draft.deleted && !previous.deleted);
@@ -15,6 +15,43 @@ function toSessionRow(row, background_tasks) {
15
15
  };
16
16
  }
17
17
  const worktreeCache = new Map();
18
+ /**
19
+ * The dispatch time of the latest turn a person started.
20
+ *
21
+ * Current Conductor writes the same `turn_id` on a turn's first prompt and every
22
+ * steering message added while it runs, but stopped populating `queue_order` on
23
+ * 2026-08-31. Start with the latest user row that was actually dispatched (so a prompt
24
+ * queued behind the current answer cannot take over), then take the first dispatch in
25
+ * that turn (so steering cannot restart the clock). A self-scheduled `/loop` lap writes
26
+ * no user row, leaving this value unchanged for the notification deduper.
27
+ *
28
+ * The second arm preserves workspaces written by older Conductor builds, where
29
+ * `queue_order` identified turn heads and `turn_id` may be absent.
30
+ * `MIN(CASE …)` is deliberate: a plain `MIN(sent_at)` makes SQLite favour the
31
+ * sent-time index and walk every older turn, while the expression lets the existing
32
+ * `(session_id, role, turn_id, …)` index jump straight to this turn's few user rows.
33
+ */
34
+ const TURN_STARTED_AT_SQL = `COALESCE(
35
+ (SELECT MIN(CASE WHEN head.sent_at IS NOT NULL THEN head.sent_at END)
36
+ FROM session_messages head
37
+ WHERE head.session_id = s.id
38
+ AND head.role = 'user'
39
+ AND head.turn_id = (
40
+ SELECT latest.turn_id
41
+ FROM session_messages latest
42
+ WHERE latest.session_id = s.id
43
+ AND latest.role = 'user'
44
+ AND latest.turn_id IS NOT NULL
45
+ AND latest.sent_at IS NOT NULL
46
+ ORDER BY latest.sent_at DESC, latest.rowid DESC
47
+ LIMIT 1
48
+ )),
49
+ (SELECT MAX(legacy.sent_at)
50
+ FROM session_messages legacy
51
+ WHERE legacy.session_id = s.id
52
+ AND legacy.queue_order IS NOT NULL
53
+ AND legacy.sent_at IS NOT NULL)
54
+ )`;
18
55
  /**
19
56
  * Neutralize LIKE's own wildcards in a user's search word. `queryTokens` already
20
57
  * strips everything but letters, digits and `_` — and `_` is LIKE's single-character
@@ -276,18 +313,12 @@ export class Reads {
276
313
  listSessions(workspaceId) {
277
314
  // created_at ASC keeps tab order stable (matches the desktop app) instead of jumping on activity.
278
315
  //
279
- // `turn_started_at` is when the current answer began, which is NOT
316
+ // `turn_started_at` is when the latest user-started turn began, which is NOT
280
317
  // `last_user_message_at`: a message typed while the agent is already working is
281
318
  // *steering* — it joins the running turn rather than starting one, and the phone's
282
- // elapsed timer must not restart on it. Conductor separates the two itself.
283
- // `session_messages.queue_order` is set exactly on the messages that head a turn
284
- // (verified over the whole DB from May 2026, when the column appeared — older rows
285
- // are all NULL, so a long-dormant chat reports null here and simply shows no timer),
286
- // and `sent_at` is when that head was dispatched, so a prompt that sat in the queue
287
- // times from when it actually ran, not from when it was typed. Still-queued heads
288
- // have `sent_at` NULL and are skipped, which is why a queued message can't blip the
289
- // timer while the previous answer is still going. Served straight off
290
- // idx_session_messages_sent_at(session_id, sent_at) — measured free at this poll rate.
319
+ // elapsed timer must not restart on it. TURN_STARTED_AT_SQL groups those messages
320
+ // by Conductor's `turn_id`, while retaining `queue_order` for older database rows.
321
+ // `sent_at` is the dispatch time, so queued messages are excluded until they run.
291
322
  const rows = this.db.query(`SELECT s.id, s.status, s.title, s.model, s.permission_mode,
292
323
  s.claude_effort_level, s.codex_thinking_level, s.fast_mode, s.agent_type,
293
324
  s.context_used_percent, s.unread_count,
@@ -303,8 +334,7 @@ export class Reads {
303
334
  AND (m.content GLOB '*"ephemeral_5m_input_tokens":[1-9]*'
304
335
  OR m.content GLOB '*"ephemeral_1h_input_tokens":[1-9]*')
305
336
  ORDER BY m.rowid DESC LIMIT 1) AS prompt_cache_ttl_ms,
306
- (SELECT MAX(m.sent_at) FROM session_messages m
307
- WHERE m.session_id = s.id AND m.queue_order IS NOT NULL AND m.sent_at IS NOT NULL) AS turn_started_at
337
+ ${TURN_STARTED_AT_SQL} AS turn_started_at
308
338
  FROM sessions s
309
339
  WHERE s.workspace_id = ? AND COALESCE(s.is_hidden, 0) = 0
310
340
  ORDER BY s.created_at ASC`, [workspaceId]);
@@ -339,14 +369,13 @@ export class Reads {
339
369
  // anything to do with the turn that just ended. An agent that schedules its own
340
370
  // next turn (a `/loop`) writes no message at all, so both stay put while
341
371
  // `status` cycles working → idle on every lap. Both are needed: `turn_started_at`
342
- // only moves for a message that *heads* a turn, so steering into a running one
343
- // would look like a lap nobody asked for. See src/notify.ts.
372
+ // stays at the first message when a person steers the running turn, while
373
+ // `last_user_message_at` moves. See src/notify.ts.
344
374
  `SELECT s.id, s.status, s.title, s.workspace_id, s.last_user_message_at,
345
375
  w.workspace_name, w.pr_title, w.branch, w.directory_name,
346
376
  r.name AS repo_name,
347
377
  (SELECT COUNT(*) FROM sessions t WHERE t.workspace_id = w.id AND COALESCE(t.is_hidden, 0) = 0) AS tab_count,
348
- (SELECT MAX(m.sent_at) FROM session_messages m
349
- WHERE m.session_id = s.id AND m.queue_order IS NOT NULL AND m.sent_at IS NOT NULL) AS turn_started_at
378
+ ${TURN_STARTED_AT_SQL} AS turn_started_at
350
379
  FROM sessions s
351
380
  JOIN workspaces w ON w.id = s.workspace_id
352
381
  LEFT JOIN repos r ON r.id = w.repository_id
@@ -65,7 +65,7 @@ export const routes = {
65
65
  toolImage: param('GET', '/api/tool-images/:reference'),
66
66
  /** Temporarily hold a file while the phone creates the workspace it will belong to. */
67
67
  stageAttachment: flat('POST', '/api/attachments'),
68
- /** Drop a staged file the user removed before creating its workspace. */
68
+ /** Drop an upload cancelled before it became a synced draft attachment. */
69
69
  discardStagedAttachment: param('DELETE', '/api/attachments/:attachmentId'),
70
70
  logs: flat('GET', '/api/logs'),
71
71
  /**
@@ -30,7 +30,7 @@ import { foldHits, queryTokens, SearchIndex } from "./search.js";
30
30
  import { SendOnce } from "./sendonce.js";
31
31
  import { readSettings, writeSettings } from "./settings.js";
32
32
  import { VIEWING_HEADER, withoutWindowEvidence } from "./shared.js";
33
- import { discardStagedAttachment, materializeStagedAttachments, stageAttachment, stagedAttachments } from "./staged-attachments.js";
33
+ import { discardStagedAttachment, materializeStagedAttachments, pruneStagedAttachments, stageAttachment, stagedAttachments } from "./staged-attachments.js";
34
34
  import { driftWarningLines, readExposeMode, tailscaleBin } from "./tailscale.js";
35
35
  import { renderTranscript, transcriptMessage, transcriptThrough } from "./transcript.js";
36
36
  import { autoJoinHotspotMode, currentSsid, looksLikeHotspot, preferredNetworks } from "./wifi.js";
@@ -350,6 +350,30 @@ const firstPrompts = new FirstPromptQueue(path.join(stateDir(), 'first-prompts.j
350
350
  // of burning all three sends into a lock screen nobody is there to see.
351
351
  gate: async () => (await screenLocked()) !== true
352
352
  });
353
+ /** Unreferenced pre-workspace uploads get one week for an offline device to reconnect. */
354
+ const STAGED_ATTACHMENT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
355
+ const STAGED_ATTACHMENT_SWEEP_MS = 6 * 60 * 60 * 1000;
356
+ function referencedStagedAttachments() {
357
+ const referenced = new Set();
358
+ for (const draft of Object.values(readPrefs().drafts)) {
359
+ if (draft.deleted)
360
+ continue;
361
+ for (const attachment of draft.attachments) {
362
+ if (attachment.stageId)
363
+ referenced.add(attachment.stageId);
364
+ }
365
+ }
366
+ for (const prompt of firstPrompts.list()) {
367
+ for (const stageId of prompt.attachmentIds ?? [])
368
+ referenced.add(stageId);
369
+ }
370
+ return referenced;
371
+ }
372
+ function sweepStagedAttachments() {
373
+ const removed = pruneStagedAttachments(STAGED_ATTACHMENTS_DIR, referencedStagedAttachments(), STAGED_ATTACHMENT_MAX_AGE_MS);
374
+ if (removed)
375
+ console.info(`[relay] removed ${removed} abandoned staged attachment(s)`);
376
+ }
353
377
  /**
354
378
  * Apply staged agent settings to a chat — the shared half of `POST …/agent` and
355
379
  * of a send that carries a patch. The `fast` translation lives here because the
@@ -1107,7 +1131,7 @@ const server = http.createServer(async (req, res) => {
1107
1131
  const attachment = stageAttachment(STAGED_ATTACHMENTS_DIR, name, bytes);
1108
1132
  return json(req, res, 201, { ok: true, attachment });
1109
1133
  }
1110
- // DELETE /api/attachments/:id — a file removed from the new-workspace sheet.
1134
+ // DELETE /api/attachments/:id — an upload cancelled before it became a synced draft attachment.
1111
1135
  const stagedAttachment = routeParam(routes.discardStagedAttachment, req.method, pathname);
1112
1136
  if (stagedAttachment)
1113
1137
  return json(req, res, discardStagedAttachment(STAGED_ATTACHMENTS_DIR, stagedAttachment) ? 200 : 404, {
@@ -1813,6 +1837,10 @@ server.listen(cfg.port, cfg.host, () => {
1813
1837
  // Pick up any first prompt the previous process was still holding — an auto-update
1814
1838
  // restart lands mid-setup often enough that this is the normal path, not a rare one.
1815
1839
  firstPrompts.start();
1840
+ // New Workspace uploads are host-side so another device can restore their pills.
1841
+ // Sweep only week-old directories absent from both a draft and the delivery queue.
1842
+ sweepStagedAttachments();
1843
+ setInterval(sweepStagedAttachments, STAGED_ATTACHMENT_SWEEP_MS).unref();
1816
1844
  // Same for prompts parked behind the lock screen — a lock outlives relay restarts.
1817
1845
  parkedPrompts.start();
1818
1846
  // A launchd/self-update restart kills the loopback bridge but not Tailscale's
@@ -86,7 +86,7 @@ export function materializeStagedAttachments(root, worktree, stageIds) {
86
86
  }
87
87
  }
88
88
  }
89
- /** Discard a file the user removed before creating its workspace. */
89
+ /** Discard an upload that was cancelled before any synced draft could reference it. */
90
90
  export function discardStagedAttachment(root, stageId) {
91
91
  const dir = attachmentDirectory(root, stageId);
92
92
  if (!dir || !fs.existsSync(dir))
@@ -94,3 +94,38 @@ export function discardStagedAttachment(root, stageId) {
94
94
  fs.rmSync(dir, { recursive: true, force: true });
95
95
  return true;
96
96
  }
97
+ /**
98
+ * Remove abandoned New Workspace uploads without touching a draft or delivery queue
99
+ * that still names one. Age is measured on the six-character directory, and only
100
+ * directories matching that exact shape are eligible — a corrupt or hand-added path
101
+ * is evidence to leave alone, not permission to recurse through it.
102
+ */
103
+ export function pruneStagedAttachments(root, referenced, maxAgeMs, now = Date.now()) {
104
+ const base = path.join(root, ATTACHMENT_DIR);
105
+ let entries;
106
+ try {
107
+ entries = fs.readdirSync(base, { withFileTypes: true });
108
+ }
109
+ catch {
110
+ return 0;
111
+ }
112
+ let removed = 0;
113
+ for (const entry of entries) {
114
+ if (!entry.isDirectory() || !STAGE_ID.test(entry.name) || referenced.has(entry.name))
115
+ continue;
116
+ const directory = attachmentDirectory(root, entry.name);
117
+ if (!directory)
118
+ continue;
119
+ try {
120
+ if (now - fs.statSync(directory).mtimeMs < maxAgeMs)
121
+ continue;
122
+ fs.rmSync(directory, { recursive: true, force: true });
123
+ removed += 1;
124
+ }
125
+ catch {
126
+ // Cleanup is best-effort. A file being changed or removed concurrently is not
127
+ // a reason to fail the relay or touch a broader path.
128
+ }
129
+ }
130
+ return removed;
131
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "conductor-remote",
3
- "version": "1.85.0",
3
+ "version": "1.86.1",
4
4
  "type": "module",
5
5
  "packageManager": "yarn@4.15.0",
6
6
  "description": "Phone control panel for local Conductor agents. Reads ride SQLite + git; prompts ride Conductor's own dispatch path.",