@buihongduc132/pi-acp-agents 0.3.1

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.
Files changed (43) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/LICENSE +21 -0
  3. package/README.md +359 -0
  4. package/index.ts +1521 -0
  5. package/package.json +103 -0
  6. package/skills/pi-acp-agents/SKILL.md +112 -0
  7. package/src/acp-widget.ts +379 -0
  8. package/src/adapter-factory.ts +55 -0
  9. package/src/adapters/acpx.ts +215 -0
  10. package/src/adapters/base.ts +117 -0
  11. package/src/adapters/codex.ts +77 -0
  12. package/src/adapters/custom.ts +14 -0
  13. package/src/adapters/gemini.ts +66 -0
  14. package/src/adapters/opencode.ts +101 -0
  15. package/src/config/config.ts +312 -0
  16. package/src/config/types.ts +203 -0
  17. package/src/coordination/alias-resolver.ts +208 -0
  18. package/src/coordination/coordinator.ts +266 -0
  19. package/src/coordination/worker-dispatcher.ts +191 -0
  20. package/src/core/async-executor.ts +149 -0
  21. package/src/core/circuit-breaker.ts +254 -0
  22. package/src/core/client.ts +661 -0
  23. package/src/core/health-monitor.ts +200 -0
  24. package/src/core/protocol-validator.ts +259 -0
  25. package/src/core/session-lifecycle.ts +46 -0
  26. package/src/core/session-manager.ts +64 -0
  27. package/src/extension-safety.ts +200 -0
  28. package/src/logger.ts +92 -0
  29. package/src/management/event-log.ts +31 -0
  30. package/src/management/governance-store.ts +123 -0
  31. package/src/management/heartbeat-parser.ts +92 -0
  32. package/src/management/mailbox-manager.ts +95 -0
  33. package/src/management/runtime-paths.ts +34 -0
  34. package/src/management/safe-mkdir.ts +78 -0
  35. package/src/management/session-archive-store.ts +136 -0
  36. package/src/management/session-name-store.ts +88 -0
  37. package/src/management/task-store.ts +260 -0
  38. package/src/management/worker-store.ts +164 -0
  39. package/src/public-api.ts +72 -0
  40. package/src/settings/agent-config-tui.ts +456 -0
  41. package/src/settings/agents-command.ts +138 -0
  42. package/src/settings/config.ts +201 -0
  43. package/src/settings/configure-tui.ts +135 -0
