@timqi/pier 0.0.8 → 0.0.15
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 +26 -9
- package/dist/agent/events.js +53 -7
- package/dist/agent/listing.js +253 -0
- package/dist/agent/pi.js +279 -32
- package/dist/boards/boards.js +65 -16
- package/dist/boards/pier.css +1 -1
- package/dist/channels/attach.js +87 -0
- package/dist/channels/control.js +2 -2
- package/dist/channels/conversations.js +10 -0
- package/dist/channels/lark-api.js +38 -0
- package/dist/channels/lark-outbound.js +11 -2
- package/dist/channels/slack-api.js +36 -0
- package/dist/channels/slack-outbound.js +12 -2
- package/dist/channels/slack-tool.js +49 -9
- package/dist/channels/telegram-api.js +21 -2
- package/dist/channels/telegram.js +23 -8
- package/dist/cli.js +34 -0
- package/dist/core/identity.js +18 -0
- package/dist/core/inbound-file.js +3 -1
- package/dist/core/reply.js +2 -1
- package/dist/core/router.js +99 -11
- package/dist/db.js +87 -0
- package/dist/extensions/index.js +37 -0
- package/dist/extensions/web/anthropic.js +118 -0
- package/dist/extensions/web/artifacts.js +62 -0
- package/dist/extensions/web/content.js +130 -0
- package/dist/extensions/web/http.js +106 -0
- package/dist/extensions/web/index.js +9 -0
- package/dist/extensions/web/json.js +5 -0
- package/dist/extensions/web/language.js +47 -0
- package/dist/extensions/web/openai.js +112 -0
- package/dist/extensions/web/provider.js +121 -0
- package/dist/extensions/web/tools.js +304 -0
- package/dist/limits.js +14 -0
- package/dist/main.js +76 -10
- package/dist/paths.js +21 -1
- package/dist/settings.js +112 -13
- package/dist/tasks/agent.js +18 -4
- package/dist/tasks/callbacks.js +20 -1
- package/dist/tasks/definitions.js +56 -12
- package/dist/tasks/execution.js +5 -1
- package/dist/tasks/groups.js +4 -4
- package/dist/tasks/messages.js +4 -2
- package/dist/tasks/runs.js +2 -2
- package/dist/tasks/service.js +16 -6
- package/dist/tasks/tool.js +0 -12
- package/dist/tools-task.js +155 -0
- package/dist/tools.js +875 -0
- package/dist/web/auth.js +5 -3
- package/dist/web/explorer.js +15 -2
- package/dist/web/files.js +1 -1
- package/dist/web/instance.js +175 -22
- package/dist/web/providers.js +16 -0
- package/dist/web/public/assets/{ghostty-web-CcIc8O2I.js → ghostty-web-xcUrfRRs.js} +1 -1
- package/dist/web/public/assets/index-BWDlAMK2.js +93 -0
- package/dist/web/public/assets/index-DHqZnZr7.css +2 -0
- package/dist/web/public/index.html +5 -8
- package/dist/web/public/sw.js +4 -0
- package/dist/web/push.js +33 -9
- package/dist/web/repos.js +75 -0
- package/dist/web/server.js +170 -52
- package/dist/web/session-state.js +57 -44
- package/dist/web/terminal.js +34 -4
- package/dist/web/types.js +5 -0
- package/package.json +1 -1
- package/skills/pier-boards/SKILL.md +23 -13
- package/skills/pier-help/SKILL.md +1 -1
- package/skills/pier-slack/SKILL.md +21 -1
- package/skills/pier-tasks/SKILL.md +2 -2
- package/dist/web/public/assets/index-DmDJKOLH.js +0 -90
- package/dist/web/public/assets/index-gcSJ9QZ5.css +0 -2
package/dist/agent/pi.js
CHANGED
|
@@ -1,18 +1,41 @@
|
|
|
1
|
-
// The
|
|
2
|
-
// AgentFactory/AgentSession seam from src/core/types.ts on the
|
|
3
|
-
// No Pi type may appear in an exported signature.
|
|
1
|
+
// The only file outside src/extensions allowed to import @earendil-works/pi-*.
|
|
2
|
+
// Implements the AgentFactory/AgentSession seam from src/core/types.ts on the
|
|
3
|
+
// Pi SDK. No Pi type may appear in an exported signature.
|
|
4
4
|
import { createAgentSession, CredentialSynchronizationError, DefaultResourceLoader, defineTool, ModelRuntime, SessionManager, } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { inlineExtensions } from "../extensions/index.js";
|
|
5
6
|
import { logger } from "../log.js";
|
|
6
7
|
import { toChatTurns, toSessionEvents, turnMetaAt, } from "./events.js";
|
|
7
8
|
import { defaultAgentDir, PiConfigStore } from "./config.js";
|
|
9
|
+
import { IndexedListing } from "./listing.js";
|
|
8
10
|
import { curateModels, pinFirst } from "./models.js";
|
|
9
11
|
const log = logger("agent");
|
|
12
|
+
/** A listed record as the seam reports it. The one mapping, because `list` and
|
|
13
|
+
* `find` answer with the same shape and drifting would mean two answers about
|
|
14
|
+
* one session. */
|
|
15
|
+
const summaryOf = (s) => ({
|
|
16
|
+
id: s.id,
|
|
17
|
+
cwd: s.cwd,
|
|
18
|
+
createdAt: s.created,
|
|
19
|
+
modified: s.modified,
|
|
20
|
+
...(s.title ? { title: s.title } : {}),
|
|
21
|
+
});
|
|
10
22
|
/** Pi's bash tool has no default timeout, so a hung command holds the turn
|
|
11
23
|
* until someone aborts it — nobody is watching in a scheduled task. Kept below
|
|
12
24
|
* the default task-run timeout so a stuck command comes back as
|
|
13
25
|
* a tool error the agent can retry with an explicit longer timeout, instead of
|
|
14
26
|
* killing the whole run. */
|
|
15
27
|
const BASH_DEFAULT_TIMEOUT_SECONDS = 600;
|
|
28
|
+
/** How long a session listing stays usable: long enough that one workspace
|
|
29
|
+
* event, which several surfaces answer at once, scans disk once; short enough
|
|
30
|
+
* that a title no invalidation covers is never stale on screen. */
|
|
31
|
+
const LIST_TTL_MS = 3_000;
|
|
32
|
+
/** A probe nobody is watching is a hung page: the Console waits on this. */
|
|
33
|
+
const PROVIDER_CHECK_TIMEOUT_MS = 20_000;
|
|
34
|
+
/** An ordinary budget, not a token: a 1-token cap is a request no real turn
|
|
35
|
+
* ever makes, and answers about it are answers about a different request. */
|
|
36
|
+
const PROVIDER_CHECK_MAX_TOKENS = 8192;
|
|
37
|
+
/** Neither half of a probe is worth more than a screen. */
|
|
38
|
+
const clip = (text) => text.length > 4000 ? `${text.slice(0, 4000)}\n[… ${text.length - 4000} more characters]` : text;
|
|
16
39
|
/** Pier's baseline replaces Pi's generic default; a user's SYSTEM.md follows it. */
|
|
17
40
|
const PIER_SYSTEM_PROMPT = `You are a general-purpose agent with a live workspace: you can read and change files and run shell commands. Act with expert care — do the work, verify results, and state what you could not check.
|
|
18
41
|
|
|
@@ -35,14 +58,46 @@ const bashTimeoutDefault = (pi) => {
|
|
|
35
58
|
}
|
|
36
59
|
});
|
|
37
60
|
};
|
|
61
|
+
/**
|
|
62
|
+
* A bundled extension stands down when a copy on disk already registers one of
|
|
63
|
+
* its tools. Pi loads both and reports the clash as a diagnostic nobody reads,
|
|
64
|
+
* leaving two tools of the same name and no way to tell which one answered;
|
|
65
|
+
* the copy the user put there wins, and the journal says so (§5b).
|
|
66
|
+
*/
|
|
67
|
+
export const standDownShadowed = (base) => {
|
|
68
|
+
const inline = (ext) => ext.path.startsWith("<inline:");
|
|
69
|
+
const onDisk = new Set(base.extensions.filter((ext) => !inline(ext)).flatMap((ext) => [...ext.tools.keys()]));
|
|
70
|
+
if (!onDisk.size)
|
|
71
|
+
return base;
|
|
72
|
+
return {
|
|
73
|
+
...base,
|
|
74
|
+
extensions: base.extensions.filter((ext) => {
|
|
75
|
+
const clash = inline(ext) && [...ext.tools.keys()].filter((tool) => onDisk.has(tool));
|
|
76
|
+
if (!clash || !clash.length)
|
|
77
|
+
return true;
|
|
78
|
+
log.info(`bundled ${ext.path} stood down — ${clash.join(", ")} already loaded from disk`);
|
|
79
|
+
return false;
|
|
80
|
+
}),
|
|
81
|
+
};
|
|
82
|
+
};
|
|
38
83
|
export class PiSession {
|
|
39
84
|
pi;
|
|
40
85
|
pinned;
|
|
86
|
+
wrote;
|
|
87
|
+
retention;
|
|
41
88
|
constructor(pi,
|
|
42
89
|
/** Operator pins, read per call — the menu can change while we run. */
|
|
43
|
-
pinned = () => []
|
|
90
|
+
pinned = () => [],
|
|
91
|
+
/** "What I just wrote is not in your listing yet." The factory retains a
|
|
92
|
+
* scan for a few seconds, which is exactly the window a rename lands in:
|
|
93
|
+
* every surface would re-read the old title and keep it until some
|
|
94
|
+
* unrelated event moved the list again. Same drop `create` and `fork`
|
|
95
|
+
* do — a callback only because the session is what knows it happened. */
|
|
96
|
+
wrote = () => { }, retention = { value: "long" }) {
|
|
44
97
|
this.pi = pi;
|
|
45
98
|
this.pinned = pinned;
|
|
99
|
+
this.wrote = wrote;
|
|
100
|
+
this.retention = retention;
|
|
46
101
|
}
|
|
47
102
|
/** Pi's dispose unhooks the one listener that persists and emits, so a turn
|
|
48
103
|
* started after it runs for real — model call, tools and all — and lands
|
|
@@ -98,6 +153,9 @@ export class PiSession {
|
|
|
98
153
|
setThinkingLevel(level) {
|
|
99
154
|
this.pi.setThinkingLevel(level);
|
|
100
155
|
}
|
|
156
|
+
setCacheRetention(retention) {
|
|
157
|
+
this.retention.value = retention;
|
|
158
|
+
}
|
|
101
159
|
async pendingQueue() {
|
|
102
160
|
return {
|
|
103
161
|
steering: [...this.pi.getSteeringMessages()],
|
|
@@ -128,11 +186,73 @@ export class PiSession {
|
|
|
128
186
|
if (cancelled)
|
|
129
187
|
throw new Error("rewind cancelled");
|
|
130
188
|
}
|
|
189
|
+
/** The compaction running right now, or null. Pi keeps no lock of its own —
|
|
190
|
+
* a second `compact()` aborts the first's turn and summarizes a transcript
|
|
191
|
+
* that is being replaced under it — and two POSTs a millisecond apart both
|
|
192
|
+
* pass the route's idle check, so the gate has to be here. */
|
|
193
|
+
compacting = null;
|
|
194
|
+
/** Pi's own compaction, minus its `CompactionResult`: the numbers reach
|
|
195
|
+
* surfaces as the `context-compacted` event the seam already emits for the
|
|
196
|
+
* automatic one, so a caller has nothing to do with them. Refused while one
|
|
197
|
+
* is running, rather than run twice over one context. */
|
|
198
|
+
async compact() {
|
|
199
|
+
this.live();
|
|
200
|
+
if (this.compacting)
|
|
201
|
+
throw new Error(`session ${this.pi.sessionId} is already compacting`);
|
|
202
|
+
// Started and recorded in the same tick, with no await between: that is
|
|
203
|
+
// what makes the check above a gate and not a hint.
|
|
204
|
+
const running = this.pi.compact().then(() => undefined);
|
|
205
|
+
this.compacting = running;
|
|
206
|
+
try {
|
|
207
|
+
await running;
|
|
208
|
+
}
|
|
209
|
+
finally {
|
|
210
|
+
this.compacting = null;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
/** One `session_info` entry, which Pi's own reader takes the latest of — so
|
|
214
|
+
* a rename is an append like everything else in a transcript, and nothing
|
|
215
|
+
* has to be rewritten. Never refused for being busy: a name has nothing to
|
|
216
|
+
* do with the turn running.
|
|
217
|
+
*
|
|
218
|
+
* Returns nothing, because the transcript is the answer: what the session is
|
|
219
|
+
* called after this — the name, or the title a cleared one falls back to —
|
|
220
|
+
* is what the next listing reads off the file, and deriving it here as well
|
|
221
|
+
* was a second copy of a rule agent/listing.ts already owns.
|
|
222
|
+
*
|
|
223
|
+
* TODO: renaming a cold session costs a whole resume, because the route
|
|
224
|
+
* reaches it through `ensure` and this method needs a live Pi session to
|
|
225
|
+
* append through. The work is one line in a file. Revisit when Pi offers a
|
|
226
|
+
* lightweight append to a session it has not loaded. */
|
|
227
|
+
async rename(name) {
|
|
228
|
+
this.live();
|
|
229
|
+
this.pi.sessionManager.appendSessionInfo(name);
|
|
230
|
+
this.wrote();
|
|
231
|
+
}
|
|
232
|
+
/** Compaction replaces the context a turn would run against, so a dispatch
|
|
233
|
+
* that lands mid-compaction waits for the summary instead of starting a turn
|
|
234
|
+
* over it — the follow-up promise ("delivered when idle") without Pi's
|
|
235
|
+
* follow-up queue, which is only drained by the *next* turn: a message
|
|
236
|
+
* parked there while nothing is running would sit unsent, and Pi's own
|
|
237
|
+
* `prompt()` guard would have thrown the user's message away (§5b). */
|
|
238
|
+
async whenCompacted() {
|
|
239
|
+
while (this.compacting)
|
|
240
|
+
await this.compacting.catch(() => undefined);
|
|
241
|
+
}
|
|
131
242
|
// Async, so a refusal is a rejected promise: the seam promises callers they
|
|
132
243
|
// may only `.catch()` (core/types.ts), and dispatch does exactly that.
|
|
133
244
|
async prompt(text) {
|
|
134
245
|
this.live();
|
|
135
|
-
|
|
246
|
+
await this.whenCompacted();
|
|
247
|
+
// Re-checked: the wait above is long enough for a dispose to land.
|
|
248
|
+
this.live();
|
|
249
|
+
// A turn may have started since the caller read the state this prompt was
|
|
250
|
+
// decided against — two messages arriving together, or several released at
|
|
251
|
+
// once by the wait above. Bare, Pi throws that back as "already
|
|
252
|
+
// processing" and the message is gone (§5b); queued, it is the same
|
|
253
|
+
// "delivered when idle" the core's own policy picks for an auto message
|
|
254
|
+
// that lands mid-turn (core/queue.ts).
|
|
255
|
+
return this.pi.prompt(text, { streamingBehavior: "followUp" });
|
|
136
256
|
}
|
|
137
257
|
async steer(text) {
|
|
138
258
|
this.live();
|
|
@@ -143,6 +263,11 @@ export class PiSession {
|
|
|
143
263
|
return this.pi.followUp(text);
|
|
144
264
|
}
|
|
145
265
|
async systemInput(text, origin, mode) {
|
|
266
|
+
this.live();
|
|
267
|
+
// Same gate as prompt(): an idle session takes a system input as a turn
|
|
268
|
+
// whatever the mode says, so a callback landing mid-compaction would race
|
|
269
|
+
// the summary too.
|
|
270
|
+
await this.whenCompacted();
|
|
146
271
|
this.live();
|
|
147
272
|
return this.pi.sendCustomMessage({ customType: "pier.system-input", content: text, display: true, details: origin }, { triggerTurn: true, deliverAs: mode === "prompt" ? undefined : mode });
|
|
148
273
|
}
|
|
@@ -177,6 +302,8 @@ export class PiAgentFactory {
|
|
|
177
302
|
credentials;
|
|
178
303
|
providerConfig;
|
|
179
304
|
pinned;
|
|
305
|
+
enabledExtensions;
|
|
306
|
+
listings;
|
|
180
307
|
constructor(extraTools = [],
|
|
181
308
|
/** Appended as a virtual context file, so Pi's own prompt stays intact.
|
|
182
309
|
* Read per session, not captured once: it carries settings a user can
|
|
@@ -193,21 +320,34 @@ export class PiAgentFactory {
|
|
|
193
320
|
credentials, providerConfig = new PiConfigStore(),
|
|
194
321
|
/** Operator-pinned models (Console → Settings → Models), surfaced first in
|
|
195
322
|
* every picker. A getter for the same reason `instructions` is one. */
|
|
196
|
-
pinned = () => []
|
|
323
|
+
pinned = () => [],
|
|
324
|
+
/** Which bundled extensions the Console has switched on. A getter for the
|
|
325
|
+
* same reason again: the toggle takes effect on the next session open. */
|
|
326
|
+
enabledExtensions = () => [],
|
|
327
|
+
/** What exists on disk. Injected so a test can hand this factory a listing
|
|
328
|
+
* instead of a session directory and a database. */
|
|
329
|
+
listings = new IndexedListing()) {
|
|
197
330
|
this.extraTools = extraTools;
|
|
198
331
|
this.instructions = instructions;
|
|
199
332
|
this.skillPaths = skillPaths;
|
|
200
333
|
this.credentials = credentials;
|
|
201
334
|
this.providerConfig = providerConfig;
|
|
202
335
|
this.pinned = pinned;
|
|
336
|
+
this.enabledExtensions = enabledExtensions;
|
|
337
|
+
this.listings = listings;
|
|
203
338
|
}
|
|
204
339
|
/** One runtime for the whole process; catalogs are global, not per session. */
|
|
205
340
|
catalog;
|
|
206
|
-
/** Where each listed session lives.
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
*
|
|
341
|
+
/** Where each listed session lives. A scan still stats every session file on
|
|
342
|
+
* disk, which `resume` would pay on every cold open — web selection, an IM
|
|
343
|
+
* message, a task run. The sidebar's own listing keeps this warm; a miss
|
|
344
|
+
* still lists. */
|
|
210
345
|
located = new Map();
|
|
346
|
+
/** That same scan, retained for LIST_TTL_MS instead of paid once per asking
|
|
347
|
+
* surface — one workspace event has three (sidebar, Activity, task lookups).
|
|
348
|
+
* Dropped on create/fork; ids appear for reasons this factory never sees, so
|
|
349
|
+
* a miss that decides something re-lists rather than trusts it (`resume`). */
|
|
350
|
+
listing;
|
|
211
351
|
refreshQueue = Promise.resolve();
|
|
212
352
|
builtinProviderIds;
|
|
213
353
|
/** Structural fit: CredentialStore mirrors pi-ai's interface of the same
|
|
@@ -276,6 +416,65 @@ export class PiAgentFactory {
|
|
|
276
416
|
};
|
|
277
417
|
});
|
|
278
418
|
}
|
|
419
|
+
/**
|
|
420
|
+
* One real request on the model the operator named. `configured` only ever
|
|
421
|
+
* meant "a credential is stored", and a wrong base URL, a revoked key, a
|
|
422
|
+
* gateway rewriting the request and a model this endpoint has never heard of
|
|
423
|
+
* all look identical until a turn fails hours later.
|
|
424
|
+
*
|
|
425
|
+
* The request goes out through a fetch of our own for one reason: what a
|
|
426
|
+
* provider (or a proxy in front of it) was actually sent, and what it
|
|
427
|
+
* actually said, is the answer here — a summary of either would be Pier's
|
|
428
|
+
* word for someone else's.
|
|
429
|
+
*/
|
|
430
|
+
async check(providerId, modelId) {
|
|
431
|
+
const started = Date.now();
|
|
432
|
+
const signal = AbortSignal.timeout(PROVIDER_CHECK_TIMEOUT_MS);
|
|
433
|
+
let request = "";
|
|
434
|
+
let body = Promise.resolve("");
|
|
435
|
+
const recorded = async (input, init) => {
|
|
436
|
+
request = typeof init?.body === "string" ? init.body : "";
|
|
437
|
+
const response = await globalThis.fetch(input, init);
|
|
438
|
+
// Cloned, not consumed: the SDK still needs to read the real stream.
|
|
439
|
+
body = response.clone().text().then(clip, () => "");
|
|
440
|
+
return response;
|
|
441
|
+
};
|
|
442
|
+
const answered = (text, ok) => ({
|
|
443
|
+
ok,
|
|
444
|
+
model: modelId,
|
|
445
|
+
ms: Date.now() - started,
|
|
446
|
+
request: clip(request),
|
|
447
|
+
response: text,
|
|
448
|
+
});
|
|
449
|
+
try {
|
|
450
|
+
const runtime = await this.refreshedRuntime();
|
|
451
|
+
const model = runtime.getModel(providerId, modelId);
|
|
452
|
+
if (!model)
|
|
453
|
+
throw new Error(`unknown model: ${providerId}/${modelId}`);
|
|
454
|
+
const answer = await runtime.completeSimple(model, { messages: [{ role: "user", content: "hi", timestamp: Date.now() }] }, { maxTokens: PROVIDER_CHECK_MAX_TOKENS, signal, fetch: recorded });
|
|
455
|
+
// A refusal can arrive as a message rather than a throw; the stop reason
|
|
456
|
+
// is the only thing separating it from an answer.
|
|
457
|
+
const refused = answer.stopReason === "error" || answer.stopReason === "aborted";
|
|
458
|
+
if (refused) {
|
|
459
|
+
throw new Error(answer.errorMessage ?? `the provider stopped: ${answer.stopReason}`);
|
|
460
|
+
}
|
|
461
|
+
const text = answer.content
|
|
462
|
+
.filter((part) => part.type === "text")
|
|
463
|
+
.map((part) => part.text)
|
|
464
|
+
.join("")
|
|
465
|
+
.trim();
|
|
466
|
+
// An empty answer is still an answer; say which kind of nothing it was.
|
|
467
|
+
return answered(clip(text) || `(no text; stop reason: ${answer.stopReason})`, true);
|
|
468
|
+
}
|
|
469
|
+
catch (err) {
|
|
470
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
471
|
+
log.warn(`provider check failed for ${providerId}/${modelId}`, err);
|
|
472
|
+
const raw = await body;
|
|
473
|
+
return answered(signal.aborted
|
|
474
|
+
? `no answer within ${PROVIDER_CHECK_TIMEOUT_MS / 1000}s (${error})`
|
|
475
|
+
: raw || error, false);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
279
478
|
async setup(input) {
|
|
280
479
|
const builtins = await this.builtinIds();
|
|
281
480
|
if (input.kind === "builtin" && !builtins.has(input.id)) {
|
|
@@ -344,7 +543,11 @@ export class PiAgentFactory {
|
|
|
344
543
|
// Pi's generic default, preserving the user's later instruction layer.
|
|
345
544
|
systemPromptOverride: pierSystemPrompt,
|
|
346
545
|
additionalSkillPaths: this.skillPaths,
|
|
347
|
-
extensionFactories: [
|
|
546
|
+
extensionFactories: [
|
|
547
|
+
{ name: "pier-bash-timeout", factory: bashTimeoutDefault, hidden: true },
|
|
548
|
+
...inlineExtensions(this.enabledExtensions()),
|
|
549
|
+
],
|
|
550
|
+
extensionsOverride: standDownShadowed,
|
|
348
551
|
agentsFilesOverride: (current) => {
|
|
349
552
|
const content = this.instructions();
|
|
350
553
|
return {
|
|
@@ -359,8 +562,12 @@ export class PiAgentFactory {
|
|
|
359
562
|
}
|
|
360
563
|
async open(cwd, sessionManager, opts = { cwd }) {
|
|
361
564
|
let live;
|
|
565
|
+
// Asked per open, not captured at wiring: a tool whose channel is not
|
|
566
|
+
// configured yet would otherwise cost context on every turn of every
|
|
567
|
+
// session and be able to answer nothing.
|
|
568
|
+
const active = this.extraTools.filter((tool) => tool.available?.() ?? true);
|
|
362
569
|
// Generic translation only — tool contracts are data owned by their feature.
|
|
363
|
-
const customTools =
|
|
570
|
+
const customTools = active.map((tool) => defineTool({
|
|
364
571
|
name: tool.name,
|
|
365
572
|
label: tool.label,
|
|
366
573
|
description: tool.description,
|
|
@@ -395,19 +602,27 @@ export class PiAgentFactory {
|
|
|
395
602
|
this.credentials?.assertUnlocked();
|
|
396
603
|
if (opts.name)
|
|
397
604
|
sessionManager.appendSessionInfo(opts.name);
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
605
|
+
// This runtime serves exactly this one session, so shadowing its
|
|
606
|
+
// streamSimple is the per-session seam for the Anthropic cache TTL:
|
|
607
|
+
// interactive sessions keep "long" (1h — turns arrive minutes apart),
|
|
608
|
+
// tasks downgrade to "short" (5m) via setCacheRetention. The default sits
|
|
609
|
+
// before the spread so an explicit per-request value still wins —
|
|
610
|
+
// compaction passes cacheRetention: "none" and must keep it.
|
|
611
|
+
const runtime = await this.createRuntime();
|
|
612
|
+
const retention = { value: "long" };
|
|
613
|
+
const stream = runtime.streamSimple.bind(runtime);
|
|
614
|
+
runtime.streamSimple = ((model, context, options) => stream(model, context, { cacheRetention: retention.value, ...options }));
|
|
401
615
|
const created = await createAgentSession({
|
|
402
616
|
cwd,
|
|
403
617
|
sessionManager,
|
|
404
618
|
customTools,
|
|
405
|
-
|
|
406
|
-
modelRuntime: await this.createRuntime(),
|
|
619
|
+
modelRuntime: runtime,
|
|
407
620
|
resourceLoader: await this.resourceLoader(cwd),
|
|
408
621
|
});
|
|
409
622
|
live = created.session;
|
|
410
|
-
const session = new PiSession(live, this.pinned)
|
|
623
|
+
const session = new PiSession(live, this.pinned, () => {
|
|
624
|
+
this.listing = undefined;
|
|
625
|
+
}, retention);
|
|
411
626
|
if (opts.model)
|
|
412
627
|
await session.setModel(opts.model);
|
|
413
628
|
if (opts.thinking)
|
|
@@ -416,6 +631,7 @@ export class PiAgentFactory {
|
|
|
416
631
|
return session;
|
|
417
632
|
}
|
|
418
633
|
async create(opts) {
|
|
634
|
+
this.listing = undefined;
|
|
419
635
|
return this.open(opts.cwd, SessionManager.create(opts.cwd), opts);
|
|
420
636
|
}
|
|
421
637
|
async fork(sourceSessionId, opts) {
|
|
@@ -435,6 +651,7 @@ export class PiAgentFactory {
|
|
|
435
651
|
if (!leafId)
|
|
436
652
|
throw new Error("cannot fork a session before its first persisted input");
|
|
437
653
|
manager.createBranchedSession(leafId);
|
|
654
|
+
this.listing = undefined;
|
|
438
655
|
return this.open(opts.cwd, manager, opts);
|
|
439
656
|
}
|
|
440
657
|
async resume(sessionId) {
|
|
@@ -450,27 +667,57 @@ export class PiAgentFactory {
|
|
|
450
667
|
this.located.delete(sessionId);
|
|
451
668
|
}
|
|
452
669
|
}
|
|
453
|
-
const
|
|
454
|
-
const info = infos.find((s) => s.id === sessionId);
|
|
670
|
+
const info = await this.locate(sessionId);
|
|
455
671
|
if (!info)
|
|
456
672
|
throw new Error(`unknown session: ${sessionId}`);
|
|
457
673
|
return this.open(info.cwd || process.cwd(), SessionManager.open(info.path));
|
|
458
674
|
}
|
|
675
|
+
/** The listed record for one id, and the one place "no such session" is
|
|
676
|
+
* decided. A retained listing is not evidence that a session is gone: it may
|
|
677
|
+
* have been written since — by another Pier, or by the first turn of a
|
|
678
|
+
* session this factory opened. The miss is what earns a fresh scan, because
|
|
679
|
+
* callers read it as permission to start a replacement session
|
|
680
|
+
* (channels/conversations.ts), which costs a conversation its history, or as
|
|
681
|
+
* a session that no longer exists (tasks/, web/files.ts). `reused` is how we
|
|
682
|
+
* know a scan is owed: same entry back, same disk state. */
|
|
683
|
+
async locate(sessionId) {
|
|
684
|
+
const find = (infos) => infos.find((s) => s.id === sessionId);
|
|
685
|
+
const reused = this.listing;
|
|
686
|
+
return find(await this.listed()) ??
|
|
687
|
+
(reused && this.listing === reused ? find(await this.listed(true)) : undefined);
|
|
688
|
+
}
|
|
689
|
+
async find(sessionId) {
|
|
690
|
+
const info = await this.locate(sessionId);
|
|
691
|
+
return info ? summaryOf(info) : undefined;
|
|
692
|
+
}
|
|
693
|
+
/** Once per process, after the first listing: agent/listing.ts reads Pi's
|
|
694
|
+
* transcripts with a parser of its own, and only a comparison notices when
|
|
695
|
+
* that format moves under it. */
|
|
696
|
+
audited = false;
|
|
459
697
|
/** Every listing goes through here, so it also refreshes `located`. */
|
|
460
|
-
|
|
461
|
-
const
|
|
462
|
-
|
|
463
|
-
this.
|
|
464
|
-
|
|
698
|
+
listed(force = false) {
|
|
699
|
+
const now = Date.now();
|
|
700
|
+
if (!force && this.listing && now - this.listing.at < LIST_TTL_MS)
|
|
701
|
+
return this.listing.infos;
|
|
702
|
+
const infos = this.listings.scan().then((listed) => {
|
|
703
|
+
for (const s of listed) {
|
|
704
|
+
this.located.set(s.id, { path: s.path, cwd: s.cwd || process.cwd() });
|
|
705
|
+
}
|
|
706
|
+
if (!this.audited && this.listings.audit) {
|
|
707
|
+
this.audited = true;
|
|
708
|
+
void this.listings.audit(() => SessionManager.listAll()).then((wrong) => wrong || log.debug("session index agrees with Pi's own listing"), (err) => log.warn("session index cross-check failed", err));
|
|
709
|
+
}
|
|
710
|
+
return listed;
|
|
711
|
+
});
|
|
712
|
+
// A failed scan is not an answer to hand the next caller for three seconds.
|
|
713
|
+
void infos.catch(() => {
|
|
714
|
+
if (this.listing?.infos === infos)
|
|
715
|
+
this.listing = undefined;
|
|
716
|
+
});
|
|
717
|
+
this.listing = { at: now, infos };
|
|
465
718
|
return infos;
|
|
466
719
|
}
|
|
467
720
|
async list() {
|
|
468
|
-
|
|
469
|
-
return infos.map((s) => ({
|
|
470
|
-
id: s.id,
|
|
471
|
-
cwd: s.cwd,
|
|
472
|
-
createdAt: s.created.getTime(),
|
|
473
|
-
title: s.name ?? (s.firstMessage ? s.firstMessage.slice(0, 80) : undefined),
|
|
474
|
-
}));
|
|
721
|
+
return (await this.listed()).map(summaryOf);
|
|
475
722
|
}
|
|
476
723
|
}
|
package/dist/boards/boards.js
CHANGED
|
@@ -5,7 +5,10 @@
|
|
|
5
5
|
// Only <board>/site is reachable over HTTP: sources, README and the manifest
|
|
6
6
|
// itself stay off the wire, so a public board leaks nothing about how it was
|
|
7
7
|
// made. `/boards/*` is authenticated; `/p/*` additionally requires the
|
|
8
|
-
// manifest's `public` flag and runs as sandboxed active content
|
|
8
|
+
// manifest's `public` flag and runs as sandboxed active content — and is the
|
|
9
|
+
// only password-free prefix, stylesheet included, so one firewall rule covers
|
|
10
|
+
// everything a logged-out reader may fetch.
|
|
11
|
+
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
9
12
|
import { readdir, readFile, realpath, rename, stat, writeFile } from "node:fs/promises";
|
|
10
13
|
import { extname, join, resolve, sep } from "node:path";
|
|
11
14
|
import { logger } from "../log.js";
|
|
@@ -14,6 +17,13 @@ export const defaultBoardsDir = () => pierPath("boards");
|
|
|
14
17
|
/** Deleted boards keep their bytes under `<slug>.deleted-<ts>`, which this
|
|
15
18
|
* pattern excludes from every scan — one rename is the whole delete path. */
|
|
16
19
|
const SLUG = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
20
|
+
/** A published board is addressed by `<slug>-<token>`: the slug alone is a
|
|
21
|
+
* guessable word, so without the 32 bits after it `/p/` could be walked with
|
|
22
|
+
* a dictionary. Minted the first time a manifest is seen public, by whichever
|
|
23
|
+
* path published it — the Console's toggle or an agent editing board.json —
|
|
24
|
+
* so a board cannot be public and enumerable at the same time. */
|
|
25
|
+
const TOKEN = /^[a-f0-9]{8}$/;
|
|
26
|
+
const mintToken = () => randomBytes(4).toString("hex");
|
|
17
27
|
// A board ships fonts and images, so the list is wider than the attachment
|
|
18
28
|
// route's — but still a whitelist: an unlisted extension is not served at all.
|
|
19
29
|
const TYPES = {
|
|
@@ -49,7 +59,10 @@ const warned = new Set();
|
|
|
49
59
|
* here and nowhere else: an unvalidated `../../etc` would read outside the
|
|
50
60
|
* boards dir, and a NUL byte would throw instead of 404. Unknown fields get
|
|
51
61
|
* defaults, a broken file is skipped whole, and extra keys are the agent's
|
|
52
|
-
* business — they survive a write.
|
|
62
|
+
* business — they survive a write. The one manifest this writes back is a
|
|
63
|
+
* public board that arrived without a token: minting is the same decision as
|
|
64
|
+
* reading `public`, and doing it anywhere else leaves the agent's own publish
|
|
65
|
+
* path — editing `board.json` — with no URL. */
|
|
53
66
|
async function readManifest(dir, slug) {
|
|
54
67
|
if (!SLUG.test(slug))
|
|
55
68
|
return null;
|
|
@@ -69,14 +82,29 @@ async function readManifest(dir, slug) {
|
|
|
69
82
|
if (typeof raw !== "object" || raw === null || Array.isArray(raw))
|
|
70
83
|
return null;
|
|
71
84
|
const m = raw;
|
|
72
|
-
|
|
85
|
+
const manifest = {
|
|
73
86
|
...m,
|
|
74
87
|
title: typeof m.title === "string" && m.title ? m.title : slug,
|
|
75
88
|
description: typeof m.description === "string" ? m.description : "",
|
|
76
89
|
sessions: Array.isArray(m.sessions) ? m.sessions.filter((s) => typeof s === "string") : [],
|
|
77
90
|
public: m.public === true,
|
|
91
|
+
token: typeof m.token === "string" && TOKEN.test(m.token) ? m.token : "",
|
|
78
92
|
};
|
|
93
|
+
if (manifest.public && !manifest.token) {
|
|
94
|
+
manifest.token = mintToken();
|
|
95
|
+
try {
|
|
96
|
+
await writeManifest(dir, slug, manifest);
|
|
97
|
+
}
|
|
98
|
+
catch (err) {
|
|
99
|
+
// A token that cannot be stored would differ on the next request, so the
|
|
100
|
+
// board stays unreachable on /p/ rather than handing out a dead link.
|
|
101
|
+
logger("boards").warn(`cannot mint a public token for ${slug}`, err);
|
|
102
|
+
manifest.token = "";
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return manifest;
|
|
79
106
|
}
|
|
107
|
+
const writeManifest = (dir, slug, manifest) => writeFile(join(dir, slug, "board.json"), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
80
108
|
/** Freshness is the site's mtime, not a manifest field — the filesystem
|
|
81
109
|
* already knows, and an agent rewriting a page cannot forget to say so. */
|
|
82
110
|
async function updatedAt(dir, slug) {
|
|
@@ -99,8 +127,8 @@ export async function listBoards(dir) {
|
|
|
99
127
|
const manifest = await readManifest(dir, slug);
|
|
100
128
|
if (!manifest)
|
|
101
129
|
continue;
|
|
102
|
-
const { title, description, sessions, public: isPublic } = manifest;
|
|
103
|
-
boards.push({ slug, title, description, sessions, public: isPublic, updatedAt: await updatedAt(dir, slug) });
|
|
130
|
+
const { title, description, sessions, public: isPublic, token } = manifest;
|
|
131
|
+
boards.push({ slug, title, description, sessions, public: isPublic, token, updatedAt: await updatedAt(dir, slug) });
|
|
104
132
|
}
|
|
105
133
|
return boards;
|
|
106
134
|
}
|
|
@@ -139,11 +167,28 @@ async function resolveFile(dir, slug, rest) {
|
|
|
139
167
|
return resolveFile(dir, slug, `${relative}/`);
|
|
140
168
|
return info.isFile() ? file : null;
|
|
141
169
|
}
|
|
142
|
-
|
|
170
|
+
/** `/p/` addresses a board as `<slug>-<token>`; a slug may itself contain
|
|
171
|
+
* hyphens, so the last one is the cut. */
|
|
172
|
+
function publicKey(key) {
|
|
173
|
+
const cut = key.lastIndexOf("-");
|
|
174
|
+
return cut < 1 ? { slug: "", token: "" } : { slug: key.slice(0, cut), token: key.slice(cut + 1) };
|
|
175
|
+
}
|
|
176
|
+
/** Digested first: the token is a secret, and a URL's half may be any length
|
|
177
|
+
* or encoding, which a raw comparison would either leak or throw on. */
|
|
178
|
+
const sameToken = (want, got) => {
|
|
179
|
+
if (!want)
|
|
180
|
+
return false;
|
|
181
|
+
const digest = (s) => createHash("sha256").update(s).digest();
|
|
182
|
+
return timingSafeEqual(digest(want), digest(got));
|
|
183
|
+
};
|
|
184
|
+
async function serveFile(c, dir, key, rest, publicOnly) {
|
|
185
|
+
const { slug, token } = publicOnly ? publicKey(key) : { slug: key, token: "" };
|
|
143
186
|
const manifest = await readManifest(dir, slug);
|
|
144
|
-
// 404, never 403: a private board's existence is not public information
|
|
145
|
-
|
|
187
|
+
// 404, never 403: a private board's existence is not public information, and
|
|
188
|
+
// a wrong token is the same non-answer as a wrong name.
|
|
189
|
+
if (!manifest || (publicOnly && (!manifest.public || !sameToken(manifest.token, token)))) {
|
|
146
190
|
return c.notFound();
|
|
191
|
+
}
|
|
147
192
|
const file = await resolveFile(dir, slug, rest);
|
|
148
193
|
if (!file)
|
|
149
194
|
return c.notFound();
|
|
@@ -179,8 +224,12 @@ export function registerBoardRoutes(app, dir = defaultBoardsDir()) {
|
|
|
179
224
|
if (!manifest)
|
|
180
225
|
return c.json({ error: "no such board" }, 404);
|
|
181
226
|
manifest.public = body.public;
|
|
182
|
-
|
|
183
|
-
|
|
227
|
+
// Publishing an unpublished board is the case readManifest cannot mint for:
|
|
228
|
+
// it read the manifest while it was still private.
|
|
229
|
+
if (manifest.public && !manifest.token)
|
|
230
|
+
manifest.token = mintToken();
|
|
231
|
+
await writeManifest(dir, slug, manifest);
|
|
232
|
+
return c.json({ public: manifest.public, token: manifest.token });
|
|
184
233
|
});
|
|
185
234
|
app.delete("/api/boards/:slug", async (c) => {
|
|
186
235
|
const slug = c.req.param("slug");
|
|
@@ -190,7 +239,7 @@ export function registerBoardRoutes(app, dir = defaultBoardsDir()) {
|
|
|
190
239
|
return c.json({ deleted: slug });
|
|
191
240
|
});
|
|
192
241
|
// Declared before the wildcards below: `_assets` is not a slug.
|
|
193
|
-
app.get("/
|
|
242
|
+
app.get("/p/_assets/pier.css", async (c) => {
|
|
194
243
|
const file = new URL("./pier.css", import.meta.url);
|
|
195
244
|
return c.body(await readFile(file), 200, {
|
|
196
245
|
"content-type": "text/css; charset=utf-8",
|
|
@@ -200,11 +249,11 @@ export function registerBoardRoutes(app, dir = defaultBoardsDir()) {
|
|
|
200
249
|
// Trailing slash matters: without it a board's relative asset paths resolve
|
|
201
250
|
// against /boards instead of the board.
|
|
202
251
|
for (const prefix of ["/boards", "/p"]) {
|
|
203
|
-
app.get(`${prefix}/:
|
|
204
|
-
app.get(`${prefix}/:
|
|
205
|
-
const
|
|
206
|
-
const rest = c.req.path.slice(`${prefix}/${
|
|
207
|
-
return serveFile(c, dir,
|
|
252
|
+
app.get(`${prefix}/:key`, (c) => c.redirect(`${prefix}/${c.req.param("key")}/`));
|
|
253
|
+
app.get(`${prefix}/:key/*`, (c) => {
|
|
254
|
+
const key = c.req.param("key");
|
|
255
|
+
const rest = c.req.path.slice(`${prefix}/${key}/`.length);
|
|
256
|
+
return serveFile(c, dir, key, rest, prefix === "/p");
|
|
208
257
|
});
|
|
209
258
|
}
|
|
210
259
|
}
|
package/dist/boards/pier.css
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/* pier.css — the one stylesheet every Board can link, served at
|
|
2
|
-
/
|
|
2
|
+
/p/_assets/pier.css. Classless on purpose: plain semantic HTML must
|
|
3
3
|
look finished, so a board needs no build step and no framework. A handful of
|
|
4
4
|
named helpers cover the shapes a report actually asks for. */
|
|
5
5
|
|