@alphacoder-v0/pi-loops 0.22.8 → 0.24.0

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 (91) hide show
  1. package/CHANGELOG.md +664 -0
  2. package/CONTRACT.md +7 -7
  3. package/README.md +91 -63
  4. package/README.zh-CN.md +59 -39
  5. package/docs/cli.md +272 -83
  6. package/docs/configuration.md +86 -24
  7. package/docs/design.md +395 -56
  8. package/docs/downstream.md +2 -2
  9. package/docs/goal.md +12 -3
  10. package/docs/hooks.md +6 -7
  11. package/docs/loops.md +288 -59
  12. package/docs/mcp.md +22 -14
  13. package/docs/recipes.md +6 -0
  14. package/docs/screenshot.png +0 -0
  15. package/docs/triggers.md +120 -26
  16. package/docs/troubleshooting.md +37 -26
  17. package/docs/web-ui-parity.md +691 -65
  18. package/package.json +4 -3
  19. package/recipes/autoresearch/recipe.toml +1 -0
  20. package/recipes/changelog-draft/recipe.toml +1 -0
  21. package/recipes/ci-sweeper/recipe.toml +1 -0
  22. package/recipes/daily-digest/recipe.toml +1 -0
  23. package/recipes/deps-sweeper/recipe.toml +1 -0
  24. package/recipes/ecosystem/recipe.toml +1 -0
  25. package/recipes/issue-loop/recipe.toml +1 -0
  26. package/recipes/pr-watch/recipe.toml +1 -0
  27. package/skills/pi-loops/SKILL.md +6 -4
  28. package/src/activity.ts +199 -0
  29. package/src/archive.ts +21 -9
  30. package/src/args.ts +6 -2
  31. package/src/cli-entry.mjs +12 -3
  32. package/src/cli.ts +746 -125
  33. package/src/config.ts +53 -5
  34. package/src/conversation-rows.ts +469 -0
  35. package/src/danger.ts +227 -6
  36. package/src/engine.ts +1240 -0
  37. package/src/goal.ts +71 -0
  38. package/src/held-read.ts +27 -0
  39. package/src/hooks.ts +49 -18
  40. package/src/host-control-channel.ts +455 -40
  41. package/src/host-control.ts +602 -111
  42. package/src/host-entry.mjs +5 -3
  43. package/src/host-runtime.ts +455 -42
  44. package/src/host.ts +659 -123
  45. package/src/inbox-card.ts +278 -0
  46. package/src/inbox.ts +139 -62
  47. package/src/job-edit.ts +7 -1
  48. package/src/job-health.ts +14 -2
  49. package/src/job-lines.ts +201 -13
  50. package/src/job-owner.ts +74 -13
  51. package/src/job-tune.ts +110 -0
  52. package/src/lease.ts +537 -0
  53. package/src/lock.ts +7 -2
  54. package/src/mcp-pool.ts +66 -14
  55. package/src/mcp.ts +1 -1
  56. package/src/open-file.ts +54 -0
  57. package/src/page-children.ts +1507 -0
  58. package/src/page-devices.ts +192 -0
  59. package/src/page-server.ts +3150 -0
  60. package/src/path-precheck.ts +215 -0
  61. package/src/paths.ts +24 -0
  62. package/src/pi-floor.ts +10 -0
  63. package/src/pi-loops.ts +1301 -450
  64. package/src/pi-resolver.d.mts +1 -0
  65. package/src/pi-resolver.mjs +110 -10
  66. package/src/presence.ts +12 -63
  67. package/src/protocol.ts +10 -1
  68. package/src/recipe.ts +43 -1
  69. package/src/requests.ts +354 -0
  70. package/src/rule-lines.ts +68 -0
  71. package/src/run-card.ts +105 -0
  72. package/src/runner.ts +18 -1
  73. package/src/schedule.ts +1 -2
  74. package/src/scheduler.ts +595 -245
  75. package/src/sdk-runner.ts +321 -27
  76. package/src/seen.ts +278 -0
  77. package/src/session-head.ts +21 -0
  78. package/src/share.ts +12 -2
  79. package/src/snapshot.ts +3 -2
  80. package/src/store.ts +81 -8
  81. package/src/tools.ts +196 -65
  82. package/src/trigger-runtime.ts +164 -127
  83. package/src/triggers.ts +39 -7
  84. package/src/trust.ts +46 -2
  85. package/src/ui-prefs.ts +16 -5
  86. package/src/watch-plan.ts +174 -0
  87. package/src/web.d.mts +3 -0
  88. package/src/web.mjs +2260 -2340
  89. package/src/window-executor.ts +459 -0
  90. package/src/window-guard.ts +158 -0
  91. package/docs/agents/issue-tracker.md +0 -34
