@timqi/pier 0.1.0 → 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.
- package/README.md +58 -125
- package/dist/agent/config.js +6 -15
- package/dist/agent/credentials.js +11 -23
- package/dist/agent/events.js +42 -64
- package/dist/agent/listing.js +39 -92
- package/dist/agent/pi.js +103 -252
- package/dist/boards/boards.js +19 -29
- package/dist/channels/attach.js +14 -42
- package/dist/channels/chains.js +33 -37
- package/dist/channels/chunk.js +8 -28
- package/dist/channels/commands.js +3 -14
- package/dist/channels/config.js +33 -52
- package/dist/channels/control.js +4 -13
- package/dist/channels/conversations.js +8 -25
- package/dist/channels/dedup.js +8 -17
- package/dist/channels/gatekeeper.js +13 -23
- package/dist/channels/lark-api.js +23 -63
- package/dist/channels/lark-outbound.js +12 -44
- package/dist/channels/lark-panel.js +7 -23
- package/dist/channels/lark-render.js +18 -62
- package/dist/channels/lark.js +52 -141
- package/dist/channels/lines.js +13 -15
- package/dist/channels/panel.js +16 -36
- package/dist/channels/receipts.js +29 -52
- package/dist/channels/routes.js +3 -9
- package/dist/channels/runtime.js +12 -23
- package/dist/channels/slack-api.js +34 -86
- package/dist/channels/slack-directory.js +7 -23
- package/dist/channels/slack-outbound.js +12 -56
- package/dist/channels/slack-panel.js +4 -13
- package/dist/channels/slack-render.js +23 -91
- package/dist/channels/slack-tool.js +48 -171
- package/dist/channels/slack.js +73 -239
- package/dist/channels/telegram-api.js +8 -20
- package/dist/channels/telegram-panel.js +5 -21
- package/dist/channels/telegram-render.js +13 -40
- package/dist/channels/telegram.js +54 -146
- package/dist/channels/types.js +5 -16
- package/dist/cli.js +17 -41
- package/dist/config-sync.js +87 -4
- package/dist/core/hub.js +7 -20
- package/dist/core/identity.js +20 -59
- package/dist/core/inbound-file.js +15 -49
- package/dist/core/inbox.js +12 -34
- package/dist/core/queue.js +3 -5
- package/dist/core/reply.js +41 -142
- package/dist/core/router.js +209 -264
- package/dist/core/types.js +4 -0
- package/dist/db.js +88 -272
- package/dist/drain.js +57 -50
- package/dist/extensions/index.js +3 -11
- package/dist/extensions/web/anthropic.js +3 -9
- package/dist/extensions/web/artifacts.js +2 -5
- package/dist/extensions/web/content.js +4 -12
- package/dist/extensions/web/http.js +2 -6
- package/dist/extensions/web/language.js +8 -18
- package/dist/extensions/web/openai.js +1 -1
- package/dist/extensions/web/provider.js +5 -18
- package/dist/extensions/web/tools.js +19 -63
- package/dist/lock.js +98 -0
- package/dist/log.js +9 -26
- package/dist/main.js +84 -183
- package/dist/paths.js +10 -26
- package/dist/secrets.js +18 -45
- package/dist/service.js +31 -73
- package/dist/settings.js +19 -63
- package/dist/tasks/agent.js +24 -45
- package/dist/tasks/callbacks.js +8 -16
- package/dist/tasks/command.js +2 -6
- package/dist/tasks/definitions.js +39 -62
- package/dist/tasks/execution.js +41 -39
- package/dist/tasks/groups.js +8 -11
- package/dist/tasks/messages.js +88 -155
- package/dist/tasks/outbox.js +33 -54
- package/dist/tasks/routes.js +4 -7
- package/dist/tasks/runs.js +4 -9
- package/dist/tasks/service.js +29 -42
- package/dist/tasks/store.js +36 -27
- package/dist/tasks/tool.js +59 -60
- package/dist/tools-task.js +20 -60
- package/dist/tools.js +98 -325
- package/dist/update.js +20 -43
- package/dist/web/auth.js +118 -179
- package/dist/web/config-sync.js +2 -2
- package/dist/web/config.js +3 -7
- package/dist/web/explorer.js +10 -21
- package/dist/web/fs.js +20 -42
- package/dist/web/instance.js +35 -82
- package/dist/web/providers.js +5 -11
- package/dist/web/public/assets/{activity-Bl3vZukb.js → activity-B89_hH7q.js} +1 -1
- package/dist/web/public/assets/activity-B89_hH7q.js.br +0 -0
- package/dist/web/public/assets/activity-B89_hH7q.js.gz +0 -0
- package/dist/web/public/assets/{boards-DYuf4Mlj.js → boards-BeKW0ZXK.js} +1 -1
- package/dist/web/public/assets/boards-BeKW0ZXK.js.br +0 -0
- package/dist/web/public/assets/boards-BeKW0ZXK.js.gz +0 -0
- package/dist/web/public/assets/explorer-DIuMlaV3.js +4 -0
- package/dist/web/public/assets/explorer-DIuMlaV3.js.br +0 -0
- package/dist/web/public/assets/explorer-DIuMlaV3.js.gz +0 -0
- package/dist/web/public/assets/index-DzXDXra_.js +85 -0
- package/dist/web/public/assets/index-DzXDXra_.js.br +0 -0
- package/dist/web/public/assets/index-DzXDXra_.js.gz +0 -0
- package/dist/web/public/assets/index-eqQLVS8Q.css +2 -0
- package/dist/web/public/assets/index-eqQLVS8Q.css.br +0 -0
- package/dist/web/public/assets/index-eqQLVS8Q.css.gz +0 -0
- package/dist/web/public/assets/{runs-BLJu7EXN.js → runs-Cwy0mN8i.js} +1 -1
- package/dist/web/public/assets/runs-Cwy0mN8i.js.br +0 -0
- package/dist/web/public/assets/runs-Cwy0mN8i.js.gz +0 -0
- package/dist/web/public/assets/{settings-BrdVh-Zi.js → settings-DzZLmujq.js} +1 -1
- package/dist/web/public/assets/settings-DzZLmujq.js.br +0 -0
- package/dist/web/public/assets/settings-DzZLmujq.js.gz +0 -0
- package/dist/web/public/assets/{task-runs-CeQS1rxa.js → task-runs-BCakxFk8.js} +1 -1
- package/dist/web/public/assets/task-runs-BCakxFk8.js.br +0 -0
- package/dist/web/public/assets/task-runs-BCakxFk8.js.gz +0 -0
- package/dist/web/public/assets/tasks-BlzEbk11.js +4 -0
- package/dist/web/public/assets/tasks-BlzEbk11.js.br +0 -0
- package/dist/web/public/assets/tasks-BlzEbk11.js.gz +0 -0
- package/dist/web/public/index.html +30 -16
- package/dist/web/public/index.html.br +0 -0
- package/dist/web/public/index.html.gz +0 -0
- package/dist/web/public/sw.js +14 -2
- package/dist/web/public/sw.js.br +0 -0
- package/dist/web/public/sw.js.gz +0 -0
- package/dist/web/push.js +55 -77
- package/dist/web/route.js +3 -7
- package/dist/web/server.js +109 -190
- package/dist/web/session-state.js +13 -53
- package/dist/web/types.js +2 -4
- package/dist/web/webpush.js +10 -25
- package/docs/deploy.md +115 -330
- package/package.json +1 -1
- package/skills/pier-boards/SKILL.md +81 -160
- package/skills/pier-help/SKILL.md +23 -20
- package/skills/pier-slack/SKILL.md +2 -2
- package/skills/pier-tasks/SKILL.md +23 -15
- package/dist/config-sync-fetch.js +0 -84
- package/dist/limits.js +0 -14
- package/dist/web/public/assets/activity-Bl3vZukb.js.br +0 -0
- package/dist/web/public/assets/activity-Bl3vZukb.js.gz +0 -0
- package/dist/web/public/assets/boards-DYuf4Mlj.js.br +0 -0
- package/dist/web/public/assets/boards-DYuf4Mlj.js.gz +0 -0
- package/dist/web/public/assets/explorer-qJH_9nTE.js +0 -4
- package/dist/web/public/assets/explorer-qJH_9nTE.js.br +0 -0
- package/dist/web/public/assets/explorer-qJH_9nTE.js.gz +0 -0
- package/dist/web/public/assets/index-Dqdb-Eqt.js +0 -85
- package/dist/web/public/assets/index-Dqdb-Eqt.js.br +0 -0
- package/dist/web/public/assets/index-Dqdb-Eqt.js.gz +0 -0
- package/dist/web/public/assets/index-DzmMzvi_.css +0 -2
- package/dist/web/public/assets/index-DzmMzvi_.css.br +0 -0
- package/dist/web/public/assets/index-DzmMzvi_.css.gz +0 -0
- package/dist/web/public/assets/runs-BLJu7EXN.js.br +0 -0
- package/dist/web/public/assets/runs-BLJu7EXN.js.gz +0 -0
- package/dist/web/public/assets/settings-BrdVh-Zi.js.br +0 -0
- package/dist/web/public/assets/settings-BrdVh-Zi.js.gz +0 -0
- package/dist/web/public/assets/task-runs-CeQS1rxa.js.br +0 -0
- package/dist/web/public/assets/task-runs-CeQS1rxa.js.gz +0 -0
- package/dist/web/public/assets/tasks-bcb3fYdK.js +0 -4
- package/dist/web/public/assets/tasks-bcb3fYdK.js.br +0 -0
- package/dist/web/public/assets/tasks-bcb3fYdK.js.gz +0 -0
package/dist/web/server.js
CHANGED
|
@@ -16,8 +16,7 @@ import { registerConfigRoutes } from "./config.js";
|
|
|
16
16
|
import { registerExplorerRoutes } from "./explorer.js";
|
|
17
17
|
import { fileHeaders, MAX_FILE_BYTES, registerFsRoutes } from "./fs.js";
|
|
18
18
|
import { guarded } from "./route.js";
|
|
19
|
-
import { isThinkingLevel } from "../core/types.js";
|
|
20
|
-
import { SESSION_TITLE_MAX } from "../limits.js";
|
|
19
|
+
import { isThinkingLevel, SESSION_TITLE_MAX } from "../core/types.js";
|
|
21
20
|
import { saveInbound } from "../core/inbox.js";
|
|
22
21
|
import { MAX_INBOUND_BYTES } from "../core/inbound-file.js";
|
|
23
22
|
import {} from "./session-state.js";
|
|
@@ -35,33 +34,47 @@ async function queueResponse(action, status = 200) {
|
|
|
35
34
|
return Response.json({ error: String(err) }, { status: code });
|
|
36
35
|
}
|
|
37
36
|
}
|
|
38
|
-
/** A
|
|
39
|
-
*
|
|
40
|
-
* collapsed group; the client fetches one turn's worth when it is opened. */
|
|
37
|
+
/** A step's `args` and `output` are ~90% of a long session's snapshot and sit
|
|
38
|
+
* in a collapsed group; the client fetches one turn's worth when opened. */
|
|
41
39
|
const slim = (turn) => turn.steps
|
|
42
40
|
? { ...turn, steps: turn.steps.map(({ args: _args, output: _output, ...step }) => step) }
|
|
43
41
|
: turn;
|
|
44
|
-
/**
|
|
45
|
-
*
|
|
46
|
-
* question it has to answer before the browser truncates — `staging - g1`. */
|
|
42
|
+
/** `$PIER_TITLE`, then the machine: the label leads because a tab is narrow
|
|
43
|
+
* and "which instance" must survive truncation. */
|
|
47
44
|
export const tabPrefix = (title, host) => [title?.trim(), host.trim()].filter(Boolean).join(" - ").slice(0, 60);
|
|
48
|
-
/** `<title>staging - g1 - Pier</title>`. Nothing to say, or a shell that does
|
|
49
|
-
* not say `Pier`: leave it exactly as built. */
|
|
50
45
|
export const withTabPrefix = (html, prefix) => prefix
|
|
51
46
|
? html.replace("<title>Pier</title>", `<title>${prefix.replace(/&/g, "&").replace(/</g, "<")} - Pier</title>`)
|
|
52
47
|
: html;
|
|
53
48
|
const HEARTBEAT_MS = 15_000;
|
|
54
|
-
/**
|
|
55
|
-
* this much frame text in flight the stream is dropped with a line in the
|
|
56
|
-
* log; EventSource reconnects and replays from its Last-Event-ID. */
|
|
49
|
+
/** Past this much frame text in flight a stream is dropped. */
|
|
57
50
|
const SSE_HIGH_WATER = 4 * 1024 * 1024;
|
|
51
|
+
/** Both SSE routes: Hono queues every write, so backpressure alone never stops
|
|
52
|
+
* a caller from adding more. Neither stream needs durable replay to recover —
|
|
53
|
+
* the session stream resumes from Last-Event-ID, the workspace stream re-lists
|
|
54
|
+
* — so a reader past the ceiling is dropped instead of buffered. */
|
|
55
|
+
function boundedWriter(stream, who) {
|
|
56
|
+
let queued = 0; // frame chars written but not yet drained by the reader
|
|
57
|
+
return (frame) => {
|
|
58
|
+
if (stream.aborted || stream.closed)
|
|
59
|
+
return;
|
|
60
|
+
if (queued > SSE_HIGH_WATER) {
|
|
61
|
+
log.warn(`dropping slow event client for ${who} — ${queued} chars queued`);
|
|
62
|
+
stream.abort(); // unsubscribes; the client reconnects
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
queued += frame.length;
|
|
66
|
+
void stream
|
|
67
|
+
.write(frame)
|
|
68
|
+
.catch((err) => log.warn(`event write for ${who} failed: ${String(err)}`))
|
|
69
|
+
.finally(() => (queued -= frame.length));
|
|
70
|
+
};
|
|
71
|
+
}
|
|
58
72
|
// Canonical base64 only: Buffer.from(.., "base64") happily "decodes" garbage.
|
|
59
73
|
const BASE64_RE = /^[A-Za-z0-9+/]+={0,2}$/;
|
|
60
74
|
export function createServer({ factory, router, hub, sessions: state, config, providers, settings, catalog, names, onToolsChanged, validateCustomTools, secrets, onUnlocked, reload, updates, updater, backgroundRuns, activeBackgroundRunCounts, taskSessions, channelOf, }) {
|
|
61
75
|
const app = new Hono();
|
|
62
76
|
const epoch = randomUUID();
|
|
63
|
-
// One frame shared by synchronous fan-out to every watching tab.
|
|
64
|
-
// and wire encoding stay here; core only stamps transport-blind events.
|
|
77
|
+
// One frame shared by synchronous fan-out to every watching tab.
|
|
65
78
|
let lastEvent = null;
|
|
66
79
|
let lastFrame = "";
|
|
67
80
|
const sseFrame = (e) => {
|
|
@@ -71,19 +84,11 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
|
|
|
71
84
|
}
|
|
72
85
|
return lastFrame;
|
|
73
86
|
};
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
// Only for the workbench's own sessions: an IM turn was already delivered to
|
|
81
|
-
// the chat it came from and a subagent's to its supervisor by callback, so no
|
|
82
|
-
// look here is owed for either — and none could clear the mark either, since
|
|
83
|
-
// the ack needs the session on screen. Marked anyway, they were a flag that
|
|
84
|
-
// only ever accumulated, and every reader had to subtract them again. The two
|
|
85
|
-
// facts are the ones the list already draws a row from (`present` below): no
|
|
86
|
-
// durable conversation row, and not a session a run made for itself.
|
|
87
|
+
// Server-side so every client shows the same attention state; it needs a
|
|
88
|
+
// start we witnessed, so a session that boots idle stays untouched. Only the
|
|
89
|
+
// workbench's own sessions: an IM turn or a subagent's was delivered
|
|
90
|
+
// elsewhere, and nothing could ever clear the mark (the ack needs the session
|
|
91
|
+
// on screen).
|
|
87
92
|
const workbenchOwn = (id) => (channelOf?.(id) ?? "web") === "web" && !(taskSessions?.().has(id) ?? false);
|
|
88
93
|
const runningNow = new Set();
|
|
89
94
|
hub.subscribeWorkspace((e) => {
|
|
@@ -100,23 +105,15 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
|
|
|
100
105
|
state.setUnread(e.sessionId, true);
|
|
101
106
|
hub.emitWorkspace({ type: "sessions-changed" });
|
|
102
107
|
});
|
|
103
|
-
/**
|
|
104
|
-
* whole list: asking row by row loaded every run object of every session to
|
|
105
|
-
* draw one dot each. */
|
|
108
|
+
/** One query for a whole list, not one per row. */
|
|
106
109
|
const activeRuns = () => activeBackgroundRunCounts?.() ?? new Map();
|
|
107
|
-
/** The web channel's session for `id` — every session route resolves here. */
|
|
108
110
|
const ensure = (id) => router.ensure({ channelId: "web", conversationId: id });
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
// so every client sees a new session immediately; dropped once Pi lists it.
|
|
111
|
+
// Pi persists a session only once the first assistant message lands; until
|
|
112
|
+
// then the rail lists it from here.
|
|
112
113
|
const nascent = new Map();
|
|
113
|
-
/**
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
* alone, clicking it 404s forever. The load path is where a ghost is
|
|
117
|
-
* discovered, so it is where the entry and its row are dropped and every
|
|
118
|
-
* rail told; the 404 then says what happened instead of looking like a
|
|
119
|
-
* crash (§5b). */
|
|
114
|
+
/** A session created and never messaged does not survive an eviction (Pi
|
|
115
|
+
* persisted nothing) but is still in `nascent`; left alone, clicking it 404s
|
|
116
|
+
* forever. Dropped here, and the 404 says what happened (§5). */
|
|
120
117
|
const ensureLoadable = async (id) => {
|
|
121
118
|
try {
|
|
122
119
|
return await ensure(id);
|
|
@@ -131,23 +128,17 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
|
|
|
131
128
|
throw err;
|
|
132
129
|
}
|
|
133
130
|
};
|
|
134
|
-
//
|
|
135
|
-
// warm, one scan cold. Concurrent consumers share it, whatever the factory
|
|
136
|
-
// behind the seam retains of its own; nothing here is cached past the last
|
|
137
|
-
// of them.
|
|
131
|
+
// Concurrent consumers share one scan; nothing is cached past the last of them.
|
|
138
132
|
let listing;
|
|
139
133
|
const listSessions = () => listing ??= factory.list().finally(() => {
|
|
140
134
|
listing = undefined;
|
|
141
135
|
});
|
|
142
|
-
/** Every session the rail may show: what Pi has written, plus the ones
|
|
143
|
-
* created here that it has not persisted yet, minus the ones task runs
|
|
144
|
-
* made for themselves. */
|
|
145
136
|
const allSessions = async () => {
|
|
146
137
|
const sessions = await listSessions();
|
|
147
138
|
for (const s of sessions)
|
|
148
139
|
nascent.delete(s.id);
|
|
149
|
-
// A session created but never prompted would otherwise
|
|
150
|
-
//
|
|
140
|
+
// A session created but never prompted would otherwise hold a working-set
|
|
141
|
+
// slot forever.
|
|
151
142
|
for (const [id, n] of nascent) {
|
|
152
143
|
if (Date.now() - n.createdAt > 86_400_000) {
|
|
153
144
|
nascent.delete(id);
|
|
@@ -160,10 +151,8 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
|
|
|
160
151
|
...sessions.filter((s) => !owned.has(s.id)),
|
|
161
152
|
];
|
|
162
153
|
};
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
// the rail's working set, when it has one; `modified` rides along for the
|
|
166
|
-
// row's tooltip, and orders nothing.
|
|
154
|
+
// `rank` is the place in the rail's working set; `modified` is for the
|
|
155
|
+
// row's tooltip and orders nothing.
|
|
167
156
|
const present = (s, own, active) => ({
|
|
168
157
|
...s,
|
|
169
158
|
...(own?.rank === undefined ? {} : { rank: own.rank }),
|
|
@@ -185,6 +174,17 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
|
|
|
185
174
|
const active = activeRuns();
|
|
186
175
|
return c.json((await allSessions()).map((s) => present(s, flags.get(s.id), active)));
|
|
187
176
|
});
|
|
177
|
+
// One session by id, the listing's filters aside: a task run's own session is
|
|
178
|
+
// never a row (allSessions), and the pane that opened it from Runs still has
|
|
179
|
+
// a header to name and a session info panel to fill.
|
|
180
|
+
app.get("/api/sessions/:id", async (c) => {
|
|
181
|
+
const id = c.req.param("id");
|
|
182
|
+
const n = nascent.get(id);
|
|
183
|
+
const summary = n ? { id, ...n } : (await listSessions()).find((s) => s.id === id);
|
|
184
|
+
if (!summary)
|
|
185
|
+
return c.json({ error: `no session ${id}` }, 404);
|
|
186
|
+
return c.json(present(summary, state.flags().get(id), activeRuns()));
|
|
187
|
+
});
|
|
188
188
|
// What was said, across every session: the palette's Messages section. The
|
|
189
189
|
// factory owns the index and the ranking; an empty query is an empty answer,
|
|
190
190
|
// not a listing of everything.
|
|
@@ -194,28 +194,22 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
|
|
|
194
194
|
});
|
|
195
195
|
app.post("/api/sessions", async (c) => {
|
|
196
196
|
const body = await c.req.json().catch(() => ({}));
|
|
197
|
-
//
|
|
197
|
+
// Never in pier's own directory.
|
|
198
198
|
if (typeof body.cwd !== "string" || !body.cwd)
|
|
199
199
|
return c.json({ error: "cwd required" }, 400);
|
|
200
|
-
//
|
|
201
|
-
//
|
|
202
|
-
// prompted. Resolved here too, or that row is the one place a directory
|
|
203
|
-
// reached through a symlink still looks like a project of its own.
|
|
200
|
+
// Resolved like the seam does (agent/pi.ts), or the nascent row is the one
|
|
201
|
+
// place a symlinked directory still looks like a project of its own.
|
|
204
202
|
const cwd = await realpath(body.cwd).catch(() => body.cwd);
|
|
205
203
|
const session = await factory.create({ cwd });
|
|
206
204
|
const createdAt = Date.now();
|
|
207
205
|
nascent.set(session.id, { cwd, createdAt });
|
|
208
206
|
router.attach({ channelId: "web", conversationId: session.id }, session);
|
|
209
|
-
// Created is as good as spoken to:
|
|
210
|
-
//
|
|
211
|
-
// first message. Ghosts give the slot back (`ensureLoadable`, the expiry
|
|
212
|
-
// above).
|
|
207
|
+
// Created is as good as spoken to: a row born below the working set would
|
|
208
|
+
// jump on the first message.
|
|
213
209
|
state.promote(session.id);
|
|
214
210
|
hub.emitWorkspace({ type: "sessions-changed" });
|
|
215
211
|
return c.json({ id: session.id }, 201);
|
|
216
212
|
});
|
|
217
|
-
// Seen = read: a client with the session selected and the tab visible acks
|
|
218
|
-
// here; the broadcast moves every other client's dot back to idle.
|
|
219
213
|
app.post("/api/sessions/:id/read", (c) => {
|
|
220
214
|
const id = c.req.param("id");
|
|
221
215
|
if (state.unread(id)) {
|
|
@@ -224,13 +218,10 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
|
|
|
224
218
|
}
|
|
225
219
|
return c.json({ ok: true });
|
|
226
220
|
});
|
|
227
|
-
//
|
|
228
|
-
//
|
|
229
|
-
// would sit on events until the encoder's buffer filled.
|
|
221
|
+
// Only these two: compressing the SSE streams would sit on events until the
|
|
222
|
+
// encoder's buffer filled.
|
|
230
223
|
app.use("/api/sessions/:id/history", compress());
|
|
231
224
|
app.use("/api/sessions/:id/turns/:index/steps", compress());
|
|
232
|
-
// Snapshot: everything a fresh client needs before it starts consuming
|
|
233
|
-
// deltas from SSE — transcript, live state, pending queue, model.
|
|
234
225
|
guarded(app, "GET", "/api/sessions/:id/history", 404, async (c) => {
|
|
235
226
|
const id = c.req.param("id");
|
|
236
227
|
const session = await ensureLoadable(id);
|
|
@@ -268,21 +259,15 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
|
|
|
268
259
|
const turn = (await (await ensure(c.req.param("id"))).history())[index];
|
|
269
260
|
if (!turn)
|
|
270
261
|
return c.json({ error: `no turn at index ${index}` }, 404);
|
|
271
|
-
// Already capped at MAX_STEP_OUTPUT by the transcript rebuild: this route
|
|
272
|
-
// hands back what a surface shows, not the untruncated tool result.
|
|
273
262
|
return c.json({ steps: turn.steps ?? [] });
|
|
274
263
|
});
|
|
275
|
-
//
|
|
276
|
-
//
|
|
277
|
-
// boundary here is the Console password (web/fs.ts), not the session's cwd:
|
|
278
|
-
// a cwd is chosen by whoever creates the session, so confining to it stopped
|
|
279
|
-
// nothing and left a report the agent wrote elsewhere as a dead card.
|
|
264
|
+
// Any readable file: the boundary is the Console password (web/fs.ts), not
|
|
265
|
+
// the session's cwd, which is chosen by whoever creates the session.
|
|
280
266
|
guarded(app, "GET", "/api/sessions/:id/files", 400, async (c) => {
|
|
281
267
|
const raw = c.req.query("path");
|
|
282
268
|
if (!raw)
|
|
283
269
|
return c.json({ error: "path required" }, 400);
|
|
284
|
-
// Absolute only:
|
|
285
|
-
// by Pier, and neither of them writes a path relative to anything.
|
|
270
|
+
// Absolute only: neither the agent nor Pier writes a relative link.
|
|
286
271
|
const file = isAbsolute(raw) ? await realpath(raw).catch(() => null) : null;
|
|
287
272
|
const info = file ? await stat(file).catch(() => null) : null;
|
|
288
273
|
if (!file || !info?.isFile())
|
|
@@ -295,9 +280,7 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
|
|
|
295
280
|
"cache-control": "private, max-age=60",
|
|
296
281
|
});
|
|
297
282
|
});
|
|
298
|
-
//
|
|
299
|
-
// path as a `[name](file:///…)` line the client builds itself — upload
|
|
300
|
-
// first, so the text it sends (and optimistically renders) is final.
|
|
283
|
+
// Upload first, so the text the client sends and optimistically renders is final.
|
|
301
284
|
guarded(app, "POST", "/api/inbox", 400, async (c) => {
|
|
302
285
|
const body = await c.req.json().catch(() => null);
|
|
303
286
|
if (typeof body?.data !== "string" ||
|
|
@@ -314,8 +297,7 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
|
|
|
314
297
|
return c.json({ error: "invalid file" }, 400);
|
|
315
298
|
return c.json({ path: await saveInbound("web", name, body.mimeType, bytes) });
|
|
316
299
|
});
|
|
317
|
-
//
|
|
318
|
-
// *future* session launches with (IM chats) have none to ask.
|
|
300
|
+
// No session needed: surfaces configuring a *future* session have none to ask.
|
|
319
301
|
app.get("/api/models", async (c) => {
|
|
320
302
|
try {
|
|
321
303
|
return c.json(await factory.availableModels());
|
|
@@ -363,19 +345,15 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
|
|
|
363
345
|
const { sessionId } = await router.dispatch({
|
|
364
346
|
key: { channelId: "web", conversationId: id },
|
|
365
347
|
senderId: "web",
|
|
366
|
-
// Named
|
|
367
|
-
//
|
|
368
|
-
// (core/identity.ts), which is the operator's own words in someone
|
|
369
|
-
// else's mouth.
|
|
348
|
+
// Named: in a session also reached from a group chat, an unheaded message
|
|
349
|
+
// is attributed to whoever spoke last (core/identity.ts).
|
|
370
350
|
sender: { id: "web", name: "operator" },
|
|
371
351
|
text: body.text,
|
|
372
352
|
mode,
|
|
373
353
|
});
|
|
374
354
|
return c.json({ sessionId }, 202);
|
|
375
355
|
});
|
|
376
|
-
// Edit a user turn: rewind
|
|
377
|
-
// the edited text as a fresh dispatch. Pi keeps the old branch in the
|
|
378
|
-
// session file but out of context — the "deleted" message stops polluting.
|
|
356
|
+
// Edit a user turn: rewind to just before it, then re-dispatch the edited text.
|
|
379
357
|
guarded(app, "POST", "/api/sessions/:id/turns/:index/edit", 400, async (c) => {
|
|
380
358
|
const id = c.req.param("id");
|
|
381
359
|
const index = Number(c.req.param("index"));
|
|
@@ -383,8 +361,7 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
|
|
|
383
361
|
if (!Number.isInteger(index) || index < 0 || typeof body?.text !== "string" || !body.text.trim()) {
|
|
384
362
|
return c.json({ error: "index and text required" }, 400);
|
|
385
363
|
}
|
|
386
|
-
//
|
|
387
|
-
// the drain gate, and by then the transcript is already rewound.
|
|
364
|
+
// Before touching anything: a refused dispatch must not cost a rewound transcript.
|
|
388
365
|
if (router.isDraining())
|
|
389
366
|
return c.json({ error: "Pier is restarting — try again in a moment" }, 503);
|
|
390
367
|
const session = await ensure(id);
|
|
@@ -396,8 +373,7 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
|
|
|
396
373
|
if (session.state !== "idle")
|
|
397
374
|
return c.json({ error: "busy — stop the turn first" }, 409);
|
|
398
375
|
await session.rewindToUserTurn(index);
|
|
399
|
-
// The rewind took the
|
|
400
|
-
// all; what the model was told about who is speaking went with them.
|
|
376
|
+
// The rewind took the speaker headers out of the context too.
|
|
401
377
|
router.forgetSender(id);
|
|
402
378
|
await router.dispatch({
|
|
403
379
|
key: { channelId: "web", conversationId: id },
|
|
@@ -408,9 +384,7 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
|
|
|
408
384
|
});
|
|
409
385
|
return c.json({ ok: true }, 202);
|
|
410
386
|
});
|
|
411
|
-
//
|
|
412
|
-
// "restart" aborts the turn and sends them as a fresh prompt. Core owns
|
|
413
|
-
// exclusion and retains originals through the asynchronous handoff.
|
|
387
|
+
// Core owns exclusion and retains originals through the asynchronous handoff.
|
|
414
388
|
guarded(app, "POST", "/api/sessions/:id/queue/deliver", 404, async (c) => {
|
|
415
389
|
const id = c.req.param("id");
|
|
416
390
|
const body = await c.req.json().catch(() => null);
|
|
@@ -420,7 +394,6 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
|
|
|
420
394
|
}
|
|
421
395
|
return queueResponse(async () => ({ submitted: await router.deliverQueue(id, mode) }), 202);
|
|
422
396
|
});
|
|
423
|
-
// Recall: drop all pending queued messages and hand them back (composer restore).
|
|
424
397
|
guarded(app, "POST", "/api/sessions/:id/queue/recall", 404, async (c) => {
|
|
425
398
|
const id = c.req.param("id");
|
|
426
399
|
return queueResponse(async () => {
|
|
@@ -432,36 +405,25 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
|
|
|
432
405
|
router.acknowledgeRecovery(c.req.param("id"), c.req.param("batchId"));
|
|
433
406
|
return { ok: true };
|
|
434
407
|
}));
|
|
435
|
-
//
|
|
436
|
-
//
|
|
437
|
-
//
|
|
438
|
-
// running turn to do it, and losing a turn is not what the button offered.
|
|
439
|
-
// The result is not in this response: it arrives on the session's stream as
|
|
440
|
-
// `context-compacted` (agent/events.ts), which is also the only place the
|
|
441
|
-
// automatic compaction can be seen.
|
|
408
|
+
// Refused while streaming: Pi's compaction aborts a running turn, and losing
|
|
409
|
+
// one is not what the button offered. The result arrives on the stream as
|
|
410
|
+
// `context-compacted`.
|
|
442
411
|
guarded(app, "POST", "/api/sessions/:id/compact", 404, async (c) => {
|
|
443
412
|
const session = await ensure(c.req.param("id"));
|
|
444
413
|
if (session.state === "streaming")
|
|
445
414
|
return c.json({ error: "busy — stop the turn first" }, 409);
|
|
446
|
-
// The check above is
|
|
447
|
-
//
|
|
448
|
-
// refusal keeps the status this route already uses for "not now" — a 404
|
|
449
|
-
// from `guarded` would have read as "no such session".
|
|
415
|
+
// The check above is not the lock: two clicks pass it on the same tick, and
|
|
416
|
+
// the seam's refusal must keep the "not now" status, not read as "no such session".
|
|
450
417
|
return await session.compact().then(() => c.json({ ok: true }, 202), (err) => c.json({ error: String(err) }, 409));
|
|
451
418
|
});
|
|
452
|
-
//
|
|
453
|
-
// characters you happened to type. Not refused while streaming: a rename has
|
|
454
|
-
// nothing to do with the turn running, and the transcript takes an append.
|
|
419
|
+
// Not refused while streaming: a rename has nothing to do with the turn running.
|
|
455
420
|
guarded(app, "POST", "/api/sessions/:id/rename", 404, async (c) => {
|
|
456
421
|
const body = await c.req.json().catch(() => null);
|
|
457
422
|
if (typeof body?.name !== "string")
|
|
458
423
|
return c.json({ error: "name required" }, 400);
|
|
459
424
|
const id = c.req.param("id");
|
|
460
425
|
await (await ensure(id)).rename(body.name.trim().slice(0, SESSION_TITLE_MAX));
|
|
461
|
-
//
|
|
462
|
-
// transcript, which is what every list reads. The event is how the
|
|
463
|
-
// surfaces learn to re-read it, and the seam dropped its retained scan on
|
|
464
|
-
// the way out so the re-read sees the new name.
|
|
426
|
+
// The transcript is the answer; the event tells surfaces to re-read it.
|
|
465
427
|
hub.emitWorkspace({ type: "sessions-changed" });
|
|
466
428
|
return c.json({ ok: true });
|
|
467
429
|
});
|
|
@@ -470,11 +432,10 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
|
|
|
470
432
|
await router.abort(id);
|
|
471
433
|
return c.json({ ok: true }, 202);
|
|
472
434
|
});
|
|
473
|
-
//
|
|
474
|
-
// (created/promoted → re-list, run state → patch) without polling.
|
|
435
|
+
// One per client; keeps every session list in sync without polling.
|
|
475
436
|
app.get("/api/events", (c) => streamSSE(c, async (stream) => {
|
|
476
|
-
|
|
477
|
-
const unsubscribe = hub.subscribeWorkspace((e) =>
|
|
437
|
+
const send = boundedWriter(stream, "workspace");
|
|
438
|
+
const unsubscribe = hub.subscribeWorkspace((e) => send(`data: ${JSON.stringify(e)}\n\n`));
|
|
478
439
|
stream.onAbort(unsubscribe);
|
|
479
440
|
while (!stream.aborted) {
|
|
480
441
|
await stream.sleep(HEARTBEAT_MS);
|
|
@@ -491,29 +452,12 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
|
|
|
491
452
|
await stream.writeSSE({ event: "reset", data: "snapshot required" });
|
|
492
453
|
return;
|
|
493
454
|
}
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
return;
|
|
498
|
-
if (queued > SSE_HIGH_WATER) {
|
|
499
|
-
log.warn(`dropping slow event client for ${id} — ${queued} chars queued`);
|
|
500
|
-
stream.abort(); // unsubscribes; the client reconnects and replays
|
|
501
|
-
return;
|
|
502
|
-
}
|
|
503
|
-
queued += frame.length;
|
|
504
|
-
void stream
|
|
505
|
-
.write(frame)
|
|
506
|
-
.catch((err) => log.warn(`event write for ${id} failed: ${String(err)}`))
|
|
507
|
-
.finally(() => (queued -= frame.length));
|
|
508
|
-
};
|
|
509
|
-
// Subscribe before the replay write can wait on its reader: an event that
|
|
510
|
-
// arrives while that write is backpressured must queue behind it, not fall
|
|
511
|
-
// between replay() and subscribe(). Both snapshots happen synchronously,
|
|
512
|
-
// so live writes cannot overtake the replay write.
|
|
455
|
+
const send = boundedWriter(stream, id);
|
|
456
|
+
// Subscribe before the replay write can wait on its reader, or an event
|
|
457
|
+
// arriving during backpressure falls between replay() and subscribe().
|
|
513
458
|
const unsubscribe = hub.subscribe(id, (e) => send(sseFrame(e)));
|
|
514
459
|
stream.onAbort(unsubscribe);
|
|
515
|
-
// One write for the whole replay
|
|
516
|
-
// cost an await per event before the client saw any of them.
|
|
460
|
+
// One write for the whole replay, not an await per event.
|
|
517
461
|
const missed = hub.replay(id, lastId);
|
|
518
462
|
if (missed.length)
|
|
519
463
|
await stream.write(missed.map(sseFrame).join(""));
|
|
@@ -524,14 +468,10 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
|
|
|
524
468
|
}
|
|
525
469
|
});
|
|
526
470
|
});
|
|
527
|
-
//
|
|
528
|
-
//
|
|
529
|
-
//
|
|
530
|
-
//
|
|
531
|
-
// next message re-opens them with the configuration just written. Watched
|
|
532
|
-
// included, unlike the background sweep: the session open in the tab that
|
|
533
|
-
// just saved is the likeliest one to need it. A turn in flight is still never
|
|
534
|
-
// interrupted; it picks the change up at its next natural eviction.
|
|
471
|
+
// Credentials, agent files and the surface prompt are read when a session
|
|
472
|
+
// opens, so a Console save recycles idle sessions — watched included, since
|
|
473
|
+
// the tab that just saved is the likeliest to need it. A turn in flight is
|
|
474
|
+
// never interrupted.
|
|
535
475
|
const recycle = (what) => {
|
|
536
476
|
void router.evictIdle(0, Date.now(), { includeWatched: true })
|
|
537
477
|
.then((n) => {
|
|
@@ -540,12 +480,8 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
|
|
|
540
480
|
})
|
|
541
481
|
.catch((err) => log.error(`recycling sessions after ${what} failed`, err));
|
|
542
482
|
};
|
|
543
|
-
// `pier reload` on a button
|
|
544
|
-
//
|
|
545
|
-
// file edited over ssh has nothing to trigger them, and this is that trigger.
|
|
546
|
-
// `busy` is reported rather than waited on: a streaming session is never
|
|
547
|
-
// interrupted (core/router.ts evictIdle), so that count is the honest answer
|
|
548
|
-
// to "why is my change not live yet".
|
|
483
|
+
// `pier reload` on a button, for a file edited outside the Console. `busy`
|
|
484
|
+
// is the honest answer to "why is my change not live yet".
|
|
549
485
|
app.post("/api/reload", async (c) => {
|
|
550
486
|
try {
|
|
551
487
|
const recycled = (await reload?.()) ?? 0;
|
|
@@ -573,64 +509,47 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
|
|
|
573
509
|
registerConfigRoutes(app, { factory, config, onConfigWritten: () => recycle("an agent file") });
|
|
574
510
|
registerFsRoutes(app);
|
|
575
511
|
registerExplorerRoutes(app);
|
|
576
|
-
// serveStatic resolves `root` against the
|
|
577
|
-
//
|
|
578
|
-
// bundle sits beside this module in both trees — src/web/public when tsx
|
|
579
|
-
// runs the source, dist/web/public in a build — so the path is derived from
|
|
580
|
-
// the module and handed over as the relative form the option wants.
|
|
512
|
+
// serveStatic resolves `root` against the working directory, and an installed
|
|
513
|
+
// Pier is started from wherever the operator happens to be.
|
|
581
514
|
const bundle = fileURLToPath(new URL("./public", import.meta.url));
|
|
582
|
-
// The tab says which instance this is
|
|
583
|
-
//
|
|
584
|
-
//
|
|
585
|
-
// Both facts are known only at runtime, so they are patched into the shell
|
|
586
|
-
// here rather than built in — and served behind the auth guard, so a stranger
|
|
587
|
-
// at /login learns neither. Read once: neither can change under a process.
|
|
515
|
+
// The tab says which instance this is: mistaking staging for production is
|
|
516
|
+
// the mistake worth a few lines. Behind the auth guard, so a stranger at
|
|
517
|
+
// /login learns neither fact.
|
|
588
518
|
const prefix = tabPrefix(process.env.PIER_TITLE, hostname().split(".")[0] ?? "");
|
|
589
519
|
let shell = null;
|
|
590
|
-
//
|
|
591
|
-
//
|
|
592
|
-
// every navigation because it may not be cached. Exact path, like the two
|
|
593
|
-
// API routes above — never the SSE streams.
|
|
520
|
+
// Answers from the patched string, not from disk, so the precompressed
|
|
521
|
+
// siblings below cannot cover it. Exact path: never the SSE streams.
|
|
594
522
|
app.use("/", compress());
|
|
595
523
|
app.get("/", async (c, next) => {
|
|
596
|
-
// A
|
|
597
|
-
// navigation so a cached index cannot name bundles that no longer exist.
|
|
524
|
+
// A cached index must not name bundles a release has replaced.
|
|
598
525
|
c.header("cache-control", "private, no-cache");
|
|
599
526
|
if (shell === null) {
|
|
600
527
|
try {
|
|
601
528
|
shell = withTabPrefix(await readFile(join(bundle, "index.html"), "utf8"), prefix);
|
|
602
529
|
}
|
|
603
530
|
catch (err) {
|
|
604
|
-
// A workbench that will not load is not worth a nicer tab
|
|
605
|
-
// request back to the static handler, which answers as it always did.
|
|
531
|
+
// A workbench that will not load is not worth a nicer tab.
|
|
606
532
|
log.warn(`shell unreadable, serving it unpatched: ${String(err)}`);
|
|
607
533
|
return next();
|
|
608
534
|
}
|
|
609
535
|
}
|
|
610
536
|
return c.html(shell);
|
|
611
537
|
});
|
|
612
|
-
//
|
|
613
|
-
//
|
|
614
|
-
// so a cached copy is a released fix that never ships.
|
|
538
|
+
// The one unhashed asset: an installed app keeps its worker until the
|
|
539
|
+
// re-fetched script differs, so a cached copy is a fix that never ships.
|
|
615
540
|
app.get("/sw.js", async (c, next) => {
|
|
616
541
|
c.header("cache-control", "private, no-cache");
|
|
617
542
|
await next();
|
|
618
543
|
});
|
|
619
|
-
// Hashed bundles never change under their name
|
|
620
|
-
//
|
|
621
|
-
// the auth layer's bare `private`, so a browser revalidates each one before it
|
|
622
|
-
// may reuse it: a round trip per bundle on a remote instance, every time the
|
|
623
|
-
// workbench is opened.
|
|
544
|
+
// Hashed bundles never change under their name; without this the auth
|
|
545
|
+
// layer's bare `private` costs a revalidation round trip per bundle per open.
|
|
624
546
|
app.get("/assets/*", async (c, next) => {
|
|
625
547
|
c.header("cache-control", "private, max-age=31536000, immutable");
|
|
626
548
|
await next();
|
|
627
549
|
});
|
|
628
|
-
//
|
|
629
|
-
//
|
|
630
|
-
//
|
|
631
|
-
// stylesheet go out verbatim.
|
|
632
|
-
// serveStatic only sets Vary when it selects a sibling. Identity must carry
|
|
633
|
-
// it too, or a cache can reuse that response for a later Brotli request.
|
|
550
|
+
// The build writes `.br`/`.gz` siblings (vite.config.ts). serveStatic sets
|
|
551
|
+
// Vary only when it selects one; identity must carry it too, or a cache can
|
|
552
|
+
// reuse that response for a later Brotli request.
|
|
634
553
|
app.use("/*", async (c, next) => {
|
|
635
554
|
await next();
|
|
636
555
|
c.header("Vary", "Accept-Encoding");
|