@piagent/platform 1.9.2 → 1.10.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 (84) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +1 -1
  3. package/README.vi.md +1 -1
  4. package/docs/command-reference-vietnamese.md +2 -2
  5. package/docs/distribution-standard.md +6 -6
  6. package/docs/en/architecture.md +1 -1
  7. package/docs/languages.json +4 -4
  8. package/docs/managed-local-acceptance.md +35 -0
  9. package/docs/operator-manual-vietnamese.md +7 -7
  10. package/docs/publishing-for-teams.md +2 -0
  11. package/docs/release-install-policy.md +2 -2
  12. package/docs/team-onboarding.md +2 -2
  13. package/docs/vi/architecture.md +1 -1
  14. package/docs-site/architecture.html +1 -1
  15. package/docs-site/benchmark.html +1 -1
  16. package/docs-site/commands.html +1 -1
  17. package/docs-site/content/en/index.html +4 -4
  18. package/docs-site/content/en/whats-new.html +22 -0
  19. package/docs-site/content/index.html +4 -4
  20. package/docs-site/content/whats-new.html +22 -0
  21. package/docs-site/context-engine.html +1 -1
  22. package/docs-site/ecosystem.html +1 -1
  23. package/docs-site/en/architecture.html +1 -1
  24. package/docs-site/en/benchmark.html +1 -1
  25. package/docs-site/en/commands.html +1 -1
  26. package/docs-site/en/context-engine.html +1 -1
  27. package/docs-site/en/ecosystem.html +1 -1
  28. package/docs-site/en/index.html +5 -5
  29. package/docs-site/en/mcp-approval.html +1 -1
  30. package/docs-site/en/mcp-auth.html +1 -1
  31. package/docs-site/en/mcp-servers.html +1 -1
  32. package/docs-site/en/mcp.html +1 -1
  33. package/docs-site/en/permissions.html +1 -1
  34. package/docs-site/en/profiles.html +1 -1
  35. package/docs-site/en/quickstart.html +1 -1
  36. package/docs-site/en/runtime.html +1 -1
  37. package/docs-site/en/scope.html +1 -1
  38. package/docs-site/en/security.html +1 -1
  39. package/docs-site/en/team.html +1 -1
  40. package/docs-site/en/whats-new.html +23 -1
  41. package/docs-site/en/workflows.html +1 -1
  42. package/docs-site/index.html +5 -5
  43. package/docs-site/mcp-approval.html +1 -1
  44. package/docs-site/mcp-auth.html +1 -1
  45. package/docs-site/mcp-servers.html +1 -1
  46. package/docs-site/mcp.html +1 -1
  47. package/docs-site/permissions.html +1 -1
  48. package/docs-site/profiles.html +1 -1
  49. package/docs-site/quickstart.html +1 -1
  50. package/docs-site/runtime.html +1 -1
  51. package/docs-site/scope.html +1 -1
  52. package/docs-site/security.html +1 -1
  53. package/docs-site/team.html +1 -1
  54. package/docs-site/whats-new.html +23 -1
  55. package/docs-site/workflows.html +1 -1
  56. package/evals/private-holdout-v1/public-exposure.v2.json +2 -2
  57. package/package.json +1 -1
  58. package/packages/piagent-core/managed/grant-share.mjs +18 -0
  59. package/packages/piagent-core/managed/helper-roles.mjs +8 -4
  60. package/packages/piagent-core/managed/helper-run.mjs +116 -0
  61. package/packages/piagent-core/managed/member-questions.d.mts +15 -0
  62. package/packages/piagent-core/managed/member-questions.mjs +178 -0
  63. package/packages/piagent-core/managed/objections.mjs +73 -0
  64. package/packages/piagent-core/managed/patch-snapshot.mjs +108 -22
  65. package/packages/piagent-core/managed/request-stream.mjs +54 -7
  66. package/packages/piagent-core/managed/review-snapshot-worker.mjs +31 -13
  67. package/packages/piagent-core/managed/session.mjs +37 -89
  68. package/packages/piagent-core/managed/web-fetch.mjs +5 -0
  69. package/packages/piagent-core/managed/web-search.mjs +23 -2
  70. package/packages/piagent-core/managed/workflow.mjs +137 -51
  71. package/packages/piagent-core/package.json +1 -1
  72. package/packages/piagent-core/runtime/managed-failure.mjs +6 -2
  73. package/packages/piagent-webui/dist/client/assets/{MarkdownMessage-BAeuN-Z9.js → MarkdownMessage-CnSRvuah.js} +1 -1
  74. package/packages/piagent-webui/dist/client/assets/{index-DgSmcnP8.css → index-Z91z6SlN.css} +1 -1
  75. package/packages/piagent-webui/dist/client/assets/{index-ClXiZwIU.js → index-yg9s8sA_.js} +14 -14
  76. package/packages/piagent-webui/dist/client/index.html +2 -2
  77. package/packages/piagent-webui/gateway/gateway-service.ts +7 -0
  78. package/packages/piagent-webui/gateway/managed-projection.ts +1 -1
  79. package/packages/piagent-webui/gateway/session-questions.ts +15 -0
  80. package/packages/piagent-webui/gateway/session-runtime-supervisor.ts +7 -2
  81. package/packages/piagent-webui/server/loopback-server.ts +29 -0
  82. package/packages/piagent-webui/server/transcript-projection.ts +17 -4
  83. package/schemas/piagent-webui/session-catalog-v1.schema.json +1 -1
  84. package/schemas/piagent-webui/transcript-v1.schema.json +29 -2
