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.
- package/LICENSE +21 -0
- package/README.md +43 -2
- package/dist/agent/claude/backend.js +124 -0
- package/dist/agent/claude/capabilities.js +30 -0
- package/dist/agent/claude/chrome.js +42 -0
- package/dist/agent/claude/hooks.js +37 -0
- package/dist/agent/claude/info.js +61 -0
- package/dist/agent/claude/listing.js +230 -0
- package/dist/agent/claude/module.js +82 -0
- package/dist/agent/claude/prompt.js +24 -0
- package/dist/agent/claude/record.js +9 -0
- package/dist/agent/claude/requests.js +122 -0
- package/dist/agent/claude/session.js +441 -0
- package/dist/agent/claude/translate.js +581 -0
- package/dist/agent/claude/trust.js +576 -0
- package/dist/agent/claude/usage-probe.js +103 -0
- package/dist/agent/claude/usage.js +110 -0
- package/dist/agent/claude/wire.js +22 -0
- package/dist/agent/seam.js +21 -0
- package/dist/chat/seam.js +10 -0
- package/dist/chat/slack/app/answers.js +209 -0
- package/dist/chat/slack/app/app.js +262 -0
- package/dist/chat/slack/app/clip.js +191 -0
- package/dist/chat/slack/app/guards.js +162 -0
- package/dist/chat/slack/app/home-controls.js +91 -0
- package/dist/chat/slack/app/messages.js +181 -0
- package/dist/chat/slack/app/open.js +392 -0
- package/dist/chat/slack/app/order.js +37 -0
- package/dist/chat/slack/app/requests.js +482 -0
- package/dist/chat/slack/app/resume-bind.js +71 -0
- package/dist/chat/slack/app/wire.js +83 -0
- package/dist/chat/slack/attachments.js +348 -0
- package/dist/chat/slack/bind.js +67 -0
- package/dist/chat/slack/channel.js +67 -0
- package/dist/chat/slack/channels.js +43 -0
- package/dist/chat/slack/delete.js +278 -0
- package/dist/chat/slack/footer.js +43 -0
- package/dist/chat/slack/hold.js +37 -0
- package/dist/chat/slack/home.js +1069 -0
- package/dist/chat/slack/module.js +174 -0
- package/dist/chat/slack/notice.js +64 -0
- package/dist/chat/slack/openfile/modal.js +362 -0
- package/dist/chat/slack/openfile/read.js +29 -0
- package/dist/chat/slack/quiet-logger.js +52 -0
- package/dist/chat/slack/repair.js +123 -0
- package/dist/chat/slack/reply/blocks.js +372 -0
- package/dist/chat/slack/reply/chars.js +59 -0
- package/dist/chat/slack/reply/clients.js +171 -0
- package/dist/chat/slack/reply/errors.js +125 -0
- package/dist/chat/slack/reply/escape.js +25 -0
- package/dist/chat/slack/reply/limiter.js +86 -0
- package/dist/chat/slack/reply/markdown.js +210 -0
- package/dist/chat/slack/reply/places.js +262 -0
- package/dist/chat/slack/reply/sinks.js +2403 -0
- package/dist/chat/slack/reply/status.js +433 -0
- package/dist/chat/slack/reply/tasks.js +11 -0
- package/dist/chat/slack/requests.js +552 -0
- package/dist/chat/slack/resume.js +127 -0
- package/dist/chat/slack/setup.js +114 -0
- package/dist/chat/slack/texts.js +185 -0
- package/dist/chat/slack/thread.js +537 -0
- package/dist/chat/slack/update.js +71 -0
- package/dist/chat/slack/voice.js +84 -0
- package/dist/cli.js +326 -0
- package/dist/clock.js +21 -0
- package/dist/core/cleanup.js +145 -0
- package/dist/core/commands.js +172 -0
- package/dist/core/config.js +174 -0
- package/dist/core/files/changed.js +125 -0
- package/dist/core/files/listing.js +382 -0
- package/dist/core/files/paths.js +9 -0
- package/dist/core/files/read.js +191 -0
- package/dist/core/files/waiting.js +34 -0
- package/dist/core/flows/answers.js +133 -0
- package/dist/core/flows/ask-owner.js +95 -0
- package/dist/core/flows/asking.js +14 -0
- package/dist/core/flows/bind.js +95 -0
- package/dist/core/flows/contract.js +1 -0
- package/dist/core/flows/flows.js +35 -0
- package/dist/core/flows/hold.js +22 -0
- package/dist/core/flows/inbound.js +24 -0
- package/dist/core/flows/lists.js +21 -0
- package/dist/core/flows/messages.js +293 -0
- package/dist/core/flows/notices.js +51 -0
- package/dist/core/flows/repair.js +106 -0
- package/dist/core/flows/resume.js +163 -0
- package/dist/core/flows/setup.js +130 -0
- package/dist/core/flows/status.js +98 -0
- package/dist/core/flows/talk.js +207 -0
- package/dist/core/flows/update.js +370 -0
- package/dist/core/flows/words.js +171 -0
- package/dist/core/folders.js +93 -0
- package/dist/core/footer.js +313 -0
- package/dist/core/guide.js +50 -0
- package/dist/core/hold.js +126 -0
- package/dist/core/instance.js +46 -0
- package/dist/core/lock.js +412 -0
- package/dist/core/models.js +10 -0
- package/dist/core/paths.js +75 -0
- package/dist/core/port/file-flows.js +1 -0
- package/dist/core/port/home.js +1 -0
- package/dist/core/port/requests.js +1 -0
- package/dist/core/port/threads.js +1 -0
- package/dist/core/port/updates.js +1 -0
- package/dist/core/port.js +17 -0
- package/dist/core/reply/fold.js +175 -0
- package/dist/core/reply/previews.js +258 -0
- package/dist/core/reply/renderer.js +846 -0
- package/dist/core/reply/tail.js +24 -0
- package/dist/core/reply/words.js +32 -0
- package/dist/core/requests.js +158 -0
- package/dist/core/sessions/constants.js +61 -0
- package/dist/core/sessions/deps.js +1 -0
- package/dist/core/sessions/directory.js +86 -0
- package/dist/core/sessions/errors.js +79 -0
- package/dist/core/sessions/manager.js +378 -0
- package/dist/core/sessions/session.js +3297 -0
- package/dist/core/sessions/tasks.js +266 -0
- package/dist/core/sessions/turn.js +45 -0
- package/dist/core/setup.js +28 -0
- package/dist/core/state.js +724 -0
- package/dist/core/texts.js +451 -0
- package/dist/core/updates.js +332 -0
- package/dist/daemon.js +566 -0
- package/dist/host/npm.js +134 -0
- package/dist/host/program.js +49 -0
- package/dist/host/run.js +52 -0
- package/dist/host/service/launchd.js +385 -0
- package/dist/host/service/listing.js +42 -0
- package/dist/host/service/seam.js +1 -0
- package/dist/log.js +37 -0
- package/dist/main.js +29 -0
- package/dist/node-version.js +40 -0
- package/dist/registry.js +12 -0
- package/dist/semver.js +53 -0
- package/dist/text.js +89 -0
- package/dist/updates-host.js +113 -0
- package/dist/values.js +30 -0
- package/dist/version.js +54 -0
- package/package.json +32 -4
- 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
|
+
}
|