talon-agent 1.48.0 → 1.50.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.
Files changed (50) hide show
  1. package/README.md +3 -0
  2. package/package.json +1 -1
  3. package/src/backend/codex/factory.ts +2 -0
  4. package/src/backend/codex/handler/message.ts +10 -0
  5. package/src/backend/kilo/factory.ts +2 -0
  6. package/src/backend/kilo/handler/message.ts +10 -0
  7. package/src/backend/openai-agents/factory.ts +2 -0
  8. package/src/backend/openai-agents/handler/message.ts +10 -0
  9. package/src/backend/opencode/factory.ts +2 -0
  10. package/src/backend/opencode/handler/message.ts +10 -0
  11. package/src/backend/shared/index.ts +4 -0
  12. package/src/backend/shared/turn-interrupt.ts +61 -0
  13. package/src/bootstrap.ts +12 -5
  14. package/src/cli/daemon-api.ts +39 -0
  15. package/src/cli/events.ts +133 -0
  16. package/src/cli/fs.ts +138 -0
  17. package/src/cli/index.ts +36 -2
  18. package/src/cli/tasks.ts +82 -48
  19. package/src/core/bus/bus.ts +114 -0
  20. package/src/core/bus/events.ts +64 -0
  21. package/src/core/bus/index.ts +20 -0
  22. package/src/core/engine/gateway-actions/index.ts +3 -0
  23. package/src/core/engine/gateway-actions/native.ts +151 -24
  24. package/src/core/engine/gateway-actions/vfs.ts +69 -0
  25. package/src/core/engine/gateway.ts +46 -0
  26. package/src/core/tasks/table.ts +25 -4
  27. package/src/core/tasks/types.ts +6 -2
  28. package/src/core/tools/index.ts +2 -0
  29. package/src/core/tools/native.ts +19 -6
  30. package/src/core/tools/types.ts +2 -1
  31. package/src/core/tools/vfs.ts +61 -0
  32. package/src/core/vfs/index.ts +113 -0
  33. package/src/core/vfs/mounts/files.ts +154 -0
  34. package/src/core/vfs/mounts/plugins.ts +72 -0
  35. package/src/core/vfs/mounts/proc.ts +125 -0
  36. package/src/core/vfs/types.ts +103 -0
  37. package/src/core/vfs/vfs.ts +237 -0
  38. package/src/core/weaver/weaver.ts +62 -15
  39. package/src/frontend/native/discovery.ts +3 -0
  40. package/src/frontend/native/index.ts +10 -1
  41. package/src/frontend/native/server.ts +65 -6
  42. package/src/frontend/native/tls.ts +313 -0
  43. package/src/storage/journal.ts +91 -0
  44. package/src/storage/repositories/journal-repo.ts +38 -0
  45. package/src/storage/sql/journal.sql +16 -0
  46. package/src/storage/sql/schema.sql +15 -0
  47. package/src/storage/sql/statements.generated.ts +24 -1
  48. package/src/util/config.ts +9 -0
  49. package/src/util/log.ts +2 -0
  50. package/src/util/paths.ts +2 -0
package/src/cli/index.ts CHANGED
@@ -31,6 +31,8 @@ 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";
35
+ import { showLs, showCat } from "./fs.js";
34
36
  import { mainMenu } from "./menu.js";
35
37
 
36
38
  export * from "./context.js";
@@ -58,6 +60,9 @@ const CLI_COMMANDS = [
58
60
  "doctor",
59
61
  "ps",
60
62
  "kill",
63
+ "events",
64
+ "ls",
65
+ "cat",
61
66
  ];
62
67
 
63
68
  /** Route a `talon <command>` invocation. Called by the entry point. */
