@timqi/pier 0.0.19 → 0.0.21

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 (85) 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 +10 -1
  4. package/dist/agent/pi.js +50 -28
  5. package/dist/config-sync-fetch.js +84 -0
  6. package/dist/config-sync-task.js +59 -0
  7. package/dist/config-sync.js +179 -0
  8. package/dist/core/hub.js +10 -3
  9. package/dist/core/reply.js +12 -2
  10. package/dist/core/router.js +162 -37
  11. package/dist/db.js +6 -0
  12. package/dist/main.js +31 -1
  13. package/dist/tasks/agent.js +11 -22
  14. package/dist/tasks/callbacks.js +1 -1
  15. package/dist/tasks/definitions.js +25 -16
  16. package/dist/tasks/execution.js +11 -3
  17. package/dist/tasks/messages.js +57 -14
  18. package/dist/tasks/routes.js +58 -10
  19. package/dist/tasks/runs.js +0 -5
  20. package/dist/tasks/service.js +9 -4
  21. package/dist/tasks/store.js +45 -0
  22. package/dist/tasks/tool.js +8 -4
  23. package/dist/web/config-sync.js +91 -0
  24. package/dist/web/public/assets/activity-Cw0LRVNI.js +5 -0
  25. package/dist/web/public/assets/activity-Cw0LRVNI.js.br +0 -0
  26. package/dist/web/public/assets/activity-Cw0LRVNI.js.gz +0 -0
  27. package/dist/web/public/assets/{boards-DEMQ51m1.js → boards-DMVbV84c.js} +1 -1
  28. package/dist/web/public/assets/boards-DMVbV84c.js.br +0 -0
  29. package/dist/web/public/assets/boards-DMVbV84c.js.gz +0 -0
  30. package/dist/web/public/assets/{code-DjWvqL94.js → code-C86xKk2k.js} +1 -1
  31. package/dist/web/public/assets/{explorer-BTGQ6Syc.js → explorer-DTvsy5Rs.js} +1 -1
  32. package/dist/web/public/assets/explorer-DTvsy5Rs.js.br +0 -0
  33. package/dist/web/public/assets/explorer-DTvsy5Rs.js.gz +0 -0
  34. package/dist/web/public/assets/{ghostty-web-D8CLFi_l.js → ghostty-web-CvSZragH.js} +1 -1
  35. package/dist/web/public/assets/ghostty-web-CvSZragH.js.br +0 -0
  36. package/dist/web/public/assets/ghostty-web-CvSZragH.js.gz +0 -0
  37. package/dist/web/public/assets/index-DHxoYoeq.js +79 -0
  38. package/dist/web/public/assets/index-DHxoYoeq.js.br +0 -0
  39. package/dist/web/public/assets/index-DHxoYoeq.js.gz +0 -0
  40. package/dist/web/public/assets/{index-yNocr797.css → index-DuZ2vERd.css} +1 -1
  41. package/dist/web/public/assets/index-DuZ2vERd.css.br +0 -0
  42. package/dist/web/public/assets/index-DuZ2vERd.css.gz +0 -0
  43. package/dist/web/public/assets/runs-jY40JBP-.js +1 -0
  44. package/dist/web/public/assets/runs-jY40JBP-.js.br +0 -0
  45. package/dist/web/public/assets/runs-jY40JBP-.js.gz +0 -0
  46. package/dist/web/public/assets/settings-Dux7PIKF.js +6 -0
  47. package/dist/web/public/assets/settings-Dux7PIKF.js.br +0 -0
  48. package/dist/web/public/assets/settings-Dux7PIKF.js.gz +0 -0
  49. package/dist/web/public/assets/task-runs-DKvtBVxS.js +3 -0
  50. package/dist/web/public/assets/task-runs-DKvtBVxS.js.br +0 -0
  51. package/dist/web/public/assets/task-runs-DKvtBVxS.js.gz +0 -0
  52. package/dist/web/public/assets/tasks-CqfrOVIt.js +4 -0
  53. package/dist/web/public/assets/tasks-CqfrOVIt.js.br +0 -0
  54. package/dist/web/public/assets/tasks-CqfrOVIt.js.gz +0 -0
  55. package/dist/web/public/assets/{terminal-zLyOvGto.js → terminal-DL_0yEy7.js} +2 -2
  56. package/dist/web/public/assets/terminal-DL_0yEy7.js.br +0 -0
  57. package/dist/web/public/assets/terminal-DL_0yEy7.js.gz +0 -0
  58. package/dist/web/public/index.html +13 -2
  59. package/dist/web/public/index.html.br +0 -0
  60. package/dist/web/public/index.html.gz +0 -0
  61. package/dist/web/server.js +65 -60
  62. package/package.json +2 -2
  63. package/skills/pier-tasks/SKILL.md +14 -9
  64. package/dist/web/public/assets/activity-BsoWK6vt.js +0 -5
  65. package/dist/web/public/assets/activity-BsoWK6vt.js.br +0 -0
  66. package/dist/web/public/assets/activity-BsoWK6vt.js.gz +0 -0
  67. package/dist/web/public/assets/boards-DEMQ51m1.js.br +0 -0
  68. package/dist/web/public/assets/boards-DEMQ51m1.js.gz +0 -0
  69. package/dist/web/public/assets/explorer-BTGQ6Syc.js.br +0 -0
  70. package/dist/web/public/assets/explorer-BTGQ6Syc.js.gz +0 -0
  71. package/dist/web/public/assets/ghostty-web-D8CLFi_l.js.br +0 -0
  72. package/dist/web/public/assets/ghostty-web-D8CLFi_l.js.gz +0 -0
  73. package/dist/web/public/assets/index-BFzmvpGX.js +0 -78
  74. package/dist/web/public/assets/index-BFzmvpGX.js.br +0 -0
  75. package/dist/web/public/assets/index-BFzmvpGX.js.gz +0 -0
  76. package/dist/web/public/assets/index-yNocr797.css.br +0 -0
  77. package/dist/web/public/assets/index-yNocr797.css.gz +0 -0
  78. package/dist/web/public/assets/settings-DSTKXN2o.js +0 -6
  79. package/dist/web/public/assets/settings-DSTKXN2o.js.br +0 -0
  80. package/dist/web/public/assets/settings-DSTKXN2o.js.gz +0 -0
  81. package/dist/web/public/assets/tasks-Dy1SCAE_.js +0 -4
  82. package/dist/web/public/assets/tasks-Dy1SCAE_.js.br +0 -0
  83. package/dist/web/public/assets/tasks-Dy1SCAE_.js.gz +0 -0
  84. package/dist/web/public/assets/terminal-zLyOvGto.js.br +0 -0
  85. package/dist/web/public/assets/terminal-zLyOvGto.js.gz +0 -0
