@pi-archimedes/subagent 2.0.1 → 2.2.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/README.md CHANGED
@@ -2,7 +2,9 @@
2
2
 
3
3
  Subagent dispatch with live TUI streaming and cost tracking for the [Pi coding agent](https://github.com/earendil-works/pi).
4
4
 
5
- ## Features
5
+ Dispatch specialized subagents to offload complex tasks with live TUI streaming, parallel execution, cost tracking, and per-agent model overrides. By fanning out work to dedicated subagents, complex workflows can be executed concurrently while maintaining full visibility into progress and token usage.
6
+
7
+ ## What you get
6
8
 
7
9
  - **Single & parallel execution** — dispatch one task or fan out multiple tasks across different agents simultaneously
8
10
  - **Live TUI streaming** — watch subagent progress in real-time with color-coded tool calls (grey while running, green/red on completion), readable argument previews, token counts, and cost updates
@@ -38,16 +40,16 @@ Toggle which tools are available to an agent from Pi's full toolset:
38
40
 
39
41
  ![subagents tool selection](../../docs/images/subagents-tool-selection.png)
40
42
 
41
- ## Installation
43
+ ## Install
42
44
 
43
45
  ```bash
44
- pi install @pi-archimedes/subagent
46
+ pi install npm:@pi-archimedes/subagent
45
47
  ```
46
48
 
47
- Or install the full [pi-archimedes](../..) meta package for the integrated experience (cost tracking in footer, shared chrome, etc.):
49
+ Or install full meta package:
48
50
 
49
51
  ```bash
50
- pi install pi-archimedes
52
+ pi install npm:pi-archimedes
51
53
  ```
52
54
 
53
55
  ## Usage
@@ -84,14 +86,18 @@ Run `/agents` to open the interactive Agents Manager for creating, editing, and
84
86
 
85
87
  Agents are defined as `.md` files with YAML frontmatter, placed in one of:
86
88
 
87
- - **Project scope:** `<cwd>/.pi/agents/` — available only in this project
88
- - **User scope:** `~/.pi/agents/` — available across all projects
89
- - **Global scope:** installed via packages or extensions
89
+ - **Project scope:** `<repo root>/.pi/agents/` — available only in this project
90
+ - **User scope:** `~/.pi/agent/agents/` — available across all projects
91
+ - **Global scope:** `<repo root>/.agents/agents/` or `~/.agents/agents/` — shared or installed subagents
92
+
93
+ Frontmatter supports: `name`, `description`, `model`, `tools`, and `thinking`. The markdown body becomes the agent's system prompt. Unknown frontmatter fields are preserved on edit but not interpreted.
90
94
 
91
- Frontmatter supports: `name`, `model`, `tools`, `thinking`, `inheritProjectContext`, `inheritSkills`, `systemPromptMode`, and `systemPrompt`.
95
+ Per-agent `model` and `thinking` assignments made in the `/agents` TUI are stored in `~/.pi/agent/agents.local.json` (machine-local, not committed) and take precedence over frontmatter values; on save, the TUI also strips these fields from the `.md` frontmatter. Frontmatter `model:` and `thinking:` still work as a fallback for hand-written agent files.
92
96
 
93
97
  ## Integration
94
98
 
95
99
  When installed via `pi-archimedes` (the meta package), subagent cost events flow through `@pi-archimedes/core/bus` and are consumed by `@pi-archimedes/footer`'s `CostAccumulator`. This merges subagent tokens and cost into the main status bar for a unified view.
96
100
 
97
101
  The `/agents` command is also only registered by the meta package (not by standalone `@pi-archimedes/subagent`).
102
+
103
+ ← Back to [pi-archimedes](../../README.md)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-archimedes/subagent",
3
- "version": "2.0.1",
3
+ "version": "2.2.0",
4
4
  "type": "module",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -11,7 +11,7 @@
11
11
  ],
12
12
  "main": "./src/index.ts",
13
13
  "dependencies": {
14
- "@pi-archimedes/core": "2.0.1"
14
+ "@pi-archimedes/core": "2.2.0"
15
15
  },