@@ -0,0 +1,278 @@
1
+ /**
2
+ * A finding as a person reads it: the one place the stored line of `inbox.jsonl` is split into what
3
+ * the terminal's `/inbox`, `pi-loops inbox list` and the page show. Parsed at read time and never
4
+ * written back, so the stored shape (CONTRACT.md 2.2) does not change.
5
+ *
6
+ * A checkpoint is `<the decision> · waits: <what waits on it> · if not: <what happens if nobody
7
+ * acts>` (docs/recipes.md); a card shows the three parts apart, under the page's words "Waiting on"
8
+ * and "If you don't". News is one finding with a headline. What a claim says to the agent and what a
9
+ * dismiss tells the loop are decided here too, so every surface says the same thing.
10
+ */
11
+ import { deadLetterParts } from "./engine.ts";
12
+ import { loopLabel } from "./job-lines.ts";
13
+ import type { InboxEntry, InboxStatus } from "./inbox.ts";
14
+ import { INBOX_TEXT_MAX_CHARS, capChars } from "./protocol.ts";
15
+ import { redact } from "./redact.ts";
16
+
17
+ export interface InboxCard {
18
+ /** `inb-…`: the only thing a button sends. */
19
+ id: string;
20
+ /** `decision` exactly when the stored kind is `checkpoint`; never read again off the text. */
21
+ kind: "decision" | "news";
22
+ status: InboxStatus;
23
+ /** A decision: the text before `· waits:`. News: the first sentence. Never empty. */
24
+ headline: string;
25
+ /** News only: what follows the first sentence. */
26
+ body?: string;
27
+ /** A decision: between `· waits:` and `· if not:`. */
28
+ waits?: string;
29
+ /** A decision: after `· if not:`; undefined when the loop left the clause out. */
30
+ ifNot?: string;
31
+ /** The checker kept it (✓). */
32
+ verified: boolean;
33
+ verifiedReason?: string;
34
+ /** `rule`: a trigger's result. `undelivered`: a prompt that never reached its conversation (a dead letter). */
35
+ origin: "loop" | "rule" | "undelivered";
36
+ /** The loop that filed it, with its name when the loop still exists, and its label (src/job-lines.ts loopLabel). */
37
+ loop?: { id: string; name?: string; label: string };
38
+ /** A rule check's result: the first rule it matched, with its condition while the rule exists. */
39
+ rule?: { id: string; condition?: string };
40
+ source: string;
41
+ cwd: string;
42
+ createdAt: string;
43
+ runId: string;
44
+ /** The conversation that created the loop: where a claim runs (D3). */
45
+ sessionId?: string;
46
+ /** The finding names its run's transcript. The file is not looked at. */
47
+ hasTranscript: boolean;
48
+ /** `sessionId` is the conversation the card is shown in. */
49
+ fromThisConversation: boolean;
50
+ /** A claim from the page is queued for it and has not marked it claimed yet: Yes is not offered again. */
51
+ claiming: boolean;
52
+ }
53
+
54
+ export interface CardContext {
55
+ /** The conversation the card is shown in. */
56
+ here?: string;
57
+ /** A loop's name from jobs.json, or undefined when the loop is gone or has none. */
58
+ loopName(jobId: string): string | undefined;
59
+ /** Whether a page claim row for this finding is queued. */
60
+ claiming?(id: string): boolean;
61
+ /** A rule's condition from triggers.json, or undefined when the rule is gone. */
62
+ ruleCondition?(ruleId: string): string | undefined;
63
+ }
64
+
65
+ /** A rule's condition on a card's origin line, clipped so the line stays one line. */
66
+ const RULE_CONDITION_CHARS = 80;
67
+
68
+ /**
69
+ * A finding's text as a screen may show it: a terminal's escape sequences (colour, clear, title),
70
+ * other control characters, the bidi marks and overrides that reorder a line, and the characters no
71
+ * surface draws are taken out: the zero-width space and invisible operators (U+200B, U+2060-2064,
72
+ * U+FEFF), the soft hyphen, U+034F, the Hangul and Mongolian fillers, the tags U+E0000-E007F and the
73
+ * variation selectors U+FE00-FE0D, which can carry bytes no one sees. What ordinary text needs stays:
74
+ * the joiners U+200C and U+200D (emoji sequences, Persian, Devanagari), U+FE0E and U+FE0F (how an
75
+ * emoji is drawn) and the ideographic variation selectors U+E0100-E01EF. So `/inbox` cannot repaint the terminal it prints in, and every
76
+ * surface, and the claim's prompt, carries the same words.
77
+ */
78
+ export function screenText(text: string): string {
79
+ return text
80
+ // Only a terminated string: an introducer with no BEL or ST would otherwise take the rest of
81
+ // the finding with it, and the card would hide what a claim then sends.
82
+ .replace(/\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)/g, "")
83
+ .replace(/\u001b\[[0-9;?]*[ -\/]*[@-~]/g, "")
84
+ .replace(/\u001b[@-_]?/g, "")
85
+ .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f\u00ad\u034f\u061c\u115f\u1160\u180e\u200b\u200e\u200f\u202a-\u202e\u2060-\u2064\u2066-\u2069\u3164\ufe00-\ufe0d\ufeff\uffa0\u{e0000}-\u{e007f}]/gu, "");
86
+ }
87
+
88
+ /** The mark `findingKind` reads a checkpoint by (CONTRACT.md 2.6), and the clause after it. */
89
+ const WAITS = /(^|\s)·\s*waits:/i;
90
+ const IF_NOT = /\s·\s*if not:/i;
91
+ /** How far into a news finding its first sentence may end and still be a headline. */
92
+ const HEADLINE_MAX_CHARS = 200;
93
+
94
+ /**
95
+ * The parts of a finding's text. A decision splits at the first `· waits:` and then at the first
96
+ * `· if not:`; a decision with no `· waits:` left (cut by the 500-character cap, or written by hand)
97
+ * keeps its whole text as the headline, and so does one whose headline would be empty. News splits
98
+ * after its first sentence, when that ends within 200 characters.
99
+ */
100
+ export function splitFinding(text: string, kind: InboxEntry["kind"]): Pick<InboxCard, "headline" | "body" | "waits" | "ifNot"> {
101
+ if (kind === "checkpoint") {
102
+ const at = WAITS.exec(text);
103
+ if (!at) return { headline: text };
104
+ const headline = text.slice(0, at.index).trim();
105
+ if (!headline) return { headline: text };
106
+ const rest = text.slice(at.index + at[0].length);
107
+ const cut = IF_NOT.exec(rest);
108
+ const waits = (cut ? rest.slice(0, cut.index) : rest).trim();
109
+ const ifNot = cut ? rest.slice(cut.index + cut[0].length).trim() : "";
110
+ return { headline, ...(waits ? { waits } : {}), ...(ifNot ? { ifNot } : {}) };
111
+ }
112
+ const end = /[.!?](?=\s)|[。!?]/.exec(text);
113
+ if (!end || end.index >= HEADLINE_MAX_CHARS) return { headline: text };
114
+ const headline = text.slice(0, end.index + 1).trim();
115
+ const body = text.slice(end.index + 1).trim();
116
+ return body ? { headline, body } : { headline };
117
+ }
118
+
119
+ /**
120
+ * The `matched <rule-id>` a rule check starts what it says with (src/triggers.ts asks for the ids),
121
+ * with more ids after it and the punctuation that ends it. It names the rule, which the card's
122
+ * origin line does already.
123
+ */
124
+ const MATCHED_TOKEN = /^\s*`?matched`?\s+`?dyn-[0-9a-f]{32}`?(?:\s*(?:,|and)\s*`?dyn-[0-9a-f]{32}`?)*\s*[.:;,!—–-]*\s*/i;
125
+
126
+ /** A rule result's text as its card reads it: without the leading `matched <rule-id>`, unless that is all it says. */
127
+ export function ruleResultText(text: string): string {
128
+ const rest = text.replace(MATCHED_TOKEN, "");
129
+ return rest || text;
130
+ }
131
+
132
+ /**
133
+ * One finding as a card, or undefined for a line with no id or no text: `readFinding` turns a
134
+ * line `{}` into an entry with both empty, and a button that sends an empty id names nothing.
135
+ * The text is redacted and capped again, since a line another tool wrote is not bounded.
136
+ */
137
+ export function inboxCard(entry: InboxEntry, ctx: CardContext): InboxCard | undefined {
138
+ if (!entry.id || !entry.text) return undefined;
139
+ const text = capChars(redact(screenText(entry.text)).replace(/\s+/g, " "), INBOX_TEXT_MAX_CHARS);
140
+ const origin = entry.source.startsWith("trigger:") ? "rule" : deadLetterParts(entry.text) ? "undelivered" : "loop";
141
+ const loopId = origin === "loop" ? entry.job_id : undefined;
142
+ const loopName = loopId ? ctx.loopName(loopId) : undefined;
143
+ // A rule check files its result with the first matched rule's id (src/trigger-runtime.ts); a push
144
+ // with no rule files its source label there, which names no rule.
145
+ const ruleId = origin === "rule" && entry.job_id?.startsWith("dyn-") ? entry.job_id : undefined;
146
+ const condition = ruleId ? ctx.ruleCondition?.(ruleId) : undefined;
147
+ return {
148
+ id: entry.id,
149
+ kind: entry.kind === "checkpoint" ? "decision" : "news",
150
+ status: entry.status,
151
+ ...splitFinding(origin === "rule" ? ruleResultText(text) : text, entry.kind),
152
+ verified: entry.verified === true,
153
+ ...(entry.verified_reason ? { verifiedReason: redact(screenText(entry.verified_reason)) } : {}),
154
+ origin,
155
+ ...(loopId ? { loop: loopName === undefined ? { id: loopId, label: loopLabel({ id: loopId }) } : { id: loopId, name: loopName, label: loopLabel({ id: loopId, name: loopName }) } } : {}),
156
+ ...(ruleId ? { rule: condition ? { id: ruleId, condition: capChars(redact(screenText(condition)).replace(/\s+/g, " "), RULE_CONDITION_CHARS - 1) } : { id: ruleId } } : {}),
157
+ source: entry.source,
158
+ cwd: entry.cwd,
159
+ createdAt: entry.created_at,
160
+ runId: entry.run_id,
161
+ ...(entry.session_id ? { sessionId: entry.session_id } : {}),
162
+ hasTranscript: origin !== "rule" && !!entry.transcript,
163
+ fromThisConversation: !!entry.session_id && entry.session_id === ctx.here,
164
+ claiming: entry.status === "new" && (ctx.claiming?.(entry.id) ?? false),
165
+ };
166
+ }
167
+
168
+ /** The cards of `entries`, decisions first and each group in the order given (`Inbox.listNew`'s); lines with no id or text are left out. */
169
+ export function cardsOf(entries: readonly InboxEntry[], ctx: CardContext): InboxCard[] {
170
+ const cards = entries.flatMap((e) => inboxCard(e, ctx) ?? []);
171
+ return [...cards.filter((c) => c.kind === "decision"), ...cards.filter((c) => c.kind === "news")];
172
+ }
173
+
174
+ /** How far a card's continuation lines are indented under its first line. */
175
+ const CLAUSE_INDENT = " ";
176
+
177
+ /** What filed a card, as every surface but `inbox --json` names it: a loop's label, else the stored source. */
178
+ export function cardOrigin(card: Pick<InboxCard, "loop" | "source">): string {
179
+ return card.loop ? card.loop.label : card.source;
180
+ }
181
+
182
+ /**
183
+ * A card as terminal lines: `head`, the marks, the headline (news: its whole text) and `meta` on
184
+ * the first line; a decision's clauses on indented lines under it, under the page's words. A clause
185
+ * the loop left out prints nothing: two packaged playbooks leave `if not` out on purpose.
186
+ */
187
+ export function cardLines(card: InboxCard, head: string, meta: string): string[] {
188
+ const mark = `${card.kind === "decision" ? "⚑ " : ""}${card.verified ? "✓ " : ""}`;
189
+ const title = card.body === undefined ? card.headline : `${card.headline}${/[。!?]$/.test(card.headline) ? "" : " "}${card.body}`;
190
+ const lines = [`${head}${mark}${title}${meta}`];
191
+ if (card.waits !== undefined) lines.push(`${CLAUSE_INDENT}waiting on: ${card.waits}`);
192
+ if (card.ifNot !== undefined) lines.push(`${CLAUSE_INDENT}if you don't: ${card.ifNot}`);
193
+ return lines;
194
+ }
195
+
196
+ /**
197
+ * A card as one line, for a shell: `pi-loops inbox list` prints one line per finding (docs/cli.md),
198
+ * so a script that reads the ids with `awk '{print $1}'` keeps working. The clauses follow the
199
+ * headline on the same line, under the page's words.
200
+ */
201
+ export function cardLine(card: InboxCard, head: string, meta: string): string {
202
+ const [first, ...clauses] = cardLines(card, "", "");
203
+ return `${head}${first}${clauses.map((c) => ` · ${c.trim()}`).join("")}${meta}`;
204
+ }
205
+
206
+ /** The reasons a page offers for a dismiss, beside a typed one. Each goes to the loop as said. */
207
+ export const DISMISS_CHIPS = ["Not a bug", "Already fixed", "Duplicate", "Too noisy", "Not now"] as const;
208
+
209
+ /** Whom a dismiss tells: the loop's next run, nobody (and why), or nobody because no reason was given. */
210
+ export type DismissOutcome = { told: "loop"; loop: string } | { told: "nobody"; why: "loop-gone" | "rule" } | { told: "silent" };
211
+
212
+ /**
213
+ * Whom a dismiss with `reason` tells. A loop's finding carries its job id; a trigger's carries the
214
+ * rule's label there, and a rule keeps no notes and has no next run to tell. `loop` is the job
215
+ * that filed it, when it still exists.
216
+ */
217
+ export function dismissOutcome(entry: Pick<InboxEntry, "job_id">, loop: { id: string; name?: string } | undefined, reason?: string): DismissOutcome {
218
+ if (!reason?.trim()) return { told: "silent" };
219
+ if (loop) return { told: "loop", loop: loopLabel(loop) };
220
+ return { told: "nobody", why: entry.job_id?.startsWith("cron-") ? "loop-gone" : "rule" };
221
+ }
222
+
223
+ /** What the terminal adds after `dismissed: <finding>`. */
224
+ export function dismissTail(outcome: DismissOutcome): string {
225
+ if (outcome.told === "loop") return ` — ${outcome.loop} is told why on its next run`;
226
+ if (outcome.told === "nobody") return outcome.why === "loop-gone" ? " — the loop that reported it is gone, so nobody is told" : " — a trigger reported it, and a trigger keeps no notes, so nobody is told";
227
+ return "";
228
+ }
229
+
230
+ /** What a card says once it is dismissed from the page. */
231
+ export function dismissLine(outcome: DismissOutcome): string {
232
+ if (outcome.told === "loop") return `Dismissed. Your reason goes to ${outcome.loop}'s next run.`;
233
+ if (outcome.told === "nobody") return outcome.why === "loop-gone" ? "Dismissed. The loop that reported it is gone, so nobody is told." : "Dismissed. A rule reported it and keeps no notes, so nobody is told.";
234
+ return "Dismissed.";
235
+ }
236
+
237
+ /** The turn a claim runs: the finding, where it came from, and what a claim of a checkpoint means. */
238
+ export function claimPrompt(e: InboxEntry): string {
239
+ // The words the card showed, redacted and capped as it does, so the turn is asked about what the
240
+ // person read and said yes to, and a secret masked on the card is not sent to the model; the
241
+ // source and the cwd are screened too, since the turn's message is drawn in a terminal when one
242
+ // holds the conversation.
243
+ const text = capChars(redact(screenText(e.text)).replace(/\s+/g, " "), INBOX_TEXT_MAX_CHARS);
244
+ const source = screenText(e.source).replace(/\s+/g, " ").trim();
245
+ const cwd = screenText(e.cwd).replace(/\s+/g, " ").trim();
246
+ const verified = e.verified ? `\n(An independent checker reviewed and kept this finding${e.verified_reason ? `: ${redact(screenText(e.verified_reason))}` : ""}.)` : "";
247
+ // A checkpoint is a decision the loop prepared and left to a person; the claim is that
248
+ // person's yes, so the turn carries the decision out rather than re-investigating it.
249
+ const checkpoint = e.kind === "checkpoint" ? "\nThis finding is a checkpoint: claiming it is the person's approval of the decision it recommends — carry that decision out, and stop where the finding says a person acts." : "";
250
+ // A prompt that never reached the chat: claiming it is asking for it now, not investigating why it was late.
251
+ const late = deadLetterParts(text);
252
+ if (late) return `This prompt (${source}, for ${cwd}) could not be delivered earlier (${late.why}). Do what it asks now:\n${late.prompt}`;
253
+ return `A recurring loop (${source}, running in ${cwd}) reported this finding — investigate and address it:\n${text}${verified}${checkpoint}`;
254
+ }
255
+
256
+ /**
257
+ * Where a claim from the page runs, as the page server finds the conversation that created the
258
+ * loop: open in the page (rung, so it runs as the person's turn), a terminal that answers what is
259
+ * queued, a terminal that does not (`pi -p`, a window behind the file), pi-loops in the middle of a
260
+ * scheduled turn there, nobody (the page starts it), no free page slot (or pi could not be started),
261
+ * pi stopping too often there to be started again on its own, a pi there without pi-loops (nothing
262
+ * would drain the row), or a conversation whose file is gone.
263
+ */
264
+ export type ClaimDelivery = "page" | "terminal" | "terminal-quiet" | "background" | "started" | "full" | "stopped" | "no-pi-loops" | "gone";
265
+
266
+ /** What a card says once its claim is queued; `title` names the conversation it runs in. */
267
+ export function claimLine(delivery: ClaimDelivery, title?: string): string {
268
+ const where = title ? `"${title}"` : "its conversation";
269
+ if (delivery === "page") return `Running in ${where}.`;
270
+ if (delivery === "started") return `Starting ${where}…`;
271
+ if (delivery === "terminal") return "Queued for your terminal.";
272
+ if (delivery === "terminal-quiet") return "Queued; the terminal that has this conversation is not taking messages (/resume there).";
273
+ if (delivery === "background") return "Queued; runs after pi-loops finishes its scheduled run there.";
274
+ if (delivery === "full") return "Queued; it opens when another conversation in the page closes.";
275
+ if (delivery === "no-pi-loops") return "Queued; the pi of that conversation has no pi-loops, so nothing runs it here. Open it in a terminal with pi-loops.";
276
+ if (delivery === "stopped") return "Queued; pi stopped too often in that conversation to be started again on its own.";
277
+ return "Queued; its conversation is gone.";
278
+ }
package/src/inbox.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  * the morning. Findings are one-liners with a new → claimed/dismissed lifecycle.
5
5
  *
