conductor-remote 1.65.1 → 1.67.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -0
- package/dist/assets/index-P1erC-f5.js +47 -0
- package/dist/assets/{index-87r9u-0C.css → index-YPRuGHP4.css} +1 -1
- package/dist/index.html +2 -2
- package/dist/sw.js +1 -1
- package/dist-node/src/conductor.applescript +47 -32
- package/dist-node/src/file-preview.js +11 -3
- package/dist-node/src/git.js +26 -0
- package/dist-node/src/prefs.js +171 -0
- package/dist-node/src/routes.js +5 -0
- package/dist-node/src/server.js +69 -5
- package/dist-node/src/writes.js +14 -0
- package/package.json +1 -1
- package/dist/assets/index-fu-yJ_gW.js +0 -47
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Device-independent PWA state, durable on the host Mac.
|
|
3
|
+
*
|
|
4
|
+
* localStorage remains the live, offline-first copy. This store is the sync peer that
|
|
5
|
+
* survives a PWA origin change and lets another phone or browser pick up where the first
|
|
6
|
+
* stopped. It deliberately does not contain the access token, pending HTTP requests, or
|
|
7
|
+
* other device-scoped state.
|
|
8
|
+
*/
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { stateDir } from "./config.js";
|
|
12
|
+
const MAX_KEYS = 50_000;
|
|
13
|
+
const MAX_KEY_LENGTH = 256;
|
|
14
|
+
const MAX_MARK_LENGTH = 128;
|
|
15
|
+
const MAX_DRAFT_LENGTH = 1_000_000;
|
|
16
|
+
const MAX_AGENT_LABEL_LENGTH = 256;
|
|
17
|
+
function object(raw) {
|
|
18
|
+
return raw !== null && typeof raw === 'object' && !Array.isArray(raw) ? raw : null;
|
|
19
|
+
}
|
|
20
|
+
function validKey(key) {
|
|
21
|
+
return key.length > 0 && key.length <= MAX_KEY_LENGTH;
|
|
22
|
+
}
|
|
23
|
+
function sanitizeAgent(raw) {
|
|
24
|
+
const value = object(raw);
|
|
25
|
+
if (!value)
|
|
26
|
+
return {};
|
|
27
|
+
const agent = {};
|
|
28
|
+
if (typeof value.model === 'string' && value.model.length <= MAX_AGENT_LABEL_LENGTH)
|
|
29
|
+
agent.model = value.model;
|
|
30
|
+
if (typeof value.effort === 'string' && value.effort.length <= MAX_AGENT_LABEL_LENGTH)
|
|
31
|
+
agent.effort = value.effort;
|
|
32
|
+
if (typeof value.plan === 'boolean')
|
|
33
|
+
agent.plan = value.plan;
|
|
34
|
+
if (typeof value.fast === 'boolean')
|
|
35
|
+
agent.fast = value.fast;
|
|
36
|
+
return agent;
|
|
37
|
+
}
|
|
38
|
+
function sanitizeReadMarks(raw) {
|
|
39
|
+
const value = object(raw);
|
|
40
|
+
if (!value)
|
|
41
|
+
return {};
|
|
42
|
+
const entries = Object.entries(value)
|
|
43
|
+
.filter((entry) => {
|
|
44
|
+
const [key, mark] = entry;
|
|
45
|
+
return validKey(key) && typeof mark === 'string' && mark.length > 0 && mark.length <= MAX_MARK_LENGTH;
|
|
46
|
+
})
|
|
47
|
+
.sort((a, b) => b[1].localeCompare(a[1]))
|
|
48
|
+
.slice(0, MAX_KEYS);
|
|
49
|
+
return Object.fromEntries(entries);
|
|
50
|
+
}
|
|
51
|
+
function sanitizeDraft(raw) {
|
|
52
|
+
const value = object(raw);
|
|
53
|
+
if (!value)
|
|
54
|
+
return null;
|
|
55
|
+
const updatedAt = Number(value.updatedAt);
|
|
56
|
+
if (!Number.isSafeInteger(updatedAt) || updatedAt < 0)
|
|
57
|
+
return null;
|
|
58
|
+
const deleted = value.deleted === true;
|
|
59
|
+
if (deleted)
|
|
60
|
+
return { text: '', agent: {}, updatedAt, deleted: true };
|
|
61
|
+
if (typeof value.text !== 'string' || value.text.length > MAX_DRAFT_LENGTH)
|
|
62
|
+
return null;
|
|
63
|
+
return { text: value.text, agent: sanitizeAgent(value.agent), updatedAt, deleted: false };
|
|
64
|
+
}
|
|
65
|
+
function sanitizeDrafts(raw) {
|
|
66
|
+
const value = object(raw);
|
|
67
|
+
if (!value)
|
|
68
|
+
return {};
|
|
69
|
+
const entries = [];
|
|
70
|
+
for (const [key, candidate] of Object.entries(value)) {
|
|
71
|
+
if (!validKey(key))
|
|
72
|
+
continue;
|
|
73
|
+
const draft = sanitizeDraft(candidate);
|
|
74
|
+
if (draft)
|
|
75
|
+
entries.push([key, draft]);
|
|
76
|
+
}
|
|
77
|
+
entries.sort((a, b) => b[1].updatedAt - a[1].updatedAt);
|
|
78
|
+
return Object.fromEntries(entries.slice(0, MAX_KEYS));
|
|
79
|
+
}
|
|
80
|
+
function sanitize(raw) {
|
|
81
|
+
const value = object(raw);
|
|
82
|
+
return {
|
|
83
|
+
readMarks: sanitizeReadMarks(value?.readMarks),
|
|
84
|
+
drafts: sanitizeDrafts(value?.drafts)
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
function sameDraft(a, b) {
|
|
88
|
+
return (a.text === b.text &&
|
|
89
|
+
a.updatedAt === b.updatedAt &&
|
|
90
|
+
a.deleted === b.deleted &&
|
|
91
|
+
a.agent.model === b.agent.model &&
|
|
92
|
+
a.agent.effort === b.agent.effort &&
|
|
93
|
+
a.agent.plan === b.agent.plan &&
|
|
94
|
+
a.agent.fast === b.agent.fast);
|
|
95
|
+
}
|
|
96
|
+
/** Cached, single-process JSON store. A custom file keeps its merge rules easy to test. */
|
|
97
|
+
export class PrefsStore {
|
|
98
|
+
file;
|
|
99
|
+
cache = null;
|
|
100
|
+
constructor(file = path.join(stateDir(), 'prefs.json')) {
|
|
101
|
+
this.file = file;
|
|
102
|
+
}
|
|
103
|
+
read() {
|
|
104
|
+
if (this.cache)
|
|
105
|
+
return this.cache;
|
|
106
|
+
try {
|
|
107
|
+
this.cache = sanitize(JSON.parse(fs.readFileSync(this.file, 'utf8')));
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
this.cache = { readMarks: {}, drafts: {} };
|
|
111
|
+
}
|
|
112
|
+
return this.cache;
|
|
113
|
+
}
|
|
114
|
+
/** Merge a client snapshot. Marks take max; draft revisions use LWW with deletion winning a tie. */
|
|
115
|
+
patch(raw) {
|
|
116
|
+
const input = object(raw);
|
|
117
|
+
if (!input)
|
|
118
|
+
return this.read();
|
|
119
|
+
const current = this.read();
|
|
120
|
+
const next = { readMarks: { ...current.readMarks }, drafts: { ...current.drafts } };
|
|
121
|
+
let changed = false;
|
|
122
|
+
if (Object.hasOwn(input, 'readMarks')) {
|
|
123
|
+
for (const [key, mark] of Object.entries(sanitizeReadMarks(input.readMarks))) {
|
|
124
|
+
if ((next.readMarks[key] ?? '') >= mark)
|
|
125
|
+
continue;
|
|
126
|
+
next.readMarks[key] = mark;
|
|
127
|
+
changed = true;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (Object.hasOwn(input, 'drafts')) {
|
|
131
|
+
for (const [key, draft] of Object.entries(sanitizeDrafts(input.drafts))) {
|
|
132
|
+
const previous = next.drafts[key];
|
|
133
|
+
const wins = !previous ||
|
|
134
|
+
draft.updatedAt > previous.updatedAt ||
|
|
135
|
+
(draft.updatedAt === previous.updatedAt && draft.deleted && !previous.deleted);
|
|
136
|
+
if (!wins || (previous && sameDraft(previous, draft)))
|
|
137
|
+
continue;
|
|
138
|
+
next.drafts[key] = draft;
|
|
139
|
+
changed = true;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (!changed)
|
|
143
|
+
return current;
|
|
144
|
+
this.persist(next);
|
|
145
|
+
this.cache = next;
|
|
146
|
+
return next;
|
|
147
|
+
}
|
|
148
|
+
persist(prefs) {
|
|
149
|
+
const dir = path.dirname(this.file);
|
|
150
|
+
const temporary = `${this.file}.${process.pid}.tmp`;
|
|
151
|
+
try {
|
|
152
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
153
|
+
fs.writeFileSync(temporary, `${JSON.stringify(prefs, null, '\t')}\n`, { mode: 0o600 });
|
|
154
|
+
fs.renameSync(temporary, this.file);
|
|
155
|
+
}
|
|
156
|
+
catch (err) {
|
|
157
|
+
try {
|
|
158
|
+
fs.unlinkSync(temporary);
|
|
159
|
+
}
|
|
160
|
+
catch { }
|
|
161
|
+
console.warn(`⚠ could not persist synced preferences (${err instanceof Error ? err.message : err})`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
const store = new PrefsStore();
|
|
166
|
+
export function readPrefs() {
|
|
167
|
+
return store.read();
|
|
168
|
+
}
|
|
169
|
+
export function writePrefs(patch) {
|
|
170
|
+
return store.patch(patch);
|
|
171
|
+
}
|
package/dist-node/src/routes.js
CHANGED
|
@@ -64,6 +64,9 @@ export const routes = {
|
|
|
64
64
|
logs: flat('GET', '/api/logs'),
|
|
65
65
|
settings: flat('GET', '/api/settings'),
|
|
66
66
|
updateSettings: flat('PATCH', '/api/settings'),
|
|
67
|
+
/** Durable, device-independent PWA state. localStorage remains its offline-first mirror. */
|
|
68
|
+
prefs: flat('GET', '/api/prefs'),
|
|
69
|
+
updatePrefs: flat('PATCH', '/api/prefs'),
|
|
67
70
|
nosleep: flat('GET', '/api/nosleep'),
|
|
68
71
|
armNoSleep: flat('POST', '/api/nosleep'),
|
|
69
72
|
disarmNoSleep: flat('DELETE', '/api/nosleep'),
|
|
@@ -78,6 +81,8 @@ export const routes = {
|
|
|
78
81
|
sessions: param('GET', '/api/workspaces/:workspaceId/sessions'),
|
|
79
82
|
newChat: param('POST', '/api/workspaces/:workspaceId/sessions'),
|
|
80
83
|
diff: param('GET', '/api/workspaces/:workspaceId/diff'),
|
|
84
|
+
/** The worktree's own file list, which is what makes a chat's `src/foo.ts` a link. */
|
|
85
|
+
workspaceFiles: param('GET', '/api/workspaces/:workspaceId/files'),
|
|
81
86
|
merge: param('POST', '/api/workspaces/:workspaceId/merge'),
|
|
82
87
|
workspaceStatus: param('POST', '/api/workspaces/:workspaceId/status'),
|
|
83
88
|
/** Read, start/forward, or stop a local workspace's selected Conductor Run task. */
|
package/dist-node/src/server.js
CHANGED
|
@@ -12,7 +12,7 @@ import { DevServerController } from "./dev-server.js";
|
|
|
12
12
|
import { isAllowedPreviewPath, parseFileReference } from "./file-preview.js";
|
|
13
13
|
import { FirstPromptQueue } from "./firstprompt.js";
|
|
14
14
|
import { startFunnelWatchdog } from "./funnel-watchdog.js";
|
|
15
|
-
import { workspaceDiff } from "./git.js";
|
|
15
|
+
import { listSourceFiles, workspaceDiff } from "./git.js";
|
|
16
16
|
import { installLogCapture, isManaged, LOG_FILE_NAMES, logFiles, processStartedAt, recentLogs, redactSecrets, tailLogFile } from "./logbuf.js";
|
|
17
17
|
import { createTools, handleRpc, READ_TIMEOUT_MS } from "./mcp-tools.js";
|
|
18
18
|
import { mergePr } from "./merge.js";
|
|
@@ -21,6 +21,7 @@ import { armNoSleep, disarmNoSleep, MAX_SECONDS as NOSLEEP_MAX_SECONDS, nosleepS
|
|
|
21
21
|
import { chatRoute, notifyAll, notifyDevice, pushConfig, startNotifier, subscribeDevice, unsubscribeDevice } from "./notify.js";
|
|
22
22
|
import { ParkedPromptQueue } from "./parked.js";
|
|
23
23
|
import { attachPrStatus } from "./pr.js";
|
|
24
|
+
import { readPrefs, writePrefs } from "./prefs.js";
|
|
24
25
|
import { Reads } from "./reads.js";
|
|
25
26
|
import { isRoute, routeParam, routes } from "./routes.js";
|
|
26
27
|
import { foldHits, queryTokens, SearchIndex } from "./search.js";
|
|
@@ -30,7 +31,7 @@ import { discardStagedAttachment, materializeStagedAttachments, stageAttachment,
|
|
|
30
31
|
import { driftWarningLines, readExposeMode, tailscaleBin } from "./tailscale.js";
|
|
31
32
|
import { renderTranscript } from "./transcript.js";
|
|
32
33
|
import { autoJoinHotspotMode, currentSsid, looksLikeHotspot, preferredNetworks } from "./wifi.js";
|
|
33
|
-
import { createWorkspace, describeActuator, EFFORT_LABELS, listAgentModels, lockBlocked, newChat, pickActuator, retryWontHelp, screenLocked, setAgentOptions, setRestartGuard, setWorkspaceStatus, stopTurn, UiBusyError, uiQueueDepth, WORKSPACE_STATUS_LABELS, withUiPriority } from "./writes.js";
|
|
34
|
+
import { createWorkspace, describeActuator, EFFORT_LABELS, listAgentModels, lockBlocked, newChat, pickActuator, retryWontHelp, screenLocked, sendNeverStarted, setAgentOptions, setRestartGuard, setWorkspaceStatus, stopTurn, UiBusyError, uiQueueDepth, WORKSPACE_STATUS_LABELS, withUiPriority } from "./writes.js";
|
|
34
35
|
// Before anything that logs: from here on every console line is also kept in memory for
|
|
35
36
|
// `GET /api/logs`, so the phone can read why a send failed without ssh-ing into the Mac.
|
|
36
37
|
installLogCapture();
|
|
@@ -258,7 +259,14 @@ async function deliverPrompt(ws, sessionId, text, budgetMs = SEND_BUDGET_MS, que
|
|
|
258
259
|
deadline: deadline - MIN_CONFIRM_MS,
|
|
259
260
|
queue
|
|
260
261
|
});
|
|
261
|
-
|
|
262
|
+
// A run that left the prompt in the composer proved it wrote no row, so the
|
|
263
|
+
// window would be six seconds of watching for nothing. One check still happens:
|
|
264
|
+
// an *earlier* attempt's row can be arriving, and typing again over that is the
|
|
265
|
+
// duplicate this whole path exists to avoid.
|
|
266
|
+
const landed = sendNeverStarted(last.error)
|
|
267
|
+
? deliveredSince(sessionId, text, beforeRowid)
|
|
268
|
+
: await confirmDelivery(sessionId, text, beforeRowid, deadline);
|
|
269
|
+
if (landed) {
|
|
262
270
|
if (attempts > 1)
|
|
263
271
|
console.info(`[relay] send to ${label} landed on attempt ${attempts}`);
|
|
264
272
|
return { ok: true, strategy: last.strategy, attempts };
|
|
@@ -481,6 +489,10 @@ async function serveFilePreview(req, res, reference) {
|
|
|
481
489
|
const target = parseFileReference(reference);
|
|
482
490
|
if (!target)
|
|
483
491
|
return json(req, res, 404, { error: 'source file not found' });
|
|
492
|
+
const refused = (filePath) => {
|
|
493
|
+
const answer = previewRefusal(filePath);
|
|
494
|
+
return json(req, res, answer.status, { error: answer.error });
|
|
495
|
+
};
|
|
484
496
|
let filePath;
|
|
485
497
|
let workspaceRoot;
|
|
486
498
|
let homeRoot;
|
|
@@ -495,7 +507,7 @@ async function serveFilePreview(req, res, reference) {
|
|
|
495
507
|
fs.promises.realpath(BUNDLED_SKILLS_ROOT).catch(() => null)
|
|
496
508
|
]);
|
|
497
509
|
if (!isAllowedPreviewPath(filePath, workspaceRoot, homeRoot, readExposeMode(), bundledSkillsRoot)) {
|
|
498
|
-
return
|
|
510
|
+
return refused(filePath);
|
|
499
511
|
}
|
|
500
512
|
const info = await fs.promises.stat(filePath);
|
|
501
513
|
if (!info.isFile())
|
|
@@ -503,7 +515,9 @@ async function serveFilePreview(req, res, reference) {
|
|
|
503
515
|
size = info.size;
|
|
504
516
|
}
|
|
505
517
|
catch {
|
|
506
|
-
|
|
518
|
+
// A path this relay would refuse must answer the same whether or not it is there, or
|
|
519
|
+
// a public client learns which home files exist by watching 404 turn into 403.
|
|
520
|
+
return refused(target.path);
|
|
507
521
|
}
|
|
508
522
|
if (size > FILE_PREVIEW_MAX_BYTES)
|
|
509
523
|
return json(req, res, 413, { error: 'source file is too large to preview' });
|
|
@@ -533,6 +547,28 @@ async function serveFilePreview(req, res, reference) {
|
|
|
533
547
|
truncated: start > 0 || end < lines.length
|
|
534
548
|
});
|
|
535
549
|
}
|
|
550
|
+
/**
|
|
551
|
+
* How to answer for a file the preview will not serve, from the path alone.
|
|
552
|
+
*
|
|
553
|
+
* Chat mentions made the home-directory case ordinary — agents write "plan written to
|
|
554
|
+
* `~/.gstack/plan.md`" constantly — and on a public funnel every one of those is refused
|
|
555
|
+
* by policy. Answering "source file not found" then sends someone hunting for a file that
|
|
556
|
+
* is sitting right there, so a refusal says it is a refusal. It discloses nothing: the
|
|
557
|
+
* verdict comes from the path and the funnel's posture, never from the disk, and it is
|
|
558
|
+
* the answer for an out-of-bounds path whether or not that path exists.
|
|
559
|
+
*/
|
|
560
|
+
function previewRefusal(filePath) {
|
|
561
|
+
const mode = readExposeMode();
|
|
562
|
+
if (isAllowedPreviewPath(path.resolve(filePath), cfg.workspacesRoot, os.homedir(), mode, BUNDLED_SKILLS_ROOT)) {
|
|
563
|
+
return { status: 404, error: 'source file not found' };
|
|
564
|
+
}
|
|
565
|
+
return {
|
|
566
|
+
status: 403,
|
|
567
|
+
error: mode === 'public'
|
|
568
|
+
? 'this relay is reachable from the internet, so it previews files inside Conductor workspaces only'
|
|
569
|
+
: 'outside the files this relay may read'
|
|
570
|
+
};
|
|
571
|
+
}
|
|
536
572
|
/** Per-file ceiling for a relay exposed through Tailscale Funnel. Large media belongs in a link. */
|
|
537
573
|
const MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024;
|
|
538
574
|
class PayloadTooLargeError extends Error {
|
|
@@ -842,6 +878,22 @@ const server = http.createServer(async (req, res) => {
|
|
|
842
878
|
return json(req, res, 400, { error: 'nothing to change' });
|
|
843
879
|
return json(req, res, 200, { settings: writeSettings(patch) });
|
|
844
880
|
}
|
|
881
|
+
// PWA state remains local-first; this host copy survives origin changes and
|
|
882
|
+
// reconciles phones. PATCH accepts a full client snapshot and merges per key.
|
|
883
|
+
if (isRoute(routes.prefs, req.method, pathname)) {
|
|
884
|
+
return json(req, res, 200, { prefs: readPrefs() });
|
|
885
|
+
}
|
|
886
|
+
if (isRoute(routes.updatePrefs, req.method, pathname)) {
|
|
887
|
+
const raw = JSON.parse((await readBody(req)) || '{}');
|
|
888
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
889
|
+
return json(req, res, 400, { error: 'preferences must be an object' });
|
|
890
|
+
}
|
|
891
|
+
const body = raw;
|
|
892
|
+
if (!Object.hasOwn(body, 'readMarks') && !Object.hasOwn(body, 'drafts')) {
|
|
893
|
+
return json(req, res, 400, { error: 'nothing to sync' });
|
|
894
|
+
}
|
|
895
|
+
return json(req, res, 200, { prefs: writePrefs(body) });
|
|
896
|
+
}
|
|
845
897
|
// GET /api/nosleep — is the Mac being held awake, and can this relay do it at all
|
|
846
898
|
if (isRoute(routes.nosleep, req.method, pathname)) {
|
|
847
899
|
return json(req, res, 200, { ...(await nosleepState()), maxSeconds: NOSLEEP_MAX_SECONDS });
|
|
@@ -1116,6 +1168,18 @@ const server = http.createServer(async (req, res) => {
|
|
|
1116
1168
|
const diff = await workspaceDiff(ws.worktree, ws.baseBranch);
|
|
1117
1169
|
return json(req, res, 200, diff);
|
|
1118
1170
|
}
|
|
1171
|
+
// GET /api/workspaces/:id/files — the worktree's own file list, which is what lets the
|
|
1172
|
+
// phone link `tests/foo.ts` in a message: a mention becomes a link only when it names
|
|
1173
|
+
// a file that is really there. A workspace with no worktree simply links nothing.
|
|
1174
|
+
const filesOf = routeParam(routes.workspaceFiles, req.method, pathname);
|
|
1175
|
+
if (filesOf) {
|
|
1176
|
+
const ws = reads.getWorkspace(filesOf);
|
|
1177
|
+
if (!ws)
|
|
1178
|
+
return json(req, res, 404, { error: 'workspace not found' });
|
|
1179
|
+
if (!ws.worktree)
|
|
1180
|
+
return json(req, res, 200, { files: [], truncated: false });
|
|
1181
|
+
return json(req, res, 200, await listSourceFiles(ws.worktree));
|
|
1182
|
+
}
|
|
1119
1183
|
// POST /api/workspaces/:id/merge — merge the workspace's open PR (mirrors Conductor's merge button)
|
|
1120
1184
|
const mergeOf = routeParam(routes.merge, req.method, pathname);
|
|
1121
1185
|
if (mergeOf) {
|
package/dist-node/src/writes.js
CHANGED
|
@@ -150,6 +150,20 @@ export function retryWontHelp(error) {
|
|
|
150
150
|
export function lockBlocked(error) {
|
|
151
151
|
return (error ?? '').includes('The Mac is locked');
|
|
152
152
|
}
|
|
153
|
+
/**
|
|
154
|
+
* A run that ended with the prompt still sitting in Conductor's composer
|
|
155
|
+
* (`submitComposer`). The draft was never consumed, so this run wrote no row and
|
|
156
|
+
* the caller's confirm window has nothing to wait for — six seconds spent watching
|
|
157
|
+
* for something the run already proved didn't happen. Only the *waiting* is
|
|
158
|
+
* skipped: an earlier attempt's row can still be arriving, so the caller checks
|
|
159
|
+
* once before typing again.
|
|
160
|
+
*
|
|
161
|
+
* Matched on the phrase the send script writes itself, like `lockBlocked` above,
|
|
162
|
+
* so macOS wording can't drift under it.
|
|
163
|
+
*/
|
|
164
|
+
export function sendNeverStarted(error) {
|
|
165
|
+
return (error ?? '').includes('still sitting in its composer');
|
|
166
|
+
}
|
|
153
167
|
/**
|
|
154
168
|
* Node's own read of the lock screen — the same CGSessionCopyCurrentDictionary
|
|
155
169
|
* probe `screenLocked()` in conductor.applescript makes, minus the AppleScript
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "conductor-remote",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.67.0",
|
|
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.",
|