@yagni-app/code-staging 0.3.0-staging.1090.1 → 0.3.0-staging.1093.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.
@@ -30,7 +30,7 @@ import { type Component } from "@earendil-works/pi-tui";
30
30
  import { Type } from "typebox";
31
31
  import { type AdvisorLimits, type AdvisorStateHandle } from "./advisor.js";
32
32
  import { runStage as defaultRunStage } from "./pipeline/runner.js";
33
- import type { PipelineStage } from "./pipeline/types.js";
33
+ import { type PipelineStage } from "./pipeline/types.js";
34
34
  import { type RenderTheme, type SubagentTaskProgress } from "./subagentRender.js";
35
35
  /**
36
36
  * Read-only recon plus grounding. Mirrors the `plan` stage's allowlist for the
@@ -29,7 +29,9 @@ import { Text } from "@earendil-works/pi-tui";
29
29
  import { Type } from "typebox";
30
30
  import { ADVISOR_MODEL_TIER, DEFAULT_ADVISOR_LIMITS, decideConsult, formatConsultCost, } from "./advisor.js";
31
31
  import { SPINNER_FRAMES } from "./pipeline/activityFeed.js";
32
+ import { withResilience } from "./pipeline/resilience.js";
32
33
  import { runStage as defaultRunStage } from "./pipeline/runner.js";
34
+ import { DEFAULT_RESILIENCE_POLICY } from "./pipeline/types.js";
33
35
  import { applyChildEvent, finalizeTask, formatWorkingMessage, newTaskProgress, progressSummaryText, receiptLine, renderSubagentResult, runningLines, } from "./subagentRender.js";
34
36
  /**
35
37
  * Read-only recon plus grounding. Mirrors the `plan` stage's allowlist for the
@@ -94,7 +96,13 @@ export function renderAdvisorCall(args, theme, _context) {
94
96
  }
95
97
  export function makeAskAdvisorTool(opts) {
96
98
  const limits = opts.limits ?? DEFAULT_ADVISOR_LIMITS;
97
- const runStage = opts.runStage ?? defaultRunStage;
99
+ // The default runner rides the /go pipeline's resilience wrapper, exactly
100
+ // like the subagent tool: idle + wall-clock ceilings and transient-only
101
+ // retry, so a stalled consult child aborts honestly instead of hanging the
102
+ // driver's tool call until the user presses Esc. The synthetic stage id is
103
+ // "plan" (read-only tools, no bash), so the wrapper's write-gate never
104
+ // blocks a retry — re-running a consult cannot double-apply anything.
105
+ const runStage = opts.runStage ?? withResilience(defaultRunStage, DEFAULT_RESILIENCE_POLICY);
98
106
  return {
99
107
  name: "ask_advisor",
100
108
  label: "Ask the advisor",
@@ -47,7 +47,7 @@ export declare const ULTRA_DELEGATION_PARAGRAPH: string;
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
49
  /** The driver identity while /ultra is on: base identity + the diamond directive. */
50
- 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.";
50
+ 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
51
  export declare const PI_IDENTITY_RE: RegExp;
52
52
  /**
53
53
  * Env switch that bypasses the system-prompt rewrite entirely, so pi's
@@ -64,7 +64,9 @@ export const ULTRA_DELEGATION_PARAGRAPH = "Delegation (ultra mode): the user has
64
64
  "agreement between checkers — not a single pass — as confirmation, and " +
65
65
  "surface what they could not verify. Delegate by default and reserve this " +
66
66
  "session for splitting, judging, and synthesis; only trivial work you can " +
67
- "finish in a couple of tool calls skips the diamond.";
67
+ "finish in a couple of tool calls skips the diamond. Subagents cannot touch " +
68
+ "your todo_write checklist, so keep it current yourself: update it when you " +
69
+ "split the job and again as each fanned-out piece lands, not only at the end.";
68
70
  /**
69
71
  * The identity used for the interactive DRIVER session ONLY: {@link
70
72
  * YAGNI_IDENTITY} plus {@link DRIVER_DELEGATION_PARAGRAPH}. The caller (index.ts)
@@ -29,7 +29,7 @@
29
29
  */
