@aragon-agent/cli 0.6.24 → 0.6.25

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 (37) hide show
  1. package/CHANGELOG.md +1835 -1786
  2. package/README.md +2578 -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.js +45 -13
  12. package/dist/commands/builtins.js +14 -34
  13. package/dist/commands/clear-command.d.ts +27 -0
  14. package/dist/commands/clear-command.js +107 -0
  15. package/dist/commands/registry.d.ts +20 -1
  16. package/dist/config/model-profiles.d.ts +8 -0
  17. package/dist/config/model-profiles.js +10 -0
  18. package/dist/session/clear-archive.d.ts +51 -0
  19. package/dist/session/clear-archive.js +160 -0
  20. package/dist/todo/types.d.ts +5 -3
  21. package/dist/ui/App.js +39 -7
  22. package/dist/ui/entries/CompactionCard.d.ts +5 -4
  23. package/dist/ui/entries/CompactionCard.js +5 -4
  24. package/dist/ui/overlays/HelpOverlay.js +8 -2
  25. package/dist/ui/overlays/ModelDiscoveryList.d.ts +23 -0
  26. package/dist/ui/overlays/ModelDiscoveryList.js +161 -0
  27. package/dist/ui/overlays/ModelProfileEditor.d.ts +18 -1
  28. package/dist/ui/overlays/ModelProfileEditor.js +117 -68
  29. package/dist/ui/overlays/SettingsScreen.d.ts +8 -0
  30. package/dist/ui/overlays/SettingsScreen.js +34 -2
  31. package/dist/ui/overlays/model-probe-view.d.ts +128 -0
  32. package/dist/ui/overlays/model-probe-view.js +225 -0
  33. package/dist/ui/overlays/model-profile-state.d.ts +9 -1
  34. package/dist/ui/overlays/model-profile-state.js +10 -0
  35. package/dist/ui/overlays/use-model-probe.d.ts +30 -0
  36. package/dist/ui/overlays/use-model-probe.js +109 -0
  37. 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
+ }
@@ -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,7 +806,7 @@ 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
  };
805
812
  case 'resetConversation':
@@ -810,22 +817,47 @@ export function viewReducer(state, action) {
810
817
  droppedEntries: 0,
811
818
  streamingId: undefined,
812
819
  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
820
+ // `context` IS DELIBERATELY UNTOUCHED (I-1). `clear-command.ts` calls
821
+ // `controller.resetConversation()` BEFORE dispatching this, and that marks
822
+ // the meter dirty and schedules a publication; zeroing here would make the
823
+ // final state `0` rather than the meter's real answer - which for an
817
824
  // emptied conversation is the system prompt's own couple of percent, not
818
825
  // nothing. `usageTotal` DOES reset: that is session spend, not occupancy.
819
826
  usageTotal: EMPTY_USAGE_TOTAL,
820
827
  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
828
+ // `/clear` clears `messages`, so the belief is gone and the list goes
829
+ // with it - the other half of I-2. `controller.clearMessages()` clears
823
830
  // the STORE; this clears the mirror.
824
831
  todos: null,
825
832
  todoEntryId: undefined,
826
833
  retryEntryId: undefined,
827
834
  retry: null,
828
835
  compactionEntryId: undefined,
836
+ // THE SIX RUN FACTS THIS BRANCH USED TO KEEP (clear-command-context-reset
837
+ // §3.6 / P0-3). `reduceRunFacts` above already zeroes `runPhase`,
838
+ // `activeTool`, `runOutcome` and `seenSteeringIds`, so nothing here
839
+ // repeats those four - these six are the actual gap, and each one
840
+ // otherwise describes a run that no longer exists.
841
+ //
842
+ // `status` is the status bar: a late `runEnd` would correct it a moment
843
+ // later, and until then the user is told a conversation that is over is
844
+ // still running. `team` is a dispatch panel with nothing dispatching.
845
+ // `fastEntryId` points at a `kind: 'fast'` card that `entries: []` just
846
+ // deleted, the same dangling-id rule `retryEntryId` and
847
+ // `compactionEntryId` above already follow.
848
+ //
849
+ // `turnProduced` / `errorNoticed` / `aborted` are ALSO cleared by
850
+ // `submit`, which is not a reason to leave them out: clearing is an
851
+ // independent entry point, so a user who clears and then only reads the
852
+ // panel would carry the previous turn's three facts into a conversation
853
+ // they never submitted into - and `runEnd`'s settle rule reads exactly
854
+ // those three.
855
+ status: 'idle',
856
+ team: null,
857
+ fastEntryId: undefined,
858
+ turnProduced: false,
859
+ errorNoticed: false,
860
+ aborted: false,
829
861
  };
830
862
  case 'setOverlay':
831
863
  return { ...state, overlay: action.overlay };
@@ -21,6 +21,7 @@ import { subagentModelSummary } from '../ui/overlays/model-profile-state.js';
21
21
  import { registerQueueCommand } from './queue-command.js';
22
22
  import { perfCommand } from './perf.js';
23
23
  import { runCompactCommand } from '../compaction/command.js';
24
+ import { runClearCommand } from './clear-command.js';
24
25
  import { runContextCommand } from '../compaction/context-command.js';
