@timqi/pier 0.0.29 → 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 (165) hide show
  1. package/README.md +58 -125
  2. package/dist/agent/config.js +24 -11
  3. package/dist/agent/credentials.js +11 -23
  4. package/dist/agent/events.js +43 -62
  5. package/dist/agent/listing.js +113 -68
  6. package/dist/agent/pi.js +204 -211
  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 +8 -24
  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 +13 -35
  45. package/dist/core/queue.js +3 -5
  46. package/dist/core/reply.js +41 -142
  47. package/dist/core/router.js +209 -260
  48. package/dist/core/types.js +17 -1
  49. package/dist/db.js +98 -252
  50. package/dist/drain.js +58 -51
  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 +6 -14
  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 +87 -179
  63. package/dist/paths.js +10 -26
  64. package/dist/secrets.js +19 -46
  65. package/dist/service.js +33 -75
  66. package/dist/settings.js +42 -65
  67. package/dist/tasks/agent.js +129 -114
  68. package/dist/tasks/callbacks.js +9 -19
  69. package/dist/tasks/command.js +29 -14
  70. package/dist/tasks/definitions.js +39 -62
  71. package/dist/tasks/execution.js +46 -42
  72. package/dist/tasks/groups.js +41 -35
  73. package/dist/tasks/messages.js +121 -182
  74. package/dist/tasks/outbox.js +61 -55
  75. package/dist/tasks/routes.js +5 -11
  76. package/dist/tasks/runs.js +14 -13
  77. package/dist/tasks/service.js +53 -54
  78. package/dist/tasks/store.js +53 -30
  79. package/dist/tasks/tool.js +132 -61
  80. package/dist/tools-task.js +20 -60
  81. package/dist/tools.js +100 -327
  82. package/dist/update.js +21 -44
  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 +45 -85
  89. package/dist/web/providers.js +14 -13
  90. package/dist/web/public/assets/{activity-D3m4L2IL.js → activity-B89_hH7q.js} +2 -2
  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-BeKW0ZXK.js +1 -0
  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-Cwy0mN8i.js +1 -0
  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-DzZLmujq.js +5 -0
  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-BCakxFk8.js +3 -0
  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 +100 -130
  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/manifest.webmanifest +2 -2
  121. package/dist/web/public/manifest.webmanifest.br +0 -0
  122. package/dist/web/public/manifest.webmanifest.gz +0 -0
  123. package/dist/web/public/sw.js +14 -2
  124. package/dist/web/public/sw.js.br +0 -0
  125. package/dist/web/public/sw.js.gz +0 -0
  126. package/dist/web/push.js +55 -77
  127. package/dist/web/route.js +3 -7
  128. package/dist/web/server.js +131 -180
  129. package/dist/web/session-state.js +14 -54
  130. package/dist/web/types.js +2 -4
  131. package/dist/web/webpush.js +10 -25
  132. package/docs/deploy.md +115 -330
  133. package/package.json +2 -1
  134. package/skills/pier-boards/SKILL.md +81 -160
  135. package/skills/pier-help/SKILL.md +23 -20
  136. package/skills/pier-slack/SKILL.md +2 -2
  137. package/skills/pier-tasks/SKILL.md +153 -160
  138. package/dist/config-sync-fetch.js +0 -84
  139. package/dist/limits.js +0 -14
  140. package/dist/web/public/assets/activity-D3m4L2IL.js.br +0 -0
  141. package/dist/web/public/assets/activity-D3m4L2IL.js.gz +0 -0
  142. package/dist/web/public/assets/boards-BIObcQeX.js +0 -1
  143. package/dist/web/public/assets/boards-BIObcQeX.js.br +0 -0
  144. package/dist/web/public/assets/boards-BIObcQeX.js.gz +0 -0
  145. package/dist/web/public/assets/explorer-C_rSWPNB.js +0 -4
  146. package/dist/web/public/assets/explorer-C_rSWPNB.js.br +0 -0
  147. package/dist/web/public/assets/explorer-C_rSWPNB.js.gz +0 -0
  148. package/dist/web/public/assets/index-CX3fYZY5.css +0 -2
  149. package/dist/web/public/assets/index-CX3fYZY5.css.br +0 -0
  150. package/dist/web/public/assets/index-CX3fYZY5.css.gz +0 -0
  151. package/dist/web/public/assets/index-uFsZkKOQ.js +0 -85
  152. package/dist/web/public/assets/index-uFsZkKOQ.js.br +0 -0
  153. package/dist/web/public/assets/index-uFsZkKOQ.js.gz +0 -0
  154. package/dist/web/public/assets/runs-Ch6DZq6O.js +0 -1
  155. package/dist/web/public/assets/runs-Ch6DZq6O.js.br +0 -0
  156. package/dist/web/public/assets/runs-Ch6DZq6O.js.gz +0 -0
  157. package/dist/web/public/assets/settings-BWcEIEcv.js +0 -5
  158. package/dist/web/public/assets/settings-BWcEIEcv.js.br +0 -0
  159. package/dist/web/public/assets/settings-BWcEIEcv.js.gz +0 -0
  160. package/dist/web/public/assets/task-runs-DPkwv2UE.js +0 -3
  161. package/dist/web/public/assets/task-runs-DPkwv2UE.js.br +0 -0
  162. package/dist/web/public/assets/task-runs-DPkwv2UE.js.gz +0 -0
  163. package/dist/web/public/assets/tasks-DTiCi2mH.js +0 -4
  164. package/dist/web/public/assets/tasks-DTiCi2mH.js.br +0 -0
  165. package/dist/web/public/assets/tasks-DTiCi2mH.js.gz +0 -0
