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.
Files changed (141) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +43 -2
  3. package/dist/agent/claude/backend.js +124 -0
  4. package/dist/agent/claude/capabilities.js +30 -0
  5. package/dist/agent/claude/chrome.js +42 -0
  6. package/dist/agent/claude/hooks.js +37 -0
  7. package/dist/agent/claude/info.js +61 -0
  8. package/dist/agent/claude/listing.js +230 -0
  9. package/dist/agent/claude/module.js +82 -0
  10. package/dist/agent/claude/prompt.js +24 -0
  11. package/dist/agent/claude/record.js +9 -0
  12. package/dist/agent/claude/requests.js +122 -0
  13. package/dist/agent/claude/session.js +441 -0
  14. package/dist/agent/claude/translate.js +581 -0
  15. package/dist/agent/claude/trust.js +576 -0
  16. package/dist/agent/claude/usage-probe.js +103 -0
  17. package/dist/agent/claude/usage.js +110 -0
  18. package/dist/agent/claude/wire.js +22 -0
  19. package/dist/agent/seam.js +21 -0
  20. package/dist/chat/seam.js +10 -0
  21. package/dist/chat/slack/app/answers.js +209 -0
  22. package/dist/chat/slack/app/app.js +262 -0
  23. package/dist/chat/slack/app/clip.js +191 -0
  24. package/dist/chat/slack/app/guards.js +162 -0
  25. package/dist/chat/slack/app/home-controls.js +91 -0
  26. package/dist/chat/slack/app/messages.js +181 -0
  27. package/dist/chat/slack/app/open.js +392 -0
  28. package/dist/chat/slack/app/order.js +37 -0
  29. package/dist/chat/slack/app/requests.js +482 -0
  30. package/dist/chat/slack/app/resume-bind.js +71 -0
  31. package/dist/chat/slack/app/wire.js +83 -0
  32. package/dist/chat/slack/attachments.js +348 -0
  33. package/dist/chat/slack/bind.js +67 -0
  34. package/dist/chat/slack/channel.js +67 -0
  35. package/dist/chat/slack/channels.js +43 -0
  36. package/dist/chat/slack/delete.js +278 -0
  37. package/dist/chat/slack/footer.js +43 -0
  38. package/dist/chat/slack/hold.js +37 -0
  39. package/dist/chat/slack/home.js +1069 -0
  40. package/dist/chat/slack/module.js +174 -0
  41. package/dist/chat/slack/notice.js +64 -0
  42. package/dist/chat/slack/openfile/modal.js +362 -0
  43. package/dist/chat/slack/openfile/read.js +29 -0
  44. package/dist/chat/slack/quiet-logger.js +52 -0
  45. package/dist/chat/slack/repair.js +123 -0
  46. package/dist/chat/slack/reply/blocks.js +372 -0
  47. package/dist/chat/slack/reply/chars.js +59 -0
  48. package/dist/chat/slack/reply/clients.js +171 -0
  49. package/dist/chat/slack/reply/errors.js +125 -0
  50. package/dist/chat/slack/reply/escape.js +25 -0
  51. package/dist/chat/slack/reply/limiter.js +86 -0
  52. package/dist/chat/slack/reply/markdown.js +210 -0
  53. package/dist/chat/slack/reply/places.js +262 -0
  54. package/dist/chat/slack/reply/sinks.js +2403 -0
  55. package/dist/chat/slack/reply/status.js +433 -0
  56. package/dist/chat/slack/reply/tasks.js +11 -0
  57. package/dist/chat/slack/requests.js +552 -0
  58. package/dist/chat/slack/resume.js +127 -0
  59. package/dist/chat/slack/setup.js +114 -0
  60. package/dist/chat/slack/texts.js +185 -0
  61. package/dist/chat/slack/thread.js +537 -0
  62. package/dist/chat/slack/update.js +71 -0
  63. package/dist/chat/slack/voice.js +84 -0
  64. package/dist/cli.js +326 -0
  65. package/dist/clock.js +21 -0
  66. package/dist/core/cleanup.js +145 -0
  67. package/dist/core/commands.js +172 -0
  68. package/dist/core/config.js +174 -0
  69. package/dist/core/files/changed.js +125 -0
  70. package/dist/core/files/listing.js +382 -0
  71. package/dist/core/files/paths.js +9 -0
  72. package/dist/core/files/read.js +191 -0
  73. package/dist/core/files/waiting.js +34 -0
  74. package/dist/core/flows/answers.js +133 -0
  75. package/dist/core/flows/ask-owner.js +95 -0
  76. package/dist/core/flows/asking.js +14 -0
  77. package/dist/core/flows/bind.js +95 -0
  78. package/dist/core/flows/contract.js +1 -0
  79. package/dist/core/flows/flows.js +35 -0
  80. package/dist/core/flows/hold.js +22 -0
  81. package/dist/core/flows/inbound.js +24 -0
  82. package/dist/core/flows/lists.js +21 -0
  83. package/dist/core/flows/messages.js +293 -0
  84. package/dist/core/flows/notices.js +51 -0
  85. package/dist/core/flows/repair.js +106 -0
  86. package/dist/core/flows/resume.js +163 -0
  87. package/dist/core/flows/setup.js +130 -0
  88. package/dist/core/flows/status.js +98 -0
  89. package/dist/core/flows/talk.js +207 -0
  90. package/dist/core/flows/update.js +370 -0
  91. package/dist/core/flows/words.js +171 -0
  92. package/dist/core/folders.js +93 -0
  93. package/dist/core/footer.js +313 -0
  94. package/dist/core/guide.js +50 -0
  95. package/dist/core/hold.js +126 -0
  96. package/dist/core/instance.js +46 -0
  97. package/dist/core/lock.js +412 -0
  98. package/dist/core/models.js +10 -0
  99. package/dist/core/paths.js +75 -0
  100. package/dist/core/port/file-flows.js +1 -0
  101. package/dist/core/port/home.js +1 -0
  102. package/dist/core/port/requests.js +1 -0
  103. package/dist/core/port/threads.js +1 -0
  104. package/dist/core/port/updates.js +1 -0
  105. package/dist/core/port.js +17 -0
  106. package/dist/core/reply/fold.js +175 -0
  107. package/dist/core/reply/previews.js +258 -0
  108. package/dist/core/reply/renderer.js +846 -0
  109. package/dist/core/reply/tail.js +24 -0
  110. package/dist/core/reply/words.js +32 -0
  111. package/dist/core/requests.js +158 -0
  112. package/dist/core/sessions/constants.js +61 -0
  113. package/dist/core/sessions/deps.js +1 -0
  114. package/dist/core/sessions/directory.js +86 -0
  115. package/dist/core/sessions/errors.js +79 -0
  116. package/dist/core/sessions/manager.js +378 -0
  117. package/dist/core/sessions/session.js +3297 -0
  118. package/dist/core/sessions/tasks.js +266 -0
  119. package/dist/core/sessions/turn.js +45 -0
  120. package/dist/core/setup.js +28 -0
  121. package/dist/core/state.js +724 -0
  122. package/dist/core/texts.js +451 -0
  123. package/dist/core/updates.js +332 -0
  124. package/dist/daemon.js +566 -0
  125. package/dist/host/npm.js +134 -0
  126. package/dist/host/program.js +49 -0
  127. package/dist/host/run.js +52 -0
  128. package/dist/host/service/launchd.js +385 -0
  129. package/dist/host/service/listing.js +42 -0
  130. package/dist/host/service/seam.js +1 -0
  131. package/dist/log.js +37 -0
  132. package/dist/main.js +29 -0
  133. package/dist/node-version.js +40 -0
  134. package/dist/registry.js +12 -0
  135. package/dist/semver.js +53 -0
  136. package/dist/text.js +89 -0
  137. package/dist/updates-host.js +113 -0
  138. package/dist/values.js +30 -0
  139. package/dist/version.js +54 -0
  140. package/package.json +32 -4
  141. package/slack-app-manifest.json +50 -0
