talon-agent 5.23.1 → 5.24.1
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 +8 -2
- package/package.json +1 -1
- package/src/app.ts +30 -0
- package/src/backend/agy/process/orphans.ts +5 -1
- package/src/backend/claude-sdk/one-shot.ts +8 -1
- package/src/backend/codex/factory.ts +5 -1
- package/src/backend/codex/plan-usage.ts +12 -0
- package/src/backend/runtime/turn/turn-phases.ts +7 -1
- package/src/bootstrap.ts +1 -0
- package/src/cli/install-sources.ts +187 -35
- package/src/cli/plugin.ts +27 -11
- package/src/cli/skill.ts +60 -7
- package/src/cli.ts +18 -12
- package/src/core/agent-runtime/capabilities.ts +7 -0
- package/src/core/agents/runner.ts +4 -0
- package/src/core/background/cron/job-oneshot.ts +7 -1
- package/src/core/background/cron/scheduler.ts +28 -14
- package/src/core/background/cron/spec.ts +23 -3
- package/src/core/background/heartbeat/agent.ts +6 -0
- package/src/core/background/triggers/resume.ts +19 -4
- package/src/core/background/triggers/spawn.ts +1 -1
- package/src/core/config/index.ts +88 -4
- package/src/core/daemon/discovery.ts +160 -0
- package/src/core/daemon/pidfile.ts +107 -0
- package/src/core/daemon/respawn.ts +4 -1
- package/src/core/engine/backend-router/breaker.ts +158 -0
- package/src/core/engine/backend-router/headroom.ts +90 -22
- package/src/core/engine/backend-router/index.ts +11 -0
- package/src/core/engine/gateway-actions/cron.ts +5 -0
- package/src/core/engine/gateway-actions/fetch-url/guard.ts +13 -44
- package/src/core/engine/gateway-actions/fetch-url/index.ts +23 -43
- package/src/core/engine/gateway-actions/mesh.ts +4 -0
- package/src/core/engine/gateway-actions/models.ts +19 -4
- package/src/core/engine/gateway-routes.ts +31 -2
- package/src/core/engine/gateway.ts +11 -1
- package/src/core/fetch/classify.ts +73 -0
- package/src/core/fetch/curl-impersonate.ts +447 -0
- package/src/core/fetch/errors.ts +14 -0
- package/src/core/fetch/index.ts +133 -0
- package/src/core/fetch/ladder.ts +401 -0
- package/src/core/fetch/rungs.ts +319 -0
- package/src/core/fetch/types.ts +105 -0
- package/src/core/frontend-runtime/admin-notify.ts +100 -12
- package/src/core/mcp-hub/child-guard.ts +215 -0
- package/src/core/mcp-hub/child-transport.ts +89 -39
- package/src/core/mcp-hub/children.ts +65 -9
- package/src/core/mcp-hub/guest-scope.ts +3 -2
- package/src/core/mcp-hub/index.ts +36 -16
- package/src/core/mcp-hub/launcher.ts +81 -37
- package/src/core/mcp-hub/proxy-server.ts +12 -8
- package/src/core/mcp-hub/reaper.ts +120 -0
- package/src/core/mesh/credentials/index.ts +2 -2
- package/src/core/mesh/credentials/store.ts +1 -1
- package/src/core/mesh/devices/service.ts +8 -0
- package/src/core/mesh/links/bridge-links.ts +37 -0
- package/src/core/mesh/links/companion-pairing.ts +2 -2
- package/src/core/plugin/mcp.ts +8 -8
- package/src/core/tools/bridge.ts +3 -0
- package/src/core/tools/content/web.ts +1 -1
- package/src/core/tools/ops/mesh.ts +21 -0
- package/src/core/tools/ops/scheduling.ts +15 -0
- package/src/frontend/native/bridge/auth-guard.ts +117 -53
- package/src/frontend/native/bridge/credentials/claims.ts +11 -1
- package/src/frontend/native/bridge/credentials/principal.ts +13 -1
- package/src/frontend/native/bridge/routes/table.ts +13 -0
- package/src/frontend/native/bridge/server.ts +37 -11
- package/src/frontend/telegram/actions/media.ts +164 -12
- package/src/index.ts +22 -19
- package/src/plugins/playwright/version-coupling.ts +172 -62
- package/src/storage/cron.ts +34 -2
- package/src/storage/db.ts +1 -0
- package/src/storage/repositories/cron-repo.ts +9 -0
- package/src/storage/skills.ts +7 -1
- package/src/storage/sql/cron.sql +5 -5
- package/src/storage/sql/db.sql +5 -0
- package/src/storage/sql/schema.sql +2 -1
- package/src/storage/sql/statements.generated.ts +10 -6
- package/src/util/log.ts +2 -1
- package/src/util/paths.ts +2 -0
- package/src/core/background/triggers/pid.ts +0 -28
package/README.md
CHANGED
|
@@ -88,7 +88,9 @@ xattr -d com.apple.quarantine ./talon-darwin-arm64
|
|
|
88
88
|
```
|
|
89
89
|
|
|
90
90
|
Verify a direct download against the release `SHA256SUMS`:
|
|
91
|
-
`sha256sum -c SHA256SUMS --ignore-missing
|
|
91
|
+
`sha256sum -c SHA256SUMS --ignore-missing`, and its build provenance with
|
|
92
|
+
`gh attestation verify talon-linux-x64 --repo thefalconry/talon` (see
|
|
93
|
+
[SECURITY.md](SECURITY.md#verifying-a-release)).
|
|
92
94
|
|
|
93
95
|
**Server only, no Telegram?** Run the daemon with just the client bridge,
|
|
94
96
|
reached by the companion app and talon-node: see
|
|
@@ -272,6 +274,7 @@ daemon (plugins) or apply on the next session (skills):
|
|
|
272
274
|
talon plugin install @scope/my-talon-plugin # npm → module plugin
|
|
273
275
|
talon plugin install some-mcp-server --mcp # npm → standalone MCP server (npx)
|
|
274
276
|
talon plugin install owner/repo # git → module plugin
|
|
277
|
+
talon plugin install owner/repo#<sha> # …at that commit (or --commit <sha>)
|
|
275
278
|
talon plugin list # built-ins + configured entries
|
|
276
279
|
talon plugin disable github # also toggles built-ins
|
|
277
280
|
talon plugin remove my-talon-plugin
|
|
@@ -285,7 +288,10 @@ talon skill remove pdf
|
|
|
285
288
|
```
|
|
286
289
|
|
|
287
290
|
Module plugins install under `~/.talon/plugins/`; standalone MCP servers are
|
|
288
|
-
registered as `npx` entries in `config.json`.
|
|
291
|
+
registered as `npx` entries in `config.json`. A git source can be pinned to a
|
|
292
|
+
commit with `#<sha>` or `--commit <sha>` (7-64 hex digits): Talon checks that
|
|
293
|
+
commit out and verifies HEAD before installing. Either way the install
|
|
294
|
+
folder's `.talon-install.json` records the repo and the exact commit. Disabling keeps the entry (or a
|
|
289
295
|
`.disabled` marker in the skill folder) so enabling restores it unchanged.
|
|
290
296
|
|
|
291
297
|
## Built-in Plugins
|
package/package.json
CHANGED
package/src/app.ts
CHANGED
|
@@ -74,6 +74,7 @@ import {
|
|
|
74
74
|
writePidRecord,
|
|
75
75
|
removePidRecordIfOwnedBy,
|
|
76
76
|
} from "./core/daemon/pidfile.js";
|
|
77
|
+
import { stampDaemonOwner } from "./core/daemon/pidfile.js";
|
|
77
78
|
import {
|
|
78
79
|
recordBootMetrics,
|
|
79
80
|
startResourceSampler,
|
|
@@ -95,6 +96,24 @@ if (process.argv.includes(BOOT_SMOKE_FLAG)) {
|
|
|
95
96
|
process.exit(0);
|
|
96
97
|
}
|
|
97
98
|
|
|
99
|
+
// One daemon per install. This runs before anything with a side effect
|
|
100
|
+
// (restore, database, pidfile, trigger resume), because a second daemon
|
|
101
|
+
// does damage in each of them — see checkSingleInstance in core/daemon/discovery.ts.
|
|
102
|
+
{
|
|
103
|
+
const { checkSingleInstance, describeRefusal } =
|
|
104
|
+
await import("./core/daemon/discovery.js");
|
|
105
|
+
const verdict = await checkSingleInstance();
|
|
106
|
+
if (!verdict.ok) {
|
|
107
|
+
const message = describeRefusal(verdict.instance);
|
|
108
|
+
logError("bot", message);
|
|
109
|
+
console.error(`talon: ${message}`);
|
|
110
|
+
process.exit(1);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
// Every child spawned from here on names this daemon as its owner, so an
|
|
114
|
+
// orphan sweep can tell a dead daemon's leftovers from a live one's runs.
|
|
115
|
+
stampDaemonOwner();
|
|
116
|
+
|
|
98
117
|
/**
|
|
99
118
|
* A `/backup restore <id>` from chat writes ~/.talon/restore-pending.json
|
|
100
119
|
* and restarts. It is applied HERE, before anything else: every store
|
|
@@ -160,6 +179,17 @@ writePidRecord({ pid: process.pid, startedAt: bootedAt });
|
|
|
160
179
|
// ── Create gateway + frontend ─────────────────────────────────────────────────
|
|
161
180
|
|
|
162
181
|
const gateway = new Gateway("daemon");
|
|
182
|
+
// Fail rather than fall back when the gateway port belongs to another
|
|
183
|
+
// daemon: a fallback port is how a second daemon went unnoticed.
|
|
184
|
+
gateway.setPortInUseCheck(async (port) => {
|
|
185
|
+
const { probeHealth } = await import("./core/daemon/discovery.js");
|
|
186
|
+
const health = await probeHealth(port);
|
|
187
|
+
return health?.app === "talon" &&
|
|
188
|
+
health.mode === "daemon" &&
|
|
189
|
+
health.pid !== process.pid
|
|
190
|
+
? `gateway port ${port} is held by another Talon daemon (pid ${String(health.pid)})`
|
|
191
|
+
: undefined;
|
|
192
|
+
});
|
|
163
193
|
gateway.onStarted((port) =>
|
|
164
194
|
writePidRecord({ pid: process.pid, port, startedAt: bootedAt }),
|
|
165
195
|
);
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
import { readdir, readFile } from "node:fs/promises";
|
|
14
14
|
import { log } from "../../../util/log.js";
|
|
15
|
+
import { childBelongsToLiveDaemon } from "../../../core/daemon/pidfile.js";
|
|
15
16
|
import { AGY_KILL_GRACE_MS } from "../constants.js";
|
|
16
17
|
import { childChatIds, getChild } from "./child.js";
|
|
17
18
|
|
|
@@ -96,7 +97,10 @@ async function findOrphanPids(contextLabel: string): Promise<number[]> {
|
|
|
96
97
|
// SIGKILL, so a chat id that happens to appear inside an
|
|
97
98
|
// unrelated path must not select a victim.
|
|
98
99
|
if (!argv.some((arg) => arg === "agy" || arg.endsWith("/agy"))) continue;
|
|
99
|
-
if (argv.includes(contextLabel))
|
|
100
|
+
if (!argv.includes(contextLabel)) continue;
|
|
101
|
+
// Another running daemon's live run, not an orphan of ours.
|
|
102
|
+
if (childBelongsToLiveDaemon(pid)) continue;
|
|
103
|
+
matched.push(pid);
|
|
100
104
|
} catch {
|
|
101
105
|
// Exited between readdir and readFile, or not ours. Skip.
|
|
102
106
|
continue;
|
|
@@ -22,6 +22,10 @@ import { buildMcpServers, buildPluginMcpServers } from "./options.js";
|
|
|
22
22
|
import { isBackgroundToolContext } from "../../core/agents/context.js";
|
|
23
23
|
import { warnIfBelowCacheMinimum } from "../runtime/cache/cache-telemetry.js";
|
|
24
24
|
import { emitAssistantText } from "../runtime/one-shot-hooks.js";
|
|
25
|
+
import {
|
|
26
|
+
isOtherLiveDaemon,
|
|
27
|
+
ownerFromEnviron,
|
|
28
|
+
} from "../../core/daemon/pidfile.js";
|
|
25
29
|
|
|
26
30
|
const DEFAULT_SUBPROCESS_KILL_GRACE_MS = 5 * 1000;
|
|
27
31
|
|
|
@@ -290,8 +294,10 @@ async function formatAndAppendMessage(
|
|
|
290
294
|
* triggers: the warden respawned them, the next sweep killed them again, and
|
|
291
295
|
* the only visible symptom was a trigger stuck in "errored" with no output.
|
|
292
296
|
*
|
|
293
|
-
*
|
|
297
|
+
* Three independent guards, because this function issues SIGKILL:
|
|
294
298
|
* - refuse anything tagged `TALON_TRIGGER_ID` (a trigger, or its child);
|
|
299
|
+
* - refuse anything spawned by another daemon that is still running
|
|
300
|
+
* (see core/daemon/pidfile.ts). Its "orphans" are that daemon's live runs;
|
|
295
301
|
* - require the argv to actually be the `claude` SDK binary, which is the
|
|
296
302
|
* only thing this sweep was ever meant to reap.
|
|
297
303
|
*/
|
|
@@ -304,6 +310,7 @@ export function isEvictableOrphan(
|
|
|
304
310
|
if (envEntries.some((entry) => entry.startsWith("TALON_TRIGGER_ID="))) {
|
|
305
311
|
return false;
|
|
306
312
|
}
|
|
313
|
+
if (isOtherLiveDaemon(ownerFromEnviron(envEntries))) return false;
|
|
307
314
|
return argv.some((arg) => arg === "claude" || arg.endsWith("/claude"));
|
|
308
315
|
}
|
|
309
316
|
|
|
@@ -19,7 +19,10 @@ import {
|
|
|
19
19
|
type ModelCatalog,
|
|
20
20
|
type UsageTelemetry,
|
|
21
21
|
} from "../../core/agent-runtime/capabilities.js";
|
|
22
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
getAuthFailure as getCodexAuthFailure,
|
|
24
|
+
getPlanUsage as getCodexPlanUsage,
|
|
25
|
+
} from "./plan-usage.js";
|
|
23
26
|
|
|
24
27
|
import { initCodexAgent, getCodexAuthInfo } from "./init.js";
|
|
25
28
|
import { handleMessage as codexHandleMessage } from "./handler/index.js";
|
|
@@ -88,6 +91,7 @@ const codexFactory: BackendFactory = {
|
|
|
88
91
|
// report its rate-limit windows.
|
|
89
92
|
const usage: UsageTelemetry = {
|
|
90
93
|
getPlanUsage: () => getCodexPlanUsage(),
|
|
94
|
+
getAuthFailure: () => getCodexAuthFailure(),
|
|
91
95
|
};
|
|
92
96
|
|
|
93
97
|
const backend = composeBackend({
|
|
@@ -172,6 +172,18 @@ async function load(): Promise<PlanUsage | undefined> {
|
|
|
172
172
|
}
|
|
173
173
|
}
|
|
174
174
|
|
|
175
|
+
/**
|
|
176
|
+
* Why the Codex login is known to be rejected, or `undefined`. Set by a 401
|
|
177
|
+
* from the usage endpoint and cleared once `codex login` rewrites
|
|
178
|
+
* auth.json (seen on the next {@link getPlanUsage} refresh). The router
|
|
179
|
+
* reads it to stop sending work to a backend whose runs can only fail.
|
|
180
|
+
*/
|
|
181
|
+
export function getAuthFailure(): string | undefined {
|
|
182
|
+
return rejectedAuthMtimeMs === undefined
|
|
183
|
+
? undefined
|
|
184
|
+
: "Codex login expired (usage endpoint returned 401) — run `codex login`";
|
|
185
|
+
}
|
|
186
|
+
|
|
175
187
|
/**
|
|
176
188
|
* Plan windows for `/usage`, cached for a minute. A failed refresh keeps
|
|
177
189
|
* serving the last known values — `fetchedAt` lets the caller age them.
|
|
@@ -26,7 +26,10 @@ import {
|
|
|
26
26
|
} from "../../../storage/sessions.js";
|
|
27
27
|
import { log } from "../../../util/log.js";
|
|
28
28
|
import { extractSessionName } from "../../../core/weaver/session-name.js";
|
|
29
|
-
import {
|
|
29
|
+
import {
|
|
30
|
+
recordBackendRunSuccess,
|
|
31
|
+
recordBackendRunUsage,
|
|
32
|
+
} from "../../../core/engine/backend-router/index.js";
|
|
30
33
|
import { traceMessage } from "../../../util/trace.js";
|
|
31
34
|
import {
|
|
32
35
|
FLOW_VIOLATION_MAX_RETRIES,
|
|
@@ -120,6 +123,9 @@ export function accountTurn(inputs: AccountTurnInputs): void {
|
|
|
120
123
|
// a provider with no account API still has a headroom signal. Backends
|
|
121
124
|
// that DO report a plan simply outrank their own ledger.
|
|
122
125
|
recordBackendRunUsage(inputs.backend, usage);
|
|
126
|
+
// A chat turn that completed is proof the backend works: close its
|
|
127
|
+
// breaker so background work may route there again.
|
|
128
|
+
if (!inputs.failed) recordBackendRunSuccess(inputs.backend);
|
|
123
129
|
recordUsage(chatId, {
|
|
124
130
|
...usage,
|
|
125
131
|
durationMs,
|
package/src/bootstrap.ts
CHANGED
|
@@ -10,11 +10,17 @@
|
|
|
10
10
|
* 4. anything else → { kind: "other" } — the caller
|
|
11
11
|
* decides (plugins treat it as an npm spec, skills reject it)
|
|
12
12
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
13
|
+
* A git source may name a commit: `<source>#<sha>` (7-64 hex digits), or
|
|
14
|
+
* `--commit <sha>` on the command line (`withCommit`).
|
|
15
|
+
*
|
|
16
|
+
* Without a commit, cloning uses `--depth=1` (installs never need history).
|
|
17
|
+
* With one, it clones with `--filter=blob:none` (history, but only the
|
|
18
|
+
* blobs of the commit it checks out), checks the commit out and verifies
|
|
19
|
+
* HEAD is that commit. Either way the options end with `--` so a URL can
|
|
20
|
+
* never be read as a git flag, a URL starting with "-" is refused before
|
|
21
|
+
* git runs, and the commit it got is reported so the install can record
|
|
22
|
+
* it. It spawns `git`/`npm` via cross-spawn, which resolves the
|
|
23
|
+
* `.cmd`/`.exe` shims on Windows — never assume a POSIX shell here.
|
|
18
24
|
*/
|
|
19
25
|
|
|
20
26
|
import crossSpawn from "cross-spawn";
|
|
@@ -22,40 +28,84 @@ import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
|
22
28
|
import { tmpdir } from "node:os";
|
|
23
29
|
import { join, resolve } from "node:path";
|
|
24
30
|
|
|
31
|
+
export type GitSource = {
|
|
32
|
+
kind: "git";
|
|
33
|
+
url: string;
|
|
34
|
+
subpath?: string;
|
|
35
|
+
/** Requested commit (lowercase hex, 7-64 digits); unset = default branch. */
|
|
36
|
+
commit?: string;
|
|
37
|
+
};
|
|
38
|
+
|
|
25
39
|
export type ResolvedSource =
|
|
26
|
-
|
|
27
|
-
| { kind: "git"; url: string; subpath?: string }
|
|
28
|
-
| { kind: "other"; raw: string };
|
|
40
|
+
{ kind: "local"; dir: string } | GitSource | { kind: "other"; raw: string };
|
|
29
41
|
|
|
30
42
|
const GIT_URL_RE = /^(https?|git|ssh):\/\//;
|
|
31
43
|
/** `owner/repo` or `owner/repo/sub/path` — never an npm scope (`@…`). */
|
|
32
44
|
const GITHUB_SHORTHAND_RE =
|
|
33
45
|
/^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)((?:\/[^\s/]+)*)$/;
|
|
46
|
+
/** An abbreviated or full commit id (SHA-1 or SHA-256). */
|
|
47
|
+
const COMMIT_RE = /^[0-9a-f]{7,64}$/;
|
|
48
|
+
/** `<source>#<commit>` — only a hex fragment is a pin. */
|
|
49
|
+
const COMMIT_FRAGMENT_RE = /^(.+)#([0-9a-fA-F]{7,64})$/;
|
|
50
|
+
|
|
51
|
+
function gitSource(spec: string): GitSource | undefined {
|
|
52
|
+
if (
|
|
53
|
+
GIT_URL_RE.test(spec) ||
|
|
54
|
+
spec.startsWith("git@") ||
|
|
55
|
+
spec.endsWith(".git")
|
|
56
|
+
) {
|
|
57
|
+
return { kind: "git", url: spec };
|
|
58
|
+
}
|
|
59
|
+
if (spec.startsWith("@")) return undefined;
|
|
60
|
+
const match = GITHUB_SHORTHAND_RE.exec(spec);
|
|
61
|
+
if (!match) return undefined;
|
|
62
|
+
const [, owner, repo, rest] = match;
|
|
63
|
+
return {
|
|
64
|
+
kind: "git",
|
|
65
|
+
url: `https://github.com/${owner}/${repo}.git`,
|
|
66
|
+
...(rest ? { subpath: rest.slice(1) } : {}),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
34
69
|
|
|
35
70
|
export function resolveSource(raw: string): ResolvedSource {
|
|
36
71
|
const trimmed = raw.trim();
|
|
37
72
|
if (existsSync(resolve(trimmed))) {
|
|
38
73
|
return { kind: "local", dir: resolve(trimmed) };
|
|
39
74
|
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
75
|
+
const pinned = COMMIT_FRAGMENT_RE.exec(trimmed);
|
|
76
|
+
if (pinned) {
|
|
77
|
+
const git = gitSource(pinned[1]!);
|
|
78
|
+
if (git) return { ...git, commit: pinned[2]!.toLowerCase() };
|
|
79
|
+
}
|
|
80
|
+
return gitSource(trimmed) ?? { kind: "other", raw: trimmed };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Apply a `--commit <sha>` flag to a resolved source: only git sources
|
|
85
|
+
* take one, and it must agree with a `#<sha>` already in the source.
|
|
86
|
+
*/
|
|
87
|
+
export function withCommit(
|
|
88
|
+
source: ResolvedSource,
|
|
89
|
+
commit: string | undefined,
|
|
90
|
+
): { ok: true; source: ResolvedSource } | { ok: false; error: string } {
|
|
91
|
+
if (commit === undefined) return { ok: true, source };
|
|
92
|
+
const sha = commit.trim().toLowerCase();
|
|
93
|
+
if (!COMMIT_RE.test(sha)) {
|
|
94
|
+
return {
|
|
95
|
+
ok: false,
|
|
96
|
+
error: `"${commit}" is not a commit id (7-64 hex digits)`,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
if (source.kind !== "git") {
|
|
100
|
+
return { ok: false, error: "--commit only applies to git sources" };
|
|
46
101
|
}
|
|
47
|
-
if (
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
kind: "git",
|
|
53
|
-
url: `https://github.com/${owner}/${repo}.git`,
|
|
54
|
-
...(rest ? { subpath: rest.slice(1) } : {}),
|
|
55
|
-
};
|
|
56
|
-
}
|
|
102
|
+
if (source.commit !== undefined && source.commit !== sha) {
|
|
103
|
+
return {
|
|
104
|
+
ok: false,
|
|
105
|
+
error: `--commit ${sha} conflicts with #${source.commit} in the source`,
|
|
106
|
+
};
|
|
57
107
|
}
|
|
58
|
-
return {
|
|
108
|
+
return { ok: true, source: { ...source, commit: sha } };
|
|
59
109
|
}
|
|
60
110
|
|
|
61
111
|
export type CommandOutcome = { ok: true } | { ok: false; error: string };
|
|
@@ -105,16 +155,56 @@ function headCommit(dir: string): string | undefined {
|
|
|
105
155
|
return sha && /^[0-9a-f]{40,64}$/.test(sha) ? sha : undefined;
|
|
106
156
|
}
|
|
107
157
|
|
|
158
|
+
/** Whether the clone already has `commit` (no network). */
|
|
159
|
+
function hasCommit(dir: string, commit: string): boolean {
|
|
160
|
+
return (
|
|
161
|
+
crossSpawn.sync(
|
|
162
|
+
"git",
|
|
163
|
+
["-C", dir, "cat-file", "-e", `${commit}^{commit}`],
|
|
164
|
+
{
|
|
165
|
+
stdio: "ignore",
|
|
166
|
+
},
|
|
167
|
+
).status === 0
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* `--` stops git's option parsing; refusing a leading dash too means a
|
|
173
|
+
* hostile "URL" never even reaches git.
|
|
174
|
+
*/
|
|
175
|
+
function dashRefusal(url: string): CloneOutcome | undefined {
|
|
176
|
+
return url.startsWith("-")
|
|
177
|
+
? { ok: false, error: `Refusing a git URL that starts with "-"` }
|
|
178
|
+
: undefined;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Check out bytes exactly as committed. Windows git defaults to
|
|
183
|
+
* core.autocrlf=true, which rewrites LF to CRLF on checkout — a pinned
|
|
184
|
+
* install would then differ from the commit it names, and CRLF frontmatter
|
|
185
|
+
* fails to parse as a skill. `clone -c` writes this into the new repo's
|
|
186
|
+
* config, so the later `checkout` honours it too.
|
|
187
|
+
*/
|
|
188
|
+
const EXACT_BYTES = ["-c", "core.autocrlf=false"];
|
|
189
|
+
|
|
190
|
+
function tempCloneDir(): { dir: string; cleanup: () => void } {
|
|
191
|
+
const dir = mkdtempSync(join(tmpdir(), "talon-install-"));
|
|
192
|
+
return { dir, cleanup: () => rmSync(dir, { recursive: true, force: true }) };
|
|
193
|
+
}
|
|
194
|
+
|
|
108
195
|
/** Shallow-clone into a fresh temp directory. Caller must run `cleanup`. */
|
|
109
196
|
export function cloneShallow(url: string): CloneOutcome {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
197
|
+
const refused = dashRefusal(url);
|
|
198
|
+
if (refused) return refused;
|
|
199
|
+
const { dir, cleanup } = tempCloneDir();
|
|
200
|
+
const outcome = runTool("git", [
|
|
201
|
+
"clone",
|
|
202
|
+
...EXACT_BYTES,
|
|
203
|
+
"--depth=1",
|
|
204
|
+
"--",
|
|
205
|
+
url,
|
|
206
|
+
dir,
|
|
207
|
+
]);
|
|
118
208
|
if (!outcome.ok) {
|
|
119
209
|
cleanup();
|
|
120
210
|
return { ok: false, error: `Clone failed: ${outcome.error}` };
|
|
@@ -122,13 +212,75 @@ export function cloneShallow(url: string): CloneOutcome {
|
|
|
122
212
|
return { ok: true, dir, commit: headCommit(dir), cleanup };
|
|
123
213
|
}
|
|
124
214
|
|
|
215
|
+
/**
|
|
216
|
+
* Clone and check out exactly `commit`, verifying HEAD is that commit.
|
|
217
|
+
* Caller must run `cleanup`. The clone keeps history (a commit can be
|
|
218
|
+
* anywhere in it) but fetches file contents only for the checked-out tree.
|
|
219
|
+
*/
|
|
220
|
+
export function cloneAtCommit(url: string, commit: string): CloneOutcome {
|
|
221
|
+
const refused = dashRefusal(url);
|
|
222
|
+
if (refused) return refused;
|
|
223
|
+
const { dir, cleanup } = tempCloneDir();
|
|
224
|
+
const fail = (error: string): CloneOutcome => {
|
|
225
|
+
cleanup();
|
|
226
|
+
return { ok: false, error };
|
|
227
|
+
};
|
|
228
|
+
const cloned = runTool("git", [
|
|
229
|
+
"clone",
|
|
230
|
+
...EXACT_BYTES,
|
|
231
|
+
"--filter=blob:none",
|
|
232
|
+
"--no-checkout",
|
|
233
|
+
"--",
|
|
234
|
+
url,
|
|
235
|
+
dir,
|
|
236
|
+
]);
|
|
237
|
+
if (!cloned.ok) return fail(`Clone failed: ${cloned.error}`);
|
|
238
|
+
if (!hasCommit(dir, commit) && commit.length >= 40) {
|
|
239
|
+
// A commit no branch reaches (a PR head, say): ask for it by id.
|
|
240
|
+
runTool("git", ["-C", dir, "fetch", "-q", "origin", commit]);
|
|
241
|
+
}
|
|
242
|
+
const checkout = runTool("git", [
|
|
243
|
+
"-C",
|
|
244
|
+
dir,
|
|
245
|
+
"checkout",
|
|
246
|
+
"-q",
|
|
247
|
+
"--detach",
|
|
248
|
+
commit,
|
|
249
|
+
"--",
|
|
250
|
+
]);
|
|
251
|
+
if (!checkout.ok) {
|
|
252
|
+
return fail(`Commit ${commit} not found in ${url}: ${checkout.error}`);
|
|
253
|
+
}
|
|
254
|
+
const head = headCommit(dir);
|
|
255
|
+
if (!head?.startsWith(commit)) {
|
|
256
|
+
return fail(`Checked out ${head ?? "nothing"}, not commit ${commit}`);
|
|
257
|
+
}
|
|
258
|
+
return { ok: true, dir, commit: head, cleanup };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Clone a git source: at its requested commit, else the default branch. */
|
|
262
|
+
export function cloneSource(source: GitSource): CloneOutcome {
|
|
263
|
+
return source.commit
|
|
264
|
+
? cloneAtCommit(source.url, source.commit)
|
|
265
|
+
: cloneShallow(source.url);
|
|
266
|
+
}
|
|
267
|
+
|
|
125
268
|
/** The file a git-installed plugin keeps its provenance in. */
|
|
126
269
|
const INSTALL_RECORD = ".talon-install.json";
|
|
127
270
|
|
|
128
|
-
/**
|
|
271
|
+
/**
|
|
272
|
+
* Write where an install came from and exactly which commit it is.
|
|
273
|
+
* `pinned` marks a commit the user asked for, rather than whatever the
|
|
274
|
+
* default branch pointed at.
|
|
275
|
+
*/
|
|
129
276
|
export function writeInstallRecord(
|
|
130
277
|
dir: string,
|
|
131
|
-
record: {
|
|
278
|
+
record: {
|
|
279
|
+
source: string;
|
|
280
|
+
subpath?: string;
|
|
281
|
+
commit?: string;
|
|
282
|
+
pinned?: boolean;
|
|
283
|
+
},
|
|
132
284
|
): void {
|
|
133
285
|
writeFileSync(
|
|
134
286
|
join(dir, INSTALL_RECORD),
|
package/src/cli/plugin.ts
CHANGED
|
@@ -12,7 +12,9 @@
|
|
|
12
12
|
* Install sources (see cli/install-sources.ts for the shared grammar):
|
|
13
13
|
* local path and git checkouts become module entries under ~/.talon/plugins;
|
|
14
14
|
* an npm spec installs there too, or registers an `npx` MCP entry with
|
|
15
|
-
* `--mcp`.
|
|
15
|
+
* `--mcp`. A cloned source installs at a given commit with `#<sha>` or
|
|
16
|
+
* `--commit <sha>`. Windows-safe throughout — tools are spawned via
|
|
17
|
+
* cross-spawn.
|
|
16
18
|
*/
|
|
17
19
|
|
|
18
20
|
import pc from "picocolors";
|
|
@@ -24,11 +26,12 @@ import { findRunningInstance } from "../core/daemon/discovery.js";
|
|
|
24
26
|
import { fetchGateway } from "./daemon-api.js";
|
|
25
27
|
import { loadConfig, saveConfig, type Config } from "./config.js";
|
|
26
28
|
import {
|
|
27
|
-
|
|
29
|
+
cloneSource,
|
|
28
30
|
writeInstallRecord,
|
|
29
31
|
resolveSource,
|
|
32
|
+
withCommit,
|
|
30
33
|
runTool,
|
|
31
|
-
type
|
|
34
|
+
type GitSource,
|
|
32
35
|
} from "./install-sources.js";
|
|
33
36
|
import {
|
|
34
37
|
BUILTIN_PLUGINS,
|
|
@@ -50,7 +53,8 @@ const USAGE = [
|
|
|
50
53
|
" Commands:",
|
|
51
54
|
` ${pc.cyan("list")} Show built-ins and configured plugins`,
|
|
52
55
|
` ${pc.cyan("install <source>")} Add a plugin (local path, git URL,`,
|
|
53
|
-
" owner/repo, or npm spec)",
|
|
56
|
+
" owner/repo, or npm spec); a git source",
|
|
57
|
+
" may end in #<commit>",
|
|
54
58
|
` ${pc.cyan("enable <name>")} Enable a plugin`,
|
|
55
59
|
` ${pc.cyan("disable <name>")} Disable a plugin (kept in config)`,
|
|
56
60
|
` ${pc.cyan("remove <name>")} Remove a plugin entry (and its install)`,
|
|
@@ -60,6 +64,7 @@ const USAGE = [
|
|
|
60
64
|
" MCP server (npx) instead of a module",
|
|
61
65
|
` ${pc.cyan("--name <name>")} Override the derived plugin name`,
|
|
62
66
|
` ${pc.cyan("--force")} Replace an existing install/entry`,
|
|
67
|
+
` ${pc.cyan("--commit <sha>")} Install a git source at this commit`,
|
|
63
68
|
"",
|
|
64
69
|
].join("\n");
|
|
65
70
|
|
|
@@ -184,7 +189,12 @@ function cmdList(): void {
|
|
|
184
189
|
|
|
185
190
|
// ── install ─────────────────────────────────────────────────────────────────
|
|
186
191
|
|
|
187
|
-
type InstallFlags = {
|
|
192
|
+
type InstallFlags = {
|
|
193
|
+
mcp: boolean;
|
|
194
|
+
force: boolean;
|
|
195
|
+
name?: string;
|
|
196
|
+
commit?: string;
|
|
197
|
+
};
|
|
188
198
|
|
|
189
199
|
function parseInstallArgs(
|
|
190
200
|
args: string[],
|
|
@@ -196,10 +206,12 @@ function parseInstallArgs(
|
|
|
196
206
|
if (arg === "--mcp") flags.mcp = true;
|
|
197
207
|
else if (arg === "--force") flags.force = true;
|
|
198
208
|
else if (arg === "--name") flags.name = args[++i];
|
|
209
|
+
else if (arg === "--commit") flags.commit = args[++i];
|
|
199
210
|
else if (!arg.startsWith("-") && source === undefined) source = arg;
|
|
200
211
|
else return null;
|
|
201
212
|
}
|
|
202
213
|
if (!source || (flags.name !== undefined && !flags.name)) return null;
|
|
214
|
+
if (flags.commit !== undefined && !flags.commit) return null;
|
|
203
215
|
return { source, flags };
|
|
204
216
|
}
|
|
205
217
|
|
|
@@ -229,10 +241,7 @@ function installFromLocalDir(dir: string): EntryOutcome {
|
|
|
229
241
|
* once the staged copy is known-good, so a failed install never destroys
|
|
230
242
|
* a working one.
|
|
231
243
|
*/
|
|
232
|
-
function installFromGit(
|
|
233
|
-
source: Extract<ResolvedSource, { kind: "git" }>,
|
|
234
|
-
flags: InstallFlags,
|
|
235
|
-
): EntryOutcome {
|
|
244
|
+
function installFromGit(source: GitSource, flags: InstallFlags): EntryOutcome {
|
|
236
245
|
const derived = source.subpath
|
|
237
246
|
? basename(source.subpath)
|
|
238
247
|
: basename(source.url, ".git");
|
|
@@ -245,7 +254,7 @@ function installFromGit(
|
|
|
245
254
|
};
|
|
246
255
|
}
|
|
247
256
|
|
|
248
|
-
const clone =
|
|
257
|
+
const clone = cloneSource(source);
|
|
249
258
|
if (!clone.ok) return { ok: false, error: clone.error };
|
|
250
259
|
try {
|
|
251
260
|
const stage = source.subpath
|
|
@@ -265,6 +274,7 @@ function installFromGit(
|
|
|
265
274
|
source: source.url,
|
|
266
275
|
...(source.subpath ? { subpath: source.subpath } : {}),
|
|
267
276
|
...(clone.commit ? { commit: clone.commit } : {}),
|
|
277
|
+
...(source.commit ? { pinned: true } : {}),
|
|
268
278
|
});
|
|
269
279
|
if (clone.commit) {
|
|
270
280
|
console.log(` ${pc.dim(`Commit ${clone.commit}`)}`);
|
|
@@ -338,7 +348,13 @@ async function cmdInstall(args: string[]): Promise<void> {
|
|
|
338
348
|
}
|
|
339
349
|
const { source, flags } = parsed;
|
|
340
350
|
|
|
341
|
-
const
|
|
351
|
+
const pinned = withCommit(resolveSource(source), flags.commit);
|
|
352
|
+
if (!pinned.ok) {
|
|
353
|
+
fail(pinned.error);
|
|
354
|
+
process.exitCode = 1;
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
const resolved = pinned.source;
|
|
342
358
|
let outcome: EntryOutcome;
|
|
343
359
|
switch (resolved.kind) {
|
|
344
360
|
case "local":
|