@@ -1,25 +1,11 @@
1
1
  // Lark (Feishu) adapter: normalize long-connection events, render outbound
2
- // turns as cards.
3
- //
4
- // The anchor is threads, exactly as on Slack: Pier never posts into a chat's
5
- // main flow — a message in the chat is answered in *its own* topic
6
- // (`reply_in_thread`), a message inside a topic is answered there. So a
7
- // conversation is `<chatId>/<rootMessageId>`, the thread is the session, and
8
- // DMs follow the same rule (Feishu DMs thread; avibe verified it). Telegram's
9
- // `topicMode` toggle is meaningless here — there is no other behaviour.
10
- //
11
- // Four more things are Lark-specific and live only here:
12
- // - Message bodies are JSON *strings* (`content` is double-encoded), and a
13
- // mention is a `@_user_N` placeholder resolved through `mentions[]`.
14
- // - Reactions are named keys: 👀 is `OnIt`, and removal is list-then-delete
15
- // because the API deletes by reaction_id.
16
- // - A card callback does not say which thread its message lives in, so every
17
- // button's value carries the thread root (`LarkActionValue.root`).
18
- // - Delivery is at-least-once and the transport acks only after the handler
19
- // returns, so handlers queue work and return; `event_id` is deduplicated.
20
- //
21
- // Everything policy-shaped (mention/bind gates, per-chat overrides) is in
22
- // config.ts, platform-blind and shared with Telegram and Slack.
2
+ // turns as cards. As on Slack, Pier never posts into a chat's main flow: a
3
+ // conversation is `<chatId>/<rootMessageId>` (`reply_in_thread`; DMs thread
4
+ // too). Lark-specific: `content` is a double-encoded JSON string and a mention
5
+ // is a `@_user_N` placeholder resolved through `mentions[]`; 👀 is the reaction
6
+ // key `OnIt`, removed by reaction_id; a card callback does not say which thread
7
+ // its message lives in, so every button value carries the root; delivery is
8
+ // at-least-once and acked when the handler returns, so handlers queue and return.
23
9
  import { saveInboundAll } from "../core/inbox.js";
24
10
  import { MAX_INBOUND_BYTES } from "../core/inbound-file.js";
25
11
  import { awaitsTurn } from "../core/reply.js";
@@ -35,21 +21,14 @@ import { CWD_SUBMIT_PREFIX, LarkPanel } from "./lark-panel.js";
35
21
  import { card, markdown, OFFER_PREFIX } from "./lark-render.js";
36
22
  import { PANEL_PREFIX } from "./panel.js";
37
23
  import { ReceiptLedger, Receipts } from "./receipts.js";
38
- /** Lark wants a named key here; 👀 has none, `OnIt` is its "being handled". */
39
24
  const WORKING = "OnIt";
40
- // Backpressure: bounds concurrency (downloads, API calls), not the backlog —
41
- // the event is already acked by the transport, so nothing slows the source.
25
+ // The event is already acked, so this bounds concurrency, not the backlog.
42
26
  const MAX_ACTIVE_CHATS = 16;
43
27
  const RECEIPT_STALE_MS = 30 * 60_000;
44
28
  const DRAIN_TIMEOUT_MS = 5000;
45
- /** How long a delivered event id is remembered, against redelivery. */
46
29
  const DEDUP_TTL_MS = 5 * 60_000;
47
30
  const DEDUP_MAX = 2000;
48
- /**
49
- * A Lark conversation is always `<chatId>/<rootMessageId>` — the thread is
50
- * the session. This pair is the only definition of the format; control.ts
51
- * decodes with it rather than splitting on "/" itself.
52
- */
31
+ /** The only definition of the conversation id format; control.ts decodes with it. */
53
32
  const conversationId = (chatId, root) => `${chatId}/${root}`;
