talon-agent 1.49.0 → 1.51.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 (55) hide show
  1. package/README.md +30 -2
  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/daemon-api.ts +2 -1
  15. package/src/cli/events.ts +47 -12
  16. package/src/cli/fs.ts +138 -0
  17. package/src/cli/index.ts +47 -7
  18. package/src/cli/install-sources.ts +106 -0
  19. package/src/cli/plugin-entries.ts +104 -0
  20. package/src/cli/plugin.ts +494 -0
  21. package/src/cli/skill.ts +252 -0
  22. package/src/cli/tasks.ts +85 -20
  23. package/src/core/engine/gateway-actions/index.ts +3 -0
  24. package/src/core/engine/gateway-actions/native.ts +151 -24
  25. package/src/core/engine/gateway-actions/plugins.ts +36 -19
  26. package/src/core/engine/gateway-actions/vfs.ts +69 -0
  27. package/src/core/engine/gateway.ts +52 -0
  28. package/src/core/plugin/loader.ts +14 -2
  29. package/src/core/plugin/types.ts +4 -0
  30. package/src/core/tasks/table.ts +1 -1
  31. package/src/core/tasks/types.ts +6 -2
  32. package/src/core/tools/index.ts +2 -0
  33. package/src/core/tools/native.ts +19 -6
  34. package/src/core/tools/types.ts +2 -1
  35. package/src/core/tools/vfs.ts +61 -0
  36. package/src/core/vfs/index.ts +113 -0
  37. package/src/core/vfs/mounts/files.ts +154 -0
  38. package/src/core/vfs/mounts/plugins.ts +72 -0
  39. package/src/core/vfs/mounts/proc.ts +125 -0
  40. package/src/core/vfs/types.ts +103 -0
  41. package/src/core/vfs/vfs.ts +237 -0
  42. package/src/core/weaver/weaver.ts +40 -3
  43. package/src/frontend/native/discovery.ts +3 -0
  44. package/src/frontend/native/index.ts +10 -1
  45. package/src/frontend/native/server.ts +65 -6
  46. package/src/frontend/native/tls.ts +313 -0
  47. package/src/storage/journal.ts +91 -0
  48. package/src/storage/repositories/journal-repo.ts +38 -0
  49. package/src/storage/skill-store.ts +118 -5
  50. package/src/storage/sql/journal.sql +16 -0
  51. package/src/storage/sql/schema.sql +15 -0
  52. package/src/storage/sql/statements.generated.ts +24 -1
  53. package/src/util/config.ts +16 -0
  54. package/src/util/log.ts +1 -0
  55. package/src/util/paths.ts +5 -0
package/README.md CHANGED
@@ -17,13 +17,14 @@ Multi-platform agentic AI harness. Runs on **Telegram**, **Discord**, **Microsof
17
17
  | **Multi-frontend** | Telegram (Grammy + GramJS userbot), Discord (discord.js), Microsoft Teams (Bot Framework), Terminal with live tool visibility, and a **Desktop/Mobile app** (Flutter) over a local/remote bridge |
18
18
  | **Pluggable backend** | Claude Agent SDK, Kilo, OpenCode, Codex, OpenAI Agents — selectable per-process via `backend` config. Streaming, model fallback, context-overflow recovery. |
19
19
  | **MCP tools** | Messaging, media, history, search, web fetch, cron jobs, triggers, goals, stickers, file system, admin controls |
20
- | **Plugins** | Hot-reloadable plugin system. Built-in: GitHub, MemPalace, Playwright, Brave Search |
20
+ | **Plugins** | Hot-reloadable plugin system with `talon plugin install/enable/disable` (npm, git, or local sources). Built-in: GitHub, MemPalace, Playwright, Brave Search |
21
21
  | **Background agents** | Heartbeat (hourly by default — advances goals, proactively messages when something matters) and Dream (memory consolidation + diary) |
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
- | **Skills** | Agent-authored reusable scripts (bash/python/node) procedures worked out once get saved and replayed locally at zero token cost |
23
+ | **Skills** | SKILL.md workflow bundles the agent authors and reuses, with `talon skill install/enable/disable` (local folders, git, or `owner/repo` the Anthropic skills ecosystem installs directly) |
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,11 +163,38 @@ 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
 
168
170
  ---
169
171
 
172
+ ## Managing plugins & skills
173
+
174
+ Both stores are managed from the CLI; changes hot-reload into a running
175
+ daemon (plugins) or apply on the next session (skills):
176
+
177
+ ```bash
178
+ # Plugins — npm specs, git repos, or local paths
179
+ talon plugin install @scope/my-talon-plugin # npm → module plugin
180
+ talon plugin install some-mcp-server --mcp # npm → standalone MCP server (npx)
181
+ talon plugin install owner/repo # git → module plugin
182
+ talon plugin list # built-ins + configured entries
183
+ talon plugin disable github # also toggles built-ins
184
+ talon plugin remove my-talon-plugin
185
+
186
+ # Skills — SKILL.md folders from local paths, git URLs, or owner/repo[/subpath]
187
+ talon skill install anthropics/skills/document-skills/pdf
188
+ talon skill install ./my-skill --force
189
+ talon skill list
190
+ talon skill disable pdf # hidden from the prompt index, still readable
191
+ talon skill remove pdf
192
+ ```
193
+
194
+ Module plugins install under `~/.talon/plugins/`; standalone MCP servers are
195
+ registered as `npx` entries in `config.json`. Disabling keeps the entry (or a
196
+ `.disabled` marker in the skill folder) so enabling restores it unchanged.
197
+
170
198
  ## Built-in Plugins
171
199
 
172
200
  ### GitHub
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "talon-agent",
3
- "version": "1.49.0",
3
+ "version": "1.51.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({
@@ -13,10 +13,11 @@ export async function fetchGateway(
13
13
  port: number,
14
14
  path: string,
15
15
  init?: RequestInit,
16
+ timeoutMs: number = REQUEST_TIMEOUT_MS,
16
17
  ): Promise<unknown> {
17
18
  const response = await fetch(`http://127.0.0.1:${port}${path}`, {
18
19
  ...init,
19
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
20
+ signal: AbortSignal.timeout(timeoutMs),
20
21
  });
21
22
  if (!response.ok) throw new Error(`gateway answered ${response.status}`);
22
23
  return response.json();
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
+ }