@yagni-app/code-staging 1.0.0-staging.1175.1 → 1.0.0-staging.1177.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -27,6 +27,7 @@ import { login } from "./login.js";
27
27
  import { logout } from "./logout.js";
28
28
  import { tokenCommand } from "./token.js";
29
29
  import { buildLaunch } from "./launch.js";
30
+ import { parseOutputFormat, parseJsonEvents, buildResultObject, readGuardianEvents, } from "./outputFormat.js";
30
31
  import { runDoctor } from "./doctor.js";
31
32
  import { installProcessCrashHandlers } from "./crashReport.js";
32
33
  import { currentCliVersion, maybeNudgeAndRefresh, upgradeCommand } from "./upgrade.js";
@@ -159,6 +160,10 @@ export function seedHideThinkingBlock(piAgentDir) {
159
160
  return seedSetting(piAgentDir, "hideThinkingBlock", true);
160
161
  }
161
162
  async function runDefault(passthroughArgs) {
163
+ // Parse --output-format out of argv before passing to pi (pi doesn't know
164
+ // about it). The format determines how we handle pi's stdout: text = inherit,
165
+ // stream-json = inherit with --mode json, json = pipe + post-process.
166
+ const { format: outputFormat, remainingArgs } = parseOutputFormat(passthroughArgs);
162
167
  // Cache-backed update nudge (never a network wait), then a background cache
163
168
  // refresh that completes while the session runs. Both fail soft.
164
169
  await maybeNudgeAndRefresh({ current: cliVersion() });
@@ -237,7 +242,7 @@ async function runDefault(passthroughArgs) {
237
242
  // re-login in another terminal is picked up here with no stale cached token.
238
243
  let plan;
239
244
  try {
240
- plan = buildLaunch(creds, passthroughArgs, {
245
+ plan = buildLaunch(creds, remainingArgs, {
241
246
  extensionPath: resolveExtensionPath(),
242
247
  agentDir: piAgentDir,
243
248
  piPackageDir: shadowPiDir,
@@ -260,18 +265,57 @@ async function runDefault(passthroughArgs) {
260
265
  process.stderr.write(`${warning}\n`);
261
266
  }
262
267
  const { env, argv } = plan;
268
+ // For json/stream-json output, inject --mode json so pi emits NDJSON events.
269
+ // Don't override if the user already chose --mode (mirrors userChoseProvider/
270
+ // userChoseModel in buildLaunch). Appended at the end — pi's flag parser
271
+ // handles --mode anywhere in argv.
272
+ const userChoseMode = remainingArgs.some((a) => a === "--mode" || a.startsWith("--mode="));
273
+ const childArgv = outputFormat === "text" || userChoseMode
274
+ ? argv
275
+ : [...argv, "--mode", "json"];
276
+ // text + stream-json: inherit stdout (passthrough). json: pipe stdout so we
277
+ // can post-process the NDJSON into a single result object.
278
+ const stdio = outputFormat === "json"
279
+ ? ["inherit", "pipe", "inherit"]
280
+ : "inherit";
263
281
  const piCli = resolvePiCliPath();
282
+ const startMs = Date.now();
264
283
  return await new Promise((resolve) => {
265
- const child = spawn(process.execPath, [piCli, ...argv], {
266
- stdio: "inherit",
284
+ const child = spawn(process.execPath, [piCli, ...childArgv], {
285
+ stdio: stdio,
267
286
  env,
268
287
  });
288
+ // Collect pi's stdout when piping for --output-format json.
289
+ let stdoutChunks = "";
290
+ if (outputFormat === "json" && child.stdout) {
291
+ child.stdout.setEncoding("utf8");
292
+ child.stdout.on("data", (chunk) => {
293
+ stdoutChunks += chunk;
294
+ });
295
+ }
269
296
  // Forward termination signals to pi instead of dying around it (see
270
297
  // signalForward.ts for the policy: first signal graceful, second tree-kill).
271
298
  installSignalForwarding(child);
272
299
  // 128+n for a signal death (bash parity), so a cancelled/killed run never
273
300
  // reads as success to scripts or CI.
274
- child.on("exit", (code, signal) => resolve(exitCodeFor(code, signal)));
301
+ child.on("exit", (code, signal) => {
302
+ const exitCode = exitCodeFor(code, signal);
303
+ if (outputFormat === "json") {
304
+ const durationMs = Date.now() - startMs;
305
+ const events = parseJsonEvents(stdoutChunks);
306
+ const yagniSessionId = env.YAGNI_SESSION_ID ?? "";
307
+ const guardianEvents = readGuardianEvents(yagniSessionId);
308
+ const verbose = remainingArgs.includes("--verbose");
309
+ const result = buildResultObject({
310
+ events,
311
+ guardianEvents,
312
+ durationMs,
313
+ verbose,
314
+ });
315
+ process.stdout.write(`${JSON.stringify(result)}\n`);
316
+ }
317
+ resolve(exitCode);
318
+ });
275
319
  child.on("error", (err) => {
276
320
  process.stderr.write(`Failed to start YAGNI Code: ${err.message}\n`);
277
321
  resolve(1);
@@ -286,6 +330,8 @@ export const HELP_TEXT = [
286
330
  " yagni -c Continue the most recent session.",
287
331
  " yagni -r Browse and resume a previous session.",
288
332
  ' yagni -p "prompt" Print one response and exit (reads piped stdin too).',
333
+ ' yagni -p "prompt" Use --output-format json for a machine-readable',
334
+ ' --output-format json result object with tools, cost, guardian reviews.',
289
335
  " yagni login Authorize the active environment (device-code flow).",
290
336
  " yagni logout Revoke and clear the active environment's token.",
291
337
  " yagni doctor Check that everything is ready (green/red checklist).",
@@ -307,6 +353,9 @@ export const HELP_TEXT = [
307
353
  " --model <tier> Model tier (fixed to advanced).",
308
354
  " --thinking <level> off | minimal | low | medium | high | xhigh | max",
309
355
  " --session <id> Open a specific session; --fork <id> branches one.",
356
+ " --output-format <fmt> Output format: text (default), json, stream-json.",
357
+ " json emits a single result object; stream-json",
358
+ " emits NDJSON events (same as --mode json).",
310
359
  " --mode json Emit machine-readable events (for scripts and CI).",
311
360
  "",
312
361
  "In a session:",
@@ -0,0 +1,83 @@
1
+ /**
2
+ * `--output-format` support for headless/print mode (YAG-593).
3
+ *
4
+ * The launcher parses `--output-format` out of argv (pi doesn't know about it),
5
+ * maps it to pi's `--mode json` internally, and post-processes pi's NDJSON
6
+ * event stream into a single JSON result object — mirroring Claude Code's
7
+ * `--output-format json` shape.
8
+ */
9
+ export type OutputFormat = "text" | "json" | "stream-json";
10
+ /**
11
+ * Strip `--output-format <value>` (or `--output-format=<value>`) from argv.
12
+ * Returns the format and the remaining args (with the flag removed).
13
+ * Unknown values fall back to "text" with a stderr warning.
14
+ */
15
+ export declare function parseOutputFormat(argv: string[], writeErr?: (line: string) => void): {
16
+ format: OutputFormat;
17
+ remainingArgs: string[];
18
+ };
19
+ export interface ToolUsed {
20
+ name: string;
21
+ is_error: boolean;
22
+ }
23
+ export interface GuardianReview {
24
+ outcome: string;
25
+ duration_ms?: number;
26
+ tier?: string;
27
+ /** True when the command was blocked (ask in headless or deny). */
28
+ blocked?: boolean;
29
+ }
30
+ export interface ResultObject {
31
+ type: "result";
32
+ subtype: "success" | "error_during_execution";
33
+ result: string;
34
+ is_error: boolean;
35
+ duration_ms: number;
36
+ num_turns: number;
37
+ session_id: string;
38
+ total_cost_usd: number;
39
+ usage: {
40
+ input_tokens: number;
41
+ output_tokens: number;
42
+ cache_read_tokens: number;
43
+ total_tokens: number;
44
+ };
45
+ tools_used: ToolUsed[];
46
+ guardian_reviews: GuardianReview[];
47
+ stop_reason: string | null;
48
+ }
49
+ interface ParsedEvent {
50
+ type: string;
51
+ [key: string]: unknown;
52
+ }
53
+ /**
54
+ * Parse raw NDJSON text (pi's --mode json stdout) into an array of event objects.
55
+ * Skips blank lines. Fail-soft: unparseable lines are skipped.
56
+ */
57
+ export declare function parseJsonEvents(raw: string): ParsedEvent[];
58
+ /**
59
+ * Build the single JSON result object from collected pi events and Guardian
60
+ * events. Pure — no I/O.
61
+ *
62
+ * @param events NDJSON event objects from pi's --mode json stdout
63
+ * @param guardian Guardian review entries from the error sink
64
+ * @param durationMs Wall-clock time measured by the launcher
65
+ * @param verbose When true, returns the raw events array instead of a single object
66
+ */
67
+ export declare function buildResultObject(opts: {
68
+ events: ParsedEvent[];
69
+ guardianEvents?: GuardianReview[];
70
+ durationMs: number;
71
+ verbose?: boolean;
72
+ }): ResultObject | ParsedEvent[];
73
+ /**
74
+ * Read Guardian review events from the error sink JSONL, filtered by
75
+ * YAGNI_SESSION_ID. The error sink is written by the extension during the
76
+ * session; this reads it post-run. Fail-soft: returns [] on any error.
77
+ */
78
+ export declare function readGuardianEvents(yagniSessionId: string, opts?: {
79
+ homeDir?: string;
80
+ now?: Date;
81
+ }): GuardianReview[];
82
+ export {};
83
+ //# sourceMappingURL=outputFormat.d.ts.map
@@ -0,0 +1,207 @@
1
+ /**
2
+ * `--output-format` support for headless/print mode (YAG-593).
3
+ *
4
+ * The launcher parses `--output-format` out of argv (pi doesn't know about it),
5
+ * maps it to pi's `--mode json` internally, and post-processes pi's NDJSON
6
+ * event stream into a single JSON result object — mirroring Claude Code's
7
+ * `--output-format json` shape.
8
+ */
9
+ import { readFileSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { homedir } from "node:os";
12
+ import { DISTRIBUTION } from "./distribution.js";
13
+ /**
14
+ * Strip `--output-format <value>` (or `--output-format=<value>`) from argv.
15
+ * Returns the format and the remaining args (with the flag removed).
16
+ * Unknown values fall back to "text" with a stderr warning.
17
+ */
18
+ export function parseOutputFormat(argv, writeErr = (l) => void process.stderr.write(`${l}\n`)) {
19
+ const remaining = [];
20
+ let format = "text";
21
+ for (let i = 0; i < argv.length; i++) {
22
+ const arg = argv[i];
23
+ if (arg === "--output-format" && i + 1 < argv.length) {
24
+ const value = argv[++i];
25
+ if (value === "text" || value === "json" || value === "stream-json") {
26
+ format = value;
27
+ }
28
+ else {
29
+ writeErr(`Unknown --output-format value "${value}", falling back to text.`);
30
+ }
31
+ continue;
32
+ }
33
+ if (arg.startsWith("--output-format=")) {
34
+ const value = arg.slice("--output-format=".length);
35
+ if (value === "text" || value === "json" || value === "stream-json") {
36
+ format = value;
37
+ }
38
+ else {
39
+ writeErr(`Unknown --output-format value "${value}", falling back to text.`);
40
+ }
41
+ continue;
42
+ }
43
+ remaining.push(arg);
44
+ }
45
+ return { format, remainingArgs: remaining };
46
+ }
47
+ /**
48
+ * Parse raw NDJSON text (pi's --mode json stdout) into an array of event objects.
49
+ * Skips blank lines. Fail-soft: unparseable lines are skipped.
50
+ */
51
+ export function parseJsonEvents(raw) {
52
+ const events = [];
53
+ for (const line of raw.split("\n")) {
54
+ const trimmed = line.trim();
55
+ if (!trimmed)
56
+ continue;
57
+ try {
58
+ events.push(JSON.parse(trimmed));
59
+ }
60
+ catch {
61
+ // skip unparseable lines
62
+ }
63
+ }
64
+ return events;
65
+ }
66
+ /**
67
+ * Build the single JSON result object from collected pi events and Guardian
68
+ * events. Pure — no I/O.
69
+ *
70
+ * @param events NDJSON event objects from pi's --mode json stdout
71
+ * @param guardian Guardian review entries from the error sink
72
+ * @param durationMs Wall-clock time measured by the launcher
73
+ * @param verbose When true, returns the raw events array instead of a single object
74
+ */
75
+ export function buildResultObject(opts) {
76
+ const { events, durationMs } = opts;
77
+ const guardianReviews = opts.guardianEvents ?? [];
78
+ if (opts.verbose) {
79
+ return events;
80
+ }
81
+ // Session ID from the "session" header event
82
+ const sessionEvent = events.find((e) => e.type === "session");
83
+ const sessionId = typeof sessionEvent?.id === "string" ? sessionEvent.id : "";
84
+ // Count turns
85
+ const numTurns = events.filter((e) => e.type === "turn_start").length;
86
+ // Find the final assistant message from agent_end
87
+ const agentEnd = events.find((e) => e.type === "agent_end");
88
+ const messages = agentEnd?.messages ?? [];
89
+ const lastAssistant = [...messages].reverse().find((m) => m.role === "assistant");
90
+ let resultText = "";
91
+ let stopReason = null;
92
+ if (lastAssistant) {
93
+ const textContent = lastAssistant.content?.find((c) => c.type === "text");
94
+ if (textContent?.text) {
95
+ resultText = textContent.text;
96
+ }
97
+ // stopReason is on the assistant message — check message_end events as fallback
98
+ }
99
+ // Get stopReason from the last assistant message_end event
100
+ const assistantMessageEnds = events.filter((e) => e.type === "message_end" && typeof e.message === "object");
101
+ const lastAssistantEnd = [...assistantMessageEnds]
102
+ .reverse()
103
+ .find((e) => e.message?.role === "assistant");
104
+ if (lastAssistantEnd?.message?.stopReason) {
105
+ stopReason = lastAssistantEnd.message.stopReason;
106
+ }
107
+ // Also try to get result text from message_end if agent_end was missing
108
+ if (!resultText && lastAssistantEnd?.message?.content) {
109
+ const textContent = lastAssistantEnd.message.content.find((c) => c.type === "text");
110
+ if (textContent?.text) {
111
+ resultText = textContent.text;
112
+ }
113
+ }
114
+ // Aggregate usage and cost across all assistant message_end events
115
+ let inputTokens = 0;
116
+ let outputTokens = 0;
117
+ let cacheReadTokens = 0;
118
+ let totalTokens = 0;
119
+ let totalCostUsd = 0;
120
+ for (const e of assistantMessageEnds) {
121
+ const usage = e.message?.usage;
122
+ if (usage) {
123
+ inputTokens += usage.input ?? 0;
124
+ outputTokens += usage.output ?? 0;
125
+ cacheReadTokens += usage.cacheRead ?? 0;
126
+ totalTokens += usage.totalTokens ?? 0;
127
+ totalCostUsd += usage.cost?.total ?? 0;
128
+ }
129
+ }
130
+ // Extract tools used from tool_execution_end events
131
+ const toolsUsed = events
132
+ .filter((e) => e.type === "tool_execution_end")
133
+ .map((e) => ({
134
+ name: typeof e.toolName === "string" ? e.toolName : "unknown",
135
+ is_error: e.isError === true,
136
+ }));
137
+ // Determine subtype
138
+ const isError = stopReason === "error" ||
139
+ stopReason === "aborted" ||
140
+ (!agentEnd && events.length === 0);
141
+ const subtype = isError
142
+ ? "error_during_execution"
143
+ : "success";
144
+ return {
145
+ type: "result",
146
+ subtype,
147
+ result: resultText,
148
+ is_error: isError,
149
+ duration_ms: durationMs,
150
+ num_turns: numTurns,
151
+ session_id: sessionId,
152
+ total_cost_usd: Math.round(totalCostUsd * 1e6) / 1e6,
153
+ usage: {
154
+ input_tokens: inputTokens,
155
+ output_tokens: outputTokens,
156
+ cache_read_tokens: cacheReadTokens,
157
+ total_tokens: totalTokens,
158
+ },
159
+ tools_used: toolsUsed,
160
+ guardian_reviews: guardianReviews,
161
+ stop_reason: stopReason,
162
+ };
163
+ }
164
+ // --- Guardian events from the error sink ---
165
+ /**
166
+ * Read Guardian review events from the error sink JSONL, filtered by
167
+ * YAGNI_SESSION_ID. The error sink is written by the extension during the
168
+ * session; this reads it post-run. Fail-soft: returns [] on any error.
169
+ */
170
+ export function readGuardianEvents(yagniSessionId, opts = {}) {
171
+ const now = opts.now ?? new Date();
172
+ const home = opts.homeDir ?? join(homedir(), DISTRIBUTION.stateDirName);
173
+ const dayStamp = now.toISOString().slice(0, 10);
174
+ const logPath = join(home, "logs", `errors-${dayStamp}.jsonl`);
175
+ try {
176
+ const data = readFileSync(logPath, "utf8");
177
+ const reviews = [];
178
+ for (const line of data.split("\n")) {
179
+ const trimmed = line.trim();
180
+ if (!trimmed)
181
+ continue;
182
+ try {
183
+ const obj = JSON.parse(trimmed);
184
+ if (obj.source === "guardian" && obj.sessionId === yagniSessionId) {
185
+ reviews.push({
186
+ outcome: obj.outcome ?? "unknown",
187
+ ...(obj.durationMs !== undefined ? { duration_ms: obj.durationMs } : {}),
188
+ ...(obj.tier !== undefined ? { tier: obj.tier } : {}),
189
+ // "ask" and "deny" both block the command in headless
190
+ ...(obj.outcome === "ask" || obj.outcome === "deny"
191
+ ? { blocked: true }
192
+ : {}),
193
+ });
194
+ }
195
+ }
196
+ catch {
197
+ // skip unparseable lines
198
+ }
199
+ }
200
+ return reviews;
201
+ }
202
+ catch {
203
+ // file missing, unreadable, etc.
204
+ return [];
205
+ }
206
+ }
207
+ //# sourceMappingURL=outputFormat.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yagni-app/code-staging",
3
- "version": "1.0.0-staging.1175.1",
3
+ "version": "1.0.0-staging.1177.1",
4
4
  "description": "YAGNI Code: a terminal coding agent that already knows your company. One YAGNI login routes the model and grounds the agent in your team's context.",
5
5
  "license": "SEE LICENSE IN LICENSE.md",
6
6
  "author": "YAGNI, Inc. <jack@yagni.app> (https://yagni.app)",
@@ -40,5 +40,5 @@
40
40
  "turndown": "^7.2.4",
41
41
  "typebox": "^1.3.15"
42
42
  },
43
- "yagniSourceSha": "15a46cded8f9e0d2555d02772ca2b0a658684109"
43
+ "yagniSourceSha": "92209df02c3660ed50b91a72f3197d47c2003e1d"
44
44
  }