conductor-remote 1.77.0 → 1.78.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/assets/index-dOiHAzne.js +47 -0
- package/dist/index.html +1 -1
- package/dist/sw.js +1 -1
- package/dist-node/src/background-tasks.js +139 -0
- package/dist-node/src/mcp-tools.js +7 -2
- package/dist-node/src/reads.js +26 -4
- package/dist-node/src/shared.js +13 -0
- package/package.json +1 -1
- package/dist/assets/index-BcQPCRZ1.js +0 -47
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-dOiHAzne.js"></script>
|
|
17
17
|
<link rel="stylesheet" crossorigin href="/assets/index-BoeZwP2N.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 c={};const l=e=>i(e,o),t={module:{uri:o},exports:c,require:l};s[o]=Promise.all(n.map(e=>t[e]||l(e))).then(e=>(r(...e),c))}}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 c={};const l=e=>i(e,o),t={module:{uri:o},exports:c,require:l};s[o]=Promise.all(n.map(e=>t[e]||l(e))).then(e=>(r(...e),c))}}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:"bc2a0334dcf9d3a15034a75c866b2e57"},{url:"assets/workbox-window.prod.es5-BBnX5xw4.js",revision:null},{url:"assets/index-dOiHAzne.js",revision:null},{url:"assets/index-BoeZwP2N.css",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\//]}))});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The desktop's "Waiting for task" row, re-derived from the SDK stream.
|
|
3
|
+
*
|
|
4
|
+
* A `Bash` call with `run_in_background` (or a background Agent) is a *task*: the SDK
|
|
5
|
+
* writes `system/task_started {task_id, tool_use_id, description, task_type}`, the tool
|
|
6
|
+
* result says "Command running in background", the turn ends, and `sessions.status`
|
|
7
|
+
* goes `idle`. Minutes or hours later the command finishes, the SDK writes
|
|
8
|
+
* `system/task_notification {task_id, status}` and resumes the agent with a fresh
|
|
9
|
+
* `init`. So a chat waiting on a task reads as a finished turn everywhere `status` is
|
|
10
|
+
* read — the phone drew nothing, and the notifier said "done" at the wait.
|
|
11
|
+
*
|
|
12
|
+
* The open set is what this file computes: started, not yet notified. What makes it
|
|
13
|
+
* truthful rather than a list that grows forever is the **process gate**. Conductor
|
|
14
|
+
* runs one `claude --resume=<session>` child per active chat, and a task belongs to
|
|
15
|
+
* that process: when it goes, the notification never comes. Measured over 4,930 tasks
|
|
16
|
+
* on this Mac, 25 were never closed and every one of them sits before a process
|
|
17
|
+
* restart (a later prompt spawned a new child, or Conductor itself relaunched). An
|
|
18
|
+
* abort does *not* end one — 6 tasks completed straight through an "aborted by user" —
|
|
19
|
+
* so the notification and the process's death are the only two things that close a
|
|
20
|
+
* wait, and nothing else here is allowed to.
|
|
21
|
+
*
|
|
22
|
+
* The process list is read from `ps` **args only**. The session id sits in the child's
|
|
23
|
+
* own arguments, so the environment — which carries Conductor's tokens — is never
|
|
24
|
+
* requested, never held and never logged (the dev-server snapshot has the opposite
|
|
25
|
+
* problem and the same rule).
|
|
26
|
+
*/
|
|
27
|
+
import { execFile } from 'node:child_process';
|
|
28
|
+
import { promisify } from 'node:util';
|
|
29
|
+
import { timestampMs } from "./shared.js";
|
|
30
|
+
const exec = promisify(execFile);
|
|
31
|
+
const str = (v) => (typeof v === 'string' && v.trim() ? v.trim() : undefined);
|
|
32
|
+
/**
|
|
33
|
+
* Fold a chat's task frames into the tasks still open, in the order they started.
|
|
34
|
+
*
|
|
35
|
+
* `processStartedAt` is when the chat's agent process came up (epoch ms). A task
|
|
36
|
+
* started before it belonged to a process that is gone, and its notification will
|
|
37
|
+
* never be written — so it is dropped here rather than shown until the heat death of
|
|
38
|
+
* the relay. Pass 0 to trust every frame (a caller that has no process to check).
|
|
39
|
+
*/
|
|
40
|
+
export function openBackgroundTasks(rows, processStartedAt) {
|
|
41
|
+
const open = new Map();
|
|
42
|
+
for (const row of rows) {
|
|
43
|
+
let frame;
|
|
44
|
+
try {
|
|
45
|
+
frame = JSON.parse(row.content);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (frame.type !== 'system')
|
|
51
|
+
continue;
|
|
52
|
+
const taskId = str(frame.task_id);
|
|
53
|
+
if (!taskId)
|
|
54
|
+
continue;
|
|
55
|
+
if (frame.subtype === 'task_notification') {
|
|
56
|
+
open.delete(taskId);
|
|
57
|
+
}
|
|
58
|
+
else if (frame.subtype === 'task_started') {
|
|
59
|
+
if (timestampMs(row.created_at) < processStartedAt)
|
|
60
|
+
continue;
|
|
61
|
+
const taskType = str(frame.task_type) ?? 'task';
|
|
62
|
+
open.set(taskId, {
|
|
63
|
+
taskId,
|
|
64
|
+
toolUseId: str(frame.tool_use_id) ?? null,
|
|
65
|
+
description: str(frame.description) ?? taskType,
|
|
66
|
+
taskType,
|
|
67
|
+
since: row.created_at
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return [...open.values()];
|
|
72
|
+
}
|
|
73
|
+
/** The SQL prefix filter that selects exactly the frames `openBackgroundTasks` reads. */
|
|
74
|
+
export const TASK_FRAME_FILTER = `(content LIKE '{"type":"system","subtype":"task_started"%'
|
|
75
|
+
OR content LIKE '{"type":"system","subtype":"task_notification"%')`;
|
|
76
|
+
/**
|
|
77
|
+
* Which chats have a live agent process, and since when — from one
|
|
78
|
+
* `ps -axo pid=,lstart=,args=` listing.
|
|
79
|
+
*
|
|
80
|
+
* Conductor's child is `…/claude --output-format stream-json … --resume=<session id>`,
|
|
81
|
+
* and the id in that argument is `sessions.id` (a Conductor chat *is* a Claude Code
|
|
82
|
+
* session). A brand-new chat may be started with `--session-id` instead, so both are
|
|
83
|
+
* read. Anything else that happens to run a `claude` binary — the Chrome native host,
|
|
84
|
+
* a terminal — carries neither and is skipped.
|
|
85
|
+
*
|
|
86
|
+
* `lstart` is second-resolution local time in `ps`'s own format ("Wed Sep 2 12:05:23
|
|
87
|
+
* 2026"), which `Date.parse` reads; a line it cannot parse is kept with a start of 0,
|
|
88
|
+
* because a process that is there and undatable is still a process that is there.
|
|
89
|
+
*/
|
|
90
|
+
export function parseAgentProcesses(ps) {
|
|
91
|
+
const starts = new Map();
|
|
92
|
+
for (const line of ps.split('\n')) {
|
|
93
|
+
// The executable runs up to the first flag: its path holds a space ("Application Support").
|
|
94
|
+
const m = /^\s*\d+\s+(\w{3}\s+\w{3}\s+\d+\s+\d\d:\d\d:\d\d\s+\d{4})\s+(.*?)(?:\s+(--.*))?$/.exec(line);
|
|
95
|
+
if (!m || !/(^|\/)claude$/.test(m[2]))
|
|
96
|
+
continue;
|
|
97
|
+
const args = m[3] ?? '';
|
|
98
|
+
const session = /--(?:resume|session-id)(?:=|\s+)([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\b/i.exec(args);
|
|
99
|
+
if (!session)
|
|
100
|
+
continue;
|
|
101
|
+
const started = Date.parse(m[1]);
|
|
102
|
+
const at = Number.isNaN(started) ? 0 : started;
|
|
103
|
+
const known = starts.get(session[1]);
|
|
104
|
+
starts.set(session[1], known === undefined ? at : Math.max(known, at));
|
|
105
|
+
}
|
|
106
|
+
return starts;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* How long one `ps` listing is trusted. The sessions poll asks every 2s, the listing
|
|
110
|
+
* costs tens of milliseconds, and a process that exits is noticed within this window —
|
|
111
|
+
* one poll late, against a wait that is measured in minutes.
|
|
112
|
+
*/
|
|
113
|
+
const PS_FRESH_MS = 5000;
|
|
114
|
+
let snapshot = { at: 0, sessions: new Map() };
|
|
115
|
+
let refreshing = null;
|
|
116
|
+
/**
|
|
117
|
+
* The live agent processes, stale-while-revalidate.
|
|
118
|
+
*
|
|
119
|
+
* Synchronous on purpose: `Reads.listSessions` is a plain DB read with six callers
|
|
120
|
+
* that do not await, and a `ps` shell-out is not worth making all of them async. The
|
|
121
|
+
* first call after start answers with an empty map — no wait shown for one poll —
|
|
122
|
+
* and every call after that answers from the last listing while a fresh one lands.
|
|
123
|
+
*/
|
|
124
|
+
export function agentProcessStarts() {
|
|
125
|
+
if (Date.now() - snapshot.at > PS_FRESH_MS && !refreshing) {
|
|
126
|
+
refreshing = exec('ps', ['-axo', 'pid=,lstart=,args='], { maxBuffer: 16 * 1024 * 1024 })
|
|
127
|
+
.then(({ stdout }) => {
|
|
128
|
+
snapshot = { at: Date.now(), sessions: parseAgentProcesses(stdout) };
|
|
129
|
+
})
|
|
130
|
+
.catch(() => {
|
|
131
|
+
// `ps` failing is not a fact about any chat: keep what we had and try again next window.
|
|
132
|
+
snapshot = { ...snapshot, at: Date.now() };
|
|
133
|
+
})
|
|
134
|
+
.finally(() => {
|
|
135
|
+
refreshing = null;
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
return snapshot.sessions;
|
|
139
|
+
}
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
*/
|
|
28
28
|
import { chatCursor, parseChatCursor } from "./chat-cursor.js";
|
|
29
29
|
import { routes } from "./routes.js";
|
|
30
|
-
import { HIT_CLOSE, HIT_OPEN, isToolResult, workspaceTitle } from "./shared.js";
|
|
30
|
+
import { HIT_CLOSE, HIT_OPEN, isToolResult, timestampMs, workspaceTitle } from "./shared.js";
|
|
31
31
|
/** Versions we know how to speak. The client's choice wins when we know it. */
|
|
32
32
|
export const PROTOCOL_VERSIONS = ['2025-06-18', '2025-03-26', '2024-11-05'];
|
|
33
33
|
export const SERVER_INFO = { name: 'conductor-remote', version: '1' };
|
|
@@ -322,7 +322,12 @@ export function createTools(call) {
|
|
|
322
322
|
const ctx = typeof s.context_used_percent === 'number' && s.context_used_percent > 0
|
|
323
323
|
? ` · ${Math.round(s.context_used_percent)}% context`
|
|
324
324
|
: '';
|
|
325
|
-
|
|
325
|
+
// A chat waiting on a background task reads `idle` in `status` and will resume
|
|
326
|
+
// itself; an agent that reads only the status would call it done and send a
|
|
327
|
+
// second prompt into the wait.
|
|
328
|
+
const waiting = (s.background_tasks ?? []).map(t => `\n waiting for task: ${t.description} (${Math.max(0, Math.round((Date.now() - timestampMs(t.since)) / 60_000))}m so far)`);
|
|
329
|
+
const glyph = s.status === 'working' ? '▶' : waiting.length ? '⧗' : '·';
|
|
330
|
+
return `${glyph} ${s.title ?? '(untitled)'} — ${s.status ?? '?'} · ${s.model ?? '?'}${ctx}\n session_id: ${s.id}${waiting.join('')}`;
|
|
326
331
|
})
|
|
327
332
|
.join('\n');
|
|
328
333
|
}
|
package/dist-node/src/reads.js
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
import { execFileSync } from 'node:child_process';
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
+
import { agentProcessStarts, openBackgroundTasks, TASK_FRAME_FILTER } from "./background-tasks.js";
|
|
4
5
|
import { describeRepoIcon, resolveRepoIcon } from "./icons.js";
|
|
5
6
|
import { workspaceTitle } from "./shared.js";
|
|
6
7
|
import { parseMessage, toolImageAt } from "./transcript.js";
|
|
7
8
|
/** Keep the stable wire field in sync with whichever provider owns the chat. */
|
|
8
|
-
function toSessionRow(row) {
|
|
9
|
+
function toSessionRow(row, background_tasks) {
|
|
9
10
|
const { codex_thinking_level, ...session } = row;
|
|
10
11
|
return {
|
|
11
12
|
...session,
|
|
12
|
-
claude_effort_level: row.agent_type === 'codex' ? codex_thinking_level : row.claude_effort_level
|
|
13
|
+
claude_effort_level: row.agent_type === 'codex' ? codex_thinking_level : row.claude_effort_level,
|
|
14
|
+
background_tasks
|
|
13
15
|
};
|
|
14
16
|
}
|
|
15
17
|
const worktreeCache = new Map();
|
|
@@ -80,9 +82,12 @@ function resolveWorktree(workspacesRoot, repoName, directoryName, branch, repoRo
|
|
|
80
82
|
export class Reads {
|
|
81
83
|
db;
|
|
82
84
|
workspacesRoot;
|
|
83
|
-
|
|
85
|
+
/** Chats with a live agent process, and its start — the gate on `background_tasks`. */
|
|
86
|
+
liveAgents;
|
|
87
|
+
constructor(db, workspacesRoot, liveAgents = agentProcessStarts) {
|
|
84
88
|
this.db = db;
|
|
85
89
|
this.workspacesRoot = workspacesRoot;
|
|
90
|
+
this.liveAgents = liveAgents;
|
|
86
91
|
}
|
|
87
92
|
/**
|
|
88
93
|
* Unread chats, grouped by workspace. `workspaces.unread` is a **dead column** —
|
|
@@ -285,7 +290,24 @@ export class Reads {
|
|
|
285
290
|
FROM sessions s
|
|
286
291
|
WHERE s.workspace_id = ? AND COALESCE(s.is_hidden, 0) = 0
|
|
287
292
|
ORDER BY s.created_at ASC`, [workspaceId]);
|
|
288
|
-
|
|
293
|
+
const live = this.liveAgents();
|
|
294
|
+
return rows.map(row => {
|
|
295
|
+
const started = live.get(row.id);
|
|
296
|
+
return toSessionRow(row, started === undefined ? [] : this.openBackgroundTasks(row.id, started));
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* The background tasks a chat is still waiting on (src/background-tasks.ts).
|
|
301
|
+
*
|
|
302
|
+
* Only asked for a chat with a live agent process, which bounds the cost twice over:
|
|
303
|
+
* a handful of chats at a time, and a prefix scan measured at 4–8ms on the largest
|
|
304
|
+
* chats here (1,000–1,700 rows, 30–58 MB) — under the 2s sessions poll it rides on.
|
|
305
|
+
*/
|
|
306
|
+
openBackgroundTasks(sessionId, processStartedAt) {
|
|
307
|
+
const rows = this.db.query(`SELECT created_at, content FROM session_messages
|
|
308
|
+
WHERE session_id = ? AND ${TASK_FRAME_FILTER}
|
|
309
|
+
ORDER BY rowid ASC`, [sessionId]);
|
|
310
|
+
return openBackgroundTasks(rows, processStartedAt);
|
|
289
311
|
}
|
|
290
312
|
/**
|
|
291
313
|
* Every live chat's status, across all workspaces — what the notifier watches for
|
package/dist-node/src/shared.js
CHANGED
|
@@ -50,6 +50,19 @@ export function workspaceTitle(w) {
|
|
|
50
50
|
export function queryTokens(raw) {
|
|
51
51
|
return raw.toLowerCase().match(/[\p{L}\p{N}_]+/gu) ?? [];
|
|
52
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* Parse one of Conductor's timestamps as an epoch.
|
|
55
|
+
*
|
|
56
|
+
* Its SQLite defaults/triggers use `datetime('now')`, which is UTC but serializes as
|
|
57
|
+
* `YYYY-MM-DD HH:MM:SS` with no zone. Other writers use ISO strings ending in `Z`.
|
|
58
|
+
* Browsers interpret the bare SQLite form as local time, making a fresh chat look two
|
|
59
|
+
* hours old in Oslo during summer. Add the zone only to that exact database shape;
|
|
60
|
+
* explicitly zoned ISO values and local wall-clock strings keep their native meaning.
|
|
61
|
+
*/
|
|
62
|
+
export function timestampMs(value) {
|
|
63
|
+
const sqliteUtc = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?$/.test(value);
|
|
64
|
+
return Date.parse(sqliteUtc ? `${value.replace(' ', 'T')}Z` : value);
|
|
65
|
+
}
|
|
53
66
|
/**
|
|
54
67
|
* A temporary `NEW` marker in Conductor's picker is a badge, not part of the
|
|
55
68
|
* model name. The relay and phone use this value for the visible label and for
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "conductor-remote",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.78.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.",
|