@yagni-app/code-staging 0.2.1-staging.1033.1 → 0.2.1-staging.1038.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.
@@ -0,0 +1,25 @@
1
+ import type { PiExtensionContextSnapshot } from "./state.js";
2
+ export interface CommandResult {
3
+ ok: boolean;
4
+ status: number | null;
5
+ stdout: string;
6
+ stderr: string;
7
+ error?: unknown;
8
+ surfaceUnavailable?: boolean;
9
+ }
10
+ export declare function normalizedLaunchArgv(): string[];
11
+ export declare function shouldPreserveEnvKey(key: string): boolean;
12
+ export declare function hookEnvironment(cwd: string, includeSocketPassword?: boolean): NodeJS.ProcessEnv;
13
+ export declare class CmuxDispatcher {
14
+ private static readonly surfaceUnavailableExitCode;
15
+ private controlQueues;
16
+ private unavailableSessions;
17
+ canDispatch(sessionId: string | null): boolean;
18
+ releaseSession(sessionId: string): void;
19
+ run(args: string[], cwd: string, input: string | undefined, context: PiExtensionContextSnapshot): Promise<CommandResult>;
20
+ private execute;
21
+ private spawnCmux;
22
+ private isSurfaceResolutionFailure;
23
+ private surfaceUnavailableResult;
24
+ }
25
+ //# sourceMappingURL=dispatcher.d.ts.map
@@ -0,0 +1,266 @@
1
+ import { spawn } from "node:child_process";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ function resolveExecutable(name) {
5
+ const pathEnv = process.env.PATH || "";
6
+ for (const dir of pathEnv.split(path.delimiter)) {
7
+ if (!dir)
8
+ continue;
9
+ const candidate = path.join(dir, name);
10
+ try {
11
+ fs.accessSync(candidate, fs.constants.X_OK);
12
+ if (fs.statSync(candidate).isFile())
13
+ return candidate;
14
+ }
15
+ catch { }
16
+ }
17
+ return name;
18
+ }
19
+ function looksLikePiExecutable(value) {
20
+ const base = path.basename(value).toLowerCase();
21
+ return base === "pi" || base === "pi-coding-agent";
22
+ }
23
+ function looksLikePiScript(value) {
24
+ const normalized = value.replaceAll("\\", "/").toLowerCase();
25
+ const base = path.basename(normalized);
26
+ return (normalized.includes("/@earendil-works/pi-coding-agent/") ||
27
+ normalized.includes("/@mariozechner/pi-coding-agent/") ||
28
+ normalized.includes("/packages/coding-agent/") ||
29
+ ((base === "cli.js" || base === "cli.ts") &&
30
+ (normalized.includes("pi-coding-agent") || normalized.includes("coding-agent"))));
31
+ }
32
+ export function normalizedLaunchArgv() {
33
+ const raw = Array.isArray(process.argv) ? process.argv.map((value) => String(value)) : [];
34
+ if (raw.length === 0)
35
+ return [resolveExecutable("pi")];
36
+ if (looksLikePiExecutable(raw[0]))
37
+ return raw;
38
+ if (raw.length > 1 && looksLikePiScript(raw[1])) {
39
+ return [resolveExecutable("pi"), ...raw.slice(2)];
40
+ }
41
+ return [resolveExecutable("pi"), ...raw.slice(1)];
42
+ }
43
+ function base64NulSeparated(values) {
44
+ const bytes = [];
45
+ for (const value of values) {
46
+ bytes.push(Buffer.from(String(value), "utf8"));
47
+ bytes.push(Buffer.from([0]));
48
+ }
49
+ return Buffer.concat(bytes).toString("base64");
50
+ }
51
+ function secretLikeEnvKey(key) {
52
+ return /(TOKEN|SECRET|PASSWORD|PASSWD|API[_-]?KEY|ACCESS[_-]?KEY|PRIVATE[_-]?KEY|CREDENTIAL|AUTHORIZATION|COOKIE)/i.test(key);
53
+ }
54
+ function safePiEnvKey(key) {
55
+ return (key === "PI_CODING_AGENT_DIR" ||
56
+ key === "PI_CONFIG_DIR" ||
57
+ key === "PI_CODING_AGENT_SESSION_DIR" ||
58
+ (key.startsWith("PI_CODING_AGENT_") && !secretLikeEnvKey(key)));
59
+ }
60
+ function safeNodeEnvKey(key) {
61
+ return (key === "NODE_ENV" ||
62
+ key === "NODE_OPTIONS" ||
63
+ key === "NODE_PATH" ||
64
+ key === "NODE_NO_WARNINGS" ||
65
+ key === "NODE_EXTRA_CA_CERTS");
66
+ }
67
+ function safeCmuxEnvKey(key) {
68
+ if (key.startsWith("CMUX_TEST_PI_"))
69
+ return !secretLikeEnvKey(key);
70
+ if (key.startsWith("CMUX_AGENT_LAUNCH_"))
71
+ return !secretLikeEnvKey(key);
72
+ if (key === "CMUX_AGENT_HOOK_STATE_DIR")
73
+ return true;
74
+ if (key === "CMUX_PI_CMUX_BIN" || key === "CMUX_PI_HOOKS_DISABLED")
75
+ return true;
76
+ if (key === "CMUX_SURFACE_ID" || key === "CMUX_WORKSPACE_ID" || key === "CMUX_WINDOW_ID")
77
+ return true;
78
+ if (key === "CMUX_PANE_ID" || key === "CMUX_TAB_ID" || key === "CMUX_PANEL_ID")
79
+ return true;
80
+ if (key === "CMUX_SOCKET" || key === "CMUX_SOCKET_PATH")
81
+ return true;
82
+ if (key === "CMUX_BUNDLE_ID" || key === "CMUX_BUNDLED_CLI_PATH")
83
+ return true;
84
+ if (key === "CMUX_CLI_SENTRY_DISABLED" || key === "CMUX_DEBUG_LOG")
85
+ return true;
86
+ return false;
87
+ }
88
+ export function shouldPreserveEnvKey(key) {
89
+ if (safeCmuxEnvKey(key))
90
+ return true;
91
+ if (safePiEnvKey(key))
92
+ return true;
93
+ if (safeNodeEnvKey(key))
94
+ return true;
95
+ if (key === "PATH" || key === "HOME" || key === "PWD" || key === "SHELL")
96
+ return true;
97
+ if (key === "USER" || key === "LOGNAME" || key === "TMPDIR" || key === "TZ")
98
+ return true;
99
+ if (key === "LANG" || key.startsWith("LC_"))
100
+ return true;
101
+ if (key === "TERM" || key === "TERM_PROGRAM" || key === "TERM_PROGRAM_VERSION" || key === "COLORTERM")
102
+ return true;
103
+ if (key === "SSH_AUTH_SOCK")
104
+ return true;
105
+ if (key.startsWith("PI_") || key.startsWith("NODE_"))
106
+ return !secretLikeEnvKey(key);
107
+ return false;
108
+ }
109
+ export function hookEnvironment(cwd, includeSocketPassword = false) {
110
+ const env = {};
111
+ for (const [key, value] of Object.entries(process.env)) {
112
+ if (value === undefined)
113
+ continue;
114
+ if (shouldPreserveEnvKey(key))
115
+ env[key] = value;
116
+ }
117
+ if (includeSocketPassword) {
118
+ const socketPassword = process.env.CMUX_SOCKET_PASSWORD;
119
+ if (socketPassword)
120
+ env.CMUX_SOCKET_PASSWORD = socketPassword;
121
+ }
122
+ if (!env.CMUX_AGENT_LAUNCH_ARGV_B64) {
123
+ const argv = normalizedLaunchArgv();
124
+ env.CMUX_AGENT_LAUNCH_KIND = "pi";
125
+ env.CMUX_AGENT_LAUNCH_EXECUTABLE = argv[0] || resolveExecutable("pi");
126
+ env.CMUX_AGENT_LAUNCH_ARGV_B64 = base64NulSeparated(argv);
127
+ env.CMUX_AGENT_LAUNCH_CWD = cwd || process.cwd();
128
+ }
129
+ return env;
130
+ }
131
+ function cmuxExecutable() {
132
+ return process.env.CMUX_PI_CMUX_BIN || "cmux";
133
+ }
134
+ export class CmuxDispatcher {
135
+ static surfaceUnavailableExitCode = 69;
136
+ controlQueues = new Map();
137
+ unavailableSessions = new Set();
138
+ canDispatch(sessionId) {
139
+ return !sessionId || !this.unavailableSessions.has(sessionId);
140
+ }
141
+ releaseSession(sessionId) {
142
+ this.unavailableSessions.delete(sessionId);
143
+ }
144
+ run(args, cwd, input, context) {
145
+ const sessionId = context.sessionId;
146
+ const previous = this.controlQueues.get(sessionId) || Promise.resolve();
147
+ const scheduled = previous.then(() => this.execute(args, cwd, input, context));
148
+ let tail;
149
+ tail = scheduled
150
+ .then(() => undefined, () => undefined)
151
+ .finally(() => {
152
+ if (this.controlQueues.get(sessionId) === tail)
153
+ this.controlQueues.delete(sessionId);
154
+ });
155
+ this.controlQueues.set(sessionId, tail);
156
+ return scheduled;
157
+ }
158
+ async execute(args, cwd, input, context) {
159
+ const sessionId = context.sessionId;
160
+ if (!this.canDispatch(sessionId)) {
161
+ return this.surfaceUnavailableResult();
162
+ }
163
+ const result = await this.spawnCmux(args, cwd, input);
164
+ if (this.isSurfaceResolutionFailure(result)) {
165
+ if (sessionId)
166
+ this.unavailableSessions.add(sessionId);
167
+ return { ...result, surfaceUnavailable: true };
168
+ }
169
+ return result;
170
+ }
171
+ spawnCmux(args, cwd, input) {
172
+ return new Promise((resolve) => {
173
+ let settled = false;
174
+ let stdout = "";
175
+ let stderr = "";
176
+ let inputError;
177
+ let timeout = null;
178
+ let terminateGrace = null;
179
+ let forceSettleTimeout = null;
180
+ let terminationError;
181
+ const appendOutput = (current, chunk) => {
182
+ const limit = 1024 * 1024;
183
+ if (current.length >= limit)
184
+ return current;
185
+ return current + String(chunk).slice(0, limit - current.length);
186
+ };
187
+ const settle = (result) => {
188
+ if (settled)
189
+ return;
190
+ settled = true;
191
+ if (timeout)
192
+ clearTimeout(timeout);
193
+ if (terminateGrace)
194
+ clearTimeout(terminateGrace);
195
+ if (forceSettleTimeout)
196
+ clearTimeout(forceSettleTimeout);
197
+ resolve(result);
198
+ };
199
+ const terminatedResult = () => ({
200
+ ok: false,
201
+ status: null,
202
+ stdout,
203
+ stderr,
204
+ error: terminationError,
205
+ });
206
+ try {
207
+ const child = spawn(cmuxExecutable(), args, {
208
+ env: hookEnvironment(cwd, true),
209
+ stdio: ["pipe", "pipe", "pipe"],
210
+ });
211
+ child.stdout.setEncoding("utf8");
212
+ child.stderr.setEncoding("utf8");
213
+ child.stdout.on("data", (chunk) => { stdout = appendOutput(stdout, chunk); });
214
+ child.stderr.on("data", (chunk) => { stderr = appendOutput(stderr, chunk); });
215
+ child.stdin.on("error", (error) => { inputError = error; });
216
+ const beginTermination = (error) => {
217
+ if (terminationError)
218
+ return;
219
+ terminationError = error;
220
+ child.stdin.destroy();
221
+ try {
222
+ child.kill("SIGTERM");
223
+ }
224
+ catch { }
225
+ terminateGrace = setTimeout(() => {
226
+ try {
227
+ child.kill("SIGKILL");
228
+ }
229
+ catch { }
230
+ forceSettleTimeout = setTimeout(() => {
231
+ child.stdout.destroy();
232
+ child.stderr.destroy();
233
+ child.unref();
234
+ settle(terminatedResult());
235
+ }, 250);
236
+ }, 250);
237
+ };
238
+ child.on("error", (error) => {
239
+ settle(terminationError ? terminatedResult() : { ok: false, status: null, stdout, stderr, error });
240
+ });
241
+ child.on("close", (code) => {
242
+ if (terminationError) {
243
+ settle(terminatedResult());
244
+ return;
245
+ }
246
+ const status = typeof code === "number" ? code : null;
247
+ settle({ ok: status === 0 && inputError === undefined, status, stdout, stderr, error: inputError });
248
+ });
249
+ timeout = setTimeout(() => {
250
+ beginTermination(new Error("cmux command timed out after 5000ms"));
251
+ }, 5000);
252
+ child.stdin.end(input);
253
+ }
254
+ catch (error) {
255
+ settle({ ok: false, status: null, stdout, stderr, error });
256
+ }
257
+ });
258
+ }
259
+ isSurfaceResolutionFailure(result) {
260
+ return !result.ok && result.status === CmuxDispatcher.surfaceUnavailableExitCode;
261
+ }
262
+ surfaceUnavailableResult() {
263
+ return { ok: false, status: null, stdout: "", stderr: "", surfaceUnavailable: true };
264
+ }
265
+ }
266
+ //# sourceMappingURL=dispatcher.js.map
@@ -0,0 +1,12 @@
1
+ import type { CmuxDispatcher, CommandResult } from "./dispatcher.js";
2
+ import type { PiExtensionContextSnapshot } from "./state.js";
3
+ type HookExtra = Record<string, unknown>;
4
+ export declare function eventName(subcommand: string): string;
5
+ export declare function surfaceTargetArgs(dispatcher: CmuxDispatcher, sessionId: string): string[] | null;
6
+ export declare function rememberSurfaceTarget(dispatcher: CmuxDispatcher, sessionId: string, result: CommandResult): void;
7
+ export declare function releaseSessionRuntime(dispatcher: CmuxDispatcher, sessionStates: Map<string, unknown>, sessionId: string): void;
8
+ export declare function sendHook(dispatcher: CmuxDispatcher, subcommand: string, context: PiExtensionContextSnapshot, extra?: HookExtra): Promise<boolean>;
9
+ export declare function ensureResumeBinding(dispatcher: CmuxDispatcher, context: PiExtensionContextSnapshot, sessionId: string): Promise<void>;
10
+ export declare function clearResumeBinding(dispatcher: CmuxDispatcher, context: PiExtensionContextSnapshot, sessionId: string): Promise<void>;
11
+ export {};
12
+ //# sourceMappingURL=hooks.d.ts.map
@@ -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
@@ -97,6 +97,7 @@ export declare function parseSpendResponse(data: unknown): SpendResponse | null;
97
97
  export declare function registerYagni(pi: ExtensionAPI, deps?: RegisterYagniDeps): Promise<void>;
