@jaybeeuu/agent-cortex 2.3.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.4.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 0b72348: The extensions now load outside this checkout. `auto-name`, `notify`, `subagent` and `agent-modes`
8
+ value-imported `@earendil-works/pi-*`, which resolved only inside the repository, where pnpm had
9
+ installed each extension's own copy of pi's packages. The staged pi package carried no such
10
+ `node_modules`, so after `agent-cortex install pi` those four extensions failed to load for every
11
+ project outside this checkout — `subagent` included, which is the dispatcher ralph uses.
12
+
13
+ The runtime coupling is gone: frontmatter parsing, pi's user agent directory, the key helper and the
14
+ transcript components are now local, and the extensions declare no peer dependency on pi's packages. Two
15
+ callers lose their model-backed behaviour, since an extension has no completion API of its own:
16
+ `auto-name` names a session from its first prompt truncated (the fallback it already had) and
17
+ `notify` summarises with that same truncation. Subagent output renders as plain text instead of
18
+ markdown.
19
+
20
+ One bare runtime import remains: `typebox`. pi documents it as a host-provided module for
21
+ extensions and aliases it in its loader, so it resolves in the staged package. Everything imported
22
+ from `@earendil-works/*` is `import type`, which is erased. `test/pi-extensions-load.test.mjs`
23
+ stages the package and loads every extension from a temp directory with pi absent. A static scan in
24
+ the same file then rejects any value import from `@earendil-works/*` — including an unused one, which
25
+ tsx elides before the loader sees it.
26
+
3
27
  ## 2.3.0
4
28
 
5
29
  ### Minor Changes
package/README.md CHANGED
@@ -201,7 +201,7 @@ The staged package contains:
201
201
  | `package.json` | pi manifest — `keywords: ["pi-package"]`, `pi.extensions` + `pi.skills`; the version tracks the package |
202
202
  | `agents/` | composed flat agents (ralph, plan, ralph-plan, strategy) with `{{TOOL:...}}` / `{{PATH:...}}` substituted — the shipped `agent-modes` extension resolves them from the package root |
203
203
  | `skills/` | token-substituted skill copies (real files, never symlinks) |
204
- | `extensions/` | copy of the repo `extensions/` tree |
204
+ | `extensions/` | copy of the repo `extensions/` tree — imports nothing from pi's runtime except the host-aliased `typebox`, so pi loads it from any directory |
205
205
  | `token-map.json` | the token map `agent-modes` reads from the package root |
206
206
  | `node_modules/` | the required third-party pi packages and their transitive closure |
207
207
 
@@ -405,10 +405,13 @@ async function installSkills({ root, skillsDir, dryRun, pluginRoot, tokenMap, wa
405
405
  /**
406
406
  * Copy the repo's extensions/ tree into the staged package verbatim.
407
407
  *
408
- * The extensions import `@earendil-works/*`, which pi's loader supplies, so no
409
- * dependency tree is needed. `node_modules` (pnpm workspace links) and symlinks
410
- * are never copied: the staged package owns its dependencies and must contain
411
- * real files only.
408
+ * The extensions import nothing from pi's runtime — only `import type`, which is
409
+ * erased, and the one module pi documents as host-provided (`typebox`) — so no
410
+ * dependency tree of their own is needed. A value import of `@earendil-works/*`
411
+ * would only resolve where pnpm installed it and would break the staged package
412
+ * everywhere else, which test/pi-extensions-load.test.mjs guards. `node_modules`
413
+ * (pnpm workspace links) and symlinks are never copied: the staged package owns
414
+ * its dependencies and must contain real files only.
412
415
  * @param {{ root: string, output: string, dryRun: boolean }} options
413
416
  * @returns {Promise<{ files: number, dir: string }>}
414
417
  */
@@ -12,8 +12,5 @@
12
12
  "@types/node": "^22.0.0",
13
13
  "tsx": "^4.0.0",
14
14
  "typescript": "^5.0.0"
15
- },
16
- "peerDependencies": {
17
- "@earendil-works/pi-coding-agent": "^0.78.1"
18
15
  }
19
16
  }
@@ -23,7 +23,6 @@
23
23
  import { join, dirname } from "node:path";
24
24
  import { fileURLToPath } from "node:url";
25
25
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
26
- import { Key } from "@earendil-works/pi-tui";
27
26
  import { discoverAgents, DEFAULT_TOOLS, type AgentDef } from "./discover.ts";
28
27
  import type { ThemeColor } from "@earendil-works/pi-coding-agent";
29
28
 
@@ -196,8 +195,10 @@ export default function agentModesExtension(pi: ExtensionAPI): void {
196
195
  },
197
196
  });
198
197
 