@@ -0,0 +1,178 @@
1
+ // Questions the main agent asks the member when the request leaves a decision
2
+ // open: 1–4 questions, each with 2–4 numbered options, and always "Other" in
3
+ // the member's own words. The agent waits for the answer. In the WebUI the
4
+ // question is a card answered there (the Gateway binds a surface for each
5
+ // conversation it runs); in the Terminal it is a selector; with no member at
6
+ // a screen the agent is told to go on with a stated assumption.
7
+ const KEY = Symbol.for('piagent.memberQuestions');
8
+ const REF = /^question\.[0-9a-f-]{36}$/;
9
+ // A waiting question is a step of the running turn: the WebUI's operation
10
+ // watchdog hears from it this often, so a member who answers late still can.
11
+ const WAITING_PULSE_MS = 60_000;
12
+ // What the agent reads when the member stopped the turn (or the screen went
13
+ // away) before answering: the decisions stay theirs.
14
+ const CANCELLED = 'managed-question-cancelled: the member stopped before answering. These decisions are still open: when the member continues, ask again (unless their new message settles them); do not choose for them.';
15
+ const text = (value, max) => typeof value === 'string' ? value.replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, ' ').trim().slice(0, max) : '';
16
+
17
+ // The questions as asked, or null when they do not have the expected shape.
18
+ export function normalizeQuestions(raw) {
19
+ const list = Array.isArray(raw) ? raw : null;
20
+ if (!list || list.length < 1 || list.length > 4) return null;
21
+ const out = [];
22
+ for (const item of list) {
23
+ const question = text(item?.question, 400), header = text(item?.header, 24);
24
+ const options = Array.isArray(item?.options) ? item.options.map(o => ({ label: text(o?.label, 80), description: text(o?.description, 200) })) : [];
25
+ if (!question || options.length < 2 || options.length > 4 || options.some(o => !o.label) || new Set(options.map(o => o.label.toLowerCase())).size !== options.length) return null;
26
+ out.push({ header, question, options, multiSelect: item?.multiSelect === true });
27
+ }
28
+ return out;
29
+ }
30
+
31
+ // The member's answer, checked against the questions: one entry per question
32
+ // with the chosen option numbers (0-based) and/or their own words; or skipped
33
+ // (the member leaves the decision to the agent). Null when it does not fit.
34
+ export function normalizeAnswer(questions, raw) {
35
+ if (raw?.skipped === true) return { skipped: true, answers: [] };
36
+ const list = Array.isArray(raw?.answers) ? raw.answers : null;
37
+ if (!list || list.length !== questions.length) return null;
38
+ const answers = [];
39
+ for (const [i, item] of list.entries()) {
40
+ const q = questions[i], other = text(item?.other, 1000) || null;
41
+ const selected = Array.isArray(item?.selected) ? [...new Set(item.selected)] : [];
42
+ if (selected.some(n => !Number.isSafeInteger(n) || n < 0 || n >= q.options.length)) return null;
43
+ if (!q.multiSelect && selected.length + (other ? 1 : 0) > 1) return null;
44
+ if (!selected.length && !other) return null;
45
+ answers.push({ selected: selected.sort((a, b) => a - b), other });
46
+ }
47
+ return { skipped: false, answers };
48
+ }
49
+
50
+ // What the agent reads back.
51
+ export function answerText(questions, answer) {
52
+ if (answer.skipped) return 'The member did not choose and leaves these decisions to you. Make the choice you judge best, say in one line which one and why, and go on.';
53
+ return ['The member answered:', ...questions.flatMap((q, i) => {
54
+ const a = answer.answers[i];
55
+ return [`${i + 1}. ${q.header ? `[${q.header}] ` : ''}${q.question}`,
56
+ ...a.selected.map(n => ` → ${n + 1}. ${q.options[n].label}`), ...(a.other ? [` → Other: ${a.other}`] : [])];
57
+ })].join('\n');
58
+ }
59
+
60
+ class MemberQuestions {
61
+ pulseMs = WAITING_PULSE_MS;
62
+ #surfaces = new Map();
63
+ #listeners = new Map();
64
+ #pending = new Map();
65
+
66
+ // A screen that can show this conversation's questions (the Gateway, per
67
+ // conversation it runs). `onEvent` hears each question asked, waiting,
68
+ // answered or withdrawn.
69
+ bindSurface(sessionId, onEvent) {
70
+ const key = String(sessionId);
71
+ this.#surfaces.set(key, (this.#surfaces.get(key) ?? 0) + 1);
72
+ const listeners = this.#listeners.get(key) ?? new Set(); listeners.add(onEvent); this.#listeners.set(key, listeners);
73
+ let bound = true;
74
+ return () => {
75
+ if (!bound) return; bound = false;
76
+ const left = (this.#surfaces.get(key) ?? 1) - 1;
77
+ if (left > 0) this.#surfaces.set(key, left); else this.#surfaces.delete(key);
78
+ // No screen is left to answer: a waiting question is withdrawn.
79
+ if (left <= 0) for (const entry of [...this.#pending.values()]) if (entry.sessionId === key) this.#settle(entry, null, 'surface-closed');
80
+ listeners.delete(onEvent);
81
+ };
82
+ }
83
+ hasSurface(sessionId) { return (this.#surfaces.get(String(sessionId)) ?? 0) > 0; }
84
+ #emit(sessionId, event) { for (const listener of [...(this.#listeners.get(sessionId) ?? [])]) { try { listener(event); } catch { /* a listener's failure is its own */ } } }
85
+
86
+ // Resolves with the normalized answer; rejects 'managed-question-cancelled'
87
+ // when the member stops the turn or the screen goes away.
88
+ ask({ sessionId, toolCallId, questions, signal }) {
89
+ const key = String(sessionId), ref = `question.${crypto.randomUUID()}`;
90
+ return new Promise((resolve, reject) => {
91
+ if (signal?.aborted) return reject(Error('managed-question-cancelled'));
92
+ const entry = { ref, sessionId: key, toolCallId: String(toolCallId ?? ''), questions, askedAt: new Date().toISOString(), resolve, reject, signal };
93
+ entry.onAbort = () => this.#settle(entry, null, 'stopped');
94
+ signal?.addEventListener('abort', entry.onAbort, { once: true });
95
+ entry.pulse = setInterval(() => this.#emit(key, { type: 'waiting', ref }), this.pulseMs);
96
+ this.#pending.set(ref, entry);
97
+ this.#emit(key, { type: 'asked', ref });
98
+ });
99
+ }
100
+ #settle(entry, answer, reason) {
101
+ if (!this.#pending.delete(entry.ref)) return;
102
+ clearInterval(entry.pulse); entry.signal?.removeEventListener('abort', entry.onAbort);
103
+ this.#emit(entry.sessionId, { type: answer ? 'answered' : 'withdrawn', ref: entry.ref, reason });
104
+ if (answer) entry.resolve(answer); else entry.reject(Error('managed-question-cancelled'));
105
+ }
106
+
107
+ // The questions waiting for this conversation's member, oldest first.
108
+ pending(sessionId) {
109
+ const key = String(sessionId);
110
+ return [...this.#pending.values()].filter(e => e.sessionId === key)
111
+ .map(e => ({ questionRef: e.ref, askedAt: e.askedAt, questions: structuredClone(e.questions) }));
112
+ }
113
+ answer(sessionId, questionRef, raw) {
114
+ const entry = REF.test(String(questionRef)) ? this.#pending.get(questionRef) : null;
115
+ if (!entry || entry.sessionId !== String(sessionId)) throw Error('question-not-pending');
116
+ const answer = normalizeAnswer(entry.questions, raw);
117
+ if (!answer) throw Error('question-answer-invalid');
118
+ this.#settle(entry, answer, 'answered');
119
+ return { questionRef, state: 'answered' };
120
+ }
121
+ }
122
+ export const memberQuestions = globalThis[KEY] ??= new MemberQuestions();
123
+
124
+ // In the Terminal: each question as a selector of its numbered options, then
125
+ // "Other" (typed) and "Let the agent decide". Esc leaves it to the agent.
126
+ async function askInTerminal(ui, questions, signal) {
127
+ const answers = [];
128
+ for (const [i, q] of questions.entries()) {
129
+ const title = `${questions.length > 1 ? `(${i + 1}/${questions.length}) ` : ''}${q.header ? `${q.header}: ` : ''}${q.question}`;
130
+ const items = q.options.map((o, n) => `${n + 1}. ${o.label}${o.description ? ` — ${o.description}` : ''}`);
131
+ const other = `${q.options.length + 1}. Khác (tự nhập)`, skip = 'Để agent tự quyết', done = 'Xong';
132
+ const selected = [];
133
+ for (;;) {
134
+ const choices = q.multiSelect ? [...items.filter((_, n) => !selected.includes(n)), other, ...(selected.length ? [done] : [skip])] : [...items, other, skip];
135
+ const picked = await ui.select(q.multiSelect && selected.length ? `${title}\nĐã chọn: ${selected.map(n => n + 1).join(', ')}` : title, choices, { signal });
136
+ if (signal?.aborted) throw Error('managed-question-cancelled');
137
+ if (picked === undefined || picked === skip) return { skipped: true, answers: [] };
138
+ if (picked === done) { answers.push({ selected, other: null }); break; }
139
+ if (picked === other) {
140
+ const typed = await ui.input(title, 'Câu trả lời của bạn', { signal });
141
+ if (signal?.aborted) throw Error('managed-question-cancelled');
142
+ if (typed?.trim()) { answers.push({ selected, other: typed.trim() }); break; }
143
+ continue;
144
+ }
145
+ const n = items.indexOf(picked);
146
+ if (!q.multiSelect) { answers.push({ selected: [n], other: null }); break; }
147
+ selected.push(n);
148
+ }
149
+ }
150
+ return normalizeAnswer(questions, { answers }) ?? { skipped: true, answers: [] };
151
+ }
152
+
153
+ const QUESTION_SCHEMA = { type: 'object', properties: {
154
+ header: { type: 'string', maxLength: 24, description: 'A short label for the question, 1–3 words (e.g. "Phạm vi", "Dữ liệu").' },
155
+ question: { type: 'string', minLength: 1, maxLength: 400, description: 'One clear question in the member\'s language, ending with a question mark.' },
156
+ options: { type: 'array', minItems: 2, maxItems: 4, description: 'The real choices, most likely first. "Other" (the member\'s own words) is always added for the member: never add an "Other" option yourself.',
157
+ items: { type: 'object', properties: { label: { type: 'string', minLength: 1, maxLength: 80 }, description: { type: 'string', maxLength: 200, description: 'What choosing it means for the work.' } }, required: ['label'], additionalProperties: false } },
158
+ multiSelect: { type: 'boolean', description: 'True when several options may be chosen together.' } },
159
+ required: ['question', 'options'], additionalProperties: false };
160
+
161
+ // The tool of the main agent. `managed` is the ManagedSession.
162
+ export function askTool(managed) {
163
+ return { name: 'ask_user', label: 'Hỏi thành viên',
164
+ description: 'Ask the member 1–4 questions and wait for the answers, when the request leaves a decision open that changes the result and the code, the docs or a subagent cannot settle it: scope, expected behaviour, data shape, a trade-off, a name the member will see. Ask before acting on a guess, all questions in one call, each with 2–4 concrete numbered options (most likely first); the member can always answer in their own words or leave the choice to you. Do not ask what you can find out yourself, to confirm what the member already said, or for permission to do what was asked.',
165
+ parameters: { type: 'object', properties: { questions: { type: 'array', minItems: 1, maxItems: 4, items: QUESTION_SCHEMA } }, required: ['questions'], additionalProperties: false },
166
+ execute: async (toolCallId, args, signal, _onUpdate, ctx) => {
167
+ const questions = normalizeQuestions(args?.questions);
168
+ if (!questions) throw Error('managed-question-invalid: give 1–4 questions, each with a question and 2–4 options with distinct labels.');
169
+ const sessionId = managed.session?.sessionManager?.getSessionId?.();
170
+ let answer;
171
+ const cancelled = (error) => { throw error?.message === 'managed-question-cancelled' ? Error(CANCELLED) : error; };
172
+ if (sessionId && memberQuestions.hasSurface(sessionId)) answer = await memberQuestions.ask({ sessionId, toolCallId, questions, signal }).catch(cancelled);
173
+ else if (ctx?.hasUI && ctx.ui?.select && !ctx.ui[Symbol.for('piagent.webui.gateway-runtime-ui.v1')]) answer = await askInTerminal(ctx.ui, questions, signal).catch(cancelled);
174
+ else return { content: [{ type: 'text', text: 'No member can answer right now (this conversation has no screen to ask on). Go on with the most reasonable choice for each question, state the assumption plainly in your answer, and keep it easy to change.' }],
175
+ details: { questions, answer: null, surface: 'none' } };
176
+ return { content: [{ type: 'text', text: answerText(questions, answer) }], details: { questions, answer } };
177
+ } };
178
+ }
@@ -0,0 +1,73 @@
1
+ import { briefIssues, currentPlan, planText, verifyVerdicts } from './workflow.mjs';
2
+
3
+ // A helper may object to the brief it was given, with evidence. It never
4
+ // decides and never does other work instead: the main agent decides and has
5
+ // to answer the objection (a corrected brief, another approach, or why the
6
+ // objection does not hold). The same helper objecting a second time in a run
7
+ // stops the exchange, and the member decides.
8
+
9
+ // The member's own words for a helper: the current message and, while a
10
+ // message only continues earlier work ("tiếp tục", a short follow-up), the
11
+ // ones before it, back to one that states a task. Newer words are kept first.
12
+ export const FOLLOW_UP_CHARS = 160;
13
+ const REQUEST_CHARS = 6000;
14
+ export function memberRequest(texts) {
15
+ const words = texts.map(text => String(text ?? '').trim()).filter(Boolean), picked = words.length ? [words.at(-1)] : [];
16
+ for (let i = words.length - 2; i >= 0 && picked.length < 5 && picked[0].length < FOLLOW_UP_CHARS; i -= 1) picked.unshift(words[i]);
17
+ const kept = [];
18
+ let room = REQUEST_CHARS;
19
+ for (const text of picked.reverse()) { if (room <= 0) break; kept.unshift(text.slice(0, room)); room -= text.length; }
20
+ return kept;
21
+ }
22
+ const userText = message => typeof message.content === 'string' ? message.content : (message.content ?? []).filter(p => p?.type === 'text').map(p => p.text).join('\n');
23
+ export const memberMessages = manager => manager.getEntries().filter(e => e.type === 'message' && e.message?.role === 'user').map(e => userText(e.message));
24
+
25
+ // What a helper reads before its brief: the member's request verbatim and the
26
+ // plan, so a brief that drifted from the request can be seen to drift.
27
+ export function anchorText(manager, harness) {
28
+ const texts = memberRequest(memberMessages(manager)), plan = planText(currentPlan(manager));
29
+ if (!texts.length) return '';
30
+ const words = texts.length === 1 ? texts[0] : texts.map((text, i) => `${i === texts.length - 1 ? 'Current message' : 'Earlier message'}:\n${text}`).join('\n\n');
31
+ const from = harness ? 'the harness' : 'the main agent';
32
+ return `The member's request, verbatim (${from} wrote the brief below from it):\n<<<\n${words}\n>>>\n${plan ? `\nCurrent plan:\n${plan}\n` : ''}\nBrief from ${from}:\n`;
33
+ }
34
+
35
+ // Before a helper starts: once a helper objected twice in a run the member
36
+ // decides, so the main agent cannot send it another brief before then. A
37
+ // new brief to a helper that objected answers that objection.
38
+ export function beforeDelegate(run, role, harness) {
39
+ if (!run || harness) return;
40
+ if (run.disputes.some(d => d.source === role && d.brief)) throw Error(`managed-helper-disputed: the ${role} subagent objected to your briefs twice for this message, so the member decides. Tell them what it objects to and what you decided; do not delegate to it again before their next message.`);
41
+ for (const objection of run.objections) if (objection.role === role) objection.answered = true;
42
+ }
43
+
44
+ const issueText = (issue, i) => `${i + 1}. [${issue.kind}] ${issue.detail}${issue.evidence ? ` (evidence: ${issue.evidence})` : ''}`;
45
+ // After a helper answered: an objection to the main agent's brief is
46
+ // recorded, shown to the member and handed to the main agent with the answer.
47
+ // Returns the note the main agent reads under the helper's answer ('' when
48
+ // there is none). The harness's own briefs (its review, its re-check) are
49
+ // fixed text: an objection to one only restates the member's request, so
50
+ // it is not relayed.
51
+ export async function relayObjection(managed, role, reply, harness) {
52
+ const run = managed.run, issues = harness ? null : briefIssues(reply);
53
+ if (!run || !issues?.length) return '';
54
+ const second = run.objections.some(o => o.role === role), list = issues.map(issueText).join('\n');
55
+ run.objections.push({ role, issues, answered: false, disputed: second });
56
+ if (second) run.disputes.push({ source: role, brief: true });
57
+ await managed.session.sendCustomMessage({ customType: 'agent-watch-process', display: true,
58
+ content: second ? `Harness: the ${role} subagent objected to a brief a second time for this message; the exchange stops and the member decides.\n${list}`
59
+ : `Harness: the ${role} subagent objected to the brief the main agent gave it:\n${list}`,
60
+ details: { phase: second ? 'dispute' : 'objection', role, by: 'main', issues: issues.map(({ kind, detail }) => ({ kind, detail })) } }, { triggerTurn: false });
61
+ return second ? `\n\n[Harness] The ${role} subagent objected to your brief again. This is its second objection for this message, so the harness ends the exchange: do not delegate to it again before the member's next message. Tell the member plainly what it objects to and what you decided, and let them decide.`
62
+ : `\n\n[Harness] The ${role} subagent objected to your brief (see brief_issues above). You decide, but answer the objection before you act on this answer: send it a corrected brief, change your approach, or tell the member why the objection does not hold. A second objection from it for this message goes to the member.`;
63
+ }
64
+
65
+ // Verify's verdicts, counted for the receipt. Under a required verify the
66
+ // failed claims are the run's to settle; once the agent has been sent back
67
+ // with them, only the harness's own re-check replaces them.
68
+ export function recordVerdicts(run, reply, harness, details) {
69
+ const found = verifyVerdicts(reply);
70
+ details.verdicts = found ? Object.fromEntries(['pass', 'fail', 'unverifiable'].map(s => [s, found.filter(v => v.status === s).length])) : null;
71
+ if (run && found && (harness || !run.claimsAsked)) run.verifyFails = found.filter(v => v.status === 'fail');
72
+ return found ? ' · ' + Object.entries(details.verdicts).filter(([, n]) => n).map(([s, n]) => `${n} ${s}`).join(', ') : '';
73
+ }
@@ -1,32 +1,46 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
1
3
  import { createHash } from 'node:crypto';
2
4
  import { fileURLToPath } from 'node:url';
3
5
 
4
6
  const quote = value => "'" + value.replaceAll("'", "'\"'\"'") + "'";
5
7
  const textContent = result => result.content?.filter(p => p.type === 'text').map(p => p.text).join('\n') ?? '';
8
+ const WORKER = fileURLToPath(new URL('./review-snapshot-worker.mjs', import.meta.url));
9
+ const MAX_SNAPSHOT = 16 * 1024 * 1024;
6
10
 
7
- // The patch a reviewer sees (diff against HEAD plus untracked files), read
8
- // inside the tool boundary. It comes in parts: the shell tool truncates its
9
- // output well below the 120 KB a review may cover. Every part names the
10
- // snapshot's digest, so parts of a patch that changed meanwhile never mix.
11
+ // The worker runs inside the tool boundary; what it prints is one short line.
12
+ async function worker(boundary, mode) {
13
+ const result = await boundary.invoke('bash', { command: `${quote(process.execPath)} ${quote(WORKER)} ${mode}`, timeout: 60 });
14
+ const text = textContent(result).trim();
15
+ if (result.details?.truncation?.truncated || text.includes('\n')) throw Error(/review-(patch|file|untracked)-limit/.exec(text)?.[0] ?? 'managed-review-patch-unreadable');
16
+ const [digest, bytes, name] = text.split(' ');
17
+ if (!/^[a-f0-9]{64}$/.test(digest ?? '') || !(Number(bytes) > 0 && Number(bytes) <= MAX_SNAPSHOT)) throw Error(/review-(patch|file|untracked)-limit/.exec(text)?.[0] ?? 'managed-review-patch-unreadable');
18
+ return { digest, bytes: Number(bytes), name };
19
+ }
20
+
21
+ // The digest of the patch a reviewer would see (diff against HEAD plus
22
+ // untracked files): whether a review is still current. Nothing else moves.
23
+ export async function readPatchDigest(boundary) { return (await worker(boundary, 'digest')).digest; }
24
+
25
+ // The patch a reviewer sees, read inside the tool boundary. The worker writes
26
+ // it to the boundary's temporary directory and names it, with its digest, on
27
+ // its output; the file is read once, must match that digest (a file changed
28
+ // meanwhile never passes) and is removed.
11
29
  export async function readPatchSnapshot(boundary) {
12
- const worker = fileURLToPath(new URL('./review-snapshot-worker.mjs', import.meta.url));
13
- const read = async part => {
14
- const result = await boundary.invoke('bash', { command: `${quote(process.execPath)} ${quote(worker)} ${part}`, timeout: 20 });
15
- if (result.details?.truncation?.truncated) throw Error('managed-review-patch-too-large');
16
- const text = textContent(result).trim(), newline = text.indexOf('\n');
17
- const [digest, parts] = (newline < 0 ? text : text.slice(0, newline)).split(' ');
18
- if (!/^[a-f0-9]{64}$/.test(digest ?? '') || !(Number(parts) >= 1 && Number(parts) <= 8)) throw Error('managed-review-patch-unreadable');
19
- return { digest, parts: Number(parts), bytes: Buffer.from(newline < 0 ? '' : text.slice(newline + 1), 'base64') };
20
- };
21
- const first = await read(0), chunks = [first.bytes];
22
- for (let part = 1; part < first.parts; part += 1) {
23
- const next = await read(part);
24
- if (next.digest !== first.digest) throw Error('managed-review-patch-changed');
25
- chunks.push(next.bytes);
26
- }
27
- const patch = Buffer.concat(chunks).toString('utf8');
28
- if (createHash('sha256').update(patch).digest('hex') !== first.digest) throw Error('managed-review-patch-changed');
29
- return { patch, digest: first.digest };
30
+ const { digest, bytes, name } = await worker(boundary, 'file');
31
+ if (!/^review-[0-9a-f-]{36}\.json$/.test(name ?? '')) throw Error('managed-review-patch-unreadable');
32
+ const file = path.join(boundary.temporary, name);
33
+ let data;
34
+ try {
35
+ const fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
36
+ try {
37
+ if (!fs.fstatSync(fd).isFile() || fs.fstatSync(fd).size !== bytes) throw Error('managed-review-patch-changed');
38
+ data = Buffer.alloc(bytes);
39
+ for (let at = 0; at < bytes;) { const n = fs.readSync(fd, data, at, bytes - at, at); if (!n) throw Error('managed-review-patch-changed'); at += n; }
40
+ } finally { fs.closeSync(fd); }
41
+ } finally { fs.rmSync(file, { force: true }); }
42
+ if (createHash('sha256').update(data).digest('hex') !== digest) throw Error('managed-review-patch-changed');
43
+ return { patch: data.toString('utf8'), digest };
30
44
  }
31
45
 
32
46
  // Why a required review could not run, in one word for the member.
@@ -43,6 +57,78 @@ export function reviewText(snapshot) {
43
57
  let value;
44
58
  try { value = JSON.parse(snapshot.patch); } catch { return `Patch snapshot ${snapshot.digest}:\n${snapshot.patch}`; }
45
59
  const files = (value.untracked ?? []).map(f => f.mode === 'symlink' ? `=== new symlink ${f.path} -> ${f.target}`
60
+ : f.mode === 'directory' ? `=== new folder ${f.path} (a repository of its own, not reviewed)`
61
+ : f.encoding === 'omitted' ? `=== new file ${f.path} (${f.bytes} bytes, too large to review)`
46
62
  : f.encoding === 'utf8' ? `=== new file ${f.path}\n${f.contents}` : `=== new binary file ${f.path} (${Math.round(f.contents.length * 0.75)} bytes)`);
47
63
  return `Patch snapshot ${snapshot.digest}:\n--- diff against HEAD ---\n${value.patch || '(no changes to tracked files)'}\n${files.length ? `--- new files ---\n${files.join('\n\n')}` : ''}`;
48
64
  }
65
+
66
+ // A patch larger than one reviewer reads at once (REVIEW_PART_BYTES, about
67
+ // 30k tokens with room to read the code around it) is reviewed in parts of
68
+ // whole files under the review helper's grant, and the findings are merged.
69
+ // The parts run one after another (REVIEW_PARALLEL): Studio admits one
70
+ // request of a run role at a time, so parts side by side only wait in its
71
+ // line (measured 2026-10-05: 4 parts, each started as the previous ended). One file's diff larger than READ_SECTION is not
72
+ // read (a lockfile, generated output): every part and the merged answer name
73
+ // it. More than REVIEW_PARTS_MAX parts: the review cannot run (too_large).
74
+ export const REVIEW_PART_BYTES = 120_000, REVIEW_PARALLEL = 1, REVIEW_PARTS_MAX = 96;
75
+ const READ_SECTION = 400_000;
76
+ const SEVERITY = { blocking: 0, major: 1, minor: 2 };
77
+
78
+ export function reviewParts(snapshot, size = REVIEW_PART_BYTES) {
79
+ const whole = reviewText(snapshot);
80
+ let value;
81
+ try { value = JSON.parse(snapshot.patch); } catch { value = null; }
82
+ const omitted = (value?.untracked ?? []).filter(f => f.encoding === 'omitted').map(f => ({ path: f.path, bytes: f.bytes, ...(f.mode === 'directory' ? { repository: true } : {}) }));
83
+ if (!value || Buffer.byteLength(whole) <= size && !omitted.length) return { parts: [{ files: null, text: whole }], omitted: [] };
84
+ const sections = [];
85
+ for (const text of String(value.patch ?? '').split(/(?=^diff --git )/m).filter(Boolean)) {
86
+ const file = /^diff --git (?:"?a\/(.+?)"? )"?b\/(.+?)"?$/m.exec(text)?.[2] ?? text.slice(0, 200).split('\n')[0];
87
+ sections.push({ file, kind: 'diff', text, bytes: Buffer.byteLength(text) });
88
+ }
89
+ for (const f of value.untracked ?? []) {
90
+ if (f.encoding === 'omitted') continue;
91
+ const text = f.mode === 'symlink' ? `=== new symlink ${f.path} -> ${f.target}` : f.encoding === 'utf8' ? `=== new file ${f.path}\n${f.contents}` : `=== new binary file ${f.path} (${Math.round(f.contents.length * 0.75)} bytes)`;
92
+ sections.push({ file: f.path, kind: 'new', text, bytes: Buffer.byteLength(text) });
93
+ }
94
+ const groups = [];
95
+ for (const section of sections) {
96
+ if (section.bytes > READ_SECTION) { omitted.push({ path: section.file, bytes: section.bytes }); continue; }
97
+ const last = groups.at(-1);
98
+ if (last && last.bytes + section.bytes <= size) { last.sections.push(section); last.bytes += section.bytes; }
99
+ else groups.push({ sections: [section], bytes: section.bytes });
100
+ }
101
+ if (groups.length > REVIEW_PARTS_MAX) throw Error('managed-review-patch-too-large');
102
+ const skipped = omitted.length ? `\nNot read: ${omitted.map(o => o.repository ? `${o.path} (a repository of its own)` : `${o.path} (${o.bytes} bytes, too large to review)`).join(', ')}.` : '';
103
+ const parts = groups.map((group, i) => {
104
+ const files = group.sections.map(s => s.file), diff = group.sections.filter(s => s.kind === 'diff').map(s => s.text).join(''), news = group.sections.filter(s => s.kind === 'new').map(s => s.text);
105
+ const head = groups.length === 1 ? `Patch snapshot ${snapshot.digest}.` : `Patch snapshot ${snapshot.digest}, part ${i + 1} of ${groups.length}. This part's files: ${files.join(', ')}. The other parts are reviewed separately: judge this part, and read other files only for context.`;
106
+ return { files, text: `${head}${skipped}\n--- diff against HEAD ---\n${diff || '(no changes to tracked files in this part)'}\n${news.length ? `--- new files ---\n${news.join('\n\n')}` : ''}` };
107
+ });
108
+ return { parts: parts.length ? parts : [{ files: [], text: `Patch snapshot ${snapshot.digest}: every changed file is too large to review.${skipped}` }], omitted };
109
+ }
110
+
111
+ // One answer from the review of every part: each part's summary, then the
112
+ // findings of all parts (most severe first) and their objections to the brief
113
+ // in one JSON block. `parsed`: every part gave its JSON.
114
+ export function mergeReviews(review, answers, { findingsOf, issuesOf }) {
115
+ const n = answers.length, all = [], issues = [], lines = [];
116
+ let parsed = true;
117
+ answers.forEach((answer, i) => {
118
+ const findings = findingsOf(answer.reply);
119
+ if (!findings) parsed = false;
120
+ all.push(...(findings ?? []));
121
+ issues.push(...(issuesOf(answer.reply) ?? []));
122
+ let summary = null;
123
+ for (const raw of [...String(answer.reply).matchAll(/```json\s*([\s\S]*?)```/g)].map(m => m[1]).reverse()) { try { summary = JSON.parse(raw)?.summary ?? null; if (summary) break; } catch { /* not this block */ } }
124
+ const files = review.parts[i].files ?? [];
125
+ lines.push(`Part ${i + 1}/${n} (${files.length} file${files.length === 1 ? '' : 's'}: ${files.slice(0, 6).join(', ')}${files.length > 6 ? ', …' : ''}): ${findings ? `${findings.length} finding(s). ${typeof summary === 'string' ? summary.slice(0, 300) : ''}` : `no JSON findings; its answer began: ${String(answer.reply).slice(0, 400)}`}`);
126
+ });
127
+ all.sort((a, b) => SEVERITY[a.severity] - SEVERITY[b.severity]);
128
+ const count = severity => all.filter(f => f.severity === severity).length;
129
+ const summary = `${n} parts reviewed: ${count('blocking')} blocking, ${count('major')} major, ${count('minor')} minor${parsed ? '' : '; a part gave no JSON findings'}${review.omitted.length ? `; ${review.omitted.length} file(s) too large to review` : ''}.`;
130
+ const json = JSON.stringify({ findings: all.slice(0, 40), brief_issues: issues.slice(0, 5), summary });
131
+ const head = `Review of the patch in ${n} parts.${review.omitted.length ? ` Not read, too large: ${review.omitted.map(o => o.path).join(', ')}.` : ''}\n`;
132
+ const body = lines.join('\n').slice(0, Math.max(0, 16000 - json.length - head.length - 20));
133
+ return { reply: `${head}${body}\n\`\`\`json\n${json}\n\`\`\``, usage: answers.reduce((total, a) => total + a.usage, 0), findings: all, parsed, blocking: count('blocking') };
134
+ }
@@ -8,9 +8,13 @@ import { randomUUID } from 'node:crypto';
8
8
  import { setTimeout as sleep } from 'node:timers/promises';
9
9
  import { describeFailure, failureCode, failureIsAdmissionRefusal, failureIsBriefRefusal, failureReason, unnamedFailureText } from '../runtime/managed-failure.mjs';
10
10
  import { CLIENT_AGENT } from './client-agent.mjs';
11
+ import { shareGrant, releaseGrant } from './grant-share.mjs';
11
12
 
12
13
  // How long one request waits in line for a free company model account.
13
14
  const CAPACITY_WAIT_MS = 20 * 60_000;
15
+ // How long a helper's request waits before the helper moves to another model.
16
+ export const HELPER_SWITCH_MS = 60_000;
17
+ const HELPER_SWITCH_CODES = new Set(['session_account_not_ready_retry_later', 'account_capacity_unavailable']);
14
18
  // A brief refusal (see failureIsBriefRefusal) is asked again up to three times,
15
19
  // 3, 6 and 9 s apart.
16
20
  const BRIEF_ASKS = 3, BRIEF_PAUSE_MS = 3_000;
@@ -23,7 +27,8 @@ export function wrapRoleStreams(owner, runtime, role, { provider, verifyGrant })
23
27
  if (role === 'main') owner.mainAnswers = (owner.mainAnswers ?? 0) + 1;
24
28
  const out = owner.ai.createAssistantMessageEventStream(), signal = options?.signal;
25
29
  void (async () => {
26
- let wait = null, noted = false, brief = 0, refused = null;
30
+ let wait = null, noted = false, brief = 0, refused = null, rebinds = 0, share = null;
31
+ const letGo = () => { if (share) { releaseGrant(owner, role, share); share = null; } };
27
32
  // The error event and the final message carry the same failure: kept once.
28
33
  const unnamed = raw => { noted ||= keepUnnamed(owner, role, raw); return raw; };
29
34
  try {
@@ -39,14 +44,22 @@ export function wrapRoleStreams(owner, runtime, role, { provider, verifyGrant })
39
44
  for (;;) {
40
45
  // Asked again after a refusal and the grant cannot be renewed (the
41
46
  // run was closed): the refusal says why, not the renewal.
42
- const grant = await owner.broker.request('renew', { role }).catch((error) => { throw refused ? Error(refused) : error; });
47
+ // Requests of one role running side by side (a review in parts, a
48
+ // helper's search beside its call) share one renew: each renew
49
+ // replaces the role's token and would void a sibling's before
50
+ // Studio admits it. The share is let go once Studio has answered.
51
+ share = shareGrant(owner, role);
52
+ const grant = await share.grant.catch((error) => { throw refused ? Error(refused) : error; });
43
53
  verifyGrant(grant, owner.manifest, role);
44
54
  if (role === 'main') owner.grant = grant;
45
55
  const route = owner.routes.get(runtime);
46
56
  if (!route || grant.provider_model_id !== route.native || grant.provider !== route.provider || grant.model_id !== route.model_id || model.id !== 'agent-watch-auto') throw Error('managed-route-changed');
47
57
  const asked = Date.now();
48
- const stream = original({ ...model, id: route.native }, context, { ...options, apiKey: grant.token, sessionId: grant.role_id, maxRetries: 0,
49
- headers: { ...options?.headers, 'User-Agent': CLIENT_AGENT, 'X-Session-Id': grant.role_id } });
58
+ // The main agent names its conversation, so every turn reaches the
59
+ // same company account and prompt cache; a helper names its role.
60
+ const conversation = role === 'main' ? conversationSession(owner) : null;
61
+ const stream = original({ ...model, id: route.native }, context, { ...options, apiKey: grant.token, sessionId: conversation ?? grant.role_id, maxRetries: 0,
62
+ headers: { ...options?.headers, 'User-Agent': CLIENT_AGENT, 'X-Session-Id': grant.role_id, ...(conversation ? { 'X-Claude-Code-Session-Id': conversation } : {}) } });
50
63
  // Whether Studio admitted the request is known at its first event
51
64
  // after "start": until then nothing is passed on, so a refused
52
65
  // request leaves no trace in the conversation.
@@ -54,12 +67,27 @@ export function wrapRoleStreams(owner, runtime, role, { provider, verifyGrant })
54
67
  for await (const event of stream) {
55
68
  if (opening && event.type === 'start') { opening.push(event); continue; }
56
69
  const code = event.type === 'error' ? failureCode(event.error?.errorMessage) : null;
57
- if (opening && code && !signal?.aborted && (failureIsAdmissionRefusal(code) || failureIsBriefRefusal(code) && brief < BRIEF_ASKS)) { refusal = event.error; break; }
58
- if (opening) { for (const held of opening) out.push(held); opening = null; wait = capacityWaitEnded(owner, wait, 'admitted'); }
70
+ if (opening && code && !signal?.aborted && (failureIsAdmissionRefusal(code) || failureIsBriefRefusal(code) && brief < BRIEF_ASKS
71
+ || role === 'main' && code === ACCOUNT_GONE && rebinds < MAX_REBINDS)) { refusal = event.error; break; }
72
+ if (opening) { for (const held of opening) out.push(held); opening = null; wait = capacityWaitEnded(owner, wait, 'admitted'); letGo(); }
59
73
  out.push(event.type === 'error' ? { ...event, ...(signal?.aborted ? { reason: 'aborted' } : {}), error: told(event.error) } : event);
60
74
  }
61
75
  if (!refusal) { for (const held of opening ?? []) out.push(held); out.end(told(await stream.result())); return; }
62
76
  refused = String(refusal.errorMessage ?? '');
77
+ // A lapsed or replaced grant: the next ask renews it, not this share.
78
+ const stale = share; letGo();
79
+ if (failureIsBriefRefusal(failureCode(refusal.errorMessage)) && owner.sharedGrants?.get(role) === stale) owner.sharedGrants.delete(role);
80
+ // The conversation's account is gone (signed out, removed) or has
81
+ // rested past SESSION_REBIND_MS (a spent quota window): a new
82
+ // generation lets Studio bind the conversation to another account.
83
+ // Its prompt cache starts over there once.
84
+ const refusedCode = failureCode(refusal.errorMessage);
85
+ if (role === 'main' && rebinds < MAX_REBINDS && (refusedCode === ACCOUNT_GONE
86
+ || refusedCode === ACCOUNT_RESTING && wait && Date.now() - wait.since >= (owner.sessionRebindMs ?? SESSION_REBIND_MS))) {
87
+ owner.conversationGeneration = (owner.conversationGeneration ?? 0) + 1; rebinds += 1;
88
+ wait = capacityWaitEnded(owner, wait, 'rebound');
89
+ continue;
90
+ }
63
91
  // A brief refusal is asked again after a short pause, without a wait step.
64
92
  if (failureIsBriefRefusal(failureCode(refusal.errorMessage))) { brief += 1; await sleep((owner.briefPauseMs ?? BRIEF_PAUSE_MS) * brief, undefined, { signal }); continue; }
65
93
  wait = await waitForCapacity(owner, wait, role, failureCode(refusal.errorMessage), signal, Date.now() - asked);
@@ -72,13 +100,28 @@ export function wrapRoleStreams(owner, runtime, role, { provider, verifyGrant })
72
100
  out.push({ type: 'error', reason: aborted ? 'aborted' : 'error', error: { role: 'assistant', content: [], api: model.api, provider: provider, model: model.id,
73
101
  stopReason: aborted ? 'aborted' : 'error', errorMessage: aborted ? 'Request was aborted' : describeFailure(role, unnamed(error?.message)), timestamp: Date.now(),
74
102
  usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } } } });
75
- } finally { capacityWaitEnded(owner, wait, signal?.aborted ? 'stopped' : 'ended'); }
103
+ } finally { letGo(); capacityWaitEnded(owner, wait, signal?.aborted ? 'stopped' : 'ended'); }
76
104
  })();
77
105
  return out;
78
106
  };
79
107
  }
80
108
  }
81
109
 
110
+ // Studio binds a conversation to one company account (session_bindings): the
111
+ // provider's prompt cache lives on that account (Claude) and follows
112
+ // prompt_cache_key (Codex). The main agent therefore names its conversation
113
+ // (the Pi session id), not the turn's run role; measured 2026-10-04, a run
114
+ // role per turn left Codex 10% cached on each turn's first request.
115
+ const ACCOUNT_GONE = 'session_account_unavailable_start_new_session';
116
+ const ACCOUNT_RESTING = 'session_account_not_ready_retry_later';
117
+ export const SESSION_REBIND_MS = 90_000;
118
+ const MAX_REBINDS = 2;
119
+ export function conversationSession(owner) {
120
+ const id = owner.session?.sessionManager?.getSessionId?.();
121
+ if (typeof id !== 'string' || !/^[\w-]{8,128}$/.test(id)) return null;
122
+ return owner.conversationGeneration ? `${id}-${owner.conversationGeneration}` : id;
123
+ }
124
+
82
125
  // A failure no code names is shown as "could not be sent"; its own words are
83
126
  // kept, redacted, in the conversation file (never shown) so it can be told
84
127
  // apart later. True when it was kept.
@@ -105,6 +148,10 @@ export function capacityDelayMs(asks, heldMs, random = Math.random()) {
105
148
  // CAPACITY_WAIT_MS it fails with Studio's reason. Null: stop waiting.
106
149
  async function waitForCapacity(owner, wait, role, code, signal, heldMs = 0) {
107
150
  if (wait && Date.now() - wait.since >= (owner.capacityWaitMs ?? CAPACITY_WAIT_MS)) { capacityWaitEnded(owner, wait, 'gave-up'); return null; }
151
+ // A helper whose model has no account free for a while (resting after a
152
+ // refusal, out of usage) stops waiting: it starts once more, and Studio
153
+ // gives the new helper the next model of its role that serves.
154
+ if (wait && role !== 'main' && HELPER_SWITCH_CODES.has(code) && Date.now() - wait.since >= (owner.helperSwitchMs ?? HELPER_SWITCH_MS)) { capacityWaitEnded(owner, wait, 'switched'); return null; }
108
155
  wait ??= { id: randomUUID(), role, since: Date.now(), asks: 0, open: true };
109
156
  wait.asks += 1; wait.code = code;
110
157
  owner.capacityWaits.add(wait);
@@ -1,27 +1,45 @@
1
1
  import fs from 'node:fs';
2
- import { createHash } from 'node:crypto';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { createHash, randomUUID } from 'node:crypto';
3
5
  import { execFileSync } from 'node:child_process';
4
6
  import { managedGit } from './toolchain.mjs';
5
7
  // Executed only through the tool Seatbelt boundary; repo-local Git config cannot
6
8
  // grant a hook, textconv or credential helper access to the trusted runtime.
7
- const git = args => execFileSync(managedGit(), ['--no-pager', ...args], { encoding: 'utf8', maxBuffer: 160000, env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_CONFIG_NOSYSTEM: '1' } });
9
+ // The snapshot holds the whole patch (its digest decides whether a review is
10
+ // stale); a reviewer reads it in parts (patch-snapshot.mjs). A new file too
11
+ // large to read is held by its hash: a change to it still changes the digest.
12
+ const MAX_SNAPSHOT = 16 * 1024 * 1024, MAX_UNTRACKED = 2000, READ_FILE = 400_000, BINARY_INLINE = 120_000, HASHED_FILE = 256 * 1024 * 1024;
13
+ const git = args => execFileSync(managedGit(), ['--no-pager', ...args], { encoding: 'utf8', maxBuffer: MAX_SNAPSHOT + 1, env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_CONFIG_NOSYSTEM: '1' } });
14
+ const sha256 = data => createHash('sha256').update(data).digest('hex');
8
15
  const patch = git(['diff', '--binary', '--no-ext-diff', '--no-textconv', 'HEAD', '--', '.']);
9
16
  const paths = git(['ls-files', '--others', '--exclude-standard', '-z']).split('\0').filter(Boolean).sort();
10
- if (paths.length > 100) throw Error('review-untracked-limit');
17
+ if (paths.length > MAX_UNTRACKED) throw Error('review-untracked-limit');
11
18
  const untracked = paths.map(name => {
12
19
  const stat = fs.lstatSync(name);
13
20
  if (stat.isSymbolicLink()) return { path: name, mode: 'symlink', target: fs.readlinkSync(name) };
14
- if (!stat.isFile() || stat.size > 120000) throw Error('review-file-limit');
21
+ // A folder Git lists as one entry is a repository of its own (a clone
22
+ // inside the project): named, not read, and its entries keep the digest.
23
+ if (stat.isDirectory()) return { path: name, mode: 'directory', encoding: 'omitted', bytes: 0, sha256: sha256(fs.readdirSync(name).sort().join('\0')) };
24
+ if (!stat.isFile() || stat.size > HASHED_FILE) throw Error('review-file-limit');
25
+ const data = fs.readFileSync(name), mode = stat.mode & 0o777;
26
+ if (stat.size > READ_FILE) return { path: name, mode, encoding: 'omitted', bytes: stat.size, sha256: sha256(data) };
15
27
  // Text goes as text, so the reviewer can read new files; binary as base64.
16
- const data = fs.readFileSync(name);
17
28
  let text = null;
18
29
  if (!data.includes(0)) { try { text = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(data); } catch { text = null; } }
19
- return text === null ? { path: name, mode: stat.mode & 0o777, contents: data.toString('base64'), encoding: 'base64' } : { path: name, mode: stat.mode & 0o777, contents: text, encoding: 'utf8' };
30
+ if (text !== null) return { path: name, mode, contents: text, encoding: 'utf8' };
31
+ return stat.size > BINARY_INLINE ? { path: name, mode, encoding: 'omitted', bytes: stat.size, sha256: sha256(data) } : { path: name, mode, contents: data.toString('base64'), encoding: 'base64' };
20
32
  });
21
- const snapshot = JSON.stringify({ patch, untracked });
22
- if (Buffer.byteLength(snapshot) > 120000) throw Error('review-patch-limit');
23
- // With a part number the snapshot is sent in base64 parts small enough for the
24
- // shell tool's output limit, each headed by the whole snapshot's digest.
25
- const part = process.argv[2], bytes = Buffer.from(snapshot), SIZE = 30000;
26
- if (part === undefined) process.stdout.write(snapshot);
27
- else process.stdout.write(`${createHash('sha256').update(bytes).digest('hex')} ${Math.max(1, Math.ceil(bytes.length / SIZE))}\n${bytes.subarray(Number(part) * SIZE, (Number(part) + 1) * SIZE).toString('base64')}`);
33
+ const snapshot = Buffer.from(JSON.stringify({ patch, untracked }));
34
+ if (snapshot.length > MAX_SNAPSHOT) throw Error('review-patch-limit');
35
+ // "digest": the digest and size only (is a review still current?). "file":
36
+ // the snapshot goes to a new file in this boundary's temporary directory,
37
+ // named on stdout with its digest; the runtime reads it once, checks the
38
+ // digest and removes it. No argument: the snapshot itself.
39
+ const mode = process.argv[2];
40
+ if (mode === 'digest') process.stdout.write(`${sha256(snapshot)} ${snapshot.length}`);
41
+ else if (mode === 'file') {
42
+ const name = `review-${randomUUID()}.json`;
43
+ fs.writeFileSync(path.join(os.tmpdir(), name), snapshot, { mode: 0o600, flag: 'wx' });
44
+ process.stdout.write(`${sha256(snapshot)} ${snapshot.length} ${name}`);
45
+ } else process.stdout.write(snapshot);