@botcord/daemon 0.2.5 → 0.2.8
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/dist/agent-discovery.d.ts +4 -0
- package/dist/agent-discovery.js +8 -0
- package/dist/agent-workspace.d.ts +62 -0
- package/dist/agent-workspace.js +140 -8
- package/dist/config.d.ts +64 -1
- package/dist/config.js +73 -1
- package/dist/daemon-config-map.d.ts +27 -9
- package/dist/daemon-config-map.js +105 -8
- package/dist/daemon.d.ts +2 -0
- package/dist/daemon.js +76 -6
- package/dist/doctor.d.ts +27 -1
- package/dist/doctor.js +22 -1
- package/dist/gateway/cli-resolver.d.ts +34 -0
- package/dist/gateway/cli-resolver.js +74 -0
- package/dist/gateway/dispatcher.d.ts +31 -1
- package/dist/gateway/dispatcher.js +337 -29
- package/dist/gateway/gateway.d.ts +29 -1
- package/dist/gateway/gateway.js +10 -0
- package/dist/gateway/index.d.ts +2 -0
- package/dist/gateway/index.js +2 -0
- package/dist/gateway/policy-resolver.d.ts +57 -0
- package/dist/gateway/policy-resolver.js +123 -0
- package/dist/gateway/runtimes/acp-stream.d.ts +99 -0
- package/dist/gateway/runtimes/acp-stream.js +394 -0
- package/dist/gateway/runtimes/codex.js +7 -0
- package/dist/gateway/runtimes/hermes-agent.d.ts +83 -0
- package/dist/gateway/runtimes/hermes-agent.js +180 -0
- package/dist/gateway/runtimes/ndjson-stream.d.ts +7 -2
- package/dist/gateway/runtimes/ndjson-stream.js +16 -3
- package/dist/gateway/runtimes/openclaw-acp.d.ts +44 -0
- package/dist/gateway/runtimes/openclaw-acp.js +500 -0
- package/dist/gateway/runtimes/registry.d.ts +4 -0
- package/dist/gateway/runtimes/registry.js +22 -0
- package/dist/gateway/transcript-paths.d.ts +30 -0
- package/dist/gateway/transcript-paths.js +114 -0
- package/dist/gateway/transcript.d.ts +123 -0
- package/dist/gateway/transcript.js +147 -0
- package/dist/gateway/types.d.ts +31 -0
- package/dist/index.js +309 -27
- package/dist/mention-scan.d.ts +22 -0
- package/dist/mention-scan.js +35 -0
- package/dist/openclaw-discovery.d.ts +28 -0
- package/dist/openclaw-discovery.js +228 -0
- package/dist/provision.d.ts +113 -1
- package/dist/provision.js +564 -12
- package/dist/system-context.d.ts +5 -4
- package/dist/system-context.js +35 -5
- package/dist/url-utils.d.ts +9 -0
- package/dist/url-utils.js +18 -0
- package/package.json +3 -2
- package/src/__tests__/agent-workspace.test.ts +93 -0
- package/src/__tests__/daemon-config-map.test.ts +79 -0
- package/src/__tests__/openclaw-acp.test.ts +234 -0
- package/src/__tests__/openclaw-discovery.test.ts +150 -0
- package/src/__tests__/policy-resolver.test.ts +124 -0
- package/src/__tests__/policy-updated-handler.test.ts +144 -0
- package/src/__tests__/provision.test.ts +265 -0
- package/src/__tests__/system-context.test.ts +52 -0
- package/src/__tests__/url-utils.test.ts +37 -0
- package/src/agent-discovery.ts +8 -0
- package/src/agent-workspace.ts +173 -7
- package/src/config.ts +168 -4
- package/src/daemon-config-map.ts +154 -9
- package/src/daemon.ts +96 -6
- package/src/doctor.ts +49 -2
- package/src/gateway/__tests__/dispatcher.test.ts +65 -0
- package/src/gateway/__tests__/hermes-agent-adapter.test.ts +302 -0
- package/src/gateway/__tests__/transcript.test.ts +496 -0
- package/src/gateway/cli-resolver.ts +92 -0
- package/src/gateway/dispatcher.ts +394 -26
- package/src/gateway/gateway.ts +46 -0
- package/src/gateway/index.ts +25 -0
- package/src/gateway/policy-resolver.ts +171 -0
- package/src/gateway/runtimes/acp-stream.ts +535 -0
- package/src/gateway/runtimes/codex.ts +7 -0
- package/src/gateway/runtimes/hermes-agent.ts +206 -0
- package/src/gateway/runtimes/ndjson-stream.ts +16 -3
- package/src/gateway/runtimes/openclaw-acp.ts +606 -0
- package/src/gateway/runtimes/registry.ts +24 -0
- package/src/gateway/transcript-paths.ts +145 -0
- package/src/gateway/transcript.ts +300 -0
- package/src/gateway/types.ts +32 -0
- package/src/index.ts +321 -30
- package/src/mention-scan.ts +38 -0
- package/src/openclaw-discovery.ts +262 -0
- package/src/provision.ts +682 -14
- package/src/system-context.ts +41 -9
- package/src/url-utils.ts +17 -0
package/dist/gateway/index.d.ts
CHANGED
|
@@ -8,6 +8,8 @@ export { resolveRoute, matchesRoute } from "./router.js";
|
|
|
8
8
|
export { ChannelManager, type ChannelManagerOptions, type ChannelBackoffOptions } from "./channel-manager.js";
|
|
9
9
|
export { Dispatcher, type DispatcherOptions, type RuntimeFactory } from "./dispatcher.js";
|
|
10
10
|
export { Gateway, type GatewayBootOptions } from "./gateway.js";
|
|
11
|
+
export { createTranscriptWriter, resolveTranscriptEnabled, defaultTranscriptRoot, truncateTextField, TRANSCRIPT_TEXT_LIMIT, TRANSCRIPT_FILE_LIMIT, type TranscriptWriter, type TranscriptRecord, type InboundTranscriptRecord, type DispatchedTranscriptRecord, type ComposeFailedTranscriptRecord, type OutboundTranscriptRecord, type TurnErrorTranscriptRecord, type AttentionSkippedTranscriptRecord, type DroppedTranscriptRecord, type DeliveryStatus, type DroppedReason, } from "./transcript.js";
|
|
12
|
+
export { safePathSegment, transcriptFilePath, transcriptRoomDir, transcriptAgentRoot, } from "./transcript-paths.js";
|
|
11
13
|
export { ClaudeCodeAdapter, probeClaude, resolveClaudeCommand, } from "./runtimes/claude-code.js";
|
|
12
14
|
export { CodexAdapter, probeCodex, resolveCodexCommand } from "./runtimes/codex.js";
|
|
13
15
|
export { GeminiAdapter, probeGemini, resolveGeminiCommand } from "./runtimes/gemini.js";
|
package/dist/gateway/index.js
CHANGED
|
@@ -8,6 +8,8 @@ export { resolveRoute, matchesRoute } from "./router.js";
|
|
|
8
8
|
export { ChannelManager } from "./channel-manager.js";
|
|
9
9
|
export { Dispatcher } from "./dispatcher.js";
|
|
10
10
|
export { Gateway } from "./gateway.js";
|
|
11
|
+
export { createTranscriptWriter, resolveTranscriptEnabled, defaultTranscriptRoot, truncateTextField, TRANSCRIPT_TEXT_LIMIT, TRANSCRIPT_FILE_LIMIT, } from "./transcript.js";
|
|
12
|
+
export { safePathSegment, transcriptFilePath, transcriptRoomDir, transcriptAgentRoot, } from "./transcript-paths.js";
|
|
11
13
|
export { ClaudeCodeAdapter, probeClaude, resolveClaudeCommand, } from "./runtimes/claude-code.js";
|
|
12
14
|
export { CodexAdapter, probeCodex, resolveCodexCommand } from "./runtimes/codex.js";
|
|
13
15
|
export { GeminiAdapter, probeGemini, resolveGeminiCommand } from "./runtimes/gemini.js";
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Daemon-side per-agent attention-policy cache (PR3, design §5).
|
|
3
|
+
*
|
|
4
|
+
* The dispatcher consults this resolver after `onInbound` fires and before
|
|
5
|
+
* the runtime turn enqueues. Cache layout:
|
|
6
|
+
*
|
|
7
|
+
* - `agent_id` → global default policy (seeded by
|
|
8
|
+
* `provision_agent` + `policy_updated{agent}`).
|
|
9
|
+
* - `agent_id:room_id` → genuine per-room override only — installed
|
|
10
|
+
* exclusively via `put` from a per-room
|
|
11
|
+
* `policy_updated` frame. Inheritance reads
|
|
12
|
+
* never write here.
|
|
13
|
+
*
|
|
14
|
+
* `resolve(agent, room)` checks the room key first, then falls back to the
|
|
15
|
+
* global key. This means a per-room override always wins, and the global
|
|
16
|
+
* propagates to every room without explicit fan-out (a global update only
|
|
17
|
+
* needs to refresh the agent_id entry).
|
|
18
|
+
*
|
|
19
|
+
* `invalidate(agent_id, room_id)` drops the matching room entry; the next
|
|
20
|
+
* resolve falls through to the global. `invalidate(agent_id)` drops every
|
|
21
|
+
* entry for that agent — both global and any room overrides — used when
|
|
22
|
+
* the agent is revoked or the cache must rebuild from scratch.
|
|
23
|
+
*/
|
|
24
|
+
import type { AttentionPolicy } from "@botcord/protocol-core";
|
|
25
|
+
/** Public surface — kept narrow so the dispatcher can mock easily in tests. */
|
|
26
|
+
export interface PolicyResolverLike {
|
|
27
|
+
resolve(agentId: string, roomId: string | null): Promise<AttentionPolicy>;
|
|
28
|
+
invalidate(agentId: string, roomId?: string): void;
|
|
29
|
+
/**
|
|
30
|
+
* Install (or replace) the cached policy entry for an agent / room. Used
|
|
31
|
+
* by the `policy_updated` control-frame handler to apply embedded policy
|
|
32
|
+
* payloads without forcing a refetch.
|
|
33
|
+
*/
|
|
34
|
+
put(agentId: string, roomId: string | null, policy: AttentionPolicy): void;
|
|
35
|
+
}
|
|
36
|
+
export interface PolicyResolverOptions {
|
|
37
|
+
/** Fetcher for the per-agent default. Returning `undefined` means "no policy known"; the resolver falls back to `mode=always`. */
|
|
38
|
+
fetchGlobal: (agentId: string) => Promise<AttentionPolicy | undefined>;
|
|
39
|
+
/**
|
|
40
|
+
* Optional per-room fetcher. PR2 supplies this; PR3 leaves it
|
|
41
|
+
* unimplemented and the resolver collapses to the global policy.
|
|
42
|
+
*/
|
|
43
|
+
fetchEffective?: (agentId: string, roomId: string) => Promise<AttentionPolicy | undefined>;
|
|
44
|
+
/** Cache TTL in milliseconds. Defaults to 5 minutes. */
|
|
45
|
+
ttlMs?: number;
|
|
46
|
+
}
|
|
47
|
+
export declare class PolicyResolver implements PolicyResolverLike {
|
|
48
|
+
private readonly fetchGlobal;
|
|
49
|
+
private readonly fetchEffective?;
|
|
50
|
+
private readonly ttlMs;
|
|
51
|
+
private readonly cache;
|
|
52
|
+
constructor(opts: PolicyResolverOptions);
|
|
53
|
+
resolve(agentId: string, roomId: string | null): Promise<AttentionPolicy>;
|
|
54
|
+
private safeFetch;
|
|
55
|
+
invalidate(agentId: string, roomId?: string): void;
|
|
56
|
+
put(agentId: string, roomId: string | null, policy: AttentionPolicy): void;
|
|
57
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Daemon-side per-agent attention-policy cache (PR3, design §5).
|
|
3
|
+
*
|
|
4
|
+
* The dispatcher consults this resolver after `onInbound` fires and before
|
|
5
|
+
* the runtime turn enqueues. Cache layout:
|
|
6
|
+
*
|
|
7
|
+
* - `agent_id` → global default policy (seeded by
|
|
8
|
+
* `provision_agent` + `policy_updated{agent}`).
|
|
9
|
+
* - `agent_id:room_id` → genuine per-room override only — installed
|
|
10
|
+
* exclusively via `put` from a per-room
|
|
11
|
+
* `policy_updated` frame. Inheritance reads
|
|
12
|
+
* never write here.
|
|
13
|
+
*
|
|
14
|
+
* `resolve(agent, room)` checks the room key first, then falls back to the
|
|
15
|
+
* global key. This means a per-room override always wins, and the global
|
|
16
|
+
* propagates to every room without explicit fan-out (a global update only
|
|
17
|
+
* needs to refresh the agent_id entry).
|
|
18
|
+
*
|
|
19
|
+
* `invalidate(agent_id, room_id)` drops the matching room entry; the next
|
|
20
|
+
* resolve falls through to the global. `invalidate(agent_id)` drops every
|
|
21
|
+
* entry for that agent — both global and any room overrides — used when
|
|
22
|
+
* the agent is revoked or the cache must rebuild from scratch.
|
|
23
|
+
*/
|
|
24
|
+
const DEFAULT_TTL_MS = 5 * 60 * 1000;
|
|
25
|
+
const FETCH_FAILED = Symbol("fetch_failed");
|
|
26
|
+
/**
|
|
27
|
+
* Force DM rooms (`rm_dm_*`) to `mode: "always"` per design §4.2 — UI never
|
|
28
|
+
* lets the user mute a DM, but a stale cache from before a UX bug is cheap
|
|
29
|
+
* to defend against here.
|
|
30
|
+
*/
|
|
31
|
+
function maybeForceDm(roomId, policy) {
|
|
32
|
+
if (roomId && roomId.startsWith("rm_dm_") && policy.mode !== "always") {
|
|
33
|
+
return { ...policy, mode: "always" };
|
|
34
|
+
}
|
|
35
|
+
return policy;
|
|
36
|
+
}
|
|
37
|
+
function defaultPolicy() {
|
|
38
|
+
return { mode: "always", keywords: [] };
|
|
39
|
+
}
|
|
40
|
+
export class PolicyResolver {
|
|
41
|
+
fetchGlobal;
|
|
42
|
+
fetchEffective;
|
|
43
|
+
ttlMs;
|
|
44
|
+
cache = new Map();
|
|
45
|
+
constructor(opts) {
|
|
46
|
+
this.fetchGlobal = opts.fetchGlobal;
|
|
47
|
+
this.fetchEffective = opts.fetchEffective;
|
|
48
|
+
this.ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS;
|
|
49
|
+
}
|
|
50
|
+
async resolve(agentId, roomId) {
|
|
51
|
+
const now = Date.now();
|
|
52
|
+
// 1. Per-room cache — populated either by a `policy_updated{room_id}`
|
|
53
|
+
// push (genuine override) or by a prior `fetchEffective` cold-start.
|
|
54
|
+
if (roomId) {
|
|
55
|
+
const roomHit = this.cache.get(cacheKey(agentId, roomId));
|
|
56
|
+
if (roomHit && roomHit.expiresAt > now)
|
|
57
|
+
return roomHit.policy;
|
|
58
|
+
}
|
|
59
|
+
// 2. If a per-room fetcher is wired, treat it as authoritative for cold
|
|
60
|
+
// rooms — it returns the override-merged effective policy and so must
|
|
61
|
+
// not be skipped just because the global cache is warm.
|
|
62
|
+
if (roomId && this.fetchEffective) {
|
|
63
|
+
const fetched = await this.safeFetch(() => this.fetchEffective(agentId, roomId));
|
|
64
|
+
if (fetched === FETCH_FAILED)
|
|
65
|
+
return defaultPolicy();
|
|
66
|
+
const policy = fetched ?? defaultPolicy();
|
|
67
|
+
this.cache.set(cacheKey(agentId, roomId), {
|
|
68
|
+
policy: maybeForceDm(roomId, policy),
|
|
69
|
+
expiresAt: now + this.ttlMs,
|
|
70
|
+
});
|
|
71
|
+
return maybeForceDm(roomId, policy);
|
|
72
|
+
}
|
|
73
|
+
// 3. No room override known — inherit from the cached agent-wide global.
|
|
74
|
+
// Without this layer, group messages collapsed to mode=always whenever
|
|
75
|
+
// the daemon ran without a per-room fetcher (the current production
|
|
76
|
+
// state), silently breaking global mention_only/muted.
|
|
77
|
+
const globalKey = cacheKey(agentId, null);
|
|
78
|
+
const globalHit = this.cache.get(globalKey);
|
|
79
|
+
if (globalHit && globalHit.expiresAt > now) {
|
|
80
|
+
return maybeForceDm(roomId, globalHit.policy);
|
|
81
|
+
}
|
|
82
|
+
// 4. Cold start for global.
|
|
83
|
+
const fetched = await this.safeFetch(() => this.fetchGlobal(agentId));
|
|
84
|
+
if (fetched === FETCH_FAILED)
|
|
85
|
+
return defaultPolicy();
|
|
86
|
+
const policy = fetched ?? defaultPolicy();
|
|
87
|
+
this.cache.set(globalKey, { policy, expiresAt: now + this.ttlMs });
|
|
88
|
+
return maybeForceDm(roomId, policy);
|
|
89
|
+
}
|
|
90
|
+
async safeFetch(fn) {
|
|
91
|
+
try {
|
|
92
|
+
return await fn();
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
// Fail-open: a fetch error must not silence the agent. The caller
|
|
96
|
+
// returns the default policy without caching so the next resolve retries.
|
|
97
|
+
return FETCH_FAILED;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
invalidate(agentId, roomId) {
|
|
101
|
+
if (roomId !== undefined) {
|
|
102
|
+
this.cache.delete(cacheKey(agentId, roomId));
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
// Drop every entry for this agent.
|
|
106
|
+
const prefix = agentId + ":";
|
|
107
|
+
for (const key of Array.from(this.cache.keys())) {
|
|
108
|
+
if (key === agentId || key.startsWith(prefix)) {
|
|
109
|
+
this.cache.delete(key);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
put(agentId, roomId, policy) {
|
|
114
|
+
const key = cacheKey(agentId, roomId);
|
|
115
|
+
this.cache.set(key, {
|
|
116
|
+
policy: maybeForceDm(roomId, policy),
|
|
117
|
+
expiresAt: Date.now() + this.ttlMs,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
function cacheKey(agentId, roomId) {
|
|
122
|
+
return roomId ? `${agentId}:${roomId}` : agentId;
|
|
123
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type { RuntimeAdapter, RuntimeProbeResult, RuntimeRunOptions, RuntimeRunResult, StreamBlock } from "../types.js";
|
|
2
|
+
/** ACP protocol version this client targets. */
|
|
3
|
+
export declare const ACP_PROTOCOL_VERSION = 1;
|
|
4
|
+
export interface AcpInitializeResult {
|
|
5
|
+
protocolVersion?: number;
|
|
6
|
+
agentInfo?: {
|
|
7
|
+
name?: string;
|
|
8
|
+
version?: string;
|
|
9
|
+
};
|
|
10
|
+
agentCapabilities?: Record<string, unknown>;
|
|
11
|
+
authMethods?: Array<{
|
|
12
|
+
id?: string;
|
|
13
|
+
name?: string;
|
|
14
|
+
description?: string;
|
|
15
|
+
}>;
|
|
16
|
+
[k: string]: unknown;
|
|
17
|
+
}
|
|
18
|
+
export interface AcpPermissionOption {
|
|
19
|
+
optionId: string;
|
|
20
|
+
name?: string;
|
|
21
|
+
/**
|
|
22
|
+
* ACP option kind. Common values: `allow_once`, `allow_always`,
|
|
23
|
+
* `reject_once`, `reject_always`. Treated as opaque by the base class —
|
|
24
|
+
* subclasses inspect `.kind` to pick the right outcome.
|
|
25
|
+
*/
|
|
26
|
+
kind?: string;
|
|
27
|
+
[k: string]: unknown;
|
|
28
|
+
}
|
|
29
|
+
export interface AcpPermissionRequest {
|
|
30
|
+
sessionId: string;
|
|
31
|
+
toolCall?: {
|
|
32
|
+
name?: string;
|
|
33
|
+
rawInput?: unknown;
|
|
34
|
+
[k: string]: unknown;
|
|
35
|
+
};
|
|
36
|
+
options: AcpPermissionOption[];
|
|
37
|
+
[k: string]: unknown;
|
|
38
|
+
}
|
|
39
|
+
export type AcpPermissionResponse = {
|
|
40
|
+
outcome: {
|
|
41
|
+
outcome: "selected";
|
|
42
|
+
optionId: string;
|
|
43
|
+
};
|
|
44
|
+
} | {
|
|
45
|
+
outcome: {
|
|
46
|
+
outcome: "cancelled";
|
|
47
|
+
};
|
|
48
|
+
};
|
|
49
|
+
export interface AcpUpdateParams {
|
|
50
|
+
sessionId: string;
|
|
51
|
+
update: {
|
|
52
|
+
sessionUpdate?: string;
|
|
53
|
+
[k: string]: unknown;
|
|
54
|
+
};
|
|
55
|
+
[k: string]: unknown;
|
|
56
|
+
}
|
|
57
|
+
/** Hooks exposed to subclasses to react to inbound traffic during a turn. */
|
|
58
|
+
export interface AcpTurnHooks {
|
|
59
|
+
/** Called for each `session/update` notification. */
|
|
60
|
+
onUpdate(params: AcpUpdateParams, ctx: AcpUpdateCtx): void;
|
|
61
|
+
/** Called for `session/request_permission` requests. Must resolve to an outcome. */
|
|
62
|
+
onPermissionRequest(req: AcpPermissionRequest): Promise<AcpPermissionResponse>;
|
|
63
|
+
}
|
|
64
|
+
export interface AcpUpdateCtx {
|
|
65
|
+
/** Append to the turn's running assistant text. */
|
|
66
|
+
appendAssistantText(text: string): void;
|
|
67
|
+
/** Forward a normalized StreamBlock to `opts.onBlock`. */
|
|
68
|
+
emitBlock(block: StreamBlock): void;
|
|
69
|
+
/** 1-based sequence within this turn. */
|
|
70
|
+
seq: number;
|
|
71
|
+
}
|
|
72
|
+
export declare abstract class AcpRuntimeAdapter implements RuntimeAdapter {
|
|
73
|
+
abstract readonly id: string;
|
|
74
|
+
probe?(): RuntimeProbeResult;
|
|
75
|
+
protected abstract resolveBinary(opts: RuntimeRunOptions): string;
|
|
76
|
+
/** Argv tail (excluding the binary). ACP servers usually take none. */
|
|
77
|
+
protected buildArgs(_opts: RuntimeRunOptions): string[];
|
|
78
|
+
protected abstract spawnEnv(opts: RuntimeRunOptions): NodeJS.ProcessEnv;
|
|
79
|
+
/** Subclass hook: react to one `session/update` notification. */
|
|
80
|
+
protected abstract onUpdate(params: AcpUpdateParams, ctx: AcpUpdateCtx): void;
|
|
81
|
+
/** Subclass hook: respond to a `session/request_permission` request. */
|
|
82
|
+
protected abstract onPermissionRequest(req: AcpPermissionRequest, opts: RuntimeRunOptions): Promise<AcpPermissionResponse>;
|
|
83
|
+
/** Runtime-specific clientCapabilities sent on initialize. */
|
|
84
|
+
protected clientCapabilities(): Record<string, unknown>;
|
|
85
|
+
/** Runtime-specific clientInfo sent on initialize. */
|
|
86
|
+
protected clientInfo(): {
|
|
87
|
+
name: string;
|
|
88
|
+
version: string;
|
|
89
|
+
};
|
|
90
|
+
/**
|
|
91
|
+
* Hook invoked synchronously before spawn. Subclasses use this to write
|
|
92
|
+
* systemContext to disk (e.g. `<cwd>/AGENTS.md`).
|
|
93
|
+
*/
|
|
94
|
+
protected prepareTurn(_opts: RuntimeRunOptions): void;
|
|
95
|
+
/** cwd passed to ACP `session/new` / `session/load`. Typically `opts.cwd`. */
|
|
96
|
+
protected sessionCwd(opts: RuntimeRunOptions): string;
|
|
97
|
+
run(opts: RuntimeRunOptions): Promise<RuntimeRunResult>;
|
|
98
|
+
private withTimeout;
|
|
99
|
+
}
|
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { consoleLogger } from "../log.js";
|
|
3
|
+
/**
|
|
4
|
+
* Minimal bidirectional ACP (Agent Client Protocol) client used by runtime
|
|
5
|
+
* adapters whose backing CLI speaks ACP over stdio (JSON-RPC 2.0,
|
|
6
|
+
* newline-delimited).
|
|
7
|
+
*
|
|
8
|
+
* Why a base class instead of `NdjsonStreamAdapter`: ACP is a bidirectional
|
|
9
|
+
* RPC protocol — the agent sends notifications (`session/update`) AND
|
|
10
|
+
* server-initiated requests (`session/request_permission`) that the daemon
|
|
11
|
+
* MUST reply to or the agent stalls. The ndjson base only models a one-way
|
|
12
|
+
* event stream, so it cannot drive ACP correctly.
|
|
13
|
+
*/
|
|
14
|
+
const log = consoleLogger;
|
|
15
|
+
/** How much stderr we keep for error reporting. */
|
|
16
|
+
const STDERR_TAIL_CAP = 8 * 1024;
|
|
17
|
+
/** How much of the retained stderr is included in synthesized errors. */
|
|
18
|
+
const STDERR_ERROR_SNIPPET = 500;
|
|
19
|
+
/** Cap on streamed assistant text per turn — guards a runaway runtime. */
|
|
20
|
+
const ASSISTANT_TEXT_CAP = 1 * 1024 * 1024;
|
|
21
|
+
/** Grace period between SIGTERM and SIGKILL on abort. */
|
|
22
|
+
const KILL_GRACE_MS = 5_000;
|
|
23
|
+
/** Deadline for the initial `initialize` handshake. */
|
|
24
|
+
const INITIALIZE_TIMEOUT_MS = 30_000;
|
|
25
|
+
/** ACP protocol version this client targets. */
|
|
26
|
+
export const ACP_PROTOCOL_VERSION = 1;
|
|
27
|
+
/** Minimal newline-JSON-RPC framing on top of a child process's stdio. */
|
|
28
|
+
class AcpConnection {
|
|
29
|
+
child;
|
|
30
|
+
handlers;
|
|
31
|
+
logId;
|
|
32
|
+
nextId = 1;
|
|
33
|
+
pending = new Map();
|
|
34
|
+
stdoutBuf = "";
|
|
35
|
+
closed = false;
|
|
36
|
+
closeReason = null;
|
|
37
|
+
constructor(child, handlers, logId) {
|
|
38
|
+
this.child = child;
|
|
39
|
+
this.handlers = handlers;
|
|
40
|
+
this.logId = logId;
|
|
41
|
+
child.stdout.setEncoding("utf8");
|
|
42
|
+
child.stdout.on("data", (chunk) => this.onStdout(chunk));
|
|
43
|
+
child.stdout.on("end", () => this.fail(new Error("stdout closed")));
|
|
44
|
+
child.on("close", (code) => this.fail(new Error(`process exited with code ${code ?? 0}`)));
|
|
45
|
+
child.on("error", (err) => this.fail(err));
|
|
46
|
+
}
|
|
47
|
+
onStdout(chunk) {
|
|
48
|
+
this.stdoutBuf += chunk;
|
|
49
|
+
let idx;
|
|
50
|
+
while ((idx = this.stdoutBuf.indexOf("\n")) !== -1) {
|
|
51
|
+
const line = this.stdoutBuf.slice(0, idx).trim();
|
|
52
|
+
this.stdoutBuf = this.stdoutBuf.slice(idx + 1);
|
|
53
|
+
if (!line)
|
|
54
|
+
continue;
|
|
55
|
+
this.dispatchLine(line);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
dispatchLine(line) {
|
|
59
|
+
let msg;
|
|
60
|
+
try {
|
|
61
|
+
msg = JSON.parse(line);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
log.warn(`${this.logId} non-json acp line`, { line: line.slice(0, 200) });
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (typeof msg !== "object" || msg === null)
|
|
68
|
+
return;
|
|
69
|
+
// Response to a client→server request
|
|
70
|
+
if (typeof msg.id === "number" && (msg.result !== undefined || msg.error !== undefined)) {
|
|
71
|
+
const pending = this.pending.get(msg.id);
|
|
72
|
+
if (!pending)
|
|
73
|
+
return;
|
|
74
|
+
this.pending.delete(msg.id);
|
|
75
|
+
if (msg.error) {
|
|
76
|
+
const err = new Error(`acp error ${msg.error.code ?? "?"}: ${msg.error.message ?? "(no message)"}`);
|
|
77
|
+
pending.reject(err);
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
pending.resolve(msg.result ?? null);
|
|
81
|
+
}
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (typeof msg.method === "string") {
|
|
85
|
+
// Server→client request (has `id`) or notification (no `id`)
|
|
86
|
+
if (msg.id !== undefined) {
|
|
87
|
+
void this.handleServerRequest(msg.id, msg.method, msg.params);
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
try {
|
|
91
|
+
this.handlers.onNotification(msg.method, msg.params);
|
|
92
|
+
}
|
|
93
|
+
catch (err) {
|
|
94
|
+
log.warn(`${this.logId} notification handler threw`, {
|
|
95
|
+
method: msg.method,
|
|
96
|
+
err: String(err),
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
async handleServerRequest(id, method, params) {
|
|
103
|
+
let result;
|
|
104
|
+
let error = null;
|
|
105
|
+
try {
|
|
106
|
+
result = await this.handlers.onRequest(method, params);
|
|
107
|
+
}
|
|
108
|
+
catch (err) {
|
|
109
|
+
error = {
|
|
110
|
+
code: -32603,
|
|
111
|
+
message: err instanceof Error ? err.message : String(err),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
const reply = error
|
|
115
|
+
? { jsonrpc: "2.0", id, error }
|
|
116
|
+
: { jsonrpc: "2.0", id, result: result ?? null };
|
|
117
|
+
this.writeMessage(reply);
|
|
118
|
+
}
|
|
119
|
+
writeMessage(obj) {
|
|
120
|
+
if (this.closed)
|
|
121
|
+
return;
|
|
122
|
+
try {
|
|
123
|
+
this.child.stdin.write(JSON.stringify(obj) + "\n");
|
|
124
|
+
}
|
|
125
|
+
catch (err) {
|
|
126
|
+
this.fail(err instanceof Error ? err : new Error(String(err)));
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
request(method, params) {
|
|
130
|
+
if (this.closed) {
|
|
131
|
+
return Promise.reject(this.closeReason ?? new Error("acp closed"));
|
|
132
|
+
}
|
|
133
|
+
const id = this.nextId++;
|
|
134
|
+
return new Promise((resolve, reject) => {
|
|
135
|
+
this.pending.set(id, {
|
|
136
|
+
resolve: (v) => resolve(v),
|
|
137
|
+
reject,
|
|
138
|
+
});
|
|
139
|
+
this.writeMessage({ jsonrpc: "2.0", id, method, params });
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
notify(method, params) {
|
|
143
|
+
this.writeMessage({ jsonrpc: "2.0", method, params });
|
|
144
|
+
}
|
|
145
|
+
fail(err) {
|
|
146
|
+
if (this.closed)
|
|
147
|
+
return;
|
|
148
|
+
this.closed = true;
|
|
149
|
+
this.closeReason = err;
|
|
150
|
+
for (const [, p] of this.pending)
|
|
151
|
+
p.reject(err);
|
|
152
|
+
this.pending.clear();
|
|
153
|
+
}
|
|
154
|
+
isClosed() {
|
|
155
|
+
return this.closed;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
export class AcpRuntimeAdapter {
|
|
159
|
+
/** Argv tail (excluding the binary). ACP servers usually take none. */
|
|
160
|
+
buildArgs(_opts) {
|
|
161
|
+
return [];
|
|
162
|
+
}
|
|
163
|
+
/** Runtime-specific clientCapabilities sent on initialize. */
|
|
164
|
+
clientCapabilities() {
|
|
165
|
+
return { fs: { readTextFile: false, writeTextFile: false } };
|
|
166
|
+
}
|
|
167
|
+
/** Runtime-specific clientInfo sent on initialize. */
|
|
168
|
+
clientInfo() {
|
|
169
|
+
return { name: "botcord-daemon", version: "0.1" };
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Hook invoked synchronously before spawn. Subclasses use this to write
|
|
173
|
+
* systemContext to disk (e.g. `<cwd>/AGENTS.md`).
|
|
174
|
+
*/
|
|
175
|
+
prepareTurn(_opts) {
|
|
176
|
+
/* default: noop */
|
|
177
|
+
}
|
|
178
|
+
/** cwd passed to ACP `session/new` / `session/load`. Typically `opts.cwd`. */
|
|
179
|
+
sessionCwd(opts) {
|
|
180
|
+
return opts.cwd;
|
|
181
|
+
}
|
|
182
|
+
async run(opts) {
|
|
183
|
+
if (opts.signal.aborted) {
|
|
184
|
+
return {
|
|
185
|
+
text: "",
|
|
186
|
+
newSessionId: opts.sessionId ?? "",
|
|
187
|
+
error: `${this.id} aborted before spawn`,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
try {
|
|
191
|
+
this.prepareTurn(opts);
|
|
192
|
+
}
|
|
193
|
+
catch (err) {
|
|
194
|
+
log.warn(`${this.id} prepareTurn threw`, { err: String(err) });
|
|
195
|
+
}
|
|
196
|
+
const binary = this.resolveBinary(opts);
|
|
197
|
+
const args = this.buildArgs(opts);
|
|
198
|
+
log.debug(`${this.id} spawn`, {
|
|
199
|
+
cwd: opts.cwd,
|
|
200
|
+
sessionId: opts.sessionId,
|
|
201
|
+
argv: args,
|
|
202
|
+
});
|
|
203
|
+
const child = spawn(binary, args, {
|
|
204
|
+
cwd: opts.cwd,
|
|
205
|
+
env: this.spawnEnv(opts),
|
|
206
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
207
|
+
});
|
|
208
|
+
let killTimer = null;
|
|
209
|
+
const onAbort = () => {
|
|
210
|
+
if (child.killed)
|
|
211
|
+
return;
|
|
212
|
+
try {
|
|
213
|
+
child.stdin.end();
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
/* best-effort */
|
|
217
|
+
}
|
|
218
|
+
child.kill("SIGTERM");
|
|
219
|
+
killTimer = setTimeout(() => {
|
|
220
|
+
if (!child.killed) {
|
|
221
|
+
log.warn(`${this.id} did not exit after SIGTERM; sending SIGKILL`);
|
|
222
|
+
try {
|
|
223
|
+
child.kill("SIGKILL");
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
/* best-effort */
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}, KILL_GRACE_MS);
|
|
230
|
+
if (typeof killTimer.unref === "function")
|
|
231
|
+
killTimer.unref();
|
|
232
|
+
};
|
|
233
|
+
opts.signal.addEventListener("abort", onAbort, { once: true });
|
|
234
|
+
let stderrTail = "";
|
|
235
|
+
child.stderr.setEncoding("utf8");
|
|
236
|
+
child.stderr.on("data", (chunk) => {
|
|
237
|
+
stderrTail = (stderrTail + chunk).slice(-STDERR_TAIL_CAP);
|
|
238
|
+
});
|
|
239
|
+
const state = {
|
|
240
|
+
finalText: "",
|
|
241
|
+
assistantTextChunks: [],
|
|
242
|
+
assistantTextBytes: 0,
|
|
243
|
+
assistantTextCapped: false,
|
|
244
|
+
};
|
|
245
|
+
const appendAssistantText = (text) => {
|
|
246
|
+
if (!text || state.assistantTextCapped)
|
|
247
|
+
return;
|
|
248
|
+
const budget = ASSISTANT_TEXT_CAP - state.assistantTextBytes;
|
|
249
|
+
if (budget <= 0) {
|
|
250
|
+
state.assistantTextCapped = true;
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
if (text.length > budget) {
|
|
254
|
+
state.assistantTextChunks.push(text.slice(0, budget));
|
|
255
|
+
state.assistantTextBytes += budget;
|
|
256
|
+
state.assistantTextCapped = true;
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
state.assistantTextChunks.push(text);
|
|
260
|
+
state.assistantTextBytes += text.length;
|
|
261
|
+
};
|
|
262
|
+
let seq = 0;
|
|
263
|
+
const conn = new AcpConnection(child, {
|
|
264
|
+
onNotification: (method, params) => {
|
|
265
|
+
if (method === "session/update") {
|
|
266
|
+
seq += 1;
|
|
267
|
+
this.onUpdate(params, {
|
|
268
|
+
appendAssistantText,
|
|
269
|
+
emitBlock: (b) => opts.onBlock?.(b),
|
|
270
|
+
seq,
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
},
|
|
274
|
+
onRequest: async (method, params) => {
|
|
275
|
+
if (method === "session/request_permission") {
|
|
276
|
+
return this.onPermissionRequest(params, opts);
|
|
277
|
+
}
|
|
278
|
+
// Unknown server→client request: signal "method not found" so the
|
|
279
|
+
// server can decide what to do. Throwing here surfaces as a JSON-RPC
|
|
280
|
+
// error reply via AcpConnection.
|
|
281
|
+
const err = new Error(`unknown server request: ${method}`);
|
|
282
|
+
throw err;
|
|
283
|
+
},
|
|
284
|
+
}, this.id);
|
|
285
|
+
const childExit = new Promise((resolve) => {
|
|
286
|
+
child.on("close", (code) => resolve(code ?? 0));
|
|
287
|
+
});
|
|
288
|
+
let newSessionId = opts.sessionId ?? "";
|
|
289
|
+
try {
|
|
290
|
+
// 1) initialize
|
|
291
|
+
await this.withTimeout(conn.request("initialize", {
|
|
292
|
+
protocolVersion: ACP_PROTOCOL_VERSION,
|
|
293
|
+
clientCapabilities: this.clientCapabilities(),
|
|
294
|
+
clientInfo: this.clientInfo(),
|
|
295
|
+
}), INITIALIZE_TIMEOUT_MS, "initialize");
|
|
296
|
+
// 2) session/load (if resuming) → fallback to session/new
|
|
297
|
+
const cwd = this.sessionCwd(opts);
|
|
298
|
+
let sessionId = "";
|
|
299
|
+
if (opts.sessionId) {
|
|
300
|
+
try {
|
|
301
|
+
const loaded = (await conn.request("session/load", {
|
|
302
|
+
sessionId: opts.sessionId,
|
|
303
|
+
cwd,
|
|
304
|
+
mcpServers: [],
|
|
305
|
+
}));
|
|
306
|
+
if (loaded !== null && loaded !== undefined) {
|
|
307
|
+
// Hermes' load_session does NOT return a session_id — reuse the
|
|
308
|
+
// requested one. If a future server returns one, prefer it.
|
|
309
|
+
sessionId =
|
|
310
|
+
(loaded && typeof loaded.sessionId === "string"
|
|
311
|
+
? loaded.sessionId
|
|
312
|
+
: "") || opts.sessionId;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
catch (err) {
|
|
316
|
+
log.warn(`${this.id} session/load failed; falling back to new`, {
|
|
317
|
+
err: err instanceof Error ? err.message : String(err),
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
if (!sessionId) {
|
|
322
|
+
const created = await conn.request("session/new", { cwd, mcpServers: [] });
|
|
323
|
+
sessionId = created?.sessionId ?? "";
|
|
324
|
+
}
|
|
325
|
+
if (!sessionId) {
|
|
326
|
+
throw new Error("acp server did not return a sessionId");
|
|
327
|
+
}
|
|
328
|
+
newSessionId = sessionId;
|
|
329
|
+
// 3) session/prompt
|
|
330
|
+
const promptResult = (await conn.request("session/prompt", {
|
|
331
|
+
sessionId,
|
|
332
|
+
prompt: [{ type: "text", text: opts.text }],
|
|
333
|
+
}));
|
|
334
|
+
const stopReason = promptResult?.stopReason ?? "end_turn";
|
|
335
|
+
if (stopReason === "refusal" || stopReason === "error") {
|
|
336
|
+
state.errorText = state.errorText ?? `prompt stopped: ${stopReason}`;
|
|
337
|
+
}
|
|
338
|
+
// Politely close stdin so the server can exit. Some ACP servers shut
|
|
339
|
+
// down on EOF; if not, abort signal will SIGTERM.
|
|
340
|
+
try {
|
|
341
|
+
child.stdin.end();
|
|
342
|
+
}
|
|
343
|
+
catch {
|
|
344
|
+
/* best-effort */
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
catch (err) {
|
|
348
|
+
state.errorText =
|
|
349
|
+
state.errorText ??
|
|
350
|
+
(err instanceof Error ? err.message : String(err));
|
|
351
|
+
try {
|
|
352
|
+
child.stdin.end();
|
|
353
|
+
}
|
|
354
|
+
catch {
|
|
355
|
+
/* best-effort */
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
let code = 0;
|
|
359
|
+
try {
|
|
360
|
+
code = await childExit;
|
|
361
|
+
}
|
|
362
|
+
finally {
|
|
363
|
+
opts.signal.removeEventListener("abort", onAbort);
|
|
364
|
+
if (killTimer)
|
|
365
|
+
clearTimeout(killTimer);
|
|
366
|
+
}
|
|
367
|
+
if (code !== 0 && !state.errorText) {
|
|
368
|
+
state.errorText = `${this.id} exited with code ${code}: ${stderrTail.slice(-STDERR_ERROR_SNIPPET)}`;
|
|
369
|
+
}
|
|
370
|
+
const rawText = state.finalText || state.assistantTextChunks.join("").trim();
|
|
371
|
+
const text = rawText.length > ASSISTANT_TEXT_CAP
|
|
372
|
+
? rawText.slice(0, ASSISTANT_TEXT_CAP)
|
|
373
|
+
: rawText;
|
|
374
|
+
return {
|
|
375
|
+
text,
|
|
376
|
+
newSessionId,
|
|
377
|
+
...(state.errorText ? { error: state.errorText } : {}),
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
withTimeout(p, ms, label) {
|
|
381
|
+
return new Promise((resolve, reject) => {
|
|
382
|
+
const t = setTimeout(() => reject(new Error(`${this.id} ${label} timed out after ${ms}ms`)), ms);
|
|
383
|
+
if (typeof t.unref === "function")
|
|
384
|
+
t.unref();
|
|
385
|
+
p.then((v) => {
|
|
386
|
+
clearTimeout(t);
|
|
387
|
+
resolve(v);
|
|
388
|
+
}, (e) => {
|
|
389
|
+
clearTimeout(t);
|
|
390
|
+
reject(e);
|
|
391
|
+
});
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
}
|