@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,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;
@@ -36,9 +36,18 @@ export class EventHub {
36
36
  sessionId,
37
37
  ...payload,
38
38
  };
39
- b.buffer.push(event);
40
- if (b.buffer.length > RING_SIZE)
41
- b.buffer.shift();
39
+ // Text deltas fan out live but never enter the ring: one long reply emits
40
+ // thousands of them, so a ring that held them would hold *only* them and
41
+ // would have evicted the turn-start, tool and turn-end events a
42
+ // reconnecting client replays for. The text is not lost — `turn-end`
43
+ // carries the full reply (web/ui/chat.ts treats it as authoritative).
44
+ // Thinking stays replayable because a native EventSource reconnect does
45
+ // not reload the transcript snapshot that would otherwise restore it.
46
+ if (payload.type !== "text-delta") {
47
+ b.buffer.push(event);
48
+ if (b.buffer.length > RING_SIZE)
49
+ b.replayFloor = b.buffer.shift().seq;
50
+ }
42
51
  fanOut(b.subscribers, event);
43
52
  }
44
53
  subscribe(sessionId, fn) {
@@ -46,10 +55,16 @@ export class EventHub {
46
55
  b.subscribers.add(fn);
47
56
  return () => b.subscribers.delete(fn);
48
57
  }
49
- /** Events with seq > afterSeq still held in the ring buffer. */
58
+ /** Events with seq > afterSeq still held in the ring buffer — oldest first,
59
+ * and without text deltas, which are live-only. */
50
60
  replay(sessionId, afterSeq) {
51
61
  return this.bus(sessionId).buffer.filter((e) => e.seq > afterSeq);
52
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
+ }
53
68
  emitWorkspace(event) {
54
69
  fanOut(this.workspace, event);
55
70
  }
@@ -78,7 +93,9 @@ export class EventHub {
78
93
  if (this.hasSubscribers(sessionId))
79
94
  return;
80
95
  const b = this.buses.get(sessionId);
81
- if (b)
96
+ if (b) {
97
+ b.replayFloor = b.buffer.at(-1)?.seq ?? b.replayFloor;
82
98
  b.buffer = [];
99
+ }
83
100
  }
84
101
  }
@@ -11,7 +11,7 @@ import { randomBytes } from "node:crypto";
11
11
  import { basename, join } from "node:path";
12
12
  import { pierPath } from "../paths.js";
13
13
  import { fileMarker, lostMarker, MAX_INBOUND_BYTES, safeName } from "./inbound-file.js";
14
- /** Where every inbound file lives; web/files.ts allowlists this root. */
14
+ /** Where every inbound file lives; the attachment route allowlists this root. */
15
15
  export const INBOX_DIR = pierPath("inbox");
