@pmndrs/pine 0.0.0-stage → 0.1.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 (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/http.js ADDED
@@ -0,0 +1,407 @@
1
+ import crypto from "node:crypto";
2
+ import http from "node:http";
3
+ import { mainAgentId } from "./config.js";
4
+ import { timingSafeEqual } from "./security.js";
5
+ import { addServer, listServers, removeServer } from "./mcp-config.js";
6
+ import { changes, diffOf, listDir, readText } from "./workspace-view.js";
7
+ import { errText, topicMatches } from "./util.js";
8
+ const MAX_BODY = 1024 * 1024;
9
+ class HttpError extends Error {
10
+ status;
11
+ constructor(status, message) {
12
+ super(message);
13
+ this.status = status;
14
+ }
15
+ }
16
+ /**
17
+ * Control API and webhooks. Every request except /health and /hook/* needs the bearer token.
18
+ * Requests that come from a web page (an Origin header, a foreign Host) are refused, so a site
19
+ * open in a browser on this machine cannot drive the daemon.
20
+ */
21
+ export class HttpServer {
22
+ cfg;
23
+ bus;
24
+ state;
25
+ tasks;
26
+ manager;
27
+ approvals;
28
+ log;
29
+ token;
30
+ server;
31
+ hits = new Map();
32
+ port = 0;
33
+ sources;
34
+ constructor(deps) {
35
+ this.sources = deps.sources;
36
+ this.cfg = deps.cfg;
37
+ this.bus = deps.bus;
38
+ this.state = deps.state;
39
+ this.tasks = deps.tasks;
40
+ this.manager = deps.manager;
41
+ this.approvals = deps.approvals;
42
+ this.log = deps.log;
43
+ this.token = deps.token;
44
+ }
45
+ async start() {
46
+ const { host, port } = this.cfg.http;
47
+ this.server = http.createServer((req, res) => void this.handle(req, res));
48
+ this.server.requestTimeout = 30_000;
49
+ this.server.headersTimeout = 10_000;
50
+ await new Promise((resolve, reject) => {
51
+ this.server.once("error", reject);
52
+ this.server.listen(port, host, resolve);
53
+ });
54
+ this.port = this.server.address().port;
55
+ this.log.info(`http listening on ${host}:${this.port}`);
56
+ }
57
+ async stop() {
58
+ this.server?.closeAllConnections?.();
59
+ await new Promise((r) => (this.server ? this.server.close(() => r()) : r()));
60
+ }
61
+ async handle(req, res) {
62
+ const send = (status, body) => {
63
+ if (res.headersSent)
64
+ return;
65
+ res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store", "x-content-type-options": "nosniff" });
66
+ res.end(JSON.stringify(body));
67
+ };
68
+ const sendPicture = (p) => {
69
+ if (!p)
70
+ return send(404, { error: "no such picture" });
71
+ res.writeHead(200, { "content-type": p.mime, "content-length": p.bytes.length, "cache-control": "no-store", "x-content-type-options": "nosniff" });
72
+ res.end(p.bytes);
73
+ };
74
+ try {
75
+ const url = new URL(req.url ?? "/", "http://pine.local");
76
+ const parts = url.pathname.split("/").filter(Boolean).map(decodeURIComponent);
77
+ const method = req.method ?? "GET";
78
+ if (method === "GET" && url.pathname === "/health")
79
+ return send(200, { ok: true });
80
+ if (parts[0] === "hook" && parts.length === 2 && method === "POST")
81
+ return send(202, await this.hook(parts[1], req));
82
+ this.guardBrowser(req);
83
+ const auth = req.headers.authorization ?? "";
84
+ if (!auth.startsWith("Bearer ") || !timingSafeEqual(auth.slice(7), this.token))
85
+ throw new HttpError(401, "missing or wrong bearer token");
86
+ if (method === "GET") {
87
+ const q = url.searchParams;
88
+ const num = (k) => (q.has(k) ? Number(q.get(k)) : undefined);
89
+ if (url.pathname === "/status")
90
+ return send(200, { ...this.manager.status(), agents: Object.keys(this.cfg.agents), approvals: this.approvals.pending().length, bypassWhenTainted: this.cfg.security.bypassWhenTainted });
91
+ if (url.pathname === "/agents")
92
+ return send(200, Object.values(this.cfg.agents).map((a) => ({ id: a.id, main: a.id === mainAgentId(this.cfg), description: a.description, workspace: a.workspace, model: a.model ?? this.cfg.defaults.model, thinking: a.thinking, mode: a.mode, delegates: a.delegates })));
93
+ if (url.pathname === "/tasks")
94
+ return send(200, this.tasks.list({ agent: q.get("agent") ?? undefined, session: q.get("session") ?? undefined, status: q.get("status") ?? undefined, limit: num("limit") }));
95
+ if (url.pathname === "/models")
96
+ return send(200, await this.manager.availableModels());
97
+ if (url.pathname === "/mcp")
98
+ return send(200, listServers(this.cfg));
99
+ if (url.pathname === "/sources")
100
+ return send(200, this.sources?.list() ?? []);
101
+ if (parts[0] === "agents" && parts[2] === "sources" && parts.length === 3) {
102
+ if (!this.cfg.agents[parts[1]])
103
+ throw new HttpError(404, "no such agent");
104
+ return send(200, this.sources?.available(parts[1]) ?? []);
105
+ }
106
+ if (parts[0] === "agents" && parts[2] === "sessions" && parts.length === 3) {
107
+ if (!this.cfg.agents[parts[1]])
108
+ throw new HttpError(404, "no such agent");
109
+ return send(200, this.manager.conversations(parts[1]));
110
+ }
111
+ if (url.pathname === "/ui/tabs")
112
+ return send(200, this.manager.uiTabs());
113
+ if (parts[0] === "tasks" && parts[2] === "transcript" && parts.length === 3) {
114
+ const t = this.manager.transcript(Number(parts[1]), q.get("full") === "1");
115
+ return t ? send(200, t) : send(404, { error: "no such task" });
116
+ }
117
+ // Pictures for a UI to show. They are never sent to a model from here.
118
+ if (parts[0] === "media" && parts[1] === "tool" && parts.length === 3)
119
+ return sendPicture(this.manager.toolImage(parts[2]));
120
+ if (parts[0] === "tasks" && parts[2] === "images" && parts.length === 4)
121
+ return sendPicture(this.manager.taskImage(Number(parts[1]), Number(parts[3])));
122
+ if (parts[0] === "agents" && parts[2] === "image" && parts.length === 3)
123
+ return sendPicture(this.manager.workspaceImage(parts[1], q.get("path") ?? "", q.get("session") ?? undefined));
124
+ // The folder a conversation works in, and what a UI shows of it: files, and what git says changed.
125
+ if (parts[0] === "agents" && parts[2] === "folder" && parts.length === 3) {
126
+ if (!this.cfg.agents[parts[1]])
127
+ throw new HttpError(404, "no such agent");
128
+ return send(200, { folder: this.manager.folderOf(parts[1], q.get("session") ?? "main"), workspace: this.cfg.agents[parts[1]].workspace });
129
+ }
130
+ if (parts[0] === "agents" && parts[2] === "folders" && parts.length === 3) {
131
+ if (!this.cfg.agents[parts[1]])
132
+ throw new HttpError(404, "no such agent");
133
+ return send(200, this.manager.folders(parts[1]));
134
+ }
135
+ if (parts[0] === "agents" && parts[2] === "view" && parts.length === 4) {
136
+ const root = this.manager.viewRoot(parts[1], q.get("session") ?? "main");
137
+ if (!root)
138
+ throw new HttpError(404, "no such agent");
139
+ const rel = q.get("path") ?? "";
140
+ const found = parts[3] === "changes" ? { root, ...(await changes(root)) } : parts[3] === "diff" ? await diffOf(root, rel) : parts[3] === "tree" ? listDir(root, rel) : parts[3] === "file" ? readText(root, rel) : undefined;
141
+ if (!found)
142
+ throw new HttpError(404, "nothing there");
143
+ return send(200, found);
144
+ }
145
+ if (parts[0] === "tasks" && parts.length === 2) {
146
+ const t = this.tasks.get(Number(parts[1]));
147
+ return t ? send(200, t) : send(404, { error: "no such task" });
148
+ }
149
+ if (url.pathname === "/events")
150
+ return send(200, this.bus.query({ topic: q.get("topic") ?? undefined, after: num("after"), limit: num("limit") }));
151
+ if (url.pathname === "/events/stream")
152
+ return this.stream(req, res, q.get("topic") ?? "#");
153
+ if (url.pathname === "/state")
154
+ return send(200, this.state.list(q.get("key") ?? "#"));
155
+ if (url.pathname === "/approvals")
156
+ return send(200, this.approvals.pending());
157
+ throw new HttpError(404, "not found");
158
+ }
159
+ if (method !== "POST")
160
+ throw new HttpError(405, "method not allowed");
161
+ // A message may carry attached files; everything else stays small.
162
+ const isPrompt = parts[0] === "agents" && parts[2] === "prompt" && parts.length === 3;
163
+ const body = (await this.json(req, isPrompt ? 64 * MAX_BODY : MAX_BODY));
164
+ if (url.pathname === "/images/fetch") {
165
+ if (typeof body.url !== "string")
166
+ throw new HttpError(400, "url required");
167
+ try {
168
+ return sendPicture(await this.manager.fetchImage(body.url));
169
+ }
170
+ catch (e) {
171
+ throw new HttpError(502, errText(e));
172
+ }
173
+ }
174
+ if (url.pathname === "/events") {
175
+ if (typeof body.topic !== "string")
176
+ throw new HttpError(400, "topic required");
177
+ const e = this.bus.publish(body.topic, body.payload ?? null, { source: "api", trust: body.trust === "untrusted" ? "untrusted" : "owner" });
178
+ return send(202, { id: e.id });
179
+ }
180
+ if (parts[0] === "agents" && parts[2] === "prompt" && parts.length === 3) {
181
+ if (typeof body.prompt !== "string" || !body.prompt)
182
+ throw new HttpError(400, "prompt required");
183
+ const task = this.manager.submit({ agent: parts[1], prompt: body.prompt, session: body.session, trust: "owner", kind: "prompt", priority: 10, attachments: Array.isArray(body.attachments) ? body.attachments : undefined });
184
+ if (body.wait === false)
185
+ return send(202, { task: task.id });
186
+ res.setTimeout(0);
187
+ req.on("close", () => !res.writableEnded && body.cancelOnDisconnect && this.manager.cancel(task.id));
188
+ return send(200, await this.manager.wait(task.id));
189
+ }
190
+ if (url.pathname === "/mcp/refresh") {
191
+ await this.manager.refreshSessions();
192
+ return send(200, { ok: true });
193
+ }
194
+ if (url.pathname === "/mcp") {
195
+ // The owner adds or removes a server while the daemon runs; sessions pick it up when next used.
196
+ if (typeof body.name !== "string")
197
+ throw new HttpError(400, "name required");
198
+ const agent = typeof body.agent === "string" ? body.agent : undefined;
199
+ if (body.remove === true) {
200
+ if (!removeServer(this.cfg, body.name, agent))
201
+ throw new HttpError(404, "no such server");
202
+ }
203
+ else {
204
+ if (!body.server || typeof body.server !== "object")
205
+ throw new HttpError(400, "server required");
206
+ if (JSON.stringify(body.server).includes("${"))
207
+ throw new HttpError(400, "a server that refers to ${NAME} values is read from the file at startup: add it with the daemon stopped, or restart afterwards");
208
+ addServer(this.cfg, body.name, body.server, agent);
209
+ }
210
+ await this.manager.refreshSessions(agent ? [agent] : undefined);
211
+ return send(200, { ok: true });
212
+ }
213
+ if (parts[0] === "agents" && parts[2] === "mode" && parts.length === 3) {
214
+ this.manager.setMode(parts[1], body.mode, body.anyway === true && typeof body.session === "string" ? body.session : undefined);
215
+ return send(200, { mode: body.mode });
216
+ }
217
+ if (parts[0] === "agents" && parts[2] === "thinking" && parts.length === 3) {
218
+ this.manager.setThinking(parts[1], String(body.level));
219
+ return send(200, { level: body.level });
220
+ }
221
+ if (parts[0] === "agents" && parts[2] === "model" && parts.length === 3) {
222
+ if (typeof body.model !== "string")
223
+ throw new HttpError(400, "model required");
224
+ await this.manager.setModel(parts[1], body.model);
225
+ return send(200, { model: body.model });
226
+ }
227
+ if (parts[0] === "agents" && (parts[2] === "subscribe" || parts[2] === "unsubscribe") && parts.length === 3) {
228
+ if (!this.sources)
229
+ throw new HttpError(404, "not found");
230
+ if (!this.cfg.agents[parts[1]])
231
+ throw new HttpError(404, "no such agent");
232
+ if (typeof body.source !== "string" || !body.source)
233
+ throw new HttpError(400, "source required");
234
+ try {
235
+ if (parts[2] === "unsubscribe") {
236
+ const sessions = await this.sources.unsubscribe(parts[1], body.source, typeof body.session === "string" ? body.session : undefined);
237
+ return send(200, { unsubscribed: sessions.length > 0, sessions });
238
+ }
239
+ const { source: _s, ...details } = body;
240
+ return send(200, await this.sources.subscribe(parts[1], body.source, details));
241
+ }
242
+ catch (e) {
243
+ throw new HttpError(400, errText(e));
244
+ }
245
+ }
246
+ // For all agents.
247
+ if ((url.pathname === "/subscribe" || url.pathname === "/unsubscribe") && this.sources) {
248
+ if (typeof body.source !== "string" || !body.source)
249
+ throw new HttpError(400, "source required");
250
+ try {
251
+ if (url.pathname === "/unsubscribe")
252
+ return send(200, { unsubscribed: await this.sources.unsubscribeAll(body.source) });
253
+ const { source: _s, ...details } = body;
254
+ return send(200, await this.sources.subscribeAll(body.source, details));
255
+ }
256
+ catch (e) {
257
+ throw new HttpError(400, errText(e));
258
+ }
259
+ }
260
+ // A conversation was closed in a UI: what it was subscribed to on its own ends with it.
261
+ if (parts[0] === "agents" && parts[2] === "close" && parts.length === 3) {
262
+ if (!this.cfg.agents[parts[1]])
263
+ throw new HttpError(404, "no such agent");
264
+ return send(200, { unsubscribed: (await this.sources?.closeSession(parts[1], String(body.session ?? ""))) ?? [] });
265
+ }
266
+ // The tabs a UI has open, kept so that it opens with the same ones next time.
267
+ if (url.pathname === "/ui/tabs")
268
+ return send(200, (this.manager.saveUiTabs(body.tabs, body.active, body.code), { ok: true }));
269
+ if (parts[0] === "agents" && parts[2] === "folder" && parts.length === 3) {
270
+ if (!this.cfg.agents[parts[1]])
271
+ throw new HttpError(404, "no such agent");
272
+ try {
273
+ return send(200, await this.manager.setFolder(parts[1], String(body.session ?? "main"), typeof body.folder === "string" && body.folder ? body.folder : null));
274
+ }
275
+ catch (e) {
276
+ throw new HttpError(409, errText(e));
277
+ }
278
+ }
279
+ if (parts[0] === "agents" && ["remove", "rename", "pin"].includes(parts[2]) && parts.length === 3) {
280
+ if (!this.cfg.agents[parts[1]])
281
+ throw new HttpError(404, "no such agent");
282
+ if (typeof body.session !== "string" || !body.session)
283
+ throw new HttpError(400, "session: the conversation's name");
284
+ try {
285
+ if (parts[2] === "pin")
286
+ return send(200, { pinned: this.manager.pinSession(parts[1], body.session, body.pinned !== false) });
287
+ if (parts[2] === "rename")
288
+ return send(200, await this.manager.renameSession(parts[1], body.session, String(body.to ?? "")));
289
+ return send(200, await this.manager.removeSession(parts[1], body.session));
290
+ }
291
+ catch (e) {
292
+ throw new HttpError(409, errText(e));
293
+ }
294
+ }
295
+ if (parts[0] === "agents" && parts[2] === "reset" && parts.length === 3)
296
+ return send(200, { reset: await this.manager.resetSession(parts[1], body.session) });
297
+ if (parts[0] === "tasks" && parts[2] === "cancel" && parts.length === 3)
298
+ return send(200, { cancelled: this.manager.cancel(Number(parts[1])) });
299
+ if (parts[0] === "approvals" && parts.length === 2) {
300
+ const values = body.values && typeof body.values === "object" && !Array.isArray(body.values) ? body.values : undefined;
301
+ const missing = body.allow === true ? this.approvals.missing(parts[1], values) : [];
302
+ if (missing.length)
303
+ throw new HttpError(400, `this request needs you to enter ${missing.join(", ")}: answer it in the terminal UI, where it is typed without being shown`);
304
+ return send(200, { decided: this.approvals.decide(parts[1], body.allow === true, "api", body.always === true, values) });
305
+ }
306
+ if (url.pathname === "/state") {
307
+ if (typeof body.key !== "string")
308
+ throw new HttpError(400, "key required");
309
+ this.state.set(body.key, body.value ?? null, { source: "api" });
310
+ return send(200, { ok: true });
311
+ }
312
+ throw new HttpError(404, "not found");
313
+ }
314
+ catch (e) {
315
+ const status = e instanceof HttpError ? e.status : /unknown agent|too many queued|does not accept|invalid topic|invalid state key|busy|is not defined in models.json|mode must be one of|at most 10 files|larger than 20 MB|inbox folder|can attach files|usable server name|MCP server needs|not a valid URL|must use https|reasoning level must be/.test(errText(e)) ? 400 : 500;
316
+ if (status === 500)
317
+ this.log.error("http request failed", e);
318
+ send(status, { error: status === 500 ? "internal error" : errText(e) });
319
+ }
320
+ }
321
+ /** DNS rebinding and cross-site requests: only accept a loopback (or configured) Host, never an Origin. */
322
+ guardBrowser(req) {
323
+ if (req.headers.origin || req.headers["sec-fetch-site"])
324
+ throw new HttpError(403, "requests from web pages are not accepted");
325
+ const host = (req.headers.host ?? "").replace(/:\d+$/, "").replace(/^\[|\]$/g, "");
326
+ const allowed = ["127.0.0.1", "localhost", "::1", this.cfg.http.host];
327
+ if (!allowed.includes(host))
328
+ throw new HttpError(403, "unexpected Host header");
329
+ }
330
+ async raw(req, limit = MAX_BODY) {
331
+ const chunks = [];
332
+ let size = 0;
333
+ for await (const c of req) {
334
+ size += c.length;
335
+ if (size > limit)
336
+ throw new HttpError(413, "body too large");
337
+ chunks.push(c);
338
+ }
339
+ return Buffer.concat(chunks);
340
+ }
341
+ async json(req, limit = MAX_BODY) {
342
+ if (!(req.headers["content-type"] ?? "").startsWith("application/json"))
343
+ throw new HttpError(415, "content-type must be application/json");
344
+ const buf = await this.raw(req, limit);
345
+ try {
346
+ return buf.length ? JSON.parse(buf.toString("utf8")) : {};
347
+ }
348
+ catch {
349
+ throw new HttpError(400, "invalid JSON");
350
+ }
351
+ }
352
+ /** A webhook: outside content by definition, so the event is untrusted whatever it says. */
353
+ async hook(name, req) {
354
+ const hook = this.cfg.http.hooks[name];
355
+ // A running event source of type webhook (or one of your own) takes requests under its name.
356
+ const sourceHook = hook ? undefined : this.sources?.hookFor(name);
357
+ if (!hook && !sourceHook)
358
+ throw new HttpError(404, "not found");
359
+ const now = Date.now();
360
+ const recent = (this.hits.get(name) ?? []).filter((t) => now - t < 60_000);
361
+ if (recent.length >= 120)
362
+ throw new HttpError(429, "too many requests");
363
+ recent.push(now);
364
+ this.hits.set(name, recent);
365
+ const body = await this.raw(req);
366
+ if (sourceHook) {
367
+ try {
368
+ await sourceHook({ headers: req.headers, body });
369
+ }
370
+ catch (e) {
371
+ throw new HttpError(typeof e?.status === "number" ? e.status : 500, typeof e?.status === "number" ? errText(e) : "internal error");
372
+ }
373
+ return { id: 0 };
374
+ }
375
+ if (!hook)
376
+ throw new HttpError(404, "not found");
377
+ if (hook.secret) {
378
+ const bearer = (req.headers.authorization ?? "").replace(/^Bearer /, "");
379
+ const sig = String(req.headers["x-hub-signature-256"] ?? req.headers["x-signature"] ?? "").replace(/^sha256=/, "");
380
+ const expected = crypto.createHmac("sha256", hook.secret).update(body).digest("hex");
381
+ if (!(bearer && timingSafeEqual(bearer, hook.secret)) && !(sig && timingSafeEqual(sig, expected)))
382
+ throw new HttpError(401, "bad signature");
383
+ }
384
+ let payload;
385
+ try {
386
+ payload = JSON.parse(body.toString("utf8"));
387
+ }
388
+ catch {
389
+ payload = { text: body.toString("utf8") };
390
+ }
391
+ const e = this.bus.publish(hook.topic, payload, { source: "http", trust: "untrusted" });
392
+ return { id: e.id };
393
+ }
394
+ stream(req, res, pattern) {
395
+ res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-store", connection: "keep-alive" });
396
+ res.write(": connected\n\n");
397
+ const off = this.bus.subscribe("#", (e) => {
398
+ if (topicMatches(pattern, e.topic))
399
+ res.write(`data: ${JSON.stringify(e)}\n\n`);
400
+ }, "http-stream");
401
+ const ping = setInterval(() => res.write(": ping\n\n"), 20_000);
402
+ req.on("close", () => {
403
+ off();
404
+ clearInterval(ping);
405
+ });
406
+ }
407
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Bounds how many agent runs talk to the model server at once. Higher priority waits less;
3
+ * equal priorities are first come, first served.
4
+ */
5
+ export declare class Limiter {
6
+ max: number;
7
+ private active;
8
+ private seq;
9
+ private queue;
10
+ constructor(max: number);
11
+ get stats(): {
12
+ active: number;
13
+ waiting: number;
14
+ max: number;
15
+ };
16
+ acquire(priority?: number): Promise<Slot>;
17
+ take(priority: number): Promise<void>;
18
+ free(): void;
19
+ }
20
+ /** One run's place at the model server. A run that only waits for other runs lends its place out with suspend(). */
21
+ export declare class Slot {
22
+ private limiter;
23
+ private priority;
24
+ private held;
25
+ private done;
26
+ private waiters;
27
+ private reacquiring?;
28
+ constructor(limiter: Limiter, priority: number);
29
+ /** Give the place up while `fn` runs (a delegated task, sub-agents), then take one again before returning. */
30
+ suspend<T>(fn: () => Promise<T>): Promise<T>;
31
+ release(): void;
32
+ }
package/src/limiter.js ADDED
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Bounds how many agent runs talk to the model server at once. Higher priority waits less;
3
+ * equal priorities are first come, first served.
4
+ */
5
+ export class Limiter {
6
+ max;
7
+ active = 0;
8
+ seq = 0;
9
+ queue = [];
10
+ constructor(max) {
11
+ this.max = Math.max(1, max);
12
+ }
13
+ get stats() {
14
+ return { active: this.active, waiting: this.queue.length, max: this.max };
15
+ }
16
+ async acquire(priority = 0) {
17
+ await this.take(priority);
18
+ return new Slot(this, priority);
19
+ }
20
+ take(priority) {
21
+ if (this.active < this.max) {
22
+ this.active++;
23
+ return Promise.resolve();
24
+ }
25
+ return new Promise((resolve) => {
26
+ this.queue.push({ priority, seq: this.seq++, resolve });
27
+ this.queue.sort((a, b) => b.priority - a.priority || a.seq - b.seq);
28
+ });
29
+ }
30
+ free() {
31
+ const next = this.queue.shift();
32
+ if (next)
33
+ next.resolve();
34
+ else
35
+ this.active--;
36
+ }
37
+ }
38
+ /** One run's place at the model server. A run that only waits for other runs lends its place out with suspend(). */
39
+ export class Slot {
40
+ limiter;
41
+ priority;
42
+ held = true;
43
+ done = false;
44
+ waiters = 0;
45
+ reacquiring;
46
+ constructor(limiter, priority) {
47
+ this.limiter = limiter;
48
+ this.priority = priority;
49
+ }
50
+ /** Give the place up while `fn` runs (a delegated task, sub-agents), then take one again before returning. */
51
+ async suspend(fn) {
52
+ this.waiters++;
53
+ if (this.reacquiring)
54
+ await this.reacquiring;
55
+ if (this.held && !this.done) {
56
+ this.held = false;
57
+ this.limiter.free();
58
+ }
59
+ try {
60
+ return await fn();
61
+ }
62
+ finally {
63
+ this.waiters--;
64
+ if (this.waiters === 0 && !this.held && !this.done) {
65
+ // A run that already started goes ahead of runs that have not.
66
+ this.reacquiring = this.limiter.take(this.priority + 1000).then(() => {
67
+ this.held = true;
68
+ this.reacquiring = undefined;
69
+ if (this.done)
70
+ this.release();
71
+ });
72
+ }
73
+ if (this.reacquiring)
74
+ await this.reacquiring;
75
+ }
76
+ }
77
+ release() {
78
+ this.done = true;
79
+ if (!this.held)
80
+ return;
81
+ this.held = false;
82
+ this.limiter.free();
83
+ }
84
+ }
package/src/login.d.ts ADDED
@@ -0,0 +1,21 @@
1
+ import type { PineConfig } from "./config.ts";
2
+ export interface Prompter {
3
+ text(message: string): Promise<string>;
4
+ secret(message: string): Promise<string>;
5
+ say(line: string): void;
6
+ open(url: string): void;
7
+ }
8
+ /**
9
+ * Read one line without showing it. The prompt stays on screen and says that the input is hidden,
10
+ * so a silent cursor is not mistaken for a hang. Works for typing and for pasting.
11
+ */
12
+ export declare function readSecret(message: string, input?: NodeJS.ReadableStream & {
13
+ setRawMode?(on: boolean): unknown;
14
+ isTTY?: boolean;
15
+ }, output?: NodeJS.WritableStream): Promise<string>;
16
+ /**
17
+ * Sign in to a model provider with pi's own login flow and store the credential in
18
+ * <home>/auth.json (mode 600). Agents cannot read that file.
19
+ */
20
+ export declare function login(cfg: PineConfig, provider: string, type?: "api_key" | "oauth", prompter?: Prompter): Promise<string[]>;
21
+ export declare function logout(cfg: PineConfig, provider: string): Promise<void>;
package/src/login.js ADDED
@@ -0,0 +1,126 @@
1
+ import { spawn } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import readline from "node:readline";
5
+ import { ModelRuntime } from "@earendil-works/pi-coding-agent";
6
+ /** Providers whose usual way in is a browser sign-in with a subscription. */
7
+ const OAUTH_FIRST = ["anthropic", "openai-codex", "github-copilot"];
8
+ /**
9
+ * Read one line without showing it. The prompt stays on screen and says that the input is hidden,
10
+ * so a silent cursor is not mistaken for a hang. Works for typing and for pasting.
11
+ */
12
+ export function readSecret(message, input = process.stdin, output = process.stdout) {
13
+ return new Promise((resolve, reject) => {
14
+ output.write(`${message} (input is hidden; paste or type it, then press enter): `);
15
+ let value = "";
16
+ const raw = Boolean(input.isTTY && input.setRawMode);
17
+ if (raw)
18
+ input.setRawMode(true);
19
+ input.resume();
20
+ const finish = (err) => {
21
+ input.off("data", onData);
22
+ if (raw)
23
+ input.setRawMode(false);
24
+ input.pause();
25
+ output.write("\n");
26
+ if (err)
27
+ reject(err);
28
+ else
29
+ resolve(value.trim());
30
+ };
31
+ const onData = (chunk) => {
32
+ for (const ch of chunk.toString("utf8")) {
33
+ if (ch === "\r" || ch === "\n")
34
+ return finish();
35
+ if (ch === "\x03")
36
+ return finish(new Error("cancelled"));
37
+ if (ch === "\x7f" || ch === "\b")
38
+ value = value.slice(0, -1);
39
+ else if (ch >= " ")
40
+ value += ch;
41
+ }
42
+ };
43
+ input.on("data", onData);
44
+ });
45
+ }
46
+ const terminalPrompter = () => {
47
+ // A readline interface per question: one that stays open would redraw the line and wipe a prompt
48
+ // printed outside it, and would fight the raw-mode reader over the keyboard.
49
+ const ask = (q) => new Promise((resolve) => {
50
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
51
+ rl.question(q, (answer) => {
52
+ rl.close();
53
+ resolve(answer);
54
+ });
55
+ });
56
+ return {
57
+ text: (m) => ask(`${m} `),
58
+ secret: (m) => readSecret(m),
59
+ say: (line) => console.log(line),
60
+ open: (url) => void spawn("open", [url], { stdio: "ignore", detached: true }).on("error", () => { }),
61
+ close: () => process.stdin.pause(),
62
+ };
63
+ };
64
+ async function runtime(cfg) {
65
+ process.env.PI_CODING_AGENT_DIR = path.join(cfg.dataDir, "pi");
66
+ process.env.PI_SKIP_VERSION_CHECK = "1";
67
+ process.env.PI_TELEMETRY = "0";
68
+ fs.mkdirSync(process.env.PI_CODING_AGENT_DIR, { recursive: true });
69
+ return ModelRuntime.create({ authPath: path.join(cfg.home, "auth.json"), modelsPath: path.join(cfg.home, "models.json") });
70
+ }
71
+ /**
72
+ * Sign in to a model provider with pi's own login flow and store the credential in
73
+ * <home>/auth.json (mode 600). Agents cannot read that file.
74
+ */
75
+ export async function login(cfg, provider, type, prompter) {
76
+ const rt = await runtime(cfg);
77
+ if (!rt.getProvider(provider))
78
+ throw new Error(`unknown provider "${provider}". Known: ${rt.getProviders().map((p) => p.id).sort().join(", ")}`);
79
+ const own = prompter ? undefined : terminalPrompter();
80
+ const p = prompter ?? own;
81
+ try {
82
+ const kind = type ?? (OAUTH_FIRST.includes(provider) ? "oauth" : "api_key");
83
+ await rt.login(provider, kind, {
84
+ prompt: async (q) => {
85
+ if (q.type === "secret")
86
+ return p.secret(q.message);
87
+ if (q.type === "select") {
88
+ q.options.forEach((o, i) => p.say(` ${i + 1}) ${o.label}${o.description ? ` - ${o.description}` : ""}`));
89
+ const n = Number(await p.text(`${q.message} [1-${q.options.length}]`));
90
+ return q.options[Math.max(1, Math.min(q.options.length, n || 1)) - 1].id;
91
+ }
92
+ return p.text(q.message);
93
+ },
94
+ notify: (e) => {
95
+ if (e.type === "auth_url") {
96
+ p.say(`Open this page to sign in:\n\n ${e.url}\n${e.instructions ? `\n${e.instructions}\n` : ""}`);
97
+ p.open(e.url);
98
+ }
99
+ else if (e.type === "device_code") {
100
+ p.say(`Go to ${e.verificationUri} and enter the code ${e.userCode}`);
101
+ p.open(e.verificationUri);
102
+ }
103
+ else if (e.message)
104
+ p.say(e.message);
105
+ },
106
+ });
107
+ await rt.settleCredentialWrites?.();
108
+ }
109
+ finally {
110
+ own?.close();
111
+ }
112
+ const authFile = path.join(cfg.home, "auth.json");
113
+ if (fs.existsSync(authFile))
114
+ fs.chmodSync(authFile, 0o600);
115
+ const models = [...(await rt.getAvailable())].filter((m) => m.provider === provider).map((m) => `${provider}/${m.id}`);
116
+ // A pasted key is stored as it is; only the first request shows whether the provider accepts it.
117
+ const head = (type ?? (OAUTH_FIRST.includes(provider) ? "oauth" : "api_key")) === "oauth" ? `Signed in to ${provider}.` : `Stored the key for ${provider} (it is checked when the first message is sent).`;
118
+ p.say(`${head} ${models.length} models are available, for example:\n${models.slice(0, 8).map((m) => ` ${m}`).join("\n")}\n\nUse one with "defaults": { "model": "<provider/id>" } in pine.json, or /model in the terminal UI.`);
119
+ return models;
120
+ }
121
+ export async function logout(cfg, provider) {
122
+ const rt = await runtime(cfg);
123
+ await rt.logout(provider);
124
+ await rt.settleCredentialWrites?.();
125
+ console.log(`Removed the stored credential for ${provider}.`);
126
+ }