pi-pier 0.4.1 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +1 -2
  2. package/package.json +1 -1
  3. package/schemas/efficiency-config.schema.json +25 -19
  4. package/schemas/role-manifest.schema.json +9 -9
  5. package/src/ask-dialog.ts +9 -2
  6. package/src/ask-user.ts +142 -74
  7. package/src/compact-coordinator.ts +204 -33
  8. package/src/compact-economics-core.ts +62 -29
  9. package/src/config-catalog-core.ts +1 -0
  10. package/src/delegation-core.ts +1 -1
  11. package/src/efficiency-config-core.ts +34 -20
  12. package/src/efficiency-store.ts +42 -5
  13. package/src/herdr-client.ts +15 -5
  14. package/src/index-notices.ts +1 -1
  15. package/src/index-pipe.ts +0 -8
  16. package/src/index.ts +71 -87
  17. package/src/jev-client.ts +32 -7
  18. package/src/jev-core.ts +70 -33
  19. package/src/manifest-compose.ts +4 -4
  20. package/src/observation-core.ts +13 -1
  21. package/src/pipe-channel.ts +0 -8
  22. package/src/plugins/heat-plan.ts +18 -3
  23. package/src/plugins/observation.ts +118 -46
  24. package/src/plugins/subagent.ts +53 -40
  25. package/src/plugins/terminal.ts +192 -47
  26. package/src/plugins/todo.ts +44 -22
  27. package/src/reducer-invoker.ts +28 -14
  28. package/src/renderers.ts +1 -13
  29. package/src/role-loader.ts +8 -8
  30. package/src/role-manifest.ts +22 -22
  31. package/src/roles/master.json +1 -1
  32. package/src/roles/worker-default.json +3 -3
  33. package/src/settle-wake-core.ts +8 -2
  34. package/src/subagent-core.ts +41 -3
  35. package/src/subagent-poller.ts +52 -18
  36. package/src/subagent-session.ts +67 -4
  37. package/src/subagent-spawn.ts +6 -5
  38. package/src/terminal-core.ts +111 -17
  39. package/src/todo-core.ts +47 -2
  40. package/src/todo-read-hook.ts +31 -17
  41. package/src/todo-reminder-core.ts +9 -5
  42. package/src/todo-window.ts +8 -4
  43. package/src/todos-service.ts +12 -0
  44. package/src/tree-view.ts +10 -10
  45. package/src/vocab.ts +7 -1
package/src/jev-client.ts CHANGED
@@ -12,6 +12,10 @@ import { parseJevAnswers, type JevAnswer, type JevQuestion, type JevUsage } from
12
12
  import type { JevConfig } from './efficiency-config-core.ts';
13
13
 
14
14
  const JEV_DEFAULT_BASE_URL = 'https://api.typesafe.ai';
15
+ /** Consecutive transport failures (timeout / network / 5xx) that open the breaker. */
16
+ export const JEV_BREAKER_FAILURES = 2;
17
+ /** While open, asks fail open locally without a request or a telemetry row; the tripping row says so. */
18
+ export const JEV_BREAKER_OPEN_MS = 60_000;
15
19
 
