@thedevlabs-io/labd 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/labd.js CHANGED
@@ -433,6 +433,36 @@ var EXEC_TOOLS = ["run_command"];
433
433
  function isExecTool(name) {
434
434
  return EXEC_TOOLS.includes(name);
435
435
  }
436
+ var AGENT_TOOLS = ["spawn_agent"];
437
+ function isAgentTool(name) {
438
+ return AGENT_TOOLS.includes(name);
439
+ }
440
+ var MAX_SUBAGENTS = 3;
441
+ var SUBAGENT_REPORT_CHARS = 8e3;
442
+ function subAgentCapabilities(parent) {
443
+ return parent.filter((c) => !isAgentTool(c));
444
+ }
445
+ function subAgentConversationId(parentId, callId) {
446
+ return parentId ? `${parentId}~helper~${callId}` : void 0;
447
+ }
448
+ function subAgentBrief(task) {
449
+ return `You are working as a helper for labd, the assistant the learner is talking to. It has handed you one piece of a larger job and is waiting on your report. The learner is the same person, in the same project, under the same rules: the same tools, the same approvals, and the same things you will not do.
450
+
451
+ Your task:
452
+
453
+ ${task.trim()}
454
+
455
+ How to work:
456
+ - You start with none of the parent conversation, so the task above is all you know about it. If it is not enough to act on, say what is missing in your report rather than guessing at the intent.
457
+ - Do the task, and only the task. The parent decides what happens next, so do not widen the job, and do not ask the learner questions - your report goes to the parent, not to them.
458
+ - If a write or command you need is declined, do not look for another way round it. Stop and say so.
459
+ - End with a report the parent can act on without redoing your work: what you found or changed, file by file and with line numbers where they matter, and anything you could not finish and why. Keep it tight. Leave out how you went about it.`;
460
+ }
461
+ function subAgentReport(text) {
462
+ const clean = text.trim() || "The helper finished without a report.";
463
+ return clean.length <= SUBAGENT_REPORT_CHARS ? clean : `${clean.slice(0, SUBAGENT_REPORT_CHARS)}
464
+ ... the helper's report was longer than this and was trimmed.`;
465
+ }
436
466
  var AGENT_MODES = ["ask", "auto", "agent"];
437
467
  var DEFAULT_AGENT_MODE = "auto";
438
468
  function toAgentMode(raw) {
@@ -1319,6 +1349,154 @@ function trim(output) {
1319
1349
  ].join("");
1320
1350
  }
1321
1351
 
