@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.
Files changed (95) hide show
  1. package/dist/agent/config-sync.js +123 -0
  2. package/dist/agent/config.js +95 -3
  3. package/dist/agent/events.js +26 -5
  4. package/dist/agent/listing.js +12 -13
  5. package/dist/agent/pi.js +75 -27
  6. package/dist/boards/boards.js +7 -6
  7. package/dist/channels/attach.js +18 -4
  8. package/dist/channels/config.js +7 -2
  9. package/dist/channels/dedup.js +9 -1
  10. package/dist/channels/lark.js +2 -6
  11. package/dist/channels/receipts.js +21 -6
  12. package/dist/channels/slack-tool.js +86 -25
  13. package/dist/channels/slack.js +2 -10
  14. package/dist/channels/telegram.js +1 -0
  15. package/dist/config-sync-fetch.js +97 -0
  16. package/dist/config-sync-task.js +59 -0
  17. package/dist/config-sync.js +179 -0
  18. package/dist/core/hub.js +23 -6
  19. package/dist/core/inbox.js +1 -1
  20. package/dist/core/reply.js +76 -2
  21. package/dist/core/router.js +162 -37
  22. package/dist/db.js +50 -0
  23. package/dist/main.js +32 -1
  24. package/dist/secrets.js +18 -6
  25. package/dist/tasks/agent.js +25 -23
  26. package/dist/tasks/callbacks.js +1 -1
  27. package/dist/tasks/definitions.js +28 -17
  28. package/dist/tasks/execution.js +11 -3
  29. package/dist/tasks/messages.js +58 -16
  30. package/dist/tasks/routes.js +58 -10
  31. package/dist/tasks/runs.js +0 -5
  32. package/dist/tasks/service.js +14 -4
  33. package/dist/tasks/store.js +87 -10
  34. package/dist/tasks/tool.js +8 -4
  35. package/dist/tools.js +32 -2
  36. package/dist/web/auth.js +17 -23
  37. package/dist/web/config-sync.js +91 -0
  38. package/dist/web/config.js +61 -0
  39. package/dist/web/explorer.js +20 -70
  40. package/dist/web/fs.js +159 -0
  41. package/dist/web/instance.js +11 -0
  42. package/dist/web/public/assets/activity-DYo7QS7k.js +5 -0
  43. package/dist/web/public/assets/activity-DYo7QS7k.js.br +0 -0
  44. package/dist/web/public/assets/activity-DYo7QS7k.js.gz +0 -0
  45. package/dist/web/public/assets/boards-CwohTbtv.js +1 -0
  46. package/dist/web/public/assets/boards-CwohTbtv.js.br +0 -0
  47. package/dist/web/public/assets/boards-CwohTbtv.js.gz +0 -0
  48. package/dist/web/public/assets/code-p-Tq1bMK.js +1 -0
  49. package/dist/web/public/assets/explorer-50no_DrD.js +5 -0
  50. package/dist/web/public/assets/explorer-50no_DrD.js.br +0 -0
  51. package/dist/web/public/assets/explorer-50no_DrD.js.gz +0 -0
  52. package/dist/web/public/assets/{ghostty-web-CV7F9_iW.js → ghostty-web-Bvhh3ZFk.js} +1 -1
  53. package/dist/web/public/assets/ghostty-web-Bvhh3ZFk.js.br +0 -0
  54. package/dist/web/public/assets/ghostty-web-Bvhh3ZFk.js.gz +0 -0
  55. package/dist/web/public/assets/hljs-tWqyD59G.js +3 -0
  56. package/dist/web/public/assets/hljs-tWqyD59G.js.br +0 -0
  57. package/dist/web/public/assets/hljs-tWqyD59G.js.gz +0 -0
  58. package/dist/web/public/assets/{index-CtR-Ob4T.css → index-DuZ2vERd.css} +1 -1
  59. package/dist/web/public/assets/index-DuZ2vERd.css.br +0 -0
  60. package/dist/web/public/assets/index-DuZ2vERd.css.gz +0 -0
  61. package/dist/web/public/assets/index-WrsNstrb.js +79 -0
  62. package/dist/web/public/assets/index-WrsNstrb.js.br +0 -0
  63. package/dist/web/public/assets/index-WrsNstrb.js.gz +0 -0
  64. package/dist/web/public/assets/runs-BAXZF-6r.js +1 -0
  65. package/dist/web/public/assets/runs-BAXZF-6r.js.br +0 -0
  66. package/dist/web/public/assets/runs-BAXZF-6r.js.gz +0 -0
  67. package/dist/web/public/assets/settings-BmNXgRne.js +6 -0
  68. package/dist/web/public/assets/settings-BmNXgRne.js.br +0 -0
  69. package/dist/web/public/assets/settings-BmNXgRne.js.gz +0 -0
  70. package/dist/web/public/assets/task-runs-CpgHxFLf.js +3 -0
  71. package/dist/web/public/assets/task-runs-CpgHxFLf.js.br +0 -0
  72. package/dist/web/public/assets/task-runs-CpgHxFLf.js.gz +0 -0
  73. package/dist/web/public/assets/tasks-DybtogPF.js +4 -0
  74. package/dist/web/public/assets/tasks-DybtogPF.js.br +0 -0
  75. package/dist/web/public/assets/tasks-DybtogPF.js.gz +0 -0
  76. package/dist/web/public/assets/terminal-m2_WYu5W.js +2 -0
  77. package/dist/web/public/assets/terminal-m2_WYu5W.js.br +0 -0
  78. package/dist/web/public/assets/terminal-m2_WYu5W.js.gz +0 -0
  79. package/dist/web/public/icon.svg.br +0 -0
  80. package/dist/web/public/icon.svg.gz +0 -0
  81. package/dist/web/public/index.html +13 -2
  82. package/dist/web/public/index.html.br +0 -0
  83. package/dist/web/public/index.html.gz +0 -0
  84. package/dist/web/public/manifest.webmanifest.br +0 -0
  85. package/dist/web/public/manifest.webmanifest.gz +0 -0
  86. package/dist/web/public/sw.js.br +0 -0
  87. package/dist/web/public/sw.js.gz +0 -0
  88. package/dist/web/route.js +17 -0
  89. package/dist/web/server.js +152 -66
  90. package/dist/web/terminal.js +2 -7
  91. package/package.json +2 -2
  92. package/skills/pier-slack/SKILL.md +4 -16
  93. package/skills/pier-tasks/SKILL.md +14 -9
  94. package/dist/web/files.js +0 -183
  95. 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
