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.
- 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,412 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One daemon at a time: Socket Mode spreads events across every open connection.
|
|
3
|
+
*
|
|
4
|
+
* What holds, by platform (`singleInstance` picks the mechanism):
|
|
5
|
+
*
|
|
6
|
+
* - macOS, the only supported host: the lock is the open file description of the configuration
|
|
7
|
+
* directory itself, opened with `O_EXLOCK | O_NONBLOCK`, which is `flock(LOCK_EX | LOCK_NB)` on
|
|
8
|
+
* the directory. It adds no file, and the kernel drops it when the process dies, a killed one
|
|
9
|
+
* included, and whatever child processes outlive it (the descriptor is close-on-exec). Renaming
|
|
10
|
+
* or removing `lock.sock` or any other file in the directory changes nothing.
|
|
11
|
+
* - Linux: a listening Unix socket `lock.sock` in the directory, with `lock.sock.claim` taken
|
|
12
|
+
* around every start so that the recovery of a stale socket is never raced. Linux has no
|
|
13
|
+
* `O_EXLOCK`, and Node has no `flock`. A path-named lock has one limit the macOS one lacks:
|
|
14
|
+
* removing `lock.sock` under a live holder lets a second start in, since the new socket is a
|
|
15
|
+
* different file. A claim file left behind blocks a start for at most `CLAIM_STALE_MS`.
|
|
16
|
+
* - Windows: a named pipe derived from the directory's real path. The system drops it with its
|
|
17
|
+
* process and refuses a second one of the same name; no claim file is involved.
|
|
18
|
+
*
|
|
19
|
+
* The update lock (`updateLock`) can also be asked about without being taken (`updateHeld`), by
|
|
20
|
+
* a daemon about to start an agent process: on macOS with a shared lock on the directory
|
|
21
|
+
* (`O_SHLOCK | O_NONBLOCK`) closed at once, which an exclusive take meets for microseconds and
|
|
22
|
+
* answers by trying again; on Linux and Windows by a connection to the socket or the pipe, which
|
|
23
|
+
* changes nothing in the holder or in the files.
|
|
24
|
+
*/
|
|
25
|
+
import { createHash } from "node:crypto";
|
|
26
|
+
import { chmodSync, closeSync, constants, lstatSync, mkdirSync, openSync, realpathSync, rmSync, statSync, writeFileSync, } from "node:fs";
|
|
27
|
+
import net from "node:net";
|
|
28
|
+
import { join } from "node:path";
|
|
29
|
+
import { errorCode } from "../values.js";
|
|
30
|
+
// `O_EXLOCK` of macOS's `open(2)`: "atomically obtain an exclusive lock", with flock(2) semantics;
|
|
31
|
+
// `#define O_EXLOCK 0x00000020` in `sys/fcntl.h` of the MacOSX SDK (`man 2 open` and that header,
|
|
32
|
+
// read on 2026-10-10). Node's `fs.constants` does not export it and Linux has no such flag; Node
|
|
33
|
+
// hands the number to `open(2)` through libuv. Measured on macOS the same day, on Node 22 and 26:
|
|
34
|
+
// it conflicts with Python's `fcntl.flock(fd, LOCK_EX | LOCK_NB)` on the directory in both
|
|
35
|
+
// directions, failing with `EAGAIN` (macOS's `EWOULDBLOCK`, which `open(2)` documents).
|
|
36
|
+
const O_EXLOCK = 0x20;
|
|
37
|
+
// `O_SHLOCK` of the same page and header (`man 2 open`: "atomically obtain a shared lock", with
|
|
38
|
+
// flock(2) semantics, and `EWOULDBLOCK` when the file is locked and `O_NONBLOCK` was given;
|
|
39
|
+
// `#define O_SHLOCK 0x00000010` in `sys/fcntl.h`, MacOSX SDK, read 2026-10-17). A shared lock
|
|
40
|
+
// conflicts with an exclusive one only: any number of probes at once see nothing of each other.
|
|
41
|
+
const O_SHLOCK = 0x10;
|
|
42
|
+
// A Unix socket path must fit `sun_path`: 104 bytes on macOS and the BSDs, 108 on Linux, one of
|
|
43
|
+
// them the terminating NUL.
|
|
44
|
+
const SOCKET_PATH_LIMIT = process.platform === "darwin" || process.platform.endsWith("bsd") ? 104 : 108;
|
|
45
|
+
const SOCKET_NAME = "lock.sock";
|
|
46
|
+
/** Another allaboard process holds the lock. */
|
|
47
|
+
export class AlreadyRunning extends Error {
|
|
48
|
+
constructor(message) {
|
|
49
|
+
super(message);
|
|
50
|
+
this.name = "AlreadyRunning";
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Another daemon holds the lock of this daemon's chat identity (the chat app's bot): two
|
|
55
|
+
* instances would share one app, and the chat then sends each event to one of the two
|
|
56
|
+
* connections at random. Its own class so that the message can tell it from the instance lock's.
|
|
57
|
+
*/
|
|
58
|
+
export class ChatIdentityTaken extends AlreadyRunning {
|
|
59
|
+
constructor(directory) {
|
|
60
|
+
super(`another allaboard instance is connected to this chat app (lock on ${directory}): ` +
|
|
61
|
+
"give each instance its own app");
|
|
62
|
+
this.name = "ChatIdentityTaken";
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
// Listening on a name that is taken fails with EADDRINUSE; macOS answers EEXIST when two binds
|
|
66
|
+
// race for the same name (seen with six starters at once).
|
|
67
|
+
function nameTaken(error) {
|
|
68
|
+
const code = errorCode(error);
|
|
69
|
+
return code === "EADDRINUSE" || code === "EEXIST";
|
|
70
|
+
}
|
|
71
|
+
/** The address the lock listens on: a socket file in `directory`, a named pipe on Windows. */
|
|
72
|
+
function lockAddress(directory) {
|
|
73
|
+
if (process.platform === "win32") {
|
|
74
|
+
const real = realpathSync.native(directory).toLowerCase();
|
|
75
|
+
return `\\\\.\\pipe\\allaboard-${createHash("sha256").update(real).digest("hex")}`;
|
|
76
|
+
}
|
|
77
|
+
const path = join(directory, SOCKET_NAME);
|
|
78
|
+
const bytes = Buffer.byteLength(path);
|
|
79
|
+
if (bytes >= SOCKET_PATH_LIMIT) {
|
|
80
|
+
throw new Error(`the lock socket path is ${bytes} bytes, over the ${SOCKET_PATH_LIMIT - 1}-byte limit of ` +
|
|
81
|
+
`Unix sockets on this system; use a shorter configuration directory: ${path}`);
|
|
82
|
+
}
|
|
83
|
+
return path;
|
|
84
|
+
}
|
|
85
|
+
export const listenLocal = (address) => new Promise((resolve, reject) => {
|
|
86
|
+
// The lock accepts no data: whoever connects is told nothing and dropped at once.
|
|
87
|
+
const server = net.createServer((connection) => connection.destroy());
|
|
88
|
+
server.once("error", reject);
|
|
89
|
+
server.listen({ path: address }, () => {
|
|
90
|
+
server.off("error", reject);
|
|
91
|
+
resolve(server);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
/** True when a process accepts connections at `path`, false when nothing is listening there. */
|
|
95
|
+
function accepts(path) {
|
|
96
|
+
return new Promise((resolve, reject) => {
|
|
97
|
+
const probe = net.connect({ path });
|
|
98
|
+
// The holder drops the connection at once; a reset after the verdict is not an error.
|
|
99
|
+
probe.on("error", (error) => {
|
|
100
|
+
const code = errorCode(error);
|
|
101
|
+
if (code === "ECONNREFUSED" || code === "ENOENT")
|
|
102
|
+
resolve(false);
|
|
103
|
+
else
|
|
104
|
+
reject(error);
|
|
105
|
+
});
|
|
106
|
+
probe.once("connect", () => {
|
|
107
|
+
resolve(true);
|
|
108
|
+
probe.destroy();
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
/** A claim older than this by its mtime is dead: a recovery takes milliseconds. */
|
|
113
|
+
export const CLAIM_STALE_MS = 5_000;
|
|
114
|
+
/**
|
|
115
|
+
* Takes the claim around a start: an exclusive create of `claim`. A claim file older than
|
|
116
|
+
* `CLAIM_STALE_MS` by its mtime is cleared and taken once more, whatever it holds: a starter that
|
|
117
|
+
* died in the milliseconds of its start, or failed to write its pid, leaves one behind. Two
|
|
118
|
+
* starters that both find the same stale claim may both clear it, the second one removing the
|
|
119
|
+
* first's fresh claim; `replaceStale` checks the socket file again for that case.
|
|
120
|
+
*/
|
|
121
|
+
function takeClaim(claim, now) {
|
|
122
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
123
|
+
try {
|
|
124
|
+
writeFileSync(claim, String(process.pid), { flag: "wx", mode: 0o600 });
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
if (errorCode(error) !== "EEXIST")
|
|
129
|
+
throw error;
|
|
130
|
+
}
|
|
131
|
+
let age;
|
|
132
|
+
try {
|
|
133
|
+
age = now() - statSync(claim).mtimeMs;
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
if (errorCode(error) === "ENOENT")
|
|
137
|
+
continue; // released meanwhile: take it
|
|
138
|
+
throw error;
|
|
139
|
+
}
|
|
140
|
+
if (age <= CLAIM_STALE_MS)
|
|
141
|
+
return false;
|
|
142
|
+
rmSync(claim, { force: true });
|
|
143
|
+
}
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
/** The inode at `path`, or undefined when there is no file. */
|
|
147
|
+
function inodeAt(path) {
|
|
148
|
+
try {
|
|
149
|
+
return lstatSync(path).ino;
|
|
150
|
+
}
|
|
151
|
+
catch (error) {
|
|
152
|
+
if (errorCode(error) === "ENOENT")
|
|
153
|
+
return undefined;
|
|
154
|
+
throw error;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Replaces a socket file whose process is dead by a listener of this one, under the claim. The
|
|
159
|
+
* file is removed only if it is still the inode that was probed and still refuses connections
|
|
160
|
+
* (the removal follows the second look with no await between them): the claim keeps another
|
|
161
|
+
* starter out, and this keeps a holder that started without it, or took a stolen claim, alive.
|
|
162
|
+
* Rejects with `refusal` when the lock is, or is being, taken.
|
|
163
|
+
*/
|
|
164
|
+
async function replaceStale(address, refusal, listenOn) {
|
|
165
|
+
const probed = inodeAt(address);
|
|
166
|
+
// A socket file outlives a process killed before it could remove it: a connection that is
|
|
167
|
+
// accepted means the holder is alive, one that is refused means the file is stale.
|
|
168
|
+
if (await accepts(address))
|
|
169
|
+
throw refusal;
|
|
170
|
+
if (inodeAt(address) !== probed)
|
|
171
|
+
throw refusal; // the file was replaced under us
|
|
172
|
+
rmSync(address, { force: true });
|
|
173
|
+
try {
|
|
174
|
+
return await listenOn(address);
|
|
175
|
+
}
|
|
176
|
+
catch (error) {
|
|
177
|
+
// A starter that found no file listened first: it holds the lock.
|
|
178
|
+
throw nameTaken(error) ? refusal : error;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* The socket mechanism (Linux, Windows): takes the lock by listening on a local socket in
|
|
183
|
+
* `directory`, so the kernel drops it when the process dies. Rejects with `AlreadyRunning` while
|
|
184
|
+
* another process holds it. Exported so its tests run on macOS too, where `singleInstance`
|
|
185
|
+
* does not use it.
|
|
186
|
+
*
|
|
187
|
+
* The claim is taken before the first `listen`, not only for a recovery: a starter that has bound
|
|
188
|
+
* its socket and not yet called `listen` refuses connections, and a second starter that probed
|
|
189
|
+
* it then would read it as stale and remove it. A start that finds a fresh claim is refused.
|
|
190
|
+
*/
|
|
191
|
+
export async function socketLock(directory, deps = {}) {
|
|
192
|
+
const { now = Date.now, listen: listenOn = listenLocal } = deps;
|
|
193
|
+
const address = lockAddress(directory);
|
|
194
|
+
const refusal = new AlreadyRunning(`another allaboard is running (lock on ${directory})`);
|
|
195
|
+
// A named pipe is created whole or refused by the system: no claim is needed there.
|
|
196
|
+
const claim = process.platform === "win32" ? null : `${address}.claim`;
|
|
197
|
+
if (claim !== null && !takeClaim(claim, now))
|
|
198
|
+
throw refusal;
|
|
199
|
+
try {
|
|
200
|
+
let server;
|
|
201
|
+
try {
|
|
202
|
+
server = await listenOn(address);
|
|
203
|
+
}
|
|
204
|
+
catch (error) {
|
|
205
|
+
if (!nameTaken(error))
|
|
206
|
+
throw error;
|
|
207
|
+
// A named pipe vanishes with its process: one in use is always a live holder.
|
|
208
|
+
if (claim === null)
|
|
209
|
+
throw refusal;
|
|
210
|
+
server = await replaceStale(address, refusal, listenOn);
|
|
211
|
+
}
|
|
212
|
+
if (claim !== null) {
|
|
213
|
+
// The kernel lets only a user with write access connect to a socket, and a new one takes
|
|
214
|
+
// the umask's mode: close it to everyone else right after listening, without touching the
|
|
215
|
+
// process-wide umask.
|
|
216
|
+
try {
|
|
217
|
+
chmodSync(address, 0o600);
|
|
218
|
+
}
|
|
219
|
+
catch (error) {
|
|
220
|
+
server.close(); // the file is not ours any more: do not run as a holder nobody can reach
|
|
221
|
+
throw error;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return {
|
|
225
|
+
// libuv unlinks the socket file when the listener closes.
|
|
226
|
+
release: () => new Promise((resolve, reject) => server.close((error) => (error === undefined ? resolve() : reject(error)))),
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
finally {
|
|
230
|
+
if (claim !== null)
|
|
231
|
+
rmSync(claim, { force: true });
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* The macOS mechanism: the lock is the open file description of `directory`, opened with
|
|
236
|
+
* `O_EXLOCK | O_NONBLOCK`, which is `flock(LOCK_EX | LOCK_NB)` on the directory.
|
|
237
|
+
*/
|
|
238
|
+
async function directoryLock(directory) {
|
|
239
|
+
let fd;
|
|
240
|
+
try {
|
|
241
|
+
fd = openSync(directory, constants.O_RDONLY | O_EXLOCK | constants.O_NONBLOCK);
|
|
242
|
+
}
|
|
243
|
+
catch (error) {
|
|
244
|
+
const code = errorCode(error);
|
|
245
|
+
if (code === "EAGAIN" || code === "EWOULDBLOCK") {
|
|
246
|
+
throw new AlreadyRunning(`another allaboard is running (lock on ${directory})`);
|
|
247
|
+
}
|
|
248
|
+
throw error;
|
|
249
|
+
}
|
|
250
|
+
let open = true;
|
|
251
|
+
return {
|
|
252
|
+
release: async () => {
|
|
253
|
+
// A second close would close whatever descriptor the number was handed to since.
|
|
254
|
+
if (!open)
|
|
255
|
+
return;
|
|
256
|
+
open = false;
|
|
257
|
+
closeSync(fd);
|
|
258
|
+
},
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Takes the lock on `directory` by the mechanism of this platform (see the header). Rejects with
|
|
263
|
+
* `AlreadyRunning` while another process holds it.
|
|
264
|
+
*/
|
|
265
|
+
export function singleInstance(directory) {
|
|
266
|
+
return process.platform === "darwin" ? directoryLock(directory) : socketLock(directory);
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* The folder whose lock stands for `identity` (one of `ChatProvider.identities`): per user and outside
|
|
270
|
+
* every instance's folder. The underscore keeps it apart from them, since an instance name
|
|
271
|
+
* cannot hold one (`core/instance.ts`).
|
|
272
|
+
*/
|
|
273
|
+
export function identityDirectory(home, identity) {
|
|
274
|
+
const digest = createHash("sha256").update(identity).digest("hex").slice(0, 16);
|
|
275
|
+
return join(home, ".config", "allaboard_identities", digest);
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Takes the lock of a chat identity by the mechanism of this platform (see the header), in
|
|
279
|
+
* `identityDirectory(home, identity)`, which it creates private to the owner. Rejects with
|
|
280
|
+
* `ChatIdentityTaken` while another process holds it. Held until `release`, which the daemon
|
|
281
|
+
* calls after its chat has stopped.
|
|
282
|
+
*/
|
|
283
|
+
export async function chatIdentityLock(identity, home) {
|
|
284
|
+
const directory = identityDirectory(home, identity);
|
|
285
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
286
|
+
try {
|
|
287
|
+
return await singleInstance(directory);
|
|
288
|
+
}
|
|
289
|
+
catch (error) {
|
|
290
|
+
throw error instanceof AlreadyRunning ? new ChatIdentityTaken(directory) : error;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Takes the lock of every key of `ChatProvider.identities`, in order, each by `chatIdentityLock`.
|
|
295
|
+
* A refusal of one releases the ones already taken and rejects with `ChatIdentityTaken`; the
|
|
296
|
+
* returned lock releases them all, last taken first.
|
|
297
|
+
*/
|
|
298
|
+
export async function chatIdentityLocks(identities, home) {
|
|
299
|
+
const taken = [];
|
|
300
|
+
const releaseAll = async () => {
|
|
301
|
+
let failure = null;
|
|
302
|
+
for (const lock of taken.splice(0).reverse()) {
|
|
303
|
+
try {
|
|
304
|
+
await lock.release();
|
|
305
|
+
}
|
|
306
|
+
catch (error) {
|
|
307
|
+
failure ??= error;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
if (failure !== null)
|
|
311
|
+
throw failure;
|
|
312
|
+
};
|
|
313
|
+
try {
|
|
314
|
+
for (const identity of identities)
|
|
315
|
+
taken.push(await chatIdentityLock(identity, home));
|
|
316
|
+
}
|
|
317
|
+
catch (error) {
|
|
318
|
+
await releaseAll().catch(() => { });
|
|
319
|
+
throw error;
|
|
320
|
+
}
|
|
321
|
+
return { release: releaseAll };
|
|
322
|
+
}
|
|
323
|
+
/** The macOS probe: whether an exclusive lock is held on `directory`, by a shared lock taken and let go. */
|
|
324
|
+
export async function directoryHeld(directory) {
|
|
325
|
+
let fd;
|
|
326
|
+
try {
|
|
327
|
+
fd = openSync(directory, constants.O_RDONLY | O_SHLOCK | constants.O_NONBLOCK);
|
|
328
|
+
}
|
|
329
|
+
catch (error) {
|
|
330
|
+
const code = errorCode(error);
|
|
331
|
+
if (code === "EAGAIN" || code === "EWOULDBLOCK")
|
|
332
|
+
return true;
|
|
333
|
+
if (code === "ENOENT")
|
|
334
|
+
return false; // nobody has ever updated: there is no folder yet
|
|
335
|
+
throw error;
|
|
336
|
+
}
|
|
337
|
+
closeSync(fd);
|
|
338
|
+
return false;
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* The probe of the socket mechanism (Linux, Windows): whether a process accepts connections at
|
|
342
|
+
* the lock's address. Read-only: it takes no claim and removes no file, so a socket left by a dead
|
|
343
|
+
* process is free, and a holder drops the connection at once. Exported so its tests run on macOS.
|
|
344
|
+
*/
|
|
345
|
+
export async function socketHeld(directory) {
|
|
346
|
+
let address;
|
|
347
|
+
try {
|
|
348
|
+
address = lockAddress(directory);
|
|
349
|
+
}
|
|
350
|
+
catch (error) {
|
|
351
|
+
if (errorCode(error) === "ENOENT")
|
|
352
|
+
return false; // Windows resolves the folder to name its pipe
|
|
353
|
+
throw error;
|
|
354
|
+
}
|
|
355
|
+
return accepts(address);
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* Whether a process, this one included, holds the machine's update lock now (see the header):
|
|
359
|
+
* false for a machine where no update ever ran. Rejects for what it cannot tell, an error of
|
|
360
|
+
* the system; the caller decides what that means.
|
|
361
|
+
*/
|
|
362
|
+
export function updateHeld(home) {
|
|
363
|
+
const directory = updateDirectory(home);
|
|
364
|
+
return process.platform === "darwin" ? directoryHeld(directory) : socketHeld(directory);
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* Another process is updating allaboard on this machine: two installs into one npm prefix would
|
|
368
|
+
* race, so the instances of a machine update one at a time.
|
|
369
|
+
*/
|
|
370
|
+
export class UpdateUnderWay extends AlreadyRunning {
|
|
371
|
+
constructor(directory) {
|
|
372
|
+
super(`an update of allaboard is already under way (lock on ${directory})`);
|
|
373
|
+
this.name = "UpdateUnderWay";
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* The folder whose lock stands for an update: one per user and outside every instance's folder,
|
|
378
|
+
* beside the identities'. The underscore keeps it apart from an instance's, as for them.
|
|
379
|
+
*/
|
|
380
|
+
export function updateDirectory(home) {
|
|
381
|
+
return join(home, ".config", "allaboard_update");
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* How long an update's take tries again before it reports an update under way: a probe
|
|
385
|
+
* (`updateHeld`) holds a shared lock for microseconds, and an exclusive take that meets one is
|
|
386
|
+
* refused like one that meets a holder. 20 tries 10 ms apart.
|
|
387
|
+
*/
|
|
388
|
+
export const UPDATE_TAKE_TRIES = 20;
|
|
389
|
+
export const UPDATE_TAKE_PAUSE_MS = 10;
|
|
390
|
+
/**
|
|
391
|
+
* Takes the lock of the machine's update by the mechanism of this platform (see the header), in
|
|
392
|
+
* `updateDirectory(home)`, which it creates private to the owner. Tries `UPDATE_TAKE_TRIES` times,
|
|
393
|
+
* `UPDATE_TAKE_PAUSE_MS` apart (about 200 ms) before it rejects with `UpdateUnderWay`, so that a
|
|
394
|
+
* daemon's probe never makes it fail. Held until `release`; the kernel drops it with a process
|
|
395
|
+
* that dies on macOS.
|
|
396
|
+
*/
|
|
397
|
+
export async function updateLock(home) {
|
|
398
|
+
const directory = updateDirectory(home);
|
|
399
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
400
|
+
for (let attempt = 1;; attempt += 1) {
|
|
401
|
+
try {
|
|
402
|
+
return await singleInstance(directory);
|
|
403
|
+
}
|
|
404
|
+
catch (error) {
|
|
405
|
+
if (!(error instanceof AlreadyRunning))
|
|
406
|
+
throw error;
|
|
407
|
+
if (attempt === UPDATE_TAKE_TRIES)
|
|
408
|
+
throw new UpdateUnderWay(directory);
|
|
409
|
+
}
|
|
410
|
+
await new Promise((resolve) => setTimeout(resolve, UPDATE_TAKE_PAUSE_MS));
|
|
411
|
+
}
|
|
412
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { DEFAULT } from "./setup.js";
|
|
2
|
+
/**
|
|
3
|
+
* How the agent's own model list names the model with this full id: its display name, or the id
|
|
4
|
+
* itself when the list does not hold it. The `default` entry is skipped, since it stands for
|
|
5
|
+
* whichever model the agent picks and not for one of its own.
|
|
6
|
+
*/
|
|
7
|
+
export function modelName(models, id) {
|
|
8
|
+
const listed = models.find((m) => m.value !== DEFAULT && m.resolvedModel === id);
|
|
9
|
+
return listed === undefined ? id : listed.displayName;
|
|
10
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/** A path the owner wrote, read as Python's `Path(...).expanduser().resolve()` reads it. */
|
|
2
|
+
import { lstatSync, readlinkSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { dirname, isAbsolute, join, parse } from "node:path";
|
|
5
|
+
import { errorCode } from "../values.js";
|
|
6
|
+
/** What Python's `Path.exists` and `Path.is_dir` read as "not there", not as a failure. */
|
|
7
|
+
export const ABSENT = new Set(["ENOENT", "ENOTDIR", "EBADF", "ELOOP"]);
|
|
8
|
+
const SEPARATORS = process.platform === "win32" ? /[\\/]+/ : /\/+/;
|
|
9
|
+
/**
|
|
10
|
+
* `~` and `~/x` to the home directory, as `Path.expanduser` does (`~user` is not expanded). Joined
|
|
11
|
+
* as text: `path.join` would fold a `link/..` that the disk has to follow first.
|
|
12
|
+
*/
|
|
13
|
+
export function expandUser(path) {
|
|
14
|
+
if (path !== "~" && !path.startsWith("~/"))
|
|
15
|
+
return path;
|
|
16
|
+
return homedir().replace(/\/+$/, "") + path.slice(1) || "/";
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* `posixpath._joinrealpath` of Python 3.12, not strict: `target` read from `start` one name at a
|
|
20
|
+
* time, each link followed where it stands, so a `..` goes up from where the link led. A name
|
|
21
|
+
* that cannot be looked at is kept as written. One difference: a loop of links throws `ELOOP`, as
|
|
22
|
+
* the kernel answers a path through it. Python keeps the rest of such a path as written, unread,
|
|
23
|
+
* and a link in that rest then leads wherever it points (`loop/../out`).
|
|
24
|
+
*/
|
|
25
|
+
function joinReal(start, target, seen) {
|
|
26
|
+
let path = start;
|
|
27
|
+
let rest = target;
|
|
28
|
+
if (isAbsolute(rest)) {
|
|
29
|
+
path = parse(rest).root;
|
|
30
|
+
rest = rest.slice(path.length);
|
|
31
|
+
}
|
|
32
|
+
for (const name of rest.split(SEPARATORS)) {
|
|
33
|
+
if (name === "" || name === ".")
|
|
34
|
+
continue;
|
|
35
|
+
if (name === "..") {
|
|
36
|
+
path = dirname(path);
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
const next = join(path, name);
|
|
40
|
+
let isLink = false;
|
|
41
|
+
try {
|
|
42
|
+
isLink = lstatSync(next).isSymbolicLink();
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
if (errorCode(error) === undefined)
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
if (!isLink) {
|
|
49
|
+
path = next;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
let led = seen.get(next);
|
|
53
|
+
if (led === null) {
|
|
54
|
+
throw Object.assign(new Error("ELOOP: too many symbolic links"), { code: "ELOOP" });
|
|
55
|
+
}
|
|
56
|
+
if (led === undefined) {
|
|
57
|
+
seen.set(next, null); // being followed: meeting it again is a loop
|
|
58
|
+
led = joinReal(path, readlinkSync(next), seen);
|
|
59
|
+
seen.set(next, led);
|
|
60
|
+
}
|
|
61
|
+
path = led;
|
|
62
|
+
}
|
|
63
|
+
return path;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Absolute, with every symlink resolved as far as the path exists, as `Path.resolve()` does: the
|
|
67
|
+
* spelling the owner wrote is kept where no link stands. Never `path.resolve`, which folds
|
|
68
|
+
* `link/..` as text before the link is followed and names another folder than the kernel
|
|
69
|
+
* reaches, nor `realpathSync.native`, which fails on a part that is not there and so leaves
|
|
70
|
+
* what follows it unread. Throws `ELOOP` on a loop of links.
|
|
71
|
+
*/
|
|
72
|
+
export function resolvePath(path) {
|
|
73
|
+
const absolute = isAbsolute(path) ? path : `${process.cwd()}/${path}`;
|
|
74
|
+
return joinReal("", absolute, new Map());
|
|
75
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export { ConfigError } from "./config.js";
|
|
2
|
+
export { changedIn } from "./files/changed.js";
|
|
3
|
+
export { Listings } from "./files/listing.js";
|
|
4
|
+
export { NotAFile, newestFirst, readOpenable, regularFiles, TooLarge, titleOf, } from "./files/read.js";
|
|
5
|
+
export { FOLDER_ROWS } from "./folders.js";
|
|
6
|
+
export { footerFields, valueWithDetail } from "./footer.js";
|
|
7
|
+
export { Fold, shownPreview } from "./reply/fold.js";
|
|
8
|
+
export { formatDuration } from "./reply/renderer.js";
|
|
9
|
+
export { isLast, isOvertaken, tails } from "./reply/tail.js";
|
|
10
|
+
export { oneLine, STOPPED, TITLE_LIMIT } from "./reply/words.js";
|
|
11
|
+
export { answerOf } from "./requests.js";
|
|
12
|
+
export { DirectoryUnavailable, SessionClosed, SessionGone } from "./sessions/errors.js";
|
|
13
|
+
export { Cancelled, cancellable, Mutex, Semaphore, Task } from "./sessions/tasks.js";
|
|
14
|
+
export { choiceOf, DEFAULT, effortLevels } from "./setup.js";
|
|
15
|
+
export * as texts from "./texts.js";
|
|
16
|
+
export { fill, Named, NESTED, named } from "./texts.js";
|
|
17
|
+
export { sharedCopyLine } from "./updates.js";
|