1352
+ // packages/core/src/calls.ts
1353
+ var TOOL_OUTPUT_KEPT = 8e3;
1354
+ function keepOutput(text) {
1355
+ const clean = text.replace(/\s+$/, "");
1356
+ return clean.length <= TOOL_OUTPUT_KEPT ? clean : `${clean.slice(0, TOOL_OUTPUT_KEPT)}
1357
+ \u2026 trimmed here.`;
1358
+ }
1359
+ async function executeCall(call, env) {
1360
+ let preview;
1361
+ if (isWriteTool(call.name) || isExecTool(call.name)) {
1362
+ try {
1363
+ preview = previewTool(call, env.toolCtx);
1364
+ env.step({ status: "running", preview });
1365
+ } catch (err) {
1366
+ const message = err instanceof Error ? err.message : String(err);
1367
+ env.step({ status: "error", detail: message });
1368
+ return { id: call.id, content: message, isError: true };
1369
+ }
1370
+ } else {
1371
+ env.step({ status: "running" });
1372
+ }
1373
+ if (needsApproval(env.mode, call)) {
1374
+ env.step({ status: "awaiting", preview });
1375
+ const ok = env.approve ? await env.approve(call, preview) : false;
1376
+ if (env.signal.aborted) return "aborted";
1377
+ if (!ok) {
1378
+ env.step({ status: "skipped", preview });
1379
+ return {
1380
+ id: call.id,
1381
+ content: env.approve ? "The learner declined this, so nothing was done. Do not retry it. Explain what you would have done and why, and let them decide what to do next." : "This needed the learner to approve it and labd is running unattended, so nothing was done. Say what you would have done; do not try another way round it."
1382
+ };
1383
+ }
1384
+ }
1385
+ if (env.signal.aborted) return "aborted";
1386
+ try {
1387
+ const content = await runTool(call, env.toolCtx);
1388
+ if (isWriteTool(call.name)) {
1389
+ const input = call.input;
1390
+ env.wrote(String(input.path ?? input.from ?? ""));
1391
+ }
1392
+ if (isExecTool(call.name)) env.ran(String(call.input.command ?? ""));
1393
+ env.step({ status: isWriteTool(call.name) ? "applied" : "done", preview, output: keepOutput(content) });
1394
+ return { id: call.id, content };
1395
+ } catch (err) {
1396
+ const message = err instanceof Error ? err.message : String(err);
1397
+ env.step({ status: "error", preview, detail: message });
1398
+ return { id: call.id, content: message, isError: true };
1399
+ }
1400
+ }
1401
+
1402
+ // packages/core/src/helper.ts
1403
+ var STALL_ROUNDS = 3;
1404
+ var LABEL_MAX = 80;
1405
+ function helperLabel(call) {
1406
+ const description = call.input.description;
1407
+ const text = typeof description === "string" ? description.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").replace(/\s+/g, " ").trim() : "";
1408
+ return text ? text.length > LABEL_MAX ? `${text.slice(0, LABEL_MAX - 1)}\u2026` : text : "helper";
1409
+ }
1410
+ async function runHelper(call, env) {
1411
+ const task = call.input.task;
1412
+ if (typeof task !== "string" || !task.trim()) {
1413
+ return { id: call.id, content: "spawn_agent needs a `task`: the complete brief for the helper.", isError: true };
1414
+ }
1415
+ const label2 = helperLabel(call);
1416
+ const helper = { id: call.id, label: label2 };
1417
+ const ask = env.approve;
1418
+ const capabilities = subAgentCapabilities(env.capabilities);
1419
+ const conversationId = subAgentConversationId(env.sessionId, call.id);
1420
+ let wire = [{ role: "user", text: subAgentBrief(task) }];
1421
+ let report = "";
1422
+ const asked = /* @__PURE__ */ new Set();
1423
+ let stalled = 0;
1424
+ for (; ; ) {
1425
+ let text = "";
1426
+ const calls = [];
1427
+ let needTools = false;
1428
+ let failure = null;
1429
+ const stream = env.client.stream(
1430
+ {
1431
+ protocol: PROTOCOL_VERSION,
1432
+ messages: wire,
1433
+ context: buildContext(env.root),
1434
+ capabilities,
1435
+ ...conversationId ? { conversationId } : {},
1436
+ ...env.effort ? { effort: env.effort } : {}
1437
+ },
1438
+ env.signal
1439
+ );
1440
+ for await (const event of stream) {
1441
+ if (env.signal.aborted) break;
1442
+ if (event.type === "text") text += event.text;
1443
+ else if (event.type === "tool_call") calls.push(event.call);
1444
+ else if (event.type === "need_tools") needTools = true;
1445
+ else if (event.type === "error") failure = event.message;
1446
+ }
1447
+ if (env.signal.aborted) return "aborted";
1448
+ if (failure) {
1449
+ const sofar = report ? `
1450
+
1451
+ What it had reported before that:
1452
+ ${subAgentReport(report)}` : "";
1453
+ return { id: call.id, content: `The helper stopped: ${failure}${sofar}`, isError: true };
1454
+ }
1455
+ wire = [...wire, { role: "assistant", text, ...calls.length ? { toolCalls: calls } : {} }];
1456
+ if (text.trim()) report = text;
1457
+ if (!needTools || !calls.length) break;
1458
+ const fresh = calls.some((c) => {
1459
+ if (isWriteTool(c.name) || isExecTool(c.name)) return true;
1460
+ const key = `${c.name}:${JSON.stringify(c.input)}`;
1461
+ if (asked.has(key)) return false;
1462
+ asked.add(key);
1463
+ return true;
1464
+ });
1465
+ stalled = fresh ? 0 : stalled + 1;
1466
+ if (stalled >= STALL_ROUNDS) {
1467
+ report = `${report}
1468
+
1469
+ (The helper kept asking for what it had already read, so it was stopped.)`.trim();
1470
+ break;
1471
+ }
1472
+ const results = [];
1473
+ for (const c of calls) {
1474
+ if (env.signal.aborted) return "aborted";
1475
+ if (isAgentTool(c.name)) {
1476
+ results.push({ id: c.id, content: "A helper cannot start helpers of its own. Do this part yourself.", isError: true });
1477
+ continue;
1478
+ }
1479
+ const outcome = await executeCall(c, {
1480
+ mode: env.mode(),
1481
+ // Routed to this call's row: two helpers can run commands at once.
1482
+ toolCtx: { ...env.toolCtx, onCommandOutput: (line) => env.toolCtx.onCommandOutput?.(line, `${call.id}/${c.id}`) },
1483
+ signal: env.signal,
1484
+ approve: ask ? (pending, preview) => ask(pending, preview, label2) : void 0,
1485
+ step: (s) => env.step({ ...s, call: c, helper }),
1486
+ wrote: env.wrote,
1487
+ ran: env.ran
1488
+ });
1489
+ if (outcome === "aborted") {
1490
+ env.step({ call: c, helper, status: "skipped", detail: "Stopped." });
1491
+ return "aborted";
1492
+ }
1493
+ results.push(outcome);
1494
+ }
1495
+ wire = [...wire, { role: "tool", results }];
1496
+ }
1497
+ return { id: call.id, content: subAgentReport(report) };
1498
+ }
1499
+
1322
1500
  // packages/core/src/agent.ts
