@aragon-agent/cli 0.6.25 → 0.6.26
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/CHANGELOG.md +89 -0
- package/README.md +165 -13
- package/dist/agent/reducer.d.ts +22 -0
- package/dist/agent/reducer.js +4 -1
- package/dist/cli.js +214 -2
- package/dist/commands/branch-command.d.ts +61 -0
- package/dist/commands/branch-command.js +117 -0
- package/dist/commands/builtins.js +48 -6
- package/dist/commands/registry.d.ts +28 -0
- package/dist/commands/session-command.d.ts +54 -0
- package/dist/commands/session-command.js +109 -0
- package/dist/config/load.d.ts +15 -0
- package/dist/exec/index.js +10 -1
- package/dist/logging/logger.d.ts +1 -1
- package/dist/proc/terminal-plan.d.ts +155 -0
- package/dist/proc/terminal-plan.js +360 -0
- package/dist/session/branch-invocation.d.ts +83 -0
- package/dist/session/branch-invocation.js +109 -0
- package/dist/session/branch-launch.d.ts +70 -0
- package/dist/session/branch-launch.js +248 -0
- package/dist/session/checkpoint.d.ts +257 -0
- package/dist/session/checkpoint.js +512 -0
- package/dist/session/cli-commands.d.ts +2 -0
- package/dist/session/cli-commands.js +36 -5
- package/dist/session/identity.d.ts +72 -0
- package/dist/session/identity.js +148 -0
- package/dist/session/limits.d.ts +66 -0
- package/dist/session/limits.js +66 -0
- package/dist/session/persist.d.ts +30 -5
- package/dist/session/startup-session.d.ts +107 -0
- package/dist/session/startup-session.js +315 -0
- package/dist/session/store.d.ts +60 -1
- package/dist/session/store.js +95 -6
- package/dist/ui/App.d.ts +54 -2
- package/dist/ui/App.js +115 -21
- package/dist/ui/Header.js +8 -16
- package/dist/ui/StatusBar.d.ts +8 -0
- package/dist/ui/StatusBar.js +12 -3
- package/dist/ui/layout/header-hints.d.ts +68 -0
- package/dist/ui/layout/header-hints.js +138 -0
- package/dist/ui/layout/session-chip.d.ts +52 -0
- package/dist/ui/layout/session-chip.js +64 -0
- package/dist/ui/layout/status-layout.d.ts +10 -0
- package/dist/ui/layout/status-layout.js +8 -1
- package/dist/ui/overlays/HelpOverlay.js +9 -3
- package/dist/ui/session-bridge.d.ts +26 -0
- package/dist/ui/session-bridge.js +32 -0
- package/package.json +2 -2
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What command the new terminal runs (tui-branch-terminal-shortcut 3.4).
|
|
3
|
+
*
|
|
4
|
+
* ASCII ONLY - `src/session/**` is inside `glyphs.test.ts::inScope`.
|
|
5
|
+
*
|
|
6
|
+
* THIS MODULE HAS NO IO ON PURPOSE. It answers two questions and nothing else:
|
|
7
|
+
* "which argv pieces make up the aragon command" and "how does that sentence read
|
|
8
|
+
* when printed to a human". Spawning lives in `session/branch-launch.ts`, and the
|
|
9
|
+
* terminal ladder lives in `proc/terminal-plan.ts`, so the one part of this
|
|
10
|
+
* feature that a unit test can pin exactly - the command line - is not buried
|
|
11
|
+
* inside an `spawn()` call. The repository already draws the boundary this way:
|
|
12
|
+
* `proc/readiness.ts` and `proc/classify.ts` are pure, `proc/supervisor.ts` is the
|
|
13
|
+
* part that touches processes.
|
|
14
|
+
*
|
|
15
|
+
* NAMED `branch-invocation`, NOT `branch-command` (R-22): `commands/branch-command.ts`
|
|
16
|
+
* is the command body, and two files with one basename make every import, stack
|
|
17
|
+
* trace and grep ambiguous. What this module exports is an *invocation*.
|
|
18
|
+
*/
|
|
19
|
+
/** Which of the three resolution rules produced the command (D-8). */
|
|
20
|
+
export type AragonCommandKind = 'override' | 'script' | 'bin';
|
|
21
|
+
export interface AragonCommandResolution {
|
|
22
|
+
/** argv pieces, ready to hand to a shell or a terminal emulator. */
|
|
23
|
+
parts: string[];
|
|
24
|
+
kind: AragonCommandKind;
|
|
25
|
+
/** Human-readable note for the log and the error copy. Never part of the command. */
|
|
26
|
+
origin: string;
|
|
27
|
+
}
|
|
28
|
+
export interface BranchInvocation {
|
|
29
|
+
/** `[...aragon.parts, '--resume', id]`. */
|
|
30
|
+
parts: string[];
|
|
31
|
+
/** One line, pasteable into cmd / PowerShell / sh (quoted where needed). */
|
|
32
|
+
command: string;
|
|
33
|
+
}
|
|
34
|
+
export declare const BRANCH_RESUME_FLAG = "--resume";
|
|
35
|
+
/** The env var that overrides "what executable the new window runs" (D-7 sibling). */
|
|
36
|
+
export declare const ARAGON_BRANCH_COMMAND_ENV = "ARAGON_BRANCH_COMMAND";
|
|
37
|
+
/**
|
|
38
|
+
* Decide what to execute in the new window (D-8), first match wins.
|
|
39
|
+
*
|
|
40
|
+
* The precedence exists because `aragon` is NOT on PATH in three of this
|
|
41
|
+
* repository's own four ways of running: `npm run dev:cli`, `npx
|
|
42
|
+
* @aragon-agent/cli`, and `node packages/cli/dist/cli.js`. Rule 2 recovers all
|
|
43
|
+
* three from `process.execPath` + `process.argv[1]` without consulting PATH at
|
|
44
|
+
* all, which is the only way "press the key and something starts" holds on a
|
|
45
|
+
* machine where the global install is missing.
|
|
46
|
+
*
|
|
47
|
+
* The override is treated as ONE executable path and never split into arguments:
|
|
48
|
+
* splitting needs a shell parser, and a shell parser is what re-introduces the
|
|
49
|
+
* quoting problem this feature spends a whole module avoiding. The documented
|
|
50
|
+
* escape hatch is a `.cmd` / `.sh` wrapper.
|
|
51
|
+
*/
|
|
52
|
+
export declare function resolveAragonCommand(input: {
|
|
53
|
+
argv: readonly string[];
|
|
54
|
+
execPath: string;
|
|
55
|
+
env: Record<string, string | undefined>;
|
|
56
|
+
}): AragonCommandResolution;
|
|
57
|
+
/** `parts` + `--resume <id>`, plus the one-line form for toasts and manual fallback. */
|
|
58
|
+
export declare function buildResumeInvocation(input: {
|
|
59
|
+
id: string;
|
|
60
|
+
aragon: AragonCommandResolution;
|
|
61
|
+
}): BranchInvocation;
|
|
62
|
+
/**
|
|
63
|
+
* Join argv pieces into one line a human can paste into any of the three shells.
|
|
64
|
+
*
|
|
65
|
+
* NOT A SHELL ESCAPER. This text goes to the toast, to the notice and to the
|
|
66
|
+
* README, never back through a parser: the three platforms agree on what double
|
|
67
|
+
* quotes mean around a path with spaces, and that is the whole requirement. The
|
|
68
|
+
* real command lines are built by `proc/terminal-plan.ts`, which quotes for the
|
|
69
|
+
* exact parser each rung lands in.
|
|
70
|
+
*/
|
|
71
|
+
export declare function joinCommandLine(parts: readonly string[]): string;
|
|
72
|
+
/**
|
|
73
|
+
* The second gate on D-9: an id is about to be concatenated into a command line.
|
|
74
|
+
*
|
|
75
|
+
* The command body checks first (it owns the user-facing sentence). This check
|
|
76
|
+
* exists because a future caller can go straight to `buildResumeInvocation`, and
|
|
77
|
+
* the injection defence belongs at the boundary that builds the string rather
|
|
78
|
+
* than at the one call site that happens to exist today.
|
|
79
|
+
*
|
|
80
|
+
* Returns `null` when the id is usable, otherwise one sentence naming the actual
|
|
81
|
+
* value (`<= 64 characters`, first 24 of it) so the user can see what was wrong.
|
|
82
|
+
*/
|
|
83
|
+
export declare function validateBranchId(id: string): string | null;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What command the new terminal runs (tui-branch-terminal-shortcut 3.4).
|
|
3
|
+
*
|
|
4
|
+
* ASCII ONLY - `src/session/**` is inside `glyphs.test.ts::inScope`.
|
|
5
|
+
*
|
|
6
|
+
* THIS MODULE HAS NO IO ON PURPOSE. It answers two questions and nothing else:
|
|
7
|
+
* "which argv pieces make up the aragon command" and "how does that sentence read
|
|
8
|
+
* when printed to a human". Spawning lives in `session/branch-launch.ts`, and the
|
|
9
|
+
* terminal ladder lives in `proc/terminal-plan.ts`, so the one part of this
|
|
10
|
+
* feature that a unit test can pin exactly - the command line - is not buried
|
|
11
|
+
* inside an `spawn()` call. The repository already draws the boundary this way:
|
|
12
|
+
* `proc/readiness.ts` and `proc/classify.ts` are pure, `proc/supervisor.ts` is the
|
|
13
|
+
* part that touches processes.
|
|
14
|
+
*
|
|
15
|
+
* NAMED `branch-invocation`, NOT `branch-command` (R-22): `commands/branch-command.ts`
|
|
16
|
+
* is the command body, and two files with one basename make every import, stack
|
|
17
|
+
* trace and grep ambiguous. What this module exports is an *invocation*.
|
|
18
|
+
*/
|
|
19
|
+
import { isAbsolute } from 'node:path';
|
|
20
|
+
import { isValidSessionId } from './store.js';
|
|
21
|
+
export const BRANCH_RESUME_FLAG = '--resume';
|
|
22
|
+
/** The env var that overrides "what executable the new window runs" (D-7 sibling). */
|
|
23
|
+
export const ARAGON_BRANCH_COMMAND_ENV = 'ARAGON_BRANCH_COMMAND';
|
|
24
|
+
/** Longest prefix of a rejected id that reaches the user's screen. */
|
|
25
|
+
const REJECTED_ID_PREVIEW_CELLS = 24;
|
|
26
|
+
/**
|
|
27
|
+
* A path that could be a script we are already running (`node <script.js>`).
|
|
28
|
+
*
|
|
29
|
+
* Anchored and case-insensitive: Windows writes `.JS` in the wild, and an
|
|
30
|
+
* unanchored variant would also match `cli.js.bak` - which is not something node
|
|
31
|
+
* could have been invoked with.
|
|
32
|
+
*/
|
|
33
|
+
const SCRIPT_PATH_RE = /\.(?:js|cjs|mjs)$/i;
|
|
34
|
+
/**
|
|
35
|
+
* Decide what to execute in the new window (D-8), first match wins.
|
|
36
|
+
*
|
|
37
|
+
* The precedence exists because `aragon` is NOT on PATH in three of this
|
|
38
|
+
* repository's own four ways of running: `npm run dev:cli`, `npx
|
|
39
|
+
* @aragon-agent/cli`, and `node packages/cli/dist/cli.js`. Rule 2 recovers all
|
|
40
|
+
* three from `process.execPath` + `process.argv[1]` without consulting PATH at
|
|
41
|
+
* all, which is the only way "press the key and something starts" holds on a
|
|
42
|
+
* machine where the global install is missing.
|
|
43
|
+
*
|
|
44
|
+
* The override is treated as ONE executable path and never split into arguments:
|
|
45
|
+
* splitting needs a shell parser, and a shell parser is what re-introduces the
|
|
46
|
+
* quoting problem this feature spends a whole module avoiding. The documented
|
|
47
|
+
* escape hatch is a `.cmd` / `.sh` wrapper.
|
|
48
|
+
*/
|
|
49
|
+
export function resolveAragonCommand(input) {
|
|
50
|
+
const override = input.env[ARAGON_BRANCH_COMMAND_ENV];
|
|
51
|
+
if (override !== undefined && override.length > 0) {
|
|
52
|
+
return {
|
|
53
|
+
parts: [override],
|
|
54
|
+
kind: 'override',
|
|
55
|
+
origin: `${ARAGON_BRANCH_COMMAND_ENV}=${override}`,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
const script = input.argv[1];
|
|
59
|
+
if (script !== undefined && isAbsolute(script) && SCRIPT_PATH_RE.test(script)) {
|
|
60
|
+
return {
|
|
61
|
+
parts: [input.execPath, script],
|
|
62
|
+
kind: 'script',
|
|
63
|
+
origin: 'process.execPath + process.argv[1]',
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
return { parts: ['aragon'], kind: 'bin', origin: 'aragon on PATH' };
|
|
67
|
+
}
|
|
68
|
+
/** `parts` + `--resume <id>`, plus the one-line form for toasts and manual fallback. */
|
|
69
|
+
export function buildResumeInvocation(input) {
|
|
70
|
+
const parts = [...input.aragon.parts, BRANCH_RESUME_FLAG, input.id];
|
|
71
|
+
return { parts, command: joinCommandLine(parts) };
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Join argv pieces into one line a human can paste into any of the three shells.
|
|
75
|
+
*
|
|
76
|
+
* NOT A SHELL ESCAPER. This text goes to the toast, to the notice and to the
|
|
77
|
+
* README, never back through a parser: the three platforms agree on what double
|
|
78
|
+
* quotes mean around a path with spaces, and that is the whole requirement. The
|
|
79
|
+
* real command lines are built by `proc/terminal-plan.ts`, which quotes for the
|
|
80
|
+
* exact parser each rung lands in.
|
|
81
|
+
*/
|
|
82
|
+
export function joinCommandLine(parts) {
|
|
83
|
+
return parts.map(quoteForDisplay).join(' ');
|
|
84
|
+
}
|
|
85
|
+
/** Empty and whitespace-or-quote-bearing pieces get double quotes; the rest stay bare. */
|
|
86
|
+
function quoteForDisplay(part) {
|
|
87
|
+
if (part.length === 0)
|
|
88
|
+
return '""';
|
|
89
|
+
if (!/[\s"]/.test(part))
|
|
90
|
+
return part;
|
|
91
|
+
return `"${part.replace(/"/g, '\\"')}"`;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* The second gate on D-9: an id is about to be concatenated into a command line.
|
|
95
|
+
*
|
|
96
|
+
* The command body checks first (it owns the user-facing sentence). This check
|
|
97
|
+
* exists because a future caller can go straight to `buildResumeInvocation`, and
|
|
98
|
+
* the injection defence belongs at the boundary that builds the string rather
|
|
99
|
+
* than at the one call site that happens to exist today.
|
|
100
|
+
*
|
|
101
|
+
* Returns `null` when the id is usable, otherwise one sentence naming the actual
|
|
102
|
+
* value (`<= 64 characters`, first 24 of it) so the user can see what was wrong.
|
|
103
|
+
*/
|
|
104
|
+
export function validateBranchId(id) {
|
|
105
|
+
if (isValidSessionId(id))
|
|
106
|
+
return null;
|
|
107
|
+
const preview = id.slice(0, REJECTED_ID_PREVIEW_CELLS);
|
|
108
|
+
return `Session id "${preview}" is not a legal file name, so it cannot be resumed.`;
|
|
109
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `/branch` port: flush, plan, spawn (tui-branch-terminal-shortcut 3.6).
|
|
3
|
+
*
|
|
4
|
+
* ASCII ONLY - `src/session/**` is inside `glyphs.test.ts::inScope`.
|
|
5
|
+
*
|
|
6
|
+
* ORDER IS CORRECTNESS (I-1). The child process's first act is to read the parent
|
|
7
|
+
* session file, and the parent's checkpoint is debounced and fingerprint-
|
|
8
|
+
* short-circuited (`checkpoint.ts`). Spawning before a successful FORCED write
|
|
9
|
+
* means the branch can read the previous turn - or, after `aragon sessions rm
|
|
10
|
+
* <id>`, no file at all - and the user sees "the branch opened but lost the last
|
|
11
|
+
* ten turns". That is a data defect, not a UX one, so `checkpoint({force:true})`
|
|
12
|
+
* returns `ok` before any terminal is created.
|
|
13
|
+
*
|
|
14
|
+
* THE CHILD IS NOT A SERVICE (D-5 / I-3): `detached: true`, `stdio: 'ignore'`,
|
|
15
|
+
* `unref()`, and deliberately no `ProcSupervisor` registration. Registering it
|
|
16
|
+
* would make the parent's exit kill a session running in another window.
|
|
17
|
+
*
|
|
18
|
+
* EVERY FAILURE CARRIES A PASTEABLE COMMAND (I-4). "Could not open a terminal" is
|
|
19
|
+
* a dead end; `aragon --resume a4k2m9` is 22 characters the user can always use.
|
|
20
|
+
*/
|
|
21
|
+
import { spawn } from 'node:child_process';
|
|
22
|
+
import { type ScopedLogger } from '../logging/logger.js';
|
|
23
|
+
import { type BranchCommandPort } from '../commands/branch-command.js';
|
|
24
|
+
export interface BranchRequest {
|
|
25
|
+
id: string;
|
|
26
|
+
cwd: string;
|
|
27
|
+
/** Late `'error'` events after the window opened (I-10). */
|
|
28
|
+
onLateError?: (message: string) => void;
|
|
29
|
+
}
|
|
30
|
+
export type BranchOpenResult = {
|
|
31
|
+
ok: true;
|
|
32
|
+
command: string;
|
|
33
|
+
shell: string;
|
|
34
|
+
rung: string;
|
|
35
|
+
} | {
|
|
36
|
+
ok: false;
|
|
37
|
+
reason: 'cooldown' | 'checkpoint-failed' | 'no-launcher' | 'spawn-failed';
|
|
38
|
+
/**
|
|
39
|
+
* A complete, user-facing sentence - never an internal reason code.
|
|
40
|
+
* `checkpoint-failed` in particular has two very different causes
|
|
41
|
+
* (`not-sendable` vs `no-identity`), and only the launcher knows which one it
|
|
42
|
+
* hit, so the sentence is composed HERE from `BRANCH_COPY`.
|
|
43
|
+
*/
|
|
44
|
+
message: string;
|
|
45
|
+
/** Always present, so no failure is a dead end (I-4). */
|
|
46
|
+
command: string;
|
|
47
|
+
retryInMs?: number;
|
|
48
|
+
};
|
|
49
|
+
export interface BranchLauncherDeps {
|
|
50
|
+
/** Injected by `cli.tsx`: a thin wrapper over `checkpointWriter.writeNow`, with `force`. */
|
|
51
|
+
checkpoint(opts: {
|
|
52
|
+
force: true;
|
|
53
|
+
}): {
|
|
54
|
+
ok: boolean;
|
|
55
|
+
message?: string;
|
|
56
|
+
};
|
|
57
|
+
platform: NodeJS.Platform;
|
|
58
|
+
env: Record<string, string | undefined>;
|
|
59
|
+
argv: readonly string[];
|
|
60
|
+
execPath: string;
|
|
61
|
+
spawn?: typeof spawn;
|
|
62
|
+
isExecutable?: (name: string) => boolean;
|
|
63
|
+
now?: () => number;
|
|
64
|
+
logger?: ScopedLogger;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Build the port. The only mutable state is this closure's four values, none of
|
|
68
|
+
* which outlives the process (no persistence, 6 in the spec).
|
|
69
|
+
*/
|
|
70
|
+
export declare function createBranchLauncher(deps: BranchLauncherDeps): BranchCommandPort;
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `/branch` port: flush, plan, spawn (tui-branch-terminal-shortcut 3.6).
|
|
3
|
+
*
|
|
4
|
+
* ASCII ONLY - `src/session/**` is inside `glyphs.test.ts::inScope`.
|
|
5
|
+
*
|
|
6
|
+
* ORDER IS CORRECTNESS (I-1). The child process's first act is to read the parent
|
|
7
|
+
* session file, and the parent's checkpoint is debounced and fingerprint-
|
|
8
|
+
* short-circuited (`checkpoint.ts`). Spawning before a successful FORCED write
|
|
9
|
+
* means the branch can read the previous turn - or, after `aragon sessions rm
|
|
10
|
+
* <id>`, no file at all - and the user sees "the branch opened but lost the last
|
|
11
|
+
* ten turns". That is a data defect, not a UX one, so `checkpoint({force:true})`
|
|
12
|
+
* returns `ok` before any terminal is created.
|
|
13
|
+
*
|
|
14
|
+
* THE CHILD IS NOT A SERVICE (D-5 / I-3): `detached: true`, `stdio: 'ignore'`,
|
|
15
|
+
* `unref()`, and deliberately no `ProcSupervisor` registration. Registering it
|
|
16
|
+
* would make the parent's exit kill a session running in another window.
|
|
17
|
+
*
|
|
18
|
+
* EVERY FAILURE CARRIES A PASTEABLE COMMAND (I-4). "Could not open a terminal" is
|
|
19
|
+
* a dead end; `aragon --resume a4k2m9` is 22 characters the user can always use.
|
|
20
|
+
*/
|
|
21
|
+
import { spawn } from 'node:child_process';
|
|
22
|
+
import { getLogger } from '../logging/logger.js';
|
|
23
|
+
import { defaultIsExecutable, pathDirsOf, planTerminalLaunch, resolveShell, truncateTitle, } from '../proc/terminal-plan.js';
|
|
24
|
+
import { BRANCH_COPY } from '../commands/branch-command.js';
|
|
25
|
+
import { buildResumeInvocation, joinCommandLine, resolveAragonCommand, validateBranchId, } from './branch-invocation.js';
|
|
26
|
+
import { BRANCH_TERMINAL_LIMITS } from './limits.js';
|
|
27
|
+
/**
|
|
28
|
+
* Build the port. The only mutable state is this closure's four values, none of
|
|
29
|
+
* which outlives the process (no persistence, 6 in the spec).
|
|
30
|
+
*/
|
|
31
|
+
export function createBranchLauncher(deps) {
|
|
32
|
+
const now = deps.now ?? Date.now;
|
|
33
|
+
const logger = deps.logger ?? getLogger().child('branch');
|
|
34
|
+
const spawnFn = deps.spawn ?? spawn;
|
|
35
|
+
const pathDirs = pathDirsOf(deps.env, deps.platform);
|
|
36
|
+
const isExecutable = deps.isExecutable ?? defaultIsExecutable(deps.platform, pathDirs);
|
|
37
|
+
/** Last SUCCESSFUL open (D-10). Failures never move it, so a retry is never blocked. */
|
|
38
|
+
let lastOpenedAt = null;
|
|
39
|
+
let inFlight = false;
|
|
40
|
+
/** Resolution is cached for the process (R-13 / K-14); a FAILED probe is not. */
|
|
41
|
+
let cachedAragon = null;
|
|
42
|
+
let cachedShell = null;
|
|
43
|
+
/**
|
|
44
|
+
* Resolve WHAT the new window runs, and the command line built from it.
|
|
45
|
+
*
|
|
46
|
+
* The resolution is cached for the process (R-13 / K-14). It costs no
|
|
47
|
+
* filesystem probe - the three rules are env, `argv[1]` and a literal - so the
|
|
48
|
+
* cache is about identity, not speed: the value that opened the first window is
|
|
49
|
+
* the value the log keeps reporting.
|
|
50
|
+
*/
|
|
51
|
+
function planFor(id) {
|
|
52
|
+
cachedAragon ??= resolveAragonCommand({
|
|
53
|
+
argv: deps.argv,
|
|
54
|
+
execPath: deps.execPath,
|
|
55
|
+
env: deps.env,
|
|
56
|
+
});
|
|
57
|
+
return {
|
|
58
|
+
aragon: cachedAragon,
|
|
59
|
+
invocation: buildResumeInvocation({ id, aragon: cachedAragon }),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
function shellFor() {
|
|
63
|
+
cachedShell ??= resolveShell({
|
|
64
|
+
platform: deps.platform,
|
|
65
|
+
env: deps.env,
|
|
66
|
+
isExecutable,
|
|
67
|
+
pathDirs,
|
|
68
|
+
});
|
|
69
|
+
return cachedShell;
|
|
70
|
+
}
|
|
71
|
+
function cooldown(command, retryInMs) {
|
|
72
|
+
return {
|
|
73
|
+
ok: false,
|
|
74
|
+
reason: 'cooldown',
|
|
75
|
+
message: BRANCH_COPY.cooldown(retryInMs),
|
|
76
|
+
command,
|
|
77
|
+
retryInMs,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
function failure(reason, message, command) {
|
|
81
|
+
return { ok: false, reason, message, command };
|
|
82
|
+
}
|
|
83
|
+
/** Steps 1-2: the in-flight gate, then the cooldown window (D-10). */
|
|
84
|
+
function gated(command) {
|
|
85
|
+
const windowMs = BRANCH_TERMINAL_LIMITS.cooldownMs;
|
|
86
|
+
if (inFlight)
|
|
87
|
+
return cooldown(command, windowMs);
|
|
88
|
+
if (lastOpenedAt === null)
|
|
89
|
+
return null;
|
|
90
|
+
const waited = now() - lastOpenedAt;
|
|
91
|
+
if (waited >= windowMs)
|
|
92
|
+
return null;
|
|
93
|
+
return cooldown(command, Math.max(1, windowMs - waited));
|
|
94
|
+
}
|
|
95
|
+
async function openOnce(request) {
|
|
96
|
+
const { aragon, invocation } = planFor(request.id);
|
|
97
|
+
const command = invocation.command;
|
|
98
|
+
// 4.5 BEFORE ANY SPAWN (R-7 / T-46): a command line that `cmd` or AppleScript
|
|
99
|
+
// would truncate is worse than no command line, because the window opens and
|
|
100
|
+
// then fails in a way this process cannot see.
|
|
101
|
+
if (command.length > BRANCH_TERMINAL_LIMITS.maxCommandLineCells) {
|
|
102
|
+
// A COMPLETE SENTENCE WITH THE FALLBACK INSIDE IT (I-4 / appendix A): the
|
|
103
|
+
// over-long case is the one failure where the user most needs a command to
|
|
104
|
+
// paste, so it may not report the bare reason "command line too long" - that
|
|
105
|
+
// is an internal code, not an answer, and it would leave a dead end.
|
|
106
|
+
return failure('spawn-failed', BRANCH_COPY.spawnFailed('command line too long', command), command);
|
|
107
|
+
}
|
|
108
|
+
// D-9's second gate: an id is about to enter a command line.
|
|
109
|
+
const invalid = validateBranchId(request.id);
|
|
110
|
+
if (invalid !== null)
|
|
111
|
+
return failure('spawn-failed', invalid, command);
|
|
112
|
+
// Step 6: flush FIRST, spawn only on `ok` (I-1). `force` skips the fingerprint
|
|
113
|
+
// short circuit and nothing else (D-15 / I-7).
|
|
114
|
+
const flushed = deps.checkpoint({ force: true });
|
|
115
|
+
if (!flushed.ok) {
|
|
116
|
+
const reason = flushed.message ?? 'write-failed';
|
|
117
|
+
// R-19: `no-identity` is a timing answer ("this frame has no id yet"), not a
|
|
118
|
+
// disk problem, so it must not be printed as "could not checkpoint
|
|
119
|
+
// (no-identity)" - that reads like corruption. It gets `/session`'s sentence.
|
|
120
|
+
const message = reason === 'no-identity' ? BRANCH_COPY.noIdentity : BRANCH_COPY.checkpointFailed(reason);
|
|
121
|
+
return failure('checkpoint-failed', message, command);
|
|
122
|
+
}
|
|
123
|
+
const shell = shellFor();
|
|
124
|
+
// Step 7: the TITLE is what gets truncated, never the id in the command line (T-47).
|
|
125
|
+
const title = truncateTitle(`aragon ${request.id}`, BRANCH_TERMINAL_LIMITS.titleMaxCells);
|
|
126
|
+
const planned = planTerminalLaunch({
|
|
127
|
+
platform: deps.platform,
|
|
128
|
+
env: deps.env,
|
|
129
|
+
pathDirs,
|
|
130
|
+
isExecutable,
|
|
131
|
+
shell,
|
|
132
|
+
command: invocation.parts,
|
|
133
|
+
title,
|
|
134
|
+
cwd: request.cwd,
|
|
135
|
+
manual: joinCommandLine(invocation.parts),
|
|
136
|
+
});
|
|
137
|
+
if (!planned.ok) {
|
|
138
|
+
// Not cached: the next press must be allowed to find a terminal that appeared.
|
|
139
|
+
return failure('no-launcher', BRANCH_COPY.noLauncher(planned.manual), command);
|
|
140
|
+
}
|
|
141
|
+
// Step 8: walk the ladder, bounded by maxRungs (T-33).
|
|
142
|
+
const rungs = planned.plans.slice(0, BRANCH_TERMINAL_LIMITS.maxRungs);
|
|
143
|
+
let lastError = null;
|
|
144
|
+
for (const plan of rungs) {
|
|
145
|
+
const outcome = await trySpawn(request, plan);
|
|
146
|
+
if (outcome === 'ok') {
|
|
147
|
+
lastOpenedAt = now();
|
|
148
|
+
// `origin` is why the record exists (D-18): "I pressed Ctrl+L and nothing
|
|
149
|
+
// happened" is answered by WHICH aragon the child ran (`aragon` from PATH,
|
|
150
|
+
// `node <script>`, or the env override) and through which rung, which no
|
|
151
|
+
// other record in the log can tell you.
|
|
152
|
+
logger.info('opened', {
|
|
153
|
+
id: request.id,
|
|
154
|
+
rung: plan.rung,
|
|
155
|
+
shell: planned.shell.path,
|
|
156
|
+
aragon: aragon.kind,
|
|
157
|
+
origin: aragon.origin,
|
|
158
|
+
});
|
|
159
|
+
return { ok: true, command, shell: planned.shell.path, rung: plan.rung };
|
|
160
|
+
}
|
|
161
|
+
logger.warn('rung_failed', { rung: plan.rung, message: outcome.message });
|
|
162
|
+
lastError = outcome;
|
|
163
|
+
}
|
|
164
|
+
// Step 9: every rung failed. `wt`'s own semantic failures land here too only if
|
|
165
|
+
// the error event fired; a forwarded-but-rejected `wt` invocation does not (K-1).
|
|
166
|
+
return failure('spawn-failed', BRANCH_COPY.spawnFailed(lastError?.message ?? 'no rung available', command), command);
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* One rung, with EXACTLY ONE `'error'` LISTENER THAT IS NEVER REMOVED (R-3 / I-10).
|
|
170
|
+
*
|
|
171
|
+
* `once('error')` is what the first draft used and it is wrong: after the probe
|
|
172
|
+
* settles on `'spawn'` (or on the timeout) that handler is still removed on
|
|
173
|
+
* first use, so a LATE error has no listener - an unhandled `'error'` on a
|
|
174
|
+
* ChildProcess throws at the process top level and takes the parent TUI with it.
|
|
175
|
+
* A permanent listener with a `settled` flag gives the probe the first error and
|
|
176
|
+
* the user the rest.
|
|
177
|
+
*/
|
|
178
|
+
async function trySpawn(request, plan) {
|
|
179
|
+
const child = spawnFn(plan.file, plan.args, {
|
|
180
|
+
cwd: request.cwd,
|
|
181
|
+
detached: true,
|
|
182
|
+
// I-3: the child's streams must never join the parent's, or Ink's frame
|
|
183
|
+
// differ treats another process's bytes as an outside writer.
|
|
184
|
+
stdio: 'ignore',
|
|
185
|
+
// A new window has to be visible; hiding it would defeat the whole key.
|
|
186
|
+
windowsHide: false,
|
|
187
|
+
// The FULL environment, inherited on purpose (K-7): `ARAGON_HOME` is how the
|
|
188
|
+
// child finds the same sessions directory, and `ARAGON_*` is user
|
|
189
|
+
// configuration (D-7). `ARAGON_LOG_FILE` appends per record with `pid` + `sid`.
|
|
190
|
+
env: deps.env,
|
|
191
|
+
...(plan.windowsVerbatimArguments ? { windowsVerbatimArguments: true } : {}),
|
|
192
|
+
});
|
|
193
|
+
child.unref();
|
|
194
|
+
return probeSpawn(child, plan, request);
|
|
195
|
+
}
|
|
196
|
+
/** Wait for `'spawn'` | `'error'` | the timeout, then keep listening for late errors. */
|
|
197
|
+
function probeSpawn(child, plan, request) {
|
|
198
|
+
return new Promise((resolve) => {
|
|
199
|
+
let settled = false;
|
|
200
|
+
let timer = null;
|
|
201
|
+
const finish = (value) => {
|
|
202
|
+
if (settled) {
|
|
203
|
+
if (value === 'ok')
|
|
204
|
+
return;
|
|
205
|
+
// LATE error: report it, never re-settle, never remove the listener.
|
|
206
|
+
const message = value.message || String(value);
|
|
207
|
+
logger.warn('late_error', { rung: plan.rung, message });
|
|
208
|
+
request.onLateError?.(BRANCH_COPY.lateError(message));
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
settled = true;
|
|
212
|
+
if (timer !== null)
|
|
213
|
+
clearTimeout(timer);
|
|
214
|
+
resolve(value);
|
|
215
|
+
};
|
|
216
|
+
timer = setTimeout(() => {
|
|
217
|
+
// A timeout counts as SUCCESS (K-8): `'spawn'` and `'error'` both missing
|
|
218
|
+
// almost always means the process was created and the event was merely
|
|
219
|
+
// delayed behind the scheduler. Reporting a failure here produces the worse
|
|
220
|
+
// error - "window is open, UI says it is not".
|
|
221
|
+
logger.debug('probe_timeout', { rung: plan.rung });
|
|
222
|
+
finish('ok');
|
|
223
|
+
}, BRANCH_TERMINAL_LIMITS.spawnProbeMs);
|
|
224
|
+
timer.unref?.();
|
|
225
|
+
// `spawn` fires at most once, so `once` is honest here.
|
|
226
|
+
child.once('spawn', () => finish('ok'));
|
|
227
|
+
// PERMANENT. Installed before any await so no error can slip past it.
|
|
228
|
+
child.on('error', (error) => finish(error));
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
return {
|
|
232
|
+
async open(request) {
|
|
233
|
+
const blocked = gated(planFor(request.id).invocation.command);
|
|
234
|
+
if (blocked !== null)
|
|
235
|
+
return blocked;
|
|
236
|
+
inFlight = true;
|
|
237
|
+
try {
|
|
238
|
+
return await openOnce(request);
|
|
239
|
+
}
|
|
240
|
+
finally {
|
|
241
|
+
// `inFlight` always clears, including on a synchronous throw from `spawn`
|
|
242
|
+
// (an invalid `cwd`), which then bubbles to `executeSlashInput`'s try/catch
|
|
243
|
+
// and becomes one honest `Command failed: ...` line (D-2).
|
|
244
|
+
inFlight = false;
|
|
245
|
+
}
|
|
246
|
+
},
|
|
247
|
+
};
|
|
248
|
+
}
|