allaboard 0.0.0-stage → 0.4.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,123 @@
1
+ import { getLogger } from "../../log.js";
2
+ import { isRecord, records } from "../../values.js";
3
+ import { contextBlock, RUNNING_LIST_ID } from "./reply/blocks.js";
4
+ import { describe, gone, NOT_STREAMING } from "./reply/errors.js";
5
+ export const logger = getLogger("allaboard.chat.slack.repair");
6
+ // What a card left running becomes: it never ended, and will not.
7
+ const RUNNING_CARD = ["pending", "in_progress"];
8
+ // Slack's own cap: the read-back count is Slack's (streamed markdown reads back as several
9
+ // blocks), not the sink's, which keeps a margin under it for what it writes itself.
10
+ export const SLACK_BLOCKS = 50;
11
+ /**
12
+ * Stop the reply's message, then rewrite it: its blocks as Slack keeps them, every card left
13
+ * running closed as an error (a stopped message would show it so anyway, until it is updated:
14
+ * measured 2026-09-28), the list of what still runs taken off (found by its `block_id`), plus the
15
+ * line that says it stopped. Kept within Slack's 50 blocks: at
16
+ * the cap the line goes into the last context block (the footer), or is left out if there is
17
+ * none; a block that holds content is never replaced. A failed read or a message already gone
18
+ * is logged and left alone: never replaced with a shorter form that would lose its content.
19
+ * `note` is the line that says it stopped. Never rejects. False when the message is there and
20
+ * was not rewritten (a failed read, a failed rewrite), so the caller can ask again; a stop that
21
+ * failed is not a reason to, since Slack ends a stream itself and the rewrite says the rest. A
22
+ * message Slack no longer has (`GONE`) counts as ended, and one that already carries `note` in
23
+ * a context block is left as it is: a rewrite whose answer was lost may have landed.
24
+ */
25
+ export async function endReply(slack, limiter, place, messageTs, note) {
26
+ const { channel: channelId, threadTs } = place;
27
+ // Whether the stream may have been open: a stop that was refused for another reason is not
28
+ // known to have found it over.
29
+ let streamed = true;
30
+ try {
31
+ await slack.chat.stopStream({ channel: channelId, ts: messageTs });
32
+ }
33
+ catch (error) {
34
+ // the stream is over already when it is: nothing to stop
35
+ if (describe(error) === NOT_STREAMING) {
36
+ streamed = false;
37
+ }
38
+ else {
39
+ logger.warning(`could not stop a crashed reply's stream in ${channelId}/${threadTs}: ${describe(error)}`);
40
+ }
41
+ }
42
+ let answer;
43
+ try {
44
+ answer = await slack.conversations.replies({ channel: channelId, ts: messageTs, limit: 1 });
45
+ }
46
+ catch (error) {
47
+ logger.warning(`could not read a crashed reply's message in ${channelId}/${threadTs}: ${describe(error)}`);
48
+ return gone(error);
49
+ }
50
+ const messages = records(isRecord(answer) ? answer.messages : undefined);
51
+ const message = messages.find((found) => String(found.ts) === messageTs);
52
+ if (message === undefined) {
53
+ logger.info(`a crashed reply's message is gone in ${channelId}/${threadTs}`);
54
+ return true;
55
+ }
56
+ if (saysStopped(records(message.blocks), note))
57
+ return true;
58
+ // The list of what still runs is no card of the reply's own work: it goes, where the card of a
59
+ // call that never ended is closed as an error.
60
+ const read = records(message.blocks);
61
+ const isList = (block) => block.block_id === RUNNING_LIST_ID || block.task_id === RUNNING_LIST_ID;
62
+ const kept = read.filter((block) => !isList(block));
63
+ const callRunning = kept.some((block) => block.type === "task_card" && RUNNING_CARD.includes(String(block.status)));
64
+ // A message kept open only by the list holds a finished answer (its stream is over and no call
65
+ // of it runs): the app did not stop before it, so no note says so.
66
+ const onlyList = kept.length < read.length && !streamed && !callRunning;
67
+ let blocks = kept.map((block) => block.type === "task_card" && RUNNING_CARD.includes(String(block.status))
68
+ ? { ...block, status: "error" }
69
+ : block);
70
+ if (!onlyList) {
71
+ if (blocks.length < SLACK_BLOCKS)
72
+ blocks.push({ ...contextBlock(note) });
73
+ else
74
+ blocks = sayStoppedInTheFooter(blocks, note);
75
+ }
76
+ try {
77
+ await limiter.acquire();
78
+ await slack.chat.update({
79
+ channel: channelId,
80
+ ts: messageTs,
81
+ text: onlyList && typeof message.text === "string" ? message.text : note,
82
+ // Blocks as Slack read them back, sent back as they are: opaque to the library's types.
83
+ blocks: blocks,
84
+ });
85
+ }
86
+ catch (error) {
87
+ logger.warning(`could not rewrite a crashed reply in ${channelId}/${threadTs}: ${describe(error)}`);
88
+ return gone(error);
89
+ }
90
+ return true;
91
+ }
92
+ /** Whether a context block's text already ends with `note`: where this file writes it. */
93
+ function saysStopped(blocks, note) {
94
+ return blocks.some((block) => {
95
+ if (block.type !== "context")
96
+ return false;
97
+ const first = records(block.elements)[0];
98
+ return typeof first?.text === "string" && first.text.endsWith(note);
99
+ });
100
+ }
101
+ /**
102
+ * The blocks with the stopped line added to the text of the footer: the context block that ends
103
+ * the message under a divider. A message that rests has no footer, and a context block that is
104
+ * not one (a line of the daemon's own) is not rewritten: the blocks are then unchanged, and the
105
+ * edit's own `text` says the stop.
106
+ */
107
+ function sayStoppedInTheFooter(blocks, note) {
108
+ const index = blocks.length - 1;
109
+ if (blocks[index]?.type !== "context" || blocks[index - 1]?.type !== "divider")
110
+ return blocks;
111
+ const last = blocks[index];
112
+ if (last === undefined)
113
+ return blocks;
114
+ const elements = records(last.elements);
115
+ const first = elements[0];
116
+ if (first === undefined || (first.type !== "mrkdwn" && first.type !== "plain_text")) {
117
+ return blocks;
118
+ }
119
+ const text = typeof first.text === "string" ? first.text : "";
120
+ const joined = `${text} · ${note}`.replace(/^[ ·]+/, "");
121
+ const rewritten = { ...last, elements: [{ ...first, text: joined }, ...elements.slice(1)] };
122
+ return blocks.map((block, i) => (i === index ? rewritten : block));
123
+ }
@@ -0,0 +1,372 @@
1
+ /**
2
+ * The blocks and the stream chunks a reply is written with, and the limits they are held to.
3
+ *
4
+ * The shapes are the ones the recordings read back; the pinned
5
+ * `@slack/types` 3.2.0 declares them as `MarkdownBlock`, `ContextBlock`, `DividerBlock`,
6
+ * `TaskCardBlock`, `ContainerBlock`, `RichTextBlock` and the chunks of `AnyChunk`.
7
+ */
8
+ import { NESTED, STOPPED, shownPreview } from "../../../core/port.js";
9
+ import { RECORD_MARK } from "../texts.js";
10
+ import { at, lastNewline, len, take } from "./chars.js";
11
+ import { shownAsWritten } from "./escape.js";
12
+ import { markdownBlocks } from "./markdown.js";
13
+ // A message holds at most 12,000 characters and 50 blocks or task cards (measured 2026-09-28),
14
+ // a text counting the blocks Slack makes of it (`markdownStarts`);
15
+ // the margins keep a preview that arrives after its card, and the footer, inside them. A stream
16
+ // and a post count the text of a collapsed container toward that cap; `chat.update` does not
17
+ // (measured 2026-10-06, slack-sdk 3.44.1: 50 containers of 10,000 characters taken, nothing ever
18
+ // refused). A message written by update therefore counts a container as its block only: at most
19
+ // BLOCKS_LIMIT containers of MESSAGE_LIMIT characters, 495,000, which is what was measured in the
20
+ // shape of a call with no card (45 of 11,000, taken).
21
+ export const MESSAGE_LIMIT = 11_000;
22
+ export const BLOCKS_LIMIT = 45;
23
+ export const FALLBACK_LIMIT = 3_000;
24
+ // What a message's `text` says: the banner of a notification, short, since a `chat.update` whose
25
+ // `text` is long fails `msg_too_long` (measured 2026-09-28).
26
+ export const BANNER_LIMIT = 300;
27
+ // A task card's details and output are text of a rich text element; a context block's text
28
+ // holds at most 3,000 characters.
29
+ export const CARD_TEXT_LIMIT = 2_900;
30
+ export const CARD_TITLE_LIMIT = 150;
31
+ // The list of what still runs: its card's id (a tool call's id never has this form), and how many
32
+ // lines it draws before `+N more`. With the footer it stays inside the margins MESSAGE_LIMIT
33
+ // leaves: 6 lines of 80 characters, a title of 150, 3 blocks of the 5 spare.
34
+ export const RUNNING_LIST_ID = "running-list";
35
+ export const RUNNING_LIST_LINES = 6;
36
+ export const CARD_TEXT_FIELDS = ["title", "details", "output"];
37
+ // A stream keeps the details and the output of every `task_update` of a card, each added to what
38
+ // the card holds; an update that carries neither leaves them, and its title replaces the title
39
+ // (measured 2026-10-01 and 2026-10-08).
40
+ export const CARD_APPENDED = ["details", "output"];
41
+ // What a card costs a streamed message besides its characters: the card, each of the two texts
42
+ // it holds, and each line of them. Slack stores a card's text as rich text, a line an element,
43
+ // and its cap follows what it stores. Replayed over the 15 streams of 2026-10-01 (slack-sdk
44
+ // 3.44.1), text and cards counted this way came to 13,514 at most in an accepted append and to
45
+ // 13,801 at least in a refused one; MESSAGE_LIMIT sits under both.
46
+ export const CARD_COST = 100;
47
+ export const CARD_FIELD_COST = 150;
48
+ export const CARD_LINE_COST = 50;
49
+ // What stands where a preview arrived too late for its message.
50
+ export const PREVIEW_CUT = "Preview left out: it did not fit this message.";
51
+ export const TERMINAL = ["complete", "error"];
52
+ // A container's title and subtitle (Block Kit reference).
53
+ const CONTAINER_TITLE_LIMIT = 150;
54
+ export function contextBlock(text) {
55
+ return { type: "context", elements: [{ type: "mrkdwn", text }] };
56
+ }
57
+ // The `block_id` of a line about the work, with the place of its text in the reply: a context
58
+ // block as the count of a run of calls is, told apart from one and from the footer by its id.
59
+ export const ASIDE_ID = "aside-";
60
+ /** A line about the work (a task's end), small and grey. `at` is unique within the message. */
61
+ export function asideBlock(text, at) {
62
+ return { ...contextBlock(text), block_id: `${ASIDE_ID}${at}` };
63
+ }
64
+ /**
65
+ * A `plain_text` text object. `emoji` false says `:name:` in `text` is not to be turned into an
66
+ * emoji (text object reference, read 2026-10-06); left out when null.
67
+ */
68
+ export function plainTextObject(text, emoji = null) {
69
+ const shown = { type: "plain_text", text };
70
+ if (emoji !== null)
71
+ shown.emoji = emoji;
72
+ return shown;
73
+ }
74
+ // A text object's limit, as a context block's mrkdwn element holds one (Block Kit reference).
75
+ export const CONTEXT_LIMIT = 3000;
76
+ /** A daemon notice fitted into one context element: cut with `…` past its limit. */
77
+ export function noticeText(text) {
78
+ const end = at(text, CONTEXT_LIMIT);
79
+ return end >= text.length ? text : `${take(text, CONTEXT_LIMIT - 1)}…`;
80
+ }
81
+ /**
82
+ * A preview's lines of words as one context element's text: each indented under its card and
83
+ * shown as written, the whole cut with `…` past the element's limit. A record has no card: its
84
+ * title leads, marked, with the lines under it.
85
+ */
86
+ export function plainLines(body, title = null) {
87
+ const lines = body.split("\n").map((line) => NESTED + shownAsWritten(line));
88
+ if (title !== null)
89
+ lines.unshift(RECORD_MARK + shownAsWritten(title));
90
+ return noticeText(lines.join("\n"));
91
+ }
92
+ // Invisible, so a block that holds only this one shows no text of its own; never pasted as a
93
+ // literal character in source, always by its code.
94
+ export const ZERO_WIDTH_SPACE = String.fromCodePoint(0x200b);
95
+ /**
96
+ * A new file's first lines as code blocks, split where a block would pass its limit. A fence
97
+ * inside the file must not close the block early: every run of three or more backticks is
98
+ * broken up, as `shownAsWritten` breaks every one.
99
+ */
100
+ export function previewBlocks(body) {
101
+ const broken = body.replace(/`{3,}/g, (run) => run.split("").join(ZERO_WIDTH_SPACE));
102
+ return split(broken)
103
+ .filter((chunk) => chunk !== "")
104
+ .map((chunk) => ({ type: "markdown", text: `\`\`\`\n${chunk}\n\`\`\`` }));
105
+ }
106
+ /**
107
+ * A preview's body, collapsed: a full-width container per MESSAGE_LIMIT piece of it, closed
108
+ * until the owner opens it. A call with no card is titled with its line, in code style as a tool
109
+ * line is (`asCode`), and says the preview's sentence under it; under a card, which is the
110
+ * call's line, the title is the sentence alone. The body sits in the message itself, so it opens
111
+ * after a restart too.
112
+ */
113
+ export function previewContainers(title, body, options = {}) {
114
+ const { subtitle = "", language = "diff", asCode = false } = options;
115
+ const shown = take(title, CONTAINER_TITLE_LIMIT);
116
+ return split(body)
117
+ .filter((chunk) => chunk !== "")
118
+ .map((chunk) => {
119
+ const block = {
120
+ type: "container",
121
+ title: { type: "plain_text", text: shown },
122
+ width: "full",
123
+ is_collapsible: true,
124
+ default_collapsed: true,
125
+ // A `markdown` block is not allowed in a container: rich text is, and its text is
126
+ // literal, so no fence to break. Slack desktop colours `diff`; mobile colours nothing.
127
+ child_blocks: [
128
+ {
129
+ type: "rich_text",
130
+ elements: [
131
+ {
132
+ type: "rich_text_preformatted",
133
+ ...(language ? { language } : {}),
134
+ elements: [{ type: "text", text: chunk }],
135
+ },
136
+ ],
137
+ },
138
+ ],
139
+ };
140
+ // The plain title is the fallback of a client that does not draw the rich one.
141
+ if (asCode) {
142
+ block.rich_text_title = {
143
+ type: "rich_text",
144
+ elements: [
145
+ {
146
+ type: "rich_text_section",
147
+ elements: [{ type: "text", text: shown, style: { code: true } }],
148
+ },
149
+ ],
150
+ };
151
+ }
152
+ if (subtitle) {
153
+ block.subtitle = { type: "plain_text", text: take(subtitle, CONTAINER_TITLE_LIMIT) };
154
+ }
155
+ return block;
156
+ });
157
+ }
158
+ export function blockText(block) {
159
+ switch (block.type) {
160
+ case "markdown":
161
+ return block.text;
162
+ case "container":
163
+ return block.child_blocks
164
+ .flatMap((child) => child.elements.flatMap((pre) => pre.elements.map((e) => e.text)))
165
+ .join("");
166
+ case "context":
167
+ return block.elements.map((element) => element.text).join("");
168
+ default:
169
+ return "";
170
+ }
171
+ }
172
+ /**
173
+ * What a refused write sent, for the log, never its content: the characters of text, the blocks,
174
+ * and how many of them are task cards.
175
+ */
176
+ export function blocksSizes(blocks) {
177
+ const cards = blocks.filter((block) => block.type === "task_card").length;
178
+ const text = blocks.reduce((sum, block) => sum + len(blockText(block)), 0);
179
+ return [text, blocks.length, cards];
180
+ }
181
+ /**
182
+ * A message's text with no block: what a refused final write is retried with. Slack caps a
183
+ * text-only message at 4,000 characters (chat.update reference, 2026-09-25).
184
+ */
185
+ export function plainText(blocks) {
186
+ let body = blocks
187
+ .flatMap((block) => (block.type === "markdown" ? [block.text] : []))
188
+ .join("\n\n");
189
+ const end = at(body, FALLBACK_LIMIT);
190
+ if (end < body.length)
191
+ body = `${body.slice(0, end)}…`;
192
+ return body || "…";
193
+ }
194
+ /** Cut a body into messages of at most MESSAGE_LIMIT characters, at line breaks if possible. */
195
+ export function split(body) {
196
+ const chunks = [];
197
+ let rest = body;
198
+ for (;;) {
199
+ // The index that ends MESSAGE_LIMIT characters: the end of the text when it has no more.
200
+ const end = at(rest, MESSAGE_LIMIT);
201
+ if (end >= rest.length)
202
+ break;
203
+ const cut = lastNewline(rest, end);
204
+ if (cut <= 0) {
205
+ chunks.push(rest.slice(0, end));
206
+ rest = rest.slice(end);
207
+ }
208
+ else {
209
+ chunks.push(rest.slice(0, cut));
210
+ rest = rest.slice(cut + 1);
211
+ }
212
+ }
213
+ chunks.push(rest);
214
+ return chunks;
215
+ }
216
+ /**
217
+ * What a tool's card says, in the terminal's words: the title, the state, what it is doing now
218
+ * while it runs, and its output when it failed, was stopped, or has a preview's sentence. A
219
+ * running call, a task and a stopped call keep the words their tool line had.
220
+ */
221
+ export function cardFields(update) {
222
+ const view = shownPreview(update);
223
+ let title = view ? view.title : update.title;
224
+ if (update.calls) {
225
+ // How much a subagent has done: its latest call alone does not say.
226
+ title += ` · ${update.calls} call${update.calls === 1 ? "" : "s"}`;
227
+ }
228
+ const fields = { title: take(title, CARD_TITLE_LIMIT), status: update.status };
229
+ const output = update.output;
230
+ if (view) {
231
+ if (view.summary)
232
+ fields.output = take(view.summary, CARD_TEXT_LIMIT);
233
+ }
234
+ else if (output && (update.status === "error" || output === STOPPED)) {
235
+ fields.output = take(output, CARD_TEXT_LIMIT);
236
+ }
237
+ else if (update.status === "in_progress" && update.details) {
238
+ fields.details = take(update.details, CARD_TEXT_LIMIT);
239
+ }
240
+ return fields;
241
+ }
242
+ /**
243
+ * What a tool's card says, as a `task_update` chunk. A stream is sent `cardAddition` of it: the
244
+ * card is updated in place by its id, and its details and output only grow.
245
+ */
246
+ export function cardChunk(update) {
247
+ return { type: "task_update", id: update.id, ...cardFields(update) };
248
+ }
249
+ /**
250
+ * What to add to the text a card holds so that it ends with `wanted`: the lines of `wanted` past
251
+ * those the card already ends with, a line break first. Empty when it lacks nothing. Ten equal
252
+ * lines followed by an eleventh read as nothing new.
253
+ */
254
+ export function lacking(held, wanted) {
255
+ if (!held)
256
+ return wanted;
257
+ const has = held.split("\n");
258
+ const wants = wanted.split("\n");
259
+ let shared = 0;
260
+ for (let n = Math.min(has.length, wants.length); n > 0; n -= 1) {
261
+ const tail = has.slice(-n);
262
+ if (tail.every((line, i) => line === wants[i])) {
263
+ shared = n;
264
+ break;
265
+ }
266
+ }
267
+ return wants
268
+ .slice(shared)
269
+ .map((line) => `\n${line}`)
270
+ .join("");
271
+ }
272
+ function newlines(text) {
273
+ let count = 0;
274
+ for (let i = text.indexOf("\n"); i !== -1; i = text.indexOf("\n", i + 1))
275
+ count += 1;
276
+ return count;
277
+ }
278
+ /**
279
+ * The chunk that brings a stream's card to `chunk`, what the card holds after it, and what it
280
+ * costs the message. `sent` is the card's last chunk (undefined for a new card) and `held` the
281
+ * details and the output Slack keeps for it. A card already in the message leaves out the
282
+ * details that do not fit `room`, and keeps those it holds; its output, which says how the call
283
+ * ended, is sent whatever the room.
284
+ */
285
+ export function cardAddition(chunk, sent, held, room) {
286
+ const told = {
287
+ type: chunk.type,
288
+ id: chunk.id,
289
+ title: chunk.title,
290
+ status: chunk.status,
291
+ };
292
+ // A title that alternates between long and short (a run's card: the call that runs, then the
293
+ // counts) is charged once for its longest, not again each time it grows back.
294
+ const after = { ...held };
295
+ const longest = held.title ?? (sent === undefined ? 0 : len(sent.title));
296
+ let cost = sent === undefined ? CARD_COST + len(chunk.title) : Math.max(0, len(chunk.title) - longest);
297
+ after.title = Math.max(longest, len(chunk.title));
298
+ for (const key of CARD_APPENDED) {
299
+ const wanted = chunk[key];
300
+ if (wanted === undefined)
301
+ continue; // nothing to say: the card keeps what it holds
302
+ const has = held[key];
303
+ const more = lacking(has ?? "", wanted);
304
+ if (!more)
305
+ continue;
306
+ let price = len(more) + CARD_LINE_COST * (newlines(more) + (has === undefined ? 1 : 0));
307
+ if (has === undefined)
308
+ price += CARD_FIELD_COST;
309
+ if (key === "details" && sent !== undefined && room !== null && cost + price > room)
310
+ continue;
311
+ told[key] = more;
312
+ after[key] = (has ?? "") + more;
313
+ cost += price;
314
+ }
315
+ return [told, after, cost];
316
+ }
317
+ export function richText(text) {
318
+ return {
319
+ type: "rich_text",
320
+ elements: [{ type: "rich_text_section", elements: [{ type: "text", text }] }],
321
+ };
322
+ }
323
+ /**
324
+ * The `task_card` block of a tool, for a message that is no longer a stream (recorded shape:
325
+ * `details` and `output` are rich text).
326
+ */
327
+ export function cardBlock(update) {
328
+ const fields = cardFields(update);
329
+ const block = {
330
+ type: "task_card",
331
+ task_id: update.id,
332
+ title: fields.title,
333
+ status: fields.status,
334
+ };
335
+ for (const key of CARD_APPENDED) {
336
+ const value = fields[key];
337
+ if (value !== undefined)
338
+ block[key] = richText(value);
339
+ }
340
+ return block;
341
+ }
342
+ /**
343
+ * The card of the list of what still runs: one `task_card` that keeps its `block_id` for its whole
344
+ * life, since Slack leaves a card with a fixed id open across an edit (measured 2026-10-12,
345
+ * desktop). Past `RUNNING_LIST_LINES` lines the rest is told as `+N more`.
346
+ */
347
+ export function runningListBlock(running) {
348
+ const lines = running.lines.length > RUNNING_LIST_LINES
349
+ ? [
350
+ ...running.lines.slice(0, RUNNING_LIST_LINES),
351
+ `+${running.lines.length - RUNNING_LIST_LINES} more`,
352
+ ]
353
+ : running.lines;
354
+ const block = {
355
+ type: "task_card",
356
+ block_id: RUNNING_LIST_ID,
357
+ task_id: RUNNING_LIST_ID,
358
+ title: take(running.title, CARD_TITLE_LIMIT),
359
+ status: "in_progress",
360
+ };
361
+ if (lines.length > 0)
362
+ block.details = richText(take(lines.join("\n"), CARD_TEXT_LIMIT));
363
+ return block;
364
+ }
365
+ /** Whether the block is the list of what still runs. */
366
+ export function isRunningList(block) {
367
+ return block.type === "task_card" && block.block_id === RUNNING_LIST_ID;
368
+ }
369
+ /** The blocks of a message as Slack counts them. */
370
+ export function blocksCount(blocks) {
371
+ return blocks.reduce((sum, block) => sum + (block.type === "markdown" ? markdownBlocks(block.text) : 1), 0);
372
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Text counted and trimmed in code points, which is what the limits of a reply were measured
3
+ * against.
4
+ *
5
+ * Python's `len` and its slices count code points; a JavaScript string counts UTF-16 units, two
6
+ * for an emoji or any other character past U+FFFF (the red and green squares of every diff
7
+ * preview are such). The rule the reply's code follows:
8
+ *
9
+ * - an offset into a text (a cursor, what a stream was sent up to, a block's start) is a UTF-16
10
+ * index, and offsets are only ever added to `.length` values and to each other;
11
+ * - a size held against a limit (`MESSAGE_LIMIT`, a card's cost, a title's 150 characters) is
12
+ * `len`, in code points;
13
+ * - a cut "after N characters" goes through `at` or `take`, which turn the count into the index
14
+ * that ends that many code points, so a cut never lands inside a surrogate pair.
15
+ */
16
+ import { isHigh, isLow, len } from "../../../text.js";
17
+ export { len };
18
+ /** The UTF-16 index that ends the first `count` code points of `text`: where `text[:count]` cuts. */
19
+ export function at(text, count) {
20
+ if (count <= 0)
21
+ return 0;
22
+ // A text has at most as many code points as units: nothing to scan when it is that short.
23
+ if (count >= text.length)
24
+ return text.length;
25
+ let index = 0;
26
+ for (let taken = 0; taken < count && index < text.length; taken += 1) {
27
+ index +=
28
+ isHigh(text.charCodeAt(index)) && index + 1 < text.length && isLow(text.charCodeAt(index + 1))
29
+ ? 2
30
+ : 1;
31
+ }
32
+ return index;
33
+ }
34
+ /** Python's `text[:count]`. */
35
+ export function take(text, count) {
36
+ return text.slice(0, at(text, count));
37
+ }
38
+ /** Python's `text.lstrip("\n")`. */
39
+ export function lstripNewlines(text) {
40
+ let start = 0;
41
+ while (text.charCodeAt(start) === 0x0a)
42
+ start += 1;
43
+ return text.slice(start);
44
+ }
45
+ /** Python's `text.rstrip("\n")`. */
46
+ export function rstripNewlines(text) {
47
+ let end = text.length;
48
+ while (end > 0 && text.charCodeAt(end - 1) === 0x0a)
49
+ end -= 1;
50
+ return text.slice(0, end);
51
+ }
52
+ /** Python's `text.strip("\n")`. */
53
+ export function stripNewlines(text) {
54
+ return rstripNewlines(lstripNewlines(text));
55
+ }
56
+ /** Python's `text.rfind("\n", 0, end)`, `end` a UTF-16 index: -1 when there is none. */
57
+ export function lastNewline(text, end) {
58
+ return end <= 0 ? -1 : text.lastIndexOf("\n", end - 1);
59
+ }