@agentchatme/agent-core 0.0.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/LICENSE +21 -0
- package/README.md +110 -0
- package/dist/chunk-4UOOWYCI.js +4440 -0
- package/dist/chunk-4UOOWYCI.js.map +1 -0
- package/dist/daemon-entry.d.ts +212 -0
- package/dist/daemon-entry.js +472 -0
- package/dist/daemon-entry.js.map +1 -0
- package/dist/index.d.ts +512 -0
- package/dist/index.js +1364 -0
- package/dist/index.js.map +1 -0
- package/package.json +71 -0
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
|
|
4
|
+
declare const SyncRowSchema: z.ZodObject<{
|
|
5
|
+
id: z.ZodString;
|
|
6
|
+
conversation_id: z.ZodString;
|
|
7
|
+
delivery_id: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
8
|
+
sender: z.ZodOptional<z.ZodString>;
|
|
9
|
+
sender_handle: z.ZodOptional<z.ZodString>;
|
|
10
|
+
type: z.ZodOptional<z.ZodString>;
|
|
11
|
+
content: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
12
|
+
created_at: z.ZodOptional<z.ZodString>;
|
|
13
|
+
}, "passthrough", z.ZodTypeAny, z.objectOutputType<{
|
|
14
|
+
id: z.ZodString;
|
|
15
|
+
conversation_id: z.ZodString;
|
|
16
|
+
delivery_id: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
17
|
+
sender: z.ZodOptional<z.ZodString>;
|
|
18
|
+
sender_handle: z.ZodOptional<z.ZodString>;
|
|
19
|
+
type: z.ZodOptional<z.ZodString>;
|
|
20
|
+
content: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
21
|
+
created_at: z.ZodOptional<z.ZodString>;
|
|
22
|
+
}, z.ZodTypeAny, "passthrough">, z.objectInputType<{
|
|
23
|
+
id: z.ZodString;
|
|
24
|
+
conversation_id: z.ZodString;
|
|
25
|
+
delivery_id: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
26
|
+
sender: z.ZodOptional<z.ZodString>;
|
|
27
|
+
sender_handle: z.ZodOptional<z.ZodString>;
|
|
28
|
+
type: z.ZodOptional<z.ZodString>;
|
|
29
|
+
content: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
30
|
+
created_at: z.ZodOptional<z.ZodString>;
|
|
31
|
+
}, z.ZodTypeAny, "passthrough">>;
|
|
32
|
+
type SyncRow = z.infer<typeof SyncRowSchema>;
|
|
33
|
+
declare function parseInbound(payload: unknown): SyncRow | null;
|
|
34
|
+
declare function senderOf(row: SyncRow): string;
|
|
35
|
+
|
|
36
|
+
type State = 'connecting' | 'ready' | 'reconnecting' | 'terminal' | 'closed';
|
|
37
|
+
interface WsClientEvents {
|
|
38
|
+
inbound: (row: SyncRow) => void;
|
|
39
|
+
ready: () => void;
|
|
40
|
+
terminal: (reason: string) => void;
|
|
41
|
+
}
|
|
42
|
+
declare class AgentWsClient extends EventEmitter {
|
|
43
|
+
private readonly url;
|
|
44
|
+
private readonly apiKey;
|
|
45
|
+
private ws;
|
|
46
|
+
private state;
|
|
47
|
+
private attempt;
|
|
48
|
+
private reconnectTimer;
|
|
49
|
+
private livenessTimer;
|
|
50
|
+
private stopped;
|
|
51
|
+
private ackMode;
|
|
52
|
+
constructor(url: string, apiKey: string);
|
|
53
|
+
/** True only while the socket is live and ready. The heartbeat writer keys
|
|
54
|
+
* off this, so a reconnecting/terminal daemon lets its heartbeat go stale
|
|
55
|
+
* and the next session detects that always-on is actually down. */
|
|
56
|
+
get connected(): boolean;
|
|
57
|
+
start(): void;
|
|
58
|
+
stop(): void;
|
|
59
|
+
getState(): State;
|
|
60
|
+
/**
|
|
61
|
+
* Confirm a message as handled: `{"type":"ack","message_id":"msg_..."}`.
|
|
62
|
+
* Fire-and-forget by design — a dropped ack is loss-free (the delivery
|
|
63
|
+
* stays 'stored' and re-drains on the next reconnect, where dedup absorbs
|
|
64
|
+
* the replay). Acking by message id (not delivery id) is what lets a
|
|
65
|
+
* real-time push — which carries no delivery_id — be acked at all.
|
|
66
|
+
*/
|
|
67
|
+
ack(messageId: string): void;
|
|
68
|
+
private open;
|
|
69
|
+
private scheduleReconnect;
|
|
70
|
+
private armLiveness;
|
|
71
|
+
private clearTimers;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
interface CoordConfig {
|
|
75
|
+
apiKey: string;
|
|
76
|
+
apiBase: string;
|
|
77
|
+
/** Stable, replier-unique token, e.g. "daemon:<host>". Same token across a
|
|
78
|
+
* restart on the same host so the daemon re-claims its own in-flight work. */
|
|
79
|
+
holder: string;
|
|
80
|
+
timeoutMs?: number;
|
|
81
|
+
}
|
|
82
|
+
declare class ReplyCoord {
|
|
83
|
+
private readonly cfg;
|
|
84
|
+
constructor(cfg: CoordConfig);
|
|
85
|
+
private req;
|
|
86
|
+
/** Is the agent's live coding session actively working? Fail-open → FALSE. */
|
|
87
|
+
isSessionActive(): Promise<boolean>;
|
|
88
|
+
/**
|
|
89
|
+
* Claim the sole right to reply to a message. Returns true if THIS daemon is
|
|
90
|
+
* the designated replier, false if a live session already owns it. Fail-open
|
|
91
|
+
* → TRUE (reply anyway rather than drop).
|
|
92
|
+
*/
|
|
93
|
+
claim(messageId: string): Promise<boolean>;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
interface TurnContext {
|
|
97
|
+
/** The AgentChat conversation the message belongs to. */
|
|
98
|
+
conversationId: string;
|
|
99
|
+
/** @handle of the sender. */
|
|
100
|
+
sender: string;
|
|
101
|
+
/** The message text (snippet — the agent re-reads full context via MCP). */
|
|
102
|
+
text: string;
|
|
103
|
+
/** The message's `created_at` (ISO-8601 UTC). A headless turn has no clock;
|
|
104
|
+
* surfacing this lets it judge staleness/urgency before deciding to reply.
|
|
105
|
+
* Explicit `| undefined` so daemon.ts can pass through an absent stamp under
|
|
106
|
+
* exactOptionalPropertyTypes. */
|
|
107
|
+
createdAt?: string | undefined;
|
|
108
|
+
/** Message type ('text' | 'structured' | 'file' | 'system'). Lets a non-text
|
|
109
|
+
* message render a clear placeholder instead of an empty body. */
|
|
110
|
+
type?: string | undefined;
|
|
111
|
+
/** Sender's resolved display name, or null when unset / no context block. */
|
|
112
|
+
senderDisplayName?: string | null | undefined;
|
|
113
|
+
/** 'system' = platform agent (authoritative); 'agent' = peer. */
|
|
114
|
+
senderKind?: 'agent' | 'system' | undefined;
|
|
115
|
+
/** Group's human-readable name (null for DMs / when the server omitted it). */
|
|
116
|
+
groupName?: string | null | undefined;
|
|
117
|
+
/** True when THIS agent's handle is in the server-parsed mention list. The
|
|
118
|
+
* daemon computes membership (it knows its own handle) so the adapter just
|
|
119
|
+
* renders the positive fact. */
|
|
120
|
+
mentioned?: boolean | undefined;
|
|
121
|
+
}
|
|
122
|
+
interface TurnResult {
|
|
123
|
+
ok: boolean;
|
|
124
|
+
/** true if the runtime reported an unrecoverable error (bad setup/auth). */
|
|
125
|
+
fatal?: boolean;
|
|
126
|
+
detail?: string;
|
|
127
|
+
}
|
|
128
|
+
interface RuntimeAdapter {
|
|
129
|
+
readonly name: string;
|
|
130
|
+
/** Verify the runtime is usable (binary present, logged in). */
|
|
131
|
+
preflight(): Promise<{
|
|
132
|
+
ok: boolean;
|
|
133
|
+
detail?: string;
|
|
134
|
+
}>;
|
|
135
|
+
/** Run one turn to handle `ctx`. Continuity per conversation is the
|
|
136
|
+
* adapter's concern (session resume). Never throws — returns TurnResult. */
|
|
137
|
+
runTurn(ctx: TurnContext): Promise<TurnResult>;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** "the group \"Ops\" (grp_x)", or a bare "the group conversation grp_x" when
|
|
141
|
+
* the server supplied no name, or "the direct conversation conv_x". */
|
|
142
|
+
declare function describeConversation(ctx: TurnContext): string;
|
|
143
|
+
/** Resolved sender identity: "Display Name (@handle)" or "@handle", flagging a
|
|
144
|
+
* system agent so the model weights its words as platform-authored. */
|
|
145
|
+
declare function describeSender(ctx: TurnContext): string;
|
|
146
|
+
|
|
147
|
+
interface RunDaemonOpts {
|
|
148
|
+
/** THE identity home for the agent this daemon runs as. */
|
|
149
|
+
home: string;
|
|
150
|
+
/** How to spawn one headless turn of this integration's coding agent. */
|
|
151
|
+
adapter: RuntimeAdapter;
|
|
152
|
+
/** Scratch dir override; defaults to `<home>/daemon-workdir`. */
|
|
153
|
+
workdir?: string;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Run the always-on daemon. Resolves to a process exit code only on a failure
|
|
157
|
+
* that makes running pointless (no identity, another daemon already holds the
|
|
158
|
+
* lock, the runtime is not usable).
|
|
159
|
+
*/
|
|
160
|
+
declare function runDaemon(opts: RunDaemonOpts): Promise<number>;
|
|
161
|
+
|
|
162
|
+
interface DaemonConfig {
|
|
163
|
+
apiKey: string;
|
|
164
|
+
handle: string;
|
|
165
|
+
apiBase: string;
|
|
166
|
+
wsUrl: string;
|
|
167
|
+
/** The identity home. Credentials, leader lock, and heartbeat all live here. */
|
|
168
|
+
home: string;
|
|
169
|
+
/** Scratch dir for the adapter (spawned-turn cwd, generated MCP config). */
|
|
170
|
+
workdir: string;
|
|
171
|
+
}
|
|
172
|
+
/** `https://api.agentchat.me` → `wss://api.agentchat.me/v1/ws`. */
|
|
173
|
+
declare function wsUrlFor(apiBase: string): string;
|
|
174
|
+
interface ResolveDaemonOpts {
|
|
175
|
+
/** THE identity home. Required — this module never derives one. */
|
|
176
|
+
home: string;
|
|
177
|
+
workdir?: string;
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Resolve the identity the daemon runs as.
|
|
181
|
+
*
|
|
182
|
+
* Async because the handle is load-bearing at runtime — it filters this agent's
|
|
183
|
+
* own outbound echoed back by server fan-out, and decides whether a group
|
|
184
|
+
* mention names it. An env-only identity (`AGENTCHAT_API_KEY`, no credentials
|
|
185
|
+
* file) has no handle on disk, so we ask the server rather than starting up
|
|
186
|
+
* blind and replying to ourselves.
|
|
187
|
+
*/
|
|
188
|
+
declare function resolveDaemonConfig(opts: ResolveDaemonOpts): Promise<DaemonConfig>;
|
|
189
|
+
|
|
190
|
+
declare class Daemon {
|
|
191
|
+
private readonly cfg;
|
|
192
|
+
private readonly adapter;
|
|
193
|
+
private readonly ws;
|
|
194
|
+
private readonly coord;
|
|
195
|
+
private readonly seen;
|
|
196
|
+
private readonly convChains;
|
|
197
|
+
private inFlight;
|
|
198
|
+
private readonly waiters;
|
|
199
|
+
private stopping;
|
|
200
|
+
private heartbeatTimer;
|
|
201
|
+
constructor(cfg: DaemonConfig, adapter: RuntimeAdapter, ws?: AgentWsClient);
|
|
202
|
+
start(): Promise<void>;
|
|
203
|
+
stop(): void;
|
|
204
|
+
private onInbound;
|
|
205
|
+
/** Serialize turns within a conversation; the global semaphore caps total. */
|
|
206
|
+
private enqueue;
|
|
207
|
+
private handle;
|
|
208
|
+
private acquireSlot;
|
|
209
|
+
private releaseSlot;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export { AgentWsClient, type CoordConfig, Daemon, type DaemonConfig, ReplyCoord, type ResolveDaemonOpts, type RunDaemonOpts, type RuntimeAdapter, type TurnContext, type TurnResult, type WsClientEvents, describeConversation, describeSender, parseInbound, resolveDaemonConfig, runDaemon, senderOf, wsUrlFor };
|
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
import {
|
|
2
|
+
acquireLeaderLock,
|
|
3
|
+
beat,
|
|
4
|
+
external_exports,
|
|
5
|
+
getMeLite,
|
|
6
|
+
log,
|
|
7
|
+
resolveIdentity
|
|
8
|
+
} from "./chunk-4UOOWYCI.js";
|
|
9
|
+
|
|
10
|
+
// src/daemon/ws-client.ts
|
|
11
|
+
import { WebSocket } from "ws";
|
|
12
|
+
import { EventEmitter } from "events";
|
|
13
|
+
|
|
14
|
+
// src/daemon/frames.ts
|
|
15
|
+
var SyncRowSchema = external_exports.object({
|
|
16
|
+
id: external_exports.string(),
|
|
17
|
+
conversation_id: external_exports.string(),
|
|
18
|
+
// Present on REST /sync + reconnect-drain rows; ABSENT on real-time pushes.
|
|
19
|
+
delivery_id: external_exports.string().nullish(),
|
|
20
|
+
sender: external_exports.string().optional(),
|
|
21
|
+
sender_handle: external_exports.string().optional(),
|
|
22
|
+
type: external_exports.string().optional(),
|
|
23
|
+
content: external_exports.record(external_exports.unknown()).optional(),
|
|
24
|
+
created_at: external_exports.string().optional()
|
|
25
|
+
}).passthrough();
|
|
26
|
+
function parseInbound(payload) {
|
|
27
|
+
const parsed = SyncRowSchema.safeParse(payload);
|
|
28
|
+
return parsed.success ? parsed.data : null;
|
|
29
|
+
}
|
|
30
|
+
function senderOf(row) {
|
|
31
|
+
return row.sender ?? row.sender_handle ?? "unknown";
|
|
32
|
+
}
|
|
33
|
+
function contextOf(row) {
|
|
34
|
+
const raw = row.context;
|
|
35
|
+
const c = raw && typeof raw === "object" ? raw : {};
|
|
36
|
+
const sender = c.sender && typeof c.sender === "object" ? c.sender : {};
|
|
37
|
+
const conv = c.conversation && typeof c.conversation === "object" ? c.conversation : {};
|
|
38
|
+
return {
|
|
39
|
+
senderDisplayName: typeof sender.display_name === "string" ? sender.display_name : null,
|
|
40
|
+
senderKind: sender.kind === "system" ? "system" : "agent",
|
|
41
|
+
groupName: typeof conv.group_name === "string" ? conv.group_name : null,
|
|
42
|
+
memberCount: typeof conv.member_count === "number" ? conv.member_count : null,
|
|
43
|
+
mentions: Array.isArray(c.mentions) ? c.mentions.filter((m) => typeof m === "string").map((m) => m.toLowerCase()) : []
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// src/daemon/ws-client.ts
|
|
48
|
+
var BASE_BACKOFF_MS = 1e3;
|
|
49
|
+
var MAX_BACKOFF_MS = 6e4;
|
|
50
|
+
var LIVENESS_MS = 1e5;
|
|
51
|
+
var AgentWsClient = class extends EventEmitter {
|
|
52
|
+
constructor(url, apiKey) {
|
|
53
|
+
super();
|
|
54
|
+
this.url = url;
|
|
55
|
+
this.apiKey = apiKey;
|
|
56
|
+
}
|
|
57
|
+
url;
|
|
58
|
+
apiKey;
|
|
59
|
+
ws = null;
|
|
60
|
+
state = "closed";
|
|
61
|
+
attempt = 0;
|
|
62
|
+
reconnectTimer = null;
|
|
63
|
+
livenessTimer = null;
|
|
64
|
+
stopped = false;
|
|
65
|
+
ackMode = false;
|
|
66
|
+
/** True only while the socket is live and ready. The heartbeat writer keys
|
|
67
|
+
* off this, so a reconnecting/terminal daemon lets its heartbeat go stale
|
|
68
|
+
* and the next session detects that always-on is actually down. */
|
|
69
|
+
get connected() {
|
|
70
|
+
return this.state === "ready";
|
|
71
|
+
}
|
|
72
|
+
start() {
|
|
73
|
+
this.stopped = false;
|
|
74
|
+
this.open();
|
|
75
|
+
}
|
|
76
|
+
stop() {
|
|
77
|
+
this.stopped = true;
|
|
78
|
+
this.state = "closed";
|
|
79
|
+
this.clearTimers();
|
|
80
|
+
if (this.ws) {
|
|
81
|
+
try {
|
|
82
|
+
this.ws.close(1e3, "daemon shutdown");
|
|
83
|
+
} catch {
|
|
84
|
+
}
|
|
85
|
+
this.ws = null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
getState() {
|
|
89
|
+
return this.state;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Confirm a message as handled: `{"type":"ack","message_id":"msg_..."}`.
|
|
93
|
+
* Fire-and-forget by design — a dropped ack is loss-free (the delivery
|
|
94
|
+
* stays 'stored' and re-drains on the next reconnect, where dedup absorbs
|
|
95
|
+
* the replay). Acking by message id (not delivery id) is what lets a
|
|
96
|
+
* real-time push — which carries no delivery_id — be acked at all.
|
|
97
|
+
*/
|
|
98
|
+
ack(messageId) {
|
|
99
|
+
if (this.state !== "ready" || !this.ws) return;
|
|
100
|
+
try {
|
|
101
|
+
this.ws.send(JSON.stringify({ type: "ack", message_id: messageId }));
|
|
102
|
+
} catch (err) {
|
|
103
|
+
log.debug(`ack send failed for ${messageId} (will re-drain): ${String(err)}`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
open() {
|
|
107
|
+
if (this.stopped) return;
|
|
108
|
+
this.state = this.attempt === 0 ? "connecting" : "reconnecting";
|
|
109
|
+
log.info(`ws ${this.state} (attempt ${this.attempt + 1}) \u2192 ${this.url}`);
|
|
110
|
+
const ws = new WebSocket(this.url, {
|
|
111
|
+
headers: {
|
|
112
|
+
authorization: `Bearer ${this.apiKey}`,
|
|
113
|
+
// Opt into the delivery-ack protocol: the server then leaves each
|
|
114
|
+
// delivery 'stored' until we ack it (by message id) instead of
|
|
115
|
+
// marking it delivered the instant it hits the socket. A crash
|
|
116
|
+
// mid-turn therefore re-drains on reconnect — at-least-once.
|
|
117
|
+
"x-agentchat-capabilities": "ack"
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
this.ws = ws;
|
|
121
|
+
ws.on("open", () => {
|
|
122
|
+
this.attempt = 0;
|
|
123
|
+
this.state = "ready";
|
|
124
|
+
this.armLiveness();
|
|
125
|
+
log.info("ws ready \u2014 draining + listening");
|
|
126
|
+
this.emit("ready");
|
|
127
|
+
});
|
|
128
|
+
ws.on("message", (data) => {
|
|
129
|
+
this.armLiveness();
|
|
130
|
+
let frame;
|
|
131
|
+
try {
|
|
132
|
+
frame = JSON.parse(data.toString());
|
|
133
|
+
} catch {
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
const f = frame;
|
|
137
|
+
if (f?.type === "message.new") {
|
|
138
|
+
const row = parseInbound(f.payload);
|
|
139
|
+
if (row) this.emit("inbound", row);
|
|
140
|
+
else log.warn(`message.new payload failed to parse: ${JSON.stringify(f.payload).slice(0, 300)}`);
|
|
141
|
+
} else if (f?.type === "hello.ok") {
|
|
142
|
+
const caps = Array.isArray(f.capabilities) ? f.capabilities : [];
|
|
143
|
+
this.ackMode = caps.includes("ack");
|
|
144
|
+
log.info(`ws hello.ok \u2014 ack-mode ${this.ackMode ? "ON" : "OFF (legacy)"}`);
|
|
145
|
+
} else {
|
|
146
|
+
log.debug(`ws frame: ${f?.type}`);
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
ws.on("ping", () => this.armLiveness());
|
|
150
|
+
ws.on("unexpected-response", (_req, res) => {
|
|
151
|
+
if (res.statusCode === 401 || res.statusCode === 403) {
|
|
152
|
+
this.state = "terminal";
|
|
153
|
+
this.clearTimers();
|
|
154
|
+
const reason = `auth rejected (${res.statusCode}) \u2014 check the agent's API key`;
|
|
155
|
+
log.error(`ws ${reason}`);
|
|
156
|
+
this.emit("terminal", reason);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
log.warn(`ws unexpected response ${res.statusCode} \u2014 will reconnect`);
|
|
160
|
+
});
|
|
161
|
+
ws.on("error", (err) => {
|
|
162
|
+
log.warn(`ws error: ${String(err)}`);
|
|
163
|
+
});
|
|
164
|
+
ws.on("close", (code) => {
|
|
165
|
+
if (this.state === "terminal" || this.stopped) return;
|
|
166
|
+
log.warn(`ws closed (${code}) \u2014 scheduling reconnect`);
|
|
167
|
+
this.scheduleReconnect();
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
scheduleReconnect() {
|
|
171
|
+
if (this.stopped || this.state === "terminal") return;
|
|
172
|
+
this.state = "reconnecting";
|
|
173
|
+
this.clearTimers();
|
|
174
|
+
const backoff = Math.min(BASE_BACKOFF_MS * 2 ** this.attempt, MAX_BACKOFF_MS);
|
|
175
|
+
const jitter = backoff * (0.5 + Math.random() * 0.5);
|
|
176
|
+
this.attempt++;
|
|
177
|
+
this.reconnectTimer = setTimeout(() => this.open(), jitter);
|
|
178
|
+
}
|
|
179
|
+
armLiveness() {
|
|
180
|
+
if (this.livenessTimer) clearTimeout(this.livenessTimer);
|
|
181
|
+
this.livenessTimer = setTimeout(() => {
|
|
182
|
+
log.warn("ws liveness timeout \u2014 forcing reconnect");
|
|
183
|
+
try {
|
|
184
|
+
this.ws?.terminate();
|
|
185
|
+
} catch {
|
|
186
|
+
}
|
|
187
|
+
this.scheduleReconnect();
|
|
188
|
+
}, LIVENESS_MS);
|
|
189
|
+
}
|
|
190
|
+
clearTimers() {
|
|
191
|
+
if (this.reconnectTimer) {
|
|
192
|
+
clearTimeout(this.reconnectTimer);
|
|
193
|
+
this.reconnectTimer = null;
|
|
194
|
+
}
|
|
195
|
+
if (this.livenessTimer) {
|
|
196
|
+
clearTimeout(this.livenessTimer);
|
|
197
|
+
this.livenessTimer = null;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
// src/daemon/coord.ts
|
|
203
|
+
var ReplyCoord = class {
|
|
204
|
+
constructor(cfg) {
|
|
205
|
+
this.cfg = cfg;
|
|
206
|
+
}
|
|
207
|
+
cfg;
|
|
208
|
+
async req(method, pathname, body) {
|
|
209
|
+
const url = this.cfg.apiBase.replace(/\/+$/, "") + pathname;
|
|
210
|
+
const res = await fetch(url, {
|
|
211
|
+
method,
|
|
212
|
+
headers: {
|
|
213
|
+
authorization: `Bearer ${this.cfg.apiKey}`,
|
|
214
|
+
...body !== void 0 ? { "content-type": "application/json" } : {}
|
|
215
|
+
},
|
|
216
|
+
...body !== void 0 ? { body: JSON.stringify(body) } : {},
|
|
217
|
+
signal: AbortSignal.timeout(this.cfg.timeoutMs ?? 5e3)
|
|
218
|
+
});
|
|
219
|
+
if (!res.ok) throw new Error(`reply-coord ${res.status}`);
|
|
220
|
+
return res.json();
|
|
221
|
+
}
|
|
222
|
+
/** Is the agent's live coding session actively working? Fail-open → FALSE. */
|
|
223
|
+
async isSessionActive() {
|
|
224
|
+
try {
|
|
225
|
+
const d = await this.req("GET", "/v1/reply/active");
|
|
226
|
+
return d?.active === true;
|
|
227
|
+
} catch (err) {
|
|
228
|
+
log.debug(`coord isSessionActive failed (assuming inactive): ${String(err)}`);
|
|
229
|
+
return false;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Claim the sole right to reply to a message. Returns true if THIS daemon is
|
|
234
|
+
* the designated replier, false if a live session already owns it. Fail-open
|
|
235
|
+
* → TRUE (reply anyway rather than drop).
|
|
236
|
+
*/
|
|
237
|
+
async claim(messageId) {
|
|
238
|
+
try {
|
|
239
|
+
const d = await this.req("POST", "/v1/reply/claim", {
|
|
240
|
+
message_id: messageId,
|
|
241
|
+
holder: this.cfg.holder
|
|
242
|
+
});
|
|
243
|
+
return d?.claimed !== false;
|
|
244
|
+
} catch (err) {
|
|
245
|
+
log.debug(`coord claim failed (proceeding): ${String(err)}`);
|
|
246
|
+
return true;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
// src/daemon/format.ts
|
|
252
|
+
function describeConversation(ctx) {
|
|
253
|
+
if (!ctx.conversationId.startsWith("grp_")) {
|
|
254
|
+
return `the direct conversation ${ctx.conversationId}`;
|
|
255
|
+
}
|
|
256
|
+
return ctx.groupName ? `the group "${ctx.groupName}" (${ctx.conversationId})` : `the group conversation ${ctx.conversationId}`;
|
|
257
|
+
}
|
|
258
|
+
function describeSender(ctx) {
|
|
259
|
+
const named = ctx.senderDisplayName ? `${ctx.senderDisplayName} (@${ctx.sender})` : `@${ctx.sender}`;
|
|
260
|
+
return ctx.senderKind === "system" ? `${named}, a system agent` : named;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// src/daemon/config.ts
|
|
264
|
+
import * as path from "path";
|
|
265
|
+
function wsUrlFor(apiBase) {
|
|
266
|
+
return apiBase.replace(/^http/, "ws").replace(/\/+$/, "") + "/v1/ws";
|
|
267
|
+
}
|
|
268
|
+
async function resolveDaemonConfig(opts) {
|
|
269
|
+
const home = path.resolve(opts.home);
|
|
270
|
+
const id = resolveIdentity(home);
|
|
271
|
+
if (id === null) {
|
|
272
|
+
throw new Error(`no AgentChat identity in ${home} \u2014 register this agent first`);
|
|
273
|
+
}
|
|
274
|
+
let handle = id.handle;
|
|
275
|
+
if (handle === null) {
|
|
276
|
+
const me = await getMeLite({ apiKey: id.apiKey, apiBase: id.apiBase });
|
|
277
|
+
if (me === null) {
|
|
278
|
+
throw new Error(
|
|
279
|
+
"could not determine this agent\u2019s handle (no credentials file, and /v1/agents/me did not answer)"
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
handle = me.handle;
|
|
283
|
+
}
|
|
284
|
+
return {
|
|
285
|
+
apiKey: id.apiKey,
|
|
286
|
+
handle,
|
|
287
|
+
apiBase: id.apiBase,
|
|
288
|
+
wsUrl: wsUrlFor(id.apiBase),
|
|
289
|
+
home,
|
|
290
|
+
workdir: opts.workdir ?? path.join(home, "daemon-workdir")
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// src/daemon/loop.ts
|
|
295
|
+
import * as os from "os";
|
|
296
|
+
var MAX_CONCURRENT_TURNS = 3;
|
|
297
|
+
var MAX_ATTEMPTS = 3;
|
|
298
|
+
var HEARTBEAT_MS = 3e4;
|
|
299
|
+
var YIELD_MS = Number(process.env["AGENTCHATD_YIELD_MS"] ?? 1e4);
|
|
300
|
+
var delay = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
301
|
+
var Daemon = class {
|
|
302
|
+
constructor(cfg, adapter, ws) {
|
|
303
|
+
this.cfg = cfg;
|
|
304
|
+
this.adapter = adapter;
|
|
305
|
+
this.coord = new ReplyCoord({
|
|
306
|
+
apiKey: cfg.apiKey,
|
|
307
|
+
apiBase: cfg.apiBase,
|
|
308
|
+
holder: `daemon:${os.hostname()}`
|
|
309
|
+
});
|
|
310
|
+
this.ws = ws ?? new AgentWsClient(cfg.wsUrl, cfg.apiKey);
|
|
311
|
+
this.ws.on("inbound", (row) => this.onInbound(row));
|
|
312
|
+
this.ws.on("ready", () => beat(this.cfg.home));
|
|
313
|
+
this.ws.on("terminal", (reason) => {
|
|
314
|
+
log.error(`daemon terminal: ${reason}`);
|
|
315
|
+
this.stop();
|
|
316
|
+
process.exitCode = 1;
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
cfg;
|
|
320
|
+
adapter;
|
|
321
|
+
ws;
|
|
322
|
+
coord;
|
|
323
|
+
seen = /* @__PURE__ */ new Map();
|
|
324
|
+
// message id → attempts
|
|
325
|
+
convChains = /* @__PURE__ */ new Map();
|
|
326
|
+
inFlight = 0;
|
|
327
|
+
waiters = [];
|
|
328
|
+
stopping = false;
|
|
329
|
+
heartbeatTimer = null;
|
|
330
|
+
async start() {
|
|
331
|
+
const pre = await this.adapter.preflight();
|
|
332
|
+
if (!pre.ok) {
|
|
333
|
+
throw new Error(`runtime (${this.adapter.name}) not ready: ${pre.detail}`);
|
|
334
|
+
}
|
|
335
|
+
log.info(`agentchat daemon up as @${this.cfg.handle} via ${this.adapter.name}; holding the wire`);
|
|
336
|
+
this.ws.start();
|
|
337
|
+
this.heartbeatTimer = setInterval(() => {
|
|
338
|
+
if (this.ws.connected) beat(this.cfg.home);
|
|
339
|
+
}, HEARTBEAT_MS);
|
|
340
|
+
this.heartbeatTimer.unref();
|
|
341
|
+
}
|
|
342
|
+
stop() {
|
|
343
|
+
this.stopping = true;
|
|
344
|
+
if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
|
|
345
|
+
this.ws.stop();
|
|
346
|
+
}
|
|
347
|
+
onInbound(row) {
|
|
348
|
+
if (senderOf(row) === this.cfg.handle) return;
|
|
349
|
+
if (this.seen.has(row.id)) return;
|
|
350
|
+
this.seen.set(row.id, 0);
|
|
351
|
+
this.enqueue(row);
|
|
352
|
+
}
|
|
353
|
+
/** Serialize turns within a conversation; the global semaphore caps total. */
|
|
354
|
+
enqueue(row) {
|
|
355
|
+
const prev = this.convChains.get(row.conversation_id) ?? Promise.resolve();
|
|
356
|
+
const next = prev.then(() => this.handle(row)).catch((err) => {
|
|
357
|
+
log.warn(`unhandled in conv ${row.conversation_id}: ${String(err)}`);
|
|
358
|
+
});
|
|
359
|
+
this.convChains.set(row.conversation_id, next);
|
|
360
|
+
void next.then(() => {
|
|
361
|
+
if (this.convChains.get(row.conversation_id) === next) this.convChains.delete(row.conversation_id);
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
async handle(row) {
|
|
365
|
+
if (this.stopping) return;
|
|
366
|
+
if (await this.coord.isSessionActive()) {
|
|
367
|
+
log.info(`msg ${row.id}: live session active \u2014 yielding for ${YIELD_MS}ms`);
|
|
368
|
+
await delay(YIELD_MS);
|
|
369
|
+
if (this.stopping) return;
|
|
370
|
+
}
|
|
371
|
+
if (!await this.coord.claim(row.id)) {
|
|
372
|
+
log.info(`msg ${row.id}: claimed by the live session \u2014 standing down`);
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
await this.acquireSlot();
|
|
376
|
+
try {
|
|
377
|
+
const attempts = (this.seen.get(row.id) ?? 0) + 1;
|
|
378
|
+
this.seen.set(row.id, attempts);
|
|
379
|
+
log.info(`turn for msg ${row.id} from @${senderOf(row)} (attempt ${attempts})`);
|
|
380
|
+
const ctx = contextOf(row);
|
|
381
|
+
const result = await this.adapter.runTurn({
|
|
382
|
+
conversationId: row.conversation_id,
|
|
383
|
+
sender: senderOf(row),
|
|
384
|
+
text: typeof row.content?.["text"] === "string" ? row.content["text"] : "",
|
|
385
|
+
createdAt: typeof row.created_at === "string" ? row.created_at : void 0,
|
|
386
|
+
type: typeof row.type === "string" ? row.type : void 0,
|
|
387
|
+
senderDisplayName: ctx.senderDisplayName,
|
|
388
|
+
senderKind: ctx.senderKind,
|
|
389
|
+
groupName: ctx.groupName,
|
|
390
|
+
mentioned: ctx.mentions.includes(this.cfg.handle.toLowerCase())
|
|
391
|
+
});
|
|
392
|
+
if (result.ok) {
|
|
393
|
+
this.ws.ack(row.id);
|
|
394
|
+
} else if (result.fatal) {
|
|
395
|
+
log.error(`fatal turn error: ${result.detail} \u2014 not acking (will re-drain)`);
|
|
396
|
+
} else if (attempts >= MAX_ATTEMPTS) {
|
|
397
|
+
log.warn(`msg ${row.id} failed ${attempts}\xD7 (${result.detail}); acking to drop (poison guard)`);
|
|
398
|
+
this.ws.ack(row.id);
|
|
399
|
+
} else {
|
|
400
|
+
log.warn(`turn failed for ${row.id}: ${result.detail}; leaving unacked for re-drain`);
|
|
401
|
+
}
|
|
402
|
+
} finally {
|
|
403
|
+
this.releaseSlot();
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
// ─── global concurrency semaphore ─────────────────────────────────────────
|
|
407
|
+
// A waiter inherits the releaser's slot directly (inFlight unchanged on
|
|
408
|
+
// hand-off) — no decrement-then-reincrement window that could momentarily
|
|
409
|
+
// exceed the cap.
|
|
410
|
+
acquireSlot() {
|
|
411
|
+
if (this.inFlight < MAX_CONCURRENT_TURNS) {
|
|
412
|
+
this.inFlight++;
|
|
413
|
+
return Promise.resolve();
|
|
414
|
+
}
|
|
415
|
+
return new Promise((resolve2) => this.waiters.push(resolve2));
|
|
416
|
+
}
|
|
417
|
+
releaseSlot() {
|
|
418
|
+
const next = this.waiters.shift();
|
|
419
|
+
if (next) next();
|
|
420
|
+
else this.inFlight--;
|
|
421
|
+
}
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
// src/daemon/run.ts
|
|
425
|
+
async function runDaemon(opts) {
|
|
426
|
+
let cfg;
|
|
427
|
+
try {
|
|
428
|
+
cfg = await resolveDaemonConfig({
|
|
429
|
+
home: opts.home,
|
|
430
|
+
...opts.workdir !== void 0 ? { workdir: opts.workdir } : {}
|
|
431
|
+
});
|
|
432
|
+
} catch (err) {
|
|
433
|
+
console.error(String(err instanceof Error ? err.message : err));
|
|
434
|
+
return 1;
|
|
435
|
+
}
|
|
436
|
+
const lock = acquireLeaderLock(cfg.home);
|
|
437
|
+
if (lock === null) return 1;
|
|
438
|
+
const daemon = new Daemon(cfg, opts.adapter);
|
|
439
|
+
let releasing = false;
|
|
440
|
+
const shutdown = (sig) => {
|
|
441
|
+
if (releasing) return;
|
|
442
|
+
releasing = true;
|
|
443
|
+
log.info(`${sig} \u2014 shutting down`);
|
|
444
|
+
daemon.stop();
|
|
445
|
+
lock.release();
|
|
446
|
+
process.exit(0);
|
|
447
|
+
};
|
|
448
|
+
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
449
|
+
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
450
|
+
try {
|
|
451
|
+
await daemon.start();
|
|
452
|
+
} catch (err) {
|
|
453
|
+
console.error(String(err instanceof Error ? err.message : err));
|
|
454
|
+
lock.release();
|
|
455
|
+
return 1;
|
|
456
|
+
}
|
|
457
|
+
return await new Promise(() => {
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
export {
|
|
461
|
+
AgentWsClient,
|
|
462
|
+
Daemon,
|
|
463
|
+
ReplyCoord,
|
|
464
|
+
describeConversation,
|
|
465
|
+
describeSender,
|
|
466
|
+
parseInbound,
|
|
467
|
+
resolveDaemonConfig,
|
|
468
|
+
runDaemon,
|
|
469
|
+
senderOf,
|
|
470
|
+
wsUrlFor
|
|
471
|
+
};
|
|
472
|
+
//# sourceMappingURL=daemon-entry.js.map
|