tau-all-agent 0.2.0 → 0.3.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.
Files changed (42) hide show
  1. package/extensions/answer.ts +112 -85
  2. package/extensions/branch-term/index.ts +9 -5
  3. package/extensions/btw.ts +67 -36
  4. package/extensions/insights.ts +67 -56
  5. package/extensions/loop.ts +14 -52
  6. package/extensions/memory.ts +65 -33
  7. package/extensions/notify.ts +5 -3
  8. package/extensions/review/fix.ts +10 -16
  9. package/extensions/review/git.ts +7 -1
  10. package/extensions/review/index.ts +2 -2
  11. package/extensions/review/models.ts +12 -2
  12. package/extensions/review/review.ts +3 -5
  13. package/extensions/review/runtime.ts +9 -8
  14. package/extensions/review/triage.ts +2 -4
  15. package/extensions/sandbox/README.md +5 -0
  16. package/extensions/sandbox/bash.ts +83 -85
  17. package/extensions/sandbox/index.ts +21 -11
  18. package/extensions/sandbox/permissions/network.ts +8 -3
  19. package/extensions/sandbox/permissions/unsandboxed.ts +1 -240
  20. package/extensions/sandbox/runtime.ts +53 -19
  21. package/extensions/subagent/README.md +58 -1
  22. package/extensions/subagent/config.ts +118 -0
  23. package/extensions/subagent/index.ts +54 -22
  24. package/extensions/telegram/README.md +2 -0
  25. package/extensions/telegram/assistant-result.d.mts +4 -1
  26. package/extensions/telegram/assistant-result.mjs +31 -16
  27. package/extensions/telegram/daemon.mjs +146 -194
  28. package/extensions/telegram/index.ts +11 -7
  29. package/extensions/tool-display-mode.ts +29 -50
  30. package/extensions/usage/README.md +5 -0
  31. package/extensions/usage/index.ts +139 -80
  32. package/extensions/websearch/README.md +8 -1
  33. package/extensions/websearch/config.ts +2 -1
  34. package/extensions/websearch/index.ts +18 -9
  35. package/extensions/websearch/providers/{openai-codex.pi.ts → openai.pi.ts} +51 -16
  36. package/extensions/websearch/providers/shared.ts +10 -3
  37. package/extensions/websearch/types.ts +2 -1
  38. package/extensions/worktree.ts +33 -89
  39. package/package.json +1 -1
  40. package/skills/oracle/SKILL.md +14 -8
  41. package/skills/oracle/scripts/oracle +43 -19
  42. package/skills/update-changelog/SKILL.md +8 -4
@@ -42,6 +42,7 @@ const POLLING_STOP_TIMEOUT_MS = 4_000;
42
42
  const ACTIVITY_NOTICE_COOLDOWN_MS = 60 * 60 * 1000;
43
43
  const UNPAIRED_IDLE_SHUTDOWN_MS = 60_000;
44
44
  const HEADLESS_START_TIMEOUT_MS = 10_000;
45
+ const HEADLESS_PROMPT_ACCEPT_TIMEOUT_MS = 30_000;
45
46
  const HEADLESS_STOP_TIMEOUT_MS = 5_000;
46
47
  const HEADLESS_ABORT_TIMEOUT_MS = 60_000;
47
48
  const POST_COMPACTION_RETRY_GRACE_MS = 500;
@@ -441,18 +442,20 @@ function createHeadlessRpcClient(cwd) {
441
442
  stdio: ["pipe", "pipe", "pipe"],
442
443
  });
443
444
 
444
- const write = child.stdin
445
- ? makeJsonlWriter(child.stdin)
446
- : () => {
447
- throw new Error("Headless pi session transport is unavailable.");
448
- };
445
+ const write = (payload) => {
446
+ if (stopping || transportClosed || !child.stdin?.writable) {
447
+ throw new Error("Headless pi session is closed.");
448
+ }
449
+ child.stdin.write(JSON.stringify(payload) + "\n");
450
+ };
449
451
  const eventHandlers = new Set();
