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.
Files changed (80) hide show
  1. package/README.md +8 -2
  2. package/package.json +1 -1
  3. package/src/app.ts +30 -0
  4. package/src/backend/agy/process/orphans.ts +5 -1
  5. package/src/backend/claude-sdk/one-shot.ts +8 -1
  6. package/src/backend/codex/factory.ts +5 -1
  7. package/src/backend/codex/plan-usage.ts +12 -0
  8. package/src/backend/runtime/turn/turn-phases.ts +7 -1
  9. package/src/bootstrap.ts +1 -0
  10. package/src/cli/install-sources.ts +187 -35
  11. package/src/cli/plugin.ts +27 -11
  12. package/src/cli/skill.ts +60 -7
  13. package/src/cli.ts +18 -12
  14. package/src/core/agent-runtime/capabilities.ts +7 -0
  15. package/src/core/agents/runner.ts +4 -0
  16. package/src/core/background/cron/job-oneshot.ts +7 -1
  17. package/src/core/background/cron/scheduler.ts +28 -14
  18. package/src/core/background/cron/spec.ts +23 -3
  19. package/src/core/background/heartbeat/agent.ts +6 -0
  20. package/src/core/background/triggers/resume.ts +19 -4
  21. package/src/core/background/triggers/spawn.ts +1 -1
  22. package/src/core/config/index.ts +88 -4
  23. package/src/core/daemon/discovery.ts +160 -0
  24. package/src/core/daemon/pidfile.ts +107 -0
  25. package/src/core/daemon/respawn.ts +4 -1
  26. package/src/core/engine/backend-router/breaker.ts +158 -0
  27. package/src/core/engine/backend-router/headroom.ts +90 -22
  28. package/src/core/engine/backend-router/index.ts +11 -0
  29. package/src/core/engine/gateway-actions/cron.ts +5 -0
  30. package/src/core/engine/gateway-actions/fetch-url/guard.ts +13 -44
  31. package/src/core/engine/gateway-actions/fetch-url/index.ts +23 -43
  32. package/src/core/engine/gateway-actions/mesh.ts +4 -0
  33. package/src/core/engine/gateway-actions/models.ts +19 -4
  34. package/src/core/engine/gateway-routes.ts +31 -2
  35. package/src/core/engine/gateway.ts +11 -1
  36. package/src/core/fetch/classify.ts +73 -0
  37. package/src/core/fetch/curl-impersonate.ts +447 -0
  38. package/src/core/fetch/errors.ts +14 -0
  39. package/src/core/fetch/index.ts +133 -0
  40. package/src/core/fetch/ladder.ts +401 -0
  41. package/src/core/fetch/rungs.ts +319 -0
  42. package/src/core/fetch/types.ts +105 -0
  43. package/src/core/frontend-runtime/admin-notify.ts +100 -12
  44. package/src/core/mcp-hub/child-guard.ts +215 -0
  45. package/src/core/mcp-hub/child-transport.ts +89 -39
  46. package/src/core/mcp-hub/children.ts +65 -9
  47. package/src/core/mcp-hub/guest-scope.ts +3 -2
  48. package/src/core/mcp-hub/index.ts +36 -16
  49. package/src/core/mcp-hub/launcher.ts +81 -37
  50. package/src/core/mcp-hub/proxy-server.ts +12 -8
  51. package/src/core/mcp-hub/reaper.ts +120 -0
  52. package/src/core/mesh/credentials/index.ts +2 -2
  53. package/src/core/mesh/credentials/store.ts +1 -1
  54. package/src/core/mesh/devices/service.ts +8 -0
  55. package/src/core/mesh/links/bridge-links.ts +37 -0
  56. package/src/core/mesh/links/companion-pairing.ts +2 -2
  57. package/src/core/plugin/mcp.ts +8 -8
  58. package/src/core/tools/bridge.ts +3 -0
  59. package/src/core/tools/content/web.ts +1 -1
  60. package/src/core/tools/ops/mesh.ts +21 -0
  61. package/src/core/tools/ops/scheduling.ts +15 -0
  62. package/src/frontend/native/bridge/auth-guard.ts +117 -53
  63. package/src/frontend/native/bridge/credentials/claims.ts +11 -1
  64. package/src/frontend/native/bridge/credentials/principal.ts +13 -1
  65. package/src/frontend/native/bridge/routes/table.ts +13 -0
  66. package/src/frontend/native/bridge/server.ts +37 -11
  67. package/src/frontend/telegram/actions/media.ts +164 -12
  68. package/src/index.ts +22 -19
  69. package/src/plugins/playwright/version-coupling.ts +172 -62
  70. package/src/storage/cron.ts +34 -2
  71. package/src/storage/db.ts +1 -0
  72. package/src/storage/repositories/cron-repo.ts +9 -0
  73. package/src/storage/skills.ts +7 -1
  74. package/src/storage/sql/cron.sql +5 -5
  75. package/src/storage/sql/db.sql +5 -0
  76. package/src/storage/sql/schema.sql +2 -1
  77. package/src/storage/sql/statements.generated.ts +10 -6
  78. package/src/util/log.ts +2 -1
  79. package/src/util/paths.ts +2 -0
  80. 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`. Disabling keeps the entry (or a
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "talon-agent",
3
- "version": "5.23.1",
3
+ "version": "5.24.1",
4
4
  "description": "Multi-frontend AI agent with full tool access, streaming, cron jobs, and plugin system",
5
5
  "author": "The Falconry",
6
6
  "license": "Apache-2.0",
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)) matched.push(pid);
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
- * Two independent guards, because this function issues SIGKILL:
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 { getPlanUsage as getCodexPlanUsage } from "./plan-usage.js";
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 { recordBackendRunUsage } from "../../../core/engine/backend-router/index.js";
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
@@ -155,6 +155,7 @@ export async function bootstrap(
155
155
  ...(config.operatorIds ?? []),
156
156
  ...(config.discord?.adminUserIds ?? []).map((id) => `discord:${id}`),
157
157
  ],
158
+ guardChildren: true,
158
159
  });
159
160
 
160
161
  initWorkspace(config.workspace);
@@ -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
- * Cloning always uses `--depth=1` (installs never need history), ends the
14
- * options with `--` so a URL can never be read as a git flag, and reports
15
- * the commit it got so the install can be pinned/audited later. It spawns
16
- * `git`/`npm` via cross-spawn, which resolves the `.cmd`/`.exe` shims on
17
- * Windows — never assume a POSIX shell here.
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
- | { kind: "local"; dir: string }
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
- if (
41
- GIT_URL_RE.test(trimmed) ||
42
- trimmed.startsWith("git@") ||
43
- trimmed.endsWith(".git")
44
- ) {
45
- return { kind: "git", url: trimmed };
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 (!trimmed.startsWith("@")) {
48
- const match = GITHUB_SHORTHAND_RE.exec(trimmed);
49
- if (match) {
50
- const [, owner, repo, rest] = match;
51
- return {
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 { kind: "other", raw: trimmed };
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
- // `--` below already stops option parsing; refusing a leading dash too
111
- // means a hostile "URL" never even reaches git.
112
- if (url.startsWith("-")) {
113
- return { ok: false, error: `Refusing a git URL that starts with "-"` };
114
- }
115
- const dir = mkdtempSync(join(tmpdir(), "talon-install-"));
116
- const cleanup = () => rmSync(dir, { recursive: true, force: true });
117
- const outcome = runTool("git", ["clone", "--depth=1", "--", url, dir]);
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
- /** Write where an install came from and exactly which commit it is. */
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: { source: string; subpath?: string; commit?: string },
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`. Windows-safe throughout — tools are spawned via cross-spawn.
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
- cloneShallow,
29
+ cloneSource,
28
30
  writeInstallRecord,
29
31
  resolveSource,
32
+ withCommit,
30
33
  runTool,
31
- type ResolvedSource,
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 = { mcp: boolean; force: boolean; name?: string };
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 = cloneShallow(source.url);
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 resolved = resolveSource(source);
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":