allaboard 0.5.0 → 0.6.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 (53) hide show
  1. package/README.md +9 -7
  2. package/dist/agent/claude/backend.js +73 -2
  3. package/dist/agent/claude/capabilities.js +4 -0
  4. package/dist/agent/claude/module.js +55 -0
  5. package/dist/agent/claude/translate.js +6 -2
  6. package/dist/agent/claude/trust.js +22 -0
  7. package/dist/chat/seam.js +3 -0
  8. package/dist/chat/slack/app/app.js +52 -4
  9. package/dist/chat/slack/app/delete-thread.js +90 -0
  10. package/dist/chat/slack/app/guards.js +44 -0
  11. package/dist/chat/slack/app/home-controls.js +8 -1
  12. package/dist/chat/slack/channels.js +23 -5
  13. package/dist/chat/slack/delete-thread.js +37 -0
  14. package/dist/chat/slack/delete.js +13 -7
  15. package/dist/chat/slack/home.js +194 -35
  16. package/dist/chat/slack/module.js +64 -14
  17. package/dist/chat/slack/onboarding.js +159 -0
  18. package/dist/chat/slack/reply/blocks.js +27 -11
  19. package/dist/chat/slack/reply/sinks.js +3 -1
  20. package/dist/chat/slack/tag.js +317 -0
  21. package/dist/chat/slack/texts.js +8 -0
  22. package/dist/chat/slack/thread.js +62 -2
  23. package/dist/chat/slack/tokens.js +15 -0
  24. package/dist/cli.js +43 -4
  25. package/dist/core/cleanup.js +66 -3
  26. package/dist/core/commands.js +40 -0
  27. package/dist/core/config.js +19 -11
  28. package/dist/core/files/listing.js +19 -0
  29. package/dist/core/flows/bind.js +29 -6
  30. package/dist/core/flows/flows.js +2 -0
  31. package/dist/core/flows/gone.js +39 -0
  32. package/dist/core/flows/inbound.js +3 -0
  33. package/dist/core/flows/messages.js +4 -2
  34. package/dist/core/flows/status.js +98 -48
  35. package/dist/core/flows/words.js +80 -1
  36. package/dist/core/guide.js +2 -0
  37. package/dist/core/port/cleanup.js +1 -0
  38. package/dist/core/port.js +1 -0
  39. package/dist/core/sessions/directory.js +47 -8
  40. package/dist/core/sessions/manager.js +32 -17
  41. package/dist/core/sessions/quote.js +57 -0
  42. package/dist/core/sessions/session.js +324 -35
  43. package/dist/core/sessions/title.js +98 -0
  44. package/dist/core/state.js +25 -0
  45. package/dist/core/texts.js +58 -3
  46. package/dist/core/wizard.js +306 -0
  47. package/dist/daemon.js +23 -11
  48. package/dist/host/program.js +22 -7
  49. package/dist/host/prompter.js +61 -0
  50. package/dist/module-setup.js +6 -0
  51. package/dist/version.js +4 -0
  52. package/package.json +5 -2
  53. package/slack-app-manifest.json +2 -1
@@ -6,16 +6,23 @@
6
6
  */
7
7
  import { createHash } from "node:crypto";
8
8
  import { ConfigError, } from "../../core/port.js";
9
+ import { getLogger } from "../../log.js";
10
+ import { setupGuide } from "../../version.js";
9
11
  import { ChatError } from "../seam.js";
10
12
  import { socketReceiver } from "./app/app.js";
11
13
  import { ChannelGuard } from "./app/guards.js";
12
14
  import { prepareUploads } from "./attachments.js";
13
15
  import { ThreadDeleter } from "./delete.js";
14
16
  import { Home } from "./home.js";
17
+ import { slackSetup } from "./onboarding.js";
15
18
  import { repliesClient, sharedClient } from "./reply/clients.js";
16
19
  import { describe } from "./reply/errors.js";
17
20
  import { UpdateLimiter } from "./reply/limiter.js";
21
+ import { RootEditor } from "./tag.js";
22
+ import { SETUP_SLACK_APP } from "./texts.js";
18
23
  import { SlackChat } from "./thread.js";
