toolroll 0.9.43 → 0.9.45

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 (50) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/dist/action-ledger.d.ts +5 -2
  3. package/dist/action-ledger.js +13 -3
  4. package/dist/cli-http.d.ts +53 -0
  5. package/dist/cli-http.js +143 -0
  6. package/dist/cli.d.ts +3 -0
  7. package/dist/cli.js +7 -0
  8. package/dist/contracts/cli.d.ts +13 -0
  9. package/dist/contracts/cli.js +7 -0
  10. package/dist/contracts/gateway-tools.d.ts +82 -0
  11. package/dist/contracts/gateway-tools.js +33 -0
  12. package/dist/desktop-update.d.ts +4 -2
  13. package/dist/desktop-update.js +7 -6
  14. package/dist/dispatch.d.ts +2 -2
  15. package/dist/dispatch.js +8 -4
  16. package/dist/lead-status.d.ts +7 -3
  17. package/dist/lead-status.js +21 -12
  18. package/dist/mcp-core.d.ts +110 -0
  19. package/dist/mcp-core.js +311 -0
  20. package/dist/mcp-http.d.ts +26 -0
  21. package/dist/mcp-http.js +151 -0
  22. package/dist/mcp-person.d.ts +31 -0
  23. package/dist/mcp-person.js +96 -0
  24. package/dist/mcp.d.ts +1 -8
  25. package/dist/mcp.js +21 -322
  26. package/dist/operate-remote-arguments.d.ts +36 -0
  27. package/dist/operate-remote-arguments.js +185 -0
  28. package/dist/operate-remote.d.ts +98 -0
  29. package/dist/operate-remote.js +145 -0
  30. package/dist/operate.d.ts +21 -1
  31. package/dist/operate.js +206 -19
  32. package/dist/remote-command.d.ts +45 -0
  33. package/dist/remote-command.js +124 -0
  34. package/dist/remote-exec.d.ts +18 -0
  35. package/dist/remote-exec.js +241 -0
  36. package/dist/remote-run.d.ts +20 -0
  37. package/dist/remote-run.js +13 -0
  38. package/dist/scope.js +10 -0
  39. package/dist/serve.d.ts +7 -0
  40. package/dist/serve.js +24 -0
  41. package/dist/store.d.ts +15 -1
  42. package/dist/store.js +34 -7
  43. package/dist/surface.d.ts +10 -3
  44. package/dist/surface.js +67 -2
  45. package/dist/team-cli.d.ts +19 -0
  46. package/dist/team-cli.js +20 -3
  47. package/dist/toolroll-update.js +4 -1
  48. package/dist/work-summary.d.ts +4 -1
  49. package/dist/work-summary.js +1 -1
  50. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,34 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.45 — 2026-10-07
4
+
5
+ - **Your projects, your lists.** People with access to some projects can now
6
+ run `status`, `task list` and `ready` remotely, and their agents' list
7
+ tools, and see and count only those projects. A task waiting on one in
8
+ another project says "waits for a task in another project", with no id,
9
+ and queue position counts only your projects. Owners see exactly what they
10
+ did before.
11
+ - Still unavailable remotely for limited access: `brief`, `spend` and
12
+ choosing a task's `--id`.
13
+
14
+ ## 0.9.44 — 2026-10-07
15
+
16
+ - **Work from your own laptop.** `toolroll connect https://… --token so_…`
17
+ and allowed commands run on the server as you, with the same output and
18
+ exit codes. Approvals, people, policy and anything touching the server's
19
+ files stay in the console and chat.
20
+ - **Agents connect over HTTPS.** `claude mcp add --transport http toolroll
21
+ https://<host>/mcp --header "Authorization: Bearer so_…"`. Tools cover only
22
+ that person's projects.
23
+ - **Every remote action is on the record**: who, whether it came from the CLI
24
+ or an agent, and the token's name. A revoked token stops on its next call.
25
+ Other projects' tasks and runs answer "not found".
26
+ - **For now, people limited to some projects** can't run the commands that
27
+ list across projects (`status`, `task list`) or choose a task's `--id`;
28
+ they're refused rather than shown a partial list. Filtered views are next.
29
+ - Still to come: token management in the CLI, rate limits, an audit view, a
30
+ real domain with TLS, and sign-in for agents.
31
+
3
32
  ## 0.9.43 — 2026-10-06
4
33
 
5
34
  - **Approve from chat and the CLI.** A plan waiting on you and a flow's
@@ -1,6 +1,7 @@
1
1
  import type { Database } from "./store.js";
2
- /** v99: sign-ins (and refusals, lockouts, sign-outs) and policy changes are their own kinds of event. */
3
- export declare const LEDGER_SOURCES: readonly ["work", "request", "access", "sign-in", "policy"];
2
+ /** v99: sign-ins (and refusals, lockouts, sign-outs) and policy changes are their own kinds of event.
3
+ * v110: commands a person ran on this server with their API token, from the CLI ('api') or their agent ('mcp'). */
4
+ export declare const LEDGER_SOURCES: readonly ["work", "request", "access", "sign-in", "policy", "api", "mcp"];
4
5
  export type LedgerSource = typeof LEDGER_SOURCES[number];
