@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,149 @@
1
+ /**
2
+ * pi-acp-agents — Async Executor (M1: Async Background Delegation)
3
+ *
4
+ * Runs agent delegation in a background Promise, tracking state in a file-backed store.
5
+ * Reuses AgentCoordinator.delegate() for actual ACP calls.
6
+ */
7
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ import { randomUUID } from "node:crypto";
10
+ import type { AcpAsyncRunRecord } from "../config/types.js";
11
+ import type { AgentCoordinator } from "../coordination/coordinator.js";
12
+ import { createNoopLogger } from "../logger.js";
13
+
14
+ const log = createNoopLogger();
15
+
16
+ interface AsyncStorePayload {
17
+ runs: AcpAsyncRunRecord[];
18
+ }
19
+
20
+ const DEFAULT_PAYLOAD: AsyncStorePayload = { runs: [] };
21
+
22
+ export class AsyncExecutor {
23
+ private runsFile: string;
24
+ private activePromises = new Map<string, Promise<void>>();
25
+
26
+ constructor(
27
+ private coordinator: AgentCoordinator,
28
+ runtimeDir: string,
29
+ ) {
30
+ mkdirSync(runtimeDir, { recursive: true });
31
+ this.runsFile = join(runtimeDir, "async-runs.json");
32
+ }
33
+
34
+ start(agentName: string, message: string, cwd?: string): string {
35
+ const runId = randomUUID().slice(0, 8);
36
+ const now = new Date().toISOString();
37
+ const record: AcpAsyncRunRecord = {
38
+ runId,
39
+ agentName,
40
+ message,
41
+ cwd,
42
+ state: "pending",
43
+ createdAt: now,
44
+ };
45
+ this.writeRun(record);
46
+
47
+ const promise = (async () => {
48
+ try {
49
+ this.updateRun(runId, { state: "running", startedAt: new Date().toISOString() });
50
+ const result = await this.coordinator.delegate(agentName, message, cwd);
51
+ this.updateRun(runId, {
52
+ state: "completed",
53
+ result: result.text,
54
+ sessionId: result.sessionId,
55
+ completedAt: new Date().toISOString(),
56
+ });
57
+ } catch (err: unknown) {
58
+ this.updateRun(runId, {
59
+ state: "failed",
60
+ error: err instanceof Error ? err.message : String(err),
61
+ completedAt: new Date().toISOString(),
62
+ });
63
+ } finally {
64
+ this.activePromises.delete(runId);
65
+ }
66
+ })();
67
+ this.activePromises.set(runId, promise);
68
+ return runId;
69
+ }
70
+
71
+ getStatus(runId: string): AcpAsyncRunRecord | undefined {
72
+ return this.readAll().runs.find((r) => r.runId === runId);
73
+ }
74
+
75
+ getResult(runId: string): string | null {
76
+ const run = this.getStatus(runId);
77
+ if (!run || run.state !== "completed") return null;
78
+ return run.result ?? null;
79
+ }
80
+
81
+ listActive(): AcpAsyncRunRecord[] {
82
+ return this.readAll().runs.filter(
83
+ (r) => r.state === "pending" || r.state === "running",
84
+ );
85
+ }
86
+
87
+ listAll(): AcpAsyncRunRecord[] {
88
+ return this.readAll().runs;
89
+ }
90
+
91
+ cancel(runId: string): boolean {
92
+ const run = this.getStatus(runId);
93
+ if (!run || run.state === "completed" || run.state === "failed") return false;
94
+ this.updateRun(runId, {
95
+ state: "failed",
96
+ error: "cancelled",
97
+ completedAt: new Date().toISOString(),
98
+ });
99
+ return true;
100
+ }
101
+
102
+ prune(olderThanMs: number): { pruned: number } {
103
+ const payload = this.readAll();
104
+ const cutoff = new Date(Date.now() - olderThanMs);
105
+ const before = payload.runs.length;
106
+ payload.runs = payload.runs.filter((r) => {
107
+ if (r.state === "pending" || r.state === "running") return true;
108
+ return new Date(r.completedAt ?? r.createdAt) >= cutoff;
109
+ });
110
+ this.writeAll(payload);
111
+ return { pruned: before - payload.runs.length };
112
+ }
113
+
114
+ private readAll(): AsyncStorePayload {
115
+ if (!existsSync(this.runsFile)) return structuredClone(DEFAULT_PAYLOAD);
116
+ try {
117
+ return JSON.parse(readFileSync(this.runsFile, "utf-8")) as AsyncStorePayload;
118
+ } catch (e) {
119
+ // File read failed — return default payload
120
+ log.debug("async-executor read failed", e);
121
+ return structuredClone(DEFAULT_PAYLOAD);
122
+ }
123
+ }
124
+
125
+ private writeAll(payload: AsyncStorePayload): void {
126
+ try {
127
+ writeFileSync(this.runsFile, JSON.stringify(payload, null, 2) + "\n", "utf-8");
128
+ } catch (e) {
129
+ // File read failed — return default payload
130
+ // EACCES or other FS error — silently degrade.
131
+ log.debug("async-executor write failed", e);
132
+ }
133
+ }
134
+
135
+ private writeRun(record: AcpAsyncRunRecord): void {
136
+ const payload = this.readAll();
137
+ payload.runs.push(record);
138
+ this.writeAll(payload);
139
+ }
140
+
141
+ private updateRun(runId: string, updates: Partial<AcpAsyncRunRecord>): void {
142
+ const payload = this.readAll();
143
+ const run = payload.runs.find((r) => r.runId === runId);
144
+ if (run) {
145
+ Object.assign(run, updates);
146
+ this.writeAll(payload);
147
+ }
148
+ }
149
+ }
@@ -0,0 +1,254 @@
1
+ /**
2
+ * Circuit breaker + stall timeout + process kill escalation.
3
+ */
4
+ import { type ChildProcess, execSync, spawn } from "node:child_process";
5
+ import { platform } from "node:os";
6
+ import type { CircuitState } from "../config/types.js";
7
+
8
+ export class CircuitOpenError extends Error {
9
+ constructor(message: string) {
10
+ super(message);
11
+ this.name = "CircuitOpenError";
12
+ }
13
+ }
14
+
15
+ export class CircuitHalfOpenError extends Error {
16
+ constructor(message: string) {
17
+ super(message);
18
+ this.name = "CircuitHalfOpenError";
19
+ }
20
+ }
21
+
22
+ interface AgentCircuit {
23
+ failures: number;
24
+ state: CircuitState;
25
+ lastFailureTime: number;
26
+ probing: boolean;
27
+ }
28
+
29
+ export class AcpCircuitBreaker {
30
+ // Per-agent circuit state
31
+ private agents = new Map<string, AgentCircuit>();
32
+ // Legacy global state for backward-compatible execute()
33
+ private failures = 0;
34
+ private _state: CircuitState = "closed";
35
+ private lastFailureTime = 0;
36
+ private probing = false; // EC-46: prevent concurrent half-open probes
37
+
38
+ constructor(
39
+ private maxFailures = 3,
40
+ private resetTimeoutMs = 60_000,
41
+ private stallTimeoutMs = 3_600_000, // 1 hour default
42
+ ) {
43
+ // Intentional no-op: defaults are set via parameter properties
44
+ }
45
+
46
+ get state(): CircuitState {
47
+ return this._state;
48
+ }
49
+
50
+ // --- Per-agent circuit breaker (for alias resolution) ---
51
+
52
+ /** Check if a specific agent's circuit is healthy (closed or half-open) */
53
+ isHealthy(agentName: string): boolean {
54
+ const circuit = this.agents.get(agentName);
55
+ if (!circuit) return true; // No history = healthy
56
+ if (circuit.state === "open") {
57
+ if (Date.now() - circuit.lastFailureTime < this.resetTimeoutMs) {
58
+ return false;
59
+ }
60
+ // Reset timeout elapsed — will transition to half-open on next attempt
61
+ return true;
62
+ }
63
+ return true; // closed or half-open = healthy
64
+ }
65
+
66
+ /** Record a success for a specific agent */
67
+ recordSuccess(agentName: string): void {
68
+ const circuit = this.getOrCreateAgent(agentName);
69
+ circuit.failures = 0;
70
+ circuit.state = "closed";
71
+ circuit.probing = false;
72
+ }
73
+
74
+ /** Record a failure for a specific agent */
75
+ recordFailure(agentName: string): void {
76
+ const circuit = this.getOrCreateAgent(agentName);
77
+ circuit.failures++;
78
+ circuit.lastFailureTime = Date.now();
79
+ circuit.probing = false;
80
+ if (circuit.failures >= this.maxFailures) {
81
+ circuit.state = "open";
82
+ }
83
+ }
84
+
85
+ /** Get circuit state for a specific agent */
86
+ getAgentState(agentName: string): CircuitState {
87
+ const circuit = this.agents.get(agentName);
88
+ if (!circuit) return "closed";
89
+ // Check if open circuit should transition to half-open
90
+ if (circuit.state === "open" && Date.now() - circuit.lastFailureTime >= this.resetTimeoutMs) {
91
+ return "half-open";
92
+ }
93
+ return circuit.state;
94
+ }
95
+
96
+ private getOrCreateAgent(agentName: string): AgentCircuit {
97
+ let circuit = this.agents.get(agentName);
98
+ if (!circuit) {
99
+ circuit = { failures: 0, state: "closed", lastFailureTime: 0, probing: false };
100
+ this.agents.set(agentName, circuit);
101
+ }
102
+ return circuit;
103
+ }
104
+
105
+ async execute<T>(fn: () => Promise<T>, opts?: { timeoutMs?: number }): Promise<T> {
106
+ // EC-46: prevent concurrent probes in half-open state
107
+ if (this._state === "half-open" && this.probing) {
108
+ throw new CircuitHalfOpenError(
109
+ "Circuit half-open probe already in progress",
110
+ );
111
+ }
112
+
113
+ if (this._state === "open") {
114
+ if (Date.now() - this.lastFailureTime < this.resetTimeoutMs) {
115
+ throw new CircuitOpenError("ACP agent circuit is open");
116
+ }
117
+ // Transition to half-open and mark that we're probing
118
+ this._state = "half-open";
119
+ this.probing = true;
120
+ }
121
+
122
+ try {
123
+ // Wrap with stall timeout
124
+ const effectiveTimeout = opts?.timeoutMs ?? this.stallTimeoutMs;
125
+ const raceResult = await this.executeWithStallTimeout(fn, {
126
+ stallTimeoutMs: effectiveTimeout,
127
+ onCancel: () => Promise.reject(new Error(`Operation stalled after ${effectiveTimeout}ms`)),
128
+ });
129
+
130
+ // Check if we timed out
131
+ if (raceResult.stalled) {
132
+ this.onFailure();
133
+ this.probing = false; // Reset probing flag
134
+ throw new Error(`Operation stalled after ${effectiveTimeout}ms`);
135
+ }
136
+
137
+ // Check if there was an error
138
+ if (raceResult.error) {
139
+ this.onFailure();
140
+ this.probing = false; // Reset probing flag
141
+ throw raceResult.error;
142
+ }
143
+
144
+ this.onSuccess();
145
+ this.probing = false; // Reset probing flag on success
146
+ return raceResult.result as T;
147
+ } catch (err) {
148
+ // Reset probing flag on any error
149
+ this.probing = false;
150
+ throw err;
151
+ }
152
+ }
153
+
154
+ async executeWithStallTimeout<T>(
155
+ fn: () => Promise<T>,
156
+ opts: { stallTimeoutMs: number; onCancel: () => Promise<void> },
157
+ ): Promise<{ result?: T; stalled: boolean; error?: unknown }> {
158
+ let settled = false;
159
+ let resolveRace: (value: {
160
+ result?: T;
161
+ stalled: boolean;
162
+ error?: unknown;
163
+ }) => void;
164
+ const racePromise = new Promise<{
165
+ result?: T;
166
+ stalled: boolean;
167
+ error?: unknown;
168
+ }>((resolve) => {
169
+ resolveRace = resolve;
170
+ });
171
+
172
+ const timeout = setTimeout(() => {
173
+ if (!settled) {
174
+ settled = true;
175
+ Promise.resolve(opts.onCancel())
176
+ .catch(() => {
177
+ // Stall timeout must still resolve even when cancellation fails.
178
+ })
179
+ .finally(() => {
180
+ resolveRace!({ stalled: true });
181
+ });
182
+ }
183
+ }, opts.stallTimeoutMs);
184
+
185
+ fn()
186
+ .then((result) => {
187
+ if (!settled) {
188
+ settled = true;
189
+ clearTimeout(timeout);
190
+ resolveRace!({ result, stalled: false });
191
+ }
192
+ })
193
+ .catch((err) => {
194
+ if (!settled) {
195
+ settled = true;
196
+ clearTimeout(timeout);
197
+ // Propagate error to caller
198
+ resolveRace!({ stalled: false, error: err });
199
+ }
200
+ });
201
+
202
+ return racePromise;
203
+ }
204
+
205
+ private onSuccess(): void {
206
+ this.failures = 0;
207
+ this._state = "closed";
208
+ }
209
+
210
+ private onFailure(): void {
211
+ this.failures++;
212
+ this.lastFailureTime = Date.now();
213
+ if (this.failures >= this.maxFailures) this._state = "open";
214
+ }
215
+ }
216
+
217
+ /**
218
+ * On Windows: uses `taskkill /T /F /PID` to kill the entire process tree,
219
+ * preventing orphaned child processes when `shell: true` was used in spawn().
220
+ * On non-Windows: standard SIGTERM → SIGKILL escalation (unchanged).
221
+ */
222
+ export function killWithEscalation(
223
+ proc: ChildProcess,
224
+ escalationMs = 5000,
225
+ ): void {
226
+ if (proc.killed) return;
227
+
228
+ if (platform() === "win32") {
229
+ // Windows: process-tree-aware kill via taskkill
230
+ if (proc.pid != null) {
231
+ try {
232
+ execSync(`taskkill /T /F /PID ${proc.pid}`, { stdio: "ignore", timeout: escalationMs });
233
+ } catch {
234
+ // process may already be dead — taskkill returns non-zero
235
+ }
236
+ }
237
+ return;
238
+ }
239
+
240
+ // Non-Windows: SIGTERM → SIGKILL escalation
241
+ try {
242
+ proc.kill("SIGTERM");
243
+ } catch {
244
+ // already dead
245
+ }
246
+ const timer = setTimeout(() => {
247
+ try {
248
+ if (!proc.killed) proc.kill("SIGKILL");
249
+ } catch {
250
+ // already dead
251
+ }
252
+ }, escalationMs);
253
+ timer.unref();
254
+ }