@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,179 @@
1
+ // One serialized configuration subscriber, shared by Settings and the hourly
2
+ // task. Only a successfully applied response advances its persistent ETag.
3
+ import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
4
+ import { configSourceUrl, CONFIG_SYNC_BYTES, downloadConfig } from "./config-sync-fetch.js";
5
+ import { transact } from "./db.js";
6
+ import { logger } from "./log.js";
7
+ import { normalizeModelMenu } from "./settings.js";
8
+ const log = logger("config-sync");
9
+ const KEY = "configSync";
10
+ // Object order is not a change; array order (model menu priority) is.
11
+ export function configJson(value) {
12
+ return JSON.stringify(value, (_key, entry) => entry && typeof entry === "object" && !Array.isArray(entry)
13
+ ? Object.fromEntries(Object.entries(entry).sort(([a], [b]) => a.localeCompare(b, "en"))) : entry);
14
+ }
15
+ const hash = (value) => createHash("sha256").update(configJson(value)).digest("hex");
16
+ export class ConfigSync {
17
+ deps;
18
+ #state;
19
+ #queue = Promise.resolve();
20
+ constructor(deps) {
21
+ this.deps = deps;
22
+ const row = deps.db.prepare("SELECT value FROM settings WHERE key = ?").get(KEY);
23
+ this.#state = row ? JSON.parse(row.value) : {
24
+ instanceId: randomUUID(), token: null, url: "", enabled: false, etag: null,
25
+ appliedHash: null, lastChecked: null, lastApplied: null, error: null, needsReload: false,
26
+ };
27
+ if (!row)
28
+ this.#save();
29
+ }
30
+ #save(patch = {}) {
31
+ const next = { ...this.#state, ...patch };
32
+ this.deps.db.prepare("INSERT INTO settings(key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value")
33
+ .run(KEY, JSON.stringify(next));
34
+ this.#state = next;
35
+ }
36
+ #exclusive(work) {
37
+ const result = this.#queue.then(work);
38
+ this.#queue = result.then(() => undefined, () => undefined);
39
+ return result;
40
+ }
41
+ status() {
42
+ const state = this.#state;
43
+ return {
44
+ publishedPath: state.token ? `/config-sync/${state.token}` : null,
45
+ sourceUrl: state.url, enabled: state.enabled,
46
+ lastChecked: state.lastChecked, lastApplied: state.lastApplied,
47
+ error: state.error, needsReload: state.needsReload,
48
+ };
49
+ }
50
+ async #local() {
51
+ return { agent: await this.deps.config.exportSnapshot(), modelMenu: this.deps.settings.get().modelMenu };
52
+ }
53
+ publish() {
54
+ return this.#exclusive(async () => {
55
+ if (this.#state.enabled)
56
+ throw new Error("Pause the configuration subscription before publishing a link");
57
+ await this.#local();
58
+ this.#save({ token: randomBytes(32).toString("hex") });
59
+ return this.status();
60
+ });
61
+ }
62
+ revoke() {
63
+ return this.#exclusive(async () => {
64
+ this.#save({ token: null });
65
+ return this.status();
66
+ });
67
+ }
68
+ async published(token) {
69
+ const expected = this.#state.token;
70
+ if (!expected || !/^[a-f0-9]{64}$/.test(token) || !timingSafeEqual(Buffer.from(token), Buffer.from(expected)))
71
+ return null;
72
+ const local = await this.#local();
73
+ if (this.#state.token !== expected)
74
+ return null;
75
+ const document = { schemaVersion: 1, instanceId: this.#state.instanceId, ...local };
76
+ const body = configJson(document);
77
+ if (Buffer.byteLength(body) > CONFIG_SYNC_BYTES)
78
+ throw new Error("Configuration exceeds 1 MiB");
79
+ return { body, etag: `"${hash(document)}"` };
80
+ }
81
+ #parse(raw) {
82
+ if (Buffer.byteLength(raw) > CONFIG_SYNC_BYTES)
83
+ throw new Error("Configuration exceeds 1 MiB");
84
+ let value;
85
+ try {
86
+ value = JSON.parse(raw);
87
+ }
88
+ catch {
89
+ throw new Error("Source returned malformed JSON");
90
+ }
91
+ if (!value || typeof value !== "object" || Array.isArray(value))
92
+ throw new Error("Invalid configuration document");
93
+ const object = value;
94
+ if (object.schemaVersion !== 1)
95
+ throw new Error("Unsupported configuration schema version");
96
+ if (Object.keys(object).some((key) => !["schemaVersion", "instanceId", "agent", "modelMenu"].includes(key))) {
97
+ throw new Error("Unexpected configuration fields");
98
+ }
99
+ if (typeof object.instanceId !== "string" || !object.instanceId || object.instanceId.length > 100)
100
+ throw new Error("Invalid source instance ID");
101
+ if (object.instanceId === this.#state.instanceId)
102
+ throw new Error("An instance cannot subscribe to itself");
103
+ const modelMenu = normalizeModelMenu(object.modelMenu);
104
+ if (!modelMenu)
105
+ throw new Error("Invalid model menu");
106
+ return { agent: this.deps.normalizeAgent(object.agent), modelMenu };
107
+ }
108
+ subscribe(url) {
109
+ return this.#exclusive(async () => {
110
+ if (this.#state.token)
111
+ throw new Error("Revoke the sharing link before enabling a configuration subscription");
112
+ return this.#sync(url);
113
+ });
114
+ }
115
+ pause() {
116
+ return this.#exclusive(async () => {
117
+ this.#save({ enabled: false });
118
+ return this.status();
119
+ });
120
+ }
121
+ sync(signal) {
122
+ return this.#exclusive(() => this.#state.enabled
123
+ ? this.#sync(this.#state.url, signal)
124
+ : Promise.resolve("Configuration subscription is paused"));
125
+ }
126
+ async #sync(rawUrl, signal) {
127
+ try {
128
+ const url = configSourceUrl(rawUrl).href;
129
+ const local = await this.#local();
130
+ const etag = url === this.#state.url && hash(local) === this.#state.appliedHash ? this.#state.etag : null;
131
+ const timeout = AbortSignal.timeout(15_000);
132
+ const response = await (this.deps.download ?? downloadConfig)(url, etag, signal ? AbortSignal.any([signal, timeout]) : timeout);
133
+ signal?.throwIfAborted();
134
+ if (response.status === 304 && !etag)
135
+ throw new Error("Source returned 304 without a matching local configuration");
136
+ const next = response.status === 304 ? local : this.#parse(response.body);
137
+ let changed = false;
138
+ const previous = structuredClone(this.#state);
139
+ const commit = (filesChanged) => transact(this.deps.db, () => {
140
+ signal?.throwIfAborted();
141
+ const menuChanged = configJson(next.modelMenu) !== configJson(this.deps.settings.get().modelMenu);
142
+ changed = filesChanged || menuChanged;
143
+ if (menuChanged)
144
+ this.deps.settings.setModelMenu(next.modelMenu);
145
+ this.#save({
146
+ url, enabled: true, etag: response.status === 304 ? etag : response.etag,
147
+ appliedHash: hash(next), lastChecked: Date.now(),
148
+ lastApplied: changed ? Date.now() : previous.lastApplied,
149
+ needsReload: changed || previous.needsReload, error: null,
150
+ });
151
+ });
152
+ try {
153
+ // Reconcile under the file lock even on 304: local edits may have
154
+ // completed while the conditional download was in flight.
155
+ await this.deps.config.applySnapshot(next.agent, commit);
156
+ }
157
+ catch (err) {
158
+ this.#state = previous;
159
+ throw err;
160
+ }
161
+ if (this.#state.needsReload) {
162
+ try {
163
+ await this.deps.reload();
164
+ }
165
+ catch {
166
+ throw new Error("Configuration saved, but reload failed; retry synchronization");
167
+ }
168
+ this.#save({ needsReload: false });
169
+ }
170
+ return changed ? "Configuration applied; reload completed" : `Configuration unchanged (${response.status})`;
171
+ }
172
+ catch (err) {
173
+ const error = err instanceof Error ? err.message : "Configuration sync failed";
174
+ log.error(error);
175
+ this.#save({ lastChecked: Date.now(), error });
176
+ throw new Error(error);
177
+ }
178
+ }
179
+ }
package/dist/core/hub.js CHANGED
@@ -23,7 +23,7 @@ export class EventHub {
23
23
  bus(sessionId) {
24
24
  let b = this.buses.get(sessionId);
25
25
  if (!b) {
26
- b = { seq: 0, buffer: [], subscribers: new Set() };
26
+ b = { seq: 0, replayFloor: 0, buffer: [], subscribers: new Set() };
27
27
  this.buses.set(sessionId, b);
28
28
  }
29
29
  return b;
@@ -46,7 +46,7 @@ export class EventHub {
46
46
  if (payload.type !== "text-delta") {
47
47
  b.buffer.push(event);
48
48
  if (b.buffer.length > RING_SIZE)
49
- b.buffer.shift();
49
+ b.replayFloor = b.buffer.shift().seq;
50
50
  }
51
51
  fanOut(b.subscribers, event);
52
52
  }
@@ -60,6 +60,11 @@ export class EventHub {
60
60
  replay(sessionId, afterSeq) {
61
61
  return this.bus(sessionId).buffer.filter((e) => e.seq > afterSeq);
62
62
  }
63
+ /** Whether replay covers this cursor; live-only text gaps are intentional. */
64
+ covers(sessionId, afterSeq) {
65
+ const b = this.bus(sessionId);
66
+ return Number.isSafeInteger(afterSeq) && afterSeq >= b.replayFloor && afterSeq <= b.seq;
67
+ }
63
68
  emitWorkspace(event) {
64
69
  fanOut(this.workspace, event);
65
70
  }
@@ -88,7 +93,9 @@ export class EventHub {
88
93
  if (this.hasSubscribers(sessionId))
89
94
  return;
90
95
  const b = this.buses.get(sessionId);
91
- if (b)
96
+ if (b) {
97
+ b.replayFloor = b.buffer.at(-1)?.seq ?? b.replayFloor;
92
98
  b.buffer = [];
99
+ }
93
100
  }
94
101
  }
@@ -40,14 +40,20 @@ new day — so the last one still applies; a gap alone shows as time only, like
40
40
  \`[14:23]\`. Use that \`id\` to mention someone; never ask for their own.
41
41
  `;
42
42
  /**
43
- * The contract above plus the two facts about *this* deployment that an agent
43
+ * The contract above plus the facts about *this* deployment that an agent
44
44
  * cannot work out for itself.
45
45
  *
46
- * Told, not discovered. Pier knows both — the home is its own startup argument
46
+ * Told, not discovered. Pier knows them — the home is its own startup argument
47
47
  * and the address is a file it wrote — while an agent can only guess at a
48
48
  * path and shell out to look. The guess is wrong on every instance that moves
49
49
  * `PIER_HOME`, and it fails as "nothing is configured": indistinguishable from
50
50
  * the truth, and reported to the user as such. Which is what happened.
51
+ *
52
+ * The editing line is the same shape of fact, learned the same way: GPT models
53
+ * carry `apply_patch` from their own post-training, and not finding it in the
54
+ * tool list they go looking in the shell — one failed call, then a filesystem
55
+ * hunt, and in one session a stale Codex binary that worked, which put every
56
+ * later edit outside Pi's tooling.
51
57
  */
52
58
  export function surfacePrompt(instance) {
53
59
  const reach = instance.publicUrl
@@ -60,6 +66,10 @@ export function surfacePrompt(instance) {
60
66
  ## This Pier instance
61
67
 
62
68
  Boards: \`${instance.boardsDir}/<slug>/\` — this path, not \`~/.pier\`. ${reach}
69
+
70
+ Editing: files change through the \`edit\` tool (exact text replacement) or
71
+ \`write\`. There is no \`apply_patch\` here — not as a tool, not as a command —
72
+ so do not call one or go looking for one in the shell.
63
73
  `;
64
74
  }
65
75
  /**
@@ -1,5 +1,6 @@
1
1
  // Conversation → session routing plus event wiring. In-memory on purpose:
2
2
  // the durable chat → session map lives in channels/conversations.ts.
3
+ import { randomUUID } from "node:crypto";
3
4
  import { logger } from "../log.js";
4
5
  import { EventHub } from "./hub.js";
5
6
  import { SenderPrefix, withPrefix } from "./identity.js";
@@ -22,6 +23,13 @@ function keyOf(key) {
22
23
  * `ensure`, and which of the two a session records costs nothing but the
23
24
  * answer to "what is it answering". */
24
25
  const isAlias = (key) => key.channelId === "web" || key.channelId === "task";
26
+ export class QueueOperationError extends Error {
27
+ reason;
28
+ constructor(reason, message) {
29
+ super(message);
30
+ this.reason = reason;
31
+ }
32
+ }
25
33
  export class Router {
26
34
  hub;
27
35
  resolve;
@@ -112,6 +120,8 @@ export class Router {
112
120
  for (const [id, attached] of [...this.bySession]) {
113
121
  if (attached.session.state === "streaming")
114
122
  continue;
123
+ if (this.queueOperations.has(id) || this.recoveries.get(id)?.some((b) => b.status === "submitting"))
124
+ continue;
115
125
  if (!includeWatched && this.hub.hasSubscribers(id))
116
126
  continue;
117
127
  if (now - attached.activeAt < ttlMs)
@@ -231,7 +241,7 @@ export class Router {
231
241
  // its own queues up to the agent_end handler, so a non-empty queue on an
232
242
  // idle session is exactly the message that missed that window.
233
243
  if (payload.type === "queue-state" && (payload.steering.length || payload.followUp.length)) {
234
- this.promoteQueued(session, key);
244
+ this.promoteQueued(session);
235
245
  }
236
246
  // Every turn-end reaches the channel, empty text included: an adapter's
237
247
  // per-turn UI (Telegram's 👀 receipts) is retired here, and a turn that
@@ -254,10 +264,124 @@ export class Router {
254
264
  unsubscribe,
255
265
  });
256
266
  }
257
- /** Sessions whose queue is being promoted right now. `clearQueue` and the
258
- * prompt that follows it both emit queue-state of their own, so without
259
- * this the handler would re-enter on its own effects. */
260
- promoting = new Set();
267
+ queueOperations = new Set();
268
+ promotionRequested = new Set();
269
+ recoveries = new Map();
270
+ // Keep the latest failed batch ID so a clear cannot erase a newer rejection
271
+ // that arrived while it awaited the backend. ACK affects only the copy.
272
+ uncertaintyHeld = new Map();
273
+ queueUncertain(sessionId) {
274
+ return this.uncertaintyHeld.has(sessionId);
275
+ }
276
+ recoveryOf(sessionId) {
277
+ return structuredClone(this.recoveries.get(sessionId) ?? []);
278
+ }
279
+ recoveryChanged(sessionId) {
280
+ if (!this.recoveries.get(sessionId)?.length)
281
+ this.recoveries.delete(sessionId);
282
+ this.hub.emit(sessionId, {
283
+ type: "queue-recovery", batches: this.recoveryOf(sessionId), uncertain: this.queueUncertain(sessionId),
284
+ });
285
+ }
286
+ acknowledgeRecovery(sessionId, batchId) {
287
+ const batches = this.recoveries.get(sessionId);
288
+ const batch = batches?.find((b) => b.id === batchId);
289
+ if (!batch)
290
+ throw new QueueOperationError("missing", "No such recovery batch");
291
+ if (batch.status === "submitting")
292
+ throw new QueueOperationError("busy", "Submission has not settled");
293
+ this.recoveries.set(sessionId, batches.filter((b) => b !== batch));
294
+ this.promotionRequested.delete(sessionId);
295
+ this.recoveryChanged(sessionId);
296
+ }
297
+ /** Only queue mutation owns this lock. A model turn owns its batch, not the
298
+ * next queue: manual controls remain available while that turn runs. */
299
+ async useQueue(sessionId, action, key = { channelId: "web", conversationId: sessionId }) {
300
+ if (this.queueOperations.has(sessionId))
301
+ throw new QueueOperationError("busy", "Queue operation in progress");
302
+ this.queueOperations.add(sessionId);
303
+ try {
304
+ return await action(await this.ensure(key));
305
+ }
306
+ finally {
307
+ this.queueOperations.delete(sessionId);
308
+ this.resumePromotion(sessionId);
309
+ }
310
+ }
311
+ async recallQueue(sessionId) {
312
+ return this.useQueue(sessionId, async (session) => {
313
+ const held = this.uncertaintyHeld.get(sessionId);
314
+ const queue = await session.clearQueue();
315
+ if (this.uncertaintyHeld.get(sessionId) === held && this.uncertaintyHeld.delete(sessionId))
316
+ this.recoveryChanged(sessionId);
317
+ if (queue.steering.length || queue.followUp.length)
318
+ this.forgetSender(sessionId);
319
+ return queue;
320
+ });
321
+ }
322
+ checkQueueDrain() {
323
+ if (this.draining)
324
+ throw new QueueOperationError("draining", "Pier is restarting; queued messages were not submitted");
325
+ }
326
+ async deliverQueue(sessionId, mode) {
327
+ let retained = false;
328
+ try {
329
+ this.checkQueueDrain();
330
+ return await this.useQueue(sessionId, async (session) => {
331
+ this.checkQueueDrain();
332
+ if (mode === "auto" && (session.state !== "idle" || this.queueUncertain(sessionId) || this.recoveries.get(sessionId)?.length))
333
+ return "";
334
+ const held = this.uncertaintyHeld.get(sessionId);
335
+ const queue = await session.clearQueue();
336
+ if (mode !== "auto" && this.uncertaintyHeld.get(sessionId) === held && this.uncertaintyHeld.delete(sessionId))
337
+ this.recoveryChanged(sessionId);
338
+ if (!queue.steering.length && !queue.followUp.length)
339
+ throw new QueueOperationError("empty", "Queue is empty");
340
+ const batch = { id: randomUUID(), ...queue, status: "submitting" };
341
+ this.recoveries.set(sessionId, [...(this.recoveries.get(sessionId) ?? []), batch]);
342
+ retained = true;
343
+ this.recoveryChanged(sessionId);
344
+ const text = [...batch.steering, ...batch.followUp].join("\n");
345
+ let invoked = false;
346
+ const failed = (err) => {
347
+ this.forgetSender(sessionId);
348
+ this.promotionRequested.delete(sessionId);
349
+ batch.status = invoked ? "uncertain" : "not-submitted";
350
+ if (invoked)
351
+ this.uncertaintyHeld.set(sessionId, batch.id);
352
+ batch.error = String(err);
353
+ this.recoveryChanged(sessionId);
354
+ this.reportTo(sessionId, `Queue promotion failed (${batch.status}); automatic queue paused, originals remain available in queue recovery: ${String(err)}`);
355
+ };
356
+ try {
357
+ this.checkQueueDrain();
358
+ if (mode === "restart")
359
+ await this.abort(sessionId);
360
+ this.checkQueueDrain();
361
+ // Already headed at original dispatch. Calling dispatch again would
362
+ // discard rejection and could attribute these words to the operator.
363
+ invoked = true;
364
+ const submitted = mode === "steer" && session.state === "streaming"
365
+ ? session.steer(text) : session.prompt(text);
366
+ void submitted.then(() => {
367
+ this.recoveries.set(sessionId, (this.recoveries.get(sessionId) ?? []).filter((b) => b !== batch));
368
+ this.recoveryChanged(sessionId);
369
+ }, failed).finally(() => this.resumePromotion(sessionId));
370
+ return text;
371
+ }
372
+ catch (err) {
373
+ failed(err);
374
+ throw err;
375
+ }
376
+ }, mode === "auto" ? this.conversationOf(sessionId) : undefined);
377
+ }
378
+ catch (err) {
379
+ if (!retained && !(err instanceof QueueOperationError && (err.reason === "busy" || err.reason === "empty"))) {
380
+ this.reportTo(sessionId, `Could not promote queued messages: ${String(err)}`);
381
+ }
382
+ throw err;
383
+ }
384
+ }
261
385
  /**
262
386
  * Turn a stranded queue into the turn it was waiting for. Not routed through
263
387
  * `dispatch`: the text was prefixed when it was first dispatched
@@ -268,40 +392,39 @@ export class Router {
268
392
  * start the very turn it was asked to stop. Recovering *those* messages stays
269
393
  * the web's recall route, which hands them back to the composer.
270
394
  */
271
- promoteQueued(session, key) {
272
- if (session.state !== "idle" || this.promoting.has(session.id))
395
+ promoteQueued(session) {
396
+ if (session.state !== "idle")
273
397
  return;
274
- this.promoting.add(session.id);
275
- void (async () => {
276
- try {
277
- // Re-read: a turn may have started since the event, and it will drain
278
- // the queue itself clearing it here would take the messages out of it.
279
- if (session.state !== "idle")
280
- return;
281
- const { steering, followUp } = await session.clearQueue();
282
- const text = [...steering, ...followUp].join("\n").trim();
283
- if (!text)
284
- return;
285
- // A drain is "no new turns", and this would be one. Told to the
286
- // conversation rather than dropped, because the message is now out of
287
- // the queue and nothing else would ever mention it (§5b).
288
- if (this.draining) {
289
- this.report(session.id, key, `queued message not taken — Pier is restarting; send it again: ${truncate(text)}`);
290
- return;
291
- }
292
- log.info(`promoting ${String(steering.length + followUp.length)} queued message(s) → session ${session.id}`);
293
- await session.prompt(text);
294
- }
295
- catch (err) {
296
- this.report(session.id, key, `delivering the queued messages failed: ${String(err)}`);
297
- }
298
- finally {
299
- this.promoting.delete(session.id);
300
- }
301
- })();
398
+ this.promotionRequested.add(session.id);
399
+ this.resumePromotion(session.id);
400
+ }
401
+ resumePromotion(sessionId) {
402
+ // queue_update precedes the backend's enqueue. Never clear it reentrantly.
403
+ queueMicrotask(() => {
404
+ if (!this.promotionRequested.has(sessionId) || this.queueOperations.has(sessionId))
405
+ return;
406
+ // A queue event followed by rejection might describe the same input.
407
+ // Retained failures require a human decision, not an automatic resend.
408
+ if (this.queueUncertain(sessionId) || this.recoveries.get(sessionId)?.length)
409
+ return;
410
+ this.promotionRequested.delete(sessionId);
411
+ const attached = this.bySession.get(sessionId);
412
+ if (!attached || attached.session.state !== "idle")
413
+ return;
414
+ void this.deliverQueue(sessionId, "auto").catch((err) => {
415
+ // deliverQueue reports failures; empty/busy simply lost the race.
416
+ log.debug(`automatic promotion of ${sessionId} stopped: ${String(err)}`);
417
+ });
418
+ });
302
419
  }
303
420
  async abort(sessionId) {
304
- await this.bySession.get(sessionId)?.session.abort();
421
+ this.promotionRequested.delete(sessionId);
422
+ try {
423
+ await this.bySession.get(sessionId)?.session.abort();
424
+ }
425
+ finally {
426
+ this.promotionRequested.delete(sessionId);
427
+ }
305
428
  }
306
429
  /**
307
430
  * Drop what this session was told about who is speaking, so the next message
@@ -358,7 +481,9 @@ export class Router {
358
481
  return this.byKey.get(keyOf(key));
359
482
  }
360
483
  async abortConversation(key) {
361
- await this.sessionOf(key)?.abort();
484
+ const session = this.sessionOf(key);
485
+ if (session)
486
+ await this.abort(session.id);
362
487
  }
363
488
  /** Session owning a conversation, resolving and attaching it on first use. */
364
489
  async ensure(key) {
package/dist/db.js CHANGED
@@ -310,6 +310,12 @@ const MIGRATIONS = [
310
310
  GENERATED ALWAYS AS (json_extract(json, '$.nextRunAt')) VIRTUAL;
311
311
  CREATE INDEX tasks_due ON tasks(next_run_at) WHERE next_run_at IS NOT NULL;
312
312
  `,
313
+ // 17 — stable global pages, including sparse lists with unmatched probes hidden.
314
+ `
315
+ CREATE INDEX task_runs_time_id ON task_runs(queued_at DESC, id DESC);
316
+ CREATE INDEX task_runs_visible_time ON task_runs(queued_at DESC, id DESC)
317
+ WHERE NOT (state = 'succeeded' AND json_extract(json, '$.matched') IS 0);
318
+ `,
313
319
  ];
314
320
  /**
315
321
  * Several writes as one, or none. `BEGIN IMMEDIATE` because every writer here
package/dist/main.js CHANGED
@@ -4,6 +4,9 @@ import { fileURLToPath } from "node:url";
4
4
  import { serve } from "@hono/node-server";
5
5
  import { Hono } from "hono";
6
6
  import { PiConfigStore } from "./agent/config.js";
7
+ import { normalizeAgentSnapshot } from "./agent/config-sync.js";
8
+ import { ConfigSync } from "./config-sync.js";
9
+ import { configSyncTask } from "./config-sync-task.js";
7
10
  import { CredentialStore } from "./agent/credentials.js";
8
11
  import { PiAgentFactory } from "./agent/pi.js";
9
12
  import { defaultBoardsDir, registerBoardRoutes } from "./boards/boards.js";
@@ -35,6 +38,7 @@ import { startUpdate, unitPath, updaterProblem } from "./service.js";
35
38
  import { SettingsStore } from "./settings.js";
36
39
  import { startAutoUpdate, UpdateCheck } from "./update.js";
37
40
  import { AuthStore, registerAuthRoutes, requireAuth } from "./web/auth.js";
41
+ import { registerConfigShareRoute, registerConfigSyncRoutes } from "./web/config-sync.js";
38
42
  import { PushStore, registerPushRoutes } from "./web/push.js";
39
43
  import { SessionStateStore } from "./web/session-state.js";
40
44
  import { createServer } from "./web/server.js";
@@ -86,7 +90,12 @@ let channelStore;
86
90
  // Shared by the adapter and the tool: a display name is looked up once per
87
91
  // process, not once per message and again per transcript.
88
92
  const slackDirectory = new SlackDirectory((m) => logger("slack").warn(m));
93
+ let readyForConfigReload = false;
89
94
  const piConfig = new PiConfigStore();
95
+ const configSync = new ConfigSync({
96
+ db, settings, config: piConfig, normalizeAgent: normalizeAgentSnapshot,
97
+ reload: () => readyForConfigReload ? reloadInstance() : Promise.resolve(),
98
+ });
90
99
  const factory = new PiAgentFactory([
91
100
  taskToolSpec((params, callerSessionId) => tasks.tool(params, callerSessionId)),
92
101
  slackToolSpec((params, callerSessionId) => handleSlackTool({
@@ -143,8 +152,9 @@ const router = new Router(hub, (key) => {
143
152
  const stopEviction = router.startIdleEviction();
144
153
  tasks = new TaskService(new TaskStore(db), factory, router, hub, {
145
154
  modelMenu: () => settings.get().modelMenu,
155
+ systemActions: { "config-sync": (signal) => configSync.sync(signal) },
146
156
  });
147
- tasks.start();
157
+ const configurationSync = configSyncTask(tasks, configSync);
148
158
  // The managed CLI tools (src/tools.ts), and the daily task that keeps them
149
159
  // current (src/tools-task.ts) — an ordinary bash task on an ordinary cron,
150
160
  // wired here because tools.ts may not import tasks/.
@@ -181,6 +191,19 @@ const reloadInstance = async (includeWatched = false) => {
181
191
  await channels.reload();
182
192
  return router.evictIdle(0, Date.now(), { includeWatched });
183
193
  };
194
+ // Bound the startup check before sessions and scheduled work can start.
195
+ // Remote outages keep the last local configuration available.
196
+ if (configSync.status().enabled) {
197
+ try {
198
+ await configSync.sync();
199
+ }
200
+ catch {
201
+ log.error("Startup configuration sync failed; using the last local configuration");
202
+ }
203
+ }
204
+ await configurationSync.reconcile();
205
+ tasks.start();
206
+ readyForConfigReload = true;
184
207
  void secrets.unlock().then(startChannels, (err) => log.error("secrets locked — channels not started; unlock from Console → Settings → Security, or repair master.key", err));
185
208
  // Replacing Pier is systemd's job, not this process's: the oneshot unit stops
186
209
  // the service, snapshots the database, installs and starts it again. Without
@@ -289,8 +312,15 @@ app.onError((err, c) => {
289
312
  // so a surface added later is covered without knowing this exists. Built
290
313
  // before the listener: a first run generates and prints its password here.
291
314
  const auth = new AuthStore(db);
315
+ registerConfigShareRoute(app, configSync);
292
316
  app.use("*", requireAuth(auth));
293
317
  registerAuthRoutes(app, auth);
318
+ registerConfigSyncRoutes(app, {
319
+ sync: configSync,
320
+ status: () => ({ ...configurationSync.status(), publicUrl: settings.get().publicUrl }),
321
+ reconcile: configurationSync.reconcile,
322
+ run: configurationSync.run,
323
+ });
294
324
  registerTaskRoutes(app, tasks, { factory, router });
295
325
  registerChannelRoutes(app, channelStore, channels);
296
326
  registerBoardRoutes(app);
@@ -1,4 +1,4 @@
1
- // A run that *is* a Pi session: which session it opens (reuse, fresh, fork),
1
+ // A run that *is* a Pi session: which session it opens (fresh or reused),
2
2
  // what the child is told before the prompt, and how many may run at once. The
3
3
  // concurrency caps are here rather than in execution.ts because they bound
4
4
  // agents specifically — a bash run costs a process, an agent run costs a
@@ -52,7 +52,7 @@ export class AgentTaskRunner {
52
52
  await this.waitUntilIdle(session, signal);
53
53
  // Task requests come seconds apart — the 1h Anthropic cache-write premium
54
54
  // never earns its 2× back, so task runs use the 5m TTL. Set here, not in
55
- // resolveSession: this covers create, fork, reuse and resume alike, on
55
+ // resolveSession: this covers create, reuse and resume alike, on
56
56
  // every attempt — and only after the session is idle, so a reused
57
57
  // interactive session's in-flight turn keeps its 1h writes.
58
58
  session.setCacheRetention("short");
@@ -149,25 +149,16 @@ export class AgentTaskRunner {
149
149
  if (run.targetSessionId) {
150
150
  return this.router.ensure({ channelId: "task", conversationId: run.targetSessionId });
151
151
  }
152
- const source = run.sourceSessionId
153
- ? await this.factory.find(run.sourceSessionId)
154
- : undefined;
155
152
  const policy = action.session;
156
- let cwd;
157
- if (run.sessionMode === "fork") {
158
- if (!run.sourceSessionId)
159
- throw new Error("fork requires a source session");
160
- cwd = policy.mode === "fork" && policy.cwd ? policy.cwd : source?.cwd ?? "";
161
- }
162
- else if (policy.mode === "fresh") {
163
- cwd = policy.cwd;
164
- }
165
- else if (policy.mode === "reuse") {
166
- cwd = (await this.factory.find(policy.sessionId))?.cwd ?? "";
167
- }
168
- else {
169
- cwd = policy.cwd ?? source?.cwd ?? "";
153
+ // Definitions stored before fork was removed still say `"fork"`. Refused by
154
+ // name: every directory this could pick instead is a guess at what the
155
+ // author meant, and a child in the wrong tree edits real files.
156
+ if (run.sessionMode === "fork" || policy.mode === "fork") {
157
+ throw new Error(`task "${run.context.definition.name}" uses the removed fork session mode; recreate it with {"mode":"fresh","cwd":"/abs/path"}`);
170
158
  }
159
+ const cwd = policy.mode === "fresh"
160
+ ? policy.cwd
161
+ : (await this.factory.find(policy.sessionId))?.cwd ?? "";
171
162
  if (!cwd)
172
163
  throw new Error("could not resolve child working directory");
173
164
  const opts = {
@@ -179,9 +170,7 @@ export class AgentTaskRunner {
179
170
  (run.sourceSessionId ? this.router.modelOf(run.sourceSessionId) : undefined),
180
171
  thinking: action.launch?.thinking,
181
172
  };
182
- const session = run.sessionMode === "fork"
183
- ? await this.factory.fork(run.sourceSessionId, opts)
184
- : await this.factory.create(opts);
173
+ const session = await this.factory.create(opts);
185
174
  run.targetSessionId = session.id;
186
175
  run.context.sessionId = session.id;
187
176
  run.context.cwd = cwd;
@@ -19,7 +19,7 @@ export const runSource = (run) => ({
19
19
  });
20
20
  export function runResultText(run) {
21
21
  let result = run.error ?? "No result";
22
- if (run.result?.type === "agent")
22
+ if (run.result?.type === "agent" || run.result?.type === "system")
23
23
  result = run.result.text;
24
24
  if (run.result?.type === "bash")
25
25
  result = run.result.stdout || run.result.stderr || `exit ${String(run.result.exitCode)}`;