@hanzo/build 0.2.30 → 0.2.31

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/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
- keep: 'Keep the work',
246
+ kept: 'Keep the sandbox',
267
247
  };
268
- /** Steps another card already says: `leased` finishes the lease, `done` is the final status, `ended` is housekeeping. */
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}; each
276
- * command in the sandbox narrates its output as `log` {message} chunks and ends
277
- * with `tool-call` {step: "exit"}. The output after the step that starts the
278
- * agent is the agent's, read by its harness's grammar (harness.ts); after any
279
- * other step it is that step's. Once the agent has exited, the commands the run
280
- * runs before it pushes commit its work.
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
- let agent = null;
289
- // The agent has run and exited: output with no step of its own is the run's.
290
- let exited = false;
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 'log': {
315
- const text = typeof body === 'string' ? body : str(b?.message) || str(b?.text);
316
- if (!text)
336
+ case 'event': {
337
+ if (!b)
317
338
  break;
318
- if (agent)
319
- agent.feed(text);
320
- else if (phase)
321
- phase.output += text;
322
- else if (exited) {
323
- phase = { kind: 'step', key: mint(), name: 'Commit', detail: 'committing the work', output: text, ran: 'running' };
324
- out.push(phase);
325
- }
326
- else {
327
- agent = harness(out, mint);
328
- agent.feed(text);
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 (agent) {
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
- settle();
393
+ phase = null;
374
394
  if (!b)
375
395
  break;
376
- const text = str(b.plan);
377
- if (str(b.status) === 'done' && (mode === 'plan' || mode === 'setup') && text) {
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
- settle();
417
- // The answer the final status carries is also the agent's last message: draw it once, as the answer.
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 = true;
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 = drawn.findIndex((c) => c.kind === 'said' && c.who === 'person' && !steered.has(c));
455
+ const ask = out.findIndex((c) => c.kind === 'said' && c.who === 'person' && !steered.has(c));
431
456
  if (ask > 0)
432
- drawn.unshift(...drawn.splice(ask, 1));
433
- return drawn;
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/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 holds, or '' before it leases one and on a machine. */
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 where the run
4
- * is published, its sandbox's Desktop and Terminal, what it produced, its files,
5
- * what it pushed, and its codebase's environment.
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 { Out } from './out.js';
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
- 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') }] })] }), _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: "Mode", value: mode || 'build' })] })) : null, tab === 'browser' ? _jsx(Browser, { 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, live: live, session: id }) : null, tab === 'terminal' ? (_jsx(Terminal, { id: id, sandbox: sandbox, lines: lines, live: live, failed: failed, refused: refused, retry: retry, onRetry: onRetry })) : null, tab === 'artifacts' ? (_jsx(Artifacts, { 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: live }) : null] })] }));
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 Browser tab: the page where the run published its work, framed, with its
33
- * address, a reload and the page in a tab of its own.
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 Browser({ site, live }) {
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 || 'Not published 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: live ? 'Not published yet' : 'Nothing was published' }), _jsx(SizableText, { size: "$2", color: "$soft", style: { textAlign: 'center' }, children: live ? 'The page appears here once the run publishes its site.' : 'This run published no site. Its work is on its branch.' })] }) })] }));
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 run holds one; and the commands the agent itself ran, which
50
- * outlive the sandbox. The shell is a tmux session named for the run, so a
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 = live && Boolean(sandbox);
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, live: live, session: `run-${id.replace(/^sess_/, '').slice(0, 12)}` }) : _jsx(Log, { lines: lines, live: live, failed: failed, refused: refused, retry: retry, onRetry: onRetry })] }));
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
- export declare function Door({ which, sandbox, live, session }: {
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
- live: boolean;
9
+ held: Held | '';
6
10
  session: string;
7
11
  }): import("react").JSX.Element;
package/lib/door.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  /**
3
3
  * One door into a run's sandbox — its screen or its shell — framed from cloud's
4
- * own page with a fresh ticket in its address.
4
+ * own page with a fresh ticket in its address, while the sandbox runs. A
5
+ * suspended sandbox is resumed from the pane's bar, never by looking at it.
5
6
  *
6
7
  * The page says when its socket is up. A page that says it failed, or silence
7
8
  * past the deadline, is a failed connection, and Retry mints a new ticket: a
@@ -17,14 +18,18 @@ import { useTarget } from './host.js';
17
18
  const SOURCE = { screen: 'hanzo-screen', terminal: 'hanzo-term' };
18
19
  const WHAT = { screen: 'desktop', terminal: 'terminal' };
19
20
  const DEADLINE = 20_000;
20
- export function Door({ which, sandbox, live, session }) {
21
+ /**
22
+ * `held` is where the run's sandbox is in its life, '' until it has been read. Only
23
+ * a running sandbox has an open door.
24
+ */
25
+ export function Door({ which, sandbox, held, session }) {
21
26
  const t = useTarget();
22
27
  const frame = useRef(null);
23
28
  const [src, setSrc] = useState('');
24
29
  const [state, setState] = useState('opening');
25
30
  const [why, setWhy] = useState('');
26
31
  const [attempt, setAttempt] = useState(0);
27
- const up = live && Boolean(sandbox);
32
+ const up = Boolean(sandbox) && held === 'running';
28
33
  useEffect(() => {
29
34
  if (!up)
30
35
  return;
@@ -73,12 +78,18 @@ export function Door({ which, sandbox, live, session }) {
73
78
  clearTimeout(late);
74
79
  };
75
80
  }, [src, which]);
76
- if (!live) {
77
- return _jsx(Shut, { title: "Not running", body: `This run’s ${WHAT[which]} closed when the run stopped.` });
78
- }
79
- if (!sandbox) {
81
+ if (!sandbox || held === '' || held === 'pending') {
80
82
  return _jsx(Shut, { title: "Starting", body: `The ${WHAT[which]} opens once the run’s sandbox is up.` });
81
83
  }
84
+ if (held === 'parked') {
85
+ return _jsx(Shut, { title: "Suspended", body: `This run’s sandbox is suspended with its files kept. Resume it from the bar above to open its ${WHAT[which]}.` });
86
+ }
87
+ if (held === 'gone') {
88
+ return _jsx(Shut, { title: "Ended", body: "This run\u2019s sandbox has ended. Its work is on its branch and in its artifacts." });
89
+ }
90
+ if (held === 'error') {
91
+ return _jsx(Shut, { title: "Unavailable", body: `This run’s sandbox could not start, so it has no ${WHAT[which]}.` });
92
+ }
82
93
  if (state === 'failed') {
83
94
  return (_jsx(Shut, { title: "Failed to connect", body: why || `Unable to reach the ${WHAT[which]}. The run may have stopped.`, children: _jsx(Button, { size: "sm", variant: "outline", onPress: () => setAttempt((a) => a + 1), children: "Retry" }) }));
84
95
  }