98
98
  export default function (pi: ExtensionAPI): Promise<void>;
99
99
  export { makeAskYagniTool } from "./askYagniTool.js";
100
+ export { makeFileTicketTool, makeUpdateTicketStatusTool } from "./ticketTools.js";
100
101
  export { makeAskAdvisorTool, registerAdviseCommand } from "./askAdvisorTool.js";
101
102
  export { ADVISOR_TIER, DEFAULT_ADVISOR_LIMITS, decideConsult, formatAdvisorSubtotal, makeAdvisorState, } from "./advisor.js";
102
103
  export type { Citation, MakeAskYagniToolOptions } from "./askYagniTool.js";
@@ -4,7 +4,9 @@ import { Text } from "@earendil-works/pi-tui";
4
4
  import { DEFAULT_ADVISOR_LIMITS, formatAdvisorSubtotal, makeAdvisorState } from "./advisor.js";
5
5
  import { makeAskAdvisorTool, registerAdviseCommand } from "./askAdvisorTool.js";
6
6
  import { makeAskYagniTool } from "./askYagniTool.js";
7
+ import { makeFileTicketTool, makeUpdateTicketStatusTool } from "./ticketTools.js";
7
8
  import { makeReviewBusinessMatchTool } from "./reviewTool.js";
9
+ import { registerCmuxBridge } from "./cmux/index.js";
8
10
  import { makeRecordEngineeringContextTool } from "./recordContextTool.js";
