toolroll 0.9.42 → 0.9.44

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 (68) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/dist/action-ledger.d.ts +5 -2
  3. package/dist/action-ledger.js +13 -3
  4. package/dist/chat-actions.js +1 -1
  5. package/dist/chat-approval.d.ts +45 -0
  6. package/dist/chat-approval.js +81 -0
  7. package/dist/chat-channel.js +5 -0
  8. package/dist/chat-decide.d.ts +18 -6
  9. package/dist/chat-decide.js +54 -20
  10. package/dist/chat-flow.js +2 -2
  11. package/dist/cli-http.d.ts +64 -0
  12. package/dist/cli-http.js +143 -0
  13. package/dist/cli.d.ts +3 -0
  14. package/dist/cli.js +8 -1
  15. package/dist/contracts/cli.d.ts +13 -0
  16. package/dist/contracts/cli.js +7 -0
  17. package/dist/contracts/console-api.d.ts +1 -0
  18. package/dist/contracts/console-api.js +1 -0
  19. package/dist/contracts/gateway-tools.d.ts +82 -0
  20. package/dist/contracts/gateway-tools.js +33 -0
  21. package/dist/contracts/lead-tools.d.ts +11 -0
  22. package/dist/contracts/lead-tools.js +2 -0
  23. package/dist/desktop-update.d.ts +3 -1
  24. package/dist/desktop-update.js +7 -6
  25. package/dist/flow-engine.d.ts +2 -0
  26. package/dist/flow-engine.js +10 -2
  27. package/dist/flows-cli.d.ts +28 -0
  28. package/dist/flows-cli.js +43 -1
  29. package/dist/guides.js +2 -0
  30. package/dist/mate-contract.js +1 -1
  31. package/dist/mate-tools.js +43 -1
  32. package/dist/mcp-core.d.ts +110 -0
  33. package/dist/mcp-core.js +311 -0
  34. package/dist/mcp-http.d.ts +26 -0
  35. package/dist/mcp-http.js +151 -0
  36. package/dist/mcp-person.d.ts +31 -0
  37. package/dist/mcp-person.js +96 -0
  38. package/dist/mcp.d.ts +1 -8
  39. package/dist/mcp.js +21 -322
  40. package/dist/operate-remote-arguments.d.ts +36 -0
  41. package/dist/operate-remote-arguments.js +185 -0
  42. package/dist/operate-remote.d.ts +86 -0
  43. package/dist/operate-remote.js +125 -0
  44. package/dist/operate.d.ts +21 -1
  45. package/dist/operate.js +229 -15
  46. package/dist/process-recovery-eligibility.d.ts +1 -1
  47. package/dist/remote-command.d.ts +45 -0
  48. package/dist/remote-command.js +124 -0
  49. package/dist/remote-exec.d.ts +18 -0
  50. package/dist/remote-exec.js +241 -0
  51. package/dist/remote-run.d.ts +16 -0
  52. package/dist/remote-run.js +13 -0
  53. package/dist/scope.d.ts +4 -2
  54. package/dist/scope.js +10 -0
  55. package/dist/serve.d.ts +7 -0
  56. package/dist/serve.js +75 -2
  57. package/dist/store.d.ts +23 -2
  58. package/dist/store.js +76 -9
  59. package/dist/surface.d.ts +10 -3
  60. package/dist/surface.js +71 -3
  61. package/dist/team-cli.d.ts +19 -0
  62. package/dist/team-cli.js +20 -3
  63. package/dist/teammate-work.js +1 -1
  64. package/dist/telegram-flow.d.ts +41 -1
  65. package/dist/telegram-flow.js +96 -12
  66. package/dist/telegram.js +27 -3
  67. package/dist/toolroll-update.js +4 -1
  68. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,42 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.44 — 2026-10-07
4
+
5
+ - **Work from your own laptop.** `toolroll connect https://… --token so_…`
6
+ and allowed commands run on the server as you, with the same output and
7
+ exit codes. Approvals, people, policy and anything touching the server's
8
+ files stay in the console and chat.
9
+ - **Agents connect over HTTPS.** `claude mcp add --transport http toolroll
10
+ https://<host>/mcp --header "Authorization: Bearer so_…"`. Tools cover only
11
+ that person's projects.
12
+ - **Every remote action is on the record**: who, whether it came from the CLI
13
+ or an agent, and the token's name. A revoked token stops on its next call.
14
+ Other projects' tasks and runs answer "not found".
15
+ - **For now, people limited to some projects** can't run the commands that
16
+ list across projects (`status`, `task list`) or choose a task's `--id`;
17
+ they're refused rather than shown a partial list. Filtered views are next.
18
+ - Still to come: token management in the CLI, rate limits, an audit view, a
19
+ real domain with TLS, and sign-in for agents.
20
+
21
+ ## 0.9.43 — 2026-10-06
22
+
23
+ - **Approve from chat and the CLI.** A plan waiting on you and a flow's
24
+ approval or choose step get **Approve** and **Send back** buttons in your
25
+ paired Telegram chat, two taps each, recorded like a console decision. Ask
26
+ the lead to "approve X" and it answers with those buttons; your tap is
27
+ still the approval. `toolroll flows card approve <flow> <card>` and
28
+ `toolroll flows card send-back <flow> <card> --note …` do the same from the
29
+ terminal with your login.
30
+ - **Chat approval can stay on.** `toolroll chat-approval on|off [--repo <p>]`
31
+ turns approving from chat on for one project or all of them, with your
32
+ password to turn it on; off is instant and off for all really is all. It no
33
+ longer lapses with a weekly mode. Plans that widen permissions, go over a
34
+ cap or touch protected paths still go to the console.
35
+ - **Old cards point onward.** A card in a past conversation says where the
36
+ item went and links to it when you can open it.
37
+ - Still to come: Slack, Discord and Teams buttons, a Settings screen for chat
38
+ approval, and sending a plan back from chat.
39
+
3
40
  ## 0.9.42 — 2026-10-06
