codebay 0.14.0 → 0.15.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/.mochi/manifest.json +526 -455
- package/.mochi/svelte-client/HydratableIsland-smgsf0ma.js +1 -0
- package/.mochi/svelte-client/_hydrate-AppShell_2rtnxn6pgp2rh-9vc087ep.js +2 -0
- package/.mochi/svelte-client/_hydrate-AvatarsView_257s9yq1wwr4f-a48xq47a.js +14 -0
- package/.mochi/svelte-client/_hydrate-InstanceView_2ma45l9bybfhl-d4zehrqz.js +3 -0
- package/.mochi/svelte-client/_hydrate-SettingsView_8hmarwmf8onq-t5175pf5.js +44 -0
- package/.mochi/svelte-client/chunk-0mstgbtr.js +2 -0
- package/.mochi/svelte-client/chunk-0q9g0kpg.js +1 -0
- package/.mochi/svelte-client/chunk-3fwjzw1r.js +1 -0
- package/.mochi/svelte-client/{chunk-spk1b2r4.js → chunk-5pg0be5f.js} +1 -1
- package/.mochi/svelte-client/{chunk-ccfh8hxz.js → chunk-a2hty99b.js} +1 -1
- package/.mochi/svelte-client/{chunk-0mc09cyb.js → chunk-ds9yn9cq.js} +1 -1
- package/.mochi/svelte-client/{chunk-phf7v5jj.js → chunk-fn03gxer.js} +1 -1
- package/.mochi/svelte-client/chunk-jhgq7f65.js +2 -0
- package/.mochi/svelte-client/chunk-p5a07kx1.js +1 -0
- package/.mochi/svelte-client/chunk-p5wpyhsf.js +2 -0
- package/.mochi/svelte-client/chunk-t2kfvtb6.js +2 -0
- package/.mochi/svelte-client/{chunk-x0m3k0rn.js → chunk-v3wg9mdy.js} +1 -1
- package/.mochi/svelte-compile/{App-94xsdn0w.server.js → App-68f9a9fq.server.js} +64 -59
- package/.mochi/svelte-compile/{Avatars-fnccw47h.server.js → Avatars-vyh3rwxx.server.js} +14 -29
- package/.mochi/svelte-compile/Instance-yfw7s5mp.server.js +627 -0
- package/.mochi/svelte-compile/{Settings-a7b11v90.server.js → Settings-0b5t9kw9.server.js} +145 -25
- package/.mochi/svelte-compile/{chunk-w0hca7va.js → chunk-0y3wrae9.js} +13 -1
- package/.mochi/svelte-compile/{chunk-ed46m58e.js → chunk-2qzc07gt.js} +13 -1
- package/.mochi/svelte-compile/chunk-ef3hvegw.js +21 -0
- package/.mochi/svelte-compile/chunk-f9td8ej2.js +24 -0
- package/.mochi/svelte-compile/{chunk-pqb403fh.js → chunk-g0eravvj.js} +5 -16
- package/.mochi/svelte-css/AgentLogBox-38adby5iygny7.css +1 -0
- package/.mochi/svelte-css/{InstanceCard-25z7nas7y98zk.css → InstanceCard-3vxozrp9uoads.css} +1 -1
- package/.mochi/svelte-css/{SettingsView-bccw34agd2bj.css → SettingsView-2400myxb9ot56.css} +1 -1
- package/README.md +32 -1
- package/migrations/0009_agent_runs.sql +24 -0
- package/migrations/0010_agent_run_model.sql +2 -0
- package/package.json +6 -2
- package/src/components/AgentLogBox.svelte +458 -0
- package/src/components/AppShell.svelte +11 -0
- package/src/components/FolderBrowser.svelte +13 -5
- package/src/components/InstanceCard.svelte +38 -0
- package/src/components/InstanceView.svelte +8 -0
- package/src/components/SettingsView.svelte +186 -0
- package/src/components/UiShowcase.svelte +4 -2
- package/src/container-injections/claude-statusline.ts +4 -1
- package/src/container-injections/github-credentials.ts +12 -1
- package/src/index.ts +32 -7
- package/src/lib/agent-run-stream.ts +246 -0
- package/src/lib/agent-runs.server.ts +724 -0
- package/src/lib/auth.server.ts +11 -2
- package/src/lib/build-freshness.server.ts +71 -0
- package/src/lib/config.server.ts +29 -6
- package/src/lib/container-files.server.ts +1 -1
- package/src/lib/data-dir-lock.server.ts +78 -0
- package/src/lib/db.server.ts +120 -1
- package/src/lib/devcontainer.server.ts +60 -14
- package/src/lib/docker-client.server.ts +11 -5
- package/src/lib/drives.ts +11 -0
- package/src/lib/exec.server.ts +66 -5
- package/src/lib/instances.server.ts +45 -3
- package/src/lib/log-capture.server.ts +17 -5
- package/src/lib/mcp-auth.server.ts +52 -0
- package/src/lib/picker.server.ts +23 -2
- package/src/lib/sandbox-ops.server.ts +263 -0
- package/src/mcp/routes.server.ts +25 -0
- package/src/mcp/server.server.ts +52 -0
- package/src/mcp/tools.server.ts +494 -0
- package/src/pages/Settings.svelte +12 -0
- package/src/routes.ts +63 -1
- package/src/types.ts +15 -0
- package/.mochi/svelte-client/HydratableIsland-zrdsrsmg.js +0 -1
- package/.mochi/svelte-client/_hydrate-AppShell_2rtnxn6pgp2rh-v7y7dh53.js +0 -2
- package/.mochi/svelte-client/_hydrate-AvatarsView_257s9yq1wwr4f-tmwd6fpb.js +0 -14
- package/.mochi/svelte-client/_hydrate-InstanceView_2ma45l9bybfhl-m7t64dbh.js +0 -1
- package/.mochi/svelte-client/_hydrate-SettingsView_8hmarwmf8onq-4a7ann1f.js +0 -40
- package/.mochi/svelte-client/chunk-5jwfs4p9.js +0 -2
- package/.mochi/svelte-client/chunk-6qg4q98a.js +0 -2
- package/.mochi/svelte-client/chunk-tmeh70wg.js +0 -2
- package/.mochi/svelte-client/chunk-vs1yjbqt.js +0 -2
- package/.mochi/svelte-client/chunk-y7f4jbdm.js +0 -2
- package/.mochi/svelte-compile/Instance-knnkcn2p.server.js +0 -377
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
import ListOrdered from '@lucide/svelte/icons/list-ordered';
|
|
25
25
|
import Boxes from '@lucide/svelte/icons/boxes';
|
|
26
26
|
import Puzzle from '@lucide/svelte/icons/puzzle';
|
|
27
|
+
import Plug from '@lucide/svelte/icons/plug';
|
|
27
28
|
import { Toaster } from 'svelte-french-toast';
|
|
28
29
|
import { TOAST_OPTIONS } from '../toast.ts';
|
|
29
30
|
import { flushSync } from 'svelte';
|
|
@@ -71,6 +72,10 @@
|
|
|
71
72
|
copyIgnorePatterns,
|
|
72
73
|
builtinCopyIgnore,
|
|
73
74
|
claudeConfigDir,
|
|
75
|
+
mcpEnabled,
|
|
76
|
+
mcpToken,
|
|
77
|
+
mcpUrl,
|
|
78
|
+
mcpPrAttribution,
|
|
74
79
|
gitIdentityEnabled,
|
|
75
80
|
gitIdentityName,
|
|
76
81
|
gitIdentityEmail,
|
|
@@ -113,6 +118,10 @@
|
|
|
113
118
|
copyIgnorePatterns: string;
|
|
114
119
|
builtinCopyIgnore: string;
|
|
115
120
|
claudeConfigDir: string;
|
|
121
|
+
mcpEnabled: boolean;
|
|
122
|
+
mcpToken: string;
|
|
123
|
+
mcpUrl: string;
|
|
124
|
+
mcpPrAttribution: boolean;
|
|
116
125
|
gitIdentityEnabled: boolean;
|
|
117
126
|
gitIdentityName: string;
|
|
118
127
|
gitIdentityEmail: string;
|
|
@@ -398,6 +407,57 @@
|
|
|
398
407
|
claudeDirFormEl?.requestSubmit();
|
|
399
408
|
}
|
|
400
409
|
|
|
410
|
+
// svelte-ignore state_referenced_locally
|
|
411
|
+
let mcp = $state(mcpEnabled);
|
|
412
|
+
// Unlike every other secret here this one round-trips to the client — a human has to copy it.
|
|
413
|
+
// svelte-ignore state_referenced_locally
|
|
414
|
+
let mcpTokenValue = $state(mcpToken);
|
|
415
|
+
let savingMcpToggle = $state(false);
|
|
416
|
+
let mcpToggleError = $state<string | null>(null);
|
|
417
|
+
let savingMcpToken = $state(false);
|
|
418
|
+
let mcpTokenError = $state<string | null>(null);
|
|
419
|
+
let mcpCopied = $state<string | null>(null);
|
|
420
|
+
|
|
421
|
+
const mcpToggleOpts = toggleOpts<{ enabled: boolean; token: string }>({
|
|
422
|
+
set: (v) => (mcp = v),
|
|
423
|
+
setSaving: (v) => (savingMcpToggle = v),
|
|
424
|
+
setError: (v) => (mcpToggleError = v),
|
|
425
|
+
onSuccess: (data) => (mcpTokenValue = data?.token ?? '')
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
const mcpTokenOpts = saveOpts<{ token: string }>({
|
|
429
|
+
setSaving: (v) => (savingMcpToken = v),
|
|
430
|
+
setError: (v) => (mcpTokenError = v),
|
|
431
|
+
onSuccess: (data) => (mcpTokenValue = data?.token ?? ''),
|
|
432
|
+
confirmMessage:
|
|
433
|
+
'Regenerate the MCP token? Any agent already configured with the current token will stop working.'
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
// svelte-ignore state_referenced_locally
|
|
437
|
+
let mcpAttribution = $state(mcpPrAttribution);
|
|
438
|
+
let savingMcpAttribution = $state(false);
|
|
439
|
+
let mcpAttributionError = $state<string | null>(null);
|
|
440
|
+
|
|
441
|
+
const mcpAttributionOpts = toggleOpts({
|
|
442
|
+
set: (v) => (mcpAttribution = v),
|
|
443
|
+
setSaving: (v) => (savingMcpAttribution = v),
|
|
444
|
+
setError: (v) => (mcpAttributionError = v)
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
const mcpCommand = $derived(
|
|
448
|
+
`claude mcp add --transport http codebay ${mcpUrl} --header "Authorization: Bearer ${mcpTokenValue}"`
|
|
449
|
+
);
|
|
450
|
+
|
|
451
|
+
async function copyMcp(label: string, text: string) {
|
|
452
|
+
try {
|
|
453
|
+
await navigator.clipboard.writeText(text);
|
|
454
|
+
mcpCopied = label;
|
|
455
|
+
setTimeout(() => (mcpCopied = null), 1500);
|
|
456
|
+
} catch {
|
|
457
|
+
mcpTokenError = 'Could not copy — select the text and copy it manually.';
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
401
461
|
// svelte-ignore state_referenced_locally
|
|
402
462
|
let gitIdentity = $state(gitIdentityEnabled);
|
|
403
463
|
let savingGitToggle = $state(false);
|
|
@@ -1230,6 +1290,116 @@
|
|
|
1230
1290
|
</form>
|
|
1231
1291
|
</section>
|
|
1232
1292
|
|
|
1293
|
+
<section class="card">
|
|
1294
|
+
<form class="row" method="POST" action="?/mcpToggle" {@attach enhance(mcpToggleOpts)}>
|
|
1295
|
+
<div class="label">
|
|
1296
|
+
<Plug size={20} />
|
|
1297
|
+
<div class="text">
|
|
1298
|
+
<div class="name">MCP server</div>
|
|
1299
|
+
<div class="desc">
|
|
1300
|
+
Expose Codebay to other AI agents over MCP, so they can create sandboxes, run Claude
|
|
1301
|
+
Code in them non-interactively and read back the result. Off by default; the endpoint
|
|
1302
|
+
404s until you turn it on, then authenticates with the bearer token below.
|
|
1303
|
+
</div>
|
|
1304
|
+
</div>
|
|
1305
|
+
</div>
|
|
1306
|
+
<label class="switch">
|
|
1307
|
+
<input
|
|
1308
|
+
type="checkbox"
|
|
1309
|
+
name="enabled"
|
|
1310
|
+
checked={mcp}
|
|
1311
|
+
disabled={savingMcpToggle}
|
|
1312
|
+
onchange={(e) => {
|
|
1313
|
+
mcp = e.currentTarget.checked;
|
|
1314
|
+
e.currentTarget.form?.requestSubmit();
|
|
1315
|
+
}}
|
|
1316
|
+
/>
|
|
1317
|
+
<span class="track"><span class="thumb"></span></span>
|
|
1318
|
+
</label>
|
|
1319
|
+
</form>
|
|
1320
|
+
{#if mcpToggleError}
|
|
1321
|
+
<div class="sub"><div class="msg error">{mcpToggleError}</div></div>
|
|
1322
|
+
{/if}
|
|
1323
|
+
|
|
1324
|
+
{#if mcp}
|
|
1325
|
+
<div class="row divided">
|
|
1326
|
+
<div class="label">
|
|
1327
|
+
<div class="text">
|
|
1328
|
+
<div class="name">Token</div>
|
|
1329
|
+
<div class="desc">
|
|
1330
|
+
Anything holding this token can create containers and run agents with your GitHub
|
|
1331
|
+
and Claude credentials. Treat it like a password.
|
|
1332
|
+
</div>
|
|
1333
|
+
</div>
|
|
1334
|
+
</div>
|
|
1335
|
+
<div class="model-fields">
|
|
1336
|
+
<label class="model-row">
|
|
1337
|
+
<span class="model-label">Bearer</span>
|
|
1338
|
+
<input
|
|
1339
|
+
type="text"
|
|
1340
|
+
class="image-input"
|
|
1341
|
+
value={mcpTokenValue}
|
|
1342
|
+
readonly
|
|
1343
|
+
spellcheck="false"
|
|
1344
|
+
onfocus={(e) => e.currentTarget.select()}
|
|
1345
|
+
/>
|
|
1346
|
+
</label>
|
|
1347
|
+
<div class="mcp-actions">
|
|
1348
|
+
<Button type="button" onclick={() => copyMcp('token', mcpTokenValue)}>
|
|
1349
|
+
{mcpCopied === 'token' ? 'Copied' : 'Copy token'}
|
|
1350
|
+
</Button>
|
|
1351
|
+
<Button type="button" onclick={() => copyMcp('command', mcpCommand)}>
|
|
1352
|
+
{mcpCopied === 'command' ? 'Copied' : 'Copy claude mcp add'}
|
|
1353
|
+
</Button>
|
|
1354
|
+
<form method="POST" action="?/mcpRegenerateToken" {@attach enhance(mcpTokenOpts)}>
|
|
1355
|
+
<Button type="submit" disabled={savingMcpToken}>
|
|
1356
|
+
{savingMcpToken ? 'Regenerating…' : 'Regenerate'}
|
|
1357
|
+
</Button>
|
|
1358
|
+
</form>
|
|
1359
|
+
</div>
|
|
1360
|
+
<code class="mcp-command">{mcpCommand}</code>
|
|
1361
|
+
{#if mcpTokenError}
|
|
1362
|
+
<div class="msg error">{mcpTokenError}</div>
|
|
1363
|
+
{/if}
|
|
1364
|
+
</div>
|
|
1365
|
+
</div>
|
|
1366
|
+
|
|
1367
|
+
<form
|
|
1368
|
+
class="row divided"
|
|
1369
|
+
method="POST"
|
|
1370
|
+
action="?/mcpPrAttributionToggle"
|
|
1371
|
+
{@attach enhance(mcpAttributionOpts)}
|
|
1372
|
+
>
|
|
1373
|
+
<div class="label">
|
|
1374
|
+
<div class="text">
|
|
1375
|
+
<div class="name">Credit Codebay on pull requests</div>
|
|
1376
|
+
<div class="desc">
|
|
1377
|
+
Append a one-line footer to the body of every pull request an agent opens through
|
|
1378
|
+
<code>create_pr</code>. Off by default — an attribution line on your PRs is yours to
|
|
1379
|
+
opt into.
|
|
1380
|
+
</div>
|
|
1381
|
+
</div>
|
|
1382
|
+
</div>
|
|
1383
|
+
<label class="switch">
|
|
1384
|
+
<input
|
|
1385
|
+
type="checkbox"
|
|
1386
|
+
name="enabled"
|
|
1387
|
+
checked={mcpAttribution}
|
|
1388
|
+
disabled={savingMcpAttribution}
|
|
1389
|
+
onchange={(e) => {
|
|
1390
|
+
mcpAttribution = e.currentTarget.checked;
|
|
1391
|
+
e.currentTarget.form?.requestSubmit();
|
|
1392
|
+
}}
|
|
1393
|
+
/>
|
|
1394
|
+
<span class="track"><span class="thumb"></span></span>
|
|
1395
|
+
</label>
|
|
1396
|
+
</form>
|
|
1397
|
+
{#if mcpAttributionError}
|
|
1398
|
+
<div class="sub"><div class="msg error">{mcpAttributionError}</div></div>
|
|
1399
|
+
{/if}
|
|
1400
|
+
{/if}
|
|
1401
|
+
</section>
|
|
1402
|
+
|
|
1233
1403
|
<section class="card">
|
|
1234
1404
|
<form
|
|
1235
1405
|
class="row"
|
|
@@ -2693,6 +2863,22 @@
|
|
|
2693
2863
|
flex: 1;
|
|
2694
2864
|
min-width: 220px;
|
|
2695
2865
|
}
|
|
2866
|
+
.mcp-actions {
|
|
2867
|
+
display: flex;
|
|
2868
|
+
flex-wrap: wrap;
|
|
2869
|
+
gap: 6px;
|
|
2870
|
+
}
|
|
2871
|
+
/* The registration line is long and unbreakable, so it scrolls rather than widening the card. */
|
|
2872
|
+
.mcp-command {
|
|
2873
|
+
display: block;
|
|
2874
|
+
overflow-x: auto;
|
|
2875
|
+
white-space: pre;
|
|
2876
|
+
padding: 6px 8px;
|
|
2877
|
+
border: 1px solid var(--edge);
|
|
2878
|
+
background: var(--panel);
|
|
2879
|
+
font-size: 11px;
|
|
2880
|
+
line-height: 1.5;
|
|
2881
|
+
}
|
|
2696
2882
|
.model-row {
|
|
2697
2883
|
display: flex;
|
|
2698
2884
|
align-items: center;
|
|
@@ -183,7 +183,8 @@
|
|
|
183
183
|
terminal_split: 0,
|
|
184
184
|
git_branch: cardBranch ? branchName : null,
|
|
185
185
|
attention: cardAttention,
|
|
186
|
-
forwarded_ports: demoPorts
|
|
186
|
+
forwarded_ports: demoPorts,
|
|
187
|
+
active_run: null
|
|
187
188
|
});
|
|
188
189
|
|
|
189
190
|
const statuses: Instance['status'][] = ['creating', 'running', 'stopped', 'error'];
|
|
@@ -234,7 +235,8 @@
|
|
|
234
235
|
terminal_split: 0,
|
|
235
236
|
git_branch: 'main',
|
|
236
237
|
attention: null,
|
|
237
|
-
forwarded_ports: []
|
|
238
|
+
forwarded_ports: [],
|
|
239
|
+
active_run: null
|
|
238
240
|
};
|
|
239
241
|
})
|
|
240
242
|
);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { statSync } from 'node:fs';
|
|
2
2
|
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { isAbsolute } from 'node:path';
|
|
3
4
|
import { writeContainerFile } from '../lib/container-files.server.ts';
|
|
4
5
|
import {
|
|
5
6
|
claudeConfigFile,
|
|
@@ -34,7 +35,9 @@ const isExistingFile = (p: string): boolean => {
|
|
|
34
35
|
export function extractScriptPath(command: string): string | null {
|
|
35
36
|
const token = command
|
|
36
37
|
.split(/\s+/)
|
|
37
|
-
|
|
38
|
+
// isAbsolute rather than a leading slash, so a Windows `C:…` command is recognised too;
|
|
39
|
+
// on POSIX the two are equivalent. The file check still rejects a bare root either way.
|
|
40
|
+
.find((t) => (isAbsolute(t) || t.startsWith('~/')) && isExistingFile(expandTilde(t)));
|
|
38
41
|
return token ?? null;
|
|
39
42
|
}
|
|
40
43
|
|
|
@@ -51,8 +51,19 @@ export function ghHostBlock(raw: string, host: string): string | null {
|
|
|
51
51
|
return rest.slice(0, end === -1 ? rest.length : end).join('\n');
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
/** Mirrors `gh`'s own resolution order, which is not XDG on Windows. */
|
|
55
|
+
function ghConfigDir(): string {
|
|
56
|
+
const explicit = process.env.GH_CONFIG_DIR?.trim();
|
|
57
|
+
if (explicit) return explicit;
|
|
58
|
+
if (process.platform === 'win32') {
|
|
59
|
+
return join(process.env.AppData || join(homedir(), 'AppData', 'Roaming'), 'GitHub CLI');
|
|
60
|
+
}
|
|
61
|
+
const xdg = process.env.XDG_CONFIG_HOME?.trim();
|
|
62
|
+
return xdg ? join(xdg, 'gh') : join(homedir(), '.config', 'gh');
|
|
63
|
+
}
|
|
64
|
+
|
|
54
65
|
async function readGhHostsFile(): Promise<string | null> {
|
|
55
|
-
const file = join(
|
|
66
|
+
const file = join(ghConfigDir(), 'hosts.yml');
|
|
56
67
|
if (!existsSync(file)) return null;
|
|
57
68
|
try {
|
|
58
69
|
return await readFile(file, 'utf8');
|
package/src/index.ts
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import { Mochi, sequence, silenceInternalRoutes } from 'mochi-framework';
|
|
2
2
|
import { routes } from './routes.ts';
|
|
3
|
-
import { basicAuth } from './lib/auth.server.ts';
|
|
3
|
+
import { basicAuth, tokenAuthenticated } from './lib/auth.server.ts';
|
|
4
4
|
import { themeHandle } from './lib/theme.server.ts';
|
|
5
5
|
import { PROXY_PREFIX } from './lib/proxy.server.ts';
|
|
6
|
+
import { resumeRuns } from './lib/agent-runs.server.ts';
|
|
7
|
+
import { warnIfBuildStale } from './lib/build-freshness.server.ts';
|
|
8
|
+
import { LOCK_STALE_MS, acquireDataDirLock } from './lib/data-dir-lock.server.ts';
|
|
6
9
|
import {
|
|
7
10
|
APP_VERSION,
|
|
8
11
|
BASIC_AUTH_PASSWORD,
|
|
12
|
+
DATA_DIR,
|
|
9
13
|
HOST,
|
|
10
14
|
PORT,
|
|
11
15
|
PUBLIC_ORIGIN,
|
|
@@ -21,10 +25,32 @@ if (HOST !== '127.0.0.1' && HOST !== 'localhost' && !BASIC_AUTH_PASSWORD) {
|
|
|
21
25
|
`⚠ Binding to ${HOST} (non-loopback) without a password — anyone on the network can reach this server.`
|
|
22
26
|
);
|
|
23
27
|
}
|
|
28
|
+
warnIfBuildStale();
|
|
29
|
+
|
|
30
|
+
const lock = acquireDataDirLock(DATA_DIR);
|
|
31
|
+
if (!lock.ok) {
|
|
32
|
+
const who = lock.holder
|
|
33
|
+
? `pid ${lock.holder.pid} on ${lock.holder.host}, started ${new Date(lock.holder.startedAt).toLocaleString()}`
|
|
34
|
+
: 'an unknown process';
|
|
35
|
+
console.error(
|
|
36
|
+
`✖ Another codebay manager (${who}) is already using ${DATA_DIR}. Two managers on one data dir ` +
|
|
37
|
+
"poll the same runs and overwrite each other's rows — stop it, or start this one with a " +
|
|
38
|
+
`different DATA_DIR. A crashed manager's lock (${lock.path}) clears itself after ${LOCK_STALE_MS / 1000}s.`
|
|
39
|
+
);
|
|
40
|
+
process.exit(1);
|
|
41
|
+
}
|
|
42
|
+
// A default Ctrl-C skips the exit hooks, and an unreleased lock refuses the restart that follows.
|
|
43
|
+
process.on('exit', () => lock.release());
|
|
44
|
+
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
|
|
45
|
+
process.on(signal, () => process.exit(signal === 'SIGINT' ? 130 : 143));
|
|
46
|
+
}
|
|
24
47
|
|
|
25
48
|
// Must precede Mochi.serve(), which is where MOCHI_KEY is read.
|
|
26
49
|
ensureMochiKey();
|
|
27
50
|
|
|
51
|
+
// Agent runs outlive the process that started them, so re-attach pollers to anything still in flight.
|
|
52
|
+
resumeRuns();
|
|
53
|
+
|
|
28
54
|
await Mochi.serve({
|
|
29
55
|
port: PORT,
|
|
30
56
|
hostname: HOST,
|
|
@@ -41,10 +67,9 @@ await Mochi.serve({
|
|
|
41
67
|
trustedOrigins: TRUSTED_ORIGINS
|
|
42
68
|
},
|
|
43
69
|
filters: {
|
|
44
|
-
// Containers
|
|
45
|
-
//
|
|
46
|
-
'csrf:check': (decision, { url }) =>
|
|
47
|
-
url.pathname.startsWith('/api/bridge/') ? null : decision,
|
|
70
|
+
// Containers and MCP clients send no Origin header, which the check would 403; safe because
|
|
71
|
+
// those routes authenticate by token, not ambient browser credentials.
|
|
72
|
+
'csrf:check': (decision, { url }) => (tokenAuthenticated(url.pathname) ? null : decision),
|
|
48
73
|
'consoleLogger:line': (line, ctx) => {
|
|
49
74
|
const kept = silenceInternalRoutes(line, ctx);
|
|
50
75
|
if (kept == null) {
|
|
@@ -53,8 +78,8 @@ await Mochi.serve({
|
|
|
53
78
|
if (ctx.source.name === 'ws:message' && ctx.path.startsWith(PROXY_PREFIX + '/')) {
|
|
54
79
|
return null;
|
|
55
80
|
}
|
|
56
|
-
//
|
|
57
|
-
if (ctx.path
|
|
81
|
+
// A line per Claude hook event or JSON-RPC frame is noise, and each would print its token.
|
|
82
|
+
if (tokenAuthenticated(ctx.path)) {
|
|
58
83
|
return null;
|
|
59
84
|
}
|
|
60
85
|
return kept;
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chunk-oriented because the poller tails raw bytes that usually end mid-line, and free of
|
|
3
|
+
* Node/Bun APIs so it can be unit-tested on its own.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** Everything the run row needs, accumulated across chunks. */
|
|
7
|
+
export interface RunStreamState {
|
|
8
|
+
sessionId: string | null;
|
|
9
|
+
/** The model Claude actually ran with (an id like `claude-opus-5`), from the `init` event. */
|
|
10
|
+
model: string | null;
|
|
11
|
+
lastActivity: string | null;
|
|
12
|
+
numTurns: number | null;
|
|
13
|
+
costUsd: number | null;
|
|
14
|
+
durationMs: number | null;
|
|
15
|
+
result: string | null;
|
|
16
|
+
/** Serialized `structured_output`, present only when the caller passed a `--json-schema`. */
|
|
17
|
+
structuredOutput: string | null;
|
|
18
|
+
isError: boolean;
|
|
19
|
+
/** True once the terminal `result` event has been seen, whatever its subtype. */
|
|
20
|
+
resultSeen: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function emptyRunState(): RunStreamState {
|
|
24
|
+
return {
|
|
25
|
+
sessionId: null,
|
|
26
|
+
model: null,
|
|
27
|
+
lastActivity: null,
|
|
28
|
+
numTurns: null,
|
|
29
|
+
costUsd: null,
|
|
30
|
+
durationMs: null,
|
|
31
|
+
result: null,
|
|
32
|
+
structuredOutput: null,
|
|
33
|
+
isError: false,
|
|
34
|
+
resultSeen: false
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Long enough to identify the step, short enough to sit on one line of a caller's poll output. */
|
|
39
|
+
const ACTIVITY_MAX = 120;
|
|
40
|
+
|
|
41
|
+
/** The timeline has a whole panel to render into, so it keeps more of each line than the poller. */
|
|
42
|
+
const TIMELINE_MAX = 400;
|
|
43
|
+
|
|
44
|
+
function truncate(text: string, max = ACTIVITY_MAX): string {
|
|
45
|
+
const oneLine = text.replace(/\s+/g, ' ').trim();
|
|
46
|
+
return oneLine.length > max ? oneLine.slice(0, max - 1) + '…' : oneLine;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The field that best identifies a call, per tool; anything unlisted falls back to the first string. */
|
|
50
|
+
const TOOL_SUMMARY_FIELDS = [
|
|
51
|
+
'command',
|
|
52
|
+
'file_path',
|
|
53
|
+
'path',
|
|
54
|
+
'pattern',
|
|
55
|
+
'description',
|
|
56
|
+
'query',
|
|
57
|
+
'url',
|
|
58
|
+
'prompt'
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
function summarizeToolInput(input: unknown): string {
|
|
62
|
+
if (typeof input !== 'object' || input === null) return '';
|
|
63
|
+
const obj = input as Record<string, unknown>;
|
|
64
|
+
for (const field of TOOL_SUMMARY_FIELDS) {
|
|
65
|
+
const value = obj[field];
|
|
66
|
+
if (typeof value === 'string' && value.trim()) return value;
|
|
67
|
+
}
|
|
68
|
+
const first = Object.values(obj).find((v) => typeof v === 'string' && v.trim());
|
|
69
|
+
return typeof first === 'string' ? first : '';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
interface ContentBlock {
|
|
73
|
+
type?: string;
|
|
74
|
+
text?: string;
|
|
75
|
+
name?: string;
|
|
76
|
+
input?: unknown;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The last block wins, so `last_activity` reflects the most recent thing Claude did in the turn. */
|
|
80
|
+
function activityFromBlocks(blocks: ContentBlock[]): string | null {
|
|
81
|
+
let activity: string | null = null;
|
|
82
|
+
for (const block of blocks) {
|
|
83
|
+
if (block.type === 'tool_use' && typeof block.name === 'string') {
|
|
84
|
+
const arg = summarizeToolInput(block.input);
|
|
85
|
+
activity = arg ? `${block.name}(${truncate(arg)})` : block.name;
|
|
86
|
+
} else if (block.type === 'text' && typeof block.text === 'string' && block.text.trim()) {
|
|
87
|
+
activity = truncate(block.text);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return activity;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
interface StreamEvent {
|
|
94
|
+
type?: string;
|
|
95
|
+
subtype?: string;
|
|
96
|
+
session_id?: string;
|
|
97
|
+
model?: string;
|
|
98
|
+
parent_tool_use_id?: string | null;
|
|
99
|
+
message?: { content?: ContentBlock[] };
|
|
100
|
+
error?: string;
|
|
101
|
+
attempt?: number;
|
|
102
|
+
result?: string;
|
|
103
|
+
structured_output?: unknown;
|
|
104
|
+
is_error?: boolean;
|
|
105
|
+
num_turns?: number;
|
|
106
|
+
total_cost_usd?: number;
|
|
107
|
+
duration_ms?: number;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function applyEvent(state: RunStreamState, event: StreamEvent): void {
|
|
111
|
+
if (typeof event.session_id === 'string' && !state.sessionId) state.sessionId = event.session_id;
|
|
112
|
+
|
|
113
|
+
if (event.type === 'system' && event.subtype === 'init' && typeof event.model === 'string') {
|
|
114
|
+
state.model = event.model;
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (event.type === 'system' && event.subtype === 'api_retry') {
|
|
119
|
+
state.lastActivity = `Retrying (${event.error ?? 'error'}), attempt ${event.attempt ?? 1}…`;
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (event.type === 'assistant' && Array.isArray(event.message?.content)) {
|
|
124
|
+
// Subagent chatter would otherwise drown out what the main loop is doing.
|
|
125
|
+
if (event.parent_tool_use_id != null) return;
|
|
126
|
+
const activity = activityFromBlocks(event.message.content);
|
|
127
|
+
if (activity) state.lastActivity = activity;
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (event.type === 'result') {
|
|
132
|
+
state.resultSeen = true;
|
|
133
|
+
state.isError = event.is_error === true || event.subtype !== 'success';
|
|
134
|
+
if (typeof event.result === 'string') state.result = event.result;
|
|
135
|
+
if (event.structured_output !== undefined && event.structured_output !== null) {
|
|
136
|
+
state.structuredOutput = JSON.stringify(event.structured_output);
|
|
137
|
+
}
|
|
138
|
+
if (typeof event.num_turns === 'number') state.numTurns = event.num_turns;
|
|
139
|
+
if (typeof event.total_cost_usd === 'number') state.costUsd = event.total_cost_usd;
|
|
140
|
+
if (typeof event.duration_ms === 'number') state.durationMs = event.duration_ms;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Folds a chunk of newline-delimited JSON into `state`, returning the incomplete trailing line for
|
|
146
|
+
* the caller to prepend next time. A line that won't parse is skipped rather than fatal — a torn
|
|
147
|
+
* write reappears whole on the following pass.
|
|
148
|
+
*/
|
|
149
|
+
export function readRunChunk(
|
|
150
|
+
state: RunStreamState,
|
|
151
|
+
carry: string,
|
|
152
|
+
chunk: string
|
|
153
|
+
): { state: RunStreamState; carry: string } {
|
|
154
|
+
const lines = (carry + chunk).split('\n');
|
|
155
|
+
const tail = lines.pop() ?? '';
|
|
156
|
+
for (const line of lines) {
|
|
157
|
+
const trimmed = line.trim();
|
|
158
|
+
if (!trimmed) continue;
|
|
159
|
+
try {
|
|
160
|
+
applyEvent(state, JSON.parse(trimmed) as StreamEvent);
|
|
161
|
+
} catch {
|
|
162
|
+
// Not a torn line but genuine garbage (a stray shell warning); ignore it.
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return { state, carry: tail };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Replays a whole mirror file — how a run's state is rebuilt after the manager restarts. */
|
|
169
|
+
export function readRunFile(text: string): RunStreamState {
|
|
170
|
+
return readRunChunk(emptyRunState(), '', text).state;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** One rendered row of the Agent log panel. */
|
|
174
|
+
export interface RunTimelineEntry {
|
|
175
|
+
kind: 'tool' | 'text' | 'retry' | 'result';
|
|
176
|
+
/** Tool name, when `kind` is `'tool'`. */
|
|
177
|
+
name?: string;
|
|
178
|
+
text: string;
|
|
179
|
+
/** True for a subagent's message, which the panel indents rather than hides. */
|
|
180
|
+
subagent?: boolean;
|
|
181
|
+
isError?: boolean;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function timelineFromEvent(event: StreamEvent): RunTimelineEntry[] {
|
|
185
|
+
if (event.type === 'system' && event.subtype === 'api_retry') {
|
|
186
|
+
return [
|
|
187
|
+
{
|
|
188
|
+
kind: 'retry',
|
|
189
|
+
text: `Retrying (${event.error ?? 'error'}), attempt ${event.attempt ?? 1}…`
|
|
190
|
+
}
|
|
191
|
+
];
|
|
192
|
+
}
|
|
193
|
+
if (event.type === 'assistant' && Array.isArray(event.message?.content)) {
|
|
194
|
+
const subagent = event.parent_tool_use_id != null;
|
|
195
|
+
const out: RunTimelineEntry[] = [];
|
|
196
|
+
for (const block of event.message.content) {
|
|
197
|
+
if (block.type === 'tool_use' && typeof block.name === 'string') {
|
|
198
|
+
const arg = summarizeToolInput(block.input);
|
|
199
|
+
out.push({
|
|
200
|
+
kind: 'tool',
|
|
201
|
+
name: block.name,
|
|
202
|
+
text: truncate(arg, TIMELINE_MAX),
|
|
203
|
+
subagent
|
|
204
|
+
});
|
|
205
|
+
} else if (block.type === 'text' && typeof block.text === 'string' && block.text.trim()) {
|
|
206
|
+
out.push({ kind: 'text', text: truncate(block.text, TIMELINE_MAX), subagent });
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return out;
|
|
210
|
+
}
|
|
211
|
+
if (event.type === 'result') {
|
|
212
|
+
const isError = event.is_error === true || event.subtype !== 'success';
|
|
213
|
+
return [
|
|
214
|
+
{
|
|
215
|
+
kind: 'result',
|
|
216
|
+
text: truncate(event.result ?? event.subtype ?? 'finished', TIMELINE_MAX),
|
|
217
|
+
isError
|
|
218
|
+
}
|
|
219
|
+
];
|
|
220
|
+
}
|
|
221
|
+
return [];
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Flattens a whole mirrored run into the rows the Agent log renders. Separate from `readRunChunk`
|
|
226
|
+
* because that one only keeps the *latest* of each field — a timeline needs every step.
|
|
227
|
+
*/
|
|
228
|
+
export function parseRunTimeline(text: string): RunTimelineEntry[] {
|
|
229
|
+
const out: RunTimelineEntry[] = [];
|
|
230
|
+
for (const line of text.split('\n')) {
|
|
231
|
+
const trimmed = line.trim();
|
|
232
|
+
if (!trimmed) continue;
|
|
233
|
+
try {
|
|
234
|
+
for (const entry of timelineFromEvent(JSON.parse(trimmed) as StreamEvent)) {
|
|
235
|
+
// Claude's closing message arrives twice — once as the final assistant text block and
|
|
236
|
+
// again as the `result` event's text — so the result supersedes it rather than repeats it.
|
|
237
|
+
const prev = out.at(-1);
|
|
238
|
+
if (entry.kind === 'result' && prev?.kind === 'text' && prev.text === entry.text) out.pop();
|
|
239
|
+
out.push(entry);
|
|
240
|
+
}
|
|
241
|
+
} catch {
|
|
242
|
+
// A torn trailing line; it reappears whole once more bytes land.
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return out;
|
|
246
|
+
}
|