30
30
  import { runStage as defaultRunStage } from "./pipeline/runner.js";
31
31
  export const DEFAULT_GUARDIAN_LIMITS = {
32
- maxReviews: 30,
32
+ maxReviews: 120,
33
33
  maxConsecutiveDenials: 3,
34
34
  timeoutMs: 15_000,
35
35
  };
@@ -143,7 +143,7 @@ export type { ComparisonReport, LaneFit, LaneOutcome } from "./pipeline/eval.js"
143
143
  export { registerGoCompareCommand } from "./pipeline/goCompareCommand.js";
144
144
  export { registerSubagents, makeSubagentTool, discoverSubagents, parseAgentMarkdown, buildSubagentStage, formatAgentList, mapModelTier, SUBAGENT_TOOL_NAME, GENERAL_AGENT_NAME, MAX_PARALLEL_SUBAGENTS, MAX_PARALLEL_SUBAGENTS_ULTRA, DEFAULT_SUBAGENT_TOOLS, } from "./subagents.js";
145
145
  export type { SubagentDef, SubagentSource } from "./subagents.js";
146
- export { registerTodos, makeTodoTool, normalizeTodos, reconstructTodos, renderTodoWidget, formatTodoList, todoSummary, TODO_TOOL_NAME, MAX_TODOS, } from "./todos.js";
146
+ export { registerTodos, makeTodoTool, normalizeTodos, reconstructTodos, renderTodoWidget, formatTodoList, formatTodoReminder, shouldRemindTodos, todoSummary, TODO_TOOL_NAME, TODO_REMINDER_TURNS, MAX_TODOS, } from "./todos.js";
147
147
  export type { TodoItem, TodoStatus, TodoTheme } from "./todos.js";
148
148
  export { decideGate, registerPermissionGate, filterStaleModeContext, filterStalePlanContext, buildModeContextMessage, DEFAULT_PERMISSION_POLICY, MODE_CONTEXT_TYPE, PLAN_CONTEXT_TYPE, PLAN_CONTEXT_MESSAGE, } from "./permission.js";
149
149
  export { classifyCommand, DEFAULT_EXEC_POLICY, } from "./execPolicy.js";
@@ -814,7 +814,7 @@ export { registerGoCompareCommand } from "./pipeline/goCompareCommand.js";
814
814
  // The general subagent tool: Claude Code-format agent discovery + fan-out.
815
815
  export { registerSubagents, makeSubagentTool, discoverSubagents, parseAgentMarkdown, buildSubagentStage, formatAgentList, mapModelTier, SUBAGENT_TOOL_NAME, GENERAL_AGENT_NAME, MAX_PARALLEL_SUBAGENTS, MAX_PARALLEL_SUBAGENTS_ULTRA, DEFAULT_SUBAGENT_TOOLS, } from "./subagents.js";
816
816
  // The session todo checklist: todo_write tool, widget renderer, /todos.
817
- export { registerTodos, makeTodoTool, normalizeTodos, reconstructTodos, renderTodoWidget, formatTodoList, todoSummary, TODO_TOOL_NAME, MAX_TODOS, } from "./todos.js";
817
+ export { registerTodos, makeTodoTool, normalizeTodos, reconstructTodos, renderTodoWidget, formatTodoList, formatTodoReminder, shouldRemindTodos, todoSummary, TODO_TOOL_NAME, TODO_REMINDER_TURNS, MAX_TODOS, } from "./todos.js";
818
818
  // P3 + W4: the permission gate seam (decideGate is pure; policy injectable) plus
819
819
  // the session bless-with-remember capture hook.
820
820
  export { decideGate, registerPermissionGate, filterStaleModeContext, filterStalePlanContext, buildModeContextMessage, DEFAULT_PERMISSION_POLICY, MODE_CONTEXT_TYPE, PLAN_CONTEXT_TYPE, PLAN_CONTEXT_MESSAGE, } from "./permission.js";
