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/README.md +6 -3
- package/dist/assets/{index-BoZE-Dzs.js → index-D_QsGlJg.js} +37 -37
- package/dist/index.html +1 -1
- package/dist/sw.js +1 -1
- package/dist-node/src/notify.js +13 -12
- package/dist-node/src/prefs.js +74 -2
- package/dist-node/src/reads.js +45 -16
- package/dist-node/src/routes.js +1 -1
- package/dist-node/src/server.js +30 -2
- package/dist-node/src/staged-attachments.js +36 -1
- package/package.json +1 -1
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-
|
|
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:"
|
|
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\//]}))});
|
package/dist-node/src/notify.js
CHANGED
|
@@ -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 (`
|
|
298
|
-
* reads.ts). Empty when the chat records neither, which is what
|
|
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.
|
|
334
|
-
*
|
|
335
|
-
*
|
|
336
|
-
*
|
|
337
|
-
*
|
|
338
|
-
*
|
|
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
|
|
341
|
-
*
|
|
342
|
-
* behaviour: with no evidence either way, silence is the dangerous
|
|
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
|
/**
|
package/dist-node/src/prefs.js
CHANGED
|
@@ -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 {
|
|
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);
|
package/dist-node/src/reads.js
CHANGED
|
@@ -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
|
|
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.
|
|
283
|
-
//
|
|
284
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
343
|
-
//
|
|
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
|
-
|
|
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
|
package/dist-node/src/routes.js
CHANGED
|
@@ -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
|
|
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
|
/**
|
package/dist-node/src/server.js
CHANGED
|
@@ -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 —
|
|
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
|
|
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.
|
|
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.",
|