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
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
import { isNewer, isVersion } from "../../semver.js";
|
|
2
|
+
import { errorName } from "../../values.js";
|
|
3
|
+
import { parseBang } from "../commands.js";
|
|
4
|
+
import * as texts from "../texts.js";
|
|
5
|
+
import { fill, named } from "../texts.js";
|
|
6
|
+
import { whyUnknown } from "../updates.js";
|
|
7
|
+
import { isCancelled, logger } from "./talk.js";
|
|
8
|
+
/** Wall-clock seconds as `HH:MM` on the daemon's clock, for "checked 14:05". */
|
|
9
|
+
export function clockTime(seconds) {
|
|
10
|
+
const at = new Date(seconds * 1000);
|
|
11
|
+
const pad = (value) => String(value).padStart(2, "0");
|
|
12
|
+
return `${pad(at.getHours())}:${pad(at.getMinutes())}`;
|
|
13
|
+
}
|
|
14
|
+
export function updating(deps, updates, talk) {
|
|
15
|
+
const { chat, sessions, state, names } = deps;
|
|
16
|
+
const { host } = updates;
|
|
17
|
+
/** Why the copy updates outside the chat, as a sentence, for a markup. */
|
|
18
|
+
const outside = (refusal, markup) => {
|
|
19
|
+
const reason = refusal.kind === "not-global"
|
|
20
|
+
? fill(texts.UPDATE_REASON_NOT_GLOBAL, { path: chat.escape(refusal.folder, markup) })
|
|
21
|
+
: refusal.kind === "link"
|
|
22
|
+
? texts.UPDATE_REASON_LINK
|
|
23
|
+
: fill(texts.UPDATE_REASON_UNPUBLISHED, { version: refusal.version });
|
|
24
|
+
return fill(named(texts.UPDATE_OUTSIDE, names), { reason });
|
|
25
|
+
};
|
|
26
|
+
const notAService = () => named(texts.UPDATE_NOT_A_SERVICE, names);
|
|
27
|
+
/** The sentence of an install that left the version it was asked for, or did not end well. */
|
|
28
|
+
const notInstalled = (outcome, version) => {
|
|
29
|
+
const installed = updates.installed;
|
|
30
|
+
if (outcome.kind === "mismatch") {
|
|
31
|
+
return outcome.found === null
|
|
32
|
+
? fill(texts.UPDATE_UNREADABLE, { installed })
|
|
33
|
+
: fill(texts.UPDATE_MISMATCH, { found: outcome.found, latest: version, installed });
|
|
34
|
+
}
|
|
35
|
+
const { failure } = outcome;
|
|
36
|
+
if (failure.kind === "network")
|
|
37
|
+
return fill(texts.UPDATE_FAILED_NETWORK, { installed });
|
|
38
|
+
const log = chat.escape(host.logFile, "notice");
|
|
39
|
+
if (failure.kind === "permission") {
|
|
40
|
+
const folder = failure.path === null ? "the folder it installs into" : failure.path;
|
|
41
|
+
return fill(texts.UPDATE_FAILED_PERMISSION, {
|
|
42
|
+
folder: chat.escape(folder, "notice"),
|
|
43
|
+
installed,
|
|
44
|
+
log,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
return fill(texts.UPDATE_FAILED_OTHER, { status: failure.status ?? "unknown", installed, log });
|
|
48
|
+
};
|
|
49
|
+
/** The sentences of the instances an install's restart did not reach, one a line; null when none. */
|
|
50
|
+
const notRestarted = (failures) => {
|
|
51
|
+
if (failures.length === 0)
|
|
52
|
+
return null;
|
|
53
|
+
return failures
|
|
54
|
+
.map(({ instance, status }) => {
|
|
55
|
+
const reason = status === null
|
|
56
|
+
? texts.UPDATE_OTHER_NO_MANAGER
|
|
57
|
+
: fill(texts.UPDATE_OTHER_STATUS, { status });
|
|
58
|
+
return instance === null
|
|
59
|
+
? fill(texts.UPDATE_OTHER_FAILED_DEFAULT, { reason })
|
|
60
|
+
: fill(texts.UPDATE_OTHER_FAILED, { name: instance, reason });
|
|
61
|
+
})
|
|
62
|
+
.join("\n");
|
|
63
|
+
};
|
|
64
|
+
/** What `NotUpdatable` says to the owner when a click could not prove the copy. */
|
|
65
|
+
const unproven = (refusal) => refusal.kind === "npm"
|
|
66
|
+
? { text: fill(texts.UPDATE_UNKNOWN, { reason: whyUnknown(refusal.failure) }), failed: true }
|
|
67
|
+
: { text: outside(refusal, "notice"), failed: false };
|
|
68
|
+
/** The offer rewritten into `text`; a chat that refuses is logged, the update goes on. */
|
|
69
|
+
const rewrite = async (channel, list, text) => {
|
|
70
|
+
if (talk.stopped)
|
|
71
|
+
return;
|
|
72
|
+
try {
|
|
73
|
+
await chat.channel(channel).rewrite(list, text);
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
if (isCancelled(error))
|
|
77
|
+
throw error;
|
|
78
|
+
logger.warning(`could not rewrite the update offer in ${channel}: ${errorName(error)}`);
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
/**
|
|
82
|
+
* Records the message the next start rewrites, before the stop that ends this process is asked
|
|
83
|
+
* (a missing note is no reason to keep the daemon from restarting).
|
|
84
|
+
*/
|
|
85
|
+
const remember = (notice) => {
|
|
86
|
+
try {
|
|
87
|
+
state.setRestartNotice(notice);
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
logger.warning(`could not record the restart notice: ${errorName(error)}`);
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
/** A `!allaboard restart` is being answered or has asked the stop. */
|
|
94
|
+
let restartAsked = false;
|
|
95
|
+
/** `install` that never throws: a host that fails to run npm at all is an install that failed. */
|
|
96
|
+
const install = async (copy, version) => {
|
|
97
|
+
try {
|
|
98
|
+
return await host.install(copy, version, updates.installing);
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
logger.error(`the install could not run: ${errorName(error)}`);
|
|
102
|
+
return { kind: "failed", failure: { kind: "other", status: null } };
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
async function click(pick) {
|
|
106
|
+
const { channel, list, version } = pick;
|
|
107
|
+
// The value of a control the chat drew from a version; anything else is not ours to install.
|
|
108
|
+
if (!isVersion(version)) {
|
|
109
|
+
logger.warning("ignored an update click that carries no version");
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const offer = channel !== null && list !== null ? { channel, list } : null;
|
|
113
|
+
// For the owner alone, leaving the offer as it is: another click is already at work on it. A
|
|
114
|
+
// click on the chat's own page has no message to answer on; that page shows the state.
|
|
115
|
+
const aside = async (text) => {
|
|
116
|
+
if (offer !== null && !talk.stopped)
|
|
117
|
+
await chat.channel(offer.channel).tell(text);
|
|
118
|
+
};
|
|
119
|
+
const running = updates.phase;
|
|
120
|
+
if (running.kind === "installing" || running.kind === "restarting") {
|
|
121
|
+
await aside(fill(texts.UPDATE_UNDER_WAY, { version: running.version }));
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (sessions.draining) {
|
|
125
|
+
await aside(texts.UPDATE_DRAINING);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
// (sync) Claimed with the checks above, before the first await: a second click finds it. A
|
|
129
|
+
// stop that already cancelled the install refuses the claim.
|
|
130
|
+
if (!updates.begin(version)) {
|
|
131
|
+
await aside(texts.UPDATE_DRAINING);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
let settled = false;
|
|
135
|
+
let lock = null;
|
|
136
|
+
let unblock = null;
|
|
137
|
+
let restarting = false;
|
|
138
|
+
/** The click ended with `text`: on the offer, and on the page when it `failed`. */
|
|
139
|
+
const settle = async (text, failed) => {
|
|
140
|
+
settled = true;
|
|
141
|
+
if (failed)
|
|
142
|
+
updates.fail(version, text);
|
|
143
|
+
else
|
|
144
|
+
updates.abandon();
|
|
145
|
+
if (offer !== null)
|
|
146
|
+
await rewrite(offer.channel, offer.list, text);
|
|
147
|
+
};
|
|
148
|
+
try {
|
|
149
|
+
lock = await host.lock();
|
|
150
|
+
if (lock === null) {
|
|
151
|
+
settled = true;
|
|
152
|
+
updates.abandon();
|
|
153
|
+
await aside(texts.UPDATE_ELSEWHERE);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (sessions.draining) {
|
|
157
|
+
settled = true;
|
|
158
|
+
updates.abandon();
|
|
159
|
+
await aside(texts.UPDATE_DRAINING);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
const installed = updates.installed;
|
|
163
|
+
if (!isNewer(version, installed)) {
|
|
164
|
+
await settle(fill(texts.UPDATE_ALREADY_RUNS, { latest: installed }), false);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
const found = await updates.validate();
|
|
168
|
+
// A stop came while npm was asked (a drain, or one with none that cancelled the install):
|
|
169
|
+
// nothing is installed, and no sentence about npm is made of its answer to the stop.
|
|
170
|
+
if (found === null || updates.installing.aborted) {
|
|
171
|
+
settled = true;
|
|
172
|
+
updates.abandon();
|
|
173
|
+
await aside(texts.UPDATE_DRAINING);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
if ("swapping" in found) {
|
|
177
|
+
// Another process (a terminal's `npm install -g`) is replacing the copy this very moment.
|
|
178
|
+
settled = true;
|
|
179
|
+
updates.abandon();
|
|
180
|
+
await aside(texts.UPDATE_ELSEWHERE);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
if ("refusal" in found) {
|
|
184
|
+
const { text, failed } = unproven(found.refusal);
|
|
185
|
+
await settle(text, failed);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
if (!host.supervised()) {
|
|
189
|
+
await settle(notAService(), true);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
if (host.copyVersion(found.copy) === version) {
|
|
193
|
+
await settle(fill(texts.UPDATE_COPY_HOLDS, { latest: version, installed }), true);
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
unblock = sessions.blockStarts();
|
|
197
|
+
const newer = updates.newer;
|
|
198
|
+
const underWay = newer !== null && isNewer(newer, version)
|
|
199
|
+
? fill(texts.UPDATE_NEWER_SINCE, { latest: version, newer })
|
|
200
|
+
: fill(texts.UPDATE_INSTALLING, { latest: version });
|
|
201
|
+
if (offer !== null)
|
|
202
|
+
await rewrite(offer.channel, offer.list, underWay);
|
|
203
|
+
const outcome = await install(found.copy, version);
|
|
204
|
+
if (outcome.kind !== "installed") {
|
|
205
|
+
await settle(notInstalled(outcome, version), true);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
let failures = [];
|
|
209
|
+
try {
|
|
210
|
+
failures = await host.restartOthers(found.copy);
|
|
211
|
+
}
|
|
212
|
+
catch (error) {
|
|
213
|
+
logger.error(`could not restart the other instances: ${errorName(error)}`);
|
|
214
|
+
}
|
|
215
|
+
// The block stays up until the process ends: no agent starts between the swap and the exit.
|
|
216
|
+
restarting = true;
|
|
217
|
+
settled = true;
|
|
218
|
+
const note = notRestarted(failures);
|
|
219
|
+
updates.restarting(version, note);
|
|
220
|
+
if (offer !== null) {
|
|
221
|
+
const installedText = fill(texts.UPDATE_INSTALLED, { latest: version });
|
|
222
|
+
await rewrite(offer.channel, offer.list, note === null ? installedText : `${installedText}\n${note}`);
|
|
223
|
+
// An offer that names an instance still to restart by hand keeps that line: the next
|
|
224
|
+
// start's rewrite would replace it with `Updated to…`.
|
|
225
|
+
if (note === null)
|
|
226
|
+
remember({ channel: offer.channel, message: offer.list, version });
|
|
227
|
+
}
|
|
228
|
+
host.restart();
|
|
229
|
+
}
|
|
230
|
+
finally {
|
|
231
|
+
if (!settled)
|
|
232
|
+
updates.abandon();
|
|
233
|
+
if (!restarting)
|
|
234
|
+
unblock?.();
|
|
235
|
+
await lock?.release();
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return {
|
|
239
|
+
async show() {
|
|
240
|
+
await updates.current();
|
|
241
|
+
const installed = updates.installed;
|
|
242
|
+
const time = clockTime(updates.checkedAt ?? deps.now());
|
|
243
|
+
const failure = updates.checkFailure;
|
|
244
|
+
const newer = updates.newer;
|
|
245
|
+
const lines = [
|
|
246
|
+
failure !== null
|
|
247
|
+
? fill(texts.UPDATE_STATUS_UNKNOWN, { installed, reason: whyUnknown(failure), time })
|
|
248
|
+
: newer !== null
|
|
249
|
+
? fill(texts.UPDATE_STATUS_NEWER, { installed, latest: newer, time })
|
|
250
|
+
: fill(texts.UPDATE_STATUS_CURRENT, { installed, time }),
|
|
251
|
+
];
|
|
252
|
+
const refusal = updates.refusal;
|
|
253
|
+
if (refusal !== null)
|
|
254
|
+
lines.push(outside(refusal, "reference"));
|
|
255
|
+
else if (updates.canUpdate && !host.supervised())
|
|
256
|
+
lines.push(notAService());
|
|
257
|
+
return lines.join("\n");
|
|
258
|
+
},
|
|
259
|
+
async update(channel) {
|
|
260
|
+
const running = updates.phase;
|
|
261
|
+
if (running.kind === "installing" || running.kind === "restarting") {
|
|
262
|
+
await talk.inChannel(channel, fill(texts.UPDATE_UNDER_WAY, { version: running.version }));
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
if (sessions.draining) {
|
|
266
|
+
await talk.inChannel(channel, texts.UPDATE_DRAINING);
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
await updates.check();
|
|
270
|
+
const refusal = updates.refusal;
|
|
271
|
+
const failure = updates.checkFailure;
|
|
272
|
+
const copy = updates.copy;
|
|
273
|
+
const newer = updates.newer;
|
|
274
|
+
if (refusal !== null) {
|
|
275
|
+
await talk.inChannel(channel, outside(refusal, "notice"));
|
|
276
|
+
}
|
|
277
|
+
else if (failure !== null) {
|
|
278
|
+
await talk.inChannel(channel, fill(texts.UPDATE_UNKNOWN, { reason: whyUnknown(failure) }));
|
|
279
|
+
}
|
|
280
|
+
else if (newer !== null && copy === null) {
|
|
281
|
+
// A newer version, and no answer yet about the copy: it is being replaced right now.
|
|
282
|
+
await talk.inChannel(channel, texts.UPDATE_ELSEWHERE);
|
|
283
|
+
}
|
|
284
|
+
else if (newer === null || copy === null) {
|
|
285
|
+
await talk.inChannel(channel, fill(texts.UPDATE_LATEST, { installed: updates.installed }));
|
|
286
|
+
}
|
|
287
|
+
else if (!host.supervised()) {
|
|
288
|
+
await talk.inChannel(channel, notAService());
|
|
289
|
+
}
|
|
290
|
+
else {
|
|
291
|
+
await chat.channel(channel).offer({
|
|
292
|
+
type: "update",
|
|
293
|
+
from: updates.installed,
|
|
294
|
+
to: newer,
|
|
295
|
+
changelog: host.changelog(newer),
|
|
296
|
+
sharing: host.sharing(copy),
|
|
297
|
+
}, fill(texts.UPDATE_OFFER, { installed: updates.installed, latest: newer }));
|
|
298
|
+
}
|
|
299
|
+
},
|
|
300
|
+
async restart(channel) {
|
|
301
|
+
const running = updates.phase;
|
|
302
|
+
if (running.kind === "installing" || running.kind === "restarting") {
|
|
303
|
+
await talk.inChannel(channel, fill(texts.UPDATE_UNDER_WAY, { version: running.version }));
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
if (sessions.draining || restartAsked) {
|
|
307
|
+
await talk.inChannel(channel, texts.RESTARTING);
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
// (sync) Claimed before the first await: a second `!allaboard restart` (a double send, an
|
|
311
|
+
// event delivered twice) finds it, so the stop is asked and recorded once.
|
|
312
|
+
restartAsked = true;
|
|
313
|
+
let asked = false;
|
|
314
|
+
try {
|
|
315
|
+
if (await host.updateHeld()) {
|
|
316
|
+
// Another process is replacing the copy: a restart now would start from half of it.
|
|
317
|
+
await talk.inChannel(channel, texts.UPDATE_ELSEWHERE);
|
|
318
|
+
}
|
|
319
|
+
else if (!host.supervised()) {
|
|
320
|
+
await talk.inChannel(channel, notAService());
|
|
321
|
+
}
|
|
322
|
+
else {
|
|
323
|
+
const message = await talk.postInChannel(channel, texts.UPDATE_RESTART);
|
|
324
|
+
if (message !== null)
|
|
325
|
+
remember({ channel, message, version: null });
|
|
326
|
+
host.restart();
|
|
327
|
+
asked = true;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
finally {
|
|
331
|
+
// A restart that was refused leaves the next one free; one that asked the stop is the last.
|
|
332
|
+
if (!asked)
|
|
333
|
+
restartAsked = false;
|
|
334
|
+
}
|
|
335
|
+
},
|
|
336
|
+
// The drain waits for a click, and a stop with no drain ends its install (`Updates.track`).
|
|
337
|
+
picked: (pick) => updates.track(() => click(pick)),
|
|
338
|
+
async notice(message) {
|
|
339
|
+
// (sync) Decided and recorded before anything is awaited: the caller starts the message's
|
|
340
|
+
// own handling right after, and it must take its place in line first (`messages.ts`).
|
|
341
|
+
const version = updates.noticeDue(state.updateNotice);
|
|
342
|
+
if (version === null)
|
|
343
|
+
return;
|
|
344
|
+
try {
|
|
345
|
+
state.setUpdateNotice(version);
|
|
346
|
+
}
|
|
347
|
+
catch (error) {
|
|
348
|
+
logger.warning(`could not record the update told of: ${errorName(error)}`);
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
// Asking about allaboard is being told; the line would repeat the answer.
|
|
352
|
+
const typed = message.literal ? null : (parseBang(message.text) ?? parseBang(message.plain));
|
|
353
|
+
if (typed?.kind === "allaboard")
|
|
354
|
+
return;
|
|
355
|
+
const text = fill(texts.UPDATE_NOTICE, { latest: version, installed: updates.installed });
|
|
356
|
+
// A message of the channel opens a thread that has no reply yet, and a chat may show a
|
|
357
|
+
// line for the owner alone in a thread only once it is active: the line goes to the
|
|
358
|
+
// channel. A chat that refuses the line does not hold the message back.
|
|
359
|
+
try {
|
|
360
|
+
if (message.topLevel)
|
|
361
|
+
await chat.channel(message.ref.channel).tell(text);
|
|
362
|
+
else
|
|
363
|
+
await talk.tell(message.ref, text);
|
|
364
|
+
}
|
|
365
|
+
catch (error) {
|
|
366
|
+
logger.warning(`could not tell the owner of ${version}: ${errorName(error)}`);
|
|
367
|
+
}
|
|
368
|
+
},
|
|
369
|
+
};
|
|
370
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { helpText, WORD } from "../commands.js";
|
|
2
|
+
import { guideText } from "../guide.js";
|
|
3
|
+
import { BypassUnavailable } from "../sessions/errors.js";
|
|
4
|
+
import * as texts from "../texts.js";
|
|
5
|
+
import { fill, named } from "../texts.js";
|
|
6
|
+
import { channelStatus } from "./status.js";
|
|
7
|
+
export function wordFlow(deps, talk, others) {
|
|
8
|
+
const { chat, agent, sessions, names } = deps;
|
|
9
|
+
const status = channelStatus(deps, talk);
|
|
10
|
+
// The words this agent and this chat cannot serve: each is refused when typed, and `!help`
|
|
11
|
+
// does not list it.
|
|
12
|
+
const unserved = new Set();
|
|
13
|
+
if (agent.capabilities.permissionModes.bypass === null)
|
|
14
|
+
unserved.add(WORD.bypass);
|
|
15
|
+
if (!agent.capabilities.sessionListing)
|
|
16
|
+
unserved.add(WORD.resume);
|
|
17
|
+
if (!chat.capabilities.fileUpload)
|
|
18
|
+
unserved.add(WORD.open);
|
|
19
|
+
// A daemon built without a way to update itself offers no `!allaboard`.
|
|
20
|
+
const allaboard = others.allaboard;
|
|
21
|
+
if (allaboard === undefined)
|
|
22
|
+
unserved.add(WORD.allaboard);
|
|
23
|
+
return async (ref, message, command, { session, outside }) => {
|
|
24
|
+
const { channel, thread } = ref;
|
|
25
|
+
const where = session === null ? null : thread;
|
|
26
|
+
const quiet = session !== null;
|
|
27
|
+
const showHelp = async (query) => {
|
|
28
|
+
if (session !== null)
|
|
29
|
+
await session.ensureConnected();
|
|
30
|
+
const commands = session === null ? null : session.commands;
|
|
31
|
+
await talk.say(channel, where, helpText(commands, query, (text) => chat.escape(text, "reference"), names, agent.capabilities.commands, unserved, agent.capabilities.folderTrust), { ownerOnly: quiet });
|
|
32
|
+
};
|
|
33
|
+
switch (command.kind) {
|
|
34
|
+
case "help":
|
|
35
|
+
await showHelp(command.query);
|
|
36
|
+
return;
|
|
37
|
+
case "invalid":
|
|
38
|
+
// A mistyped word gets the full list, which shows how each word is written.
|
|
39
|
+
await showHelp("");
|
|
40
|
+
return;
|
|
41
|
+
case "guide": {
|
|
42
|
+
const have = agent.capabilities;
|
|
43
|
+
const offered = {
|
|
44
|
+
folderTrust: have.folderTrust,
|
|
45
|
+
models: have.models,
|
|
46
|
+
efforts: have.effortLevels.length > 0,
|
|
47
|
+
bypass: have.permissionModes.bypass !== null,
|
|
48
|
+
commands: have.commands,
|
|
49
|
+
questions: have.questions,
|
|
50
|
+
backgroundTasks: have.backgroundTasks,
|
|
51
|
+
sessionListing: have.sessionListing,
|
|
52
|
+
fileUpload: chat.capabilities.fileUpload,
|
|
53
|
+
updates: allaboard !== undefined,
|
|
54
|
+
};
|
|
55
|
+
await talk.say(channel, where, guideText(names, offered), { ownerOnly: quiet });
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
case "bind":
|
|
59
|
+
if (session !== null || (outside && command.path)) {
|
|
60
|
+
await talk.tell(ref, fill(texts.WORD_IN_THREAD, { word: WORD.bind }));
|
|
61
|
+
}
|
|
62
|
+
else if (command.path) {
|
|
63
|
+
await others.bind.to(channel, command.path);
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
await others.bind.list(channel);
|
|
67
|
+
}
|
|
68
|
+
return;
|
|
69
|
+
case "bypass": {
|
|
70
|
+
if (session === null) {
|
|
71
|
+
await talk.inChannel(channel, texts.BYPASS_TOP_LEVEL);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
let switched;
|
|
75
|
+
try {
|
|
76
|
+
switched = await session.switchBypass(command.on);
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
if (!(error instanceof BypassUnavailable))
|
|
80
|
+
throw error;
|
|
81
|
+
await talk.tell(ref, named(texts.BYPASS_UNAVAILABLE, names));
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (!switched) {
|
|
85
|
+
// Start writes the setup's box over the switch (`applySetup`): one flipped before
|
|
86
|
+
// it would not hold, so the word changed nothing.
|
|
87
|
+
await talk.tell(ref, texts.BYPASS_BEFORE_START);
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
// Both: the line says what changed and is gone on reload, the mark stays.
|
|
91
|
+
await talk.acknowledge(ref, message);
|
|
92
|
+
await talk.tell(ref, command.on ? texts.BYPASS_ON_THREAD : named(texts.BYPASS_OFF_THREAD, names));
|
|
93
|
+
}
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
case "status":
|
|
97
|
+
if (session === null)
|
|
98
|
+
await status(channel);
|
|
99
|
+
else
|
|
100
|
+
await talk.say(channel, thread, await session.status(), { ownerOnly: true });
|
|
101
|
+
return;
|
|
102
|
+
case "stop":
|
|
103
|
+
if (outside) {
|
|
104
|
+
await talk.tell(ref, texts.STOP_OUTSIDE_SESSION);
|
|
105
|
+
}
|
|
106
|
+
else if (session === null) {
|
|
107
|
+
const stopped = await sessions.stopChannel(channel);
|
|
108
|
+
// null: only a hold was cancelled somewhere in the channel (`Not sent.`, from its
|
|
109
|
+
// own waiter); no second notice, since nothing the agent was doing stopped.
|
|
110
|
+
if (stopped !== null) {
|
|
111
|
+
await talk.inChannel(channel, stopped ? texts.STOPPED_CHANNEL : texts.NOTHING_TO_STOP);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
// Either answer is a post that stays in the thread: a line for the owner alone is
|
|
116
|
+
// gone on reload, and the root's mark alone does not say a stop was received. null:
|
|
117
|
+
// only a hold was cancelled, which already said `Not sent.` from its own waiter.
|
|
118
|
+
const stopped = await session.stop();
|
|
119
|
+
if (stopped) {
|
|
120
|
+
// The answer goes under the ending and the footer of the reply the stop cut
|
|
121
|
+
// short, which may be a message of its own.
|
|
122
|
+
await session.stopLanded();
|
|
123
|
+
}
|
|
124
|
+
if (stopped !== null) {
|
|
125
|
+
await talk.tell(ref, stopped ? texts.STOPPED_THREAD : texts.NOTHING_TO_STOP_THREAD, {
|
|
126
|
+
stays: true,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return;
|
|
131
|
+
case "open":
|
|
132
|
+
if (session === null) {
|
|
133
|
+
await talk.inChannel(channel, texts.OPEN_TOP_LEVEL);
|
|
134
|
+
}
|
|
135
|
+
else if (!chat.capabilities.fileUpload || chat.openFile === undefined) {
|
|
136
|
+
await talk.tell(ref, named(texts.OPEN_UNAVAILABLE, names));
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
await chat.openFile(ref, session.directory, command.target);
|
|
140
|
+
}
|
|
141
|
+
return;
|
|
142
|
+
case "allaboard":
|
|
143
|
+
if (allaboard === undefined) {
|
|
144
|
+
await showHelp("");
|
|
145
|
+
}
|
|
146
|
+
else if (command.action === "show") {
|
|
147
|
+
await talk.say(channel, where, await allaboard.show(), { ownerOnly: quiet });
|
|
148
|
+
}
|
|
149
|
+
else if (session !== null || outside) {
|
|
150
|
+
// The offer lives in the channel, so a word that makes one is refused in a thread.
|
|
151
|
+
const word = `${WORD.allaboard} ${command.action}`;
|
|
152
|
+
await talk.tell(ref, fill(texts.WORD_IN_THREAD, { word }));
|
|
153
|
+
}
|
|
154
|
+
else if (command.action === "update") {
|
|
155
|
+
await allaboard.update(channel);
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
await allaboard.restart(channel);
|
|
159
|
+
}
|
|
160
|
+
return;
|
|
161
|
+
case "resume":
|
|
162
|
+
if (session !== null || outside) {
|
|
163
|
+
await talk.tell(ref, fill(texts.WORD_IN_THREAD, { word: WORD.resume }));
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
await others.resume.word(ref, command.target);
|
|
167
|
+
}
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `!bind` alone: the folders under the allowed root where a session can start.
|
|
3
|
+
*
|
|
4
|
+
* Claude Code has no folder picker: the terminal starts in the folder it is launched from. The list
|
|
5
|
+
* holds the allowed root and the folders two levels below it that Claude Code trusts, since a
|
|
6
|
+
* session starts nowhere else. It never descends into a git repository: its subfolders belong to
|
|
7
|
+
* that one project. The Bind button rows are the chat provider's.
|
|
8
|
+
*/
|
|
9
|
+
import { lstat, opendir, readdir, stat } from "node:fs/promises";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { getLogger } from "../log.js";
|
|
12
|
+
import { compareCodePoints } from "../text.js";
|
|
13
|
+
import { ABSENT } from "./paths.js";
|
|
14
|
+
export const logger = getLogger("allaboard.core.folders");
|
|
15
|
+
export const FOLDER_ROWS = 20;
|
|
16
|
+
export const FOLDER_DEPTH = 2;
|
|
17
|
+
// Each trust check reads the filesystem: a batch at a time, so a root with many untrusted folders
|
|
18
|
+
// lists in a few rounds, and the check stops soon after the rows are filled.
|
|
19
|
+
export const TRUST_BATCH = 8;
|
|
20
|
+
function code(error) {
|
|
21
|
+
return error?.code;
|
|
22
|
+
}
|
|
23
|
+
async function isFolder(directory, name) {
|
|
24
|
+
// A symlink could lead outside the root; hidden folders are tooling (.git, .venv, .claude).
|
|
25
|
+
if (name.startsWith("."))
|
|
26
|
+
return false;
|
|
27
|
+
try {
|
|
28
|
+
// `lstat` does not follow a link: a real directory is the only thing that passes.
|
|
29
|
+
return (await lstat(join(directory, name))).isDirectory();
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
async function descends(folder) {
|
|
36
|
+
// `.git` is a directory in a repository and a file in a worktree. A folder that cannot be
|
|
37
|
+
// searched fails here with more than "not there": it has nothing to list.
|
|
38
|
+
try {
|
|
39
|
+
await stat(join(folder, ".git"));
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
return ABSENT.has(code(error));
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
async function children(folder) {
|
|
47
|
+
let names;
|
|
48
|
+
try {
|
|
49
|
+
names = (await readdir(folder)).toSorted(compareCodePoints);
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
// A subfolder the daemon may not open (macOS privacy, permissions) lists nothing.
|
|
53
|
+
logger.debug(`skipped an unreadable folder: ${code(error)}`);
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
const found = [];
|
|
57
|
+
for (const name of names) {
|
|
58
|
+
if (await isFolder(folder, name))
|
|
59
|
+
found.push(join(folder, name));
|
|
60
|
+
}
|
|
61
|
+
return found;
|
|
62
|
+
}
|
|
63
|
+
/** The root, then its folders, then theirs: the higher levels come first. */
|
|
64
|
+
export async function foldersWithin(root) {
|
|
65
|
+
// An unreadable root raises: an empty list would send the owner to trust folders instead.
|
|
66
|
+
await (await opendir(root)).close();
|
|
67
|
+
const levels = [[root]];
|
|
68
|
+
for (let depth = 0; depth < FOLDER_DEPTH; depth += 1) {
|
|
69
|
+
const next = [];
|
|
70
|
+
for (const folder of levels[levels.length - 1]) {
|
|
71
|
+
if (await descends(folder))
|
|
72
|
+
next.push(...(await children(folder)));
|
|
73
|
+
}
|
|
74
|
+
levels.push(next);
|
|
75
|
+
}
|
|
76
|
+
return levels.flat();
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Trusted folders under `root`, the higher levels first, at most `FOLDER_ROWS + 1`: one past the
|
|
80
|
+
* rows shown is enough to say that more exist, and stops the trust checks there.
|
|
81
|
+
*/
|
|
82
|
+
export async function bindableFolders(root, trusted) {
|
|
83
|
+
const candidates = await foldersWithin(root);
|
|
84
|
+
const found = [];
|
|
85
|
+
for (let start = 0; start < candidates.length; start += TRUST_BATCH) {
|
|
86
|
+
const batch = candidates.slice(start, start + TRUST_BATCH);
|
|
87
|
+
const verdicts = await Promise.all(batch.map((folder) => trusted(folder)));
|
|
88
|
+
found.push(...batch.filter((_folder, index) => verdicts[index]));
|
|
89
|
+
if (found.length > FOLDER_ROWS)
|
|
90
|
+
return found.slice(0, FOLDER_ROWS + 1);
|
|
91
|
+
}
|
|
92
|
+
return found;
|
|
93
|
+
}
|