@@ -4,7 +4,8 @@
4
4
  * `withResilience(runStage, policy)` is a composable higher-order function that
5
5
  * wraps the per-stage child spawn (`runner.ts#runStage`) with the one axis the
6
6
  * roadmap calls the whole competitive gap: a per-stage IDLE timeout (no NDJSON
7
- * event for N ms) and a total WALL-CLOCK timeout, both firing the runner's
7
+ * event for N ms, deferred while a tool is in flight see the stall note at
8
+ * the timer wiring) and a total WALL-CLOCK timeout, both firing the runner's
8
9
  * existing SIGTERM -> SIGKILL abort; bounded exponential backoff with jitter; and
9
10
  * retry of CLASSIFIED-TRANSIENT outcomes only. One structured telemetry record is
10
11
  * emitted per attempt.
@@ -4,7 +4,8 @@
4
4
  * `withResilience(runStage, policy)` is a composable higher-order function that
5
5
  * wraps the per-stage child spawn (`runner.ts#runStage`) with the one axis the
6
6
  * roadmap calls the whole competitive gap: a per-stage IDLE timeout (no NDJSON
7
- * event for N ms) and a total WALL-CLOCK timeout, both firing the runner's
7
+ * event for N ms, deferred while a tool is in flight see the stall note at
8
+ * the timer wiring) and a total WALL-CLOCK timeout, both firing the runner's
8
9
  * existing SIGTERM -> SIGKILL abort; bounded exponential backoff with jitter; and
9
10
  * retry of CLASSIFIED-TRANSIENT outcomes only. One structured telemetry record is
10
11
  * emitted per attempt.
@@ -100,19 +101,37 @@ export function withResilience(base, policy, opts = {}) {
100
101
  if (!timeoutController.signal.aborted)
101
102
  timeoutController.abort();
102
103
  };
104
+ // Tools the child has started but not finished. The idle window measures
105
+ // STALL, not silence: a long quiet tool (a 6-minute test suite, a slow
106
+ // build) emits no NDJSON between its start and end events, and that is
107
+ // progress, not a hang. While a tool is in flight the idle expiry defers
108
+ // and re-arms instead of aborting; the wall-clock timer stays the
109
+ // backstop for a tool that is genuinely hung.
110
+ let inFlightTools = 0;
103
111
  let idleTimer;
104
112
  const armIdle = () => {
105
113
  if (idleTimer)
106
114
  clearTimeout(idleTimer);
107
- idleTimer = setTimeout(fireTimeout, policy.idleTimeoutMs);
115
+ idleTimer = setTimeout(fireIdle, policy.idleTimeoutMs);
108
116
  idleTimer.unref?.();
109
117
  };
118
+ const fireIdle = () => {
119
+ if (inFlightTools > 0) {
120
+ armIdle();
121
+ return;
122
+ }
123
+ fireTimeout();
124
+ };
110
125
  const wallTimer = setTimeout(fireTimeout, policy.wallTimeoutMs);
111
126
  wallTimer.unref?.();
112
127
  armIdle();
113
128
  const originalOnEvent = deps.onEvent;
114
129
  const onEvent = (ev) => {
115
130
  sawAnyEvent = true;
131
+ if (ev.type === "tool_execution_start")
132
+ inFlightTools += 1;
133
+ else if (ev.type === "tool_execution_end")
134
+ inFlightTools = Math.max(0, inFlightTools - 1);
116
135
  armIdle(); // reset the idle window on every live event
117
136
  originalOnEvent?.(ev);
118
137
  };
@@ -17,6 +17,16 @@ import { Type } from "typebox";
17
17
  export declare const TODO_TOOL_NAME = "todo_write";
18
18
  export declare const MAX_TODOS = 50;
19
19
  export declare const MAX_TODO_TEXT = 300;
