conductor-remote 1.42.0 → 1.42.2
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 +25 -0
- package/bin/cli.js +8 -0
- package/dist/assets/{index-BNMm_aez.js → index-DFdIcbun.js} +3 -3
- package/dist/index.html +1 -1
- package/dist/sw.js +1 -1
- package/dist-node/scripts/service.js +156 -1
- package/dist-node/src/mcp-tools.js +22 -20
- package/dist-node/src/reads.js +1 -14
- package/dist-node/src/search.js +8 -9
- package/dist-node/src/shared.js +59 -0
- package/dist-node/src/wire.js +23 -0
- package/package.json +3 -2
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-DFdIcbun.js"></script>
|
|
17
17
|
<link rel="stylesheet" crossorigin href="/assets/index-95IdnA_r.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
|
|
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:"e1e682e2e5e88fa03b7808ae9db8e098"},{url:"index.html",revision:"94f9b873fb04ccb55680c1b74315be0f"},{url:"assets/workbox-window.prod.es5-BBnX5xw4.js",revision:null},{url:"assets/index-DFdIcbun.js",revision:null},{url:"assets/index-95IdnA_r.css",revision:null},{url:"apple-touch-icon.png",revision:"2b9301416b880d45d4bb655f2600d1f2"},{url:"icon-192.png",revision:"c5e01ac58768627e18ee7b8b6a9239ef"},{url:"icon-512.png",revision:"a40638c55e310312457a621c9a0002c8"},{url:"icon-maskable-512.png",revision:"a40638c55e310312457a621c9a0002c8"},{url:"icon.svg",revision:"c1aee186821798733dd477e69a0ef243"},{url:"manifest.webmanifest",revision:"cf88fbc5755108a7fe0616fa160a8a15"}],{}),e.cleanupOutdatedCaches(),e.registerRoute(new e.NavigationRoute(e.createHandlerBoundToURL("/index.html"),{denylist:[/^\/api\//]}))});
|
|
@@ -133,6 +133,14 @@ function buildPlist() {
|
|
|
133
133
|
['PATH', daemonPath],
|
|
134
134
|
['CONDUCTOR_REMOTE_MANAGED', '1']
|
|
135
135
|
];
|
|
136
|
+
// EXPOSE belongs here with the other runtime knobs, and leaving it out was a real bug. The daemon
|
|
137
|
+
// reads its posture on every boot (the funnel watchdog only arms itself for `public`), but install()
|
|
138
|
+
// reloads launchd *before* ensureTailscale() persists the choice — so a fresh `--expose tailnet`
|
|
139
|
+
// daemon came up reading the old posture, armed the watchdog against a Funnel that was about to be
|
|
140
|
+
// switched off, and ~3 failed probes later healed it straight back to public. Baking the resolved
|
|
141
|
+
// value makes the daemon's own environment the answer, so nothing depends on write ordering.
|
|
142
|
+
if (process.env.EXPOSE)
|
|
143
|
+
envEntries.push(['EXPOSE', process.env.EXPOSE]);
|
|
136
144
|
if (process.env.WRITE_STRATEGY)
|
|
137
145
|
envEntries.push(['WRITE_STRATEGY', process.env.WRITE_STRATEGY]);
|
|
138
146
|
if (process.env.RELAY_HOST)
|
|
@@ -419,6 +427,11 @@ function install() {
|
|
|
419
427
|
process.exit(1);
|
|
420
428
|
}
|
|
421
429
|
persistPinnedToken();
|
|
430
|
+
// Resolve (and persist) the expose posture *before* the plist is written and launchd is reloaded, so
|
|
431
|
+
// the daemon starts with an explicit EXPOSE in its own environment rather than racing the file
|
|
432
|
+
// ensureTailscale() writes further down. Folding it back into process.env is how every other knob
|
|
433
|
+
// travels here (see applyFlags), and it keeps ensureTailscale()'s own resolve a no-op re-read.
|
|
434
|
+
process.env.EXPOSE = resolveExposeMode();
|
|
422
435
|
fs.mkdirSync(path.dirname(plistPath), { recursive: true });
|
|
423
436
|
fs.mkdirSync(logDir, { recursive: true });
|
|
424
437
|
fs.writeFileSync(plistPath, buildPlist());
|
|
@@ -447,6 +460,145 @@ function restart() {
|
|
|
447
460
|
console.info(`✓ restarted ${LABEL}`);
|
|
448
461
|
printUrl();
|
|
449
462
|
}
|
|
463
|
+
/**
|
|
464
|
+
* The daemon's environment, read back out of the plist it was installed with.
|
|
465
|
+
*
|
|
466
|
+
* This is the whole point of `config`: `process.env` here belongs to *your shell*, and the daemon
|
|
467
|
+
* runs under launchd with whatever `buildPlist()` baked in. Reading the shell's env to report the
|
|
468
|
+
* daemon's configuration is how a posture change can look applied while the running relay still
|
|
469
|
+
* believes something else — which is exactly the bug that made EXPOSE a plist entry.
|
|
470
|
+
*/
|
|
471
|
+
function readPlistEnv() {
|
|
472
|
+
try {
|
|
473
|
+
const out = execFileSync('plutil', ['-convert', 'json', '-o', '-', plistPath], {
|
|
474
|
+
encoding: 'utf8',
|
|
475
|
+
stdio: 'pipe'
|
|
476
|
+
});
|
|
477
|
+
const env = JSON.parse(out)?.EnvironmentVariables;
|
|
478
|
+
return env && typeof env === 'object' ? env : {};
|
|
479
|
+
}
|
|
480
|
+
catch {
|
|
481
|
+
return {};
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
const HOME = os.homedir();
|
|
485
|
+
const tilde = (p) => (p.startsWith(HOME) ? `~${p.slice(HOME.length)}` : p);
|
|
486
|
+
const KNOBS = [
|
|
487
|
+
{
|
|
488
|
+
name: 'expose',
|
|
489
|
+
env: 'EXPOSE',
|
|
490
|
+
// The runtime's own precedence (src/tailscale.ts ▸ readExposeMode): env, then the persisted
|
|
491
|
+
// file, then public. Reported the same way so this can never describe a posture the relay
|
|
492
|
+
// isn't running.
|
|
493
|
+
fallback: () => {
|
|
494
|
+
try {
|
|
495
|
+
return normalizeExposeMode(fs.readFileSync(exposeStorePath(), 'utf8')) ?? 'public';
|
|
496
|
+
}
|
|
497
|
+
catch {
|
|
498
|
+
return 'public';
|
|
499
|
+
}
|
|
500
|
+
},
|
|
501
|
+
fallbackSource: () => (fs.existsSync(exposeStorePath()) ? 'expose file' : 'default')
|
|
502
|
+
},
|
|
503
|
+
{ name: 'port', env: 'RELAY_PORT', fallback: () => '8787' },
|
|
504
|
+
{ name: 'host', env: 'RELAY_HOST', fallback: () => '127.0.0.1' },
|
|
505
|
+
{ name: 'write-strategy', env: 'WRITE_STRATEGY', fallback: () => 'applescript' },
|
|
506
|
+
{ name: 'auto-update', env: 'AUTO_UPDATE', fallback: () => 'auto' },
|
|
507
|
+
{ name: 'auto-update-interval', env: 'AUTO_UPDATE_INTERVAL_MINUTES', fallback: () => '5 (minutes)' },
|
|
508
|
+
{
|
|
509
|
+
name: 'funnel-watchdog',
|
|
510
|
+
env: 'FUNNEL_WATCHDOG',
|
|
511
|
+
// Two gates, not one: it defaults on for the managed daemon and then stands down unless the
|
|
512
|
+
// posture is public (src/funnel-watchdog.ts ▸ wantEnabled + startFunnelWatchdog).
|
|
513
|
+
fallback: env => (env.CONDUCTOR_REMOTE_MANAGED === '1' ? 'on (managed daemon)' : 'off (not managed)')
|
|
514
|
+
},
|
|
515
|
+
{ name: 'funnel-watchdog-interval', env: 'FUNNEL_WATCHDOG_INTERVAL_SECONDS', fallback: () => '60 (seconds)' },
|
|
516
|
+
{
|
|
517
|
+
name: 'db',
|
|
518
|
+
env: 'CONDUCTOR_DB',
|
|
519
|
+
fallback: () => tilde(path.join(HOME, 'Library', 'Application Support', 'com.conductor.app', 'conductor.db'))
|
|
520
|
+
},
|
|
521
|
+
{
|
|
522
|
+
name: 'workspaces',
|
|
523
|
+
env: 'CONDUCTOR_WORKSPACES',
|
|
524
|
+
fallback: () => tilde(path.join(HOME, 'conductor', 'workspaces'))
|
|
525
|
+
}
|
|
526
|
+
];
|
|
527
|
+
/**
|
|
528
|
+
* What is this daemon actually configured with, and where did each value come from.
|
|
529
|
+
*
|
|
530
|
+
* Read-only on purpose. Every knob is written by `service install`, and a second write path for the
|
|
531
|
+
* same state is how the plist and the `expose` file came to disagree in the first place. What was
|
|
532
|
+
* missing was the ability to *see* the disagreement, so that is what this adds — including a live
|
|
533
|
+
* cross-check of the Tailscale posture against the configured one.
|
|
534
|
+
*/
|
|
535
|
+
function config() {
|
|
536
|
+
if (!fs.existsSync(plistPath)) {
|
|
537
|
+
console.info(`plist: (not installed)\n\nRun \`conductor-remote service install\` first.`);
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
const env = readPlistEnv();
|
|
541
|
+
console.info(`plist: ${tilde(plistPath)}`);
|
|
542
|
+
console.info(`state: ${tilde(path.dirname(exposeStorePath()))}`);
|
|
543
|
+
try {
|
|
544
|
+
const out = execFileSync('launchctl', ['print', `${domain}/${LABEL}`], { encoding: 'utf8', stdio: 'pipe' });
|
|
545
|
+
console.info(`daemon: ${out.match(/state = (\S+)/)?.[1] ?? 'unknown'} (pid ${out.match(/pid = (\d+)/)?.[1] ?? '—'})`);
|
|
546
|
+
}
|
|
547
|
+
catch {
|
|
548
|
+
console.info('daemon: loaded but not running (check `conductor-remote logs`)');
|
|
549
|
+
}
|
|
550
|
+
const rows = KNOBS.map(k => {
|
|
551
|
+
const set = env[k.env];
|
|
552
|
+
return {
|
|
553
|
+
name: k.name,
|
|
554
|
+
value: set ?? k.fallback(env),
|
|
555
|
+
source: set ? 'plist' : (k.fallbackSource?.(env) ?? 'default')
|
|
556
|
+
};
|
|
557
|
+
});
|
|
558
|
+
// The token is deliberately not a row above: it never rides in the plist, and this output is the
|
|
559
|
+
// kind of thing that gets pasted into an issue. Shown truncated, like every other log surface here.
|
|
560
|
+
let token = '(none yet — minted on first start)';
|
|
561
|
+
try {
|
|
562
|
+
const raw = fs.readFileSync(path.join(path.dirname(exposeStorePath()), 'token'), 'utf8').trim();
|
|
563
|
+
if (raw)
|
|
564
|
+
token = `${raw.slice(0, 4)}…${raw.slice(-4)}`;
|
|
565
|
+
}
|
|
566
|
+
catch {
|
|
567
|
+
// no token file yet
|
|
568
|
+
}
|
|
569
|
+
rows.push({ name: 'token', value: token, source: 'token file' });
|
|
570
|
+
const width = Math.max(...rows.map(r => r.name.length));
|
|
571
|
+
const valueWidth = Math.max(...rows.map(r => r.value.length));
|
|
572
|
+
console.info('');
|
|
573
|
+
for (const r of rows) {
|
|
574
|
+
console.info(` ${r.name.padEnd(width)} ${r.value.padEnd(valueWidth)} ${r.source}`);
|
|
575
|
+
}
|
|
576
|
+
console.info('\n (values come from the daemon\u2019s own plist environment, not this shell\u2019s)');
|
|
577
|
+
console.info(' change one with: conductor-remote service install --<setting> <value>');
|
|
578
|
+
// The cross-check worth having. A posture the relay believes and a Tailscale that is doing something
|
|
579
|
+
// else is invisible in every other command, and it is self-healing in the wrong direction: the funnel
|
|
580
|
+
// watchdog only runs for `public`, so a relay that thinks it is public will re-register Funnel.
|
|
581
|
+
const bin = tailscaleBin();
|
|
582
|
+
const configured = rows.find(r => r.name === 'expose')?.value;
|
|
583
|
+
if (!bin || !configured)
|
|
584
|
+
return;
|
|
585
|
+
const dns = magicDnsName(bin);
|
|
586
|
+
const live = tailscaleState(bin, dns);
|
|
587
|
+
const liveMode = live.funnelOn ? 'public' : live.proxyOk ? 'tailnet' : null;
|
|
588
|
+
console.info('');
|
|
589
|
+
if (liveMode === null) {
|
|
590
|
+
console.info(` \u26a0 tailscale is not fronting 127.0.0.1:${RELAY_PORT} at all — the phone URL is not wired.`);
|
|
591
|
+
console.info(' Fix with: conductor-remote service install');
|
|
592
|
+
}
|
|
593
|
+
else if (liveMode === configured) {
|
|
594
|
+
console.info(` \u2713 tailscale agrees: ${liveMode === 'public' ? 'Funnel on (internet-facing)' : 'serve only (tailnet)'}`);
|
|
595
|
+
}
|
|
596
|
+
else {
|
|
597
|
+
console.info(` \u26a0 tailscale says ${liveMode}, the daemon is configured ${configured}. They must match:`);
|
|
598
|
+
console.info(` the funnel watchdog only runs for \`public\`, so the two can heal each other apart.`);
|
|
599
|
+
console.info(` Fix with: conductor-remote service install --expose ${configured}`);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
450
602
|
function status() {
|
|
451
603
|
const installed = fs.existsSync(plistPath);
|
|
452
604
|
console.info(`plist: ${installed ? plistPath : '(not installed)'}`);
|
|
@@ -497,12 +649,15 @@ switch (cmd) {
|
|
|
497
649
|
case 'status':
|
|
498
650
|
status();
|
|
499
651
|
break;
|
|
652
|
+
case 'config':
|
|
653
|
+
config();
|
|
654
|
+
break;
|
|
500
655
|
case 'logs':
|
|
501
656
|
logs();
|
|
502
657
|
break;
|
|
503
658
|
default:
|
|
504
659
|
console.error(`unknown command: ${cmd}\n` +
|
|
505
|
-
'usage: service.ts <install|uninstall|restart|status|logs> [flags]\n' +
|
|
660
|
+
'usage: service.ts <install|uninstall|restart|status|config|logs> [flags]\n' +
|
|
506
661
|
` flags (install): ${Object.keys(FLAG_ENV).join(', ')}`);
|
|
507
662
|
process.exit(1);
|
|
508
663
|
}
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
* router it would otherwise have to be carved out of, and it keeps *one* code path:
|
|
19
19
|
* a tool cannot behave differently over HTTP than it does over stdio.
|
|
20
20
|
*/
|
|
21
|
+
import { HIT_CLOSE, HIT_OPEN, workspaceTitle } from "./shared.js";
|
|
21
22
|
/** Versions we know how to speak. The client's choice wins when we know it. */
|
|
22
23
|
export const PROTOCOL_VERSIONS = ['2025-06-18', '2025-03-26', '2024-11-05'];
|
|
23
24
|
export const SERVER_INFO = { name: 'conductor-remote', version: '1' };
|
|
@@ -29,23 +30,12 @@ export const WRITE_TIMEOUT_MS = 75_000;
|
|
|
29
30
|
// Tool results are text an agent reads, so they are formatted rather than dumped as
|
|
30
31
|
// JSON: half the tokens, and every id an agent needs to chain the next call stays
|
|
31
32
|
// visible instead of buried in a nested object.
|
|
32
|
-
/** Search snippets arrive with control-character hit markers (search.ts ▸ HIT_OPEN). */
|
|
33
|
-
const HIT_OPEN = '\u0001';
|
|
34
|
-
const HIT_CLOSE = '\u0002';
|
|
35
33
|
function unmark(text) {
|
|
36
34
|
return text.replaceAll(HIT_OPEN, '«').replaceAll(HIT_CLOSE, '»').replace(/\s+/g, ' ').trim();
|
|
37
35
|
}
|
|
38
36
|
function clip(text, max) {
|
|
39
37
|
return text.length > max ? `${text.slice(0, max)}… [${text.length - max} more chars]` : text;
|
|
40
38
|
}
|
|
41
|
-
/** Conductor's own title precedence, third copy — see reads.ts ▸ workspaceTitle for why. */
|
|
42
|
-
function label(w) {
|
|
43
|
-
const branch = w.branch ?? '';
|
|
44
|
-
const slug = branch.includes('/') ? branch.slice(branch.indexOf('/') + 1) : branch;
|
|
45
|
-
const words = slug.replace(/[-_]/g, ' ').trim();
|
|
46
|
-
const humanized = words ? words[0].toUpperCase() + words.slice(1) : '';
|
|
47
|
-
return w.workspace_name || w.pr_title || humanized || w.directory_name || w.id.slice(0, 8);
|
|
48
|
-
}
|
|
49
39
|
const str = (v) => (typeof v === 'string' && v.trim() ? v.trim() : undefined);
|
|
50
40
|
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : undefined);
|
|
51
41
|
function need(args, key) {
|
|
@@ -83,7 +73,7 @@ export function createTools(call) {
|
|
|
83
73
|
const w = r.workspace;
|
|
84
74
|
const tags = [w.repo_name, w.branch, w.archived ? 'ARCHIVED' : w.state].filter(Boolean).join(' · ');
|
|
85
75
|
lines.push('');
|
|
86
|
-
lines.push(`## ${
|
|
76
|
+
lines.push(`## ${workspaceTitle(w)}`);
|
|
87
77
|
lines.push(`${tags}${r.byName ? ' · name match' : ''}`);
|
|
88
78
|
lines.push(`workspace_id: ${w.id}${r.sessionId ? ` session_id: ${r.sessionId}` : ''}`);
|
|
89
79
|
if (r.hits)
|
|
@@ -110,7 +100,9 @@ export function createTools(call) {
|
|
|
110
100
|
const sessionId = need(args, 'session_id');
|
|
111
101
|
const limit = Math.min(400, Math.max(1, num(args.limit) ?? 40));
|
|
112
102
|
const includeTools = args.include_tools === true;
|
|
113
|
-
const data = await call(`/api/sessions/${encodeURIComponent(sessionId)}/messages?after=0`, {
|
|
103
|
+
const data = await call(`/api/sessions/${encodeURIComponent(sessionId)}/messages?after=0`, {
|
|
104
|
+
timeoutMs: 30_000
|
|
105
|
+
});
|
|
114
106
|
const wanted = includeTools
|
|
115
107
|
? data.entries
|
|
116
108
|
: data.entries.filter(e => e.role === 'user' || e.role === 'assistant');
|
|
@@ -148,7 +140,7 @@ export function createTools(call) {
|
|
|
148
140
|
return status ? `no workspace is ${status}` : 'no live workspaces';
|
|
149
141
|
return shown
|
|
150
142
|
.map(w => [
|
|
151
|
-
`${w.session_status === 'working' ? '▶' : '·'} ${
|
|
143
|
+
`${w.session_status === 'working' ? '▶' : '·'} ${workspaceTitle(w)}`,
|
|
152
144
|
` ${[w.repo_name, w.branch, w.model, w.pr_number ? `PR #${w.pr_number} ${w.pr_status ?? ''}`.trim() : null].filter(Boolean).join(' · ')}`,
|
|
153
145
|
` workspace_id: ${w.id}${w.active_session_id ? ` session_id: ${w.active_session_id}` : ''}`
|
|
154
146
|
].join('\n'))
|
|
@@ -183,10 +175,12 @@ export function createTools(call) {
|
|
|
183
175
|
},
|
|
184
176
|
run: async (args) => {
|
|
185
177
|
const id = need(args, 'workspace_id');
|
|
186
|
-
const data = await call(`/api/workspaces/${encodeURIComponent(id)}/diff`, {
|
|
187
|
-
|
|
178
|
+
const data = await call(`/api/workspaces/${encodeURIComponent(id)}/diff`, {
|
|
179
|
+
timeoutMs: 30_000
|
|
180
|
+
});
|
|
181
|
+
if (!data.files.length)
|
|
188
182
|
return 'no changes against the target branch';
|
|
189
|
-
return data.files.map(f => `${f.path} +${f.
|
|
183
|
+
return data.files.map(f => `${f.path} +${f.added} -${f.removed}`).join('\n');
|
|
190
184
|
}
|
|
191
185
|
},
|
|
192
186
|
{
|
|
@@ -223,7 +217,7 @@ export function createTools(call) {
|
|
|
223
217
|
timeoutMs: send ? WRITE_TIMEOUT_MS : 30_000
|
|
224
218
|
});
|
|
225
219
|
const lines = [
|
|
226
|
-
`created ${data.workspace ?
|
|
220
|
+
`created ${data.workspace ? workspaceTitle(data.workspace) : data.workspaceId}`,
|
|
227
221
|
`workspace_id: ${data.workspaceId}`
|
|
228
222
|
];
|
|
229
223
|
if (data.warning)
|
|
@@ -253,7 +247,11 @@ export function createTools(call) {
|
|
|
253
247
|
run: async (args) => {
|
|
254
248
|
const sessionId = need(args, 'session_id');
|
|
255
249
|
const text = need(args, 'text');
|
|
256
|
-
const data = await call(`/api/sessions/${encodeURIComponent(sessionId)}/prompt`, {
|
|
250
|
+
const data = await call(`/api/sessions/${encodeURIComponent(sessionId)}/prompt`, {
|
|
251
|
+
method: 'POST',
|
|
252
|
+
body: { text, workspaceId: str(args.workspace_id) },
|
|
253
|
+
timeoutMs: WRITE_TIMEOUT_MS
|
|
254
|
+
});
|
|
257
255
|
if (data.parked)
|
|
258
256
|
return 'the Mac is locked — the prompt is parked and will be sent on unlock';
|
|
259
257
|
if (!data.ok)
|
|
@@ -277,7 +275,11 @@ export function createTools(call) {
|
|
|
277
275
|
},
|
|
278
276
|
run: async (args) => {
|
|
279
277
|
const sessionId = need(args, 'session_id');
|
|
280
|
-
const data = await call(`/api/sessions/${encodeURIComponent(sessionId)}/stop`, {
|
|
278
|
+
const data = await call(`/api/sessions/${encodeURIComponent(sessionId)}/stop`, {
|
|
279
|
+
method: 'POST',
|
|
280
|
+
body: { workspaceId: str(args.workspace_id) },
|
|
281
|
+
timeoutMs: WRITE_TIMEOUT_MS
|
|
282
|
+
});
|
|
281
283
|
if (data.alreadyIdle)
|
|
282
284
|
return 'that chat had already finished — nothing to stop';
|
|
283
285
|
if (!data.ok)
|
package/dist-node/src/reads.js
CHANGED
|
@@ -2,6 +2,7 @@ import { execFileSync } from 'node:child_process';
|
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { describeRepoIcon, resolveRepoIcon } from "./icons.js";
|
|
5
|
+
import { workspaceTitle } from "./shared.js";
|
|
5
6
|
import { parseMessage } from "./transcript.js";
|
|
6
7
|
const worktreeCache = new Map();
|
|
7
8
|
/**
|
|
@@ -27,20 +28,6 @@ function toSearchWorkspace(r) {
|
|
|
27
28
|
archived: r.state === 'archived'
|
|
28
29
|
};
|
|
29
30
|
}
|
|
30
|
-
/**
|
|
31
|
-
* Conductor's sidebar title for a workspace: manual name → PR title → humanized
|
|
32
|
-
* branch → worktree codename → id. Deliberately a second copy of the web's
|
|
33
|
-
* `workspaceLabel` (web/src/lib/format.ts, where the precedence is documented) —
|
|
34
|
-
* the relay can't import from the Vite root, and a notification that names a
|
|
35
|
-
* workspace differently from the list it came from is worse than the duplication.
|
|
36
|
-
*/
|
|
37
|
-
export function workspaceTitle(w) {
|
|
38
|
-
const branch = w.branch ?? '';
|
|
39
|
-
const slug = branch.includes('/') ? branch.slice(branch.indexOf('/') + 1) : branch;
|
|
40
|
-
const words = slug.replace(/[-_]/g, ' ').trim();
|
|
41
|
-
const humanized = words ? words[0].toUpperCase() + words.slice(1) : '';
|
|
42
|
-
return w.workspace_name || w.pr_title || humanized || w.directory_name || w.id.slice(0, 8);
|
|
43
|
-
}
|
|
44
31
|
/**
|
|
45
32
|
* Resolve a workspace's worktree path. Conductor lays worktrees out as
|
|
46
33
|
* `<workspacesRoot>/<repoName>/<directoryName>`, but we verify against
|
package/dist-node/src/search.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { DatabaseSync } from 'node:sqlite';
|
|
4
|
+
import { HIT_CLOSE, HIT_OPEN } from "./shared.js";
|
|
4
5
|
import { parseMessage } from "./transcript.js";
|
|
5
6
|
/**
|
|
6
7
|
* Full-text search over the chat history, in a sidecar DB the relay owns.
|
|
@@ -41,13 +42,13 @@ const MAX_CHUNK_CHARS = 64_000;
|
|
|
41
42
|
/** How many chunks a query ranks before they are folded into workspaces. */
|
|
42
43
|
const CHUNK_LIMIT = 300;
|
|
43
44
|
/**
|
|
44
|
-
* Snippet highlight markers.
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
45
|
+
* Snippet highlight markers, from `src/shared.ts` because the phone splits on the
|
|
46
|
+
* same two characters (`web/src/lib/format.ts` ▸ `splitSnippet`). Control characters
|
|
47
|
+
* rather than brackets: they survive JSON, they need no escaping on the way to the
|
|
48
|
+
* phone, and no transcript contains them, so the client needs no parser. The client
|
|
49
|
+
* must not render them literally.
|
|
48
50
|
*/
|
|
49
|
-
export
|
|
50
|
-
export const HIT_CLOSE = '\u0002';
|
|
51
|
+
export { HIT_CLOSE, HIT_OPEN } from "./shared.js";
|
|
51
52
|
/**
|
|
52
53
|
* Turn a phone query into an FTS5 MATCH expression.
|
|
53
54
|
*
|
|
@@ -74,9 +75,7 @@ export function matchQuery(raw) {
|
|
|
74
75
|
return terms.join(' OR ');
|
|
75
76
|
}
|
|
76
77
|
/** The tokens `matchQuery` will search for — what a caller matches names against. */
|
|
77
|
-
export
|
|
78
|
-
return raw.toLowerCase().match(/[\p{L}\p{N}_]+/gu) ?? [];
|
|
79
|
-
}
|
|
78
|
+
export { queryTokens } from "./shared.js";
|
|
80
79
|
export class SearchIndex {
|
|
81
80
|
source;
|
|
82
81
|
file;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The handful of things the relay and the phone must compute *identically*, in the
|
|
3
|
+
* one place both can import.
|
|
4
|
+
*
|
|
5
|
+
* `src/` and `web/src/` are one TypeScript project (tsconfig.json includes both), so
|
|
6
|
+
* a plain relative import crosses between them fine. What does not cross is Node: the
|
|
7
|
+
* moment this file imports `node:anything` it stops being bundleable and every web
|
|
8
|
+
* import of it becomes a build failure. **So this module stays stdlib-free — no
|
|
9
|
+
* `node:` imports, ever.** It is the only file under `src/` the web app may import a
|
|
10
|
+
* *value* from; everything else it may only `import type` (see src/wire.ts), which
|
|
11
|
+
* `verbatimModuleSyntax` erases before the bundler ever sees it.
|
|
12
|
+
*
|
|
13
|
+
* Each of these was a second copy before, and each copy was a way for two screens to
|
|
14
|
+
* disagree about the same workspace.
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* The branch minus its prefix, sentence-cased — Conductor's own fallback title while a
|
|
18
|
+
* workspace is still in progress. Prefix-agnostic (github_username / custom / none): it
|
|
19
|
+
* strips the first path segment rather than reading Conductor's `branch_prefix_type`
|
|
20
|
+
* setting, because the branch already embeds whichever prefix was resolved.
|
|
21
|
+
*/
|
|
22
|
+
export function humanizeBranch(branch) {
|
|
23
|
+
const b = branch ?? '';
|
|
24
|
+
const slug = b.includes('/') ? b.slice(b.indexOf('/') + 1) : b;
|
|
25
|
+
const words = slug.replace(/[-_]/g, ' ').trim();
|
|
26
|
+
return words ? words[0].toUpperCase() + words.slice(1) : '';
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Conductor's own sidebar title for a workspace: manual name, then PR title, then the
|
|
30
|
+
* humanized branch, then the worktree codename, then the id.
|
|
31
|
+
*
|
|
32
|
+
* `pr_title` is Conductor's cached PR title, present exactly when the workspace has a PR
|
|
33
|
+
* (in-review or done) and cleared back to empty otherwise, so it is the live sidebar
|
|
34
|
+
* title rather than a stale value. `directory_name` (the worktree codename, e.g.
|
|
35
|
+
* "managua-v2") is the last resort for a branchless workspace.
|
|
36
|
+
*
|
|
37
|
+
* Three callers have to agree — the sidebar list on the phone, the workspace a push
|
|
38
|
+
* notification names (src/notify.ts), and the workspace an MCP tool result names
|
|
39
|
+
* (src/mcp-tools.ts). A notification that titles a workspace differently from the list
|
|
40
|
+
* it came from reads as a different workspace.
|
|
41
|
+
*/
|
|
42
|
+
export function workspaceTitle(w) {
|
|
43
|
+
return w.workspace_name || w.pr_title || humanizeBranch(w.branch) || w.directory_name || w.id.slice(0, 8);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The words a query actually searches for. The phone filters the live workspace list
|
|
47
|
+
* with these while the relay searches the transcript index with the same call, so two
|
|
48
|
+
* different splits would make one list disagree with the other on the same keystroke.
|
|
49
|
+
*/
|
|
50
|
+
export function queryTokens(raw) {
|
|
51
|
+
return raw.toLowerCase().match(/[\p{L}\p{N}_]+/gu) ?? [];
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Markers the relay wraps search hits in (src/search.ts, via FTS5 `snippet()`). They
|
|
55
|
+
* are control characters, so they must never reach the DOM: an unsplit snippet renders
|
|
56
|
+
* as invisible garbage between the words it was meant to emphasise.
|
|
57
|
+
*/
|
|
58
|
+
export const HIT_OPEN = '\u0001';
|
|
59
|
+
export const HIT_CLOSE = '\u0002';
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The HTTP contract: every shape that crosses `/api`, in one place, named once.
|
|
3
|
+
*
|
|
4
|
+
* This file holds **no runtime code**. It re-exports the relay's own domain types
|
|
5
|
+
* under the names the wire uses, and declares the response envelopes the route
|
|
6
|
+
* handlers assemble. Three callers read it and none of them may disagree:
|
|
7
|
+
*
|
|
8
|
+
* - `src/server.ts` builds these payloads,
|
|
9
|
+
* - `web/src/lib/types.ts` re-exports the lot for the PWA,
|
|
10
|
+
* - `src/mcp-tools.ts` annotates its relay calls with them.
|
|
11
|
+
*
|
|
12
|
+
* Before this, the phone kept a hand-written mirror of all of it and `mcp-tools.ts`
|
|
13
|
+
* kept a third copy inline. Nothing enforced the copies — a field renamed in
|
|
14
|
+
* `reads.ts` typechecked cleanly on both sides and surfaced as `undefined` on a
|
|
15
|
+
* phone. So the rule is: **a shape that leaves the relay is declared here, and
|
|
16
|
+
* nowhere else.**
|
|
17
|
+
*
|
|
18
|
+
* The web app may only `import type` from `src/` (see `scripts/check-imports.ts`).
|
|
19
|
+
* `verbatimModuleSyntax` erases those imports, so nothing here reaches the bundle —
|
|
20
|
+
* which is what lets a type live beside the `node:sqlite` code that produces it.
|
|
21
|
+
* The one exception is `src/shared.ts`, which is stdlib-free on purpose.
|
|
22
|
+
*/
|
|
23
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "conductor-remote",
|
|
3
|
-
"version": "1.42.
|
|
3
|
+
"version": "1.42.2",
|
|
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.",
|
|
@@ -50,11 +50,12 @@
|
|
|
50
50
|
"typecheck": "tsc -p tsconfig.json",
|
|
51
51
|
"lint": "biome check .",
|
|
52
52
|
"fix": "biome check . --fix",
|
|
53
|
-
"verify": "yarn typecheck && yarn lint && yarn check:applescript && yarn check:nosleep && yarn check:uilock",
|
|
53
|
+
"verify": "yarn typecheck && yarn lint && yarn check:imports && yarn check:applescript && yarn check:nosleep && yarn check:uilock",
|
|
54
54
|
"release": "semantic-release",
|
|
55
55
|
"prepack": "yarn build && yarn build:node",
|
|
56
56
|
"postinstall": "husky || true",
|
|
57
57
|
"check:applescript": "node scripts/check-applescript.ts",
|
|
58
|
+
"check:imports": "node scripts/check-imports.ts",
|
|
58
59
|
"check:nosleep": "node scripts/check-nosleep.ts",
|
|
59
60
|
"check:uilock": "node scripts/check-uilock.ts"
|
|
60
61
|
},
|