4
41
 
5
42
  - **Zod revamp, waves 3 and 4.** What comes into Toolroll from outside and
@@ -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);
@@ -1347,7 +1347,7 @@ function runFlowAction(store, payload, actor, repos, root, now) {
1347
1347
  return chosen.ok ? { ok: true, said: chosen.said, card: card.id } : { ok: false, message: chosen.message };
1348
1348
  })()
1349
1349
  : (() => {
1350
- const decided = decideFlowCard(store, { card: card.id, decision: payload.operation === "flow_card_approve" ? "approve" : "send-back", note: typeof req["note"] === "string" ? req["note"] : null, actor, repos, ...(root === undefined ? {} : { evidenceRoot: root }) }, now);
1350
+ const decided = decideFlowCard(store, { card: card.id, decision: payload.operation === "flow_card_approve" ? "approve" : "send-back", note: typeof req["note"] === "string" ? req["note"] : null, actor, repos, where: "Chat", ...(root === undefined ? {} : { evidenceRoot: root }) }, now);
1351
1351
  return decided.ok ? { ok: true, said: decided.said, card: card.id } : { ok: false, message: decided.message };
1352
1352
  })();
1353
1353
  if (!acted.ok)
@@ -0,0 +1,45 @@
1
+ import type { Store } from "./store.js";
2
+ /** The all-projects row's scope. */
3
+ export declare const ALL_PROJECTS = "*";
4
+ export type ChatApprovalLimits = {
5
+ fullAccess: boolean;
6
+ capMicrousd: number | null;
7
+ };
8
+ export type ChatApprovalSetting = ChatApprovalLimits & {
9
+ approver: string;
10
+ scope: string;
11
+ enabled: boolean;
12
+ generation: number;
13
+ digest: string;
14
+ updatedBy: string;
15
+ updatedAt: string;
16
+ };
17
+ /** This person's rows: all projects first, then each project's. */
18
+ export declare function chatApprovalSettings(store: Store, approver: string): ChatApprovalSetting[];
19
+ /** The limits in plain words, said once: what a yes in chat may start. */
20
+ export declare function chatApprovalWords(limits: ChatApprovalLimits): string;
21
+ /** Whether this person's chat may approve on this project now, under their lasting setting: which row, and its limits. */
22
+ export declare function effectiveChatApproval(store: Store, repo: string, approver: string): {
23
+ ok: true;
24
+ setting: ChatApprovalSetting;
25
+ limits: ChatApprovalLimits;
26
+ } | {
27
+ ok: false;
28
+ why: string;
29
+ };
30
+ export type ChatApprovalChange = {
31
+ approver: string;
32
+ scope: string;
33
+ enabled: boolean;
34
+ limits?: ChatApprovalLimits;
35
+ via: string;
36
+ };
37
+ /** Save and ledger the setting; all-projects off also disables every saved project row for this owner.
38
+ * The caller has proved who is asking, and for "on" the password. */
39
+ export declare function setChatApproval(store: Store, change: ChatApprovalChange, now: Date): {
40
+ ok: true;
41
+ said: string;
42
+ } | {
43
+ ok: false;
44
+ message: string;
45
+ };
@@ -0,0 +1,81 @@
1
+ /** Approving from chat as a lasting owner setting. An approver turns it on for all their projects or for one, with the
2
+ * limits they agree to (whether a plan may ask for full access, and the most one attempt may cost); a project's own
3
+ * row outranks the all-projects one, and an explicit "off" on a project keeps it off there. Turning it off for all
4
+ * projects also disables that owner's saved project rows. It is off until turned on.
5
+ *
6
+ * It never lapses by date, but it ends with the account: a reset password, revoked access or a lost project ends it.
7
+ * Turning it on or changing its limits takes the password; turning it off is one step. Every change is a ledger line.
8
+ *
9
+ * What the setting allows is only that the owner's own two taps in their paired chat count as their yes. Every other
10
+ * rule still applies at the offer and again at the tap (chat-decide.ts planInChat): protected paths, separation of
11
+ * duties, organisation policy, written-for-you plans, unresolved routes, and the limits here. */
12
+ import { createHash } from "node:crypto";
13
+ /** The all-projects row's scope. */
14
+ export const ALL_PROJECTS = "*";
15
+ const money = (micro) => `$${(micro / 1_000_000).toFixed(2)}`;
16
+ function digestOf(approver, scope, limits, generation) {
17
+ return createHash("sha256").update(JSON.stringify({ v: 1, approver, scope, fullAccess: limits.fullAccess, capMicrousd: limits.capMicrousd, generation })).digest("hex");
18
+ }
19
+ function readRow(row) {
20
+ return {
21
+ approver: String(row["approver"]), scope: String(row["scope"]), enabled: Number(row["enabled"]) === 1, fullAccess: Number(row["full_access"]) === 1,
22
+ capMicrousd: row["attempt_cap_microusd"] == null ? null : Number(row["attempt_cap_microusd"]), generation: Number(row["generation"]),
23
+ digest: String(row["digest"]), updatedBy: String(row["updated_by"]), updatedAt: String(row["updated_at"]),
24
+ };
25
+ }
26
+ /** This person's rows: all projects first, then each project's. */
27
+ export function chatApprovalSettings(store, approver) {
28
+ return store.handle.prepare("SELECT * FROM chat_approval_setting WHERE approver = ? ORDER BY scope = '*' DESC, scope").all(approver).map(row => readRow(row));
29
+ }
30
+ /** The limits in plain words, said once: what a yes in chat may start. */
31
+ export function chatApprovalWords(limits) {
32
+ return `${limits.fullAccess ? "plans may ask for full access" : "plans that ask for full access open in Toolroll"} · ${limits.capMicrousd === null ? "any attempt limit" : `attempts up to ${money(limits.capMicrousd)}`}`;
33
+ }
34
+ /** Whether this person's chat may approve on this project now, under their lasting setting: which row, and its limits. */
35
+ export function effectiveChatApproval(store, repo, approver) {
36
+ const project = store.handle.prepare("SELECT * FROM chat_approval_setting WHERE approver = ? AND scope = ?").get(approver, repo);
37
+ const all = store.handle.prepare("SELECT * FROM chat_approval_setting WHERE approver = ? AND scope = ?").get(approver, ALL_PROJECTS);
38
+ const row = project ?? all;
39
+ const setting = row === undefined ? null : readRow(row);
40
+ if (setting === null || !setting.enabled)
41
+ return { ok: false, why: "Approving from chat isn't turned on for this project." };
42
+ const account = store.accountOf(approver);
43
+ if (account === null || account.revokedAt !== null || account.role !== "approver" || account.generation !== setting.generation || !store.accountCanAccess(approver, repo)) {
44
+ return { ok: false, why: "Your access changed since you turned on approving from chat. Turn it on again in Settings." };
45
+ }
46
+ if (setting.digest !== digestOf(setting.approver, setting.scope, setting, setting.generation))
47
+ return { ok: false, why: "Your chat approval setting can't be read. Turn it on again in Settings." };
48
+ return { ok: true, setting, limits: { fullAccess: setting.fullAccess, capMicrousd: setting.capMicrousd } };
49
+ }
50
+ /** Save and ledger the setting; all-projects off also disables every saved project row for this owner.
51
+ * The caller has proved who is asking, and for "on" the password. */
52
+ export function setChatApproval(store, change, now) {
53
+ const account = store.accountOf(change.approver);
54
+ if (account === null || account.revokedAt !== null || account.role !== "approver")
55
+ return { ok: false, message: "Only an approver can approve from chat." };
56
+ if (change.scope !== ALL_PROJECTS && !store.accountCanAccess(change.approver, change.scope))
57
+ return { ok: false, message: "That isn't one of your projects." };
58
+ const limits = change.limits ?? { fullAccess: false, capMicrousd: null };
59
+ if (limits.capMicrousd !== null && (!Number.isSafeInteger(limits.capMicrousd) || limits.capMicrousd < 0))
60
+ return { ok: false, message: "Give the attempt limit in dollars, like 5." };
61
+ const where = change.scope === ALL_PROJECTS ? "all your projects" : change.scope;
62
+ store.transact(() => {
63
+ const settings = [{ scope: change.scope, limits }];
64
+ if (change.scope === ALL_PROJECTS && !change.enabled) {
65
+ for (const setting of chatApprovalSettings(store, change.approver)) {
66
+ if (setting.scope !== ALL_PROJECTS)
67
+ settings.push({ scope: setting.scope, limits: setting });
68
+ }
69
+ }
70
+ const save = store.handle.prepare(`INSERT INTO chat_approval_setting (approver, scope, enabled, full_access, attempt_cap_microusd, generation, digest, updated_by, updated_at)
71
+ VALUES (?,?,?,?,?,?,?,?,?) ON CONFLICT (approver, scope) DO UPDATE SET enabled = excluded.enabled, full_access = excluded.full_access,
72
+ attempt_cap_microusd = excluded.attempt_cap_microusd, generation = excluded.generation, digest = excluded.digest, updated_by = excluded.updated_by, updated_at = excluded.updated_at`);
73
+ for (const { scope, limits } of settings) {
74
+ save.run(change.approver, scope, change.enabled ? 1 : 0, limits.fullAccess ? 1 : 0, limits.capMicrousd, account.generation, digestOf(change.approver, scope, limits, account.generation), change.approver, now.toISOString());
75
+ store.recordAction({ at: now.toISOString(), actor: change.approver, repo: scope === ALL_PROJECTS ? null : scope, taskId: null, runId: null,
76
+ action: "chat approval setting", outcome: change.enabled ? "on" : "off", source: "request",
77
+ detail: `${scope === ALL_PROJECTS ? "all projects" : "this project"}${change.enabled ? ` · ${chatApprovalWords(limits)}` : ""} · via ${change.via}` });
78
+ }
79
+ });
80
+ return { ok: true, said: change.enabled ? `Approving from chat is on for ${where}: ${chatApprovalWords(limits)}.` : `Approving from chat is off for ${where}.` };
81
+ }
@@ -556,6 +556,11 @@ export const CHAT_ACTION_PARITY = {
556
556
  how: "Shared read-only acceptance packet: exact result, criterion states, gate, reviewer findings, caveats and recorded human acceptance. Screenshot files use get_result_images; marking complete is a propose_action result_accept confirmed behind the phone's own yes/cancel challenge or on the signed-in console.",
557
557
  gap: "A physical-phone completion has not been exercised.",
558
558
  },
