toolroll 0.9.82 → 0.9.84
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/CHANGELOG.md +41 -0
- package/README.md +4 -3
- package/dist/assignment-ui.js +5 -1
- package/dist/browser/workspace.css +1 -1
- package/dist/browser/workspace.js +22 -22
- package/dist/browser-crew.js +5 -1
- package/dist/browser-workspace.d.ts +7 -0
- package/dist/contracts/console-api.d.ts +2 -1
- package/dist/contracts/console-api.js +5 -1
- package/dist/control-setup.d.ts +9 -0
- package/dist/control-setup.js +31 -4
- package/dist/control-ui.d.ts +7 -1
- package/dist/control-ui.js +3 -2
- package/dist/dispatch.d.ts +15 -0
- package/dist/dispatch.js +26 -4
- package/dist/first-run.d.ts +5 -2
- package/dist/first-run.js +13 -2
- package/dist/first-tasks-child.d.ts +1 -0
- package/dist/first-tasks-child.js +11 -0
- package/dist/folder-look.d.ts +110 -0
- package/dist/folder-look.js +306 -0
- package/dist/guides.js +4 -3
- package/dist/learning-child.d.ts +1 -0
- package/dist/learning-child.js +34 -0
- package/dist/learning-look.d.ts +25 -0
- package/dist/learning-look.js +72 -0
- package/dist/model-catalog.d.ts +5 -0
- package/dist/model-catalog.js +11 -2
- package/dist/model-choices.d.ts +188 -0
- package/dist/model-choices.js +405 -0
- package/dist/model-names.d.ts +6 -0
- package/dist/model-names.js +34 -0
- package/dist/models-ui.d.ts +69 -5
- package/dist/models-ui.js +53 -10
- package/dist/needs-you.d.ts +8 -0
- package/dist/needs-you.js +8 -0
- package/dist/operate.d.ts +53 -0
- package/dist/operate.js +277 -49
- package/dist/project-admission.d.ts +4 -1
- package/dist/project-admission.js +23 -2
- package/dist/project-identity.d.ts +44 -0
- package/dist/project-identity.js +153 -0
- package/dist/project-knowledge.d.ts +6 -6
- package/dist/project-tools.d.ts +4 -1
- package/dist/project-tools.js +7 -2
- package/dist/project.d.ts +4 -1
- package/dist/project.js +11 -4
- package/dist/repos.d.ts +7 -0
- package/dist/repos.js +57 -0
- package/dist/serve.js +91 -17
- package/dist/server/chat.js +5 -4
- package/dist/server/chrome.d.ts +3 -0
- package/dist/server/chrome.js +5 -2
- package/dist/server/console.css +5 -2
- package/dist/server/flows.js +13 -3
- package/dist/server/guards.d.ts +4 -0
- package/dist/server/guards.js +12 -3
- package/dist/server/http.d.ts +12 -0
- package/dist/server/http.js +9 -0
- package/dist/server/pages.js +102 -92
- package/dist/server/people-tokens.js +40 -20
- package/dist/server/render-pages.d.ts +12 -16
- package/dist/server/render-pages.js +30 -69
- package/dist/server/render-people.d.ts +14 -2
- package/dist/server/render-people.js +35 -12
- package/dist/server/render-settings.js +1 -0
- package/dist/server/render-tasks.js +7 -14
- package/dist/server/request-context.d.ts +2 -0
- package/dist/server/route-table.js +8 -1
- package/dist/server/runtime.d.ts +4 -0
- package/dist/server/runtime.js +1 -0
- package/dist/server/settings.d.ts +58 -1
- package/dist/server/settings.js +211 -71
- package/dist/server/signup-gate.d.ts +9 -0
- package/dist/server/signup-gate.js +40 -0
- package/dist/server/tasks.js +19 -5
- package/dist/settings-kit-script.d.ts +21 -0
- package/dist/settings-kit-script.js +67 -0
- package/dist/settings-kit.d.ts +137 -0
- package/dist/settings-kit.js +186 -0
- package/dist/settings-proofs.d.ts +64 -0
- package/dist/settings-proofs.js +101 -0
- package/dist/setup-guide.js +4 -2
- package/dist/spend-report.d.ts +87 -0
- package/dist/spend-report.js +265 -0
- package/dist/spend-ui.d.ts +29 -8
- package/dist/spend-ui.js +74 -39
- package/dist/task-sizing.d.ts +5 -3
- package/dist/task-sizing.js +4 -4
- package/dist/up-processes.d.ts +13 -0
- package/dist/up-processes.js +6 -2
- package/package.json +1 -1
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads of a person's own folders, kept off the console's thread.
|
|
3
|
+
*
|
|
4
|
+
* A folder can stop answering: a drive that disconnected without unmounting, a folder macOS keeps in iCloud (Desktop &
|
|
5
|
+
* Documents), or one behind a privacy prompt nobody has answered. Opening it then blocks until the system answers, and
|
|
6
|
+
* a synchronous read on the console's thread freezes every page with it (the 0.9.82 deploy, 2026-10-31: the console
|
|
7
|
+
* listened for twenty minutes without answering, its main thread inside a directory read). So every such read runs in a
|
|
8
|
+
* child process, the same read it always was, and the console waits for it only as long as a deadline allows: a folder
|
|
9
|
+
* that didn't answer degrades the one thing that needed it, in plain words, and the next look tries again.
|
|
10
|
+
*
|
|
11
|
+
* A look never goes into another mounted drive, /Volumes, or the protected folders under ~/Library, unless that is the
|
|
12
|
+
* folder the person chose. Its answers are filesystem data, read exactly as before (directory names, a repository's
|
|
13
|
+
* `.git/config` as a plain bounded file never followed through a link), and the caller still checks every path against
|
|
14
|
+
* its own ceiling.
|
|
15
|
+
*/
|
|
16
|
+
import { spawn } from "node:child_process";
|
|
17
|
+
import { homedir } from "node:os";
|
|
18
|
+
import { resolve } from "node:path";
|
|
19
|
+
/** How long the console waits for a look before saying the folder didn't answer. */
|
|
20
|
+
export const FOLDER_LOOK_MS = 4_000;
|
|
21
|
+
/** The `url` of `[remote "origin"]` in a git config's text. Plain JavaScript with no outside names: the look's child
|
|
22
|
+
* runs this same function (its source is sent along), so the parent and the child read a config one way. */
|
|
23
|
+
export function originUrlIn(config) {
|
|
24
|
+
let inOrigin = false;
|
|
25
|
+
for (const line of config.split("\n")) {
|
|
26
|
+
if (/^\s*\[/.test(line)) {
|
|
27
|
+
inOrigin = /^\s*\[remote "origin"\]\s*$/.test(line);
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (!inOrigin)
|
|
31
|
+
continue;
|
|
32
|
+
const url = /^\s*url\s*=\s*(.+)$/.exec(line)?.[1];
|
|
33
|
+
if (url !== undefined)
|
|
34
|
+
return url.trim();
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
/** At most this many look processes at once, stuck ones included: a folder that never answers costs one each. */
|
|
39
|
+
export const FOLDER_LOOK_CHILDREN = 8;
|
|
40
|
+
/**
|
|
41
|
+
* The child's script: plain CommonJS, given the request as its one argument, printing one JSON answer. Every file it
|
|
42
|
+
* reads is opened without following a link and without waiting on a FIFO, checked to be a regular file under its cap
|
|
43
|
+
* on the open descriptor, and read only up to its limit; every folder is read through a bounded iterator.
|
|
44
|
+
*/
|
|
45
|
+
const LOOK_SCRIPT = `"use strict";
|
|
46
|
+
const fs = require("fs"), path = require("path");
|
|
47
|
+
const originUrlIn = ${originUrlIn.toString()};
|
|
48
|
+
const request = JSON.parse(process.argv[1]);
|
|
49
|
+
const within = (p, root) => p === root || p.startsWith(root === "/" ? "/" : root + "/");
|
|
50
|
+
const home = request.home || "/nonexistent";
|
|
51
|
+
const fenced = [path.join(home, "Library"), "/Volumes", "/System/Volumes", "/Network", "/net", "/private/var/automount"];
|
|
52
|
+
// A place the look never enters, unless the path is inside a folder the person chose there (that root itself, not any
|
|
53
|
+
// root that happens to lie in the same protected tree).
|
|
54
|
+
const offLimits = (p, roots) => fenced.some(one => within(p, one) && !roots.some(root => within(root, one) && within(p, root)));
|
|
55
|
+
const rootOf = (p, roots) => roots.filter(root => within(p, root)).sort((a, b) => b.length - a.length)[0];
|
|
56
|
+
// A plain file read bounded by construction (ghlist review, finding 1): never through a link (O_NOFOLLOW), never
|
|
57
|
+
// waiting on a FIFO (O_NONBLOCK), a regular file under the cap by the opened descriptor, at most the limit read.
|
|
58
|
+
function readBounded(file, cap, limit) {
|
|
59
|
+
let fd = null;
|
|
60
|
+
try {
|
|
61
|
+
fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK);
|
|
62
|
+
const stat = fs.fstatSync(fd);
|
|
63
|
+
if (!stat.isFile() || stat.size > cap) return null;
|
|
64
|
+
const buffer = Buffer.alloc(Math.min(limit, stat.size));
|
|
65
|
+
const read = buffer.length === 0 ? 0 : fs.readSync(fd, buffer, 0, buffer.length, 0);
|
|
66
|
+
return buffer.toString("utf8", 0, read);
|
|
67
|
+
} catch { return null; }
|
|
68
|
+
finally { if (fd !== null) { try { fs.closeSync(fd); } catch {} } }
|
|
69
|
+
}
|
|
70
|
+
function origin(repo) {
|
|
71
|
+
const config = readBounded(path.join(repo, ".git", "config"), 1024 * 1024, 64 * 1024);
|
|
72
|
+
return config === null ? null : originUrlIn(config);
|
|
73
|
+
}
|
|
74
|
+
function children(folder, roots, links) {
|
|
75
|
+
const out = [];
|
|
76
|
+
const folderDev = fs.statSync(folder).dev;
|
|
77
|
+
const dir = fs.opendirSync(folder);
|
|
78
|
+
try {
|
|
79
|
+
for (let seen = 0; seen < 2000 && out.length < 400; seen++) {
|
|
80
|
+
const entry = dir.readSync();
|
|
81
|
+
if (entry === null) break;
|
|
82
|
+
if (!(entry.isDirectory() || (links && entry.isSymbolicLink()))) continue;
|
|
83
|
+
const spelled = path.join(folder, entry.name);
|
|
84
|
+
if (offLimits(spelled, roots)) continue;
|
|
85
|
+
let real, stat;
|
|
86
|
+
try { real = fs.realpathSync(spelled); } catch { continue; }
|
|
87
|
+
if (offLimits(real, roots) || !roots.some(root => within(real, root))) continue;
|
|
88
|
+
try { stat = fs.statSync(real); } catch { continue; }
|
|
89
|
+
if (!stat.isDirectory()) continue;
|
|
90
|
+
// Another drive mounted here: not entered unless it is itself a chosen folder.
|
|
91
|
+
if (stat.dev !== folderDev && !roots.includes(real)) continue;
|
|
92
|
+
out.push({ name: entry.name, path: real });
|
|
93
|
+
}
|
|
94
|
+
} finally { dir.closeSync(); }
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
function look() {
|
|
98
|
+
if (request.op === "projectMcp") {
|
|
99
|
+
const text = readBounded(path.join(request.path, ".mcp.json"), 1024 * 1024, 1024 * 1024);
|
|
100
|
+
if (text === null) return { config: null };
|
|
101
|
+
try { const parsed = JSON.parse(text); return { config: parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null }; }
|
|
102
|
+
catch { return { config: null }; }
|
|
103
|
+
}
|
|
104
|
+
if (request.op === "identities") {
|
|
105
|
+
return { paths: request.paths.slice(0, 500).map(one => {
|
|
106
|
+
try { const real = fs.realpathSync(path.resolve(one)); return { path: one, real: fs.statSync(real).isDirectory() ? real : null }; }
|
|
107
|
+
catch { return { path: one, real: null }; }
|
|
108
|
+
}) };
|
|
109
|
+
}
|
|
110
|
+
const roots = request.roots;
|
|
111
|
+
if (request.op === "list") {
|
|
112
|
+
const at = fs.realpathSync(request.path);
|
|
113
|
+
const root = rootOf(at, roots);
|
|
114
|
+
if (root === undefined || offLimits(at, roots) || !fs.statSync(at).isDirectory()) return { at: null, entries: [] };
|
|
115
|
+
// A folder on another drive than the root that allows it is not entered (the root itself may be one).
|
|
116
|
+
if (at !== root && fs.statSync(at).dev !== fs.statSync(root).dev) return { at: null, entries: [] };
|
|
117
|
+
const entries = children(at, roots, true)
|
|
118
|
+
.filter(one => !one.name.startsWith(".") && one.name !== "node_modules")
|
|
119
|
+
.map(one => ({ name: one.name, path: one.path, git: fs.existsSync(path.join(one.path, ".git")) }));
|
|
120
|
+
return { at, entries };
|
|
121
|
+
}
|
|
122
|
+
const registered = request.registered.slice(0, 500).map(repo => ({ path: repo, url: origin(repo) }));
|
|
123
|
+
const found = [];
|
|
124
|
+
for (const root of roots) {
|
|
125
|
+
let listed = [];
|
|
126
|
+
try { listed = children(root, roots, false); } catch { continue; }
|
|
127
|
+
for (const child of listed) {
|
|
128
|
+
if (!fs.existsSync(path.join(child.path, ".git"))) continue;
|
|
129
|
+
found.push({ path: child.path, url: origin(child.path) });
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return { registered, found };
|
|
133
|
+
}
|
|
134
|
+
let answer;
|
|
135
|
+
try { answer = { ok: true, value: look() }; } catch (error) { answer = { ok: false, code: error && error.code || null }; }
|
|
136
|
+
process.stdout.write(JSON.stringify(answer));
|
|
137
|
+
`;
|
|
138
|
+
/** The production look: the script above in a child process of this same Node (or Electron as Node), killed at its
|
|
139
|
+
* deadline. Its environment carries nothing of this process's (no NODE_OPTIONS, no secrets). At the deadline its pipe
|
|
140
|
+
* is closed and nothing of it keeps this process alive; `ended` settles only when the process has really gone. */
|
|
141
|
+
export const childLook = (request, deadlineMs) => {
|
|
142
|
+
const child = spawn(process.execPath, ["-e", LOOK_SCRIPT, JSON.stringify(request)], {
|
|
143
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
144
|
+
env: { PATH: process.env["PATH"] ?? "/usr/bin:/bin", ELECTRON_RUN_AS_NODE: "1" },
|
|
145
|
+
killSignal: "SIGKILL",
|
|
146
|
+
});
|
|
147
|
+
child.unref();
|
|
148
|
+
const ended = new Promise(resolveEnded => {
|
|
149
|
+
child.once("exit", () => resolveEnded());
|
|
150
|
+
child.once("error", () => resolveEnded());
|
|
151
|
+
});
|
|
152
|
+
const answer = new Promise((resolveAnswer, reject) => {
|
|
153
|
+
const chunks = [];
|
|
154
|
+
let size = 0;
|
|
155
|
+
const timer = setTimeout(() => {
|
|
156
|
+
child.kill("SIGKILL");
|
|
157
|
+
// A child stuck in the kernel can't be reaped yet: its pipe is closed now, so it holds nothing here.
|
|
158
|
+
child.stdout.destroy();
|
|
159
|
+
reject(new Error("the look didn't answer"));
|
|
160
|
+
}, deadlineMs);
|
|
161
|
+
timer.unref();
|
|
162
|
+
child.stdout.on("data", (chunk) => {
|
|
163
|
+
size += chunk.length;
|
|
164
|
+
if (size > 8 * 1024 * 1024) {
|
|
165
|
+
child.kill("SIGKILL");
|
|
166
|
+
child.stdout.destroy();
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
chunks.push(chunk);
|
|
170
|
+
});
|
|
171
|
+
child.once("error", error => { clearTimeout(timer); reject(error); });
|
|
172
|
+
child.once("close", code => {
|
|
173
|
+
clearTimeout(timer);
|
|
174
|
+
if (code !== 0) {
|
|
175
|
+
reject(new Error(`the look ended with ${code}`));
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
try {
|
|
179
|
+
resolveAnswer(JSON.parse(Buffer.concat(chunks).toString("utf8")));
|
|
180
|
+
}
|
|
181
|
+
catch (error) {
|
|
182
|
+
reject(error);
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
answer.catch(() => { });
|
|
187
|
+
return { answer, ended };
|
|
188
|
+
};
|
|
189
|
+
/** Looks whose process hasn't ended, by request: callers asking the same thing share one; each holds a child slot
|
|
190
|
+
* until its process is gone. */
|
|
191
|
+
const inFlight = new Map();
|
|
192
|
+
/** Looks started so far: each look's place in that order is fixed when its process starts. */
|
|
193
|
+
let starts = 0;
|
|
194
|
+
/** The same request however its paths were spelled: resolved paths, roots in order. */
|
|
195
|
+
function lookKey(request) {
|
|
196
|
+
const sorted = (paths) => paths.map(one => resolve(one)).sort();
|
|
197
|
+
switch (request.op) {
|
|
198
|
+
case "list": return JSON.stringify([request.op, resolve(request.path), sorted(request.roots), request.home]);
|
|
199
|
+
case "origins": return JSON.stringify([request.op, sorted(request.registered), sorted(request.roots), request.home]);
|
|
200
|
+
case "identities": return JSON.stringify([request.op, sorted(request.paths)]);
|
|
201
|
+
case "projectMcp": return JSON.stringify([request.op, resolve(request.path)]);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
/** How many look processes are running now (stuck ones included). */
|
|
205
|
+
export function folderLooksRunning() {
|
|
206
|
+
return inFlight.size;
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* One look, answered within `deadlineMs` whatever the folder does: its value, or that the folder didn't answer (it ran
|
|
210
|
+
* past the deadline, the same look is still stuck, or every child slot is held) or the look failed. A look already
|
|
211
|
+
* under way for the same request is joined, never started twice.
|
|
212
|
+
*/
|
|
213
|
+
export async function lookFolders(request, options = {}) {
|
|
214
|
+
const deadlineMs = options.deadlineMs ?? FOLDER_LOOK_MS;
|
|
215
|
+
const key = lookKey(request);
|
|
216
|
+
let run = inFlight.get(key);
|
|
217
|
+
if (run === undefined) {
|
|
218
|
+
if (inFlight.size >= FOLDER_LOOK_CHILDREN)
|
|
219
|
+
return { ok: false, why: "no-answer" };
|
|
220
|
+
let started;
|
|
221
|
+
try {
|
|
222
|
+
started = (options.runner ?? childLook)(request, deadlineMs);
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
return { ok: false, why: "failed" };
|
|
226
|
+
}
|
|
227
|
+
const mine = { ...started, started: ++starts };
|
|
228
|
+
run = mine;
|
|
229
|
+
inFlight.set(key, mine);
|
|
230
|
+
const forget = () => { if (inFlight.get(key) === mine)
|
|
231
|
+
inFlight.delete(key); };
|
|
232
|
+
mine.ended.then(forget, forget);
|
|
233
|
+
}
|
|
234
|
+
let timer;
|
|
235
|
+
const late = new Promise(resolveLate => { timer = setTimeout(() => resolveLate("late"), deadlineMs); });
|
|
236
|
+
try {
|
|
237
|
+
const first = await Promise.race([run.answer.then(value => ({ value }), () => "failed"), late]);
|
|
238
|
+
if (first === "late")
|
|
239
|
+
return { ok: false, why: "no-answer" };
|
|
240
|
+
if (first === "failed")
|
|
241
|
+
return { ok: false, why: "failed" };
|
|
242
|
+
const answer = first.value;
|
|
243
|
+
if (answer === null || typeof answer !== "object" || answer.ok !== true)
|
|
244
|
+
return { ok: false, why: "failed" };
|
|
245
|
+
return { ok: true, value: answer.value, started: run.started };
|
|
246
|
+
}
|
|
247
|
+
finally {
|
|
248
|
+
clearTimeout(timer);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
const text = (value) => typeof value === "string" && value.length > 0 && value.length <= 4096 && !value.includes("\0");
|
|
252
|
+
/** A folder's sub-folders for the browse page (null `at`: outside every root, or not a folder). */
|
|
253
|
+
export async function listFolder(path, roots, options = {}) {
|
|
254
|
+
const looked = await lookFolders({ op: "list", path, roots: [...roots], home: options.home ?? homedir() }, options);
|
|
255
|
+
if (!looked.ok)
|
|
256
|
+
return looked;
|
|
257
|
+
const value = looked.value;
|
|
258
|
+
if (value === null || typeof value !== "object" || !(value.at === null || text(value.at)) || !Array.isArray(value.entries))
|
|
259
|
+
return { ok: false, why: "failed" };
|
|
260
|
+
const entries = value.entries.flatMap((one) => {
|
|
261
|
+
const row = one;
|
|
262
|
+
return row !== null && typeof row === "object" && text(row.name) && text(row.path) && typeof row.git === "boolean" ? [{ name: row.name, path: row.path, git: row.git }] : [];
|
|
263
|
+
});
|
|
264
|
+
return { ok: true, value: { at: value.at, entries } };
|
|
265
|
+
}
|
|
266
|
+
/** The origin URL of each registered project, and the repositories directly in each root with theirs. */
|
|
267
|
+
export async function localOrigins(registered, roots, options = {}) {
|
|
268
|
+
const looked = await lookFolders({ op: "origins", registered: [...registered], roots: [...roots], home: options.home ?? homedir() }, options);
|
|
269
|
+
if (!looked.ok)
|
|
270
|
+
return looked;
|
|
271
|
+
const value = looked.value;
|
|
272
|
+
if (value === null || typeof value !== "object" || !Array.isArray(value.registered) || !Array.isArray(value.found))
|
|
273
|
+
return { ok: false, why: "failed" };
|
|
274
|
+
const rows = (list) => list.flatMap((one) => {
|
|
275
|
+
const row = one;
|
|
276
|
+
return row !== null && typeof row === "object" && text(row.path) && (row.url === null || text(row.url)) ? [{ path: row.path, url: row.url }] : [];
|
|
277
|
+
});
|
|
278
|
+
return { ok: true, value: { registered: rows(value.registered), found: rows(value.found) } };
|
|
279
|
+
}
|
|
280
|
+
/** Each path's identity (its real path when that is a folder, else null), resolved off the thread. */
|
|
281
|
+
export async function lookIdentities(paths, options = {}) {
|
|
282
|
+
const looked = await lookFolders({ op: "identities", paths: [...paths] }, options);
|
|
283
|
+
if (!looked.ok)
|
|
284
|
+
return looked;
|
|
285
|
+
const value = looked.value;
|
|
286
|
+
if (value === null || typeof value !== "object" || !Array.isArray(value.paths))
|
|
287
|
+
return { ok: false, why: "failed" };
|
|
288
|
+
const out = new Map();
|
|
289
|
+
for (const one of value.paths) {
|
|
290
|
+
const row = one;
|
|
291
|
+
if (row !== null && typeof row === "object" && text(row.path) && (row.real === null || text(row.real)))
|
|
292
|
+
out.set(row.path, row.real);
|
|
293
|
+
}
|
|
294
|
+
return { ok: true, value: out, ...(looked.started === undefined ? {} : { started: looked.started }) };
|
|
295
|
+
}
|
|
296
|
+
/** A project's own `.mcp.json` (parsed object, or null when absent or not one), read off the thread. */
|
|
297
|
+
export async function lookProjectMcp(repo, options = {}) {
|
|
298
|
+
const looked = await lookFolders({ op: "projectMcp", path: repo }, options);
|
|
299
|
+
if (!looked.ok)
|
|
300
|
+
return looked;
|
|
301
|
+
const value = looked.value;
|
|
302
|
+
if (value === null || typeof value !== "object")
|
|
303
|
+
return { ok: false, why: "failed" };
|
|
304
|
+
const config = value.config;
|
|
305
|
+
return { ok: true, value: config !== null && typeof config === "object" && !Array.isArray(config) ? config : null };
|
|
306
|
+
}
|
package/dist/guides.js
CHANGED
|
@@ -287,9 +287,10 @@ ${AUTHORITY_LINE}
|
|
|
287
287
|
|
|
288
288
|
- One command, inside the repository: \`npx toolroll up\` (or
|
|
289
289
|
\`bunx toolroll up\`; Node 22.13+ must be installed — Bun's own
|
|
290
|
-
runtime cannot host it). \`up\` mints the first login and
|
|
291
|
-
|
|
292
|
-
|
|
290
|
+
runtime cannot host it). \`up\` mints the first login and saves it beside the
|
|
291
|
+
database as \`up-login.txt\` (every start prints that path and the
|
|
292
|
+
\`cat\` command that shows the password, never the password itself),
|
|
293
|
+
registers the machine as a worker, runs the console, opens the browser. Later starts ask for the
|
|
293
294
|
password at most once and remember it on that machine.
|
|
294
295
|
- From a phone over a tailnet: add \`--host 0.0.0.0 --allow-host
|
|
295
296
|
<name>:4180\`.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Started only by learning-look.ts: one project's learning recovery (project-learning.ts acceptedIdentities and
|
|
3
|
+
* recoverLearning) in a process of its own, so its synchronous reads of the project's Git folder can never hold the
|
|
4
|
+
* console's thread. It opens the database without migrating, does that one project, and ends; the console kills it
|
|
5
|
+
* at its deadline. Its one argument is JSON naming the database, the evidence folder and the project.
|
|
6
|
+
*/
|
|
7
|
+
import { acceptedIdentities, recoverLearning } from "./project-learning.js";
|
|
8
|
+
import { openStoreNoMigrate } from "./store.js";
|
|
9
|
+
const input = JSON.parse(process.argv[2] ?? "null");
|
|
10
|
+
const text = (value) => typeof value === "string" && value !== "" && !value.includes("\0");
|
|
11
|
+
if (input === null || !text(input.databaseFile) || !text(input.evidenceRoot) || !text(input.repo))
|
|
12
|
+
process.exit(2);
|
|
13
|
+
const opened = openStoreNoMigrate(input.databaseFile);
|
|
14
|
+
if (!opened.ok)
|
|
15
|
+
process.exit(3);
|
|
16
|
+
let code = 0;
|
|
17
|
+
try {
|
|
18
|
+
try {
|
|
19
|
+
acceptedIdentities(opened.store, input.repo);
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
code = 1;
|
|
23
|
+
}
|
|
24
|
+
try {
|
|
25
|
+
recoverLearning(opened.store, input.evidenceRoot, input.repo);
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
code = 1;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
finally {
|
|
32
|
+
opened.store.close();
|
|
33
|
+
}
|
|
34
|
+
process.exit(code);
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type FirstTaskSuggestion } from "./first-run.js";
|
|
2
|
+
/** How long one project's recovery may take before it is stopped (the next pass tries again). */
|
|
3
|
+
export declare const LEARNING_LOOK_MS = 60000;
|
|
4
|
+
/** How long reading one project's first-task suggestions may take. */
|
|
5
|
+
export declare const FIRST_TASKS_LOOK_MS = 20000;
|
|
6
|
+
export type LearningOutcome = "done" | "failed" | "no-answer";
|
|
7
|
+
export type LearningRunner = (input: {
|
|
8
|
+
databaseFile: string;
|
|
9
|
+
evidenceRoot: string;
|
|
10
|
+
repo: string;
|
|
11
|
+
}, deadlineMs: number) => Promise<LearningOutcome>;
|
|
12
|
+
/** The production learning runner: learning-child in its own process. */
|
|
13
|
+
export declare const learningChild: LearningRunner;
|
|
14
|
+
/** Recover learning for each project in turn, each in its own bounded process. */
|
|
15
|
+
export declare function recoverLearningOffThread(input: {
|
|
16
|
+
databaseFile: string;
|
|
17
|
+
evidenceRoot: string;
|
|
18
|
+
repos: readonly string[];
|
|
19
|
+
}, options?: {
|
|
20
|
+
runner?: LearningRunner;
|
|
21
|
+
deadlineMs?: number;
|
|
22
|
+
}): Promise<Map<string, LearningOutcome>>;
|
|
23
|
+
/** One project's first-task suggestions (first-run.ts findFirstTasks: its issues through gh, its TODOs through the
|
|
24
|
+
* hardened git) read in a process of its own. Rejects when it failed or didn't answer: the caller keeps the generic ones. */
|
|
25
|
+
export declare function firstTasksOffThread(repo: string, deadlineMs?: number): Promise<FirstTaskSuggestion[]>;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Work that reads a project's own folder synchronously, run in a process of its own and killed at a deadline, so a
|
|
3
|
+
* folder that doesn't answer (a disconnected drive, an iCloud folder macOS is holding) costs that one piece of work,
|
|
4
|
+
* never the console's thread: learning recovery (learning-child.ts) and Chat's first-task suggestions
|
|
5
|
+
* (first-tasks-child.ts).
|
|
6
|
+
*/
|
|
7
|
+
import { spawn } from "node:child_process";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import {} from "./first-run.js";
|
|
10
|
+
/** How long one project's recovery may take before it is stopped (the next pass tries again). */
|
|
11
|
+
export const LEARNING_LOOK_MS = 60_000;
|
|
12
|
+
/** How long reading one project's first-task suggestions may take. */
|
|
13
|
+
export const FIRST_TASKS_LOOK_MS = 20_000;
|
|
14
|
+
/** Only what git and gh need from the environment (no NODE_OPTIONS, no other secrets). */
|
|
15
|
+
const CHILD_ENV = ["PATH", "HOME", "TMPDIR", "LANG", "USER", "XDG_CONFIG_HOME", "GH_TOKEN", "GITHUB_TOKEN", "GH_HOST", "GH_CONFIG_DIR"];
|
|
16
|
+
/** One of this build's own child entries, given `input` as JSON, killed at the deadline. Its stdout, bounded. */
|
|
17
|
+
function ownChild(name, input, deadlineMs) {
|
|
18
|
+
return new Promise(resolveOutcome => {
|
|
19
|
+
const source = import.meta.url.endsWith(".ts");
|
|
20
|
+
const entry = fileURLToPath(new URL(source ? `./${name}.ts` : `./${name}.js`, import.meta.url));
|
|
21
|
+
const env = { ELECTRON_RUN_AS_NODE: "1" };
|
|
22
|
+
for (const one of CHILD_ENV)
|
|
23
|
+
if (process.env[one] !== undefined)
|
|
24
|
+
env[one] = process.env[one];
|
|
25
|
+
const child = spawn(process.execPath, [...(source ? ["--import", import.meta.resolve("tsx")] : []), entry, JSON.stringify(input)], {
|
|
26
|
+
stdio: ["ignore", "pipe", "ignore"], env, killSignal: "SIGKILL",
|
|
27
|
+
});
|
|
28
|
+
child.unref();
|
|
29
|
+
const chunks = [];
|
|
30
|
+
let size = 0;
|
|
31
|
+
let settled = false;
|
|
32
|
+
const settle = (outcome) => {
|
|
33
|
+
if (settled)
|
|
34
|
+
return;
|
|
35
|
+
settled = true;
|
|
36
|
+
clearTimeout(timer);
|
|
37
|
+
child.stdout.destroy();
|
|
38
|
+
resolveOutcome({ outcome, stdout: Buffer.concat(chunks).toString("utf8") });
|
|
39
|
+
};
|
|
40
|
+
const timer = setTimeout(() => { child.kill("SIGKILL"); settle("no-answer"); }, deadlineMs);
|
|
41
|
+
timer.unref();
|
|
42
|
+
child.stdout.on("data", (chunk) => { size += chunk.length; if (size <= 1024 * 1024)
|
|
43
|
+
chunks.push(chunk); });
|
|
44
|
+
child.once("error", () => settle("failed"));
|
|
45
|
+
child.once("close", code => settle(code === 0 ? "done" : "failed"));
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
/** The production learning runner: learning-child in its own process. */
|
|
49
|
+
export const learningChild = async (input, deadlineMs) => (await ownChild("learning-child", input, deadlineMs)).outcome;
|
|
50
|
+
/** Recover learning for each project in turn, each in its own bounded process. */
|
|
51
|
+
export async function recoverLearningOffThread(input, options = {}) {
|
|
52
|
+
const outcomes = new Map();
|
|
53
|
+
for (const repo of input.repos) {
|
|
54
|
+
outcomes.set(repo, await (options.runner ?? learningChild)({ databaseFile: input.databaseFile, evidenceRoot: input.evidenceRoot, repo }, options.deadlineMs ?? LEARNING_LOOK_MS));
|
|
55
|
+
}
|
|
56
|
+
return outcomes;
|
|
57
|
+
}
|
|
58
|
+
/** One project's first-task suggestions (first-run.ts findFirstTasks: its issues through gh, its TODOs through the
|
|
59
|
+
* hardened git) read in a process of its own. Rejects when it failed or didn't answer: the caller keeps the generic ones. */
|
|
60
|
+
export async function firstTasksOffThread(repo, deadlineMs = FIRST_TASKS_LOOK_MS) {
|
|
61
|
+
const ran = await ownChild("first-tasks-child", { repo }, deadlineMs);
|
|
62
|
+
if (ran.outcome !== "done")
|
|
63
|
+
throw new Error(`the first-task read ${ran.outcome === "no-answer" ? "didn't answer" : "failed"}`);
|
|
64
|
+
const parsed = JSON.parse(ran.stdout);
|
|
65
|
+
if (!Array.isArray(parsed))
|
|
66
|
+
throw new Error("the first-task read answered something else");
|
|
67
|
+
return parsed.flatMap((one) => {
|
|
68
|
+
const row = one;
|
|
69
|
+
return row !== null && typeof row === "object" && (row.source === "issue" || row.source === "todo" || row.source === "generic") && typeof row.label === "string" && typeof row.draft === "string"
|
|
70
|
+
? [{ source: row.source, label: row.label.slice(0, 500), draft: row.draft.slice(0, 4000) }] : [];
|
|
71
|
+
});
|
|
72
|
+
}
|
package/dist/model-catalog.d.ts
CHANGED
|
@@ -47,6 +47,11 @@ export declare const RUNTIME_TOOLS: Record<RuntimeTool, {
|
|
|
47
47
|
pkg: string;
|
|
48
48
|
}>;
|
|
49
49
|
export declare const WATCH_EVERY_MS: number;
|
|
50
|
+
/** The current Claude models by exact id, offered even before the first catalog check has run. */
|
|
51
|
+
export declare const CLAUDE_EXACT: readonly {
|
|
52
|
+
value: string;
|
|
53
|
+
label: string;
|
|
54
|
+
}[];
|
|
50
55
|
/** `anthropic/claude-opus-5.5` → `claude-opus-5-5`, the name the Claude CLI and API use. */
|
|
51
56
|
export declare function claudeIdOf(openrouterId: string): string | null;
|
|
52
57
|
/** The Codex CLI's own list — account-specific, maintained by the CLI. */
|
package/dist/model-catalog.js
CHANGED
|
@@ -58,6 +58,12 @@ export const WATCH_EVERY_MS = 6 * 3_600_000;
|
|
|
58
58
|
const NEW_FOR_MS = 14 * 86_400_000;
|
|
59
59
|
const FAMILIES = ["opus", "sonnet", "haiku", "fable"];
|
|
60
60
|
const ALIAS_WORDS = { opus: "complex tasks", sonnet: "everyday coding", haiku: "smaller tasks" };
|
|
61
|
+
/** The current Claude models by exact id, offered even before the first catalog check has run. */
|
|
62
|
+
export const CLAUDE_EXACT = Object.freeze([
|
|
63
|
+
{ value: "claude-opus-5-5", label: "Claude Opus 5.5" },
|
|
64
|
+
{ value: "claude-sonnet-5-5", label: "Claude Sonnet 5.5" },
|
|
65
|
+
{ value: "claude-haiku-5-5", label: "Claude Haiku 5.5" },
|
|
66
|
+
]);
|
|
61
67
|
const title = (word) => word.charAt(0).toUpperCase() + word.slice(1);
|
|
62
68
|
const stripVendor = (name) => name.replace(/^(Anthropic|OpenAI|Google): /, "");
|
|
63
69
|
/** `anthropic/claude-opus-5.5` → `claude-opus-5-5`, the name the Claude CLI and API use. */
|
|
@@ -182,12 +188,15 @@ export function modelWords(store, provider, model) {
|
|
|
182
188
|
export function modelOptions(store, provider, now, home = homedir()) {
|
|
183
189
|
const option = (model) => ({ value: model.id, label: `${model.name} · ${priceWords(model)}${isNewModel(model, now) ? " · New" : ""}`, isNew: isNewModel(model, now) });
|
|
184
190
|
if (provider === "claude") {
|
|
191
|
+
// Exact ids first (Settings → Models presets write only these); the short names float to the newest version.
|
|
192
|
+
const seen = seenModels(store, "claude").slice(0, 20);
|
|
193
|
+
const builtIn = CLAUDE_EXACT.filter(one => !seen.some(model => model.id === one.value)).map(one => ({ ...one, isNew: false }));
|
|
185
194
|
const aliases = ["sonnet", "opus", "haiku"].map(alias => {
|
|
186
195
|
const target = aliasTarget(store, alias);
|
|
187
196
|
// Short enough that a phone's select still shows which model it means.
|
|
188
|
-
return { value: alias, label: target === null ? `${title(alias)} — ${ALIAS_WORDS[alias]}` : `${title(alias)} ·
|
|
197
|
+
return { value: alias, label: target === null ? `${title(alias)} (floating) — ${ALIAS_WORDS[alias]}` : `${title(alias)} (floating) · now ${target.name.replace(/^Claude /, "")}`, isNew: false };
|
|
189
198
|
});
|
|
190
|
-
return [...
|
|
199
|
+
return [...seen.map(option), ...builtIn, ...aliases];
|
|
191
200
|
}
|
|
192
201
|
if (provider === "codex") {
|
|
193
202
|
const live = codexCatalog(home);
|