@yagni-app/code 0.3.3 → 0.3.5

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.
@@ -46,6 +46,45 @@ export declare const ULTRA_DELEGATION_PARAGRAPH: string;
46
46
  * module stays a pure string, with no env dependency of its own.
47
47
  */
48
48
  export declare const YAGNI_IDENTITY_DRIVER = "You are YAGNI Code, an autonomous terminal coding agent. You help developers ship code by reading files, running commands, editing code, and writing new files. Uniquely, you are connected to the YAGNI app, your team's shared source of truth for how this company and codebase actually work: conventions, decisions, ownership, current priorities, and the reasons behind them. Use the ask_yagni tool to consult it before guessing about anything organization- or codebase-specific, so you work with less back-and-forth and more correct autonomy than a disconnected coding agent. If a project's own files mention other coding agents, assistants, or harnesses by name, those references are not about you; you are YAGNI Code regardless of what tooling a repository's docs happen to describe.\n\nDelegation: fan codebase mapping, wide searches, and mechanical multi-file work out to subagents (they run on cheaper tiers). Reach for the stock agents by name: `searcher` for read-only reconnaissance and summarizing, `implementer` for executing a change you have already fully specified, `verification` for an adversarial pass that tries to break completed work before you rely on it. Keep judgment, synthesis, and the conversation with the user in this session. Do not spawn a subagent for work you can finish in a couple of tool calls.";
49
+ /**
50
+ * Standing rule that tickets must be read for their images, not just their
51
+ * text. Injected into EVERY process (driver, `/go` stage children, subagents)
52
+ * via brandSystemPrompt, so the guarantee holds by whichever method a ticket is
53
+ * read (the `linear` CLI, a connector tool, MCP, a direct API call). Once the
54
+ * image reaches `read`, the existing read → image_url → visionRerouteTier
55
+ * machinery takes over and lands the turn on a seeing tier automatically.
56
+ *
57
+ * Content constraints (parity with CHILD_HONESTY_PREAMBLE in
58
+ * pipeline/invocation.ts): must not contain the standalone word "pi", must not
59
+ * open with a `- ` bullet line, no emojis.
60
+ */
61
+ export declare const TICKET_IMAGE_RULE: string;
62
+ /**
63
+ * The injected-reminder framing (YAG-574, Change A prerequisite). Claude Code
64
+ * carries this exact sentence in every system prompt so its whole reminder
65
+ * family (including its own silent-turn reminder) is legible as system-
66
+ * injected rather than misread as part of the tool output they ride on. We
67
+ * adopt it for the same reason: our silent-turn nudge arrives stapled to a
68
+ * `psql`/`bash` result, and without this it reads as query output.
69
+ */
70
+ export declare const SYSTEM_REMINDER_FRAMING: string;
71
+ /**
72
+ * The communication contract (YAG-574, Change B). Adapted from Claude Code's
73
+ * `SendUserMessage`/`Brief` prompt: ack in one line before going to look, then
74
+ * work, then result; a checkpoint between them only when something useful
75
+ * happened — a decision, a surprise, a phase boundary — never filler like
76
+ * "running tests…"; keep messages tight and second-person. Complements the
77
+ * silent-turn nudge by spelling out what "keep the user updated" means.
78
+ */
79
+ export declare const COMMUNICATION_CONTRACT: string;
80
+ /**
81
+ * Write-findings-down (YAG-574, Change B). Claude Code's version ties the
82
+ * habit to result-clearing, which we do not do; reworded to the failure we
83
+ * actually saw, the model re-deriving the same answer turn after turn because
84
+ * nothing pushed it to commit a finding to its response (which is also what
85
+ * makes the user see it).
86
+ */
87
+ export declare const WRITE_FINDINGS_DOWN: string;
49
88
  /** The driver identity while /ultra is on: base identity + the diamond directive. */
50
89
  export declare const YAGNI_IDENTITY_ULTRA = "You are YAGNI Code, an autonomous terminal coding agent. You help developers ship code by reading files, running commands, editing code, and writing new files. Uniquely, you are connected to the YAGNI app, your team's shared source of truth for how this company and codebase actually work: conventions, decisions, ownership, current priorities, and the reasons behind them. Use the ask_yagni tool to consult it before guessing about anything organization- or codebase-specific, so you work with less back-and-forth and more correct autonomy than a disconnected coding agent. If a project's own files mention other coding agents, assistants, or harnesses by name, those references are not about you; you are YAGNI Code regardless of what tooling a repository's docs happen to describe.\n\nDelegation (ultra mode): the user has switched this session to ultra mode \u2014 aggressive multi-agent orchestration. Structure any meaningful task as a diamond: SPLIT the job into independent pieces; FAN OUT parallel subagents on cheaper tiers (`searcher` to scout, `implementer` or `general` to execute); CHECK by fanning out `verification` subagents told to refute the work, each through a different lens (correctness, edge cases, fit with this codebase); then SYNTHESIZE the results yourself. Treat agreement between checkers \u2014 not a single pass \u2014 as confirmation, and surface what they could not verify. Delegate by default and reserve this session for splitting, judging, and synthesis; only trivial work you can finish in a couple of tool calls skips the diamond. Subagents cannot touch your todo_write checklist, so keep it current yourself: update it when you split the job and again as each fanned-out piece lands, not only at the end.";
51
90
  export declare const PI_IDENTITY_RE: RegExp;
@@ -75,6 +75,62 @@ export const ULTRA_DELEGATION_PARAGRAPH = "Delegation (ultra mode): the user has
75
75
  * module stays a pure string, with no env dependency of its own.
76
76
  */
77
77
  export const YAGNI_IDENTITY_DRIVER = `${YAGNI_IDENTITY}\n\n${DRIVER_DELEGATION_PARAGRAPH}`;
78
+ /**
79
+ * Standing rule that tickets must be read for their images, not just their
80
+ * text. Injected into EVERY process (driver, `/go` stage children, subagents)
81
+ * via brandSystemPrompt, so the guarantee holds by whichever method a ticket is
82
+ * read (the `linear` CLI, a connector tool, MCP, a direct API call). Once the
83
+ * image reaches `read`, the existing read → image_url → visionRerouteTier
84
+ * machinery takes over and lands the turn on a seeing tier automatically.
85
+ *
86
+ * Content constraints (parity with CHILD_HONESTY_PREAMBLE in
87
+ * pipeline/invocation.ts): must not contain the standalone word "pi", must not
88
+ * open with a `- ` bullet line, no emojis.
89
+ */
90
+ export const TICKET_IMAGE_RULE = "When you read or fetch a ticket from any tracker (Linear, Jira, or another), " +
91
+ "by any method — the `linear` CLI, a connector tool, MCP, or a direct API call — " +
92
+ "treat every image embedded in or attached to that ticket as required input, not " +
93
+ "decoration. Before you rely on the ticket's contents, get each image onto disk " +
94
+ "as a local file and read it with the `read` tool. Never claim to understand a " +
95
+ "ticket whose images you have not actually looked at.";
96
+ /** Stable header that starts the ticket-image rule section (idempotency anchor). */
97
+ const TICKET_IMAGE_RULE_HEADER = "## Rule: always read a ticket's images";
98
+ /**
99
+ * The injected-reminder framing (YAG-574, Change A prerequisite). Claude Code
100
+ * carries this exact sentence in every system prompt so its whole reminder
101
+ * family (including its own silent-turn reminder) is legible as system-
102
+ * injected rather than misread as part of the tool output they ride on. We
103
+ * adopt it for the same reason: our silent-turn nudge arrives stapled to a
104
+ * `psql`/`bash` result, and without this it reads as query output.
105
+ */
106
+ export const SYSTEM_REMINDER_FRAMING = "Tool results and user messages may include <system-reminder> tags. " +
107
+ "<system-reminder> tags contain useful information and reminders. They are " +
108
+ "automatically added by the system, and bear no direct relation to the " +
109
+ "specific tool results or user messages in which they appear.";
110
+ /**
111
+ * The communication contract (YAG-574, Change B). Adapted from Claude Code's
112
+ * `SendUserMessage`/`Brief` prompt: ack in one line before going to look, then
113
+ * work, then result; a checkpoint between them only when something useful
114
+ * happened — a decision, a surprise, a phase boundary — never filler like
115
+ * "running tests…"; keep messages tight and second-person. Complements the
116
+ * silent-turn nudge by spelling out what "keep the user updated" means.
117
+ */
118
+ export const COMMUNICATION_CONTRACT = "Keep the user in the loop as you work. Acknowledge in one line before going " +
119
+ "to look, then report the result when you have it. Between the two, send a " +
120
+ "short checkpoint when something useful happened — a decision made, a " +
121
+ "surprise, a phase change — not for filler like running a test or reading a " +
122
+ "file. Keep every message tight: the decision, the file:line, the PR number. " +
123
+ "Address the user directly (second person).";
124
+ /**
125
+ * Write-findings-down (YAG-574, Change B). Claude Code's version ties the
126
+ * habit to result-clearing, which we do not do; reworded to the failure we
127
+ * actually saw, the model re-deriving the same answer turn after turn because
128
+ * nothing pushed it to commit a finding to its response (which is also what
129
+ * makes the user see it).
130
+ */
131
+ export const WRITE_FINDINGS_DOWN = "When working with tool results, write down any important information you " +
132
+ "might need later in your response, so you do not re-derive it turn after " +
133
+ "turn.";
78
134
  /** The driver identity while /ultra is on: base identity + the diamond directive. */