@@ -0,0 +1,164 @@
1
+ /**
2
+ * pi-acp-agents — Worker Store (M6: Worker Lifecycle)
3
+ *
4
+ * File-backed store for persistent worker identities.
5
+ * Same pattern as AcpTaskStore and MailboxManager.
6
+ */
7
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
8
+ import { ensureRuntimeDir } from "./runtime-paths.js";
9
+ import { createNoopLogger } from "../logger.js";
10
+ import type { AcpWorkerRecord, AcpWorkerStatus } from "../config/types.js";
11
+
12
+ const log = createNoopLogger();
13
+
14
+ interface WorkerPayload {
15
+ workers: AcpWorkerRecord[];
16
+ }
17
+
18
+ const DEFAULT_PAYLOAD: WorkerPayload = { workers: [] };
19
+
20
+ export class WorkerStore {
21
+ constructor(private rootDir?: string) {}
22
+
23
+ register(input: { name: string; sessionId: string; agentName: string }): AcpWorkerRecord {
24
+ const payload = this.read();
25
+ const existing = payload.workers.find((w) => w.name === input.name);
26
+ if (existing) {
27
+ existing.sessionId = input.sessionId;
28
+ existing.status = "online";
29
+ existing.lastActivityAt = new Date().toISOString();
30
+ this.write(payload);
31
+ return existing;
32
+ }
33
+ const now = new Date().toISOString();
34
+ const worker: AcpWorkerRecord = {
35
+ name: input.name,
36
+ sessionId: input.sessionId,
37
+ agentName: input.agentName,
38
+ status: "online",
39
+ spawnedAt: now,
40
+ lastActivityAt: now,
41
+ metadata: {},
42
+ };
43
+ payload.workers.push(worker);
44
+ this.write(payload);
45
+ return worker;
46
+ }
47
+
48
+ get(name: string): AcpWorkerRecord | undefined {
49
+ return this.read().workers.find((w) => w.name === name);
50
+ }
51
+
52
+ list(options?: { status?: AcpWorkerStatus }): AcpWorkerRecord[] {
53
+ const workers = this.read().workers;
54
+ if (options?.status) return workers.filter((w) => w.status === options.status);
55
+ return workers;
56
+ }
57
+
58
+ updateStatus(name: string, status: AcpWorkerStatus): AcpWorkerRecord {
59
+ const payload = this.read();
60
+ const worker = payload.workers.find((w) => w.name === name);
61
+ if (!worker) throw new Error(`Worker "${name}" not found`);
62
+ worker.status = status;
63
+ worker.lastActivityAt = new Date().toISOString();
64
+ this.write(payload);
65
+ return worker;
66
+ }
67
+
68
+ assignTask(name: string, taskId: string): void {
69
+ const payload = this.read();
70
+ const worker = payload.workers.find((w) => w.name === name);
71
+ if (!worker) throw new Error(`Worker "${name}" not found`);
72
+ worker.currentTaskId = taskId;
73
+ this.write(payload);
74
+ }
75
+
76
+ touch(name: string, deltas?: { tokenDelta?: number; toolCallDelta?: number }): AcpWorkerRecord {
77
+ const payload = this.read();
78
+ const worker = payload.workers.find((w) => w.name === name);
79
+ if (!worker) throw new Error(`Worker "${name}" not found`);
80
+ const now = new Date().toISOString();
81
+ worker.lastHeartbeatAt = now;
82
+ worker.lastActivityAt = now;
83
+ if (deltas?.tokenDelta) {
84
+ worker.tokenCountTotal = (worker.tokenCountTotal ?? 0) + deltas.tokenDelta;
85
+ }
86
+ if (deltas?.toolCallDelta) {
87
+ worker.toolCallCount = (worker.toolCallCount ?? 0) + deltas.toolCallDelta;
88
+ }
89
+ this.write(payload);
90
+ return worker;
91
+ }
92
+
93
+ unassignTask(name: string): AcpWorkerRecord {
94
+ const payload = this.read();
95
+ const worker = payload.workers.find((w) => w.name === name);
96
+ if (!worker) throw new Error(`Worker "${name}" not found`);
97
+ worker.currentTaskId = undefined;
98
+ this.write(payload);
99
+ return worker;
100
+ }
101
+
102
+ unregister(name: string): void {
103
+ const payload = this.read();
104
+ payload.workers = payload.workers.filter((w) => w.name !== name);
105
+ this.write(payload);
106
+ }
107
+
108
+ /** Update worker metadata fields */
109
+ updateMetadata(name: string, metadata: Partial<Record<string, unknown>>): AcpWorkerRecord | undefined {
110
+ const payload = this.read();
111
+ const worker = payload.workers.find((w) => w.name === name);
112
+ if (!worker) return undefined;
113
+ for (const [k, v] of Object.entries(metadata)) {
114
+ if (v === undefined) {
115
+ delete worker.metadata[k];
116
+ } else {
117
+ worker.metadata[k] = v;
118
+ }
119
+ }
120
+ this.write(payload);
121
+ return worker;
122
+ }
123
+
124
+ pruneStale(cutoffMs = 3_600_000): { pruned: string[] } {
125
+ const payload = this.read();
126
+ const cutoff = new Date(Date.now() - cutoffMs);
127
+ const pruned: string[] = [];
128
+ for (const w of payload.workers) {
129
+ if (w.status !== "offline" && new Date(w.lastActivityAt) < cutoff) {
130
+ w.status = "offline";
131
+ pruned.push(w.name);
132
+ }
133
+ }
134
+ this.write(payload);
135
+ return { pruned };
136
+ }
137
+
138
+ countOnline(): number {
139
+ return this.read().workers.filter((w) => w.status !== "offline").length;
140
+ }
141
+
142
+ private read(): WorkerPayload {
143
+ try {
144
+ const paths = ensureRuntimeDir(this.rootDir);
145
+ if (!existsSync(paths.workersFile)) return structuredClone(DEFAULT_PAYLOAD);
146
+ return JSON.parse(readFileSync(paths.workersFile, "utf-8")) as WorkerPayload;
147
+ } catch (e) {
148
+ // File read failed — return default payload
149
+ log.debug("worker-store read failed", e);
150
+ return structuredClone(DEFAULT_PAYLOAD);
151
+ }
152
+ }
153
+
154
+ private write(payload: WorkerPayload): void {
155
+ try {
156
+ const paths = ensureRuntimeDir(this.rootDir);
157
+ writeFileSync(paths.workersFile, JSON.stringify(payload, null, 2) + "\n", "utf-8");
158
+ } catch (e) {
159
+ // File read failed — return default payload
160
+ // EACCES or other FS error — silently degrade. Workers are non-critical runtime state.
161
+ log.debug("worker-store write failed", e);
162
+ }
163
+ }
164
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * pi-acp-agents — Public API surface for extension packages.
3
+ *
4
+ * This is the stable contract that pi-acp-advanced (and other extensions)
5
+ * import from. Breaking changes here require a major version bump.
6
+ *
7
+ * Usage from extension:
8
+ * import { loadConfig, AcpConfig, getRuntimePaths } from "pi-acp-agents";
9
+ */
10
+
11
+ // Config
12
+ export { loadConfig, validateConfig, saveConfig, resolveConfigPath } from "./config/config.js";
13
+
14
+ // Runtime
15
+ export { ensureRuntimeDir } from "./management/runtime-paths.js";
16
+
17
+ // Types (re-exported from pi-acp-types)
18
+ export type {
19
+ AcpConfig,
20
+ AcpAgentConfig,
21
+ AcpAliasConfig,
22
+ AcpPromptResult,
23
+ AcpSessionHandle,
24
+ AcpArchivedSessionMetadata,
25
+ AcpRuntimePaths,
26
+ AcpAdapterOptions,
27
+ Logger,
28
+ CircuitState,
29
+ } from "pi-acp-types";
30
+
31
+ // Core classes (stable API for extensions)
32
+ export { AcpCircuitBreaker } from "./core/circuit-breaker.js";
33
+ export { HealthMonitor } from "./core/health-monitor.js";
34
+ export { SessionManager } from "./core/session-manager.js";
35
+
36
+ // Adapter factory (for extension packages to create agent adapters)
37
+ export { createAdapter } from "./adapter-factory.js";
38
+
39
+ // Coordination
40
+ export { AgentCoordinator } from "./coordination/coordinator.js";
41
+ export { AliasResolver } from "./coordination/alias-resolver.js";
42
+
43
+ // Extension safety (R-SP1, R-SP4)
44
+ export {
45
+ detectBaseLoaded,
46
+ activateExtensionSafely,
47
+ checkVersionCompatibility,
48
+ MIN_BASE_VERSION,
49
+ type BaseDetectionResult,
50
+ type ActivationResult,
51
+ type VersionCheckResult,
52
+ } from "./extension-safety.js";
53
+
54
+ // Logging
55
+ export { createFileLogger, createNoopLogger } from "./logger.js";
56
+
57
+ // Stores (for extension packages to instantiate against shared runtime dir)
58
+ export { AcpTaskStore } from "./management/task-store.js";
59
+ export { MailboxManager } from "./management/mailbox-manager.js";
60
+ export { GovernanceStore } from "./management/governance-store.js";
61
+ export { SessionArchiveStore } from "./management/session-archive-store.js";
62
+ export { SessionNameStore } from "./management/session-name-store.js";
63
+ export { WorkerStore } from "./management/worker-store.js";
64
+ export { AcpEventLog } from "./management/event-log.js";
65
+
66
+ // Version (read at runtime to avoid NodeNext JSON import issues)
67
+ import { readFileSync } from "node:fs";
68
+ import { fileURLToPath } from "node:url";
69
+ import { dirname, join } from "node:path";
70
+ const __filename = fileURLToPath(import.meta.url);
71
+ const __dirname = dirname(__filename);
72
+ export const version: string = JSON.parse(readFileSync(join(__dirname, "../../package.json"), "utf-8")).version;
@@ -0,0 +1,456 @@
1
+ /**
2
+ * agent-config-tui.ts — Interactive TUI panel for managing agent_servers.
3
+ *
4
+ * Pattern: ui.custom() + SettingsList from @mariozechner/pi-tui.
5
+ * Design: rebuild-on-change — SettingsList is static, so on add/remove agent
6
+ * we dispose and rebuild the entire list.
7
+ *
8
+ * CRITICAL: SettingsList.activateItem() gives `submenu` precedence over `values`
9
+ * cycling. When an item has BOTH `submenu` and `values`, Enter always opens
10
+ * the submenu and the values cycle path is unreachable. Therefore:
11
+ * - Agent rows use submenu as an ACTION MENU (Edit/Remove/Set Default/Cancel)
12
+ * - Add Agent uses submenu for manual/preset entry
13
+ * - Default Agent uses `values` only (no submenu)
14
+ */
15
+
16
+ import { Container, Input, type SettingItem, SettingsList, Spacer, Text } from "@mariozechner/pi-tui";
17
+ import {
18
+ loadConfig,
19
+ saveConfig,
20
+ upsertAgentServer,
21
+ removeAgentServer,
22
+ setDefaultAgent,
23
+ detectAvailablePresets,
24
+ } from "../config/config.js";
25
+ import type { AcpAgentConfig, AcpConfig } from "../config/types.js";
26
+
27
+ // ── Types ────────────────────────────────────────────────
28
+
29
+ export type SettingItemWithMeta = SettingItem;
30
+
31
+ export type SettingsUI = {
32
+ custom<T>(
33
+ factory: (tui: any, theme: any, keybindings: any, done: (result: T) => void) => any,
34
+ options?: { overlay?: boolean; overlayOptions?: any },
35
+ ): Promise<T>;
36
+ };
37
+
38
+ // ── Description formatting ───────────────────────────────
39
+
40
+ function formatAgentDescription(agent: AcpAgentConfig): string {
41
+ const parts: string[] = [`command: ${agent.command}`];
42
+ if (agent.args && agent.args.length > 0) {
43
+ parts.push(`args: ${agent.args.join(" ")}`);
44
+ }
45
+ if (agent.default_model) {
46
+ parts.push(`model: ${agent.default_model}`);
47
+ }
48
+ if (agent.default_mode) {
49
+ parts.push(`mode: ${agent.default_mode}`);
50
+ }
51
+ return parts.join(" | ");
52
+ }
53
+
54
+ // ── Build SettingItems from config ───────────────────────
55
+
56
+ /**
57
+ * Build the array of SettingItem entries for the agent config TUI.
58
+ * Exported for testing — pure function, no UI dependency.
59
+ */
60
+ export function buildSettingItems(
61
+ config: AcpConfig,
62
+ detectedPresets: Array<{ name: string; config: AcpAgentConfig }>,
63
+ ): SettingItemWithMeta[] {
64
+ const items: SettingItemWithMeta[] = [];
65
+ const agentNames = Object.keys(config.agent_servers);
66
+
67
+ // Agent items — submenu as action menu (Edit/Remove/Set Default/Cancel)
68
+ for (const name of agentNames) {
69
+ const agent = config.agent_servers[name];
70
+ const isDefault = config.defaultAgent === name;
71
+ items.push({
72
+ id: `agent:${name}`,
73
+ label: `${isDefault ? "★ " : ""}${name}`,
74
+ description: formatAgentDescription(agent),
75
+ currentValue: "Edit",
76
+ // NOTE: values are shown for display but submenu takes precedence on Enter.
77
+ // The submenu acts as the action picker.
78
+ values: ["Edit", "Remove", "Set Default"],
79
+ submenu: (_currentValue, finish) => {
80
+ return createActionMenu(name, agent, isDefault, finish);
81
+ },
82
+ });
83
+ }
84
+
85
+ // Empty state hint
86
+ if (agentNames.length === 0) {
87
+ items.push({
88
+ id: "empty:hint",
89
+ label: "(no agents)",
90
+ description: "No agent servers configured. Use 'Add Agent' below to get started.",
91
+ currentValue: "",
92
+ });
93
+ }
94
+
95
+ // Add Agent section
96
+ const presetNames = detectedPresets.map((p) => p.name);
97
+ const addDescription = presetNames.length > 0
98
+ ? `Detected on PATH: ${presetNames.join(", ")}. Select to add, or enter manually.`
99
+ : "Enter agent name and command manually.";
100
+ items.push({
101
+ id: "preset:add",
102
+ label: "+ Add Agent",
103
+ description: addDescription,
104
+ currentValue: presetNames.length > 0 ? presetNames[0] : "manual",
105
+ values: presetNames.length > 0 ? [...presetNames, "(manual)"] : undefined,
106
+ submenu: (currentValue, finish) => {
107
+ return createAddSubmenu(currentValue, detectedPresets, finish);
108
+ },
109
+ });
110
+
111
+ // Default Agent global setting — values ONLY (no submenu), so cycling works
112
+ if (agentNames.length > 0) {
113
+ items.push({
114
+ id: "global:defaultAgent",
115
+ label: "Default Agent",
116
+ description: "The agent used when no agent name is specified.",
117
+ currentValue: config.defaultAgent ?? "(none)",
118
+ values: [...agentNames, "(none)"],
119
+ });
120
+ }
121
+
122
+ return items;
123
+ }
124
+
125
+ // ── Submenu factories ────────────────────────────────────
126
+
127
+ /**
128
+ * Create an action menu for an agent row.
129
+ * Since SettingsList gives submenu precedence, this IS the interaction surface.
130
+ * Returns a submenu that shows choices and dispatches the selected action.
131
+ */
132
+ function createActionMenu(
133
+ agentName: string,
134
+ agent: AcpAgentConfig,
135
+ isDefault: boolean,
136
+ finish: (value?: string) => void,
137
+ ): { render: (w: number) => string[]; handleInput: (data: string) => void; invalidate: () => void } {
138
+ // Choices: e=edit, r=remove, d=set default, Enter on selection, Esc=cancel
139
+ const choices = [
140
+ { key: "e", label: "Edit", action: "edit" },
141
+ { key: "r", label: "Remove", action: "remove" },
142
+ ];
143
+ if (!isDefault) {
144
+ choices.push({ key: "d", label: "Set Default", action: "setDefault" });
145
+ }
146
+
147
+ let editMode = false;
148
+ let commandInput: Input | undefined;
149
+ let argsInput: Input | undefined;
150
+ let modelInput: Input | undefined;
151
+ let activeField: "command" | "args" | "model" = "command";
152
+
153
+ function initEditFields(): void {
154
+ commandInput = new Input();
155
+ commandInput.setValue(agent.command ?? "");
156
+ commandInput.focused = true;
157
+ argsInput = new Input();
158
+ argsInput.setValue((agent.args ?? []).join(", "));
159
+ modelInput = new Input();
160
+ modelInput.setValue(agent.default_model ?? "");
161
+ activeField = "command";
162
+ editMode = true;
163
+ }
164
+
165
+ function render(w: number): string[] {
166
+ if (editMode && commandInput) {
167
+ const prefix = (label: string, field: string) =>
168
+ field === activeField ? `▸ ${label}: ` : ` ${label}: `;
169
+ return [
170
+ `Edit agent "${agentName}" (Tab=next field, Enter=save, Esc=cancel)`,
171
+ prefix("Command", activeField) + commandInput.render(w).join(""),
172
+ prefix("Args (comma-sep)", activeField) + argsInput!.render(w).join(""),
173
+ prefix("Default model", activeField) + modelInput!.render(w).join(""),
174
+ ];
175
+ }
176
+
177
+ const lines = [
178
+ `Agent: ${agentName} — choose action`,
179
+ "",
180
+ ];
181
+ for (const c of choices) {
182
+ lines.push(` [${c.key}] ${c.label}`);
183
+ }
184
+ lines.push(" [Esc] Cancel");
185
+ return lines;
186
+ }
187
+
188
+ function handleInput(data: string): void {
189
+ if (editMode && commandInput) {
190
+ // Edit mode: Tab/Enter/Esc + field input
191
+ if (data === "\t") {
192
+ activeField = activeField === "command" ? "args" : activeField === "args" ? "model" : "command";
193
+ commandInput.focused = activeField === "command";
194
+ argsInput!.focused = activeField === "args";
195
+ modelInput!.focused = activeField === "model";
196
+ return;
197
+ }
198
+ if (data === "\x1b") {
199
+ finish(undefined);
200
+ return;
201
+ }
202
+ if (data === "\r") {
203
+ const cmd = commandInput.getValue().trim();
204
+ if (!cmd) {
205
+ finish(undefined);
206
+ return;
207
+ }
208
+ const argsStr = argsInput!.getValue().trim();
209
+ const model = modelInput!.getValue().trim();
210
+ finish(JSON.stringify({
211
+ action: "edit",
212
+ agent: agentName,
213
+ command: cmd,
214
+ args: argsStr ? argsStr.split(",").map((s: string) => s.trim()).filter(Boolean) : [],
215
+ default_model: model || undefined,
216
+ }));
217
+ return;
218
+ }
219
+ // Forward to active input
220
+ if (activeField === "command") commandInput.handleInput(data);
221
+ else if (activeField === "args") argsInput!.handleInput(data);
222
+ else modelInput!.handleInput(data);
223
+ return;
224
+ }
225
+
226
+ // Action menu mode
227
+ if (data === "\x1b") {
228
+ finish(undefined);
229
+ return;
230
+ }
231
+ const lower = data.toLowerCase();
232
+ for (const c of choices) {
233
+ if (lower === c.key) {
234
+ if (c.action === "edit") {
235
+ initEditFields();
236
+ return;
237
+ }
238
+ // Remove / SetDefault: dispatch immediately
239
+ finish(JSON.stringify({ action: c.action, agent: agentName }));
240
+ return;
241
+ }
242
+ }
243
+ }
244
+
245
+ function invalidate(): void {
246
+ if (editMode) {
247
+ commandInput?.invalidate();
248
+ argsInput?.invalidate();
249
+ modelInput?.invalidate();
250
+ }
251
+ }
252
+
253
+ return { render, handleInput, invalidate };
254
+ }
255
+
256
+ /** Create add submenu — either from preset or manual entry. */
257
+ function createAddSubmenu(
258
+ currentValue: string,
259
+ detectedPresets: Array<{ name: string; config: AcpAgentConfig }>,
260
+ finish: (value?: string) => void,
261
+ ): { render: (w: number) => string[]; handleInput: (data: string) => void; invalidate: () => void } {
262
+ const preset = detectedPresets.find((p) => p.name === currentValue);
263
+
264
+ const nameInput = new Input();
265
+ nameInput.setValue(preset ? preset.name : "");
266
+ nameInput.focused = true;
267
+
268
+ const commandInput = new Input();
269
+ commandInput.setValue(preset?.config.command ?? "");
270
+
271
+ const argsInput = new Input();
272
+ argsInput.setValue(preset ? (preset.config.args ?? []).join(", ") : "");
273
+
274
+ let activeField: "name" | "command" | "args" = "name";
275
+
276
+ function render(w: number): string[] {
277
+ const prefix = (label: string, field: string) =>
278
+ field === activeField ? `▸ ${label}: ` : ` ${label}: `;
279
+ return [
280
+ `Add agent (Tab=next, Enter=add, Esc=cancel)${preset ? ` [preset: ${preset.name}]` : ""}`,
281
+ prefix("Name", activeField) + nameInput.render(w).join(""),
282
+ prefix("Command", activeField) + commandInput.render(w).join(""),
283
+ prefix("Args (comma-sep)", activeField) + argsInput.render(w).join(""),
284
+ ];
285
+ }
286
+
287
+ function handleInput(data: string): void {
288
+ if (data === "\t") {
289
+ activeField = activeField === "name" ? "command" : activeField === "command" ? "args" : "name";
290
+ nameInput.focused = activeField === "name";
291
+ commandInput.focused = activeField === "command";
292
+ argsInput.focused = activeField === "args";
293
+ return;
294
+ }
295
+ if (data === "\x1b") {
296
+ finish(undefined);
297
+ return;
298
+ }
299
+ if (data === "\r") {
300
+ const name = nameInput.getValue().trim();
301
+ const cmd = commandInput.getValue().trim();
302
+ if (!name || !cmd) {
303
+ finish(undefined);
304
+ return;
305
+ }
306
+ const argsStr = argsInput.getValue().trim();
307
+ finish(JSON.stringify({
308
+ action: "add",
309
+ name,
310
+ command: cmd,
311
+ args: argsStr ? argsStr.split(",").map((s: string) => s.trim()).filter(Boolean) : [],
312
+ }));
313
+ return;
314
+ }
315
+ if (activeField === "name") nameInput.handleInput(data);
316
+ else if (activeField === "command") commandInput.handleInput(data);
317
+ else argsInput.handleInput(data);
318
+ }
319
+
320
+ function invalidate(): void {
321
+ nameInput.invalidate();
322
+ commandInput.invalidate();
323
+ argsInput.invalidate();
324
+ }
325
+
326
+ return { render, handleInput, invalidate };
327
+ }
328
+
329
+ // ── Submenu result handler ───────────────────────────────
330
+
331
+ /**
332
+ * Parse a JSON payload from a submenu finish() call and apply the action.
333
+ * Returns true if config was mutated (caller should rebuildList).
334
+ */
335
+ function handleSubmenuResult(payload: string, configRef: { config: AcpConfig }): boolean {
336
+ let parsed: { action: string; agent?: string; name?: string; command?: string; args?: string[]; default_model?: string };
337
+ try {
338
+ parsed = JSON.parse(payload);
339
+ } catch {
340
+ // Invalid JSON payload from submenu — return false
341
+ return false;
342
+ }
343
+
344
+ switch (parsed.action) {
345
+ case "edit": {
346
+ if (!parsed.agent) return false;
347
+ if (!parsed.command) return false;
348
+ configRef.config = upsertAgentServer(configRef.config, parsed.agent, {
349
+ command: parsed.command,
350
+ args: parsed.args,
351
+ default_model: parsed.default_model,
352
+ });
353
+ saveConfig(configRef.config);
354
+ return true;
355
+ }
356
+ case "remove": {
357
+ if (!parsed.agent) return false;
358
+ configRef.config = removeAgentServer(configRef.config, parsed.agent);
359
+ saveConfig(configRef.config);
360
+ return true;
361
+ }
362
+ case "setDefault": {
363
+ if (!parsed.agent) return false;
364
+ try {
365
+ configRef.config = setDefaultAgent(configRef.config, parsed.agent);
366
+ saveConfig(configRef.config);
367
+ } catch (e) {
368
+ /* setDefault may fail if agent removed */
369
+ return true;
370
+ }
371
+ return true;
372
+ }
373
+ case "add": {
374
+ if (!parsed.name || !parsed.command) return false;
375
+ configRef.config = upsertAgentServer(configRef.config, parsed.name, {
376
+ command: parsed.command,
377
+ args: parsed.args,
378
+ });
379
+ saveConfig(configRef.config);
380
+ return true;
381
+ }
382
+ default:
383
+ return false;
384
+ }
385
+ }
386
+
387
+ // ── Main TUI panel ───────────────────────────────────────
388
+
389
+ /**
390
+ * Open the agent config TUI panel.
391
+ * Uses ui.custom() + SettingsList. Rebuilds on every mutation.
392
+ */
393
+ export async function openAgentConfigTUI(ui: SettingsUI): Promise<void> {
394
+ await ui.custom((_tui, theme, _kb, done) => {
395
+ const configRef = { config: loadConfig() };
396
+ let detectedPresets = detectAvailablePresets();
397
+ let list: SettingsList;
398
+ let container: Container;
399
+
400
+ function rebuildList(): void {
401
+ const items = buildSettingItems(configRef.config, detectedPresets);
402
+ const maxVisible = Math.min(items.length + 2, 20);
403
+
404
+ list = new SettingsList(
405
+ items,
406
+ maxVisible,
407
+ {
408
+ label: (text, selected) => selected ? theme.bold(theme.fg("accent", text)) : text,
409
+ value: (text, selected) => selected ? theme.fg("accent", text) : theme.fg("dim", text),
410
+ description: (text) => theme.fg("dim", text),
411
+ cursor: "❯",
412
+ hint: (text) => theme.fg("dim", text),
413
+ },
414
+ // onChange — handles Default Agent cycling (values-only items)
415
+ (id, newValue) => {
416
+ if (id === "global:defaultAgent") {
417
+ if (newValue === "(none)") {
418
+ configRef.config = { ...configRef.config, defaultAgent: undefined };
419
+ } else {
420
+ try {
421
+ configRef.config = setDefaultAgent(configRef.config, newValue);
422
+ } catch { /* setDefault may fail */ return; }
423
+ }
424
+ saveConfig(configRef.config);
425
+ rebuildList();
426
+ return;
427
+ }
428
+
429
+ // Agent rows and preset:add are handled by submenu → onChange receives
430
+ // the JSON payload from finish(). Parse and dispatch.
431
+ if (id.startsWith("agent:") || id === "preset:add") {
432
+ if (handleSubmenuResult(newValue, configRef)) {
433
+ rebuildList();
434
+ }
435
+ return;
436
+ }
437
+ },
438
+ () => done(undefined),
439
+ { enableSearch: true },
440
+ );
441
+
442
+ container = new Container();
443
+ container.addChild(new Text(theme.bold(theme.fg("accent", "⚙ ACP Agent Configuration")), 0, 0));
444
+ container.addChild(new Spacer(1));
445
+ container.addChild(list);
446
+ }
447
+
448
+ rebuildList();
449
+
450
+ return {
451
+ render: (w: number) => container.render(w),
452
+ invalidate: () => { list.invalidate(); container.invalidate(); },
453
+ handleInput: (data: string) => list.handleInput(data),
454
+ };
455
+ });
456
+ }