@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.
Files changed (77) hide show
  1. package/CHANGELOG.md +1924 -1786
  2. package/README.md +2730 -2562
  3. package/dist/agent/controller.d.ts +49 -3
  4. package/dist/agent/controller.js +78 -3
  5. package/dist/agent/conversation-reset.d.ts +91 -0
  6. package/dist/agent/conversation-reset.js +66 -0
  7. package/dist/agent/model-probe-limits.d.ts +39 -0
  8. package/dist/agent/model-probe-limits.js +25 -0
  9. package/dist/agent/model-probe.d.ts +104 -0
  10. package/dist/agent/model-probe.js +391 -0
  11. package/dist/agent/reducer.d.ts +22 -0
  12. package/dist/agent/reducer.js +49 -14
  13. package/dist/cli.js +214 -2
  14. package/dist/commands/branch-command.d.ts +61 -0
  15. package/dist/commands/branch-command.js +117 -0
  16. package/dist/commands/builtins.js +62 -40
  17. package/dist/commands/clear-command.d.ts +27 -0
  18. package/dist/commands/clear-command.js +107 -0
  19. package/dist/commands/registry.d.ts +48 -1
  20. package/dist/commands/session-command.d.ts +54 -0
  21. package/dist/commands/session-command.js +109 -0
  22. package/dist/config/load.d.ts +15 -0
  23. package/dist/config/model-profiles.d.ts +8 -0
  24. package/dist/config/model-profiles.js +10 -0
  25. package/dist/exec/index.js +10 -1
  26. package/dist/logging/logger.d.ts +1 -1
  27. package/dist/proc/terminal-plan.d.ts +155 -0
  28. package/dist/proc/terminal-plan.js +360 -0
  29. package/dist/session/branch-invocation.d.ts +83 -0
  30. package/dist/session/branch-invocation.js +109 -0
  31. package/dist/session/branch-launch.d.ts +70 -0
  32. package/dist/session/branch-launch.js +248 -0
  33. package/dist/session/checkpoint.d.ts +257 -0
  34. package/dist/session/checkpoint.js +512 -0
  35. package/dist/session/clear-archive.d.ts +51 -0
  36. package/dist/session/clear-archive.js +160 -0
  37. package/dist/session/cli-commands.d.ts +2 -0
  38. package/dist/session/cli-commands.js +36 -5
  39. package/dist/session/identity.d.ts +72 -0
  40. package/dist/session/identity.js +148 -0
  41. package/dist/session/limits.d.ts +66 -0
  42. package/dist/session/limits.js +66 -0
  43. package/dist/session/persist.d.ts +30 -5
  44. package/dist/session/startup-session.d.ts +107 -0
  45. package/dist/session/startup-session.js +315 -0
  46. package/dist/session/store.d.ts +60 -1
  47. package/dist/session/store.js +95 -6
  48. package/dist/todo/types.d.ts +5 -3
  49. package/dist/ui/App.d.ts +54 -2
  50. package/dist/ui/App.js +154 -28
  51. package/dist/ui/Header.js +8 -16
  52. package/dist/ui/StatusBar.d.ts +8 -0
  53. package/dist/ui/StatusBar.js +12 -3
  54. package/dist/ui/entries/CompactionCard.d.ts +5 -4
  55. package/dist/ui/entries/CompactionCard.js +5 -4
  56. package/dist/ui/layout/header-hints.d.ts +68 -0
  57. package/dist/ui/layout/header-hints.js +138 -0
  58. package/dist/ui/layout/session-chip.d.ts +52 -0
  59. package/dist/ui/layout/session-chip.js +64 -0
  60. package/dist/ui/layout/status-layout.d.ts +10 -0
  61. package/dist/ui/layout/status-layout.js +8 -1
  62. package/dist/ui/overlays/HelpOverlay.js +17 -5
  63. package/dist/ui/overlays/ModelDiscoveryList.d.ts +23 -0
  64. package/dist/ui/overlays/ModelDiscoveryList.js +161 -0
  65. package/dist/ui/overlays/ModelProfileEditor.d.ts +18 -1
  66. package/dist/ui/overlays/ModelProfileEditor.js +117 -68
  67. package/dist/ui/overlays/SettingsScreen.d.ts +8 -0
  68. package/dist/ui/overlays/SettingsScreen.js +34 -2
  69. package/dist/ui/overlays/model-probe-view.d.ts +128 -0
  70. package/dist/ui/overlays/model-probe-view.js +225 -0
  71. package/dist/ui/overlays/model-profile-state.d.ts +9 -1
  72. package/dist/ui/overlays/model-profile-state.js +10 -0
  73. package/dist/ui/overlays/use-model-probe.d.ts +30 -0
  74. package/dist/ui/overlays/use-model-probe.js +109 -0
  75. package/dist/ui/session-bridge.d.ts +26 -0
  76. package/dist/ui/session-bridge.js +32 -0
  77. package/package.json +2 -2
