toolroll 0.9.39 → 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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.40 — 2026-10-06
4
+
5
+ - **Confirming a proposal checks the same things the card does, everywhere.**
6
+ The console, a direct request, Telegram and the CLI all ask one check: you
7
+ can act on this chat (with the current provider, model and price), and no
8
+ reply is still running. If the terms changed, the card says to turn chat on
9
+ again and every other way of confirming refuses too, with the same reason.
10
+ A teammate reply that stalls before it starts stops blocking confirms after
11
+ a short limit instead of until a restart.
12
+
3
13
  ## 0.9.39 — 2026-10-06
4
14
 
5
15
  - **Chat is the conversation.** The Chat tab is your conversation with the
@@ -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,13 +739,14 @@ 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
  });
747
748
  // Only browser sessions receive HTML cards. The CLI keeps its summary contract.
748
- const teamBrowserReply = (reply, csrf) => {
749
+ const teamBrowserReply = (reply, actor, csrf) => {
749
750
  const snapshot = reply.snapshot;
750
751
  if (!snapshot?.selected || !snapshot.proposals)
751
752
  return reply;
@@ -754,7 +755,7 @@ export function createDecisionServer(options) {
754
755
  if (!proposal || proposal.thread !== snapshot.selected.threadId)
755
756
  return summary;
756
757
  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
+ return { ...summary, card: teamProposalCardParts(store, actor, proposal, snapshot, csrf, decision, clock(), teamChatProvider).card };
758
759
  }) } };
759
760
  };
760
761
  const teamEndpoint = (request, response) => handleTeamHttp(request, response, {
@@ -789,7 +790,7 @@ export function createDecisionServer(options) {
789
790
  execute: async (actor, input) => {
790
791
  const reply = await team.execute(actor, input);
791
792
  const who = identify(request, false);
792
- return who?.via === 'cookie' ? teamBrowserReply(reply, who.session.csrf) : reply;
793
+ return who?.via === 'cookie' ? teamBrowserReply(reply, actor, who.session.csrf) : reply;
793
794
  }, cursor: team.cursor, streams: teamStreams,
794
795
  });
795
796
  const sessionEndpoint = createSessionEndpoint({ store, workspace: coding, projects: codingProjects, projectAllowed: repo => rowVisible(liveCeiling(), repo) });
@@ -3606,7 +3607,7 @@ export function createDecisionServer(options) {
3606
3607
  operation: url.searchParams.has('conversation') ? 'show' : 'list',
3607
3608
  args: { ...(url.searchParams.has('conversation') ? { conversationId: url.searchParams.get('conversation') } : {}),
3608
3609
  ...(url.searchParams.has('lead') ? { leadId: url.searchParams.get('lead') } : {}) },
3609
- }), who.session.csrf);
3610
+ }), { name: who.name, generation: who.session.generation }, who.session.csrf);
3610
3611
  if (!reply.ok || !reply.snapshot)
3611
3612
  return refuse(response, who, 403, reply.message, '/chat?private=1');
3612
3613
  if (url.searchParams.has('proposal')) {
@@ -3616,7 +3617,7 @@ export function createDecisionServer(options) {
3616
3617
  return refuse(response, who, 404, 'This proposal is unavailable.', '/chat');
3617
3618
  const back = '/chat?conversation=' + encodeURIComponent(selected.id);
3618
3619
  const decision = proposal.kind === 'answer' && typeof proposal.payload['decision'] === 'number' ? store.getDecision(proposal.payload['decision']) : null;
3619
- const card = teamProposalCardParts(proposal, reply.snapshot, who.session.csrf, decision).html;
3620
+ const card = teamProposalCardParts(store, { name: who.name, generation: who.session.generation }, proposal, reply.snapshot, who.session.csrf, decision, clock(), teamChatProvider).html;
3620
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') }));
3621
3622
  }
3622
3623
  if (reply.snapshot.leads.length > 0 || url.searchParams.has('conversation') || url.searchParams.get('team') === '1') {
@@ -9645,7 +9646,7 @@ export function createDecisionServer(options) {
9645
9646
  noteMate(who.session.csrf, null, "that proposal was already acted on");
9646
9647
  return redirect(response, chatReturnWithLatest(back));
9647
9648
  }
9648
- 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") ?? "" } } : {}) });
9649
9650
  if (cardJson) {
9650
9651
  if (!outcome.ok)
9651
9652
  return cardAnswer(outcome.reason === "standing" ? 403 : outcome.reason === "not-yours" ? 404 : 409, false, outcome.said);
@@ -18276,7 +18277,7 @@ function proposalCardParts(view, csrf, inert, decision, returnTo = null) {
18276
18277
  `</div>`;
18277
18278
  }
18278
18279
  else if (view.state === "pending") {
18279
- 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>`;
18280
18281
  }
18281
18282
  else if (view.state === "confirmed") {
18282
18283
  const filed = outcome !== null && typeof outcome.taskId === "string" ? outcome.taskId : null;
@@ -18325,7 +18326,7 @@ function proposalCardParts(view, csrf, inert, decision, returnTo = null) {
18325
18326
  ],
18326
18327
  primary,
18327
18328
  dismissable: pending && view.kind !== "control",
18328
- note: view.state === "pending" && inert ? "Available when the current reply finishes."
18329
+ note: view.state === "pending" && inert ? PROPOSAL_WAIT_REASON
18329
18330
  : pending && view.kind === "cancel" ? "Cancelling is confirmed on the task itself."
18330
18331
  : view.state === "dismissed" ? "Dismissed." : view.state === "expired" ? "Expired — this conversation moved on." : null,
18331
18332
  };
@@ -18338,14 +18339,13 @@ function mateProposalCardParts(proposal, csrf, inert, decision, returnTo = null)
18338
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);
18339
18340
  }
18340
18341
  /** 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');
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;
18344
18345
  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);
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
18349
  parts.card = { ...parts.card, note: reason };
18350
18350
  }
18351
18351
  return parts;
@@ -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,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.39",
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"