toolroll 0.9.89 → 0.9.90
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 +44 -0
- package/README.md +16 -10
- package/THIRD_PARTY_NOTICES.md +37 -0
- package/dist/agent-board.d.ts +9 -0
- package/dist/agent-board.js +20 -0
- package/dist/agent-fence.js +20 -2
- package/dist/approval-authority.d.ts +7 -0
- package/dist/approval-authority.js +13 -2
- package/dist/browser/THIRD_PARTY_NOTICES.txt +692 -28
- package/dist/browser/terminal.js +42 -42
- package/dist/browser/workspace.css +1 -1
- package/dist/browser/workspace.js +32 -32
- package/dist/browser-workspace.d.ts +13 -0
- package/dist/builder.js +5 -2
- package/dist/chat-actions.d.ts +5 -0
- package/dist/chat-actions.js +7 -1
- package/dist/chat-delivery.js +2 -0
- package/dist/chat-task-actions.d.ts +1 -0
- package/dist/check-access.d.ts +3 -0
- package/dist/check-access.js +4 -1
- package/dist/cli-help.d.ts +1 -1
- package/dist/cli-help.js +6 -2
- package/dist/cli.d.ts +6 -0
- package/dist/cli.js +23 -0
- package/dist/coding-ui.js +4 -2
- package/dist/connect-cli.js +2 -1
- package/dist/console-instance.d.ts +34 -1
- package/dist/console-instance.js +124 -15
- package/dist/contracts/cli.d.ts +72 -0
- package/dist/contracts/cli.js +13 -0
- package/dist/contracts/console-api.d.ts +9 -2
- package/dist/contracts/console-api.js +9 -2
- package/dist/desktop-host.js +8 -3
- package/dist/desktop-update.js +2 -1
- package/dist/dispatch.d.ts +3 -0
- package/dist/dispatch.js +22 -2
- package/dist/easy-start-args.d.ts +13 -0
- package/dist/easy-start-args.js +24 -0
- package/dist/easy-start.d.ts +77 -0
- package/dist/easy-start.js +321 -0
- package/dist/first-run.d.ts +77 -0
- package/dist/first-run.js +45 -0
- package/dist/guides.js +1 -1
- package/dist/invoke.js +11 -6
- package/dist/laptop-background.d.ts +171 -0
- package/dist/laptop-background.js +550 -0
- package/dist/laptop-service.d.ts +111 -0
- package/dist/laptop-service.js +268 -0
- package/dist/lead-cli.js +2 -1
- package/dist/lead-doors.d.ts +6 -0
- package/dist/lead-doors.js +20 -2
- package/dist/live-records.d.ts +1 -1
- package/dist/live-records.js +3 -12
- package/dist/local-owner.d.ts +124 -0
- package/dist/local-owner.js +212 -0
- package/dist/login-secrets.d.ts +60 -0
- package/dist/login-secrets.js +195 -0
- package/dist/mcp-oauth.d.ts +4 -0
- package/dist/mcp-oauth.js +13 -7
- package/dist/model-choices.d.ts +43 -0
- package/dist/model-choices.js +80 -0
- package/dist/operate.d.ts +46 -1
- package/dist/operate.js +461 -40
- package/dist/person-proof.js +3 -0
- package/dist/project-knowledge.d.ts +7 -7
- package/dist/quick-start.d.ts +3 -1
- package/dist/quick-start.js +6 -1
- package/dist/release-cli.js +2 -1
- package/dist/runner-process.d.ts +9 -0
- package/dist/runner-process.js +18 -0
- package/dist/sample-project.d.ts +31 -0
- package/dist/sample-project.js +141 -0
- package/dist/scope.d.ts +24 -2
- package/dist/scope.js +74 -3
- package/dist/serve.js +129 -22
- package/dist/server/chat.js +10 -3
- package/dist/server/chrome.d.ts +10 -2
- package/dist/server/chrome.js +7 -5
- package/dist/server/console.css +10 -0
- package/dist/server/guards.d.ts +3 -0
- package/dist/server/guards.js +22 -2
- package/dist/server/http.d.ts +22 -0
- package/dist/server/http.js +58 -0
- package/dist/server/live.js +4 -14
- package/dist/server/mutation-key.d.ts +70 -0
- package/dist/server/mutation-key.js +142 -0
- package/dist/server/pages.js +10 -7
- package/dist/server/people-tokens.js +240 -20
- package/dist/server/remote.d.ts +2 -0
- package/dist/server/remote.js +19 -4
- package/dist/server/render-new-task.js +5 -1
- package/dist/server/render-pages.d.ts +2 -1
- package/dist/server/render-pages.js +1 -1
- package/dist/server/render-people.d.ts +36 -0
- package/dist/server/render-people.js +65 -1
- package/dist/server/render-projects.js +4 -2
- package/dist/server/render-tasks.d.ts +12 -0
- package/dist/server/render-tasks.js +12 -4
- package/dist/server/request-context.d.ts +6 -0
- package/dist/server/route-table.d.ts +16 -0
- package/dist/server/route-table.js +121 -0
- package/dist/server/runtime.d.ts +6 -4
- package/dist/server/session.d.ts +31 -1
- package/dist/server/session.js +12 -2
- package/dist/server/settings.js +63 -8
- package/dist/server/task-views.js +10 -1
- package/dist/server/tasks.d.ts +10 -0
- package/dist/server/tasks.js +59 -9
- package/dist/server/terminals.js +15 -2
- package/dist/server/welcome.d.ts +22 -16
- package/dist/server/welcome.js +150 -52
- package/dist/settings-groups-ui.d.ts +7 -1
- package/dist/settings-groups-ui.js +23 -2
- package/dist/settings-proofs.js +1 -0
- package/dist/setup-guide.d.ts +2 -2
- package/dist/store.d.ts +124 -1
- package/dist/store.js +245 -12
- package/dist/surface.d.ts +10 -0
- package/dist/surface.js +40 -1
- package/dist/task-text.d.ts +6 -0
- package/dist/task-text.js +17 -0
- package/dist/telegram-lead.js +2 -0
- package/dist/terminal-text.d.ts +11 -0
- package/dist/terminal-text.js +26 -0
- package/dist/toolroll-update.d.ts +8 -1
- package/dist/toolroll-update.js +151 -4
- package/dist/tui-contract.d.ts +118 -0
- package/dist/tui-contract.js +9 -0
- package/dist/tui-source.d.ts +14 -0
- package/dist/tui-source.js +182 -0
- package/dist/tui.js +129 -0
- package/dist/up-processes.d.ts +13 -1
- package/dist/up-processes.js +13 -3
- package/dist/welcome-facts.d.ts +18 -0
- package/dist/welcome-facts.js +79 -0
- package/package.json +4 -2
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The laptop-mode login item, generated and compared — never installed.
|
|
3
|
+
*
|
|
4
|
+
* Laptop mode keeps one `toolroll up --laptop` console running for the
|
|
5
|
+
* person who is logged in: a launchd LaunchAgent on macOS
|
|
6
|
+
* (~/Library/LaunchAgents/com.toolroll.up.plist), a systemd user unit on
|
|
7
|
+
* Linux (~/.config/systemd/user/toolroll-up.service). This module only
|
|
8
|
+
* computes what that definition would be, whether an installed one matches,
|
|
9
|
+
* and which existing services would conflict with it. Nothing here writes a
|
|
10
|
+
* unit, loads one, or calls launchctl or systemctl; the handoff that does
|
|
11
|
+
* (`toolroll background on`) is laptop-background.ts.
|
|
12
|
+
*
|
|
13
|
+
* The command is pinned with absolute paths, as daemon.ts pins its services:
|
|
14
|
+
* launchd and systemd do not inherit an interactive shell's PATH, so the unit
|
|
15
|
+
* runs the absolute Node binary with the package entry, and the database is
|
|
16
|
+
* the canonical path (realpath of its folder) so a symlinked folder and its
|
|
17
|
+
* target are one database. Values with control characters are refused: a
|
|
18
|
+
* newline would end a systemd line and a NUL cannot appear in either format.
|
|
19
|
+
*
|
|
20
|
+
* Conflicts are reported, never touched: the desktop/browser service
|
|
21
|
+
* (com.toolroll.browser, and com.standing-orders.browser from before the
|
|
22
|
+
* rename) and any `daemon install` watch whose unit points at the same
|
|
23
|
+
* database. Whatever cannot be read counts as a conflict, so the caller asks
|
|
24
|
+
* instead of assuming.
|
|
25
|
+
*/
|
|
26
|
+
import { existsSync, readFileSync, readdirSync, realpathSync } from "node:fs";
|
|
27
|
+
// POSIX paths throughout: a LaunchAgent and a systemd unit only ever name POSIX paths.
|
|
28
|
+
import { posix } from "node:path";
|
|
29
|
+
import { definitionDigest, launchdPlist } from "./daemon.js";
|
|
30
|
+
const { basename, dirname, isAbsolute, join } = posix;
|
|
31
|
+
export const LAPTOP_LABEL_DARWIN = "com.toolroll.up";
|
|
32
|
+
export const LAPTOP_LABEL_LINUX = "toolroll-up";
|
|
33
|
+
/** The services laptop mode never runs beside on macOS: the desktop app's browser controller, either name. */
|
|
34
|
+
export const BROWSER_SERVICE_LABELS = ["com.toolroll.browser", "com.standing-orders.browser"];
|
|
35
|
+
/** The watch labels daemon.ts writes (labelFor / legacyLabelFor). */
|
|
36
|
+
const WATCH_PREFIXES = ["com.toolroll.watch.", "com.standing-orders.watch."];
|
|
37
|
+
export const nodeFs = {
|
|
38
|
+
exists: existsSync,
|
|
39
|
+
readFile: path => readFileSync(path, "utf8"),
|
|
40
|
+
readdir: path => readdirSync(path),
|
|
41
|
+
realpath: path => realpathSync(path),
|
|
42
|
+
};
|
|
43
|
+
// Every C0 control character and DEL: newline and NUL are the dangerous ones,
|
|
44
|
+
// the rest are never part of a real path and are not valid XML 1.0 either.
|
|
45
|
+
const CONTROL = /[\u0000-\u001f\u007f]/;
|
|
46
|
+
function checkPath(name, value) {
|
|
47
|
+
if (value === "")
|
|
48
|
+
return `${name} is empty`;
|
|
49
|
+
if (CONTROL.test(value))
|
|
50
|
+
return `${name} contains a newline, NUL or other control character`;
|
|
51
|
+
if (!isAbsolute(value))
|
|
52
|
+
return `${name} must be an absolute path (got ${value})`;
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* The database path the unit names: its folder resolved through realpath,
|
|
57
|
+
* its file name kept (the file may not exist yet). A relative, empty or
|
|
58
|
+
* control-character path, or a folder that cannot be resolved, refuses.
|
|
59
|
+
*/
|
|
60
|
+
export function canonicalDatabasePath(db, fs = nodeFs) {
|
|
61
|
+
const problem = checkPath("the database path", db);
|
|
62
|
+
if (problem !== null)
|
|
63
|
+
return { error: problem };
|
|
64
|
+
const name = basename(db);
|
|
65
|
+
if (db.endsWith("/") || name === "" || name === "." || name === "..")
|
|
66
|
+
return { error: `the database path ${db} does not name a file` };
|
|
67
|
+
let folder;
|
|
68
|
+
try {
|
|
69
|
+
folder = fs.realpath(dirname(db));
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return { error: `the database folder ${dirname(db)} cannot be resolved` };
|
|
73
|
+
}
|
|
74
|
+
const canonical = join(folder, name);
|
|
75
|
+
const after = checkPath("the database path", canonical);
|
|
76
|
+
return after === null ? canonical : { error: after };
|
|
77
|
+
}
|
|
78
|
+
function unitPathFor(platform, home) {
|
|
79
|
+
return platform === "darwin"
|
|
80
|
+
? { label: LAPTOP_LABEL_DARWIN, path: join(home, "Library", "LaunchAgents", `${LAPTOP_LABEL_DARWIN}.plist`) }
|
|
81
|
+
: { label: LAPTOP_LABEL_LINUX, path: join(home, ".config", "systemd", "user", `${LAPTOP_LABEL_LINUX}.service`) };
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* The login item for laptop mode, computed without writing anything. On
|
|
85
|
+
* macOS the text comes from daemon.ts's launchdPlist, so it carries the same
|
|
86
|
+
* KeepAlive/ExitTimeOut contract and the same service digest (which binds the
|
|
87
|
+
* runtime's and package's code, read only). On Linux it is a systemd user
|
|
88
|
+
* unit in daemon.ts's shape, with systemd's `%` and `$` expansion escaped.
|
|
89
|
+
*/
|
|
90
|
+
export function laptopServiceDefinition(args) {
|
|
91
|
+
const { platform } = args;
|
|
92
|
+
if (platform !== "darwin" && platform !== "linux")
|
|
93
|
+
return { error: `laptop mode has no login item for ${platform}; keep \`toolroll up\` running yourself` };
|
|
94
|
+
for (const [name, value] of [["the home folder", args.home], ["the Node binary", args.node], ["the package folder", args.dist]]) {
|
|
95
|
+
const problem = checkPath(name, value);
|
|
96
|
+
if (problem !== null)
|
|
97
|
+
return { error: problem };
|
|
98
|
+
}
|
|
99
|
+
if (!Number.isSafeInteger(args.port) || args.port < 1 || args.port > 65535)
|
|
100
|
+
return { error: `the port must be a whole number from 1 to 65535 (got ${args.port})` };
|
|
101
|
+
const pathEnv = args.pathEnv ?? process.env["PATH"] ?? "";
|
|
102
|
+
if (CONTROL.test(pathEnv))
|
|
103
|
+
return { error: "the PATH contains a newline, NUL or other control character" };
|
|
104
|
+
const db = canonicalDatabasePath(args.db, args.fs ?? nodeFs);
|
|
105
|
+
if (typeof db !== "string")
|
|
106
|
+
return db;
|
|
107
|
+
const entry = join(args.dist, "bin.js");
|
|
108
|
+
const command = [args.node, entry, "up", "--laptop", "--no-open", "--db", db, "--port", String(args.port)];
|
|
109
|
+
const stateDir = dirname(db);
|
|
110
|
+
const { label, path } = unitPathFor(platform, args.home);
|
|
111
|
+
const logPath = join(stateDir, "logs", `${label}.log`);
|
|
112
|
+
const text = platform === "darwin"
|
|
113
|
+
? launchdPlist({ label, command, workingDirectory: stateDir, pathEnv, logPath })
|
|
114
|
+
: systemdUnit({ command, workingDirectory: stateDir, pathEnv, logPath });
|
|
115
|
+
return { platform, label, path, text, digest: definitionDigest(text), command, db, logPath };
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* The systemd user unit: daemon.ts's watch unit shape (Restart=always,
|
|
119
|
+
* default.target) plus TimeoutStopSec=60, launchd's ExitTimeOut, so a stop
|
|
120
|
+
* gets the same drain window on both platforms.
|
|
121
|
+
*/
|
|
122
|
+
function systemdUnit(args) {
|
|
123
|
+
return `[Unit]
|
|
124
|
+
Description=toolroll laptop console
|
|
125
|
+
|
|
126
|
+
[Service]
|
|
127
|
+
Environment=${systemdQuote(`PATH=${args.pathEnv}`, false)}
|
|
128
|
+
ExecStart=${args.command.map(part => systemdQuote(part, true)).join(" ")}
|
|
129
|
+
WorkingDirectory=${systemdSpecifiers(args.workingDirectory)}
|
|
130
|
+
Restart=always
|
|
131
|
+
RestartSec=15
|
|
132
|
+
TimeoutStopSec=60
|
|
133
|
+
|
|
134
|
+
StandardOutput=append:${systemdSpecifiers(args.logPath)}
|
|
135
|
+
StandardError=append:${systemdSpecifiers(args.logPath)}
|
|
136
|
+
|
|
137
|
+
[Install]
|
|
138
|
+
WantedBy=default.target
|
|
139
|
+
`;
|
|
140
|
+
}
|
|
141
|
+
/** `%` starts a specifier in every systemd setting. */
|
|
142
|
+
function systemdSpecifiers(text) {
|
|
143
|
+
return text.replace(/%/g, "%%");
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* daemon.ts's quoting (double quotes around anything with whitespace,
|
|
147
|
+
* quotes or backslashes; `"` and `\` escaped), plus `%%` everywhere and, in
|
|
148
|
+
* a command line, `$$`, since ExecStart expands `$NAME`.
|
|
149
|
+
*/
|
|
150
|
+
function systemdQuote(part, command) {
|
|
151
|
+
let text = systemdSpecifiers(part);
|
|
152
|
+
if (command)
|
|
153
|
+
text = text.replace(/\$/g, "$$$$");
|
|
154
|
+
return /[\s"'\\]/.test(text) || text === "" ? `"${text.replace(/(["\\])/g, "\\$1")}"` : text;
|
|
155
|
+
}
|
|
156
|
+
/** Whether an installed unit's text is exactly this definition. Null (or undefined) text means no unit is installed. */
|
|
157
|
+
export function compareInstalled(existingText, definition) {
|
|
158
|
+
if (existingText === null || existingText === undefined)
|
|
159
|
+
return "absent";
|
|
160
|
+
return existingText === definition.text && definitionDigest(existingText) === definition.digest ? "same" : "different";
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Existing services laptop mode must not run beside: the browser service on
|
|
164
|
+
* macOS (whatever database it uses — it is a console of its own), and any
|
|
165
|
+
* daemon.ts watch whose unit points at this database, by `--db` or by its
|
|
166
|
+
* `--token-file` sitting in the database's folder (daemon install keeps the
|
|
167
|
+
* runner token beside the database it was installed from). A unit that
|
|
168
|
+
* cannot be read is listed too. Laptop mode's own unit is never a conflict.
|
|
169
|
+
*/
|
|
170
|
+
export function detectOtherServices(args) {
|
|
171
|
+
const fs = args.fs ?? nodeFs;
|
|
172
|
+
if (args.platform !== "darwin" && args.platform !== "linux")
|
|
173
|
+
return [];
|
|
174
|
+
const homeProblem = checkPath("the home folder", args.home);
|
|
175
|
+
if (homeProblem !== null)
|
|
176
|
+
return { error: homeProblem };
|
|
177
|
+
const db = canonicalDatabasePath(args.db, fs);
|
|
178
|
+
if (typeof db !== "string")
|
|
179
|
+
return db;
|
|
180
|
+
const conflicts = [];
|
|
181
|
+
const darwin = args.platform === "darwin";
|
|
182
|
+
const folder = darwin ? join(args.home, "Library", "LaunchAgents") : join(args.home, ".config", "systemd", "user");
|
|
183
|
+
const suffix = darwin ? ".plist" : ".service";
|
|
184
|
+
if (darwin) {
|
|
185
|
+
for (const label of BROWSER_SERVICE_LABELS) {
|
|
186
|
+
const path = join(folder, `${label}${suffix}`);
|
|
187
|
+
if (fs.exists(path))
|
|
188
|
+
conflicts.push({ kind: "browser", label, path, reason: `the Toolroll app's service ${label} is installed` });
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
let names = [];
|
|
192
|
+
try {
|
|
193
|
+
if (fs.exists(folder))
|
|
194
|
+
names = fs.readdir(folder);
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
return { error: `${folder} cannot be listed` };
|
|
198
|
+
}
|
|
199
|
+
for (const name of [...names].sort()) {
|
|
200
|
+
if (!name.endsWith(suffix) || !WATCH_PREFIXES.some(prefix => name.startsWith(prefix)))
|
|
201
|
+
continue;
|
|
202
|
+
const label = name.slice(0, -suffix.length);
|
|
203
|
+
const path = join(folder, name);
|
|
204
|
+
let text;
|
|
205
|
+
try {
|
|
206
|
+
text = fs.readFile(path);
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
conflicts.push({ kind: "watch", label, path, reason: `the watch ${label} could not be read, so it may use this database` });
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
const argv = darwin ? plistProgramArguments(text) : systemdExecStart(text);
|
|
213
|
+
const target = watchDatabase(argv, fs);
|
|
214
|
+
if (target === "unknown") {
|
|
215
|
+
conflicts.push({ kind: "watch", label, path, reason: `the watch ${label} does not say which database it uses` });
|
|
216
|
+
}
|
|
217
|
+
else if (target.db !== undefined ? target.db === db : target.folder === dirname(db)) {
|
|
218
|
+
conflicts.push({ kind: "watch", label, path, reason: `the watch ${label} runs on this database` });
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return conflicts;
|
|
222
|
+
}
|
|
223
|
+
/** Which database a watch's command line names: its `--db`, else the folder of its `--token-file`. */
|
|
224
|
+
function watchDatabase(argv, fs) {
|
|
225
|
+
const value = (flag) => {
|
|
226
|
+
const at = argv.indexOf(flag);
|
|
227
|
+
if (at >= 0)
|
|
228
|
+
return argv[at + 1];
|
|
229
|
+
return argv.find(part => part.startsWith(`${flag}=`))?.slice(flag.length + 1);
|
|
230
|
+
};
|
|
231
|
+
const dbFlag = value("--db");
|
|
232
|
+
if (dbFlag !== undefined) {
|
|
233
|
+
const db = canonicalDatabasePath(dbFlag, fs);
|
|
234
|
+
return typeof db === "string" ? { db } : "unknown";
|
|
235
|
+
}
|
|
236
|
+
const tokenFile = value("--token-file");
|
|
237
|
+
if (tokenFile === undefined || !isAbsolute(tokenFile))
|
|
238
|
+
return "unknown";
|
|
239
|
+
try {
|
|
240
|
+
return { folder: fs.realpath(dirname(tokenFile)) };
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
return { folder: dirname(tokenFile) };
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
/** ProgramArguments from a plist daemon.ts wrote. */
|
|
247
|
+
function plistProgramArguments(text) {
|
|
248
|
+
const array = /<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/.exec(text)?.[1] ?? "";
|
|
249
|
+
return [...array.matchAll(/<string>([^<]*)<\/string>/g)].map(match => unxml(match[1] ?? ""));
|
|
250
|
+
}
|
|
251
|
+
/** ExecStart from a systemd unit, split as systemd would for the quoting daemon.ts and this module write. */
|
|
252
|
+
function systemdExecStart(text) {
|
|
253
|
+
const line = /^ExecStart=(.*)$/m.exec(text)?.[1] ?? "";
|
|
254
|
+
return (line.match(/"(?:[^"\\]|\\.)*"|\S+/g) ?? []).map(part => (part.startsWith('"') ? part.slice(1, -1).replace(/\\(["\\])/g, "$1") : part).replace(/%%/g, "%").replace(/\$\$/g, "$"));
|
|
255
|
+
}
|
|
256
|
+
function unxml(text) {
|
|
257
|
+
return text.replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/g, "'").replace(/&/g, "&");
|
|
258
|
+
}
|
|
259
|
+
/** The command an installed unit runs, argument by argument (a plist's ProgramArguments, a unit's ExecStart). */
|
|
260
|
+
export function unitCommand(text, platform) {
|
|
261
|
+
return platform === "darwin" ? plistProgramArguments(text) : systemdExecStart(text);
|
|
262
|
+
}
|
|
263
|
+
/** The value after `flag` in an installed unit's command, or null. */
|
|
264
|
+
export function unitFlag(text, platform, flag) {
|
|
265
|
+
const argv = unitCommand(text, platform);
|
|
266
|
+
const at = argv.indexOf(flag);
|
|
267
|
+
return at >= 0 && argv[at + 1] !== undefined ? argv[at + 1] : null;
|
|
268
|
+
}
|
package/dist/lead-cli.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { currentLocalOwner } from "./local-owner.js";
|
|
1
2
|
import { parseFeedbackImage } from "./result-review.js";
|
|
2
3
|
import { configureLeadFollow, leadFollowStatus } from './lead-follow.js';
|
|
3
4
|
import { closedQuestionWords } from "./decision.js";
|
|
@@ -324,7 +325,7 @@ export async function runLeadCli(input) {
|
|
|
324
325
|
say(done ? `dismissed ${act[2]}` : "that proposal was already acted on");
|
|
325
326
|
}
|
|
326
327
|
else {
|
|
327
|
-
const outcome = confirmLeadProposal(store, who, proposal.id, now, { confirm: act[3] !== undefined, via: "cli", chatProvider: () => {
|
|
328
|
+
const outcome = confirmLeadProposal(store, who, proposal.id, now, { authority: currentLocalOwner() === null ? "full" : "local-owner", confirm: act[3] !== undefined, via: "cli", chatProvider: () => {
|
|
328
329
|
const current = store.getChatConfig();
|
|
329
330
|
return current === null ? null : { config: current, key: isDirectChatProvider(current.provider) ? keyFor(current, input.databaseFile, env) : null };
|
|
330
331
|
}, ...(input.evidenceRoot === undefined ? {} : { evidenceRoot: input.evidenceRoot }) });
|
package/dist/lead-doors.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { type SharedActionOptions } from './chat-actions.js';
|
|
|
2
2
|
import { type LeadProposal, type Store } from "./store.js";
|
|
3
3
|
import type { VerifiedApprover } from "./principal.js";
|
|
4
4
|
import { type TeamChatProviderResolver } from './team-chat-authorization.js';
|
|
5
|
+
import { type Authority } from "./local-owner.js";
|
|
5
6
|
export type ProposalKind = LeadProposal["kind"];
|
|
6
7
|
export type DoorOutcome = {
|
|
7
8
|
ok: true;
|
|
@@ -19,6 +20,11 @@ export type DoorOutcome = {
|
|
|
19
20
|
};
|
|
20
21
|
export type DoorRefusal = "standing" | "not-yours" | "ceiling-changed" | "not-pending" | "session-ended" | "turn-not-answered" | "turn-running" | "not-confirmable" | "needs-confirm" | "stale" | "claimed" | "not-queued" | "unknown-task" | "unknown-decision" | "already-answered" | "expired" | "outside-ceiling" | "refused";
|
|
21
22
|
export type DoorOptions = {
|
|
23
|
+
/**
|
|
24
|
+
* Easy-start C1: whose authority confirms — a full session (or the CLI, a paired phone, a chat relay), or a laptop's
|
|
25
|
+
* local owner. Named by every caller; the door also reads the local owner its chain acts as, and the stricter wins.
|
|
26
|
+
*/
|
|
27
|
+
authority: Authority;
|
|
22
28
|
/** Resolved inside the transaction. Without it, only current subscription terms are available. */
|
|
23
29
|
chatProvider?: TeamChatProviderResolver;
|
|
24
30
|
evidenceRoot?: string;
|
package/dist/lead-doors.js
CHANGED
|
@@ -30,6 +30,14 @@ import { isTaskSize, PHASES, specWords } from "./phase-routing.js";
|
|
|
30
30
|
import { isProviderId } from "./provider.js";
|
|
31
31
|
import { applyChatReview } from "./chat-review.js";
|
|
32
32
|
import { applyChatTaskAction } from "./chat-task-actions.js";
|
|
33
|
+
import { currentLocalOwner, localOwnerRefusal, proposalLocalAct, withLocalAct } from "./local-owner.js";
|
|
34
|
+
/** Easy-start C1: whether this door is a local owner's — named by the caller, or the chain's own (the stricter wins). */
|
|
35
|
+
function localDoor(options) {
|
|
36
|
+
return options.authority !== "full" || currentLocalOwner() !== null;
|
|
37
|
+
}
|
|
38
|
+
function localDoorWords(store, name) {
|
|
39
|
+
return localOwnerRefusal(store.accountOf(name)?.passwordSet === true, "/chat").message;
|
|
40
|
+
}
|
|
33
41
|
/** A coordinator proposal lives seven days (§9); the door refuses an older one whatever the sweep did (v3 review, finding 3). */
|
|
34
42
|
export const COORDINATOR_PROPOSAL_TTL_MS = 7 * 24 * 3_600_000;
|
|
35
43
|
function payloadString(payload, key) {
|
|
@@ -154,6 +162,11 @@ export function confirmLeadProposal(store, who, proposalId, now, options) {
|
|
|
154
162
|
if (sharedActionNeedsReview(action) && !challenged && (options.via !== "web" || options.actionReview === undefined || options.confirm !== true))
|
|
155
163
|
return { ok: false, kind: proposal.kind, reason: "needs-confirm", said: "Review this action in the secure confirmation screen." };
|
|
156
164
|
}
|
|
165
|
+
// Easy-start C1, before the CAS: a local owner confirms only a kind it may (LOCAL_OWNER_PROPOSALS) and, for an
|
|
166
|
+
// operation kind, only an operation that is one of its acts. The card stays pending for a full session.
|
|
167
|
+
const localAct = localDoor(options) ? proposalLocalAct(proposal.kind, proposal.kind === "action" ? sharedActionPayload(proposal.payload)?.operation : proposal.payload["operation"]) : null;
|
|
168
|
+
if (localDoor(options) && localAct === null)
|
|
169
|
+
return { ok: false, kind: proposal.kind, reason: "refused", said: localDoorWords(store, who.name) };
|
|
157
170
|
// An irreversible answer takes the explicit field BEFORE the CAS: a
|
|
158
171
|
// missing confirmation leaves the card pending, not refused.
|
|
159
172
|
const needsConfirm = proposal.kind === "answer" && proposal.payload["reversible"] === false && options.confirm !== true;
|
|
@@ -162,9 +175,11 @@ export function confirmLeadProposal(store, who, proposalId, now, options) {
|
|
|
162
175
|
if (!store.casLeadProposal(proposalId, "pending", "confirming", who.name, null, now)) {
|
|
163
176
|
return { ok: false, kind: proposal.kind, reason: "not-pending", said: "this proposal was already acted on" };
|
|
164
177
|
}
|
|
178
|
+
// Both executors run as the card's own act: the prover answers "local-owner" only for it.
|
|
179
|
+
const execute = (body) => localAct === null ? body() : withLocalAct(localAct, body);
|
|
165
180
|
const outcome = proposal.kind === "action"
|
|
166
|
-
? { ...executeSharedAction(store, who, proposal.id, sharedActionPayload(proposal.payload), now, { via: options.via, ...(options.evidenceRoot === undefined ? {} : { root: options.evidenceRoot }), ...(options.actionReview === undefined ? {} : { review: options.actionReview }), ...(options.confirm === undefined ? {} : { confirm: options.confirm }) }), kind: proposal.kind }
|
|
167
|
-
: executeProposal(store, who, proposal.kind, proposal.payload, now, { ...options, deferSignal: defer });
|
|
181
|
+
? { ...execute(() => executeSharedAction(store, who, proposal.id, sharedActionPayload(proposal.payload), now, { via: options.via, authority: localAct === null ? "full" : "local-owner", ...(options.evidenceRoot === undefined ? {} : { root: options.evidenceRoot }), ...(options.actionReview === undefined ? {} : { review: options.actionReview }), ...(options.confirm === undefined ? {} : { confirm: options.confirm }) })), kind: proposal.kind }
|
|
182
|
+
: execute(() => executeProposal(store, who, proposal.kind, proposal.payload, now, { ...options, deferSignal: defer }));
|
|
168
183
|
if (!outcome.ok && outcome.reason === "needs-confirm") {
|
|
169
184
|
store.casLeadProposal(proposalId, "confirming", "pending", null, null, now);
|
|
170
185
|
return outcome;
|
|
@@ -221,6 +236,9 @@ export function confirmCoordinatorProposal(store, who, proposalId, now, options)
|
|
|
221
236
|
if (!isVerifiedApprover(who))
|
|
222
237
|
return { ok: false, kind: null, reason: "standing", said: "your approver standing changed — sign in again" };
|
|
223
238
|
const signals = [];
|
|
239
|
+
// Easy-start C1: a coordinator's proposal is never a local owner's to confirm (work a coordinator files needs a password).
|
|
240
|
+
if (localDoor(options))
|
|
241
|
+
return { ok: false, kind: null, reason: "refused", said: localDoorWords(store, who.name) };
|
|
224
242
|
const result = store.transact(() => {
|
|
225
243
|
if (!reproveApprover(store, who).ok)
|
|
226
244
|
return { ok: false, kind: null, reason: "standing", said: "your approver standing changed — sign in again" };
|
package/dist/live-records.d.ts
CHANGED
|
@@ -64,7 +64,7 @@ export declare const OUTSIDE_PROJECT = "a file outside the project";
|
|
|
64
64
|
export declare const SOME_FILE = "a file";
|
|
65
65
|
/** Whether a line looks like it carries a credential (best effort). */
|
|
66
66
|
export declare function liveSecretHit(line: string): boolean;
|
|
67
|
-
/** Controls, ANSI escapes and bidi overrides out; tabs to spaces. */
|
|
67
|
+
/** Controls, ANSI escapes and bidi overrides out; tabs to spaces (terminal-text.ts, the one sanitizer). */
|
|
68
68
|
export declare function cleanLiveText(text: string): string;
|
|
69
69
|
/** An escape a line ended in the middle of, which the next line finishes: CSI, OSC, or a bare ESC. */
|
|
70
70
|
export type EscapeCarry = "" | "C" | "O" | "E";
|
package/dist/live-records.js
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
17
17
|
import { scanForSecrets } from "./evidence.js";
|
|
18
18
|
import { topLevelEvent } from "./stream-scope.js";
|
|
19
|
+
import { cleanTerminalLine } from "./terminal-text.js";
|
|
19
20
|
export const LIVE_ACTIVITY_KINDS = ["session", "message", "edit", "command", "read", "search", "lookup", "plan", "delegate", "tool"];
|
|
20
21
|
/** A record's serialized size limit: a longer one is replaced by `cut`. */
|
|
21
22
|
export const LIVE_RECORD_CAP = 8 * 1024;
|
|
@@ -46,7 +47,6 @@ const LIVE_SECRET_SHAPES = [
|
|
|
46
47
|
/\b[a-z][a-z0-9+.-]*:\/\/[^\s/@]+@/i,
|
|
47
48
|
/[?&](?:access_token|token|key|api_key|apikey|secret|sig|signature|password|pwd|auth|code)=[^&\s]+/i,
|
|
48
49
|
];
|
|
49
|
-
const BIDI_CONTROLS = /[--]/g;
|
|
50
50
|
/** A line that is only a long run of mixed-case base64 — a key body whose BEGIN line was elsewhere (a hash is not). */
|
|
51
51
|
function keyBodyShaped(line) {
|
|
52
52
|
const bare = line.trim();
|
|
@@ -58,18 +58,9 @@ export function liveSecretHit(line) {
|
|
|
58
58
|
return true;
|
|
59
59
|
return keyBodyShaped(line) || LIVE_SECRET_SHAPES.some(shape => shape.test(line));
|
|
60
60
|
}
|
|
61
|
-
/** Controls, ANSI escapes and bidi overrides out; tabs to spaces. */
|
|
61
|
+
/** Controls, ANSI escapes and bidi overrides out; tabs to spaces (terminal-text.ts, the one sanitizer). */
|
|
62
62
|
export function cleanLiveText(text) {
|
|
63
|
-
return text
|
|
64
|
-
// Escape sequences in every form: CSI (7- or 8-bit, any parameter bytes including ':'), OSC, and the rest.
|
|
65
|
-
.replace(/(?:\x1b\[|\u009b)[0-?]*[ -/]*[@-~]/g, "")
|
|
66
|
-
.replace(/(?:\x1b\]|\u009d)[^\x07\x1b\u009c]*(?:\x07|\x1b\\|\u009c)?/g, "")
|
|
67
|
-
.replace(/\x1b[ -/]*[0-~]/g, "")
|
|
68
|
-
.replace(/\t/g, " ")
|
|
69
|
-
.replace(/[\x00-\x1f\x7f-\x9f]/g, "")
|
|
70
|
-
// Every invisible format character (zero-width, direction marks and overrides, joiners, BOM): never shown.
|
|
71
|
-
.replace(/\p{Cf}/gu, "")
|
|
72
|
-
.replace(BIDI_CONTROLS, "");
|
|
63
|
+
return cleanTerminalLine(text);
|
|
73
64
|
}
|
|
74
65
|
/** Letters, digits and dashes only, after folding look-alike letters and dashes. */
|
|
75
66
|
const reduceForm = (text) => text.normalize("NFKC").replace(/[\u2010-\u2015\u2212\u2E3A\u2E3B\uFE58\uFE63\uFF0D]/g, "-").toUpperCase().replace(/[^A-Z0-9-]/g, "");
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import type { LeadProposalKind, Store } from "./store.js";
|
|
2
|
+
import type { ChatAction } from "./chat-actions.js";
|
|
3
|
+
import type { ChatTaskAction } from "./chat-task-actions.js";
|
|
4
|
+
/** Who a browser session or a terminal command speaks as. Saved sessions from before local mode read "full". */
|
|
5
|
+
export type Authority = "full" | "local-owner";
|
|
6
|
+
export declare const AUTHORITIES: readonly Authority[];
|
|
7
|
+
/** A runtime string as an authority; anything else is null (never "full" by default). */
|
|
8
|
+
export declare function authorityOf(value: unknown): Authority | null;
|
|
9
|
+
/**
|
|
10
|
+
* The acts a local owner may do (plan C1). Ids only: each surface (a route row, a chat action, a CLI command) names
|
|
11
|
+
* the act it is, and an act not in this list is refused to a local owner.
|
|
12
|
+
*/
|
|
13
|
+
export declare const LOCAL_OWNER_ACTS: readonly ["file-task", "lead-send", "approve-plan", "answer-question", "stop-run", "pause-run", "resume-run", "complete-task", "request-revision", "archive-task", "add-project", "choose-default-agent", "mark-seen", "watch-agent", "read"];
|
|
14
|
+
export type LocalOwnerAct = (typeof LOCAL_OWNER_ACTS)[number];
|
|
15
|
+
/** Whether a runtime string names an act a local owner may do. */
|
|
16
|
+
export declare function localOwnerAct(value: unknown): value is LocalOwnerAct;
|
|
17
|
+
/**
|
|
18
|
+
* Every lead proposal kind, decided for the local owner. A new kind fails to compile here until someone decides it
|
|
19
|
+
* (like METHOD_IS_PERSON in person-proof.ts). `action` and `task_action` are allowed only for an operation that is a
|
|
20
|
+
* LOCAL_OWNER_ACTS act; the confirm gate checks that operation too.
|
|
21
|
+
*/
|
|
22
|
+
export declare const LOCAL_OWNER_PROPOSALS: Readonly<Record<LeadProposalKind, "allow" | "refuse">>;
|
|
23
|
+
/** Kinds whose allow also needs the operation itself to be a local-owner act. */
|
|
24
|
+
export declare const OPERATION_KINDS: ReadonlySet<LeadProposalKind>;
|
|
25
|
+
/**
|
|
26
|
+
* Whether a local owner may confirm a proposal of this kind. A runtime string: an unknown kind is refused. For
|
|
27
|
+
* `action`/`task_action`, `act` must be the local-owner act the operation is (null or unknown refuses).
|
|
28
|
+
*/
|
|
29
|
+
export declare function localOwnerMayConfirm(kind: unknown, act?: unknown): boolean;
|
|
30
|
+
/** Local mode as saved (store.ts `local_mode`, one row): who the owner is, its generation, and whether it is on. */
|
|
31
|
+
export type LocalMode = {
|
|
32
|
+
owner: string;
|
|
33
|
+
generation: number;
|
|
34
|
+
enabled: boolean;
|
|
35
|
+
initializedAt: string;
|
|
36
|
+
changedAt: string;
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Whether a local-owner session or link still stands against local mode as read now: on, at the generation it was
|
|
40
|
+
* bound to, for the same owner. Pure: the caller reads `mode` in the same transaction as the act.
|
|
41
|
+
*/
|
|
42
|
+
export declare function localBindingStands(mode: LocalMode | null, binding: {
|
|
43
|
+
account: string;
|
|
44
|
+
localGeneration: number | null;
|
|
45
|
+
}): boolean;
|
|
46
|
+
/** Where a local owner's act came from, for the ledger: the console in a browser, or the terminal UI in-process. */
|
|
47
|
+
export type LocalTransport = "browser" | "terminal";
|
|
48
|
+
/**
|
|
49
|
+
* A local owner acting now: who, the local-mode and account generations its session (or terminal start) was bound to,
|
|
50
|
+
* the transport, and the act the surface it came through is (a route row, a CLI row, a chat card). Only the console's
|
|
51
|
+
* admission and the CLI's dispatch create one; a request's input never names it.
|
|
52
|
+
*/
|
|
53
|
+
export type LocalPrincipal = {
|
|
54
|
+
readonly account: string;
|
|
55
|
+
readonly localGeneration: number;
|
|
56
|
+
readonly accountGeneration: number;
|
|
57
|
+
readonly transport: LocalTransport;
|
|
58
|
+
readonly act: LocalOwnerAct;
|
|
59
|
+
/** The browser session's saved hash (web_session.id_hash): a write re-reads that it is still saved. Null in the terminal. */
|
|
60
|
+
readonly sessionHash: string | null;
|
|
61
|
+
/**
|
|
62
|
+
* The browser request it was admitted for, marked ended when its answer is sent. Work that outlives the request (a
|
|
63
|
+
* timer or a turn started in its async chain) is never the local owner proving anything: the prover refuses it, and
|
|
64
|
+
* the write-time re-check no longer applies to it. Null in the terminal (a command runs to its end).
|
|
65
|
+
*/
|
|
66
|
+
readonly request: {
|
|
67
|
+
ended: boolean;
|
|
68
|
+
} | null;
|
|
69
|
+
};
|
|
70
|
+
/** The local owner this async chain acts as, or null (full authority, or nobody). Work that outlived its request still
|
|
71
|
+
* reads it (so it is never mistaken for full authority); `localOwnerInFlight` says whether it may still prove anything. */
|
|
72
|
+
export declare function currentLocalOwner(): LocalPrincipal | null;
|
|
73
|
+
/** Whether a local owner's act is still in its request (or its terminal command): only then may it prove or write. */
|
|
74
|
+
export declare function localOwnerInFlight(principal: LocalPrincipal): boolean;
|
|
75
|
+
/** Run `body` as a local owner (the console's admission and the CLI's dispatch only). */
|
|
76
|
+
export declare function asLocalOwner<T>(principal: LocalPrincipal, body: () => T): T;
|
|
77
|
+
/**
|
|
78
|
+
* Narrow the current local owner to one act for `body` (a chat card's own act inside the confirm route). Outside a
|
|
79
|
+
* local owner's chain it changes nothing. It can only ever name an allowlisted act: a caller passing null refuses.
|
|
80
|
+
*/
|
|
81
|
+
export declare function withLocalAct<T>(act: LocalOwnerAct, body: () => T): T;
|
|
82
|
+
/**
|
|
83
|
+
* The terminal UI's door (plan C3): run CLI commands (`runOperate`) inside `fn` as the local owner. runOperate binds the
|
|
84
|
+
* owner from local mode as it reads it when its database opens, and refuses every command not marked for the local owner
|
|
85
|
+
* (surface.ts LOCAL_ALLOW). Never read from argv or the environment.
|
|
86
|
+
*/
|
|
87
|
+
export declare function runAsLocalOwner<T>(fn: () => T): T;
|
|
88
|
+
/** Whether this chain asked to run commands as the local owner (runOperate reads it; nothing else should). */
|
|
89
|
+
export declare function localOwnerRequested(): boolean;
|
|
90
|
+
/**
|
|
91
|
+
* The act each lead proposal kind is when a local owner confirms it (null: refused, as LOCAL_OWNER_PROPOSALS says).
|
|
92
|
+
* `action` and `task_action` take their operation's act instead (CHAT_ACTION_LOCAL_ACTS, CHAT_TASK_ACTION_LOCAL_ACTS).
|
|
93
|
+
*/
|
|
94
|
+
export declare const PROPOSAL_LOCAL_ACTS: Readonly<Record<LeadProposalKind, LocalOwnerAct | null>>;
|
|
95
|
+
/** Every chat action (chat-actions.ts CHAT_ACTIONS), decided for the local owner: its act, or null (refused). A new
|
|
96
|
+
* action fails to compile here until someone decides it. */
|
|
97
|
+
export declare const CHAT_ACTION_LOCAL_ACTS: Readonly<Record<ChatAction, LocalOwnerAct | null>>;
|
|
98
|
+
/** Every task card operation (chat-task-actions.ts), decided for the local owner. */
|
|
99
|
+
export declare const CHAT_TASK_ACTION_LOCAL_ACTS: Readonly<Record<ChatTaskAction, LocalOwnerAct | null>>;
|
|
100
|
+
/**
|
|
101
|
+
* The act a saved proposal is for the local owner, read from its kind and (for operation kinds) its saved operation;
|
|
102
|
+
* null refuses. A runtime read of saved JSON: anything unknown is null.
|
|
103
|
+
*/
|
|
104
|
+
export declare function proposalLocalAct(kind: unknown, operation: unknown): LocalOwnerAct | null;
|
|
105
|
+
/** The words every local-owner refusal says, and where it leads: set a first password, or sign in with the one set. */
|
|
106
|
+
export declare function localOwnerRefusal(passwordSet: boolean, returnTo: string): {
|
|
107
|
+
message: string;
|
|
108
|
+
action: string;
|
|
109
|
+
href: string;
|
|
110
|
+
};
|
|
111
|
+
/** Whether local mode is on now (absent is off). Read straight from its one row, so the store's own checks can use it. */
|
|
112
|
+
export declare function localModeOn(store: Pick<Store, "handle">): boolean;
|
|
113
|
+
/**
|
|
114
|
+
* The write-time re-check (plan C1): a local owner acting now still stands — local mode on, at the generation it was
|
|
115
|
+
* bound to, for the same owner; the owner an active approver at the account generation it was bound to; and (a browser)
|
|
116
|
+
* its session still saved. Read in the caller's transaction: Store.transact runs it inside every write a local owner's
|
|
117
|
+
* chain makes, and the prover runs it inside the approval's own transaction.
|
|
118
|
+
*/
|
|
119
|
+
export declare function localOwnerStandsNow(store: Pick<Store, "handle" | "accountOf" | "webSession">, principal: LocalPrincipal): boolean;
|
|
120
|
+
/** What a local owner's write hears when it no longer stands (local mode turned off or moved, the owner changed, the
|
|
121
|
+
* session ended). */
|
|
122
|
+
export declare const LOCAL_OWNER_LAPSED = "This local sign-in no longer stands. Run `toolroll open` to sign in again.";
|
|
123
|
+
/** What widening a launch's confinement hears while local mode is on (plan C5). */
|
|
124
|
+
export declare const LOCAL_MODE_CONFINES = "Turn off local mode first.";
|