@bastani/atomic 0.9.14-alpha.3 → 0.9.14-alpha.4

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/CHANGELOG.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.9.14-alpha.4] - 2026-08-18
6
+
7
+ ### Changed
8
+
9
+ - Bundled `web_search` no longer opens the curator confirmation UI by default. Persist `"workflow": "summary-review"` in web-search config or run `/curator on` to opt in.
10
+
11
+ ### Removed
12
+
13
+ - The `workflow` parameter on bundled `web_search`. Models cannot select the curator per call.
14
+
5
15
  ## [0.9.14-alpha.3] - 2026-08-17
6
16
 
7
17
  ### Changed
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bastani/intercom",
3
- "version": "0.9.14-alpha.3",
3
+ "version": "0.9.14-alpha.4",
4
4
  "private": true,
5
5
  "description": "Atomic extension providing a private coordination channel between parent and child agent sessions. Fork of: https://github.com/nicobailon/pi-intercom",
6
6
  "contributors": [
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bastani/mcp",
3
- "version": "0.9.14-alpha.3",
3
+ "version": "0.9.14-alpha.4",
4
4
  "private": true,
5
5
  "description": "Atomic extension that adapts MCP (Model Context Protocol) servers into the coding agent. Fork of: https://github.com/nicobailon/pi-mcp-adapter",
6
6
  "contributors": [
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bastani/subagents",
3
- "version": "0.9.14-alpha.3",
3
+ "version": "0.9.14-alpha.4",
4
4
  "private": true,
5
5
  "description": "Atomic extension for delegating tasks to subagents with parallel execution. Fork of: https://github.com/nicobailon/pi-subagents",
6
6
  "contributors": [
@@ -4,6 +4,16 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.9.14-alpha.4] - 2026-08-18
8
+
9
+ ### Changed
10
+
11
+ - The interactive search curator is now opt-in. `web_search` returns raw results unless you set `"workflow": "summary-review"` in web-search config or run `/curator on`.
12
+
13
+ ### Removed
14
+
15
+ - The `workflow` parameter on `web_search`. The curator is no longer selectable per tool call; enable it with web-search config or `/curator`.
16
+
7
17
  ## [0.9.14-alpha.3] - 2026-08-17
8
18
 
9
19
  ### Fixed
@@ -78,7 +78,7 @@ For batch search/fetch calls, partial success remains usable and retains item-le
78
78
 
79
79
  ### web_search
80
80
 
81
- Search the web via Exa, Perplexity AI, or Gemini. Returns a synthesized answer with source citations.
81
+ Search the web via Exa, Perplexity AI, or Gemini. Returns a synthesized answer with source citations. The curator UI is off by default; enable it with `/curator on` or `"workflow": "summary-review"` in config.
82
82
 
83
83
  ```typescript
84
84
  web_search({ query: "rust async programming" })
@@ -87,8 +87,6 @@ web_search({ query: "latest news", numResults: 10, recencyFilter: "week" })
87
87
  web_search({ query: "...", domainFilter: ["github.com"] })
88
88
  web_search({ query: "...", provider: "exa" })
89
89
  web_search({ query: "...", includeContent: true })
90
- web_search({ queries: ["query 1", "query 2"], workflow: "none" })
91
- web_search({ queries: ["query 1", "query 2"], workflow: "summary-review" })
92
90
  ```
93
91
 
94
92
  | Parameter | Description |
@@ -99,7 +97,6 @@ web_search({ queries: ["query 1", "query 2"], workflow: "summary-review" })
99
97
  | `domainFilter` | Limit to domains (prefix with `-` to exclude) |
100
98
  | `provider` | `auto` (default), `exa`, `perplexity`, or `gemini` |
101
99
  | `includeContent` | Fetch full page content from sources in background |
102
- | `workflow` | `none` (skip curator) or `summary-review` (auto-generate summary draft after search completion, default) |
103
100
 
104
101
  ### code_search
105
102
 
@@ -227,7 +224,7 @@ Toggle or configure the curator workflow at runtime.
227
224
  /curator summary-review # explicit workflow
228
225
  ```
229
226
 
230
- Persists to `~/.pi/web-search.json` and takes effect on the next `web_search` call. When disabled, `web_search` returns raw results without opening the curator window.
227
+ Persists to `~/.pi/web-search.json` and takes effect on the next `web_search` call. The curator is off by default. When disabled, `web_search` returns raw results without opening the curator window.
231
228
 
232
229
  ### /search
233
230
 
@@ -287,7 +284,7 @@ All config lives in `~/.pi/web-search.json`. Every field is optional.
287
284
  }
288
285
  ```
289
286
 
290
- `EXA_API_KEY`, `GEMINI_API_KEY`, and `PERPLEXITY_API_KEY` env vars take precedence over config file values. `provider` sets the default search provider: `"exa"`, `"perplexity"`, or `"gemini"`. This is also updated automatically when you change the provider in the curator UI. `workflow` sets the default curator mode: `"summary-review"` (default, opens curator with auto-generated summary draft) or `"none"` (raw results, no curator). Overridden per-call via the `workflow` parameter on `web_search`, or toggled at runtime with `/curator`. `chromeProfile` overrides the Chromium profile directory used for Gemini Web cookie lookup. `allowBrowserCookies` enables Chromium cookie extraction for Gemini Web; it defaults to `false` to avoid surprise macOS Keychain prompts. You can also set `PI_ALLOW_BROWSER_COOKIES=1`. `searchModel` overrides the Gemini API model used by `web_search` without changing URL, YouTube, or video extraction defaults. `summaryModel` sets the default model used for generating summary drafts in the curator UI (e.g. `"anthropic/claude-haiku-4-5"` or `"openai-codex/gpt-5.3-codex-spark"`). Only models available in your model registry are eligible; if the configured model is unavailable, the default falls back to the built-in preference list. `curatorTimeoutSeconds` controls the initial curator idle timeout (default `20`, max `600`); users can still adjust the timer in the curator UI.
287
+ `EXA_API_KEY`, `GEMINI_API_KEY`, and `PERPLEXITY_API_KEY` env vars take precedence over config file values. `provider` sets the default search provider: `"exa"`, `"perplexity"`, or `"gemini"`. This is also updated automatically when you change the provider in the curator UI. `workflow` sets the curator mode: `"none"` (default, raw results, no curator) or `"summary-review"` (opens curator with auto-generated summary draft). Enable it in this file or toggle at runtime with `/curator`. `chromeProfile` overrides the Chromium profile directory used for Gemini Web cookie lookup. `allowBrowserCookies` enables Chromium cookie extraction for Gemini Web; it defaults to `false` to avoid surprise macOS Keychain prompts. You can also set `PI_ALLOW_BROWSER_COOKIES=1`. `searchModel` overrides the Gemini API model used by `web_search` without changing URL, YouTube, or video extraction defaults. `summaryModel` sets the default model used for generating summary drafts in the curator UI (e.g. `"anthropic/claude-haiku-4-5"` or `"openai-codex/gpt-5.3-codex-spark"`). Only models available in your model registry are eligible; if the configured model is unavailable, the default falls back to the built-in preference list. `curatorTimeoutSeconds` controls the initial curator idle timeout (default `20`, max `600`); users can still adjust the timer in the curator UI.
291
288
 
292
289
  ### Shortcuts
293
290
 
@@ -320,7 +320,7 @@ export default function webAccess(pi: ExtensionAPI) {
320
320
  pi.registerTool({
321
321
  name: "web_search",
322
322
  label: "Web Search",
323
- description: "Search the web using Perplexity AI, Exa, or Gemini. Returns an AI-synthesized answer with source citations. For comprehensive research, prefer queries (plural) with 2-4 varied angles over a single query — each query gets its own synthesized answer, so varying phrasing and scope gives much broader coverage. When includeContent is true, full page content is fetched in the background. Searches auto-open the interactive browser curator and stream results live; set workflow to \"none\" to skip curation. Provider auto-selects: Exa (direct API with key, MCP fallback without), else Perplexity (needs key), else Gemini API (needs key), else Gemini Web (needs a supported Chromium-based browser login).",
323
+ description: "Search the web using Perplexity AI, Exa, or Gemini. Returns an AI-synthesized answer with source citations. For comprehensive research, prefer queries (plural) with 2-4 varied angles over a single query — each query gets its own synthesized answer, so varying phrasing and scope gives much broader coverage. When includeContent is true, full page content is fetched in the background. Searches return raw results by default. Enable the interactive browser curator with /curator on or by setting workflow to \"summary-review\" in web-search config. Provider auto-selects: Exa (direct API with key, MCP fallback without), else Perplexity (needs key), else Gemini API (needs key), else Gemini Web (needs a supported Chromium-based browser login).",
324
324
  promptSnippet: "Use for web research questions. Prefer {queries:[...]} with 2-4 varied angles over a single query for broader coverage.",
325
325
  parameters: Type.Object({
326
326
  query: Type.Optional(Type.String({ description: "Single search query. For research tasks, prefer 'queries' with multiple varied angles instead." })),
@@ -330,7 +330,6 @@ export default function webAccess(pi: ExtensionAPI) {
330
330
  recencyFilter: Type.Optional(Type.String({ enum: ["day", "week", "month", "year"], description: "Filter by recency" })),
331
331
  domainFilter: Type.Optional(Type.Array(Type.String(), { description: "Limit to domains (prefix with - to exclude)" })),
332
332
  provider: Type.Optional(Type.String({ enum: ["auto", "perplexity", "gemini", "exa"], description: "Search provider (default: auto)" })),
333
- workflow: Type.Optional(Type.String({ enum: ["none", "summary-review"], description: "Search workflow mode: none = no curator, summary-review = open curator with auto summary draft (default)" })),
334
333
  }),
335
334
  execute: (...args) => executeHeavyTool(loadHeavy, "web_search", args),
336
335
  renderResult: (...args) => renderHeavyToolResult(loadedHeavy?.heavy ?? null, "web_search", args),
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bastani/web-access",
3
- "version": "0.9.14-alpha.3",
3
+ "version": "0.9.14-alpha.4",
4
4
  "private": true,
5
5
  "description": "Atomic extension for web search, URL fetching, GitHub repo cloning, PDF/video extraction. Fork of: https://github.com/nicobailon/pi-web-access",
6
6
  "contributors": [
@@ -33,8 +33,8 @@ export interface ProviderAvailability {
33
33
  gemini: boolean;
34
34
  }
35
35
 
36
- export type WebSearchWorkflow = "none" | "summary-review";
37
- export type CuratorWorkflow = "summary-review";
36
+ export type { CuratorWorkflow, WebSearchWorkflow } from "./web-search-workflow.js";
37
+ export { resolveWorkflow } from "./web-search-workflow.js";
38
38
 
39
39
  export interface CuratorBootstrap {
40
40
  availableProviders: ProviderAvailability;
@@ -99,12 +99,6 @@ function normalizeCuratorTimeoutSeconds(value: unknown): number | undefined {
99
99
  return Math.min(normalized, MAX_CURATOR_TIMEOUT_SECONDS);
100
100
  }
101
101
 
102
- export function resolveWorkflow(input: unknown, hasUI: boolean): WebSearchWorkflow {
103
- if (!hasUI) return "none";
104
- if (typeof input === "string" && input.trim().toLowerCase() === "none") return "none";
105
- return "summary-review";
106
- }
107
-
108
102
  export function normalizeQueryList(queryList: unknown[]): string[] {
109
103
  const normalized: string[] = [];
110
104
  for (const query of queryList) {
@@ -32,7 +32,7 @@ export function registerWebSearchTool(pi: ExtensionAPI, deps: RegisterWebSearchT
32
32
  name: "web_search",
33
33
  label: "Web Search",
34
34
  description:
35
- `Search the web using Perplexity AI, Exa, or Gemini. Returns an AI-synthesized answer with source citations. For comprehensive research, prefer queries (plural) with 2-4 varied angles over a single query — each query gets its own synthesized answer, so varying phrasing and scope gives much broader coverage. When includeContent is true, full page content is fetched in the background. Searches auto-open the interactive browser curator and stream results live; set workflow to "none" to skip curation. Provider auto-selects: Exa (direct API with key, MCP fallback without), else Perplexity (needs key), else Gemini API (needs key), else Gemini Web (needs a supported Chromium-based browser login).`,
35
+ `Search the web using Perplexity AI, Exa, or Gemini. Returns an AI-synthesized answer with source citations. For comprehensive research, prefer queries (plural) with 2-4 varied angles over a single query — each query gets its own synthesized answer, so varying phrasing and scope gives much broader coverage. When includeContent is true, full page content is fetched in the background. Searches return raw results by default. Enable the interactive browser curator with /curator on or by setting workflow to "summary-review" in web-search config. Provider auto-selects: Exa (direct API with key, MCP fallback without), else Perplexity (needs key), else Gemini API (needs key), else Gemini Web (needs a supported Chromium-based browser login).`,
36
36
  promptSnippet:
37
37
  "Use for web research questions. Prefer {queries:[...]} with 2-4 varied angles over a single query for broader coverage.",
38
38
  parameters: Type.Object({
@@ -47,11 +47,6 @@ export function registerWebSearchTool(pi: ExtensionAPI, deps: RegisterWebSearchT
47
47
  provider: Type.Optional(
48
48
  StringEnum(["auto", "perplexity", "gemini", "exa"], { description: "Search provider (default: auto)" }),
49
49
  ),
50
- workflow: Type.Optional(
51
- StringEnum(["none", "summary-review"], {
52
- description: "Search workflow mode: none = no curator, summary-review = open curator with auto summary draft (default)",
53
- }),
54
- ),
55
50
  }),
56
51
 
57
52
  async execute(_toolCallId, params, signal, onUpdate, ctx) {
@@ -60,7 +55,7 @@ export function registerWebSearchTool(pi: ExtensionAPI, deps: RegisterWebSearchT
60
55
  : (params.query !== undefined ? [params.query] : []);
61
56
  const queryList = normalizeQueryList(rawQueryList);
62
57
  const configWorkflow = loadConfigForExtensionInit().workflow;
63
- const workflow = resolveWorkflow(params.workflow ?? configWorkflow, ctx?.hasUI !== false);
58
+ const workflow = resolveWorkflow(configWorkflow, ctx?.hasUI !== false);
64
59
  const shouldCurate = workflow !== "none";
65
60
 
66
61
  if (queryList.length === 0) {
@@ -0,0 +1,10 @@
1
+ export type WebSearchWorkflow = "none" | "summary-review";
2
+ export type CuratorWorkflow = "summary-review";
3
+
4
+ export function resolveWorkflow(input: unknown, hasUI: boolean): WebSearchWorkflow {
5
+ if (!hasUI) return "none";
6
+ if (typeof input === "string" && input.trim().toLowerCase() === "summary-review") {
7
+ return "summary-review";
8
+ }
9
+ return "none";
10
+ }
@@ -6,6 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.9.14-alpha.4] - 2026-08-18
10
+
11
+ ### Fixed
12
+
13
+ - Opened `ctx.tool` detail now matches the main-chat tool block: `Box(1,1)` inner padding, a blank row under the `$` header, full-width alignment with the orchestrator bars, unpainted canvas above and below the card, host `toolTitle`/`toolOutput` colors, a dim `ctrl+o Expand` hint, and second-resolution `Took`/`Elapsed` timing.
14
+
9
15
  ## [0.9.14-alpha.3] - 2026-08-17
10
16
 
11
17
  ### Changed
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bastani/workflows",
3
- "version": "0.9.14-alpha.3",
3
+ "version": "0.9.14-alpha.4",
4
4
  "private": true,
5
5
  "description": "Atomic extension for multi-stage workflow authoring and execution.",
6
6
  "contributors": [
@@ -38912,7 +38912,8 @@ var MOCHA = {
38912
38912
  yellow: "#f9e2af",
38913
38913
  red: "#f38ba8",
38914
38914
  mauve: "#cba6f7",
38915
- sky: "#89dceb"
38915
+ sky: "#89dceb",
38916
+ lavender: "#b4befe"
38916
38917
  };
38917
38918
  function deriveGraphTheme(theme = {}) {
38918
38919
  return {
@@ -38929,6 +38930,8 @@ function deriveGraphTheme(theme = {}) {
38929
38930
  text: theme.text ?? MOCHA.text,
38930
38931
  textMuted: theme.textMuted ?? MOCHA.subtext0,
38931
38932
  dim: theme.dim ?? MOCHA.overlay1,
38933
+ toolTitle: theme.toolTitle ?? MOCHA.lavender,
38934
+ toolOutput: theme.toolOutput ?? MOCHA.text,
38932
38935
  accent: theme.accent ?? MOCHA.blue,
38933
38936
  mauve: theme.mauve ?? MOCHA.mauve,
38934
38937
  success: theme.success ?? MOCHA.green,
@@ -39002,6 +39005,8 @@ function deriveGraphThemeFromPiTheme(theme) {
39002
39005
  text: fgHex(t, "text") ?? fgHex(t, "customMessageText"),
39003
39006
  textMuted: fgHex(t, "muted"),
39004
39007
  dim: fgHex(t, "dim"),
39008
+ toolTitle: fgHex(t, "toolTitle"),
39009
+ toolOutput: fgHex(t, "toolOutput") ?? fgHex(t, "text") ?? fgHex(t, "customMessageText"),
39005
39010
  accent,
39006
39011
  success: fgHex(t, "success"),
39007
39012
  warning: fgHex(t, "warning"),
@@ -43801,7 +43806,12 @@ function renderSwitcher(stages, state, opts) {
43801
43806
  var TOOL_DETAIL_VALUE_LIMIT = TOOL_PAYLOAD_VALUE_LIMIT;
43802
43807
  var COLLAPSED_ARGS_LIMIT = 240;
43803
43808
  var COLLAPSED_RESULT_LINES = 10;
43804
- var BODY_INDENT = " ";
43809
+ var BOX_PAD = 1;
43810
+ function boxMetrics(width) {
43811
+ const safeWidth = Math.max(1, Math.floor(width));
43812
+ const inset = safeWidth >= BOX_PAD * 2 + 1 ? BOX_PAD : 0;
43813
+ return { pad: " ".repeat(inset), inner: Math.max(1, safeWidth - inset * 2) };
43814
+ }
43805
43815
  function statusColor2(status, theme) {
43806
43816
  switch (status) {
43807
43817
  case "running":
@@ -43842,14 +43852,26 @@ function styledStatus(status, theme) {
43842
43852
  return theme === undefined ? glyph : `${hexToAnsi(statusColor2(status, theme))}${glyph}${RESET}`;
43843
43853
  }
43844
43854
  function styledTitle(text, theme) {
43845
- return theme === undefined ? text : `${hexToAnsi(theme.text)}${BOLD}${text}${RESET}`;
43855
+ return theme === undefined ? text : `${hexToAnsi(theme.toolTitle)}${BOLD}${text}${RESET}`;
43856
+ }
43857
+ function styledOutput(text, theme) {
43858
+ return theme === undefined ? text : `${hexToAnsi(theme.toolOutput)}${text}${RESET}`;
43846
43859
  }
43847
43860
  function styledMuted(text, theme) {
43848
43861
  return theme === undefined ? text : `${hexToAnsi(theme.textMuted)}${text}${RESET}`;
43849
43862
  }
43863
+ function styledDim(text, theme) {
43864
+ return theme === undefined ? text : `${hexToAnsi(theme.dim)}${text}${RESET}`;
43865
+ }
43850
43866
  function styledError(text, theme) {
43851
43867
  return theme === undefined ? text : `${hexToAnsi(theme.error)}${text}${RESET}`;
43852
43868
  }
43869
+ function expandHintLine(earlier, expandKey, pad2, theme) {
43870
+ const lead = `... (${earlier} earlier lines`;
43871
+ if (expandKey.length === 0)
43872
+ return `${pad2}${styledMuted(`${lead})`, theme)}`;
43873
+ return `${pad2}${styledMuted(`${lead}, `, theme)}${styledDim(expandKey, theme)}${styledMuted(" Expand)", theme)}`;
43874
+ }
43853
43875
  function toolBackground(status, theme) {
43854
43876
  switch (status) {
43855
43877
  case "completed":
@@ -43993,70 +44015,70 @@ function paintRow(content, width, theme, status) {
43993
44015
  return `${background}${repainted}${background}${" ".repeat(padding)}${RESET}`;
43994
44016
  }
43995
44017
  function messageHeaderRows(tool, width, theme, expanded) {
43996
- const safeWidth = Math.max(1, Math.floor(width));
44018
+ const { pad: pad2, inner } = boxMetrics(width);
43997
44019
  const name = sanitizeToolTitleText(tool.name);
43998
44020
  const glyph = headerGlyph(tool.status);
43999
44021
  const glyphSuffix = glyph === undefined ? "" : ` ${glyph}`;
44000
44022
  const args = tool.args === undefined ? "" : boundedSerializedValue(tool.args, expanded ? TOOL_DETAIL_VALUE_LIMIT : COLLAPSED_ARGS_LIMIT);
44001
44023
  const withArgs = `$ ${name}${args ? ` ${args}` : ""}${glyphSuffix}`;
44002
- const plain = !expanded && visibleWidth(withArgs) > safeWidth ? `$ ${name}${glyphSuffix}` : withArgs;
44024
+ const plain = !expanded && visibleWidth(withArgs) > inner ? `$ ${name}${glyphSuffix}` : withArgs;
44003
44025
  const titleEnd = `$ ${name}`;
44004
- const continuationIndent = BODY_INDENT;
44005
- const continuationWidth = Math.max(1, safeWidth - visibleWidth(continuationIndent));
44006
- return wrapFieldValue(plain, safeWidth, continuationWidth).map((chunk, index, chunks) => {
44007
- const prefix = index === 0 ? "" : continuationIndent;
44026
+ return wrapFieldValue(plain, inner, inner).map((chunk, index, chunks) => {
44008
44027
  if (index !== 0 || !chunk.startsWith(titleEnd))
44009
- return `${prefix}${styledMuted(chunk, theme)}`;
44028
+ return `${pad2}${styledMuted(chunk, theme)}`;
44010
44029
  const remainder = chunk.slice(titleEnd.length);
44011
44030
  const hasStatus = glyphSuffix.length > 0 && index === chunks.length - 1 && remainder.endsWith(glyphSuffix);
44012
44031
  const summary = hasStatus ? remainder.slice(0, -glyphSuffix.length) : remainder;
44013
44032
  const suffix = hasStatus ? ` ${styledStatus(tool.status, theme)}` : "";
44014
- return `${styledTitle(titleEnd, theme)}${summary.trim().length > 0 ? styledMuted(summary, theme) : ""}${suffix}`;
44033
+ const title = styledTitle(titleEnd, theme);
44034
+ const rest = summary.trim().length > 0 ? styledMuted(summary, theme) : "";
44035
+ return `${pad2}${title}${rest}${suffix}`;
44015
44036
  });
44016
44037
  }
44017
44038
  function bodyRows(text, error, width, theme) {
44018
- const contentWidth = Math.max(1, width - visibleWidth(BODY_INDENT));
44019
- return wrapFieldValue(text, contentWidth).map((line) => {
44020
- const styled = error ? styledError(line, theme) : styledMuted(line, theme);
44021
- return `${BODY_INDENT}${styled}`;
44039
+ const { pad: pad2, inner } = boxMetrics(width);
44040
+ return wrapFieldValue(text, inner).map((line) => {
44041
+ const styled = error ? styledError(line, theme) : styledOutput(line, theme);
44042
+ return `${pad2}${styled}`;
44022
44043
  });
44023
44044
  }
44024
44045
  function footerText(tool, elapsed) {
44025
44046
  if (elapsed === undefined)
44026
44047
  return;
44027
44048
  const label = tool.status === "running" ? "Elapsed" : "Took";
44028
- const duration = elapsed < 1000 ? `${Math.round(elapsed)}ms` : fmtDuration(elapsed);
44029
44049
  const markers = [
44030
44050
  tool.status === "cached" ? "cached" : undefined,
44031
44051
  tool.replayed === true ? "replayed" : undefined
44032
44052
  ].filter((marker) => marker !== undefined);
44033
- return `${label} ${duration}${markers.length > 0 ? ` · ${markers.join(" · ")}` : ""}`;
44053
+ return `${label} ${fmtDuration(elapsed)}${markers.length > 0 ? ` · ${markers.join(" · ")}` : ""}`;
44034
44054
  }
44035
44055
  function renderMessageLines(tool, width, theme, expanded, expandKey, now) {
44036
44056
  const safeWidth = Math.max(1, Math.floor(width));
44057
+ const { pad: pad2, inner } = boxMetrics(safeWidth);
44037
44058
  const result = resultText(tool, TOOL_DETAIL_VALUE_LIMIT);
44038
44059
  const rows = messageHeaderRows(tool, safeWidth, theme, expanded);
44060
+ rows.push("");
44039
44061
  const wrappedBody = bodyRows(result.text, result.error, safeWidth, theme);
44040
44062
  if (expanded) {
44041
44063
  rows.push(...wrappedBody);
44042
44064
  if (tool.source !== undefined && tool.source.length > 0) {
44043
- const sourceWidth = Math.max(1, safeWidth - visibleWidth(BODY_INDENT));
44044
- rows.push(...wrapFieldValue(boundedToolText(sanitizeToolDisplayText(tool.source)), sourceWidth).map((line) => `${BODY_INDENT}${styledMuted(line, theme)}`));
44065
+ rows.push(...wrapFieldValue(boundedToolText(sanitizeToolDisplayText(tool.source)), inner).map((line) => `${pad2}${styledMuted(line, theme)}`));
44045
44066
  }
44046
44067
  } else {
44047
44068
  const earlier = Math.max(0, wrappedBody.length - COLLAPSED_RESULT_LINES);
44048
44069
  if (earlier > 0) {
44049
- const hint = expandKey.length > 0 ? `... (${earlier} earlier lines, ${expandKey} Expand)` : `... (${earlier} earlier lines)`;
44050
- rows.push(` ${styledMuted(hint, theme)}`);
44070
+ rows.push(expandHintLine(earlier, expandKey, pad2, theme));
44051
44071
  }
44052
44072
  rows.push(...wrappedBody.slice(-COLLAPSED_RESULT_LINES));
44053
44073
  }
44054
44074
  const footer = footerText(tool, durationMs(tool, now));
44055
44075
  if (footer !== undefined) {
44056
44076
  rows.push("");
44057
- rows.push(styledMuted(footer, theme));
44077
+ rows.push(`${pad2}${styledMuted(footer, theme)}`);
44058
44078
  }
44059
- return rows.map((row) => paintRow(row, safeWidth, theme, tool.status));
44079
+ const painted = rows.map((row) => paintRow(row, safeWidth, theme, tool.status));
44080
+ const padRow = paintRow("", safeWidth, theme, tool.status);
44081
+ return [padRow, ...painted, padRow];
44060
44082
  }
44061
44083
  function renderToolDetail(tool, opts = {}) {
44062
44084
  const width = Math.max(1, Math.floor(opts.width ?? 80));
@@ -44078,6 +44100,10 @@ function stripLayoutWrapper(line) {
44078
44100
  const end = endsWithWrapper ? line.length - LAYOUT_OSC_RESET.length : line.length;
44079
44101
  return line.slice(start, Math.max(start, end));
44080
44102
  }
44103
+ var TOOL_DETAIL_VIEWPORT_PAD = 1;
44104
+ function toolDetailViewportPad(rows) {
44105
+ return rows >= TOOL_DETAIL_VIEWPORT_PAD * 2 + 1 ? TOOL_DETAIL_VIEWPORT_PAD : 0;
44106
+ }
44081
44107
 
44082
44108
  class GraphViewRenderer extends GraphViewGraphRenderer {
44083
44109
  graphLayout;
@@ -44169,14 +44195,13 @@ class GraphViewRenderer extends GraphViewGraphRenderer {
44169
44195
  const cached = this.detailFrame;
44170
44196
  if (cached !== null && cached.width === safeWidth)
44171
44197
  return cached;
44172
- const blockWidth = Math.max(1, safeWidth - 1);
44173
44198
  const frame = {
44174
44199
  width: safeWidth,
44175
- blockWidth,
44176
- leftPad: safeWidth > 1 ? 1 : 0,
44200
+ blockWidth: safeWidth,
44201
+ leftPad: 0,
44177
44202
  lines: renderToolDetailLines(node, {
44178
44203
  theme: this.graphTheme,
44179
- width: blockWidth,
44204
+ width: safeWidth,
44180
44205
  expanded: this.toolDetailExpanded,
44181
44206
  expandKey: toolExpandKey(isKeybindingsLike(this.piKeybindings) ? this.piKeybindings : undefined)
44182
44207
  })
@@ -44187,7 +44212,8 @@ class GraphViewRenderer extends GraphViewGraphRenderer {
44187
44212
  _graphBodyContentHeight(width, viewportRows) {
44188
44213
  const rows = Math.max(0, Math.floor(viewportRows));
44189
44214
  if (this.toolDetail !== null) {
44190
- return Math.max(rows, this._toolDetailFrame(this.toolDetail, width).lines.length);
44215
+ const pad2 = toolDetailViewportPad(rows);
44216
+ return Math.max(rows, this._toolDetailFrame(this.toolDetail, width).lines.length + pad2 * 2);
44191
44217
  }
44192
44218
  if (!this._currentRenderRun() || this.cachedRenderGeometry.totalRows <= 0)
44193
44219
  return rows;
@@ -44245,7 +44271,10 @@ class GraphViewRenderer extends GraphViewGraphRenderer {
44245
44271
  _renderBody(width, top, rows, _contentRows) {
44246
44272
  if (this.toolDetail !== null) {
44247
44273
  const detail = this._toolDetailFrame(this.toolDetail, width);
44248
- return detail.lines.slice(top, top + rows).map((line) => this._canvasRow(`${" ".repeat(detail.leftPad)}${line}`, width));
44274
+ const pad2 = toolDetailViewportPad(rows);
44275
+ const inner = Math.max(0, rows - pad2 * 2);
44276
+ const painted = detail.lines.slice(top, top + inner).map((line) => this._canvasRow(`${" ".repeat(detail.leftPad)}${line}`, width));
44277
+ return [...Array.from({ length: pad2 }, () => ""), ...painted, ...Array.from({ length: pad2 }, () => "")];
44249
44278
  }
44250
44279
  const run = this._currentRenderRun();
44251
44280
  const raw = run ? this._renderGraph(width, top, rows, run) : this._renderEmptyBody(width, rows);
@@ -31,6 +31,7 @@ const MOCHA = {
31
31
  red: "#f38ba8",
32
32
  mauve: "#cba6f7",
33
33
  sky: "#89dceb",
34
+ lavender: "#b4befe",
34
35
  } as const;
35
36
 
36
37
  /**
@@ -51,6 +52,8 @@ export interface GenericTheme {
51
52
  text?: string;
52
53
  textMuted?: string;
53
54
  dim?: string;
55
+ toolTitle?: string;
56
+ toolOutput?: string;
54
57
  accent?: string;
55
58
  mauve?: string;
56
59
  success?: string;
@@ -87,6 +90,10 @@ export interface GraphTheme {
87
90
  text: string;
88
91
  textMuted: string;
89
92
  dim: string;
93
+ /** Host tool-card title (`toolTitle`, Catppuccin lavender). */
94
+ toolTitle: string;
95
+ /** Host tool-card body (`toolOutput`, usually the same as `text`). */
96
+ toolOutput: string;
90
97
 
91
98
  /** Accents */
92
99
  accent: string;
@@ -116,6 +123,8 @@ export function deriveGraphTheme(theme: GenericTheme = {}): GraphTheme {
116
123
  text: theme.text ?? MOCHA.text,
117
124
  textMuted: theme.textMuted ?? MOCHA.subtext0,
118
125
  dim: theme.dim ?? MOCHA.overlay1,
126
+ toolTitle: theme.toolTitle ?? MOCHA.lavender,
127
+ toolOutput: theme.toolOutput ?? MOCHA.text,
119
128
 
120
129
  accent: theme.accent ?? MOCHA.blue,
121
130
  mauve: theme.mauve ?? MOCHA.mauve,
@@ -262,6 +271,8 @@ export function deriveGraphThemeFromPiTheme(theme: unknown): GraphTheme {
262
271
  text: fgHex(t, "text") ?? fgHex(t, "customMessageText"),
263
272
  textMuted: fgHex(t, "muted"),
264
273
  dim: fgHex(t, "dim"),
274
+ toolTitle: fgHex(t, "toolTitle"),
275
+ toolOutput: fgHex(t, "toolOutput") ?? fgHex(t, "text") ?? fgHex(t, "customMessageText"),
265
276
 
266
277
  accent,
267
278
  success: fgHex(t, "success"),
@@ -26,6 +26,13 @@ function stripLayoutWrapper(line: string): string {
26
26
  const end = endsWithWrapper ? line.length - LAYOUT_OSC_RESET.length : line.length;
27
27
  return line.slice(start, Math.max(start, end));
28
28
  }
29
+ /** Unpainted canvas between the orchestrator bars and the painted card. */
30
+ const TOOL_DETAIL_VIEWPORT_PAD = 1;
31
+
32
+ function toolDetailViewportPad(rows: number): number {
33
+ return rows >= TOOL_DETAIL_VIEWPORT_PAD * 2 + 1 ? TOOL_DETAIL_VIEWPORT_PAD : 0;
34
+ }
35
+
29
36
  /** One frame's tool-message geometry, derived once and shared by both callbacks. */
30
37
  interface ToolDetailFrame {
31
38
  readonly width: number;
@@ -159,17 +166,15 @@ export abstract class GraphViewRenderer extends GraphViewGraphRenderer {
159
166
  const safeWidth = Math.max(1, Math.floor(width));
160
167
  const cached = this.detailFrame;
161
168
  if (cached !== null && cached.width === safeWidth) return cached;
162
- // Match the flat message surface used by agent tool calls. The one-cell
163
- // leading inset gives the block the same breathing room without drawing a
164
- // standalone rounded panel or hiding content behind a border.
165
- const blockWidth = Math.max(1, safeWidth - 1);
169
+ // Span the same columns as the header/footer bars. Main-chat tool
170
+ // blocks are full width; only the Box(1,1) pad insets the `$` text.
166
171
  const frame: ToolDetailFrame = {
167
172
  width: safeWidth,
168
- blockWidth,
169
- leftPad: safeWidth > 1 ? 1 : 0,
173
+ blockWidth: safeWidth,
174
+ leftPad: 0,
170
175
  lines: renderToolDetailLines(node, {
171
176
  theme: this.graphTheme,
172
- width: blockWidth,
177
+ width: safeWidth,
173
178
  expanded: this.toolDetailExpanded,
174
179
  expandKey: toolExpandKey(isKeybindingsLike(this.piKeybindings) ? this.piKeybindings : undefined),
175
180
  }),
@@ -181,7 +186,8 @@ export abstract class GraphViewRenderer extends GraphViewGraphRenderer {
181
186
  private _graphBodyContentHeight(width: number, viewportRows: number): number {
182
187
  const rows = Math.max(0, Math.floor(viewportRows));
183
188
  if (this.toolDetail !== null) {
184
- return Math.max(rows, this._toolDetailFrame(this.toolDetail, width).lines.length);
189
+ const pad = toolDetailViewportPad(rows);
190
+ return Math.max(rows, this._toolDetailFrame(this.toolDetail, width).lines.length + pad * 2);
185
191
  }
186
192
  if (!this._currentRenderRun() || this.cachedRenderGeometry.totalRows <= 0) return rows;
187
193
  return Math.max(rows, this.cachedRenderGeometry.totalRows);
@@ -249,9 +255,12 @@ export abstract class GraphViewRenderer extends GraphViewGraphRenderer {
249
255
  private _renderBody(width: number, top: number, rows: number, _contentRows: number): string[] {
250
256
  if (this.toolDetail !== null) {
251
257
  const detail = this._toolDetailFrame(this.toolDetail, width);
252
- return detail.lines
253
- .slice(top, top + rows)
258
+ const pad = toolDetailViewportPad(rows);
259
+ const inner = Math.max(0, rows - pad * 2);
260
+ const painted = detail.lines
261
+ .slice(top, top + inner)
254
262
  .map((line) => this._canvasRow(`${" ".repeat(detail.leftPad)}${line}`, width));
263
+ return [...Array.from({ length: pad }, () => ""), ...painted, ...Array.from({ length: pad }, () => "")];
255
264
  }
256
265
  const run = this._currentRenderRun();
257
266
  const raw = run ? this._renderGraph(width, top, rows, run) : this._renderEmptyBody(width, rows);
@@ -19,7 +19,14 @@ export const TOOL_DETAIL_VALUE_LIMIT = TOOL_PAYLOAD_VALUE_LIMIT;
19
19
  const COLLAPSED_ARGS_LIMIT = 240;
20
20
  /** Match the host tool renderer's collapsed fallback preview row bound. */
21
21
  const COLLAPSED_RESULT_LINES = 10;
22
- const BODY_INDENT = " ";
22
+ /** Match pi-tui `Box(paddingX=1, paddingY=1)` around the host tool card. */
23
+ const BOX_PAD = 1;
24
+
25
+ function boxMetrics(width: number): { pad: string; inner: number } {
26
+ const safeWidth = Math.max(1, Math.floor(width));
27
+ const inset = safeWidth >= BOX_PAD * 2 + 1 ? BOX_PAD : 0;
28
+ return { pad: " ".repeat(inset), inner: Math.max(1, safeWidth - inset * 2) };
29
+ }
23
30
 
24
31
  export interface RenderToolDetailOpts {
25
32
  /** Provide for ANSI output; omit for plain text. */
@@ -79,17 +86,31 @@ function styledStatus(status: ToolNodeStatus, theme: GraphTheme | undefined): st
79
86
  }
80
87
 
81
88
  function styledTitle(text: string, theme: GraphTheme | undefined): string {
82
- return theme === undefined ? text : `${hexToAnsi(theme.text)}${BOLD}${text}${RESET}`;
89
+ return theme === undefined ? text : `${hexToAnsi(theme.toolTitle)}${BOLD}${text}${RESET}`;
90
+ }
91
+
92
+ function styledOutput(text: string, theme: GraphTheme | undefined): string {
93
+ return theme === undefined ? text : `${hexToAnsi(theme.toolOutput)}${text}${RESET}`;
83
94
  }
84
95
 
85
96
  function styledMuted(text: string, theme: GraphTheme | undefined): string {
86
97
  return theme === undefined ? text : `${hexToAnsi(theme.textMuted)}${text}${RESET}`;
87
98
  }
88
99
 
100
+ function styledDim(text: string, theme: GraphTheme | undefined): string {
101
+ return theme === undefined ? text : `${hexToAnsi(theme.dim)}${text}${RESET}`;
102
+ }
103
+
89
104
  function styledError(text: string, theme: GraphTheme | undefined): string {
90
105
  return theme === undefined ? text : `${hexToAnsi(theme.error)}${text}${RESET}`;
91
106
  }
92
107
 
108
+ function expandHintLine(earlier: number, expandKey: string, pad: string, theme: GraphTheme | undefined): string {
109
+ const lead = `... (${earlier} earlier lines`;
110
+ if (expandKey.length === 0) return `${pad}${styledMuted(`${lead})`, theme)}`;
111
+ return `${pad}${styledMuted(`${lead}, `, theme)}${styledDim(expandKey, theme)}${styledMuted(" Expand)", theme)}`;
112
+ }
113
+
93
114
  function toolBackground(status: ToolNodeStatus, theme: GraphTheme): string {
94
115
  switch (status) {
95
116
  case "completed":
@@ -249,7 +270,7 @@ function messageHeaderRows(
249
270
  theme: GraphTheme | undefined,
250
271
  expanded: boolean,
251
272
  ): string[] {
252
- const safeWidth = Math.max(1, Math.floor(width));
273
+ const { pad, inner } = boxMetrics(width);
253
274
  const name = sanitizeToolTitleText(tool.name);
254
275
  const glyph = headerGlyph(tool.status);
255
276
  const glyphSuffix = glyph === undefined ? "" : ` ${glyph}`;
@@ -258,38 +279,36 @@ function messageHeaderRows(
258
279
  ? ""
259
280
  : boundedSerializedValue(tool.args, expanded ? TOOL_DETAIL_VALUE_LIMIT : COLLAPSED_ARGS_LIMIT);
260
281
  const withArgs = `$ ${name}${args ? ` ${args}` : ""}${glyphSuffix}`;
261
- const plain = !expanded && visibleWidth(withArgs) > safeWidth ? `$ ${name}${glyphSuffix}` : withArgs;
282
+ const plain = !expanded && visibleWidth(withArgs) > inner ? `$ ${name}${glyphSuffix}` : withArgs;
262
283
  const titleEnd = `$ ${name}`;
263
- const continuationIndent = BODY_INDENT;
264
- const continuationWidth = Math.max(1, safeWidth - visibleWidth(continuationIndent));
265
- return wrapFieldValue(plain, safeWidth, continuationWidth).map((chunk, index, chunks) => {
266
- const prefix = index === 0 ? "" : continuationIndent;
267
- if (index !== 0 || !chunk.startsWith(titleEnd)) return `${prefix}${styledMuted(chunk, theme)}`;
284
+ return wrapFieldValue(plain, inner, inner).map((chunk, index, chunks) => {
285
+ if (index !== 0 || !chunk.startsWith(titleEnd)) return `${pad}${styledMuted(chunk, theme)}`;
268
286
  const remainder = chunk.slice(titleEnd.length);
269
287
  const hasStatus = glyphSuffix.length > 0 && index === chunks.length - 1 && remainder.endsWith(glyphSuffix);
270
288
  const summary = hasStatus ? remainder.slice(0, -glyphSuffix.length) : remainder;
271
289
  const suffix = hasStatus ? ` ${styledStatus(tool.status, theme)}` : "";
272
- return `${styledTitle(titleEnd, theme)}${summary.trim().length > 0 ? styledMuted(summary, theme) : ""}${suffix}`;
290
+ const title = styledTitle(titleEnd, theme);
291
+ const rest = summary.trim().length > 0 ? styledMuted(summary, theme) : "";
292
+ return `${pad}${title}${rest}${suffix}`;
273
293
  });
274
294
  }
275
295
 
276
296
  function bodyRows(text: string, error: boolean, width: number, theme: GraphTheme | undefined): string[] {
277
- const contentWidth = Math.max(1, width - visibleWidth(BODY_INDENT));
278
- return wrapFieldValue(text, contentWidth).map((line) => {
279
- const styled = error ? styledError(line, theme) : styledMuted(line, theme);
280
- return `${BODY_INDENT}${styled}`;
297
+ const { pad, inner } = boxMetrics(width);
298
+ return wrapFieldValue(text, inner).map((line) => {
299
+ const styled = error ? styledError(line, theme) : styledOutput(line, theme);
300
+ return `${pad}${styled}`;
281
301
  });
282
302
  }
283
303
 
284
304
  function footerText(tool: ToolNodeSnapshot, elapsed: number | undefined): string | undefined {
285
305
  if (elapsed === undefined) return undefined;
286
306
  const label = tool.status === "running" ? "Elapsed" : "Took";
287
- const duration = elapsed < 1000 ? `${Math.round(elapsed)}ms` : fmtDuration(elapsed);
288
307
  const markers = [
289
308
  tool.status === "cached" ? "cached" : undefined,
290
309
  tool.replayed === true ? "replayed" : undefined,
291
310
  ].filter((marker): marker is string => marker !== undefined);
292
- return `${label} ${duration}${markers.length > 0 ? ` · ${markers.join(" · ")}` : ""}`;
311
+ return `${label} ${fmtDuration(elapsed)}${markers.length > 0 ? ` · ${markers.join(" · ")}` : ""}`;
293
312
  }
294
313
 
295
314
  function renderMessageLines(
@@ -301,36 +320,36 @@ function renderMessageLines(
301
320
  now: number,
302
321
  ): string[] {
303
322
  const safeWidth = Math.max(1, Math.floor(width));
323
+ const { pad, inner } = boxMetrics(safeWidth);
304
324
  const result = resultText(tool, TOOL_DETAIL_VALUE_LIMIT);
305
325
  const rows: string[] = messageHeaderRows(tool, safeWidth, theme, expanded);
326
+ // Host bash/result renderers put a blank row between the `$` call and the body.
327
+ rows.push("");
306
328
  const wrappedBody = bodyRows(result.text, result.error, safeWidth, theme);
307
329
  if (expanded) {
308
330
  rows.push(...wrappedBody);
309
331
  if (tool.source !== undefined && tool.source.length > 0) {
310
- const sourceWidth = Math.max(1, safeWidth - visibleWidth(BODY_INDENT));
311
332
  rows.push(
312
- ...wrapFieldValue(boundedToolText(sanitizeToolDisplayText(tool.source)), sourceWidth).map(
313
- (line) => `${BODY_INDENT}${styledMuted(line, theme)}`,
333
+ ...wrapFieldValue(boundedToolText(sanitizeToolDisplayText(tool.source)), inner).map(
334
+ (line) => `${pad}${styledMuted(line, theme)}`,
314
335
  ),
315
336
  );
316
337
  }
317
338
  } else {
318
339
  const earlier = Math.max(0, wrappedBody.length - COLLAPSED_RESULT_LINES);
319
340
  if (earlier > 0) {
320
- const hint =
321
- expandKey.length > 0
322
- ? `... (${earlier} earlier lines, ${expandKey} Expand)`
323
- : `... (${earlier} earlier lines)`;
324
- rows.push(` ${styledMuted(hint, theme)}`);
341
+ rows.push(expandHintLine(earlier, expandKey, pad, theme));
325
342
  }
326
343
  rows.push(...wrappedBody.slice(-COLLAPSED_RESULT_LINES));
327
344
  }
328
345
  const footer = footerText(tool, durationMs(tool, now));
329
346
  if (footer !== undefined) {
330
347
  rows.push("");
331
- rows.push(styledMuted(footer, theme));
348
+ rows.push(`${pad}${styledMuted(footer, theme)}`);
332
349
  }
333
- return rows.map((row) => paintRow(row, safeWidth, theme, tool.status));
350
+ const painted = rows.map((row) => paintRow(row, safeWidth, theme, tool.status));
351
+ const padRow = paintRow("", safeWidth, theme, tool.status);
352
+ return [padRow, ...painted, padRow];
334
353
  }
335
354
 
336
355
  /** Render one read-only host-style operator tool message block. */
package/docs/workflows.md CHANGED
@@ -3314,7 +3314,7 @@ Repeated, sibling, sequential, parallel, and multi-level child calls keep indepe
3314
3314
 
3315
3315
  ### `ctx.tool` — durable cached tool execution
3316
3316
 
3317
- The `ctx.tool(name, args, fn, options?)` primitive runs arbitrary TypeScript code as a first-class durable graph node and caches the result durably. The node is non-attachable and has no stage chat controls, and its graph card body is the constant `durable tool` in every state — status, timing, and dependency rows keep their own rows, and the card does not preview the result or error. In the graph viewer, focusing the node and pressing Enter, clicking it, or choosing it from the switcher opens a read-only host-style operator card from the snapshot: a status-tinted shaded rectangle with a `$ <tool-name>` call header, an optional short argument summary, and the result or error as its body. It is collapsed by default and wraps the fully bounded result or error before showing its last visual rows, with `... (N earlier lines, ctrl+o Expand)` above the tail when the action is bound; the configured `app.tools.expand` action (`ctrl+o` by default) toggles the full bounded result or error and then a muted callback-source block when source exists. The graph statusline advertises the resolved expand key with `expand` or `collapse` alongside return-to-graph and scroll hints, including remapped keys, and omits that segment entirely when the action is unbound. The footer says `Took` for settled calls or `Elapsed` for running calls, using milliseconds below one second and the existing human duration above it, with cached/replayed markers kept as a quiet suffix. The operator surface has no ARGS/RESULT/SOURCE/TIMING/MARKERS debug table and does not expose raw clock fields. Source capture uses `fn.toString()` at registration without re-executing the callback or reading a file. `↑`/`↓`, `PageUp`/`PageDown`, `Home`/`End`, the wheel, and the scrollbar all scroll the block, so a long payload stays readable on a keyboard-only session or a terminal without mouse reporting; Escape or `ctrl+x` returns to the graph. The message block is read-only and never offers chat attachment, steering, interrupt, or resume. Bounded payloads remain width-safe and mark truncation explicitly with `… [truncated]`; source tabs expand and control bytes become `\xNN`, while cyclic payloads, throwing `toJSON`, or throwing property getters render `<cycle>`, `<unserializable>`, or `<unreadable>` instead of crashing the view. The same cap applies to what the live run snapshot retains for a tool node, while durable checkpoints keep the exact output, raw-args `argsHash`, and replay behavior unchanged.
3317
+ The `ctx.tool(name, args, fn, options?)` primitive runs arbitrary TypeScript code as a first-class durable graph node and caches the result durably. The node is non-attachable and has no stage chat controls, and its graph card body is the constant `durable tool` in every state — status, timing, and dependency rows keep their own rows, and the card does not preview the result or error. In the graph viewer, focusing the node and pressing Enter, clicking it, or choosing it from the switcher opens a read-only host-style operator card from the snapshot: a status-tinted shaded rectangle with the same inner padding and header/body gap as the main-chat tool block, inset from the orchestrator header and footer bars, a `$ <tool-name>` call header, an optional short argument summary, and the result or error as its body. It is collapsed by default and wraps the fully bounded result or error before showing its last visual rows, with `... (N earlier lines, ctrl+o Expand)` above the tail when the action is bound; the configured `app.tools.expand` action (`ctrl+o` by default) toggles the full bounded result or error and then a muted callback-source block when source exists. The graph statusline advertises the resolved expand key with `expand` or `collapse` alongside return-to-graph and scroll hints, including remapped keys, and omits that segment entirely when the action is unbound. The footer says `Took` for settled calls or `Elapsed` for running calls, using the same second-resolution duration as the main-chat tool block, with cached/replayed markers kept as a quiet suffix. The operator surface has no ARGS/RESULT/SOURCE/TIMING/MARKERS debug table and does not expose raw clock fields. Source capture uses `fn.toString()` at registration without re-executing the callback or reading a file. `↑`/`↓`, `PageUp`/`PageDown`, `Home`/`End`, the wheel, and the scrollbar all scroll the block, so a long payload stays readable on a keyboard-only session or a terminal without mouse reporting; Escape or `ctrl+x` returns to the graph. The message block is read-only and never offers chat attachment, steering, interrupt, or resume. Bounded payloads remain width-safe and mark truncation explicitly with `… [truncated]`; source tabs expand and control bytes become `\xNN`, while cyclic payloads, throwing `toJSON`, or throwing property getters render `<cycle>`, `<unserializable>`, or `<unreadable>` instead of crashing the view. The same cap applies to what the live run snapshot retains for a tool node, while durable checkpoints keep the exact output, raw-args `argsHash`, and replay behavior unchanged.
3318
3318
 
3319
3319
  When the workflow body fulfills but one or more admitted tool calls failed, Atomic promotes the first observed failure to the terminal run failure, regardless of admission order, and persists that selected tool-node identity for status inspection and lifecycle output. A direct uncaught `await ctx.tool(...)` rejection keeps the original error and persists its failed-node link through session and durable restore. First-event arbitration also preserves the selected node when concurrent failures throw the same object or primitive; unrelated later stage or body errors do not inherit a caught tool's origin. Tool admission remains open while author code can catch a failure and continue. Once the body settles and failure has won before any real cancellation, Atomic closes admission, cancels remaining non-failed tool nodes, waits for observed failed nodes to finish publication, and publishes the failed root without waiting for callbacks that ignore cancellation.
3320
3320
 
@@ -1,15 +1,15 @@
1
1
  {
2
2
  "name": "@bastani/atomic",
3
- "version": "0.9.14-alpha.3",
3
+ "version": "0.9.14-alpha.4",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bastani/atomic",
9
- "version": "0.9.14-alpha.3",
9
+ "version": "0.9.14-alpha.4",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
- "@bastani/atomic-natives": "0.9.14-alpha.3",
12
+ "@bastani/atomic-natives": "0.9.14-alpha.4",
13
13
  "@dbos-inc/dbos-sdk": "4.25.14",
14
14
  "@earendil-works/pi-agent-core": "^0.84.2",
15
15
  "@earendil-works/pi-ai": "^0.84.2",
@@ -517,18 +517,18 @@
517
517
  }
518
518
  },
519
519
  "node_modules/@bastani/atomic-natives": {
520
- "version": "0.9.14-alpha.3",
521
- "resolved": "https://registry.npmjs.org/@bastani/atomic-natives/-/atomic-natives-0.9.14-alpha.3.tgz",
520
+ "version": "0.9.14-alpha.4",
521
+ "resolved": "https://registry.npmjs.org/@bastani/atomic-natives/-/atomic-natives-0.9.14-alpha.4.tgz",
522
522
  "license": "MIT",
523
523
  "optionalDependencies": {
524
- "@bastani/atomic-natives-darwin-arm64": "0.9.14-alpha.3",
525
- "@bastani/atomic-natives-darwin-x64": "0.9.14-alpha.3",
526
- "@bastani/atomic-natives-linux-arm64-gnu": "0.9.14-alpha.3",
527
- "@bastani/atomic-natives-linux-arm64-musl": "0.9.14-alpha.3",
528
- "@bastani/atomic-natives-linux-x64-gnu": "0.9.14-alpha.3",
529
- "@bastani/atomic-natives-linux-x64-musl": "0.9.14-alpha.3",
530
- "@bastani/atomic-natives-win32-arm64-msvc": "0.9.14-alpha.3",
531
- "@bastani/atomic-natives-win32-x64-msvc": "0.9.14-alpha.3"
524
+ "@bastani/atomic-natives-darwin-arm64": "0.9.14-alpha.4",
525
+ "@bastani/atomic-natives-darwin-x64": "0.9.14-alpha.4",
526
+ "@bastani/atomic-natives-linux-arm64-gnu": "0.9.14-alpha.4",
527
+ "@bastani/atomic-natives-linux-arm64-musl": "0.9.14-alpha.4",
528
+ "@bastani/atomic-natives-linux-x64-gnu": "0.9.14-alpha.4",
529
+ "@bastani/atomic-natives-linux-x64-musl": "0.9.14-alpha.4",
530
+ "@bastani/atomic-natives-win32-arm64-msvc": "0.9.14-alpha.4",
531
+ "@bastani/atomic-natives-win32-x64-msvc": "0.9.14-alpha.4"
532
532
  },
533
533
  "engines": {
534
534
  "bun": ">=1.3.14",
@@ -536,8 +536,8 @@
536
536
  }
537
537
  },
538
538
  "node_modules/@bastani/atomic-natives-darwin-arm64": {
539
- "version": "0.9.14-alpha.3",
540
- "resolved": "https://registry.npmjs.org/@bastani/atomic-natives-darwin-arm64/-/atomic-natives-darwin-arm64-0.9.14-alpha.3.tgz",
539
+ "version": "0.9.14-alpha.4",
540
+ "resolved": "https://registry.npmjs.org/@bastani/atomic-natives-darwin-arm64/-/atomic-natives-darwin-arm64-0.9.14-alpha.4.tgz",
541
541
  "license": "MIT",
542
542
  "os": [
543
543
  "darwin"
@@ -548,8 +548,8 @@
548
548
  "optional": true
549
549
  },
550
550
  "node_modules/@bastani/atomic-natives-darwin-x64": {
551
- "version": "0.9.14-alpha.3",
552
- "resolved": "https://registry.npmjs.org/@bastani/atomic-natives-darwin-x64/-/atomic-natives-darwin-x64-0.9.14-alpha.3.tgz",
551
+ "version": "0.9.14-alpha.4",
552
+ "resolved": "https://registry.npmjs.org/@bastani/atomic-natives-darwin-x64/-/atomic-natives-darwin-x64-0.9.14-alpha.4.tgz",
553
553
  "license": "MIT",
554
554
  "os": [
555
555
  "darwin"
@@ -560,8 +560,8 @@
560
560
  "optional": true
561
561
  },
562
562
  "node_modules/@bastani/atomic-natives-linux-arm64-gnu": {
563
- "version": "0.9.14-alpha.3",
564
- "resolved": "https://registry.npmjs.org/@bastani/atomic-natives-linux-arm64-gnu/-/atomic-natives-linux-arm64-gnu-0.9.14-alpha.3.tgz",
563
+ "version": "0.9.14-alpha.4",
564
+ "resolved": "https://registry.npmjs.org/@bastani/atomic-natives-linux-arm64-gnu/-/atomic-natives-linux-arm64-gnu-0.9.14-alpha.4.tgz",
565
565
  "license": "MIT",
566
566
  "os": [
567
567
  "linux"
@@ -575,8 +575,8 @@
575
575
  "optional": true
576
576
  },
577
577
  "node_modules/@bastani/atomic-natives-linux-arm64-musl": {
578
- "version": "0.9.14-alpha.3",
579
- "resolved": "https://registry.npmjs.org/@bastani/atomic-natives-linux-arm64-musl/-/atomic-natives-linux-arm64-musl-0.9.14-alpha.3.tgz",
578
+ "version": "0.9.14-alpha.4",
579
+ "resolved": "https://registry.npmjs.org/@bastani/atomic-natives-linux-arm64-musl/-/atomic-natives-linux-arm64-musl-0.9.14-alpha.4.tgz",
580
580
  "license": "MIT",
581
581
  "os": [
582
582
  "linux"
@@ -590,8 +590,8 @@
590
590
  "optional": true
591
591
  },
592
592
  "node_modules/@bastani/atomic-natives-linux-x64-gnu": {
593
- "version": "0.9.14-alpha.3",
594
- "resolved": "https://registry.npmjs.org/@bastani/atomic-natives-linux-x64-gnu/-/atomic-natives-linux-x64-gnu-0.9.14-alpha.3.tgz",
593
+ "version": "0.9.14-alpha.4",
594
+ "resolved": "https://registry.npmjs.org/@bastani/atomic-natives-linux-x64-gnu/-/atomic-natives-linux-x64-gnu-0.9.14-alpha.4.tgz",
595
595
  "license": "MIT",
596
596
  "os": [
597
597
  "linux"
@@ -605,8 +605,8 @@
605
605
  "optional": true
606
606
  },
607
607
  "node_modules/@bastani/atomic-natives-linux-x64-musl": {
608
- "version": "0.9.14-alpha.3",
609
- "resolved": "https://registry.npmjs.org/@bastani/atomic-natives-linux-x64-musl/-/atomic-natives-linux-x64-musl-0.9.14-alpha.3.tgz",
608
+ "version": "0.9.14-alpha.4",
609
+ "resolved": "https://registry.npmjs.org/@bastani/atomic-natives-linux-x64-musl/-/atomic-natives-linux-x64-musl-0.9.14-alpha.4.tgz",
610
610
  "license": "MIT",
611
611
  "os": [
612
612
  "linux"
@@ -620,8 +620,8 @@
620
620
  "optional": true
621
621
  },
622
622
  "node_modules/@bastani/atomic-natives-win32-arm64-msvc": {
623
- "version": "0.9.14-alpha.3",
624
- "resolved": "https://registry.npmjs.org/@bastani/atomic-natives-win32-arm64-msvc/-/atomic-natives-win32-arm64-msvc-0.9.14-alpha.3.tgz",
623
+ "version": "0.9.14-alpha.4",
624
+ "resolved": "https://registry.npmjs.org/@bastani/atomic-natives-win32-arm64-msvc/-/atomic-natives-win32-arm64-msvc-0.9.14-alpha.4.tgz",
625
625
  "license": "MIT",
626
626
  "os": [
627
627
  "win32"
@@ -632,8 +632,8 @@
632
632
  "optional": true
633
633
  },
634
634
  "node_modules/@bastani/atomic-natives-win32-x64-msvc": {
635
- "version": "0.9.14-alpha.3",
636
- "resolved": "https://registry.npmjs.org/@bastani/atomic-natives-win32-x64-msvc/-/atomic-natives-win32-x64-msvc-0.9.14-alpha.3.tgz",
635
+ "version": "0.9.14-alpha.4",
636
+ "resolved": "https://registry.npmjs.org/@bastani/atomic-natives-win32-x64-msvc/-/atomic-natives-win32-x64-msvc-0.9.14-alpha.4.tgz",
637
637
  "license": "MIT",
638
638
  "os": [
639
639
  "win32"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bastani/atomic",
3
- "version": "0.9.14-alpha.3",
3
+ "version": "0.9.14-alpha.4",
4
4
  "description": "Atomic coding agent CLI with read, bash, edit, write tools and session management",
5
5
  "type": "module",
6
6
  "atomicConfig": {
@@ -83,7 +83,7 @@
83
83
  "prepublishOnly": "bun run clean && bun run build && bun run shrinkwrap"
84
84
  },
85
85
  "dependencies": {
86
- "@bastani/atomic-natives": "0.9.14-alpha.3",
86
+ "@bastani/atomic-natives": "0.9.14-alpha.4",
87
87
  "@dbos-inc/dbos-sdk": "4.25.14",
88
88
  "@earendil-works/pi-agent-core": "^0.84.2",
89
89
  "@earendil-works/pi-ai": "^0.84.2",