@pmndrs/pine 0.0.0-stage → 0.1.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.
Files changed (117) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +180 -2
  3. package/bin/pine.js +27 -0
  4. package/docs/SKILL.md +189 -0
  5. package/docs/references/agents.md +368 -0
  6. package/docs/references/coding.md +126 -0
  7. package/docs/references/configuration.md +229 -0
  8. package/docs/references/events.md +263 -0
  9. package/docs/references/mcp.md +257 -0
  10. package/docs/references/operations.md +280 -0
  11. package/docs/references/overview.md +186 -0
  12. package/docs/references/security.md +489 -0
  13. package/docs/references/setup.md +181 -0
  14. package/docs/references/sources.md +768 -0
  15. package/docs/references/stack.md +105 -0
  16. package/docs/references/tui.md +348 -0
  17. package/examples/home/.env.example +5 -0
  18. package/examples/home/USER.md +4 -0
  19. package/examples/home/agents/main/agent.json +4 -0
  20. package/examples/home/agents/main/workspace/AGENTS.md +14 -0
  21. package/examples/home/agents/main/workspace/MEMORY.md +4 -0
  22. package/examples/home/agents/main/workspace/SOUL.md +4 -0
  23. package/examples/home/models.json +62 -0
  24. package/examples/home/pine.json +30 -0
  25. package/examples/home/skills/README.txt +7 -0
  26. package/examples/launchd/com.pine.daemon.plist +32 -0
  27. package/mcp/browser.d.ts +34 -0
  28. package/mcp/browser.js +228 -0
  29. package/mcp/commands.d.ts +20 -0
  30. package/mcp/commands.js +88 -0
  31. package/mcp/lib.d.ts +13 -0
  32. package/mcp/lib.js +50 -0
  33. package/mcp/web.d.ts +6 -0
  34. package/mcp/web.js +140 -0
  35. package/package.json +32 -4
  36. package/src/agent-admin.d.ts +24 -0
  37. package/src/agent-admin.js +55 -0
  38. package/src/agents.d.ts +343 -0
  39. package/src/agents.js +1587 -0
  40. package/src/cli.d.ts +2 -0
  41. package/src/cli.js +526 -0
  42. package/src/config.d.ts +351 -0
  43. package/src/config.js +514 -0
  44. package/src/dev/claude-model.d.ts +28 -0
  45. package/src/dev/claude-model.js +174 -0
  46. package/src/env-file.d.ts +4 -0
  47. package/src/env-file.js +40 -0
  48. package/src/http.d.ts +46 -0
  49. package/src/http.js +407 -0
  50. package/src/limiter.d.ts +32 -0
  51. package/src/limiter.js +84 -0
  52. package/src/login.d.ts +21 -0
  53. package/src/login.js +126 -0
  54. package/src/manual.d.ts +61 -0
  55. package/src/manual.js +425 -0
  56. package/src/mcp-config.d.ts +40 -0
  57. package/src/mcp-config.js +132 -0
  58. package/src/net.d.ts +36 -0
  59. package/src/net.js +122 -0
  60. package/src/pine.d.ts +32 -0
  61. package/src/pine.js +142 -0
  62. package/src/router.d.ts +56 -0
  63. package/src/router.js +164 -0
  64. package/src/sandbox-fs.d.ts +27 -0
  65. package/src/sandbox-fs.js +172 -0
  66. package/src/security.d.ts +121 -0
  67. package/src/security.js +363 -0
  68. package/src/skills.d.ts +67 -0
  69. package/src/skills.js +163 -0
  70. package/src/source-types.d.ts +61 -0
  71. package/src/source-types.js +1 -0
  72. package/src/sources/command.d.ts +4 -0
  73. package/src/sources/command.js +77 -0
  74. package/src/sources/folder.d.ts +6 -0
  75. package/src/sources/folder.js +116 -0
  76. package/src/sources/ftp.d.ts +4 -0
  77. package/src/sources/ftp.js +307 -0
  78. package/src/sources/http.d.ts +4 -0
  79. package/src/sources/http.js +201 -0
  80. package/src/sources/mqtt.d.ts +18 -0
  81. package/src/sources/mqtt.js +97 -0
  82. package/src/sources/telegram.d.ts +22 -0
  83. package/src/sources/telegram.js +269 -0
  84. package/src/sources/webhook.d.ts +4 -0
  85. package/src/sources/webhook.js +52 -0
  86. package/src/sources.d.ts +339 -0
  87. package/src/sources.js +955 -0
  88. package/src/store.d.ts +89 -0
  89. package/src/store.js +251 -0
  90. package/src/timers.d.ts +83 -0
  91. package/src/timers.js +318 -0
  92. package/src/tools.d.ts +6 -0
  93. package/src/tools.js +777 -0
  94. package/src/tui/app.d.ts +209 -0
  95. package/src/tui/app.js +2223 -0
  96. package/src/tui/client.d.ts +178 -0
  97. package/src/tui/client.js +116 -0
  98. package/src/tui/code-pane.d.ts +51 -0
  99. package/src/tui/code-pane.js +398 -0
  100. package/src/tui/highlight.d.ts +8 -0
  101. package/src/tui/highlight.js +86 -0
  102. package/src/tui/picture.d.ts +141 -0
  103. package/src/tui/picture.js +571 -0
  104. package/src/tui/run.d.ts +8 -0
  105. package/src/tui/run.js +97 -0
  106. package/src/tui/theme.d.ts +32 -0
  107. package/src/tui/theme.js +128 -0
  108. package/src/types.d.ts +124 -0
  109. package/src/types.js +1 -0
  110. package/src/update.d.ts +25 -0
  111. package/src/update.js +110 -0
  112. package/src/util.d.ts +32 -0
  113. package/src/util.js +116 -0
  114. package/src/watching.d.ts +20 -0
  115. package/src/watching.js +68 -0
  116. package/src/workspace-view.d.ts +38 -0
  117. package/src/workspace-view.js +160 -0
