@bermudi/pi-delegate 0.1.19 → 0.1.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dispatch.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import type { AgentToolUpdateCallback } from "@earendil-works/pi-agent-core";
3
3
  import { getDefaultDelegateRuntime, type DelegateRuntime } from "./runtime.ts";
4
+ import { PauseController } from "./pause.ts";
4
5
  import {
5
6
  getConcurrencyLimit,
6
7
  getMaxAsyncTickets,
@@ -303,6 +304,7 @@ async function sharedWriteSafetyFailure(
303
304
  * `finally` so throw, abort, and queued-abort paths all unblock successors. */
304
305
  function buildSerializationGate(
305
306
  groups: readonly SharedWriteConflict[] | undefined,
307
+ progress: TaskProgress[],
306
308
  ):
307
309
  | {
308
310
  beforeAcquire: (index: number) => Promise<void>;
@@ -314,6 +316,8 @@ function buildSerializationGate(
314
316
  for (const { taskIndexes } of groups) {
315
317
  for (let position = 1; position < taskIndexes.length; position++) {
316
318
  predecessor.set(taskIndexes[position]!, taskIndexes[position - 1]!);
319
+ const row = progress[taskIndexes[position]!];
320
+ if (row) row.waitingFor = taskIndexes[position - 1]!;
317
321
  }
318
322
  }
319
323
  if (predecessor.size === 0) return undefined;
@@ -328,6 +332,8 @@ function buildSerializationGate(
328
332
  beforeAcquire: async (index) => {
329
333
  const predecessorIndex = predecessor.get(index);
330
334
  if (predecessorIndex !== undefined) await settled.get(predecessorIndex);
335
+ const row = progress[index];
336
+ if (row) row.waitingFor = undefined;
331
337
  },
332
338
  complete: (index) => resolvers.get(index)?.(),
333
339
  };
@@ -824,6 +830,21 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
824
830
  config: dispatchConfig,
825
831
  };
826
832
  runtime.tickets.set(ticketId, ticket);
833
+ let lastPauseState = "running";
834
+ const pause = new PauseController(() => {
835
+ if (pause.state !== lastPauseState) {
836
+ lastPauseState = pause.state;
837
+ if (ticket.status === "running")
838
+ console.info(`[delegate] ticket '${ticketId}' ${lastPauseState}`);
839
+ }
840
+ for (const p of ticket.progress) p.paused = pause.isParked(p.index);
841
+ runtime.tickets.notifyWaiters(ticket);
842
+ syncDelegateStatus(undefined, runtime);
843
+ });
844
+ ticket.pause = pause;
845
+ // Preparation/source integration are operations too: a pause request must
846
+ // not claim quiet while either is mutating a workspace.
847
+ pause.enter(-1);
827
848
  callSpan?.spawn();
828
849
  // Footer visibility for the new background work (see status.ts). Uses the
829
850
  // ctx cached from the dispatch path in extension.ts — DelegateToolCtx is
@@ -836,6 +857,7 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
836
857
  const modelRegistry = ctx.modelRegistry;
837
858
 
838
859
  const asyncEnv: TaskRunEnv = {
860
+ pause,
839
861
  signal: ticketSignal,
840
862
  modelRegistry,
841
863
  ticketId,
@@ -879,6 +901,7 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
879
901
  let executionResolved = resolved;
880
902
  let isolatedBatch: PreparedIsolatedBatch | undefined;
881
903
  try {
904
+ await pause.checkpoint(-1, ticketSignal);
882
905
  isolatedBatch = await prepareIsolatedBatch(resolved, ticketSignal);
883
906
  if (isolatedBatch) executionResolved = isolatedBatch.resolved;
884
907
  } catch (error) {
@@ -887,31 +910,44 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
887
910
  `Isolated workspace setup failed; no subagents were started. ${detail}`,
888
911
  { cause: error },
889
912
  );
913
+ } finally {
914
+ pause.leave(-1);
890
915
  }
891
916
 
892
917
  let results: TaskResult[];
893
- const gate = buildSerializationGate(serializedGroups);
918
+ const gate = buildSerializationGate(serializedGroups, ticket.progress);
894
919
  try {
895
920
  results = await mapConcurrentByModel(
896
921
  executionResolved,
897
922
  (t) => getModelKey(t.model),
898
923
  (modelKey) => getConcurrencyLimit(modelKey, dispatchConfig),
899
924
  async (t, i) => {
925
+ pause.enter(i);
900
926
  try {
927
+ await pause.checkpoint(i, ticketSignal);
901
928
  const result = await runResolvedTask(
902
929
  asyncEnv,
903
930
  t,
904
931
  ticket.progress[i]!,
905
932
  i,
906
933
  );
934
+ const group = serializedGroups?.findIndex((group) =>
935
+ group.taskIndexes.includes(i),
936
+ );
937
+ if (group !== undefined && group >= 0)
938
+ result.serializedGroup = group;
907
939
  ticket.results[i] = result;
908
940
  return result;
909
941
  } finally {
942
+ pause.leave(i);
910
943
  gate?.complete(i);
911
944
  }
912
945
  },
913
946
  ticketSignal,
914
- gate?.beforeAcquire,
947
+ async (i) => {
948
+ await gate?.beforeAcquire(i);
949
+ await pause.checkpoint(i, ticketSignal);
950
+ },
915
951
  );
916
952
  } catch (error) {
917
953
  results = completeUnexpectedResults(
@@ -921,34 +957,46 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
921
957
  error,
922
958
  );
923
959
  if (isolatedBatch) {
960
+ pause.enter(-1);
961
+ try {
962
+ await pause.checkpoint(-1, ticketSignal);
963
+ results = await reconcileIsolatedResults(
964
+ isolatedBatch,
965
+ resolved,
966
+ results,
967
+ {
968
+ shouldApplySource: () => false,
969
+ retainedReason:
970
+ "Batch execution failed before source application; completed proposals were retained for recovery.",
971
+ },
972
+ );
973
+ } finally {
974
+ pause.leave(-1);
975
+ }
976
+ }
977
+ ticket.results = [...results];
978
+ throw error;
979
+ }
980
+ if (isolatedBatch) {
981
+ pause.enter(-1);
982
+ try {
983
+ await pause.checkpoint(-1, ticketSignal);
924
984
  results = await reconcileIsolatedResults(
925
985
  isolatedBatch,
926
986
  resolved,
927
987
  results,
928
988
  {
929
- shouldApplySource: () => false,
989
+ shouldApplySource: () =>
990
+ ticket.status === "running" && !ticketSignal.aborted,
991
+ signal: ticketSignal,
930
992
  retainedReason:
931
- "Batch execution failed before source application; completed proposals were retained for recovery.",
993
+ "The async ticket was cancelled before source application; the proposal was retained for recovery.",
932
994
  },
933
995
  );
996
+ } finally {
997
+ pause.leave(-1);
934
998
  }
935
999
  ticket.results = [...results];
936
- throw error;
937
- }
938
- if (isolatedBatch) {
939
- results = await reconcileIsolatedResults(
940
- isolatedBatch,
941
- resolved,
942
- results,
943
- {
944
- shouldApplySource: () =>
945
- ticket.status === "running" && !ticketSignal.aborted,
946
- signal: ticketSignal,
947
- retainedReason:
948
- "The async ticket was cancelled before source application; the proposal was retained for recovery.",
949
- },
950
- );
951
- ticket.results = [...results];
952
1000
  }
953
1001
 
954
1002
  // Worker execution and isolated reconciliation are complete. Publish that
@@ -1100,7 +1148,7 @@ export async function dispatchSync(
1100
1148
  );
1101
1149
  let results: TaskResult[];
1102
1150
  let isolatedReconciled = false;
1103
- const gate = buildSerializationGate(serializedGroups);
1151
+ const gate = buildSerializationGate(serializedGroups, progress);
1104
1152
  try {
1105
1153
  results = await mapConcurrentByModel(
1106
1154
  executionResolved,
@@ -1109,6 +1157,10 @@ export async function dispatchSync(
1109
1157
  async (t, i) => {
1110
1158
  try {
1111
1159
  const result = await runResolvedTask(syncEnv, t, progress[i]!, i);
1160
+ const group = serializedGroups?.findIndex((group) =>
1161
+ group.taskIndexes.includes(i),
1162
+ );
1163
+ if (group !== undefined && group >= 0) result.serializedGroup = group;
1112
1164
  partialResults[i] = result;
1113
1165
  return result;
1114
1166
  } finally {
package/extension.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { registerSubagentBrowser } from "./browser.ts";
2
3
  import { getDefaultDelegateRuntime, type DelegateRuntime } from "./runtime.ts";
3
4
  import { discoverAgents } from "./agents.ts";
4
5
  import { getSubagentManualMarkdown } from "./manual.ts";
@@ -136,6 +137,7 @@ export default function delegateExtension(
136
137
  // its SQLite handle. Permit the new runtime to open a fresh backend; stale
137
138
  // workers from the old runtime remain blocked from reopening it.
138
139
  prepareTelemetryForSession();
140
+ const browserHistory = registerSubagentBrowser(pi, runtime);
139
141
 
140
142
  // Async completion arrives as a custom message after the original tool call
141
143
  // has returned. Give it the same compact/expanded UI as sync results while
@@ -156,6 +158,8 @@ export default function delegateExtension(
156
158
  prepareArguments: normalizeDelegateArguments,
157
159
 
158
160
  async execute(_id, params: DelegateArguments, signal, onUpdate, ctx) {
161
+ const browserGeneration = browserHistory.generation;
162
+ const captureBrowser = ctx.mode === "tui" && !params.async;
159
163
  // Reload user-edited delegate.json at the start of every execution.
160
164
  // Help, poll, cancel, wait, and invalid calls observe new settings, and
161
165
  // the global concurrency cap is reconfigured so hot-reloaded maxConcurrent
@@ -243,6 +247,16 @@ export default function delegateExtension(
243
247
  }
244
248
 
245
249
  // ── Cancel action ─────────────────────────────────────────────────
250
+ if (params.ticketAction === "pause" || params.ticketAction === "resume") {
251
+ const result = runtime.tickets.handlePause({
252
+ ticket: params.ticket,
253
+ ticketAction: params.ticketAction,
254
+ });
255
+ syncDelegateStatus(ctx, runtime);
256
+ succeedCall();
257
+ return result;
258
+ }
259
+
246
260
  if (params.ticketAction === "cancel") {
247
261
  const result = runtime.tickets.handleCancel(params);
248
262
  // A forced cancel flips the ticket to "cancelling" — keep the
@@ -331,7 +345,7 @@ export default function delegateExtension(
331
345
  // visible without restarting Pi.
332
346
  invalidateHostDepsCache();
333
347
  try {
334
- return await dispatchDelegate({
348
+ const result = await dispatchDelegate({
335
349
  pi,
336
350
  params,
337
351
  ctx,
@@ -342,11 +356,25 @@ export default function delegateExtension(
342
356
  tools: pi.getActiveTools(),
343
357
  },
344
358
  signal,
345
- onUpdate,
359
+ onUpdate: captureBrowser
360
+ ? (update) => {
361
+ browserHistory.update(
362
+ _id,
363
+ update.details,
364
+ false,
365
+ browserGeneration,
366
+ );
367
+ onUpdate?.(update);
368
+ }
369
+ : onUpdate,
346
370
  callSpan,
347
371
  runtime,
348
372
  });
373
+ if (captureBrowser)
374
+ browserHistory.update(_id, result.details, true, browserGeneration);
375
+ return result;
349
376
  } catch (err) {
377
+ if (captureBrowser) browserHistory.fail(_id, err, browserGeneration);
350
378
  failCall();
351
379
  throw err;
352
380
  }
package/format.ts CHANGED
@@ -586,6 +586,7 @@ export function latestActivity(p: TaskProgress): ToolActivity | null {
586
586
  * - "last: read src/bar.ts" after a tool completes and the model is thinking
587
587
  * - "thinking" when no activity has been recorded yet */
588
588
  export function formatActivityLabel(p: TaskProgress): string {
589
+ if (p.paused) return "paused between turns";
589
590
  const activity = inFlightActivity(p) ?? latestActivity(p);
590
591
  if (!activity) return "thinking";
591
592
  const call = sanitizeTerminalLine(
@@ -599,6 +600,7 @@ export function formatActivityLabel(p: TaskProgress): string {
599
600
  * as {@link formatActivityLabel} but adds elapsed time for in-flight tools and
600
601
  * a completion/error icon for finished ones. */
601
602
  export function compactActivity(p: TaskProgress): string {
603
+ if (p.paused) return "paused between turns";
602
604
  const activity = inFlightActivity(p) ?? latestActivity(p);
603
605
  if (!activity) return "thinking…";
604
606
  const call = sanitizeTerminalLine(
@@ -656,18 +658,31 @@ export function findTouchedOverlaps(
656
658
  results: readonly {
657
659
  attributedFiles?: string[];
658
660
  workspace?: WorkspaceMode;
661
+ serializedGroup?: number;
662
+ incomplete?: string;
659
663
  }[],
660
664
  ): string[] {
661
- const counts = new Map<string, number>();
665
+ const owners = new Map<string, (typeof results)[number][]>();
666
+ const overlaps = new Set<string>();
662
667
  for (const r of results) {
663
- for (const f of r.attributedFiles ?? []) {
664
- counts.set(f, (counts.get(f) ?? 0) + 1);
668
+ for (const f of new Set(r.attributedFiles ?? [])) {
669
+ const previous = owners.get(f) ?? [];
670
+ if (
671
+ previous.some(
672
+ (other) =>
673
+ r.serializedGroup === undefined ||
674
+ r.serializedGroup !== other.serializedGroup ||
675
+ r.incomplete !== undefined ||
676
+ other.incomplete !== undefined,
677
+ )
678
+ ) {
679
+ overlaps.add(f);
680
+ }
681
+ previous.push(r);
682
+ owners.set(f, previous);
665
683
  }
666
684
  }
667
- return [...counts.entries()]
668
- .filter(([, count]) => count > 1)
669
- .map(([file]) => file)
670
- .sort();
685
+ return [...overlaps].sort();
671
686
  }
672
687
 
673
688
  /**
@@ -680,5 +695,5 @@ export function findTouchedOverlaps(
680
695
  */
681
696
  export function formatTouchedOverlapWarning(overlaps: string[]): string | null {
682
697
  if (!overlaps.length) return null;
683
- return `WARNING: These tasks reported touching the same file(s): ${overlaps.join(", ")}. Delegate does not isolate or serialize file access and does not roll back completed writes.`;
698
+ return `WARNING: Tasks without a verified ordering reported touching the same file(s): ${overlaps.join(", ")}. File reports do not prove simultaneous writes or a conflict; completed writes are not rolled back.`;
684
699
  }
package/lifecycle.ts CHANGED
@@ -361,6 +361,8 @@ export function updateProgressFromRun(
361
361
  u.durationMs,
362
362
  );
363
363
  p.lastActivityAt = u.lastActivityAt;
364
+ p.assistantPreview = u.assistantPreview;
365
+ p.activity = u.activity;
364
366
  p.activities = mergeToolActivities(p.activities, u.activities);
365
367
  p.failureKind = u.failureKind;
366
368
  }
@@ -1546,6 +1548,7 @@ async function runTaskAttempt(
1546
1548
  timing.taskStartedAt,
1547
1549
  timing.deadlineAt,
1548
1550
  env.config,
1551
+ env.pause ? { controller: env.pause, index: p.index } : undefined,
1549
1552
  );
1550
1553
 
1551
1554
  accounting.cumulativeTokens += r.tokens;
package/manual.ts CHANGED
@@ -114,7 +114,7 @@ export function getSubagentManualMarkdown(
114
114
  "",
115
115
  "The three handles have different lifetimes:",
116
116
  "",
117
- "- **ticket** — controls one async batch with `poll`, `wait`, or `cancel`.",
117
+ "- **ticket** — controls one async batch with `poll`, `wait`, `pause`, `resume`, or `cancel`.",
118
118
  "- **sessionId** — a caller-chosen key for a live multi-turn worker, retained until close or parent shutdown.",
119
119
  "- **resumeFrom** — an absolute `.jsonl` transcript path used to recover an interrupted worker.",
120
120
  "",
@@ -139,6 +139,7 @@ export function getSubagentManualMarkdown(
139
139
  ...builtinLines,
140
140
  "",
141
141
  "Start with a built-in and its configured defaults. Set only `agent` and `prompt`; omit `model`, `thinking`, `tools`, and `workspace` unless the user requests an override or a concrete task requirement makes the built-in default unsuitable.",
142
+ "A scout needs no scratch copy with its read-only defaults. Adding `bash` makes it write-capable regardless of its name; an explicit scratch copy can then be appropriate for disposable shell-based investigation.",
142
143
  "",
143
144
  "Prefer `default` for general work: it is the only built-in guaranteed to run the parent's exact model and thinking. `scout`/`coder`/`reviewer` apply any configured delegate.json tiers (`agentOverrides`, `agentOverridesByParentModel`) and may run a different model or thinking level than the parent. `scout` is read-only; `coder` and `reviewer` use the shared workspace unless overridden. Choose a specialist for its role, not its name.",
144
145
  "",
@@ -168,6 +169,12 @@ export function getSubagentManualMarkdown(
168
169
  "",
169
170
  schemaTable(delegateArgumentsSchema.properties),
170
171
  "",
172
+ "## Pause and Resume",
173
+ "",
174
+ 'Use `delegate({ ticketAction: "pause", ticket: "<id>" })` to pause an async batch, and `ticketAction: "resume"` to continue its same live sessions. These controls return a snapshot immediately.',
175
+ "Pause finishes each current model response and its tool calls, then blocks before the next model request. Not-yet-started tasks stay queued. `pausing` means an operation is still finishing; `paused` means all remaining work has reached a checkpoint. A task that finishes naturally need not pause.",
176
+ "The inactivity watchdog is suspended at a paused turn boundary, but explicit wall-clock `deadlineMs` budgets continue. Wait does not resume. Cancel and shutdown still abort paused work. Live sessions, concurrency slots, workspace reservations, and scratch/isolated workspaces remain held; pause does not survive Pi exit or reload. Isolated preparation/application already in progress finishes before pausing; background processes are not frozen and files may be unfinished.",
177
+ "",
171
178
  "## Session Reuse",
172
179
  "",
173
180
  "When `sessionId` is set, the subagent is kept alive in a pool for the duration of the pi session.",
@@ -229,6 +236,8 @@ export function getSubagentManualMarkdown(
229
236
  "",
230
237
  '- `delegate({ ticketAction: "poll" })` \u2014 list all tickets',
231
238
  '- `delegate({ ticketAction: "poll", ticket: "abc123" })` \u2014 take one progress snapshot',
239
+ '- `delegate({ ticketAction: "pause", ticket: "abc123" })` \u2014 pause between turns, not after the entire task',
240
+ '- `delegate({ ticketAction: "resume", ticket: "abc123" })` \u2014 continue the same live sessions',
232
241
  '- `delegate({ ticketAction: "wait", ticket: "abc123" })` \u2014 block until finished; omit `timeoutMs` when the result is needed this turn',
233
242
  '- `delegate({ ticketAction: "wait", ticket: "abc123", timeoutMs: 600000 })` \u2014 bounded wait; timeout includes the latest snapshot, so do not poll afterward',
234
243
  '- `delegate({ ticketAction: "cancel", ticket: "abc123" })` \u2014 preview activity and partial effects before cancelling',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bermudi/pi-delegate",
3
- "version": "0.1.19",
3
+ "version": "0.1.20",
4
4
  "description": "Delegate tool for the Pi coding agent.",
5
5
  "keywords": [
6
6
  "pi-package"
package/pause.ts ADDED
@@ -0,0 +1,81 @@
1
+ /** Cooperative, in-memory ticket pause. Active operations finish; participants
2
+ * park at a checkpoint before starting their next operation. This neither
3
+ * freezes child processes nor releases workspace reservations/concurrency. */
4
+ export type PauseState = "running" | "pausing" | "paused";
5
+
6
+ export class PauseController {
7
+ private requested = false;
8
+ private readonly active = new Set<number>();
9
+ private readonly parked = new Set<number>();
10
+ private readonly wake = new Set<() => void>();
11
+
12
+ constructor(private readonly onChange: () => void = () => {}) {}
13
+
14
+ get state(): PauseState {
15
+ if (!this.requested) return "running";
16
+ return [...this.active].every((index) => this.parked.has(index))
17
+ ? "paused"
18
+ : "pausing";
19
+ }
20
+
21
+ isParked(index: number): boolean {
22
+ return this.requested && this.parked.has(index);
23
+ }
24
+
25
+ private publish(): void {
26
+ try {
27
+ this.onChange();
28
+ } catch (error) {
29
+ console.error("[delegate] pause state notification failed", error);
30
+ }
31
+ }
32
+
33
+ pause(): void {
34
+ if (this.requested) return;
35
+ this.requested = true;
36
+ this.publish();
37
+ }
38
+
39
+ resume(): void {
40
+ if (!this.requested) return;
41
+ this.requested = false;
42
+ for (const wake of this.wake) wake();
43
+ this.publish();
44
+ }
45
+
46
+ enter(index: number): void {
47
+ this.active.add(index);
48
+ this.publish();
49
+ }
50
+
51
+ leave(index: number): void {
52
+ this.active.delete(index);
53
+ this.parked.delete(index);
54
+ this.publish();
55
+ }
56
+
57
+ /** Abort unblocks the checkpoint without clearing another task's pause.
58
+ * Callers still observe their signal and take their normal cancellation path. */
59
+ async checkpoint(index: number, signal?: AbortSignal): Promise<void> {
60
+ if (!this.requested || signal?.aborted) return;
61
+ this.parked.add(index);
62
+ this.publish();
63
+ try {
64
+ while (this.requested && !signal?.aborted) {
65
+ await new Promise<void>((resolve) => {
66
+ const wake = () => {
67
+ this.wake.delete(wake);
68
+ signal?.removeEventListener("abort", wake);
69
+ resolve();
70
+ };
71
+ this.wake.add(wake);
72
+ signal?.addEventListener("abort", wake, { once: true });
73
+ if (signal?.aborted) wake();
74
+ });
75
+ }
76
+ } finally {
77
+ this.parked.delete(index);
78
+ this.publish();
79
+ }
80
+ }
81
+ }
@@ -54,6 +54,7 @@ export interface RenderState {
54
54
 
55
55
  /** Shared inputs for the partial and final render branches. */
56
56
  export interface BranchCtx {
57
+ pauseState?: import("./pause.ts").PauseState;
57
58
  progress: TaskProgress[];
58
59
  taskResults: (TaskResult | { error: string })[];
59
60
  total: number;
@@ -98,7 +99,11 @@ export function renderPartialBranch(ctx: BranchCtx, h: RenderHelpers): void {
98
99
  // Keep the live and final summaries in the same order so the header remains
99
100
  // easy to scan as a partial result resolves into its final form.
100
101
  const headerParts: string[] = [];
101
- if (running > 0) headerParts.push(`${running} running`);
102
+ const paused = progress.filter(
103
+ (p) => p.status === "running" && p.paused,
104
+ ).length;
105
+ if (running > paused) headerParts.push(`${running - paused} running`);
106
+ if (paused > 0) headerParts.push(`${paused} paused`);
102
107
  headerParts.push(`${finished}/${total} finished`);
103
108
  if (failed > 0) headerParts.push(`${failed} failed`);
104
109
  headerParts.push(
@@ -111,7 +116,9 @@ export function renderPartialBranch(ctx: BranchCtx, h: RenderHelpers): void {
111
116
  const stateLabel =
112
117
  ctx.ticketStatus === "cancelling"
113
118
  ? `${theme.fg("error", "■ cancelling")} · `
114
- : "";
119
+ : ctx.pauseState && ctx.pauseState !== "running"
120
+ ? `${theme.fg("warning", `Ⅱ ${ctx.pauseState}`)} · `
121
+ : "";
115
122
  const expandHint = toolExpandHint();
116
123
  const detailHint =
117
124
  !expanded && running > 0 && expandHint
@@ -362,7 +369,11 @@ export function renderFinalBranch(ctx: BranchCtx, h: RenderHelpers): void {
362
369
  if (ticketId && ticketIsLive) {
363
370
  // Background ticket — frame it as in-progress, not a finished result.
364
371
  const ticketParts = [`${finalized}/${total} finished`];
365
- if (running > 0) ticketParts.push(`${running} active`);
372
+ const paused = progress.filter(
373
+ (p) => p.status === "running" && p.paused,
374
+ ).length;
375
+ if (running > paused) ticketParts.push(`${running - paused} active`);
376
+ if (paused > 0) ticketParts.push(`${paused} paused`);
366
377
  if (pending > 0) ticketParts.push(`${pending} queued`);
367
378
  if (failed > 0) ticketParts.push(`${failed} failed`);
368
379
  if (cancelled > 0) ticketParts.push(`${cancelled} cancelled`);
@@ -373,7 +384,9 @@ export function renderFinalBranch(ctx: BranchCtx, h: RenderHelpers): void {
373
384
  const stateLabel =
374
385
  ticketStatus === "cancelling"
375
386
  ? ` ${theme.fg("error", "cancelling")}`
376
- : "";
387
+ : ctx.pauseState && ctx.pauseState !== "running"
388
+ ? ` ${theme.fg("warning", ctx.pauseState)}`
389
+ : "";
377
390
  lines.push(
378
391
  `${glyph}${stateLabel} ${theme.fg("muted", `${ticketLabel}${ticketParts.join(" · ")}`)}${detailHint}`,
379
392
  "",
package/render-result.ts CHANGED
@@ -202,6 +202,7 @@ export function renderDelegateResult(
202
202
  lines,
203
203
  ticketId,
204
204
  ticketStatus,
205
+ pauseState: details.pauseState,
205
206
  elapsedMs: details.elapsedMs,
206
207
  };
207
208
 
package/runner.ts CHANGED
@@ -3,6 +3,7 @@ import type {
3
3
  AgentSession,
4
4
  AgentSessionEvent,
5
5
  } from "@earendil-works/pi-coding-agent";
6
+ import { AssistantPreview } from "./assistant-preview.ts";
6
7
  import {
7
8
  getGitChangedFiles,
8
9
  extractAttributedFromActivities,
@@ -127,6 +128,10 @@ export async function runAgentSession(
127
128
  deadlineAt?: number,
128
129
  /** Dispatch-scoped delegate.json snapshot for the stall timeout. */
129
130
  delegateConfig?: import("./config.ts").DelegateConfig,
131
+ pause?: {
132
+ controller: import("./pause.ts").PauseController;
133
+ index: number;
134
+ },
130
135
  ): Promise<{
131
136
  output: string;
132
137
  error?: string;
@@ -176,6 +181,8 @@ export async function runAgentSession(
176
181
  let recoveryBarrier: QuiescenceBarrier | undefined;
177
182
  let abandonmentSafety: Promise<void> | undefined;
178
183
  let unsubscribeFull: (() => void) | undefined;
184
+ let unsubscribePause: (() => void) | undefined;
185
+ let pausedAtTurnBoundary = false;
179
186
  let unsubscribeRecovery: (() => void) | undefined;
180
187
  const safeLog = (message: string, error?: unknown): void => {
181
188
  try {
@@ -187,11 +194,18 @@ export async function runAgentSession(
187
194
  const removeFullListener = (): void => {
188
195
  const remove = unsubscribeFull;
189
196
  unsubscribeFull = undefined;
197
+ const removePause = unsubscribePause;
198
+ unsubscribePause = undefined;
190
199
  try {
191
200
  remove?.();
192
201
  } catch (error) {
193
202
  safeLog("[delegate] full AgentSession listener cleanup failed", error);
194
203
  }
204
+ try {
205
+ removePause?.();
206
+ } catch (error) {
207
+ safeLog("[delegate] turn-pause listener cleanup failed", error);
208
+ }
195
209
  };
196
210
  const removeRecoveryListener = (): void => {
197
211
  const remove = unsubscribeRecovery;
@@ -206,6 +220,7 @@ export async function runAgentSession(
206
220
  }
207
221
  };
208
222
  const activities: ToolActivity[] = [];
223
+ const assistantPreview = new AssistantPreview();
209
224
  const pendingById = new Map<string, ToolActivity>();
210
225
  let notifyCancellationRequested!: () => void;
211
226
  const cancellationRequested = new Promise<void>((resolve) => {
@@ -377,6 +392,8 @@ export async function runAgentSession(
377
392
  try {
378
393
  const cancellationSource = currentCancellationSource();
379
394
  onProgress({
395
+ assistantPreview: assistantPreview.text,
396
+ activity: phase,
380
397
  tokens: delta,
381
398
  toolUses,
382
399
  durationMs: Date.now() - startTime,
@@ -415,7 +432,13 @@ export async function runAgentSession(
415
432
  };
416
433
  const armStallWatchdog = (graceMs = 0) => {
417
434
  clearStallWatchdog();
418
- if (!stallTimeoutMs || stalled || signal?.aborted || deadlineExceeded)
435
+ if (
436
+ !stallTimeoutMs ||
437
+ stalled ||
438
+ signal?.aborted ||
439
+ deadlineExceeded ||
440
+ pausedAtTurnBoundary
441
+ )
419
442
  return;
420
443
 
421
444
  const grace = Number.isFinite(graceMs) && graceMs > 0 ? graceMs : 0;
@@ -625,6 +648,35 @@ export async function runAgentSession(
625
648
  // result, isError) — AgentSession forwards the underlying agent events
626
649
  // verbatim. Retry and compaction events are handled below; queue/bookkeeping
627
650
  // events and thinking changes are intentionally ignored.
651
+ // AgentSession subscribers are notifications, not an awaited barrier.
652
+ // Pi core explicitly awaits Agent subscribers before proceeding from
653
+ // turn_start to the next model request, including retries/continuations.
654
+ // Never gate turn_end: a final turn should be allowed to finish the task.
655
+ if (pause) {
656
+ unsubscribePause = session.agent.subscribe(async (event, turnSignal) => {
657
+ if (event.type !== "turn_start" || pause.controller.state === "running")
658
+ return;
659
+ pausedAtTurnBoundary = true;
660
+ clearStallWatchdog();
661
+ try {
662
+ await pause.controller.checkpoint(
663
+ pause.index,
664
+ turnSignal && signal
665
+ ? AbortSignal.any([turnSignal, signal])
666
+ : (turnSignal ?? signal),
667
+ );
668
+ if (turnSignal?.aborted || signal?.aborted) {
669
+ throw new Error(
670
+ "Paused turn cancelled before the next model request",
671
+ );
672
+ }
673
+ } finally {
674
+ pausedAtTurnBoundary = false;
675
+ noteActivity("resuming at turn boundary");
676
+ }
677
+ });
678
+ }
679
+
628
680
  unsubscribeFull = session.subscribe((event: AgentSessionEvent) => {
629
681
  barrier.noteEvent();
630
682
  recoveryBarrier?.noteEvent();
@@ -678,12 +730,14 @@ export async function runAgentSession(
678
730
  case "message_start":
679
731
  case "message_update":
680
732
  if (event.message?.role === "assistant") {
733
+ assistantPreview.update(extractOutput([event.message]));
681
734
  rememberPartialAssistant(event.message);
682
735
  }
683
736
  noteActivity("streaming model output");
684
737
  break;
685
738
  case "message_end":
686
739
  if (event.message.role === "assistant") {
740
+ assistantPreview.finish(extractOutput([event.message]));
687
741
  assistantMessagesForAttempt.push(event.message);
688
742
  // Keep a text-bearing partial if the host follows a provider
689
743
  // exception with an empty synthetic failure message. A real