24
+ import { wrongKind } from "./tokens.js";
25
+ export const logger = getLogger("allaboard.chat.slack.module");
19
26
  /**
20
27
  * The daemon's two Slack clients on one token (the retry policy is in `reply/clients.ts`): the
21
28
  * shared one for everything but a reply, and the one replies are written with.
@@ -35,10 +42,16 @@ export function authField(answer, key) {
35
42
  return value;
36
43
  }
37
44
  /**
38
- * What deletes a thread for the Home tab, when the owner's user token is configured. The token
39
- * must be the owner's own, in the bot's workspace: it deletes as whoever it belongs to.
45
+ * The client of the owner's user token with the token's scopes, or null when no token is
46
+ * configured. The token must be the owner's own, in the bot's workspace: what it writes is
47
+ * written as whoever it belongs to, so it is checked once here for everything that uses it (the
48
+ * thread deleter, the root editor). The scopes are read from the same `auth.test`: the Web API
49
+ * reference for `auth.test` lists none (read 2026-10-19), but `@slack/web-api` 8.2.0 types
50
+ * `WebAPICallResult.response_metadata.scopes` and fills it from the `X-OAuth-Scopes` header of
51
+ * every response (`WebClient.js`), and a user token's `auth.test` answered
52
+ * `identify,chat:write,pins:write` on a dev app on 2026-10-19.
40
53
  */
