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
package/dist/cli.js ADDED
@@ -0,0 +1,326 @@
1
+ /**
2
+ * The command line of `allaboard`. With no argument the command runs the daemon, which is what the
3
+ * service starts; the other forms answer and end without reading `.env` or taking the
4
+ * single-instance lock. `--instance <name>`, or `ALLABOARD_INSTANCE` when the flag is absent, picks
5
+ * which of the machine's daemons a command is about (`core/instance.ts`); this file is the only
6
+ * reader of the variable.
7
+ */
8
+ import { homedir } from "node:os";
9
+ import { createInterface } from "node:readline/promises";
10
+ import { configDirOf, DEFAULT_INSTANCE, INSTANCE_VARIABLE, parseInstanceName, } from "./core/instance.js";
11
+ import { UpdateUnderWay, updateLock } from "./core/lock.js";
12
+ import { installVersion, isNewer, latestVersion, updatableCopy, } from "./host/npm.js";
13
+ import { programOf } from "./host/program.js";
14
+ import { runAsync, runCommand } from "./host/run.js";
15
+ import { Launchd } from "./host/service/launchd.js";
16
+ import { loadedOn } from "./host/service/listing.js";
17
+ import { SERVICE_ACTIONS } from "./host/service/seam.js";
18
+ import { changelogOf, installedVersion } from "./version.js";
19
+ export const USAGE = [
20
+ "usage: allaboard [--instance <name>] run the daemon",
21
+ ` allaboard service <${SERVICE_ACTIONS.join("|")}> [--instance <name>]`,
22
+ " allaboard update [--check] [--yes]",
23
+ " allaboard --version | --help",
24
+ "",
25
+ `--instance picks one of the machine's daemons; ${INSTANCE_VARIABLE} does the same when the flag`,
26
+ "is absent. `service list` shows every instance and takes no --instance.",
27
+ "`update` installs the latest version of the copy that `npm install -g` replaces and restarts",
28
+ "the services that run it, asking first unless --yes; --check only prints the versions.",
29
+ ].join("\n");
30
+ function isAction(text) {
31
+ return SERVICE_ACTIONS.includes(text);
32
+ }
33
+ const invalid = (reason) => ({ kind: "invalid", reason });
34
+ /** The arguments without `--instance <name>`, and the name when it was given. */
35
+ function takeInstanceFlag(args) {
36
+ const rest = [];
37
+ let flag;
38
+ for (let index = 0; index < args.length; index += 1) {
39
+ const argument = args[index];
40
+ if (argument !== "--instance") {
41
+ rest.push(argument);
42
+ continue;
43
+ }
44
+ if (flag !== undefined)
45
+ return invalid("--instance is given twice");
46
+ const value = args[index + 1];
47
+ if (value === undefined)
48
+ return invalid("--instance needs a name");
49
+ flag = value;
50
+ index += 1;
51
+ }
52
+ return { rest, flag };
53
+ }
54
+ /** The instance the flag names, else the variable, else the default; the refusal says which. */
55
+ function instanceOf(flag, env) {
56
+ const variable = env[INSTANCE_VARIABLE];
57
+ const named = flag !== undefined
58
+ ? { source: "--instance", text: flag }
59
+ : variable !== undefined && variable !== ""
60
+ ? { source: INSTANCE_VARIABLE, text: variable }
61
+ : null;
62
+ if (named === null)
63
+ return DEFAULT_INSTANCE;
64
+ const parsed = parseInstanceName(named.text);
65
+ return "refusal" in parsed
66
+ ? invalid(`${named.source}: ${parsed.refusal}`)
67
+ : { name: parsed.name };
68
+ }
69
+ /** `update`'s own flags: `--check` and `--yes`, once each and not together. */
70
+ function parseUpdate(args, flag) {
71
+ if (flag !== undefined) {
72
+ return invalid("update takes no instance: it updates the copy that all of them run");
73
+ }
74
+ const given = { "--check": false, "--yes": false };
75
+ for (const argument of args) {
76
+ if (argument === undefined)
77
+ continue;
78
+ if (argument !== "--check" && argument !== "--yes") {
79
+ return invalid(`unknown argument ${JSON.stringify(argument)}`);
80
+ }
81
+ if (given[argument])
82
+ return invalid(`${argument} is given twice`);
83
+ given[argument] = true;
84
+ }
85
+ if (given["--check"] && given["--yes"]) {
86
+ return invalid("--check installs nothing, so --yes has no meaning with it");
87
+ }
88
+ return { kind: "update", check: given["--check"], yes: given["--yes"] };
89
+ }
90
+ /** What the arguments after the command ask for; anything else is `invalid`, with the reason. */
91
+ export function parseArguments(args, env = {}) {
92
+ const taken = takeInstanceFlag(args);
93
+ if ("kind" in taken)
94
+ return taken;
95
+ const { rest: positional, flag } = taken;
96
+ const [first, second, ...rest] = positional;
97
+ if (first === "update")
98
+ return parseUpdate([second, ...rest], flag);
99
+ if (first === undefined) {
100
+ const instance = instanceOf(flag, env);
101
+ return "kind" in instance ? instance : { kind: "run", instance };
102
+ }
103
+ if (first === "service") {
104
+ if (!isAction(second)) {
105
+ return invalid(`service needs one of: ${SERVICE_ACTIONS.join(", ")}`);
106
+ }
107
+ if (rest.length > 0)
108
+ return invalid(`unknown argument ${JSON.stringify(rest[0])}`);
109
+ if (second === "list") {
110
+ return flag === undefined
111
+ ? { kind: "service", action: second, instance: DEFAULT_INSTANCE }
112
+ : invalid("service list takes no instance: it lists them all");
113
+ }
114
+ const instance = instanceOf(flag, env);
115
+ return "kind" in instance ? instance : { kind: "service", action: second, instance };
116
+ }
117
+ if (second !== undefined)
118
+ return invalid(`unknown argument ${JSON.stringify(second)}`);
119
+ if (first === "--version" || first === "--help") {
120
+ if (flag !== undefined)
121
+ return invalid(`${first} takes no instance`);
122
+ return { kind: first === "--version" ? "version" : "help" };
123
+ }
124
+ return invalid(`unknown argument ${JSON.stringify(first)}`);
125
+ }
126
+ export const machineHost = {
127
+ service: (instance) => process.platform === "darwin"
128
+ ? new Launchd({
129
+ home: homedir(),
130
+ uid: process.getuid?.() ?? 0,
131
+ instance: instance.name,
132
+ configDir: configDirOf(instance, homedir()),
133
+ run: runCommand,
134
+ })
135
+ : null,
136
+ program: () => programOf(process.argv[1] ?? "", process.env.PATH ?? "", process.execPath),
137
+ };
138
+ /**
139
+ * Answers an invocation that does not run the daemon, and returns the exit status: 0, 2 for
140
+ * arguments it does not know (with the usage on stderr), or the service action's own status.
141
+ */
142
+ export function answer(invocation, output, host = machineHost) {
143
+ switch (invocation.kind) {
144
+ case "version":
145
+ output.out(`${installedVersion()}\n`);
146
+ return 0;
147
+ case "help":
148
+ output.out(`${USAGE}\n`);
149
+ return 0;
150
+ case "invalid":
151
+ output.err(`allaboard: ${invocation.reason}\n${USAGE}\n`);
152
+ return 2;
153
+ case "service": {
154
+ const manager = host.service(invocation.instance);
155
+ if (manager === null) {
156
+ output.err("allaboard service: allaboard runs as a service on macOS only, for now.\n");
157
+ return 1;
158
+ }
159
+ const report = invocation.action === "install"
160
+ ? manager.install(host.program())
161
+ : manager[invocation.action]();
162
+ for (const line of report.out)
163
+ output.out(`${line}\n`);
164
+ for (const line of report.err)
165
+ output.err(`${line}\n`);
166
+ return report.code;
167
+ }
168
+ }
169
+ }
170
+ export const machineUpdateHost = {
171
+ ...machineHost,
172
+ run: runAsync,
173
+ home: homedir(),
174
+ version: installedVersion,
175
+ // A getter: reading `process.stdin` at import would open it in the daemon too.
176
+ get interactive() {
177
+ return process.stdin.isTTY === true && process.stdout.isTTY === true;
178
+ },
179
+ ask: async (question) => {
180
+ const lines = createInterface({ input: process.stdin, output: process.stdout });
181
+ try {
182
+ return await lines.question(question);
183
+ }
184
+ finally {
185
+ lines.close();
186
+ }
187
+ },
188
+ };
189
+ const failure = (output, sentence) => {
190
+ output.err(`allaboard update: ${sentence}\n`);
191
+ return 1;
192
+ };
193
+ /** Why the latest version is unknown, from what npm failed with. */
194
+ function unknownLatest(why) {
195
+ const reason = why.kind === "network"
196
+ ? "npm could not reach the registry"
197
+ : why.kind === "permission"
198
+ ? "npm may not read what it needs"
199
+ : why.status === null
200
+ ? "npm could not be run"
201
+ : `npm ended with status ${why.status}`;
202
+ return `the latest version is unknown: ${reason}.`;
203
+ }
204
+ /** Why the copy is not one `update` replaces. */
205
+ function notUpdated(refusal) {
206
+ switch (refusal.kind) {
207
+ case "not-global":
208
+ return `this copy is not updated by this command: it is not the copy \`npm install -g\` replaces (${refusal.folder}).`;
209
+ case "link":
210
+ return "this copy is not updated by this command: it is a link, not an install.";
211
+ case "unpublished":
212
+ return `this copy is not updated by this command: its version ${refusal.version} is not published on npm.`;
213
+ case "npm":
214
+ return unknownLatest(refusal.failure);
215
+ }
216
+ }
217
+ /** The sentence for an install that did not leave the version asked, or null when it did. */
218
+ function installFailed(result, running, latest) {
219
+ switch (result.kind) {
220
+ case "installed":
221
+ return null;
222
+ case "mismatch":
223
+ return result.found === null
224
+ ? `npm ended, but the copy's version cannot be read, so nothing was restarted; the services keep running ${running}. \`npm install -g allaboard@${running}\` repairs it.`
225
+ : `npm ended, but the copy now holds ${result.found} instead of ${latest}, so nothing was restarted; the services keep running ${running}. \`npm install -g allaboard@${running}\` puts back the version that runs.`;
226
+ case "failed": {
227
+ const why = result.failure;
228
+ const reason = why.kind === "network"
229
+ ? "npm could not reach the registry"
230
+ : why.kind === "permission"
231
+ ? why.path === null
232
+ ? "npm may not write where it installs"
233
+ : `npm may not write to ${why.path}`
234
+ : why.status === null
235
+ ? "npm could not be run"
236
+ : `npm ended with status ${why.status}`;
237
+ return `the update failed: ${reason}. Nothing was restarted.`;
238
+ }
239
+ }
240
+ }
241
+ const instanceName = (instance) => instance ?? "the default instance";
242
+ /**
243
+ * Answers `allaboard update`: `--check` prints the installed and the latest version; otherwise
244
+ * checks that this copy is the one `npm install -g` replaces, asks (unless `--yes`), takes the
245
+ * machine's update lock, installs the latest version, checks the copy now holds it, and sends the
246
+ * restart to every service running this copy. Returns the exit status: 0 when it updated or had
247
+ * nothing to do, 1 when it failed.
248
+ */
249
+ export async function answerUpdate(invocation, output, host = machineUpdateHost) {
250
+ // Before anything is awaited: the install replaces the `package.json` this reads.
251
+ const running = host.version();
252
+ if (invocation.check) {
253
+ const latest = await latestVersion(host.run);
254
+ if ("failure" in latest)
255
+ return failure(output, unknownLatest(latest.failure));
256
+ output.out(`allaboard ${running} · latest ${latest.version}\n`);
257
+ return 0;
258
+ }
259
+ const updatable = await updatableCopy(host.run, host.program().main, running);
260
+ if ("refusal" in updatable)
261
+ return failure(output, notUpdated(updatable.refusal));
262
+ const latest = await latestVersion(host.run);
263
+ if ("failure" in latest)
264
+ return failure(output, unknownLatest(latest.failure));
265
+ if (!isNewer(latest.version, running)) {
266
+ output.out(`allaboard ${running} is already the latest version.\n`);
267
+ return 0;
268
+ }
269
+ const listing = host.service(DEFAULT_INSTANCE)?.instances() ?? { rows: [], refused: [] };
270
+ if ("failure" in listing)
271
+ return failure(output, listing.failure);
272
+ const restarts = loadedOn(listing.rows, updatable.copy).filter((row) => row.pid !== null);
273
+ output.out(`Update allaboard ${running} → ${latest.version} · What changed: ${changelogOf(latest.version)}\n`);
274
+ output.out(restarts.length === 0
275
+ ? "No service runs this copy: it is installed and nothing restarts.\n"
276
+ : `It restarts: ${restarts.map((row) => instanceName(row.instance)).join(", ")}. Running turns finish first.\n`);
277
+ if (!invocation.yes) {
278
+ if (!host.interactive) {
279
+ return failure(output, "there is no terminal to ask on; --yes updates without asking.");
280
+ }
281
+ if (!/^y(es)?$/i.test((await host.ask("Update? [y/N] ")).trim())) {
282
+ output.out("Not updated.\n");
283
+ return 0;
284
+ }
285
+ }
286
+ let lock;
287
+ try {
288
+ lock = await updateLock(host.home);
289
+ }
290
+ catch (error) {
291
+ if (error instanceof UpdateUnderWay) {
292
+ return failure(output, "an update of allaboard is already under way on this machine.");
293
+ }
294
+ throw error;
295
+ }
296
+ try {
297
+ output.out(`Installing allaboard ${latest.version}…\n`);
298
+ const installed = await installVersion(host.run, updatable.copy, latest.version);
299
+ const refusal = installFailed(installed, running, latest.version);
300
+ if (refusal !== null)
301
+ return failure(output, refusal);
302
+ }
303
+ finally {
304
+ await lock.release();
305
+ }
306
+ const sent = [];
307
+ let code = 0;
308
+ for (const row of restarts) {
309
+ const report = host.service({ name: row.instance })?.restart();
310
+ if (report === undefined || report.code !== 0) {
311
+ code = 1;
312
+ for (const line of report?.err ?? [])
313
+ output.err(`${line}\n`);
314
+ }
315
+ else {
316
+ sent.push(instanceName(row.instance));
317
+ }
318
+ }
319
+ const named = sent.length < 2 ? sent.join("") : `${sent.slice(0, -1).join(", ")} and ${sent.at(-1)}`;
320
+ output.out(restarts.length === 0
321
+ ? `Installed allaboard ${latest.version}. No service runs this copy, so nothing was restarted.\n`
322
+ : sent.length === 0
323
+ ? `Installed allaboard ${latest.version}.\n`
324
+ : `Installed allaboard ${latest.version}. Sent the restart to ${named}: running turns finish first.\n`);
325
+ return code;
326
+ }
package/dist/clock.js ADDED
@@ -0,0 +1,21 @@
1
+ /**
2
+ * The time the daemon waits by: every pause (a reply's debounce and its stream's 280 seconds,
3
+ * the limiter's pacing, a status refresh, an idle close) goes through a `Clock`, so a test
4
+ * crosses them without waiting. Any layer may import it.
5
+ */
6
+ import { setTimeout as pause } from "node:timers/promises";
7
+ async function sleep(seconds, signal) {
8
+ try {
9
+ await pause(seconds * 1000, undefined, { signal });
10
+ }
11
+ catch (error) {
12
+ // `node:timers/promises` rejects with its own AbortError, the reason as its cause.
13
+ if (signal?.aborted)
14
+ throw signal.reason;
15
+ throw error;
16
+ }
17
+ }
18
+ /** Wall-clock seconds, as a Slack ts counts them. */
19
+ export const systemClock = { sleep, time: () => Date.now() / 1000 };
20
+ /** Seconds that only move forward: what a rate is paced by. */
21
+ export const monotonicClock = { sleep, time: () => performance.now() / 1000 };
@@ -0,0 +1,145 @@
1
+ import { getLogger } from "../log.js";
2
+ import { errorName } from "../values.js";
3
+ export const logger = getLogger("allaboard.core.cleanup");
4
+ export const CLEAN_EVERY_SECONDS = 6 * 60 * 60;
5
+ /**
6
+ * Remove from `state` each bound channel the chat answers `gone` about, with its threads. Returns
7
+ * the ids it forgot and those it `found` (answered `there`): the channels whose threads may be
8
+ * asked about. A channel with a live session is left for the next pass. When the chat finds
9
+ * none of the bound channels (a channel it gave no answer about is not one it found), nothing is
10
+ * removed: that is what the token of another workspace, or an app removed from every channel,
11
+ * looks like, and forgetting every binding would be the wrong repair. `chat` is the chat's
12
+ * name, for the log lines.
13
+ */
14
+ export async function forgetGoneChannels(lookup, state, live, chat, log = logger) {
15
+ const bound = state.channels();
16
+ const gone = [];
17
+ const found = [];
18
+ for (const channelId of bound) {
19
+ let answer;
20
+ try {
21
+ answer = await lookup(channelId);
22
+ }
23
+ catch (error) {
24
+ log.warning(`could not read channel ${channelId}: ${errorName(error)}`);
25
+ continue;
26
+ }
27
+ if (answer === "there") {
28
+ found.push(channelId);
29
+ }
30
+ else if (answer === "gone") {
31
+ gone.push(channelId);
32
+ }
33
+ else {
34
+ log.warning(`could not read channel ${channelId}: ${answer}`);
35
+ }
36
+ }
37
+ if (gone.length === 0)
38
+ return { forgotten: [], found };
39
+ if (found.length === 0) {
40
+ log.warning(`${chat} finds none of the ${bound.length} bound channels: nothing is forgotten (check the ` +
41
+ "workspace of the bot token, and that the bot is still in its channels)");
42
+ return { forgotten: [], found };
43
+ }
44
+ const busy = new Set([...live()].map((ref) => ref.channel));
45
+ const forgotten = [];
46
+ for (const channelId of gone) {
47
+ const record = state.channel(channelId);
48
+ if (record === null || busy.has(channelId))
49
+ continue;
50
+ state.removeChannel(channelId);
51
+ forgotten.push(channelId);
52
+ log.info(`forgot channel ${channelId}: ${chat} no longer has it (${record.threads.size} thread(s))`);
53
+ }
54
+ return { forgotten, found };
55
+ }
56
+ /**
57
+ * Remove from `state` each thread, among those of the `channels` the chat found, whose first
58
+ * message the chat answers `gone` about, and return them. A thread with a live session (read
59
+ * when it is removed, after the chat was asked) is left for the next pass, as are the threads of
60
+ * a channel after the first one the chat gave no answer about, and every thread not yet asked
61
+ * about once `stopping` says a stop has begun.
62
+ */
63
+ export async function forgetGoneThreads(lookup, state, channels, live, chat, log = logger, stopping = () => false) {
64
+ const asked = new Set(channels);
65
+ const gone = [];
66
+ for (const [channel, thread] of state.threads()) {
67
+ // One call to the chat per thread: a stop that begins meanwhile waits for one, not for all.
68
+ if (stopping())
69
+ break;
70
+ if (!asked.has(channel))
71
+ continue;
72
+ const ref = { channel, thread };
73
+ let answer;
74
+ try {
75
+ answer = await lookup(ref);
76
+ }
77
+ catch (error) {
78
+ answer = errorName(error);
79
+ }
80
+ if (answer === "gone") {
81
+ gone.push(ref);
82
+ }
83
+ else if (answer !== "there") {
84
+ // What kept the chat from answering (a rate limit, a history it may not read) holds for
85
+ // the channel's other threads too: they wait for the next pass, with one line here.
86
+ log.warning(`could not read thread ${thread} of ${channel}: ${answer}`);
87
+ asked.delete(channel);
88
+ }
89
+ }
90
+ const busy = new Set([...live()].map((ref) => `${ref.channel}\u0000${ref.thread}`));
91
+ const forgotten = [];
92
+ for (const ref of gone) {
93
+ if (busy.has(`${ref.channel}\u0000${ref.thread}`) ||
94
+ state.thread(ref.channel, ref.thread) === null) {
95
+ continue;
96
+ }
97
+ state.removeThread(ref.channel, ref.thread);
98
+ forgotten.push(ref);
99
+ log.info(`forgot thread ${ref.thread} of ${ref.channel}: ${chat} no longer has its first message`);
100
+ }
101
+ return forgotten;
102
+ }
103
+ /**
104
+ * One pass: forget the channels that are gone, then the threads of the others whose first message
105
+ * is gone, then prune the threads whose session is (`alive`
106
+ * gives a folder's session ids, or null when it cannot tell). Never throws: a pass that fails is
107
+ * logged and the next one tries again.
108
+ */
109
+ export async function clean(lookup, state, options) {
110
+ const { alive, live } = options;
111
+ const now = options.now ?? (() => Date.now() / 1000);
112
+ const log = options.logger ?? logger;
113
+ let found = [];
114
+ try {
115
+ found = (await forgetGoneChannels(lookup, state, live, options.chat, log)).found;
116
+ }
117
+ catch (error) {
118
+ log.warning(`could not check the bound channels: ${errorName(error)}`);
119
+ }
120
+ try {
121
+ await forgetGoneThreads(options.threadLookup, state, found, live, options.chat, log, options.stopping);
122
+ }
123
+ catch (error) {
124
+ log.warning(`could not check the threads: ${errorName(error)}`);
125
+ }
126
+ try {
127
+ const folders = new Set(state
128
+ .threads()
129
+ .filter(([, , thread]) => thread.sessionId !== null)
130
+ .map(([, , thread]) => thread.directory));
131
+ // Reading a folder's sessions is file work that may wait: the state is touched only once all
132
+ // of it is read, and a folder opened meanwhile has no entry here, so its threads are kept.
133
+ const known = new Map();
134
+ for (const folder of folders) {
135
+ const sessions = await alive(folder);
136
+ known.set(folder, sessions === null ? null : [...sessions]);
137
+ }
138
+ const removed = state.prune((directory) => known.get(directory) ?? null, options.openedAt, now(), live());
139
+ if (removed > 0)
140
+ log.info(`pruned ${removed} stale thread(s) from state.json`);
141
+ }
142
+ catch (error) {
143
+ log.warning(`could not prune stale threads: ${errorName(error)}`);
144
+ }
145
+ }
@@ -0,0 +1,172 @@
1
+ import { compareCodePoints, PY_SPACE, strip } from "../text.js";
2
+ import * as texts from "./texts.js";
3
+ /** The word each of the daemon's own commands is typed as; a new kind of `Word` fails to compile here. */
4
+ export const WORD = {
5
+ help: "help",
6
+ guide: "guide",
7
+ bind: "bind",
8
+ bypass: "bypass",
9
+ status: "status",
10
+ stop: "stop",
11
+ resume: "resume",
12
+ open: "open",
13
+ allaboard: "allaboard",
14
+ };
15
+ export const DESCRIPTION_LIMIT = 100;
16
+ // Python's whitespace, which `strip` and `split` follow, is not JavaScript's `\s` (`text.ts`).
17
+ const TRAILING = new RegExp(`[${PY_SPACE}]+$`);
18
+ const STARTS_WITH_SPACE = new RegExp(`^[${PY_SPACE}]`);
19
+ const WORD_AND_REST = new RegExp(`^([^${PY_SPACE}]+)(?:[${PY_SPACE}]+([\\s\\S]*))?$`);
20
+ const RUNS_OF_SPACE = new RegExp(`[${PY_SPACE}]+`);
21
+ /**
22
+ * Map a `!word` message to the daemon's own command or a Claude Code passthrough; null when the
23
+ * message is not a command at all.
24
+ */
25
+ export function parseBang(text) {
26
+ const stripped = strip(text);
27
+ if (!stripped.startsWith("!"))
28
+ return null;
29
+ const body = stripped.slice(1);
30
+ if (body === "" || STARTS_WITH_SPACE.test(body))
31
+ return null;
32
+ // The word ends at any whitespace: a line break after it (a code block that follows the
33
+ // word, a second line) separates it from its arguments as a space does.
34
+ const split = WORD_AND_REST.exec(body);
35
+ const word = split?.[1] ?? "";
36
+ const rest = strip(split?.[2] ?? "");
37
+ switch (word.toLowerCase()) {
38
+ case "help":
39
+ return { kind: "help", query: rest.toLowerCase() };
40
+ case "guide":
41
+ if (rest === "")
42
+ return { kind: "guide" };
43
+ break;
44
+ case "bind":
45
+ return { kind: "bind", path: rest }; // alone, it lists the folders a session can start in
46
+ case "bypass":
47
+ switch (rest.toLowerCase()) {
48
+ case "on":
49
+ return { kind: "bypass", on: true };
50
+ case "off":
51
+ return { kind: "bypass", on: false };
52
+ default:
53
+ return { kind: "invalid" };
54
+ }
55
+ case "status":
56
+ if (rest === "")
57
+ return { kind: "status" };
58
+ break;
59
+ case "stop":
60
+ if (rest === "")
61
+ return { kind: "stop" };
62
+ break;
63
+ case "resume":
64
+ return { kind: "resume", target: rest };
65
+ case "open":
66
+ return { kind: "open", target: rest };
67
+ case "allaboard":
68
+ switch (rest.toLowerCase()) {
69
+ case "":
70
+ return { kind: "allaboard", action: "show" };
71
+ case "update":
72
+ return { kind: "allaboard", action: "update" };
73
+ case "restart":
74
+ return { kind: "allaboard", action: "restart" };
75
+ default:
76
+ return { kind: "invalid" };
77
+ }
78
+ }
79
+ return { kind: "passthrough", text: strip(`${word} ${rest}`) };
80
+ }
81
+ /**
82
+ * The daemon's words, then every command the session offers now (null: not bound yet) except
83
+ * those its thread refuses (`refusedInThread`), keeping only the lines whose name or description
84
+ * contains `query`, ignoring case. With no query, the rule that tells a command from a text comes
85
+ * first. `escapeMarkdown` is the chat provider's escape for a markdown block; `names` fill the
86
+ * texts that name the agent and the chat. An agent with no commands of its own
87
+ * (`agentCommands` false) has none listed, and no line about where they are listed. An agent that
88
+ * keeps no record of trust (`folderTrust` false) has a `!bind` line that speaks of none.
89
+ */
90
+ export function helpText(commands, query, escapeMarkdown, names, agentCommands = true, unserved = new Set(), folderTrust = true) {
91
+ const keep = (line) => line.toLowerCase().includes(query.toLowerCase());
92
+ // A word the agent or the chat cannot serve is not offered: its line is left out.
93
+ const served = (line) => ![...unserved].some((word) => line.startsWith(`\`!${word} `) || line.startsWith(`\`!${word}\``));
94
+ const own = (folderTrust ? texts.HELP_WORDS : texts.HELP_WORDS_ANY)
95
+ .map((word) => texts.named(word, names))
96
+ .filter(served)
97
+ .filter(keep);
98
+ const refused = refusedInThread(commands, names.newSession, agentCommands);
99
+ const offered = (commands ?? []).filter((c) => !refused.has(c.name.toLowerCase()));
100
+ const session = offered
101
+ .toSorted((a, b) => compareCodePoints(a.name, b.name))
102
+ .map((command) => commandParts(command, escapeMarkdown))
103
+ .filter(([usage, description]) => keep(`${usage} ${description}`))
104
+ .map(([usage, description]) => `${usage} ${escapeMarkdown(description)}`.replace(TRAILING, ""));
105
+ // The rule heads the whole list; a search shows its matches alone.
106
+ const lines = [texts.HELP_OWN, ...(query ? [] : [texts.HELP_RULE]), ...own];
107
+ if (agentCommands && commands === null) {
108
+ lines.push(texts.named(texts.HELP_UNBOUND, names));
109
+ }
110
+ else if (agentCommands) {
111
+ lines.push(texts.named(texts.HELP_AGENT, names), ...session);
112
+ }
113
+ if (query && own.length === 0 && session.length === 0) {
114
+ lines.push(texts.fill(texts.HELP_NO_MATCH, { query }));
115
+ }
116
+ return lines.join("\n");
117
+ }
118
+ /**
119
+ * The answer to a command that is only ever run on the host (the agent's `login` and `logout`
120
+ * names, in `names`), or null for any other, for a name the agent does not have, and for every one
121
+ * when the agent has no commands of its own (`agentCommands` false): the caller sends that answer
122
+ * and nothing to the agent. These commands act on the host's own login, which the daemon and
123
+ * every session run on.
124
+ */
125
+ export function hostOnly(command, names, agentCommands = true) {
126
+ if (!agentCommands)
127
+ return null; // no command is the agent's, so none is run on the host
128
+ const word = command.text.split(" ", 1)[0].toLowerCase();
129
+ if (names.login !== null && word === names.login.toLowerCase()) {
130
+ return texts.named(texts.LOGIN_ON_HOST, names);
131
+ }
132
+ if (names.logout !== null && word === names.logout.toLowerCase()) {
133
+ return texts.named(texts.LOGOUT_ON_HOST, names);
134
+ }
135
+ return null;
136
+ }
137
+ /**
138
+ * The command names a thread refuses, lowercase: the agent's `newSession` names (one thread is
139
+ * one session, and a thread is also the only place a session's commands are listed; they are
140
+ * known before a session rebuilt after a restart connects), with every alias the session's own
141
+ * list (`commands`, null or empty before it connects) gives one of them. None for an agent with
142
+ * no commands of its own (`agentCommands` false).
143
+ */
144
+ export function refusedInThread(commands, newSession, agentCommands = true) {
145
+ if (!agentCommands)
146
+ return new Set(); // an agent with no commands of its own has none to refuse
147
+ const own = new Set(newSession.map((name) => name.toLowerCase()));
148
+ const aliases = (commands ?? [])
149
+ .filter((command) => own.has(command.name.toLowerCase()))
150
+ .flatMap((command) => command.aliases.map((alias) => alias.toLowerCase()));
151
+ return new Set([...own, ...aliases]);
152
+ }
153
+ /**
154
+ * `!name hint` and the description as written, cut to one short line; not escaped. The
155
+ * description is third-party text, and a cut can fall inside its own code span: the caller
156
+ * escapes it before showing it (`helpText`).
157
+ */
158
+ export function commandParts(command, escapeMarkdown) {
159
+ const plain = `!${command.name} ${command.argumentHint}`.replace(TRAILING, "");
160
+ // A code span cannot hold a backtick: such a usage is plain text, escaped.
161
+ const usage = plain.includes("`") ? escapeMarkdown(plain) : `\`${plain}\``;
162
+ let description = command.description
163
+ .split(RUNS_OF_SPACE)
164
+ .filter((word) => word !== "")
165
+ .join(" ");
166
+ // The cut counts code points: a unit-wise cut could split an emoji.
167
+ const points = Array.from(description);
168
+ if (points.length > DESCRIPTION_LIMIT) {
169
+ description = `${points.slice(0, DESCRIPTION_LIMIT - 1).join("")}…`;
170
+ }
171
+ return [usage, description];
172
+ }