@@ -100,11 +105,29 @@ export async function runCli(): Promise<void> {
100
105
  runDoctor();
101
106
  break;
102
107
  case "ps":
103
- await showTasks();
108
+ await showTasks(process.argv[3] === "--all" || process.argv[3] === "-a");
104
109
  break;
105
110
  case "kill":
106
111
  await killTask(process.argv[3]);
107
112
  break;
113
+ case "events": {
114
+ const args = process.argv.slice(3);
115
+ const historyAt = args.findIndex((a) => a === "--history");
116
+ const next = historyAt >= 0 ? Number(args[historyAt + 1]) : NaN;
117
+ await showEvents({
118
+ follow: args.includes("-f") || args.includes("--follow"),
119
+ ...(historyAt >= 0
120
+ ? { history: Number.isInteger(next) && next > 0 ? next : 100 }
121
+ : {}),
122
+ });
123
+ break;
124
+ }
125
+ case "ls":
126
+ await showLs(process.argv[3]);
127
+ break;
128
+ case "cat":
129
+ await showCat(process.argv[3]);
130
+ break;
108
131
  case "--version":
109
132
  case "-v": {
110
133
  console.log(pkg.version);
@@ -122,8 +145,19 @@ export async function runCli(): Promise<void> {
122
145
  console.log(` ${pc.cyan("run")} Run in foreground (attached)`);
123
146
  console.log(` ${pc.cyan("chat")} Terminal chat mode`);
124
147
  console.log(` ${pc.cyan("status")} Show bot health`);
125
- console.log(` ${pc.cyan("ps")} List agent tasks (task table)`);
148
+ console.log(
149
+ ` ${pc.cyan("ps")} List agent tasks (--all includes journal history)`,
150
+ );
126
151
  console.log(` ${pc.cyan("kill")} Abort a killable task by id`);
152
+ console.log(
153
+ ` ${pc.cyan("events")} Tail the event bus (-f follows, --history [N] reads the journal)`,
154
+ );
155
+ console.log(
156
+ ` ${pc.cyan("ls")} List the talon:// namespace (ls proc/tasks)`,
157
+ );
158
+ console.log(
159
+ ` ${pc.cyan("cat")} Read a talon:// file (cat proc/events)`,
160
+ );
127
161
  console.log(` ${pc.cyan("config")} View/edit configuration`);
128
162
  console.log(` ${pc.cyan("logs")} Tail log file`);
129
163
  console.log(` ${pc.cyan("doctor")} Validate environment`);
package/src/cli/tasks.ts CHANGED
@@ -2,21 +2,21 @@
2
2
  * `talon ps` / `talon kill` — the task table from the outside.
3
3
  *
4
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 rendersthe table itself
7
- * lives in core/tasks/.
5
+ * `POST /tasks/kill`); `--all` also folds in settled runs from the
6
+ * durable journal in talon.db, which answers across restartswith or
7
+ * without a daemon. This module only renders — the table lives in
8
+ * core/tasks/, the journal in storage/journal.ts.
8
9
  */
9
10
 
10
11
  import pc from "picocolors";
11
12
  import { findRunningInstance } from "../core/daemon/discovery.js";
13
+ import { fetchGateway, requireGatewayPort } from "./daemon-api.js";
12
14
  import type {
13
15
  KillOutcome,
14
16
  TaskRecord,
15
17
  TaskState,
16
18
  } from "../core/tasks/index.js";
17
19
 
18
- const REQUEST_TIMEOUT_MS = 3_000;
19
-
20
20
  function formatDuration(ms: number): string {
21
21
  const seconds = Math.floor(ms / 1000);
22
22
  if (seconds < 60) return `${seconds}s`;
@@ -66,7 +66,12 @@ function displayOrder(tasks: TaskRecord[]): TaskRecord[] {
66
66
  (t) => t.state !== "running" && t.state !== "queued",
67
67
  );
68
68
  live.sort((a, b) => a.id - b.id);
69
- settled.sort((a, b) => b.id - a.id);
69
+ // By time, not id: journal history spans daemon restarts, and per-process
70
+ // ids restart with the daemon.
71
+ settled.sort(
72
+ (a, b) =>
73
+ (b.endedAt ?? b.queuedAt) - (a.endedAt ?? a.queuedAt) || b.id - a.id,
74
+ );
70
75
  return [...live, ...settled];
71
76
  }
72
77
 
@@ -107,56 +112,85 @@ function renderTable(tasks: TaskRecord[], now: number): void {
107
112
  console.log();
108
113
  }
109
114
 
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;
115
+ /** Settled runs from the journal, minus any already in the live table. */
116
+ async function journalTasks(
117
+ liveTasks: readonly TaskRecord[],
118
+ limit: number,
119
+ ): Promise<TaskRecord[]> {
120
+ const { readJournal } = await import("../storage/journal.js");
121
+ const seen = new Set(liveTasks.map((t) => `${t.id}:${t.queuedAt}`));
122
+ const historical: TaskRecord[] = [];
123
+ for (const entry of readJournal({ type: "task.settled", limit })) {
124
+ if (entry.event.type !== "task.settled") continue;
125
+ const task = entry.event.task;
126
+ // (id, queuedAt) identifies a run across surfaces — per-process ids
127
+ // repeat between daemon runs, enqueue times don't.
128
+ if (seen.has(`${task.id}:${task.queuedAt}`)) continue;
129
+ seen.add(`${task.id}:${task.queuedAt}`);
130
+ historical.push(task);
129
131
  }
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;
132
+ return historical;
137
133
  }
138
134
 
139
- export async function showTasks(): Promise<void> {
135
+ export async function showTasks(all = false): Promise<void> {
140
136
  console.log();
141
- const port = await requireGatewayPort();
142
- if (port === null) return;
143
137
 
144
- let tasks: TaskRecord[];
145
- try {
146
- const body = (await fetchGateway(port, "/tasks")) as {
147
- tasks?: TaskRecord[];
148
- };
149
- tasks = body.tasks ?? [];
150
- } catch (err) {
151
- console.log(
152
- ` ${pc.red("✖")} Could not read the task table: ${err instanceof Error ? err.message : err}\n`,
153
- );
154
- return;
138
+ let tasks: TaskRecord[] = [];
139
+ let daemonUp = true;
140
+ if (all) {
141
+ // History works without a daemon — fold in the live table only when
142
+ // one is actually running.
143
+ const instance = await findRunningInstance();
144
+ if (instance?.port) {
145
+ try {
146
+ const body = (await fetchGateway(instance.port, "/tasks")) as {
147
+ tasks?: TaskRecord[];
148
+ };
149
+ tasks = body.tasks ?? [];
150
+ } catch {
151
+ daemonUp = false;
152
+ }
153
+ } else {
154
+ daemonUp = false;
155
+ }
156
+ } else {
157
+ const port = await requireGatewayPort();
158
+ if (port === null) return;
159
+ try {
160
+ const body = (await fetchGateway(port, "/tasks")) as {
161
+ tasks?: TaskRecord[];
162
+ };
163
+ tasks = body.tasks ?? [];
164
+ } catch (err) {
165
+ console.log(
166
+ ` ${pc.red("✖")} Could not read the task table: ${err instanceof Error ? err.message : err}\n`,
167
+ );
168
+ return;
169
+ }
170
+ }
171
+
172
+ if (all) {
173
+ if (!daemonUp) {
174
+ console.log(
175
+ ` ${pc.dim("Talon is not running — journal history only.")}`,
176
+ );
177
+ }
178
+ try {
179
+ tasks = [...tasks, ...(await journalTasks(tasks, 200))];
180
+ } catch (err) {
181
+ console.log(
182
+ ` ${pc.red("✖")} Could not read the journal: ${err instanceof Error ? err.message : err}\n`,
183
+ );
184
+ process.exitCode = 1;
185
+ return;
186
+ }
155
187
  }
156
188
 
157
189
  if (tasks.length === 0) {
158
190
  console.log(
159
- ` ${pc.dim("No tasks — the table starts empty on each daemon start.")}\n`,
191
+ all
192
+ ? ` ${pc.dim("No tasks — nothing live and the journal is empty.")}\n`
193
+ : ` ${pc.dim("No tasks — the table starts empty on each daemon start. Older runs: talon ps --all")}\n`,
160
194
  );
161
195
  return;
162
196
  }
@@ -205,7 +239,7 @@ export async function killTask(rawId: string | undefined): Promise<void> {
205
239
  return;
206
240
  case "not-killable":
207
241
  console.log(
208
- ` ${pc.yellow("!")} Task ${id} has no abort hook (chat turns run to completion) it cannot be killed.\n`,
242
+ ` ${pc.yellow("!")} Task ${id} has no abort hook its backend can't interrupt a running turn.\n`,
209
243
  );
210
244
  return;
211
245
  }
@@ -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";
@@ -17,6 +17,7 @@
17
17
  * - `plugins` — plugin hot-reload
18
18
  * - `models` — model / backend discovery
19
19
  * - `mesh` — companion device mesh (presence + location)
20
+ * - `vfs` — the unified talon:// namespace (list/read/write)
20
21
  */
21
22
 
22
23
  import type { ActionResult } from "../../types.js";
@@ -33,6 +34,7 @@ import { pluginHandlers } from "./plugins.js";
33
34
  import { modelHandlers } from "./models.js";
34
35
  import { meshHandlers } from "./mesh.js";
35
36
  import { nativeHandlers } from "./native.js";
37
+ import { vfsHandlers } from "./vfs.js";
36
38
 
37
39
  // Null-prototype so a request `action` of "toString" / "constructor" / etc.
38
40
  // can't resolve an inherited Object.prototype method — `handlers[action]` only
@@ -49,6 +51,7 @@ const handlers: SharedActionHandlers = Object.assign(Object.create(null), {
49
51
  ...modelHandlers,
50
52
  ...meshHandlers,
51
53
  ...nativeHandlers,
54
+ ...vfsHandlers,
52
55
  });
53
56
 
54
57
  export async function handleSharedAction(