toolroll 0.9.38 → 0.9.40

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.
@@ -100,6 +100,10 @@ export type BrowserConversation = {
100
100
  resultRunId: number | null;
101
101
  /** A project's own thread (v77); absent or null for the lead conversation or a task. */
102
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;
103
107
  };
104
108
  /** A page rebuilt as React components (shadcn/ui). The server still renders
105
109
  * its HTML as the no-JavaScript fallback; forms post to the same routes. */
@@ -176,6 +180,8 @@ export type BrowserLimits = {
176
180
  export type BrowserSettingsView = {
177
181
  kind: 'settings';
178
182
  said: string | null;
183
+ /** Using it from a phone, under Chat apps (approvers only). */
184
+ phone?: BrowserPhoneCard;
179
185
  /** The settings destinations under short headings; a chat app's tile says whether it is connected and gets alerts. */
180
186
  groups: BrowserSettingsGroup[];
181
187
  theme: 'system' | 'light' | 'dark';
@@ -125,6 +125,7 @@ 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
+ version?: never;
128
129
  active: boolean;
129
130
  phase: string;
130
131
  detail: string;
@@ -138,7 +139,6 @@ export declare function desktopUpdateStatus(stateDir: string): {
138
139
  wasRunning?: never;
139
140
  backupPath?: never;
140
141
  updateId?: never;
141
- version?: never;
142
142
  buildId?: never;
143
143
  workDir?: never;
144
144
  } | {
package/dist/mate-cli.js CHANGED
@@ -316,7 +316,10 @@ export async function runMateCli(input) {
316
316
  say(done ? `dismissed ${act[2]}` : "that proposal was already acted on");
317
317
  }
318
318
  else {
319
- const outcome = confirmMateProposal(store, who, proposal.id, now, { confirm: act[3] !== undefined, via: "cli", ...(input.evidenceRoot === undefined ? {} : { evidenceRoot: input.evidenceRoot }) });
319
+ const outcome = confirmMateProposal(store, who, proposal.id, now, { confirm: act[3] !== undefined, via: "cli", chatProvider: () => {
320
+ const current = store.getChatConfig();
321
+ return current === null ? null : { config: current, key: isDirectChatProvider(current.provider) ? keyFor(current, input.databaseFile, env) : null };
322
+ }, ...(input.evidenceRoot === undefined ? {} : { evidenceRoot: input.evidenceRoot }) });
320
323
  emit({ ok: outcome.ok, act: "confirm", proposal: proposal.id, ...(outcome.ok ? { said: outcome.said, taskId: outcome.taskId } : { reason: outcome.reason, said: outcome.said }) });
321
324
  say(outcome.ok ? outcome.said : outcome.reason === "needs-confirm" ? `${outcome.said}: confirm ${act[2]} yes` : `refused: ${outcome.said}`);
322
325
  if (outcome.ok && outcome.kind === "scope" && outcome.taskId !== null)
@@ -1,6 +1,7 @@
1
1
  import { type SharedActionOptions } from './chat-actions.js';
2
2
  import { type CoordinatorProposal, type MateProposal, type Store } from "./store.js";
3
3
  import type { VerifiedApprover } from "./principal.js";
4
+ import { type TeamChatProviderResolver } from './team-chat-authorization.js';
4
5
  export type ProposalKind = MateProposal["kind"];
5
6
  export type DoorOutcome = {
6
7
  ok: true;
@@ -14,8 +15,10 @@ export type DoorOutcome = {
14
15
  reason: DoorRefusal;
15
16
  said: string;
16
17
  };
17
- export type DoorRefusal = "standing" | "not-yours" | "ceiling-changed" | "not-pending" | "session-ended" | "turn-not-answered" | "not-confirmable" | "needs-confirm" | "stale" | "claimed" | "not-queued" | "contest-open" | "unknown-task" | "unknown-decision" | "already-answered" | "expired" | "outside-ceiling" | "refused";
18
+ export type DoorRefusal = "standing" | "not-yours" | "ceiling-changed" | "not-pending" | "session-ended" | "turn-not-answered" | "turn-running" | "not-confirmable" | "needs-confirm" | "stale" | "claimed" | "not-queued" | "contest-open" | "unknown-task" | "unknown-decision" | "already-answered" | "expired" | "outside-ceiling" | "refused";
18
19
  export type DoorOptions = {
20
+ /** Resolved inside the transaction. Without it, only current subscription terms are available. */
21
+ chatProvider?: TeamChatProviderResolver;
19
22
  evidenceRoot?: string;
20
23
  /** Only the secure human review endpoint supplies this; never saved or model-authored. */
21
24
  actionReview?: SharedActionOptions["review"];
@@ -36,6 +39,32 @@ export type DoorOptions = {
36
39
  };
37
40
  /** A coordinator proposal lives seven days (§9); the door refuses an older one whatever the sweep did (v3 review, finding 3). */
38
41
  export declare const COORDINATOR_PROPOSAL_TTL_MS: number;
42
+ /** The plain reasons a card shows instead of its controls; the door refuses with the same words. */
43
+ export declare const PROPOSAL_CONTRIBUTOR_REASON = "An authorized contributor can act on this proposal.";
44
+ export declare const PROPOSAL_CHAT_REASON = "Enable chat in this conversation before acting on a proposal.";
45
+ export declare const PROPOSAL_WAIT_REASON = "Available when the current reply finishes.";
46
+ /** Unbound claims get the same wall-clock allowance as a provider turn. */
47
+ export declare const TEAM_UNBOUND_CLAIM_MS = 120000;
48
+ export type ProposalGate = {
49
+ ok: true;
50
+ } | {
51
+ ok: false;
52
+ reason: Extract<DoorRefusal, "not-yours" | "session-ended" | "turn-running">;
53
+ said: string;
54
+ };
55
+ /**
56
+ * Whether `who` may act on a card in `thread` right now: the one gate the
57
+ * cards and the confirm door share. Re-proved from saved state and live provider facts, so
58
+ * every caller (console, CLI, Telegram) reaches the same answer: the thread
59
+ * is theirs (or they contribute to its team conversation), chat is enabled
60
+ * for it under their current generation, and no turn in that thread is
61
+ * live. A team message still marked running after its turn finished is not
62
+ * live; the delivery sweep settles it.
63
+ */
64
+ export declare function proposalActGate(store: Store, who: {
65
+ name: string;
66
+ generation: number;
67
+ }, thread: number, now: Date, provider?: TeamChatProviderResolver): ProposalGate;
39
68
  export declare function confirmMateProposal(store: Store, who: VerifiedApprover, proposalId: number, now: Date, options: DoorOptions): DoorOutcome;
40
69
  /** The operator declines the mate's card: `pending → dismissed`. */
41
70
  export declare function dismissMateProposal(store: Store, who: VerifiedApprover, proposalId: number, now: Date): boolean;
@@ -17,6 +17,9 @@ import { executeSharedAction, sharedActionPayload, sharedActionAllowsChallenge,
17
17
  import { isCheckLevel, setTaskCheckLevel } from "./check-levels.js";
18
18
  import { mateTurnKeepsProposals, verifiedAuthor } from "./store.js";
19
19
  import { isVerifiedApprover, reproveApprover } from "./principal.js";
20
+ import { TURN_WALL_CLOCK_MS } from './converse.js';
21
+ import { teamChatAuthorization, subscriptionTeamChatProvider } from './team-chat-authorization.js';
22
+ import { readProjectAccess } from "./project-access.js";
20
23
  import { TeamLeads } from './team-leads.js';
21
24
  import { fileTaskProposal } from "./proposal.js";
22
25
  import { proposeGuarded } from "./scope.js";
@@ -74,6 +77,38 @@ function recordInTaskChat(store, who, proposal, outcome, via, now) {
74
77
  const thread = store.openMateThread(who.name, who.ceilingDigest, now, { kind: "task", key: root }).thread;
75
78
  store.appendMateMessage({ thread: thread.id, turn: null, role: "assistant", text: `${where} — ${label}: ${said}` }, now);
76
79
  }
80
+ /** The plain reasons a card shows instead of its controls; the door refuses with the same words. */
81
+ export const PROPOSAL_CONTRIBUTOR_REASON = "An authorized contributor can act on this proposal.";
82
+ export const PROPOSAL_CHAT_REASON = "Enable chat in this conversation before acting on a proposal.";
83
+ export const PROPOSAL_WAIT_REASON = "Available when the current reply finishes.";
84
+ /** Unbound claims get the same wall-clock allowance as a provider turn. */
85
+ export const TEAM_UNBOUND_CLAIM_MS = TURN_WALL_CLOCK_MS;
86
+ /**
87
+ * Whether `who` may act on a card in `thread` right now: the one gate the
88
+ * cards and the confirm door share. Re-proved from saved state and live provider facts, so
89
+ * every caller (console, CLI, Telegram) reaches the same answer: the thread
90
+ * is theirs (or they contribute to its team conversation), chat is enabled
91
+ * for it under their current generation, and no turn in that thread is
92
+ * live. A team message still marked running after its turn finished is not
93
+ * live; the delivery sweep settles it.
94
+ */
95
+ export function proposalActGate(store, who, thread, now, provider = () => subscriptionTeamChatProvider(store)) {
96
+ const row = store.getMateThread(thread);
97
+ const shared = row === null ? undefined : store.handle.prepare("SELECT id,projects_json FROM team_conversation WHERE thread=?").get(thread);
98
+ if (row === null || (shared ? !store.canUseTeamMateThread(who.name, who.generation, thread) : row.approver !== who.name))
99
+ return { ok: false, reason: "not-yours", said: PROPOSAL_CONTRIBUTOR_REASON };
100
+ const session = shared ? store.teamMateSession(who.name, thread) : store.activeMateSession(who.name);
101
+ const authorized = shared
102
+ ? teamChatAuthorization(store, who, { id: String(shared['id']), threadId: thread, projects: [...(readProjectAccess(shared['projects_json']) ?? [])] }, provider()).enabled
103
+ : session !== null && session.endedAt === null && session.approverGeneration === who.generation;
104
+ if (!authorized) {
105
+ return { ok: false, reason: "session-ended", said: PROPOSAL_CHAT_REASON };
106
+ }
107
+ const live = store.handle.prepare(`SELECT 1 FROM mate_turn WHERE thread=? AND state IN ('queued','running')
108
+ UNION ALL SELECT 1 FROM team_message q JOIN team_conversation c ON c.id=q.conversation LEFT JOIN mate_turn t ON t.id=q.turn_id
109
+ WHERE c.thread=? AND q.status='running' AND ((q.turn_id IS NULL AND q.claimed_at>?) OR t.state IN ('queued','running')) LIMIT 1`).get(thread, thread, new Date(now.getTime() - TEAM_UNBOUND_CLAIM_MS).toISOString());
110
+ return live === undefined ? { ok: true } : { ok: false, reason: "turn-running", said: PROPOSAL_WAIT_REASON };
111
+ }
77
112
  export function confirmMateProposal(store, who, proposalId, now, options) {
78
113
  if (!isVerifiedApprover(who))
79
114
  return { ok: false, kind: null, reason: "standing", said: "your approver standing changed — sign in again" };
@@ -89,10 +124,11 @@ export function confirmMateProposal(store, who, proposalId, now, options) {
89
124
  const shared = thread !== null && store.handle.prepare("SELECT id,lead FROM team_conversation WHERE thread=?").get(thread.id);
90
125
  if (thread === null || (shared ? !store.canUseTeamMateThread(who.name, who.generation, thread.id) : thread.approver !== who.name))
91
126
  return { ok: false, kind: null, reason: "not-yours", said: "no such proposal" };
92
- const session = shared ? store.teamMateSession(who.name, thread.id) : store.activeMateSession(who.name);
93
- if (session === null || session.approverGeneration !== who.generation) {
94
- return { ok: false, kind: proposal.kind, reason: "session-ended", said: "the mate session this was proposed in has ended — its cards cannot be confirmed" };
95
- }
127
+ // The card's own gate: chat enabled for this thread and no live turn in it.
128
+ const gate = proposalActGate(store, who, thread.id, now, options.chatProvider);
129
+ if (!gate.ok)
130
+ return { ok: false, kind: proposal.kind, reason: gate.reason, said: gate.said };
131
+ const session = (shared ? store.teamMateSession(who.name, thread.id) : store.activeMateSession(who.name));
96
132
  if (proposal.ceilingDigest !== who.ceilingDigest || session.ceilingDigest !== who.ceilingDigest) {
97
133
  return { ok: false, kind: proposal.kind, reason: "ceiling-changed", said: "the admitted projects changed since this was proposed — it cannot be confirmed" };
98
134
  }
package/dist/serve.js CHANGED
@@ -272,7 +272,7 @@ import { checkTeamsCredentials, clearTeamsCredentials, loadTeamsCredentials, sav
272
272
  import { verifyApproverByPassword, verifyApproverStanding } from "./principal.js";
273
273
  import { runMateTurn, MATE_MESSAGE_MAX_CHARS } from "./mate.js";
274
274
  import { replyHtmlInline, shapeReply } from "./reply-shape.js";
275
- import { confirmCoordinatorProposal, confirmMateProposal, dismissCoordinatorProposal, dismissMateProposal } from "./mate-doors.js";
275
+ import { confirmCoordinatorProposal, confirmMateProposal, dismissCoordinatorProposal, dismissMateProposal, proposalActGate, PROPOSAL_WAIT_REASON } from "./mate-doors.js";
276
276
  import { envValue } from "./names.js";
277
277
  import { cachedRelease, isNewer, runnerVersions, setUpdateChecks, updateChecksOff } from "./releases.js";
278
278
  import { installMethod } from "./install-method.js";
@@ -739,11 +739,25 @@ export function createDecisionServer(options) {
739
739
  const connectVisits = new Map();
740
740
  /** Flows open in a browser (v88): who's here, and a nudge when one changes. */
741
741
  const flowRooms = createFlowRooms(flow => flowFingerprint(store, flow));
742
+ const teamChatProvider = () => { const enabled = chatEnablement(); return enabled.ok ? { config: enabled.config, key: enabled.key } : null; };
742
743
  const team = createTeamRuntime({ store, repos: codingProjects, evidenceRoot, clock, workspaceRevision,
743
744
  ...(options.chatFetcher ? { fetcher: options.chatFetcher } : {}),
744
- provider: () => { const enabled = chatEnablement(); return enabled.ok ? { config: enabled.config, key: enabled.key } : null; },
745
+ provider: teamChatProvider,
745
746
  ...(options.subscriptionChatRunner ? { subscriptionRunner: options.subscriptionChatRunner } : {}),
746
747
  });
748
+ // Only browser sessions receive HTML cards. The CLI keeps its summary contract.
749
+ const teamBrowserReply = (reply, actor, csrf) => {
750
+ const snapshot = reply.snapshot;
751
+ if (!snapshot?.selected || !snapshot.proposals)
752
+ return reply;
753
+ return { ...reply, snapshot: { ...snapshot, proposals: snapshot.proposals.map(summary => {
754
+ const proposal = store.getMateProposal(summary.id);
755
+ if (!proposal || proposal.thread !== snapshot.selected.threadId)
756
+ return summary;
757
+ const decision = proposal.kind === 'answer' && typeof proposal.payload['decision'] === 'number' ? store.getDecision(proposal.payload['decision']) : null;
758
+ return { ...summary, card: teamProposalCardParts(store, actor, proposal, snapshot, csrf, decision, clock(), teamChatProvider).card };
759
+ }) } };
760
+ };
747
761
  const teamEndpoint = (request, response) => handleTeamHttp(request, response, {
748
762
  authenticate: request => {
749
763
  const who = identify(request, request.method === 'POST');
@@ -773,7 +787,11 @@ export function createDecisionServer(options) {
773
787
  return false;
774
788
  return request.headers['x-csrf-token'] === who.session.csrf;
775
789
  },
776
- execute: team.execute, cursor: team.cursor, streams: teamStreams,
790
+ execute: async (actor, input) => {
791
+ const reply = await team.execute(actor, input);
792
+ const who = identify(request, false);
793
+ return who?.via === 'cookie' ? teamBrowserReply(reply, actor, who.session.csrf) : reply;
794
+ }, cursor: team.cursor, streams: teamStreams,
777
795
  });
778
796
  const sessionEndpoint = createSessionEndpoint({ store, workspace: coding, projects: codingProjects, projectAllowed: repo => rowVisible(liveCeiling(), repo) });
779
797
  /** The enumerable admission list for roll-up SQL: repos-only ceilings
@@ -3585,11 +3603,11 @@ export function createDecisionServer(options) {
3585
3603
  if (who.via !== "cookie")
3586
3604
  return refuse(response, who, 403, "chat is a browser surface — it keeps your drafts in the session");
3587
3605
  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 }, {
3606
+ const reply = teamBrowserReply(await team.execute({ name: who.name, generation: who.session.generation }, {
3589
3607
  operation: url.searchParams.has('conversation') ? 'show' : 'list',
3590
3608
  args: { ...(url.searchParams.has('conversation') ? { conversationId: url.searchParams.get('conversation') } : {}),
3591
3609
  ...(url.searchParams.has('lead') ? { leadId: url.searchParams.get('lead') } : {}) },
3592
- });
3610
+ }), { name: who.name, generation: who.session.generation }, who.session.csrf);
3593
3611
  if (!reply.ok || !reply.snapshot)
3594
3612
  return refuse(response, who, 403, reply.message, '/chat?private=1');
3595
3613
  if (url.searchParams.has('proposal')) {
@@ -3599,11 +3617,7 @@ export function createDecisionServer(options) {
3599
3617
  return refuse(response, who, 404, 'This proposal is unavailable.', '/chat');
3600
3618
  const back = '/chat?conversation=' + encodeURIComponent(selected.id);
3601
3619
  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.');
3620
+ const card = teamProposalCardParts(store, { name: who.name, generation: who.session.generation }, proposal, reply.snapshot, who.session.csrf, decision, clock(), teamChatProvider).html;
3607
3621
  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
3622
  }
3609
3623
  if (reply.snapshot.leads.length > 0 || url.searchParams.has('conversation') || url.searchParams.get('team') === '1') {
@@ -4290,7 +4304,7 @@ export function createDecisionServer(options) {
4290
4304
  })(), (() => {
4291
4305
  const chosen = store.notificationPreference(who.name);
4292
4306
  return { mode: chosen.mode, digestAt: chosen.digestAt, screenshots: chosen.screenshots, projects: notificationProjects(store, who.name) };
4293
- })(), telegramTrouble(store, loadBotToken(process.env, options.telegramTokenFile))));
4307
+ })(), telegramTrouble(store, loadBotToken(process.env, options.telegramTokenFile)), who.via === "cookie" ? phoneSetup(who) ?? null : null));
4294
4308
  }
4295
4309
  if (url.pathname === "/push/key") {
4296
4310
  // Session-gated: the key is not secret, but strangers get nothing.
@@ -4464,12 +4478,19 @@ export function createDecisionServer(options) {
4464
4478
  return first !== undefined && String(first["id"]) === rootId;
4465
4479
  }
4466
4480
  /** After the first Ready result, once (onboarding): the phone, by a chat app or the console over Tailscale. */
4481
+ /** Chat's one-line pointer to the phone setup: after the first Ready result, until put away or Telegram is paired. */
4467
4482
  function phoneCard(who, now) {
4468
4483
  if (who.role !== "approver" || store.isDemo() || store.firstSuccessAt(now) === null || store.installationFact(PHONE_CARD_FACT) !== null)
4469
4484
  return undefined;
4470
4485
  const botId = options.telegramTokenFile === undefined ? null : loadBotToken(process.env, options.telegramTokenFile)?.botId ?? null;
4471
4486
  if (botId !== null && store.liveTelegramBindings(botId).length > 0)
4472
4487
  return undefined;
4488
+ return phoneSetup(who);
4489
+ }
4490
+ /** The phone setup itself (Settings → Chat apps): a chat app, or this console over Tailscale. */
4491
+ function phoneSetup(who) {
4492
+ if (who.role !== "approver" || store.isDemo())
4493
+ return undefined;
4473
4494
  const port = servedPort() ?? 4180;
4474
4495
  const address = server.address();
4475
4496
  const bound = typeof address === "object" && address !== null ? address.address : "127.0.0.1";
@@ -4509,7 +4530,11 @@ export function createDecisionServer(options) {
4509
4530
  const ask = last?.role === "assistant" && last.turn !== null && store.getMateTurn(last.turn)?.approver === who.name ? store.mateAskOpen(last.turn, now) : null;
4510
4531
  // Answered questions stay readable above the answer; only their buttons go.
4511
4532
  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]]; }));
4533
+ // The thread a ceiling change replaced (ruling 9): read after the open, display only.
4534
+ const replaced = store.replacedLeadThread(opened.thread);
4535
+ const previousProposals = replaced === null ? [] : store.listMateProposals(replaced.id);
4512
4536
  return {
4537
+ previous: replaced === null ? null : { messages: store.listMateMessages(replaced.id, 40), proposals: previousProposals, decisions: decisionsFor(store, previousProposals) },
4513
4538
  messages,
4514
4539
  ask,
4515
4540
  asks,
@@ -9621,7 +9646,7 @@ export function createDecisionServer(options) {
9621
9646
  noteMate(who.session.csrf, null, "that proposal was already acted on");
9622
9647
  return redirect(response, chatReturnWithLatest(back));
9623
9648
  }
9624
- const outcome = confirmMateProposal(store, principal, id, now, { confirm: body.get("confirm") === "yes", via: "web", evidenceRoot, held: options.attended?.coordinator, ...(body.has("nonce") ? { actionReview: { nonce: body.get("nonce") ?? "", password: body.get("token") ?? "" } } : {}) });
9649
+ const outcome = confirmMateProposal(store, principal, id, now, { chatProvider: teamChatProvider, confirm: body.get("confirm") === "yes", via: "web", evidenceRoot, held: options.attended?.coordinator, ...(body.has("nonce") ? { actionReview: { nonce: body.get("nonce") ?? "", password: body.get("token") ?? "" } } : {}) });
9625
9650
  if (cardJson) {
9626
9651
  if (!outcome.ok)
9627
9652
  return cardAnswer(outcome.reason === "standing" ? 403 : outcome.reason === "not-yours" ? 404 : 409, false, outcome.said);
@@ -15406,6 +15431,8 @@ ${THEME_DARK}
15406
15431
  min-height: min(32rem, 48vh); padding: .25rem;
15407
15432
  }
15408
15433
  .thread .msg { max-width: 48rem; line-height: 1.65; overflow-wrap: anywhere; }
15434
+ .chat-previous-divider { display: flex; align-items: center; gap: .6rem; margin: 1rem 0; color: var(--so-muted); font-size: .8rem; }
15435
+ .chat-previous-divider::before, .chat-previous-divider::after { content: ""; flex: 1; border-top: 1px solid var(--so-line); }
15409
15436
  .thread .msg p { margin: .3rem 0; }
15410
15437
  .thread .msg.op {
15411
15438
  align-self: flex-end; max-width: min(82%, 40rem); padding: .75rem 1rem;
@@ -18250,7 +18277,7 @@ function proposalCardParts(view, csrf, inert, decision, returnTo = null) {
18250
18277
  `</div>`;
18251
18278
  }
18252
18279
  else if (view.state === "pending") {
18253
- acts = `<p class="meta proposal-wait">Available when the current turn finishes.</p>`;
18280
+ acts = `<p class="meta proposal-wait">${escape(PROPOSAL_WAIT_REASON)}</p>`;
18254
18281
  }
18255
18282
  else if (view.state === "confirmed") {
18256
18283
  const filed = outcome !== null && typeof outcome.taskId === "string" ? outcome.taskId : null;
@@ -18299,7 +18326,7 @@ function proposalCardParts(view, csrf, inert, decision, returnTo = null) {
18299
18326
  ],
18300
18327
  primary,
18301
18328
  dismissable: pending && view.kind !== "control",
18302
- note: view.state === "pending" && inert ? "Available when the current reply finishes."
18329
+ note: view.state === "pending" && inert ? PROPOSAL_WAIT_REASON
18303
18330
  : pending && view.kind === "cancel" ? "Cancelling is confirmed on the task itself."
18304
18331
  : view.state === "dismissed" ? "Dismissed." : view.state === "expired" ? "Expired — this conversation moved on." : null,
18305
18332
  };
@@ -18311,6 +18338,18 @@ function mateProposalCard(proposal, csrf, inert, decision, returnTo = null) {
18311
18338
  function mateProposalCardParts(proposal, csrf, inert, decision, returnTo = null) {
18312
18339
  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);
18313
18340
  }
18341
+ /** Review and inline team cards share the same server-decided controls and reasons. */
18342
+ function teamProposalCardParts(store, actor, proposal, snapshot, csrf, decision, now, provider) {
18343
+ const gate = proposalActGate(store, actor, proposal.thread, now, provider);
18344
+ const reason = !gate.ok ? gate.said : null;
18345
+ const back = '/chat?conversation=' + encodeURIComponent(snapshot.selected.id);
18346
+ const parts = mateProposalCardParts(proposal, csrf, reason !== null, decision, back);
18347
+ if (reason !== null && reason !== PROPOSAL_WAIT_REASON && proposal.state === 'pending') {
18348
+ parts.html = parts.html.replace(PROPOSAL_WAIT_REASON, reason);
18349
+ parts.card = { ...parts.card, note: reason };
18350
+ }
18351
+ return parts;
18352
+ }
18314
18353
  function coordinatorProposalCard(proposal, csrf, now, decision, returnTo = null) {
18315
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);
18316
18355
  }
@@ -18330,6 +18369,24 @@ function coordinatorProposalsSection(proposals, decisions, csrf, now, heading =
18330
18369
  proposals.map(one => coordinatorProposalCard(one, csrf, now, decisions.get(typeof one.payload["decision"] === "number" ? one.payload["decision"] : -1) ?? null, returnTo)).join("") +
18331
18370
  `</div>`);
18332
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
+ }
18333
18390
  /** The lead's question as buttons: each option sends itself as the next message (the same POST as a typed one);
18334
18391
  * "Something else" moves to the composer. Nothing shows while a reply is running. */
18335
18392
  function mateAskHtml(ask, csrf, target, composer, open = true) {
@@ -18378,6 +18435,7 @@ function mateChatVersion(rows) {
18378
18435
  const focus = rows.focusTask;
18379
18436
  const facts = [
18380
18437
  rows.messages.map(one => [one.id, one.role, one.turn]),
18438
+ rows.previous == null ? null : rows.previous.messages.map(one => one.id),
18381
18439
  rows.proposals.map(one => [one.id, one.state, one.outcome === null ? null : JSON.stringify(one.outcome)]),
18382
18440
  rows.coordinatorProposals.map(one => [one.id, one.state, one.outcome === null ? null : JSON.stringify(one.outcome)]),
18383
18441
  [...rows.decisions.values()].map(one => [one.id, one.state, one.choice ?? null]),
@@ -18504,6 +18562,7 @@ function matePage(chrome, data) {
18504
18562
  }
18505
18563
  const lastMessage = data.messages.at(-1);
18506
18564
  const latestReply = data.pending === null && lastMessage?.role === "assistant" ? lastMessage.id : null;
18565
+ conversation.push(replacedThreadHtml(data.previous));
18507
18566
  conversation.push(mateThreadHtml({ ...data, problem: null, chatProject }));
18508
18567
  conversation.push(
18509
18568
  // The New update action (package 2): hidden until a live update lands
@@ -18524,6 +18583,7 @@ function matePage(chrome, data) {
18524
18583
  messages: mateBrowserMessages(data, data.csrf, data.focusTask === null && chatProject === null ? null : returnTo, { task: data.focusTask?.id ?? null, project: chatProject }),
18525
18584
  pendingTurnId: data.pending?.id ?? null, requestId: randomBytes(16).toString('hex'), maxChars: MATE_MESSAGE_MAX_CHARS,
18526
18585
  taskId: data.focusTask?.id ?? null, resultRunId: data.resultRunId ?? null, project: chatProject,
18586
+ ...(data.previous == null ? {} : { previous: { messages: replacedBrowserMessages(data.previous, data.csrf) } }),
18527
18587
  },
18528
18588
  focus: data.focusTask === null ? null : { id: data.focusTask.id, title: data.focusTask.title,
18529
18589
  html: taskChatLiveRegion(data.focusTask, data.csrf, requestContext.getStore()?.workspaceRead === true, data.pending !== null) },
@@ -24057,7 +24117,7 @@ function notificationProjects(store, account) {
24057
24117
  .map(one => ({ repo: one.path, name: one.name, muted: muted.has(one.path) }))
24058
24118
  .sort((a, b) => a.name.localeCompare(b.name));
24059
24119
  }
24060
- 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) {
24061
24121
  const permissionCard = permissionDefault === null
24062
24122
  ? ""
24063
24123
  : [
@@ -24283,10 +24343,12 @@ function settingsPage(chrome, existing, hasEnv, csrf, problem, messaging = null,
24283
24343
  workers,
24284
24344
  updates,
24285
24345
  firstResult,
24346
+ ...(phone === null ? {} : { phone }),
24286
24347
  };
24287
24348
  return screen("Settings", [
24288
24349
  "<h1>Settings</h1>",
24289
24350
  settingsTiles(view.groups),
24351
+ phone === null ? "" : phoneSetupHtml(phone),
24290
24352
  appearanceCard(csrf),
24291
24353
  permissionCard,
24292
24354
  qualityCard,
@@ -24312,6 +24374,15 @@ function settingsPage(chrome, existing, hasEnv, csrf, problem, messaging = null,
24312
24374
  `</details>`,
24313
24375
  ].join("\n"), { chrome, workspace: { view }, functional: { script: SETTINGS_AUTOSAVE_SCRIPT + (pushScript ?? ""), ...(pushScript === null ? {} : { fetches: true }) } });
24314
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
+ }
24315
24386
  /** Every worker that is not retired: its capacity, and the tasks it holds now. */
24316
24387
  function settingsWorkers(store, now) {
24317
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);
@@ -0,0 +1,11 @@
1
+ import type { Store, ChatConfig } from './store.js';
2
+ import type { TeamActor, TeamChatAuthorization, TeamSnapshot } from './team-contract.js';
3
+ export type TeamChatProvider = {
4
+ config: ChatConfig;
5
+ key: string | null;
6
+ };
7
+ export type TeamChatProviderResolver = () => TeamChatProvider | null;
8
+ /** Subscription-only surfaces can resolve current terms from saved settings.
9
+ * Direct API callers must supply their live key; a stored grant is never proof of it. */
10
+ export declare function subscriptionTeamChatProvider(store: Store): TeamChatProvider | null;
11
+ export declare function teamChatAuthorization(store: Store, actor: TeamActor, conversation: Pick<NonNullable<TeamSnapshot['selected']>, 'id' | 'threadId' | 'projects'> | null, live: TeamChatProvider | null): TeamChatAuthorization;
@@ -0,0 +1,33 @@
1
+ /** The live grant shared by team execution, proposal cards and confirmation doors. */
2
+ import { createHash } from 'node:crypto';
3
+ import { ceilingDigestOf } from './principal.js';
4
+ import { credentialKeyOf, isDirectChatProvider, priceForConfig, subscriptionCredentialKey } from './converse.js';
5
+ const hash = (value) => createHash('sha256').update(JSON.stringify(value)).digest('hex');
6
+ /** Subscription-only surfaces can resolve current terms from saved settings.
7
+ * Direct API callers must supply their live key; a stored grant is never proof of it. */
8
+ export function subscriptionTeamChatProvider(store) {
9
+ const config = store.getChatConfig();
10
+ return config && !isDirectChatProvider(config.provider) ? { config, key: null } : null;
11
+ }
12
+ export function teamChatAuthorization(store, actor, conversation, live) {
13
+ const config = live?.config;
14
+ const empty = { enabled: false, provider: config?.provider ?? null, model: config?.model ?? null,
15
+ dailyTurns: config?.dailyTurns ?? 0, weeklyCeilingUsd: null, conversationCeilingUsd: null, termsDigest: '' };
16
+ if (!conversation)
17
+ return empty;
18
+ if (!live || !config)
19
+ return { ...empty, waitingReason: 'Choose a chat provider in Settings.' };
20
+ const direct = isDirectChatProvider(config.provider);
21
+ if (direct && (live.key === null || priceForConfig(config) === null))
22
+ return { ...empty, waitingReason: 'The configured chat connection is unavailable. Open Settings.' };
23
+ const credential = direct ? credentialKeyOf(config.provider, live.key) : subscriptionCredentialKey(config.provider);
24
+ const session = store.teamMateSession(actor.name, conversation.threadId);
25
+ const ceiling = direct ? (session ? session.ceilingMicrousd / 1_000_000 : Math.min(5, config.weeklyCeilingMicrousd / 1_000_000)) : null;
26
+ const termsDigest = hash({ version: 1, actor: actor.name, generation: actor.generation, conversation: conversation.id,
27
+ projects: conversation.projects, credential, provider: config.provider, model: config.model,
28
+ daily: config.dailyTurns, weekly: config.weeklyCeilingMicrousd, priceIn: config.priceInMicrousd, priceOut: config.priceOutMicrousd, ceiling });
29
+ const enabled = session !== null && session.approverGeneration === actor.generation && session.endedAt === null
30
+ && session.credentialKey === credential && session.ceilingDigest === ceilingDigestOf(conversation.projects) && session.termsDigest === termsDigest;
31
+ return { enabled, provider: config.provider, model: config.model, dailyTurns: config.dailyTurns,
32
+ weeklyCeilingUsd: direct ? config.weeklyCeilingMicrousd / 1_000_000 : null, conversationCeilingUsd: ceiling, termsDigest };
33
+ }
@@ -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];
@@ -1,16 +1,14 @@
1
- import type { Store, ChatConfig } from './store.js';
1
+ import type { Store } from './store.js';
2
2
  import type { WorkspaceRevision } from './workspace-revision.js';
3
3
  import { TeamLeads } from './team-leads.js';
4
4
  import type { TeamActor, TeamExecute } from './team-contract.js';
5
5
  import { type MateTurnInput } from './mate.js';
6
+ import { type TeamChatProviderResolver } from './team-chat-authorization.js';
6
7
  export type TeamRuntimeOptions = {
7
8
  store: Store;
8
9
  repos: () => readonly string[];
9
10
  evidenceRoot: string;
10
- provider: () => {
11
- config: ChatConfig;
12
- key: string | null;
13
- } | null;
11
+ provider: TeamChatProviderResolver;
14
12
  subscriptionRunner?: MateTurnInput['subscriptionRunner'];
15
13
  fetcher?: typeof fetch;
16
14
  clock?: () => Date;
@@ -4,9 +4,10 @@ import { createHash, randomUUID } from 'node:crypto';
4
4
  import { deliverTeamUpdates, startTeamUpdates } from './team-updates.js';
5
5
  import { workIndexPage } from './work-index.js';
6
6
  import { TeamLeads } from './team-leads.js';
7
- import { ceilingDigestOf, verifyApproverStanding } from './principal.js';
7
+ import { verifyApproverStanding } from './principal.js';
8
8
  import { credentialKeyOf, isDirectChatProvider, priceForConfig, subscriptionCredentialKey, mateWorstCaseForPrice } from './converse.js';
9
9
  import { runMateTurn } from './mate.js';
10
+ import { teamChatAuthorization } from './team-chat-authorization.js';
10
11
  import { updateAdmissionPaused } from './desktop-update-gate.js';
11
12
  const hash = (value) => createHash('sha256').update(JSON.stringify(value)).digest('hex');
12
13
  const response = (ok, code, message) => ({ version: 1, ok, code, message });
@@ -20,28 +21,7 @@ export function createTeamRuntime(options) {
20
21
  const waiting = new Map();
21
22
  const pages = new Map();
22
23
  let timer = null, closed = false, passing = false, lastDelivery = 0;
23
- function authorization(actor, snapshot) {
24
- const live = options.provider(), config = live?.config, conversation = snapshot.selected;
25
- const empty = { enabled: false, provider: config?.provider ?? null, model: config?.model ?? null,
26
- dailyTurns: config?.dailyTurns ?? 0, weeklyCeilingUsd: null, conversationCeilingUsd: null, termsDigest: '' };
27
- if (!conversation)
28
- return empty;
29
- if (!live || !config)
30
- return { ...empty, waitingReason: 'Choose a chat provider in Settings.' };
31
- const direct = isDirectChatProvider(config.provider);
32
- if (direct && (live.key === null || priceForConfig(config) === null))
33
- return { ...empty, waitingReason: 'The configured chat connection is unavailable. Open Settings.' };
34
- const credential = direct ? credentialKeyOf(config.provider, live.key) : subscriptionCredentialKey(config.provider);
35
- const session = store.teamMateSession(actor.name, conversation.threadId);
36
- const ceiling = direct ? (session ? session.ceilingMicrousd / 1_000_000 : Math.min(5, config.weeklyCeilingMicrousd / 1_000_000)) : null;
37
- const termsDigest = hash({ version: 1, actor: actor.name, generation: actor.generation, conversation: conversation.id,
38
- projects: conversation.projects, credential, provider: config.provider, model: config.model,
39
- daily: config.dailyTurns, weekly: config.weeklyCeilingMicrousd, priceIn: config.priceInMicrousd, priceOut: config.priceOutMicrousd, ceiling });
40
- const enabled = session !== null && session.approverGeneration === actor.generation && session.endedAt === null
41
- && session.credentialKey === credential && session.ceilingDigest === ceilingDigestOf(conversation.projects) && session.termsDigest === termsDigest;
42
- return { enabled, provider: config.provider, model: config.model, dailyTurns: config.dailyTurns,
43
- weeklyCeilingUsd: direct ? config.weeklyCeilingMicrousd / 1_000_000 : null, conversationCeilingUsd: ceiling, termsDigest };
44
- }
24
+ const authorization = (actor, snapshot) => teamChatAuthorization(store, actor, snapshot.selected, options.provider());
45
25
  function admission(actor, id) {
46
26
  const conversation = domain.access(actor, id, 'contributor').conversation;
47
27
  const grant = authorization(actor, { selected: conversation }), live = options.provider();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolroll",
3
- "version": "0.9.38",
3
+ "version": "0.9.40",
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"