199
- // Ctrl+Shift+A to cycle agents (A = Agent)
200
- pi.registerShortcut(Key.ctrlShift("a"), {
198
+ // Ctrl+Shift+A to cycle agents (A = Agent). Spelled out rather than built
199
+ // with pi-tui's Key helper: a value import of pi-tui stops the extension
200
+ // loading outside the agent-cortex checkout (test/pi-extensions-load.test.mjs).
201
+ pi.registerShortcut("ctrl+shift+a", {
201
202
  description: "Next agent mode",
202
203
  handler: async (ctx) => {
203
204
  const next = (activeAgentIndex + 1) % (agents.length + 1);
@@ -9,12 +9,8 @@
9
9
  },
10
10
  "devDependencies": {
11
11
  "@earendil-works/pi-coding-agent": "^0.78.1",
12
- "@earendil-works/pi-tui": "^0.78.1",
13
12
  "@types/node": "^22.0.0",
14
13
  "tsx": "^4.0.0",
15
14
  "typescript": "^5.0.0"
16
- },
17
- "peerDependencies": {
18
- "@earendil-works/pi-coding-agent": "^0.78.1"
19
15
  }
20
- }
16
+ }
@@ -1,30 +1,29 @@
1
1
  /**
2
- * Auto-name sessions using a tiny side-agent LLM.
2
+ * Auto-name sessions from the first user prompt.
3
3
  *
4
- * On the first user prompt of a new session, calls a cheap configured model
5
- * (default: opencode/mimo-v2.5-free) to generate a concise session title,
6
- * then sets it via pi.setSessionName(). Falls back to simple truncation if
7
- * the LLM call fails.
4
+ * On the first prompt of a new session, names the session with a trimmed
5
+ * version of that prompt, then sets it via pi.setSessionName(). Existing names
6
+ * (set via --name or /name) are never overwritten.
7
+ *
8
+ * The name used to come from a cheap side-model, which needed pi-ai's
9
+ * `complete()` — a value import that stopped this extension loading outside the
10
+ * agent-cortex checkout (see test/pi-extensions-load.test.mjs). The truncation
11
+ * that already served as its fallback is now the only path; `tinyModel.enabled`
12
+ * still turns naming off entirely.
8
13
  *
9
14
  * Config (in settings.json under `tinyModel`):
10
15
  * ```json
11
16
  * {
12
17
  * "tinyModel": {
13
- * "model": "opencode/mimo-v2.5-free",
18
+ * "enabled": true,
14
19
  * "maxNameLength": 50
15
20
  * }
16
21
  * }
17
22
  * ```
18
- *
19
- * Existing names (set via --name or /name) are never overwritten.
20
23
  */
21
24
 
22
25
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
23
- import {
24
- loadTinyModelConfig,
25
- parseModelString,
26
- generateNameFromPrompt,
27
- } from "./lib/tiny-model.js";
26
+ import { loadTinyModelConfig } from "./lib/tiny-model.js";
28
27
  import type { TinyModelConfig } from "./lib/tiny-model.js";
29
28
 
30
29
  export default function (pi: ExtensionAPI) {
@@ -41,7 +40,7 @@ export default function (pi: ExtensionAPI) {
41
40
  }
42
41
  });
43
42
 
44
- pi.on("input", async (event, ctx) => {
43
+ pi.on("input", async (event, _ctx) => {
45
44
  if (!config.enabled || hasNamed) return;
46
45
  if (event.source === "rpc") return;
47
46
  if (pi.getSessionName()) {
@@ -51,46 +50,14 @@ export default function (pi: ExtensionAPI) {
51
50
  if (!event.text?.trim()) return;
52
51
 
53
52
  hasNamed = true;
54
-
55
- // Fire the LLM call asynchronously — don't block the agent
56
- (async () => {
57
- try {
58
- const { provider, id } = parseModelString(config.model);
59
- const model = ctx.modelRegistry.find(provider, id);
60
- if (!model) {
61
- // Model not found; fall back to truncation
62
- fallbackName(event.text, config.maxNameLength, pi);
63
- return;
64
- }
65
-
66
- const { apiKey, headers } =
67
- await ctx.modelRegistry.getApiKeyAndHeaders(model);
68
-
69
- const name = await generateNameFromPrompt(
70
- model,
71
- apiKey,
72
- headers,
73
- event.text,
74
- config.maxNameLength,
75
- );
76
-
77
- if (name) {
78
- pi.setSessionName(name);
79
- } else {
80
- fallbackName(event.text, config.maxNameLength, pi);
81
- }
82
- } catch (err) {
83
- console.warn("[auto-name] LLM call failed, falling back:", err);
84
- fallbackName(event.text, config.maxNameLength, pi);
85
- }
86
- })();
53
+ nameFromPrompt(event.text, config.maxNameLength, pi);
87
54
  });
88
55
  }
89
56
 
90
57
  /**
91
- * Fallback: truncate the first line of the prompt to use as a session name.
58
+ * Name the session with the first line of the prompt, truncated to `maxLength`.
92
59
  */
93
- function fallbackName(
60
+ function nameFromPrompt(
94
61
  text: string,
95
62
  maxLength: number,
96
63
  pi: ExtensionAPI,
@@ -1,43 +1,45 @@
1
1
  /**
2
- * Shared helpers for making cheap LLM calls from extensions.
2
+ * The `tinyModel` config block shared by the extensions that used to call a
3
+ * cheap side-model: session naming (auto-name) and response summaries (notify).
3
4
  *
4
- * Reads a `tinyModel` config key from settings.json (global + project merged)
5
- * and provides functions for session naming and response summarization using
6
- * the configured model — typically the tiniest free model available.
7
- *
8
- * Config (in ~/.pi/agent/settings.json or .pi/settings.json):
5
+ * Reads the key from settings.json (global + project merged):
9
6
  * ```json
10
7
  * {
11
8
  * "tinyModel": {
12
- * "model": "opencode/mimo-v2.5-free",
9
+ * "enabled": true,
13
10
  * "maxNameLength": 50,
14
11
  * "maxSummaryLength": 100
15
12
  * }
16
13
  * }
17
14
  * ```
15
+ *
16
+ * The model call itself is gone. It went through pi-ai's `complete()`, a value
17
+ * import of pi's runtime, and that import stopped the extension loading outside
18
+ * the agent-cortex checkout (see test/pi-extensions-load.test.mjs). The
19
+ * ExtensionAPI promises no completion call. Newer hosts (pi 1.4.0) expose
20
+ * `ModelRegistry.complete`, but the installed host (0.73.1) does not. Calling it
21
+ * would need an untyped cast to a method that exists on one version only.
22
+ * Extensions also make no internal LLM calls (AGENTS.md), so this feature stays
23
+ * off. Both callers fall back to the truncation they already used when the model
24
+ * failed. The `model` key is no longer read and is ignored if present.
18
25
  */
19
26
 
20
- import { complete } from "@earendil-works/pi-ai";
21
27
  import { homedir } from "node:os";
22
28
  import { join } from "node:path";
23
29
  import { readFileSync, existsSync } from "node:fs";
24
- import type { Model, Api } from "@earendil-works/pi-ai";
25
30
 
26
31
  /* ── Types ── */
27
32
 
28
33
  export interface TinyModelConfig {
29
- /** Provider/id string, e.g. "opencode/mimo-v2.5-free" */
30
- model: string;
31
34
  /** Global on/off toggle */
32
35
  enabled: boolean;
33
- /** Max characters for auto-generated session names */
36
+ /** Max characters for generated session names */
34
37
  maxNameLength: number;
35
38
  /** Max characters for response summaries in notifications */
36
39
  maxSummaryLength: number;
37
40
  }
38
41
 
39
42
  const DEFAULT_CONFIG: TinyModelConfig = {
40
- model: "opencode/mimo-v2.5-free",
41
43
  enabled: true,
42
44
  maxNameLength: 40,
43
45
  maxSummaryLength: 100,
@@ -72,141 +74,3 @@ export function loadTinyModelConfig(cwd?: string): TinyModelConfig {
72
74
 
73
75
  return { ...DEFAULT_CONFIG, ...merged } as TinyModelConfig;
74
76
  }
75
-
76
- /**
77
- * Parse a "provider/id" string into its parts.
78
- */
79
- export function parseModelString(modelStr: string): {
80
- provider: string;
81
- id: string;
82
- } {
83
- const slash = modelStr.indexOf("/");
84
- if (slash === -1) return { provider: "opencode", id: modelStr };
85
- return {
86
- provider: modelStr.slice(0, slash),
87
- id: modelStr.slice(slash + 1),
88
- };
89
- }
90
-
91
- /* ── LLM helpers ── */
92
-
93
- type ModelAny = Model<Api>;
94
- type CompleteOptions = Record<string, any>;
95
-
96
- /**
97
- * Generate a concise session title from the user's first prompt.
98
- *
99
- * Calls the configured tiny model with a system prompt tuned for naming.
100
- * Returns null on failure (caller should fall back to truncation).
101
- */
102
- export async function generateNameFromPrompt(
103
- model: ModelAny,
104
- apiKey: string | undefined,
105
- headers: Record<string, string> | undefined,
106
- prompt: string,
107
- maxLength: number,
108
- ): Promise<string | null> {
109
- const systemPrompt = [
110
- "You are a session naming assistant. Generate a concise title for a coding session.",
111
- "",
112
- "Rules:",
113
- "- Output ONLY the title, no quotes, no prefix, no explanation",
114
- `- Maximum ${maxLength} characters`,
115
- "- Summarize intent, do not copy verbatim",
116
- "- Keep it short (3\u20135 words), capture the general theme",
117
- "- If the message mentions files or modules, keep the key names brief",
118
- ].join("\n");
119
-
120
- const truncated = prompt.length > 2000 ? prompt.slice(0, 2000) + "..." : prompt;
121
-
122
- try {
123
- const options: CompleteOptions = { maxTokens: 64 };
124
- if (apiKey) options.apiKey = apiKey;
125
- if (headers) options.headers = headers;
126
-
127
- const result = await complete(model, {
128
- systemPrompt,
129
- messages: [{ role: "user", content: truncated, timestamp: Date.now() }],
130
- }, options);
131
-
132
- const text = result.content
133
- ?.filter((b: any): b is { type: "text"; text: string } => b.type === "text")
134
- ?.map((b) => b.text)
135
- ?.join("")
136
- ?.trim() ?? "";
137
-
138
- return cleanTitle(text, maxLength);
139
- } catch {
140
- return null;
141
- }
142
- }
143
-
144
- /**
145
- * Summarize an assistant response into a one-line summary.
146
- *
147
- * Calls the configured tiny model. Returns null on failure.
148
- */
149
- export async function summarizeResponseText(
150
- model: ModelAny,
151
- apiKey: string | undefined,
152
- headers: Record<string, string> | undefined,
153
- responseText: string,
154
- maxLength: number,
155
- ): Promise<string | null> {
156
- const systemPrompt = [
157
- "Summarize what the AI assistant did in this coding session response.",
158
- "Be concise — one short sentence or phrase.",
159
- `Maximum ${maxLength} characters.`,
160
- "Focus on what changed or was accomplished.",
161
- "Output ONLY the summary, no quotes, no prefix, no explanation.",
162
- ].join("\n");
163
-
164
- const truncated =
165
- responseText.length > 2000 ? responseText.slice(0, 2000) + "..." : responseText;
166
-
167
- try {
168
- const options: CompleteOptions = { maxTokens: 64 };
169
- if (apiKey) options.apiKey = apiKey;
170
- if (headers) options.headers = headers;
171
-
172
- const result = await complete(model, {
173
- systemPrompt,
174
- messages: [{ role: "user", content: truncated, timestamp: Date.now() }],
175
- }, options);
176
-
177
- const text = result.content
178
- ?.filter((b: any): b is { type: "text"; text: string } => b.type === "text")
179
- ?.map((b) => b.text)
180
- ?.join("")
181
- ?.trim() ?? "";
182
-
183
- return text || null;
184
- } catch {
185
- return null;
186
- }
187
- }
188
-
189
- /* ── Internal helpers ── */
190
-
191
- function cleanTitle(raw: string, maxLength: number): string | null {
192
- let name = raw.trim();
193
- if (!name) return null;
194
-
195
- // Strip surrounding quotes if present
196
- if (
197
- (name.startsWith('"') && name.endsWith('"')) ||
198
- (name.startsWith("'") && name.endsWith("'"))
199
- ) {
200
- name = name.slice(1, -1);
201
- }
202
-
203
- // Collapse newlines
204
- name = name.replace(/\n/g, " ").trim();
205
-
206
- // Truncate
207
- if (name.length > maxLength) {
208
- name = name.slice(0, maxLength - 3) + "...";
209
- }
210
-
211
- return name || null;
212
- }
@@ -5,13 +5,16 @@
5
5
  * multiline notification showing:
6
6
  * Line 1: working directory path
7
7
  * Line 2: session name (if set)
8
- * Line 3: LLM-generated summary of what the agent did
8
+ * Line 3: truncated summary of what the agent did
9
9
  *
10
10
  * The notification title is the tmux session:window.pane identifier (when
11
11
  * available) or "pi".
12
12
  *
13
- * Response summarization uses the tiny model configured in settings.json
14
- * under the `tinyModel` key.
13
+ * The summary used to come from a cheap side-model, which needed pi-ai's
14
+ * `complete()` — a value import that stopped this extension loading outside the
15
+ * agent-cortex checkout (see test/pi-extensions-load.test.mjs). The truncation
16
+ * that already served as its fallback is now the only path; `maxSummaryLength`
17
+ * still sets its length.
15
18
  */
16
19
 
17
20
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
@@ -19,11 +22,7 @@ import type { AgentMessage } from "@earendil-works/pi-agent-core";
19
22
  import { execSync } from "node:child_process";
20
23
  import { homedir } from "node:os";
21
24
 
22
- import {
23
- loadTinyModelConfig,
24
- parseModelString,
25
- summarizeResponseText,
26
- } from "../lib/tiny-model.js";
25
+ import { loadTinyModelConfig } from "../lib/tiny-model.js";
27
26
  import type { TinyModelConfig } from "../lib/tiny-model.js";
28
27
 
29
28
  /* ── Context helpers ── */
@@ -215,20 +214,10 @@ export default function (pi: ExtensionAPI) {
215
214
  const sessionName = pi.getSessionName();
216
215
  if (sessionName) sections.push(sessionName);
217
216
 
218
- // Section 2: LLM-generated summary of the assistant's response
217
+ // Section 2: truncated summary of the assistant's response
219
218
  const responseText = extractAssistantText(event.messages);
220
219
  if (responseText) {
221
- try {
222
- const summary = await summarizeResponseWithFallback(
223
- ctx,
224
- responseText,
225
- config,
226
- );
227
- sections.push(summary);
228
- } catch {
229
- // If summary fails (e.g. config not loaded), just show raw text
230
- sections.push(responseText.slice(0, config?.maxSummaryLength ?? 100).replace(/\s+/g, ' ').trim());
231
- }
220
+ sections.push(truncateToOneLine(responseText, config?.maxSummaryLength ?? 100));
232
221
  }
233
222
 
234
223
  // Desktop notification
@@ -239,45 +228,6 @@ export default function (pi: ExtensionAPI) {
239
228
  });
240
229
  }
241
230
 
242
- /**
243
- * Summarize response text using the configured tiny model, falling back
244
- * to simple truncation if the LLM call fails or is disabled.
245
- */
246
- async function summarizeResponseWithFallback(
247
- ctx: any,
248
- responseText: string,
249
- config: TinyModelConfig,
250
- ): Promise<string> {
251
- if (!config.enabled) {
252
- return truncateToOneLine(responseText, config.maxSummaryLength);
253
- }
254
-
255
- try {
256
- const { provider, id } = parseModelString(config.model);
257
- const model = ctx.modelRegistry.find(provider, id);
258
- if (!model) {
259
- return truncateToOneLine(responseText, config.maxSummaryLength);
260
- }
261
-
262
- const { apiKey, headers } =
263
- await ctx.modelRegistry.getApiKeyAndHeaders(model);
264
-
265
- const summary = await summarizeResponseText(
266
- model,
267
- apiKey,
268
- headers,
269
- responseText,
270
- config.maxSummaryLength,
271
- );
272
-
273
- if (summary) return summary;
274
- } catch (err) {
275
- console.warn("[notify] LLM summary failed, falling back:", err);
276
- }
277
-
278
- return truncateToOneLine(responseText, config.maxSummaryLength);
279
- }
280
-
281
231
  /**
282
232
  * Truncate text to roughly `maxLen` characters on a single line,
283
233
  * breaking at a word boundary.
@@ -12,8 +12,5 @@
12
12
  "@types/node": "^22.0.0",
13
13
  "tsx": "^4.0.0",
14
14
  "typescript": "^5.0.0"
15
- },
16
- "peerDependencies": {
17
- "@earendil-works/pi-coding-agent": "^0.78.1"
18
15
  }
19
16
  }
@@ -12,8 +12,5 @@
12
12
  "@types/node": "^22.0.0",
13
13
  "tsx": "^4.0.0",
14
14
  "typescript": "^5.0.0"
15
- },
16
- "peerDependencies": {
17
- "@earendil-works/pi-coding-agent": "^0.78.1"
18
15
  }
19
16
  }
@@ -12,8 +12,5 @@
12
12
  "@types/node": "^22.0.0",
13
13
  "tsx": "^4.0.0",
14
14
  "typescript": "^5.0.0"
15
- },
16
- "peerDependencies": {
17
- "@earendil-works/pi-coding-agent": "^0.78.1"
18
15
  }
19
16
  }
@@ -7,7 +7,7 @@ Delegate tasks to specialized subagents with isolated context windows.
7
7
  - **Isolated context**: Each subagent runs in a separate `pi` process
8
8
  - **Streaming output**: See tool calls and progress as they happen
9
9
  - **Parallel streaming**: All parallel tasks stream updates simultaneously
10
- - **Markdown rendering**: Final output rendered with proper formatting (expanded view)
10
+ - **Plain-text output**: Final output wrapped to the terminal width (expanded view)
11
11
  - **Usage tracking**: Shows turns, tokens, cost, and context usage per agent
12
12
  - **Abort support**: Ctrl+C propagates to kill subagent processes
13
13
 
@@ -125,7 +125,7 @@ pending `wait_for_agents` returns immediately with whatever is known so far.
125
125
  **Expanded view** (Ctrl+O):
126
126
  - Full task text
127
127
  - All tool calls with formatted arguments
128
- - Final output rendered as Markdown
128
+ - Final output rendered as width-wrapped plain text
129
129
  - Per-task usage (for chain/parallel)
130
130
 
131
131
  **Parallel mode streaming**:
@@ -3,9 +3,10 @@
3
3
  */
4
4
 
5
5
  import * as fs from "node:fs";
6
+ import * as os from "node:os";
6
7
  import * as path from "node:path";
7
8
  import { fileURLToPath } from "node:url";
8
- import { getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent";
9
+ import { parseFrontmatter } from "./frontmatter.ts";
9
10
 
10
11
  export type AgentScope = "user" | "project" | "both";
11
12
 
@@ -145,6 +146,21 @@ function isRenderedAgentsTree(agentsDir: string): boolean {
145
146
  return !entries.some((entry) => entry.isDirectory() && fs.existsSync(path.join(agentsDir, entry.name, "agent.md")));
146
147
  }
147
148
 
149
+ /**
150
+ * pi's user config directory, which holds pi's own `agents/` directory.
151
+ *
152
+ * Read here rather than through `getAgentDir()` from
153
+ * `@earendil-works/pi-coding-agent`: a value import of pi's runtime makes this
154
+ * extension fail to load outside the agent-cortex checkout
155
+ * (test/pi-extensions-load.test.mjs). Same contract — `PI_CODING_AGENT_DIR`
156
+ * wins when set, otherwise `~/.pi/agent`.
157
+ */
158
+ function getAgentDir(): string {
159
+ const override = process.env.PI_CODING_AGENT_DIR;
160
+ if (override) return path.resolve(override.replace(/^~(?=$|[\\/])/, os.homedir()));
161
+ return path.join(os.homedir(), ".pi", "agent");
162
+ }
163
+
148
164
  /**
149
165
  * Directories the "user" scope discovers agents from, in precedence order.
150
166
  *
@@ -0,0 +1,111 @@
1
+ /**
2
+ * The local transcript components pi's renderers draw for the subagent tool.
3
+ *
4
+ * pi-tui's own components are unreachable from an installed extension, so these
5
+ * implement pi's `Component` contract directly. The contract that matters is the
6
+ * width: pi-tui reports a rendered line wider than the terminal as an error, so
7
+ * every line these return must fit the width they are given, with SGR colour
8
+ * sequences still intact.
9
+ */
10
+
11
+ import { describe, it } from "node:test";
12
+ import assert from "node:assert/strict";
13
+
14
+ import { Container, Spacer, Text } from "./components.ts";
15
+
16
+ const RESET = "\x1b[0m";
17
+ const GREEN = "\x1b[32m";
18
+
19
+ /** Visible column count, ignoring SGR sequences. */
20
+ function visibleWidth(line: string): number {
21
+ return line.replace(/\x1b\[[0-9;]*m/g, "").length;
22
+ }
23
+
24
+ /**
25
+ * Visible terminal-cell count, ignoring SGR sequences and counting a wide or
26
+ * astral character as two cells. `String.length` counts a wide character as one
27
+ * unit, so a width assertion built on it misses the overflow this guards.
28
+ */
29
+ function cellWidth(line: string): number {
30
+ return [...line.replace(/\x1b\[[0-9;]*m/g, "")].reduce((cells, character) => {
31
+ const codePoint = character.codePointAt(0) ?? 0;
32
+ const wide =
33
+ codePoint > 0xffff ||
34
+ (codePoint >= 0x1100 &&
35
+ (codePoint <= 0x115f ||
36
+ (codePoint >= 0x2e80 && codePoint <= 0xa4cf) ||
37
+ (codePoint >= 0xac00 && codePoint <= 0xd7a3) ||
38
+ (codePoint >= 0xf900 && codePoint <= 0xfaff) ||
39
+ (codePoint >= 0xfe30 && codePoint <= 0xfe6f) ||
40
+ (codePoint >= 0xff00 && codePoint <= 0xff60) ||
41
+ (codePoint >= 0xffe0 && codePoint <= 0xffe6)));
42
+ return cells + (wide ? 2 : 1);
43
+ }, 0);
44
+ }
45
+
46
+ describe("Text", () => {
47
+ it("wraps a long line to the width it is given", () => {
48
+ const lines = new Text("alpha beta gamma delta").render(11);
49
+
50
+ assert.deepEqual(lines, ["alpha beta", "gamma delta"]);
51
+ assert.ok(lines.every((line) => visibleWidth(line) <= 11));
52
+ });
53
+
54
+ it("hard-wraps a word longer than the width instead of overflowing", () => {
55
+ const lines = new Text("abcdefghij").render(4);
56
+
57
+ assert.deepEqual(lines, ["abcd", "efgh", "ij"]);
58
+ });
59
+
60
+ it("keeps a colour sequence intact and carries it onto the wrapped line", () => {
61
+ const lines = new Text(`${GREEN}alpha beta gamma${RESET}`).render(11);
62
+
63
+ assert.equal(lines.length, 2);
64
+ for (const line of lines) assert.ok(visibleWidth(line) <= 11, `width of ${JSON.stringify(line)}`);
65
+ assert.ok(lines[0].endsWith(RESET), "a line cut inside a colour span closes it");
66
+ assert.ok(lines[1].startsWith(GREEN), "the continuation reopens the colour");
67
+ assert.equal(lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "")).join(" "), "alpha beta gamma");
68
+ });
69
+
70
+ it("pads and separates lines with the padding it is given", () => {
71
+ const lines = new Text("body", 1, 1).render(10);
72
+
73
+ assert.deepEqual(lines, [" ", " body ", " "]);
74
+ });
75
+
76
+ it("keeps an empty line and renders blank text as one empty line", () => {
77
+ assert.deepEqual(new Text("a\n\nb").render(10), ["a", "", "b"]);
78
+ assert.deepEqual(new Text().render(10), [""]);
79
+ });
80
+
81
+ it("counts a wide character as two cells so it cannot overflow the width", () => {
82
+ // Each of these characters occupies two terminal cells but one string unit.
83
+ assert.deepEqual(new Text("日本語").render(4), ["日本", "語"]);
84
+
85
+ const lines = new Text("日本語のテキストは全角文字です。これは折り返しのテストです。").render(20);
86
+ assert.ok(lines.length > 1, "wide text must wrap");
87
+ for (const line of lines) assert.ok(cellWidth(line) <= 20, `width of ${JSON.stringify(line)}`);
88
+ });
89
+
90
+ it("counts an astral character as two cells", () => {
91
+ assert.deepEqual(new Text("🚀🚀🚀").render(4), ["🚀🚀", "🚀"]);
92
+ });
93
+ });
94
+
95
+ describe("Spacer", () => {
96
+ it("renders the blank lines it was asked for", () => {
97
+ assert.deepEqual(new Spacer(2).render(10), ["", ""]);
98
+ assert.deepEqual(new Spacer().render(10), [""]);
99
+ });
100
+ });
101
+
102
+ describe("Container", () => {
103
+ it("draws its children in order, each at the given width", () => {
104
+ const container = new Container();
105
+ container.addChild(new Text("alpha beta"));
106
+ container.addChild(new Spacer(1));
107
+ container.addChild(new Text("gamma"));
108
+
109
+ assert.deepEqual(container.render(5), ["alpha", "beta", "", "gamma"]);
110
+ });
111
+ });
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Transcript components for the subagent tool's renderers.
3
+ *
4
+ * pi-tui's `Text`, `Container` and `Spacer` are not reachable from an installed
5
+ * extension: a value import of `@earendil-works/pi-tui` makes the extension fail
6
+ * to load outside the agent-cortex checkout (test/pi-extensions-load.test.mjs).
7
+ * These are the local equivalents, implementing the `Component` contract pi's
8
+ * transcript renderer calls: `render(width)` returns lines that fit `width`, and
9
+ * `invalidate()` drops cached layout.
10
+ *
11
+ * Two deliberate limits, because reimplementing pi-tui is not this change:
12
+ * markdown is drawn as plain text, and only SGR colour sequences (`ESC[…m`) are
13
+ * measured and carried across a wrapped line. Other escapes — OSC-8 hyperlinks,
14
+ * images — count as visible characters, the same way a naive strip does.
15
+ * Width is measured in terminal cells: a wide (East Asian) character, and any
16
+ * astral character such as an emoji, counts as two.
17
+ */
18
+
19
+ /** Lines to draw at a given width — what pi's transcript renderer calls. */
20
+ export interface Component {
21
+ render(width: number): string[];
22
+ invalidate(): void;
23
+ }
24
+
25
+ const SGR = /\x1b\[[0-9;]*m/g;
26
+ const SGR_AT = /\x1b\[[0-9;]*m/y;
27
+ const RESET = "\x1b[0m";
28
+
29
+ /** A block of text, word-wrapped to the width it is drawn at. */
30
+ export class Text implements Component {
31
+ readonly #text: string;
32
+ readonly #paddingX: number;
33
+ readonly #paddingY: number;
34
+
35
+ constructor(text = "", paddingX = 0, paddingY = 0) {
36
+ this.#text = text;
37
+ this.#paddingX = paddingX;
38
+ this.#paddingY = paddingY;
39
+ }
40
+
41
+ render(width: number): string[] {
42
+ const available = Math.max(1, width - this.#paddingX * 2);
43
+ const indent = " ".repeat(this.#paddingX);
44
+ const blank = indent.repeat(2);
45
+ const body = this.#text.split("\n").flatMap((line) => wrapLine(line, available));
46
+
47
+ return [
48
+ ...emptyLines(this.#paddingY, blank),
49
+ ...body.map((line) => `${indent}${line}${indent}`),
50
+ ...emptyLines(this.#paddingY, blank),
51
+ ];
52
+ }
53
+
54
+ invalidate(): void {}
55
+ }
56
+
57
+ /** Blank lines. */
58
+ export class Spacer implements Component {
59
+ readonly #lines: number;
60
+
61
+ constructor(lines = 1) {
62
+ this.#lines = lines;
63
+ }
64
+
65
+ render(_width: number): string[] {
66
+ return emptyLines(this.#lines, "");
67
+ }
68
+
69
+ invalidate(): void {}
70
+ }
71
+
72
+ /** A vertical stack of components. */
73
+ export class Container implements Component {
74
+ readonly #children: Component[] = [];
75
+
76
+ addChild(child: Component): void {
77
+ this.#children.push(child);
78
+ }
79
+
80
+ render(width: number): string[] {
81
+ return this.#children.flatMap((child) => child.render(width));
82
+ }
83
+
84
+ invalidate(): void {
85
+ for (const child of this.#children) child.invalidate();
86
+ }
87
+ }
88
+
89
+ /** `count` blank lines, each filled with `filler` (padding keeps the width). */
90
+ function emptyLines(count: number, filler: string): string[] {
91
+ return Array.from({ length: Math.max(0, count) }, () => filler);
92
+ }
93
+
94
+ /** Visible cell count of a line: everything except SGR sequences, wide characters as two. */
95
+ function visibleWidth(line: string): number {
96
+ let cells = 0;
97
+ for (const character of line.replace(SGR, "")) cells += cellWidth(character.codePointAt(0) ?? 0);
98
+ return cells;
99
+ }
100
+
101
+ /**
102
+ * Terminal cells one character occupies. Wide East Asian characters and astral
103
+ * characters (emoji included) occupy two; every other character occupies one.
104
+ * Counting in `String.length` units would let a wide character overflow: it is
105
+ * one unit but two cells.
106
+ */
107
+ function cellWidth(codePoint: number): number {
108
+ if (codePoint > 0xffff) return 2;
109
+ return codePoint >= 0x1100 &&
110
+ (codePoint <= 0x115f ||
111
+ (codePoint >= 0x2e80 && codePoint <= 0xa4cf) ||
112
+ (codePoint >= 0xac00 && codePoint <= 0xd7a3) ||
113
+ (codePoint >= 0xf900 && codePoint <= 0xfaff) ||
114
+ (codePoint >= 0xfe30 && codePoint <= 0xfe6f) ||
115
+ (codePoint >= 0xff00 && codePoint <= 0xff60) ||
116
+ (codePoint >= 0xffe0 && codePoint <= 0xffe6))
117
+ ? 2
118
+ : 1;
119
+ }
120
+
121
+ /**
122
+ * Wrap one line to `width` visible columns without cutting an SGR sequence. A
123
+ * cut inside a coloured span closes the line and reopens the same colour on the
124
+ * continuation, so broken escapes never reach the terminal.
125
+ */
126
+ function wrapLine(line: string, width: number): string[] {
127
+ const lines: string[] = [];
128
+ let rest = line;
129
+
130
+ while (visibleWidth(rest) > width) {
131
+ const cut = breakIndex(rest, width);
132
+ if (cut <= 0) break;
133
+
134
+ const head = rest.slice(0, cut);
135
+ const colour = lastColour(head);
136
+ lines.push(colour === "" ? head : `${head}${RESET}`);
137
+ // A break taken at a space consumes it, so the continuation starts with its word.
138
+ rest = `${colour}${rest.slice(rest[cut] === " " ? cut + 1 : cut)}`;
139
+ }
140
+
141
+ lines.push(rest);
142
+ return lines;
143
+ }
144
+
145
+ /**
146
+ * Index at which to cut `text` so its visible width stays within `width`:
147
+ * the last space at or before the limit, or the limit itself when the line has
148
+ * no usable space.
149
+ */
150
+ function breakIndex(text: string, width: number): number {
151
+ let visible = 0;
152
+ let lastSpace = -1;
153
+ let index = 0;
154
+
155
+ while (index < text.length) {
156
+ SGR_AT.lastIndex = index;
157
+ const escape = SGR_AT.exec(text);
158
+ if (escape !== null) {
159
+ index += escape[0].length;
160
+ continue;
161
+ }
162
+
163
+ const codePoint = text.codePointAt(index) ?? 0;
164
+ const character = String.fromCodePoint(codePoint);
165
+ if (character === " ") lastSpace = index;
166
+ if (visible + cellWidth(codePoint) > width) return lastSpace > 0 ? lastSpace : index;
167
+
168
+ visible += cellWidth(codePoint);
169
+ index += character.length;
170
+ }
171
+
172
+ return lastSpace > 0 ? lastSpace : index;
173
+ }
174
+
175
+ /** The colour sequence left open at the end of `text`, or "" when none is. */
176
+ function lastColour(text: string): string {
177
+ let colour = "";
178
+ for (const [sequence] of text.matchAll(SGR)) {
179
+ colour = sequence === RESET || sequence === "\x1b[m" ? "" : sequence;
180
+ }
181
+ return colour;
182
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * The local frontmatter reader for agent files.
3
+ *
4
+ * pi's `parseFrontmatter` is unreachable from an installed extension, so this
5
+ * parser reads the subset agent files use. Agents are user-authored files at a
6
+ * system edge: a field this parser mishandles silently changes an agent's name,
7
+ * description or tool list, and an exception here would break discovery of every
8
+ * other agent, so both the parsing and the tolerance are covered.
9
+ */
10
+
11
+ import { describe, it } from "node:test";
12
+ import assert from "node:assert/strict";
13
+
14
+ import { parseFrontmatter } from "./frontmatter.ts";
15
+
16
+ describe("parseFrontmatter", () => {
17
+ it("splits fields from the body and trims the body", () => {
18
+ const { frontmatter, body } = parseFrontmatter(`---\nname: ralph\ndescription: d\n---\n\n# ralph\n`);
19
+
20
+ assert.deepEqual(frontmatter, { name: "ralph", description: "d" });
21
+ assert.equal(body, "# ralph");
22
+ });
23
+
24
+ it("reads a quoted scalar, unescaping it", () => {
25
+ const { frontmatter } = parseFrontmatter(`---\nname: "a: b\\"c\\n"\n---\n`);
26
+
27
+ assert.equal(frontmatter.name, 'a: b"c\n');
28
+ });
29
+
30
+ it("reads a flow sequence of quoted items", () => {
31
+ const { frontmatter } = parseFrontmatter(`---\ntools: ["bash", "read"]\n---\n`);
32
+
33
+ assert.deepEqual(frontmatter.tools, ["bash", "read"]);
34
+ });
35
+
36
+ it("reads a block sequence", () => {
37
+ const { frontmatter } = parseFrontmatter(`---\ntools:\n - bash\n - read\n---\n`);
38
+
39
+ assert.deepEqual(frontmatter.tools, ["bash", "read"]);
40
+ });
41
+
42
+ it("keeps a comma-separated tool list as one scalar", () => {
43
+ const { frontmatter } = parseFrontmatter(`---\ntools: "bash, read"\n---\n`);
44
+
45
+ assert.equal(frontmatter.tools, "bash, read");
46
+ });
47
+
48
+ it("separates fields from a following block sequence", () => {
49
+ const { frontmatter } = parseFrontmatter(`---\nname: ralph\ntools:\n - bash\nmodel: fast\n---\n`);
50
+
51
+ assert.deepEqual(frontmatter, { name: "ralph", tools: ["bash"], model: "fast" });
52
+ });
53
+
54
+ it("normalises CRLF line endings", () => {
55
+ const { frontmatter, body } = parseFrontmatter(`---\r\nname: ralph\r\n---\r\n\r\nbody\r\n`);
56
+
57
+ assert.deepEqual(frontmatter, { name: "ralph" });
58
+ assert.equal(body, "body");
59
+ });
60
+
61
+ it("returns the whole document as body when there is no frontmatter", () => {
62
+ const { frontmatter, body } = parseFrontmatter("# just markdown\n");
63
+
64
+ assert.deepEqual(frontmatter, {});
65
+ assert.equal(body, "# just markdown\n");
66
+ });
67
+
68
+ it("treats an unclosed block as body rather than throwing", () => {
69
+ const { frontmatter, body } = parseFrontmatter(`---\nname: ralph\n`);
70
+
71
+ assert.deepEqual(frontmatter, {});
72
+ assert.equal(body, "---\nname: ralph\n");
73
+ });
74
+ });
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Read the YAML frontmatter block of a markdown document.
3
+ *
4
+ * pi's own `parseFrontmatter` is not reachable from an installed extension: a
5
+ * value import of `@earendil-works/pi-coding-agent` makes the extension fail to
6
+ * load outside the agent-cortex checkout (test/pi-extensions-load.test.mjs).
7
+ * This module is the subset agent files use — top-level `key: value` pairs,
8
+ * plain and quoted scalars, flow sequences (`[a, b]`) and block sequences
9
+ * (`- a`).
10
+ *
11
+ * Deliberately outside the subset: nested maps and block scalars (`|`, `>`) keep
12
+ * their literal text as the value. No construct throws — an unreadable block
13
+ * yields empty frontmatter — so one malformed agent file cannot break discovery
14
+ * of the others.
15
+ */
16
+
17
+ /** A parsed document: its frontmatter fields and the markdown body. */
18
+ export interface ParsedFrontmatter<T extends Record<string, unknown>> {
19
+ frontmatter: T;
20
+ body: string;
21
+ }
22
+
23
+ const FENCE = "---";
24
+
25
+ /**
26
+ * Split a markdown document into frontmatter fields and body, matching pi's
27
+ * contract: the document must open with `---` and close the block with a line
28
+ * that starts with `---`; the body is trimmed.
29
+ */
30
+ export function parseFrontmatter<T extends Record<string, unknown>>(content: string): ParsedFrontmatter<T> {
31
+ const normalized = content.replace(/^\uFEFF/, "").replace(/\r\n?/g, "\n");
32
+ const end = normalized.startsWith(FENCE) ? normalized.indexOf(`\n${FENCE}`, FENCE.length) : -1;
33
+ if (end === -1) return { frontmatter: {} as T, body: normalized };
34
+
35
+ return {
36
+ frontmatter: parseBlock(normalized.slice(FENCE.length + 1, end)) as T,
37
+ body: normalized.slice(end + FENCE.length + 1).trim(),
38
+ };
39
+ }
40
+
41
+ /** Parse the `key: value` lines of a frontmatter block. */
42
+ function parseBlock(block: string): Record<string, unknown> {
43
+ const fields: Record<string, unknown> = {};
44
+ const lines = block.split("\n");
45
+
46
+ for (let index = 0; index < lines.length; index++) {
47
+ const match = /^([\w.-]+):(?:[ \t]+(.*))?$/.exec(lines[index]);
48
+ if (match === null) continue;
49
+
50
+ const [, key, inlineValue] = match;
51
+ if (inlineValue !== undefined && inlineValue.trim() !== "") {
52
+ fields[key] = parseValue(inlineValue.trim());
53
+ continue;
54
+ }
55
+
56
+ const { items, consumed } = readBlockSequence(lines, index + 1);
57
+ index += consumed;
58
+ fields[key] = items.length > 0 ? items : null;
59
+ }
60
+
61
+ return fields;
62
+ }
63
+
64
+ /**
65
+ * Read the indented `- item` lines that follow a `key:` line with no inline
66
+ * value. Returns the items and how many lines were consumed.
67
+ */
68
+ function readBlockSequence(lines: string[], start: number): { items: string[]; consumed: number } {
69
+ const items: string[] = [];
70
+ let consumed = 0;
71
+
72
+ for (let index = start; index < lines.length; index++) {
73
+ const match = /^[ \t]+-[ \t]+(.*)$/.exec(lines[index]);
74
+ if (match === null) break;
75
+
76
+ const value = parseValue(match[1].trim());
77
+ items.push(typeof value === "string" ? value : String(value));
78
+ consumed++;
79
+ }
80
+
81
+ return { items, consumed };
82
+ }
83
+
84
+ /** Parse one scalar or flow sequence. */
85
+ function parseValue(raw: string): unknown {
86
+ if (raw.startsWith("[") && raw.endsWith("]")) {
87
+ return raw
88
+ .slice(1, -1)
89
+ .split(",")
90
+ .map((item) => item.trim())
91
+ .filter(Boolean)
92
+ .map(parseValue);
93
+ }
94
+ if (raw.startsWith('"')) return unquote(raw, '"', (text) => text.replace(/\\(["\\/nrt])/g, (_, escape: string) => {
95
+ return escape === "n" ? "\n" : escape === "r" ? "\r" : escape === "t" ? "\t" : escape;
96
+ }));
97
+ if (raw.startsWith("'")) return unquote(raw, "'", (text) => text.replace(/''/g, "'"));
98
+ return raw;
99
+ }
100
+
101
+ /** Strip a matching quote pair and unescape the content; an unterminated quote keeps the raw text. */
102
+ function unquote(raw: string, quote: string, unescape: (text: string) => string): string {
103
+ if (raw.length < 2 || !raw.endsWith(quote)) return raw;
104
+ return unescape(raw.slice(1, -1));
105
+ }
@@ -21,8 +21,13 @@ import type { AgentToolResult } from "@earendil-works/pi-agent-core";
21
21
  import type { Message } from "@earendil-works/pi-ai";
22
22
  import { type AgentConfig, type AgentScope, discoverAgents } from "./agents.ts";
23
23
  import { waitForAgents } from "./wait.ts";
24
- import { type ExtensionAPI, getMarkdownTheme, withFileMutationQueue } from "@earendil-works/pi-coding-agent";
25
- import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
24
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
25
+ import { Container, Spacer, Text } from "./components.ts";
26
+ // `typebox` is the one bare runtime import an extension may make: pi documents
27
+ // it as host-provided and its loader aliases it (pi's docs/packages.md), so it
28
+ // resolves in the staged package too. Every `@earendil-works/*` import here is
29
+ // `import type`, which is erased; a value import of pi's runtime breaks the
30
+ // package outside this checkout (test/pi-extensions-load.test.mjs).
26
31
  import { Type } from "typebox";
27
32
 
28
33
 
@@ -236,9 +241,8 @@ async function writePromptToTempFile(agentName: string, prompt: string): Promise
236
241
  const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "pi-subagent-"));
237
242
  const safeName = agentName.replace(/[^\w.-]+/g, "_");
238
243
  const filePath = path.join(tmpDir, `prompt-${safeName}.md`);
239
- await withFileMutationQueue(filePath, async () => {
240
- await fs.promises.writeFile(filePath, prompt, { encoding: "utf-8", mode: 0o600 });
241
- });
244
+ // No mutation queue: the directory is fresh per call, so nothing else writes this file.
245
+ await fs.promises.writeFile(filePath, prompt, { encoding: "utf-8", mode: 0o600 });
242
246
  return { dir: tmpDir, filePath };
243
247
  }
244
248
 
@@ -746,8 +750,6 @@ export default function (pi: ExtensionAPI) {
746
750
  return new Text(text?.type === "text" ? text.text : "(no output)", 0, 0);
747
751
  }
748
752
 
749
- const mdTheme = getMarkdownTheme();
750
-
751
753
  const renderDisplayItems = (items: DisplayItem[], limit?: number) => {
752
754
  const toShow = limit ? items.slice(-limit) : items;
753
755
  const skipped = limit && items.length > limit ? items.length - limit : 0;
@@ -798,7 +800,7 @@ export default function (pi: ExtensionAPI) {
798
800
  }
799
801
  if (finalOutput) {
800
802
  container.addChild(new Spacer(1));
801
- container.addChild(new Markdown(finalOutput.trim(), 0, 0, mdTheme));
803
+ container.addChild(new Text(finalOutput.trim(), 0, 0));
802
804
  }
803
805
  }
804
806
  const usageStr = formatUsageStats(r.usage, r.model);
@@ -880,10 +882,10 @@ export default function (pi: ExtensionAPI) {
880
882
  }
881
883
  }
882
884
 
883
- // Show final output as markdown
885
+ // Show final output as plain text
884
886
  if (finalOutput) {
885
887
  container.addChild(new Spacer(1));
886
- container.addChild(new Markdown(finalOutput.trim(), 0, 0, mdTheme));
888
+ container.addChild(new Text(finalOutput.trim(), 0, 0));
887
889
  }
888
890
 
889
891
  const stepUsage = formatUsageStats(r.usage, r.model);
@@ -965,10 +967,10 @@ export default function (pi: ExtensionAPI) {
965
967
  }
966
968
  }
967
969
 
968
- // Show final output as markdown
970
+ // Show final output as plain text
969
971
  if (finalOutput) {
970
972
  container.addChild(new Spacer(1));
971
- container.addChild(new Markdown(finalOutput.trim(), 0, 0, mdTheme));
973
+ container.addChild(new Text(finalOutput.trim(), 0, 0));
972
974
  }
973
975
 
974
976
  const taskUsage = formatUsageStats(r.usage, r.model);
@@ -4,20 +4,19 @@
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "scripts": {
7
- "test": "node --test --import tsx/esm wait.test.ts agents.test.ts",
7
+ "test": "node --test --import tsx/esm wait.test.ts agents.test.ts frontmatter.test.ts components.test.ts",
8
8
  "typecheck": "tsc --noEmit"
9
9
  },
10
10
  "devDependencies": {
11
11
  "@earendil-works/pi-agent-core": "^0.78.1",
12
12
  "@earendil-works/pi-ai": "^0.78.1",
13
13
  "@earendil-works/pi-coding-agent": "^0.78.1",
14
- "@earendil-works/pi-tui": "^0.78.1",
15
14
  "@types/node": "^22.0.0",
16
15
  "tsx": "^4.0.0",
17
16
  "typebox": "^1.1.38",
18
17
  "typescript": "^5.0.0"
19
18
  },
20
19
  "peerDependencies": {
21
- "@earendil-works/pi-coding-agent": "^0.78.1"
20
+ "typebox": "*"
22
21
  }
23
- }
22
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jaybeeuu/agent-cortex",
3
- "version": "2.3.0",
3
+ "version": "2.4.0",
4
4
  "description": "Personal PI package with custom agents, skills, and extensions",
5
5
  "keywords": [
6
6
  "pi-package"