@nklisch/pi-enhanced 0.2.7 → 0.3.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.
Files changed (70) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/node_modules/@nklisch/pi-clearance/native/clearance-core.linux-x64-gnu.node +0 -0
  3. package/node_modules/@nklisch/pi-clearance/native/clearance-core.win32-x64-msvc.node +0 -0
  4. package/node_modules/@nklisch/pi-plugins/README.md +4 -2
  5. package/node_modules/@nklisch/pi-plugins/dist/hooks.js +81 -8
  6. package/node_modules/@nklisch/pi-plugins/dist/hooks.js.map +1 -1
  7. package/node_modules/@nklisch/pi-plugins/dist/mcp.js +32 -1
  8. package/node_modules/@nklisch/pi-plugins/dist/mcp.js.map +1 -1
  9. package/node_modules/@nklisch/pi-plugins/dist/pi/plugin-manager.d.ts +10 -4
  10. package/node_modules/@nklisch/pi-plugins/dist/pi/plugin-manager.js +144 -10
  11. package/node_modules/@nklisch/pi-plugins/dist/pi/plugin-manager.js.map +1 -1
  12. package/node_modules/@nklisch/pi-plugins/dist/runtime-discovery.js +40 -14
  13. package/node_modules/@nklisch/pi-plugins/dist/runtime-discovery.js.map +1 -1
  14. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/CHANGELOG.md +27 -0
  15. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/README.md +76 -47
  16. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/dist/public.d.ts +171 -140
  17. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/dist/settings.d.ts +2 -2
  18. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/docs/FORK-MAINTENANCE.md +8 -7
  19. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/docs/VISION.md +12 -10
  20. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/docs/architecture/architecture.md +118 -104
  21. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/docs/comparison-with-upstream.md +3 -3
  22. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/docs/decisions/0004-reconsider-ui-direction.md +5 -0
  23. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/package.json +1 -1
  24. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/config/custom-agents.ts +31 -4
  25. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/config/default-agents.ts +1 -1
  26. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/config/invocation-config.ts +7 -4
  27. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/index.ts +18 -9
  28. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/layered-settings.ts +2 -2
  29. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/child-lifecycle.ts +10 -63
  30. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/concurrency-limiter.ts +122 -76
  31. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/create-subagent-session.ts +3 -5
  32. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/lifecycle-interceptor.ts +4 -3
  33. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/run-listeners.ts +2 -23
  34. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/subagent-manager.ts +177 -252
  35. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/subagent-session.ts +90 -280
  36. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/subagent-state.ts +153 -239
  37. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/subagent.ts +569 -700
  38. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/workspace.ts +1 -1
  39. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/observation/notification.ts +78 -122
  40. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/observation/renderer.ts +15 -42
  41. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/observation/subagent-events-observer.ts +44 -95
  42. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/service/service-adapter.ts +157 -90
  43. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/service/service.ts +60 -68
  44. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/session/conversation.ts +3 -3
  45. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/session/query-source.ts +28 -0
  46. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/session/query.ts +488 -0
  47. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/settings.ts +1 -1
  48. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/agent-tool.ts +171 -249
  49. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/get-result-tool.ts +51 -94
  50. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/helpers.ts +9 -6
  51. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/list-tool.ts +46 -0
  52. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/parent-tool-registry.ts +17 -0
  53. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/query-session-tool.ts +412 -0
  54. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/result-renderer.ts +27 -111
  55. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/resume-tool.ts +105 -0
  56. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/spawn-config.ts +26 -8
  57. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/steer-tool.ts +27 -111
  58. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/stop-tool.ts +53 -0
  59. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/types.ts +11 -5
  60. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/ui/agent-widget.ts +33 -27
  61. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/ui/display.ts +6 -3
  62. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/ui/session-navigation.ts +121 -10
  63. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/ui/session-navigator.ts +430 -112
  64. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/ui/subagents-settings.ts +1 -1
  65. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/ui/widget-renderer.ts +8 -9
  66. package/node_modules/@nklisch/pi-plugins/package.json +2 -2
  67. package/package.json +1 -1
  68. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/background-spawner.ts +0 -81
  69. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/foreground-runner.ts +0 -149
  70. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/get-result-report.ts +0 -77
