@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.
Files changed (88) hide show
  1. package/dist/agent-discovery.d.ts +4 -0
  2. package/dist/agent-discovery.js +8 -0
  3. package/dist/agent-workspace.d.ts +62 -0
  4. package/dist/agent-workspace.js +140 -8
  5. package/dist/config.d.ts +64 -1
  6. package/dist/config.js +73 -1
  7. package/dist/daemon-config-map.d.ts +27 -9
  8. package/dist/daemon-config-map.js +105 -8
  9. package/dist/daemon.d.ts +2 -0
  10. package/dist/daemon.js +76 -6
  11. package/dist/doctor.d.ts +27 -1
  12. package/dist/doctor.js +22 -1
  13. package/dist/gateway/cli-resolver.d.ts +34 -0
  14. package/dist/gateway/cli-resolver.js +74 -0
  15. package/dist/gateway/dispatcher.d.ts +31 -1
  16. package/dist/gateway/dispatcher.js +337 -29
  17. package/dist/gateway/gateway.d.ts +29 -1
  18. package/dist/gateway/gateway.js +10 -0
  19. package/dist/gateway/index.d.ts +2 -0
  20. package/dist/gateway/index.js +2 -0
  21. package/dist/gateway/policy-resolver.d.ts +57 -0
  22. package/dist/gateway/policy-resolver.js +123 -0
  23. package/dist/gateway/runtimes/acp-stream.d.ts +99 -0
  24. package/dist/gateway/runtimes/acp-stream.js +394 -0
  25. package/dist/gateway/runtimes/codex.js +7 -0
  26. package/dist/gateway/runtimes/hermes-agent.d.ts +83 -0
  27. package/dist/gateway/runtimes/hermes-agent.js +180 -0
  28. package/dist/gateway/runtimes/ndjson-stream.d.ts +7 -2
  29. package/dist/gateway/runtimes/ndjson-stream.js +16 -3
  30. package/dist/gateway/runtimes/openclaw-acp.d.ts +44 -0
  31. package/dist/gateway/runtimes/openclaw-acp.js +500 -0
  32. package/dist/gateway/runtimes/registry.d.ts +4 -0
  33. package/dist/gateway/runtimes/registry.js +22 -0
  34. package/dist/gateway/transcript-paths.d.ts +30 -0
  35. package/dist/gateway/transcript-paths.js +114 -0
  36. package/dist/gateway/transcript.d.ts +123 -0
  37. package/dist/gateway/transcript.js +147 -0
  38. package/dist/gateway/types.d.ts +31 -0
  39. package/dist/index.js +309 -27
  40. package/dist/mention-scan.d.ts +22 -0
  41. package/dist/mention-scan.js +35 -0
  42. package/dist/openclaw-discovery.d.ts +28 -0
  43. package/dist/openclaw-discovery.js +228 -0
  44. package/dist/provision.d.ts +113 -1
  45. package/dist/provision.js +564 -12
  46. package/dist/system-context.d.ts +5 -4
  47. package/dist/system-context.js +35 -5
  48. package/dist/url-utils.d.ts +9 -0
  49. package/dist/url-utils.js +18 -0
  50. package/package.json +3 -2
  51. package/src/__tests__/agent-workspace.test.ts +93 -0
  52. package/src/__tests__/daemon-config-map.test.ts +79 -0
  53. package/src/__tests__/openclaw-acp.test.ts +234 -0
  54. package/src/__tests__/openclaw-discovery.test.ts +150 -0
  55. package/src/__tests__/policy-resolver.test.ts +124 -0
  56. package/src/__tests__/policy-updated-handler.test.ts +144 -0
  57. package/src/__tests__/provision.test.ts +265 -0
  58. package/src/__tests__/system-context.test.ts +52 -0
  59. package/src/__tests__/url-utils.test.ts +37 -0
  60. package/src/agent-discovery.ts +8 -0
  61. package/src/agent-workspace.ts +173 -7
  62. package/src/config.ts +168 -4
  63. package/src/daemon-config-map.ts +154 -9
  64. package/src/daemon.ts +96 -6
  65. package/src/doctor.ts +49 -2
  66. package/src/gateway/__tests__/dispatcher.test.ts +65 -0
  67. package/src/gateway/__tests__/hermes-agent-adapter.test.ts +302 -0
  68. package/src/gateway/__tests__/transcript.test.ts +496 -0
  69. package/src/gateway/cli-resolver.ts +92 -0
  70. package/src/gateway/dispatcher.ts +394 -26
  71. package/src/gateway/gateway.ts +46 -0
  72. package/src/gateway/index.ts +25 -0
  73. package/src/gateway/policy-resolver.ts +171 -0
  74. package/src/gateway/runtimes/acp-stream.ts +535 -0
  75. package/src/gateway/runtimes/codex.ts +7 -0
  76. package/src/gateway/runtimes/hermes-agent.ts +206 -0
  77. package/src/gateway/runtimes/ndjson-stream.ts +16 -3
  78. package/src/gateway/runtimes/openclaw-acp.ts +606 -0
  79. package/src/gateway/runtimes/registry.ts +24 -0
  80. package/src/gateway/transcript-paths.ts +145 -0
  81. package/src/gateway/transcript.ts +300 -0
  82. package/src/gateway/types.ts +32 -0
  83. package/src/index.ts +321 -30
  84. package/src/mention-scan.ts +38 -0
  85. package/src/openclaw-discovery.ts +262 -0
  86. package/src/provision.ts +682 -14
  87. package/src/system-context.ts +41 -9
  88. package/src/url-utils.ts +17 -0
@@ -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";
@@ -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
+ }