talon-agent 1.47.2 → 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
@@ -22,6 +22,8 @@ Multi-platform agentic AI harness. Runs on **Telegram**, **Discord**, **Microsof
22
22
  | **Goals** | Persistent multi-day objectives the agent commits to in chat; every heartbeat run re-reads them, makes progress, and records what it did |
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
+ | **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` |
25
27
  | **Per-chat settings** | Model, effort level, and pulse toggle per conversation via inline keyboard |
26
28
  | **Model registry** | Models discovered from the active backend at startup — new models appear in all pickers automatically |
27
29
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "talon-agent",
3
- "version": "1.47.2",
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
@@ -30,6 +30,8 @@ import { tailLogs } from "./logs.js";
30
30
  import { runDoctor } from "./doctor.js";
31
31
  import { startChat } from "./chat.js";
32
32
  import { daemonStart, daemonStop, daemonRestart } from "./daemon.js";
33
+ import { showTasks, killTask } from "./tasks.js";
34
+ import { showEvents } from "./events.js";
33
35
  import { mainMenu } from "./menu.js";
34
36
 
35
37
  export * from "./context.js";
@@ -55,6 +57,9 @@ const CLI_COMMANDS = [
55
57
  "run",
56
58
  "chat",
57
59
  "doctor",
60
+ "ps",
61
+ "kill",
62
+ "events",
58
63
  ];
59
64
 
60
65
  /** Route a `talon <command>` invocation. Called by the entry point. */
@@ -96,6 +101,17 @@ export async function runCli(): Promise<void> {
96
101
  case "doctor":
97
102
  runDoctor();
98
103
  break;
104
+ case "ps":
105
+ await showTasks();
106
+ break;
107
+ case "kill":
108
+ await killTask(process.argv[3]);
109
+ break;
110
+ case "events":
111
+ await showEvents(
112
+ process.argv[3] === "-f" || process.argv[3] === "--follow",
113
+ );
114
+ break;
99
115
  case "--version":
100
116
  case "-v": {
101
117
  console.log(pkg.version);
@@ -113,6 +129,11 @@ export async function runCli(): Promise<void> {
113
129
  console.log(` ${pc.cyan("run")} Run in foreground (attached)`);
114
130
  console.log(` ${pc.cyan("chat")} Terminal chat mode`);
115
131
  console.log(` ${pc.cyan("status")} Show bot health`);
132
+ console.log(` ${pc.cyan("ps")} List agent tasks (task table)`);
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
+ );
116
137
  console.log(` ${pc.cyan("config")} View/edit configuration`);
117
138
  console.log(` ${pc.cyan("logs")} Tail log file`);
118
139
  console.log(` ${pc.cyan("doctor")} Validate environment`);
@@ -0,0 +1,181 @@
1
+ /**
2
+ * `talon ps` / `talon kill` — the task table from the outside.
3
+ *
4
+ * Data comes from the running daemon's gateway (`GET /tasks`,
5
+ * `POST /tasks/kill`); discovery finds the instance the same way
6
+ * `talon status` does. This module only renders — the table itself
7
+ * lives in core/tasks/.
8
+ */
9
+
10
+ import pc from "picocolors";
11
+ import { fetchGateway, requireGatewayPort } from "./daemon-api.js";
12
+ import type {
13
+ KillOutcome,
14
+ TaskRecord,
15
+ TaskState,
16
+ } from "../core/tasks/index.js";
17
+
18
+ function formatDuration(ms: number): string {
19
+ const seconds = Math.floor(ms / 1000);
20
+ if (seconds < 60) return `${seconds}s`;
21
+ if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
22
+ return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`;
23
+ }
24
+
25
+ function formatTokenCount(n: number): string {
26
+ if (n < 1000) return String(n);
27
+ if (n < 1_000_000) return `${(n / 1000).toFixed(1)}k`;
28
+ return `${(n / 1_000_000).toFixed(1)}M`;
29
+ }
30
+
31
+ function formatTokens(task: TaskRecord): string {
32
+ if (!task.usage) return "—";
33
+ return `${formatTokenCount(task.usage.inputTokens)}→${formatTokenCount(task.usage.outputTokens)}`;
34
+ }
35
+
36
+ /** Queued: wait so far. Running: runtime so far. Settled: total runtime. */
37
+ function formatTime(task: TaskRecord, now: number): string {
38
+ if (task.state === "queued") return formatDuration(now - task.queuedAt);
39
+ const start = task.startedAt ?? task.queuedAt;
40
+ return formatDuration((task.endedAt ?? now) - start);
41
+ }
42
+
43
+ function colorState(state: TaskState): string {
44
+ switch (state) {
45
+ case "running":
46
+ return pc.green(state);
47
+ case "queued":
48
+ return pc.yellow(state);
49
+ case "done":
50
+ return pc.dim(state);
51
+ case "failed":
52
+ return pc.red(state);
53
+ case "killed":
54
+ return pc.magenta(state);
55
+ }
56
+ }
57
+
58
+ /** Live tasks first (oldest running at the top), then history newest-first. */
59
+ function displayOrder(tasks: TaskRecord[]): TaskRecord[] {
60
+ const live = tasks.filter(
61
+ (t) => t.state === "running" || t.state === "queued",
62
+ );
63
+ const settled = tasks.filter(
64
+ (t) => t.state !== "running" && t.state !== "queued",
65
+ );
66
+ live.sort((a, b) => a.id - b.id);
67
+ settled.sort((a, b) => b.id - a.id);
68
+ return [...live, ...settled];
69
+ }
70
+
71
+ type Row = readonly string[];
72
+
73
+ const HEADER: Row = ["ID", "STATE", "KIND", "TIME", "TOKENS", "LABEL", "CHAT"];
74
+
75
+ function toRow(task: TaskRecord, now: number): Row {
76
+ return [
77
+ String(task.id),
78
+ task.state,
79
+ task.kind,
80
+ formatTime(task, now),
81
+ formatTokens(task),
82
+ task.label,
83
+ task.chatId ?? "—",
84
+ ];
85
+ }
86
+
87
+ /**
88
+ * Pad every column to its widest cell. Colors are applied after padding
89
+ * (the state cell) so ANSI escapes never skew the width math.
90
+ */
91
+ function renderTable(tasks: TaskRecord[], now: number): void {
92
+ const rows = tasks.map((t) => toRow(t, now));
93
+ const widths = HEADER.map((h, col) =>
94
+ Math.max(h.length, ...rows.map((r) => r[col]!.length)),
95
+ );
96
+ const pad = (row: Row) => row.map((cell, col) => cell.padEnd(widths[col]!));
97
+
98
+ console.log(` ${pc.dim(pad(HEADER).join(" "))}`);
99
+ tasks.forEach((task, i) => {
100
+ const cells = pad(rows[i]!);
101
+ cells[1] =
102
+ colorState(task.state) + " ".repeat(widths[1]! - task.state.length);
103
+ console.log(` ${cells.join(" ")}`);
104
+ });
105
+ console.log();
106
+ }
107
+
108
+ export async function showTasks(): Promise<void> {
109
+ console.log();
110
+ const port = await requireGatewayPort();
111
+ if (port === null) return;
112
+
113
+ let tasks: TaskRecord[];
114
+ try {
115
+ const body = (await fetchGateway(port, "/tasks")) as {
116
+ tasks?: TaskRecord[];
117
+ };
118
+ tasks = body.tasks ?? [];
119
+ } catch (err) {
120
+ console.log(
121
+ ` ${pc.red("✖")} Could not read the task table: ${err instanceof Error ? err.message : err}\n`,
122
+ );
123
+ return;
124
+ }
125
+
126
+ if (tasks.length === 0) {
127
+ console.log(
128
+ ` ${pc.dim("No tasks — the table starts empty on each daemon start.")}\n`,
129
+ );
130
+ return;
131
+ }
132
+ renderTable(displayOrder(tasks), Date.now());
133
+ }
134
+
135
+ export async function killTask(rawId: string | undefined): Promise<void> {
136
+ console.log();
137
+ const id = Number(rawId);
138
+ if (rawId === undefined || !Number.isInteger(id)) {
139
+ console.log(
140
+ ` ${pc.red("✖")} Usage: ${pc.cyan("talon kill <id>")} — ids come from ${pc.cyan("talon ps")}\n`,
141
+ );
142
+ return;
143
+ }
144
+
145
+ const port = await requireGatewayPort();
146
+ if (port === null) return;
147
+
148
+ let outcome: KillOutcome;
149
+ try {
150
+ outcome = (await fetchGateway(port, "/tasks/kill", {
151
+ method: "POST",
152
+ headers: { "Content-Type": "application/json" },
153
+ body: JSON.stringify({ id }),
154
+ })) as KillOutcome;
155
+ } catch (err) {
156
+ console.log(
157
+ ` ${pc.red("✖")} Kill request failed: ${err instanceof Error ? err.message : err}\n`,
158
+ );
159
+ return;
160
+ }
161
+
162
+ if (outcome.ok) {
163
+ console.log(
164
+ ` ${pc.green("●")} Task ${id} aborted — it settles as ${pc.magenta("killed")} once the backend honours the abort.\n`,
165
+ );
166
+ return;
167
+ }
168
+ switch (outcome.reason) {
169
+ case "not-found":
170
+ console.log(` ${pc.red("✖")} No task ${id} in the table.\n`);
171
+ return;
172
+ case "finished":
173
+ console.log(` ${pc.dim("●")} Task ${id} already finished.\n`);
174
+ return;
175
+ case "not-killable":
176
+ console.log(
177
+ ` ${pc.yellow("!")} Task ${id} has no abort hook (chat turns run to completion) — it cannot be killed.\n`,
178
+ );
179
+ return;
180
+ }
181
+ }
@@ -22,6 +22,7 @@ import { getDefaultModel } from "../models/catalog.js";
22
22
  import type { OneShotAgentParams } from "../types.js";
