@timqi/pier 0.1.0 → 0.1.1

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 (158) hide show
  1. package/README.md +58 -125
  2. package/dist/agent/config.js +6 -15
  3. package/dist/agent/credentials.js +11 -23
  4. package/dist/agent/events.js +42 -64
  5. package/dist/agent/listing.js +39 -92
  6. package/dist/agent/pi.js +103 -252
  7. package/dist/boards/boards.js +19 -29
  8. package/dist/channels/attach.js +14 -42
  9. package/dist/channels/chains.js +33 -37
  10. package/dist/channels/chunk.js +8 -28
  11. package/dist/channels/commands.js +3 -14
  12. package/dist/channels/config.js +33 -52
  13. package/dist/channels/control.js +4 -13
  14. package/dist/channels/conversations.js +8 -25
  15. package/dist/channels/dedup.js +8 -17
  16. package/dist/channels/gatekeeper.js +13 -23
  17. package/dist/channels/lark-api.js +23 -63
  18. package/dist/channels/lark-outbound.js +12 -44
  19. package/dist/channels/lark-panel.js +7 -23
  20. package/dist/channels/lark-render.js +18 -62
  21. package/dist/channels/lark.js +52 -141
  22. package/dist/channels/lines.js +13 -15
  23. package/dist/channels/panel.js +16 -36
  24. package/dist/channels/receipts.js +29 -52
  25. package/dist/channels/routes.js +3 -9
  26. package/dist/channels/runtime.js +12 -23
  27. package/dist/channels/slack-api.js +34 -86
  28. package/dist/channels/slack-directory.js +7 -23
  29. package/dist/channels/slack-outbound.js +12 -56
  30. package/dist/channels/slack-panel.js +4 -13
  31. package/dist/channels/slack-render.js +23 -91
  32. package/dist/channels/slack-tool.js +48 -171
  33. package/dist/channels/slack.js +73 -239
  34. package/dist/channels/telegram-api.js +8 -20
  35. package/dist/channels/telegram-panel.js +5 -21
  36. package/dist/channels/telegram-render.js +13 -40
  37. package/dist/channels/telegram.js +54 -146
  38. package/dist/channels/types.js +5 -16
  39. package/dist/cli.js +17 -41
  40. package/dist/config-sync.js +87 -4
  41. package/dist/core/hub.js +7 -20
  42. package/dist/core/identity.js +20 -59
  43. package/dist/core/inbound-file.js +15 -49
  44. package/dist/core/inbox.js +12 -34
  45. package/dist/core/queue.js +3 -5
  46. package/dist/core/reply.js +41 -142
  47. package/dist/core/router.js +209 -264
  48. package/dist/core/types.js +4 -0
  49. package/dist/db.js +88 -272
  50. package/dist/drain.js +57 -50
  51. package/dist/extensions/index.js +3 -11
  52. package/dist/extensions/web/anthropic.js +3 -9
  53. package/dist/extensions/web/artifacts.js +2 -5
  54. package/dist/extensions/web/content.js +4 -12
  55. package/dist/extensions/web/http.js +2 -6
  56. package/dist/extensions/web/language.js +8 -18
  57. package/dist/extensions/web/openai.js +1 -1
  58. package/dist/extensions/web/provider.js +5 -18
  59. package/dist/extensions/web/tools.js +19 -63
  60. package/dist/lock.js +98 -0
  61. package/dist/log.js +9 -26
  62. package/dist/main.js +84 -183
  63. package/dist/paths.js +10 -26
  64. package/dist/secrets.js +18 -45
  65. package/dist/service.js +31 -73
  66. package/dist/settings.js +19 -63
  67. package/dist/tasks/agent.js +24 -45
  68. package/dist/tasks/callbacks.js +8 -16
  69. package/dist/tasks/command.js +2 -6
  70. package/dist/tasks/definitions.js +39 -62
  71. package/dist/tasks/execution.js +41 -39
  72. package/dist/tasks/groups.js +8 -11
  73. package/dist/tasks/messages.js +88 -155
  74. package/dist/tasks/outbox.js +33 -54
  75. package/dist/tasks/routes.js +4 -7
  76. package/dist/tasks/runs.js +4 -9
  77. package/dist/tasks/service.js +29 -42
  78. package/dist/tasks/store.js +36 -27
  79. package/dist/tasks/tool.js +59 -60
  80. package/dist/tools-task.js +20 -60
  81. package/dist/tools.js +98 -325
  82. package/dist/update.js +20 -43
  83. package/dist/web/auth.js +118 -179
  84. package/dist/web/config-sync.js +2 -2
  85. package/dist/web/config.js +3 -7
  86. package/dist/web/explorer.js +10 -21
  87. package/dist/web/fs.js +20 -42
  88. package/dist/web/instance.js +35 -82
  89. package/dist/web/providers.js +5 -11
  90. package/dist/web/public/assets/{activity-Bl3vZukb.js → activity-B89_hH7q.js} +1 -1
  91. package/dist/web/public/assets/activity-B89_hH7q.js.br +0 -0
  92. package/dist/web/public/assets/activity-B89_hH7q.js.gz +0 -0
  93. package/dist/web/public/assets/{boards-DYuf4Mlj.js → boards-BeKW0ZXK.js} +1 -1
  94. package/dist/web/public/assets/boards-BeKW0ZXK.js.br +0 -0
  95. package/dist/web/public/assets/boards-BeKW0ZXK.js.gz +0 -0
  96. package/dist/web/public/assets/explorer-DIuMlaV3.js +4 -0
  97. package/dist/web/public/assets/explorer-DIuMlaV3.js.br +0 -0
  98. package/dist/web/public/assets/explorer-DIuMlaV3.js.gz +0 -0
  99. package/dist/web/public/assets/index-DzXDXra_.js +85 -0
  100. package/dist/web/public/assets/index-DzXDXra_.js.br +0 -0
  101. package/dist/web/public/assets/index-DzXDXra_.js.gz +0 -0
  102. package/dist/web/public/assets/index-eqQLVS8Q.css +2 -0
  103. package/dist/web/public/assets/index-eqQLVS8Q.css.br +0 -0
  104. package/dist/web/public/assets/index-eqQLVS8Q.css.gz +0 -0
  105. package/dist/web/public/assets/{runs-BLJu7EXN.js → runs-Cwy0mN8i.js} +1 -1
  106. package/dist/web/public/assets/runs-Cwy0mN8i.js.br +0 -0
  107. package/dist/web/public/assets/runs-Cwy0mN8i.js.gz +0 -0
  108. package/dist/web/public/assets/{settings-BrdVh-Zi.js → settings-DzZLmujq.js} +1 -1
  109. package/dist/web/public/assets/settings-DzZLmujq.js.br +0 -0
  110. package/dist/web/public/assets/settings-DzZLmujq.js.gz +0 -0
  111. package/dist/web/public/assets/{task-runs-CeQS1rxa.js → task-runs-BCakxFk8.js} +1 -1
  112. package/dist/web/public/assets/task-runs-BCakxFk8.js.br +0 -0
  113. package/dist/web/public/assets/task-runs-BCakxFk8.js.gz +0 -0
  114. package/dist/web/public/assets/tasks-BlzEbk11.js +4 -0
  115. package/dist/web/public/assets/tasks-BlzEbk11.js.br +0 -0
  116. package/dist/web/public/assets/tasks-BlzEbk11.js.gz +0 -0
  117. package/dist/web/public/index.html +30 -16
  118. package/dist/web/public/index.html.br +0 -0
  119. package/dist/web/public/index.html.gz +0 -0
  120. package/dist/web/public/sw.js +14 -2
  121. package/dist/web/public/sw.js.br +0 -0
  122. package/dist/web/public/sw.js.gz +0 -0
  123. package/dist/web/push.js +55 -77
  124. package/dist/web/route.js +3 -7
  125. package/dist/web/server.js +109 -190
  126. package/dist/web/session-state.js +13 -53
  127. package/dist/web/types.js +2 -4
  128. package/dist/web/webpush.js +10 -25
  129. package/docs/deploy.md +115 -330
  130. package/package.json +1 -1
  131. package/skills/pier-boards/SKILL.md +81 -160
  132. package/skills/pier-help/SKILL.md +23 -20
  133. package/skills/pier-slack/SKILL.md +2 -2
  134. package/skills/pier-tasks/SKILL.md +23 -15
  135. package/dist/config-sync-fetch.js +0 -84
  136. package/dist/limits.js +0 -14
  137. package/dist/web/public/assets/activity-Bl3vZukb.js.br +0 -0
  138. package/dist/web/public/assets/activity-Bl3vZukb.js.gz +0 -0
  139. package/dist/web/public/assets/boards-DYuf4Mlj.js.br +0 -0
  140. package/dist/web/public/assets/boards-DYuf4Mlj.js.gz +0 -0
  141. package/dist/web/public/assets/explorer-qJH_9nTE.js +0 -4
  142. package/dist/web/public/assets/explorer-qJH_9nTE.js.br +0 -0
  143. package/dist/web/public/assets/explorer-qJH_9nTE.js.gz +0 -0
  144. package/dist/web/public/assets/index-Dqdb-Eqt.js +0 -85
  145. package/dist/web/public/assets/index-Dqdb-Eqt.js.br +0 -0
  146. package/dist/web/public/assets/index-Dqdb-Eqt.js.gz +0 -0
  147. package/dist/web/public/assets/index-DzmMzvi_.css +0 -2
  148. package/dist/web/public/assets/index-DzmMzvi_.css.br +0 -0
  149. package/dist/web/public/assets/index-DzmMzvi_.css.gz +0 -0
  150. package/dist/web/public/assets/runs-BLJu7EXN.js.br +0 -0
  151. package/dist/web/public/assets/runs-BLJu7EXN.js.gz +0 -0
  152. package/dist/web/public/assets/settings-BrdVh-Zi.js.br +0 -0
  153. package/dist/web/public/assets/settings-BrdVh-Zi.js.gz +0 -0
  154. package/dist/web/public/assets/task-runs-CeQS1rxa.js.br +0 -0
  155. package/dist/web/public/assets/task-runs-CeQS1rxa.js.gz +0 -0
  156. package/dist/web/public/assets/tasks-bcb3fYdK.js +0 -4
  157. package/dist/web/public/assets/tasks-bcb3fYdK.js.br +0 -0
  158. package/dist/web/public/assets/tasks-bcb3fYdK.js.gz +0 -0
