pi-long-task 0.7.0 → 0.7.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  Notable changes to Pi Long Task are recorded here. This project follows semantic versioning.
4
4
 
5
+ ## 0.7.1 - 2026-09-23
6
+
7
+ ### Performance
8
+
9
+ - Coalesce and cap retained worker token-delta events instead of returning thousands of tiny diagnostic objects, while preserving complete final assistant results.
10
+ - Bound and throttle live worker commentary so long streamed responses do not trigger quadratic text processing or excessive progress renders.
11
+ - Keep planner-generated task counts lean by combining tightly coupled implementation, test, and documentation work, and guide workers to batch independent inspection and use focused checks.
12
+
5
13
  ## 0.7.0 - 2026-09-08
6
14
 
7
15
  ### Added
package/README.md CHANGED
@@ -258,6 +258,10 @@ Pi Long Task coordinates a long request from planning through task completion:
258
258
  5. **Write run artifacts:** the coordinator writes the generated/normalized `TODO.md`, `TASK_RESULT.md`, attempt summaries, and final run details to `tmp/pi-long-task/<run-id>/`.
259
259
  6. **Commit only when enabled:** if `commit` is `true`, Pi Long Task may create a commit after each completed task using only eligible task changes. If commits are disabled, no commits are created; even when enabled, commits can be skipped when there are no eligible changes or the task outcome is not commit-worthy.
260
260
 
261
+ ### Low-overhead streaming and planning
262
+
263
+ Pi Long Task keeps live status responsive without serializing every model token as a separate diagnostic event. Worker text updates use a bounded rolling status buffer, progress publication is throttled, and retained token deltas are coalesced and capped; the complete final assistant result remains available in each outcome. Planning prompts also tell the planner to use the fewest safe worker handoffs and to keep tightly coupled implementation, tests, and documentation in one assignment when they share context.
264
+
261
265
  ### Adaptive worker-session reuse
262
266
 
263
267
  Reuse is enabled by default. Related sequential TODOs in the same coordinator run and worktree may share one idle Pi `AgentSession`, which avoids repeated startup and repository exploration. Reuse does not merge task semantics: every TODO still gets its complete current assignment, an explicit boundary from the previous assignment, its own result extraction, attempts, progress, and `TASK_RESULT` outcome.
