talon-agent 1.49.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 (46) hide show
  1. package/README.md +2 -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 +5 -0
  14. package/src/cli/events.ts +47 -12
  15. package/src/cli/fs.ts +138 -0
  16. package/src/cli/index.ts +31 -7
  17. package/src/cli/tasks.ts +85 -20
  18. package/src/core/engine/gateway-actions/index.ts +3 -0
  19. package/src/core/engine/gateway-actions/native.ts +151 -24
  20. package/src/core/engine/gateway-actions/vfs.ts +69 -0
  21. package/src/core/engine/gateway.ts +28 -0
  22. package/src/core/tasks/table.ts +1 -1
  23. package/src/core/tasks/types.ts +6 -2
  24. package/src/core/tools/index.ts +2 -0
  25. package/src/core/tools/native.ts +19 -6
  26. package/src/core/tools/types.ts +2 -1
  27. package/src/core/tools/vfs.ts +61 -0
  28. package/src/core/vfs/index.ts +113 -0
  29. package/src/core/vfs/mounts/files.ts +154 -0
  30. package/src/core/vfs/mounts/plugins.ts +72 -0
  31. package/src/core/vfs/mounts/proc.ts +125 -0
  32. package/src/core/vfs/types.ts +103 -0
  33. package/src/core/vfs/vfs.ts +237 -0
  34. package/src/core/weaver/weaver.ts +40 -3
  35. package/src/frontend/native/discovery.ts +3 -0
  36. package/src/frontend/native/index.ts +10 -1
  37. package/src/frontend/native/server.ts +65 -6
  38. package/src/frontend/native/tls.ts +313 -0
  39. package/src/storage/journal.ts +91 -0
  40. package/src/storage/repositories/journal-repo.ts +38 -0
  41. package/src/storage/sql/journal.sql +16 -0
  42. package/src/storage/sql/schema.sql +15 -0
  43. package/src/storage/sql/statements.generated.ts +24 -1
  44. package/src/util/config.ts +9 -0
  45. package/src/util/log.ts +1 -0
  46. package/src/util/paths.ts +2 -0
package/README.md CHANGED
@@ -24,6 +24,7 @@ Multi-platform agentic AI harness. Runs on **Telegram**, **Discord**, **Microsof
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
26
  | **Event bus** | Typed internal pub-sub spine (task + turn lifecycle events); subsystems subscribe instead of importing each other; `talon events -f` |
27
+ | **VFS** | Unified `talon://` namespace over workspace, skills, scripts, logs, plus /proc-style live views of the task table, event bus, and plugin registry; `talon ls` / `talon cat` |
27
28
  | **Per-chat settings** | Model, effort level, and pulse toggle per conversation via inline keyboard |
28
29
  | **Model registry** | Models discovered from the active backend at startup — new models appear in all pickers automatically |
29
30
 
@@ -162,6 +163,7 @@ The `desktop` frontend turns the daemon into a **client bridge** — a versioned
162
163
 
163
164
  - **Local (desktop):** the app connects to a Talon on the same machine and launches one if needed (`TALON_FRONTEND_OVERRIDE=desktop`).
164
165
  - **Remote (mobile/LAN):** point the app at `host:port` + token; the bridge requires `Authorization: Bearer …` (or `?token=` on the SSE stream) whenever a token is set.
166
+ - **Encryption:** off-loopback binds serve **HTTPS by default** with a persistent self-signed certificate (`~/.talon/keys/`); the companion pins its SHA-256 fingerprint on first connect and refuses any change afterwards. The daemon logs the fingerprint at startup and `/health` advertises it. Opt out (or in, on loopback) with `"tls": false` / `true` in the `desktop` section.
165
167
 
166
168
  The app provides multi-chat history, live streaming with reasoning + tool-call visibility, per-chat model/effort/pulse/reset, and **settings sync** — read and change the daemon's own config (default model, display name, timezone, pulse/heartbeat/dream) and restart it. See [apps/companion/README.md](apps/companion/README.md).
