@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.
- package/dist/proc/kill-tree.d.ts +19 -1
- package/dist/proc/kill-tree.js +32 -5
- package/dist/proc/limits.d.ts +19 -0
- package/dist/proc/limits.js +19 -0
- package/dist/proc/supervisor.js +8 -1
- package/dist/tools/bounded-spawn.d.ts +56 -0
- package/dist/tools/bounded-spawn.js +76 -0
- package/dist/tools/search-tools.js +52 -44
- package/dist/ui/ScrollbackShell.d.ts +35 -7
- package/dist/ui/ScrollbackShell.js +1 -1
- package/dist/ui/scrollback/LiveTail.d.ts +3 -1
- package/dist/ui/scrollback/budget.d.ts +12 -6
- package/dist/ui/scrollback/budget.js +11 -4
- package/dist/ui/scrollback/tail-anchor.d.ts +5 -2
- package/dist/ui/scrollback/tail-anchor.js +3 -1
- package/package.json +2 -2
package/dist/proc/kill-tree.d.ts
CHANGED
|
@@ -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):
|
|
60
|
+
export declare function killTreeSync(pid: number | undefined): boolean;
|
package/dist/proc/kill-tree.js
CHANGED
|
@@ -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:
|
|
120
|
+
timeout: PROC_LIMITS.killTreeSyncTimeoutMs,
|
|
101
121
|
});
|
|
102
122
|
}
|
|
103
123
|
catch {
|
|
104
|
-
|
|
124
|
+
return false;
|
|
105
125
|
}
|
|
106
|
-
|
|
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
|
}
|
package/dist/proc/limits.d.ts
CHANGED
|
@@ -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
|
/**
|
package/dist/proc/limits.js
CHANGED
|
@@ -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 --------
|
package/dist/proc/supervisor.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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: [
|
|
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
|
|
75
|
-
* Required rather than optional for the reason `budget` and `band` are
|
|
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: [
|
|
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
|
|
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 `[
|
|
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
|
|
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
|
|
62
|
-
*
|
|
63
|
-
* (I-F4) on exactly the frames `/perf` is
|
|
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 `[
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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",
|