@@ -7,37 +7,25 @@ import { SenderPrefix, withPrefix } from "./identity.js";
7
7
  import { decide } from "./queue.js";
8
8
  import { splitReply } from "./reply.js";
9
9
  const log = logger("core");
10
- /** How long a session may sit idle in memory before it is let go. Generous on
11
- * purpose: eviction is a memory measure, and re-opening one costs a Pi
12
- * resume plus the transcript being read back. */
10
+ /** Generous: eviction is a memory measure, and re-opening costs a Pi resume
11
+ * plus a transcript read. */
13
12
  const IDLE_TTL_MS = 30 * 60_000;
14
- /** Sweep interval. Nothing here is urgent, so it is coarse. */
15
13
  const SWEEP_MS = 5 * 60_000;
16
14
  /** An error goes into a chat window, so it is trimmed to something readable. */
17
15
  const truncate = (message) => message.length > 600 ? `${message.slice(0, 600)}…` : message;
18
- /** What a chat window gets of a system input, and how much it may stand in
19
- * for. A task callback carries up to 8000 characters of result text
20
- * (tasks/callbacks.ts). */
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). */
21
18
  const NOTE_CHARS = 200;
22
19
  const NOTE_LINES = 4;
23
- /**
24
- * The head of a system input plus a count of what was left out.
25
- *
26
- * A note is *context* for the turn it precedes, not the message: pasted whole,
27
- * a run result buries the conversation it was meant to explain — and on IM
28
- * that is the one surface with no way to collapse it. The hub, the web
29
- * timeline and the Pi transcript keep every character.
30
- *
31
- * Cut on a line boundary and then on a word, so the head reads as text rather
32
- * than as a string that ran out.
33
- */
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. */
34
23
  function digest(text) {
35
24
  const body = text.trimEnd();
36
25
  let head = body.split("\n").slice(0, NOTE_LINES).join("\n");
37
26
  if (head.length > NOTE_CHARS) {
38
27
  const capped = head.slice(0, NOTE_CHARS);
39
- // Only a boundary in the second half is worth taking: cutting further back
40
- // than that loses more than the ragged edge was costing.
28
+ // A boundary before the midpoint loses more than the ragged edge costs.
41
29
  const boundary = Math.max(capped.lastIndexOf("\n"), capped.lastIndexOf(" "));
42
30
  head = capped.slice(0, boundary > NOTE_CHARS / 2 ? boundary : NOTE_CHARS);
43
31
  }
@@ -50,10 +38,8 @@ function digest(text) {
50
38
  function keyOf(key) {
51
39
  return `${key.channelId}:${key.conversationId}`;
52
40
  }
53
- /** `web:<id>` and `task:<id>` are two names for one session id, and neither is
54
- * a chat — no Channel is registered under them. So they share a lock in
55
- * `ensure`, and which of the two a session records costs nothing but the
56
- * answer to "what is it answering". */
41
+ /** `web:<id>` and `task:<id>` name one session id and neither is a chat — no
42
+ * Channel is registered under them — so they share a lock in `ensure`. */
57
43
  const isAlias = (key) => key.channelId === "web" || key.channelId === "task";
58
44
  export class QueueOperationError extends Error {
59
45
  reason;
@@ -65,55 +51,47 @@ export class QueueOperationError extends Error {
65
51
  export class Router {
66
52
  hub;
67
53
  resolve;
54
+ sessionIdOf;
68
55
  byKey = new Map();
69
56
  bySession = new Map();
70
- /** Resolves in flight, so two surfaces asking at once share one session
71
- * object instead of opening a second Pi runtime on the same transcript.
72
- * Web and task keys collapse to the session id they both name. */
57
+ /** Resolves in flight: two surfaces asking at once must share one session
58
+ * object, not open a second Pi runtime on the same transcript. */
73
59
  opening = new Map();
74
60
  channels = new Map();
75
61
  /** Who each session last heard from, so a header costs tokens only on news. */
76
62
  senders = new SenderPrefix();
77
- /** Set by a graceful restart (src/drain.ts). Usually never unset, because
78
- * the process exits when the drain ends — `endDrain` exists for the one
79
- * caller that drains *speculatively* and may not get to exit. */
63
+ /** Set by a graceful restart (src/drain.ts); `endDrain` is for the caller
64
+ * that drains speculatively and may not get to exit. */
80
65
  draining = false;
81
66
  spokenTo;
82
67
  constructor(hub,
83
68
  /** Create or resume the session owning a conversation (wired in main.ts). */
84
- resolve) {
69
+ resolve,
70
+ /** The durable chat → session mapping, read before opening: a chat and the
71
+ * workbench asking for one transcript must share one lock and one object.
72
+ * Undefined for a chat that has none yet. */
73
+ sessionIdOf = () => undefined) {
85
74
  this.hub = hub;
86
75
  this.resolve = resolve;
76
+ this.sessionIdOf = sessionIdOf;
87
77
  }
88
78
  registerChannel(channel) {
89
79
  this.channels.set(channel.id, channel);
90
80
  }
91
- /** Told which session a human just spoke to — every message from a chat or
92
- * the workbench passes through `dispatch`, and nothing a task or a subagent
93
- * starts does. Registered rather than a constructor argument: the one
94
- * listener is the rail's working set (web/session-state.ts), built with the
95
- * web surface long after the router. */
81
+ /** Fires for humans only: chats and the workbench pass through `dispatch`,
82
+ * tasks and subagents do not. Registered late because the listener
83
+ * (web/session-state.ts) is built with the web surface. */
96
84
  onSpokenTo(listener) {
97
85
  this.spokenTo = listener;
98
86
  }
99
- /**
100
- * Tell the conversation something went wrong, then the event stream.
101
- *
102
- * Applies to every channel, because the failure mode is the same everywhere:
103
- * an IM user watching the eyes come off with no reply cannot tell a crash
104
- * from a deliberate silence, and the operator cannot debug what they never
105
- * saw. The web reads errors off the hub already, so only a registered channel
106
- * gets a note; `notify` is used rather than `send` so it is never mistaken
107
- * for an assistant turn.
108
- */
87
+ /** A failure reaches the chat as well as the hub (§5): on IM, silence is
88
+ * indistinguishable from a crash. `notify`, not `send`, so it is never
89
+ * mistaken for an assistant turn. */
109
90
  report(sessionId, key, message) {
110
- // Three surfaces, one failure: the chat that is waiting, the web timeline,
111
- // and the log the operator greps once it is reported to them.
112
91
  log.error(`${keyOf(key)} session ${sessionId}: ${message}`);
113
92
  this.hub.emit(sessionId, { type: "error", message });
114
93
  const channel = this.channels.get(key.channelId);
115
- // Best-effort and never recursive: if telling the chat also fails, the hub
116
- // already has the original.
94
+ // Never recursive: if telling the chat also fails, the hub has the original.
117
95
  channel?.notify(key.conversationId, { text: truncate(message), origin: { kind: "error" } })
118
96
  .catch((err) => {
119
97
  log.error(`could not report the failure to ${key.channelId}`, err);
@@ -123,13 +101,8 @@ export class Router {
123
101
  });
124
102
  });
125
103
  }
126
- /**
127
- * Report something that happened *to* a session rather than in it: a task
128
- * result that could not be delivered, say. Its conversation is told when one
129
- * is attached — an agent that was promised an answer and a human watching the
130
- * same thread learn it is not coming from the same place they were waiting.
131
- * Otherwise the hub carries it for the web timeline.
132
- */
104
+ /** Something that happened *to* a session (an undeliverable task result): the
105
+ * attached conversation is told where it was waiting, else the hub carries it. */
133
106
  reportTo(sessionId, message) {
134
107
  const key = this.conversationOf(sessionId);
135
108
  if (key)
@@ -137,36 +110,32 @@ export class Router {
137
110
  else
138
111
  this.hub.emit(sessionId, { type: "error", message });
139
112
  }
140
- /**
141
- * Let go of every session that has been idle too long, so a process serving
142
- * IM threads and task runs for weeks does not hold one live Pi runtime per
143
- * conversation it ever saw. Only the in-memory attachment goes: the durable
144
- * conversation → session mapping stays, so the next message resumes the very
145
- * same transcript (channels/conversations.ts).
146
- *
147
- * Skipped for anything that would notice: a streaming turn, and a session
148
- * someone is still watching over SSE.
149
- *
150
- * `includeWatched` is the one caller that may take a watched session too:
151
- * configuration a session reads only when it opens has just changed, and the
152
- * session most likely to need it is the one open in the tab that changed it.
153
- * A turn in flight is still never touched — the exemption that stands is the
154
- * one about interrupting work, not the one about being looked at.
155
- */
113
+ /** Only the in-memory attachment goes; the durable mapping
114
+ * (channels/conversations.ts) resumes the same transcript on the next message.
115
+ * `includeWatched` is for config a session reads only at open: the session
116
+ * most likely to need it is the one open in the tab that changed it. A
117
+ * streaming turn is never evicted, nor a session holding queued messages:
118
+ * Pi's queue lives only in the runtime, so disposing it would drop them. */
156
119
  async evictIdle(ttlMs = IDLE_TTL_MS, now = Date.now(), { includeWatched = false } = {}) {
157
120
  let evicted = 0;
158
- // Snapshot on purpose: this loop awaits dispose(), so another turn may
159
- // attach or drop a session while it is suspended.
121
+ // Snapshot: the loop awaits dispose(), and the map may change meanwhile.
160
122
  // oxlint-disable-next-line unicorn/no-useless-spread
161
123
  for (const [id, attached] of [...this.bySession]) {
162
- if (attached.session.state === "streaming")
163
- continue;
164
124
  if (this.queueOperations.has(id) || this.recoveries.get(id)?.some((b) => b.status === "submitting"))
165
125
  continue;
166
126
  if (!includeWatched && this.hub.hasSubscribers(id))
167
127
  continue;
168
128
  if (now - attached.activeAt < ttlMs)
169
129
  continue;
130
+ const touched = attached.touched;
131
+ const queued = await attached.session.pendingQueue();
132
+ // Everything re-read after the await: a dispatch, a queue operation or a
133
+ // replacement may have landed meanwhile, and a prompt already accepted
134
+ // must not run against a disposed session.
135
+ if (this.bySession.get(id) !== attached || attached.touched !== touched || this.queueOperations.has(id))
136
+ continue;
137
+ if (attached.session.state === "streaming" || queued.steering.length || queued.followUp.length)
138
+ continue;
170
139
  this.bySession.delete(id);
171
140
  this.forgetKeys(attached.session);
172
141
  attached.unsubscribe();
@@ -174,8 +143,7 @@ export class Router {
174
143
  this.hub.dropReplay(id);
175
144
  evicted += 1;
176
145
  log.info(`evicted idle session ${id} (${keyOf(attached.key)})`);
177
- // Best-effort: a runtime that will not shut down must not keep the
178
- // sweeper from releasing the rest.
146
+ // A runtime that will not shut down must not block releasing the rest.
179
147
  await attached.session.dispose().catch((err) => log.error(`disposing session ${id} failed`, err));
180
148
  }
181
149
  return evicted;
@@ -199,18 +167,12 @@ export class Router {
199
167
  modelOf(sessionId) {
200
168
  return this.bySession.get(sessionId)?.session.model;
201
169
  }
202
- /**
203
- * Which conversation a session is answering, if any. The inverse of
204
- * `sessionOf`, and what lets a tool act on "here" — an agent reached through
205
- * a Slack thread otherwise has no way to name the thread it is replying in.
206
- * A task or subagent session is attached to nothing and answers undefined.
207
- */
170
+ /** Inverse of `sessionOf`; lets a tool act on "here". A task or subagent
171
+ * session is attached to nothing and answers undefined. */
208
172
  conversationOf(sessionId) {
209
173
  return this.bySession.get(sessionId)?.key;
210
174
  }
211
- /** Every key that points at this session object. One session can be reached
212
- * under more than one — `web:<id>` and `task:<id>` name the same session —
213
- * and a key left behind hands out a session that is no longer live. */
175
+ /** Every alias: a key left behind hands out a session that is no longer live. */
214
176
  forgetKeys(session) {
215
177
  for (const [key, held] of this.byKey)
216
178
  if (held === session)
@@ -225,96 +187,109 @@ export class Router {
225
187
  return;
226
188
  }
227
189
  if (existing) {
228
- // Two live objects on one transcript: both would write it and both would
229
- // answer the chat. Single-flight `ensure` closes the race that makes
230
- // this, so reaching here is a bug worth seeing — the replaced one is
231
- // silenced and unreachable, rather than left answering under aliases
232
- // nobody knows are stale.
190
+ // Two live objects on one transcript would both write it and both answer
191
+ // the chat; single-flight `ensure` should make this unreachable.
233
192
  log.warn(`session ${session.id} replaced while attached to ${keyOf(existing.key)}`);
234
193
  existing.unsubscribe();
235
194
  this.forgetKeys(existing.session);
195
+ void existing.session.dispose().catch((err) => log.error(`disposing replaced session ${session.id} failed`, err));
236
196
  }
237
197
  this.byKey.set(keyOf(key), session);
238
198
  log.info(`attached ${keyOf(key)} → session ${session.id}`);
239
- const unsubscribe = session.subscribe((payload) => {
240
- this.hub.emit(session.id, payload);
241
- // Run state is workspace-visible: every client's session list shows it.
242
- if (payload.type === "state") {
243
- const attached = this.bySession.get(session.id);
244
- // Every turn passes through here, so this is also where a session
245
- // proves to the sweeper that it is still in use.
246
- if (attached)
247
- attached.stateSince = attached.activeAt = Date.now();
248
- this.hub.emitWorkspace({
249
- type: "session-state",
250
- sessionId: session.id,
251
- state: payload.state,
252
- });
253
- }
254
- // A title the session gave itself: every list reads the transcript, so
255
- // the same re-list a rename route broadcasts.
256
- if (payload.type === "renamed")
257
- this.hub.emitWorkspace({ type: "sessions-changed" });
258
- // An error the session itself reported (a tool that threw, a model
259
- // refusal, a lost connection). Without this it lands only in the web
260
- // timeline and the IM side goes quiet for no visible reason.
261
- if (payload.type === "error") {
262
- log.error(`${keyOf(key)} session ${session.id} reported: ${payload.message}`);
263
- const channel = this.channels.get(key.channelId);
264
- channel?.notify(key.conversationId, {
265
- text: truncate(payload.message),
266
- origin: { kind: "error" },
267
- }).catch((err) => log.error(`notify ${key.channelId} failed`, err));
268
- }
269
- // A system input is context the chat did not see being typed. It goes
270
- // out before the turn it triggers, so the answer has a visible cause.
271
- if (payload.type === "system-input") {
272
- const channel = this.channels.get(key.channelId);
273
- // A digest, not the input: the hub emit above is what carries it whole.
274
- channel?.notify(key.conversationId, { text: digest(payload.text), origin: payload.origin })
275
- .catch((err) => {
276
- log.error(`notify ${key.channelId} failed`, err);
277
- this.hub.emit(session.id, {
278
- type: "error",
279
- message: `notify ${key.channelId} failed: ${String(err)}`,
280
- });
281
- });
282
- }
283
- // A queued message with no turn left to deliver it. `decide` reads the
284
- // state once, so a steer chosen against a turn that ends before the call
285
- // lands sits in Pi's queue until some *later* turn reads it — on IM that
286
- // is indistinguishable from the message never arriving (§5b). Pi drains
287
- // its own queues up to the agent_end handler, so a non-empty queue on an
288
- // idle session is exactly the message that missed that window.
289
- if (payload.type === "queue-state" && (payload.steering.length || payload.followUp.length)) {
290
- this.promoteQueued(session);
291
- }
292
- // Every turn-end reaches the channel, empty text included: an adapter's
293
- // per-turn UI (Telegram's 👀 receipts) is retired here, and a turn that
294
- // settled with nothing to say still has to settle.
295
- if (payload.type === "turn-end") {
296
- log.info(`turn end ${keyOf(key)} session ${session.id}: ${String(payload.text.length)} chars`);
297
- const channel = this.channels.get(key.channelId);
298
- if (channel) {
299
- channel.send(key.conversationId, splitReply(payload.text, payload.meta)).catch((err) => {
300
- this.report(session.id, key, `outbound to ${key.channelId} failed: ${String(err)}`);
301
- });
302
- }
303
- }
304
- });
305
- this.bySession.set(session.id, {
199
+ // Delivery reads `attached.key` live: a chat attaching after the workbench
200
+ // opened the session takes over (`reached`), and the closure must follow.
201
+ const attached = {
306
202
  session,
307
203
  key,
308
204
  stateSince: Date.now(),
309
205
  activeAt: Date.now(),
310
- unsubscribe,
206
+ touched: 0,
207
+ unsubscribe: session.subscribe((payload) => {
208
+ const key = attached.key;
209
+ this.hub.emit(session.id, payload);
210
+ if (payload.type === "state") {
211
+ // Every turn passes here, so it also proves liveness to the sweeper.
212
+ attached.stateSince = attached.activeAt = Date.now();
213
+ attached.touched += 1;
214
+ this.hub.emitWorkspace({
215
+ type: "session-state",
216
+ sessionId: session.id,
217
+ state: payload.state,
218
+ });
219
+ }
220
+ if (payload.type === "renamed")
221
+ this.hub.emitWorkspace({ type: "sessions-changed" });
222
+ // Without this a session-reported error lands only in the web timeline
223
+ // and the IM side goes quiet for no visible reason.
224
+ if (payload.type === "error") {
225
+ log.error(`${keyOf(key)} session ${session.id} reported: ${payload.message}`);
226
+ const channel = this.channels.get(key.channelId);
227
+ channel?.notify(key.conversationId, {
228
+ text: truncate(payload.message),
229
+ origin: { kind: "error" },
230
+ }).catch((err) => log.error(`notify ${key.channelId} failed`, err));
231
+ }
232
+ // Context the chat did not see typed goes out before the turn it
233
+ // triggers, so the answer has a visible cause. The hub carries it whole.
234
+ if (payload.type === "system-input") {
235
+ const channel = this.channels.get(key.channelId);
236
+ channel?.notify(key.conversationId, { text: digest(payload.text), origin: payload.origin })
237
+ .catch((err) => {
238
+ log.error(`notify ${key.channelId} failed`, err);
239
+ this.hub.emit(session.id, {
240
+ type: "error",
241
+ message: `notify ${key.channelId} failed: ${String(err)}`,
242
+ });
243
+ });
244
+ }
245
+ // A steer chosen against a turn that ended before the call landed sits in
246
+ // Pi's queue until some later turn — on IM, a message that never arrived
247
+ // (§5). A non-empty queue on an idle session is exactly that case.
248
+ if (payload.type === "queue-state" && (payload.steering.length || payload.followUp.length)) {
249
+ this.promoteQueued(session);
250
+ }
251
+ // Empty text included: adapters retire per-turn UI (👀 receipts) on it.
252
+ if (payload.type === "turn-end") {
253
+ log.info(`turn end ${keyOf(key)} session ${session.id}: ${String(payload.text.length)} chars`);
254
+ const channel = this.channels.get(key.channelId);
255
+ if (channel) {
256
+ const reply = splitReply(payload.text, payload.meta);
257
+ this.deliver(session, key, () => channel.send(key.conversationId, reply))
258
+ .catch((err) => {
259
+ this.report(session.id, key, `outbound to ${key.channelId} failed: ${String(err)}`);
260
+ });
261
+ }
262
+ }
263
+ }),
264
+ };
265
+ this.bySession.set(session.id, attached);
266
+ }
267
+ /** An adapter's send is several platform calls (chunks, then attachments),
268
+ * so two answers left to overlap interleave in the chat. Per conversation:
269
+ * a slow chat may not hold up another. Also what `busy` counts as still
270
+ * sending: a finished turn is not delivered until the adapter says so. */
271
+ delivering = new Map();
272
+ deliver(session, key, send) {
273
+ const id = keyOf(key);
274
+ const pending = this.delivering.get(id)?.settled;
275
+ // The async wrapper turns a synchronous throw into this reply's rejection.
276
+ const done = pending ? pending.then(send) : (async () => send())();
277
+ // A rejection is the caller's to report; inherited, it would fail every
278
+ // later reply to this conversation.
279
+ const settled = done.catch(() => { });
280
+ this.delivering.set(id, { session, key, settled });
281
+ // Only the tail clears the slot — a newer reply owns it by then.
282
+ void settled.then(() => {
283
+ if (this.delivering.get(id)?.settled === settled)
284
+ this.delivering.delete(id);
311
285
  });
286
+ return done;
312
287
  }
313
288
  queueOperations = new Set();
314
289
  promotionRequested = new Set();
315
290
  recoveries = new Map();
316
- // Keep the latest failed batch ID so a clear cannot erase a newer rejection
317
- // that arrived while it awaited the backend. ACK affects only the copy.
291
+ // Latest failed batch id, so a clear cannot erase a newer rejection that
292
+ // arrived while it awaited the backend.
318
293
  uncertaintyHeld = new Map();
319
294
  queueUncertain(sessionId) {
320
295
  return this.uncertaintyHeld.has(sessionId);
@@ -340,8 +315,8 @@ export class Router {
340
315
  this.promotionRequested.delete(sessionId);
341
316
  this.recoveryChanged(sessionId);
342
317
  }
343
- /** Only queue mutation owns this lock. A model turn owns its batch, not the
344
- * next queue: manual controls remain available while that turn runs. */
318
+ /** Only queue mutation holds this lock; a running turn does not, so manual
319
+ * controls stay available while it runs. */
345
320
  async useQueue(sessionId, action, key = { channelId: "web", conversationId: sessionId }) {
346
321
  if (this.queueOperations.has(sessionId))
347
322
  throw new QueueOperationError("busy", "Queue operation in progress");
@@ -404,8 +379,8 @@ export class Router {
404
379
  if (mode === "restart")
405
380
  await this.abort(sessionId);
406
381
  this.checkQueueDrain();
407
- // Already headed at original dispatch. Calling dispatch again would
408
- // discard rejection and could attribute these words to the operator.
382
+ // Not via dispatch: the text was headed at original dispatch, and a
383
+ // second pass could attribute these words to the operator.
409
384
  invoked = true;
410
385
  const submitted = mode === "steer" && session.state === "streaming"
411
386
  ? session.steer(text) : session.prompt(text);
@@ -428,16 +403,9 @@ export class Router {
428
403
  throw err;
429
404
  }
430
405
  }
431
- /**
432
- * Turn a stranded queue into the turn it was waiting for. Not routed through
433
- * `dispatch`: the text was prefixed when it was first dispatched
434
- * (identity.ts), and sending it back through would head it a second time.
435
- *
436
- * Only ever reached from a queue-state event, never from a turn ending: Pi
437
- * leaves the queue alone on `abort()`, so promoting on idle would make /stop
438
- * start the very turn it was asked to stop. Recovering *those* messages stays
439
- * the web's recall route, which hands them back to the composer.
440
- */
406
+ /** Only from a queue-state event, never from a turn ending: Pi leaves the
407
+ * queue alone on `abort()`, so promoting on idle would make /stop start the
408
+ * very turn it was asked to stop. */
441
409
  promoteQueued(session) {
442
410
  if (session.state !== "idle")
443
411
  return;
@@ -449,7 +417,6 @@ export class Router {
449
417
  queueMicrotask(() => {
450
418
  if (!this.promotionRequested.has(sessionId) || this.queueOperations.has(sessionId))
451
419
  return;
452
- // A queue event followed by rejection might describe the same input.
453
420
  // Retained failures require a human decision, not an automatic resend.
454
421
  if (this.queueUncertain(sessionId) || this.recoveries.get(sessionId)?.length)
455
422
  return;
@@ -472,17 +439,9 @@ export class Router {
472
439
  this.promotionRequested.delete(sessionId);
473
440
  }
474
441
  }
475
- /**
476
- * Drop what this session was told about who is speaking, so the next message
477
- * carries a full header again.
478
- *
479
- * For the surfaces that take a prefixed message back *out* of the context it
480
- * was counted into — a recalled queue, a rewound turn. The tracker's whole
481
- * job is "the model has already been told" (identity.ts), and a header that
482
- * never reached the model, or reached it and was then rewound away, makes
483
- * every later message from that speaker unattributed in a group chat. One
484
- * redundant header is the same price eviction already pays.
485
- */
442
+ /** For surfaces that take a prefixed message back out of the context it was
443
+ * counted into (recalled queue, rewound turn): the tracker means "the model
444
+ * has been told" (identity.ts), and here it has not. */
486
445
  forgetSender(sessionId) {
487
446
  this.senders.forget(sessionId);
488
447
  }
@@ -490,21 +449,17 @@ export class Router {
490
449
  beginDrain() {
491
450
  this.draining = true;
492
451
  }
493
- /** Take work again. The auto-updater closes the gate *before* handing over,
494
- * so a turn cannot slip in behind the idle check — and when the handover
495
- * never happens, a Pier left refusing every message forever would be a far
496
- * worse outcome than the race it was avoiding. */
452
+ /** The auto-updater closes the gate before handing over; when the handover
453
+ * never happens, refusing every message forever is the worse outcome. */
497
454
  endDrain() {
498
455
  this.draining = false;
499
456
  }
500
- /** For surfaces that mutate state before dispatching (the web's edit and
501
- * queue-deliver routes): ask first, so a refused dispatch cannot cost a
502
- * rewound transcript or a cleared queue. */
457
+ /** For surfaces that mutate state before dispatching (edit, queue-deliver):
458
+ * a refused dispatch must not cost a rewound transcript or a cleared queue. */
503
459
  isDraining() {
504
460
  return this.draining;
505
461
  }
506
- /** The drain gate, throwing. Told to the chat directly (5b): an adapter's
507
- * dispatch catch only logs, and the web caller gets the throw. */
462
+ /** Told to the chat directly (§5): an adapter's dispatch catch only logs. */
508
463
  refuseDraining(key) {
509
464
  const message = "Pier is restarting — this message was not taken; send it again in a moment.";
510
465
  this.channels.get(key.channelId)
@@ -512,17 +467,25 @@ export class Router {
512
467
  .catch((err) => log.error(`could not report the drain to ${key.channelId}`, err));
513
468
  throw new Error(message);
514
469
  }
515
- /** Attached sessions still mid-turn — what the drain waits on, and what its
516
- * deadline snapshots into the ledger. */
470
+ /** Attached sessions still mid-turn, and conversations whose answer is still
471
+ * going out (`sending`) — what the drain waits on, and what its deadline
472
+ * writes into the ledger. */
517
473
  busy() {
518
- return [...this.bySession.values()]
519
- .filter((attached) => attached.session.state === "streaming")
520
- .map((attached) => ({ session: attached.session, key: attached.key }));
521
- }
522
- /**
523
- * The session already attached to a conversation, if any. Never creates one:
524
- * a channel's stop or settings command must not be what opens a session.
525
- */
474
+ return [
475
+ ...[...this.bySession.values()]
476
+ .filter((attached) => attached.session.state === "streaming")
477
+ .map((attached) => ({ session: attached.session, key: attached.key })),
478
+ ...[...this.delivering.values()]
479
+ .map(({ session, key }) => ({ session, key, sending: true })),
480
+ ];
481
+ }
482
+ /** Every attached session, mid-turn or not — what the drain snapshots: Pi's
483
+ * queue lives only in the runtime, so an idle session's queued messages die
484
+ * with the process just the same. */
485
+ attachedSessions() {
486
+ return [...this.bySession.values()].map(({ session, key }) => ({ session, key }));
487
+ }
488
+ /** Never creates one: a stop or settings command must not open a session. */
526
489
  sessionOf(key) {
527
490
  return this.byKey.get(keyOf(key));
528
491
  }
@@ -531,55 +494,46 @@ export class Router {
531
494
  if (session)
532
495
  await this.abort(session.id);
533
496
  }
534
- /** Session owning a conversation, resolving and attaching it on first use. */
497
+ /** Session owning a conversation, resolving and attaching it on first use.
498
+ * One object per session id whichever key asks first: an IM key is looked up
499
+ * to its session id so it shares the lock with `web:`/`task:` aliases. */
535
500
  async ensure(key) {
536
501
  let session = this.byKey.get(keyOf(key));
537
- // Web and task conversation ids are session ids. Reuse an attached
538
- // instance so two surfaces never open the same Pi transcript twice.
539
- if (!session && isAlias(key)) {
540
- session = this.bySession.get(key.conversationId)?.session;
541
- if (session)
542
- this.byKey.set(keyOf(key), session);
543
- }
502
+ if (session)
503
+ return this.reached(session, key);
504
+ const id = isAlias(key) ? key.conversationId : this.sessionIdOf(key);
505
+ session = id === undefined ? undefined : this.bySession.get(id)?.session;
544
506
  if (!session) {
545
- // Aliases share one lock: web:<id> and task:<id> must not each open one.
546
- const lock = isAlias(key) ? `session:${key.conversationId}` : keyOf(key);
507
+ const lock = id === undefined ? keyOf(key) : `session:${id}`;
547
508
  const inflight = this.opening.get(lock);
548
- // A second caller rides the first one's resolve — which attaches before
549
- // this continuation runs, having awaited it first — and registers its own
550
- // key against the session that came back.
551
509
  if (inflight) {
552
510
  session = await inflight;
553
- this.byKey.set(keyOf(key), session);
554
- return this.reached(session, key);
555
- }
556
- try {
557
- // Inside the try: a resolver that throws synchronously is the same
558
- // failure as one that rejects, and reports the same way.
559
- const opening = this.resolve(key);
560
- this.opening.set(lock, opening);
561
- session = await opening;
562
511
  }
563
- catch (err) {
564
- this.unopened(key, err);
565
- throw err;
566
- }
567
- finally {
568
- this.opening.delete(lock);
512
+ else {
513
+ try {
514
+ // Inside the try: a synchronous throw must report like a rejection.
515
+ const opening = this.resolve(key);
516
+ this.opening.set(lock, opening);
517
+ session = await opening;
518
+ }
519
+ catch (err) {
520
+ this.unopened(key, err);
521
+ throw err;
522
+ }
523
+ finally {
524
+ this.opening.delete(lock);
525
+ }
569
526
  }
570
- this.attach(key, session);
571
527
  }
572
- return this.reached(session, key);
528
+ // Attaches fresh, or adds this key to the object already attached.
529
+ this.attach(key, session);
530
+ return session;
573
531
  }
574
- /** A session that would not open has no event stream of its own to report on
575
- * — unless its id is what we were asked for, which is what a web or task key
576
- * is. Otherwise the chat that is waiting is told directly. Callers still get
577
- * the rejection; this is only so the waiting side is not left with nothing. */
532
+ /** A web or task key names the session's own stream; an IM key names a chat
533
+ * that is waiting. Either way the waiting side is not left with nothing. */
578
534
  unopened(key, err) {
579
535
  log.error(`could not open a session for ${keyOf(key)}`, err);
580
536
  const message = truncate(`could not open a session: ${String(err)}`);
581
- // A web or task key names the session that would not open, so its own
582
- // stream is where the waiting surface is looking; an IM key names a chat.
583
537
  if (key.channelId === "web" || key.channelId === "task") {
584
538
  this.reportTo(key.conversationId, message);
585
539
  return;
@@ -588,45 +542,36 @@ export class Router {
588
542
  ?.notify(key.conversationId, { text: message, origin: { kind: "error" } })
589
543
  .catch((e) => log.error(`could not report it to ${key.channelId}`, e));
590
544
  }
591
- /** Reached for, so not idle — every surface that uses a session comes
592
- * through `ensure`, including the ones that only read it.
593
- *
594
- * Also where a session learns which of its two aliases is current: a task
595
- * callback (tasks/outbox.ts) opens a workbench session under `task:<id>`
596
- * whenever nothing had it attached, and the key from that first attach used
597
- * to stand forever — so the workbench's own next turn was still "a task",
598
- * and the notification for it (web/push.ts) was never sent. A chat key is
599
- * never overwritten: that one is also where turn-ends are delivered. */
545
+ /** Also where a session learns which key is current: a task callback
546
+ * attaches under `task:<id>`, and the workbench's next turn must not still
547
+ * read as "a task" (web/push.ts). A chat outranks an alias — the workbench
548
+ * may have opened the session, but its turns are answered in the chat — and
549
+ * an alias never overwrites a chat. */
600
550
  reached(session, key) {
601
551
  const attached = this.bySession.get(session.id);
602
552
  if (!attached)
603
553
  return session;
604
554
  attached.activeAt = Date.now();
605
- if (isAlias(key) && isAlias(attached.key))
555
+ attached.touched += 1;
556
+ if (!isAlias(key) || isAlias(attached.key))
606
557
  attached.key = key;
607
558
  return session;
608
559
  }
609
560
  async dispatch(msg) {
610
- // Before ensure — a drain must not be what opens a session …
561
+ // Before ensure — a drain must not open a session — and after, for a
562
+ // dispatch that was inside a slow ensure when the gate closed.
611
563
  if (this.draining)
612
564
  this.refuseDraining(msg.key);
613
565
  const session = await this.ensure(msg.key);
614
- // … and after — a dispatch that was inside a slow ensure when the gate
615
- // closed must not start the turn the drain just declared finished with.
616
566
  if (this.draining)
617
567
  this.refuseDraining(msg.key);
618
568
  this.spokenTo?.(session.id);
619
569
  const { action, text } = decide(msg, session.state);
620
- // A group chat is many people talking into one session; without a speaker
621
- // line the agent cannot tell them apart or mention anyone back. Emitted
622
- // only when the speaker or the clock says something new.
623
570
  const prompt = withPrefix(this.senders.next(session.id, msg.sender), text);
624
571
  log.debug(`${action} ${keyOf(msg.key)} → session ${session.id} (${String(prompt.length)} chars)`);
625
- // Turn outcomes flow through the event stream; a rejected call surfaces
626
- // there too, never as a thrown exception across the seam.
572
+ // A rejected call surfaces on the event stream, never as a throw across the seam.
627
573
  session[action](prompt).catch((err) => {
628
- // The header was counted as delivered a line above; this message never
629
- // arrived, so the next one from this speaker must carry it again.
574
+ // The header was counted as delivered above; it never arrived.
630
575
  this.senders.forget(session.id);
631
576
  this.report(session.id, msg.key, String(err));
632
577
  });