@timqi/pier 0.0.29 → 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 +24 -11
- package/dist/agent/credentials.js +11 -23
- package/dist/agent/events.js +43 -62
- package/dist/agent/listing.js +113 -68
- package/dist/agent/pi.js +204 -211
- 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 +8 -24
- 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 +13 -35
- package/dist/core/queue.js +3 -5
- package/dist/core/reply.js +41 -142
- package/dist/core/router.js +209 -260
- package/dist/core/types.js +17 -1
- package/dist/db.js +98 -252
- package/dist/drain.js +58 -51
- 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 +6 -14
- 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 +87 -179
- package/dist/paths.js +10 -26
- package/dist/secrets.js +19 -46
- package/dist/service.js +33 -75
- package/dist/settings.js +42 -65
- package/dist/tasks/agent.js +129 -114
- package/dist/tasks/callbacks.js +9 -19
- package/dist/tasks/command.js +29 -14
- package/dist/tasks/definitions.js +39 -62
- package/dist/tasks/execution.js +46 -42
- package/dist/tasks/groups.js +41 -35
- package/dist/tasks/messages.js +121 -182
- package/dist/tasks/outbox.js +61 -55
- package/dist/tasks/routes.js +5 -11
- package/dist/tasks/runs.js +14 -13
- package/dist/tasks/service.js +53 -54
- package/dist/tasks/store.js +53 -30
- package/dist/tasks/tool.js +132 -61
- package/dist/tools-task.js +20 -60
- package/dist/tools.js +100 -327
- package/dist/update.js +21 -44
- 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 +45 -85
- package/dist/web/providers.js +14 -13
- package/dist/web/public/assets/{activity-D3m4L2IL.js → activity-B89_hH7q.js} +2 -2
- 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-BeKW0ZXK.js +1 -0
- 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-Cwy0mN8i.js +1 -0
- 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-DzZLmujq.js +5 -0
- 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-BCakxFk8.js +3 -0
- 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 +100 -130
- package/dist/web/public/index.html.br +0 -0
- package/dist/web/public/index.html.gz +0 -0
- package/dist/web/public/manifest.webmanifest +2 -2
- package/dist/web/public/manifest.webmanifest.br +0 -0
- package/dist/web/public/manifest.webmanifest.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 +131 -180
- package/dist/web/session-state.js +14 -54
- package/dist/web/types.js +2 -4
- package/dist/web/webpush.js +10 -25
- package/docs/deploy.md +115 -330
- package/package.json +2 -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 +153 -160
- package/dist/config-sync-fetch.js +0 -84
- package/dist/limits.js +0 -14
- package/dist/web/public/assets/activity-D3m4L2IL.js.br +0 -0
- package/dist/web/public/assets/activity-D3m4L2IL.js.gz +0 -0
- package/dist/web/public/assets/boards-BIObcQeX.js +0 -1
- package/dist/web/public/assets/boards-BIObcQeX.js.br +0 -0
- package/dist/web/public/assets/boards-BIObcQeX.js.gz +0 -0
- package/dist/web/public/assets/explorer-C_rSWPNB.js +0 -4
- package/dist/web/public/assets/explorer-C_rSWPNB.js.br +0 -0
- package/dist/web/public/assets/explorer-C_rSWPNB.js.gz +0 -0
- package/dist/web/public/assets/index-CX3fYZY5.css +0 -2
- package/dist/web/public/assets/index-CX3fYZY5.css.br +0 -0
- package/dist/web/public/assets/index-CX3fYZY5.css.gz +0 -0
- package/dist/web/public/assets/index-uFsZkKOQ.js +0 -85
- package/dist/web/public/assets/index-uFsZkKOQ.js.br +0 -0
- package/dist/web/public/assets/index-uFsZkKOQ.js.gz +0 -0
- package/dist/web/public/assets/runs-Ch6DZq6O.js +0 -1
- package/dist/web/public/assets/runs-Ch6DZq6O.js.br +0 -0
- package/dist/web/public/assets/runs-Ch6DZq6O.js.gz +0 -0
- package/dist/web/public/assets/settings-BWcEIEcv.js +0 -5
- package/dist/web/public/assets/settings-BWcEIEcv.js.br +0 -0
- package/dist/web/public/assets/settings-BWcEIEcv.js.gz +0 -0
- package/dist/web/public/assets/task-runs-DPkwv2UE.js +0 -3
- package/dist/web/public/assets/task-runs-DPkwv2UE.js.br +0 -0
- package/dist/web/public/assets/task-runs-DPkwv2UE.js.gz +0 -0
- package/dist/web/public/assets/tasks-DTiCi2mH.js +0 -4
- package/dist/web/public/assets/tasks-DTiCi2mH.js.br +0 -0
- package/dist/web/public/assets/tasks-DTiCi2mH.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,11 +84,12 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
|
|
|
71
84
|
}
|
|
72
85
|
return lastFrame;
|
|
73
86
|
};
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
//
|
|
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).
|
|
92
|
+
const workbenchOwn = (id) => (channelOf?.(id) ?? "web") === "web" && !(taskSessions?.().has(id) ?? false);
|
|
79
93
|
const runningNow = new Set();
|
|
80
94
|
hub.subscribeWorkspace((e) => {
|
|
81
95
|
if (e.type !== "session-state")
|
|
@@ -86,26 +100,20 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
|
|
|
86
100
|
}
|
|
87
101
|
if (!runningNow.delete(e.sessionId))
|
|
88
102
|
return;
|
|
103
|
+
if (!workbenchOwn(e.sessionId))
|
|
104
|
+
return;
|
|
89
105
|
state.setUnread(e.sessionId, true);
|
|
90
106
|
hub.emitWorkspace({ type: "sessions-changed" });
|
|
91
107
|
});
|
|
92
|
-
/**
|
|
93
|
-
* whole list: asking row by row loaded every run object of every session to
|
|
94
|
-
* draw one dot each. */
|
|
108
|
+
/** One query for a whole list, not one per row. */
|
|
95
109
|
const activeRuns = () => activeBackgroundRunCounts?.() ?? new Map();
|
|
96
|
-
/** The web channel's session for `id` — every session route resolves here. */
|
|
97
110
|
const ensure = (id) => router.ensure({ channelId: "web", conversationId: id });
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
// 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.
|
|
101
113
|
const nascent = new Map();
|
|
102
|
-
/**
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
* alone, clicking it 404s forever. The load path is where a ghost is
|
|
106
|
-
* discovered, so it is where the entry and its row are dropped and every
|
|
107
|
-
* rail told; the 404 then says what happened instead of looking like a
|
|
108
|
-
* 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). */
|
|
109
117
|
const ensureLoadable = async (id) => {
|
|
110
118
|
try {
|
|
111
119
|
return await ensure(id);
|
|
@@ -120,35 +128,31 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
|
|
|
120
128
|
throw err;
|
|
121
129
|
}
|
|
122
130
|
};
|
|
123
|
-
//
|
|
124
|
-
// warm, one scan cold. Concurrent consumers share it, whatever the factory
|
|
125
|
-
// behind the seam retains of its own; nothing here is cached past the last
|
|
126
|
-
// of them.
|
|
131
|
+
// Concurrent consumers share one scan; nothing is cached past the last of them.
|
|
127
132
|
let listing;
|
|
128
133
|
const listSessions = () => listing ??= factory.list().finally(() => {
|
|
129
134
|
listing = undefined;
|
|
130
135
|
});
|
|
131
|
-
/** Every session the rail may show: what Pi has written, plus the ones
|
|
132
|
-
* created here that it has not persisted yet, minus the ones task runs
|
|
133
|
-
* made for themselves. */
|
|
134
136
|
const allSessions = async () => {
|
|
135
137
|
const sessions = await listSessions();
|
|
136
138
|
for (const s of sessions)
|
|
137
139
|
nascent.delete(s.id);
|
|
138
|
-
// A session created but never prompted would otherwise
|
|
139
|
-
|
|
140
|
-
|
|
140
|
+
// A session created but never prompted would otherwise hold a working-set
|
|
141
|
+
// slot forever.
|
|
142
|
+
for (const [id, n] of nascent) {
|
|
143
|
+
if (Date.now() - n.createdAt > 86_400_000) {
|
|
141
144
|
nascent.delete(id);
|
|
145
|
+
state.forget(id);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
142
148
|
const owned = taskSessions?.() ?? new Set();
|
|
143
149
|
return [
|
|
144
150
|
...[...nascent].map(([id, n]) => ({ id, ...n })),
|
|
145
151
|
...sessions.filter((s) => !owned.has(s.id)),
|
|
146
152
|
];
|
|
147
153
|
};
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
// the rail's working set, when it has one; `modified` rides along for the
|
|
151
|
-
// 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.
|
|
152
156
|
const present = (s, own, active) => ({
|
|
153
157
|
...s,
|
|
154
158
|
...(own?.rank === undefined ? {} : { rank: own.rank }),
|
|
@@ -158,9 +162,9 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
|
|
|
158
162
|
activeRuns: active.get(s.id) ?? 0,
|
|
159
163
|
});
|
|
160
164
|
// The rail's top rows are maintained here and nowhere else: a session a
|
|
161
|
-
// human speaks to and that is not in the working set
|
|
162
|
-
// slot (web/session-state.ts). No route — there is no
|
|
163
|
-
// an IM message has no browser to make it from.
|
|
165
|
+
// human speaks to — or creates, below — and that is not in the working set
|
|
166
|
+
// already takes the front slot (web/session-state.ts). No route — there is no
|
|
167
|
+
// gesture to make, and an IM message has no browser to make it from.
|
|
164
168
|
router.onSpokenTo((id) => {
|
|
165
169
|
if (state.promote(id))
|
|
166
170
|
hub.emitWorkspace({ type: "sessions-changed" });
|
|
@@ -170,20 +174,42 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
|
|
|
170
174
|
const active = activeRuns();
|
|
171
175
|
return c.json((await allSessions()).map((s) => present(s, flags.get(s.id), active)));
|
|
172
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
|
+
// What was said, across every session: the palette's Messages section. The
|
|
189
|
+
// factory owns the index and the ranking; an empty query is an empty answer,
|
|
190
|
+
// not a listing of everything.
|
|
191
|
+
app.get("/api/search", async (c) => {
|
|
192
|
+
const q = (c.req.query("q") ?? "").trim();
|
|
193
|
+
return c.json({ hits: q ? await factory.search(q) : [] });
|
|
194
|
+
});
|
|
173
195
|
app.post("/api/sessions", async (c) => {
|
|
174
196
|
const body = await c.req.json().catch(() => ({}));
|
|
175
|
-
//
|
|
197
|
+
// Never in pier's own directory.
|
|
176
198
|
if (typeof body.cwd !== "string" || !body.cwd)
|
|
177
199
|
return c.json({ error: "cwd required" }, 400);
|
|
178
|
-
|
|
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.
|
|
202
|
+
const cwd = await realpath(body.cwd).catch(() => body.cwd);
|
|
203
|
+
const session = await factory.create({ cwd });
|
|
179
204
|
const createdAt = Date.now();
|
|
180
|
-
nascent.set(session.id, { cwd
|
|
205
|
+
nascent.set(session.id, { cwd, createdAt });
|
|
181
206
|
router.attach({ channelId: "web", conversationId: session.id }, session);
|
|
207
|
+
// Created is as good as spoken to: a row born below the working set would
|
|
208
|
+
// jump on the first message.
|
|
209
|
+
state.promote(session.id);
|
|
182
210
|
hub.emitWorkspace({ type: "sessions-changed" });
|
|
183
211
|
return c.json({ id: session.id }, 201);
|
|
184
212
|
});
|
|
185
|
-
// Seen = read: a client with the session selected and the tab visible acks
|
|
186
|
-
// here; the broadcast moves every other client's dot back to idle.
|
|
187
213
|
app.post("/api/sessions/:id/read", (c) => {
|
|
188
214
|
const id = c.req.param("id");
|
|
189
215
|
if (state.unread(id)) {
|
|
@@ -192,13 +218,10 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
|
|
|
192
218
|
}
|
|
193
219
|
return c.json({ ok: true });
|
|
194
220
|
});
|
|
195
|
-
//
|
|
196
|
-
//
|
|
197
|
-
// 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.
|
|
198
223
|
app.use("/api/sessions/:id/history", compress());
|
|
199
224
|
app.use("/api/sessions/:id/turns/:index/steps", compress());
|
|
200
|
-
// Snapshot: everything a fresh client needs before it starts consuming
|
|
201
|
-
// deltas from SSE — transcript, live state, pending queue, model.
|
|
202
225
|
guarded(app, "GET", "/api/sessions/:id/history", 404, async (c) => {
|
|
203
226
|
const id = c.req.param("id");
|
|
204
227
|
const session = await ensureLoadable(id);
|
|
@@ -236,21 +259,15 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
|
|
|
236
259
|
const turn = (await (await ensure(c.req.param("id"))).history())[index];
|
|
237
260
|
if (!turn)
|
|
238
261
|
return c.json({ error: `no turn at index ${index}` }, 404);
|
|
239
|
-
// Already capped at MAX_STEP_OUTPUT by the transcript rebuild: this route
|
|
240
|
-
// hands back what a surface shows, not the untruncated tool result.
|
|
241
262
|
return c.json({ steps: turn.steps ?? [] });
|
|
242
263
|
});
|
|
243
|
-
//
|
|
244
|
-
//
|
|
245
|
-
// boundary here is the Console password (web/fs.ts), not the session's cwd:
|
|
246
|
-
// a cwd is chosen by whoever creates the session, so confining to it stopped
|
|
247
|
-
// 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.
|
|
248
266
|
guarded(app, "GET", "/api/sessions/:id/files", 400, async (c) => {
|
|
249
267
|
const raw = c.req.query("path");
|
|
250
268
|
if (!raw)
|
|
251
269
|
return c.json({ error: "path required" }, 400);
|
|
252
|
-
// Absolute only:
|
|
253
|
-
// by Pier, and neither of them writes a path relative to anything.
|
|
270
|
+
// Absolute only: neither the agent nor Pier writes a relative link.
|
|
254
271
|
const file = isAbsolute(raw) ? await realpath(raw).catch(() => null) : null;
|
|
255
272
|
const info = file ? await stat(file).catch(() => null) : null;
|
|
256
273
|
if (!file || !info?.isFile())
|
|
@@ -263,9 +280,7 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
|
|
|
263
280
|
"cache-control": "private, max-age=60",
|
|
264
281
|
});
|
|
265
282
|
});
|
|
266
|
-
//
|
|
267
|
-
// path as a `[name](file:///…)` line the client builds itself — upload
|
|
268
|
-
// 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.
|
|
269
284
|
guarded(app, "POST", "/api/inbox", 400, async (c) => {
|
|
270
285
|
const body = await c.req.json().catch(() => null);
|
|
271
286
|
if (typeof body?.data !== "string" ||
|
|
@@ -282,8 +297,7 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
|
|
|
282
297
|
return c.json({ error: "invalid file" }, 400);
|
|
283
298
|
return c.json({ path: await saveInbound("web", name, body.mimeType, bytes) });
|
|
284
299
|
});
|
|
285
|
-
//
|
|
286
|
-
// *future* session launches with (IM chats) have none to ask.
|
|
300
|
+
// No session needed: surfaces configuring a *future* session have none to ask.
|
|
287
301
|
app.get("/api/models", async (c) => {
|
|
288
302
|
try {
|
|
289
303
|
return c.json(await factory.availableModels());
|
|
@@ -331,19 +345,15 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
|
|
|
331
345
|
const { sessionId } = await router.dispatch({
|
|
332
346
|
key: { channelId: "web", conversationId: id },
|
|
333
347
|
senderId: "web",
|
|
334
|
-
// Named
|
|
335
|
-
//
|
|
336
|
-
// (core/identity.ts), which is the operator's own words in someone
|
|
337
|
-
// 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).
|
|
338
350
|
sender: { id: "web", name: "operator" },
|
|
339
351
|
text: body.text,
|
|
340
352
|
mode,
|
|
341
353
|
});
|
|
342
354
|
return c.json({ sessionId }, 202);
|
|
343
355
|
});
|
|
344
|
-
// Edit a user turn: rewind
|
|
345
|
-
// the edited text as a fresh dispatch. Pi keeps the old branch in the
|
|
346
|
-
// 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.
|
|
347
357
|
guarded(app, "POST", "/api/sessions/:id/turns/:index/edit", 400, async (c) => {
|
|
348
358
|
const id = c.req.param("id");
|
|
349
359
|
const index = Number(c.req.param("index"));
|
|
@@ -351,8 +361,7 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
|
|
|
351
361
|
if (!Number.isInteger(index) || index < 0 || typeof body?.text !== "string" || !body.text.trim()) {
|
|
352
362
|
return c.json({ error: "index and text required" }, 400);
|
|
353
363
|
}
|
|
354
|
-
//
|
|
355
|
-
// the drain gate, and by then the transcript is already rewound.
|
|
364
|
+
// Before touching anything: a refused dispatch must not cost a rewound transcript.
|
|
356
365
|
if (router.isDraining())
|
|
357
366
|
return c.json({ error: "Pier is restarting — try again in a moment" }, 503);
|
|
358
367
|
const session = await ensure(id);
|
|
@@ -364,8 +373,7 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
|
|
|
364
373
|
if (session.state !== "idle")
|
|
365
374
|
return c.json({ error: "busy — stop the turn first" }, 409);
|
|
366
375
|
await session.rewindToUserTurn(index);
|
|
367
|
-
// The rewind took the
|
|
368
|
-
// 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.
|
|
369
377
|
router.forgetSender(id);
|
|
370
378
|
await router.dispatch({
|
|
371
379
|
key: { channelId: "web", conversationId: id },
|
|
@@ -376,9 +384,7 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
|
|
|
376
384
|
});
|
|
377
385
|
return c.json({ ok: true }, 202);
|
|
378
386
|
});
|
|
379
|
-
//
|
|
380
|
-
// "restart" aborts the turn and sends them as a fresh prompt. Core owns
|
|
381
|
-
// exclusion and retains originals through the asynchronous handoff.
|
|
387
|
+
// Core owns exclusion and retains originals through the asynchronous handoff.
|
|
382
388
|
guarded(app, "POST", "/api/sessions/:id/queue/deliver", 404, async (c) => {
|
|
383
389
|
const id = c.req.param("id");
|
|
384
390
|
const body = await c.req.json().catch(() => null);
|
|
@@ -388,7 +394,6 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
|
|
|
388
394
|
}
|
|
389
395
|
return queueResponse(async () => ({ submitted: await router.deliverQueue(id, mode) }), 202);
|
|
390
396
|
});
|
|
391
|
-
// Recall: drop all pending queued messages and hand them back (composer restore).
|
|
392
397
|
guarded(app, "POST", "/api/sessions/:id/queue/recall", 404, async (c) => {
|
|
393
398
|
const id = c.req.param("id");
|
|
394
399
|
return queueResponse(async () => {
|
|
@@ -400,36 +405,25 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
|
|
|
400
405
|
router.acknowledgeRecovery(c.req.param("id"), c.req.param("batchId"));
|
|
401
406
|
return { ok: true };
|
|
402
407
|
}));
|
|
403
|
-
//
|
|
404
|
-
//
|
|
405
|
-
//
|
|
406
|
-
// running turn to do it, and losing a turn is not what the button offered.
|
|
407
|
-
// The result is not in this response: it arrives on the session's stream as
|
|
408
|
-
// `context-compacted` (agent/events.ts), which is also the only place the
|
|
409
|
-
// 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`.
|
|
410
411
|
guarded(app, "POST", "/api/sessions/:id/compact", 404, async (c) => {
|
|
411
412
|
const session = await ensure(c.req.param("id"));
|
|
412
413
|
if (session.state === "streaming")
|
|
413
414
|
return c.json({ error: "busy — stop the turn first" }, 409);
|
|
414
|
-
// The check above is
|
|
415
|
-
//
|
|
416
|
-
// refusal keeps the status this route already uses for "not now" — a 404
|
|
417
|
-
// 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".
|
|
418
417
|
return await session.compact().then(() => c.json({ ok: true }, 202), (err) => c.json({ error: String(err) }, 409));
|
|
419
418
|
});
|
|
420
|
-
//
|
|
421
|
-
// characters you happened to type. Not refused while streaming: a rename has
|
|
422
|
-
// 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.
|
|
423
420
|
guarded(app, "POST", "/api/sessions/:id/rename", 404, async (c) => {
|
|
424
421
|
const body = await c.req.json().catch(() => null);
|
|
425
422
|
if (typeof body?.name !== "string")
|
|
426
423
|
return c.json({ error: "name required" }, 400);
|
|
427
424
|
const id = c.req.param("id");
|
|
428
425
|
await (await ensure(id)).rename(body.name.trim().slice(0, SESSION_TITLE_MAX));
|
|
429
|
-
//
|
|
430
|
-
// transcript, which is what every list reads. The event is how the
|
|
431
|
-
// surfaces learn to re-read it, and the seam dropped its retained scan on
|
|
432
|
-
// 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.
|
|
433
427
|
hub.emitWorkspace({ type: "sessions-changed" });
|
|
434
428
|
return c.json({ ok: true });
|
|
435
429
|
});
|
|
@@ -438,11 +432,10 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
|
|
|
438
432
|
await router.abort(id);
|
|
439
433
|
return c.json({ ok: true }, 202);
|
|
440
434
|
});
|
|
441
|
-
//
|
|
442
|
-
// (created/promoted → re-list, run state → patch) without polling.
|
|
435
|
+
// One per client; keeps every session list in sync without polling.
|
|
443
436
|
app.get("/api/events", (c) => streamSSE(c, async (stream) => {
|
|
444
|
-
|
|
445
|
-
const unsubscribe = hub.subscribeWorkspace((e) =>
|
|
437
|
+
const send = boundedWriter(stream, "workspace");
|
|
438
|
+
const unsubscribe = hub.subscribeWorkspace((e) => send(`data: ${JSON.stringify(e)}\n\n`));
|
|
446
439
|
stream.onAbort(unsubscribe);
|
|
447
440
|
while (!stream.aborted) {
|
|
448
441
|
await stream.sleep(HEARTBEAT_MS);
|
|
@@ -459,29 +452,12 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
|
|
|
459
452
|
await stream.writeSSE({ event: "reset", data: "snapshot required" });
|
|
460
453
|
return;
|
|
461
454
|
}
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
return;
|
|
466
|
-
if (queued > SSE_HIGH_WATER) {
|
|
467
|
-
log.warn(`dropping slow event client for ${id} — ${queued} chars queued`);
|
|
468
|
-
stream.abort(); // unsubscribes; the client reconnects and replays
|
|
469
|
-
return;
|
|
470
|
-
}
|
|
471
|
-
queued += frame.length;
|
|
472
|
-
void stream
|
|
473
|
-
.write(frame)
|
|
474
|
-
.catch((err) => log.warn(`event write for ${id} failed: ${String(err)}`))
|
|
475
|
-
.finally(() => (queued -= frame.length));
|
|
476
|
-
};
|
|
477
|
-
// Subscribe before the replay write can wait on its reader: an event that
|
|
478
|
-
// arrives while that write is backpressured must queue behind it, not fall
|
|
479
|
-
// between replay() and subscribe(). Both snapshots happen synchronously,
|
|
480
|
-
// 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().
|
|
481
458
|
const unsubscribe = hub.subscribe(id, (e) => send(sseFrame(e)));
|
|
482
459
|
stream.onAbort(unsubscribe);
|
|
483
|
-
// One write for the whole replay
|
|
484
|
-
// cost an await per event before the client saw any of them.
|
|
460
|
+
// One write for the whole replay, not an await per event.
|
|
485
461
|
const missed = hub.replay(id, lastId);
|
|
486
462
|
if (missed.length)
|
|
487
463
|
await stream.write(missed.map(sseFrame).join(""));
|
|
@@ -492,14 +468,10 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
|
|
|
492
468
|
}
|
|
493
469
|
});
|
|
494
470
|
});
|
|
495
|
-
//
|
|
496
|
-
//
|
|
497
|
-
//
|
|
498
|
-
//
|
|
499
|
-
// next message re-opens them with the configuration just written. Watched
|
|
500
|
-
// included, unlike the background sweep: the session open in the tab that
|
|
501
|
-
// just saved is the likeliest one to need it. A turn in flight is still never
|
|
502
|
-
// 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.
|
|
503
475
|
const recycle = (what) => {
|
|
504
476
|
void router.evictIdle(0, Date.now(), { includeWatched: true })
|
|
505
477
|
.then((n) => {
|
|
@@ -508,12 +480,8 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
|
|
|
508
480
|
})
|
|
509
481
|
.catch((err) => log.error(`recycling sessions after ${what} failed`, err));
|
|
510
482
|
};
|
|
511
|
-
// `pier reload` on a button
|
|
512
|
-
//
|
|
513
|
-
// file edited over ssh has nothing to trigger them, and this is that trigger.
|
|
514
|
-
// `busy` is reported rather than waited on: a streaming session is never
|
|
515
|
-
// interrupted (core/router.ts evictIdle), so that count is the honest answer
|
|
516
|
-
// 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".
|
|
517
485
|
app.post("/api/reload", async (c) => {
|
|
518
486
|
try {
|
|
519
487
|
const recycled = (await reload?.()) ?? 0;
|
|
@@ -541,64 +509,47 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
|
|
|
541
509
|
registerConfigRoutes(app, { factory, config, onConfigWritten: () => recycle("an agent file") });
|
|
542
510
|
registerFsRoutes(app);
|
|
543
511
|
registerExplorerRoutes(app);
|
|
544
|
-
// serveStatic resolves `root` against the
|
|
545
|
-
//
|
|
546
|
-
// bundle sits beside this module in both trees — src/web/public when tsx
|
|
547
|
-
// runs the source, dist/web/public in a build — so the path is derived from
|
|
548
|
-
// 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.
|
|
549
514
|
const bundle = fileURLToPath(new URL("./public", import.meta.url));
|
|
550
|
-
// The tab says which instance this is
|
|
551
|
-
//
|
|
552
|
-
//
|
|
553
|
-
// Both facts are known only at runtime, so they are patched into the shell
|
|
554
|
-
// here rather than built in — and served behind the auth guard, so a stranger
|
|
555
|
-
// 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.
|
|
556
518
|
const prefix = tabPrefix(process.env.PIER_TITLE, hostname().split(".")[0] ?? "");
|
|
557
519
|
let shell = null;
|
|
558
|
-
//
|
|
559
|
-
//
|
|
560
|
-
// every navigation because it may not be cached. Exact path, like the two
|
|
561
|
-
// 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.
|
|
562
522
|
app.use("/", compress());
|
|
563
523
|
app.get("/", async (c, next) => {
|
|
564
|
-
// A
|
|
565
|
-
// navigation so a cached index cannot name bundles that no longer exist.
|
|
524
|
+
// A cached index must not name bundles a release has replaced.
|
|
566
525
|
c.header("cache-control", "private, no-cache");
|
|
567
526
|
if (shell === null) {
|
|
568
527
|
try {
|
|
569
528
|
shell = withTabPrefix(await readFile(join(bundle, "index.html"), "utf8"), prefix);
|
|
570
529
|
}
|
|
571
530
|
catch (err) {
|
|
572
|
-
// A workbench that will not load is not worth a nicer tab
|
|
573
|
-
// 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.
|
|
574
532
|
log.warn(`shell unreadable, serving it unpatched: ${String(err)}`);
|
|
575
533
|
return next();
|
|
576
534
|
}
|
|
577
535
|
}
|
|
578
536
|
return c.html(shell);
|
|
579
537
|
});
|
|
580
|
-
//
|
|
581
|
-
//
|
|
582
|
-
// 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.
|
|
583
540
|
app.get("/sw.js", async (c, next) => {
|
|
584
541
|
c.header("cache-control", "private, no-cache");
|
|
585
542
|
await next();
|
|
586
543
|
});
|
|
587
|
-
// Hashed bundles never change under their name
|
|
588
|
-
//
|
|
589
|
-
// the auth layer's bare `private`, so a browser revalidates each one before it
|
|
590
|
-
// may reuse it: a round trip per bundle on a remote instance, every time the
|
|
591
|
-
// 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.
|
|
592
546
|
app.get("/assets/*", async (c, next) => {
|
|
593
547
|
c.header("cache-control", "private, max-age=31536000, immutable");
|
|
594
548
|
await next();
|
|
595
549
|
});
|
|
596
|
-
//
|
|
597
|
-
//
|
|
598
|
-
//
|
|
599
|
-
// stylesheet go out verbatim.
|
|
600
|
-
// serveStatic only sets Vary when it selects a sibling. Identity must carry
|
|
601
|
-
// 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.
|
|
602
553
|
app.use("/*", async (c, next) => {
|
|
603
554
|
await next();
|
|
604
555
|
c.header("Vary", "Accept-Encoding");
|