16
16
  /**
17
17
  * Write one inbound file and return its absolute path. The timestamp-random
@@ -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
  /**
@@ -216,3 +226,67 @@ export function splitReply(rawMarkdown, meta) {
216
226
  return { text: markdown, suggestions: [], meta, silence };
217
227
  return { text: markdown.slice(0, m.index).trimEnd(), suggestions, meta, silence };
218
228
  }
229
+ /**
230
+ * The body of a block that is still being written, for a surface rendering it
231
+ * before the turn ends: everything `splitReply` repairs, minus the next-step
232
+ * block. That block is only a next-step block at the very *end* of a turn, so
233
+ * text a later paragraph has already closed behind keeps it as body — which is
234
+ * what the final render will do with it too.
235
+ */
236
+ export const streamBody = (markdown) => cjkFriendly(markdown.replace(SILENT, "").trim());
237
+ /** Lines a blank line does not necessarily separate: a list item, a quote and
238
+ * an indented continuation all survive one — a loose list is still one list.
239
+ * Deliberately over-matching (`*` and `-` also catch emphasis and a rule):
240
+ * claiming one boundary too few costs nothing but a repaint. */
241
+ const CONTINUES = /^(?:\s|[-*+>]|\d+[.)])/;
242
+ /**
243
+ * How much of `markdown` after `from` can no longer change as more text
244
+ * arrives: the offset just past the last blank line that closes a block, or
245
+ * `from` if there is none.
246
+ *
247
+ * Streaming markdown is the caller. Re-parsing the whole reply on every
248
+ * repaint costs O(N²) over a turn, so the web chat renders each closed prefix
249
+ * once and keeps its DOM. A boundary is therefore only claimed where parsing
250
+ * the two sides separately renders the same as parsing them together: never
251
+ * inside a backtick or tilde fence, inside a block stripped on completion, or
252
+ * between two lines a blank line leaves in the same list or quote. Fence runs
253
+ * keep their length as in `channels/chunk.ts`: a ```` fence is not closed by
254
+ * the ``` it quotes.
255
+ */
256
+ export function stableBlockEnd(markdown, from = 0) {
257
+ // The last line is still growing, so it decides nothing — not the fence
258
+ // state, and not whether the blank line above it ended a block.
259
+ const end = markdown.lastIndexOf("\n") + 1;
260
+ /** Marker run of the fence currently open; empty = closed. */
261
+ let open = "";
262
+ let silent = false;
263
+ let cut = from;
264
+ /** Start of the block after a blank line, waiting for its first line. */
265
+ let pending = -1;
266
+ let prev = "";
267
+ for (let at = from; at < end;) {
268
+ const nl = markdown.indexOf("\n", at);
269
+ const line = markdown.slice(at, nl);
270
+ at = nl + 1;
271
+ const fence = /^\s*(`{3,}|~{3,})(.*)$/.exec(line);
272
+ if (!open && !silent && !line.trim()) {
273
+ if (pending < 0 && prev)
274
+ pending = at;
275
+ continue;
276
+ }
277
+ if (!open && !silent && pending >= 0 && !(CONTINUES.test(prev) && CONTINUES.test(line)))
278
+ cut = pending;
279
+ const run = fence?.[1] ?? "";
280
+ if (!open && run && !silent)
281
+ open = run;
282
+ else if (open && run[0] === open[0] && run.length >= open.length && !fence?.[2]?.trim())
283
+ open = "";
284
+ if (!open && (!run || silent)) {
285
+ for (const tag of line.matchAll(/<(\/?)silent>/gi))
286
+ silent = !tag[1];
287
+ }
288
+ pending = -1;
289
+ prev = line;
290
+ }
291
+ return cut;
292
+ }
@@ -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
@@ -289,6 +289,33 @@ const MIGRATIONS = [
289
289
  session_id TEXT NOT NULL REFERENCES web_sessions(id) ON DELETE CASCADE
290
290
  );
291
291
  `,
292
+ // 15 — the scheduler's tick stops reading the rows it cannot deliver.
293
+ `
294
+ -- Once a second, tasks/ asks for the runs whose callback is still owed and
295
+ -- the messages whose injection has not landed. Both are a handful of rows
296
+ -- filtered on one low-cardinality column, and without an index both are a
297
+ -- full scan of a table that only grows — the sweep got slower with every
298
+ -- run that finished cleanly and can never match again.
299
+ CREATE INDEX task_runs_callback_state ON task_runs(callback_state);
300
+ CREATE INDEX task_messages_state ON task_messages(state);
301
+ `,
302
+ // 16 — the same tick stops parsing every task document to find none due.
303
+ `
304
+ -- When a task is next due is the one field the scheduler asks about once a
305
+ -- second, and it lived only inside the JSON: answering meant reading and
306
+ -- parsing every definition, due or not. A generated column keeps the JSON
307
+ -- as the only record while giving the query planner a value it can index; a
308
+ -- disabled or archived task has no next run, so NULLs stay out of the index.
309
+ ALTER TABLE tasks ADD COLUMN next_run_at INTEGER
310
+ GENERATED ALWAYS AS (json_extract(json, '$.nextRunAt')) VIRTUAL;
311
+ CREATE INDEX tasks_due ON tasks(next_run_at) WHERE next_run_at IS NOT NULL;
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
+ `,
292
319
  ];
293
320
  /**
294
321
  * Several writes as one, or none. `BEGIN IMMEDIATE` because every writer here
@@ -310,6 +337,24 @@ export function transact(db, work) {
310
337
  throw err;
311
338
  }
312
339
  }
340
+ /**
341
+ * Prepared statements, memoized by their SQL. `prepare()` compiles, and the
342
+ * callers here hand it the same handful of strings forever — twice per
343
+ * authenticated request, once a second per scheduler sweep. A `StatementSync`
344
+ * is reusable with different bound parameters, so one per SQL string per
345
+ * connection is the whole cache. Bound to the connection because a statement
346
+ * belongs to the database that compiled it; SQL built per call does not
347
+ * belong in here, since the cache would then grow without a bound.
348
+ */
349
+ export function statements(db) {
350
+ const cache = new Map();
351
+ return (sql) => {
352
+ let stmt = cache.get(sql);
353
+ if (!stmt)
354
+ cache.set(sql, (stmt = db.prepare(sql)));
355
+ return stmt;
356
+ };
357
+ }
313
358
  let shared;
314
359
  /**
315
360
  * The process's one connection, opened and migrated on first use. Every store
@@ -350,6 +395,11 @@ export function openDb(path, migrations = MIGRATIONS) {
350
395
  // file, and SQLite refuses to change it inside one.
351
396
  db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
352
397
  db.exec("PRAGMA journal_mode = WAL");
398
+ // WAL's default leaves every commit waiting on an fsync, and DatabaseSync is
399
+ // synchronous — that wait is the event loop's. NORMAL still survives a
400
+ // process crash; only a power loss can cost the last transactions, which for
401
+ // routing state and task bookkeeping is a fair trade for not blocking.
402
+ db.exec("PRAGMA synchronous = NORMAL");
353
403
  // Off by default in SQLite, and a declared relationship nothing enforces is
354
404
  // a comment. Set before migrate(): it is a per-connection switch and a no-op
355
405
  // inside a transaction. Nothing older declares a key, so this changes the