@hanzo/build 0.2.29 → 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')
@@ -89,7 +67,9 @@ export function plain(error) {
89
67
  if (!error)
90
68
  return 'The run hit an error';
91
69
  const ours = /providers_exhausted|every provider refused|temporarily unavailable/i.test(error);
92
- if (!ours && /\b402 Payment Required|insufficient (credit|balance)/i.test(error))
70
+ if (/free sandboxes .*are used/i.test(error))
71
+ return 'This plan’s free sandboxes are used for now. Add credit to keep building, or try again later.';
72
+ if (!ours && /\b402 Payment Required|insufficient (credit|balance)|top up/i.test(error))
93
73
  return 'Your balance is out of credit. Add credit, then try again.';
94
74
  if (ours || /\b(429 Too Many Requests|503 Service Unavailable)|stream disconnected|retries exhausted|rate limit/i.test(error))
95
75
  return 'The model did not answer. Try again in a moment, or pick another model.';
@@ -208,6 +188,19 @@ export function outcome(events) {
208
188
  }
209
189
  return out;
210
190
  }
191
+ /** Why the run failed, as its last status says it, or '' when that status is not a failure. */
192
+ export function failure(events) {
193
+ let out = '';
194
+ for (const e of [...events].sort((a, b) => a.seq - b.seq)) {
195
+ if (e.kind !== 'status')
196
+ continue;
197
+ const b = decode(e.payload);
198
+ if (!b || typeof b === 'string' || !str(b.status))
199
+ continue;
200
+ out = str(b.status) === 'error' ? str(b.error) || 'The run hit an error' : '';
201
+ }
202
+ return out;
203
+ }
211
204
  /** The turns, ordered and deduped: a recorded read and the live feed overlap. */
212
205
  export function merge(...lists) {
213
206
  const seen = new Map();
@@ -238,31 +231,76 @@ export function pull(url, repo) {
238
231
  return { href: '', label: '' };
239
232
  return { href: url, label: `#${m[3]}` };
240
233
  }
241
- /** The platform's own words for the step that starts the agent (apps/coding sandboxrunner.go). */
242
- const TASK = 'running the task';
243
234
  /** What the run's own steps are called on a card. */
244
235
  const NAMES = {
245
236
  lease: 'Sandbox',
237
+ workspace: 'Workspace',
246
238
  clone: 'Clone',
239
+ branch: 'Branch',
247
240
  secrets: 'Secrets',
248
241
  install: 'Install',
249
242
  start: 'Start',
243
+ skills: 'Skills',
244
+ artifacts: 'Save artifacts',
250
245
  push: 'Push',
251
- keep: 'Keep the work',
246
+ kept: 'Keep the sandbox',
252
247
  };
253
- /** Steps another card already says: `leased` finishes the lease, `done` is the final status, `ended` is housekeeping. */
254
- 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']);
255
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
+ }
256
292
  /**
257
293
  * A run's events, as the cards its transcript draws. `mode` is the run's, from
258
294
  * its record: a plan's final status is its plan, and nothing else draws a plan.
259
295
  *
260
- * The run's own steps arrive as `tool-call` {step, message, status}; each
261
- * command in the sandbox narrates its output as `log` {message} chunks and ends
262
- * with `tool-call` {step: "exit"}. The output after the step that starts the
263
- * agent is the agent's, read by its harness's grammar (harness.ts); after any
264
- * other step it is that step's. Once the agent has exited, the commands the run
265
- * 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.
266
304
  */
