talon-agent 1.48.0 → 1.49.0

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/README.md CHANGED
@@ -23,6 +23,7 @@ Multi-platform agentic AI harness. Runs on **Telegram**, **Discord**, **Microsof
23
23
  | **Skills** | Agent-authored reusable scripts (bash/python/node) — procedures worked out once get saved and replayed locally at zero token cost |
24
24
  | **Triggers** | Self-authored watcher scripts (bash/python/node) that wake the bot when conditions are met |
25
25
  | **Task table** | Every unit of agent work — chat turns, heartbeat, dream, isolated cron/trigger jobs — registered live; `talon ps` / `talon kill` |
26
+ | **Event bus** | Typed internal pub-sub spine (task + turn lifecycle events); subsystems subscribe instead of importing each other; `talon events -f` |
26
27
  | **Per-chat settings** | Model, effort level, and pulse toggle per conversation via inline keyboard |
27
28
  | **Model registry** | Models discovered from the active backend at startup — new models appear in all pickers automatically |
28
29
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "talon-agent",
3
- "version": "1.48.0",
3
+ "version": "1.49.0",
4
4
  "description": "Multi-frontend AI agent with full tool access, streaming, cron jobs, and plugin system",
5
5
  "author": "Dylan Neve",
6
6
  "license": "MIT",
package/src/bootstrap.ts CHANGED
@@ -22,6 +22,7 @@ import {
22
22
  initDispatcher,
23
23
  execute as dispatcherExecute,
24
24
  } from "./core/engine/dispatcher.js";
25
+ import { bus } from "./core/bus/index.js";
25
26
  import { initPulse, resetPulseTimer } from "./core/background/pulse.js";
26
27
  import { initCron } from "./core/background/cron.js";
27
28
  import {
@@ -410,13 +411,14 @@ export async function initBackendAndDispatcher(
410
411
  resolveFrontendByNumericId(chatId, stringId, frontends).sendTyping(
411
412
  chatId,
412
413
  ),
413
- onActivity: () => resetPulseTimer(),
414
- // Turn-start hook: fire-and-forget dream (background memory
415
- // consolidation) check. Wired here so the Weaver stays ignorant of
416
- // the dream subsystem.
417
- onTurnStart: maybeStartDream,
418
414
  });
419
415
 
416
+ // Cross-subsystem reactions ride the bus, so the Weaver stays ignorant of
417
+ // dream and pulse: a bound turn kicks the fire-and-forget dream check, a
418
+ // completed turn resets the pulse idle timer.
419
+ bus.subscribe("turn.started", () => maybeStartDream());
420
+ bus.subscribe("turn.completed", () => resetPulseTimer());
421
+
420
422
  initPulse();
