pi-pier 0.3.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 +7 -5
- package/package.json +3 -3
- package/schemas/efficiency-config.schema.json +6 -0
- package/src/ask-dialog.ts +9 -2
- package/src/ask-user.ts +142 -74
- package/src/bootstrap.ts +2 -1
- package/src/compact-coordinator.ts +207 -35
- package/src/compact-economics-core.ts +62 -29
- package/src/config-catalog-core.ts +1 -0
- package/src/cordis-services.ts +64 -0
- package/src/dashboard-command.ts +40 -15
- package/src/delegation-core.ts +115 -0
- package/src/efficiency-config-core.ts +17 -6
- package/src/efficiency-store.ts +43 -6
- package/src/guards.ts +6 -0
- package/src/herdr-client.ts +141 -36
- package/src/history-store.ts +2 -0
- package/src/index-gates.ts +6 -12
- package/src/index-master.ts +12 -16
- package/src/index-pipe.ts +6 -0
- package/src/index-runtime.ts +14 -0
- package/src/index-worker.ts +4 -10
- package/src/index.ts +310 -76
- package/src/jev-client.ts +32 -7
- package/src/jev-core.ts +71 -36
- package/src/observation-core.ts +13 -1
- package/src/panel-open.ts +47 -0
- package/src/pier-options.ts +1 -2
- package/src/pipe-channel.ts +9 -0
- package/src/plugins/heat-plan.ts +18 -3
- package/src/plugins/observation.ts +118 -46
- package/src/plugins/subagent.ts +119 -68
- package/src/plugins/terminal.ts +221 -57
- package/src/plugins/todo.ts +63 -63
- package/src/reducer-core.ts +3 -5
- package/src/reducer-invoker.ts +27 -13
- package/src/renderers.ts +25 -2
- package/src/role-manifest.ts +2 -3
- package/src/roles/worker-default.json +2 -2
- package/src/settle-wake-core.ts +17 -3
- package/src/subagent-core.ts +209 -3
- package/src/subagent-poller.ts +69 -19
- package/src/subagent-session.ts +67 -4
- package/src/subagent-spawn.ts +55 -7
- package/src/terminal-core.ts +178 -50
- package/src/todo-core.ts +47 -2
- package/src/todo-read-hook.ts +54 -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/tree-view.ts +162 -0
- package/src/vocab.ts +7 -1
- package/src/worker-marker.ts +76 -0
- package/src/focus-poller.ts +0 -223
package/src/plugins/todo.ts
CHANGED
|
@@ -7,16 +7,17 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { Context } from '@deepseek-ai/cordis';
|
|
9
9
|
import { Type } from 'typebox';
|
|
10
|
-
import
|
|
10
|
+
import { cordisGet } from '../cordis-services.ts';
|
|
11
11
|
import type { TodosService } from '../todos-service.ts';
|
|
12
|
-
import { planTodoReadHook } from '../todo-read-hook.ts';
|
|
12
|
+
import { planTodoReadHook, shouldRunTodoRead } from '../todo-read-hook.ts';
|
|
13
13
|
import { TODO_REMINDER_CUSTOM_TYPE, planStopTodoReminder, todoReminderGraceMs } from '../todo-reminder-core.ts';
|
|
14
14
|
import { makeProgressUpdate } from '../subagent-core.ts';
|
|
15
15
|
import { TODO_DETAILS_KEY, TODO_STATUSES, TODO_TOOL_NAME, formatTodoConfirmation, type TodoItem } from '../vocab.ts';
|
|
16
16
|
import { toolError } from '../tool-error.ts';
|
|
17
17
|
import { formatAge, isArchived } from '../stale-core.ts';
|
|
18
|
-
import { TODO_EDIT_CUSTOM_TYPE, completionTransitions, fuzzyFind, listsEqual, makeSnapshot, normalizeStrict, revertedCompleted, validateTodos, type TodoEditOp } from '../todo-core.ts';
|
|
19
|
-
import {
|
|
18
|
+
import { TODO_ARCHIVE_WINDOW_CUSTOM_TYPE, TODO_EDIT_CUSTOM_TYPE, completionTransitions, fuzzyFind, listsEqual, makeSnapshot, normalizeStrict, revertedCompleted, todoListHash, validateTodos, type TodoEditOp } from '../todo-core.ts';
|
|
19
|
+
import { formatTodoSummary, renderTodoGroups } from '../todo-window.ts';
|
|
20
|
+
import { unifiedWidgetLines, type WorkerWidgetRow } from '../tree-view.ts';
|
|
20
21
|
import { swallow } from '../swallow.ts';
|
|
21
22
|
|
|
22
23
|
type ToolParams = Record<string, unknown> | undefined;
|
|
@@ -29,7 +30,7 @@ export interface TodoUiSlot {
|
|
|
29
30
|
cancelReminder?: () => void;
|
|
30
31
|
}
|
|
31
32
|
|
|
32
|
-
interface TodoDeps {
|
|
33
|
+
export interface TodoDeps {
|
|
33
34
|
todos: TodosService;
|
|
34
35
|
allowParallelInProgress: boolean;
|
|
35
36
|
/** Warn above this size without rejecting otherwise valid plans. */
|
|
@@ -39,6 +40,8 @@ interface TodoDeps {
|
|
|
39
40
|
state: TodoUiSlot;
|
|
40
41
|
/** Human-gate depth (ask_user_question / herdr:blocked); >0 collapses the widget to one line. */
|
|
41
42
|
getBlockedDepth?: () => number;
|
|
43
|
+
/** D113: running subagent rows for the unified widget; master mounts only. */
|
|
44
|
+
getWorkers?: () => readonly WorkerWidgetRow[];
|
|
42
45
|
/** Master-only unfinished-todo reminder; omitted on worker panes. */
|
|
43
46
|
stopReminder?: {
|
|
44
47
|
getBlockedDepth: () => number;
|
|
@@ -49,17 +52,12 @@ interface TodoDeps {
|
|
|
49
52
|
}
|
|
50
53
|
|
|
51
54
|
const TOOL_DESCRIPTION = [
|
|
52
|
-
'Record
|
|
53
|
-
'Entries: `content` (
|
|
54
|
-
'
|
|
55
|
-
'
|
|
56
|
-
'Before stopping, reconcile open entries: anything waiting on a human (decision, approval, or an ops action you cannot perform) must be marked blocked with a blocker note — never left pending, and never executed just to clear the list; finished or obsolete entries must be completed or removed.',
|
|
57
|
-
'Do not make todo_write the only tool call of a turn — batch it together with real work.',
|
|
58
|
-
'Delegated work belongs on this list too. When you hand an entry to a subagent, append ` <sub>` to its content (the subagent is doing it, not you); when the subagent settles, a matching entry is auto-completed — you will see "Reconciled:" in the settlement note. If no auto-match fired, update the entry yourself.',
|
|
55
|
+
'Record the complete task list for the current work. Every call REPLACES the whole list, so always resend it in full.',
|
|
56
|
+
'Entries: `content` (unique short imperative, no numbering), `status`, `blocker` (only when blocked: what it waits for), optional `phase` (group for a multi-stage plan). At most 15 entries, one per meaningful unit of work; several may be in_progress when work truly runs in parallel.',
|
|
57
|
+
'Do not make todo_write the only tool call of a turn — batch it with real work.',
|
|
58
|
+
'When you hand an entry to a subagent, append ` <sub>` to its content; it auto-completes when the subagent settles ("Reconciled:" in the note), otherwise update it yourself.',
|
|
59
59
|
].join(' ');
|
|
60
60
|
|
|
61
|
-
/** pi interactive mode renders at most ten widget lines. */
|
|
62
|
-
const WIDGET_MAX_LINES = 10;
|
|
63
61
|
|
|
64
62
|
/** `/todos <op> <query>` verbs; the value is the user-facing past-tense feedback. */
|
|
65
63
|
const OP_VERBS: Record<TodoEditOp, string> = {
|
|
@@ -69,48 +67,21 @@ const OP_VERBS: Record<TodoEditOp, string> = {
|
|
|
69
67
|
rm: 'removed',
|
|
70
68
|
};
|
|
71
69
|
|
|
72
|
-
/**
|
|
73
|
-
* Keep the active task visible within WIDGET_MAX_LINES rather than truncating to a fixed head or tail:
|
|
74
|
-
* the window anchors on the first in-progress item, then the last open item, then recent completions.
|
|
75
|
-
*/
|
|
70
|
+
/** D113 unified resident widget (see tree-view.ts for the line rules). */
|
|
76
71
|
export function widgetLines(
|
|
77
72
|
items: readonly TodoItem[],
|
|
78
|
-
opts?: { archivedAgeMs?: number | null; blockedDepth?: number | null },
|
|
73
|
+
opts?: { archivedAgeMs?: number | null; blockedDepth?: number | null; workers?: readonly WorkerWidgetRow[] },
|
|
79
74
|
): string[] {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
return [`${formatTodoSummary(items)} · /todos 全量`];
|
|
85
|
-
}
|
|
86
|
-
// Collapse archived plans to two lines so stale work cannot monopolize the widget.
|
|
87
|
-
if (opts?.archivedAgeMs != null) {
|
|
88
|
-
return [
|
|
89
|
-
`${formatTodoSummary(items)} · archived ${formatAge(opts.archivedAgeMs)}`,
|
|
90
|
-
' archived — /todos 全量',
|
|
91
|
-
];
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
// Reserve summary and overflow lines; phase headers consume the budget too, hence the shrink loop.
|
|
95
|
-
const renderBudget = WIDGET_MAX_LINES - 2;
|
|
96
|
-
const [start, end] = anchorTodoRange(
|
|
97
|
-
items,
|
|
98
|
-
(s, e) => renderTodoGroups(items.slice(s, e)).length <= renderBudget,
|
|
99
|
-
);
|
|
100
|
-
const kept = items.slice(start, end);
|
|
101
|
-
const hidden = items.filter((_, i) => i < start || i >= end);
|
|
102
|
-
const lines = [formatTodoSummary(items), ...renderTodoGroups(kept)];
|
|
103
|
-
if (hidden.length > 0) {
|
|
104
|
-
const hiddenCompleted = hidden.filter((it) => it.status === 'completed').length;
|
|
105
|
-
lines.push(` +${hidden.length} hidden (${hiddenCompleted}✓) · /todos 全量`);
|
|
106
|
-
}
|
|
107
|
-
return lines;
|
|
75
|
+
return unifiedWidgetLines(items, opts?.workers ?? [], {
|
|
76
|
+
archivedAgeMs: opts?.archivedAgeMs,
|
|
77
|
+
blockedDepth: opts?.blockedDepth,
|
|
78
|
+
});
|
|
108
79
|
}
|
|
109
80
|
|
|
110
81
|
export default function todoPlugin(ctx: Context): void {
|
|
111
|
-
const surface = ctx
|
|
112
|
-
const { todos, allowParallelInProgress, maxItems, mirrorTodos, appendEntry, state, stopReminder, getBlockedDepth } =
|
|
113
|
-
ctx
|
|
82
|
+
const surface = cordisGet(ctx, 'pi-herdr.surface');
|
|
83
|
+
const { todos, allowParallelInProgress, maxItems, mirrorTodos, appendEntry, state, stopReminder, getBlockedDepth, getWorkers } =
|
|
84
|
+
cordisGet(ctx, 'pi-herdr.todo-deps');
|
|
114
85
|
const scoped = surface.forModule(import.meta.url);
|
|
115
86
|
|
|
116
87
|
/** Share one archive-age decision between the widget and /todos. */
|
|
@@ -129,6 +100,7 @@ export default function todoPlugin(ctx: Context): void {
|
|
|
129
100
|
ui?.setWidget?.('todos', widgetLines(todos.items, {
|
|
130
101
|
archivedAgeMs: archivedAgeMs(),
|
|
131
102
|
blockedDepth: getBlockedDepth?.() ?? 0,
|
|
103
|
+
workers: getWorkers?.() ?? [],
|
|
132
104
|
}));
|
|
133
105
|
} catch {
|
|
134
106
|
/* Older pi versions may omit widget support without disabling todo tracking. */
|
|
@@ -151,7 +123,10 @@ export default function todoPlugin(ctx: Context): void {
|
|
|
151
123
|
staleNotices = 0;
|
|
152
124
|
});
|
|
153
125
|
|
|
154
|
-
scoped.on('before_agent_start', async (eventCtx?: unknown) => {
|
|
126
|
+
scoped.on('before_agent_start', async (eventCtx?: unknown, ctx?: unknown) => {
|
|
127
|
+
// pi 1.2+ also fires this for extension-triggered runs; skip the ones that would double-inject
|
|
128
|
+
// or consume the guard cadence (stop reminder, config guide, third-party custom runs).
|
|
129
|
+
if (!shouldRunTodoRead(eventCtx)) return;
|
|
155
130
|
const plan = planTodoReadHook({
|
|
156
131
|
items: todos.items,
|
|
157
132
|
turn: todoReadTurn,
|
|
@@ -161,12 +136,28 @@ export default function todoPlugin(ctx: Context): void {
|
|
|
161
136
|
now: Date.now(),
|
|
162
137
|
staleNotices,
|
|
163
138
|
lastStaleGuardTurn,
|
|
139
|
+
archiveWindowOffered: todos.archiveWindowOffered,
|
|
164
140
|
});
|
|
165
141
|
if (plan.inject && plan.effect === 'stale-notice') {
|
|
166
142
|
staleNotices += 1;
|
|
167
143
|
lastStaleGuardTurn = todoReadTurn;
|
|
168
144
|
}
|
|
169
|
-
//
|
|
145
|
+
// Persist the one-window offer against this list identity: the plugin closure dies with the
|
|
146
|
+
// process, and on /resume the same dead list must reach the terminal clear instead of re-warning.
|
|
147
|
+
if (plan.inject && plan.offerArchiveWindow) {
|
|
148
|
+
try {
|
|
149
|
+
appendEntry(TODO_ARCHIVE_WINDOW_CUSTOM_TYPE, {
|
|
150
|
+
version: 1,
|
|
151
|
+
itemsHash: todoListHash(todos.items),
|
|
152
|
+
});
|
|
153
|
+
} catch (err) {
|
|
154
|
+
// The in-memory mark still advances escalation in this process.
|
|
155
|
+
swallow('todo.persist-archive-window', err);
|
|
156
|
+
}
|
|
157
|
+
todos.markArchiveWindowOffered();
|
|
158
|
+
}
|
|
159
|
+
// Empty-list guards and the archive rewrite window share one cadence so adjacent notices do not
|
|
160
|
+
// each consume the grace window; the terminal clear is immediate and only marks the tick.
|
|
170
161
|
if (plan.inject && (plan.effect === 'empty-guard' || plan.archived)) {
|
|
171
162
|
lastEmptyGuardTurn = todoReadTurn;
|
|
172
163
|
}
|
|
@@ -181,6 +172,11 @@ export default function todoPlugin(ctx: Context): void {
|
|
|
181
172
|
swallow('todo.persist-archive', err);
|
|
182
173
|
}
|
|
183
174
|
todos.replace([], { source: 'archive' });
|
|
175
|
+
// The clear is the one todo mutation outside the tool/human paths that must refresh the
|
|
176
|
+
// title, sidebar token and widget; the dead list would otherwise stay authoritative. The hook's
|
|
177
|
+
// first argument is the event (no ui), so render through the run ctx; tests pass a ui ctx first.
|
|
178
|
+
mirrorTodos();
|
|
179
|
+
renderWidget(ctx ?? eventCtx);
|
|
184
180
|
}
|
|
185
181
|
todoReadTurn += 1;
|
|
186
182
|
if (!plan.inject) return;
|
|
@@ -216,11 +212,18 @@ export default function todoPlugin(ctx: Context): void {
|
|
|
216
212
|
const { role, stopReason } = msg as { role?: unknown; stopReason?: unknown };
|
|
217
213
|
if (role === 'assistant' && typeof stopReason === 'string') lastAssistantStopReason = stopReason;
|
|
218
214
|
});
|
|
219
|
-
|
|
220
|
-
|
|
215
|
+
// A retry starts a fresh turn, not necessarily a fresh agent_start: cancel the pending reminder
|
|
216
|
+
// there too so a provider-error retry cannot be interrupted by a stale 30 s timer.
|
|
217
|
+
for (const ev of ['agent_start', 'turn_start', 'session_shutdown']) scoped.on(ev, () => cancelTodoReminder());
|
|
218
|
+
scoped.on('agent_settled', async (event: unknown) => {
|
|
221
219
|
cancelTodoReminder();
|
|
220
|
+
// pi ≥ 1.1 carries `aborted` on agent_settled: authoritative for ESC mid-tool, where stopReason
|
|
221
|
+
// tracking sees a stale natural reason and would still schedule a reminder.
|
|
222
|
+
const aborted = event !== null && typeof event === 'object'
|
|
223
|
+
&& 'aborted' in event && event.aborted === true;
|
|
222
224
|
const plan = planStopTodoReminder({
|
|
223
225
|
lastStopReason: lastAssistantStopReason,
|
|
226
|
+
aborted,
|
|
224
227
|
intentionalAbort: stopReminder.isIntentionalAbort?.() ?? false,
|
|
225
228
|
compactionInFlight: stopReminder.isCompactionInFlight?.() ?? false,
|
|
226
229
|
reminders: todoReminders,
|
|
@@ -258,23 +261,20 @@ export default function todoPlugin(ctx: Context): void {
|
|
|
258
261
|
label: 'Todo',
|
|
259
262
|
description: TOOL_DESCRIPTION,
|
|
260
263
|
promptGuidelines: [
|
|
261
|
-
'Before stopping
|
|
264
|
+
'Before stopping, reconcile open entries: anything waiting on a human (decision, approval, an ops action you cannot perform) is marked blocked with a blocker note — never left pending, never executed just to clear the list; finished or obsolete entries are completed or removed.',
|
|
262
265
|
],
|
|
263
266
|
parameters: Type.Object({
|
|
264
267
|
todos: Type.Array(
|
|
265
268
|
Type.Object(
|
|
266
269
|
{
|
|
267
|
-
content: Type.String({ description: '
|
|
268
|
-
status: Type.Union(
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
),
|
|
272
|
-
blocker: Type.Optional(Type.String({ description: 'Only when status is "blocked": what it is waiting for. Omit the field entirely (never an empty string) for other statuses' })),
|
|
273
|
-
phase: Type.Optional(Type.String({ description: 'Optional group name (≤30 chars); omit for a flat list (never an empty string)' })),
|
|
270
|
+
content: Type.String({ description: 'Short imperative line' }),
|
|
271
|
+
status: Type.Union(TODO_STATUSES.map((status) => Type.Literal(status))),
|
|
272
|
+
blocker: Type.Optional(Type.String({ description: 'Only for blocked; omit otherwise (never an empty string)' })),
|
|
273
|
+
phase: Type.Optional(Type.String({ description: 'Group name (≤30 chars); omit for a flat list' })),
|
|
274
274
|
},
|
|
275
275
|
{ additionalProperties: false },
|
|
276
276
|
),
|
|
277
|
-
{ description: 'The COMPLETE
|
|
277
|
+
{ description: 'The COMPLETE list; replaces the previous one' },
|
|
278
278
|
),
|
|
279
279
|
}),
|
|
280
280
|
async execute(toolCallId: string, params: ToolParams, signal: AbortSignal | undefined, onUpdate: ((update: unknown) => void) | undefined, eventCtx: unknown) {
|
package/src/reducer-core.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/** D102 Evidence-Preserving Reducer core: diagnostic-command recognition, secret filtering, receipt
|
|
2
2
|
* schema checking, byte-for-byte quote verification (storage I/O lives in efficiency-store.ts). */
|
|
3
|
+
import { isPlainObject } from './guards.ts';
|
|
3
4
|
|
|
4
5
|
import { createHash } from 'node:crypto';
|
|
5
6
|
|
|
@@ -104,9 +105,6 @@ function stripReceiptJsonFences(raw: string): string {
|
|
|
104
105
|
return fenced?.[1]?.trim() ?? trimmed;
|
|
105
106
|
}
|
|
106
107
|
|
|
107
|
-
function isRecord(v: unknown): v is Record<string, unknown> {
|
|
108
|
-
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
109
|
-
}
|
|
110
108
|
|
|
111
109
|
function lineNumberOf(body: string, quote: string): number | undefined {
|
|
112
110
|
const index = body.indexOf(quote);
|
|
@@ -168,7 +166,7 @@ export function validateReceipt(
|
|
|
168
166
|
return { ok: false, reason: 'invalid-json' };
|
|
169
167
|
}
|
|
170
168
|
|
|
171
|
-
if (!
|
|
169
|
+
if (!isPlainObject(parsed)) {
|
|
172
170
|
return { ok: false, reason: 'schema-mismatch' };
|
|
173
171
|
}
|
|
174
172
|
|
|
@@ -189,7 +187,7 @@ export function validateReceipt(
|
|
|
189
187
|
const seen = new Set<string>();
|
|
190
188
|
|
|
191
189
|
for (const item of parsed.evidence) {
|
|
192
|
-
if (!
|
|
190
|
+
if (!isPlainObject(item)) {
|
|
193
191
|
return { ok: false, reason: 'schema-mismatch' };
|
|
194
192
|
}
|
|
195
193
|
const kind = item.kind;
|
package/src/reducer-invoker.ts
CHANGED
|
@@ -68,6 +68,8 @@ type ReducerModelOutput = {
|
|
|
68
68
|
content?: readonly unknown[];
|
|
69
69
|
text?: unknown;
|
|
70
70
|
usage?: UsageLike;
|
|
71
|
+
stopReason?: string;
|
|
72
|
+
errorMessage?: string;
|
|
71
73
|
};
|
|
72
74
|
|
|
73
75
|
type ReducerModel = NonNullable<ExtensionContext['model']>;
|
|
@@ -139,23 +141,23 @@ function resolveCandidate(
|
|
|
139
141
|
}
|
|
140
142
|
|
|
141
143
|
/**
|
|
142
|
-
* Step 6,
|
|
143
|
-
*
|
|
144
|
-
*
|
|
144
|
+
* Step 6, regex first: only regex-listed commands are candidates; jev may veto them (a confident
|
|
145
|
+
* rejection is authoritative) but never promotes an unlisted command. Asking jev about every result
|
|
146
|
+
* flipped 0 of 437 decisions over a week while blocking each tool result ~0.8s and uploading every
|
|
147
|
+
* command line (D122). Jev failures, low confidence and credential-shaped commands keep the regex hit.
|
|
145
148
|
*/
|
|
146
149
|
async function isDiagnosticCandidate(
|
|
147
150
|
command: string,
|
|
148
151
|
gate: JevEprGateDependency | undefined,
|
|
149
152
|
sessionId: string | undefined,
|
|
150
153
|
): Promise<boolean> {
|
|
151
|
-
|
|
152
|
-
if (!gate || containsLikelySecret(command)) return
|
|
154
|
+
if (!isDiagnosticCommand(command)) return false;
|
|
155
|
+
if (!gate || containsLikelySecret(command)) return true;
|
|
153
156
|
const result = await gate.ask(diagnosticGateRequest(command), {
|
|
154
157
|
questionId: 'epr-diagnostic-gate',
|
|
155
158
|
timeoutMs: 2500, // cold TLS hit 1002ms at 1s; at 1500ms a real session still saw 14/73 calls (19%) time out — 2.5s keeps the gate decisive without stalling reducers
|
|
156
159
|
sessionId,
|
|
157
|
-
|
|
158
|
-
extra: { site: 'epr-gate', commandSha256: sha256Hex(command), regexHit },
|
|
160
|
+
extra: { site: 'epr-gate', commandSha256: sha256Hex(command) },
|
|
159
161
|
enrich: ({ answers }) => {
|
|
160
162
|
if (!answers) return {};
|
|
161
163
|
const verdict = evaluateDiagnosticGate(answers, gate.getMinConfidence());
|
|
@@ -167,10 +169,9 @@ async function isDiagnosticCandidate(
|
|
|
167
169
|
};
|
|
168
170
|
},
|
|
169
171
|
});
|
|
170
|
-
if (!result.ok) return
|
|
172
|
+
if (!result.ok) return true;
|
|
171
173
|
const verdict = evaluateDiagnosticGate(result.answers, gate.getMinConfidence());
|
|
172
|
-
|
|
173
|
-
return isDiagnosticGateUnanswered(verdict) ? regexHit : false;
|
|
174
|
+
return verdict.hit || isDiagnosticGateUnanswered(verdict);
|
|
174
175
|
}
|
|
175
176
|
|
|
176
177
|
/** Step 7: the untruncated source; both failure modes write the same `truncated-source` evidence
|
|
@@ -317,7 +318,10 @@ export async function handleReducerToolResult(
|
|
|
317
318
|
if (config.localOnly) return undefined; // Archival is the whole job in local-only mode.
|
|
318
319
|
|
|
319
320
|
const reducer = resolveReducerModel(ctx, config);
|
|
320
|
-
if (!reducer)
|
|
321
|
+
if (!reducer) {
|
|
322
|
+
await log({ sourceBytes, model: config.model ?? 'default', verificationOk: false, reason: 'no-reducer-model', action: 'fallback_full_text' });
|
|
323
|
+
return undefined;
|
|
324
|
+
}
|
|
321
325
|
/** Evidence row for a fail-open exit taken after the reducer model was resolved. */
|
|
322
326
|
const fallbackRow = (reason: string, durationMs: number, extra: Record<string, unknown> = {}): Promise<void> =>
|
|
323
327
|
log({
|
|
@@ -354,7 +358,14 @@ export async function handleReducerToolResult(
|
|
|
354
358
|
: typeof output.text === 'string'
|
|
355
359
|
? output.text
|
|
356
360
|
: '';
|
|
357
|
-
if (!modelOutput.trim())
|
|
361
|
+
if (!modelOutput.trim()) {
|
|
362
|
+
// pi's complete() reports provider failures as an empty `error` message instead of throwing.
|
|
363
|
+
await fallbackRow(output.stopReason === 'error' ? 'invoke-failed' : 'empty-output', durationMs, {
|
|
364
|
+
...(output.stopReason ? { stopReason: output.stopReason } : {}),
|
|
365
|
+
...(output.errorMessage ? { error: output.errorMessage } : {}),
|
|
366
|
+
});
|
|
367
|
+
return undefined;
|
|
368
|
+
}
|
|
358
369
|
|
|
359
370
|
const validated = validateReceipt(modelOutput, sourceHash, body, event.isError);
|
|
360
371
|
if (!validated.ok) {
|
|
@@ -377,7 +388,10 @@ export async function handleReducerToolResult(
|
|
|
377
388
|
totalTokens: output.usage?.totalTokens,
|
|
378
389
|
});
|
|
379
390
|
const receiptBytes = Buffer.byteLength(receipt, 'utf8');
|
|
380
|
-
if (receiptBytes >= sourceBytes)
|
|
391
|
+
if (receiptBytes >= sourceBytes) {
|
|
392
|
+
await fallbackRow('receipt-not-smaller', durationMs, { receiptBytes });
|
|
393
|
+
return undefined;
|
|
394
|
+
}
|
|
381
395
|
|
|
382
396
|
await log({
|
|
383
397
|
sourceBytes,
|
package/src/renderers.ts
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* needed and older pi builds degrade to "no renderer".
|
|
5
5
|
*/
|
|
6
6
|
import { TODO_EDIT_CUSTOM_TYPE, type TodoEditPayload } from './todo-core.ts';
|
|
7
|
-
import { SUBS_CUSTOM_TYPE, type SubsRegistry } from './subagent-core.ts';
|
|
7
|
+
import { SUBS_CUSTOM_TYPE, subRef, type SubsRegistry } from './subagent-core.ts';
|
|
8
|
+
import { DELEGATION_CUSTOM_TYPE } from './delegation-core.ts';
|
|
8
9
|
import { TERMINALS_CUSTOM_TYPE, TERM_REMINDER_CUSTOM_TYPE, type TerminalsRegistry } from './terminal-core.ts';
|
|
9
10
|
import { TODO_REMINDER_CUSTOM_TYPE } from './todo-reminder-core.ts';
|
|
10
11
|
|
|
@@ -94,10 +95,31 @@ function subsLines(data: unknown, theme: RenderTheme, expanded: boolean): string
|
|
|
94
95
|
label: 'subagents',
|
|
95
96
|
unit: 'tracked',
|
|
96
97
|
...(running ? { badge: `${running} running` } : {}),
|
|
97
|
-
|
|
98
|
+
// Every registry row IS a delegated worker (↳ marks the delegation edge; status text carries state).
|
|
99
|
+
row: (sub) => ` ↳ ${subRef(sub)} · ${sub.status} · ${clip(String(sub.description ?? ''), 70)}`,
|
|
98
100
|
});
|
|
99
101
|
}
|
|
100
102
|
|
|
103
|
+
/** First-frame delegation card in the worker's own transcript (RFC rfc-delegation-identity §2.3). */
|
|
104
|
+
function delegationLines(data: unknown, theme: RenderTheme, expanded: boolean): string[] {
|
|
105
|
+
const rec = (data ?? {}) as {
|
|
106
|
+
parentPaneId?: unknown;
|
|
107
|
+
parentSessionId?: unknown;
|
|
108
|
+
taskId?: unknown;
|
|
109
|
+
description?: unknown;
|
|
110
|
+
role?: unknown;
|
|
111
|
+
};
|
|
112
|
+
const parent = typeof rec.parentPaneId === 'string' && rec.parentPaneId ? rec.parentPaneId : '?';
|
|
113
|
+
const head = `${theme.fg('accent', '⟵ delegated')} ${theme.fg('dim', `by master ${parent} · ${clip(String(rec.description ?? ''), 60)}`)}`;
|
|
114
|
+
if (!expanded) return [head];
|
|
115
|
+
const session = typeof rec.parentSessionId === 'string' && rec.parentSessionId ? rec.parentSessionId : '?';
|
|
116
|
+
const role = typeof rec.role === 'string' && rec.role ? rec.role : '?';
|
|
117
|
+
return [
|
|
118
|
+
head,
|
|
119
|
+
theme.fg('dim', ` task ${typeof rec.taskId === 'string' ? rec.taskId : '?'} · role ${role} · parent session ${session}`),
|
|
120
|
+
];
|
|
121
|
+
}
|
|
122
|
+
|
|
101
123
|
function terminalsLines(data: unknown, theme: RenderTheme, expanded: boolean): string[] {
|
|
102
124
|
const terminals = (data as TerminalsRegistry | undefined)?.terminals;
|
|
103
125
|
return registryLines(Array.isArray(terminals) ? terminals : undefined, theme, expanded, {
|
|
@@ -179,6 +201,7 @@ export function installRenderers(pi: unknown): string[] {
|
|
|
179
201
|
entry(TODO_EDIT_CUSTOM_TYPE, todoEditLines);
|
|
180
202
|
entry(SUBS_CUSTOM_TYPE, subsLines);
|
|
181
203
|
entry(TERMINALS_CUSTOM_TYPE, terminalsLines);
|
|
204
|
+
entry(DELEGATION_CUSTOM_TYPE, delegationLines);
|
|
182
205
|
entry(ROLE_MANIFEST_CUSTOM_TYPE, (data, theme) => roleManifestLines(data, theme));
|
|
183
206
|
entry(APPROVAL_NEEDED_CUSTOM_TYPE, (data, theme) => approvalLines(data, theme));
|
|
184
207
|
message(TODO_REMINDER_CUSTOM_TYPE, (msg, theme, expanded) => reminderLines(msg, theme, 'todo reminder', expanded));
|
package/src/role-manifest.ts
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
* top-level keys are reported (a typo like "rulez" must not silently fail) and every issue is
|
|
5
5
|
* collected in one pass so a manifest can be repaired in one go.
|
|
6
6
|
*/
|
|
7
|
+
import { isPlainObject } from './guards.ts';
|
|
8
|
+
|
|
7
9
|
export type PermissionAction = 'allow' | 'ask' | 'deny';
|
|
8
10
|
export type TodosMode = 'serial' | 'parallel';
|
|
9
11
|
/** D82: stance for unknown tools—visibility is a trust relationship (user→master via install; master→delegated via mirror). */
|
|
@@ -47,9 +49,6 @@ const SEMVER_RE = /^\d+\.\d+\.\d+$/;
|
|
|
47
49
|
const TOOL_KEY_RE = /^[a-z0-9_*-]+$/;
|
|
48
50
|
const ACTIONS: readonly PermissionAction[] = ['allow', 'ask', 'deny'];
|
|
49
51
|
|
|
50
|
-
function isPlainObject(v: unknown): v is Record<string, unknown> {
|
|
51
|
-
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
52
|
-
}
|
|
53
52
|
|
|
54
53
|
export function validateRoleManifest(input: unknown): ValidateResult {
|
|
55
54
|
const issues: string[] = [];
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"role": "worker-default",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "默认worker角色(D83 继承制):镜像 master.tools 减 subagent/terminal(禁嵌套、资源归属);排除族显式 deny——在 D82 unknownTools=allow 姿态下仍持禁令;todos serial。v1.4.0: 工具合并后更新 deny 列表",
|
|
3
|
+
"version": "1.6.0",
|
|
4
|
+
"description": "默认worker角色(D83 继承制):镜像 master.tools 减 subagent/terminal(禁嵌套、资源归属);排除族显式 deny——在 D82 unknownTools=allow 姿态下仍持禁令;todos serial。v1.6.0: 删除 report_progress(一周 0 调用,D125);v1.5.0: Phase 2 显式放行 report_progress;v1.4.0: 工具合并后更新 deny 列表",
|
|
5
5
|
"manifest": {
|
|
6
6
|
"unknownTools": "allow",
|
|
7
7
|
"tools": [
|
package/src/settle-wake-core.ts
CHANGED
|
@@ -7,6 +7,9 @@
|
|
|
7
7
|
|
|
8
8
|
export const ABORT_STOP_REASON = 'aborted';
|
|
9
9
|
|
|
10
|
+
/** pi/provider failure stop. pi auto-retries these, so the run has not settled for the user. */
|
|
11
|
+
export const ERROR_STOP_REASON = 'error';
|
|
12
|
+
|
|
10
13
|
/** D96: cooldown before repeating a notice for the same running set. */
|
|
11
14
|
export const D96_REPEAT_NOTICE_MS = 10 * 60_000;
|
|
12
15
|
|
|
@@ -15,6 +18,11 @@ export interface SettleWakeInput {
|
|
|
15
18
|
lastStopReason: string | null;
|
|
16
19
|
/** True when abort was triggered intentionally by internal mechanisms like OCC. */
|
|
17
20
|
intentionalAbort?: boolean;
|
|
21
|
+
/**
|
|
22
|
+
* pi ≥ 1.1 carries `aborted` on agent_settled itself: authoritative for runs cancelled without a
|
|
23
|
+
* final assistant message (ESC mid-tool), where stopReason tracking sees a stale natural reason.
|
|
24
|
+
*/
|
|
25
|
+
aborted?: boolean;
|
|
18
26
|
running: ReadonlyArray<{ paneId: string }>;
|
|
19
27
|
lastNoticeKey: string | null;
|
|
20
28
|
lastNoticeAt: number;
|
|
@@ -50,7 +58,7 @@ export function planSettleWake(input: SettleWakeInput): SettleWakePlan {
|
|
|
50
58
|
noticeAt: input.lastNoticeAt,
|
|
51
59
|
};
|
|
52
60
|
}
|
|
53
|
-
if (input.intentionalAbort || input.lastStopReason === ABORT_STOP_REASON) {
|
|
61
|
+
if (input.aborted === true || input.intentionalAbort || input.lastStopReason === ABORT_STOP_REASON) {
|
|
54
62
|
return {
|
|
55
63
|
wake: false,
|
|
56
64
|
compact: input.intentionalAbort === true,
|
|
@@ -75,14 +83,20 @@ export function planSettleWake(input: SettleWakeInput): SettleWakePlan {
|
|
|
75
83
|
* coming: answering now reports "finished, no closing message" for live work and — worse —
|
|
76
84
|
* consumes the one-shot request plus the parent's claim latch, so the real closing message can
|
|
77
85
|
* never be delivered (observed: a worker mid-compaction announced finished; its final report
|
|
78
|
-
* never reached the parent). A user abort ('aborted') likewise settles a run that may re-run
|
|
86
|
+
* never reached the parent). A user abort ('aborted') likewise settles a run that may re-run, and
|
|
87
|
+
* an 'error' stop is an upstream failure pi retries — answering then consumes the latch for a
|
|
88
|
+
* parent's claim that the retry's closing message still has to satisfy.
|
|
79
89
|
*/
|
|
80
90
|
export function shouldPushSettleReply(input: {
|
|
81
91
|
intentionalAbort?: boolean;
|
|
92
|
+
/** pi ≥ 1.1 agent_settled.aborted: the run was cancelled — the closing message may still come after a re-run. */
|
|
93
|
+
aborted?: boolean;
|
|
82
94
|
compactionInFlight?: boolean;
|
|
83
95
|
lastStopReason: string | null;
|
|
84
96
|
}): boolean {
|
|
85
97
|
return !input.intentionalAbort
|
|
98
|
+
&& input.aborted !== true
|
|
86
99
|
&& !input.compactionInFlight
|
|
87
|
-
&& input.lastStopReason !== ABORT_STOP_REASON
|
|
100
|
+
&& input.lastStopReason !== ABORT_STOP_REASON
|
|
101
|
+
&& input.lastStopReason !== ERROR_STOP_REASON;
|
|
88
102
|
}
|