toolroll 0.9.96 → 0.9.98

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.
Files changed (131) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/approval-authority.d.ts +10 -1
  3. package/dist/approval-authority.js +18 -2
  4. package/dist/browser/workspace.css +2 -2
  5. package/dist/browser/workspace.js +32 -32
  6. package/dist/browser-workspace.d.ts +6 -3
  7. package/dist/chat-actions.d.ts +40 -5
  8. package/dist/chat-actions.js +124 -13
  9. package/dist/chat-card.js +4 -1
  10. package/dist/chat-channel.d.ts +3 -0
  11. package/dist/chat-channel.js +24 -7
  12. package/dist/chat-decide.js +4 -2
  13. package/dist/chat-delivery.d.ts +5 -1
  14. package/dist/chat-delivery.js +74 -25
  15. package/dist/chat-follow.d.ts +6 -3
  16. package/dist/chat-follow.js +23 -7
  17. package/dist/chat-live-schema.d.ts +17 -0
  18. package/dist/chat-live-schema.js +32 -0
  19. package/dist/chat-live.d.ts +161 -0
  20. package/dist/chat-live.js +491 -0
  21. package/dist/chat-task-actions.js +1 -1
  22. package/dist/chat-terms.d.ts +8 -0
  23. package/dist/chat-terms.js +14 -0
  24. package/dist/chat-voice.js +1 -1
  25. package/dist/contracts/chat-actions.d.ts +6 -0
  26. package/dist/contracts/chat-actions.js +2 -0
  27. package/dist/contracts/cli.d.ts +6 -0
  28. package/dist/contracts/cli.js +3 -0
  29. package/dist/contracts/console-api.d.ts +1 -0
  30. package/dist/contracts/console-api.js +1 -0
  31. package/dist/contracts/lead-tools.d.ts +17 -0
  32. package/dist/contracts/lead-tools.js +4 -1
  33. package/dist/contracts/store-json.d.ts +1 -0
  34. package/dist/discord-api.d.ts +3 -1
  35. package/dist/discord-api.js +9 -3
  36. package/dist/discord-chat.d.ts +13 -3
  37. package/dist/discord-chat.js +77 -21
  38. package/dist/discord.js +8 -1
  39. package/dist/fast-start.d.ts +83 -6
  40. package/dist/fast-start.js +112 -15
  41. package/dist/home-groups.d.ts +39 -0
  42. package/dist/home-groups.js +33 -0
  43. package/dist/lead-answer-eval.d.ts +93 -0
  44. package/dist/lead-answer-eval.js +259 -0
  45. package/dist/lead-capabilities.js +1 -1
  46. package/dist/lead-cli-live.d.ts +56 -0
  47. package/dist/lead-cli-live.js +200 -0
  48. package/dist/lead-cli.d.ts +3 -0
  49. package/dist/lead-cli.js +34 -4
  50. package/dist/lead-context.js +2 -2
  51. package/dist/lead-contract.d.ts +1 -1
  52. package/dist/lead-contract.js +10 -7
  53. package/dist/lead-doors.d.ts +32 -0
  54. package/dist/lead-doors.js +80 -14
  55. package/dist/lead-latency-schema.d.ts +24 -0
  56. package/dist/lead-latency-schema.js +36 -0
  57. package/dist/lead-progress.d.ts +10 -1
  58. package/dist/lead-progress.js +1 -1
  59. package/dist/lead-queue-runtime.d.ts +1 -1
  60. package/dist/lead-queue-runtime.js +1 -1
  61. package/dist/lead-route.d.ts +56 -0
  62. package/dist/lead-route.js +104 -0
  63. package/dist/lead-status.d.ts +3 -0
  64. package/dist/lead-status.js +8 -3
  65. package/dist/lead-tools.d.ts +20 -0
  66. package/dist/lead-tools.js +66 -3
  67. package/dist/lead-voice.js +1 -1
  68. package/dist/lead.d.ts +4 -2
  69. package/dist/lead.js +97 -28
  70. package/dist/live-reply.d.ts +83 -0
  71. package/dist/live-reply.js +302 -0
  72. package/dist/local-owner.js +2 -0
  73. package/dist/one-step-chat-schema.d.ts +22 -0
  74. package/dist/one-step-chat-schema.js +37 -0
  75. package/dist/operate.d.ts +27 -1
  76. package/dist/operate.js +245 -33
  77. package/dist/project-delete.js +9 -0
  78. package/dist/project-knowledge.d.ts +10 -10
  79. package/dist/publish.d.ts +2 -0
  80. package/dist/publish.js +1 -1
  81. package/dist/pull-request-flow.d.ts +4 -0
  82. package/dist/pull-request-flow.js +16 -1
  83. package/dist/reply-shape.d.ts +9 -0
  84. package/dist/reply-shape.js +13 -0
  85. package/dist/reply-voice.d.ts +23 -0
  86. package/dist/reply-voice.js +37 -0
  87. package/dist/scope.d.ts +6 -1
  88. package/dist/scope.js +3 -2
  89. package/dist/serve.js +11 -4
  90. package/dist/server/chat.d.ts +2 -1
  91. package/dist/server/chat.js +22 -10
  92. package/dist/server/chrome.js +2 -2
  93. package/dist/server/home.d.ts +4 -3
  94. package/dist/server/home.js +33 -28
  95. package/dist/server/live.js +18 -21
  96. package/dist/server/render-chat.d.ts +5 -0
  97. package/dist/server/render-chat.js +15 -8
  98. package/dist/server/render-pages.js +2 -2
  99. package/dist/server/render-settings.d.ts +8 -0
  100. package/dist/server/render-settings.js +13 -2
  101. package/dist/server/route-table.js +2 -0
  102. package/dist/server/settings.js +14 -1
  103. package/dist/setup-guide.d.ts +2 -2
  104. package/dist/slack-api.js +11 -0
  105. package/dist/slack-chat.d.ts +11 -1
  106. package/dist/slack-chat.js +50 -5
  107. package/dist/slack-settings.js +8 -1
  108. package/dist/slack.js +10 -3
  109. package/dist/store.d.ts +33 -2
  110. package/dist/store.js +92 -16
  111. package/dist/subscription-chat.d.ts +2 -0
  112. package/dist/subscription-chat.js +3 -1
  113. package/dist/teams-chat.d.ts +9 -2
  114. package/dist/teams-chat.js +50 -2
  115. package/dist/teams.js +5 -2
  116. package/dist/telegram-lead.d.ts +3 -0
  117. package/dist/telegram-lead.js +132 -14
  118. package/dist/telegram-live.d.ts +116 -0
  119. package/dist/telegram-live.js +224 -0
  120. package/dist/telegram-status.js +9 -4
  121. package/dist/telegram.d.ts +12 -0
  122. package/dist/telegram.js +142 -10
  123. package/dist/toolroll-update.js +6 -1
  124. package/dist/tui-contract.d.ts +2 -0
  125. package/dist/tui-source.js +3 -0
  126. package/dist/tui.js +41 -41
  127. package/dist/typed-start.d.ts +51 -0
  128. package/dist/typed-start.js +98 -0
  129. package/dist/wake-hub.d.ts +72 -0
  130. package/dist/wake-hub.js +151 -0
  131. package/package.json +1 -1