16
16
  "peerDependencies": {
17
17
  "@earendil-works/pi-ai": ">=0.1.0",
@@ -17,10 +17,22 @@ import { serializeAgent, validateAgentName } from "./frontmatter-io.js";
17
17
  import {
18
18
  writeLocalModel,
19
19
  deleteLocalModel,
20
+ writeLocalThinking,
21
+ deleteLocalThinking,
22
+ deleteLocalAgent,
20
23
  readLocalConfig,
21
24
  setLocalConfig,
22
25
  type LocalConfig,
23
26
  } from "./local-config.js";
27
+ import {
28
+ visibleWidth,
29
+ padEnd,
30
+ wrapText,
31
+ hardTruncate,
32
+ renderHeader,
33
+ renderFooter,
34
+ wrapWithBorder,
35
+ } from "@pi-archimedes/core/overlay";
24
36
 
25
37
  // ── Screen constants ────────────────────────────────────────────────────────
26
38
 
@@ -148,55 +160,6 @@ function fuzzyFilter(items: AgentConfig[], query: string): AgentConfig[] {
148
160
  );
149
161
  }
150
162
 
151
- function wrapText(text: string, width: number): string[] {
152
- if (width <= 0) return [];
153
- const lines: string[] = [];
154
- const paragraphs = text.split("\n");
155
- for (const para of paragraphs) {
156
- if (para.length === 0) {
157
- lines.push("");
158
- continue;
159
- }
160
- const words = para.split(/(\s+)/).filter(Boolean);
161
- let current = "";
162
- for (const word of words) {
163
- const test = current === "" ? word : current + word;
164
- if (test.length > width && current.length > 0) {
165
- lines.push(current);
166
- current = word;
167
- } else {
168
- current = test;
169
- }
170
- }
171
- if (current) lines.push(current);
172
- }
173
- return lines;
174
- }
175
-
176
- function padEnd(text: string, width: number): string {
177
- if (width <= 0) return "";
178
- const vw = visibleWidth(text);
179
- if (vw >= width) return text;
180
- return text + " ".repeat(width - vw);
181
- }
182
-
183
- function visibleWidth(text: string): number {
184
- // Strip ANSI escape sequences for width calculation
185
- return text.replace(/\x1b\[[0-9;]*m/g, "").length;
186
- }
187
-
188
- function row(text: string, width: number, theme: Theme): string {
189
- return padEnd(text, width);
190
- }
191
-
192
- function renderHeader(text: string, width: number, theme: Theme): string {
193
- return theme.fg("accent", padEnd(text, width));
194
- }
195
-
196
- function renderFooter(text: string, width: number, theme: Theme): string {
197
- return theme.fg("dim", padEnd(text, width));
198
- }
199
-
200
163
  function scopeLabel(source: "global" | "user" | "project"): string {
201
164
  if (source === "global") return "home";
202
165
  return source === "user" ? "user" : "proj";
@@ -217,57 +180,6 @@ function filterModels(models: ModelInfo[], query: string): ModelInfo[] {
217
180
  );
218
181
  }
219
182
 
220
-
221
- // ── Border wrapper ────────────────────────────────────────────────────────────
222
-
223
- /** Hard-truncate by visible width — no "..." suffix. Strips ANSI, truncates, rebuilds. */
224
- function hardTruncate(text: string, maxVisible: number): string {
225
- if (visibleWidth(text) <= maxVisible) return text;
226
- // Strip ANSI codes, truncate, then re-apply any trailing reset codes
227
- const plain = text.replace(/\x1b\[[0-9;]*m/g, "");
228
- const truncated = plain.slice(0, maxVisible);
229
- // Restore any ANSI codes that were in the original up to this point
230
- let result = "";
231
- let plainPos = 0;
232
- let i = 0;
233
- let copiedSgr = false;
234
- while (i < text.length && plainPos < maxVisible) {
235
- if (text[i] === "\x1b" && text[i + 1] === "[") {
236
- // Copy the escape sequence
237
- let j = i;
238
- while (j < text.length && text[j] !== "m") j++;
239
- result += text.slice(i, j + 1);
240
- copiedSgr = true;
241
- i = j + 1;
242
- } else {
243
- result += text[i];
244
- plainPos++;
245
- i++;
246
- }
247
- }
248
- // Ensure styling doesn't bleed: append reset if we copied SGR and result doesn't end with one
249
- if (copiedSgr && !/\x1b\[0?m$/.test(result)) {
250
- result += "\x1b[0m";
251
- }
252
- return result;
253
- }
254
-
255
- function wrapWithBorder(lines: string[], width: number, theme: Theme): string[] {
256
- const innerWidth = Math.max(1, width - 2);
257
- const contentWidth = Math.max(1, innerWidth - 2); // minus 1 space padding each side
258
- const left = theme.fg("dim", "│");
259
- const right = theme.fg("dim", "│");
260
- const top = theme.fg("dim", `┌${"─".repeat(innerWidth)}┐`);
261
- const bottom = theme.fg("dim", `└${"─".repeat(innerWidth)}┘`);
262
- const result: string[] = [top];
263
- for (const line of lines) {
264
- const clamped = hardTruncate(line, contentWidth);
265
- const padded = " " + padEnd(clamped, contentWidth) + " ";
266
- result.push(left + padded + right);
267
- }
268
- result.push(bottom);
269
- return result;
270
- }
271
183
  // ── List screen ─────────────────────────────────────────────────────────────
272
184
 
273
185
  function renderList(state: ManagerState, width: number, theme: Theme): string[] {
@@ -1248,9 +1160,11 @@ export function saveAgent(state: ManagerState, requestRender: () => void): void
1248
1160
  const newName = agent.name.endsWith(".md") ? agent.name : `${agent.name}.md`;
1249
1161
  const newPath = path.join(dir, newName);
1250
1162
 
1251
- // Capture model before entering the try block so it is available in the
1252
- // catch block for .md rollback if a later step (JSON write, etc.) fails.
1163
+ // Capture model and thinking before entering the try block so they are
1164
+ // available in the catch block for .md rollback if a later step
1165
+ // (JSON write, etc.) fails.
1253
1166
  const model = agent.model;
1167
+ const thinking = agent.thinking;
1254
1168
 
1255
1169
  // Track whether the .md write succeeded so the catch block knows whether
1256
1170
  // to restore or clean up the on-disk file.
@@ -1277,11 +1191,13 @@ export function saveAgent(state: ManagerState, requestRender: () => void): void
1277
1191
  // Ensure directory exists
1278
1192
  fs.mkdirSync(dir, { recursive: true });
1279
1193
 
1280
- // Build a shallow copy without the model for .md serialization so the
1281
- // live edit object is NOT mutated during serialization. If the .md
1282
- // write fails below, the live object stays intact for a retry.
1194
+ // Build a shallow copy without the model/thinking for .md serialization
1195
+ // (both fields are stored in agents.local.json) so the live edit object
1196
+ // is NOT mutated during serialization. If the .md write fails below, the
1197
+ // live object stays intact for a retry.
1283
1198
  const mdAgent = { ...agent };
1284
1199
  delete mdAgent.model;
1200
+ delete mdAgent.thinking;
1285
1201
 
1286
1202
  // Serialize and write the .md file FIRST. If this fails, no JSON state
1287
1203
  // is persisted and the live edit object is untouched.
@@ -1293,21 +1209,27 @@ export function saveAgent(state: ManagerState, requestRender: () => void): void
1293
1209
  // Capture a backup of the current JSON config so we can roll it back
1294
1210
  // if a later step (re-discovery, etc.) fails after this write succeeds.
1295
1211
  jsonConfigBefore = readLocalConfig();
1296
- // Write/remove the NEW name entry first, then clean up the OLD name.
1212
+ // Write/remove the NEW name entries first, then clean up the OLD name.
1297
1213
  if (model !== undefined) {
1298
1214
  writeLocalModel(agent.name, model);
1299
1215
  } else {
1300
1216
  deleteLocalModel(agent.name);
1301
1217
  }
1218
+ if (thinking !== undefined) {
1219
+ writeLocalThinking(agent.name, thinking);
1220
+ } else {
1221
+ deleteLocalThinking(agent.name);
1222
+ }
1302
1223
  jsonWritten = true;
1303
1224
 
1304
1225
  // Handle rename: delete old JSON entry keyed by original name (after
1305
- // the new entry is safely written). Wrapped in try-catch so a failure
1306
- // here does not leave the .md written but the live object un-stripped.
1226
+ // the new entries are safely written) deleteLocalAgent covers all
1227
+ // fields (model and thinking). Wrapped in try-catch so a failure here
1228
+ // does not leave the .md written but the live object un-stripped.
1307
1229
  const originalName = state.editOriginal?.name;
1308
1230
  if (originalName && originalName !== agent.name) {
1309
1231
  try {
1310
- deleteLocalModel(originalName);
1232
+ deleteLocalAgent(originalName);
1311
1233
  } catch {
1312
1234
  // Best-effort: stale entry is harmless and will be cleaned up on
1313
1235
  // a subsequent save/rename
@@ -1348,22 +1270,24 @@ export function saveAgent(state: ManagerState, requestRender: () => void): void
1348
1270
  state.editError = null;
1349
1271
  requestRender();
1350
1272
 
1351
- // Only strip model from the live edit object AFTER all operations
1352
- // (including re-discovery) have succeeded. This ensures that if any
1353
- // step fails, the catch block can restore the model to .md and the
1354
- // live object retains it for a safe retry.
1273
+ // Only strip model/thinking from the live edit object AFTER all
1274
+ // operations (including re-discovery) have succeeded. This ensures
1275
+ // that if any step fails, the catch block can restore them to .md
1276
+ // and the live object retains them for a safe retry.
1355
1277
  delete agent.model;
1278
+ delete agent.thinking;
1356
1279
  } catch (err) {
1357
1280
  // Restore prior .md state if the write succeeded but a later step
1358
1281
  // (JSON write, re-discovery, etc.) failed:
1359
1282
  // - rename (old file not yet unlinked): check whether the old file
1360
1283
  // still exists. If so, delete newPath so only the original remains.
1361
1284
  // If the old file is gone (deleted externally or by a prior attempt),
1362
- // newPath may be the sole copy — keep it with a model fallback, or
1363
- // delete it when there is no model to fall back on.
1285
+ // newPath may be the sole copy — keep it with a model/thinking
1286
+ // fallback, or delete it when there is no model/thinking to fall
1287
+ // back on.
1364
1288
  // - existing file: write the original content back verbatim.
1365
- // - new file with a model: keep a frontmatter fallback so the model
1366
- // override survives for the next retry.
1289
+ // - new file with model/thinking: keep a frontmatter fallback so the
1290
+ // overrides survive for the next retry.
1367
1291
  // If the old .md was already unlinked during rename (oldPathDeleted),
1368
1292
  // newPath is the sole surviving copy — leave it in place.
1369
1293
  // If the .md write itself failed (mdWritten is false) there is nothing
@@ -1373,19 +1297,25 @@ export function saveAgent(state: ManagerState, requestRender: () => void): void
1373
1297
  if (oldPath && fs.existsSync(oldPath)) {
1374
1298
  // Old file still exists — safe to delete newPath and restore prior state
1375
1299
  try { fs.unlinkSync(newPath); } catch { /* best-effort */ }
1376
- } else if (model !== undefined) {
1377
- // Old file is gone — keep newPath with model as fallback
1378
- try { fs.writeFileSync(newPath, serializeAgent({ ...agent, model }), "utf-8"); } catch { /* best-effort */ }
1300
+ } else if (model !== undefined || thinking !== undefined) {
1301
+ // Old file is gone — keep newPath with model/thinking as fallback
1302
+ const fallback: AgentConfig = { ...agent };
1303
+ if (model !== undefined) fallback.model = model;
1304
+ if (thinking !== undefined) fallback.thinking = thinking;
1305
+ try { fs.writeFileSync(newPath, serializeAgent(fallback), "utf-8"); } catch { /* best-effort */ }
1379
1306
  } else {
1380
- // Old file is gone and no model — delete newPath (no prior state to restore)
1307
+ // Old file is gone and no model/thinking — delete newPath (no prior state to restore)
1381
1308
  try { fs.unlinkSync(newPath); } catch { /* best-effort */ }
1382
1309
  }
1383
1310
  } else if (originalContent !== undefined) {
1384
1311
  try { fs.writeFileSync(newPath, originalContent, "utf-8"); } catch { /* best-effort */ }
1385
- } else if (model !== undefined) {
1386
- try { fs.writeFileSync(newPath, serializeAgent({ ...agent, model }), "utf-8"); } catch { /* best-effort */ }
1312
+ } else if (model !== undefined || thinking !== undefined) {
1313
+ const fallback: AgentConfig = { ...agent };
1314
+ if (model !== undefined) fallback.model = model;
1315
+ if (thinking !== undefined) fallback.thinking = thinking;
1316
+ try { fs.writeFileSync(newPath, serializeAgent(fallback), "utf-8"); } catch { /* best-effort */ }
1387
1317
  } else {
1388
- // New file without model — delete it (no prior state to restore)
1318
+ // New file without model/thinking — delete it (no prior state to restore)
1389
1319
  try { fs.unlinkSync(newPath); } catch { /* best-effort */ }
1390
1320
  }
1391
1321
  }
@@ -14,6 +14,7 @@ process.env.PI_CODING_AGENT_DIR = testDir;
14
14
  function makeAgent(
15
15
  name: string,
16
16
  model?: string,
17
+ thinking?: string,
17
18
  ): AgentConfig {
18
19
  return {
19
20
  name,
@@ -22,6 +23,7 @@ function makeAgent(
22
23
  source: "global" as const,
23
24
  filePath: join(testDir, `${name}.md`),
24
25
  ...(model !== undefined ? { model } : {}),
26
+ ...(thinking !== undefined ? { thinking } : {}),
25
27
  };
26
28
  }
27
29
 
@@ -62,6 +64,41 @@ describe("applyLocalOverrides", () => {
62
64
  expect(() => applyLocalOverrides([])).not.toThrow();
63
65
  });
64
66
 
67
+ it("sets thinking from JSON override", () => {
68
+ const path = join(testDir, "agents.local.json");
69
+ writeFileSync(path, JSON.stringify({ codex: { thinking: "high" } }), "utf-8");
70
+
71
+ const agent = makeAgent("codex");
72
+ applyLocalOverrides([agent]);
73
+ expect(agent.thinking).toBe("high");
74
+ });
75
+
76
+ it("leaves thinking unchanged when no JSON entry exists", () => {
77
+ const agent = makeAgent("codex", undefined, "low");
78
+ applyLocalOverrides([agent]);
79
+ expect(agent.thinking).toBe("low");
80
+ });
81
+
82
+ it("leaves thinking unchanged when JSON entry has no thinking field (model still applied)", () => {
83
+ const path = join(testDir, "agents.local.json");
84
+ writeFileSync(path, JSON.stringify({ codex: { model: "o1" } }), "utf-8");
85
+
86
+ const agent = makeAgent("codex", "M1", "low");
87
+ applyLocalOverrides([agent]);
88
+ expect(agent.thinking).toBe("low");
89
+ expect(agent.model).toBe("o1");
90
+ });
91
+
92
+ it("applies model and thinking together when both are present", () => {
93
+ const path = join(testDir, "agents.local.json");
94
+ writeFileSync(path, JSON.stringify({ codex: { model: "o1", thinking: "high" } }), "utf-8");
95
+
96
+ const agent = makeAgent("codex", "M1", "low");
97
+ applyLocalOverrides([agent]);
98
+ expect(agent.model).toBe("o1");
99
+ expect(agent.thinking).toBe("high");
100
+ });
101
+
65
102
  it("works with corrupt JSON file (agent model stays unchanged)", () => {
66
103
  const path = join(testDir, "agents.local.json");
67
104
  writeFileSync(path, "{ broken json", "utf-8");
package/src/agents.ts CHANGED
@@ -138,8 +138,10 @@ export interface AgentsDiscoveryResult {
138
138
  }
139
139
 
140
140
  /**
141
- * Apply local model overrides from agents.local.json to a list of agents.
142
- * Reads the config once and mutates matching agents in place.
141
+ * Apply local overrides from agents.local.json (model and thinking) to a list of agents.
142
+ * Reads the config once and mutates matching agents in place. JSON takes
143
+ * precedence over the agent's .md values; a missing entry or field leaves the
144
+ * agent's value untouched.
143
145
  */
144
146
  export function applyLocalOverrides(agents: AgentConfig[]): void {
145
147
  const config = readLocalConfig();
@@ -148,6 +150,9 @@ export function applyLocalOverrides(agents: AgentConfig[]): void {
148
150
  if (local?.model !== undefined) {
149
151
  agent.model = local.model;
150
152
  }
153
+ if (local?.thinking !== undefined) {
154
+ agent.thinking = local.thinking;
155
+ }
151
156
  }
152
157
  }
153
158