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.
- package/README.md +3 -0
- package/package.json +1 -1
- package/src/backend/codex/factory.ts +2 -0
- package/src/backend/codex/handler/message.ts +10 -0
- package/src/backend/kilo/factory.ts +2 -0
- package/src/backend/kilo/handler/message.ts +10 -0
- package/src/backend/openai-agents/factory.ts +2 -0
- package/src/backend/openai-agents/handler/message.ts +10 -0
- package/src/backend/opencode/factory.ts +2 -0
- package/src/backend/opencode/handler/message.ts +10 -0
- package/src/backend/shared/index.ts +4 -0
- package/src/backend/shared/turn-interrupt.ts +61 -0
- package/src/bootstrap.ts +12 -5
- package/src/cli/daemon-api.ts +39 -0
- package/src/cli/events.ts +133 -0
- package/src/cli/fs.ts +138 -0
- package/src/cli/index.ts +36 -2
- package/src/cli/tasks.ts +82 -48
- package/src/core/bus/bus.ts +114 -0
- package/src/core/bus/events.ts +64 -0
- package/src/core/bus/index.ts +20 -0
- package/src/core/engine/gateway-actions/index.ts +3 -0
- package/src/core/engine/gateway-actions/native.ts +151 -24
- package/src/core/engine/gateway-actions/vfs.ts +69 -0
- package/src/core/engine/gateway.ts +46 -0
- package/src/core/tasks/table.ts +25 -4
- package/src/core/tasks/types.ts +6 -2
- package/src/core/tools/index.ts +2 -0
- package/src/core/tools/native.ts +19 -6
- package/src/core/tools/types.ts +2 -1
- package/src/core/tools/vfs.ts +61 -0
- package/src/core/vfs/index.ts +113 -0
- package/src/core/vfs/mounts/files.ts +154 -0
- package/src/core/vfs/mounts/plugins.ts +72 -0
- package/src/core/vfs/mounts/proc.ts +125 -0
- package/src/core/vfs/types.ts +103 -0
- package/src/core/vfs/vfs.ts +237 -0
- package/src/core/weaver/weaver.ts +62 -15
- package/src/frontend/native/discovery.ts +3 -0
- package/src/frontend/native/index.ts +10 -1
- package/src/frontend/native/server.ts +65 -6
- package/src/frontend/native/tls.ts +313 -0
- package/src/storage/journal.ts +91 -0
- package/src/storage/repositories/journal-repo.ts +38 -0
- package/src/storage/sql/journal.sql +16 -0
- package/src/storage/sql/schema.sql +15 -0
- package/src/storage/sql/statements.generated.ts +24 -1
- package/src/util/config.ts +9 -0
- package/src/util/log.ts +2 -0
- 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
|
@@ -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
|
+
}
|