@botcord/daemon 0.2.13 → 0.2.15
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-workspace.js +47 -1
- package/dist/gateway/channels/botcord.js +39 -0
- package/dist/gateway/dispatcher.d.ts +6 -0
- package/dist/gateway/dispatcher.js +207 -9
- package/dist/gateway/runtimes/acp-stream.d.ts +7 -1
- package/dist/gateway/runtimes/acp-stream.js +19 -0
- package/dist/gateway/runtimes/claude-code.js +34 -0
- package/dist/gateway/runtimes/codex.js +50 -0
- package/dist/gateway/runtimes/hermes-agent.d.ts +8 -3
- package/dist/gateway/runtimes/hermes-agent.js +36 -6
- package/dist/gateway/runtimes/ndjson-stream.d.ts +8 -1
- package/dist/gateway/runtimes/ndjson-stream.js +8 -0
- package/dist/gateway/types.d.ts +54 -2
- package/dist/index.js +72 -5
- package/dist/provision.js +63 -1
- package/package.json +1 -1
- package/src/__tests__/agent-workspace.test.ts +25 -0
- package/src/__tests__/provision.test.ts +68 -1
- package/src/agent-workspace.ts +47 -0
- package/src/gateway/__tests__/botcord-channel.test.ts +97 -0
- package/src/gateway/__tests__/claude-code-adapter.test.ts +35 -0
- package/src/gateway/__tests__/codex-adapter.test.ts +44 -0
- package/src/gateway/__tests__/dispatcher.test.ts +552 -1
- package/src/gateway/__tests__/hermes-agent-adapter.test.ts +39 -0
- package/src/gateway/channels/botcord.ts +38 -0
- package/src/gateway/dispatcher.ts +217 -15
- package/src/gateway/runtimes/acp-stream.ts +24 -0
- package/src/gateway/runtimes/claude-code.ts +41 -1
- package/src/gateway/runtimes/codex.ts +58 -0
- package/src/gateway/runtimes/hermes-agent.ts +45 -5
- package/src/gateway/runtimes/ndjson-stream.ts +15 -0
- package/src/gateway/types.ts +55 -2
- package/src/index.ts +88 -5
- package/src/provision.ts +62 -1
package/src/gateway/types.ts
CHANGED
|
@@ -247,6 +247,19 @@ export interface ChannelStreamBlockContext {
|
|
|
247
247
|
log: GatewayLogger;
|
|
248
248
|
}
|
|
249
249
|
|
|
250
|
+
/**
|
|
251
|
+
* Context passed to `ChannelAdapter.typing()` when the dispatcher signals
|
|
252
|
+
* "agent has accepted this turn but no execution block has surfaced yet".
|
|
253
|
+
* Adapters that bridge to a presence-style API (BotCord `/hub/typing`, etc.)
|
|
254
|
+
* map this into a one-shot ephemeral notification.
|
|
255
|
+
*/
|
|
256
|
+
export interface ChannelTypingContext {
|
|
257
|
+
traceId: string;
|
|
258
|
+
accountId: string;
|
|
259
|
+
conversationId: string;
|
|
260
|
+
log: GatewayLogger;
|
|
261
|
+
}
|
|
262
|
+
|
|
250
263
|
/** Upstream messaging surface such as BotCord, Telegram, or WeChat. */
|
|
251
264
|
export interface ChannelAdapter {
|
|
252
265
|
readonly id: string;
|
|
@@ -256,6 +269,12 @@ export interface ChannelAdapter {
|
|
|
256
269
|
send(ctx: ChannelSendContext): Promise<ChannelSendResult>;
|
|
257
270
|
status?(): ChannelStatusSnapshot;
|
|
258
271
|
streamBlock?(ctx: ChannelStreamBlockContext): Promise<void>;
|
|
272
|
+
/**
|
|
273
|
+
* Optional ephemeral "agent is responding" hint. Fire-and-forget; failures
|
|
274
|
+
* must not break the turn. Channels without a presence concept should leave
|
|
275
|
+
* this undefined.
|
|
276
|
+
*/
|
|
277
|
+
typing?(ctx: ChannelTypingContext): Promise<void>;
|
|
259
278
|
}
|
|
260
279
|
|
|
261
280
|
// ---------------------------------------------------------------------------
|
|
@@ -266,12 +285,38 @@ export interface ChannelAdapter {
|
|
|
266
285
|
export interface StreamBlock {
|
|
267
286
|
/** Raw JSON object as emitted by the underlying CLI (e.g. claude-code stream-json). */
|
|
268
287
|
raw: unknown;
|
|
269
|
-
/**
|
|
270
|
-
|
|
288
|
+
/**
|
|
289
|
+
* Normalized kind, used by channels to decide whether to forward progressive
|
|
290
|
+
* output. `thinking` is synthesized by the dispatcher (or emitted explicitly
|
|
291
|
+
* by an adapter) to represent "the runtime is busy but has nothing visible
|
|
292
|
+
* to show yet" — see `RuntimeStatusEvent`.
|
|
293
|
+
*/
|
|
294
|
+
kind: "assistant_text" | "tool_use" | "tool_result" | "system" | "thinking" | "other";
|
|
271
295
|
/** 1-based sequence number within this turn. */
|
|
272
296
|
seq: number;
|
|
273
297
|
}
|
|
274
298
|
|
|
299
|
+
/**
|
|
300
|
+
* Lightweight lifecycle event emitted by runtime adapters and consumed by the
|
|
301
|
+
* dispatcher to drive Dashboard-side `typing` / `thinking` UI states. Not
|
|
302
|
+
* exposed to channels directly — the dispatcher decides how to forward.
|
|
303
|
+
*
|
|
304
|
+
* - `typing` — ephemeral presence; dispatcher pings the channel's
|
|
305
|
+
* `typing()` API on `started`. `stopped` is observed for
|
|
306
|
+
* internal bookkeeping but not forwarded (frontend clears on
|
|
307
|
+
* stream/message arrival).
|
|
308
|
+
* - `thinking` — trace-bound execution state; dispatcher converts each
|
|
309
|
+
* event into a `kind: "thinking"` stream block.
|
|
310
|
+
*/
|
|
311
|
+
export type RuntimeStatusEvent =
|
|
312
|
+
| { kind: "typing"; phase: "started" | "stopped" }
|
|
313
|
+
| {
|
|
314
|
+
kind: "thinking";
|
|
315
|
+
phase: "started" | "updated" | "stopped";
|
|
316
|
+
label?: string;
|
|
317
|
+
raw?: unknown;
|
|
318
|
+
};
|
|
319
|
+
|
|
275
320
|
/** Options passed to a runtime adapter for a single turn. */
|
|
276
321
|
export interface RuntimeRunOptions {
|
|
277
322
|
text: string;
|
|
@@ -302,6 +347,14 @@ export interface RuntimeRunOptions {
|
|
|
302
347
|
context?: Record<string, unknown>;
|
|
303
348
|
/** Called for every parsed block while the turn is in progress. */
|
|
304
349
|
onBlock?: (block: StreamBlock) => void;
|
|
350
|
+
/**
|
|
351
|
+
* Optional lifecycle hook for `typing` / `thinking` status. Adapters that
|
|
352
|
+
* can identify session/turn/tool transitions before any `StreamBlock` is
|
|
353
|
+
* available should emit through here so the dispatcher can drive
|
|
354
|
+
* Dashboard-side state. Errors from this callback must be swallowed
|
|
355
|
+
* by the adapter — the dispatcher's handler is fire-and-forget.
|
|
356
|
+
*/
|
|
357
|
+
onStatus?: (event: RuntimeStatusEvent) => void;
|
|
305
358
|
/**
|
|
306
359
|
* External service endpoint required by some runtimes (first user:
|
|
307
360
|
* openclaw-acp). Resolved at config-load time and passed through here per
|
package/src/index.ts
CHANGED
|
@@ -83,7 +83,7 @@ Usage: botcord-daemon <command> [options]
|
|
|
83
83
|
|
|
84
84
|
Commands:
|
|
85
85
|
start [--background|-d] [--relogin] [--hub <url>] [--label <name>]
|
|
86
|
-
[--agent <ag_xxx> ...] [--cwd <path>]
|
|
86
|
+
[--install-token <dit_xxx>] [--agent <ag_xxx> ...] [--cwd <path>]
|
|
87
87
|
Start the daemon in the foreground by
|
|
88
88
|
default. Pass --background (alias -d)
|
|
89
89
|
to detach and return to the shell.
|
|
@@ -92,6 +92,9 @@ Commands:
|
|
|
92
92
|
first. --hub defaults to ${DEFAULT_HUB}
|
|
93
93
|
(or the URL stored in a previous
|
|
94
94
|
login). --relogin forces re-login.
|
|
95
|
+
--install-token redeems a dashboard
|
|
96
|
+
issued one-time install ticket for
|
|
97
|
+
non-interactive first start.
|
|
95
98
|
--label is sent to the Hub on connect
|
|
96
99
|
for the dashboard device list
|
|
97
100
|
(defaults to hostname). Non-TTY
|
|
@@ -275,6 +278,66 @@ function delay(ms: number): Promise<void> {
|
|
|
275
278
|
return new Promise((r) => setTimeout(r, ms));
|
|
276
279
|
}
|
|
277
280
|
|
|
281
|
+
interface DaemonTokenResponse {
|
|
282
|
+
accessToken: string;
|
|
283
|
+
refreshToken: string;
|
|
284
|
+
expiresIn: number;
|
|
285
|
+
userId: string;
|
|
286
|
+
daemonInstanceId: string;
|
|
287
|
+
hubUrl: string;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function parseDaemonTokenResponse(raw: unknown, fallbackHubUrl: string): DaemonTokenResponse {
|
|
291
|
+
const obj = (raw && typeof raw === "object" ? raw : {}) as Record<string, unknown>;
|
|
292
|
+
const pick = (camel: string, snake: string): unknown => obj[camel] ?? obj[snake];
|
|
293
|
+
const accessToken = pick("accessToken", "access_token");
|
|
294
|
+
const refreshToken = pick("refreshToken", "refresh_token");
|
|
295
|
+
const expiresIn = pick("expiresIn", "expires_in");
|
|
296
|
+
const userId = pick("userId", "user_id");
|
|
297
|
+
const daemonInstanceId = pick("daemonInstanceId", "daemon_instance_id");
|
|
298
|
+
const hubUrl = pick("hubUrl", "hub_url");
|
|
299
|
+
if (typeof accessToken !== "string" || !accessToken) {
|
|
300
|
+
throw new Error("daemon auth response missing accessToken");
|
|
301
|
+
}
|
|
302
|
+
if (typeof refreshToken !== "string" || !refreshToken) {
|
|
303
|
+
throw new Error("daemon auth response missing refreshToken");
|
|
304
|
+
}
|
|
305
|
+
if (typeof userId !== "string" || !userId) {
|
|
306
|
+
throw new Error("daemon auth response missing userId");
|
|
307
|
+
}
|
|
308
|
+
if (typeof daemonInstanceId !== "string" || !daemonInstanceId) {
|
|
309
|
+
throw new Error("daemon auth response missing daemonInstanceId");
|
|
310
|
+
}
|
|
311
|
+
return {
|
|
312
|
+
accessToken,
|
|
313
|
+
refreshToken,
|
|
314
|
+
expiresIn: typeof expiresIn === "number" && expiresIn > 0 ? expiresIn : 3600,
|
|
315
|
+
userId,
|
|
316
|
+
daemonInstanceId,
|
|
317
|
+
hubUrl: typeof hubUrl === "string" && hubUrl.length > 0 ? hubUrl : fallbackHubUrl,
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async function redeemInstallToken(opts: {
|
|
322
|
+
hubUrl: string;
|
|
323
|
+
installToken: string;
|
|
324
|
+
label?: string;
|
|
325
|
+
}): Promise<DaemonTokenResponse> {
|
|
326
|
+
const body: Record<string, unknown> = { install_token: opts.installToken };
|
|
327
|
+
if (opts.label) body.label = opts.label;
|
|
328
|
+
const resp = await fetch(`${opts.hubUrl.replace(/\/+$/, "")}/daemon/auth/install-token`, {
|
|
329
|
+
method: "POST",
|
|
330
|
+
headers: { "Content-Type": "application/json" },
|
|
331
|
+
body: JSON.stringify(body),
|
|
332
|
+
signal: AbortSignal.timeout(10_000),
|
|
333
|
+
});
|
|
334
|
+
if (!resp.ok) {
|
|
335
|
+
const text = await resp.text().catch(() => "");
|
|
336
|
+
throw new Error(`daemon install-token redeem failed: ${resp.status} ${text}`);
|
|
337
|
+
}
|
|
338
|
+
return parseDaemonTokenResponse(await resp.json(), opts.hubUrl);
|
|
339
|
+
}
|
|
340
|
+
|
|
278
341
|
/**
|
|
279
342
|
* Run the device-code login flow against the given Hub. Polls every
|
|
280
343
|
* `interval` seconds (the Hub may bump this) until the user authorizes
|
|
@@ -354,15 +417,17 @@ async function runDeviceCodeFlow(opts: {
|
|
|
354
417
|
* plane (legacy P0 behavior — caller may still log a warning).
|
|
355
418
|
*
|
|
356
419
|
* Decision tree (plan §4.4 + §6.4):
|
|
357
|
-
* 1. `--relogin` →
|
|
358
|
-
* 2.
|
|
359
|
-
* 3.
|
|
420
|
+
* 1. Have existing creds and no `--relogin` → return existing record.
|
|
421
|
+
* 2. `--install-token` → redeem the one-time dashboard ticket.
|
|
422
|
+
* 3. `--relogin` → device-code login.
|
|
360
423
|
* 4. No creds + TTY → device-code login.
|
|
361
424
|
* 5. No creds + no TTY → exit 1 with the §6.4 hint.
|
|
362
425
|
*/
|
|
363
426
|
async function ensureUserAuthForStart(args: ParsedArgs): Promise<UserAuthRecord | null> {
|
|
364
427
|
const hubFlag = typeof args.flags.hub === "string" ? args.flags.hub : undefined;
|
|
365
428
|
const labelFlag = typeof args.flags.label === "string" ? args.flags.label : undefined;
|
|
429
|
+
const installToken =
|
|
430
|
+
typeof args.flags["install-token"] === "string" ? args.flags["install-token"] : undefined;
|
|
366
431
|
const relogin = args.flags.relogin === true;
|
|
367
432
|
|
|
368
433
|
const existing = safeLoadUserAuth();
|
|
@@ -383,11 +448,30 @@ async function ensureUserAuthForStart(args: ParsedArgs): Promise<UserAuthRecord
|
|
|
383
448
|
`note: --label "${labelFlag}" ignored (already logged in as "${existing.label ?? "<unset>"}"); pass --relogin to change it`,
|
|
384
449
|
);
|
|
385
450
|
}
|
|
451
|
+
if (installToken) {
|
|
452
|
+
console.error("note: --install-token ignored because daemon is already logged in");
|
|
453
|
+
}
|
|
386
454
|
return existing;
|
|
387
455
|
}
|
|
388
456
|
|
|
389
457
|
// Need a fresh login. Resolve hubUrl: explicit --hub > existing record > DEFAULT_HUB.
|
|
390
458
|
const hubUrl = hubFlag ?? existing?.hubUrl ?? DEFAULT_HUB;
|
|
459
|
+
const label = labelFlag ?? defaultLoginLabel();
|
|
460
|
+
|
|
461
|
+
if (installToken) {
|
|
462
|
+
const tok = await redeemInstallToken({ hubUrl, installToken, label });
|
|
463
|
+
const record = userAuthFromTokenResponse(tok, { label });
|
|
464
|
+
saveUserAuth(record);
|
|
465
|
+
clearAuthExpiredFlag();
|
|
466
|
+
log.info("install-token flow: authorized", {
|
|
467
|
+
userId: record.userId,
|
|
468
|
+
daemonInstanceId: record.daemonInstanceId,
|
|
469
|
+
hubUrl: record.hubUrl,
|
|
470
|
+
label,
|
|
471
|
+
});
|
|
472
|
+
console.log(`Logged in as ${record.userId}`);
|
|
473
|
+
return record;
|
|
474
|
+
}
|
|
391
475
|
|
|
392
476
|
if (!process.stdin.isTTY) {
|
|
393
477
|
// Plan §6.4 — non-interactive environment. Fail fast with actionable
|
|
@@ -402,7 +486,6 @@ async function ensureUserAuthForStart(args: ParsedArgs): Promise<UserAuthRecord
|
|
|
402
486
|
process.exit(1);
|
|
403
487
|
}
|
|
404
488
|
|
|
405
|
-
const label = labelFlag ?? defaultLoginLabel();
|
|
406
489
|
return runDeviceCodeFlow({ hubUrl, label });
|
|
407
490
|
}
|
|
408
491
|
|
package/src/provision.ts
CHANGED
|
@@ -712,9 +712,11 @@ export async function adoptDiscoveredOpenclawAgents(ctx: {
|
|
|
712
712
|
return;
|
|
713
713
|
}
|
|
714
714
|
try {
|
|
715
|
+
const name = resolveOpenclawIdentityName(oc.id, oc.workspace) ?? oc.name ?? `openclaw-${oc.id}`;
|
|
715
716
|
const params: ProvisionAgentParams = {
|
|
716
717
|
runtime: "openclaw-acp",
|
|
717
|
-
name
|
|
718
|
+
name,
|
|
719
|
+
bio: `OpenClaw agent ${oc.id} adopted from gateway ${gw.name}.`,
|
|
718
720
|
openclaw: { gateway: gw.name, agent: oc.id },
|
|
719
721
|
};
|
|
720
722
|
const credentials = await materializeCredentials(params, freshCfg, {
|
|
@@ -1197,6 +1199,8 @@ function readLocalOpenclawAgents(): Array<{
|
|
|
1197
1199
|
const row: { id: string; name?: string; workspace?: string; model?: { name?: string; provider?: string } } = { id };
|
|
1198
1200
|
if (typeof raw?.name === "string") row.name = raw.name;
|
|
1199
1201
|
if (typeof raw?.workspace === "string") row.workspace = raw.workspace;
|
|
1202
|
+
const identityName = resolveOpenclawIdentityName(id, row.workspace, cfg);
|
|
1203
|
+
if (identityName) row.name = identityName;
|
|
1200
1204
|
const m = raw?.model;
|
|
1201
1205
|
if (m && typeof m === "object") {
|
|
1202
1206
|
const model: { name?: string; provider?: string } = {};
|
|
@@ -1216,6 +1220,63 @@ function readLocalOpenclawAgents(): Array<{
|
|
|
1216
1220
|
}
|
|
1217
1221
|
}
|
|
1218
1222
|
|
|
1223
|
+
function resolveOpenclawIdentityName(
|
|
1224
|
+
agentId: string,
|
|
1225
|
+
workspace?: string,
|
|
1226
|
+
cfg?: any,
|
|
1227
|
+
): string | undefined {
|
|
1228
|
+
const root = workspace ?? resolveOpenclawWorkspace(agentId, cfg);
|
|
1229
|
+
if (!root) return undefined;
|
|
1230
|
+
const file = path.join(expandHomePath(root), "IDENTITY.md");
|
|
1231
|
+
try {
|
|
1232
|
+
if (!existsSync(file)) return undefined;
|
|
1233
|
+
return parseIdentityName(readFileSync(file, "utf8"));
|
|
1234
|
+
} catch {
|
|
1235
|
+
return undefined;
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
function resolveOpenclawWorkspace(agentId: string, cfg?: any): string | undefined {
|
|
1240
|
+
let parsed = cfg;
|
|
1241
|
+
if (!parsed) {
|
|
1242
|
+
try {
|
|
1243
|
+
const file = path.join(homedir(), ".openclaw", "openclaw.json");
|
|
1244
|
+
if (!existsSync(file)) return undefined;
|
|
1245
|
+
parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
1246
|
+
} catch {
|
|
1247
|
+
return undefined;
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
const defaults = parsed?.agents?.defaults;
|
|
1252
|
+
const defaultId = typeof defaults?.id === "string" && defaults.id ? defaults.id : "default";
|
|
1253
|
+
if ((agentId === defaultId || agentId === "default") && typeof defaults?.workspace === "string") {
|
|
1254
|
+
return defaults.workspace;
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
const list = Array.isArray(parsed?.agents?.list) ? parsed.agents.list : [];
|
|
1258
|
+
for (const entry of list) {
|
|
1259
|
+
if (entry?.id === agentId && typeof entry.workspace === "string") return entry.workspace;
|
|
1260
|
+
}
|
|
1261
|
+
return undefined;
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
function parseIdentityName(raw: string): string | undefined {
|
|
1265
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
1266
|
+
const m = line.match(/^\s*-\s*\*\*Name:\*\*\s*(.+?)\s*$/i);
|
|
1267
|
+
if (!m) continue;
|
|
1268
|
+
const name = m[1].trim();
|
|
1269
|
+
if (name && !name.startsWith("_(")) return name;
|
|
1270
|
+
}
|
|
1271
|
+
return undefined;
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
function expandHomePath(p: string): string {
|
|
1275
|
+
if (p === "~") return homedir();
|
|
1276
|
+
if (p.startsWith("~/")) return path.join(homedir(), p.slice(2));
|
|
1277
|
+
return p;
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1219
1280
|
/**
|
|
1220
1281
|
* Async variant that includes L2 (gateway reachability) and L3 (agent listing)
|
|
1221
1282
|
* probes for runtimes that talk to external services. Used by the production
|