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
package/README.md CHANGED
@@ -14,18 +14,20 @@ Claude Code keeps running on each Mac, with its own files and its own setup.
14
14
  npm install -g allaboard
15
15
  ```
16
16
 
17
- The [setup guide](https://github.com/duqaXxX/allaboard/blob/v0.5.0/docs/setup.md) says which
17
+ The [setup guide](https://github.com/duqaXxX/allaboard/blob/v0.6.0/docs/setup.md) says which
18
18
  systems this version supports, and takes you through
19
- [creating the Slack app](https://github.com/duqaXxX/allaboard/blob/v0.5.0/docs/setup.md#part-1-the-slack-app)
20
- from [`slack-app-manifest.json`](https://github.com/duqaXxX/allaboard/blob/v0.5.0/slack-app-manifest.json),
21
- the settings, and the service that starts allaboard at login:
19
+ [creating the Slack app](https://github.com/duqaXxX/allaboard/blob/v0.6.0/docs/setup.md#part-1-the-slack-app)
20
+ from [`slack-app-manifest.json`](https://github.com/duqaXxX/allaboard/blob/v0.6.0/slack-app-manifest.json),
21
+ the settings, and the service that starts allaboard at login. `allaboard setup` asks for the
22
+ tokens, checks each with Slack, writes the configuration and offers to install the service
23
+ (`allaboard service install` does that later):
22
24
 
23
25
  ```sh
