@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,312 @@
1
+ /**
2
+ * pi-acp-agents — Config loading and validation
3
+ *
4
+ * Config shape mirrors Zed's `agent_servers` pattern.
5
+ * Back-compat: auto-migrates old `agents` key to `agent_servers`.
6
+ */
7
+
8
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
9
+ import { dirname, join } from "node:path";
10
+ import { homedir } from "node:os";
11
+ import { execSync } from "node:child_process";
12
+ import type { AcpAgentConfig, AcpAliasConfig, AcpConfig, LegacyAcpConfig } from "./types.js";
13
+ import { createNoopLogger } from "../logger.js";
14
+
15
+ const log = createNoopLogger();
16
+
17
+ const CONFIG_PATH = join(homedir(), ".pi", "acp-agents", "config.json");
18
+
19
+ export const DEFAULT_CONFIG: AcpConfig = {
20
+ agent_servers: {},
21
+ staleTimeoutMs: 3_600_000,
22
+ healthCheckIntervalMs: 30_000,
23
+ circuitBreakerMaxFailures: 3,
24
+ circuitBreakerResetMs: 60_000,
25
+ stallTimeoutMs: 3_600_000, // 1 hour default stall timeout
26
+ workerAutoClaim: true,
27
+ workerClaimIntervalMs: 5_000,
28
+ workerShutdownTimeoutMs: 30_000,
29
+ workerOnlineMs: 60_000,
30
+ workerStaleMs: 60_000,
31
+ modelPolicy: {
32
+ allowedModels: [],
33
+ blockedModels: [],
34
+ requireProviderPrefix: false,
35
+ },
36
+ };
37
+
38
+ /** Known agent presets with auto-detected commands */
39
+ export const AGENT_PRESETS: Record<string, () => AcpAgentConfig | null> = {
40
+ gemini: () => {
41
+ try {
42
+ execSync("which gemini", { stdio: "pipe" });
43
+ return { command: "gemini", args: ["--acp"] };
44
+ } catch (e) { /* gemini not found on PATH */ log.debug("gemini preset not found", e); return null; }
45
+ },
46
+ opencode: () => {
47
+ for (const cmd of ["opencode", "ocxo"]) {
48
+ try {
49
+ execSync(`which ${cmd}`, { stdio: "pipe" });
50
+ return { command: cmd, args: ["acp"] };
51
+ } catch (e) { /* binary not found on PATH */ log.debug(`opencode preset '${cmd}' not found`, e); continue; }
52
+ }
53
+ return null;
54
+ },
55
+ codex: () => {
56
+ try {
57
+ execSync("which codex-acp", { stdio: "pipe" });
58
+ return { command: "codex-acp", args: [] };
59
+ } catch (e) { /* codex-acp not found on PATH */ log.debug("codex-acp preset not found", e); return null; }
60
+ },
61
+ };
62
+
63
+ export function resolveConfigPath(): string {
64
+ return CONFIG_PATH;
65
+ }
66
+
67
+ /**
68
+ * Migrate old `agents` key to `agent_servers`.
69
+ * Returns normalized partial config with `agent_servers` (never `agents`).
70
+ */
71
+ function migrateLegacyConfig(raw: Record<string, unknown>): Partial<AcpConfig> {
72
+ if ("agents" in raw && !("agent_servers" in raw)) {
73
+ raw.agent_servers = raw.agents;
74
+ delete raw.agents;
75
+ }
76
+ return raw as Partial<AcpConfig>;
77
+ }
78
+
79
+ /** Validate a partial config. Throws on invalid. Returns full config with defaults merged. */
80
+ export function validateConfig(partial: Partial<AcpConfig>): AcpConfig {
81
+ if (!partial.agent_servers || typeof partial.agent_servers !== "object") {
82
+ throw new Error('Config must have an "agent_servers" object');
83
+ }
84
+
85
+ const entries = Object.entries(partial.agent_servers);
86
+ // Allow empty agent_servers (relaxed validation for CRUD operations)
87
+ // Still validate individual entries if present
88
+
89
+ const agent_servers: Record<string, AcpAgentConfig> = {};
90
+ for (const [name, agent] of entries) {
91
+ // EC-16: Validate agent name is non-empty
92
+ if (!name || name.trim() === "") {
93
+ throw new Error("Agent name must be a non-empty string");
94
+ }
95
+
96
+ if (!agent || typeof agent !== "object") {
97
+ throw new Error(`Invalid agent config for "${name}": must be an object`);
98
+ }
99
+ // Command is required unless mode is 'acpx' (acpx derives command from its own binary)
100
+ const isAcpxMode = (agent as Record<string, unknown>).mode === "acpx";
101
+ if (
102
+ !isAcpxMode &&
103
+ (!("command" in agent) ||
104
+ typeof agent.command !== "string" ||
105
+ !agent.command)
106
+ ) {
107
+ throw new Error(
108
+ `Invalid agent config for "${name}": "command" is required (or set mode: "acpx")`,
109
+ );
110
+ }
111
+ agent_servers[name] = {
112
+ ...agent,
113
+ args: agent.args ?? [],
114
+ env: agent.env ?? {},
115
+ };
116
+ }
117
+
118
+ // Clamp stallTimeoutMs to minimum 60_000 (1 minute)
119
+ const rawStallTimeout = partial.stallTimeoutMs ?? DEFAULT_CONFIG.stallTimeoutMs!;
120
+ const stallTimeoutMs = Math.max(rawStallTimeout, 60_000);
121
+
122
+ const resolved: AcpConfig = {
123
+ ...DEFAULT_CONFIG,
124
+ ...partial,
125
+ agent_servers,
126
+ stallTimeoutMs,
127
+ staleTimeoutMs: partial.staleTimeoutMs ?? DEFAULT_CONFIG.staleTimeoutMs,
128
+ healthCheckIntervalMs:
129
+ partial.healthCheckIntervalMs ?? DEFAULT_CONFIG.healthCheckIntervalMs,
130
+ circuitBreakerMaxFailures:
131
+ partial.circuitBreakerMaxFailures ??
132
+ DEFAULT_CONFIG.circuitBreakerMaxFailures,
133
+ circuitBreakerResetMs:
134
+ partial.circuitBreakerResetMs ?? DEFAULT_CONFIG.circuitBreakerResetMs,
135
+ modelPolicy: {
136
+ ...DEFAULT_CONFIG.modelPolicy,
137
+ ...partial.modelPolicy,
138
+ },
139
+ toolTimeouts: partial.toolTimeouts
140
+ ? { ...partial.toolTimeouts }
141
+ : undefined,
142
+ };
143
+
144
+ // EC-20: Validate numeric fields are non-negative
145
+ validateNumericFields(resolved);
146
+ // EC-21: Validate healthCheckIntervalMs <= staleTimeoutMs
147
+ validateTimeoutOrder(resolved);
148
+ // EC-22: Validate agent_aliases if present
149
+ if (resolved.agent_aliases) {
150
+ validateAgentAliases(resolved.agent_aliases, resolved.agent_servers);
151
+ }
152
+
153
+ return resolved;
154
+ }
155
+
156
+ /** Validate numeric timeout fields are non-negative (EC-20) */
157
+ function validateNumericFields(resolved: AcpConfig): void {
158
+ const numericFields: Array<[string, number | undefined]> = [
159
+ ["staleTimeoutMs", resolved.staleTimeoutMs],
160
+ ["healthCheckIntervalMs", resolved.healthCheckIntervalMs],
161
+ ["circuitBreakerResetMs", resolved.circuitBreakerResetMs],
162
+ ];
163
+ for (const [field, val] of numericFields) {
164
+ if (val !== undefined && val < 0) {
165
+ throw new Error(`Config field "${field}" must be non-negative`);
166
+ }
167
+ }
168
+ if (
169
+ resolved.circuitBreakerMaxFailures !== undefined &&
170
+ resolved.circuitBreakerMaxFailures < 0
171
+ ) {
172
+ throw new Error(
173
+ 'Config field "circuitBreakerMaxFailures" must be non-negative',
174
+ );
175
+ }
176
+ }
177
+
178
+ /** Validate healthCheckIntervalMs <= staleTimeoutMs (EC-21) */
179
+ function validateTimeoutOrder(resolved: AcpConfig): void {
180
+ if (resolved.healthCheckIntervalMs! > resolved.staleTimeoutMs!) {
181
+ throw new Error("healthCheckIntervalMs must be <= staleTimeoutMs");
182
+ }
183
+ }
184
+
185
+ /** Validate agent_aliases entries (EC-22) */
186
+ function validateAgentAliases(
187
+ aliases: Record<string, AcpAliasConfig>,
188
+ agent_servers: Record<string, AcpAgentConfig>,
189
+ ): void {
190
+ for (const [aliasName, alias] of Object.entries(aliases)) {
191
+ if (!aliasName || aliasName.trim() === "") {
192
+ throw new Error("Alias name must be a non-empty string");
193
+ }
194
+ if (!alias.agents || !Array.isArray(alias.agents) || alias.agents.length === 0) {
195
+ throw new Error(`Alias "${aliasName}" must have a non-empty agents array`);
196
+ }
197
+ if (alias.strategy !== "failover" && alias.strategy !== "race") {
198
+ throw new Error(`Alias "${aliasName}" strategy must be "failover" or "race", got "${alias.strategy}"`);
199
+ }
200
+ for (const agentName of alias.agents) {
201
+ if (!agent_servers[agentName]) {
202
+ throw new Error(`Alias "${aliasName}" references unknown agent "${agentName}"`);
203
+ }
204
+ }
205
+ }
206
+ }
207
+
208
+ /** Load config from disk, falling back to defaults. Auto-migrates old `agents` key. */
209
+ export function loadConfig(configPath?: string): AcpConfig {
210
+ const path = configPath ?? CONFIG_PATH;
211
+ if (!existsSync(path)) {
212
+ return structuredClone(DEFAULT_CONFIG);
213
+ }
214
+ try {
215
+ const raw = JSON.parse(readFileSync(path, "utf-8")) as Record<string, unknown>;
216
+ const needsMigration = "agents" in raw && !("agent_servers" in raw);
217
+ const migrated = migrateLegacyConfig(raw);
218
+
219
+ // Auto-save migrated config back to disk
220
+ if (needsMigration) {
221
+ try {
222
+ writeFileSync(path, JSON.stringify(migrated, null, 2) + "\n");
223
+ } catch (e) { log.debug("config migration write failed", e); /* best-effort */ }
224
+ }
225
+
226
+ return validateConfig(migrated);
227
+ } catch (e) {
228
+ // Config file corrupt or unreadable — fall back to defaults
229
+ log.debug("config load failed, using defaults", e);
230
+ return structuredClone(DEFAULT_CONFIG);
231
+ }
232
+ }
233
+
234
+ /** Save config to disk at the given path (or default path) */
235
+ export function saveConfig(config: AcpConfig, configPath?: string): void {
236
+ const path = configPath ?? CONFIG_PATH;
237
+ const dir = dirname(path);
238
+ try {
239
+ if (!existsSync(dir)) {
240
+ mkdirSync(dir, { recursive: true });
241
+ }
242
+ const data = JSON.stringify(config, null, 2) + "\n";
243
+ writeFileSync(path, data, "utf-8");
244
+ } catch (e) {
245
+ // EACCES or other FS error — silently degrade. Config changes are non-critical.
246
+ log.debug("config save failed", e);
247
+ }
248
+ }
249
+
250
+ /** Add or update an agent server entry. Returns a new config (does not mutate original). */
251
+ export function upsertAgentServer(config: AcpConfig, name: string, agent: Partial<AcpAgentConfig> & { command: string }): AcpConfig {
252
+ if (!name || name.trim() === "") {
253
+ throw new Error("Agent name must be a non-empty string");
254
+ }
255
+ if (!agent.command || agent.command.trim() === "") {
256
+ throw new Error('Agent "command" is required');
257
+ }
258
+ const cloned = structuredClone(config);
259
+ cloned.agent_servers[name] = {
260
+ command: agent.command,
261
+ args: agent.args ?? [],
262
+ env: agent.env ?? {},
263
+ ...(agent.default_model ? { default_model: agent.default_model } : {}),
264
+ ...(agent.default_mode ? { default_mode: agent.default_mode } : {}),
265
+ };
266
+ return cloned;
267
+ }
268
+
269
+ /** Remove an agent server entry. Returns a new config (does not mutate original). Clears defaultAgent if it was the removed agent. */
270
+ export function removeAgentServer(config: AcpConfig, name: string): AcpConfig {
271
+ const cloned = structuredClone(config);
272
+ delete cloned.agent_servers[name];
273
+ if (cloned.defaultAgent === name) {
274
+ delete cloned.defaultAgent;
275
+ }
276
+ return cloned;
277
+ }
278
+
279
+ /** Set the default agent. Returns a new config (does not mutate original). Throws if agent not found. */
280
+ export function setDefaultAgent(config: AcpConfig, name: string): AcpConfig {
281
+ if (!config.agent_servers[name]) {
282
+ throw new Error(`Agent "${name}" not found`);
283
+ }
284
+ const cloned = structuredClone(config);
285
+ cloned.defaultAgent = name;
286
+ return cloned;
287
+ }
288
+
289
+ /** Detect available agent presets from the system PATH. */
290
+ export function detectAvailablePresets(): Array<{ name: string; config: AcpAgentConfig }> {
291
+ const results: Array<{ name: string; config: AcpAgentConfig }> = [];
292
+ for (const [name, factory] of Object.entries(AGENT_PRESETS)) {
293
+ const config = factory();
294
+ if (config) {
295
+ results.push({ name, config });
296
+ }
297
+ }
298
+ return results;
299
+ }
300
+
301
+ /** Get a specific agent config, throwing if not found */
302
+ export function getAgentConfig(
303
+ config: AcpConfig,
304
+ name: string,
305
+ ): AcpAgentConfig {
306
+ const agent = config.agent_servers[name];
307
+ if (!agent) {
308
+ const available = Object.keys(config.agent_servers).join(", ");
309
+ throw new Error(`Agent "${name}" not found. Available: ${available}`);
310
+ }
311
+ return agent;
312
+ }
@@ -0,0 +1,203 @@
1
+ /**
2
+ * pi-acp-agents — Shared types
3
+ *
4
+ * Config shape mirrors Zed's `agent_servers` pattern:
5
+ * flat map of name → server config. No provider-specific fields.
6
+ * All customization goes through args/env.
7
+ */
8
+
9
+ /** Configuration for a single agent alias with fallback chain */
10
+ export interface AcpAliasConfig {
11
+ agents: string[];
12
+ strategy: "failover" | "race";
13
+ }
14
+
15
+ /** Per-agent server configuration (matches Zed's agent_servers entry) */
16
+ export interface AcpAgentConfig {
17
+ /** How to connect to this agent: 'direct' (subprocess) or 'acpx' (CLI delegation). Default: 'direct'. */
18
+ mode?: "direct" | "acpx";
19
+ /** Required for 'direct' mode. Command to spawn the agent subprocess. */
20
+ command?: string;
21
+ args?: string[];
22
+ env?: Record<string, string>;
23
+ cwd?: string;
24
+ /** Default model for sessions created with this agent */
25
+ default_model?: string;
26
+ /** Default mode for sessions created with this agent */
27
+ default_mode?: string;
28
+ /** Allow passthrough of unknown fields for forward compat */
29
+ [key: string]: unknown;
30
+ }
31
+
32
+ /** Top-level config stored at ~/.pi/acp-agents/config.json */
33
+ export interface AcpConfig {
34
+ /** Agent server definitions. Key = alias name. Matches Zed's agent_servers. */
35
+ agent_servers: Record<string, AcpAgentConfig>;
36
+ defaultAgent?: string;
37
+ logsDir?: string;
38
+ staleTimeoutMs?: number;
39
+ healthCheckIntervalMs?: number;
40
+ circuitBreakerMaxFailures?: number;
41
+ circuitBreakerResetMs?: number;
42
+ /** Stall timeout in milliseconds (default: 3_600_000 = 1 hour) */
43
+ stallTimeoutMs?: number;
44
+ /** Per-tool timeout overrides in milliseconds. Falls back to stallTimeoutMs. */
45
+ toolTimeouts?: {
46
+ prompt?: number;
47
+ delegate?: number;
48
+ broadcast?: number;
49
+ compare?: number;
50
+ };
51
+ /** Worker lifecycle config */
52
+ workerAutoClaim?: boolean; // default: true
53
+ workerClaimIntervalMs?: number; // default: 5000
54
+ workerShutdownTimeoutMs?: number; // default: 30000
55
+ workerOnlineMs?: number; // default: 60000
56
+ workerStaleMs?: number; // default: 60000
57
+ /** Activity-based stall detection for persistent sessions (ms) */
58
+ needsAttentionMs?: number; // default: 60_000
59
+ autoInterruptMs?: number; // default: 300_000, 0 = disabled
60
+ interruptGraceMs?: number; // default: 10_000
61
+ /** Agent alias definitions for fallback chains */
62
+ agent_aliases?: Record<string, AcpAliasConfig>;
63
+ /** Timeout for race strategy in ms (default: 30_000 = 30s) */
64
+ raceTimeoutMs?: number;
65
+ runtimeDir?: string;
66
+ modelPolicy?: {
67
+ allowedModels?: string[];
68
+ blockedModels?: string[];
69
+ requireProviderPrefix?: boolean;
70
+ };
71
+ }
72
+
73
+ /**
74
+ * Back-compat: old configs may use `agents` instead of `agent_servers`.
75
+ * This legacy type is only used during migration.
76
+ */
77
+ export interface LegacyAcpConfig {
78
+ agents?: Record<string, AcpAgentConfig>;
79
+ agent_servers?: Record<string, AcpAgentConfig>;
80
+ [key: string]: unknown;
81
+ }
82
+
83
+ /** Alias used by some tests */
84
+ export type AcpAgentsConfig = AcpConfig;
85
+
86
+ /** Result of a prompt call */
87
+ export interface AcpPromptResult {
88
+ text: string;
89
+ stopReason: string;
90
+ sessionId: string;
91
+ stalled?: boolean;
92
+ }
93
+
94
+ /** Tracked session info */
95
+ export interface AcpSessionInfo {
96
+ sessionId: string;
97
+ sessionName?: string;
98
+ agentName: string;
99
+ cwd: string;
100
+ model?: string;
101
+ mode?: string;
102
+ createdAt: Date;
103
+ }
104
+
105
+ /** Archived runtime metadata used to reopen ACP sessions after auto-close. */
106
+ export interface AcpArchivedSessionMetadata {
107
+ sessionId: string;
108
+ sessionName?: string;
109
+ agentName: string;
110
+ cwd: string;
111
+ createdAt: Date;
112
+ lastActivityAt: Date;
113
+ lastResponseAt?: Date;
114
+ completedAt?: Date;
115
+ disposed: boolean;
116
+ autoClosed?: boolean;
117
+ closeReason?: string;
118
+ model?: string;
119
+ mode?: string;
120
+ /** Loadability tracking — whether this archived session can be successfully resumed */
121
+ loadStatus?: "loadable" | "unloadable" | "unknown";
122
+ lastLoadAttemptAt?: string;
123
+ lastLoadError?: string;
124
+ loadAttemptCount?: number;
125
+ }
126
+
127
+ /** Internal handle kept by SessionManager. Also satisfies HealthMonitorable. */
128
+ export interface AcpSessionHandle extends AcpArchivedSessionMetadata {
129
+ accumulatedText: string;
130
+ busy?: boolean;
131
+ /** True while a prompt() call is in-flight */
132
+ isPrompting?: boolean;
133
+ /** Timestamp when the current prompt started */
134
+ promptStartedAt?: Date;
135
+ planStatus?: "none" | "pending" | "approved" | "rejected";
136
+ dispose: () => Promise<void>;
137
+ }
138
+
139
+ /** Circuit breaker states */
140
+ export type CircuitState = "closed" | "open" | "half-open";
141
+
142
+ /** Adapter options */
143
+ export interface AcpAdapterOptions {
144
+ config: AcpAgentConfig;
145
+ clientInfo?: { name: string; version: string };
146
+ logger?: Logger;
147
+ cwd?: string;
148
+ agentName?: string;
149
+ /** Activity callback — called on every session/update notification from the agent */
150
+ onActivity?: (sessionId: string) => void;
151
+ /** Session update callback — called on every session/update with full update data */
152
+ onSessionUpdate?: (sessionId: string, update: import("@agentclientprotocol/sdk").SessionUpdate) => void;
153
+ }
154
+
155
+ // --- Worker types (M6) ---
156
+
157
+ export type AcpWorkerStatus = "online" | "idle" | "busy" | "streaming" | "offline";
158
+
159
+ export interface AcpWorkerRecord {
160
+ name: string;
161
+ sessionId: string;
162
+ agentName: string;
163
+ status: AcpWorkerStatus;
164
+ currentTaskId?: string;
165
+ spawnedAt: string;
166
+ lastActivityAt: string;
167
+ /** Last heartbeat timestamp (ISO 8601), updated on every session/update event */
168
+ lastHeartbeatAt?: string;
169
+ /** Cumulative token count (tokensIn + tokensOut) from session/update deltas */
170
+ tokenCountTotal?: number;
171
+ /** Count of tool calls observed from session/update events */
172
+ toolCallCount?: number;
173
+ metadata: Record<string, unknown>;
174
+ }
175
+
176
+ // --- Task priority (M3, M5) ---
177
+
178
+ export type AcpTaskPriority = "urgent" | "high" | "normal" | "low";
179
+
180
+ // --- Async run types (M1) ---
181
+
182
+ export type AcpAsyncRunState = "pending" | "running" | "completed" | "failed";
183
+
184
+ export interface AcpAsyncRunRecord {
185
+ runId: string;
186
+ agentName: string;
187
+ message: string;
188
+ cwd?: string;
189
+ state: AcpAsyncRunState;
190
+ sessionId?: string;
191
+ result?: string;
192
+ error?: string;
193
+ createdAt: string;
194
+ startedAt?: string;
195
+ completedAt?: string;
196
+ }
197
+
198
+ /** Logger interface */
199
+ export interface Logger {
200
+ info(msg: string, data?: unknown): void;
201
+ error(msg: string, data?: unknown): void;
202
+ debug(msg: string, data?: unknown): void;
203
+ }