9
11
  import { makeRecordDecisionTool } from "./recordDecisionTool.js";
10
12
  import { makeSuggestNextWorkTool } from "./nextWorkTool.js";
@@ -125,6 +127,12 @@ export async function registerYagni(pi, deps = {}) {
125
127
  pi.registerProvider("yagni", buildYagniProvider(catalog, baseUrl, attributionHeaders(deps.env)));
126
128
  const toolOpts = { baseUrl, getToken: getTokenFn, fetchImpl: authedFetch };
127
129
  pi.registerTool(makeAskYagniTool(toolOpts));
130
+ // Ticket write-back (spec 2026-08-09): explicit user-intent writes to the
131
+ // workspace tracker, attributed to the developer via per-user credentials.
132
+ if (!evalMode) {
133
+ pi.registerTool(makeFileTicketTool(toolOpts));
134
+ pi.registerTool(makeUpdateTicketStatusTool(toolOpts));
135
+ }
128
136
  // The peak-tier escalation for Advanced sessions (YAG-380). Registered
129
137
  // UNCONDITIONALLY and gated at execute time on the live session model: pi's
130
138
  // picker can switch the model after activation, so a registration-time tier
@@ -224,6 +232,7 @@ export async function registerYagni(pi, deps = {}) {
224
232
  ...DEFAULT_PERMISSION_POLICY.reviewConfirmTools,
225
233
  ...mcpMutatingTools,
226
234
  ],
235
+ alwaysConfirmTools: DEFAULT_PERMISSION_POLICY.alwaysConfirmTools,
227
236
  },
