@hanzo/build 0.2.30 → 0.2.32
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/lib/access.d.ts +4 -0
- package/lib/access.js +16 -0
- package/lib/api/coding.d.ts +29 -3
- package/lib/api/coding.js +42 -10
- package/lib/api/models.d.ts +5 -8
- package/lib/api/models.js +15 -1
- package/lib/api/sandbox.d.ts +41 -8
- package/lib/api/sandbox.js +98 -9
- package/lib/api/turn.d.ts +45 -14
- package/lib/api/turn.js +113 -88
- package/lib/customize/agents.js +3 -2
- package/lib/desk.d.ts +1 -1
- package/lib/desk.js +187 -16
- package/lib/door.d.ts +7 -3
- package/lib/door.js +18 -7
- package/lib/files.d.ts +4 -1
- package/lib/files.js +61 -13
- package/lib/landing.js +2 -1
- package/lib/out.d.ts +6 -0
- package/lib/out.js +20 -0
- package/lib/project.js +12 -7
- package/lib/run.js +16 -6
- package/lib/settings/code.js +2 -1
- package/lib/transcript.js +11 -23
- package/package.json +3 -3
- package/lib/api/harness.d.ts +0 -62
- package/lib/api/harness.js +0 -556
package/lib/api/turn.js
CHANGED
|
@@ -1,27 +1,5 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* What a run's events SAY, as text a person reads.
|
|
3
|
-
*
|
|
4
|
-
* A curated projection, never a dump: an event carries the machinery a run
|
|
5
|
-
* happened on — hosts, paths, whole file bodies — and printing it would put
|
|
6
|
-
* someone's filesystem in front of anyone who can read the run. Each kind gets a
|
|
7
|
-
* sentence built from the few fields that describe the outcome; the rest is
|
|
8
|
-
* dropped by omission, because a projection cannot leak a field it never reads.
|
|
9
|
-
*
|
|
10
|
-
* Every string here is rendered as TEXT by the transcript. Nothing is HTML.
|
|
11
|
-
*
|
|
12
|
-
* The coding plane's vocabulary (apps/coding): `status` for a lifecycle move
|
|
13
|
-
* (started, routed, done, error, stopped, paused), `tool-call` for a step,
|
|
14
|
-
* `log` for a free line. A person's steering is a `control` {command, message}.
|
|
15
|
-
* A run on a project with a site ends `done` with `live` when it published what
|
|
16
|
-
* it pushed, or `unpublished` saying why it did not.
|
|
17
|
-
*
|
|
18
|
-
* `cards` is the transcript as it is drawn: the run's steps, and the agent's own
|
|
19
|
-
* output read by what each part is (harness.ts). The output a card shows is what
|
|
20
|
-
* the run's Terminal log already prints; a command's working directory, which is
|
|
21
|
-
* the sandbox's layout, is dropped.
|
|
22
|
-
*/
|
|
23
|
-
import { harness } from './harness.js';
|
|
24
1
|
const str = (v) => (typeof v === 'string' ? v : '');
|
|
2
|
+
const obj = (v) => (v && typeof v === 'object' ? v : {});
|
|
25
3
|
/** The payload as an object. A live frame may carry it as a JSON string. */
|
|
26
4
|
export function decode(payload) {
|
|
27
5
|
if (payload && typeof payload === 'object')
|
|
@@ -253,31 +231,76 @@ export function pull(url, repo) {
|
|
|
253
231
|
return { href: '', label: '' };
|
|
254
232
|
return { href: url, label: `#${m[3]}` };
|
|
255
233
|
}
|
|
256
|
-
/** The platform's own words for the step that starts the agent (apps/coding sandboxrunner.go). */
|
|
257
|
-
const TASK = 'running the task';
|
|
258
234
|
/** What the run's own steps are called on a card. */
|
|
259
235
|
const NAMES = {
|
|
260
236
|
lease: 'Sandbox',
|
|
237
|
+
workspace: 'Workspace',
|
|
261
238
|
clone: 'Clone',
|
|
239
|
+
branch: 'Branch',
|
|
262
240
|
secrets: 'Secrets',
|
|
263
241
|
install: 'Install',
|
|
264
242
|
start: 'Start',
|
|
243
|
+
skills: 'Skills',
|
|
244
|
+
artifacts: 'Save artifacts',
|
|
265
245
|
push: 'Push',
|
|
266
|
-
|
|
246
|
+
kept: 'Keep the sandbox',
|
|
267
247
|
};
|
|
268
|
-
/** Steps another card already says: `leased` finishes the lease, `done` is the final status, `ended`
|
|
269
|
-
const QUIET = new Set(['leased', 'done', 'ended']);
|
|
248
|
+
/** Steps another card already says: `leased` finishes the lease, `done` is the final status, `ended` and `exit` are housekeeping. */
|
|
249
|
+
const QUIET = new Set(['leased', 'done', 'ended', 'exit']);
|
|
270
250
|
const ranOf = (status) => (status === 'ok' || status === 'done' ? 'done' : status === 'error' ? 'error' : 'running');
|
|
251
|
+
/** How an agent's item went: in progress, finished, or failed or declined. */
|
|
252
|
+
function itemRan(item) {
|
|
253
|
+
switch (str(item.status)) {
|
|
254
|
+
case 'in_progress':
|
|
255
|
+
return 'running';
|
|
256
|
+
case 'failed':
|
|
257
|
+
return 'error';
|
|
258
|
+
case 'declined':
|
|
259
|
+
return 'cancelled';
|
|
260
|
+
}
|
|
261
|
+
return typeof item.exit_code === 'number' && item.exit_code !== 0 ? 'error' : 'done';
|
|
262
|
+
}
|
|
263
|
+
/** A path in the workspace, without the sandbox's working directory above it. */
|
|
264
|
+
const rel = (path) => path.replace(/^\/work\//, '');
|
|
265
|
+
/**
|
|
266
|
+
* One item of the agent's narration as a card, or null when it says nothing a
|
|
267
|
+
* person reads. `key` is the card's, kept as the item is updated.
|
|
268
|
+
*/
|
|
269
|
+
function item(it, key) {
|
|
270
|
+
switch (str(it.type)) {
|
|
271
|
+
case 'agent_message':
|
|
272
|
+
return str(it.text) ? { kind: 'said', key, who: 'agent', text: str(it.text) } : null;
|
|
273
|
+
case 'reasoning':
|
|
274
|
+
return str(it.text) ? { kind: 'think', key, text: str(it.text) } : null;
|
|
275
|
+
case 'command_execution':
|
|
276
|
+
return { kind: 'shell', key, command: str(it.command), output: str(it.aggregated_output), ran: itemRan(it) };
|
|
277
|
+
case 'file_change': {
|
|
278
|
+
const changes = Array.isArray(it.changes) ? it.changes : [];
|
|
279
|
+
return { kind: 'edit', key, files: changes.map((c) => rel(str(obj(c).path))).filter(Boolean), ran: itemRan(it) };
|
|
280
|
+
}
|
|
281
|
+
case 'mcp_tool_call':
|
|
282
|
+
return { kind: 'step', key, name: str(it.tool) || 'Tool', detail: str(it.server), output: '', ran: itemRan(it) };
|
|
283
|
+
case 'web_search':
|
|
284
|
+
return { kind: 'step', key, name: 'Search', detail: str(it.query), output: '', ran: 'done' };
|
|
285
|
+
case 'todo_list': {
|
|
286
|
+
const items = Array.isArray(it.items) ? it.items : [];
|
|
287
|
+
return { kind: 'todo', key, items: items.map((x) => ({ text: str(obj(x).text), done: obj(x).completed === true })).filter((x) => x.text) };
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
271
292
|
/**
|
|
272
293
|
* A run's events, as the cards its transcript draws. `mode` is the run's, from
|
|
273
294
|
* its record: a plan's final status is its plan, and nothing else draws a plan.
|
|
274
295
|
*
|
|
275
|
-
* The run's own steps arrive as `tool-call` {step, message, status}
|
|
276
|
-
*
|
|
277
|
-
*
|
|
278
|
-
* agent is the agent's
|
|
279
|
-
*
|
|
280
|
-
*
|
|
296
|
+
* The run's own steps arrive as `tool-call` {step, message, status}. The agent
|
|
297
|
+
* narrates itself as `event` lines: an item is started, updated and completed
|
|
298
|
+
* under one id, and its card is drawn once and kept current. Ids count from zero
|
|
299
|
+
* in each agent process, so an id is the agent's within the thread it started. A
|
|
300
|
+
* turn that fails is one plain line with the detail folded beneath it, drawn once
|
|
301
|
+
* however many times it is said; an `error` on its own is the agent retrying and
|
|
302
|
+
* is drawn only if the turn then fails. The final status carries the agent's
|
|
303
|
+
* answer, drawn once. `log` is never drawn here.
|
|
281
304
|
*/
|
|
282
305
|
export function cards(events, mode = '') {
|
|
283
306
|
const out = [];
|
|
@@ -285,48 +308,56 @@ export function cards(events, mode = '') {
|
|
|
285
308
|
let n = 0;
|
|
286
309
|
const mint = () => `${seq}.${n++}`;
|
|
287
310
|
let phase = null;
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
let
|
|
291
|
-
const settle = () => {
|
|
292
|
-
agent?.end();
|
|
293
|
-
agent = null;
|
|
294
|
-
phase = null;
|
|
295
|
-
};
|
|
311
|
+
const items = new Map();
|
|
312
|
+
let thread = 0;
|
|
313
|
+
let said = '';
|
|
296
314
|
const answers = new Set();
|
|
297
|
-
// What the person said in an event of its own. The harness prints their ask
|
|
298
|
-
// too (harness.ts); an event saying the same words is the one drawn. Words
|
|
299
|
-
// that steered the run are not its ask.
|
|
300
|
-
const told = new Set();
|
|
301
315
|
const steered = new Set();
|
|
302
316
|
const person = (text) => {
|
|
303
317
|
const c = { kind: 'said', key: mint(), who: 'person', text };
|
|
304
318
|
out.push(c);
|
|
305
|
-
told.add(c);
|
|
306
319
|
return c;
|
|
307
320
|
};
|
|
321
|
+
const failures = new Set();
|
|
322
|
+
const failed = (message) => {
|
|
323
|
+
if (failures.has(message))
|
|
324
|
+
return;
|
|
325
|
+
failures.add(message);
|
|
326
|
+
out.push({ kind: 'note', key: mint(), text: plain(message) });
|
|
327
|
+
if (message)
|
|
328
|
+
out.push({ kind: 'step', key: mint(), name: 'Error', detail: '', output: message, ran: 'error' });
|
|
329
|
+
};
|
|
308
330
|
for (const e of [...events].sort((a, b) => a.seq - b.seq)) {
|
|
309
331
|
seq = e.seq;
|
|
310
332
|
n = 0;
|
|
311
333
|
const body = decode(e.payload);
|
|
312
334
|
const b = body && typeof body === 'object' ? body : null;
|
|
313
335
|
switch (e.kind) {
|
|
314
|
-
case '
|
|
315
|
-
|
|
316
|
-
if (!text)
|
|
336
|
+
case 'event': {
|
|
337
|
+
if (!b)
|
|
317
338
|
break;
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
339
|
+
const type = str(b.type);
|
|
340
|
+
if (type === 'thread.started')
|
|
341
|
+
thread++;
|
|
342
|
+
if (type === 'error')
|
|
343
|
+
said = str(b.message);
|
|
344
|
+
if (type === 'turn.failed')
|
|
345
|
+
failed(str(obj(b.error).message) || said);
|
|
346
|
+
if (type !== 'item.started' && type !== 'item.updated' && type !== 'item.completed')
|
|
347
|
+
break;
|
|
348
|
+
const it = obj(b.item);
|
|
349
|
+
const id = str(it.id) && `${thread}:${str(it.id)}`;
|
|
350
|
+
const at = id ? items.get(id) : undefined;
|
|
351
|
+
const card = item(it, at === undefined ? mint() : out[at].key);
|
|
352
|
+
if (!card)
|
|
353
|
+
break;
|
|
354
|
+
if (at === undefined) {
|
|
355
|
+
if (id)
|
|
356
|
+
items.set(id, out.length);
|
|
357
|
+
out.push(card);
|
|
329
358
|
}
|
|
359
|
+
else
|
|
360
|
+
out[at] = card;
|
|
330
361
|
break;
|
|
331
362
|
}
|
|
332
363
|
case 'tool-call': {
|
|
@@ -336,20 +367,10 @@ export function cards(events, mode = '') {
|
|
|
336
367
|
const message = str(b.message);
|
|
337
368
|
if (step === 'exit') {
|
|
338
369
|
// One command in the sandbox ended: `exit 0`, `exit 1`, or `ended: …`.
|
|
339
|
-
if (
|
|
340
|
-
settle();
|
|
341
|
-
exited = true;
|
|
342
|
-
}
|
|
343
|
-
else if (phase && phase.ran === 'running')
|
|
370
|
+
if (phase && phase.ran === 'running')
|
|
344
371
|
phase.ran = message === 'exit 0' ? 'done' : 'error';
|
|
345
372
|
break;
|
|
346
373
|
}
|
|
347
|
-
if (message === TASK) {
|
|
348
|
-
settle();
|
|
349
|
-
exited = false;
|
|
350
|
-
agent = harness(out, mint);
|
|
351
|
-
break;
|
|
352
|
-
}
|
|
353
374
|
if (step === 'leased') {
|
|
354
375
|
const lease = out.find((c) => c.kind === 'step' && c.name === NAMES.lease);
|
|
355
376
|
if (lease)
|
|
@@ -364,26 +385,33 @@ export function cards(events, mode = '') {
|
|
|
364
385
|
phase.detail = message;
|
|
365
386
|
break;
|
|
366
387
|
}
|
|
367
|
-
settle();
|
|
368
388
|
phase = { kind: 'step', key: mint(), name, detail: message, output: '', ran: ranOf(str(b.status)) };
|
|
369
389
|
out.push(phase);
|
|
370
390
|
break;
|
|
371
391
|
}
|
|
372
392
|
case 'status': {
|
|
373
|
-
|
|
393
|
+
phase = null;
|
|
374
394
|
if (!b)
|
|
375
395
|
break;
|
|
376
|
-
const
|
|
377
|
-
|
|
396
|
+
const done = str(b.status) === 'done';
|
|
397
|
+
const text = str(b.plan) || str(b.answer);
|
|
398
|
+
if (done && (mode === 'plan' || mode === 'setup') && text) {
|
|
378
399
|
answers.add(text.trim());
|
|
379
400
|
out.push(mode === 'plan' ? { kind: 'plan', key: mint(), text } : { kind: 'said', key: mint(), who: 'agent', text });
|
|
380
401
|
break;
|
|
381
402
|
}
|
|
403
|
+
const answer = str(b.answer).trim();
|
|
404
|
+
if (answer) {
|
|
405
|
+
answers.add(answer);
|
|
406
|
+
out.push({ kind: 'said', key: mint(), who: 'agent', text: answer });
|
|
407
|
+
}
|
|
408
|
+
if (str(b.status) === 'error') {
|
|
409
|
+
failed(str(b.error));
|
|
410
|
+
break;
|
|
411
|
+
}
|
|
382
412
|
const line = status(b, mode);
|
|
383
413
|
if (line)
|
|
384
414
|
out.push({ kind: 'note', key: mint(), text: line });
|
|
385
|
-
if (str(b.status) === 'error' && str(b.error))
|
|
386
|
-
out.push({ kind: 'step', key: mint(), name: 'Error', detail: '', output: str(b.error), ran: 'error' });
|
|
387
415
|
break;
|
|
388
416
|
}
|
|
389
417
|
case 'control': {
|
|
@@ -413,24 +441,21 @@ export function cards(events, mode = '') {
|
|
|
413
441
|
}
|
|
414
442
|
}
|
|
415
443
|
}
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
let kept = false;
|
|
444
|
+
// The answer a status carries is also the agent's last message: each is drawn once, as the answer.
|
|
445
|
+
const kept = new Set();
|
|
419
446
|
for (let i = out.length - 1; i >= 0; i--) {
|
|
420
447
|
const c = out[i];
|
|
421
448
|
if (!((c.kind === 'said' && c.who === 'agent') || c.kind === 'plan') || !answers.has(c.text.trim()))
|
|
422
449
|
continue;
|
|
423
|
-
if (kept)
|
|
450
|
+
if (kept.has(c.text.trim()))
|
|
424
451
|
out.splice(i, 1);
|
|
425
|
-
kept
|
|
452
|
+
kept.add(c.text.trim());
|
|
426
453
|
}
|
|
427
|
-
const heard = new Set([...told].map((c) => c.text.trim()));
|
|
428
|
-
const drawn = out.filter((c) => !(c.kind === 'said' && c.who === 'person' && !told.has(c) && heard.has(c.text.trim())));
|
|
429
454
|
// The ask opens the conversation, ahead of the steps that set the run up.
|
|
430
|
-
const ask =
|
|
455
|
+
const ask = out.findIndex((c) => c.kind === 'said' && c.who === 'person' && !steered.has(c));
|
|
431
456
|
if (ask > 0)
|
|
432
|
-
|
|
433
|
-
return
|
|
457
|
+
out.unshift(...out.splice(ask, 1));
|
|
458
|
+
return out;
|
|
434
459
|
}
|
|
435
460
|
/** The plan a plan run answered with, from its final status, or ''. */
|
|
436
461
|
export function answer(events) {
|
package/lib/customize/agents.js
CHANGED
|
@@ -16,6 +16,7 @@ import { Button, Input, Switch, Textarea } from '@hanzo/ui';
|
|
|
16
16
|
import { ChipSelect } from '@hanzo/ui/product';
|
|
17
17
|
import { useEffect, useMemo, useState } from 'react';
|
|
18
18
|
import { agents, ALL, create, draft, EMPTY, fromPreset, one, PERIODS, presets, remove, update } from '../api/agents.js';
|
|
19
|
+
import { Access } from '../access.js';
|
|
19
20
|
import { label as named, models } from '../api/models.js';
|
|
20
21
|
import { tools } from '../api/tools.js';
|
|
21
22
|
import { useRead } from '../data.js';
|
|
@@ -96,7 +97,7 @@ function Editor({ agent, start, onClose, onSaved, onDeleted, }) {
|
|
|
96
97
|
}, [on.value, d.tools]);
|
|
97
98
|
const shownTools = choices.filter((x) => matches(find, x.name, x.source, x.description));
|
|
98
99
|
const items = useMemo(() => {
|
|
99
|
-
const list = [{ id: DEFAULT, label: 'Default', hint: 'the deployment’s' }, ...catalog.value
|
|
100
|
+
const list = [{ id: DEFAULT, label: 'Default', hint: 'the deployment’s' }, ...catalog.value];
|
|
100
101
|
if (d.model && !list.some((m) => m.id === d.model))
|
|
101
102
|
list.splice(1, 0, { id: d.model, label: named(d.model) });
|
|
102
103
|
return agent ? list.filter((m) => m.id !== DEFAULT) : list;
|
|
@@ -118,7 +119,7 @@ function Editor({ agent, start, onClose, onSaved, onDeleted, }) {
|
|
|
118
119
|
}
|
|
119
120
|
};
|
|
120
121
|
const pick = (name, yes) => set({ tools: yes ? [...d.tools, name] : d.tools.filter((n) => n !== name) });
|
|
121
|
-
return (_jsx(Sheet, { title: agent ? agent.name : 'New agent', open: true, onOpenChange: (o) => !o && onClose(), width: 680, children: agent && full.error && !full.value ? (_jsx(Soft, { children: full.error.message })) : !ready ? (_jsx(Soft, { children: "Reading the agent\u2026" })) : (_jsxs(_Fragment, { children: [_jsx(Field, { label: "Name", hint: agent ? 'An agent keeps its name.' : 'Letters, digits, . _ or -. Other agents call it as agent_<name>.', children: _jsx(Input, { value: d.name, onChangeText: (v) => set({ name: v }), placeholder: "helper", "aria-label": "Name", disabled: Boolean(agent), autoCapitalize: "none" }) }), _jsx(Field, { label: "Description", hint: "The line another agent reads to decide whether to call this one.", children: _jsx(Input, { value: d.description, onChangeText: (v) => set({ description: v }), placeholder: "Answers questions about our codebase", "aria-label": "Description" }) }), _jsx(Field, { label: "Model", children: _jsx(XStack, { children: _jsx(ChipSelect, { name: "Model", label: chosen?.label ?? 'Default', chosen: chosen, items: items, onChange: (m) => set({ model: m.id === DEFAULT ? '' : m.id }), placeholder: "Search models\u2026", placement: "bottom-start", loading: catalog.loading, error: catalog.error?.message ?? null }) }) }), _jsx(Field, { label: "Instructions", hint: "The system prompt: what the model reads before every run.", children: _jsx(Textarea, { value: d.instructions, onChangeText: (v) => set({ instructions: v }), placeholder: "Be terse, and cite the file you read.", "aria-label": "Instructions", rows: 8 }) }), _jsxs(YStack, { gap: "$2", children: [_jsxs(XStack, { items: "center", gap: "$3", children: [_jsxs(YStack, { flex: 1, minW: 0, children: [_jsx(SizableText, { size: "$2", color: "$ink", children: "Tools" }), _jsx(SizableText, { size: "$1", color: "$soft", children: every ? 'Every tool the fleet’s MCP server serves when it runs.' : d.tools.length ? `${d.tools.length} chosen. It can call only these.` : 'None chosen: it can call no tool.' })] }), _jsx(SizableText, { size: "$1", color: "$soft", children: "Every tool" }), _jsx(Switch, { checked: every, onCheckedChange: (v) => set({ tools: v ? [ALL] : [] }), "aria-label": "Every tool" })] }), every ? null : (_jsxs(_Fragment, { children: [_jsx(Input, { value: find, onChangeText: setFind, placeholder: "Find a tool\u2026", "aria-label": "Find a tool" }), _jsx(YStack, { borderWidth: 1, borderColor: "$borderColor", rounded: "$3", maxH: 240, overflow: "scroll", children: on.loading && !on.value.length ? (_jsx(Soft, { children: "Reading the tools that are on\u2026" })) : on.error && !on.value.length ? (_jsx(Soft, { children: on.error.message })) : !shownTools.length ? (_jsx(Soft, { children: choices.length ? 'No tool matches.' : 'No tool is on for your organization. Switch skills and connectors on first.' })) : (shownTools.map((x, i) => (_jsxs(XStack, { items: "center", gap: "$3", px: "$3", py: "$2", borderTopWidth: i ? 1 : 0, borderColor: "$borderColor", children: [_jsxs(YStack, { flex: 1, minW: 0, children: [_jsx(SizableText, { size: "$2", color: "$ink", style: mono, numberOfLines: 1, children: x.name }), _jsx(SizableText, { size: "$1", color: "$soft", numberOfLines: 1, children: [x.source, x.description].filter(Boolean).join(' · ') })] }), _jsx(Switch, { checked: d.tools.includes(x.name), onCheckedChange: (v) => pick(x.name, v), "aria-label": `Use ${x.name}` })] }, x.name)))) })] }))] }), _jsxs(YStack, { gap: "$2", children: [_jsx(SizableText, { size: "$2", color: "$ink", children: "Budget" }), _jsxs(XStack, { gap: "$3", flexWrap: "wrap", rowGap: "$2", items: "flex-end", children: [_jsxs(YStack, { gap: "$1", width: 140, children: [_jsxs(SizableText, { size: "$1", color: "$soft", children: ["Each ", d.period, ", USD"] }), _jsx(Input, { value: d.cap, onChangeText: (v) => set({ cap: v }), placeholder: "10", "aria-label": "Budget each period", inputMode: "decimal" })] }), _jsxs(YStack, { gap: "$1", width: 140, children: [_jsx(SizableText, { size: "$1", color: "$soft", children: "One run, USD" }), _jsx(Input, { value: d.task, onChangeText: (v) => set({ task: v }), placeholder: "1", "aria-label": "Budget for one run", inputMode: "decimal" })] }), _jsx(Choice, { label: "Period", value: d.period, options: PERIODS.map((p) => ({ id: p, label: p[0].toUpperCase() + p.slice(1) })), onChange: (p) => set({ period: p }) })] }), _jsxs(SizableText, { size: "$1", color: "$soft", children: [full.value && full.value.cap ? `Spent ${money(full.value.spent)} this ${full.value.period}. ` : '', "A run stops at either limit."] })] }), _jsx(Line, { children: note }), _jsxs(XStack, { gap: "$2", items: "center", children: [agent && onDeleted ? (_jsx(Button, { size: "sm", variant: "ghost", onPress: () => setAsking(true), children: "Delete" })) : null, _jsx(XStack, { flex: 1 }), _jsx(Button, { size: "sm", variant: "ghost", onPress: onClose, children: "Cancel" }), _jsx(Button, { size: "sm", disabled: working, onPress: () => void save(), children: agent ? 'Save' : 'Create' })] }), agent && onDeleted ? (_jsx(Confirm, { what: agent.name, says: "The agent and every run recorded against it are removed.", open: asking, onOpenChange: setAsking, onYes: async () => {
|
|
122
|
+
return (_jsx(Sheet, { title: agent ? agent.name : 'New agent', open: true, onOpenChange: (o) => !o && onClose(), width: 680, children: agent && full.error && !full.value ? (_jsx(Soft, { children: full.error.message })) : !ready ? (_jsx(Soft, { children: "Reading the agent\u2026" })) : (_jsxs(_Fragment, { children: [_jsx(Field, { label: "Name", hint: agent ? 'An agent keeps its name.' : 'Letters, digits, . _ or -. Other agents call it as agent_<name>.', children: _jsx(Input, { value: d.name, onChangeText: (v) => set({ name: v }), placeholder: "helper", "aria-label": "Name", disabled: Boolean(agent), autoCapitalize: "none" }) }), _jsx(Field, { label: "Description", hint: "The line another agent reads to decide whether to call this one.", children: _jsx(Input, { value: d.description, onChangeText: (v) => set({ description: v }), placeholder: "Answers questions about our codebase", "aria-label": "Description" }) }), _jsx(Field, { label: "Model", children: _jsx(XStack, { children: _jsx(ChipSelect, { name: "Model", label: chosen?.label ?? 'Default', chosen: chosen, items: items, footer: _jsx(Access, { items: items }), onChange: (m) => set({ model: m.id === DEFAULT ? '' : m.id }), placeholder: "Search models\u2026", placement: "bottom-start", loading: catalog.loading, error: catalog.error?.message ?? null }) }) }), _jsx(Field, { label: "Instructions", hint: "The system prompt: what the model reads before every run.", children: _jsx(Textarea, { value: d.instructions, onChangeText: (v) => set({ instructions: v }), placeholder: "Be terse, and cite the file you read.", "aria-label": "Instructions", rows: 8 }) }), _jsxs(YStack, { gap: "$2", children: [_jsxs(XStack, { items: "center", gap: "$3", children: [_jsxs(YStack, { flex: 1, minW: 0, children: [_jsx(SizableText, { size: "$2", color: "$ink", children: "Tools" }), _jsx(SizableText, { size: "$1", color: "$soft", children: every ? 'Every tool the fleet’s MCP server serves when it runs.' : d.tools.length ? `${d.tools.length} chosen. It can call only these.` : 'None chosen: it can call no tool.' })] }), _jsx(SizableText, { size: "$1", color: "$soft", children: "Every tool" }), _jsx(Switch, { checked: every, onCheckedChange: (v) => set({ tools: v ? [ALL] : [] }), "aria-label": "Every tool" })] }), every ? null : (_jsxs(_Fragment, { children: [_jsx(Input, { value: find, onChangeText: setFind, placeholder: "Find a tool\u2026", "aria-label": "Find a tool" }), _jsx(YStack, { borderWidth: 1, borderColor: "$borderColor", rounded: "$3", maxH: 240, overflow: "scroll", children: on.loading && !on.value.length ? (_jsx(Soft, { children: "Reading the tools that are on\u2026" })) : on.error && !on.value.length ? (_jsx(Soft, { children: on.error.message })) : !shownTools.length ? (_jsx(Soft, { children: choices.length ? 'No tool matches.' : 'No tool is on for your organization. Switch skills and connectors on first.' })) : (shownTools.map((x, i) => (_jsxs(XStack, { items: "center", gap: "$3", px: "$3", py: "$2", borderTopWidth: i ? 1 : 0, borderColor: "$borderColor", children: [_jsxs(YStack, { flex: 1, minW: 0, children: [_jsx(SizableText, { size: "$2", color: "$ink", style: mono, numberOfLines: 1, children: x.name }), _jsx(SizableText, { size: "$1", color: "$soft", numberOfLines: 1, children: [x.source, x.description].filter(Boolean).join(' · ') })] }), _jsx(Switch, { checked: d.tools.includes(x.name), onCheckedChange: (v) => pick(x.name, v), "aria-label": `Use ${x.name}` })] }, x.name)))) })] }))] }), _jsxs(YStack, { gap: "$2", children: [_jsx(SizableText, { size: "$2", color: "$ink", children: "Budget" }), _jsxs(XStack, { gap: "$3", flexWrap: "wrap", rowGap: "$2", items: "flex-end", children: [_jsxs(YStack, { gap: "$1", width: 140, children: [_jsxs(SizableText, { size: "$1", color: "$soft", children: ["Each ", d.period, ", USD"] }), _jsx(Input, { value: d.cap, onChangeText: (v) => set({ cap: v }), placeholder: "10", "aria-label": "Budget each period", inputMode: "decimal" })] }), _jsxs(YStack, { gap: "$1", width: 140, children: [_jsx(SizableText, { size: "$1", color: "$soft", children: "One run, USD" }), _jsx(Input, { value: d.task, onChangeText: (v) => set({ task: v }), placeholder: "1", "aria-label": "Budget for one run", inputMode: "decimal" })] }), _jsx(Choice, { label: "Period", value: d.period, options: PERIODS.map((p) => ({ id: p, label: p[0].toUpperCase() + p.slice(1) })), onChange: (p) => set({ period: p }) })] }), _jsxs(SizableText, { size: "$1", color: "$soft", children: [full.value && full.value.cap ? `Spent ${money(full.value.spent)} this ${full.value.period}. ` : '', "A run stops at either limit."] })] }), _jsx(Line, { children: note }), _jsxs(XStack, { gap: "$2", items: "center", children: [agent && onDeleted ? (_jsx(Button, { size: "sm", variant: "ghost", onPress: () => setAsking(true), children: "Delete" })) : null, _jsx(XStack, { flex: 1 }), _jsx(Button, { size: "sm", variant: "ghost", onPress: onClose, children: "Cancel" }), _jsx(Button, { size: "sm", disabled: working, onPress: () => void save(), children: agent ? 'Save' : 'Create' })] }), agent && onDeleted ? (_jsx(Confirm, { what: agent.name, says: "The agent and every run recorded against it are removed.", open: asking, onOpenChange: setAsking, onYes: async () => {
|
|
122
123
|
await remove(t, agent.id || agent.name);
|
|
123
124
|
onDeleted();
|
|
124
125
|
} })) : null] })) }));
|
package/lib/desk.d.ts
CHANGED
|
@@ -13,7 +13,7 @@ export declare function Desk({ id, repo, branch, base, environment, mode, pr, ti
|
|
|
13
13
|
};
|
|
14
14
|
title: string;
|
|
15
15
|
project: string;
|
|
16
|
-
/** The sandbox the run
|
|
16
|
+
/** The sandbox the run leased, or '' before it leases one and on a machine. */
|
|
17
17
|
sandbox: string;
|
|
18
18
|
/** Where the run's work is published, or '' before it is. */
|
|
19
19
|
site: string;
|
package/lib/desk.js
CHANGED
|
@@ -1,22 +1,32 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
/**
|
|
3
|
-
* The pane beside a run's transcript, as tabs: the Browser showing
|
|
4
|
-
* is published, its sandbox's Desktop and
|
|
5
|
-
* what it pushed, and its codebase's
|
|
3
|
+
* The pane beside a run's transcript, as tabs: the Browser showing what the run's
|
|
4
|
+
* sandbox serves (or where the run is published), its sandbox's Desktop and
|
|
5
|
+
* Terminal, what it produced, its files, what it pushed, and its codebase's
|
|
6
|
+
* environment.
|
|
7
|
+
*
|
|
8
|
+
* The sandbox outlives the run: kept when the run ends, parked while nobody
|
|
9
|
+
* watches, retired a day after it parks. The bar under the tabs says which and
|
|
10
|
+
* suspends or resumes it; a tab that needs it running resumes it (sandbox.ts).
|
|
6
11
|
*/
|
|
7
12
|
import { SizableText, XStack, YStack } from '@hanzo/gui';
|
|
8
13
|
import { ExternalLink, Info, MoreHorizontal, PanelRightClose, RefreshCcw } from '@hanzogui/lucide-icons-2';
|
|
9
14
|
import { Button, DropdownMenu } from '@hanzo/ui';
|
|
10
15
|
import { PreviewFrame } from '@hanzo/ui/agents';
|
|
11
|
-
import { useRef, useState } from 'react';
|
|
16
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
17
|
+
import { park, ports as listening, preview, sandbox as look, wake } from './api/sandbox.js';
|
|
12
18
|
import { failure, shell } from './api/turn.js';
|
|
13
19
|
import { Door } from './door.js';
|
|
14
20
|
import { Environment } from './environment.js';
|
|
15
21
|
import { Artifacts, Files } from './files.js';
|
|
16
22
|
import { Git } from './git.js';
|
|
17
|
-
import {
|
|
23
|
+
import { useTarget } from './host.js';
|
|
24
|
+
import { away, Out } from './out.js';
|
|
18
25
|
/** The tabs that are a surface of their own, drawn edge to edge. */
|
|
19
26
|
const BLEED = ['browser', 'desktop', 'terminal'];
|
|
27
|
+
/** How often the sandbox's state, and what it serves, is read while the pane is open. */
|
|
28
|
+
const EVERY = 10_000;
|
|
29
|
+
const PORTS = 5_000;
|
|
20
30
|
export function Desk({ id, repo, branch, base, environment, mode, pr, title, project, sandbox, site, published, events, live, menu, refused, retry, onRetry, onHide, }) {
|
|
21
31
|
// Until a tab is picked it follows the run: a setup run's work IS the
|
|
22
32
|
// environment, a published run opens on its site, any other on its terminal.
|
|
@@ -26,15 +36,177 @@ export function Desk({ id, repo, branch, base, environment, mode, pr, title, pro
|
|
|
26
36
|
const lines = shell(events);
|
|
27
37
|
const failed = failure(events);
|
|
28
38
|
const bleed = BLEED.includes(tab);
|
|
29
|
-
|
|
39
|
+
const box = useBox(sandbox);
|
|
40
|
+
// Nothing is said of the sandbox until it has been read.
|
|
41
|
+
const held = box.value?.state ?? '';
|
|
42
|
+
return (_jsxs(YStack, { role: "complementary", "aria-label": "Run details", flex: 1, minW: 0, minH: 0, borderLeftWidth: 1, borderColor: "$borderColor", display: "none", $md: { display: 'flex' }, children: [_jsxs(XStack, { px: "$2", pt: "$2", gap: "$1", borderBottomWidth: 1, borderColor: "$borderColor", items: "center", children: [_jsxs(XStack, { flex: 1, minW: 0, gap: "$1", overflow: "scroll", children: [_jsx(TabButton, { id: "browser", tab: tab, onPick: setTab, children: "Browser" }), _jsx(TabButton, { id: "desktop", tab: tab, onPick: setTab, children: "Desktop" }), _jsx(TabButton, { id: "terminal", tab: tab, onPick: setTab, children: "Terminal" }), _jsx(TabButton, { id: "artifacts", tab: tab, onPick: setTab, children: "Artifacts" }), _jsx(TabButton, { id: "files", tab: tab, onPick: setTab, children: "Files" }), _jsx(TabButton, { id: "git", tab: tab, onPick: setTab, children: "Git" }), _jsx(TabButton, { id: "environment", tab: tab, onPick: setTab, children: "Environment" })] }), _jsx(XStack, { render: "button", "aria-label": "Hide the side pane", px: "$2", py: "$1", rounded: "$2", hoverStyle: { bg: '$hover' }, onPress: onHide, children: _jsx(PanelRightClose, { size: 16 }) }), _jsx(DropdownMenu, { trigger: _jsx(XStack, { render: "button", "aria-label": "Run actions", px: "$2", py: "$1", rounded: "$2", hoverStyle: { bg: '$hover' }, children: _jsx(MoreHorizontal, { size: 16 }) }), items: [...menu, { key: 'details', label: 'Details', icon: _jsx(Info, { size: 16 }), onSelect: () => setTab('environment') }] })] }), sandbox ? _jsx(Held, { box: box, live: live }) : null, _jsxs(YStack, { flex: 1, minH: 0, overflow: bleed ? 'hidden' : 'scroll', px: bleed ? 0 : '$3', py: bleed ? 0 : '$3', children: [tab === 'environment' ? (_jsxs(YStack, { gap: "$2", children: [_jsx(Environment, { repo: name, busy: live && mode === 'setup' }), _jsx(SizableText, { size: "$2", color: "$ink", pt: "$4", children: "This run" }), _jsx(Fact, { label: "Run", value: id }), _jsx(Fact, { label: "Repository", value: name || '—' }), _jsx(Fact, { label: "Branch", value: branch || '—' }), _jsx(Fact, { label: "Base", value: base || '—' }), _jsx(Fact, { label: "Runs on", value: environment || 'sandbox' }), _jsx(Fact, { label: "Sandbox", value: sandbox || '—' }), _jsx(Fact, { label: "Mode", value: mode || 'build' })] })) : null, tab === 'browser' ? _jsx(Browser, { sandbox: sandbox, held: held, site: site, live: live }, published) : null, tab === 'git' ? _jsx(Git, { session: id, title: title, live: live }) : null, tab === 'desktop' ? _jsx(Door, { which: "screen", sandbox: sandbox, held: held, session: id }) : null, tab === 'terminal' ? (_jsx(Terminal, { id: id, sandbox: sandbox, held: held, lines: lines, live: live, failed: failed, refused: refused, retry: retry, onRetry: onRetry })) : null, tab === 'artifacts' ? (_jsx(Artifacts, { session: id, sandbox: sandbox, repo: repo, branch: branch, mode: mode, project: project, site: site, pr: pr, onEnvironment: () => setTab('environment') })) : null, tab === 'files' ? _jsx(Files, { session: id, repo: repo, branch: branch, sandbox: sandbox, live: held === 'running' }) : null] })] }));
|
|
30
43
|
}
|
|
31
44
|
/**
|
|
32
|
-
* The
|
|
33
|
-
*
|
|
45
|
+
* The run's sandbox as it stands, and what moves it. It is read while the pane is
|
|
46
|
+
* open and the page is in view, and no more once it has ended or failed.
|
|
34
47
|
*/
|
|
35
|
-
function
|
|
48
|
+
function useBox(id) {
|
|
49
|
+
const t = useTarget();
|
|
50
|
+
const [value, setValue] = useState(null);
|
|
51
|
+
const [busy, setBusy] = useState(false);
|
|
52
|
+
const [note, setNote] = useState('');
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
setValue(null);
|
|
55
|
+
if (!id)
|
|
56
|
+
return;
|
|
57
|
+
let gone = false;
|
|
58
|
+
let every;
|
|
59
|
+
const read = () => {
|
|
60
|
+
if (document.hidden)
|
|
61
|
+
return;
|
|
62
|
+
look(t, id).then((b) => {
|
|
63
|
+
if (gone)
|
|
64
|
+
return;
|
|
65
|
+
setValue(b);
|
|
66
|
+
if (b.state === 'gone' || b.state === 'error')
|
|
67
|
+
clearInterval(every);
|
|
68
|
+
}, (e) => !gone && setNote(e.message));
|
|
69
|
+
};
|
|
70
|
+
read();
|
|
71
|
+
every = setInterval(read, EVERY);
|
|
72
|
+
return () => {
|
|
73
|
+
gone = true;
|
|
74
|
+
clearInterval(every);
|
|
75
|
+
};
|
|
76
|
+
}, [t, id]);
|
|
77
|
+
const act = useCallback((how) => {
|
|
78
|
+
setBusy(true);
|
|
79
|
+
setNote('');
|
|
80
|
+
(how === 'park' ? park(t, id) : wake(t, id)).then(setValue, (e) => setNote(e.message)).finally(() => setBusy(false));
|
|
81
|
+
}, [t, id]);
|
|
82
|
+
return { value, busy, note, act };
|
|
83
|
+
}
|
|
84
|
+
/** When a parked sandbox is retired, as a person reads it. */
|
|
85
|
+
const until = (ms) => new Date(ms).toLocaleString(undefined, { weekday: 'short', hour: 'numeric', minute: '2-digit' });
|
|
86
|
+
/**
|
|
87
|
+
* The sandbox's line under the tabs: where it is, and the one thing to do to it.
|
|
88
|
+
* A sandbox a run is working in is not suspended from here.
|
|
89
|
+
*/
|
|
90
|
+
function Held({ box, live }) {
|
|
91
|
+
const b = box.value;
|
|
92
|
+
const state = b?.state ?? 'pending';
|
|
93
|
+
const says = state === 'running'
|
|
94
|
+
? 'Sandbox running'
|
|
95
|
+
: state === 'parked'
|
|
96
|
+
? b?.ends
|
|
97
|
+
? `Sandbox suspended · its files are kept until ${until(b.ends)}`
|
|
98
|
+
: 'Sandbox suspended · its files are kept'
|
|
99
|
+
: state === 'pending'
|
|
100
|
+
? 'Sandbox starting…'
|
|
101
|
+
: state === 'gone'
|
|
102
|
+
? 'Sandbox ended'
|
|
103
|
+
: `Sandbox unavailable${b?.error ? ` · ${b.error}` : ''}`;
|
|
104
|
+
return (_jsxs(XStack, { px: "$3", py: "$1.5", gap: "$2", items: "center", borderBottomWidth: 1, borderColor: "$borderColor", "aria-label": "Sandbox", children: [_jsx(YStack, { width: 7, height: 7, rounded: 99, bg: state === 'running' ? '$green10' : state === 'parked' ? '$soft' : '$borderColor' }), _jsx(SizableText, { flex: 1, minW: 0, size: "$1", color: "$soft", numberOfLines: 1, children: box.note || says }), state === 'running' && !live ? (_jsx(Button, { size: "sm", variant: "ghost", disabled: box.busy, onPress: () => box.act('park'), children: "Suspend" })) : state === 'parked' ? (_jsx(Button, { size: "sm", variant: "outline", disabled: box.busy, onPress: () => box.act('wake'), children: box.busy ? 'Resuming…' : 'Resume' })) : null] }));
|
|
105
|
+
}
|
|
106
|
+
/** What a preview page says of itself, when it cannot serve. */
|
|
107
|
+
const PREVIEW = 'hanzo-preview';
|
|
108
|
+
/** How often a preview is opened again on its own before Retry is the person's to press. */
|
|
109
|
+
const REMINTS = 2;
|
|
110
|
+
const REMINT = 30_000;
|
|
111
|
+
/** What a preview page's status means to the person looking at it, when it is not reopened. */
|
|
112
|
+
const SAYS = {
|
|
113
|
+
401: () => 'This browser keeps the preview’s cookie out of a frame. Open it in a new tab.',
|
|
114
|
+
502: (port) => `Nothing answered on port ${port}. Start the server, then Retry.`,
|
|
115
|
+
};
|
|
116
|
+
/**
|
|
117
|
+
* The Browser tab: what the run's sandbox serves, one port at a time, framed at
|
|
118
|
+
* its own origin; or, with nothing serving, where the run is published. Only a
|
|
119
|
+
* running sandbox is shown: a suspended one is resumed from the bar, never by
|
|
120
|
+
* looking. A preview that lost its sandbox (409) is opened again, at most twice
|
|
121
|
+
* in a while; any other status is said, and Retry is the person's.
|
|
122
|
+
*/
|
|
123
|
+
function Browser({ sandbox, held, site, live }) {
|
|
124
|
+
const t = useTarget();
|
|
125
|
+
const [open, setOpen] = useState([]);
|
|
126
|
+
const [port, setPort] = useState(0);
|
|
127
|
+
const [src, setSrc] = useState('');
|
|
128
|
+
const [why, setWhy] = useState('');
|
|
129
|
+
const [attempt, setAttempt] = useState(0);
|
|
130
|
+
const reopened = useRef([]);
|
|
131
|
+
const frame = useRef(null);
|
|
132
|
+
const running = Boolean(sandbox) && held === 'running';
|
|
133
|
+
// What listens, read again while the sandbox runs: a dev server appears once it listens.
|
|
134
|
+
useEffect(() => {
|
|
135
|
+
if (!running) {
|
|
136
|
+
setOpen([]);
|
|
137
|
+
setPort(0);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
let gone = false;
|
|
141
|
+
const read = () => listening(t, sandbox).then((ps) => {
|
|
142
|
+
if (gone)
|
|
143
|
+
return;
|
|
144
|
+
setOpen(ps);
|
|
145
|
+
setPort((p) => (ps.some((x) => x.port === p) ? p : (ps[0]?.port ?? 0)));
|
|
146
|
+
}, () => undefined);
|
|
147
|
+
void read();
|
|
148
|
+
const every = setInterval(read, PORTS);
|
|
149
|
+
return () => {
|
|
150
|
+
gone = true;
|
|
151
|
+
clearInterval(every);
|
|
152
|
+
};
|
|
153
|
+
}, [t, sandbox, running]);
|
|
154
|
+
// One address per port and attempt: its ticket is spent when the page opens it.
|
|
155
|
+
useEffect(() => {
|
|
156
|
+
setSrc('');
|
|
157
|
+
setWhy('');
|
|
158
|
+
if (!port || !sandbox)
|
|
159
|
+
return;
|
|
160
|
+
let gone = false;
|
|
161
|
+
preview(t, sandbox, port).then((u) => !gone && setSrc(u), (e) => !gone && setWhy(e.message));
|
|
162
|
+
return () => {
|
|
163
|
+
gone = true;
|
|
164
|
+
};
|
|
165
|
+
}, [t, sandbox, port, attempt]);
|
|
166
|
+
// A preview that can no longer serve says so; one whose sandbox stopped is opened again, a little.
|
|
167
|
+
useEffect(() => {
|
|
168
|
+
if (!src)
|
|
169
|
+
return;
|
|
170
|
+
const origin = new URL(src).origin;
|
|
171
|
+
const hear = (e) => {
|
|
172
|
+
if (e.origin !== origin || e.source !== frame.current?.contentWindow)
|
|
173
|
+
return;
|
|
174
|
+
const m = e.data;
|
|
175
|
+
if (m?.source !== PREVIEW || typeof m.status !== 'number')
|
|
176
|
+
return;
|
|
177
|
+
const now = Date.now();
|
|
178
|
+
reopened.current = reopened.current.filter((at) => now - at < REMINT);
|
|
179
|
+
if (m.status === 409 && reopened.current.length < REMINTS) {
|
|
180
|
+
reopened.current.push(now);
|
|
181
|
+
setAttempt((a) => a + 1);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
const say = SAYS[m.status];
|
|
185
|
+
if (say || m.status === 409)
|
|
186
|
+
setWhy(say ? say(port) : 'The sandbox stopped serving. Retry to open it again.');
|
|
187
|
+
};
|
|
188
|
+
window.addEventListener('message', hear);
|
|
189
|
+
return () => window.removeEventListener('message', hear);
|
|
190
|
+
}, [src, port]);
|
|
191
|
+
const tab = () => away(preview(t, sandbox, port)).catch((e) => setWhy(e.message));
|
|
192
|
+
if (!running)
|
|
193
|
+
return _jsx(Site, { site: site, live: live, held: held });
|
|
194
|
+
if (!port)
|
|
195
|
+
return _jsx(Site, { site: site, live: live, held: held });
|
|
196
|
+
const host = open.find((p) => p.port === port)?.host ?? '';
|
|
197
|
+
return (_jsxs(YStack, { flex: 1, minH: 0, children: [_jsxs(XStack, { px: "$2", py: "$1.5", gap: "$1.5", items: "center", borderBottomWidth: 1, borderColor: "$borderColor", children: [_jsx(XStack, { render: "button", "aria-label": "Reload the page", onPress: () => setAttempt((a) => a + 1), p: "$1.5", rounded: "$2", hoverStyle: { bg: '$hover' }, children: _jsx(RefreshCcw, { size: 14 }) }), open.length > 1 ? (_jsx(XStack, { gap: "$1", children: open.map((p) => (_jsx(XStack, { render: "button", "aria-label": `Port ${p.port}`, onPress: () => setPort(p.port), px: "$2", py: "$1", rounded: "$2", bg: p.port === port ? '$hover' : 'transparent', children: _jsx(SizableText, { size: "$1", color: p.port === port ? '$ink' : '$soft', children: String(p.port) }) }, p.port))) })) : null, _jsx(SizableText, { flex: 1, minW: 0, size: "$1", color: "$soft", numberOfLines: 1, style: mono, children: host || `port ${port}` }), _jsx(XStack, { render: "button", "aria-label": "Open in a new tab", onPress: () => void tab(), p: "$1.5", rounded: "$2", hoverStyle: { bg: '$hover' }, children: _jsx(ExternalLink, { size: 14 }) })] }), why ? (_jsxs(YStack, { flex: 1, items: "center", justify: "center", gap: "$2", p: "$6", children: [_jsx(SizableText, { size: "$2", color: "$soft", style: { textAlign: 'center' }, children: why }), _jsxs(XStack, { gap: "$2", children: [_jsx(Button, { size: "sm", variant: "outline", onPress: () => setAttempt((a) => a + 1), children: "Retry" }), _jsx(Button, { size: "sm", variant: "outline", onPress: () => void tab(), children: "Open in a new tab" })] })] })) : src ? (_jsx("iframe", { ref: frame, src: src, title: `What the run serves on port ${port}`, sandbox: "allow-scripts allow-same-origin allow-forms allow-popups", style: { border: 0, width: '100%', height: '100%', display: 'block', background: '#fff' } })) : (_jsx(YStack, { flex: 1, items: "center", justify: "center", children: _jsx(SizableText, { size: "$2", color: "$soft", children: `Opening port ${port}…` }) }))] }));
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Where the run published its work, framed, with its address, a reload and the
|
|
201
|
+
* page in a tab of its own; or what keeps its sandbox from serving.
|
|
202
|
+
*/
|
|
203
|
+
function Site({ site, live, held }) {
|
|
36
204
|
const frame = useRef(null);
|
|
37
|
-
return (_jsxs(YStack, { flex: 1, minH: 0, children: [_jsxs(XStack, { px: "$2", py: "$1.5", gap: "$1.5", items: "center", borderBottomWidth: 1, borderColor: "$borderColor", children: [_jsx(XStack, { render: "button", "aria-label": "Reload the page", onPress: () => frame.current?.reload(), p: "$1.5", rounded: "$2", hoverStyle: { bg: '$hover' }, children: _jsx(RefreshCcw, { size: 14 }) }), _jsx(SizableText, { flex: 1, minW: 0, size: "$1", color: "$soft", numberOfLines: 1, style: mono, children: site || '
|
|
205
|
+
return (_jsxs(YStack, { flex: 1, minH: 0, children: [_jsxs(XStack, { px: "$2", py: "$1.5", gap: "$1.5", items: "center", borderBottomWidth: 1, borderColor: "$borderColor", children: [_jsx(XStack, { render: "button", "aria-label": "Reload the page", onPress: () => frame.current?.reload(), p: "$1.5", rounded: "$2", hoverStyle: { bg: '$hover' }, children: _jsx(RefreshCcw, { size: 14 }) }), _jsx(SizableText, { flex: 1, minW: 0, size: "$1", color: "$soft", numberOfLines: 1, style: mono, children: site || 'Nothing is serving yet' }), site ? (_jsx(Out, { href: site, label: "Open in a new tab", children: _jsx(XStack, { p: "$1.5", rounded: "$2", hoverStyle: { bg: '$hover' }, children: _jsx(ExternalLink, { size: 14 }) }) })) : null] }), _jsx(PreviewFrame, { ref: frame, src: site, title: "Where this run is published", device: "desktop", empty: _jsxs(YStack, { items: "center", gap: "$2", p: "$6", children: [_jsx(SizableText, { size: "$3", color: "$ink", children: "Nothing is serving yet" }), _jsx(SizableText, { size: "$2", color: "$soft", style: { textAlign: 'center' }, children: held === 'parked'
|
|
206
|
+
? 'The run’s sandbox is suspended. Resume it to see what it serves.'
|
|
207
|
+
: live
|
|
208
|
+
? 'When the run starts a server in its sandbox, the page opens here.'
|
|
209
|
+
: 'Nothing listens in this run’s sandbox, and it published no site. Its work is on its branch.' })] }) })] }));
|
|
38
210
|
}
|
|
39
211
|
function TabButton({ id, tab, onPick, children }) {
|
|
40
212
|
const on = tab === id;
|
|
@@ -46,16 +218,15 @@ function Fact({ label, value }) {
|
|
|
46
218
|
const mono = { fontFamily: 'var(--f-mono, ui-monospace, monospace)', whiteSpace: 'pre' };
|
|
47
219
|
/**
|
|
48
220
|
* The Terminal tab: a shell in the run's sandbox, in the same working tree as the
|
|
49
|
-
* agent, while the
|
|
50
|
-
*
|
|
51
|
-
* reopened tab reattaches to it.
|
|
221
|
+
* agent, while the sandbox is kept; and the agent's log, which outlives it. The
|
|
222
|
+
* shell is a tmux session named for the run, so a reopened tab reattaches to it.
|
|
52
223
|
*/
|
|
53
|
-
function Terminal({ id, sandbox, lines, live, failed, refused, retry, onRetry, }) {
|
|
54
|
-
const reachable =
|
|
224
|
+
function Terminal({ id, sandbox, held, lines, live, failed, refused, retry, onRetry, }) {
|
|
225
|
+
const reachable = Boolean(sandbox) && held === 'running';
|
|
55
226
|
// The shell while there is one, unless the log was asked for.
|
|
56
227
|
const [log, setLog] = useState(false);
|
|
57
228
|
const showShell = reachable && !log;
|
|
58
|
-
return (_jsxs(YStack, { flex: 1, minH: 0, bg: "$background", children: [_jsxs(XStack, { px: "$2", py: "$1.5", gap: "$1", borderBottomWidth: 1, borderColor: "$borderColor", items: "center", children: [reachable ? (_jsx(XStack, { render: "button", "aria-label": "Shell", onPress: () => setLog(false), px: "$2", py: "$1", rounded: "$2", bg: showShell ? '$hover' : 'transparent', children: _jsx(SizableText, { size: "$1", color: showShell ? '$ink' : '$soft', children: "Shell" }) })) : null, _jsx(XStack, { render: "button", "aria-label": "Agent log", onPress: () => setLog(true), px: "$2", py: "$1", rounded: "$2", bg: showShell ? 'transparent' : '$hover', children: _jsx(SizableText, { size: "$1", color: showShell ? '$soft' : '$ink', children: "Agent log" }) })] }), showShell ? _jsx(Door, { which: "terminal", sandbox: sandbox,
|
|
229
|
+
return (_jsxs(YStack, { flex: 1, minH: 0, bg: "$background", children: [_jsxs(XStack, { px: "$2", py: "$1.5", gap: "$1", borderBottomWidth: 1, borderColor: "$borderColor", items: "center", children: [reachable ? (_jsx(XStack, { render: "button", "aria-label": "Shell", onPress: () => setLog(false), px: "$2", py: "$1", rounded: "$2", bg: showShell ? '$hover' : 'transparent', children: _jsx(SizableText, { size: "$1", color: showShell ? '$ink' : '$soft', children: "Shell" }) })) : null, _jsx(XStack, { render: "button", "aria-label": "Agent log", onPress: () => setLog(true), px: "$2", py: "$1", rounded: "$2", bg: showShell ? 'transparent' : '$hover', children: _jsx(SizableText, { size: "$1", color: showShell ? '$soft' : '$ink', children: "Agent log" }) })] }), showShell ? _jsx(Door, { which: "terminal", sandbox: sandbox, held: held, session: `run-${id.replace(/^sess_/, '').slice(0, 12)}` }) : _jsx(Log, { lines: lines, live: live, failed: failed, refused: refused, retry: retry, onRetry: onRetry })] }));
|
|
59
230
|
}
|
|
60
231
|
function Log({ lines, live, failed, refused, retry, onRetry, }) {
|
|
61
232
|
const prompt = 'workspace';
|
package/lib/door.d.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
|
-
import { type Door as Which } from './api/sandbox.ts';
|
|
2
|
-
|
|
1
|
+
import { type Door as Which, type State as Held } from './api/sandbox.ts';
|
|
2
|
+
/**
|
|
3
|
+
* `held` is where the run's sandbox is in its life, '' until it has been read. Only
|
|
4
|
+
* a running sandbox has an open door.
|
|
5
|
+
*/
|
|
6
|
+
export declare function Door({ which, sandbox, held, session }: {
|
|
3
7
|
which: Which;
|
|
4
8
|
sandbox: string;
|
|
5
|
-
|
|
9
|
+
held: Held | '';
|
|
6
10
|
session: string;
|
|
7
11
|
}): import("react").JSX.Element;
|