5
6
  export type LedgerEntry = {
6
7
  id: number;
@@ -18,6 +19,8 @@ export type LedgerEntry = {
18
19
  /** The table as v54 made it: the one earlier shape the v99 rebuild accepts. */
19
20
  export declare const LEDGER_V54_TABLE: (name: string) => string;
20
21
  export declare const LEDGER_V54_COLUMNS: readonly ["id", "at", "actor", "repo", "task_id", "run_id", "action", "outcome", "source"];
22
+ /** The table as v99 made it: the v110 rebuild accepts this shape and v54's. */
23
+ export declare const LEDGER_V99_TABLE: (name: string) => string;
21
24
  export declare const LEDGER_TABLE: (name: string) => string;
22
25
  export declare const LEDGER_SCHEMA: string;
23
26
  /** Installed after all table migrations. Work events commit with the work,
@@ -1,5 +1,6 @@
1
- /** v99: sign-ins (and refusals, lockouts, sign-outs) and policy changes are their own kinds of event. */
2
- export const LEDGER_SOURCES = ["work", "request", "access", "sign-in", "policy"];
1
+ /** v99: sign-ins (and refusals, lockouts, sign-outs) and policy changes are their own kinds of event.
2
+ * v110: commands a person ran on this server with their API token, from the CLI ('api') or their agent ('mcp'). */
3
+ export const LEDGER_SOURCES = ["work", "request", "access", "sign-in", "policy", "api", "mcp"];
3
4
  /** The table as v54 made it: the one earlier shape the v99 rebuild accepts. */
4
5
  export const LEDGER_V54_TABLE = (name) => `CREATE TABLE ${name} (
5
6
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -9,7 +10,8 @@ export const LEDGER_V54_TABLE = (name) => `CREATE TABLE ${name} (
9
10
  source TEXT NOT NULL CHECK (source IN ('work','request','access'))
10
11
  )`;
11
12
  export const LEDGER_V54_COLUMNS = ["id", "at", "actor", "repo", "task_id", "run_id", "action", "outcome", "source"];
12
- export const LEDGER_TABLE = (name) => `CREATE TABLE ${name} (
13
+ /** The table as v99 made it: the v110 rebuild accepts this shape and v54's. */
14
+ export const LEDGER_V99_TABLE = (name) => `CREATE TABLE ${name} (
13
15
  id INTEGER PRIMARY KEY AUTOINCREMENT,
14
16
  at TEXT NOT NULL, actor TEXT NOT NULL, repo TEXT,
15
17
  task_id TEXT, run_id INTEGER,
@@ -17,6 +19,14 @@ export const LEDGER_TABLE = (name) => `CREATE TABLE ${name} (
17
19
  source TEXT NOT NULL CHECK (source IN ('work','request','access','sign-in','policy')),
18
20
  detail TEXT
19
21
  )`;
22
+ export const LEDGER_TABLE = (name) => `CREATE TABLE ${name} (
23
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
24
+ at TEXT NOT NULL, actor TEXT NOT NULL, repo TEXT,
25
+ task_id TEXT, run_id INTEGER,
26
+ action TEXT NOT NULL, outcome TEXT NOT NULL,
27
+ source TEXT NOT NULL CHECK (source IN ('work','request','access','sign-in','policy','api','mcp')),
28
+ detail TEXT
29
+ )`;
20
30
  export const LEDGER_SCHEMA = `
21
31
  ${LEDGER_TABLE("IF NOT EXISTS action_ledger")};
22
32
  CREATE INDEX IF NOT EXISTS action_ledger_project ON action_ledger(repo, id DESC);
@@ -0,0 +1,53 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http';
2
+ import type { Store } from './store.js';
3
+ import type { Principal } from './operate.js';
4
+ import { type RemoteCommandLookup } from './remote-command.js';
5
+ /**
6
+ * `POST /api/cli` (remote CLI, Phase 1): a teammate's laptop sends the argv it would have run locally, and the central
7
+ * server runs that same command as that person. There is no per-resource API here and no second policy: the transport
8
+ * authenticates one API token, checks the shared command contract and transport arguments, and hands the argv to
9
+ * `runOperateAs`, which owns scope, project grants and attribution.
10
+ *
11
+ * Only `Authorization: Bearer so_…` is accepted — never a password, cookie or the owner's saved login — and a request
12
+ * carrying a browser Origin is refused, so a page can never drive it.
13
+ */
14
+ /** Who a remote command runs as, and the runner: operate.ts's own types, so a change there breaks this build instead of
15
+ * drifting. Type-only: the value is loaded lazily through `run` (operate.ts imports the server that imports this). */
16
+ export type { Principal } from './operate.js';
17
+ export type RunOperateAs = typeof import('./operate.js').runOperateAs;
18
+ export type CliRequest = {
19
+ argv: string[];
20
+ files: Record<string, string>;
21
+ };
22
+ export type CliReply = {
23
+ exitCode: number;
24
+ stdout: string;
25
+ stderr: string;
26
+ };
27
+ export type CliFailure = {
28
+ ok: false;
29
+ code: string;
30
+ message: string;
31
+ };
32
+ /** Inlined file contents, summed as UTF-8 bytes. */
33
+ export declare const CLI_FILES_BYTES: number;
34
+ /** The whole JSON body: the files plus argv and JSON escaping headroom. */
35
+ export declare const CLI_REQUEST_BYTES: number;
36
+ export type CliHttpOptions = {
37
+ /** The live API token's person, or null. Only called for a single, well-formed `Bearer so_…` header. */
38
+ authenticate: (request: IncomingMessage) => Principal | null;
39
+ /** The shared command boundary; null when this server cannot run remote commands. */
40
+ run: () => Promise<RunOperateAs | null>;
41
+ /** Tests: command policy while the parallel command contract is not yet available. Defaults to COMMAND_GUIDE. */
42
+ modeOf?: RemoteCommandLookup | undefined;
43
+ store: Store;
44
+ };
45
+ /** Decode the request's shape and byte limits. The endpoint checks file references against its command policy. */
46
+ export declare function cliRequest(value: unknown): CliRequest | {
47
+ problem: string;
48
+ tooLarge?: true;
49
+ };
50
+ /** The longest one request may hold the server: the client polls again for longer waits. */
51
+ export declare const CLI_WAIT_SECONDS = 25;
52
+ /** Handles exactly `/api/cli`; false for any other path. */
53
+ export declare function handleCliHttp(request: IncomingMessage, response: ServerResponse, options: CliHttpOptions): Promise<boolean>;
@@ -0,0 +1,143 @@
1
+ import { contractRow, fileArguments, STEP_UP_MESSAGE } from './remote-command.js';
2
+ /** Inlined file contents, summed as UTF-8 bytes. */
3
+ export const CLI_FILES_BYTES = 256 * 1024;
4
+ /** The whole JSON body: the files plus argv and JSON escaping headroom. */
5
+ export const CLI_REQUEST_BYTES = 2 * CLI_FILES_BYTES;
6
+ const ARGV_MAX = 256;
7
+ const ARG_BYTES = 16 * 1024;
8
+ const FILES_MAX = 16;
9
+ function send(response, status, value) {
10
+ if (response.destroyed || response.writableEnded)
11
+ return;
12
+ response.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' });
13
+ response.end(JSON.stringify(value));
14
+ }
15
+ async function body(request) {
16
+ const chunks = [];
17
+ let size = 0;
18
+ for await (const chunk of request) {
19
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
20
+ size += bytes.length;
21
+ if (size > CLI_REQUEST_BYTES)
22
+ throw new Error('too-large');
23
+ chunks.push(bytes);
24
+ }
25
+ return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks)));
26
+ }
27
+ /** Decode the request's shape and byte limits. The endpoint checks file references against its command policy. */
28
+ export function cliRequest(value) {
29
+ if (!value || typeof value !== 'object' || Array.isArray(value))
30
+ return { problem: 'Send {argv, files}.' };
31
+ const row = value;
32
+ if (!Object.keys(row).every(key => key === 'argv' || key === 'files'))
33
+ return { problem: 'Send only argv and files.' };
34
+ const argv = row.argv;
35
+ if (!Array.isArray(argv) || argv.length === 0 || argv.length > ARGV_MAX || !argv.every(one => typeof one === 'string' && Buffer.byteLength(one) <= ARG_BYTES && !one.includes('\0')))
36
+ return { problem: 'argv must be 1–256 strings.' };
37
+ const files = row.files ?? {};
38
+ if (!files || typeof files !== 'object' || Array.isArray(files))
39
+ return { problem: 'files must map an argument to its contents.' };
40
+ const entries = Object.entries(files);
41
+ if (entries.length > FILES_MAX)
42
+ return { problem: 'Too many files.' };
43
+ let size = 0;
44
+ const kept = Object.create(null);
45
+ for (const [key, content] of entries) {
46
+ if (typeof content !== 'string' || key === '' || key.includes('\0'))
47
+ return { problem: 'files must map file paths to text contents.' };
48
+ size += Buffer.byteLength(content);
49
+ if (size > CLI_FILES_BYTES)
50
+ return { problem: 'Files are limited to 256 KiB in total.', tooLarge: true };
51
+ kept[key] = content;
52
+ }
53
+ return { argv: argv, files: kept };
54
+ }
55
+ /** The longest one request may hold the server: the client polls again for longer waits. */
56
+ export const CLI_WAIT_SECONDS = 25;
57
+ function longPollBounded(argv) {
58
+ const words = argv.filter(one => !one.startsWith('-'));
59
+ if (words[0] !== 'task' || words[1] !== 'wait')
60
+ return true;
61
+ const at = argv.indexOf('--timeout'), given = at < 0 ? NaN : Number(argv[at + 1]);
62
+ return argv.filter(one => one === '--timeout').length === 1 && Number.isFinite(given) && given >= 0 && given <= CLI_WAIT_SECONDS;
63
+ }
64
+ /** Handles exactly `/api/cli`; false for any other path. */
65
+ export async function handleCliHttp(request, response, options) {
66
+ const url = new URL(request.url ?? '/', 'http://standing-orders.local');
67
+ if (url.pathname !== '/api/cli')
68
+ return false;
69
+ const reject = (status, code, message) => { send(response, status, { ok: false, code, message }); request.resume(); return true; };
70
+ if (url.search !== '' || url.username !== '' || url.password !== '')
71
+ return reject(400, 'credentials-in-url', 'Send the command in the request body; nothing travels in the URL.');
72
+ if (request.method !== 'POST')
73
+ return reject(405, 'method-not-allowed', 'Use POST.');
74
+ // A browser always names its page. The remote CLI never does, so a page can never drive this endpoint.
75
+ if (request.headers.origin !== undefined)
76
+ return reject(403, 'browser-origin', 'Remote commands come from the CLI, not a browser.');
77
+ const names = request.rawHeaders.filter((_value, index) => index % 2 === 0).map(name => name.toLowerCase());
78
+ const count = names.filter(name => name === 'authorization').length;
79
+ if (count > 1)
80
+ return reject(400, 'ambiguous-credentials', 'Send one authorization header.');
81
+ if (names.includes('cookie'))
82
+ return reject(400, 'cookie-refused', 'Remote commands sign in with an API token only.');
83
+ if (!/^Bearer so_\S+$/.test(request.headers.authorization ?? ''))
84
+ return reject(401, 'unauthenticated', 'Sign in with your API token: toolroll connect <origin> --token-stdin.');
85
+ if (request.headers['content-type']?.split(';')[0]?.trim() !== 'application/json')
86
+ return reject(415, 'invalid-content-type', 'Send application/json.');
87
+ if (Number(request.headers['content-length'] ?? 0) > CLI_REQUEST_BYTES)
88
+ return reject(413, 'request-too-large', 'Files are limited to 256 KiB in total.');
89
+ let principal;
90
+ try {
91
+ principal = options.authenticate(request);
92
+ }
93
+ catch {
94
+ return reject(503, 'authentication-unavailable', 'Sign-in could not be checked.');
95
+ }
96
+ if (!principal)
97
+ return reject(401, 'unauthenticated', 'This API token is not valid (expired, revoked or its person removed).');
98
+ let value;
99
+ try {
100
+ value = await body(request);
101
+ }
102
+ catch (error) {
103
+ return reject(error instanceof Error && error.message === 'too-large' ? 413 : 400, 'invalid-body', 'Send a valid JSON request; files are limited to 256 KiB in total.');
104
+ }
105
+ const parsed = cliRequest(value);
106
+ if ('problem' in parsed)
107
+ return reject(parsed.tooLarge ? 413 : 400, 'invalid-request', parsed.problem);
108
+ const row = (options.modeOf ?? contractRow)(parsed.argv);
109
+ if (row?.mode === 'step-up')
110
+ return reject(403, 'step-up', STEP_UP_MESSAGE);
111
+ if (row?.mode !== 'yes')
112
+ return reject(403, 'remote-refused', 'This command cannot run remotely.');
113
+ const paths = fileArguments(parsed.argv, row);
114
+ if ('problem' in paths)
115
+ return reject(403, paths.code, paths.problem);
116
+ const missing = paths.find(path => !Object.hasOwn(parsed.files, path));
117
+ if (missing !== undefined)
118
+ return reject(403, 'missing-file', 'Send every input file\'s contents in files; remote commands cannot read a server file.');
119
+ if (Object.keys(parsed.files).some(path => !paths.includes(path)))
120
+ return reject(400, 'invalid-request', 'Each file must name a declared input file argument.');
121
+ if (!longPollBounded(parsed.argv))
122
+ return reject(400, 'wait-too-long', `A remote task wait holds the server for at most ${CLI_WAIT_SECONDS} seconds; give --timeout ${CLI_WAIT_SECONDS} or less.`);
123
+ let run;
124
+ try {
125
+ run = await options.run();
126
+ }
127
+ catch {
128
+ run = null;
129
+ }
130
+ if (run === null)
131
+ return reject(501, 'remote-unavailable', 'This server cannot run remote commands yet. Update it, or use --local.');
132
+ // Files stay in memory: runOperateAs reads them by their argument, so nothing is written to disk here.
133
+ const out = [];
134
+ let exitCode;
135
+ try {
136
+ exitCode = await run([...parsed.argv], { principal, store: options.store, write: line => { out.push(`${line}\n`); }, files: parsed.files, source: 'api' });
137
+ }
138
+ catch {
139
+ return reject(500, 'command-failed', 'The command stopped unexpectedly on the server. Check its state before running it again.');
140
+ }
141
+ send(response, 200, { exitCode, stdout: out.join(''), stderr: '' });
142
+ return true;
143
+ }
package/dist/cli.d.ts CHANGED
@@ -13,6 +13,7 @@
13
13
  import { previewGithubRepo, cloneGithubRepo } from "./onboard.js";