228
237
  }
229
238
  : {}),
@@ -547,8 +556,15 @@ export async function registerYagni(pi, deps = {}) {
547
556
  }
548
557
  export default async function (pi) {
549
558
  await registerYagni(pi);
559
+ // cmux session bridge: report lifecycle state (running/idle), workspace
560
+ // naming, and resume bindings to cmux when running inside it. Complete
561
+ // no-op outside cmux — guarded by CMUX_SURFACE_ID. Registered in the
562
+ // default export (not registerYagni) so tests that call registerYagni
563
+ // directly don't get the bridge's handlers.
564
+ registerCmuxBridge(pi);
550
565
  }
551
566
  export { makeAskYagniTool } from "./askYagniTool.js";
567
+ export { makeFileTicketTool, makeUpdateTicketStatusTool } from "./ticketTools.js";
552
568
  export { makeAskAdvisorTool, registerAdviseCommand } from "./askAdvisorTool.js";
553
569
  export { ADVISOR_TIER, DEFAULT_ADVISOR_LIMITS, decideConsult, formatAdvisorSubtotal, makeAdvisorState, } from "./advisor.js";
554
570
  export { makeReviewBusinessMatchTool } from "./reviewTool.js";
@@ -4,7 +4,8 @@
4
4
  * YAGNI Code registers no tool_call handler today, so the interactive session has