54
33
  export const parseConversation = (id) => {
55
34
  const at = id.indexOf("/");
@@ -57,31 +36,20 @@ export const parseConversation = (id) => {
57
36
  ? { chatId: id, root: "" }
58
37
  : { chatId: id.slice(0, at), root: id.slice(at + 1) };
59
38
  };
60
- /**
61
- * The thread a message belongs to. A message already in a topic keeps its
62
- * root; one posted in the chat becomes the root of its own — which is what
63
- * makes every request its own session without asking Lark for anything.
64
- */
39
+ /** A message posted in the chat becomes the root of its own topic. */
65
40
  const threadOf = (msg) => msg.rootId || msg.messageId;
66
41
  export class LarkChannel {
67
42
  deps;
68
43
  id = "lark";
69
44
  api;
70
45
  log;
71
- /** 👀 lifecycle, durable; see receipts.ts for why it is not just a Map. */
72
46
  receipts;
73
- /** Ordering per chat, concurrency across them; see chains.ts. */
74
47
  chains;
75
- /** The inbound gate and the bind-hint throttle; see gatekeeper.ts. */
76
48
  gate;
77
- /** Event ids already handled, against at-least-once delivery. */
78
49
  seen;
79
- /** The in-chat settings panel; absent when no control was wired (tests). */
50
+ /** Absent when no control was wired (tests). */
80
51
  panel;
81
- /** User names, cached for the process — one contact lookup per person. */
82
52
  names = new Map();
83
- /** Chats already reported to the store this process; a rename waits for a
84
- * restart, which is soon enough for a Console display label. */
85
53
  discovered = new Set();
86
54
  me = "";
87
55
  out;
@@ -98,7 +66,7 @@ export class LarkChannel {
98
66
  this.api = deps.client ?? new LarkApi(config.token, config.appToken, this.log);
99
67
  this.out = new LarkOutbound(this.api, this.log);
100
68
  this.receipts = new Receipts(
101
- // Reaction removal needs the emoji key back, and Pier only applies one.
69
+ // Removal needs the emoji key back.
102
70
  {
103
71
  setReaction: (_chatId, messageId, emoji) => emoji
104
72
  ? this.api.addReaction(messageId, emoji)
@@ -116,12 +84,10 @@ export class LarkChannel {
116
84
  async start(onMessage) {
117
85
  this.me = await this.api.botOpenId();
118
86
  if (!this.me) {
119
- // Without our own open_id, "was I mentioned?" can only answer no, so
120
- // every chat with require-mention on goes silent. Loud, not a debug line.
87
+ // Every chat with require-mention on goes silent; loud, not a debug line.
121
88
  this.log("bot info returned no open_id: mention detection is disabled");
122
89
  }
123
90
  this.running = true;
124
- // Best-effort and off the critical path.
125
91
  void this.receipts.sweep(true);
126
92
  this.socket = await this.api.connect({
127
93
  onMessage: (event) => this.onEvent(event, onMessage),
@@ -135,16 +101,12 @@ export class LarkChannel {
135
101
  await this.chains.drain(DRAIN_TIMEOUT_MS);
136
102
  }
137
103
  // --- inbound ---------------------------------------------------------------
138
- /**
139
- * Already (about to be) acked by the transport — the SDK answers the frame
140
- * when this returns, so routing is synchronous and the work is queued.
141
- */
104
+ /** The SDK acks the frame when this returns, so routing is synchronous and
105
+ * the work is queued. */
142
106
  onEvent(event, onMessage) {
143
107
  if (!this.running)
144
108
  return;
145
- // Asked on every event, throttled inside receipts.ts.
146
109
  void this.receipts.sweep();
147
- // Our own echo or another app's message.
148
110
  if (event.senderType === "app")
149
111
  return;
150
112
  if (this.seen.duplicate(event.eventId))
@@ -157,8 +119,6 @@ export class LarkChannel {
157
119
  onCardEvent(action, onMessage) {
158
120
  if (!this.running)
159
121
  return;
160
- // A card callback carries its own event id; the composed key is the
161
- // fallback for a payload that arrives without one.
162
122
  const dedupId = action.eventId ??
163
123
  `card:${action.messageId}:${action.operatorId}:${action.value?.key ?? action.name ?? ""}`;
164
124
  if (this.seen.duplicate(dedupId))
@@ -178,12 +138,12 @@ export class LarkChannel {
178
138
  if (!raw && !attachments.length && !mentioned)
179
139
  return;
180
140
  const isDm = msg.chatType === "p2p";
181
- if (!this.discovered.has(msg.chatId)) {
141
+ if (!this.discovered.has(msg.chatId) && this.gate.mayDiscover({ isDm, userId: senderId })) {
182
142
  this.discovered.add(msg.chatId);
183
143
  const name = isDm
184
144
  ? `DM · ${await this.userName(senderId)}`
185
145
  : (await this.api.chatName(msg.chatId).catch((err) => {
186
- // Named, not silent: this failing usually means a missing scope.
146
+ // Usually a missing scope.
187
147
  this.log(`chat lookup failed for ${msg.chatId}: ${String(err)}`);
188
148
  return undefined;
189
149
  })) ?? msg.chatId;
@@ -194,9 +154,8 @@ export class LarkChannel {
194
154
  });
195
155
  }
196
156
  const text = raw.trim();
197
- // A command aimed at another bot (`/stop@other`) is not ours to answer
198
- // and travels on as ordinary text — Lark gives Pier no @username a target
199
- // could positively match, so any target means "not us".
157
+ // Lark gives Pier no @username a command target could match, so any target
158
+ // means "not us".
200
159
  const parsed = parseCommand(text);
201
160
  const command = parsed?.target ? undefined : parsed;
202
161
  const root = threadOf(msg);
@@ -204,8 +163,7 @@ export class LarkChannel {
204
163
  const bindRequest = command?.name === "bind" && isDm;
205
164
  const admitted = this.gate.admit("message", msg.chatId, {
206
165
  isDm,
207
- // Mentioned, or continuing a topic Pier already owns — Lark's
208
- // equivalent of Telegram's "replying to the bot", durable so it still
166
+ // Mentioned, or continuing a topic Pier already owns — durable, so it
209
167
  // holds after a restart.
210
168
  addressed: mentioned || (!!msg.rootId && !!this.deps.control?.knows(here)),
211
169
  userId: senderId,
@@ -220,22 +178,18 @@ export class LarkChannel {
220
178
  return this.bind(senderId, msg.messageId, command?.args ?? "");
221
179
  if (command?.name === "stop")
222
180
  return this.abortTurn(here, msg.messageId);
223
- // `@bot` on its own (the text is empty once the mention is stripped) and
224
- // `/settings` are the same request: show me this conversation's settings.
181
+ // A bare `@bot` and `/settings` are the same request.
225
182
  if (this.panel && (command?.name === "settings" || (!text && !attachments.length && mentioned))) {
226
183
  return this.panel.open(here, msg.chatId, root);
227
184
  }
228
- // Downloading only past the gate: an unauthorized sender must not be able
229
- // to make the bot pull bytes on their behalf.
185
+ // Downloading only past the gate: an unauthorized sender must not make the
186
+ // bot pull bytes on their behalf.
230
187
  const markers = await this.saveAttachments(msg.messageId, attachments);
231
- // Every await between mark() and dispatch is a window in which a previous
232
- // turn can end and settle — taking this receipt with it before its own
233
- // turn even starts — so the name is resolved first and the mark→dispatch
234
- // pair stays synchronous.
188
+ // Resolved before the mark: any await between mark() and dispatch is a
189
+ // window in which a previous turn can settle and take this receipt with it.
235
190
  const sender = { id: senderId, name: await this.userName(senderId) };
236
191
  this.receipts.mark(here.conversationId, msg.chatId, msg.messageId);
237
- // IM messages steer by default: a follow-up that waits for the turn to
238
- // end is the wrong default when the human is watching a 👀 in a topic.
192
+ // Steer: a follow-up is the wrong default when the human is watching a 👀.
239
193
  onMessage({
240
194
  key: here,
241
195
  senderId,
@@ -244,11 +198,8 @@ export class LarkChannel {
244
198
  mode: "steer",
245
199
  });
246
200
  }
247
- /**
248
- * One message's readable content: text with mentions resolved, attachments
249
- * to fetch, and whether the bot was addressed. `content` is a JSON string;
250
- * malformed or unreadable types are logged and dropped at this boundary.
251
- */
201
+ /** `content` is a JSON string; malformed or unreadable types are logged and
202
+ * dropped at this boundary. */
252
203
  readContent(msg) {
253
204
  let content = {};
254
205
  try {
@@ -278,8 +229,7 @@ export class LarkChannel {
278
229
  case "media":
279
230
  case "audio":
280
231
  if (content.file_key) {
281
- // `file_size` is optional and sometimes a numeric string; a missing
282
- // one is fine — download() enforces the cap mid-stream regardless.
232
+ // `file_size` is optional and sometimes a numeric string.
283
233
  const size = Number(content.file_size);
284
234
  attachments.push({
285
235
  key: String(content.file_key),
@@ -292,9 +242,8 @@ export class LarkChannel {
292
242
  default:
293
243
  this.log(`ignored message type ${msg.messageType ?? "?"}`);
294
244
  }
295
- // A mention arrives as a `@_user_N` placeholder: the bot's own is
296
- // addressing, not content, and is removed; anyone else's becomes their
297
- // name, so the agent sees who was meant.
245
+ // The bot's own placeholder is addressing and is removed; anyone else's
246
+ // becomes their name.
298
247
  let mentioned = false;
299
248
  for (const mention of msg.mentions ?? []) {
300
249
  const isMe = !!this.me && mention.id?.open_id === this.me;
@@ -303,11 +252,8 @@ export class LarkChannel {
303
252
  }
304
253
  return { text, attachments, mentioned };
305
254
  }
306
- /** Rich text: the readable runs, and any images embedded in it. */
307
255
  readPost(raw) {
308
- // A post body may arrive wrapped in a locale (`{zh_cn: {title, content}}`)
309
- // rather than flat — both shapes are real. Take the flat body when it is
310
- // one, else the first locale entry that is an object.
256
+ // A post body may arrive flat or wrapped in a locale (`{zh_cn: {title, content}}`).
311
257
  const content = Array.isArray(raw.content) || typeof raw.title === "string"
312
258
  ? raw
313
259
  : (Object.values(raw).find((v) => !!v && typeof v === "object" && !Array.isArray(v)) ??
@@ -326,9 +272,8 @@ export class LarkChannel {
326
272
  if (run.tag === "text" || run.tag === "a")
327
273
  parts.push(String(run.text ?? ""));
328
274
  else if (run.tag === "at") {
329
- // Inline, not a `@_user_N` placeholder: rich text carries the at run
330
- // itself. The bot's own is addressing (detected via `mentions[]`),
331
- // not content; anyone else's becomes their name.
275
+ // Rich text carries the at run inline, not as a placeholder; the
276
+ // bot's own is detected via `mentions[]`.
332
277
  if (run.user_id !== this.me)
333
278
  parts.push(`@${run.user_name ?? run.user_id ?? "?"}`);
334
279
  }
@@ -343,9 +288,8 @@ export class LarkChannel {
343
288
  }
344
289
  // --- card actions ------------------------------------------------------------
345
290
  async onAction(action, onMessage) {
346
- // The thread root travels in the button payload (a callback does not say
347
- // which topic its message lives in); a form submit carries it in the
348
- // button's name. Absent both, the payload is not one Pier minted.
291
+ // A form submit carries the root in the button's name instead of the
292
+ // value. Absent both, the payload is not one Pier minted.
349
293
  const payload = action.value?.key ?? "";
350
294
  const formRoot = action.name?.startsWith(CWD_SUBMIT_PREFIX)
351
295
  ? action.name.slice(CWD_SUBMIT_PREFIX.length)
@@ -370,18 +314,15 @@ export class LarkChannel {
370
314
  await this.panel?.onCwdSubmit(key, action, root);
371
315
  return;
372
316
  }
373
- // Panel clicks are namespaced `cfg:` and never reach the agent.
374
317
  if (payload.startsWith(PANEL_PREFIX)) {
375
318
  if (!(await this.panel?.onAction(action, key, payload, root))) {
376
319
  this.log(`panel action ${payload} with no panel wired, dropped`);
377
320
  }
378
321
  return;
379
322
  }
380
- // A next-step button. The label travels in the value the platform echoes
381
- // back — the only durable place, since Lark cannot return a 2.0 card
382
- // (LarkActionValue documents the probe) — so a click needs no adapter
383
- // state and survives a restart. A value without one is a stale card from
384
- // before this convention, and the user clicked expecting something.
323
+ // The label travels in the echoed value (Lark cannot return a 2.0 card;
324
+ // see LarkActionValue), so a click survives a restart. A value without
325
+ // one is a stale card, and the user clicked expecting something.
385
326
  const label = payload.startsWith(OFFER_PREFIX) && typeof action.value?.label === "string"
386
327
  ? action.value.label
387
328
  : undefined;
@@ -391,10 +332,8 @@ export class LarkChannel {
391
332
  .catch((err) => this.log(`stale-option notice failed: ${String(err)}`));
392
333
  return;
393
334
  }
394
- // The taken row comes off (best-effort; see LarkOutbound.retire), and the
395
- // pick is echoed — a bot cannot post as the user, so without the echo the
396
- // topic shows an answer to a request nobody can see being made, and there
397
- // is nothing to carry the eyes.
335
+ // A bot cannot post as the user, so the pick is echoed: otherwise the
336
+ // topic shows an answer to a request nobody can see, with nothing to carry the eyes.
398
337
  const sender = { id: action.operatorId, name: await this.userName(action.operatorId) };
399
338
  await this.out.retire(action.messageId);
400
339
  const echo = await this.api.replyCard(root, card([markdown(picked(label))]))
@@ -413,20 +352,14 @@ export class LarkChannel {
413
352
  mode: "steer",
414
353
  });
415
354
  }
416
- /**
417
- * Stop the turn this conversation is running. The abort makes Pi end the
418
- * turn, which reaches send() through the normal turn-end path and clears
419
- * the 👀 receipts — so nothing here touches them.
420
- */
355
+ /** The abort ends the turn, which reaches send() and clears the receipts. */
421
356
  async abortTurn(key, messageId) {
422
357
  await this.deps.control?.abort(key);
423
358
  await this.api.replyCard(messageId, card([markdown(STOPPED)]));
424
359
  }
425
360
  // --- bind ------------------------------------------------------------------
426
- /**
427
- * Tell an unbound DM sender what to do. Groups stay silent (see gate()),
428
- * but a DM that swallows every message looks broken rather than locked.
429
- */
361
+ /** Groups stay silent, but a DM that swallows every message looks broken
362
+ * rather than locked. */
430
363
  async hintBind(userId, messageId) {
431
364
  if (!this.gate.mayHint(userId))
432
365
  return;
@@ -435,8 +368,8 @@ export class LarkChannel {
435
368
  }
436
369
  async bind(userId, messageId, code) {
437
370
  const name = await this.userName(userId);
438
- const ok = this.deps.store.redeemBindCode("lark", code, { id: userId, name });
439
- await this.api.replyCard(messageId, card([markdown(bindResult(ok, name))]));
371
+ const outcome = this.deps.store.redeemBindCode("lark", code, { id: userId, name });
372
+ await this.api.replyCard(messageId, card([markdown(bindResult(outcome, name))]));
440
373
  }
441
374
  // --- lookups ---------------------------------------------------------------
442
375
  async userName(openId) {
@@ -444,17 +377,12 @@ export class LarkChannel {
444
377
  if (hit)
445
378
  return hit;
446
379
  const name = await this.api.userName(openId).catch((err) => {
447
- // The id is the honest fallback label; the reason still gets said.
448
380
  this.log(`user lookup failed for ${openId}: ${String(err)}`);
449
381
  return openId;
450
382
  });
451
383
  this.names.set(openId, name);
452
384
  return name;
453
385
  }
454
- /** The message's attachments as the shared save loop wants them (the loop
455
- * itself, size gate and lost markers included, is core/inbox.ts; the
456
- * mid-stream refusal in download() names "too large" so the loop's marker
457
- * stays honest when the metadata lied by omission). */
458
386
  saveAttachments(messageId, files) {
459
387
  return saveInboundAll(this.id, files.map((file) => ({
460
388
  label: file.name ?? "attachment",
@@ -465,34 +393,20 @@ export class LarkChannel {
465
393
  })), this.log);
466
394
  }
467
395
  // --- outbound --------------------------------------------------------------
468
- /**
469
- * Called on every turn-end, empty text included: the turn settled with
470
- * nothing to say, and the 👀 receipts still have to come off.
471
- */
472
396
  async send(conversation, reply) {
473
397
  const { root } = parseConversation(conversation);
474
- // Every id this adapter mints carries a thread root, so an empty one is a
475
- // corrupted or foreign conversation id. Posting it would put an agent
476
- // turn in the chat's main flow — the one thing this adapter promises
477
- // never to do — so it is refused loudly instead, and the receipts still
478
- // come off so no 👀 is stranded.
398
+ // No root is a foreign id; posting it would put a turn in the chat's main
399
+ // flow. Refused loudly, receipts still cleared.
479
400
  if (!root) {
480
401
  this.log(`refusing to answer ${conversation}: no thread root in the conversation id`);
481
402
  await this.receipts.settle(conversation);
482
403
  return;
483
404
  }
484
- // settleAfter: the turn ended either way, and a 👀 left up because the
485
- // reply failed to send looks like work until the stale sweep.
486
- await this.receipts.settleAfter(conversation, () => this.out.reply(root, reply));
405
+ // The turn ended either way; a 👀 left up by a failed send looks like work.
406
+ await this.receipts.settleAfter(conversation, () => this.out.reply(root, reply), reply.meta);
487
407
  }
488
- /**
489
- * A system note, and the 👀 goes on the note itself: the turn it triggers has
490
- * no message of the user's to carry them — nobody typed one — so without this
491
- * the topic shows nothing at all while the agent works. The turn-end `send`
492
- * clears it like any other receipt; an error note is not marked, because no
493
- * turn follows it (`awaitsTurn`) and the eyes would sit there until the stale
494
- * sweep.
495
- */
408
+ /** The 👀 goes on the note itself: the turn it triggers has no message of
409
+ * the user's to carry them. */
496
410
  async notify(conversation, note) {
497
411
  const { chatId, root } = parseConversation(conversation);
498
412
  if (!root) {
@@ -500,9 +414,6 @@ export class LarkChannel {
500
414
  return;
501
415
  }
502
416
  const messageId = await this.out.note(root, note);
503
- // The last card of a long note, so the eyes sit at the foot of the topic,
504
- // where the reply will land. A reaction that fails is swallowed and logged
505
- // by receipts.ts, so the note itself is never lost to one.
506
417
  if (messageId && awaitsTurn(note.origin))
507
418
  this.receipts.mark(conversation, chatId, messageId);
508
419
  }
@@ -1,19 +1,17 @@
1
- // What the shared control moments say — one spelling for three platforms.
2
- //
3
- // Bind, stop and the option echo behave identically everywhere by contract,
4
- // and their lines were copied per adapter until Lark made three of each; the
5
- // same wording drifting apart is how the panels went (see panel.ts). How a
6
- // line is *sent* stays with the adapter; the only legitimate variation is the
7
- // spelling of the bind command, so it is the parameter.
8
- /** DM-only, throttled by Gatekeeper.mayHint; groups stay silent by contract. */
1
+ // What the shared control moments say — one spelling for three platforms. The
2
+ // only legitimate variation is the spelling of the bind command, so it is the parameter.
9
3
  export const bindHint = (command) => `You are not bound yet. Ask the operator for a bind code, then send ${command}.`;
10
- /** The answer to a bind attempt. The name arrives escaped by the caller. */
11
- export const bindResult = (ok, name) => ok ? `Bound as ${name}.` : "That bind code is invalid or expired.";
12
- /** Acknowledges /stop; the turn's own end still arrives through send(). */
4
+ /** The name arrives escaped by the caller. */
5
+ export const bindResult = (outcome, name) => {
6
+ if (outcome === "bound")
7
+ return `Bound as ${name}.`;
8
+ return outcome === "voided"
9
+ ? "That bind code is invalid or expired — and too many wrong tries have now" +
10
+ " voided it. Ask the operator for a new one."
11
+ : "That bind code is invalid or expired.";
12
+ };
13
13
  export const STOPPED = "\u23f9 Stopped.";
14
- /** A picked option, echoed because a bot cannot post as the user. */
14
+ /** Echoed because a bot cannot post as the user. */
15
15
  export const picked = (label) => `\u25b8 ${label}`;
16
- /** A click on options that are gone — retired, or from before this process'
17
- * conventions. Said in the chat: the person clicked and would otherwise see
18
- * nothing happen, which reads as broken (5b). */
16
+ /** Said in the chat: the person clicked and would otherwise see nothing happen (§5). */
19
17
  export const STALE_OPTION = "⚠ That option is no longer available — please type the choice instead.";
@@ -1,21 +1,9 @@
1
- // The in-chat settings panel, minus the platform.
2
- //
3
- // One message edited in place — a new message per tap would bury the chat.
4
- // Every payload is namespaced `cfg:` and consumed here, so a panel tap can
5
- // never be mistaken for one of the agent's next-step buttons (whose payload is
6
- // the label itself) and never reaches the agent. Choices travel as an index
7
- // rather than a name: Telegram's callback data caps at 64 bytes, and an index
8
- // cannot be invalidated by a label someone rewrote.
9
- //
10
- // What is left to a platform is markup, how its one message is sent, edited
11
- // and deleted, and how it asks for a single typed answer — Slack has modals,
12
- // Telegram has a forced reply. Everything above that is the same panel, so it
13
- // is written once here.
1
+ // The in-chat settings panel, minus the platform: one message edited in place,
2
+ // every payload namespaced `cfg:` so a tap never reaches the agent. Choices
3
+ // travel as an index: Telegram's callback data caps at 64 bytes.
14
4
  import { compact, thinkingLabel } from "../core/reply.js";
15
5
  export const PANEL_PREFIX = "cfg:";
16
6
  const MODELS_PER_PAGE = 8;
17
- /** The cwd prompt's one sentence and placeholder — each platform owns only
18
- * its widget's lead-in ("Reply with…", a modal hint, a form label). */
19
7
  export const CWD_TAIL = "A new session starts there; the current one stays in its own directory.";
20
8
  export const CWD_PLACEHOLDER = "/path/to/project";
21
9
  const onOff = (v) => (v ? "on" : "off");
@@ -40,7 +28,6 @@ export class ChatPanel {
40
28
  return this.panels.get(key.conversationId);
41
29
  }
42
30
  // --- rendering ---------------------------------------------------------------
43
- /** The panel proper: this session, this chat, and what can be done to them. */
44
31
  async view(key, chatId) {
45
32
  const status = await this.deps.control.status(key);
46
33
  return {
@@ -51,8 +38,6 @@ export class ChatPanel {
51
38
  ? this.sessionLines(status)
52
39
  : ["None yet — send a message to start one."],
53
40
  },
54
- // Slack calls it a channel, Telegram a chat; the panel says what the
55
- // person reading it says.
56
41
  {
57
42
  title: this.platform === "slack" ? "Channel" : "Chat",
58
43
  lines: this.chatLines(chatId),
@@ -90,7 +75,6 @@ export class ChatPanel {
90
75
  : `mention ${onOff(policy.requireMention)} · bind ${onOff(policy.requireBind)}${this.gateExtras(chat, policy)}`;
91
76
  return [`${this.esc(chat.name || chatId)} · ${chat.kind} · ${this.code(chatId)}`, gates];
92
77
  }
93
- /** Redraw the panel this conversation owns. */
94
78
  async refresh(key, note) {
95
79
  const state = this.state(key);
96
80
  if (!state)
@@ -98,14 +82,8 @@ export class ChatPanel {
98
82
  await this.draw(state, await this.view(key, state.chatId), note);
99
83
  }
100
84
  // --- actions -----------------------------------------------------------------
101
- /**
102
- * Handle a `cfg:` payload. Returns false when it is not ours, so the caller
103
- * can treat it as one of the agent's next-step labels instead.
104
- *
105
- * `reopen` is how a panel left behind by a previous process recovers: its
106
- * state died with that process, and redrawing from the platform's own copy
107
- * of the message is the only honest answer. It costs one tap.
108
- */
85
+ /** Returns false when the payload is not ours. `reopen` recovers a panel
86
+ * whose state died with a previous process; it costs one tap. */
109
87
  async dispatch(key, payload, ctx, reopen) {
110
88
  if (!payload.startsWith(PANEL_PREFIX))
111
89
  return false;
@@ -157,7 +135,14 @@ export class ChatPanel {
157
135
  const state = this.state(key);
158
136
  if (!state)
159
137
  return;
160
- state.models = await this.deps.control.models().catch(() => []);
138
+ // An empty list and a catalog that could not be read are two different
139
+ // answers; the second must not draw as the first.
140
+ let unavailable;
141
+ state.models = await this.deps.control.models().catch((err) => {
142
+ unavailable = `Could not list models: ${String(err)}`;
143
+ this.deps.log(unavailable);
144
+ return [];
145
+ });
161
146
  const status = await this.deps.control.status(key);
162
147
  const pages = Math.max(1, Math.ceil(state.models.length / MODELS_PER_PAGE));
163
148
  const at = Math.min(Math.max(page, 0), pages - 1);
@@ -166,7 +151,7 @@ export class ChatPanel {
166
151
  groups: [{
167
152
  title: "Model",
168
153
  suffix: ` · page ${at + 1}/${pages}`,
169
- lines: state.models.length ? [] : ["No models with configured auth."],
154
+ lines: state.models.length ? [] : [unavailable ?? "No models with configured auth."],
170
155
  }],
171
156
  picks: slice.map((model, i) => {
172
157
  const current = status?.model?.provider === model.provider && status.model.id === model.id;
@@ -210,15 +195,10 @@ export class ChatPanel {
210
195
  await this.deps.control.setThinking(key, level);
211
196
  await this.refresh(key, `Reasoning set to ${thinkingLabel(level)}.`);
212
197
  }
213
- /**
214
- * The one action that is not reversible in place: Pi fixes cwd at session
215
- * creation, so "change the working directory" *is* "start a new session
216
- * there". Shared because both platforms have to say so and handle the same
217
- * two failures.
218
- */
198
+ /** Pi fixes cwd at session creation, so "change the working directory" *is*
199
+ * "start a new session there". */
219
200
  async startSessionIn(key, path) {
220
201
  if (!path.startsWith("/")) {
221
- // A silent no-op here reads as "the button is broken".
222
202
  const error = "That is not an absolute path — nothing changed.";
223
203
  await this.refresh(key, error);
224
204
  return { error };