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/README.md CHANGED
@@ -23,6 +23,8 @@ Multi-platform agentic AI harness. Runs on **Telegram**, **Discord**, **Microsof
23
23
  | **Skills** | Agent-authored reusable scripts (bash/python/node) — procedures worked out once get saved and replayed locally at zero token cost |
24
24
  | **Triggers** | Self-authored watcher scripts (bash/python/node) that wake the bot when conditions are met |
25
25
  | **Task table** | Every unit of agent work — chat turns, heartbeat, dream, isolated cron/trigger jobs — registered live; `talon ps` / `talon kill` |
26
+ | **Event bus** | Typed internal pub-sub spine (task + turn lifecycle events); subsystems subscribe instead of importing each other; `talon events -f` |
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` |
26
28
  | **Per-chat settings** | Model, effort level, and pulse toggle per conversation via inline keyboard |
27
29
  | **Model registry** | Models discovered from the active backend at startup — new models appear in all pickers automatically |
28
30
 
@@ -161,6 +163,7 @@ The `desktop` frontend turns the daemon into a **client bridge** — a versioned
161
163
 
162
164
  - **Local (desktop):** the app connects to a Talon on the same machine and launches one if needed (`TALON_FRONTEND_OVERRIDE=desktop`).
163
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.
164
167
 
165
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).
166
169
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "talon-agent",
3
- "version": "1.48.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
@@ -22,6 +22,8 @@ import {
22
22
  initDispatcher,
23
23
  execute as dispatcherExecute,
24
24
  } from "./core/engine/dispatcher.js";
25
+ import { bus } from "./core/bus/index.js";
26
+ import { appendToJournal } from "./storage/journal.js";
25
27
  import { initPulse, resetPulseTimer } from "./core/background/pulse.js";
26
28
  import { initCron } from "./core/background/cron.js";
27
29
  import {
@@ -410,13 +412,18 @@ export async function initBackendAndDispatcher(
410
412
  resolveFrontendByNumericId(chatId, stringId, frontends).sendTyping(
411
413
  chatId,
412
414
  ),
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
415
  });
419
416
 
417
+ // Cross-subsystem reactions ride the bus, so the Weaver stays ignorant of
418
+ // dream and pulse: a bound turn kicks the fire-and-forget dream check, a
419
+ // completed turn resets the pulse idle timer.
420
+ bus.subscribe("turn.started", () => maybeStartDream());
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));
426
+
420
427
  initPulse();
421
428
  initCron({
422
429
  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,133 @@
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; `--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.
9
+ */
10
+
11
+ import pc from "picocolors";
12
+ import { fetchGateway, requireGatewayPort } from "./daemon-api.js";
13
+ import type { PublishedEvent, TalonEvent } from "../core/bus/index.js";
14
+
15
+ const FOLLOW_POLL_MS = 1_000;
16
+
17
+ function timestamp(at: number): string {
18
+ return new Date(at).toTimeString().slice(0, 8);
19
+ }
20
+
21
+ /** One tail line of type-specific detail, content-free like the events. */
22
+ function describe(event: TalonEvent): string {
23
+ switch (event.type) {
24
+ case "task.started":
25
+ return (
26
+ `#${event.task.id} ${event.task.kind} "${event.task.label}"` +
27
+ (event.task.chatId ? ` chat=${event.task.chatId}` : "")
28
+ );
29
+ case "task.settled":
30
+ return (
31
+ `#${event.task.id} ${event.task.kind} "${event.task.label}" → ${event.task.state}` +
32
+ (event.task.error ? ` (${event.task.error})` : "")
33
+ );
34
+ case "turn.started":
35
+ return `chat=${event.chatId} ${event.backendId}/${event.model} (${event.source})`;
36
+ case "turn.completed":
37
+ return `chat=${event.chatId} ${event.durationMs}ms in=${event.inputTokens} out=${event.outputTokens}`;
38
+ }
39
+ }
40
+
41
+ function render(event: TalonEvent, at: number): void {
42
+ console.log(
43
+ ` ${pc.dim(timestamp(at))} ${pc.cyan(event.type.padEnd(14))} ${describe(event)}`,
44
+ );
45
+ }
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
+
72
+ async function fetchEvents(
73
+ port: number,
74
+ sinceId: number,
75
+ ): Promise<PublishedEvent[]> {
76
+ const body = (await fetchGateway(
77
+ port,
78
+ `/events/recent?since=${sinceId}`,
79
+ )) as { events?: PublishedEvent[] };
80
+ return body.events ?? [];
81
+ }
82
+
83
+ function sleep(ms: number): Promise<void> {
84
+ return new Promise((resolve) => setTimeout(resolve, ms));
85
+ }
86
+
87
+ export async function showEvents(options: {
88
+ follow: boolean;
89
+ history?: number;
90
+ }): Promise<void> {
91
+ console.log();
92
+ if (options.history !== undefined) {
93
+ await showHistory(options.history);
94
+ return;
95
+ }
96
+
97
+ const port = await requireGatewayPort();
98
+ if (port === null) return;
99
+
100
+ let cursor = 0;
101
+ try {
102
+ const events = await fetchEvents(port, cursor);
103
+ if (events.length === 0 && !options.follow) {
104
+ console.log(
105
+ ` ${pc.dim("No events yet — the ring starts empty on each daemon start. Older events: talon events --history")}\n`,
106
+ );
107
+ return;
108
+ }
109
+ for (const event of events) render(event, event.at);
110
+ cursor = events.at(-1)?.id ?? 0;
111
+ } catch (err) {
112
+ console.log(
113
+ ` ${pc.red("✖")} Could not read the event bus: ${err instanceof Error ? err.message : err}\n`,
114
+ );
115
+ return;
116
+ }
117
+
118
+ if (!options.follow) {
119
+ console.log();
120
+ return;
121
+ }
122
+ console.log(` ${pc.dim("Following — Ctrl+C to stop.")}`);
123
+ for (;;) {
124
+ await sleep(FOLLOW_POLL_MS);
125
+ try {
126
+ const events = await fetchEvents(port, cursor);
127
+ for (const event of events) render(event, event.at);
128
+ if (events.length > 0) cursor = events.at(-1)!.id;
129
+ } catch {
130
+ // Transient gateway hiccup (restart mid-follow) — keep polling.
131
+ }
132
+ }
133
+ }
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
+ }