talon-agent 5.14.0 → 5.18.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 (112) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +8 -5
  3. package/package.json +3 -3
  4. package/prompts/system/heartbeat-agent.md +1 -1
  5. package/src/backend/agy/factory.ts +3 -0
  6. package/src/backend/agy/mcp/config.ts +14 -2
  7. package/src/backend/claude-sdk/factory.ts +3 -0
  8. package/src/backend/claude-sdk/options.ts +24 -3
  9. package/src/backend/codex/factory.ts +3 -0
  10. package/src/backend/codex/init.ts +4 -0
  11. package/src/backend/codex/mcp-config.ts +10 -0
  12. package/src/backend/codex/oauth-incompat.ts +1 -1
  13. package/src/backend/codex/token-usage.ts +2 -2
  14. package/src/backend/openai-agents/factory.ts +3 -0
  15. package/src/backend/openai-agents/mcp-pool.ts +4 -0
  16. package/src/backend/remote-server/factory.ts +3 -0
  17. package/src/backend/remote-server/mcp.ts +3 -0
  18. package/src/backend/runtime/prompt/prompt-format.ts +3 -3
  19. package/src/bootstrap.ts +8 -0
  20. package/src/cli/commands/backup.ts +61 -5
  21. package/src/cli/commands/mesh.ts +133 -0
  22. package/src/cli/config.ts +3 -1
  23. package/src/cli/daemon-api.ts +22 -0
  24. package/src/cli/index.ts +6 -0
  25. package/src/cli/install-sources.ts +40 -5
  26. package/src/cli/plugin.ts +10 -0
  27. package/src/cli/setup.ts +45 -4
  28. package/src/cli/skill.ts +3 -0
  29. package/src/core/agent-runtime/backend-registry.ts +16 -0
  30. package/src/core/backup/archive/crypt.ts +429 -0
  31. package/src/core/backup/archive/manifest-auth.ts +98 -0
  32. package/src/core/backup/passphrase.ts +130 -0
  33. package/src/core/backup/plan.ts +66 -13
  34. package/src/core/backup/restore-guard.ts +101 -0
  35. package/src/core/backup/restore.ts +249 -32
  36. package/src/core/backup/snapshot.ts +418 -60
  37. package/src/core/backup/sources/plugins.ts +223 -0
  38. package/src/core/backup/sources/relocate.ts +136 -0
  39. package/src/core/backup/sources/sessions.ts +198 -0
  40. package/src/core/backup/store.ts +3 -1
  41. package/src/core/backup/types.ts +66 -0
  42. package/src/core/backup/upload.ts +66 -6
  43. package/src/core/config/index.ts +140 -8
  44. package/src/core/daemon/control.ts +9 -0
  45. package/src/core/daemon/discovery.ts +7 -0
  46. package/src/core/engine/backend-router/headroom.ts +29 -5
  47. package/src/core/engine/backend-router/usage.ts +6 -2
  48. package/src/core/engine/gateway-actions/fetch-url/guard.ts +201 -0
  49. package/src/core/engine/gateway-actions/{fetch-url.ts → fetch-url/index.ts} +60 -32
  50. package/src/core/engine/gateway-actions/index.ts +4 -2
  51. package/src/core/engine/gateway-actions/native/index.ts +24 -0
  52. package/src/core/engine/gateway-actions/whatsapp-account.ts +1 -1
  53. package/src/core/engine/gateway-auth.ts +164 -0
  54. package/src/core/engine/gateway-routes.ts +100 -5
  55. package/src/core/engine/gateway.ts +12 -4
  56. package/src/core/mcp-hub/guest-scope.ts +170 -29
  57. package/src/core/mcp-hub/index.ts +33 -16
  58. package/src/core/mcp-hub/talon-server.ts +71 -14
  59. package/src/core/mesh/credentials/admin.ts +146 -0
  60. package/src/core/mesh/credentials/index.ts +19 -0
  61. package/src/core/mesh/credentials/store.ts +443 -0
  62. package/src/core/mesh/credentials/token.ts +45 -0
  63. package/src/core/mesh/credentials/types.ts +83 -0
  64. package/src/core/mesh/devices/service.ts +58 -6
  65. package/src/core/mesh/links/bridge-links.ts +46 -4
  66. package/src/core/mesh/links/node-binaries.ts +1 -1
  67. package/src/core/mesh/links/node-provision.ts +8 -1
  68. package/src/core/models/active-model.ts +1 -1
  69. package/src/core/plugin/loader.ts +4 -0
  70. package/src/core/plugin/mcp.ts +4 -0
  71. package/src/core/tools/bridge.ts +2 -1
  72. package/src/core/types.ts +13 -0
  73. package/src/core/weaver/weaver.ts +47 -15
  74. package/src/frontend/discord/callbacks/components/agent-buttons.ts +1 -0
  75. package/src/frontend/discord/handlers/delivery.ts +3 -0
  76. package/src/frontend/discord/handlers/queue.ts +1 -0
  77. package/src/frontend/native/bridge/auth-guard.ts +277 -0
  78. package/src/frontend/native/bridge/auth.ts +84 -1
  79. package/src/frontend/native/bridge/credentials/claims.ts +51 -0
  80. package/src/frontend/native/bridge/credentials/principal.ts +200 -0
  81. package/src/frontend/native/bridge/credentials/upgrade.ts +129 -0
  82. package/src/frontend/native/bridge/routes/auth.ts +37 -0
  83. package/src/frontend/native/bridge/routes/chats.ts +20 -2
  84. package/src/frontend/native/bridge/routes/host.ts +11 -1
  85. package/src/frontend/native/bridge/routes/index.ts +2 -0
  86. package/src/frontend/native/bridge/routes/mesh.ts +72 -15
  87. package/src/frontend/native/bridge/routes/table.ts +73 -51
  88. package/src/frontend/native/bridge/server.ts +266 -83
  89. package/src/frontend/native/index.ts +54 -3
  90. package/src/frontend/native/turn/turn.ts +2 -0
  91. package/src/frontend/teams/turn.ts +1 -0
  92. package/src/frontend/telegram/admin.ts +20 -0
  93. package/src/frontend/telegram/commands/admin.ts +1 -1
  94. package/src/frontend/telegram/commands/state.ts +7 -9
  95. package/src/frontend/telegram/handlers/access.ts +31 -10
  96. package/src/frontend/telegram/handlers/delivery.ts +14 -1
  97. package/src/frontend/telegram/handlers/group-access.ts +50 -0
  98. package/src/frontend/telegram/handlers/messages.ts +1 -0
  99. package/src/frontend/telegram/handlers/queue.ts +14 -0
  100. package/src/frontend/telegram/handlers/state.ts +2 -2
  101. package/src/frontend/telegram/index.ts +32 -9
  102. package/src/frontend/telegram/middleware.ts +2 -2
  103. package/src/frontend/telegram/polling/poll-deadline.ts +52 -0
  104. package/src/frontend/telegram/{stale-command.ts → polling/stale-command.ts} +1 -1
  105. package/src/frontend/telegram/{update-offset.ts → polling/update-offset.ts} +1 -1
  106. package/src/frontend/telegram/userbot.ts +56 -5
  107. package/src/frontend/terminal/index.ts +8 -1
  108. package/src/frontend/whatsapp/commands.ts +3 -3
  109. package/src/frontend/whatsapp/messages/inbound.ts +1 -0
  110. package/src/plugins/playwright/index.ts +1 -1
  111. package/src/storage/backup/index.ts +1 -1
  112. package/src/storage/db.ts +23 -0