@@ -5,12 +5,8 @@ import { type AgentDetails, formatTokens } from "#src/ui/display";
5
5
  /** Parenthetical status note for completed agent result text. */
6
6
  export function getStatusNote(status: string): string {
7
7
  switch (status) {
8
- case "aborted":
9
- return " (aborted \u2014 max turns exceeded, output may be incomplete)";
10
- case "steered":
11
- return " (wrapped up \u2014 reached turn limit)";
12
8
  case "stopped":
13
- return " (stopped by user)";
9
+ return " (stopped)";
14
10
  default:
15
11
  return "";
16
12
  }
@@ -23,7 +19,10 @@ export function buildDetails(
23
19
  toolUses: number;
24
20
  startedAt: number;
25
21
  completedAt?: number;
22
+ /** Active wall-clock duration for the current run lease. */
23
+ activeRuntimeMs?: number;
26
24
  status: string;
25
+ terminalReason?: import("#src/lifecycle/subagent-state").SubagentTerminalReason;
27
26
  error?: string;
28
27
  id?: string;
29
28
  lifetimeUsage: LifetimeUsage;
@@ -39,8 +38,12 @@ export function buildDetails(
39
38
  tokens: formatLifetimeTokens(record),
40
39
  turnCount: record.turnCount,
41
40
  maxTurns: record.maxTurns,
42
- durationMs: (record.completedAt ?? Date.now()) - record.startedAt,
41
+ // Do not derive runtime from record timestamps: startedAt may represent a
42
+ // prior lease and completedAt includes queue/resume waiting time. The
43
+ // lifecycle record owns the active-runtime measurement.
44
+ durationMs: record.activeRuntimeMs ?? 0,
43
45
  status: record.status as AgentDetails["status"],
46
+ terminalReason: record.terminalReason,
44
47
  agentId: record.id,
45
48
  error: record.error,
46
49
  ...overrides,
@@ -0,0 +1,46 @@
1
+ import { defineTool } from "@earendil-works/pi-coding-agent";
2
+ import { Type } from "@sinclair/typebox";
3
+ import { toSubagentRecord } from "#src/service/service-adapter";
4
+ import { textResult } from "#src/tools/helpers";
5
+
6
+ export interface ListToolManager {
7
+ listAgents(): import("#src/lifecycle/subagent").Subagent[];
8
+ }
9
+
10
+ export class ListTool {
11
+ constructor(private readonly manager: ListToolManager) {}
12
+
13
+ async execute(_toolCallId: string, params: { state?: "active" | "terminal" | "all"; limit?: number }, _signal: AbortSignal | undefined, _onUpdate: unknown, _ctx: unknown) {
14
+ const state = params.state ?? "all";
15
+ const limit = params.limit ?? 20;
16
+ if (!Number.isInteger(limit) || limit < 1 || limit > 100) return textResult("limit must be an integer from 1 to 100");
17
+ const records = this.manager.listAgents()
18
+ .filter((record) => state === "all" || (state === "active" ? record.isActive() : !record.isActive()))
19
+ .slice(0, limit)
20
+ .map(toSubagentRecord);
21
+ const lines = records.length === 0
22
+ ? ["No subagents match the requested state."]
23
+ : records.map((record) => [
24
+ `${record.id} run=${record.runId} ${record.mode} ${record.status}${record.stopRequested ? " stop_requested" : ""}`,
25
+ ` ${record.modelLabel} · thinking: ${record.thinkingLevel} · ${record.activeRuntimeMs}ms${record.status === "queued" || record.status === "running" ? ` · ${record.currentActivity.slice(0, 160)}` : ""}`,
26
+ record.terminalReason ? ` reason=${record.terminalReason}` : undefined,
27
+ record.activeTools.length > 0 ? ` tools=${record.activeTools.join(",")}` : undefined,
28
+ ` ${record.description}`,
29
+ ].filter((line): line is string => line !== undefined).join("\n"));
30
+ return textResult(lines.join("\n"), records);
31
+ }
32
+
33
+ toToolDefinition() {
34
+ return defineTool({
35
+ name: "list_subagents" as const,
36
+ label: "List Subagents",
37
+ promptSnippet: "list_subagents: Inspect bounded subagent fleet state.",
38
+ description: "List a bounded newest-first summary of active, terminal, or all subagents, including run identity, delivery mode, exact model/thinking, active runtime, live activity, tools, and terminal reason.",
39
+ parameters: Type.Object({
40
+ state: Type.Optional(Type.Union([Type.Literal("active"), Type.Literal("terminal"), Type.Literal("all")], { description: "Which records to include. Defaults to all." })),
41
+ limit: Type.Optional(Type.Integer({ description: "Maximum records, 1-100, default 20.", minimum: 1, maximum: 100 })),
42
+ }),
43
+ execute: (toolCallId: string, params: { state?: "active" | "terminal" | "all"; limit?: number }, signal: AbortSignal | undefined, onUpdate: unknown, ctx: unknown) => this.execute(toolCallId, params, signal, onUpdate, ctx),
44
+ });
45
+ }
46
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The single source of truth for tools that belong to the parent orchestrator.
3
+ * Children inherit ordinary extension registrations, but these controls are
4
+ * removed to prevent recursive orchestration.
5
+ */
6
+ export const PARENT_ONLY_TOOL_NAMES = [
7
+ "subagent",
8
+ "resume_subagent",
9
+ "stop_subagent",
10
+ "steer_subagent",
11
+ "list_subagents",
12
+ "get_subagent_result",
13
+ "query_subagent_session",
14
+ ] as const;
15
+
16
+ export type ParentOnlyToolName = typeof PARENT_ONLY_TOOL_NAMES[number];
17
+ export const PARENT_ONLY_TOOL_SET: ReadonlySet<string> = new Set(PARENT_ONLY_TOOL_NAMES);
@@ -0,0 +1,412 @@
1
+ import { defineTool } from "@earendil-works/pi-coding-agent";
2
+ import { Type } from "@sinclair/typebox";
3
+ import type { Subagent } from "#src/lifecycle/subagent";
4
+ import {
5
+ DEFAULT_QUERY_LIMIT,
6
+ MAX_QUERY_LIMIT,
7
+ MIN_QUERY_LIMIT,
8
+ querySession,
9
+ type SessionQueryEntry,
10
+ type SessionQueryKind,
11
+ type SessionQueryOrder,
12
+ } from "#src/session/query";
13
+ import { readSessionFileMessages } from "#src/session/query-source";
14
+ import { textResult } from "#src/tools/helpers";
15
+ import { describeActivity, formatMs, formatModelThinking } from "#src/ui/display";
16
+
17
+ const MAX_QUERY_OUTPUT_BYTES = 50 * 1024;
18
+ const MAX_QUERY_OUTPUT_LINES = 2_000;
19
+ // Leave room for the summary, source path, omission tail, and a final line/
20
+ // byte boundary. The entry budget is intentionally smaller than Pi's limits;
21
+ // entries are additionally checked against the actual fixed text below.
22
+ const MAX_QUERY_ENTRY_BYTES = 39 * 1024;
23
+ const MAX_QUERY_ENTRY_LINES = 1_800;
24
+ const MAX_QUERY_METADATA_LINE_BYTES = 2_048;
25
+ const MAX_QUERY_TRANSCRIPT_PATH_BYTES = 1_024;
26
+ const utf8Encoder = new TextEncoder();
27
+ const VALID_KINDS = new Set<SessionQueryKind>(["all", "messages", "tool_calls", "tool_results"]);
28
+ const VALID_ORDERS = new Set<SessionQueryOrder>(["newest", "oldest"]);
29
+
30
+ export type QuerySessionOutcome = "matches" | "no_matches" | "page_out_of_range" | "transcript_unavailable" | "not_found" | "read_error";
31
+
32
+ export interface QuerySessionDetails {
33
+ readonly outcome: QuerySessionOutcome;
34
+ readonly agentId: string;
35
+ readonly runId?: number;
36
+ readonly mode?: string;
37
+ readonly status?: string;
38
+ readonly terminalReason?: string;
39
+ readonly model?: string;
40
+ readonly thinkingLevel?: Subagent["effectiveThinkingLevel"];
41
+ readonly activeRuntimeMs?: number;
42
+ readonly activity?: string;
43
+ readonly source?: "live" | "file";
44
+ readonly transcriptPath?: string;
45
+ readonly query?: string;
46
+ readonly kind?: SessionQueryKind;
47
+ readonly order?: SessionQueryOrder;
48
+ readonly limit?: number;
49
+ readonly offset?: number;
50
+ readonly entries?: readonly SessionQueryEntry[];
51
+ readonly totalMatches?: number;
52
+ readonly returnedCount?: number;
53
+ readonly nextOffset?: number;
54
+ readonly previousOffset?: number;
55
+ readonly omittedBefore?: number;
56
+ readonly omittedAfter?: number;
57
+ readonly hasMore?: boolean;
58
+ /** True means every searchable field was examined, not just a preview prefix. */
59
+ readonly searchComplete?: boolean;
60
+ readonly truncation?: {
61
+ readonly output: boolean;
62
+ readonly omittedMatches: number;
63
+ readonly transcriptPath?: string;
64
+ };
65
+ readonly error?: string;
66
+ }
67
+
68
+ export interface QuerySessionToolManager {
69
+ getRecord(id: string): Subagent | undefined;
70
+ }
71
+
72
+ export type QuerySessionFileReader = (path: string) => string;
73
+
74
+ /** Parent-only bounded transcript drill-down. */
75
+ export class QuerySessionTool {
76
+ constructor(
77
+ private readonly manager: QuerySessionToolManager,
78
+ private readonly readFile: QuerySessionFileReader,
79
+ ) {}
80
+
81
+ async execute(
82
+ _toolCallId: string,
83
+ params: {
84
+ agent_id?: string;
85
+ query?: string;
86
+ kind?: SessionQueryKind;
87
+ order?: SessionQueryOrder;
88
+ limit?: number;
89
+ offset?: number;
90
+ },
91
+ _signal: AbortSignal | undefined,
92
+ _onUpdate: unknown,
93
+ _ctx: unknown,
94
+ ) {
95
+ const validation = validateParams(params);
96
+ if (validation) return textResult(validation);
97
+
98
+ const agentId = params.agent_id!;
99
+ const record = this.manager.getRecord(agentId);
100
+ if (!record) {
101
+ return textResult(`Agent not found: "${boundedOutputLine(agentId, MAX_QUERY_METADATA_LINE_BYTES)}". It may have been cleaned up.`, {
102
+ outcome: "not_found",
103
+ agentId,
104
+ } satisfies QuerySessionDetails);
105
+ }
106
+
107
+ const base = recordDetails(record, agentId);
108
+ let source: "live" | "file";
109
+ let messages;
110
+ try {
111
+ if (record.isSessionReady()) {
112
+ source = "live";
113
+ messages = record.agentMessages;
114
+ } else if (record.outputFile) {
115
+ source = "file";
116
+ messages = readSessionFileMessages(record.outputFile, this.readFile);
117
+ } else {
118
+ return textResult(
119
+ `${boundedOutputLine(summaryLine(base), MAX_QUERY_METADATA_LINE_BYTES)}\nTranscript unavailable: no retained live session or persisted transcript is available.`,
120
+ { ...base, outcome: "transcript_unavailable" } satisfies QuerySessionDetails,
121
+ );
122
+ }
123
+ } catch (error) {
124
+ if (sourceIsMissingFile(record, error)) {
125
+ return textResult(
126
+ `${boundedOutputLine(summaryLine(base), MAX_QUERY_METADATA_LINE_BYTES)}\nTranscript unavailable: ${boundedOutputLine(record.outputFile ?? "", MAX_QUERY_TRANSCRIPT_PATH_BYTES)}.`,
127
+ { ...base, outcome: "transcript_unavailable", source: "file", transcriptPath: record.outputFile } satisfies QuerySessionDetails,
128
+ );
129
+ }
130
+ const message = error instanceof Error ? error.message : String(error);
131
+ return textResult(
132
+ `${boundedOutputLine(summaryLine(base), MAX_QUERY_METADATA_LINE_BYTES)}\nCould not read the subagent transcript: ${boundedOutputLine(message, MAX_QUERY_METADATA_LINE_BYTES)}`,
133
+ { ...base, outcome: "read_error", source: record.isSessionReady() ? "live" : "file", transcriptPath: record.outputFile, error: message } satisfies QuerySessionDetails,
134
+ );
135
+ }
136
+
137
+ const result = querySession(messages, {
138
+ query: params.query,
139
+ kind: params.kind,
140
+ order: params.order,
141
+ limit: params.limit,
142
+ offset: params.offset,
143
+ });
144
+ const summary = boundedOutputLine(summaryLine(base), MAX_QUERY_METADATA_LINE_BYTES);
145
+ const sourceDescription = boundedOutputLine(
146
+ `${source}${record.outputFile ? ` (${record.outputFile})` : ""}`,
147
+ MAX_QUERY_METADATA_LINE_BYTES,
148
+ );
149
+ const prefix = `${summary}\nSource: ${sourceDescription}\nSearch: complete (full transcript fields).\n`;
150
+ // Reserve the largest possible omission/navigation tail before selecting
151
+ // entries. This keeps a later bounds notice from pushing the body over
152
+ // Pi's byte or line ceiling.
153
+ const maximumOmittedCount = result.omittedBefore + result.omittedAfter + result.entries.length;
154
+ const reservedTail = omissionTail(
155
+ maximumOmittedCount,
156
+ maximumOmittedCount,
157
+ maximumOmittedCount,
158
+ result.nextOffset,
159
+ result.previousOffset,
160
+ record.outputFile,
161
+ );
162
+ const output = formatEntries(
163
+ result.entries,
164
+ Math.min(
165
+ MAX_QUERY_ENTRY_BYTES,
166
+ Math.max(0, MAX_QUERY_OUTPUT_BYTES - utf8ByteLength(prefix) - utf8ByteLength(reservedTail) - 1),
167
+ ),
168
+ Math.min(
169
+ MAX_QUERY_ENTRY_LINES,
170
+ Math.max(0, MAX_QUERY_OUTPUT_LINES - lineCount(prefix) - lineCount(reservedTail) - 1),
171
+ ),
172
+ );
173
+ const returnedCount = output.entries.length;
174
+ const omittedBefore = result.omittedBefore;
175
+ const omittedAfter = Math.max(0, result.totalMatches - result.offset - returnedCount);
176
+ // Recompute this from entries that really made it into the output. If the
177
+ // byte/line budget drops entries from a page, the next request starts after
178
+ // only those entries and cannot skip an unseen match.
179
+ const nextOffset = result.outcome === "matches" && returnedCount > 0 && result.offset + returnedCount < result.totalMatches
180
+ ? result.offset + returnedCount
181
+ : undefined;
182
+ const outcome = result.outcome;
183
+ const details: QuerySessionDetails = {
184
+ ...base,
185
+ outcome,
186
+ source,
187
+ transcriptPath: record.outputFile,
188
+ query: params.query ?? "",
189
+ kind: params.kind ?? "all",
190
+ order: params.order ?? "newest",
191
+ limit: params.limit ?? DEFAULT_QUERY_LIMIT,
192
+ offset: result.offset,
193
+ entries: output.entries,
194
+ totalMatches: result.totalMatches,
195
+ returnedCount,
196
+ ...(nextOffset === undefined ? {} : { nextOffset }),
197
+ ...(result.previousOffset === undefined ? {} : { previousOffset: result.previousOffset }),
198
+ omittedBefore,
199
+ omittedAfter,
200
+ hasMore: nextOffset !== undefined,
201
+ searchComplete: true,
202
+ ...(output.omittedCount > 0 ? {
203
+ truncation: {
204
+ output: true,
205
+ omittedMatches: output.omittedCount,
206
+ ...(record.outputFile ? { transcriptPath: record.outputFile } : {}),
207
+ },
208
+ } : {}),
209
+ };
210
+ const body = outcome === "no_matches"
211
+ ? "No transcript entries match the requested query."
212
+ : outcome === "page_out_of_range"
213
+ ? `No transcript page starts at offset ${result.offset}; ${result.totalMatches} matching entr${result.totalMatches === 1 ? "y is" : "ies are"} available.`
214
+ : output.lines.join("\n");
215
+ const tail = omissionTail(
216
+ omittedBefore,
217
+ omittedAfter,
218
+ output.omittedCount,
219
+ nextOffset,
220
+ result.previousOffset,
221
+ record.outputFile,
222
+ );
223
+ return textResult(`${prefix}${body}${tail}`, details);
224
+ }
225
+
226
+ toToolDefinition() {
227
+ return defineTool({
228
+ name: "query_subagent_session" as const,
229
+ label: "Query Subagent Session",
230
+ promptSnippet: "query_subagent_session: Search a bounded child transcript without steering it.",
231
+ description: "Query a subagent's recent or matching messages and tool calls. Search is complete, case-insensitive literal text over full fields, bounded only in returned excerpts/output, and read-only; use get_subagent_result for the final result and list_subagents for fleet triage.",
232
+ parameters: Type.Object({
233
+ agent_id: Type.String({ description: "The subagent ID to inspect." }),
234
+ query: Type.Optional(Type.String({ description: "Case-insensitive literal text. Omit or use an empty string for beginning-of-entry previews." })),
235
+ kind: Type.Optional(Type.Union([
236
+ Type.Literal("all"),
237
+ Type.Literal("messages"),
238
+ Type.Literal("tool_calls"),
239
+ Type.Literal("tool_results"),
240
+ ], { description: "Search scope. Defaults to all." })),
241
+ order: Type.Optional(Type.Union([
242
+ Type.Literal("newest"),
243
+ Type.Literal("oldest"),
244
+ ], { description: "Result order. Defaults to newest." })),
245
+ limit: Type.Optional(Type.Integer({ minimum: MIN_QUERY_LIMIT, maximum: MAX_QUERY_LIMIT, description: "Maximum entries, 1-50; defaults to 20." })),
246
+ offset: Type.Optional(Type.Integer({ minimum: 0, maximum: Number.MAX_SAFE_INTEGER, description: "Non-negative safe integer offset into the ordered matching entries; defaults to 0." })),
247
+ }),
248
+ execute: (toolCallId: string, params: {
249
+ agent_id?: string;
250
+ query?: string;
251
+ kind?: SessionQueryKind;
252
+ order?: SessionQueryOrder;
253
+ limit?: number;
254
+ offset?: number;
255
+ }, signal: AbortSignal | undefined, onUpdate: unknown, ctx: unknown) => this.execute(toolCallId, params, signal, onUpdate, ctx),
256
+ });
257
+ }
258
+ }
259
+
260
+ function validateParams(params: {
261
+ agent_id?: string;
262
+ query?: string;
263
+ kind?: SessionQueryKind;
264
+ order?: SessionQueryOrder;
265
+ limit?: number;
266
+ offset?: number;
267
+ }): string | undefined {
268
+ if (typeof params.agent_id !== "string" || params.agent_id.length === 0) return "agent_id must be a non-empty string";
269
+ if (params.query !== undefined && typeof params.query !== "string") return "query must be a string";
270
+ if (params.kind !== undefined && !VALID_KINDS.has(params.kind)) return "kind must be all, messages, tool_calls, or tool_results";
271
+ if (params.order !== undefined && !VALID_ORDERS.has(params.order)) return "order must be newest or oldest";
272
+ if (params.limit !== undefined && (!Number.isInteger(params.limit) || params.limit < MIN_QUERY_LIMIT || params.limit > MAX_QUERY_LIMIT)) {
273
+ return `limit must be an integer from ${MIN_QUERY_LIMIT} to ${MAX_QUERY_LIMIT}`;
274
+ }
275
+ if (params.offset !== undefined && !Number.isSafeInteger(params.offset)) return "offset must be a non-negative safe integer";
276
+ if (params.offset !== undefined && params.offset < 0) return "offset must be a non-negative safe integer";
277
+ return undefined;
278
+ }
279
+
280
+ function recordDetails(record: Subagent, agentId: string): QuerySessionDetails {
281
+ return {
282
+ outcome: "read_error",
283
+ agentId,
284
+ runId: record.runId,
285
+ mode: record.mode,
286
+ status: record.status,
287
+ terminalReason: record.stateTerminalReason,
288
+ model: record.modelLabel,
289
+ thinkingLevel: record.effectiveThinkingLevel,
290
+ activeRuntimeMs: record.activeRuntimeMs,
291
+ activity: describeActivity(record.activeTools, record.responseText),
292
+ };
293
+ }
294
+
295
+ function summaryLine(details: QuerySessionDetails): string {
296
+ const model = details.model && details.thinkingLevel ? ` · ${formatModelThinking(details.model, details.thinkingLevel)}` : "";
297
+ const status = details.status ? ` · ${details.status}` : "";
298
+ const activity = details.activity ? ` · ${details.activity}` : "";
299
+ return `Agent ${details.agentId} run=${details.runId ?? "?"}${status}${model} · ${formatMs(details.activeRuntimeMs ?? 0)}${activity}`;
300
+ }
301
+
302
+ function sourceIsMissingFile(record: Subagent, error: unknown): boolean {
303
+ return !record.isSessionReady() && (error as { code?: unknown } | undefined)?.code === "ENOENT";
304
+ }
305
+
306
+ function formatEntries(
307
+ entries: readonly SessionQueryEntry[],
308
+ maxBytes = MAX_QUERY_ENTRY_BYTES,
309
+ maxLines = MAX_QUERY_ENTRY_LINES,
310
+ ): {
311
+ entries: SessionQueryEntry[];
312
+ lines: string[];
313
+ omittedCount: number;
314
+ } {
315
+ const selected: SessionQueryEntry[] = [];
316
+ const lines: string[] = [];
317
+ let bytes = 0;
318
+ let lineTotal = 0;
319
+ for (const entry of entries) {
320
+ const line = formatEntry(entry);
321
+ const addition = selected.length === 0 ? line : `\n${line}`;
322
+ const additionBytes = utf8ByteLength(addition);
323
+ const additionLines = lineCount(addition);
324
+ if (bytes + additionBytes > maxBytes || lineTotal + additionLines > maxLines) {
325
+ // A newline-dense first entry can exceed the line budget even though its
326
+ // projection is character-bounded. Compact only this fallback
327
+ // representation so a real match can always make progress; later pages
328
+ // still use the normal output bounds and the returned count remains exact.
329
+ if (selected.length === 0) {
330
+ const compactLine = formatEntry(entry, true);
331
+ const compactAddition = compactLine;
332
+ const compactBytes = utf8ByteLength(compactAddition);
333
+ const compactLines = lineCount(compactAddition);
334
+ if (compactBytes <= maxBytes && compactLines <= maxLines) {
335
+ selected.push(entry);
336
+ lines.push(compactLine);
337
+ bytes = compactBytes;
338
+ lineTotal = compactLines;
339
+ }
340
+ }
341
+ break;
342
+ }
343
+ selected.push(entry);
344
+ lines.push(line);
345
+ bytes += additionBytes;
346
+ lineTotal += additionLines;
347
+ }
348
+ return { entries: selected, lines, omittedCount: entries.length - selected.length };
349
+ }
350
+
351
+ function formatEntry(entry: SessionQueryEntry, compact = false): string {
352
+ const display = (value: string): string => compact ? compactLineBreaks(value) : value;
353
+ const match = entry.match === undefined
354
+ ? ""
355
+ : ` · match=${entry.match.field} [${entry.match.sourceRange.start},${entry.match.sourceRange.end})`;
356
+ if (entry.kind === "message") {
357
+ return `[${entry.role}] ${entry.id}${match}\n${display(entry.text)}`;
358
+ }
359
+ const callIdentity = entry.toolCallId ?? entry.id;
360
+ const result = entry.result === undefined ? "" : `\nresult: ${display(entry.result)}`;
361
+ return `[tool ${display(entry.toolName)} · ${entry.state}] id=${display(callIdentity)}${match}\narguments: ${display(entry.arguments)}${result}`;
362
+ }
363
+
364
+ function compactLineBreaks(value: string): string {
365
+ return value.replace(/\r\n|\r|\n/g, " ");
366
+ }
367
+
368
+ function utf8ByteLength(value: string): number {
369
+ return utf8Encoder.encode(value).byteLength;
370
+ }
371
+
372
+ /** Count lines conservatively, including the empty line after a trailing newline. */
373
+ function lineCount(value: string): number {
374
+ return value.length === 0 ? 0 : value.split(/\r\n|\r|\n/).length;
375
+ }
376
+
377
+ /** Keep metadata single-line and bounded; full paths remain available in details. */
378
+ function boundedOutputLine(value: string, maxBytes: number): string {
379
+ const singleLine = value.replace(/\r\n|\r|\n/g, " ");
380
+ if (utf8ByteLength(singleLine) <= maxBytes) return singleLine;
381
+ const ellipsis = "…";
382
+ const budget = Math.max(0, maxBytes - utf8ByteLength(ellipsis));
383
+ let output = "";
384
+ let bytes = 0;
385
+ for (const character of singleLine) {
386
+ const characterBytes = utf8ByteLength(character);
387
+ if (bytes + characterBytes > budget) break;
388
+ output += character;
389
+ bytes += characterBytes;
390
+ }
391
+ return `${output}${ellipsis}`;
392
+ }
393
+
394
+ function omissionTail(
395
+ omittedBefore: number,
396
+ omittedAfter: number,
397
+ outputOmitted: number,
398
+ nextOffset: number | undefined,
399
+ previousOffset: number | undefined,
400
+ transcriptPath: string | undefined,
401
+ ): string {
402
+ const lines: string[] = [];
403
+ if (omittedBefore > 0) lines.push(`${omittedBefore} matching entr${omittedBefore === 1 ? "y precedes" : "ies precede"} this page.`);
404
+ if (omittedAfter > 0) lines.push(`${omittedAfter} matching entr${omittedAfter === 1 ? "y remains" : "ies remain"} after this page.`);
405
+ if (outputOmitted > 0) lines.push(`${outputOmitted} entries from this page were omitted by the output bounds.`);
406
+ if (nextOffset !== undefined) lines.push(`More matches available; repeat with offset: ${nextOffset}.`);
407
+ if (previousOffset !== undefined) lines.push(`Previous page available; repeat with offset: ${previousOffset}.`);
408
+ if (transcriptPath && outputOmitted > 0) {
409
+ lines.push(`Full transcript: ${boundedOutputLine(transcriptPath, MAX_QUERY_TRANSCRIPT_PATH_BYTES)}`);
410
+ }
411
+ return lines.length === 0 ? "" : `\n\n${lines.join("\n")}`;
412
+ }