79
135
  export const YAGNI_IDENTITY_ULTRA = `${YAGNI_IDENTITY}\n\n${ULTRA_DELEGATION_PARAGRAPH}`;
80
136
  /**
@@ -186,6 +242,26 @@ export function brandSystemPrompt(original, opts = {}) {
186
242
  if (rules && rulesHeader && !s.includes(rulesHeader)) {
187
243
  s = `${s}\n\n${rules}`;
188
244
  }
245
+ // 5b. Ticket-image rule — a standing directive placed AFTER the user's rules
246
+ // (so a long, noisy rules block cannot bury it) but BEFORE the closing
247
+ // reminder (which stays the most-recent instruction). The idempotency guard
248
+ // keys on the stable section header, mirroring the rules guard above.
249
+ if (!s.includes(TICKET_IMAGE_RULE_HEADER)) {
250
+ s = `${s}\n\n${TICKET_IMAGE_RULE_HEADER}\n${TICKET_IMAGE_RULE}`;
251
+ }
252
+ // 5c. YAG-574: the injected-reminder framing (which makes the silent-turn
253
+ // nudge legible) and the two communication lines (Change B). Appended after
254
+ // everything user-provided but before the closing reminder, each guarded by
255
+ // its own stable anchor so re-branding never duplicates them.
256
+ if (!s.includes(SYSTEM_REMINDER_FRAMING)) {
257
+ s = `${s}\n\n${SYSTEM_REMINDER_FRAMING}`;
258
+ }
259
+ if (!s.includes(COMMUNICATION_CONTRACT)) {
260
+ s = `${s}\n\n${COMMUNICATION_CONTRACT}`;
261
+ }
262
+ if (!s.includes(WRITE_FINDINGS_DOWN)) {
263
+ s = `${s}\n\n${WRITE_FINDINGS_DOWN}`;
264
+ }
189
265
  // 6. Closing reinforcement. Weak open-weight models weight the most recent
190
266
  // instruction heavily, and the user's own project files may name other
191
267
  // harnesses; a trailing reminder keeps the agent from claiming one as its own.
@@ -1,3 +1,19 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
- export declare function registerCmuxBridge(pi: ExtensionAPI): void;
2
+ import { type SessionState } from "./state.js";
3
+ import { type SummarizeTitleResult } from "../sessionTitle/summarize.js";
4
+ /** Injectable seams so the bridge's state machine is testable without a child. */
5
+ export interface CmuxBridgeDeps {
6
+ summarize?: (prompt: string, deps?: {
7
+ cwd?: string;
8
+ }) => Promise<SummarizeTitleResult>;
9
+ }
10
+ export declare function registerCmuxBridge(pi: ExtensionAPI, deps?: CmuxBridgeDeps): void;
11
+ /**
12
+ * The naming half of `publishCompletion`, extracted so the placeholder→final
13
+ * guard is directly testable without a child process. Once a session's title
14
+ * has been upgraded (or the upgrade was attempted), a later settle does nothing.
15
+ */
16
+ export declare function upgradeTitleOnce(state: SessionState, summarize: (prompt: string, deps?: {
17
+ cwd?: string;
18
+ }) => Promise<SummarizeTitleResult>, setTitle: ((title: string) => void) | undefined, onFail: (reason: string) => void, cwd: string): Promise<void>;
3
19
  //# sourceMappingURL=index.d.ts.map
@@ -3,7 +3,9 @@ import { join } from "node:path";
3
3
  import { CmuxDispatcher } from "./dispatcher.js";
4
4
  import { stateFor, snapshotContext, beginTurn, currentTurnId, finishTurn, settleTurn, lastAssistantMessage, firstString, objectValue, warn, } from "./state.js";
5
5
  import { sendHook, ensureResumeBinding, clearResumeBinding, releaseSessionRuntime, } from "./hooks.js";
6
- import { renameWorkspaceFromPrompt } from "./naming.js";
6
+ import { isDriverCaller } from "../config.js";
7
+ import { sessionTitle } from "../sessionTitle/title.js";
8
+ import { summarizeTitle } from "../sessionTitle/summarize.js";
7
9
  /** Remove a stale cmux-installed extension so hooks don't double-fire. */
8
10
  function removeStaleManagedExtension() {
9
11
  const agentDir = process.env.PI_CODING_AGENT_DIR || process.env.YAGNI_CODING_AGENT_DIR;
@@ -24,11 +26,17 @@ function removeStaleManagedExtension() {
24
26
  }
25
27
  catch { }
26
28
  }
27
- export function registerCmuxBridge(pi) {
29
+ export function registerCmuxBridge(pi, deps = {}) {
28
30
  if (!process.env.CMUX_SURFACE_ID)
29
31
  return;
30
32
  if (process.env.CMUX_PI_HOOKS_DISABLED === "1")
31
33
  return;
34
+ // A child process (guardian consult, advisor, /go stage, subagent) has no
35
+ // direct user — reporting its internals to cmux would overwrite the driver
36
+ // session's card with e.g. the Guardian consult prompt/verdict.
37
+ if (!isDriverCaller())
38
+ return;
39
+ const summarize = deps.summarize ?? summarizeTitle;
32
40
  const dispatcher = new CmuxDispatcher();
33
41
  const sessionStates = new Map();
34
42
  const lifecycleTails = new Map();
@@ -74,6 +82,18 @@ export function registerCmuxBridge(pi) {
74
82
  if (typeof event.prompt === "string" && event.prompt.trim()) {
75
83
  st.lastPrompt = event.prompt.trim();
76
84
  }
85
+ // Set the terminal title to the heuristic session title immediately at the
86
+ // first prompt (mirroring Claude's derive-then-upgrade: the tab is named
87
+ // from the prompt, not the model reply). cmux freezes auto-titling on its
88
+ // own once the user sets a manual name (customTitle), so we never clobber it.
89
+ // The optional LLM upgrade happens later on agent_settled.
90
+ if (!st.nameState && typeof event.prompt === "string" && event.prompt.trim()) {
91
+ const placeholder = sessionTitle(event.prompt.trim());
92
+ if (placeholder) {
93
+ st.nameState = "placeholder";
94
+ context.setTitle?.(placeholder);
95
+ }
96
+ }
77
97
  enqueueLifecycleTask(sessionId, context, () => sendHook(dispatcher, "prompt-submit", context, { prompt: event.prompt, turn_id: turnId }));
78
98
  });
79
99
  pi.on("agent_end", (event, ctx) => {
@@ -104,7 +124,7 @@ export function registerCmuxBridge(pi) {
104
124
  return;
105
125
  const completion = settleTurn(sessionStates, sessionId);
106
126
  if (completion) {
107
- enqueueLifecycleTask(sessionId, context, () => publishCompletion(dispatcher, sessionStates, context, sessionId, completion));
127
+ enqueueLifecycleTask(sessionId, context, () => publishCompletion(dispatcher, sessionStates, context, sessionId, completion, summarize));
108
128
  }
109
129
  });
110
130
  pi.on("session_shutdown", async (event, ctx) => {
@@ -133,7 +153,27 @@ export function registerCmuxBridge(pi) {
133
153
  });
134
154
  });
135
155
  }
