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,9 @@
|
|
|
1
|
+
/** Paths as Python's `Path` treats them. */
|
|
2
|
+
import { isAbsolute, relative, sep } from "node:path";
|
|
3
|
+
/** `Path.is_relative_to` and `relative_to`: `path` from `root` when it lies under it, else null. */
|
|
4
|
+
export function relativeTo(root, path) {
|
|
5
|
+
const found = relative(root, path);
|
|
6
|
+
if (found === ".." || found.startsWith(`..${sep}`) || isAbsolute(found))
|
|
7
|
+
return null;
|
|
8
|
+
return found;
|
|
9
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What `!open` may share: a path under the session's folder that is a regular file, read once and
|
|
3
|
+
* handed to the chat as bytes. The path is untrusted (an option's value comes back from the chat),
|
|
4
|
+
* so it is resolved with its links, held to the folder, and opened by a descriptor that refuses a
|
|
5
|
+
* link.
|
|
6
|
+
*/
|
|
7
|
+
import { constants } from "node:fs";
|
|
8
|
+
import { open, readlink, realpath, stat } from "node:fs/promises";
|
|
9
|
+
import { basename, dirname, isAbsolute, join, posix, sep } from "node:path";
|
|
10
|
+
import { compareCodePoints } from "../../text.js";
|
|
11
|
+
import { relativeTo } from "./paths.js";
|
|
12
|
+
// Windows has neither flag; the descriptor check below still holds there.
|
|
13
|
+
const OPEN_FLAGS = constants.O_RDONLY | (constants.O_NONBLOCK ?? 0) | (constants.O_NOFOLLOW ?? 0);
|
|
14
|
+
/** The path is not a regular file inside the folder, once its links are resolved. */
|
|
15
|
+
export class NotAFile extends Error {
|
|
16
|
+
constructor(options) {
|
|
17
|
+
super("not a regular file inside the folder", options);
|
|
18
|
+
this.name = "NotAFile";
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/** The file is over the cap the caller gave. */
|
|
22
|
+
export class TooLarge extends Error {
|
|
23
|
+
constructor() {
|
|
24
|
+
super("over the snippet limit");
|
|
25
|
+
this.name = "TooLarge";
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
// The kernel's own bound on the links one path may cross (Linux `MAXSYMLINKS`).
|
|
29
|
+
const LINK_LIMIT = 40;
|
|
30
|
+
/**
|
|
31
|
+
* Python's non-strict `os.path.realpath`: the links of what exists are resolved, the rest kept; a
|
|
32
|
+
* link whose target is missing leads to that target. A loop of links is left where the bound ends.
|
|
33
|
+
*/
|
|
34
|
+
export async function realpathLoose(path, links = 0) {
|
|
35
|
+
// Made absolute as text and never normalized: `resolve` would fold `link/..` before the link
|
|
36
|
+
// is followed, and name another folder than the one the kernel reaches.
|
|
37
|
+
const absolute = isAbsolute(path) ? path : `${process.cwd()}/${path}`;
|
|
38
|
+
try {
|
|
39
|
+
return await realpath(absolute);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
const parent = dirname(absolute);
|
|
43
|
+
if (parent === absolute)
|
|
44
|
+
return absolute;
|
|
45
|
+
const leaf = join(await realpathLoose(parent, links), basename(absolute));
|
|
46
|
+
// `realpath` failed on a link: it dangles, or it is part of a loop.
|
|
47
|
+
const led = await readlink(leaf).catch(() => null);
|
|
48
|
+
if (led === null || links >= LINK_LIMIT)
|
|
49
|
+
return leaf;
|
|
50
|
+
return realpathLoose(isAbsolute(led) ? led : `${dirname(leaf)}${sep}${led}`, links + 1);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* The regular file `relative` names under `real` (a resolved folder), with its stat. Throws
|
|
55
|
+
* `NotAFile` for anything else: a path that leaves the folder by `..`, an absolute path or a link,
|
|
56
|
+
* a directory, a missing file. `relative` is untrusted.
|
|
57
|
+
*
|
|
58
|
+
* `realpath` of `fs/promises` is libuv's `realpath(3)`, which follows a `..` after the link
|
|
59
|
+
* before it, as Python's `os.path.realpath` does; the path is joined as text, never normalized
|
|
60
|
+
* first, since `path.join` would drop `link/..` lexically.
|
|
61
|
+
*/
|
|
62
|
+
async function locate(real, relative) {
|
|
63
|
+
if (!relative || relative.includes("\0"))
|
|
64
|
+
throw new NotAFile();
|
|
65
|
+
let target;
|
|
66
|
+
let found;
|
|
67
|
+
try {
|
|
68
|
+
// `real / relative`: an absolute `relative` replaces `real`.
|
|
69
|
+
target = await realpath(isAbsolute(relative) ? relative : `${real}${sep}${relative}`);
|
|
70
|
+
if (relativeTo(real, target) === null)
|
|
71
|
+
throw new NotAFile();
|
|
72
|
+
found = await stat(target, { bigint: true });
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
throw error instanceof NotAFile ? error : new NotAFile({ cause: error });
|
|
76
|
+
}
|
|
77
|
+
if (!found.isFile())
|
|
78
|
+
throw new NotAFile();
|
|
79
|
+
return { target, found };
|
|
80
|
+
}
|
|
81
|
+
/** The path as the shared file is titled: `./docs/../docs/a.md` as `docs/a.md`. */
|
|
82
|
+
export function titleOf(relative) {
|
|
83
|
+
// Python's `posixpath.normpath`: no trailing slash, and exactly two leading slashes are kept.
|
|
84
|
+
let normal = posix.normalize(relative);
|
|
85
|
+
if (normal.length > 1 && normal.endsWith("/"))
|
|
86
|
+
normal = normal.slice(0, -1);
|
|
87
|
+
if (relative.startsWith("//") && !relative.startsWith("///") && !normal.startsWith("//")) {
|
|
88
|
+
normal = `/${normal}`;
|
|
89
|
+
}
|
|
90
|
+
return normal;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* The content of the already resolved file `target`, opened once; everything after is read from
|
|
94
|
+
* that descriptor, so what was checked is what is read. Throws `NotAFile`, or `TooLarge` when the
|
|
95
|
+
* file is over `limit` bytes.
|
|
96
|
+
*
|
|
97
|
+
* It is opened with `fs.promises.open` and numeric flags from `fs.constants` (`O_RDONLY |
|
|
98
|
+
* O_NONBLOCK | O_NOFOLLOW`: a file that became a link in between is refused, a FIFO is never
|
|
99
|
+
* waited on). The handle's `stat` says it is a regular file under the limit, its `read` takes the
|
|
100
|
+
* bytes at the current position, up to `limit` + 1, and its `close` ends it.
|
|
101
|
+
*/
|
|
102
|
+
export async function readOnce(target, limit, opener = (path) => open(path, OPEN_FLAGS)) {
|
|
103
|
+
let handle;
|
|
104
|
+
try {
|
|
105
|
+
handle = await opener(target);
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
throw new NotAFile({ cause: error });
|
|
109
|
+
}
|
|
110
|
+
let total = 0;
|
|
111
|
+
const content = Buffer.allocUnsafe(limit + 1);
|
|
112
|
+
try {
|
|
113
|
+
const found = await handle.stat();
|
|
114
|
+
if (!found.isFile())
|
|
115
|
+
throw new NotAFile();
|
|
116
|
+
if (found.size > limit)
|
|
117
|
+
throw new TooLarge();
|
|
118
|
+
while (total <= limit) {
|
|
119
|
+
const { bytesRead } = await handle.read(content, total, limit + 1 - total, null);
|
|
120
|
+
if (bytesRead === 0)
|
|
121
|
+
break;
|
|
122
|
+
total += bytesRead;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
if (error instanceof NotAFile || error instanceof TooLarge)
|
|
127
|
+
throw error;
|
|
128
|
+
throw new NotAFile({ cause: error });
|
|
129
|
+
}
|
|
130
|
+
finally {
|
|
131
|
+
await handle.close().catch(() => { });
|
|
132
|
+
}
|
|
133
|
+
if (total > limit)
|
|
134
|
+
throw new TooLarge();
|
|
135
|
+
return Buffer.from(content.subarray(0, total));
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* The content of the file `relative` names under `folder`, at most `limit` bytes. Throws
|
|
139
|
+
* `NotAFile` or `TooLarge`. The chat gets these bytes: a library handed a path would open it
|
|
140
|
+
* again, following links and with no size limit.
|
|
141
|
+
*/
|
|
142
|
+
export async function readOpenable(folder, relative, limit) {
|
|
143
|
+
const { target } = await locate(await realpathLoose(folder), relative);
|
|
144
|
+
return readOnce(target, limit);
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* The paths of `relatives` that are regular files inside `folder`, in order, at most `limit`. The
|
|
148
|
+
* iterable is read only as far as the checks need.
|
|
149
|
+
*/
|
|
150
|
+
export async function regularFiles(folder, relatives, limit) {
|
|
151
|
+
const real = await realpathLoose(folder);
|
|
152
|
+
const found = [];
|
|
153
|
+
if (limit !== undefined && limit <= 0)
|
|
154
|
+
return found;
|
|
155
|
+
for (const relative of relatives) {
|
|
156
|
+
try {
|
|
157
|
+
await locate(real, relative);
|
|
158
|
+
}
|
|
159
|
+
catch (error) {
|
|
160
|
+
if (error instanceof NotAFile)
|
|
161
|
+
continue;
|
|
162
|
+
throw error;
|
|
163
|
+
}
|
|
164
|
+
found.push(relative);
|
|
165
|
+
// Checked here, not before the next path is taken: the iterable is not read past the limit.
|
|
166
|
+
if (limit !== undefined && found.length >= limit)
|
|
167
|
+
break;
|
|
168
|
+
}
|
|
169
|
+
return found;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* The paths of `relatives` that are regular files inside `folder`, each once, the most recently
|
|
173
|
+
* modified first (ties by path).
|
|
174
|
+
*/
|
|
175
|
+
export async function newestFirst(folder, relatives) {
|
|
176
|
+
const real = await realpathLoose(folder);
|
|
177
|
+
const dated = [];
|
|
178
|
+
for (const relative of new Set(relatives)) {
|
|
179
|
+
try {
|
|
180
|
+
const { found } = await locate(real, relative);
|
|
181
|
+
dated.push([found.mtimeNs, relative]);
|
|
182
|
+
}
|
|
183
|
+
catch (error) {
|
|
184
|
+
if (error instanceof NotAFile)
|
|
185
|
+
continue;
|
|
186
|
+
throw error;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
dated.sort(([leftTime, left], [rightTime, right]) => leftTime === rightTime ? compareCodePoints(left, right) : leftTime > rightTime ? -1 : 1);
|
|
190
|
+
return dated.map(([, relative]) => relative);
|
|
191
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** What `within` answers when the time ran out first. */
|
|
2
|
+
export const TIMED_OUT = Symbol("timed out");
|
|
3
|
+
/**
|
|
4
|
+
* `work` with `seconds` of the clock to finish: `TIMED_OUT` when they pass first, after the signal
|
|
5
|
+
* given to `work` is aborted. A `seconds` of nothing is out of time at once, with `work` not
|
|
6
|
+
* started. Work that ignores its signal keeps running unseen: a call to the chat or a lookup cannot be
|
|
7
|
+
* cut short.
|
|
8
|
+
*/
|
|
9
|
+
export async function within(clock, seconds, work) {
|
|
10
|
+
if (seconds <= 0)
|
|
11
|
+
return TIMED_OUT;
|
|
12
|
+
const limit = new AbortController();
|
|
13
|
+
const finished = new AbortController();
|
|
14
|
+
const expired = new Promise((resolve) => {
|
|
15
|
+
clock.sleep(seconds, finished.signal).then(() => {
|
|
16
|
+
limit.abort(new Error("time limit"));
|
|
17
|
+
resolve(TIMED_OUT);
|
|
18
|
+
}, () => { });
|
|
19
|
+
});
|
|
20
|
+
const running = work(limit.signal);
|
|
21
|
+
// Raced out, it may still reject later: nobody is left to read that.
|
|
22
|
+
running.catch(() => { });
|
|
23
|
+
try {
|
|
24
|
+
return await Promise.race([running, expired]);
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
if (limit.signal.aborted)
|
|
28
|
+
return TIMED_OUT;
|
|
29
|
+
throw error;
|
|
30
|
+
}
|
|
31
|
+
finally {
|
|
32
|
+
finished.abort();
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { APPROVE, DENY } from "../requests.js";
|
|
2
|
+
import * as texts from "../texts.js";
|
|
3
|
+
import { chosen } from "./setup.js";
|
|
4
|
+
import { logger } from "./talk.js";
|
|
5
|
+
export function answersFlow(deps, talk) {
|
|
6
|
+
const { approvals, holds } = deps;
|
|
7
|
+
// Issue #142: the paths that answer `texts.HOLD_GONE` are told apart by these facts alone.
|
|
8
|
+
// Ids and flags only: the answer's content and the setup's choice never reach the log.
|
|
9
|
+
function logRefused(path, ref, message, facts) {
|
|
10
|
+
const told = Object.entries(facts)
|
|
11
|
+
.map(([name, value]) => `${name}=${value ? "True" : "False"}`)
|
|
12
|
+
.join(", ");
|
|
13
|
+
logger.info(`refused a click (${path}) in ${ref.channel}/${ref.thread} on message ${message ?? "none"}; ${told}`);
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Allow, deny or skip: the request must be waiting in this thread. Whether it was a question
|
|
17
|
+
* only changes the words a refusal carries to the agent (`toAnswer`), except for a permission's
|
|
18
|
+
* own buttons (`fromPermission`), which a question never shows: those are refused for one.
|
|
19
|
+
* The message that goes is the one the core stored for the request; the click's only when the
|
|
20
|
+
* core has none yet (answered before the post returned).
|
|
21
|
+
*/
|
|
22
|
+
async function decide(ref, requestId, outcome, message, fromPermission) {
|
|
23
|
+
const pending = approvals.get(requestId);
|
|
24
|
+
if (pending === null ||
|
|
25
|
+
pending.channelId !== ref.channel ||
|
|
26
|
+
pending.threadId !== ref.thread ||
|
|
27
|
+
(fromPermission && pending.questions !== null && pending.questions.length > 0)) {
|
|
28
|
+
await talk.tell(ref, texts.APPROVAL_GONE);
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
const resolved = approvals.resolve(requestId, ref.channel, ref.thread, outcome);
|
|
32
|
+
if (resolved === null) {
|
|
33
|
+
await talk.tell(ref, texts.APPROVAL_GONE);
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
await talk.removeRequest(ref, resolved.messageTs ?? message);
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
async function answer(ref, requestId, given, message) {
|
|
40
|
+
switch (given.type) {
|
|
41
|
+
case "permission":
|
|
42
|
+
return decide(ref, requestId, given.allow ? APPROVE : DENY, message, true);
|
|
43
|
+
case "question": {
|
|
44
|
+
if (given.answers === null)
|
|
45
|
+
return decide(ref, requestId, DENY, message, false);
|
|
46
|
+
const pending = approvals.get(requestId);
|
|
47
|
+
if (pending === null ||
|
|
48
|
+
pending.channelId !== ref.channel ||
|
|
49
|
+
pending.threadId !== ref.thread ||
|
|
50
|
+
pending.questions === null ||
|
|
51
|
+
pending.questions.length === 0) {
|
|
52
|
+
await talk.tell(ref, texts.APPROVAL_GONE);
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
const resolved = approvals.resolve(requestId, ref.channel, ref.thread, {
|
|
56
|
+
kind: "answer",
|
|
57
|
+
answers: given.answers,
|
|
58
|
+
notes: given.notes,
|
|
59
|
+
});
|
|
60
|
+
if (resolved === null) {
|
|
61
|
+
await talk.tell(ref, texts.APPROVAL_GONE);
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
// The session that asked decides what becomes of the request message: its reply keeps
|
|
65
|
+
// the answers, or the request itself does (`ThreadSession` keeps them).
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
case "hold": {
|
|
69
|
+
// (sync) What the log says of a refused answer is read with the resolve it explains.
|
|
70
|
+
const held = holds.get(requestId);
|
|
71
|
+
// A setup carries the model list it offered; a hold carries none (`Pending.context`).
|
|
72
|
+
if (held?.context != null) {
|
|
73
|
+
logRefused("hold decision, wrong kind", ref, message, {
|
|
74
|
+
send: given.send,
|
|
75
|
+
held: true,
|
|
76
|
+
decided: held.decided,
|
|
77
|
+
kind_matches: false,
|
|
78
|
+
});
|
|
79
|
+
await talk.tell(ref, texts.HOLD_GONE);
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
const resolved = holds.resolve(requestId, ref.channel, ref.thread, given.send ? true : null);
|
|
83
|
+
if (resolved === null) {
|
|
84
|
+
logRefused("hold decision", ref, message, {
|
|
85
|
+
send: given.send,
|
|
86
|
+
held: held !== null,
|
|
87
|
+
decided: held?.decided ?? false,
|
|
88
|
+
});
|
|
89
|
+
await talk.tell(ref, texts.HOLD_GONE);
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
await talk.removeRequest(ref, resolved.messageTs ?? message);
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
case "setup": {
|
|
96
|
+
const pending = holds.get(requestId);
|
|
97
|
+
if (pending === null) {
|
|
98
|
+
logRefused("setup start, not held", ref, message, { held: false });
|
|
99
|
+
await talk.tell(ref, texts.HOLD_GONE);
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
if (pending.context === null) {
|
|
103
|
+
logRefused("setup start, wrong kind", ref, message, {
|
|
104
|
+
held: true,
|
|
105
|
+
decided: pending.decided,
|
|
106
|
+
kind_matches: false,
|
|
107
|
+
});
|
|
108
|
+
await talk.tell(ref, texts.HOLD_GONE);
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
// The controls' own state rides on the answer: nothing about the choice is stored
|
|
112
|
+
// here, and the model list is the one the request was built from.
|
|
113
|
+
const choice = chosen(deps, pending.context, given.selection);
|
|
114
|
+
if (holds.resolve(requestId, ref.channel, ref.thread, choice) === null) {
|
|
115
|
+
const sameThread = pending.channelId === ref.channel && pending.threadId === ref.thread;
|
|
116
|
+
logRefused("setup start, not resolved", ref, message, {
|
|
117
|
+
held: true,
|
|
118
|
+
decided: pending.decided,
|
|
119
|
+
same_thread: sameThread,
|
|
120
|
+
});
|
|
121
|
+
// A second Start while the first is applied (issue #142): what ends the apply (the
|
|
122
|
+
// summary, `texts.NOT_SENT` or the error) says what happened.
|
|
123
|
+
if (!(pending.decided && sameThread))
|
|
124
|
+
await talk.tell(ref, texts.HOLD_GONE);
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
logger.info(`accepted a setup start in ${ref.channel}/${ref.thread} on message ${message ?? "none"}`);
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return { answer };
|
|
133
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { errorName } from "../../values.js";
|
|
2
|
+
import * as texts from "../texts.js";
|
|
3
|
+
import { named } from "../texts.js";
|
|
4
|
+
import { isCancelled, logger } from "./talk.js";
|
|
5
|
+
/**
|
|
6
|
+
* Put a question that holds the owner's message and wait for the answer: whatever the owner
|
|
7
|
+
* answered, or null when it was cancelled (`!stop`, a drain) or never shown (`Not sent.` or the
|
|
8
|
+
* unposted notice, told already). `settle(answer, shownAt, pending)` applies an answer and
|
|
9
|
+
* writes its summary while the thread still shows it waiting and the hold is still open, so a
|
|
10
|
+
* `!stop` or drain meanwhile cancels (`Pending.cancelled`: nothing is sent, the message is
|
|
11
|
+
* removed, `Not sent.`). When it throws, the message is removed, the hold ends as a cancel would
|
|
12
|
+
* and the error goes on.
|
|
13
|
+
*/
|
|
14
|
+
export async function askOwner(deps, talk, ref, session, question) {
|
|
15
|
+
const { sessions, holds, state } = deps;
|
|
16
|
+
// (sync) The check and the hold it guards: a drain cancels every hold open when it starts
|
|
17
|
+
// and never one opened after, so nothing is awaited between the two.
|
|
18
|
+
if (talk.stopped)
|
|
19
|
+
return null;
|
|
20
|
+
if (sessions.draining) {
|
|
21
|
+
// a restart could have started during an await before this
|
|
22
|
+
await talk.refuseRestarting(ref);
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
const [holdId, pending] = holds.open(ref.channel, ref.thread, question.context ?? null);
|
|
26
|
+
let shownAt;
|
|
27
|
+
try {
|
|
28
|
+
shownAt = await talk.thread(ref).ask(holdId, question.request(holdId), question.title);
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
if (isCancelled(error))
|
|
32
|
+
throw error;
|
|
33
|
+
// Nobody can answer a question that was never shown: fail closed, as an unpostable
|
|
34
|
+
// approval does, rather than send into a folder another session is using.
|
|
35
|
+
logger.error(`could not post a hold in ${ref.channel}/${ref.thread}: ${errorName(error)}`);
|
|
36
|
+
holds.discard(holdId);
|
|
37
|
+
await talk.tell(ref, named(texts.HOLD_UNPOSTED, deps.names));
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
// False when the answer came before `ask` returned (a fast answer, `!stop`, a drain): nobody
|
|
41
|
+
// knew the message then, so it is still ours to settle or remove, and `holdStart`/`holdEnd`
|
|
42
|
+
// (and the waiting status they hold) never run for it.
|
|
43
|
+
const waiting = holds.posted(holdId, shownAt);
|
|
44
|
+
if (waiting) {
|
|
45
|
+
// Crash repair (issue #19): a hold question is a request like an approval. Best-effort:
|
|
46
|
+
// outside the try/finally below on purpose, so a failed write here can never skip
|
|
47
|
+
// `holdStart`/`holdEnd` and leave the hold itself undiscarded; the message is already
|
|
48
|
+
// live either way, so a failure is logged loudly.
|
|
49
|
+
try {
|
|
50
|
+
state.addRequest(ref.channel, ref.thread, shownAt, question.kind);
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
logger.error(`posted a hold in ${ref.channel}/${ref.thread} that state.json could not record: ${errorName(error)}`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
let answer = null;
|
|
57
|
+
let sent = false;
|
|
58
|
+
try {
|
|
59
|
+
if (waiting)
|
|
60
|
+
session.holdStart();
|
|
61
|
+
answer = await pending.answer;
|
|
62
|
+
if (answer === null) {
|
|
63
|
+
if (!waiting)
|
|
64
|
+
await talk.removeRequest(ref, shownAt);
|
|
65
|
+
}
|
|
66
|
+
else if (question.settle !== undefined) {
|
|
67
|
+
try {
|
|
68
|
+
await question.settle(answer, shownAt, pending);
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
if (isCancelled(error))
|
|
72
|
+
throw error;
|
|
73
|
+
if (!pending.cancelled) {
|
|
74
|
+
await talk.removeRequest(ref, shownAt);
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
logger.warning(`a cancelled hold in ${ref.channel}/${ref.thread} failed while applied: ${errorName(error)}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (pending.cancelled) {
|
|
81
|
+
answer = null;
|
|
82
|
+
if (!waiting)
|
|
83
|
+
await talk.removeRequest(ref, shownAt);
|
|
84
|
+
}
|
|
85
|
+
sent = answer !== null;
|
|
86
|
+
}
|
|
87
|
+
finally {
|
|
88
|
+
if (waiting)
|
|
89
|
+
await session.holdEnd({ continued: sent });
|
|
90
|
+
holds.discard(holdId); // catches a cancelled wait, and ends an answered hold
|
|
91
|
+
}
|
|
92
|
+
if (answer === null)
|
|
93
|
+
await talk.tell(ref, texts.NOT_SENT);
|
|
94
|
+
return answer;
|
|
95
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The questions the daemon and the agent put to the owner, and what follows each answer: a
|
|
3
|
+
* tool's permission, the agent's questions, the same-folder hold, a session's setup.
|
|
4
|
+
*/
|
|
5
|
+
import { answersFlow } from "./answers.js";
|
|
6
|
+
import { holdFlow } from "./hold.js";
|
|
7
|
+
import { setupFlow } from "./setup.js";
|
|
8
|
+
export function asking(deps, talk) {
|
|
9
|
+
return {
|
|
10
|
+
setup: setupFlow(deps, talk),
|
|
11
|
+
hold: holdFlow(deps, talk),
|
|
12
|
+
answers: answersFlow(deps, talk),
|
|
13
|
+
};
|
|
14
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { compareCodePoints } from "../../text.js";
|
|
2
|
+
import { resolveDirectory } from "../sessions/directory.js";
|
|
3
|
+
import * as texts from "../texts.js";
|
|
4
|
+
import { fill, named } from "../texts.js";
|
|
5
|
+
/**
|
|
6
|
+
* The answer to a bind: what keeps a session from starting in the folder, if anything
|
|
7
|
+
* (`BIND_UNAVAILABLE` keeps priority: what would stop every session there matters more than
|
|
8
|
+
* where an existing thread's session lives), else the old folders existing threads keep working
|
|
9
|
+
* in, if any. `inNotice` makes a folder safe to stand inside the notice.
|
|
10
|
+
*/
|
|
11
|
+
export function boundText(directory, unavailable, oldFolders, inNotice) {
|
|
12
|
+
if (unavailable !== null) {
|
|
13
|
+
return fill(texts.BIND_UNAVAILABLE, {
|
|
14
|
+
directory: inNotice(directory),
|
|
15
|
+
reason: unavailable.message,
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
if (oldFolders.length === 0) {
|
|
19
|
+
return fill(texts.BIND_OK, { directory: inNotice(directory) });
|
|
20
|
+
}
|
|
21
|
+
const old = oldFolders.map((folder) => `\`${inNotice(folder)}\``).join(", ");
|
|
22
|
+
return fill(texts.BIND_OK_ELSEWHERE, { directory: inNotice(directory), old });
|
|
23
|
+
}
|
|
24
|
+
export function binding(deps, talk) {
|
|
25
|
+
const { agent, chat, sessions, state, names, allowedRoot } = deps;
|
|
26
|
+
const inNotice = (text) => chat.escape(text, "notice");
|
|
27
|
+
/** The folder a typed path or a pick names, inside the allowed root; null, told, otherwise. */
|
|
28
|
+
const folderNamed = async (channel, path) => {
|
|
29
|
+
const directory = resolveDirectory(path, allowedRoot);
|
|
30
|
+
if (directory === null) {
|
|
31
|
+
await talk.inChannel(channel, fill(texts.BIND_OUTSIDE, { path: inNotice(path), root: inNotice(allowedRoot) }));
|
|
32
|
+
}
|
|
33
|
+
return directory;
|
|
34
|
+
};
|
|
35
|
+
// Thread entries keep their own folder either side of the bind (`StateStore.bind`), so the
|
|
36
|
+
// old folders can be read now: those of the channel's threads that differ from the new one.
|
|
37
|
+
const announce = async (channel, directory) => {
|
|
38
|
+
const unavailable = await sessions.unavailable(directory);
|
|
39
|
+
const record = state.channel(channel);
|
|
40
|
+
const threads = record === null ? [] : [...record.threads.values()];
|
|
41
|
+
const oldFolders = [
|
|
42
|
+
...new Set(threads.map((thread) => thread.directory).filter((old) => old !== directory)),
|
|
43
|
+
].sort(compareCodePoints);
|
|
44
|
+
await talk.inChannel(channel, boundText(directory, unavailable, oldFolders, inNotice));
|
|
45
|
+
};
|
|
46
|
+
return {
|
|
47
|
+
async to(channel, path) {
|
|
48
|
+
const directory = await folderNamed(channel, path);
|
|
49
|
+
if (directory === null)
|
|
50
|
+
return;
|
|
51
|
+
if (!(await sessions.bind(channel, directory))) {
|
|
52
|
+
await talk.inChannel(channel, texts.BIND_BUSY);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
await announce(channel, directory);
|
|
56
|
+
},
|
|
57
|
+
async list(channel) {
|
|
58
|
+
const record = state.channel(channel);
|
|
59
|
+
const folders = await sessions.foldersIn(allowedRoot);
|
|
60
|
+
// An agent with no record of trust is offered every folder: no title names its trust.
|
|
61
|
+
const trusted = folders.length > 0 ? named(texts.BIND_LIST, names) : named(texts.BIND_EMPTY, names);
|
|
62
|
+
const title = fill(agent.capabilities.folderTrust ? trusted : texts.BIND_LIST_ANY, {
|
|
63
|
+
root: allowedRoot,
|
|
64
|
+
});
|
|
65
|
+
await chat.channel(channel).offer({
|
|
66
|
+
type: "folders",
|
|
67
|
+
root: allowedRoot,
|
|
68
|
+
folders,
|
|
69
|
+
bound: record === null ? null : record.directory,
|
|
70
|
+
}, title);
|
|
71
|
+
},
|
|
72
|
+
async picked(pick) {
|
|
73
|
+
const { channel } = pick;
|
|
74
|
+
// The pick is not trusted: its folder goes through the same check as a typed `!bind`.
|
|
75
|
+
const directory = await folderNamed(channel, pick.path);
|
|
76
|
+
if (directory === null)
|
|
77
|
+
return;
|
|
78
|
+
const record = state.channel(channel);
|
|
79
|
+
if (record !== null && record.directory === directory) {
|
|
80
|
+
await talk.inChannel(channel, fill(texts.BIND_ALREADY, { directory: inNotice(directory) }));
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
// A list can be old: unlike a typed `!bind`, a pick never ends work in flight either.
|
|
84
|
+
if (!(await sessions.bind(channel, directory))) {
|
|
85
|
+
await talk.inChannel(channel, texts.BIND_BUSY);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
await announce(channel, directory);
|
|
89
|
+
// The list is a message of the channel, not a thread's request: withdrawn through the
|
|
90
|
+
// channel, as `!resume` does.
|
|
91
|
+
if (!talk.stopped)
|
|
92
|
+
await chat.channel(channel).withdraw(pick.list);
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { asking } from "./asking.js";
|
|
2
|
+
import { Inbound } from "./inbound.js";
|
|
3
|
+
import { lists } from "./lists.js";
|
|
4
|
+
import { messages } from "./messages.js";
|
|
5
|
+
import { Talk } from "./talk.js";
|
|
6
|
+
import { updating } from "./update.js";
|
|
7
|
+
export function buildFlows(deps) {
|
|
8
|
+
const talk = new Talk(deps);
|
|
9
|
+
const { setup, hold, answers } = asking(deps, talk);
|
|
10
|
+
const { resume, bind, picks } = lists(deps, talk);
|
|
11
|
+
const allaboard = deps.updates === undefined ? undefined : updating(deps, deps.updates, talk);
|
|
12
|
+
const flows = { setup, hold, resume, bind, ...(allaboard ? { allaboard } : {}) };
|
|
13
|
+
const all = messages(deps, talk, flows);
|
|
14
|
+
// A failure after the provider's checks reaches the owner in the channel of the offer; a click
|
|
15
|
+
// on the chat's own page has none, and that page shows the state.
|
|
16
|
+
const updatePicked = (pick) => {
|
|
17
|
+
const { channel } = pick;
|
|
18
|
+
if (allaboard === undefined)
|
|
19
|
+
return Promise.resolve();
|
|
20
|
+
return talk.replyOnFailure({ channel: channel ?? "page", thread: pick.list ?? "page" }, () => allaboard.picked(pick), (told) => (channel === null ? Promise.resolve() : talk.inChannel(channel, told)));
|
|
21
|
+
};
|
|
22
|
+
return {
|
|
23
|
+
inbound: new Inbound({
|
|
24
|
+
// The notice decides before the message is handled and is not waited for: the message
|
|
25
|
+
// takes its place in its thread's line first (`messages.ts`), the line follows it.
|
|
26
|
+
message: async (message) => {
|
|
27
|
+
const told = allaboard?.notice(message);
|
|
28
|
+
await Promise.all([told, all.message(message)]);
|
|
29
|
+
},
|
|
30
|
+
answer: (ref, requestId, answer, message) => answers.answer(ref, requestId, answer, message),
|
|
31
|
+
picked: (pick) => (pick.type === "update" ? updatePicked(pick) : picks.picked(pick)),
|
|
32
|
+
unbound: (channel) => talk.unbound(channel),
|
|
33
|
+
}),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import * as texts from "../texts.js";
|
|
2
|
+
import { fill } from "../texts.js";
|
|
3
|
+
import { askOwner } from "./ask-owner.js";
|
|
4
|
+
export function holdFlow(deps, talk) {
|
|
5
|
+
/**
|
|
6
|
+
* Post `Another session is working in this folder: <link>. Send anyway?` and wait for the
|
|
7
|
+
* owner's answer, cancelled the same way by `!stop` (in this thread or the whole channel) or
|
|
8
|
+
* a drain. True to send the message on; false when it was not, either way telling the owner
|
|
9
|
+
* `Not sent.` already.
|
|
10
|
+
*/
|
|
11
|
+
async function beforeSending(ref, session, other) {
|
|
12
|
+
const otherRef = { channel: other.channelId, thread: other.threadId };
|
|
13
|
+
const link = await talk.noticeLink(otherRef, "Session");
|
|
14
|
+
const answer = await askOwner(deps, talk, ref, session, {
|
|
15
|
+
kind: "hold",
|
|
16
|
+
title: fill(texts.HOLD_QUESTION, { link }),
|
|
17
|
+
request: (requestId) => ({ type: "hold", requestId, other: otherRef, link }),
|
|
18
|
+
});
|
|
19
|
+
return answer === true;
|
|
20
|
+
}
|
|
21
|
+
return { beforeSending };
|
|
22
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export class Inbound {
|
|
2
|
+
#flows;
|
|
3
|
+
constructor(flows = {}) {
|
|
4
|
+
this.#flows = flows;
|
|
5
|
+
}
|
|
6
|
+
message(message) {
|
|
7
|
+
return this.#flow("message")(message);
|
|
8
|
+
}
|
|
9
|
+
answer(ref, requestId, answer, message) {
|
|
10
|
+
return this.#flow("answer")(ref, requestId, answer, message);
|
|
11
|
+
}
|
|
12
|
+
picked(pick) {
|
|
13
|
+
return this.#flow("picked")(pick);
|
|
14
|
+
}
|
|
15
|
+
unbound(channel) {
|
|
16
|
+
return this.#flow("unbound")(channel);
|
|
17
|
+
}
|
|
18
|
+
#flow(name) {
|
|
19
|
+
const flow = this.#flows[name];
|
|
20
|
+
if (flow === undefined)
|
|
21
|
+
throw new Error(`the core has no flow for ${name}`);
|
|
22
|
+
return flow;
|
|
23
|
+
}
|
|
24
|
+
}
|