@pi-archimedes/subagent 2.1.0 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -2
- package/src/agent-manager.ts +12 -1767
- package/src/agent-panel.ts +1442 -0
- package/src/agent-store.ts +309 -0
- package/src/compact.test.ts +19 -8
- package/src/compact.ts +141 -161
- package/src/expanded.ts +16 -26
- package/src/index.ts +10 -39
- package/src/tool-schema.ts +33 -0
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent store — file I/O for the Agent Manager.
|
|
3
|
+
* Handles save logic: .md serialization, agents.local.json update, and rollback.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import * as fs from "node:fs";
|
|
7
|
+
import * as path from "node:path";
|
|
8
|
+
import type { AgentConfig } from "./agents.js";
|
|
9
|
+
import { discoverAgentsAll } from "./agents.js";
|
|
10
|
+
import { serializeAgent, validateAgentName } from "./frontmatter-io.js";
|
|
11
|
+
import {
|
|
12
|
+
writeLocalModel,
|
|
13
|
+
deleteLocalModel,
|
|
14
|
+
writeLocalThinking,
|
|
15
|
+
deleteLocalThinking,
|
|
16
|
+
deleteLocalAgent,
|
|
17
|
+
readLocalConfig,
|
|
18
|
+
setLocalConfig,
|
|
19
|
+
type LocalConfig,
|
|
20
|
+
} from "./local-config.js";
|
|
21
|
+
|
|
22
|
+
// ── Types shared between store and panel ───────────────────────────────────
|
|
23
|
+
|
|
24
|
+
interface ModelInfo {
|
|
25
|
+
id: string;
|
|
26
|
+
provider: string;
|
|
27
|
+
fullId: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface ToolInfo {
|
|
31
|
+
name: string;
|
|
32
|
+
description: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ManagerState {
|
|
36
|
+
screen: "list" | "detail" | "edit" | "name-input" | "confirm-delete";
|
|
37
|
+
agents: AgentConfig[];
|
|
38
|
+
globalAgents: AgentConfig[];
|
|
39
|
+
userAgents: AgentConfig[];
|
|
40
|
+
projectAgents: AgentConfig[];
|
|
41
|
+
globalDir: string | null;
|
|
42
|
+
userDir: string;
|
|
43
|
+
projectDir: string | null;
|
|
44
|
+
|
|
45
|
+
// List state
|
|
46
|
+
listCursor: number;
|
|
47
|
+
listScroll: number;
|
|
48
|
+
filterQuery: string;
|
|
49
|
+
filterMode: boolean;
|
|
50
|
+
|
|
51
|
+
// Detail state
|
|
52
|
+
detailAgent: AgentConfig | null;
|
|
53
|
+
detailScroll: number;
|
|
54
|
+
|
|
55
|
+
// Edit state
|
|
56
|
+
editAgent: AgentConfig | null;
|
|
57
|
+
editFieldIndex: number;
|
|
58
|
+
editInField: boolean;
|
|
59
|
+
editDirty: boolean;
|
|
60
|
+
editFieldCursor: number;
|
|
61
|
+
editPromptMode: boolean;
|
|
62
|
+
editPromptCursor: number;
|
|
63
|
+
editPromptScroll: number;
|
|
64
|
+
editDiscardPrompt: boolean;
|
|
65
|
+
editError: string | null;
|
|
66
|
+
editOriginal: AgentConfig | null;
|
|
67
|
+
editReturnScreen: "list" | "detail" | "name-input";
|
|
68
|
+
|
|
69
|
+
// Name input state
|
|
70
|
+
nameInputBuffer: string;
|
|
71
|
+
nameInputCursor: number;
|
|
72
|
+
nameInputScope: "global" | "user" | "project";
|
|
73
|
+
nameInputMode: "new" | "clone";
|
|
74
|
+
nameInputSource: AgentConfig | null;
|
|
75
|
+
nameInputError: string | null;
|
|
76
|
+
|
|
77
|
+
// Model picker state
|
|
78
|
+
models: ModelInfo[];
|
|
79
|
+
modelPickerOpen: boolean;
|
|
80
|
+
modelSearchQuery: string;
|
|
81
|
+
modelCursor: number;
|
|
82
|
+
filteredModels: ModelInfo[];
|
|
83
|
+
|
|
84
|
+
// Tool picker state
|
|
85
|
+
tools: ToolInfo[];
|
|
86
|
+
toolPickerOpen: boolean;
|
|
87
|
+
toolCursor: number;
|
|
88
|
+
toolSelected: Set<string>;
|
|
89
|
+
toolSearch: string;
|
|
90
|
+
filteredTools: ToolInfo[];
|
|
91
|
+
|
|
92
|
+
// Confirm delete state
|
|
93
|
+
deleteTarget: AgentConfig | null;
|
|
94
|
+
deleteFromScreen: "list" | "detail";
|
|
95
|
+
|
|
96
|
+
// New agent tracking
|
|
97
|
+
isNew: boolean;
|
|
98
|
+
|
|
99
|
+
// Render width (stored so input handlers can compute correct scroll bounds)
|
|
100
|
+
lastWidth: number;
|
|
101
|
+
lastContentWidth: number;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ── Save logic ──────────────────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
export function saveAgent(state: ManagerState, requestRender: () => void): void {
|
|
107
|
+
if (!state.editAgent) return;
|
|
108
|
+
|
|
109
|
+
const agent = state.editAgent;
|
|
110
|
+
|
|
111
|
+
// Validate name
|
|
112
|
+
const nameError = validateAgentName(agent.name);
|
|
113
|
+
if (nameError) {
|
|
114
|
+
state.editError = nameError;
|
|
115
|
+
requestRender();
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Check duplicate name within same scope
|
|
120
|
+
const duplicate = state.agents.find(
|
|
121
|
+
(a) => a.source === agent.source && a.name === agent.name && a.filePath !== agent.filePath,
|
|
122
|
+
);
|
|
123
|
+
if (duplicate) {
|
|
124
|
+
state.editError = `Agent "${agent.name}" already exists in ${agent.source} scope`;
|
|
125
|
+
requestRender();
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Determine target directory
|
|
130
|
+
const dir = agent.source === "global" ? state.globalDir
|
|
131
|
+
: agent.source === "user" ? state.userDir
|
|
132
|
+
: state.projectDir;
|
|
133
|
+
if (!dir) {
|
|
134
|
+
state.editError = "Target directory not available";
|
|
135
|
+
requestRender();
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const oldPath = agent.filePath;
|
|
140
|
+
const newName = agent.name.endsWith(".md") ? agent.name : `${agent.name}.md`;
|
|
141
|
+
const newPath = path.join(dir, newName);
|
|
142
|
+
|
|
143
|
+
// Capture model and thinking before entering the try block so they are
|
|
144
|
+
// available in the catch block for .md rollback if a later step
|
|
145
|
+
// (JSON write, etc.) fails.
|
|
146
|
+
const model = agent.model;
|
|
147
|
+
const thinking = agent.thinking;
|
|
148
|
+
|
|
149
|
+
// Track whether the .md write succeeded so the catch block knows whether
|
|
150
|
+
// to restore or clean up the on-disk file.
|
|
151
|
+
const isRename = oldPath && oldPath !== newPath;
|
|
152
|
+
let originalContent: string | undefined;
|
|
153
|
+
if (!isRename && fs.existsSync(newPath)) {
|
|
154
|
+
// Read existing .md content so we can restore it verbatim if a later
|
|
155
|
+
// step (JSON write, re-discovery, etc.) fails.
|
|
156
|
+
originalContent = fs.readFileSync(newPath, "utf-8");
|
|
157
|
+
}
|
|
158
|
+
let mdWritten = false;
|
|
159
|
+
// Track whether the old .md file was already removed during a rename so
|
|
160
|
+
// the catch block knows whether newPath is the sole surviving copy.
|
|
161
|
+
let oldPathDeleted = false;
|
|
162
|
+
// Track whether the JSON config write succeeded so the catch block can
|
|
163
|
+
// roll it back if a later step (re-discovery, etc.) fails.
|
|
164
|
+
let jsonWritten = false;
|
|
165
|
+
// Snapshot of the JSON config captured before the write so the catch
|
|
166
|
+
// block can restore it. Declared here (not inside try) so it is
|
|
167
|
+
// accessible in the catch block.
|
|
168
|
+
let jsonConfigBefore: LocalConfig = {};
|
|
169
|
+
|
|
170
|
+
try {
|
|
171
|
+
// Ensure directory exists
|
|
172
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
173
|
+
|
|
174
|
+
// Build a shallow copy without the model/thinking for .md serialization
|
|
175
|
+
// (both fields are stored in agents.local.json) so the live edit object
|
|
176
|
+
// is NOT mutated during serialization. If the .md write fails below, the
|
|
177
|
+
// live object stays intact for a retry.
|
|
178
|
+
const mdAgent = { ...agent };
|
|
179
|
+
delete mdAgent.model;
|
|
180
|
+
delete mdAgent.thinking;
|
|
181
|
+
|
|
182
|
+
// Serialize and write the .md file FIRST. If this fails, no JSON state
|
|
183
|
+
// is persisted and the live edit object is untouched.
|
|
184
|
+
const content = serializeAgent(mdAgent);
|
|
185
|
+
fs.writeFileSync(newPath, content, "utf-8");
|
|
186
|
+
mdWritten = true;
|
|
187
|
+
|
|
188
|
+
// Only after the .md write succeeds, perform JSON store mutations.
|
|
189
|
+
// Capture a backup of the current JSON config so we can roll it back
|
|
190
|
+
// if a later step (re-discovery, etc.) fails after this write succeeds.
|
|
191
|
+
jsonConfigBefore = readLocalConfig();
|
|
192
|
+
// Write/remove the NEW name entries first, then clean up the OLD name.
|
|
193
|
+
if (model !== undefined) {
|
|
194
|
+
writeLocalModel(agent.name, model);
|
|
195
|
+
} else {
|
|
196
|
+
deleteLocalModel(agent.name);
|
|
197
|
+
}
|
|
198
|
+
if (thinking !== undefined) {
|
|
199
|
+
writeLocalThinking(agent.name, thinking);
|
|
200
|
+
} else {
|
|
201
|
+
deleteLocalThinking(agent.name);
|
|
202
|
+
}
|
|
203
|
+
jsonWritten = true;
|
|
204
|
+
|
|
205
|
+
// Handle rename: delete old JSON entry keyed by original name (after
|
|
206
|
+
// the new entries are safely written) — deleteLocalAgent covers all
|
|
207
|
+
// fields (model and thinking). Wrapped in try-catch so a failure here
|
|
208
|
+
// does not leave the .md written but the live object un-stripped.
|
|
209
|
+
const originalName = state.editOriginal?.name;
|
|
210
|
+
if (originalName && originalName !== agent.name) {
|
|
211
|
+
try {
|
|
212
|
+
deleteLocalAgent(originalName);
|
|
213
|
+
} catch {
|
|
214
|
+
// Best-effort: stale entry is harmless and will be cleaned up on
|
|
215
|
+
// a subsequent save/rename
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Handle rename: delete old .md file if name changed
|
|
220
|
+
if (oldPath && oldPath !== newPath) {
|
|
221
|
+
try {
|
|
222
|
+
fs.unlinkSync(oldPath);
|
|
223
|
+
oldPathDeleted = true;
|
|
224
|
+
} catch {
|
|
225
|
+
// Old file may not exist (e.g., new agent)
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Update filePath
|
|
230
|
+
agent.filePath = newPath;
|
|
231
|
+
|
|
232
|
+
// Refresh agents list
|
|
233
|
+
const cwd = process.cwd();
|
|
234
|
+
const discovery = discoverAgentsAll(cwd);
|
|
235
|
+
state.globalAgents = discovery.global;
|
|
236
|
+
state.userAgents = discovery.user;
|
|
237
|
+
state.projectAgents = discovery.project;
|
|
238
|
+
state.globalDir = discovery.globalDir;
|
|
239
|
+
state.agents = [...discovery.global, ...discovery.user, ...discovery.project];
|
|
240
|
+
|
|
241
|
+
// Find the saved agent and switch to detail
|
|
242
|
+
const savedAgent = state.agents.find((a) => a.name === agent.name && a.source === agent.source);
|
|
243
|
+
if (savedAgent) {
|
|
244
|
+
state.detailAgent = savedAgent;
|
|
245
|
+
state.detailScroll = 0;
|
|
246
|
+
state.screen = "detail";
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
state.editDirty = false;
|
|
250
|
+
state.editError = null;
|
|
251
|
+
requestRender();
|
|
252
|
+
|
|
253
|
+
// Only strip model/thinking from the live edit object AFTER all
|
|
254
|
+
// operations (including re-discovery) have succeeded. This ensures
|
|
255
|
+
// that if any step fails, the catch block can restore them to .md
|
|
256
|
+
// and the live object retains them for a safe retry.
|
|
257
|
+
delete agent.model;
|
|
258
|
+
delete agent.thinking;
|
|
259
|
+
} catch (err) {
|
|
260
|
+
// Restore prior .md state if the write succeeded but a later step
|
|
261
|
+
// (JSON write, re-discovery, etc.) failed:
|
|
262
|
+
// - rename (old file not yet unlinked): check whether the old file
|
|
263
|
+
// still exists. If so, delete newPath so only the original remains.
|
|
264
|
+
// If the old file is gone (deleted externally or by a prior attempt),
|
|
265
|
+
// newPath may be the sole copy — keep it with a model/thinking
|
|
266
|
+
// fallback, or delete it when there is no model/thinking to fall
|
|
267
|
+
// back on.
|
|
268
|
+
// - existing file: write the original content back verbatim.
|
|
269
|
+
// - new file with model/thinking: keep a frontmatter fallback so the
|
|
270
|
+
// overrides survive for the next retry.
|
|
271
|
+
// If the old .md was already unlinked during rename (oldPathDeleted),
|
|
272
|
+
// newPath is the sole surviving copy — leave it in place.
|
|
273
|
+
// If the .md write itself failed (mdWritten is false) there is nothing
|
|
274
|
+
// to restore on disk.
|
|
275
|
+
if (mdWritten) {
|
|
276
|
+
if (isRename && !oldPathDeleted) {
|
|
277
|
+
if (oldPath && fs.existsSync(oldPath)) {
|
|
278
|
+
// Old file still exists — safe to delete newPath and restore prior state
|
|
279
|
+
try { fs.unlinkSync(newPath); } catch { /* best-effort */ }
|
|
280
|
+
} else if (model !== undefined || thinking !== undefined) {
|
|
281
|
+
// Old file is gone — keep newPath with model/thinking as fallback
|
|
282
|
+
const fallback: AgentConfig = { ...agent };
|
|
283
|
+
if (model !== undefined) fallback.model = model;
|
|
284
|
+
if (thinking !== undefined) fallback.thinking = thinking;
|
|
285
|
+
try { fs.writeFileSync(newPath, serializeAgent(fallback), "utf-8"); } catch { /* best-effort */ }
|
|
286
|
+
} else {
|
|
287
|
+
// Old file is gone and no model/thinking — delete newPath (no prior state to restore)
|
|
288
|
+
try { fs.unlinkSync(newPath); } catch { /* best-effort */ }
|
|
289
|
+
}
|
|
290
|
+
} else if (originalContent !== undefined) {
|
|
291
|
+
try { fs.writeFileSync(newPath, originalContent, "utf-8"); } catch { /* best-effort */ }
|
|
292
|
+
} else if (model !== undefined || thinking !== undefined) {
|
|
293
|
+
const fallback: AgentConfig = { ...agent };
|
|
294
|
+
if (model !== undefined) fallback.model = model;
|
|
295
|
+
if (thinking !== undefined) fallback.thinking = thinking;
|
|
296
|
+
try { fs.writeFileSync(newPath, serializeAgent(fallback), "utf-8"); } catch { /* best-effort */ }
|
|
297
|
+
} else {
|
|
298
|
+
// New file without model/thinking — delete it (no prior state to restore)
|
|
299
|
+
try { fs.unlinkSync(newPath); } catch { /* best-effort */ }
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
// Roll back JSON if it was written but a later step failed
|
|
303
|
+
if (jsonWritten) {
|
|
304
|
+
try { setLocalConfig(jsonConfigBefore); } catch { /* best-effort */ }
|
|
305
|
+
}
|
|
306
|
+
state.editError = err instanceof Error ? err.message : "Failed to save agent";
|
|
307
|
+
requestRender();
|
|
308
|
+
}
|
|
309
|
+
}
|
package/src/compact.test.ts
CHANGED
|
@@ -126,13 +126,15 @@ describe("buildActivityLine", () => {
|
|
|
126
126
|
expect(result).toContain("bash");
|
|
127
127
|
});
|
|
128
128
|
|
|
129
|
-
it("shows '
|
|
129
|
+
it("shows '▸ Starting...' when running with no info", () => {
|
|
130
130
|
const { theme } = makeMockTheme();
|
|
131
131
|
const result = buildActivityLine(
|
|
132
132
|
activityData({ status: "running" }),
|
|
133
133
|
theme,
|
|
134
134
|
);
|
|
135
|
-
|
|
135
|
+
// Glyph and label are rendered as separate themed fragments.
|
|
136
|
+
expect(result).toContain("▸ ");
|
|
137
|
+
expect(result).toContain("Starting...");
|
|
136
138
|
});
|
|
137
139
|
|
|
138
140
|
it("shows first line of final output when available", () => {
|
|
@@ -326,7 +328,7 @@ describe("renderCompactParallel", () => {
|
|
|
326
328
|
};
|
|
327
329
|
}
|
|
328
330
|
|
|
329
|
-
it("
|
|
331
|
+
it("renders a full 3-line block per agent (parity with single view)", () => {
|
|
330
332
|
const { theme } = makeMockTheme();
|
|
331
333
|
const text = new MockText();
|
|
332
334
|
const details = makeDetails();
|
|
@@ -337,9 +339,18 @@ describe("renderCompactParallel", () => {
|
|
|
337
339
|
const output = (text as unknown as { getContent(): string }).getContent();
|
|
338
340
|
expect(output).toContain("agent-a");
|
|
339
341
|
expect(output).toContain("agent-b");
|
|
340
|
-
//
|
|
342
|
+
// Each agent is a 3-line block (label / model+stats / activity); with two
|
|
343
|
+
// agents that is 6 lines total — no status-glyph prefix on the label line.
|
|
341
344
|
const lines = output.split("\n");
|
|
342
|
-
expect(lines.length).
|
|
345
|
+
expect(lines.length).toBe(6);
|
|
346
|
+
// Status shows only in each agent's activity line, not as a label prefix:
|
|
347
|
+
// agent-a succeeded → "✓ Done"; agent-b failed with an error string →
|
|
348
|
+
// "✗ <error>" (the raw error, here "failed").
|
|
349
|
+
expect(output).toContain("✓ Done");
|
|
350
|
+
expect(output).toContain("✗ ");
|
|
351
|
+
// The label lines carry no leading status glyph.
|
|
352
|
+
expect(lines[0]).not.toContain("✓");
|
|
353
|
+
expect(lines[3]).not.toContain("✗");
|
|
343
354
|
});
|
|
344
355
|
|
|
345
356
|
it("returns the text instance", () => {
|
|
@@ -409,8 +420,8 @@ describe("buildActivityLine property tests", () => {
|
|
|
409
420
|
);
|
|
410
421
|
// Strip ANSI tags to get visible text
|
|
411
422
|
const visible = result.replace(/\[muted\]/g, "").replace(/\[\/muted\]/g, "");
|
|
412
|
-
// The visible text after "
|
|
413
|
-
const afterArrow = visible.replace(
|
|
423
|
+
// The visible text after the running glyph "▸ " should be <= 80
|
|
424
|
+
const afterArrow = visible.replace(/^▸ /, "");
|
|
414
425
|
expect(afterArrow.length).toBeLessThanOrEqual(80);
|
|
415
426
|
},
|
|
416
427
|
),
|
|
@@ -428,7 +439,7 @@ describe("buildActivityLine property tests", () => {
|
|
|
428
439
|
theme,
|
|
429
440
|
);
|
|
430
441
|
const visible = result.replace(/\[dim\]/g, "").replace(/\[\/dim\]/g, "");
|
|
431
|
-
const afterArrow = visible.replace(
|
|
442
|
+
const afterArrow = visible.replace(/^▸ /, "");
|
|
432
443
|
expect(afterArrow.length).toBeLessThanOrEqual(60);
|
|
433
444
|
},
|
|
434
445
|
),
|