@@ -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
  }
@@ -203,9 +203,18 @@ export function toSessionEvents(e) {
203
203
  ];
204
204
  if (failure)
205
205
  out.push({ type: "error", message: failure });
206
- out.push({ type: "state", state: "idle" });
207
206
  return out;
208
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" }];
209
218
  case "message_start": {
210
219
  // Pi emits this for every message entering the context; the user ones are
211
220
  // what a client can't know about (queued/steered messages, IM traffic).
package/dist/agent/pi.js CHANGED
@@ -112,8 +112,8 @@ export class PiSession {
112
112
  /** "What I just wrote is not in your listing yet." The factory retains a
113
113
  * scan for a few seconds, which is exactly the window a rename lands in:
114
114
  * every surface would re-read the old title and keep it until some
115
- * unrelated event moved the list again. Same drop `create` and `fork`
116
- * 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. */
117
117
  wrote = () => { }, retention = { value: "long" }) {
118
118
  this.pi = pi;
119
119
  this.pinned = pinned;
@@ -183,7 +183,24 @@ export class PiSession {
183
183
  followUp: [...this.pi.getFollowUpMessages()],
184
184
  };
185
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
+ }
186
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;
187
204
  return this.pi.clearQueue();
188
205
  }
189
206
  async history() {
@@ -290,7 +307,22 @@ export class PiSession {
290
307
  // the summary too.
291
308
  await this.whenCompacted();
292
309
  this.live();
293
- 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
+ }
294
326
  }