@@ -0,0 +1,391 @@
1
+ /**
2
+ * The connection probe behind `Test connection` and `Discover models`
3
+ * (model-profile-connection-test §4.2-§4.8).
4
+ *
5
+ * WHY THIS IS NOT `ModelRegistry.discoverModels()`: that path folds every exception
6
+ * into `[]` and stores the result in a five-minute process-wide cache. Using it here
7
+ * would (a) collapse "wrong key", "wrong URL" and "this account owns no models" into
8
+ * one sentence, and (b) let a probe of an UNSAVED draft endpoint poison `/model`,
9
+ * `/context` and the main session's window resolution for five minutes. So the probe
10
+ * talks to the adapter directly and classifies by `LLMError.errorType`.
11
+ *
12
+ * WHY `complete()` AND NOT ONLY `listModels()`: a model can be listed and still be
13
+ * unable to generate (gateway aliases, permission layers). One real round trip is the
14
+ * only honest answer to "does this work right now", and it is also the only evidence
15
+ * that can produce `model_not_found`.
16
+ *
17
+ * ASCII ONLY: `agent/` is inside `glyphs.test.ts::inScope`.
18
+ */
19
+ import { createHash } from 'node:crypto';
20
+ import { LLMError, MIN_MAX_OUTPUT_TOKENS, classifyOutputLimitFailure, } from '@aragon-agent/core';
21
+ import { isValidEndpointUrl } from '../config/model-profiles.js';
22
+ import { getLogger } from '../logging/logger.js';
23
+ import { PROBE_LIMITS } from './model-probe-limits.js';
24
+ /** `Reply with the single word OK.` - one isolated call, no tools, no system prompt. */
25
+ const PROBE_PROMPT = 'Reply with the single word OK.';
26
+ const UNKNOWN_DETAIL = 'The provider failed in an unrecognized way.';
27
+ const NO_KEY_DETAIL_SUFFIX = ' Set one here or in the environment.';
28
+ /**
29
+ * Which key this draft would actually use (§4.2.1).
30
+ *
31
+ * The editor's `apiKey` field is ALWAYS the empty string: it is the replacement
32
+ * input, not the live credential. `finishProfileEditor` resolves `keep` to the
33
+ * saved entry key and `shared` to `null`, and the runtime then falls back to the
34
+ * shared per-provider key. Without this table the most common path - editing an
35
+ * existing profile, whose credential mode defaults to `keep` - would report
36
+ * `(no key)` for a profile that has one.
37
+ */
38
+ export function resolveDraftKey(input) {
39
+ const nonEmpty = (value) => value && value.trim().length > 0 ? value.trim() : null;
40
+ if (input.credentialMode === 'replace')
41
+ return nonEmpty(input.draftKey);
42
+ if (input.credentialMode === 'keep') {
43
+ return nonEmpty(input.existingKey) ?? nonEmpty(input.sharedKey);
44
+ }
45
+ return nonEmpty(input.sharedKey);
46
+ }
47
+ /** Trailing slashes are not part of an endpoint's identity. */
48
+ function normalizeBaseUrl(baseUrl) {
49
+ const trimmed = (baseUrl ?? '').trim();
50
+ if (trimmed.length === 0)
51
+ return null;
52
+ return trimmed.replace(/\/+$/, '');
53
+ }
54
+ function keyDigest(apiKey) {
55
+ const trimmed = (apiKey ?? '').trim();
56
+ if (trimmed.length === 0)
57
+ return 'none';
58
+ return createHash('sha256').update(trimmed).digest('hex').slice(0, 12);
59
+ }
60
+ /**
61
+ * The host a failure sentence names - same four clauses as
62
+ * `profileEndpointSummary()` in `ui/overlays/ModelProfilePicker.tsx`, which this
63
+ * layer may not import (agent -> ui would invert the layering). `model-probe.test.ts`
64
+ * asserts the two agree, so the duplication cannot drift silently.
65
+ */
66
+ export function endpointHost(baseUrl) {
67
+ if (!baseUrl || baseUrl.trim().length === 0)
68
+ return 'the provider';
69
+ try {
70
+ const url = new URL(baseUrl.trim());
71
+ return url.username || url.password || url.search || url.hash ? 'this endpoint'
72
+ : url.host || 'the provider';
73
+ }
74
+ catch {
75
+ return 'this endpoint';
76
+ }
77
+ }
78
+ function fingerprint(provider, baseUrl, apiKey, model) {
79
+ const connection = `${provider}|${normalizeBaseUrl(baseUrl) ?? ''}|${keyDigest(apiKey)}`;
80
+ return model === undefined ? connection : `${connection}|${model.trim()}`;
81
+ }
82
+ /** Absolute sentence so a redacted, truncated message can never show a key. */
83
+ function redactDetail(text, secrets) {
84
+ let safe = text;
85
+ for (const secret of secrets) {
86
+ if (secret.length > 0)
87
+ safe = safe.split(secret).join('[redacted]');
88
+ }
89
+ return safe.replace(/[\u0000-\u001f\u007f]/g, ' ').slice(0, PROBE_LIMITS.detailChars);
90
+ }
91
+ /**
92
+ * One timer, two recorded facts. `cancelled` and `timeout` must be distinguishable
93
+ * AFTER the fact, because an aborted adapter may resolve quietly or throw something
94
+ * that looks like a network error (§4.3 step 6).
95
+ */
96
+ function createDeadline(timeoutMs, parent) {
97
+ const controller = new AbortController();
98
+ let fired = false;
99
+ const onParentAbort = () => controller.abort();
100
+ if (parent) {
101
+ if (parent.aborted)
102
+ controller.abort();
103
+ else
104
+ parent.addEventListener('abort', onParentAbort, { once: true });
105
+ }
106
+ const handle = setTimeout(() => { fired = true; controller.abort(); }, timeoutMs);
107
+ unrefTimer(handle);
108
+ return {
109
+ signal: controller.signal,
110
+ isFired: () => fired,
111
+ isCancelled: () => parent?.aborted === true,
112
+ dispose: () => {
113
+ clearTimeout(handle);
114
+ parent?.removeEventListener('abort', onParentAbort);
115
+ },
116
+ };
117
+ }
118
+ /**
119
+ * Run the best-effort model list under a cap. It never rejects and never vetoes.
120
+ *
121
+ * The cap timer is `unref`'d exactly like the deadline (§4.8): after a fast verdict the
122
+ * list is still in flight, and a referenced five-second timer would keep the process
123
+ * alive on exit for a number nobody is going to read.
124
+ *
125
+ * Taking a THUNK rather than a promise is what stops a synchronous throw (§4.3 step 3):
126
+ * an adapter that fails before its first `await` - a test double, or a guard clause -
127
+ * would otherwise escape past this layer and cancel a completion not yet attempted.
128
+ */
129
+ function withCap(call, ms) {
130
+ let pending;
131
+ try {
132
+ pending = call();
133
+ }
134
+ catch {
135
+ return Promise.resolve(undefined);
136
+ }
137
+ return new Promise((resolve) => {
138
+ const handle = setTimeout(() => resolve(undefined), ms);
139
+ unrefTimer(handle);
140
+ const settle = (value) => { clearTimeout(handle); resolve(value); };
141
+ pending.then((value) => settle(value), () => settle(undefined));
142
+ });
143
+ }
144
+ /** Timers and immediates both expose `unref<Timer>`; neither may outlive the probe. */
145
+ function unrefTimer(handle) {
146
+ const unrefable = handle;
147
+ unrefable.unref?.();
148
+ }
149
+ /**
150
+ * The list AS IT STANDS when the verdict is reached.
151
+ *
152
+ * One macrotask of grace, so a list that resolved while the completion was in flight
153
+ * publishes itself before we judge - and a list still in flight is NOT waited for,
154
+ * because it has no veto over the verdict (§4.3 step 3 / RV-P1-1).
155
+ */
156
+ function listAtVerdict(listing) {
157
+ const grant = new Promise((resolve) => {
158
+ unrefTimer(setImmediate(() => resolve(undefined)));
159
+ });
160
+ return Promise.race([listing, grant]);
161
+ }
162
+ function guardFailure(provider, request, targetId) {
163
+ const base = { ok: false, targetId, latencyMs: 0 };
164
+ if (!provider) {
165
+ return { ...base, kind: 'unsupported_provider',
166
+ detail: `This build has no adapter for ${request.provider}.` };
167
+ }
168
+ if (!request.apiKey || request.apiKey.trim().length === 0) {
169
+ return { ...base, kind: 'missing_key',
170
+ detail: `No API key for ${request.provider}.${NO_KEY_DETAIL_SUFFIX}` };
171
+ }
172
+ if (request.baseUrl && !isValidEndpointUrl(request.baseUrl)) {
173
+ return { ...base, kind: 'invalid_base_url',
174
+ detail: 'Base URL must be an http(s) URL without credentials, query or fragment.' };
175
+ }
176
+ return undefined;
177
+ }
178
+ function mentionsModel(err) {
179
+ return /model/i.test(err.message);
180
+ }
181
+ /** The ONE classification matrix (§4.4). Signal facts are checked by the caller. */
182
+ function classifyError(err, facts) {
183
+ if (!(err instanceof LLMError))
184
+ return { kind: 'unknown', detail: UNKNOWN_DETAIL };
185
+ const status = typeof err.statusCode === 'number' ? err.statusCode : undefined;
186
+ const suffix = status === undefined ? '' : ` (HTTP ${status})`;
187
+ switch (err.errorType) {
188
+ case 'auth_error':
189
+ return { kind: 'auth', detail: `Key rejected by ${facts.provider}${suffix}.` };
190
+ case 'invalid_request':
191
+ case 'context_overflow':
192
+ return mentionsModel(err)
193
+ ? { kind: 'model_not_found',
194
+ detail: `The provider did not accept model "${facts.model}"${suffix}.` }
195
+ : { kind: 'invalid_request', detail: `The provider rejected the request${suffix}.` };
196
+ case 'rate_limit':
197
+ return { kind: 'rate_limit',
198
+ detail: `Rate limited by ${facts.provider}${suffix || ' (HTTP 429)'}. Try again shortly.` };
199
+ case 'overloaded':
200
+ case 'server_error':
201
+ return { kind: 'provider_unavailable',
202
+ detail: `${facts.provider} is unavailable right now${suffix}.` };
203
+ case 'network_error':
204
+ return { kind: 'network',
205
+ detail: `Could not reach ${facts.host}. Check the base URL, DNS, proxy or VPN.` };
206
+ case 'timeout':
207
+ return { kind: 'timeout',
208
+ detail: `No answer from ${facts.host} within ${facts.timeoutSeconds} s.` };
209
+ default:
210
+ return { kind: 'unknown', detail: UNKNOWN_DETAIL };
211
+ }
212
+ }
213
+ function firstText(message) {
214
+ const block = message.content.find((part) => part.type === 'text');
215
+ return block && block.type === 'text' ? block.text : undefined;
216
+ }
217
+ /** Only text literally present in the error body may count as "the field is wrong". */
218
+ function errorBody(err) {
219
+ return typeof err.raw === 'string' ? err.raw : err.message;
220
+ }
221
+ function isTokenFieldRejection(err, provider) {
222
+ if (!(err instanceof LLMError))
223
+ return false;
224
+ return classifyOutputLimitFailure(provider, err.statusCode ?? 0, errorBody(err)) !== undefined;
225
+ }
226
+ function requestFor(request, deadline, maxTokens) {
227
+ return {
228
+ model: request.model.trim(),
229
+ messages: [{ role: 'user', content: PROBE_PROMPT }],
230
+ apiKey: request.apiKey ?? '',
231
+ ...(request.baseUrl ? { baseUrl: request.baseUrl } : {}),
232
+ signal: deadline.signal,
233
+ ...(maxTokens === undefined ? {} : { maxTokens }),
234
+ };
235
+ }
236
+ function logProbe(facts, outcome, now) {
237
+ try {
238
+ getLogger().child('llm').info('probe_result', {
239
+ provider: facts.provider, model: facts.model, host: facts.host,
240
+ latencyMs: outcome.latencyMs, ok: outcome.ok, kind: outcome.kind ?? 'ok',
241
+ at: now,
242
+ });
243
+ }
244
+ catch { /* logging must never fail a probe */ }
245
+ }
246
+ function candidateOf(model) {
247
+ return {
248
+ id: model.id,
249
+ name: model.name && model.name !== model.id ? model.name : model.id,
250
+ ...(model.contextWindowSource === 'api' && model.contextWindow
251
+ ? { contextWindow: model.contextWindow } : {}),
252
+ supportsTools: model.supportsTools === true,
253
+ };
254
+ }
255
+ async function runDiscovery(provider, request, connectionId) {
256
+ const guard = guardFailure(provider, request, connectionId);
257
+ if (guard) {
258
+ return { ok: false, connectionId, kind: guard.kind, detail: guard.detail, models: [],
259
+ truncated: false, total: 0 };
260
+ }
261
+ const deadline = createDeadline(PROBE_LIMITS.discoveryTimeoutMs, request.signal);
262
+ const facts = { provider: request.provider, model: request.model,
263
+ host: endpointHost(request.baseUrl), timeoutSeconds: PROBE_LIMITS.discoveryTimeoutMs / 1000 };
264
+ try {
265
+ const listed = await provider.listModels(request.apiKey, request.baseUrl ?? undefined, deadline.signal);
266
+ const candidates = (listed ?? []).map(candidateOf).filter((entry) => entry.id.length > 0);
267
+ return { ok: true, connectionId, total: candidates.length,
268
+ models: candidates.slice(0, PROBE_LIMITS.maxListedModels),
269
+ truncated: candidates.length > PROBE_LIMITS.maxListedModels };
270
+ }
271
+ catch (failure) {
272
+ const verdict = request.signal?.aborted ? { kind: 'cancelled', detail: 'Cancelled.' }
273
+ : deadline.isFired()
274
+ ? { kind: 'timeout',
275
+ detail: `No answer from ${facts.host} within ${facts.timeoutSeconds} s.` }
276
+ : classifyError(failure, facts);
277
+ return { ok: false, connectionId, kind: verdict.kind, detail: verdict.detail, models: [],
278
+ truncated: false, total: 0 };
279
+ }
280
+ finally {
281
+ deadline.dispose();
282
+ }
283
+ }
284
+ /** Create the probe port. One instance per App; `SettingsScreen` only passes it down. */
285
+ export function createModelProbe(deps) {
286
+ const now = deps.now ?? Date.now;
287
+ return {
288
+ targetFingerprint: (target) => fingerprint(target.provider, target.baseUrl, target.apiKey, target.model),
289
+ connectionFingerprint: (target) => fingerprint(target.provider, target.baseUrl, target.apiKey),
290
+ discover: (request) => runDiscovery(deps.registry.get(request.provider), request, fingerprint(request.provider, request.baseUrl, request.apiKey)),
291
+ test: (request) => runTest(deps, request),
292
+ };
293
+ }
294
+ async function runTest(deps, request) {
295
+ const clock = deps.now ?? Date.now;
296
+ const started = clock();
297
+ const targetId = fingerprint(request.provider, request.baseUrl, request.apiKey, request.model);
298
+ const provider = deps.registry.get(request.provider);
299
+ const guard = guardFailure(provider, request, targetId);
300
+ if (guard)
301
+ return guard;
302
+ const adapter = provider;
303
+ const deadline = createDeadline(PROBE_LIMITS.testTimeoutMs, request.signal);
304
+ const facts = { provider: request.provider, model: request.model.trim(),
305
+ host: endpointHost(request.baseUrl), timeoutSeconds: PROBE_LIMITS.testTimeoutMs / 1000 };
306
+ // The list runs CONCURRENTLY and has no veto (§4.3 step 3): it may only decorate a
307
+ // verdict the completion already produced. Nothing waits for it except the
308
+ // model-less path, which has no completion to wait for.
309
+ const listing = withCap(() => adapter.listModels(request.apiKey, request.baseUrl ?? undefined, deadline.signal), PROBE_LIMITS.listModelsBudgetMs);
310
+ try {
311
+ return await completeAndJudge(adapter, request, deadline, facts, { targetId, started, now: clock, listing });
312
+ }
313
+ finally {
314
+ deadline.dispose();
315
+ }
316
+ }
317
+ async function completeAndJudge(provider, request, deadline, facts, context) {
318
+ const base = { targetId: context.targetId };
319
+ if (request.model.trim().length === 0) {
320
+ // Nothing to complete: list only, and report what the key can reach.
321
+ const reachable = await context.listing;
322
+ const latencyMs = context.now() - context.started;
323
+ if (request.signal?.aborted) {
324
+ return { ...base, ok: false, latencyMs, kind: 'cancelled', detail: 'Cancelled.' };
325
+ }
326
+ if (deadline.isFired()) {
327
+ return { ...base, ok: false, latencyMs, kind: 'timeout',
328
+ detail: `No answer from ${facts.host} within ${facts.timeoutSeconds} s.` };
329
+ }
330
+ return { ...base, ok: true, latencyMs,
331
+ ...(reachable ? { availableModels: reachable.length } : {}) };
332
+ }
333
+ let message;
334
+ let failure;
335
+ try {
336
+ message = await provider.complete(requestFor(request, deadline, MIN_MAX_OUTPUT_TOKENS));
337
+ }
338
+ catch (error) {
339
+ failure = error;
340
+ }
341
+ if (failure !== undefined && isTokenFieldRejection(failure, request.provider)) {
342
+ // Exactly ONE repair retry with the cap left to the adapter's own learned field.
343
+ try {
344
+ message = await provider.complete(requestFor(request, deadline));
345
+ failure = undefined;
346
+ }
347
+ catch (error) {
348
+ failure = error;
349
+ }
350
+ }
351
+ const latencyMs = context.now() - context.started;
352
+ if (request.signal?.aborted) {
353
+ return { ...base, ok: false, latencyMs, kind: 'cancelled', detail: 'Cancelled.' };
354
+ }
355
+ if (deadline.isFired()) {
356
+ return { ...base, ok: false, latencyMs, kind: 'timeout',
357
+ detail: `No answer from ${facts.host} within ${facts.timeoutSeconds} s.` };
358
+ }
359
+ if (failure !== undefined || !message) {
360
+ const verdict = classifyError(failure, facts);
361
+ const secrets = [request.apiKey ?? ''];
362
+ const outcome = { ...base, ok: false, latencyMs, kind: verdict.kind,
363
+ detail: redactDetail(verdict.detail, secrets) };
364
+ const reachable = await listAtVerdict(context.listing);
365
+ if (reachable)
366
+ outcome.availableModels = reachable.length;
367
+ if (verdict.kind === 'model_not_found' && reachable) {
368
+ outcome.modelListed = reachable.some((model) => model.id === facts.model);
369
+ }
370
+ logProbe(facts, outcome, context.now());
371
+ return outcome;
372
+ }
373
+ const text = firstText(message);
374
+ if (text === undefined) {
375
+ const outcome = { ...base, ok: false, latencyMs, kind: 'unknown',
376
+ detail: UNKNOWN_DETAIL };
377
+ logProbe(facts, outcome, context.now());
378
+ return outcome;
379
+ }
380
+ const reachable = await listAtVerdict(context.listing);
381
+ const outcome = {
382
+ ...base, ok: true, latencyMs,
383
+ reply: text.trim().slice(0, PROBE_LIMITS.replyChars),
384
+ ...(message.usage ? { usage: { inputTokens: message.usage.inputTokens,
385
+ outputTokens: message.usage.outputTokens } } : {}),
386
+ ...(reachable ? { availableModels: reachable.length,
387
+ modelListed: reachable.some((model) => model.id === facts.model) } : {}),
388
+ };
389
+ logProbe(facts, outcome, context.now());
390
+ return outcome;
391
+ }
@@ -385,6 +385,14 @@ export interface ViewState {
385
385
  */
386
386
  droppedEntries: number;
387
387
  }