41
- export async function deleter(userToken, slack, identity, state, sessions, names, clients = CLIENTS) {
54
+ export async function ownerClient(userToken, identity, clients = CLIENTS) {
42
55
  if (userToken === null)
43
56
  return null;
44
57
  const owner = clients.owner(userToken);
@@ -47,6 +60,36 @@ export async function deleter(userToken, slack, identity, state, sessions, names
47
60
  authField(who, "team_id") !== identity.teamId) {
48
61
  throw new ConfigError("SLACK_USER_TOKEN must be the owner's own token, in the bot's workspace");
49
62
  }
63
+ return { client: owner, scopes: who.response_metadata?.scopes ?? null };
64
+ }
65
+ /** The scope `!tag` and `!untag` need beyond what the Home tab's Delete does (`chat:write`). */
66
+ export const PINS_SCOPE = "pins:write";
67
+ /**
68
+ * Whether the owner's token can pin, which `!tag` needs and a title does not. A token made before
69
+ * the manifest asked for `pins:write` has the Delete's scope alone: tags are then not offered,
70
+ * with one line saying why, and Delete and titles keep working. A token whose scopes Slack did
71
+ * not report is not trusted with tags either.
72
+ */
73
+ export function canTag(owner) {
74
+ if (owner === null)
75
+ return false;
76
+ if (owner.scopes === null) {
77
+ logger.warning(`Slack lists no scopes for SLACK_USER_TOKEN, so !tag and !untag are not offered: reinstall the app with the user scope ${PINS_SCOPE} (see ${setupGuide(SETUP_SLACK_APP)})`);
78
+ return false;
79
+ }
80
+ if (!owner.scopes.includes(PINS_SCOPE)) {
81
+ logger.warning(`SLACK_USER_TOKEN lacks the scope ${PINS_SCOPE}, so !tag is not offered until the app is reinstalled with that scope (see ${setupGuide(SETUP_SLACK_APP)})`);
82
+ return false;
83
+ }
84
+ return true;
85
+ }
86
+ /**
87
+ * What deletes a thread for the Home tab and for `!delete-thread`, when the owner has a client of
88
+ * their own.
89
+ */
90
+ export function deleter(owner, slack, identity, state, sessions, names) {
91
+ if (owner === null)
92
+ return null;
50
93
  return new ThreadDeleter(slack, owner, {
51
94
  botUserId: identity.botUserId,
52
95
  ownerUserId: identity.ownerUserId,
@@ -73,22 +116,21 @@ function variable(context, name) {
73
116
  }
74
117
  /** The shape of each token, checked before Slack is called: a swapped pair is told at once. */
75
118
  function checkTokens(context) {
76
- if (!variable(context, "SLACK_BOT_TOKEN").startsWith("xoxb-")) {
77
- throw new ConfigError("SLACK_BOT_TOKEN must be the Bot User OAuth Token (xoxb-...)");
78
- }
79
- if (!variable(context, "SLACK_APP_TOKEN").startsWith("xapp-")) {
80
- throw new ConfigError("SLACK_APP_TOKEN must be the app-level token (xapp-...)");
81
- }
119
+ const wrong = wrongKind("SLACK_BOT_TOKEN", variable(context, "SLACK_BOT_TOKEN")) ??
120
+ wrongKind("SLACK_APP_TOKEN", variable(context, "SLACK_APP_TOKEN"));
121
+ if (wrong !== null)
122
+ throw new ConfigError(wrong);
82
123
  const userToken = context.variables.SLACK_USER_TOKEN;
83
- if (userToken !== undefined && !userToken.startsWith("xoxp-")) {
84
- throw new ConfigError("SLACK_USER_TOKEN must be the User OAuth Token (xoxp-...)");
85
- }
124
+ const wrongUser = userToken === undefined ? null : wrongKind("SLACK_USER_TOKEN", userToken);
125
+ if (wrongUser !== null)
126
+ throw new ConfigError(wrongUser);
86
127
  }
87
128
  /** The registry's entry for Slack. */
88
129
  export function slackChat(options = {}) {
89
130
  return {
90
131
  required: ["SLACK_BOT_TOKEN", "SLACK_APP_TOKEN", "SLACK_OWNER_USER_ID"],
91
132
  optional: ["SLACK_USER_TOKEN"],
133
+ setup: (instance) => slackSetup(instance, options.probe),
92
134
  async build(context) {
93
135
  checkTokens(context);
94
136
  try {
@@ -129,10 +171,13 @@ async function assemble(options, context) {
129
171
  // The provider is made last, from these parts; they ask it for the names when they show a
130
172
  // line, which is after the core exists.
131
173
  const names = () => chat.names();
132
- const threadDeleter = await deleter(context.variables.SLACK_USER_TOKEN ?? null, slack, identity, state, {
174
+ const owner = await ownerClient(context.variables.SLACK_USER_TOKEN ?? null, identity, clients);
175
+ // One editor serves tags and titles, so their edits of a root queue behind one another.
176
+ const roots = owner === null ? null : new RootEditor(slack, owner.client);
177
+ const threadDeleter = deleter(owner?.client ?? null, slack, identity, state, {
133
178
  release: (channelId, threadId) => context.core().sessions.release(channelId, threadId),
134
179
  free: (channelId, threadId) => context.core().sessions.free(channelId, threadId),
135
- }, names, clients);
180
+ }, names);
136
181
  const home = new Home(slack, {
137
182
  ownerUserId: identity.ownerUserId,
138
183
  teamId: identity.teamId,
@@ -144,6 +189,7 @@ async function assemble(options, context) {
144
189
  clean: (channelId) => threadDeleter.clean(channelId),
145
190
  }),
146
191
  update: () => context.core().updates?.line() ?? null,
192
+ cleanup: () => context.core().cleanup ?? null,
147
193
  clock: context.clock,
148
194
  });
149
195
  const uploads = options.uploads ?? context.scratchDir;
@@ -156,11 +202,15 @@ async function assemble(options, context) {
156
202
  clock: context.clock,
157
203
  steady: context.steady,
158
204
  agent: () => context.core().agent,
205
+ ...(roots !== null && { roots, canTag: canTag(owner) }),
159
206
  app: {
160
207
  core: () => context.core(),
161
208
  config: { botToken, allowedRoot: context.allowedRoot },
162
209
  guard: new ChannelGuard(slack, identity),
163
210
  home,
211
+ ...(threadDeleter !== null && {
212
+ deleteThread: (channelId, threadId) => threadDeleter.delete(channelId, threadId),
213
+ }),
164
214
  uploads,
165
215
  receiver: (options.receiver ?? socketReceiver)(appToken),
166
216
  appKey: appTokenKey(appToken),
@@ -0,0 +1,159 @@
1
+ /**
2
+ * What `allaboard setup` asks for Slack: the link that creates the app from the package's manifest,
3
+ * the three tokens, each checked with Slack as it is pasted, and the owner's member ID. The core
4
+ * runs the flow (`core/wizard.ts`); this file holds the questions and what a Slack answer means.
5
+ *
6
+ * A token is checked with the one call that needs no scope (Slack's method reference, read
7
+ * 2026-10-18): `auth.test` for the bot and user tokens, `apps.connections.open` for the app-level
8
+ * token, the only method documented for it. The latter only asks for a WebSocket URL; nothing
9
+ * here connects to it.
10
+ */
11
+ import { readFileSync } from "node:fs";
12
+ import { WebAPIPlatformError, WebClient } from "@slack/web-api";
13
+ import { silentLogger } from "./quiet-logger.js";
14
+ import { REQUEST_TIMEOUT_MS, SENT_ONCE } from "./reply/clients.js";
15
+ import { describe } from "./reply/errors.js";
16
+ import { wrongKind } from "./tokens.js";
17
+ /** The longest name Slack takes for an app (manifest reference, read 2026-10-17). */
18
+ export const APP_NAME_LIMIT = 35;
19
+ /** A client that sends each call once and logs nothing: the answer or the error is all there is. */
20
+ function probeClient(token) {
21
+ return new WebClient(token, {
22
+ ...SENT_ONCE,
23
+ timeout: REQUEST_TIMEOUT_MS,
24
+ logger: silentLogger(),
25
+ });
26
+ }
27
+ const MANIFEST = new URL("../../../slack-app-manifest.json", import.meta.url);
28
+ /**
29
+ * The package's manifest as the JSON that `api.slack.com/apps?new_app=1` takes, named for
30
+ * `instance`: a named instance's app and bot are called `allaboard-<name>`, the app's name cut to
31
+ * `APP_NAME_LIMIT`. `cut` says whether it was.
32
+ */
33
+ export function manifestOf(instance) {
34
+ const manifest = JSON.parse(readFileSync(MANIFEST, "utf8"));
35
+ let cut = false;
36
+ if (instance.name !== null) {
37
+ const name = `${manifest.display_information.name}-${instance.name}`;
38
+ cut = name.length > APP_NAME_LIMIT;
39
+ manifest.display_information.name = name.slice(0, APP_NAME_LIMIT);
40
+ manifest.features.bot_user.display_name = name;
41
+ }
42
+ return { json: JSON.stringify(manifest), cut };
43
+ }
44
+ /** The link that opens Slack's "create an app" page with `manifest` filled in. */
45
+ export function creationLink(manifestJson) {
46
+ return `https://api.slack.com/apps?new_app=1&manifest_json=${encodeURIComponent(manifestJson)}`;
47
+ }
48
+ const MEMBER_ID = /^[UW][A-Z0-9]{7,}$/;
49
+ /** What Slack's refusal code means for the owner pasting a token. */
50
+ function refusalOf(variable, code) {
51
+ switch (code) {
52
+ case "invalid_auth":
53
+ case "not_authed":
54
+ return `Slack does not accept this ${variable}: check you copied all of it.`;
55
+ case "token_revoked":
56
+ case "token_expired":
57
+ case "account_inactive":
58
+ return `Slack says this token is no longer valid (${code}): create a new one.`;
59
+ case "not_allowed_token_type":
60
+ return `Slack says this is not the kind of token ${variable} takes (${code}).`;
61
+ default:
62
+ return `Slack refused this token (${code}).`;
63
+ }
64
+ }
65
+ /** Runs `call`; a refusal sentence when Slack refused the token or could not be reached. */
66
+ async function answered(variable, call) {
67
+ try {
68
+ return { answer: await call() };
69
+ }
70
+ catch (error) {
71
+ if (error instanceof WebAPIPlatformError) {
72
+ return { refusal: refusalOf(variable, String(error.data.error)) };
73
+ }
74
+ return {
75
+ refusal: `Could not reach Slack (${describe(error)}): check the connection and try again.`,
76
+ };
77
+ }
78
+ }
79
+ /** The questions of the Slack setup for `instance`; `probe` makes the client for a token. */
80
+ export function slackSetup(instance, probe = probeClient) {
81
+ const { json, cut } = manifestOf(instance);
82
+ // What the bot token turned out to be, which the user token is held against.
83
+ let teamId = null;
84
+ const bot = {
85
+ variable: "SLACK_BOT_TOKEN",
86
+ message: "Bot token (xoxb-…, OAuth & Permissions):",
87
+ secret: true,
88
+ optional: false,
89
+ async check(token) {
90
+ const wrong = wrongKind("SLACK_BOT_TOKEN", token);
91
+ if (wrong !== null)
92
+ return { refusal: wrong };
93
+ const result = await answered("SLACK_BOT_TOKEN", () => probe(token).auth.test());
94
+ if ("refusal" in result)
95
+ return result;
96
+ teamId = result.answer.team_id ?? null;
97
+ return { note: `workspace "${result.answer.team}", bot "${result.answer.user}"` };
98
+ },
99
+ };
100
+ const app = {
101
+ variable: "SLACK_APP_TOKEN",
102
+ message: "App-level token (xapp-…, Basic Information > App-Level Tokens):",
103
+ secret: true,
104
+ optional: false,
105
+ async check(token) {
106
+ const wrong = wrongKind("SLACK_APP_TOKEN", token);
107
+ if (wrong !== null)
108
+ return { refusal: wrong };
109
+ const result = await answered("SLACK_APP_TOKEN", () => probe(token).apps.connections.open());
110
+ return "refusal" in result ? result : {};
111
+ },
112
+ };
113
+ const user = {
114
+ variable: "SLACK_USER_TOKEN",
115
+ message: "User token (xoxp-…, optional, Enter to skip):",
116
+ secret: true,
117
+ optional: true,
118
+ sets: ["SLACK_OWNER_USER_ID"],
119
+ async check(token) {
120
+ const wrong = wrongKind("SLACK_USER_TOKEN", token);
121
+ if (wrong !== null)
122
+ return { refusal: wrong };
123
+ const result = await answered("SLACK_USER_TOKEN", () => probe(token).auth.test());
124
+ if ("refusal" in result)
125
+ return result;
126
+ const { user_id: userId, team_id: owners } = result.answer;
127
+ if (userId === undefined || owners === undefined) {
128
+ return { refusal: "Slack did not say whose token this is." };
129
+ }
130
+ // The Home tab deletes under this token's name: it must be the owner's, in the bot's workspace.
131
+ if (owners !== teamId) {
132
+ return { refusal: "This token belongs to another workspace than the bot token." };
133
+ }
134
+ return { note: `you are ${userId}`, set: { SLACK_OWNER_USER_ID: userId } };
135
+ },
136
+ };
137
+ const owner = {
138
+ variable: "SLACK_OWNER_USER_ID",
139
+ message: "Your Slack member ID (Profile > ⋮ > Copy member ID):",
140
+ secret: false,
141
+ optional: false,
142
+ check: async (id) => MEMBER_ID.test(id)
143
+ ? {}
144
+ : {
145
+ refusal: "A member ID is a U or W followed by capital letters and digits, such as U012AB3CD.",
146
+ },
147
+ };
148
+ return {
149
+ intro: [
150
+ "1. Create the Slack app. Open this link, pick your workspace and confirm:",
151
+ ` ${creationLink(json)}`,
152
+ ...(cut
153
+ ? [` (The app's name is cut to Slack's limit of ${APP_NAME_LIMIT} characters.)`]
154
+ : []),
155
+ " Then install the app to the workspace (Install App in the sidebar).",
156
+ ],
157
+ questions: [bot, app, user, owner],
158
+ };
159
+ }
@@ -5,7 +5,7 @@
5
5
  * `@slack/types` 3.2.0 declares them as `MarkdownBlock`, `ContextBlock`, `DividerBlock`,
6
6
  * `TaskCardBlock`, `ContainerBlock`, `RichTextBlock` and the chunks of `AnyChunk`.
7
7
  */
8
- import { NESTED, STOPPED, shownPreview } from "../../../core/port.js";
8
+ import { formatDuration, NESTED, STOPPED, shownPreview } from "../../../core/port.js";
9
9
  import { RECORD_MARK } from "../texts.js";
10
10
  import { at, lastNewline, len, take } from "./chars.js";
11
11
  import { shownAsWritten } from "./escape.js";
@@ -27,6 +27,9 @@ export const BANNER_LIMIT = 300;
27
27
  // A task card's details and output are text of a rich text element; a context block's text
28
28
  // holds at most 3,000 characters.
29
29
  export const CARD_TEXT_LIMIT = 2_900;
30
+ // A line of the list of what still runs, or its step: seven tasks of two such lines and an
31
+ // agent's duration and count of calls stay under CARD_TEXT_LIMIT.
32
+ const CARD_LINE_LIMIT = 200;
30
33
  export const CARD_TITLE_LIMIT = 150;
31
34
  // The list of what still runs: its card's id (a tool call's id never has this form), and how many
32
35
  // lines it draws before `+N more`. With the footer it stays inside the margins MESSAGE_LIMIT
@@ -339,18 +342,31 @@ export function cardBlock(update) {
339
342
  }
340
343
  return block;
341
344
  }
345
+ /**
346
+ * A task's line, and under it the step of an agent that has made a call. How long the agent has
347
+ * run as `now` (on the steady clock) reads it, worded as a task's end line words its duration, and
348
+ * its count of calls end the agent's line, not the step's: they are the agent's.
349
+ */
350
+ function runningRows(line, now) {
351
+ if (line.agent === null)
352
+ return take(line.text, CARD_LINE_LIMIT);
353
+ const { since, calls, step } = line.agent;
354
+ const count = `${calls} ${calls === 1 ? "call" : "calls"}`;
355
+ const text = take(line.text, CARD_LINE_LIMIT);
356
+ return `${text} · ${formatDuration(now - since)} · ${count}\n ⎿ ${take(step, CARD_LINE_LIMIT)}`;
357
+ }
342
358
  /**
343
359
  * The card of the list of what still runs: one `task_card` that keeps its `block_id` for its whole
344
360
  * 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`.
361
+ * desktop). Past `RUNNING_LIST_LINES` tasks the rest is told as `+N more`; an agent's step is part
362
+ * of its task's entry. `now` is the steady clock's reading, against which an agent's start is
363
+ * measured.
346
364
  */
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;
365
+ export function runningListBlock(running, now) {
366
+ const rows = running.lines.slice(0, RUNNING_LIST_LINES).map((line) => runningRows(line, now));
367
+ if (running.lines.length > RUNNING_LIST_LINES) {
368
+ rows.push(`+${running.lines.length - RUNNING_LIST_LINES} more`);
369
+ }
354
370
  const block = {
355
371
  type: "task_card",
356
372
  block_id: RUNNING_LIST_ID,
@@ -358,8 +374,8 @@ export function runningListBlock(running) {
358
374
  title: take(running.title, CARD_TITLE_LIMIT),
359
375
  status: "in_progress",
360
376
  };
361
- if (lines.length > 0)
362
- block.details = richText(take(lines.join("\n"), CARD_TEXT_LIMIT));
377
+ if (rows.length > 0)
378
+ block.details = richText(take(rows.join("\n"), CARD_TEXT_LIMIT));
363
379
  return block;
364
380
  }
365
381
  /** Whether the block is the list of what still runs. */
@@ -975,7 +975,9 @@ export class ReplySink {
975
975
  // The list only from what the places handed this reply once it rests: a reply whose end is on
976
976
  // its way would draw a second card beside the one its holder still shows.
977
977
  const list = this.resting ? this.assigned : null;
978
- return list === null ? this.footerBlocks() : [runningListBlock(list), ...this.footerBlocks()];
978
+ return list === null
979
+ ? this.footerBlocks()
980
+ : [runningListBlock(list, this.steady.time()), ...this.footerBlocks()];
979
981
  }
980
982
  /**
981
983
  * What a message shows under its body: only the reply's last, when the footer rode on its