allaboard 0.0.0-stage → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +43 -2
  3. package/dist/agent/claude/backend.js +124 -0
  4. package/dist/agent/claude/capabilities.js +30 -0
  5. package/dist/agent/claude/chrome.js +42 -0
  6. package/dist/agent/claude/hooks.js +37 -0
  7. package/dist/agent/claude/info.js +61 -0
  8. package/dist/agent/claude/listing.js +230 -0
  9. package/dist/agent/claude/module.js +82 -0
  10. package/dist/agent/claude/prompt.js +24 -0
  11. package/dist/agent/claude/record.js +9 -0
  12. package/dist/agent/claude/requests.js +122 -0
  13. package/dist/agent/claude/session.js +441 -0
  14. package/dist/agent/claude/translate.js +581 -0
  15. package/dist/agent/claude/trust.js +576 -0
  16. package/dist/agent/claude/usage-probe.js +103 -0
  17. package/dist/agent/claude/usage.js +110 -0
  18. package/dist/agent/claude/wire.js +22 -0
  19. package/dist/agent/seam.js +21 -0
  20. package/dist/chat/seam.js +10 -0
  21. package/dist/chat/slack/app/answers.js +209 -0
  22. package/dist/chat/slack/app/app.js +262 -0
  23. package/dist/chat/slack/app/clip.js +191 -0
  24. package/dist/chat/slack/app/guards.js +162 -0
  25. package/dist/chat/slack/app/home-controls.js +91 -0
  26. package/dist/chat/slack/app/messages.js +181 -0
  27. package/dist/chat/slack/app/open.js +392 -0
  28. package/dist/chat/slack/app/order.js +37 -0
  29. package/dist/chat/slack/app/requests.js +482 -0
  30. package/dist/chat/slack/app/resume-bind.js +71 -0
  31. package/dist/chat/slack/app/wire.js +83 -0
  32. package/dist/chat/slack/attachments.js +348 -0
  33. package/dist/chat/slack/bind.js +67 -0
  34. package/dist/chat/slack/channel.js +67 -0
  35. package/dist/chat/slack/channels.js +43 -0
  36. package/dist/chat/slack/delete.js +278 -0
  37. package/dist/chat/slack/footer.js +43 -0
  38. package/dist/chat/slack/hold.js +37 -0
  39. package/dist/chat/slack/home.js +1069 -0
  40. package/dist/chat/slack/module.js +174 -0
  41. package/dist/chat/slack/notice.js +64 -0
  42. package/dist/chat/slack/openfile/modal.js +362 -0
  43. package/dist/chat/slack/openfile/read.js +29 -0
  44. package/dist/chat/slack/quiet-logger.js +52 -0
  45. package/dist/chat/slack/repair.js +123 -0
  46. package/dist/chat/slack/reply/blocks.js +372 -0
  47. package/dist/chat/slack/reply/chars.js +59 -0
  48. package/dist/chat/slack/reply/clients.js +171 -0
  49. package/dist/chat/slack/reply/errors.js +125 -0
  50. package/dist/chat/slack/reply/escape.js +25 -0
  51. package/dist/chat/slack/reply/limiter.js +86 -0
  52. package/dist/chat/slack/reply/markdown.js +210 -0
  53. package/dist/chat/slack/reply/places.js +262 -0
  54. package/dist/chat/slack/reply/sinks.js +2403 -0
  55. package/dist/chat/slack/reply/status.js +433 -0
  56. package/dist/chat/slack/reply/tasks.js +11 -0
  57. package/dist/chat/slack/requests.js +552 -0
  58. package/dist/chat/slack/resume.js +127 -0
  59. package/dist/chat/slack/setup.js +114 -0
  60. package/dist/chat/slack/texts.js +185 -0
  61. package/dist/chat/slack/thread.js +537 -0
  62. package/dist/chat/slack/update.js +71 -0
  63. package/dist/chat/slack/voice.js +84 -0
  64. package/dist/cli.js +326 -0
  65. package/dist/clock.js +21 -0
  66. package/dist/core/cleanup.js +145 -0
  67. package/dist/core/commands.js +172 -0
  68. package/dist/core/config.js +174 -0
  69. package/dist/core/files/changed.js +125 -0
  70. package/dist/core/files/listing.js +382 -0
  71. package/dist/core/files/paths.js +9 -0
  72. package/dist/core/files/read.js +191 -0
  73. package/dist/core/files/waiting.js +34 -0
  74. package/dist/core/flows/answers.js +133 -0
  75. package/dist/core/flows/ask-owner.js +95 -0
  76. package/dist/core/flows/asking.js +14 -0
  77. package/dist/core/flows/bind.js +95 -0
  78. package/dist/core/flows/contract.js +1 -0
  79. package/dist/core/flows/flows.js +35 -0
  80. package/dist/core/flows/hold.js +22 -0
  81. package/dist/core/flows/inbound.js +24 -0
  82. package/dist/core/flows/lists.js +21 -0
  83. package/dist/core/flows/messages.js +293 -0
  84. package/dist/core/flows/notices.js +51 -0
  85. package/dist/core/flows/repair.js +106 -0
  86. package/dist/core/flows/resume.js +163 -0
  87. package/dist/core/flows/setup.js +130 -0
  88. package/dist/core/flows/status.js +98 -0
  89. package/dist/core/flows/talk.js +207 -0
  90. package/dist/core/flows/update.js +370 -0
  91. package/dist/core/flows/words.js +171 -0
  92. package/dist/core/folders.js +93 -0
  93. package/dist/core/footer.js +313 -0
  94. package/dist/core/guide.js +50 -0
  95. package/dist/core/hold.js +126 -0
  96. package/dist/core/instance.js +46 -0
  97. package/dist/core/lock.js +412 -0
  98. package/dist/core/models.js +10 -0
  99. package/dist/core/paths.js +75 -0
  100. package/dist/core/port/file-flows.js +1 -0
  101. package/dist/core/port/home.js +1 -0
  102. package/dist/core/port/requests.js +1 -0
  103. package/dist/core/port/threads.js +1 -0
  104. package/dist/core/port/updates.js +1 -0
  105. package/dist/core/port.js +17 -0
  106. package/dist/core/reply/fold.js +175 -0
  107. package/dist/core/reply/previews.js +258 -0
  108. package/dist/core/reply/renderer.js +846 -0
  109. package/dist/core/reply/tail.js +24 -0
  110. package/dist/core/reply/words.js +32 -0
  111. package/dist/core/requests.js +158 -0
  112. package/dist/core/sessions/constants.js +61 -0
  113. package/dist/core/sessions/deps.js +1 -0
  114. package/dist/core/sessions/directory.js +86 -0
  115. package/dist/core/sessions/errors.js +79 -0
  116. package/dist/core/sessions/manager.js +378 -0
  117. package/dist/core/sessions/session.js +3297 -0
  118. package/dist/core/sessions/tasks.js +266 -0
  119. package/dist/core/sessions/turn.js +45 -0
  120. package/dist/core/setup.js +28 -0
  121. package/dist/core/state.js +724 -0
  122. package/dist/core/texts.js +451 -0
  123. package/dist/core/updates.js +332 -0
  124. package/dist/daemon.js +566 -0
  125. package/dist/host/npm.js +134 -0
  126. package/dist/host/program.js +49 -0
  127. package/dist/host/run.js +52 -0
  128. package/dist/host/service/launchd.js +385 -0
  129. package/dist/host/service/listing.js +42 -0
  130. package/dist/host/service/seam.js +1 -0
  131. package/dist/log.js +37 -0
  132. package/dist/main.js +29 -0
  133. package/dist/node-version.js +40 -0
  134. package/dist/registry.js +12 -0
  135. package/dist/semver.js +53 -0
  136. package/dist/text.js +89 -0
  137. package/dist/updates-host.js +113 -0
  138. package/dist/values.js +30 -0
  139. package/dist/version.js +54 -0
  140. package/package.json +32 -4
  141. package/slack-app-manifest.json +50 -0