20
+ /**
21
+ * Staleness-reminder throttle (both counters must trip): a reminder is
22
+ * eligible only after this many assistant turns since the last todo_write AND
23
+ * this many since the last reminder. The two-counter shape (staleness gate +
24
+ * anti-spam gate) mirrors what Claude Code ships for its own todo tool; the
25
+ * driver model routinely stops updating the board mid-grind (the frozen
26
+ * "Todos 0/8" report), and a bare description-level instruction does not
27
+ * survive a long run.
28
+ */
29
+ export declare const TODO_REMINDER_TURNS = 10;
20
30
  /**
21
31
  * The desktop's structured state record rides its own widget key, like the
22
32
  * `/go` run state: one JSON line the app parses and renders itself, never
@@ -61,6 +71,23 @@ export interface TodoTheme {
61
71
  export declare function renderTodoWidget(todos: TodoItem[], theme: TodoTheme): string[];
62
72
  /** The desktop state record: exactly one JSON line under TODO_STATE_KEY. */
63
73
  export declare function todoStateLine(todos: TodoItem[]): string;
74
+ /**
75
+ * PURE: is a staleness reminder due? Only when the board has open work (an
76
+ * empty or fully-completed list never nags) and BOTH throttle counters have
77
+ * reached {@link TODO_REMINDER_TURNS}.
78
+ */
79
+ export declare function shouldRemindTodos(input: {
80
+ todos: TodoItem[];
81
+ turnsSinceWrite: number;
82
+ turnsSinceReminder: number;
83
+ }): boolean;
84
+ /**
85
+ * PURE: the hedged reminder block appended to a tool result when the board has
86
+ * gone stale. Carries the CURRENT list so the model can reconcile without a
87
+ * read, and explicitly licenses ignoring it, so an accurate board costs one
88
+ * glance rather than a spurious todo_write.
89
+ */
90
+ export declare function formatTodoReminder(todos: TodoItem[]): string;
64
91
  /** Replay the branch: the last todo_write result is the canonical list. */
65
92
  export declare function reconstructTodos(entries: unknown[]): TodoItem[];
66
93
  type TodoParams = {
@@ -104,7 +131,7 @@ export declare function makeTodoTool(get: () => TodoItem[], set: (todos: TodoIte
104
131
  isError?: undefined;
105
132
  }>;
106
133
  };
107
- /** Wire the tool, the branch-replay events, and the /todos command. */
134
+ /** Wire the tool, the branch-replay events, the staleness reminder, and /todos. */
108
135
  export declare function registerTodos(pi: ExtensionAPI): void;
109
136
  export {};
110
137
  //# sourceMappingURL=todos.d.ts.map
@@ -17,6 +17,16 @@ import { isDesktopSurface } from "./surface.js";
17
17
  export const TODO_TOOL_NAME = "todo_write";
18
18
  export const MAX_TODOS = 50;
19
19
  export const MAX_TODO_TEXT = 300;
20
+ /**
21
+ * Staleness-reminder throttle (both counters must trip): a reminder is
22
+ * eligible only after this many assistant turns since the last todo_write AND
23
+ * this many since the last reminder. The two-counter shape (staleness gate +
24
+ * anti-spam gate) mirrors what Claude Code ships for its own todo tool; the
25
+ * driver model routinely stops updating the board mid-grind (the frozen
26
+ * "Todos 0/8" report), and a bare description-level instruction does not
27
+ * survive a long run.
28
+ */
29
+ export const TODO_REMINDER_TURNS = 10;
20
30
  const WIDGET_KEY = "yagni-todos";
21
31
  /**
22
32
  * The desktop's structured state record rides its own widget key, like the
@@ -101,6 +111,33 @@ export function renderTodoWidget(todos, theme) {
101
111
  export function todoStateLine(todos) {
102
112
  return JSON.stringify({ v: 1, todos });
103
113
  }
114
+ /**
115
+ * PURE: is a staleness reminder due? Only when the board has open work (an
116
+ * empty or fully-completed list never nags) and BOTH throttle counters have
117
+ * reached {@link TODO_REMINDER_TURNS}.
118
+ */
119
+ export function shouldRemindTodos(input) {
120
+ const { todos, turnsSinceWrite, turnsSinceReminder } = input;
121
+ if (todos.length === 0)
122
+ return false;
123
+ const { done, total } = todoSummary(todos);
124
+ if (done === total)
125
+ return false;
126
+ return turnsSinceWrite >= TODO_REMINDER_TURNS && turnsSinceReminder >= TODO_REMINDER_TURNS;
127
+ }
128
+ /**
129
+ * PURE: the hedged reminder block appended to a tool result when the board has
130
+ * gone stale. Carries the CURRENT list so the model can reconcile without a
131
+ * read, and explicitly licenses ignoring it, so an accurate board costs one
132
+ * glance rather than a spurious todo_write.
133
+ */
134
+ export function formatTodoReminder(todos) {
135
+ return ("⟦YAGNI todos⟧ The todo_write checklist has not been updated for a while. " +
136
+ "If the work has moved on, bring it current now: mark finished steps completed, " +
137
+ "set the step you are on to in_progress, and add newly discovered steps. " +
138
+ "If the list is already accurate, ignore this.\n" +
139
+ formatTodoList(todos));
140
+ }
104
141
  /** Replay the branch: the last todo_write result is the canonical list. */
105
142
  export function reconstructTodos(entries) {
106
143
  let todos = [];
@@ -186,9 +223,14 @@ export function makeTodoTool(get, set) {
186
223
  },
187
224
  };
188
225
  }
