codebay 0.15.0 → 0.16.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 +493 -432
- package/.mochi/svelte-client/HydratableIsland-6y9p903x.js +1 -0
- package/.mochi/svelte-client/_hydrate-AppShell_2rtnxn6pgp2rh-nv6yzk0x.js +2 -0
- package/.mochi/svelte-client/_hydrate-AvatarsView_257s9yq1wwr4f-ry83ck8a.js +14 -0
- package/.mochi/svelte-client/_hydrate-InstanceView_2ma45l9bybfhl-vjt4jscf.js +3 -0
- package/.mochi/svelte-client/_hydrate-SettingsView_8hmarwmf8onq-1jdrk8x6.js +48 -0
- package/.mochi/svelte-client/{chunk-5pg0be5f.js → chunk-0vqvn81s.js} +1 -1
- package/.mochi/svelte-client/chunk-2dc6352a.js +1 -0
- package/.mochi/svelte-client/chunk-3cxaw5hr.js +2 -0
- package/.mochi/svelte-client/chunk-3jqc40sp.js +2 -0
- package/.mochi/svelte-client/chunk-3pgw6t5j.js +4 -0
- package/.mochi/svelte-client/chunk-7g3xt6ez.js +2 -0
- package/.mochi/svelte-client/chunk-b5s3mpg0.js +1 -0
- package/.mochi/svelte-client/chunk-bhseezf3.js +2 -0
- package/.mochi/svelte-client/chunk-bhwkq8er.js +1 -0
- package/.mochi/svelte-client/chunk-jm3ge348.js +1 -0
- package/.mochi/svelte-client/{chunk-ds9yn9cq.js → chunk-sxcr9gzp.js} +1 -1
- package/.mochi/svelte-client/{chunk-a2hty99b.js → chunk-tss2473w.js} +1 -1
- package/.mochi/svelte-compile/{App-68f9a9fq.server.js → App-8mpgrxza.server.js} +489 -295
- package/.mochi/svelte-compile/{Avatars-vyh3rwxx.server.js → Avatars-y5nf6q25.server.js} +102 -86
- package/.mochi/svelte-compile/{DefaultError-je52k17d.server.js → DefaultError-rrjye0s2.server.js} +1 -2
- package/.mochi/svelte-compile/{Instance-yfw7s5mp.server.js → Instance-3sbzyp0g.server.js} +186 -118
- package/.mochi/svelte-compile/{Settings-0b5t9kw9.server.js → Settings-ysvh5vhh.server.js} +739 -628
- package/.mochi/svelte-compile/chunk-7zaw9t5a.js +25 -0
- package/.mochi/svelte-compile/chunk-878xcmp4.js +65 -0
- package/.mochi/svelte-compile/chunk-d2z3qxxe.js +28 -0
- package/.mochi/svelte-compile/{chunk-60mpagr9.js → chunk-gnf5cnct.js} +661 -22
- package/.mochi/svelte-compile/{chunk-0y3wrae9.js → chunk-jq7jj8jd.js} +10 -6
- package/.mochi/svelte-compile/chunk-vr0e32mv.js +24 -0
- package/.mochi/svelte-compile/{chunk-apdte4nh.js → chunk-xmygdsj4.js} +35 -612
- package/.mochi/svelte-compile/{chunk-g0eravvj.js → chunk-zk4a9zms.js} +25 -17
- package/.mochi/svelte-css/DropZone-1zeitd761uwyd.css +1 -0
- package/.mochi/svelte-css/IdeBar-d3un7kuigxc5.css +1 -0
- package/.mochi/svelte-css/InstanceView-3ucotech5097w.css +1 -0
- package/README.md +13 -4
- package/migrations/0011_agent_run_rejected_structured_output.sql +2 -0
- package/package.json +9 -9
- package/src/__fixtures__/IdeBarStrip.svelte +41 -0
- package/src/avatars/dog.ts +1 -1
- package/src/avatars/lightning.ts +8 -9
- package/src/avatars/rocket.ts +4 -5
- package/src/components/AppShell.svelte +101 -1
- package/src/components/DropZone.svelte +77 -0
- package/src/components/HealthBox.svelte +6 -5
- package/src/components/IdeBar.svelte +222 -76
- package/src/components/InstanceView.svelte +75 -2
- package/src/components/SettingsView.svelte +87 -0
- package/src/components/TerminalPane.svelte +54 -0
- package/src/components/UiShowcase.svelte +19 -1
- package/src/container-injections/claude-code-install.ts +1 -1
- package/src/container-injections/claude-history.ts +52 -0
- package/src/container-injections/tmux.ts +6 -2
- package/src/index.ts +3 -0
- package/src/lib/agent-run-stream.ts +41 -1
- package/src/lib/agent-runs.server.ts +14 -3
- package/src/lib/config.server.ts +7 -1
- package/src/lib/db.server.ts +6 -2
- package/src/lib/devcontainer.server.ts +33 -2
- package/src/lib/injections.server.ts +5 -1
- package/src/lib/instances.server.ts +21 -4
- package/src/lib/log-capture.server.ts +46 -18
- package/src/lib/osc52.ts +18 -0
- package/src/lib/upload.ts +32 -0
- package/src/lib/uploads.server.ts +164 -0
- package/src/mcp/server.server.ts +4 -1
- package/src/mcp/tools.server.ts +178 -12
- package/src/pages/Instance.svelte +6 -2
- package/src/pages/Settings.svelte +3 -0
- package/src/routes.ts +16 -2
- package/src/types.ts +2 -0
- package/.mochi/svelte-client/HydratableIsland-smgsf0ma.js +0 -1
- package/.mochi/svelte-client/_hydrate-AppShell_2rtnxn6pgp2rh-9vc087ep.js +0 -2
- package/.mochi/svelte-client/_hydrate-AvatarsView_257s9yq1wwr4f-a48xq47a.js +0 -14
- package/.mochi/svelte-client/_hydrate-InstanceView_2ma45l9bybfhl-d4zehrqz.js +0 -3
- package/.mochi/svelte-client/_hydrate-SettingsView_8hmarwmf8onq-t5175pf5.js +0 -44
- package/.mochi/svelte-client/chunk-0mstgbtr.js +0 -2
- package/.mochi/svelte-client/chunk-0q9g0kpg.js +0 -1
- package/.mochi/svelte-client/chunk-3fwjzw1r.js +0 -1
- package/.mochi/svelte-client/chunk-fn03gxer.js +0 -4
- package/.mochi/svelte-client/chunk-jhgq7f65.js +0 -2
- package/.mochi/svelte-client/chunk-p5a07kx1.js +0 -1
- package/.mochi/svelte-client/chunk-p5wpyhsf.js +0 -2
- package/.mochi/svelte-client/chunk-t2kfvtb6.js +0 -2
- package/.mochi/svelte-client/chunk-v3wg9mdy.js +0 -1
- package/.mochi/svelte-compile/chunk-2qzc07gt.js +0 -34
- package/.mochi/svelte-compile/chunk-ef3hvegw.js +0 -21
- package/.mochi/svelte-compile/chunk-f9td8ej2.js +0 -24
- package/.mochi/svelte-compile/chunk-jx6h1xzp.js +0 -20
- package/.mochi/svelte-css/IdeBar-1mtgemqfuvqzi.css +0 -1
- package/.mochi/svelte-css/InstanceView-32j12hyg8v9u0.css +0 -1
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import type { Terminal } from '@xterm/xterm';
|
|
5
5
|
import type { FitAddon } from '@xterm/addon-fit';
|
|
6
6
|
import RotateCw from '@lucide/svelte/icons/rotate-cw';
|
|
7
|
+
import { decodeOsc52 } from '../lib/osc52.ts';
|
|
7
8
|
|
|
8
9
|
let {
|
|
9
10
|
id,
|
|
@@ -118,6 +119,40 @@
|
|
|
118
119
|
}
|
|
119
120
|
}
|
|
120
121
|
|
|
122
|
+
/**
|
|
123
|
+
* The Clipboard API is the only path to the *host* clipboard, but it needs a secure context and
|
|
124
|
+
* (outside a user gesture) can still reject — hence the legacy textarea fallback behind it.
|
|
125
|
+
*/
|
|
126
|
+
async function toClipboard(text: string) {
|
|
127
|
+
if (!text) return;
|
|
128
|
+
try {
|
|
129
|
+
await navigator.clipboard.writeText(text);
|
|
130
|
+
return;
|
|
131
|
+
} catch {
|
|
132
|
+
/* no Clipboard API, or a write the browser refused — fall through */
|
|
133
|
+
}
|
|
134
|
+
const ta = document.createElement('textarea');
|
|
135
|
+
ta.value = text;
|
|
136
|
+
ta.setAttribute('aria-hidden', 'true');
|
|
137
|
+
ta.style.position = 'fixed';
|
|
138
|
+
ta.style.opacity = '0';
|
|
139
|
+
document.body.appendChild(ta);
|
|
140
|
+
ta.select();
|
|
141
|
+
try {
|
|
142
|
+
document.execCommand('copy');
|
|
143
|
+
} catch {
|
|
144
|
+
/* nothing left to try */
|
|
145
|
+
}
|
|
146
|
+
ta.remove();
|
|
147
|
+
if (active && focus) term?.focus();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** A finished drag is both the moment the selection exists and the gesture the clipboard needs. */
|
|
151
|
+
function copySelection() {
|
|
152
|
+
const sel = term?.getSelection();
|
|
153
|
+
if (sel) void toClipboard(sel);
|
|
154
|
+
}
|
|
155
|
+
|
|
121
156
|
function fitSafe() {
|
|
122
157
|
// A hidden pane (display:none) has no size, so fitting there would throw or size to 0.
|
|
123
158
|
if (el?.clientWidth > 0 && el?.clientHeight > 0) {
|
|
@@ -165,6 +200,21 @@
|
|
|
165
200
|
term.open(el);
|
|
166
201
|
applyTheme();
|
|
167
202
|
fitSafe();
|
|
203
|
+
// Without this, tmux's `set-clipboard on` writes land in a handler xterm doesn't have.
|
|
204
|
+
term.parser.registerOscHandler(52, (data) => {
|
|
205
|
+
const text = decodeOsc52(data);
|
|
206
|
+
if (text !== null) void toClipboard(text);
|
|
207
|
+
return true;
|
|
208
|
+
});
|
|
209
|
+
// Chrome keeps Ctrl+Shift+C for devtools, so a browser terminal needs its own copy key.
|
|
210
|
+
term.attachCustomKeyEventHandler((e) => {
|
|
211
|
+
if (e.type !== 'keydown' || !term?.hasSelection()) return true;
|
|
212
|
+
const isCopy =
|
|
213
|
+
e.ctrlKey && (e.key === 'Insert' || (e.altKey && e.key.toLowerCase() === 'c'));
|
|
214
|
+
if (!isCopy) return true;
|
|
215
|
+
copySelection();
|
|
216
|
+
return false;
|
|
217
|
+
});
|
|
168
218
|
term.onData((d) => send(CMD_INPUT + d));
|
|
169
219
|
term.onResize(({ cols, rows }) => send(CMD_RESIZE + JSON.stringify({ columns: cols, rows })));
|
|
170
220
|
// The theme cookie flips data-theme on <html>; re-derive xterm's colors when it does.
|
|
@@ -182,8 +232,12 @@
|
|
|
182
232
|
const sizeObs = new ResizeObserver(() => fitSafe());
|
|
183
233
|
sizeObs.observe(el);
|
|
184
234
|
|
|
235
|
+
// Shift+drag selects in xterm even while tmux owns the mouse; copy it before a repaint clears it.
|
|
236
|
+
el.addEventListener('mouseup', copySelection);
|
|
237
|
+
|
|
185
238
|
return () => {
|
|
186
239
|
disposed = true;
|
|
240
|
+
el.removeEventListener('mouseup', copySelection);
|
|
187
241
|
sizeObs.disconnect();
|
|
188
242
|
themeObs?.disconnect();
|
|
189
243
|
scheme.removeEventListener('change', onScheme);
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
import SettingsCog from './SettingsCog.svelte';
|
|
15
15
|
import TopBar from './TopBar.svelte';
|
|
16
16
|
import IdeLoader from './IdeLoader.svelte';
|
|
17
|
+
import DropZone from './DropZone.svelte';
|
|
17
18
|
import HealthBox from './HealthBox.svelte';
|
|
18
19
|
import BranchBox from './BranchBox.svelte';
|
|
19
20
|
import PortsBox from './PortsBox.svelte';
|
|
@@ -213,6 +214,8 @@
|
|
|
213
214
|
let tabEditingName = $state('');
|
|
214
215
|
// Renames are applied for real, so the inline editor is inspectable end to end.
|
|
215
216
|
let tabRenames = $state<Record<string, string>>({});
|
|
217
|
+
// No container behind the demo, so a stopped tab just stays disabled.
|
|
218
|
+
let tabStopping = $state<string[]>([]);
|
|
216
219
|
|
|
217
220
|
const demoTabs = $derived(
|
|
218
221
|
Array.from({ length: tabCount }, (_, i): Instance => {
|
|
@@ -463,6 +466,15 @@
|
|
|
463
466
|
{/snippet}
|
|
464
467
|
</ComponentDemo>
|
|
465
468
|
|
|
469
|
+
<ComponentDemo
|
|
470
|
+
title="DropZone"
|
|
471
|
+
note="Shown while a file drag is over an instance; the page decides when."
|
|
472
|
+
>
|
|
473
|
+
<div class="loader-stage">
|
|
474
|
+
<DropZone />
|
|
475
|
+
</div>
|
|
476
|
+
</ComponentDemo>
|
|
477
|
+
|
|
466
478
|
<ComponentDemo title="HealthBox">
|
|
467
479
|
<HealthBox
|
|
468
480
|
health={demoHealth}
|
|
@@ -555,6 +567,12 @@
|
|
|
555
567
|
bind:editingName={tabEditingName}
|
|
556
568
|
onreload={() => toast('Reload editor (demo)')}
|
|
557
569
|
onselect={(id) => (tabActive = demoTabs.findIndex((t) => t.id === id))}
|
|
570
|
+
stopping={tabStopping}
|
|
571
|
+
onstop={(id) => {
|
|
572
|
+
tabStopping = [...tabStopping, id];
|
|
573
|
+
toast(`Stop ${id} (demo)`);
|
|
574
|
+
}}
|
|
575
|
+
ondropfiles={(id, files) => toast(`Drop ${files.length} file(s) on ${id} (demo)`)}
|
|
558
576
|
onstartrename={(inst) => {
|
|
559
577
|
tabEditingId = inst.id;
|
|
560
578
|
tabEditingName = inst.name;
|
|
@@ -677,7 +695,7 @@
|
|
|
677
695
|
.showcase :global(.fields input[type='checkbox']) {
|
|
678
696
|
accent-color: var(--ink);
|
|
679
697
|
}
|
|
680
|
-
/* IdeLoader
|
|
698
|
+
/* IdeLoader and DropZone are inset:0 overlays, so give them a sized, positioned stage. */
|
|
681
699
|
.loader-stage {
|
|
682
700
|
position: relative;
|
|
683
701
|
width: 100%;
|
|
@@ -49,7 +49,7 @@ const CHECK_SCRIPT = 'command -v claude >/dev/null 2>&1 && echo 1 || echo 0';
|
|
|
49
49
|
*/
|
|
50
50
|
export const claudeCodeInstall: Injection = {
|
|
51
51
|
id: 'claude-code-install',
|
|
52
|
-
label: 'Claude Code',
|
|
52
|
+
label: 'Claude Code installed',
|
|
53
53
|
modes: ['terminal'],
|
|
54
54
|
|
|
55
55
|
async apply(target, log) {
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { Injection } from '../lib/injections.server.ts';
|
|
2
|
+
import { CLAUDE_RESTORE_MOUNT } from '../lib/devcontainer.server.ts';
|
|
3
|
+
import { afterMarker, checkPresence, execInContainer } from '../lib/exec.server.ts';
|
|
4
|
+
|
|
5
|
+
const RESTORE_MARKER = '__CODEBAY_RESTORE__';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `cp -n` isn't in busybox, so missing files are selected explicitly; the count rides a marker
|
|
9
|
+
* because the login shell's profile output precedes anything the script prints.
|
|
10
|
+
*/
|
|
11
|
+
export function restoreScript(mountDir: string = CLAUDE_RESTORE_MOUNT): string {
|
|
12
|
+
return (
|
|
13
|
+
// The host mirror's modes don't survive the mount, so match the 0600 Claude Code writes.
|
|
14
|
+
`umask 077; ` +
|
|
15
|
+
`h=$(eval echo ~$(id -un)); cfg="\${CLAUDE_CONFIG_DIR:-$h/.claude}"; ` +
|
|
16
|
+
`cd ${mountDir} 2>/dev/null || { printf '%s0\\n' '${RESTORE_MARKER}'; exit 0; }; ` +
|
|
17
|
+
// Listed to a file, not piped: a pipeline would run the loop in a subshell the count dies with.
|
|
18
|
+
// Redirecting the loop (rather than word-splitting `find`) also survives a path with spaces.
|
|
19
|
+
`l="\${TMPDIR:-/tmp}/codebay-restore.$$"; find . -type f > "$l"; n=0; ` +
|
|
20
|
+
`while IFS= read -r f; do ` +
|
|
21
|
+
`d="$cfg/\${f#./}"; ` +
|
|
22
|
+
// A file the container already has is live state; the mirror is only ever a floor.
|
|
23
|
+
`[ -e "$d" ] && continue; ` +
|
|
24
|
+
`mkdir -p "$(dirname "$d")" && cp "$f" "$d" && n=$((n + 1)); ` +
|
|
25
|
+
`done < "$l"; rm -f "$l"; printf '%s%s\\n' '${RESTORE_MARKER}' "$n"`
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A rebuild recreates the container filesystem, so `~/.claude` — and with it every transcript
|
|
31
|
+
* `/resume` offers — starts empty. The host mirror is bind-mounted back in read-only; this copies
|
|
32
|
+
* whatever the fresh container is missing out of it.
|
|
33
|
+
*/
|
|
34
|
+
export const claudeHistory: Injection = {
|
|
35
|
+
id: 'claude-history',
|
|
36
|
+
label: 'Claude history',
|
|
37
|
+
|
|
38
|
+
async apply(target, log) {
|
|
39
|
+
const res = await execInContainer(target, { script: restoreScript(), capture: true });
|
|
40
|
+
if (!res.ok) {
|
|
41
|
+
log(`⚠ Claude history restore failed: ${res.error}\n`);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
const count = Number(afterMarker(res.stdout, RESTORE_MARKER)?.trim());
|
|
45
|
+
if (count > 0) log(`✓ Restored ${count} Claude conversation file(s) from a previous build\n`);
|
|
46
|
+
},
|
|
47
|
+
|
|
48
|
+
// The mount, not the copied files, is what the next rebuild restores from; compose configs drop it.
|
|
49
|
+
async check(target) {
|
|
50
|
+
return checkPresence(target, `[ -d ${CLAUDE_RESTORE_MOUNT} ] && echo 1 || echo 0`);
|
|
51
|
+
}
|
|
52
|
+
};
|
|
@@ -15,13 +15,17 @@ export const INSTALL_SCRIPT =
|
|
|
15
15
|
'command -v tmux >/dev/null 2>&1';
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
|
-
* `mouse on` buys wheel-scroll
|
|
19
|
-
*
|
|
18
|
+
* `mouse on` buys wheel-scroll and makes tmux — not the browser — own the drag, which is what
|
|
19
|
+
* survives Claude's screen repaints; `set-clipboard on` then hands the copy to the host via OSC 52,
|
|
20
|
+
* which `TerminalPane` decodes. `bind m` shadows mark-pane, unused in a single pane.
|
|
20
21
|
*/
|
|
21
22
|
export const TMUX_CONF_LINES = [
|
|
22
23
|
'set -g mouse on',
|
|
23
24
|
'set -g history-limit 50000',
|
|
24
25
|
'set -g set-clipboard on',
|
|
26
|
+
// Only `xterm*` gets the clipboard feature by default, so an image whose TERM is anything
|
|
27
|
+
// else silently turns `set-clipboard on` into a no-op.
|
|
28
|
+
"set -as terminal-overrides ',*:Ms=\\E]52;%p1%s;%p2%s\\007'",
|
|
25
29
|
'set -g allow-passthrough on',
|
|
26
30
|
'set -g status off',
|
|
27
31
|
'bind m set -g mouse \\; display-message "mouse: #{?mouse,on,off}"'
|
package/src/index.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { PROXY_PREFIX } from './lib/proxy.server.ts';
|
|
|
6
6
|
import { resumeRuns } from './lib/agent-runs.server.ts';
|
|
7
7
|
import { warnIfBuildStale } from './lib/build-freshness.server.ts';
|
|
8
8
|
import { LOCK_STALE_MS, acquireDataDirLock } from './lib/data-dir-lock.server.ts';
|
|
9
|
+
import { UPLOAD_MAX_BYTES } from './lib/uploads.server.ts';
|
|
9
10
|
import {
|
|
10
11
|
APP_VERSION,
|
|
11
12
|
BASIC_AUTH_PASSWORD,
|
|
@@ -56,6 +57,8 @@ await Mochi.serve({
|
|
|
56
57
|
hostname: HOST,
|
|
57
58
|
// Bun defaults to 10s and aborts slower form POSTs mid-flight; passed through to Bun.serve.
|
|
58
59
|
idleTimeout: 120,
|
|
60
|
+
// Bun's own default (128 MiB) is below UPLOAD_MAX_BYTES; passed through to Bun.serve.
|
|
61
|
+
maxRequestBodySize: UPLOAD_MAX_BYTES,
|
|
59
62
|
development: process.env.MODE === 'development',
|
|
60
63
|
htmlShell: './src/shell.html',
|
|
61
64
|
handle: sequence(basicAuth, themeHandle),
|
|
@@ -3,6 +3,15 @@
|
|
|
3
3
|
* Node/Bun APIs so it can be unit-tested on its own.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
/** Claude Code's terminal subtype when every `--json-schema` attempt failed validation. */
|
|
7
|
+
const STRUCTURED_OUTPUT_RETRY_SUBTYPE = 'error_max_structured_output_retries';
|
|
8
|
+
|
|
9
|
+
/** The tool Claude Code calls to hand back a `--json-schema` payload. */
|
|
10
|
+
const STRUCTURED_OUTPUT_TOOL = 'StructuredOutput';
|
|
11
|
+
|
|
12
|
+
/** A run's `error` when the agent answered but its schema payload never validated. */
|
|
13
|
+
export const STRUCTURED_OUTPUT_FAILED_ERROR = 'structured_output_failed';
|
|
14
|
+
|
|
6
15
|
/** Everything the run row needs, accumulated across chunks. */
|
|
7
16
|
export interface RunStreamState {
|
|
8
17
|
sessionId: string | null;
|
|
@@ -15,6 +24,14 @@ export interface RunStreamState {
|
|
|
15
24
|
result: string | null;
|
|
16
25
|
/** Serialized `structured_output`, present only when the caller passed a `--json-schema`. */
|
|
17
26
|
structuredOutput: string | null;
|
|
27
|
+
/** The last untruncated main-loop text block — the answer to fall back on when the schema fails. */
|
|
28
|
+
lastAssistantText: string | null;
|
|
29
|
+
/** Serialized input of the latest `StructuredOutput` call, whether or not it was accepted. */
|
|
30
|
+
lastStructuredOutputInput: string | null;
|
|
31
|
+
/** Set on the terminal event: the payload the schema rejected, for a caller to salvage. */
|
|
32
|
+
rejectedStructuredOutput: string | null;
|
|
33
|
+
/** True when the run died on `--json-schema` retry exhaustion rather than a crash. */
|
|
34
|
+
structuredOutputFailed: boolean;
|
|
18
35
|
isError: boolean;
|
|
19
36
|
/** True once the terminal `result` event has been seen, whatever its subtype. */
|
|
20
37
|
resultSeen: boolean;
|
|
@@ -30,6 +47,10 @@ export function emptyRunState(): RunStreamState {
|
|
|
30
47
|
durationMs: null,
|
|
31
48
|
result: null,
|
|
32
49
|
structuredOutput: null,
|
|
50
|
+
lastAssistantText: null,
|
|
51
|
+
lastStructuredOutputInput: null,
|
|
52
|
+
rejectedStructuredOutput: null,
|
|
53
|
+
structuredOutputFailed: false,
|
|
33
54
|
isError: false,
|
|
34
55
|
resultSeen: false
|
|
35
56
|
};
|
|
@@ -123,6 +144,17 @@ function applyEvent(state: RunStreamState, event: StreamEvent): void {
|
|
|
123
144
|
if (event.type === 'assistant' && Array.isArray(event.message?.content)) {
|
|
124
145
|
// Subagent chatter would otherwise drown out what the main loop is doing.
|
|
125
146
|
if (event.parent_tool_use_id != null) return;
|
|
147
|
+
for (const block of event.message.content) {
|
|
148
|
+
if (block.type === 'text' && typeof block.text === 'string' && block.text.trim()) {
|
|
149
|
+
state.lastAssistantText = block.text.trim();
|
|
150
|
+
} else if (
|
|
151
|
+
block.type === 'tool_use' &&
|
|
152
|
+
block.name === STRUCTURED_OUTPUT_TOOL &&
|
|
153
|
+
block.input
|
|
154
|
+
) {
|
|
155
|
+
state.lastStructuredOutputInput = JSON.stringify(block.input);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
126
158
|
const activity = activityFromBlocks(event.message.content);
|
|
127
159
|
if (activity) state.lastActivity = activity;
|
|
128
160
|
return;
|
|
@@ -135,6 +167,13 @@ function applyEvent(state: RunStreamState, event: StreamEvent): void {
|
|
|
135
167
|
if (event.structured_output !== undefined && event.structured_output !== null) {
|
|
136
168
|
state.structuredOutput = JSON.stringify(event.structured_output);
|
|
137
169
|
}
|
|
170
|
+
if (event.subtype === STRUCTURED_OUTPUT_RETRY_SUBTYPE) {
|
|
171
|
+
state.structuredOutputFailed = true;
|
|
172
|
+
// Retries only run out once every attempt was rejected, so the latest one is a rejected one.
|
|
173
|
+
state.rejectedStructuredOutput = state.lastStructuredOutputInput;
|
|
174
|
+
// Claude Code leaves `result` empty on this subtype, throwing away an answer it did give.
|
|
175
|
+
if (!state.result?.trim()) state.result = state.lastAssistantText;
|
|
176
|
+
}
|
|
138
177
|
if (typeof event.num_turns === 'number') state.numTurns = event.num_turns;
|
|
139
178
|
if (typeof event.total_cost_usd === 'number') state.costUsd = event.total_cost_usd;
|
|
140
179
|
if (typeof event.duration_ms === 'number') state.durationMs = event.duration_ms;
|
|
@@ -213,7 +252,8 @@ function timelineFromEvent(event: StreamEvent): RunTimelineEntry[] {
|
|
|
213
252
|
return [
|
|
214
253
|
{
|
|
215
254
|
kind: 'result',
|
|
216
|
-
|
|
255
|
+
// A failed subtype often carries an empty `result`, which would render a blank row.
|
|
256
|
+
text: truncate(event.result?.trim() || event.subtype || 'finished', TIMELINE_MAX),
|
|
217
257
|
isError
|
|
218
258
|
}
|
|
219
259
|
];
|
|
@@ -40,6 +40,7 @@ import {
|
|
|
40
40
|
type ClaudePermissionMode
|
|
41
41
|
} from '../types.ts';
|
|
42
42
|
import {
|
|
43
|
+
STRUCTURED_OUTPUT_FAILED_ERROR,
|
|
43
44
|
emptyRunState,
|
|
44
45
|
parseRunTimeline,
|
|
45
46
|
readRunChunk,
|
|
@@ -312,6 +313,9 @@ export function runDetail(row: AgentRunRow) {
|
|
|
312
313
|
structured_output: row.structured_output
|
|
313
314
|
? (JSON.parse(row.structured_output) as unknown)
|
|
314
315
|
: null,
|
|
316
|
+
rejected_structured_output: row.rejected_structured_output
|
|
317
|
+
? (JSON.parse(row.rejected_structured_output) as unknown)
|
|
318
|
+
: null,
|
|
315
319
|
is_error: row.is_error === 1,
|
|
316
320
|
error: row.error,
|
|
317
321
|
exit_code: row.exit_code,
|
|
@@ -537,15 +541,21 @@ function poll(row: AgentRunRow, instance: InstanceRow): Promise<void> {
|
|
|
537
541
|
is_error: failed ? 1 : 0,
|
|
538
542
|
result: state.result,
|
|
539
543
|
structured_output: state.structuredOutput,
|
|
544
|
+
rejected_structured_output: state.rejectedStructuredOutput,
|
|
540
545
|
duration_ms: state.durationMs ?? (row.started_at ? Date.now() - row.started_at : null),
|
|
541
546
|
num_turns: state.numTurns,
|
|
542
547
|
cost_usd: state.costUsd,
|
|
543
548
|
session_id: state.sessionId,
|
|
544
549
|
model: state.model,
|
|
545
550
|
last_activity: state.lastActivity,
|
|
546
|
-
//
|
|
547
|
-
//
|
|
548
|
-
|
|
551
|
+
// A schema failure gets its own marker so a caller can tell "answered but the payload
|
|
552
|
+
// never validated" (result holds that answer) from "claude crashed"; otherwise the
|
|
553
|
+
// stream's own result text beats stderr, which only fills in on an outright crash.
|
|
554
|
+
error: failed
|
|
555
|
+
? state.structuredOutputFailed
|
|
556
|
+
? STRUCTURED_OUTPUT_FAILED_ERROR
|
|
557
|
+
: stderr || state.result || `claude exited ${exitCode}`
|
|
558
|
+
: null
|
|
549
559
|
});
|
|
550
560
|
return;
|
|
551
561
|
}
|
|
@@ -614,6 +624,7 @@ export function startRun(
|
|
|
614
624
|
options: JSON.stringify(opts),
|
|
615
625
|
result: null,
|
|
616
626
|
structured_output: null,
|
|
627
|
+
rejected_structured_output: null,
|
|
617
628
|
last_activity: null,
|
|
618
629
|
is_error: 0,
|
|
619
630
|
exit_code: null,
|
package/src/lib/config.server.ts
CHANGED
|
@@ -48,12 +48,18 @@ export const APP_VERSION: string = (() => {
|
|
|
48
48
|
}
|
|
49
49
|
})();
|
|
50
50
|
|
|
51
|
-
/** Per-instance
|
|
51
|
+
/** Per-instance state lives here: <INSTANCES_DIR>/<id>/<repo-or-folder-name> is the working copy. */
|
|
52
52
|
export const INSTANCES_DIR = join(DATA_DIR, 'instances');
|
|
53
53
|
|
|
54
54
|
/** Extracted Claude Code logs, in one flat folder that outlives the instances they came from. */
|
|
55
55
|
export const LOGS_DIR = join(DATA_DIR, 'logs');
|
|
56
56
|
|
|
57
|
+
/**
|
|
58
|
+
* The container's Claude config dir mirrored at its original layout, so it can be mounted back in
|
|
59
|
+
* after a rebuild. Sibling of the working copy, so it dies with the instance — unlike LOGS_DIR.
|
|
60
|
+
*/
|
|
61
|
+
export const claudeMirrorDir = (id: string): string => join(INSTANCES_DIR, id, 'claude');
|
|
62
|
+
|
|
57
63
|
export const DB_PATH = join(DATA_DIR, 'app.sqlite');
|
|
58
64
|
|
|
59
65
|
export const PORT_BASE = 8001;
|
package/src/lib/db.server.ts
CHANGED
|
@@ -230,6 +230,8 @@ export interface AgentRunRow {
|
|
|
230
230
|
result: string | null;
|
|
231
231
|
/** JSON string, only when the caller passed a `--json-schema`. */
|
|
232
232
|
structured_output: string | null;
|
|
233
|
+
/** JSON string of the last schema-rejected `StructuredOutput` input, so a caller can salvage it. */
|
|
234
|
+
rejected_structured_output: string | null;
|
|
233
235
|
/** Latest tool call or assistant line, so a poller can see progress mid-run. */
|
|
234
236
|
last_activity: string | null;
|
|
235
237
|
is_error: number;
|
|
@@ -249,8 +251,8 @@ const OPEN_RUN_STATUSES = "('queued', 'running')";
|
|
|
249
251
|
export function insertRun(row: AgentRunRow): void {
|
|
250
252
|
db.query(
|
|
251
253
|
`INSERT INTO agent_runs
|
|
252
|
-
(id, instance_id, prompt, status, session_id, model, resume_session_id, options, result, structured_output, last_activity, is_error, exit_code, cost_usd, duration_ms, num_turns, error, created_at, started_at, finished_at)
|
|
253
|
-
VALUES ($id, $instance_id, $prompt, $status, $session_id, $model, $resume_session_id, $options, $result, $structured_output, $last_activity, $is_error, $exit_code, $cost_usd, $duration_ms, $num_turns, $error, $created_at, $started_at, $finished_at)`
|
|
254
|
+
(id, instance_id, prompt, status, session_id, model, resume_session_id, options, result, structured_output, rejected_structured_output, last_activity, is_error, exit_code, cost_usd, duration_ms, num_turns, error, created_at, started_at, finished_at)
|
|
255
|
+
VALUES ($id, $instance_id, $prompt, $status, $session_id, $model, $resume_session_id, $options, $result, $structured_output, $rejected_structured_output, $last_activity, $is_error, $exit_code, $cost_usd, $duration_ms, $num_turns, $error, $created_at, $started_at, $finished_at)`
|
|
254
256
|
).run({
|
|
255
257
|
$id: row.id,
|
|
256
258
|
$instance_id: row.instance_id,
|
|
@@ -262,6 +264,7 @@ export function insertRun(row: AgentRunRow): void {
|
|
|
262
264
|
$options: row.options,
|
|
263
265
|
$result: row.result,
|
|
264
266
|
$structured_output: row.structured_output,
|
|
267
|
+
$rejected_structured_output: row.rejected_structured_output,
|
|
265
268
|
$last_activity: row.last_activity,
|
|
266
269
|
$is_error: row.is_error,
|
|
267
270
|
$exit_code: row.exit_code,
|
|
@@ -307,6 +310,7 @@ const UPDATABLE_RUN_COLUMNS = [
|
|
|
307
310
|
'model',
|
|
308
311
|
'result',
|
|
309
312
|
'structured_output',
|
|
313
|
+
'rejected_structured_output',
|
|
310
314
|
'last_activity',
|
|
311
315
|
'is_error',
|
|
312
316
|
'exit_code',
|
|
@@ -117,7 +117,10 @@ const MANAGER_GIT_EXCLUDES = [
|
|
|
117
117
|
'/.devcontainer/codebay-ttyd/',
|
|
118
118
|
'/.devcontainer/codebay-claude/',
|
|
119
119
|
'/.devcontainer/codebay-terminal.sh',
|
|
120
|
-
'/.vscode/tasks.json'
|
|
120
|
+
'/.vscode/tasks.json',
|
|
121
|
+
// Self-excluding on top of this (see uploads.server.ts), so an already-running instance whose
|
|
122
|
+
// info/exclude predates the upload feature still hides the folder until its next rebuild.
|
|
123
|
+
'/codebay-inbox/'
|
|
121
124
|
];
|
|
122
125
|
|
|
123
126
|
/** Bound the manager-owned block so a rebuild can replace it without touching the user's own lines. */
|
|
@@ -536,6 +539,7 @@ type DevcontainerConfig = {
|
|
|
536
539
|
postStartCommand?: unknown;
|
|
537
540
|
runArgs?: string[];
|
|
538
541
|
containerEnv?: Record<string, string>;
|
|
542
|
+
mounts?: unknown[];
|
|
539
543
|
[key: string]: unknown;
|
|
540
544
|
};
|
|
541
545
|
|
|
@@ -594,7 +598,8 @@ export async function writeOverrideConfig(
|
|
|
594
598
|
defaultImage: string = DEFAULT_IMAGE,
|
|
595
599
|
mode: InstanceMode = 'ide',
|
|
596
600
|
permissionMode: ClaudePermissionMode = 'default',
|
|
597
|
-
envVars: { name: string; value: string }[] = []
|
|
601
|
+
envVars: { name: string; value: string }[] = [],
|
|
602
|
+
claudeRestoreDir: string | null = null
|
|
598
603
|
): Promise<{ imageSource: string; configPath: string | null; overrideConfigPath: string }> {
|
|
599
604
|
const isTerminal = mode === 'terminal';
|
|
600
605
|
const canonical = findDevcontainerConfig(workspaceDir);
|
|
@@ -681,6 +686,19 @@ export async function writeOverrideConfig(
|
|
|
681
686
|
};
|
|
682
687
|
}
|
|
683
688
|
|
|
689
|
+
// Read-only so the container can't rewrite its own history; claude-history copies out of it.
|
|
690
|
+
// Our entry is re-rendered rather than merged (like appPort) so it can never accumulate, while
|
|
691
|
+
// the project's own mounts — string or object form — pass through untouched.
|
|
692
|
+
if (claudeRestoreDir) {
|
|
693
|
+
const existingMounts = Array.isArray(config.mounts) ? config.mounts : [];
|
|
694
|
+
config.mounts = [
|
|
695
|
+
...existingMounts.filter((m) => !isClaudeRestoreMount(m)),
|
|
696
|
+
// Docker Desktop takes a Windows path with forward slashes; a backslash would have to
|
|
697
|
+
// survive both JSON escaping and the CLI's comma-separated --mount parser.
|
|
698
|
+
`source=${claudeRestoreDir.replaceAll('\\', '/')},target=${CLAUDE_RESTORE_MOUNT},type=bind,readonly`
|
|
699
|
+
];
|
|
700
|
+
}
|
|
701
|
+
|
|
684
702
|
await mkdir(join(workspaceDir, '.devcontainer'), { recursive: true }).catch(() => {});
|
|
685
703
|
await writeFile(target, JSON.stringify(config, null, 2) + '\n', 'utf8');
|
|
686
704
|
|
|
@@ -707,6 +725,19 @@ export async function writeOverrideConfig(
|
|
|
707
725
|
return { imageSource, configPath: canonical, overrideConfigPath: target };
|
|
708
726
|
}
|
|
709
727
|
|
|
728
|
+
/** Where the instance's host-side Claude mirror is mounted, for `claude-history` to restore from. */
|
|
729
|
+
export const CLAUDE_RESTORE_MOUNT = '/codebay/claude-restore';
|
|
730
|
+
|
|
731
|
+
/** Matches our own entry in either devcontainer.json mount form, so the rewrite stays idempotent. */
|
|
732
|
+
function isClaudeRestoreMount(mount: unknown): boolean {
|
|
733
|
+
if (typeof mount === 'string') return mount.split(',').includes(`target=${CLAUDE_RESTORE_MOUNT}`);
|
|
734
|
+
return (
|
|
735
|
+
typeof mount === 'object' &&
|
|
736
|
+
mount !== null &&
|
|
737
|
+
(mount as { target?: unknown }).target === CLAUDE_RESTORE_MOUNT
|
|
738
|
+
);
|
|
739
|
+
}
|
|
740
|
+
|
|
710
741
|
/** `codebay-tmux` has been injected into every config this manager has ever written. */
|
|
711
742
|
const OVERRIDE_FINGERPRINT = TMUX_FEATURE_DIR;
|
|
712
743
|
|
|
@@ -8,6 +8,7 @@ import { gitIdentity } from '../container-injections/git-identity.ts';
|
|
|
8
8
|
import { claudeCodeCredentials } from '../container-injections/claude-code-credentials.ts';
|
|
9
9
|
import { claudeCodeCustom } from '../container-injections/claude-code-custom.ts';
|
|
10
10
|
import { claudeCodeSkills } from '../container-injections/claude-code-skills.ts';
|
|
11
|
+
import { claudeHistory } from '../container-injections/claude-history.ts';
|
|
11
12
|
import { claudeCodeIdeExtension } from '../container-injections/claude-code-ide-extension.ts';
|
|
12
13
|
import { codeServerDark } from '../container-injections/code-server-dark.ts';
|
|
13
14
|
import { claudeCodeInstall } from '../container-injections/claude-code-install.ts';
|
|
@@ -70,7 +71,10 @@ function buildStages(claudeInjection: Injection): Injection[][] {
|
|
|
70
71
|
claudeInjection,
|
|
71
72
|
claudeCodeSkills,
|
|
72
73
|
claudeCodeIdeExtension,
|
|
73
|
-
claudeEffortLevel
|
|
74
|
+
claudeEffortLevel,
|
|
75
|
+
// Only writes `projects/**` + `history.jsonl`, which no other injection touches, and
|
|
76
|
+
// lands before the injections-done sentinel releases the launcher's `claude`.
|
|
77
|
+
claudeHistory
|
|
74
78
|
],
|
|
75
79
|
// git-identity needs stage 1's safe.directory; ttyd shares the apt/dpkg lock with tmux and
|
|
76
80
|
// the /usr/local/bin symlink with claude-code-install, so it trails both. code-server-dark
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { mkdirSync } from 'node:fs';
|
|
1
2
|
import { rm, stat } from 'node:fs/promises';
|
|
2
3
|
import { basename, join } from 'node:path';
|
|
3
4
|
import {
|
|
5
|
+
claudeMirrorDir,
|
|
4
6
|
CODE_SERVER_PORT,
|
|
5
7
|
DATA_DIR,
|
|
6
8
|
DEFAULT_COPY_IGNORE,
|
|
@@ -74,6 +76,7 @@ import { runCapturePass, stopLogCapture, syncLogCapture } from './log-capture.se
|
|
|
74
76
|
import { currentRunSummaries, runSummary, setRunChangeHook, stopRun } from './agent-runs.server.ts';
|
|
75
77
|
import { isHostPortBindable, pickBindablePort } from './ports.server.ts';
|
|
76
78
|
import { pickAvatar, pickUniqueAvatar } from '../avatars/pick.server.ts';
|
|
79
|
+
import { uploadEnabled } from './uploads.server.ts';
|
|
77
80
|
import type { ServerWebSocket } from 'bun';
|
|
78
81
|
import {
|
|
79
82
|
isInstanceFilter,
|
|
@@ -203,7 +206,9 @@ export type StreamEvent =
|
|
|
203
206
|
// The global default editor surface, so the picker's toggle follows a settings change.
|
|
204
207
|
| { type: 'default-mode'; data: { mode: InstanceMode } }
|
|
205
208
|
// The colour scheme, so a change in the settings popup repaints the window behind it.
|
|
206
|
-
| { type: 'theme'; data: { value: Theme } }
|
|
209
|
+
| { type: 'theme'; data: { value: Theme } }
|
|
210
|
+
// Arms/disarms the drop zones on every open page when the setting flips.
|
|
211
|
+
| { type: 'upload-enabled'; data: { enabled: boolean } };
|
|
207
212
|
|
|
208
213
|
interface StreamHub {
|
|
209
214
|
sockets: Set<ServerWebSocket<unknown>>;
|
|
@@ -277,6 +282,11 @@ export function broadcastTheme(value: Theme): void {
|
|
|
277
282
|
broadcast({ type: 'theme', data: { value } });
|
|
278
283
|
}
|
|
279
284
|
|
|
285
|
+
/** Settings opens in its own popup, so every open dashboard/IDE tab needs the new toggle pushed. */
|
|
286
|
+
export function broadcastUploadEnabled(enabled: boolean): void {
|
|
287
|
+
broadcast({ type: 'upload-enabled', data: { enabled } });
|
|
288
|
+
}
|
|
289
|
+
|
|
280
290
|
async function reconcileInstances(force = false): Promise<void> {
|
|
281
291
|
const list = await listInstances();
|
|
282
292
|
const listJson = JSON.stringify(list);
|
|
@@ -327,6 +337,8 @@ export function streamOpen(ws: ServerWebSocket<unknown>): void {
|
|
|
327
337
|
});
|
|
328
338
|
// Same, for the default mode the picker's toggle seeds from.
|
|
329
339
|
sendTo(ws, { type: 'default-mode', data: { mode: getDefaultMode() } });
|
|
340
|
+
// Same, for the upload toggle, so a reconnecting client's drop zones match the current setting.
|
|
341
|
+
sendTo(ws, { type: 'upload-enabled', data: { enabled: uploadEnabled() } });
|
|
330
342
|
}
|
|
331
343
|
|
|
332
344
|
export function streamClose(ws: ServerWebSocket<unknown>): void {
|
|
@@ -493,6 +505,10 @@ async function provision(row: InstanceRow, opts: { noCache?: boolean } = {}): Pr
|
|
|
493
505
|
}));
|
|
494
506
|
appendLog(row.id, `Injecting ${surfaceLabel(row.mode)} (host port ${row.host_port})\n`);
|
|
495
507
|
const defaultImage = getOption('default_image') ?? DEFAULT_IMAGE;
|
|
508
|
+
// Mounted read-only for claude-history to restore from, so it has to exist before the boot —
|
|
509
|
+
// and it has to be mounted *after* the drain above, or a rebuild would restore a stale mirror.
|
|
510
|
+
const mirrorDir = claudeMirrorDir(row.id);
|
|
511
|
+
mkdirSync(mirrorDir, { recursive: true });
|
|
496
512
|
const { imageSource, configPath, overrideConfigPath } = await writeOverrideConfig(
|
|
497
513
|
row.workspace_path,
|
|
498
514
|
row.host_port,
|
|
@@ -500,7 +516,8 @@ async function provision(row: InstanceRow, opts: { noCache?: boolean } = {}): Pr
|
|
|
500
516
|
defaultImage,
|
|
501
517
|
row.mode,
|
|
502
518
|
getClaudePermissionMode(),
|
|
503
|
-
customEnvVarsConfig()?.vars ?? []
|
|
519
|
+
customEnvVarsConfig()?.vars ?? [],
|
|
520
|
+
mirrorDir
|
|
504
521
|
);
|
|
505
522
|
updateInstance(row.id, { image_source: imageSource });
|
|
506
523
|
|
|
@@ -908,8 +925,8 @@ export async function deleteInstance(id: string): Promise<void> {
|
|
|
908
925
|
await cancelActiveRun(id, 'the sandbox was deleted');
|
|
909
926
|
stopHealthMonitor(id);
|
|
910
927
|
// Mirror the tail of the session before the transcripts die with the container; the periodic
|
|
911
|
-
// chain is stopped first so it can't race this last pass.
|
|
912
|
-
//
|
|
928
|
+
// chain is stopped first so it can't race this last pass. The flat <LOGS_DIR>/*-<id>.jsonl
|
|
929
|
+
// archive is deliberately left on disk; the per-instance mirror goes with the instance below.
|
|
913
930
|
stopLogCapture(id);
|
|
914
931
|
if (row.container_id) {
|
|
915
932
|
await runCapturePass(row).catch(() => undefined);
|