16
20
  export interface JevAskMeta {
17
21
  /** Stable question-set id for telemetry rows (e.g. 'epr-diagnostic-gate'). */
@@ -23,7 +27,7 @@ export interface JevAskMeta {
23
27
  extra?: Record<string, unknown>;
24
28
  /**
25
29
  * Post-answer decision fields for telemetry (choices/scores/final verdicts — what threshold tuning
26
- * needs; still never request bodies). Called exactly once per ask.
30
+ * needs; still never request bodies). Called exactly once per ask, with null answers on failure.
27
31
  */
28
32
  enrich?: (outcome: { ok: boolean; answers: Record<string, JevAnswer> | null }) => Record<string, unknown>;
29
33
  }
@@ -49,11 +53,14 @@ export interface JevRuntime {
49
53
  export function createJevRuntime(getConfig: () => JevConfig, deps: JevRuntimeDeps = {}): JevRuntime {
50
54
  const doFetch = deps.fetchImpl ?? ((input, init) => globalThis.fetch(input, init));
51
55
  const now = deps.now ?? Date.now;
52
-
56
+ // A run of 12 back-to-back network failures and 2.5s timeout stalls showed every site paying
57
+ // the full budget during an outage; the breaker makes an outage cost one tripping ask.
58
+ let transportFailures = 0;
59
+ let breakerOpenUntil = 0;
53
60
  async function logAsk(
54
61
  config: JevConfig,
55
62
  meta: JevAskMeta,
56
- outcome: { ok: true; model: string; usage: JevUsage } | { ok: false; reason: string },
63
+ outcome: { ok: true; model: string; usage: JevUsage } | { ok: false; reason: string; extra?: Record<string, unknown> },
57
64
  latencyMs: number,
58
65
  state: unknown,
59
66
  enriched: Record<string, unknown>,
@@ -77,6 +84,7 @@ export function createJevRuntime(getConfig: () => JevConfig, deps: JevRuntimeDep
77
84
  stateHash: sha256Hex(serialized),
78
85
  ...(outcome.ok ? { usage: { input_tokens: outcome.usage.inputTokens, output_tokens: outcome.usage.outputTokens } } : {}),
79
86
  ...(meta.extra ?? {}),
87
+ ...(outcome.ok ? {} : outcome.extra ?? {}),
80
88
  ...enriched,
81
89
  };
82
90
  try {
@@ -95,14 +103,25 @@ export function createJevRuntime(getConfig: () => JevConfig, deps: JevRuntimeDep
95
103
  async ask(request, meta): Promise<JevAskResult> {
96
104
  const startedAt = now();
97
105
  const config = getConfig();
98
- const fail = async (reason: string): Promise<JevAskResult> => {
106
+ const fail = async (reason: string, extra?: Record<string, unknown>): Promise<JevAskResult> => {
99
107
  const latencyMs = now() - startedAt;
100
108
  const enriched = meta.enrich?.({ ok: false, answers: null }) ?? {};
101
- await logAsk(config, meta, { ok: false, reason }, latencyMs, request.state, enriched);
109
+ await logAsk(config, meta, { ok: false, reason, extra }, latencyMs, request.state, enriched);
102
110
  return { ok: false, reason, latencyMs };
103
111
  };
112
+ const transportFail = (reason: string, extra: Record<string, unknown> = {}): Promise<JevAskResult> => {
113
+ transportFailures += 1;
114
+ if (transportFailures < JEV_BREAKER_FAILURES) return fail(reason, extra);
115
+ transportFailures = 0;
116
+ breakerOpenUntil = now() + JEV_BREAKER_OPEN_MS;
117
+ return fail(reason, { ...extra, breakerOpenedMs: JEV_BREAKER_OPEN_MS });
118
+ };
104
119
  if (!config.enabled) return fail('disabled');
105
120
  if (!config.apiKey) return fail('no-api-key');
121
+ if (now() < breakerOpenUntil) {
122
+ meta.enrich?.({ ok: false, answers: null });
123
+ return { ok: false, reason: 'breaker-open', latencyMs: 0 };
124
+ }
106
125
 
107
126
  const timeoutMs = meta.timeoutMs ?? config.timeoutMs;
108
127
  const controller = new AbortController();
@@ -121,8 +140,11 @@ export function createJevRuntime(getConfig: () => JevConfig, deps: JevRuntimeDep
121
140
  if (!response.ok) {
122
141
  // 429 (and everything else) fails open immediately: limits are
123
142
  // dynamically adjusted upstream and every pier call is droppable.
143
+ if (response.status >= 500) return transportFail(`http-${response.status}`);
144
+ transportFailures = 0;
124
145
  return fail(response.status === 429 ? 'rate-limited' : `http-${response.status}`);
125
146
  }
147
+ transportFailures = 0;
126
148
  const raw: unknown = await response.json();
127
149
  const parsed = parseJevAnswers(raw, Object.keys(request.questions));
128
150
  if (!parsed.ok) return fail(parsed.reason);
@@ -131,8 +153,11 @@ export function createJevRuntime(getConfig: () => JevConfig, deps: JevRuntimeDep
131
153
  await logAsk(config, meta, { ok: true, model: parsed.model, usage: parsed.usage }, latencyMs, request.state, enriched);
132
154
  return { ok: true, answers: parsed.answers, model: parsed.model, usage: parsed.usage, latencyMs };
133
155
  } catch (error) {
134
- const aborted = error instanceof Error && error.name === 'AbortError';
135
- return fail(aborted ? 'timeout' : 'network-error');
156
+ if (error instanceof Error && error.name === 'AbortError') return transportFail('timeout');
157
+ // fetch wraps the socket error in `cause`; its code (ECONNRESET, ENOTFOUND, ...) is the diagnosis.
158
+ const source = error instanceof Error && error.cause instanceof Error ? error.cause : error;
159
+ const code = source instanceof Error && 'code' in source && typeof source.code === 'string' ? source.code : undefined;
160
+ return transportFail('network-error', code ? { errorCode: code } : {});
136
161
  } finally {
137
162
  clearTimeout(timer);
138
163
  }
package/src/jev-core.ts CHANGED
@@ -10,6 +10,7 @@
10
10
  */
11
11
  import { isPlainObject } from './guards.ts';
12
12
  import { containsLikelySecret, FAILURE_SIGNAL } from './reducer-core.ts';
13
+ import { completeLineExcerpt } from './observation-core.ts';
13
14
 
14
15
  /* ── Request/response shapes (closed set) ── */
15
16
 
@@ -153,6 +154,8 @@ export interface DiagnosticGateVerdict {
153
154
  readonly choice: string | null;
154
155
  readonly noul: number | null;
155
156
  }
157
+ /** Confidence is checked before the choice: an unconfident rejection is not authoritative, so the
158
+ * caller's regex list must decide (same fail-open path as a typed `low-confidence` answer). */
156
159
  export function evaluateDiagnosticGate(
157
160
  answers: Record<string, JevAnswer>,
158
161
  minConfidence: number,
@@ -162,12 +165,12 @@ export function evaluateDiagnosticGate(
162
165
  if (kind === undefined || output === undefined) {
163
166
  return { hit: false, reason: 'missing-answer', confidence: null, choice: null, noul: null };
164
167
  }
165
- if (kind.choice !== 'build_test_run') {
166
- return { hit: false, reason: `not-build-test:${kind.choice}`, confidence: kind.confidence, choice: kind.choice, noul: output.noul };
167
- }
168
168
  if (kind.confidence < minConfidence) {
169
169
  return { hit: false, reason: 'low-confidence', confidence: kind.confidence, choice: kind.choice, noul: output.noul };
170
170
  }
171
+ if (kind.choice !== 'build_test_run') {
172
+ return { hit: false, reason: `not-build-test:${kind.choice}`, confidence: kind.confidence, choice: kind.choice, noul: output.noul };
173
+ }
171
174
  if (output.noul < DIAGNOSTIC_OUTPUT_MIN_NOUL) {
172
175
  return { hit: false, reason: 'non-diagnostic-output', confidence: kind.confidence, choice: kind.choice, noul: output.noul };
173
176
  }
@@ -262,9 +265,12 @@ export interface ExcerptWindow {
262
265
  readonly id: ExcerptWindowId;
263
266
  readonly label: string;
264
267
  readonly text: string;
268
+ /** 0-based first line of the window in the packed output (telemetry joins picks to objects). */
269
+ readonly startLine: number;
265
270
  }
266
271
 
267
272
  interface LineWindow {
273
+ readonly start: number;
268
274
  readonly text: string;
269
275
  readonly bytes: number;
270
276
  readonly signalCount: number;
@@ -286,12 +292,12 @@ function buildLineWindow(lines: readonly string[], start: number, budgetBytes: n
286
292
  if (signalLines.has(i)) signalCount++;
287
293
  }
288
294
  if (selected.length === 0) return null;
289
- return { text: selected.join('\n'), bytes, signalCount };
295
+ return { start, text: selected.join('\n'), bytes, signalCount };
290
296
  }
291
297
 
292
298
  /** Code-owned middle-window candidates for a packed observation: failure-signal windows (first hit,
293
- * densest cluster) when present, plus a plain mid-output window ALWAYS offered, so jev judges every
294
- * packed output — including logs whose failure lines the English FAILURE_SIGNAL regex cannot see. */
299
+ * densest cluster) when present, plus a plain mid-output window. The picker asks jev only when a
300
+ * signal window exists; a mid-only set kept head/tail or failed in 8 of 10 asks (D123). */
295
301
  export function buildExcerptWindows(text: string, halfBudgetBytes: number): ExcerptWindow[] {
296
302
  if (halfBudgetBytes <= 0) return [];
297
303
  const lines = text.split('\n');
@@ -308,7 +314,7 @@ export function buildExcerptWindows(text: string, halfBudgetBytes: number): Exce
308
314
  const firstWindow = buildLineWindow(lines, Math.max(0, first - 2), halfBudgetBytes, signalLines);
309
315
  if (firstWindow !== null && !seen.has(firstWindow.text)) {
310
316
  seen.add(firstWindow.text);
311
- out.push({ id: 'first_signal', label: 'first failure-signal region', text: firstWindow.text });
317
+ out.push({ id: 'first_signal', label: 'first failure-signal region', text: firstWindow.text, startLine: firstWindow.start });
312
318
  }
313
319
 
314
320
  let best: LineWindow | null = null;
@@ -319,49 +325,80 @@ export function buildExcerptWindows(text: string, halfBudgetBytes: number): Exce
319
325
  }
320
326
  if (best !== null && !seen.has(best.text)) {
321
327
  seen.add(best.text);
322
- out.push({ id: 'densest', label: 'densest failure-signal region', text: best.text });
328
+ out.push({ id: 'densest', label: 'densest failure-signal region', text: best.text, startLine: best.start });
323
329
  }
324
330
  }
325
331
 
326
332
  const midAnchor = Math.min(Math.floor(lines.length / 2), Math.max(0, lines.length - 1));
327
333
  const midWindow = buildLineWindow(lines, midAnchor, halfBudgetBytes, signalLines);
328
334
  if (midWindow !== null && !seen.has(midWindow.text)) {
329
- out.push({ id: 'mid', label: 'middle region of the output', text: midWindow.text });
335
+ out.push({ id: 'mid', label: 'middle region of the output', text: midWindow.text, startLine: midWindow.start });
330
336
  }
331
337
  return out;
332
338
  }
333
339
 
334
- export function excerptPickRequest(
335
- middleWindows: readonly ExcerptWindow[],
336
- headExcerpt: string,
337
- tailExcerpt: string,
338
- ): JevRequest | null {
339
- if (middleWindows.length === 0) return null;
340
- const criteria: Record<string, string> = {
341
- keep_head_tail: 'The existing head and tail excerpts are already the most informative; keep them',
342
- };
343
- const excerpts: Record<string, string> = { head_excerpt: headExcerpt, tail_excerpt: tailExcerpt };
344
- for (const window of middleWindows) {
345
- criteria[window.id] = `${window.label}: excerpt from the middle of the output`;
346
- excerpts[window.id] = window.text;
340
+ /**
341
+ * Which packed outputs are worth an excerpt ask, keeping each item's index into `items`. Skipped:
342
+ * outputs without a failure-signal window (only `mid` competes with head/tail; that lost in 8 of 10
343
+ * asks, D123) and outputs whose excerpts carry a credential shape (§8 privacy boundary).
344
+ */
345
+ export function planExcerptAsks(
346
+ items: ReadonlyArray<{ obsId: string; text: string }>,
347
+ excerptBudgetBytes: number,
348
+ ): Array<{ index: number; obsId: string } & ExcerptPickItem> {
349
+ const halfBudget = Math.floor(excerptBudgetBytes / 2);
350
+ const asks: Array<{ index: number; obsId: string } & ExcerptPickItem> = [];
351
+ for (let index = 0; index < items.length; index++) {
352
+ const { obsId, text } = items[index]!;
353
+ const windows = buildExcerptWindows(text, halfBudget);
354
+ if (!windows.some((w) => w.id !== 'mid')) continue;
355
+ const headExcerpt = completeLineExcerpt(text, halfBudget, false);
356
+ const tailExcerpt = completeLineExcerpt(text, excerptBudgetBytes - halfBudget, true);
357
+ if (!excerptAskIsSafe(windows, headExcerpt, tailExcerpt)) continue;
358
+ asks.push({ index, obsId, windows, headExcerpt, tailExcerpt });
347
359
  }
348
- return {
349
- state: { excerpts },
350
- questions: {
351
- window: {
352
- type: 'choice',
353
- instructions: 'A large tool output was replaced by a placeholder keeping two short excerpts. Which excerpt set would most help a coding agent locate the problem without recalling the full text?',
354
- criteria,
355
- },
356
- },
357
- };
360
+ return asks;
361
+ }
362
+
363
+ export interface ExcerptPickItem {
364
+ readonly windows: readonly ExcerptWindow[];
365
+ readonly headExcerpt: string;
366
+ readonly tailExcerpt: string;
367
+ }
368
+
369
+ /** One request for every packed output (a compaction batch packs up to 20): one round trip instead of
370
+ * a 20-way burst. `outputs[i]` carries item i's excerpts; its question is `excerpt_${i}_window`. */
371
+ export function excerptPickRequest(items: readonly ExcerptPickItem[]): JevRequest | null {
372
+ if (items.length === 0) return null;
373
+ const questions: Record<string, JevQuestion> = {};
374
+ const outputs: Array<Record<string, string>> = [];
375
+ for (let i = 0; i < items.length; i++) {
376
+ const item = items[i]!;
377
+ const criteria: Record<string, string> = {
378
+ keep_head_tail: 'The existing head and tail excerpts are already the most informative; keep them',
379
+ };
380
+ const excerpts: Record<string, string> = { head_excerpt: item.headExcerpt, tail_excerpt: item.tailExcerpt };
381
+ for (const window of item.windows) {
382
+ criteria[window.id] = `${window.label}: excerpt from the middle of the output`;
383
+ excerpts[window.id] = window.text;
384
+ }
385
+ outputs.push(excerpts);
386
+ // Question keys are NOT sent to the model, so the instruction names its output index.
387
+ questions[`excerpt_${i}_window`] = {
388
+ type: 'choice',
389
+ instructions: `A large tool output (outputs[${i}]) was replaced by a placeholder keeping two short excerpts. Which excerpt set would most help a coding agent locate the problem without recalling the full text?`,
390
+ criteria,
391
+ };
392
+ }
393
+ return { state: { outputs }, questions };
358
394
  }
359
395
 
360
396
  export function evaluateExcerptPick(
361
397
  answers: Record<string, JevAnswer>,
398
+ index: number,
362
399
  minConfidence: number,
363
400
  ): ExcerptWindowId | null {
364
- const picked = answerOf(answers, 'window', 'choice');
401
+ const picked = answerOf(answers, `excerpt_${index}_window`, 'choice');
365
402
  if (picked === undefined) return null;
366
403
  if (picked.confidence < minConfidence) return null;
367
404
  return picked.choice === 'first_signal' || picked.choice === 'densest' || picked.choice === 'mid'
@@ -36,7 +36,7 @@ export function composeManifest(sources: ManifestSources): ComposedManifest {
36
36
  if (baseline.length === 0) {
37
37
  throw new ManifestError(
38
38
  'INVALID_ROLE_CONFIG',
39
- 'INVALID_ROLE_CONFIG: 基线工具不能为空,至少需包含 todo_write 和 ask_user_question',
39
+ 'INVALID_ROLE_CONFIG: baseline tools must not be empty; todo_write and ask_user_question are required',
40
40
  );
41
41
  }
42
42
  const candidates = new Set<string>([
@@ -62,9 +62,9 @@ export function composeManifest(sources: ManifestSources): ComposedManifest {
62
62
  if (tools.length === 0) {
63
63
  throw new ManifestError(
64
64
  'EMPTY_MANIFEST',
65
- `裁剪后 manifest 为空(deny-all trap?)。基线: ${baseline.join(', ') || '(空)'};` +
66
- `模型建议: ${sources.modelSuggested.join(', ') || '(无)'};` +
67
- `规则: ${JSON.stringify(rules)}`,
65
+ `Trimmed manifest is empty (deny-all trap?). Baseline: ${baseline.join(', ') || '(empty)'}; ` +
66
+ `model suggestions: ${sources.modelSuggested.join(', ') || '(none)'}; ` +
67
+ `rules: ${JSON.stringify(rules)}`,
68
68
  );
69
69
  }
70
70
 
@@ -138,6 +138,8 @@ export interface RecallSliceResult {
138
138
  text: string;
139
139
  bytes: number;
140
140
  lines: number;
141
+ /** Byte offset the returned text actually starts at (after line-start alignment). */
142
+ offset: number;
141
143
  nextOffset: number;
142
144
  eof: boolean;
143
145
  }
@@ -157,7 +159,7 @@ export function sliceBufferChunk(
157
159
  limits: { maxBytes: number; maxLines: number },
158
160
  ): RecallSliceResult {
159
161
  if (offset >= buf.length) {
160
- return { text: '', bytes: 0, lines: 0, nextOffset: buf.length, eof: true };
162
+ return { text: '', bytes: 0, lines: 0, offset, nextOffset: buf.length, eof: true };
161
163
  }
162
164
 
163
165
  let end = Math.min(buf.length - offset, limits.maxBytes);
@@ -169,6 +171,15 @@ export function sliceBufferChunk(
169
171
  }
170
172
  }
171
173
 
174
+ // A byte-cap cut mid-line would make the next page (and its aligned next_offset) skip the rest of
175
+ // that line; end this page at the previous line boundary instead. A line longer than maxBytes has
176
+ // no boundary to snap to and stays byte-sliced.
177
+ const cappedAt = offset + end;
178
+ if (cappedAt < buf.length && buf[cappedAt - 1] !== 0x0a) {
179
+ const lastNewline = buf.lastIndexOf(0x0a, cappedAt - 1);
180
+ if (lastNewline >= offset) end = lastNewline + 1 - offset;
181
+ }
182
+
172
183
  const chunkBuf = buf.subarray(offset, offset + trimUtf8End(buf.subarray(offset), end));
173
184
  const text = chunkBuf.toString('utf8');
174
185
  const nextOffset = offset + chunkBuf.length;
@@ -176,6 +187,7 @@ export function sliceBufferChunk(
176
187
  text,
177
188
  bytes: chunkBuf.length,
178
189
  lines: countLines(text),
190
+ offset,
179
191
  nextOffset,
180
192
  eof: nextOffset >= buf.length,
181
193
  };
@@ -58,14 +58,6 @@ export type PipeRequest =
58
58
  id: string;
59
59
  role: string;
60
60
  }
61
- | {
62
- /** Phase 2 (RFC rfc-delegation-identity §3.2): worker → master progress report. The master
63
- * injects it as a display-less custom message (no turn, no visible chat noise). */
64
- type: 'progress';
65
- id: string;
66
- paneId: string;
67
- text: string;
68
- }
69
61
  | {
70
62
  /** D113 (RFC rfc-unified-widget): worker → master full todo push on every todo_write, so the
71
63
  * master's resident widget and /pier-tree render live worker state without polling herdr. */
@@ -10,6 +10,7 @@ import { type ShapeNode } from './grid-shape.ts';
10
10
 
11
11
  export const SPAWN_PLACEHOLDER_ID = '__pier_new__';
12
12
  const RATIO_FLOOR = 0.10;
13
+ const NON_AGENT_RATIO_FLOOR = 0.25;
13
14
  const FOCUS_SHARE = 0.72;
14
15
  const FOCUS_SHARE_BLOCKED = 0.60;
15
16
  const MAX_AUTO_LAYOUT_PANES = 10;
@@ -57,6 +58,19 @@ function clampRatio(r: number): number {
57
58
  return Math.min(1 - RATIO_FLOOR, Math.max(RATIO_FLOOR, r));
58
59
  }
59
60
 
61
+ /** Agent-less panes (plain shells report no status or `unknown`) keep NON_AGENT_RATIO_FLOOR in mixed splits. */
62
+ function hasAgentPanes(node: LayoutNode, statuses: AgentStatusMap): boolean {
63
+ return flattenPanes(node).some((id) => statuses[id] !== undefined && statuses[id] !== 'unknown');
64
+ }
65
+
66
+ function splitRatio(node: SplitNode, ratio: number, statuses: AgentStatusMap): number {
67
+ const firstAgent = hasAgentPanes(node.first, statuses);
68
+ const secondAgent = hasAgentPanes(node.second, statuses);
69
+ if (firstAgent && !secondAgent) ratio = Math.min(ratio, 1 - NON_AGENT_RATIO_FLOOR);
70
+ else if (!firstAgent && secondAgent) ratio = Math.max(ratio, NON_AGENT_RATIO_FLOOR);
71
+ return clampRatio(ratio);
72
+ }
73
+
60
74
  function tierWeight(status: string | undefined, isAsk = false, slim = false): number {
61
75
  if (status === 'blocked') return isAsk ? ASK_WEIGHT : BLOCKED_WEIGHT;
62
76
  if (status === 'working') return slim ? WORKING_WEIGHT * SLIM_FACTOR : WORKING_WEIGHT;
@@ -129,7 +143,8 @@ export function planSpawnSplitRatio(opts: {
129
143
  if (paneCount <= 1 || paneCount > MAX_AUTO_LAYOUT_PANES) return null;
130
144
  if (!containsPane(simulated, opts.focusPaneId) || !containsPane(simulated, newId)) return null;
131
145
 
132
- const statuses = opts.statuses ?? {};
146
+ // The new pane is an agent pane; `idle` weighs the same as a missing status.
147
+ const statuses: AgentStatusMap = { [newId]: 'idle', ...opts.statuses };
133
148
  const askFlags = opts.askFlags ?? {};
134
149
  const slim = paneCount - 1 >= SLIM_THRESHOLD;
135
150
  const focusSteps = findPath(simulated, opts.focusPaneId);
@@ -148,11 +163,11 @@ export function planSpawnSplitRatio(opts: {
148
163
  const r = clampRatio(target ** (1 / focusSteps.length));
149
164
  const at = focusSteps.find((s) => s.node === newSplit);
150
165
  if (!at) return null;
151
- return at.side === 'first' ? r : 1 - r;
166
+ return splitRatio(newSplit, at.side === 'first' ? r : 1 - r, statuses);
152
167
  }
153
168
 
154
169
  const wFirst = weightOf(newSplit.first, statuses, askFlags, slim);
155
170
  const wSecond = weightOf(newSplit.second, statuses, askFlags, slim);
156
171
  if (wFirst + wSecond <= 0) return null;
157
- return clampRatio(wFirst / (wFirst + wSecond));
172
+ return splitRatio(newSplit, wFirst / (wFirst + wSecond), statuses);
158
173
  }