267
305
  export function cards(events, mode = '') {
268
306
  const out = [];
@@ -270,48 +308,56 @@ export function cards(events, mode = '') {
270
308
  let n = 0;
271
309
  const mint = () => `${seq}.${n++}`;
272
310
  let phase = null;
273
- let agent = null;
274
- // The agent has run and exited: output with no step of its own is the run's.
275
- let exited = false;
276
- const settle = () => {
277
- agent?.end();
278
- agent = null;
279
- phase = null;
280
- };
311
+ const items = new Map();
312
+ let thread = 0;
313
+ let said = '';
281
314
  const answers = new Set();
282
- // What the person said in an event of its own. The harness prints their ask
283
- // too (harness.ts); an event saying the same words is the one drawn. Words
284
- // that steered the run are not its ask.
285
- const told = new Set();
286
315
  const steered = new Set();
287
316
  const person = (text) => {
288
317
  const c = { kind: 'said', key: mint(), who: 'person', text };
289
318
  out.push(c);
290
- told.add(c);
291
319
  return c;
292
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
+ };
293
330
  for (const e of [...events].sort((a, b) => a.seq - b.seq)) {
294
331
  seq = e.seq;
295
332
  n = 0;
296
333
  const body = decode(e.payload);
297
334
  const b = body && typeof body === 'object' ? body : null;
298
335
  switch (e.kind) {
299
- case 'log': {
300
- const text = typeof body === 'string' ? body : str(b?.message) || str(b?.text);
301
- if (!text)
336
+ case 'event': {
337
+ if (!b)
302
338
  break;
303
- if (agent)
304
- agent.feed(text);
305
- else if (phase)
306
- phase.output += text;
307
- else if (exited) {
308
- phase = { kind: 'step', key: mint(), name: 'Commit', detail: 'committing the work', output: text, ran: 'running' };
309
- out.push(phase);
310
- }
311
- else {
312
- agent = harness(out, mint);
313
- 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);
314
358
  }
359
+ else
360
+ out[at] = card;
315
361
  break;
316
362
  }
317
363
  case 'tool-call': {
@@ -321,20 +367,10 @@ export function cards(events, mode = '') {
321
367
  const message = str(b.message);
322
368
  if (step === 'exit') {
323
369
  // One command in the sandbox ended: `exit 0`, `exit 1`, or `ended: …`.
324
- if (agent) {
325
- settle();
326
- exited = true;
327
- }
328
- else if (phase && phase.ran === 'running')
370
+ if (phase && phase.ran === 'running')
329
371
  phase.ran = message === 'exit 0' ? 'done' : 'error';
330
372
  break;
331
373
  }
332
- if (message === TASK) {
333
- settle();
334
- exited = false;
335
- agent = harness(out, mint);
336
- break;
337
- }
338
374
  if (step === 'leased') {
339
375
  const lease = out.find((c) => c.kind === 'step' && c.name === NAMES.lease);
340
376
  if (lease)
@@ -349,26 +385,33 @@ export function cards(events, mode = '') {
349
385
  phase.detail = message;
350
386
  break;
351
387
  }
352
- settle();
353
388
  phase = { kind: 'step', key: mint(), name, detail: message, output: '', ran: ranOf(str(b.status)) };
354
389
  out.push(phase);
355
390
  break;
356
391
  }
357
392
  case 'status': {
358
- settle();
393
+ phase = null;
359
394
  if (!b)
360
395
  break;
361
- const text = str(b.plan);
362
- 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) {
363
399
  answers.add(text.trim());
364
400
  out.push(mode === 'plan' ? { kind: 'plan', key: mint(), text } : { kind: 'said', key: mint(), who: 'agent', text });
365
401
  break;
366
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
+ }
367
412
  const line = status(b, mode);
368
413
  if (line)
369
414
  out.push({ kind: 'note', key: mint(), text: line });
370
- if (str(b.status) === 'error' && str(b.error))
371
- out.push({ kind: 'step', key: mint(), name: 'Error', detail: '', output: str(b.error), ran: 'error' });
372
415
  break;
373
416
  }
374
417
  case 'control': {
@@ -398,24 +441,21 @@ export function cards(events, mode = '') {
398
441
  }
399
442
  }
400
443
  }
401
- settle();
402
- // The answer the final status carries is also the agent's last message: draw it once, as the answer.
403
- 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();
404
446
  for (let i = out.length - 1; i >= 0; i--) {
405
447
  const c = out[i];
406
448
  if (!((c.kind === 'said' && c.who === 'agent') || c.kind === 'plan') || !answers.has(c.text.trim()))
407
449
  continue;
408
- if (kept)
450
+ if (kept.has(c.text.trim()))
409
451
  out.splice(i, 1);
410
- kept = true;
452
+ kept.add(c.text.trim());
411
453
  }
412
- const heard = new Set([...told].map((c) => c.text.trim()));
413
- const drawn = out.filter((c) => !(c.kind === 'said' && c.who === 'person' && !told.has(c) && heard.has(c.text.trim())));
414
454
  // The ask opens the conversation, ahead of the steps that set the run up.
415
- 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));
416
456
  if (ask > 0)
417
- drawn.unshift(...drawn.splice(ask, 1));
418
- return drawn;
457
+ out.unshift(...out.splice(ask, 1));
458
+ return out;
419
459
  }
420
460
  /** The plan a plan run answered with, from its final status, or ''. */
421
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';
12
- import { shell } from './api/turn.js';
16
+ import { useCallback, useEffect, useRef, useState } from 'react';
17
+ import { park, ports as listening, preview, sandbox as look, wake } from './api/sandbox.js';
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.
@@ -24,16 +34,179 @@ export function Desk({ id, repo, branch, base, environment, mode, pr, title, pro
24
34
  const tab = picked ?? (mode === 'setup' ? 'environment' : site ? 'browser' : 'terminal');
25
35
  const name = repo.split('/').filter(Boolean).pop() || repo;
26
36
  const lines = shell(events);
37
+ const failed = failure(events);
27
38
  const bleed = BLEED.includes(tab);
28
- 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, 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] })] }));
29
43
  }
