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.
- package/README.md +30 -2
- 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 +5 -0
- package/src/cli/daemon-api.ts +2 -1
- package/src/cli/events.ts +47 -12
- package/src/cli/fs.ts +138 -0
- package/src/cli/index.ts +47 -7
- package/src/cli/install-sources.ts +106 -0
- package/src/cli/plugin-entries.ts +104 -0
- package/src/cli/plugin.ts +494 -0
- package/src/cli/skill.ts +252 -0
- package/src/cli/tasks.ts +85 -20
- 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/plugins.ts +36 -19
- package/src/core/engine/gateway-actions/vfs.ts +69 -0
- package/src/core/engine/gateway.ts +52 -0
- package/src/core/plugin/loader.ts +14 -2
- package/src/core/plugin/types.ts +4 -0
- package/src/core/tasks/table.ts +1 -1
- 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 +40 -3
- 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/skill-store.ts +118 -5
- 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 +16 -0
- package/src/util/log.ts +1 -0
- 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** |
|
|
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
|
@@ -10,6 +10,7 @@ import { registerBackend } from "../../core/agent-runtime/backend-registry.js";
|
|
|
10
10
|
import type { BackendFactory } from "../../core/agent-runtime/backend-registry.js";
|
|
11
11
|
import { log } from "../../util/log.js";
|
|
12
12
|
import { handlerToEvents } from "../shared/handler-to-events.js";
|
|
13
|
+
import { interruptChatTurn } from "../shared/turn-interrupt.js";
|
|
13
14
|
import {
|
|
14
15
|
composeBackend,
|
|
15
16
|
type ChatBackend,
|
|
@@ -46,6 +47,7 @@ const codexFactory: BackendFactory = {
|
|
|
46
47
|
const chat: ChatBackend = {
|
|
47
48
|
runChatTurn: (params) =>
|
|
48
49
|
handlerToEvents((p) => codexHandleMessage(p), params),
|
|
50
|
+
interruptChatTurn: (chatId) => interruptChatTurn(chatId),
|
|
49
51
|
};
|
|
50
52
|
|
|
51
53
|
const background: BackgroundRunner = {
|
|
@@ -40,6 +40,7 @@ import {
|
|
|
40
40
|
recordTurnMetrics,
|
|
41
41
|
recordFailedTurnAccounting,
|
|
42
42
|
pushLiveUsage,
|
|
43
|
+
registerTurnInterrupt,
|
|
43
44
|
} from "../../shared/index.js";
|
|
44
45
|
|
|
45
46
|
import {
|
|
@@ -322,6 +323,14 @@ export async function handleMessage(
|
|
|
322
323
|
const codexToolMetrics = { count: 0 };
|
|
323
324
|
const abortController = new AbortController();
|
|
324
325
|
activeAborts.set(chatId, abortController);
|
|
326
|
+
// A user interrupt is a synthetic turn terminator: marking the flag
|
|
327
|
+
// before aborting routes the close through the same clean path a
|
|
328
|
+
// model-fired end_turn takes (abort swallowed, no retry, no flow
|
|
329
|
+
// violation), settling with the partial text and real usage.
|
|
330
|
+
const unregisterInterrupt = registerTurnInterrupt(chatId, () => {
|
|
331
|
+
streamState.turnTerminated = true;
|
|
332
|
+
abortController.abort();
|
|
333
|
+
});
|
|
325
334
|
|
|
326
335
|
let usage: Usage | null = null;
|
|
327
336
|
let turnFailedError: string | undefined;
|
|
@@ -539,6 +548,7 @@ export async function handleMessage(
|
|
|
539
548
|
throw outcome.classified;
|
|
540
549
|
}
|
|
541
550
|
} finally {
|
|
551
|
+
unregisterInterrupt();
|
|
542
552
|
if (activeAborts.get(chatId) === abortController) {
|
|
543
553
|
activeAborts.delete(chatId);
|
|
544
554
|
}
|
|
@@ -13,6 +13,7 @@ import { registerBackend } from "../../core/agent-runtime/backend-registry.js";
|
|
|
13
13
|
import type { BackendFactory } from "../../core/agent-runtime/backend-registry.js";
|
|
14
14
|
import { log } from "../../util/log.js";
|
|
15
15
|
import { handlerToEvents } from "../shared/handler-to-events.js";
|
|
16
|
+
import { interruptChatTurn } from "../shared/turn-interrupt.js";
|
|
16
17
|
import {
|
|
17
18
|
composeBackend,
|
|
18
19
|
type ChatBackend,
|
|
@@ -47,6 +48,7 @@ const kiloFactory: BackendFactory = {
|
|
|
47
48
|
const chat: ChatBackend = {
|
|
48
49
|
runChatTurn: (params) =>
|
|
49
50
|
handlerToEvents((p) => kiloHandleMessage(p), params),
|
|
51
|
+
interruptChatTurn: (chatId) => interruptChatTurn(chatId),
|
|
50
52
|
};
|
|
51
53
|
|
|
52
54
|
const background: BackgroundRunner = {
|
|
@@ -45,6 +45,7 @@ import {
|
|
|
45
45
|
applyRetryDecision,
|
|
46
46
|
recordTurnMetrics,
|
|
47
47
|
recordFailedTurnAccounting,
|
|
48
|
+
registerTurnInterrupt,
|
|
48
49
|
} from "../../shared/index.js";
|
|
49
50
|
import { activeSessions } from "./state.js";
|
|
50
51
|
import { runKiloTurn } from "./turn.js";
|
|
@@ -119,6 +120,14 @@ export async function handleMessage(
|
|
|
119
120
|
// into the live-turn overlay — /status updates while the turn runs.
|
|
120
121
|
const state = createStreamState(chatId);
|
|
121
122
|
state.newSessionId = sessionId;
|
|
123
|
+
// A user interrupt is a synthetic turn terminator: marking the flag
|
|
124
|
+
// before aborting the session routes the close through the same clean
|
|
125
|
+
// path a model-fired end_turn takes (MessageAborted swallowed as the
|
|
126
|
+
// expected close, no retry), settling with whatever the turn produced.
|
|
127
|
+
const unregisterInterrupt = registerTurnInterrupt(chatId, async () => {
|
|
128
|
+
state.turnTerminated = true;
|
|
129
|
+
await oc.session.abort({ sessionID: sessionId });
|
|
130
|
+
});
|
|
122
131
|
const promptStartedAt = Date.now();
|
|
123
132
|
const seenQuestionIds = new Set<string>();
|
|
124
133
|
const seenToolCallIds = new Set<string>();
|
|
@@ -185,6 +194,7 @@ export async function handleMessage(
|
|
|
185
194
|
logError("agent", `[${chatId}] Kilo error: ${outcome.classified.message}`);
|
|
186
195
|
throw outcome.classified;
|
|
187
196
|
} finally {
|
|
197
|
+
unregisterInterrupt();
|
|
188
198
|
if (activeSessions.get(chatId) === sessionId) {
|
|
189
199
|
activeSessions.delete(chatId);
|
|
190
200
|
}
|
|
@@ -13,6 +13,7 @@ import { registerBackend } from "../../core/agent-runtime/backend-registry.js";
|
|
|
13
13
|
import type { BackendFactory } from "../../core/agent-runtime/backend-registry.js";
|
|
14
14
|
import { log } from "../../util/log.js";
|
|
15
15
|
import { handlerToEvents } from "../shared/handler-to-events.js";
|
|
16
|
+
import { interruptChatTurn } from "../shared/turn-interrupt.js";
|
|
16
17
|
import {
|
|
17
18
|
composeBackend,
|
|
18
19
|
type ChatBackend,
|
|
@@ -46,6 +47,7 @@ const openAIAgentsFactory: BackendFactory = {
|
|
|
46
47
|
const chat: ChatBackend = {
|
|
47
48
|
runChatTurn: (params) =>
|
|
48
49
|
handlerToEvents((p) => openAIAgentsHandleMessage(p), params),
|
|
50
|
+
interruptChatTurn: (chatId) => interruptChatTurn(chatId),
|
|
49
51
|
};
|
|
50
52
|
|
|
51
53
|
const defaultIdSync = (): string | null => {
|
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
recordTurnMetrics,
|
|
39
39
|
recordFailedTurnAccounting,
|
|
40
40
|
recordFlowViolation,
|
|
41
|
+
registerTurnInterrupt,
|
|
41
42
|
} from "../../shared/index.js";
|
|
42
43
|
import {
|
|
43
44
|
detectFlowViolation,
|
|
@@ -182,6 +183,14 @@ export async function handleMessage(
|
|
|
182
183
|
const seenToolCallIds = new Set<string>();
|
|
183
184
|
const abortController = new AbortController();
|
|
184
185
|
activeAborts.set(chatId, abortController);
|
|
186
|
+
// A user interrupt is a synthetic turn terminator: marking the flag
|
|
187
|
+
// before aborting routes the close through the same clean path a
|
|
188
|
+
// model-fired end_turn takes (abort swallowed, no retry, no flow
|
|
189
|
+
// violation), settling with the partial text and real usage.
|
|
190
|
+
const unregisterInterrupt = registerTurnInterrupt(chatId, () => {
|
|
191
|
+
streamState.turnTerminated = true;
|
|
192
|
+
abortController.abort();
|
|
193
|
+
});
|
|
185
194
|
|
|
186
195
|
const setupMs = Date.now() - t0;
|
|
187
196
|
let turnMs = 0;
|
|
@@ -385,6 +394,7 @@ export async function handleMessage(
|
|
|
385
394
|
throw classified;
|
|
386
395
|
}
|
|
387
396
|
} finally {
|
|
397
|
+
unregisterInterrupt();
|
|
388
398
|
if (activeAborts.get(chatId) === abortController) {
|
|
389
399
|
activeAborts.delete(chatId);
|
|
390
400
|
}
|
|
@@ -13,6 +13,7 @@ import { registerBackend } from "../../core/agent-runtime/backend-registry.js";
|
|
|
13
13
|
import type { BackendFactory } from "../../core/agent-runtime/backend-registry.js";
|
|
14
14
|
import { log } from "../../util/log.js";
|
|
15
15
|
import { handlerToEvents } from "../shared/handler-to-events.js";
|
|
16
|
+
import { interruptChatTurn } from "../shared/turn-interrupt.js";
|
|
16
17
|
import {
|
|
17
18
|
composeBackend,
|
|
18
19
|
type ChatBackend,
|
|
@@ -47,6 +48,7 @@ const opencodeFactory: BackendFactory = {
|
|
|
47
48
|
const chat: ChatBackend = {
|
|
48
49
|
runChatTurn: (params) =>
|
|
49
50
|
handlerToEvents((p) => ocHandleMessage(p), params),
|
|
51
|
+
interruptChatTurn: (chatId) => interruptChatTurn(chatId),
|
|
50
52
|
};
|
|
51
53
|
|
|
52
54
|
const background: BackgroundRunner = {
|
|
@@ -47,6 +47,7 @@ import {
|
|
|
47
47
|
applyRetryDecision,
|
|
48
48
|
recordTurnMetrics,
|
|
49
49
|
recordFailedTurnAccounting,
|
|
50
|
+
registerTurnInterrupt,
|
|
50
51
|
} from "../../shared/index.js";
|
|
51
52
|
import { activeSessions } from "./state.js";
|
|
52
53
|
import { runOpenCodeTurn } from "./turn.js";
|
|
@@ -122,6 +123,14 @@ export async function handleMessage(
|
|
|
122
123
|
// into the live-turn overlay — /status updates while the turn runs.
|
|
123
124
|
const state = createStreamState(chatId);
|
|
124
125
|
state.newSessionId = sessionId;
|
|
126
|
+
// A user interrupt is a synthetic turn terminator: marking the flag
|
|
127
|
+
// before aborting the session routes the close through the same clean
|
|
128
|
+
// path a model-fired end_turn takes (MessageAborted swallowed as the
|
|
129
|
+
// expected close, no retry), settling with whatever the turn produced.
|
|
130
|
+
const unregisterInterrupt = registerTurnInterrupt(chatId, async () => {
|
|
131
|
+
state.turnTerminated = true;
|
|
132
|
+
await oc.session.abort({ sessionID: sessionId });
|
|
133
|
+
});
|
|
125
134
|
const promptStartedAt = Date.now();
|
|
126
135
|
const seenQuestionIds = new Set<string>();
|
|
127
136
|
const seenToolCallIds = new Set<string>();
|
|
@@ -191,6 +200,7 @@ export async function handleMessage(
|
|
|
191
200
|
);
|
|
192
201
|
throw outcome.classified;
|
|
193
202
|
} finally {
|
|
203
|
+
unregisterInterrupt();
|
|
194
204
|
if (activeSessions.get(chatId) === sessionId) {
|
|
195
205
|
activeSessions.delete(chatId);
|
|
196
206
|
}
|
|
@@ -21,6 +21,8 @@
|
|
|
21
21
|
* (assembly itself lives in `core/prompt/`).
|
|
22
22
|
* - `model-retry` — session-expiry / context-overflow / fallback decisions.
|
|
23
23
|
* - `stream-state` — backend-agnostic accumulator for stream loops.
|
|
24
|
+
* - `turn-interrupt` — user-driven mid-turn interrupt registry (the
|
|
25
|
+
* shared `ChatBackend.interruptChatTurn` for callback backends).
|
|
24
26
|
*
|
|
25
27
|
* What's NOT here (intentionally):
|
|
26
28
|
* - SDK-specific event types — those live in each backend.
|
|
@@ -43,6 +45,8 @@ export {
|
|
|
43
45
|
type FlowViolationResult,
|
|
44
46
|
} from "./flow-violation.js";
|
|
45
47
|
|
|
48
|
+
export { registerTurnInterrupt, interruptChatTurn } from "./turn-interrupt.js";
|
|
49
|
+
|
|
46
50
|
export {
|
|
47
51
|
formatUserPrompt,
|
|
48
52
|
formatPromptWithRetrievedMemory,
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* User-driven mid-turn interrupts, shared by the callback backends
|
|
3
|
+
* (Codex, OpenAI Agents, Kilo, OpenCode). The Claude SDK backend has a
|
|
4
|
+
* native `Query.interrupt()` and doesn't come through here.
|
|
5
|
+
*
|
|
6
|
+
* A handler registers how to stop its in-flight turn when the turn
|
|
7
|
+
* starts and unregisters when it settles; `interruptChatTurn` — the
|
|
8
|
+
* shared implementation of `ChatBackend.interruptChatTurn` — invokes it.
|
|
9
|
+
*
|
|
10
|
+
* The registered closure must make the turn end the way a model-fired
|
|
11
|
+
* terminator does: mark `state.turnTerminated` FIRST, then fire the
|
|
12
|
+
* native abort. Every backend's close-out logic already keys off that
|
|
13
|
+
* flag — abort errors are swallowed as the expected close, wrap-up
|
|
14
|
+
* round-trips are skipped, and the flow-violation retry (which would
|
|
15
|
+
* otherwise resurrect a killed turn as a "missed delivery") stands
|
|
16
|
+
* down. The stream then settles as a normal completion carrying the
|
|
17
|
+
* partial text and real usage — never as an error, never as a retry.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { log, logWarn } from "../../util/log.js";
|
|
21
|
+
import { incrementCounter } from "../../util/metrics.js";
|
|
22
|
+
|
|
23
|
+
const interrupts = new Map<string, () => void | Promise<void>>();
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Register the abort path for a chat's in-flight turn. Returns the
|
|
27
|
+
* unregister function; it only removes its own registration, so a retry
|
|
28
|
+
* that re-registered for the same chat is never torn down by the
|
|
29
|
+
* attempt that spawned it.
|
|
30
|
+
*/
|
|
31
|
+
export function registerTurnInterrupt(
|
|
32
|
+
chatId: string,
|
|
33
|
+
interrupt: () => void | Promise<void>,
|
|
34
|
+
): () => void {
|
|
35
|
+
interrupts.set(chatId, interrupt);
|
|
36
|
+
return () => {
|
|
37
|
+
if (interrupts.get(chatId) === interrupt) interrupts.delete(chatId);
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Best-effort interrupt of the chat's in-flight turn. Resolves `true`
|
|
43
|
+
* when a running turn was found and signalled, `false` otherwise —
|
|
44
|
+
* the `ChatBackend.interruptChatTurn` contract.
|
|
45
|
+
*/
|
|
46
|
+
export async function interruptChatTurn(chatId: string): Promise<boolean> {
|
|
47
|
+
const interrupt = interrupts.get(chatId);
|
|
48
|
+
if (!interrupt) return false;
|
|
49
|
+
try {
|
|
50
|
+
await interrupt();
|
|
51
|
+
log("agent", `[${chatId}] turn interrupted by user`);
|
|
52
|
+
incrementCounter("turn_interrupted");
|
|
53
|
+
return true;
|
|
54
|
+
} catch (err) {
|
|
55
|
+
logWarn(
|
|
56
|
+
"agent",
|
|
57
|
+
`[${chatId}] interrupt failed: ${err instanceof Error ? err.message : err}`,
|
|
58
|
+
);
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
}
|
package/src/bootstrap.ts
CHANGED
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
execute as dispatcherExecute,
|
|
24
24
|
} from "./core/engine/dispatcher.js";
|
|
25
25
|
import { bus } from "./core/bus/index.js";
|
|
26
|
+
import { appendToJournal } from "./storage/journal.js";
|
|
26
27
|
import { initPulse, resetPulseTimer } from "./core/background/pulse.js";
|
|
27
28
|
import { initCron } from "./core/background/cron.js";
|
|
28
29
|
import {
|
|
@@ -418,6 +419,10 @@ export async function initBackendAndDispatcher(
|
|
|
418
419
|
// completed turn resets the pulse idle timer.
|
|
419
420
|
bus.subscribe("turn.started", () => maybeStartDream());
|
|
420
421
|
bus.subscribe("turn.completed", () => resetPulseTimer());
|
|
422
|
+
// The journal is the bus's durable tail: every published event lands in
|
|
423
|
+
// talon.db so history survives restarts (`talon events --history`,
|
|
424
|
+
// `talon ps --all`). Append failures are logged and swallowed inside.
|
|
425
|
+
bus.subscribeAll((event) => appendToJournal(event));
|
|
421
426
|
|
|
422
427
|
initPulse();
|
|
423
428
|
initCron({
|
package/src/cli/daemon-api.ts
CHANGED
|
@@ -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(
|
|
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
|
|
6
|
-
*
|
|
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:
|
|
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:
|
|
41
|
+
function render(event: TalonEvent, at: number): void {
|
|
40
42
|
console.log(
|
|
41
|
-
` ${pc.dim(timestamp(
|
|
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(
|
|
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
|
|
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
|
+
}
|