@yagni-app/code-staging 1.0.9-staging.1301.1 → 1.0.9-staging.1320.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.
@@ -6,8 +6,8 @@
6
6
  * at lifecycle events. The config format mirrors Claude Code's `settings.json`
7
7
  * hooks shape so a user can copy-paste between them.
8
8
  *
9
- * Supported events (8): SessionStart, UserPromptSubmit, PreToolUse,
10
- * PostToolUse, PermissionRequest, PreCompact, PostCompact, SessionEnd.
9
+ * Supported events (9): SessionStart, UserPromptSubmit, PreToolUse,
10
+ * PostToolUse, PermissionRequest, PreCompact, PostCompact, SessionEnd, Stop.
11
11
  *
12
12
  * Exit code semantics (matching Claude Code / Codex):
13
13
  * 0 = success; stdout parsed as JSON for structured decisions
@@ -32,7 +32,7 @@ export interface HookGroup {
32
32
  /** The hooks section of config.json. */
33
33
  export type HooksConfig = Record<string, HookGroup[]>;
34
34
  /** Supported Claude Code event names. */
35
- export type HookEventName = "SessionStart" | "UserPromptSubmit" | "PreToolUse" | "PostToolUse" | "PermissionRequest" | "PreCompact" | "PostCompact" | "SessionEnd";
35
+ export type HookEventName = "SessionStart" | "UserPromptSubmit" | "PreToolUse" | "PostToolUse" | "PermissionRequest" | "PreCompact" | "PostCompact" | "SessionEnd" | "Stop";
36
36
  declare const SUPPORTED_EVENTS: readonly HookEventName[];
37
37
  /** Result of a PreToolUse hook evaluation. */
38
38
  export type PreToolUseHookResult = {
@@ -82,6 +82,16 @@ export declare function parsePermissionRequestOutput(stdout: string): Permission
82
82
  export declare function parseAdditionalContext(stdout: string): string | null;
83
83
  /** Check if stdout JSON has continue: false (for PreCompact cancellation). */
84
84
  export declare function parseCompactCancel(stdout: string): boolean;
85
+ /**
86
+ * A short upload-safe label for a hook-exec exception, for the always-on
87
+ * trail line. Tiers, in order: a Node error code ("ENOENT", "ETIMEDOUT"),
88
+ * the message's first token (a machine word like "spawn"), the constructor
89
+ * name, and typeof for non-Error throws (execImpl is an injected seam —
90
+ * a string throw is possible). Message-derived tiers are scrubbed — a
91
+ * message can begin with content (a credentials URL), and this label rides
92
+ * the default-on (upload-safe) tier.
93
+ */
94
+ export declare function hookErrorLabel(err: unknown): string;
85
95
  interface HookExecutorOptions {
86
96
  config: HooksConfig;
87
97
  env?: NodeJS.ProcessEnv;
@@ -6,8 +6,8 @@
6
6
  * at lifecycle events. The config format mirrors Claude Code's `settings.json`
7
7
  * hooks shape so a user can copy-paste between them.
8
8
  *
9
- * Supported events (8): SessionStart, UserPromptSubmit, PreToolUse,
10
- * PostToolUse, PermissionRequest, PreCompact, PostCompact, SessionEnd.
9
+ * Supported events (9): SessionStart, UserPromptSubmit, PreToolUse,
10
+ * PostToolUse, PermissionRequest, PreCompact, PostCompact, SessionEnd, Stop.
11
11
  *
12
12
  * Exit code semantics (matching Claude Code / Codex):
13
13
  * 0 = success; stdout parsed as JSON for structured decisions
@@ -21,8 +21,10 @@ import { existsSync, readFileSync } from "node:fs";
21
21
  import { join } from "node:path";
22
22
  import { homedir } from "node:os";
23
23
  import { codeStateHome } from "./stateHome.js";
24
+ import { isDriverCaller } from "./config.js";
24
25
  import { isDebug } from "./diagnostics.js";
25
26
  import { logEvent } from "./errorSink.js";
27
+ import { scrubSecrets } from "./pipeline/scrubSecrets.js";
26
28
  const SUPPORTED_EVENTS = [
27
29
  "SessionStart",
28
30
  "UserPromptSubmit",
@@ -32,6 +34,7 @@ const SUPPORTED_EVENTS = [
32
34
  "PreCompact",
33
35
  "PostCompact",
34
36
  "SessionEnd",
37
+ "Stop",
35
38
  ];
36
39
  // ---------------------------------------------------------------------------
37
40
  // Config loading
@@ -301,8 +304,72 @@ export function parseCompactCancel(stdout) {
301
304
  return parsed.continue === false;
302
305
  }
303
306
  // ---------------------------------------------------------------------------
307
+ // Stop capture
308
+ // ---------------------------------------------------------------------------
309
+ /**
310
+ * Extract the last assistant message's text and stopReason from an
311
+ * agent_end event. Walks backwards (the shape cmux/state.ts walks forwards)
312
+ * so an aborted final turn with no text still reports its stopReason —
313
+ * the reason is what decides whether Stop hooks fire at all.
314
+ */
315
+ function lastAssistantInfo(event) {
316
+ const messages = event?.messages;
317
+ if (!Array.isArray(messages))
318
+ return undefined;
319
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
320
+ const message = messages[index];
321
+ if (!message || typeof message !== "object")
322
+ continue;
323
+ const typed = message;
324
+ if (typed.role !== "assistant")
325
+ continue;
326
+ const text = typeof typed.content === "string"
327
+ ? typed.content.trim() || undefined
328
+ : textFromBlocks(typed.content);
329
+ return { text, stopReason: typeof typed.stopReason === "string" ? typed.stopReason : undefined };
330
+ }
331
+ return undefined;
332
+ }
333
+ /** Join text blocks from a content array (mirrors cmux's textFromContent). */
334
+ function textFromBlocks(content) {
335
+ if (!Array.isArray(content))
336
+ return undefined;
337
+ const parts = [];
338
+ for (const block of content) {
339
+ if (!block || typeof block !== "object")
340
+ continue;
341
+ const typed = block;
342
+ if (typed.type === "text" && typeof typed.text === "string" && typed.text.trim()) {
343
+ parts.push(typed.text);
344
+ }
345
+ }
346
+ return parts.join("\n") || undefined;
347
+ }
348
+ // ---------------------------------------------------------------------------
304
349
  // Diagnostic logging
305
350
  // ---------------------------------------------------------------------------
351
+ /**
352
+ * A short upload-safe label for a hook-exec exception, for the always-on
353
+ * trail line. Tiers, in order: a Node error code ("ENOENT", "ETIMEDOUT"),
354
+ * the message's first token (a machine word like "spawn"), the constructor
355
+ * name, and typeof for non-Error throws (execImpl is an injected seam —
356
+ * a string throw is possible). Message-derived tiers are scrubbed — a
357
+ * message can begin with content (a credentials URL), and this label rides
358
+ * the default-on (upload-safe) tier.
359
+ */
360
+ export function hookErrorLabel(err) {
361
+ const code = err?.code;
362
+ // Scrub BEFORE slicing: a secret-shaped value longer than the 40-char cap
363
+ // would be split first and the fragment could match no pattern.
364
+ if (typeof code === "string" && code)
365
+ return scrubSecrets(code).slice(0, 40);
366
+ if (err instanceof Error) {
367
+ if (err.message)
368
+ return scrubSecrets(err.message.split(/[\s:]+/)[0]).slice(0, 40);
369
+ return err.constructor.name;
370
+ }
371
+ return typeof err;
372
+ }
306
373
  function logHookEvent(env, payload) {
307
374
  if (!isDebug(env))
308
375
  return;
@@ -656,6 +723,64 @@ export function registerHooks(pi, deps = {}) {
656
723
  }
657
724
  });
658
725
  }
726
+ // --- Stop (agent finished responding; driver turn complete) ---
727
+ // Two-phase capture, mirroring the cmux bridge: agent_settled carries no
728
+ // payload, so the last assistant message is captured at agent_end and
729
+ // consumed at settled. Firing is VOID, not awaited — pi awaits settled
730
+ // handlers before unblocking the TUI's prompt loop and RPC waitForIdle,
731
+ // so an awaited hook would keep a finished session looking busy for up
732
+ // to the full hook timeout.
733
+ const stopGroups = config["Stop"] ?? [];
734
+ if (stopGroups.length > 0 && isDriverCaller(env)) {
735
+ let pendingStop;
736
+ pi.on("agent_end", (event) => {
737
+ pendingStop = lastAssistantInfo(event);
738
+ });
739
+ pi.on("agent_settled", (_event, ctx) => {
740
+ // hasUI is fixed per session — checking it first keeps the capture
741
+ // consume order-independent (a headless session simply never fires).
742
+ if (!ctx.hasUI)
743
+ return;
744
+ const captured = pendingStop;
745
+ if (!captured)
746
+ return;
747
+ // Mid-retry/compaction/continuation settles are not the turn's end —
748
+ // pi sets isIdle only once no automatic follow-up work will run. The
749
+ // capture is NOT consumed here: a settle that isn't idle leaves it for
750
+ // the genuine end-of-turn settle (an agent_end between the two
751
+ // overwrites it with the continuation's own final message).
752
+ try {
753
+ if (!ctx.isIdle())
754
+ return;
755
+ }
756
+ catch {
757
+ return;
758
+ }
759
+ pendingStop = undefined;
760
+ // Claude Code parity: its query loop returns before running Stop hooks
761
+ // when the user aborted or the model errored — "finished, your move"
762
+ // would be a lie for a killed turn. Retry/continuation agent_ends
763
+ // overwrite the capture, so an error followed by a successful retry
764
+ // still fires (with the retry's message).
765
+ if (captured.stopReason === "aborted" || captured.stopReason === "error")
766
+ return;
767
+ const cwd = ctx.cwd;
768
+ const inputJson = JSON.stringify({
769
+ session_id: sessionId,
770
+ cwd,
771
+ hook_event_name: "Stop",
772
+ stop_hook_active: false,
773
+ ...(captured.text ? { last_assistant_message: captured.text } : {}),
774
+ });
775
+ void (async () => {
776
+ for (const group of filterByTrust(stopGroups, trusted(ctx))) {
777
+ for (const entry of group.hooks) {
778
+ await runSideEffectHook(entry.command, inputJson, cwd, "Stop", ctx, env, execImpl);
779
+ }
780
+ }
781
+ })();
782
+ });
783
+ }
659
784
  }
