@oh-my-pi/pi-coding-agent 3.24.0 → 3.25.0

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.
@@ -36,6 +36,11 @@ const outputSchema = Type.Object({
36
36
  description: "Output format: raw (default), json (structured), stripped (no ANSI)",
37
37
  }),
38
38
  ),
39
+ query: Type.Optional(
40
+ Type.String({
41
+ description: "jq-like query for JSON outputs (e.g., .result.items[0].name). Requires JSON output.",
42
+ }),
43
+ ),
39
44
  offset: Type.Optional(
40
45
  Type.Number({
41
46
  description: "Line number to start reading from (1-indexed)",
@@ -70,6 +75,7 @@ interface OutputEntry {
70
75
  provenance?: OutputProvenance;
71
76
  previewLines?: string[];
72
77
  range?: OutputRange;
78
+ query?: string;
73
79
  }
74
80
 
75
81
  export interface OutputToolDetails {
@@ -83,6 +89,77 @@ function stripAnsi(text: string): string {
83
89
  return text.replace(/\x1b\[[0-9;]*m/g, "");
84
90
  }
85
91
 
92
+ function parseQuery(query: string): Array<string | number> {
93
+ let input = query.trim();
94
+ if (!input) return [];
95
+ if (input.startsWith(".")) input = input.slice(1);
96
+ if (!input) return [];
97
+
98
+ const tokens: Array<string | number> = [];
99
+ let i = 0;
100
+
101
+ const isIdentChar = (ch: string) => /[A-Za-z0-9_-]/.test(ch);
102
+
103
+ while (i < input.length) {
104
+ const ch = input[i];
105
+ if (ch === ".") {
106
+ i++;
107
+ continue;
108
+ }
109
+ if (ch === "[") {
110
+ const closeIndex = input.indexOf("]", i + 1);
111
+ if (closeIndex === -1) {
112
+ throw new Error(`Invalid query: missing ] in ${query}`);
113
+ }
114
+ const raw = input.slice(i + 1, closeIndex).trim();
115
+ if (!raw) {
116
+ throw new Error(`Invalid query: empty [] in ${query}`);
117
+ }
118
+ const quote = raw[0];
119
+ if ((quote === '"' || quote === "'") && raw.endsWith(quote)) {
120
+ let inner = raw.slice(1, -1);
121
+ inner = inner.replace(/\\(["'\\])/g, "$1");
122
+ tokens.push(inner);
123
+ } else if (/^\d+$/.test(raw)) {
124
+ tokens.push(Number(raw));
125
+ } else {
126
+ tokens.push(raw);
127
+ }
128
+ i = closeIndex + 1;
129
+ continue;
130
+ }
131
+
132
+ const start = i;
133
+ while (i < input.length && isIdentChar(input[i])) {
134
+ i++;
135
+ }
136
+ if (start === i) {
137
+ throw new Error(`Invalid query: unexpected token '${input[i]}' in ${query}`);
138
+ }
139
+ const ident = input.slice(start, i);
140
+ tokens.push(ident);
141
+ }
142
+
143
+ return tokens;
144
+ }
145
+
146
+ function applyQuery(data: unknown, query: string): unknown {
147
+ const tokens = parseQuery(query);
148
+ let current: unknown = data;
149
+ for (const token of tokens) {
150
+ if (current === null || current === undefined) return undefined;
151
+ if (typeof token === "number") {
152
+ if (!Array.isArray(current)) return undefined;
153
+ current = current[token];
154
+ continue;
155
+ }
156
+ if (typeof current !== "object") return undefined;
157
+ const record = current as Record<string, unknown>;
158
+ current = record[token];
159
+ }
160
+ return current;
161
+ }
162
+
86
163
  /** List available output IDs in artifacts directory */
87
164
  function listAvailableOutputs(artifactsDir: string): string[] {
88
165
  try {
@@ -128,7 +205,13 @@ export function createOutputTool(session: ToolSession): AgentTool<typeof outputS
128
205
  parameters: outputSchema,
129
206
  execute: async (
130
207
  _toolCallId: string,
131
- params: { ids: string[]; format?: "raw" | "json" | "stripped"; offset?: number; limit?: number },
208
+ params: {
209
+ ids: string[];
210
+ format?: "raw" | "json" | "stripped";
211
+ query?: string;
212
+ offset?: number;
213
+ limit?: number;
214
+ },
132
215
  ): Promise<{ content: TextContent[]; details: OutputToolDetails }> => {
133
216
  const sessionFile = session.getSessionFile();
134
217
 
@@ -150,7 +233,15 @@ export function createOutputTool(session: ToolSession): AgentTool<typeof outputS
150
233
  const outputs: OutputEntry[] = [];
151
234
  const notFound: string[] = [];
152
235
  const outputContentById = new Map<string, string>();
153
- const format = params.format ?? "raw";
236
+ const query = params.query?.trim();
237
+ const wantsQuery = query !== undefined && query.length > 0;
238
+ const format = params.format ?? (wantsQuery ? "json" : "raw");
239
+
240
+ if (wantsQuery && (params.offset !== undefined || params.limit !== undefined)) {
241
+ throw new Error("query cannot be combined with offset/limit");
242
+ }
243
+
244
+ const queryResults: Array<{ id: string; value: unknown }> = [];
154
245
 
155
246
  for (const id of params.ids) {
156
247
  const outputPath = path.join(artifactsDir, `${id}.out.md`);
@@ -168,7 +259,22 @@ export function createOutputTool(session: ToolSession): AgentTool<typeof outputS
168
259
  let selectedContent = rawContent;
169
260
  let range: OutputRange | undefined;
170
261
 
171
- if (params.offset !== undefined || params.limit !== undefined) {
262
+ if (wantsQuery && query) {
263
+ let jsonValue: unknown;
264
+ try {
265
+ jsonValue = JSON.parse(rawContent);
266
+ } catch (err) {
267
+ const message = err instanceof Error ? err.message : String(err);
268
+ throw new Error(`Output ${id} is not valid JSON: ${message}`);
269
+ }
270
+ const value = applyQuery(jsonValue, query);
271
+ queryResults.push({ id, value });
272
+ try {
273
+ selectedContent = JSON.stringify(value, null, 2) ?? "null";
274
+ } catch {
275
+ selectedContent = String(value);
276
+ }
277
+ } else if (params.offset !== undefined || params.limit !== undefined) {
172
278
  const startLine = Math.max(1, params.offset ?? 1);
173
279
  if (startLine > totalLines) {
174
280
  throw new Error(
@@ -186,11 +292,12 @@ export function createOutputTool(session: ToolSession): AgentTool<typeof outputS
186
292
  outputs.push({
187
293
  id,
188
294
  path: outputPath,
189
- lineCount: totalLines,
190
- charCount: totalChars,
295
+ lineCount: wantsQuery ? selectedContent.split("\n").length : totalLines,
296
+ charCount: wantsQuery ? selectedContent.length : totalChars,
191
297
  provenance: parseOutputProvenance(id),
192
298
  previewLines: extractPreviewLines(selectedContent, 4),
193
299
  range,
300
+ query: query,
194
301
  });
195
302
  }
196
303
 
@@ -212,15 +319,17 @@ export function createOutputTool(session: ToolSession): AgentTool<typeof outputS
212
319
  let contentText: string;
213
320
 
214
321
  if (format === "json") {
215
- const jsonData = outputs.map((o) => ({
216
- id: o.id,
217
- lineCount: o.lineCount,
218
- charCount: o.charCount,
219
- provenance: o.provenance,
220
- previewLines: o.previewLines,
221
- range: o.range,
222
- content: outputContentById.get(o.id) ?? "",
223
- }));
322
+ const jsonData = wantsQuery
323
+ ? queryResults
324
+ : outputs.map((o) => ({
325
+ id: o.id,
326
+ lineCount: o.lineCount,
327
+ charCount: o.charCount,
328
+ provenance: o.provenance,
329
+ previewLines: o.previewLines,
330
+ range: o.range,
331
+ content: outputContentById.get(o.id) ?? "",
332
+ }));
224
333
  contentText = JSON.stringify(jsonData, null, 2);
225
334
  } else {
226
335
  // raw or stripped
@@ -257,6 +366,7 @@ export function createOutputTool(session: ToolSession): AgentTool<typeof outputS
257
366
  interface OutputRenderArgs {
258
367
  ids: string[];
259
368
  format?: "raw" | "json" | "stripped";
369
+ query?: string;
260
370
  offset?: number;
261
371
  limit?: number;
262
372
  }
@@ -285,6 +395,7 @@ export const outputToolRenderer = {
285
395
 
286
396
  const meta: string[] = [];
287
397
  if (args.format && args.format !== "raw") meta.push(`format:${args.format}`);
398
+ if (args.query) meta.push(`query:${args.query}`);
288
399
  if (args.offset !== undefined) meta.push(`offset:${args.offset}`);
289
400
  if (args.limit !== undefined) meta.push(`limit:${args.limit}`);
290
401
  text += formatMeta(meta, uiTheme);
@@ -38,14 +38,12 @@ export function ensureArtifactsDir(dir: string): void {
38
38
  */
39
39
  export function getArtifactPaths(
40
40
  dir: string,
41
- agentName: string,
42
- index: number,
41
+ taskId: string,
43
42
  ): { inputPath: string; outputPath: string; jsonlPath: string } {
44
- const base = `${agentName}_${index}`;
45
43
  return {
46
- inputPath: path.join(dir, `${base}.in.md`),
47
- outputPath: path.join(dir, `${base}.out.md`),
48
- jsonlPath: path.join(dir, `${base}.jsonl`),
44
+ inputPath: path.join(dir, `${taskId}.in.md`),
45
+ outputPath: path.join(dir, `${taskId}.out.md`),
46
+ jsonlPath: path.join(dir, `${taskId}.jsonl`),
49
47
  };
50
48
  }
51
49
 
@@ -54,15 +52,14 @@ export function getArtifactPaths(
54
52
  */
55
53
  export async function writeArtifacts(
56
54
  dir: string,
57
- agentName: string,
58
- index: number,
55
+ taskId: string,
59
56
  input: string,
60
57
  output: string,
61
58
  jsonlEvents?: string[],
62
59
  ): Promise<{ inputPath: string; outputPath: string; jsonlPath?: string }> {
63
60
  ensureArtifactsDir(dir);
64
61
 
65
- const paths = getArtifactPaths(dir, agentName, index);
62
+ const paths = getArtifactPaths(dir, taskId);
66
63
 
67
64
  // Write input
68
65
  await fs.promises.writeFile(paths.inputPath, input, "utf-8");
@@ -28,8 +28,10 @@ export interface ExecutorOptions {
28
28
  task: string;
29
29
  description?: string;
30
30
  index: number;
31
+ taskId: string;
31
32
  context?: string;
32
33
  modelOverride?: string;
34
+ outputSchema?: unknown;
33
35
  signal?: AbortSignal;
34
36
  onProgress?: (progress: AgentProgress) => void;
35
37
  sessionFile?: string | null;
@@ -130,12 +132,13 @@ function getUsageTokens(usage: unknown): number {
130
132
  * Run a single agent in a worker.
131
133
  */
132
134
  export async function runSubprocess(options: ExecutorOptions): Promise<SingleResult> {
133
- const { cwd, agent, task, index, context, modelOverride, signal, onProgress } = options;
135
+ const { cwd, agent, task, index, taskId, context, modelOverride, outputSchema, signal, onProgress } = options;
134
136
  const startTime = Date.now();
135
137
 
136
138
  // Initialize progress
137
139
  const progress: AgentProgress = {
138
140
  index,
141
+ taskId,
139
142
  agent: agent.name,
140
143
  agentSource: agent.source,
141
144
  status: "running",
@@ -153,6 +156,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
153
156
  if (signal?.aborted) {
154
157
  return {
155
158
  index,
159
+ taskId,
156
160
  agent: agent.name,
157
161
  agentSource: agent.source,
158
162
  task,
@@ -177,7 +181,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
177
181
 
178
182
  if (options.artifactsDir) {
179
183
  ensureArtifactsDir(options.artifactsDir);
180
- artifactPaths = getArtifactPaths(options.artifactsDir, agent.name, index);
184
+ artifactPaths = getArtifactPaths(options.artifactsDir, taskId);
181
185
  subtaskSessionFile = artifactPaths.jsonlPath;
182
186
 
183
187
  // Write input file immediately (real-time visibility)
@@ -451,6 +455,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
451
455
  systemPrompt: agent.systemPrompt,
452
456
  model: resolvedModel,
453
457
  toolNames,
458
+ outputSchema,
454
459
  sessionFile,
455
460
  spawnsEnv,
456
461
  },
@@ -497,13 +502,46 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
497
502
  }
498
503
  worker.terminate();
499
504
 
500
- const exitCode = done.exitCode;
505
+ let exitCode = done.exitCode;
501
506
  if (done.error) {
502
507
  stderr = done.error;
503
508
  }
504
509
 
505
510
  // Use final output if available, otherwise accumulated output
506
- const rawOutput = finalOutput || output;
511
+ let rawOutput = finalOutput || output;
512
+ let abortedViaComplete = false;
513
+ const completeItems = progress.extractedToolData?.complete as
514
+ | Array<{ data?: unknown; status?: "success" | "aborted"; error?: string }>
515
+ | undefined;
516
+ const hasComplete = Array.isArray(completeItems) && completeItems.length > 0;
517
+ if (hasComplete) {
518
+ const lastComplete = completeItems[completeItems.length - 1];
519
+ if (lastComplete?.status === "aborted") {
520
+ // Agent explicitly aborted via complete tool - clean exit with error info
521
+ abortedViaComplete = true;
522
+ exitCode = 0;
523
+ stderr = lastComplete.error || "Subagent aborted task";
524
+ try {
525
+ rawOutput = JSON.stringify({ aborted: true, error: lastComplete.error }, null, 2);
526
+ } catch {
527
+ rawOutput = `{"aborted":true,"error":"${lastComplete.error || "Unknown error"}"}`;
528
+ }
529
+ } else {
530
+ // Normal successful completion
531
+ const completeData = lastComplete?.data ?? null;
532
+ try {
533
+ rawOutput = JSON.stringify(completeData, null, 2) ?? "null";
534
+ } catch (err) {
535
+ const errorMessage = err instanceof Error ? err.message : String(err);
536
+ rawOutput = `{"error":"Failed to serialize complete data: ${errorMessage}"}`;
537
+ }
538
+ exitCode = 0;
539
+ stderr = "";
540
+ }
541
+ } else {
542
+ const warning = "SYSTEM WARNING: Subagent exited without calling complete tool after 3 reminders.";
543
+ rawOutput = rawOutput ? `${warning}\n\n${rawOutput}` : warning;
544
+ }
507
545
  const { text: truncatedOutput, truncated } = truncateOutput(rawOutput);
508
546
 
509
547
  // Write output artifact (input and jsonl already written in real-time)
@@ -522,12 +560,13 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
522
560
  }
523
561
 
524
562
  // Update final progress
525
- const wasAborted = done.aborted || signal?.aborted || false;
563
+ const wasAborted = abortedViaComplete || (!hasComplete && (done.aborted || signal?.aborted || false));
526
564
  progress.status = wasAborted ? "aborted" : exitCode === 0 ? "completed" : "failed";
527
565
  emitProgress();
528
566
 
529
567
  return {
530
568
  index,
569
+ taskId,
531
570
  agent: agent.name,
532
571
  agentSource: agent.source,
533
572
  task,
@@ -21,6 +21,7 @@ import { formatDuration } from "../render-utils";
21
21
  import { cleanupTempDir, createTempArtifactsDir, getArtifactsDir } from "./artifacts";
22
22
  import { discoverAgents, getAgent } from "./discovery";
23
23
  import { runSubprocess } from "./executor";
24
+ import { generateTaskName } from "./name-generator";
24
25
  import { mapWithConcurrencyLimit } from "./parallel";
25
26
  import { renderCall, renderResult } from "./render";
26
27
  import {
@@ -135,6 +136,7 @@ export async function createTaskTool(
135
136
  const startTime = Date.now();
136
137
  const { agents, projectAgentsDir } = await discoverAgents(session.cwd);
137
138
  const context = params.context;
139
+ const outputSchema = params.output_schema;
138
140
 
139
141
  // Handle empty or missing tasks
140
142
  if (!params.tasks || params.tasks.length === 0) {
@@ -259,34 +261,37 @@ export async function createTaskTool(
259
261
  }
260
262
  }
261
263
 
264
+ // Build full prompts with context prepended and generate task IDs
265
+ const tasksWithContext = tasks.map((t) => ({
266
+ agent: t.agent,
267
+ task: context ? `${context}\n\n${t.task}` : t.task,
268
+ model: t.model,
269
+ description: t.description,
270
+ taskId: generateTaskName(),
271
+ }));
272
+
262
273
  // Initialize progress for all tasks
263
- for (let i = 0; i < tasks.length; i++) {
264
- const agentCfg = getAgent(agents, tasks[i].agent);
274
+ for (let i = 0; i < tasksWithContext.length; i++) {
275
+ const t = tasksWithContext[i];
276
+ const agentCfg = getAgent(agents, t.agent);
265
277
  progressMap.set(i, {
266
278
  index: i,
267
- agent: tasks[i].agent,
279
+ taskId: t.taskId,
280
+ agent: t.agent,
268
281
  agentSource: agentCfg?.source ?? "user",
269
282
  status: "pending",
270
- task: tasks[i].task,
283
+ task: t.task,
271
284
  recentTools: [],
272
285
  recentOutput: [],
273
286
  toolCount: 0,
274
287
  tokens: 0,
275
288
  durationMs: 0,
276
- modelOverride: tasks[i].model,
277
- description: tasks[i].description,
289
+ modelOverride: t.model,
290
+ description: t.description,
278
291
  });
279
292
  }
280
293
  emitProgress();
281
294
 
282
- // Build full prompts with context prepended
283
- const tasksWithContext = tasks.map((t) => ({
284
- agent: t.agent,
285
- task: context ? `${context}\n\n${t.task}` : t.task,
286
- model: t.model,
287
- description: t.description,
288
- }));
289
-
290
295
  // Execute in parallel with concurrency limit
291
296
  const results = await mapWithConcurrencyLimit(tasksWithContext, MAX_CONCURRENCY, async (task, index) => {
292
297
  const agent = getAgent(agents, task.agent)!;
@@ -296,8 +301,10 @@ export async function createTaskTool(
296
301
  task: task.task,
297
302
  description: task.description,
298
303
  index,
304
+ taskId: task.taskId,
299
305
  context: undefined, // Already prepended above
300
306
  modelOverride: task.model,
307
+ outputSchema,
301
308
  sessionFile,
302
309
  persistArtifacts: !!artifactsDir,
303
310
  artifactsDir: effectiveArtifactsDir,
@@ -336,19 +343,17 @@ export async function createTaskTool(
336
343
  const status = r.exitCode === 0 ? "completed" : `failed (exit ${r.exitCode})`;
337
344
  const output = r.output.trim() || r.stderr.trim() || "(no output)";
338
345
  const preview = output.split("\n").slice(0, 5).join("\n");
339
- // Include output metadata and ID
340
- const outputId = `${r.agent}_${r.index}`;
341
346
  const meta = r.outputMeta
342
347
  ? ` [${r.outputMeta.lineCount} lines, ${formatBytes(r.outputMeta.charCount)}]`
343
348
  : "";
344
- return `[${r.agent}] ${status}${meta} ${outputId}\n${preview}`;
349
+ return `[${r.agent}] ${status}${meta} ${r.taskId}\n${preview}`;
345
350
  });
346
351
 
347
352
  const skippedNote =
348
353
  skippedSelfRecursion > 0
349
354
  ? ` (${skippedSelfRecursion} ${blockedAgent} task${skippedSelfRecursion > 1 ? "s" : ""} skipped - self-recursion blocked)`
350
355
  : "";
351
- const outputIds = results.map((r) => `${r.agent}_${r.index}`);
356
+ const outputIds = results.map((r) => r.taskId);
352
357
  const outputHint =
353
358
  outputIds.length > 0 ? `\n\nUse output tool for full logs: output ids ${outputIds.join(", ")}` : "";
354
359
  const summary = `${successCount}/${results.length} succeeded${skippedNote} [${formatDuration(