388
+ /**
389
+ * The zero session total.
390
+ *
391
+ * ONE LITERAL FOR TWO SITES (`initialViewState` and `resetConversation`). They
392
+ * used to spell the object out twice, which is how `cacheReadTokens` would have
393
+ * been added to one of them and not the other.
394
+ */
395
+ export declare const EMPTY_USAGE_TOTAL: UsageTotal;
388
396
  export interface ViewStateSeed {
389
397
  /** Start with thinking blocks expanded. Defaults to FALSE, the product default. */
390
398
  thinkingVisible?: boolean;
@@ -490,6 +498,20 @@ export type ViewAction = {
490
498
  type: 'clearTranscript';
491
499
  } | {
492
500
  type: 'resetConversation';
501
+ }
502
+ /**
503
+ * Zero the session's spend WITHOUT touching the conversation
504
+ * (tui-session-id-resume D-10 / R-9 / T-45).
505
+ *
506
+ * A separate action rather than `resetConversation`, because the two mean
507
+ * different things: `/clear` ends the conversation and the spend together,
508
+ * while an in-TUI `/resume` adopts another conversation and starts a NEW id -
509
+ * so the counter must restart, but the entries that were just restored must
510
+ * not be thrown away with it. `context` is left untouched for the reason
511
+ * `resetConversation` records: the meter, not the reducer, owns occupancy.
512
+ */
513
+ | {
514
+ type: 'resetUsage';
493
515
  } | {
494
516
  type: 'setOverlay';
495
517
  overlay: Overlay;
@@ -24,7 +24,7 @@ export const DEFAULT_TOAST_TTL_MS = 2500;
24
24
  * used to spell the object out twice, which is how `cacheReadTokens` would have
25
25
  * been added to one of them and not the other.
26
26
  */
27
- const EMPTY_USAGE_TOTAL = {
27
+ export const EMPTY_USAGE_TOTAL = {
28
28
  inputTokens: 0,
29
29
  outputTokens: 0,
30
30
  cacheReadTokens: 0,
@@ -767,25 +767,32 @@ export function viewReducer(state, action) {
767
767
  ...state,
768
768
  entries: [],
769
769
  // The count describes what the RETAIN RING removed from the visible
770
- // transcript. `/clear` empties it by intent, so carrying the number
770
+ // transcript. `/clear screen` empties it by intent, so carrying the number
771
771
  // over would make the exported transcript claim a loss that did not
772
772
  // happen.
773
773
  droppedEntries: 0,
774
774
  streamingId: undefined,
775
775
  expandedToolIds: {},
776
776
  teamEntryId: undefined,
777
- // The list goes with the screen it was on. `/clear` is an explicit user
778
- // instruction, which is I-2's standing exception (`TodoClearReason`
779
- // `'user'`, the same door `/todo clear` uses) — so the command clears the
777
+ // The list goes with the screen it was on. `/clear screen` is an explicit
778
+ // user instruction, which is I-2's standing exception (`TodoClearReason`
779
+ // `'user'`, the same door `/todo clear` uses) - so the command clears the
780
780
  // STORE via `controller.clearTodos()` and this clears the MIRROR, exactly
781
781
  // the pairing `resetConversation` below has always had.
782
782
  //
783
783
  // The mirror write is same-valued with the store's own `todoCleared`
784
784
  // dispatch rather than redundant with it: it is what makes this action
785
- // stand on its own. `builtins.ts` is the only dispatcher of it TODAY, and
786
- // a second one — a real clear-screen key, a session-restore path — would
785
+ // stand on its own. `commands/clear-command.ts` is its only dispatcher
786
+ // TODAY - `/clear` itself now goes through `resetConversation`, because
787
+ // clearing the SCREEN and clearing the SESSION are different intentions
788
+ // (clear-command-context-reset D-3) - and a hypothetical second one would
787
789
  // otherwise bring the bug back in identical form and in silence.
788
790
  //
791
+ // KEPT RATHER THAN FOLDED INTO `resetConversation` for the same reason:
792
+ // a user who wants the screen back must not have to destroy their context
793
+ // to get it, and an action with no dispatcher is dead code by `CLAUDE.md`
794
+ // section 4 - which is what `/clear screen` prevents.
795
+ //
789
796
  // The entry id is dropped because the entry it pointed at is gone.
790
797
  todos: null,
791
798
  todoEntryId: undefined,
@@ -799,9 +806,12 @@ export function viewReducer(state, action) {
799
806
  // id must go with it or `compactionEnd` would `mapEntry` an id that no
800
807
  // longer exists. `compaction` STAYS — unlike `retry` it is not a
801
808
  // projection of an entry, it is the live state of a subsystem that is
802
- // still running, and `/clear` clears the screen rather than the session.
809
+ // still running. `/clear screen` clears the screen, not the session.
803
810
  compactionEntryId: undefined,
804
811
  };
812
+ case 'resetUsage':
813
+ // The one field, for the one reason: a new id bills from zero (D-10).
814
+ return { ...state, usageTotal: EMPTY_USAGE_TOTAL };
805
815
  case 'resetConversation':
806
816
  return {
807
817
  ...state,
@@ -810,22 +820,47 @@ export function viewReducer(state, action) {
810
820
  droppedEntries: 0,
811
821
  streamingId: undefined,
812
822
  expandedToolIds: {},
813
- // `context` IS DELIBERATELY UNTOUCHED (I-1). `builtins.ts` calls
814
- // `controller.clearMessages()` BEFORE dispatching this, and that marks
815
- // the meter dirty and schedules a publication; zeroing here would make
816
- // the final state `0` rather than the meter's real answer - which for an
823
+ // `context` IS DELIBERATELY UNTOUCHED (I-1). `clear-command.ts` calls
824
+ // `controller.resetConversation()` BEFORE dispatching this, and that marks
825
+ // the meter dirty and schedules a publication; zeroing here would make the
826
+ // final state `0` rather than the meter's real answer - which for an
817
827
  // emptied conversation is the system prompt's own couple of percent, not
818
828
  // nothing. `usageTotal` DOES reset: that is session spend, not occupancy.
819
829
  usageTotal: EMPTY_USAGE_TOTAL,
820
830
  teamEntryId: undefined,
821
- // `/reset` clears `messages`, so the belief is gone and the list goes
822
- // with it — the other half of I-2. `controller.clearMessages()` clears
831
+ // `/clear` clears `messages`, so the belief is gone and the list goes
832
+ // with it - the other half of I-2. `controller.clearMessages()` clears
823
833
  // the STORE; this clears the mirror.
824
834
  todos: null,
825
835
  todoEntryId: undefined,
826
836
  retryEntryId: undefined,
827
837
  retry: null,
828
838
  compactionEntryId: undefined,
839
+ // THE SIX RUN FACTS THIS BRANCH USED TO KEEP (clear-command-context-reset
840
+ // §3.6 / P0-3). `reduceRunFacts` above already zeroes `runPhase`,
841
+ // `activeTool`, `runOutcome` and `seenSteeringIds`, so nothing here
842
+ // repeats those four - these six are the actual gap, and each one
843
+ // otherwise describes a run that no longer exists.
844
+ //
845
+ // `status` is the status bar: a late `runEnd` would correct it a moment
846
+ // later, and until then the user is told a conversation that is over is
847
+ // still running. `team` is a dispatch panel with nothing dispatching.
848
+ // `fastEntryId` points at a `kind: 'fast'` card that `entries: []` just
849
+ // deleted, the same dangling-id rule `retryEntryId` and
850
+ // `compactionEntryId` above already follow.
851
+ //
852
+ // `turnProduced` / `errorNoticed` / `aborted` are ALSO cleared by
853
+ // `submit`, which is not a reason to leave them out: clearing is an
854
+ // independent entry point, so a user who clears and then only reads the
855
+ // panel would carry the previous turn's three facts into a conversation
856
+ // they never submitted into - and `runEnd`'s settle rule reads exactly
857
+ // those three.
858
+ status: 'idle',
859
+ team: null,
860
+ fastEntryId: undefined,
861
+ turnProduced: false,
862
+ errorNoticed: false,
863
+ aborted: false,
829
864
  };
830
865
  case 'setOverlay':
831
866
  return { ...state, overlay: action.overlay };