23
23
  import type { Backend } from "../agent-runtime/capabilities.js";
24
24
  import { getSoul } from "../soul/service.js";
25
+ import { taskTable } from "../tasks/index.js";
25
26
  import { FailureBackoff } from "./failure-backoff.js";
26
27
 
27
28
  // ── Types ────────────────────────────────────────────────────────────────────
@@ -259,6 +260,13 @@ If commands fail, log the error and continue — this stage is optional.`
259
260
 
260
261
  const abortController = new AbortController();
261
262
 
263
+ const task = taskTable.begin({
264
+ kind: "dream",
265
+ label: "consolidation",
266
+ abort: () => abortController.abort(),
267
+ });
268
+ task.bind({ model });
269
+
262
270
  const oneShotParams: OneShotAgentParams = {
263
271
  prompt,
264
272
  systemPrompt,
@@ -298,6 +306,7 @@ If commands fail, log the error and continue — this stage is optional.`
298
306
  try {
299
307
  await Promise.race([agentPromise, timeoutPromise]);
300
308
  } catch (err) {
309
+ task.fail(err);
301
310
  appendDreamLog(
302
311
  dreamLogFile,
303
312
  `\n---\n**Dream FAILED at ${new Date().toISOString()}:** ${err}\n`,
@@ -323,6 +332,7 @@ If commands fail, log the error and continue — this stage is optional.`
323
332
  if (timeoutHandle) clearTimeout(timeoutHandle);
324
333
  }
325
334
 
335
+ task.succeed();
326
336
  return dreamLogFile;
327
337
  }
328
338
 
@@ -12,6 +12,7 @@ import { toYMD } from "../../../util/time.js";
12
12
  import { getDefaultModel } from "../../models/catalog.js";
13
13
  import { loadSystemTemplate } from "../../prompt/templates.js";
14
14
  import { formatGoal, getOpenGoals } from "../../../storage/goal-store.js";
15
+ import { taskTable } from "../../tasks/index.js";
15
16
  import type { OneShotAgentParams } from "../../types.js";
16
17
  import { hb } from "./state.js";
17
18
 
@@ -184,6 +185,13 @@ export async function runHeartbeatAgent(
184
185
  // heartbeatAbortGraceMs below.
185
186
  const abortController = new AbortController();
186
187
 
188
+ const task = taskTable.begin({
189
+ kind: "heartbeat",
190
+ label: `#${runCount}`,
191
+ abort: () => abortController.abort(),
192
+ });
193
+ task.bind({ model });
194
+
187
195
  const oneShotParams: OneShotAgentParams = {
188
196
  prompt,
189
197
  systemPrompt: buildHeartbeatSystemPrompt(),
@@ -231,6 +239,7 @@ export async function runHeartbeatAgent(
231
239
  clearTimeout(timeoutHandle);
232
240
  timeoutHandle = null;
233
241
  }
242
+ task.fail(err);
234
243
  await appendHeartbeatLog(
235
244
  heartbeatLogFile,
236
245
  `\n---\n**Heartbeat #${runCount} FAILED at ${new Date().toISOString()}:** ${err}\n`,
@@ -269,6 +278,7 @@ export async function runHeartbeatAgent(
269
278
  if (timeoutHandle) clearTimeout(timeoutHandle);
270
279
  }
271
280
 
281
+ task.succeed();
272
282
  return heartbeatLogFile;
273
283
  }
274
284
 
@@ -20,6 +20,7 @@ import {
20
20
  acquireBackendInstance,
21
21
  isModelValidForBackend,
22
22
  } from "../engine/backend-controller/index.js";
23
+ import { taskTable } from "../tasks/index.js";
23
24
  import type { OneShotAgentParams } from "../types.js";
24
25
  import { runIsolatedAgent } from "./isolated-agent.js";
25
26
  import {
@@ -132,6 +133,15 @@ export async function runJobOneShot(
132
133
  params.model,
133
134
  );
134
135
 
136
+ const abortController = new AbortController();
137
+ const task = taskTable.begin({
138
+ kind: params.kind,
139
+ label: params.label,
140
+ chatId: params.chatId,
141
+ abort: () => abortController.abort(),
142
+ });
143
+ task.bind({ model: params.model, backendId: params.backendId });
144
+
135
145
  const oneShot: OneShotAgentParams = {
136
146
  prompt: params.payload,
137
147
  systemPrompt: buildJobSystemPrompt(
@@ -142,18 +152,24 @@ export async function runJobOneShot(
142
152
  contextLabel: JOB_CONTEXT_LABEL,
143
153
  workspace: dirs.workspace,
144
154
  model: params.model,
145
- abortController: new AbortController(),
155
+ abortController,
146
156
  appendLog,
147
157
  };
148
158
 
149
- await runIsolatedAgent({
150
- background,
151
- params: oneShot,
152
- timeoutMs: params.timeoutMs ?? DEFAULT_JOB_TIMEOUT_MS,
153
- // No evictLabel: the job context label is shared with heartbeat, so a
154
- // sweep here could kill a concurrent heartbeat's subprocess. Bounded
155
- // abort-grace is enough.
156
- });
159
+ try {
160
+ await runIsolatedAgent({
161
+ background,
162
+ params: oneShot,
163
+ timeoutMs: params.timeoutMs ?? DEFAULT_JOB_TIMEOUT_MS,
164
+ // No evictLabel: the job context label is shared with heartbeat, so a
165
+ // sweep here could kill a concurrent heartbeat's subprocess. Bounded
166
+ // abort-grace is enough.
167
+ });
168
+ task.succeed();
169
+ } catch (err) {
170
+ task.fail(err);
171
+ throw err;
172
+ }
157
173
  log(
158
174
  params.kind === "cron" ? "cron" : "triggers",
159
175
  `isolated job "${params.label}" ran on ${params.backendId}/${params.model}`,
@@ -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,8 @@ 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";
31
+ import { taskTable } from "../tasks/index.js";
30
32
  import type { FrontendActionHandler } from "../types.js";
31
33
  import { BOT_MESSAGE_ACTIONS, noteBotMessage } from "../soul/taps.js";
32
34
  import type { Backend } from "../agent-runtime/capabilities.js";
@@ -395,6 +397,56 @@ export class Gateway {
395
397
  return;
396
398
  }
397
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
+
417
+ if (req.method === "GET" && req.url === "/tasks") {
418
+ // The task table — every live/recent unit of agent work. Read by
419
+ // `talon ps`. Same 127.0.0.1 trust boundary as /action.
420
+ res.writeHead(200, { "Content-Type": "application/json" });
421
+ res.end(JSON.stringify({ ok: true, tasks: taskTable.list() }));
422
+ return;
423
+ }
424
+
425
+ if (req.method === "POST" && req.url === "/tasks/kill") {
426
+ // Abort one killable task by id — the transport for `talon kill`.
427
+ const chunks: Buffer[] = [];
428
+ for await (const chunk of req) chunks.push(chunk as Buffer);
429
+ let id: unknown;
430
+ try {
431
+ const body = JSON.parse(Buffer.concat(chunks).toString("utf-8"));
432
+ id = (body as { id?: unknown }).id;
433
+ } catch {
434
+ res.writeHead(400, { "Content-Type": "application/json" });
435
+ res.end(JSON.stringify({ ok: false, error: "Invalid JSON" }));
436
+ return;
437
+ }
438
+ if (typeof id !== "number" || !Number.isInteger(id)) {
439
+ res.writeHead(400, { "Content-Type": "application/json" });
440
+ res.end(
441
+ JSON.stringify({ ok: false, error: "id must be an integer" }),
442
+ );
443
+ return;
444
+ }
445
+ res.writeHead(200, { "Content-Type": "application/json" });
446
+ res.end(JSON.stringify(taskTable.kill(id)));
447
+ return;
448
+ }
449
+
398
450
  if (req.url?.startsWith(HUB_PATH_PREFIX)) {
399
451
  // MCP hub — daemon-hosted MCP-over-HTTP endpoints for every
400
452
  // backend (see core/mcp-hub). Same 127.0.0.1 trust boundary
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Task table — public surface.
3
+ *
4
+ * See table.ts for the registry and types.ts for the vocabulary. Wiring
5
+ * points: weaver (turns), heartbeat/agent, dream, background/job-oneshot
6
+ * (isolated cron/trigger jobs); read surfaces: gateway `GET /tasks` +
7
+ * `POST /tasks/kill`, CLI `talon ps` / `talon kill`.
8
+ */
9
+
10
+ export { TaskTable, taskTable } from "./table.js";
11
+ export type {
12
+ KillOutcome,
13
+ TaskBinding,
14
+ TaskHandle,
15
+ TaskKind,
16
+ TaskRecord,
17
+ TaskSpec,
18
+ TaskState,
19
+ TaskUsage,
20
+ } from "./types.js";
@@ -0,0 +1,182 @@
1
+ /**
2
+ * TaskTable — live registry plus bounded history of agent-work runs.
3
+ *
4
+ * One instance (`taskTable`) serves the whole daemon. Call sites register a
5
+ * run with `begin`/`enqueue` and settle it through the returned TaskHandle;
6
+ * the gateway serves `list()` over HTTP and routes `talon kill` to `kill()`.
7
+ *
8
+ * The table is observational: it never schedules, retries, or times out a
9
+ * run — those disciplines stay with the owning module (weaver, heartbeat,
10
+ * dream, job-oneshot). In-memory only by design: a task is a live run, and
11
+ * a daemon restart ends every run, so persisted rows could only describe
12
+ * work that no longer exists.
13
+ */
14
+
15
+ import { bus } from "../bus/index.js";
16
+ import type { TaskSettledEvent, TaskStartedEvent } from "../bus/events.js";
17
+ import type {
18
+ KillOutcome,
19
+ TaskBinding,
20
+ TaskHandle,
21
+ TaskRecord,
22
+ TaskSpec,
23
+ TaskState,
24
+ TaskUsage,
25
+ } from "./types.js";
26
+
27
+ /** Settled tasks kept for `list()` after they leave the live map. */
28
+ const DEFAULT_HISTORY_LIMIT = 50;
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
+
41
+ type MutableTaskRecord = {
42
+ -readonly [K in keyof TaskRecord]: TaskRecord[K];
43
+ };
44
+
45
+ interface LiveTask {
46
+ readonly record: MutableTaskRecord;
47
+ readonly abort?: () => void;
48
+ killRequested: boolean;
49
+ }
50
+
51
+ export class TaskTable {
52
+ private readonly live = new Map<number, LiveTask>();
53
+ private readonly history: TaskRecord[] = [];
54
+ private readonly historyLimit: number;
55
+ private readonly publish?: (
56
+ event: TaskStartedEvent | TaskSettledEvent,
57
+ ) => void;
58
+ private nextId = 1;
59
+
60
+ constructor(options: TaskTableOptions = {}) {
61
+ this.historyLimit = options.historyLimit ?? DEFAULT_HISTORY_LIMIT;
62
+ if (options.publish) this.publish = options.publish;
63
+ }
64
+
65
+ /** Register a run that starts immediately. */
66
+ begin(spec: TaskSpec): TaskHandle {
67
+ const handle = this.enqueue(spec);
68
+ handle.start();
69
+ return handle;
70
+ }
71
+
72
+ /** Register a run that is waiting its turn (e.g. a chat's FIFO queue). */
73
+ enqueue(spec: TaskSpec): TaskHandle {
74
+ const id = this.nextId++;
75
+ const record: MutableTaskRecord = {
76
+ id,
77
+ kind: spec.kind,
78
+ label: spec.label,
79
+ state: "queued",
80
+ killable: spec.abort !== undefined,
81
+ queuedAt: Date.now(),
82
+ };
83
+ if (spec.chatId !== undefined) record.chatId = spec.chatId;
84
+ const task: LiveTask = {
85
+ record,
86
+ killRequested: false,
87
+ ...(spec.abort !== undefined ? { abort: spec.abort } : {}),
88
+ };
89
+ this.live.set(id, task);
90
+ return {
91
+ id,
92
+ start: () => this.start(id),
93
+ bind: (binding) => this.bind(id, binding),
94
+ succeed: (usage) => this.settle(id, "done", undefined, usage),
95
+ fail: (error) => this.settle(id, "failed", error),
96
+ };
97
+ }
98
+
99
+ /**
100
+ * Request an abort of a live killable task. Returns immediately — the
101
+ * task stays `running` until its owner's failure path lands, at which
102
+ * point it settles as `killed`. Repeat kills are no-ops that still
103
+ * report `ok` (the request stands); the abort hook fires only once.
104
+ */
105
+ kill(id: number): KillOutcome {
106
+ const task = this.live.get(id);
107
+ if (!task) {
108
+ return this.history.some((record) => record.id === id)
109
+ ? { ok: false, reason: "finished" }
110
+ : { ok: false, reason: "not-found" };
111
+ }
112
+ if (!task.abort) return { ok: false, reason: "not-killable" };
113
+ if (!task.killRequested) {
114
+ task.killRequested = true;
115
+ try {
116
+ task.abort();
117
+ } catch {
118
+ // The abort hook contract is "must not throw" — a violation must
119
+ // not break the kill path for the caller.
120
+ }
121
+ }
122
+ return { ok: true };
123
+ }
124
+
125
+ /**
126
+ * Every live task plus the bounded settled history, id-ascending.
127
+ * Snapshots are copies — callers can never mutate table state.
128
+ */
129
+ list(): TaskRecord[] {
130
+ const records: TaskRecord[] = this.history.map((record) => ({
131
+ ...record,
132
+ }));
133
+ for (const task of this.live.values()) records.push({ ...task.record });
134
+ return records.sort((a, b) => a.id - b.id);
135
+ }
136
+
137
+ private start(id: number): void {
138
+ const task = this.live.get(id);
139
+ if (!task || task.record.state !== "queued") return;
140
+ task.record.state = "running";
141
+ task.record.startedAt = Date.now();
142
+ this.publish?.({ type: "task.started", task: { ...task.record } });
143
+ }
144
+
145
+ private bind(id: number, binding: TaskBinding): void {
146
+ const task = this.live.get(id);
147
+ if (!task) return;
148
+ if (binding.model !== undefined) task.record.model = binding.model;
149
+ if (binding.backendId !== undefined)
150
+ task.record.backendId = binding.backendId;
151
+ }
152
+
153
+ private settle(
154
+ id: number,
155
+ state: Extract<TaskState, "done" | "failed">,
156
+ error?: unknown,
157
+ usage?: TaskUsage,
158
+ ): void {
159
+ const task = this.live.get(id);
160
+ if (!task) return;
161
+ const { record } = task;
162
+ // A kill only "takes" when the run actually dies: a run that completes
163
+ // despite the abort settles as done, one that unwinds settles as killed.
164
+ record.state = state === "failed" && task.killRequested ? "killed" : state;
165
+ record.endedAt = Date.now();
166
+ if (error !== undefined) {
167
+ record.error = error instanceof Error ? error.message : String(error);
168
+ }
169
+ if (usage !== undefined) record.usage = usage;
170
+ this.live.delete(id);
171
+ this.history.push(record);
172
+ if (this.history.length > this.historyLimit) {
173
+ this.history.splice(0, this.history.length - this.historyLimit);
174
+ }
175
+ this.publish?.({ type: "task.settled", task: { ...record } });
176
+ }
177
+ }
178
+
179
+ /** The daemon-wide table. Tests needing isolation construct their own. */
180
+ export const taskTable = new TaskTable({
181
+ publish: (event) => bus.publish(event),
182
+ });
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Task table vocabulary — the daemon's registry of agent work.
3
+ *
4
+ * A task is one bounded run of agent work: a chat turn, a heartbeat pass, a
5
+ * dream consolidation, or an isolated cron/trigger job. The table gives every
6
+ * such run an id, a lifecycle, an owner chat, and — where the run can be
7
+ * aborted — a kill switch. It is the process-table analogue for the agent
8
+ * runtime, with tokens (not CPU) as the accounted resource.
9
+ *
10
+ * Deliberately NOT tasks: trigger watcher scripts (long-lived OS processes
11
+ * owned by the trigger store, which already tracks their pid/status), cron
12
+ * "message" jobs (a single send, no agent run), and the pulse ticker.
13
+ */
14
+
15
+ /** Which unit of agent work a task represents. */
16
+ export type TaskKind = "turn" | "heartbeat" | "dream" | "cron" | "trigger";
17
+
18
+ /** Task lifecycle. `done`, `failed`, and `killed` are terminal. */
19
+ export type TaskState = "queued" | "running" | "done" | "failed" | "killed";
20
+
21
+ /**
22
+ * Token spend for the run, captured at settlement when the runner reports
23
+ * it. Chat turns report usage today; isolated one-shots run through
24
+ * `runOneShotAgent`, which returns no usage, so their tasks settle without.
25
+ */
26
+ export interface TaskUsage {
27
+ readonly inputTokens: number;
28
+ readonly outputTokens: number;
29
+ readonly cacheRead: number;
30
+ readonly cacheWrite: number;
31
+ }
32
+
33
+ /** What a call site declares when it registers a run with the table. */
34
+ export interface TaskSpec {
35
+ readonly kind: TaskKind;
36
+ /**
37
+ * Short human label shown in `talon ps`: the turn source ("message",
38
+ * "trigger", …), the cron job or trigger name, or the heartbeat run
39
+ * number. Never message content — the listing must not leak chat text.
40
+ */
41
+ readonly label: string;
42
+ /** Chat the work belongs to, when it belongs to one. */
43
+ readonly chatId?: string;
44
+ /**
45
+ * Abort the run mid-flight (typically `AbortController.abort`). Present ⇒
46
+ * the task is killable via `TaskTable.kill`. Must not throw; the table
47
+ * swallows a throwing abort so a broken hook cannot break the kill path.
48
+ */
49
+ readonly abort?: () => void;
50
+ }
51
+
52
+ /** The model/backend a task actually resolved to, once known. */
53
+ export interface TaskBinding {
54
+ readonly model?: string;
55
+ readonly backendId?: string;
56
+ }
57
+
58
+ /** Immutable snapshot of one task, as returned by `TaskTable.list()`. */
59
+ export interface TaskRecord {
60
+ readonly id: number;
61
+ readonly kind: TaskKind;
62
+ readonly label: string;
63
+ readonly chatId?: string;
64
+ readonly state: TaskState;
65
+ readonly killable: boolean;
66
+ /** When the task entered the table (equal to `startedAt` unless queued). */
67
+ readonly queuedAt: number;
68
+ readonly startedAt?: number;
69
+ readonly endedAt?: number;
70
+ readonly model?: string;
71
+ readonly backendId?: string;
72
+ /** Failure detail. Also set for killed tasks (the abort's error). */
73
+ readonly error?: string;
74
+ readonly usage?: TaskUsage;
75
+ }
76
+
77
+ /**
78
+ * Mutator handed to the owning call site. Every transition is idempotent:
79
+ * once a task settles, later calls (including a duplicate settle) no-op, so
80
+ * error paths never need to guard against double settlement.
81
+ */
82
+ export interface TaskHandle {
83
+ readonly id: number;
84
+ /** Move a queued task to running. No-op unless queued. */
85
+ start(): void;
86
+ /** Record the model/backend the run resolved to. No-op once settled. */
87
+ bind(binding: TaskBinding): void;
88
+ /** Settle as `done`, optionally recording token usage. */
89
+ succeed(usage?: TaskUsage): void;
90
+ /** Settle as `failed` — or `killed` when a kill was requested first. */
91
+ fail(error: unknown): void;
92
+ }
93
+
94
+ /** Result of `TaskTable.kill`. */
95
+ export type KillOutcome =
96
+ | { readonly ok: true }
97
+ | {
98
+ readonly ok: false;
99
+ readonly reason: "not-found" | "finished" | "not-killable";
100
+ };
@@ -24,6 +24,8 @@ 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";
28
+ import { taskTable, type TaskHandle } from "../tasks/index.js";
27
29
  import { log, logDebug, logWarn } from "../../util/log.js";
28
30
  import { Loom } from "./loom.js";
29
31
  import { prefetchMemory } from "./memory-prefetch.js";
@@ -50,15 +52,6 @@ export type WeaverDeps = {
50
52
  ) => Promise<ModelRef | null>;
51
53
  context: ContextManager;
52
54
  sendTyping: (chatId: number, stringId?: string) => Promise<void>;
53
- onActivity: () => void;
54
- /**
55
- * Optional fire-and-forget hook invoked at the start of every turn,
56
- * after the warp is bound and before the backend is called. The
57
- * composition root wires background work here (e.g. dream memory
58
- * consolidation) so the Weaver stays ignorant of those subsystems.
59
- * Must not throw and must not block — it is called synchronously.
60
- */
61
- onTurnStart?: () => void;
62
55
  /**
63
56
  * Optional memory pre-retrieval (Phase B). Called for `source: "message"`
64
57
  * turns after model/backend resolution and context acquisition, before
@@ -80,7 +73,14 @@ export class Weaver {
80
73
 
81
74
  runTurn(params: ExecuteParams): Promise<ExecuteResult> {
82
75
  const thread = this.loom.thread(params.chatId);
83
- return thread.enqueue(() => this.run(thread, params));
76
+ // Registered before enqueueing so a turn waiting in its chat's FIFO is
77
+ // visible as `queued` in the task table, not invisible until it runs.
78
+ const task = taskTable.enqueue({
79
+ kind: "turn",
80
+ label: params.source,
81
+ chatId: params.chatId,
82
+ });
83
+ return thread.enqueue(() => this.run(thread, params, task));
84
84
  }
85
85
 
86
86
  /** Number of turns currently running (not queued) across all chats. */
@@ -100,10 +100,22 @@ export class Weaver {
100
100
  private async run(
101
101
  thread: Thread,
102
102
  params: ExecuteParams,
103
+ task: TaskHandle,
103
104
  ): Promise<ExecuteResult> {
104
105
  this.activeCount++;
106
+ task.start();
105
107
  try {
106
- return await this.executeInner(thread, params);
108
+ const result = await this.executeInner(thread, params, task);
109
+ task.succeed({
110
+ inputTokens: result.inputTokens,
111
+ outputTokens: result.outputTokens,
112
+ cacheRead: result.cacheRead,
113
+ cacheWrite: result.cacheWrite,
114
+ });
115
+ return result;
116
+ } catch (err) {
117
+ task.fail(err);
118
+ throw err;
107
119
  } finally {
108
120
  this.activeCount--;
109
121
  }
@@ -112,8 +124,9 @@ export class Weaver {
112
124
  private async executeInner(
113
125
  thread: Thread,
114
126
  params: ExecuteParams,
127
+ task: TaskHandle,
115
128
  ): Promise<ExecuteResult> {
116
- const { context, onActivity, retrieveMemory } = this.deps;
129
+ const { context, retrieveMemory } = this.deps;
117
130
  const backend = this.deps.getBackend(params.chatId);
118
131
  const reqId = randomBytes(4).toString("hex");
119
132
 
@@ -151,6 +164,7 @@ export class Weaver {
151
164
  overridden: warp.overridden,
152
165
  boundAt: Date.now(),
153
166
  });
167
+ task.bind({ model: warp.ref.id, backendId: warp.backendId });
154
168
  if (drifted && previous) {
155
169
  logDebug(
156
170
  "dispatcher",
@@ -158,7 +172,16 @@ export class Weaver {
158
172
  );
159
173
  }
160
174
 
161
- 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
+ });
162
185
 
163
186
  logDebug(
164
187
  "dispatcher",
@@ -199,7 +222,16 @@ export class Weaver {
199
222
  });
200
223
  const agentResult = await carryTurnEvents(stream, params.onEvent);
201
224
 
202
- 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
+ });
203
235
  logDebug(
204
236
  "dispatcher",
205
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"