5
5
  * no plan/approval surface. P3 adds one on pi's documented `tool_call` block seam
6
6
  * plus a `/mode` command:
7
- * - auto (default): never blocks. Byte-identical to today, so this is additive.
7
+ * - auto (default): ordinary coding tools run directly; external tracker
8
+ * changes still require fresh human confirmation.
8
9
  * - plan : blocks write/edit/bash so the agent can explore + propose without
9
10
  * touching the tree.
10
11
  * - review : surfaces a three-way ctx.ui.select before a write/edit/bash; a
@@ -14,7 +15,7 @@
14
15
  * adds a session-scoped bless rule AND drafts a decision capture.
15
16
  *
16
17
  * `decideGate` is PURE; the live wiring holds the mode in a small closure (no
17
- * module-global state). The default auto mode remains fail-open, but stricter
18
+ * module-global state). The default auto mode remains direct for coding tools, but stricter
18
19
  * modes fail closed for side-effect tools if the gate itself errors. Bless rules
19
20
  * are session-scoped, path-prefix-bound, never persisted, and never consulted in
20
21
  * plan mode (plan blocks outright before isBlessed is reached).
@@ -34,6 +35,8 @@ export interface PermissionPolicy {
34
35
  planBlockTools: string[];
35
36
  /** Tools that prompt for confirmation in review mode. */
36
37
  reviewConfirmTools: string[];
38
+ /** Consequential external writes that require fresh consent in every mode. */
39
+ alwaysConfirmTools?: string[];
37
40
  /**
38
41
  * Optional: a recorded decision already blesses this action, so it auto-runs in
39
42
  * review mode instead of prompting. The hook for tying the gate to captured
@@ -50,8 +53,8 @@ export interface GateDecision {
50
53
  confirm?: boolean;
51
54
  }
52
55
  /**
53
- * Pure permission decision for one tool call under a mode + policy. auto always
54
- * allows; plan blocks the write/exec set; review marks writes for confirmation
56
+ * Pure permission decision for one tool call under a mode + policy. Auto allows
57
+ * ordinary tools; plan blocks the write/exec set; review marks writes for confirmation
55
58
  * unless a recorded decision blesses them.
56
59
  */
57
60
  export declare function decideGate(toolName: string, params: Record<string, unknown>, mode: PermissionMode, policy: PermissionPolicy): GateDecision;
@@ -4,7 +4,8 @@
4
4
  * YAGNI Code registers no tool_call handler today, so the interactive session has
5
5
  * no plan/approval surface. P3 adds one on pi's documented `tool_call` block seam
6
6
  * plus a `/mode` command:
7
- * - auto (default): never blocks. Byte-identical to today, so this is additive.
7
+ * - auto (default): ordinary coding tools run directly; external tracker
8
+ * changes still require fresh human confirmation.
8
9
  * - plan : blocks write/edit/bash so the agent can explore + propose without
9
10
  * touching the tree.
10
11
  * - review : surfaces a three-way ctx.ui.select before a write/edit/bash; a
@@ -14,7 +15,7 @@
14
15
  * adds a session-scoped bless rule AND drafts a decision capture.
15
16
  *
16
17
  * `decideGate` is PURE; the live wiring holds the mode in a small closure (no
17
- * module-global state). The default auto mode remains fail-open, but stricter
18
+ * module-global state). The default auto mode remains direct for coding tools, but stricter
18
19
  * modes fail closed for side-effect tools if the gate itself errors. Bless rules
19
20
  * are session-scoped, path-prefix-bound, never persisted, and never consulted in
20
21
  * plan mode (plan blocks outright before isBlessed is reached).
@@ -27,17 +28,16 @@
27
28
  */
28
29
  import { makeBlessStore as defaultMakeBlessStore } from "./bless.js";
29
30
  export const DEFAULT_PERMISSION_POLICY = {
30
- planBlockTools: ["write", "edit", "bash"],
31
- reviewConfirmTools: ["write", "edit", "bash"],
31
+ planBlockTools: ["write", "edit", "bash", "file_ticket", "update_ticket_status"],
32
+ reviewConfirmTools: ["write", "edit", "bash", "file_ticket", "update_ticket_status"],
33
+ alwaysConfirmTools: ["file_ticket", "update_ticket_status"],
32
34
  };
