talon-agent 3.28.0 → 3.29.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 +12 -9
- package/package.json +3 -3
- package/src/bootstrap.ts +22 -5
- package/src/core/doctor.ts +41 -0
- package/src/core/plugin/builtins.ts +81 -5
- package/src/core/plugin/native-runtimes.ts +87 -0
- package/src/core/plugin/provision-journal.ts +172 -0
- package/src/core/plugin/provision.ts +276 -0
- package/src/frontend/discord/commands/admin.ts +5 -0
- package/src/frontend/telegram/commands/admin.ts +5 -0
- package/src/plugins/github/index.ts +15 -17
- package/src/plugins/github/provision.ts +172 -0
- package/src/plugins/mempalace/index.ts +17 -3
- package/src/plugins/mempalace/provision.ts +667 -0
- package/src/plugins/playwright/provision.ts +322 -0
- package/src/util/config.ts +27 -0
package/README.md
CHANGED
|
@@ -297,6 +297,8 @@ GitHub API access via the official GitHub MCP server. Gives the agent access to
|
|
|
297
297
|
|
|
298
298
|
The token is optional --- defaults to the output of `gh auth token` if the GitHub CLI is authenticated.
|
|
299
299
|
|
|
300
|
+
The server image is pinned to a known-good tag and pulled in the background at boot when absent (docker still pulls on first use as the fallback). Override with `"imageTag"` (`"latest"` opts out of pinning); `"autoProvision": false` disables the pre-pull.
|
|
301
|
+
|
|
300
302
|
### Long-term Memory
|
|
301
303
|
|
|
302
304
|
Talon supports two long-term memory backends, selected via the unified `memory` section:
|
|
@@ -316,14 +318,9 @@ Set `"backend"` to `"mempalace"` (local, vector search + knowledge graph) or `"m
|
|
|
316
318
|
|
|
317
319
|
Structured long-term memory with vector search. The agent can store, search, and retrieve memories semantically. Integrates with Dream mode for automatic memory consolidation and personal diary entries.
|
|
318
320
|
|
|
319
|
-
**Requirements:** Python 3.10+
|
|
321
|
+
**Requirements:** Python 3.10+ on PATH. Nothing else — Talon provisions its own environment.
|
|
320
322
|
|
|
321
|
-
|
|
322
|
-
# Set up a Python environment
|
|
323
|
-
python -m venv ~/.talon/mempalace-venv
|
|
324
|
-
~/.talon/mempalace-venv/bin/pip install mempalace # Unix
|
|
325
|
-
# or: ~/.talon/mempalace-venv/Scripts/pip install mempalace # Windows
|
|
326
|
-
```
|
|
323
|
+
On first boot Talon creates a venv at `~/.talon/mempalace-venv` and installs the pinned `mempalace` version into it. From then on the venv is **self-maintaining**: version drift against the pin reconciles automatically in the background, a broken install (half-written site-packages, a gutted venv) self-heals at the next start, and one-time palace data migrations (e.g. the ≥3.4 wing-name normalization) are applied exactly once, safely and idempotently. Failed upgrades never take the working install down — the current version keeps serving and the retry backs off.
|
|
327
324
|
|
|
328
325
|
```json
|
|
329
326
|
{
|
|
@@ -332,13 +329,17 @@ python -m venv ~/.talon/mempalace-venv
|
|
|
332
329
|
"backend": "mempalace",
|
|
333
330
|
"mempalace": {
|
|
334
331
|
"palacePath": "~/.talon/workspace/palace",
|
|
335
|
-
"
|
|
332
|
+
"version": "3.8.0",
|
|
333
|
+
"autoUpdate": true,
|
|
334
|
+
"autoProvision": true
|
|
336
335
|
}
|
|
337
336
|
}
|
|
338
337
|
}
|
|
339
338
|
```
|
|
340
339
|
|
|
341
|
-
|
|
340
|
+
Everything is optional --- `palacePath` defaults to `~/.talon/workspace/palace/`, `version` defaults to the built-in pin, and both `auto*` flags default to `true`. Leave `pythonPath` unset to use the managed venv --- its interpreter is `~/.talon/mempalace-venv/bin/python` on Linux/macOS and `~/.talon/mempalace-venv/Scripts/python.exe` on Windows; any other value is treated as operator-managed (see below). `autoProvision` governs creating and healing the venv; `autoUpdate` governs reconciling a working venv to the pin --- they are independent.
|
|
341
|
+
|
|
342
|
+
**Bring your own environment:** point `pythonPath` at any interpreter — a `uv tool` install, pipx, conda, or your own venv — and Talon treats it as operator-managed: it is probed and reported on (`talon doctor` shows the exact upgrade command for your install flavor) but never mutated.
|
|
342
343
|
|
|
343
344
|
#### mem0 backend
|
|
344
345
|
|
|
@@ -379,6 +380,8 @@ Headless browser automation via the Playwright MCP server. The agent can browse
|
|
|
379
380
|
|
|
380
381
|
Supported browsers: `chromium` (default), `chrome`, `firefox`, `webkit`, `msedge`.
|
|
381
382
|
|
|
383
|
+
For Playwright-managed engines (`chromium`, `firefox`, `webkit`) the browser build is downloaded automatically at boot when missing — version-matched to the bundled `@playwright/mcp`. System channels (`chrome`, `msedge`) and endpoint mode are never touched. `"autoProvision": false` disables the download.
|
|
384
|
+
|
|
382
385
|
### Brave Search
|
|
383
386
|
|
|
384
387
|
Web search via the Brave Search MCP server. Replaces the built-in WebSearch/WebFetch tools with higher-quality search results.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "talon-agent",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.29.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",
|
|
@@ -105,8 +105,8 @@
|
|
|
105
105
|
"@grammyjs/transformer-throttler": "^1.2.1",
|
|
106
106
|
"@kilocode/sdk": "^7.2.22",
|
|
107
107
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
108
|
-
"@openai/agents": "^0.
|
|
109
|
-
"@openai/codex-sdk": "^0.
|
|
108
|
+
"@openai/agents": "^0.17.0",
|
|
109
|
+
"@openai/codex-sdk": "^0.149.0",
|
|
110
110
|
"@opencode-ai/sdk": "^1.17.4",
|
|
111
111
|
"@playwright/mcp": "0.0.79",
|
|
112
112
|
"@types/cross-spawn": "^6.0.6",
|
package/src/bootstrap.ts
CHANGED
|
@@ -479,11 +479,9 @@ export async function initBackendAndDispatcher(
|
|
|
479
479
|
if (config.mempalace?.enabled) {
|
|
480
480
|
const { getPlugin } = await import("./core/plugin/index.js");
|
|
481
481
|
if (getPlugin("mempalace")) {
|
|
482
|
-
const {
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
palacePath: config.mempalace.palacePath ?? dirs.palace,
|
|
486
|
-
};
|
|
482
|
+
const { resolveMempalacePaths } =
|
|
483
|
+
await import("./plugins/mempalace/provision.js");
|
|
484
|
+
mempalaceCfg = resolveMempalacePaths(config.mempalace);
|
|
487
485
|
} else {
|
|
488
486
|
log(
|
|
489
487
|
"mempalace",
|
|
@@ -529,5 +527,24 @@ export async function initBackendAndDispatcher(
|
|
|
529
527
|
mempalace: Boolean(mempalaceCfg),
|
|
530
528
|
});
|
|
531
529
|
|
|
530
|
+
// Post-/update provisioning report — if the previous process armed one
|
|
531
|
+
// before respawning, tell the chat that asked for the update what the
|
|
532
|
+
// provisioners changed during this boot. Fire-and-forget; the delivery
|
|
533
|
+
// helper retries while frontends finish registering on the cross-send
|
|
534
|
+
// broker and gives up quietly.
|
|
535
|
+
{
|
|
536
|
+
const { deliverPendingProvisionReport } =
|
|
537
|
+
await import("./core/plugin/provision-journal.js");
|
|
538
|
+
const { crossSendHandlers } =
|
|
539
|
+
await import("./core/engine/gateway-actions/cross-send.js");
|
|
540
|
+
void deliverPendingProvisionReport(async (frontend, target, text) => {
|
|
541
|
+
const result = await crossSendHandlers.send_via(
|
|
542
|
+
{ frontend, target, text },
|
|
543
|
+
0,
|
|
544
|
+
);
|
|
545
|
+
return Boolean(result && (result as { ok?: unknown }).ok === true);
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
|
|
532
549
|
return { backend };
|
|
533
550
|
}
|
package/src/core/doctor.ts
CHANGED
|
@@ -78,6 +78,18 @@ export interface DoctorConfigSlice {
|
|
|
78
78
|
codexApiKey?: string;
|
|
79
79
|
openaiApiKey?: string;
|
|
80
80
|
openaiBaseUrl?: string;
|
|
81
|
+
mempalace?: {
|
|
82
|
+
enabled?: boolean;
|
|
83
|
+
pythonPath?: string;
|
|
84
|
+
version?: string;
|
|
85
|
+
};
|
|
86
|
+
playwright?: {
|
|
87
|
+
enabled?: boolean;
|
|
88
|
+
browser?: string;
|
|
89
|
+
endpoint?: string;
|
|
90
|
+
endpointFile?: string;
|
|
91
|
+
};
|
|
92
|
+
github?: { enabled?: boolean; imageTag?: string };
|
|
81
93
|
}
|
|
82
94
|
|
|
83
95
|
function errorNote(err: unknown): string {
|
|
@@ -297,6 +309,34 @@ async function checkClaudeConfiguredModels(
|
|
|
297
309
|
return checks;
|
|
298
310
|
}
|
|
299
311
|
|
|
312
|
+
/**
|
|
313
|
+
* Native plugin runtimes (MemPalace's venv, Playwright's browser build,
|
|
314
|
+
* GitHub's Docker image) — the artifacts the provisioners own. The
|
|
315
|
+
* runtime list and each inspection live in the native-runtimes registry
|
|
316
|
+
* (src/core/plugin/native-runtimes.ts); a new native plugin shows up
|
|
317
|
+
* here by registering there. Doctor reads, never mutates: a drifted or
|
|
318
|
+
* missing runtime reports what will fix it (usually "next talon start").
|
|
319
|
+
*/
|
|
320
|
+
async function checkPluginRuntimes(
|
|
321
|
+
config: DoctorConfigSlice | undefined,
|
|
322
|
+
): Promise<DoctorCheck[]> {
|
|
323
|
+
const checks: DoctorCheck[] = [];
|
|
324
|
+
const { NATIVE_RUNTIMES } = await import("./plugin/native-runtimes.js");
|
|
325
|
+
for (const runtime of NATIVE_RUNTIMES) {
|
|
326
|
+
if (!runtime.enabled(config)) continue;
|
|
327
|
+
try {
|
|
328
|
+
checks.push(...(await runtime.inspect(config!)));
|
|
329
|
+
} catch (err) {
|
|
330
|
+
checks.push({
|
|
331
|
+
label: `${runtime.id} runtime check errored`,
|
|
332
|
+
status: "warn",
|
|
333
|
+
detail: errorNote(err),
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
return checks;
|
|
338
|
+
}
|
|
339
|
+
|
|
300
340
|
/** Every backend id doctor knows how to inspect. */
|
|
301
341
|
const KNOWN_BACKENDS = [
|
|
302
342
|
"claude",
|
|
@@ -632,6 +672,7 @@ export async function collectDoctorReport(opts: {
|
|
|
632
672
|
}
|
|
633
673
|
|
|
634
674
|
const native = await checkNativeModules();
|
|
675
|
+
checks.push(...(await checkPluginRuntimes(opts.config)));
|
|
635
676
|
checks.push(...(await checkBackend(opts.config)));
|
|
636
677
|
|
|
637
678
|
const issues =
|
|
@@ -3,26 +3,101 @@
|
|
|
3
3
|
* path that re-reads config, tears down, and re-loads everything.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { log, logError } from "../../util/log.js";
|
|
6
|
+
import { log, logError, logWarn } from "../../util/log.js";
|
|
7
7
|
import type { TalonConfig } from "../../util/config.js";
|
|
8
8
|
import { registry, reloadState } from "./registry.js";
|
|
9
|
+
import type { ProvisionOutcome } from "./provision.js";
|
|
10
|
+
import { NATIVE_RUNTIMES, type NativePluginId } from "./native-runtimes.js";
|
|
11
|
+
import {
|
|
12
|
+
recordProvisionEvents,
|
|
13
|
+
trackBackgroundProvision,
|
|
14
|
+
} from "./provision-journal.js";
|
|
9
15
|
import {
|
|
10
16
|
initPluginWithTimeout,
|
|
11
17
|
loadPlugins,
|
|
12
18
|
registerPlugin,
|
|
13
19
|
} from "./loader.js";
|
|
14
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Surface a provisioning outcome in the logs and the journal, and fire
|
|
23
|
+
* its background reconcile task (fire-and-forget: the plugin is already
|
|
24
|
+
* serving on whatever the outcome declared usable).
|
|
25
|
+
*/
|
|
26
|
+
function reportProvision(
|
|
27
|
+
pluginName: NativePluginId,
|
|
28
|
+
outcome: ProvisionOutcome,
|
|
29
|
+
): void {
|
|
30
|
+
recordProvisionEvents(pluginName, outcome.actions);
|
|
31
|
+
for (const action of outcome.actions) {
|
|
32
|
+
log(pluginName, `provision: ${action}`);
|
|
33
|
+
}
|
|
34
|
+
for (const warning of outcome.warnings) {
|
|
35
|
+
logWarn(pluginName, `provision: ${warning}`);
|
|
36
|
+
}
|
|
37
|
+
if (outcome.status === "failed" && outcome.error) {
|
|
38
|
+
logError(pluginName, `provision failed: ${outcome.error}`);
|
|
39
|
+
}
|
|
40
|
+
const background = outcome.background;
|
|
41
|
+
if (background) {
|
|
42
|
+
// Tracked so the post-update report waits for it to settle (its
|
|
43
|
+
// actions are the changes worth reporting).
|
|
44
|
+
void trackBackgroundProvision(
|
|
45
|
+
background()
|
|
46
|
+
.then((result) =>
|
|
47
|
+
reportProvision(pluginName, { ...result, background: undefined }),
|
|
48
|
+
)
|
|
49
|
+
.catch((err) =>
|
|
50
|
+
logError(
|
|
51
|
+
pluginName,
|
|
52
|
+
`background provision: ${err instanceof Error ? err.message : err}`,
|
|
53
|
+
),
|
|
54
|
+
),
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Provision every enabled native runtime (see native-runtimes.ts) and
|
|
61
|
+
* return the outcomes so plugin construction can read what it got
|
|
62
|
+
* (e.g. the installed version). A provisioner that throws is treated
|
|
63
|
+
* as a failed pass, never a failed boot.
|
|
64
|
+
*/
|
|
65
|
+
async function provisionNativeRuntimes(
|
|
66
|
+
config: TalonConfig,
|
|
67
|
+
): Promise<Map<NativePluginId, ProvisionOutcome>> {
|
|
68
|
+
const outcomes = new Map<NativePluginId, ProvisionOutcome>();
|
|
69
|
+
for (const runtime of NATIVE_RUNTIMES) {
|
|
70
|
+
if (!runtime.enabled(config)) continue;
|
|
71
|
+
try {
|
|
72
|
+
const outcome = await runtime.provision(config);
|
|
73
|
+
outcomes.set(runtime.id, outcome);
|
|
74
|
+
reportProvision(runtime.id, outcome);
|
|
75
|
+
} catch (err) {
|
|
76
|
+
logError(
|
|
77
|
+
runtime.id,
|
|
78
|
+
`provision: ${err instanceof Error ? err.message : err}`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return outcomes;
|
|
83
|
+
}
|
|
84
|
+
|
|
15
85
|
/**
|
|
16
86
|
* Load built-in plugins (GitHub, MemPalace, mem0, Playwright) based on config flags.
|
|
17
87
|
* Shared by both bootstrap and hot-reload to avoid duplication.
|
|
18
88
|
*/
|
|
19
89
|
export async function loadBuiltinPlugins(config: TalonConfig): Promise<void> {
|
|
90
|
+
const provisioned = await provisionNativeRuntimes(config);
|
|
91
|
+
|
|
20
92
|
const github = config.github;
|
|
21
93
|
if (github?.enabled) {
|
|
22
94
|
try {
|
|
23
95
|
const { createGitHubPlugin } =
|
|
24
96
|
await import("../../plugins/github/index.js");
|
|
25
|
-
const gh = createGitHubPlugin({
|
|
97
|
+
const gh = createGitHubPlugin({
|
|
98
|
+
token: github.token,
|
|
99
|
+
imageTag: github.imageTag,
|
|
100
|
+
});
|
|
26
101
|
const ghConfig = github as unknown as Record<string, unknown>;
|
|
27
102
|
const loaded = registerPlugin(gh, ghConfig);
|
|
28
103
|
if (loaded) {
|
|
@@ -47,14 +122,15 @@ export async function loadBuiltinPlugins(config: TalonConfig): Promise<void> {
|
|
|
47
122
|
try {
|
|
48
123
|
const { createMempalacePlugin } =
|
|
49
124
|
await import("../../plugins/mempalace/index.js");
|
|
50
|
-
const {
|
|
51
|
-
|
|
52
|
-
const palacePath = mempalace
|
|
125
|
+
const { resolveMempalacePaths } =
|
|
126
|
+
await import("../../plugins/mempalace/provision.js");
|
|
127
|
+
const { pythonPath, palacePath } = resolveMempalacePaths(mempalace);
|
|
53
128
|
const mp = createMempalacePlugin({
|
|
54
129
|
pythonPath,
|
|
55
130
|
palacePath,
|
|
56
131
|
entityLanguages: mempalace.entityLanguages,
|
|
57
132
|
verbose: mempalace.verbose,
|
|
133
|
+
installedVersion: provisioned.get("mempalace")?.version,
|
|
58
134
|
});
|
|
59
135
|
const mpConfig = mempalace as unknown as Record<string, unknown>;
|
|
60
136
|
const loaded = registerPlugin(mp, mpConfig);
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single source of truth for native plugin runtimes — the built-in
|
|
3
|
+
* plugins whose MCP servers depend on an artifact Talon doesn't ship
|
|
4
|
+
* (MemPalace's Python venv, Playwright's browser build, GitHub's Docker
|
|
5
|
+
* image).
|
|
6
|
+
*
|
|
7
|
+
* Everything that iterates "the native plugins" — boot provisioning
|
|
8
|
+
* (builtins.ts), doctor inspection (doctor.ts), the provisioning CI —
|
|
9
|
+
* walks this list. Adding a native runtime is one descriptor here plus
|
|
10
|
+
* its provision module; no other call site changes.
|
|
11
|
+
*
|
|
12
|
+
* Plugin modules load lazily inside each method so this module stays
|
|
13
|
+
* import-cheap for consumers that only need the id list.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { DoctorCheck } from "../doctor.js";
|
|
17
|
+
import type { ProvisionOutcome } from "./provision.js";
|
|
18
|
+
import type { MempalaceSection } from "../../plugins/mempalace/provision.js";
|
|
19
|
+
import type { PlaywrightSection } from "../../plugins/playwright/provision.js";
|
|
20
|
+
import type { GithubSection } from "../../plugins/github/provision.js";
|
|
21
|
+
|
|
22
|
+
const NATIVE_PLUGIN_IDS = ["mempalace", "playwright", "github"] as const;
|
|
23
|
+
export type NativePluginId = (typeof NATIVE_PLUGIN_IDS)[number];
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The config slice native runtimes read. Both TalonConfig and doctor's
|
|
27
|
+
* DoctorConfigSlice satisfy it structurally.
|
|
28
|
+
*/
|
|
29
|
+
interface NativeRuntimeConfig {
|
|
30
|
+
mempalace?: ({ enabled?: boolean } & MempalaceSection) | undefined;
|
|
31
|
+
playwright?: ({ enabled?: boolean } & PlaywrightSection) | undefined;
|
|
32
|
+
github?: ({ enabled?: boolean } & GithubSection) | undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface NativeRuntime {
|
|
36
|
+
id: NativePluginId;
|
|
37
|
+
enabled(config: NativeRuntimeConfig | undefined): boolean;
|
|
38
|
+
/** Install/heal/reconcile the runtime. Only called when enabled. */
|
|
39
|
+
provision(config: NativeRuntimeConfig): Promise<ProvisionOutcome>;
|
|
40
|
+
/** Read-only health report for doctor. Only called when enabled. */
|
|
41
|
+
inspect(config: NativeRuntimeConfig): Promise<DoctorCheck[]>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const NATIVE_RUNTIMES: readonly NativeRuntime[] = [
|
|
45
|
+
{
|
|
46
|
+
id: "mempalace",
|
|
47
|
+
enabled: (config) => config?.mempalace?.enabled === true,
|
|
48
|
+
provision: async (config) => {
|
|
49
|
+
const { provisionMempalace } =
|
|
50
|
+
await import("../../plugins/mempalace/provision.js");
|
|
51
|
+
return provisionMempalace(config.mempalace ?? {});
|
|
52
|
+
},
|
|
53
|
+
inspect: async (config) => {
|
|
54
|
+
const { inspectMempalace } =
|
|
55
|
+
await import("../../plugins/mempalace/provision.js");
|
|
56
|
+
return inspectMempalace(config.mempalace ?? {});
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
id: "playwright",
|
|
61
|
+
enabled: (config) => config?.playwright?.enabled === true,
|
|
62
|
+
provision: async (config) => {
|
|
63
|
+
const { provisionPlaywright } =
|
|
64
|
+
await import("../../plugins/playwright/provision.js");
|
|
65
|
+
return provisionPlaywright(config.playwright ?? {});
|
|
66
|
+
},
|
|
67
|
+
inspect: async (config) => {
|
|
68
|
+
const { inspectPlaywright } =
|
|
69
|
+
await import("../../plugins/playwright/provision.js");
|
|
70
|
+
return inspectPlaywright(config.playwright ?? {});
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
id: "github",
|
|
75
|
+
enabled: (config) => config?.github?.enabled === true,
|
|
76
|
+
provision: async (config) => {
|
|
77
|
+
const { provisionGithubMcp } =
|
|
78
|
+
await import("../../plugins/github/provision.js");
|
|
79
|
+
return provisionGithubMcp(config.github ?? {});
|
|
80
|
+
},
|
|
81
|
+
inspect: async (config) => {
|
|
82
|
+
const { inspectGithub } =
|
|
83
|
+
await import("../../plugins/github/provision.js");
|
|
84
|
+
return inspectGithub(config.github ?? {});
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
];
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provision journal — what the provisioners changed, and the post-restart
|
|
3
|
+
* report that documents it.
|
|
4
|
+
*
|
|
5
|
+
* Two small persisted pieces under ~/.talon/data/:
|
|
6
|
+
*
|
|
7
|
+
* - provision-events.json — a capped rolling log of provisioning
|
|
8
|
+
* mutations ("upgraded mempalace 3.3.5 → 3.8.0", "palace wing-name
|
|
9
|
+
* migration: Migrated 3 drawer(s)."). Every action a provisioner
|
|
10
|
+
* reports lands here, whichever boot or background pass produced it.
|
|
11
|
+
*
|
|
12
|
+
* - provision-report-pending.json — armed by a frontend's /update
|
|
13
|
+
* command just before the respawn. The successor process, once its
|
|
14
|
+
* frontends are serving, reads the marker and messages the chat that
|
|
15
|
+
* asked for the update with whatever provisioning changed during the
|
|
16
|
+
* new boot — closing the loop that the pre-restart reply can't see.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
import { dirs } from "../../util/paths.js";
|
|
22
|
+
import { log, logWarn } from "../../util/log.js";
|
|
23
|
+
import type { NativePluginId } from "./native-runtimes.js";
|
|
24
|
+
|
|
25
|
+
interface ProvisionEvent {
|
|
26
|
+
at: string;
|
|
27
|
+
plugin: NativePluginId;
|
|
28
|
+
action: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface PendingReport {
|
|
32
|
+
frontend: string;
|
|
33
|
+
target: string;
|
|
34
|
+
since: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const EVENT_CAP = 50;
|
|
38
|
+
const eventsPath = (): string => join(dirs.data, "provision-events.json");
|
|
39
|
+
const pendingPath = (): string =>
|
|
40
|
+
join(dirs.data, "provision-report-pending.json");
|
|
41
|
+
|
|
42
|
+
function readJson<T>(path: string): T | undefined {
|
|
43
|
+
try {
|
|
44
|
+
return JSON.parse(readFileSync(path, "utf-8")) as T;
|
|
45
|
+
} catch {
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Append provisioning mutations to the rolling event log. */
|
|
51
|
+
export function recordProvisionEvents(
|
|
52
|
+
plugin: NativePluginId,
|
|
53
|
+
actions: readonly string[],
|
|
54
|
+
): void {
|
|
55
|
+
if (actions.length === 0) return;
|
|
56
|
+
const existing = readJson<ProvisionEvent[]>(eventsPath());
|
|
57
|
+
const at = new Date().toISOString();
|
|
58
|
+
const events = [
|
|
59
|
+
...(Array.isArray(existing) ? existing : []),
|
|
60
|
+
...actions.map((action) => ({ at, plugin, action })),
|
|
61
|
+
].slice(-EVENT_CAP);
|
|
62
|
+
try {
|
|
63
|
+
writeFileSync(eventsPath(), JSON.stringify(events, null, 2));
|
|
64
|
+
} catch {
|
|
65
|
+
/* the journal is reporting, never load-bearing */
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Events recorded at or after the given ISO timestamp. */
|
|
70
|
+
function provisionEventsSince(sinceIso: string): ProvisionEvent[] {
|
|
71
|
+
const events = readJson<ProvisionEvent[]>(eventsPath());
|
|
72
|
+
if (!Array.isArray(events)) return [];
|
|
73
|
+
return events.filter((e) => e.at >= sinceIso);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Arm the post-restart report. Called by a frontend's /update handler
|
|
78
|
+
* right before the respawn, with the chat that asked for the update.
|
|
79
|
+
*/
|
|
80
|
+
export function armProvisionReport(frontend: string, target: string): void {
|
|
81
|
+
try {
|
|
82
|
+
writeFileSync(
|
|
83
|
+
pendingPath(),
|
|
84
|
+
JSON.stringify({
|
|
85
|
+
frontend,
|
|
86
|
+
target,
|
|
87
|
+
since: new Date().toISOString(),
|
|
88
|
+
} satisfies PendingReport),
|
|
89
|
+
);
|
|
90
|
+
} catch {
|
|
91
|
+
/* losing the report loses a courtesy message, nothing more */
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const DELIVER_ATTEMPTS = 60;
|
|
96
|
+
const DELIVER_DELAY_MS = 15_000;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Background reconcile tasks still running (a version upgrade, a docker
|
|
100
|
+
* pull). The post-update report waits for them: their actions are the
|
|
101
|
+
* changes worth reporting, and they settle minutes after boot.
|
|
102
|
+
*/
|
|
103
|
+
let backgroundInFlight = 0;
|
|
104
|
+
|
|
105
|
+
/** Track a background provisioning task until it settles (including its journaling). */
|
|
106
|
+
export function trackBackgroundProvision<T>(task: Promise<T>): Promise<T> {
|
|
107
|
+
backgroundInFlight++;
|
|
108
|
+
return task.finally(() => {
|
|
109
|
+
backgroundInFlight--;
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function backgroundProvisionInFlight(): number {
|
|
114
|
+
return backgroundInFlight;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Deliver the pending report, if one is armed and this boot's
|
|
119
|
+
* provisioning changed something. Waits for the boot's background
|
|
120
|
+
* reconcile tasks to settle before reading the journal, so the report
|
|
121
|
+
* covers what they did rather than a snapshot taken while pip was still
|
|
122
|
+
* running, and retries the send because it races frontend registration
|
|
123
|
+
* on the cross-send broker. Bounded (~15 minutes, matching the longest
|
|
124
|
+
* provisioner timeout), then gives up quietly — this is a courtesy
|
|
125
|
+
* message, not state. Returns immediately when nothing is pending and
|
|
126
|
+
* nothing changed.
|
|
127
|
+
*/
|
|
128
|
+
export async function deliverPendingProvisionReport(
|
|
129
|
+
send: (frontend: string, target: string, text: string) => Promise<boolean>,
|
|
130
|
+
sleep: (ms: number) => Promise<void> = (ms) =>
|
|
131
|
+
new Promise((r) => setTimeout(r, ms).unref?.()),
|
|
132
|
+
): Promise<void> {
|
|
133
|
+
const pending = readJson<PendingReport>(pendingPath());
|
|
134
|
+
if (!pending?.frontend || !pending.target || !pending.since) return;
|
|
135
|
+
// Consume the marker up front so a crashy boot can't replay a stale
|
|
136
|
+
// report later; the wait loop below keeps it alive in memory.
|
|
137
|
+
try {
|
|
138
|
+
rmSync(pendingPath(), { force: true });
|
|
139
|
+
} catch {
|
|
140
|
+
/* already gone is fine */
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
for (let attempt = 1; attempt <= DELIVER_ATTEMPTS; attempt++) {
|
|
144
|
+
// Report only once the background work is done — or, on the last
|
|
145
|
+
// attempt, whatever has landed so far rather than nothing.
|
|
146
|
+
const settled = backgroundInFlight === 0 || attempt === DELIVER_ATTEMPTS;
|
|
147
|
+
if (settled) {
|
|
148
|
+
const events = provisionEventsSince(pending.since);
|
|
149
|
+
if (events.length === 0 && backgroundInFlight === 0) return;
|
|
150
|
+
if (events.length > 0) {
|
|
151
|
+
const lines = events.map((e) => `• ${e.plugin}: ${e.action}`);
|
|
152
|
+
const text = `♻️ Back online. Provisioning changes during the update:\n${lines.join("\n")}`;
|
|
153
|
+
try {
|
|
154
|
+
if (await send(pending.frontend, pending.target, text)) {
|
|
155
|
+
log(
|
|
156
|
+
"plugin",
|
|
157
|
+
`Post-update provision report delivered (${events.length} change${events.length === 1 ? "" : "s"})`,
|
|
158
|
+
);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
} catch {
|
|
162
|
+
/* fall through to retry */
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (attempt < DELIVER_ATTEMPTS) await sleep(DELIVER_DELAY_MS);
|
|
167
|
+
}
|
|
168
|
+
logWarn(
|
|
169
|
+
"plugin",
|
|
170
|
+
`Post-update provision report not delivered (${pending.frontend} unavailable or provisioning still running)`,
|
|
171
|
+
);
|
|
172
|
+
}
|