30
44
  /**
31
- * The Browser tab: the page where the run published its work, framed, with its
32
- * 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.
33
47
  */
34
- 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 }) {
35
204
  const frame = useRef(null);
36
- 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.' })] }) })] }));
37
210
  }
38
211
  function TabButton({ id, tab, onPick, children }) {
39
212
  const on = tab === id;
@@ -45,19 +218,18 @@ function Fact({ label, value }) {
45
218
  const mono = { fontFamily: 'var(--f-mono, ui-monospace, monospace)', whiteSpace: 'pre' };
46
219
  /**
47
220
  * The Terminal tab: a shell in the run's sandbox, in the same working tree as the
48
- * agent, while the run holds one; and the commands the agent itself ran, which
49
- * outlive the sandbox. The shell is a tmux session named for the run, so a
50
- * 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.
51
223
  */
52
- function Terminal({ id, sandbox, lines, live, refused, retry, onRetry, }) {
53
- const reachable = live && Boolean(sandbox);
224
+ function Terminal({ id, sandbox, held, lines, live, failed, refused, retry, onRetry, }) {
225
+ const reachable = Boolean(sandbox) && held === 'running';
54
226
  // The shell while there is one, unless the log was asked for.
55
227
  const [log, setLog] = useState(false);
56
228
  const showShell = reachable && !log;
57
- 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, 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 })] }));
58
230
  }
59
- function Log({ lines, live, refused, retry, onRetry, }) {
231
+ function Log({ lines, live, failed, refused, retry, onRetry, }) {
60
232
  const prompt = 'workspace';
61
233
  const idle = lines.length === 0 && !live;
62
- return (_jsxs(YStack, { flex: 1, minH: 0, children: [_jsxs(YStack, { flex: 1, minH: 0, overflow: "scroll", px: "$3", py: "$3", gap: "$1", children: [idle ? (_jsxs(YStack, { flex: 1, items: "center", justify: "center", gap: "$3", py: "$8", children: [_jsx(SizableText, { size: "$2", color: "$soft", style: { textAlign: 'center' }, children: refused || 'This run finished without writing a command.' }), refused ? (_jsx(Button, { size: "sm", variant: "outline", onPress: onRetry, children: retry })) : null] })) : (lines.map((line, i) => line.role === 'cmd' ? (_jsxs(XStack, { gap: "$2", children: [_jsx(SizableText, { size: "$1", color: "$soft", style: mono, children: `${prompt} $` }), _jsx(SizableText, { flex: 1, size: "$1", color: "$ink", style: mono, children: line.text })] }, i)) : (_jsx(SizableText, { size: "$1", color: "$ink", style: mono, children: line.text }, i)))), live ? (_jsxs(XStack, { gap: "$2", items: "center", children: [_jsx(SizableText, { size: "$1", color: "$soft", style: mono, children: `${prompt} $` }), _jsx(YStack, { width: 7, height: 14, bg: "$ink" })] })) : null] }), _jsx(XStack, { px: "$3", py: "$2", borderTopWidth: 1, borderColor: "$borderColor", children: _jsx(SizableText, { size: "$1", color: "$soft", children: "Commands appear here as the run writes them." }) })] }));
234
+ return (_jsxs(YStack, { flex: 1, minH: 0, children: [_jsxs(YStack, { flex: 1, minH: 0, overflow: "scroll", px: "$3", py: "$3", gap: "$1", children: [idle ? (_jsxs(YStack, { flex: 1, items: "center", justify: "center", gap: "$3", py: "$8", children: [_jsx(SizableText, { size: "$2", color: "$soft", style: { textAlign: 'center' }, children: refused || (failed ? `This run stopped before its first command: ${failed}` : 'This run finished without writing a command.') }), refused ? (_jsx(Button, { size: "sm", variant: "outline", onPress: onRetry, children: retry })) : null] })) : (lines.map((line, i) => line.role === 'cmd' ? (_jsxs(XStack, { gap: "$2", children: [_jsx(SizableText, { size: "$1", color: "$soft", style: mono, children: `${prompt} $` }), _jsx(SizableText, { flex: 1, size: "$1", color: "$ink", style: mono, children: line.text })] }, i)) : (_jsx(SizableText, { size: "$1", color: "$ink", style: mono, children: line.text }, i)))), live ? (_jsxs(XStack, { gap: "$2", items: "center", children: [_jsx(SizableText, { size: "$1", color: "$soft", style: mono, children: `${prompt} $` }), _jsx(YStack, { width: 7, height: 14, bg: "$ink" })] })) : null] }), _jsx(XStack, { px: "$3", py: "$2", borderTopWidth: 1, borderColor: "$borderColor", children: _jsx(SizableText, { size: "$1", color: "$soft", children: "Commands appear here as the run writes them." }) })] }));
63
235
  }
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;