package/src/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/src/cli.js ADDED
@@ -0,0 +1,526 @@
1
+ #!/usr/bin/env node
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { ConfigError, defaultHome, effectiveMcp, loadConfig, mainAgentId, PINE_ROOT } from "./config.js";
5
+ import { audit } from "./security.js";
6
+ import { pineManual, showRead, showSearch } from "./manual.js";
7
+ import { allSkills, listSkills } from "./skills.js";
8
+ import { whenText } from "./timers.js";
9
+ import { expandHome } from "./util.js";
10
+ const HELP = `pine - PI neural engine
11
+
12
+ Start here
13
+ pine open the terminal UI (same as "pine tui")
14
+ pine . | pine <folder> the same, with a tab on that folder: a conversation of the main agent that
15
+ works there (the one from last time, if there is one)
16
+ pine tui chat with your agents; starts a daemon inside if none is running
17
+ pine login <provider> sign in to a model provider (OpenCode, Claude, ChatGPT, ...)
18
+ pine logout <provider>
19
+
20
+ Daemon
21
+ pine init [dir] set pine up: a home directory (default ~/.pine) from the example, and the
22
+ browser its web tools use. Safe to run again
23
+ pine check validate the configuration and list what it sets up
24
+ pine start run the daemon in the foreground
25
+ pine dev-model [--port 7464] [--model haiku]
26
+ OpenAI-compatible model server backed by "claude -p" (development only)
27
+ pine update [--check] bring pine up to date (npm for an install, git for a checkout) with what the
28
+ new version needs; --check only says whether there is something newer
29
+ pine version which version this is and how it is installed
30
+ pine browser-install fetch the browser again (pine init and pine update do this)
31
+
32
+ Talking to a running daemon
33
+ pine status running tasks, live sessions, queue
34
+ pine agents configured agents
35
+ pine send <agent> <text...> give an agent a task and print its answer
36
+ [--session <key>] [--no-wait]
37
+ pine emit <topic> [json] publish an event
38
+ pine events [--topic <pattern>] [--limit n] [-f]
39
+ show the event log; -f follows live
40
+ pine tasks [--agent id] [--status s] recent tasks
41
+ pine task <id> one task in full
42
+ pine cancel <id> stop a task
43
+ pine state [key-pattern] shared state
44
+ pine approvals open approval requests
45
+ pine approve <id> [--always] | pine deny <id> --always: do not ask again in that conversation
46
+ pine reset <agent> [session] forget a conversation (also clears its taint)
47
+ pine mode <agent> [manual|auto|bypass]
48
+ show or change how much an agent may do without asking
49
+
50
+ Event sources
51
+ pine sources configured sources, whether they run, who is subscribed, last event
52
+ pine subscribe <agent> <source> [--session name] [--note text] [--throttle 5m] [--when json] [--separate]
53
+ wake that agent whenever the source reports something: in its main
54
+ conversation, or only the conversation named with --session
55
+ pine subscribe --global <source> [--note text] ...
56
+ wake every agent that can use the source
57
+ pine unsubscribe <agent> <source> [--session name] | pine unsubscribe --global <source>
58
+
59
+ Schedules, skills, the manual
60
+ pine schedules what is scheduled: for whom, when, and the task
61
+ pine skills every skill: whom it is for and where it lies
62
+ pine manual <words> search pine's manual, as the agents do; --read <id> shows a part in full
63
+
64
+ MCP servers
65
+ pine mcp list configured servers, who gets them, and whether they connect
66
+ pine mcp add <name> <https-url> [--agent id] [--taints] [--approve always|tainted] [--timeout seconds]
67
+ pine mcp add <name> [--agent id] -- <command> [args...]
68
+ pine mcp remove <name> [--agent id]
69
+ pine mcp login <name> sign in to a remote server through the browser (OAuth)
70
+ pine mcp logout <name>
71
+
72
+ Options
73
+ --home <dir> home directory (default $PINE_HOME or ~/.pine)
74
+ `;
75
+ function parseArgs(argv) {
76
+ const flags = {};
77
+ const rest = [];
78
+ for (let i = 0; i < argv.length; i++) {
79
+ const a = argv[i];
80
+ if (a === "-f")
81
+ flags.follow = true;
82
+ else if (a.startsWith("--no-"))
83
+ flags[a.slice(5)] = false;
84
+ else if (a.startsWith("--"))
85
+ flags[a.slice(2)] = argv[i + 1] !== undefined && !argv[i + 1].startsWith("--") && !["follow", "json", "api-key", "oauth", "taints", "offline", "always", "separate", "global"].includes(a.slice(2)) ? argv[++i] : true;
86
+ else
87
+ rest.push(a);
88
+ }
89
+ return { flags, rest };
90
+ }
91
+ function copyDir(from, to, skip = () => false) {
92
+ const created = [];
93
+ for (const entry of fs.readdirSync(from, { withFileTypes: true })) {
94
+ const src = path.join(from, entry.name);
95
+ const dst = path.join(to, entry.name === ".env.example" ? ".env" : entry.name);
96
+ if (entry.isDirectory()) {
97
+ fs.mkdirSync(dst, { recursive: true });
98
+ created.push(...copyDir(src, dst, skip));
99
+ }
100
+ else if (!fs.existsSync(dst) && !skip(dst)) {
101
+ fs.copyFileSync(src, dst);
102
+ fs.chmodSync(dst, 0o600);
103
+ created.push(dst);
104
+ }
105
+ }
106
+ return created;
107
+ }
108
+ function client(cfg) {
109
+ if (!cfg.http)
110
+ throw new Error("http is disabled in pine.json, so there is no control API to talk to");
111
+ const tokenFile = path.join(cfg.dataDir, "http-token");
112
+ const token = cfg.http.token ?? (fs.existsSync(tokenFile) ? fs.readFileSync(tokenFile, "utf8").trim() : "");
113
+ const base = `http://${cfg.http.host === "0.0.0.0" ? "127.0.0.1" : cfg.http.host}:${cfg.http.port}`;
114
+ const call = async (method, url, body) => {
115
+ let res;
116
+ try {
117
+ res = await fetch(base + url, { method, headers: { authorization: `Bearer ${token}`, ...(body !== undefined ? { "content-type": "application/json" } : {}) }, body: body !== undefined ? JSON.stringify(body) : undefined });
118
+ }
119
+ catch {
120
+ throw new Error(`no daemon answers on ${base}. Start it with "pine start".`);
121
+ }
122
+ const json = await res.json().catch(() => ({}));
123
+ if (!res.ok)
124
+ throw new Error(json.error ?? `HTTP ${res.status}`);
125
+ return json;
126
+ };
127
+ return { base, token, get: (u) => call("GET", u), post: (u, b = {}) => call("POST", u, b) };
128
+ }
129
+ const when = (ts) => new Date(ts).toLocaleString("sv-SE");
130
+ const line = (e) => `${when(e.ts)} #${String(e.id).padEnd(5)} ${e.topic.padEnd(34)} ${e.trust === "owner" ? "owner " : "untrusted"} ${e.source.padEnd(14)} ${JSON.stringify(e.payload).slice(0, 160)}`;
131
+ /** pine mcp list | add | remove | login | logout */
132
+ async function mcpCommand(cfg, argv, out) {
133
+ const { addServer, findServer, listServers, piMcp, removeServer, serverStates } = await import("./mcp-config.js");
134
+ // Everything after "--" is the command line of a local server.
135
+ const dashes = argv.indexOf("--");
136
+ const command = dashes >= 0 ? argv.slice(dashes + 1) : [];
137
+ const { flags, rest } = parseArgs((dashes >= 0 ? argv.slice(0, dashes) : argv).filter((a, i, all) => !(a === "--home" || all[i - 1] === "--home")));
138
+ const sub = rest.shift() ?? "list";
139
+ const agent = typeof flags.agent === "string" ? flags.agent : undefined;
140
+ // A running daemon applies the change at once; without one the files are edited and it applies at the next start.
141
+ const live = async (body) => {
142
+ try {
143
+ const api = client(cfg);
144
+ if (!(await fetch(`${api.base}/health`, { signal: AbortSignal.timeout(800) })).ok)
145
+ return false;
146
+ await api.post("/mcp", body);
147
+ return true;
148
+ }
149
+ catch (e) {
150
+ if (e instanceof Error && !/no daemon|fetch failed|HTTP 404|not found|aborted|timeout/i.test(e.message))
151
+ throw e;
152
+ return false;
153
+ }
154
+ };
155
+ switch (sub) {
156
+ case "list": {
157
+ const servers = listServers(cfg);
158
+ if (!servers.length)
159
+ return out("no MCP servers configured");
160
+ const states = flags.offline ? {} : await serverStates(cfg);
161
+ for (const s of servers)
162
+ out(`${s.name.padEnd(14)} ${(s.enabled ? (states[s.name] ?? (s.remote ? "?" : "local")) : "disabled").padEnd(16)} ${s.scope === "global" ? `all: ${s.agents.join(", ") || "nobody"}` : `only ${s.scope}`}\n${" ".repeat(15)}${s.target}${s.taints ? " [outside content]" : ""}${s.approve !== "never" ? ` [approval: ${s.approve}]` : ""}`);
163
+ return;
164
+ }
165
+ case "add": {
166
+ const name = rest[0];
167
+ const url = rest[1];
168
+ if (!name || (!url && !command.length))
169
+ throw new Error("usage: pine mcp add <name> <https-url> [--agent id] [--taints] [--approve always|tainted] [--timeout seconds] [--description text]\n pine mcp add <name> [--agent id] -- <command> [args...]");
170
+ const server = url ? { url } : { command: command[0], args: command.slice(1) };
171
+ if (typeof flags.description === "string")
172
+ server.description = flags.description;
173
+ if (flags.taints === true)
174
+ server.taints = true;
175
+ if (flags.timeout !== undefined) {
176
+ if (!(Number(flags.timeout) > 0))
177
+ throw new Error("--timeout takes a number of seconds, for example --timeout 180");
178
+ server.timeout = Number(flags.timeout);
179
+ }
180
+ if (typeof flags.approve === "string")
181
+ server.approve = flags.approve;
182
+ if (typeof flags.exposure === "string")
183
+ server.exposure = flags.exposure;
184
+ if (!(await live({ name, server, agent })))
185
+ addServer(cfg, name, server, agent);
186
+ out(`Added "${name}" ${agent ? `for agent ${agent}` : "for all agents"}.${url ? ` If it requires sign-in: pine mcp login ${name}` : ""}`);
187
+ return;
188
+ }
189
+ case "remove": {
190
+ if (!rest[0])
191
+ throw new Error("usage: pine mcp remove <name> [--agent id]");
192
+ if (!(await live({ name: rest[0], agent, remove: true })) && !removeServer(cfg, rest[0], agent))
193
+ throw new Error(`no server "${rest[0]}"${agent ? ` on agent ${agent}` : " in pine.json"}`);
194
+ return out(`Removed "${rest[0]}".`);
195
+ }
196
+ case "login":
197
+ case "logout": {
198
+ const name = rest[0];
199
+ const server = name ? findServer(cfg, name, agent) : undefined;
200
+ if (!name || !server)
201
+ throw new Error(name ? `no server "${name}". pine mcp list shows what is configured.` : `usage: pine mcp ${sub} <name>`);
202
+ if (!server.url)
203
+ throw new Error(`"${name}" is a local server; only remote servers have a sign-in`);
204
+ const { code } = await piMcp(cfg, { [name]: server }, [sub, name]);
205
+ if (code === 0 && sub === "login") {
206
+ // Sessions that started before the sign-in do not have the server's tools yet.
207
+ try {
208
+ await client(cfg).post("/mcp/refresh");
209
+ }
210
+ catch {
211
+ // No daemon running: nothing to refresh.
212
+ }
213
+ }
214
+ process.exitCode = code;
215
+ return;
216
+ }
217
+ default:
218
+ throw new Error(`unknown: pine mcp ${sub}. Use list, add, remove, login or logout.`);
219
+ }
220
+ }
221
+ /**
222
+ * Download the Chromium build the bundled browser tools drive. It has to match the Playwright
223
+ * version pine ships with, so that copy's own installer is used. Quick when it is there already.
224
+ */
225
+ async function installBrowser() {
226
+ const { spawnSync } = await import("node:child_process");
227
+ const { createRequire } = await import("node:module");
228
+ const cli = path.join(path.dirname(createRequire(import.meta.url).resolve("playwright-core")), "cli.js");
229
+ return spawnSync(process.execPath, [cli, "install", "chromium"], { stdio: "inherit" }).status ?? 1;
230
+ }
231
+ const COMMANDS = new Set(["agents", "approvals", "approve", "browser-install", "cancel", "check", "deny", "dev-model", "emit", "events", "help", "init", "login", "logout", "manual", "mcp", "mode", "reset", "schedules", "send", "skills", "sources", "start", "state", "status", "subscribe", "task", "tasks", "tui", "unsubscribe", "update", "version"]);
232
+ /** Whether a first argument means a folder to open: something written as a path, or the name of a folder here that is not a command. */
233
+ function folderArgument(word, cwd = process.cwd()) {
234
+ if (!word || COMMANDS.has(word))
235
+ return undefined;
236
+ const full = path.resolve(cwd, expandHome(word));
237
+ const isDir = (() => {
238
+ try {
239
+ return fs.statSync(full).isDirectory();
240
+ }
241
+ catch {
242
+ return false;
243
+ }
244
+ })();
245
+ if (isDir)
246
+ return full;
247
+ // Written as a path but not there: an error about the folder says more than "unknown command".
248
+ if (/^(\.{1,2}|~)($|\/)|\//.test(word))
249
+ throw new Error(`${word} is not a folder`);
250
+ return undefined;
251
+ }
252
+ async function main() {
253
+ const { flags, rest } = parseArgs(process.argv.slice(2));
254
+ let cmd = rest.shift();
255
+ // "pine ." and "pine ~/dev/project" open the terminal UI with a tab on that folder.
256
+ let folder = folderArgument(cmd);
257
+ if (folder)
258
+ cmd = "tui";
259
+ else if (cmd === "tui")
260
+ folder = folderArgument(rest[0]);
261
+ const home = path.resolve(expandHome(typeof flags.home === "string" ? flags.home : defaultHome()));
262
+ const out = (v) => console.log(typeof v === "string" ? v : JSON.stringify(v, null, 2));
263
+ if (cmd === "help" || flags.help || (!cmd && !process.stdout.isTTY))
264
+ return out(HELP);
265
+ if (cmd === "manual") {
266
+ // The same search the agents use, for you and for trying out what they would find.
267
+ const manual = pineManual(path.join(PINE_ROOT, "docs"));
268
+ if (typeof flags.read === "string")
269
+ return out(showRead(manual, flags.read.split(",")));
270
+ if (!rest.length)
271
+ return out(`The parts of pine's manual. Search it (pine manual telegram pairing), or read a part (pine manual --read sources#telegram).\n${manual.contents()}`);
272
+ return out(showSearch(manual, [rest.join(" ")]));
273
+ }
274
+ if (cmd === "init") {
275
+ const target = path.resolve(expandHome(rest[0] ?? home));
276
+ fs.mkdirSync(target, { recursive: true, mode: 0o700 });
277
+ fs.chmodSync(target, 0o700);
278
+ // A home from before instructions moved into the workspace keeps them where they are: with
279
+ // a second AGENTS.md in the workspace the agent would have both.
280
+ const kept = [];
281
+ const created = copyDir(path.join(PINE_ROOT, "examples", "home"), target, (dst) => {
282
+ const old = path.join(path.dirname(path.dirname(dst)), "AGENTS.md");
283
+ const twice = path.basename(dst) === "AGENTS.md" && path.basename(path.dirname(dst)) === "workspace" && fs.existsSync(old);
284
+ if (twice)
285
+ kept.push(old);
286
+ return twice;
287
+ });
288
+ out(created.length ? `Created in ${target}:\n${created.map((f) => ` ${path.relative(target, f)}`).join("\n")}` : `${target} already has every example file; nothing changed.`);
289
+ for (const old of kept)
290
+ out(`\n${path.relative(target, old)} stays where it is: there the agent reads it but cannot change it. To let the agent keep its own instructions, move it to ${path.relative(target, path.join(path.dirname(old), "workspace", "AGENTS.md"))}.`);
291
+ // Everything the built-in tools need comes with it, so that what is set up also works. The
292
+ // browser is the one large piece; a second run finds it there and takes a moment.
293
+ // (PINE_SKIP_DOWNLOADS is for pine's own tests, which must not fetch 600 MB.)
294
+ if (!process.env.PINE_SKIP_DOWNLOADS) {
295
+ out("\nGetting the browser the built-in web tools drive (about 600 MB, once) ...");
296
+ const status = await installBrowser();
297
+ if (status !== 0) {
298
+ out(`\nThe browser could not be downloaded (exit ${status}). Everything else is set up; web search and page reading work without it. Run "pine init" again when you are online to get it.`);
299
+ process.exitCode = 1;
300
+ return;
301
+ }
302
+ }
303
+ out(`\nReady. Start with: pine${target === defaultHome() ? "" : ` --home ${target}`}\nThe example uses Claude through "claude -p". For another model: pine login <provider>, then /model in the UI.`);
304
+ return;
305
+ }
306
+ if (cmd === "browser-install")
307
+ process.exit(await installBrowser());
308
+ if (cmd === "version" || flags.version === true) {
309
+ const { installed } = await import("./update.js");
310
+ const me = installed();
311
+ return out(`pine ${me.version} (${me.kind === "checkout" ? `checkout in ${me.root}` : me.kind === "global" ? "installed with npm" : `a dependency in ${me.root}`})`);
312
+ }
313
+ if (cmd === "update") {
314
+ const { update } = await import("./update.js");
315
+ process.exit(await update({ check: flags.check === true, out, browser: !process.env.PINE_SKIP_DOWNLOADS }));
316
+ }
317
+ if (cmd === "dev-model") {
318
+ const { startClaudeModel } = await import("./dev/claude-model.js");
319
+ const server = await startClaudeModel({ port: Number(flags.port ?? 7464), model: typeof flags.model === "string" ? flags.model : "haiku", log: (l) => console.error(`${new Date().toISOString()} ${l}`) });
320
+ out(`claude -p model server on ${server.baseUrl} (model ids: claude-haiku, claude-sonnet, claude-opus). Ctrl-C to stop.`);
321
+ process.on("SIGINT", () => void server.close().then(() => process.exit(0)));
322
+ return;
323
+ }
324
+ if ((!cmd || cmd === "tui") && !fs.existsSync(path.join(home, "pine.json")))
325
+ return out(`No pine home at ${home} yet. Create one with:\n\n pine init\n\n${HELP}`);
326
+ const cfg = loadConfig(home);
327
+ if (!cmd || cmd === "tui") {
328
+ const { runTui } = await import("./tui/run.js");
329
+ return runTui(cfg, { folder });
330
+ }
331
+ if (cmd === "login" || cmd === "logout") {
332
+ const { login, logout } = await import("./login.js");
333
+ if (!rest[0])
334
+ throw new Error(`usage: pine ${cmd} <provider> (for example opencode-go, opencode, anthropic, openai-codex)`);
335
+ if (cmd === "login")
336
+ await login(cfg, rest[0], flags["api-key"] === true ? "api_key" : flags.oauth === true ? "oauth" : undefined);
337
+ else
338
+ await logout(cfg, rest[0]);
339
+ return;
340
+ }
341
+ if (cmd === "mcp")
342
+ return mcpCommand(cfg, process.argv.slice(process.argv.indexOf("mcp") + 1), out);
343
+ if (cmd === "schedules") {
344
+ const list = [...cfg.schedules.map((s) => ({ s, whom: s.task ? "all agents" : `event ${s.topic} only` })), ...Object.values(cfg.agents).flatMap((a) => a.schedules.map((s) => ({ s, whom: s.session ? `conversation ${s.session} of ${a.id}` : `agent ${a.id}` })))];
345
+ if (!list.length)
346
+ return out("nothing is scheduled. An agent schedules something when you ask it to (\"every morning at seven ...\", \"remind me at five\"), or write it under \"schedules\"; see docs/references/events.md");
347
+ for (const { s, whom } of list) {
348
+ out(`${s.name} ${whom} ${whenText(s)}`);
349
+ if (s.task)
350
+ out(` ${s.task}`);
351
+ }
352
+ return;
353
+ }
354
+ if (cmd === "skills") {
355
+ const list = allSkills(cfg);
356
+ if (!list.length)
357
+ return out("no skills yet. An agent writes one when you ask it to (\"make a skill for this\"), or put a folder with a SKILL.md into the skills folder of pine's home or of an agent; see docs/references/agents.md");
358
+ for (const s of list) {
359
+ out(`${s.name} ${s.scope === "global" ? "all agents" : s.scope === "agent" ? `agent ${s.agent}` : `conversation ${s.session} of ${s.agent}`}${s.managed ? "" : " [yours, from the configuration]"}`);
360
+ out(` ${s.description}`);
361
+ out(` ${s.dir}`);
362
+ }
363
+ return;
364
+ }
365
+ if (cmd === "check") {
366
+ out(`home ${cfg.home}`);
367
+ out(`http ${cfg.http ? `${cfg.http.host}:${cfg.http.port}, hooks: ${Object.keys(cfg.http.hooks).join(", ") || "none"}` : "off"}`);
368
+ out(`global mcp ${Object.keys(cfg.mcp).join(", ") || "none"}`);
369
+ out(`routes ${cfg.routes.length}, schedules ${cfg.schedules.length}`);
370
+ for (const a of Object.values(cfg.agents)) {
371
+ out(`\nagent ${a.id}${a.id === mainAgentId(cfg) ? " (main)" : ""}: ${a.description || "no description"}`);
372
+ out(` workspace ${a.workspace}`);
373
+ out(` model ${a.model ?? cfg.defaults.model}`);
374
+ out(` tools ${(a.tools ?? ["read", "bash", "edit", "write"]).join(", ")}`);
375
+ out(` mcp ${Object.keys(effectiveMcp(cfg, a)).join(", ") || "none"}`);
376
+ out(` skills ${listSkills(cfg, a, "main").map((s) => s.name).join(", ") || "none"}`);
377
+ out(` delegates ${a.delegates.join(", ") || "none"}; sub-agents ${a.subagents.enabled ? `up to ${a.subagents.maxParallel}` : "off"}`);
378
+ out(` mode ${a.mode}`);
379
+ out(` sandbox ${a.sandbox.enabled ? `on, network ${a.sandbox.network ? "allowed" : "blocked"}` : "OFF"}; untrusted events: ${a.untrusted === false ? "refused" : a.untrusted.tools.join(", ") || "no tools"}`);
380
+ }
381
+ const warnings = audit(cfg);
382
+ out(warnings.length ? `\nSecurity notes:\n${warnings.map((w) => ` ! ${w}`).join("\n")}` : "\nNo security notes.");
383
+ return;
384
+ }
385
+ if (cmd === "start") {
386
+ const { startPine } = await import("./pine.js");
387
+ // One misbehaving extension or MCP client must not take every agent down with it.
388
+ process.on("unhandledRejection", (e) => console.error(`${new Date().toISOString()} ERROR [pine] unhandled rejection: ${e instanceof Error ? (e.stack ?? e.message) : e}`));
389
+ process.on("uncaughtException", (e) => console.error(`${new Date().toISOString()} ERROR [pine] uncaught exception: ${e.stack ?? e.message}`));
390
+ const pine = await startPine({ config: cfg });
391
+ const stop = (sig) => {
392
+ console.error(`\n${sig} received, stopping`);
393
+ void pine.stop().then(() => process.exit(0));
394
+ setTimeout(() => process.exit(1), 10_000).unref();
395
+ };
396
+ process.on("SIGINT", () => stop("SIGINT"));
397
+ process.on("SIGTERM", () => stop("SIGTERM"));
398
+ return;
399
+ }
400
+ const api = client(cfg);
401
+ const qs = (o) => {
402
+ const p = Object.entries(o).filter(([, v]) => v !== undefined && v !== true && v !== false);
403
+ return p.length ? `?${p.map(([k, v]) => `${k}=${encodeURIComponent(String(v))}`).join("&")}` : "";
404
+ };
405
+ switch (cmd) {
406
+ case "status":
407
+ return out(await api.get("/status"));
408
+ case "agents":
409
+ return out(await api.get("/agents"));
410
+ case "send": {
411
+ const agent = rest.shift();
412
+ if (!agent || !rest.length)
413
+ throw new Error("usage: pine send <agent> <text...>");
414
+ const r = await api.post(`/agents/${agent}/prompt`, { prompt: rest.join(" "), session: flags.session, wait: flags.wait !== false });
415
+ if (flags.wait === false)
416
+ return out(`task #${r.task} queued`);
417
+ if (r.status !== "done")
418
+ throw new Error(`task #${r.id} ${r.status}: ${r.error ?? ""}`);
419
+ return out(r.text);
420
+ }
421
+ case "emit": {
422
+ if (!rest[0])
423
+ throw new Error("usage: pine emit <topic> [json]");
424
+ const r = await api.post("/events", { topic: rest[0], payload: rest[1] !== undefined ? JSON.parse(rest[1]) : null });
425
+ return out(`event #${r.id}`);
426
+ }
427
+ case "events": {
428
+ const events = await api.get(`/events${qs({ topic: flags.topic, limit: flags.limit ?? 30 })}`);
429
+ for (const e of events)
430
+ out(line(e));
431
+ if (!flags.follow)
432
+ return;
433
+ const res = await fetch(`${api.base}/events/stream${qs({ topic: flags.topic })}`, { headers: { authorization: `Bearer ${api.token}` } });
434
+ let buf = "";
435
+ for await (const chunk of res.body) {
436
+ buf += Buffer.from(chunk).toString("utf8");
437
+ for (let i = buf.indexOf("\n\n"); i >= 0; i = buf.indexOf("\n\n")) {
438
+ const block = buf.slice(0, i);
439
+ buf = buf.slice(i + 2);
440
+ if (block.startsWith("data: "))
441
+ out(line(JSON.parse(block.slice(6))));
442
+ }
443
+ }
444
+ return;
445
+ }
446
+ case "tasks": {
447
+ const tasks = await api.get(`/tasks${qs({ agent: flags.agent, status: flags.status, limit: flags.limit ?? 20 })}`);
448
+ for (const t of tasks.reverse())
449
+ out(`#${String(t.id).padEnd(5)} ${when(t.created)} ${t.status.padEnd(9)} ${`${t.agent}/${t.session}`.padEnd(26)} ${t.kind.padEnd(9)} ${String(t.meta?.label ?? t.prompt).replace(/\s+/g, " ").slice(0, 90)}`);
450
+ return;
451
+ }
452
+ case "task":
453
+ return out(await api.get(`/tasks/${rest[0]}`));
454
+ case "cancel":
455
+ return out(await api.post(`/tasks/${rest[0]}/cancel`));
456
+ case "state":
457
+ for (const s of (await api.get(`/state${qs({ key: rest[0] })}`)))
458
+ out(`${s.key} = ${JSON.stringify(s.value)} (${when(s.ts)}, ${s.source})`);
459
+ return;
460
+ case "sources": {
461
+ const list = await api.get("/sources");
462
+ if (!list.length)
463
+ return out(`no event sources configured (add "sources" to pine.json or an agent.json; see docs/references/sources.md)`);
464
+ for (const s of list) {
465
+ out(`${s.name}${s.scope === "global" ? "" : ` (of ${s.scope})`} ${s.type} ${s.error ? `not running: ${s.error}` : s.running ? "running" : "idle, nobody is subscribed"}${s.trust === "owner" ? " [yours]" : ""}`);
466
+ if (s.description)
467
+ out(` ${s.description}`);
468
+ if (s.status)
469
+ out(` ${s.status}`);
470
+ out(` subscribers: ${s.subscribers.join(", ") || "none"} · ${s.last ? `last event ${when(s.last.ts)} (${s.last.topic}), ${s.events} since start` : "no events since start"}`);
471
+ }
472
+ return;
473
+ }
474
+ case "subscribe":
475
+ case "unsubscribe": {
476
+ const details = { note: flags.note, throttle: flags.throttle, separate: flags.separate === true, when: typeof flags.when === "string" ? JSON.parse(flags.when) : undefined };
477
+ if (flags.global === true) {
478
+ if (!rest[0])
479
+ throw new Error(`usage: pine ${cmd} --global <source>`);
480
+ if (cmd === "unsubscribe")
481
+ return out((await api.post("/unsubscribe", { source: rest[0] })).unsubscribed ? `all agents: no longer subscribed to ${rest[0]}` : `there was no subscription of all agents to ${rest[0]}`);
482
+ const all = await api.post("/subscribe", { source: rest[0], ...details });
483
+ return out(`all agents are subscribed to ${rest[0]} (${all.running ? "running" : "not running"}); it wakes ${all.agents.join(", ") || "nobody yet"}, each in its main conversation`);
484
+ }
485
+ if (!rest[0] || !rest[1])
486
+ throw new Error(`usage: pine ${cmd} <agent> <source>${cmd === "subscribe" ? " [--session name] [--note text] [--throttle 5m] [--when json]" : " [--session name]"}\n pine ${cmd} --global <source>`);
487
+ if (cmd === "unsubscribe") {
488
+ const r = await api.post(`/agents/${rest[0]}/unsubscribe`, { source: rest[1], session: typeof flags.session === "string" ? flags.session : undefined });
489
+ return out(r.unsubscribed ? `${rest[0]} is no longer subscribed to ${rest[1]}${r.sessions.some((x) => x !== "main") ? ` (conversations: ${r.sessions.join(", ")})` : ""}` : `${rest[0]} was not subscribed to ${rest[1]}`);
490
+ }
491
+ const info = await api.post(`/agents/${rest[0]}/subscribe`, { source: rest[1], session: flags.session, ...details });
492
+ return out(`${rest[0]} is subscribed to ${rest[1]} (${info.running ? "running" : "not running"}); each event wakes it in its conversation "${info.session}"`);
493
+ }
494
+ case "approvals": {
495
+ const list = await api.get("/approvals");
496
+ if (!list.length)
497
+ return out("nothing waits for approval");
498
+ for (const a of list)
499
+ out(`${a.id} ${a.agent} (task #${a.task}) wants ${a.summary.split("\n")[0]}\n why: ${a.why}\n expires ${when(a.expires)}`);
500
+ return;
501
+ }
502
+ case "approve":
503
+ case "deny":
504
+ return out(await api.post(`/approvals/${rest[0]}`, { allow: cmd === "approve", always: flags.always === true }));
505
+ case "mode": {
506
+ if (!rest[0])
507
+ throw new Error("usage: pine mode <agent> [manual|auto|bypass]");
508
+ if (rest[1])
509
+ return out(await api.post(`/agents/${rest[0]}/mode`, { mode: rest[1] }));
510
+ const agent = (await api.get("/agents")).find((a) => a.id === rest[0]);
511
+ if (!agent)
512
+ throw new Error(`unknown agent "${rest[0]}"`);
513
+ return out(agent.mode);
514
+ }
515
+ case "reset":
516
+ if (!rest[0])
517
+ throw new Error("usage: pine reset <agent> [session]");
518
+ return out(await api.post(`/agents/${rest[0]}/reset`, { session: rest[1] }));
519
+ default:
520
+ throw new Error(`unknown command "${cmd}". Run "pine help".`);
521
+ }
522
+ }
523
+ main().catch((e) => {
524
+ console.error(e instanceof ConfigError ? e.message : `pine: ${e instanceof Error ? e.message : e}`);
525
+ process.exit(1);
526
+ });