toolroll 0.9.72 → 0.9.73

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 (63) hide show
  1. package/CHANGELOG.md +76 -0
  2. package/dist/agent-fence.d.ts +5 -0
  3. package/dist/agent-fence.js +42 -7
  4. package/dist/browser/workspace.js +28 -28
  5. package/dist/chat-actions.js +13 -8
  6. package/dist/chat-decide.d.ts +47 -1
  7. package/dist/chat-decide.js +170 -4
  8. package/dist/check-report.js +2 -1
  9. package/dist/coding-provider.js +4 -2
  10. package/dist/commit-extract.d.ts +17 -0
  11. package/dist/commit-extract.js +82 -4
  12. package/dist/contracts/console-api.d.ts +3 -3
  13. package/dist/contracts/console-api.js +4 -4
  14. package/dist/contracts/release-terms.d.ts +7 -1
  15. package/dist/contracts/release-terms.js +6 -2
  16. package/dist/exec.d.ts +1 -1
  17. package/dist/exec.js +7 -3
  18. package/dist/flow-gallery-ui.js +4 -2
  19. package/dist/flow-gallery.d.ts +3 -0
  20. package/dist/flow-gallery.js +6 -2
  21. package/dist/flow-triggers.d.ts +8 -0
  22. package/dist/flow-triggers.js +15 -0
  23. package/dist/flows-cli.js +13 -3
  24. package/dist/guides.js +10 -1
  25. package/dist/kits-ui.js +4 -2
  26. package/dist/kits.d.ts +6 -4
  27. package/dist/kits.js +9 -11
  28. package/dist/mcp-connect.d.ts +10 -0
  29. package/dist/mcp-connect.js +2 -0
  30. package/dist/project-tools.js +3 -1
  31. package/dist/protected-storage.d.ts +67 -0
  32. package/dist/protected-storage.js +398 -0
  33. package/dist/release-adapters.d.ts +8 -1
  34. package/dist/release-adapters.js +80 -14
  35. package/dist/release-authority.d.ts +6 -0
  36. package/dist/release-authority.js +9 -0
  37. package/dist/release-card.d.ts +76 -0
  38. package/dist/release-card.js +252 -0
  39. package/dist/release-carry.d.ts +3 -0
  40. package/dist/release-carry.js +23 -4
  41. package/dist/release-cli.js +36 -3
  42. package/dist/release-git.d.ts +43 -0
  43. package/dist/release-git.js +106 -0
  44. package/dist/release.d.ts +8 -2
  45. package/dist/release.js +79 -6
  46. package/dist/scope.d.ts +7 -0
  47. package/dist/scope.js +22 -5
  48. package/dist/server/flows.js +40 -10
  49. package/dist/server/render-settings.js +1 -1
  50. package/dist/server/settings.js +26 -5
  51. package/dist/setup-guide.d.ts +2 -2
  52. package/dist/store.d.ts +3 -0
  53. package/dist/store.js +6 -0
  54. package/dist/subagent-memory.d.ts +1 -0
  55. package/dist/subagent-memory.js +1 -1
  56. package/dist/subagent-tools.d.ts +22 -6
  57. package/dist/subagent-tools.js +44 -8
  58. package/dist/subagent-work.d.ts +2 -0
  59. package/dist/subagent-work.js +5 -2
  60. package/dist/subagents-ui.js +6 -4
  61. package/dist/telegram.d.ts +6 -1
  62. package/dist/telegram.js +58 -6
  63. package/package.json +1 -1
