talon-agent 1.50.0 → 1.51.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -17,10 +17,10 @@ Multi-platform agentic AI harness. Runs on **Telegram**, **Discord**, **Microsof
17
17
  | **Multi-frontend** | Telegram (Grammy + GramJS userbot), Discord (discord.js), Microsoft Teams (Bot Framework), Terminal with live tool visibility, and a **Desktop/Mobile app** (Flutter) over a local/remote bridge |
18
18
  | **Pluggable backend** | Claude Agent SDK, Kilo, OpenCode, Codex, OpenAI Agents — selectable per-process via `backend` config. Streaming, model fallback, context-overflow recovery. |
19
19
  | **MCP tools** | Messaging, media, history, search, web fetch, cron jobs, triggers, goals, stickers, file system, admin controls |
20
- | **Plugins** | Hot-reloadable plugin system. Built-in: GitHub, MemPalace, Playwright, Brave Search |
20
+ | **Plugins** | Hot-reloadable plugin system with `talon plugin install/enable/disable` (npm, git, or local sources). Built-in: GitHub, MemPalace, Playwright, Brave Search |
21
21
  | **Background agents** | Heartbeat (hourly by default — advances goals, proactively messages when something matters) and Dream (memory consolidation + diary) |
22
22
  | **Goals** | Persistent multi-day objectives the agent commits to in chat; every heartbeat run re-reads them, makes progress, and records what it did |
23
- | **Skills** | Agent-authored reusable scripts (bash/python/node) procedures worked out once get saved and replayed locally at zero token cost |
23
+ | **Skills** | SKILL.md workflow bundles the agent authors and reuses, with `talon skill install/enable/disable` (local folders, git, or `owner/repo` the Anthropic skills ecosystem installs directly) |
24
24
  | **Triggers** | Self-authored watcher scripts (bash/python/node) that wake the bot when conditions are met |
25
25
  | **Task table** | Every unit of agent work — chat turns, heartbeat, dream, isolated cron/trigger jobs — registered live; `talon ps` / `talon kill` |
26
26
  | **Event bus** | Typed internal pub-sub spine (task + turn lifecycle events); subsystems subscribe instead of importing each other; `talon events -f` |
@@ -169,6 +169,32 @@ The app provides multi-chat history, live streaming with reasoning + tool-call v
169
169
 
170
170
  ---
171
171
 
172
+ ## Managing plugins & skills
173
+
174
+ Both stores are managed from the CLI; changes hot-reload into a running
175
+ daemon (plugins) or apply on the next session (skills):
176
+
177
+ ```bash
178
+ # Plugins — npm specs, git repos, or local paths
179
+ talon plugin install @scope/my-talon-plugin # npm → module plugin
180
+ talon plugin install some-mcp-server --mcp # npm → standalone MCP server (npx)
181
+ talon plugin install owner/repo # git → module plugin
182
+ talon plugin list # built-ins + configured entries
183
+ talon plugin disable github # also toggles built-ins
184
+ talon plugin remove my-talon-plugin
185
+
186
+ # Skills — SKILL.md folders from local paths, git URLs, or owner/repo[/subpath]
187
+ talon skill install anthropics/skills/document-skills/pdf
188
+ talon skill install ./my-skill --force
189
+ talon skill list
190
+ talon skill disable pdf # hidden from the prompt index, still readable
191
+ talon skill remove pdf
192
+ ```
193
+
194
+ Module plugins install under `~/.talon/plugins/`; standalone MCP servers are
195
+ registered as `npx` entries in `config.json`. Disabling keeps the entry (or a
196
+ `.disabled` marker in the skill folder) so enabling restores it unchanged.
197
+
172
198
  ## Built-in Plugins
173
199
 