167
169
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "talon-agent",
3
- "version": "1.49.0",
3
+ "version": "1.50.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",
@@ -10,6 +10,7 @@ import { registerBackend } from "../../core/agent-runtime/backend-registry.js";
10
10
  import type { BackendFactory } from "../../core/agent-runtime/backend-registry.js";
11
11
  import { log } from "../../util/log.js";
12
12
  import { handlerToEvents } from "../shared/handler-to-events.js";
13
+ import { interruptChatTurn } from "../shared/turn-interrupt.js";
13
14
  import {
14
15
  composeBackend,
15
16
  type ChatBackend,
@@ -46,6 +47,7 @@ const codexFactory: BackendFactory = {
46
47
  const chat: ChatBackend = {
47
48
  runChatTurn: (params) =>
48
49
  handlerToEvents((p) => codexHandleMessage(p), params),
50
+ interruptChatTurn: (chatId) => interruptChatTurn(chatId),
49
51
  };
50
52
 
51
53
  const background: BackgroundRunner = {
@@ -40,6 +40,7 @@ import {
40
40
  recordTurnMetrics,
41
41
  recordFailedTurnAccounting,
42
42
  pushLiveUsage,
43
+ registerTurnInterrupt,
43
44
  } from "../../shared/index.js";
44
45
 
45
46
  import {
@@ -322,6 +323,14 @@ export async function handleMessage(
322
323
  const codexToolMetrics = { count: 0 };
323
324
  const abortController = new AbortController();
324
325
  activeAborts.set(chatId, abortController);
326
+ // A user interrupt is a synthetic turn terminator: marking the flag
327
+ // before aborting routes the close through the same clean path a
328
+ // model-fired end_turn takes (abort swallowed, no retry, no flow
329
+ // violation), settling with the partial text and real usage.
330
+ const unregisterInterrupt = registerTurnInterrupt(chatId, () => {
331
+ streamState.turnTerminated = true;
332
+ abortController.abort();
333
+ });
325
334
 
326
335
  let usage: Usage | null = null;
327
336
  let turnFailedError: string | undefined;
@@ -539,6 +548,7 @@ export async function handleMessage(
539
548
  throw outcome.classified;
540
549
  }
541
550
  } finally {
551
+ unregisterInterrupt();
542
552
  if (activeAborts.get(chatId) === abortController) {
543
553
  activeAborts.delete(chatId);
544
554
  }
@@ -13,6 +13,7 @@ import { registerBackend } from "../../core/agent-runtime/backend-registry.js";
13
13
  import type { BackendFactory } from "../../core/agent-runtime/backend-registry.js";
14
14
  import { log } from "../../util/log.js";
15
15
  import { handlerToEvents } from "../shared/handler-to-events.js";
16
+ import { interruptChatTurn } from "../shared/turn-interrupt.js";
16
17
  import {
17
18
  composeBackend,
18
19
  type ChatBackend,
@@ -47,6 +48,7 @@ const kiloFactory: BackendFactory = {
47
48
  const chat: ChatBackend = {
48
49
  runChatTurn: (params) =>
49
50
  handlerToEvents((p) => kiloHandleMessage(p), params),
51
+ interruptChatTurn: (chatId) => interruptChatTurn(chatId),
50
52
  };
51
53
 
52
54
  const background: BackgroundRunner = {
@@ -45,6 +45,7 @@ import {
45
45
  applyRetryDecision,
46
46
  recordTurnMetrics,
47
47
  recordFailedTurnAccounting,
48
+ registerTurnInterrupt,
48
49
  } from "../../shared/index.js";
49
50
  import { activeSessions } from "./state.js";
50
51
  import { runKiloTurn } from "./turn.js";
@@ -119,6 +120,14 @@ export async function handleMessage(
119
120
  // into the live-turn overlay — /status updates while the turn runs.
120
121
  const state = createStreamState(chatId);
121
122
  state.newSessionId = sessionId;
123
+ // A user interrupt is a synthetic turn terminator: marking the flag
124
+ // before aborting the session routes the close through the same clean
125
+ // path a model-fired end_turn takes (MessageAborted swallowed as the
126
+ // expected close, no retry), settling with whatever the turn produced.
127
+ const unregisterInterrupt = registerTurnInterrupt(chatId, async () => {
128
+ state.turnTerminated = true;
129
+ await oc.session.abort({ sessionID: sessionId });
130
+ });
122
131
  const promptStartedAt = Date.now();
123
132
  const seenQuestionIds = new Set<string>();
124
133
  const seenToolCallIds = new Set<string>();
@@ -185,6 +194,7 @@ export async function handleMessage(
185
194
  logError("agent", `[${chatId}] Kilo error: ${outcome.classified.message}`);
186
195
  throw outcome.classified;
187
196
  } finally {
197
+ unregisterInterrupt();
188
198
  if (activeSessions.get(chatId) === sessionId) {
189
199
  activeSessions.delete(chatId);
190
200
  }
@@ -13,6 +13,7 @@ import { registerBackend } from "../../core/agent-runtime/backend-registry.js";
13
13
  import type { BackendFactory } from "../../core/agent-runtime/backend-registry.js";
14
14
  import { log } from "../../util/log.js";
15
15
  import { handlerToEvents } from "../shared/handler-to-events.js";
16
+ import { interruptChatTurn } from "../shared/turn-interrupt.js";
16
17
  import {
17
18
  composeBackend,
18
19
  type ChatBackend,
@@ -46,6 +47,7 @@ const openAIAgentsFactory: BackendFactory = {
46
47
  const chat: ChatBackend = {
47
48
  runChatTurn: (params) =>
48
49
  handlerToEvents((p) => openAIAgentsHandleMessage(p), params),
50
+ interruptChatTurn: (chatId) => interruptChatTurn(chatId),
49
51
  };
50
52
 
51
53
  const defaultIdSync = (): string | null => {
@@ -38,6 +38,7 @@ import {
38
38
  recordTurnMetrics,
39
39
  recordFailedTurnAccounting,
40
40
  recordFlowViolation,
41
+ registerTurnInterrupt,
41
42
  } from "../../shared/index.js";
42
43
  import {
43
44
  detectFlowViolation,
@@ -182,6 +183,14 @@ export async function handleMessage(
182
183
  const seenToolCallIds = new Set<string>();
183
184
  const abortController = new AbortController();
184
185
  activeAborts.set(chatId, abortController);
186
+ // A user interrupt is a synthetic turn terminator: marking the flag
187
+ // before aborting routes the close through the same clean path a
188
+ // model-fired end_turn takes (abort swallowed, no retry, no flow
189
+ // violation), settling with the partial text and real usage.
190
+ const unregisterInterrupt = registerTurnInterrupt(chatId, () => {
191
+ streamState.turnTerminated = true;
192
+ abortController.abort();
193
+ });
185
194
 
186
195
  const setupMs = Date.now() - t0;
187
196
  let turnMs = 0;
@@ -385,6 +394,7 @@ export async function handleMessage(
385
394
  throw classified;
386
395
  }
387
396
  } finally {
397
+ unregisterInterrupt();
388
398
  if (activeAborts.get(chatId) === abortController) {
389
399
  activeAborts.delete(chatId);
390
400
  }
@@ -13,6 +13,7 @@ import { registerBackend } from "../../core/agent-runtime/backend-registry.js";
13
13
  import type { BackendFactory } from "../../core/agent-runtime/backend-registry.js";
14
14
  import { log } from "../../util/log.js";
15
15
  import { handlerToEvents } from "../shared/handler-to-events.js";
16
+ import { interruptChatTurn } from "../shared/turn-interrupt.js";
16
17
  import {
17
18
  composeBackend,
18
19
  type ChatBackend,
@@ -47,6 +48,7 @@ const opencodeFactory: BackendFactory = {
47
48
  const chat: ChatBackend = {
48
49
  runChatTurn: (params) =>
49
50
  handlerToEvents((p) => ocHandleMessage(p), params),
51
+ interruptChatTurn: (chatId) => interruptChatTurn(chatId),
50
52
  };
51
53
 
52
54
  const background: BackgroundRunner = {
@@ -47,6 +47,7 @@ import {
47
47
  applyRetryDecision,
48
48
  recordTurnMetrics,
49
49
  recordFailedTurnAccounting,
50
+ registerTurnInterrupt,
50
51
  } from "../../shared/index.js";
51
52
  import { activeSessions } from "./state.js";
52
53
  import { runOpenCodeTurn } from "./turn.js";
@@ -122,6 +123,14 @@ export async function handleMessage(
122
123
  // into the live-turn overlay — /status updates while the turn runs.
123
124
  const state = createStreamState(chatId);
124
125
  state.newSessionId = sessionId;
126
+ // A user interrupt is a synthetic turn terminator: marking the flag
127
+ // before aborting the session routes the close through the same clean
128
+ // path a model-fired end_turn takes (MessageAborted swallowed as the
129
+ // expected close, no retry), settling with whatever the turn produced.
130
+ const unregisterInterrupt = registerTurnInterrupt(chatId, async () => {
131
+ state.turnTerminated = true;
132
+ await oc.session.abort({ sessionID: sessionId });
133
+ });
125
134
  const promptStartedAt = Date.now();
126
135
  const seenQuestionIds = new Set<string>();
127
136
  const seenToolCallIds = new Set<string>();
@@ -191,6 +200,7 @@ export async function handleMessage(
191
200
  );
192
201
  throw outcome.classified;
193
202
  } finally {
203
+ unregisterInterrupt();
194
204
  if (activeSessions.get(chatId) === sessionId) {
195
205
  activeSessions.delete(chatId);
196
206
  }
@@ -21,6 +21,8 @@
21
21
  * (assembly itself lives in `core/prompt/`).
22
22
  * - `model-retry` — session-expiry / context-overflow / fallback decisions.
23
23
  * - `stream-state` — backend-agnostic accumulator for stream loops.
24
+ * - `turn-interrupt` — user-driven mid-turn interrupt registry (the
25
+ * shared `ChatBackend.interruptChatTurn` for callback backends).
24
26
  *
25
27
  * What's NOT here (intentionally):
26
28
  * - SDK-specific event types — those live in each backend.
@@ -43,6 +45,8 @@ export {
43
45
  type FlowViolationResult,
44
46
  } from "./flow-violation.js";
45
47
 
48
+ export { registerTurnInterrupt, interruptChatTurn } from "./turn-interrupt.js";
49
+
46
50
  export {
47
51
  formatUserPrompt,
48
52
  formatPromptWithRetrievedMemory,
@@ -0,0 +1,61 @@
1
+ /**
2
+ * User-driven mid-turn interrupts, shared by the callback backends
3
+ * (Codex, OpenAI Agents, Kilo, OpenCode). The Claude SDK backend has a
4
+ * native `Query.interrupt()` and doesn't come through here.
5
+ *
6
+ * A handler registers how to stop its in-flight turn when the turn
7
+ * starts and unregisters when it settles; `interruptChatTurn` — the
8
+ * shared implementation of `ChatBackend.interruptChatTurn` — invokes it.
9
+ *
10
+ * The registered closure must make the turn end the way a model-fired
11
+ * terminator does: mark `state.turnTerminated` FIRST, then fire the
12
+ * native abort. Every backend's close-out logic already keys off that
13
+ * flag — abort errors are swallowed as the expected close, wrap-up
14
+ * round-trips are skipped, and the flow-violation retry (which would
15
+ * otherwise resurrect a killed turn as a "missed delivery") stands
16
+ * down. The stream then settles as a normal completion carrying the
17
+ * partial text and real usage — never as an error, never as a retry.
18
+ */
19
+
20
+ import { log, logWarn } from "../../util/log.js";
21
+ import { incrementCounter } from "../../util/metrics.js";
22
+
23
+ const interrupts = new Map<string, () => void | Promise<void>>();
24
+
25
+ /**
26
+ * Register the abort path for a chat's in-flight turn. Returns the
27
+ * unregister function; it only removes its own registration, so a retry
28
+ * that re-registered for the same chat is never torn down by the
29
+ * attempt that spawned it.
30
+ */
31
+ export function registerTurnInterrupt(
32
+ chatId: string,
33
+ interrupt: () => void | Promise<void>,
34
+ ): () => void {
35
+ interrupts.set(chatId, interrupt);
36
+ return () => {
37
+ if (interrupts.get(chatId) === interrupt) interrupts.delete(chatId);
38
+ };
39
+ }
40
+
41
+ /**
42
+ * Best-effort interrupt of the chat's in-flight turn. Resolves `true`
43
+ * when a running turn was found and signalled, `false` otherwise —
44
+ * the `ChatBackend.interruptChatTurn` contract.
45
+ */
46
+ export async function interruptChatTurn(chatId: string): Promise<boolean> {
47
+ const interrupt = interrupts.get(chatId);
48
+ if (!interrupt) return false;
49
+ try {
50
+ await interrupt();
51
+ log("agent", `[${chatId}] turn interrupted by user`);
52
+ incrementCounter("turn_interrupted");
53
+ return true;
54
+ } catch (err) {
55
+ logWarn(
56
+ "agent",
57
+ `[${chatId}] interrupt failed: ${err instanceof Error ? err.message : err}`,
58
+ );
59
+ return false;
60
+ }
61
+ }
package/src/bootstrap.ts CHANGED
@@ -23,6 +23,7 @@ import {
23
23
  execute as dispatcherExecute,
24
24
  } from "./core/engine/dispatcher.js";
25
25
  import { bus } from "./core/bus/index.js";
26
+ import { appendToJournal } from "./storage/journal.js";
26
27
  import { initPulse, resetPulseTimer } from "./core/background/pulse.js";
27
28
  import { initCron } from "./core/background/cron.js";
28
29
  import {
@@ -418,6 +419,10 @@ export async function initBackendAndDispatcher(
418
419
  // completed turn resets the pulse idle timer.
419
420
  bus.subscribe("turn.started", () => maybeStartDream());
420
421
  bus.subscribe("turn.completed", () => resetPulseTimer());
422
+ // The journal is the bus's durable tail: every published event lands in
423
+ // talon.db so history survives restarts (`talon events --history`,
424
+ // `talon ps --all`). Append failures are logged and swallowed inside.
425
+ bus.subscribeAll((event) => appendToJournal(event));
421
426
 
422
427
  initPulse();
423
428
  initCron({
package/src/cli/events.ts CHANGED
@@ -2,13 +2,15 @@
2
2
  * `talon events` — tail the daemon's event bus.
3
3
  *
4
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/.
5
+ * polling with a since-cursor for a live tail; `--history [N]` reads the
6
+ * durable journal in talon.db instead (works across restarts, daemon up
7
+ * or down). Rendering only — the bus lives in core/bus/, the journal in
8
+ * storage/journal.ts.
7
9
  */
8
10
 
9
11
  import pc from "picocolors";
10
12
  import { fetchGateway, requireGatewayPort } from "./daemon-api.js";
11
- import type { PublishedEvent } from "../core/bus/index.js";
13
+ import type { PublishedEvent, TalonEvent } from "../core/bus/index.js";
12
14
 
13
15
  const FOLLOW_POLL_MS = 1_000;
14
16
 
@@ -17,7 +19,7 @@ function timestamp(at: number): string {
17
19
  }
18
20
 
19
21
  /** One tail line of type-specific detail, content-free like the events. */
20
- function describe(event: PublishedEvent): string {
22
+ function describe(event: TalonEvent): string {
21
23
  switch (event.type) {
22
24
  case "task.started":
23
25
  return (
@@ -36,12 +38,37 @@ function describe(event: PublishedEvent): string {
36
38
  }
37
39
  }
38
40
 
39
- function render(event: PublishedEvent): void {
41
+ function render(event: TalonEvent, at: number): void {
40
42
  console.log(
41
- ` ${pc.dim(timestamp(event.at))} ${pc.cyan(event.type.padEnd(14))} ${describe(event)}`,
43
+ ` ${pc.dim(timestamp(at))} ${pc.cyan(event.type.padEnd(14))} ${describe(event)}`,
42
44
  );
43
45
  }
44
46
 
47
+ /**
48
+ * The journal path: read the durable tail from talon.db directly — no
49
+ * daemon required, and it answers across restarts (unlike the ring).
50
+ */
51
+ async function showHistory(limit: number): Promise<void> {
52
+ const { readJournal } = await import("../storage/journal.js");
53
+ let entries;
54
+ try {
55
+ entries = readJournal({ limit });
56
+ } catch (err) {
57
+ console.log(
58
+ ` ${pc.red("✖")} Could not read the journal: ${err instanceof Error ? err.message : err}\n`,
59
+ );
60
+ process.exitCode = 1;
61
+ return;
62
+ }
63
+ if (entries.length === 0) {
64
+ console.log(` ${pc.dim("Journal is empty — no events recorded yet.")}\n`);
65
+ return;
66
+ }
67
+ // readJournal returns newest-first; a tail reads oldest-to-newest.
68
+ for (const entry of entries.reverse()) render(entry.event, entry.at);
69
+ console.log();
70
+ }
71
+
45
72
  async function fetchEvents(
46
73
  port: number,
47
74
  sinceId: number,
@@ -57,21 +84,29 @@ function sleep(ms: number): Promise<void> {
57
84
  return new Promise((resolve) => setTimeout(resolve, ms));
58
85
  }
59
86
 
60
- export async function showEvents(follow: boolean): Promise<void> {
87
+ export async function showEvents(options: {
88
+ follow: boolean;
89
+ history?: number;
90
+ }): Promise<void> {
61
91
  console.log();
92
+ if (options.history !== undefined) {
93
+ await showHistory(options.history);
94
+ return;
95
+ }
96
+
62
97
  const port = await requireGatewayPort();
63
98
  if (port === null) return;
64
99
 
65
100
  let cursor = 0;
66
101
  try {
67
102
  const events = await fetchEvents(port, cursor);
68
- if (events.length === 0 && !follow) {
103
+ if (events.length === 0 && !options.follow) {
69
104
  console.log(
70
- ` ${pc.dim("No events yet — the bus ring starts empty on each daemon start.")}\n`,
105
+ ` ${pc.dim("No events yet — the ring starts empty on each daemon start. Older events: talon events --history")}\n`,
71
106
  );
72
107
  return;
73
108
  }
74
- for (const event of events) render(event);
109
+ for (const event of events) render(event, event.at);
75
110
  cursor = events.at(-1)?.id ?? 0;
76
111
  } catch (err) {
77
112
  console.log(
@@ -80,7 +115,7 @@ export async function showEvents(follow: boolean): Promise<void> {
80
115
  return;
81
116
  }
82
117
 
83
- if (!follow) {
118
+ if (!options.follow) {
84
119
  console.log();
85
120
  return;
86
121
  }
@@ -89,7 +124,7 @@ export async function showEvents(follow: boolean): Promise<void> {
89
124
  await sleep(FOLLOW_POLL_MS);
90
125
  try {
91
126
  const events = await fetchEvents(port, cursor);
92
- for (const event of events) render(event);
127
+ for (const event of events) render(event, event.at);
93
128
  if (events.length > 0) cursor = events.at(-1)!.id;
94
129
  } catch {
95
130
  // Transient gateway hiccup (restart mid-follow) — keep polling.
package/src/cli/fs.ts ADDED
@@ -0,0 +1,138 @@
1
+ /**
2
+ * `talon ls` / `talon cat` — the talon:// namespace from the outside.
3
+ *
4
+ * A running daemon answers through the gateway (`GET /vfs/list|read`) so
5
+ * the synthetic mounts (proc, plugins) show live state. With no daemon,
6
+ * the same namespace is served in-process — file mounts work identically,
7
+ * the synthetic mounts are simply empty — so browsing the workspace never
8
+ * requires Talon to be up.
9
+ */
10
+
11
+ import pc from "picocolors";
12
+ import { findRunningInstance } from "../core/daemon/discovery.js";
13
+ import type { VfsErrorCode, VfsResult, VfsStat } from "../core/vfs/index.js";
14
+ import { fetchGateway } from "./daemon-api.js";
15
+
16
+ /**
17
+ * Ask the daemon first (live proc/plugins state), fall back to serving the
18
+ * namespace in-process. `live` records which one answered.
19
+ */
20
+ async function query(
21
+ op: "list" | "read",
22
+ path: string,
23
+ ): Promise<{ result: VfsResult<VfsStat[] | string>; live: boolean }> {
24
+ const instance = await findRunningInstance();
25
+ if (instance?.port) {
26
+ const body = (await fetchGateway(
27
+ instance.port,
28
+ `/vfs/${op}?path=${encodeURIComponent(path)}`,
29
+ )) as {
30
+ ok: boolean;
31
+ entries?: VfsStat[];
32
+ content?: string;
33
+ error?: string;
34
+ detail?: string;
35
+ };
36
+ const result: VfsResult<VfsStat[] | string> = body.ok
37
+ ? { ok: true, value: op === "list" ? body.entries! : body.content! }
38
+ : {
39
+ ok: false,
40
+ error: (body.error ?? "io-error") as VfsErrorCode,
41
+ ...(body.detail !== undefined ? { detail: body.detail } : {}),
42
+ };
43
+ return { result, live: true };
44
+ }
45
+
46
+ const { getVfs } = await import("../core/vfs/index.js");
47
+ const vfs = getVfs();
48
+ return {
49
+ result: op === "list" ? vfs.list(path) : vfs.read(path),
50
+ live: false,
51
+ };
52
+ }
53
+
54
+ function printError(
55
+ path: string,
56
+ result: { error: string; detail?: string },
57
+ ): void {
58
+ console.error(
59
+ ` ${pc.red("✖")} talon://${path}: ${result.error}${result.detail ? ` — ${result.detail}` : ""}`,
60
+ );
61
+ process.exitCode = 1;
62
+ }
63
+
64
+ function formatModified(entry: VfsStat): string {
65
+ if (entry.modifiedAt === undefined) return "-";
66
+ return new Date(entry.modifiedAt)
67
+ .toISOString()
68
+ .slice(0, 16)
69
+ .replace("T", " ");
70
+ }
71
+
72
+ export async function showLs(rawPath: string | undefined): Promise<void> {
73
+ const path = (rawPath ?? "").trim();
74
+ console.log();
75
+ const { result, live } = await query("list", path);
76
+ if (!result.ok) {
77
+ printError(path, result);
78
+ return;
79
+ }
80
+ const entries = result.value as VfsStat[];
81
+
82
+ if (!live) {
83
+ console.log(
84
+ ` ${pc.dim("Talon is not running — file mounts only; proc/ and plugins/ are empty.")}`,
85
+ );
86
+ }
87
+ if (entries.length === 0) {
88
+ console.log(` ${pc.dim("(empty)")}\n`);
89
+ return;
90
+ }
91
+
92
+ const header = ["KIND", "SIZE", "MODIFIED", "NAME"];
93
+ const cells = entries.map((entry) => [
94
+ entry.kind === "dir" ? "dir" : "file",
95
+ entry.kind === "dir" ? "-" : String(entry.size ?? "?"),
96
+ formatModified(entry),
97
+ entry.kind === "dir" ? `${entry.name}/` : entry.name,
98
+ ]);
99
+ const widths = header.map((h, col) =>
100
+ Math.max(h.length, ...cells.map((row) => row[col]!.length)),
101
+ );
102
+ console.log(
103
+ ` ${pc.dim(header.map((h, col) => h.padEnd(widths[col]!)).join(" "))}`,
104
+ );
105
+ cells.forEach((row, i) => {
106
+ const padded = row.map((cell, col) => cell.padEnd(widths[col]!));
107
+ const name = entries[i]!.kind === "dir" ? pc.cyan(padded[3]!) : padded[3]!;
108
+ // Root entries carry the mount's disk location — show the mapping, it's
109
+ // what shell commands (which can't read talon://) need.
110
+ const entry = entries[i]!;
111
+ const mapping =
112
+ entry.osPath !== undefined && !entry.path.includes("/")
113
+ ? pc.dim(` → ${entry.osPath}`)
114
+ : "";
115
+ console.log(
116
+ ` ${padded[0]} ${padded[1]} ${padded[2]} ${name}${mapping}`,
117
+ );
118
+ });
119
+ console.log();
120
+ }
121
+
122
+ export async function showCat(rawPath: string | undefined): Promise<void> {
123
+ if (!rawPath || !rawPath.trim()) {
124
+ console.error(
125
+ ` ${pc.red("✖")} Usage: ${pc.cyan("talon cat <path>")} — e.g. ${pc.cyan("talon cat proc/events")}`,
126
+ );
127
+ process.exitCode = 1;
128
+ return;
129
+ }
130
+ const path = rawPath.trim();
131
+ const { result } = await query("read", path);
132
+ if (!result.ok) {
133
+ printError(path, result);
134
+ return;
135
+ }
136
+ // Raw content, no decoration — `talon cat x | jq` must work.
137
+ process.stdout.write(result.value as string);
138
+ }
package/src/cli/index.ts CHANGED
@@ -32,6 +32,7 @@ import { startChat } from "./chat.js";
32
32
  import { daemonStart, daemonStop, daemonRestart } from "./daemon.js";
33
33
  import { showTasks, killTask } from "./tasks.js";
34
34
  import { showEvents } from "./events.js";
35
+ import { showLs, showCat } from "./fs.js";
35
36
  import { mainMenu } from "./menu.js";
36
37
 
37
38
  export * from "./context.js";
@@ -60,6 +61,8 @@ const CLI_COMMANDS = [
60
61
  "ps",
61
62
  "kill",
62
63
  "events",
64
+ "ls",
65
+ "cat",
63
66
  ];
64
67
 
65
68
  /** Route a `talon <command>` invocation. Called by the entry point. */
@@ -102,15 +105,28 @@ export async function runCli(): Promise<void> {
102
105
  runDoctor();
103
106
  break;
104
107
  case "ps":
105
- await showTasks();
108
+ await showTasks(process.argv[3] === "--all" || process.argv[3] === "-a");
106
109
  break;
107
110
  case "kill":
108
111
  await killTask(process.argv[3]);
109
112
  break;
110
- case "events":
111
- await showEvents(
112
- process.argv[3] === "-f" || process.argv[3] === "--follow",
113
- );
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]);
114
130
  break;
115
131
  case "--version":
116
132
  case "-v": {
@@ -129,10 +145,18 @@ export async function runCli(): Promise<void> {
129
145
  console.log(` ${pc.cyan("run")} Run in foreground (attached)`);
130
146
  console.log(` ${pc.cyan("chat")} Terminal chat mode`);
131
147
  console.log(` ${pc.cyan("status")} Show bot health`);
132
- 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
+ );
133
151
  console.log(` ${pc.cyan("kill")} Abort a killable task by id`);
134
152
  console.log(
135
- ` ${pc.cyan("events")} Tail the event bus (-f follows)`,
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)`,
136
160
  );
137
161
  console.log(` ${pc.cyan("config")} View/edit configuration`);
138
162
  console.log(` ${pc.cyan("logs")} Tail log file`);