@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,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Health monitor — tracks ACP session lifecycle auto-close policies, polls periodically.
|
|
3
|
+
*
|
|
4
|
+
* Two detection layers:
|
|
5
|
+
* 1. Idle/stale detection (existing): disposes sessions with no activity after staleTimeoutMs.
|
|
6
|
+
* 2. Prompt stall detection (new): detects active prompts with no streaming chunks,
|
|
7
|
+
* emits needs-attention notification, then auto-interrupts if still idle.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { getSessionAutoCloseReason } from "./session-lifecycle.js";
|
|
11
|
+
|
|
12
|
+
export interface HealthMonitorable {
|
|
13
|
+
sessionId: string;
|
|
14
|
+
lastActivityAt: Date;
|
|
15
|
+
lastResponseAt?: Date;
|
|
16
|
+
completedAt?: Date;
|
|
17
|
+
busy?: boolean;
|
|
18
|
+
disposed: boolean;
|
|
19
|
+
/** True while a prompt() call is in-flight */
|
|
20
|
+
isPrompting?: boolean;
|
|
21
|
+
/** Timestamp when the current prompt started */
|
|
22
|
+
promptStartedAt?: Date;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Prompt stall reason — activity-based, separate from idle auto-close */
|
|
26
|
+
export type PromptStallReason = "slow-prompt" | "stalled-prompt";
|
|
27
|
+
|
|
28
|
+
export interface HealthMonitorOptions {
|
|
29
|
+
intervalMs: number;
|
|
30
|
+
staleTimeoutMs: number;
|
|
31
|
+
/** Idle threshold (ms) before emitting needs-attention for active prompts. Default: 60_000 */
|
|
32
|
+
needsAttentionMs?: number;
|
|
33
|
+
/** Idle threshold (ms) before auto-interrupting stalled prompts. Default: 300_000, 0 = disabled */
|
|
34
|
+
autoInterruptMs?: number;
|
|
35
|
+
/** Grace period (ms) after cancel before force-kill. Default: 10_000 */
|
|
36
|
+
interruptGraceMs?: number;
|
|
37
|
+
onStale?: (sessionId: string) => void | Promise<void>;
|
|
38
|
+
/** Called when a prompt has been idle > needsAttentionMs but < autoInterruptMs. Notification only. */
|
|
39
|
+
onNeedsAttention?: (sessionId: string) => void | Promise<void>;
|
|
40
|
+
/** Called when a prompt is auto-interrupted (stalled-prompt). index.ts handles cancel → kill. */
|
|
41
|
+
onInterrupt?: (sessionId: string) => void | Promise<void>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface TrackedEntry {
|
|
45
|
+
session: HealthMonitorable;
|
|
46
|
+
/** Track whether we already notified for this stall cycle (reset on next touch) */
|
|
47
|
+
attentionNotified: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export class HealthMonitor {
|
|
51
|
+
private entries = new Map<string, TrackedEntry>();
|
|
52
|
+
private timer: ReturnType<typeof setInterval> | null = null;
|
|
53
|
+
private _running = false;
|
|
54
|
+
private opts: HealthMonitorOptions;
|
|
55
|
+
|
|
56
|
+
constructor(opts: HealthMonitorOptions) {
|
|
57
|
+
this.opts = opts;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
get size(): number {
|
|
61
|
+
return this.entries.size;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
get running(): boolean {
|
|
65
|
+
return this._running;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
register(session: HealthMonitorable): void {
|
|
69
|
+
this.entries.set(session.sessionId, { session, attentionNotified: false });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
unregister(sessionId: string): void {
|
|
73
|
+
this.entries.delete(sessionId);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
isStale(sessionId: string): boolean {
|
|
77
|
+
const entry = this.entries.get(sessionId);
|
|
78
|
+
if (!entry) return false;
|
|
79
|
+
return this.getStaleReason(entry.session) !== undefined;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
touch(sessionId: string): void {
|
|
83
|
+
const entry = this.entries.get(sessionId);
|
|
84
|
+
if (entry) {
|
|
85
|
+
entry.session.lastActivityAt = new Date();
|
|
86
|
+
entry.attentionNotified = false;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Mark that a prompt() call has started for this session */
|
|
91
|
+
markPromptStart(sessionId: string): void {
|
|
92
|
+
const entry = this.entries.get(sessionId);
|
|
93
|
+
if (entry) {
|
|
94
|
+
entry.session.isPrompting = true;
|
|
95
|
+
entry.session.promptStartedAt = new Date();
|
|
96
|
+
entry.attentionNotified = false;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Mark that the prompt() call has ended (completed or threw) */
|
|
101
|
+
markPromptEnd(sessionId: string): void {
|
|
102
|
+
const entry = this.entries.get(sessionId);
|
|
103
|
+
if (entry) {
|
|
104
|
+
entry.session.isPrompting = false;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
start(): void {
|
|
109
|
+
if (this._running) return;
|
|
110
|
+
this._running = true;
|
|
111
|
+
this.timer = setInterval(async () => {
|
|
112
|
+
const staleIds = await this.check();
|
|
113
|
+
if (this.opts.onStale) {
|
|
114
|
+
for (const id of staleIds) {
|
|
115
|
+
try {
|
|
116
|
+
await this.opts.onStale(id);
|
|
117
|
+
} catch (err) {
|
|
118
|
+
console.error("[acp-health] onStale callback error:", err);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}, this.opts.intervalMs);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
stop(): void {
|
|
126
|
+
this._running = false;
|
|
127
|
+
if (this.timer) {
|
|
128
|
+
clearInterval(this.timer);
|
|
129
|
+
this.timer = null;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async check(): Promise<string[]> {
|
|
134
|
+
const staleIds: string[] = [];
|
|
135
|
+
const toRemove: string[] = [];
|
|
136
|
+
|
|
137
|
+
for (const [id, entry] of this.entries) {
|
|
138
|
+
if (entry.session.disposed) {
|
|
139
|
+
toRemove.push(id);
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Check idle/stale detection (existing)
|
|
144
|
+
if (this.getStaleReason(entry.session)) {
|
|
145
|
+
staleIds.push(id);
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Check prompt stall detection (new) — only for active prompts
|
|
150
|
+
const stallReason = this.getPromptStallReason(entry.session);
|
|
151
|
+
if (stallReason === "slow-prompt") {
|
|
152
|
+
// Emit notification once per stall cycle (reset on touch)
|
|
153
|
+
if (!entry.attentionNotified && this.opts.onNeedsAttention) {
|
|
154
|
+
entry.attentionNotified = true;
|
|
155
|
+
try {
|
|
156
|
+
await this.opts.onNeedsAttention(id);
|
|
157
|
+
} catch (err) {
|
|
158
|
+
console.error("[acp-health] onNeedsAttention callback error:", err);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
// Don't add to staleIds — this is notification only
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (stallReason === "stalled-prompt") {
|
|
165
|
+
staleIds.push(id);
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
for (const id of toRemove) {
|
|
171
|
+
this.entries.delete(id);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return staleIds;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
private getStaleReason(session: HealthMonitorable): "stalled-no-response" | "completed-idle" | undefined {
|
|
178
|
+
return getSessionAutoCloseReason(session, this.opts.staleTimeoutMs);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Check if an active prompt has been idle too long.
|
|
183
|
+
* Uses lastActivityAt (updated by touch() via onActivity callback) as the activity signal.
|
|
184
|
+
*/
|
|
185
|
+
private getPromptStallReason(session: HealthMonitorable): PromptStallReason | undefined {
|
|
186
|
+
const autoInterruptMs = this.opts.autoInterruptMs ?? 300_000;
|
|
187
|
+
if (autoInterruptMs === 0) return undefined; // disabled
|
|
188
|
+
if (!session.isPrompting) return undefined;
|
|
189
|
+
|
|
190
|
+
const now = Date.now();
|
|
191
|
+
const idleMs = now - session.lastActivityAt.getTime();
|
|
192
|
+
|
|
193
|
+
if (idleMs > autoInterruptMs) return "stalled-prompt";
|
|
194
|
+
|
|
195
|
+
const needsAttentionMs = this.opts.needsAttentionMs ?? 60_000;
|
|
196
|
+
if (idleMs > needsAttentionMs) return "slow-prompt";
|
|
197
|
+
|
|
198
|
+
return undefined;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ACP Protocol Validation — behavior-based detection of non-ACP agents.
|
|
3
|
+
*
|
|
4
|
+
* Validates that an agent actually speaks the Agent Client Protocol by checking
|
|
5
|
+
* response shapes at runtime. Does NOT judge by command name.
|
|
6
|
+
*
|
|
7
|
+
* When a mismatch is detected, throws `AcpProtocolError` with an actionable message
|
|
8
|
+
* explaining what went wrong and how to fix it.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Thrown when an agent's behavior doesn't match ACP protocol */
|
|
12
|
+
export class AcpProtocolError extends Error {
|
|
13
|
+
readonly agentName: string;
|
|
14
|
+
readonly command: string;
|
|
15
|
+
readonly phase: AcpPhase;
|
|
16
|
+
readonly cause_detail: string;
|
|
17
|
+
|
|
18
|
+
constructor(opts: {
|
|
19
|
+
agentName: string;
|
|
20
|
+
command: string;
|
|
21
|
+
phase: AcpPhase;
|
|
22
|
+
message: string;
|
|
23
|
+
cause: string;
|
|
24
|
+
}) {
|
|
25
|
+
super(
|
|
26
|
+
`[ACP Protocol Mismatch] Agent "${opts.agentName}" (${opts.command}) failed at ${opts.phase}:\n` +
|
|
27
|
+
` ${opts.message}\n` +
|
|
28
|
+
` Cause: ${opts.cause}\n` +
|
|
29
|
+
` Fix: verify the command speaks ACP over stdio (nd-JSON). ` +
|
|
30
|
+
`For Zed/JetBrains-style config, use: { "command": "<agent>", "args": ["acp"] }`,
|
|
31
|
+
);
|
|
32
|
+
this.name = "AcpProtocolError";
|
|
33
|
+
this.agentName = opts.agentName;
|
|
34
|
+
this.command = opts.command;
|
|
35
|
+
this.phase = opts.phase;
|
|
36
|
+
this.cause_detail = opts.cause;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Phases of ACP protocol where mismatches can occur */
|
|
41
|
+
export type AcpPhase =
|
|
42
|
+
| "spawn"
|
|
43
|
+
| "connect"
|
|
44
|
+
| "initialize"
|
|
45
|
+
| "newSession"
|
|
46
|
+
| "prompt"
|
|
47
|
+
| "response_shape";
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Validate the `initialize` response has the minimum ACP shape.
|
|
51
|
+
*
|
|
52
|
+
* Per ACP spec, initialize MUST return:
|
|
53
|
+
* - protocolVersion: string
|
|
54
|
+
*
|
|
55
|
+
* And SHOULD return (may be absent in older agents):
|
|
56
|
+
* - agentCapabilities: object
|
|
57
|
+
* - agentInfo: { name?: string, version?: string }
|
|
58
|
+
*/
|
|
59
|
+
export function validateInitializeResponse(
|
|
60
|
+
resp: unknown,
|
|
61
|
+
agentName: string,
|
|
62
|
+
command: string,
|
|
63
|
+
): void {
|
|
64
|
+
if (!resp || typeof resp !== "object") {
|
|
65
|
+
throw new AcpProtocolError({
|
|
66
|
+
agentName,
|
|
67
|
+
command,
|
|
68
|
+
phase: "initialize",
|
|
69
|
+
message: `Response is ${resp === null ? "null" : typeof resp}, expected an object.`,
|
|
70
|
+
cause: "The agent did not return a valid JSON-RPC response to the 'initialize' method. " +
|
|
71
|
+
"This usually means the command does not speak ACP, or it printed non-JSON output to stdout.",
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const r = resp as Record<string, unknown>;
|
|
76
|
+
|
|
77
|
+
if (!("protocolVersion" in r)) {
|
|
78
|
+
throw new AcpProtocolError({
|
|
79
|
+
agentName,
|
|
80
|
+
command,
|
|
81
|
+
phase: "initialize",
|
|
82
|
+
message: "Missing 'protocolVersion' field.",
|
|
83
|
+
cause: "The response does not look like an ACP InitializeResponse. " +
|
|
84
|
+
"The command may speak a different protocol or returned an error. " +
|
|
85
|
+
"Got keys: " + Object.keys(r).join(", "),
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Warn but don't fail on missing optional fields
|
|
90
|
+
if (!("agentCapabilities" in r) && !("capabilities" in r)) {
|
|
91
|
+
// Log but don't throw — agentCapabilities is optional in the spec
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Validate the `newSession` response has the minimum ACP shape.
|
|
97
|
+
*
|
|
98
|
+
* Per ACP spec, newSession MUST return:
|
|
99
|
+
* - sessionId: string
|
|
100
|
+
*/
|
|
101
|
+
export function validateNewSessionResponse(
|
|
102
|
+
resp: unknown,
|
|
103
|
+
agentName: string,
|
|
104
|
+
command: string,
|
|
105
|
+
): void {
|
|
106
|
+
if (!resp || typeof resp !== "object") {
|
|
107
|
+
throw new AcpProtocolError({
|
|
108
|
+
agentName,
|
|
109
|
+
command,
|
|
110
|
+
phase: "newSession",
|
|
111
|
+
message: `Response is ${resp === null ? "null" : typeof resp}, expected an object.`,
|
|
112
|
+
cause: "The agent did not return a valid response to the 'session/new' method.",
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const r = resp as Record<string, unknown>;
|
|
117
|
+
|
|
118
|
+
if (!("sessionId" in r) || typeof r.sessionId !== "string" || !r.sessionId) {
|
|
119
|
+
throw new AcpProtocolError({
|
|
120
|
+
agentName,
|
|
121
|
+
command,
|
|
122
|
+
phase: "newSession",
|
|
123
|
+
message: "Missing or invalid 'sessionId' field.",
|
|
124
|
+
cause: "ACP requires a 'sessionId' string in the newSession response. " +
|
|
125
|
+
"Got keys: " + Object.keys(r).join(", "),
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Validate the `prompt` response has the minimum ACP shape.
|
|
132
|
+
*
|
|
133
|
+
* Per ACP spec, prompt MUST return:
|
|
134
|
+
* - stopReason: string
|
|
135
|
+
*/
|
|
136
|
+
export function validatePromptResponse(
|
|
137
|
+
resp: unknown,
|
|
138
|
+
agentName: string,
|
|
139
|
+
command: string,
|
|
140
|
+
): void {
|
|
141
|
+
if (!resp || typeof resp !== "object") {
|
|
142
|
+
throw new AcpProtocolError({
|
|
143
|
+
agentName,
|
|
144
|
+
command,
|
|
145
|
+
phase: "prompt",
|
|
146
|
+
message: `Response is ${resp === null ? "null" : typeof resp}, expected an object.`,
|
|
147
|
+
cause: "The agent did not return a valid response to the 'prompt' method.",
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const r = resp as Record<string, unknown>;
|
|
152
|
+
|
|
153
|
+
if (!("stopReason" in r)) {
|
|
154
|
+
throw new AcpProtocolError({
|
|
155
|
+
agentName,
|
|
156
|
+
command,
|
|
157
|
+
phase: "prompt",
|
|
158
|
+
message: "Missing 'stopReason' field.",
|
|
159
|
+
cause: "ACP requires 'stopReason' in the prompt response. " +
|
|
160
|
+
"Got keys: " + Object.keys(r).join(", "),
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Classify an error from the ACP connection phase.
|
|
167
|
+
* Returns an AcpProtocolError if the error indicates a protocol mismatch,
|
|
168
|
+
* or the original error if it's something else (network, timeout, etc).
|
|
169
|
+
*/
|
|
170
|
+
export function classifyConnectionError(
|
|
171
|
+
err: unknown,
|
|
172
|
+
agentName: string,
|
|
173
|
+
command: string,
|
|
174
|
+
stderr?: string,
|
|
175
|
+
): Error {
|
|
176
|
+
if (err instanceof AcpProtocolError) return err;
|
|
177
|
+
|
|
178
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
179
|
+
const code = (err as { code?: string } | null)?.code ?? "";
|
|
180
|
+
|
|
181
|
+
// Method not found (agent doesn't implement ACP) — check BEFORE ENOENT
|
|
182
|
+
if (msg.includes("Method not found") || msg.includes("-32601")) {
|
|
183
|
+
return new AcpProtocolError({
|
|
184
|
+
agentName,
|
|
185
|
+
command,
|
|
186
|
+
phase: "initialize",
|
|
187
|
+
message: "Agent does not implement the ACP 'initialize' method.",
|
|
188
|
+
cause: "The command exists but doesn't speak ACP. " +
|
|
189
|
+
"Ensure it's an ACP-compatible agent or you're passing the correct args (e.g., 'acp' or '--acp').",
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Binary missing (ENOENT) or spawn-level failure
|
|
194
|
+
if (msg.includes("ENOENT") || msg.includes("spawn")) {
|
|
195
|
+
return new AcpProtocolError({
|
|
196
|
+
agentName,
|
|
197
|
+
command,
|
|
198
|
+
phase: "spawn",
|
|
199
|
+
message: `Command "${command}" could not be spawned.`,
|
|
200
|
+
cause: msg + (stderr ? `\nStderr: ${stderr.slice(0, 500)}` : ""),
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Broken pipe / connection reset while talking to the child — the child
|
|
205
|
+
// exited before the ACP handshake completed (GAP-2). Common when the binary
|
|
206
|
+
// EXISTS but crashes / has wrong args and dies immediately (e.g. `false`,
|
|
207
|
+
// missing '--acp' flag). The process writes its initialize request, the
|
|
208
|
+
// already-dead child's pipe returns EPIPE. Surface as a fast, classified,
|
|
209
|
+
// spawn-phase rejection instead of hanging to an RPC timeout.
|
|
210
|
+
if (
|
|
211
|
+
code === "EPIPE" || code === "ECONNRESET" ||
|
|
212
|
+
msg.includes("EPIPE") || msg.includes("ECONNRESET") ||
|
|
213
|
+
msg.includes("ERR_STREAM_WRITE_AFTER_END")
|
|
214
|
+
) {
|
|
215
|
+
return new AcpProtocolError({
|
|
216
|
+
agentName,
|
|
217
|
+
command,
|
|
218
|
+
phase: "spawn",
|
|
219
|
+
message: `Command "${command}" exited immediately before completing the ACP handshake.`,
|
|
220
|
+
cause:
|
|
221
|
+
"The process started but exited before speaking ACP — the binary " +
|
|
222
|
+
"may be missing the ACP flag (e.g. '--acp' or 'acp') or crashed on " +
|
|
223
|
+
"startup. Original error: " + msg +
|
|
224
|
+
(stderr ? `\nStderr: ${stderr.slice(0, 500)}` : ""),
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// JSON parse errors (agent wrote non-JSON to stdout)
|
|
229
|
+
if (msg.includes("JSON") || msg.includes("parse") || msg.includes("Unexpected token")) {
|
|
230
|
+
return new AcpProtocolError({
|
|
231
|
+
agentName,
|
|
232
|
+
command,
|
|
233
|
+
phase: "connect",
|
|
234
|
+
message: "Agent output is not valid JSON-RPC.",
|
|
235
|
+
cause: msg + (stderr ? `\nStderr: ${stderr.slice(0, 500)}` : ""),
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Auth errors — NOT a protocol mismatch, just needs setup
|
|
240
|
+
if (msg.includes("auth") || msg.includes("Auth") || msg.includes("401") || msg.includes("403")) {
|
|
241
|
+
return err instanceof Error ? err : new Error(msg);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Timeout — agent hung without responding
|
|
245
|
+
if (msg.includes("timeout") || msg.includes("Timeout") || msg.includes("timed out")) {
|
|
246
|
+
return new AcpProtocolError({
|
|
247
|
+
agentName,
|
|
248
|
+
command,
|
|
249
|
+
phase: "connect",
|
|
250
|
+
message: "Agent did not respond within timeout.",
|
|
251
|
+
cause: "The process started but never sent a valid ACP response. " +
|
|
252
|
+
"It may be waiting for interactive input, or the command doesn't speak ACP." +
|
|
253
|
+
(stderr ? `\nStderr: ${stderr.slice(0, 500)}` : ""),
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Unknown error — pass through as-is
|
|
258
|
+
return err instanceof Error ? err : new Error(msg);
|
|
259
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { AcpSessionHandle } from "../config/types.js";
|
|
2
|
+
import type { HealthMonitorable } from "./health-monitor.js";
|
|
3
|
+
|
|
4
|
+
export type SessionAutoCloseReason = "stalled-no-response" | "completed-idle";
|
|
5
|
+
|
|
6
|
+
export interface SessionLifecycleState {
|
|
7
|
+
disposed: boolean;
|
|
8
|
+
busy?: boolean;
|
|
9
|
+
lastResponseAt?: Date;
|
|
10
|
+
completedAt?: Date;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function getSessionAutoCloseReason(
|
|
14
|
+
session: SessionLifecycleState,
|
|
15
|
+
timeoutMs: number,
|
|
16
|
+
now = Date.now(),
|
|
17
|
+
): SessionAutoCloseReason | undefined {
|
|
18
|
+
if (session.busy) {
|
|
19
|
+
if (!session.lastResponseAt) return undefined;
|
|
20
|
+
return now - session.lastResponseAt.getTime() > timeoutMs
|
|
21
|
+
? "stalled-no-response"
|
|
22
|
+
: undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (!session.completedAt) return undefined;
|
|
26
|
+
return now - session.completedAt.getTime() > timeoutMs
|
|
27
|
+
? "completed-idle"
|
|
28
|
+
: undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function isSessionAutoClosable(
|
|
32
|
+
session: SessionLifecycleState,
|
|
33
|
+
timeoutMs: number,
|
|
34
|
+
now = Date.now(),
|
|
35
|
+
): boolean {
|
|
36
|
+
return getSessionAutoCloseReason(session, timeoutMs, now) !== undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function getSessionPruneReason(
|
|
40
|
+
session: Pick<AcpSessionHandle | HealthMonitorable, "disposed" | "busy" | "lastResponseAt" | "completedAt">,
|
|
41
|
+
timeoutMs: number,
|
|
42
|
+
now = Date.now(),
|
|
43
|
+
): "disposed" | SessionAutoCloseReason | undefined {
|
|
44
|
+
if (session.disposed) return "disposed";
|
|
45
|
+
return getSessionAutoCloseReason(session, timeoutMs, now);
|
|
46
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-acp-agents — Session manager
|
|
3
|
+
*
|
|
4
|
+
* Simple add/get/remove/list/disposeAll for AcpSessionHandle objects.
|
|
5
|
+
*/
|
|
6
|
+
import type { AcpSessionHandle } from "../config/types.js";
|
|
7
|
+
import { getSessionPruneReason } from "./session-lifecycle.js";
|
|
8
|
+
|
|
9
|
+
export interface SessionPruneResult {
|
|
10
|
+
removedSessionIds: string[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class SessionManager {
|
|
14
|
+
private sessions = new Map<string, AcpSessionHandle>();
|
|
15
|
+
|
|
16
|
+
add(handle: AcpSessionHandle): void {
|
|
17
|
+
this.sessions.set(handle.sessionId, handle);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
get(sessionId: string): AcpSessionHandle | undefined {
|
|
21
|
+
return this.sessions.get(sessionId);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
list(): AcpSessionHandle[] {
|
|
25
|
+
return Array.from(this.sessions.values());
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
listByAgent(agentName?: string): AcpSessionHandle[] {
|
|
29
|
+
return this.list().filter((session) => !agentName || session.agentName === agentName);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async remove(sessionId: string): Promise<void> {
|
|
33
|
+
const handle = this.sessions.get(sessionId);
|
|
34
|
+
if (handle) {
|
|
35
|
+
try {
|
|
36
|
+
await handle.dispose();
|
|
37
|
+
} catch (err) {
|
|
38
|
+
console.error("[acp] dispose error:", err);
|
|
39
|
+
}
|
|
40
|
+
this.sessions.delete(sessionId);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async disposeAll(): Promise<void> {
|
|
45
|
+
const ids = Array.from(this.sessions.keys());
|
|
46
|
+
for (const id of ids) {
|
|
47
|
+
await this.remove(id);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async pruneStale(maxIdleMs: number, now = Date.now()): Promise<SessionPruneResult> {
|
|
52
|
+
const removedSessionIds: string[] = [];
|
|
53
|
+
for (const session of this.list()) {
|
|
54
|
+
if (!getSessionPruneReason(session, maxIdleMs, now)) continue;
|
|
55
|
+
removedSessionIds.push(session.sessionId);
|
|
56
|
+
await this.remove(session.sessionId);
|
|
57
|
+
}
|
|
58
|
+
return { removedSessionIds };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
get size(): number {
|
|
62
|
+
return this.sessions.size;
|
|
63
|
+
}
|
|
64
|
+
}
|