allaboard 0.0.0-stage → 0.5.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 (141) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +43 -2
  3. package/dist/agent/claude/backend.js +124 -0
  4. package/dist/agent/claude/capabilities.js +30 -0
  5. package/dist/agent/claude/chrome.js +42 -0
  6. package/dist/agent/claude/hooks.js +37 -0
  7. package/dist/agent/claude/info.js +61 -0
  8. package/dist/agent/claude/listing.js +230 -0
  9. package/dist/agent/claude/module.js +82 -0
  10. package/dist/agent/claude/prompt.js +24 -0
  11. package/dist/agent/claude/record.js +9 -0
  12. package/dist/agent/claude/requests.js +122 -0
  13. package/dist/agent/claude/session.js +441 -0
  14. package/dist/agent/claude/translate.js +581 -0
  15. package/dist/agent/claude/trust.js +576 -0
  16. package/dist/agent/claude/usage-probe.js +103 -0
  17. package/dist/agent/claude/usage.js +110 -0
  18. package/dist/agent/claude/wire.js +22 -0
  19. package/dist/agent/seam.js +21 -0
  20. package/dist/chat/seam.js +10 -0
  21. package/dist/chat/slack/app/answers.js +209 -0
  22. package/dist/chat/slack/app/app.js +262 -0
  23. package/dist/chat/slack/app/clip.js +191 -0
  24. package/dist/chat/slack/app/guards.js +162 -0
  25. package/dist/chat/slack/app/home-controls.js +91 -0
  26. package/dist/chat/slack/app/messages.js +181 -0
  27. package/dist/chat/slack/app/open.js +392 -0
  28. package/dist/chat/slack/app/order.js +37 -0
  29. package/dist/chat/slack/app/requests.js +482 -0
  30. package/dist/chat/slack/app/resume-bind.js +71 -0
  31. package/dist/chat/slack/app/wire.js +83 -0
  32. package/dist/chat/slack/attachments.js +348 -0
  33. package/dist/chat/slack/bind.js +67 -0
  34. package/dist/chat/slack/channel.js +67 -0
  35. package/dist/chat/slack/channels.js +43 -0
  36. package/dist/chat/slack/delete.js +278 -0
  37. package/dist/chat/slack/footer.js +43 -0
  38. package/dist/chat/slack/hold.js +37 -0
  39. package/dist/chat/slack/home.js +1069 -0
  40. package/dist/chat/slack/module.js +174 -0
  41. package/dist/chat/slack/notice.js +64 -0
  42. package/dist/chat/slack/openfile/modal.js +362 -0
  43. package/dist/chat/slack/openfile/read.js +29 -0
  44. package/dist/chat/slack/quiet-logger.js +52 -0
  45. package/dist/chat/slack/repair.js +123 -0
  46. package/dist/chat/slack/reply/blocks.js +372 -0
  47. package/dist/chat/slack/reply/chars.js +59 -0
  48. package/dist/chat/slack/reply/clients.js +171 -0
  49. package/dist/chat/slack/reply/errors.js +125 -0
  50. package/dist/chat/slack/reply/escape.js +25 -0
  51. package/dist/chat/slack/reply/limiter.js +86 -0
  52. package/dist/chat/slack/reply/markdown.js +210 -0
  53. package/dist/chat/slack/reply/places.js +262 -0
  54. package/dist/chat/slack/reply/sinks.js +2403 -0
  55. package/dist/chat/slack/reply/status.js +433 -0
  56. package/dist/chat/slack/reply/tasks.js +11 -0
  57. package/dist/chat/slack/requests.js +552 -0
  58. package/dist/chat/slack/resume.js +127 -0
  59. package/dist/chat/slack/setup.js +114 -0
  60. package/dist/chat/slack/texts.js +185 -0
  61. package/dist/chat/slack/thread.js +537 -0
  62. package/dist/chat/slack/update.js +71 -0
  63. package/dist/chat/slack/voice.js +84 -0
  64. package/dist/cli.js +326 -0
  65. package/dist/clock.js +21 -0
  66. package/dist/core/cleanup.js +145 -0
  67. package/dist/core/commands.js +172 -0
  68. package/dist/core/config.js +174 -0
  69. package/dist/core/files/changed.js +125 -0
  70. package/dist/core/files/listing.js +382 -0
  71. package/dist/core/files/paths.js +9 -0
  72. package/dist/core/files/read.js +191 -0
  73. package/dist/core/files/waiting.js +34 -0
  74. package/dist/core/flows/answers.js +133 -0
  75. package/dist/core/flows/ask-owner.js +95 -0
  76. package/dist/core/flows/asking.js +14 -0
  77. package/dist/core/flows/bind.js +95 -0
  78. package/dist/core/flows/contract.js +1 -0
  79. package/dist/core/flows/flows.js +35 -0
  80. package/dist/core/flows/hold.js +22 -0
  81. package/dist/core/flows/inbound.js +24 -0
  82. package/dist/core/flows/lists.js +21 -0
  83. package/dist/core/flows/messages.js +293 -0
  84. package/dist/core/flows/notices.js +51 -0
  85. package/dist/core/flows/repair.js +106 -0
  86. package/dist/core/flows/resume.js +163 -0
  87. package/dist/core/flows/setup.js +130 -0
  88. package/dist/core/flows/status.js +98 -0
  89. package/dist/core/flows/talk.js +207 -0
  90. package/dist/core/flows/update.js +370 -0
  91. package/dist/core/flows/words.js +171 -0
  92. package/dist/core/folders.js +93 -0
  93. package/dist/core/footer.js +313 -0
  94. package/dist/core/guide.js +50 -0
  95. package/dist/core/hold.js +126 -0
  96. package/dist/core/instance.js +46 -0
  97. package/dist/core/lock.js +412 -0
  98. package/dist/core/models.js +10 -0
  99. package/dist/core/paths.js +75 -0
  100. package/dist/core/port/file-flows.js +1 -0
  101. package/dist/core/port/home.js +1 -0
  102. package/dist/core/port/requests.js +1 -0
  103. package/dist/core/port/threads.js +1 -0
  104. package/dist/core/port/updates.js +1 -0
  105. package/dist/core/port.js +17 -0
  106. package/dist/core/reply/fold.js +175 -0
  107. package/dist/core/reply/previews.js +258 -0
  108. package/dist/core/reply/renderer.js +846 -0
  109. package/dist/core/reply/tail.js +24 -0
  110. package/dist/core/reply/words.js +32 -0
  111. package/dist/core/requests.js +158 -0
  112. package/dist/core/sessions/constants.js +61 -0
  113. package/dist/core/sessions/deps.js +1 -0
  114. package/dist/core/sessions/directory.js +86 -0
  115. package/dist/core/sessions/errors.js +79 -0
  116. package/dist/core/sessions/manager.js +378 -0
  117. package/dist/core/sessions/session.js +3297 -0
  118. package/dist/core/sessions/tasks.js +266 -0
  119. package/dist/core/sessions/turn.js +45 -0
  120. package/dist/core/setup.js +28 -0
  121. package/dist/core/state.js +724 -0
  122. package/dist/core/texts.js +451 -0
  123. package/dist/core/updates.js +332 -0
  124. package/dist/daemon.js +566 -0
  125. package/dist/host/npm.js +134 -0
  126. package/dist/host/program.js +49 -0
  127. package/dist/host/run.js +52 -0
  128. package/dist/host/service/launchd.js +385 -0
  129. package/dist/host/service/listing.js +42 -0
  130. package/dist/host/service/seam.js +1 -0
  131. package/dist/log.js +37 -0
  132. package/dist/main.js +29 -0
  133. package/dist/node-version.js +40 -0
  134. package/dist/registry.js +12 -0
  135. package/dist/semver.js +53 -0
  136. package/dist/text.js +89 -0
  137. package/dist/updates-host.js +113 -0
  138. package/dist/values.js +30 -0
  139. package/dist/version.js +54 -0
  140. package/package.json +32 -4
  141. package/slack-app-manifest.json +50 -0
