@thedevlabs-io/labd 0.5.0 → 0.6.1

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 = [""];
@@ -3183,7 +3391,6 @@ function wrap2(text, width, first, rest) {
3183
3391
  }
3184
3392
 
3185
3393
  // packages/tui/src/transcript.ts
3186
- var MAX_PROSE_WIDTH = 100;
3187
3394
  var LIVE_LINES = 8;
3188
3395
  var Transcript = class {
3189
3396
  entries = [];
@@ -3203,6 +3410,17 @@ var Transcript = class {
3203
3410
  this.entries.push(entry);
3204
3411
  return entry;
3205
3412
  }
3413
+ /**
3414
+ * Move a block to the bottom. For an approval card, which must stay the last
3415
+ * thing on screen while it waits: a helper that keeps working adds rows below
3416
+ * it, and a card pushed up past the fold is one approved without being read.
3417
+ */
3418
+ toEnd(entry) {
3419
+ const at2 = this.entries.indexOf(entry);
3420
+ if (at2 === -1 || at2 === this.entries.length - 1) return;
3421
+ this.entries.splice(at2, 1);
3422
+ this.entries.push(entry);
3423
+ }
3206
3424
  /** Say a block changed, so its cached lines are dropped on the next render. */
3207
3425
  touch(entry) {
3208
3426
  entry.version++;
@@ -3274,14 +3492,17 @@ function splitStreaming(text) {
3274
3492
  return { complete: complete2.join("\n"), tail };
3275
3493
  }
3276
3494
  function proseWidth(width) {
3277
- return Math.max(20, Math.min(width - 2, MAX_PROSE_WIDTH));
3495
+ return Math.max(20, width - 2);
3278
3496
  }
3279
3497
  function renderBlock(block, width) {
3280
3498
  const inner = proseWidth(width);
3281
3499
  switch (block.kind) {
3282
3500
  case "user":
3283
3501
  return block.text.split("\n").flatMap(
3284
- (line, i) => wrapPlain(line, inner - 2).map((piece, j) => i === 0 && j === 0 ? `${paint.accent(">")} ${piece}` : ` ${piece}`)
3502
+ (line, i) => wrapPlain(line, inner - 2).map((piece, j) => {
3503
+ const text = i === 0 && j === 0 ? `${paint.accent(">")} ${piece}` : ` ${piece}`;
3504
+ return band(text, visibleWidth(text), width);
3505
+ })
3285
3506
  );
3286
3507
  case "assistant":
3287
3508
  return [];
@@ -3295,7 +3516,10 @@ function renderBlock(block, width) {
3295
3516
  return out;
3296
3517
  }
3297
3518
  case "approval": {
3298
- const out = approvalCard(block.preview, block.call, block.mode, inner);
3519
+ const out = [
3520
+ ...block.helper ? [` ${paint.accent("from a helper:")} ${paint.muted(block.helper)}`] : [],
3521
+ ...approvalCard(block.preview, block.call, block.mode, inner)
3522
+ ];
3299
3523
  out.push(
3300
3524
  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
3525
  );
@@ -4469,11 +4693,18 @@ function fileTag(line) {
4469
4693
  return { at: at2, partial };
4470
4694
  }
4471
4695
  function label(step) {
4472
- return `${step.call.name.replace(/_/g, " ")} ${detail(step)}`;
4696
+ if (step.call.name === "spawn_agent") return `working with a helper: ${detail(step)}`;
4697
+ const own = `${step.call.name.replace(/_/g, " ")} ${detail(step)}`;
4698
+ return step.helper ? `${step.helper.label}: ${own}` : own;
4699
+ }
4700
+ function stepKey(step) {
4701
+ return step.helper ? `${step.helper.id}/${step.call.id}` : step.call.id;
4473
4702
  }
4474
4703
  function detail(step) {
4475
4704
  const input = step.call.input;
4476
- return String(input.why ?? input.path ?? input.from ?? input.query ?? "");
4705
+ if (step.call.name === "spawn_agent") return String(input.description ?? "helper");
4706
+ const own = String(input.why ?? input.path ?? input.from ?? input.query ?? "");
4707
+ return step.helper ? `\u21B3 ${step.helper.label} \xB7 ${own}` : own;
4477
4708
  }
4478
4709
  function verbatim(call) {
4479
4710
  const i = call.input;
@@ -4489,6 +4720,10 @@ function verbatim(call) {
4489
4720
  return `${str("from")} \u2192 ${str("to")}`;
4490
4721
  case "list_dir":
4491
4722
  return str("path") || "/";
4723
+ // The whole brief: what the helper was actually told is what a learner
4724
+ // wants when its report surprises them.
4725
+ case "spawn_agent":
4726
+ return str("task");
4492
4727
  default:
4493
4728
  return str("path");
4494
4729
  }
@@ -4580,6 +4815,8 @@ async function runInteractive(runtime) {
4580
4815
  abort = new AbortController();
4581
4816
  const markdown = new MarkdownStream(renderer.contentWidth);
4582
4817
  let opened = false;
4818
+ let held = null;
4819
+ const say = (line) => held ? held.push(line) : renderer.print(line);
4583
4820
  const ui = {
4584
4821
  text(chunk) {
4585
4822
  const ready = markdown.push(chunk);
@@ -4601,7 +4838,7 @@ async function runInteractive(runtime) {
4601
4838
  }
4602
4839
  }
4603
4840
  if (step.status === "running" || step.status === "awaiting") {
4604
- startSpinner(label(step));
4841
+ if (!held) startSpinner(label(step));
4605
4842
  return;
4606
4843
  }
4607
4844
  for (const line of toolBlock(
@@ -4611,19 +4848,27 @@ async function runInteractive(runtime) {
4611
4848
  { input: verbatim(step.call), output: step.output ?? step.detail },
4612
4849
  renderer.contentWidth
4613
4850
  )) {
4614
- renderer.print(line);
4851
+ say(line);
4615
4852
  }
4616
4853
  },
4617
- async approve(call, preview) {
4854
+ async approve(call, preview, helper) {
4618
4855
  stopSpinner();
4856
+ if (helper) renderer.print(` ${paint.accent("from a helper:")} ${paint.muted(helper)}`);
4619
4857
  for (const line of approvalCard(preview, call, state.mode, renderer.contentWidth)) {
4620
4858
  renderer.print(line);
4621
4859
  }
4622
4860
  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;
4861
+ held = [];
4862
+ try {
4863
+ const answer = await prompt.read(`${verb}? [y/N]`);
4864
+ const ok = answer.kind === "line" && /^y(es)?$/i.test(answer.text.trim());
4865
+ renderer.print(` ${ok ? paint.green("allowed") : paint.muted("declined")}`);
4866
+ return ok;
4867
+ } finally {
4868
+ const waiting = held;
4869
+ held = null;
4870
+ for (const line of waiting) renderer.print(line);
4871
+ }
4627
4872
  },
4628
4873
  error(message) {
4629
4874
  stopSpinner();
@@ -4634,7 +4879,7 @@ async function runInteractive(runtime) {
4634
4879
  if (info2.credits) state.credits = info2.credits;
4635
4880
  },
4636
4881
  commandOutput(line) {
4637
- renderer.print(paint.dim(` | ${line.replace(/\n/g, "\n | ")}`));
4882
+ say(paint.dim(` | ${line.replace(/\n/g, "\n | ")}`));
4638
4883
  }
4639
4884
  };
4640
4885
  startSpinner("thinking");
@@ -4817,6 +5062,14 @@ async function runScreen(runtime, opts) {
4817
5062
  const history = new History();
4818
5063
  const state = {
4819
5064
  mode: runtime.settings.mode,
5065
+ /**
5066
+ * The mode the running turn is held to, when shift+tab has moved `mode` up
5067
+ * to `agent` mid-answer. Going up does not apply until the next question -
5068
+ * a habit keypress must not let the very next command run unasked - while
5069
+ * going down applies at once, being the safe direction. Null when the turn
5070
+ * simply follows `mode`.
5071
+ */
5072
+ heldMode: null,
4820
5073
  effort: runtime.settings.effort,
4821
5074
  context: null,
4822
5075
  credits: null,
@@ -4952,7 +5205,8 @@ async function runScreen(runtime, opts) {
4952
5205
  `${paint.muted(basename5(runtime.root))}${runtime.branch ? ` ${paint.muted(runtime.branch)}` : ""}`,
4953
5206
  paint.dim(`session ${session}`)
4954
5207
  ].join(paint.dim(" \xB7 "));
4955
- const mode = state.mode === "agent" ? paint.red(paint.bold("agent")) : state.mode === "auto" ? paint.accent("auto") : paint.muted("ask");
5208
+ const shown = state.mode === "agent" ? paint.red(paint.bold("agent")) : state.mode === "auto" ? paint.accent("auto") : paint.muted("ask");
5209
+ const mode = state.heldMode ? `${shown}${paint.dim(" (next question)")}` : shown;
4956
5210
  const right = `${mode}${state.effort ? paint.dim(` \xB7 think ${state.effort}`) : ""} `;
4957
5211
  return spread(` ${left}`, right, width);
4958
5212
  }
@@ -4966,7 +5220,7 @@ async function runScreen(runtime, opts) {
4966
5220
  else if (state.popup) left = d("\u2191\u2193 move \xB7 tab/enter accept \xB7 esc close");
4967
5221
  else {
4968
5222
  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");
5223
+ 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
5224
  }
4971
5225
  const bits = [];
4972
5226
  if (state.context) {
@@ -5050,6 +5304,7 @@ async function runScreen(runtime, opts) {
5050
5304
  if (key.sequence === "n" || key.sequence === "N" || key.name === "escape") return answerApproval(false);
5051
5305
  return;
5052
5306
  }
5307
+ if (key.name === "tab" && key.shift) return cycleMode();
5053
5308
  if (key.ctrl && key.name === "d") {
5054
5309
  if (editor.isEmpty) quit();
5055
5310
  return;
@@ -5127,6 +5382,25 @@ async function runScreen(runtime, opts) {
5127
5382
  state.ctrlCAt = now;
5128
5383
  notice("press ctrl+c again to quit");
5129
5384
  }
5385
+ function effectiveMode() {
5386
+ return state.heldMode ?? state.mode;
5387
+ }
5388
+ function cycleMode() {
5389
+ const previous = effectiveMode();
5390
+ const next = AGENT_MODES[(AGENT_MODES.indexOf(state.mode) + 1) % AGENT_MODES.length];
5391
+ state.mode = next;
5392
+ const running = state.abort !== null;
5393
+ state.heldMode = next === "agent" && running ? previous : null;
5394
+ notice(`${next}: ${MODE_CAPTION[next]}`, 4e3);
5395
+ if (next === "agent") {
5396
+ note(
5397
+ ` ${paint.red(paint.bold("agent"))} ${paint.muted(
5398
+ running ? `- from your next question. This answer stays on ${previous}.` : "- commands now run without asking, and cannot be put back."
5399
+ )}
5400
+ ${paint.muted("For this session only. shift+tab again to leave it.")}`
5401
+ );
5402
+ }
5403
+ }
5130
5404
  function answerApproval(ok) {
5131
5405
  const approval = state.approval;
5132
5406
  if (!approval) return;
@@ -5241,7 +5515,10 @@ async function runScreen(runtime, opts) {
5241
5515
  });
5242
5516
  if (outcome.farewell) state.farewell = outcome.farewell;
5243
5517
  if (outcome.exit) return quit();
5244
- if (outcome.mode) state.mode = outcome.mode;
5518
+ if (outcome.mode) {
5519
+ state.mode = outcome.mode;
5520
+ state.heldMode = null;
5521
+ }
5245
5522
  if (outcome.effort !== void 0) state.effort = outcome.effort;
5246
5523
  schedule();
5247
5524
  }
@@ -5254,6 +5531,7 @@ async function runScreen(runtime, opts) {
5254
5531
  ["shift+enter, ctrl+j, or a trailing \\", "new line"],
5255
5532
  ["/ and @", "commands and files, with a list"],
5256
5533
  ["tab", "complete"],
5534
+ ["shift+tab", "next mode: ask, auto, agent"],
5257
5535
  ["pgup / pgdn, wheel", "scroll the transcript"],
5258
5536
  ["home / end", "top / bottom (with an empty box)"],
5259
5537
  ["esc", "close a list or dialog; jump back to the bottom"],
@@ -5336,6 +5614,7 @@ async function runScreen(runtime, opts) {
5336
5614
  }
5337
5615
  }
5338
5616
  async function ask(input) {
5617
+ state.heldMode = null;
5339
5618
  transcript.add({ kind: "user", text: input });
5340
5619
  state.asked = true;
5341
5620
  state.answer = null;
@@ -5372,7 +5651,8 @@ async function runScreen(runtime, opts) {
5372
5651
  }
5373
5652
  }
5374
5653
  closeAnswer();
5375
- let entry = state.toolBlocks.get(step.call.id);
5654
+ const key = stepKey(step);
5655
+ let entry = state.toolBlocks.get(key);
5376
5656
  if (!entry) {
5377
5657
  entry = transcript.add({
5378
5658
  kind: "tool",
@@ -5382,7 +5662,11 @@ async function runScreen(runtime, opts) {
5382
5662
  input: verbatim(step.call) || void 0,
5383
5663
  live: []
5384
5664
  });
5385
- state.toolBlocks.set(step.call.id, entry);
5665
+ state.toolBlocks.set(key, entry);
5666
+ if (state.approval) {
5667
+ transcript.toEnd(state.approval.entry);
5668
+ if (viewport.follow) viewport.end();
5669
+ }
5386
5670
  }
5387
5671
  entry.block.status = step.status;
5388
5672
  if (step.status !== "running" && step.status !== "awaiting") {
@@ -5393,10 +5677,10 @@ async function runScreen(runtime, opts) {
5393
5677
  if (step.status === "running" || step.status === "awaiting") setBusy(label(step));
5394
5678
  schedule();
5395
5679
  },
5396
- approve(call, preview) {
5680
+ approve(call, preview, helper) {
5397
5681
  setBusy(null);
5398
5682
  closeAnswer();
5399
- const entry = transcript.add({ kind: "approval", call, preview, mode: state.mode, answer: null });
5683
+ const entry = transcript.add({ kind: "approval", call, preview, helper, mode: effectiveMode(), answer: null });
5400
5684
  viewport.end();
5401
5685
  return new Promise((resolve2) => {
5402
5686
  state.approval = { entry, resolve: resolve2 };
@@ -5413,11 +5697,8 @@ async function runScreen(runtime, opts) {
5413
5697
  if (info2.credits) state.credits = info2.credits;
5414
5698
  schedule();
5415
5699
  },
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
- }
5700
+ commandOutput(line, key) {
5701
+ const target = key ? state.toolBlocks.get(key) : void 0;
5421
5702
  if (!target) return;
5422
5703
  target.block.live.push(...line.replace(/\n$/, "").split("\n"));
5423
5704
  transcript.touch(target);
@@ -5431,6 +5712,7 @@ async function runScreen(runtime, opts) {
5431
5712
  client: runtime.client,
5432
5713
  root: runtime.root,
5433
5714
  mode: state.mode,
5715
+ currentMode: effectiveMode,
5434
5716
  checkpoints: runtime.checkpoints,
5435
5717
  effort: state.effort,
5436
5718
  attachments,
@@ -5453,6 +5735,7 @@ async function runScreen(runtime, opts) {
5453
5735
  transcript.add({ kind: "error", text: err instanceof Error ? err.message : String(err) });
5454
5736
  } finally {
5455
5737
  if (state.abort === abort) state.abort = null;
5738
+ state.heldMode = null;
5456
5739
  if (state.approval) answerApproval(false);
5457
5740
  setBusy(null);
5458
5741
  }
@@ -5637,6 +5920,7 @@ async function runPrint(runtime, opts) {
5637
5920
  emit({
5638
5921
  type: "tool",
5639
5922
  name: step.call.name,
5923
+ ...step.helper ? { helper: step.helper } : {},
5640
5924
  status: step.status,
5641
5925
  input: step.call.input,
5642
5926
  detail: step.detail,
@@ -5644,7 +5928,8 @@ async function runPrint(runtime, opts) {
5644
5928
  output: step.output
5645
5929
  });
5646
5930
  } else if (step.status !== "running") {
5647
- toStderr(`${paint.dim(`${step.call.name}: ${step.status}${step.detail ? ` - ${step.detail}` : ""}`)}
5931
+ const who = step.helper ? `${step.helper.label} > ` : "";
5932
+ toStderr(`${paint.dim(`${who}${step.call.name}: ${step.status}${step.detail ? ` - ${step.detail}` : ""}`)}
5648
5933
  `);
5649
5934
  }
5650
5935
  if (step.status === "skipped") declined = true;
@@ -5803,6 +6088,8 @@ async function runRpc(runtime) {
5803
6088
  tool(step) {
5804
6089
  notify("labd/tool", {
5805
6090
  name: step.call.name,
6091
+ // Which helper made the call, when one did: its spawn_agent call id and name.
6092
+ ...step.helper ? { helper: step.helper } : {},
5806
6093
  status: step.status,
5807
6094
  input: step.call.input,
5808
6095
  detail: step.detail,
@@ -5816,8 +6103,8 @@ async function runRpc(runtime) {
5816
6103
  done(info2) {
5817
6104
  notify("labd/done", info2);
5818
6105
  },
5819
- commandOutput(line) {
5820
- notify("labd/command_output", { line });
6106
+ commandOutput(line, key) {
6107
+ notify("labd/command_output", { line, ...key ? { key } : {} });
5821
6108
  },
5822
6109
  /**
5823
6110
  * Ask the host. A host that never answers gets a refusal after a timeout
@@ -5825,7 +6112,7 @@ async function runRpc(runtime) {
5825
6112
  * alternative is writing to someone's files because their integration was
5826
6113
  * asleep.
5827
6114
  */
5828
- approve(call, preview) {
6115
+ approve(call, preview, helper) {
5829
6116
  return new Promise((resolve2) => {
5830
6117
  if (closed) return resolve2(false);
5831
6118
  const id = nextId++;
@@ -5836,7 +6123,7 @@ async function runRpc(runtime) {
5836
6123
  clearTimeout(timer);
5837
6124
  resolve2(approved);
5838
6125
  });
5839
- out({ jsonrpc: "2.0", id, method: "labd/approve", params: { call, preview } });
6126
+ out({ jsonrpc: "2.0", id, method: "labd/approve", params: { call, preview, ...helper ? { helper } : {} } });
5840
6127
  });
5841
6128
  }
5842
6129
  };
@@ -5866,7 +6153,7 @@ async function runRpc(runtime) {
5866
6153
  }
5867
6154
 
5868
6155
  // packages/cli/src/index.ts
5869
- var VERSION = true ? "0.5.0" : "0.0.0-dev";
6156
+ var VERSION = true ? "0.6.1" : "0.0.0-dev";
5870
6157
  async function main(argv) {
5871
6158
  const args = parse(argv);
5872
6159
  if (args.color !== void 0) setColor(args.color);