136
- async function publishCompletion(dispatcher, sessionStates, context, sessionId, completion) {
156
+ /**
157
+ * The naming half of `publishCompletion`, extracted so the placeholder→final
158
+ * guard is directly testable without a child process. Once a session's title
159
+ * has been upgraded (or the upgrade was attempted), a later settle does nothing.
160
+ */
161
+ export async function upgradeTitleOnce(state, summarize, setTitle, onFail, cwd) {
162
+ if (state.nameState !== "placeholder")
163
+ return;
164
+ state.nameState = "final";
165
+ const prompt = state.lastPrompt;
166
+ if (!prompt)
167
+ return;
168
+ const result = await summarize(prompt, { cwd });
169
+ if (result.ok) {
170
+ setTitle?.(result.title);
171
+ }
172
+ else {
173
+ onFail(result.reason);
174
+ }
175
+ }
176
+ async function publishCompletion(dispatcher, sessionStates, context, sessionId, completion, summarize) {
137
177
  const stopPayload = {
138
178
  last_assistant_message: completion.lastAssistantMessage,
139
179
  turn_id: completion.turnId,
@@ -147,9 +187,8 @@ async function publishCompletion(dispatcher, sessionStates, context, sessionId,
147
187
  stopPayload.cmux_notification_routed = true;
148
188
  await sendHook(dispatcher, "stop", context, stopPayload);
149
189
  const state = stateFor(sessionStates, sessionId);
150
- if (!state.hasNamed) {
151
- renameWorkspaceFromPrompt(dispatcher, sessionStates, context, sessionId);
152
- state.hasNamed = true;
153
- }
190
+ // Upgrade the heuristic placeholder to an LLM-summarized title, once (see
191
+ // upgradeTitleOnce). A failed summary keeps the placeholder in place.
192
+ await upgradeTitleOnce(state, summarize, context.setTitle, (reason) => warn(context, "title summary failed; keeping heuristic title", { session_id: sessionId, reason }), context.cwd);
154
193
  }
155
194
  //# sourceMappingURL=index.js.map
@@ -4,18 +4,22 @@ export interface PendingCompletion {
4
4
  notificationType: string;
5
5
  turnId: string;
6
6
  }
7
+ /** The naming lifecycle of a session's cmux workspace title. */
8
+ export type NameState = "unnamed" | "placeholder" | "final";
7
9
  export interface SessionState {
8
10
  nextTurn: number;
9
11
  activeTurnId?: string;
10
12
  pendingCompletion?: PendingCompletion;
11
13
  stopped: boolean;
12
14
  lastPrompt?: string;
13
- hasNamed?: boolean;
15
+ nameState?: NameState;
14
16
  }
15
17
  export interface PiExtensionContextSnapshot {
16
18
  readonly sessionId: string | null;
17
19
  readonly cwd: string;
18
20
  readonly notifyWarning?: () => void;
21
+ /** Set the terminal tab/window title (pi's OSC 0), mirroring Claude's title channel. */
22
+ readonly setTitle?: (title: string) => void;
19
23
  }
20
24
  export declare function firstString(...values: unknown[]): string | null;
21
25
  export declare function objectValue(value: unknown, keys: string[]): unknown;
@@ -1,3 +1,6 @@
1
+ import { appendFileSync, mkdirSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { codeStateHome } from "../stateHome.js";
1
4
  export function firstString(...values) {
2
5
  for (const value of values) {
3
6
  if (typeof value === "string" && value.trim().length > 0)
@@ -67,14 +70,18 @@ export function snapshotContext(ctx) {
67
70
  return { sessionId: null, cwd: process.cwd() };
68
71
  }
69
72
  let notifyWarning;
73
+ let setTitle;
70
74
  try {
71
75
  const ui = ctx.ui;
72
76
  if (typeof ui?.notify === "function") {
73
77
  notifyWarning = () => ui.notify?.("cmux integration warning — check the terminal for details", "warning");
74
78
  }
79
+ if (typeof ui?.setTitle === "function") {
80
+ setTitle = (title) => ui.setTitle?.(title);
81
+ }
75
82
  }
76
83
  catch { }
77
- return { sessionId: sessionIdFrom(ctx), cwd: cwdFrom(ctx), notifyWarning };
84
+ return { sessionId: sessionIdFrom(ctx), cwd: cwdFrom(ctx), notifyWarning, setTitle };
78
85
  }
79
86
  export function stateFor(sessionStates, sessionId) {
80
87
  let state = sessionStates.get(sessionId);
@@ -126,11 +133,18 @@ export function settleTurn(sessionStates, sessionId) {
126
133
  }
127
134
  export function warn(ctx, message, details = {}, notifyUser = false) {
128
135
  const payload = { source: "yagni-cmux-bridge", level: "warning", message, ...details };
136
+ // The TUI is in raw mode: writing to stdout/stderr corrupts the terminal (the
137
+ // JSON was landing at the prompt cursor). Route warnings/errors to a rotating
138
+ // local log instead — never to the terminal.
129
139
  try {
130
- console.warn(JSON.stringify(payload));
140
+ if (!process.env.NODE_TEST_CONTEXT) {
141
+ const path = join(codeStateHome(null), "logs", "cmux-bridge.log");
142
+ mkdirSync(dirname(path), { recursive: true });
143
+ appendFileSync(path, JSON.stringify({ ts: new Date().toISOString(), ...payload }) + "\n", "utf8");
144
+ }
131
145
  }
132
146
  catch {
133
- console.warn(`[yagni-cmux-bridge] ${message}`);
147
+ /* logging must never throw into the editor */
134
148
  }
135
149
  if (notifyUser) {
136
150
  try {
@@ -90,6 +90,14 @@ export interface CatalogResult {
90
90
  * "off" (fail toward not sending).
91
91
  */
92
92
  guardianStorage: GuardianStorageTier;
93
+ /**
94
+ * Backend-owned per-request Guardian wall ceiling (YAG-562). The client
95
+ * derives its whole-consult deadline from this instead of a hardcoded abort.
96
+ * Absent on older backends → the extension falls back to its default.
97
+ */
98
+ guardianTimeoutMs?: number;
99
+ /** Number of attempts the backend makes (primary + one failover). */
100
+ guardianMaxAttempts?: number;
93
101
  }
94
102
  /**
95
103
  * Fetch the YAGNI model catalog at startup.
@@ -91,6 +91,12 @@ export async function fetchCatalog(opts) {
91
91
  guardianStorage: data.guardianStorage === "raw" || data.guardianStorage === "hash"
92
92
  ? data.guardianStorage
93
93
  : "off",
94
+ ...(typeof data.guardianTimeoutMs === "number" && Number.isFinite(data.guardianTimeoutMs) && data.guardianTimeoutMs > 0
95
+ ? { guardianTimeoutMs: data.guardianTimeoutMs }
96
+ : {}),
97
+ ...(typeof data.guardianMaxAttempts === "number" && Number.isSafeInteger(data.guardianMaxAttempts) && data.guardianMaxAttempts >= 1
98
+ ? { guardianMaxAttempts: data.guardianMaxAttempts }
99
+ : {}),
94
100
  };
95
101
  }
96
102
  /** Shape-check for a caller label: mirrors the model proxy's own validation regex. */
@@ -121,7 +121,7 @@ export { makeAskYagniTool } from "./askYagniTool.js";
121
121
  export { makeFileTicketTool, makeUpdateTicketStatusTool } from "./ticketTools.js";
122
122
  export { makeAskAdvisorTool, registerAdviseCommand } from "./askAdvisorTool.js";
123
123
  export { ADVISOR_TIER, DEFAULT_ADVISOR_LIMITS, decideConsult, formatAdvisorSubtotal, makeAdvisorState, } from "./advisor.js";
124
- export { DEFAULT_GUARDIAN_LIMITS, GUARDIAN_MODEL_TIER, formatGuardianSubtotal, makeGuardianState, resolveGuardianLimits, reviewCommand, } from "./permission/guardian.js";
124
+ export { DEFAULT_GUARDIAN_LIMITS, GUARDIAN_MODEL_TIER, formatGuardianSubtotal, makeGuardianState, resolveGuardianLimits, reviewCommand, deriveGuardianTimeoutMs, } from "./permission/guardian.js";
125
125
  export type { GuardianOutcome, GuardianVerdict, GuardianState, GuardianStateHandle, GuardianLimits, ReviewResult, ReviewCommandDeps, } from "./permission/guardian.js";
126
126
  export type { Citation, MakeAskYagniToolOptions } from "./askYagniTool.js";
127
127
  export { makeReviewBusinessMatchTool } from "./reviewTool.js";
@@ -5,7 +5,7 @@ import { Text } from "@earendil-works/pi-tui";
5
5
  import { DEFAULT_ADVISOR_LIMITS, formatAdvisorSubtotal, makeAdvisorState } from "./advisor.js";
6
6
  import { appendGrant, loadGrants, resolveRepoKey, storagePrefix } from "./permission/approvedPrefixes.js";
7
7
  import { redactCommand } from "./redact.js";
8
- import { formatGuardianSubtotal, GUARDIAN_MODEL_TIER, makeGuardianState, resolveGuardianLimits, reviewCommand } from "./permission/guardian.js";
8
+ import { formatGuardianSubtotal, GUARDIAN_MODEL_TIER, makeGuardianState, resolveGuardianLimits, reviewCommand, deriveGuardianTimeoutMs } from "./permission/guardian.js";
9
9
  import { makeAskAdvisorTool, registerAdviseCommand } from "./askAdvisorTool.js";
10
10
  import { makeAskYagniTool } from "./askYagniTool.js";
11
11
  import { makeFileTicketTool, makeUpdateTicketStatusTool } from "./ticketTools.js";
@@ -45,6 +45,7 @@ import { buildYagniProvider } from "./provider.js";
45
45
  import { registerChipEditor } from "./chipEditor.js";
46
46
  import { defaultMineBeatGit, fileMineBeatMarkers, maybeOfferMiningBeat as defaultMaybeOfferMiningBeat, } from "./mineBeat.js";
47
47
  import { makeFlywheelState } from "./flywheel.js";
48
+ import { registerSilentTurnReminder } from "./silentTurnReminder.js";
48
49
  function isEvalMode(env = process.env) {
49
50
  return env.YAGNI_CODE_EVAL_MODE === "1";
50
51
  }
@@ -135,7 +136,7 @@ export async function registerYagni(pi, deps = {}) {
135
136
  // after_provider_response event does NOT fire on a 401 (the OpenAI SDK throws
136
137
  // before onResponse is reached), so message_end is the only seam.
137
138
  let lastAuthRecovery = null;
138
- const { models: fullCatalog, guardianEnabled: workspaceGuardianEnabled, guardianStorage: guardianStorageTier, } = await fetchCatalog({ baseUrl, getToken: getTokenFn, fetchImpl: authedFetch });
139
+ const { models: fullCatalog, guardianEnabled: workspaceGuardianEnabled, guardianStorage: guardianStorageTier, guardianTimeoutMs: guardianTimeoutAdvertisedMs, guardianMaxAttempts: guardianMaxAttemptsAdvertised, } = await fetchCatalog({ baseUrl, getToken: getTokenFn, fetchImpl: authedFetch });
139
140
  // Lock the interactive session to the `advanced` tier only. The backend
140
141
  // catalog returns all tiers, but only `advanced` is registered with the
141
142
  // `yagni` provider, so /model and Ctrl+P show a single entry. Child
@@ -191,6 +192,11 @@ export async function registerYagni(pi, deps = {}) {
191
192
  // above-editor widget, and /todos. Branch-replayed, so forks and resumes
192
193
  // show the list as it stood at that point.
193
194
  registerTodos(pi);
195
+ // YAG-574: the silent-turn reminder, driver-only. A child/subagent/advisor
196
+ // process has no direct user to answer, so it is never nudged (same gating
197
+ // as the delegation identity in branding.ts); eval mode is untouched so its
198
+ // measured behavior is not perturbed.
199
+ registerSilentTurnReminder(pi, { isDriver: isDriverCaller(env), evalMode, env: deps.env });
194
200
  // Image paste with [Image #N] chips: replaces the editor on
195
201
  // session_start (TUI mode) so pasting a screenshot drops a chip instead of a
196
202
  // temp path, and registers the input transform that turns chips into image
@@ -272,6 +278,16 @@ export async function registerYagni(pi, deps = {}) {
272
278
  !workspaceGuardianEnabled;
273
279
  const guardianTier = env.YAGNI_GUARDIAN_TIER ?? GUARDIAN_MODEL_TIER;
274
280
  const guardianLimits = resolveGuardianLimits(env);
281
+ // YAG-562: the backend owns the Guardian timeout and retries once. The client
282
+ // derives its whole-consult deadline from the catalog's advertised wall
283
+ // ceiling + attempt count (plus a boot/tool overhead margin) rather than
284
+ // hard-aborting at 15s and preempting the backend's retry. Absent an
285
+ // advertisement (older backend) this keeps the default.
286
+ const guardianTimeoutMs = deriveGuardianTimeoutMs(guardianLimits, {
287
+ ...(guardianTimeoutAdvertisedMs !== undefined ? { timeoutMs: guardianTimeoutAdvertisedMs } : {}),
288
+ ...(guardianMaxAttemptsAdvertised !== undefined ? { maxAttempts: guardianMaxAttemptsAdvertised } : {}),
289
+ });
290
+ guardianLimits.timeoutMs = guardianTimeoutMs;
275
291
  // YAG-510: guardian.log stays the sanitized local debug sink (hash-only,
276
292
  // never the command). The remote guardian-events stream below is the
277
293
  // separate, opt-in, per-workspace analytics sink; the two are independent.
@@ -887,7 +903,7 @@ export { makeAskYagniTool } from "./askYagniTool.js";
887
903
  export { makeFileTicketTool, makeUpdateTicketStatusTool } from "./ticketTools.js";
888
904
  export { makeAskAdvisorTool, registerAdviseCommand } from "./askAdvisorTool.js";
889
905
  export { ADVISOR_TIER, DEFAULT_ADVISOR_LIMITS, decideConsult, formatAdvisorSubtotal, makeAdvisorState, } from "./advisor.js";
890
- export { DEFAULT_GUARDIAN_LIMITS, GUARDIAN_MODEL_TIER, formatGuardianSubtotal, makeGuardianState, resolveGuardianLimits, reviewCommand, } from "./permission/guardian.js";
906
+ export { DEFAULT_GUARDIAN_LIMITS, GUARDIAN_MODEL_TIER, formatGuardianSubtotal, makeGuardianState, resolveGuardianLimits, reviewCommand, deriveGuardianTimeoutMs, } from "./permission/guardian.js";
891
907
  export { makeReviewBusinessMatchTool } from "./reviewTool.js";
892
908
  export { makeRecordEngineeringContextTool } from "./recordContextTool.js";
893
909
  export { makeRecordDecisionTool } from "./recordDecisionTool.js";
@@ -45,13 +45,32 @@ export interface GuardianLimits {
45
45
  timeoutMs: number;
46
46
  }
47
47
  export declare const DEFAULT_GUARDIAN_LIMITS: GuardianLimits;
48
+ /**
49
+ * Client-side safety margin on top of the backend's advertised wall ceiling
50
+ * (YAG-562). The backend now owns the real timeout (first-output + wall) and
51
+ * retries once, so the client must NOT pre-empt it; it derives its own
52
+ * deadline from the catalog's `guardianTimeoutMs * attempts` plus this margin
53
+ * for child boot + read-tool round-trips + teardown, and treats that bound as
54
+ * a last-resort floor, not the normal path.
55
+ */
56
+ export declare const GUARDIAN_CLIENT_OVERHEAD_MS = 10000;
48
57
  /**
49
58
  * Resolve Guardian limits from the environment. `YAGNI_GUARDIAN_MAX_REVIEWS`
50
59
  * overrides the sliding-window review cap; anything non-numeric or < 1 falls
51
60
  * back to the default (a bad value must never zero out the cap and lock the
52
- * session).
61
+ * session). `YAGNI_GUARDIAN_TIMEOUT_MS` overrides the consult deadline for a
62
+ * single developer (previously documented but unwired).
53
63
  */
54
64
  export declare function resolveGuardianLimits(env?: Record<string, string | undefined>): GuardianLimits;
65
+ /**
66
+ * Derive the client's whole-consult deadline from the backend's advertised
67
+ * wall ceiling and attempt count (YAG-562). Falls back to the resolved limit
68
+ * when the catalog omitted the fields (older backend).
69
+ */
70
+ export declare function deriveGuardianTimeoutMs(base: GuardianLimits, advertised: {
71
+ timeoutMs?: number;
72
+ maxAttempts?: number;
73
+ }): number;
55
74
  /** The model tier the Guardian runs on. Configurable via YAGNI_GUARDIAN_TIER. */
56
75
  export declare const GUARDIAN_MODEL_TIER = "efficient";
57
76
  /** Read-only tools — the Guardian can read files for context but cannot write or execute. */
@@ -33,17 +33,41 @@ export const DEFAULT_GUARDIAN_LIMITS = {
33
33
  maxConsecutiveDenials: 3,
34
34
  timeoutMs: 15_000,
35
35
  };
36
+ /**
37
+ * Client-side safety margin on top of the backend's advertised wall ceiling
38
+ * (YAG-562). The backend now owns the real timeout (first-output + wall) and
39
+ * retries once, so the client must NOT pre-empt it; it derives its own
40
+ * deadline from the catalog's `guardianTimeoutMs * attempts` plus this margin
41
+ * for child boot + read-tool round-trips + teardown, and treats that bound as
42
+ * a last-resort floor, not the normal path.
43
+ */
44
+ export const GUARDIAN_CLIENT_OVERHEAD_MS = 10_000;
36
45
  /**
37
46
  * Resolve Guardian limits from the environment. `YAGNI_GUARDIAN_MAX_REVIEWS`
38
47
  * overrides the sliding-window review cap; anything non-numeric or < 1 falls
39
48
  * back to the default (a bad value must never zero out the cap and lock the
40
- * session).
49
+ * session). `YAGNI_GUARDIAN_TIMEOUT_MS` overrides the consult deadline for a
50
+ * single developer (previously documented but unwired).
41
51
  */
42
52
  export function resolveGuardianLimits(env = process.env) {
43
- const raw = env.YAGNI_GUARDIAN_MAX_REVIEWS?.trim();
44
- const parsed = raw ? Number.parseInt(raw, 10) : NaN;
45
- const maxReviews = Number.isFinite(parsed) && parsed >= 1 ? parsed : DEFAULT_GUARDIAN_LIMITS.maxReviews;
46
- return { ...DEFAULT_GUARDIAN_LIMITS, maxReviews };
53
+ const rawReviews = env.YAGNI_GUARDIAN_MAX_REVIEWS?.trim();
54
+ const parsedReviews = rawReviews ? Number.parseInt(rawReviews, 10) : NaN;
55
+ const maxReviews = Number.isFinite(parsedReviews) && parsedReviews >= 1 ? parsedReviews : DEFAULT_GUARDIAN_LIMITS.maxReviews;
56
+ const rawTimeout = env.YAGNI_GUARDIAN_TIMEOUT_MS?.trim();
57
+ const parsedTimeout = rawTimeout ? Number.parseInt(rawTimeout, 10) : NaN;
58
+ const timeoutMs = Number.isFinite(parsedTimeout) && parsedTimeout >= 1 ? parsedTimeout : DEFAULT_GUARDIAN_LIMITS.timeoutMs;
59
+ return { ...DEFAULT_GUARDIAN_LIMITS, maxReviews, timeoutMs };
60
+ }
61
+ /**
62
+ * Derive the client's whole-consult deadline from the backend's advertised
63
+ * wall ceiling and attempt count (YAG-562). Falls back to the resolved limit
64
+ * when the catalog omitted the fields (older backend).
65
+ */
66
+ export function deriveGuardianTimeoutMs(base, advertised) {
67
+ if (advertised.timeoutMs !== undefined && advertised.maxAttempts !== undefined) {
68
+ return advertised.timeoutMs * advertised.maxAttempts + GUARDIAN_CLIENT_OVERHEAD_MS;
69
+ }
70
+ return base.timeoutMs;
47
71
  }
48
72
  /** The model tier the Guardian runs on. Configurable via YAGNI_GUARDIAN_TIER. */
49
73
  export const GUARDIAN_MODEL_TIER = "efficient";
@@ -190,7 +214,19 @@ export async function reviewCommand(command, deps) {
190
214
  if (deps.signal?.aborted) {
191
215
  return { verdict: null, error: "aborted", cost };
192
216
  }
217
+ // The backend now owns the timeout (YAG-562) and streams a terminal error
218
+ // frame when the consult fails; a result with a `stopReason: "error"` or an
219
+ // `errorMessage` is a real backend failure, NOT the silent-empty shape. Map
220
+ // it to a concrete error so the gate shows "review timed out" / "service
221
+ // unavailable" instead of the misleading "no response". A timeout-shaped
222
+ // message (stream_idle_timeout / "timed out") is distinguished so the gate
223
+ // can add its timeout-specific note.
193
224
  if (!output) {
225
+ if (result.stopReason === "error" || result.errorMessage) {
226
+ const msg = (result.errorMessage ?? "").toLowerCase();
227
+ const isTimeout = /stream_idle_timeout|timed out|idle|wall|timeout/i.test(msg);
228
+ return { verdict: null, error: isTimeout ? "timeout" : "network", cost };
229
+ }
194
230
  return { verdict: null, error: "empty", cost };
195
231
  }
196
232
  const verdict = parseVerdict(output);
@@ -184,6 +184,14 @@ Output ONLY a JSON object with this exact shape:
184
184
  {"outcome":"allow"|"ask"|"deny","riskLevel":"low"|"medium"|"high"|"critical","rationale":"see rationale rules"}
185
185
 
186
186
  Do not output anything else after the JSON. No markdown fences, only the JSON object.`;
187
+ const TITLE_BODY = `You produce a session title from a user's prompt. Output ONLY a concise, sentence-case title of 3-7 words that captures the main topic or goal. Capitalize only the first word and proper nouns. Do not include a ticket code in the title text itself (the caller prepends it). No markdown, no prose, no quotes — just the title on one line.
188
+
189
+ Good:
190
+ Fix the login page
191
+ Add OAuth authentication
192
+
193
+ Bad (too vague): Code changes
194
+ Bad (too long): Investigate and fix the login button not responding on mobile devices`;
187
195
  /** Persona body keyed by the agent name referenced in `stages.ts`. */
188
196
  export const PERSONA_BODIES = {
189
197
  scout: SCOUT_BODY,
@@ -192,6 +200,7 @@ export const PERSONA_BODIES = {
192
200
  reviewer: REVIEWER_BODY,
193
201
  advisor: ADVISOR_BODY,
194
202
  guardian: GUARDIAN_BODY,
203
+ title: TITLE_BODY,
195
204
  orchestrator: [ORCHESTRATOR_BODY, PARTITION_CONTRACT].join("\n\n"),
196
205
  synthesizer: SYNTHESIZER_BODY,
197
206
  };
@@ -0,0 +1,40 @@
1
+ /**
2
+ * The I/O half of session titling: summarize a prompt into a title via a
3
+ * locked-down efficient-tier child (same `runStage` seam the Guardian and
4
+ * advisor use). Fully optional — any failure returns undefined so the caller
5
+ * falls back to the heuristic title without ever blocking a turn.
6
+ */
7
+ import { runStage as defaultRunStage } from "../pipeline/runner.js";
8
+ import type { PipelineStage } from "../pipeline/types.js";
9
+ /** The model tier the title child runs on (read-only, like the Guardian). */
10
+ export declare const TITLE_MODEL_TIER = "efficient";
11
+ /** The read-only tools the title child may use (only `read`, for context). */
12
+ export declare const TITLE_TOOLS: string[];
13
+ /** Default wall-clock timeout for the title consult (matches Guardian). */
14
+ export declare const DEFAULT_TITLE_TIMEOUT_MS = 15000;
15
+ /**
16
+ * The synthetic stage a title consult runs as. Borrows the `plan` StageId (same
17
+ * pattern as the advisor/guardian) so it doesn't ripple into /go feed/reducers.
18
+ * The agent name selects the `title` persona from PERSONA_BODIES.
19
+ */
20
+ export declare function titleStage(modelTier?: string): PipelineStage;
21
+ export interface SummarizeTitleDeps {
22
+ runStage?: typeof defaultRunStage;
23
+ timeoutMs?: number;
24
+ cwd?: string;
25
+ }
26
+ /** The outcome of a title consult: a prefixed title, or a failure reason. */
27
+ export type SummarizeTitleResult = {
28
+ ok: true;
29
+ title: string;
30
+ } | {
31
+ ok: false;
32
+ reason: string;
33
+ };
34
+ /**
35
+ * Summarize a prompt into a title. Returns `{ title }` on success, or
36
+ * `{ reason }` on any failure (timeout, abort, non-zero exit, empty or unusable
37
+ * output) — the caller falls back to the heuristic and logs the reason.
38
+ */
39
+ export declare function summarizeTitle(prompt: string, deps?: SummarizeTitleDeps): Promise<SummarizeTitleResult>;
40
+ //# sourceMappingURL=summarize.d.ts.map
@@ -0,0 +1,63 @@
1
+ /**
2
+ * The I/O half of session titling: summarize a prompt into a title via a
3
+ * locked-down efficient-tier child (same `runStage` seam the Guardian and
4
+ * advisor use). Fully optional — any failure returns undefined so the caller
5
+ * falls back to the heuristic title without ever blocking a turn.
6
+ */
7
+ import { runStage as defaultRunStage } from "../pipeline/runner.js";
8
+ import { parseSummarizedTitle } from "./title.js";
9
+ /** The model tier the title child runs on (read-only, like the Guardian). */
10
+ export const TITLE_MODEL_TIER = "efficient";
11
+ /** The read-only tools the title child may use (only `read`, for context). */
12
+ export const TITLE_TOOLS = ["read"];
13
+ /** Default wall-clock timeout for the title consult (matches Guardian). */
14
+ export const DEFAULT_TITLE_TIMEOUT_MS = 15_000;
15
+ /**
16
+ * The synthetic stage a title consult runs as. Borrows the `plan` StageId (same
17
+ * pattern as the advisor/guardian) so it doesn't ripple into /go feed/reducers.
18
+ * The agent name selects the `title` persona from PERSONA_BODIES.
19
+ */
20
+ export function titleStage(modelTier = TITLE_MODEL_TIER) {
21
+ return {
22
+ id: "plan",
23
+ agent: "title",
24
+ model: modelTier,
25
+ tools: TITLE_TOOLS,
26
+ taskTemplate: "{ticket}",
27
+ };
28
+ }
29
+ /**
30
+ * Summarize a prompt into a title. Returns `{ title }` on success, or
31
+ * `{ reason }` on any failure (timeout, abort, non-zero exit, empty or unusable
32
+ * output) — the caller falls back to the heuristic and logs the reason.
33
+ */
34
+ export async function summarizeTitle(prompt, deps = {}) {
35
+ const runStage = deps.runStage ?? defaultRunStage;
36
+ const timeoutMs = deps.timeoutMs ?? DEFAULT_TITLE_TIMEOUT_MS;
37
+ const controller = new AbortController();
38
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
39
+ timer.unref?.();
40
+ try {
41
+ const result = await runStage(titleStage(), { ticket: `Summarize this session into a short title:\n\n${prompt}` }, {
42
+ cwd: deps.cwd ?? process.cwd(),
43
+ signal: controller.signal,
44
+ callerLabel: "title",
45
+ });
46
+ if (result.exitCode !== 0) {
47
+ const stderr = result.stderr?.trim().slice(0, 200);
48
+ return { ok: false, reason: stderr ? `exit=${result.exitCode} ${stderr}` : `exit=${result.exitCode}` };
49
+ }
50
+ const title = parseSummarizedTitle(prompt, result.finalOutput);
51
+ if (title)
52
+ return { ok: true, title };
53
+ return { ok: false, reason: `empty_output output=${JSON.stringify(result.finalOutput.slice(0, 200))}` };
54
+ }
55
+ catch (error) {
56
+ const msg = error instanceof Error ? error.message : String(error);
57
+ return { ok: false, reason: `threw ${msg.slice(0, 200)}` };
58
+ }
59
+ finally {
60
+ clearTimeout(timer);
61
+ }
62
+ }
63
+ //# sourceMappingURL=summarize.js.map
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Pure session-title derivation — the reusable half of naming an agent session.
3
+ * No cmux, no workspace, no I/O: just "given a prompt, what's a good title".
4
+ *
5
+ * cmux is the current (and only) consumer, but this module is deliberately
6
+ * cmux-free so a future consumer (terminal title, a resume hint, the Feed) can
7
+ * reuse it without importing cmux vocabulary.
8
+ */
9
+ /** Extract a ticket code from a prompt, or null when none is present. */
10
+ export declare function ticketCode(prompt: string): string | null;
11
+ /** The heuristic fallback title: the first 8 words, capped at 60 chars. */
12
+ export declare function titleFromPrompt(prompt: string): string | undefined;
13
+ /**
14
+ * Compose a session title from a prompt, prepending the ticket code when one is
15
+ * present. The code is stripped from the derived body so it doesn't appear
16
+ * twice ("YAG-532: work on YAG-532 …").
17
+ */
18
+ export declare function sessionTitle(prompt: string, body?: string): string | undefined;
19
+ /**
20
+ * Normalize a model-produced title into the same shape `sessionTitle` emits.
21
+ * Returns undefined when the model's output is empty, whitespace, or otherwise
22
+ * unusable — the caller then falls back to the heuristic.
23
+ */
24
+ export declare function parseSummarizedTitle(prompt: string, raw: string): string | undefined;
25
+ /** The maximum chars `parseSummarizedTitle` may return (the same cap). */
26
+ export declare const SESSION_TITLE_MAX_LEN = 60;
27
+ //# sourceMappingURL=title.d.ts.map
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Pure session-title derivation — the reusable half of naming an agent session.
3
+ * No cmux, no workspace, no I/O: just "given a prompt, what's a good title".
4
+ *
5
+ * cmux is the current (and only) consumer, but this module is deliberately
6
+ * cmux-free so a future consumer (terminal title, a resume hint, the Feed) can
7
+ * reuse it without importing cmux vocabulary.
8
+ */
9
+ /** The ticket-code shape ("YAG-532", "PROJ-123"), matching the backend's IDENTIFIER_RE. */
10
+ const TICKET_ID_RE = /\b[A-Z][A-Z0-9]*-\d+\b/i;
11
+ const MAX_TITLE_LEN = 60;
12
+ /** Extract a ticket code from a prompt, or null when none is present. */
13
+ export function ticketCode(prompt) {
14
+ return prompt.match(TICKET_ID_RE)?.[0]?.toUpperCase() ?? null;
15
+ }
16
+ /** The heuristic fallback title: the first 8 words, capped at 60 chars. */
17
+ export function titleFromPrompt(prompt) {
18
+ const words = prompt.trim().split(/\s+/).filter(Boolean);
19
+ if (words.length === 0)
20
+ return undefined;
21
+ return words.slice(0, 8).join(" ").slice(0, MAX_TITLE_LEN) || undefined;
22
+ }
23
+ /**
24
+ * Compose a session title from a prompt, prepending the ticket code when one is
25
+ * present. The code is stripped from the derived body so it doesn't appear
26
+ * twice ("YAG-532: work on YAG-532 …").
27
+ */
28
+ export function sessionTitle(prompt, body) {
29
+ const code = ticketCode(prompt);
30
+ // Derive the body from the prompt with the ticket code removed (unless the
31
+ // caller supplied an already-summarized body, in which case prefer it).
32
+ const effectiveBody = body ?? titleFromPrompt(code ? prompt.replace(TICKET_ID_RE, "") : prompt);
33
+ if (!code)
34
+ return effectiveBody;
35
+ if (!effectiveBody)
36
+ return code;
37
+ return `${code}: ${effectiveBody}`.slice(0, MAX_TITLE_LEN);
38
+ }
39
+ /**
40
+ * Normalize a model-produced title into the same shape `sessionTitle` emits.
41
+ * Returns undefined when the model's output is empty, whitespace, or otherwise
42
+ * unusable — the caller then falls back to the heuristic.
43
+ */
44
+ export function parseSummarizedTitle(prompt, raw) {
45
+ const cleaned = raw.trim().replace(/\s+/g, " ").replace(/^["']|["']$/g, "");
46
+ if (!cleaned)
47
+ return undefined;
48
+ // Strip any ticket-code token the model echoed back into its summary (the
49
+ // prompt carries the code, and models repeat it) so the composed title never
50
+ // doubles the prefix ("YAG-485: YAG-485 …"). Deterministic, not prompt hygiene.
51
+ const code = ticketCode(prompt);
52
+ const body = code ? cleaned.replace(TICKET_ID_RE, "").trim().replace(/\s+/g, " ") : cleaned;
53
+ return sessionTitle(prompt, body);
54
+ }
55
+ /** The maximum chars `parseSummarizedTitle` may return (the same cap). */
56
+ export const SESSION_TITLE_MAX_LEN = MAX_TITLE_LEN;
57
+ //# sourceMappingURL=title.js.map
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Silent-turn reminder — the harness fix for YAG-574.
3
+ *
4
+ * The failure behind that ticket was a termination failure, not a discovery
5
+ * one: the model had assembled the full answer in thinking, re-derived it 15
6
+ * times, and never converted "I have an answer" into "the turn ends." Nothing
7
+ * in the harness nudges it across that boundary when it goes quiet.
8
+ *
9
+ * This module is that nudge. It counts assistant turns and wall-clock time
10
+ * since the model last *spoke* (a non-empty visible text block, or a call to a
11
+ * user-facing tool), and when that silence passes a configurable threshold it
12
+ * appends a one-line reminder to the next tool result — the same
13
+ * result-modification seam the todos staleness reminder and ambient recall
14
+ * already ride, so it reaches the model mid-run without spending a turn.
15
+ *
16
+ * Structure mirrors Claude Code's `silent_turn_reminder` (the problem is real
17
+ * and already solved upstream): same trigger semantics, same single-fixed-
18
+ * sentence escalation, same "suppress on the turn after a user message" rule.
19
+ * Two deliberate deviations, both justified on the ticket:
20
+ * - trigger is wall-clock (90s default), not turn-count, because turn-count
21
+ * thresholds are tuned to a ~5s turn and ours drifted to ~34s;
22
+ * - no per-stretch nudge cap, because the cap is the exact thing that silences
23
+ * the mechanism during the silence it exists for.
24
+ */
25
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
26
+ /**
27
+ * Default silence trigger, in milliseconds. Calibrated (see the ticket's
28
+ * trigger table) to nudge at roughly Claude Code's on-observed density while
29
+ * still covering the broken session's 25-minute dead zone.
30
+ */
31
+ export declare const DEFAULT_SILENT_INTERVAL_MS = 90000;
32
+ /**
33
+ * Minimum assistant turns of silence before a reminder is eligible. Guards a
34
+ * single long tool call from counting as "the user is being ignored" when the
35
+ * model simply spent a while on one tool.
36
+ */
37
+ export declare const MIN_SILENT_TURNS = 2;
38
+ /**
39
+ * Tools that count as "speaking to the user." Claude Code's whitelist maps to
40
+ * `AskUserQuestion`, `SendUserMessage`, `Brief`, `ExitPlanMode`,
41
+ * `SendUserFile`; ours are the three tools that surface something directly to
42
+ * the human this session serves. A call to one of these is a substantive
43
+ * utterance even when the assistant emitted no visible text block.
44
+ */
45
+ export declare const USER_FACING_TOOLS: Set<string>;
46
+ export declare const SILENT_REMINDER_ENV = "YAGNI_SILENT_TURN_REMINDER";
47
+ export declare const SILENT_REMINDER_SECONDS_ENV = "YAGNI_SILENT_TURN_REMINDER_SECONDS";
48
+ /** Disable the whole mechanism ("0"/"off"), or resolve the trigger override. */
49
+ export declare function silentReminderDisabled(env?: NodeJS.ProcessEnv): boolean;
50
+ /** Resolve the trigger interval: env seconds override wins, else the default. */
51
+ export declare function resolveSilentIntervalMs(env?: NodeJS.ProcessEnv): number;
52
+ /** The minimal slice of an assistant message the reminder logic cares about. */
53
+ interface AssistantLike {
54
+ content?: Array<{
55
+ type?: string;
56
+ text?: string;
57
+ name?: string;
58
+ }>;
59
+ }
60
+ /**
61
+ * PURE: whether the assistant emitted any non-empty, visible text block this
62
+ * turn. `thinking` blocks deliberately do NOT count — 525k chars of thinking
63
+ * is still silence to the user.
64
+ */
65
+ export declare function hasSubstantiveText(message: AssistantLike): boolean;
66
+ /**
67
+ * PURE: whether the assistant "spoke" this turn — visible text, OR a call to a
68
+ * user-facing tool (which is itself an utterance to the user, even without a
69
+ * text block).
70
+ */
71
+ export declare function didSpeak(message: AssistantLike): boolean;
72
+ /**
73
+ * PURE: is a reminder due? The turn count must pass {@link MIN_SILENT_TURNS},
74
+ * the wall-clock silence must pass the configured interval, and the spacing
75
+ * between nudges must pass the same interval. `lastNudgeAt` is `null` before
76
+ * any nudge has fired (still eligible once the other gates pass).
77
+ */
78
+ export declare function shouldRemind(input: {
79
+ msSinceSpoke: number;
80
+ turnsSinceSpoke: number;
81
+ msSinceLastNudge: number | null;
82
+ intervalMs: number;
83
+ }): boolean;
84
+ /**
85
+ * The injected payload. Claude Code's wording plus one clause for the failure
86
+ * we actually saw (the model holding an assembled answer and never giving it).
87
+ * Wrapped in `<system-reminder>` tags — the framing branding.ts teaches the
88
+ * model to parse as system-injected, not part of the tool output.
89
+ */
90
+ export declare function formatSilentReminder(): string;
91
+ export interface RegisterSilentTurnReminderDeps {
92
+ /** Only the interactive driver is nudged; children/subagents/advisor/no-op. */
93
+ isDriver?: boolean;
94
+ /** Eval mode (headless evals, scoping sessions) never nudges. */
95
+ evalMode?: boolean;
96
+ /** Env seam (defaults to process.env). */
97
+ env?: NodeJS.ProcessEnv;
98
+ /** Clock seam (defaults to Date.now). */
99
+ now?: () => number;
100
+ }
101
+ /**
102
+ * Wire the silent-turn reminder. No handlers are registered for non-driver
103
+ * processes, eval mode, or when disabled — so children, subagents, advisor
104
+ * consults, and headless evals are untouched, and the kill switch is a true
105
+ * no-op.
106
+ */
107
+ export declare function registerSilentTurnReminder(pi: ExtensionAPI, deps?: RegisterSilentTurnReminderDeps): void;
108
+ export {};
109
+ //# sourceMappingURL=silentTurnReminder.d.ts.map
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Silent-turn reminder — the harness fix for YAG-574.
3
+ *
4
+ * The failure behind that ticket was a termination failure, not a discovery
5
+ * one: the model had assembled the full answer in thinking, re-derived it 15
6
+ * times, and never converted "I have an answer" into "the turn ends." Nothing
7
+ * in the harness nudges it across that boundary when it goes quiet.
8
+ *
9
+ * This module is that nudge. It counts assistant turns and wall-clock time
10
+ * since the model last *spoke* (a non-empty visible text block, or a call to a
11
+ * user-facing tool), and when that silence passes a configurable threshold it
12
+ * appends a one-line reminder to the next tool result — the same
13
+ * result-modification seam the todos staleness reminder and ambient recall
14
+ * already ride, so it reaches the model mid-run without spending a turn.
15
+ *
16
+ * Structure mirrors Claude Code's `silent_turn_reminder` (the problem is real
17
+ * and already solved upstream): same trigger semantics, same single-fixed-
18
+ * sentence escalation, same "suppress on the turn after a user message" rule.
19
+ * Two deliberate deviations, both justified on the ticket:
20
+ * - trigger is wall-clock (90s default), not turn-count, because turn-count
21
+ * thresholds are tuned to a ~5s turn and ours drifted to ~34s;
22
+ * - no per-stretch nudge cap, because the cap is the exact thing that silences
23
+ * the mechanism during the silence it exists for.
24
+ */
25
+ import { logTurnLifecycle } from "./turnLog.js";
26
+ /**
27
+ * Default silence trigger, in milliseconds. Calibrated (see the ticket's
28
+ * trigger table) to nudge at roughly Claude Code's on-observed density while
29
+ * still covering the broken session's 25-minute dead zone.
30
+ */
31
+ export const DEFAULT_SILENT_INTERVAL_MS = 90_000;
32
+ /**
33
+ * Minimum assistant turns of silence before a reminder is eligible. Guards a
34
+ * single long tool call from counting as "the user is being ignored" when the
35
+ * model simply spent a while on one tool.
36
+ */
37
+ export const MIN_SILENT_TURNS = 2;
38
+ /**
39
+ * Tools that count as "speaking to the user." Claude Code's whitelist maps to
40
+ * `AskUserQuestion`, `SendUserMessage`, `Brief`, `ExitPlanMode`,
41
+ * `SendUserFile`; ours are the three tools that surface something directly to
42
+ * the human this session serves. A call to one of these is a substantive
43
+ * utterance even when the assistant emitted no visible text block.
44
+ */
45
+ export const USER_FACING_TOOLS = new Set([
46
+ "todo_write",
47
+ "file_ticket",
48
+ "update_ticket_status",
49
+ ]);
50
+ export const SILENT_REMINDER_ENV = "YAGNI_SILENT_TURN_REMINDER";
51
+ export const SILENT_REMINDER_SECONDS_ENV = "YAGNI_SILENT_TURN_REMINDER_SECONDS";
52
+ /** Disable the whole mechanism ("0"/"off"), or resolve the trigger override. */
53
+ export function silentReminderDisabled(env = process.env) {
54
+ const v = env[SILENT_REMINDER_ENV];
55
+ return v === "0" || v?.toLowerCase() === "off";
56
+ }
57
+ /** Resolve the trigger interval: env seconds override wins, else the default. */
58
+ export function resolveSilentIntervalMs(env = process.env) {
59
+ const raw = env[SILENT_REMINDER_SECONDS_ENV];
60
+ if (raw === undefined || raw.trim() === "")
61
+ return DEFAULT_SILENT_INTERVAL_MS;
62
+ const seconds = Number(raw);
63
+ if (!Number.isFinite(seconds) || seconds <= 0)
64
+ return DEFAULT_SILENT_INTERVAL_MS;
65
+ return Math.round(seconds * 1000);
66
+ }
67
+ /**
68
+ * PURE: whether the assistant emitted any non-empty, visible text block this
69
+ * turn. `thinking` blocks deliberately do NOT count — 525k chars of thinking
70
+ * is still silence to the user.
71
+ */
72
+ export function hasSubstantiveText(message) {
73
+ const content = message.content ?? [];
74
+ return content.some((c) => c.type === "text" && typeof c.text === "string" && c.text.trim().length > 0);
75
+ }
76
+ /**
77
+ * PURE: whether the assistant "spoke" this turn — visible text, OR a call to a
78
+ * user-facing tool (which is itself an utterance to the user, even without a
79
+ * text block).
80
+ */
81
+ export function didSpeak(message) {
82
+ const content = message.content ?? [];
83
+ for (const c of content) {
84
+ if (c.type === "text" && typeof c.text === "string" && c.text.trim().length > 0) {
85
+ return true;
86
+ }
87
+ if (c.type === "toolCall" && typeof c.name === "string" && USER_FACING_TOOLS.has(c.name)) {
88
+ return true;
89
+ }
90
+ }
91
+ return false;
92
+ }
93
+ /**
94
+ * PURE: is a reminder due? The turn count must pass {@link MIN_SILENT_TURNS},
95
+ * the wall-clock silence must pass the configured interval, and the spacing
96
+ * between nudges must pass the same interval. `lastNudgeAt` is `null` before
97
+ * any nudge has fired (still eligible once the other gates pass).
98
+ */
99
+ export function shouldRemind(input) {
100
+ const { msSinceSpoke, turnsSinceSpoke, msSinceLastNudge, intervalMs } = input;
101
+ if (turnsSinceSpoke < MIN_SILENT_TURNS)
102
+ return false;
103
+ if (msSinceSpoke < intervalMs)
104
+ return false;
105
+ return msSinceLastNudge === null || msSinceLastNudge >= intervalMs;
106
+ }
107
+ /**
108
+ * The injected payload. Claude Code's wording plus one clause for the failure
109
+ * we actually saw (the model holding an assembled answer and never giving it).
110
+ * Wrapped in `<system-reminder>` tags — the framing branding.ts teaches the
111
+ * model to parse as system-injected, not part of the tool output.
112
+ */
113
+ export function formatSilentReminder() {
114
+ return ("<system-reminder>\n" +
115
+ "The user hasn't heard from you in a while. As you continue, keep them updated " +
116
+ "when there's something to tell — a finding, a change of plan. If you already " +
117
+ "have an answer, give it now with your confidence and what's still open.\n" +
118
+ "</system-reminder>");
119
+ }
120
+ /**
121
+ * Wire the silent-turn reminder. No handlers are registered for non-driver
122
+ * processes, eval mode, or when disabled — so children, subagents, advisor
123
+ * consults, and headless evals are untouched, and the kill switch is a true
124
+ * no-op.
125
+ */
126
+ export function registerSilentTurnReminder(pi, deps = {}) {
127
+ const env = deps.env ?? process.env;
128
+ if (deps.evalMode)
129
+ return;
130
+ if (deps.isDriver === false)
131
+ return;
132
+ if (silentReminderDisabled(env))
133
+ return;
134
+ const now = deps.now ?? (() => Date.now());
135
+ const intervalMs = resolveSilentIntervalMs(env);
136
+ let lastSpokeAt = now();
137
+ let turnsSinceSpoke = 0;
138
+ let nudgeCount = 0; // per-stretch ordinal, reset when the silence clock resets
139
+ let lastNudgeAt = null;
140
+ const reset = (at) => {
141
+ lastSpokeAt = at;
142
+ turnsSinceSpoke = 0;
143
+ nudgeCount = 0;
144
+ };
145
+ // One tick per finalized assistant message: did it speak (reset the clock),
146
+ // or not (extend the silence)? The observability counters are logged here so
147
+ // the recorded values are identical to the ones that drive the decision.
148
+ pi.on("message_end", async (event) => {
149
+ try {
150
+ const msg = event.message;
151
+ if (msg?.role !== "assistant")
152
+ return;
153
+ const spoke = didSpeak(msg);
154
+ if (spoke) {
155
+ reset(now());
156
+ }
157
+ else {
158
+ turnsSinceSpoke += 1;
159
+ }
160
+ logTurnLifecycle({
161
+ kind: "silent_turn",
162
+ secondsSinceSpoke: Math.round((now() - lastSpokeAt) / 1000),
163
+ turnsSinceSpoke,
164
+ });
165
+ }
166
+ catch {
167
+ // A reminder must never break a turn.
168
+ }
169
+ });
170
+ // Any user message resets the silence clock — the "suppressed on the turn
171
+ // right after a user message" rule, implemented as a clock reset.
172
+ pi.on("input", async () => {
173
+ try {
174
+ reset(now());
175
+ }
176
+ catch {
177
+ // Never break input handling.
178
+ }
179
+ });
180
+ // The nudge itself rides an existing tool result. Never appended to a
181
+ // user-facing tool's own result (a file_ticket/todo_write already IS
182
+ // speaking), and fail-soft: a reminder must never break a tool call.
183
+ pi.on("tool_result", async (event) => {
184
+ try {
185
+ if (event.isError)
186
+ return;
187
+ if (event.toolName === "todo_write" || event.toolName === "file_ticket" || event.toolName === "update_ticket_status") {
188
+ return;
189
+ }
190
+ if (!shouldRemind({
191
+ msSinceSpoke: now() - lastSpokeAt,
192
+ turnsSinceSpoke,
193
+ msSinceLastNudge: lastNudgeAt === null ? null : now() - lastNudgeAt,
194
+ intervalMs,
195
+ })) {
196
+ return;
197
+ }
198
+ nudgeCount += 1;
199
+ lastNudgeAt = now();
200
+ logTurnLifecycle({
201
+ kind: "silent_turn_nudge",
202
+ nudgesInStretch: nudgeCount,
203
+ secondsSinceSpoke: Math.round((now() - lastSpokeAt) / 1000),
204
+ });
205
+ return {
206
+ content: [
207
+ ...event.content,
208
+ { type: "text", text: `\n\n${formatSilentReminder()}` },
209
+ ],
210
+ };
211
+ }
212
+ catch {
213
+ return;
214
+ }
215
+ });
216
+ }
217
+ //# sourceMappingURL=silentTurnReminder.js.map
@@ -26,6 +26,20 @@ export type TurnLifecycleEvent = {
26
26
  stopReason?: string;
27
27
  /** Time since the matching turn_start, ms. */
28
28
  elapsedMs?: number;
29
+ } | {
30
+ kind: "silent_turn";
31
+ sessionId?: string;
32
+ /** Whole seconds since the model last spoke (visible text or a user-facing tool). */
33
+ secondsSinceSpoke?: number;
34
+ /** Assistant turns since the model last spoke. */
35
+ turnsSinceSpoke?: number;
36
+ } | {
37
+ kind: "silent_turn_nudge";
38
+ sessionId?: string;
39
+ /** 1-indexed fire count within the current silence stretch. */
40
+ nudgesInStretch?: number;
41
+ /** Whole seconds of silence at the moment the nudge fired. */
42
+ secondsSinceSpoke?: number;
29
43
  };
30
44
  /**
31
45
  * Append one sanitized lifecycle record. Fail-soft — a logging failure must
@@ -64,9 +64,13 @@ export function logTurnLifecycle(ev) {
64
64
  ts: new Date().toISOString(),
65
65
  event: ev.kind,
66
66
  ...(ev.sessionId ? { sessionId: ev.sessionId } : {}),
67
- ...(ev.turnIndex !== undefined ? { turnIndex: ev.turnIndex } : {}),
67
+ ...((ev.kind === "turn_start" || ev.kind === "turn_end") && ev.turnIndex !== undefined ? { turnIndex: ev.turnIndex } : {}),
68
68
  ...(ev.kind === "turn_end" && ev.stopReason !== undefined ? { stopReason: ev.stopReason } : {}),
69
69
  ...(ev.kind === "turn_end" && ev.elapsedMs !== undefined ? { elapsedMs: ev.elapsedMs } : {}),
70
+ ...(ev.kind === "silent_turn" && ev.secondsSinceSpoke !== undefined ? { secondsSinceSpoke: ev.secondsSinceSpoke } : {}),
71
+ ...(ev.kind === "silent_turn" && ev.turnsSinceSpoke !== undefined ? { turnsSinceSpoke: ev.turnsSinceSpoke } : {}),
72
+ ...(ev.kind === "silent_turn_nudge" && ev.nudgesInStretch !== undefined ? { nudgesInStretch: ev.nudgesInStretch } : {}),
73
+ ...(ev.kind === "silent_turn_nudge" && ev.secondsSinceSpoke !== undefined ? { secondsSinceSpoke: ev.secondsSinceSpoke } : {}),
70
74
  };
71
75
  const path = turnLogPath();
72
76
  mkdirSync(dirname(path), { recursive: true });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yagni-app/code",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
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)",
@@ -37,7 +37,7 @@
37
37
  "@earendil-works/pi-coding-agent": "0.84.1",
38
38
  "@earendil-works/pi-tui": "0.84.1",
39
39
  "smol-toml": "^1.8.0",
40
- "typebox": "^1.3.11"
40
+ "typebox": "^1.3.15"
41
41
  },
42
- "yagniSourceSha": "36ebf50cf01b962741558ccbf04485ef2231d1e1"
42
+ "yagniSourceSha": "0b9558cffa5fe5c8163a6911d2c5695180f336f2"
43
43
  }
@@ -1,5 +0,0 @@
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
@@ -1,23 +0,0 @@
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