@@ -199,6 +199,14 @@ function shapeWith(text, options, state) {
199
199
  export function shapeReply(text, options = {}) {
200
200
  return shapeWith(text, options, stateFor(text, options));
201
201
  }
202
+ /**
203
+ * A reply still being written, shaped with the final reply's presentation policy (docs/plans/responsive-chat.md §2.2),
204
+ * except that ids are dropped unless the owner asked for them: whether a reply names several of a kind isn't known
205
+ * until it ends, so a prefix never keeps them on that ground. Presentation, not redaction.
206
+ */
207
+ export function shapeReplyPrefix(text, options = {}) {
208
+ return shapeWith(text, options, { boldLeft: BOLD_ANCHORS, keepIds: askedForIds(options.asked) ? "all" : new Set() });
209
+ }
202
210
  /** Where a long reply may be cut: never inside code, a link, a URL or a bold anchor; best at a paragraph, then a line,
203
211
  * then a space. Always past `from`, and never between the two halves of an emoji. */
204
212
  function cutAt(text, from, size) {
@@ -231,6 +239,11 @@ function cutAt(text, from, size) {
231
239
  end = end - 1 > from ? end - 1 : end + 1;
232
240
  return end;
233
241
  }
242
+ /** The first window of a shaped text of at most `size` characters, cut where a part would be (never inside code, a link,
243
+ * a bold anchor or an emoji's two halves). A single link longer than the window stays whole, so the caller measures. */
244
+ export function shapedWindow(shaped, size) {
245
+ return shaped.slice(0, cutAt(shaped, 0, size));
246
+ }
234
247
  /**
235
248
  * A long reply split into parts of at most `size` characters, then each part shaped: the cut is made on the reply as
236
249
  * written, at a paragraph or line where possible and never inside code, a link or a bold anchor, so no part carries
@@ -0,0 +1,23 @@
1
+ export type ReplyVoice = {
2
+ /** Characters of the shaped reply. */
3
+ characters: number;
4
+ sentences: number;
5
+ /** The `PLAIN_WORDS` rows said outside code, plus "owner" when the reply calls the person "the owner". */
6
+ internalWords: string[];
7
+ /** Opens with its own steps or a filler word ("I checked", "Let me", "Sure,") instead of the answer. */
8
+ narratedOpening: boolean;
9
+ };
10
+ /** A reply longer than this is counted as long in the eval's report. */
11
+ export declare const LONG_REPLY_CHARACTERS = 320;
12
+ export declare function replyVoice(reply: string): ReplyVoice;
13
+ export type VoiceSummary = {
14
+ answers: number;
15
+ medianCharacters: number;
16
+ /** Share of answers over `LONG_REPLY_CHARACTERS`, from 0 to 1. */
17
+ longShare: number;
18
+ /** Internal words said, summed over the answers. */
19
+ internalWords: number;
20
+ narratedOpenings: number;
21
+ };
22
+ /** One model's answers, summed up for the report. */
23
+ export declare function summarizeVoice(voices: readonly ReplyVoice[]): VoiceSummary;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * How a lead reply reads (docs/plans/lead-voice.md, V3): its length, its sentences, the internal words it says and
3
+ * whether it opens by describing its own steps. Measured on the shaped text a person sees, never the raw reply.
4
+ * A measure for the lead-answer eval's report; nothing gates on it and no reply is rewritten from it.
5
+ */
6
+ import { PLAIN_WORDS } from "./plain-words.js";
7
+ import { shapeReply } from "./reply-shape.js";
8
+ /** A reply longer than this is counted as long in the eval's report. */
9
+ export const LONG_REPLY_CHARACTERS = 320;
10
+ const CODE = /```[\s\S]*?(?:```|$)|`[^`\n]+`/g;
11
+ const OWNER_AS_ADDRESS = /(?<![\w-])the\s+owner(?![\w-])/i;
12
+ const NARRATED_OPENING = /^(?:i\s+checked|i\s+looked\s+at|let\s+me|certainly|sure,)/i;
13
+ export function replyVoice(reply) {
14
+ const shaped = shapeReply(reply);
15
+ const prose = shaped.replace(CODE, " ");
16
+ const internalWords = PLAIN_WORDS.filter(one => one.pattern.test(prose)).map(one => one.never);
17
+ if (OWNER_AS_ADDRESS.test(prose))
18
+ internalWords.push("owner");
19
+ return {
20
+ characters: shaped.length,
21
+ sentences: prose.split(/(?<=[.!?])\s+|\n+/).filter(part => /[\p{L}\p{N}]/u.test(part)).length,
22
+ internalWords,
23
+ narratedOpening: NARRATED_OPENING.test(shaped.trimStart()),
24
+ };
25
+ }
26
+ /** One model's answers, summed up for the report. */
27
+ export function summarizeVoice(voices) {
28
+ const sorted = voices.map(one => one.characters).sort((a, b) => a - b);
29
+ const mid = Math.floor(sorted.length / 2);
30
+ return {
31
+ answers: voices.length,
32
+ medianCharacters: sorted.length === 0 ? 0 : sorted.length % 2 === 1 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2,
33
+ longShare: voices.length === 0 ? 0 : voices.filter(one => one.characters > LONG_REPLY_CHARACTERS).length / voices.length,
34
+ internalWords: voices.reduce((sum, one) => sum + one.internalWords.length, 0),
35
+ narratedOpenings: voices.filter(one => one.narratedOpening).length,
36
+ };
37
+ }
package/dist/scope.d.ts CHANGED
@@ -796,10 +796,15 @@ export declare function fileAndSealUnderMode(store: Store, input: ScopeInput & {
796
796
  detail?: string;
797
797
  };
798
798
  export declare function approve(store: Store, taskId: string, by: string, now: Date, sawDigest: string, token: string, mutation?: Mutation,
799
- /** A fast start (fast-start.ts): Build is this same yes, with its marker in the proof. Only that road passes it. */
799
+ /** A fast start (fast-start.ts): Build is this same yes, with its marker in the proof. Only that road passes it.
800
+ * `typed`: a typed yes to a Start card (one-step-chat.md C5), the message that was the yes, recorded beside the marker. */
800
801
  fastStart?: {
801
802
  rule: string;
802
803
  at: string;
804
+ typed?: {
805
+ provider: string;
806
+ event: string;
807
+ };
803
808
  },
804
809
  /** A person's own yes in chat (owner's decision, docs/plans/toolroll-cards.md): their tap or signed-in click stands in
805
810
  * for a password, so `token` is unused. Every rule below still applies. The caller has proved the person is the one
package/dist/scope.js CHANGED
@@ -1075,7 +1075,8 @@ export function fileAndSealUnderMode(store, input) {
1075
1075
  });
1076
1076
  }
1077
1077
  export function approve(store, taskId, by, now, sawDigest, token, mutation = {},
1078
- /** A fast start (fast-start.ts): Build is this same yes, with its marker in the proof. Only that road passes it. */
1078
+ /** A fast start (fast-start.ts): Build is this same yes, with its marker in the proof. Only that road passes it.
1079
+ * `typed`: a typed yes to a Start card (one-step-chat.md C5), the message that was the yes, recorded beside the marker. */
1079
1080
  fastStart,
1080
1081
  /** A person's own yes in chat (owner's decision, docs/plans/toolroll-cards.md): their tap or signed-in click stands in
1081
1082
  * for a password, so `token` is unused. Every rule below still applies. The caller has proved the person is the one
@@ -1216,7 +1217,7 @@ export const APPROVAL_PROOF = "approval proved";
1216
1217
  export const approvalProofDetail = (at, generation, digest, local) => `${new Date(at).toISOString()} · generation ${generation}${local === undefined ? "" : ` · local ${local.generation} ${local.transport}`} · digest ${digest}`;
1217
1218
  /** A fast start's proof: the v1 record proofDetailOf reads, with the marker. `local` keeps the legacy words
1218
1219
  * localSealStandsSql reads, so a local owner's seal stands on the same rule. */
1219
- const fastProofDetail = (fastStart) => (at, generation, digest, local) => JSON.stringify({ v: 1, at: new Date(at).toISOString(), generation, digest, fastStart, ...(local === undefined ? {} : { local: `proof · local ${local.generation} ${local.transport} · seal` }) });
1220
+ const fastProofDetail = ({ typed, ...fastStart }) => (at, generation, digest, local) => JSON.stringify({ v: 1, at: new Date(at).toISOString(), generation, digest, fastStart, ...(typed === undefined ? {} : { typed: { provider: typed.provider, event: typed.event } }), ...(local === undefined ? {} : { local: `proof · local ${local.generation} ${local.transport} · seal` }) });
1220
1221
  /** The proof of THIS approval: the approver's own row for these exact bytes (never another approval's words). */
1221
1222
  export function approvalProofOf(store, taskId, by, digest) {
1222
1223
  const rows = store.handle.prepare("SELECT outcome, detail FROM action_ledger WHERE task_id = ? AND action = ? AND actor = ? ORDER BY id DESC LIMIT 50").all(taskId, APPROVAL_PROOF, by);
package/dist/serve.js CHANGED
@@ -121,7 +121,7 @@ import { findFirstTasks, firstRunSteps, signInCommandFor } from "./first-run.js"
121
121
  import { installMethod } from "./install-method.js";
122
122
  import { createIntegrationMonitor } from "./integrations.js";
123
123
  import { logEvent } from "./log.js";
124
- import { LEAD_MESSAGE_MAX_CHARS } from "./lead.js";
124
+ import { LEAD_MESSAGE_MAX_CHARS, leadFailureText } from "./lead.js";
125
125
  import {} from "./model-catalog.js";
126
126
  import { modeTermsFromJson } from "./modes.js";
127
127
  import { envValue } from "./names.js";
@@ -171,7 +171,7 @@ import { createBrowserKeys, KEY_FILL_SCRIPT, pagePathOf } from './server/mutatio
171
171
  import { DEFAULT_GUARD_POLICY, passwordGuardOf, provenPasswordAccount, SourceBudget, withPasswordSource } from "./sign-in-guard.js";
172
172
  import { sourceKey } from "./source-key.js";
173
173
  import { readSsoSettings } from "./sso-settings.js";
174
- import { LEAD_THREAD, localOwnerBinding } from "./store.js";
174
+ import { LEAD_THREAD, latestLeadTurnOfThread, localOwnerBinding } from "./store.js";
175
175
  import { waitingUpdate } from "./toolroll-update.js";
176
176
  import { PACKAGE_VERSION } from "./version.js";
177
177
  import { loadConsoleUrl } from "./webhooks.js";
@@ -629,7 +629,8 @@ export function createDecisionServer(options) {
629
629
  return run !== null && family.versions.some(one => one.refId === run.taskRef);
630
630
  }),
631
631
  names: { project: projectName, modes: TASK_COMPOSER_MODES },
632
- live: { begin: thread => chatHandlers.beginLiveTurn(thread), end: (thread, ok, progress) => chatHandlers.endLiveTurn(thread, ok, progress) },
632
+ // A queued message's partials are shaped as its final reply will be: for its words, with this console's own addresses.
633
+ live: { begin: (thread, asked) => chatHandlers.beginLiveTurn(thread, { asked, appOrigin: [publicOrigin?.origin ?? null, options.configDir === undefined ? null : loadConsoleUrl(process.env, options.configDir)] }), end: (thread, ok, progress) => chatHandlers.endLiveTurn(thread, ok, progress) },
633
634
  ...(options.chatFetcher ? { fetcher: options.chatFetcher } : {}),
634
635
  ...(options.subscriptionChatRunner ? { subscriptionRunner: options.subscriptionChatRunner } : {}),
635
636
  });
@@ -1326,7 +1327,13 @@ export function createDecisionServer(options) {
1326
1327
  const replaced = thread !== null ? store.replacedLeadThread(thread)
1327
1328
  : found !== null && found.scope.kind === "lead" && store.listLeadMessages(found.id, 1).length > 0 ? found : null;
1328
1329
  const previousProposals = replaced === null ? [] : store.listLeadProposals(replaced.id);
1330
+ // How this thread's last reply ended, read from its saved turn (never process memory, which a restart loses): a
1331
+ // failed turn that saved no message of its own says why in the thread, until the next reply starts.
1332
+ const lastTurn = thread === null ? null : latestLeadTurnOfThread(store, thread.id);
1333
+ const failure = lastTurn !== null && lastTurn.state === "failed" && !messages.some(one => one.role === "assistant" && one.turn === lastTurn.id)
1334
+ ? leadFailureText(lastTurn.failureReason, store.leadTurnCostUnconfirmed(lastTurn.id), lastTurn.failureDetail ?? null) : null;
1329
1335
  return {
1336
+ failure,
1330
1337
  previous: replaced === null ? null : { messages: store.listLeadMessages(replaced.id, 40), proposals: previousProposals, decisions: decisionsFor(store, previousProposals) },
1331
1338
  messages,
1332
1339
  ask,
@@ -1369,7 +1376,7 @@ export function createDecisionServer(options) {
1369
1376
  sessionId: session?.id ?? NOT_STARTED_SESSION, user: session?.approver ?? who.name, version: leadChatVersion({ ...rows, focusTask }),
1370
1377
  messages: leadBrowserMessages(rows, back, { task: focusTask?.id ?? null, project: chatProject }),
1371
1378
  pendingTurnId: rows.pending?.id ?? null, requestId: randomBytes(16).toString("hex"), maxChars: LEAD_MESSAGE_MAX_CHARS,
1372
- taskId: focusTask?.id ?? null, resultRunId, project: focusTask === null ? chatProject : null,
1379
+ taskId: focusTask?.id ?? null, resultRunId, project: focusTask === null ? chatProject : null, failure: rows.pending === null ? rows.failure : null,
1373
1380
  };
1374
1381
  }
1375
1382
  /** The session-layer principal (ruling 3): cookie, csrf, and role were
@@ -3,6 +3,7 @@ import { type DemoResultView } from "../demo-chat.js";
3
3
  import type { DemoExchange } from "../demo.js";
4
4
  import { type FirstTaskSuggestion } from "../first-run.js";
5
5
  import type { LeadProgress } from "../lead-progress.js";
6
+ import type { ShapeOptions } from "../reply-shape.js";
6
7
  import type { DirectChatProviderId } from "../store.js";
7
8
  import type { ServerRuntime } from './runtime.js';
8
9
  import { type ChatEnablement } from "./render-chat.js";
@@ -14,7 +15,7 @@ export declare function createChatHandlers(runtime: ServerRuntime): {
14
15
  via: "cookie";
15
16
  }, now: Date) => BrowserPhoneCard | undefined;
16
17
  chatProjectOf: (values: string[]) => string | null | undefined;
17
- beginLiveTurn: (thread: number) => (event: LeadProgress) => void;
18
+ beginLiveTurn: (thread: number, shape?: ShapeOptions) => (event: LeadProgress) => void;
18
19
  endLiveTurn: (thread: number, ok: boolean, progress?: (event: LeadProgress) => void) => void;
19
20
  noteLead: (csrf: string, turn: number | null, message: string) => void;
20
21
  takeLeadNote: (csrf: string, liveSession: number | null) => string | null;
@@ -23,6 +23,7 @@ import { configureLeadFollow, leadFollowStatus } from '../lead-follow.js';
23
23
  import { leadActivity } from "../lead-voice.js";
24
24
  import { limitsView } from "../limits-ui.js";
25
25
  import { confirmCoordinatorProposal, confirmLeadProposal, dismissCoordinatorProposal, dismissLeadProposal } from "../lead-doors.js";
26
+ import { createLiveReply } from "../live-reply.js";
26
27
  import { LEAD_MESSAGE_MAX_CHARS, runLeadTurn } from "../lead.js";
27
28
  import { livePin, modelOptions } from "../model-catalog.js";
28
29
  import { verifyApproverByPassword, verifyApproverStanding } from "../principal.js";
@@ -779,7 +780,7 @@ export function createChatHandlers(runtime) {
779
780
  const cardJson = String(request.headers["accept"] ?? "").includes("application/json") && !body.has("nonce");
780
781
  const cardAnswer = (status, ok, said, taskId = null) => respond(response, status, "application/json", JSON.stringify({ ok, said, taskId }));
781
782
  if (principal === null)
782
- return cardJson ? cardAnswer(403, false, "Your approver standing changed. Sign in again.") : refuse(response, who, 403, "your approver standing changed — sign in again", back);
783
+ return cardJson ? cardAnswer(403, false, "Your sign-in changed. Sign in again.") : refuse(response, who, 403, "Your sign-in changed. Sign in again.", back);
783
784
  if (op === "dismiss") {
784
785
  const dismissed = dismissLeadProposal(store, principal, id, now);
785
786
  if (cardJson)
@@ -848,7 +849,7 @@ export function createChatHandlers(runtime) {
848
849
  return refuse(response, who, 403, "proposals are confirmed from the browser or the CLI");
849
850
  const principal = leadPrincipal(who);
850
851
  if (principal === null)
851
- return refuse(response, who, 403, "your approver standing changed — sign in again", "/chat");
852
+ return refuse(response, who, 403, "Your sign-in changed. Sign in again.", "/chat");
852
853
  const id = Number(url.pathname.split("/")[2]);
853
854
  store.sweepCoordinatorProposals(now);
854
855
  const back = body.get("return") === null ? "/chat" : safeReturn(body.get("return"));
@@ -918,7 +919,7 @@ export function createChatHandlers(runtime) {
918
919
  if (who.role === "approver" && enabled.billing === "subscription" && unbound) {
919
920
  const principal = leadPrincipal(who);
920
921
  if (principal === null)
921
- return wantsJson ? said(403, "your approver standing changed — sign in again") : refuse(response, who, 403, "your approver standing changed — sign in again", back);
922
+ return wantsJson ? said(403, "Your sign-in changed. Sign in again.") : refuse(response, who, 403, "Your sign-in changed. Sign in again.", back);
922
923
  const sentRequest = body.get("request");
923
924
  if (sentRequest !== null && (body.getAll("request").length !== 1 || !/^[a-f0-9]{32}$/.test(sentRequest)))
924
925
  return said(409, "This conversation changed. Reload it before sending your message.");
@@ -960,7 +961,7 @@ export function createChatHandlers(runtime) {
960
961
  const requestId = sentRequest ?? randomBytes(16).toString("hex");
961
962
  const principal = leadPrincipal(who);
962
963
  if (principal === null)
963
- return wantsJson ? said(403, "your approver standing changed — sign in again") : refuse(response, who, 403, "your approver standing changed — sign in again", back);
964
+ return wantsJson ? said(403, "Your sign-in changed. Sign in again.") : refuse(response, who, 403, "Your sign-in changed. Sign in again.", back);
964
965
  const message = (body.get("message") ?? "").trim();
965
966
  if (message === "" || message.length > LEAD_MESSAGE_MAX_CHARS) {
966
967
  return said(400, `a message is 1 to ${LEAD_MESSAGE_MAX_CHARS} characters`, leadSession.id);
@@ -991,20 +992,24 @@ export function createChatHandlers(runtime) {
991
992
  // The live display starts once this message holds a turn: a refusal (a secret, a turn still running) never blanks
992
993
  // the display of a reply that is still running in the same conversation.
993
994
  let onProgress;
995
+ // Partials are shaped as the final reply is: for this message, with this console's own addresses.
996
+ const liveShape = { asked: message, appOrigin: requestContext.getStore()?.appOrigins ?? null };
994
997
  const ended = (ok) => { if (onProgress !== undefined)
995
998
  endLiveTurn(opened.thread.id, ok, onProgress); };
996
999
  // Detached work never carries this request's browser session (Passwordless reads it): it runs with no session facts.
997
1000
  void freshFacts.run(null, () => runLeadTurn({ store, who: principal, session: leadSession, thread: opened.thread, config: enabled.config, key: enabled.key, message,
998
- onAdmitted: () => { onProgress = beginLiveTurn(opened.thread.id); }, onProgress: event => onProgress?.(event), channel: "console", requestId, ...(focusTask === null && chatProject !== null ? { context: `Current project: ${projectName(chatProject)} (${chatProject}). Keep this conversation about that project unless the operator explicitly asks to broaden it; use it as the repo for project tools.` } : {}), ...(focusTask === null ? {} : { context: `Current task: ${focusTask.id}. Read it with get_task before answering or proposing changes. Read its currentExecution next and bind new actions to that exact execution. Never replace the target of a prior proposal with a newer revision. Keep this turn about that task unless the operator explicitly asks to broaden it.${resultContext}${modeContext}` }), fetcher: chatFetcher, ...(options.subscriptionChatRunner === undefined ? {} : { subscriptionRunner: options.subscriptionChatRunner }), clock, evidenceRoot }))
1001
+ onAdmitted: () => { onProgress = beginLiveTurn(opened.thread.id, liveShape); }, onProgress: event => onProgress?.(event), channel: "console", requestId, ...(focusTask === null && chatProject !== null ? { context: `Current project: ${projectName(chatProject)} (${chatProject}). Keep this conversation about that project unless the operator explicitly asks to broaden it; use it as the repo for project tools.` } : {}), ...(focusTask === null ? {} : { context: `Current task: ${focusTask.id}. Read it with get_task before answering or proposing changes. Read its currentExecution next and bind new actions to that exact execution. Never replace the target of a prior proposal with a newer revision. Keep this turn about that task unless the operator explicitly asks to broaden it.${resultContext}${modeContext}` }), fetcher: chatFetcher, ...(options.subscriptionChatRunner === undefined ? {} : { subscriptionRunner: options.subscriptionChatRunner }), clock, evidenceRoot }))
999
1002
  .then(outcome => {
1000
1003
  // Lost a race to another turn of this person's between deciding and admission: it waits instead of being refused.
1001
1004
  if (!outcome.ok && "refused" in outcome && (outcome.refused === "concurrent" || outcome.refused === "backlog") && queueIt().kind === "queued") {
1002
1005
  ended(false);
1003
1006
  return;
1004
1007
  }
1005
- // A turn that saved its outcome in the thread (one stopped at its deadline) is said there, once.
1006
- if (!outcome.ok && !("saved" in outcome && outcome.saved === true))
1007
- noteLead(who.session.csrf, "turn" in outcome ? outcome.turn : null, outcome.message);
1008
+ // A turn that saved its outcome in the thread (one stopped at its deadline) is said there, once. Any other turn
1009
+ // that ended failed is said from its saved row (leadConversationRows `failure`), which a restart keeps; only a
1010
+ // refusal before any turn existed is said from memory.
1011
+ if (!outcome.ok && !("saved" in outcome && outcome.saved === true) && !("turn" in outcome))
1012
+ noteLead(who.session.csrf, null, outcome.message);
1008
1013
  ended(outcome.ok);
1009
1014
  })
1010
1015
  .catch(() => { noteLead(who.session.csrf, null, "the turn failed unexpectedly"); ended(false); })
@@ -1208,11 +1213,11 @@ export function createChatHandlers(runtime) {
1208
1213
  /** Which live display each progress handle started: ending a turn ends only its own display (a turn stopped from
1209
1214
  * another window that settles late can never end the next turn's). */
1210
1215
  const progressOf = new WeakMap();
1211
- function beginLiveTurn(thread) {
1216
+ function beginLiveTurn(thread, shape = {}) {
1212
1217
  const previous = liveTurns.get(thread);
1213
1218
  if (previous?.expiry)
1214
1219
  clearTimeout(previous.expiry);
1215
- const live = { steps: [], done: false, ok: false, listeners: previous?.listeners ?? new Set(), startedAt: clock().getTime(), stepAt: [] };
1220
+ const live = { steps: [], done: false, ok: false, listeners: previous?.listeners ?? new Set(), startedAt: clock().getTime(), stepAt: [], reply: createLiveReply(shape) };
1216
1221
  liveTurns.set(thread, live);
1217
1222
  // A page that has this conversation open follows the reply (GET /live, its chat room).
1218
1223
  runtime.liveTurnStarted(thread);
@@ -1222,6 +1227,7 @@ export function createChatHandlers(runtime) {
1222
1227
  if (live.done)
1223
1228
  return;
1224
1229
  live.turn ??= event.turn;
1230
+ live.reply?.push(event);
1225
1231
  if (event.kind === "step")
1226
1232
  newStep();
1227
1233
  else if (event.kind === "tool") {
@@ -1241,6 +1247,11 @@ export function createChatHandlers(runtime) {
1241
1247
  current().text = event.text;
1242
1248
  (live.confirmed ??= [])[live.steps.length - 1] = event.final === true;
1243
1249
  }
1250
+ else if (event.kind === "blocked") {
1251
+ // Held back as a possible password or key: no step keeps its words (the Live grid's tile reads these too).
1252
+ for (const step of live.steps)
1253
+ step.text = "";
1254
+ }
1244
1255
  for (const listener of live.listeners)
1245
1256
  listener();
1246
1257
  };
@@ -1255,6 +1266,7 @@ export function createChatHandlers(runtime) {
1255
1266
  return;
1256
1267
  live.done = true;
1257
1268
  live.ok = ok;
1269
+ live.reply?.close(ok ? "answered" : "failed");
1258
1270
  live.endedAt = clock().getTime();
1259
1271
  for (const listener of live.listeners)
1260
1272
  listener();
@@ -726,7 +726,7 @@ export function workToolRows(scoped = false, _offersChat = false, offersCode = f
726
726
  { key: "tasks", href: "/tasks", label: "Task list", hint: "everything, filterable by state" },
727
727
  { key: "recipes", href: "/recipes", label: "Recipes", hint: "choose, customize, and reuse a workflow" },
728
728
  { key: "workbench", href: "/tasks?scope=all", label: "Every project", hint: "every project's tasks in one list" },
729
- { key: "ledger", href: "/ledger", label: "Action ledger", hint: "who acted, what happened, and the result" },
729
+ { key: "ledger", href: "/ledger", label: "Audit log", hint: "who acted, what happened, and the result" },
730
730
  { key: "spend", href: "/spend", label: "Spend", hint: "what agent work cost, and monthly budgets" },
731
731
  ];
732
732
  return scoped ? rows.filter(row => row.key !== "workbench" && row.key !== "spend") : rows;
@@ -737,7 +737,7 @@ export function settingsRows(scoped = false, offersSettings = false) {
737
737
  { key: "fleet", href: "/fleet", label: "Fleet", hint: "who is working, and on what" },
738
738
  { key: "caps", href: "/caps", label: "Requirements", hint: "tools and credentials builds need" },
739
739
  { key: "people", href: "/people", label: "People", hint: "who can sign in, and what they have done" },
740
- { key: "mode", href: "/mode", label: "Operating mode", hint: "the signed posture this repository runs under" },
740
+ { key: "mode", href: "/mode", label: "Operating mode", hint: "how much this project may do without asking" },
741
741
  { key: "system", href: "/system", label: "System", hint: "workers, providers, and grants" },
742
742
  ];
743
743
  return scoped ? rows.filter(row => row.key === "people" || row.key === "settings") : rows;
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Home ("/", UI revamp wave B): one sentence that answers "what needs me?", four counts read from the same work index
3
- * the Tasks list filters (so a count and the list it opens always agree), what waits on this person grouped by what it
4
- * asks, what is running, and the lead's message box. Reads only: every act keeps its own route and rules (an agent's
3
+ * the Tasks list filters (so a count and the list it opens always agree), the work in three groups (Waiting on you,
4
+ * Working, Done; home-groups.ts), and the lead's message box. Reads only: every act keeps its own route and rules (an agent's
5
5
  * question is answered through POST /d/<id>/answer, the same one-click act as its own page).
6
6
  */
7
7
  import type { BrowserHomeView } from "../browser-workspace.js";
@@ -37,5 +37,6 @@ export declare function homeSentence(needs: number, perProject: readonly {
37
37
  name: string;
38
38
  needs: number;
39
39
  }[], scope: string | null, any: boolean): string;
40
- /** Home's view for one person and scope, from two reads of the work index (what waits, what runs). */
40
+ /** Home's view for one person and scope, from four reads of the work index (what waits on you, what runs, what waits on
41
+ * Toolroll, what was done), grouped by the one rule every surface shares (home-groups.ts). */
41
42
  export declare function homeView(facts: HomeFacts): BrowserHomeView;
@@ -1,15 +1,16 @@
1
1
  import { browserWorkActionHref } from "../browser-crew.js";
2
- import { ASK_LABEL } from "../needs-you.js";
2
+ import { HOME_GROUP_LABEL, homeGroupOf } from "../home-groups.js";
3
3
  import { projectName } from "../project.js";
4
4
  import { workIndexPage } from "../work-index.js";
5
5
  import { relativeAge } from "./chrome.js";
6
6
  import { taskHref } from "./http.js";
7
7
  const TONE = { attention: "needs", ready: "needs", live: "live", done: "done", problem: "failed", muted: "", neutral: "" };
8
- const ASKS = ["decide", "review", "unblock"];
9
8
  const ASK_WORD = { decide: "Decision", review: "Result", unblock: "Blocked" };
10
9
  /** How many waiting rows Home lists; the rest open in Tasks. */
11
10
  export const HOME_NEEDS_SHOWN = 30;
12
11
  const HOME_RUNNING_SHOWN = 8;
12
+ /** How many rows finished in the last 7 days Home lists; the rest open in Tasks. */
13
+ const HOME_DONE_SHOWN = 8;
13
14
  /** "7 need you across 2 projects." — the sentence and the figures read the same totals. */
14
15
  export function homeSentence(needs, perProject, scope, any) {
15
16
  if (!any)
@@ -51,50 +52,54 @@ function rowOf(store, item, now, multi, canAnswer, closedDoor) {
51
52
  : href === null || action === null ? null : { label: action.label, href }, question,
52
53
  };
53
54
  }
54
- /** Home's view for one person and scope, from two reads of the work index (what waits, what runs). */
55
+ /** Home's view for one person and scope, from four reads of the work index (what waits on you, what runs, what waits on
56
+ * Toolroll, what was done), grouped by the one rule every surface shares (home-groups.ts). */
55
57
  export function homeView(facts) {
56
58
  const { store, now, access, project } = facts;
57
59
  const read = (view, limit) => workIndexPage(store, now, access, { view, limit, project, ...(facts.evidenceRoot === undefined ? {} : { root: facts.evidenceRoot }) });
58
60
  const needs = read("needs-you", HOME_NEEDS_SHOWN);
59
61
  const running = read("running", HOME_RUNNING_SHOWN);
60
- // Waiting (docs/plans/plain-toolroll.md P1.4): what Toolroll or another task moves on, with no person asked.
61
- const waiting = workIndexPage(store, now, access, { view: "queued", limit: HOME_RUNNING_SHOWN, project, ...(facts.evidenceRoot === undefined ? {} : { root: facts.evidenceRoot }) });
62
+ // Queued: what waits its turn, or what Toolroll or another task moves on, with no person asked.
63
+ const waiting = read("queued", HOME_RUNNING_SHOWN);
64
+ // Done: the Completed list is ordered by completion time, newest first, so its first rows are the newest finished;
65
+ // only those finished in the last 7 days are listed, and that filter runs before the cap.
66
+ const completed = read("completed", HOME_DONE_SHOWN);
62
67
  const totals = needs.totals;
63
68
  // Queued: the Tasks list's own Queued count (the same predicate its view=queued filter lists).
64
69
  const queued = totals.queued;
65
70
  // Every project is said by name (scope=all): /tasks alone would narrow to the session's open project.
66
71
  const scopeQuery = project === null ? "scope=all&" : `project=${encodeURIComponent(project)}&`;
72
+ // Each figure keeps the name of the list it opens; Done is the whole Completed list, not just this week.
67
73
  const counts = [
68
- { key: "needs", label: "Needs you", value: totals["needs-you"], href: `/tasks?${scopeQuery}view=needs-you` },
74
+ { key: "needs", label: HOME_GROUP_LABEL.you, value: totals["needs-you"], href: `/tasks?${scopeQuery}view=needs-you` },
69
75
  { key: "running", label: "Running", value: totals.running, href: `/tasks?${scopeQuery}view=running` },
70
76
  { key: "queued", label: "Queued", value: queued, href: `/tasks?${scopeQuery}view=queued` },
71
- { key: "finished", label: "Finished", value: totals.completed, href: `/tasks?${scopeQuery}view=completed` },
77
+ { key: "finished", label: HOME_GROUP_LABEL.done, value: totals.completed, href: `/tasks?${scopeQuery}view=completed` },
72
78
  ];
73
- const multi = project === null && new Set([...needs.items, ...running.items].map(one => one.repo ?? "")).size > 1;
79
+ // One row per family, in the first group that claims it.
80
+ const seen = new Set();
81
+ const take = (items, group) => items.filter(one => {
82
+ if (homeGroupOf(one) !== group || seen.has(one.rootId))
83
+ return false;
84
+ seen.add(one.rootId);
85
+ return true;
86
+ });
87
+ // Waiting on you, in today's order of what it asks: decide (with any row whose ask the index couldn't name, which
88
+ // still waits on this person and is never lost), then review, then unblock. Each row's chip names its ask.
89
+ const yours = take(["decide", null, "review", "unblock"].flatMap(ask => needs.items.filter(one => one.ask === ask)), "you");
90
+ // Working: what runs, then what is queued (waits its turn, or on Toolroll or another task), each read with its own bound.
91
+ const working = [...take(running.items, "working"), ...take(waiting.items, "working")];
92
+ const weekAgo = new Date(now.getTime() - 7 * 86_400_000).toISOString();
93
+ const done = take(completed.items.filter(one => (one.completion?.at ?? one.updatedAt) >= weekAgo), "done").slice(0, HOME_DONE_SHOWN);
94
+ const multi = project === null && new Set([...yours, ...working, ...done].map(one => one.repo ?? "")).size > 1;
74
95
  const groups = [];
75
- for (const ask of ASKS) {
76
- const rows = needs.items.filter(one => one.ask === ask).map(one => rowOf(store, one, now, multi, facts.canAnswer, facts.closedDoor));
77
- if (rows.length > 0)
78
- groups.push({ key: ask, label: ASK_LABEL[ask], rows });
96
+ for (const [key, items] of [["you", yours], ["working", working], ["done", done]]) {
97
+ if (items.length > 0)
98
+ groups.push({ key, label: HOME_GROUP_LABEL[key], rows: items.map(one => rowOf(store, one, now, multi, facts.canAnswer, facts.closedDoor)) });
79
99
  }
80
- // A waiting row whose ask the index couldn't name still waits on this person: it goes under Decide, never lost.
81
- const unnamed = needs.items.filter(one => one.ask === null).map(one => rowOf(store, one, now, multi, facts.canAnswer, facts.closedDoor));
82
- if (unnamed.length > 0) {
83
- const decide = groups.find(one => one.key === "decide");
84
- if (decide)
85
- decide.rows.push(...unnamed);
86
- else
87
- groups.unshift({ key: "decide", label: ASK_LABEL.decide, rows: unnamed });
88
- }
89
- const live = running.items.filter(one => !one.status.views.includes("needs-you")).map(one => rowOf(store, one, now, multi, facts.canAnswer, facts.closedDoor));
90
- if (live.length > 0)
91
- groups.push({ key: "running", label: "Running", rows: live });
92
- const waits = waiting.items.filter(one => one.status.label === "Waiting" && !one.status.views.includes("needs-you")).map(one => rowOf(store, one, now, multi, facts.canAnswer, facts.closedDoor));
93
- if (waits.length > 0)
94
- groups.push({ key: "waiting", label: "Waiting", rows: waits });
95
100
  const perProject = needs.projects.map(one => ({ name: one.repo === null ? "Unplaced" : projectName(one.repo), needs: one.totals["needs-you"] }));
96
101
  const any = totals.all > 0 || totals.completed > 0 || (totals.archived ?? 0) > 0;
97
- const shown = groups.filter(one => one.key !== "running" && one.key !== "waiting").reduce((sum, one) => sum + one.rows.length, 0);
102
+ const shown = yours.length;
98
103
  return {
99
104
  kind: "home",
100
105
  say: homeSentence(totals["needs-you"], perProject, project === null ? null : projectName(project), any),
@@ -4,6 +4,7 @@ import { agentRunFingerprint } from "../agents-board.js";
4
4
  import { createLiveRooms, LiveConnection } from "../live-bus.js";
5
5
  import { reproveRemote } from "../operate-remote.js";
6
6
  import { rowVisible } from "../project.js";
7
+ import { createLiveSender, LIVE_STATUS } from "../live-reply.js";
7
8
  import { TEAM_PASSWORD_REVERIFY_MS } from "../team-http.js";
8
9
  import { handlersOf } from "./handler-registry.js";
9
10
  import { respond } from "./http.js";
@@ -18,6 +19,11 @@ const ROOM_NAME = /^(workspace|task|flow|chat|team|agent|summary|agents-live)(?:
18
19
  const WORKSPACE_SETTLE_MS = 1_000;
19
20
  /** A running reply's preview: at most one snapshot every 80 ms; the last one always goes out. */
20
21
  const TURN_SETTLE_MS = 80;
22
+ function chatSnapshot(turn) {
23
+ const view = turn.reply?.view() ?? { phase: "working", status: LIVE_STATUS.thinking };
24
+ const steps = (turn.reply?.tools() ?? []).map(calls => ({ tools: calls.map(call => call.label), toolCalls: calls }));
25
+ return { steps, phase: view.phase, status: turn.done ? "" : turn.reply?.status() ?? LIVE_STATUS.thinking, text: view.phase === "writing" ? view.text : "", done: turn.done, ok: turn.ok };
26
+ }
21
27
  /** A room's name, read strictly: anything else is not a room. */
22
28
  export function parseLiveRoom(name) {
23
29
  const match = ROOM_NAME.exec(name);
@@ -114,39 +120,30 @@ export function createLiveHandlers(runtime, live) {
114
120
  }
115
121
  return valid;
116
122
  }
117
- /** A running reply for one viewer's thread: snapshots while it runs, then the last one. */
123
+ /** A running reply for one viewer's thread: snapshots while it runs, then the last one. Every snapshot goes through
124
+ * one serialized sender (live-reply.ts): one in flight, the newest waiting, standing re-proved before each, and the
125
+ * last one only after the sender has closed, so no preview lands after it. */
118
126
  function follow(viewer, thread) {
119
127
  if (!admitted(viewer, thread))
120
128
  return;
121
129
  const turn = liveTurns.get(thread);
122
130
  if (turn === undefined || turn.done || viewer.detach !== null)
123
131
  return;
124
- let queued = null;
125
- const snapshot = () => {
126
- if (admitted(viewer, thread))
127
- viewer.connection.send(viewer.room, "turn", { steps: turn.steps, done: turn.done, ok: turn.ok });
128
- };
129
- const flush = () => {
130
- queued = null;
131
- snapshot();
132
- if (turn.done)
133
- stop();
134
- };
132
+ const send = (payload) => { viewer.connection.send(viewer.room, "turn", payload); };
133
+ const sender = createLiveSender({ send, standing: () => admitted(viewer, thread), spacingMs: TURN_SETTLE_MS });
135
134
  const listener = () => {
136
- if (turn.done) {
137
- if (queued !== null)
138
- clearTimeout(queued);
139
- flush();
135
+ if (!turn.done) {
136
+ sender.offer(chatSnapshot(turn));
140
137
  return;
141
138
  }
142
- if (queued === null)
143
- queued = setTimeout(flush, TURN_SETTLE_MS);
139
+ stop();
140
+ void sender.close().then(() => { if (admitted(viewer, thread))
141
+ send(chatSnapshot(turn)); });
144
142
  };
145
- const stop = () => { if (queued !== null)
146
- clearTimeout(queued); turn.listeners.delete(listener); viewer.detach = null; };
143
+ const stop = () => { turn.listeners.delete(listener); viewer.detach = null; void sender.close(); };
147
144
  turn.listeners.add(listener);
148
145
  viewer.detach = stop;
149
- snapshot();
146
+ sender.offer(chatSnapshot(turn));
150
147
  }
151
148
  /** A reply starts on a thread: whoever has that conversation open follows it. */
152
149
  function turnStarted(thread) {
@@ -300,6 +300,8 @@ export declare function replacedBrowserMessages(previous: ReplacedThread): impor
300
300
  /** The replaced thread for the server-rendered page: read only, then the divider. */
301
301
  export declare function replacedThreadHtml(previous: ReplacedThread | null | undefined): Html;
302
302
  export type LeadThreadRows = {
303
+ /** How the thread's last reply failed, from its saved turn (no message of its own); null otherwise. */
304
+ failure?: string | null;
303
305
  /** The lead thread a change in reachable projects replaced; shown only. */
304
306
  previous?: ReplacedThread | null;
305
307
  messages: LeadMessage[];
@@ -404,6 +406,9 @@ export type LiveTurn = {
404
406
  stepAt?: number[];
405
407
  /** Per step: its text is the provider's finished answer, secret-checked whole (lead.ts `final`). */
406
408
  confirmed?: boolean[];
409
+ /** The shared live-reply core (live-reply.ts): the only source of what the chat room shows — one status line, the
410
+ * tools behind a disclosure, and shaped, settled partial text. `steps[].text` above is raw and never sent. */
411
+ reply?: import("../live-reply.js").LiveReply;
407
412
  };
408
413
  export type ChatEnablement = {
409
414
  ok: true;