@aragon-agent/cli 0.6.43 → 0.6.44

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.
@@ -38,5 +38,23 @@ export declare function killTree(pid: number | undefined, signal?: NodeJS.Signal
38
38
  * The only kill primitive a signal handler, `process.on('exit')` or
39
39
  * `handleFatal` may reach (I-9). No escalation ladder and no logging: a reaper
40
40
  * that can block or throw is worse than one that misses a child.
41
+ *
42
+ * IT RETURNS WHETHER THE KILL WAS CONFIRMED, AND THAT IS NOT BOOKKEEPING. The
43
+ * Windows branch is a `taskkill` PROCESS, and `spawnSync`'s own `timeout` kills
44
+ * that process when the budget runs out. Measured on 2026-10-23: one
45
+ * `taskkill /t /f` of a shell plus its node child takes 1027-1058 ms on an IDLE
46
+ * machine (five samples), and a `taskkill` cut off at a 150 ms deadline leaves the
47
+ * tree ALIVE 1.5 s later (`ETIMEDOUT`, `status = null`, `signal = SIGTERM`). This
48
+ * function used to `catch {}` that result and return `void`, so the one caller
49
+ * that promises "no service outlives the CLI" could not tell a kill from a
50
+ * timeout - and a loaded host, where a single tree kill genuinely needs more than
51
+ * the old 2000 ms, silently leaked every child it was asked to reap.
52
+ *
53
+ * The budget therefore lives in `PROC_LIMITS.killTreeSyncTimeoutMs`, sized from
54
+ * that measurement rather than from optimism, and the answer comes back to the
55
+ * caller so `reapSync` can raise `killIncomplete` instead of pretending.
56
+ *
57
+ * POSIX reports `true` when a signal was DELIVERED, which is as much as a signal
58
+ * can ever confirm.
41
59
  */
42
- export declare function killTreeSync(pid: number | undefined): void;
60
+ export declare function killTreeSync(pid: number | undefined): boolean;
@@ -24,6 +24,7 @@
24
24
  */
25
25
  import { spawn, spawnSync } from 'node:child_process';
26
26
  import process from 'node:process';
27
+ import { PROC_LIMITS } from './limits.js';
27
28
  /** Is the pid still there? `signal 0` tests for existence without delivering. */
28
29
  export function isAlive(pid) {
29
30
  if (pid === undefined || !Number.isFinite(pid) || pid <= 0)
@@ -88,32 +89,58 @@ export function killTree(pid, signal = 'SIGKILL') {
88
89
  * The only kill primitive a signal handler, `process.on('exit')` or
89
90
  * `handleFatal` may reach (I-9). No escalation ladder and no logging: a reaper
90
91
  * that can block or throw is worse than one that misses a child.
92
+ *
93
+ * IT RETURNS WHETHER THE KILL WAS CONFIRMED, AND THAT IS NOT BOOKKEEPING. The
94
+ * Windows branch is a `taskkill` PROCESS, and `spawnSync`'s own `timeout` kills
95
+ * that process when the budget runs out. Measured on 2026-10-23: one
96
+ * `taskkill /t /f` of a shell plus its node child takes 1027-1058 ms on an IDLE
97
+ * machine (five samples), and a `taskkill` cut off at a 150 ms deadline leaves the
98
+ * tree ALIVE 1.5 s later (`ETIMEDOUT`, `status = null`, `signal = SIGTERM`). This
99
+ * function used to `catch {}` that result and return `void`, so the one caller
100
+ * that promises "no service outlives the CLI" could not tell a kill from a
101
+ * timeout - and a loaded host, where a single tree kill genuinely needs more than
102
+ * the old 2000 ms, silently leaked every child it was asked to reap.
103
+ *
104
+ * The budget therefore lives in `PROC_LIMITS.killTreeSyncTimeoutMs`, sized from
105
+ * that measurement rather than from optimism, and the answer comes back to the
106
+ * caller so `reapSync` can raise `killIncomplete` instead of pretending.
107
+ *
108
+ * POSIX reports `true` when a signal was DELIVERED, which is as much as a signal
109
+ * can ever confirm.
91
110
  */
92
111
  export function killTreeSync(pid) {
93
112
  if (pid === undefined || !Number.isFinite(pid) || pid <= 0)
94
- return;
113
+ return false;
95
114
  if (process.platform === 'win32') {
115
+ let result;
96
116
  try {
97
- spawnSync('taskkill', ['/pid', String(pid), '/t', '/f'], {
117
+ result = spawnSync('taskkill', ['/pid', String(pid), '/t', '/f'], {
98
118
  windowsHide: true,
99
119
  stdio: 'ignore',
100
- timeout: 2000,
120
+ timeout: PROC_LIMITS.killTreeSyncTimeoutMs,
101
121
  });
102
122
  }
103
123
  catch {
104
- /* ignore */
124
+ return false;
105
125
  }
106
- return;
126
+ // `error` is set for a spawn failure AND for the budget expiring (`ETIMEDOUT`,
127
+ // with `signal = SIGTERM`), and neither is a kill.
128
+ if (result.error)
129
+ return false;
130
+ return result.status === 0;
107
131
  }
108
132
  try {
109
133
  process.kill(-pid, 'SIGKILL');
134
+ return true;
110
135
  }
111
136
  catch {
112
137
  try {
113
138
  process.kill(pid, 'SIGKILL');
139
+ return true;
114
140
  }
115
141
  catch {
116
142
  /* already gone */
143
+ return false;
117
144
  }
118
145
  }
119
146
  }
@@ -102,6 +102,25 @@ export declare const PROC_LIMITS: {
102
102
  readonly killVerifyMs: 2000;
103
103
  /** Liveness poll interval while waiting for a `SIGKILL` to land. */
104
104
  readonly killVerifyPollMs: 50;
105
+ /**
106
+ * How long ONE synchronous tree kill (`killTreeSync`) may take.
107
+ *
108
+ * MEASURED, NOT GUESSED. A `taskkill /pid <shell> /t /f` of a shell plus its
109
+ * node child takes 1027-1058 ms on the reference machine on an IDLE host (five
110
+ * samples, 2026-10-23), so the old 2000 ms was 1.9x the honest cost and the
111
+ * 2026-10-23 release run - where the whole suite took 340 s against the usual
112
+ * 200-240 s - is comfortably inside the range where a single tree kill exceeds
113
+ * it. Crossing it is not a slow kill, it is NO kill: `spawnSync` kills
114
+ * `taskkill` itself at the deadline and the tree keeps running (verified: a
115
+ * 150 ms budget left the child alive 1.5 s later, `ETIMEDOUT` / `status null`).
116
+ *
117
+ * 5000 ms is ~5x the measured cost. It is a CEILING and not a cost: a tree that
118
+ * dies in the usual second still returns in that second. The worst case is
119
+ * `maxJobs` (16) trees at the ceiling on an exit hook, which is why this is not
120
+ * any larger - the reaper prefers a reported miss to an exit that never ends,
121
+ * and the miss is now reported (`ProcRecord.killIncomplete`).
122
+ */
123
+ readonly killTreeSyncTimeoutMs: 5000;
105
124
  /** Below this many terminal columns the status chip degrades to `[N]`. */
106
125
  readonly statusCompactCols: 92;
107
126
  /**
@@ -102,6 +102,25 @@ export const PROC_LIMITS = {
102
102
  killVerifyMs: 2000,
103
103
  /** Liveness poll interval while waiting for a `SIGKILL` to land. */
104
104
  killVerifyPollMs: 50,
105
+ /**
106
+ * How long ONE synchronous tree kill (`killTreeSync`) may take.
107
+ *
108
+ * MEASURED, NOT GUESSED. A `taskkill /pid <shell> /t /f` of a shell plus its
109
+ * node child takes 1027-1058 ms on the reference machine on an IDLE host (five
110
+ * samples, 2026-10-23), so the old 2000 ms was 1.9x the honest cost and the
111
+ * 2026-10-23 release run - where the whole suite took 340 s against the usual
112
+ * 200-240 s - is comfortably inside the range where a single tree kill exceeds
113
+ * it. Crossing it is not a slow kill, it is NO kill: `spawnSync` kills
114
+ * `taskkill` itself at the deadline and the tree keeps running (verified: a
115
+ * 150 ms budget left the child alive 1.5 s later, `ETIMEDOUT` / `status null`).
116
+ *
117
+ * 5000 ms is ~5x the measured cost. It is a CEILING and not a cost: a tree that
118
+ * dies in the usual second still returns in that second. The worst case is
119
+ * `maxJobs` (16) trees at the ceiling on an exit hook, which is why this is not
120
+ * any larger - the reaper prefers a reported miss to an exit that never ends,
121
+ * and the miss is now reported (`ProcRecord.killIncomplete`).
122
+ */
123
+ killTreeSyncTimeoutMs: 5000,
105
124
  /** Below this many terminal columns the status chip degrades to `[N]`. */
106
125
  statusCompactCols: 92,
107
126
  // --- long-task-jobs: bounded waiting, progress, notices, log mirror --------
@@ -404,9 +404,16 @@ export class ProcSupervisor {
404
404
  for (const entry of this.services.values()) {
405
405
  if (isTerminalStatus(entry.record.status))
406
406
  continue;
407
- killTreeSync(entry.record.pid);
407
+ // THE ANSWER IS PART OF THE REAP (AC-42, 2026-10-23 release). A `taskkill`
408
+ // that outlived its budget is killed itself and the tree survives, so an
409
+ // unconfirmed kill has to reach the record the card and `bash_output` read -
410
+ // otherwise "stopped" is a claim nobody made good on. `killIncomplete` is
411
+ // that field (P2-6), and it is the reason the reaper may keep a bound at all.
412
+ const killed = killTreeSync(entry.record.pid);
408
413
  entry.record.status = 'stopped';
409
414
  entry.record.endedAt = Date.now();
415
+ if (!killed)
416
+ entry.record.killIncomplete = true;
410
417
  entry.watcher?.stop();
411
418
  if (entry.outputTimer)
412
419
  clearTimeout(entry.outputTimer);
@@ -0,0 +1,56 @@
1
+ /**
2
+ * A CHILD PROCESS WITH A DEADLINE (bug-diagnosis 0b33323c, release gate 2026-10-23).
3
+ *
4
+ * WHY THIS EXISTS. `search-tools.ts` used to await a bare `spawn('rg', ...)` whose
5
+ * promise settled on `'close'` and on nothing else. A child that never closes -
6
+ * a launcher shim blocked by a scanner, a wedged network drive, a filesystem that
7
+ * stopped answering - therefore left the tool pending FOREVER: the model's turn
8
+ * had no result to return, the Ink frame had no answer to draw, and the only thing
9
+ * that ever ended it was the test runner's wall clock
10
+ * (`tools.test.ts > grep finds a seeded pattern`, `Test timed out in 30000ms`,
11
+ * 2026-10-23 release). CLAUDE.md section 5 says exactly what is missing here: at
12
+ * an IO / subprocess boundary use a structured wrapper with a timeout, never a
13
+ * bare call.
14
+ *
15
+ * SETTLES ONCE, ALWAYS. Every way a child can end - exit, spawn failure, and OUR
16
+ * OWN deadline - resolves this promise, and the first of them wins. The deadline
17
+ * is the last resort, not a measuring instrument: a caller that has produced an
18
+ * answer is not interrupted by it (`clearTimeout` happens in `finish`).
19
+ *
20
+ * THE KILL IS A TREE KILL. `rg` on Windows is reached through a launcher
21
+ * (`shims/rg.exe`), and killing the launcher alone leaves the real search running
22
+ * forever - which would keep this fix from fixing anything. `killTree` is the one
23
+ * owner of that knowledge in this package and it never throws.
24
+ */
25
+ /** What ended the child, which is the only thing the caller can act on. */
26
+ export type DeadlineOutcome =
27
+ /** The child closed on its own; `exitCode` is real. */
28
+ 'exit'
29
+ /** It outlived `deadlineMs`; it has been killed and `stdout` is what it managed. */
30
+ | 'deadline'
31
+ /** It never started (`ENOENT` and friends). */
32
+ | 'unavailable';
33
+ export interface DeadlineResult {
34
+ outcome: DeadlineOutcome;
35
+ /** `null` for `'deadline'` and `'unavailable'`: there was no exit status. */
36
+ exitCode: number | null;
37
+ /** Everything the child wrote to stdout before it settled. */
38
+ stdout: string;
39
+ }
40
+ export interface DeadlineOptions {
41
+ cwd: string;
42
+ /** Hard limit on the child's lifetime, in milliseconds. Must be > 0. */
43
+ deadlineMs: number;
44
+ }
45
+ /**
46
+ * Run `command` and settle within `deadlineMs`, whatever the child does.
47
+ *
48
+ * stderr is DROPPED rather than piped: nothing here reads it, and an undrained
49
+ * pipe is a second way to wedge a child (the buffer fills and it blocks on write).
50
+ * stdin is `ignore` for the same reason in reverse - a child that decides to read
51
+ * stdin must see EOF, not wait for a user who is not there.
52
+ *
53
+ * Never throws, never rejects: a caller that has to explain a failure gets
54
+ * `'unavailable'` or `'deadline'` instead of an exception it did not expect.
55
+ */
56
+ export declare function runWithDeadline(command: string, args: string[], options: DeadlineOptions): Promise<DeadlineResult>;
@@ -0,0 +1,76 @@
1
+ /**
2
+ * A CHILD PROCESS WITH A DEADLINE (bug-diagnosis 0b33323c, release gate 2026-10-23).
3
+ *
4
+ * WHY THIS EXISTS. `search-tools.ts` used to await a bare `spawn('rg', ...)` whose
5
+ * promise settled on `'close'` and on nothing else. A child that never closes -
6
+ * a launcher shim blocked by a scanner, a wedged network drive, a filesystem that
7
+ * stopped answering - therefore left the tool pending FOREVER: the model's turn
8
+ * had no result to return, the Ink frame had no answer to draw, and the only thing
9
+ * that ever ended it was the test runner's wall clock
10
+ * (`tools.test.ts > grep finds a seeded pattern`, `Test timed out in 30000ms`,
11
+ * 2026-10-23 release). CLAUDE.md section 5 says exactly what is missing here: at
12
+ * an IO / subprocess boundary use a structured wrapper with a timeout, never a
13
+ * bare call.
14
+ *
15
+ * SETTLES ONCE, ALWAYS. Every way a child can end - exit, spawn failure, and OUR
16
+ * OWN deadline - resolves this promise, and the first of them wins. The deadline
17
+ * is the last resort, not a measuring instrument: a caller that has produced an
18
+ * answer is not interrupted by it (`clearTimeout` happens in `finish`).
19
+ *
20
+ * THE KILL IS A TREE KILL. `rg` on Windows is reached through a launcher
21
+ * (`shims/rg.exe`), and killing the launcher alone leaves the real search running
22
+ * forever - which would keep this fix from fixing anything. `killTree` is the one
23
+ * owner of that knowledge in this package and it never throws.
24
+ */
25
+ import { spawn } from 'node:child_process';
26
+ import { killTree } from '../proc/kill-tree.js';
27
+ /**
28
+ * Run `command` and settle within `deadlineMs`, whatever the child does.
29
+ *
30
+ * stderr is DROPPED rather than piped: nothing here reads it, and an undrained
31
+ * pipe is a second way to wedge a child (the buffer fills and it blocks on write).
32
+ * stdin is `ignore` for the same reason in reverse - a child that decides to read
33
+ * stdin must see EOF, not wait for a user who is not there.
34
+ *
35
+ * Never throws, never rejects: a caller that has to explain a failure gets
36
+ * `'unavailable'` or `'deadline'` instead of an exception it did not expect.
37
+ */
38
+ export function runWithDeadline(command, args, options) {
39
+ return new Promise((settle) => {
40
+ let child;
41
+ try {
42
+ child = spawn(command, args, {
43
+ cwd: options.cwd,
44
+ stdio: ['ignore', 'pipe', 'ignore'],
45
+ windowsHide: true,
46
+ });
47
+ }
48
+ catch {
49
+ settle({ outcome: 'unavailable', exitCode: null, stdout: '' });
50
+ return;
51
+ }
52
+ let stdout = '';
53
+ let settled = false;
54
+ let timer;
55
+ const finish = (result) => {
56
+ if (settled)
57
+ return;
58
+ settled = true;
59
+ if (timer !== undefined)
60
+ clearTimeout(timer);
61
+ settle(result);
62
+ };
63
+ timer = setTimeout(() => {
64
+ // A pid is all `killTree` needs, and it is the only kill that reaches a
65
+ // grandchild. Partial output is still output: the caller decides whether a
66
+ // truncated answer is worth having.
67
+ killTree(child.pid);
68
+ finish({ outcome: 'deadline', exitCode: null, stdout });
69
+ }, options.deadlineMs);
70
+ child.stdout?.on('data', (chunk) => {
71
+ stdout += chunk.toString();
72
+ });
73
+ child.on('error', () => finish({ outcome: 'unavailable', exitCode: null, stdout }));
74
+ child.on('close', (code) => finish({ outcome: 'exit', exitCode: code, stdout }));
75
+ });
76
+ }
@@ -5,13 +5,30 @@
5
5
  * prefers ripgrep when it is on PATH and falls back to a pure-JS scan otherwise;
6
6
  * both produce `path:line:text` output.
7
7
  */
8
- import { spawn } from 'node:child_process';
9
8
  import { promises as fs } from 'node:fs';
10
9
  import { isAbsolute, join, relative, resolve } from 'node:path';
11
10
  import { glob as tinyGlob } from 'tinyglobby';
12
11
  import { defineTool, errorResult, textResult, } from '@aragon-agent/core';
12
+ import { runWithDeadline } from './bounded-spawn.js';
13
13
  const MAX_GREP_MATCHES = 500;
14
14
  const MAX_FILE_BYTES = 5_000_000;
15
+ /**
16
+ * How long `rg` may run before the search is abandoned and answered in-process.
17
+ *
18
+ * THE BUDGET IS NOT A GUESS ABOUT SPEED, IT IS THE ONLY WAY THIS TOOL ENDS WHEN
19
+ * `rg` WILL NOT. Measured on the reference machine (2026-10-23): `rg` over a
20
+ * two-file temp directory settles in 58-62 ms (five samples), so 15 s is ~250x
21
+ * the normal case - it cannot fire because a search is merely slow, only because
22
+ * the child is stuck (a launcher shim blocked by a scanner, a filesystem that
23
+ * stopped answering). Without it, the failing 2026-10-23 release run left this
24
+ * tool's promise pending until `tools.test.ts` hit its 30 s wall clock: a hung
25
+ * `rg` was indistinguishable from a hung agent turn, forever.
26
+ *
27
+ * WHEN IT FIRES the in-process scan below answers instead, which is the same
28
+ * fallback a host without `rg` already gets - so a stuck child degrades the
29
+ * search rather than the session.
30
+ */
31
+ const GREP_SUBPROCESS_DEADLINE_MS = 15_000;
15
32
  // ---------------------------------------------------------------------------
16
33
  // glob
17
34
  // ---------------------------------------------------------------------------
@@ -109,51 +126,42 @@ export function makeGrep(deps) {
109
126
  }
110
127
  /**
111
128
  * Attempt ripgrep. Returns the matched lines, `[]` for a clean no-match, or
112
- * `null` when ripgrep is unavailable (so the caller falls back to JS).
129
+ * `null` when ripgrep cannot answer (so the caller falls back to JS).
130
+ *
131
+ * THE SUBPROCESS BUDGET IS PART OF THE CONTRACT (see `GREP_SUBPROCESS_DEADLINE_MS`
132
+ * and `tools/bounded-spawn.ts`). Every one of the three ways `rg` can fail now
133
+ * settles this promise: it exits, it never starts, or it outlives its deadline and
134
+ * the in-process scan takes over. What is gone is the fourth one - hanging here
135
+ * forever - which is what the release gate actually caught.
113
136
  */
114
- function tryRipgrep(params, searchRoot, cwd) {
115
- return new Promise((resolvePromise) => {
116
- const args = ['--no-heading', '--line-number', '--color', 'never', '--max-count', String(MAX_GREP_MATCHES)];
117
- if (params.ignoreCase)
118
- args.push('--ignore-case');
119
- if (params.glob)
120
- args.push('--glob', params.glob);
121
- args.push('--regexp', params.pattern, searchRoot);
122
- let child;
123
- try {
124
- child = spawn('rg', args, { cwd });
125
- }
126
- catch {
127
- resolvePromise(null);
128
- return;
129
- }
130
- let stdout = '';
131
- let settled = false;
132
- const done = (value) => {
133
- if (!settled) {
134
- settled = true;
135
- resolvePromise(value);
136
- }
137
- };
138
- child.on('error', () => done(null)); // ENOENT — rg not installed.
139
- child.stdout?.on('data', (d) => {
140
- stdout += d.toString();
141
- });
142
- child.on('close', (code) => {
143
- // rg exits 0 with matches, 1 with no matches, 2 on error.
144
- if (code === 0 || code === 1) {
145
- const lines = stdout
146
- .split('\n')
147
- .filter((l) => l.trim().length > 0)
148
- .slice(0, MAX_GREP_MATCHES)
149
- .map((l) => normalizeRgLine(l, searchRoot, cwd));
150
- done(lines);
151
- }
152
- else {
153
- done(null);
154
- }
155
- });
137
+ async function tryRipgrep(params, searchRoot, cwd) {
138
+ const args = [
139
+ '--no-heading',
140
+ '--line-number',
141
+ '--color',
142
+ 'never',
143
+ '--max-count',
144
+ String(MAX_GREP_MATCHES),
145
+ ];
146
+ if (params.ignoreCase)
147
+ args.push('--ignore-case');
148
+ if (params.glob)
149
+ args.push('--glob', params.glob);
150
+ args.push('--regexp', params.pattern, searchRoot);
151
+ const run = await runWithDeadline('rg', args, {
152
+ cwd,
153
+ deadlineMs: GREP_SUBPROCESS_DEADLINE_MS,
156
154
  });
155
+ if (run.outcome !== 'exit')
156
+ return null;
157
+ // rg exits 0 with matches, 1 with no matches, 2 on error.
158
+ if (run.exitCode !== 0 && run.exitCode !== 1)
159
+ return null;
160
+ return run.stdout
161
+ .split('\n')
162
+ .filter((line) => line.trim().length > 0)
163
+ .slice(0, MAX_GREP_MATCHES)
164
+ .map((line) => normalizeRgLine(line, searchRoot, cwd));
157
165
  }
158
166
  function normalizeRgLine(line, searchRoot, cwd) {
159
167
  // ripgrep emits absolute or root-relative paths; normalize to cwd-relative.
@@ -11,7 +11,7 @@
11
11
  * off its `clearTerminal` branch (that branch fires at `outputHeight >= rows` and
12
12
  * contains `ESC[3J`, which erases the user's scrollback).
13
13
  *
14
- * Top to bottom: [header][overlay | panel band][composer][status][details].
14
+ * Top to bottom: [overlay | panel band][composer][identity][status][details].
15
15
  *
16
16
  * THE PANEL BAND IS THE PART NATIVE MODE WAS MISSING
17
17
  * (release-gate-and-native-panels §6.1). A fixed frame can put a rail beside the
@@ -40,11 +40,39 @@
40
40
  * content would make the region taller than `budget.liveRows`, and a frame as tall as
41
41
  * the terminal makes Ink erase the user's scrollback.
42
42
  *
43
+ * THE IDENTITY ROW IS A BOTTOM SLOT, NOT THE FIRST ONE
44
+ * (tui-header-leaks-into-scrollback, plan A-prime). It is still `budget.headerRows` tall
45
+ * and still spent out of the region's pool - only its ORDER changed:
46
+ * - position is not cosmetic here, it is the whole defect. The writer lays the canvas out
47
+ * as `[gap][content][region]` and only CONTENT rows ever scroll out (`canvas.ts`), so
48
+ * region row 0 is by construction the successor of the newest row in the terminal's
49
+ * scrollback. A tall reply gives way at the top (`overflow-print.ts`: head printed,
50
+ * tail on screen), so chrome parked on row 0 is read BETWEEN two rows of the SAME
51
+ * answer: the user's report is the word mark, `provider:model`, `cwd`, the key and
52
+ * `^G more` appearing in the middle of a finished reply.
53
+ * - the bottom slots have no such property: nothing below the band is ever printed or
54
+ * scrolled, so `[composer][identity][status][details]` stays outside the conversation
55
+ * for a reply of any length. That band is also the one the writer protects on purpose
56
+ * (I-B17: give way at the TOP, because the composer and the status line are the two
57
+ * rows the human is interacting with).
58
+ * - the two surrounding slots are NOT interchangeable. Above the composer would also
59
+ * dodge the seam, but it would break the promise that the newest line hugs the input
60
+ * box (`canvas.ts`, `tail-anchor.ts`); below the status group would split the
61
+ * `[status][details]` pair, and "the status is the screen's last row" is its own
62
+ * invariant (`tui-duplicate-status-row`); inside the band's row pool would change
63
+ * `previewRows`, and no budget number may move for this fix.
64
+ * - `app` mode (`layout/AppShell.tsx`) deliberately keeps its header on TOP, so the two
65
+ * modes do NOT agree and must not be "unified": there the header sits above a viewport
66
+ * that scrolls itself, so there is no print channel and no seam to leak into.
67
+ * - `headerRows` is still produced by `buildScrollbackBudget` alone (still 1, still
68
+ * subtracted from `remaining`), so `liveRows`, `bandRows`, `previewRows` and `railCols`
69
+ * are unchanged: a row ORDER change, not a row BUDGET change.
70
+ * - the row is still NOT printed into the scrollback: at `H = rows - 1` any printed row
71
+ * is pushed off the screen by the next frame, so "print the header" would mean "the
72
+ * header is never visible".
73
+ *
43
74
  * THE BAND'S PREVIEW BOX IS TOP-ALIGNED; THE OTHER SLOTS ARE FIXED
44
75
  * (D3 / D4 / I-F7; tui-first-reply-bottom-aligned, plan A-prime):
45
- * - `header` is the fixed 1-row slot at the very top. It is NOT printed into the
46
- * scrollback any more: at `H = rows - 1` any printed row is pushed off the screen by
47
- * the next frame, so "print the header" would mean "the header is never visible".
48
76
  * - the band's preview box is TOP-aligned unconditionally, which is why the alignment is
49
77
  * no longer a function of `opener`. `opener` is a 12-row card in a 24-row column, so
50
78
  * bottom-aligning it left 12 blank rows in the upper half of the screen - the original
@@ -71,9 +99,9 @@ import type { ScrollbackBudget } from './scrollback/budget.js';
71
99
  export interface ScrollbackShellProps {
72
100
  budget: ScrollbackBudget;
73
101
  /**
74
- * REQUIRED: the one-row slot at the top of the region, `budget.headerRows` tall.
75
- * Required rather than optional for the reason `budget` and `band` are - a frame
76
- * that forgot it would render a header-less screen with nothing to catch it.
102
+ * REQUIRED: the identity row, `budget.headerRows` tall, drawn BETWEEN the composer and
103
+ * the status group. Required rather than optional for the reason `budget` and `band` are
104
+ * - a frame that forgot it would render a header-less screen with nothing to catch it.
77
105
  */
78
106
  header: React.ReactNode;
79
107
  /** Shown instead of the band when an overlay is open. */
@@ -22,5 +22,5 @@ export function ScrollbackShell({ budget, header, overlay, liveTailLines, opener
22
22
  const top = band.reason === 'overlay' ? (_jsx(Box, { flexDirection: "column", height: budget.overlayRows, flexShrink: 0, overflow: "hidden", children: overlay })) : band.bandRows > 0 ? (
23
23
  // ROW REGION ABOVE, FULL-WIDTH ROSTER BELOW (tui-subagent-panel §4.1.2 / D-2).
24
24
  _jsxs(Box, { flexDirection: "column", height: band.bandRows, flexShrink: 0, overflow: "hidden", children: [_jsxs(Box, { flexDirection: "row", height: rowRegion, flexShrink: 0, overflow: "hidden", children: [_jsx(Box, { flexDirection: "column", width: budget.previewCols, height: rowRegion, flexShrink: 0, overflow: "hidden", children: band.previewRows > 0 && (_jsx(Box, { flexDirection: "column", height: band.previewRows, flexShrink: 0, justifyContent: "flex-start", overflow: "hidden", children: leftColumn ?? tailLines.map((line, index) => (_jsx(Text, { wrap: "truncate", color: theme.muted, children: line }, index))) })) }), band.railRows > 0 && (_jsx(Box, { height: band.railRows, width: band.railCols, flexShrink: 0, overflow: "hidden", children: rail }))] }), band.teamRows > 0 && (_jsx(Box, { width: width, height: band.teamRows, flexShrink: 0, overflow: "hidden", children: team }))] })) : null;
25
- return (_jsxs(Box, { flexDirection: "column", height: budget.liveRows, width: width, overflow: "hidden", flexShrink: 0, children: [budget.headerRows > 0 && (_jsx(Box, { height: budget.headerRows, flexShrink: 0, overflow: "hidden", children: header })), top, _jsx(Box, { flexDirection: "column", height: budget.composerSlotRows, justifyContent: "flex-end", flexShrink: 0, overflow: "hidden", children: composer }), _jsx(Box, { height: budget.statusRows > 0 ? 1 : 0, flexShrink: 0, overflow: "hidden", children: status }), budget.statusRows === 2 && (_jsx(Box, { height: 1, flexShrink: 0, overflow: "hidden", children: details }))] }));
25
+ return (_jsxs(Box, { flexDirection: "column", height: budget.liveRows, width: width, overflow: "hidden", flexShrink: 0, children: [top, _jsx(Box, { flexDirection: "column", height: budget.composerSlotRows, justifyContent: "flex-end", flexShrink: 0, overflow: "hidden", children: composer }), budget.headerRows > 0 && (_jsx(Box, { height: budget.headerRows, flexShrink: 0, overflow: "hidden", children: header })), _jsx(Box, { height: budget.statusRows > 0 ? 1 : 0, flexShrink: 0, overflow: "hidden", children: status }), budget.statusRows === 2 && (_jsx(Box, { height: 1, flexShrink: 0, overflow: "hidden", children: details }))] }));
26
26
  }
@@ -32,7 +32,9 @@
32
32
  * so a four-row first turn was pushed against the composer and every unused row sat ABOVE
33
33
  * the conversation. `tailAnchor` therefore sums the estimated height of exactly the
34
34
  * entries this frame renders and asks for `flex-start` while they fit - the conversation
35
- * is then written downwards from under the header, the way a terminal writes.
35
+ * is then written downwards from the region's top row, the way a terminal writes. (That row
36
+ * belongs to the band: `tui-header-leaks-into-scrollback` moved the identity row below the
37
+ * composer, so nothing sits between the newest printed row and the first row of the tail.)
36
38
  *
37
39
  * THE OVERFLOW FRAME STAYS `flex-end`, AND THAT IS THE OTHER HALF OF THE FIX.
38
40
  * `flex-start` PLUS `overflow="hidden"` clips the NEWEST rows (Ink's clip rectangle is
@@ -12,15 +12,20 @@
12
12
  * and every row it did not ask for became `gap` - blank rows ABOVE the content, which
13
13
  * is what the user reported as "the app starts halfway down the screen" and as "a big
14
14
  * blank between the result and the running card". The band now takes the whole pool
15
- * (`fill`), so the region is `[header][band][composer][status]` top to bottom and
15
+ * (`fill`), so the region is `[band][composer][identity][status]` top to bottom and
16
16
  * `gap` cannot exist: `resolveCanvasLayout` is given `frameRows === capacity` and
17
17
  * derives `gap = 0` (see the spec's §5.5 proof, which is why the writer needs no
18
18
  * change at all).
19
19
  *
20
20
  * The composer, popup and status rows come from the SAME `buildFrameBudget` the
21
21
  * full-screen layout uses, so the two modes cannot disagree about how tall the
22
- * editor is. What differs is the top: the header is a real slot here (D3), and the
23
- * row it occupies is spent from the region, not from the printed transcript.
22
+ * editor is. What differs is the top: the identity row is a real slot here (D3), and
23
+ * the row it occupies is spent from the region, not from the printed transcript.
24
+ * THE COUNT IS ALL THIS MODULE OWNS, NOT THE POSITION: since
25
+ * `tui-header-leaks-into-scrollback` the identity row is drawn between the composer and
26
+ * the status group (`ScrollbackShell.tsx`), because region row 0 is necessarily the
27
+ * successor of the newest printed row and chrome there lands inside the conversation.
28
+ * Moving it changed no number below: it is still 1 row, still taken out of `remaining`.
24
29
  *
25
30
  * THIS IS THE ONLY CALL SITE OF `buildLiveBand` (I-8). `remaining` is computed here
26
31
  * once and handed to the band; an `App` that computed its own copy would make
@@ -58,9 +63,10 @@ export interface ScrollbackBudget {
58
63
  /** Terminal below the 40x12 floor: the region is a one-row placeholder. */
59
64
  inactive: boolean;
60
65
  /**
61
- * The header slot, a real row inside the region (D3). 0 on a degraded frame: the
62
- * placeholder draws no header, and reporting 1 there would break the row identity
63
- * (I-F4) on exactly the frames `/perf` is read to explain.
66
+ * The identity slot, a real row inside the region (D3), drawn between the composer and
67
+ * the status group. 0 on a degraded frame: the placeholder draws no header, and
68
+ * reporting 1 there would break the row identity (I-F4) on exactly the frames `/perf` is
69
+ * read to explain.
64
70
  */
65
71
  headerRows: number;
66
72
  statusRows: 0 | 1 | 2;
@@ -12,15 +12,20 @@
12
12
  * and every row it did not ask for became `gap` - blank rows ABOVE the content, which
13
13
  * is what the user reported as "the app starts halfway down the screen" and as "a big
14
14
  * blank between the result and the running card". The band now takes the whole pool
15
- * (`fill`), so the region is `[header][band][composer][status]` top to bottom and
15
+ * (`fill`), so the region is `[band][composer][identity][status]` top to bottom and
16
16
  * `gap` cannot exist: `resolveCanvasLayout` is given `frameRows === capacity` and
17
17
  * derives `gap = 0` (see the spec's §5.5 proof, which is why the writer needs no
18
18
  * change at all).
19
19
  *
20
20
  * The composer, popup and status rows come from the SAME `buildFrameBudget` the
21
21
  * full-screen layout uses, so the two modes cannot disagree about how tall the
22
- * editor is. What differs is the top: the header is a real slot here (D3), and the
23
- * row it occupies is spent from the region, not from the printed transcript.
22
+ * editor is. What differs is the top: the identity row is a real slot here (D3), and
23
+ * the row it occupies is spent from the region, not from the printed transcript.
24
+ * THE COUNT IS ALL THIS MODULE OWNS, NOT THE POSITION: since
25
+ * `tui-header-leaks-into-scrollback` the identity row is drawn between the composer and
26
+ * the status group (`ScrollbackShell.tsx`), because region row 0 is necessarily the
27
+ * successor of the newest printed row and chrome there lands inside the conversation.
28
+ * Moving it changed no number below: it is still 1 row, still taken out of `remaining`.
24
29
  *
25
30
  * THIS IS THE ONLY CALL SITE OF `buildLiveBand` (I-8). `remaining` is computed here
26
31
  * once and handed to the band; an `App` that computed its own copy would make
@@ -56,9 +61,11 @@ export function buildScrollbackBudget(input) {
56
61
  }
57
62
  // D1 · H = C. `frameRows` is `rows - 1` by construction (`layout/budget.ts`), so the
58
63
  // region ends exactly on the row above the writer's parked cursor.
64
+ // The identity row is drawn at the BOTTOM now; it is still one row and still deducted
65
+ // here, which is why `liveRows`, `bandRows` and `previewRows` did not move with it.
59
66
  const headerRows = 1;
60
67
  const liveRows = Math.max(1, base.frameRows);
61
- // The whole pool: everything between the header and the status line. Two independent
68
+ // The whole pool: everything above the composer slot. Two independent
62
69
  // derivations put its floor at 1, so `max(0, ...)` is defensive only:
63
70
  // 1. `composerSlotRows = 2 + clampDraftRows(rows, draftRows)` and
64
71
  // `draftMaxRows(rows) = max(1, min(6, floor(rows / 4)))`, so on the narrowest
@@ -7,7 +7,9 @@
7
7
  * `justifyContent="flex-end"` therefore pushed those four rows against the composer and
8
8
  * left every unused row ABOVE the conversation - the "the first reply starts at the
9
9
  * bottom of the screen" report. The answer is conditional anchoring: top while the
10
- * content fits, so the conversation is written downwards from under the header.
10
+ * content fits, so the conversation is written downwards from the region's top row
11
+ * (`tui-header-leaks-into-scrollback` took the identity row off that row and put it
12
+ * below the composer, so the first line of the band is now the first line of the screen).
11
13
  *
12
14
  * WHY THE FALLBACK IS `bottom` AND NOT THE OTHER WAY ROUND. `flex-start` inside an
13
15
  * `overflow="hidden"` box clips the NEWEST rows (Ink's clip rectangle is the box's
@@ -26,7 +28,8 @@
26
28
  * parameters and a unit test can drive it with any table it likes.
27
29
  */
28
30
  import type { Entry } from '../../agent/reducer.js';
29
- /** `top` = content grows down from the header; `bottom` = newest row hugs the composer. */
31
+ /** `top` = content grows down from the region's top row; `bottom` = newest row hugs the
32
+ * composer. */
30
33
  export type TailAnchor = 'top' | 'bottom';
31
34
  /**
32
35
  * Which alignment the live tail should use for the entries it is about to render.
@@ -7,7 +7,9 @@
7
7
  * `justifyContent="flex-end"` therefore pushed those four rows against the composer and
8
8
  * left every unused row ABOVE the conversation - the "the first reply starts at the
9
9
  * bottom of the screen" report. The answer is conditional anchoring: top while the
10
- * content fits, so the conversation is written downwards from under the header.
10
+ * content fits, so the conversation is written downwards from the region's top row
11
+ * (`tui-header-leaks-into-scrollback` took the identity row off that row and put it
12
+ * below the composer, so the first line of the band is now the first line of the screen).
11
13
  *
12
14
  * WHY THE FALLBACK IS `bottom` AND NOT THE OTHER WAY ROUND. `flex-start` inside an
13
15
  * `overflow="hidden"` box clips the NEWEST rows (Ink's clip rectangle is the box's
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aragon-agent/cli",
3
- "version": "0.6.43",
3
+ "version": "0.6.44",
4
4
  "description": "AragonAgent CLI — a Claude-Code / Codex-style interactive terminal UI (TUI) on top of the @aragon-agent/core engine. Installable and runnable via npm / npx.",
5
5
  "license": "MIT",
6
6
  "author": "AragonAgent contributors",
@@ -56,7 +56,7 @@
56
56
  "prepublishOnly": "npm run build"
57
57
  },
58
58
  "dependencies": {
59
- "@aragon-agent/core": "^0.2.55",
59
+ "@aragon-agent/core": "^0.2.56",
60
60
  "chalk": "^5.6.2",
61
61
  "cli-highlight": "^2.0.0",
62
62
  "commander": "^12.1.0",