@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.
Files changed (34) hide show
  1. package/dist/agent-workspace.js +47 -1
  2. package/dist/gateway/channels/botcord.js +39 -0
  3. package/dist/gateway/dispatcher.d.ts +6 -0
  4. package/dist/gateway/dispatcher.js +207 -9
  5. package/dist/gateway/runtimes/acp-stream.d.ts +7 -1
  6. package/dist/gateway/runtimes/acp-stream.js +19 -0
  7. package/dist/gateway/runtimes/claude-code.js +34 -0
  8. package/dist/gateway/runtimes/codex.js +50 -0
  9. package/dist/gateway/runtimes/hermes-agent.d.ts +8 -3
  10. package/dist/gateway/runtimes/hermes-agent.js +36 -6
  11. package/dist/gateway/runtimes/ndjson-stream.d.ts +8 -1
  12. package/dist/gateway/runtimes/ndjson-stream.js +8 -0
  13. package/dist/gateway/types.d.ts +54 -2
  14. package/dist/index.js +72 -5
  15. package/dist/provision.js +63 -1
  16. package/package.json +1 -1
  17. package/src/__tests__/agent-workspace.test.ts +25 -0
  18. package/src/__tests__/provision.test.ts +68 -1
  19. package/src/agent-workspace.ts +47 -0
  20. package/src/gateway/__tests__/botcord-channel.test.ts +97 -0
  21. package/src/gateway/__tests__/claude-code-adapter.test.ts +35 -0
  22. package/src/gateway/__tests__/codex-adapter.test.ts +44 -0
  23. package/src/gateway/__tests__/dispatcher.test.ts +552 -1
  24. package/src/gateway/__tests__/hermes-agent-adapter.test.ts +39 -0
  25. package/src/gateway/channels/botcord.ts +38 -0
  26. package/src/gateway/dispatcher.ts +217 -15
  27. package/src/gateway/runtimes/acp-stream.ts +24 -0
  28. package/src/gateway/runtimes/claude-code.ts +41 -1
  29. package/src/gateway/runtimes/codex.ts +58 -0
  30. package/src/gateway/runtimes/hermes-agent.ts +45 -5
  31. package/src/gateway/runtimes/ndjson-stream.ts +15 -0
  32. package/src/gateway/types.ts +55 -2
  33. package/src/index.ts +88 -5
  34. package/src/provision.ts +62 -1
@@ -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
- /** Normalized kind, used by channels to decide whether to forward progressive output. */
270
- kind: "assistant_text" | "tool_use" | "tool_result" | "system" | "other";
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` → device-code login.
358
- * 2. Have valid creds (not near expiry) → return existing record.
359
- * 3. Have stale creds leave as-is; the control channel will refresh.
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: oc.name ?? `openclaw-${oc.id}`,
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