@timqi/pier 0.1.14 → 0.2.0

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 (126) hide show
  1. package/README.md +44 -22
  2. package/dist/agent/config.js +2 -2
  3. package/dist/agent/events.js +32 -3
  4. package/dist/agent/packages.js +6 -10
  5. package/dist/agent/pi.js +111 -68
  6. package/dist/agent/roles.js +103 -0
  7. package/dist/agent/types.js +78 -0
  8. package/dist/boards/boards.js +42 -32
  9. package/dist/channels/chains.js +0 -4
  10. package/dist/channels/commands.js +7 -12
  11. package/dist/channels/config.js +27 -1
  12. package/dist/channels/control.js +5 -16
  13. package/dist/channels/conversations.js +0 -5
  14. package/dist/channels/lark-api.js +20 -26
  15. package/dist/channels/lark-outbound.js +16 -29
  16. package/dist/channels/lark-panel.js +1 -1
  17. package/dist/channels/lark.js +61 -39
  18. package/dist/channels/lines.js +33 -1
  19. package/dist/channels/panel.js +17 -107
  20. package/dist/channels/routes.js +11 -21
  21. package/dist/channels/runtime.js +34 -25
  22. package/dist/channels/slack-outbound.js +6 -15
  23. package/dist/channels/slack-panel.js +1 -1
  24. package/dist/channels/slack-render.js +1 -1
  25. package/dist/channels/slack.js +63 -57
  26. package/dist/channels/types.js +1 -1
  27. package/dist/config-sync.js +42 -37
  28. package/dist/core/chain.js +202 -0
  29. package/dist/core/identity.js +34 -11
  30. package/dist/core/inbound-file.js +4 -3
  31. package/dist/core/reply.js +96 -80
  32. package/dist/core/router.js +111 -118
  33. package/dist/core/types.js +36 -77
  34. package/dist/db.js +33 -2
  35. package/dist/main.js +46 -37
  36. package/dist/settings.js +25 -18
  37. package/dist/tasks/agent.js +11 -5
  38. package/dist/tasks/callbacks.js +55 -5
  39. package/dist/tasks/cli.js +22 -11
  40. package/dist/tasks/definitions.js +30 -17
  41. package/dist/tasks/execution.js +2 -2
  42. package/dist/tasks/groups.js +5 -2
  43. package/dist/tasks/messages.js +7 -7
  44. package/dist/tasks/open-items.js +70 -0
  45. package/dist/tasks/operations.js +76 -39
  46. package/dist/tasks/outbox.js +19 -6
  47. package/dist/tasks/routes.js +19 -249
  48. package/dist/tasks/service.js +145 -34
  49. package/dist/tasks/store.js +119 -56
  50. package/dist/tasks/types.js +5 -0
  51. package/dist/tools.js +2 -2
  52. package/dist/web/auth.js +2 -2
  53. package/dist/web/config-sync.js +4 -5
  54. package/dist/web/config.js +9 -2
  55. package/dist/web/explorer.js +2 -2
  56. package/dist/web/fs.js +16 -1
  57. package/dist/web/instance.js +8 -5
  58. package/dist/web/packages.js +1 -1
  59. package/dist/web/passkeys.js +3 -3
  60. package/dist/web/providers.js +3 -3
  61. package/dist/web/public/assets/code-BZwvPani.js +2 -0
  62. package/dist/web/public/assets/code-BZwvPani.js.br +0 -0
  63. package/dist/web/public/assets/code-BZwvPani.js.gz +0 -0
  64. package/dist/web/public/assets/explorer-DDoVzMLQ.js +5 -0
  65. package/dist/web/public/assets/explorer-DDoVzMLQ.js.br +0 -0
  66. package/dist/web/public/assets/explorer-DDoVzMLQ.js.gz +0 -0
  67. package/dist/web/public/assets/highlight-5Bl8zZ8q.js +72 -0
  68. package/dist/web/public/assets/highlight-5Bl8zZ8q.js.br +0 -0
  69. package/dist/web/public/assets/highlight-5Bl8zZ8q.js.gz +0 -0
  70. package/dist/web/public/assets/{hljs-tWqyD59G.js → hljs-BRz6a8Dq.js} +2 -2
  71. package/dist/web/public/assets/hljs-BRz6a8Dq.js.br +0 -0
  72. package/dist/web/public/assets/hljs-BRz6a8Dq.js.gz +0 -0
  73. package/dist/web/public/assets/index-BpFzCwlo.js +7 -0
  74. package/dist/web/public/assets/index-BpFzCwlo.js.br +0 -0
  75. package/dist/web/public/assets/index-BpFzCwlo.js.gz +0 -0
  76. package/dist/web/public/assets/index-wAGxFFPj.css +2 -0
  77. package/dist/web/public/assets/index-wAGxFFPj.css.br +0 -0
  78. package/dist/web/public/assets/index-wAGxFFPj.css.gz +0 -0
  79. package/dist/web/public/assets/report-zjE8Bt8S.js +8 -0
  80. package/dist/web/public/assets/report-zjE8Bt8S.js.br +0 -0
  81. package/dist/web/public/assets/report-zjE8Bt8S.js.gz +0 -0
  82. package/dist/web/public/assets/settings-Cv7rbeqf.js +9 -0
  83. package/dist/web/public/assets/settings-Cv7rbeqf.js.br +0 -0
  84. package/dist/web/public/assets/settings-Cv7rbeqf.js.gz +0 -0
  85. package/dist/web/public/index.html +40 -109
  86. package/dist/web/public/index.html.br +0 -0
  87. package/dist/web/public/index.html.gz +0 -0
  88. package/dist/web/public/manifest.webmanifest +0 -2
  89. package/dist/web/push.js +1 -1
  90. package/dist/web/server.js +91 -116
  91. package/dist/web/session-state.js +5 -40
  92. package/docs/deploy.md +5 -3
  93. package/package.json +2 -1
  94. package/skills/pier-boards/SKILL.md +14 -4
  95. package/skills/pier-help/SKILL.md +57 -23
  96. package/skills/pier-tasks/SKILL.md +58 -18
  97. package/dist/channels/handoff.js +0 -94
  98. package/dist/web/public/assets/activity-Ds6fCHnb.js +0 -5
  99. package/dist/web/public/assets/activity-Ds6fCHnb.js.br +0 -0
  100. package/dist/web/public/assets/activity-Ds6fCHnb.js.gz +0 -0
  101. package/dist/web/public/assets/boards-BKCj6EwK.js +0 -1
  102. package/dist/web/public/assets/boards-BKCj6EwK.js.br +0 -0
  103. package/dist/web/public/assets/boards-BKCj6EwK.js.gz +0 -0
  104. package/dist/web/public/assets/explorer-DnTm975c.js +0 -4
  105. package/dist/web/public/assets/explorer-DnTm975c.js.br +0 -0
  106. package/dist/web/public/assets/explorer-DnTm975c.js.gz +0 -0
  107. package/dist/web/public/assets/hljs-tWqyD59G.js.br +0 -0
  108. package/dist/web/public/assets/hljs-tWqyD59G.js.gz +0 -0
  109. package/dist/web/public/assets/index-BQo-haPN.js +0 -85
  110. package/dist/web/public/assets/index-BQo-haPN.js.br +0 -0
  111. package/dist/web/public/assets/index-BQo-haPN.js.gz +0 -0
  112. package/dist/web/public/assets/index-DiHj0w1i.css +0 -2
  113. package/dist/web/public/assets/index-DiHj0w1i.css.br +0 -0
  114. package/dist/web/public/assets/index-DiHj0w1i.css.gz +0 -0
  115. package/dist/web/public/assets/runs-C_AthWcW.js +0 -1
  116. package/dist/web/public/assets/runs-C_AthWcW.js.br +0 -0
  117. package/dist/web/public/assets/runs-C_AthWcW.js.gz +0 -0
  118. package/dist/web/public/assets/settings-DDaAtFlc.js +0 -5
  119. package/dist/web/public/assets/settings-DDaAtFlc.js.br +0 -0
  120. package/dist/web/public/assets/settings-DDaAtFlc.js.gz +0 -0
  121. package/dist/web/public/assets/task-runs-0pvdITiV.js +0 -3
  122. package/dist/web/public/assets/task-runs-0pvdITiV.js.br +0 -0
  123. package/dist/web/public/assets/task-runs-0pvdITiV.js.gz +0 -0
  124. package/dist/web/public/assets/tasks-Bz29caHJ.js +0 -4
  125. package/dist/web/public/assets/tasks-Bz29caHJ.js.br +0 -0
  126. package/dist/web/public/assets/tasks-Bz29caHJ.js.gz +0 -0
