@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.
- package/CHANGELOG.md +45 -0
- package/LICENSE +21 -0
- package/README.md +359 -0
- package/index.ts +1521 -0
- package/package.json +103 -0
- package/skills/pi-acp-agents/SKILL.md +112 -0
- package/src/acp-widget.ts +379 -0
- package/src/adapter-factory.ts +55 -0
- package/src/adapters/acpx.ts +215 -0
- package/src/adapters/base.ts +117 -0
- package/src/adapters/codex.ts +77 -0
- package/src/adapters/custom.ts +14 -0
- package/src/adapters/gemini.ts +66 -0
- package/src/adapters/opencode.ts +101 -0
- package/src/config/config.ts +312 -0
- package/src/config/types.ts +203 -0
- package/src/coordination/alias-resolver.ts +208 -0
- package/src/coordination/coordinator.ts +266 -0
- package/src/coordination/worker-dispatcher.ts +191 -0
- package/src/core/async-executor.ts +149 -0
- package/src/core/circuit-breaker.ts +254 -0
- package/src/core/client.ts +661 -0
- package/src/core/health-monitor.ts +200 -0
- package/src/core/protocol-validator.ts +259 -0
- package/src/core/session-lifecycle.ts +46 -0
- package/src/core/session-manager.ts +64 -0
- package/src/extension-safety.ts +200 -0
- package/src/logger.ts +92 -0
- package/src/management/event-log.ts +31 -0
- package/src/management/governance-store.ts +123 -0
- package/src/management/heartbeat-parser.ts +92 -0
- package/src/management/mailbox-manager.ts +95 -0
- package/src/management/runtime-paths.ts +34 -0
- package/src/management/safe-mkdir.ts +78 -0
- package/src/management/session-archive-store.ts +136 -0
- package/src/management/session-name-store.ts +88 -0
- package/src/management/task-store.ts +260 -0
- package/src/management/worker-store.ts +164 -0
- package/src/public-api.ts +72 -0
- package/src/settings/agent-config-tui.ts +456 -0
- package/src/settings/agents-command.ts +138 -0
- package/src/settings/config.ts +201 -0
- 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
|
+
}
|