24
- allaboard service install
26
+ allaboard setup
25
27
  ```
26
28
 
27
29
  To bring another Mac or another Claude Code account into the same Slack, see
28
- [the guide's Part 6](https://github.com/duqaXxX/allaboard/blob/v0.5.0/docs/setup.md#part-6-a-second-instance).
30
+ [the guide's Part 6](https://github.com/duqaXxX/allaboard/blob/v0.6.0/docs/setup.md#part-6-a-second-instance).
29
31
 
30
32
  ## Update
31
33
 
@@ -40,5 +42,5 @@ allaboard update
40
42
  ## More
41
43
 
42
44
  - Documentation and source: [github.com/duqaXxX/allaboard](https://github.com/duqaXxX/allaboard)
43
- - Security model: [SECURITY.md](https://github.com/duqaXxX/allaboard/blob/v0.5.0/SECURITY.md)
45
+ - Security model: [SECURITY.md](https://github.com/duqaXxX/allaboard/blob/v0.6.0/SECURITY.md)
44
46
  - License: MIT
@@ -4,18 +4,53 @@
4
4
  * session's start is the core's (`folderTrusted` is its question to ask), not this class's.
5
5
  */
6
6
  import { homedir } from "node:os";
7
+ import { getSessionInfo as sdkGetSessionInfo, tagSession as sdkTagSession, } from "@anthropic-ai/claude-agent-sdk";
7
8
  import { shellQuote } from "../../text.js";
8
9
  import { CAPABILITIES } from "./capabilities.js";
9
10
  import { chromeEnabled } from "./chrome.js";
10
11
  import { aliveSessions, byLastActivity, directorySessions, projectsDir } from "./listing.js";
11
12
  import { ClaudeSession } from "./session.js";
12
- import { trustedRepository, workspaceTrusted } from "./trust.js";
13
+ import { trustedRepository, untrustedRepository, workspaceTrusted } from "./trust.js";
13
14
  import { UsageProbe } from "./usage-probe.js";
14
15
  export const NAME = "Claude Code";
15
16
  /** The shell line that forks a session in a terminal; `environment` is what precedes `claude`. */
16
17
  function terminalWith(environment) {
17
18
  return `cd {directory} && ${environment}claude --resume {session} --fork-session`;
18
19
  }
20
+ /**
21
+ * The shell line that opens Claude Code in a folder, whose trust dialog records the folder for
22
+ * the login `environment` names (`CLAUDE_CONFIG_DIR`): another login's record does not count.
23
+ */
24
+ function trustWith(environment) {
25
+ return `cd {directory} && ${environment}claude`;
26
+ }
27
+ // The commands `docs/limits.md` lists as not offered, which a session of the SDK refuses with
28
+ // `/<name> isn't available in this environment.` (measured on Claude Code 2.1.292 on 2026-10-09
29
+ // and 2.1.303 on 2026-10-20; claim P23 sends each to every release). `test/docs.test.ts` holds
30
+ // the two lists to the same names. What follows a name is what the page says to do instead.
31
+ const SETTINGS_APPLY = "The settings you made in the terminal apply: change them there.";
32
+ const BOUND_FOLDER = "A thread works in the folder its channel was bound to.";
33
+ const NOT_OFFERED = Object.freeze({
34
+ rewind: null,
35
+ plan: null,
36
+ branch: null,
37
+ fork: null,
38
+ subtask: null,
39
+ btw: null,
40
+ background: null,
41
+ cd: BOUND_FOLDER,
42
+ "add-dir": BOUND_FOLDER,
43
+ diff: "Ask Claude for the diff.",
44
+ export: "Copy the reply from its thread.",
45
+ copy: "Copy the reply from its thread.",
46
+ permissions: SETTINGS_APPLY,
47
+ memory: SETTINGS_APPLY,
48
+ hooks: SETTINGS_APPLY,
49
+ skills: SETTINGS_APPLY,
50
+ tasks: SETTINGS_APPLY,
51
+ sandbox: SETTINGS_APPLY,
52
+ voice: "Send an audio clip instead.",
53
+ });
19
54
  export const WORDS = Object.freeze({
20
55
  author: "Claude",
21
56
  command: "claude",
@@ -27,6 +62,8 @@ export const WORDS = Object.freeze({
27
62
  login: "login",
28
63
  logout: "logout",
29
64
  terminal: terminalWith(""),
65
+ trust: trustWith(""),
66
+ notOffered: NOT_OFFERED,
30
67
  });
31
68
  /**
32
69
  * `WORDS` for a back end whose sessions use the login in `configDir`: the line that continues a
@@ -38,7 +75,12 @@ function wordsFor(configDir) {
38
75
  if (configDir === undefined)
39
76
  return WORDS;
40
77
  const folder = shellQuote(configDir).replaceAll("{", `'"{"'`);
41
- return Object.freeze({ ...WORDS, terminal: terminalWith(`CLAUDE_CONFIG_DIR=${folder} `) });
78
+ const environment = `CLAUDE_CONFIG_DIR=${folder} `;
79
+ return Object.freeze({
80
+ ...WORDS,
81
+ terminal: terminalWith(environment),
82
+ trust: trustWith(environment),
83
+ });
42
84
  }
43
85
  export class ClaudeBackend {
44
86
  name = NAME;
@@ -113,10 +155,39 @@ export class ClaudeBackend {
113
155
  trustedRepository(folder) {
114
156
  return trustedRepository(folder, this.#home, this.#configDir);
115
157
  }
158
+ untrustedRepository(folder) {
159
+ return untrustedRepository(folder, this.#home, this.#configDir);
160
+ }
116
161
  /** The limits `/usage` reports now, read by a session of the back end's own. */
117
162
  usageLimits() {
118
163
  return this.#probe.read();
119
164
  }
165
+ /**
166
+ * Sets or clears (`tag` null) the tag of a session of `folder`, in its transcript: the SDK
167
+ * appends a `tag` entry, an empty one to clear, and the last entry wins. Like the SDK's
168
+ * listing it finds the transcript through `CLAUDE_CONFIG_DIR` in the daemon's environment,
169
+ * which `module.claudeAgent` writes from the same folder it gives this back end as `configDir`
170
+ * (an instance with a login of its own); with none set, the SDK uses the default login's.
171
+ * Measured on 2026-10-19 (SDK 0.3.302, CLI 2.1.302): setting, clearing and changing the tag
172
+ * of a session whose query is alive holds through later turns and the close; the tag reaches
173
+ * none of the 8 request bodies captured, so the model never sees it; the terminal's
174
+ * `/resume` picker prints it as `#name`.
175
+ */
176
+ tagSession(folder, session, tag) {
177
+ return sdkTagSession(session, tag, { dir: folder });
178
+ }
179
+ /**
180
+ * The title Claude Code shows for a session of `folder`: `customTitle`, which holds the one
181
+ * Claude Code generated and the owner's `/rename` alike (the declaration says "/rename only";
182
+ * measured on 2026-10-20, SDK 0.3.302, CLI 2.1.302: a generated title is there at the `result`
183
+ * of the turn that wrote it, usually the first, sometimes the third or later). `summary` is not
184
+ * read: it falls back to the first prompt while there is no title. Reads the session's file
185
+ * through `getSessionInfo` alone, since it runs after every turn.
186
+ */
187
+ async sessionTitle(folder, session) {
188
+ const info = await sdkGetSessionInfo(session, { dir: folder });
189
+ return info?.customTitle?.trim() || null;
190
+ }
120
191
  /** Stops the usage probe: its session is closed and no read starts another. */
121
192
  close() {
122
193
  return this.#probe.stop();
@@ -27,4 +27,8 @@ export const CAPABILITIES = Object.freeze({
27
27
  promptDuringTurn: true,
28
28
  sessionListing: true,
29
29
  folderTrust: true,
30
+ // The SDK's `tagSession`.
31
+ sessionTags: true,
32
+ // The SDK's `getSessionInfo`.
33
+ sessionTitles: true,
30
34
  });
@@ -12,6 +12,15 @@ import { ClaudeBackend } from "./backend.js";
12
12
  // Claude Code account (environment variables page, "useful for running multiple accounts side by
13
13
  // side", read 2026-10-17); it is handled below, since the daemon reads the folder too.
14
14
  export const VARIABLES = ["BASH_DEFAULT_TIMEOUT_MS", "CLAUDE_CONFIG_DIR"];
15
+ const MS_PER_HOUR = 3_600_000;
16
+ const DEFAULT_TIMEOUT_HOURS = 4;
17
+ // The largest delay a Node timer takes (2^31 - 1 ms); Claude Code's own limit on the variable.
18
+ const MAX_TIMER_MS = 2_147_483_647;
19
+ // The shortest timeout the wizard takes: 1 minute, stated as 0.02 hours (72000 ms).
20
+ const MIN_TIMEOUT_MS = 60_000;
21
+ const MIN_TIMEOUT_HOURS = 0.02;
22
+ // One decimal below the cap (596.5 hours is 2147400000 ms), so the stated bound is accepted.
23
+ const MAX_TIMEOUT_HOURS = 596.5;
15
24
  /**
16
25
  * The declared variables the owner's file sets, each read by its name: the daemon hands a module
17
26
  * its variables readable by lookup and shown by nothing, so a spread of them copies none.
@@ -48,6 +57,50 @@ function configDirOf(written, home) {
48
57
  }
49
58
  return expanded;
50
59
  }
60
+ /**
61
+ * The two questions of `allaboard setup`: the timeout, which Enter sets to 4 hours, and the folder, which Enter skips. The folder is not
62
+ * required to exist: a second login creates it. The same rule the daemon applies at build.
63
+ */
64
+ export function claudeSetup(home) {
65
+ return {
66
+ intro: [],
67
+ questions: [
68
+ {
69
+ variable: "BASH_DEFAULT_TIMEOUT_MS",
70
+ message: `Bash command timeout in hours (BASH_DEFAULT_TIMEOUT_MS, Enter for ${DEFAULT_TIMEOUT_HOURS}):`,
71
+ secret: false,
72
+ optional: false,
73
+ default: String(DEFAULT_TIMEOUT_HOURS),
74
+ check: async (answer) => {
75
+ const hours = /^\d+(\.\d+)?$/.test(answer) ? Number(answer) : Number.NaN;
76
+ const ms = Math.round(hours * MS_PER_HOUR);
77
+ return ms >= MIN_TIMEOUT_MS && ms <= MAX_TIMER_MS
78
+ ? { value: String(ms), note: `${ms} ms` }
79
+ : {
80
+ refusal: `A timeout is a number of hours from ${MIN_TIMEOUT_HOURS} to ${MAX_TIMEOUT_HOURS}, such as 0.5 or 4.`,
81
+ };
82
+ },
83
+ },
84
+ {
85
+ variable: "CLAUDE_CONFIG_DIR",
86
+ message: "Claude Code configuration folder (CLAUDE_CONFIG_DIR, optional, Enter for ~/.claude):",
87
+ secret: false,
88
+ optional: true,
89
+ check: async (answer) => {
90
+ try {
91
+ configDirOf(answer, home);
92
+ return {};
93
+ }
94
+ catch (error) {
95
+ if (error instanceof AgentConfigError)
96
+ return { refusal: error.message };
97
+ throw error;
98
+ }
99
+ },
100
+ },
101
+ ],
102
+ };
103
+ }
51
104
  /**
52
105
  * The entry that builds the Claude back end. `options` are what a test replaces: the SDK's
53
106
  * query, the owner's home, the usage probe's start.
@@ -56,6 +109,8 @@ export function claudeAgent(options = {}) {
56
109
  return {
57
110
  required: [],
58
111
  optional: VARIABLES,
112
+ executable: "claude",
113
+ setup: () => claudeSetup(options.home ?? homedir()),
59
114
  build: (context) => {
60
115
  const variables = set(context.variables);
61
116
  const configDir = configDirOf(variables.CLAUDE_CONFIG_DIR, options.home ?? homedir());
@@ -416,7 +416,11 @@ function taskNotification(memory, record) {
416
416
  return [];
417
417
  const usage = isRecord(record.usage) ? record.usage : {};
418
418
  // The replay of a folded notification names the run, which the task's start recorded: a task
419
- // whose run this process never saw cannot be named (`SDKTaskStartedMessage.run_id`).
419
+ // whose run this process never saw cannot be named (`SDKTaskStartedMessage.run_id`). Nor can a
420
+ // stopped one, which gets no notification of its own (`SDKMessageOrigin.runId`): a Monitor
421
+ // past its deadline reaches the turn as one of its lines, with no run (recorded 2026-10-20,
422
+ // `monitor-expired-in-turn`, issue #392), and no stopped task of the other recordings
423
+ // (`interrupt`, `stop-close-resume`, `background-forms`) has its end replayed with its run.
420
424
  const runId = string(record.run_id);
421
425
  return [
422
426
  {
@@ -426,7 +430,7 @@ function taskNotification(memory, record) {
426
430
  summary,
427
431
  durationMs: number(usage.duration_ms),
428
432
  callId: words(record.tool_use_id),
429
- foldNamed: runId !== null && memory.runs.has(runId),
433
+ foldNamed: reported !== "stopped" && runId !== null && memory.runs.has(runId),
430
434
  },
431
435
  ];
432
436
  }
@@ -539,6 +539,28 @@ export async function trustedRepository(directory, home = homedir(), configDir)
539
539
  }
540
540
  return (await isTrusted(repository.key, home, configDir)) ? repository : null;
541
541
  }
542
+ /**
543
+ * The repository holding `directory` when the owner has not trusted its key in Claude Code: the
544
+ * one `trustedRepository` refuses for that alone. Null with no repository, with a trusted one,
545
+ * and where the layout has no key, since no trust the dialog gives would cover that. Nothing
546
+ * here runs git.
547
+ */
548
+ export async function untrustedRepository(directory, home = homedir(), configDir) {
549
+ let repository;
550
+ try {
551
+ repository = await locate(directory);
552
+ }
553
+ catch (error) {
554
+ if (error instanceof Unkeyed) {
555
+ return null;
556
+ }
557
+ throw error;
558
+ }
559
+ if (repository === null) {
560
+ return null;
561
+ }
562
+ return (await isTrusted(repository.key, home, configDir)) ? null : repository;
563
+ }
542
564
  /**
543
565
  * Whether the owner trusted `directory` in Claude Code, as the terminal would decide; false where
544
566
  * git would find a repository that no trusted path stands for.
package/dist/chat/seam.js CHANGED
@@ -3,8 +3,11 @@
3
3
  * (`channel_not_found`), or the error's own name: all a log line may say of it, never content.
4
4
  */
5
5
  export class ChatError extends Error {
6
+ /** The chat will refuse the same call again, whenever it is made: retrying cannot help. */
7
+ permanent;
6
8
  constructor(code, options) {
7
9
  super(code, options);
8
10
  this.name = code;
11
+ this.permanent = options?.permanent === true;
9
12
  }
10
13
  }
@@ -40,8 +40,9 @@ import { monotonicClock } from "../../../clock.js";
40
40
  import { Listings } from "../../../core/port.js";
41
41
  import { download } from "../attachments.js";
42
42
  import { BIND_ACTION } from "../bind.js";
43
+ import { DELETE_THREAD_CANCEL, DELETE_THREAD_CONFIRM } from "../delete-thread.js";
43
44
  import { HOLD_CANCEL, HOLD_CONTINUE } from "../hold.js";
44
- import { CLEAN_ACTION, DELETE_ACTION, EDIT_ACTION, FILTER_ACTIONS, HOME_UPDATE_ACTION, NEW_THREAD_ACTION, SHOW_ALL_ACTION, } from "../home.js";
45
+ import { CLEAN_ACTION, DELETE_ACTION, EDIT_ACTION, FILTER_ACTIONS, HOME_UPDATE_ACTION, NEW_THREAD_ACTION, REFRESH_ACTION, SHOW_ALL_ACTION, } from "../home.js";
45
46
  import { OPEN_BUTTON_ACTION, OPEN_FORM, QUERY_ACTION } from "../openfile/modal.js";
46
47
  import { levelLogger } from "../quiet-logger.js";
47
48
  import { REQUEST_TIMEOUT_MS } from "../reply/clients.js";
@@ -53,7 +54,8 @@ import { UPDATE_ACTION } from "../update.js";
53
54
  import * as voice from "../voice.js";
54
55
  import { Answers, logger } from "./answers.js";
55
56
  import { Clips } from "./clip.js";
56
- import { isPromptMessage, isWrittenMessage, messageActor, } from "./guards.js";
57
+ import { DeleteThread } from "./delete-thread.js";
58
+ import { deletedAuthor, deletedTs, isPromptMessage, isTombstoneRemoval, isWrittenMessage, messageActor, } from "./guards.js";
57
59
  import { HomeControls } from "./home-controls.js";
58
60
  import { filesOf, inboundMessage } from "./messages.js";
59
61
  import { Open } from "./open.js";
@@ -123,6 +125,7 @@ export function buildApp(options) {
123
125
  inbound: options.inbound,
124
126
  uploads: options.uploads,
125
127
  home: options.home,
128
+ ...(options.deleteThread !== undefined && { deleteThread: options.deleteThread }),
126
129
  limiter: chat.limiter,
127
130
  fetch: options.fetch ??
128
131
  (({ url, mimetype, limit }) => download(url, config.botToken, mimetype, limit)),
@@ -137,6 +140,7 @@ export function buildApp(options) {
137
140
  const resumeBind = new ResumeBind(parts, answers);
138
141
  const clips = new Clips(parts, answers);
139
142
  const home = new HomeControls(parts);
143
+ const deleteThread = new DeleteThread(parts, answers);
140
144
  const app = new App({
141
145
  receiver: options.receiver,
142
146
  // auth.test already ran at startup; who may act is decided by the guards of each listener.
@@ -198,8 +202,48 @@ export function buildApp(options) {
198
202
  }
199
203
  await handed;
200
204
  });
205
+ // A first message the owner deleted by hand. Its own listener: the guard above stays closed to
206
+ // every subtype but a person's message. Three checks, on this path's own: the envelope names the
207
+ // owner's workspace, its ts is a thread the daemon keeps, and the deleted message was the owner's
208
+ // (`previous_message.user`). No field of either event names who DELETED the message, so the last
209
+ // check proves whose message went, not who removed it. This may be revisited.
210
+ app.event("message", async ({ event, body }) => {
211
+ const message = event;
212
+ const deleted = deletedTs(message);
213
+ if (deleted === null)
214
+ return;
215
+ const channel = text(message.channel);
216
+ if (answers.stopped || channel === null)
217
+ return;
218
+ if (text(body.team_id) !== identity.teamId) {
219
+ logger.info("ignored a deleted message from another workspace");
220
+ return;
221
+ }
222
+ // The kept thread first: a deletion of the daemon's own message, or the removal of a tombstone,
223
+ // is no kept root and is dropped without a word.
224
+ if (parts.state.thread(channel, deleted) === null)
225
+ return;
226
+ // Silently: the tombstone's own removal, which the earlier event already acted on.
227
+ if (isTombstoneRemoval(message))
228
+ return;
229
+ if (deletedAuthor(message) !== identity.ownerUserId) {
230
+ logger.info(`ignored the deletion of the first message of ${channel}/${deleted}: not the owner's`);
231
+ return;
232
+ }
233
+ const ref = { channel, thread: deleted };
234
+ try {
235
+ await parts.inbound.rootGone(ref);
236
+ }
237
+ catch (error) {
238
+ logger.warning(`could not forget ${channel}/${deleted}: ${describe(error)}`);
239
+ }
240
+ // Forgetting the entry already asked the index (`onSessionsChange`); one left stands for a
241
+ // turn that runs, or for a call that failed, and the index reads its row again.
242
+ if (parts.state.thread(channel, deleted) !== null)
243
+ parts.chat.index?.refresh([ref]);
244
+ });
201
245
  app.event("file_change", ({ event }) => clips.onFileChange(event));
202
- registered.push({ kind: "event", id: "message", acknowledgeOnly: false }, { kind: "event", id: "file_change", acknowledgeOnly: false });
246
+ registered.push({ kind: "event", id: "message", acknowledgeOnly: false }, { kind: "event", id: "message deleted", acknowledgeOnly: false }, { kind: "event", id: "file_change", acknowledgeOnly: false });
203
247
  /** A click or a select: the listener acknowledges, then checks, on its own. */
204
248
  const action = (actionId, listener, options = {}) => {
205
249
  registered.push({
@@ -207,7 +251,7 @@ export function buildApp(options) {
207
251
  id: String(actionId),
208
252
  acknowledgeOnly: options.acknowledgeOnly ?? false,
209
253
  });
210
- app.action(actionId, ({ ack, body }) => listener(() => ack(), body));
254
+ app.action(actionId, ({ ack, body, respond }) => listener(() => ack(), body, respond));
211
255
  };
212
256
  /** A modal's submit: what the listener answers is what the modal does next. */
213
257
  const view = (callbackId, listener) => {
@@ -234,6 +278,8 @@ export function buildApp(options) {
234
278
  action(OPEN_BUTTON_ACTION, open.onOpenChoose);
235
279
  action(QUERY_ACTION, open.onOpenQuery);
236
280
  view(OPEN_FORM, open.onOpenSubmit);
281
+ action(DELETE_THREAD_CONFIRM, deleteThread.onConfirm);
282
+ action(DELETE_THREAD_CANCEL, deleteThread.onCancel);
237
283
  action(BIND_ACTION, resumeBind.onBind);
238
284
  action(RESUME_ACTION, resumeBind.onResume);
239
285
  action(UPDATE_ACTION, resumeBind.onUpdate);
@@ -242,6 +288,7 @@ export function buildApp(options) {
242
288
  action(actionId, home.onHomeFilter);
243
289
  action(SHOW_ALL_ACTION, home.onHomeShowAll);
244
290
  action(EDIT_ACTION, home.onHomeEdit);
291
+ action(REFRESH_ACTION, home.onHomeRefresh);
245
292
  action(DELETE_ACTION, home.onHomeDelete);
246
293
  action(CLEAN_ACTION, home.onHomeClean);
247
294
  action(HOME_UPDATE_ACTION, home.onHomeUpdate);
@@ -258,5 +305,6 @@ export function buildApp(options) {
258
305
  clips.close();
259
306
  },
260
307
  openFile: (channel, threadTs, folder, words) => open.openWord(channel, threadTs, folder, words),
308
+ askDelete: (channel, threadTs) => deleteThread.ask(channel, threadTs),
261
309
  };
262
310
  }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * `!delete-thread` as the Slack provider hears it: a question only the owner sees, under their
3
+ * word in the thread, with Delete thread and Cancel. The thread is deleted by the Home tab's own
4
+ * deleter (`ThreadDeleter.delete`), so both ways share one path and one rule.
5
+ *
6
+ * The question is ephemeral: nothing of it is left to delete or to store. A click on it carries
7
+ * `response_url` and `container.is_ephemeral`, and no `message` (measured 2026-10-19 on bolt
8
+ * 5.1.0), so the thread is read from the container (`clickThread`). Either button takes the
9
+ * question away with `respond({ delete_original: true })`: `chat.update` cannot edit an
10
+ * ephemeral message (chat.update reference, read 2026-10-19), and a question rewritten instead
11
+ * stays in the channel's view once its thread is gone, until a reload (measured 2026-10-19).
12
+ */
13
+ import { fill, named, texts } from "../../../core/port.js";
14
+ import { deleteThreadBlocks } from "../delete-thread.js";
15
+ import { describe } from "../reply/errors.js";
16
+ import * as slackTexts from "../texts.js";
17
+ import { logger } from "./answers.js";
18
+ import { interactionActor } from "./guards.js";
19
+ import { clickThread, record, text } from "./wire.js";
20
+ /** The line, under the owner's word, for a delete that left the thread. */
21
+ function deleteLine(refusal, names) {
22
+ switch (refusal.reason) {
23
+ case "busy":
24
+ return texts.DELETE_THREAD_BUSY;
25
+ case "refused":
26
+ return fill(named(slackTexts.THREAD_DELETE_REFUSED, names), { count: refusal.count });
27
+ case "failed":
28
+ return fill(slackTexts.THREAD_DELETE_FAILED, { error: refusal.error });
29
+ }
30
+ }
31
+ export class DeleteThread {
32
+ parts;
33
+ answers;
34
+ constructor(parts, answers) {
35
+ this.parts = parts;
36
+ this.answers = answers;
37
+ }
38
+ /** Shows the owner alone the question, in the thread; resolves once it is posted. */
39
+ async ask(channel, threadTs) {
40
+ await this.answers.deliver(channel, threadTs, slackTexts.HOME_DELETE_TITLE, deleteThreadBlocks(this.parts.names), { ephemeral: true });
41
+ }
42
+ /** Delete thread: the question goes, then the thread; a delete that stopped says why. */
43
+ onConfirm = async (ack, body, respond) => {
44
+ const where = await this.admitted(ack, body);
45
+ if (where === null)
46
+ return;
47
+ const [channel, threadTs] = where;
48
+ const { deleteThread, chat, names } = this.parts;
49
+ if (deleteThread === undefined) {
50
+ // A question left from a run that had the owner's token: it stays, and says why.
51
+ await this.answers.tellOwner(channel, threadTs, named(texts.DELETE_THREAD_UNAVAILABLE, names));
52
+ return;
53
+ }
54
+ await this.withdraw(respond);
55
+ const refusal = await deleteThread(channel, threadTs);
56
+ // A thread that is gone needs no line, and its removal from `state.json` refreshes the Home.
57
+ // One still there says why, and its root is read again: the delete may have stopped half way.
58
+ if (refusal === null)
59
+ return;
60
+ chat.index?.refresh([{ channel, thread: threadTs }]);
61
+ await this.answers.tellOwner(channel, threadTs, deleteLine(refusal, names));
62
+ };
63
+ /** Cancel: the question goes, and nothing else changes. */
64
+ onCancel = async (ack, body, respond) => {
65
+ if ((await this.admitted(ack, body)) !== null)
66
+ await this.withdraw(respond);
67
+ };
68
+ /** Acknowledges, then the channel and the thread of the owner's click; null for anyone else's. */
69
+ async admitted(ack, body) {
70
+ await ack();
71
+ const [user, team] = interactionActor(body);
72
+ const channel = text(record(body.channel).id);
73
+ const threadTs = clickThread(body);
74
+ if (!(await this.answers.admitted(user, team, channel, threadTs)) || channel === null) {
75
+ return null;
76
+ }
77
+ return [channel, threadTs];
78
+ }
79
+ /** Takes the question away. A failure leaves it showing until a reload, and is logged. */
80
+ async withdraw(respond) {
81
+ if (respond === undefined)
82
+ return; // a click with no `response_url`: nothing to answer
83
+ try {
84
+ await respond({ delete_original: true });
85
+ }
86
+ catch (error) {
87
+ logger.warning(`could not take back the delete question: ${describe(error)}`);
88
+ }
89
+ }
90
+ }
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import { getLogger } from "../../../log.js";
8
8
  import { isSpace } from "../../../text.js";
9
+ import { isTombstone } from "../channels.js";
9
10
  import { describe } from "../reply/errors.js";
10
11
  import * as slackTexts from "../texts.js";
11
12
  export const logger = getLogger("allaboard.chat.slack.app.guards");
@@ -118,6 +119,49 @@ export function isWrittenMessage(event) {
118
119
  WRITTEN_SUBTYPES.includes(subtype) &&
119
120
  !("bot_id" in event));
120
121
  }
122
+ /**
123
+ * The `ts` of the message a deletion removed, when `event` says one was deleted; null for any
124
+ * other event. Two shapes, both seen on 2026-10-20 when the owner's user token deleted roots:
125
+ * `message_deleted` (`deleted_ts`; the reference, docs.slack.dev/reference/events/message/
126
+ * message_deleted, read 2026-10-20), which comes for a message with no replies and for the last
127
+ * reply deleted afterwards; and a `message_changed` whose inner `message.subtype` is `tombstone`,
128
+ * which comes for a root whose replies remain (the reference for `message_changed` does not
129
+ * name the subtype: it is measured). A `message_changed` of an edit has no such subtype, so the
130
+ * daemon's own edits of a root (title, tag) are not deletions.
131
+ *
132
+ * Neither event says who deleted the message. `deletedAuthor` gives who wrote it, which is the
133
+ * most the events prove.
134
+ */
135
+ export function deletedTs(event) {
136
+ if (event.type !== "message")
137
+ return null;
138
+ if (event.subtype === "message_deleted")
139
+ return text(event.deleted_ts);
140
+ if (event.subtype === "message_changed") {
141
+ const inner = record(event.message);
142
+ return isTombstone(inner) ? text(inner.ts) : null;
143
+ }
144
+ return null;
145
+ }
146
+ /**
147
+ * The author of the message a deletion event removed: `previous_message.user`, measured on
148
+ * 2026-10-20 in both shapes (the root's author, the owner, for a root; the bot for a reply). Not
149
+ * `message.user` of a tombstone, which is a Slack system user. Null when the event has no
150
+ * `previous_message`. The removal of a tombstone never reaches it (`isTombstoneRemoval` drops it
151
+ * first). This proves whose message was deleted, not who deleted it, which no field of the events
152
+ * says.
153
+ */
154
+ export function deletedAuthor(event) {
155
+ return text(record(event.previous_message).user);
156
+ }
157
+ /**
158
+ * Whether `event` is the removal of a tombstone: the last reply of a deleted root went, and the
159
+ * deleted message was the tombstone (`previous_message.subtype` `tombstone`, measured
160
+ * 2026-10-20). The earlier tombstone event already forgot the thread.
161
+ */
162
+ export function isTombstoneRemoval(event) {
163
+ return isTombstone(event.previous_message);
164
+ }
121
165
  const SHARED_FLAGS = ["is_shared", "is_ext_shared", "is_org_shared", "is_pending_ext_shared"];
122
166
  export class ChannelGuard {
123
167
  #slack;
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * The controls of the session index (the app's Home tab, `home.ts`): its filters, Show all, Edit,
3
- * Delete, Clean up and the update line's button.
3
+ * Refresh, Delete, Clean up and the update line's button.
4
4
  */
5
5
  import { EDIT_ON, readFilter } from "../home.js";
6
6
  import { logger } from "./answers.js";
@@ -55,6 +55,13 @@ export class HomeControls {
55
55
  return;
56
56
  await this.home.edit(str(firstAction(body).value) === EDIT_ON);
57
57
  };
58
+ // The click asks for a cleanup of state.json and a fresh page; `Home.refresh` never throws.
59
+ onHomeRefresh = async (ack, body) => {
60
+ await ack();
61
+ if (!this.homeOwner(body))
62
+ return;
63
+ await this.home.refresh();
64
+ };
58
65
  // Slack sends this click only once the owner confirmed in the button's own dialog.
59
66
  onHomeDelete = async (ack, body) => {
60
67
  await ack();
@@ -1,4 +1,5 @@
1
1
  /** What the daemon asks Slack about the channels it is bound to, and the threads in them. */
2
+ import { isRecord } from "../../values.js";
2
3
  import { ChatError } from "../seam.js";
3
4
  import { THREAD_NOT_FOUND } from "./delete.js";
4
5
  import { describe } from "./reply/errors.js";
@@ -23,15 +24,32 @@ export function channelLookup(slack) {
23
24
  };
24
25
  }
25
26
  /**
26
- * What the cleanup asks about a thread: `conversations.replies` on its root with `limit=1`, which
27
- * a deleted root answers with `thread_not_found` (measured 2026-10-01, see `home.ts`). Only that
28
- * code says the thread is gone; any other failure crosses as a `ChatError` named by Slack's code.
27
+ * Whether `root`, a message `conversations.replies` returned, is a tombstone: a root deleted while
28
+ * replies remain, which Slack returns with `subtype` `tombstone` and the text `This message was
29
+ * deleted.` (measured 2026-10-20 on a development app; the subtype is not in the method's
30
+ * reference). Such a root is gone, as one answered with `thread_not_found` is.
31
+ */
32
+ export function isTombstone(root) {
33
+ return isRecord(root) && root.subtype === "tombstone";
34
+ }
35
+ /**
36
+ * What the cleanup asks about a thread: `conversations.replies` on its root with `limit=1`. A root
37
+ * deleted with no replies, or whose last reply was deleted since, answers `thread_not_found`
38
+ * (measured 2026-10-01, see `home.ts`). A root deleted while replies remain is returned as a
39
+ * tombstone, a message with `subtype` `tombstone` and the text `This message was deleted.` (measured
40
+ * 2026-10-20; the subtype is not in the method's reference). Those two say the thread is gone; any
41
+ * other failure crosses as a `ChatError` named by Slack's code.
29
42
  */
30
43
  export function threadLookup(slack) {
31
44
  return async (ref) => {
32
45
  try {
33
- await slack.conversations.replies({ channel: ref.channel, ts: ref.thread, limit: 1 });
34
- return "there";
46
+ const answer = await slack.conversations.replies({
47
+ channel: ref.channel,
48
+ ts: ref.thread,
49
+ limit: 1,
50
+ });
51
+ const root = answer.messages?.find((message) => message.ts === ref.thread);
52
+ return isTombstone(root) ? "gone" : "there";
35
53
  }
36
54
  catch (error) {
37
55
  const code = describe(error);