toolroll 0.9.63 → 0.9.67

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 (302) hide show
  1. package/CHANGELOG.md +108 -0
  2. package/README.md +33 -5
  3. package/dist/acp-agents.d.ts +43 -0
  4. package/dist/acp-agents.js +62 -0
  5. package/dist/acp-attest.d.ts +44 -0
  6. package/dist/acp-attest.js +269 -0
  7. package/dist/acp-check.d.ts +28 -0
  8. package/dist/acp-check.js +410 -0
  9. package/dist/acp-client.d.ts +163 -0
  10. package/dist/acp-client.js +738 -0
  11. package/dist/acp-inspect.d.ts +23 -0
  12. package/dist/acp-inspect.js +109 -0
  13. package/dist/agent-board.d.ts +19 -6
  14. package/dist/agent-board.js +30 -9
  15. package/dist/agent-fence.d.ts +117 -2
  16. package/dist/agent-fence.js +439 -12
  17. package/dist/agent-pulse.js +9 -2
  18. package/dist/agent-state-words.d.ts +19 -0
  19. package/dist/agent-state-words.js +20 -0
  20. package/dist/agent-state.d.ts +65 -0
  21. package/dist/agent-state.js +229 -0
  22. package/dist/agentconfig.js +39 -13
  23. package/dist/agents-board.d.ts +34 -2
  24. package/dist/agents-board.js +110 -1
  25. package/dist/approval-rules-ui.js +2 -2
  26. package/dist/assignment-delivery.d.ts +14 -0
  27. package/dist/assignment-delivery.js +13 -0
  28. package/dist/assignment.js +13 -0
  29. package/dist/attest.js +7 -0
  30. package/dist/backup-ui.js +2 -2
  31. package/dist/batch-checks.js +3 -2
  32. package/dist/browser/THIRD_PARTY_NOTICES.txt +94 -0
  33. package/dist/browser/terminal.css +37 -0
  34. package/dist/browser/terminal.js +228 -0
  35. package/dist/browser/workspace.css +2 -2
  36. package/dist/browser/workspace.js +42 -38
  37. package/dist/browser-shell.js +6 -3
  38. package/dist/browser-workspace.d.ts +235 -1
  39. package/dist/browser-workspace.js +60 -0
  40. package/dist/builder.d.ts +10 -1
  41. package/dist/builder.js +87 -24
  42. package/dist/chat-actions.d.ts +13 -1
  43. package/dist/chat-actions.js +197 -15
  44. package/dist/chat-ask.d.ts +4 -0
  45. package/dist/chat-ask.js +14 -0
  46. package/dist/chat-channel.js +20 -0
  47. package/dist/chat-continuity.js +5 -4
  48. package/dist/chat-decide.d.ts +0 -14
  49. package/dist/chat-decide.js +4 -1
  50. package/dist/chat-delivery.d.ts +9 -0
  51. package/dist/chat-delivery.js +45 -6
  52. package/dist/chat-messages.d.ts +8 -0
  53. package/dist/chat-messages.js +36 -4
  54. package/dist/check-levels-ui.js +2 -2
  55. package/dist/checkout-proof.d.ts +28 -0
  56. package/dist/checkout-proof.js +62 -0
  57. package/dist/claim.d.ts +12 -0
  58. package/dist/claim.js +19 -0
  59. package/dist/cli-help.d.ts +1 -1
  60. package/dist/cli-help.js +4 -1
  61. package/dist/cli.d.ts +3 -0
  62. package/dist/cli.js +21 -2
  63. package/dist/coding-ui.js +6 -4
  64. package/dist/console-instance.d.ts +124 -0
  65. package/dist/console-instance.js +337 -0
  66. package/dist/contracts/chat-actions.d.ts +16 -0
  67. package/dist/contracts/chat-actions.js +4 -0
  68. package/dist/contracts/chat-content.d.ts +19 -0
  69. package/dist/contracts/chat-content.js +6 -0
  70. package/dist/contracts/console-api.d.ts +13 -4
  71. package/dist/contracts/console-api.js +13 -4
  72. package/dist/contracts/flow-card.d.ts +4 -0
  73. package/dist/contracts/flow-card.js +3 -0
  74. package/dist/contracts/flow.d.ts +46 -0
  75. package/dist/contracts/flow.js +11 -2
  76. package/dist/contracts/lead-tools.d.ts +92 -0
  77. package/dist/contracts/lead-tools.js +13 -0
  78. package/dist/contracts/provider.d.ts +11 -1
  79. package/dist/contracts/provider.js +1 -1
  80. package/dist/contracts/route.d.ts +76 -1
  81. package/dist/contracts/route.js +1 -1
  82. package/dist/contracts/store-json.d.ts +2 -0
  83. package/dist/control-setup.d.ts +1 -1
  84. package/dist/control-ui.js +2 -2
  85. package/dist/converse.d.ts +16 -0
  86. package/dist/converse.js +32 -4
  87. package/dist/demo-chat.js +4 -4
  88. package/dist/demo.js +2 -2
  89. package/dist/desktop-update-gate.js +6 -0
  90. package/dist/desktop-update.d.ts +1 -1
  91. package/dist/discord-chat.js +4 -1
  92. package/dist/doctor.js +1 -1
  93. package/dist/egress-bridge.d.ts +17 -0
  94. package/dist/egress-bridge.js +111 -0
  95. package/dist/egress.d.ts +119 -0
  96. package/dist/egress.js +423 -0
  97. package/dist/exec.d.ts +78 -0
  98. package/dist/exec.js +367 -9
  99. package/dist/export-ui.js +2 -2
  100. package/dist/flow-engine.js +24 -5
  101. package/dist/flow-gallery-ui.js +2 -2
  102. package/dist/flow-gallery.js +27 -8
  103. package/dist/flow-share.js +16 -5
  104. package/dist/flow-triggers.d.ts +77 -8
  105. package/dist/flow-triggers.js +517 -70
  106. package/dist/flows-cli.js +8 -2
  107. package/dist/flows-ui.d.ts +0 -2
  108. package/dist/flows-ui.js +52 -4
  109. package/dist/flows.d.ts +4 -0
  110. package/dist/flows.js +9 -1
  111. package/dist/html.d.ts +9 -0
  112. package/dist/html.js +11 -0
  113. package/dist/invoke.d.ts +35 -0
  114. package/dist/invoke.js +347 -28
  115. package/dist/keys.d.ts +18 -14
  116. package/dist/keys.js +5 -0
  117. package/dist/lead-capabilities.js +3 -2
  118. package/dist/lead-contract.js +2 -0
  119. package/dist/lead-doors.d.ts +2 -0
  120. package/dist/lead-doors.js +7 -0
  121. package/dist/lead-follow.d.ts +2 -0
  122. package/dist/lead-follow.js +58 -2
  123. package/dist/lead-progress.js +1 -0
  124. package/dist/lead-queue-runtime.d.ts +46 -0
  125. package/dist/lead-queue-runtime.js +191 -0
  126. package/dist/lead-queue.d.ts +104 -0
  127. package/dist/lead-queue.js +187 -0
  128. package/dist/lead-sweep.d.ts +67 -0
  129. package/dist/lead-sweep.js +99 -0
  130. package/dist/lead-tools.d.ts +22 -0
  131. package/dist/lead-tools.js +152 -1
  132. package/dist/lead.d.ts +13 -5
  133. package/dist/lead.js +89 -29
  134. package/dist/limits-ui.d.ts +8 -1
  135. package/dist/limits-ui.js +26 -0
  136. package/dist/live-bus.d.ts +8 -0
  137. package/dist/live-bus.js +40 -4
  138. package/dist/live-detail.d.ts +27 -0
  139. package/dist/live-detail.js +57 -0
  140. package/dist/live-notes.d.ts +20 -0
  141. package/dist/live-notes.js +47 -0
  142. package/dist/live-records.d.ts +140 -0
  143. package/dist/live-records.js +935 -0
  144. package/dist/live-terminal.d.ts +269 -0
  145. package/dist/live-terminal.js +686 -0
  146. package/dist/live.d.ts +38 -27
  147. package/dist/live.js +142 -124
  148. package/dist/mcp-oauth.d.ts +6 -3
  149. package/dist/mcp-oauth.js +8 -4
  150. package/dist/memory-pass.js +2 -2
  151. package/dist/mobile-viewport.d.ts +5 -1
  152. package/dist/mobile-viewport.js +18 -2
  153. package/dist/model-catalog.js +4 -0
  154. package/dist/monitoring-ui.js +2 -2
  155. package/dist/needs-you.d.ts +1 -1
  156. package/dist/needs-you.js +11 -4
  157. package/dist/operate.d.ts +4 -0
  158. package/dist/operate.js +335 -19
  159. package/dist/passwordless.d.ts +23 -0
  160. package/dist/passwordless.js +26 -0
  161. package/dist/peek-cli.d.ts +12 -4
  162. package/dist/peek-cli.js +19 -11
  163. package/dist/phase-routing.js +10 -3
  164. package/dist/planner-source.d.ts +3 -0
  165. package/dist/planner-source.js +14 -0
  166. package/dist/planner.d.ts +2 -1
  167. package/dist/planner.js +7 -1
  168. package/dist/policy-ui.js +2 -2
  169. package/dist/policy.d.ts +1 -20
  170. package/dist/policy.js +29 -2
  171. package/dist/project-delete-ui.d.ts +18 -0
  172. package/dist/project-delete-ui.js +60 -9
  173. package/dist/project-delete.js +2 -2
  174. package/dist/project-memory.d.ts +3 -1
  175. package/dist/project-memory.js +57 -25
  176. package/dist/project-tools.d.ts +5 -3
  177. package/dist/project-tools.js +12 -9
  178. package/dist/provider-auth.d.ts +4 -3
  179. package/dist/provider-auth.js +5 -4
  180. package/dist/provider-connection.d.ts +2 -1
  181. package/dist/provider-connection.js +5 -0
  182. package/dist/provider.d.ts +25 -2
  183. package/dist/provider.js +63 -9
  184. package/dist/pull-request-ui.js +2 -2
  185. package/dist/push.js +1 -1
  186. package/dist/quick-start.d.ts +24 -0
  187. package/dist/quick-start.js +127 -0
  188. package/dist/release-adapters.d.ts +6 -0
  189. package/dist/release-adapters.js +70 -3
  190. package/dist/release.d.ts +10 -1
  191. package/dist/release.js +34 -15
  192. package/dist/request-budget-ui.js +2 -2
  193. package/dist/result-follow-ups.js +3 -2
  194. package/dist/result-review.d.ts +1 -1
  195. package/dist/result-review.js +9 -3
  196. package/dist/retention-ui.js +2 -2
  197. package/dist/retention.js +10 -1
  198. package/dist/run-efficiency.d.ts +73 -0
  199. package/dist/run-efficiency.js +228 -0
  200. package/dist/runner.d.ts +5 -1
  201. package/dist/runner.js +22 -1
  202. package/dist/scope.d.ts +183 -5
  203. package/dist/scope.js +256 -31
  204. package/dist/scout-net.d.ts +4 -1
  205. package/dist/scout-net.js +37 -12
  206. package/dist/scout.d.ts +20 -6
  207. package/dist/scout.js +27 -3
  208. package/dist/serve.js +248 -40
  209. package/dist/server/chat.d.ts +1 -1
  210. package/dist/server/chat.js +122 -31
  211. package/dist/server/chrome.d.ts +16 -2
  212. package/dist/server/chrome.js +63 -11
  213. package/dist/server/console.css +107 -42
  214. package/dist/server/flows.js +38 -8
  215. package/dist/server/guards.d.ts +1 -0
  216. package/dist/server/guards.js +23 -9
  217. package/dist/server/http.d.ts +7 -0
  218. package/dist/server/http.js +3 -2
  219. package/dist/server/live.d.ts +34 -1
  220. package/dist/server/live.js +39 -17
  221. package/dist/server/pages-assets.js +5 -0
  222. package/dist/server/pages.js +90 -13
  223. package/dist/server/people-tokens.d.ts +3 -0
  224. package/dist/server/people-tokens.js +55 -17
  225. package/dist/server/remote-hooks.js +12 -6
  226. package/dist/server/remote.d.ts +2 -1
  227. package/dist/server/remote.js +14 -5
  228. package/dist/server/render-chat.d.ts +12 -3
  229. package/dist/server/render-chat.js +47 -16
  230. package/dist/server/render-pages.d.ts +32 -4
  231. package/dist/server/render-pages.js +83 -13
  232. package/dist/server/render-people.d.ts +3 -2
  233. package/dist/server/render-people.js +3 -2
  234. package/dist/server/render-results.d.ts +18 -2
  235. package/dist/server/render-results.js +37 -28
  236. package/dist/server/render-settings.js +6 -5
  237. package/dist/server/render-tasks.d.ts +35 -10
  238. package/dist/server/render-tasks.js +111 -27
  239. package/dist/server/request-context.d.ts +2 -0
  240. package/dist/server/request-context.js +2 -1
  241. package/dist/server/route-table.d.ts +3 -0
  242. package/dist/server/route-table.js +47 -5
  243. package/dist/server/runtime.d.ts +33 -2
  244. package/dist/server/session.d.ts +25 -1
  245. package/dist/server/session.js +18 -4
  246. package/dist/server/settings.js +142 -10
  247. package/dist/server/sweeps.d.ts +4 -0
  248. package/dist/server/sweeps.js +58 -0
  249. package/dist/server/task-views.d.ts +2 -1
  250. package/dist/server/task-views.js +96 -6
  251. package/dist/server/tasks.js +327 -78
  252. package/dist/server/terminals.d.ts +31 -0
  253. package/dist/server/terminals.js +365 -0
  254. package/dist/setup-guide.js +3 -2
  255. package/dist/slack-chat.js +4 -1
  256. package/dist/spend-ui.js +3 -3
  257. package/dist/spend.js +2 -1
  258. package/dist/steer-capabilities.d.ts +32 -0
  259. package/dist/steer-capabilities.js +60 -0
  260. package/dist/steer-controls.d.ts +14 -0
  261. package/dist/steer-controls.js +36 -0
  262. package/dist/steer-status.js +2 -2
  263. package/dist/storage-ui.js +4 -4
  264. package/dist/store-baseline.d.ts +1 -1
  265. package/dist/store-baseline.js +1 -1
  266. package/dist/store.d.ts +440 -15
  267. package/dist/store.js +1072 -52
  268. package/dist/subagent-desk.js +1 -1
  269. package/dist/summary.js +3 -0
  270. package/dist/task-source.d.ts +58 -0
  271. package/dist/task-source.js +93 -0
  272. package/dist/team-leads.js +1 -1
  273. package/dist/teams-chat.js +4 -1
  274. package/dist/teardown-git.d.ts +22 -0
  275. package/dist/teardown-git.js +136 -2
  276. package/dist/telegram-lead.d.ts +3 -0
  277. package/dist/telegram-lead.js +44 -7
  278. package/dist/telegram.d.ts +11 -2
  279. package/dist/telegram.js +51 -17
  280. package/dist/terminal-catalog.d.ts +11 -0
  281. package/dist/terminal-catalog.js +129 -0
  282. package/dist/text-limits.d.ts +2 -0
  283. package/dist/text-limits.js +2 -0
  284. package/dist/tool-broker.d.ts +14 -0
  285. package/dist/tool-broker.js +67 -2
  286. package/dist/toolroll-update-ui.js +2 -2
  287. package/dist/toolroll-update.js +13 -3
  288. package/dist/tools-ui.js +2 -2
  289. package/dist/unfenced-test-support.d.ts +2 -0
  290. package/dist/unfenced-test-support.js +11 -0
  291. package/dist/webhook-limits.d.ts +23 -0
  292. package/dist/webhook-limits.js +30 -0
  293. package/dist/webhook-readers.d.ts +56 -0
  294. package/dist/webhook-readers.js +138 -0
  295. package/dist/webhook-signing.d.ts +27 -0
  296. package/dist/webhook-signing.js +119 -0
  297. package/dist/work-index.d.ts +29 -0
  298. package/dist/work-index.js +16 -2
  299. package/dist/workspace-revision.js +3 -1
  300. package/dist/writes-mode.d.ts +9 -0
  301. package/dist/writes-mode.js +8 -0
  302. package/package.json +15 -2