25
26
  /**
26
27
  * `/fast status` — THE GUARANTEED REPORTING SURFACE for the tier (§4.4 / R-8).
@@ -957,44 +958,23 @@ const COMMANDS = [
957
958
  },
958
959
  },
959
960
  {
961
+ // REGISTRATION ONLY. The body lives in `commands/clear-command.ts`: this file
962
+ // is already past the 1000-line guideline, and `/clear` is no longer a
963
+ // two-line projection reset - it interrupts a run, waits for it, archives the
964
+ // conversation and then clears (clear-command-context-reset D-8).
960
965
  name: 'clear',
961
- description: 'Clear the visible transcript and the todo panel',
962
- run: (ctx) => {
963
- // A USER OVERRIDE OF A PROJECTION, the same class as `/todo clear`, so it
964
- // reuses reason `'user'`; I-2 and its standing exception are stated on
965
- // `TodoStore`. Clearing the STORE and not merely the view mirror is what
966
- // keeps `/save`, `/todo status|continue` and follow-through agreeing with
967
- // the screen — they read the store directly. `messages` is untouched:
968
- // clearing the conversation is still `/reset` alone.
969
- //
970
- // DELIBERATELY WITHOUT `/todo clear`'s two guards: an `isRunning()` refusal
971
- // would leave `/clear` unable to clear even the transcript mid-run, and an
972
- // early return on an empty list would skip the dispatch below entirely.
973
- // Clearing an empty store is already silent — it emits into a reducer
974
- // branch that writes two fields that are both already at their defaults.
975
- //
976
- // Bounded side effect: the emptied store re-arms `todo_write`'s "a fresh
977
- // plan has at least two items" gate, so a model that shrinks a plan to one
978
- // item right after a mid-run `/clear` gets the refusal text and retries.
979
- ctx.controller.clearTodos();
980
- ctx.dispatch({ type: 'clearTranscript' });
981
- },
966
+ description: 'Start a new conversation; "/clear screen" only clears the screen',
967
+ run: runClearCommand,
982
968
  },
983
969
  {
970
+ // ALIAS BY DESIGN (D-2), AND THIS IS THE WHOLE FIX. Until this change `/clear`
971
+ // cleared the screen while `/reset` cleared the session, so the command a
972
+ // user reaches for in a long conversation was the one that kept the history.
973
+ // One implementation makes that drift structurally impossible rather than
974
+ // merely fixed: there is no second body left to diverge.
984
975
  name: 'reset',
985
- description: 'Start a new conversation',
986
- run: (ctx) => {
987
- if (ctx.controller.isRunning()) {
988
- ctx.notify('warn', 'Cannot reset while the agent is running. Wait for it to stop.');
989
- return;
990
- }
991
- ctx.controller.clearMessages();
992
- ctx.controller.clearAllQueues();
993
- ctx.dispatch({ type: 'resetConversation' });
994
- ctx.toast('info', 'Started a new conversation.');
995
- if (ctx.state.pendingSteering.length)
996
- ctx.notify('warn', `\u5df2\u53d6\u6d88 ${ctx.state.pendingSteering.length} \u6761\u672a\u63a5\u6536\u6d88\u606f`);
997
- },
976
+ description: 'Alias of /clear',
977
+ run: runClearCommand,
998
978
  },
999
979
  {
1000
980
  name: 'cwd',
@@ -0,0 +1,27 @@
1
+ /**
2
+ * `/clear` - start a new conversation - plus its `/reset` alias and the
3
+ * `/clear screen` form that keeps the old "just wipe the screen" meaning
4
+ * (clear-command-context-reset §3.5 / D-1 / D-2 / D-3).
5
+ *
6
+ * ASCII ONLY: `src/commands/**` is inside `glyphs.test.ts::inScope`. The one
7
+ * localized string below is written with `\u` escapes for that reason, exactly
8
+ * as the `/reset` body it replaces did.
9
+ *
10
+ * WHY THE BODY LIVES HERE AND THE REGISTRATION STAYS IN `builtins.ts`: the same
11
+ * reason `/compact` and `/context` do (D-8). `builtins.ts` is 1511 lines against
12
+ * `CLAUDE.md`'s 1000-line cap, and this command is no longer a two-line
13
+ * projection reset - it interrupts a run, waits for it, archives a conversation
14
+ * and then clears. A two-line edit belongs inline; this does not.
15
+ *
16
+ * THE COMMAND'S ONLY JOB IS SEQUENCING AND SPEAKING. Every state change happens
17
+ * on the controller port (`AgentController.resetConversation`, which delegates
18
+ * the ORDER to `agent/conversation-reset.ts`), and every word the user reads is
19
+ * decided here, in one place, in the exact order they will read it.
20
+ */
21
+ import type { CommandContext } from './registry.js';
22
+ /**
23
+ * Run `/clear` or `/reset`. Both registry entries point HERE (D-2), which is
24
+ * what stops the two from drifting back into "one clears the screen, one clears
25
+ * the session" - the drift that produced the defect this feature fixes.
26
+ */
27
+ export declare function runClearCommand(ctx: CommandContext): Promise<void>;