pi-pier 0.4.1 → 0.4.2
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.
- package/README.md +1 -2
- package/package.json +1 -1
- package/schemas/efficiency-config.schema.json +6 -0
- package/src/ask-dialog.ts +9 -2
- package/src/ask-user.ts +142 -74
- package/src/compact-coordinator.ts +204 -33
- package/src/compact-economics-core.ts +62 -29
- package/src/config-catalog-core.ts +1 -0
- package/src/delegation-core.ts +1 -1
- package/src/efficiency-config-core.ts +16 -2
- package/src/efficiency-store.ts +42 -5
- package/src/herdr-client.ts +15 -5
- package/src/index-pipe.ts +0 -8
- package/src/index.ts +69 -85
- package/src/jev-client.ts +32 -7
- package/src/jev-core.ts +70 -33
- package/src/observation-core.ts +13 -1
- package/src/pipe-channel.ts +0 -8
- package/src/plugins/heat-plan.ts +18 -3
- package/src/plugins/observation.ts +118 -46
- package/src/plugins/subagent.ts +53 -40
- package/src/plugins/terminal.ts +192 -47
- package/src/plugins/todo.ts +43 -21
- package/src/reducer-invoker.ts +27 -13
- package/src/renderers.ts +1 -13
- package/src/roles/worker-default.json +3 -3
- package/src/settle-wake-core.ts +8 -2
- package/src/subagent-core.ts +41 -3
- package/src/subagent-poller.ts +52 -18
- package/src/subagent-session.ts +67 -4
- package/src/subagent-spawn.ts +6 -5
- package/src/terminal-core.ts +111 -17
- package/src/todo-core.ts +47 -2
- package/src/todo-read-hook.ts +31 -17
- package/src/todo-reminder-core.ts +9 -5
- package/src/todo-window.ts +8 -4
- package/src/todos-service.ts +12 -0
- package/src/vocab.ts +7 -1
|
@@ -14,6 +14,10 @@ import { existsSync, readFileSync } from 'node:fs';
|
|
|
14
14
|
import { homedir } from 'node:os';
|
|
15
15
|
import { join } from 'node:path';
|
|
16
16
|
|
|
17
|
+
/** pi's thinking levels in ascending order, plus `inherit` (summarize at the session's own level). */
|
|
18
|
+
export const SUMMARY_THINKING_LEVELS = ['inherit', 'off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'] as const;
|
|
19
|
+
export type SummaryThinkingLevel = (typeof SUMMARY_THINKING_LEVELS)[number];
|
|
20
|
+
|
|
17
21
|
export interface OnlineContextCompactConfig {
|
|
18
22
|
enabled: boolean;
|
|
19
23
|
logEnabled: boolean;
|
|
@@ -21,6 +25,8 @@ export interface OnlineContextCompactConfig {
|
|
|
21
25
|
keepRecentTokens?: number;
|
|
22
26
|
firstCompactionRequestScale: number;
|
|
23
27
|
subsequentCompactionMargin: number;
|
|
28
|
+
/** Cap on the summary request's thinking level (never raised above the session's). */
|
|
29
|
+
summaryThinkingLevel: SummaryThinkingLevel;
|
|
24
30
|
}
|
|
25
31
|
|
|
26
32
|
export interface ObservationPackConfig {
|
|
@@ -74,7 +80,7 @@ export const DEFAULT_EFFICIENCY_CONFIG: EfficiencyConfig = Object.freeze({
|
|
|
74
80
|
version: 1,
|
|
75
81
|
onlineContextCompact: Object.freeze({
|
|
76
82
|
enabled: false, logEnabled: false, cacheWriteReadRatio: 'auto', keepRecentTokens: 20000,
|
|
77
|
-
firstCompactionRequestScale: 2.0, subsequentCompactionMargin: 1.5,
|
|
83
|
+
firstCompactionRequestScale: 2.0, subsequentCompactionMargin: 1.5, summaryThinkingLevel: 'low',
|
|
78
84
|
}),
|
|
79
85
|
observationPack: Object.freeze({
|
|
80
86
|
enabled: false, logEnabled: false, thresholdBytes: 10240, fullSends: 2,
|
|
@@ -87,7 +93,7 @@ export const DEFAULT_EFFICIENCY_CONFIG: EfficiencyConfig = Object.freeze({
|
|
|
87
93
|
jev: Object.freeze({ enabled: false, logEnabled: false, model: 'jev-1.13.0', timeoutMs: 2000, minConfidence: 0.6 }),
|
|
88
94
|
});
|
|
89
95
|
|
|
90
|
-
type FieldKind = 'bool' | 'int' | 'number' | 'ratio' | 'string' | 'optionalString';
|
|
96
|
+
type FieldKind = 'bool' | 'int' | 'number' | 'ratio' | 'string' | 'optionalString' | 'enum';
|
|
91
97
|
|
|
92
98
|
interface FieldSpec {
|
|
93
99
|
readonly key: string;
|
|
@@ -97,6 +103,8 @@ interface FieldSpec {
|
|
|
97
103
|
readonly max?: number;
|
|
98
104
|
/** Issue text for a bound that must not be printed as a plain JS number ("1.0"). */
|
|
99
105
|
readonly minText?: string;
|
|
106
|
+
/** Accepted values for `enum`. */
|
|
107
|
+
readonly values?: readonly string[];
|
|
100
108
|
}
|
|
101
109
|
|
|
102
110
|
/** A section is `{enabled, ...fields}`; field order fixes the order of reported issues. */
|
|
@@ -113,6 +121,7 @@ const SECTIONS: readonly SectionSpec[] = [
|
|
|
113
121
|
{ key: 'keepRecentTokens', kind: 'int', min: 1000 },
|
|
114
122
|
{ key: 'firstCompactionRequestScale', kind: 'number', min: 1, minText: '1.0' },
|
|
115
123
|
{ key: 'subsequentCompactionMargin', kind: 'number', min: 1, minText: '1.0' },
|
|
124
|
+
{ key: 'summaryThinkingLevel', kind: 'enum', values: SUMMARY_THINKING_LEVELS },
|
|
116
125
|
] },
|
|
117
126
|
{ name: 'observationPack', fields: [
|
|
118
127
|
{ key: 'enabled', kind: 'bool' },
|
|
@@ -156,6 +165,8 @@ function issueFor(section: string, field: FieldSpec): string {
|
|
|
156
165
|
: `${path} 必须是 ${field.min} 到 ${field.max} 之间的数字`;
|
|
157
166
|
case 'ratio':
|
|
158
167
|
return `${path} 必须是 "auto" 或非负有限数`;
|
|
168
|
+
case 'enum':
|
|
169
|
+
return `${path} 必须是 ${(field.values ?? []).join(' | ')} 之一`;
|
|
159
170
|
default:
|
|
160
171
|
return `${path} 必须是非空字符串`;
|
|
161
172
|
}
|
|
@@ -190,6 +201,9 @@ function applyField(target: Record<string, unknown>, field: FieldSpec, raw: unkn
|
|
|
190
201
|
if (typeof raw !== 'string' || !raw.trim()) return false;
|
|
191
202
|
target[field.key] = raw.trim();
|
|
192
203
|
return true;
|
|
204
|
+
case 'enum':
|
|
205
|
+
if (typeof raw !== 'string' || !(field.values ?? []).includes(raw)) return false;
|
|
206
|
+
break;
|
|
193
207
|
}
|
|
194
208
|
target[field.key] = raw;
|
|
195
209
|
return true;
|
package/src/efficiency-store.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { constants } from 'node:fs';
|
|
8
|
-
import { lstat, mkdir, open, readdir, realpath, rename, rm, stat } from 'node:fs/promises';
|
|
8
|
+
import { lstat, mkdir, open, readdir, realpath, rename, rm, stat, type FileHandle } from 'node:fs/promises';
|
|
9
9
|
import { tmpdir } from 'node:os';
|
|
10
10
|
import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
11
11
|
import {
|
|
@@ -52,6 +52,21 @@ export function observationObjectPath(root: string, obsId: string): string {
|
|
|
52
52
|
return join(root, 'observation-pack', 'objects', `${obsId}.txt`);
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
/** Cross-process idempotence probe: a resumed process has no memo but the content-addressed object
|
|
56
|
+
* is still on disk. A wrong-sized file is treated as absent so the writer re-verifies it. */
|
|
57
|
+
export async function observationObjectExists(
|
|
58
|
+
root: string,
|
|
59
|
+
obsId: string,
|
|
60
|
+
expectedBytes?: number,
|
|
61
|
+
): Promise<boolean> {
|
|
62
|
+
try {
|
|
63
|
+
const stats = await lstat(observationObjectPath(root, obsId));
|
|
64
|
+
return stats.isFile() && (expectedBytes === undefined || stats.size === expectedBytes);
|
|
65
|
+
} catch {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
55
70
|
export function reducerObjectPath(root: string, sha256: string): string {
|
|
56
71
|
return join(root, 'evidence-preserving-reducer', 'objects', `${sha256}.txt`);
|
|
57
72
|
}
|
|
@@ -202,6 +217,27 @@ export async function storeContentAddressedObject(
|
|
|
202
217
|
};
|
|
203
218
|
}
|
|
204
219
|
|
|
220
|
+
const ALIGN_SCAN_BYTES = 64 * 1024;
|
|
221
|
+
|
|
222
|
+
/** Advance a mid-line offset to the next line start so a recall page never opens inside a line.
|
|
223
|
+
* A single-line tail (no newline left) keeps the requested offset: skipping to EOF would make the
|
|
224
|
+
* rest of the object unreadable. */
|
|
225
|
+
async function alignOffsetToLineStart(handle: FileHandle, offset: number, size: number): Promise<number> {
|
|
226
|
+
if (offset <= 0 || offset >= size) return offset;
|
|
227
|
+
const prev = Buffer.alloc(1);
|
|
228
|
+
const { bytesRead: prevRead } = await handle.read(prev, 0, 1, offset - 1);
|
|
229
|
+
if (prevRead === 0 || prev[0] === 0x0a) return offset;
|
|
230
|
+
|
|
231
|
+
const scan = Buffer.alloc(Math.min(ALIGN_SCAN_BYTES, size - offset));
|
|
232
|
+
for (let pos = offset; pos < size; pos += scan.length) {
|
|
233
|
+
const { bytesRead } = await handle.read(scan, 0, Math.min(scan.length, size - pos), pos);
|
|
234
|
+
if (bytesRead === 0) break;
|
|
235
|
+
const newline = scan.subarray(0, bytesRead).indexOf(0x0a);
|
|
236
|
+
if (newline !== -1) return pos + newline + 1;
|
|
237
|
+
}
|
|
238
|
+
return offset;
|
|
239
|
+
}
|
|
240
|
+
|
|
205
241
|
export async function readStoredObjectChunk(
|
|
206
242
|
filePath: string,
|
|
207
243
|
offset: number,
|
|
@@ -218,14 +254,15 @@ export async function readStoredObjectChunk(
|
|
|
218
254
|
throw new Error(`Offset ${offset} exceeds object size ${fileStat.size}`);
|
|
219
255
|
}
|
|
220
256
|
|
|
221
|
-
const
|
|
257
|
+
const start = await alignOffsetToLineStart(handle, offset, fileStat.size);
|
|
258
|
+
const available = Math.max(0, fileStat.size - start);
|
|
222
259
|
const buffer = Buffer.alloc(Math.min(available, limits.maxBytes + 4));
|
|
223
|
-
const { bytesRead } = await handle.read(buffer, 0, buffer.length,
|
|
260
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, start);
|
|
224
261
|
const sliced = sliceBufferChunk(buffer.subarray(0, bytesRead), 0, limits);
|
|
225
262
|
// sliceBufferChunk's nextOffset is relative to the window it was given (offset 0). Callers page
|
|
226
263
|
// with the absolute file offset from the header, so add the seek back.
|
|
227
|
-
const nextOffset =
|
|
228
|
-
return { ...sliced, nextOffset, eof: nextOffset >= fileStat.size };
|
|
264
|
+
const nextOffset = start + sliced.nextOffset;
|
|
265
|
+
return { ...sliced, offset: start, nextOffset, eof: nextOffset >= fileStat.size };
|
|
229
266
|
} finally {
|
|
230
267
|
await handle?.close();
|
|
231
268
|
}
|
package/src/herdr-client.ts
CHANGED
|
@@ -147,9 +147,20 @@ export function herdrUnavailableHint(err: unknown): string | null {
|
|
|
147
147
|
/** Wire protocol this client is written against (herdr 0.9.5, contract fixture regenerated 2026-10-20). */
|
|
148
148
|
export const HERDR_PROTOCOL_EXPECTED = 22;
|
|
149
149
|
|
|
150
|
+
export type WaitFailureReason = 'timeout' | 'unavailable';
|
|
151
|
+
|
|
150
152
|
export type WaitForOutputResult =
|
|
151
153
|
| { matched: true }
|
|
152
|
-
| { matched: false; reason:
|
|
154
|
+
| { matched: false; reason: WaitFailureReason };
|
|
155
|
+
|
|
156
|
+
/** Classify a failed `pane.wait_for_output`: null means the error is not a wait outcome and should
|
|
157
|
+
* surface. herdr may report its own server-side timeout under an `unavailable` code or as
|
|
158
|
+
* "timed out"; a wait that consumed its budget is a timeout regardless of the label it chose. */
|
|
159
|
+
export function classifyWaitFailure(message: string, elapsedMs: number, timeoutMs: number): WaitFailureReason | null {
|
|
160
|
+
if (/timeout|timed out|deadline exceeded/i.test(message) || elapsedMs >= timeoutMs * 0.95) return 'timeout';
|
|
161
|
+
if (/not supported|not implemented|unknown method|unavailable|method not found/i.test(message)) return 'unavailable';
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
153
164
|
|
|
154
165
|
/** pane.read / agent.read selection: `recent` keeps ANSI so T6 fullscreen detection still sees it. */
|
|
155
166
|
export interface ReadOptions {
|
|
@@ -783,6 +794,7 @@ export class HerdrClient implements HerdrClientLike {
|
|
|
783
794
|
}
|
|
784
795
|
|
|
785
796
|
async waitForOutput(paneId: string, match: { type: 'substring' | 'regex'; value: string }, timeoutMs: number): Promise<WaitForOutputResult> {
|
|
797
|
+
const started = Date.now();
|
|
786
798
|
try {
|
|
787
799
|
await this.request('pane.wait_for_output', {
|
|
788
800
|
pane_id: paneId,
|
|
@@ -793,10 +805,8 @@ export class HerdrClient implements HerdrClientLike {
|
|
|
793
805
|
return { matched: true };
|
|
794
806
|
} catch (err) {
|
|
795
807
|
const msg = String((err as Error)?.message ?? err);
|
|
796
|
-
|
|
797
|
-
if (
|
|
798
|
-
return { matched: false, reason: 'unavailable' };
|
|
799
|
-
}
|
|
808
|
+
const reason = classifyWaitFailure(msg, Date.now() - started, timeoutMs);
|
|
809
|
+
if (reason != null) return { matched: false, reason };
|
|
800
810
|
throw err;
|
|
801
811
|
}
|
|
802
812
|
}
|
package/src/index-pipe.ts
CHANGED
|
@@ -21,8 +21,6 @@ export interface PipeHandlerSession {
|
|
|
21
21
|
setPendingMachineRequest: (req: MachineRequest | null) => void;
|
|
22
22
|
/** P0 (RFC §4.4): apply a role switch requested over the pipe (master → worker). */
|
|
23
23
|
applyRoleSwitch: (role: string, switchedBy: string) => Promise<{ ok: boolean; message: string }>;
|
|
24
|
-
/** Phase 2 (RFC rfc-delegation-identity §3.2): worker → master progress note (display-less custom message). */
|
|
25
|
-
deliverProgress: (workerPaneId: string, text: string) => Promise<void>;
|
|
26
24
|
/** D113 (RFC rfc-unified-widget): worker → master full todo push; updates the widget cache. */
|
|
27
25
|
applyWorkerTodos: (workerPaneId: string, items: Array<{ content: string; status: string; blocker?: string }>, summary: string) => void;
|
|
28
26
|
}
|
|
@@ -58,12 +56,6 @@ export async function handlePipeRequest(
|
|
|
58
56
|
? { type: 'ok', id: req.id, detail: res.message }
|
|
59
57
|
: { type: 'error', id: req.id, message: res.message };
|
|
60
58
|
}
|
|
61
|
-
case 'progress': {
|
|
62
|
-
// Never triggers a master turn and never floods the visible chat (RFC §3.2); the
|
|
63
|
-
// display-less custom message rides context only, rendered as a compact card.
|
|
64
|
-
await s.deliverProgress(req.paneId, req.text);
|
|
65
|
-
return { type: 'ok', id: req.id };
|
|
66
|
-
}
|
|
67
59
|
case 'todo_sync': {
|
|
68
60
|
s.applyWorkerTodos(req.paneId, req.items, req.summary);
|
|
69
61
|
return { type: 'ok', id: req.id };
|
package/src/index.ts
CHANGED
|
@@ -10,7 +10,6 @@
|
|
|
10
10
|
* details persist in session JSONL and getBranch() replay implements branch rollback.
|
|
11
11
|
*/
|
|
12
12
|
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
13
|
-
import { Type } from 'typebox';
|
|
14
13
|
import {
|
|
15
14
|
ASK_PARAMETERS,
|
|
16
15
|
ASK_PROMPT_GUIDELINES,
|
|
@@ -20,6 +19,7 @@ import {
|
|
|
20
19
|
hasAskUi,
|
|
21
20
|
noUiResult,
|
|
22
21
|
prepareAsk,
|
|
22
|
+
prepareAskArguments,
|
|
23
23
|
runAsk,
|
|
24
24
|
} from './ask-user.ts';
|
|
25
25
|
import { pipeNameFor, pipeRequest, startPipeServer } from './pipe-channel.ts';
|
|
@@ -42,7 +42,7 @@ import { parseRuntimeManifest } from './tool-gate.ts';
|
|
|
42
42
|
import { formatPaneTitle } from './pane-title.ts';
|
|
43
43
|
import { registerSlimFrame, updateSlimFrame } from './slim-frame.ts';
|
|
44
44
|
import { applyRestoredWorkerMode, planIndexMode } from './index-runtime.ts';
|
|
45
|
-
import { buildResumeArgv, emptySubagentPortBox,
|
|
45
|
+
import { buildResumeArgv, emptySubagentPortBox, sleepWhileSubsRunningNote } from './subagent-core.ts';
|
|
46
46
|
import { hasWorkerMarker, writeWorkerMarker, readWorkerMarker } from './worker-marker.ts';
|
|
47
47
|
import {
|
|
48
48
|
DELEGATION_CUSTOM_TYPE,
|
|
@@ -58,18 +58,16 @@ import { handlePipeRequest, type MachineRequest } from './index-pipe.ts';
|
|
|
58
58
|
import { installWriteLocks } from './index-locks.ts';
|
|
59
59
|
import { installDashboardCommand } from './dashboard-command.ts';
|
|
60
60
|
import { openPierPanel } from './panel-open.ts';
|
|
61
|
-
import { registerObservationPack, createCompactionBatchPackHook, type
|
|
61
|
+
import { registerObservationPack, createCompactionBatchPackHook, type PickMiddleExcerpts } from './plugins/observation.ts';
|
|
62
62
|
import { createJevRuntime } from './jev-client.ts';
|
|
63
63
|
import {
|
|
64
64
|
NOTICE_RANK_MIN_CONFIDENCE,
|
|
65
|
-
buildExcerptWindows,
|
|
66
65
|
composeNoticeRanking,
|
|
67
66
|
evaluateExcerptPick,
|
|
68
|
-
excerptAskIsSafe,
|
|
69
67
|
excerptPickRequest,
|
|
70
68
|
noticeRankRequest,
|
|
69
|
+
planExcerptAsks,
|
|
71
70
|
} from './jev-core.ts';
|
|
72
|
-
import { completeLineExcerpt } from './observation-core.ts';
|
|
73
71
|
import { installConfigCommand } from './config-command.ts';
|
|
74
72
|
import { CompactCoordinator } from './compact-coordinator.ts';
|
|
75
73
|
import { handleReducerToolResult, type ToolResultEventLike } from './reducer-invoker.ts';
|
|
@@ -113,30 +111,37 @@ export default async function (pi: ExtensionAPI) {
|
|
|
113
111
|
/* ── Jev decision layer (RFC docs/rfc-jev-integration.md): direct HTTP, total-budget
|
|
114
112
|
* abort, fail-open at every site. Disabled or keyless → behavior identical to before. ── */
|
|
115
113
|
const jevRuntime = createJevRuntime(() => effConfig.jev, { getSessionRoot: () => sessionRoot, getSessionId: () => sessionId || null });
|
|
116
|
-
/** P0-3: candidate windows are generated in code; jev only picks (rerank pattern). */
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
const tailExcerpt = completeLineExcerpt(text, excerptBudgetBytes - halfBudget, true);
|
|
124
|
-
// Credential-shaped text never leaves the process (§8 privacy boundary).
|
|
125
|
-
if (!excerptAskIsSafe(windows, headExcerpt, tailExcerpt)) return null;
|
|
126
|
-
const request = excerptPickRequest(windows, headExcerpt, tailExcerpt);
|
|
127
|
-
if (!request) return null;
|
|
114
|
+
/** P0-3: candidate windows are generated in code; jev only picks (rerank pattern), one request per batch. */
|
|
115
|
+
const pickMiddleExcerpts: PickMiddleExcerpts = async (items, excerptBudgetBytes) => {
|
|
116
|
+
const picks: Array<{ text: string; label: string } | null> = items.map(() => null);
|
|
117
|
+
if (!jevRuntime.available) return picks;
|
|
118
|
+
const asked = planExcerptAsks(items, excerptBudgetBytes);
|
|
119
|
+
const request = excerptPickRequest(asked);
|
|
120
|
+
if (!request) return picks;
|
|
128
121
|
const result = await jevRuntime.ask(request, {
|
|
129
122
|
questionId: 'obs-excerpt-window',
|
|
130
|
-
extra: {
|
|
123
|
+
extra: {
|
|
124
|
+
site: 'obs-excerpt',
|
|
125
|
+
items: asked.map((a) => ({ obsId: a.obsId, windows: a.windows.map((w) => ({ id: w.id, startLine: w.startLine })) })),
|
|
126
|
+
},
|
|
131
127
|
enrich: ({ answers }) => {
|
|
132
|
-
if (!answers
|
|
133
|
-
return {
|
|
128
|
+
if (!answers) return {};
|
|
129
|
+
return {
|
|
130
|
+
picked: asked.map((_, i) => {
|
|
131
|
+
const answer = answers[`excerpt_${i}_window`];
|
|
132
|
+
return answer?.type === 'choice' ? { choice: answer.choice, confidence: answer.confidence } : null;
|
|
133
|
+
}),
|
|
134
|
+
};
|
|
134
135
|
},
|
|
135
136
|
});
|
|
136
|
-
if (!result.ok) return
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
137
|
+
if (!result.ok) return picks;
|
|
138
|
+
for (let i = 0; i < asked.length; i++) {
|
|
139
|
+
const { index, windows } = asked[i]!;
|
|
140
|
+
const picked = evaluateExcerptPick(result.answers, i, effConfig.jev.minConfidence);
|
|
141
|
+
const window = picked === null ? undefined : windows.find((w) => w.id === picked);
|
|
142
|
+
if (window) picks[index] = { text: window.text, label: window.label };
|
|
143
|
+
}
|
|
144
|
+
return picks;
|
|
140
145
|
};
|
|
141
146
|
|
|
142
147
|
const rendererTypes = installRenderers(pi);
|
|
@@ -290,6 +295,8 @@ export default async function (pi: ExtensionAPI) {
|
|
|
290
295
|
}
|
|
291
296
|
todos.applyEdits(plan.edits, { source: 'reconcile' });
|
|
292
297
|
mirrorTodos();
|
|
298
|
+
// Reconcile is not a tool call, so nothing else refreshes the resident widget after an edit.
|
|
299
|
+
todoUi.rerenderWidget?.();
|
|
293
300
|
}
|
|
294
301
|
return plan.noteLines;
|
|
295
302
|
} catch {
|
|
@@ -353,6 +360,18 @@ export default async function (pi: ExtensionAPI) {
|
|
|
353
360
|
});
|
|
354
361
|
});
|
|
355
362
|
|
|
363
|
+
/* ── Masters sleep-polled background workers (sleep 120…840) instead of trusting the settlement notice. ── */
|
|
364
|
+
if (!isSubagent) {
|
|
365
|
+
pi.on('tool_result', async (event: unknown) => {
|
|
366
|
+
const rec = (event ?? {}) as { toolName?: unknown; input?: { command?: unknown }; content?: unknown };
|
|
367
|
+
if (rec.toolName !== 'bash' || typeof rec.input?.command !== 'string') return;
|
|
368
|
+
const note = sleepWhileSubsRunningNote(rec.input.command, subagentPort.current?.listRunningSubs() ?? []);
|
|
369
|
+
if (!note) return;
|
|
370
|
+
const content = Array.isArray(rec.content) ? rec.content as Array<{ type: 'text'; text: string }> : [];
|
|
371
|
+
return { content: [...content, { type: 'text' as const, text: note }] };
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
|
|
356
375
|
let locksHandle = { getHeldLocks: () => [] as readonly string[] };
|
|
357
376
|
if (env) {
|
|
358
377
|
locksHandle = installWriteLocks(pi, { client, env, hard: process.env[WRITE_LOCK_ENV] === '1' });
|
|
@@ -365,19 +384,11 @@ export default async function (pi: ExtensionAPI) {
|
|
|
365
384
|
return Promise.resolve();
|
|
366
385
|
};
|
|
367
386
|
|
|
368
|
-
/** Phase 2 (RFC rfc-delegation-identity §3.2): a worker's progress note enters master context as
|
|
369
|
-
* a display-less custom message — no turn, no visible chat line; renderers.ts gives it a card. */
|
|
370
|
-
const deliverProgress = (workerPaneId: string, text: string): Promise<void> => {
|
|
371
|
-
(pi as unknown as {
|
|
372
|
-
sendMessage?: (msg: { customType: string; content: string; display: boolean; details?: Record<string, unknown> }, opts?: { triggerTurn?: boolean }) => void;
|
|
373
|
-
}).sendMessage?.(
|
|
374
|
-
{ customType: PROGRESS_CUSTOM_TYPE, content: text.slice(0, 2000), display: false, details: { paneId: workerPaneId } },
|
|
375
|
-
{ triggerTurn: false },
|
|
376
|
-
);
|
|
377
|
-
return Promise.resolve();
|
|
378
|
-
};
|
|
379
387
|
const notices = createNoticeBuffer({
|
|
380
|
-
|
|
388
|
+
// pi core rejects prompts while it is compacting (and drops fire-and-forget sends), so a
|
|
389
|
+
// settlement landing inside the compaction window must stay buffered until the continuation
|
|
390
|
+
// run settles — otherwise the result is lost with nobody left to retry it.
|
|
391
|
+
isBusy: () => agentActive || lastStopReason === ABORT_STOP_REASON || coordinator.compactionInFlight,
|
|
381
392
|
send: sendUserMessageAs,
|
|
382
393
|
// P0-2: rank collapsed batches by relevance to the master's in-progress work.
|
|
383
394
|
rank: async (contents) => {
|
|
@@ -443,7 +454,6 @@ export default async function (pi: ExtensionAPI) {
|
|
|
443
454
|
abort: () => { latestCtx?.abort?.(); },
|
|
444
455
|
setPendingMachineRequest: (next) => { pendingMachineRequest = next; },
|
|
445
456
|
applyRoleSwitch: roles.applyRoleSwitch,
|
|
446
|
-
deliverProgress,
|
|
447
457
|
applyWorkerTodos,
|
|
448
458
|
}));
|
|
449
459
|
} catch {
|
|
@@ -589,6 +599,9 @@ export default async function (pi: ExtensionAPI) {
|
|
|
589
599
|
// Safety fallback: pi core prohibits prompts while compaction is active.
|
|
590
600
|
coordinator.compactionInFlight = false;
|
|
591
601
|
coordinator.intentionalAbort = false;
|
|
602
|
+
// A selection that never became startable must not latch every later decision (and the
|
|
603
|
+
// settle-wake hold) until the next human input.
|
|
604
|
+
coordinator.selectedCompaction = null;
|
|
592
605
|
gates.report('working', currentActivity(todos.items));
|
|
593
606
|
});
|
|
594
607
|
|
|
@@ -612,15 +625,20 @@ export default async function (pi: ExtensionAPI) {
|
|
|
612
625
|
// flush ran regardless. The merged handler keeps both semantics.
|
|
613
626
|
const msg = event !== null && typeof event === 'object' && 'message' in event ? (event as { message: unknown }).message : null;
|
|
614
627
|
if (msg !== null && typeof msg === 'object') {
|
|
615
|
-
const { role, stopReason } = msg as { role?: unknown; stopReason?: unknown };
|
|
628
|
+
const { role, stopReason, usage } = msg as { role?: unknown; stopReason?: unknown; usage?: unknown };
|
|
616
629
|
if (role === 'assistant' && typeof stopReason === 'string') {
|
|
617
630
|
lastStopReason = stopReason;
|
|
618
631
|
}
|
|
632
|
+
if (role === 'assistant' && usage !== null && typeof usage === 'object') {
|
|
633
|
+
coordinator.onAssistantUsage(usage as { input?: number; cacheRead?: number; cacheWrite?: number });
|
|
634
|
+
}
|
|
619
635
|
if (ctx && typeof ctx === 'object') {
|
|
620
636
|
coordinator.onTurnEnd({
|
|
621
637
|
ctx,
|
|
622
638
|
todos: todos.items,
|
|
623
639
|
config: effConfig.onlineContextCompact,
|
|
640
|
+
pi,
|
|
641
|
+
lastStopReason: typeof stopReason === 'string' ? stopReason : null,
|
|
624
642
|
cancelReminder: todoUi.cancelReminder,
|
|
625
643
|
});
|
|
626
644
|
}
|
|
@@ -654,6 +672,13 @@ export default async function (pi: ExtensionAPI) {
|
|
|
654
672
|
void sendUserMessageAs(notice, 'followUp');
|
|
655
673
|
}
|
|
656
674
|
if (plan.compact && ctx && typeof ctx === 'object') {
|
|
675
|
+
// Settlement notices that land inside the compaction window stay buffered (isBusy includes
|
|
676
|
+
// compactionInFlight). The continuation run flushes them at its own agent_settled; a cancelled
|
|
677
|
+
// or rejected compaction sends no continuation, so release the buffer once the coordinator is
|
|
678
|
+
// done and no run has started.
|
|
679
|
+
const flushAfterCompaction = (): void => {
|
|
680
|
+
if (!coordinator.compactionInFlight && !agentActive) void notices.flush('followUp');
|
|
681
|
+
};
|
|
657
682
|
void coordinator.onAgentSettled({
|
|
658
683
|
ctx,
|
|
659
684
|
todos: todos.items,
|
|
@@ -664,9 +689,9 @@ export default async function (pi: ExtensionAPI) {
|
|
|
664
689
|
getObsConfig: () => effConfig.observationPack,
|
|
665
690
|
getManifest: () => roles.state.manifest,
|
|
666
691
|
getSessionId: () => sessionId,
|
|
667
|
-
|
|
692
|
+
pickMiddleExcerpts,
|
|
668
693
|
}),
|
|
669
|
-
});
|
|
694
|
+
}).then(flushAfterCompaction, flushAfterCompaction);
|
|
670
695
|
}
|
|
671
696
|
if (lastStopReason !== ABORT_STOP_REASON && !aborted) void notices.flush('followUp');
|
|
672
697
|
void pushSettleReply(aborted);
|
|
@@ -677,7 +702,7 @@ export default async function (pi: ExtensionAPI) {
|
|
|
677
702
|
pi,
|
|
678
703
|
getConfig: () => effConfig,
|
|
679
704
|
getRuntimeManifest: () => roles.state.manifest,
|
|
680
|
-
|
|
705
|
+
pickMiddleExcerpts,
|
|
681
706
|
getRemainingHorizon: () => {
|
|
682
707
|
const usage = (latestCtx as { getContextUsage?: () => { contextWindow?: number } })?.getContextUsage?.();
|
|
683
708
|
return coordinator.getRemainingHorizon(3, usage?.contextWindow ?? null);
|
|
@@ -701,6 +726,8 @@ export default async function (pi: ExtensionAPI) {
|
|
|
701
726
|
description: ASK_TOOL_DESCRIPTION,
|
|
702
727
|
promptGuidelines: ASK_PROMPT_GUIDELINES,
|
|
703
728
|
parameters: ASK_PARAMETERS,
|
|
729
|
+
// Models sometimes send options/questions as a JSON string; repair them before pi validates.
|
|
730
|
+
prepareArguments: prepareAskArguments,
|
|
704
731
|
async execute(_tc, params, signal, _upd, ctx) {
|
|
705
732
|
const prepared = prepareAsk(params);
|
|
706
733
|
if (!prepared.ok) return prepared.result;
|
|
@@ -717,49 +744,6 @@ export default async function (pi: ExtensionAPI) {
|
|
|
717
744
|
},
|
|
718
745
|
});
|
|
719
746
|
|
|
720
|
-
/* ── report_progress (Phase 2, RFC rfc-delegation-identity §3.2): worker → master checkpoint ── */
|
|
721
|
-
if (isSubagent) {
|
|
722
|
-
pi.registerTool({
|
|
723
|
-
name: 'report_progress',
|
|
724
|
-
label: 'Report Progress',
|
|
725
|
-
description:
|
|
726
|
-
'Send the delegating master a short progress update without finishing the task. Use at meaningful checkpoints (a module done, a test went green, a blocker found) — the master sees it in its context but is not interrupted.',
|
|
727
|
-
promptSnippet: 'report_progress: mid-task checkpoint to the delegating master (does not end the task).',
|
|
728
|
-
promptGuidelines: [
|
|
729
|
-
'Call at meaningful checkpoints only — a milestone reached, a risk discovered, a decision the master should know early — not after every tool call.',
|
|
730
|
-
'One or two sentences per call; the final answer still goes in your normal closing message when the task finishes.',
|
|
731
|
-
],
|
|
732
|
-
parameters: Type.Object({
|
|
733
|
-
message: Type.String({ description: 'Short checkpoint update for the delegating master.' }),
|
|
734
|
-
}),
|
|
735
|
-
async execute(_tc, params) {
|
|
736
|
-
const parentPipe = delegation?.parentPipe;
|
|
737
|
-
const message = typeof (params as { message?: unknown })?.message === 'string'
|
|
738
|
-
? (params as { message: string }).message.trim()
|
|
739
|
-
: '';
|
|
740
|
-
if (!message) {
|
|
741
|
-
return { content: [{ type: 'text', text: 'Error: `message` is required (one or two sentences).' }], details: {} };
|
|
742
|
-
}
|
|
743
|
-
if (!parentPipe) {
|
|
744
|
-
return { content: [{ type: 'text', text: 'Error: no delegating master pipe is known to this session; progress cannot be delivered.' }], details: {} };
|
|
745
|
-
}
|
|
746
|
-
try {
|
|
747
|
-
const res = await pipeRequest(parentPipe, {
|
|
748
|
-
type: 'progress',
|
|
749
|
-
id: `prog-${Date.now()}`,
|
|
750
|
-
paneId: env?.paneId ?? '',
|
|
751
|
-
text: message,
|
|
752
|
-
}, 5000);
|
|
753
|
-
return res.type === 'ok'
|
|
754
|
-
? { content: [{ type: 'text', text: 'Progress delivered to the master (visible in its context, no interruption).' }], details: {} }
|
|
755
|
-
: { content: [{ type: 'text', text: `Error: master rejected progress report: ${res.message}` }], details: {} };
|
|
756
|
-
} catch (err) {
|
|
757
|
-
return { content: [{ type: 'text', text: `Error: could not reach the master pipe: ${(err as Error)?.message ?? err}` }], details: {} };
|
|
758
|
-
}
|
|
759
|
-
},
|
|
760
|
-
});
|
|
761
|
-
}
|
|
762
|
-
|
|
763
747
|
pi.on('session_shutdown', async () => {
|
|
764
748
|
coordinator.compactionInFlight = false;
|
|
765
749
|
coordinator.intentionalAbort = false;
|
package/src/jev-client.ts
CHANGED
|
@@ -12,6 +12,10 @@ import { parseJevAnswers, type JevAnswer, type JevQuestion, type JevUsage } from
|
|
|
12
12
|
import type { JevConfig } from './efficiency-config-core.ts';
|
|
13
13
|
|
|
14
14
|
const JEV_DEFAULT_BASE_URL = 'https://api.typesafe.ai';
|
|
15
|
+
/** Consecutive transport failures (timeout / network / 5xx) that open the breaker. */
|
|
16
|
+
export const JEV_BREAKER_FAILURES = 2;
|
|
17
|
+
/** While open, asks fail open locally without a request or a telemetry row; the tripping row says so. */
|
|
18
|
+
export const JEV_BREAKER_OPEN_MS = 60_000;
|
|
15
19
|
|
|
16
20
|
export interface JevAskMeta {
|
|
17
21
|
/** Stable question-set id for telemetry rows (e.g. 'epr-diagnostic-gate'). */
|
|
@@ -23,7 +27,7 @@ export interface JevAskMeta {
|
|
|
23
27
|
extra?: Record<string, unknown>;
|
|
24
28
|
/**
|
|
25
29
|
* Post-answer decision fields for telemetry (choices/scores/final verdicts — what threshold tuning
|
|
26
|
-
* needs; still never request bodies). Called exactly once per ask.
|
|
30
|
+
* needs; still never request bodies). Called exactly once per ask, with null answers on failure.
|
|
27
31
|
*/
|
|
28
32
|
enrich?: (outcome: { ok: boolean; answers: Record<string, JevAnswer> | null }) => Record<string, unknown>;
|
|
29
33
|
}
|
|
@@ -49,11 +53,14 @@ export interface JevRuntime {
|
|
|
49
53
|
export function createJevRuntime(getConfig: () => JevConfig, deps: JevRuntimeDeps = {}): JevRuntime {
|
|
50
54
|
const doFetch = deps.fetchImpl ?? ((input, init) => globalThis.fetch(input, init));
|
|
51
55
|
const now = deps.now ?? Date.now;
|
|
52
|
-
|
|
56
|
+
// A run of 12 back-to-back network failures and 2.5s timeout stalls showed every site paying
|
|
57
|
+
// the full budget during an outage; the breaker makes an outage cost one tripping ask.
|
|
58
|
+
let transportFailures = 0;
|
|
59
|
+
let breakerOpenUntil = 0;
|
|
53
60
|
async function logAsk(
|
|
54
61
|
config: JevConfig,
|
|
55
62
|
meta: JevAskMeta,
|
|
56
|
-
outcome: { ok: true; model: string; usage: JevUsage } | { ok: false; reason: string },
|
|
63
|
+
outcome: { ok: true; model: string; usage: JevUsage } | { ok: false; reason: string; extra?: Record<string, unknown> },
|
|
57
64
|
latencyMs: number,
|
|
58
65
|
state: unknown,
|
|
59
66
|
enriched: Record<string, unknown>,
|
|
@@ -77,6 +84,7 @@ export function createJevRuntime(getConfig: () => JevConfig, deps: JevRuntimeDep
|
|
|
77
84
|
stateHash: sha256Hex(serialized),
|
|
78
85
|
...(outcome.ok ? { usage: { input_tokens: outcome.usage.inputTokens, output_tokens: outcome.usage.outputTokens } } : {}),
|
|
79
86
|
...(meta.extra ?? {}),
|
|
87
|
+
...(outcome.ok ? {} : outcome.extra ?? {}),
|
|
80
88
|
...enriched,
|
|
81
89
|
};
|
|
82
90
|
try {
|
|
@@ -95,14 +103,25 @@ export function createJevRuntime(getConfig: () => JevConfig, deps: JevRuntimeDep
|
|
|
95
103
|
async ask(request, meta): Promise<JevAskResult> {
|
|
96
104
|
const startedAt = now();
|
|
97
105
|
const config = getConfig();
|
|
98
|
-
const fail = async (reason: string): Promise<JevAskResult> => {
|
|
106
|
+
const fail = async (reason: string, extra?: Record<string, unknown>): Promise<JevAskResult> => {
|
|
99
107
|
const latencyMs = now() - startedAt;
|
|
100
108
|
const enriched = meta.enrich?.({ ok: false, answers: null }) ?? {};
|
|
101
|
-
await logAsk(config, meta, { ok: false, reason }, latencyMs, request.state, enriched);
|
|
109
|
+
await logAsk(config, meta, { ok: false, reason, extra }, latencyMs, request.state, enriched);
|
|
102
110
|
return { ok: false, reason, latencyMs };
|
|
103
111
|
};
|
|
112
|
+
const transportFail = (reason: string, extra: Record<string, unknown> = {}): Promise<JevAskResult> => {
|
|
113
|
+
transportFailures += 1;
|
|
114
|
+
if (transportFailures < JEV_BREAKER_FAILURES) return fail(reason, extra);
|
|
115
|
+
transportFailures = 0;
|
|
116
|
+
breakerOpenUntil = now() + JEV_BREAKER_OPEN_MS;
|
|
117
|
+
return fail(reason, { ...extra, breakerOpenedMs: JEV_BREAKER_OPEN_MS });
|
|
118
|
+
};
|
|
104
119
|
if (!config.enabled) return fail('disabled');
|
|
105
120
|
if (!config.apiKey) return fail('no-api-key');
|
|
121
|
+
if (now() < breakerOpenUntil) {
|
|
122
|
+
meta.enrich?.({ ok: false, answers: null });
|
|
123
|
+
return { ok: false, reason: 'breaker-open', latencyMs: 0 };
|
|
124
|
+
}
|
|
106
125
|
|
|
107
126
|
const timeoutMs = meta.timeoutMs ?? config.timeoutMs;
|
|
108
127
|
const controller = new AbortController();
|
|
@@ -121,8 +140,11 @@ export function createJevRuntime(getConfig: () => JevConfig, deps: JevRuntimeDep
|
|
|
121
140
|
if (!response.ok) {
|
|
122
141
|
// 429 (and everything else) fails open immediately: limits are
|
|
123
142
|
// dynamically adjusted upstream and every pier call is droppable.
|
|
143
|
+
if (response.status >= 500) return transportFail(`http-${response.status}`);
|
|
144
|
+
transportFailures = 0;
|
|
124
145
|
return fail(response.status === 429 ? 'rate-limited' : `http-${response.status}`);
|
|
125
146
|
}
|
|
147
|
+
transportFailures = 0;
|
|
126
148
|
const raw: unknown = await response.json();
|
|
127
149
|
const parsed = parseJevAnswers(raw, Object.keys(request.questions));
|
|
128
150
|
if (!parsed.ok) return fail(parsed.reason);
|
|
@@ -131,8 +153,11 @@ export function createJevRuntime(getConfig: () => JevConfig, deps: JevRuntimeDep
|
|
|
131
153
|
await logAsk(config, meta, { ok: true, model: parsed.model, usage: parsed.usage }, latencyMs, request.state, enriched);
|
|
132
154
|
return { ok: true, answers: parsed.answers, model: parsed.model, usage: parsed.usage, latencyMs };
|
|
133
155
|
} catch (error) {
|
|
134
|
-
|
|
135
|
-
|
|
156
|
+
if (error instanceof Error && error.name === 'AbortError') return transportFail('timeout');
|
|
157
|
+
// fetch wraps the socket error in `cause`; its code (ECONNRESET, ENOTFOUND, ...) is the diagnosis.
|
|
158
|
+
const source = error instanceof Error && error.cause instanceof Error ? error.cause : error;
|
|
159
|
+
const code = source instanceof Error && 'code' in source && typeof source.code === 'string' ? source.code : undefined;
|
|
160
|
+
return transportFail('network-error', code ? { errorCode: code } : {});
|
|
136
161
|
} finally {
|
|
137
162
|
clearTimeout(timer);
|
|
138
163
|
}
|