@clanker-code/pi-subagents 0.10.6 → 0.10.8

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
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.10.8] - 2026-06-23
11
+
12
+ ### Changed
13
+ - **`get_subagent_result` wait:true now detects queued user messages** — when the parent session has user messages waiting (e.g. the user typed while waiting), the tool returns early with a `pending_message` outcome instead of blocking for the full wait timeout. The queued message is delivered to the parent LLM immediately. Uses `ctx.hasPendingMessages()` polling during the wait. The subagent continues running undisturbed.
14
+
15
+ ## [0.10.7] - 2026-06-23
16
+
17
+ ### Fixed
18
+ - **`get_subagent_result` output is now bounded** — normal result previews, `verbose: true` conversation previews, and `peek` responses are capped so a large subagent transcript cannot flood the parent context. Truncated responses include the omitted size, continuation guidance, and the agent output file path for full-log inspection. Output files are now attached to background records before the agent can complete, avoiding a fast-completion race where retrieval guidance could miss the transcript path.
19
+ - **Agent-runner e2e no longer waits on an unnecessary prompt turn** — the real-runtime tool-gating test now stops after capturing active tools at session construction, avoiding intermittent 30s timeouts under full-suite load.
20
+
10
21
  ## [0.10.6] - 2026-06-22
11
22
 
12
23
  ### Changed
@@ -28,7 +39,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
28
39
  - **Fork metadata applied** — package author, repository, homepage, bugs, and media URLs now point to the `clankercode/pi-subagents` fork.
29
40
 
30
41
  ### Fixed
31
- - **Subagent completion notifications now include final output and explicit retrieval guidance** — both the machine-readable XML payload and the visible custom renderer include a `get_subagent_result <id>` instruction plus the transcript file path, so the parent can read the full output/log from the notification itself.
42
+ - **Subagent completion notifications now include final output previews and explicit retrieval guidance** — both the machine-readable XML payload and the visible custom renderer include a `get_subagent_result <id>` bounded-preview instruction plus the transcript file path for full-log inspection when available.
32
43
 
33
44
  ## [0.10.3] - 2026-06-12
34
45
 
package/README.md CHANGED
@@ -19,7 +19,7 @@ https://github.com/user-attachments/assets/8685261b-9338-4fea-8dfe-1c590d5df543
19
19
  ### Fork-specific differences
20
20
 
21
21
  - **Packaging under `@clanker-code`** — published from this fork, not the upstream namespace.
22
- - **Completion notifications include full-output guidance** — every notification carries final output preview plus an explicit `get_subagent_result <id>` instruction and transcript path, so the parent can retrieve the full log directly from the notification.
22
+ - **Completion notifications include bounded-output guidance** — every notification carries a final output preview plus an explicit `get_subagent_result <id>` bounded-preview instruction and transcript path for full-log inspection when available.
23
23
  - Additional fork-specific changes are listed in the [CHANGELOG](./CHANGELOG.md).
24
24
 
25
25
  Upstream changes are reviewed for cherry-picking when practical; otherwise they are reimplemented to fit this fork.
@@ -41,7 +41,7 @@ Upstream changes are reviewed for cherry-picking when practical; otherwise they
41
41
  - **Git worktree isolation** — run agents in isolated repo copies; changes auto-committed to branches on completion
42
42
  - **Skill preloading** — inject named skills into agent system prompts, discovered from `.pi/skills/`, `.agents/skills/`, and global locations (Pi-standard `<name>/SKILL.md` directory layout supported)
43
43
  - **Tool denylist** — block specific tools via `disallowed_tools` frontmatter
44
- - **Styled completion notifications** — background agent results render as themed, compact notification boxes (icon, stats, result preview) instead of raw XML. Expandable to show full output. Group completions render each agent individually
44
+ - **Styled completion notifications** — background agent results render as themed, compact notification boxes (icon, stats, result preview) instead of raw XML. Expandable to show a bounded preview and transcript path. Group completions render each agent individually
45
45
  - **Event bus** — lifecycle events (`subagents:created`, `started`, `completed`, `failed`, `steered`, `compacted`) emitted via `pi.events`, enabling other extensions to react to sub-agent activity
46
46
  - **Cross-extension RPC** — other pi extensions can spawn and stop subagents via the `pi.events` event bus (`subagents:rpc:ping`, `subagents:rpc:spawn`, `subagents:rpc:stop`). Standardized reply envelopes with protocol versioning. Emits `subagents:ready` on load
47
47
  - **Schedule subagents** — pass `schedule` to the `Agent` tool to fire on cron / interval / one-shot. Session-scoped jobs with PID-locked persistence; results land via the same steering-style `subagent-notification` path as manual background completions; manage via `/agents → Scheduled jobs`
@@ -279,17 +279,18 @@ Launch a sub-agent.
279
279
  | `isolation` | `"worktree"` | no | Run in an isolated git worktree |
280
280
  | `inherit_context` | boolean | no | Fork parent conversation into agent |
281
281
 
282
- All `Agent` calls run in the background. Completion notifications are delivered automatically; use `get_subagent_result` only when you explicitly need to check status or fetch a full result.
282
+ All `Agent` calls run in the background. Completion notifications are delivered automatically; use `get_subagent_result` only when you explicitly need to check status or inspect a bounded result preview. Full transcripts are written to the agent output file shown in the `Agent` response and `get_subagent_result` output.
283
283
 
284
284
  ### `get_subagent_result`
285
285
 
286
- Check status and retrieve results from a background agent.
286
+ Check status and retrieve bounded output from a background agent. The tool is safe-by-default for LLM context: normal results, verbose conversation output, and `peek` responses are capped. If output is truncated, the response includes the output file path and continuation guidance.
287
287
 
288
288
  | Parameter | Type | Required | Description |
289
289
  |-----------|------|----------|-------------|
290
290
  | `agent_id` | string | yes | Agent ID to check |
291
291
  | `wait` | boolean | no | Wait for completion |
292
- | `verbose` | boolean | no | Include full conversation log |
292
+ | `verbose` | boolean | no | Include a bounded conversation preview |
293
+ | `peek` | object | no | Return a bounded tail/filter view with line numbers. Supports `lines`, `regex`, and `after` for incremental reads. |
293
294
 
294
295
  ### `steer_subagent`
295
296
 