189
- /** Wire the tool, the branch-replay events, and the /todos command. */
226
+ /** Wire the tool, the branch-replay events, the staleness reminder, and /todos. */
190
227
  export function registerTodos(pi) {
191
228
  let todos = [];
229
+ // Staleness-reminder counters (see TODO_REMINDER_TURNS). Session-local like
230
+ // the list cache itself; branch replay resets them so a resume/fork never
231
+ // opens with an instantly-due reminder.
232
+ let turnsSinceWrite = 0;
233
+ let turnsSinceReminder = 0;
192
234
  const reconstruct = (ctx) => {
193
235
  try {
194
236
  todos = reconstructTodos(ctx.sessionManager.getBranch());
@@ -196,12 +238,45 @@ export function registerTodos(pi) {
196
238
  catch {
197
239
  todos = [];
198
240
  }
241
+ turnsSinceWrite = 0;
242
+ turnsSinceReminder = 0;
199
243
  paintWidget(ctx, todos);
200
244
  };
201
245
  pi.on("session_start", async (_event, ctx) => reconstruct(ctx));
202
246
  pi.on("session_tree", async (_event, ctx) => reconstruct(ctx));
247
+ // Turn counting: one tick per finalized assistant message, the same "turn"
248
+ // the model experiences between opportunities to call todo_write.
249
+ pi.on("message_end", async (event) => {
250
+ if (event.message?.role === "assistant") {
251
+ turnsSinceWrite += 1;
252
+ turnsSinceReminder += 1;
253
+ }
254
+ });
255
+ // The reminder rides an existing tool result (the same result-modification
256
+ // seam ambient recall uses), so it reaches the model mid-run without
257
+ // spending a turn. Never appended to todo_write's own result, and fail-soft:
258
+ // a reminder must never break a tool call.
259
+ pi.on("tool_result", async (event) => {
260
+ try {
261
+ if (event.toolName === TODO_TOOL_NAME)
262
+ return;
263
+ if (!shouldRemindTodos({ todos, turnsSinceWrite, turnsSinceReminder }))
264
+ return;
265
+ turnsSinceReminder = 0;
266
+ return {
267
+ content: [
268
+ ...event.content,
269
+ { type: "text", text: `\n\n${formatTodoReminder(todos)}` },
270
+ ],
271
+ };
272
+ }
273
+ catch {
274
+ return;
275
+ }
276
+ });
203
277
  pi.registerTool(makeTodoTool(() => todos, (next) => {
204
278
  todos = next;
279
+ turnsSinceWrite = 0;
205
280
  }));
206
281
  pi.registerCommand("todos", {
207
282
  description: "Show the agent's current task list for this session.",
@@ -4,10 +4,14 @@
4
4
  *
5
5
  * Off by default so the trial-default behavior is unchanged; toggling on swaps
6
6
  * the driver's delegation paragraph for the diamond directive (branding.ts's
7
- * YAGNI_IDENTITY_ULTRA, read live per turn in index.ts) and widens the
8
- * subagent tool's per-call fan-out ceiling (subagents.ts). Ultra is a prompt +
9
- * ceiling change only: it never touches the permission mode, the model tier,
10
- * or the /go pipeline.
7
+ * YAGNI_IDENTITY_ULTRA) and widens the subagent tool's per-call fan-out
8
+ * ceiling (subagents.ts). The two halves take effect at different moments:
9
+ * the fan-out ceiling is probed live on every subagent call, but the identity
10
+ * is read in index.ts's before_agent_start handler, which pi fires only when
11
+ * a NEW user prompt is submitted — a toggle mid-run leaves the running task on
12
+ * its existing instructions until the next message (the handler notifies when
13
+ * that is the case). Ultra is a prompt + ceiling change only: it never touches
14
+ * the permission mode, the model tier, or the /go pipeline.
11
15
  */
12
16
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
13
17
  export interface UltraHolder {
@@ -4,10 +4,14 @@
4
4
  *
5
5
  * Off by default so the trial-default behavior is unchanged; toggling on swaps
6
6
  * the driver's delegation paragraph for the diamond directive (branding.ts's
7
- * YAGNI_IDENTITY_ULTRA, read live per turn in index.ts) and widens the
8
- * subagent tool's per-call fan-out ceiling (subagents.ts). Ultra is a prompt +
9
- * ceiling change only: it never touches the permission mode, the model tier,
10
- * or the /go pipeline.
7
+ * YAGNI_IDENTITY_ULTRA) and widens the subagent tool's per-call fan-out
8
+ * ceiling (subagents.ts). The two halves take effect at different moments:
9
+ * the fan-out ceiling is probed live on every subagent call, but the identity
10
+ * is read in index.ts's before_agent_start handler, which pi fires only when
11
+ * a NEW user prompt is submitted — a toggle mid-run leaves the running task on
12
+ * its existing instructions until the next message (the handler notifies when
13
+ * that is the case). Ultra is a prompt + ceiling change only: it never touches
14
+ * the permission mode, the model tier, or the /go pipeline.
11
15
  */
12
16
  export function createUltraHolder(initial = false) {
13
17
  let on = initial;
@@ -24,6 +28,13 @@ const ULTRA_ON_COPY = "Ultra mode ON: meaningful work fans out to parallel subag
24
28
  "verification agents try to break the result, then the agent synthesizes. " +
25
29
  "Expect more subagent spend per task.";
26
30
  const ULTRA_OFF_COPY = "Ultra mode OFF: back to delegate-when-useful.";
31
+ /**
32
+ * Appended when the toggle lands mid-run: the identity swap only applies when
33
+ * the next prompt is submitted (see the module docblock), so without this note
34
+ * the chip flips while the running task visibly keeps its old behavior — which
35
+ * reads as ultra mode being broken.
36
+ */
37
+ const MID_RUN_NOTE = " The task currently running keeps its existing instructions; the change takes full effect on your next message.";
27
38
  /**
28
39
  * Wire the /ultra command onto a shared holder. No argument toggles; `on` /
29
40
  * `off` set explicitly; `status` reports without changing anything.
@@ -54,7 +65,11 @@ export function registerUltraCommand(pi, holder) {
54
65
  catch {
55
66
  // The chip is chrome; never let it break /ultra.
56
67
  }
57
- notify(next ? ULTRA_ON_COPY : ULTRA_OFF_COPY, "info");
68
+ // Guarded probe: test fakes (and any minimal harness ctx) may not carry
69
+ // isIdle, and its absence must read as idle, never as busy.
70
+ const midRun = typeof ctx.isIdle === "function" && !ctx.isIdle();
71
+ const copy = next ? ULTRA_ON_COPY : ULTRA_OFF_COPY;
72
+ notify(midRun ? copy + MID_RUN_NOTE : copy, "info");
58
73
  },
59
74
  });
60
75
  }
package/dist/login.d.ts CHANGED
@@ -34,8 +34,10 @@ type OpenRunner = (cmd: string, args: string[]) => Promise<void>;
34
34
  *
35
35
  * macOS: open <url>
36
36
  * Linux: xdg-open <url>
37
- * Windows: cmd /c start "" <url> (the empty "" is start's window-title slot;
38
- * without it `start` swallows the URL as the title and opens nothing)
37
+ * Windows: rundll32 url.dll,FileProtocolHandler <url> (opens the default
38
+ * browser without going through cmd.exe `cmd /c start <url>`
39
+ * re-parses its arguments as a shell line, so a hostile URL with
40
+ * `&`/`^` metacharacters could execute commands)
39
41
  */
40
42
  export declare function resolveOpenCommand(url: string, platform?: NodeJS.Platform): {
41
43
  cmd: string;
package/dist/login.js CHANGED
@@ -31,14 +31,17 @@ function isTimeoutError(err) {
31
31
  *
32
32
  * macOS: open <url>
33
33
  * Linux: xdg-open <url>
34
- * Windows: cmd /c start "" <url> (the empty "" is start's window-title slot;
35
- * without it `start` swallows the URL as the title and opens nothing)
34
+ * Windows: rundll32 url.dll,FileProtocolHandler <url> (opens the default
35
+ * browser without going through cmd.exe `cmd /c start <url>`
36
+ * re-parses its arguments as a shell line, so a hostile URL with
37
+ * `&`/`^` metacharacters could execute commands)
36
38
  */
37
39
  export function resolveOpenCommand(url, platform = process.platform) {
38
40
  if (platform === "darwin")
39
41
  return { cmd: "open", args: [url] };
40
- if (platform === "win32")
41
- return { cmd: "cmd", args: ["/c", "start", "", url] };
42
+ if (platform === "win32") {
43
+ return { cmd: "rundll32", args: ["url.dll,FileProtocolHandler", url] };
44
+ }
42
45
  return { cmd: "xdg-open", args: [url] };
43
46
  }
44
47
  const spawnRunner = (cmd, args) => new Promise((resolve, reject) => {
@@ -50,6 +53,18 @@ const spawnRunner = (cmd, args) => new Promise((resolve, reject) => {
50
53
  * URL is still printed for manual copy. `runner` is injectable for tests.
51
54
  */
52
55
  export const realOpenUrl = (url, runner = spawnRunner) => {
56
+ // Only ever hand http(s) URLs to the OS opener — refuse file:, javascript:,
57
+ // or custom schemes a compromised server response could try to smuggle in.
58
+ let parsed;
59
+ try {
60
+ parsed = new URL(url);
61
+ }
62
+ catch {
63
+ return Promise.resolve();
64
+ }
65
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
66
+ return Promise.resolve();
67
+ }
53
68
  const { cmd, args } = resolveOpenCommand(url);
54
69
  return runner(cmd, args).catch(() => {
55
70
  // Silently fail — the user can still copy the URL manually.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yagni-app/code-staging",
3
- "version": "0.3.0-staging.1090.1",
3
+ "version": "0.3.0-staging.1093.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.84.1",
39
39
  "typebox": "^1.3.11"
40
40
  },
41
- "yagniSourceSha": "79e1523affbd694ad96a236da6aefe33000497d8"
41
+ "yagniSourceSha": "189159f08a0b5f7ab5cf10221f8238782ff7d1f7"
42
42
  }