6
6
  * Appends are line-atomic; status rewrites are serialized through a file lock.
7
- * Corrupt lines are skipped on read, never deleted.
7
+ * Corrupt lines are skipped on read and written back verbatim by every rewrite, never deleted.
8
8
  */
9
9
  import * as fs from "node:fs";
10
10
  import * as path from "node:path";
@@ -15,6 +15,9 @@ import { stamp } from "./schedule.ts";
15
15
 
16
16
  export type InboxStatus = "new" | "claimed" | "dismissed";
17
17
 
18
+ /** What `Inbox.decide` did: changed the finding, found none with that id, or found it already decided (and by whom, for a claim). */
19
+ export type Decision = { ok: InboxEntry } | { missing: true } | { already: InboxStatus; by?: string };
20
+
18
21
  /** Past this the inbox drops its oldest triaged entries (docs/configuration.md). */
19
22
  export const INBOX_ROTATE_BYTES = 1_000_000;
20
23
 
@@ -49,18 +52,23 @@ export interface InboxEntry {
49
52
  */
50
53
  dismiss_reason: string | null;
51
54
  job_id?: string;
52
- /** Session that owned the loop when it reported; loops are machine-global here. */
55
+ /** The session that created the loop that filed this finding. */
53
56
  session_id?: string;
54
57
  claimed_by?: string;
55
58
  /** Checker's reason when it kept a finding, if it gave one. */
56
59
  verified_reason?: string;
57
60
  /** When it was dismissed — what decides whether the loop's next run is shown the reason. */
58
61
  dismissed_at?: string;
62
+ /** The transcript of the run that filed this finding (RunRecord.sessionFile). */
63
+ transcript?: string;
59
64
  }