421
423
  initCron({
422
424
  sendMessage: async (chatId: number, text: string, stringId?: string) =>
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Shared CLI access to the running daemon's gateway (127.0.0.1 JSON
3
+ * endpoints: /tasks, /events/recent, …). Discovery finds the instance the
4
+ * same way `talon status` does; commands render, this module only fetches.
5
+ */
6
+
7
+ import pc from "picocolors";
8
+ import { findRunningInstance } from "../core/daemon/discovery.js";
9
+
10
+ const REQUEST_TIMEOUT_MS = 3_000;
11
+
12
+ export async function fetchGateway(
13
+ port: number,
14
+ path: string,
15
+ init?: RequestInit,
16
+ ): Promise<unknown> {
17
+ const response = await fetch(`http://127.0.0.1:${port}${path}`, {
18
+ ...init,
19
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
20
+ });
21
+ if (!response.ok) throw new Error(`gateway answered ${response.status}`);
22
+ return response.json();
23
+ }
24
+
25
+ /** Find the daemon and return its gateway port, or render why not. */
26
+ export async function requireGatewayPort(): Promise<number | null> {
27
+ const instance = await findRunningInstance();
28
+ if (!instance) {
29
+ console.log(` ${pc.red("●")} Talon is not running\n`);
30
+ return null;
31
+ }
32
+ if (!instance.port) {
33
+ console.log(
34
+ ` ${pc.yellow("●")} Talon is running (PID ${instance.pid}) but its gateway port is unknown — possibly still starting.\n`,
35
+ );
36
+ return null;
37
+ }
38
+ return instance.port;
39
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * `talon events` — tail the daemon's event bus.
3
+ *
4
+ * Renders the gateway's `/events/recent` ring; `--follow` (`-f`) keeps
5
+ * polling with a since-cursor for a live tail. Rendering only — the bus
6
+ * itself lives in core/bus/.
7
+ */
8
+
9
+ import pc from "picocolors";
10
+ import { fetchGateway, requireGatewayPort } from "./daemon-api.js";
11
+ import type { PublishedEvent } from "../core/bus/index.js";
12
+
13
+ const FOLLOW_POLL_MS = 1_000;
14
+
15
+ function timestamp(at: number): string {
16
+ return new Date(at).toTimeString().slice(0, 8);
17
+ }
18
+
19
+ /** One tail line of type-specific detail, content-free like the events. */
20
+ function describe(event: PublishedEvent): string {
21
+ switch (event.type) {
22
+ case "task.started":
23
+ return (
24
+ `#${event.task.id} ${event.task.kind} "${event.task.label}"` +
25
+ (event.task.chatId ? ` chat=${event.task.chatId}` : "")
26
+ );
27
+ case "task.settled":
28
+ return (
29
+ `#${event.task.id} ${event.task.kind} "${event.task.label}" → ${event.task.state}` +
30
+ (event.task.error ? ` (${event.task.error})` : "")
31
+ );
32
+ case "turn.started":
33
+ return `chat=${event.chatId} ${event.backendId}/${event.model} (${event.source})`;
34
+ case "turn.completed":
35
+ return `chat=${event.chatId} ${event.durationMs}ms in=${event.inputTokens} out=${event.outputTokens}`;
36
+ }
37
+ }
38
+
39
+ function render(event: PublishedEvent): void {
40
+ console.log(
41
+ ` ${pc.dim(timestamp(event.at))} ${pc.cyan(event.type.padEnd(14))} ${describe(event)}`,
42
+ );
43
+ }
44
+
45
+ async function fetchEvents(
46
+ port: number,
47
+ sinceId: number,
48
+ ): Promise<PublishedEvent[]> {
49
+ const body = (await fetchGateway(
50
+ port,
51
+ `/events/recent?since=${sinceId}`,
52
+ )) as { events?: PublishedEvent[] };
53
+ return body.events ?? [];
54
+ }
55
+
56
+ function sleep(ms: number): Promise<void> {
57
+ return new Promise((resolve) => setTimeout(resolve, ms));
58
+ }
59
+
60
+ export async function showEvents(follow: boolean): Promise<void> {
61
+ console.log();
62
+ const port = await requireGatewayPort();
63
+ if (port === null) return;
64
+
65
+ let cursor = 0;
66
+ try {
67
+ const events = await fetchEvents(port, cursor);
68
+ if (events.length === 0 && !follow) {
69
+ console.log(
70
+ ` ${pc.dim("No events yet — the bus ring starts empty on each daemon start.")}\n`,
71
+ );
72
+ return;
73
+ }
74
+ for (const event of events) render(event);
75
+ cursor = events.at(-1)?.id ?? 0;
76
+ } catch (err) {
77
+ console.log(
78
+ ` ${pc.red("✖")} Could not read the event bus: ${err instanceof Error ? err.message : err}\n`,
79
+ );
80
+ return;
81
+ }
82
+
83
+ if (!follow) {
84
+ console.log();
85
+ return;
86
+ }
87
+ console.log(` ${pc.dim("Following — Ctrl+C to stop.")}`);
88
+ for (;;) {
89
+ await sleep(FOLLOW_POLL_MS);
90
+ try {
91
+ const events = await fetchEvents(port, cursor);
92
+ for (const event of events) render(event);
93
+ if (events.length > 0) cursor = events.at(-1)!.id;
94
+ } catch {
95
+ // Transient gateway hiccup (restart mid-follow) — keep polling.
96
+ }
97
+ }
98
+ }
package/src/cli/index.ts CHANGED
@@ -31,6 +31,7 @@ import { runDoctor } from "./doctor.js";
31
31
  import { startChat } from "./chat.js";
32
32
  import { daemonStart, daemonStop, daemonRestart } from "./daemon.js";
33
33
  import { showTasks, killTask } from "./tasks.js";
34
+ import { showEvents } from "./events.js";
34
35
  import { mainMenu } from "./menu.js";
35
36
 
36
37
  export * from "./context.js";
@@ -58,6 +59,7 @@ const CLI_COMMANDS = [
58
59
  "doctor",
59
60
  "ps",
60
61
  "kill",
62
+ "events",
61
63
  ];
62
64
 
63
65
  /** Route a `talon <command>` invocation. Called by the entry point. */
@@ -105,6 +107,11 @@ export async function runCli(): Promise<void> {
105
107
  case "kill":
106
108
  await killTask(process.argv[3]);
107
109
  break;
110
+ case "events":
111
+ await showEvents(
112
+ process.argv[3] === "-f" || process.argv[3] === "--follow",
113
+ );
114
+ break;
108
115
  case "--version":
109
116
  case "-v": {
110
117
  console.log(pkg.version);
@@ -124,6 +131,9 @@ export async function runCli(): Promise<void> {
124
131
  console.log(` ${pc.cyan("status")} Show bot health`);
125
132
  console.log(` ${pc.cyan("ps")} List agent tasks (task table)`);
126
133
  console.log(` ${pc.cyan("kill")} Abort a killable task by id`);
134
+ console.log(
135
+ ` ${pc.cyan("events")} Tail the event bus (-f follows)`,
136
+ );
127
137
  console.log(` ${pc.cyan("config")} View/edit configuration`);
128
138
  console.log(` ${pc.cyan("logs")} Tail log file`);
129
139
  console.log(` ${pc.cyan("doctor")} Validate environment`);
package/src/cli/tasks.ts CHANGED
@@ -8,15 +8,13 @@
8
8
  */
9
9
 
10
10
  import pc from "picocolors";
11
- import { findRunningInstance } from "../core/daemon/discovery.js";
11
+ import { fetchGateway, requireGatewayPort } from "./daemon-api.js";
12
12
  import type {
13
13
  KillOutcome,
14
14
  TaskRecord,
15
15
  TaskState,
16
16
  } from "../core/tasks/index.js";
17
17
 
18
- const REQUEST_TIMEOUT_MS = 3_000;
19
-
20
18
  function formatDuration(ms: number): string {
21
19
  const seconds = Math.floor(ms / 1000);
22
20
  if (seconds < 60) return `${seconds}s`;
@@ -107,35 +105,6 @@ function renderTable(tasks: TaskRecord[], now: number): void {
107
105
  console.log();
108
106
  }
109
107
 
110
- async function fetchGateway(
111
- port: number,
112
- path: string,
113
- init?: RequestInit,
114
- ): Promise<unknown> {
115
- const response = await fetch(`http://127.0.0.1:${port}${path}`, {
116
- ...init,
117
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
118
- });
119
- if (!response.ok) throw new Error(`gateway answered ${response.status}`);
120
- return response.json();
121
- }
122
-
123
- /** Find the daemon and return its gateway port, or render why not. */
124
- async function requireGatewayPort(): Promise<number | null> {
125
- const instance = await findRunningInstance();
126
- if (!instance) {
127
- console.log(` ${pc.red("●")} Talon is not running\n`);
128
- return null;
129
- }
130
- if (!instance.port) {
131
- console.log(
132
- ` ${pc.yellow("●")} Talon is running (PID ${instance.pid}) but its gateway port is unknown — possibly still starting.\n`,
133
- );
134
- return null;
135
- }
136
- return instance.port;
137
- }
138
-
139
108
  export async function showTasks(): Promise<void> {
140
109
  console.log();
141
110
  const port = await requireGatewayPort();
@@ -0,0 +1,114 @@
1
+ /**
2
+ * TalonBus — the daemon's internal event spine.
3
+ *
4
+ * One instance (`bus`) serves the whole process. Producers publish typed
5
+ * events (see events.ts); consumers subscribe by type. The bus is the seam
6
+ * that lets subsystems react to each other without importing each other —
7
+ * bootstrap wires dream and pulse as subscribers instead of threading
8
+ * callbacks through the Weaver, and future work (triggers as subscribers,
9
+ * companion task feeds, mesh presence) lands as more publishers/consumers.
10
+ *
11
+ * Delivery contract:
12
+ * - Synchronous, in publish order, to every matching subscriber.
13
+ * - Fire-and-forget: a subscriber that throws (or returns a rejecting
14
+ * promise) is logged and never affects the publisher or its peers.
15
+ * - Subscribers must not block: anything slow belongs behind its own
16
+ * queue or timer, not inside a handler.
17
+ *
18
+ * The bus also keeps a bounded ring of recent events with monotonic ids —
19
+ * the observability surface behind the gateway's `/events/recent` and
20
+ * `talon events`. In-memory only, like the task table: events describe
21
+ * moments in a live process, so there is nothing truthful to persist.
22
+ */
23
+
24
+ import { logError } from "../../util/log.js";
25
+ import type { PublishedEvent, TalonEvent, TalonEventType } from "./events.js";
26
+
27
+ /** Recent events kept for the tail surfaces. */
28
+ const DEFAULT_RECENT_LIMIT = 200;
29
+
30
+ type Handler<T extends TalonEventType> = (
31
+ event: Extract<PublishedEvent, { type: T }>,
32
+ ) => void | Promise<void>;
33
+
34
+ type AnyHandler = (event: PublishedEvent) => void | Promise<void>;
35
+
36
+ export class TalonBus {
37
+ private readonly byType = new Map<TalonEventType, Set<AnyHandler>>();
38
+ private readonly all = new Set<AnyHandler>();
39
+ private readonly ring: PublishedEvent[] = [];
40
+ private readonly recentLimit: number;
41
+ private nextId = 1;
42
+
43
+ constructor(recentLimit = DEFAULT_RECENT_LIMIT) {
44
+ this.recentLimit = recentLimit;
45
+ }
46
+
47
+ /** Subscribe to one event type. Returns the unsubscribe function. */
48
+ subscribe<T extends TalonEventType>(
49
+ type: T,
50
+ handler: Handler<T>,
51
+ ): () => void {
52
+ const set = this.byType.get(type) ?? new Set<AnyHandler>();
53
+ this.byType.set(type, set);
54
+ const anyHandler = handler as AnyHandler;
55
+ set.add(anyHandler);
56
+ return () => {
57
+ set.delete(anyHandler);
58
+ };
59
+ }
60
+
61
+ /** Subscribe to every event (tails, forwarders). Returns unsubscribe. */
62
+ subscribeAll(handler: AnyHandler): () => void {
63
+ this.all.add(handler);
64
+ return () => {
65
+ this.all.delete(handler);
66
+ };
67
+ }
68
+
69
+ /** Stamp and deliver an event. Never throws. */
70
+ publish(event: TalonEvent): PublishedEvent {
71
+ const published: PublishedEvent = {
72
+ ...event,
73
+ id: this.nextId++,
74
+ at: Date.now(),
75
+ };
76
+ this.ring.push(published);
77
+ if (this.ring.length > this.recentLimit) {
78
+ this.ring.splice(0, this.ring.length - this.recentLimit);
79
+ }
80
+ for (const handler of this.byType.get(event.type) ?? []) {
81
+ this.deliver(handler, published);
82
+ }
83
+ for (const handler of this.all) {
84
+ this.deliver(handler, published);
85
+ }
86
+ return published;
87
+ }
88
+
89
+ /**
90
+ * Recent events, id-ascending — all of the ring, or only those after
91
+ * `sinceId` (the tail-follow cursor).
92
+ */
93
+ recent(sinceId = 0): PublishedEvent[] {
94
+ return sinceId > 0
95
+ ? this.ring.filter((event) => event.id > sinceId)
96
+ : [...this.ring];
97
+ }
98
+
99
+ private deliver(handler: AnyHandler, event: PublishedEvent): void {
100
+ try {
101
+ const result = handler(event);
102
+ if (result instanceof Promise) {
103
+ result.catch((err: unknown) => {
104
+ logError("bus", `Subscriber for ${event.type} rejected`, err);
105
+ });
106
+ }
107
+ } catch (err) {
108
+ logError("bus", `Subscriber for ${event.type} threw`, err);
109
+ }
110
+ }
111
+ }
112
+
113
+ /** The daemon-wide bus. Tests needing isolation construct their own. */
114
+ export const bus = new TalonBus();
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Event vocabulary — every event the Talon bus carries.
3
+ *
4
+ * The union is deliberately small and honest: a type exists here only when
5
+ * something in the runtime actually publishes it. Growing the vocabulary is
6
+ * a one-line union change; each addition should land together with its
7
+ * publisher (and ideally its first subscriber).
8
+ *
9
+ * Two families exist today:
10
+ *
11
+ * - `task.*` — lifecycle of agent work, published by the task table
12
+ * (core/tasks). Uniform across kinds: a heartbeat pass, a dream run, an
13
+ * isolated cron/trigger job, and a chat turn all surface here.
14
+ * - `turn.*` — the chat-domain moments inside a turn that other
15
+ * subsystems key off: `turn.started` fires once the warp is bound and
16
+ * the backend is about to run (never for a no-model refusal);
17
+ * `turn.completed` fires only for a successfully finished turn.
18
+ */
19
+
20
+ import type { TaskRecord } from "../tasks/types.js";
21
+
22
+ /** A task left the queue and began running. */
23
+ export interface TaskStartedEvent {
24
+ readonly type: "task.started";
25
+ readonly task: TaskRecord;
26
+ }
27
+
28
+ /** A task reached a terminal state (done / failed / killed). */
29
+ export interface TaskSettledEvent {
30
+ readonly type: "task.settled";
31
+ readonly task: TaskRecord;
32
+ }
33
+
34
+ /** A chat turn bound its warp and is about to run on the backend. */
35
+ export interface TurnStartedEvent {
36
+ readonly type: "turn.started";
37
+ readonly chatId: string;
38
+ readonly source: string;
39
+ readonly model: string;
40
+ readonly backendId: string;
41
+ }
42
+
43
+ /** A chat turn finished successfully (refusals and failures never emit this). */
44
+ export interface TurnCompletedEvent {
45
+ readonly type: "turn.completed";
46
+ readonly chatId: string;
47
+ readonly source: string;
48
+ readonly durationMs: number;
49
+ readonly inputTokens: number;
50
+ readonly outputTokens: number;
51
+ }
52
+
53
+ export type TalonEvent =
54
+ TaskStartedEvent | TaskSettledEvent | TurnStartedEvent | TurnCompletedEvent;
55
+
56
+ export type TalonEventType = TalonEvent["type"];
57
+
58
+ /** What subscribers receive: the event plus its bus stamp. */
59
+ export type PublishedEvent = TalonEvent & {
60
+ /** Monotonic per-process sequence number — the tail cursor. */
61
+ readonly id: number;
62
+ /** Publish time, epoch ms. */
63
+ readonly at: number;
64
+ };
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Event bus — public surface.
3
+ *
4
+ * See bus.ts for the spine and events.ts for the vocabulary. Publishers
5
+ * today: the task table (task.*) and the Weaver (turn.*). Subscribers are
6
+ * wired at the composition root (bootstrap: dream on turn.started, pulse on
7
+ * turn.completed); read surfaces: gateway `GET /events/recent`, CLI
8
+ * `talon events`.
9
+ */
10
+
11
+ export { TalonBus, bus } from "./bus.js";
12
+ export type {
13
+ PublishedEvent,
14
+ TalonEvent,
15
+ TalonEventType,
16
+ TaskSettledEvent,
17
+ TaskStartedEvent,
18
+ TurnCompletedEvent,
19
+ TurnStartedEvent,
20
+ } from "./events.js";
@@ -27,6 +27,7 @@ import {
27
27
  HUB_PATH_PREFIX,
28
28
  } from "../mcp-hub/index.js";
29
29
  import { handlePluginAction } from "../plugin/index.js";
30
+ import { bus } from "../bus/index.js";
30
31
  import { taskTable } from "../tasks/index.js";
31
32
  import type { FrontendActionHandler } from "../types.js";
32
33
  import { BOT_MESSAGE_ACTIONS, noteBotMessage } from "../soul/taps.js";
@@ -396,6 +397,23 @@ export class Gateway {
396
397
  return;
397
398
  }
398
399
 
400
+ if (req.method === "GET" && req.url?.startsWith("/events/recent")) {
401
+ // Bus tail — recent events, optionally after a cursor. Read by
402
+ // `talon events`. Same 127.0.0.1 trust boundary as /action.
403
+ const url = new URL(req.url, "http://gateway");
404
+ const since = Number(url.searchParams.get("since") ?? "0");
405
+ res.writeHead(200, { "Content-Type": "application/json" });
406
+ res.end(
407
+ JSON.stringify({
408
+ ok: true,
409
+ events: bus.recent(
410
+ Number.isInteger(since) && since > 0 ? since : 0,
411
+ ),
412
+ }),
413
+ );
414
+ return;
415
+ }
416
+
399
417
  if (req.method === "GET" && req.url === "/tasks") {
400
418
  // The task table — every live/recent unit of agent work. Read by
401
419
  // `talon ps`. Same 127.0.0.1 trust boundary as /action.
@@ -12,6 +12,8 @@
12
12
  * work that no longer exists.
13
13
  */
14
14
 
15
+ import { bus } from "../bus/index.js";
16
+ import type { TaskSettledEvent, TaskStartedEvent } from "../bus/events.js";
15
17
  import type {
16
18
  KillOutcome,
17
19
  TaskBinding,
@@ -25,6 +27,17 @@ import type {
25
27
  /** Settled tasks kept for `list()` after they leave the live map. */
26
28
  const DEFAULT_HISTORY_LIMIT = 50;
27
29
 
30
+ export interface TaskTableOptions {
31
+ /** Settled tasks kept for `list()` (default 50). */
32
+ readonly historyLimit?: number;
33
+ /**
34
+ * Sink for `task.*` lifecycle events — the singleton wires the bus here.
35
+ * Injected (rather than imported at the emit sites) so unit-constructed
36
+ * tables stay silent.
37
+ */
38
+ readonly publish?: (event: TaskStartedEvent | TaskSettledEvent) => void;
39
+ }
40
+
28
41
  type MutableTaskRecord = {
29
42
  -readonly [K in keyof TaskRecord]: TaskRecord[K];
30
43
  };
@@ -39,10 +52,14 @@ export class TaskTable {
39
52
  private readonly live = new Map<number, LiveTask>();
40
53
  private readonly history: TaskRecord[] = [];
41
54
  private readonly historyLimit: number;
55
+ private readonly publish?: (
56
+ event: TaskStartedEvent | TaskSettledEvent,
57
+ ) => void;
42
58
  private nextId = 1;
43
59
 
44
- constructor(historyLimit = DEFAULT_HISTORY_LIMIT) {
45
- this.historyLimit = historyLimit;
60
+ constructor(options: TaskTableOptions = {}) {
61
+ this.historyLimit = options.historyLimit ?? DEFAULT_HISTORY_LIMIT;
62
+ if (options.publish) this.publish = options.publish;
46
63
  }
47
64
 
48
65
  /** Register a run that starts immediately. */
@@ -122,6 +139,7 @@ export class TaskTable {
122
139
  if (!task || task.record.state !== "queued") return;
123
140
  task.record.state = "running";
124
141
  task.record.startedAt = Date.now();
142
+ this.publish?.({ type: "task.started", task: { ...task.record } });
125
143
  }
126
144
 
127
145
  private bind(id: number, binding: TaskBinding): void {
@@ -154,8 +172,11 @@ export class TaskTable {
154
172
  if (this.history.length > this.historyLimit) {
155
173
  this.history.splice(0, this.history.length - this.historyLimit);
156
174
  }
175
+ this.publish?.({ type: "task.settled", task: { ...record } });
157
176
  }
158
177
  }
159
178
 
160
179
  /** The daemon-wide table. Tests needing isolation construct their own. */
161
- export const taskTable = new TaskTable();
180
+ export const taskTable = new TaskTable({
181
+ publish: (event) => bus.publish(event),
182
+ });
@@ -24,6 +24,7 @@ import type { AgentResult } from "../agent-runtime/events.js";
24
24
  import type { ModelRef } from "../agent-runtime/model-ref.js";
25
25
  import type { MemoryRetriever } from "../memory/retrieval.js";
26
26
  import type { ContextManager, ExecuteParams, ExecuteResult } from "../types.js";
27
+ import { bus } from "../bus/index.js";
27
28
  import { taskTable, type TaskHandle } from "../tasks/index.js";
28
29
  import { log, logDebug, logWarn } from "../../util/log.js";
29
30
  import { Loom } from "./loom.js";
@@ -51,15 +52,6 @@ export type WeaverDeps = {
51
52
  ) => Promise<ModelRef | null>;
52
53
  context: ContextManager;
53
54
  sendTyping: (chatId: number, stringId?: string) => Promise<void>;
54
- onActivity: () => void;
55
- /**
56
- * Optional fire-and-forget hook invoked at the start of every turn,
57
- * after the warp is bound and before the backend is called. The
58
- * composition root wires background work here (e.g. dream memory
59
- * consolidation) so the Weaver stays ignorant of those subsystems.
60
- * Must not throw and must not block — it is called synchronously.
61
- */
62
- onTurnStart?: () => void;
63
55
  /**
64
56
  * Optional memory pre-retrieval (Phase B). Called for `source: "message"`
65
57
  * turns after model/backend resolution and context acquisition, before
@@ -134,7 +126,7 @@ export class Weaver {
134
126
  params: ExecuteParams,
135
127
  task: TaskHandle,
136
128
  ): Promise<ExecuteResult> {
137
- const { context, onActivity, retrieveMemory } = this.deps;
129
+ const { context, retrieveMemory } = this.deps;
138
130
  const backend = this.deps.getBackend(params.chatId);
139
131
  const reqId = randomBytes(4).toString("hex");
140
132
 
@@ -180,7 +172,16 @@ export class Weaver {
180
172
  );
181
173
  }
182
174
 
183
- this.deps.onTurnStart?.();
175
+ // Turn-start signal — dream (and anything else the composition root
176
+ // subscribes) keys off this. Fires only for turns that actually reach
177
+ // the backend: the no-model refusal above returns before this point.
178
+ bus.publish({
179
+ type: "turn.started",
180
+ chatId: params.chatId,
181
+ source: params.source,
182
+ model: warp.ref.id,
183
+ backendId: warp.backendId,
184
+ });
184
185
 
185
186
  logDebug(
186
187
  "dispatcher",
@@ -221,7 +222,16 @@ export class Weaver {
221
222
  });
222
223
  const agentResult = await carryTurnEvents(stream, params.onEvent);
223
224
 
224
- onActivity();
225
+ // Completion signal — pulse (and any other liveness subscriber) keys
226
+ // off this. Failures throw past it; refusals never get this far.
227
+ bus.publish({
228
+ type: "turn.completed",
229
+ chatId: params.chatId,
230
+ source: params.source,
231
+ durationMs: agentResult?.durationMs ?? 0,
232
+ inputTokens: agentResult?.usage.inputTokens ?? 0,
233
+ outputTokens: agentResult?.usage.outputTokens ?? 0,
234
+ });
225
235
  logDebug(
226
236
  "dispatcher",
227
237
  `[${reqId}] completed in ${agentResult?.durationMs ?? 0}ms ` +
package/src/util/log.ts CHANGED
@@ -23,6 +23,7 @@ import { dirs, files } from "./paths.js";
23
23
  export type LogComponent =
24
24
  | "bot"
25
25
  | "bridge"
26
+ | "bus"
26
27
  | "db"
27
28
  | "kv"
28
29
  | "media"