@timqi/pier 0.0.17 → 0.0.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent/config-sync.js +123 -0
- package/dist/agent/config.js +95 -3
- package/dist/agent/events.js +26 -5
- package/dist/agent/listing.js +12 -13
- package/dist/agent/pi.js +75 -27
- package/dist/boards/boards.js +7 -6
- package/dist/channels/attach.js +18 -4
- package/dist/channels/config.js +7 -2
- package/dist/channels/dedup.js +9 -1
- package/dist/channels/lark.js +2 -6
- package/dist/channels/receipts.js +21 -6
- package/dist/channels/slack-tool.js +86 -25
- package/dist/channels/slack.js +2 -10
- package/dist/channels/telegram.js +1 -0
- package/dist/config-sync-fetch.js +97 -0
- package/dist/config-sync-task.js +59 -0
- package/dist/config-sync.js +179 -0
- package/dist/core/hub.js +23 -6
- package/dist/core/inbox.js +1 -1
- package/dist/core/reply.js +76 -2
- package/dist/core/router.js +162 -37
- package/dist/db.js +50 -0
- package/dist/main.js +32 -1
- package/dist/secrets.js +18 -6
- package/dist/tasks/agent.js +25 -23
- package/dist/tasks/callbacks.js +1 -1
- package/dist/tasks/definitions.js +28 -17
- package/dist/tasks/execution.js +11 -3
- package/dist/tasks/messages.js +58 -16
- package/dist/tasks/routes.js +58 -10
- package/dist/tasks/runs.js +0 -5
- package/dist/tasks/service.js +14 -4
- package/dist/tasks/store.js +87 -10
- package/dist/tasks/tool.js +8 -4
- package/dist/tools.js +32 -2
- package/dist/web/auth.js +17 -23
- package/dist/web/config-sync.js +91 -0
- package/dist/web/config.js +61 -0
- package/dist/web/explorer.js +20 -70
- package/dist/web/fs.js +159 -0
- package/dist/web/instance.js +11 -0
- package/dist/web/public/assets/activity-DYo7QS7k.js +5 -0
- package/dist/web/public/assets/activity-DYo7QS7k.js.br +0 -0
- package/dist/web/public/assets/activity-DYo7QS7k.js.gz +0 -0
- package/dist/web/public/assets/boards-CwohTbtv.js +1 -0
- package/dist/web/public/assets/boards-CwohTbtv.js.br +0 -0
- package/dist/web/public/assets/boards-CwohTbtv.js.gz +0 -0
- package/dist/web/public/assets/code-p-Tq1bMK.js +1 -0
- package/dist/web/public/assets/explorer-50no_DrD.js +5 -0
- package/dist/web/public/assets/explorer-50no_DrD.js.br +0 -0
- package/dist/web/public/assets/explorer-50no_DrD.js.gz +0 -0
- package/dist/web/public/assets/{ghostty-web-CV7F9_iW.js → ghostty-web-Bvhh3ZFk.js} +1 -1
- package/dist/web/public/assets/ghostty-web-Bvhh3ZFk.js.br +0 -0
- package/dist/web/public/assets/ghostty-web-Bvhh3ZFk.js.gz +0 -0
- package/dist/web/public/assets/hljs-tWqyD59G.js +3 -0
- package/dist/web/public/assets/hljs-tWqyD59G.js.br +0 -0
- package/dist/web/public/assets/hljs-tWqyD59G.js.gz +0 -0
- package/dist/web/public/assets/{index-CtR-Ob4T.css → index-DuZ2vERd.css} +1 -1
- package/dist/web/public/assets/index-DuZ2vERd.css.br +0 -0
- package/dist/web/public/assets/index-DuZ2vERd.css.gz +0 -0
- package/dist/web/public/assets/index-WrsNstrb.js +79 -0
- package/dist/web/public/assets/index-WrsNstrb.js.br +0 -0
- package/dist/web/public/assets/index-WrsNstrb.js.gz +0 -0
- package/dist/web/public/assets/runs-BAXZF-6r.js +1 -0
- package/dist/web/public/assets/runs-BAXZF-6r.js.br +0 -0
- package/dist/web/public/assets/runs-BAXZF-6r.js.gz +0 -0
- package/dist/web/public/assets/settings-BmNXgRne.js +6 -0
- package/dist/web/public/assets/settings-BmNXgRne.js.br +0 -0
- package/dist/web/public/assets/settings-BmNXgRne.js.gz +0 -0
- package/dist/web/public/assets/task-runs-CpgHxFLf.js +3 -0
- package/dist/web/public/assets/task-runs-CpgHxFLf.js.br +0 -0
- package/dist/web/public/assets/task-runs-CpgHxFLf.js.gz +0 -0
- package/dist/web/public/assets/tasks-DybtogPF.js +4 -0
- package/dist/web/public/assets/tasks-DybtogPF.js.br +0 -0
- package/dist/web/public/assets/tasks-DybtogPF.js.gz +0 -0
- package/dist/web/public/assets/terminal-m2_WYu5W.js +2 -0
- package/dist/web/public/assets/terminal-m2_WYu5W.js.br +0 -0
- package/dist/web/public/assets/terminal-m2_WYu5W.js.gz +0 -0
- package/dist/web/public/icon.svg.br +0 -0
- package/dist/web/public/icon.svg.gz +0 -0
- package/dist/web/public/index.html +13 -2
- package/dist/web/public/index.html.br +0 -0
- package/dist/web/public/index.html.gz +0 -0
- package/dist/web/public/manifest.webmanifest.br +0 -0
- package/dist/web/public/manifest.webmanifest.gz +0 -0
- package/dist/web/public/sw.js.br +0 -0
- package/dist/web/public/sw.js.gz +0 -0
- package/dist/web/route.js +17 -0
- package/dist/web/server.js +152 -66
- package/dist/web/terminal.js +2 -7
- package/package.json +2 -2
- package/skills/pier-slack/SKILL.md +4 -16
- package/skills/pier-tasks/SKILL.md +14 -9
- package/dist/web/files.js +0 -183
- package/dist/web/public/assets/index-DAxKtidH.js +0 -93
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// Credential-blind projection of the portable agent configuration.
|
|
2
|
+
const MODEL_KEYS = ["id", "name", "reasoning", "input", "cost", "contextWindow", "maxTokens", "api"];
|
|
3
|
+
const COST_KEYS = ["input", "output", "cacheRead", "cacheWrite"];
|
|
4
|
+
function record(value, where) {
|
|
5
|
+
if (!value || typeof value !== "object" || Array.isArray(value) ||
|
|
6
|
+
![Object.prototype, null].includes(Object.getPrototypeOf(value))) {
|
|
7
|
+
throw new Error(`${where} must be an object`);
|
|
8
|
+
}
|
|
9
|
+
return value;
|
|
10
|
+
}
|
|
11
|
+
function only(value, keys, where) {
|
|
12
|
+
if (Object.keys(value).some((key) => !keys.includes(key))) {
|
|
13
|
+
throw new Error(`${where} contains unsupported fields`);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
function modelDefinition(value) {
|
|
17
|
+
const model = record(value, "model");
|
|
18
|
+
only(model, MODEL_KEYS, "model");
|
|
19
|
+
if (typeof model.id !== "string" || !model.id.trim() || model.id !== model.id.trim()) {
|
|
20
|
+
throw new Error("model id must be a non-empty trimmed string");
|
|
21
|
+
}
|
|
22
|
+
if (model.name !== undefined && (typeof model.name !== "string" || !model.name.length))
|
|
23
|
+
throw new Error("model name must be a non-empty string");
|
|
24
|
+
if (model.reasoning !== undefined && typeof model.reasoning !== "boolean") {
|
|
25
|
+
throw new Error("model reasoning must be a boolean");
|
|
26
|
+
}
|
|
27
|
+
if (model.api !== undefined && (typeof model.api !== "string" || !/^[a-z][a-z0-9-]*$/.test(model.api))) {
|
|
28
|
+
throw new Error("model api must be a protocol name");
|
|
29
|
+
}
|
|
30
|
+
if (model.input !== undefined && (!Array.isArray(model.input) || !model.input.length ||
|
|
31
|
+
model.input.some((input) => input !== "text" && input !== "image") ||
|
|
32
|
+
new Set(model.input).size !== model.input.length)) {
|
|
33
|
+
throw new Error("model input must contain text or image without duplicates");
|
|
34
|
+
}
|
|
35
|
+
for (const key of ["contextWindow", "maxTokens"]) {
|
|
36
|
+
if (model[key] !== undefined && (typeof model[key] !== "number" || !Number.isSafeInteger(model[key]) || model[key] <= 0)) {
|
|
37
|
+
throw new Error(`model ${key} must be a positive integer`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (model.cost !== undefined) {
|
|
41
|
+
const cost = record(model.cost, "model cost");
|
|
42
|
+
only(cost, COST_KEYS, "model cost");
|
|
43
|
+
for (const key of COST_KEYS) {
|
|
44
|
+
if (typeof cost[key] !== "number" || !Number.isFinite(cost[key]) || cost[key] < 0) {
|
|
45
|
+
throw new Error(`model cost ${key} must be a non-negative finite number`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return structuredClone(model);
|
|
50
|
+
}
|
|
51
|
+
/** Strict import boundary: unknown fields are rejected, never treated as masked secrets. */
|
|
52
|
+
export function normalizeAgentSnapshot(raw) {
|
|
53
|
+
const snapshot = record(raw, "agent snapshot");
|
|
54
|
+
only(snapshot, ["files", "providers"], "agent snapshot");
|
|
55
|
+
const files = record(snapshot.files, "snapshot files");
|
|
56
|
+
only(files, ["SYSTEM.md", "AGENTS.md"], "snapshot files");
|
|
57
|
+
for (const name of ["SYSTEM.md", "AGENTS.md"]) {
|
|
58
|
+
if (files[name] !== null && typeof files[name] !== "string") {
|
|
59
|
+
throw new Error(`${name} must be a string or null`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const providers = Object.fromEntries(Object.entries(record(snapshot.providers, "snapshot providers")).map(([id, value]) => {
|
|
63
|
+
if (id.length > 100 || !/^[a-z0-9][a-z0-9._-]*$/.test(id) || ["constructor", "prototype"].includes(id)) {
|
|
64
|
+
throw new Error("invalid snapshot provider id");
|
|
65
|
+
}
|
|
66
|
+
const provider = record(value, "snapshot provider");
|
|
67
|
+
only(provider, ["models"], "snapshot provider");
|
|
68
|
+
if (!Array.isArray(provider.models))
|
|
69
|
+
throw new Error("snapshot provider models must be an array");
|
|
70
|
+
const models = provider.models.map(modelDefinition);
|
|
71
|
+
if (new Set(models.map((model) => model.id)).size !== models.length)
|
|
72
|
+
throw new Error("duplicate model ids");
|
|
73
|
+
return [id, { models }];
|
|
74
|
+
}));
|
|
75
|
+
return { files: { "SYSTEM.md": files["SYSTEM.md"], "AGENTS.md": files["AGENTS.md"] }, providers };
|
|
76
|
+
}
|
|
77
|
+
/** Project disk metadata before validation, so no nested transport object can escape. */
|
|
78
|
+
export function snapshotProviders(providers = {}) {
|
|
79
|
+
return Object.fromEntries(Object.entries(providers).flatMap(([id, provider]) => {
|
|
80
|
+
if (provider.models === undefined)
|
|
81
|
+
return [];
|
|
82
|
+
if (!Array.isArray(provider.models))
|
|
83
|
+
throw new Error("models.json models must be an array");
|
|
84
|
+
const models = provider.models.map((value) => {
|
|
85
|
+
const source = record(value, "models.json model");
|
|
86
|
+
const model = Object.fromEntries(MODEL_KEYS.flatMap((key) => source[key] === undefined ? [] : [[key, source[key]]]));
|
|
87
|
+
if (source.cost !== undefined) {
|
|
88
|
+
const cost = record(source.cost, "models.json model cost");
|
|
89
|
+
model.cost = Object.fromEntries(COST_KEYS.flatMap((key) => cost[key] === undefined ? [] : [[key, cost[key]]]));
|
|
90
|
+
}
|
|
91
|
+
return modelDefinition(model);
|
|
92
|
+
});
|
|
93
|
+
return [[id, { models }]];
|
|
94
|
+
}));
|
|
95
|
+
}
|
|
96
|
+
/** Replace only portable fields, retaining excluded fields for matching local model ids. */
|
|
97
|
+
export function mergeSnapshotProviders(local = {}, incoming) {
|
|
98
|
+
const providers = structuredClone(local);
|
|
99
|
+
for (const [id, provider] of Object.entries(providers)) {
|
|
100
|
+
if (!Object.hasOwn(incoming, id)) {
|
|
101
|
+
delete provider.models;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
for (const [id, { models }] of Object.entries(incoming)) {
|
|
105
|
+
if (!Object.hasOwn(local, id)) {
|
|
106
|
+
throw new Error(`provider ${id} must be configured locally before importing models`);
|
|
107
|
+
}
|
|
108
|
+
const provider = providers[id];
|
|
109
|
+
if (provider.models !== undefined && !Array.isArray(provider.models))
|
|
110
|
+
throw new Error("models.json models must be an array");
|
|
111
|
+
const existing = new Map((provider.models ?? []).map((value) => {
|
|
112
|
+
const model = record(value, "models.json model");
|
|
113
|
+
return [model.id, model];
|
|
114
|
+
}));
|
|
115
|
+
provider.models = models.map((model) => {
|
|
116
|
+
const retained = { ...existing.get(model.id) };
|
|
117
|
+
for (const key of MODEL_KEYS)
|
|
118
|
+
delete retained[key];
|
|
119
|
+
return { ...retained, ...model };
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
return providers;
|
|
123
|
+
}
|
package/dist/agent/config.js
CHANGED
|
@@ -6,8 +6,10 @@ import { promises as fs } from "node:fs";
|
|
|
6
6
|
import { join, resolve, sep } from "node:path";
|
|
7
7
|
import { isProviderApi, validateEndpoint, validateProviderSetup } from "../core/types.js";
|
|
8
8
|
import { pierPath } from "../paths.js";
|
|
9
|
+
import { mergeSnapshotProviders, normalizeAgentSnapshot, snapshotProviders } from "./config-sync.js";
|
|
9
10
|
const GLOBAL_FILES = ["SYSTEM.md", "AGENTS.md", "settings.json", "models.json"];
|
|
10
11
|
const PROJECT_FILES = ["AGENTS.md"];
|
|
12
|
+
const SNAPSHOT_FILES = ["SYSTEM.md", "AGENTS.md", "models.json"];
|
|
11
13
|
const RESOURCE_DEPTH = 3; // extensions/skills nest at most a couple of levels
|
|
12
14
|
/** Pier owns the Pi runtime dir: config lives in the syncable `~/.pier/pi`
|
|
13
15
|
* repo, not `~/.pi`. main.ts exports this as PI_CODING_AGENT_DIR so the SDK's
|
|
@@ -16,16 +18,17 @@ export const defaultAgentDir = () => process.env.PI_CODING_AGENT_DIR ?? pierPath
|
|
|
16
18
|
/** Stable mask: mapped back by field, without exposing key fragments. */
|
|
17
19
|
const maskKey = (_key) => "••••••••";
|
|
18
20
|
const missing = (err) => err instanceof Error && "code" in err && err.code === "ENOENT";
|
|
19
|
-
const
|
|
21
|
+
const readNullable = async (path) => {
|
|
20
22
|
try {
|
|
21
23
|
return await fs.readFile(path, "utf8");
|
|
22
24
|
}
|
|
23
25
|
catch (err) {
|
|
24
26
|
if (missing(err))
|
|
25
|
-
return
|
|
27
|
+
return null;
|
|
26
28
|
throw err;
|
|
27
29
|
}
|
|
28
30
|
};
|
|
31
|
+
const readOptional = async (path) => (await readNullable(path)) ?? "";
|
|
29
32
|
const pathExists = async (path) => {
|
|
30
33
|
try {
|
|
31
34
|
await fs.access(path);
|
|
@@ -57,9 +60,14 @@ const atomicWrite = async (path, data, mode = 0o644) => {
|
|
|
57
60
|
export class PiConfigStore {
|
|
58
61
|
agentDir;
|
|
59
62
|
#writes = Promise.resolve();
|
|
63
|
+
#rollbackFailed = false;
|
|
60
64
|
constructor(agentDir = defaultAgentDir()) {
|
|
61
65
|
this.agentDir = agentDir;
|
|
62
66
|
}
|
|
67
|
+
#assertSnapshot() {
|
|
68
|
+
if (this.#rollbackFailed)
|
|
69
|
+
throw new Error("Configuration rollback failed; repair agent files before restarting");
|
|
70
|
+
}
|
|
63
71
|
get globalDir() {
|
|
64
72
|
return this.agentDir;
|
|
65
73
|
}
|
|
@@ -87,6 +95,8 @@ export class PiConfigStore {
|
|
|
87
95
|
async readFile(scope, name) {
|
|
88
96
|
const path = this.filePath(scope, name);
|
|
89
97
|
await this.#writes;
|
|
98
|
+
if (scope.kind === "global")
|
|
99
|
+
this.#assertSnapshot();
|
|
90
100
|
const raw = await readOptional(path);
|
|
91
101
|
return name === "models.json" ? maskModels(raw) : raw;
|
|
92
102
|
}
|
|
@@ -104,6 +114,7 @@ export class PiConfigStore {
|
|
|
104
114
|
}
|
|
105
115
|
async providerStructures() {
|
|
106
116
|
await this.#writes;
|
|
117
|
+
this.#assertSnapshot();
|
|
107
118
|
const raw = await readOptional(join(this.agentDir, "models.json"));
|
|
108
119
|
const parsed = parseModels(raw);
|
|
109
120
|
const out = {};
|
|
@@ -199,8 +210,89 @@ export class PiConfigStore {
|
|
|
199
210
|
}
|
|
200
211
|
});
|
|
201
212
|
}
|
|
213
|
+
exportSnapshot() {
|
|
214
|
+
return this.#withWrite(async () => {
|
|
215
|
+
const [system, agents, raw] = await Promise.all(SNAPSHOT_FILES.map((name) => readNullable(join(this.agentDir, name))));
|
|
216
|
+
const parsed = raw?.trim() ? parseModels(raw) : {};
|
|
217
|
+
if (!parsed)
|
|
218
|
+
throw new Error("models.json must be valid JSON before exporting configuration");
|
|
219
|
+
return normalizeAgentSnapshot({
|
|
220
|
+
files: { "SYSTEM.md": system, "AGENTS.md": agents },
|
|
221
|
+
providers: snapshotProviders(parsed.providers),
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
async applySnapshot(snapshot, commit) {
|
|
226
|
+
const incoming = normalizeAgentSnapshot(snapshot);
|
|
227
|
+
await this.#withWrite(async () => {
|
|
228
|
+
const names = SNAPSHOT_FILES;
|
|
229
|
+
const before = await Promise.all(names.map((name) => readNullable(join(this.agentDir, name))));
|
|
230
|
+
const raw = before[2];
|
|
231
|
+
const parsed = raw?.trim() ? parseModels(raw) : {};
|
|
232
|
+
if (!parsed)
|
|
233
|
+
throw new Error("models.json must be valid JSON before importing configuration");
|
|
234
|
+
const providers = mergeSnapshotProviders(parsed.providers, incoming.providers);
|
|
235
|
+
const models = JSON.stringify(providers) === JSON.stringify(parsed.providers ?? {})
|
|
236
|
+
? raw : `${JSON.stringify({ ...parsed, providers }, null, 2)}\n`;
|
|
237
|
+
const after = [incoming.files["SYSTEM.md"], incoming.files["AGENTS.md"], models];
|
|
238
|
+
const changes = names.flatMap((name, index) => before[index] === after[index] ? [] : [{
|
|
239
|
+
path: join(this.agentDir, name), before: before[index], after: after[index],
|
|
240
|
+
temp: join(this.agentDir, `${name}.${process.pid}.${randomUUID()}.tmp`),
|
|
241
|
+
mode: name === "models.json" ? 0o600 : 0o644,
|
|
242
|
+
}]);
|
|
243
|
+
const applied = [];
|
|
244
|
+
try {
|
|
245
|
+
// Prepare every replacement before touching a live file.
|
|
246
|
+
if (changes.length)
|
|
247
|
+
await fs.mkdir(this.agentDir, { recursive: true });
|
|
248
|
+
for (const change of changes) {
|
|
249
|
+
if (change.after !== null)
|
|
250
|
+
await fs.writeFile(change.temp, change.after, { mode: change.mode });
|
|
251
|
+
}
|
|
252
|
+
for (const change of changes) {
|
|
253
|
+
if (change.after === null)
|
|
254
|
+
await fs.unlink(change.path);
|
|
255
|
+
else
|
|
256
|
+
await fs.rename(change.temp, change.path);
|
|
257
|
+
applied.push(change);
|
|
258
|
+
}
|
|
259
|
+
commit?.(changes.length > 0);
|
|
260
|
+
}
|
|
261
|
+
catch (err) {
|
|
262
|
+
const errors = [err];
|
|
263
|
+
for (const change of applied.reverse()) {
|
|
264
|
+
try {
|
|
265
|
+
if (change.before === null)
|
|
266
|
+
await fs.unlink(change.path);
|
|
267
|
+
else
|
|
268
|
+
await atomicWrite(change.path, change.before, change.mode);
|
|
269
|
+
}
|
|
270
|
+
catch (rollback) {
|
|
271
|
+
this.#rollbackFailed = true;
|
|
272
|
+
errors.push(rollback);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
for (const change of changes) {
|
|
276
|
+
try {
|
|
277
|
+
await fs.unlink(change.temp);
|
|
278
|
+
}
|
|
279
|
+
catch (cleanup) {
|
|
280
|
+
if (!missing(cleanup))
|
|
281
|
+
errors.push(cleanup);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
if (errors.length > 1)
|
|
285
|
+
throw new AggregateError(errors, "configuration import rollback failed");
|
|
286
|
+
throw err;
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
/** Pi loads several files directly; hold the same queue while it opens a session. */
|
|
291
|
+
withSnapshot(read) {
|
|
292
|
+
return this.#withWrite(read);
|
|
293
|
+
}
|
|
202
294
|
#withWrite(write) {
|
|
203
|
-
const result = this.#writes.then(write);
|
|
295
|
+
const result = this.#writes.then(() => { this.#assertSnapshot(); return write(); });
|
|
204
296
|
this.#writes = result.then(() => undefined, () => undefined);
|
|
205
297
|
return result;
|
|
206
298
|
}
|
package/dist/agent/events.js
CHANGED
|
@@ -184,16 +184,37 @@ export function toSessionEvents(e) {
|
|
|
184
184
|
case "agent_start":
|
|
185
185
|
return [{ type: "state", state: "streaming" }, { type: "turn-start" }];
|
|
186
186
|
case "agent_end": {
|
|
187
|
+
// Pi retries a retryable provider error itself and emits one agent_end
|
|
188
|
+
// per attempt. Only the last one ends the turn: translating the others
|
|
189
|
+
// posts a reply and an error per attempt for a failure Pi is still
|
|
190
|
+
// recovering from, and an `idle` the session is not in.
|
|
191
|
+
if (e.willRetry)
|
|
192
|
+
return [];
|
|
187
193
|
const final = lastAssistant(e.messages);
|
|
194
|
+
// A turn can end without the model ever answering. Carried twice on
|
|
195
|
+
// purpose: on turn-end because it is *how this turn ended*, which is what
|
|
196
|
+
// a task run settles on (tasks/agent.ts), and as the error event that is
|
|
197
|
+
// already every chat surface's failure path (core/router.ts).
|
|
198
|
+
const failure = final?.stopReason === "error"
|
|
199
|
+
? final.errorMessage || "unknown agent error"
|
|
200
|
+
: undefined;
|
|
188
201
|
const out = [
|
|
189
|
-
{ type: "turn-end", text: textOf(final?.content) },
|
|
202
|
+
{ type: "turn-end", text: textOf(final?.content), ...(failure ? { error: failure } : {}) },
|
|
190
203
|
];
|
|
191
|
-
if (
|
|
192
|
-
out.push({ type: "error", message:
|
|
193
|
-
}
|
|
194
|
-
out.push({ type: "state", state: "idle" });
|
|
204
|
+
if (failure)
|
|
205
|
+
out.push({ type: "error", message: failure });
|
|
195
206
|
return out;
|
|
196
207
|
}
|
|
208
|
+
// Pi's own "the run-active flag is now false": `_emitAgentSettled` clears
|
|
209
|
+
// `isStreaming` one statement before emitting this, and reaches it from the
|
|
210
|
+
// finally of the prompt — many microtasks after the last `agent_end`. Idle
|
|
211
|
+
// rides on it rather than on the turn ending, so the seam's `state` getter
|
|
212
|
+
// and this stream stop being two derivations of one fact (§5): a waiter the
|
|
213
|
+
// idle wakes now re-reads `state` as idle instead of re-arming for an event
|
|
214
|
+
// that is already spent. It is also the truthful moment — Pi's
|
|
215
|
+
// auto-compaction and queued continuations run past `agent_end`.
|
|
216
|
+
case "agent_settled":
|
|
217
|
+
return [{ type: "state", state: "idle" }];
|
|
197
218
|
case "message_start": {
|
|
198
219
|
// Pi emits this for every message entering the context; the user ones are
|
|
199
220
|
// what a client can't know about (queued/steered messages, IM traffic).
|
package/dist/agent/listing.js
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
// incremental.
|
|
17
17
|
import { createReadStream, promises as fs } from "node:fs";
|
|
18
18
|
import { join } from "node:path";
|
|
19
|
-
import { pierDb } from "../db.js";
|
|
19
|
+
import { pierDb, statements, transact } from "../db.js";
|
|
20
20
|
import { SESSION_TITLE_MAX } from "../limits.js";
|
|
21
21
|
import { logger } from "../log.js";
|
|
22
22
|
import { defaultAgentDir } from "./config.js";
|
|
@@ -75,6 +75,7 @@ function fold(acc, line) {
|
|
|
75
75
|
export class IndexedListing {
|
|
76
76
|
dir;
|
|
77
77
|
#db;
|
|
78
|
+
#statements;
|
|
78
79
|
constructor(dir = join(defaultAgentDir(), "sessions"), db) {
|
|
79
80
|
this.dir = dir;
|
|
80
81
|
this.#db = db;
|
|
@@ -85,9 +86,12 @@ export class IndexedListing {
|
|
|
85
86
|
#store() {
|
|
86
87
|
return (this.#db ??= pierDb());
|
|
87
88
|
}
|
|
89
|
+
/** The scan's three statements, compiled on the first scan and not again. */
|
|
90
|
+
#sql() {
|
|
91
|
+
return (this.#statements ??= statements(this.#store()));
|
|
92
|
+
}
|
|
88
93
|
async scan() {
|
|
89
|
-
const
|
|
90
|
-
const known = new Map(db.prepare("SELECT * FROM session_index").all().map((row) => [row.path, row]));
|
|
94
|
+
const known = new Map(this.#sql()("SELECT * FROM session_index").all().map((row) => [row.path, row]));
|
|
91
95
|
const records = [];
|
|
92
96
|
const write = [];
|
|
93
97
|
for (const file of await this.#files()) {
|
|
@@ -228,26 +232,21 @@ export class IndexedListing {
|
|
|
228
232
|
if (!rows.length && !gone.length)
|
|
229
233
|
return;
|
|
230
234
|
const db = this.#store();
|
|
231
|
-
const
|
|
235
|
+
const sql = this.#sql();
|
|
236
|
+
const upsert = sql(`INSERT INTO session_index(path, id, cwd, created_at, name, first_message, size, mtime, parsed_bytes)
|
|
232
237
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
233
238
|
ON CONFLICT(path) DO UPDATE SET
|
|
234
239
|
id = excluded.id, cwd = excluded.cwd, created_at = excluded.created_at,
|
|
235
240
|
name = excluded.name, first_message = excluded.first_message,
|
|
236
241
|
size = excluded.size, mtime = excluded.mtime,
|
|
237
242
|
parsed_bytes = excluded.parsed_bytes`);
|
|
238
|
-
const drop =
|
|
239
|
-
db
|
|
240
|
-
try {
|
|
243
|
+
const drop = sql("DELETE FROM session_index WHERE path = ?");
|
|
244
|
+
transact(db, () => {
|
|
241
245
|
for (const r of rows) {
|
|
242
246
|
upsert.run(r.path, r.id, r.cwd, r.created_at, r.name, r.first_message, r.size, r.mtime, r.parsed_bytes);
|
|
243
247
|
}
|
|
244
248
|
for (const path of gone)
|
|
245
249
|
drop.run(path);
|
|
246
|
-
|
|
247
|
-
}
|
|
248
|
-
catch (err) {
|
|
249
|
-
db.exec("ROLLBACK");
|
|
250
|
-
throw err;
|
|
251
|
-
}
|
|
250
|
+
});
|
|
252
251
|
}
|
|
253
252
|
}
|
package/dist/agent/pi.js
CHANGED
|
@@ -90,6 +90,17 @@ export const standDownShadowed = (base) => {
|
|
|
90
90
|
}),
|
|
91
91
|
};
|
|
92
92
|
};
|
|
93
|
+
/**
|
|
94
|
+
* A bundled skill stands down with the tool it documents. A skill's
|
|
95
|
+
* description is resident in every prompt, so one pointing at a tool this
|
|
96
|
+
* session was not given is both a cost and a route the agent cannot take.
|
|
97
|
+
*/
|
|
98
|
+
export const standDownUndocumented = (tools, skills) => {
|
|
99
|
+
const gone = new Set(tools.filter((tool) => tool.skill && !(tool.available?.() ?? true)).map((tool) => tool.skill));
|
|
100
|
+
if (!gone.size)
|
|
101
|
+
return skills;
|
|
102
|
+
return skills.filter((skill) => !gone.has(skill.name));
|
|
103
|
+
};
|
|
93
104
|
export class PiSession {
|
|
94
105
|
pi;
|
|
95
106
|
pinned;
|
|
@@ -101,8 +112,8 @@ export class PiSession {
|
|
|
101
112
|
/** "What I just wrote is not in your listing yet." The factory retains a
|
|
102
113
|
* scan for a few seconds, which is exactly the window a rename lands in:
|
|
103
114
|
* every surface would re-read the old title and keep it until some
|
|
104
|
-
* unrelated event moved the list again. Same drop `create`
|
|
105
|
-
*
|
|
115
|
+
* unrelated event moved the list again. Same drop `create` does — a
|
|
116
|
+
* callback only because the session is what knows it happened. */
|
|
106
117
|
wrote = () => { }, retention = { value: "long" }) {
|
|
107
118
|
this.pi = pi;
|
|
108
119
|
this.pinned = pinned;
|
|
@@ -172,7 +183,24 @@ export class PiSession {
|
|
|
172
183
|
followUp: [...this.pi.getFollowUpMessages()],
|
|
173
184
|
};
|
|
174
185
|
}
|
|
186
|
+
/** A system input handed to a streaming session goes into Pi's *agent*
|
|
187
|
+
* queue, and the arrays above are the *session's* — they only ever hold
|
|
188
|
+
* text a `prompt`/`steer`/`followUp` call queued. So a task message sitting
|
|
189
|
+
* in front of a running turn is in neither the queue nor the transcript,
|
|
190
|
+
* and this list is the only thing that says it exists. */
|
|
191
|
+
queuedInputs = [];
|
|
192
|
+
async pendingSystemInputs() {
|
|
193
|
+
// Pi drains its follow-up queue before the turn ends, so an idle session
|
|
194
|
+
// has nothing in flight. Anything still listed was aborted or cleared, and
|
|
195
|
+
// calling that queued would leave its sender waiting on it forever (§5b).
|
|
196
|
+
if (!this.pi.isStreaming)
|
|
197
|
+
this.queuedInputs.length = 0;
|
|
198
|
+
return [...this.queuedInputs];
|
|
199
|
+
}
|
|
175
200
|
async clearQueue() {
|
|
201
|
+
// Not among the texts returned — a system input is nobody's draft — but
|
|
202
|
+
// dropped all the same, so its sender stops being told it is on its way.
|
|
203
|
+
this.queuedInputs.length = 0;
|
|
176
204
|
return this.pi.clearQueue();
|
|
177
205
|
}
|
|
178
206
|
async history() {
|
|
@@ -279,14 +307,40 @@ export class PiSession {
|
|
|
279
307
|
// the summary too.
|
|
280
308
|
await this.whenCompacted();
|
|
281
309
|
this.live();
|
|
282
|
-
|
|
310
|
+
// Read here, not before the wait, and whatever the mode says: a turn is
|
|
311
|
+
// running, so the call below queues this input rather than starting one.
|
|
312
|
+
const queued = this.pi.isStreaming;
|
|
313
|
+
if (queued)
|
|
314
|
+
this.queuedInputs.push(origin);
|
|
315
|
+
try {
|
|
316
|
+
return await this.pi.sendCustomMessage({ customType: "pier.system-input", content: text, display: true, details: origin }, { triggerTurn: true, deliverAs: mode === "prompt" ? undefined : mode });
|
|
317
|
+
}
|
|
318
|
+
catch (error) {
|
|
319
|
+
// Refused: nothing is in flight, and the caller is about to retry. The
|
|
320
|
+
// turn may have ended while we waited, so the entry may be gone already.
|
|
321
|
+
const at = this.queuedInputs.indexOf(origin);
|
|
322
|
+
if (at >= 0)
|
|
323
|
+
this.queuedInputs.splice(at, 1);
|
|
324
|
+
throw error;
|
|
325
|
+
}
|
|
283
326
|
}
|
|
284
327
|
abort() {
|
|
285
328
|
return this.pi.abort();
|
|
286
329
|
}
|
|
287
330
|
subscribe(fn) {
|
|
331
|
+
let retryPending = false;
|
|
288
332
|
return this.pi.subscribe((event) => {
|
|
289
|
-
|
|
333
|
+
const piEvent = event;
|
|
334
|
+
if (piEvent.type === "agent_end")
|
|
335
|
+
retryPending = piEvent.willRetry === true;
|
|
336
|
+
if (piEvent.type === "agent_settled" && retryPending) {
|
|
337
|
+
// Aborting Pi during retry backoff produces no final agent_end, so the
|
|
338
|
+
// turn has to be ended here. The idle is not: `agent_settled` carries
|
|
339
|
+
// it through the table below like any other settle.
|
|
340
|
+
retryPending = false;
|
|
341
|
+
fn({ type: "turn-end", text: "", meta: this.lastTurnMeta() });
|
|
342
|
+
}
|
|
343
|
+
for (const payload of toSessionEvents(piEvent)) {
|
|
290
344
|
fn(payload.type === "turn-end" ? { ...payload, meta: this.lastTurnMeta() } : payload);
|
|
291
345
|
}
|
|
292
346
|
});
|
|
@@ -355,7 +409,7 @@ export class PiAgentFactory {
|
|
|
355
409
|
located = new Map();
|
|
356
410
|
/** That same scan, retained for LIST_TTL_MS instead of paid once per asking
|
|
357
411
|
* surface — one workspace event has three (sidebar, Activity, task lookups).
|
|
358
|
-
* Dropped on create
|
|
412
|
+
* Dropped on create; ids appear for reasons this factory never sees, so
|
|
359
413
|
* a miss that decides something re-lists rather than trusts it (`resume`). */
|
|
360
414
|
listing;
|
|
361
415
|
refreshQueue = Promise.resolve();
|
|
@@ -553,6 +607,10 @@ export class PiAgentFactory {
|
|
|
553
607
|
// Pi's generic default, preserving the user's later instruction layer.
|
|
554
608
|
systemPromptOverride: pierSystemPrompt,
|
|
555
609
|
additionalSkillPaths: this.skillPaths,
|
|
610
|
+
skillsOverride: (base) => ({
|
|
611
|
+
...base,
|
|
612
|
+
skills: standDownUndocumented(this.extraTools, base.skills),
|
|
613
|
+
}),
|
|
556
614
|
extensionFactories: [
|
|
557
615
|
{ name: "pier-bash-timeout", factory: bashTimeoutDefault, hidden: true },
|
|
558
616
|
...inlineExtensions(this.enabledExtensions()),
|
|
@@ -570,7 +628,10 @@ export class PiAgentFactory {
|
|
|
570
628
|
await loader.reload();
|
|
571
629
|
return loader;
|
|
572
630
|
}
|
|
573
|
-
|
|
631
|
+
open(cwd, sessionManager, opts = { cwd }) {
|
|
632
|
+
return this.providerConfig.withSnapshot(() => this.openSnapshot(cwd, sessionManager, opts));
|
|
633
|
+
}
|
|
634
|
+
async openSnapshot(cwd, sessionManager, opts) {
|
|
574
635
|
let live;
|
|
575
636
|
// Asked per open, not captured at wiring: a tool whose channel is not
|
|
576
637
|
// configured yet would otherwise cost context on every turn of every
|
|
@@ -630,6 +691,13 @@ export class PiAgentFactory {
|
|
|
630
691
|
resourceLoader: await this.resourceLoader(cwd),
|
|
631
692
|
});
|
|
632
693
|
live = created.session;
|
|
694
|
+
// Pi's queue defaults to handing over one follow-up per turn boundary, so N
|
|
695
|
+
// queued messages cost N model turns and anything behind them waits them
|
|
696
|
+
// out — 11 progress reports drained over 2.5 minutes and put a guidance
|
|
697
|
+
// message 11 minutes late (2026-09-07). The agent's own setter only flips
|
|
698
|
+
// the in-memory queue; `session.setFollowUpMode` would persist it to Pi's
|
|
699
|
+
// settings.json on every open and make it global forever.
|
|
700
|
+
live.agent.followUpMode = "all";
|
|
633
701
|
const session = new PiSession(live, this.pinned, () => {
|
|
634
702
|
this.listing = undefined;
|
|
635
703
|
}, retention);
|
|
@@ -644,26 +712,6 @@ export class PiAgentFactory {
|
|
|
644
712
|
this.listing = undefined;
|
|
645
713
|
return this.open(opts.cwd, SessionManager.create(opts.cwd), opts);
|
|
646
714
|
}
|
|
647
|
-
async fork(sourceSessionId, opts) {
|
|
648
|
-
const infos = await this.listed();
|
|
649
|
-
const source = infos.find((session) => session.id === sourceSessionId);
|
|
650
|
-
if (!source)
|
|
651
|
-
throw new Error(`unknown session: ${sourceSessionId}`);
|
|
652
|
-
const targetDir = SessionManager.create(opts.cwd).getSessionDir();
|
|
653
|
-
const manager = SessionManager.open(source.path, targetDir, opts.cwd);
|
|
654
|
-
const branch = manager.getBranch();
|
|
655
|
-
const latest = branch.at(-1);
|
|
656
|
-
const hasPendingToolCall = latest?.type === "message" &&
|
|
657
|
-
latest.message.role === "assistant" &&
|
|
658
|
-
Array.isArray(latest.message.content) &&
|
|
659
|
-
latest.message.content.some((part) => part.type === "toolCall");
|
|
660
|
-
const leafId = hasPendingToolCall ? latest.parentId : latest?.id;
|
|
661
|
-
if (!leafId)
|
|
662
|
-
throw new Error("cannot fork a session before its first persisted input");
|
|
663
|
-
manager.createBranchedSession(leafId);
|
|
664
|
-
this.listing = undefined;
|
|
665
|
-
return this.open(opts.cwd, manager, opts);
|
|
666
|
-
}
|
|
667
715
|
async resume(sessionId) {
|
|
668
716
|
const known = this.located.get(sessionId);
|
|
669
717
|
if (known) {
|
|
@@ -688,7 +736,7 @@ export class PiAgentFactory {
|
|
|
688
736
|
* session this factory opened. The miss is what earns a fresh scan, because
|
|
689
737
|
* callers read it as permission to start a replacement session
|
|
690
738
|
* (channels/conversations.ts), which costs a conversation its history, or as
|
|
691
|
-
* a session that no longer exists (tasks/, web/
|
|
739
|
+
* a session that no longer exists (tasks/, web/server.ts). `reused` is how we
|
|
692
740
|
* know a scan is owed: same entry back, same disk state. */
|
|
693
741
|
async locate(sessionId) {
|
|
694
742
|
const find = (infos) => infos.find((s) => s.id === sessionId);
|
package/dist/boards/boards.js
CHANGED
|
@@ -122,15 +122,16 @@ export async function listBoards(dir) {
|
|
|
122
122
|
catch {
|
|
123
123
|
return []; // no boards yet
|
|
124
124
|
}
|
|
125
|
-
|
|
126
|
-
for
|
|
125
|
+
// One board's manifest says nothing about the next one's, so the scan waits
|
|
126
|
+
// once for all of them rather than once per board.
|
|
127
|
+
const boards = await Promise.all(entries.sort().map(async (slug) => {
|
|
127
128
|
const manifest = await readManifest(dir, slug);
|
|
128
129
|
if (!manifest)
|
|
129
|
-
|
|
130
|
+
return null;
|
|
130
131
|
const { title, description, sessions, public: isPublic, token } = manifest;
|
|
131
|
-
|
|
132
|
-
}
|
|
133
|
-
return boards;
|
|
132
|
+
return { slug, title, description, sessions, public: isPublic, token, updatedAt: await updatedAt(dir, slug) };
|
|
133
|
+
}));
|
|
134
|
+
return boards.filter((board) => board !== null);
|
|
134
135
|
}
|
|
135
136
|
/** Containment, not normalization: the resolved realpath must sit inside the
|
|
136
137
|
* board's own site dir or nothing is served. */
|
package/dist/channels/attach.js
CHANGED
|
@@ -65,7 +65,14 @@ export async function sendAttachments(paths, upload, log) {
|
|
|
65
65
|
log(`attachment ${path} not sent: ${reason}`);
|
|
66
66
|
lost.push(lostMarker(nameOf(path), reason));
|
|
67
67
|
};
|
|
68
|
-
|
|
68
|
+
const taken = paths.slice(0, MAX_ATTACHMENTS);
|
|
69
|
+
// Five files that know nothing about each other cost one round trip, not
|
|
70
|
+
// five. Two phases so the platform still receives them in the order the
|
|
71
|
+
// turn linked them, and each failure is kept in its own slot — `lost` reads
|
|
72
|
+
// in link order whichever upload finished first.
|
|
73
|
+
const reasons = [];
|
|
74
|
+
const reasonOf = (err) => (err instanceof Error ? err.message : String(err));
|
|
75
|
+
const files = await Promise.all(taken.map(async (path, i) => {
|
|
69
76
|
try {
|
|
70
77
|
const info = await stat(path);
|
|
71
78
|
if (!info.isFile())
|
|
@@ -74,12 +81,19 @@ export async function sendAttachments(paths, upload, log) {
|
|
|
74
81
|
throw new Error(`too large (>${MAX_ATTACH_BYTES} bytes)`);
|
|
75
82
|
const name = nameOf(path);
|
|
76
83
|
const ext = extname(name).slice(1).toLowerCase();
|
|
77
|
-
|
|
84
|
+
return { name, bytes: await readFile(path), image: IMAGE_EXT.has(ext) };
|
|
78
85
|
}
|
|
79
86
|
catch (err) {
|
|
80
|
-
|
|
87
|
+
reasons[i] = reasonOf(err);
|
|
88
|
+
return undefined;
|
|
81
89
|
}
|
|
82
|
-
}
|
|
90
|
+
}));
|
|
91
|
+
await Promise.all(files.map((file, i) => file ? upload(file).catch((err) => void (reasons[i] = reasonOf(err))) : undefined));
|
|
92
|
+
taken.forEach((path, i) => {
|
|
93
|
+
const reason = reasons[i];
|
|
94
|
+
if (reason !== undefined)
|
|
95
|
+
fail(path, reason);
|
|
96
|
+
});
|
|
83
97
|
for (const path of paths.slice(MAX_ATTACHMENTS)) {
|
|
84
98
|
fail(path, `more than ${MAX_ATTACHMENTS} files in one turn`);
|
|
85
99
|
}
|
package/dist/channels/config.js
CHANGED
|
@@ -73,13 +73,18 @@ export class ChannelStore {
|
|
|
73
73
|
* discovery is passive. A new chat copies the platform defaults and owns
|
|
74
74
|
* them from then on — the mention and bind gates are what keep it harmless
|
|
75
75
|
* until an operator configures it.
|
|
76
|
+
*
|
|
77
|
+
* Telegram gets a chat's name free with every update, so it discovers on
|
|
78
|
+
* every message: the "already known, unchanged" answer is read off the
|
|
79
|
+
* cached document and costs no clone. Only a real change pays for one.
|
|
76
80
|
*/
|
|
77
81
|
discoverChat(platform, chat) {
|
|
82
|
+
const cached = this.cached(platform).chats.find((c) => c.id === chat.id);
|
|
83
|
+
if (cached && cached.name === chat.name && cached.kind === chat.kind)
|
|
84
|
+
return;
|
|
78
85
|
const config = this.get(platform);
|
|
79
86
|
const known = config.chats.find((c) => c.id === chat.id);
|
|
80
87
|
if (known) {
|
|
81
|
-
if (known.name === chat.name && known.kind === chat.kind)
|
|
82
|
-
return;
|
|
83
88
|
known.name = chat.name || known.name;
|
|
84
89
|
known.kind = chat.kind;
|
|
85
90
|
}
|