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/daemon.js ADDED
@@ -0,0 +1,566 @@
1
+ /**
2
+ * The daemon and the command's wiring: `start` runs the daemon (normally from the LaunchAgent in
3
+ * docs/setup.md), or answers one of the arguments of `cli.ts` and ends. The one module that may
4
+ * import all three layers. `main.ts` loads it once the Node is known to be new enough.
5
+ *
6
+ * How the process behaves under Node:
7
+ *
8
+ * - Signals. `SIGTERM` and `SIGINT` are caught with `process.on`, from the start of `run` up to
9
+ * the end of the shutdown. Node's signal watchers do not keep the event loop alive, and a
10
+ * listener removed at the end puts the default disposition back.
11
+ * - What keeps the process running. A pending promise holds nothing. While the daemon waits for
12
+ * a signal, the Socket Mode client and the cleanup's timer are what the libraries and the
13
+ * clock happen to keep alive, so `run` holds one timer of its own until the shutdown ends. On
14
+ * a stop it clears that timer and closes everything else it made (the cleanup's sleep and the
15
+ * drain's limit are aborted, never left to run out).
16
+ * - Errors nobody handles. A rejected promise or a thrown exception that nobody handles would
17
+ * end a Node process at once, without the shutdown below, with open replies and a Claude Code
18
+ * process per session. `installProcessHandlers` logs each by its name (the message and the
19
+ * stack can quote a request). After a rejected promise nobody handled the daemon goes on: it
20
+ * is a piece of work that failed with nobody waiting for it, and with a listener set Node
21
+ * leaves the process running (`--unhandled-rejections`, default `throw`).
22
+ * An uncaught exception stops the daemon as a SIGINT would (the shutdown without a drain:
23
+ * sessions closed, lock released) and ends the process with status 1, so that launchd starts
24
+ * a clean daemon and the crash repair runs: Node's documentation of the event says it is not
25
+ * safe to resume normal operation after it (read 2026-10-10, v22). One rejection the daemon
26
+ * goes on from leaves it deaf: `@slack/socket-mode` 3.1.0 reconnects from its `close`
27
+ * listener without awaiting the call (`SocketModeClient.delayReconnectAttempt`), and when
28
+ * Slack refuses the app token for good (`invalid_auth` and the four other codes of
29
+ * `UnrecoverableSocketModeStartError`) nothing but that rejection shows it. The log then
30
+ * holds `unhandled rejection: <code>`, and the fix is the token and a restart.
31
+ * - The update (issue #361). `run` gives the core a way to update the daemon from the chat:
32
+ * `updatesHost` over the machine (`RunOptions.machine`: npm, the service manager, the entry
33
+ * file), a check for a newer version at start and every 6 hours (`checkEvery`, stopped by the
34
+ * same signal as the cleanup), and the stop the click asks for (`StopSignals.request`, which
35
+ * the service answers by starting the new copy). Both ask npm through a signal that sends it
36
+ * SIGTERM (`RunAsync`). The shutdown ends a check at its start, so that a hung `npm view` does
37
+ * not hold it. An install under way is work the drain waits for; a stop with no drain
38
+ * (SIGINT, a failure), or a drain that was cut short, sends npm SIGTERM, which makes it finish
39
+ * its step and put the old folder back (measured 2026-10-17, npm 11.17.0), and the shutdown
40
+ * goes on once npm has ended (`endInstall`: it reports progress while it waits, and gives up
41
+ * after `ROLLBACK_LIMIT_SECONDS`). A SIGKILL of npm would leave a half copy for every instance.
42
+ * - The end of the process. `main` ends it with the code `run` gave as soon as `run` returns.
43
+ * Node would otherwise stay alive for as long as anything holds its event loop, and the
44
+ * Socket Mode connection does after every stop: `SocketModeReceiver.stop` of `@slack/bolt`
45
+ * 5.1.0 does not wait for the client's `disconnect`, the WebSocket stays open once the close
46
+ * frame is sent, and `@slack/socket-mode` 3.1.0 ends it only when its own ping goes
47
+ * unanswered (measured on 2026-10-10: 5.9 seconds after the stop, three times out of three).
48
+ * In that time the lock is already free, and launchd, which sees a live job, starts nothing.
49
+ * A shutdown that completes no step for `SHUTDOWN_LIMIT_SECONDS` ends the process with 1 on
50
+ * a timer that does not keep it alive (`deferred`): a shutdown stuck on a connection, or on a
51
+ * start of the agent that never answers (one a word of the daemon made, `!status` or the
52
+ * setup of a thread, or one the session's own worker gave up: `ThreadSession.close` waits for
53
+ * both), would leave the daemon deaf, still
54
+ * holding the single-instance lock, with nobody to Ctrl-C it. For a stop that a signal asked
55
+ * for, the limit starts once the drain is over (the drain's own wait is
56
+ * `DRAIN_LIMIT_SECONDS`, never shortened) and runs from the last step that completed: each
57
+ * step of the shutdown, and each session `SessionManager.closeAll` has closed, reports through
58
+ * `RunOptions.shutdownProgress`, since a shutdown with many replies to end takes longer than
59
+ * the limit and is not stuck. For a stop that a failure asked for, one deadline runs from the
60
+ * failure and no step moves it, but the wait for the click of an update to end (npm's rollback
61
+ * of an install is what it is for): while it lasts the deadline is `ROLLBACK_LIMIT_SECONDS`
62
+ * plus the limit, and the rest of the stop gets the limit from its end
63
+ * (`RunOptions.waitingForInstall`). A failure during a signal's shutdown replaces the deadline
64
+ * of progress with that one. What an end by the limit leaves is ended by the next start's
65
+ * repair: the replies still open, the status of the threads. The note that lists the prompts
66
+ * that were not sent lives in memory only and is not written.
67
+ */
68
+ import { homedir, tmpdir } from "node:os";
69
+ import { join } from "node:path";
70
+ import { AgentConfigError } from "./agent/seam.js";
71
+ import { answer, answerUpdate, parseArguments } from "./cli.js";
72
+ import { monotonicClock, systemClock } from "./clock.js";
73
+ import { CLEAN_EVERY_SECONDS, clean } from "./core/cleanup.js";
74
+ import { ConfigError, loadConfig } from "./core/config.js";
75
+ import { buildFlows } from "./core/flows/flows.js";
76
+ import { postUpgradeNotices, rewriteRestartNotice } from "./core/flows/notices.js";
77
+ import { repair } from "./core/flows/repair.js";
78
+ import { UsageCache } from "./core/footer.js";
79
+ import { Holds } from "./core/hold.js";
80
+ import { configDirOf, DEFAULT_INSTANCE, INSTANCE_VARIABLE, scratchDirOf, } from "./core/instance.js";
81
+ import { AlreadyRunning, chatIdentityLocks, singleInstance, } from "./core/lock.js";
82
+ import { Approvals } from "./core/requests.js";
83
+ import { SessionManager } from "./core/sessions/manager.js";
84
+ import { StateError, StateStore } from "./core/state.js";
85
+ import { namesOf } from "./core/texts.js";
86
+ import { checkEvery, UPDATE_CHECK_SECONDS, UPDATE_RETRY_SECONDS, Updates } from "./core/updates.js";
87
+ import { getLogger } from "./log.js";
88
+ import { REGISTRY } from "./registry.js";
89
+ import { realMachine, updatesHost } from "./updates-host.js";
90
+ import { errorName } from "./values.js";
91
+ import { installedVersion } from "./version.js";
92
+ export const logger = getLogger("allaboard.main");
93
+ // How long a stop waits for running turns before it ends them itself. It holds after
94
+ // `launchctl kill TERM`, which only sends the signal; a stop launchd makes itself (`bootout`,
95
+ // `kickstart -k`) kills the process after ExitTimeOut, which launchd caps at 60 seconds.
96
+ export const DRAIN_LIMIT_SECONDS = 1740;
97
+ // How long a shutdown may go without completing a step before `main` ends the process with 1: one
98
+ // that hangs on a connection, a child or a start of the agent leaves the daemon deaf, with nobody
99
+ // to Ctrl-C it. For a failure's stop it is the whole stop, from the failure.
100
+ export const SHUTDOWN_LIMIT_SECONDS = 30;
101
+ // How long a stop waits for npm to put the old folder back after it was sent SIGTERM (measured
102
+ // 2026-10-17, npm 11.17.0: it finishes the step it is in, a few seconds). Past it the stop goes on
103
+ // and npm ends on its own.
104
+ export const ROLLBACK_LIMIT_SECONDS = 120;
105
+ /** `action` after `seconds`, on a timer that does not keep the process alive. */
106
+ export function deferred(seconds, action) {
107
+ return setTimeout(action, seconds * 1000).unref();
108
+ }
109
+ /**
110
+ * The scheduled cleanup of `state.json`: a pass every `seconds`, none once a stop has begun (the
111
+ * sessions are closing, and the next start cleans anyway). Ends when `signal` aborts; a pass in
112
+ * flight is finished first, since a call to Slack cannot be cut short, though it asks about no
113
+ * further thread once the stop has begun (`CleanOptions.stopping`).
114
+ */
115
+ export async function cleanEvery(options) {
116
+ for (;;) {
117
+ try {
118
+ await options.clock.sleep(options.seconds, options.signal);
119
+ }
120
+ catch {
121
+ return; // the stop: the only thing that ends a sleep
122
+ }
123
+ if (options.sessions.draining)
124
+ return;
125
+ await options.clean();
126
+ }
127
+ }
128
+ /**
129
+ * The signals of a stop. The first one starts it: `SIGTERM` lets running turns finish
130
+ * (`drain`), `SIGINT` does not, since a Ctrl-C in a terminal reaches the Claude Code processes
131
+ * too, which share the daemon's process group. A signal that arrives while the drain runs ends
132
+ * the wait. One that arrives before the drain starts (during a long repair, say) is only
133
+ * counted.
134
+ */
135
+ export class StopSignals {
136
+ received = [];
137
+ /** Whether the stop was asked for by a failure, not by a signal (`fail`). */
138
+ failed = false;
139
+ /** Settles on the first signal. */
140
+ requested;
141
+ #source;
142
+ #listeners = [];
143
+ #requested = () => { };
144
+ #draining = null;
145
+ constructor(source) {
146
+ this.#source = source;
147
+ this.requested = new Promise((resolve) => {
148
+ this.#requested = resolve;
149
+ });
150
+ for (const signal of ["SIGTERM", "SIGINT"]) {
151
+ const listener = () => this.#stop(signal);
152
+ this.#listeners.push([signal, listener]);
153
+ source.on(signal, listener);
154
+ }
155
+ }
156
+ #stop(signal) {
157
+ this.received.push(signal);
158
+ this.#requested();
159
+ this.#draining?.abort();
160
+ }
161
+ /**
162
+ * Asks for the stop a `SIGTERM` asks for (counted as one, so running turns finish first), from
163
+ * inside the process: the update, once the new copy is installed. Nothing happens once a stop
164
+ * was asked, so it never cuts a drain short the way a signal does.
165
+ */
166
+ request() {
167
+ if (this.received.length === 0)
168
+ this.#stop("SIGTERM");
169
+ }
170
+ /**
171
+ * Asks for a stop the process cannot go without (an uncaught exception, a connection that is
172
+ * gone for good): counted as a SIGINT, so no drain starts and one under way is cut short.
173
+ */
174
+ fail() {
175
+ this.failed = true;
176
+ this.received.push("SIGINT");
177
+ this.#requested();
178
+ this.#draining?.abort();
179
+ }
180
+ /**
181
+ * Runs `drain` until it ends, `limitSeconds` pass, or another signal arrives, the last two
182
+ * through the signal `drain` is given. Leaves no timer behind.
183
+ */
184
+ async drain(drain, clock, limitSeconds) {
185
+ const cut = new AbortController();
186
+ this.#draining = cut;
187
+ const limit = clock.sleep(limitSeconds, cut.signal).then(() => cut.abort(), () => undefined);
188
+ try {
189
+ await drain(cut.signal);
190
+ }
191
+ finally {
192
+ cut.abort();
193
+ this.#draining = null;
194
+ await limit;
195
+ }
196
+ }
197
+ /** Stops listening: the default disposition of the signals returns. */
198
+ dispose() {
199
+ for (const [signal, listener] of this.#listeners.splice(0))
200
+ this.#source.off(signal, listener);
201
+ }
202
+ }
203
+ /**
204
+ * Runs one step of the shutdown, then reports it done; a step that fails is logged by its name,
205
+ * never skips the rest.
206
+ */
207
+ async function step(name, work, done) {
208
+ try {
209
+ await work();
210
+ }
211
+ catch (error) {
212
+ logger.error(`shutdown: could not ${name}: ${errorName(error)}`);
213
+ }
214
+ done();
215
+ }
216
+ /**
217
+ * Ends the install of a click: npm under way gets SIGTERM, and this returns once the click has
218
+ * ended, or after `ROLLBACK_LIMIT_SECONDS`. It reports progress every second it waits, since a
219
+ * rollback of a few seconds is work and not a shutdown stuck on a step (`SHUTDOWN_LIMIT_SECONDS`).
220
+ */
221
+ export async function endInstall(updates, clock, progress, waiting = () => { }) {
222
+ if (!updates.working) {
223
+ await updates.cancelInstall();
224
+ return;
225
+ }
226
+ const over = new AbortController();
227
+ void updates.cancelInstall().then(() => over.abort());
228
+ waiting(true);
229
+ try {
230
+ for (let waited = 0; waited < ROLLBACK_LIMIT_SECONDS; waited += 1) {
231
+ try {
232
+ await clock.sleep(1, over.signal);
233
+ }
234
+ catch {
235
+ return; // the click ended
236
+ }
237
+ progress();
238
+ }
239
+ logger.error("the install did not end after SIGTERM: the stop goes on");
240
+ }
241
+ finally {
242
+ waiting(false);
243
+ }
244
+ }
245
+ /**
246
+ * Start the daemon and return once it has stopped: 0 for a stop by a signal, 1 for one a failure
247
+ * asked for (`RunOptions.failure`).
248
+ */
249
+ export async function run(options = {}) {
250
+ // Read now, not at the first use: an update replaces the copy's `package.json` while this
251
+ // process runs on, and the version it reports is the one it started as.
252
+ const installed = installedVersion();
253
+ const instance = options.instance ?? DEFAULT_INSTANCE;
254
+ const configDir = options.configDir ?? configDirOf(instance, homedir());
255
+ const scratchDir = options.scratchDir ?? scratchDirOf(instance, tmpdir());
256
+ const home = options.home ?? homedir();
257
+ const environment = options.environment ?? process.env;
258
+ if (instance.name === null)
259
+ delete environment[INSTANCE_VARIABLE];
260
+ else
261
+ environment[INSTANCE_VARIABLE] = instance.name;
262
+ // Wall time for what is dated (a reply's footer, the page's hours), the monotonic clock for
263
+ // what is paced.
264
+ const wall = options.clock ?? systemClock;
265
+ const steady = options.clock ?? monotonicClock;
266
+ const registry = options.registry ?? REGISTRY;
267
+ const config = loadConfig(registry, configDir);
268
+ const lock = await singleInstance(configDir);
269
+ let identityLock = null;
270
+ let stop = null;
271
+ const onFailure = () => stop?.fail();
272
+ try {
273
+ const state = (options.openState ?? ((path, modules) => new StateStore(path, { modules })))(join(configDir, "state.json"), { agent: config.agent, chat: config.chat });
274
+ // `loadConfig` refused a name the registry does not hold, so both entries exist.
275
+ const agentEntry = registry.agents[config.agent];
276
+ const chatEntry = registry.chats[config.chat];
277
+ const backend = agentEntry.build({ variables: config.agentVariables, clock: steady });
278
+ const limits = backend.usageLimits?.bind(backend);
279
+ // The core exists once the sessions do, and the chat is built before them: it reads the core
280
+ // through `context.core()` when it first needs it.
281
+ let core = null;
282
+ const context = {
283
+ variables: config.chatVariables,
284
+ allowedRoot: config.allowedRoot,
285
+ scratchDir,
286
+ clock: wall,
287
+ steady,
288
+ core: () => {
289
+ if (core === null)
290
+ throw new Error("the core is read before the daemon built it");
291
+ return core;
292
+ },
293
+ };
294
+ const chat = await chatEntry.build(context);
295
+ // After the build, which knows what the daemon connects to in the chat, and before
296
+ // `chat.start`, which is what connects: a second daemon on the same chat app would be sent
297
+ // half of its events.
298
+ try {
299
+ identityLock = await chatIdentityLocks(chat.identities, home);
300
+ }
301
+ catch (error) {
302
+ // Nothing is connected yet: what the chat and the agent hold is let go of, as a stop does.
303
+ const none = () => { };
304
+ await step("stop the chat", () => chat.stop(), none);
305
+ await step("close the chat", () => chat.close(), none);
306
+ await step("close the agent", () => backend.close(), none);
307
+ throw error;
308
+ }
309
+ const names = namesOf(backend, chat);
310
+ const approvals = new Approvals(names);
311
+ const holds = new Holds();
312
+ // The update asks the host through the daemon's own stop: `stop` is set below, before the
313
+ // chat connects, and a click only arrives after.
314
+ const host = updatesHost({
315
+ machine: options.machine ?? realMachine(),
316
+ instance,
317
+ home,
318
+ installed,
319
+ restart: () => stop?.request(),
320
+ });
321
+ const sessions = new SessionManager({
322
+ chat,
323
+ agent: backend,
324
+ state,
325
+ approvals,
326
+ holds,
327
+ usage: limits === undefined ? null : new UsageCache(limits),
328
+ clock: steady,
329
+ // No agent process starts while any process of the machine replaces the copy.
330
+ updating: () => host.updateHeld(),
331
+ });
332
+ // Aborted once the chat has stopped: a flow still in flight then does nothing more.
333
+ const stopping = new AbortController();
334
+ const updates = new Updates(host, { now: () => wall.time() });
335
+ const { inbound } = buildFlows({
336
+ chat,
337
+ agent: backend,
338
+ sessions,
339
+ state,
340
+ approvals,
341
+ holds,
342
+ names,
343
+ allowedRoot: config.allowedRoot,
344
+ stopped: stopping.signal,
345
+ now: () => wall.time(),
346
+ updates,
347
+ });
348
+ core = { sessions, state, approvals, holds, agent: backend, updates };
349
+ const cleanOnce = () => clean((channel) => chat.channelState(channel), state, {
350
+ threadLookup: (ref) => chat.threadState(ref),
351
+ stopping: () => sessions.draining || (stop?.received.length ?? 0) > 0,
352
+ alive: async (directory) => backend.capabilities.sessionListing ? backend.aliveSessions(directory) : null,
353
+ live: () => sessions.liveThreads(),
354
+ chat: chat.name,
355
+ openedAt: (ref) => chat.openedAt(ref),
356
+ });
357
+ const cleaning = new AbortController();
358
+ let cleaned = null;
359
+ let checked = null;
360
+ // Held until the shutdown is done (see the header).
361
+ const keepAlive = setInterval(() => { }, 2 ** 31 - 1);
362
+ try {
363
+ // Installed before the repair, not after, so a SIGTERM that arrives during a long repair
364
+ // (many threads, each Slack call under the shared rate limiter) is caught and not left to
365
+ // the default disposition, which ends the process at once and skips the shutdown below
366
+ // (the single-instance lock, the Socket Mode connection, every session). The repair still
367
+ // runs to completion either way: the signal is only lost if nothing listens yet.
368
+ const signals = new StopSignals(options.signals ?? process);
369
+ stop = signals;
370
+ // A failure that came earlier is applied now, like a signal that came during the repair.
371
+ if (options.failure?.aborted)
372
+ signals.fail();
373
+ else
374
+ options.failure?.addEventListener("abort", onFailure, { once: true });
375
+ // Repaired, then cleaned, before the connection opens: the chat (already built, and so
376
+ // known to its workspace) works without it, and opening it is what starts delivering
377
+ // events. A pruned thread's leftovers must be repaired first.
378
+ await repair(chat, state);
379
+ await cleanOnce();
380
+ cleaned = cleanEvery({
381
+ seconds: CLEAN_EVERY_SECONDS,
382
+ clean: cleanOnce,
383
+ sessions,
384
+ clock: steady,
385
+ signal: cleaning.signal,
386
+ });
387
+ // The session index (the owner's Home tab): written once what the last run left is
388
+ // repaired and pruned, then again whenever state.json's sessions change.
389
+ state.onSessionsChange = (changed) => chat.index?.refresh(changed);
390
+ // The page also shows the update line: a check or a phase of an update redraws it.
391
+ updates.onChange = () => chat.index?.refresh();
392
+ chat.index?.refresh();
393
+ // The registry is asked now and every 6 hours, never while the daemon stops.
394
+ checked = checkEvery({
395
+ seconds: UPDATE_CHECK_SECONDS,
396
+ retrySeconds: UPDATE_RETRY_SECONDS,
397
+ check: () => updates.check(),
398
+ sessions,
399
+ clock: steady,
400
+ signal: cleaning.signal,
401
+ });
402
+ // (sync) Taken before the owner can be heard: a restart asked from now on records a notice
403
+ // of its own, which this start does not rewrite.
404
+ const restartNotice = state.restartNotice;
405
+ await chat.start(inbound);
406
+ await postUpgradeNotices(chat, state, names);
407
+ // The message a restart asked from the chat left, now that the daemon is back.
408
+ await rewriteRestartNotice(chat, state, restartNotice, updates.installed, wall.time());
409
+ await signals.requested;
410
+ // launchd stops and restarts with SIGTERM: the turns already running finish first. Not on
411
+ // SIGINT: see `StopSignals`. A second signal stops without waiting.
412
+ if (signals.received[0] === "SIGTERM") {
413
+ logger.info("stopping: letting running turns finish");
414
+ // An install under way is work the drain waits for (issue #361): it ends with the turns.
415
+ await signals.drain(async (cutShort) => {
416
+ await Promise.all([sessions.drain(cutShort), updates.settled(cutShort)]);
417
+ }, steady, DRAIN_LIMIT_SECONDS);
418
+ }
419
+ }
420
+ finally {
421
+ logger.info("shutting down");
422
+ const progress = options.shutdownProgress ?? (() => { });
423
+ progress();
424
+ cleaning.abort();
425
+ // No registry question is worth waiting for now.
426
+ updates.endChecks();
427
+ // Past the drain (or with none: a SIGINT, a failure) an install is not waited for: npm is
428
+ // sent SIGTERM and the stop goes on once it has put the old folder back.
429
+ await step("end the install", () => endInstall(updates, steady, progress, options.waitingForInstall), progress);
430
+ if (cleaned !== null)
431
+ await step("stop the cleanup", () => cleaned, progress);
432
+ // The connection, and the clips' waits, whose timers would keep the process alive.
433
+ await step("stop the chat", async () => {
434
+ try {
435
+ await chat.stop();
436
+ }
437
+ finally {
438
+ stopping.abort();
439
+ }
440
+ }, progress);
441
+ await step("close the sessions", () => sessions.closeAll(progress), progress);
442
+ // After the sessions: the page shows them as this stop left them.
443
+ await step("close the chat", () => chat.close(), progress);
444
+ await step("close the agent", () => backend.close(), progress);
445
+ // Last: a check in flight was ended with the stop's start and ends at once.
446
+ if (checked !== null)
447
+ await step("end the update check", () => checked, progress);
448
+ clearInterval(keepAlive);
449
+ }
450
+ }
451
+ finally {
452
+ options.failure?.removeEventListener("abort", onFailure);
453
+ try {
454
+ await identityLock?.release();
455
+ }
456
+ finally {
457
+ try {
458
+ await lock.release();
459
+ }
460
+ finally {
461
+ stop?.dispose();
462
+ }
463
+ }
464
+ }
465
+ return stop?.failed ? 1 : 0;
466
+ }
467
+ /**
468
+ * What the process does with a rejection or an exception nobody handled: the name goes to the
469
+ * log, and after an exception `stop` is asked for, which stops the daemon (see the header).
470
+ */
471
+ export function installProcessHandlers(events, stop) {
472
+ events.on("unhandledRejection", (reason) => {
473
+ logger.error(`unhandled rejection: ${errorName(reason)}`);
474
+ });
475
+ events.on("uncaughtException", (error) => {
476
+ logger.error(`uncaught exception: ${errorName(error)}`);
477
+ stop();
478
+ });
479
+ }
480
+ /** The entry point: runs the daemon, then ends the process: 0 after a stop, 1 after a failure. */
481
+ export async function main(options = {}) {
482
+ const exit = options.exit ?? ((code) => process.exit(code));
483
+ const later = options.later ??
484
+ ((seconds, action) => {
485
+ const timer = deferred(seconds, action);
486
+ return () => clearTimeout(timer);
487
+ });
488
+ const failure = new AbortController();
489
+ // One deadline at a time. A failure's is fixed: armed at the failure, replacing any other, and
490
+ // moved only by the wait for the click of an update to end (npm's rollback of an install is what
491
+ // it is for): that wait is given its `ROLLBACK_LIMIT_SECONDS` on top, and the rest of the stop
492
+ // `SHUTDOWN_LIMIT_SECONDS` from its end. A signal's moves at every report of progress, so that it ends only a shutdown that
493
+ // completes no step for `SHUTDOWN_LIMIT_SECONDS`.
494
+ let cancel = null;
495
+ let fixed = false;
496
+ let rollingBack = false;
497
+ const arm = () => {
498
+ cancel?.();
499
+ const seconds = fixed && rollingBack
500
+ ? ROLLBACK_LIMIT_SECONDS + SHUTDOWN_LIMIT_SECONDS
501
+ : SHUTDOWN_LIMIT_SECONDS;
502
+ cancel = later(seconds, () => {
503
+ logger.error("the shutdown did not finish: ending the process");
504
+ exit(1);
505
+ });
506
+ };
507
+ installProcessHandlers(options.process ?? process, () => {
508
+ if (failure.signal.aborted)
509
+ return;
510
+ failure.abort();
511
+ fixed = true;
512
+ arm();
513
+ });
514
+ const progress = () => {
515
+ if (!fixed)
516
+ arm();
517
+ };
518
+ const waitingForInstall = (waiting) => {
519
+ rollingBack = waiting;
520
+ if (fixed)
521
+ arm();
522
+ };
523
+ try {
524
+ const code = await run({
525
+ ...options,
526
+ failure: failure.signal,
527
+ shutdownProgress: progress,
528
+ waitingForInstall,
529
+ });
530
+ // At once: the Socket Mode connection outlives the stop (see the header).
531
+ exit(code);
532
+ }
533
+ catch (error) {
534
+ if (error instanceof ConfigError ||
535
+ error instanceof AgentConfigError ||
536
+ error instanceof StateError ||
537
+ error instanceof AlreadyRunning) {
538
+ logger.error(error.message);
539
+ }
540
+ else {
541
+ logger.error(`stopped by an unexpected error: ${errorName(error)}`);
542
+ }
543
+ exit(1);
544
+ }
545
+ }
546
+ /**
547
+ * What the command does with its arguments: runs the daemon when they ask for it, else prints the
548
+ * answer of `cli.ts` (`update` asks npm, so it is awaited) and leaves its status in
549
+ * `process.exitCode`.
550
+ */
551
+ export async function start() {
552
+ const invocation = parseArguments(process.argv.slice(2), process.env);
553
+ if (invocation.kind === "run") {
554
+ void main({ instance: invocation.instance });
555
+ return;
556
+ }
557
+ const output = {
558
+ out: (text) => process.stdout.write(text),
559
+ err: (text) => process.stderr.write(text),
560
+ };
561
+ // An exit code, not `process.exit`: a write to a pipe can still be pending on macOS.
562
+ process.exitCode =
563
+ invocation.kind === "update"
564
+ ? await answerUpdate(invocation, output)
565
+ : answer(invocation, output);
566
+ }