@@ -0,0 +1,133 @@
1
+ /**
2
+ * `talon mesh` — per-device credentials from the outside.
3
+ *
4
+ * talon mesh [list] every credential + devices still on
5
+ * the shared token
6
+ * talon mesh revoke <device|credId> revoke now; live sessions drop
7
+ * talon mesh rotate <device> the device swaps credentials on its
8
+ * next heartbeat (old one: 7-day grace)
9
+ * talon mesh scopes <device> <list> e.g. device,client,operator
10
+ *
11
+ * Everything goes through the running daemon's loopback gateway
12
+ * (core/engine/gateway-routes.ts → core/mesh/credentials/admin.ts): the
13
+ * daemon owns the store and the live sessions a revocation must drop, so
14
+ * editing the file behind its back could never take effect in time.
15
+ */
16
+
17
+ import pc from "picocolors";
18
+ import { fetchGateway, requireGatewayPort } from "../daemon-api.js";
19
+ import type { DeviceCredential } from "../../core/mesh/credentials/index.js";
20
+
21
+ const USAGE = `
22
+ ${pc.bold("talon mesh")} — per-device mesh credentials
23
+
24
+ ${pc.cyan("list")} credentials, scopes, last use (default)
25
+ ${pc.cyan("revoke")} <device|credential-id> revoke now; drops its live sessions
26
+ ${pc.cyan("rotate")} <device> re-issue on the device's next heartbeat
27
+ ${pc.cyan("scopes")} <device> <scope,scope> set scopes: device, client, operator
28
+ `;
29
+
30
+ type Overview = {
31
+ ok: boolean;
32
+ error?: string;
33
+ credentials?: DeviceCredential[];
34
+ legacyDevices?: { deviceId: string; lastSeen: number }[];
35
+ legacySharedToken?: boolean;
36
+ };
37
+
38
+ type AdminReply = { ok: boolean; text?: string; error?: string };
39
+
40
+ function ago(ts: number | undefined, now: number): string {
41
+ if (ts === undefined) return "never";
42
+ const s = Math.max(0, Math.round((now - ts) / 1000));
43
+ if (s < 90) return `${s}s ago`;
44
+ if (s < 5400) return `${Math.round(s / 60)}m ago`;
45
+ if (s < 172_800) return `${Math.round(s / 3600)}h ago`;
46
+ return `${Math.round(s / 86_400)}d ago`;
47
+ }
48
+
49
+ function stateOf(c: DeviceCredential, now: number): string {
50
+ if (c.revokedAt !== undefined) return pc.dim(`revoked (${c.revokeReason})`);
51
+ if (c.expiresAt !== undefined && now >= c.expiresAt) return pc.dim("expired");
52
+ if (c.supersededBy) return pc.yellow(`superseded by ${c.supersededBy}`);
53
+ if (c.rotateRequestedAt !== undefined) return pc.yellow("rotation pending");
54
+ if (c.deviceId === null) return pc.yellow("unbound (awaiting first use)");
55
+ return pc.green("active");
56
+ }
57
+
58
+ function renderOverview(o: Overview): void {
59
+ const now = Date.now();
60
+ const creds = o.credentials ?? [];
61
+ console.log(`\n ${pc.bold("Mesh credentials")}\n`);
62
+ if (creds.length === 0) console.log(` ${pc.dim("(none issued yet)")}`);
63
+ for (const c of creds) {
64
+ console.log(
65
+ ` ${pc.cyan(c.id)} ${c.deviceId ?? pc.dim("—")} [${c.scopes.join(", ")}] ${stateOf(c, now)}`,
66
+ );
67
+ console.log(
68
+ pc.dim(
69
+ ` via ${c.origin}, issued ${ago(c.createdAt, now)}, last used ${ago(c.lastUsedAt, now)}`,
70
+ ),
71
+ );
72
+ }
73
+ const legacy = o.legacyDevices ?? [];
74
+ if (legacy.length > 0) {
75
+ console.log(`\n ${pc.yellow("Still on the shared native.token:")}`);
76
+ for (const d of legacy) {
77
+ console.log(` ${d.deviceId} ${pc.dim(`seen ${ago(d.lastSeen, now)}`)}`);
78
+ }
79
+ }
80
+ console.log(
81
+ `\n Legacy shared token from remote clients: ${
82
+ o.legacySharedToken ? pc.yellow("accepted") : pc.green("refused")
83
+ }\n`,
84
+ );
85
+ }
86
+
87
+ async function adminOp(
88
+ port: number,
89
+ body: Record<string, unknown>,
90
+ ): Promise<void> {
91
+ const reply = (await fetchGateway(port, "/mesh/credentials", {
92
+ method: "POST",
93
+ headers: { "Content-Type": "application/json" },
94
+ body: JSON.stringify(body),
95
+ })) as AdminReply;
96
+ if (reply.ok) {
97
+ console.log(`\n ${pc.green("✔")} ${reply.text}\n`);
98
+ } else {
99
+ console.error(`\n ${pc.red("✖")} ${reply.error}\n`);
100
+ process.exitCode = 1;
101
+ }
102
+ }
103
+
104
+ export async function runMeshCommand(args: string[]): Promise<void> {
105
+ const [sub = "list", target, scopes] = args;
106
+ if (sub === "help" || sub === "--help" || sub === "-h") {
107
+ console.log(USAGE);
108
+ return;
109
+ }
110
+ const known = ["list", "revoke", "rotate", "scopes"];
111
+ if (!known.includes(sub)) {
112
+ console.error(` Unknown mesh command: ${sub}\n${USAGE}`);
113
+ process.exitCode = 1;
114
+ return;
115
+ }
116
+ if (sub !== "list" && !target) {
117
+ console.error(
118
+ ` ${pc.red("✖")} talon mesh ${sub} needs a device.\n${USAGE}`,
119
+ );
120
+ process.exitCode = 1;
121
+ return;
122
+ }
123
+ const port = await requireGatewayPort();
124
+ if (port === null) {
125
+ process.exitCode = 1;
126
+ return;
127
+ }
128
+ if (sub === "list") {
129
+ renderOverview((await fetchGateway(port, "/mesh/credentials")) as Overview);
130
+ return;
131
+ }
132
+ await adminOp(port, { op: sub, device: target, scopes });
133
+ }
package/src/cli/config.ts CHANGED
@@ -46,6 +46,8 @@ export type Config = {
46
46
  pulse: boolean;
47
47
  pulseIntervalMs: number;
48
48
  adminUserId?: number;
49
+ /** Telegram DM allowlist; defaults to `[adminUserId]` when set up. */
50
+ allowedUsers?: number[];
49
51
  apiId?: number;
50
52
  apiHash?: string;
51
53
  maxMessageLength: number;
@@ -222,7 +224,7 @@ export function isConfigured(config: Config): boolean {
222
224
  ? config.frontend
223
225
  : [config.frontend];
224
226
  return fes.every((fe) => {
225
- if (fe === "telegram") return !!config.botToken;
227
+ if (fe === "telegram") return !!config.botToken && !!config.adminUserId;
226
228
  if (fe === "teams") return !!config.teamsWebhookUrl;
227
229
  if (fe === "discord") return !!config.discord?.botToken;
228
230
  // WhatsApp's credentials live in ~/.talon/whatsapp-auth/, not the
@@ -6,19 +6,41 @@
6
6
 
7
7
  import pc from "picocolors";
8
8
  import { findRunningInstance } from "../core/daemon/discovery.js";
9
+ import {
10
+ gatewayAuthHeaders,
11
+ gatewayTokenPath,
12
+ readGatewayToken,
13
+ } from "../core/engine/gateway-auth.js";
9
14
 
10
15
  const REQUEST_TIMEOUT_MS = 3_000;
11
16
 
17
+ /**
18
+ * Call a gateway endpoint as the operator: every request carries the gateway
19
+ * token (read from ~/.talon/keys/gateway-token), and a POST always declares a
20
+ * JSON body — the gateway refuses anything else.
21
+ */
12
22
  export async function fetchGateway(
13
23
  port: number,
14
24
  path: string,
15
25
  init?: RequestInit,
16
26
  timeoutMs: number = REQUEST_TIMEOUT_MS,
17
27
  ): Promise<unknown> {
28
+ const isPost = init?.method === "POST";
18
29
  const response = await fetch(`http://127.0.0.1:${port}${path}`, {
19
30
  ...init,
31
+ ...(isPost && init?.body === undefined ? { body: "{}" } : {}),
32
+ headers: {
33
+ ...(isPost ? { "Content-Type": "application/json" } : {}),
34
+ ...(init?.headers as Record<string, string> | undefined),
35
+ ...gatewayAuthHeaders(readGatewayToken()),
36
+ },
20
37
  signal: AbortSignal.timeout(timeoutMs),
21
38
  });
39
+ if (response.status === 401) {
40
+ throw new Error(
41
+ `gateway refused the request (401) — is ${gatewayTokenPath()} readable by this user?`,
42
+ );
43
+ }
22
44
  if (!response.ok) throw new Error(`gateway answered ${response.status}`);
23
45
  return response.json();
24
46
  }
package/src/cli/index.ts CHANGED
@@ -39,6 +39,7 @@ import { runSkillCommand } from "./skill.js";
39
39
  import { runMemoryCommand } from "./memory.js";
40
40
  import { mainMenu } from "./menu.js";
41
41
  import { runBackupCommand } from "./commands/backup.js";
42
+ import { runMeshCommand } from "./commands/mesh.js";
42
43
 
43
44
  export * from "./context.js";
44
45
  export * from "./config.js";
@@ -62,6 +63,7 @@ const CLI_COMMANDS = [
62
63
  "skill",
63
64
  "memory",
64
65
  "backup",
66
+ "mesh",
65
67
  ];
66
68
 
67
69
  /** `talon events [-f] [--history [N]]` → the tail options. */
@@ -110,6 +112,9 @@ function printHelp(): void {
110
112
  console.log(
111
113
  ` ${pc.cyan("backup")} Snapshots and checkpoints (now/list/show/pin/restore)`,
112
114
  );
115
+ console.log(
116
+ ` ${pc.cyan("mesh")} Device credentials (list/revoke/rotate/scopes)`,
117
+ );
113
118
  console.log(` ${pc.cyan("config")} View/edit configuration`);
114
119
  console.log(` ${pc.cyan("logs")} Tail log file`);
115
120
  console.log(` ${pc.cyan("doctor")} Validate environment`);
@@ -173,6 +178,7 @@ const COMMANDS: Record<string, CommandHandler> = {
173
178
  kill: (args) => killTask(args[0]),
174
179
  events: (args) => showEvents(eventsOptions(args)),
175
180
  backup: (args) => runBackupCommand(args),
181
+ mesh: (args) => runMeshCommand(args),
176
182
  plugin: (args) => runPluginCommand(args),
177
183
  skill: (args) => runSkillCommand(args),
178
184
  memory: (args) => runMemoryCommand(args),
@@ -10,13 +10,15 @@
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) and spawns
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
14
16
  * `git`/`npm` via cross-spawn, which resolves the `.cmd`/`.exe` shims on
15
17
  * Windows — never assume a POSIX shell here.
16
18
  */
17
19
 
18
20
  import crossSpawn from "cross-spawn";
19
- import { existsSync, mkdtempSync, rmSync } from "node:fs";
21
+ import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
20
22
  import { tmpdir } from "node:os";
21
23
  import { join, resolve } from "node:path";
22
24
 
@@ -91,16 +93,49 @@ export function runTool(
91
93
  }
92
94
 
93
95
  export type CloneOutcome =
94
- { ok: true; dir: string; cleanup: () => void } | { ok: false; error: string };
96
+ | { ok: true; dir: string; commit?: string; cleanup: () => void }
97
+ | { ok: false; error: string };
98
+
99
+ /** The checked-out commit of a clone, when git can tell us. */
100
+ function headCommit(dir: string): string | undefined {
101
+ const result = crossSpawn.sync("git", ["-C", dir, "rev-parse", "HEAD"], {
102
+ stdio: ["ignore", "pipe", "ignore"],
103
+ });
104
+ const sha = result.status === 0 ? result.stdout?.toString().trim() : "";
105
+ return sha && /^[0-9a-f]{40,64}$/.test(sha) ? sha : undefined;
106
+ }
95
107
 
96
108
  /** Shallow-clone into a fresh temp directory. Caller must run `cleanup`. */
97
109
  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
+ }
98
115
  const dir = mkdtempSync(join(tmpdir(), "talon-install-"));
99
116
  const cleanup = () => rmSync(dir, { recursive: true, force: true });
100
- const outcome = runTool("git", ["clone", "--depth=1", url, dir]);
117
+ const outcome = runTool("git", ["clone", "--depth=1", "--", url, dir]);
101
118
  if (!outcome.ok) {
102
119
  cleanup();
103
120
  return { ok: false, error: `Clone failed: ${outcome.error}` };
104
121
  }
105
- return { ok: true, dir, cleanup };
122
+ return { ok: true, dir, commit: headCommit(dir), cleanup };
123
+ }
124
+
125
+ /** The file a git-installed plugin keeps its provenance in. */
126
+ const INSTALL_RECORD = ".talon-install.json";
127
+
128
+ /** Write where an install came from and exactly which commit it is. */
129
+ export function writeInstallRecord(
130
+ dir: string,
131
+ record: { source: string; subpath?: string; commit?: string },
132
+ ): void {
133
+ writeFileSync(
134
+ join(dir, INSTALL_RECORD),
135
+ JSON.stringify(
136
+ { ...record, installedAt: new Date().toISOString() },
137
+ null,
138
+ 2,
139
+ ) + "\n",
140
+ );
106
141
  }
package/src/cli/plugin.ts CHANGED
@@ -25,6 +25,7 @@ import { fetchGateway } from "./daemon-api.js";
25
25
  import { loadConfig, saveConfig, type Config } from "./config.js";
26
26
  import {
27
27
  cloneShallow,
28
+ writeInstallRecord,
28
29
  resolveSource,
29
30
  runTool,
30
31
  type ResolvedSource,
@@ -258,7 +259,16 @@ function installFromGit(
258
259
  }
259
260
  // An install is a snapshot, not a checkout — updates go through
260
261
  // `install --force`, so the clone's history has no business here.
262
+ // What it WAS a snapshot of is kept, so the exact commit is auditable.
261
263
  rmSync(join(stage, ".git"), { recursive: true, force: true });
264
+ writeInstallRecord(stage, {
265
+ source: source.url,
266
+ ...(source.subpath ? { subpath: source.subpath } : {}),
267
+ ...(clone.commit ? { commit: clone.commit } : {}),
268
+ });
269
+ if (clone.commit) {
270
+ console.log(` ${pc.dim(`Commit ${clone.commit}`)}`);
271
+ }
262
272
 
263
273
  if (existsSync(join(stage, "package.json"))) {
264
274
  console.log(` ${pc.dim("Installing dependencies…")}`);
package/src/cli/setup.ts CHANGED
@@ -94,6 +94,20 @@ type TelegramAnswers = Pick<
94
94
  "botToken" | "adminId" | "apiId" | "apiHash"
95
95
  >;
96
96
 
97
+ /**
98
+ * The Telegram admin id is required: it is the bot's owner (admin commands)
99
+ * and the default DM allowlist. The daemon refuses to start without it.
100
+ */
101
+ export function validateTelegramAdminId(
102
+ value: string | undefined,
103
+ ): string | undefined {
104
+ const v = value?.trim() ?? "";
105
+ if (!v) return "Required — the bot answers only its admin (and allowedUsers)";
106
+ if (!/^\d+$/.test(v) || Number(v) <= 0 || !Number.isSafeInteger(Number(v)))
107
+ return "Must be your numeric Telegram user ID";
108
+ return undefined;
109
+ }
110
+
97
111
  async function askTelegram(config: Config): Promise<TelegramAnswers> {
98
112
  const botToken = await askOrExit(
99
113
  p.text({
@@ -110,8 +124,9 @@ async function askTelegram(config: Config): Promise<TelegramAnswers> {
110
124
  const adminId = await askOrExit(
111
125
  p.text({
112
126
  message: "Your Telegram user ID",
113
- placeholder: "optional — message @userinfobot to find yours",
127
+ placeholder: "message @userinfobot to find yours",
114
128
  initialValue: config.adminUserId ? String(config.adminUserId) : "",
129
+ validate: validateTelegramAdminId,
115
130
  }),
116
131
  );
117
132
 
@@ -619,6 +634,34 @@ export type SetupAnswers = {
619
634
  whatsapp?: Config["whatsapp"];
620
635
  };
621
636
 
637
+ /**
638
+ * Admin + DM allowlist for the saved config. With Telegram on, the admin id
639
+ * is the one just entered, and an empty allowlist defaults to that admin —
640
+ * a fresh bot answers its owner and no one else. With Telegram off, both are
641
+ * left as they were (other surfaces read `adminUserId` too).
642
+ */
643
+ function telegramAccess(
644
+ existing: Config,
645
+ answers: SetupAnswers,
646
+ telegramOn: boolean,
647
+ ): Pick<Config, "adminUserId" | "allowedUsers"> {
648
+ const adminUserId = answers.adminId
649
+ ? parseInt(answers.adminId, 10) || undefined
650
+ : undefined;
651
+ if (!telegramOn || adminUserId === undefined) {
652
+ return {
653
+ adminUserId: adminUserId ?? existing.adminUserId,
654
+ allowedUsers: existing.allowedUsers,
655
+ };
656
+ }
657
+ return {
658
+ adminUserId,
659
+ allowedUsers: existing.allowedUsers?.length
660
+ ? existing.allowedUsers
661
+ : [adminUserId],
662
+ };
663
+ }
664
+
622
665
  /**
623
666
  * Fold the wizard's answers onto the existing config.
624
667
  *
@@ -674,9 +717,7 @@ export function buildSetupConfig(
674
717
  concurrency: existing.concurrency,
675
718
  pulse: answers.pulse,
676
719
  pulseIntervalMs: existing.pulseIntervalMs,
677
- adminUserId: answers.adminId
678
- ? parseInt(answers.adminId, 10) || undefined
679
- : undefined,
720
+ ...telegramAccess(existing, answers, on("telegram")),
680
721
  apiId: answers.apiId,
681
722
  apiHash: answers.apiHash,
682
723
  maxMessageLength: existing.maxMessageLength,
package/src/cli/skill.ts CHANGED
@@ -171,6 +171,9 @@ async function cmdInstall(args: string[]): Promise<void> {
171
171
  return;
172
172
  }
173
173
  installed = installAll(dirs, force);
174
+ if (clone.commit) {
175
+ console.log(` ${pc.dim(`From ${resolved.url} @ ${clone.commit}`)}`);
176
+ }
174
177
  } finally {
175
178
  clone.cleanup();
176
179
  }
@@ -76,6 +76,14 @@ export interface BackendFactory {
76
76
  id: string;
77
77
  /** Display label used in `/status` and agent logs (e.g. "Kilo"). */
78
78
  label: string;
79
+ /**
80
+ * Can this backend run a guest-scoped turn (a non-operator sender) with
81
+ * nothing but the hub's conversation-only tools? `"enforced"` means its
82
+ * own built-ins (shell, file tools) are dropped for guest turns;
83
+ * `"refused"` means they can't be, so the weaver refuses guest turns on
84
+ * it. Absent is treated as `"refused"` — fail closed.
85
+ */
86
+ guestToolScope?: "enforced" | "refused";
79
87
  /** Initialise the backend; called exactly once per Talon process. */
80
88
  init(config: TalonConfig, ctx: BackendInitContext): Promise<BackendInstance>;
81
89
  /**
@@ -136,6 +144,14 @@ export function clearBackends(): void {
136
144
  backends.clear();
137
145
  }
138
146
 
147
+ /**
148
+ * Whether guest-scoped turns may run on this backend. Unknown ids and
149
+ * factories that don't declare `guestToolScope` fail closed.
150
+ */
151
+ export function backendEnforcesGuestScope(id: string): boolean {
152
+ return backends.get(id)?.guestToolScope === "enforced";
153
+ }
154
+
139
155
  /** Whether a backend with this id is currently registered. */
140
156
  export function hasBackend(id: string): boolean {
141
157
  return backends.has(id);