@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
package/package.json ADDED
@@ -0,0 +1,103 @@
1
+ {
2
+ "name": "@buihongduc132/pi-acp-agents",
3
+ "version": "0.3.1",
4
+ "description": "Pi extension: ACP agent client — spawn and control ACP-compatible agents (Gemini CLI, etc.) from within pi",
5
+ "keywords": [
6
+ "pi-package",
7
+ "pi-extension",
8
+ "pi",
9
+ "pi-coding-agent",
10
+ "acp",
11
+ "agent-client-protocol",
12
+ "multi-agent",
13
+ "pi",
14
+ "pi-coding-agent"
15
+ ],
16
+ "type": "module",
17
+ "main": "./index.ts",
18
+ "types": "./src/public-api.ts",
19
+ "exports": {
20
+ ".": {
21
+ "types": "./src/public-api.ts",
22
+ "import": "./index.ts"
23
+ },
24
+ "./api": {
25
+ "types": "./src/public-api.ts",
26
+ "import": "./src/public-api.ts"
27
+ }
28
+ },
29
+ "license": "MIT",
30
+ "author": "buihongduc132",
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "git+https://github.com/buihongduc132/pi-acp-agents.git"
34
+ },
35
+ "homepage": "https://github.com/buihongduc132/pi-acp-agents#readme",
36
+ "bugs": {
37
+ "url": "https://github.com/buihongduc132/pi-acp-agents/issues"
38
+ },
39
+ "files": [
40
+ "index.ts",
41
+ "src/**/*.ts",
42
+ "skills/**/*",
43
+ "README.md",
44
+ "CHANGELOG.md",
45
+ "LICENSE"
46
+ ],
47
+ "pi": {
48
+ "extensions": [
49
+ "./index.ts"
50
+ ],
51
+ "skills": [
52
+ "./skills"
53
+ ]
54
+ },
55
+ "peerDependencies": {
56
+ "@mariozechner/pi-coding-agent": "*",
57
+ "typebox": "*"
58
+ },
59
+ "peerDependenciesMeta": {
60
+ "@mariozechner/pi-coding-agent": {
61
+ "optional": true
62
+ },
63
+ "typebox": {
64
+ "optional": true
65
+ }
66
+ },
67
+ "workspaces": [
68
+ "packages/*"
69
+ ],
70
+ "dependencies": {
71
+ "@agentclientprotocol/sdk": "^0.21.0",
72
+ "pi-acp-types": "workspace:*"
73
+ },
74
+ "devDependencies": {
75
+ "@mariozechner/pi-coding-agent": "^0.73.0",
76
+ "@mariozechner/pi-tui": "^0.73.0",
77
+ "@types/node": "^22.0.0",
78
+ "@vitest/coverage-v8": "^4.1.5",
79
+ "typebox": "^1.1.37",
80
+ "typescript": "^6.0.3",
81
+ "vitest": "^4.1.5"
82
+ },
83
+ "scripts": {
84
+ "test": "vitest run",
85
+ "test:ci": "vitest run --coverage",
86
+ "test:watch": "vitest",
87
+ "typecheck": "tsc --noEmit",
88
+ "prepublishOnly": "npm run test:ci",
89
+ "prepack": "node scripts/prepack.mjs",
90
+ "postpack": "node scripts/postpack.mjs",
91
+ "version": "node scripts/version-sync.mjs",
92
+ "release:patch": "npm version patch -m 'release: v%s'",
93
+ "release:minor": "npm version minor -m 'release: v%s'",
94
+ "release:major": "npm version major -m 'release: v%s'",
95
+ "release:beta": "npm version prerelease --preid=beta -m 'release: v%s'",
96
+ "publish:beta": "npm publish --tag beta --access public",
97
+ "publish:next": "npm publish --tag next --access public",
98
+ "publish:dry": "npm publish --dry-run --access public"
99
+ },
100
+ "engines": {
101
+ "node": ">=22.0.0"
102
+ }
103
+ }
@@ -0,0 +1,112 @@
1
+ ---
2
+ name: pi-acp-agents
3
+ description: Use when working with the pi-acp-agents package, its ACP tool surface, ACP agent session lifecycle, or ACP configuration and runtime behavior inside pi.
4
+ ---
5
+
6
+ # pi-acp-agents
7
+
8
+ ACP agent client for pi — spawn and control ACP-compatible agents from within pi.
9
+
10
+ ## What it does
11
+
12
+ This pi extension registers tools that let the pi LLM communicate with external ACP (Agent Client Protocol) compatible agents like Gemini CLI, Claude, or any custom command that speaks ACP over stdio JSON-RPC.
13
+
14
+ ## Tools
15
+
16
+ | Tool | Description |
17
+ | ----------------------- | -------------------------------------------------------------- |
18
+ | `acp_prompt` | Send a prompt to an ACP agent, get the text response |
19
+ | `acp_status` | Show configured agents, active sessions, circuit breaker state |
20
+ | `acp_session_new` | Create a new isolated session with an agent |
21
+ | `acp_session_load` | Load an existing session by ID |
22
+ | `acp_session_set_model` | Change the model for an active session |
23
+ | `acp_session_set_mode` | Change the mode (thinking level) for an active session |
24
+ | `acp_cancel` | Cancel an ongoing prompt |
25
+ | `acp_delegate` | Delegate a task (short-lived session, auto-disposed) |
26
+ | `acp_broadcast` | Send same prompt to multiple agents in parallel |
27
+ | `acp_compare` | Get responses from multiple agents and compare them |
28
+
29
+ ## Configuration
30
+
31
+ Config file: `~/.pi/acp-agents/config.json`
32
+
33
+ ```json
34
+ {
35
+ "agent_servers": {
36
+ "gemini": {
37
+ "command": "gemini",
38
+ "args": ["--acp"],
39
+ "default_model": "gemini-2.5-pro"
40
+ }
41
+ },
42
+ "defaultAgent": "gemini"
43
+ }
44
+ ```
45
+
46
+ ### Fields
47
+
48
+ | Field | Default | Description |
49
+ | --------------------------- | ------------------- | ----------------------------------------- |
50
+ | `agent_servers` | `{ gemini: {...} }` | Map of agent name → config |
51
+ | `defaultAgent` | `"gemini"` | Agent used when not specified |
52
+ | `staleTimeoutMs` | `3600000` (1 hour) | Session auto-close timeout for stalled-no-response and completed-idle |
53
+ | `healthCheckIntervalMs` | `30000` (30s) | Background health polling interval |
54
+ | `circuitBreakerMaxFailures` | `3` | Consecutive failures before circuit opens |
55
+ | `circuitBreakerResetMs` | `60000` (60s) | Time before circuit half-opens |
56
+ | `stallTimeoutMs` | `3600000` (1 hour) | Per-operation timeout |
57
+
58
+ ### Per-agent config
59
+
60
+ | Field | Required | Description |
61
+ | -------------- | -------- | ----------------------------- |
62
+ | `command` | **yes** | Executable to spawn |
63
+ | `args` | no | Arguments (e.g., `["--acp"]`) |
64
+ | `env` | no | Extra environment variables |
65
+ | `cwd` | no | Working directory override |
66
+ | `default_model` | no | Default model ID |
67
+
68
+ ## Resilience
69
+
70
+ - **Circuit breaker**: Opens after N consecutive failures, auto-recovers after timeout
71
+ - **Stall timeout**: Prompts that receive no activity are auto-cancelled
72
+ - **Health polling**: Background monitor enforces separate `lastResponseAt` and `completedAt` 1-hour auto-close policies
73
+ - **Busy mutex**: Prevents concurrent prompts on same session
74
+ - **Process safeguards**: SIGTERM → SIGKILL escalation, EPIPE error handlers
75
+ - **Non-blocking**: All tool errors return as tool error results, never throw
76
+
77
+ ## Architecture
78
+
79
+ ```
80
+ Adapter pattern:
81
+ AcpAgentAdapter (abstract base)
82
+ ├── GeminiAcpAdapter — gemini --acp defaults
83
+ └── CustomAcpAdapter — any user-defined ACP command
84
+
85
+ Client layer:
86
+ AcpClient — wraps @agentclientprotocol/sdk ClientSideConnection
87
+
88
+ Resilience:
89
+ AcpCircuitBreaker — wraps all tool execute calls
90
+ HealthMonitor — background session health polling
91
+
92
+ Coordination:
93
+ AgentCoordinator — multi-agent delegate/broadcast/compare
94
+ ```
95
+
96
+ ## Logs
97
+
98
+ Central logs: `~/.pi/acp-agents/logs/`
99
+
100
+ - `main.log` — general log
101
+ - `session-{id}/trace.jsonl` — per-session JSON-RPC traces
102
+
103
+ ## Prerequisites
104
+
105
+ - Gemini CLI installed and authenticated: `gemini --version` + `gemini` (login)
106
+ - ACP TypeScript SDK: bundled as dependency
107
+
108
+ ## References
109
+
110
+ - ACP Protocol: https://agentclientprotocol.com/protocol/overview
111
+ - ACP TypeScript SDK: `@agentclientprotocol/sdk`
112
+ - Gemini ACP docs: https://geminicli.com/docs/cli/acp-mode/
@@ -0,0 +1,379 @@
1
+ /**
2
+ * ACP TUI Widget — persistent panel for ACP agent status
3
+ *
4
+ * Renders in pi's TUI status area, similar to pi-agent-teams widget.
5
+ * Shows: header, circuit breaker state, per-session rows, totals, hints.
6
+ */
7
+
8
+ import type { Theme, ThemeColor } from "@mariozechner/pi-coding-agent";
9
+ import type { Component } from "@mariozechner/pi-tui";
10
+ import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
11
+
12
+ // ── Types ──────────────────────────────────────────────────────────
13
+
14
+ export type AcpSessionStatus = "active" | "idle" | "stale" | "error";
15
+
16
+ export interface AcpWidgetSession {
17
+ sessionId: string;
18
+ sessionName?: string;
19
+ agentName: string;
20
+ cwd: string;
21
+ status: AcpSessionStatus;
22
+ lastActivityAt: Date;
23
+ createdAt: Date;
24
+ model?: string;
25
+ }
26
+
27
+ export interface AcpWidgetDelegation {
28
+ id: string;
29
+ agentName: string;
30
+ phase: string;
31
+ startedAt: Date;
32
+ lastActivityAt: Date;
33
+ text?: string;
34
+ }
35
+
36
+ export interface AcpDelegationHistoryEntry {
37
+ agentName: string;
38
+ status: "completed" | "error";
39
+ error?: string;
40
+ sessionId?: string;
41
+ finishedAt: Date;
42
+ }
43
+
44
+ export interface AcpWidgetActivity {
45
+ activeDelegations: number;
46
+ activeBroadcasts: number;
47
+ activeCompares: number;
48
+ delegations: AcpWidgetDelegation[];
49
+ /** Capped at 20 entries — most recent last. */
50
+ delegationHistory?: AcpDelegationHistoryEntry[];
51
+ lastError?: string;
52
+ }
53
+
54
+ export interface AcpWidgetWorker {
55
+ name: string;
56
+ agentName: string;
57
+ status: string;
58
+ tokenCountTotal: number;
59
+ toolCallCount: number;
60
+ ageSeconds: number;
61
+ stale: boolean;
62
+ currentTaskId?: string;
63
+ }
64
+
65
+ export interface AcpWidgetState {
66
+ sessions: AcpWidgetSession[];
67
+ circuitBreakerState: "closed" | "open" | "half-open";
68
+ configuredAgentNames: string[];
69
+ configuredAliases?: string[];
70
+ defaultAgent?: string;
71
+ activity: AcpWidgetActivity;
72
+ workers?: AcpWidgetWorker[];
73
+ }
74
+
75
+ // ── Status styling ──────────────────────────────────────────────────
76
+
77
+ const STATUS_ICON: Record<AcpSessionStatus, string> = {
78
+ active: "●",
79
+ idle: "○",
80
+ stale: "◻",
81
+ error: "✕",
82
+ };
83
+
84
+ const STATUS_COLOR: Record<AcpSessionStatus, ThemeColor> = {
85
+ active: "success",
86
+ idle: "muted",
87
+ stale: "warning",
88
+ error: "error",
89
+ };
90
+
91
+ const CB_ICON: Record<string, { icon: string; color: ThemeColor }> = {
92
+ closed: { icon: "●", color: "success" },
93
+ open: { icon: "✕", color: "error" },
94
+ "half-open": { icon: "◐", color: "warning" },
95
+ };
96
+
97
+ const WORKER_STATUS_ICON: Record<string, { icon: string; color: ThemeColor }> = {
98
+ online: { icon: "●", color: "success" },
99
+ idle: { icon: "○", color: "muted" },
100
+ busy: { icon: "●", color: "accent" },
101
+ offline: { icon: "✕", color: "dim" },
102
+ };
103
+
104
+ // ── Helpers ─────────────────────────────────────────────────────────
105
+
106
+ /** Pad a string to a visible width, accounting for ANSI escape codes. */
107
+ function padRight(text: string, width: number): string {
108
+ const vw = visibleWidth(text);
109
+ return vw >= width ? text : text + " ".repeat(width - vw);
110
+ }
111
+
112
+ /** Format token count with K/M suffix. */
113
+ function formatTokens(count: number): string {
114
+ if (count === 0) return "0";
115
+ if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
116
+ if (count >= 1_000) return `${(count / 1_000).toFixed(1)}K`;
117
+ return String(count);
118
+ }
119
+
120
+ /** Short session ID — first 8 chars. */
121
+ function shortId(id: string): string {
122
+ return id.length <= 8 ? id : id.slice(0, 8) + "…";
123
+ }
124
+
125
+ /** Time ago string. */
126
+ function timeAgo(date: Date): string {
127
+ const ms = Date.now() - date.getTime();
128
+ if (ms < 5_000) return "just now";
129
+ if (ms < 60_000) return `${Math.floor(ms / 1_000)}s ago`;
130
+ if (ms < 3_600_000) return `${Math.floor(ms / 60_000)}m ago`;
131
+ if (ms < 86_400_000) return `${Math.floor(ms / 3_600_000)}h ago`;
132
+ return `${Math.floor(ms / 86_400_000)}d ago`;
133
+ }
134
+
135
+ // ── Widget factory ──────────────────────────────────────────────────
136
+
137
+ export type AcpWidgetDeps = {
138
+ getState(): AcpWidgetState;
139
+ };
140
+
141
+ export type AcpWidgetFactory = (
142
+ tui: unknown,
143
+ theme: Theme,
144
+ ) => Component & { dispose?(): void };
145
+
146
+ export function createAcpWidget(deps: AcpWidgetDeps): AcpWidgetFactory {
147
+ return (_tui: unknown, theme: Theme): Component & { dispose?(): void } => {
148
+ // Refresh every 5s to update "time ago" displays
149
+ const refreshInterval = setInterval(() => {
150
+ // No-op; the widget is re-rendered on each TUI paint cycle
151
+ // via the closure over deps.getState()
152
+ }, 5_000);
153
+
154
+ return {
155
+ render(width: number): string[] {
156
+ const state = deps.getState();
157
+
158
+ // Hide when nothing to show
159
+ const hasWorkers = (state.workers?.length ?? 0) > 0;
160
+ if (
161
+ state.sessions.length === 0 &&
162
+ state.configuredAgentNames.length === 0 &&
163
+ !hasWorkers
164
+ ) {
165
+ return [];
166
+ }
167
+
168
+ const lines: string[] = [];
169
+
170
+ // ── Header ──
171
+ const header = " " + theme.bold(theme.fg("accent", "ACP Agents"));
172
+ lines.push(truncateToWidth(header, width));
173
+
174
+ // ── Circuit breaker state (only show if not closed) ──
175
+ if (state.circuitBreakerState !== "closed") {
176
+ const cb = CB_ICON[state.circuitBreakerState] ?? CB_ICON["open"];
177
+ const cbLabel =
178
+ state.circuitBreakerState === "half-open"
179
+ ? "half-open (probing)"
180
+ : state.circuitBreakerState;
181
+ lines.push(
182
+ truncateToWidth(
183
+ ` ${theme.fg(cb.color, `${cb.icon} circuit breaker: ${cbLabel}`)}`,
184
+ width,
185
+ ),
186
+ );
187
+ }
188
+
189
+ const totalTransient =
190
+ state.activity.activeDelegations +
191
+ state.activity.activeBroadcasts +
192
+ state.activity.activeCompares;
193
+ const activitySummary = state.activity.lastError
194
+ ? `error: ${state.activity.lastError}`
195
+ : totalTransient > 1
196
+ ? `busy (${totalTransient})`
197
+ : state.activity.activeDelegations > 0
198
+ ? "delegating"
199
+ : state.activity.activeBroadcasts > 0
200
+ ? "broadcasting"
201
+ : state.activity.activeCompares > 0
202
+ ? "comparing"
203
+ : "idle";
204
+ const activityColor: ThemeColor = state.activity.lastError
205
+ ? "error"
206
+ : totalTransient > 0
207
+ ? "accent"
208
+ : "muted";
209
+ lines.push(
210
+ truncateToWidth(
211
+ ` ${theme.fg(activityColor, `status: ${activitySummary}`)}`,
212
+ width,
213
+ ),
214
+ );
215
+
216
+ // ── Delegation rows (when active) ──
217
+ if (state.activity.delegations && state.activity.delegations.length > 0) {
218
+ for (const del of state.activity.delegations) {
219
+ const phaseIcon = del.phase === "prompting" ? "\u27F3" : del.phase === "done" ? "\u2713" : "\u23F3";
220
+ const delLine = ` ${theme.fg("accent", `${phaseIcon} ${del.agentName}`)} ${theme.fg("dim", del.phase)}`;
221
+ const delText = del.text ? ` ${theme.fg("dim", truncateToWidth(del.text, 40))}` : "";
222
+ lines.push(truncateToWidth(`${delLine}${delText}`, width));
223
+ }
224
+ }
225
+
226
+ // ── Recent delegations (from history) ──
227
+ if (state.activity.delegationHistory && state.activity.delegationHistory.length > 0) {
228
+ const recent = state.activity.delegationHistory.slice(-5);
229
+ lines.push(
230
+ truncateToWidth(
231
+ ` ${theme.fg("dim", "─ recent ─")}`,
232
+ width,
233
+ ),
234
+ );
235
+ for (const entry of recent) {
236
+ const icon = entry.status === "completed" ? "\u2713" : "\u2717";
237
+ const color = entry.status === "completed" ? "success" : "error";
238
+ const errText = entry.error ? ` ${theme.fg("error", truncateToWidth(entry.error, 40))}` : "";
239
+ const histLine = ` ${theme.fg(color, `${icon} ${entry.agentName}`)}${errText}`;
240
+ lines.push(truncateToWidth(histLine, width));
241
+ }
242
+ }
243
+
244
+ // ── No sessions ──
245
+ if (state.sessions.length === 0) {
246
+ const agentList = state.configuredAgentNames.join(", ") || "none";
247
+ lines.push(
248
+ truncateToWidth(
249
+ ` ${theme.fg("dim", `(no active sessions) agents: ${agentList}`)}`,
250
+ width,
251
+ ),
252
+ );
253
+ // Don't return early — workers may still need rendering below
254
+ }
255
+
256
+ // ── Build rows ──
257
+ // Compute column widths
258
+ const nameWidth = Math.max(
259
+ ...state.sessions.map((s) => visibleWidth(s.agentName)),
260
+ 8, // minimum
261
+ );
262
+ const idWidth = 8; // short session ID
263
+
264
+ for (const session of state.sessions) {
265
+ const icon = theme.fg(
266
+ STATUS_COLOR[session.status],
267
+ STATUS_ICON[session.status],
268
+ );
269
+ const name = theme.bold(padRight(session.agentName, nameWidth));
270
+ const id = theme.fg(
271
+ "dim",
272
+ shortId(session.sessionId).padEnd(idWidth),
273
+ );
274
+ const friendlyName = session.sessionName
275
+ ? ` ${theme.fg("accent", session.sessionName)}`
276
+ : "";
277
+ const statusLabel = theme.fg(
278
+ STATUS_COLOR[session.status],
279
+ padRight(session.status, 7),
280
+ );
281
+ const modelLabel = session.model
282
+ ? ` ${theme.fg("muted", session.model)}`
283
+ : "";
284
+ const activity = theme.fg("dim", timeAgo(session.lastActivityAt));
285
+
286
+ const row = ` ${icon} ${name}${friendlyName} ${id} ${statusLabel}${modelLabel} · ${activity}`;
287
+ lines.push(truncateToWidth(row, width));
288
+ }
289
+
290
+ // ── Worker rows (persistent workers) ──
291
+ if (state.workers && state.workers.length > 0) {
292
+ lines.push(
293
+ truncateToWidth(
294
+ " " + theme.fg("dim", "─ workers ─"),
295
+ width,
296
+ ),
297
+ );
298
+ for (const w of state.workers) {
299
+ // Resolve status icon — use stale icon for stale workers, otherwise map by status
300
+ const statusBase = w.status.startsWith("stale") ? "offline" : w.status;
301
+ const wIcon = WORKER_STATUS_ICON[statusBase] ?? { icon: "○", color: "muted" as ThemeColor };
302
+ const statusColor: ThemeColor = w.status.startsWith("stale") ? "warning" : wIcon.color;
303
+ const staleIndicator = w.stale ? theme.fg("warning", " ⚠ stale") : "";
304
+ const taskInfo = w.currentTaskId ? theme.fg("dim", ` · task=${w.currentTaskId}`) : "";
305
+ const workerLine = ` ${theme.fg(statusColor, wIcon.icon)} ${theme.bold(w.name)}: ${theme.fg(statusColor, w.status)} · tok=${formatTokens(w.tokenCountTotal)} · tools=${w.toolCallCount} · ${w.ageSeconds}s ago${taskInfo}${staleIndicator}`;
306
+ lines.push(truncateToWidth(workerLine, width));
307
+ }
308
+ }
309
+
310
+ // ── Separator + Summary ──
311
+ lines.push(
312
+ truncateToWidth(
313
+ " " + theme.fg("dim", "─".repeat(Math.max(0, width - 2))),
314
+ width,
315
+ ),
316
+ );
317
+
318
+ const totalActive = state.sessions.filter(
319
+ (s) => s.status === "active",
320
+ ).length;
321
+ const totalIdle = state.sessions.filter(
322
+ (s) => s.status === "idle",
323
+ ).length;
324
+ const totalStale = state.sessions.filter(
325
+ (s) => s.status === "stale",
326
+ ).length;
327
+ const totalSessions = state.sessions.length;
328
+
329
+ const summaryParts: string[] = [
330
+ `${totalSessions} session${totalSessions !== 1 ? "s" : ""}`,
331
+ ];
332
+ if (totalActive > 0) summaryParts.push(`${totalActive} active`);
333
+ if (totalIdle > 0) summaryParts.push(`${totalIdle} idle`);
334
+ if (totalStale > 0) summaryParts.push(`${totalStale} stale`);
335
+
336
+ // Worker summary counts
337
+ const workerCount = (state.workers?.length ?? 0);
338
+ if (workerCount > 0) {
339
+ const wBusy = state.workers!.filter((w) => w.status === "busy").length;
340
+ const wStale = state.workers!.filter((w) => w.stale).length;
341
+ const wIdle = workerCount - wBusy - wStale - state.workers!.filter((w) => w.status === "offline").length;
342
+ const wParts: string[] = [`${workerCount} worker${workerCount !== 1 ? "s" : ""}`];
343
+ if (wBusy > 0) wParts.push(`${wBusy} busy`);
344
+ if (wIdle > 0) wParts.push(`${wIdle} idle`);
345
+ if (wStale > 0) wParts.push(`${wStale} stale`);
346
+ summaryParts.push(wParts.join(" "));
347
+ }
348
+
349
+ const agentsConfigured = state.configuredAgentNames.length;
350
+ const defaultLabel = state.defaultAgent
351
+ ? ` · default: ${state.defaultAgent}`
352
+ : "";
353
+
354
+ lines.push(
355
+ truncateToWidth(
356
+ ` ${theme.fg("muted", summaryParts.join(" · "))} ${theme.fg("dim", `${agentsConfigured} agent${agentsConfigured !== 1 ? "s" : ""} configured${defaultLabel}`)}`,
357
+ width,
358
+ ),
359
+ );
360
+
361
+ // ── Hints ──
362
+ lines.push(
363
+ truncateToWidth(
364
+ theme.fg("dim", " /acp · /acp-config · acp_status · acp_prompt <msg>"),
365
+ width,
366
+ ),
367
+ );
368
+
369
+ return lines;
370
+ },
371
+
372
+ invalidate() {},
373
+
374
+ dispose() {
375
+ clearInterval(refreshInterval);
376
+ },
377
+ };
378
+ };
379
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * pi-acp-agents — Adapter factory with mode-based routing.
3
+ *
4
+ * Routing priority:
5
+ * 1. mode === 'acpx' → AcpxAdapter (CLI delegation)
6
+ * 2. mode === 'direct' or undefined → name-based routing to dedicated adapters
7
+ */
8
+
9
+ import type { AcpAgentAdapter } from "./adapters/base.js";
10
+ import { AcpxAdapter } from "./adapters/acpx.js";
11
+ import { CodexAcpAdapter } from "./adapters/codex.js";
12
+ import { CustomAcpAdapter } from "./adapters/custom.js";
13
+ import { GeminiAcpAdapter } from "./adapters/gemini.js";
14
+ import { OpenCodeAcpAdapter } from "./adapters/opencode.js";
15
+ import type { AcpAgentConfig, AcpConfig } from "./config/types.js";
16
+
17
+ /** Known adapter names that map to dedicated adapter classes */
18
+ const KNOWN_ADAPTERS = new Set(["gemini", "opencode", "codex"]);
19
+
20
+ export function createAdapter(
21
+ agentName: string,
22
+ agentConfig: AcpAgentConfig,
23
+ _globalConfig: AcpConfig,
24
+ cwd?: string,
25
+ adapterOpts?: { onActivity?: (sessionId: string) => void; onSessionUpdate?: (sessionId: string, update: import("@agentclientprotocol/sdk").SessionUpdate) => void },
26
+ ): AcpAgentAdapter {
27
+ const sharedOpts = { onActivity: adapterOpts?.onActivity, onSessionUpdate: adapterOpts?.onSessionUpdate };
28
+
29
+ // Mode-based routing: acpx mode always routes to AcpxAdapter
30
+ if (agentConfig.mode === "acpx") {
31
+ return new AcpxAdapter({ config: agentConfig, cwd, agentName, ...sharedOpts }) as unknown as AcpAgentAdapter;
32
+ }
33
+
34
+ // Direct mode (or undefined/default) → name-based routing
35
+ switch (agentName) {
36
+ case "gemini":
37
+ return new GeminiAcpAdapter({ config: agentConfig, cwd, ...sharedOpts });
38
+ case "opencode":
39
+ return new OpenCodeAcpAdapter({ config: agentConfig, cwd, ...sharedOpts });
40
+ case "codex":
41
+ return new CodexAcpAdapter({ config: agentConfig, cwd, ...sharedOpts });
42
+ default:
43
+ return new CustomAcpAdapter({
44
+ config: agentConfig,
45
+ agentName,
46
+ cwd,
47
+ ...sharedOpts,
48
+ });
49
+ }
50
+ }
51
+
52
+ /** Check if an agent name has a dedicated adapter */
53
+ export function isKnownAdapter(name: string): boolean {
54
+ return KNOWN_ADAPTERS.has(name);
55
+ }