+ }
@@ -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 readOptional = async (path) => {
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
  }
@@ -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 (final?.stopReason === "error") {
192
- out.push({ type: "error", message: final.errorMessage ?? "unknown agent error" });
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).
@@ -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 db = this.#store();
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 upsert = db.prepare(`INSERT INTO session_index(path, id, cwd, created_at, name, first_message, size, mtime, parsed_bytes)
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 = db.prepare("DELETE FROM session_index WHERE path = ?");
239
- db.exec("BEGIN");
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
- db.exec("COMMIT");
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` and `fork`
105
- * do — a callback only because the session is what knows it happened. */
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
- return this.pi.sendCustomMessage({ customType: "pier.system-input", content: text, display: true, details: origin }, { triggerTurn: true, deliverAs: mode === "prompt" ? undefined : mode });
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
- for (const payload of toSessionEvents(event)) {
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/fork; ids appear for reasons this factory never sees, so
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
- async open(cwd, sessionManager, opts = { cwd }) {
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/files.ts). `reused` is how we
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);
@@ -122,15 +122,16 @@ export async function listBoards(dir) {
122
122
  catch {
123
123
  return []; // no boards yet
124
124
  }
125
- const boards = [];
126
- for (const slug of entries.sort()) {
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
- continue;
130
+ return null;
130
131
  const { title, description, sessions, public: isPublic, token } = manifest;
131
- boards.push({ slug, title, description, sessions, public: isPublic, token, updatedAt: await updatedAt(dir, slug) });
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. */
@@ -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
- for (const path of paths.slice(0, MAX_ATTACHMENTS)) {
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
- await upload({ name, bytes: await readFile(path), image: IMAGE_EXT.has(ext) });
84
+ return { name, bytes: await readFile(path), image: IMAGE_EXT.has(ext) };
78
85
  }
79
86
  catch (err) {
80
- fail(path, err instanceof Error ? err.message : String(err));
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
  }
@@ -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
  }