33
35
  /**
34
- * Pure permission decision for one tool call under a mode + policy. auto always
35
- * allows; plan blocks the write/exec set; review marks writes for confirmation
36
+ * Pure permission decision for one tool call under a mode + policy. Auto allows
37
+ * ordinary tools; plan blocks the write/exec set; review marks writes for confirmation
36
38
  * unless a recorded decision blesses them.
37
39
  */
38
40
  export function decideGate(toolName, params, mode, policy) {
39
- if (mode === "auto")
40
- return { block: false };
41
41
  if (mode === "plan") {
42
42
  if (policy.planBlockTools.includes(toolName)) {
43
43
  return {
@@ -47,6 +47,11 @@ export function decideGate(toolName, params, mode, policy) {
47
47
  }
48
48
  return { block: false };
49
49
  }
50
+ if (policy.alwaysConfirmTools?.includes(toolName)) {
51
+ return { block: false, confirm: true };
52
+ }
53
+ if (mode === "auto")
54
+ return { block: false };
50
55
  // review
51
56
  if (policy.reviewConfirmTools.includes(toolName)) {
52
57
  if (policy.isBlessed?.(toolName, params))
@@ -96,7 +101,7 @@ const MODE_STATUS = {
96
101
  review: "✓ review",
97
102
  };
98
103
  const MODE_COPY = {
99
- auto: "auto: changes apply without prompting (default).",
104
+ auto: "auto: coding changes apply directly; external tracker changes ask first (default).",
100
105
  plan: "plan: write, edit, and bash are held so the agent can explore and propose only.",
101
106
  review: "review: you confirm each write, edit, or bash command before it applies.",
102
107
  };
@@ -104,7 +109,32 @@ function isMode(value) {
104
109
  return value === "auto" || value === "plan" || value === "review";
105
110
  }
106
111
  function sideEffectTools(policy) {
107
- return new Set([...policy.planBlockTools, ...policy.reviewConfirmTools]);
112
+ return new Set([
113
+ ...policy.planBlockTools,
114
+ ...policy.reviewConfirmTools,
115
+ ...(policy.alwaysConfirmTools ?? []),
116
+ ]);
117
+ }
118
+ function boundedPromptValue(value, fallback) {
119
+ if (typeof value !== "string")
120
+ return fallback;
121
+ const normalized = value.replace(/\s+/g, " ").trim();
122
+ if (normalized.length === 0)
123
+ return fallback;
124
+ return normalized.length <= 80 ? normalized : `${normalized.slice(0, 77)}…`;
125
+ }
126
+ function externalTrackerPrompt(toolName, input) {
127
+ if (toolName === "file_ticket") {
128
+ const title = boundedPromptValue(input.title, "Untitled ticket");
129
+ const target = boundedPromptValue(input.target_key, "default project/team");
130
+ return `File “${title}” in ${target}?`;
131
+ }
132
+ if (toolName === "update_ticket_status") {
133
+ const ref = boundedPromptValue(input.ref, "ticket");
134
+ const status = boundedPromptValue(input.status, "requested status");
135
+ return `Move ${ref} to ${status}?`;
136
+ }
137
+ return "Confirm external tracker change";
108
138
  }
109
139
  /**
110
140
  * Wire the tool_call gate + the /mode command onto a shared mode holder. Default
@@ -121,6 +151,7 @@ export function registerPermissionGate(pi, deps = {}) {
121
151
  const effectivePolicy = {
122
152
  planBlockTools: basePolicy.planBlockTools,
123
153
  reviewConfirmTools: basePolicy.reviewConfirmTools,
154
+ alwaysConfirmTools: basePolicy.alwaysConfirmTools,
124
155
  isBlessed: basePolicy.isBlessed ?? ((tool, params) => blessStore?.isBlessed(tool, params) ?? false),
125
156
  };
126
157
  const sideEffects = sideEffectTools(effectivePolicy);
@@ -136,7 +167,7 @@ export function registerPermissionGate(pi, deps = {}) {
136
167
  // cannot get consent for is held rather than silently auto-applied (this
137
168
  // mirrors plan mode, which blocks regardless of UI).
138
169
  if (!ctx?.hasUI) {
139
- return { block: true, reason: `review mode: ${event.toolName} held (no UI to confirm). Switch to /mode auto to apply.` };
170
+ return { block: true, reason: `${event.toolName} held (no UI to confirm this action).` };
140
171
  }
141
172
  // Lazily bind the bless store to this session's cwd.
142
173
  if (!blessStore)
@@ -144,14 +175,16 @@ export function registerPermissionGate(pi, deps = {}) {
144
175
  // Three-way prompt (pi's confirm is boolean-only, so use select): Yes,
145
176
  // Yes-and-remember (only when a path-prefix bless is meaningful), or No.
146
177
  const dir = blessStore.describeDir(input);
147
- const blessable = dir !== null;
178
+ const blessable = dir !== null && !effectivePolicy.alwaysConfirmTools?.includes(event.toolName);
148
179
  const yes = "Yes";
149
180
  const no = "No";
150
181
  const remember = blessable
151
182
  ? `Yes, and don't ask again for ${event.toolName} in ${dir}`
152
183
  : undefined;
153
184
  const options = blessable ? [yes, remember, no] : [yes, no];
154
- const choice = await ctx.ui.select("YAGNI Code review mode", options);
185
+ const choice = await ctx.ui.select(effectivePolicy.alwaysConfirmTools?.includes(event.toolName)
186
+ ? externalTrackerPrompt(event.toolName, input)
187
+ : "YAGNI Code review mode", options);
155
188
  if (choice === yes)
156
189
  return {};
157
190
  if (blessable && choice === remember) {
@@ -0,0 +1,37 @@
1
+ import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
2
+ import { Type } from "typebox";
3
+ /**
4
+ * Ticket write-back tools (spec 2026-08-09): file a ticket, move a ticket.
5
+ *
6
+ * Both are EXPLICIT user-intent actions — the guidelines pin the agent to
7
+ * calling them only when the developer asked. Writes execute server-side as
8
+ * the developer's own tracker account (per-user write identity); when no
9
+ * personal connection exists the backend answers 412 `connect_required` and
10
+ * the tool renders the one-time connect prompt instead of failing opaquely.
11
+ */
12
+ export interface MakeTicketToolOptions {
13
+ baseUrl: string;
14
+ getToken: () => string | undefined;
15
+ fetchImpl?: typeof fetch;
16
+ }
17
+ declare const fileTicketParams: Type.TObject<{
18
+ title: Type.TString;
19
+ description: Type.TOptional<Type.TString>;
20
+ tracker: Type.TOptional<Type.TUnion<[Type.TLiteral<"jira">, Type.TLiteral<"linear">]>>;
21
+ target_key: Type.TOptional<Type.TString>;
22
+ }>;
23
+ export declare function makeFileTicketTool(opts: MakeTicketToolOptions): ToolDefinition<typeof fileTicketParams, {
24
+ identifier?: string;
25
+ url?: string | null;
26
+ }>;
27
+ declare const updateTicketStatusParams: Type.TObject<{
28
+ ref: Type.TString;
29
+ status: Type.TString;
30
+ tracker: Type.TOptional<Type.TUnion<[Type.TLiteral<"jira">, Type.TLiteral<"linear">]>>;
31
+ }>;
32
+ export declare function makeUpdateTicketStatusTool(opts: MakeTicketToolOptions): ToolDefinition<typeof updateTicketStatusParams, {
33
+ identifier?: string;
34
+ state?: string;
35
+ }>;
36
+ export {};
37
+ //# sourceMappingURL=ticketTools.d.ts.map
@@ -0,0 +1,117 @@
1
+ import { Type } from "typebox";
2
+ import { friendlyFetchError, METERED_POST_FETCH_POLICY, resilientFetch, } from "./resilientFetch.js";
3
+ /** Render backend problem responses (412/404/422) as agent-relayable text. */
4
+ async function renderProblem(toolName, res) {
5
+ let body = null;
6
+ try {
7
+ body = (await res.clone().json());
8
+ }
9
+ catch {
10
+ body = null;
11
+ }
12
+ if (res.status === 412 && body?.error === "connect_required") {
13
+ return [
14
+ `A personal ${body.service ?? "tracker"} connection is needed to write as you.`,
15
+ `Connect here (one time): ${body.connect_url ?? "(ask your admin for the connect link)"}`,
16
+ "Then ask me again and I'll retry.",
17
+ ].join("\n");
18
+ }
19
+ if ((res.status === 404 || res.status === 422) && body?.error) {
20
+ const options = body.options?.length
21
+ ? `\nAvailable options: ${body.options.join(", ")}`
22
+ : "";
23
+ return `${body.message ?? body.error}${options}`;
24
+ }
25
+ throw new Error(await friendlyFetchError(toolName, res));
26
+ }
27
+ const fileTicketParams = Type.Object({
28
+ title: Type.String(),
29
+ description: Type.Optional(Type.String()),
30
+ tracker: Type.Optional(Type.Union([Type.Literal("jira"), Type.Literal("linear")])),
31
+ target_key: Type.Optional(Type.String()),
32
+ });
33
+ export function makeFileTicketTool(opts) {
34
+ return {
35
+ name: "file_ticket",
36
+ label: "File Ticket",
37
+ description: "File a ticket in the workspace's tracker (Jira or Linear), attributed to the developer's " +
38
+ "own account. ONLY call when the user explicitly asks to file/create a ticket — never " +
39
+ "speculatively, never as a side effect of other work. Pass target_key (Jira project key or " +
40
+ "Linear team key) when the workspace has more than one.",
41
+ promptSnippet: "file_ticket: file a ticket in the workspace tracker as the developer (explicit request only).",
42
+ promptGuidelines: [
43
+ "Call file_ticket ONLY when the user explicitly asks to file, create, or capture a ticket.",
44
+ "If the tool reports a connect prompt or asks for a target/tracker, relay it verbatim and wait for the user.",
45
+ ],
46
+ parameters: fileTicketParams,
47
+ async execute(toolCallId, params, signal) {
48
+ const res = await resilientFetch(`${opts.baseUrl}/api/yagni-code/tickets`, {
49
+ method: "POST",
50
+ headers: {
51
+ "content-type": "application/json",
52
+ authorization: `Bearer ${opts.getToken() ?? ""}`,
53
+ },
54
+ body: JSON.stringify({ ...params, idempotencyKey: toolCallId }),
55
+ }, { fetchImpl: opts.fetchImpl, signal, policy: METERED_POST_FETCH_POLICY });
56
+ if (!res.ok) {
57
+ const text = await renderProblem("file_ticket", res);
58
+ return { content: [{ type: "text", text }], details: {} };
59
+ }
60
+ const data = (await res.json());
61
+ return {
62
+ content: [
63
+ {
64
+ type: "text",
65
+ text: `Filed ${data.identifier}${data.url ? `: ${data.url}` : ""}`,
66
+ },
67
+ ],
68
+ details: { identifier: data.identifier, url: data.url },
69
+ };
70
+ },
71
+ };
72
+ }
73
+ const updateTicketStatusParams = Type.Object({
74
+ ref: Type.String(),
75
+ status: Type.String(),
76
+ tracker: Type.Optional(Type.Union([Type.Literal("jira"), Type.Literal("linear")])),
77
+ });
78
+ export function makeUpdateTicketStatusTool(opts) {
79
+ return {
80
+ name: "update_ticket_status",
81
+ label: "Update Ticket Status",
82
+ description: "Move a ticket to a new status by name (e.g. mark YAG-123 In Progress), attributed to the " +
83
+ "developer's own tracker account. ONLY call on the user's explicit request — never as an " +
84
+ "automatic side effect of starting or finishing work.",
85
+ promptSnippet: "update_ticket_status: move a tracker ticket to a named status as the developer (explicit request only).",
86
+ promptGuidelines: [
87
+ "Call update_ticket_status ONLY when the user explicitly asks to move/mark a ticket's status.",
88
+ "If the requested status is not available, relay the offered options and let the user pick.",
89
+ ],
90
+ parameters: updateTicketStatusParams,
91
+ async execute(toolCallId, params, signal) {
92
+ const res = await resilientFetch(`${opts.baseUrl}/api/yagni-code/tickets/transition`, {
93
+ method: "POST",
94
+ headers: {
95
+ "content-type": "application/json",
96
+ authorization: `Bearer ${opts.getToken() ?? ""}`,
97
+ },
98
+ body: JSON.stringify({ ...params, idempotencyKey: toolCallId }),
99
+ }, { fetchImpl: opts.fetchImpl, signal, policy: METERED_POST_FETCH_POLICY });
100
+ if (!res.ok) {
101
+ const text = await renderProblem("update_ticket_status", res);
102
+ return { content: [{ type: "text", text }], details: {} };
103
+ }
104
+ const data = (await res.json());
105
+ return {
106
+ content: [
107
+ {
108
+ type: "text",
109
+ text: `${data.identifier} → ${data.state ?? params.status}`,
110
+ },
111
+ ],
112
+ details: { identifier: data.identifier, state: data.state },
113
+ };
114
+ },
115
+ };
116
+ }
117
+ //# sourceMappingURL=ticketTools.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yagni-app/code-staging",
3
- "version": "0.2.1-staging.1033.1",
3
+ "version": "0.2.1-staging.1038.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)",
@@ -38,5 +38,5 @@
38
38
  "@earendil-works/pi-tui": "0.83.0",
39
39
  "typebox": "^1.1.38"
40
40
  },
41
- "yagniSourceSha": "42e45669472777af680dce047ce01490793d6712"
41
+ "yagniSourceSha": "4493e421b21ba86ed37645beecef7c9a74799f4b"
42
42
  }