174
200
  ### GitHub
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "talon-agent",
3
- "version": "1.50.0",
3
+ "version": "1.51.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",
@@ -13,10 +13,11 @@ export async function fetchGateway(
13
13
  port: number,
14
14
  path: string,
15
15
  init?: RequestInit,
16
+ timeoutMs: number = REQUEST_TIMEOUT_MS,
16
17
  ): Promise<unknown> {
17
18
  const response = await fetch(`http://127.0.0.1:${port}${path}`, {
18
19
  ...init,
19
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
20
+ signal: AbortSignal.timeout(timeoutMs),
20
21
  });
21
22
  if (!response.ok) throw new Error(`gateway answered ${response.status}`);
22
23
  return response.json();
package/src/cli/index.ts CHANGED
@@ -33,6 +33,8 @@ import { daemonStart, daemonStop, daemonRestart } from "./daemon.js";
33
33
  import { showTasks, killTask } from "./tasks.js";
34
34
  import { showEvents } from "./events.js";
35
35
  import { showLs, showCat } from "./fs.js";
36
+ import { runPluginCommand } from "./plugin.js";
37
+ import { runSkillCommand } from "./skill.js";
36
38
  import { mainMenu } from "./menu.js";
37
39
 
38
40
  export * from "./context.js";
@@ -63,6 +65,8 @@ const CLI_COMMANDS = [
63
65
  "events",
64
66
  "ls",
65
67
  "cat",
68
+ "plugin",
69
+ "skill",
66
70
  ];
67
71
 
68
72
  /** Route a `talon <command>` invocation. Called by the entry point. */
@@ -128,6 +132,12 @@ export async function runCli(): Promise<void> {
128
132
  case "cat":
129
133
  await showCat(process.argv[3]);
130
134
  break;
135
+ case "plugin":
136
+ await runPluginCommand(process.argv.slice(3));
137
+ break;
138
+ case "skill":
139
+ await runSkillCommand(process.argv.slice(3));
140
+ break;
131
141
  case "--version":
132
142
  case "-v": {
133
143
  console.log(pkg.version);
@@ -158,6 +168,12 @@ export async function runCli(): Promise<void> {
158
168
  console.log(
159
169
  ` ${pc.cyan("cat")} Read a talon:// file (cat proc/events)`,
160
170
  );
171
+ console.log(
172
+ ` ${pc.cyan("plugin")} Manage plugins (install/enable/disable)`,
173
+ );
174
+ console.log(
175
+ ` ${pc.cyan("skill")} Manage skills (install/enable/disable)`,
176
+ );
161
177
  console.log(` ${pc.cyan("config")} View/edit configuration`);
162
178
  console.log(` ${pc.cyan("logs")} Tail log file`);
163
179
  console.log(` ${pc.cyan("doctor")} Validate environment`);
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Install-source resolution shared by `talon plugin install` and
3
+ * `talon skill install`.
4
+ *
5
+ * One grammar for both commands, checked in order:
6
+ *
7
+ * 1. an existing local path → { kind: "local" }
8
+ * 2. a git URL (scheme, `git@`, or `.git`) → { kind: "git" }
9
+ * 3. `owner/repo[/subpath]` shorthand → { kind: "git" } on github.com
10
+ * 4. anything else → { kind: "other" } — the caller
11
+ * decides (plugins treat it as an npm spec, skills reject it)
12
+ *
13
+ * Cloning always uses `--depth=1` (installs never need history) and spawns
14
+ * `git`/`npm` via cross-spawn, which resolves the `.cmd`/`.exe` shims on
15
+ * Windows — never assume a POSIX shell here.
16
+ */
17
+
18
+ import crossSpawn from "cross-spawn";
19
+ import { existsSync, mkdtempSync, rmSync } from "node:fs";
20
+ import { tmpdir } from "node:os";
21
+ import { join, resolve } from "node:path";
22
+
23
+ export type ResolvedSource =
24
+ | { kind: "local"; dir: string }
25
+ | { kind: "git"; url: string; subpath?: string }
26
+ | { kind: "other"; raw: string };
27
+
28
+ const GIT_URL_RE = /^(https?|git|ssh):\/\//;
29
+ /** `owner/repo` or `owner/repo/sub/path` — never an npm scope (`@…`). */
30
+ const GITHUB_SHORTHAND_RE =
31
+ /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)((?:\/[^\s/]+)*)$/;
32
+
33
+ export function resolveSource(raw: string): ResolvedSource {
34
+ const trimmed = raw.trim();
35
+ if (existsSync(resolve(trimmed))) {
36
+ return { kind: "local", dir: resolve(trimmed) };
37
+ }
38
+ if (
39
+ GIT_URL_RE.test(trimmed) ||
40
+ trimmed.startsWith("git@") ||
41
+ trimmed.endsWith(".git")
42
+ ) {
43
+ return { kind: "git", url: trimmed };
44
+ }
45
+ if (!trimmed.startsWith("@")) {
46
+ const match = GITHUB_SHORTHAND_RE.exec(trimmed);
47
+ if (match) {
48
+ const [, owner, repo, rest] = match;
49
+ return {
50
+ kind: "git",
51
+ url: `https://github.com/${owner}/${repo}.git`,
52
+ ...(rest ? { subpath: rest.slice(1) } : {}),
53
+ };
54
+ }
55
+ }
56
+ return { kind: "other", raw: trimmed };
57
+ }
58
+
59
+ export type CommandOutcome = { ok: true } | { ok: false; error: string };
60
+
61
+ /**
62
+ * Run a tool from PATH synchronously. `inherit` streams output to the
63
+ * terminal (npm installs); otherwise stderr is captured for the error.
64
+ */
65
+ export function runTool(
66
+ tool: string,
67
+ args: string[],
68
+ options: { cwd?: string; inherit?: boolean } = {},
69
+ ): CommandOutcome {
70
+ const result = crossSpawn.sync(tool, args, {
71
+ cwd: options.cwd,
72
+ stdio: options.inherit ? "inherit" : ["ignore", "ignore", "pipe"],
73
+ });
74
+ if (result.error) {
75
+ const missing = (result.error as NodeJS.ErrnoException).code === "ENOENT";
76
+ return {
77
+ ok: false,
78
+ error: missing
79
+ ? `${tool} is not installed or not on PATH`
80
+ : result.error.message,
81
+ };
82
+ }
83
+ if (result.status !== 0) {
84
+ const stderr = result.stderr?.toString().trim();
85
+ return {
86
+ ok: false,
87
+ error: stderr || `${tool} ${args[0]} exited with ${result.status}`,
88
+ };
89
+ }
90
+ return { ok: true };
91
+ }
92
+
93
+ export type CloneOutcome =
94
+ { ok: true; dir: string; cleanup: () => void } | { ok: false; error: string };
95
+
96
+ /** Shallow-clone into a fresh temp directory. Caller must run `cleanup`. */
97
+ export function cloneShallow(url: string): CloneOutcome {
98
+ const dir = mkdtempSync(join(tmpdir(), "talon-install-"));
99
+ const cleanup = () => rmSync(dir, { recursive: true, force: true });
100
+ const outcome = runTool("git", ["clone", "--depth=1", url, dir]);
101
+ if (!outcome.ok) {
102
+ cleanup();
103
+ return { ok: false, error: `Clone failed: ${outcome.error}` };
104
+ }
105
+ return { ok: true, dir, cleanup };
106
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Pure helpers over the config `plugins` array for the `talon plugin`
3
+ * command group. The CLI edits config.json as JSON — entries here are the
4
+ * on-disk shape (`core/plugin/types.ts` PluginEntry), not loaded plugins.
5
+ * No filesystem or process access — everything is testable data-in/data-out.
6
+ */
7
+
8
+ /** On-disk plugin entry — path-based module or standalone MCP server. */
9
+ export type PluginEntryJson = {
10
+ path?: string;
11
+ config?: Record<string, unknown>;
12
+ name?: string;
13
+ command?: string;
14
+ args?: string[];
15
+ env?: Record<string, string>;
16
+ enabled?: boolean;
17
+ };
18
+
19
+ /**
20
+ * Built-in plugins toggled by their own config sections (`github.enabled`,
21
+ * …) rather than `plugins[]` entries — see core/plugin/builtins.ts.
22
+ */
23
+ export const BUILTIN_PLUGINS = [
24
+ "github",
25
+ "mempalace",
26
+ "mem0",
27
+ "playwright",
28
+ ] as const;
29
+
30
+ export function isBuiltinPlugin(name: string): boolean {
31
+ return (BUILTIN_PLUGINS as readonly string[]).includes(name);
32
+ }
33
+
34
+ /** Split a config path on either separator — Windows configs carry `\`. */
35
+ function pathSegments(path: string): string[] {
36
+ return path.split(/[\\/]+/).filter(Boolean);
37
+ }
38
+
39
+ /**
40
+ * Display name for an entry: the MCP name, or for path entries the package
41
+ * name — segments after the last `node_modules` (preserving `@scope/pkg`),
42
+ * else the folder basename.
43
+ */
44
+ export function entryDisplayName(entry: PluginEntryJson): string {
45
+ if (entry.name) return entry.name;
46
+ if (!entry.path) return "(invalid entry)";
47
+ const segments = pathSegments(entry.path);
48
+ const nm = segments.lastIndexOf("node_modules");
49
+ if (nm >= 0 && nm < segments.length - 1) {
50
+ return segments.slice(nm + 1).join("/");
51
+ }
52
+ return segments[segments.length - 1] ?? entry.path;
53
+ }
54
+
55
+ /** Does `token` identify this entry? Display name, basename, or full path. */
56
+ export function entryMatches(entry: PluginEntryJson, token: string): boolean {
57
+ if (entryDisplayName(entry) === token) return true;
58
+ if (!entry.path) return false;
59
+ const segments = pathSegments(entry.path);
60
+ return entry.path === token || segments[segments.length - 1] === token;
61
+ }
62
+
63
+ export function findEntryIndex(
64
+ entries: PluginEntryJson[],
65
+ token: string,
66
+ ): number {
67
+ return entries.findIndex((entry) => entryMatches(entry, token));
68
+ }
69
+
70
+ /**
71
+ * Index of an entry with the same identity as `candidate` (same module
72
+ * path or same MCP name) — the duplicate an install must replace.
73
+ */
74
+ export function findConflictIndex(
75
+ entries: PluginEntryJson[],
76
+ candidate: PluginEntryJson,
77
+ ): number {
78
+ return entries.findIndex((entry) =>
79
+ candidate.path !== undefined
80
+ ? entry.path === candidate.path
81
+ : entry.name !== undefined && entry.name === candidate.name,
82
+ );
83
+ }
84
+
85
+ /**
86
+ * Copy of `entry` with the enabled state applied. Enabled is the default,
87
+ * so enabling removes the key rather than writing `enabled: true`.
88
+ */
89
+ export function withEnabled(
90
+ entry: PluginEntryJson,
91
+ enabled: boolean,
92
+ ): PluginEntryJson {
93
+ const { enabled: _drop, ...rest } = entry;
94
+ return enabled ? rest : { ...rest, enabled: false };
95
+ }
96
+
97
+ /**
98
+ * Package name of an npm spec, version stripped: `pkg@1.2` → `pkg`,
99
+ * `@scope/pkg@^1` → `@scope/pkg`. A leading `@` is the scope, not a version.
100
+ */
101
+ export function npmSpecName(spec: string): string {
102
+ const at = spec.lastIndexOf("@");
103
+ return at > 0 ? spec.slice(0, at) : spec;
104
+ }