@@ -426,6 +430,7 @@ Pi session statistics can be cumulative across reused assignments. `outcomes[].w
426
430
 
427
431
  ## Feature reference
428
432
 
433
+ - **Low-overhead execution:** coalesce and bound streamed token diagnostics, throttle live commentary updates, and minimize unnecessary model handoffs without dropping final worker results.
429
434
  - **Adaptive TODO-planner budgets:** deterministically extend the normal 5-minute budget for explicit large item sets, up to 15 minutes, while preserving exact caller overrides.
430
435
  - **Visible planner timing:** report effective budget, extension reason, elapsed/remaining time, grace entry, and safe partial-output diagnostics across CLI/TUI and headless progress.
431
436
  - **Capability-aware planning:** warn when isolated workers are explicitly asked to use disabled extensions or unavailable browser tools, then constrain the plan to honest alternatives or a blocked result.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-long-task",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "type": "module",
5
5
  "description": "Pi coding agent extension that breaks large coding requests into tracked TODOs and runs them in bounded, adaptively reused AI worker sessions. A long-running task runner and subagent orchestrator for Pi, with a live TUI progress sidebar, retries, goal loops, and optional per-task git commits.",
6
6
  "keywords": [
@@ -101,6 +101,11 @@ import {
101
101
 
102
102
  export type { CoordinatorStatus } from "./types.ts";
103
103
 
104
+ const WORKER_PROGRESS_MAX_BUFFER_CHARS = 2_048;
105
+ const WORKER_PROGRESS_MAX_STATUS_CHARS = 800;
106
+ const WORKER_PROGRESS_MIN_CHARACTER_DELTA = 256;
107
+ const WORKER_PROGRESS_MIN_INTERVAL_MS = 100;
108
+
104
109
  export const DEFAULT_COORDINATOR_OPTIONS = {
105
110
  maxAttemptsPerTask: 3,
106
111
  taskTimeoutMs: 900_000,
@@ -385,7 +390,9 @@ interface RuntimeOptions {
385
390
  workerCostState: WorkerCostState;
386
391
  workerActivityByWorker: Map<string, string>;
387
392
  workerTextByWorker: Map<string, string>;
393
+ workerTextLengthByWorker: Map<string, number>;
388
394
  workerTextPublishedLengthByWorker: Map<string, number>;
395
+ workerTextPublishedAtByWorker: Map<string, number>;
389
396
  plannerDiagnostics: PlannerDiagnostic[];
390
397
  capabilityWarnings: WorkerCapabilityWarning[];
391
398
  workerSessionMetrics: WorkerSessionMetrics;
@@ -1183,7 +1190,9 @@ export async function runCoordinator(options: RunCoordinatorOptions): Promise<Co
1183
1190
  activeTaskReference = taskPlanReference;
1184
1191
  runtime.workerActivityByWorker.set(worker, initialActivity);
1185
1192
  runtime.workerTextByWorker.delete(worker);
1193
+ runtime.workerTextLengthByWorker.delete(worker);
1186
1194
  runtime.workerTextPublishedLengthByWorker.delete(worker);
1195
+ runtime.workerTextPublishedAtByWorker.delete(worker);
1187
1196
  emitProgress(
1188
1197
  runtime,
1189
1198
  `Running TODO ${nextTask.taskId} — ${nextTask.title}${attempt > 1 ? ` (attempt ${attempt})` : ""}...`,
@@ -2208,7 +2217,9 @@ function buildRuntimeOptions(options: RunCoordinatorOptions): RuntimeOptions {
2208
2217
  workerCostState: createWorkerCostState(),
2209
2218
  workerActivityByWorker: new Map(),
2210
2219
  workerTextByWorker: new Map(),
2220
+ workerTextLengthByWorker: new Map(),
2211
2221
  workerTextPublishedLengthByWorker: new Map(),
2222
+ workerTextPublishedAtByWorker: new Map(),
2212
2223
  plannerDiagnostics: [],
2213
2224
  capabilityWarnings,
2214
2225
  workerSessionMetrics: createWorkerSessionMetrics(),
@@ -2668,15 +2679,23 @@ function emitWorkerEventProgress(
2668
2679
  let activeStatus = runtime.workerActivityByWorker.get(worker);
2669
2680
 
2670
2681
  if (event.type === "message_update" && event.textDelta) {
2671
- const workerText = `${runtime.workerTextByWorker.get(worker) ?? ""}${event.textDelta}`;
2682
+ const previousText = runtime.workerTextByWorker.get(worker) ?? "";
2683
+ const workerText = `${previousText}${event.textDelta}`.slice(-WORKER_PROGRESS_MAX_BUFFER_CHARS);
2684
+ const receivedLength = (runtime.workerTextLengthByWorker.get(worker) ?? 0) + event.textDelta.length;
2672
2685
  runtime.workerTextByWorker.set(worker, workerText);
2686
+ runtime.workerTextLengthByWorker.set(worker, receivedLength);
2673
2687
  const streamedStatus = activeStatusFromWorkerText(workerText);
2674
2688
  const publishedLength = runtime.workerTextPublishedLengthByWorker.get(worker) ?? 0;
2675
- const publishBoundary = /[\n.!?:]\s*$/.test(event.textDelta) || streamedStatus.length - publishedLength >= 48;
2676
- if (streamedStatus && publishBoundary) {
2689
+ const publishedAt = runtime.workerTextPublishedAtByWorker.get(worker);
2690
+ const nowMs = runtime.now().getTime();
2691
+ const sentenceBoundary = /[\n.!?:]\s*$/.test(event.textDelta);
2692
+ const enoughTimeElapsed = publishedAt === undefined || nowMs - publishedAt >= WORKER_PROGRESS_MIN_INTERVAL_MS;
2693
+ const enoughNewText = receivedLength - publishedLength >= WORKER_PROGRESS_MIN_CHARACTER_DELTA;
2694
+ if (streamedStatus && ((sentenceBoundary && enoughTimeElapsed) || enoughNewText)) {
2677
2695
  activeStatus = streamedStatus;
2678
2696
  runtime.workerActivityByWorker.set(worker, activeStatus);
2679
- runtime.workerTextPublishedLengthByWorker.set(worker, streamedStatus.length);
2697
+ runtime.workerTextPublishedLengthByWorker.set(worker, receivedLength);
2698
+ runtime.workerTextPublishedAtByWorker.set(worker, nowMs);
2680
2699
  emitProgress(runtime, activeStatus, {
2681
2700
  phase: "worker_tool",
2682
2701
  taskId: task.taskId,
@@ -2694,7 +2713,9 @@ function emitWorkerEventProgress(
2694
2713
 
2695
2714
  if (event.type === "message_end") {
2696
2715
  runtime.workerTextByWorker.delete(worker);
2716
+ runtime.workerTextLengthByWorker.delete(worker);
2697
2717
  runtime.workerTextPublishedLengthByWorker.delete(worker);
2718
+ runtime.workerTextPublishedAtByWorker.delete(worker);
2698
2719
  }
2699
2720
 
2700
2721
  if (event.activity) {
@@ -2780,7 +2801,9 @@ function stripToolOutcomePrefix(activity: string): string {
2780
2801
 
2781
2802
  function activeStatusFromWorkerText(text: string): string {
2782
2803
  const taskResultIndex = text.indexOf("TASK_RESULT:");
2783
- return (taskResultIndex >= 0 ? text.slice(0, taskResultIndex) : text).replace(/\s+/g, " ").trim();
2804
+ const normalized = (taskResultIndex >= 0 ? text.slice(0, taskResultIndex) : text).replace(/\s+/g, " ").trim();
2805
+ if (normalized.length <= WORKER_PROGRESS_MAX_STATUS_CHARS) return normalized;
2806
+ return `… ${normalized.slice(-WORKER_PROGRESS_MAX_STATUS_CHARS + 2)}`;
2784
2807
  }
2785
2808
 
2786
2809
  function emitObsoleteTaskOutcomeProgress(
@@ -23,6 +23,8 @@ export interface GuardedSessionPromptOptions {
23
23
  dispose?: boolean;
24
24
  }
25
25
 
26
+ const MAX_CAPTURED_SESSION_EVENTS = 512;
27
+
26
28
  export interface GuardedSessionPromptResult {
27
29
  assistantText: string;
28
30
  /** True when the primary prompt deadline elapsed, even if the prompt safely completed during grace. */
@@ -54,7 +56,7 @@ export async function runGuardedSessionPrompt(
54
56
  const events: unknown[] = [];
55
57
  const timers = new Set<ReturnType<typeof setTimeout>>();
56
58
  let assistantText = "";
57
- let currentAssistantText = "";
59
+ let currentAssistantTextChunks: string[] = [];
58
60
  let outputObserved = false;
59
61
  let timedOut = false;
60
62
  let graceExpired = false;
@@ -198,18 +200,19 @@ export async function runGuardedSessionPrompt(
198
200
  } else {
199
201
  unsubscribe = session.subscribe((event: unknown) => {
200
202
  events.push(event);
203
+ if (events.length > MAX_CAPTURED_SESSION_EVENTS) events.shift();
201
204
  if (isAssistantMessageStart(event)) {
202
- currentAssistantText = "";
205
+ currentAssistantTextChunks = [];
206
+ assistantText = "";
203
207
  }
204
208
  const delta = assistantTextDeltaFromEvent(event);
205
209
  if (delta !== undefined) {
206
- currentAssistantText += delta;
207
- assistantText = currentAssistantText || assistantText;
210
+ if (delta) currentAssistantTextChunks.push(delta);
208
211
  outputObserved ||= delta.trim().length > 0;
209
212
  } else {
210
213
  const text = assistantTextFromEvent(event);
211
214
  if (text) {
212
- currentAssistantText = text;
215
+ currentAssistantTextChunks = [text];
213
216
  assistantText = text;
214
217
  outputObserved ||= text.trim().length > 0;
215
218
  }
@@ -259,7 +262,7 @@ export async function runGuardedSessionPrompt(
259
262
  clearTimers();
260
263
  options.abortSignal?.removeEventListener("abort", abortListener);
261
264
  unsubscribe?.();
262
- assistantText = latestAssistantText(session, events, assistantText);
265
+ assistantText = latestAssistantText(session, events, currentAssistantTextChunks.join("") || assistantText);
263
266
  outputObserved ||= assistantText.trim().length > 0 && assistantText !== assistantTextAtStart;
264
267
  if (options.dispose !== false) {
265
268
  try {
@@ -331,8 +334,10 @@ function latestAssistantText(session: WorkerSessionLike, events: unknown[], fall
331
334
  if (fromMessages) {
332
335
  return fromMessages;
333
336
  }
334
- const fromEvents = lastAssistantTextFromEvents(events);
335
- return fromEvents || fallback;
337
+ if (fallback) {
338
+ return fallback;
339
+ }
340
+ return lastAssistantTextFromEvents(events);
336
341
  }
337
342
 
338
343
  function timeoutMs(value: number | undefined): number {
@@ -259,6 +259,7 @@ export function todoPlanningOnlyPromptBlock(capabilityConstraints: readonly stri
259
259
  - Do not perform requested end work: do not implement or write code, execute research or report findings, create requested creative output (prose, stories, copy, designs, or assets), or produce any other final deliverable.
260
260
  - Use future-worker action language; do not claim work is complete or invent results.
261
261
  - Keep repeated task sections compact: use a one-sentence Goal and Done when, plus only the necessary Status and Verify bullets. Omit rationale, lengthy analysis, summaries, duplicated context, unrequested examples, and boilerplate.
262
+ - Minimize worker handoffs because each TODO starts another model assignment. Use the fewest tasks that safely preserve dependencies and explicit boundaries; combine tightly coupled implementation, tests, and documentation that use the same context. Do not create separate setup, audit, or final-verification tasks when that work belongs inside an implementation task.
262
263
  - Preserve every instruction, constraint, required deliverable, and acceptance condition from the source request and supplied planning context. Put shared constraints above ## Progress and task-specific requirements in the relevant task.${capabilityBlock}`;