@@ -0,0 +1,313 @@
1
+ /** The context line under every reply as data: git state, usage limits, tokens and the fields. */
2
+ import { spawn } from "node:child_process";
3
+ import { getLogger } from "../log.js";
4
+ import { strip } from "../text.js";
5
+ import { errorName } from "../values.js";
6
+ export const logger = getLogger("allaboard.core.footer");
7
+ export const USAGE_TTL = 300_000;
8
+ // For every git call of one footer together: the reply waits for it.
9
+ export const GIT_TIMEOUT = 5_000;
10
+ // ` 2 files changed, 42 insertions(+), 10 deletions(-)`, as every --shortstat writes it, git 2.54
11
+ // (2026-09-27).
12
+ const SHORTSTAT_INSERTIONS = /(\d+) insertions?\(\+\)/;
13
+ const SHORTSTAT_DELETIONS = /(\d+) deletions?\(-\)/;
14
+ // Variables of an outer git (a hook) that would send ours elsewhere.
15
+ const GIT_REDIRECTS = ["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE"];
16
+ const MINUTE_MS = 60_000;
17
+ /**
18
+ * The usage cache: `/usage` costs a model call on a client of its own, so it is read at most once
19
+ * in `ttl` unless `invalidate` says the limits moved.
20
+ */
21
+ export class UsageCache {
22
+ current = null;
23
+ fetch;
24
+ ttl;
25
+ clock;
26
+ warn;
27
+ fetchedAt = null;
28
+ refreshing = false;
29
+ constructor(fetch, options = {}) {
30
+ this.fetch = fetch;
31
+ this.ttl = options.ttl ?? USAGE_TTL;
32
+ this.clock = options.clock ?? (() => performance.now());
33
+ this.warn = options.warn ?? ((message) => logger.warning(message));
34
+ }
35
+ invalidate() {
36
+ this.fetchedAt = null;
37
+ }
38
+ /** Refresh when older than the TTL; a failure keeps the old value and waits a full TTL. */
39
+ async refreshIfStale() {
40
+ if (this.refreshing) {
41
+ return;
42
+ }
43
+ this.refreshing = true;
44
+ try {
45
+ if (this.fetchedAt !== null && this.clock() - this.fetchedAt < this.ttl) {
46
+ return;
47
+ }
48
+ try {
49
+ this.current = await this.fetch();
50
+ }
51
+ catch (error) {
52
+ // The footer is optional: never let it break a reply. The name only, never the text.
53
+ this.warn(`usage refresh failed: ${errorName(error)}`);
54
+ }
55
+ this.fetchedAt = this.clock();
56
+ }
57
+ finally {
58
+ this.refreshing = false;
59
+ }
60
+ }
61
+ }
62
+ /**
63
+ * `git` on `repository`: its output, or null when it fails. The caller holds the time limit
64
+ * (`gitState` here, `openfile` for `!open`) through `signal`, which kills the process and
65
+ * rejects with its reason, and chooses commands that never write the index (see `changesOf`).
66
+ */
67
+ export function runGit(repository, args, signal) {
68
+ if (repository.gitDir === null) {
69
+ return Promise.resolve(null);
70
+ }
71
+ const env = { ...process.env };
72
+ for (const name of GIT_REDIRECTS) {
73
+ delete env[name];
74
+ }
75
+ return new Promise((resolve, reject) => {
76
+ if (signal?.aborted) {
77
+ reject(signal.reason);
78
+ return;
79
+ }
80
+ const child = spawn("git", [
81
+ // A diff must not run the repo's own fsmonitor command.
82
+ "-c",
83
+ "core.fsmonitor=false",
84
+ // Named, never found: git left to search from the folder would take a planted `.git`
85
+ // file, a bare layout or a `core.worktree` at its word (git(1), `--git-dir`).
86
+ "--git-dir",
87
+ repository.gitDir,
88
+ ...args,
89
+ ], { cwd: repository.root, env, stdio: ["ignore", "pipe", "ignore"] });
90
+ const chunks = [];
91
+ const onAbort = () => {
92
+ // Also when the time limit or the session closing cancels the footer: no git left running.
93
+ child.kill("SIGKILL");
94
+ reject(signal?.reason);
95
+ };
96
+ signal?.addEventListener("abort", onAbort, { once: true });
97
+ child.stdout.on("data", (chunk) => chunks.push(chunk));
98
+ child.once("error", () => {
99
+ signal?.removeEventListener("abort", onAbort);
100
+ resolve(null);
101
+ });
102
+ child.once("close", (code) => {
103
+ signal?.removeEventListener("abort", onAbort);
104
+ resolve(code === 0 ? Buffer.concat(chunks).toString("utf8") : null);
105
+ });
106
+ });
107
+ }
108
+ /** Insertions and deletions from a `--shortstat`, which leaves out a zero count. */
109
+ export function shortstatLines(out) {
110
+ const insertions = SHORTSTAT_INSERTIONS.exec(out);
111
+ const deletions = SHORTSTAT_DELETIONS.exec(out);
112
+ return [insertions ? Number(insertions[1]) : 0, deletions ? Number(deletions[1]) : 0];
113
+ }
114
+ /**
115
+ * Lines inserted and deleted since the last commit, staged and unstaged, as ccstatusline's
116
+ * git-changes counts them for the terminal (untracked files not counted). Null where there is
117
+ * no work tree.
118
+ *
119
+ * Plumbing only: `git diff` refreshes and rewrites the index under `index.lock` (measured on
120
+ * git 2.54, `--no-optional-locks` included), and a lock left by a killed diff would stop every
121
+ * `git add` and commit in the repo. `diff-files` and `diff-index` never write it.
122
+ *
123
+ * A submodule counts by its commit alone. Without `--ignore-submodules=dirty` git runs
124
+ * `git status` inside every nested repository the index names, under that repository's own
125
+ * config and filters (measured on git 2.54, 2026-10-04).
126
+ */
127
+ async function changesOf(repository, signal) {
128
+ const unstaged = await runGit(repository, ["diff-files", "--shortstat", "--ignore-submodules=dirty"], signal);
129
+ if (unstaged === null) {
130
+ return null;
131
+ }
132
+ // `--`: git runs at the root, where a file named HEAD would make the revision ambiguous.
133
+ let staged = await runGit(repository, ["diff-index", "--cached", "--shortstat", "HEAD", "--"], signal);
134
+ if (staged === null) {
135
+ // No commit yet: what is staged is compared with the empty tree, as `git diff --cached`.
136
+ const empty = await runGit(repository, ["hash-object", "-t", "tree", "/dev/null"], signal);
137
+ if (empty === null) {
138
+ return null;
139
+ }
140
+ staged = await runGit(repository, ["diff-index", "--cached", "--shortstat", empty.trim()], signal);
141
+ if (staged === null) {
142
+ return null;
143
+ }
144
+ }
145
+ const [added, removed] = shortstatLines(unstaged);
146
+ const [addedStaged, removedStaged] = shortstatLines(staged);
147
+ return [added + addedStaged, removed + removedStaged];
148
+ }
149
+ /** `promise`, or the signal's reason when it aborts first. */
150
+ function until(promise, signal) {
151
+ return new Promise((resolve, reject) => {
152
+ if (signal.aborted) {
153
+ reject(signal.reason);
154
+ return;
155
+ }
156
+ const onAbort = () => reject(signal.reason);
157
+ signal.addEventListener("abort", onAbort, { once: true });
158
+ promise.then(resolve, reject).finally(() => signal.removeEventListener("abort", onAbort));
159
+ });
160
+ }
161
+ /**
162
+ * The branch and the changes of the repository holding `here`, each null when unknown.
163
+ *
164
+ * `repository` answers only for a repository the owner trusted in Claude Code
165
+ * (`trustedRepository`): anywhere else no git runs, since a diff runs the filters a repository's
166
+ * config names. Every call shares one `timeout` (milliseconds).
167
+ */
168
+ export async function gitState(here, repository, timeout = GIT_TIMEOUT) {
169
+ let branch = null;
170
+ let changes = null;
171
+ const limit = new AbortController();
172
+ const timer = setTimeout(() => limit.abort(new Error("git timeout")), timeout);
173
+ const { signal } = limit;
174
+ try {
175
+ const found = await until(repository(here), signal);
176
+ if (found === null || found.gitDir === null) {
177
+ return [null, null];
178
+ }
179
+ const out = await runGit(found, ["branch", "--show-current"], signal);
180
+ branch = out ? strip(out) || null : null;
181
+ if (!found.insideGitDir) {
182
+ changes = await changesOf(found, signal);
183
+ }
184
+ }
185
+ catch (error) {
186
+ // What the limit cut off stays unknown; anything else is not ours to hide.
187
+ if (!signal.aborted) {
188
+ throw error;
189
+ }
190
+ }
191
+ finally {
192
+ clearTimeout(timer);
193
+ }
194
+ return [branch, changes];
195
+ }
196
+ /** The tokens of a turn summed over its models; null when it used none. */
197
+ export function sessionTokens(tokens) {
198
+ const models = Object.values(tokens);
199
+ if (models.length === 0) {
200
+ return null;
201
+ }
202
+ return models.reduce((sum, t) => sum + t.input + t.output + t.cacheRead + t.cacheCreation, 0);
203
+ }
204
+ /**
205
+ * `value` with `digits` decimals as Python's `format` writes it. A tie such as 6.5 or 12.25
206
+ * (exact in binary) goes to the even digit there and to the larger one in `toFixed`.
207
+ */
208
+ function formatFixed(value, digits) {
209
+ const rounded = value.toFixed(digits);
210
+ // `toFixed` writes the exact binary value up to 100 digits: a tie is a 5 and then zeros.
211
+ const exact = value.toFixed(digits + 30);
212
+ const tail = exact.slice(exact.length - 30);
213
+ if (!/^50*$/.test(tail)) {
214
+ return rounded;
215
+ }
216
+ const kept = exact.slice(0, exact.length - 30).replace(/\.$/, "");
217
+ return Number(rounded.at(-1)) % 2 === 0 ? rounded : kept;
218
+ }
219
+ export function formatTokens(count) {
220
+ if (count >= 1_000_000) {
221
+ return `${formatFixed(count / 1_000_000, 1)}M`;
222
+ }
223
+ if (count >= 1_000) {
224
+ return `${formatFixed(count / 1_000, 1)}k`;
225
+ }
226
+ return String(count);
227
+ }
228
+ /** The time to a reset, `delta` in milliseconds: minutes, then hours, then days and hours. */
229
+ export function formatUntil(delta) {
230
+ const minutes = Math.max(0, Math.floor(delta / MINUTE_MS));
231
+ if (minutes < 60) {
232
+ return `${minutes}m`;
233
+ }
234
+ let hours = Math.floor(minutes / 60);
235
+ if (hours < 48) {
236
+ return `${hours}h`;
237
+ }
238
+ // Days and hours, as the terminal's weekly reset timer (ccstatusline) counts down a week.
239
+ const days = Math.floor(hours / 24);
240
+ hours %= 24;
241
+ return hours ? `${days}d ${hours}h` : `${days}d`;
242
+ }
243
+ /** `3% ↻ 2h`: the share used, and the time to its reset when known. */
244
+ export function formatLimit(limit, now) {
245
+ if (limit.resetsAt === null) {
246
+ return `${limit.percent}%`;
247
+ }
248
+ return `${limit.percent}% ↻ ${formatUntil(limit.resetsAt - now)}`;
249
+ }
250
+ /**
251
+ * The values the footer and `!status` both show, in the footer's order; what is not known is left
252
+ * out. One list, so the two never write a value differently.
253
+ */
254
+ export function footerFields(data, now) {
255
+ const fields = [];
256
+ if (data.model) {
257
+ fields.push({ key: "model", label: "Model", value: data.model });
258
+ }
259
+ if (data.effort) {
260
+ fields.push({
261
+ key: "effort",
262
+ label: "Effort",
263
+ value: data.effort,
264
+ detail: data.effortObserved ?? undefined,
265
+ });
266
+ }
267
+ if (data.branch) {
268
+ fields.push({ key: "branch", label: "Branch", value: data.branch });
269
+ }
270
+ if (data.changes !== null) {
271
+ fields.push({
272
+ key: "changes",
273
+ label: "Uncommitted",
274
+ value: `(+${data.changes[0]},-${data.changes[1]})`,
275
+ });
276
+ }
277
+ if (data.sessionTokens !== null) {
278
+ fields.push({
279
+ key: "tokens",
280
+ label: "Session tokens",
281
+ value: formatTokens(data.sessionTokens),
282
+ });
283
+ }
284
+ if (data.contextPercent !== null) {
285
+ fields.push({
286
+ key: "context",
287
+ label: "Context",
288
+ value: `${formatFixed(data.contextPercent, 0)}%`,
289
+ });
290
+ }
291
+ if (data.sessionLimit) {
292
+ fields.push({
293
+ key: "session_limit",
294
+ label: "5h limit",
295
+ value: formatLimit(data.sessionLimit, now),
296
+ });
297
+ }
298
+ if (data.weekLimit) {
299
+ fields.push({ key: "week_limit", label: "7d limit", value: formatLimit(data.weekLimit, now) });
300
+ }
301
+ return fields;
302
+ }
303
+ /** `auto (medium)`: a value and what it ran at, or the value alone. */
304
+ export function valueWithDetail(field) {
305
+ return !field.detail ? field.value : `${field.value} (${field.detail})`;
306
+ }
307
+ /**
308
+ * The footer's values as `!status` lines, one per field; bypass and the folder are left out,
309
+ * since the status's Mode and Directory lines already show them.
310
+ */
311
+ export function formatStatusFields(data, now) {
312
+ return footerFields(data, now).map((field) => `${field.label}: \`${valueWithDetail(field)}\``);
313
+ }
@@ -0,0 +1,50 @@
1
+ /** `!guide`: the guide assembled from the parts of `texts.ts` that the agent and the chat can serve. */
2
+ import * as texts from "./texts.js";
3
+ import { fill, named } from "./texts.js";
4
+ /** `a`, `a and b`, `a, b and c`. */
5
+ function joined(items) {
6
+ if (items.length < 2)
7
+ return items.join("");
8
+ return `${items.slice(0, -1).join(", ")} and ${items.at(-1)}`;
9
+ }
10
+ /** The guide for these names, with only the parts `offered` says the pair can serve. */
11
+ export function guideText(names, offered) {
12
+ const say = (text) => typeof text === "string" ? text : named(text, names);
13
+ const choices = [
14
+ offered.models ? texts.GUIDE_SETUP_MODEL : null,
15
+ offered.efforts ? texts.GUIDE_SETUP_EFFORT : null,
16
+ offered.bypass ? texts.GUIDE_SETUP_BYPASS : null,
17
+ ].flatMap((item) => (item === null ? [] : [say(item)]));
18
+ const start = [
19
+ texts.GUIDE_START,
20
+ say(offered.folderTrust ? texts.GUIDE_BIND_TRUSTED : texts.GUIDE_BIND_ANY),
21
+ say(texts.GUIDE_FIRST_MESSAGE),
22
+ ...(choices.length > 0 ? [fill(texts.GUIDE_SETUP, { choices: joined(choices) })] : []),
23
+ ];
24
+ const approvals = [
25
+ say(texts.GUIDE_PERMISSION),
26
+ ...(offered.questions ? [say(texts.GUIDE_QUESTION)] : []),
27
+ ];
28
+ const sessions = texts.GUIDE_SESSIONS_STATUS +
29
+ (offered.backgroundTasks ? texts.GUIDE_SESSIONS_TASKS : "") +
30
+ texts.GUIDE_SESSIONS_STOP +
31
+ (offered.sessionListing ? ` ${texts.GUIDE_SESSIONS_RESUME}` : "") +
32
+ ` ${texts.GUIDE_SESSIONS_TERMINAL}`;
33
+ const [first, ...others] = names.newSession.map((name) => `\`!${name}\``);
34
+ const newSession = first === undefined
35
+ ? ""
36
+ : ` ${fill(texts.GUIDE_NEW_SESSION, { names: others.length > 0 ? `${first} (${others.join(", ")})` : first })}`;
37
+ const commands = say(texts.GUIDE_COMMANDS) + newSession;
38
+ const sections = [
39
+ say(texts.GUIDE_INTRO),
40
+ start.join("\n"),
41
+ ...(offered.commands ? [commands] : []),
42
+ `${offered.questions ? texts.GUIDE_APPROVALS_AND_QUESTIONS : texts.GUIDE_APPROVALS}\n${approvals.join(" ")}`,
43
+ sessions,
44
+ ...(offered.fileUpload ? [say(texts.GUIDE_FILES)] : []),
45
+ ...(offered.bypass ? [say(texts.GUIDE_BYPASS)] : []),
46
+ ...(offered.updates ? [texts.GUIDE_UPDATES] : []),
47
+ texts.GUIDE_END,
48
+ ];
49
+ return sections.join("\n\n");
50
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Questions that hold a message before it is sent. The same-folder hold: before a message wakes
3
+ * an idle session while a live session of another thread (any channel) is busy in the same
4
+ * resolved folder, the daemon asks
5
+ * `Another session is working in this folder: <link>. Send anyway?`, with two buttons,
6
+ * `Send anyway` and `Don't send`, which the code calls Continue and Cancel.
7
+ * The session setup (`setup.ts`) is the other one: model, effort and bypass, then Start.
8
+ *
9
+ * Kept in memory only, like `Approvals`: a hold never outlives the process (`!stop`, a top-level
10
+ * `!stop` of its channel, and a drain all cancel one exactly as Cancel does), so nothing here
11
+ * needs to survive a restart. A thread has at most one open at a time, whichever kind.
12
+ */
13
+ import { randomBytes } from "node:crypto";
14
+ /**
15
+ * Questions waiting for the owner, by an id that only the posted buttons carry (as `Approvals`
16
+ * keys its own requests).
17
+ */
18
+ export class Holds {
19
+ entries = new Map();
20
+ open(channelId, threadId, context = null) {
21
+ const holdId = randomBytes(16).toString("base64url");
22
+ let settle = () => { };
23
+ const answer = new Promise((resolve) => {
24
+ settle = resolve;
25
+ });
26
+ const pending = {
27
+ channelId,
28
+ threadId,
29
+ answer,
30
+ decided: false,
31
+ messageTs: null,
32
+ context,
33
+ cancelled: false,
34
+ };
35
+ this.entries.set(holdId, { pending, settle });
36
+ return [holdId, pending];
37
+ }
38
+ get(holdId) {
39
+ return this.entries.get(holdId)?.pending ?? null;
40
+ }
41
+ /**
42
+ * Resolve once with `value` (null cancels), and only from the channel and thread the request
43
+ * was posted in. A value keeps the entry until the asker `discard`s it, so a stop that arrives
44
+ * while the answer is applied still finds it (`cancel`).
45
+ */
46
+ resolve(holdId, channelId, threadId, value) {
47
+ const entry = this.entries.get(holdId);
48
+ if (entry === undefined ||
49
+ entry.pending.channelId !== channelId ||
50
+ entry.pending.threadId !== threadId ||
51
+ entry.pending.decided) {
52
+ return null;
53
+ }
54
+ entry.pending.decided = true;
55
+ entry.settle(value);
56
+ if (value === null)
57
+ this.entries.delete(holdId);
58
+ return entry.pending;
59
+ }
60
+ /**
61
+ * Record the message that shows a hold, so it can be removed once decided; false when the hold
62
+ * was decided before its message was known (a click or `!stop` while posting).
63
+ */
64
+ posted(holdId, messageTs) {
65
+ const entry = this.entries.get(holdId);
66
+ if (entry === undefined)
67
+ return false;
68
+ entry.pending.messageTs = messageTs;
69
+ return !entry.pending.decided;
70
+ }
71
+ /**
72
+ * Cancel the hold open in this thread, if any: the same outcome as the owner clicking Cancel
73
+ * (`!stop`, a top-level `!stop` of its channel, a drain). One already answered but still being
74
+ * applied is flagged `cancelled` instead: its asker sends nothing.
75
+ */
76
+ cancel(channelId, threadId) {
77
+ for (const [holdId, entry] of [...this.entries]) {
78
+ const { pending } = entry;
79
+ if (pending.channelId !== channelId || pending.threadId !== threadId)
80
+ continue;
81
+ if (!pending.decided) {
82
+ pending.decided = true;
83
+ entry.settle(null);
84
+ this.entries.delete(holdId);
85
+ return pending;
86
+ }
87
+ if (!pending.cancelled) {
88
+ pending.cancelled = true;
89
+ return pending;
90
+ }
91
+ }
92
+ return null;
93
+ }
94
+ /**
95
+ * The id of the open hold shown by this message, for a control that carries no id of its own
96
+ * (a select): null when it is decided, gone, or shown elsewhere.
97
+ */
98
+ atMessage(channelId, threadId, messageTs) {
99
+ for (const [holdId, { pending }] of this.entries) {
100
+ if (pending.channelId === channelId &&
101
+ pending.threadId === threadId &&
102
+ pending.messageTs === messageTs &&
103
+ !pending.decided) {
104
+ return holdId;
105
+ }
106
+ }
107
+ return null;
108
+ }
109
+ /**
110
+ * Whether this message shows a hold that was decided and whose answer is still being applied:
111
+ * false once its asker discarded it, as for a hold never held (a restart).
112
+ */
113
+ decidedAt(channelId, threadId, messageTs) {
114
+ for (const { pending } of this.entries.values()) {
115
+ if (pending.channelId === channelId &&
116
+ pending.threadId === threadId &&
117
+ pending.messageTs === messageTs) {
118
+ return pending.decided;
119
+ }
120
+ }
121
+ return false;
122
+ }
123
+ discard(holdId) {
124
+ this.entries.delete(holdId);
125
+ }
126
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * The instance: which of the daemons on one machine this is. It is a name, or none for the default
3
+ * instance, and what is the instance's own derives from it here: its configuration folder and its
4
+ * scratch folder. The same rule holds on every system, and a function takes the home and the
5
+ * temporary folder as arguments so that a test passes its own.
6
+ */
7
+ import { join } from "node:path";
8
+ /**
9
+ * The variable that selects an instance when `--instance` is absent. The command line reads it;
10
+ * the daemon writes it to its own environment, so that the sessions it starts inherit it.
11
+ */
12
+ export const INSTANCE_VARIABLE = "ALLABOARD_INSTANCE";
13
+ export const DEFAULT_INSTANCE = { name: null };
14
+ // 32 characters at most: the lock of a Linux instance is a socket, whose path has 108 bytes on
15
+ // Linux (`sun_path`); macOS, whose limit is 104, takes the directory lock instead.
16
+ const NAME = /^[a-z0-9][a-z0-9-]{0,31}$/;
17
+ /** A name that follows the rule, or the sentence that says why `text` is refused. */
18
+ export function parseInstanceName(text) {
19
+ if (text === "default") {
20
+ return {
21
+ refusal: `the instance name "default" would read as the default instance, which has no name: leave the instance out to use it`,
22
+ };
23
+ }
24
+ if (text === "allaboard") {
25
+ return {
26
+ refusal: `the instance name "allaboard" would share the default instance's log, allaboard.log: choose another name`,
27
+ };
28
+ }
29
+ if (!NAME.test(text)) {
30
+ return {
31
+ refusal: `${JSON.stringify(text)} is not an instance name: a name is 1 to 32 characters of a-z, 0-9 and "-", and starts with a letter or a digit`,
32
+ };
33
+ }
34
+ return { name: text };
35
+ }
36
+ function suffix(instance) {
37
+ return instance.name === null ? "" : `-${instance.name}`;
38
+ }
39
+ /** `~/.config/allaboard` for the default instance, `~/.config/allaboard-<name>` for a named one. */
40
+ export function configDirOf(instance, home) {
41
+ return join(home, ".config", `allaboard${suffix(instance)}`);
42
+ }
43
+ /** The folder under `tmp` that holds the files a chat saves for the instance: `allaboard[-<name>]`. */
44
+ export function scratchDirOf(instance, tmp) {
45
+ return join(tmp, `allaboard${suffix(instance)}`);
46
+ }