@timqi/pier 0.0.17 → 0.0.20
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/dist/agent/config-sync.js +123 -0
- package/dist/agent/config.js +95 -3
- package/dist/agent/events.js +26 -5
- package/dist/agent/listing.js +12 -13
- package/dist/agent/pi.js +75 -27
- package/dist/boards/boards.js +7 -6
- package/dist/channels/attach.js +18 -4
- package/dist/channels/config.js +7 -2
- package/dist/channels/dedup.js +9 -1
- package/dist/channels/lark.js +2 -6
- package/dist/channels/receipts.js +21 -6
- package/dist/channels/slack-tool.js +86 -25
- package/dist/channels/slack.js +2 -10
- package/dist/channels/telegram.js +1 -0
- package/dist/config-sync-fetch.js +97 -0
- package/dist/config-sync-task.js +59 -0
- package/dist/config-sync.js +179 -0
- package/dist/core/hub.js +23 -6
- package/dist/core/inbox.js +1 -1
- package/dist/core/reply.js +76 -2
- package/dist/core/router.js +162 -37
- package/dist/db.js +50 -0
- package/dist/main.js +32 -1
- package/dist/secrets.js +18 -6
- package/dist/tasks/agent.js +25 -23
- package/dist/tasks/callbacks.js +1 -1
- package/dist/tasks/definitions.js +28 -17
- package/dist/tasks/execution.js +11 -3
- package/dist/tasks/messages.js +58 -16
- package/dist/tasks/routes.js +58 -10
- package/dist/tasks/runs.js +0 -5
- package/dist/tasks/service.js +14 -4
- package/dist/tasks/store.js +87 -10
- package/dist/tasks/tool.js +8 -4
- package/dist/tools.js +32 -2
- package/dist/web/auth.js +17 -23
- package/dist/web/config-sync.js +91 -0
- package/dist/web/config.js +61 -0
- package/dist/web/explorer.js +20 -70
- package/dist/web/fs.js +159 -0
- package/dist/web/instance.js +11 -0
- package/dist/web/public/assets/activity-DYo7QS7k.js +5 -0
- package/dist/web/public/assets/activity-DYo7QS7k.js.br +0 -0
- package/dist/web/public/assets/activity-DYo7QS7k.js.gz +0 -0
- package/dist/web/public/assets/boards-CwohTbtv.js +1 -0
- package/dist/web/public/assets/boards-CwohTbtv.js.br +0 -0
- package/dist/web/public/assets/boards-CwohTbtv.js.gz +0 -0
- package/dist/web/public/assets/code-p-Tq1bMK.js +1 -0
- package/dist/web/public/assets/explorer-50no_DrD.js +5 -0
- package/dist/web/public/assets/explorer-50no_DrD.js.br +0 -0
- package/dist/web/public/assets/explorer-50no_DrD.js.gz +0 -0
- package/dist/web/public/assets/{ghostty-web-CV7F9_iW.js → ghostty-web-Bvhh3ZFk.js} +1 -1
- package/dist/web/public/assets/ghostty-web-Bvhh3ZFk.js.br +0 -0
- package/dist/web/public/assets/ghostty-web-Bvhh3ZFk.js.gz +0 -0
- package/dist/web/public/assets/hljs-tWqyD59G.js +3 -0
- package/dist/web/public/assets/hljs-tWqyD59G.js.br +0 -0
- package/dist/web/public/assets/hljs-tWqyD59G.js.gz +0 -0
- package/dist/web/public/assets/{index-CtR-Ob4T.css → index-DuZ2vERd.css} +1 -1
- package/dist/web/public/assets/index-DuZ2vERd.css.br +0 -0
- package/dist/web/public/assets/index-DuZ2vERd.css.gz +0 -0
- package/dist/web/public/assets/index-WrsNstrb.js +79 -0
- package/dist/web/public/assets/index-WrsNstrb.js.br +0 -0
- package/dist/web/public/assets/index-WrsNstrb.js.gz +0 -0
- package/dist/web/public/assets/runs-BAXZF-6r.js +1 -0
- package/dist/web/public/assets/runs-BAXZF-6r.js.br +0 -0
- package/dist/web/public/assets/runs-BAXZF-6r.js.gz +0 -0
- package/dist/web/public/assets/settings-BmNXgRne.js +6 -0
- package/dist/web/public/assets/settings-BmNXgRne.js.br +0 -0
- package/dist/web/public/assets/settings-BmNXgRne.js.gz +0 -0
- package/dist/web/public/assets/task-runs-CpgHxFLf.js +3 -0
- package/dist/web/public/assets/task-runs-CpgHxFLf.js.br +0 -0
- package/dist/web/public/assets/task-runs-CpgHxFLf.js.gz +0 -0
- package/dist/web/public/assets/tasks-DybtogPF.js +4 -0
- package/dist/web/public/assets/tasks-DybtogPF.js.br +0 -0
- package/dist/web/public/assets/tasks-DybtogPF.js.gz +0 -0
- package/dist/web/public/assets/terminal-m2_WYu5W.js +2 -0
- package/dist/web/public/assets/terminal-m2_WYu5W.js.br +0 -0
- package/dist/web/public/assets/terminal-m2_WYu5W.js.gz +0 -0
- package/dist/web/public/icon.svg.br +0 -0
- package/dist/web/public/icon.svg.gz +0 -0
- package/dist/web/public/index.html +13 -2
- package/dist/web/public/index.html.br +0 -0
- package/dist/web/public/index.html.gz +0 -0
- package/dist/web/public/manifest.webmanifest.br +0 -0
- package/dist/web/public/manifest.webmanifest.gz +0 -0
- package/dist/web/public/sw.js.br +0 -0
- package/dist/web/public/sw.js.gz +0 -0
- package/dist/web/route.js +17 -0
- package/dist/web/server.js +152 -66
- package/dist/web/terminal.js +2 -7
- package/package.json +2 -2
- package/skills/pier-slack/SKILL.md +4 -16
- package/skills/pier-tasks/SKILL.md +14 -9
- package/dist/web/files.js +0 -183
- package/dist/web/public/assets/index-DAxKtidH.js +0 -93
package/dist/web/server.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// Web workbench backend: REST + SSE, a pure consumer of core.
|
|
2
2
|
// See docs/design/03-web-workbench.md for the route contract.
|
|
3
3
|
import { hostname } from "node:os";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { readFile, stat } from "node:fs/promises";
|
|
6
|
+
import { isAbsolute, join, relative } from "node:path";
|
|
6
7
|
import { fileURLToPath } from "node:url";
|
|
7
8
|
import { serveStatic } from "@hono/node-server/serve-static";
|
|
8
9
|
import { Hono } from "hono";
|
|
@@ -10,18 +11,31 @@ import { compress } from "hono/compress";
|
|
|
10
11
|
import { streamSSE } from "hono/streaming";
|
|
11
12
|
import { EventHub } from "../core/hub.js";
|
|
12
13
|
import { logger } from "../log.js";
|
|
13
|
-
import { Router } from "../core/router.js";
|
|
14
|
+
import { QueueOperationError, Router } from "../core/router.js";
|
|
15
|
+
import { registerConfigRoutes } from "./config.js";
|
|
14
16
|
import { registerExplorerRoutes } from "./explorer.js";
|
|
15
|
-
import {
|
|
17
|
+
import { fileHeaders, MAX_FILE_BYTES, registerFsRoutes, scopedFile } from "./fs.js";
|
|
18
|
+
import { guarded } from "./route.js";
|
|
16
19
|
import { isThinkingLevel } from "../core/types.js";
|
|
17
20
|
import { SESSION_TITLE_MAX } from "../limits.js";
|
|
18
|
-
import { saveInbound } from "../core/inbox.js";
|
|
21
|
+
import { INBOX_DIR, saveInbound } from "../core/inbox.js";
|
|
19
22
|
import { MAX_INBOUND_BYTES } from "../core/inbound-file.js";
|
|
20
23
|
import { RepoIndex } from "./repos.js";
|
|
21
24
|
import {} from "./session-state.js";
|
|
22
25
|
import { registerInstanceRoutes } from "./instance.js";
|
|
23
26
|
import { registerProviderRoutes } from "./providers.js";
|
|
24
27
|
const log = logger("web");
|
|
28
|
+
async function queueResponse(action, status = 200) {
|
|
29
|
+
try {
|
|
30
|
+
return Response.json(await action(), { status });
|
|
31
|
+
}
|
|
32
|
+
catch (err) {
|
|
33
|
+
const code = err instanceof QueueOperationError
|
|
34
|
+
? err.reason === "draining" ? 503 : err.reason === "missing" ? 404 : 409
|
|
35
|
+
: 404;
|
|
36
|
+
return Response.json({ error: String(err) }, { status: code });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
25
39
|
/** A transcript without the bytes nobody has asked to see yet. A step's `args`
|
|
26
40
|
* and `output` are ~90% of a long session's snapshot and sit inside a
|
|
27
41
|
* collapsed group; the client fetches one turn's worth when it is opened. */
|
|
@@ -38,10 +52,26 @@ export const withTabPrefix = (html, prefix) => prefix
|
|
|
38
52
|
? html.replace("<title>Pier</title>", `<title>${prefix.replace(/&/g, "&").replace(/</g, "<")} - Pier</title>`)
|
|
39
53
|
: html;
|
|
40
54
|
const HEARTBEAT_MS = 15_000;
|
|
55
|
+
/** A reader that stopped reading must not queue frames without bound. Past
|
|
56
|
+
* this much frame text in flight the stream is dropped with a line in the
|
|
57
|
+
* log; EventSource reconnects and replays from its Last-Event-ID. */
|
|
58
|
+
const SSE_HIGH_WATER = 4 * 1024 * 1024;
|
|
41
59
|
// Canonical base64 only: Buffer.from(.., "base64") happily "decodes" garbage.
|
|
42
60
|
const BASE64_RE = /^[A-Za-z0-9+/]+={0,2}$/;
|
|
43
|
-
export function createServer({ factory, router, hub, sessions: state, config, providers, settings, catalog, names, onToolsChanged, validateCustomTools, secrets, onUnlocked, reload, updates, updater, backgroundRuns, channelOf, }) {
|
|
61
|
+
export function createServer({ factory, router, hub, sessions: state, config, providers, settings, catalog, names, onToolsChanged, validateCustomTools, secrets, onUnlocked, reload, updates, updater, backgroundRuns, activeBackgroundRunCounts, channelOf, }) {
|
|
44
62
|
const app = new Hono();
|
|
63
|
+
const epoch = randomUUID();
|
|
64
|
+
// One frame shared by synchronous fan-out to every watching tab. The epoch
|
|
65
|
+
// and wire encoding stay here; core only stamps transport-blind events.
|
|
66
|
+
let lastEvent = null;
|
|
67
|
+
let lastFrame = "";
|
|
68
|
+
const sseFrame = (e) => {
|
|
69
|
+
if (e !== lastEvent) {
|
|
70
|
+
lastEvent = e;
|
|
71
|
+
lastFrame = `data: ${JSON.stringify(e)}\nid: ${epoch}:${e.seq}\n\n`;
|
|
72
|
+
}
|
|
73
|
+
return lastFrame;
|
|
74
|
+
};
|
|
45
75
|
// A finished turn marks its session unread until some client reports it was
|
|
46
76
|
// seen (session selected + tab visible → POST read below). Server-side so
|
|
47
77
|
// every client shows the same attention state. Streaming → idle is the
|
|
@@ -62,8 +92,10 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
|
|
|
62
92
|
});
|
|
63
93
|
// Repository identity per project directory, refreshed off the request path.
|
|
64
94
|
const repos = new RepoIndex(() => hub.emitWorkspace({ type: "sessions-changed" }));
|
|
65
|
-
/** Background runs
|
|
66
|
-
|
|
95
|
+
/** Background runs still in flight, per launching session. One query for a
|
|
96
|
+
* whole list: asking row by row loaded every run object of every session to
|
|
97
|
+
* draw one dot each. */
|
|
98
|
+
const activeRuns = () => activeBackgroundRunCounts?.() ?? new Map();
|
|
67
99
|
/** The web channel's session for `id` — every session route resolves here. */
|
|
68
100
|
const ensure = (id) => router.ensure({ channelId: "web", conversationId: id });
|
|
69
101
|
// Sessions created here that Pi doesn't list yet — it persists a session
|
|
@@ -118,7 +150,7 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
|
|
|
118
150
|
// decided about it, and what is true of it right now.
|
|
119
151
|
// The listing's `modified` is dropped rather than forwarded: it dates a
|
|
120
152
|
// transcript, and no surface renders it.
|
|
121
|
-
const present = ({ modified: _drop, ...s }, own) => {
|
|
153
|
+
const present = ({ modified: _drop, ...s }, own, active) => {
|
|
122
154
|
return {
|
|
123
155
|
...s,
|
|
124
156
|
...(own?.sort === undefined ? {} : { sort: own.sort }),
|
|
@@ -133,7 +165,7 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
|
|
|
133
165
|
listed: own?.pinned ?? false,
|
|
134
166
|
unread: own?.unread ?? false,
|
|
135
167
|
channel: channelOf?.(s.id) ?? "web",
|
|
136
|
-
activeRuns:
|
|
168
|
+
activeRuns: active.get(s.id) ?? 0,
|
|
137
169
|
};
|
|
138
170
|
};
|
|
139
171
|
// The rail: the same rows as the full list, minus everything Projects is not
|
|
@@ -141,8 +173,9 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
|
|
|
141
173
|
// the summaries out of SQLite, which is what had to be kept in step.
|
|
142
174
|
app.get("/api/projects", async (c) => {
|
|
143
175
|
const flags = state.flags();
|
|
176
|
+
const active = activeRuns();
|
|
144
177
|
return c.json((await allSessions())
|
|
145
|
-
.map((s) => present(s, flags.get(s.id)))
|
|
178
|
+
.map((s) => present(s, flags.get(s.id), active))
|
|
146
179
|
.filter((row) => row.listed));
|
|
147
180
|
});
|
|
148
181
|
// One drag, one write of the list that changed: the projects, or one
|
|
@@ -167,7 +200,8 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
|
|
|
167
200
|
});
|
|
168
201
|
app.get("/api/sessions", async (c) => {
|
|
169
202
|
const flags = state.flags();
|
|
170
|
-
|
|
203
|
+
const active = activeRuns();
|
|
204
|
+
return c.json((await allSessions()).map((s) => present(s, flags.get(s.id), active)));
|
|
171
205
|
});
|
|
172
206
|
app.post("/api/sessions", async (c) => {
|
|
173
207
|
const body = await c.req.json().catch(() => ({}));
|
|
@@ -219,16 +253,28 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
|
|
|
219
253
|
guarded(app, "GET", "/api/sessions/:id/history", 404, async (c) => {
|
|
220
254
|
const id = c.req.param("id");
|
|
221
255
|
const session = await ensureLoadable(id);
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
256
|
+
// Async seam reads can straddle an event. Never label older content with a
|
|
257
|
+
// newer cursor, and never spin indefinitely if the session stays busy.
|
|
258
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
259
|
+
const lastSeq = hub.lastSeq(id);
|
|
260
|
+
const turns = (await session.history()).map(slim);
|
|
261
|
+
const queue = await session.pendingQueue();
|
|
262
|
+
if (hub.lastSeq(id) !== lastSeq)
|
|
263
|
+
continue;
|
|
264
|
+
return c.json({
|
|
265
|
+
turns, lastSeq, epoch,
|
|
266
|
+
model: session.model ?? null,
|
|
267
|
+
state: session.state,
|
|
268
|
+
context: session.contextUsage ?? null,
|
|
269
|
+
thinkingLevel: session.thinkingLevel,
|
|
270
|
+
queue,
|
|
271
|
+
queueRecovery: router.recoveryOf(id),
|
|
272
|
+
queueUncertain: router.queueUncertain(id),
|
|
273
|
+
backgroundRuns: backgroundRuns?.(id) ?? [],
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
log.warn(`snapshot for ${id} changed during all 3 reads`);
|
|
277
|
+
return c.json({ error: "Session changed while loading history; retry loading the session" }, 503);
|
|
232
278
|
});
|
|
233
279
|
// One turn's activity in full, for the group the user just opened. Indexed
|
|
234
280
|
// like the edit route below; the steps carry their own id and tool name so a
|
|
@@ -245,6 +291,29 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
|
|
|
245
291
|
// hands back what a surface shows, not the untruncated tool result.
|
|
246
292
|
return c.json({ steps: turn.steps ?? [] });
|
|
247
293
|
});
|
|
294
|
+
// Attachments, both directions: the agent links a file it produced, the chat
|
|
295
|
+
// renders a file the user sent. Read-only, and only from the session's own
|
|
296
|
+
// cwd or the inbox inbound files land in (core/inbox.ts) — a session route
|
|
297
|
+
// rather than an /api/fs one, because the session *is* the scope here.
|
|
298
|
+
guarded(app, "GET", "/api/sessions/:id/files", 400, async (c) => {
|
|
299
|
+
const raw = c.req.query("path");
|
|
300
|
+
if (!raw)
|
|
301
|
+
return c.json({ error: "path required" }, 400);
|
|
302
|
+
const id = c.req.param("id");
|
|
303
|
+
const cwd = nascent.get(id)?.cwd ?? (await factory.find(id))?.cwd;
|
|
304
|
+
// Absolute only: a link into a session's files is written by the agent or
|
|
305
|
+
// by Pier, and neither of them writes a path relative to anything.
|
|
306
|
+
const file = cwd && isAbsolute(raw) ? await scopedFile([cwd, INBOX_DIR], raw) : null;
|
|
307
|
+
if (!file)
|
|
308
|
+
return c.json({ error: "no such file" }, 404);
|
|
309
|
+
if ((await stat(file)).size > MAX_FILE_BYTES)
|
|
310
|
+
return c.json({ error: "file too large" }, 413);
|
|
311
|
+
const bytes = await readFile(file);
|
|
312
|
+
return c.body(bytes, 200, {
|
|
313
|
+
...fileHeaders(file, bytes, c.req.query("download") === "1"),
|
|
314
|
+
"cache-control": "private, max-age=60",
|
|
315
|
+
});
|
|
316
|
+
});
|
|
248
317
|
// Composer attachments: bytes land in the inbox, the message carries the
|
|
249
318
|
// path as a `[name](file:///…)` line the client builds itself — upload
|
|
250
319
|
// first, so the text it sends (and optimistically renders) is final.
|
|
@@ -354,8 +423,8 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
|
|
|
354
423
|
return c.json({ ok: true }, 202);
|
|
355
424
|
});
|
|
356
425
|
// Promote queued messages: "steer" delivers them into the running turn,
|
|
357
|
-
// "restart" aborts the turn and sends them as a fresh prompt.
|
|
358
|
-
//
|
|
426
|
+
// "restart" aborts the turn and sends them as a fresh prompt. Core owns
|
|
427
|
+
// exclusion and retains originals through the asynchronous handoff.
|
|
359
428
|
guarded(app, "POST", "/api/sessions/:id/queue/deliver", 404, async (c) => {
|
|
360
429
|
const id = c.req.param("id");
|
|
361
430
|
const body = await c.req.json().catch(() => null);
|
|
@@ -363,38 +432,20 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
|
|
|
363
432
|
if (mode !== "steer" && mode !== "restart") {
|
|
364
433
|
return c.json({ error: "mode must be steer or restart" }, 400);
|
|
365
434
|
}
|
|
366
|
-
|
|
367
|
-
if (router.isDraining())
|
|
368
|
-
return c.json({ error: "Pier is restarting — try again in a moment" }, 503);
|
|
369
|
-
const session = await ensure(id);
|
|
370
|
-
const { steering, followUp } = await session.clearQueue();
|
|
371
|
-
const text = [...steering, ...followUp].join("\n").trim();
|
|
372
|
-
if (!text)
|
|
373
|
-
return c.json({ error: "queue is empty" }, 409);
|
|
374
|
-
if (mode === "restart")
|
|
375
|
-
await router.abort(id); // resolves once idle
|
|
376
|
-
// No sender here, unlike the other dispatches: the queued texts were
|
|
377
|
-
// headed when they were first dispatched — and in a shared session they
|
|
378
|
-
// are other speakers' words, which an operator header would claim.
|
|
379
|
-
await router.dispatch({
|
|
380
|
-
key: { channelId: "web", conversationId: id },
|
|
381
|
-
senderId: "web",
|
|
382
|
-
text,
|
|
383
|
-
mode: mode === "steer" ? "steer" : "auto",
|
|
384
|
-
});
|
|
385
|
-
return c.json({ delivered: text }, 202);
|
|
435
|
+
return queueResponse(async () => ({ submitted: await router.deliverQueue(id, mode) }), 202);
|
|
386
436
|
});
|
|
387
437
|
// Recall: drop all pending queued messages and hand them back (composer restore).
|
|
388
438
|
guarded(app, "POST", "/api/sessions/:id/queue/recall", 404, async (c) => {
|
|
389
439
|
const id = c.req.param("id");
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
if (steering.length || followUp.length)
|
|
395
|
-
router.forgetSender(id);
|
|
396
|
-
return c.json({ messages: [...steering, ...followUp] });
|
|
440
|
+
return queueResponse(async () => {
|
|
441
|
+
const { steering, followUp } = await router.recallQueue(id);
|
|
442
|
+
return { messages: [...steering, ...followUp] };
|
|
443
|
+
});
|
|
397
444
|
});
|
|
445
|
+
guarded(app, "POST", "/api/sessions/:id/queue/recovery/:batchId/ack", 404, async (c) => queueResponse(async () => {
|
|
446
|
+
router.acknowledgeRecovery(c.req.param("id"), c.req.param("batchId"));
|
|
447
|
+
return { ok: true };
|
|
448
|
+
}));
|
|
398
449
|
// Shrink the context on demand: Pi summarizes the older transcript away and
|
|
399
450
|
// the session continues from the summary. Refused while streaming, like the
|
|
400
451
|
// edit route above and for the same reason — Pi's own compaction aborts a
|
|
@@ -446,16 +497,40 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
|
|
|
446
497
|
}));
|
|
447
498
|
app.get("/api/sessions/:id/events", (c) => {
|
|
448
499
|
const id = c.req.param("id");
|
|
449
|
-
const
|
|
450
|
-
|
|
451
|
-
|
|
500
|
+
const cursor = c.req.header("Last-Event-ID") ?? c.req.query("after") ?? "";
|
|
501
|
+
const match = /^([^:]+):(0|[1-9]\d*)$/.exec(cursor);
|
|
502
|
+
const lastId = match ? Number(match[2]) : NaN;
|
|
452
503
|
return streamSSE(c, async (stream) => {
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
504
|
+
if (match?.[1] !== epoch || !hub.covers(id, lastId)) {
|
|
505
|
+
await stream.writeSSE({ event: "reset", data: "snapshot required" });
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
let queued = 0; // frame chars written but not yet drained by the reader
|
|
509
|
+
const send = (frame) => {
|
|
510
|
+
if (stream.aborted || stream.closed)
|
|
511
|
+
return;
|
|
512
|
+
if (queued > SSE_HIGH_WATER) {
|
|
513
|
+
log.warn(`dropping slow event client for ${id} — ${queued} chars queued`);
|
|
514
|
+
stream.abort(); // unsubscribes; the client reconnects and replays
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
queued += frame.length;
|
|
518
|
+
void stream
|
|
519
|
+
.write(frame)
|
|
520
|
+
.catch((err) => log.warn(`event write for ${id} failed: ${String(err)}`))
|
|
521
|
+
.finally(() => (queued -= frame.length));
|
|
522
|
+
};
|
|
523
|
+
// Subscribe before the replay write can wait on its reader: an event that
|
|
524
|
+
// arrives while that write is backpressured must queue behind it, not fall
|
|
525
|
+
// between replay() and subscribe(). Both snapshots happen synchronously,
|
|
526
|
+
// so live writes cannot overtake the replay write.
|
|
527
|
+
const unsubscribe = hub.subscribe(id, (e) => send(sseFrame(e)));
|
|
458
528
|
stream.onAbort(unsubscribe);
|
|
529
|
+
// One write for the whole replay: a reconnect after a busy turn used to
|
|
530
|
+
// cost an await per event before the client saw any of them.
|
|
531
|
+
const missed = hub.replay(id, lastId);
|
|
532
|
+
if (missed.length)
|
|
533
|
+
await stream.write(missed.map(sseFrame).join(""));
|
|
459
534
|
// Heartbeat keeps proxies from closing the stream; loop ends on abort.
|
|
460
535
|
while (!stream.aborted) {
|
|
461
536
|
await stream.sleep(HEARTBEAT_MS);
|
|
@@ -509,12 +584,8 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
|
|
|
509
584
|
onSettingsChanged: () => recycle("instance settings"),
|
|
510
585
|
});
|
|
511
586
|
registerProviderRoutes(app, providers, () => recycle("provider configuration"));
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
config,
|
|
515
|
-
nascentCwd: (id) => nascent.get(id)?.cwd,
|
|
516
|
-
onConfigWritten: () => recycle("an agent file"),
|
|
517
|
-
});
|
|
587
|
+
registerConfigRoutes(app, { factory, config, onConfigWritten: () => recycle("an agent file") });
|
|
588
|
+
registerFsRoutes(app);
|
|
518
589
|
registerExplorerRoutes(app);
|
|
519
590
|
// serveStatic resolves `root` against the *working directory*, and an
|
|
520
591
|
// installed Pier is started from wherever the operator happens to be. The
|
|
@@ -530,6 +601,11 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
|
|
|
530
601
|
// at /login learns neither. Read once: neither can change under a process.
|
|
531
602
|
const prefix = tabPrefix(process.env.PIER_TITLE, hostname().split(".")[0] ?? "");
|
|
532
603
|
let shell = null;
|
|
604
|
+
// The one file the precompressed siblings below cannot cover: this route
|
|
605
|
+
// answers from the patched string, not from disk, and it is re-fetched on
|
|
606
|
+
// every navigation because it may not be cached. Exact path, like the two
|
|
607
|
+
// API routes above — never the SSE streams.
|
|
608
|
+
app.use("/", compress());
|
|
533
609
|
app.get("/", async (c, next) => {
|
|
534
610
|
// A release replaces hashed assets. Revalidate the shell on every
|
|
535
611
|
// navigation so a cached index cannot name bundles that no longer exist.
|
|
@@ -563,6 +639,16 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
|
|
|
563
639
|
c.header("cache-control", "private, max-age=31536000, immutable");
|
|
564
640
|
await next();
|
|
565
641
|
});
|
|
566
|
-
|
|
642
|
+
// `precompressed` looks for a `.br`/`.gz` sibling of the file it is about to
|
|
643
|
+
// serve and hands that over when the request accepts the encoding; the build
|
|
644
|
+
// writes them (vite.config.ts). Without it the 325 kB bundle, the 87 kB
|
|
645
|
+
// stylesheet and the 636 kB terminal emulator all go out verbatim.
|
|
646
|
+
// serveStatic only sets Vary when it selects a sibling. Identity must carry
|
|
647
|
+
// it too, or a cache can reuse that response for a later Brotli request.
|
|
648
|
+
app.use("/*", async (c, next) => {
|
|
649
|
+
await next();
|
|
650
|
+
c.header("Vary", "Accept-Encoding");
|
|
651
|
+
});
|
|
652
|
+
app.use("/*", serveStatic({ root: relative(process.cwd(), bundle) || ".", precompressed: true }));
|
|
567
653
|
return app;
|
|
568
654
|
}
|
package/dist/web/terminal.js
CHANGED
|
@@ -6,11 +6,10 @@
|
|
|
6
6
|
// to survive that — durability is tmux's job, not ours). This is the one
|
|
7
7
|
// WebSocket surface: a keystroke is a round trip, which SSE cannot carry
|
|
8
8
|
// upstream. Everything else stays on the event stream.
|
|
9
|
-
import { realpath, stat } from "node:fs/promises";
|
|
10
|
-
import { isAbsolute } from "node:path";
|
|
11
9
|
import { spawn } from "node-pty";
|
|
12
10
|
import { WebSocketServer } from "ws";
|
|
13
11
|
import { logger } from "../log.js";
|
|
12
|
+
import { scoped } from "./fs.js";
|
|
14
13
|
import { ALL, upgradeAuthorized } from "./auth.js";
|
|
15
14
|
const log = logger("terminal");
|
|
16
15
|
/** Live shells at once — a guard against forgotten spawns, not a quota. */
|
|
@@ -71,11 +70,7 @@ export class TerminalHub {
|
|
|
71
70
|
async attach(cwd, sock) {
|
|
72
71
|
let key;
|
|
73
72
|
try {
|
|
74
|
-
|
|
75
|
-
throw new Error("cwd must be an absolute directory");
|
|
76
|
-
key = await realpath(cwd);
|
|
77
|
-
if (!(await stat(key)).isDirectory())
|
|
78
|
-
throw new Error("cwd must be an absolute directory");
|
|
73
|
+
key = await scoped(cwd); // absolute, real, and a directory — or it throws
|
|
79
74
|
}
|
|
80
75
|
catch (err) {
|
|
81
76
|
control(sock, { t: "error", message: String(err) });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@timqi/pier",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.20",
|
|
4
4
|
"description": "A self-hosted workspace for coding agents: web workbench and IM channels in front of Pi sessions",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
6
|
"repository": "github:timqi/pier",
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
"vitest": "^4.1.11"
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
|
-
"@earendil-works/pi-coding-agent": "^0.
|
|
47
|
+
"@earendil-works/pi-coding-agent": "^0.85.1",
|
|
48
48
|
"@hono/node-server": "^2.1.1",
|
|
49
49
|
"@larksuiteoapi/node-sdk": "^1.73.0",
|
|
50
50
|
"croner": "^10.0.1",
|
|
@@ -24,20 +24,13 @@ this: there is no current conversation and `channel` is required.
|
|
|
24
24
|
|
|
25
25
|
`{"operation":"channels"}` lists what Pier can reach (`id`, `name`, `kind`,
|
|
26
26
|
`respondsToMessages`); pass either an id or a `#name` anywhere a channel is
|
|
27
|
-
wanted.
|
|
27
|
+
wanted.
|
|
28
28
|
|
|
29
29
|
## Never ask for an id
|
|
30
30
|
|
|
31
|
-
You already have them:
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
added by Pier, not typed by them. It appears only on a change — new speaker,
|
|
35
|
-
a time gap, a new day — so the last one you saw still applies (a gap alone
|
|
36
|
-
shows as time only, like `[14:23]`).
|
|
37
|
-
- **Anyone in a transcript** — every line carries `name[id]`.
|
|
38
|
-
- **This channel and thread** — from `context`.
|
|
39
|
-
|
|
40
|
-
Asking a human to paste their own user ID is never acceptable.
|
|
31
|
+
You already have them: the sender header on the message you are answering,
|
|
32
|
+
`name[id]` on every line of a transcript, and `context` for this channel and
|
|
33
|
+
thread. Asking a human to paste their own user ID is never acceptable.
|
|
41
34
|
|
|
42
35
|
## Reading
|
|
43
36
|
|
|
@@ -107,8 +100,6 @@ The response carries `ts` and `threadTs` for replying under what you posted.
|
|
|
107
100
|
`text` replaces the message outright; there is no partial edit. Read the
|
|
108
101
|
message first if you are changing part of it.
|
|
109
102
|
|
|
110
|
-
- Slack only lets Pier edit what **its own bot** posted — anyone else's message
|
|
111
|
-
answers `cant_update_message`, and the answer is to reply, not to retry.
|
|
112
103
|
- `ts` is always explicit, as with `delete`, and means nothing outside the
|
|
113
104
|
channel it was read in.
|
|
114
105
|
- The old text is gone — Slack keeps no version a reader can open, and may not
|
|
@@ -123,9 +114,6 @@ message first if you are changing part of it.
|
|
|
123
114
|
{"operation":"delete","channel":"#ops","ts":"1717243800.000100"}
|
|
124
115
|
```
|
|
125
116
|
|
|
126
|
-
Slack only lets Pier delete what **its own bot** posted — a person's message
|
|
127
|
-
answers `cant_delete_message`, and the answer is to ask them, not to retry.
|
|
128
|
-
|
|
129
117
|
- `ts` is always explicit. There is no "delete the last one" and no default
|
|
130
118
|
from the thread you are in: the wrong `ts` cannot be undone.
|
|
131
119
|
- A `ts` means nothing outside the conversation it came from — pass the
|
|
@@ -32,12 +32,18 @@ A summary's `triggerSource` is who fired that run (`agent` when you did, plus
|
|
|
32
32
|
`manual` / `cron` / `watch` / `task`); a definition's `trigger` is only its
|
|
33
33
|
schedule policy, where `manual` means on-demand — by a human or by you.
|
|
34
34
|
|
|
35
|
-
- `fresh`: clean context, requires `cwd
|
|
36
|
-
|
|
37
|
-
|
|
35
|
+
- `fresh`: clean context, requires `cwd` — how a child normally starts.
|
|
36
|
+
`reuse`: sends work to an existing session by id, continuing its history.
|
|
37
|
+
There is no way to copy your context into a child: what the child needs, you
|
|
38
|
+
write down.
|
|
39
|
+
- **The handoff is the prompt.** A child cannot see your discussion, so state:
|
|
40
|
+
the goal; the decisions *currently* in force (not the ones you argued out of);
|
|
41
|
+
the constraints the user added late; absolute paths; what "done" means and how
|
|
42
|
+
to verify it. Writing it is also the check — a decision you cannot state in a
|
|
43
|
+
sentence was not settled.
|
|
38
44
|
- The child inherits your current model unless `launch.model` is set.
|
|
39
45
|
Unknown models fail with the available list in the error. `launch` applies to
|
|
40
|
-
`fresh`
|
|
46
|
+
`fresh` only — a `reuse` session owns its own model and tools.
|
|
41
47
|
- The child has the same tools you do.
|
|
42
48
|
- Runs time out after 900s by default; set `timeoutSeconds` in the draft for
|
|
43
49
|
longer jobs.
|
|
@@ -65,9 +71,8 @@ group in core — you never track run ids across turns:
|
|
|
65
71
|
## Chains
|
|
66
72
|
|
|
67
73
|
Run step 1, end your turn, receive its callback, splice the needed part into
|
|
68
|
-
step 2's prompt. Children share nothing implicitly
|
|
69
|
-
|
|
70
|
-
expected output format. Branching and retries are your own logic between
|
|
74
|
+
step 2's prompt. Children share nothing implicitly — every prompt must be
|
|
75
|
+
self-contained: paths, acceptance criteria, expected output format. Branching and retries are your own logic between
|
|
71
76
|
turns.
|
|
72
77
|
|
|
73
78
|
## Choosing a model and thinking level
|
|
@@ -103,8 +108,8 @@ list in the error, so a stale guess costs one call.
|
|
|
103
108
|
|
|
104
109
|
`{"operation":"contact","reason":"decision","message":"..."}` returns a
|
|
105
110
|
receipt immediately. State what you are waiting for and **end your turn** —
|
|
106
|
-
never spin or poll. The answer
|
|
107
|
-
active; if your run already finished, Pier resumes your session with the
|
|
111
|
+
never spin or poll. The answer is steered into your current turn if your run is
|
|
112
|
+
still active; if your run already finished, Pier resumes your session with the
|
|
108
113
|
reply as the prompt. One open decision per run.
|
|
109
114
|
|
|
110
115
|
As a parent: the question arrives as a system input with a message id, steered
|