@aragon-agent/cli 0.6.24 → 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 +1924 -1786
- package/README.md +2730 -2562
- package/dist/agent/controller.d.ts +49 -3
- package/dist/agent/controller.js +78 -3
- package/dist/agent/conversation-reset.d.ts +91 -0
- package/dist/agent/conversation-reset.js +66 -0
- package/dist/agent/model-probe-limits.d.ts +39 -0
- package/dist/agent/model-probe-limits.js +25 -0
- package/dist/agent/model-probe.d.ts +104 -0
- package/dist/agent/model-probe.js +391 -0
- package/dist/agent/reducer.d.ts +22 -0
- package/dist/agent/reducer.js +49 -14
- 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 +62 -40
- package/dist/commands/clear-command.d.ts +27 -0
- package/dist/commands/clear-command.js +107 -0
- package/dist/commands/registry.d.ts +48 -1
- 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/config/model-profiles.d.ts +8 -0
- package/dist/config/model-profiles.js +10 -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/clear-archive.d.ts +51 -0
- package/dist/session/clear-archive.js +160 -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/todo/types.d.ts +5 -3
- package/dist/ui/App.d.ts +54 -2
- package/dist/ui/App.js +154 -28
- 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/entries/CompactionCard.d.ts +5 -4
- package/dist/ui/entries/CompactionCard.js +5 -4
- 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 +17 -5
- package/dist/ui/overlays/ModelDiscoveryList.d.ts +23 -0
- package/dist/ui/overlays/ModelDiscoveryList.js +161 -0
- package/dist/ui/overlays/ModelProfileEditor.d.ts +18 -1
- package/dist/ui/overlays/ModelProfileEditor.js +117 -68
- package/dist/ui/overlays/SettingsScreen.d.ts +8 -0
- package/dist/ui/overlays/SettingsScreen.js +34 -2
- package/dist/ui/overlays/model-probe-view.d.ts +128 -0
- package/dist/ui/overlays/model-probe-view.js +225 -0
- package/dist/ui/overlays/model-profile-state.d.ts +9 -1
- package/dist/ui/overlays/model-profile-state.js +10 -0
- package/dist/ui/overlays/use-model-probe.d.ts +30 -0
- package/dist/ui/overlays/use-model-probe.js +109 -0
- 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,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
|
+
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session checkpoint writer (tui-session-id-resume §3.4 / D-2 / D-3 / D-4 /
|
|
3
|
+
* D-21 / D-22 / D-23).
|
|
4
|
+
*
|
|
5
|
+
* ASCII ONLY - `src/session/**` is inside `glyphs.test.ts::inScope`.
|
|
6
|
+
*
|
|
7
|
+
* WHY THIS MODULE EXISTS AT ALL: a TUI conversation lives in memory
|
|
8
|
+
* (`agent.state.messages`). Another terminal can only continue it if the process
|
|
9
|
+
* has put it on disk, and the only serializer that exists (`saveSession`) is
|
|
10
|
+
* written for explicit exports. So this is the one owner of three properties an
|
|
11
|
+
* export never needed:
|
|
12
|
+
*
|
|
13
|
+
* 1. THE FILE ON DISK IS ALWAYS SENDABLE (D-3 / I-4). A checkpoint taken
|
|
14
|
+
* mid-tool would leave an assistant `tool_call` with no `tool_result`, and
|
|
15
|
+
* the next request of the branch that resumes it is an HTTP 400 - the very
|
|
16
|
+
* first thing `--resume` is for.
|
|
17
|
+
* 2. THE REPLACEMENT IS ATOMIC (D-4). This is the repository's first "a writer
|
|
18
|
+
* and a reader coexist" session file: the parent TUI writes while another
|
|
19
|
+
* terminal reads. A truncated read says "corrupt session", and one false
|
|
20
|
+
* report is enough for a user to stop trusting the feature.
|
|
21
|
+
* 3. NOTHING LIVE SURVIVES INTO THE FILE (D-22 / I-11). A `running` tool card
|
|
22
|
+
* that never settles keeps `ToolCard` spinning and keeps `Transcript`'s
|
|
23
|
+
* MONOTONIC settled boundary from ever moving past it, so every frame
|
|
24
|
+
* re-renders the tail. The settlement happens on the WRITE COPY, not on the
|
|
25
|
+
* load path, because `live-session.test.ts` pins the `/save` decision that
|
|
26
|
+
* a reloaded card keeps its status - and `/save` is an explicit export made
|
|
27
|
+
* with the user watching, while a checkpoint is not.
|
|
28
|
+
*
|
|
29
|
+
* NOTHING HERE MAY INTERRUPT A CONVERSATION (I-8): every filesystem failure is
|
|
30
|
+
* swallowed, logged, and reported as a value.
|
|
31
|
+
*/
|
|
32
|
+
import { type Message, type ModelRef } from '@aragon-agent/core';
|
|
33
|
+
import type { Entry, UsageTotal } from '../agent/reducer.js';
|
|
34
|
+
import type { TodoItem } from '../todo/types.js';
|
|
35
|
+
import type { CompactionIdentity } from '../compaction/memory-identity.js';
|
|
36
|
+
import { type SessionLockHandle } from './store.js';
|
|
37
|
+
/**
|
|
38
|
+
* What the view side can tell the writer: the identity, the transcript, and this
|
|
39
|
+
* process's spend.
|
|
40
|
+
*
|
|
41
|
+
* A MINIMAL PORT RATHER THAN A REFERENCE TO `ViewState`. The writer must not know
|
|
42
|
+
* what a reducer is, and `usageTotal` is here because `meta.usage` has no other
|
|
43
|
+
* source (R-5): it is the cumulative value this process has spent, it is NEVER
|
|
44
|
+
* read back (D-10), and it starts again from zero whenever the id changes.
|
|
45
|
+
*/
|
|
46
|
+
export interface LiveSessionView {
|
|
47
|
+
id: string;
|
|
48
|
+
entries: Entry[];
|
|
49
|
+
/** This process's spend; the only source of `meta.usage` (R-5). Never read back. */
|
|
50
|
+
usageTotal: UsageTotal;
|
|
51
|
+
}
|
|
52
|
+
export interface CheckpointDeps {
|
|
53
|
+
/** `cli.tsx` injects `readSessionView`; `undefined` means "no identity here". */
|
|
54
|
+
readView(): LiveSessionView | undefined;
|
|
55
|
+
/** The controller's two live pieces; injected so the writer is unit-testable. */
|
|
56
|
+
readHistory(): {
|
|
57
|
+
messages: Message[];
|
|
58
|
+
compactionIdentity?: CompactionIdentity;
|
|
59
|
+
};
|
|
60
|
+
readTodos(): TodoItem[];
|
|
61
|
+
readModel(): ModelRef;
|
|
62
|
+
/** `meta.cwd`; read at every write because `/cwd` can change it. */
|
|
63
|
+
readCwd(): string;
|
|
64
|
+
/** `meta.cli`. */
|
|
65
|
+
version: string;
|
|
66
|
+
/**
|
|
67
|
+
* `meta.branchOf`. Read at write time rather than captured, because the App
|
|
68
|
+
* can rotate the identity after `/resume` (D-15) and the lineage must follow.
|
|
69
|
+
*/
|
|
70
|
+
readParentId?(): string | undefined;
|
|
71
|
+
/** `--save <id>` starts the session pinned; `/session pin` sets it later. */
|
|
72
|
+
startPinned?: boolean;
|
|
73
|
+
now?(): number;
|
|
74
|
+
/** The target path; injected so tests point at a temporary directory. */
|
|
75
|
+
pathFor?(id: string): string;
|
|
76
|
+
/**
|
|
77
|
+
* The per-process claim on this id (S-7). Injected so a unit test can answer
|
|
78
|
+
* `null` instead of creating a lock in the real sessions directory.
|
|
79
|
+
*/
|
|
80
|
+
lockFor?(id: string): SessionLockHandle | null;
|
|
81
|
+
}
|
|
82
|
+
export interface CheckpointResultOk {
|
|
83
|
+
ok: true;
|
|
84
|
+
path: string;
|
|
85
|
+
bytes: number;
|
|
86
|
+
repaired: number;
|
|
87
|
+
settled: number;
|
|
88
|
+
}
|
|
89
|
+
export type CheckpointResult = CheckpointResultOk | {
|
|
90
|
+
ok: false;
|
|
91
|
+
reason: 'no-identity' | 'not-sendable' | 'write-failed';
|
|
92
|
+
message: string;
|
|
93
|
+
};
|
|
94
|
+
/**
|
|
95
|
+
* `writeNow`'s switches.
|
|
96
|
+
*
|
|
97
|
+
* `force` skips the fingerprint short circuit and NOTHING ELSE (D-15 / I-7): an
|
|
98
|
+
* unsendable history is still refused, the settle-and-repair pass still runs, and
|
|
99
|
+
* the file it produces is still `loadSession()`-readable. It exists because
|
|
100
|
+
* `Ctrl+L` spawns a process that reads this file immediately, and a short-
|
|
101
|
+
* circuited write returns `bytes: 0` with the file's content being whatever the
|
|
102
|
+
* last successful write left there - right after `aragon sessions rm <id>`, that
|
|
103
|
+
* is "no file at all", and the branch would greet the user with
|
|
104
|
+
* `No session "<id>"` (tui-branch-terminal-shortcut D-3).
|
|
105
|
+
*/
|
|
106
|
+
export interface CheckpointOptions {
|
|
107
|
+
/** `/session pin` and `--save <id>`. */
|
|
108
|
+
pin?: boolean;
|
|
109
|
+
/** Ignore the fingerprint short circuit (D-15). */
|
|
110
|
+
force?: boolean;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Repair every `tool_call` that never received its `tool_result` (D-3).
|
|
114
|
+
*
|
|
115
|
+
* ON A COPY, AND THAT IS THE POINT (I-2). The live history belongs to the
|
|
116
|
+
* engine; a host that edited it would change what the next request sends. Each
|
|
117
|
+
* dangling call gets an `isError` result carrying one fixed sentence, and the
|
|
118
|
+
* caller re-runs `validateHistory` afterwards: a repair that is still not
|
|
119
|
+
* sendable means the file must not be written.
|
|
120
|
+
*/
|
|
121
|
+
export declare function closeDanglingToolCalls(messages: readonly Message[]): {
|
|
122
|
+
messages: Message[];
|
|
123
|
+
repaired: number;
|
|
124
|
+
};
|
|
125
|
+
/**
|
|
126
|
+
* Settle every `running` tool card on a COPY (D-22 / I-11).
|
|
127
|
+
*
|
|
128
|
+
* `error` rather than `done`: the command did not finish, and a card that said
|
|
129
|
+
* otherwise would claim a result nobody saw. The live tail is cleared for the
|
|
130
|
+
* same reason `stripLiveToolOutput` clears it - it describes a process that died
|
|
131
|
+
* with the session - and `preview` names WHY, so a resumed card is not a silent
|
|
132
|
+
* lie. `pending` is left alone: `ToolCard` draws no spinner for it and
|
|
133
|
+
* `Transcript.nowSecFor` never re-clocks it, so it settles by itself.
|
|
134
|
+
*/
|
|
135
|
+
export declare function settleRunningToolCards(entries: readonly Entry[]): {
|
|
136
|
+
entries: Entry[];
|
|
137
|
+
settled: number;
|
|
138
|
+
};
|
|
139
|
+
/** What may reach the disk: nothing at all, or a history the engine can resend. */
|
|
140
|
+
export declare function isSendable(messages: readonly Message[]): boolean;
|
|
141
|
+
/**
|
|
142
|
+
* The checkpoint writer.
|
|
143
|
+
*
|
|
144
|
+
* ONE TIMER (I-10). Several `agent_end` events in a row - a todo follow-through
|
|
145
|
+
* restarts the run without the user typing - are merged into one write, and the
|
|
146
|
+
* timer is `unref`ed so a pending write can never hold the process open. Every
|
|
147
|
+
* exit path calls `flushSync`, which is idempotent so the three of them
|
|
148
|
+
* (`waitUntilExit().then()`, the signal hook and `process.on('exit')`) can
|
|
149
|
+
* coexist without writing three times.
|
|
150
|
+
*/
|
|
151
|
+
export declare class SessionCheckpointWriter {
|
|
152
|
+
private readonly deps;
|
|
153
|
+
private readonly pathFor;
|
|
154
|
+
private lastFingerprint;
|
|
155
|
+
private lastWriteAt;
|
|
156
|
+
private createdAt;
|
|
157
|
+
private pinned;
|
|
158
|
+
private failureStreak;
|
|
159
|
+
private pending;
|
|
160
|
+
private unsubscribe;
|
|
161
|
+
private exited;
|
|
162
|
+
/**
|
|
163
|
+
* Whether the LAST WRITE FILE still says `live: true`.
|
|
164
|
+
*
|
|
165
|
+
* The fingerprint deliberately does not include the reason for the write, so
|
|
166
|
+
* without this the exit flush would short-circuit as "nothing changed" and the
|
|
167
|
+
* file would keep `live: true` forever - which is the flag `sessions prune`
|
|
168
|
+
* reads as "a writer is still here" (T-10's sibling, and the reason
|
|
169
|
+
* `flushSync` must be able to write a file whose CONTENT is unchanged but whose
|
|
170
|
+
* METADATA is not).
|
|
171
|
+
*/
|
|
172
|
+
private lastWroteLive;
|
|
173
|
+
private lastResult;
|
|
174
|
+
/**
|
|
175
|
+
* THIS PROCESS'S CLAIM ON THE FILE IT WRITES (S-7).
|
|
176
|
+
*
|
|
177
|
+
* Without it, `meta.live` is a flag nobody can verify: `sessions prune` reads
|
|
178
|
+
* the `.lock` payload to decide whether the writer is still alive, and the TUI
|
|
179
|
+
* never took one - so `prune --origin tui --yes` deleted the checkpoint of a
|
|
180
|
+
* session that was running at that moment. The lock is the same one `exec`
|
|
181
|
+
* uses to keep two writers off one file; a killed process leaves a dead pid
|
|
182
|
+
* behind and the existing staleness probe collects it.
|
|
183
|
+
*/
|
|
184
|
+
private lock;
|
|
185
|
+
private lockedId;
|
|
186
|
+
constructor(deps: CheckpointDeps);
|
|
187
|
+
/**
|
|
188
|
+
* Write once now: after mount, so the id on screen is resumable immediately.
|
|
189
|
+
*
|
|
190
|
+
* THE `no-identity` ANSWER IS A TIMING ANSWER, NOT A PERMANENT ONE. `render()`
|
|
191
|
+
* returns before `App` has run its first effects, so the session slot can still
|
|
192
|
+
* be empty when this is called - and "the id on screen is not resumable until
|
|
193
|
+
* the first turn" is exactly the failure D-2 exists to prevent (a zero-turn
|
|
194
|
+
* session must already be a session). One deferred retry through the SAME
|
|
195
|
+
* single timer (I-10) turns that window into the write; if the view was already
|
|
196
|
+
* published, the first call succeeded and no timer is armed at all.
|
|
197
|
+
*/
|
|
198
|
+
start(): CheckpointResult;
|
|
199
|
+
/** Subscribe to `agent_end`. The writer does not own the controller's lifetime. */
|
|
200
|
+
attach(subscribe: (fn: () => void) => () => void): void;
|
|
201
|
+
/** Write now, optionally pinning (`/session pin`) or forcing past the short circuit. */
|
|
202
|
+
writeNow(opts?: CheckpointOptions): CheckpointResult;
|
|
203
|
+
/**
|
|
204
|
+
* The exit-path write. Never throws, and after the first call it is a no-op
|
|
205
|
+
* (I-10): all three exit paths run on a normal quit.
|
|
206
|
+
*/
|
|
207
|
+
flushSync(reason: 'exit' | 'signal'): CheckpointResult;
|
|
208
|
+
dispose(): void;
|
|
209
|
+
private onAgentEnd;
|
|
210
|
+
/**
|
|
211
|
+
* Coalesce into one timer. `debounceMs` is the window; `minWriteIntervalMs`
|
|
212
|
+
* keeps two consecutive run boundaries - which arrive closer together than a
|
|
213
|
+
* human can type - from landing two full session writes back to back.
|
|
214
|
+
*/
|
|
215
|
+
private schedule;
|
|
216
|
+
private clearPending;
|
|
217
|
+
private now;
|
|
218
|
+
/**
|
|
219
|
+
* The full write, in the one order that keeps the file honest: read, repair,
|
|
220
|
+
* gate, settle, write to a temporary file, replace.
|
|
221
|
+
*
|
|
222
|
+
* `force` is ONE switch with ONE effect (D-15 / I-7): it skips the fingerprint
|
|
223
|
+
* short circuit below. Everything after it - the dangling-call repair, the
|
|
224
|
+
* sendable gate, the settle pass, the atomic replace - runs unchanged, because a
|
|
225
|
+
* forced write still has to produce a file `loadSession()` can read back.
|
|
226
|
+
*/
|
|
227
|
+
private write;
|
|
228
|
+
/** Serialize to `<id>.json.tmp-<uuid>` and rename over the real file (D-4). */
|
|
229
|
+
private persist;
|
|
230
|
+
/**
|
|
231
|
+
* `meta`, field by field (§6.1). Two of these are easy to get subtly wrong:
|
|
232
|
+
* `createdAt` is frozen at the first write (re-reading it every time would
|
|
233
|
+
* make the field a second `updatedAt`), and `turns` counts assistant messages
|
|
234
|
+
* IN THE FILE - a quantity a reader can verify, unlike "what this cost".
|
|
235
|
+
*/
|
|
236
|
+
private buildMeta;
|
|
237
|
+
/**
|
|
238
|
+
* Take, or move, this process's lock on the id being written (S-7).
|
|
239
|
+
*
|
|
240
|
+
* BEST EFFORT, AND NEVER FATAL: a checkpoint must not fail because someone
|
|
241
|
+
* else holds the lock - the write is atomic and the file is this id's own, so
|
|
242
|
+
* the worst case is a `live` flag that `prune` declines to trust.
|
|
243
|
+
*/
|
|
244
|
+
private holdLock;
|
|
245
|
+
private releaseLock;
|
|
246
|
+
/** A failed write is a log line and a return value, never an exception (I-8). */
|
|
247
|
+
private fail;
|
|
248
|
+
/**
|
|
249
|
+
* A cheap content fingerprint: identity, message count, last message shape,
|
|
250
|
+
* entry count, last entry id, todo count and the pin flag.
|
|
251
|
+
*
|
|
252
|
+
* SHORT AND STRUCTURAL ON PURPOSE. Hashing the whole conversation would cost
|
|
253
|
+
* as much as writing it (K-1), and the question this answers is only "did
|
|
254
|
+
* anything settle since the last write".
|
|
255
|
+
*/
|
|
256
|
+
private fingerprint;
|
|
257
|
+
}
|