14
14
  import { type SessionCliOptions } from "./session-cli.js";
15
15
  import { type TeamCliOptions } from "./team-cli.js";
16
+ import { type RemoteExecOptions } from "./remote-exec.js";
16
17
  import { type UpdateCliDeps } from "./toolroll-update-cli.js";
17
18
  import { type OperateOptions } from "./operate.js";
18
19
  export type CliOptions = {
@@ -54,6 +55,8 @@ export type MainOptions = {
54
55
  operate?: OperateOptions;
55
56
  session?: SessionCliOptions;
56
57
  team?: TeamCliOptions;
58
+ /** Injected by tests: the remote CLI's profile, fetch and output streams (defaults to `team`'s profile seams). */
59
+ remote?: RemoteExecOptions;
57
60
  /** Injected by tests: the gh-facing halves of `repos add-from-github` —
58
61
  * the verb's parsing, gating, and enrollment are what CLI tests prove;
59
62
  * gh itself is proved by onboard.test.ts. */
package/dist/cli.js CHANGED
@@ -27,6 +27,7 @@ import { run as execRun } from "./exec.js";
27
27
  import { COMMAND_GUIDE, SURFACE_NOTES, SURFACE_SCHEMA_VERSION } from "./surface.js";
28
28
  import { runSessionCommand, SESSION_CLI_ACTIONS } from "./session-cli.js";
29
29
  import { maybeRunTeamCommand, TEAM_CLI_ACTIONS } from "./team-cli.js";
30
+ import { maybeRunRemoteCommand } from "./remote-exec.js";
30
31
  import { discover, inspectAll } from "./discover.js";
31
32
  import { readPulls } from "./pulls.js";
32
33
  import { detectGraphs, chooseBackend, setupOptions, LABELS as GRAPH_LABELS, } from "./graph.js";
@@ -375,6 +376,12 @@ export async function main(argv, write = line => console.log(line), mainOptions
375
376
  return code;
376
377
  }
377
378
  async function dispatch(argv, write, mainOptions) {
379
+ // A saved API token sends commands marked remote to the central server, before anything local opens.
380
+ const central = await maybeRunRemoteCommand(argv, mainOptions.remote ?? mainOptions.team);
381
+ if (typeof central === "number")
382
+ return central;
383
+ if (central !== null)
384
+ argv = central.local;
378
385
  const team = await maybeRunTeamCommand(argv, write, mainOptions.team);
379
386
  if (team !== null)
380
387
  return team;
@@ -25,6 +25,9 @@ export declare const commandFlagSchema: z.ZodReadonly<z.ZodObject<{
25
25
  meaning: z.ZodString;
26
26
  }, z.core.$loose>>;
27
27
  export type CommandFlag = z.infer<typeof commandFlagSchema>;
28
+ /** The remote policy every command row states; see `remote` below. */
29
+ export declare const REMOTE_POLICIES: readonly ["yes", "no", "step-up"];
30
+ export type RemotePolicy = typeof REMOTE_POLICIES[number];
28
31
  export declare const commandRowSchema: z.ZodReadonly<z.ZodObject<{
29
32
  invocation: z.ZodString;
30
33
  envelopeCommand: z.ZodOptional<z.ZodString>;
@@ -82,6 +85,11 @@ export declare const commandRowSchema: z.ZodReadonly<z.ZodObject<{
82
85
  const?: number;
83
86
  }>;
84
87
  }>>;
88
+ remote: z.ZodEnum<{
89
+ no: "no";
90
+ "step-up": "step-up";
91
+ yes: "yes";
92
+ }>;
85
93
  }, z.core.$loose>>;
86
94
  export type CommandRow = z.infer<typeof commandRowSchema>;
87
95
  /**
@@ -157,6 +165,11 @@ export declare const COMMAND_OUTPUTS: {
157
165
  const?: number;
158
166
  }>;
159
167
  }>>;
168
+ remote: z.ZodEnum<{
169
+ no: "no";
170
+ "step-up": "step-up";
171
+ yes: "yes";
172
+ }>;
160
173
  }, z.core.$loose>>>;
161
174
  }, z.core.$loose>, z.ZodObject<{
162
175
  envelopeVersion: z.ZodLiteral<1>;
@@ -35,6 +35,8 @@ function answers(command, shape) {
35
35
  }
36
36
  // ---- the declared command guide, as `contract --commands` dumps it ----
37
37
  export const commandFlagSchema = z.looseObject({ name: str, takesValue: z.boolean(), meaning: str }).readonly();
38
+ /** The remote policy every command row states; see `remote` below. */
39
+ export const REMOTE_POLICIES = ["yes", "no", "step-up"];
38
40
  /** A session operation's executable input schema, as session-contract.ts states it. */
39
41
  const sessionInputSchema = z.custom(value => typeof value === "object" && value !== null && !Array.isArray(value));
40
42
  export const commandRowSchema = z.looseObject({
@@ -61,6 +63,11 @@ export const commandRowSchema = z.looseObject({
61
63
  notableReasons: z.array(str).readonly().optional(),
62
64
  /** Session schemas are executable input contracts, not a grant of authority. */
63
65
  inputSchema: sessionInputSchema.optional(),
66
+ /** Whether a person signed in to a central server with their own API token may run it there (`runOperateAs`):
67
+ * yes — runs as that person, within their token's scope and project access.
68
+ * no — this machine's own infrastructure, credentials or files; never run for a remote caller.
69
+ * step-up — approvals, people and policy: a person's act in the console or chat, never a token's. */
70
+ remote: z.enum(REMOTE_POLICIES),
64
71
  }).readonly();
65
72
  // ---- the commands that describe their fields ----
66
73
  const taskSchema = z.looseObject({ id: str, title: str, state: z.enum(LISTED_TASK_STATES), createdAt: str, updatedAt: str, priority: z.number() });
@@ -372,3 +372,85 @@ export declare const GATEWAY_TOOL_OUTPUTS: {
372
372
  }, z.core.$loose>;
373
373
  };
374
374
  export type GatewayToolOutput<N extends GatewayToolName> = z.infer<(typeof GATEWAY_TOOL_OUTPUTS)[N]>;
375
+ export declare const PERSON_TOOL_INPUTS: {
376
+ readonly status: z.ZodObject<{}, z.core.$strict>;
377
+ readonly list_tasks: z.ZodObject<{
378
+ state: z.ZodOptional<z.ZodEnum<{
379
+ cancelled: "cancelled";
380
+ done: "done";
381
+ failed: "failed";
382
+ queued: "queued";
383
+ running: "running";
384
+ }>>;
385
+ repo: z.ZodOptional<z.ZodString>;
386
+ cursor: z.ZodOptional<z.ZodInt>;
387
+ limit: z.ZodOptional<z.ZodInt>;
388
+ }, z.core.$strict>;
389
+ readonly task_show: z.ZodObject<{
390
+ ref: z.ZodString;
391
+ }, z.core.$strict>;
392
+ readonly task_review: z.ZodObject<{
393
+ ref: z.ZodString;
394
+ run: z.ZodOptional<z.ZodInt>;
395
+ all: z.ZodOptional<z.ZodBoolean>;
396
+ }, z.core.$strict>;
397
+ readonly review_findings: z.ZodObject<{
398
+ run: z.ZodInt;
399
+ }, z.core.$strict>;
400
+ readonly file_task: z.ZodObject<{
401
+ repo: z.ZodString;
402
+ title: z.ZodString;
403
+ idempotency_key: z.ZodString;
404
+ deliverable: z.ZodOptional<z.ZodEnum<{
405
+ branch: "branch";
406
+ report: "report";
407
+ }>>;
408
+ }, z.core.$strict>;
409
+ };
410
+ export type PersonToolName = keyof typeof PERSON_TOOL_INPUTS;
411
+ export type PersonToolInput<N extends PersonToolName> = z.infer<(typeof PERSON_TOOL_INPUTS)[N]>;
412
+ export declare const PERSON_TOOL_OUTPUTS: {
413
+ readonly status: z.ZodObject<{
414
+ envelopeVersion: z.ZodLiteral<1>;
415
+ ok: z.ZodBoolean;
416
+ command: z.ZodString;
417
+ reason: z.ZodOptional<z.ZodString>;
418
+ message: z.ZodOptional<z.ZodString>;
419
+ }, z.core.$loose>;
420
+ readonly list_tasks: z.ZodObject<{
421
+ envelopeVersion: z.ZodLiteral<1>;
422
+ ok: z.ZodBoolean;
423
+ command: z.ZodString;
424
+ reason: z.ZodOptional<z.ZodString>;
425
+ message: z.ZodOptional<z.ZodString>;
426
+ }, z.core.$loose>;
427
+ readonly task_show: z.ZodObject<{
428
+ envelopeVersion: z.ZodLiteral<1>;
429
+ ok: z.ZodBoolean;
430
+ command: z.ZodString;
431
+ reason: z.ZodOptional<z.ZodString>;
432
+ message: z.ZodOptional<z.ZodString>;
433
+ }, z.core.$loose>;
434
+ readonly task_review: z.ZodObject<{
435
+ envelopeVersion: z.ZodLiteral<1>;
436
+ ok: z.ZodBoolean;
437
+ command: z.ZodString;
438
+ reason: z.ZodOptional<z.ZodString>;
439
+ message: z.ZodOptional<z.ZodString>;
440
+ }, z.core.$loose>;
441
+ readonly review_findings: z.ZodObject<{
442
+ envelopeVersion: z.ZodLiteral<1>;
443
+ ok: z.ZodBoolean;
444
+ command: z.ZodString;
445
+ reason: z.ZodOptional<z.ZodString>;
446
+ message: z.ZodOptional<z.ZodString>;
447
+ }, z.core.$loose>;
448
+ readonly file_task: z.ZodObject<{
449
+ envelopeVersion: z.ZodLiteral<1>;
450
+ ok: z.ZodBoolean;
451
+ command: z.ZodString;
452
+ reason: z.ZodOptional<z.ZodString>;
453
+ message: z.ZodOptional<z.ZodString>;
454
+ id: z.ZodOptional<z.ZodString>;
455
+ }, z.core.$loose>;
456
+ };
@@ -10,6 +10,7 @@
10
10
  */
11
11
  import { z } from "zod";
12
12
  import { TEXT_LIMITS } from "../text-limits.js";
13
+ import { envelopeSchema } from "./cli.js";
13
14
  import { catchUpOutput, decisionOutput, decisionsOutput, LISTED_TASK_STATES, queueColumnsOutput, recapOutput, repositoryContextOutput, scopeText, touches } from "./lead-tools.js";
14
15
  const text = (key) => z.string().max(TEXT_LIMITS[key]);
15
16
  const ref = z.string().min(1).max(TEXT_LIMITS.taskRef);
@@ -92,3 +93,35 @@ export const GATEWAY_TOOL_OUTPUTS = {
92
93
  get_contract: z.looseObject({ contract: str }),
93
94
  file_proposal: z.looseObject({ ref: str, replayed: z.boolean(), admission: str }),
94
95
  };
96
+ // ------------------------------------------------------------- a person's tools
97
+ /**
98
+ * The tools a PERSON's API token sees over the HTTP gateway (mcp-person.ts). Each call becomes the exact `toolroll`
99
+ * command line the same person could type, run on the server under their own principal (operate.ts `runOperateAs`):
100
+ * the command's own authorization, project grants and attribution apply, and its `--json` envelope is the answer.
101
+ * Two reads keep the coordinator's names and arguments; the rest are new. Every value that lands in a command line
102
+ * may not begin with "-", so an argument can never be read as a flag.
103
+ */
104
+ const notFlag = (schema) => schema.regex(/^[^-]/, { error: "must not begin with -" });
105
+ const taskRef = notFlag(ref);
106
+ const runId = z.int().min(1);
107
+ export const PERSON_TOOL_INPUTS = {
108
+ status: GATEWAY_TOOL_INPUTS.status,
109
+ list_tasks: GATEWAY_TOOL_INPUTS.list_tasks,
110
+ task_show: z.strictObject({ ref: taskRef }),
111
+ task_review: z.strictObject({ ref: taskRef, run: runId.optional(), all: z.boolean().optional() }),
112
+ review_findings: z.strictObject({ run: runId }),
113
+ file_task: z.strictObject({
114
+ repo: notFlag(gatewayRepo), title: notFlag(text("taskTitle").min(1)), idempotency_key: notFlag(text("idempotencyKey").min(8)),
115
+ deliverable: z.enum(["branch", "report"]).optional(),
116
+ }),
117
+ };
118
+ /** The command's `--json` envelope (contracts/cli.ts); a refusal is `ok: false` with its stable reason. */
119
+ const envelope = envelopeSchema;
120
+ export const PERSON_TOOL_OUTPUTS = {
121
+ status: envelope,
122
+ list_tasks: envelope,
123
+ task_show: envelope,
124
+ task_review: envelope,
125
+ review_findings: envelope,
126
+ file_task: envelope.extend({ id: str.optional() }),
127
+ };
@@ -28,6 +28,8 @@ export declare const updateTerminal: (phase: string) => boolean;
28
28
  /** Durable receipts are private and independent of the app being replaced. */
29
29
  export declare function durableJson(file: string, value: unknown): void;
30
30
  export declare function readUpdateJournal(stateDir: string, retainedReceipt?: string): UpdateJournal | null;
31
+ /** Which settled schema versions a caller works on: the desktop update, which never migrates, only this build's own. */
32
+ export type SchemaAccepted = (version: number | null) => boolean;
31
33
  /** A finished run whose end Toolroll cannot show yet: in plain words, with the one command that clears it when a
32
34
  * person can. */
33
35
  export type LingeringRun = {
@@ -92,13 +94,13 @@ export declare function prepareDesktopUpdate(stateDir: string, installed: string
92
94
  export declare function sqliteLock(file: string, busyTimeoutMs?: number): DatabaseSync | null;
93
95
  export declare const installationLock: (j: UpdateJournal) => DatabaseSync | null;
94
96
  export declare function desktopUpdateStatus(stateDir: string): {
95
- error?: never;
96
97
  active: boolean;
97
98
  phase: string;
98
99
  detail: string;
99
100
  running: boolean;
100
101
  canResume: boolean;
101
102
  canCancel: boolean;
103
+ error?: never;
102
104
  automaticRecovery?: never;
103
105
  recoveryAttempts?: never;
104
106
  recoveryRepairs?: never;
@@ -135,7 +137,7 @@ export declare function pauseUpdateRecovery(stateDir: string, id: string, detail
135
137
  * reservation so no writer slips in between the snapshot and the copy. The
136
138
  * copy drops the update's own admission pause. `inside` runs before the
137
139
  * reservation is released, `after` once it is; returns the copy's hash. */
138
- export declare function verifiedDatabaseBackup(databaseFile: string, backupPath: string, gateId: string, inside?: () => Promise<void>, after?: () => void): Promise<string>;
140
+ export declare function verifiedDatabaseBackup(databaseFile: string, backupPath: string, gateId: string, inside?: () => Promise<void>, after?: () => void, accepts?: SchemaAccepted): Promise<string>;
139
141
  /** A SQLite OS lock releases on updater death. Each irreversible step has a
140
142
  * preceding durable intent; recovery determines the actual side by hashes. */
141
143
  export declare function runDesktopUpdate(stateDir: string, hooks?: UpdateHooks): Promise<void>;
@@ -116,13 +116,14 @@ export function readUpdateJournal(stateDir, retainedReceipt) {
116
116
  }
117
117
  return j;
118
118
  }
119
- function connect(file, readOnly = false) {
119
+ const currentSchemaOnly = version => version === SCHEMA_VERSION;
120
+ function connect(file, readOnly = false, accepts = currentSchemaOnly) {
120
121
  if (!existsSync(file) || lstatSync(file).isSymbolicLink())
121
122
  throw Error("The task database is missing or linked. It was not recreated.");
122
123
  const db = new (sqlite().DatabaseSync)(file, { readOnly });
123
124
  db.exec("PRAGMA busy_timeout=1000");
124
125
  const schema = readSchemaVersion(db);
125
- if (!schema.ok || schema.version !== SCHEMA_VERSION) {
126
+ if (!schema.ok || !accepts(schema.version)) {
126
127
  db.close();
127
128
  throw Error("This update needs the current database schema. Use the separate verified migration procedure; the installed app is unchanged.");
128
129
  }
@@ -515,13 +516,13 @@ async function verifiedBackup(j) {
515
516
  * reservation so no writer slips in between the snapshot and the copy. The
516
517
  * copy drops the update's own admission pause. `inside` runs before the
517
518
  * reservation is released, `after` once it is; returns the copy's hash. */
518
- export async function verifiedDatabaseBackup(databaseFile, backupPath, gateId, inside = async () => { }, after = () => { }) {
519
- const db = connect(databaseFile);
519
+ export async function verifiedDatabaseBackup(databaseFile, backupPath, gateId, inside = async () => { }, after = () => { }, accepts = currentSchemaOnly) {
520
+ const db = connect(databaseFile, false, accepts);
520
521
  try {
521
522
  db.exec("BEGIN IMMEDIATE");
522
523
  // The write reservation prevents a concurrent writer; a separate read
523
524
  // connection is required because SQLite cannot back up a write transaction.
524
- const source = connect(databaseFile, true);
525
+ const source = connect(databaseFile, true, accepts);
525
526
  let before;
526
527
  try {
527
528
  before = snapshot(source);
@@ -531,7 +532,7 @@ export async function verifiedDatabaseBackup(databaseFile, backupPath, gateId, i
531
532
  source.close();
532
533
  }
533
534
  chmodSync(backupPath, 0o600);
534
- const copied = connect(backupPath);
535
+ const copied = connect(backupPath, false, accepts);
535
536
  try {
536
537
  if (copied.prepare("PRAGMA integrity_check").get()?.integrity_check !== "ok" || copied.prepare("PRAGMA foreign_key_check").all().length !== 0 || snapshot(copied) !== before)
537
538
  throw Error("Backup verification failed. The installed app is unchanged.");
@@ -94,8 +94,8 @@ export declare function scopeApprovedForDispatch(store: Store, taskRef: number,
94
94
  export declare function dispatchRoleFor(store: Store, taskRef: number, now: Date): "builder" | "planner" | "scout";
95
95
  /** The first requirement this task fails, in words, or null. */
96
96
  export declare function missingCapability(store: Store, taskRef: number, dispatchRepo: string | null, now: Date): string | null;
97
- /** The read-side lifecycle answer. It changes nothing and grants nothing. */
98
- export declare function diagnoseTaskDispatch(store: Store, taskId: string, now: Date): DispatchDiagnosis | null;
97
+ /** The read-side lifecycle answer. Project access limits dependency detail, never the readiness gate. */
98
+ export declare function diagnoseTaskDispatch(store: Store, taskId: string, now: Date, readableRepos?: readonly string[] | null): DispatchDiagnosis | null;
99
99
  export declare function diagnosisIsDispatchable(diagnosis: DispatchDiagnosis | null): boolean;
100
100
  /** Attach the same lifecycle answer to the bounded fleet snapshot used by
101
101
  * both direct chat and the long-running mate. */
package/dist/dispatch.js CHANGED
@@ -163,8 +163,8 @@ function approvedProfile(scope) {
163
163
  return null;
164
164
  return scope.approvedProfile ?? scope.profile ?? null;
165
165
  }
166
- /** The read-side lifecycle answer. It changes nothing and grants nothing. */
167
- export function diagnoseTaskDispatch(store, taskId, now) {
166
+ /** The read-side lifecycle answer. Project access limits dependency detail, never the readiness gate. */
167
+ export function diagnoseTaskDispatch(store, taskId, now, readableRepos = null) {
168
168
  const task = store.getTask(taskId);
169
169
  const ref = store.lookupRef(taskId);
170
170
  if (task === null || ref === null)
@@ -251,9 +251,13 @@ export function diagnoseTaskDispatch(store, taskId, now) {
251
251
  }
252
252
  if (local?.code === "dependency") {
253
253
  const terminal = local.blockerState === "failed" || local.blockerState === "cancelled";
254
+ const blockerRepo = readableRepos === null ? null : store.lookupRef(local.blockerId)?.repo;
255
+ const hidden = readableRepos !== null && (blockerRepo == null || !readableRepos.includes(blockerRepo));
256
+ const blocker = hidden ? "A task in another project" : local.blockerId;
257
+ const blockerTaskId = hidden ? null : local.blockerId;
254
258
  return terminal
255
- ? answer("terminal-dependency", "waiting", "A required task did not finish", `${local.blockerId} ${local.blockerState === "cancelled" ? "was cancelled" : "failed"} before it finished.`, { action: "repair-dependency", blockerTaskId: local.blockerId })
256
- : answer("waiting-dependency", "waiting", "Waiting for another task", `${local.blockerId} must finish before this task can start.`, { blockerTaskId: local.blockerId });
259
+ ? answer("terminal-dependency", "waiting", "A required task did not finish", `${blocker} ${local.blockerState === "cancelled" ? "was cancelled" : "failed"} before it finished.`, { action: "repair-dependency", blockerTaskId })
260
+ : answer("waiting-dependency", "waiting", "Waiting for another task", `${blocker} must finish before this task can start.`, { blockerTaskId });
257
261
  }
258
262
  const role = dispatchRoleFor(store, ref.id, now);
259
263
  const scope = store.getScope(taskId);
@@ -101,7 +101,11 @@ export declare function repairStaleStatuses(store: Store, now: Date): {
101
101
  exitsRecorded: number;
102
102
  readyForReview: number;
103
103
  };
104
- /** `viewer`: the person asking (the lead asks as its person); null when nobody is known, which shows no lead. */
105
- export declare function installationStatus(store: Store, now: Date, viewer?: string | null): InstallationStatus;
106
- export declare function renderInstallationStatus(status: InstallationStatus): string[];
104
+ /** `viewer`: the person asking (the lead asks as its person); null when nobody is known, which shows no lead.
105
+ * `repos`: the only projects to report (a remote person limited to some projects); null is the whole installation.
106
+ * A limited report counts only those projects' tasks and leaves out what belongs to the installation as a whole:
107
+ * the release check, plan windows and the lead line are empty, never another project's. */
108
+ export declare function installationStatus(store: Store, now: Date, viewer?: string | null, repos?: readonly string[] | null): InstallationStatus;
109
+ /** `limited`: a report over some projects (installationStatus's `repos`), which has no release check or plan windows to state. */
110
+ export declare function renderInstallationStatus(status: InstallationStatus, limited?: boolean): string[];
107
111
  export declare function renderTaskWait(snapshot: TaskWaitSnapshot, outcome?: string): string;