60
65
 
61
66
  /** The fields a finding is, in their order — CONTRACT.md §2.2 and docs/downstream.md §3. */
62
67
  export const FINDING_FIELDS = ["id", "created_at", "status", "kind", "source", "run_id", "cwd", "text", "verified", "dismiss_reason"] as const;
63
68
 
69
+ /** What a writer gives for a new finding; the inbox fills in the id, the time and the status. */
70
+ export type NewFinding = Omit<InboxEntry, "id" | "created_at" | "status" | "kind" | "verified" | "dismiss_reason"> & Partial<Pick<InboxEntry, "kind" | "verified">>;
71
+
64
72
  /** Reasons longer than a finding are a paragraph, and the prompt they go into is capped. */
65
73
  const DISMISS_REASON_MAX_CHARS = INBOX_TEXT_MAX_CHARS;
66
74
 
@@ -73,9 +81,45 @@ export class Inbox {
73
81
  this.lockPath = path.join(dir, "inbox.lock");
74
82
  }
75
83
 
76
- async append(entry: Omit<InboxEntry, "id" | "created_at" | "status" | "kind" | "verified" | "dismiss_reason"> & Partial<Pick<InboxEntry, "kind" | "verified">>): Promise<InboxEntry> {
77
- // Built in `FINDING_FIELDS` order, then pi-loops' own: the line is this object as it stands.
78
- const full: InboxEntry = {
84
+ async append(entry: NewFinding): Promise<InboxEntry> {
85
+ const full = this.build(entry);
86
+ fs.mkdirSync(path.dirname(this.file), { recursive: true });
87
+ // Under the same lock as the triage rewrites: findings are appended by every pi window, the
88
+ // headless host and up to `max_concurrent_runs` loop runs, while `/inbox dismiss|clear`
89
+ // rewrites the whole file — an append landing mid-rewrite is a finding nobody ever sees.
90
+ // The lock is awaited, never spun on: a leftover lock directory from a killed process would
91
+ // otherwise block this process's event loop for the whole stale window.
92
+ await withFileLock(this.lockPath, () => this.write(full));
93
+ return full;
94
+ }
95
+
96
+ /**
97
+ * Append `entry` unless a finding with the key's `run_id` and `source` is in the inbox already, and
98
+ * return the finding that is there either way. A key that has `session_id`, even undefined, names
99
+ * one creating conversation's group and matches only its finding; a key without it matches any.
100
+ * The look and the append are one step under the inbox lock. A rule check files its result this
101
+ * way, and the promotion's inbox fallback and the dead letter of its row then find it: one result,
102
+ * one finding, whichever writer comes first. With `unlessRepeat`, the latest new finding of the
103
+ * same `job_id`, `cwd` and source with the same text is the one that is there.
104
+ */
105
+ async appendOnce(entry: NewFinding, key: { run_id: string; source: string } | { run_id: string; source: string; session_id: string | undefined }, opts: { unlessRepeat?: boolean } = {}): Promise<InboxEntry> {
106
+ const full = this.build(entry);
107
+ const source = capChars(key.source, 80);
108
+ fs.mkdirSync(path.dirname(this.file), { recursive: true });
109
+ return withFileLock(this.lockPath, () => {
110
+ const all = this.list();
111
+ const there = all.find((e) => e.run_id === key.run_id && e.source === source && (!("session_id" in key) || e.session_id === key.session_id));
112
+ if (there) return there;
113
+ const latest = opts.unlessRepeat ? all.filter((e) => e.status === "new" && e.job_id === full.job_id && e.cwd === full.cwd && e.source === full.source).at(-1) : undefined;
114
+ if (latest && latest.text === full.text) return latest;
115
+ this.write(full);
116
+ return full;
117
+ });
118
+ }
119
+
120
+ /** The line a new finding is: built in `FINDING_FIELDS` order, then pi-loops' own. */
121
+ private build(entry: NewFinding): InboxEntry {
122
+ return {
79
123
  id: newId("inb"), // inb-<32 hex>, the shape every other id in pi-loops has
80
124
  created_at: stamp(),
81
125
  status: "new",
@@ -87,22 +131,18 @@ export class Inbox {
87
131
  verified: entry.verified ?? null,
88
132
  dismiss_reason: null,
89
133
  job_id: entry.job_id,
90
- session_id: entry.session_id,
91
134
  claimed_by: entry.claimed_by,
92
135
  verified_reason: entry.verified_reason,
93
136
  dismissed_at: entry.dismissed_at,
137
+ ...(entry.session_id === undefined ? {} : { session_id: entry.session_id }),
138
+ ...(entry.transcript === undefined ? {} : { transcript: entry.transcript }),
94
139
  };
95
- fs.mkdirSync(path.dirname(this.file), { recursive: true });
96
- // Under the same lock as the triage rewrites: findings are appended by every pi window, the
97
- // headless host and up to `max_concurrent_runs` loop runs, while `/inbox dismiss|clear`
98
- // rewrites the whole file — an append landing mid-rewrite is a finding nobody ever sees.
99
- // The lock is awaited, never spun on: a leftover lock directory from a killed process would
100
- // otherwise block this process's event loop for the whole stale window.
101
- await withFileLock(this.lockPath, () => {
102
- fs.appendFileSync(this.file, `${JSON.stringify(full)}\n`, "utf8");
103
- this.rotate();
104
- });
105
- return full;
140
+ }
141
+
142
+ /** Caller holds the lock. */
143
+ private write(full: InboxEntry): void {
144
+ fs.appendFileSync(this.file, `${JSON.stringify(full)}\n`, "utf8");
145
+ this.rotate();
106
146
  }
107
147
 
108
148
  /**
@@ -114,14 +154,14 @@ export class Inbox {
114
154
  private rotate(): void {
115
155
  try {
116
156
  if (fs.statSync(this.file).size < INBOX_ROTATE_BYTES) return;
117
- const entries = this.list();
118
- const triaged = entries.filter((e) => e.status !== "new");
157
+ const lines = this.lines();
158
+ const triaged = lines.flatMap(({ entry }) => (entry && entry.status !== "new" ? [entry] : []));
119
159
  const drop = Math.floor(triaged.length / 2);
120
160
  // Nothing triaged to drop means an inbox full of unread findings: rewriting it would
121
161
  // change nothing and would cost a full read+write on every append from here on.
122
162
  if (drop === 0) return;
123
- const dropped = new Set(triaged.slice(0, drop).map((e) => e.id));
124
- this.rewrite(entries.filter((e) => !dropped.has(e.id)));
163
+ const dropped = new Set(triaged.slice(0, drop));
164
+ this.rewrite(lines.filter(({ entry }) => !entry || !dropped.has(entry)));
125
165
  } catch {
126
166
  /* best effort: a rotation that fails must never lose the append that just succeeded */
127
167
  }
@@ -129,6 +169,14 @@ export class Inbox {
129
169
 
130
170
  /** All entries, oldest first. Unparseable lines are skipped. */
131
171
  list(): InboxEntry[] {
172
+ return this.lines().flatMap(({ entry }) => (entry ? [entry] : []));
173
+ }
174
+
175
+ /**
176
+ * Every non-blank line of the file, in order, with the entry it reads as; `entry` is missing for a
177
+ * line that is not a finding (torn, not JSON). A rewrite writes such a line back as it found it.
178
+ */
179
+ private lines(): Array<{ raw: string; entry?: InboxEntry }> {
132
180
  let text: string;
133
181
  try {
134
182
  text = fs.readFileSync(this.file, "utf8");
@@ -136,15 +184,16 @@ export class Inbox {
136
184
  if (err?.code === "ENOENT") return [];
137
185
  throw err;
138
186
  }
139
- const out: InboxEntry[] = [];
140
- for (const line of text.split("\n")) {
141
- if (!line.trim()) continue;
187
+ const out: Array<{ raw: string; entry?: InboxEntry }> = [];
188
+ for (const raw of text.split("\n")) {
189
+ if (!raw.trim()) continue;
190
+ let entry: InboxEntry | undefined;
142
191
  try {
143
- const e = readFinding(JSON.parse(line));
144
- if (e && typeof e.id === "string" && typeof e.text === "string") out.push(e);
192
+ entry = readFinding(JSON.parse(raw));
145
193
  } catch {
146
- /* skip corrupt line */
194
+ /* a corrupt line: kept as raw text, read as nothing */
147
195
  }
196
+ out.push(entry ? { raw, entry } : { raw });
148
197
  }
149
198
  return out;
150
199
  }
@@ -155,10 +204,20 @@ export class Inbox {
155
204
  * resolve numbers against it, so the order is decided once, here.
156
205
  */
157
206
  listNew(): InboxEntry[] {
158
- const fresh = this.list().filter((e) => e.status === "new");
207
+ // A finding with no id or no text names nothing a person could read or act on: not listed, not counted.
208
+ const fresh = this.list().filter((e) => e.status === "new" && e.id !== "" && e.text !== "");
159
209
  return [...fresh.filter((e) => e.kind === "checkpoint"), ...fresh.filter((e) => e.kind !== "checkpoint")];
160
210
  }
161
211
 
212
+ newFor(sessionId: string, since?: string): InboxEntry[] {
213
+ const after = since === undefined ? Number.NEGATIVE_INFINITY : Date.parse(since);
214
+ return this.list().filter((e) => e.status === "new" && e.session_id === sessionId && Date.parse(e.created_at) > after);
215
+ }
216
+
217
+ pinnedTranscripts(jobId: string): Set<string> {
218
+ return new Set(this.list().filter((e) => e.status === "new" && e.job_id === jobId && e.transcript).map((e) => path.resolve(e.transcript!)));
219
+ }
220
+
162
221
  /** Count of new entries; 0 on any error (badge path must never throw). */
163
222
  newCount(): number {
164
223
  try {
@@ -177,38 +236,52 @@ export class Inbox {
177
236
  }
178
237
  }
179
238
 
180
- /** `reason` is only read for a dismiss: what the person said, kept for the loop's next run. */
181
- async setStatus(id: string, status: InboxStatus, claimedBy?: string, reason?: string): Promise<InboxEntry | undefined> {
182
- return withFileLock(this.lockPath, () => {
183
- const entries = this.list();
184
- const entry = entries.find((e) => e.id === id);
185
- if (!entry) return undefined;
186
- entry.status = status;
187
- if (claimedBy) entry.claimed_by = claimedBy;
188
- if (status === "dismissed") {
239
+ /**
240
+ * Claim or dismiss one finding, only while it is `new` (CONTRACT.md 2.3: each succeeds once).
241
+ * The check and the write are one critical section under the inbox lock, against the file as it
242
+ * is then: two pages, a page and a terminal, or two `pi-loops inbox claim` processes that all
243
+ * listed the finding as new get one `ok` between them. `by` is who claimed it; `reason` is only
244
+ * read for a dismiss — what the person said, kept for the loop's next run.
245
+ *
246
+ * `before` runs inside the same critical section, after the check and before the write: a claim
247
+ * queues its hand-off there, so only the winner ever queues one, and a crash between the two leaves
248
+ * the finding new with its hand-off queued (which the page's sweep finishes). Queued before the
249
+ * check, a loser's row could be run by its holder at once, or be the row the winner collapsed onto
250
+ * and then taken back. A `before` that throws leaves the finding as it was.
251
+ */
252
+ async decide(id: string, to: "claimed" | "dismissed", opts: { by?: string; reason?: string; before?: () => void } = {}): Promise<Decision> {
253
+ return withFileLock(this.lockPath, (): Decision => {
254
+ const lines = this.lines();
255
+ const entry = lines.find((l) => l.entry?.id === id)?.entry;
256
+ if (!entry) return { missing: true };
257
+ if (entry.status !== "new") return entry.claimed_by === undefined ? { already: entry.status } : { already: entry.status, by: entry.claimed_by };
258
+ opts.before?.();
259
+ entry.status = to;
260
+ if (opts.by) entry.claimed_by = opts.by;
261
+ if (to === "dismissed") {
189
262
  entry.dismissed_at = stamp();
190
- const why = reason?.replace(/\s+/g, " ").trim();
263
+ const why = opts.reason?.replace(/\s+/g, " ").trim();
191
264
  if (why) entry.dismiss_reason = capChars(why, DISMISS_REASON_MAX_CHARS);
192
265
  }
193
- this.rewrite(entries);
194
- return entry;
266
+ this.rewrite(lines);
267
+ return { ok: entry };
195
268
  });
196
269
  }
197
270
 
198
271
  /** `match` limits it to what the caller listed — `/inbox clear` must not dismiss findings it did not show. */
199
272
  async dismissAllNew(match?: (entry: InboxEntry) => boolean): Promise<number> {
200
273
  return withFileLock(this.lockPath, () => {
201
- const entries = this.list();
274
+ const lines = this.lines();
202
275
  const at = stamp();
203
276
  let changed = 0;
204
- for (const e of entries) {
205
- if (e.status === "new" && (!match || match(e))) {
277
+ for (const { entry: e } of lines) {
278
+ if (e && e.status === "new" && (!match || match(e))) {
206
279
  e.status = "dismissed";
207
280
  e.dismissed_at = at;
208
281
  changed++;
209
282
  }
210
283
  }
211
- if (changed) this.rewrite(entries);
284
+ if (changed) this.rewrite(lines);
212
285
  return changed;
213
286
  });
214
287
  }
@@ -225,8 +298,9 @@ export class Inbox {
225
298
  return this.list().filter((e) => e.job_id === jobId && e.status === "dismissed" && !!e.dismiss_reason && !!e.dismissed_at && Date.parse(e.dismissed_at) > after);
226
299
  }
227
300
 
228
- private rewrite(entries: InboxEntry[]): void {
229
- writeFileAtomic(this.file, entries.map((e) => JSON.stringify(e)).join("\n") + (entries.length ? "\n" : ""));
301
+ /** Write `lines` back, each finding in this version's shape and every line it could not read as it was. Caller holds the lock. */
302
+ private rewrite(lines: Array<{ raw: string; entry?: InboxEntry }>): void {
303
+ writeFileAtomic(this.file, lines.map(({ raw, entry }) => (entry ? JSON.stringify(entry) : raw)).join("\n") + (lines.length ? "\n" : ""));
230
304
  }
231
305
  }
232
306
 
@@ -238,27 +312,30 @@ export class Inbox {
238
312
  */
239
313
  function readFinding(raw: any): InboxEntry | undefined {
240
314
  if (!raw || typeof raw !== "object") return undefined;
241
- const pick = <T>(...keys: string[]): T | undefined => {
242
- for (const k of keys) if (raw[k] !== undefined && raw[k] !== null) return raw[k] as T;
315
+ // A line anything may have written: a field of the wrong type reads as missing, so every reader
316
+ // of a finding can call a string method on a string field.
317
+ const str = (...keys: string[]): string | undefined => {
318
+ for (const k of keys) if (typeof raw[k] === "string") return raw[k];
243
319
  return undefined;
244
320
  };
245
- const status = pick<string>("status");
321
+ const status = str("status");
246
322
  return {
247
- id: String(raw.id ?? ""),
248
- created_at: pick<string>("created_at", "createdAt") ?? "",
323
+ id: str("id") ?? "",
324
+ created_at: str("created_at", "createdAt") ?? "",
249
325
  status: status === "claimed" || status === "dismissed" ? status : "new",
250
326
  kind: raw.kind === "checkpoint" ? "checkpoint" : "news",
251
- source: pick<string>("source") ?? "",
252
- run_id: pick<string>("run_id", "trace_id", "runId") ?? "",
253
- cwd: pick<string>("cwd") ?? "",
254
- text: String(raw.text ?? ""),
255
- verified: pick<boolean>("verified") ?? null,
256
- dismiss_reason: pick<string>("dismiss_reason") ?? null,
257
- job_id: pick<string>("job_id", "jobId"),
258
- session_id: pick<string>("session_id", "sessionId"),
259
- claimed_by: pick<string>("claimed_by", "claimedBy"),
260
- verified_reason: pick<string>("verified_reason", "verifiedReason"),
261
- dismissed_at: pick<string>("dismissed_at"),
327
+ source: str("source") ?? "",
328
+ run_id: str("run_id", "trace_id", "runId") ?? "",
329
+ cwd: str("cwd") ?? "",
330
+ text: str("text") ?? "",
331
+ verified: typeof raw.verified === "boolean" ? raw.verified : null,
332
+ dismiss_reason: str("dismiss_reason") ?? null,
333
+ job_id: str("job_id", "jobId"),
334
+ session_id: str("session_id", "sessionId"),
335
+ claimed_by: str("claimed_by", "claimedBy"),
336
+ verified_reason: str("verified_reason", "verifiedReason"),
337
+ dismissed_at: str("dismissed_at"),
338
+ transcript: str("transcript"),
262
339
  };
263
340
  }
264
341
 
package/src/job-edit.ts CHANGED
@@ -19,7 +19,8 @@ export interface JobEdit {
19
19
  thinking?: string | null;
20
20
  timeoutMs?: number | null;
21
21
  name?: string | null;
22
- /** "here" pins the job to this machine; null lets any machine run it. */
22
+ /** The tools the job's turns run with; null clears the list (a plain job's turns then run uncapped, as its 0.22 turns did). */
23
+ tools?: string[] | null;
23
24
  prompt?: string;
24
25
  schedule?: Schedule;
25
26
  }
@@ -64,6 +65,11 @@ export function applyJobEdit(job: LoopJob, edit: JobEdit, ctx: JobEditContext):
64
65
  if (edit.thinking !== undefined) patch.thinking = edit.thinking ?? undefined;
65
66
  if (edit.timeoutMs !== undefined) patch.timeoutMs = edit.timeoutMs ?? undefined;
66
67
  if (edit.name !== undefined) patch.name = edit.name ?? undefined;
68
+ if (edit.tools !== undefined) {
69
+ patch.tools = edit.tools ?? undefined;
70
+ // The old list is what the job's turns were capped to, and the edit is what uncapped them.
71
+ changed.push(`tools changed from ${job.tools ? job.tools.join(",") : "(none)"} to ${edit.tools ? edit.tools.join(",") : "(none)"}`);
72
+ }
67
73
  if (edit.prompt !== undefined) {
68
74
  patch.prompt = edit.prompt;
69
75
  // The old wording is otherwise unrecoverable, and it is what ties "this loop started