@@ -0,0 +1,552 @@
1
+ /**
2
+ * The agent's permission requests and questions as Slack draws them: the approval message, the
3
+ * one-line question with Answer and Skip, the form that asks it one question at a time, and the
4
+ * answered question that replaces a request. Every limit here is Slack's. What is pending and
5
+ * what the answer is belong to `core/requests.ts`.
6
+ *
7
+ * Everything the model wrote (the title, the description, the input, a question's header) goes
8
+ * to Slack with `&`, `<` and `>` escaped and a zero-width space after each backtick, so no
9
+ * `<url|label>` can hide what it links and no text can close its code block.
10
+ */
11
+ import { answerOf, named, oneLine, texts } from "../../core/port.js";
12
+ import { PY_SPACE, strip } from "../../text.js";
13
+ import { isRecord } from "../../values.js";
14
+ import { shownAsWritten } from "./reply/escape.js";
15
+ import * as slackTexts from "./texts.js";
16
+ export const SECTION_LIMIT = 3000;
17
+ export const MESSAGE_BLOCKS = 50; // Slack's limit on blocks in one message
18
+ export const OPTION_TEXT_LIMIT = 75;
19
+ export const LABEL_LIMIT = 2000; // an input block's label
20
+ const TAB_LIMIT = 40; // a question's header in the tab row
21
+ export const APPROVAL_ALLOW = "approval_allow";
22
+ export const APPROVAL_DENY = "approval_deny";
23
+ export const QUESTION_OPEN = "question_open";
24
+ export const QUESTION_SKIP = "question_skip";
25
+ export const QUESTION_FORM = "question_form";
26
+ /** The Edit buttons of the review page: `question_edit_{n}`, n a question's index. */
27
+ export const QUESTION_EDIT = /^question_edit_(\d+)$/;
28
+ // Text typed under Other and in Notes, per question. The draft lives in the daemon's memory
29
+ // (`Requests`), so Slack's 3,000 characters of private_metadata bound neither.
30
+ export const TYPED_LIMIT = 300;
31
+ export const NOTES_LIMIT = 300;
32
+ // The value of the Other option; the others carry their index in the question's options.
33
+ const OTHER_VALUE = "other";
34
+ const RUNS = new RegExp(`[${PY_SPACE}]+`);
35
+ /** The first `limit` characters, counted in code points as Slack counts them. */
36
+ function head(text, limit) {
37
+ return Array.from(text).slice(0, limit).join("");
38
+ }
39
+ function length(text) {
40
+ return Array.from(text).length;
41
+ }
42
+ function cut(text, limit) {
43
+ return length(text) <= limit ? text : `${head(text, limit - 1)}…`;
44
+ }
45
+ /** `text` with every run of white space as one space. */
46
+ function squeezed(text) {
47
+ return strip(text).split(RUNS).join(" ");
48
+ }
49
+ function button(actionId, label, value, style) {
50
+ const entry = {
51
+ type: "button",
52
+ action_id: actionId,
53
+ value,
54
+ text: { type: "plain_text", text: label },
55
+ };
56
+ if (style)
57
+ entry.style = style;
58
+ return entry;
59
+ }
60
+ function context(text) {
61
+ return { type: "context", elements: [{ type: "mrkdwn", text }] };
62
+ }
63
+ /** `text` in pieces that each fit `limit` once shown as written, never splitting an escape. */
64
+ function codeChunks(text, limit) {
65
+ const chunks = [];
66
+ let chunk = "";
67
+ let size = 0;
68
+ for (const char of text) {
69
+ // Only these four change when shown as written; any other code point stays one character.
70
+ const width = "&<>`".includes(char) ? length(shownAsWritten(char)) : 1;
71
+ if (size + width > limit) {
72
+ chunks.push(chunk);
73
+ chunk = "";
74
+ size = 0;
75
+ }
76
+ chunk += char;
77
+ size += width;
78
+ }
79
+ return chunk !== "" || chunks.length === 0 ? [...chunks, chunk] : chunks;
80
+ }
81
+ function code(chunk) {
82
+ return {
83
+ type: "section",
84
+ text: { type: "mrkdwn", text: `\`\`\`\n${shownAsWritten(chunk)}\n\`\`\`` },
85
+ };
86
+ }
87
+ /**
88
+ * The request the owner approves: everything the tool will run with, never cut silently, since
89
+ * Approve hands the agent the whole input whatever was shown.
90
+ */
91
+ export function approvalBlocks(approvalId, request, names) {
92
+ const heading = request.title
93
+ ? shownAsWritten(request.title)
94
+ : texts.fill(named(slackTexts.APPROVAL_PROMPT, names), {
95
+ tool: shownAsWritten(request.toolName),
96
+ });
97
+ const blocks = [
98
+ { type: "section", text: { type: "mrkdwn", text: head(heading, SECTION_LIMIT) } },
99
+ ];
100
+ if (request.description) {
101
+ blocks.push(context(head(shownAsWritten(request.description), SECTION_LIMIT)));
102
+ }
103
+ const detail = JSON.stringify(request.input, null, 2);
104
+ const chunks = codeChunks(detail, SECTION_LIMIT - length("```\n\n```"));
105
+ const room = MESSAGE_BLOCKS - blocks.length - 1; // the actions block closes it
106
+ let codeBlocks;
107
+ if (chunks.length > room) {
108
+ // Past one message, the start and the end stay (a payload hides at the end of padding)
109
+ // and a line says how much is not shown, so the owner can Deny rather than guess.
110
+ const shown = chunks.slice(0, room - 2);
111
+ const tail = chunks.at(-1);
112
+ const hidden = chunks.slice(room - 2, -1).reduce((total, chunk) => total + length(chunk), 0);
113
+ const notice = texts.fill(slackTexts.APPROVAL_CUT, { count: hidden.toLocaleString("en-US") });
114
+ codeBlocks = [...shown.map(code), context(notice), code(tail)];
115
+ }
116
+ else {
117
+ codeBlocks = chunks.map(code);
118
+ }
119
+ blocks.push(...codeBlocks, {
120
+ type: "actions",
121
+ elements: [
122
+ button(APPROVAL_ALLOW, "Approve", approvalId, "primary"),
123
+ button(APPROVAL_DENY, "Deny", approvalId, "danger"),
124
+ ],
125
+ });
126
+ return blocks;
127
+ }
128
+ /**
129
+ * An answered request as the terminal keeps it, for a question whose answers the reply cannot
130
+ * show: `User answered Claude's questions:`, then `· question → answer` per question, with no
131
+ * buttons left to press.
132
+ */
133
+ export function answeredBlocks(questions, answers, names) {
134
+ const lines = [slackTexts.RECORD_MARK + named(texts.ANSWERED, names)];
135
+ for (const q of questions) {
136
+ const answer = Object.hasOwn(answers, q.text) ? answers[q.text] : "";
137
+ const shown = typeof answer === "string" ? answer : answer.join(", ");
138
+ lines.push(`${texts.NESTED}· ${shownAsWritten(q.text)} → ${shownAsWritten(shown)}`);
139
+ }
140
+ // A context element holds at most 3,000 characters; long questions and answers are cut.
141
+ return [context(cut(lines.join("\n"), SECTION_LIMIT))];
142
+ }
143
+ /**
144
+ * The request in the channel: one line naming the questions, with Answer (which opens the form)
145
+ * and Skip. It stays one line however many questions the agent asks.
146
+ */
147
+ export function questionBlocks(approvalId, questions, names) {
148
+ const headers = questions.map((q) => `*${shownAsWritten(q.header || q.text)}*`).join(" · ");
149
+ const count = questions.length === 1
150
+ ? named(slackTexts.QUESTIONS_ONE, names)
151
+ : texts.fill(named(slackTexts.QUESTIONS_MANY, names), { count: questions.length });
152
+ return [
153
+ {
154
+ type: "section",
155
+ text: { type: "mrkdwn", text: head(`${count}: ${headers}`, SECTION_LIMIT) },
156
+ },
157
+ {
158
+ type: "actions",
159
+ elements: [
160
+ button(QUESTION_OPEN, slackTexts.QUESTION_ANSWER, approvalId, "primary"),
161
+ button(QUESTION_SKIP, "Skip", approvalId),
162
+ ],
163
+ },
164
+ ];
165
+ }
166
+ /** A draft of the request `approvalId` posted in this thread, with nothing filled in unless given. */
167
+ export function newDraft(approvalId, channelId, threadTs, filled = {}) {
168
+ return {
169
+ approvalId,
170
+ channelId,
171
+ threadTs,
172
+ generation: filled.generation ?? 0,
173
+ active: filled.active ?? 0,
174
+ reviewed: filled.reviewed ?? false,
175
+ picks: filled.picks ?? new Map(),
176
+ other: filled.other ?? new Set(),
177
+ typed: filled.typed ?? new Map(),
178
+ notes: filled.notes ?? new Map(),
179
+ };
180
+ }
181
+ /** The `private_metadata` of a form: its ids as JSON, which `loadIds` reads back. */
182
+ export function dumpIds(ids) {
183
+ return JSON.stringify({
184
+ a: ids.approvalId,
185
+ c: ids.channelId,
186
+ h: ids.threadTs,
187
+ g: ids.generation,
188
+ n: ids.active,
189
+ });
190
+ }
191
+ /**
192
+ * The ids `dumpIds` wrote; throws on anything else. Generation and page are read when they are
193
+ * there: the metadata of an earlier version has no generation (0, older than any form now open)
194
+ * and may carry a whole draft beside the ids, which is left unread.
195
+ */
196
+ export function loadIds(dumped) {
197
+ const data = JSON.parse(dumped);
198
+ if (!isRecord(data) || ![data.a, data.c, data.h].every((id) => typeof id === "string")) {
199
+ throw new Error("malformed form");
200
+ }
201
+ return {
202
+ approvalId: data.a,
203
+ channelId: data.c,
204
+ threadTs: data.h,
205
+ generation: data.g === undefined ? 0 : index(data.g),
206
+ active: data.n === undefined ? 0 : index(data.n),
207
+ };
208
+ }
209
+ function index(value) {
210
+ const found = typeof value === "string" ? value : typeof value === "number" ? String(value) : "";
211
+ if (!/^\d+$/.test(found))
212
+ throw new Error("malformed form");
213
+ return Number(found);
214
+ }
215
+ function fieldsOf(value) {
216
+ return isRecord(value) ? value : {};
217
+ }
218
+ /**
219
+ * The draft with what the question on screen shows now: Slack sends the form's state with Next
220
+ * and Submit, and with a dispatched action, but only for the blocks on screen. The Other and
221
+ * Notes fields leave the screen with the choice, so a field the state does not carry keeps what
222
+ * the draft holds.
223
+ */
224
+ export function absorb(draft, stateValues) {
225
+ const at = draft.active;
226
+ const values = fieldsOf(stateValues);
227
+ const picked = fieldsOf(fieldsOf(values[`q${at}`]).answer);
228
+ const single = fieldsOf(picked.selected_option);
229
+ const several = picked.selected_options;
230
+ const chosen = Array.isArray(several) && several.length > 0
231
+ ? several
232
+ : Object.keys(single).length > 0
233
+ ? [single]
234
+ : [];
235
+ const values_ = chosen.map((o) => fieldsOf(o).value);
236
+ const picks = new Map(draft.picks);
237
+ picks.set(at, values_.filter((v) => v !== OTHER_VALUE).map(index));
238
+ const other = new Set(draft.other);
239
+ if (values_.includes(OTHER_VALUE))
240
+ other.add(at);
241
+ else
242
+ other.delete(at);
243
+ return {
244
+ ...draft,
245
+ picks: new Map([...picks].filter(([, v]) => v.length > 0)),
246
+ other,
247
+ typed: withField(draft.typed, at, fieldsOf(fieldsOf(values[`o${at}`]).other)),
248
+ notes: withField(draft.notes, at, fieldsOf(fieldsOf(values[`n${at}`]).notes)),
249
+ };
250
+ }
251
+ /** `kept` with question `at` set to what `field` holds; unchanged when the field is not on screen. */
252
+ function withField(kept, at, field) {
253
+ const next = new Map(kept);
254
+ if (Object.keys(field).length === 0)
255
+ return next;
256
+ const value = typeof field.value === "string" ? strip(field.value) : "";
257
+ if (value === "")
258
+ next.delete(at);
259
+ else
260
+ next.set(at, value);
261
+ return next;
262
+ }
263
+ function answerAt(draft, questions, at) {
264
+ const question = questions[at];
265
+ if (question === undefined)
266
+ throw new RangeError(`no question ${at}`);
267
+ const picked = draft.picks.get(at) ?? [];
268
+ if (!draft.other.has(at))
269
+ return answerOf(question, picked, undefined);
270
+ // Other chosen with no text is no answer, even beside options picked in a multi select.
271
+ const typed = draft.typed.get(at);
272
+ return typed === undefined ? null : answerOf(question, picked, typed);
273
+ }
274
+ /**
275
+ * Whether a note belongs to the answer of question `at`: only a real option carries one, so Other
276
+ * alone shows no Notes field and sends no note.
277
+ */
278
+ function takesNotes(draft, at) {
279
+ return (draft.picks.get(at) ?? []).length > 0;
280
+ }
281
+ /** An option, or Other with its text, makes an answer, as in the terminal. */
282
+ export function isAnswered(draft, questions, at) {
283
+ return answerAt(draft, questions, at) !== null;
284
+ }
285
+ /** The index of the first question with no answer, else null. */
286
+ export function firstIncomplete(draft, questions) {
287
+ const found = questions.findIndex((_, at) => !isAnswered(draft, questions, at));
288
+ return found === -1 ? null : found;
289
+ }
290
+ /**
291
+ * Every question's answer by its text and the notes added to it by the question's text, or null
292
+ * while one question is unanswered. A note kept for a question with no answer, or whose answer
293
+ * is Other alone, is not sent.
294
+ */
295
+ export function draftAnswers(draft, questions) {
296
+ const answers = [];
297
+ const notes = [];
298
+ for (const [at, question] of questions.entries()) {
299
+ const answer = answerAt(draft, questions, at);
300
+ if (answer === null)
301
+ return null;
302
+ answers.push([question.text, answer]);
303
+ const note = draft.notes.get(at);
304
+ if (note !== undefined && takesNotes(draft, at))
305
+ notes.push([question.text, note]);
306
+ }
307
+ return { answers: Object.fromEntries(answers), notes: Object.fromEntries(notes) };
308
+ }
309
+ /**
310
+ * An option's preview as it is shown: its own lines, with no blank line around them; empty when
311
+ * it holds nothing to read.
312
+ */
313
+ function previewOf(option) {
314
+ const preview = option.preview ?? "";
315
+ return strip(preview) !== "" ? preview.replace(/^\n+|\n+$/g, "") : "";
316
+ }
317
+ /**
318
+ * Whether an option says more than a choice holds: Slack caps an option object's `text` and
319
+ * `description` at 75 characters each (option object reference, read 2026-10-03), and it has no
320
+ * place for a `preview`.
321
+ */
322
+ function saysMore(question) {
323
+ return question.options.some((option) => length(squeezed(option.label)) > OPTION_TEXT_LIMIT ||
324
+ length(squeezed(option.description ?? "")) > OPTION_TEXT_LIMIT ||
325
+ previewOf(option) !== "");
326
+ }
327
+ function optionEntry(at, option, described) {
328
+ const entry = {
329
+ text: { type: "plain_text", text: oneLine(option.label, OPTION_TEXT_LIMIT) },
330
+ value: String(at),
331
+ };
332
+ // Slack refuses an empty text: a description of blanks alone is none.
333
+ const description = oneLine(option.description ?? "", OPTION_TEXT_LIMIT);
334
+ if (described && description !== "") {
335
+ entry.description = { type: "plain_text", text: description };
336
+ }
337
+ return entry;
338
+ }
339
+ function bold(text) {
340
+ return { type: "text", text: cut(text, SECTION_LIMIT), style: { bold: true } };
341
+ }
342
+ /**
343
+ * An option with every word the agent wrote for it, as one rich text block: its label in bold,
344
+ * its description, and its preview in a preformatted element, which keeps its line breaks. Rich
345
+ * text shows its text as written, so nothing in it is read as markup (a modal takes no
346
+ * `markdown` block; block references, read 2026-10-03). The reference names no limit for a rich
347
+ * text element's text: a section's is kept for each.
348
+ */
349
+ function optionWhole(option) {
350
+ const parts = [bold(oneLine(option.label, SECTION_LIMIT))];
351
+ const description = strip(option.description ?? "");
352
+ if (description !== "")
353
+ parts.push({ type: "text", text: `\n${cut(description, SECTION_LIMIT)}` });
354
+ const elements = [{ type: "rich_text_section", elements: parts }];
355
+ const preview = previewOf(option);
356
+ if (preview !== "") {
357
+ const shown = { type: "text", text: cut(preview, SECTION_LIMIT) };
358
+ elements.push({ type: "rich_text_preformatted", elements: [shown] });
359
+ }
360
+ return { type: "rich_text", elements };
361
+ }
362
+ /**
363
+ * A question's header for the tab row: cut, not escaped. Slack's mrkdwn has no escape for `*`, `_`
364
+ * and `~`, and one inside the header would end the bold of the page on screen early, so they are
365
+ * left out of the header here and nowhere else.
366
+ */
367
+ function tabName(question, at) {
368
+ const header = cut(squeezed(question.header.replace(/[*_~]/g, "")), TAB_LIMIT);
369
+ return header !== "" ? header : texts.fill(slackTexts.QUESTION_TAB_UNNAMED, { number: at + 1 });
370
+ }
371
+ /**
372
+ * The terminal's tab row, as one context line: each question's header with `☒` once answered
373
+ * and `☐` before, the page on screen in bold, then `✔ Submit` (bold on the review page).
374
+ */
375
+ function tabRow(draft, questions) {
376
+ const cells = questions.map((question, at) => {
377
+ const cell = `${isAnswered(draft, questions, at) ? "☒" : "☐"} ${shownAsWritten(tabName(question, at))}`;
378
+ return at === draft.active ? `*${cell}*` : cell;
379
+ });
380
+ const review = draft.active === questions.length;
381
+ cells.push(review ? `*${slackTexts.QUESTION_TAB_SUBMIT}*` : slackTexts.QUESTION_TAB_SUBMIT);
382
+ return context(cells.join(" "));
383
+ }
384
+ /** The page that ends a form of several questions: every answer, to read before it is sent. */
385
+ function reviewView(draft, questions, names) {
386
+ const sections = questions.map((question, at) => {
387
+ const answer = answerAt(draft, questions, at);
388
+ const shown = typeof answer === "string" ? answer : (answer ?? []).join(", ");
389
+ let lines = `● ${question.text}\n→ ${shown}`;
390
+ // A note kept for a question with no answer, or answered by Other alone, is not sent, so the
391
+ // page does not show it.
392
+ const noted = draft.notes.get(at);
393
+ if (answer !== null && noted !== undefined && takesNotes(draft, at)) {
394
+ lines += `\n${slackTexts.QUESTION_NOTES}: ${noted}`;
395
+ }
396
+ // Cut as written, on the room the escaped text takes, so no escape is split.
397
+ const [first = "", ...rest] = codeChunks(lines, SECTION_LIMIT - 1);
398
+ const text = `${shownAsWritten(first)}${rest.length > 0 ? "…" : ""}`;
399
+ // One action id per question: Slack wants them unique within a view.
400
+ const edit = button(`question_edit_${at}`, slackTexts.QUESTION_EDIT, String(at));
401
+ return { type: "section", text: { type: "mrkdwn", text }, accessory: edit };
402
+ });
403
+ return {
404
+ type: "modal",
405
+ callback_id: QUESTION_FORM,
406
+ private_metadata: dumpIds(draft),
407
+ title: { type: "plain_text", text: named(slackTexts.QUESTION_TITLE, names) },
408
+ submit: { type: "plain_text", text: slackTexts.QUESTION_SUBMIT_ALL },
409
+ close: { type: "plain_text", text: slackTexts.QUESTION_CLOSE },
410
+ blocks: [
411
+ tabRow(draft, questions),
412
+ { type: "section", text: { type: "mrkdwn", text: slackTexts.QUESTION_REVIEW_HEADING } },
413
+ ...sections,
414
+ ],
415
+ };
416
+ }
417
+ /**
418
+ * The form, one question at a time, as the terminal's tabs: with several questions a tab row
419
+ * (one context line) names them and tells which are answered, the button reads `Next (1/3)` and
420
+ * leads to the next question (whether or not the others have an answer), and `Review` on the
421
+ * last opens a page that lists every answer with an Edit button (`reviewView`), whose `Submit
422
+ * answers` sends. Once the review was reached (`Draft.reviewed`) every page's button reads
423
+ * `Review`. One question has none of that and its `Submit` sends. Each question needs an answer
424
+ * before the next. The question shows as radio buttons, or checkboxes when several may be
425
+ * picked, each option with its description, and Other as the last option, as the terminal's list
426
+ * ends with its own row for a typed answer (tools reference: "type your own text through the
427
+ * Other row"). The choice is required. While Other is chosen its text field shows, required too;
428
+ * Notes shows while a real option is chosen, not for Other alone. When an option says more than
429
+ * a choice holds (`saysMore`), the question and every option are shown whole above the choice,
430
+ * which keeps the labels alone under the question's header (issue #47).
431
+ */
432
+ export function questionView(draft, questions, names) {
433
+ const blocks = [];
434
+ const count = questions.length;
435
+ const at = draft.active;
436
+ if (count > 1 && at === count)
437
+ return reviewView(draft, questions, names);
438
+ const question = questions[at];
439
+ if (question === undefined)
440
+ throw new RangeError(`no question ${at}`);
441
+ if (count > 1)
442
+ blocks.push(tabRow(draft, questions));
443
+ const whole = saysMore(question);
444
+ let label = oneLine(question.text, LABEL_LIMIT);
445
+ if (whole) {
446
+ const asked = { type: "rich_text_section", elements: [bold(label)] };
447
+ blocks.push({ type: "rich_text", elements: [asked] });
448
+ blocks.push(...question.options.map(optionWhole));
449
+ // A header of blanks alone would leave the choice an empty label, which Slack refuses.
450
+ const header = strip(question.header);
451
+ label = oneLine(header !== "" ? header : slackTexts.QUESTION_ANSWER, LABEL_LIMIT);
452
+ }
453
+ const options = question.options.map((o, i) => optionEntry(i, o, !whole));
454
+ const otherOption = {
455
+ text: { type: "plain_text", text: slackTexts.QUESTION_OTHER },
456
+ description: { type: "plain_text", text: slackTexts.QUESTION_OTHER_HINT },
457
+ value: OTHER_VALUE,
458
+ };
459
+ const wantsOther = draft.other.has(at);
460
+ const element = {
461
+ type: question.multiSelect ? "checkboxes" : "radio_buttons",
462
+ action_id: "answer",
463
+ options: [...options, otherOption],
464
+ };
465
+ const real = (draft.picks.get(at) ?? []).flatMap((i) => options[i] ?? []);
466
+ const picked = [...real, ...(wantsOther ? [otherOption] : [])];
467
+ if (picked.length > 0 && question.multiSelect) {
468
+ element.initial_options = picked;
469
+ }
470
+ else if (picked.length > 0) {
471
+ element.initial_option = picked[0];
472
+ }
473
+ // A change of the choice redraws the form (`Requests.onQuestionChoice`): Other's field and
474
+ // Notes come and go with it. The two text fields only report to the draft, so that a form
475
+ // closed with Close and opened again finds what was typed.
476
+ blocks.push({
477
+ type: "input",
478
+ block_id: `q${at}`,
479
+ dispatch_action: true,
480
+ label: { type: "plain_text", text: label },
481
+ element,
482
+ });
483
+ const typing = { trigger_actions_on: ["on_character_entered"] };
484
+ if (wantsOther) {
485
+ const other = {
486
+ type: "plain_text_input",
487
+ action_id: "other",
488
+ max_length: TYPED_LIMIT,
489
+ placeholder: { type: "plain_text", text: slackTexts.QUESTION_OTHER_HINT },
490
+ dispatch_action_config: typing,
491
+ };
492
+ const typed = draft.typed.get(at);
493
+ if (typed)
494
+ other.initial_value = typed;
495
+ blocks.push({
496
+ type: "input",
497
+ block_id: `o${at}`,
498
+ dispatch_action: true,
499
+ label: { type: "plain_text", text: slackTexts.QUESTION_YOUR_ANSWER },
500
+ element: other,
501
+ });
502
+ }
503
+ if (real.length > 0) {
504
+ const notes = {
505
+ type: "plain_text_input",
506
+ action_id: "notes",
507
+ max_length: NOTES_LIMIT,
508
+ placeholder: { type: "plain_text", text: slackTexts.QUESTION_NOTES_HINT },
509
+ dispatch_action_config: typing,
510
+ };
511
+ const noted = draft.notes.get(at);
512
+ if (noted)
513
+ notes.initial_value = noted;
514
+ blocks.push({
515
+ type: "input",
516
+ block_id: `n${at}`,
517
+ optional: true,
518
+ dispatch_action: true,
519
+ label: { type: "plain_text", text: slackTexts.QUESTION_NOTES },
520
+ element: notes,
521
+ });
522
+ }
523
+ const last = at === count - 1;
524
+ return {
525
+ type: "modal",
526
+ callback_id: QUESTION_FORM,
527
+ private_metadata: dumpIds(draft),
528
+ title: { type: "plain_text", text: named(slackTexts.QUESTION_TITLE, names) },
529
+ submit: {
530
+ type: "plain_text",
531
+ text: count === 1
532
+ ? "Submit"
533
+ : last || draft.reviewed
534
+ ? slackTexts.QUESTION_REVIEW
535
+ : texts.fill(slackTexts.QUESTION_NEXT, { number: at + 1, count }),
536
+ },
537
+ close: { type: "plain_text", text: slackTexts.QUESTION_CLOSE },
538
+ blocks,
539
+ };
540
+ }
541
+ /**
542
+ * The question form once its channel is refused: `told` alone, with no Submit and no draft.
543
+ * Slack lets an app close a modal only in answer to a submission, so the open one is replaced.
544
+ */
545
+ export function refusedView(told, names) {
546
+ return {
547
+ type: "modal",
548
+ title: { type: "plain_text", text: named(slackTexts.QUESTION_TITLE, names) },
549
+ close: { type: "plain_text", text: slackTexts.QUESTION_CLOSE },
550
+ blocks: [{ type: "section", text: { type: "mrkdwn", text: told } }],
551
+ };
552
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * `!resume`: Claude Code's session picker, as a message with a Resume button per session.
3
+ *
4
+ * The terminal's `/resume` is interactive and the SDK does not offer it (not among the session's
5
+ * commands, Claude Code 2.1.280), so the daemon answers the word itself, the way the terminal
6
+ * does: `/resume` alone opens the picker, `/resume <session>` resumes by id or name (commands
7
+ * reference, read 2026-09-25). Each row shows what a picker row shows: the session's name or
8
+ * title, the time since its last activity, its git branch and its size (sessions reference, read
9
+ * 2026-09-25). The rows come from the agent's session listing, newest first. Ordering that list
10
+ * by the sessions' last message belongs to the Claude back end, which reads the transcripts.
11
+ */
12
+ import { oneLine, TITLE_LIMIT, texts } from "../../core/port.js";
13
+ import { contextBlock } from "./reply/blocks.js";
14
+ import { take } from "./reply/chars.js";
15
+ import { shownAsWritten } from "./reply/escape.js";
16
+ import * as slackTexts from "./texts.js";
17
+ export const RESUME_ROWS = 20;
18
+ export const RESUME_ACTION = "session_resume";
19
+ // The first characters of a session id the list shows, and the fewest `!resume` takes as an id:
20
+ // the core's `ID_PREFIX_MIN`.
21
+ export const ID_SHOWN = 8;
22
+ const SLACK_TS = /^\d+\.\d+$/;
23
+ /** The Resume button's value: the session, and the thread of the owner's `!resume` message it is
24
+ * resumed into (the list is a top-level post, not that thread). */
25
+ export function resumeValue(sessionId, threadTs) {
26
+ return `${sessionId}@${threadTs}`;
27
+ }
28
+ /** The session id and thread ts a Resume button carries; null for any other shape. The value is
29
+ * untrusted like every click's: only its form is checked here. */
30
+ export function parseResumeValue(value) {
31
+ const at = value.indexOf("@");
32
+ if (at === -1)
33
+ return null;
34
+ const sessionId = value.slice(0, at);
35
+ const threadTs = value.slice(at + 1);
36
+ if (sessionId === "" || !SLACK_TS.test(threadTs))
37
+ return null;
38
+ return [sessionId, threadTs];
39
+ }
40
+ /** As the terminal's picker writes it ("2 days ago", seen 2026-09-25). */
41
+ function age(modifiedMs, now) {
42
+ const seconds = Math.max(0, now.getTime() / 1000 - modifiedMs / 1000);
43
+ for (const [unit, length] of [
44
+ ["day", 86400],
45
+ ["hour", 3600],
46
+ ["minute", 60],
47
+ ]) {
48
+ if (seconds >= length) {
49
+ // The whole quotient: one that the division rounded up is taken back.
50
+ let count = Math.floor(seconds / length);
51
+ if (count * length > seconds)
52
+ count -= 1;
53
+ return `${count} ${unit}${count > 1 ? "s" : ""} ago`;
54
+ }
55
+ }
56
+ return "just now";
57
+ }
58
+ /** `bytes` over `unit`, with one decimal as Python's `f"{x:.1f}"` writes it: an exact tie goes to
59
+ * the even tenth, where `toFixed` goes up. A size over a power of two is exact in a double. */
60
+ function tenths(bytes, unit) {
61
+ const scaled = (bytes / unit) * 10;
62
+ const floor = Math.floor(scaled);
63
+ const rest = scaled - floor;
64
+ const rounded = rest > 0.5 || (rest === 0.5 && floor % 2 === 1) ? floor + 1 : floor;
65
+ return `${Math.floor(rounded / 10)}.${rounded % 10}`;
66
+ }
67
+ /** As the terminal's picker writes it ("953.1KB" for 976,000 bytes: 1,024 per KB). */
68
+ function size(bytes) {
69
+ if (bytes === null)
70
+ return null;
71
+ if (bytes < 1024 * 1024)
72
+ return `${tenths(bytes, 1024)}KB`;
73
+ return `${tenths(bytes, 1024 * 1024)}MB`;
74
+ }
75
+ function row(session, now, threadTs) {
76
+ // Shown as the terminal's picker shows it, HEAD outside a repository included.
77
+ const branch = session.branch ? shownAsWritten(session.branch) : null;
78
+ const title = shownAsWritten(oneLine(session.title, TITLE_LIMIT));
79
+ // The terminal's picker shows no id, since picking a row resumes it; here the id's start is
80
+ // what `!resume <id>` takes. Plain text, as the rest of the row (the maintainer, 2026-09-26).
81
+ const parts = [
82
+ title,
83
+ age(session.lastModified, now),
84
+ branch,
85
+ size(session.size),
86
+ take(session.id, ID_SHOWN),
87
+ ];
88
+ return {
89
+ type: "section",
90
+ block_id: `session-${session.id}`,
91
+ text: { type: "mrkdwn", text: parts.filter((part) => part).join(" · ") },
92
+ accessory: {
93
+ type: "button",
94
+ action_id: RESUME_ACTION,
95
+ value: resumeValue(session.id, threadTs),
96
+ text: { type: "plain_text", text: slackTexts.RESUME_BUTTON },
97
+ },
98
+ };
99
+ }
100
+ /**
101
+ * The picker: the newest RESUME_ROWS of `sessions`, the ones of `directory` that can be resumed.
102
+ * `openElsewhere` counts the sessions a thread already holds (one session lives in one
103
+ * thread): they are left out of the rows, since resuming one is refused anyway, and named by one
104
+ * line under the list, so the limit applies to what the owner can act on. Each Resume button
105
+ * carries `threadTs`, the thread of the owner's `!resume` message, where the session is resumed.
106
+ */
107
+ export function resumeBlocks(directory, sessions, openElsewhere, now, threadTs) {
108
+ // The list's own lines are the daemon's notices, small and grey; the rows keep their button.
109
+ const shown = shownAsWritten(directory);
110
+ let held = [];
111
+ if (openElsewhere === 1)
112
+ held = [contextBlock(slackTexts.RESUME_OPEN_ONE)];
113
+ else if (openElsewhere)
114
+ held = [contextBlock(texts.fill(slackTexts.RESUME_OPEN_MANY, { count: openElsewhere }))];
115
+ if (sessions.length === 0) {
116
+ const empty = held.length > 0 ? slackTexts.RESUME_NONE_LEFT : slackTexts.RESUME_EMPTY;
117
+ return [contextBlock(texts.fill(empty, { directory: shown })), ...held];
118
+ }
119
+ const blocks = [
120
+ contextBlock(texts.fill(texts.RESUME_LIST, { directory: shown })),
121
+ ...sessions.slice(0, RESUME_ROWS).map((s) => row(s, now, threadTs)),
122
+ ];
123
+ if (sessions.length > RESUME_ROWS) {
124
+ blocks.push(contextBlock(texts.fill(slackTexts.RESUME_MORE, { rows: RESUME_ROWS })));
125
+ }
126
+ return [...blocks, ...held];
127
+ }