@yagni-app/code 0.2.1 → 0.3.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.
Files changed (77) hide show
  1. package/dist/cli.d.ts +30 -0
  2. package/dist/cli.js +135 -3
  3. package/dist/doctor.d.ts +1 -1
  4. package/dist/doctor.js +1 -1
  5. package/dist/extension/advisor.d.ts +4 -4
  6. package/dist/extension/advisor.js +6 -7
  7. package/dist/extension/approvedPrefixes.d.ts +92 -0
  8. package/dist/extension/approvedPrefixes.js +252 -0
  9. package/dist/extension/askAdvisorTool.d.ts +2 -2
  10. package/dist/extension/askAdvisorTool.js +5 -5
  11. package/dist/extension/askYagniTool.js +49 -0
  12. package/dist/extension/branding.d.ts +24 -3
  13. package/dist/extension/branding.js +71 -10
  14. package/dist/extension/chipEditor.d.ts +30 -9
  15. package/dist/extension/chipEditor.js +173 -59
  16. package/dist/extension/claudeRules.d.ts +0 -2
  17. package/dist/extension/claudeRules.js +0 -8
  18. package/dist/extension/cmux/dispatcher.d.ts +25 -0
  19. package/dist/extension/cmux/dispatcher.js +266 -0
  20. package/dist/extension/cmux/hooks.d.ts +12 -0
  21. package/dist/extension/cmux/hooks.js +192 -0
  22. package/dist/extension/cmux/index.d.ts +3 -0
  23. package/dist/extension/cmux/index.js +155 -0
  24. package/dist/extension/cmux/naming.d.ts +5 -0
  25. package/dist/extension/cmux/naming.js +23 -0
  26. package/dist/extension/cmux/state.d.ts +33 -0
  27. package/dist/extension/cmux/state.js +142 -0
  28. package/dist/extension/config.d.ts +32 -1
  29. package/dist/extension/config.js +36 -4
  30. package/dist/extension/costHud.d.ts +16 -22
  31. package/dist/extension/costHud.js +8 -47
  32. package/dist/extension/crashReport.js +1 -3
  33. package/dist/extension/execPolicy.d.ts +119 -0
  34. package/dist/extension/execPolicy.js +805 -0
  35. package/dist/extension/footer.d.ts +111 -0
  36. package/dist/extension/footer.js +294 -0
  37. package/dist/extension/guardian.d.ts +129 -0
  38. package/dist/extension/guardian.js +213 -0
  39. package/dist/extension/index.d.ts +15 -4
  40. package/dist/extension/index.js +250 -24
  41. package/dist/extension/permission.d.ts +123 -10
  42. package/dist/extension/permission.js +586 -40
  43. package/dist/extension/pipeline/childRegistry.d.ts +41 -0
  44. package/dist/extension/pipeline/childRegistry.js +118 -0
  45. package/dist/extension/pipeline/finish.js +5 -1
  46. package/dist/extension/pipeline/goCommand.d.ts +1 -1
  47. package/dist/extension/pipeline/goCommand.js +35 -6
  48. package/dist/extension/pipeline/goStatusCommands.d.ts +10 -0
  49. package/dist/extension/pipeline/goStatusCommands.js +61 -1
  50. package/dist/extension/pipeline/personas.js +25 -0
  51. package/dist/extension/pipeline/runRegistry.d.ts +14 -0
  52. package/dist/extension/pipeline/runRegistry.js +35 -0
  53. package/dist/extension/pipeline/runner.js +4 -0
  54. package/dist/extension/pipeline/verify.d.ts +4 -0
  55. package/dist/extension/pipeline/verify.js +48 -26
  56. package/dist/extension/redact.d.ts +20 -0
  57. package/dist/extension/redact.js +64 -0
  58. package/dist/extension/rerouteNotice.d.ts +3 -4
  59. package/dist/extension/rerouteNotice.js +20 -11
  60. package/dist/extension/subagentRender.d.ts +129 -0
  61. package/dist/extension/subagentRender.js +441 -0
  62. package/dist/extension/subagents.d.ts +4 -7
  63. package/dist/extension/subagents.js +103 -33
  64. package/dist/extension/ticketTools.d.ts +37 -0
  65. package/dist/extension/ticketTools.js +117 -0
  66. package/dist/extension/tokenProvider.js +46 -5
  67. package/dist/launch.d.ts +7 -0
  68. package/dist/launch.js +24 -12
  69. package/dist/padding.d.ts +22 -0
  70. package/dist/padding.js +25 -0
  71. package/dist/promptEnrichment.d.ts +40 -0
  72. package/dist/promptEnrichment.js +85 -0
  73. package/dist/signalForward.d.ts +60 -0
  74. package/dist/signalForward.js +130 -0
  75. package/package.json +5 -5
  76. package/dist/extension/boostCommand.d.ts +0 -144
  77. package/dist/extension/boostCommand.js +0 -263