295
327
  abort() {
296
328
  return this.pi.abort();
@@ -302,11 +334,11 @@ export class PiSession {
302
334
  if (piEvent.type === "agent_end")
303
335
  retryPending = piEvent.willRetry === true;
304
336
  if (piEvent.type === "agent_settled" && retryPending) {
305
- // Aborting Pi during retry backoff produces no final agent_end.
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.
306
340
  retryPending = false;
307
341
  fn({ type: "turn-end", text: "", meta: this.lastTurnMeta() });
308
- fn({ type: "state", state: "idle" });
309
- return;
310
342
  }
311
343
  for (const payload of toSessionEvents(piEvent)) {
312
344
  fn(payload.type === "turn-end" ? { ...payload, meta: this.lastTurnMeta() } : payload);
@@ -377,7 +409,7 @@ export class PiAgentFactory {
377
409
  located = new Map();
378
410
  /** That same scan, retained for LIST_TTL_MS instead of paid once per asking
379
411
  * surface — one workspace event has three (sidebar, Activity, task lookups).
380
- * 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
381
413
  * a miss that decides something re-lists rather than trusts it (`resume`). */
382
414
  listing;
383
415
  refreshQueue = Promise.resolve();
@@ -596,7 +628,10 @@ export class PiAgentFactory {
596
628
  await loader.reload();
597
629
  return loader;
598
630
  }
599
- 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) {
600
635
  let live;
601
636
  // Asked per open, not captured at wiring: a tool whose channel is not
602
637
  // configured yet would otherwise cost context on every turn of every
@@ -656,6 +691,13 @@ export class PiAgentFactory {
656
691
  resourceLoader: await this.resourceLoader(cwd),
657
692
  });
658
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";
659
701
  const session = new PiSession(live, this.pinned, () => {
660
702
  this.listing = undefined;
661
703
  }, retention);
@@ -670,26 +712,6 @@ export class PiAgentFactory {
670
712
  this.listing = undefined;
671
713
  return this.open(opts.cwd, SessionManager.create(opts.cwd), opts);
672
714
  }
673
- async fork(sourceSessionId, opts) {
674
- const infos = await this.listed();
675
- const source = infos.find((session) => session.id === sourceSessionId);
676
- if (!source)
677
- throw new Error(`unknown session: ${sourceSessionId}`);
678
- const targetDir = SessionManager.create(opts.cwd).getSessionDir();
679
- const manager = SessionManager.open(source.path, targetDir, opts.cwd);
680
- const branch = manager.getBranch();
681
- const latest = branch.at(-1);
682
- const hasPendingToolCall = latest?.type === "message" &&
683
- latest.message.role === "assistant" &&
684
- Array.isArray(latest.message.content) &&
685
- latest.message.content.some((part) => part.type === "toolCall");
686
- const leafId = hasPendingToolCall ? latest.parentId : latest?.id;
687
- if (!leafId)
688
- throw new Error("cannot fork a session before its first persisted input");
689
- manager.createBranchedSession(leafId);
690
- this.listing = undefined;
691
- return this.open(opts.cwd, manager, opts);
692
- }
693
715
  async resume(sessionId) {
694
716
  const known = this.located.get(sessionId);
695
717
  if (known) {
@@ -0,0 +1,84 @@
1
+ // Fetch one configuration document from the operator's source URL: HTTPS with
2
+ // no credentials, redirects followed while they stay HTTPS, JSON capped at
3
+ // 1 MiB so a hostile or broken source cannot exhaust memory.
4
+ export const CONFIG_SYNC_BYTES = 1024 * 1024;
5
+ const MAX_REDIRECTS = 5;
6
+ export function configSourceUrl(raw) {
7
+ let url;
8
+ try {
9
+ url = new URL(raw.trim());
10
+ }
11
+ catch {
12
+ throw new Error("A valid HTTPS source URL is required");
13
+ }
14
+ if (url.protocol !== "https:" || url.username || url.password || url.hash || raw.length > 4096) {
15
+ throw new Error("Source must be HTTPS, without credentials or a fragment");
16
+ }
17
+ return url;
18
+ }
19
+ export async function downloadConfig(raw, etag, signal) {
20
+ let url = configSourceUrl(raw);
21
+ for (let hop = 0;; hop++) {
22
+ signal.throwIfAborted();
23
+ let res;
24
+ try {
25
+ res = await fetch(url, {
26
+ method: "GET", redirect: "manual", signal,
27
+ headers: { accept: "application/json", ...(etag ? { "if-none-match": etag } : {}) },
28
+ });
29
+ }
30
+ catch {
31
+ throw new Error(signal.aborted ? "Configuration download timed out or was cancelled" : "Could not connect to source");
32
+ }
33
+ const location = res.status >= 300 && res.status < 400 ? res.headers.get("location") : null;
34
+ if (location) {
35
+ await res.body?.cancel().catch(() => { });
36
+ if (hop >= MAX_REDIRECTS)
37
+ throw new Error("Source redirected too many times");
38
+ try {
39
+ url = configSourceUrl(new URL(location, url).href);
40
+ }
41
+ catch {
42
+ throw new Error("Source redirected to a location that is not HTTPS");
43
+ }
44
+ continue;
45
+ }
46
+ if (res.status !== 200 && res.status !== 304) {
47
+ await res.body?.cancel().catch(() => { });
48
+ throw new Error(res.status === 404 || res.status === 410
49
+ ? "Source link was revoked or does not exist"
50
+ : `Source returned HTTP ${String(res.status)}`);
51
+ }
52
+ const status = res.status === 304 ? 304 : 200;
53
+ if (status === 200 && !/^application\/json(?:\s*;|$)/i.test(res.headers.get("content-type") ?? "")) {
54
+ await res.body?.cancel().catch(() => { });
55
+ throw new Error("Source did not return JSON");
56
+ }
57
+ return { status, etag: res.headers.get("etag"), body: await read(res, signal) };
58
+ }
59
+ }
60
+ async function read(res, signal) {
61
+ const reader = res.body?.getReader();
62
+ if (!reader)
63
+ return "";
64
+ const chunks = [];
65
+ let size = 0;
66
+ try {
67
+ for (;;) {
68
+ const { done, value } = await reader.read();
69
+ if (done)
70
+ break;
71
+ size += value.length;
72
+ if (size > CONFIG_SYNC_BYTES)
73
+ throw new Error("Configuration exceeds 1 MiB");
74
+ chunks.push(Buffer.from(value));
75
+ }
76
+ }
77
+ catch (err) {
78
+ await reader.cancel().catch(() => { });
79
+ if (err instanceof Error && err.message === "Configuration exceeds 1 MiB")
80
+ throw err;
81
+ throw new Error(signal.aborted ? "Configuration download timed out or was cancelled" : "Configuration download failed");
82
+ }
83
+ return Buffer.concat(chunks).toString("utf8");
84
+ }
@@ -0,0 +1,59 @@
1
+ // The single hourly task owned by the configuration subscription. The task
2
+ // history records manual and scheduled checks; its enabled bit follows the
3
+ // subscription, never a second independently editable switch.
4
+ const OWNER = "config-sync";
5
+ export function configSyncTask(tasks, sync) {
6
+ let taskId = null;
7
+ let reconciliation = Promise.resolve();
8
+ const reconcile = () => {
9
+ const next = reconciliation.then(async () => {
10
+ const owned = tasks.list().filter((task) => task.creator === OWNER && !task.archived);
11
+ for (const extra of owned.slice(1))
12
+ tasks.archive(extra.id, OWNER);
13
+ const status = sync.status();
14
+ if (!owned.length && !status.sourceUrl)
15
+ return;
16
+ const draft = {
17
+ name: "Configuration sync",
18
+ description: "Checks the configuration source from Settings > Agent every hour.",
19
+ enabled: status.enabled,
20
+ trigger: { type: "cron", expression: "23 * * * *", timezone: "UTC" },
21
+ action: { type: "system", name: OWNER },
22
+ callback: { type: "none" },
23
+ timeoutSeconds: 30,
24
+ };
25
+ const task = owned[0];
26
+ if (!task)
27
+ taskId = (await tasks.create(draft, OWNER)).id;
28
+ else {
29
+ taskId = task.id;
30
+ if (Object.entries(draft).some(([key, value]) => JSON.stringify(task[key]) !== JSON.stringify(value))) {
31
+ await tasks.update(task.id, draft, OWNER);
32
+ }
33
+ }
34
+ });
35
+ // A failed reconciliation must be retryable, but the caller still receives
36
+ // the original rejection and reports it on its originating surface.
37
+ reconciliation = next.then(() => undefined, () => undefined);
38
+ return next;
39
+ };
40
+ const run = async () => {
41
+ await reconcile();
42
+ if (!taskId || !sync.status().enabled)
43
+ throw new Error("Configuration subscription is paused");
44
+ const active = tasks.activeRun(taskId);
45
+ const started = active ?? tasks.run(taskId, null, "manual");
46
+ const done = await tasks.waitForRun(started.id);
47
+ if (done.state !== "succeeded")
48
+ throw new Error(done.error ?? `Configuration sync ${done.state}`);
49
+ return done.result?.type === "system" ? done.result.text : "Configuration sync finished";
50
+ };
51
+ return {
52
+ reconcile,
53
+ run,
54
+ status: () => ({
55
+ ...sync.status(), taskId,
56
+ nextRunAt: taskId ? tasks.get(taskId).nextRunAt : null,
57
+ }),
58
+ };
59
+ }