@@ -1,46 +1,23 @@
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";
4
3
  import { logger } from "../log.js";
5
4
  import { EventHub } from "./hub.js";
6
5
  import { SenderPrefix, withPrefix } from "./identity.js";
7
6
  import { decide } from "./queue.js";
8
- import { splitReply } from "./reply.js";
7
+ import { cut, splitReply } from "./reply.js";
8
+ import { isChatCommand } from "./types.js";
9
9
  const log = logger("core");
10
10
  /** Generous: eviction is a memory measure, and re-opening costs a Pi resume
11
11
  * plus a transcript read. */
12
12
  const IDLE_TTL_MS = 30 * 60_000;
13
13
  const SWEEP_MS = 5 * 60_000;
14
- /** An error goes into a chat window, so it is trimmed to something readable. */
15
- const truncate = (message) => message.length > 600 ? `${message.slice(0, 600)}…` : message;
16
- /** What a chat window gets of a system input; a task callback carries up to
17
- * 8000 characters of result text (tasks/callbacks.ts). */
18
- const NOTE_CHARS = 200;
19
- const NOTE_LINES = 4;
20
- /** A note is context for the turn it precedes, not the message: pasted whole,
21
- * a run result buries the chat on IM, which cannot collapse it. The hub and
22
- * the transcript keep every character. */
23
- function digest(text) {
24
- const body = text.trimEnd();
25
- let head = body.split("\n").slice(0, NOTE_LINES).join("\n");
26
- if (head.length > NOTE_CHARS) {
27
- const capped = head.slice(0, NOTE_CHARS);
28
- // A boundary before the midpoint loses more than the ragged edge costs.
29
- const boundary = Math.max(capped.lastIndexOf("\n"), capped.lastIndexOf(" "));
30
- head = capped.slice(0, boundary > NOTE_CHARS / 2 ? boundary : NOTE_CHARS);
31
- }
32
- const rest = body.slice(head.length).trim();
33
- if (!rest)
34
- return body;
35
- const dropped = rest.split("\n").length;
36
- return `${head.trimEnd()}\n… +${String(dropped)} more line${dropped === 1 ? "" : "s"}`;
37
- }
38
14
  function keyOf(key) {
39
15
  return `${key.channelId}:${key.conversationId}`;
40
16
  }