@@ -0,0 +1,174 @@
1
+ /** Load the daemon's configuration from the `.env` of its instance's configuration folder. */
2
+ import { lstatSync, readFileSync, statSync } from "node:fs";
3
+ import { isAbsolute, join } from "node:path";
4
+ import { parseEnv } from "node:util";
5
+ import { strip } from "../text.js";
6
+ import { errorCode } from "../values.js";
7
+ import { SETUP_SECTIONS, setupGuide } from "../version.js";
8
+ import { expandUser, resolvePath } from "./paths.js";
9
+ /** The variables that select the agent and the chat; absent, the registry's defaults hold. */
10
+ export const SELECTORS = ["ALLABOARD_AGENT", "ALLABOARD_CHAT"];
11
+ const [AGENT_VARIABLE, CHAT_VARIABLE] = SELECTORS;
12
+ /** The variables the core itself requires; each module names its own. */
13
+ export const REQUIRED = ["ALLOWED_ROOT"];
14
+ /** The configuration is missing, unsafe or invalid; the message says what to fix. */
15
+ export class ConfigError extends Error {
16
+ constructor(message) {
17
+ super(message);
18
+ this.name = "ConfigError";
19
+ }
20
+ }
21
+ // python-dotenv's own pattern (`dotenv/variables.py`, 1.2.4): `${NAME}` and `${NAME:-default}`.
22
+ // `$NAME` without braces, a lone `$` and `${NAME:other}` stay as they are.
23
+ const VARIABLE = /\$\{([^}:]*)(?::-([^}]*))?\}/g;
24
+ /**
25
+ * The key names of `text` in the order the file defines them. `util.parseEnv` returns its keys
26
+ * sorted, which loses the order python-dotenv resolves in: a name defined later in the file does
27
+ * not exist yet for the values before it. A key `parseEnv` read that no line start names (should
28
+ * a syntax differ) goes last, so no key is dropped.
29
+ */
30
+ function fileOrder(text, keys) {
31
+ const wanted = new Set(keys);
32
+ const ordered = new Set();
33
+ for (const [, key] of text.matchAll(/^[ \t]*(?:export[ \t]+)?([^\s=#]+)[ \t]*=/gm)) {
34
+ if (key !== undefined && wanted.has(key))
35
+ ordered.add(key);
36
+ }
37
+ return [...ordered, ...keys.filter((key) => !ordered.has(key))];
38
+ }
39
+ /**
40
+ * The values of a `.env` text with `${NAME}` and `${NAME:-default}` expanded as python-dotenv does
41
+ * by default (`interpolate=True`), which is how the Python daemon read this file. A name
42
+ * resolves from the values the file defined above it, else from `env`, else to its default, else
43
+ * to nothing; an `env` entry that is empty counts as set. The expanded value is final: nothing in
44
+ * it is expanded again. Quotes were dropped by `parseEnv` and python-dotenv expands inside single
45
+ * quotes too, so every value is expanded alike. Differences from python-dotenv: a key repeated in
46
+ * the file (the last value is used and expanded once, where python-dotenv expands each
47
+ * occurrence) and a bare `NAME` line with no `=` (python-dotenv records it as unset and
48
+ * `${NAME:-d}` then gives nothing; here `d`).
49
+ *
50
+ * The syntax is Node's. Of 24 cases run through `util.parseEnv` of Node 26.5.1 and python-dotenv
51
+ * 1.2.1 (2026-10-11), 7 read differently: a `#` right after an unquoted
52
+ * value starts a comment (`C=a#b` is `a`; python-dotenv keeps `a#b`), and `A= # note` is empty
53
+ * (python-dotenv: `# note`); inside quotes a backslash is kept as written, `\n` in double quotes
54
+ * aside, so `\t` and `\\` stay two characters and a `\"` ends the value at that quote
55
+ * (python-dotenv decodes them); and three shapes python-dotenv skips as malformed are read: a
56
+ * name with a space (`KEY WITH SPACE=1`), text after a closing quote (`B="p"q` is `p`) and an
57
+ * unclosed quote (the value keeps the quote).
58
+ */
59
+ export function expandEnv(text, env = process.env) {
60
+ const raw = new Map(Object.entries(parseEnv(text)));
61
+ const resolved = new Map();
62
+ for (const key of fileOrder(text, [...raw.keys()])) {
63
+ const value = raw.get(key);
64
+ if (value === undefined)
65
+ continue;
66
+ resolved.set(key, value.replace(VARIABLE, (_match, name, fallback) => {
67
+ const defined = resolved.get(name);
68
+ if (defined !== undefined)
69
+ return defined;
70
+ const inherited = Object.hasOwn(env, name) ? env[name] : undefined;
71
+ return inherited ?? fallback ?? "";
72
+ }));
73
+ }
74
+ return Object.fromEntries(resolved);
75
+ }
76
+ function isDirectory(path) {
77
+ try {
78
+ return statSync(path).isDirectory();
79
+ }
80
+ catch (error) {
81
+ const code = errorCode(error);
82
+ if (code === "ENOENT" || code === "ENOTDIR")
83
+ return false;
84
+ throw error;
85
+ }
86
+ }
87
+ /** The values of `names` that `values` holds, readable by lookup and shown by nothing. */
88
+ function hiddenVariables(names, values) {
89
+ const hidden = Object.create(null);
90
+ for (const name of names) {
91
+ const value = values[name];
92
+ if (Object.hasOwn(values, name) && value !== undefined) {
93
+ Object.defineProperty(hidden, name, { value, enumerable: false });
94
+ }
95
+ }
96
+ return hidden;
97
+ }
98
+ /** The module `variable` selects (its default when the file does not set it), or a `ConfigError`. */
99
+ function selected(variable, chosen, fallback, held) {
100
+ const name = chosen ?? fallback;
101
+ const module = Object.hasOwn(held, name) ? held[name] : undefined;
102
+ if (module === undefined) {
103
+ throw new ConfigError(`${variable} is "${name}", which this daemon does not hold; the valid names are: ` +
104
+ Object.keys(held).sort().join(", "));
105
+ }
106
+ return [name, module];
107
+ }
108
+ /**
109
+ * Read and validate `.env` for the modules it selects; throws ConfigError, never touches
110
+ * `process.env`. The values a module's own checks refuse (the shape of a token) are its to
111
+ * judge when it is built.
112
+ */
113
+ export function loadConfig(modules, configDir, env = process.env) {
114
+ const envPath = join(configDir, ".env");
115
+ let st;
116
+ try {
117
+ st = lstatSync(envPath);
118
+ }
119
+ catch (error) {
120
+ if (errorCode(error) === "ENOENT") {
121
+ throw new ConfigError(`${envPath} does not exist; see ${setupGuide(SETUP_SECTIONS.configuration)}`);
122
+ }
123
+ throw error;
124
+ }
125
+ if (!st.isFile()) {
126
+ throw new ConfigError(`${envPath} must be a regular file, not a link`);
127
+ }
128
+ // Ownership and mode are POSIX facts; the daemon is not supported on Windows yet.
129
+ if (process.platform !== "win32") {
130
+ if (st.uid !== process.getuid?.()) {
131
+ throw new ConfigError(`${envPath} must belong to the user running allaboard`);
132
+ }
133
+ // The tokens drive a shell on this machine: nobody but the owner may read them.
134
+ if (st.mode & 0o077) {
135
+ throw new ConfigError(`${envPath} is readable by others; run: chmod 600 ${envPath}`);
136
+ }
137
+ }
138
+ const values = {};
139
+ // A byte order mark would become part of the first variable's name.
140
+ const text = readFileSync(envPath, "utf8").replace(/^\ufeff/, "");
141
+ for (const [key, raw] of Object.entries(expandEnv(text, env))) {
142
+ const value = raw === undefined ? "" : strip(raw);
143
+ if (value)
144
+ values[key] = value;
145
+ }
146
+ const [agent, agentModule] = selected(AGENT_VARIABLE, values[AGENT_VARIABLE], modules.defaults.agent, modules.agents);
147
+ const [chat, chatModule] = selected(CHAT_VARIABLE, values[CHAT_VARIABLE], modules.defaults.chat, modules.chats);
148
+ // The chat's, then the agent's, then the core's own.
149
+ const missing = [...chatModule.required, ...agentModule.required, ...REQUIRED].filter((key) => !Object.hasOwn(values, key));
150
+ if (missing.length > 0) {
151
+ throw new ConfigError(`missing in ${envPath}: ${missing.join(", ")}`);
152
+ }
153
+ const written = values.ALLOWED_ROOT;
154
+ const expanded = expandUser(written);
155
+ // A relative path would read from the working directory: the terminal's folder at Checkpoint 2,
156
+ // `/` under launchd.
157
+ if (!isAbsolute(expanded)) {
158
+ throw new ConfigError(`ALLOWED_ROOT must be an absolute path, or one starting with ~/; it is "${written}"`);
159
+ }
160
+ const root = resolvePath(expanded);
161
+ if (!isDirectory(root)) {
162
+ throw new ConfigError(`ALLOWED_ROOT is not a directory: ${root}`);
163
+ }
164
+ return Object.defineProperties({ agent, chat, allowedRoot: root, configDir }, {
165
+ agentVariables: {
166
+ value: hiddenVariables([...agentModule.required, ...agentModule.optional], values),
167
+ enumerable: false,
168
+ },
169
+ chatVariables: {
170
+ value: hiddenVariables([...chatModule.required, ...chatModule.optional], values),
171
+ enumerable: false,
172
+ },
173
+ });
174
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * The changed files of a session: those added or modified since the thread started, committed or
3
+ * not, and the untracked ones that are not ignored. The measurements behind the git commands are
4
+ * in the `listing.ts` header.
5
+ */
6
+ import { monotonicClock } from "../../clock.js";
7
+ import { len, strip } from "../../text.js";
8
+ import { GIT_TIMEOUT, runGit } from "../footer.js";
9
+ import { Semaphore } from "../sessions/tasks.js";
10
+ import { GIT_CHAINS, relation, scoped, under } from "./listing.js";
11
+ import { TIMED_OUT, within } from "./waiting.js";
12
+ const DECIMAL = /^\p{Nd}+$/u;
13
+ /**
14
+ * Where the repository's HEAD was when the thread started, which `startedAt` (seconds since the
15
+ * epoch) says: `HEAD@{<seconds> +0000}`, read from HEAD's own reflog (a linked worktree has its
16
+ * own). A commit, or the empty tree when the repository was made after the thread began (below),
17
+ * so that every file counts. Null when git has no answer: no reflog (a repository with
18
+ * `core.logAllRefUpdates` off, or a first commit not made yet), or a `startedAt` that is no time.
19
+ *
20
+ * Measured on git 2.54.0 (2026-10-05, 2026-10-06). The bare number is read as a time
21
+ * (`HEAD@{1790000150}`) and so is the internal format with its zone, which is the one used: it
22
+ * leaves no room for git's approximate dates. A time before the oldest entry gives that entry's
23
+ * old commit (a log that `git reflog expire` shortened) or, when its old value is null, its new
24
+ * one: the first commit, or a clone's tip, which would leave that commit's own files out of what
25
+ * changed. The two are told apart by commands that read the log and write nothing:
26
+ *
27
+ * - `rev-list -g --until=<seconds> +0000 --count HEAD` is 0 when every entry is after the thread's
28
+ * start (it counts the entries up to that time, as `HEAD@{<time>}` does);
29
+ * - `rev-list -g --count HEAD` is the number of entries, N, and `rev-parse --verify --quiet
30
+ * HEAD@{N}` is the oldest entry's old value, which fails when it is null (the repository was
31
+ * made, or cloned, since); a shortened log answers;
32
+ * - `hash-object -t tree /dev/null` is the empty tree of the repository's hash algorithm.
33
+ *
34
+ * A log that git cannot be asked this about keeps the answer `HEAD@{<time>}` gave.
35
+ */
36
+ export async function startCommit(repository, startedAt, signal) {
37
+ if (!Number.isSafeInteger(startedAt) || startedAt < 0)
38
+ return null;
39
+ const when = `${startedAt} +0000`;
40
+ const found = await runGit(repository, ["rev-parse", "--verify", "--quiet", `HEAD@{${when}}`], signal);
41
+ const start = strip(found ?? "") || null;
42
+ if (start === null)
43
+ return null;
44
+ const until = await runGit(repository, ["rev-list", "-g", `--until=${when}`, "--count", "HEAD"], signal);
45
+ if (until === null || strip(until) !== "0")
46
+ return start;
47
+ const entries = await runGit(repository, ["rev-list", "-g", "--count", "HEAD"], signal);
48
+ if (entries === null || !DECIMAL.test(strip(entries)))
49
+ return start;
50
+ const oldest = await runGit(repository, ["rev-parse", "--verify", "--quiet", `HEAD@{${strip(entries)}}`], signal);
51
+ if (oldest)
52
+ return start;
53
+ const emptied = await runGit(repository, ["hash-object", "-t", "tree", "/dev/null"], signal);
54
+ return strip(emptied ?? "") || start;
55
+ }
56
+ /**
57
+ * The files in the part of the repository that is `folder` or inside it, added or modified since
58
+ * `start`, committed or not, and the untracked ones that are not ignored, as paths from `folder`.
59
+ * Not checked for existence: a deleted file may be named (`newestFirst` drops it). With no `start`,
60
+ * only what is not committed. Null when git fails.
61
+ */
62
+ export async function changedSince(repository, folder, start, signal) {
63
+ const found = await relation(repository, folder);
64
+ if (found === null)
65
+ return null;
66
+ const [stripped, add] = found;
67
+ const scope = scoped(stripped);
68
+ const status = await runGit(repository, [
69
+ "--literal-pathspecs",
70
+ "--no-optional-locks",
71
+ "status",
72
+ "--porcelain=v1",
73
+ "-z",
74
+ "--untracked-files=all",
75
+ "--ignore-submodules=all",
76
+ "--no-renames",
77
+ ...scope,
78
+ ], signal);
79
+ if (status === null)
80
+ return null;
81
+ // `XY path`: two status letters and a space come before the path.
82
+ const names = status
83
+ .split("\0")
84
+ .filter((entry) => len(entry) > 3)
85
+ .map((entry) => entry.slice(3));
86
+ if (start !== null) {
87
+ const committed = await runGit(repository, [
88
+ "--literal-pathspecs",
89
+ "diff-tree",
90
+ "-r",
91
+ "-z",
92
+ "--name-only",
93
+ "--no-renames",
94
+ "--diff-filter=AMT",
95
+ start,
96
+ "HEAD",
97
+ ...scope,
98
+ ], signal);
99
+ // A start that no longer resolves (rewritten history): the uncommitted files alone.
100
+ names.push(...(committed ?? "").split("\0"));
101
+ }
102
+ return under(names.join("\0"), stripped, add);
103
+ }
104
+ /**
105
+ * The changed files of every repository of `repositories` (see `changedSince`), each from where
106
+ * its own HEAD was when the thread started, at `startedAt` seconds since the epoch
107
+ * (`startCommit`; null when the chat cannot tell, which counts from no start), as paths from `folder`,
108
+ * each once. A repository whose git fails or runs over `GIT_TIMEOUT` adds none.
109
+ */
110
+ export async function changedIn(folder, repositories, startedAt, options = {}) {
111
+ const clock = options.clock ?? monotonicClock;
112
+ const start = options.start ?? startCommit;
113
+ const since = options.since ?? changedSince;
114
+ const gate = new Semaphore(options.chains ?? GIT_CHAINS);
115
+ // The turn is waited for outside the time limit: that is git's own.
116
+ const changed = (repository) => gate.run(async () => {
117
+ const found = await within(clock, GIT_TIMEOUT / 1000, async (signal) => {
118
+ const from = startedAt === null ? null : await start(repository, startedAt, signal);
119
+ return (await since(repository, folder, from, signal)) ?? [];
120
+ });
121
+ return found === TIMED_OUT ? [] : found;
122
+ });
123
+ const parts = await Promise.all(repositories.map(changed));
124
+ return [...new Set(parts.flat())];
125
+ }
@@ -0,0 +1,382 @@
1
+ /**
2
+ * Which files the search offers. Inside a repository the daemon's git may run in
3
+ * (`trustedRepository`: the one holding the session's folder, or the ones found at most two levels
4
+ * below it) the files come from git, through the footer's `runGit`: the tracked ones and the
5
+ * untracked ones that are not ignored. That is what the terminal's `@` file picker does, since its
6
+ * setting `respectGitignore` defaults to `true` and leaves out the files that match `.gitignore`
7
+ * patterns (code.claude.com/docs/en/settings-reference, read 2026-10-05). Everywhere else the
8
+ * folder is walked: regular files only, no symlinked folder entered, no `.git` entered.
9
+ *
10
+ * Every git command of `!open` (here and in `changed.ts`) is plumbing or `status` under
11
+ * `--no-optional-locks`, because `git diff` refreshes and rewrites the index under `index.lock`
12
+ * (see `changesOf` in the footer), and a lock left by a killed command would stop the owner's own
13
+ * `git add` and commit. Measured on git 2.54.0 (2026-10-05, and 2026-10-06 for the reflog
14
+ * commands) on a scratch repository, with the index's inode, mtime and sha256 compared before and
15
+ * after, in four states of the work tree (clean; a tracked file touched and left unchanged, which
16
+ * is a stat-dirty file; a tracked file edited; an untracked file):
17
+ *
18
+ * - never written, in any state: `rev-parse --verify HEAD@{<time>}` and `HEAD@{<n>}`, `rev-list
19
+ * -g --count` (also with `--until`), `hash-object -t tree /dev/null`, `ls-files --cached
20
+ * --others --exclude-standard`, `diff-tree -r`, `diff-files`, `diff-index` and
21
+ * `--no-optional-locks status`;
22
+ * - written for the stat-dirty file: `status` without `--no-optional-locks`, `diff --name-only`
23
+ * and `--no-optional-locks diff --name-only`;
24
+ * - reported as changed when only touched: `diff-files` and `diff-index` (the footer's commands);
25
+ * not reported: `status`, which compares the content of a file whose stat differs.
26
+ *
27
+ * So the committed changes come from `diff-tree` and the rest from `status`, which also lists the
28
+ * untracked files. `test/core/files/changed.test.ts` repeats the measurement on every run.
29
+ */
30
+ import { lstat, readdir } from "node:fs/promises";
31
+ import { join, sep } from "node:path";
32
+ import { monotonicClock } from "../../clock.js";
33
+ import { getLogger } from "../../log.js";
34
+ import { compareCodePoints, len } from "../../text.js";
35
+ import { errorName } from "../../values.js";
36
+ import { foldersWithin } from "../folders.js";
37
+ import { runGit } from "../footer.js";
38
+ import { cancellable, Semaphore } from "../sessions/tasks.js";
39
+ import { relativeTo } from "./paths.js";
40
+ import { realpathLoose, regularFiles } from "./read.js";
41
+ import { TIMED_OUT, within } from "./waiting.js";
42
+ export const logger = getLogger("allaboard.core.files");
43
+ // A folder's listing is kept for LISTING_TTL (LISTING_PARTIAL_TTL when it is not complete), so
44
+ // the keystrokes of one search do not walk the folder again, and a listing stops at
45
+ // LISTING_BUDGET with what it found; LISTING_KEPT folders are kept at most. All in seconds.
46
+ export const LISTING_BUDGET = 2.0;
47
+ export const LISTING_TTL = 30.0;
48
+ export const LISTING_PARTIAL_TTL = 3.0;
49
+ export const LISTING_KEPT = 16;
50
+ // The repositories found in a folder are kept for a shorter time than its files: one the owner
51
+ // trusts while the session runs should show within moments. At most GIT_CHAINS
52
+ // repositories are asked at once, the rest wait their turn.
53
+ export const REPOSITORIES_TTL = 5.0;
54
+ export const GIT_CHAINS = 4;
55
+ /** `repository` when it has a work tree git can list, else null. */
56
+ export function usable(repository) {
57
+ if (repository === null || repository.gitDir === null || repository.insideGitDir)
58
+ return null;
59
+ return repository;
60
+ }
61
+ /**
62
+ * How the folder and the repository's root meet, as `[strip, add]`: git's paths are relative to
63
+ * the root, and a path from the folder is that path less `strip` and with `add` before it. A
64
+ * folder inside the repository strips its own path from the root (`""` at the root itself), a
65
+ * repository inside the folder adds its own path from the folder, and any other pair is null.
66
+ */
67
+ export async function relation(repository, folder) {
68
+ const real = await realpathLoose(folder);
69
+ const inside = relativeTo(repository.root, real);
70
+ if (inside !== null)
71
+ return [inside.split(sep).join("/"), ""];
72
+ const held = relativeTo(real, repository.root);
73
+ if (held !== null)
74
+ return ["", `${held.split(sep).join("/")}/`];
75
+ return null;
76
+ }
77
+ /**
78
+ * The paths of a NUL-separated git output that lie under `strip`, as paths from the folder, each
79
+ * once.
80
+ */
81
+ export function under(output, strip, add) {
82
+ const start = strip ? `${strip}/` : "";
83
+ const names = output
84
+ .split("\0")
85
+ .filter((name) => name.startsWith(start))
86
+ .map((name) => name.slice(start.length));
87
+ return [...new Set(names.filter((name) => name).map((name) => `${add}${name}`))];
88
+ }
89
+ export function scoped(strip) {
90
+ return strip ? ["--", strip] : [];
91
+ }
92
+ /** The folders under `folder`, as `!bind` lists them, that hold a `.git` entry. */
93
+ async function repositoryFolders(folder) {
94
+ let below;
95
+ try {
96
+ below = await foldersWithin(folder);
97
+ }
98
+ catch (error) {
99
+ // A system error has a code.
100
+ if (typeof error?.code === "string")
101
+ return [];
102
+ throw error;
103
+ }
104
+ const holding = await Promise.all(below.map((path) => path === folder
105
+ ? false
106
+ : lstat(join(path, ".git")).then(() => true, () => false)));
107
+ return below.filter((_path, index) => holding[index]);
108
+ }
109
+ /**
110
+ * The repositories the daemon's git may run in for a session started in `folder`: the one holding
111
+ * the folder when `lookup` finds it usable, else the usable ones found at most two levels below
112
+ * it (the depth `!bind` lists folders to), none of them inside another.
113
+ */
114
+ export async function repositoriesOf(folder, lookup) {
115
+ const held = usable(await lookup(folder));
116
+ if (held !== null)
117
+ return [held];
118
+ const below = await repositoryFolders(folder);
119
+ const looked = await Promise.all(below.map((candidate) => lookup(candidate)));
120
+ const found = new Map();
121
+ for (const repository of looked.map(usable)) {
122
+ if (repository !== null)
123
+ found.set(repository.root, repository);
124
+ }
125
+ return [...found.values()];
126
+ }
127
+ /**
128
+ * The regular files under `root` as paths from it, the shallower first: a symlink is neither
129
+ * followed nor listed, no `.git` is entered, nor any folder in `skip` (paths). Stops when
130
+ * `expired()` is true, with what it has found; the flag says the walk reached its end.
131
+ */
132
+ export async function walkFiles(root, skip, expired) {
133
+ const found = [];
134
+ const queue = [[root, ""]];
135
+ let next = 0;
136
+ while (next < queue.length && !expired()) {
137
+ const [directory, prefix] = queue[next];
138
+ next += 1;
139
+ try {
140
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
141
+ if (entry.name === ".git")
142
+ continue;
143
+ const path = join(directory, entry.name);
144
+ // `Dirent` describes the entry itself: a symlink is neither a directory nor a file.
145
+ if (entry.isDirectory()) {
146
+ if (!skip.has(path))
147
+ queue.push([path, `${prefix}${entry.name}/`]);
148
+ }
149
+ else if (entry.isFile()) {
150
+ found.push(`${prefix}${entry.name}`);
151
+ }
152
+ }
153
+ }
154
+ catch (error) {
155
+ // A folder the daemon may not open (macOS privacy, permissions) lists nothing.
156
+ logger.debug(`skipped an unreadable folder: ${error.code}`);
157
+ }
158
+ }
159
+ return [found, next >= queue.length];
160
+ }
161
+ /**
162
+ * The tracked files and the untracked ones that are not ignored, in the part of the repository
163
+ * that is `folder` or inside it, as paths from `folder`; null when git fails. Not checked for
164
+ * existence: a tracked file deleted from the work tree is listed (`regularFiles` drops it).
165
+ */
166
+ export async function projectFiles(repository, folder, signal) {
167
+ const found = await relation(repository, folder);
168
+ if (found === null)
169
+ return null;
170
+ const [strip, add] = found;
171
+ const out = await runGit(repository, [
172
+ // Literal: the folder's name is not a glob.
173
+ "--literal-pathspecs",
174
+ "ls-files",
175
+ "-z",
176
+ "--cached",
177
+ "--others",
178
+ "--exclude-standard",
179
+ ...scoped(strip),
180
+ ], signal);
181
+ return out === null ? null : under(out, strip, add);
182
+ }
183
+ /**
184
+ * The files `!open` can name under `folder`, as paths from it: from git inside a repository of
185
+ * `repositories`, the folder's own disk anywhere else. Within `budget` seconds: what is found by
186
+ * then is returned, a repository whose listing is not done adding nothing. The flag says nothing
187
+ * was left out: no walk or git listing ran out of time or failed.
188
+ */
189
+ export async function folderFiles(folder, repositories, budget, options = {}) {
190
+ const clock = options.clock ?? monotonicClock;
191
+ const list = options.list ?? projectFiles;
192
+ const deadline = clock.time() + budget;
193
+ const real = await realpathLoose(folder);
194
+ const gate = new Semaphore(options.chains ?? GIT_CHAINS);
195
+ const listed = async (repository) => {
196
+ // Waiting for a turn is part of the budget: it is the listing's own.
197
+ const found = await within(clock, budget, (signal) => gate.run(() => list(repository, folder, signal), signal));
198
+ if (found === TIMED_OUT)
199
+ return [[], false];
200
+ return [found ?? [], found !== null];
201
+ };
202
+ let walked = [];
203
+ let walkDone = true;
204
+ let parts;
205
+ if (repositories.some((r) => relativeTo(r.root, real) !== null)) {
206
+ // Every file of the folder is in the repository that holds it.
207
+ parts = await Promise.all(repositories.map(listed));
208
+ }
209
+ else {
210
+ const skip = new Set(repositories.map((r) => r.root));
211
+ const [walk, ...listings] = await Promise.all([
212
+ walkFiles(real, skip, () => clock.time() >= deadline),
213
+ ...repositories.map(listed),
214
+ ]);
215
+ [walked, walkDone] = walk;
216
+ parts = listings;
217
+ }
218
+ const files = [...new Set([...walked, ...parts.flatMap(([found]) => found)])];
219
+ return [files, walkDone && parts.every(([, done]) => done)];
220
+ }
221
+ /**
222
+ * What is made for a folder, kept for as long as its maker says, at most `limit` folders (the
223
+ * oldest go first) with the expired ones removed on the next request. Requests for a folder whose
224
+ * value is being made wait for that one.
225
+ */
226
+ class Kept {
227
+ #limit;
228
+ #clock;
229
+ #kept = new Map();
230
+ #running = new Map();
231
+ constructor(limit, clock) {
232
+ this.#limit = limit;
233
+ this.#clock = clock;
234
+ }
235
+ /** The folders whose value is kept. */
236
+ folders() {
237
+ return [...this.#kept.keys()];
238
+ }
239
+ /**
240
+ * The value, and whether it was made for this request (not kept). `make` answers the value and
241
+ * how long to keep it, null for not at all. `again` skips what is kept. A request whose `signal`
242
+ * aborts stops waiting; the work goes on for the others.
243
+ */
244
+ async get(folder, make, options = {}) {
245
+ const now = this.#clock.time();
246
+ for (const [path, entry] of [...this.#kept]) {
247
+ if (entry.until <= now)
248
+ this.#kept.delete(path);
249
+ }
250
+ const kept = options.again ? undefined : this.#kept.get(folder);
251
+ if (kept !== undefined)
252
+ return [kept.value, false];
253
+ let task = this.#running.get(folder);
254
+ if (task === undefined) {
255
+ const started = this.#make(folder, make);
256
+ task = started;
257
+ this.#running.set(folder, started);
258
+ const done = () => {
259
+ if (this.#running.get(folder) === started)
260
+ this.#running.delete(folder);
261
+ };
262
+ started.then(done, done);
263
+ }
264
+ return [await cancellable(task, options.signal), true];
265
+ }
266
+ async #make(folder, make) {
267
+ const started = this.#clock.time();
268
+ const [value, keep] = await make();
269
+ if (keep !== null) {
270
+ this.#kept.delete(folder);
271
+ this.#kept.set(folder, { until: started + keep, value });
272
+ while (this.#kept.size > this.#limit) {
273
+ this.#kept.delete(this.#kept.keys().next().value);
274
+ }
275
+ }
276
+ return value;
277
+ }
278
+ }
279
+ /** The paths that contain `words`, ignoring case: those whose file name does first, then the shorter paths, then by path. */
280
+ export function rank(paths, words) {
281
+ const wanted = words.toLowerCase();
282
+ const found = [...paths].filter((path) => path.toLowerCase().includes(wanted));
283
+ const named = (path) => path
284
+ .slice(path.lastIndexOf("/") + 1)
285
+ .toLowerCase()
286
+ .includes(wanted);
287
+ return found.toSorted((left, right) => Number(!named(left)) - Number(!named(right)) ||
288
+ len(left) - len(right) ||
289
+ compareCodePoints(left, right));
290
+ }
291
+ /**
292
+ * The files of a session's folder for the search, and the repositories found in it, kept in
293
+ * memory: the chat asks again on every keystroke, and a folder is not walked each time. A listing is
294
+ * kept `ttl` seconds when it is complete and `partialTtl` when it ran out of time or git failed
295
+ * for part of it, so a folder too large for the budget is not walked on each keystroke but is not
296
+ * taken for complete either. The repositories are kept `repositoriesTtl` seconds. At most `limit`
297
+ * folders of each are kept (the oldest go first), and the expired ones are removed on the next
298
+ * request. Requests for a folder that is being listed wait for that listing. A kept listing is
299
+ * never the reason for "no match": `search` makes it again first.
300
+ */
301
+ export class Listings {
302
+ #lookup;
303
+ #ttl;
304
+ #partialTtl;
305
+ #repositoriesTtl;
306
+ #budget;
307
+ #clock;
308
+ #regular;
309
+ #listed;
310
+ #found;
311
+ constructor(lookup, options = {}) {
312
+ const limit = options.limit ?? LISTING_KEPT;
313
+ this.#lookup = lookup;
314
+ this.#ttl = options.ttl ?? LISTING_TTL;
315
+ this.#partialTtl = options.partialTtl ?? LISTING_PARTIAL_TTL;
316
+ this.#repositoriesTtl = options.repositoriesTtl ?? REPOSITORIES_TTL;
317
+ this.#budget = options.budget ?? LISTING_BUDGET;
318
+ this.#clock = options.clock ?? monotonicClock;
319
+ this.#regular = options.regular ?? regularFiles;
320
+ this.#listed = new Kept(limit, this.#clock);
321
+ this.#found = new Kept(limit, this.#clock);
322
+ }
323
+ /** The folders whose listing is kept. */
324
+ keptListings() {
325
+ return this.#listed.folders();
326
+ }
327
+ /**
328
+ * The repositories the daemon's git may run in for a session started in `folder`
329
+ * (`repositoriesOf`). Rejects with what the lookup rejects with.
330
+ */
331
+ async repositories(folder, options = {}) {
332
+ const find = async () => [
333
+ await repositoriesOf(folder, this.#lookup),
334
+ this.#repositoriesTtl,
335
+ ];
336
+ return (await this.#found.get(folder, find, options))[0];
337
+ }
338
+ /**
339
+ * The folder's files as paths from it. Never rejects but for the signal: a listing that fails
340
+ * is empty, incomplete and not kept. `again` makes it anew whatever is kept.
341
+ */
342
+ async of(folder, options = {}) {
343
+ const [listing, fresh] = await this.#listed.get(folder, () => this.#list(folder, options.again ?? false), options);
344
+ return fresh ? listing : { ...listing, fresh: false };
345
+ }
346
+ async #list(folder, again) {
347
+ try {
348
+ const repositories = await this.repositories(folder, { again });
349
+ const [files, complete] = await folderFiles(folder, repositories, this.#budget, {
350
+ clock: this.#clock,
351
+ });
352
+ return [{ files, complete, fresh: true }, complete ? this.#ttl : this.#partialTtl];
353
+ }
354
+ catch (error) {
355
+ // A search that finds nothing beats a modal that never loads.
356
+ logger.warning(`could not list a folder's files: ${errorName(error)}`);
357
+ return [{ files: [], complete: false, fresh: true }, null];
358
+ }
359
+ }
360
+ /**
361
+ * The files of the folder whose path contains `words`, in `rank`'s order, that exist
362
+ * (`regularFiles`), at most `limit` of them checked. Past the limit `count` is the number of
363
+ * matches by name. When a kept listing finds none, the folder is listed again and searched
364
+ * anew: a file made since the listing is not told to be missing.
365
+ */
366
+ async search(folder, words, options = {}) {
367
+ const { limit, signal } = options;
368
+ const listing = await this.of(folder, { signal });
369
+ let found = await this.#matching(folder, listing, words, limit);
370
+ if (found.paths.length === 0 && !listing.fresh) {
371
+ const again = await this.of(folder, { again: true, signal });
372
+ found = await this.#matching(folder, again, words, limit);
373
+ }
374
+ return found;
375
+ }
376
+ async #matching(folder, listing, words, limit) {
377
+ const ranked = rank(listing.files, words);
378
+ const paths = await this.#regular(folder, ranked, limit);
379
+ const count = limit !== undefined && paths.length >= limit ? ranked.length : paths.length;
380
+ return { paths, count, complete: listing.complete };
381
+ }
382
+ }