@yagni-app/code-staging 1.1.3-staging.1378.1 → 1.1.3-staging.1382.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.
@@ -24,6 +24,7 @@
24
24
  * `backend/src/yagniCode/crashReports.ts`. Spec:
25
25
  * docs/superpowers/specs/2026-08-08-crash-reporting-design.md
26
26
  */
27
+ import { type SinkEvent } from "./errorSink.js";
27
28
  export declare const CRASH_REPORT_DISABLE_ENV = "YAGNI_DISABLE_CRASH_REPORTS";
28
29
  export declare const CRASH_REPORT_TIMEOUT_MS = 1500;
29
30
  export declare const MAX_CRASH_MESSAGE = 512;
@@ -97,13 +98,30 @@ export type SpawnLike = (command: string, args: string[], options: {
97
98
  export interface FatalCrashOpts extends CrashReporterOpts {
98
99
  /** Spawn seam for tests (defaults to node:child_process spawn). */
99
100
  spawnImpl?: SpawnLike;
101
+ /** Local error-trail seam for tests (defaults to errorSink's logEvent). */
102
+ trail?: (event: SinkEvent) => void;
100
103
  }
104
+ /** Test seam: forget that a fatal report went out, and that a reporter is installed. */
105
+ export declare function resetFatalReportedForTests(): void;
101
106
  /**
102
107
  * Deliver a crash report from a process that is about to die: build the
103
108
  * sanitized payload in-process (cheap, synchronous), then spawn a detached
104
109
  * one-shot sender that outlives the crash. Never throws.
105
110
  */
106
111
  export declare function reportFatalCrash(error: unknown, opts: FatalCrashOpts, context?: string): void;
112
+ /**
113
+ * Report a pi process that ends with a non-zero code WITHOUT an uncaught
114
+ * exception. pi's own fatal paths print to stderr and `process.exit(1)`, which
115
+ * the exception monitor never sees, so until now those deaths were invisible
116
+ * unless the user pasted their terminal. An `exit` listener observes only: it
117
+ * cannot keep the process alive or change the code, and the detached sender
118
+ * is spawned synchronously, which is all an `exit` handler is allowed to do.
119
+ *
120
+ * Skipped on the desktop surface, where the shell files a richer report built
121
+ * from the driver's stderr tail (one incident, one report), and for the two
122
+ * "stopped on purpose" codes.
123
+ */
124
+ export declare function installNonZeroExitReporter(opts: FatalCrashOpts, proc?: Pick<NodeJS.Process, "on">): void;
107
125
  /**
108
126
  * Observe (never alter) a fatal crash in pi's process. Uses
109
127
  * `uncaughtExceptionMonitor`, which fires before the process dies without
@@ -170,7 +170,7 @@ export function makeCrashReporter(opts) {
170
170
  const token = opts.getToken();
171
171
  const sanitized = sanitizeCrashError(error, { env, repoRoot });
172
172
  const payload = {
173
- client: isDesktopSurface() ? "desktop" : "cli",
173
+ client: isDesktopSurface(env) ? "desktop" : "cli",
174
174
  clientVersion: env.YAGNI_CODE_VERSION?.trim() || "unknown",
175
175
  platform: platformLabel(),
176
176
  ...sanitized,
@@ -226,6 +226,19 @@ const DETACHED_SENDER_SRC = [
226
226
  " signal: AbortSignal.timeout(4000),",
227
227
  "}).catch(() => {}).finally(done);",
228
228
  ].join("\n");
229
+ /**
230
+ * Set once a fatal report has been handed to a sender, so the exit reporter
231
+ * never files a second report for the same death (an uncaught exception is
232
+ * followed by an exit-1, and both hooks would otherwise fire).
233
+ */
234
+ let fatalReported = false;
235
+ /** The exit reporter is per process, not per extension load (a reload re-runs the factory). */
236
+ let exitReporterInstalled = false;
237
+ /** Test seam: forget that a fatal report went out, and that a reporter is installed. */
238
+ export function resetFatalReportedForTests() {
239
+ fatalReported = false;
240
+ exitReporterInstalled = false;
241
+ }
229
242
  /**
230
243
  * Deliver a crash report from a process that is about to die: build the
231
244
  * sanitized payload in-process (cheap, synchronous), then spawn a detached
@@ -241,7 +254,7 @@ export function reportFatalCrash(error, opts, context) {
241
254
  return;
242
255
  const sanitized = sanitizeCrashError(error, { env });
243
256
  const payload = {
244
- client: isDesktopSurface() ? "desktop" : "cli",
257
+ client: isDesktopSurface(env) ? "desktop" : "cli",
245
258
  clientVersion: env.YAGNI_CODE_VERSION?.trim() || "unknown",
246
259
  platform: platformLabel(),
247
260
  ...sanitized,
@@ -263,11 +276,62 @@ export function reportFatalCrash(error, opts, context) {
263
276
  },
264
277
  });
265
278
  child.unref();
279
+ fatalReported = true;
266
280
  }
267
281
  catch {
268
282
  // a crash reporter must never add its own crash
269
283
  }
270
284
  }
285
+ /** Exit codes that mean "someone stopped it" (Ctrl+C, SIGTERM), not "it broke". */
286
+ const STOPPED_EXIT_CODES = new Set([130, 143]);
287
+ /**
288
+ * Report a pi process that ends with a non-zero code WITHOUT an uncaught
289
+ * exception. pi's own fatal paths print to stderr and `process.exit(1)`, which
290
+ * the exception monitor never sees, so until now those deaths were invisible
291
+ * unless the user pasted their terminal. An `exit` listener observes only: it
292
+ * cannot keep the process alive or change the code, and the detached sender
293
+ * is spawned synchronously, which is all an `exit` handler is allowed to do.
294
+ *
295
+ * Skipped on the desktop surface, where the shell files a richer report built
296
+ * from the driver's stderr tail (one incident, one report), and for the two
297
+ * "stopped on purpose" codes.
298
+ */
299
+ export function installNonZeroExitReporter(opts, proc = process) {
300
+ // Once per process: pi re-runs the extension factory on every reload, and
301
+ // a listener per reload would both pile up and race each other on exit.
302
+ if (exitReporterInstalled)
303
+ return;
304
+ exitReporterInstalled = true;
305
+ proc.on("exit", (code) => {
306
+ try {
307
+ if (code === 0 || STOPPED_EXIT_CODES.has(code) || fatalReported)
308
+ return;
309
+ const env = opts.env ?? process.env;
310
+ // The same source the payload's client label reads, so the skip and the
311
+ // label can never disagree about which surface this is.
312
+ if (isDesktopSurface(env))
313
+ return;
314
+ const error = new Error(`pi exited with code ${code}`);
315
+ error.name = "ProcessExit";
316
+ // The listener's own frames say nothing about why pi exited.
317
+ error.stack = undefined;
318
+ reportFatalCrash(error, opts, `process-exit:${code}`);
319
+ // The local trail too, so a logged-out user (no token, nothing sent)
320
+ // still has a record /feedback can bind next session.
321
+ (opts.trail ?? logEvent)({
322
+ source: "tool",
323
+ level: "error",
324
+ event: "process_exit",
325
+ sessionId: env.YAGNI_SESSION_ID ?? undefined,
326
+ flush: "sync",
327
+ fields: { code },
328
+ });
329
+ }
330
+ catch {
331
+ // an exit hook must never throw
332
+ }
333
+ });
334
+ }
271
335
  /**
272
336
  * Observe (never alter) a fatal crash in pi's process. Uses
273
337
  * `uncaughtExceptionMonitor`, which fires before the process dies without
@@ -225,6 +225,6 @@ export { ToolRunTracker, summarizeRun, isQuiet, kindForTool } from "./toolRuns.j
225
225
  export type { RowKind, ToolRow, ToolRowPatch } from "./toolRuns.js";
226
226
  export { registerWorkingLine, composeWorkingMessage, NULL_WORKING_LINE, WORKING_INDICATOR_FRAMES, WORKING_VERBS, } from "./workingLine.js";
227
227
  export type { WorkingLineHandle, RegisterWorkingLineDeps } from "./workingLine.js";
228
- export { crashReportsDisabled, installUncaughtExceptionMonitor, makeCrashReporter, reportFatalCrash, sanitizeCrashError, sanitizeCrashText, } from "./crashReport.js";
228
+ export { crashReportsDisabled, installNonZeroExitReporter, installUncaughtExceptionMonitor, makeCrashReporter, reportFatalCrash, sanitizeCrashError, sanitizeCrashText, } from "./crashReport.js";
229
229
  export type { CrashReporter, CrashReporterOpts, FatalCrashOpts, SanitizedCrash } from "./crashReport.js";
230
230
  //# sourceMappingURL=index.d.ts.map
@@ -53,7 +53,7 @@ import { loadGroundingEnabled } from "./grounding.js";
53
53
  import { attributionPromptSection, loadAttributionSettings, } from "./attribution.js";
54
54
  import { registerAmbientRecall } from "./recall.js";
55
55
  import { resilientFetch } from "./resilientFetch.js";
56
- import { installUncaughtExceptionMonitor, makeCrashReporter, runningUnderTest } from "./crashReport.js";
56
+ import { installNonZeroExitReporter, installUncaughtExceptionMonitor, makeCrashReporter, runningUnderTest, } from "./crashReport.js";
57
57
  import { createToolOutcomeBatcher } from "./toolOutcomes.js";
58
58
  import { flushSpool as defaultFlushSpool } from "./spool.js";
59
59
  import { makeAuthedFetch, makeTokenProvider } from "./tokenProvider.js";
@@ -252,7 +252,12 @@ export async function registerYagni(pi, deps = {}) {
252
252
  // soft, opt-out via YAGNI_DISABLE_CRASH_REPORTS=1; off in eval mode like
253
253
  // every other external side effect.
254
254
  if (!evalMode) {
255
- installUncaughtExceptionMonitor({ baseUrl, getToken: getTokenFn, env: deps.env });
255
+ const fatalOpts = { baseUrl, getToken: getTokenFn, env: deps.env };
256
+ installUncaughtExceptionMonitor(fatalOpts);
257
+ // A pi that exits non-zero without throwing (its own fatal paths) is a
258
+ // death the monitor never sees; report it too, so a terminal user's
259
+ // "it just quit" reaches Sentry without a pasted transcript.
260
+ installNonZeroExitReporter(fatalOpts);
256
261
  }
257
262
  // YAG-500 Fix E: non-fatal auth-event reporter for 401s on the model path.
258
263
  // Reuses the crash endpoint (/api/yagni-code/crash) with a distinct context
@@ -1870,5 +1875,5 @@ export { appendToSpool, loadSpool, flushSpool, sendOrSpool, spoolFile, MAX_SPOOL
1870
1875
  export { registerCondensedTools, displayPath, isScratchpadPath, primaryArg, formatRowTitle, formatWriteBody, formatEditBody, formatBashErrorBody, formatBashPartialBody, formatExpandedOutput, splitBashError, countPatchAdditions, WRITE_PREVIEW_LINES, DIFF_PREVIEW_LINES, } from "./condensedTools.js";
1871
1876
  export { ToolRunTracker, summarizeRun, isQuiet, kindForTool } from "./toolRuns.js";
1872
1877
  export { registerWorkingLine, composeWorkingMessage, NULL_WORKING_LINE, WORKING_INDICATOR_FRAMES, WORKING_VERBS, } from "./workingLine.js";
1873
- export { crashReportsDisabled, installUncaughtExceptionMonitor, makeCrashReporter, reportFatalCrash, sanitizeCrashError, sanitizeCrashText, } from "./crashReport.js";
1878
+ export { crashReportsDisabled, installNonZeroExitReporter, installUncaughtExceptionMonitor, makeCrashReporter, reportFatalCrash, sanitizeCrashError, sanitizeCrashText, } from "./crashReport.js";
1874
1879
  //# sourceMappingURL=index.js.map
@@ -6,5 +6,5 @@
6
6
  * Desktop-facing widgets are structured single-line JSON records the app
7
7
  * parses, not themed terminal lines.
8
8
  */
9
- export declare function isDesktopSurface(): boolean;
9
+ export declare function isDesktopSurface(env?: NodeJS.ProcessEnv): boolean;
10
10
  //# sourceMappingURL=surface.d.ts.map
@@ -6,7 +6,7 @@
6
6
  * Desktop-facing widgets are structured single-line JSON records the app
7
7
  * parses, not themed terminal lines.
8
8
  */
9
- export function isDesktopSurface() {
10
- return process.env.YAGNI_SURFACE === "desktop";
9
+ export function isDesktopSurface(env = process.env) {
10
+ return env.YAGNI_SURFACE === "desktop";
11
11
  }
12
12
  //# sourceMappingURL=surface.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yagni-app/code-staging",
3
- "version": "1.1.3-staging.1378.1",
3
+ "version": "1.1.3-staging.1382.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)",
@@ -58,5 +58,5 @@
58
58
  "turndown": "^7.2.4",
59
59
  "typebox": "^1.3.15"
60
60
  },
61
- "yagniSourceSha": "cc4bcf38093fc70fc51ed4b712777566307a1e72"
61
+ "yagniSourceSha": "d05478fe95b7c47498cd44b4cd37c20b711724b7"
62
62
  }