toolroll 0.9.38 → 0.9.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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';
package/dist/serve.js CHANGED
@@ -744,6 +744,19 @@ export function createDecisionServer(options) {
744
744
  provider: () => { const enabled = chatEnablement(); return enabled.ok ? { config: enabled.config, key: enabled.key } : null; },
745
745
  ...(options.subscriptionChatRunner ? { subscriptionRunner: options.subscriptionChatRunner } : {}),
746
746
  });
747
+ // Only browser sessions receive HTML cards. The CLI keeps its summary contract.
748
+ const teamBrowserReply = (reply, csrf) => {
749
+ const snapshot = reply.snapshot;
750
+ if (!snapshot?.selected || !snapshot.proposals)
751
+ return reply;
752
+ return { ...reply, snapshot: { ...snapshot, proposals: snapshot.proposals.map(summary => {
753
+ const proposal = store.getMateProposal(summary.id);
754
+ if (!proposal || proposal.thread !== snapshot.selected.threadId)
755
+ return summary;
756
+ const decision = proposal.kind === 'answer' && typeof proposal.payload['decision'] === 'number' ? store.getDecision(proposal.payload['decision']) : null;
757
+ return { ...summary, card: teamProposalCardParts(proposal, snapshot, csrf, decision).card };
758
+ }) } };
759
+ };
747
760
  const teamEndpoint = (request, response) => handleTeamHttp(request, response, {
748
761
  authenticate: request => {
749
762
  const who = identify(request, request.method === 'POST');
@@ -773,7 +786,11 @@ export function createDecisionServer(options) {
773
786
  return false;
774
787
  return request.headers['x-csrf-token'] === who.session.csrf;
775
788
  },
776
- execute: team.execute, cursor: team.cursor, streams: teamStreams,
789
+ execute: async (actor, input) => {
790
+ const reply = await team.execute(actor, input);
791
+ const who = identify(request, false);
792
+ return who?.via === 'cookie' ? teamBrowserReply(reply, who.session.csrf) : reply;
793
+ }, cursor: team.cursor, streams: teamStreams,
777
794
  });
778
795
  const sessionEndpoint = createSessionEndpoint({ store, workspace: coding, projects: codingProjects, projectAllowed: repo => rowVisible(liveCeiling(), repo) });
779
796
  /** The enumerable admission list for roll-up SQL: repos-only ceilings
@@ -3585,11 +3602,11 @@ export function createDecisionServer(options) {
3585
3602
  if (who.via !== "cookie")
3586
3603
  return refuse(response, who, 403, "chat is a browser surface — it keeps your drafts in the session");
3587
3604
  if (url.searchParams.get('private') !== '1' && !url.searchParams.has('task') && !url.searchParams.has('result') && !url.searchParams.has('project')) {
3588
- const reply = await team.execute({ name: who.name, generation: who.session.generation }, {
3605
+ const reply = teamBrowserReply(await team.execute({ name: who.name, generation: who.session.generation }, {
3589
3606
  operation: url.searchParams.has('conversation') ? 'show' : 'list',
3590
3607
  args: { ...(url.searchParams.has('conversation') ? { conversationId: url.searchParams.get('conversation') } : {}),
3591
3608
  ...(url.searchParams.has('lead') ? { leadId: url.searchParams.get('lead') } : {}) },
3592
- });
3609
+ }), who.session.csrf);
3593
3610
  if (!reply.ok || !reply.snapshot)
3594
3611
  return refuse(response, who, 403, reply.message, '/chat?private=1');
3595
3612
  if (url.searchParams.has('proposal')) {
@@ -3599,11 +3616,7 @@ export function createDecisionServer(options) {
3599
3616
  return refuse(response, who, 404, 'This proposal is unavailable.', '/chat');
3600
3617
  const back = '/chat?conversation=' + encodeURIComponent(selected.id);
3601
3618
  const decision = proposal.kind === 'answer' && typeof proposal.payload['decision'] === 'number' ? store.getDecision(proposal.payload['decision']) : null;
3602
- const active = reply.snapshot.messages.some(message => message.status === 'running');
3603
- const canAct = reply.snapshot.canSend && reply.snapshot.chatAuthorization?.enabled === true;
3604
- let card = mateProposalCard(proposal, who.session.csrf, !canAct || active, decision, back);
3605
- if (!canAct)
3606
- card = card.replace('Available when the current turn finishes.', reply.snapshot.canSend ? 'Enable chat in this conversation before acting on a proposal.' : 'An authorized contributor can act on this proposal.');
3619
+ const card = teamProposalCardParts(proposal, reply.snapshot, who.session.csrf, decision).html;
3607
3620
  return sendScreen(response, 200, screen('Review action', `<p><a href="${escape(back)}">Back to conversation</a></p>` + card, { chrome: chromeFor(null, 'chat', undefined, 'all') }));
3608
3621
  }
3609
3622
  if (reply.snapshot.leads.length > 0 || url.searchParams.has('conversation') || url.searchParams.get('team') === '1') {
@@ -4290,7 +4303,7 @@ export function createDecisionServer(options) {
4290
4303
  })(), (() => {
4291
4304
  const chosen = store.notificationPreference(who.name);
4292
4305
  return { mode: chosen.mode, digestAt: chosen.digestAt, screenshots: chosen.screenshots, projects: notificationProjects(store, who.name) };
4293
- })(), telegramTrouble(store, loadBotToken(process.env, options.telegramTokenFile))));
4306
+ })(), telegramTrouble(store, loadBotToken(process.env, options.telegramTokenFile)), who.via === "cookie" ? phoneSetup(who) ?? null : null));
4294
4307
  }
4295
4308
  if (url.pathname === "/push/key") {
4296
4309
  // Session-gated: the key is not secret, but strangers get nothing.
@@ -4464,12 +4477,19 @@ export function createDecisionServer(options) {
4464
4477
  return first !== undefined && String(first["id"]) === rootId;
4465
4478
  }
4466
4479
  /** After the first Ready result, once (onboarding): the phone, by a chat app or the console over Tailscale. */
4480
+ /** Chat's one-line pointer to the phone setup: after the first Ready result, until put away or Telegram is paired. */
4467
4481
  function phoneCard(who, now) {
4468
4482
  if (who.role !== "approver" || store.isDemo() || store.firstSuccessAt(now) === null || store.installationFact(PHONE_CARD_FACT) !== null)
4469
4483
  return undefined;
4470
4484
  const botId = options.telegramTokenFile === undefined ? null : loadBotToken(process.env, options.telegramTokenFile)?.botId ?? null;
4471
4485
  if (botId !== null && store.liveTelegramBindings(botId).length > 0)
4472
4486
  return undefined;
4487
+ return phoneSetup(who);
4488
+ }
4489
+ /** The phone setup itself (Settings → Chat apps): a chat app, or this console over Tailscale. */
4490
+ function phoneSetup(who) {
4491
+ if (who.role !== "approver" || store.isDemo())
4492
+ return undefined;
4473
4493
  const port = servedPort() ?? 4180;
4474
4494
  const address = server.address();
4475
4495
  const bound = typeof address === "object" && address !== null ? address.address : "127.0.0.1";
@@ -4509,7 +4529,11 @@ export function createDecisionServer(options) {
4509
4529
  const ask = last?.role === "assistant" && last.turn !== null && store.getMateTurn(last.turn)?.approver === who.name ? store.mateAskOpen(last.turn, now) : null;
4510
4530
  // Answered questions stay readable above the answer; only their buttons go.
4511
4531
  const asks = new Map(messages.flatMap(one => { const asked = one.role === "assistant" && one.turn !== null ? store.mateAsk(one.turn) : null; return asked === null ? [] : [[asked.turn, asked]]; }));
4532
+ // The thread a ceiling change replaced (ruling 9): read after the open, display only.
4533
+ const replaced = store.replacedLeadThread(opened.thread);
4534
+ const previousProposals = replaced === null ? [] : store.listMateProposals(replaced.id);
4512
4535
  return {
4536
+ previous: replaced === null ? null : { messages: store.listMateMessages(replaced.id, 40), proposals: previousProposals, decisions: decisionsFor(store, previousProposals) },
4513
4537
  messages,
4514
4538
  ask,
4515
4539
  asks,
@@ -15406,6 +15430,8 @@ ${THEME_DARK}
15406
15430
  min-height: min(32rem, 48vh); padding: .25rem;
15407
15431
  }
15408
15432
  .thread .msg { max-width: 48rem; line-height: 1.65; overflow-wrap: anywhere; }
15433
+ .chat-previous-divider { display: flex; align-items: center; gap: .6rem; margin: 1rem 0; color: var(--so-muted); font-size: .8rem; }
15434
+ .chat-previous-divider::before, .chat-previous-divider::after { content: ""; flex: 1; border-top: 1px solid var(--so-line); }
15409
15435
  .thread .msg p { margin: .3rem 0; }
15410
15436
  .thread .msg.op {
15411
15437
  align-self: flex-end; max-width: min(82%, 40rem); padding: .75rem 1rem;
@@ -18311,6 +18337,19 @@ function mateProposalCard(proposal, csrf, inert, decision, returnTo = null) {
18311
18337
  function mateProposalCardParts(proposal, csrf, inert, decision, returnTo = null) {
18312
18338
  return proposalCardParts({ id: proposal.id, kind: proposal.kind, payload: proposal.payload, state: proposal.state, outcome: proposal.outcome, by: { mate: true }, actionBase: "/chat/proposal" }, csrf, inert, decision, returnTo);
18313
18339
  }
18340
+ /** Review and inline team cards share the same server-decided controls and reasons. */
18341
+ function teamProposalCardParts(proposal, snapshot, csrf, decision) {
18342
+ const canAct = snapshot.canSend && snapshot.chatAuthorization?.enabled === true;
18343
+ const active = snapshot.messages.some(message => message.status === 'running');
18344
+ const back = '/chat?conversation=' + encodeURIComponent(snapshot.selected.id);
18345
+ const parts = mateProposalCardParts(proposal, csrf, !canAct || active, decision, back);
18346
+ if (!canAct && proposal.state === 'pending') {
18347
+ const reason = snapshot.canSend ? 'Enable chat in this conversation before acting on a proposal.' : 'An authorized contributor can act on this proposal.';
18348
+ parts.html = parts.html.replace('Available when the current turn finishes.', reason);
18349
+ parts.card = { ...parts.card, note: reason };
18350
+ }
18351
+ return parts;
18352
+ }
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);
@@ -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];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolroll",
3
- "version": "0.9.38",
3
+ "version": "0.9.39",
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"