@@ -53,6 +53,10 @@ interface SpawnOptions {
53
53
  onToolActivity?: (activity: ToolActivity) => void;
54
54
  /** Called on streaming text deltas from the assistant response. */
55
55
  onTextDelta?: (delta: string, fullText: string) => void;
56
+ /** Build a per-agent output file path before the agent can complete. */
57
+ outputFileForAgent?: (agentId: string) => string;
58
+ /** Called after the output file path is attached to the record. */
59
+ onOutputFileCreated?: (path: string, agentId: string) => void;
56
60
  /** Called when the agent session is created (for accessing session stats). */
57
61
  onSessionCreated?: (session: AgentSession) => void;
58
62
  /** Called at the end of each agentic turn with the cumulative count. */
@@ -98,6 +98,10 @@ export class AgentManager {
98
98
  depth,
99
99
  parentAgentId: options.parentAgentId,
100
100
  };
101
+ if (options.outputFileForAgent) {
102
+ record.outputFile = options.outputFileForAgent(id);
103
+ options.onOutputFileCreated?.(record.outputFile, id);
104
+ }
101
105
  this.agents.set(id, record);
102
106
  const args = { pi, ctx, type, prompt, options };
103
107
  if (options.isBackground && !options.bypassQueue && this.runningBackground >= this.maxConcurrent) {
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Shared caps for tool responses that may otherwise flood the parent context.
3
+ * Full subagent logs remain available via the per-agent output file.
4
+ */
5
+ export declare const MAX_RESULT_CHARS = 20000;
6
+ export declare const MAX_VERBOSE_CHARS = 20000;
7
+ export declare const MAX_PEEK_CHARS = 20000;
8
+ export declare const MAX_PEEK_LINES = 200;
9
+ export interface LimitedText {
10
+ text: string;
11
+ truncated: boolean;
12
+ omittedChars: number;
13
+ }
14
+ export declare function limitText(text: string, maxChars: number): LimitedText;
15
+ export declare function clampPeekLines(lines: number | undefined): number;
16
+ export declare function formatOutputFileHint(outputFile?: string): string;
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Shared caps for tool responses that may otherwise flood the parent context.
3
+ * Full subagent logs remain available via the per-agent output file.
4
+ */
5
+ export const MAX_RESULT_CHARS = 20_000;
6
+ export const MAX_VERBOSE_CHARS = 20_000;
7
+ export const MAX_PEEK_CHARS = 20_000;
8
+ export const MAX_PEEK_LINES = 200;
9
+ export function limitText(text, maxChars) {
10
+ if (text.length <= maxChars) {
11
+ return { text, truncated: false, omittedChars: 0 };
12
+ }
13
+ return {
14
+ text: text.slice(0, maxChars),
15
+ truncated: true,
16
+ omittedChars: text.length - maxChars,
17
+ };
18
+ }
19
+ export function clampPeekLines(lines) {
20
+ if (typeof lines !== "number" || !Number.isFinite(lines) || lines < 1)
21
+ return 20;
22
+ return Math.min(Math.floor(lines), MAX_PEEK_LINES);
23
+ }
24
+ export function formatOutputFileHint(outputFile) {
25
+ return outputFile ? ` Full output/log: ${outputFile}` : "";
26
+ }
package/dist/index.js CHANGED
@@ -22,6 +22,7 @@ import { AgentManager } from "./agent-manager.js";
22
22
  import { getAgentConversation, getCurrentExtensionAgentId, getCurrentExtensionDepth, getDefaultMaxTurns, getGraceTurns, normalizeMaxTurns, SUBAGENT_TOOL_NAMES, setDefaultMaxTurns, setGraceTurns, steerAgent } from "./agent-runner.js";
23
23
  import { buildAgentToolDescription, getModelLabelFromConfig } from "./agent-tool-description.js";
24
24
  import { BUILTIN_TOOL_NAMES, getAgentConfig, getAllTypes, getAvailableTypes, isDefaultsDisabled, registerAgents, resolveType, setDefaultsDisabled } from "./agent-types.js";
25
+ import { formatOutputFileHint, limitText, MAX_RESULT_CHARS, MAX_VERBOSE_CHARS } from "./bounded-output.js";
25
26
  import { registerRpcHandlers } from "./cross-extension-rpc.js";
26
27
  import { loadCustomAgents } from "./custom-agents.js";
27
28
  import { isModelInScope, readEnabledModels, resolveEnabledModels } from "./enabled-models.js";
@@ -41,7 +42,7 @@ import { AgentWidget, buildInvocationTags, describeActivity, formatContextWindow
41
42
  import { menuSelect } from "./ui/menu-select.js";
42
43
  import { showSchedulesMenu } from "./ui/schedule-menu.js";
43
44
  import { addUsage, getLifetimeTotal, getSessionContextPercent } from "./usage.js";
44
- import { formatWaitTimeout, raceWait, waitTimeoutMessage } from "./wait.js";
45
+ import { formatWaitTimeout, pollPendingMessages, raceWait, waitTimeoutMessage } from "./wait.js";
45
46
  // ---- Shared helpers ----
46
47
  /** Tool execute return value for a text response. */
47
48
  function textResult(msg, details) {
@@ -236,7 +237,7 @@ export default function (pi) {
236
237
  }
237
238
  pi.sendMessage({
238
239
  customType: "subagent-notification",
239
- content: `Background agent group completed: ${label}\n\n${notifications}\n\nUse get_subagent_result for full output.`,
240
+ content: `Background agent group completed: ${label}\n\n${notifications}\n\nUse get_subagent_result for a bounded preview; inspect the transcript file path for full output when needed.`,
240
241
  display: true,
241
242
  details,
242
243
  }, { deliverAs: "steer", triggerTurn: true });
@@ -823,7 +824,7 @@ export default function (pi) {
823
824
  widget.update();
824
825
  const resumeDetails = { displayName: getDisplayName(record.type), description: record.description, subagentType: record.type, modelName: record.invocation?.modelName };
825
826
  return textResult(`Agent resumed in background.\nAgent ID: ${record.id}\nType: ${resumeDetails.displayName}\nDescription: ${record.description}\n\n` +
826
- `You will be notified when this agent completes.\nUse get_subagent_result to retrieve full results, or steer_subagent to send it messages.\nDo not duplicate this agent's work.`, buildDetails(resumeDetails, record, state, { status: "background" }));
827
+ `You will be notified when this agent completes.\nUse get_subagent_result to inspect bounded result previews, or steer_subagent to send it messages.\nDo not duplicate this agent's work.`, buildDetails(resumeDetails, record, state, { status: "background" }));
827
828
  }
828
829
  // Background execution
829
830
  const { state: bgState, callbacks: bgCallbacks } = createActivityTracker(effectiveMaxTurns);
@@ -852,6 +853,8 @@ export default function (pi) {
852
853
  invocation: agentInvocation,
853
854
  depth: nextSubagentDepth,
854
855
  parentAgentId: extensionAgentId,
856
+ outputFileForAgent: (agentId) => createOutputFilePath(ctx.cwd, agentId, ctx.sessionManager.getSessionId()),
857
+ onOutputFileCreated: (outputFile, agentId) => writeInitialEntry(outputFile, agentId, P.prompt, ctx.cwd),
855
858
  ...bgCallbacks,
856
859
  });
857
860
  }
@@ -862,15 +865,13 @@ export default function (pi) {
862
865
  const h = stashInvocation(P, retryHandle, ["isolation"]);
863
866
  return retryableResult(h, err instanceof Error ? err.message : String(err), "isolation");
864
867
  }
865
- // Set output file + join mode synchronously after spawn, before the
866
- // event loop yields onSessionCreated is async so this is safe.
868
+ // Set join mode synchronously after spawn. The output file path is
869
+ // attached inside AgentManager.spawn(), before the agent can complete.
867
870
  const joinMode = resolveJoinMode(defaultJoinMode);
868
871
  const record = manager.getRecord(id);
869
872
  if (record) {
870
873
  record.joinMode = joinMode;
871
874
  record.toolCallId = toolCallId;
872
- record.outputFile = createOutputFilePath(ctx.cwd, id, ctx.sessionManager.getSessionId());
873
- writeInitialEntry(record.outputFile, id, P.prompt, ctx.cwd);
874
875
  }
875
876
  if (joinMode === 'async') {
876
877
  // No join mode or explicit async — not part of any batch
@@ -900,7 +901,7 @@ export default function (pi) {
900
901
  return textResult(`Agent ${isQueued ? "queued" : "started"} in background.\nAgent ID: ${id}\nType: ${displayName}\nDescription: ${P.description}\n` +
901
902
  (record?.outputFile ? `Output file: ${record.outputFile}\n` : "") +
902
903
  (isQueued ? `Position: queued (max ${manager.getMaxConcurrent()} concurrent)\n` : "") +
903
- `\nYou will be notified when this agent completes.\nUse get_subagent_result to retrieve full results, or steer_subagent to send it messages.\nDo not duplicate this agent's work.`, { ...detailBase, toolUses: 0, tokens: "", durationMs: 0, status: "background", agentId: id });
904
+ `\nYou will be notified when this agent completes.\nUse get_subagent_result to inspect bounded result previews, or steer_subagent to send it messages.\nDo not duplicate this agent's work.`, { ...detailBase, toolUses: 0, tokens: "", durationMs: 0, status: "background", agentId: id });
904
905
  },
905
906
  }));