450
- const responseHandlers = new Set();
451
452
  const closeHandlers = new Set();
452
453
  const pending = new Map();
453
454
  const closed = Promise.withResolvers();
454
455
  let nextRequestId = 1;
455
456
  let stopPromise = null;
457
+ let stopping = false;
458
+ let stopError;
456
459
  let transportClosed = false;
457
460
 
458
461
  function createExitError(code, signal) {
@@ -483,27 +486,22 @@ function createHeadlessRpcClient(cwd) {
483
486
  }
484
487
  }
485
488
 
486
- child.stdin?.on("error", () => {
487
- // handled by transport close
488
- });
489
+ for (const stream of [child.stdin, child.stdout, child.stderr]) {
490
+ stream?.on("error", (error) => {
491
+ void stop({ force: true, error });
492
+ });
493
+ }
489
494
  child.on("error", (error) => {
490
- finishTransportClose(error, { code: null, signal: null });
495
+ void stop({ force: true, error });
491
496
  });
492
497
 
493
498
  if (child.stdout) {
494
499
  attachJsonlReader(child.stdout, (line) => {
500
+ if (stopping || transportClosed) return;
495
501
  const msg = safeJsonParse(line);
496
502
  if (!msg || typeof msg.type !== "string") return;
497
503
 
498
504
  if (msg.type === "response") {
499
- for (const handler of Array.from(responseHandlers)) {
500
- try {
501
- handler(msg);
502
- } catch {
503
- // ignore
504
- }
505
- }
506
-
507
505
  const id = typeof msg.id === "string" ? msg.id : undefined;
508
506
  if (!id) return;
509
507
  const pendingRequest = pending.get(id);
@@ -538,183 +536,109 @@ function createHeadlessRpcClient(cwd) {
538
536
  });
539
537
  }
540
538
 
539
+ child.once("exit", closeExitedTransport);
541
540
  child.once("close", (code, signal) => {
542
- finishTransportClose(createExitError(code, signal), { code, signal });
541
+ finishTransportClose(stopError ?? createExitError(code, signal), { code, signal });
543
542
  });
544
543
 
545
544
  async function call(command, { timeoutMs = HEADLESS_START_TIMEOUT_MS } = {}) {
546
- if (child.exitCode !== null) {
547
- return {
548
- type: "response",
549
- success: false,
550
- error: `Headless pi session already exited with code ${child.exitCode}`,
551
- };
545
+ if (stopping || transportClosed || child.exitCode !== null || child.signalCode !== null) {
546
+ return { type: "response", success: false, error: "Headless pi session is closed." };
552
547
  }
553
548
 
554
549
  const id = `telegram-rpc-${nextRequestId++}`;
555
- const payload = { ...command, id };
550
+ const isPrompt = command.type === "prompt";
556
551
 
557
552
  return await new Promise((resolve) => {
558
- const timeout = setTimeout(() => {
559
- pending.delete(id);
560
- resolve({
561
- type: "response",
562
- success: false,
563
- error: `Timed out waiting for ${command.type} response`,
564
- });
565
- }, timeoutMs);
566
-
567
- pending.set(id, { resolve, timeout });
553
+ const fail = (error) => {
554
+ if (isPrompt) {
555
+ // A deadline or write failure cannot retract RPC input. Resolve only after close.
556
+ void stop({ force: true, error });
557
+ } else {
558
+ pending.delete(id);
559
+ clearTimeout(timeout);
560
+ resolve({ type: "response", success: false, error: errorMessage(error) });
561
+ }
562
+ };
563
+ const timeout = setTimeout(
564
+ () =>
565
+ fail(
566
+ new Error(
567
+ isPrompt
568
+ ? "Timed out waiting for prompt acceptance. Session closed; saved history was kept. The prompt was not retried."
569
+ : `Timed out waiting for ${command.type} response`,
570
+ ),
571
+ ),
572
+ timeoutMs,
573
+ );
574
+ pending.set(id, { resolve, timeout, isPrompt });
568
575
 
569
576
  try {
570
- write(payload);
577
+ write({ ...command, id });
571
578
  } catch (error) {
572
- pending.delete(id);
573
- clearTimeout(timeout);
574
- resolve({ type: "response", success: false, error: errorMessage(error) });
579
+ fail(error);
575
580
  }
576
581
  });
577
582
  }
578
583
 
579
- async function prompt(
580
- message,
581
- {
582
- streamingBehavior = "followUp",
583
- waitForStart = false,
584
- timeoutMs = HEADLESS_START_TIMEOUT_MS,
585
- onAccepted,
586
- onStarted,
587
- onFailed,
588
- } = {},
589
- ) {
590
- if (child.exitCode !== null) {
591
- throw new Error(`Headless pi session already exited with code ${child.exitCode}`);
584
+ async function prompt(message) {
585
+ const response = await call(
586
+ { type: "prompt", message, streamingBehavior: "followUp" },
587
+ { timeoutMs: HEADLESS_PROMPT_ACCEPT_TIMEOUT_MS },
588
+ );
589
+ if (!response.success) {
590
+ throw new Error(getCommandError(response, "Failed to send prompt to headless session"));
592
591
  }
593
-
594
- const id = `telegram-rpc-${nextRequestId++}`;
595
- const payload = { type: "prompt", message, streamingBehavior, id };
596
-
597
- return await new Promise((resolve, reject) => {
598
- let accepted = false;
599
- let resolved = false;
600
- let trackingActive = true;
601
- const timeout = setTimeout(() => {
602
- cleanup();
603
- onFailed?.({
604
- type: "response",
605
- id,
606
- success: false,
607
- error: "Timed out waiting for prompt response",
608
- });
609
- reject(
610
- new Error(
611
- waitForStart
612
- ? "Timed out waiting for prompt to start"
613
- : "Timed out waiting for prompt response",
614
- ),
615
- );
616
- }, timeoutMs);
617
-
618
- const cleanup = () => {
619
- if (!trackingActive) return;
620
- trackingActive = false;
621
- clearTimeout(timeout);
622
- responseHandlers.delete(handleResponse);
623
- eventHandlers.delete(handleEvent);
624
- closeHandlers.delete(handleClose);
625
- };
626
-
627
- const resolveOnce = () => {
628
- if (resolved) return;
629
- resolved = true;
630
- resolve({ id, success: true });
631
- };
632
-
633
- const rejectOnce = (error) => {
634
- if (resolved) return;
635
- resolved = true;
636
- reject(error instanceof Error ? error : new Error(String(error)));
637
- };
638
-
639
- const handleResponse = (response) => {
640
- if (response.id !== id) return;
641
-
642
- if (response.success === false) {
643
- cleanup();
644
- onFailed?.(response);
645
- rejectOnce(
646
- new Error(getCommandError(response, "Failed to send prompt to headless session")),
647
- );
648
- return;
649
- }
650
-
651
- if (accepted) return;
652
- accepted = true;
653
- onAccepted?.(id);
654
-
655
- if (!waitForStart) {
656
- cleanup();
657
- resolveOnce();
658
- }
659
- };
660
-
661
- const handleEvent = (event) => {
662
- if (!accepted || event.type !== "agent_start") return;
663
- onStarted?.(id);
664
- cleanup();
665
- resolveOnce();
666
- };
667
-
668
- const handleClose = ({ error }) => {
669
- cleanup();
670
- onFailed?.({ type: "response", id, success: false, error });
671
- rejectOnce(new Error(error));
672
- };
673
-
674
- responseHandlers.add(handleResponse);
675
- eventHandlers.add(handleEvent);
676
- closeHandlers.add(handleClose);
677
-
678
- try {
679
- write(payload);
680
- } catch (error) {
681
- cleanup();
682
- onFailed?.({ type: "response", id, success: false, error: errorMessage(error) });
683
- rejectOnce(error);
684
- }
685
- });
592
+ const disposition = response.data?.disposition;
593
+ if (!["started", "queued", "handled"].includes(disposition)) {
594
+ const error = new Error("Headless pi returned an invalid prompt disposition.");
595
+ await stop({ force: true, error });
596
+ throw error;
597
+ }
598
+ return disposition;
686
599
  }
687
600
 
688
- async function stop() {
601
+ async function stop({ force = hasPendingPrompt(), error } = {}) {
689
602
  if (stopPromise) return await stopPromise;
603
+ stopping = true;
604
+ stopError = error;
690
605
 
691
- stopPromise = (async () => {
692
- if (child.exitCode !== null) return;
693
-
694
- try {
695
- child.kill("SIGTERM");
696
- } catch {
697
- // ignore
698
- }
699
-
700
- const result = await Promise.race([
701
- closed.promise,
702
- sleep(HEADLESS_STOP_TIMEOUT_MS).then(() => null),
703
- ]);
606
+ stopPromise = closed.promise;
607
+ if (transportClosed) return;
704
608
 
705
- if (result !== null) return;
706
- if (child.exitCode !== null) return;
609
+ // Native abort/shutdown cannot retract a suspended before_agent_start hook.
610
+ // Force retirement when acceptance is uncertain, rather than letting it resume.
611
+ const timeout = setTimeout(() => signalChild("SIGKILL"), HEADLESS_STOP_TIMEOUT_MS);
612
+ try {
613
+ closeExitedTransport();
614
+ signalChild(force ? "SIGKILL" : "SIGTERM");
615
+ // `exit` is not enough: stdout/stderr must be closed before callers clean up.
616
+ await stopPromise;
617
+ } finally {
618
+ clearTimeout(timeout);
619
+ }
620
+ }
707
621
 
708
- try {
709
- child.kill("SIGKILL");
710
- } catch {
711
- // ignore
712
- }
622
+ function hasPendingPrompt() {
623
+ return Array.from(pending.values()).some((request) => request.isPrompt);
624
+ }
713
625
 
714
- await Promise.race([closed.promise, sleep(1_000)]);
715
- })();
626
+ function closeExitedTransport() {
627
+ if (!stopping || (child.exitCode === null && child.signalCode === null)) return;
628
+ // Descendants can retain inherited writers after Pi exits. Close only our endpoints,
629
+ // then let Node emit the real `close` event once these streams have finished closing.
630
+ child.stdin?.destroy();
631
+ child.stdout?.destroy();
632
+ child.stderr?.destroy();
633
+ }
716
634
 
717
- return await stopPromise;
635
+ function signalChild(signal) {
636
+ try {
637
+ child.kill(signal);
638
+ } catch (error) {
639
+ stopError ??= error;
640
+ console.error(`[telegram] Failed to send ${signal} to headless pi: ${errorMessage(error)}`);
641
+ }
718
642
  }
719
643
 
720
644
  return {
@@ -722,14 +646,16 @@ function createHeadlessRpcClient(cwd) {
722
646
  call,
723
647
  prompt,
724
648
  stop,
649
+ get closing() {
650
+ return stopping || transportClosed;
651
+ },
652
+ get awaitingPromptAcceptance() {
653
+ return hasPendingPrompt();
654
+ },
725
655
  onEvent(handler) {
726
656
  eventHandlers.add(handler);
727
657
  return () => eventHandlers.delete(handler);
728
658
  },
729
- onResponse(handler) {
730
- responseHandlers.add(handler);
731
- return () => responseHandlers.delete(handler);
732
- },
733
659
  onClose(handler) {
734
660
  closeHandlers.add(handler);
735
661
  return () => closeHandlers.delete(handler);
@@ -990,8 +916,9 @@ function setSessionCompacting(session, compacting) {
990
916
  }
991
917
 
992
918
  async function waitForHeadlessSessionPromptWindow(session) {
993
- while (sessions.has(session.key)) {
919
+ while (sessions.has(session.key) && !session.closing && !session.rpc.closing) {
994
920
  await refreshHeadlessSessionState(session).catch(() => {});
921
+ if (session.closing || session.rpc.closing) break;
995
922
 
996
923
  const pendingMessageCount =
997
924
  typeof session.pendingMessageCount === "number" ? session.pendingMessageCount : 0;
@@ -1559,7 +1486,7 @@ async function createHeadlessSession(cwd) {
1559
1486
  : 0,
1560
1487
  retryAfterCompactionUntil: 0,
1561
1488
  awaitingRetry: false,
1562
- lastAgentEndMessages: undefined,
1489
+ runAssistantMessages: undefined,
1563
1490
  lastTurnResult: undefined,
1564
1491
  lastTurnSeq: 0,
1565
1492
  unreadTurns: [],
@@ -1572,6 +1499,9 @@ async function createHeadlessSession(cwd) {
1572
1499
  compactionWaiters: new Set(),
1573
1500
  sendQueue: Promise.resolve(),
1574
1501
  async sendText(text) {
1502
+ if (session.closing || rpc.closing || !sessions.has(session.key)) {
1503
+ throw new Error("Session is no longer available.");
1504
+ }
1575
1505
  const backlogSize = session.pendingSendCount + session.queuedPromptCount;
1576
1506
  if (backlogSize >= MAX_QUEUED_HEADLESS_PROMPTS) {
1577
1507
  throw new Error("Session is busy. Too many queued prompts. Wait for it to catch up.");
@@ -1582,17 +1512,15 @@ async function createHeadlessSession(cwd) {
1582
1512
  .then(async () => {
1583
1513
  await waitForHeadlessSessionPromptWindow(session);
1584
1514
 
1585
- const waitForStart = !session.busy;
1586
1515
  session.queuedPromptCount += 1;
1587
-
1588
- await rpc.prompt(text, {
1589
- streamingBehavior: "followUp",
1590
- waitForStart,
1591
- timeoutMs: HEADLESS_START_TIMEOUT_MS,
1592
- onFailed: () => {
1516
+ try {
1517
+ if ((await rpc.prompt(text)) === "handled") {
1593
1518
  session.queuedPromptCount = Math.max(0, session.queuedPromptCount - 1);
1594
- },
1595
- });
1519
+ }
1520
+ } catch (error) {
1521
+ session.queuedPromptCount = Math.max(0, session.queuedPromptCount - 1);
1522
+ throw error;
1523
+ }
1596
1524
  })
1597
1525
  .finally(() => {
1598
1526
  session.pendingSendCount = Math.max(0, session.pendingSendCount - 1);
@@ -1602,20 +1530,35 @@ async function createHeadlessSession(cwd) {
1602
1530
  await sendOperation;
1603
1531
  },
1604
1532
  async abort() {
1533
+ if (
1534
+ rpc.awaitingPromptAcceptance ||
1535
+ rpc.closing ||
1536
+ (!session.busy && session.pendingSendCount > 0)
1537
+ ) {
1538
+ session.closing = true;
1539
+ await rpc.stop({
1540
+ force: true,
1541
+ error: new Error("Prompt cancelled. Session closed; saved history was kept."),
1542
+ });
1543
+ await session.sendQueue;
1544
+ return;
1545
+ }
1605
1546
  const response = await rpc.call({ type: "abort" }, { timeoutMs: HEADLESS_ABORT_TIMEOUT_MS });
1606
1547
  if (!response.success) {
1607
1548
  throw new Error(getCommandError(response, "Failed to abort headless session"));
1608
1549
  }
1609
1550
  },
1610
1551
  async quit() {
1611
- if (session.closing) return;
1612
- session.closing = true;
1613
- try {
1614
- await session.abort();
1615
- } catch {
1616
- // ignore
1552
+ if (!session.closing) {
1553
+ session.closing = true;
1554
+ try {
1555
+ await session.abort();
1556
+ } catch {
1557
+ // ignore
1558
+ }
1617
1559
  }
1618
1560
  await rpc.stop();
1561
+ await session.sendQueue;
1619
1562
  },
1620
1563
  };
1621
1564
 
@@ -1645,6 +1588,8 @@ async function createHeadlessSession(cwd) {
1645
1588
  }
1646
1589
 
1647
1590
  if (event.type === "agent_start") {
1591
+ // Keep same-run partial text through native retries, never across settlement.
1592
+ session.runAssistantMessages ??= [];
1648
1593
  session.retryAfterCompactionUntil = 0;
1649
1594
  session.queuedPromptCount = Math.max(0, session.queuedPromptCount - 1);
1650
1595
  session.busy = true;
@@ -1652,8 +1597,12 @@ async function createHeadlessSession(cwd) {
1652
1597
  return;
1653
1598
  }
1654
1599
 
1600
+ if (event.type === "message_end" && event.message.role === "assistant") {
1601
+ session.runAssistantMessages?.push(event.message);
1602
+ return;
1603
+ }
1604
+
1655
1605
  if (event.type === "agent_end") {
1656
- session.lastAgentEndMessages = event.messages;
1657
1606
  session.awaitingRetry = Boolean(event.willRetry);
1658
1607
  session.busy = true;
1659
1608
  updateTypingIndicator();
@@ -1661,8 +1610,11 @@ async function createHeadlessSession(cwd) {
1661
1610
  }
1662
1611
 
1663
1612
  if (event.type === "agent_settled") {
1664
- const result = formatTelegramAssistantResultFromMessages(session.lastAgentEndMessages);
1665
- session.lastAgentEndMessages = undefined;
1613
+ const result = formatTelegramAssistantResultFromMessages(
1614
+ session.runAssistantMessages,
1615
+ event.aborted,
1616
+ );
1617
+ session.runAssistantMessages = undefined;
1666
1618
  session.awaitingRetry = false;
1667
1619
  session.retryAfterCompactionUntil = 0;
1668
1620
  session.busy = false;
@@ -500,7 +500,7 @@ export default function (pi: ExtensionAPI) {
500
500
  sendFileToolRegistered: false,
501
501
  };
502
502
 
503
- let lastAgentEndMessages: AgentMessage[] | undefined;
503
+ let runAssistantMessages: AgentMessage[] | undefined;
504
504
  const daemonMessageHandlers = new Set<(msg: DaemonToClientMessage) => void>();
505
505
 
506
506
  function clearPendingInjectedFlushTimer() {
@@ -1070,6 +1070,7 @@ export default function (pi: ExtensionAPI) {
1070
1070
  }
1071
1071
 
1072
1072
  pi.on("session_start", async (_event, ctx) => {
1073
+ runAssistantMessages = undefined;
1073
1074
  state.lastCtx = ctx;
1074
1075
  startAutoConnectLoop();
1075
1076
  if (isSocketConnected()) {
@@ -1090,6 +1091,8 @@ export default function (pi: ExtensionAPI) {
1090
1091
  });
1091
1092
 
1092
1093
  pi.on("agent_start", async (_event, ctx) => {
1094
+ // Native retries start another low-level agent run before session-level settlement.
1095
+ runAssistantMessages ??= [];
1093
1096
  if (state.compacting) {
1094
1097
  applyCompactingState(false, ctx);
1095
1098
  }
@@ -1108,13 +1111,14 @@ export default function (pi: ExtensionAPI) {
1108
1111
  }
1109
1112
  });
1110
1113
 
1111
- pi.on("agent_end", async (event) => {
1112
- lastAgentEndMessages = event.messages;
1114
+ // turn_end observes the final message after every message_end replacement handler.
1115
+ pi.on("turn_end", (event) => {
1116
+ if (event.message.role === "assistant") runAssistantMessages?.push(event.message);
1113
1117
  });
1114
1118
 
1115
- pi.on("agent_settled", async (_event, ctx) => {
1116
- const result = formatTelegramAssistantResultFromMessages(lastAgentEndMessages);
1117
- lastAgentEndMessages = undefined;
1119
+ pi.on("agent_settled", async (event, ctx) => {
1120
+ const result = formatTelegramAssistantResultFromMessages(runAssistantMessages, event.aborted);
1121
+ runAssistantMessages = undefined;
1118
1122
  state.awaitingRetry = false;
1119
1123
  state.busy = false;
1120
1124
  if (isSocketConnected()) {
@@ -1161,7 +1165,7 @@ export default function (pi: ExtensionAPI) {
1161
1165
  state.busy = false;
1162
1166
  state.compacting = false;
1163
1167
  state.awaitingRetry = false;
1164
- lastAgentEndMessages = undefined;
1168
+ runAssistantMessages = undefined;
1165
1169
  state.pendingInjectedTexts = [];
1166
1170
  disconnect(false);
1167
1171
  state.lastCtx = null;
@@ -5,19 +5,13 @@ import {
5
5
  getAgentDir,
6
6
  type AppKeybinding,
7
7
  CustomEditor,
8
- createBashToolDefinition,
9
- createFindToolDefinition,
10
- createGrepToolDefinition,
11
- createLsToolDefinition,
12
- createReadToolDefinition,
13
8
  type AgentToolResult,
14
9
  type BashToolDetails,
15
10
  type ExtensionAPI,
16
11
  type ExtensionContext,
17
12
  type KeybindingsManager,
18
13
  type Theme,
19
- type ToolDefinition,
20
- type ToolInfo,
14
+ type ToolRenderers,
21
15
  type ToolRenderResultOptions,
22
16
  } from "@earendil-works/pi-coding-agent";
23
17
  import {
@@ -48,20 +42,13 @@ const FIND_NO_MATCHES_OUTPUT = "No files found matching pattern"; // core/tools/
48
42
  const LS_EMPTY_DIRECTORY_OUTPUT = "(empty directory)"; // core/tools/ls.ts
49
43
 
50
44
  // PowerShell is intentionally omitted because Tau does not officially support Windows.
51
- const TOOL_FACTORIES = {
52
- read: createReadToolDefinition,
53
- bash: createBashToolDefinition,
54
- grep: createGrepToolDefinition,
55
- find: createFindToolDefinition,
56
- ls: createLsToolDefinition,
57
- };
45
+ const TOOL_NAMES = ["read", "bash", "grep", "find", "ls"] as const;
58
46
 
59
47
  // --- Types ---
60
48
 
61
49
  type Mode = (typeof MODES)[number];
62
- type ToolName = keyof typeof TOOL_FACTORIES;
63
- type AnyToolDefinition = ToolDefinition<any, any, any>;
64
- type AnyToolRenderContext = Parameters<NonNullable<AnyToolDefinition["renderResult"]>>[3];
50
+ type ToolName = (typeof TOOL_NAMES)[number];
51
+ type AnyToolRenderContext = Parameters<NonNullable<ToolRenderers["renderResult"]>>[3];
65
52
  type JsonObject = Record<string, unknown>;
66
53
  type EditorFactory = NonNullable<ReturnType<ExtensionContext["ui"]["getEditorComponent"]>>;
67
54
  type WorkingIndicator = NonNullable<Parameters<CustomEditor["setWorkingStatusIndicator"]>[0]>;
@@ -137,20 +124,13 @@ async function saveConfig(config: ToolDisplayModeConfig): Promise<void> {
137
124
  await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
138
125
  }
139
126
 
140
- // --- Tool definitions ---
141
-
142
- function shouldRegisterToolRenderer(tools: ToolInfo[], name: ToolName): boolean {
143
- const existingTool = tools.find((tool) => tool.name === name);
144
- return existingTool?.sourceInfo.source === "builtin";
145
- }
146
-
147
- function createToolDisplayDefinition(options: {
148
- name: ToolName;
149
- getMode: () => Mode;
150
- }): AnyToolDefinition {
151
- const { name, getMode } = options;
152
- const base: AnyToolDefinition = TOOL_FACTORIES[name](process.cwd());
127
+ // --- Tool rendering ---
153
128
 
129
+ function createToolDisplayRenderers(
130
+ name: ToolName,
131
+ base: ToolRenderers,
132
+ getMode: () => Mode | undefined,
133
+ ): ToolRenderers {
154
134
  return {
155
135
  ...base,
156
136
 
@@ -159,22 +139,21 @@ function createToolDisplayDefinition(options: {
159
139
  const renderer = base.renderResult;
160
140
  const previous = context.lastComponent;
161
141
 
162
- if (mode === "minimal") {
142
+ // Explicit expansion (including HTML export) always reveals the delegated output.
143
+ if (mode === "minimal" && !options.expanded) {
163
144
  const component =
164
145
  previous instanceof MinimalResultComponent
165
146
  ? previous
166
147
  : new MinimalResultComponent(previous);
167
- if (name === "bash") {
168
- component.nativeComponent = renderer?.(result, options, theme, {
169
- ...context,
170
- lastComponent: component.nativeComponent,
171
- });
172
- }
148
+ component.nativeComponent = renderer?.(result, options, theme, {
149
+ ...context,
150
+ lastComponent: component.nativeComponent,
151
+ });
173
152
  component.setText(formatMinimalResult(name, result, options, theme, context));
174
153
  return component;
175
154
  }
176
155
 
177
- const expanded = mode === "expanded";
156
+ const expanded = mode === "expanded" || options.expanded;
178
157
  return (
179
158
  renderer?.(result, { ...options, expanded }, theme, {
180
159
  ...context,
@@ -576,7 +555,6 @@ class ToolDisplayEditor implements EditorComponent, Focusable {
576
555
 
577
556
  export default function toolDisplayModeExtension(pi: ExtensionAPI): void {
578
557
  let mode: Mode = INITIAL_MODE;
579
- let registeredToolRenderers = false;
580
558
  let installedEditorFactory: EditorFactory | undefined;
581
559
  let previousEditorFactory: EditorFactory | undefined;
582
560
  let editor: ToolDisplayEditor | undefined;
@@ -588,6 +566,17 @@ export default function toolDisplayModeExtension(pi: ExtensionAPI): void {
588
566
  let compacting = false;
589
567
  const activities = new Map<ActivitySource, string>();
590
568
 
569
+ pi.registerToolRenderer((name, next) => {
570
+ const renderers = next();
571
+ if (!renderers?.renderResult || !(TOOL_NAMES as readonly string[]).includes(name))
572
+ return renderers;
573
+
574
+ // Restored rows resolve before session_start. Read the mode when rendering.
575
+ return createToolDisplayRenderers(name as ToolName, renderers, () =>
576
+ context ? mode : undefined,
577
+ );
578
+ });
579
+
591
580
  const getActivity = (): ActivityState | undefined => {
592
581
  if (
593
582
  !context ||
@@ -662,22 +651,12 @@ export default function toolDisplayModeExtension(pi: ExtensionAPI): void {
662
651
  pi.on("session_start", async (_event, ctx) => {
663
652
  if (!ctx.hasUI) return;
664
653
  mode = (await loadConfig()).mode;
665
-
666
- if (!registeredToolRenderers) {
667
- const tools = pi.getAllTools();
668
- for (const name of Object.keys(TOOL_FACTORIES) as ToolName[]) {
669
- if (!shouldRegisterToolRenderer(tools, name)) continue;
670
-
671
- pi.registerTool(createToolDisplayDefinition({ name, getMode: () => mode }));
672
- }
673
- registeredToolRenderers = true;
674
- }
654
+ context = ctx.mode === "tui" ? ctx : undefined;
675
655
 
676
656
  applyMode(ctx, mode);
677
657
 
678
658
  if (installedEditorFactory && ctx.ui.getEditorComponent() === installedEditorFactory) return;
679
659
 
680
- context = ctx.mode === "tui" ? ctx : undefined;
681
660
  sessionKey =
682
661
  ctx.sessionManager.getSessionFile() ?? `session:${ctx.sessionManager.getSessionId()}`;
683
662
  previousEditorFactory = ctx.ui.getEditorComponent();