pi-pier 0.2.6 → 0.3.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.
- package/README.md +20 -7
- package/package.json +7 -2
- package/schemas/efficiency-config.schema.json +77 -0
- package/skills/pi-herdr-workflow/SKILL.md +13 -1
- package/src/ansi-text.ts +83 -0
- package/src/ask-multi.ts +260 -0
- package/src/ask-user.ts +458 -0
- package/src/bootstrap.ts +5 -2
- package/src/compact-coordinator.ts +454 -0
- package/src/compact-economics-core.ts +396 -0
- package/src/config-catalog-core.ts +452 -0
- package/src/config-command.ts +181 -0
- package/src/config-guide.ts +361 -0
- package/src/core/grid-shape.ts +4 -2
- package/src/core/heat-plan.ts +160 -0
- package/src/core/observation.ts +555 -0
- package/src/core/subagent.ts +187 -393
- package/src/core/terminal.ts +270 -47
- package/src/core/todo.ts +49 -14
- package/src/dashboard-command.ts +89 -0
- package/src/efficiency-config-core.ts +661 -0
- package/src/efficiency-config-io.ts +53 -0
- package/src/efficiency-store.ts +312 -0
- package/src/focus-poller.ts +244 -0
- package/src/gc-core.ts +85 -0
- package/src/git-adapter.ts +12 -3
- package/src/herdr-client.ts +292 -36
- package/src/index-locks.ts +27 -9
- package/src/index-master.ts +5 -0
- package/src/index-notices.ts +13 -2
- package/src/index-pipe.ts +5 -0
- package/src/index-worker.ts +5 -0
- package/src/index.ts +360 -60
- package/src/jev-client.ts +146 -0
- package/src/jev-core.ts +381 -0
- package/src/lock-core.ts +105 -16
- package/src/observation-core.ts +248 -0
- package/src/pier-options.ts +96 -0
- package/src/pipe-channel.ts +52 -7
- package/src/reducer-core.ts +358 -0
- package/src/reducer-invoker.ts +392 -0
- package/src/renderers.ts +198 -0
- package/src/role-loader.ts +2 -1
- package/src/role-manifest.ts +0 -4
- package/src/runtime-policy.ts +5 -8
- package/src/session-tail.ts +60 -5
- package/src/settle-wake-core.ts +3 -1
- package/src/slim-frame.ts +60 -3
- package/src/storage-layout.ts +23 -0
- package/src/subagent-core.ts +3 -1
- package/src/subagent-execute.ts +328 -0
- package/src/subagent-gc.ts +33 -13
- package/src/subagent-launch.ts +4 -0
- package/src/subagent-list-action.ts +69 -0
- package/src/subagent-output-action.ts +104 -0
- package/src/subagent-output-adapter.ts +27 -0
- package/src/subagent-output-core.ts +263 -0
- package/src/subagent-poll-loop.ts +59 -39
- package/src/subagent-poller.ts +46 -0
- package/src/subagent-ready.ts +89 -0
- package/src/subagent-registry.ts +70 -0
- package/src/subagent-resolution.ts +41 -0
- package/src/subagent-scope.ts +3 -1
- package/src/subagent-session-io.ts +115 -32
- package/src/subagent-spawn.ts +153 -8
- package/src/swallow.ts +75 -0
- package/src/terminal-core.ts +161 -10
- package/src/todo-core.ts +5 -4
- package/src/todo-reminder-core.ts +8 -1
- package/src/todos-service.ts +18 -6
- package/src/tool-error.ts +26 -0
- package/src/vocab.ts +7 -4
package/src/index-worker.ts
CHANGED
|
@@ -16,10 +16,14 @@ export interface TodoOnlyMount {
|
|
|
16
16
|
todos: TodosService;
|
|
17
17
|
todoUi: TodoUiSlot;
|
|
18
18
|
mirrorTodos: () => void;
|
|
19
|
+
/** Human-gate depth source; collapses the todo widget to one line while a gate is open. */
|
|
20
|
+
getBlockedDepth?: () => number;
|
|
19
21
|
/** Master-only stop reminder; omit on worker panes. */
|
|
20
22
|
stopReminder?: {
|
|
21
23
|
getBlockedDepth: () => number;
|
|
22
24
|
getRunningSubs: () => number;
|
|
25
|
+
isCompactionInFlight?: () => boolean;
|
|
26
|
+
isIntentionalAbort?: () => boolean;
|
|
23
27
|
};
|
|
24
28
|
}
|
|
25
29
|
|
|
@@ -36,6 +40,7 @@ export async function mountTodoOnly(m: TodoOnlyMount): Promise<void> {
|
|
|
36
40
|
(m.pi as { appendEntry?: (t: string, d: unknown) => void }).appendEntry?.(customType, data);
|
|
37
41
|
},
|
|
38
42
|
state: m.todoUi,
|
|
43
|
+
...(m.getBlockedDepth ? { getBlockedDepth: m.getBlockedDepth } : {}),
|
|
39
44
|
...(m.stopReminder ? { stopReminder: m.stopReminder } : {}),
|
|
40
45
|
});
|
|
41
46
|
await workerRoot.plugin(todoPlugin);
|
package/src/index.ts
CHANGED
|
@@ -15,16 +15,24 @@
|
|
|
15
15
|
* - tool-result details persist in session JSONL, and getBranch() replay implements branch rollback.
|
|
16
16
|
*/
|
|
17
17
|
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
ASK_PARAMETERS,
|
|
20
|
+
ASK_PROMPT_GUIDELINES,
|
|
21
|
+
ASK_TOOL_DESCRIPTION,
|
|
22
|
+
ASK_TOOL_NAME,
|
|
23
|
+
gateLabel,
|
|
24
|
+
hasAskUi,
|
|
25
|
+
noUiResult,
|
|
26
|
+
prepareAsk,
|
|
27
|
+
runAsk,
|
|
28
|
+
} from './ask-user.ts';
|
|
19
29
|
import { pipeNameFor, pipeRequest, startPipeServer } from './pipe-channel.ts';
|
|
20
30
|
import {
|
|
21
31
|
TODO_EDIT_CUSTOM_TYPE,
|
|
22
32
|
currentActivity,
|
|
23
33
|
} from './todo-core.ts';
|
|
24
34
|
|
|
25
|
-
|
|
26
|
-
const ROLE_MANIFEST_CUSTOM_TYPE = 'pi-herdr.role-manifest';
|
|
27
|
-
|
|
35
|
+
import { APPROVAL_NEEDED_CUSTOM_TYPE, ROLE_MANIFEST_CUSTOM_TYPE, installRenderers } from './renderers.ts';
|
|
28
36
|
import { createHerdrClient } from './herdr-client.ts';
|
|
29
37
|
// The subagent family moved to core/subagent.ts (loader entry, D78/D81); its history-store,
|
|
30
38
|
// session-tail, and gc-core imports moved with it, leaving index.ts only the common readers.
|
|
@@ -32,6 +40,7 @@ import { lastAssistantText, readSessionFile } from './session-tail.ts';
|
|
|
32
40
|
import { fileURLToPath } from 'node:url';
|
|
33
41
|
import { TodosService } from './todos-service.ts';
|
|
34
42
|
import { reconcileTodos } from './reconcile-core.ts';
|
|
43
|
+
import type { TodoUiSlot } from './core/todo.ts';
|
|
35
44
|
import {
|
|
36
45
|
estimateEta,
|
|
37
46
|
formatProgressSuffix,
|
|
@@ -50,6 +59,50 @@ import { emptySubagentPortBox } from './subagent-port.ts';
|
|
|
50
59
|
import { createNoticeBuffer } from './index-notices.ts';
|
|
51
60
|
import { handlePipeRequest } from './index-pipe.ts';
|
|
52
61
|
import { installWriteLocks } from './index-locks.ts';
|
|
62
|
+
import { installDashboardCommand } from './dashboard-command.ts';
|
|
63
|
+
import { registerObservationPack, createCompactionBatchPackHook, type PickMiddleExcerpt } from './core/observation.ts';
|
|
64
|
+
import { createJevRuntime } from './jev-client.ts';
|
|
65
|
+
import {
|
|
66
|
+
NOTICE_RANK_MIN_CONFIDENCE,
|
|
67
|
+
buildExcerptWindows,
|
|
68
|
+
composeNoticeRanking,
|
|
69
|
+
evaluateExcerptPick,
|
|
70
|
+
excerptPickRequest,
|
|
71
|
+
noticeRankRequest,
|
|
72
|
+
} from './jev-core.ts';
|
|
73
|
+
import { completeLineExcerpt } from './observation-core.ts';
|
|
74
|
+
import { installConfigCommand } from './config-command.ts';
|
|
75
|
+
import { CompactCoordinator } from './compact-coordinator.ts';
|
|
76
|
+
import { handleReducerToolResult, type ToolResultEventLike } from './reducer-invoker.ts';
|
|
77
|
+
import {
|
|
78
|
+
pruneSessionObjects,
|
|
79
|
+
resolveSessionRoot,
|
|
80
|
+
} from './efficiency-store.ts';
|
|
81
|
+
import {
|
|
82
|
+
loadEfficiencyConfigFromDisk,
|
|
83
|
+
resolveEfficiencyConfig,
|
|
84
|
+
type EfficiencyConfig,
|
|
85
|
+
} from './efficiency-config-core.ts';
|
|
86
|
+
import {
|
|
87
|
+
resolveDefaultFocusPollMs,
|
|
88
|
+
parseFocusSample,
|
|
89
|
+
spawnReflow,
|
|
90
|
+
startFocusPoller,
|
|
91
|
+
type FocusPoller,
|
|
92
|
+
} from './focus-poller.ts';
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* D-4: focus sampling cadence. `PIER_FOCUS_POLL_MS=0` disables the poller (heat layout then only
|
|
96
|
+
* reacts to herdr events). Invalid values fall back to the version default: 1500ms on Herdr <0.9.1,
|
|
97
|
+
* 0 (event-first) on 0.9.1+ where native pane.focused is authoritative.
|
|
98
|
+
*/
|
|
99
|
+
function focusPollIntervalMs(env: NodeJS.ProcessEnv = process.env, herdrVersion?: string | null): number {
|
|
100
|
+
const defaultMs = resolveDefaultFocusPollMs(herdrVersion);
|
|
101
|
+
const raw = env.PIER_FOCUS_POLL_MS;
|
|
102
|
+
if (raw === undefined || raw.trim() === '') return defaultMs;
|
|
103
|
+
const ms = Number(raw);
|
|
104
|
+
return Number.isFinite(ms) && ms >= 0 ? ms : defaultMs;
|
|
105
|
+
}
|
|
53
106
|
|
|
54
107
|
/**
|
|
55
108
|
* WS-D7: apply the master-pane manifest to itself through the same mandatory chain as subagents
|
|
@@ -87,6 +140,47 @@ export default async function (pi: ExtensionAPI) {
|
|
|
87
140
|
(mode.composeMaster ? composeMasterRuntime() : null);
|
|
88
141
|
const todos = new TodosService(TodosService.configFromRuntime(runtimeManifest, isSubagent));
|
|
89
142
|
const { client, env } = createHerdrClient();
|
|
143
|
+
let effConfig: EfficiencyConfig = resolveEfficiencyConfig();
|
|
144
|
+
const coordinator = new CompactCoordinator();
|
|
145
|
+
let sessionRoot: string | null = null;
|
|
146
|
+
|
|
147
|
+
/* ── Jev decision layer (RFC docs/rfc-jev-integration.md): direct HTTP, total-budget abort,
|
|
148
|
+
* fail-open at every site. Disabled or keyless => behavior is byte-identical to before. ── */
|
|
149
|
+
const jevRuntime = createJevRuntime(() => effConfig.jev, {
|
|
150
|
+
getSessionRoot: () => sessionRoot,
|
|
151
|
+
});
|
|
152
|
+
const jevMinConfidence = (): number => effConfig.jev.minConfidence;
|
|
153
|
+
/** P0-3: candidate windows are generated in code; jev only picks (rerank pattern). */
|
|
154
|
+
const pickMiddleExcerpt: PickMiddleExcerpt = async (text, excerptBudgetBytes) => {
|
|
155
|
+
if (!jevRuntime.available) return null;
|
|
156
|
+
const halfBudget = Math.floor(excerptBudgetBytes / 2);
|
|
157
|
+
const windows = buildExcerptWindows(text, halfBudget);
|
|
158
|
+
if (windows.length === 0) return null; // no failure-signal lines -> keep halves, no API call
|
|
159
|
+
const request = excerptPickRequest(
|
|
160
|
+
windows,
|
|
161
|
+
completeLineExcerpt(text, halfBudget, false),
|
|
162
|
+
completeLineExcerpt(text, excerptBudgetBytes - halfBudget, true),
|
|
163
|
+
);
|
|
164
|
+
if (!request) return null;
|
|
165
|
+
const result = await jevRuntime.ask(request, {
|
|
166
|
+
questionId: 'obs-excerpt-window',
|
|
167
|
+
extra: { site: 'obs-excerpt', candidates: windows.map((w) => w.id) },
|
|
168
|
+
enrich: ({ answers }) => {
|
|
169
|
+
if (!answers || answers.window === undefined || answers.window.type !== 'choice') return {};
|
|
170
|
+
return { picked: answers.window.choice, confidence: answers.window.confidence };
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
if (!result.ok) return null;
|
|
174
|
+
const picked = evaluateExcerptPick(result.answers, jevMinConfidence());
|
|
175
|
+
const window = picked === null ? undefined : windows.find((w) => w.id === picked);
|
|
176
|
+
return window ? { text: window.text, label: window.label } : null;
|
|
177
|
+
};
|
|
178
|
+
// Transcript polish for pier's own custom entries and reminder messages; display-only and
|
|
179
|
+
// best-effort (older pi builds without the renderer API simply keep the raw rows).
|
|
180
|
+
const rendererTypes = installRenderers(pi);
|
|
181
|
+
if (process.env.PI_HERDR_TRACE) {
|
|
182
|
+
console.error(`[pi-herdr] transcript renderers: ${rendererTypes.length ? rendererTypes.join(', ') : 'none'}`);
|
|
183
|
+
}
|
|
90
184
|
|
|
91
185
|
let sessionId: string = process.env.PI_SESSION_FILE ?? process.env.PI_SESSION_ID ?? '';
|
|
92
186
|
/** M16: completion timestamps used as rate-estimation input, appended with todo.completed events. */
|
|
@@ -99,6 +193,7 @@ export default async function (pi: ExtensionAPI) {
|
|
|
99
193
|
const entries = (ctx as { sessionManager?: { getBranch?: () => readonly unknown[] } })
|
|
100
194
|
?.sessionManager?.getBranch?.() ?? [];
|
|
101
195
|
todos.rebuild(entries);
|
|
196
|
+
coordinator.rebuildFromBranch(entries);
|
|
102
197
|
} catch {
|
|
103
198
|
// A reconstruction failure must not disrupt the main flow; the next todo_write re-anchors state.
|
|
104
199
|
}
|
|
@@ -120,11 +215,6 @@ export default async function (pi: ExtensionAPI) {
|
|
|
120
215
|
client.reportAgent(state, message).catch(() => {});
|
|
121
216
|
}
|
|
122
217
|
|
|
123
|
-
function reportSession(path: string): void {
|
|
124
|
-
if (!client.available) return;
|
|
125
|
-
client.reportAgentSession(path).catch(() => {});
|
|
126
|
-
}
|
|
127
|
-
|
|
128
218
|
/** Session identity: sessionManager is authoritative (env may be unset in print/RPC mode), with env as fallback. */
|
|
129
219
|
function resolveSessionId(ctx: unknown): string {
|
|
130
220
|
try {
|
|
@@ -183,7 +273,7 @@ export default async function (pi: ExtensionAPI) {
|
|
|
183
273
|
} catch {
|
|
184
274
|
/* Best effort persistence; in-memory state still advances. */
|
|
185
275
|
}
|
|
186
|
-
todos.applyEdits(plan.edits);
|
|
276
|
+
todos.applyEdits(plan.edits, { source: 'reconcile' });
|
|
187
277
|
mirrorTodos();
|
|
188
278
|
}
|
|
189
279
|
return plan.noteLines;
|
|
@@ -199,8 +289,9 @@ export default async function (pi: ExtensionAPI) {
|
|
|
199
289
|
|
|
200
290
|
/* ── M16: progress and tool badges (title suffix + report_agent.message; no new protocol) ── */
|
|
201
291
|
|
|
202
|
-
todos.on('todo.completed', (e: { count: number; at: number }) => {
|
|
292
|
+
todos.on('todo.completed', (e: { count: number; at: number; source?: import('./todos-service.ts').TodoCompletionSource }) => {
|
|
203
293
|
for (let i = 0; i < e.count; i++) completedStamps.push(e.at);
|
|
294
|
+
coordinator.recordBoundaryCompleted(e.count, e.source);
|
|
204
295
|
mirrorTodos();
|
|
205
296
|
});
|
|
206
297
|
|
|
@@ -228,8 +319,19 @@ export default async function (pi: ExtensionAPI) {
|
|
|
228
319
|
}
|
|
229
320
|
});
|
|
230
321
|
|
|
322
|
+
/* ── D102: Evidence-Preserving Reducer (runs before append-handlers like write-locks) ── */
|
|
323
|
+
pi.on('tool_result', async (event, ctx) => {
|
|
324
|
+
if (!effConfig.evidencePreservingReducer.enabled) return;
|
|
325
|
+
if (!event || typeof event !== 'object') return;
|
|
326
|
+
return handleReducerToolResult(event as unknown as ToolResultEventLike, ctx, effConfig.evidencePreservingReducer, {
|
|
327
|
+
epoch: coordinator.state.epoch,
|
|
328
|
+
jev: { ask: jevRuntime.ask, getMinConfidence: jevMinConfidence },
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
let locksHandle = { getHeldLocks: () => [] as readonly string[] };
|
|
231
333
|
if (env) {
|
|
232
|
-
installWriteLocks(pi, {
|
|
334
|
+
locksHandle = installWriteLocks(pi, {
|
|
233
335
|
client,
|
|
234
336
|
env,
|
|
235
337
|
hard: process.env[WRITE_LOCK_ENV] === '1',
|
|
@@ -237,7 +339,7 @@ export default async function (pi: ExtensionAPI) {
|
|
|
237
339
|
}
|
|
238
340
|
|
|
239
341
|
/* ── todo family slot (core/todo.ts fills the plugin hook; widget rendering moved with the family) ── */
|
|
240
|
-
const todoUi:
|
|
342
|
+
const todoUi: TodoUiSlot = {
|
|
241
343
|
renderWidget: () => { /* No-op before plugin mounting; filled after mounting. */ },
|
|
242
344
|
};
|
|
243
345
|
|
|
@@ -245,6 +347,16 @@ export default async function (pi: ExtensionAPI) {
|
|
|
245
347
|
|
|
246
348
|
pi.on('session_start', async (event, ctx) => {
|
|
247
349
|
sessionId = resolveSessionId(ctx);
|
|
350
|
+
const cwd = (ctx as { cwd?: string }).cwd ?? process.cwd();
|
|
351
|
+
const isTrusted =
|
|
352
|
+
typeof (ctx as { isProjectTrusted?: () => boolean }).isProjectTrusted === 'function'
|
|
353
|
+
? (ctx as { isProjectTrusted: () => boolean }).isProjectTrusted()
|
|
354
|
+
: false;
|
|
355
|
+
effConfig = loadEfficiencyConfigFromDisk({ cwd, isProjectTrusted: isTrusted });
|
|
356
|
+
sessionRoot = resolveSessionRoot(
|
|
357
|
+
(ctx as { sessionManager?: { getSessionDir?: () => string | undefined } })?.sessionManager?.getSessionDir?.(),
|
|
358
|
+
sessionId,
|
|
359
|
+
);
|
|
248
360
|
rebuildFromBranch(ctx);
|
|
249
361
|
// v1.3 M9 fix (observed): on resume pi restores widget state from the session;
|
|
250
362
|
// calling setWidget again breaks the TUI '/' command-panel route ('/' would be sent to the model as message text).
|
|
@@ -254,7 +366,9 @@ export default async function (pi: ExtensionAPI) {
|
|
|
254
366
|
// and the heatmap amplification provides the exit path. Re-register on resume because session switching resets the overlay.
|
|
255
367
|
if (env) registerSlimFrame(ctx);
|
|
256
368
|
mirrorTodos();
|
|
257
|
-
|
|
369
|
+
// D-2: the session path is NOT reported here. herdr's own pi integration publishes it
|
|
370
|
+
// (agent.list/agent.pane show `session_source: "herdr:pi"` on every pi pane, incl. pier subagents),
|
|
371
|
+
// so pier's pane.report_agent_session was a pure duplicate write of the same field by a second source.
|
|
258
372
|
// D93: sidebar identity is the role name (display_agent takes precedence over detected agent, actions.rs:563 in 0.8.2).
|
|
259
373
|
// master → 'master'; worker → manifest.role (prettify worker-default as worker).
|
|
260
374
|
// A bare pi without a manifest does not report, so ordinary pi sessions remain undisturbed.
|
|
@@ -294,6 +408,10 @@ export default async function (pi: ExtensionAPI) {
|
|
|
294
408
|
}
|
|
295
409
|
}
|
|
296
410
|
reportAgent('idle', null);
|
|
411
|
+
// Self-healing: a gate does not survive a process restart, but the ask marker lives in herdr
|
|
412
|
+
// with a 24h TTL, so a session killed while a dialog was open would otherwise keep the pane
|
|
413
|
+
// looking blocked forever. Depth is 0 on session_start, so this cannot race a live gate.
|
|
414
|
+
void client.reportAskFlag(null).catch(() => {});
|
|
297
415
|
});
|
|
298
416
|
|
|
299
417
|
/* ── Tier 2 Weeks 4–5: worker execution enforcement (the manifest's final gate) ──
|
|
@@ -305,14 +423,16 @@ export default async function (pi: ExtensionAPI) {
|
|
|
305
423
|
const tool = typeof event?.toolName === 'string' ? event.toolName : '';
|
|
306
424
|
const gate = planToolGate(tool, runtimeManifest);
|
|
307
425
|
if (gate.kind === 'deny') {
|
|
308
|
-
|
|
426
|
+
// terminate (pi 0.84.1+) stops a batch whose results are all terminating without another
|
|
427
|
+
// model call, so a blocked worker tool no longer costs an extra round trip.
|
|
428
|
+
return { block: true, reason: gate.reason, terminate: true };
|
|
309
429
|
}
|
|
310
430
|
if (gate.kind === 'ask') {
|
|
311
431
|
console.error(`${gate.notice} (v1 soft-approval: allowed, hard gate lands in v2)`);
|
|
312
432
|
// Durable trace (V56 anchor): TUI redraw erases stderr, so the session custom entry is authoritative.
|
|
313
433
|
try {
|
|
314
434
|
(pi as { appendEntry?: (customType: string, data: unknown) => void }).appendEntry?.(
|
|
315
|
-
|
|
435
|
+
APPROVAL_NEEDED_CUSTOM_TYPE,
|
|
316
436
|
{ role: runtimeManifest.role, tool, ts: Date.now() },
|
|
317
437
|
);
|
|
318
438
|
} catch {
|
|
@@ -340,9 +460,32 @@ export default async function (pi: ExtensionAPI) {
|
|
|
340
460
|
let lastStopReason: string | null = null;
|
|
341
461
|
pi.on('turn_start', async () => {
|
|
342
462
|
agentActive = true;
|
|
463
|
+
// Why (A11): Reset lastStopReason on turn_start so a previous turn's abort
|
|
464
|
+
// does not taint subsequent turns or suppress settlement wake / notice flush.
|
|
465
|
+
lastStopReason = null;
|
|
466
|
+
// Safety fallback: if previous compaction finished without clean callback settlement,
|
|
467
|
+
// ensure flags are reset. Pi core prohibits prompts while compaction is active, so this is safe.
|
|
468
|
+
coordinator.compactionInFlight = false;
|
|
469
|
+
coordinator.intentionalAbort = false;
|
|
343
470
|
reportAgent('working', currentActivity(todos.items));
|
|
344
471
|
});
|
|
345
|
-
|
|
472
|
+
|
|
473
|
+
pi.on('before_provider_request', (_event: unknown, ctx: unknown) => {
|
|
474
|
+
const usage = (ctx as { getContextUsage?: () => { tokens?: number | null } })?.getContextUsage?.();
|
|
475
|
+
if (typeof usage?.tokens === 'number') {
|
|
476
|
+
coordinator.onBeforeProviderRequest(usage.tokens);
|
|
477
|
+
}
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
pi.on('input', (event: unknown) => {
|
|
481
|
+
if (event && typeof event === 'object') {
|
|
482
|
+
coordinator.onInput(event as { text?: string; source?: string; streamingBehavior?: string });
|
|
483
|
+
}
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
pi.on('session_before_tree', () => (coordinator.compactionInFlight ? { cancel: true } : undefined));
|
|
487
|
+
|
|
488
|
+
pi.on('turn_end', async (event: unknown, ctx) => {
|
|
346
489
|
if (event === null || typeof event !== 'object' || !('message' in event)) return;
|
|
347
490
|
const msg = (event as { message: unknown }).message; // 'message' in has been guarded.
|
|
348
491
|
if (msg === null || typeof msg !== 'object') return;
|
|
@@ -350,13 +493,22 @@ export default async function (pi: ExtensionAPI) {
|
|
|
350
493
|
if (role === 'assistant' && typeof stopReason === 'string') {
|
|
351
494
|
lastStopReason = stopReason;
|
|
352
495
|
}
|
|
496
|
+
if (ctx && typeof ctx === 'object') {
|
|
497
|
+
coordinator.onTurnEnd({
|
|
498
|
+
ctx,
|
|
499
|
+
todos: todos.items,
|
|
500
|
+
config: effConfig.onlineContextCompact,
|
|
501
|
+
cancelReminder: todoUi.cancelReminder,
|
|
502
|
+
});
|
|
503
|
+
}
|
|
353
504
|
});
|
|
354
505
|
|
|
355
|
-
pi.on('agent_settled', async () => {
|
|
506
|
+
pi.on('agent_settled', async (_event: unknown, ctx) => {
|
|
356
507
|
agentActive = false;
|
|
357
508
|
reportAgent('idle', null);
|
|
358
509
|
const plan = planSettleWake({
|
|
359
510
|
lastStopReason,
|
|
511
|
+
intentionalAbort: coordinator.intentionalAbort,
|
|
360
512
|
running: subagentPort.current?.listRunningSubs() ?? [],
|
|
361
513
|
lastNoticeKey: d96NoticeKey,
|
|
362
514
|
lastNoticeAt: d96NoticeAt,
|
|
@@ -376,6 +528,21 @@ export default async function (pi: ExtensionAPI) {
|
|
|
376
528
|
const notice = `注意:仍有 ${running.length} 个后台 subagent 在运行:${brief}。若你的任务依赖它们,请等待其结算(subagent list 查看状态);若不等待,请说明放弃原因。`;
|
|
377
529
|
void sendUserMessageIn(notice);
|
|
378
530
|
}
|
|
531
|
+
if (ctx && typeof ctx === 'object') {
|
|
532
|
+
void coordinator.onAgentSettled({
|
|
533
|
+
ctx,
|
|
534
|
+
todos: todos.items,
|
|
535
|
+
pi,
|
|
536
|
+
config: effConfig.onlineContextCompact,
|
|
537
|
+
cancelReminder: todoUi.cancelReminder,
|
|
538
|
+
onBeforeCompact: createCompactionBatchPackHook({
|
|
539
|
+
getObsConfig: () => effConfig.observationPack,
|
|
540
|
+
getManifest: () => runtimeManifest,
|
|
541
|
+
getSessionId: () => sessionId,
|
|
542
|
+
pickMiddleExcerpt,
|
|
543
|
+
}),
|
|
544
|
+
});
|
|
545
|
+
}
|
|
379
546
|
});
|
|
380
547
|
|
|
381
548
|
// Official herdr:pi is full-lifecycle authority when installed: it reports blocked
|
|
@@ -383,28 +550,42 @@ export default async function (pi: ExtensionAPI) {
|
|
|
383
550
|
// pane.report_agent is ignored while that authority is live
|
|
384
551
|
// (screen_detection_skip_reason=full_lifecycle_hook_authority). Emit the event so
|
|
385
552
|
// herdr:pi publishes blocked; keep report_agent as fallback when it is absent.
|
|
386
|
-
|
|
553
|
+
/**
|
|
554
|
+
* Open one human gate. Nested gates (pier's ask tool plus pi's own ctx.ui prompt, or an
|
|
555
|
+
* external herdr:blocked producer) are coalesced: only the 0→1 transition reports blocked,
|
|
556
|
+
* marks the ask flag and folds the widget. Returns true when this call opened the outer gate.
|
|
557
|
+
*/
|
|
558
|
+
function enterBlocked(label: string | null): boolean {
|
|
387
559
|
blockedDepth += 1;
|
|
560
|
+
if (blockedDepth > 1) return false;
|
|
388
561
|
reportAgent('blocked', label);
|
|
389
562
|
// D95: human-gate marker lets the workbench heatmap distinguish ask from block.
|
|
390
563
|
if (label) void client.reportAskFlag(label).catch(() => {});
|
|
564
|
+
// Gate open collapses the todo widget to one line — the question owns the fixed area.
|
|
565
|
+
todoUi.rerenderWidget?.();
|
|
566
|
+
return true;
|
|
391
567
|
}
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
568
|
+
/** Close one gate; only the 1→0 transition restores the pre-gate state. Returns true when closed.
|
|
569
|
+
* An unmatched release (no gate open) is ignored rather than reported, so a stray
|
|
570
|
+
* ui_prompt_end cannot emit a false "no longer blocked" edge. */
|
|
571
|
+
function exitBlocked(): boolean {
|
|
572
|
+
if (blockedDepth === 0) return false;
|
|
573
|
+
blockedDepth -= 1;
|
|
574
|
+
// Gate closed → restore the full widget window.
|
|
575
|
+
todoUi.rerenderWidget?.();
|
|
576
|
+
if (blockedDepth > 0) return false;
|
|
577
|
+
reportAgent(agentActive ? 'working' : 'idle', agentActive ? currentActivity(todos.items) : null);
|
|
578
|
+
// D95: gate released → clear marker.
|
|
579
|
+
void client.reportAskFlag(null).catch(() => {});
|
|
580
|
+
return true;
|
|
399
581
|
}
|
|
400
582
|
|
|
401
583
|
// emit() is synchronous; this flag stops our own listener from double-counting depth.
|
|
402
584
|
let publishingHerdrBlocked = false;
|
|
403
|
-
|
|
585
|
+
/** Publish the herdr:blocked edge for pi-herdr's own gates; no-op re-entry is prevented by the flag. */
|
|
586
|
+
function emitHerdrBlocked(active: boolean, label: string | null): void {
|
|
404
587
|
publishingHerdrBlocked = true;
|
|
405
588
|
try {
|
|
406
|
-
if (active) enterBlocked(label);
|
|
407
|
-
else exitBlocked();
|
|
408
589
|
pi.events.emit(
|
|
409
590
|
'herdr:blocked',
|
|
410
591
|
active ? { active: true, ...(label ? { label } : {}) } : { active: false },
|
|
@@ -413,6 +594,11 @@ export default async function (pi: ExtensionAPI) {
|
|
|
413
594
|
publishingHerdrBlocked = false;
|
|
414
595
|
}
|
|
415
596
|
}
|
|
597
|
+
function publishHerdrBlocked(active: boolean, label: string | null): void {
|
|
598
|
+
if (active) enterBlocked(label);
|
|
599
|
+
else exitBlocked();
|
|
600
|
+
emitHerdrBlocked(active, label);
|
|
601
|
+
}
|
|
416
602
|
pi.events.on('herdr:blocked', (data) => {
|
|
417
603
|
if (publishingHerdrBlocked) return;
|
|
418
604
|
if (!data || typeof data !== 'object') {
|
|
@@ -426,47 +612,96 @@ export default async function (pi: ExtensionAPI) {
|
|
|
426
612
|
enterBlocked('label' in data && typeof data.label === 'string' ? data.label : null);
|
|
427
613
|
});
|
|
428
614
|
|
|
615
|
+
/* ── Generic human-gate reporting (pi 0.84.4+) ─────────────────────────
|
|
616
|
+
* Why: `ui_prompt_start` / `ui_prompt_end` fire around blocking ctx.ui dialogs.
|
|
617
|
+
* Scope: only the inherently human-blocking kinds (select / confirm / input / editor).
|
|
618
|
+
* `custom` is deliberately excluded: pi routes both modals and *persistent overlays*
|
|
619
|
+
* through ctx.ui.custom, and pier itself registers a resident slim-frame overlay that
|
|
620
|
+
* never calls done() — treating it as a gate left the pane blocked (and the ask marker
|
|
621
|
+
* set) for the entire session while the agent kept working. Note that a resident overlay
|
|
622
|
+
* also holds pi's own prompt span open, so these events generally stop firing in herdr
|
|
623
|
+
* panes anyway; pier's ask tool opens its gate explicitly and is the reliable path there.
|
|
624
|
+
* Probed through a structural type because the events postdate our pinned pi devDependency. */
|
|
625
|
+
const uiPromptEvents = pi as unknown as {
|
|
626
|
+
on?: (name: 'ui_prompt_start' | 'ui_prompt_end', handler: (event: unknown) => void) => void;
|
|
627
|
+
};
|
|
628
|
+
const GATING_PROMPT_KINDS = new Set(['select', 'confirm', 'input', 'editor']);
|
|
629
|
+
uiPromptEvents.on?.('ui_prompt_start', (event) => {
|
|
630
|
+
const rec = (event ?? {}) as { kind?: unknown; title?: unknown };
|
|
631
|
+
const title = typeof rec.title === 'string' && rec.title.trim() ? rec.title.trim() : null;
|
|
632
|
+
const kind = typeof rec.kind === 'string' ? rec.kind : 'prompt';
|
|
633
|
+
if (!GATING_PROMPT_KINDS.has(kind)) return;
|
|
634
|
+
// Normalize once so the gate report and the published edge carry the same label.
|
|
635
|
+
const label = title ?? kind;
|
|
636
|
+
// The ask tool opens its own gate first, so this is usually a nested (no-op) transition;
|
|
637
|
+
// prompts from other extensions or pi core open the gate here.
|
|
638
|
+
if (enterBlocked(label)) emitHerdrBlocked(true, label);
|
|
639
|
+
});
|
|
640
|
+
uiPromptEvents.on?.('ui_prompt_end', () => {
|
|
641
|
+
if (exitBlocked()) emitHerdrBlocked(false, null);
|
|
642
|
+
});
|
|
643
|
+
|
|
429
644
|
/* ── Tool: ask_user_question (v1.3 M8 human gate, available to master and subagents) ── */
|
|
430
645
|
|
|
646
|
+
/* ── D101: ObservationPack (obs_recall tool & context projector) ── */
|
|
647
|
+
registerObservationPack({
|
|
648
|
+
pi,
|
|
649
|
+
getConfig: () => effConfig,
|
|
650
|
+
getRuntimeManifest: () => runtimeManifest,
|
|
651
|
+
pickMiddleExcerpt,
|
|
652
|
+
getRemainingHorizon: () => {
|
|
653
|
+
const usage = (latestCtx as { getContextUsage?: () => { contextWindow?: number } })?.getContextUsage?.();
|
|
654
|
+
return coordinator.getRemainingHorizon(3, usage?.contextWindow ?? null);
|
|
655
|
+
},
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
/* ── D104: /pier-config (read-only config guide + guided-change handoff) ──
|
|
659
|
+
* Supersedes the former /efficiency command: the index line reports OCC/OBS/EPR state and
|
|
660
|
+
* `show efficiency` lists every D100-D103 knob with its effective value and source. */
|
|
661
|
+
|
|
662
|
+
installConfigCommand({ pi });
|
|
663
|
+
installDashboardCommand({
|
|
664
|
+
pi,
|
|
665
|
+
client,
|
|
666
|
+
env,
|
|
667
|
+
getTodoItems: () => todos.items,
|
|
668
|
+
getHeldLocks: () => locksHandle.getHeldLocks(),
|
|
669
|
+
});
|
|
670
|
+
|
|
431
671
|
pi.registerTool({
|
|
432
|
-
name:
|
|
672
|
+
name: ASK_TOOL_NAME,
|
|
433
673
|
label: 'Ask User',
|
|
434
|
-
description:
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
}
|
|
448
|
-
const ui = (ctx as {
|
|
449
|
-
ui?: { input?: (title: string, placeholder?: string) => Promise<string | undefined> };
|
|
450
|
-
}).ui;
|
|
451
|
-
publishHerdrBlocked(true, question);
|
|
452
|
-
// Fallback when herdr:pi is absent: a one-shot pi-herdr blocked report can lose
|
|
453
|
-
// to later working reports; refresh while the gate is open. Do not re-emit
|
|
454
|
-
// herdr:blocked here — official blockedCount is edge-triggered.
|
|
455
|
-
const hb = setInterval(() => { reportAgent('blocked', question); }, 5000);
|
|
674
|
+
description: ASK_TOOL_DESCRIPTION,
|
|
675
|
+
promptGuidelines: ASK_PROMPT_GUIDELINES,
|
|
676
|
+
parameters: ASK_PARAMETERS,
|
|
677
|
+
async execute(_tc, params, signal, _upd, ctx) {
|
|
678
|
+
const prepared = prepareAsk(params);
|
|
679
|
+
if (!prepared.ok) return prepared.result;
|
|
680
|
+
const ui = ctx && typeof ctx === 'object' && 'ui' in ctx ? ctx.ui : undefined;
|
|
681
|
+
if (!hasAskUi(ui)) return noUiResult();
|
|
682
|
+
const label = gateLabel(prepared.spec);
|
|
683
|
+
publishHerdrBlocked(true, label);
|
|
684
|
+
// The gate stays reported for the whole wait: reportAgent() drops working/idle reports while
|
|
685
|
+
// blockedDepth > 0, and pi's ui_prompt_start/end events keep the depth honest for prompts
|
|
686
|
+
// that do not come from this tool. (The old 5s refresh interval is obsolete.)
|
|
456
687
|
try {
|
|
457
|
-
|
|
458
|
-
return {
|
|
459
|
-
content: [{ type: 'text', text: `The human answered: ${answer ?? '(no answer given)'}` }],
|
|
460
|
-
details: { question, answer: answer ?? null },
|
|
461
|
-
};
|
|
688
|
+
return await runAsk(prepared.spec, ui, signal instanceof AbortSignal ? signal : undefined);
|
|
462
689
|
} finally {
|
|
463
|
-
clearInterval(hb);
|
|
464
690
|
publishHerdrBlocked(false, null);
|
|
465
691
|
}
|
|
466
692
|
},
|
|
467
693
|
});
|
|
468
694
|
|
|
469
695
|
pi.on('session_shutdown', async () => {
|
|
696
|
+
coordinator.compactionInFlight = false;
|
|
697
|
+
coordinator.intentionalAbort = false;
|
|
698
|
+
if (sessionRoot) {
|
|
699
|
+
try {
|
|
700
|
+
await pruneSessionObjects(sessionRoot);
|
|
701
|
+
} catch {
|
|
702
|
+
/* ignore shutdown pruning errors */
|
|
703
|
+
}
|
|
704
|
+
}
|
|
470
705
|
client.close();
|
|
471
706
|
});
|
|
472
707
|
|
|
@@ -480,20 +715,56 @@ export default async function (pi: ExtensionAPI) {
|
|
|
480
715
|
return true;
|
|
481
716
|
}
|
|
482
717
|
const pipeServerBox: { current: ReturnType<typeof startPipeServer> | null } = { current: null };
|
|
718
|
+
/** D-4: focus sampler for this pane (started on session_start, stopped on session_shutdown). */
|
|
719
|
+
const sessionFocusPoller: { current: FocusPoller | null } = { current: null };
|
|
483
720
|
let pendingMachineRequest: { id: string; from: string | null; push: boolean; sinceTs: number } | null = null;
|
|
484
721
|
let latestCtx: { abort?: () => void } | null = null;
|
|
485
722
|
|
|
486
723
|
// triggerTurn:true — idle followUp must start a new turn or settlement is lost.
|
|
487
724
|
const sendUserMessageIn = (content: string): Promise<void> =>
|
|
488
|
-
(pi as { sendUserMessage?: (content: string, opts?: { deliverAs?: string; triggerTurn?: boolean }) => Promise<void> })
|
|
725
|
+
(pi as unknown as { sendUserMessage?: (content: string, opts?: { deliverAs?: string; triggerTurn?: boolean }) => Promise<void> })
|
|
489
726
|
.sendUserMessage?.(content, { deliverAs: 'followUp', triggerTurn: true }) ?? Promise.resolve();
|
|
490
727
|
const sendUserMessageAs = (content: string, mode: 'steer' | 'followUp'): Promise<void> =>
|
|
491
|
-
(pi as { sendUserMessage?: (content: string, opts?: { deliverAs?: string; triggerTurn?: boolean }) => Promise<void> })
|
|
728
|
+
(pi as unknown as { sendUserMessage?: (content: string, opts?: { deliverAs?: string; triggerTurn?: boolean }) => Promise<void> })
|
|
492
729
|
.sendUserMessage?.(content, { deliverAs: mode, triggerTurn: true }) ?? Promise.resolve();
|
|
493
730
|
|
|
494
731
|
const notices = createNoticeBuffer({
|
|
495
732
|
isBusy: () => agentActive || lastStopReason === ABORT_STOP_REASON,
|
|
496
733
|
send: sendUserMessageAs,
|
|
734
|
+
// P0-2: rank collapsed batches by relevance to the master's in-progress work
|
|
735
|
+
rank: async (contents) => {
|
|
736
|
+
if (!jevRuntime.available) return null;
|
|
737
|
+
const inProgressTodos = todos.items
|
|
738
|
+
.filter((item) => item.status === 'in_progress')
|
|
739
|
+
.map((item) => item.content);
|
|
740
|
+
let rankedOrder: number[] | null | undefined;
|
|
741
|
+
const result = await jevRuntime.ask(
|
|
742
|
+
noticeRankRequest({ inProgressTodos, notices: [...contents] }),
|
|
743
|
+
{
|
|
744
|
+
questionId: 'notice-rank',
|
|
745
|
+
sessionId,
|
|
746
|
+
extra: { site: 'notice-rank', noticeCount: contents.length },
|
|
747
|
+
enrich: ({ answers }) => {
|
|
748
|
+
if (!answers) return {};
|
|
749
|
+
rankedOrder = composeNoticeRanking(
|
|
750
|
+
contents.length,
|
|
751
|
+
answers,
|
|
752
|
+
Math.max(effConfig.jev.minConfidence, NOTICE_RANK_MIN_CONFIDENCE),
|
|
753
|
+
);
|
|
754
|
+
const values = Array.from({ length: contents.length }, (_, i) => {
|
|
755
|
+
const rank = answers[`notice_${i}_rank`];
|
|
756
|
+
const fail = answers[`notice_${i}_fail`];
|
|
757
|
+
return rank !== undefined && fail !== undefined && rank.type === 'score' && fail.type === 'noul'
|
|
758
|
+
? `${rank.score.toFixed(2)}:${rank.confidence.toFixed(2)}:${fail.noul.toFixed(2)}`
|
|
759
|
+
: '?';
|
|
760
|
+
});
|
|
761
|
+
return { order: rankedOrder === null ? 'fallback' : rankedOrder.join(','), values };
|
|
762
|
+
},
|
|
763
|
+
},
|
|
764
|
+
);
|
|
765
|
+
if (!result.ok) return null;
|
|
766
|
+
return rankedOrder === undefined || rankedOrder === null ? null : rankedOrder.map((index) => contents[index]!);
|
|
767
|
+
},
|
|
497
768
|
});
|
|
498
769
|
const deliverNotice = notices.deliverNotice;
|
|
499
770
|
pi.on('turn_end', () => {
|
|
@@ -508,6 +779,28 @@ export default async function (pi: ExtensionAPI) {
|
|
|
508
779
|
latestCtx = ctx as { abort?: () => void } | null;
|
|
509
780
|
const cwd = (ctx as { cwd?: string }).cwd ?? process.cwd();
|
|
510
781
|
const paneId = env?.paneId ?? '';
|
|
782
|
+
// D-4: herdr 0.9.0 never delivered pane.focused to plugins; 0.9.1+ does. Poller is a
|
|
783
|
+
// fallback for old servers (see resolveDefaultFocusPollMs). One poller per pane, self-scoped.
|
|
784
|
+
if (sessionFocusPoller.current) {
|
|
785
|
+
sessionFocusPoller.current.stop();
|
|
786
|
+
sessionFocusPoller.current = null;
|
|
787
|
+
}
|
|
788
|
+
const serverVer = await client.getServerVersion();
|
|
789
|
+
const pollMs = focusPollIntervalMs(process.env, serverVer);
|
|
790
|
+
if (paneId && client.available && pollMs > 0) {
|
|
791
|
+
sessionFocusPoller.current = startFocusPoller({
|
|
792
|
+
myPaneId: paneId,
|
|
793
|
+
intervalMs: pollMs,
|
|
794
|
+
sample: async () => {
|
|
795
|
+
// layout.export reports the focused pane of our own tab. Without that field (older herdr)
|
|
796
|
+
// the tick stays silent instead of guessing focus from the tree.
|
|
797
|
+
const layout = await client.exportLayout({ paneId });
|
|
798
|
+
const sample = parseFocusSample(layout);
|
|
799
|
+
return sample && sample.focusedPaneId !== null ? sample : null;
|
|
800
|
+
},
|
|
801
|
+
fire: (focusedPaneId, cause) => spawnReflow({ paneId: focusedPaneId, cause }),
|
|
802
|
+
});
|
|
803
|
+
}
|
|
511
804
|
if (!paneId) return;
|
|
512
805
|
const name = pipeNameFor(cwd, paneId);
|
|
513
806
|
if (pipeServerBox.current) {
|
|
@@ -530,6 +823,8 @@ export default async function (pi: ExtensionAPI) {
|
|
|
530
823
|
}
|
|
531
824
|
});
|
|
532
825
|
pi.on('session_shutdown', () => {
|
|
826
|
+
sessionFocusPoller.current?.stop();
|
|
827
|
+
sessionFocusPoller.current = null;
|
|
533
828
|
if (pipeServerBox.current) {
|
|
534
829
|
try { pipeServerBox.current.close(); } catch { /* Already closed. */ }
|
|
535
830
|
pipeServerBox.current = null;
|
|
@@ -581,6 +876,8 @@ export default async function (pi: ExtensionAPI) {
|
|
|
581
876
|
reconcileOnSettlement,
|
|
582
877
|
withReconcileNotes,
|
|
583
878
|
claimSettleNotice,
|
|
879
|
+
isCompactionInFlight: () => coordinator.compactionInFlight,
|
|
880
|
+
isIntentionalAbort: () => coordinator.intentionalAbort,
|
|
584
881
|
});
|
|
585
882
|
} else {
|
|
586
883
|
const { mountTodoOnly } = await import('./index-worker.ts');
|
|
@@ -589,12 +886,15 @@ export default async function (pi: ExtensionAPI) {
|
|
|
589
886
|
todos,
|
|
590
887
|
todoUi,
|
|
591
888
|
mirrorTodos,
|
|
889
|
+
getBlockedDepth: () => blockedDepth,
|
|
592
890
|
...(isSubagent
|
|
593
891
|
? {}
|
|
594
892
|
: {
|
|
595
893
|
stopReminder: {
|
|
596
894
|
getBlockedDepth: () => blockedDepth,
|
|
597
895
|
getRunningSubs: () => subagentPort.current?.listRunningSubs().length ?? 0,
|
|
896
|
+
isCompactionInFlight: () => coordinator.compactionInFlight,
|
|
897
|
+
isIntentionalAbort: () => coordinator.intentionalAbort,
|
|
598
898
|
},
|
|
599
899
|
}),
|
|
600
900
|
});
|