@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 (
|
|
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;
|
package/dist/extension/hooks.js
CHANGED
|
@@ -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 (
|
|
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 (
|
|
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.
|
|
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": "
|
|
61
|
+
"yagniSourceSha": "957fbf97db7f25d2f34ef4b2e1e443670a4c180f"
|
|
62
62
|
}
|