@@ -74,7 +74,7 @@ export function askSubagent(store, given, from, text, now) {
74
74
  return { ok: false, said: `${name}'s desk can't take cards right now.` };
75
75
  const firstLine = said.split("\n")[0].trim();
76
76
  const title = firstLine.length <= 100 ? firstLine : `${firstLine.slice(0, 99)}…`;
77
- const card = store.addFlowCard({ flow: desk.id, title, description: said === title ? null : said, stage: zone.id, by: from.who, source: { kind: "message", label: `Asked through ${from.via}`, url: null } }, now);
77
+ const card = store.addFlowCard({ flow: desk.id, title, description: said === title ? null : said, stage: zone.id, by: from.who, byKind: "person", source: { kind: "message", label: `Asked through ${from.via}`, url: null } }, now);
78
78
  store.setFlowCardWatcher(card, from.who, true, now);
79
79
  return { ok: true, said: `${name} has it. The answer comes to you when it's done.`, card, link: { label: "Open the card", path: flowCardHref(desk.id, card) } };
80
80
  }
package/dist/summary.js CHANGED
@@ -32,6 +32,9 @@ export function tally(runs) {
32
32
  * key spend and must never be presented as a separate charge.
33
33
  */
34
34
  export function runCostWords(run, live = false) {
35
+ // An ACP agent signs in its own way and reports no price Toolroll trusts.
36
+ if (run.authMode === "agent-login")
37
+ return live ? "the agent's own sign-in · in progress" : "the agent's own sign-in · cost not measured";
35
38
  if (run.authMode === "subscription") {
36
39
  return run.costUsd === null
37
40
  ? live
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Where a task came from (docs/plans/webhook-tasks.md §5): an outside event's delivery, kept with the task. A card's
3
+ * source is written once at receipt (who proved it, the outside thing's identity and link); the task filed from that
4
+ * card keeps a snapshot of it (task_source), which its revisions copy, so the reference outlives the card's own
5
+ * task link and zone. Every word shown comes from that snapshot — never from today's trigger settings.
6
+ */
7
+ import type { FlowCardSource, Store } from "./store.js";
8
+ /** What a task keeps of the card it was filed from. */
9
+ export type TaskSourceSnapshot = {
10
+ label: string;
11
+ verified: string;
12
+ external: {
13
+ id: string | null;
14
+ url: string | null;
15
+ };
16
+ receipt: string | null;
17
+ };
18
+ /** As much of a card's source as a reference reads. */
19
+ export type SourceLike = Pick<FlowCardSource, "label"> & Partial<Pick<FlowCardSource, "verified" | "external" | "receipt">>;
20
+ /** The snapshot an outside event's card gives its task, or null when the card didn't come from one (a person's, a poll's). */
21
+ export declare function snapshotOf(source: SourceLike | null): TaskSourceSnapshot | null;
22
+ /** A snapshot as saved, read back strictly: anything else is no snapshot at all. */
23
+ export declare function readSnapshot(json: string): TaskSourceSnapshot | null;
24
+ /** The host a link names, for words that always show where it goes. */
25
+ export declare function hostOf(url: string | null): string | null;
26
+ /** Where a link goes, as it is shown: its host and path (no scheme), the middle elided when long, so the destination is
27
+ * always visible beside the label. */
28
+ export declare function linkWords(url: string, max?: number): string;
29
+ /** How a snapshot's delivery was proved, in words: "signed by Sentry", or "unsigned". */
30
+ export declare function provedWords(snapshot: TaskSourceSnapshot): string;
31
+ /**
32
+ * The reference a task's brief carries: what the outside thing is, from where, and that it is data, not instructions.
33
+ * Bounded (an identity is at most 120 characters and a link 2,000), and said plainly when it was unsigned.
34
+ */
35
+ /** A reference's words, at most: every reader of them (the planner's source, the scout's brief) accepts exactly this. The
36
+ * largest there can be (a 253-character host, 120-character label and identity, a 2,000-character link) fits. */
37
+ export declare const REFERENCE_LIMIT = 3000;
38
+ export declare function referenceWords(snapshot: TaskSourceSnapshot): string;
39
+ /** The same reference for a card that hasn't filed its task yet ({{card.source}}), or "" for a card from no outside event. */
40
+ export declare function cardReference(source: SourceLike | null | undefined): string;
41
+ /** Where a task came from, in the words a page shows: the flow (named and linked only for a viewer who can see it) and
42
+ * the outside event (its label, how it was proved, its link and that link's host), all from what was kept. */
43
+ export type TaskOrigin = {
44
+ flow: {
45
+ name: string;
46
+ href: string;
47
+ } | "hidden" | "removed" | null;
48
+ source: {
49
+ label: string;
50
+ proved: string;
51
+ url: string | null;
52
+ host: string | null;
53
+ } | null;
54
+ };
55
+ /** A task's origin, or null when it came from neither a flow nor an outside event. */
56
+ export declare function originOf(store: Store, taskId: string, canSee: (repo: string) => boolean): TaskOrigin | null;
57
+ /** The one line an approval shows about an outside event that started its work: context, never a term agreed to. */
58
+ export declare function originNote(origin: TaskOrigin | null | undefined): string | null;
@@ -0,0 +1,93 @@
1
+ import { SENDER_WORDS } from "./webhook-signing.js";
2
+ /** The snapshot an outside event's card gives its task, or null when the card didn't come from one (a person's, a poll's). */
3
+ export function snapshotOf(source) {
4
+ if (source === null || typeof source.verified !== "string")
5
+ return null;
6
+ return { label: source.label.slice(0, 120), verified: source.verified, external: { id: source.external?.id ?? null, url: source.external?.url ?? null }, receipt: source.receipt ?? null };
7
+ }
8
+ /** A snapshot as saved, read back strictly: anything else is no snapshot at all. */
9
+ export function readSnapshot(json) {
10
+ try {
11
+ const raw = JSON.parse(json);
12
+ const external = raw["external"];
13
+ const optional = (value) => typeof value === "string" ? value : null;
14
+ if (typeof raw["label"] !== "string" || typeof raw["verified"] !== "string" || external === undefined || external === null || typeof external !== "object")
15
+ return null;
16
+ return { label: raw["label"], verified: raw["verified"], external: { id: optional(external["id"]), url: optional(external["url"]) }, receipt: optional(raw["receipt"]) };
17
+ }
18
+ catch {
19
+ return null;
20
+ }
21
+ }
22
+ /** The host a link names, for words that always show where it goes. */
23
+ export function hostOf(url) {
24
+ if (url === null)
25
+ return null;
26
+ try {
27
+ return new URL(url).host;
28
+ }
29
+ catch {
30
+ return null;
31
+ }
32
+ }
33
+ /** Where a link goes, as it is shown: its host and path (no scheme), the middle elided when long, so the destination is
34
+ * always visible beside the label. */
35
+ export function linkWords(url, max = 72) {
36
+ let host, path;
37
+ try {
38
+ const parsed = new URL(url);
39
+ host = parsed.host;
40
+ path = parsed.pathname === "/" ? "" : parsed.pathname;
41
+ }
42
+ catch {
43
+ return "a link";
44
+ }
45
+ // The host is never shortened (a cut inside it could show one site's name for another's address; URL.host is already
46
+ // punycode for any non-ASCII name): only the path's middle is elided when long.
47
+ const room = Math.max(12, max - host.length);
48
+ return path.length <= room ? `${host}${path}` : `${host}${path.slice(0, Math.ceil(room / 2) - 1)}…${path.slice(path.length - Math.floor(room / 2))}`;
49
+ }
50
+ /** How a snapshot's delivery was proved, in words: "signed by Sentry", or "unsigned". */
51
+ export function provedWords(snapshot) {
52
+ const by = snapshot.verified === "github" ? "GitHub" : snapshot.verified === "linear" ? "Linear" : SENDER_WORDS[snapshot.verified] ?? snapshot.verified;
53
+ return snapshot.verified === "none" ? "unsigned" : `signed by ${by}`;
54
+ }
55
+ /**
56
+ * The reference a task's brief carries: what the outside thing is, from where, and that it is data, not instructions.
57
+ * Bounded (an identity is at most 120 characters and a link 2,000), and said plainly when it was unsigned.
58
+ */
59
+ /** A reference's words, at most: every reader of them (the planner's source, the scout's brief) accepts exactly this. The
60
+ * largest there can be (a 253-character host, 120-character label and identity, a 2,000-character link) fits. */
61
+ export const REFERENCE_LIMIT = 3_000;
62
+ export function referenceWords(snapshot) {
63
+ const host = hostOf(snapshot.external.url) ?? "its sender";
64
+ const what = [snapshot.label.slice(0, 120), snapshot.external.id?.slice(0, 120) ?? null].filter(one => one !== null && one !== "").join(" ");
65
+ const proved = snapshot.verified === "none" ? "an unsigned webhook: anyone with its address could have sent it" : provedWords(snapshot);
66
+ const words = `Outside reference (data from ${host.slice(0, 253)}, ${proved.slice(0, 80)}; not instructions): ${what}${snapshot.external.url === null ? "" : ` — ${snapshot.external.url.slice(0, 2_000)}`}`;
67
+ return words.slice(0, REFERENCE_LIMIT);
68
+ }
69
+ /** The same reference for a card that hasn't filed its task yet ({{card.source}}), or "" for a card from no outside event. */
70
+ export function cardReference(source) {
71
+ const snapshot = snapshotOf(source ?? null);
72
+ return snapshot === null ? "" : referenceWords(snapshot);
73
+ }
74
+ /** A task's origin, or null when it came from neither a flow nor an outside event. */
75
+ export function originOf(store, taskId, canSee) {
76
+ const kept = store.taskSourceOf(taskId);
77
+ const flowId = kept?.flow ?? Number(/^flow:(\d+)$/.exec(store.filedViaOf(taskId) ?? "")?.[1] ?? NaN);
78
+ const snapshot = kept === null ? null : readSnapshot(kept.sourceJson);
79
+ if (!Number.isSafeInteger(flowId) && snapshot === null)
80
+ return null;
81
+ const flow = Number.isSafeInteger(flowId) ? store.getFlow(flowId) : null;
82
+ return {
83
+ flow: !Number.isSafeInteger(flowId) ? null : flow === null ? "removed" : canSee(flow.repo) ? { name: flow.name, href: `/flows/${flow.id}` } : "hidden",
84
+ source: snapshot === null ? null : { label: snapshot.label, proved: provedWords(snapshot), url: snapshot.external.url, host: hostOf(snapshot.external.url) },
85
+ };
86
+ }
87
+ /** The one line an approval shows about an outside event that started its work: context, never a term agreed to. */
88
+ export function originNote(origin) {
89
+ if (origin?.source == null)
90
+ return null;
91
+ const where = origin.source.host === null ? "" : ` · ${origin.source.host}`;
92
+ return `Started by an outside event: ${origin.source.label} (${origin.source.proved})${where}. The agent reads what it sent as data, not instructions.`;
93
+ }
@@ -345,7 +345,7 @@ export class TeamLeads {
345
345
  return ok('Message already saved.', { messageId: Number(seen['message']), status: String(seen['status']) });
346
346
  }
347
347
  const revisions = this.memberRevisions(conversationId, actor.name);
348
- const messageId = this.store.appendLeadMessage({ thread: conversation.threadId, turn: null, role: 'operator', text }, now);
348
+ const messageId = this.store.appendLeadMessage({ thread: conversation.threadId, turn: null, role: 'operator', text, pending: true }, now);
349
349
  this.db.prepare("INSERT INTO team_message(message,conversation,author,author_generation,lead_member_revision,participant_revision,request_id,payload_hash,status) VALUES(?,?,?,?,?,?,?,?,'queued')").run(messageId, conversationId, actor.name, actor.generation, revisions.lead, revisions.participant, requestId, digest);
350
350
  this.event(lead.id, conversationId, 'message-queued', actor.name, now);
351
351
  return ok('Message saved.', { messageId, status: 'queued' });
@@ -4,7 +4,7 @@ import { resultShotsPruned } from "./result-shots.js";
4
4
  import { chatQuestionButtons } from "./subagent-question.js";
5
5
  import { chatAskButtons } from "./chat-ask.js";
6
6
  import { ChatDeliveryError, ChatState, chatHash, partContent } from "./chat-delivery-state.js";
7
- import { channelAccess, chatObject as object, planChatNotifications, planRoomMessages, processChatEvent, splitChatText } from "./chat-delivery.js";
7
+ import { channelAccess, chatObject as object, heldForWatch, planChatNotifications, planRoomMessages, processChatEvent, splitChatText } from "./chat-delivery.js";
8
8
  import { PLATFORM_LIMITS } from "./text-limits.js";
9
9
  import { roomCommand } from "./chat-rooms.js";
10
10
  import { LEAD_MESSAGE_MAX_CHARS } from "./lead.js";
@@ -236,6 +236,9 @@ export async function deliverTeamsPart(options) {
236
236
  const bodies = teamsMessages(voiced ?? text, voiced !== null, one => actions.length || content.proposal ? teamsCard(one, actions) : { type: "message", text: one, textFormat: voiced !== null ? "markdown" : "plain" });
237
237
  const activities = `/v3/conversations/${encodeURIComponent(destination)}/activities`;
238
238
  // An edit replaces its one message with the first part; any more follow it as new messages.
239
+ // The watch, at the last moment before the network (cockpit §3).
240
+ if (heldForWatch(state, store, row.id, event, binding.approver, content, now))
241
+ return true;
239
242
  let answer = await options.api(target ? "PUT" : "POST", serviceUrl, target ? `${activities}/${encodeURIComponent(target)}` : activities, bodies[0]);
240
243
  for (const more of bodies.slice(1))
241
244
  answer = await options.api("POST", serviceUrl, activities, more);
@@ -8,7 +8,29 @@ export declare const REFUSED_CODE = 128;
8
8
  /** The command-running setting a `git config --list --show-scope -z` listing sets, or null. Only local and worktree
9
9
  * scope count: teardown commands never read system or global config. */
10
10
  export declare function commandSetting(listing: string): string | null;
11
+ /** The `-c` settings that carry the operator's own values (CARRIED_VALUES, LFS_FILTER) from a global/system listing. */
12
+ export declare function carriedSettings(listing: string): string[];
11
13
  /** Why a teardown command was refused, or null when `result` isn't a refusal. */
12
14
  export declare function refusal(result: ExecResult): string | null;
13
15
  /** The one way teardown runs git, including the target checkout immediately before removal. */
14
16
  export declare function teardownGit(runner?: Runner): Git;
17
+ /**
18
+ * Git on a build's checkout, as the plane runs it outside every fence (staging, committing and inspecting the build's
19
+ * work): the same fixed hardening as teardown (no hooks, fsmonitor, ext transport, credential helpers or automatic
20
+ * maintenance, no submodule recursion, no signing), refused outright when the checkout's own config includes a file
21
+ * or names a command — a filter, an external diff, a textconv, a pager — which a build could have planted. It reads no
22
+ * global or system config: only their values (identity, line endings, a standard git-lfs install) are carried over.
23
+ * It never enters a submodule or nested repository (whose config it never read).
24
+ */
25
+ /**
26
+ * Whether a checkout's own git config (its repository's config and its worktree's config.worktree) includes another
27
+ * file, read without git: every git command follows includes as it starts, so an include of the build's could point
28
+ * git at any file, a pipe that never ends among them. Each file is read only as the regular file it is, bounded; one
29
+ * that can't be read so counts as including.
30
+ */
31
+ export declare function checkoutIncludes(checkout: string): boolean;
32
+ /** Whether the checkout's .lfsconfig (read as git parses it, that file only) sets any git-lfs key that could name a
33
+ * program (anything but INERT_LFS). A file that
34
+ * isn't a plain one, or doesn't parse, counts as naming one. */
35
+ export declare function lfsExtensionIn(checkout: string): boolean;
36
+ export declare function checkoutGit(inner?: Runner): Runner;
@@ -10,7 +10,8 @@
10
10
  * any of them the command never runs. The refusal is a failed git result, so every caller's existing doubt applies and
11
11
  * nothing is removed.
12
12
  */
13
- import { existsSync, readdirSync } from "node:fs";
13
+ import { spawnSync } from "node:child_process";
14
+ import { existsSync, lstatSync, readdirSync, readFileSync } from "node:fs";
14
15
  import { join, resolve } from "node:path";
15
16
  import { run } from "./exec.js";
16
17
  import { ENV, ENV_ALLOWLIST } from "./remote-main.js";
@@ -31,6 +32,10 @@ const COMMAND_KEYS = [
31
32
  "diff.*.command", "diff.*.textconv", "merge.*.driver", "gpg.program", "gpg.*.program", "uploadpack.packobjectshook",
32
33
  "remote.*.uploadpack", "remote.*.receivepack", "remote.*.vcs", "pager.*", "include.path", "includeif.*.path",
33
34
  ].map(key => new RegExp(`^${key.replace(/\./g, "\\.").replace(/\*/g, ".+")}$`));
35
+ /** git-lfs settings that name no program (where it fetches, what, whether it checks locks, how it signs in); any other
36
+ * `lfs.*` key in a checkout's own config could (extensions, custom and standalone transfer agents), so it's refused. */
37
+ const INERT_LFS = /^lfs\.(url|pushurl|fetchinclude|fetchexclude|locksverify|repositoryformatversion|.+\.access|.+\.locksverify|setlockablereadonly|skipdownloaderrors|concurrenttransfers)$/;
38
+ const lfsCommand = (key) => key.startsWith("lfs.") && !INERT_LFS.test(key);
34
39
  /** core.fsmonitor as a boolean is git's own daemon; anything else is a command to run. */
35
40
  const BOOLEAN = /^(|true|false|yes|no|on|off|-?\d+)$/i;
36
41
  /** The command-running setting a `git config --list --show-scope -z` listing sets, or null. Only local and worktree
@@ -45,11 +50,40 @@ export function commandSetting(listing) {
45
50
  const newline = entry.indexOf("\n");
46
51
  const key = (newline < 0 ? entry : entry.slice(0, newline)).toLowerCase();
47
52
  const value = newline < 0 ? "" : entry.slice(newline + 1);
48
- if (key === "core.fsmonitor" ? !BOOLEAN.test(value.trim()) : COMMAND_KEYS.some(pattern => pattern.test(key)))
53
+ if (key === "core.fsmonitor" ? !BOOLEAN.test(value.trim()) : COMMAND_KEYS.some(pattern => pattern.test(key)) || lfsCommand(key))
49
54
  return key;
50
55
  }
51
56
  return null;
52
57
  }
58
+ /**
59
+ * What the plane's git on a build's checkout keeps of the operator's own (global and system) configuration: values,
60
+ * never commands — identity, line endings, ignore and case settings — and git-lfs's filters only as git-lfs itself
61
+ * installs them, exactly. Everything else there (filters, diff drivers, fsmonitor, signing and its key commands,
62
+ * whatever an include brings in) is left out, so nothing in it can run a file of the checkout's.
63
+ */
64
+ const CARRIED_VALUES = new Set(["user.name", "user.email", "core.autocrlf", "core.eol", "core.safecrlf", "core.excludesfile", "core.ignorecase",
65
+ "core.precomposeunicode", "core.quotepath", "core.filemode", "core.symlinks", "core.longpaths"]);
66
+ const LFS_FILTER = {
67
+ "filter.lfs.clean": "git-lfs clean -- %f", "filter.lfs.smudge": "git-lfs smudge -- %f", "filter.lfs.process": "git-lfs filter-process", "filter.lfs.required": "true",
68
+ };
69
+ /** The `-c` settings that carry the operator's own values (CARRIED_VALUES, LFS_FILTER) from a global/system listing. */
70
+ export function carriedSettings(listing) {
71
+ const fields = listing.split("\0");
72
+ const carried = new Map();
73
+ for (let at = 0; at + 1 < fields.length; at += 2) {
74
+ if (fields[at] !== "global" && fields[at] !== "system")
75
+ continue;
76
+ const entry = fields[at + 1];
77
+ const newline = entry.indexOf("\n");
78
+ if (newline < 0)
79
+ continue;
80
+ const key = entry.slice(0, newline).toLowerCase();
81
+ const value = entry.slice(newline + 1);
82
+ if (CARRIED_VALUES.has(key) || LFS_FILTER[key] === value)
83
+ carried.set(key, value);
84
+ }
85
+ return [...carried].flatMap(([key, value]) => ["-c", `${key}=${value}`]);
86
+ }
53
87
  /** Why a teardown command was refused, or null when `result` isn't a refusal. */
54
88
  export function refusal(result) {
55
89
  return result.code === REFUSED_CODE && result.stderr.startsWith(REFUSED_PREFIX) ? result.stderr.slice(REFUSED_PREFIX.length).trim() : null;
@@ -117,3 +151,103 @@ export function teardownGit(runner = run) {
117
151
  return runner("git", [...TEARDOWN_FLAGS, ...safeArgs], options(cwd));
118
152
  };
119
153
  }
154
+ /**
155
+ * Git on a build's checkout, as the plane runs it outside every fence (staging, committing and inspecting the build's
156
+ * work): the same fixed hardening as teardown (no hooks, fsmonitor, ext transport, credential helpers or automatic
157
+ * maintenance, no submodule recursion, no signing), refused outright when the checkout's own config includes a file
158
+ * or names a command — a filter, an external diff, a textconv, a pager — which a build could have planted. It reads no
159
+ * global or system config: only their values (identity, line endings, a standard git-lfs install) are carried over.
160
+ * It never enters a submodule or nested repository (whose config it never read).
161
+ */
162
+ /**
163
+ * Whether a checkout's own git config (its repository's config and its worktree's config.worktree) includes another
164
+ * file, read without git: every git command follows includes as it starts, so an include of the build's could point
165
+ * git at any file, a pipe that never ends among them. Each file is read only as the regular file it is, bounded; one
166
+ * that can't be read so counts as including.
167
+ */
168
+ export function checkoutIncludes(checkout) {
169
+ const regular = (path) => {
170
+ try {
171
+ const held = lstatSync(path);
172
+ if (!held.isFile() || held.size > 1024 * 1024)
173
+ return null;
174
+ return readFileSync(path, "utf8");
175
+ }
176
+ catch {
177
+ return null;
178
+ }
179
+ };
180
+ let gitdir = join(checkout, ".git");
181
+ try {
182
+ if (!lstatSync(gitdir).isDirectory()) {
183
+ // As git reads a .git file: "gitdir: " then the path, one line; anything else (more lines, stray space) refused.
184
+ const text = (regular(gitdir) ?? "").replace(/\r?\n$/, "");
185
+ if (!text.startsWith("gitdir: ") || /[\r\n]/.test(text))
186
+ return true;
187
+ const pointer = text.slice("gitdir: ".length);
188
+ if (pointer === "" || pointer !== pointer.trim())
189
+ return true;
190
+ gitdir = resolve(checkout, pointer);
191
+ }
192
+ }
193
+ catch {
194
+ return true;
195
+ }
196
+ const common = (() => { const named = regular(join(gitdir, "commondir"))?.trim(); return named === undefined || named === "" ? gitdir : resolve(gitdir, named); })();
197
+ for (const path of [join(common, "config"), join(gitdir, "config.worktree")]) {
198
+ if (!existsSync(path) && path.endsWith("config.worktree"))
199
+ continue;
200
+ const text = regular(path);
201
+ if (text === null || /^\s*\[\s*include(if)?\b/im.test(text))
202
+ return true;
203
+ // And as git itself parses it, includes not followed (only this one regular file, from a neutral folder, with no
204
+ // other config read): an include git sees by any spelling is refused.
205
+ const parsed = spawnSync("git", ["config", "--file", path, "--no-includes", "--name-only", "--list", "-z"], { cwd: "/", encoding: "utf8", timeout: 5_000, env: { PATH: process.env["PATH"] ?? "", GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null", HOME: "/nonexistent" } });
206
+ if (parsed.status !== 0 || parsed.stdout.split("\0").some(name => /^(include\.path|includeif\..*\.path)$/i.test(name)))
207
+ return true;
208
+ }
209
+ return false;
210
+ }
211
+ /** Whether the checkout's .lfsconfig (read as git parses it, that file only) sets any git-lfs key that could name a
212
+ * program (anything but INERT_LFS). A file that
213
+ * isn't a plain one, or doesn't parse, counts as naming one. */
214
+ export function lfsExtensionIn(checkout) {
215
+ const path = join(checkout, ".lfsconfig");
216
+ try {
217
+ if (!lstatSync(path).isFile())
218
+ return true;
219
+ }
220
+ catch {
221
+ return false;
222
+ }
223
+ const parsed = spawnSync("git", ["config", "--file", path, "--no-includes", "--name-only", "--list", "-z"], { cwd: "/", encoding: "utf8", timeout: 5_000, env: { PATH: process.env["PATH"] ?? "", GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null", HOME: "/nonexistent" } });
224
+ return parsed.status !== 0 || parsed.stdout.split("\0").filter(Boolean).some(name => lfsCommand(name.toLowerCase()) || /^include(if)?\./i.test(name));
225
+ }
226
+ export function checkoutGit(inner = run) {
227
+ return async (file, args, options) => {
228
+ if (file !== "git")
229
+ return inner(file, args, options);
230
+ const cwd = options?.cwd;
231
+ if (cwd === undefined || checkoutIncludes(cwd))
232
+ return refused("the checkout's git config includes another file, or couldn't be read as a plain file");
233
+ // git-lfs also reads .lfsconfig from the checkout: one that names an extension (a command it would run) is refused.
234
+ if (lfsExtensionIn(cwd))
235
+ return refused("the checkout's .lfsconfig sets a git-lfs key that can run a command");
236
+ // The checkout's own config may name no command.
237
+ const own = await inner("git", ["--no-optional-locks", "config", "--list", "--show-scope", "-z"], options);
238
+ if (own.code !== 0)
239
+ return refused("its config couldn't be read");
240
+ const key = commandSetting(own.stdout);
241
+ if (key !== null)
242
+ return refused(`the checkout's git config sets ${key}, which can run a command`);
243
+ // The operator's own config contributes values only (carriedSettings); git then reads no global or system config.
244
+ const carried = carriedSettings(own.stdout);
245
+ const env = { ...(options?.env ?? {}), GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null" };
246
+ // Never into a submodule or nested repository, whose own config this never read: status and diff skip them.
247
+ const safeArgs = [...args];
248
+ const command = safeArgs.findIndex(arg => arg === "status" || arg === "diff");
249
+ if (command >= 0 && !safeArgs.includes("--ignore-submodules=all"))
250
+ safeArgs.splice(command + 1, 0, "--ignore-submodules=all");
251
+ return inner("git", [...TEARDOWN_FLAGS, "-c", "diff.ignoreSubmodules=all", "-c", "commit.gpgsign=false", "-c", "tag.gpgsign=false", ...carried, ...safeArgs], { ...options, env });
252
+ };
253
+ }
@@ -9,6 +9,9 @@ export declare const CONVERSATION_CLAIM_MS: number;
9
9
  /** A busy engine (one turn at a time) defers a queued message this long, up to the age bound below. */
10
10
  export declare const CONVERSATION_RETRY_MS = 5000;
11
11
  export declare const CONVERSATION_MAX_AGE_MS: number;
12
+ /** Waiting for the lead itself (it is answering, or earlier messages wait in the person's lead queue) is bounded longer:
13
+ * two hours from the message's arrival, never reset as it moves from waiting behind the queue to waiting on a turn. */
14
+ export declare const CONVERSATION_WAIT_MAX_AGE_MS: number;
12
15
  /** A card's buttons and an armed irreversible challenge live this long. */
13
16
  export declare const CARD_TTL_MS: number;
14
17
  export declare const CHALLENGE_TTL_MS: number;
@@ -39,6 +39,11 @@ export const CONVERSATION_CLAIM_MS = 2 * 60_000;
39
39
  /** A busy engine (one turn at a time) defers a queued message this long, up to the age bound below. */
40
40
  export const CONVERSATION_RETRY_MS = 5_000;
41
41
  export const CONVERSATION_MAX_AGE_MS = 10 * 60_000;
42
+ /** Waiting for the lead itself (it is answering, or earlier messages wait in the person's lead queue) is bounded longer:
43
+ * two hours from the message's arrival, never reset as it moves from waiting behind the queue to waiting on a turn. */
44
+ export const CONVERSATION_WAIT_MAX_AGE_MS = 2 * 3_600_000;
45
+ /** A message whose turn was admitted, waiting for the project records to deliver its answer (no age bound). */
46
+ const ADMITTED_REGISTRY_WAIT = "registry-unavailable:admitted";
42
47
  /** A card's buttons and an armed irreversible challenge live this long. */
43
48
  export const CARD_TTL_MS = 24 * 3_600_000;
44
49
  export const CHALLENGE_TTL_MS = 10 * 60_000;
@@ -367,12 +372,19 @@ async function runTelegramConversation(row, args) {
367
372
  const held = () => store.renewTelegramConversation(row.id, owner, CONVERSATION_CLAIM_MS, clock());
368
373
  const ageMs = clock().getTime() - Date.parse(row.createdAt);
369
374
  const requeue = async (outcome) => {
370
- if (ageMs > CONVERSATION_MAX_AGE_MS) {
375
+ // A turn already admitted for it waits for that turn's own outcome (bounded by the turn's deadline); waiting for the
376
+ // lead gets two hours from arrival; anything else, ten minutes.
377
+ const limit = outcome === "replayed:running" || outcome === ADMITTED_REGISTRY_WAIT ? Number.POSITIVE_INFINITY
378
+ : outcome === "busy" || outcome === "backlog" ? CONVERSATION_WAIT_MAX_AGE_MS : CONVERSATION_MAX_AGE_MS;
379
+ if (ageMs > limit) {
371
380
  await notify(couldNotAnswerText(outcome === "registry-unavailable" ? "your projects couldn't be read" : "the assistant stayed busy with another message"));
372
381
  finish({ state: "failed", outcome: `${outcome}:gave-up` });
373
382
  report.refused++;
374
383
  return;
375
384
  }
385
+ // Behind the person's own earlier messages: said once, then it waits quietly.
386
+ if (outcome === "backlog" && row.outcome !== "backlog")
387
+ await notify("Queued behind your earlier messages. It goes to the lead once they're answered.");
376
388
  finish({ state: "queued", outcome, nextAttemptAt: new Date(clock().getTime() + CONVERSATION_RETRY_MS).toISOString() });
377
389
  };
378
390
  // 1. The exact binding the message arrived under must still be the live one.
@@ -435,7 +447,9 @@ async function runTelegramConversation(row, args) {
435
447
  continue;
436
448
  }
437
449
  }
438
- if (now.getTime() - Date.parse(row.createdAt) > DELIVERY_MAX_AGE_MS) {
450
+ // A day from when this part was planned (its card's tokens were minted then), not from the message's arrival: an
451
+ // answer that waited out a registry outage still gets its full day to go out.
452
+ if (now.getTime() - Date.parse(part.createdAt) > DELIVERY_MAX_AGE_MS) {
439
453
  report.problems.push(`telegram chat reply for update ${row.updateId} was not sent within a day: ${part.lastError ?? "unsent"}`);
440
454
  await notify(couldNotAnswerText("my reply couldn't be sent for a day"));
441
455
  finish({ state: "failed", outcome: `unsent:gave-up:${part.lastError ?? "unsent"}` });
@@ -644,7 +658,7 @@ async function runTelegramConversation(row, args) {
644
658
  return;
645
659
  }
646
660
  if (turn.state !== "answered") {
647
- await notify(leadFailureText(turn.failureReason));
661
+ await notify(leadFailureText(turn.failureReason, store.leadTurnCostUnconfirmed(turn.id)));
648
662
  finish({ state: "failed", outcome: `replayed:${turn.failureReason ?? "failed"}` });
649
663
  report.refused++;
650
664
  return;
@@ -668,14 +682,31 @@ async function runTelegramConversation(row, args) {
668
682
  await deliver(row.outcome === ANSWERED_SENDING ? "answered" : "replayed");
669
683
  return;
670
684
  }
671
- // 3. The ceiling, reloaded now.
685
+ // 3. A turn already admitted for it and still working: wait for that turn's own outcome. Nothing here needs the
686
+ // project records, so a registry that can't be read never fails a message whose turn is running.
687
+ if (row.session !== null) {
688
+ const admitted = store.leadRequestReceipt(row.session, row.request);
689
+ if (admitted !== null) {
690
+ store.sweepStaleLeadTurns(clock());
691
+ const turn = store.getLeadTurn(admitted.turn);
692
+ if (turn !== null && (turn.state === "queued" || turn.state === "running")) {
693
+ store.bindTelegramConversationTurn(row.id, owner, row.session, admitted.turn);
694
+ await requeue("replayed:running");
695
+ return;
696
+ }
697
+ }
698
+ }
699
+ // The ceiling, reloaded now.
672
700
  let registry;
673
701
  try {
674
702
  registry = await readProjects();
675
703
  }
676
704
  catch {
677
705
  report.problems.push("telegram chat could not read the current project records");
678
- await requeue("registry-unavailable");
706
+ // A turn already took it (its receipt is in the bound session): its answer exists or will, and is delivered once
707
+ // the projects can be read again — never abandoned to the outage.
708
+ const admitted = row.session !== null && store.leadRequestReceipt(row.session, row.request) !== null;
709
+ await requeue(admitted ? ADMITTED_REGISTRY_WAIT : "registry-unavailable");
679
710
  return;
680
711
  }
681
712
  const repos = telegramConversationRepos(store, binding.approver, registry);
@@ -688,6 +719,12 @@ async function runTelegramConversation(row, args) {
688
719
  return;
689
720
  }
690
721
  }
722
+ // It waited (for the lead, or behind earlier messages), was never admitted, and is past the wait from its arrival:
723
+ // it is never sent this late, even when the lead is free now.
724
+ if ((row.outcome === "busy" || row.outcome === "backlog") && ageMs > CONVERSATION_WAIT_MAX_AGE_MS) {
725
+ await requeue(row.outcome);
726
+ return;
727
+ }
691
728
  // 5. The session and thread — shared with the console and the CLI.
692
729
  const resolved = resolveTelegramLead(store, binding, repos, clock());
693
730
  if (!resolved.ok && resolved.reason === "busy") {
@@ -743,8 +780,8 @@ async function runTelegramConversation(row, args) {
743
780
  if (receipt !== null)
744
781
  store.bindTelegramConversationTurn(row.id, owner, session.id, receipt.turn);
745
782
  if (!outcome.ok && "refused" in outcome) {
746
- if (outcome.refused === "concurrent") {
747
- await requeue("busy");
783
+ if (outcome.refused === "concurrent" || outcome.refused === "backlog") {
784
+ await requeue(outcome.refused === "backlog" ? "backlog" : "busy");
748
785
  return;
749
786
  }
750
787
  await notify(phoneText(outcome.message, 1_000));
@@ -20,8 +20,6 @@
20
20
  */
21
21
  import { type Store, type Notification } from "./store.js";
22
22
  import { type TelegramConversationOptions } from "./telegram-lead.js";
23
- /** Read the enrolled project list on demand. No callback means no task data,
24
- * never an implicit all-database ceiling. Shared by pass and embedded follower. */
25
23
  export type TelegramReadProjects = () => Promise<readonly string[]>;
26
24
  /** The environment name — and therefore the name the builder strips from agents (both names; see names.ts). */
27
25
  export { TELEGRAM_TOKEN_ENV as TOKEN_ENV, TELEGRAM_TOKEN_ENVS as TOKEN_ENVS } from "./names.js";
@@ -244,3 +242,14 @@ export declare function followBridge(store: Store, options: {
244
242
  * fact per entry — its subject, then its body's first line, indented.
245
243
  * Plain text, no buttons: nothing in a digest is tappable. */
246
244
  export declare function digestText(rows: readonly Notification[], since: string | null, now: Date, titleOf?: (taskId: string) => string): string;
245
+ /**
246
+ * An ordinary message from the paired person: persisted for the shared
247
+ * assistant in THIS transaction — before the cursor moves — with the exact
248
+ * binding, sender, update, message and the request identity the engine
249
+ * receipts its turn under. Two things are answered here and now without a
250
+ * model: text over the engine's bound (never truncated), and a reply to a
251
+ * message that carried several tasks (never guessed). Everything hostile is
252
+ * silence, as for every other inbound shape.
253
+ */
254
+ /** At most this many of a chat's messages wait for the lead at once (the personal lead queue's bound). */
255
+ export declare const TELEGRAM_MAX_WAITING = 10;