41
17
  /** `web:<id>` and `task:<id>` name one session id and neither is a chat — no
42
18
  * Channel is registered under them — so they share a lock in `ensure`. */
43
19
  const isAlias = (key) => key.channelId === "web" || key.channelId === "task";
20
+ const webKey = (sessionId) => ({ channelId: "web", conversationId: sessionId });
44
21
  export class QueueOperationError extends Error {
45
22
  reason;
46
23
  constructor(reason, message) {
@@ -48,6 +25,28 @@ export class QueueOperationError extends Error {
48
25
  this.reason = reason;
49
26
  }
50
27
  }
28
+ /** A message the router did not take and has already told the chat about
29
+ * (`refuse`): a caller that reports its own failures skips these. */
30
+ export class Refused extends Error {
31
+ }
32
+ /** A short skill spelling that names more than one skill: not sent, the chat told. */
33
+ export class SkillAmbiguous extends Refused {
34
+ }
35
+ /** `/<word> <rest>` or `%<word> <rest>` (also `/skill:<word>`) resolved to the
36
+ * one skill `word` is a prefix of — of the name, or of the name after any `-`;
37
+ * the text unchanged when it names none (docs/design/11-im-conversation.md). */
38
+ function skillText(text, skills) {
39
+ // Two characters at least: `/s <text>` is the settings draft's spelling (channels/commands.ts).
40
+ const [, kept, spelled, rest] = /^[/%](skill:)?([^\s/%]{2,})(?:[ \t]+([\s\S]*))?$/i.exec(text.trim()) ?? [];
41
+ const word = spelled?.toLowerCase();
42
+ if (!word || (!kept && isChatCommand(word)))
43
+ return text;
44
+ const names = skills.map((s) => s.name);
45
+ const found = names.includes(word) ? [word] : names.filter((name) => name.split("-").some((_, i, parts) => parts.slice(i).join("-").startsWith(word)));
46
+ if (found.length > 1)
47
+ return new SkillAmbiguous(`/${word} matches ${found.join(", ")} — say more`);
48
+ return found[0] ? `/skill:${found[0]}${rest ? ` ${rest}` : ""}` : text;
49
+ }
51
50
  export class Router {
52
51
  hub;
53
52
  resolve;
@@ -64,7 +63,7 @@ export class Router {
64
63
  /** Set by a graceful restart (src/drain.ts); `endDrain` is for the caller
65
64
  * that drains speculatively and may not get to exit. */
66
65
  draining = false;
67
- spokenTo;
66
+ turnEnded;
68
67
  constructor(hub,
69
68
  /** Create or resume the session owning a conversation (wired in main.ts). */
70
69
  resolve,
@@ -80,14 +79,46 @@ export class Router {
80
79
  this.sessionIdOf = sessionIdOf;
81
80
  this.chatKeyOf = chatKeyOf;
82
81
  }
82
+ /** A session its adapter's stop sent back to its own stream takes its chat again. */
83
83
  registerChannel(channel) {
84
84
  this.channels.set(channel.id, channel);
85
+ for (const attached of this.bySession.values())
86
+ this.toChat(attached);
87
+ }
88
+ /** A stopped adapter's sessions answer on their own stream until it is back:
89
+ * a key on a dead channel delivers nowhere, and Web Push reads it as a chat's. */
90
+ unregisterChannel(channelId) {
91
+ this.channels.delete(channelId);
92
+ for (const key of this.byKey.keys())
93
+ if (key.startsWith(`${channelId}:`))
94
+ this.byKey.delete(key);
95
+ for (const attached of this.bySession.values()) {
96
+ if (attached.key.channelId === channelId)
97
+ attached.key = webKey(attached.session.id);
98
+ }
85
99
  }
86
- /** Fires for humans only: chats and the workbench pass through `dispatch`,
87
- * tasks and subagents do not. Registered late because the listener
88
- * (web/session-state.ts) is built with the web surface. */
89
- onSpokenTo(listener) {
90
- this.spokenTo = listener;
100
+ /** The durable chat outranks the alias that happened to open the session
101
+ * first (a restart, the web speaking first), same rule as `reached`. */
102
+ toChat(attached) {
103
+ const chat = isAlias(attached.key) ? this.chatKeyOf(attached.session.id) : undefined;
104
+ if (!chat || !this.channels.has(chat.channelId))
105
+ return;
106
+ this.hold(chat, attached.session);
107
+ attached.key = chat;
108
+ }
109
+ /** A chat key taken from another session (the chain's rotation) leaves it,
110
+ * or two sessions would answer one chat. */
111
+ hold(key, session) {
112
+ const previous = this.byKey.get(keyOf(key));
113
+ const left = previous && previous !== session ? this.bySession.get(previous.id) : undefined;
114
+ if (left && keyOf(left.key) === keyOf(key))
115
+ left.key = webKey(left.session.id);
116
+ this.byKey.set(keyOf(key), session);
117
+ }
118
+ /** Every attached session's answered turn, runs' and humans' alike; a failed
119
+ * turn is not one. Registered by the task service (tasks/service.ts). */
120
+ onTurnEnd(listener) {
121
+ this.turnEnded = listener;
91
122
  }
92
123
  /** A failure reaches the chat as well as the hub (§5): on IM, silence is
93
124
  * indistinguishable from a crash. `notify`, not `send`, so it is never
@@ -97,7 +128,7 @@ export class Router {
97
128
  this.hub.emit(sessionId, { type: "error", message });
98
129
  const channel = this.channels.get(key.channelId);
99
130
  // Never recursive: if telling the chat also fails, the hub has the original.
100
- channel?.notify(key.conversationId, { text: truncate(message), origin: { kind: "error" } })
131
+ channel?.notify(key.conversationId, { text: cut(message, 600), origin: { kind: "error" } })
101
132
  .catch((err) => {
102
133
  log.error(`could not report the failure to ${key.channelId}`, err);
103
134
  this.hub.emit(sessionId, {
@@ -126,7 +157,7 @@ export class Router {
126
157
  // Snapshot: the loop awaits dispose(), and the map may change meanwhile.
127
158
  // oxlint-disable-next-line unicorn/no-useless-spread
128
159
  for (const [id, attached] of [...this.bySession]) {
129
- if (this.queueOperations.has(id) || this.recoveries.get(id)?.some((b) => b.status === "submitting"))
160
+ if (this.queueOperations.has(id) || this.submitting.has(id))
130
161
  continue;
131
162
  if (!includeWatched && this.hub.hasSubscribers(id))
132
163
  continue;
@@ -165,9 +196,6 @@ export class Router {
165
196
  stateOf(sessionId) {
166
197
  return this.bySession.get(sessionId)?.session.state;
167
198
  }
168
- stateSinceOf(sessionId) {
169
- return this.bySession.get(sessionId)?.stateSince;
170
- }
171
199
  /** Current in-memory model of a live session (undefined when not attached). */
172
200
  modelOf(sessionId) {
173
201
  return this.bySession.get(sessionId)?.session.model;
@@ -187,7 +215,7 @@ export class Router {
187
215
  attach(key, session) {
188
216
  const existing = this.bySession.get(session.id);
189
217
  if (existing?.session === session) {
190
- this.byKey.set(keyOf(key), session);
218
+ this.hold(key, session);
191
219
  this.reached(session, key);
192
220
  return;
193
221
  }
@@ -199,14 +227,13 @@ export class Router {
199
227
  this.forgetKeys(existing.session);
200
228
  void existing.session.dispose().catch((err) => log.error(`disposing replaced session ${session.id} failed`, err));
201
229
  }
202
- this.byKey.set(keyOf(key), session);
230
+ this.hold(key, session);
203
231
  log.info(`attached ${keyOf(key)} → session ${session.id}`);
204
232
  // Delivery reads `attached.key` live: a chat attaching after the workbench
205
233
  // opened the session takes over (`reached`), and the closure must follow.
206
234
  const attached = {
207
235
  session,
208
236
  key,
209
- stateSince: Date.now(),
210
237
  activeAt: Date.now(),
211
238
  touched: 0,
212
239
  unsubscribe: session.subscribe((payload) => {
@@ -214,7 +241,7 @@ export class Router {
214
241
  this.hub.emit(session.id, payload);
215
242
  if (payload.type === "state") {
216
243
  // Every turn passes here, so it also proves liveness to the sweeper.
217
- attached.stateSince = attached.activeAt = Date.now();
244
+ attached.activeAt = Date.now();
218
245
  attached.touched += 1;
219
246
  this.hub.emitWorkspace({
220
247
  type: "session-state",
@@ -230,7 +257,7 @@ export class Router {
230
257
  log.error(`${keyOf(key)} session ${session.id} reported: ${payload.message}`);
231
258
  const channel = this.channels.get(key.channelId);
232
259
  channel?.notify(key.conversationId, {
233
- text: truncate(payload.message),
260
+ text: cut(payload.message, 600),
234
261
  origin: { kind: "error" },
235
262
  }).catch((err) => log.error(`notify ${key.channelId} failed`, err));
236
263
  }
@@ -239,7 +266,7 @@ export class Router {
239
266
  if (payload.type === "system-input") {
240
267
  const channel = this.channels.get(key.channelId);
241
268
  channel?.notify(key.conversationId, {
242
- text: digest(payload.text),
269
+ text: payload.text,
243
270
  origin: payload.origin,
244
271
  at: payload.at,
245
272
  })
@@ -260,6 +287,8 @@ export class Router {
260
287
  // Empty text included: adapters retire per-turn UI (👀 receipts) on it.
261
288
  if (payload.type === "turn-end") {
262
289
  log.info(`turn end ${keyOf(key)} session ${session.id}: ${String(payload.text.length)} chars`);
290
+ if (!payload.error)
291
+ this.turnEnded?.(session.id, payload.text);
263
292
  const channel = this.channels.get(key.channelId);
264
293
  if (channel) {
265
294
  const reply = splitReply(payload.text, payload.meta);
@@ -272,13 +301,7 @@ export class Router {
272
301
  }),
273
302
  };
274
303
  this.bySession.set(session.id, attached);
275
- // The durable chat outranks the alias that happened to open the session
276
- // first (a restart, the web speaking first), same rule as `reached`.
277
- const chat = isAlias(key) ? this.chatKeyOf(session.id) : undefined;
278
- if (chat) {
279
- this.byKey.set(keyOf(chat), session);
280
- attached.key = chat;
281
- }
304
+ this.toChat(attached);
282
305
  }
283
306
  /** An adapter's send is several platform calls (chunks, then attachments),
284
307
  * so two answers left to overlap interleave in the chat. Per conversation:
@@ -303,37 +326,12 @@ export class Router {
303
326
  }
304
327
  queueOperations = new Set();
305
328
  promotionRequested = new Set();
306
- recoveries = new Map();
307
- // Latest failed batch id, so a clear cannot erase a newer rejection that
308
- // arrived while it awaited the backend.
309
- uncertaintyHeld = new Map();
310
- queueUncertain(sessionId) {
311
- return this.uncertaintyHeld.has(sessionId);
312
- }
313
- recoveryOf(sessionId) {
314
- return structuredClone(this.recoveries.get(sessionId) ?? []);
315
- }
316
- recoveryChanged(sessionId) {
317
- if (!this.recoveries.get(sessionId)?.length)
318
- this.recoveries.delete(sessionId);
319
- this.hub.emit(sessionId, {
320
- type: "queue-recovery", batches: this.recoveryOf(sessionId), uncertain: this.queueUncertain(sessionId),
321
- });
322
- }
323
- acknowledgeRecovery(sessionId, batchId) {
324
- const batches = this.recoveries.get(sessionId);
325
- const batch = batches?.find((b) => b.id === batchId);
326
- if (!batch)
327
- throw new QueueOperationError("missing", "No such recovery batch");
328
- if (batch.status === "submitting")
329
- throw new QueueOperationError("busy", "Submission has not settled");
330
- this.recoveries.set(sessionId, batches.filter((b) => b !== batch));
331
- this.promotionRequested.delete(sessionId);
332
- this.recoveryChanged(sessionId);
333
- }
329
+ // Promotions launched but not settled, per session: automatic promotion
330
+ // waits for them, and eviction must not dispose a preflight.
331
+ submitting = new Map();
334
332
  /** Only queue mutation holds this lock; a running turn does not, so manual
335
333
  * controls stay available while it runs. */
336
- async useQueue(sessionId, action, key = { channelId: "web", conversationId: sessionId }) {
334
+ async useQueue(sessionId, action, key = webKey(sessionId)) {
337
335
  if (this.queueOperations.has(sessionId))
338
336
  throw new QueueOperationError("busy", "Queue operation in progress");
339
337
  this.queueOperations.add(sessionId);
@@ -347,10 +345,7 @@ export class Router {
347
345
  }
348
346
  async recallQueue(sessionId) {
349
347
  return this.useQueue(sessionId, async (session) => {
350
- const held = this.uncertaintyHeld.get(sessionId);
351
348
  const queue = await session.clearQueue();
352
- if (this.uncertaintyHeld.get(sessionId) === held && this.uncertaintyHeld.delete(sessionId))
353
- this.recoveryChanged(sessionId);
354
349
  if (queue.steering.length || queue.followUp.length)
355
350
  this.forgetSender(sessionId);
356
351
  return queue;
@@ -360,35 +355,25 @@ export class Router {
360
355
  if (this.draining)
361
356
  throw new QueueOperationError("draining", "Pier is restarting; queued messages were not submitted");
362
357
  }
358
+ /** Once cleared, the originals exist only here: a failure hands them back
359
+ * to the conversation with the error, and nothing is kept for a resend. */
363
360
  async deliverQueue(sessionId, mode) {
364
- let retained = false;
361
+ let cleared = false;
365
362
  try {
366
363
  this.checkQueueDrain();
367
364
  return await this.useQueue(sessionId, async (session) => {
368
365
  this.checkQueueDrain();
369
- if (mode === "auto" && (session.state !== "idle" || this.queueUncertain(sessionId) || this.recoveries.get(sessionId)?.length))
366
+ if (mode === "auto" && session.state !== "idle")
370
367
  return "";
371
- const held = this.uncertaintyHeld.get(sessionId);
372
368
  const queue = await session.clearQueue();
373
- if (mode !== "auto" && this.uncertaintyHeld.get(sessionId) === held && this.uncertaintyHeld.delete(sessionId))
374
- this.recoveryChanged(sessionId);
375
369
  if (!queue.steering.length && !queue.followUp.length)
376
370
  throw new QueueOperationError("empty", "Queue is empty");
377
- const batch = { id: randomUUID(), ...queue, status: "submitting" };
378
- this.recoveries.set(sessionId, [...(this.recoveries.get(sessionId) ?? []), batch]);
379
- retained = true;
380
- this.recoveryChanged(sessionId);
381
- const text = [...batch.steering, ...batch.followUp].join("\n");
382
- let invoked = false;
371
+ cleared = true;
372
+ const text = [...queue.steering, ...queue.followUp].join("\n");
383
373
  const failed = (err) => {
384
374
  this.forgetSender(sessionId);
385
375
  this.promotionRequested.delete(sessionId);
386
- batch.status = invoked ? "uncertain" : "not-submitted";
387
- if (invoked)
388
- this.uncertaintyHeld.set(sessionId, batch.id);
389
- batch.error = String(err);
390
- this.recoveryChanged(sessionId);
391
- this.reportTo(sessionId, `Queue promotion failed (${batch.status}); automatic queue paused, originals remain available in queue recovery: ${String(err)}`);
376
+ this.reportTo(sessionId, `Queued messages were not delivered — send them again: ${String(err)}\n\n${text}`);
392
377
  };
393
378
  try {
394
379
  this.checkQueueDrain();
@@ -397,13 +382,17 @@ export class Router {
397
382
  this.checkQueueDrain();
398
383
  // Not via dispatch: the text was headed at original dispatch, and a
399
384
  // second pass could attribute these words to the operator.
400
- invoked = true;
401
385
  const submitted = mode === "steer" && session.state === "streaming"
402
386
  ? session.steer(text) : session.prompt(text);
403
- void submitted.then(() => {
404
- this.recoveries.set(sessionId, (this.recoveries.get(sessionId) ?? []).filter((b) => b !== batch));
405
- this.recoveryChanged(sessionId);
406
- }, failed).finally(() => this.resumePromotion(sessionId));
387
+ this.submitting.set(sessionId, (this.submitting.get(sessionId) ?? 0) + 1);
388
+ void submitted.catch(failed).finally(() => {
389
+ const left = this.submitting.get(sessionId) - 1;
390
+ if (left)
391
+ this.submitting.set(sessionId, left);
392
+ else
393
+ this.submitting.delete(sessionId);
394
+ this.resumePromotion(sessionId);
395
+ });
407
396
  return text;
408
397
  }
409
398
  catch (err) {
@@ -413,7 +402,7 @@ export class Router {
413
402
  }, mode === "auto" ? this.conversationOf(sessionId) : undefined);
414
403
  }
415
404
  catch (err) {
416
- if (!retained && !(err instanceof QueueOperationError && (err.reason === "busy" || err.reason === "empty"))) {
405
+ if (!cleared && !(err instanceof QueueOperationError && (err.reason === "busy" || err.reason === "empty"))) {
417
406
  this.reportTo(sessionId, `Could not promote queued messages: ${String(err)}`);
418
407
  }
419
408
  throw err;
@@ -431,10 +420,7 @@ export class Router {
431
420
  resumePromotion(sessionId) {
432
421
  // queue_update precedes the backend's enqueue. Never clear it reentrantly.
433
422
  queueMicrotask(() => {
434
- if (!this.promotionRequested.has(sessionId) || this.queueOperations.has(sessionId))
435
- return;
436
- // Retained failures require a human decision, not an automatic resend.
437
- if (this.queueUncertain(sessionId) || this.recoveries.get(sessionId)?.length)
423
+ if (!this.promotionRequested.has(sessionId) || this.queueOperations.has(sessionId) || this.submitting.has(sessionId))
438
424
  return;
439
425
  this.promotionRequested.delete(sessionId);
440
426
  const attached = this.bySession.get(sessionId);
@@ -476,12 +462,14 @@ export class Router {
476
462
  return this.draining;
477
463
  }
478
464
  /** Told to the chat directly (§5): an adapter's dispatch catch only logs. */
479
- refuseDraining(key) {
480
- const message = "Pier is restarting — this message was not taken; send it again in a moment.";
465
+ refuse(key, err) {
481
466
  this.channels.get(key.channelId)
482
- ?.notify(key.conversationId, { text: message, origin: { kind: "error" } })
483
- .catch((err) => log.error(`could not report the drain to ${key.channelId}`, err));
484
- throw new Error(message);
467
+ ?.notify(key.conversationId, { text: err.message, origin: { kind: "error" } })
468
+ .catch((e) => log.error(`could not report the refusal to ${key.channelId}`, e));
469
+ throw err;
470
+ }
471
+ refuseDraining(key) {
472
+ this.refuse(key, new Refused("Pier is restarting — this message was not taken; send it again in a moment."));
485
473
  }
486
474
  /** Attached sessions still mid-turn, and conversations whose answer is still
487
475
  * going out (`sending`) — what the drain waits on, and what its deadline
@@ -549,7 +537,7 @@ export class Router {
549
537
  * that is waiting. Either way the waiting side is not left with nothing. */
550
538
  unopened(key, err) {
551
539
  log.error(`could not open a session for ${keyOf(key)}`, err);
552
- const message = truncate(`could not open a session: ${String(err)}`);
540
+ const message = cut(`could not open a session: ${String(err)}`, 600);
553
541
  if (key.channelId === "web" || key.channelId === "task") {
554
542
  this.reportTo(key.conversationId, message);
555
543
  return;
@@ -581,13 +569,18 @@ export class Router {
581
569
  const session = await this.ensure(msg.key);
582
570
  if (this.draining)
583
571
  this.refuseDraining(msg.key);
584
- this.spokenTo?.(session.id);
585
- const { action, text } = decide(msg, session.state);
572
+ const skilled = skillText(msg.text, session.skills());
573
+ if (skilled instanceof SkillAmbiguous)
574
+ this.refuse(msg.key, skilled);
575
+ const { action, text } = decide({ ...msg, text: skilled }, session.state);
586
576
  // A chat is named so the agent can hand it to a script (skills/pier-slack);
587
577
  // an alias names nothing a shell could reach.
588
578
  const where = isAlias(msg.key) ? undefined : keyOf(msg.key);
589
579
  const opaque = this.channels.get(msg.key.channelId)?.opaqueIds;
590
- const prompt = withPrefix(this.senders.next(session.id, msg.sender, Date.now(), where, opaque), text);
580
+ const header = this.senders.next(session.id, msg.sender, Date.now(), where, opaque, text);
581
+ // Pi expands `/skill:<name>` only at the very start, so a header rides in its args.
582
+ const skill = header ? /^\/skill:\S+/.exec(text)?.[0] : undefined;
583
+ const prompt = skill ? `${skill} ${withPrefix(header, text.slice(skill.length).trimStart())}` : withPrefix(header, text);
591
584
  log.debug(`${action} ${keyOf(msg.key)} → session ${session.id} (${String(prompt.length)} chars)`);
592
585
  // A rejected call surfaces on the event stream, never as a throw across the seam.
593
586
  session[action](prompt).catch((err) => {
@@ -1,87 +1,46 @@
1
- // Normative seam types — THIS FILE is the system contract (docs/architecture.md
2
- // documents the rules around it). Changing a seam is a design decision, not a
3
- // refactor; keep it implementable over RPC (no Pi types may appear here).
1
+ // Normative seam types of the conversation — Channel, AgentSession, its events,
2
+ // the chain — THIS FILE is the system contract (docs/architecture.md documents
3
+ // the rules around it). Changing a seam is a design decision, not a refactor;
4
+ // keep it implementable over RPC (no Pi types may appear here).
5
+ /** The chat commands, each with the one line the composer's completion shows:
6
+ * a message to the continuous conversation that is exactly `/<word>` or `%<word>` is a
7
+ * command, never a message (core/chain.ts). Browser-safe: the composer
8
+ * lists this table. */
9
+ export const CHAT_COMMANDS = {
10
+ status: "what is open — in flight, or waiting on you",
11
+ new: "start a new session now",
12
+ stop: "stop the reply in progress",
13
+ skills: "the skills this session can run, by name",
14
+ };
15
+ export const isChatCommand = (v) => typeof v === "string" && Object.hasOwn(CHAT_COMMANDS, v);
16
+ /** How long the continuous conversation's head may go without a user message
17
+ * before the next one starts a new session: the "long" prompt-cache TTL
18
+ * interactive sessions request, past which the cache is cold anyway. */
19
+ export const CHAIN_IDLE_MS = 60 * 60_000;
20
+ /** The run ledger's recent window: what `pier task runs` lists and the open items join against. */
21
+ export const LEDGER_WINDOW_MS = 24 * 60 * 60_000;
22
+ /** How large the continuous conversation's head's context may grow before the
23
+ * next user message starts a new session: past it, a turn costs more than the
24
+ * rotation's one cache write pays back. */
25
+ export const CHAIN_FULL_TOKENS = 60_000;
26
+ /** Declared here, not in tasks/, because `BackgroundRun`, the `task-status` event core carries, names it.
27
+ * In order: the Console lists them so, and a worker count reads so. */
28
+ export const TASK_RUN_STATES = ["queued", "running", "succeeded", "failed", "cancelled", "interrupted", "skipped"];
4
29
  /** How much of a tool result any surface ever shows. A transcript replay
5
30
  * carries no more than that: a session's tool output is most of its history
6
31
  * payload, and the bytes past this point were downloaded to be sliced off. */
7
32
  export const MAX_STEP_OUTPUT = 8_000;
8
33
  /** How much of a message becomes a title, wherever one is derived: the listing
9
- * reading a transcript (agent/listing.ts), a rename's fallback (agent/pi.ts),
10
- * the fill at first prompt and the rename boundary (web/). */
34
+ * reading a transcript (agent/listing.ts), the title model's answer
35
+ * (agent/pi.ts), the fill at first prompt (web/). */
11
36
  export const SESSION_TITLE_MAX = 80;
37
+ /** The one spelling a model is compared and printed by. */
38
+ export const modelKey = (m) => `${m.provider}/${m.id}`;
12
39
  /** Every level Pi accepts, in order. The union is derived so the two cannot
13
40
  * drift, and boundary validators use isThinkingLevel instead of their own copy. */
14
41
  export const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
15
42
  export const isThinkingLevel = (v) => typeof v === "string" && THINKING_LEVELS.includes(v);
16
- export class PackageError extends Error {
17
- reason;
18
- constructor(reason, message) {
19
- super(message);
20
- this.reason = reason;
21
- }
22
- }
23
- // Wire-protocol names, not SDK types — but they are pi-ai's spellings, and a
24
- // non-Pi backend is bound to them by this seam.
25
- const PROVIDER_APIS = [
26
- "openai-completions",
27
- "openai-responses",
28
- "anthropic-messages",
29
- "google-generative-ai",
30
- ];
31
- export const isProviderApi = (value) => typeof value === "string" && PROVIDER_APIS.includes(value);
32
- /** How far a model's reasoning goes. Every reasoning model offers up to
33
- * "high"; the two levels above it exist only for a model whose catalog entry
34
- * says so, which is the one thing a Console-defined model could not say. */
35
- export const MODEL_EFFORTS = ["high", "xhigh", "max"];
36
- /** The rules of the ProviderSetup seam, in one place: agent/ enforces them on
37
- * write and web/ pre-checks them at its HTTP boundary, and neither may import
38
- * the other. Throws the message the surface shows. */
39
- export function validateProviderSetup(input) {
40
- if (input.id.length > 100 || !/^[a-z0-9][a-z0-9._-]*$/.test(input.id)) {
41
- throw new Error("invalid provider id");
42
- }
43
- if (input.endpoint) {
44
- if (input.endpoint.length > 2048 || input.endpoint !== input.endpoint.trim()) {
45
- throw new Error("invalid endpoint");
46
- }
47
- validateEndpoint(input.endpoint);
48
- }
49
- if (input.kind === "builtin")
50
- return;
51
- if (!input.endpoint)
52
- throw new Error("custom provider endpoint required");
53
- if (input.name && (input.name.length > 200 || input.name !== input.name.trim())) {
54
- throw new Error("invalid provider name");
55
- }
56
- if (!isProviderApi(input.api))
57
- throw new Error("unsupported provider API");
58
- if (!input.models.length || input.models.length > 100)
59
- throw new Error("1-100 models required");
60
- const ids = input.models.map((model) => model.id);
61
- if (ids.some((id) => !id || id.length > 200 || id !== id.trim()) || new Set(ids).size !== ids.length) {
62
- throw new Error("model ids must be non-empty, trimmed and unique");
63
- }
64
- // An effort ceiling on a model that does not reason would be written into
65
- // the catalog and never offered — a setting that lies about itself.
66
- if (input.models.some((model) => model.effort !== undefined && !model.reasoning)) {
67
- throw new Error("effort requires reasoning");
68
- }
69
- if (input.models.some((model) => model.effort !== undefined && !MODEL_EFFORTS.includes(model.effort))) {
70
- throw new Error("unsupported model effort");
71
- }
72
- }
73
- export function validateEndpoint(endpoint) {
74
- let url;
75
- try {
76
- url = new URL(endpoint);
77
- }
78
- catch {
79
- throw new Error("endpoint must be an http(s) URL");
80
- }
81
- if (url.protocol !== "http:" && url.protocol !== "https:") {
82
- throw new Error("endpoint must be an http(s) URL");
83
- }
84
- if (url.username || url.password || url.search || url.hash) {
85
- throw new Error("endpoint must not contain credentials, query or fragment");
86
- }
87
- }
43
+ /** The work classes a dispatcher names instead of a model; a tier's menu
44
+ * entries are tried in order. Here, not in settings.ts, so the browser shares it. */
45
+ export const MODEL_TIERS = ["hardest", "balanced", "cheap"];
46
+ export const isModelTier = (v) => typeof v === "string" && MODEL_TIERS.includes(v);
package/dist/db.js CHANGED
@@ -143,7 +143,7 @@ const MIGRATIONS = [
143
143
  created_at INTEGER NOT NULL
144
144
  );
145
145
  `,
146
- // 6 — manual order in the rail.
146
+ // 6 — manual order in the session list.
147
147
  `
148
148
  -- NULL sorts on top, so a fresh database needs no backfill.
149
149
  ALTER TABLE session_state ADD COLUMN sort INTEGER;
@@ -246,7 +246,7 @@ const MIGRATIONS = [
246
246
  `
247
247
  UPDATE session_state SET pinned = 0;
248
248
  `,
249
- // 20 — sort becomes the rank in the working set the rail keeps on top; pinned goes.
249
+ // 20 — sort becomes the rank in the working set the session list keeps on top; pinned goes.
250
250
  `
251
251
  -- Pinned rows seed the set at rank -1, capped at the set's size (8).
252
252
  UPDATE session_state SET sort = -1 WHERE pinned = 1 AND sort IS NULL;
@@ -317,6 +317,37 @@ const MIGRATIONS = [
317
317
  created_at INTEGER NOT NULL,
318
318
  last_used_at INTEGER
319
319
  );
320
+ `,
321
+ // 28 — the continuous conversation's sessions in order (core/chain.ts); the
322
+ // head is the newest, and the transcripts are the record.
323
+ `
324
+ CREATE TABLE main_chain (
325
+ session_id TEXT PRIMARY KEY,
326
+ started_at INTEGER NOT NULL,
327
+ reason TEXT NOT NULL
328
+ );
329
+ `,
330
+ // 29 — runs by the session they run in: a session's role, asked on every open,
331
+ // every `pier task` call and every callback.
332
+ `
333
+ CREATE INDEX task_runs_target ON task_runs(json_extract(json, '$.targetSessionId'), queued_at);
334
+ `,
335
+ // 30 — a session the operator closed leaves the session list until a human speaks to it (web/session-state.ts).
336
+ `ALTER TABLE session_state ADD COLUMN closed INTEGER NOT NULL DEFAULT 0;`,
337
+ // 31 — the continuous conversation's open items, written from the head's
338
+ // `<open>`/`<done>` markers (tasks/open-items.ts); run_ids a JSON array.
339
+ `
340
+ CREATE TABLE open_items (
341
+ problem TEXT PRIMARY KEY,
342
+ stage TEXT NOT NULL,
343
+ run_ids TEXT NOT NULL,
344
+ updated_at INTEGER NOT NULL
345
+ );
346
+ `,
347
+ // 32 — the working set and the closed flag go; unread is all the workbench keeps.
348
+ `
349
+ ALTER TABLE session_state DROP COLUMN sort;
350
+ ALTER TABLE session_state DROP COLUMN closed;
320
351
  `,
321
352
  ];
322
353
  /** `BEGIN IMMEDIATE`: taking the write lock up front turns a race with another