660
785
  /** Run a side-effect-only hook (no control effects, output ignored). */
661
786
  async function runSideEffectHook(command, inputJson, cwd, eventName, ctx, env, execImpl) {
@@ -665,6 +790,13 @@ async function runSideEffectHook(command, inputJson, cwd, eventName, ctx, env, e
665
790
  YAGNI_HOOK_CWD: cwd,
666
791
  YAGNI_HOOK_SESSION_ID: env.YAGNI_SESSION_ID ?? "",
667
792
  });
793
+ const failed = !!result.error || (result.exitCode !== 0 && result.exitCode !== null);
794
+ // One outcome, two tiers: the debug line carries the full detail
795
+ // (stderr, timing) and is YAGNI_DEBUG-gated; the warn line is always-on
796
+ // and upload-safe, so "hook fired and failed" is distinguishable from
797
+ // "hook never fired" in a default session. The command rides the warn
798
+ // line scrubbed (below) so N hooks on one event produce distinguishable
799
+ // lines without leaking anything the user embedded in the command.
668
800
  logHookEvent(env, {
669
801
  event: eventName,
670
802
  command,
@@ -674,14 +806,47 @@ async function runSideEffectHook(command, inputJson, cwd, eventName, ctx, env, e
674
806
  ...(result.error ? { error: result.error } : {}),
675
807
  ...(result.stderr.trim() ? { stderr: result.stderr.trim().slice(0, 512) } : {}),
676
808
  });
677
- if (result.error || (result.exitCode !== 0 && result.exitCode !== null)) {
809
+ if (failed) {
810
+ logEvent({
811
+ source: "hooks",
812
+ level: "warn",
813
+ event: "hook_failed",
814
+ sessionId: env.YAGNI_SESSION_ID ?? undefined,
815
+ fields: {
816
+ event_name: eventName,
817
+ // Scrubbed at write time: the trail file stores lines raw (scrubbing
818
+ // otherwise happens only at readSessionTrail), and a user's hook
819
+ // command can embed secrets inline (curl -H "Authorization: Bearer …").
820
+ // The full, unscrubbed command stays on the YAGNI_DEBUG tier.
821
+ command: scrubSecrets(command).slice(0, 80),
822
+ exit_code: result.exitCode,
823
+ // Scrub the whole error string before taking the first colon token —
824
+ // result.error embeds a raw err.message (spawn failed/err paths in
825
+ // execHook), and the sibling hookErrorLabel path scrubs message-derived
826
+ // tiers the same way.
827
+ ...(result.error ? { error: scrubSecrets(result.error).split(":")[0] } : {}),
828
+ },
829
+ });
678
830
  if (ctx.hasUI && result.stderr.trim()) {
679
831
  ctx.ui.notify(`Hook '${eventName}' exited with code ${result.exitCode}: ${result.stderr.trim().slice(0, 200)}`, "warning");
680
832
  }
681
833
  }
682
834
  }
683
- catch {
684
- // Fail-soft: a hook error never breaks the session
835
+ catch (err) {
836
+ // Fail-soft: a hook error never breaks the session — but never silent.
837
+ // The label distinguishes "spawn ENOENT" from a timeout (see
838
+ // hookErrorLabel); the command is scrubbed like the sibling path's.
839
+ logEvent({
840
+ source: "hooks",
841
+ level: "warn",
842
+ event: "hook_exec_threw",
843
+ sessionId: env.YAGNI_SESSION_ID ?? undefined,
844
+ fields: {
845
+ event_name: eventName,
846
+ command: scrubSecrets(command).slice(0, 80),
847
+ error: hookErrorLabel(err),
848
+ },
849
+ });
685
850
  }
686
851
  }
687
852
  // ---------------------------------------------------------------------------
@@ -22,6 +22,11 @@ const PATTERNS = [
22
22
  [/\b([A-Za-z0-9_]*(?:secret|password|passwd|api[_-]?key|token|private[_-]?key|access[_-]?key)[A-Za-z0-9_]*)\b(\s*[:=]\s*)("[^"]+"|'[^']+'|`[^`]+`|[^\s"']+)/gi, "$1$2[REDACTED]"],
23
23
  // Long base64-ish blobs (likely keys/JWTs)
24
24
  [/\b[A-Za-z0-9+/]{40,}={0,2}\b/g, "[REDACTED]"],
25
+ // Opaque bearer tokens in Authorization headers: no provider prefix, no
26
+ // secret-named key, and usually under the base64 length threshold — none of
27
+ // the patterns above catch them. Keep the scheme, redact the token. Header
28
+ // names and auth schemes are case-insensitive (RFC 9110) — hence the flag.
29
+ [/(\bauthorization\s*[:=]\s*["']?bearer\s+)([a-z0-9._~+/=-]+)/gi, "$1[REDACTED]"],
25
30
  ];
26
31
  export function scrubSecrets(text) {
27
32
  let out = text;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yagni-app/code-staging",
3
- "version": "1.0.9-staging.1301.1",
3
+ "version": "1.0.9-staging.1320.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": "2d51041ec00bbf091fa349e5c0a199cf52d4fe1e"
61
+ "yagniSourceSha": "957fbf97db7f25d2f34ef4b2e1e443670a4c180f"
62
62
  }