@@ -0,0 +1,192 @@
1
+ import { normalizedLaunchArgv } from "./dispatcher.js";
2
+ import { firstString, warn } from "./state.js";
3
+ export function eventName(subcommand) {
4
+ switch (subcommand) {
5
+ case "session-start": return "SessionStart";
6
+ case "prompt-submit": return "UserPromptSubmit";
7
+ case "stop": return "Stop";
8
+ case "notification": return "Notification";
9
+ default: return subcommand;
10
+ }
11
+ }
12
+ const resolvedSurfaceTargets = new WeakMap();
13
+ function surfaceTargetsFor(dispatcher) {
14
+ let targets = resolvedSurfaceTargets.get(dispatcher);
15
+ if (!targets) {
16
+ targets = new Map();
17
+ resolvedSurfaceTargets.set(dispatcher, targets);
18
+ }
19
+ return targets;
20
+ }
21
+ export function surfaceTargetArgs(dispatcher, sessionId) {
22
+ const resolved = surfaceTargetsFor(dispatcher).get(sessionId);
23
+ if (resolved)
24
+ return [...resolved];
25
+ const surfaceId = firstString(process.env.CMUX_SURFACE_ID);
26
+ if (!surfaceId)
27
+ return null;
28
+ const args = [];
29
+ const workspaceId = firstString(process.env.CMUX_WORKSPACE_ID);
30
+ if (workspaceId)
31
+ args.push("--workspace", workspaceId);
32
+ args.push("--surface", surfaceId);
33
+ return args;
34
+ }
35
+ export function rememberSurfaceTarget(dispatcher, sessionId, result) {
36
+ const payload = parseJSONOutput(result);
37
+ const workspaceId = firstString(payload?.workspace_id);
38
+ const surfaceId = firstString(payload?.surface_id);
39
+ if (!workspaceId || !surfaceId)
40
+ return;
41
+ surfaceTargetsFor(dispatcher).set(sessionId, ["--workspace", workspaceId, "--surface", surfaceId]);
42
+ }
43
+ export function releaseSessionRuntime(dispatcher, sessionStates, sessionId) {
44
+ dispatcher.releaseSession(sessionId);
45
+ sessionStates.delete(sessionId);
46
+ surfaceTargetsFor(dispatcher).delete(sessionId);
47
+ }
48
+ function parseJSONOutput(result) {
49
+ if (!result.ok)
50
+ return null;
51
+ try {
52
+ const parsed = JSON.parse(result.stdout);
53
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
54
+ }
55
+ catch {
56
+ return null;
57
+ }
58
+ }
59
+ export async function sendHook(dispatcher, subcommand, context, extra = {}) {
60
+ if (process.env.CMUX_PI_HOOKS_DISABLED === "1")
61
+ return true;
62
+ const sessionId = context.sessionId;
63
+ if (!sessionId)
64
+ return true;
65
+ const target = surfaceTargetArgs(dispatcher, sessionId);
66
+ if (!target)
67
+ return !firstString(process.env.CMUX_PANEL_ID);
68
+ const cwd = context.cwd;
69
+ const payload = {
70
+ session_id: sessionId,
71
+ cwd,
72
+ hook_event_name: eventName(subcommand),
73
+ event: eventName(subcommand),
74
+ ...extra,
75
+ };
76
+ const result = await dispatcher.run(["hooks", "pi", subcommand, ...target], cwd, JSON.stringify(payload), context);
77
+ if (result.ok)
78
+ rememberSurfaceTarget(dispatcher, sessionId, result);
79
+ if (!result.ok && !result.surfaceUnavailable) {
80
+ warn(context, "cmux hook command failed", {
81
+ subcommand,
82
+ status: result.status,
83
+ stderr_available: result.stderr.trim().length > 0,
84
+ error_available: result.error !== undefined,
85
+ });
86
+ }
87
+ return result.ok;
88
+ }
89
+ // --- Resume bindings ---
90
+ const piOptionsWithValue = new Set([
91
+ "--model", "-m", "--thinking", "--provider", "--extension", "-e",
92
+ "--skill", "--mcp-config", "--permission-mode", "--session-dir",
93
+ "--config", "--profile", "--system-prompt", "--append-system-prompt",
94
+ "--cwd", "--dir", "--trust", "--sandbox",
95
+ ]);
96
+ const piOptionsWithoutValue = new Set([
97
+ "--no-color", "--dangerously-skip-permissions", "--yolo",
98
+ ]);
99
+ const piSelectorsToDrop = new Set([
100
+ "--session", "-s", "--resume", "--fork", "--api-key", "--prompt", "--print",
101
+ ]);
102
+ function sanitizedResumeArgv(sessionId) {
103
+ const raw = normalizedLaunchArgv();
104
+ const executable = raw[0] || "pi";
105
+ const out = [executable, "--session", sessionId];
106
+ for (let index = 1; index < raw.length; index += 1) {
107
+ const arg = raw[index];
108
+ if (!arg)
109
+ continue;
110
+ if (piSelectorsToDrop.has(arg)) {
111
+ if (index + 1 < raw.length && !raw[index + 1].startsWith("-"))
112
+ index += 1;
113
+ continue;
114
+ }
115
+ if (arg.startsWith("--session=") || arg.startsWith("--resume=") ||
116
+ arg.startsWith("--fork=") || arg.startsWith("--api-key=") ||
117
+ arg.startsWith("--prompt=")) {
118
+ continue;
119
+ }
120
+ if (piOptionsWithValue.has(arg)) {
121
+ out.push(arg);
122
+ if (index + 1 < raw.length) {
123
+ out.push(raw[index + 1]);
124
+ index += 1;
125
+ }
126
+ continue;
127
+ }
128
+ if ([...piOptionsWithValue].some((option) => arg.startsWith(`${option}=`)) || piOptionsWithoutValue.has(arg)) {
129
+ out.push(arg);
130
+ }
131
+ }
132
+ return out;
133
+ }
134
+ function resumeBindingMatches(payload, sessionId) {
135
+ const binding = payload?.resume_binding;
136
+ if (!binding || typeof binding !== "object")
137
+ return false;
138
+ const typed = binding;
139
+ return firstString(typed.kind) === "pi" &&
140
+ firstString(typed.checkpoint_id, typed.checkpointId) === sessionId;
141
+ }
142
+ export async function ensureResumeBinding(dispatcher, context, sessionId) {
143
+ if (process.env.CMUX_PI_HOOKS_DISABLED === "1")
144
+ return;
145
+ const target = surfaceTargetArgs(dispatcher, sessionId);
146
+ if (!target)
147
+ return;
148
+ const cwd = context.cwd;
149
+ const resumeArgv = sanitizedResumeArgv(sessionId);
150
+ const set = await dispatcher.run([
151
+ "--json", "surface", "resume", "set", ...target,
152
+ "--name", "Pi", "--kind", "pi", "--checkpoint-id", sessionId,
153
+ "--source", "agent-hook", "--cwd", cwd, "--", ...resumeArgv,
154
+ ], cwd, undefined, context);
155
+ if (!set.ok && !set.surfaceUnavailable) {
156
+ warn(context, "failed to set Pi resume binding", {
157
+ status: set.status,
158
+ stderr_available: set.stderr.trim().length > 0,
159
+ });
160
+ return;
161
+ }
162
+ if (set.surfaceUnavailable)
163
+ return;
164
+ const verification = await dispatcher.run(["--json", "surface", "resume", "get", ...target], cwd, undefined, context);
165
+ if (verification.surfaceUnavailable)
166
+ return;
167
+ const verified = parseJSONOutput(verification);
168
+ if (!resumeBindingMatches(verified, sessionId)) {
169
+ warn(context, "Pi resume binding did not verify after write", { session_id: sessionId });
170
+ }
171
+ }
172
+ export async function clearResumeBinding(dispatcher, context, sessionId) {
173
+ if (process.env.CMUX_PI_HOOKS_DISABLED === "1")
174
+ return;
175
+ const target = surfaceTargetArgs(dispatcher, sessionId);
176
+ if (!target)
177
+ return;
178
+ const cwd = context.cwd;
179
+ const result = await dispatcher.run([
180
+ "--json", "surface", "resume", "clear", ...target,
181
+ "--checkpoint-id", sessionId, "--source", "agent-hook",
182
+ ], cwd, undefined, context);
183
+ if (result.surfaceUnavailable)
184
+ return;
185
+ if (!result.ok) {
186
+ warn(context, "failed to clear Pi resume binding", {
187
+ status: result.status,
188
+ stderr_available: result.stderr.trim().length > 0,
189
+ });
190
+ }
191
+ }
192
+ //# sourceMappingURL=hooks.js.map
@@ -0,0 +1,3 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ export declare function registerCmuxBridge(pi: ExtensionAPI): void;
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,155 @@
1
+ import { existsSync, readFileSync, unlinkSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { CmuxDispatcher } from "./dispatcher.js";
4
+ import { stateFor, snapshotContext, beginTurn, currentTurnId, finishTurn, settleTurn, lastAssistantMessage, firstString, objectValue, warn, } from "./state.js";
5
+ import { sendHook, ensureResumeBinding, clearResumeBinding, releaseSessionRuntime, } from "./hooks.js";
6
+ import { renameWorkspaceFromPrompt } from "./naming.js";
7
+ /** Remove a stale cmux-installed extension so hooks don't double-fire. */
8
+ function removeStaleManagedExtension() {
9
+ const agentDir = process.env.PI_CODING_AGENT_DIR || process.env.YAGNI_CODING_AGENT_DIR;
10
+ if (!agentDir)
11
+ return;
12
+ const managed = join(agentDir, "extensions", "cmux-session.ts");
13
+ try {
14
+ if (!existsSync(managed))
15
+ return;
16
+ const head = readFileSync(managed, "utf8").slice(0, 200);
17
+ if (head.includes("cmux-pi-session-extension-marker")) {
18
+ unlinkSync(managed);
19
+ try {
20
+ unlinkSync(join(agentDir, "extensions", ".cmux-session.lock"));
21
+ }
22
+ catch { }
23
+ }
24
+ }
25
+ catch { }
26
+ }
27
+ export function registerCmuxBridge(pi) {
28
+ if (!process.env.CMUX_SURFACE_ID)
29
+ return;
30
+ if (process.env.CMUX_PI_HOOKS_DISABLED === "1")
31
+ return;
32
+ const dispatcher = new CmuxDispatcher();
33
+ const sessionStates = new Map();
34
+ const lifecycleTails = new Map();
35
+ const enqueueLifecycleTask = (sessionId, context, operation) => {
36
+ const previous = lifecycleTails.get(sessionId) || Promise.resolve();
37
+ let tracked;
38
+ tracked = previous
39
+ .then(operation)
40
+ .then(() => undefined)
41
+ .catch((error) => {
42
+ const msg = error instanceof Error ? error.message : undefined;
43
+ warn(context, "cmux lifecycle task failed", { error_message: msg?.slice(0, 512) }, true);
44
+ })
45
+ .finally(() => {
46
+ if (lifecycleTails.get(sessionId) === tracked)
47
+ lifecycleTails.delete(sessionId);
48
+ });
49
+ lifecycleTails.set(sessionId, tracked);
50
+ return tracked;
51
+ };
52
+ pi.on("session_start", (_event, ctx) => {
53
+ removeStaleManagedExtension();
54
+ const context = snapshotContext(ctx);
55
+ const sessionId = context.sessionId;
56
+ if (!sessionId)
57
+ return;
58
+ const state = stateFor(sessionStates, sessionId);
59
+ state.pendingCompletion = undefined;
60
+ state.stopped = false;
61
+ enqueueLifecycleTask(sessionId, context, async () => {
62
+ const ok = await sendHook(dispatcher, "session-start", context);
63
+ if (ok)
64
+ await ensureResumeBinding(dispatcher, context, sessionId);
65
+ });
66
+ });
67
+ pi.on("before_agent_start", (event, ctx) => {
68
+ const context = snapshotContext(ctx);
69
+ const sessionId = context.sessionId;
70
+ if (!sessionId)
71
+ return;
72
+ const turnId = beginTurn(sessionStates, sessionId, event);
73
+ const st = stateFor(sessionStates, sessionId);
74
+ if (typeof event.prompt === "string" && event.prompt.trim()) {
75
+ st.lastPrompt = event.prompt.trim();
76
+ }
77
+ enqueueLifecycleTask(sessionId, context, () => sendHook(dispatcher, "prompt-submit", context, { prompt: event.prompt, turn_id: turnId }));
78
+ });
79
+ pi.on("agent_end", (event, ctx) => {
80
+ const context = snapshotContext(ctx);
81
+ const sessionId = context.sessionId;
82
+ if (!sessionId)
83
+ return;
84
+ const state = stateFor(sessionStates, sessionId);
85
+ const message = lastAssistantMessage(event);
86
+ state.pendingCompletion = {
87
+ lastAssistantMessage: message || state.pendingCompletion?.lastAssistantMessage,
88
+ notificationType: firstString(objectValue(event, ["stopReason", "reason", "terminationReason"])) || "completed",
89
+ turnId: currentTurnId(sessionStates, sessionId, event),
90
+ };
91
+ // Pi >= 0.80.5 always emits agent_settled, so we wait for it there.
92
+ });
93
+ pi.on("agent_settled", (_event, ctx) => {
94
+ const context = snapshotContext(ctx);
95
+ try {
96
+ if (!ctx.isIdle())
97
+ return;
98
+ }
99
+ catch {
100
+ return;
101
+ }
102
+ const sessionId = context.sessionId;
103
+ if (!sessionId)
104
+ return;
105
+ const completion = settleTurn(sessionStates, sessionId);
106
+ if (completion) {
107
+ enqueueLifecycleTask(sessionId, context, () => publishCompletion(dispatcher, sessionStates, context, sessionId, completion));
108
+ }
109
+ });
110
+ pi.on("session_shutdown", async (event, ctx) => {
111
+ const context = snapshotContext(ctx);
112
+ const sessionId = context.sessionId;
113
+ if (!sessionId)
114
+ return;
115
+ const state = stateFor(sessionStates, sessionId);
116
+ let stopPayload;
117
+ if (!state.stopped) {
118
+ const turnId = finishTurn(sessionStates, sessionId, event);
119
+ stopPayload = {
120
+ turn_id: turnId,
121
+ terminationReason: firstString(objectValue(event, ["reason"])) || "session_shutdown",
122
+ };
123
+ }
124
+ await enqueueLifecycleTask(sessionId, context, async () => {
125
+ if (stopPayload)
126
+ await sendHook(dispatcher, "stop", context, stopPayload);
127
+ try {
128
+ await clearResumeBinding(dispatcher, context, sessionId);
129
+ }
130
+ finally {
131
+ releaseSessionRuntime(dispatcher, sessionStates, sessionId);
132
+ }
133
+ });
134
+ });
135
+ }
136
+ async function publishCompletion(dispatcher, sessionStates, context, sessionId, completion) {
137
+ const stopPayload = {
138
+ last_assistant_message: completion.lastAssistantMessage,
139
+ turn_id: completion.turnId,
140
+ };
141
+ const notificationRouted = await sendHook(dispatcher, "notification", context, {
142
+ message: completion.lastAssistantMessage || "Task completed",
143
+ turn_id: completion.turnId,
144
+ notification: { type: completion.notificationType },
145
+ });
146
+ if (notificationRouted)
147
+ stopPayload.cmux_notification_routed = true;
148
+ await sendHook(dispatcher, "stop", context, stopPayload);
149
+ const state = stateFor(sessionStates, sessionId);
150
+ if (!state.hasNamed) {
151
+ renameWorkspaceFromPrompt(dispatcher, sessionStates, context, sessionId);
152
+ state.hasNamed = true;
153
+ }
154
+ }
155
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,5 @@
1
+ import type { CmuxDispatcher } from "./dispatcher.js";
2
+ import { type PiExtensionContextSnapshot, type SessionState } from "./state.js";
3
+ export declare function titleFromPrompt(prompt: string): string | undefined;
4
+ export declare function renameWorkspaceFromPrompt(dispatcher: CmuxDispatcher, sessionStates: Map<string, SessionState>, context: PiExtensionContextSnapshot, sessionId: string): void;
5
+ //# sourceMappingURL=naming.d.ts.map
@@ -0,0 +1,23 @@
1
+ import { firstString } from "./state.js";
2
+ export function titleFromPrompt(prompt) {
3
+ const words = prompt.trim().split(/\s+/).filter(Boolean);
4
+ if (words.length === 0)
5
+ return undefined;
6
+ return words.slice(0, 8).join(" ").slice(0, 60) || undefined;
7
+ }
8
+ export function renameWorkspaceFromPrompt(dispatcher, sessionStates, context, sessionId) {
9
+ const prompt = sessionStates.get(sessionId)?.lastPrompt;
10
+ if (!prompt)
11
+ return;
12
+ const title = titleFromPrompt(prompt);
13
+ if (!title)
14
+ return;
15
+ // Use the workspace-scoped rename form: cmux workspace rename <ws> --title <title>
16
+ // (NOT rename-workspace --workspace ... --surface ... which misinterprets --surface
17
+ // as part of the title).
18
+ const workspaceId = firstString(process.env.CMUX_WORKSPACE_ID);
19
+ if (!workspaceId)
20
+ return;
21
+ void dispatcher.run(["workspace", "rename", workspaceId, "--title", title], context.cwd, undefined, context);
22
+ }
23
+ //# sourceMappingURL=naming.js.map
@@ -0,0 +1,33 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ export interface PendingCompletion {
3
+ lastAssistantMessage?: string;
4
+ notificationType: string;
5
+ turnId: string;
6
+ }
7
+ export interface SessionState {
8
+ nextTurn: number;
9
+ activeTurnId?: string;
10
+ pendingCompletion?: PendingCompletion;
11
+ stopped: boolean;
12
+ lastPrompt?: string;
13
+ hasNamed?: boolean;
14
+ }
15
+ export interface PiExtensionContextSnapshot {
16
+ readonly sessionId: string | null;
17
+ readonly cwd: string;
18
+ readonly notifyWarning?: () => void;
19
+ }
20
+ export declare function firstString(...values: unknown[]): string | null;
21
+ export declare function objectValue(value: unknown, keys: string[]): unknown;
22
+ export declare function textFromContent(content: unknown): string | null;
23
+ export declare function lastAssistantMessage(event: unknown): string | undefined;
24
+ export declare function sessionIdFrom(ctx: ExtensionContext): string | null;
25
+ export declare function cwdFrom(ctx: ExtensionContext): string;
26
+ export declare function snapshotContext(ctx: ExtensionContext): PiExtensionContextSnapshot;
27
+ export declare function stateFor(sessionStates: Map<string, SessionState>, sessionId: string): SessionState;
28
+ export declare function beginTurn(sessionStates: Map<string, SessionState>, sessionId: string, event: unknown): string;
29
+ export declare function currentTurnId(sessionStates: Map<string, SessionState>, sessionId: string, event: unknown): string;
30
+ export declare function finishTurn(sessionStates: Map<string, SessionState>, sessionId: string, event: unknown): string;
31
+ export declare function settleTurn(sessionStates: Map<string, SessionState>, sessionId: string): PendingCompletion | undefined;
32
+ export declare function warn(ctx: PiExtensionContextSnapshot | null, message: string, details?: Record<string, unknown>, notifyUser?: boolean): void;
33
+ //# sourceMappingURL=state.d.ts.map
@@ -0,0 +1,142 @@
1
+ export function firstString(...values) {
2
+ for (const value of values) {
3
+ if (typeof value === "string" && value.trim().length > 0)
4
+ return value.trim();
5
+ }
6
+ return null;
7
+ }
8
+ export function objectValue(value, keys) {
9
+ if (!value || typeof value !== "object")
10
+ return undefined;
11
+ const typed = value;
12
+ for (const key of keys) {
13
+ if (typed[key] !== undefined && typed[key] !== null)
14
+ return typed[key];
15
+ }
16
+ return undefined;
17
+ }
18
+ export function textFromContent(content) {
19
+ if (typeof content === "string")
20
+ return content;
21
+ if (!Array.isArray(content))
22
+ return null;
23
+ const parts = [];
24
+ for (const block of content) {
25
+ if (!block || typeof block !== "object")
26
+ continue;
27
+ const typed = block;
28
+ if (typed.type === "text" && typeof typed.text === "string")
29
+ parts.push(typed.text);
30
+ }
31
+ return parts.join("\n") || null;
32
+ }
33
+ export function lastAssistantMessage(event) {
34
+ const messagesValue = objectValue(event, ["messages"]);
35
+ const messages = Array.isArray(messagesValue) ? messagesValue : [];
36
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
37
+ const message = messages[index];
38
+ if (!message || typeof message !== "object")
39
+ continue;
40
+ const typed = message;
41
+ if (typed.role !== "assistant")
42
+ continue;
43
+ const text = firstString(textFromContent(typed.content));
44
+ if (text)
45
+ return text;
46
+ }
47
+ return undefined;
48
+ }
49
+ export function sessionIdFrom(ctx) {
50
+ try {
51
+ return firstString(ctx.sessionManager?.getSessionId?.());
52
+ }
53
+ catch {
54
+ return null;
55
+ }
56
+ }
57
+ export function cwdFrom(ctx) {
58
+ try {
59
+ return firstString(ctx.cwd, process.cwd()) || process.cwd();
60
+ }
61
+ catch {
62
+ return process.cwd();
63
+ }
64
+ }
65
+ export function snapshotContext(ctx) {
66
+ if (!ctx || typeof ctx !== "object") {
67
+ return { sessionId: null, cwd: process.cwd() };
68
+ }
69
+ let notifyWarning;
70
+ try {
71
+ const ui = ctx.ui;
72
+ if (typeof ui?.notify === "function") {
73
+ notifyWarning = () => ui.notify?.("cmux integration warning — check the terminal for details", "warning");
74
+ }
75
+ }
76
+ catch { }
77
+ return { sessionId: sessionIdFrom(ctx), cwd: cwdFrom(ctx), notifyWarning };
78
+ }
79
+ export function stateFor(sessionStates, sessionId) {
80
+ let state = sessionStates.get(sessionId);
81
+ if (!state) {
82
+ state = { nextTurn: 0, stopped: false };
83
+ sessionStates.set(sessionId, state);
84
+ }
85
+ return state;
86
+ }
87
+ function eventTurnId(event) {
88
+ return firstString(objectValue(event, ["turn_id", "turnId", "turnID"]));
89
+ }
90
+ export function beginTurn(sessionStates, sessionId, event) {
91
+ const state = stateFor(sessionStates, sessionId);
92
+ const turnId = eventTurnId(event) || `${sessionId}:turn-${state.nextTurn + 1}`;
93
+ if (!eventTurnId(event))
94
+ state.nextTurn += 1;
95
+ state.activeTurnId = turnId;
96
+ state.pendingCompletion = undefined;
97
+ state.stopped = false;
98
+ return turnId;
99
+ }
100
+ export function currentTurnId(sessionStates, sessionId, event) {
101
+ const state = stateFor(sessionStates, sessionId);
102
+ const turnId = eventTurnId(event) || state.activeTurnId || `${sessionId}:turn-${state.nextTurn + 1}`;
103
+ if (!eventTurnId(event) && !state.activeTurnId)
104
+ state.nextTurn += 1;
105
+ return turnId;
106
+ }
107
+ export function finishTurn(sessionStates, sessionId, event) {
108
+ const state = stateFor(sessionStates, sessionId);
109
+ const turnId = eventTurnId(event) || state.activeTurnId || `${sessionId}:turn-${state.nextTurn + 1}`;
110
+ if (!eventTurnId(event) && !state.activeTurnId)
111
+ state.nextTurn += 1;
112
+ state.activeTurnId = undefined;
113
+ state.pendingCompletion = undefined;
114
+ state.stopped = true;
115
+ return turnId;
116
+ }
117
+ export function settleTurn(sessionStates, sessionId) {
118
+ const state = sessionStates.get(sessionId);
119
+ const completion = state?.pendingCompletion;
120
+ if (!state || !completion || state.stopped)
121
+ return undefined;
122
+ state.activeTurnId = undefined;
123
+ state.pendingCompletion = undefined;
124
+ state.stopped = true;
125
+ return completion;
126
+ }
127
+ export function warn(ctx, message, details = {}, notifyUser = false) {
128
+ const payload = { source: "yagni-cmux-bridge", level: "warning", message, ...details };
129
+ try {
130
+ console.warn(JSON.stringify(payload));
131
+ }
132
+ catch {
133
+ console.warn(`[yagni-cmux-bridge] ${message}`);
134
+ }
135
+ if (notifyUser) {
136
+ try {
137
+ ctx?.notifyWarning?.();
138
+ }
139
+ catch { }
140
+ }
141
+ }
142
+ //# sourceMappingURL=state.js.map
@@ -72,13 +72,32 @@ export interface FetchCatalogOptions {
72
72
  getToken: () => string | undefined;
73
73
  fetchImpl?: typeof fetch;
74
74
  }
