@pi-archimedes/subagent 2.2.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.
@@ -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
+ }
@@ -126,13 +126,15 @@ describe("buildActivityLine", () => {
126
126
  expect(result).toContain("bash");
127
127
  });
128
128
 
129
- it("shows ' Starting...' when running with no info", () => {
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
- expect(result).toContain("↳ Starting...");
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("calls setText with one line per result", () => {
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
- // Two results joined by newline
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).toBeGreaterThanOrEqual(2);
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 " " should be <= 80
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
  ),