906
907
  // ---- get_subagent_result tool ----
@@ -917,7 +918,7 @@ export default function (pi) {
917
918
  description: `If true, block until the agent completes before returning. Blocks up to the configured wait timeout (${formatWaitTimeout(getWaitTimeoutSeconds())} by default); if the agent is still running when the timeout is reached, returns its current status — call again with wait: true to keep waiting. Interruptible by the parent turn. Default: false.`,
918
919
  })),
919
920
  verbose: Type.Optional(Type.Boolean({
920
- description: "If true, include the agent's full conversation (messages + tool calls). Default: false.",
921
+ description: "If true, include a bounded preview of the agent's conversation (messages + tool calls). Default: false.",
921
922
  })),
922
923
  peek: Type.Optional(Type.Object({
923
924
  lines: Type.Optional(Type.Number({ minimum: 1, description: "Number of trailing lines to return. Default: 20." })),
@@ -927,7 +928,7 @@ export default function (pi) {
927
928
  description: "Return a lightweight tail/filter view of the agent's result or live output file, with line numbers. Ignored when verbose is true.",
928
929
  })),
929
930
  }),
930
- execute: async (_toolCallId, params, signal, _onUpdate, _ctx) => {
931
+ execute: async (_toolCallId, params, signal, _onUpdate, ctx) => {
931
932
  const record = manager.getRecord(params.agent_id);
932
933
  if (!record) {
933
934
  return textResult(`Agent not found: "${params.agent_id}". It may have been cleaned up.`);
@@ -936,7 +937,7 @@ export default function (pi) {
936
937
  if (params.peek && !params.verbose) {
937
938
  const peek = peekAgentOutput(record, params.peek);
938
939
  return textResult(peek
939
- ? `${peek.text}\n\n---\nUse verbose: true for the full conversation, or omit peek for the complete result.`
940
+ ? `${peek.text}\n\n---\nUse verbose: true for a bounded conversation preview, or omit peek for a bounded result preview.`
940
941
  : "No output yet for this agent.");
941
942
  }
942
943
  // WAIT — race the agent's completion against the configured timeout and
@@ -945,7 +946,18 @@ export default function (pi) {
945
946
  let waitOutcome = "completed";
946
947
  if (params.wait && record.status === "running" && record.promise) {
947
948
  cancelNudge(params.agent_id);
948
- waitOutcome = await raceWait(record.promise, signal, getWaitTimeoutSeconds());
949
+ // Poll for queued user messages so we can return early and let the
950
+ // parent LLM process them immediately instead of blocking for the
951
+ // full wait timeout.
952
+ const pending = typeof ctx?.hasPendingMessages === "function"
953
+ ? pollPendingMessages(() => ctx.hasPendingMessages())
954
+ : undefined;
955
+ try {
956
+ waitOutcome = await raceWait(record.promise, signal, getWaitTimeoutSeconds(), pending?.promise);
957
+ }
958
+ finally {
959
+ pending?.cancel();
960
+ }
949
961
  if (waitOutcome === "completed") {
950
962
  record.resultConsumed = true;
951
963
  }
@@ -964,27 +976,42 @@ export default function (pi) {
964
976
  statsParts.push(`Duration: ${duration}`);
965
977
  let output = `Agent: ${record.id}\n` +
966
978
  `Type: ${displayName} | Status: ${record.status}${getStatusNote(record.status)} | ${statsParts.join(" | ")}\n` +
967
- `Description: ${record.description}\n\n`;
979
+ `Description: ${record.description}\n` +
980
+ (record.outputFile ? `Output file: ${record.outputFile}\n` : "") +
981
+ `\n`;
968
982
  if (record.status === "running") {
969
983
  // The wait returned while the agent was still running (timeout or abort).
970
984
  output += waitTimeoutMessage(waitOutcome, getWaitTimeoutSeconds());
971
985
  }
972
986
  else if (record.status === "error") {
973
- output += `Error: ${record.error}`;
987
+ const limited = limitText(record.error ?? "unknown", MAX_RESULT_CHARS);
988
+ output += `Error: ${limited.text}`;
989
+ if (limited.truncated) {
990
+ output += `\n\n---\nError truncated: omitted ${limited.omittedChars} chars. Inspect the output file for the full error when available.${formatOutputFileHint(record.outputFile)}`;
991
+ }
974
992
  }
975
993
  else {
976
- output += record.result?.trim() || "No output.";
994
+ const resultText = record.result?.trim() || "No output.";
995
+ const limited = limitText(resultText, MAX_RESULT_CHARS);
996
+ output += limited.text;
997
+ if (limited.truncated) {
998
+ output += `\n\n---\nResult truncated: omitted ${limited.omittedChars} chars. Use peek for targeted retrieval, or inspect the output file for the full log.${formatOutputFileHint(record.outputFile)}`;
999
+ }
977
1000
  }
978
1001
  // Mark result as consumed — suppresses the completion notification
979
1002
  if (record.status !== "running" && record.status !== "queued") {
980
1003
  record.resultConsumed = true;
981
1004
  cancelNudge(params.agent_id);
982
1005
  }
983
- // Verbose: include full conversation
1006
+ // Verbose: include a bounded conversation preview
984
1007
  if (params.verbose && record.session) {
985
1008
  const conversation = getAgentConversation(record.session);
986
1009
  if (conversation) {
987
- output += `\n\n--- Agent Conversation ---\n${conversation}`;
1010
+ const limited = limitText(conversation, MAX_VERBOSE_CHARS);
1011
+ output += `\n\n--- Agent Conversation ---\n${limited.text}`;
1012
+ if (limited.truncated) {
1013
+ output += `\n\n---\nAgent conversation truncated: omitted ${limited.omittedChars} chars. Use peek for targeted retrieval, or inspect the output file for the full log.${formatOutputFileHint(record.outputFile)}`;
1014
+ }
988
1015
  }
989
1016
  }
990
1017
  return textResult(output);
@@ -23,12 +23,14 @@ export function formatTaskNotification(record, resultMaxLen) {
23
23
  const compactXml = record.compactionCount ? `<compactions>${record.compactionCount}</compactions>` : "";
24
24
  const resultPreview = record.result
25
25
  ? record.result.length > resultMaxLen
26
- ? record.result.slice(0, resultMaxLen) + "\n...(truncated, use get_subagent_result for full output)"
26
+ ? record.result.slice(0, resultMaxLen) + (record.outputFile
27
+ ? "\n...(truncated, use get_subagent_result for a bounded preview or inspect the transcript file)"
28
+ : "\n...(truncated, use get_subagent_result for a bounded preview)")
27
29
  : record.result
28
30
  : "No output.";
29
31
  const fullOutputInstruction = record.outputFile
30
- ? `Read the full output/log with get_subagent_result for agent ${record.id}, or inspect the transcript file: ${record.outputFile}`
31
- : `Read the full output/log with get_subagent_result for agent ${record.id}.`;
32
+ ? `Read a bounded preview with get_subagent_result for agent ${record.id}, or inspect the full transcript file: ${record.outputFile}`
33
+ : `Read a bounded preview with get_subagent_result for agent ${record.id}.`;
32
34
  return [
33
35
  `<task-notification>`,
34
36
  `<task-id>${record.id}</task-id>`,
@@ -96,8 +98,8 @@ export function registerSubagentNotificationRenderer(pi) {
96
98
  line += "\n " + theme.fg("dim", `⎿ ${preview}`);
97
99
  }
98
100
  const fullOutputHint = d.outputFile
99
- ? `full output: get_subagent_result ${d.id} or transcript: ${d.outputFile}`
100
- : `full output: get_subagent_result ${d.id}`;
101
+ ? `bounded preview: get_subagent_result ${d.id}; full transcript: ${d.outputFile}`
102
+ : `bounded preview: get_subagent_result ${d.id}`;
101
103
  line += "\n " + theme.fg("muted", fullOutputHint);
102
104
  return line;
103
105
  }
package/dist/peek.js CHANGED
@@ -14,8 +14,7 @@
14
14
  * `after` for incremental updates without missing anything.
15
15
  */
16
16
  import { existsSync, readFileSync } from "node:fs";
17
- /** Default number of tail lines when neither `after` nor `lines` is given. */
18
- const DEFAULT_LINES = 20;
17
+ import { clampPeekLines, formatOutputFileHint, limitText, MAX_PEEK_CHARS, MAX_PEEK_LINES } from "./bounded-output.js";
19
18
  /**
20
19
  * Produce a peek view of an agent's output. Returns null when there is no
21
20
  * source content at all (the caller renders a "no output yet" message).
@@ -26,20 +25,33 @@ export function peekAgentOutput(record, opts = {}) {
26
25
  return null;
27
26
  const regex = opts.regex ? compileRegex(opts.regex) : undefined;
28
27
  const after = typeof opts.after === "number" ? opts.after : -1;
29
- const tail = typeof opts.lines === "number" && opts.lines >= 1 ? opts.lines : DEFAULT_LINES;
28
+ const tail = clampPeekLines(opts.lines);
30
29
  // Index each source line with its original position (1-based for display).
31
30
  const indexed = lines.map((text, i) => ({ no: i + 1, text }));
32
31
  // Filter-then-select.
33
32
  const filtered = regex ? indexed.filter((l) => regex.test(l.text)) : indexed;
34
- const selected = after >= 0
35
- ? filtered.filter((l) => l.no > after)
36
- : filtered.slice(-tail);
33
+ const matching = after >= 0 ? filtered.filter((l) => l.no > after) : filtered;
34
+ const selected = after >= 0 ? matching.slice(0, MAX_PEEK_LINES) : matching.slice(-tail);
35
+ const lineLimited = matching.length > selected.length;
37
36
  const totalLines = lines.length;
38
37
  const isRunning = record.status === "running" || record.status === "queued";
39
38
  const source = isRunning && record.outputFile && existsSync(record.outputFile) ? "outputFile" : "result";
40
- const header = buildHeader(opts, selected.length, totalLines, source);
39
+ const header = buildHeader(opts, selected.length, totalLines, source, tail, lineLimited, matching.length);
41
40
  const body = selected.map((l) => `[${l.no}] ${l.text}`).join("\n");
42
- return { text: `${header}\n\n${body}`, totalLines, source };
41
+ const limited = limitText(body, MAX_PEEK_CHARS);
42
+ const lastLine = selected.at(-1)?.no;
43
+ const notices = [];
44
+ if (lineLimited && lastLine !== undefined) {
45
+ notices.push(`Peek limited to ${MAX_PEEK_LINES} lines from ${matching.length} matching lines. Use peek.after: ${lastLine} to continue.`);
46
+ }
47
+ if (limited.truncated) {
48
+ notices.push(`Peek output truncated by ${limited.omittedChars} chars. Use a smaller lines value or regex filter.${formatOutputFileHint(record.outputFile)}`);
49
+ }
50
+ return {
51
+ text: `${header}\n\n${limited.text}${notices.length ? `\n\n---\n${notices.join("\n")}` : ""}`,
52
+ totalLines,
53
+ source,
54
+ };
43
55
  }
44
56
  /** Read the most useful text lines from the agent's output. */
45
57
  function readSourceLines(record) {
@@ -105,14 +117,15 @@ function compileRegex(pattern) {
105
117
  return new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
106
118
  }
107
119
  }
108
- function buildHeader(opts, shown, total, source) {
120
+ function buildHeader(opts, shown, total, source, tail, lineLimited, matching) {
109
121
  const parts = [];
110
122
  if (typeof opts.after === "number") {
111
123
  parts.push(`after line number ${opts.after}`);
124
+ if (lineLimited)
125
+ parts.push(`limited to ${MAX_PEEK_LINES} lines from ${matching} matching lines`);
112
126
  }
113
127
  else {
114
- const n = typeof opts.lines === "number" && opts.lines >= 1 ? opts.lines : DEFAULT_LINES;
115
- parts.push(`last ${n} lines`);
128
+ parts.push(`last ${tail} lines`);
116
129
  }
117
130
  if (opts.regex)
118
131
  parts.push(`filtered by regex /${opts.regex}/`);
package/dist/wait.d.ts CHANGED
@@ -1,10 +1,24 @@
1
- export type WaitOutcome = "completed" | "timeout" | "aborted";
1
+ export type WaitOutcome = "completed" | "timeout" | "aborted" | "pending_message";
2
2
  /** Human-readable "Xm Ys" for a duration in seconds. */
3
3
  export declare function formatWaitTimeout(seconds: number): string;
4
4
  /**
5
- * Race an agent completion promise against the configured wait timeout and the
6
- * parent abort signal. The subagent is never aborted here.
5
+ * Race an agent completion promise against the configured wait timeout, the
6
+ * parent abort signal, and an optional pending-message check. The subagent is
7
+ * never aborted here.
8
+ *
9
+ * @param pendingCheck - Optional promise that resolves when the parent session
10
+ * has queued user messages waiting to be delivered. When it resolves, the
11
+ * wait ends early so the parent turn can process the incoming message.
7
12
  */
8
- export declare function raceWait(promise: Promise<string>, signal: AbortSignal | undefined, timeoutSeconds: number): Promise<WaitOutcome>;
13
+ export declare function raceWait(promise: Promise<string>, signal: AbortSignal | undefined, timeoutSeconds: number, pendingCheck?: Promise<void>): Promise<WaitOutcome>;
9
14
  /** Message returned when a wait ends with the agent still running. */
10
15
  export declare function waitTimeoutMessage(outcome: WaitOutcome, timeoutSeconds: number): string;
16
+ /**
17
+ * Create a promise that resolves when the parent session has queued user
18
+ * messages. Polls at the given interval until `hasPendingMessages()` returns
19
+ * true. The caller should race this against the agent completion / timeout.
20
+ */
21
+ export declare function pollPendingMessages(hasPendingMessages: () => boolean, intervalMs?: number): {
22
+ promise: Promise<void>;
23
+ cancel: () => void;
24
+ };
package/dist/wait.js CHANGED
@@ -5,10 +5,15 @@ export function formatWaitTimeout(seconds) {
5
5
  return m > 0 ? `${m}m${s > 0 ? ` ${s}s` : ""}` : `${s}s`;
6
6
  }
7
7
  /**
8
- * Race an agent completion promise against the configured wait timeout and the
9
- * parent abort signal. The subagent is never aborted here.
8
+ * Race an agent completion promise against the configured wait timeout, the
9
+ * parent abort signal, and an optional pending-message check. The subagent is
10
+ * never aborted here.
11
+ *
12
+ * @param pendingCheck - Optional promise that resolves when the parent session
13
+ * has queued user messages waiting to be delivered. When it resolves, the
14
+ * wait ends early so the parent turn can process the incoming message.
10
15
  */
11
- export function raceWait(promise, signal, timeoutSeconds) {
16
+ export function raceWait(promise, signal, timeoutSeconds, pendingCheck) {
12
17
  return new Promise((resolve) => {
13
18
  let settled = false;
14
19
  const finish = (outcome) => {
@@ -23,6 +28,7 @@ export function raceWait(promise, signal, timeoutSeconds) {
23
28
  const onAbort = () => finish("aborted");
24
29
  signal?.addEventListener("abort", onAbort, { once: true });
25
30
  promise.then(() => finish("completed"));
31
+ pendingCheck?.then(() => finish("pending_message"));
26
32
  });
27
33
  }
28
34
  /** Message returned when a wait ends with the agent still running. */
@@ -33,5 +39,44 @@ export function waitTimeoutMessage(outcome, timeoutSeconds) {
33
39
  if (outcome === "aborted") {
34
40
  return `Agent is still running. The wait was cancelled by the user (parent turn aborted). The subagent was NOT stopped — it continues in the background.\nCall get_subagent_result with wait: true again to keep waiting, use peek to check progress, or omit wait to check status.`;
35
41
  }
42
+ if (outcome === "pending_message") {
43
+ return `Agent is still running. The wait was interrupted by an incoming user message. The subagent was NOT stopped — it continues in the background.\nThe queued message will be delivered after this tool returns.\nCall get_subagent_result with wait: true again to keep waiting, use peek to check progress, or omit wait to check status.`;
44
+ }
36
45
  return "Agent is still running. Use peek to check recent progress, wait: true to block until it finishes, or check back later.";
37
46
  }
47
+ /**
48
+ * Create a promise that resolves when the parent session has queued user
49
+ * messages. Polls at the given interval until `hasPendingMessages()` returns
50
+ * true. The caller should race this against the agent completion / timeout.
51
+ */
52
+ export function pollPendingMessages(hasPendingMessages, intervalMs = 1000) {
53
+ let settled = false;
54
+ let resolve;
55
+ const promise = new Promise((r) => { resolve = r; });
56
+ // Check immediately in case a message arrived between the tool call
57
+ // start and this poll setup.
58
+ if (hasPendingMessages()) {
59
+ settled = true;
60
+ resolve();
61
+ return { promise, cancel: () => { } };
62
+ }
63
+ const timer = setInterval(() => {
64
+ if (settled)
65
+ return;
66
+ if (hasPendingMessages()) {
67
+ settled = true;
68
+ clearInterval(timer);
69
+ resolve();
70
+ }
71
+ }, intervalMs);
72
+ return {
73
+ promise,
74
+ cancel: () => {
75
+ if (!settled) {
76
+ settled = true;
77
+ clearInterval(timer);
78
+ resolve();
79
+ }
80
+ },
81
+ };
82
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clanker-code/pi-subagents",
3
- "version": "0.10.6",
3
+ "version": "0.10.8",
4
4
  "description": "A pi extension extension that brings smart Claude Code-style autonomous sub-agents to pi.",
5
5
  "author": "clankercode",
6
6
  "license": "MIT",
@@ -90,6 +90,10 @@ interface SpawnOptions {
90
90
  onToolActivity?: (activity: ToolActivity) => void;
91
91
  /** Called on streaming text deltas from the assistant response. */
92
92
  onTextDelta?: (delta: string, fullText: string) => void;
93
+ /** Build a per-agent output file path before the agent can complete. */
94
+ outputFileForAgent?: (agentId: string) => string;
95
+ /** Called after the output file path is attached to the record. */
96
+ onOutputFileCreated?: (path: string, agentId: string) => void;
93
97
  /** Called when the agent session is created (for accessing session stats). */
94
98
  onSessionCreated?: (session: AgentSession) => void;
95
99
  /** Called at the end of each agentic turn with the cumulative count. */
@@ -185,6 +189,10 @@ export class AgentManager {
185
189
  depth,
186
190
  parentAgentId: options.parentAgentId,
187
191
  };
192
+ if (options.outputFileForAgent) {
193
+ record.outputFile = options.outputFileForAgent(id);
194
+ options.onOutputFileCreated?.(record.outputFile, id);
195
+ }
188
196
  this.agents.set(id, record);
189
197
 
190
198
  const args: SpawnArgs = { pi, ctx, type, prompt, options };
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Shared caps for tool responses that may otherwise flood the parent context.
3
+ * Full subagent logs remain available via the per-agent output file.
4
+ */
5
+
6
+ export const MAX_RESULT_CHARS = 20_000;
7
+ export const MAX_VERBOSE_CHARS = 20_000;
8
+ export const MAX_PEEK_CHARS = 20_000;
9
+ export const MAX_PEEK_LINES = 200;
10
+
11
+ export interface LimitedText {
12
+ text: string;
13
+ truncated: boolean;
14
+ omittedChars: number;
15
+ }
16
+
17
+ export function limitText(text: string, maxChars: number): LimitedText {
18
+ if (text.length <= maxChars) {
19
+ return { text, truncated: false, omittedChars: 0 };
20
+ }
21
+ return {
22
+ text: text.slice(0, maxChars),
23
+ truncated: true,
24
+ omittedChars: text.length - maxChars,
25
+ };
26
+ }
27
+
28
+ export function clampPeekLines(lines: number | undefined): number {
29
+ if (typeof lines !== "number" || !Number.isFinite(lines) || lines < 1) return 20;
30
+ return Math.min(Math.floor(lines), MAX_PEEK_LINES);
31
+ }
32
+
33
+ export function formatOutputFileHint(outputFile?: string): string {
34
+ return outputFile ? ` Full output/log: ${outputFile}` : "";
35
+ }
package/src/index.ts CHANGED
@@ -23,6 +23,7 @@ import { AgentManager } from "./agent-manager.js";
23
23
  import { getAgentConversation, getCurrentExtensionAgentId, getCurrentExtensionDepth, getDefaultMaxTurns, getGraceTurns, normalizeMaxTurns, SUBAGENT_TOOL_NAMES, setDefaultMaxTurns, setGraceTurns, steerAgent } from "./agent-runner.js";
24
24
  import { buildAgentToolDescription, getModelLabelFromConfig } from "./agent-tool-description.js";
25
25
  import { BUILTIN_TOOL_NAMES, getAgentConfig, getAllTypes, getAvailableTypes, isDefaultsDisabled, registerAgents, resolveType, setDefaultsDisabled } from "./agent-types.js";
26
+ import { formatOutputFileHint, limitText, MAX_RESULT_CHARS, MAX_VERBOSE_CHARS } from "./bounded-output.js";
26
27
  import { registerRpcHandlers } from "./cross-extension-rpc.js";
27
28
  import { loadCustomAgents } from "./custom-agents.js";
28
29
  import { isModelInScope, readEnabledModels, resolveEnabledModels } from "./enabled-models.js";
@@ -54,7 +55,7 @@ import type { WidgetAgentSnapshot, WidgetDisplayMode } from "./ui/agent-widget-t
54
55
  import { menuSelect } from "./ui/menu-select.js";
55
56
  import { showSchedulesMenu } from "./ui/schedule-menu.js";
56
57
  import { addUsage, getLifetimeTotal, getSessionContextPercent } from "./usage.js";
57
- import { formatWaitTimeout, raceWait, type WaitOutcome, waitTimeoutMessage } from "./wait.js";
58
+ import { formatWaitTimeout, pollPendingMessages, raceWait, type WaitOutcome, waitTimeoutMessage } from "./wait.js";
58
59
 
59
60
  // ---- Shared helpers ----
60
61
 
@@ -280,7 +281,7 @@ export default function (pi: ExtensionAPI) {
280
281
 
281
282
  pi.sendMessage<NotificationDetails>({
282
283
  customType: "subagent-notification",
283
- content: `Background agent group completed: ${label}\n\n${notifications}\n\nUse get_subagent_result for full output.`,
284
+ content: `Background agent group completed: ${label}\n\n${notifications}\n\nUse get_subagent_result for a bounded preview; inspect the transcript file path for full output when needed.`,
284
285
  display: true,
285
286
  details,
286
287
  }, { deliverAs: "steer", triggerTurn: true });
@@ -953,7 +954,7 @@ export default function (pi: ExtensionAPI) {
953
954
  const resumeDetails = { displayName: getDisplayName(record.type), description: record.description, subagentType: record.type, modelName: record.invocation?.modelName };
954
955
  return textResult(
955
956
  `Agent resumed in background.\nAgent ID: ${record.id}\nType: ${resumeDetails.displayName}\nDescription: ${record.description}\n\n` +
956
- `You will be notified when this agent completes.\nUse get_subagent_result to retrieve full results, or steer_subagent to send it messages.\nDo not duplicate this agent's work.`,
957
+ `You will be notified when this agent completes.\nUse get_subagent_result to inspect bounded result previews, or steer_subagent to send it messages.\nDo not duplicate this agent's work.`,
957
958
  buildDetails(resumeDetails, record, state, { status: "background" }),
958
959
  );
959
960
  }
@@ -987,6 +988,8 @@ export default function (pi: ExtensionAPI) {
987
988
  invocation: agentInvocation,
988
989
  depth: nextSubagentDepth,
989
990
  parentAgentId: extensionAgentId,
991
+ outputFileForAgent: (agentId) => createOutputFilePath(ctx.cwd, agentId, ctx.sessionManager.getSessionId()),
992
+ onOutputFileCreated: (outputFile, agentId) => writeInitialEntry(outputFile, agentId, P.prompt!, ctx.cwd),
990
993
  ...bgCallbacks,
991
994
  });
992
995
  } catch (err) {
@@ -997,15 +1000,13 @@ export default function (pi: ExtensionAPI) {
997
1000
  return retryableResult(h, err instanceof Error ? err.message : String(err), "isolation");
998
1001
  }
999
1002
 
1000
- // Set output file + join mode synchronously after spawn, before the
1001
- // event loop yields onSessionCreated is async so this is safe.
1003
+ // Set join mode synchronously after spawn. The output file path is
1004
+ // attached inside AgentManager.spawn(), before the agent can complete.
1002
1005
  const joinMode = resolveJoinMode(defaultJoinMode);
1003
1006
  const record = manager.getRecord(id);
1004
1007
  if (record) {
1005
1008
  record.joinMode = joinMode;
1006
1009
  record.toolCallId = toolCallId;
1007
- record.outputFile = createOutputFilePath(ctx.cwd, id, ctx.sessionManager.getSessionId());
1008
- writeInitialEntry(record.outputFile, id, P.prompt!, ctx.cwd);
1009
1010
  }
1010
1011
 
1011
1012
  if (joinMode === 'async') {
@@ -1038,7 +1039,7 @@ export default function (pi: ExtensionAPI) {
1038
1039
  `Agent ${isQueued ? "queued" : "started"} in background.\nAgent ID: ${id}\nType: ${displayName}\nDescription: ${P.description}\n` +
1039
1040
  (record?.outputFile ? `Output file: ${record.outputFile}\n` : "") +
1040
1041
  (isQueued ? `Position: queued (max ${manager.getMaxConcurrent()} concurrent)\n` : "") +
1041
- `\nYou will be notified when this agent completes.\nUse get_subagent_result to retrieve full results, or steer_subagent to send it messages.\nDo not duplicate this agent's work.`,
1042
+ `\nYou will be notified when this agent completes.\nUse get_subagent_result to inspect bounded result previews, or steer_subagent to send it messages.\nDo not duplicate this agent's work.`,
1042
1043
  { ...detailBase, toolUses: 0, tokens: "", durationMs: 0, status: "background" as const, agentId: id },
1043
1044
  );
1044
1045
  },
@@ -1063,7 +1064,7 @@ export default function (pi: ExtensionAPI) {
1063
1064
  ),
1064
1065
  verbose: Type.Optional(
1065
1066
  Type.Boolean({
1066
- description: "If true, include the agent's full conversation (messages + tool calls). Default: false.",
1067
+ description: "If true, include a bounded preview of the agent's conversation (messages + tool calls). Default: false.",
1067
1068
  }),
1068
1069
  ),
1069
1070
  peek: Type.Optional(
@@ -1076,7 +1077,7 @@ export default function (pi: ExtensionAPI) {
1076
1077
  }),
1077
1078
  ),
1078
1079
  }),
1079
- execute: async (_toolCallId, params, signal, _onUpdate, _ctx) => {
1080
+ execute: async (_toolCallId, params, signal, _onUpdate, ctx) => {
1080
1081
  const record = manager.getRecord(params.agent_id);
1081
1082
  if (!record) {
1082
1083
  return textResult(`Agent not found: "${params.agent_id}". It may have been cleaned up.`);
@@ -1087,7 +1088,7 @@ export default function (pi: ExtensionAPI) {
1087
1088
  const peek = peekAgentOutput(record, params.peek as PeekOptions);
1088
1089
  return textResult(
1089
1090
  peek
1090
- ? `${peek.text}\n\n---\nUse verbose: true for the full conversation, or omit peek for the complete result.`
1091
+ ? `${peek.text}\n\n---\nUse verbose: true for a bounded conversation preview, or omit peek for a bounded result preview.`
1091
1092
  : "No output yet for this agent.",
1092
1093
  );
1093
1094
  }
@@ -1098,7 +1099,17 @@ export default function (pi: ExtensionAPI) {
1098
1099
  let waitOutcome: WaitOutcome = "completed";
1099
1100
  if (params.wait && record.status === "running" && record.promise) {
1100
1101
  cancelNudge(params.agent_id);
1101
- waitOutcome = await raceWait(record.promise, signal, getWaitTimeoutSeconds());
1102
+ // Poll for queued user messages so we can return early and let the
1103
+ // parent LLM process them immediately instead of blocking for the
1104
+ // full wait timeout.
1105
+ const pending = typeof ctx?.hasPendingMessages === "function"
1106
+ ? pollPendingMessages(() => ctx.hasPendingMessages())
1107
+ : undefined;
1108
+ try {
1109
+ waitOutcome = await raceWait(record.promise, signal, getWaitTimeoutSeconds(), pending?.promise);
1110
+ } finally {
1111
+ pending?.cancel();
1112
+ }
1102
1113
  if (waitOutcome === "completed") {
1103
1114
  record.resultConsumed = true;
1104
1115
  }
@@ -1117,15 +1128,26 @@ export default function (pi: ExtensionAPI) {
1117
1128
  let output =
1118
1129
  `Agent: ${record.id}\n` +
1119
1130
  `Type: ${displayName} | Status: ${record.status}${getStatusNote(record.status)} | ${statsParts.join(" | ")}\n` +
1120
- `Description: ${record.description}\n\n`;
1131
+ `Description: ${record.description}\n` +
1132
+ (record.outputFile ? `Output file: ${record.outputFile}\n` : "") +
1133
+ `\n`;
1121
1134
 
1122
1135
  if (record.status === "running") {
1123
1136
  // The wait returned while the agent was still running (timeout or abort).
1124
1137
  output += waitTimeoutMessage(waitOutcome, getWaitTimeoutSeconds());
1125
1138
  } else if (record.status === "error") {
1126
- output += `Error: ${record.error}`;
1139
+ const limited = limitText(record.error ?? "unknown", MAX_RESULT_CHARS);
1140
+ output += `Error: ${limited.text}`;
1141
+ if (limited.truncated) {
1142
+ output += `\n\n---\nError truncated: omitted ${limited.omittedChars} chars. Inspect the output file for the full error when available.${formatOutputFileHint(record.outputFile)}`;
1143
+ }
1127
1144
  } else {
1128
- output += record.result?.trim() || "No output.";
1145
+ const resultText = record.result?.trim() || "No output.";
1146
+ const limited = limitText(resultText, MAX_RESULT_CHARS);
1147
+ output += limited.text;
1148
+ if (limited.truncated) {
1149
+ output += `\n\n---\nResult truncated: omitted ${limited.omittedChars} chars. Use peek for targeted retrieval, or inspect the output file for the full log.${formatOutputFileHint(record.outputFile)}`;
1150
+ }
1129
1151
  }
1130
1152
 
1131
1153
  // Mark result as consumed — suppresses the completion notification
@@ -1134,11 +1156,15 @@ export default function (pi: ExtensionAPI) {
1134
1156
  cancelNudge(params.agent_id);
1135
1157
  }
1136
1158
 
1137
- // Verbose: include full conversation
1159
+ // Verbose: include a bounded conversation preview
1138
1160
  if (params.verbose && record.session) {
1139
1161
  const conversation = getAgentConversation(record.session);
1140
1162
  if (conversation) {
1141
- output += `\n\n--- Agent Conversation ---\n${conversation}`;
1163
+ const limited = limitText(conversation, MAX_VERBOSE_CHARS);
1164
+ output += `\n\n--- Agent Conversation ---\n${limited.text}`;
1165
+ if (limited.truncated) {
1166
+ output += `\n\n---\nAgent conversation truncated: omitted ${limited.omittedChars} chars. Use peek for targeted retrieval, or inspect the output file for the full log.${formatOutputFileHint(record.outputFile)}`;
1167
+ }
1142
1168
  }
1143
1169
  }
1144
1170
 
@@ -30,12 +30,14 @@ export function formatTaskNotification(record: AgentRecord, resultMaxLen: number
30
30
 
31
31
  const resultPreview = record.result
32
32
  ? record.result.length > resultMaxLen
33
- ? record.result.slice(0, resultMaxLen) + "\n...(truncated, use get_subagent_result for full output)"
33
+ ? record.result.slice(0, resultMaxLen) + (record.outputFile
34
+ ? "\n...(truncated, use get_subagent_result for a bounded preview or inspect the transcript file)"
35
+ : "\n...(truncated, use get_subagent_result for a bounded preview)")
34
36
  : record.result
35
37
  : "No output.";
36
38
  const fullOutputInstruction = record.outputFile
37
- ? `Read the full output/log with get_subagent_result for agent ${record.id}, or inspect the transcript file: ${record.outputFile}`
38
- : `Read the full output/log with get_subagent_result for agent ${record.id}.`;
39
+ ? `Read a bounded preview with get_subagent_result for agent ${record.id}, or inspect the full transcript file: ${record.outputFile}`
40
+ : `Read a bounded preview with get_subagent_result for agent ${record.id}.`;
39
41
 
40
42
  return [
41
43
  `<task-notification>`,
@@ -106,8 +108,8 @@ export function registerSubagentNotificationRenderer(pi: ExtensionAPI): void {
106
108
  }
107
109
 
108
110
  const fullOutputHint = d.outputFile
109
- ? `full output: get_subagent_result ${d.id} or transcript: ${d.outputFile}`
110
- : `full output: get_subagent_result ${d.id}`;
111
+ ? `bounded preview: get_subagent_result ${d.id}; full transcript: ${d.outputFile}`
112
+ : `bounded preview: get_subagent_result ${d.id}`;
111
113
  line += "\n " + theme.fg("muted", fullOutputHint);
112
114
 
113
115
  return line;
package/src/peek.ts CHANGED
@@ -15,6 +15,7 @@
15
15
  */
16
16
 
17
17
  import { existsSync, readFileSync } from "node:fs";
18
+ import { clampPeekLines, formatOutputFileHint, limitText, MAX_PEEK_CHARS, MAX_PEEK_LINES } from "./bounded-output.js";
18
19
  import type { AgentRecord } from "./types.js";
19
20
 
20
21
  export interface PeekOptions {
@@ -35,9 +36,6 @@ export interface PeekResult {
35
36
  source: "outputFile" | "result";
36
37
  }
37
38
 
38
- /** Default number of tail lines when neither `after` nor `lines` is given. */
39
- const DEFAULT_LINES = 20;
40
-
41
39
  /**
42
40
  * Produce a peek view of an agent's output. Returns null when there is no
43
41
  * source content at all (the caller renders a "no output yet" message).
@@ -48,27 +46,39 @@ export function peekAgentOutput(record: AgentRecord, opts: PeekOptions = {}): Pe
48
46
 
49
47
  const regex = opts.regex ? compileRegex(opts.regex) : undefined;
50
48
  const after = typeof opts.after === "number" ? opts.after : -1;
51
- const tail = typeof opts.lines === "number" && opts.lines >= 1 ? opts.lines : DEFAULT_LINES;
49
+ const tail = clampPeekLines(opts.lines);
52
50
 
53
51
  // Index each source line with its original position (1-based for display).
54
52
  const indexed = lines.map((text, i) => ({ no: i + 1, text }));
55
53
 
56
54
  // Filter-then-select.
57
55
  const filtered = regex ? indexed.filter((l) => regex.test(l.text)) : indexed;
58
- const selected =
59
- after >= 0
60
- ? filtered.filter((l) => l.no > after)
61
- : filtered.slice(-tail);
56
+ const matching = after >= 0 ? filtered.filter((l) => l.no > after) : filtered;
57
+ const selected = after >= 0 ? matching.slice(0, MAX_PEEK_LINES) : matching.slice(-tail);
58
+ const lineLimited = matching.length > selected.length;
62
59
 
63
60
  const totalLines = lines.length;
64
61
  const isRunning = record.status === "running" || record.status === "queued";
65
62
  const source: PeekResult["source"] =
66
63
  isRunning && record.outputFile && existsSync(record.outputFile) ? "outputFile" : "result";
67
64
 
68
- const header = buildHeader(opts, selected.length, totalLines, source);
65
+ const header = buildHeader(opts, selected.length, totalLines, source, tail, lineLimited, matching.length);
69
66
  const body = selected.map((l) => `[${l.no}] ${l.text}`).join("\n");
67
+ const limited = limitText(body, MAX_PEEK_CHARS);
68
+ const lastLine = selected.at(-1)?.no;
69
+ const notices: string[] = [];
70
+ if (lineLimited && lastLine !== undefined) {
71
+ notices.push(`Peek limited to ${MAX_PEEK_LINES} lines from ${matching.length} matching lines. Use peek.after: ${lastLine} to continue.`);
72
+ }
73
+ if (limited.truncated) {
74
+ notices.push(`Peek output truncated by ${limited.omittedChars} chars. Use a smaller lines value or regex filter.${formatOutputFileHint(record.outputFile)}`);
75
+ }
70
76
 
71
- return { text: `${header}\n\n${body}`, totalLines, source };
77
+ return {
78
+ text: `${header}\n\n${limited.text}${notices.length ? `\n\n---\n${notices.join("\n")}` : ""}`,
79
+ totalLines,
80
+ source,
81
+ };
72
82
  }
73
83
 
74
84
  /** Read the most useful text lines from the agent's output. */
@@ -141,13 +151,16 @@ function buildHeader(
141
151
  shown: number,
142
152
  total: number,
143
153
  source: PeekResult["source"],
154
+ tail: number,
155
+ lineLimited: boolean,
156
+ matching: number,
144
157
  ): string {
145
158
  const parts: string[] = [];
146
159
  if (typeof opts.after === "number") {
147
160
  parts.push(`after line number ${opts.after}`);
161
+ if (lineLimited) parts.push(`limited to ${MAX_PEEK_LINES} lines from ${matching} matching lines`);
148
162
  } else {
149
- const n = typeof opts.lines === "number" && opts.lines >= 1 ? opts.lines : DEFAULT_LINES;
150
- parts.push(`last ${n} lines`);
163
+ parts.push(`last ${tail} lines`);
151
164
  }
152
165
  if (opts.regex) parts.push(`filtered by regex /${opts.regex}/`);
153
166
  parts.push(`of ${total} total (${source === "outputFile" ? "live output file" : "result"})`);
package/src/wait.ts CHANGED
@@ -1,4 +1,4 @@
1
- export type WaitOutcome = "completed" | "timeout" | "aborted";
1
+ export type WaitOutcome = "completed" | "timeout" | "aborted" | "pending_message";
2
2
 
3
3
  /** Human-readable "Xm Ys" for a duration in seconds. */
4
4
  export function formatWaitTimeout(seconds: number): string {
@@ -8,13 +8,19 @@ export function formatWaitTimeout(seconds: number): string {
8
8
  }
9
9
 
10
10
  /**
11
- * Race an agent completion promise against the configured wait timeout and the
12
- * parent abort signal. The subagent is never aborted here.
11
+ * Race an agent completion promise against the configured wait timeout, the
12
+ * parent abort signal, and an optional pending-message check. The subagent is
13
+ * never aborted here.
14
+ *
15
+ * @param pendingCheck - Optional promise that resolves when the parent session
16
+ * has queued user messages waiting to be delivered. When it resolves, the
17
+ * wait ends early so the parent turn can process the incoming message.
13
18
  */
14
19
  export function raceWait(
15
20
  promise: Promise<string>,
16
21
  signal: AbortSignal | undefined,
17
22
  timeoutSeconds: number,
23
+ pendingCheck?: Promise<void>,
18
24
  ): Promise<WaitOutcome> {
19
25
  return new Promise((resolve) => {
20
26
  let settled = false;
@@ -29,6 +35,7 @@ export function raceWait(
29
35
  const onAbort = () => finish("aborted");
30
36
  signal?.addEventListener("abort", onAbort, { once: true });
31
37
  promise.then(() => finish("completed"));
38
+ pendingCheck?.then(() => finish("pending_message"));
32
39
  });
33
40
  }
34
41
 
@@ -40,5 +47,50 @@ export function waitTimeoutMessage(outcome: WaitOutcome, timeoutSeconds: number)
40
47
  if (outcome === "aborted") {
41
48
  return `Agent is still running. The wait was cancelled by the user (parent turn aborted). The subagent was NOT stopped — it continues in the background.\nCall get_subagent_result with wait: true again to keep waiting, use peek to check progress, or omit wait to check status.`;
42
49
  }
50
+ if (outcome === "pending_message") {
51
+ return `Agent is still running. The wait was interrupted by an incoming user message. The subagent was NOT stopped — it continues in the background.\nThe queued message will be delivered after this tool returns.\nCall get_subagent_result with wait: true again to keep waiting, use peek to check progress, or omit wait to check status.`;
52
+ }
43
53
  return "Agent is still running. Use peek to check recent progress, wait: true to block until it finishes, or check back later.";
44
54
  }
55
+
56
+ /**
57
+ * Create a promise that resolves when the parent session has queued user
58
+ * messages. Polls at the given interval until `hasPendingMessages()` returns
59
+ * true. The caller should race this against the agent completion / timeout.
60
+ */
61
+ export function pollPendingMessages(
62
+ hasPendingMessages: () => boolean,
63
+ intervalMs = 1000,
64
+ ): { promise: Promise<void>; cancel: () => void } {
65
+ let settled = false;
66
+ let resolve!: () => void;
67
+ const promise = new Promise<void>((r) => { resolve = r; });
68
+
69
+ // Check immediately in case a message arrived between the tool call
70
+ // start and this poll setup.
71
+ if (hasPendingMessages()) {
72
+ settled = true;
73
+ resolve();
74
+ return { promise, cancel: () => {} };
75
+ }
76
+
77
+ const timer = setInterval(() => {
78
+ if (settled) return;
79
+ if (hasPendingMessages()) {
80
+ settled = true;
81
+ clearInterval(timer);
82
+ resolve();
83
+ }
84
+ }, intervalMs);
85
+
86
+ return {
87
+ promise,
88
+ cancel: () => {
89
+ if (!settled) {
90
+ settled = true;
91
+ clearInterval(timer);
92
+ resolve();
93
+ }
94
+ },
95
+ };
96
+ }