@@ -56,10 +56,20 @@ export type OfferedTool = {
56
56
  export declare function offeredTools(store: Store, mate: SubagentRow): OfferedTool[];
57
57
  /** Before a turn: re-list tools whose listing is old (or never happened), so the turn sees what they offer today. A tool that can't be reached keeps its last listing. */
58
58
  export declare function refreshGrants(store: Store, mate: SubagentRow, now: Date, io: ToolIo): Promise<void>;
59
- /** Let a subagent use one of the project's tools. Its actions start at their defaults: reading is free, everything else asks first. */
60
- export declare function grantTool(store: Store, mate: SubagentRow, tool: string, by: string, now: Date, io?: ToolIo): Promise<Done>;
61
- /** A grant from what the tool offers (or, not reachable now, the names its last test found). */
62
- export declare function grantListed(store: Store, mate: SubagentRow, tool: ProjectTool, actions: ToolActionInfo[] | null, by: string, now: Date): Done;
59
+ /** Let a subagent use one of the project's tools. Its actions start at their defaults: reading is free, everything else asks first.
60
+ * A grant widens what it does unasked, so it takes the person's proof (a password, or a session under Passwordless):
61
+ * `widening: "proved"` (chat-management.md §0a.3). */
62
+ export declare function grantTool(store: Store, mate: SubagentRow, tool: string, by: string, now: Date, io?: ToolIo,
63
+ /** `stillProved`: the proof re-checked after the listing's wait, in the same synchronous step as the save. */
64
+ options?: {
65
+ widening?: "proved";
66
+ stillProved?: () => boolean;
67
+ }): Promise<Done>;
68
+ /** A grant from what the tool offers (or, not reachable now, the names its last test found). Every road to a grant
69
+ * comes here: it takes the person's proof, and a tool it already uses keeps its rules (a grant never resets them). */
70
+ export declare function grantListed(store: Store, mate: SubagentRow, tool: ProjectTool, actions: ToolActionInfo[] | null, by: string, now: Date, options?: {
71
+ widening?: "proved";
72
+ }): Done;
63
73
  export declare function revokeTool(store: Store, mate: SubagentRow, tool: string, by: string, now?: Date): Done;
64
74
  /** Check one rule: an action the tool offers, a known use, and a limit on one of its number fields. */
65
75
  export declare function checkRule(grant: Pick<SubagentGrantRow, "tool" | "actions">, action: string, rule: {
@@ -76,7 +86,11 @@ export declare function checkRule(grant: Pick<SubagentGrantRow, "tool" | "action
76
86
  ok: false;
77
87
  said: string;
78
88
  };
79
- /** Set the rules for some of a granted tool's actions; the others keep theirs. */
89
+ /** Whether `next` lets the subagent do more unasked than `before` (chat-management.md §0a.3): a higher rank, or a limit
90
+ * on another number or a higher one. The undo action is never authority. */
91
+ export declare function ruleLoosens(before: ToolRule, next: ToolRule): boolean;
92
+ /** Set the rules for some of a granted tool's actions; the others keep theirs. Loosening any of them takes the
93
+ * person's proof (a password, or a session under Passwordless): `widening: "proved"`; tightening never does. */
80
94
  export declare function setToolRules(store: Store, mate: SubagentRow, tool: string, changes: Record<string, {
81
95
  use: unknown;
82
96
  limit?: {
@@ -84,7 +98,9 @@ export declare function setToolRules(store: Store, mate: SubagentRow, tool: stri
84
98
  over: unknown;
85
99
  } | null;
86
100
  undo?: unknown;
87
- }>, by: string, now: Date): Done;
101
+ }>, by: string, now: Date, options?: {
102
+ widening?: "proved";
103
+ }): Done;
88
104
  /**
89
105
  * Make one call now: a free one, or one a person approved (exactly as they
90
106
  * saw it). What the tool said is kept on the receipt, secrets scrubbed; a
@@ -116,7 +116,14 @@ export async function refreshGrants(store, mate, now, io) {
116
116
  const listed = await listed_(store, tool, mate.repo, io);
117
117
  if (!listed.ok)
118
118
  continue;
119
- store.saveSubagentGrant({ subagent: mate.id, tool: grant.tool, ...withActions(grant.rules, listed.actions), listedAt: now.toISOString() }, grant.updatedBy, now);
119
+ // The grant as it is now, after the listing's wait, in one transaction with the save: one taken away meanwhile stays
120
+ // gone, and only the tool's list of actions is new (rules changed meanwhile, tightened ones included, stand).
121
+ store.transact(() => {
122
+ const current = store.subagentGrant(mate.id, grant.tool);
123
+ if (current === null)
124
+ return;
125
+ store.saveSubagentGrant({ subagent: mate.id, tool: grant.tool, ...withActions(current.rules, listed.actions), listedAt: now.toISOString() }, current.updatedBy, now);
126
+ });
120
127
  }
121
128
  }
122
129
  const listed_ = (store, tool, repo, io) => (io.listTools ?? ((spec, values) => listProjectToolActions(spec, values, { timeoutMs: 60_000, omitEnv: ALL_CREDENTIAL_ENV, server: serverOf(store, repo, io) })))(tool.spec, readToolSecrets(repo, tool.name, io.toolHome));
@@ -124,18 +131,32 @@ const listed_ = (store, tool, repo, io) => (io.listTools ?? ((spec, values) => l
124
131
  const serverOf = (store, repo, io) => toolServerFor(store, repo, { ...(io.toolHome === undefined ? {} : { home: io.toolHome }), ...(io.fenceAvailable === undefined ? {} : { available: io.fenceAvailable }) });
125
132
  /** Names only, from the tool's last test: what a grant starts with when it can't be listed right now. */
126
133
  const namesOnly = (tool) => (tool.lastTest?.tools ?? []).map(name => ({ name, about: "", input: null, readOnly: false }));
127
- /** Let a subagent use one of the project's tools. Its actions start at their defaults: reading is free, everything else asks first. */
128
- export async function grantTool(store, mate, tool, by, now, io = {}) {
134
+ /** Let a subagent use one of the project's tools. Its actions start at their defaults: reading is free, everything else asks first.
135
+ * A grant widens what it does unasked, so it takes the person's proof (a password, or a session under Passwordless):
136
+ * `widening: "proved"` (chat-management.md §0a.3). */
137
+ export async function grantTool(store, mate, tool, by, now, io = {},
138
+ /** `stillProved`: the proof re-checked after the listing's wait, in the same synchronous step as the save. */
139
+ options = {}) {
140
+ if (options.widening !== "proved")
141
+ return grantRefused(tool);
129
142
  const found = projectToolsOf(store, mate.repo).find(one => one.name === tool);
130
143
  if (found === undefined)
131
144
  return { ok: false, said: `This project has no tool called ${tool}. Add it on the Tools page first.` };
132
145
  if (store.subagentGrant(mate.id, tool) !== null)
133
146
  return { ok: true, said: `It can already use ${tool}.` };
134
147
  const listed = await listed_(store, found, mate.repo, io);
135
- return grantListed(store, mate, found, listed.ok ? listed.actions : null, by, now);
148
+ if (options.stillProved !== undefined && !options.stillProved())
149
+ return grantRefused(tool);
150
+ return grantListed(store, mate, found, listed.ok ? listed.actions : null, by, now, { widening: "proved" });
136
151
  }
137
- /** A grant from what the tool offers (or, not reachable now, the names its last test found). */
138
- export function grantListed(store, mate, tool, actions, by, now) {
152
+ const grantRefused = (tool) => ({ ok: false, said: `Letting it use ${tool} takes your password. Do it on its page in Toolroll.` });
153
+ /** A grant from what the tool offers (or, not reachable now, the names its last test found). Every road to a grant
154
+ * comes here: it takes the person's proof, and a tool it already uses keeps its rules (a grant never resets them). */
155
+ export function grantListed(store, mate, tool, actions, by, now, options = {}) {
156
+ if (options.widening !== "proved")
157
+ return grantRefused(tool.name);
158
+ if (store.subagentGrant(mate.id, tool.name) !== null)
159
+ return { ok: true, said: `It can already use ${tool.name}.` };
139
160
  const offered = actions ?? namesOnly(tool);
140
161
  if (offered.length === 0)
141
162
  return { ok: false, said: `${tool.name} didn't say what it can do. Test it on the Tools page, then try again.` };
@@ -176,8 +197,19 @@ export function checkRule(grant, action, rule) {
176
197
  return { ok: false, said: "A limit is a number, 0 or more." };
177
198
  return withUndo({ use: "free", limit: { field, over } });
178
199
  }
179
- /** Set the rules for some of a granted tool's actions; the others keep theirs. */
180
- export function setToolRules(store, mate, tool, changes, by, now) {
200
+ /** How much a rule lets a subagent do unasked: never, ask first, on its own up to a limit, on its own. */
201
+ const ruleRank = (rule) => rule.use === "never" ? 0 : rule.use === "ask" ? 1 : rule.limit !== undefined ? 2 : 3;
202
+ /** Whether `next` lets the subagent do more unasked than `before` (chat-management.md §0a.3): a higher rank, or a limit
203
+ * on another number or a higher one. The undo action is never authority. */
204
+ export function ruleLoosens(before, next) {
205
+ const was = ruleRank(before), now = ruleRank(next);
206
+ if (now !== was)
207
+ return now > was;
208
+ return now === 2 && (next.limit.field !== before.limit.field || next.limit.over > before.limit.over);
209
+ }
210
+ /** Set the rules for some of a granted tool's actions; the others keep theirs. Loosening any of them takes the
211
+ * person's proof (a password, or a session under Passwordless): `widening: "proved"`; tightening never does. */
212
+ export function setToolRules(store, mate, tool, changes, by, now, options = {}) {
181
213
  const grant = store.subagentGrant(mate.id, tool);
182
214
  if (grant === null)
183
215
  return { ok: false, said: `It doesn't use ${tool}. Let it use ${tool} first.` };
@@ -186,6 +218,10 @@ export function setToolRules(store, mate, tool, changes, by, now) {
186
218
  const checked = checkRule(grant, action, change);
187
219
  if (!checked.ok)
188
220
  return { ok: false, said: checked.said };
221
+ const info = grant.actions.find(one => one.name === action);
222
+ if (options.widening !== "proved" && ruleLoosens(grant.rules[action] ?? defaultRule(info), checked.rule)) {
223
+ return { ok: false, said: `Letting it do more with ${action} on its own takes your password. Change the rule on its page in Toolroll.` };
224
+ }
189
225
  rules[action] = checked.rule;
190
226
  }
191
227
  store.saveSubagentGrant({ subagent: mate.id, tool, actions: grant.actions, rules }, by, now);
@@ -65,6 +65,8 @@ export declare function answerSubagentQuestion(store: Store, id: number, answer:
65
65
  text: string | null;
66
66
  by: string;
67
67
  via: string;
68
+ /** The person proved themselves for this answer: only then may accepting a suggested rule loosen it. */
69
+ widening?: "proved";
68
70
  }, now: Date): {
69
71
  ok: true;
70
72
  said: string;
@@ -5,7 +5,7 @@ import { claudeTurnRunner, parseSoul, readTurn, subagentActor, subagentLabel, TU
5
5
  import { passOn, shortenAsk, TEXT_LIMITS } from "./text-limits.js";
6
6
  import { LIMITS } from "./decision.js";
7
7
  import { callName, callOutcome, callWords, inputProblem, makeCall, offeredTools, refreshGrants, ruleFor } from "./subagent-tools.js";
8
- import { answerSuggestion, considerSuggestion, memoriesFor, remember } from "./subagent-memory.js";
8
+ import { ACCEPT, answerSuggestion, considerSuggestion, memoriesFor, remember } from "./subagent-memory.js";
9
9
  import { replyToAsker } from "./subagent-desk.js";
10
10
  import { claudeMachineBilling } from "./spend.js";
11
11
  /** A question waits for its answer this long before the step is due again on its own: never, in practice. */
@@ -275,8 +275,11 @@ export function answerSubagentQuestion(store, id, answer, now) {
275
275
  const card = store.getFlowCard(question.card);
276
276
  // v95: a rule change it suggested to its manager. Nothing about the card waits on it.
277
277
  if (question.suggestion !== null) {
278
+ // A suggestion loosens a rule: accepted only with the person's proof, on its page (chat-management.md §0a.3).
279
+ if (choice?.id === ACCEPT && answer.widening !== "proved")
280
+ return { ok: false, said: "Changing this rule takes your password. Answer it on the subagent's page in Toolroll." };
278
281
  const mate = store.getSubagent(question.subagent);
279
- const decided = mate === null ? { ok: false, said: "That subagent is off the team." } : answerSuggestion(store, mate, question.suggestion, { choice: choice?.id ?? null, text, by: answer.by }, now);
282
+ const decided = mate === null ? { ok: false, said: "That subagent is off the team." } : answerSuggestion(store, mate, question.suggestion, { choice: choice?.id ?? null, text, by: answer.by, ...(answer.widening === undefined ? {} : { widening: answer.widening }) }, now);
280
283
  store.answerSubagentQuestion(id, { choice: choice?.id ?? null, text, by: answer.by, via: answer.via }, now);
281
284
  store.addSubagentEvent({ subagent: question.subagent, card: question.card, entry: question.entry, kind: "answered", said: `${answer.by}: ${[choice?.label, text].filter(Boolean).join(" — ")}`, by: answer.by }, now);
282
285
  return decided;
@@ -6,7 +6,7 @@ import { MEMORY_CHARS, searchMemories } from "./subagent-memory.js";
6
6
  import { deskOf, localZone, routinesOf } from "./subagent-desk.js";
7
7
  import { weekOf, weekWords } from "./subagent-week.js";
8
8
  import { describeSchedule, parseSchedule } from "./flow-schedule.js";
9
- import { html, joinHtml, postForm } from "./html.js";
9
+ import { html, joinHtml, postForm, reauth } from "./html.js";
10
10
  /** v94: the Tools section's rule rows; a limit's fields show only while "Up to a limit" is chosen. */
11
11
  export const SUBAGENT_CSS = `.subagent-tools .tool-grant{border-top:1px solid var(--so-line);padding-top:12px;margin-top:12px}.subagent-tools .tool-grant:first-of-type{border-top:0;margin-top:0;padding-top:0}.subagent-tools h3{font-size:1rem;margin:0 0 4px}.subagent-tools .tool-rule{display:grid;gap:6px;padding:10px 0;border-bottom:1px solid var(--so-line)}.subagent-tools .tool-rule:last-of-type{border-bottom:0}.subagent-tools .tool-rule .meta{display:block}.subagent-tools select,.subagent-tools input{max-width:100%;box-sizing:border-box}.subagent-tools .tool-limit{display:none;flex-wrap:wrap;align-items:center;gap:6px}.subagent-tools .tool-rule:has(option[value="limit"]:checked) .tool-limit{display:flex}.subagent-tools .tool-rule>select{width:auto;min-width:16em}.subagent-tools .tool-limit select{width:auto}.subagent-tools .tool-limit input{width:7em}.subagent-tools .tool-buttons{display:flex;flex-wrap:wrap;gap:8px;margin-top:8px}.subagent-tools button{min-height:44px}.subagent-activity li{overflow-wrap:anywhere}.subagent-memory .memory-tell,.subagent-memory .memory-search{display:flex;flex-wrap:wrap;gap:8px;align-items:flex-end;margin:0 0 12px}.subagent-memory .memory-tell textarea{flex:1 1 280px}.subagent-memory .memory-search input{flex:1 1 220px}.subagent-memory button{min-height:44px}.subagent-memories{list-style:none;padding:0;margin:0}.subagent-memories li{padding:10px 0;border-top:1px solid var(--so-line);overflow-wrap:anywhere}.subagent-memories li:first-child{border-top:0}.subagent-memories .meta{display:block;font-size:.85rem}.subagent-memories details.memory-edit{border:0;padding:0;margin:2px 0 0;background:transparent;box-shadow:none}.memory-edit summary{cursor:pointer;min-height:32px;display:inline-flex;align-items:center;font-size:.85rem;font-weight:400;color:var(--so-muted)}.tool-undo{border:0;padding:0;margin:8px 0;background:transparent;box-shadow:none}.tool-undo summary{cursor:pointer;min-height:36px;display:inline-flex;align-items:center;font-size:.9rem}.tool-undo-row{display:flex;flex-wrap:wrap;gap:6px;align-items:center;margin:6px 0}.tool-undo-row select{width:auto}.subagent-overrides,.subagent-undo{padding-left:18px}.subagent-undo li{margin:6px 0}.subagent-undo form{display:inline}.subagent-week h3{font-size:1rem;margin:14px 0 4px}.subagent-week button{min-height:44px}.subagent-routines{list-style:none;padding:0;margin:8px 0}.subagent-routines li{display:grid;gap:4px;padding:10px 0;border-top:1px solid var(--so-line)}.subagent-routines li:first-child{border-top:0}.routine-buttons{display:flex;flex-wrap:wrap;gap:8px}.routine-buttons button,.routine-add button{min-height:44px}.routine-add{display:flex;flex-wrap:wrap;gap:8px;align-items:flex-end;margin:8px 0}.routine-add label{display:grid;gap:4px;flex:1 1 200px}.routine-add label:first-of-type{flex:0 1 180px}.subagent-actions,.question-options{display:flex;flex-wrap:wrap;gap:8px;margin:8px 0}.subagent-actions form,.question-options form{margin:0}.memory-edit form{display:grid;gap:8px;margin-top:6px}.memory-buttons{display:flex;flex-wrap:wrap;gap:8px}@media(max-width:600px){.subagent-tools select,.subagent-tools input{font-size:16px}}`;
12
12
  const USES = [["free", "Do it"], ["limit", "Do it, up to a limit"], ["ask", "Ask first"], ["never", "Never"]];
@@ -38,12 +38,12 @@ function toolsSection(store, mate, canManage) {
38
38
  });
39
39
  const undoBox = undoing.length > 0 && html `<details class="tool-undo"${grant.actions.some(one => grant.rules[one.name]?.undo !== undefined) && html ` open`}><summary>Undo</summary><p class="meta">When an action has an opposite, a person can press Undo on its receipts: the opposite is called with the same input.</p>${undoing}</details>`;
40
40
  return canManage
41
- ? html `<div class="tool-grant" data-tool-grant="${grant.tool}">${head}${postForm(`/settings/lead/subagents/${mate.id}/tools`, html `${rows}${undoBox}<div class="tool-buttons"><button name="op" value="rules">Save rules</button><button name="op" value="revoke" class="secondary">Stop using ${grant.tool}</button></div>`, { hidden: { tool: grant.tool } })}</div>`
41
+ ? html `<div class="tool-grant" data-tool-grant="${grant.tool}">${head}${postForm(`/settings/lead/subagents/${mate.id}/tools`, html `${rows}${undoBox}${reauth(html `<label>Your password <span class="meta">only to let it do more on its own</span><input type="password" name="password" autocomplete="current-password"></label>`)}<div class="tool-buttons"><button name="op" value="rules">Save rules</button><button name="op" value="revoke" class="secondary">Stop using ${grant.tool}</button></div>`, { hidden: { tool: grant.tool } })}</div>`
42
42
  : html `<div class="tool-grant" data-tool-grant="${grant.tool}">${head}<ul>${rows}</ul></div>`;
43
43
  });
44
44
  const open = tools.filter(one => !grants.some(grant => grant.tool === one.name));
45
45
  const add = !canManage ? null : tools.length === 0 ? html `<p class="meta">This project has no tools yet. <a href="${toolsPage}">Add one</a>, then let ${name} use it here.</p>`
46
- : open.length === 0 ? null : postForm(`/settings/lead/subagents/${mate.id}/tools`, html `<label>Let ${name} use<select name="tool">${open.map(one => html `<option value="${one.name}">${one.name}${one.spec.about === "" ? "" : ` — ${one.spec.about.slice(0, 80)}`}</option>`)}</select></label><button>Add tool</button>`, { attrs: { class: "tool-add" }, hidden: { op: "grant" } });
46
+ : open.length === 0 ? null : postForm(`/settings/lead/subagents/${mate.id}/tools`, html `<label>Let ${name} use<select name="tool">${open.map(one => html `<option value="${one.name}">${one.name}${one.spec.about === "" ? "" : ` — ${one.spec.about.slice(0, 80)}`}</option>`)}</select></label>${reauth(html `<label>Your password<input type="password" name="password" autocomplete="current-password"></label>`)}<button>Add tool</button>`, { attrs: { class: "tool-add" }, hidden: { op: "grant" } });
47
47
  if (grants.length === 0 && add === null)
48
48
  return "";
49
49
  return html `<section class="card subagent-tools" id="tools"><h2>Tools</h2>${grants.length === 0 && html `<p class="meta">${name} doesn't use any tools yet. Reading starts as “Do it”; everything else asks you first.</p>`}${granted}${add}</section>`;
@@ -110,7 +110,9 @@ export function subagentPageHtml(store, mate, viewer, projectName, canManage, ap
110
110
  const flow = card === null ? null : store.getFlow(card.flow);
111
111
  const mine = question.askedOf === viewer;
112
112
  const cardLink = card === null || flow === null ? "a card" : html `<a href="/flows/${flow.id}?card=${card.id}">${card.title}</a>`;
113
- const answer = mine && html `<div class="question-options">${question.options.map(one => postForm(`/settings/lead/subagents/questions/${question.id}/answer`, html `<button>${one.label}</button>`, { hidden: { choice: one.id } }))}</div>${postForm(`/settings/lead/subagents/questions/${question.id}/answer`, html `<label>Or answer in your words<textarea name="text" rows="2" maxlength="2000"></textarea></label><button>Answer</button>`, { attrs: { class: "question-reply" } })}`;
113
+ const answer = mine && html `<div class="question-options">${question.options.map(one => postForm(`/settings/lead/subagents/questions/${question.id}/answer`,
114
+ // Accepting a looser rule it suggested takes the password (chat-management.md §0a.3).
115
+ question.suggestion !== null && one.id === "accept" ? html `${reauth(html `<input type="password" name="password" autocomplete="current-password" aria-label="Your password" placeholder="Your password">`)}<button>${one.label}</button>` : html `<button>${one.label}</button>`, { hidden: { choice: one.id } }))}</div>${postForm(`/settings/lead/subagents/questions/${question.id}/answer`, html `<label>Or answer in your words<textarea name="text" rows="2" maxlength="2000"></textarea></label><button>Answer</button>`, { attrs: { class: "question-reply" } })}`;
114
116
  return html `<article class="card subagent-question" data-question="${question.id}"${question.suggestion !== null && html ` data-suggestion`}><p class="meta">${question.suggestion === null ? html `About ${cardLink}` : "Suggests a rule change"} · ${when(question.createdAt)}${!mine && ` · for ${question.askedOf}`}</p><p><strong>${question.question}</strong></p>${answer}</article>`;
115
117
  });
116
118
  const zones = zonesOf(store, mate);
@@ -94,9 +94,14 @@ more?: readonly TelegramUpload[]) => Promise<{
94
94
  export type TelegramUpload = {
95
95
  field: "document" | "photo" | `shot${number}`;
96
96
  fileName: string;
97
- contentType: "image/png" | "image/jpeg";
97
+ contentType: "image/png" | "image/jpeg" | "text/plain";
98
98
  bytes: Buffer;
99
99
  };
100
+ /** A release card's full terms (release-card.ts) as the document Telegram sends with it: text this process wrote. */
101
+ export declare const termsUpload: (file: {
102
+ name: string;
103
+ text: string;
104
+ }) => TelegramUpload;
100
105
  export declare function createTransport(token: string, timeoutMs?: number): TelegramTransport;
101
106
  /**
102
107
  * Why this process can't reach Telegram the way the service does, or null.
package/dist/telegram.js CHANGED
@@ -172,6 +172,8 @@ export function redactToken(token) {
172
172
  export function scrub(text, token) {
173
173
  return token === "" ? text : text.split(token).join(redactToken(token));
174
174
  }
175
+ /** A release card's full terms (release-card.ts) as the document Telegram sends with it: text this process wrote. */
176
+ export const termsUpload = (file) => ({ field: "document", fileName: file.name, contentType: "text/plain", bytes: Buffer.from(file.text, "utf8") });
175
177
  export function createTransport(token, timeoutMs = 30_000) {
176
178
  return async (method, params, signal, upload, more) => {
177
179
  const controller = new AbortController();
@@ -763,6 +765,15 @@ async function deliverOutboxTo(store, botId, binding, transport, owner, clock, r
763
765
  sentAny = true;
764
766
  return answer;
765
767
  };
768
+ /** A release card's terms file (release-card.ts), sent before the card itself. */
769
+ const fileSender = async (file) => {
770
+ try {
771
+ return (await wire("sendDocument", { chat_id: binding.chatId, caption: "Every term of this release" }, undefined, termsUpload(file))).ok === true;
772
+ }
773
+ catch {
774
+ return false;
775
+ }
776
+ };
766
777
  // `beforeSend`: the fence before a transport call also asks the watch — until something of this delivery went out;
767
778
  // the one after a confirmed send never does, so a message Telegram accepted is recorded as delivered.
768
779
  const fence = (beforeSend = !sentAny) => {
@@ -984,7 +995,7 @@ async function deliverOutboxTo(store, botId, binding, transport, owner, clock, r
984
995
  // A lone update that is not finished work goes out exactly as it always has; a later one edits it in place.
985
996
  const lone = batch.items.length === 1 && batch.items[0].notification !== null;
986
997
  if (batch.message === null && lone) {
987
- const sent = await deliverOne(store, botId, binding, sender, fact, clock, phoneOrigin, evidenceRoot, projects);
998
+ const sent = await deliverOne(store, botId, binding, sender, fact, clock, phoneOrigin, evidenceRoot, projects, fileSender);
988
999
  const placed = store.telegramMessageOf(fact.id, fact.destination);
989
1000
  if (placed !== null)
990
1001
  store.setChatBatchMessage(batch.id, placed, "");
@@ -996,7 +1007,7 @@ async function deliverOutboxTo(store, botId, binding, transport, owner, clock, r
996
1007
  if (view === null) {
997
1008
  if (readyRun !== null)
998
1009
  return { ok: true, receipt: receiptFor(botId, binding.chatId, card.messageId) };
999
- return deliverOne(store, botId, binding, sender, fact, clock, phoneOrigin, evidenceRoot, projects);
1010
+ return deliverOne(store, botId, binding, sender, fact, clock, phoneOrigin, evidenceRoot, projects, fileSender);
1000
1011
  }
1001
1012
  // A message about one result, plan, failure or pull request acts in place (chat-decide.ts); its buttons are
1002
1013
  // minted only when what it offers changed, or its own were spent (Not now), so an unchanged repaint keeps the
@@ -1008,6 +1019,8 @@ async function deliverOutboxTo(store, botId, binding, transport, owner, clock, r
1008
1019
  let keyboard = own !== null ? [own] : viewKeyboard(phoneOrigin, view);
1009
1020
  const shown = createHash("sha256").update(JSON.stringify(offer === null ? [view.text, keyboard] : [text, offerFingerprint(offer)])).digest("hex");
1010
1021
  let tokens = [];
1022
+ // A release card's terms go as a file first, on this road as on a lone delivery: no file accepted, no buttons.
1023
+ const termsFirst = async () => offer?.file === undefined || (fileSender !== undefined && await fileSender(offer.file)) ? null : { ok: false, error: "The release's terms file wasn't accepted" };
1011
1024
  // Minted unplaced: they ride a message only once it shows them, and until then the old card's buttons work.
1012
1025
  const mintFor = () => {
1013
1026
  if (offer === null)
@@ -1024,6 +1037,9 @@ async function deliverOutboxTo(store, botId, binding, transport, owner, clock, r
1024
1037
  const problem = (await readAccess()) ?? fence();
1025
1038
  if (problem !== null)
1026
1039
  return { ok: false, error: problem };
1040
+ const terms = await termsFirst();
1041
+ if (terms !== null)
1042
+ return terms;
1027
1043
  mintFor();
1028
1044
  const edited = await editProgress(wire, binding.chatId, batch.message, text, keyboard);
1029
1045
  if (edited.ok) {
@@ -1044,6 +1060,11 @@ async function deliverOutboxTo(store, botId, binding, transport, owner, clock, r
1044
1060
  return edited;
1045
1061
  }
1046
1062
  // A new message: its buttons are minted for it, never the ones a failed edit was given.
1063
+ if (batch.message === null) {
1064
+ const terms = await termsFirst();
1065
+ if (terms !== null)
1066
+ return terms;
1067
+ }
1047
1068
  mintFor();
1048
1069
  const sent = await sender(text, keyboard.length === 0 ? undefined : keyboard, [fact]);
1049
1070
  const placed = sent.ok ? sent.messageId : store.telegramMessageOf(fact.id, fact.destination);
@@ -1059,7 +1080,7 @@ async function deliverOutboxTo(store, botId, binding, transport, owner, clock, r
1059
1080
  dropDecideTokens(store, tokens, clock());
1060
1081
  return sent.ok ? { ok: true, receipt: receiptFor(botId, binding.chatId, sent.messageId) } : sent;
1061
1082
  }
1062
- return fact.kind === "acceptance-evidence" ? imageSender(fact) : deliverOne(store, botId, binding, sender, fact, clock, phoneOrigin, evidenceRoot, projects);
1083
+ return fact.kind === "acceptance-evidence" ? imageSender(fact) : deliverOne(store, botId, binding, sender, fact, clock, phoneOrigin, evidenceRoot, projects, fileSender);
1063
1084
  };
1064
1085
  /** The lead's words (lead-voice.ts): one message; a later say within two minutes edits it in place. */
1065
1086
  const leadSayOutcome = async (fact) => {
@@ -1102,7 +1123,7 @@ async function deliverOutboxTo(store, botId, binding, transport, owner, clock, r
1102
1123
  outcome = updated !== null ? updated.ok ? { ok: true, receipt: receiptFor(botId, binding.chatId, updated.messageId) } : updated
1103
1124
  : rows[0].kind === "acceptance-evidence" ? await imageSender(rows[0]) : batched
1104
1125
  ? await deliverDigest(botId, binding, sender, rows, digest.lastSentAt, clock, taskId => chatTitle(store, taskId))
1105
- : await deliverOne(store, botId, binding, sender, rows[0], clock, phoneOrigin, evidenceRoot, projects);
1126
+ : await deliverOne(store, botId, binding, sender, rows[0], clock, phoneOrigin, evidenceRoot, projects, fileSender);
1106
1127
  }
1107
1128
  const finalProblem = outcome.ok ? (await readAccess()) ?? fence(false) : null;
1108
1129
  const settled = finalProblem === null ? outcome : { ok: false, error: finalProblem };
@@ -1180,7 +1201,7 @@ async function deliverDigest(botId, binding, sender, rows, since, clock, titleOf
1180
1201
  }
1181
1202
  return { ok: true, receipt: receiptFor(botId, binding.chatId, last) };
1182
1203
  }
1183
- async function deliverOne(store, botId, binding, sender, notification, clock, phoneOrigin, evidenceRoot, projects = []) {
1204
+ async function deliverOne(store, botId, binding, sender, notification, clock, phoneOrigin, evidenceRoot, projects = [], fileSender) {
1184
1205
  const decisionId = /^decision:(\d+)$/.exec(notification.dedupeKey);
1185
1206
  const decision = decisionId === null ? null : store.getDecision(Number(decisionId[1]));
1186
1207
  if (decision !== null && (notification.taskRef === null || store.getRun(decision.run)?.taskRef !== notification.taskRef || (notification.run !== null && notification.run !== decision.run))) {
@@ -1272,6 +1293,13 @@ async function deliverOne(store, botId, binding, sender, notification, clock, ph
1272
1293
  const questionKeys = asked === null ? null : telegramQuestionButtons(store, binding, asked, clock());
1273
1294
  const keys = flowKeys ?? choiceKeys ?? questionKeys;
1274
1295
  let last = null;
1296
+ // A release card: every term goes as a file first, so Continue is offered only once it has arrived.
1297
+ if (offer?.file !== undefined && decided !== null) {
1298
+ if (fileSender === undefined || !await fileSender(offer.file)) {
1299
+ dropDecideTokens(store, decided.tokens, clock());
1300
+ return { ok: false, error: "The release's terms file wasn't accepted" };
1301
+ }
1302
+ }
1275
1303
  for (const [index, part] of parts.entries()) {
1276
1304
  const final = index === parts.length - 1;
1277
1305
  const decideKeys = decided === null ? [] : decideKeyboard(phoneOrigin, decided.rows);
@@ -2309,7 +2337,31 @@ function applyCallback(context, update, effects) {
2309
2337
  if (tapped.ignored === true)
2310
2338
  report.ignored++;
2311
2339
  ack(tapped.ack.slice(0, 190));
2312
- if (tapped.edit !== undefined)
2340
+ // A release card's terms, as a file, before its Approve is shown (chat-management.md §0a.5): Approve appears only
2341
+ // once Telegram has accepted this file; a refused or failed upload shows the card without it, Continue to try again.
2342
+ if (tapped.file !== undefined && tapped.edit !== undefined && tapped.unsent !== undefined) {
2343
+ const file = tapped.file, edit = tapped.edit, unsent = tapped.unsent;
2344
+ effects.push(async () => {
2345
+ let sent = false;
2346
+ try {
2347
+ sent = (await transport("sendDocument", { chat_id: binding.chatId, caption: "Every term of this release, read again just now" }, undefined, termsUpload(file))).ok === true;
2348
+ }
2349
+ catch {
2350
+ sent = false;
2351
+ }
2352
+ const shown = sent ? edit : unsent;
2353
+ dropDecideTokens(store, (sent ? unsent : edit).tokens, clock());
2354
+ await transport("editMessageText", { chat_id: binding.chatId, message_id: message.message_id, text: shown.text.slice(0, 4000), link_preview_options: { is_disabled: true },
2355
+ reply_markup: { inline_keyboard: decideKeyboard(context.conversation?.phoneOrigin, shown.rows) } });
2356
+ });
2357
+ }
2358
+ else if (tapped.file !== undefined) {
2359
+ // A file without its gate never reaches here (chat-decide pairs them); fail closed: nothing that approves is shown.
2360
+ if (tapped.edit !== undefined)
2361
+ dropDecideTokens(store, tapped.edit.tokens, clock());
2362
+ editText(`${message.text ?? ""}\n\nThis release can't be approved here. Open it in Toolroll.`.trim().slice(0, 4000), []);
2363
+ }
2364
+ else if (tapped.edit !== undefined)
2313
2365
  editText(tapped.edit.text.slice(0, 4000), decideKeyboard(context.conversation?.phoneOrigin, tapped.edit.rows));
2314
2366
  if (tapped.prompt !== undefined) {
2315
2367
  const prompt = tapped.prompt;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolroll",
3
- "version": "0.9.72",
3
+ "version": "0.9.73",
4
4
  "description": "A control plane for unattended coding agents — queue tasks, walk away, come back to pull requests. Agents build in leased worktrees, never touch your default branch, and interrupt you only for decisions that need a human.",
5
5
  "directories": {
6
6
  "doc": "docs"