toolroll 0.9.37 → 0.9.39

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.
@@ -38,6 +38,10 @@ export function browserCrewFromIndex(page, conversationId) {
38
38
  const link = (href) => conversationId && href.startsWith('/chat?') ? href + '&conversation=' + encodeURIComponent(conversationId) : href;
39
39
  const rows = page.items.map(summary => {
40
40
  const status = summary.status;
41
+ // Keep the server's sentence intact; ignore only empty or repeated badge words.
42
+ const words = (text) => text.trim().replace(/\s+/g, ' ').replace(/[.!?]+$/, '').toLowerCase();
43
+ const detail = ['Waiting', 'Failed', 'Needs you'].includes(status.label)
44
+ && words(status.detail) !== '' && words(status.detail) !== words(status.label) ? status.detail : undefined;
41
45
  const resultHref = summary.resultRunId === null || summary.resultTaskId === null || (summary.resultOutcome !== 'built' && summary.resultOutcome !== 'no-change')
42
46
  ? null : chatResultHref(summary.resultTaskId, summary.resultRunId);
43
47
  const href = chatControlHref('task', summary.rootId);
@@ -48,6 +52,7 @@ export function browserCrewFromIndex(page, conversationId) {
48
52
  state: summary.assignmentState, label: status.label, tone: status.tone, href: link(href), resultHref: resultHref === null ? null : link(resultHref),
49
53
  action: action === null || actionHref === null ? null : { label: action.label, href: link(actionHref) },
50
54
  updatedAt: summary.updatedAt,
55
+ ...(detail === undefined ? {} : { detail }),
51
56
  ...(summary.lead === undefined ? {} : { lead: summary.lead }),
52
57
  };
53
58
  return { item, rank: status.rank };
@@ -49,6 +49,8 @@ export type BrowserCrewItem = {
49
49
  } | null;
50
50
  /** When the task family last changed (the index's own sort time), shown as the row's age. */
51
51
  updatedAt: string;
52
+ /** The server's status detail, when Waiting, Failed or Needs you adds to its badge. */
53
+ detail?: string;
52
54
  /** "<name> is on it.": the person's own lead took it on, by the name they gave it. */
53
55
  lead?: string;
54
56
  };
@@ -98,6 +100,10 @@ export type BrowserConversation = {
98
100
  resultRunId: number | null;
99
101
  /** A project's own thread (v77); absent or null for the lead conversation or a task. */
100
102
  project?: string | null;
103
+ /** The lead thread a change in reachable projects replaced (mate arc ruling 9): shown above a divider, read only. */
104
+ previous?: {
105
+ messages: BrowserMessage[];
106
+ } | null;
101
107
  };
102
108
  /** A page rebuilt as React components (shadcn/ui). The server still renders
103
109
  * its HTML as the no-JavaScript fallback; forms post to the same routes. */
@@ -174,6 +180,8 @@ export type BrowserLimits = {
174
180
  export type BrowserSettingsView = {
175
181
  kind: 'settings';
176
182
  said: string | null;
183
+ /** Using it from a phone, under Chat apps (approvers only). */
184
+ phone?: BrowserPhoneCard;
177
185
  /** The settings destinations under short headings; a chat app's tile says whether it is connected and gets alerts. */
178
186
  groups: BrowserSettingsGroup[];
179
187
  theme: 'system' | 'light' | 'dark';
@@ -273,16 +273,27 @@ export function proposalPreview(store, proposal, repos, channel = null) {
273
273
  : undefined;
274
274
  return path === undefined ? id : projectLabel(path);
275
275
  })();
276
+ // Telegram's card leads with the action and what confirming does, then the exact terms; its buttons say the rest.
277
+ const phone = channel === "telegram";
276
278
  const card = (headline, body = [], consequence = null) => ({
277
- text: [
278
- headline,
279
- ...body,
280
- ...(consequence === null ? [] : ["", consequence]),
281
- "",
282
- "Confirm or Dismiss below. Nothing changes until you confirm.",
283
- ].join("\n"),
279
+ text: (phone
280
+ ? [
281
+ headline,
282
+ ...(consequence === null ? [] : [consequence]),
283
+ ...(body.length === 0 ? [] : ["", ...body]),
284
+ "",
285
+ "Nothing changes until you confirm.",
286
+ ]
287
+ : [
288
+ headline,
289
+ ...body,
290
+ ...(consequence === null ? [] : ["", consequence]),
291
+ "",
292
+ "Confirm or Dismiss below. Nothing changes until you confirm.",
293
+ ]).join("\n"),
284
294
  buttons: true,
285
295
  });
296
+ const lasting = phone ? " ⚠ can't be undone" : " — IRREVERSIBLE";
286
297
  // A handoff's text is origin-free on purpose: it is persisted before the
287
298
  // send, and the button or the missing-setup line joins it at send time.
288
299
  const handoff = (headline, body = []) => ({
@@ -361,11 +372,11 @@ export function proposalPreview(store, proposal, repos, channel = null) {
361
372
  ? ["(the decision is gone)"]
362
373
  : [
363
374
  `Q: ${phoneText(decision.question, 400)}`,
364
- ...decision.options.map((one) => `${one.id === pick ? "→" : " "} ${phoneText(one.label, 120)}${one.reversible ? "" : " — IRREVERSIBLE"}${one.id === decision.recommendation ? " (the builder recommends this)" : ""}: ${phoneText(one.consequence, 300)}`),
375
+ ...decision.options.map((one) => `${one.id === pick ? "→" : " "} ${phoneText(one.label, 120)}${one.reversible ? "" : lasting}${one.id === decision.recommendation ? " (the builder recommends this)" : ""}: ${phoneText(one.consequence, 300)}`),
365
376
  ...(t("rationale") === "" ? [] : [`Why: ${t("rationale", 300)}`]),
366
377
  ];
367
378
  return card(`Answer decision #${String(id ?? "?")} on ${taskName} with "${t("optionLabel", 120) || pick}"`, body, irreversible
368
- ? "⚠ This choice is irreversible. Confirming asks you once more."
379
+ ? phone ? "⚠ This choice can't be undone. Confirming asks you once more." : "⚠ This choice is irreversible. Confirming asks you once more."
369
380
  : null);
370
381
  }
371
382
  case "task_action": {
@@ -108,11 +108,12 @@ export declare function mergeInChat(store: Store, taskId: string, run: number, a
108
108
  ok: false;
109
109
  why: string;
110
110
  };
111
- /** The plan card: title, goal in two lines, what changes, done when, and what the yes allows. */
112
- export declare function planCardText(store: Store, taskId: string, scope: Scope, root?: string): string;
111
+ /** The plan card: title, goal in two lines, what changes, done when, and what the yes allows. On Telegram the yes's
112
+ * terms come second, under the title, and the steps are fewer: the full plan is behind Edit. */
113
+ export declare function planCardText(store: Store, taskId: string, scope: Scope, root?: string, channel?: DecideChannel): string;
113
114
  /** The acts a card about this target can take in chat right now, or null when none can (the card keeps its own
114
115
  * link). Reads only; nothing is minted. */
115
- export declare function decideOffer(store: Store, target: DecideTarget, who: VerifiedApprover, now: Date, root?: string): DecideOffer | null;
116
+ export declare function decideOffer(store: Store, target: DecideTarget, who: VerifiedApprover, now: Date, root?: string, channel?: DecideChannel): DecideOffer | null;
116
117
  /** One fingerprint of what the card offers, without its tokens: a repaint with the same offer keeps its buttons. */
117
118
  export declare function offerFingerprint(offer: DecideOffer): string;
118
119
  /** The card's buttons, minted now (message unknown until it is sent: `placeDecideTokens` puts them on it). */
@@ -155,8 +155,9 @@ export function mergeInChat(store, taskId, run, approver, now) {
155
155
  return mode;
156
156
  return { ok: true, modeDigest: mode.digest, head: publication.headSha };
157
157
  }
158
- /** The plan card: title, goal in two lines, what changes, done when, and what the yes allows. */
159
- export function planCardText(store, taskId, scope, root) {
158
+ /** The plan card: title, goal in two lines, what changes, done when, and what the yes allows. On Telegram the yes's
159
+ * terms come second, under the title, and the steps are fewer: the full plan is behind Edit. */
160
+ export function planCardText(store, taskId, scope, root, channel) {
160
161
  const ref = store.lookupRef(taskId);
161
162
  const artifact = ref === null ? null : store.latestPlanArtifact(ref.id);
162
163
  let milestones = [];
@@ -176,6 +177,19 @@ export function planCardText(store, taskId, scope, root) {
176
177
  const goal = scope.goal.split("\n").filter(one => one.trim() !== "").slice(0, 2).map(one => phoneText(one, 160));
177
178
  const allowing = [permissionPlainWords(scope.profile), scope.budgetMicrousd === null ? "no attempt limit" : `up to ${money(scope.budgetMicrousd)} per attempt`]
178
179
  .filter((one) => one !== null);
180
+ if (channel === "telegram") {
181
+ return [
182
+ `Plan ready: ${title(store, taskId)}`,
183
+ `Starting allows: ${allowing.join(" · ")}`,
184
+ "",
185
+ ...goal,
186
+ ...(scope.touches.length > 0 ? [`Only in: ${scope.touches.slice(0, 6).map(one => phoneText(one, 80)).join(", ")}${scope.touches.length > 6 ? ", …" : ""}`] : ["Any file in the project."]),
187
+ "",
188
+ "Done when:",
189
+ ...(scope.acceptance.length > 0 ? bullets(scope.acceptance.map(one => one.statement), 5) : ["You decide when you review the result."]),
190
+ ...(milestones.length > 0 ? ["", "Steps:", ...bullets(milestones, 3)] : []),
191
+ ].join("\n");
192
+ }
179
193
  return [
180
194
  `Plan ready: ${title(store, taskId)}`,
181
195
  "",
@@ -194,7 +208,7 @@ export function planCardText(store, taskId, scope, root) {
194
208
  // ---- what a card offers ---------------------------------------------------------------
195
209
  /** The acts a card about this target can take in chat right now, or null when none can (the card keeps its own
196
210
  * link). Reads only; nothing is minted. */
197
- export function decideOffer(store, target, who, now, root) {
211
+ export function decideOffer(store, target, who, now, root, channel) {
198
212
  const ref = store.lookupRef(target.taskId);
199
213
  if (ref === null || ref.repo === null || !who.repos.includes(ref.repo))
200
214
  return null;
@@ -222,7 +236,7 @@ export function decideOffer(store, target, who, now, root) {
222
236
  const plan = planInChat(store, target.taskId, who.name, now, root);
223
237
  if (!plan.ok)
224
238
  return null;
225
- return { text: planCardText(store, target.taskId, plan.scope, root),
239
+ return { text: planCardText(store, target.taskId, plan.scope, root, channel),
226
240
  rows: [[{ act: "approve", run: null, digest: plan.digest }], [{ label: DECIDE_LABELS.edit, path: chatControlHref("approval", target.taskId) }, { act: "not-now", run: null, digest: plan.digest }]] };
227
241
  }
228
242
  case "merge": {
@@ -414,7 +428,7 @@ export function applyDecideTap(store, seat, input) {
414
428
  }
415
429
  if (row.phase === "cancel") {
416
430
  spend();
417
- const offer = decideOffer(store, target, who, now, input.root);
431
+ const offer = decideOffer(store, target, who, now, input.root, seat.channel);
418
432
  if (offer === null)
419
433
  return { ack: "Cancelled.", edit: { text: body, rows: linksFor(target), tokens: [] } };
420
434
  const minted = mintDecideButtons(store, seat, target, offer, now, input.message);
@@ -125,13 +125,13 @@ export declare function prepareDesktopUpdate(stateDir: string, installed: string
125
125
  export declare function sqliteLock(file: string, busyTimeoutMs?: number): DatabaseSync | null;
126
126
  export declare const installationLock: (j: UpdateJournal) => DatabaseSync | null;
127
127
  export declare function desktopUpdateStatus(stateDir: string): {
128
- error?: never;
129
128
  active: boolean;
130
129
  phase: string;
131
130
  detail: string;
132
131
  running: boolean;
133
132
  canResume: boolean;
134
133
  canCancel: boolean;
134
+ error?: never;
135
135
  automaticRecovery?: never;
136
136
  recoveryAttempts?: never;
137
137
  recoveryRepairs?: never;
@@ -12,14 +12,40 @@ export type MateProgress = {
12
12
  kind: "tool";
13
13
  turn: number;
14
14
  step: number;
15
+ id: string;
15
16
  label: string;
17
+ } | {
18
+ kind: "tool-result";
19
+ turn: number;
20
+ step: number;
21
+ id: string;
22
+ outcome: MateToolOutcome;
16
23
  } | {
17
24
  kind: "text";
18
25
  turn: number;
19
26
  step: number;
20
27
  text: string;
21
28
  };
29
+ export type MateToolOutcome = {
30
+ state: "succeeded";
31
+ } | {
32
+ state: "failed";
33
+ reason: string;
34
+ };
35
+ export type MateLiveTool = {
36
+ id: string;
37
+ label: string;
38
+ } & ({
39
+ state: "running";
40
+ } | MateToolOutcome);
41
+ /** Keep tools as labels for older clients; toolCalls adds per-call outcomes. */
42
+ export type MateLiveStep = {
43
+ tools: string[];
44
+ toolCalls: MateLiveTool[];
45
+ text: string;
46
+ };
22
47
  export declare function mateToolLabel(name: string): string;
48
+ export declare function mateToolFailureReason(name: string, message: string): string;
23
49
  /** The `text` of a structured answer still being written, when the answer
24
50
  * opens with it (`{"text": "…`); null until then. Escapes are decoded, and
25
51
  * an escape cut off at the end is left for the next chunk. */
@@ -43,6 +43,30 @@ export function mateToolLabel(name) {
43
43
  return TOOL_LABELS[name];
44
44
  return name.startsWith("propose_") ? "Preparing a card for you to confirm" : "Working";
45
45
  }
46
+ /** Tool errors instruct the model how to retry. Only known, fixed copy belongs
47
+ * in the live UI; never echo arguments, redaction placeholders or exceptions. */
48
+ const TOOL_FAILURE_REASONS = new Map([
49
+ ["Choose a project from list_repos.", "That project isn't available."],
50
+ ["Choose an available project from list_repos.", "That project isn't available."],
51
+ ["repo must be one of the ids from list_repos", "That project isn't available."],
52
+ ["not-found: no such task in your projects", "That task isn't available in your projects."],
53
+ ["That task is not in your projects.", "That task isn't available in your projects."],
54
+ ["not-found: no such decision in your projects", "That decision isn't available in your projects."],
55
+ ["that decision is no longer open", "That decision is already closed."],
56
+ ["No such flow in your projects.", "No such flow in your projects."],
57
+ ["That action is outside your access.", "You don't have access to that action."],
58
+ ["Read the current task version before proposing this action.", "The task needs to be checked again before this action."],
59
+ ["That file is not in these changes. Call get_diff without file for the list.", "That file isn't in the saved changes."],
60
+ ["The saved changes could not be verified.", "The saved changes could not be verified."],
61
+ ["The saved check log could not be verified.", "The saved check log could not be verified."],
62
+ ["Saved results are unavailable here.", "Saved results are unavailable here."],
63
+ ["There is no finished result for that version yet.", "There is no finished result for that version yet."],
64
+ ["Task text cannot contain credentials.", "The task text contains a password or secret key."],
65
+ ["that tool refused — the plane could not answer it right now", "Toolroll couldn't finish that step right now."],
66
+ ]);
67
+ export function mateToolFailureReason(name, message) {
68
+ return TOOL_FAILURE_REASONS.get(message) ?? `That step didn't work (${mateToolLabel(name)}).`;
69
+ }
46
70
  /** The `text` of a structured answer still being written, when the answer
47
71
  * opens with it (`{"text": "…`); null until then. Escapes are decoded, and
48
72
  * an escape cut off at the end is left for the next chunk. */
package/dist/mate.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { Buffer } from "node:buffer";
2
- import { mateToolLabel } from "./mate-progress.js";
2
+ import { mateToolFailureReason, mateToolLabel } from "./mate-progress.js";
3
3
  import { createHash } from "node:crypto";
4
4
  import { mateTimeoutNotice } from "./store.js";
5
5
  import { RESULT_IMAGES_PER_TURN_CAP } from "./chat-evidence.js";
@@ -499,8 +499,17 @@ export async function runMateTurn(input) {
499
499
  if (changed !== null)
500
500
  return changed;
501
501
  }
502
- progress({ kind: "tool", turn: turnId, step: steps, label: mateToolLabel(call.name) });
502
+ // Position within this turn is unique even if a provider reuses call ids.
503
+ const progressId = `${turnId}:${steps}:${index}`;
504
+ progress({ kind: "tool", turn: turnId, step: steps, id: progressId, label: mateToolLabel(call.name) });
503
505
  const outcome = executeMateTool({ store, who, now: clock(), draft, selectEvidence, step: steps, readDecisions, readResults, searchedMemory, checkedCapabilities, ask, ...(input.integrations === undefined ? {} : { integrations: input.integrations }), thread: thread.id, turn: turnId, ...(promiseChannelOfTurn(input.channel) === undefined ? {} : { channel: promiseChannelOfTurn(input.channel) }), ...(input.evidenceRoot === undefined ? {} : { evidenceRoot: input.evidenceRoot }), ...(input.mediaDelivery === undefined ? {} : { mediaDelivery: input.mediaDelivery }) }, call.name, call.args, view);
506
+ let displayOutcome = { state: "succeeded" };
507
+ if (!outcome.ok) {
508
+ const reason = mateToolFailureReason(call.name, outcome.message);
509
+ displayOutcome = { state: "failed", reason: scanForSecrets(reason).length === 0
510
+ ? reason : `That step didn't work (${mateToolLabel(call.name)}).` };
511
+ }
512
+ progress({ kind: "tool-result", turn: turnId, step: steps, id: progressId, outcome: displayOutcome });
504
513
  if (READ_TOOLS.has(call.name))
505
514
  reads++;
506
515
  if (call.name === "show_control" && outcome.ok)
package/dist/serve.js CHANGED
@@ -744,6 +744,19 @@ export function createDecisionServer(options) {
744
744
  provider: () => { const enabled = chatEnablement(); return enabled.ok ? { config: enabled.config, key: enabled.key } : null; },
745
745
  ...(options.subscriptionChatRunner ? { subscriptionRunner: options.subscriptionChatRunner } : {}),
746
746
  });
747
+ // Only browser sessions receive HTML cards. The CLI keeps its summary contract.
748
+ const teamBrowserReply = (reply, csrf) => {
749
+ const snapshot = reply.snapshot;
750
+ if (!snapshot?.selected || !snapshot.proposals)
751
+ return reply;
752
+ return { ...reply, snapshot: { ...snapshot, proposals: snapshot.proposals.map(summary => {
753
+ const proposal = store.getMateProposal(summary.id);
754
+ if (!proposal || proposal.thread !== snapshot.selected.threadId)
755
+ return summary;
756
+ const decision = proposal.kind === 'answer' && typeof proposal.payload['decision'] === 'number' ? store.getDecision(proposal.payload['decision']) : null;
757
+ return { ...summary, card: teamProposalCardParts(proposal, snapshot, csrf, decision).card };
758
+ }) } };
759
+ };
747
760
  const teamEndpoint = (request, response) => handleTeamHttp(request, response, {
748
761
  authenticate: request => {
749
762
  const who = identify(request, request.method === 'POST');
@@ -773,7 +786,11 @@ export function createDecisionServer(options) {
773
786
  return false;
774
787
  return request.headers['x-csrf-token'] === who.session.csrf;
775
788
  },
776
- execute: team.execute, cursor: team.cursor, streams: teamStreams,
789
+ execute: async (actor, input) => {
790
+ const reply = await team.execute(actor, input);
791
+ const who = identify(request, false);
792
+ return who?.via === 'cookie' ? teamBrowserReply(reply, who.session.csrf) : reply;
793
+ }, cursor: team.cursor, streams: teamStreams,
777
794
  });
778
795
  const sessionEndpoint = createSessionEndpoint({ store, workspace: coding, projects: codingProjects, projectAllowed: repo => rowVisible(liveCeiling(), repo) });
779
796
  /** The enumerable admission list for roll-up SQL: repos-only ceilings
@@ -3585,11 +3602,11 @@ export function createDecisionServer(options) {
3585
3602
  if (who.via !== "cookie")
3586
3603
  return refuse(response, who, 403, "chat is a browser surface — it keeps your drafts in the session");
3587
3604
  if (url.searchParams.get('private') !== '1' && !url.searchParams.has('task') && !url.searchParams.has('result') && !url.searchParams.has('project')) {
3588
- const reply = await team.execute({ name: who.name, generation: who.session.generation }, {
3605
+ const reply = teamBrowserReply(await team.execute({ name: who.name, generation: who.session.generation }, {
3589
3606
  operation: url.searchParams.has('conversation') ? 'show' : 'list',
3590
3607
  args: { ...(url.searchParams.has('conversation') ? { conversationId: url.searchParams.get('conversation') } : {}),
3591
3608
  ...(url.searchParams.has('lead') ? { leadId: url.searchParams.get('lead') } : {}) },
3592
- });
3609
+ }), who.session.csrf);
3593
3610
  if (!reply.ok || !reply.snapshot)
3594
3611
  return refuse(response, who, 403, reply.message, '/chat?private=1');
3595
3612
  if (url.searchParams.has('proposal')) {
@@ -3599,11 +3616,7 @@ export function createDecisionServer(options) {
3599
3616
  return refuse(response, who, 404, 'This proposal is unavailable.', '/chat');
3600
3617
  const back = '/chat?conversation=' + encodeURIComponent(selected.id);
3601
3618
  const decision = proposal.kind === 'answer' && typeof proposal.payload['decision'] === 'number' ? store.getDecision(proposal.payload['decision']) : null;
3602
- const active = reply.snapshot.messages.some(message => message.status === 'running');
3603
- const canAct = reply.snapshot.canSend && reply.snapshot.chatAuthorization?.enabled === true;
3604
- let card = mateProposalCard(proposal, who.session.csrf, !canAct || active, decision, back);
3605
- if (!canAct)
3606
- card = card.replace('Available when the current turn finishes.', reply.snapshot.canSend ? 'Enable chat in this conversation before acting on a proposal.' : 'An authorized contributor can act on this proposal.');
3619
+ const card = teamProposalCardParts(proposal, reply.snapshot, who.session.csrf, decision).html;
3607
3620
  return sendScreen(response, 200, screen('Review action', `<p><a href="${escape(back)}">Back to conversation</a></p>` + card, { chrome: chromeFor(null, 'chat', undefined, 'all') }));
3608
3621
  }
3609
3622
  if (reply.snapshot.leads.length > 0 || url.searchParams.has('conversation') || url.searchParams.get('team') === '1') {
@@ -4290,7 +4303,7 @@ export function createDecisionServer(options) {
4290
4303
  })(), (() => {
4291
4304
  const chosen = store.notificationPreference(who.name);
4292
4305
  return { mode: chosen.mode, digestAt: chosen.digestAt, screenshots: chosen.screenshots, projects: notificationProjects(store, who.name) };
4293
- })(), telegramTrouble(store, loadBotToken(process.env, options.telegramTokenFile))));
4306
+ })(), telegramTrouble(store, loadBotToken(process.env, options.telegramTokenFile)), who.via === "cookie" ? phoneSetup(who) ?? null : null));
4294
4307
  }
4295
4308
  if (url.pathname === "/push/key") {
4296
4309
  // Session-gated: the key is not secret, but strangers get nothing.
@@ -4464,12 +4477,19 @@ export function createDecisionServer(options) {
4464
4477
  return first !== undefined && String(first["id"]) === rootId;
4465
4478
  }
4466
4479
  /** After the first Ready result, once (onboarding): the phone, by a chat app or the console over Tailscale. */
4480
+ /** Chat's one-line pointer to the phone setup: after the first Ready result, until put away or Telegram is paired. */
4467
4481
  function phoneCard(who, now) {
4468
4482
  if (who.role !== "approver" || store.isDemo() || store.firstSuccessAt(now) === null || store.installationFact(PHONE_CARD_FACT) !== null)
4469
4483
  return undefined;
4470
4484
  const botId = options.telegramTokenFile === undefined ? null : loadBotToken(process.env, options.telegramTokenFile)?.botId ?? null;
4471
4485
  if (botId !== null && store.liveTelegramBindings(botId).length > 0)
4472
4486
  return undefined;
4487
+ return phoneSetup(who);
4488
+ }
4489
+ /** The phone setup itself (Settings → Chat apps): a chat app, or this console over Tailscale. */
4490
+ function phoneSetup(who) {
4491
+ if (who.role !== "approver" || store.isDemo())
4492
+ return undefined;
4473
4493
  const port = servedPort() ?? 4180;
4474
4494
  const address = server.address();
4475
4495
  const bound = typeof address === "object" && address !== null ? address.address : "127.0.0.1";
@@ -4509,7 +4529,11 @@ export function createDecisionServer(options) {
4509
4529
  const ask = last?.role === "assistant" && last.turn !== null && store.getMateTurn(last.turn)?.approver === who.name ? store.mateAskOpen(last.turn, now) : null;
4510
4530
  // Answered questions stay readable above the answer; only their buttons go.
4511
4531
  const asks = new Map(messages.flatMap(one => { const asked = one.role === "assistant" && one.turn !== null ? store.mateAsk(one.turn) : null; return asked === null ? [] : [[asked.turn, asked]]; }));
4532
+ // The thread a ceiling change replaced (ruling 9): read after the open, display only.
4533
+ const replaced = store.replacedLeadThread(opened.thread);
4534
+ const previousProposals = replaced === null ? [] : store.listMateProposals(replaced.id);
4512
4535
  return {
4536
+ previous: replaced === null ? null : { messages: store.listMateMessages(replaced.id, 40), proposals: previousProposals, decisions: decisionsFor(store, previousProposals) },
4513
4537
  messages,
4514
4538
  ask,
4515
4539
  asks,
@@ -4540,14 +4564,25 @@ export function createDecisionServer(options) {
4540
4564
  clearTimeout(previous.expiry);
4541
4565
  const live = { steps: [], done: false, ok: false, listeners: previous?.listeners ?? new Set() };
4542
4566
  liveTurns.set(thread, live);
4543
- const current = () => live.steps.at(-1) ?? (live.steps.push({ tools: [], text: "" }), live.steps.at(-1));
4567
+ const current = () => live.steps.at(-1) ?? (live.steps.push({ tools: [], toolCalls: [], text: "" }), live.steps.at(-1));
4544
4568
  return event => {
4545
4569
  if (live.done)
4546
4570
  return;
4547
4571
  if (event.kind === "step")
4548
- live.steps.push({ tools: [], text: "" });
4549
- else if (event.kind === "tool")
4572
+ live.steps.push({ tools: [], toolCalls: [], text: "" });
4573
+ else if (event.kind === "tool") {
4550
4574
  current().tools.push(event.label);
4575
+ current().toolCalls.push({ id: event.id, label: event.label, state: "running" });
4576
+ }
4577
+ else if (event.kind === "tool-result") {
4578
+ for (const step of live.steps) {
4579
+ const index = step.toolCalls.findIndex(tool => tool.id === event.id);
4580
+ if (index !== -1) {
4581
+ step.toolCalls[index] = { ...step.toolCalls[index], ...event.outcome };
4582
+ break;
4583
+ }
4584
+ }
4585
+ }
4551
4586
  else if (event.kind === "text")
4552
4587
  current().text = event.text;
4553
4588
  for (const listener of live.listeners)
@@ -15395,6 +15430,8 @@ ${THEME_DARK}
15395
15430
  min-height: min(32rem, 48vh); padding: .25rem;
15396
15431
  }
15397
15432
  .thread .msg { max-width: 48rem; line-height: 1.65; overflow-wrap: anywhere; }
15433
+ .chat-previous-divider { display: flex; align-items: center; gap: .6rem; margin: 1rem 0; color: var(--so-muted); font-size: .8rem; }
15434
+ .chat-previous-divider::before, .chat-previous-divider::after { content: ""; flex: 1; border-top: 1px solid var(--so-line); }
15398
15435
  .thread .msg p { margin: .3rem 0; }
15399
15436
  .thread .msg.op {
15400
15437
  align-self: flex-end; max-width: min(82%, 40rem); padding: .75rem 1rem;
@@ -18300,6 +18337,19 @@ function mateProposalCard(proposal, csrf, inert, decision, returnTo = null) {
18300
18337
  function mateProposalCardParts(proposal, csrf, inert, decision, returnTo = null) {
18301
18338
  return proposalCardParts({ id: proposal.id, kind: proposal.kind, payload: proposal.payload, state: proposal.state, outcome: proposal.outcome, by: { mate: true }, actionBase: "/chat/proposal" }, csrf, inert, decision, returnTo);
18302
18339
  }
18340
+ /** Review and inline team cards share the same server-decided controls and reasons. */
18341
+ function teamProposalCardParts(proposal, snapshot, csrf, decision) {
18342
+ const canAct = snapshot.canSend && snapshot.chatAuthorization?.enabled === true;
18343
+ const active = snapshot.messages.some(message => message.status === 'running');
18344
+ const back = '/chat?conversation=' + encodeURIComponent(snapshot.selected.id);
18345
+ const parts = mateProposalCardParts(proposal, csrf, !canAct || active, decision, back);
18346
+ if (!canAct && proposal.state === 'pending') {
18347
+ const reason = snapshot.canSend ? 'Enable chat in this conversation before acting on a proposal.' : 'An authorized contributor can act on this proposal.';
18348
+ parts.html = parts.html.replace('Available when the current turn finishes.', reason);
18349
+ parts.card = { ...parts.card, note: reason };
18350
+ }
18351
+ return parts;
18352
+ }
18303
18353
  function coordinatorProposalCard(proposal, csrf, now, decision, returnTo = null) {
18304
18354
  return proposalCard({ id: proposal.id, kind: proposal.kind, payload: proposal.payload, state: proposal.state, outcome: proposal.outcome, by: { mate: false, name: proposal.name, ago: relativeAge(proposal.createdAt, now) }, actionBase: "/proposals" }, csrf, false, decision, returnTo);
18305
18355
  }
@@ -18319,6 +18369,24 @@ function coordinatorProposalsSection(proposals, decisions, csrf, now, heading =
18319
18369
  proposals.map(one => coordinatorProposalCard(one, csrf, now, decisions.get(typeof one.payload["decision"] === "number" ? one.payload["decision"] : -1) ?? null, returnTo)).join("") +
18320
18370
  `</div>`);
18321
18371
  }
18372
+ const REPLACED_THREAD_DIVIDER = "New conversation — the projects I can reach changed";
18373
+ /** The replaced thread as the React conversation renders it: words and card outcomes, no buttons. */
18374
+ function replacedBrowserMessages(previous, csrf) {
18375
+ return mateBrowserMessages({ ...previous, pending: null, ask: null, asks: new Map() }, csrf, null).map(message => ({
18376
+ ...message, cardsHtml: "",
18377
+ cards: (message.cards ?? []).map(card => ({ ...card, primary: null, dismissable: false })),
18378
+ }));
18379
+ }
18380
+ /** The replaced thread for the server-rendered page: read only, then the divider. */
18381
+ function replacedThreadHtml(previous) {
18382
+ if (previous == null)
18383
+ return "";
18384
+ return `<section class="thread chat-previous" aria-label="Earlier conversation" data-previous-thread>` +
18385
+ previous.messages.map(one => one.role === "operator"
18386
+ ? `<div class="msg op" data-previous-message="${one.id}"><p style="white-space:pre-wrap">${escape(one.text)}</p></div>`
18387
+ : `<div class="msg mate" data-previous-message="${one.id}">${renderChatText(one.text)}</div>`).join("") +
18388
+ `<p class="chat-previous-divider" role="separator" data-thread-divider>${escape(REPLACED_THREAD_DIVIDER)}</p></section>`;
18389
+ }
18322
18390
  /** The lead's question as buttons: each option sends itself as the next message (the same POST as a typed one);
18323
18391
  * "Something else" moves to the composer. Nothing shows while a reply is running. */
18324
18392
  function mateAskHtml(ask, csrf, target, composer, open = true) {
@@ -18367,6 +18435,7 @@ function mateChatVersion(rows) {
18367
18435
  const focus = rows.focusTask;
18368
18436
  const facts = [
18369
18437
  rows.messages.map(one => [one.id, one.role, one.turn]),
18438
+ rows.previous == null ? null : rows.previous.messages.map(one => one.id),
18370
18439
  rows.proposals.map(one => [one.id, one.state, one.outcome === null ? null : JSON.stringify(one.outcome)]),
18371
18440
  rows.coordinatorProposals.map(one => [one.id, one.state, one.outcome === null ? null : JSON.stringify(one.outcome)]),
18372
18441
  [...rows.decisions.values()].map(one => [one.id, one.state, one.choice ?? null]),
@@ -18493,6 +18562,7 @@ function matePage(chrome, data) {
18493
18562
  }
18494
18563
  const lastMessage = data.messages.at(-1);
18495
18564
  const latestReply = data.pending === null && lastMessage?.role === "assistant" ? lastMessage.id : null;
18565
+ conversation.push(replacedThreadHtml(data.previous));
18496
18566
  conversation.push(mateThreadHtml({ ...data, problem: null, chatProject }));
18497
18567
  conversation.push(
18498
18568
  // The New update action (package 2): hidden until a live update lands
@@ -18513,6 +18583,7 @@ function matePage(chrome, data) {
18513
18583
  messages: mateBrowserMessages(data, data.csrf, data.focusTask === null && chatProject === null ? null : returnTo, { task: data.focusTask?.id ?? null, project: chatProject }),
18514
18584
  pendingTurnId: data.pending?.id ?? null, requestId: randomBytes(16).toString('hex'), maxChars: MATE_MESSAGE_MAX_CHARS,
18515
18585
  taskId: data.focusTask?.id ?? null, resultRunId: data.resultRunId ?? null, project: chatProject,
18586
+ ...(data.previous == null ? {} : { previous: { messages: replacedBrowserMessages(data.previous, data.csrf) } }),
18516
18587
  },
18517
18588
  focus: data.focusTask === null ? null : { id: data.focusTask.id, title: data.focusTask.title,
18518
18589
  html: taskChatLiveRegion(data.focusTask, data.csrf, requestContext.getStore()?.workspaceRead === true, data.pending !== null) },
@@ -24046,7 +24117,7 @@ function notificationProjects(store, account) {
24046
24117
  .map(one => ({ repo: one.path, name: one.name, muted: muted.has(one.path) }))
24047
24118
  .sort((a, b) => a.name.localeCompare(b.name));
24048
24119
  }
24049
- function settingsPage(chrome, existing, hasEnv, csrf, problem, messaging = null, push = null, providerKeys = null, digest = null, permissionDefault = null, qualityDefault = null, email = null, telegramDelivery = null, workers = null, updates = null, firstResult = null, chatNotices = null, telegramFailing = false) {
24120
+ function settingsPage(chrome, existing, hasEnv, csrf, problem, messaging = null, push = null, providerKeys = null, digest = null, permissionDefault = null, qualityDefault = null, email = null, telegramDelivery = null, workers = null, updates = null, firstResult = null, chatNotices = null, telegramFailing = false, phone = null) {
24050
24121
  const permissionCard = permissionDefault === null
24051
24122
  ? ""
24052
24123
  : [
@@ -24272,10 +24343,12 @@ function settingsPage(chrome, existing, hasEnv, csrf, problem, messaging = null,
24272
24343
  workers,
24273
24344
  updates,
24274
24345
  firstResult,
24346
+ ...(phone === null ? {} : { phone }),
24275
24347
  };
24276
24348
  return screen("Settings", [
24277
24349
  "<h1>Settings</h1>",
24278
24350
  settingsTiles(view.groups),
24351
+ phone === null ? "" : phoneSetupHtml(phone),
24279
24352
  appearanceCard(csrf),
24280
24353
  permissionCard,
24281
24354
  qualityCard,
@@ -24301,6 +24374,15 @@ function settingsPage(chrome, existing, hasEnv, csrf, problem, messaging = null,
24301
24374
  `</details>`,
24302
24375
  ].join("\n"), { chrome, workspace: { view }, functional: { script: SETTINGS_AUTOSAVE_SCRIPT + (pushScript ?? ""), ...(pushScript === null ? {} : { fetches: true }) } });
24303
24376
  }
24377
+ /** The phone setup for the server-rendered Settings page: the same choices as the React card. */
24378
+ function phoneSetupHtml(phone) {
24379
+ const [first, ...others] = phone.chatApps;
24380
+ return `<section class="card" id="phone" aria-labelledby="phone-card-title" data-phone-card><h2 id="phone-card-title">Use it from your phone</h2>` +
24381
+ `<p>${first === undefined ? "" : `<a href="${escape(first.href)}">Pair ${escape(first.label)}</a>`}${others.length === 0 ? "" : ` · or ${others.map(one => `<a href="${escape(one.href)}">${escape(one.label)}</a>`).join(", ")}`}</p>` +
24382
+ `<p class="meta" data-phone-tailnet>${phone.tailnet === null ? `Or install <a href="https://tailscale.com/download">Tailscale</a> on this computer and your phone, then reload this page for the address.`
24383
+ : phone.tailnet.restart === null ? `Or on your phone open <code>${escape(phone.tailnet.address)}</code> and sign in.`
24384
+ : `Or start Toolroll with <code>${escape(phone.tailnet.restart)}</code>, then on your phone open <code>${escape(phone.tailnet.address)}</code> and sign in.`}</p></section>`;
24385
+ }
24304
24386
  /** Every worker that is not retired: its capacity, and the tasks it holds now. */
24305
24387
  function settingsWorkers(store, now) {
24306
24388
  const claims = store.liveClaims(null, now);
package/dist/store.d.ts CHANGED
@@ -6323,6 +6323,11 @@ export declare class Store {
6323
6323
  };
6324
6324
  /** The approver's live thread for one scope, read only — null when none is open. */
6325
6325
  liveMateThreadFor(approver: string, scope?: MateThreadScope): MateThread | null;
6326
+ /** The lead thread a ceiling change closed just before `current` (ruling 9),
6327
+ * read only and for display only: it never continues and never reaches the
6328
+ * model. Null when there is none, when it was ended or revoked (its text is
6329
+ * gone), or when it closed under the same ceiling. */
6330
+ replacedLeadThread(current: MateThread): MateThread | null;
6326
6331
  /** The task a paired chat is talking about (v78), or null for the lead conversation. */
6327
6332
  chatFocus(surface: string, binding: number): string | null;
6328
6333
  setChatFocus(surface: string, binding: number, task: string | null, now: Date): void;
package/dist/store.js CHANGED
@@ -19275,6 +19275,20 @@ export class Store {
19275
19275
  const row = this.db.prepare("SELECT * FROM mate_thread WHERE approver = ? AND scope_kind = ? AND scope_key IS ? AND closed_at IS NULL AND NOT EXISTS (SELECT 1 FROM team_conversation tc WHERE tc.thread=mate_thread.id) ORDER BY id DESC LIMIT 1").get(approver, scope.kind, scope.kind === "lead" ? null : scope.key);
19276
19276
  return row === undefined ? null : readMateThread(row);
19277
19277
  }
19278
+ /** The lead thread a ceiling change closed just before `current` (ruling 9),
19279
+ * read only and for display only: it never continues and never reaches the
19280
+ * model. Null when there is none, when it was ended or revoked (its text is
19281
+ * gone), or when it closed under the same ceiling. */
19282
+ replacedLeadThread(current) {
19283
+ if (current.scope.kind !== "lead")
19284
+ return null;
19285
+ const row = this.db.prepare(`SELECT * FROM mate_thread WHERE approver = ? AND scope_kind = 'lead' AND scope_key IS NULL AND id < ? AND closed_at IS NOT NULL
19286
+ AND NOT EXISTS (SELECT 1 FROM team_conversation tc WHERE tc.thread=mate_thread.id) ORDER BY id DESC LIMIT 1`).get(current.approver, current.id);
19287
+ if (row === undefined || String(row["ceiling_digest"]) === current.ceilingDigest)
19288
+ return null;
19289
+ const previous = readMateThread(row);
19290
+ return this.db.prepare("SELECT 1 FROM mate_message WHERE thread = ? LIMIT 1").get(previous.id) === undefined ? null : previous;
19291
+ }
19278
19292
  /** The task a paired chat is talking about (v78), or null for the lead conversation. */
19279
19293
  chatFocus(surface, binding) {
19280
19294
  const row = this.db.prepare("SELECT task FROM chat_focus WHERE surface = ? AND binding = ?").get(surface, binding);
@@ -1,4 +1,5 @@
1
1
  import type { WorkIndexPage } from './work-index.js';
2
+ import type { BrowserActionCard } from './browser-workspace.js';
2
3
  /** Shared central-team transport contract. Requests never establish actor identity. */
3
4
  export type TeamActor = {
4
5
  name: string;
@@ -52,6 +53,15 @@ export type TeamChatAuthorization = {
52
53
  termsDigest: string;
53
54
  waitingReason?: string;
54
55
  };
56
+ /** Browser cards extend the existing CLI summary; both refer to the same saved proposal. */
57
+ export type TeamProposal = {
58
+ id: number;
59
+ turnId: number;
60
+ title: string;
61
+ state: string;
62
+ href: string;
63
+ card?: BrowserActionCard;
64
+ };
55
65
  export type TeamSnapshot = {
56
66
  leads: TeamLead[];
57
67
  conversations: TeamConversation[];
@@ -67,13 +77,7 @@ export type TeamSnapshot = {
67
77
  accounts: string[];
68
78
  chatAuthorization?: TeamChatAuthorization;
69
79
  tasks?: WorkIndexPage;
70
- proposals?: {
71
- id: number;
72
- turnId: number;
73
- title: string;
74
- state: string;
75
- href: string;
76
- }[];
80
+ proposals?: TeamProposal[];
77
81
  };
78
82
  export declare const TEAM_OPERATIONS: readonly ['list', 'show', 'create-lead', 'update-lead', 'create-conversation', 'member', 'send', 'edit', 'withdraw', 'read', 'authorize', 'follow', 'stop', 'transfer'];
79
83
  export type TeamOperation = typeof TEAM_OPERATIONS[number];
@@ -11,6 +11,12 @@ type Waiting = {
11
11
  };
12
12
  /** The card, if it is still at that visit, waiting at a decision. */
13
13
  export declare function flowDecisionAt(store: Store, cardId: number, entry: number): Waiting | null;
14
+ /**
15
+ * A flow decision as Telegram shows it: the action and where each button takes the card first, then the draft (as
16
+ * the card holds it now, so what is approved is what is read) and why a teammate handed it over, then the flow and
17
+ * zone. `said` is the saved notice's body: the words before its instruction are the teammate's handoff.
18
+ */
19
+ export declare function flowDecisionText(waiting: Waiting, said: string | null, head?: string): string;
14
20
  /** Buttons for one visit, minted before the send; `place` stamps the message they landed on. */
15
21
  export declare function flowButtons(store: Store, binding: TelegramBinding, waiting: Waiting, now: Date): {
16
22
  keyboard: InlineButton[][];
@@ -20,6 +26,9 @@ export declare function flowButtons(store: Store, binding: TelegramBinding, wait
20
26
  export declare function flowSentContent(store: Store, card: number, entry: number): FlowSendContent | null;
21
27
  /** A sent visit's links as one row of buttons: Toolroll's pages under the trusted origin, and its pull request. */
22
28
  export declare function flowSendKeyboardRow(origin: string | null, content: FlowSendContent): InlineButton[];
29
+ /** A choice as Telegram shows it, first: what to choose, then where each option (and a reply) takes the card. The
30
+ * options are the ones its buttons carry: offered when sent, and still offered. */
31
+ export declare function flowChoiceHead(visit: FlowChoiceVisit, content: FlowSendContent): string[];
23
32
  /** A choice's buttons for one visit, one option to a row, minted before the send; `place` stamps the message they landed on. */
24
33
  export declare function flowChoiceButtons(store: Store, binding: TelegramBinding, visit: FlowChoiceVisit, content: FlowSendContent, now: Date): {
25
34
  keyboard: InlineButton[][];