toolroll 0.9.39 → 0.9.41
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 +20 -0
- package/dist/desktop-update.d.ts +1 -1
- package/dist/mate-cli.js +4 -1
- package/dist/mate-doors.d.ts +30 -1
- package/dist/mate-doors.js +40 -4
- package/dist/mcp-connect.d.ts +0 -1
- package/dist/mcp-connect.js +4 -1
- package/dist/serve.js +17 -17
- package/dist/team-chat-authorization.d.ts +11 -0
- package/dist/team-chat-authorization.js +33 -0
- package/dist/team-runtime.d.ts +3 -5
- package/dist/team-runtime.js +3 -23
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.9.41 — 2026-10-06
|
|
4
|
+
|
|
5
|
+
- **A read-only PostHog connection works for research.** PostHog adds
|
|
6
|
+
`introspection` to every connection it grants, asked for or not; it only
|
|
7
|
+
lets a connection be checked and reaches no data. Toolroll counted it as
|
|
8
|
+
write access, so research skipped PostHog even after **Reconnect read-only**.
|
|
9
|
+
It now counts as read-only alongside the reads it came with. Nothing is
|
|
10
|
+
requested differently, and a connection that already reconnected read-only
|
|
11
|
+
works without signing in again.
|
|
12
|
+
|
|
13
|
+
## 0.9.40 — 2026-10-06
|
|
14
|
+
|
|
15
|
+
- **Confirming a proposal checks the same things the card does, everywhere.**
|
|
16
|
+
The console, a direct request, Telegram and the CLI all ask one check: you
|
|
17
|
+
can act on this chat (with the current provider, model and price), and no
|
|
18
|
+
reply is still running. If the terms changed, the card says to turn chat on
|
|
19
|
+
again and every other way of confirming refuses too, with the same reason.
|
|
20
|
+
A teammate reply that stalls before it starts stops blocking confirms after
|
|
21
|
+
a short limit instead of until a restart.
|
|
22
|
+
|
|
3
23
|
## 0.9.39 — 2026-10-06
|
|
4
24
|
|
|
5
25
|
- **Chat is the conversation.** The Chat tab is your conversation with the
|
package/dist/desktop-update.d.ts
CHANGED
|
@@ -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",
|
|
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)
|
package/dist/mate-doors.d.ts
CHANGED
|
@@ -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;
|
package/dist/mate-doors.js
CHANGED
|
@@ -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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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/mcp-connect.d.ts
CHANGED
|
@@ -64,7 +64,6 @@ export declare function connectionsOf(store: Store, repo: string, home?: string)
|
|
|
64
64
|
*/
|
|
65
65
|
export declare function readsOnly(action: string, subjects: readonly string[]): boolean;
|
|
66
66
|
export declare const readScope: (scope: string) => boolean;
|
|
67
|
-
/** Whether a saved grant only reads: it names at least one scope and every one is a read or identity scope. Unknown (null) never is. */
|
|
68
67
|
export declare const readOnlyGrant: (grant: readonly string[] | null) => boolean;
|
|
69
68
|
/** An OAuth `scope` value as scope tokens (RFC 6749), sorted and deduplicated; null when absent, empty or malformed. */
|
|
70
69
|
export declare function scopeList(value: unknown): string[] | null;
|
package/dist/mcp-connect.js
CHANGED
|
@@ -133,7 +133,10 @@ const IDENTITY_SCOPES = new Set(["openid", "profile", "email"]);
|
|
|
133
133
|
const READ_SCOPE = /^[a-z][a-z0-9_]{0,63}:read$/;
|
|
134
134
|
export const readScope = (scope) => IDENTITY_SCOPES.has(scope) || READ_SCOPE.test(scope);
|
|
135
135
|
/** Whether a saved grant only reads: it names at least one scope and every one is a read or identity scope. Unknown (null) never is. */
|
|
136
|
-
|
|
136
|
+
// A scope a server adds to every token without being asked, that grants no access to data. PostHog adds
|
|
137
|
+
// `introspection` (its ALWAYS_ALLOWED_SCOPES): it lets the token be checked, and has no write form. Never requested.
|
|
138
|
+
const GRANTED_ANYWAY = new Set(["introspection"]);
|
|
139
|
+
export const readOnlyGrant = (grant) => grant !== null && grant.some(readScope) && grant.every(scope => readScope(scope) || GRANTED_ANYWAY.has(scope));
|
|
137
140
|
/** An OAuth `scope` value as scope tokens (RFC 6749), sorted and deduplicated; null when absent, empty or malformed. */
|
|
138
141
|
export function scopeList(value) {
|
|
139
142
|
if (typeof value !== "string")
|
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:
|
|
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"
|
|
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 ?
|
|
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
|
|
18343
|
-
const
|
|
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,
|
|
18346
|
-
if (
|
|
18347
|
-
|
|
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
|
+
}
|
package/dist/team-runtime.d.ts
CHANGED
|
@@ -1,16 +1,14 @@
|
|
|
1
|
-
import type { Store
|
|
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;
|
package/dist/team-runtime.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "0.9.41",
|
|
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"
|