75
+ /** Guardian storage tier for this workspace (YAG-510). */
76
+ export type GuardianStorageTier = "off" | "hash" | "raw";
77
+ /** The startup catalog response from GET /api/yagni-code/models. */
78
+ export interface CatalogResult {
79
+ models: ModelEntry[];
80
+ /**
81
+ * Per-workspace Guardian kill switch (yagni_code.guardian). Fail-safe:
82
+ * only an explicit `false` from the backend disables the Guardian — a
83
+ * missing field (older backend) reads as enabled.
84
+ */
85
+ guardianEnabled: boolean;
86
+ /**
87
+ * Guardian event storage tier (YAG-510). Polarity is deliberately the
88
+ * OPPOSITE of guardianEnabled: only an explicit "hash" or "raw" turns
89
+ * storage on — a missing field (older backend) or unknown value reads as
90
+ * "off" (fail toward not sending).
91
+ */
92
+ guardianStorage: GuardianStorageTier;
93
+ }
75
94
  /**
76
95
  * Fetch the YAGNI model catalog at startup.
77
96
  *
78
97
  * @throws an actionable Error (mentioning `yagni login`) on any non-2xx
79
98
  * response so the launcher can surface a clear re-authentication prompt.
80
99
  */
81
- export declare function fetchCatalog(opts: FetchCatalogOptions): Promise<ModelEntry[]>;
100
+ export declare function fetchCatalog(opts: FetchCatalogOptions): Promise<CatalogResult>;
82
101
  /** The startup company brief returned by GET /api/yagni-code/context. */
