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.
@@ -1,9 +1,11 @@
1
1
  /**
2
2
  * Minimal herdr socket API client used inside the pi extension.
3
3
  *
4
- * Validated against herdr 0.9.1 (protocol 22; see WIRE.md). The request shapes pier builds are pinned
5
- * to herdr's own `api schema` by test/herdr-client.test.ts — regenerate test/fixtures/herdr-contract.json
6
- * and bump HERDR_PROTOCOL_EXPECTED when the server moves on:
4
+ * Validated against herdr 0.9.5 (protocol 22; see WIRE.md — unchanged since 0.9.1; 0.9.2 removed only the
5
+ * pane.graphics.* API, which pier never used). The request shapes pier builds are pinned to the
6
+ * 0.9.5 methods snapshot in test/fixtures/herdr-contract.json (recent `api schema` releases no longer
7
+ * emit a methods map) — after a herdr upgrade, re-verify the protocol number and the method set, then
8
+ * regenerate the fixture and bump HERDR_PROTOCOL_EXPECTED when the server moves on:
7
9
  * - Transport is NDJSON: request {id, method, params} → response {id, result} | {id, error:{code,message}};
8
10
  * - **Control requests use one connection per request**; the server closes it after replying;
9
11
  * - Windows targets are named pipes whose name is the complete socket_path with a \\.\pipe\ prefix;
@@ -18,6 +20,7 @@
18
20
  */
19
21
  import { REPORT_AGENT_SOURCE, type TodoItem } from './vocab.ts';
20
22
  import { BLOCKED_LABEL_KEY, SIDEBAR_ASK_TOKEN, formatBlockedLabel, formatPaneTitle, sidebarTodoTokens, staleTokenClearance } from './pane-title.ts';
23
+ import { applyWorkerTag, delegationSidebarTokens, type DelegationContext } from './delegation-core.ts';
21
24
  import { LOCK_BATCH_LIMIT, LOCK_TTL_MS } from './lock-core.ts';
22
25
  import * as net from 'node:net';
23
26
 
@@ -57,6 +60,18 @@ export interface PaneListItem {
57
60
  terminalTitleStripped?: string | null;
58
61
  }
59
62
 