559
+ offer_approval: {
560
+ support: "direct",
561
+ how: "Pushes the plan's or flow card's own decision card to the owner's paired chat: Approve arms Yes and Cancel, and only the owner's Yes decides, through sealScopeApproval (as a chat-origin approval) or decideFlowCard. Outside the owner's chat-approval setting or a decision's rules it answers why, and the lead opens the console control.",
562
+ gap: "A flow card's Send back, Edit and a Person chooses step act from the pushed card; a plan's Send back stays in the console. Fixture proof only, no physical-phone rendering.",
563
+ },
559
564
  show_control: {
560
565
  support: "handoff",
561
566
  how: "The card names the control and the task, with one url button to that exact console control when a trusted https console-url is configured; the button opens the signed-in console and acts on nothing.",
@@ -4,14 +4,15 @@
4
4
  * and each button is one opaque token bound to the exact result, receipt, plan or commit the card showed: a card a
5
5
  * newer result, a finished task or a changed plan has overtaken says so and acts on nothing.
6
6
  *
7
- * Approving a plan or merging needs the password, which never goes in chat, so they act in chat only under a
8
- * repository's signed mode that carries the chatApprove term, for the signer's own chat; otherwise, and for a plan
9
- * that widens permissions, exceeds the mode's budget or touches protected paths, the card keeps its link.
7
+ * Approving a plan or merging needs the password, which never goes in chat, so they act in chat only where the owner
8
+ * turned approving from chat on (their lasting setting, chat-approval.ts, or a signed mode's chatApprove term), for
9
+ * their own chat; otherwise, and for a plan that widens permissions, exceeds the limit or touches protected paths, the
10
+ * card keeps its link.
10
11
  *
11
12
  * Channel-agnostic: Telegram renders these today, and Slack, Discord and Teams reach the same acts through the same
12
13
  * tokens (the `channel` column). Every act runs through the door the console uses. */
13
14
  import { DECIDE_ACTS } from "./contracts/chat-callback-rows.js";
14
- import { type ModeTerms } from "./modes.js";
15
+ import { type ChatApprovalLimits } from "./chat-approval.js";
15
16
  import { type VerifiedApprover } from "./principal.js";
16
17
  import type { Scope } from "./scope.js";
17
18
  import type { Store } from "./store.js";
@@ -77,11 +78,14 @@ export type DecideOffer = {
77
78
  text: string | null;
78
79
  rows: Array<Array<ActSpec | DecideLink>>;
79
80
  };
80
- /** The live mode on this repository, when it lets this person's chat approve: the term signed, by them. */
81
+ /** What lets this person's chat approve on this repository now: their lasting chat-approval setting (chat-approval.ts),
82
+ * or else a live signed mode carrying the chatApprove term, signed by them. `digest` names that authority for the
83
+ * ledger; the yes itself is the person's tap and is sealed as theirs, so it doesn't lapse when the authority does. */
81
84
  export declare function chatApproveMode(store: Store, repo: string | null, approver: string, now: Date): {
82
85
  ok: true;
83
86
  digest: string;
84
- terms: ModeTerms;
87
+ limits: ChatApprovalLimits;
88
+ source: "setting" | "mode";
85
89
  } | {
86
90
  ok: false;
87
91
  why: string;
@@ -93,6 +97,7 @@ export declare function permissionPlainWords(profile: Scope["profile"] | null |
93
97
  export declare function planInChat(store: Store, taskId: string, approver: string, now: Date, root?: string): {
94
98
  ok: true;
95
99
  modeDigest: string;
100
+ source: "setting" | "mode";
96
101
  digest: string;
97
102
  scope: Scope;
98
103
  } | {
@@ -103,6 +108,7 @@ export declare function planInChat(store: Store, taskId: string, approver: strin
103
108
  export declare function mergeInChat(store: Store, taskId: string, run: number, approver: string, now: Date): {
104
109
  ok: true;
105
110
  modeDigest: string;
111
+ source: "setting" | "mode";
106
112
  head: string;
107
113
  } | {
108
114
  ok: false;
@@ -160,6 +166,12 @@ export type ChatMerge = {
160
166
  };
161
167
  /** The links a card keeps once its acts are spent or stale: where to look, never a token. */
162
168
  export declare function linksFor(target: DecideTarget): DecideButton[][];
169
+ /** Where a plan card's task is now, for a card whose buttons no longer act: the family's current version, named only
170
+ * inside this person's projects, and linked only while it still waits on them. Never a live button. */
171
+ export declare function planWhereNow(store: Store, taskId: string, who: VerifiedApprover, now: Date, root?: string): {
172
+ said: string;
173
+ rows: DecideButton[][];
174
+ };
163
175
  /**
164
176
  * One tap on a decide button, inside the caller's transaction. Null: not this module's token. The seat must be the
165
177
  * token's own (person, chat, message); a spent, expired or foreign button acts on nothing.
@@ -4,9 +4,10 @@
4
4
  * and each button is one opaque token bound to the exact result, receipt, plan or commit the card showed: a card a
5
5
  * newer result, a finished task or a changed plan has overtaken says so and acts on nothing.
6
6
  *
7
- * Approving a plan or merging needs the password, which never goes in chat, so they act in chat only under a
8
- * repository's signed mode that carries the chatApprove term, for the signer's own chat; otherwise, and for a plan
9
- * that widens permissions, exceeds the mode's budget or touches protected paths, the card keeps its link.
7
+ * Approving a plan or merging needs the password, which never goes in chat, so they act in chat only where the owner
8
+ * turned approving from chat on (their lasting setting, chat-approval.ts, or a signed mode's chatApprove term), for
9
+ * their own chat; otherwise, and for a plan that widens permissions, exceeds the limit or touches protected paths, the
10
+ * card keeps its link.
10
11
  *
11
12
  * Channel-agnostic: Telegram renders these today, and Slack, Discord and Teams reach the same acts through the same
12
13
  * tokens (the `channel` column). Every act runs through the door the console uses. */
@@ -17,6 +18,7 @@ import { chatControlHref, chatResultHref } from "./chat-controls.js";
17
18
  import { applyChatTaskAction, chatTaskStamp } from "./chat-task-actions.js";
18
19
  import { chatTitle } from "./chat-voice.js";
19
20
  import { readVerifiedArtifact } from "./evidence.js";
21
+ import { effectiveChatApproval } from "./chat-approval.js";
20
22
  import { modeTermsFromJson } from "./modes.js";
21
23
  import { parseExecutionPlanDocument } from "./plan.js";
22
24
  import { verifyApproverStanding } from "./principal.js";
@@ -45,15 +47,22 @@ const sha = (text) => createHash("sha256").update(text).digest("hex");
45
47
  const isAct = (one) => "act" in one;
46
48
  const title = (store, taskId) => phoneText(chatTitle(store, taskId), 60);
47
49
  // ---- the chatApprove term ----------------------------------------------------------
48
- /** The live mode on this repository, when it lets this person's chat approve: the term signed, by them. */
50
+ /** What lets this person's chat approve on this repository now: their lasting chat-approval setting (chat-approval.ts),
51
+ * or else a live signed mode carrying the chatApprove term, signed by them. `digest` names that authority for the
52
+ * ledger; the yes itself is the person's tap and is sealed as theirs, so it doesn't lapse when the authority does. */
49
53
  export function chatApproveMode(store, repo, approver, now) {
50
- const mode = repo === null ? null : store.activeMode(repo, now);
54
+ if (repo === null)
55
+ return { ok: false, why: "Approving from chat isn't turned on for this project." };
56
+ const setting = effectiveChatApproval(store, repo, approver);
57
+ if (setting.ok)
58
+ return { ok: true, digest: setting.setting.digest, limits: setting.limits, source: "setting" };
59
+ const mode = store.activeMode(repo, now);
51
60
  const terms = mode === null ? null : modeTermsFromJson(mode.termsJson);
52
61
  if (mode === null || terms === null || !terms.chatApprove)
53
- return { ok: false, why: "Approving from chat isn't turned on for this project." };
62
+ return setting;
54
63
  if (mode.signedBy !== approver || !store.accountCanAccess(approver, repo))
55
64
  return { ok: false, why: "Only the person who signed this project's mode can approve from chat." };
56
- return { ok: true, digest: mode.digest, terms };
65
+ return { ok: true, digest: mode.digest, limits: { fullAccess: terms.permissionDefault === "escalated", capMicrousd: terms.perAttemptBudgetMicrousd }, source: "mode" };
57
66
  }
58
67
  /** What a plan's yes allows an agent to do, in a few plain words (the approval sheet's "You're allowing"). */
59
68
  export function permissionPlainWords(profile) {
@@ -102,11 +111,12 @@ export function planInChat(store, taskId, approver, now, root) {
102
111
  return { ok: false, why: "Agents compete on this task, so you approve it in Toolroll." };
103
112
  if (scope.profileState === "unresolved" || scope.routeEra == null || (scope.termsProblem ?? null) !== null)
104
113
  return { ok: false, why: "This plan can't say exactly which agents run, so you approve it in Toolroll." };
105
- if (fullAccess(scope.profile) && mode.terms.permissionDefault !== "escalated")
106
- return { ok: false, why: "This plan asks for more access than your mode allows." };
107
- const cap = mode.terms.perAttemptBudgetMicrousd;
114
+ const yours = mode.source === "setting" ? "your chat approval setting" : "your mode";
115
+ if (fullAccess(scope.profile) && !mode.limits.fullAccess)
116
+ return { ok: false, why: `This plan asks for more access than ${yours} allows.` };
117
+ const cap = mode.limits.capMicrousd;
108
118
  if (cap !== null && (scope.budgetMicrousd === null || scope.budgetMicrousd > cap))
109
- return { ok: false, why: `This plan has ${scope.budgetMicrousd === null ? "no attempt limit" : `a ${money(scope.budgetMicrousd)} attempt limit`}, more than your mode's ${money(cap)}.` };
119
+ return { ok: false, why: `This plan has ${scope.budgetMicrousd === null ? "no attempt limit" : `a ${money(scope.budgetMicrousd)} attempt limit`}, more than ${yours === "your mode" ? "your mode's" : "your chat approval limit of"} ${money(cap)}.` };
110
120
  if (store.approvalGate(taskId, approver, "mode").verdict !== "seal")
111
121
  return { ok: false, why: "This plan touches protected work, so two people approve it in Toolroll." };
112
122
  if (store.scopePolicyRefusal(taskId) !== null)
@@ -137,7 +147,7 @@ export function planInChat(store, taskId, approver, now, root) {
137
147
  if (!verified)
138
148
  return { ok: false, why: "The saved plan can't be verified here, so you approve it in Toolroll." };
139
149
  }
140
- return { ok: true, modeDigest: mode.digest, scope, digest: sha(`${scope.digest}:${plan?.sha256 ?? ""}`) };
150
+ return { ok: true, modeDigest: mode.digest, source: mode.source, scope, digest: sha(`${scope.digest}:${plan?.sha256 ?? ""}`) };
141
151
  }
142
152
  /** A ready pull request this person's chat may merge now, bound to the exact commit. */
143
153
  export function mergeInChat(store, taskId, run, approver, now) {
@@ -153,7 +163,7 @@ export function mergeInChat(store, taskId, run, approver, now) {
153
163
  const mode = chatApproveMode(store, ref.repo, approver, now);
154
164
  if (!mode.ok)
155
165
  return mode;
156
- return { ok: true, modeDigest: mode.digest, head: publication.headSha };
166
+ return { ok: true, modeDigest: mode.digest, source: mode.source, head: publication.headSha };
157
167
  }
158
168
  /** The plan card: title, goal in two lines, what changes, done when, and what the yes allows. On Telegram the yes's
159
169
  * terms come second, under the title, and the steps are fewer: the full plan is behind Edit. */
@@ -380,6 +390,27 @@ export function linksFor(target) {
380
390
  case "failed": return [[{ label: "Open task", link: { label: "Open task", path: chatControlHref("task", target.taskId) } }]];
381
391
  }
382
392
  }
393
+ /** Where a plan card's task is now, for a card whose buttons no longer act: the family's current version, named only
394
+ * inside this person's projects, and linked only while it still waits on them. Never a live button. */
395
+ export function planWhereNow(store, taskId, who, now, root) {
396
+ const family = store.taskFamilyOf(taskId, who.repos, false);
397
+ if (family === null)
398
+ return { said: "It has moved on.", rows: [] };
399
+ const current = family.current.id;
400
+ const name = title(store, current);
401
+ const state = store.getTask(current)?.state;
402
+ if (state === "done" || state === "cancelled") {
403
+ const assignment = assignmentOf(store, current, now, { principal: "operator", repos: who.repos }, root);
404
+ if (assignment?.state !== "ready-to-check")
405
+ return { said: state === "cancelled" ? `"${name}" was cancelled.` : `"${name}" is finished.`, rows: [] };
406
+ return { said: `"${name}" has a result waiting for you.`, rows: [[{ label: "Open the task", link: { label: "Open the task", path: chatControlHref("task", current) } }]] };
407
+ }
408
+ const scope = store.getScope(current);
409
+ if (scope !== null && !(scope.approvedDigest != null && scope.approvedDigest === scope.digest) && store.lookupRef(current)?.plan !== "requested") {
410
+ return { said: `"${name}" waits for your approval${current === taskId ? "" : " (a newer version)"}.`, rows: [[{ label: "Review & start", link: { label: "Review & start", path: chatControlHref("approval", current) } }]] };
411
+ }
412
+ return { said: `"${name}" is under way.`, rows: [] };
413
+ }
383
414
  const QUESTION_STARTS = ["Accept and finish \"", "Retry \"", "Approve and start \"", "Merge \""];
384
415
  /** The card's words without a question this module appended. */
385
416
  function cardBody(shown) {
@@ -417,14 +448,16 @@ export function applyDecideTap(store, seat, input) {
417
448
  store.handle.prepare("UPDATE chat_decide_action SET consumed_at = ? WHERE token = ? AND consumed_at IS NULL").run(now.toISOString(), row.token);
418
449
  retireDecideTokens(store, seat.channel, seat.chat, input.message, now);
419
450
  };
451
+ // A plan's card names where its task is now (its newest version, or finished), and links it only while it waits.
452
+ const here = target.kind === "plan" ? planWhereNow(store, row.taskId, who, now, input.root) : null;
420
453
  const stale = (why) => {
421
454
  spend();
422
- return { ack: why, edit: { text: `${body}\n\n${why} Nothing was done.`, rows: linksFor(target), tokens: [] } };
455
+ return { ack: why, edit: { text: `${body}\n\n${why} Nothing was done.${here === null || here.rows.length > 0 && why.startsWith("The plan changed") ? "" : ` ${here.said}`}`, rows: here?.rows ?? linksFor(target), tokens: [] } };
423
456
  };
424
- // An expired button: the card says so and keeps its links, so it is never a dead end.
457
+ // An expired button: the card says so and where the work is now, so it is never a dead end; the old button never acts.
425
458
  if (row.expiresAt <= now.toISOString()) {
426
459
  spend();
427
- return { ack: "That button expired.", edit: { text: `${body}\n\nThat button expired. Nothing was done.`, rows: linksFor(target), tokens: [] } };
460
+ return { ack: "That button expired.", edit: { text: `${body}\n\nThat button expired. Nothing was done.${here === null ? "" : ` ${here.said}`}`, rows: here?.rows ?? linksFor(target), tokens: [] } };
428
461
  }
429
462
  if (row.phase === "cancel") {
430
463
  spend();
@@ -475,17 +508,18 @@ export function applyDecideTap(store, seat, input) {
475
508
  const plan = planInChat(store, row.taskId, who.name, now, input.root);
476
509
  if (!plan.ok)
477
510
  return notDone(plan.why);
478
- if (!store.sealScopeApproval(row.taskId, who.name, now, {}, { kind: "mode", modeDigest: plan.modeDigest }))
511
+ // The owner's own yes, sealed as theirs ("chat"), never as a mode's: it stands like the console's after the mode or setting ends.
512
+ if (!store.sealScopeApproval(row.taskId, who.name, now, {}, { kind: "chat", modeDigest: plan.modeDigest }))
479
513
  return notDone("the plan couldn't be approved from chat. Open it in Toolroll.");
480
514
  store.recordAction({ at: now.toISOString(), actor: who.name, repo: ref.repo, taskId: row.taskId, runId: null, action: "plan approved in chat", outcome: "approved",
481
- source: "request", detail: `via ${seat.channel} · chat binding #${seat.binding} · mode ${plan.modeDigest}` });
482
- return done("✓ Approved under your chat approval mode. Work starts when a worker is free.");
515
+ source: "request", detail: `via ${seat.channel} · chat binding #${seat.binding} · ${plan.source} ${plan.modeDigest}` });
516
+ return done("✓ Approved. Work starts when a worker is free.");
483
517
  }
484
518
  case "merge": {
485
519
  const merge = mergeInChat(store, row.taskId, row.run ?? 0, who.name, now);
486
520
  if (!merge.ok)
487
521
  return notDone(merge.why);
488
- const via = `via ${seat.channel} · chat binding #${seat.binding} · mode ${merge.modeDigest} · ${merge.head.slice(0, 12)}`;
522
+ const via = `via ${seat.channel} · chat binding #${seat.binding} · ${merge.source} ${merge.modeDigest} · ${merge.head.slice(0, 12)}`;
489
523
  // The approval is ledgered with the tap, so a bridge that stops before GitHub answers still leaves it on record;
490
524
  // what GitHub then did is its own line (recordChatMerge).
491
525
  store.recordAction({ at: now.toISOString(), actor: who.name, repo: ref.repo, taskId: row.taskId, runId: row.run ?? null, action: "merge approved in chat", outcome: "approved",
package/dist/chat-flow.js CHANGED
@@ -190,7 +190,7 @@ export function applyChatFlowTap(options, event, binding, token, repos, now) {
190
190
  }
191
191
  const kind = String(action["action"]);
192
192
  if (kind === "approve") {
193
- const decided = decideFlowCard(store, { card, decision: "approve", note: null, actor: binding.approver, repos, entry }, now);
193
+ const decided = decideFlowCard(store, { card, decision: "approve", note: null, actor: binding.approver, repos, entry, where: options.label }, now);
194
194
  if (!decided.ok) {
195
195
  say(decided.message);
196
196
  return true;
@@ -441,7 +441,7 @@ export function answerChatFlowPrompt(options, event, binding, input, repos, now)
441
441
  state.plan(event.id, decisionParts(`${waiting.flow.name}: your version of the draft for “${waiting.card.title}”\n\n${kept}\n\nApprove to send it as written.`, { card, entry, actions: actionsFor(fresh) }, { label: "Open", path: flowCardHref(waiting.flow.id, card) }), now);
442
442
  return true;
443
443
  }
444
- const decided = decideFlowCard(store, { card, decision: "send-back", note: said, actor: binding.approver, repos, entry }, now);
444
+ const decided = decideFlowCard(store, { card, decision: "send-back", note: said, actor: binding.approver, repos, entry, where: options.label }, now);
445
445
  if (!decided.ok) {
446
446
  close();
447
447
  say(decided.message);
@@ -0,0 +1,64 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http';
2
+ import type { Store } from './store.js';
3
+ import { type RemoteCommandLookup } from './remote-command.js';
4
+ /**
5
+ * `POST /api/cli` (remote CLI, Phase 1): a teammate's laptop sends the argv it would have run locally, and the central
6
+ * server runs that same command as that person. There is no per-resource API here and no second policy: the transport
7
+ * authenticates one API token, checks the shared command contract and transport arguments, and hands the argv to
8
+ * `runOperateAs`, which owns scope, project grants and attribution.
9
+ *
10
+ * Only `Authorization: Bearer so_…` is accepted — never a password, cookie or the owner's saved login — and a request
11
+ * carrying a browser Origin is refused, so a page can never drive it.
12
+ */
13
+ /** Who a remote command runs as. The shared seam with operate.ts (remote-principal). */
14
+ export type Principal = {
15
+ kind: 'person';
16
+ account: string;
17
+ generation: number;
18
+ scope: 'read' | 'act';
19
+ tokenId: string;
20
+ projects: string[] | null;
21
+ };
22
+ export type RunOperateAs = (argv: string[], opts: {
23
+ principal: Principal;
24
+ store: Store;
25
+ write: (s: string) => void;
26
+ files?: Record<string, string>;
27
+ source?: 'api' | 'mcp';
28
+ }) => Promise<number>;
29
+ export type CliRequest = {
30
+ argv: string[];
31
+ files: Record<string, string>;
32
+ };
33
+ export type CliReply = {
34
+ exitCode: number;
35
+ stdout: string;
36
+ stderr: string;
37
+ };
38
+ export type CliFailure = {
39
+ ok: false;
40
+ code: string;
41
+ message: string;
42
+ };
43
+ /** Inlined file contents, summed as UTF-8 bytes. */
44
+ export declare const CLI_FILES_BYTES: number;
45
+ /** The whole JSON body: the files plus argv and JSON escaping headroom. */
46
+ export declare const CLI_REQUEST_BYTES: number;
47
+ export type CliHttpOptions = {
48
+ /** The live API token's person, or null. Only called for a single, well-formed `Bearer so_…` header. */
49
+ authenticate: (request: IncomingMessage) => Principal | null;
50
+ /** The shared command boundary; null when this server cannot run remote commands. */
51
+ run: () => Promise<RunOperateAs | null>;
52
+ /** Tests: command policy while the parallel command contract is not yet available. Defaults to COMMAND_GUIDE. */
53
+ modeOf?: RemoteCommandLookup | undefined;
54
+ store: Store;
55
+ };
56
+ /** Decode the request's shape and byte limits. The endpoint checks file references against its command policy. */
57
+ export declare function cliRequest(value: unknown): CliRequest | {
58
+ problem: string;
59
+ tooLarge?: true;
60
+ };
61
+ /** The longest one request may hold the server: the client polls again for longer waits. */
62
+ export declare const CLI_WAIT_SECONDS = 25;
63
+ /** Handles exactly `/api/cli`; false for any other path. */
64
+ export declare function handleCliHttp(request: IncomingMessage, response: ServerResponse, options: CliHttpOptions): Promise<boolean>;