@aragon-agent/cli 0.6.42 → 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/agent/controller.js +9 -12
- package/dist/agent/headless.js +6 -2
- package/dist/exec/runner.js +2 -1
- 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/team/bus.d.ts +4 -0
- package/dist/team/bus.js +12 -2
- package/dist/team/comm-tools.js +3 -0
- package/dist/team/limits.d.ts +1 -1
- package/dist/team/limits.js +1 -1
- package/dist/team/model.d.ts +17 -1
- package/dist/team/model.js +14 -0
- package/dist/team/overseer.d.ts +4 -0
- package/dist/team/overseer.js +38 -6
- package/dist/team/prompt.js +3 -3
- package/dist/team/report.d.ts +21 -0
- package/dist/team/report.js +40 -9
- package/dist/team/runtime.d.ts +3 -0
- package/dist/team/runtime.js +61 -5
- package/dist/team/subagent.js +25 -7
- 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/App.js +24 -10
- 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/team/runtime.js
CHANGED
|
@@ -361,6 +361,36 @@ export class TeamRuntime {
|
|
|
361
361
|
// Dispatch
|
|
362
362
|
// -------------------------------------------------------------------------
|
|
363
363
|
async dispatch(specs, requested, signal) {
|
|
364
|
+
try {
|
|
365
|
+
return await this.runDispatch(specs, requested, signal);
|
|
366
|
+
}
|
|
367
|
+
catch (error) {
|
|
368
|
+
// A throw out of the dispatch body (a child that cannot be constructed) must not
|
|
369
|
+
// leave `busy` latched: every later `task` call, `/team` and busy-guarded command
|
|
370
|
+
// of the session would answer "a dispatch is already running". The success path
|
|
371
|
+
// tears down inline and never reaches this.
|
|
372
|
+
this.resetDispatchState();
|
|
373
|
+
throw error;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
/** Idempotent: puts every per-dispatch field back where `dispatch()` found it. */
|
|
377
|
+
resetDispatchState() {
|
|
378
|
+
if (this.dispatchTimer)
|
|
379
|
+
clearTimeout(this.dispatchTimer);
|
|
380
|
+
this.dispatchTimer = null;
|
|
381
|
+
for (const handle of this.live)
|
|
382
|
+
handle.unsubscribe();
|
|
383
|
+
this.monitor?.dispose();
|
|
384
|
+
this.monitor = null;
|
|
385
|
+
this.supervision?.clearAll();
|
|
386
|
+
this.supervision = null;
|
|
387
|
+
this.overseer = null;
|
|
388
|
+
this.live = [];
|
|
389
|
+
this.bus = null;
|
|
390
|
+
this.snapshotState = null;
|
|
391
|
+
this.busy = false;
|
|
392
|
+
}
|
|
393
|
+
async runDispatch(specs, requested, signal) {
|
|
364
394
|
this.counter += 1;
|
|
365
395
|
const dispatchId = `d${this.counter}`;
|
|
366
396
|
const config = this.deps.getConfig();
|
|
@@ -703,7 +733,9 @@ export class TeamRuntime {
|
|
|
703
733
|
// where the replacement is actually built and driven. `attempt` resets so
|
|
704
734
|
// the fresh body gets its own cold-start budget.
|
|
705
735
|
if (verdict !== null && verdict.action === 'replace' && !this.aborted) {
|
|
706
|
-
this.replaceChild(dispatchId, handles, index, makeHandle, handle, verdict)
|
|
736
|
+
if (this.replaceChild(dispatchId, handles, index, makeHandle, handle, verdict) === null) {
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
707
739
|
attempt = 0;
|
|
708
740
|
continue;
|
|
709
741
|
}
|
|
@@ -734,7 +766,8 @@ export class TeamRuntime {
|
|
|
734
766
|
this.monitor?.clear(run.label);
|
|
735
767
|
return;
|
|
736
768
|
}
|
|
737
|
-
this.replaceChild(dispatchId, handles, index, makeHandle, handle, null)
|
|
769
|
+
if (this.replaceChild(dispatchId, handles, index, makeHandle, handle, null) === null)
|
|
770
|
+
return;
|
|
738
771
|
await this.backoff(TEAM_LIMITS.retryBackoffMs);
|
|
739
772
|
if (this.aborted) {
|
|
740
773
|
const fresh = handles[index];
|
|
@@ -778,12 +811,13 @@ export class TeamRuntime {
|
|
|
778
811
|
trigger: 'postmortem',
|
|
779
812
|
});
|
|
780
813
|
if (decision.action === 'replace' && overseer.replacementsLeft(run.label)) {
|
|
781
|
-
this.replaceChild(ctx.dispatchId, ctx.handles, index, ctx.makeHandle, handle, {
|
|
814
|
+
const fresh = this.replaceChild(ctx.dispatchId, ctx.handles, index, ctx.makeHandle, handle, {
|
|
782
815
|
action: 'replace',
|
|
783
816
|
reason: decision.reason,
|
|
784
817
|
...(decision.guidance !== undefined ? { guidance: decision.guidance } : {}),
|
|
785
818
|
});
|
|
786
|
-
|
|
819
|
+
if (fresh !== null)
|
|
820
|
+
await this.runOne(ctx, index);
|
|
787
821
|
}
|
|
788
822
|
}
|
|
789
823
|
/**
|
|
@@ -822,13 +856,35 @@ export class TeamRuntime {
|
|
|
822
856
|
}, handle.run),
|
|
823
857
|
}
|
|
824
858
|
: handle.spec;
|
|
825
|
-
|
|
859
|
+
let fresh;
|
|
860
|
+
try {
|
|
861
|
+
fresh = makeHandle(spec);
|
|
862
|
+
}
|
|
863
|
+
catch (err) {
|
|
864
|
+
// The old body is already unsubscribed and aborted, so nothing will ever update
|
|
865
|
+
// its run again: left alone the panel shows it running forever. Settle IT.
|
|
866
|
+
const run = handle.run;
|
|
867
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
868
|
+
run.phase = 'failed';
|
|
869
|
+
run.error = `${run.error ? `${run.error}; ` : ''}could not build replacement: ${reason}`;
|
|
870
|
+
run.endedAt = run.endedAt ?? Date.now();
|
|
871
|
+
this.supervision?.clear(run.label);
|
|
872
|
+
this.monitor?.clear(run.label);
|
|
873
|
+
this.publish(dispatchId, run);
|
|
874
|
+
return null;
|
|
875
|
+
}
|
|
826
876
|
// Provably {0,0} under the cold-start predicate above (no turn completed,
|
|
827
877
|
// so no `turn_end` and no usage event). Carried anyway so that a future
|
|
828
878
|
// relaxation of the predicate cannot silently under-report spend - the most
|
|
829
879
|
// misleading failure this feature can produce.
|
|
830
880
|
fresh.run.usage = carried;
|
|
831
881
|
fresh.run.retries = retries;
|
|
882
|
+
// The files the discarded body already wrote are on disk whatever happens next,
|
|
883
|
+
// so the report and the conflict warning must keep seeing them (and a chained
|
|
884
|
+
// replacement must keep telling the next body about them). ONLY the ledger moves:
|
|
885
|
+
// `turns` / `toolCalls` are live controls (turn cap, cold-start retry predicate,
|
|
886
|
+
// supervisor digest) that describe the CURRENT body. Empty on a cold-start retry.
|
|
887
|
+
fresh.run.filesTouched = handle.run.filesTouched.slice(0, TEAM_LIMITS.filesTouchedMax);
|
|
832
888
|
if (replacements > 0)
|
|
833
889
|
fresh.run.replacements = replacements;
|
|
834
890
|
if (interventions !== undefined)
|
package/dist/team/subagent.js
CHANGED
|
@@ -15,6 +15,7 @@ import { formatStreamError } from '../agent/reducer.js';
|
|
|
15
15
|
import { buildSystemPrompt } from '../agent/system-prompt.js';
|
|
16
16
|
import { buildFastBlock } from '../fast/prompt.js';
|
|
17
17
|
import { createBuiltinTools, gatedToolTimeouts, } from '../tools/index.js';
|
|
18
|
+
import { ledgerPathKey, normalizeLedgerPath } from './report.js';
|
|
18
19
|
import { describeToolActivity, pickActivityArgs, sanitizeActivity } from './activity.js';
|
|
19
20
|
import { TEAM_LIMITS, TEAM_SUBAGENT_TOOL_NAMES } from './limits.js';
|
|
20
21
|
import { appendTimeline } from './timeline.js';
|
|
@@ -386,17 +387,26 @@ export function createSubagent(spec, deps, hooks) {
|
|
|
386
387
|
textTail.length > 0 ? sanitizeActivity(textTail, TEAM_LIMITS.activityChars) : undefined;
|
|
387
388
|
hooks.onUpdate({ ...run, filesTouched: [...run.filesTouched] });
|
|
388
389
|
};
|
|
389
|
-
|
|
390
|
+
/**
|
|
391
|
+
* The path a write tool is ABOUT to touch, or `undefined` for every other tool.
|
|
392
|
+
* Staged on the start event and committed on the end event, because only the end
|
|
393
|
+
* event knows whether the write happened (`isError`). The string is the path alone,
|
|
394
|
+
* never the file body (P1-4 / P1-5).
|
|
395
|
+
*/
|
|
396
|
+
const stagedWritePath = (toolName, args) => {
|
|
390
397
|
if (toolName !== 'write_file' && toolName !== 'edit_file')
|
|
391
|
-
return;
|
|
398
|
+
return undefined;
|
|
392
399
|
const path = args?.path;
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
400
|
+
return typeof path === 'string' && path.length > 0 ? path : undefined;
|
|
401
|
+
};
|
|
402
|
+
const noteFile = (path) => {
|
|
403
|
+
const normalized = normalizeLedgerPath(deps.getCwd(), path);
|
|
404
|
+
const key = ledgerPathKey(normalized);
|
|
405
|
+
if (run.filesTouched.some((known) => ledgerPathKey(known) === key))
|
|
396
406
|
return;
|
|
397
407
|
if (run.filesTouched.length >= TEAM_LIMITS.filesTouchedMax)
|
|
398
408
|
return;
|
|
399
|
-
run.filesTouched.push(
|
|
409
|
+
run.filesTouched.push(normalized);
|
|
400
410
|
};
|
|
401
411
|
const unsubscribe = agent.subscribe((event) => {
|
|
402
412
|
switch (event.type) {
|
|
@@ -476,18 +486,26 @@ export function createSubagent(spec, deps, hooks) {
|
|
|
476
486
|
// Recorded for the DURATION only; the entry itself is appended on the end
|
|
477
487
|
// event, when `ok` and `ms` are both known (§4.5.2).
|
|
478
488
|
pendingTool = { name: event.toolName, at: Date.now() };
|
|
489
|
+
const stagedPath = stagedWritePath(event.toolName, event.args);
|
|
490
|
+
if (stagedPath !== undefined)
|
|
491
|
+
pendingTool.path = stagedPath;
|
|
479
492
|
run.phase = event.toolName === 'team_wait' ? 'waiting' : 'tool';
|
|
480
493
|
run.lastTool = event.toolName;
|
|
481
494
|
run.toolCalls += 1;
|
|
482
495
|
textTail = '';
|
|
483
496
|
run.activity = undefined; // prose is stale now
|
|
484
497
|
run.activityArgs = pickActivityArgs(event.args); // PICKED, not raw (P1-4)
|
|
485
|
-
noteFile(event.toolName, event.args);
|
|
486
498
|
break;
|
|
487
499
|
case 'tool_execution_end': {
|
|
488
500
|
const endedAt = Date.now();
|
|
489
501
|
const startedAt = pendingTool?.at;
|
|
502
|
+
const writtenPath = pendingTool?.path;
|
|
490
503
|
pendingTool = null;
|
|
504
|
+
// The ledger records a write that HAPPENED: a refused (plan gate, --confirm,
|
|
505
|
+
// sandbox) or failed write is not a touched file, and the timeline below
|
|
506
|
+
// already reads `isError` for the same reason.
|
|
507
|
+
if (writtenPath !== undefined && event.isError !== true)
|
|
508
|
+
noteFile(writtenPath);
|
|
491
509
|
// THE ARGUMENTS ARE GONE BY NOW, on purpose (P1-5): `activityArgs` is cleared a
|
|
492
510
|
// few lines below, and the timeline's text comes from `describeToolActivity` on
|
|
493
511
|
// the PICKED subset, so a `write_file` body can never reach the board.
|
|
@@ -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.
|
package/dist/ui/App.js
CHANGED
|
@@ -55,6 +55,7 @@ import { advanceBudget, decideFollowThrough, emptyBudget, } from '../todo/follow
|
|
|
55
55
|
// no policy. Imported beside the todo decision it shares one timer with.
|
|
56
56
|
import { decideForcedDispatch, FORCED_NUDGE_NOTICE } from '../team/dispatch-gate.js';
|
|
57
57
|
import { FORCED_NUDGE_GRACE_MS } from '../team/limits.js';
|
|
58
|
+
import { priceTeamUsage } from '../team/model.js';
|
|
58
59
|
import { useTerminalSize } from './layout/useTerminalSize.js';
|
|
59
60
|
import { useHeightStore } from './use-height-store.js';
|
|
60
61
|
import { useRenderGovernor } from './use-render-governor.js';
|
|
@@ -929,21 +930,34 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
|
|
|
929
930
|
// handler above records): this effect's dependency list never changes,
|
|
930
931
|
// so it closes over the `cfg` object of the render it mounted in, and
|
|
931
932
|
// `setModel` builds a NEW one.
|
|
932
|
-
|
|
933
|
-
//
|
|
934
|
-
//
|
|
935
|
-
|
|
936
|
-
//
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
:
|
|
933
|
+
//
|
|
934
|
+
// PRICED AT THE MODEL THE CHILDREN RAN (`team.model`), through the same
|
|
935
|
+
// resolution the dispatch report prices with: under `fast` / `profile` the
|
|
936
|
+
// lead's table would over-report by up to an order of magnitude, and an
|
|
937
|
+
// unpriced child model must read as unknown (ZERO here), not as the lead's.
|
|
938
|
+
const costDelta = priceTeamUsage(controller.subagentModelResolution(), event.usage, {
|
|
939
|
+
mainCost: controller.getModelInfo().cost,
|
|
940
|
+
isPriced: (ref) => controller.isPricedModel(ref),
|
|
941
|
+
costFor: (ref) => controller.getModelInfoFor(ref).cost,
|
|
942
|
+
});
|
|
941
943
|
dispatch({ type: 'teamUsage', usage: event.usage, costDelta });
|
|
942
944
|
break;
|
|
943
945
|
}
|
|
944
|
-
case 'dispatch_end':
|
|
946
|
+
case 'dispatch_end': {
|
|
947
|
+
// The supervisor's own calls never emit a usage event (they go through the fast
|
|
948
|
+
// registry directly), so their spend reaches the readout HERE, once per
|
|
949
|
+
// dispatch, at the fast tier's price - the same channel review spend uses.
|
|
950
|
+
const spend = event.outcome.overseerUsage;
|
|
951
|
+
if (spend !== undefined && (spend.inputTokens > 0 || spend.outputTokens > 0)) {
|
|
952
|
+
const tier = controller.getFastStatus().tier;
|
|
953
|
+
const costDelta = tier.ok && controller.isPricedModel(tier.ref)
|
|
954
|
+
? computeCost(spend, controller.getModelInfoFor(tier.ref).cost)
|
|
955
|
+
: 0;
|
|
956
|
+
dispatch({ type: 'fastUsage', usage: spend, costDelta });
|
|
957
|
+
}
|
|
945
958
|
dispatch({ type: 'teamEnd', outcome: event.outcome });
|
|
946
959
|
break;
|
|
960
|
+
}
|
|
947
961
|
}
|
|
948
962
|
});
|
|
949
963
|
return () => {
|
|
@@ -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;
|