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.
Files changed (54) hide show
  1. package/README.md +7 -5
  2. package/package.json +3 -3
  3. package/schemas/efficiency-config.schema.json +6 -0
  4. package/src/ask-dialog.ts +9 -2
  5. package/src/ask-user.ts +142 -74
  6. package/src/bootstrap.ts +2 -1
  7. package/src/compact-coordinator.ts +207 -35
  8. package/src/compact-economics-core.ts +62 -29
  9. package/src/config-catalog-core.ts +1 -0
  10. package/src/cordis-services.ts +64 -0
  11. package/src/dashboard-command.ts +40 -15
  12. package/src/delegation-core.ts +115 -0
  13. package/src/efficiency-config-core.ts +17 -6
  14. package/src/efficiency-store.ts +43 -6
  15. package/src/guards.ts +6 -0
  16. package/src/herdr-client.ts +141 -36
  17. package/src/history-store.ts +2 -0
  18. package/src/index-gates.ts +6 -12
  19. package/src/index-master.ts +12 -16
  20. package/src/index-pipe.ts +6 -0
  21. package/src/index-runtime.ts +14 -0
  22. package/src/index-worker.ts +4 -10
  23. package/src/index.ts +310 -76
  24. package/src/jev-client.ts +32 -7
  25. package/src/jev-core.ts +71 -36
  26. package/src/observation-core.ts +13 -1
  27. package/src/panel-open.ts +47 -0
  28. package/src/pier-options.ts +1 -2
  29. package/src/pipe-channel.ts +9 -0
  30. package/src/plugins/heat-plan.ts +18 -3
  31. package/src/plugins/observation.ts +118 -46
  32. package/src/plugins/subagent.ts +119 -68
  33. package/src/plugins/terminal.ts +221 -57
  34. package/src/plugins/todo.ts +63 -63
  35. package/src/reducer-core.ts +3 -5
  36. package/src/reducer-invoker.ts +27 -13
  37. package/src/renderers.ts +25 -2
  38. package/src/role-manifest.ts +2 -3
  39. package/src/roles/worker-default.json +2 -2
  40. package/src/settle-wake-core.ts +17 -3
  41. package/src/subagent-core.ts +209 -3
  42. package/src/subagent-poller.ts +69 -19
  43. package/src/subagent-session.ts +67 -4
  44. package/src/subagent-spawn.ts +55 -7
  45. package/src/terminal-core.ts +178 -50
  46. package/src/todo-core.ts +47 -2
  47. package/src/todo-read-hook.ts +54 -17
  48. package/src/todo-reminder-core.ts +9 -5
  49. package/src/todo-window.ts +8 -4
  50. package/src/todos-service.ts +12 -0
  51. package/src/tree-view.ts +162 -0
  52. package/src/vocab.ts +7 -1
  53. package/src/worker-marker.ts +76 -0
  54. package/src/focus-poller.ts +0 -223
@@ -7,16 +7,17 @@
7
7
  */
8
8
  import { Context } from '@deepseek-ai/cordis';
9
9
  import { Type } from 'typebox';
10
- import type { PiSurface } from '../pi-surface.ts';
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 { anchorTodoRange, formatTodoSummary, renderTodoGroups } from '../todo-window.ts';
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 and update the complete task list for the current work. Every call REPLACES the entire list (last write wins), so always resend the full list.',
53
- 'Entries: `content` (non-empty, unique; short imperative, what-not-how; no numbering prefixes), `status` (pending | in_progress | completed | blocked | abandoned), optional `blocker` (only when blocked: what it is waiting for), optional `phase` (group name for multi-stage plans; omit for a flat list).',
54
- 'Keep the list short (at most 15 entries): one entry per meaningful unit of work, not per micro-step.',
55
- 'Mark tasks you are actively working on as in_progress; several may be in_progress when work genuinely runs in parallel.',
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
- if (items.length === 0) return [];
81
- // Human gate open (ask_user_question waiting): the question and editor own the fixed area, so the widget
82
- // collapses to one summary line — a 10-line widget plus a multi-line question would leave no scrollable transcript.
83
- if ((opts?.blockedDepth ?? 0) > 0) {
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.get('pi-herdr.surface') as PiSurface<object>;
112
- const { todos, allowParallelInProgress, maxItems, mirrorTodos, appendEntry, state, stopReminder, getBlockedDepth } =
113
- ctx.get('pi-herdr.todo-deps') as TodoDeps;
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
- // Empty, archive, and R2 rewrite notices share one cadence so adjacent notices do not each consume the grace window.
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
- for (const ev of ['agent_start', 'session_shutdown']) scoped.on(ev, () => cancelTodoReminder());
220
- scoped.on('agent_settled', async () => {
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 with open todo_write entries, reconcile them: entries waiting on a human decision, approval, or ops action must be marked blocked with a blocker note — never left pending, and never executed just to clear the list.',
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: 'What the task is — a short imperative line' }),
268
- status: Type.Union(
269
- TODO_STATUSES.map((status) => Type.Literal(status)),
270
- { description: TODO_STATUSES.join(' | ') },
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 task list; this call replaces the previous list' },
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) {
@@ -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 (!isRecord(parsed)) {
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 (!isRecord(item)) {
190
+ if (!isPlainObject(item)) {
193
191
  return { ok: false, reason: 'schema-mismatch' };
194
192
  }
195
193
  const kind = item.kind;
@@ -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, jev first (RFC §3 P0-1): a confident jev rejection is authoritative even for regex-listed
143
- * commands; the regex list decides only when jev is absent, fails or answers below the confidence
144
- * gate (credential-shaped command lines stay local).
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
- const regexHit = isDiagnosticCommand(command);
152
- if (!gate || containsLikelySecret(command)) return regexHit;
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
- // regex_hit makes the flip observable: how often and in which direction jev overrides the regex list (jev.jsonl).
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 regexHit;
172
+ if (!result.ok) return true;
171
173
  const verdict = evaluateDiagnosticGate(result.answers, gate.getMinConfidence());
172
- if (verdict.hit) return true;
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) return undefined;
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()) return undefined;
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) return undefined;
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
- row: (sub) => ` ${sub.status === 'running' ? '●' : '○'} ${sub.paneId} · ${sub.status} · ${clip(String(sub.description ?? ''), 70)}`,
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));
@@ -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.0",
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": [
@@ -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
  }