83
102
  export interface ContextBrief {
84
103
  brief: string;
@@ -156,11 +175,23 @@ export declare function isDriverCaller(env?: NodeJS.ProcessEnv): boolean;
156
175
  * sanitizing client-side is what keeps the attribution instead of losing it.
157
176
  */
158
177
  export declare function sanitizeCallerSegment(name: string, maxLength?: number): string;
178
+ /**
179
+ * Attribution headers for the boot-time /context fetch. Extends
180
+ * {@link attributionHeaders} (session id + caller) with the surface the
181
+ * session is painting for (the desktop driver sets YAGNI_SURFACE=desktop;
182
+ * the terminal CLI sets nothing) and the shipping client version. The server
183
+ * uses these to emit its "YAGNI Code Session Started" analytics event —
184
+ * labels only on that side: a missing or malformed value can never fail the
185
+ * fetch, it just goes uncounted or unversioned.
186
+ */
187
+ export declare function sessionTelemetryHeaders(env?: NodeJS.ProcessEnv): Record<string, string>;
159
188
  /** Options for {@link fetchContextBrief}. */
160
189
  export interface FetchContextBriefOptions {
161
190
  baseUrl: string;
162
191
  getToken: () => string | undefined;
163
192
  fetchImpl?: typeof fetch;
193
+ /** Env seam for the telemetry headers; defaults to process.env. */
194
+ env?: NodeJS.ProcessEnv;
164
195
  }
165
196
  /**
166
197
  * Fetch the workspace company brief at startup.
@@ -85,7 +85,13 @@ export async function fetchCatalog(opts) {
85
85
  throw new Error(`Failed to fetch YAGNI model catalog (HTTP ${res.status}). Run \`yagni login\` to re-authenticate.`);
86
86
  }
87
87
  const data = (await res.json());
88
- return data.models;
88
+ return {
89
+ models: data.models,
90
+ guardianEnabled: data.guardianEnabled !== false,
91
+ guardianStorage: data.guardianStorage === "raw" || data.guardianStorage === "hash"
92
+ ? data.guardianStorage
93
+ : "off",
94
+ };
89
95
  }
90
96
  /** Shape-check for a caller label: mirrors the model proxy's own validation regex. */
91
97
  const CALLER_LABEL_RE = /^[a-z0-9][a-z0-9:_.-]{0,63}$/i;
@@ -129,8 +135,6 @@ export function attributionHeaders(env = process.env) {
129
135
  headers["x-yagni-run-id"] = runId;
130
136
  const caller = env.YAGNI_CALLER ?? "";
131
137
  headers["x-yagni-caller"] = CALLER_LABEL_RE.test(caller) ? caller : "driver";
132
- if (env.YAGNI_BOOST === "1")
133
- headers["x-yagni-boost"] = "1";
134
138
  return headers;
135
139
  }
136
140
  /**
@@ -173,6 +177,28 @@ export function sanitizeCallerSegment(name, maxLength = 64) {
173
177
  const trimmed = cleaned.slice(0, Math.max(0, maxLength));
174
178
  return trimmed || "agent";
175
179
  }
180
+ /**
181
+ * Shape-check for a client version label (semver-ish; matches the server's
182
+ * own allowlist). A value that fails is dropped, never sent raw.
183
+ */
184
+ const CLIENT_VERSION_RE = /^[0-9a-z][0-9a-z.+-]{0,31}$/i;
185
+ /**
186
+ * Attribution headers for the boot-time /context fetch. Extends
187
+ * {@link attributionHeaders} (session id + caller) with the surface the
188
+ * session is painting for (the desktop driver sets YAGNI_SURFACE=desktop;
189
+ * the terminal CLI sets nothing) and the shipping client version. The server
190
+ * uses these to emit its "YAGNI Code Session Started" analytics event —
191
+ * labels only on that side: a missing or malformed value can never fail the
192
+ * fetch, it just goes uncounted or unversioned.
193
+ */
194
+ export function sessionTelemetryHeaders(env = process.env) {
195
+ const headers = attributionHeaders(env);
196
+ headers["x-yagni-surface"] = env.YAGNI_SURFACE === "desktop" ? "desktop" : "cli";
197
+ const version = env.YAGNI_CODE_VERSION ?? "";
198
+ if (CLIENT_VERSION_RE.test(version))
199
+ headers["x-yagni-client-version"] = version;
200
+ return headers;
201
+ }
176
202
  /**
177
203
  * Fetch the workspace company brief at startup.
178
204
  *
@@ -182,7 +208,13 @@ export function sanitizeCallerSegment(name, maxLength = 64) {
182
208
  */
183
209
  export async function fetchContextBrief(opts) {
184
210
  try {
185
- const res = await resilientFetch(`${opts.baseUrl}/api/yagni-code/context`, { method: "GET", headers: { authorization: `Bearer ${opts.getToken() ?? ""}` } }, { fetchImpl: opts.fetchImpl });
211
+ const res = await resilientFetch(`${opts.baseUrl}/api/yagni-code/context`, {
212
+ method: "GET",
213
+ headers: {
214
+ authorization: `Bearer ${opts.getToken() ?? ""}`,
215
+ ...sessionTelemetryHeaders(opts.env),
216
+ },
217
+ }, { fetchImpl: opts.fetchImpl });
186
218
  if (!res.ok)
187
219
  return null;
188
220
  return (await res.json());