pi-pier 0.3.1 → 0.4.1

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.
@@ -7,12 +7,13 @@
7
7
  import { Context } from '@deepseek-ai/cordis';
8
8
  import { Type } from 'typebox';
9
9
  import { basename, dirname, join } from 'node:path';
10
- import type { PiSurface } from '../pi-surface.ts';
10
+ import { cordisGet } from '../cordis-services.ts';
11
11
  import type { HerdrClientLike } from '../herdr-client.ts';
12
12
  import {
13
13
  Semaphore,
14
+ SUBAGENT_OUTPUT_SCHEMA,
14
15
  agoText,
15
- ambiguousIdError,
16
+ attachSubagentStructuredContent,
16
17
  buildAliveNotice,
17
18
  idParam,
18
19
  flattenToolParams,
@@ -20,7 +21,9 @@ import {
20
21
  newestPerTaskId,
21
22
  rekeySub,
22
23
  resolveTaskIdPrefix,
24
+ taskIdResolutionError,
23
25
  tabNameForTask,
26
+ subRef,
24
27
  type AliveProbe,
25
28
  type SubEntry,
26
29
  type SubagentPort,
@@ -41,8 +44,9 @@ import { toolError, ToolError } from '../tool-error.ts';
41
44
  import { pipeNameFor, pipeRequestTo, type PipeRequest, type PipeResponse } from '../pipe-channel.ts';
42
45
  import type { TerminalStateSlot } from './terminal.ts';
43
46
  import { bareSessionId, resolveSessionFileValue } from '../session-tail.ts';
44
- import { createGitIo, createSpawnAction, createSpawner } from '../subagent-spawn.ts';
47
+ import { DELEGATION_ENV, type DelegationContext } from '../delegation-core.ts';
45
48
  import type { JevRuntime } from '../jev-client.ts';
49
+ import { createGitIo, createSpawnAction, createSpawner } from '../subagent-spawn.ts';
46
50
  import { createPoller } from '../subagent-poller.ts';
47
51
  import { createGcController, createSubagentRegistry } from '../subagent-gc.ts';
48
52
  import { createSessionIo, executeSubagentOutput, type SubagentOutputCursor } from '../subagent-session.ts';
@@ -106,8 +110,8 @@ function defaultAgentSessionsDir(): string {
106
110
  }
107
111
 
108
112
  export default function subagentPlugin(ctx: Context): void {
109
- const surface = ctx.get('pi-herdr.surface') as PiSurface<object>;
110
- const d = ctx.get('pi-herdr.subagent-deps') as SubagentDeps;
113
+ const surface = cordisGet(ctx, 'pi-herdr.surface');
114
+ const d = cordisGet(ctx, 'pi-herdr.subagent-deps');
111
115
  const { client, env, sessionRoot, port, terminalState } = d;
112
116
  const pi = surface.raw as {
113
117
  appendEntry?: (customType: string, data: unknown) => void;
@@ -141,6 +145,7 @@ export default function subagentPlugin(ctx: Context): void {
141
145
  taskId: e.taskId,
142
146
  kind: e.kind,
143
147
  paneId: e.paneId,
148
+ ...(e.slug ? { slug: e.slug } : {}),
144
149
  tabId: e.tabId,
145
150
  tabName: e.tabName,
146
151
  workspaceId: env?.workspaceId ?? '',
@@ -187,7 +192,19 @@ export default function subagentPlugin(ctx: Context): void {
187
192
  listRunningSubs() {
188
193
  return [...subs.values()]
189
194
  .filter((s) => s.background && s.status === 'running')
190
- .map((s) => ({ paneId: s.paneId, description: s.description }));
195
+ .map((s) => ({ paneId: s.paneId, slug: s.slug, description: s.description, kind: s.kind }));
196
+ },
197
+ listRows() {
198
+ return [...subs.values()]
199
+ .filter((s) => s.status !== 'closed')
200
+ .map((s) => ({
201
+ paneId: s.paneId,
202
+ slug: s.slug,
203
+ description: s.description,
204
+ kind: s.kind,
205
+ status: s.status,
206
+ settledAt: s.consumedAt ?? null,
207
+ }));
191
208
  },
192
209
  async settleStatLine(paneId) {
193
210
  const entry = subs.get(paneId);
@@ -331,7 +348,18 @@ export default function subagentPlugin(ctx: Context): void {
331
348
  const spawned = await spawnPaneInTaskTab(
332
349
  { desiredTab: entry.tabName || latest.tabName || null, description: entry.description },
333
350
  entry.cwd,
334
- { PI_HERDR_SUBAGENT: '1' },
351
+ {
352
+ PI_HERDR_SUBAGENT: '1',
353
+ [DELEGATION_ENV]: JSON.stringify({
354
+ parentPaneId: env?.paneId ?? '',
355
+ parentSessionId: bareSessionId(d.getSessionId()),
356
+ // Phase 2: report_progress needs the master's pipe; kind 'task' maps to the composed default.
357
+ parentPipe: pipeNameFor(process.cwd(), env?.paneId ?? ''),
358
+ taskId: entry.taskId,
359
+ description: entry.description,
360
+ role: entry.kind === 'task' ? 'worker-default' : entry.kind,
361
+ } satisfies DelegationContext),
362
+ },
335
363
  launchLine(resumeFile, null, approve),
336
364
  );
337
365
  const ready = await waitSubReady(entry.cwd, spawned.paneId);
@@ -358,6 +386,7 @@ export default function subagentPlugin(ctx: Context): void {
358
386
  taskId: h.taskId,
359
387
  kind: h.kind,
360
388
  paneId: h.paneId,
389
+ ...(h.slug ? { slug: h.slug } : {}),
361
390
  tabId: h.tabId,
362
391
  tabName: h.tabName ?? '',
363
392
  cwd: h.cwd,
@@ -377,12 +406,23 @@ export default function subagentPlugin(ctx: Context): void {
377
406
  if (!rawId) return { error: 'Error: unknown subagent id "" (see action list)' };
378
407
  const direct = subs.get(rawId);
379
408
  if (direct) return { entry: direct };
409
+ // Phase 2 slug reference: exact match against live rows (paneId stays first for back-compat).
410
+ // `slug@paneId` accepts both parts; a revived row changed its paneId, so the slug part wins then.
411
+ const at = rawId.indexOf('@');
412
+ const slugPart = at > 0 ? rawId.slice(0, at) : rawId;
413
+ const panePart = at > 0 ? rawId.slice(at + 1) : null;
414
+ const combined = panePart !== null
415
+ ? (subs.get(panePart) ?? [...subs.values()].find((s) => s.slug === slugPart && s.paneId === panePart) ?? null)
416
+ : null;
417
+ if (combined) return { entry: combined };
418
+ const bySlug = [...subs.values()].find((s) => s.slug === slugPart);
419
+ if (bySlug) return { entry: bySlug };
380
420
 
381
421
  const byTaskId = newestPerTaskId(subs.values());
382
422
  const res = resolveTaskIdPrefix(rawId, byTaskId.keys());
383
423
  if (res.kind === 'resolved') return { entry: byTaskId.get(res.taskId)! };
384
- if (res.kind === 'ambiguous') return { error: ambiguousIdError('subagent id', rawId, res.candidates) };
385
- if (res.kind === 'too_short') return { error: `Error: subagent id prefix "${rawId}" is too short (minimum 4 characters).` };
424
+ const resError = taskIdResolutionError(res, 'subagent id', rawId);
425
+ if (resError) return { error: resError };
386
426
 
387
427
  if (cwd) {
388
428
  const hist = readHistory(histFile(cwd));
@@ -395,7 +435,8 @@ export default function subagentPlugin(ctx: Context): void {
395
435
  return { entry };
396
436
  }
397
437
  }
398
- if (histRes.kind === 'ambiguous') return { error: ambiguousIdError('subagent id', rawId, histRes.candidates) };
438
+ const histError = taskIdResolutionError(histRes, 'subagent id', rawId);
439
+ if (histError) return { error: histError };
399
440
  }
400
441
  return { error: `Error: unknown subagent id "${rawId}" (see action list)` };
401
442
  }
@@ -415,16 +456,14 @@ export default function subagentPlugin(ctx: Context): void {
415
456
  persistSubs();
416
457
  }
417
458
 
418
- async function executeSubagentResume(params: ToolParams, toolCtx: unknown) {
459
+ async function executeSubagentResume(params: ToolParams) {
419
460
  if (!client.available) return toolError('requires a herdr-managed pane.');
420
461
  const rawTaskId = idParam(params, 'taskId', 'agentId');
421
462
  if (!rawTaskId) return toolError('missing taskId for resume (see action list or delegation ledger).');
422
463
  const running = newestPerTaskId([...subs.values()].filter((s) => s.background && s.status === 'running'));
423
464
  const live = resolveTaskIdPrefix(rawTaskId, running.keys());
424
- if (live.kind === 'too_short') {
425
- return toolError(`Error: task id prefix "${rawTaskId}" is too short (minimum 4 characters).`);
426
- }
427
- if (live.kind === 'ambiguous') return toolError(ambiguousIdError('task id', rawTaskId, live.candidates));
465
+ const liveError = taskIdResolutionError(live, 'task id', rawTaskId);
466
+ if (liveError) return toolError(liveError);
428
467
  if (live.kind === 'resolved') {
429
468
  const entry = running.get(live.taskId)!;
430
469
  return {
@@ -434,12 +473,9 @@ export default function subagentPlugin(ctx: Context): void {
434
473
  }
435
474
  const history = readHistoryTree(dirname(defaultAgentSessionsDir()));
436
475
  const resolution = resolveTaskIdPrefix(rawTaskId, history.map((e) => e.taskId));
437
- if (resolution.kind === 'too_short') {
438
- return toolError(`Error: task id prefix "${rawTaskId}" is too short (minimum 4 characters).`);
439
- }
440
- if (resolution.kind === 'ambiguous') return toolError(ambiguousIdError('task id', rawTaskId, resolution.candidates));
441
- if (resolution.kind === 'not_found') {
442
- return toolError(`Error: no history for task "${rawTaskId}" in this workspace or its spawned checkouts.`);
476
+ if (resolution.kind !== 'resolved') {
477
+ return toolError(taskIdResolutionError(resolution, 'task id', rawTaskId)
478
+ ?? `Error: no history for task "${rawTaskId}" in this workspace or its spawned checkouts.`);
443
479
  }
444
480
  const taskId = resolution.taskId;
445
481
  const latest = latestGeneration(history, taskId);
@@ -477,7 +513,7 @@ export default function subagentPlugin(ctx: Context): void {
477
513
  ? 'reused existing pane with same session; pi still running there'
478
514
  : entry.sessionFile ? 'session restored' : 'session file missing; fresh conversation';
479
515
  return {
480
- content: [{ type: 'text', text: `resumed subagent ${entry.paneId} from task ${taskId} (${how}).` }],
516
+ content: [{ type: 'text', text: `resumed subagent ${subRef(entry)} from task ${taskId} (${how}).` }],
481
517
  details: { paneId: entry.paneId, taskId },
482
518
  };
483
519
  } catch (err) {
@@ -548,7 +584,7 @@ export default function subagentPlugin(ctx: Context): void {
548
584
  persistSubs();
549
585
  throw inner;
550
586
  }
551
- return { content: [{ type: 'text', text: `Message sent to subagent ${entry.paneId}.` }], details: { paneId: entry.paneId } };
587
+ return { content: [{ type: 'text', text: `Message sent to subagent ${subRef(entry)}.` }], details: { paneId: entry.paneId } };
552
588
  },
553
589
  });
554
590
  }
@@ -556,7 +592,7 @@ export default function subagentPlugin(ctx: Context): void {
556
592
  async function executeSubagentInterrupt(params: ToolParams, toolCtx: unknown) {
557
593
  const { entry } = targetOf(params, toolCtx);
558
594
  if (entry.status === 'closed') {
559
- return { content: [{ type: 'text', text: `Interrupt accepted for subagent ${entry.paneId} (already idle/closed; no-op).` }], details: {} };
595
+ return { content: [{ type: 'text', text: `Interrupt accepted for subagent ${subRef(entry)} (already idle/closed; no-op).` }], details: {} };
560
596
  }
561
597
  return pipeAction(entry, {
562
598
  ready: false,
@@ -565,7 +601,7 @@ export default function subagentPlugin(ctx: Context): void {
565
601
  // No settlement notice for an interrupted turn: the requester already knows.
566
602
  const lastId = lastRequestIdByPane.get(entry.paneId);
567
603
  if (lastId) d.claimSettleNotice(`${entry.paneId}:${lastId}`);
568
- return { content: [{ type: 'text', text: `Interrupt accepted for subagent ${entry.paneId} (fire-and-return).` }], details: { paneId: entry.paneId } };
604
+ return { content: [{ type: 'text', text: `Interrupt accepted for subagent ${subRef(entry)} (fire-and-return).` }], details: { paneId: entry.paneId } };
569
605
  },
570
606
  });
571
607
  }
@@ -608,7 +644,7 @@ export default function subagentPlugin(ctx: Context): void {
608
644
  continue;
609
645
  }
610
646
  if (sub.status === 'consumed' || sub.status === 'settled') {
611
- lines.push(`${sub.paneId} [idle] (${sub.kind}, settled; action send revives)${tabTag}${wtTag} ${sub.description}`);
647
+ lines.push(`${subRef(sub)} [idle] (${sub.kind}, settled; action send revives)${tabTag}${wtTag} ${sub.description}`);
612
648
  continue;
613
649
  }
614
650
  const state = 'running';
@@ -624,7 +660,7 @@ export default function subagentPlugin(ctx: Context): void {
624
660
  const cwdTag = probe?.foregroundCwd && probe.foregroundCwd !== sub.cwd
625
661
  ? ` [cwd: ${basename(probe.foregroundCwd) || probe.foregroundCwd}]`
626
662
  : '';
627
- lines.push(`${sub.paneId} [${state}${takeoverMark}${statusTag}${activityTag}${gateTag}] (${sub.kind})${tabTag}${wtTag}${cwdTag} ${sub.description}`);
663
+ lines.push(`${subRef(sub)} [${state}${takeoverMark}${statusTag}${activityTag}${gateTag}] (${sub.kind})${tabTag}${wtTag}${cwdTag} ${sub.description}`);
628
664
  }
629
665
  return { content: [{ type: 'text', text: lines.join('\n') }], details: {} };
630
666
  }
@@ -643,6 +679,7 @@ export default function subagentPlugin(ctx: Context): void {
643
679
  session,
644
680
  spawn,
645
681
  poller,
682
+ getSessionId: d.getSessionId,
646
683
  ...(d.logRouting ? { logRouting: d.logRouting } : {}),
647
684
  });
648
685
 
@@ -659,7 +696,7 @@ export default function subagentPlugin(ctx: Context): void {
659
696
 
660
697
  const ACTIONS: Record<string, (params: ToolParams, toolCtx: unknown, onUpdate?: (u: unknown) => void) => Promise<unknown>> = {
661
698
  spawn: (params, toolCtx, onUpdate) => executeSubagentSpawn(params, toolCtx, onUpdate),
662
- resume: (params, toolCtx) => executeSubagentResume(params, toolCtx),
699
+ resume: (params) => executeSubagentResume(params),
663
700
  list: () => executeSubagentList(),
664
701
  send: (params, toolCtx) => executeSubagentSend(params, toolCtx),
665
702
  interrupt: (params, toolCtx) => executeSubagentInterrupt(params, toolCtx),
@@ -702,10 +739,11 @@ export default function subagentPlugin(ctx: Context): void {
702
739
  tab: Type.Optional(Type.String({ description: '[spawn] Name of a task tab to place the subagent into (join if exists, otherwise create). Default placement groups by git worktree.' })),
703
740
  allowed_tools: Type.Optional(Type.Array(Type.String(), { description: '[spawn] Additional tools for role composition (union with role baseline)' })),
704
741
  taskId: Type.Optional(Type.String({ description: '[resume] The task id to revive from the delegation ledger' })),
705
- agentId: Type.Optional(Type.String({ description: '[send|interrupt|output|role] The subagent id (herdr pane id)' })),
742
+ agentId: Type.Optional(Type.String({ description: '[send|interrupt|output|role] The subagent reference: task slug (e.g. "fix-auth"), herdr pane id, or "slug@paneId" as printed by spawn/list' })),
706
743
  message: Type.Optional(Type.String({ description: '[send] The follow-up message' })),
707
744
  max_chars: Type.Optional(Type.Integer({ description: '[output] Maximum characters of output delta to return (default 6000, 100-16000)' })),
708
745
  }),
746
+ outputSchema: SUBAGENT_OUTPUT_SCHEMA,
709
747
  // B2: `action` is optional in the schema; normalize it so the validated arguments and the
710
748
  // code path never disagree and `spawn` is explicit in logs.
711
749
  prepareArguments: (args: unknown) => {
@@ -722,7 +760,7 @@ export default function subagentPlugin(ctx: Context): void {
722
760
  if (!handler) {
723
761
  return toolError(`Error: unknown action "${action}" (valid: ${Object.keys(ACTIONS).join(', ')})`);
724
762
  }
725
- return handler(flat, toolCtx, onUpdate);
763
+ return attachSubagentStructuredContent(action, await handler(flat, toolCtx, onUpdate));
726
764
  },
727
765
  });
728
766
  }
@@ -6,7 +6,7 @@
6
6
  */
7
7
  import { Context } from '@deepseek-ai/cordis';
8
8
  import { Type } from 'typebox';
9
- import type { PiSurface } from '../pi-surface.ts';
9
+ import { cordisGet } from '../cordis-services.ts';
10
10
  import type { HerdrClientLike } from '../herdr-client.ts';
11
11
  import { herdrUnavailableHint } from '../herdr-client.ts';
12
12
  import { toolError } from '../tool-error.ts'
@@ -23,7 +23,7 @@ import {
23
23
  detectFullscreenTUI,
24
24
  foldTerminalsRegistry,
25
25
  makeTerminalsRegistry,
26
- planIdleTerminalReminder,
26
+ planTerminalReminder,
27
27
  planShellInit,
28
28
  promptStrategyFor,
29
29
  registerTerminal,
@@ -52,15 +52,15 @@ export interface TerminalStateSlot {
52
52
  activePaneIds: () => Set<string>;
53
53
  }
54
54
 
55
- interface TerminalDeps {
55
+ export interface TerminalDeps {
56
56
  client: HerdrClientLike;
57
57
  env: { paneId: string; tabId: string } | null;
58
58
  state: TerminalStateSlot;
59
59
  }
60
60
 
61
61
  export default function terminalPlugin(ctx: Context): void {
62
- const surface = ctx.get('pi-herdr.surface') as PiSurface<object>;
63
- const { client, env, state } = ctx.get('pi-herdr.terminal-deps') as TerminalDeps;
62
+ const surface = cordisGet(ctx, 'pi-herdr.surface');
63
+ const { client, env, state } = cordisGet(ctx, 'pi-herdr.terminal-deps');
64
64
  const pi = surface.raw as {
65
65
  appendEntry?: (customType: string, data: unknown) => void;
66
66
  };
@@ -129,17 +129,20 @@ export default function terminalPlugin(ctx: Context): void {
129
129
  persistTerminals();
130
130
  });
131
131
 
132
- // Turn-end nudge: remind the model to close terminals idle past the threshold. Same delivery
133
- // shape as the todo stop reminder: grace window, cancel on agent_start, capped followUp.
132
+ // Turn-end nudge: remind the model to close terminals it just used, then once more after the idle
133
+ // threshold. Same delivery shape as the todo stop reminder: grace window, cancel on agent_start,
134
+ // capped per terminal.
134
135
  const piSend = surface.raw as {
135
136
  sendMessage?: (
136
137
  message: { customType: string; content: string; display?: boolean },
137
138
  opts?: { deliverAs?: string; triggerTurn?: boolean },
138
139
  ) => void;
139
140
  };
140
- let idleReminders = 0;
141
+ /** Start of the current agent run; terminals touched since then count as "used this turn". */
142
+ let turnStartedAt: number | null = null;
141
143
  let idleReminderTimer: NodeJS.Timeout | null = null;
142
144
  scoped.on('agent_start', () => {
145
+ turnStartedAt = Date.now();
143
146
  if (idleReminderTimer !== null) {
144
147
  clearTimeout(idleReminderTimer);
145
148
  idleReminderTimer = null;
@@ -150,19 +153,33 @@ export default function terminalPlugin(ctx: Context): void {
150
153
  clearTimeout(idleReminderTimer);
151
154
  idleReminderTimer = null;
152
155
  }
153
- const plan = planIdleTerminalReminder({
154
- open: terminals.filter((t) => t.status === 'open'),
156
+ const open = await Promise.all(
157
+ terminals
158
+ .filter((t) => t.status === 'open')
159
+ .map(async (t) => {
160
+ // A pane running a foreground job (dev server, REPL, load test) is in use even when the
161
+ // last tool interaction is old: nudge only after it is back at a shell prompt.
162
+ const info = await client.processInfo(t.paneId).catch(() => null);
163
+ const busy = info?.shellPid != null
164
+ && info.foreground.some((p) => p.pid != null && p.pid !== info.shellPid);
165
+ return { ...t, busy };
166
+ }),
167
+ );
168
+ const plan = planTerminalReminder({
169
+ open,
155
170
  now: Date.now(),
156
- reminders: idleReminders,
171
+ turnStartedAt,
157
172
  });
173
+ turnStartedAt = null;
158
174
  if (!plan.due || plan.content == null) return;
159
- // Goodbye-loop guard: mark the covered terminals nudged NOW (persisted), and deliver the notice as a
160
- // queued followUp WITHOUT triggerTurn — a reminder must never wake the agent, or the loop never ends.
175
+ // Goodbye-loop guard: bump each covered terminal's nudge count NOW (persisted), and deliver the
176
+ // notice as a queued followUp WITHOUT triggerTurn — a reminder must never wake the agent.
161
177
  const now = Date.now();
162
178
  const ids = new Set(plan.ids);
163
- terminals = terminals.map((t) => (ids.has(t.terminalId) ? { ...t, nudgedAt: now } : t));
179
+ terminals = terminals.map((t) => (ids.has(t.terminalId)
180
+ ? { ...t, nudges: (t.nudges ?? (t.nudgedAt != null ? 1 : 0)) + 1, nudgedAt: now }
181
+ : t));
164
182
  persistTerminals();
165
- idleReminders += 1;
166
183
  const content = plan.content;
167
184
  idleReminderTimer = setTimeout(() => {
168
185
  idleReminderTimer = null;
@@ -345,6 +362,8 @@ export default function terminalPlugin(ctx: Context): void {
345
362
  async function executeTerminalWait(params: ToolParams) {
346
363
  const entry = findOpenTerminal(params?.terminal_id);
347
364
  if (!entry) return fail(`unknown or closed terminal "${String(params?.terminal_id)}" (see action list)`);
365
+ // Waiting on a pane is use: keep it out of the "used this turn" cleanup nudge at settle.
366
+ touchTerminal(entry);
348
367
  const raw = typeof params?.pattern === 'string' ? params.pattern : '';
349
368
  if (!raw) return fail('pattern is required (literal text, or a regular expression with regex: true)');
350
369
  const useRegex = params?.regex === true;
@@ -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
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';
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;
@@ -58,8 +61,6 @@ const TOOL_DESCRIPTION = [
58
61
  '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.',
59
62
  ].join(' ');
60
63
 
61
- /** pi interactive mode renders at most ten widget lines. */
62
- const WIDGET_MAX_LINES = 10;
63
64
 
64
65
  /** `/todos <op> <query>` verbs; the value is the user-facing past-tense feedback. */
65
66
  const OP_VERBS: Record<TodoEditOp, string> = {
@@ -69,48 +70,21 @@ const OP_VERBS: Record<TodoEditOp, string> = {
69
70
  rm: 'removed',
70
71
  };
71
72
 
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
- */
73
+ /** D113 unified resident widget (see tree-view.ts for the line rules). */
76
74
  export function widgetLines(
77
75
  items: readonly TodoItem[],
78
- opts?: { archivedAgeMs?: number | null; blockedDepth?: number | null },
76
+ opts?: { archivedAgeMs?: number | null; blockedDepth?: number | null; workers?: readonly WorkerWidgetRow[] },
79
77
  ): 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;
78
+ return unifiedWidgetLines(items, opts?.workers ?? [], {
79
+ archivedAgeMs: opts?.archivedAgeMs,
80
+ blockedDepth: opts?.blockedDepth,
81
+ });
108
82
  }
109
83
 
110
84
  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;
85
+ const surface = cordisGet(ctx, 'pi-herdr.surface');
86
+ const { todos, allowParallelInProgress, maxItems, mirrorTodos, appendEntry, state, stopReminder, getBlockedDepth, getWorkers } =
87
+ cordisGet(ctx, 'pi-herdr.todo-deps');
114
88
  const scoped = surface.forModule(import.meta.url);
115
89
 
116
90
  /** Share one archive-age decision between the widget and /todos. */
@@ -129,6 +103,7 @@ export default function todoPlugin(ctx: Context): void {
129
103
  ui?.setWidget?.('todos', widgetLines(todos.items, {
130
104
  archivedAgeMs: archivedAgeMs(),
131
105
  blockedDepth: getBlockedDepth?.() ?? 0,
106
+ workers: getWorkers?.() ?? [],
132
107
  }));
133
108
  } catch {
134
109
  /* Older pi versions may omit widget support without disabling todo tracking. */
@@ -152,6 +127,9 @@ export default function todoPlugin(ctx: Context): void {
152
127
  });
153
128
 
154
129
  scoped.on('before_agent_start', async (eventCtx?: unknown) => {
130
+ // pi 1.2+ also fires this for extension-triggered runs; skip the ones that would double-inject
131
+ // or consume the guard cadence (stop reminder, config guide, third-party custom runs).
132
+ if (!shouldRunTodoRead(eventCtx)) return;
155
133
  const plan = planTodoReadHook({
156
134
  items: todos.items,
157
135
  turn: todoReadTurn,
@@ -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;
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, PROGRESS_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, {
@@ -137,6 +159,17 @@ function approvalLines(data: unknown, theme: RenderTheme): string[] {
137
159
  return [`${theme.fg('warning', '⚠ approval needed')} ${theme.fg('dim', `· ${tool} (${role})`)}`];
138
160
  }
139
161
 
162
+ /** Phase 2: worker progress note (display-less custom message) as a one-line card — the sender
163
+ * pane rides `details.paneId`; keep it recognizable without flooding the transcript. */
164
+ function progressLines(message: unknown, theme: RenderTheme, expanded: boolean): string[] {
165
+ const rec = (message ?? {}) as { content?: unknown; details?: { paneId?: unknown } };
166
+ const from = typeof rec.details?.paneId === 'string' && rec.details.paneId ? rec.details.paneId : '?';
167
+ const content = typeof rec.content === 'string' ? rec.content : '';
168
+ const head = `${theme.fg('accent', '⤴ progress')} ${theme.fg('dim', `· ${from}: ${clip(content, 100)}`)}`;
169
+ if (!expanded) return [head];
170
+ return [head, ...content.split('\n').map((line) => theme.fg('dim', line))];
171
+ }
172
+
140
173
  /** Reminder custom messages carry the full injected text; restyle without altering it. */
141
174
  function reminderLines(message: unknown, theme: RenderTheme, label: string, expanded: boolean): string[] {
142
175
  const content = typeof (message as { content?: unknown } | undefined)?.content === 'string'
@@ -179,8 +212,10 @@ export function installRenderers(pi: unknown): string[] {
179
212
  entry(TODO_EDIT_CUSTOM_TYPE, todoEditLines);
180
213
  entry(SUBS_CUSTOM_TYPE, subsLines);
181
214
  entry(TERMINALS_CUSTOM_TYPE, terminalsLines);
215
+ entry(DELEGATION_CUSTOM_TYPE, delegationLines);
182
216
  entry(ROLE_MANIFEST_CUSTOM_TYPE, (data, theme) => roleManifestLines(data, theme));
183
217
  entry(APPROVAL_NEEDED_CUSTOM_TYPE, (data, theme) => approvalLines(data, theme));
218
+ message(PROGRESS_CUSTOM_TYPE, progressLines);
184
219
  message(TODO_REMINDER_CUSTOM_TYPE, (msg, theme, expanded) => reminderLines(msg, theme, 'todo reminder', expanded));
185
220
  message(TERM_REMINDER_CUSTOM_TYPE, (msg, theme, expanded) => reminderLines(msg, theme, 'terminal nudge', expanded));
186
221
  return registered;
@@ -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,11 +1,11 @@
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.5.0",
4
+ "description": "默认worker角色(D83 继承制):镜像 master.tools 减 subagent/terminal(禁嵌套、资源归属);排除族显式 deny——在 D82 unknownTools=allow 姿态下仍持禁令;todos serial。v1.5.0: Phase 2 显式放行 report_progress(worker→master 检查点上报);v1.4.0: 工具合并后更新 deny 列表",
5
5
  "manifest": {
6
6
  "unknownTools": "allow",
7
7
  "tools": [
8
- "todo_write", "ask_user_question",
8
+ "todo_write", "ask_user_question", "report_progress",
9
9
  "read", "grep", "glob", "bash", "pwsh", "edit", "write",
10
10
  "web_search"
11
11
  ],