63
+ /** pane.process_info: the processes currently holding the pane's PTY foreground. */
64
+ export interface PaneProcessInfo {
65
+ paneId: string;
66
+ shellPid: number | null;
67
+ foreground: ReadonlyArray<{
68
+ name: string | null;
69
+ argv0: string | null;
70
+ pid: number | null;
71
+ cwd: string | null;
72
+ }>;
73
+ }
74
+
60
75
  export interface PaneLayoutCell {
61
76
  paneId: string;
62
77
  x: number;
@@ -88,6 +103,8 @@ export interface OpenPluginPaneOptions {
88
103
 
89
104
  export type OpenPluginPaneResult =
90
105
  | { mode: 'popup'; ok: true }
106
+ /** Another popup/modal owns the session; the caller may switch that panel instead of opening a tab. */
107
+ | { mode: 'busy' }
91
108
  | { mode: 'pane'; paneId: string }
92
109
  | { mode: 'fallback_tab'; tabId?: string; paneId?: string };
93
110
 
@@ -127,7 +144,7 @@ export function herdrUnavailableHint(err: unknown): string | null {
127
144
  return `herdr unreachable (${msg}) — check that the herdr server is running and HERDR_SOCKET_PATH matches its socket`;
128
145
  }
129
146
 
130
- /** Wire protocol this client is written against (herdr 0.9.1). See test/fixtures/herdr-contract.json. */
147
+ /** Wire protocol this client is written against (herdr 0.9.5, contract fixture regenerated 2026-10-20). */
131
148
  export const HERDR_PROTOCOL_EXPECTED = 22;
132
149
 
133
150
  export type WaitForOutputResult =
@@ -151,8 +168,7 @@ export interface ReadBuffer {
151
168
  export interface HerdrClientLike {
152
169
  readonly available: boolean;
153
170
  reportAgent(state: PaneAgentState, message: string | null): Promise<void>;
154
- reportMetadata(meta: { session: string; items: readonly TodoItem[]; progressSuffix?: string | null; lastWriteAt?: number | null }): Promise<void>;
155
- /** M18: report write-lock tokens (lock-<hash> → paneId|path); null releases, and batches stay within 16 keys. */
171
+ reportMetadata(meta: { session: string; items: readonly TodoItem[]; progressSuffix?: string | null; lastWriteAt?: number | null; workerTag?: string | null }): Promise<void>;
156
172
  reportLockTokens(tokens: Record<string, string | null>): Promise<void>;
157
173
  /** D93: sidebar agent display name (display_agent is the role name); null clears it. Persists, no TTL. */
158
174
  reportDisplayAgent(name: string | null): Promise<void>;
@@ -161,6 +177,14 @@ export interface HerdrClientLike {
161
177
  * grade blocked + pi-ask as the ask level, distinct from plain block.
162
178
  */
163
179
  reportAskFlag(text: string | null): Promise<void>;
180
+ /** Delegation identity tokens (pi-parent/pi-task): who delegated this pane and for what task. */
181
+ reportDelegationTokens(d: DelegationContext | null): Promise<void>;
182
+ /**
183
+ * herdr 0.9.2+ self-reported restore: register the command that reopens this exact session after
184
+ * a server restart. Worker panes only; masters stay with herdr's native pi integration.
185
+ * Silently no-ops on older servers and on any error — resume is always best-effort.
186
+ */
187
+ reportAgentSessionResume(sessionId: string, resumeArgv: readonly string[]): Promise<void>;
164
188
  listAgents(): Promise<AgentInfo[]>;
165
189
  /** Inject text into a pane terminal (pi editor input + Enter); the PTY channel is used only to start launchLine. */
166
190
  sendPaneText(paneId: string, text: string): Promise<void>;
@@ -173,6 +197,8 @@ export interface HerdrClientLike {
173
197
  closePane(paneId: string): Promise<void>;
174
198
  createTab(opts: { workspaceId: string; label?: string; cwd?: string; env?: Record<string, string> }): Promise<{ tabId: string; paneId: string }>;
175
199
  listPanes(): Promise<PaneListItem[]>;
200
+ /** Foreground process snapshot; null when the server predates the method or the pane is gone. */
201
+ processInfo(paneId: string): Promise<PaneProcessInfo | null>;
176
202
  /** D91: layout tree by paneId or tabId; null on failure. */
177
203
  exportLayout(opts?: { paneId?: string; tabId?: string }): Promise<{ tabId: string | null; zoomed: boolean; root: unknown; focusedPaneId: string | null } | null>;
178
204
  /** 0.9.1: live pane rects for the tab containing paneId. Best effort returns null. */
@@ -183,7 +209,6 @@ export interface HerdrClientLike {
183
209
  /** 0.9.1: Launch a manifest pane entrypoint with placement (popup/tab/split/overlay/zoomed). */
184
210
  openPluginPane(opts: OpenPluginPaneOptions): Promise<OpenPluginPaneResult>;
185
211
  agentExplain(target: string): Promise<Record<string, unknown> | null>;
186
- getServerVersion(): Promise<string | null>;
187
212
  /** pane.send_keys; herdr key combos include ctrl+c, enter, and esc. */
188
213
  sendPaneKeys(paneId: string, keys: string[]): Promise<void>;
189
214
  readPane(paneId: string, opts?: ReadOptions): Promise<ReadBuffer>;
@@ -212,8 +237,10 @@ function findIdIn(obj: unknown, key: string, depth = 0): string | null {
212
237
  export class NoopHerdrClient implements HerdrClientLike {
213
238
  readonly available = false;
214
239
  async reportAgent(): Promise<void> {}
240
+ async reportAgentSessionResume(): Promise<void> {}
215
241
  async reportMetadata(): Promise<void> {}
216
242
  async reportLockTokens(): Promise<void> {}
243
+ async reportDelegationTokens(): Promise<void> {}
217
244
  async reportDisplayAgent(): Promise<void> {}
218
245
  async reportAskFlag(): Promise<void> {}
219
246
  async sendPaneText(): Promise<void> {}
@@ -223,13 +250,13 @@ export class NoopHerdrClient implements HerdrClientLike {
223
250
  close(): void {}
224
251
  async listAgents(): Promise<AgentInfo[]> { return []; }
225
252
  async listPanes(): Promise<PaneListItem[]> { return []; }
253
+ async processInfo(): Promise<PaneProcessInfo | null> { return null; }
226
254
  async tabList(): Promise<TabInfo[]> { return []; }
227
255
  async waitAgent(): Promise<null> { return null; }
228
256
  async getAgentSessionPath(): Promise<null> { return null; }
229
257
  async exportLayout(): Promise<null> { return null; }
230
258
  async paneLayout(): Promise<null> { return null; }
231
259
  async agentExplain(): Promise<null> { return null; }
232
- async getServerVersion(): Promise<null> { return null; }
233
260
  async readPane(): Promise<ReadBuffer> { return { text: '', revision: 0, truncated: false }; }
234
261
  async readAgent(): Promise<ReadBuffer> { return { text: '', revision: 0, truncated: false }; }
235
262
  async waitForOutput(): Promise<WaitForOutputResult> { return { matched: false, reason: 'unavailable' }; }
@@ -312,6 +339,13 @@ export class HerdrClient implements HerdrClientLike {
312
339
 
313
340
  /* ── Reporting (silent best effort: the workbench mirror must never affect pi's main flow) ── */
314
341
 
342
+ /**
343
+ * D-2 (verified 2026-10-20): herdr's native `herdr:pi` integration owns `agent_status`
344
+ * (`screen_detection_skip_reason: full_lifecycle_hook_authority`), but it never reports the
345
+ * activity text. `pane.report_agent` is still the only carrier for the tool badge, so pier keeps
346
+ * sending state with the message; removing it requires migrating the badge to
347
+ * `report_metadata.state_labels` first.
348
+ */
315
349
  async reportAgent(state: PaneAgentState, message: string | null): Promise<void> {
316
350
  try {
317
351
  await this.request('pane.report_agent', {
@@ -325,16 +359,47 @@ export class HerdrClient implements HerdrClientLike {
325
359
  }
326
360
  }
327
361
 
328
- // Single writer: herdr's native pi integration owns the session path, so pier never sends
329
- // pane.report_agent_session; the activity badge stays on report_agent.
362
+ /**
363
+ * Master panes stay with herdr's native pi integration (it owns the session path); workers
364
+ * self-report so a restart replays pier's own launch line — extension loaded, role model, approve,
365
+ * fullscreen frame — instead of a bare `pi --resume` without `-e`. herdr requires the source to
366
+ * hold the pane before accepting a resume command (resume_not_accepted), so an idle state report
367
+ * goes first, with one retry to tolerate a lost race with the session_start gate report.
368
+ */
369
+ async reportAgentSessionResume(sessionId: string, resumeArgv: readonly string[]): Promise<void> {
370
+ if (!this.env) return;
371
+ try {
372
+ await this.reportAgent('idle', null);
373
+ const params = {
374
+ pane_id: this.env.paneId,
375
+ source: REPORT_AGENT_SOURCE,
376
+ agent: 'pi',
377
+ agent_session_id: sessionId,
378
+ resume_argv: [...resumeArgv],
379
+ };
380
+ try {
381
+ await this.request('pane.report_agent_session', params);
382
+ } catch {
383
+ const { promise: retryDelay, resolve: retryNow } = Promise.withResolvers<void>();
384
+ setTimeout(retryNow, 250);
385
+ await retryDelay;
386
+ await this.request('pane.report_agent_session', params);
387
+ }
388
+ } catch {
389
+ /* best effort: live sessions never depend on restore metadata */
390
+ }
391
+ }
330
392
 
331
- async reportMetadata(meta: { session: string; items: readonly TodoItem[]; progressSuffix?: string | null; lastWriteAt?: number | null }): Promise<void> {
393
+ async reportMetadata(meta: { session: string; items: readonly TodoItem[]; progressSuffix?: string | null; lastWriteAt?: number | null; workerTag?: string | null }): Promise<void> {
332
394
  try {
333
395
  void meta.session;
334
- const title = formatPaneTitle(meta.items, null, {
335
- progressSuffix: meta.progressSuffix,
336
- lastWriteAt: meta.lastWriteAt,
337
- });
396
+ const title = applyWorkerTag(
397
+ formatPaneTitle(meta.items, null, {
398
+ progressSuffix: meta.progressSuffix,
399
+ lastWriteAt: meta.lastWriteAt,
400
+ }),
401
+ meta.workerTag ?? null,
402
+ );
338
403
  const blocked = formatBlockedLabel(meta.items);
339
404
  // D96: stale cleanup needs its own batch — stale(16) + pi-todo(1) exceeds herdr's 16-token
340
405
  // limit, and the rejected request would drop both the title and the tokens.
@@ -362,6 +427,22 @@ export class HerdrClient implements HerdrClientLike {
362
427
  }
363
428
  }
364
429
 
430
+ /** RFC rfc-delegation-identity §2.3: separate batch — tokens patches never merge with the stale
431
+ * cleanup or the todo title (D96 discipline); an unknown parent pane skips the report. */
432
+ async reportDelegationTokens(d: DelegationContext | null): Promise<void> {
433
+ const tokens = d ? delegationSidebarTokens(d) : {};
434
+ if (Object.keys(tokens).length === 0) return;
435
+ try {
436
+ await this.request('pane.report_metadata', {
437
+ pane_id: this.env.paneId,
438
+ source: REPORT_AGENT_SOURCE,
439
+ tokens,
440
+ ttl_ms: 86400000,
441
+ });
442
+ } catch {
443
+ }
444
+ }
445
+
365
446
  async reportLockTokens(tokens: Record<string, string | null>): Promise<void> {
366
447
  const keys = Object.keys(tokens);
367
448
  if (keys.length === 0) return;
@@ -513,6 +594,29 @@ export class HerdrClient implements HerdrClientLike {
513
594
  }));
514
595
  }
515
596
 
597
+ async processInfo(paneId: string): Promise<PaneProcessInfo | null> {
598
+ try {
599
+ const result = (await this.request('pane.process_info', { pane_id: paneId })) as Record<string, unknown> | null;
600
+ const info = (result?.process_info ?? result) as Record<string, unknown> | null | undefined;
601
+ if (!info || typeof info !== 'object') return null;
602
+ const raw = Array.isArray(info.foreground_processes) ? info.foreground_processes : [];
603
+ return {
604
+ paneId: typeof info.pane_id === 'string' ? info.pane_id : paneId,
605
+ shellPid: typeof info.shell_pid === 'number' ? info.shell_pid : null,
606
+ foreground: raw
607
+ .filter((p): p is Record<string, unknown> => p !== null && typeof p === 'object')
608
+ .map((p) => ({
609
+ name: typeof p.name === 'string' ? p.name : null,
610
+ argv0: typeof p.argv0 === 'string' ? p.argv0 : null,
611
+ pid: typeof p.pid === 'number' ? p.pid : null,
612
+ cwd: typeof p.cwd === 'string' ? p.cwd : null,
613
+ })),
614
+ };
615
+ } catch {
616
+ return null;
617
+ }
618
+ }
619
+
516
620
  async exportLayout(opts: { paneId?: string; tabId?: string } = {}): Promise<{ tabId: string | null; zoomed: boolean; root: unknown; focusedPaneId: string | null } | null> {
517
621
  try {
518
622
  const result = (await this.request('layout.export', {
@@ -608,8 +712,14 @@ export class HerdrClient implements HerdrClientLike {
608
712
  }
609
713
  return { mode: 'pane', paneId: findIdIn(result, 'pane_id') ?? '' };
610
714
  } catch (err) {
715
+ // A live popup/modal is a transient state, not an old server: opening a tab would orphan a
716
+ // second dashboard and silently change the surface. Callers decide whether to switch panels.
717
+ const message = err instanceof Error ? err.message : String(err);
718
+ if (opts.placement === 'popup' && /ui_busy|popup pane is already open/i.test(message)) {
719
+ return { mode: 'busy' };
720
+ }
611
721
  // 0.9.0 serde rejects unknown `popup` as "unknown variant" / invalid_params — not a dedicated
612
- // invalid_placement code. Any popup failure retries as tab; tab failure surfaces to Level 3.
722
+ // invalid_placement code. Any other popup failure retries as tab; tab failure surfaces to Level 3.
613
723
  if (opts.placement !== 'popup') throw err;
614
724
  const { width: _width, height: _height, target_pane_id: _target, ...base } = params;
615
725
  const fallbackRes = (await this.request('plugin.pane.open', { ...base, placement: 'tab' })) as Record<string, unknown> | null;
@@ -635,21 +745,6 @@ export class HerdrClient implements HerdrClientLike {
635
745
  }
636
746
  }
637
747
 
638
- private cachedVersion: string | null = null;
639
-
640
- async getServerVersion(): Promise<string | null> {
641
- if (this.cachedVersion) return this.cachedVersion;
642
- try {
643
- const result = (await this.request('ping', {})) as { version?: unknown } | null;
644
- if (typeof result?.version === 'string') {
645
- this.cachedVersion = result.version;
646
- return result.version;
647
- }
648
- } catch {
649
- }
650
- return null;
651
- }
652
-
653
748
  async sendPaneKeys(paneId: string, keys: string[]): Promise<void> {
654
749
  await this.request('pane.send_keys', { pane_id: paneId, keys });
655
750
  }
@@ -29,6 +29,8 @@ export interface HistoryEntry {
29
29
  kind: string;
30
30
  paneId: string;
31
31
  tabId: string;
32
+ /** Phase 2 task slug — carried so resume-from-history keeps the tool-facing reference stable. */
33
+ slug?: string;
32
34
  tabName?: string;
33
35
  workspaceId: string;
34
36
  cwd: string;
@@ -8,10 +8,7 @@
8
8
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
9
9
  import type { HerdrClientLike } from './herdr-client.ts';
10
10
 
11
- /** pi surface pieces the gate runtime touches (ui_prompt events postdate the pinned devDependency). */
12
- type GatePi = ExtensionAPI & {
13
- on?: (name: 'ui_prompt_start' | 'ui_prompt_end', handler: (event: unknown) => void) => void;
14
- };
11
+ /** ui_prompt events are typed by pi ≥ 1.1 (they postdated the 0.86 pin this gate was written against). */
15
12
 
16
13
  /** Only inherently human-blocking kinds open the gate; `custom` covers resident overlays too (D97). */
17
14
  const GATING_PROMPT_KINDS = new Set(['select', 'confirm', 'input', 'editor']);
@@ -95,18 +92,15 @@ export function createGateRuntime(d: GateRuntimeDeps): GateRuntime {
95
92
  }
96
93
  });
97
94
 
98
- const gatePi = d.pi as GatePi;
99
- gatePi.on?.('ui_prompt_start', (event) => {
100
- const rec = (event ?? {}) as { kind?: unknown; title?: unknown };
101
- const title = typeof rec.title === 'string' && rec.title.trim() ? rec.title.trim() : null;
102
- const kind = typeof rec.kind === 'string' ? rec.kind : 'prompt';
103
- if (!GATING_PROMPT_KINDS.has(kind)) return;
104
- const label = title ?? kind;
95
+ d.pi.on('ui_prompt_start', (event) => {
96
+ const title = typeof event.title === 'string' && event.title.trim() ? event.title.trim() : null;
97
+ if (!GATING_PROMPT_KINDS.has(event.kind)) return;
98
+ const label = title ?? event.kind;
105
99
  // The ask tool opens its own gate first, so this is usually a nested no-op;
106
100
  // prompts from other extensions or pi core open the gate here.
107
101
  if (enter(label)) emitHerdrBlocked(true, label);
108
102
  });
109
- gatePi.on?.('ui_prompt_end', () => {
103
+ d.pi.on('ui_prompt_end', () => {
110
104
  if (exit()) emitHerdrBlocked(false, null);
111
105
  });
112
106
 
@@ -5,6 +5,7 @@ import type { Context } from '@deepseek-ai/cordis';
5
5
  import type { Server } from 'node:net';
6
6
  import { PiSurface } from './pi-surface.ts';
7
7
  import { createCordisApp } from './bootstrap.ts';
8
+ import { baseTodoDeps, cordisProvide } from './cordis-services.ts';
8
9
  import { disposeSessionRoot } from './subagent-poller.ts';
9
10
  import terminalPlugin from './plugins/terminal.ts';
10
11
  import todoPlugin from './plugins/todo.ts';
@@ -15,7 +16,7 @@ import type { RoutingTelemetryRecord } from './routing-telemetry.ts';
15
16
  import type { TodosService } from './todos-service.ts';
16
17
  import type { TodoUiSlot } from './plugins/todo.ts';
17
18
  import type { SubagentPortBox } from './subagent-core.ts';
18
- import type { SubagentDeps } from './plugins/subagent.ts';
19
+ import type { WorkerWidgetRow } from './tree-view.ts';
19
20
 
20
21
  export interface MasterPluginMount {
21
22
  pi: ExtensionAPI;
@@ -40,6 +41,8 @@ export interface MasterPluginMount {
40
41
  jev?: { ask: JevRuntime['ask']; getMinConfidence: () => number };
41
42
  /** Phase 0 routing telemetry append (RFC rfc-jev-role-routing §8); absent in tests. */
42
43
  appendRoutingLog?: (row: RoutingTelemetryRecord) => void;
44
+ /** D113: running subagent rows joined with their cached todos — feeds the unified widget. */
45
+ getWorkers?: () => readonly WorkerWidgetRow[];
43
46
  }
44
47
 
45
48
  async function loadEntry(
@@ -74,25 +77,19 @@ export async function mountMasterPlugins(m: MasterPluginMount): Promise<void> {
74
77
  }, 'pipe-server');
75
78
 
76
79
  const surface = new PiSurface(m.pi as unknown as object, cordisApp.ledger);
77
- sessionRoot.provide('pi-herdr.surface', surface);
80
+ cordisProvide(sessionRoot, 'pi-herdr.surface', surface);
78
81
  const terminalDeps = {
79
82
  client: m.client,
80
83
  env: m.env,
81
84
  state: { activePaneIds: (): Set<string> => new Set() },
82
85
  };
83
- sessionRoot.provide('pi-herdr.terminal-deps', terminalDeps);
86
+ cordisProvide(sessionRoot, 'pi-herdr.terminal-deps', terminalDeps);
84
87
 
85
88
  await loadEntry(sessionRoot, useLoader, './plugins/terminal.ts', terminalPlugin);
86
89
 
87
- sessionRoot.provide('pi-herdr.todo-deps', {
88
- todos: m.todos,
89
- allowParallelInProgress: m.todos.config.allowParallelInProgress,
90
- maxItems: 15,
91
- mirrorTodos: m.mirrorTodos,
92
- appendEntry: (customType: string, data: unknown) => {
93
- (m.pi as { appendEntry?: (t: string, d: unknown) => void }).appendEntry?.(customType, data);
94
- },
95
- state: m.todoUi,
90
+ cordisProvide(sessionRoot, 'pi-herdr.todo-deps', {
91
+ ...baseTodoDeps(m),
92
+ ...(m.getWorkers ? { getWorkers: m.getWorkers } : {}),
96
93
  getBlockedDepth: m.getBlockedDepth,
97
94
  stopReminder: {
98
95
  getBlockedDepth: m.getBlockedDepth,
@@ -103,9 +100,8 @@ export async function mountMasterPlugins(m: MasterPluginMount): Promise<void> {
103
100
  });
104
101
  await loadEntry(sessionRoot, useLoader, './plugins/todo.ts', todoPlugin);
105
102
 
106
- // D98 regression guard: `satisfies` makes tsc reject a missing/mistyped key here — the bag is
107
- // consumed untyped by plugins/subagent.ts, and the fe99252 crash was exactly a dropped key.
108
- sessionRoot.provide('pi-herdr.subagent-deps', {
103
+ // Dropped keys fail here: cordisProvide checks the bag against PierCordisServices (fe99252).
104
+ cordisProvide(sessionRoot, 'pi-herdr.subagent-deps', {
109
105
  client: m.client,
110
106
  env: m.env,
111
107
  extPath: m.extPath,
@@ -120,7 +116,7 @@ export async function mountMasterPlugins(m: MasterPluginMount): Promise<void> {
120
116
  terminalState: terminalDeps.state,
121
117
  ...(m.jev ? { jev: m.jev } : {}),
122
118
  ...(m.appendRoutingLog ? { logRouting: m.appendRoutingLog } : {}),
123
- } satisfies SubagentDeps);
119
+ });
124
120
  await loadEntry(sessionRoot, useLoader, './plugins/subagent.ts', subagentPlugin);
125
121
 
126
122
  m.pi.on('session_shutdown', () => {
package/src/index-pipe.ts CHANGED
@@ -21,6 +21,10 @@ export interface PipeHandlerSession {
21
21
  setPendingMachineRequest: (req: MachineRequest | null) => void;
22
22
  /** P0 (RFC §4.4): apply a role switch requested over the pipe (master → worker). */
23
23
  applyRoleSwitch: (role: string, switchedBy: string) => Promise<{ ok: boolean; message: string }>;
24
+ /** Phase 2 (RFC rfc-delegation-identity §3.2): worker → master progress note (display-less custom message). */
25
+ deliverProgress: (workerPaneId: string, text: string) => Promise<void>;
26
+ /** D113 (RFC rfc-unified-widget): worker → master full todo push; updates the widget cache. */
27
+ applyWorkerTodos: (workerPaneId: string, items: Array<{ content: string; status: string; blocker?: string }>, summary: string) => void;
24
28
  }
25
29
 
26
30
  export async function handlePipeRequest(
@@ -54,6 +58,16 @@ export async function handlePipeRequest(
54
58
  ? { type: 'ok', id: req.id, detail: res.message }
55
59
  : { type: 'error', id: req.id, message: res.message };
56
60
  }
61
+ case 'progress': {
62
+ // Never triggers a master turn and never floods the visible chat (RFC §3.2); the
63
+ // display-less custom message rides context only, rendered as a compact card.
64
+ await s.deliverProgress(req.paneId, req.text);
65
+ return { type: 'ok', id: req.id };
66
+ }
67
+ case 'todo_sync': {
68
+ s.applyWorkerTodos(req.paneId, req.items, req.summary);
69
+ return { type: 'ok', id: req.id };
70
+ }
57
71
  case 'reply': {
58
72
  s.port.current?.applyReplySession(req.paneId, req.sessionFile);
59
73
  // Consume before the finish notice: otherwise listRunningSubs still reports the pane and the
@@ -21,3 +21,17 @@ export function planIndexMode(env: NodeJS.ProcessEnv = process.env): IndexMode {
21
21
  composeMaster: hasHerdr && !isSubagent,
22
22
  };
23
23
  }
24
+
25
+ /**
26
+ * herdr 0.9.2 restore path: a reopened worker pane runs pier's resume_argv, which cannot carry the
27
+ * spawn env. A worker marker next to the session file is the durable identity, so the restored boot
28
+ * plans worker mode (todo-only mount, no master services) instead of master composition.
29
+ */
30
+ export function applyRestoredWorkerMode(
31
+ mode: IndexMode,
32
+ sessionFile: string | null | undefined,
33
+ markerExists: (sessionFile: string) => boolean,
34
+ ): IndexMode {
35
+ if (mode.isSubagent || !mode.hasHerdr || !sessionFile) return mode;
36
+ return markerExists(sessionFile) ? { isSubagent: true, hasHerdr: true, composeMaster: false } : mode;
37
+ }
@@ -3,6 +3,7 @@
3
3
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
4
4
  import { Context } from '@deepseek-ai/cordis';
5
5
  import { PiSurface } from './pi-surface.ts';
6
+ import { baseTodoDeps, cordisProvide } from './cordis-services.ts';
6
7
  import todoPlugin from './plugins/todo.ts';
7
8
  import type { TodosService } from './todos-service.ts';
8
9
  import type { TodoUiSlot } from './plugins/todo.ts';
@@ -26,16 +27,9 @@ export interface TodoOnlyMount {
26
27
  export async function mountTodoOnly(m: TodoOnlyMount): Promise<void> {
27
28
  const workerRoot = new Context();
28
29
  const workerSurface = new PiSurface(m.pi as unknown as object);
29
- workerRoot.provide('pi-herdr.surface', workerSurface);
30
- workerRoot.provide('pi-herdr.todo-deps', {
31
- todos: m.todos,
32
- allowParallelInProgress: m.todos.config.allowParallelInProgress,
33
- maxItems: 15,
34
- mirrorTodos: m.mirrorTodos,
35
- appendEntry: (customType: string, data: unknown) => {
36
- (m.pi as { appendEntry?: (t: string, d: unknown) => void }).appendEntry?.(customType, data);
37
- },
38
- state: m.todoUi,
30
+ cordisProvide(workerRoot, 'pi-herdr.surface', workerSurface);
31
+ cordisProvide(workerRoot, 'pi-herdr.todo-deps', {
32
+ ...baseTodoDeps(m),
39
33
  ...(m.getBlockedDepth ? { getBlockedDepth: m.getBlockedDepth } : {}),
40
34
  ...(m.stopReminder ? { stopReminder: m.stopReminder } : {}),
41
35
  });