263
264
  }
264
265
 
@@ -113,6 +113,7 @@ Rules:
113
113
  - If you need to stop because context is high or the work is blocked, leave the repository in a safe state and report \`status: partial\` or \`status: blocked\`.
114
114
  - Use the repository's AGENTS.md/project instructions.
115
115
  - Run focused verification commands when practical.
116
+ - Minimize latency: batch independent reads and searches when possible, avoid repeated repository-wide scans, and prefer focused checks over full suites unless the task requires a full suite.
116
117
  - Do not run bash commands with timeout greater than ${options.maxBashTimeoutSeconds.toFixed(0)} seconds. For long full-suite checks, run once with a bounded timeout and report any timeout/failure in TASK_RESULT instead of continuing indefinitely.
117
118
  - If TODO-file global instructions restrict scope, obey them strictly. If the task appears to require out-of-scope code changes, stop and report \`status: blocked\` instead of changing those files.
118
119
 
@@ -253,6 +254,9 @@ export const DEFAULT_WORKER_TOOLS = ["read", "bash", "edit", "write", "grep", "f
253
254
  export const DEFAULT_WORKER_THINKING_LEVEL = "high";
254
255
  export const DEFAULT_TASK_TIMEOUT_SECONDS = 60 * 60;
255
256
  export const DEFAULT_GRACEFUL_SHUTDOWN_SECONDS = 60;
257
+ /** Keep streamed diagnostics useful without returning thousands of token-delta objects. */
258
+ export const MAX_CAPTURED_WORKER_EVENTS = 256;
259
+ export const MAX_CAPTURED_STREAM_TEXT_CHARS = 4_096;
256
260
 
257
261
  export interface WorkerSessionLike {
258
262
  prompt(text: string, options?: Record<string, unknown>): Promise<void>;
@@ -532,7 +536,7 @@ export async function runWorkerTaskAssignment(
532
536
  const compactionEvents: string[] = [];
533
537
  const events: CapturedWorkerEvent[] = [];
534
538
  let assistantText = "";
535
- let currentAssistantText = "";
539
+ let currentAssistantTextChunks: string[] = [];
536
540
  let sessionFile: string | undefined;
537
541
  let sessionId: string | undefined;
538
542
  let shutdownRequested = false;
@@ -561,7 +565,7 @@ export async function runWorkerTaskAssignment(
561
565
  const timers = new Set<ReturnType<typeof setTimeout>>();
562
566
 
563
567
  const capture = (event: CapturedWorkerEvent) => {
564
- events.push(event);
568
+ retainCapturedWorkerEvent(events, event);
565
569
  options.onEvent?.(event);
566
570
  };
567
571
 
@@ -711,7 +715,8 @@ export async function runWorkerTaskAssignment(
711
715
  case "message_start": {
712
716
  const message = event.message;
713
717
  if (isRecord(message) && message.role === "assistant") {
714
- currentAssistantText = "";
718
+ currentAssistantTextChunks = [];
719
+ assistantText = "";
715
720
  }
716
721
  break;
717
722
  }
@@ -719,8 +724,7 @@ export async function runWorkerTaskAssignment(
719
724
  const assistantEvent = event.assistantMessageEvent;
720
725
  if (isRecord(assistantEvent) && assistantEvent.type === "text_delta") {
721
726
  const delta = typeof assistantEvent.delta === "string" ? assistantEvent.delta : "";
722
- currentAssistantText += delta;
723
- assistantText = currentAssistantText || assistantText;
727
+ if (delta) currentAssistantTextChunks.push(delta);
724
728
  }
725
729
  break;
726
730
  }
@@ -728,6 +732,7 @@ export async function runWorkerTaskAssignment(
728
732
  const messageText = assistantMessageText(event.message);
729
733
  if (messageText) {
730
734
  assistantText = messageText;
735
+ currentAssistantTextChunks = [messageText];
731
736
  }
732
737
  recordWorkerUsageCost(workerUsageCostFromEvent(event), workerUsageCostKeyFromEvent(event));
733
738
  break;
@@ -777,7 +782,8 @@ export async function runWorkerTaskAssignment(
777
782
 
778
783
  if (taskTimeoutSeconds > 0) {
779
784
  schedule(() => {
780
- if (finished || hasCompleteTaskResult(assistantText)) {
785
+ const currentAssistantText = currentAssistantTextChunks.join("") || assistantText;
786
+ if (finished || hasCompleteTaskResult(currentAssistantText)) {
781
787
  return;
782
788
  }
783
789
  timedOut = true;
@@ -792,14 +798,24 @@ export async function runWorkerTaskAssignment(
792
798
  }
793
799
 
794
800
  await waitForPrompt(prompt);
795
- assistantText = latestInvocationAssistantText(session, assistantText, invocationMessageStart, !reusedAssignment);
801
+ assistantText = latestInvocationAssistantText(
802
+ session,
803
+ currentAssistantTextChunks.join("") || assistantText,
804
+ invocationMessageStart,
805
+ !reusedAssignment,
806
+ );
796
807
 
797
808
  if (!hasCompleteTaskResult(assistantText) && !error && !aborted && !timedOut && !options.abortSignal?.aborted) {
798
809
  contextObservations.push(
799
810
  "missing TASK_RESULT status after initial prompt, or required fields were incomplete; requested required block once",
800
811
  );
801
812
  await waitForPrompt(buildMissingTaskResultMessage());
802
- assistantText = latestInvocationAssistantText(session, assistantText, invocationMessageStart, !reusedAssignment);
813
+ assistantText = latestInvocationAssistantText(
814
+ session,
815
+ currentAssistantTextChunks.join("") || assistantText,
816
+ invocationMessageStart,
817
+ !reusedAssignment,
818
+ );
803
819
  }
804
820
  } catch (exc) {
805
821
  failure ??= exc;
@@ -809,7 +825,12 @@ export async function runWorkerTaskAssignment(
809
825
  clearTimers();
810
826
  options.abortSignal?.removeEventListener("abort", abortListener);
811
827
  unsubscribe?.();
812
- assistantText = latestInvocationAssistantText(session, assistantText, invocationMessageStart, !reusedAssignment);
828
+ assistantText = latestInvocationAssistantText(
829
+ session,
830
+ currentAssistantTextChunks.join("") || assistantText,
831
+ invocationMessageStart,
832
+ !reusedAssignment,
833
+ );
813
834
  sessionFile = session.sessionFile ?? sessionFile;
814
835
  sessionId = session.sessionId ?? sessionId;
815
836
  sessionStatsEnd = await workerSessionStatsSnapshot(session);
@@ -907,6 +928,23 @@ export function buildWorkerSessionCreationFailureOutcome(
907
928
  };
908
929
  }
909
930
 
931
+ function retainCapturedWorkerEvent(events: CapturedWorkerEvent[], event: CapturedWorkerEvent): void {
932
+ if (event.type === "message_update" && event.textDelta !== undefined) {
933
+ const previous = events.at(-1);
934
+ if (previous?.type === "message_update" && previous.textDelta !== undefined) {
935
+ previous.textDelta = `${previous.textDelta}${event.textDelta}`.slice(-MAX_CAPTURED_STREAM_TEXT_CHARS);
936
+ return;
937
+ }
938
+ }
939
+
940
+ events.push(event);
941
+ if (events.length <= MAX_CAPTURED_WORKER_EVENTS) return;
942
+
943
+ // Prefer evicting token-stream diagnostics over lifecycle/tool evidence.
944
+ const streamedIndex = events.findIndex((item) => item.type === "message_update");
945
+ events.splice(streamedIndex >= 0 ? streamedIndex : 0, 1);
946
+ }
947
+
910
948
  function textFromContentPart(item: unknown): string {
911
949
  if (!isRecord(item)) {
912
950
  return "";