1323
1501
  var CLI_CAPABILITIES = [
1324
1502
  "read_file",
@@ -1328,18 +1506,15 @@ var CLI_CAPABILITIES = [
1328
1506
  "create_file",
1329
1507
  "move_file",
1330
1508
  "delete_file",
1331
- "run_command"
1509
+ "run_command",
1510
+ // Helpers run here, in this process, on the same rules - see helper.ts.
1511
+ "spawn_agent"
1332
1512
  ];
1333
- var TOOL_OUTPUT_KEPT = 8e3;
1334
- function keepOutput(text) {
1335
- const clean = text.replace(/\s+$/, "");
1336
- return clean.length <= TOOL_OUTPUT_KEPT ? clean : `${clean.slice(0, TOOL_OUTPUT_KEPT)}
1337
- \u2026 trimmed here.`;
1338
- }
1339
- var STALL_ROUNDS = 3;
1513
+ var STALL_ROUNDS2 = 3;
1340
1514
  var COMPACT_AT = 75;
1341
1515
  async function runTurn(question, opts) {
1342
- const { session, client, ui, mode } = opts;
1516
+ const { session, client, ui } = opts;
1517
+ const modeNow = () => opts.currentMode?.() ?? opts.mode;
1343
1518
  const turnId = randomUUID2();
1344
1519
  const controller = new AbortController();
1345
1520
  opts.signal?.addEventListener("abort", () => controller.abort(), { once: true });
@@ -1348,10 +1523,17 @@ async function runTurn(question, opts) {
1348
1523
  checkpoints: opts.checkpoints,
1349
1524
  turn: { sessionId: session.id, turnId },
1350
1525
  commandTimeoutMs: opts.commandTimeoutMs ?? 12e4,
1351
- onCommandOutput: (line) => ui.commandOutput?.(line)
1526
+ onCommandOutput: (line2, key) => ui.commandOutput?.(line2, key)
1352
1527
  };
1353
1528
  session.add({ kind: "user", text: question, ...opts.attachments?.length ? { attachments: opts.attachments.map((a) => ({ path: a.path })) } : {} }, turnId);
1354
1529
  const result = { text: "", turnId, wrote: [], ran: [], aborted: false };
1530
+ const ask = ui.approve;
1531
+ let line = Promise.resolve();
1532
+ const approve = ask ? (call, preview, helper) => {
1533
+ const answer = line.then(() => controller.signal.aborted ? false : ask(call, preview, helper));
1534
+ line = answer.catch(() => void 0);
1535
+ return answer;
1536
+ } : void 0;
1355
1537
  const asked = /* @__PURE__ */ new Set();
1356
1538
  let stalled = 0;
1357
1539
  for (let round = 0; ; round++) {
@@ -1430,20 +1612,79 @@ async function runTurn(question, opts) {
1430
1612
  return true;
1431
1613
  });
1432
1614
  stalled = fresh ? 0 : stalled + 1;
1433
- if (stalled >= STALL_ROUNDS) {
1615
+ if (stalled >= STALL_ROUNDS2) {
1434
1616
  ui.error(
1435
1617
  `labd asked for the same things ${stalled} rounds running, so it stopped rather than keep going round. Tell it which file to start in, or ask for a narrower piece of this.`
1436
1618
  );
1437
1619
  session.add({ kind: "tool", results: calls.map((c) => stopped(c, "error", "Stopped: labd was repeating itself.")) }, turnId);
1438
1620
  return result;
1439
1621
  }
1440
- const results = [];
1441
- for (const call of calls) {
1442
- if (controller.signal.aborted) {
1443
- results.push(stopped(call));
1444
- continue;
1622
+ const results = new Array(calls.length);
1623
+ const working = [];
1624
+ try {
1625
+ let helpers = 0;
1626
+ calls.forEach((call, at2) => {
1627
+ if (!isAgentTool(call.name)) return;
1628
+ const label2 = helperLabel(call);
1629
+ if (helpers++ >= MAX_SUBAGENTS) {
1630
+ const why = `Only ${MAX_SUBAGENTS} helpers can run at once, so this one was not started. Do it yourself, or start it once the others have reported.`;
1631
+ ui.tool({ call, status: "error", detail: why });
1632
+ results[at2] = { id: call.id, content: why, isError: true };
1633
+ return;
1634
+ }
1635
+ ui.tool({ call, status: "running", detail: label2 });
1636
+ working.push(
1637
+ runHelper(call, {
1638
+ client,
1639
+ root: opts.root,
1640
+ mode: modeNow,
1641
+ effort: opts.effort,
1642
+ capabilities: CLI_CAPABILITIES,
1643
+ sessionId: session.id,
1644
+ toolCtx,
1645
+ signal: controller.signal,
1646
+ approve,
1647
+ step: (step) => ui.tool(step),
1648
+ wrote: (path) => result.wrote.push(path),
1649
+ ran: (command) => result.ran.push(command)
1650
+ }).catch((err) => ({
1651
+ id: call.id,
1652
+ content: `The helper failed: ${err instanceof Error ? err.message : String(err)}`,
1653
+ isError: true
1654
+ })).then((outcome) => {
1655
+ if (outcome === "aborted") {
1656
+ results[at2] = stopped(call);
1657
+ return;
1658
+ }
1659
+ results[at2] = outcome;
1660
+ ui.tool({ call, status: outcome.isError ? "error" : "done", detail: label2, output: keepOutput(outcome.content) });
1661
+ })
1662
+ );
1663
+ });
1664
+ for (const [at2, call] of calls.entries()) {
1665
+ if (isAgentTool(call.name)) continue;
1666
+ if (controller.signal.aborted) {
1667
+ results[at2] = stopped(call);
1668
+ continue;
1669
+ }
1670
+ const outcome = await executeCall(call, {
1671
+ mode: modeNow(),
1672
+ // Output names its row, so it cannot land in a helper's command
1673
+ // running at the same moment.
1674
+ toolCtx: { ...toolCtx, onCommandOutput: (line2) => ui.commandOutput?.(line2, call.id) },
1675
+ signal: controller.signal,
1676
+ approve,
1677
+ step: (step) => ui.tool({ ...step, call }),
1678
+ wrote: (path) => result.wrote.push(path),
1679
+ ran: (command) => result.ran.push(command)
1680
+ });
1681
+ results[at2] = outcome === "aborted" ? stopped(call) : outcome;
1445
1682
  }
1446
- results.push(await runOne(call));
1683
+ await Promise.all(working);
1684
+ } catch (err) {
1685
+ controller.abort();
1686
+ await Promise.allSettled(working);
1687
+ throw err;
1447
1688
  }
1448
1689
  session.add({ kind: "tool", results }, turnId);
1449
1690
  if (controller.signal.aborted) {
@@ -1455,52 +1696,6 @@ async function runTurn(question, opts) {
1455
1696
  ui.tool({ call, status, detail: why });
1456
1697
  return { id: call.id, content: why, isError: true };
1457
1698
  }
1458
- async function runOne(call) {
1459
- let preview;
1460
- if (isWriteTool(call.name) || isExecTool(call.name)) {
1461
- try {
1462
- preview = previewTool(call, toolCtx);
1463
- ui.tool({ call, status: "running", preview });
1464
- } catch (err) {
1465
- const message = err instanceof Error ? err.message : String(err);
1466
- ui.tool({ call, status: "error", detail: message });
1467
- return { id: call.id, content: message, isError: true };
1468
- }
1469
- } else {
1470
- ui.tool({ call, status: "running" });
1471
- }
1472
- if (needsApproval(mode, call)) {
1473
- ui.tool({ call, status: "awaiting", preview });
1474
- const ok = ui.approve ? await ui.approve(call, preview) : false;
1475
- if (controller.signal.aborted) return stopped(call);
1476
- if (!ok) {
1477
- ui.tool({ call, status: "skipped", preview });
1478
- return {
1479
- id: call.id,
1480
- content: ui.approve ? "The learner declined this, so nothing was done. Do not retry it. Explain what you would have done and why, and let them decide what to do next." : "This needed the learner to approve it and labd is running unattended, so nothing was done. Say what you would have done; do not try another way round it."
1481
- };
1482
- }
1483
- }
1484
- try {
1485
- const content = await runTool(call, toolCtx);
1486
- if (isWriteTool(call.name)) {
1487
- const input = call.input;
1488
- result.wrote.push(String(input.path ?? input.from ?? ""));
1489
- }
1490
- if (isExecTool(call.name)) result.ran.push(String(call.input.command ?? ""));
1491
- ui.tool({
1492
- call,
1493
- status: isWriteTool(call.name) ? "applied" : "done",
1494
- preview,
1495
- output: keepOutput(content)
1496
- });
1497
- return { id: call.id, content };
1498
- } catch (err) {
1499
- const message = err instanceof Error ? err.message : String(err);
1500
- ui.tool({ call, status: "error", preview, detail: message });
1501
- return { id: call.id, content: message, isError: true };
1502
- }
1503
- }
1504
1699
  }
1505
1700
  async function compact(opts) {
1506
1701
  const controller = new AbortController();
@@ -1898,9 +2093,16 @@ function setColor(value) {
1898
2093
  on = value;
1899
2094
  }
1900
2095
  var wrap = (open, close = "0") => (text) => on ? `${ESC}[${open}m${text}${ESC}[${close}m` : text;
2096
+ function lightBackground() {
2097
+ const bg = Number(process.env.COLORFGBG?.split(";").at(-1));
2098
+ return bg === 7 || bg === 15;
2099
+ }
1901
2100
  var paint = {
1902
- /** labd's accent. 214 is the nearest xterm cube entry to the brand orange. */
1903
- accent: wrap("38;5;214"),
2101
+ /**
2102
+ * labd's accent. 214 is the nearest xterm cube entry to the brand orange.
2103
+ * Closes the foreground only, so it can sit inside a `band` without ending it.
2104
+ */
2105
+ accent: wrap("38;5;214", "39"),
1904
2106
  bold: wrap("1", "22"),
1905
2107
  dim: wrap("2", "22"),
1906
2108
  muted: wrap("90"),
@@ -1912,6 +2114,11 @@ var paint = {
1912
2114
  /** For the one place a background is worth it: the cursor cell in the box. */
1913
2115
  invert: wrap("7", "27")
1914
2116
  };
2117
+ function band(text, visible, width) {
2118
+ if (!on) return text;
2119
+ const bg = lightBackground() ? "48;5;254" : "48;5;236";
2120
+ return `${ESC}[${bg}m${text}${" ".repeat(Math.max(0, width - visible))}${ESC}[49m`;
2121
+ }
1915
2122
  function stripColor(text) {
1916
2123
  return text.replace(new RegExp(`${ESC}\\[[0-9;]*m`, "g"), "");
1917
2124
  }
@@ -2073,7 +2280,8 @@ var LABELS = {
2073
2280
  create_file: "Create",
2074
2281
  move_file: "Move",
2075
2282
  delete_file: "Delete",
2076
- run_command: "Run"
2283
+ run_command: "Run",
2284
+ spawn_agent: "Helper"
2077
2285
  };
2078
2286
  function approvalCard(preview, call, mode, width) {
2079
2287
  const out = [""];
@@ -3203,6 +3411,17 @@ var Transcript = class {
3203
3411
  this.entries.push(entry);
3204
3412
  return entry;
3205
3413
  }
3414
+ /**
3415
+ * Move a block to the bottom. For an approval card, which must stay the last
3416
+ * thing on screen while it waits: a helper that keeps working adds rows below
3417
+ * it, and a card pushed up past the fold is one approved without being read.
3418
+ */
3419
+ toEnd(entry) {
3420
+ const at2 = this.entries.indexOf(entry);
3421
+ if (at2 === -1 || at2 === this.entries.length - 1) return;
3422
+ this.entries.splice(at2, 1);
3423
+ this.entries.push(entry);
3424
+ }
3206
3425
  /** Say a block changed, so its cached lines are dropped on the next render. */
3207
3426
  touch(entry) {
3208
3427
  entry.version++;
@@ -3281,7 +3500,10 @@ function renderBlock(block, width) {
3281
3500
  switch (block.kind) {
3282
3501
  case "user":
3283
3502
  return block.text.split("\n").flatMap(
3284
- (line, i) => wrapPlain(line, inner - 2).map((piece, j) => i === 0 && j === 0 ? `${paint.accent(">")} ${piece}` : ` ${piece}`)
3503
+ (line, i) => wrapPlain(line, inner - 2).map((piece, j) => {
3504
+ const text = i === 0 && j === 0 ? `${paint.accent(">")} ${piece}` : ` ${piece}`;
3505
+ return band(text, visibleWidth(text), inner);
3506
+ })
3285
3507
  );
3286
3508
  case "assistant":
3287
3509
  return [];
@@ -3295,7 +3517,10 @@ function renderBlock(block, width) {
3295
3517
  return out;
3296
3518
  }
3297
3519
  case "approval": {
3298
- const out = approvalCard(block.preview, block.call, block.mode, inner);
3520
+ const out = [
3521
+ ...block.helper ? [` ${paint.accent("from a helper:")} ${paint.muted(block.helper)}`] : [],
3522
+ ...approvalCard(block.preview, block.call, block.mode, inner)
3523
+ ];
3299
3524
  out.push(
3300
3525
  block.answer === null ? ` ${paint.yellow("y")} ${paint.muted("to allow \xB7")} ${paint.yellow("n")} ${paint.muted("or esc to decline")}` : ` ${block.answer === "allowed" ? paint.green("allowed") : paint.muted("declined")}`
3301
3526
  );
@@ -4469,11 +4694,18 @@ function fileTag(line) {
4469
4694
  return { at: at2, partial };
4470
4695
  }
4471
4696
  function label(step) {
4472
- return `${step.call.name.replace(/_/g, " ")} ${detail(step)}`;
4697
+ if (step.call.name === "spawn_agent") return `working with a helper: ${detail(step)}`;
4698
+ const own = `${step.call.name.replace(/_/g, " ")} ${detail(step)}`;
4699
+ return step.helper ? `${step.helper.label}: ${own}` : own;
4700
+ }
4701
+ function stepKey(step) {
4702
+ return step.helper ? `${step.helper.id}/${step.call.id}` : step.call.id;
4473
4703
  }
4474
4704
  function detail(step) {
4475
4705
  const input = step.call.input;
4476
- return String(input.why ?? input.path ?? input.from ?? input.query ?? "");
4706
+ if (step.call.name === "spawn_agent") return String(input.description ?? "helper");
4707
+ const own = String(input.why ?? input.path ?? input.from ?? input.query ?? "");
4708
+ return step.helper ? `\u21B3 ${step.helper.label} \xB7 ${own}` : own;
4477
4709
  }
4478
4710
  function verbatim(call) {
4479
4711
  const i = call.input;
@@ -4489,6 +4721,10 @@ function verbatim(call) {
4489
4721
  return `${str("from")} \u2192 ${str("to")}`;
4490
4722
  case "list_dir":
4491
4723
  return str("path") || "/";
4724
+ // The whole brief: what the helper was actually told is what a learner
4725
+ // wants when its report surprises them.
4726
+ case "spawn_agent":
4727
+ return str("task");
4492
4728
  default:
4493
4729
  return str("path");
4494
4730
  }
@@ -4580,6 +4816,8 @@ async function runInteractive(runtime) {
4580
4816
  abort = new AbortController();
4581
4817
  const markdown = new MarkdownStream(renderer.contentWidth);
4582
4818
  let opened = false;
4819
+ let held = null;
4820
+ const say = (line) => held ? held.push(line) : renderer.print(line);
4583
4821
  const ui = {
4584
4822
  text(chunk) {
4585
4823
  const ready = markdown.push(chunk);
@@ -4601,7 +4839,7 @@ async function runInteractive(runtime) {
4601
4839
  }
4602
4840
  }
4603
4841
  if (step.status === "running" || step.status === "awaiting") {
4604
- startSpinner(label(step));
4842
+ if (!held) startSpinner(label(step));
4605
4843
  return;
4606
4844
  }
4607
4845
  for (const line of toolBlock(
@@ -4611,19 +4849,27 @@ async function runInteractive(runtime) {
4611
4849
  { input: verbatim(step.call), output: step.output ?? step.detail },
4612
4850
  renderer.contentWidth
4613
4851
  )) {
4614
- renderer.print(line);
4852
+ say(line);
4615
4853
  }
4616
4854
  },
4617
- async approve(call, preview) {
4855
+ async approve(call, preview, helper) {
4618
4856
  stopSpinner();
4857
+ if (helper) renderer.print(` ${paint.accent("from a helper:")} ${paint.muted(helper)}`);
4619
4858
  for (const line of approvalCard(preview, call, state.mode, renderer.contentWidth)) {
4620
4859
  renderer.print(line);
4621
4860
  }
4622
4861
  const verb = call.name === "run_command" ? "run this command" : "apply this change";
4623
- const answer = await prompt.read(`${verb}? [y/N]`);
4624
- const ok = answer.kind === "line" && /^y(es)?$/i.test(answer.text.trim());
4625
- renderer.print(` ${ok ? paint.green("allowed") : paint.muted("declined")}`);
4626
- return ok;
4862
+ held = [];
4863
+ try {
4864
+ const answer = await prompt.read(`${verb}? [y/N]`);
4865
+ const ok = answer.kind === "line" && /^y(es)?$/i.test(answer.text.trim());
4866
+ renderer.print(` ${ok ? paint.green("allowed") : paint.muted("declined")}`);
4867
+ return ok;
4868
+ } finally {
4869
+ const waiting = held;
4870
+ held = null;
4871
+ for (const line of waiting) renderer.print(line);
4872
+ }
4627
4873
  },
4628
4874
  error(message) {
4629
4875
  stopSpinner();
@@ -4634,7 +4880,7 @@ async function runInteractive(runtime) {
4634
4880
  if (info2.credits) state.credits = info2.credits;
4635
4881
  },
4636
4882
  commandOutput(line) {
4637
- renderer.print(paint.dim(` | ${line.replace(/\n/g, "\n | ")}`));
4883
+ say(paint.dim(` | ${line.replace(/\n/g, "\n | ")}`));
4638
4884
  }
4639
4885
  };
4640
4886
  startSpinner("thinking");
@@ -4817,6 +5063,14 @@ async function runScreen(runtime, opts) {
4817
5063
  const history = new History();
4818
5064
  const state = {
4819
5065
  mode: runtime.settings.mode,
5066
+ /**
5067
+ * The mode the running turn is held to, when shift+tab has moved `mode` up
5068
+ * to `agent` mid-answer. Going up does not apply until the next question -
5069
+ * a habit keypress must not let the very next command run unasked - while
5070
+ * going down applies at once, being the safe direction. Null when the turn
5071
+ * simply follows `mode`.
5072
+ */
5073
+ heldMode: null,
4820
5074
  effort: runtime.settings.effort,
4821
5075
  context: null,
4822
5076
  credits: null,
@@ -4952,7 +5206,8 @@ async function runScreen(runtime, opts) {
4952
5206
  `${paint.muted(basename5(runtime.root))}${runtime.branch ? ` ${paint.muted(runtime.branch)}` : ""}`,
4953
5207
  paint.dim(`session ${session}`)
4954
5208
  ].join(paint.dim(" \xB7 "));
4955
- const mode = state.mode === "agent" ? paint.red(paint.bold("agent")) : state.mode === "auto" ? paint.accent("auto") : paint.muted("ask");
5209
+ const shown = state.mode === "agent" ? paint.red(paint.bold("agent")) : state.mode === "auto" ? paint.accent("auto") : paint.muted("ask");
5210
+ const mode = state.heldMode ? `${shown}${paint.dim(" (next question)")}` : shown;
4956
5211
  const right = `${mode}${state.effort ? paint.dim(` \xB7 think ${state.effort}`) : ""} `;
4957
5212
  return spread(` ${left}`, right, width);
4958
5213
  }
@@ -4966,7 +5221,7 @@ async function runScreen(runtime, opts) {
4966
5221
  else if (state.popup) left = d("\u2191\u2193 move \xB7 tab/enter accept \xB7 esc close");
4967
5222
  else {
4968
5223
  const described = describe(editor.currentLine, runtime);
4969
- left = described.hint ? described.hint : d("enter send \xB7 shift+enter newline \xB7 / commands \xB7 @ files \xB7 pgup/pgdn scroll \xB7 ctrl+c stop \xB7 ctrl+d quit");
5224
+ left = described.hint ? described.hint : d("enter send \xB7 shift+tab mode \xB7 / commands \xB7 @ files \xB7 pgup/pgdn scroll \xB7 ctrl+c stop \xB7 ctrl+d quit");
4970
5225
  }
4971
5226
  const bits = [];
4972
5227
  if (state.context) {
@@ -5050,6 +5305,7 @@ async function runScreen(runtime, opts) {
5050
5305
  if (key.sequence === "n" || key.sequence === "N" || key.name === "escape") return answerApproval(false);
5051
5306
  return;
5052
5307
  }
5308
+ if (key.name === "tab" && key.shift) return cycleMode();
5053
5309
  if (key.ctrl && key.name === "d") {
5054
5310
  if (editor.isEmpty) quit();
5055
5311
  return;
@@ -5127,6 +5383,25 @@ async function runScreen(runtime, opts) {
5127
5383
  state.ctrlCAt = now;
5128
5384
  notice("press ctrl+c again to quit");
5129
5385
  }
5386
+ function effectiveMode() {
5387
+ return state.heldMode ?? state.mode;
5388
+ }
5389
+ function cycleMode() {
5390
+ const previous = effectiveMode();
5391
+ const next = AGENT_MODES[(AGENT_MODES.indexOf(state.mode) + 1) % AGENT_MODES.length];
5392
+ state.mode = next;
5393
+ const running = state.abort !== null;
5394
+ state.heldMode = next === "agent" && running ? previous : null;
5395
+ notice(`${next}: ${MODE_CAPTION[next]}`, 4e3);
5396
+ if (next === "agent") {
5397
+ note(
5398
+ ` ${paint.red(paint.bold("agent"))} ${paint.muted(
5399
+ running ? `- from your next question. This answer stays on ${previous}.` : "- commands now run without asking, and cannot be put back."
5400
+ )}
5401
+ ${paint.muted("For this session only. shift+tab again to leave it.")}`
5402
+ );
5403
+ }
5404
+ }
5130
5405
  function answerApproval(ok) {
5131
5406
  const approval = state.approval;
5132
5407
  if (!approval) return;
@@ -5241,7 +5516,10 @@ async function runScreen(runtime, opts) {
5241
5516
  });
5242
5517
  if (outcome.farewell) state.farewell = outcome.farewell;
5243
5518
  if (outcome.exit) return quit();
5244
- if (outcome.mode) state.mode = outcome.mode;
5519
+ if (outcome.mode) {
5520
+ state.mode = outcome.mode;
5521
+ state.heldMode = null;
5522
+ }
5245
5523
  if (outcome.effort !== void 0) state.effort = outcome.effort;
5246
5524
  schedule();
5247
5525
  }
@@ -5254,6 +5532,7 @@ async function runScreen(runtime, opts) {
5254
5532
  ["shift+enter, ctrl+j, or a trailing \\", "new line"],
5255
5533
  ["/ and @", "commands and files, with a list"],
5256
5534
  ["tab", "complete"],
5535
+ ["shift+tab", "next mode: ask, auto, agent"],
5257
5536
  ["pgup / pgdn, wheel", "scroll the transcript"],
5258
5537
  ["home / end", "top / bottom (with an empty box)"],
5259
5538
  ["esc", "close a list or dialog; jump back to the bottom"],
@@ -5336,6 +5615,7 @@ async function runScreen(runtime, opts) {
5336
5615
  }
5337
5616
  }
5338
5617
  async function ask(input) {
5618
+ state.heldMode = null;
5339
5619
  transcript.add({ kind: "user", text: input });
5340
5620
  state.asked = true;
5341
5621
  state.answer = null;
@@ -5372,7 +5652,8 @@ async function runScreen(runtime, opts) {
5372
5652
  }
5373
5653
  }
5374
5654
  closeAnswer();
5375
- let entry = state.toolBlocks.get(step.call.id);
5655
+ const key = stepKey(step);
5656
+ let entry = state.toolBlocks.get(key);
5376
5657
  if (!entry) {
5377
5658
  entry = transcript.add({
5378
5659
  kind: "tool",
@@ -5382,7 +5663,11 @@ async function runScreen(runtime, opts) {
5382
5663
  input: verbatim(step.call) || void 0,
5383
5664
  live: []
5384
5665
  });
5385
- state.toolBlocks.set(step.call.id, entry);
5666
+ state.toolBlocks.set(key, entry);
5667
+ if (state.approval) {
5668
+ transcript.toEnd(state.approval.entry);
5669
+ if (viewport.follow) viewport.end();
5670
+ }
5386
5671
  }
5387
5672
  entry.block.status = step.status;
5388
5673
  if (step.status !== "running" && step.status !== "awaiting") {
@@ -5393,10 +5678,10 @@ async function runScreen(runtime, opts) {
5393
5678
  if (step.status === "running" || step.status === "awaiting") setBusy(label(step));
5394
5679
  schedule();
5395
5680
  },
5396
- approve(call, preview) {
5681
+ approve(call, preview, helper) {
5397
5682
  setBusy(null);
5398
5683
  closeAnswer();
5399
- const entry = transcript.add({ kind: "approval", call, preview, mode: state.mode, answer: null });
5684
+ const entry = transcript.add({ kind: "approval", call, preview, helper, mode: effectiveMode(), answer: null });
5400
5685
  viewport.end();
5401
5686
  return new Promise((resolve2) => {
5402
5687
  state.approval = { entry, resolve: resolve2 };
@@ -5413,11 +5698,8 @@ async function runScreen(runtime, opts) {
5413
5698
  if (info2.credits) state.credits = info2.credits;
5414
5699
  schedule();
5415
5700
  },
5416
- commandOutput(line) {
5417
- let target;
5418
- for (const entry of state.toolBlocks.values()) {
5419
- if (entry.block.name === "run_command" && entry.block.status === "running") target = entry;
5420
- }
5701
+ commandOutput(line, key) {
5702
+ const target = key ? state.toolBlocks.get(key) : void 0;
5421
5703
  if (!target) return;
5422
5704
  target.block.live.push(...line.replace(/\n$/, "").split("\n"));
5423
5705
  transcript.touch(target);
@@ -5431,6 +5713,7 @@ async function runScreen(runtime, opts) {
5431
5713
  client: runtime.client,
5432
5714
  root: runtime.root,
5433
5715
  mode: state.mode,
5716
+ currentMode: effectiveMode,
5434
5717
  checkpoints: runtime.checkpoints,
5435
5718
  effort: state.effort,
5436
5719
  attachments,
@@ -5453,6 +5736,7 @@ async function runScreen(runtime, opts) {
5453
5736
  transcript.add({ kind: "error", text: err instanceof Error ? err.message : String(err) });
5454
5737
  } finally {
5455
5738
  if (state.abort === abort) state.abort = null;
5739
+ state.heldMode = null;
5456
5740
  if (state.approval) answerApproval(false);
5457
5741
  setBusy(null);
5458
5742
  }
@@ -5637,6 +5921,7 @@ async function runPrint(runtime, opts) {
5637
5921
  emit({
5638
5922
  type: "tool",
5639
5923
  name: step.call.name,
5924
+ ...step.helper ? { helper: step.helper } : {},
5640
5925
  status: step.status,
5641
5926
  input: step.call.input,
5642
5927
  detail: step.detail,
@@ -5644,7 +5929,8 @@ async function runPrint(runtime, opts) {
5644
5929
  output: step.output
5645
5930
  });
5646
5931
  } else if (step.status !== "running") {
5647
- toStderr(`${paint.dim(`${step.call.name}: ${step.status}${step.detail ? ` - ${step.detail}` : ""}`)}
5932
+ const who = step.helper ? `${step.helper.label} > ` : "";
5933
+ toStderr(`${paint.dim(`${who}${step.call.name}: ${step.status}${step.detail ? ` - ${step.detail}` : ""}`)}
5648
5934
  `);
5649
5935
  }
5650
5936
  if (step.status === "skipped") declined = true;
@@ -5803,6 +6089,8 @@ async function runRpc(runtime) {
5803
6089
  tool(step) {
5804
6090
  notify("labd/tool", {
5805
6091
  name: step.call.name,
6092
+ // Which helper made the call, when one did: its spawn_agent call id and name.
6093
+ ...step.helper ? { helper: step.helper } : {},
5806
6094
  status: step.status,
5807
6095
  input: step.call.input,
5808
6096
  detail: step.detail,
@@ -5816,8 +6104,8 @@ async function runRpc(runtime) {
5816
6104
  done(info2) {
5817
6105
  notify("labd/done", info2);
5818
6106
  },
5819
- commandOutput(line) {
5820
- notify("labd/command_output", { line });
6107
+ commandOutput(line, key) {
6108
+ notify("labd/command_output", { line, ...key ? { key } : {} });
5821
6109
  },
5822
6110
  /**
5823
6111
  * Ask the host. A host that never answers gets a refusal after a timeout
@@ -5825,7 +6113,7 @@ async function runRpc(runtime) {
5825
6113
  * alternative is writing to someone's files because their integration was
5826
6114
  * asleep.
5827
6115
  */
5828
- approve(call, preview) {
6116
+ approve(call, preview, helper) {
5829
6117
  return new Promise((resolve2) => {
5830
6118
  if (closed) return resolve2(false);
5831
6119
  const id = nextId++;
@@ -5836,7 +6124,7 @@ async function runRpc(runtime) {
5836
6124
  clearTimeout(timer);
5837
6125
  resolve2(approved);
5838
6126
  });
5839
- out({ jsonrpc: "2.0", id, method: "labd/approve", params: { call, preview } });
6127
+ out({ jsonrpc: "2.0", id, method: "labd/approve", params: { call, preview, ...helper ? { helper } : {} } });
5840
6128
  });
5841
6129
  }
5842
6130
  };
@@ -5866,7 +6154,7 @@ async function runRpc(runtime) {
5866
6154
  }
5867
6155
 
5868
6156
  // packages/cli/src/index.ts
5869
- var VERSION = true ? "0.5.0" : "0.0.0-dev";
6157
+ var VERSION = true ? "0.6.0" : "0.0.0-dev";
5870
6158
  async function main(argv) {
5871
6159
  const args = parse(argv);
5872
6160
  if (args.color !== void 0) setColor(args.color);