@aragon-agent/cli 0.5.2 → 0.5.4
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 +127 -0
- package/README.md +193 -3
- package/dist/agent/controller.d.ts +51 -1
- package/dist/agent/controller.js +177 -2
- package/dist/agent/headless.d.ts +12 -0
- package/dist/agent/headless.js +41 -1
- package/dist/agent/reducer.d.ts +48 -0
- package/dist/agent/reducer.js +91 -2
- package/dist/agent/system-prompt.d.ts +17 -0
- package/dist/agent/system-prompt.js +4 -0
- package/dist/cli.js +98 -7
- package/dist/commands/builtins.d.ts +2 -2
- package/dist/commands/builtins.js +133 -3
- package/dist/config/app-paths.d.ts +17 -0
- package/dist/config/app-paths.js +21 -0
- package/dist/config/cli-commands.d.ts +26 -2
- package/dist/config/cli-commands.js +83 -6
- package/dist/config/env.d.ts +3 -2
- package/dist/config/env.js +44 -6
- package/dist/config/history-commands.d.ts +20 -0
- package/dist/config/history-commands.js +71 -0
- package/dist/config/load.d.ts +13 -0
- package/dist/config/load.js +107 -6
- package/dist/config/migrate-state-out-of-config.d.ts +32 -0
- package/dist/config/migrate-state-out-of-config.js +85 -0
- package/dist/config/prompt-history.d.ts +86 -0
- package/dist/config/prompt-history.js +332 -0
- package/dist/config/schema.d.ts +176 -18
- package/dist/config/schema.js +162 -8
- package/dist/config/store.js +40 -4
- package/dist/config/ui-state.d.ts +52 -0
- package/dist/config/ui-state.js +149 -0
- package/dist/logging/install.d.ts +85 -0
- package/dist/logging/install.js +94 -0
- package/dist/logging/logger.d.ts +1 -1
- package/dist/session/persist.d.ts +16 -0
- package/dist/session/persist.js +19 -1
- package/dist/team/activity.d.ts +67 -0
- package/dist/team/activity.js +130 -0
- package/dist/team/bus.d.ts +190 -0
- package/dist/team/bus.js +366 -0
- package/dist/team/comm-tools.d.ts +61 -0
- package/dist/team/comm-tools.js +221 -0
- package/dist/team/human-queue.d.ts +55 -0
- package/dist/team/human-queue.js +72 -0
- package/dist/team/limits.d.ts +123 -0
- package/dist/team/limits.js +128 -0
- package/dist/team/normalize.d.ts +39 -0
- package/dist/team/normalize.js +106 -0
- package/dist/team/panel-rows.d.ts +48 -0
- package/dist/team/panel-rows.js +75 -0
- package/dist/team/prompt.d.ts +49 -0
- package/dist/team/prompt.js +87 -0
- package/dist/team/report.d.ts +58 -0
- package/dist/team/report.js +205 -0
- package/dist/team/retry.d.ts +48 -0
- package/dist/team/retry.js +56 -0
- package/dist/team/runtime.d.ts +122 -0
- package/dist/team/runtime.js +406 -0
- package/dist/team/subagent.d.ts +90 -0
- package/dist/team/subagent.js +294 -0
- package/dist/team/task-tool.d.ts +47 -0
- package/dist/team/task-tool.js +118 -0
- package/dist/team/types.d.ts +172 -0
- package/dist/team/types.js +13 -0
- package/dist/tools/index.d.ts +48 -2
- package/dist/tools/index.js +26 -1
- package/dist/ui/App.d.ts +22 -1
- package/dist/ui/App.js +144 -18
- package/dist/ui/StatusBar.d.ts +21 -0
- package/dist/ui/StatusBar.js +5 -2
- package/dist/ui/TeamPanel.d.ts +57 -0
- package/dist/ui/TeamPanel.js +134 -0
- package/dist/ui/Transcript.js +10 -0
- package/dist/ui/entries/TeamCard.d.ts +31 -0
- package/dist/ui/entries/TeamCard.js +39 -0
- package/dist/ui/glyphs.d.ts +10 -0
- package/dist/ui/glyphs.js +6 -0
- package/dist/ui/layout/AppShell.d.ts +11 -1
- package/dist/ui/layout/AppShell.js +3 -3
- package/dist/ui/overlays/HelpOverlay.js +13 -0
- package/dist/ui/overlays/SettingsScreen.d.ts +12 -1
- package/dist/ui/overlays/SettingsScreen.js +38 -1
- package/dist/ui/transcript-text.js +20 -0
- package/dist/ui/use-startup-notices.d.ts +1 -1
- package/dist/ui/use-startup-notices.js +2 -1
- package/package.json +2 -2
package/dist/config/schema.js
CHANGED
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
* invariant).
|
|
7
7
|
*/
|
|
8
8
|
import { isAbsolute } from 'node:path';
|
|
9
|
+
import { ABSOLUTE_MAX_OUTPUT_TOKENS, DEFAULT_MAX_OUTPUT_TOKENS, MIN_MAX_OUTPUT_TOKENS, } from '@aragon-agent/core';
|
|
9
10
|
import { clampLogLevel } from '../logging/levels.js';
|
|
11
|
+
import { TEAM_LIMITS } from '../team/limits.js';
|
|
10
12
|
// ---------------------------------------------------------------------------
|
|
11
13
|
// Provider set — only the adapter-backed providers are usable at run time (R5).
|
|
12
14
|
// ---------------------------------------------------------------------------
|
|
@@ -88,9 +90,13 @@ export const DEFAULT_TOOL_TIMEOUT_MS = 180_000;
|
|
|
88
90
|
export const IDLE_TIMEOUT_MARGIN_MS = 30_000;
|
|
89
91
|
/** Watchdog idle timeout (§3.8) — must stay ≥ toolTimeout so long tools survive. */
|
|
90
92
|
export const DEFAULT_IDLE_TIMEOUT_MS = DEFAULT_TOOL_TIMEOUT_MS + IDLE_TIMEOUT_MARGIN_MS;
|
|
91
|
-
/**
|
|
93
|
+
/**
|
|
94
|
+
* Cap on the recall list.
|
|
95
|
+
*
|
|
96
|
+
* The list itself now lives in `<home>/prompt-history.jsonl`; this constant is
|
|
97
|
+
* consumed by `config/prompt-history.ts`, not by the UI.
|
|
98
|
+
*/
|
|
92
99
|
export const PROMPT_HISTORY_CAP = 100;
|
|
93
|
-
export const RECENT_MODELS_CAP = 10;
|
|
94
100
|
/**
|
|
95
101
|
* How many trailing entries the full-screen viewport renders (invariant I-3).
|
|
96
102
|
* Yoga lays out every child even when `overflow: hidden` clips it, so the cost
|
|
@@ -260,13 +266,61 @@ export function clampLogConfig(raw) {
|
|
|
260
266
|
: DEFAULT_LOG_CONFIG.previewChars,
|
|
261
267
|
};
|
|
262
268
|
}
|
|
269
|
+
/**
|
|
270
|
+
* The requirement's ceiling on the fan-out (R-g).
|
|
271
|
+
*
|
|
272
|
+
* Re-exported from `TEAM_LIMITS` rather than re-spelled, because it is enforced
|
|
273
|
+
* in TWO places — here on the config value, and again in
|
|
274
|
+
* `normalizeSubagentSpecs` on what the model asked for — and two copies of the
|
|
275
|
+
* same 10 is exactly how a hand-edited `config.json` ends up being able to raise
|
|
276
|
+
* a ceiling the requirement fixed.
|
|
277
|
+
*/
|
|
278
|
+
export const HARD_MAX_SUBAGENTS = TEAM_LIMITS.hardMaxSubagents;
|
|
279
|
+
export const DEFAULT_TEAM_CONFIG = {
|
|
280
|
+
enabled: true,
|
|
281
|
+
maxSubagents: 5,
|
|
282
|
+
maxConcurrent: 3,
|
|
283
|
+
subagentTimeoutMs: 300_000,
|
|
284
|
+
dispatchTimeoutMs: 900_000,
|
|
285
|
+
maxTurnsPerSubagent: 24,
|
|
286
|
+
};
|
|
287
|
+
const SUBAGENTS_RANGE = { min: 1, max: HARD_MAX_SUBAGENTS };
|
|
288
|
+
const CONCURRENT_RANGE = { min: 1, max: HARD_MAX_SUBAGENTS };
|
|
289
|
+
const SUBAGENT_TIMEOUT_RANGE = { min: 30_000, max: 1_800_000 };
|
|
290
|
+
const DISPATCH_TIMEOUT_RANGE = { min: 60_000, max: 3_600_000 };
|
|
291
|
+
const TURNS_RANGE = { min: 4, max: 100 };
|
|
292
|
+
/**
|
|
293
|
+
* THE single gate for every team-config read AND write, mirroring
|
|
294
|
+
* `clampSkillsConfig` / `clampLogConfig` for the reason those two record:
|
|
295
|
+
* hardening only the read path leaves a bad value on disk that reverts to the
|
|
296
|
+
* default on every launch, which presents to the user as "my setting won't
|
|
297
|
+
* stick".
|
|
298
|
+
*
|
|
299
|
+
* `maxConcurrent` is additionally capped at `maxSubagents`: a pool wider than
|
|
300
|
+
* the population it draws from is not wrong so much as meaningless, and letting
|
|
301
|
+
* the two disagree would make the panel's `n/m` readout confusing for no gain.
|
|
302
|
+
*/
|
|
303
|
+
export function clampTeamConfig(raw) {
|
|
304
|
+
const src = (raw && typeof raw === 'object' ? raw : {});
|
|
305
|
+
const maxSubagents = clampInt(src.maxSubagents, DEFAULT_TEAM_CONFIG.maxSubagents, SUBAGENTS_RANGE);
|
|
306
|
+
return {
|
|
307
|
+
enabled: bool(src.enabled, DEFAULT_TEAM_CONFIG.enabled),
|
|
308
|
+
maxSubagents,
|
|
309
|
+
maxConcurrent: Math.min(maxSubagents, clampInt(src.maxConcurrent, DEFAULT_TEAM_CONFIG.maxConcurrent, CONCURRENT_RANGE)),
|
|
310
|
+
subagentTimeoutMs: clampInt(src.subagentTimeoutMs, DEFAULT_TEAM_CONFIG.subagentTimeoutMs, SUBAGENT_TIMEOUT_RANGE),
|
|
311
|
+
dispatchTimeoutMs: clampInt(src.dispatchTimeoutMs, DEFAULT_TEAM_CONFIG.dispatchTimeoutMs, DISPATCH_TIMEOUT_RANGE),
|
|
312
|
+
maxTurnsPerSubagent: clampInt(src.maxTurnsPerSubagent, DEFAULT_TEAM_CONFIG.maxTurnsPerSubagent, TURNS_RANGE),
|
|
313
|
+
};
|
|
314
|
+
}
|
|
263
315
|
export const DEFAULT_CONFIG = {
|
|
264
316
|
version: CONFIG_VERSION,
|
|
265
317
|
provider: 'anthropic',
|
|
266
318
|
model: DEFAULT_MODEL,
|
|
267
319
|
baseUrl: null,
|
|
268
320
|
thinkingLevel: 'off',
|
|
269
|
-
|
|
321
|
+
// The engine owns this number; spelling it here again is how the CLI and the
|
|
322
|
+
// provider adapters end up disagreeing about what "the default" is.
|
|
323
|
+
maxTokens: DEFAULT_MAX_OUTPUT_TOKENS,
|
|
270
324
|
theme: 'auto',
|
|
271
325
|
reducedMotion: false,
|
|
272
326
|
fullscreen: true,
|
|
@@ -276,24 +330,56 @@ export const DEFAULT_CONFIG = {
|
|
|
276
330
|
toolTimeoutMs: DEFAULT_TOOL_TIMEOUT_MS,
|
|
277
331
|
idleTimeoutMs: DEFAULT_IDLE_TIMEOUT_MS,
|
|
278
332
|
apiKeys: {},
|
|
279
|
-
|
|
280
|
-
promptHistory: [],
|
|
333
|
+
historyEnabled: true,
|
|
281
334
|
density: 'comfortable',
|
|
282
335
|
hints: true,
|
|
283
336
|
mouse: true,
|
|
284
|
-
mouseNoticeSeen: false,
|
|
285
|
-
submitCount: 0,
|
|
286
337
|
planModeDefault: false,
|
|
287
338
|
planModeMaxAskRounds: DEFAULT_PLAN_MAX_ASK_ROUNDS,
|
|
288
339
|
planModeHumanTimeoutMs: DEFAULT_PLAN_HUMAN_TIMEOUT_MS,
|
|
289
340
|
skills: DEFAULT_SKILLS_CONFIG,
|
|
290
341
|
log: DEFAULT_LOG_CONFIG,
|
|
342
|
+
team: DEFAULT_TEAM_CONFIG,
|
|
291
343
|
};
|
|
344
|
+
/**
|
|
345
|
+
* Runtime state that used to be stored in `config.json` by mistake
|
|
346
|
+
* (config-state-separation §4.5). Each one now lives in its own file:
|
|
347
|
+
* `promptHistory` → `prompt-history.jsonl`, `submitCount` / `mouseNoticeSeen`
|
|
348
|
+
* → `state.json`, and `recentModels` was dead — nothing ever wrote it.
|
|
349
|
+
*/
|
|
350
|
+
export const LEGACY_STATE_KEYS = [
|
|
351
|
+
'promptHistory',
|
|
352
|
+
'submitCount',
|
|
353
|
+
'mouseNoticeSeen',
|
|
354
|
+
'recentModels',
|
|
355
|
+
];
|
|
356
|
+
/**
|
|
357
|
+
* THE single gate that keeps those keys out of `config.json`, and the one thing
|
|
358
|
+
* in this change that cannot be skipped.
|
|
359
|
+
*
|
|
360
|
+
* `loadPersistedConfig()` merges `{ ...DEFAULT_CONFIG, ...partial }` where
|
|
361
|
+
* `partial` is the raw parse of whatever is on disk. Removing the fields from
|
|
362
|
+
* the interface therefore removes NOTHING from the file: the keys ride through
|
|
363
|
+
* the spread and `updatePersistedConfig` writes them straight back. The user
|
|
364
|
+
* deletes `promptHistory` by hand and watches it grow back.
|
|
365
|
+
*
|
|
366
|
+
* Applied on the READ side on purpose, which is what makes it self-healing:
|
|
367
|
+
* every write goes read-strip-merge-write, so a hand-edited file, a downgrade
|
|
368
|
+
* to 0.5.x and back, or a half-finished migration all converge on a clean file
|
|
369
|
+
* at the next write. Same shape as `clampSkillsConfig` / `clampLogConfig` —
|
|
370
|
+
* one door for both directions.
|
|
371
|
+
*/
|
|
372
|
+
export function stripLegacyStateKeys(raw) {
|
|
373
|
+
const out = { ...raw };
|
|
374
|
+
for (const key of LEGACY_STATE_KEYS)
|
|
375
|
+
delete out[key];
|
|
376
|
+
return out;
|
|
377
|
+
}
|
|
292
378
|
// ---------------------------------------------------------------------------
|
|
293
379
|
// Coercion / validation helpers
|
|
294
380
|
// ---------------------------------------------------------------------------
|
|
295
381
|
/** Coerce an arbitrary value into a positive integer, or `undefined`. */
|
|
296
|
-
|
|
382
|
+
function coerceTokenCount(v) {
|
|
297
383
|
if (v === null || v === undefined || v === '')
|
|
298
384
|
return undefined;
|
|
299
385
|
const n = typeof v === 'number' ? v : Number.parseInt(String(v), 10);
|
|
@@ -301,6 +387,74 @@ export function coerceMaxTokens(v) {
|
|
|
301
387
|
return undefined;
|
|
302
388
|
return Math.floor(n);
|
|
303
389
|
}
|
|
390
|
+
/**
|
|
391
|
+
* Coerce an arbitrary value into a positive integer, or `undefined`.
|
|
392
|
+
*
|
|
393
|
+
* Kept because callers outside this feature still use it, and deliberately
|
|
394
|
+
* WITHOUT the range clamp so its behaviour for in-range values is byte-identical
|
|
395
|
+
* to what it always was. New code wants `clampMaxTokens`.
|
|
396
|
+
*/
|
|
397
|
+
export function coerceMaxTokens(v) {
|
|
398
|
+
return coerceTokenCount(v);
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* The accepted range for an explicit output cap.
|
|
402
|
+
*
|
|
403
|
+
* The floor is the engine's: below it a turn cannot produce a usable tool call.
|
|
404
|
+
* The ceiling is a sanity bound on a hand-edited file, not a model limit — the
|
|
405
|
+
* real per-model ceiling is applied later, by `resolveOutputTokens`.
|
|
406
|
+
*/
|
|
407
|
+
export const MAX_TOKENS_RANGE = {
|
|
408
|
+
min: MIN_MAX_OUTPUT_TOKENS,
|
|
409
|
+
max: ABSOLUTE_MAX_OUTPUT_TOKENS,
|
|
410
|
+
};
|
|
411
|
+
/** `auto`, an empty string and `0` all mean AUTO — "let the model decide". */
|
|
412
|
+
export function isAutoToken(v) {
|
|
413
|
+
if (v === 0)
|
|
414
|
+
return true;
|
|
415
|
+
if (typeof v !== 'string')
|
|
416
|
+
return false;
|
|
417
|
+
const trimmed = v.trim().toLowerCase();
|
|
418
|
+
return trimmed === '' || trimmed === 'auto' || trimmed === '0';
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* THE single gate for every `maxTokens` read AND write, in the shape
|
|
422
|
+
* `clampSkillsConfig` / `clampLogConfig` / `clampTeamConfig` already use.
|
|
423
|
+
*
|
|
424
|
+
* Both directions on purpose: hardening only the read path leaves a bad value on
|
|
425
|
+
* disk that reverts to a default on every launch, which presents to the user as
|
|
426
|
+
* "my setting won't stick".
|
|
427
|
+
*
|
|
428
|
+
* Returns `undefined` for AUTO and for anything unusable — the caller decides
|
|
429
|
+
* which of the two it was, because they mean different things.
|
|
430
|
+
*/
|
|
431
|
+
export function clampMaxTokens(v) {
|
|
432
|
+
const n = coerceTokenCount(v);
|
|
433
|
+
if (n === undefined)
|
|
434
|
+
return undefined;
|
|
435
|
+
return Math.min(MAX_TOKENS_RANGE.max, Math.max(MAX_TOKENS_RANGE.min, n));
|
|
436
|
+
}
|
|
437
|
+
/**
|
|
438
|
+
* Parse text typed into the settings screen, a slash command, or `config set`.
|
|
439
|
+
*
|
|
440
|
+
* Distinguishes AUTO from "the user typed nonsense", which `clampMaxTokens`
|
|
441
|
+
* cannot: both come back as `undefined` there, and treating a typo as AUTO would
|
|
442
|
+
* silently discard the number the user meant to set.
|
|
443
|
+
*/
|
|
444
|
+
export function parseMaxTokensInput(raw) {
|
|
445
|
+
const trimmed = String(raw ?? '').trim();
|
|
446
|
+
if (isAutoToken(trimmed))
|
|
447
|
+
return { kind: 'auto' };
|
|
448
|
+
if (!/^\d+$/.test(trimmed))
|
|
449
|
+
return { kind: 'invalid' };
|
|
450
|
+
const parsed = Number.parseInt(trimmed, 10);
|
|
451
|
+
if (!Number.isFinite(parsed) || parsed <= 0)
|
|
452
|
+
return { kind: 'invalid' };
|
|
453
|
+
const value = Math.min(MAX_TOKENS_RANGE.max, Math.max(MAX_TOKENS_RANGE.min, parsed));
|
|
454
|
+
return { kind: 'value', value, clamped: value !== parsed };
|
|
455
|
+
}
|
|
456
|
+
/** The product default, re-exported so the CLI never spells the number itself. */
|
|
457
|
+
export const DEFAULT_MAX_TOKENS = DEFAULT_MAX_OUTPUT_TOKENS;
|
|
304
458
|
/** Coerce a value into a positive integer with a fallback (for timeouts). */
|
|
305
459
|
export function coercePositiveInt(v, fallback) {
|
|
306
460
|
if (v === null || v === undefined || v === '')
|
package/dist/config/store.js
CHANGED
|
@@ -14,7 +14,23 @@ import { dirname } from 'node:path';
|
|
|
14
14
|
import process from 'node:process';
|
|
15
15
|
import { getConfigPath, getSessionsDir } from './app-paths.js';
|
|
16
16
|
import { registerSecretsFrom } from '../logging/secret-registry.js';
|
|
17
|
-
import { CONFIG_VERSION, DEFAULT_CONFIG, clampLogConfig, clampSkillsConfig, } from './schema.js';
|
|
17
|
+
import { CONFIG_VERSION, DEFAULT_CONFIG, clampLogConfig, clampMaxTokens, clampSkillsConfig, clampTeamConfig, isAutoToken, stripLegacyStateKeys, } from './schema.js';
|
|
18
|
+
/**
|
|
19
|
+
* Normalize `maxTokens` for both directions of the config file.
|
|
20
|
+
*
|
|
21
|
+
* `null` MEANS AUTO and must survive untouched; a number is clamped to the
|
|
22
|
+
* accepted range; anything else on a hand-edited file (a string, a float, a
|
|
23
|
+
* negative) becomes AUTO rather than a silent default. Same discipline as
|
|
24
|
+
* `clampSkillsConfig` — read AND write pass through one gate, or a bad value on
|
|
25
|
+
* disk reverts on every launch and presents as "my setting won't stick".
|
|
26
|
+
*/
|
|
27
|
+
function normalizeMaxTokens(v) {
|
|
28
|
+
if (v === undefined)
|
|
29
|
+
return DEFAULT_CONFIG.maxTokens;
|
|
30
|
+
if (v === null || isAutoToken(v))
|
|
31
|
+
return null;
|
|
32
|
+
return clampMaxTokens(v) ?? null;
|
|
33
|
+
}
|
|
18
34
|
// Re-exported so the many existing importers of `store.js` keep working; the
|
|
19
35
|
// definitions live in `app-paths.ts` because they are pure path arithmetic.
|
|
20
36
|
export { getConfigPath, getSessionsDir };
|
|
@@ -82,19 +98,30 @@ export function writeConfigFile(config) {
|
|
|
82
98
|
* Unlike `readConfigFile`, this always returns a complete `PersistedConfig`.
|
|
83
99
|
*/
|
|
84
100
|
export function loadPersistedConfig() {
|
|
85
|
-
|
|
101
|
+
// STRIP BEFORE THE SPREAD, not after. `partial` is the raw file, so any
|
|
102
|
+
// `promptHistory` / `submitCount` / `mouseNoticeSeen` / `recentModels` left
|
|
103
|
+
// on disk would otherwise ride the spread into `merged` and be written back
|
|
104
|
+
// out by `updatePersistedConfig` — the "I deleted it and it grew back" bug
|
|
105
|
+
// that `stripLegacyStateKeys` exists to make impossible (§4.5).
|
|
106
|
+
const partial = stripLegacyStateKeys(readConfigFile().config ?? {});
|
|
86
107
|
return {
|
|
87
108
|
...DEFAULT_CONFIG,
|
|
88
109
|
...partial,
|
|
89
110
|
version: CONFIG_VERSION,
|
|
111
|
+
// Own-property aware, because `null` is the value we need to preserve: a
|
|
112
|
+
// spread cannot tell "the key is absent" (⇒ the default) from "the key is
|
|
113
|
+
// null" (⇒ AUTO), and those are different settings.
|
|
114
|
+
maxTokens: Object.prototype.hasOwnProperty.call(partial, 'maxTokens')
|
|
115
|
+
? normalizeMaxTokens(partial.maxTokens)
|
|
116
|
+
: DEFAULT_CONFIG.maxTokens,
|
|
90
117
|
apiKeys: { ...DEFAULT_CONFIG.apiKeys, ...(partial.apiKeys ?? {}) },
|
|
91
|
-
recentModels: partial.recentModels ?? [],
|
|
92
|
-
promptHistory: partial.promptHistory ?? [],
|
|
93
118
|
// `skills` is the second nested object in this file and needs the same
|
|
94
119
|
// treatment `apiKeys` gets — see the note on updatePersistedConfig below.
|
|
95
120
|
skills: clampSkillsConfig({ ...DEFAULT_CONFIG.skills, ...(partial.skills ?? {}) }),
|
|
96
121
|
// `log` is the third. Same one-level rule, same merge.
|
|
97
122
|
log: clampLogConfig({ ...DEFAULT_CONFIG.log, ...(partial.log ?? {}) }),
|
|
123
|
+
// `team` is the fourth. Same one-level rule, same merge.
|
|
124
|
+
team: clampTeamConfig({ ...DEFAULT_CONFIG.team, ...(partial.team ?? {}) }),
|
|
98
125
|
};
|
|
99
126
|
}
|
|
100
127
|
/**
|
|
@@ -126,12 +153,21 @@ export function updatePersistedConfig(patch) {
|
|
|
126
153
|
...current,
|
|
127
154
|
...patch,
|
|
128
155
|
version: CONFIG_VERSION,
|
|
156
|
+
// The write half of the gate: without it `config set maxTokens 900000` puts
|
|
157
|
+
// a number on disk that the read path silently rewrites on every launch.
|
|
158
|
+
maxTokens: Object.prototype.hasOwnProperty.call(patch, 'maxTokens')
|
|
159
|
+
? normalizeMaxTokens(patch.maxTokens)
|
|
160
|
+
: current.maxTokens,
|
|
129
161
|
apiKeys: { ...current.apiKeys, ...(patch.apiKeys ?? {}) },
|
|
130
162
|
skills: clampSkillsConfig({ ...current.skills, ...(patch.skills ?? {}) }),
|
|
131
163
|
// Without this line, changing the log level from the settings screen would
|
|
132
164
|
// replace the whole section and quietly revert `maxFiles`, `previewChars`
|
|
133
165
|
// and — worst of all — `redactSecrets`.
|
|
134
166
|
log: clampLogConfig({ ...current.log, ...(patch.log ?? {}) }),
|
|
167
|
+
// The fourth section, and it needs the merge for the same reason: `/team max
|
|
168
|
+
// 4` sends `{ team: { maxSubagents: 4 } }`, which a shallow spread would
|
|
169
|
+
// turn into "changing the fan-out width silently reverted every timeout".
|
|
170
|
+
team: clampTeamConfig({ ...current.team, ...(patch.team ?? {}) }),
|
|
135
171
|
};
|
|
136
172
|
writeConfigFile(merged);
|
|
137
173
|
return merged;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<home>/state.json` — UI bookkeeping (config-state-separation §4.4).
|
|
3
|
+
*
|
|
4
|
+
* ══════════════════════════════════════════════════════════════════════════
|
|
5
|
+
* WHAT MAY LIVE HERE: UI bookkeeping SCALARS. Counters, and "has this one-shot
|
|
6
|
+
* notice been shown" flags. NOTHING ELSE.
|
|
7
|
+
*
|
|
8
|
+
* Anything carrying user content, and anything that grows with usage, opens its
|
|
9
|
+
* own file. This rule is not a preference — it is the entire reason this module
|
|
10
|
+
* exists. `config.json` became a place where a prompt-by-prompt transcript sat
|
|
11
|
+
* next to the API key precisely because nobody had written a rule like this
|
|
12
|
+
* down, and a file called `state.json` is the most inviting drawer in the
|
|
13
|
+
* codebase (Q-4 / RV-8).
|
|
14
|
+
* ══════════════════════════════════════════════════════════════════════════
|
|
15
|
+
*
|
|
16
|
+
* Nothing here is a preference: none of these fields has a flag, an env var or
|
|
17
|
+
* a `config set` key, and none of them should ever get one. Losing the file
|
|
18
|
+
* costs a composer hint reappearing once.
|
|
19
|
+
*
|
|
20
|
+
* FAILURE POLICY (C2): never throws, exactly like `prompt-history.ts` and
|
|
21
|
+
* `skills/usage.ts`. Writes are immediate rather than debounced (D-7) — the
|
|
22
|
+
* file is one small object, and a debounce would add a flush-on-exit obligation
|
|
23
|
+
* whose failure mode is the counter silently not sticking.
|
|
24
|
+
*
|
|
25
|
+
* NOT the same thing as `migrate-legacy-state.ts`, despite the shared word:
|
|
26
|
+
* that one is the 0.4.x legacy-to-current brand migration and has nothing to do
|
|
27
|
+
* with this file.
|
|
28
|
+
*/
|
|
29
|
+
export declare const UI_STATE_FILENAME = "state.json";
|
|
30
|
+
export declare const UI_STATE_SCHEMA = 1;
|
|
31
|
+
export interface UiState {
|
|
32
|
+
schema: number;
|
|
33
|
+
/** Lifetime submit count; drives the composer hint fade (`HINT_FADE_AFTER`). */
|
|
34
|
+
submitCount: number;
|
|
35
|
+
/** Whether the one-shot "hold Shift to select text" notice has been shown. */
|
|
36
|
+
mouseNoticeSeen: boolean;
|
|
37
|
+
}
|
|
38
|
+
/** Read the state, caching it for the life of the process. Never throws. */
|
|
39
|
+
export declare function loadUiState(): UiState;
|
|
40
|
+
export declare function readSubmitCount(): number;
|
|
41
|
+
/** Increment, persist, and return the new value. */
|
|
42
|
+
export declare function bumpSubmitCount(): number;
|
|
43
|
+
export declare function getMouseNoticeSeen(): boolean;
|
|
44
|
+
export declare function setMouseNoticeSeen(seen: boolean): void;
|
|
45
|
+
/**
|
|
46
|
+
* Migration-only: fill in fields that are still at their default, and never
|
|
47
|
+
* overwrite a value already here. A second import — after a half-finished
|
|
48
|
+
* migration retries — must not walk the counter backwards.
|
|
49
|
+
*/
|
|
50
|
+
export declare function importLegacyUiState(patch: Partial<Pick<UiState, 'submitCount' | 'mouseNoticeSeen'>>): void;
|
|
51
|
+
/** Test-only: forget the cached state. */
|
|
52
|
+
export declare function resetUiStateForTests(): void;
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<home>/state.json` — UI bookkeeping (config-state-separation §4.4).
|
|
3
|
+
*
|
|
4
|
+
* ══════════════════════════════════════════════════════════════════════════
|
|
5
|
+
* WHAT MAY LIVE HERE: UI bookkeeping SCALARS. Counters, and "has this one-shot
|
|
6
|
+
* notice been shown" flags. NOTHING ELSE.
|
|
7
|
+
*
|
|
8
|
+
* Anything carrying user content, and anything that grows with usage, opens its
|
|
9
|
+
* own file. This rule is not a preference — it is the entire reason this module
|
|
10
|
+
* exists. `config.json` became a place where a prompt-by-prompt transcript sat
|
|
11
|
+
* next to the API key precisely because nobody had written a rule like this
|
|
12
|
+
* down, and a file called `state.json` is the most inviting drawer in the
|
|
13
|
+
* codebase (Q-4 / RV-8).
|
|
14
|
+
* ══════════════════════════════════════════════════════════════════════════
|
|
15
|
+
*
|
|
16
|
+
* Nothing here is a preference: none of these fields has a flag, an env var or
|
|
17
|
+
* a `config set` key, and none of them should ever get one. Losing the file
|
|
18
|
+
* costs a composer hint reappearing once.
|
|
19
|
+
*
|
|
20
|
+
* FAILURE POLICY (C2): never throws, exactly like `prompt-history.ts` and
|
|
21
|
+
* `skills/usage.ts`. Writes are immediate rather than debounced (D-7) — the
|
|
22
|
+
* file is one small object, and a debounce would add a flush-on-exit obligation
|
|
23
|
+
* whose failure mode is the counter silently not sticking.
|
|
24
|
+
*
|
|
25
|
+
* NOT the same thing as `migrate-legacy-state.ts`, despite the shared word:
|
|
26
|
+
* that one is the 0.4.x legacy-to-current brand migration and has nothing to do
|
|
27
|
+
* with this file.
|
|
28
|
+
*/
|
|
29
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync, } from 'node:fs';
|
|
30
|
+
import process from 'node:process';
|
|
31
|
+
import { getHomeRoot, getUiStatePath } from './app-paths.js';
|
|
32
|
+
export const UI_STATE_FILENAME = 'state.json';
|
|
33
|
+
export const UI_STATE_SCHEMA = 1;
|
|
34
|
+
const DEFAULT_UI_STATE = {
|
|
35
|
+
schema: UI_STATE_SCHEMA,
|
|
36
|
+
submitCount: 0,
|
|
37
|
+
mouseNoticeSeen: false,
|
|
38
|
+
};
|
|
39
|
+
let memory = null;
|
|
40
|
+
function coerceCount(value) {
|
|
41
|
+
const n = typeof value === 'number' ? value : Number.parseInt(String(value ?? ''), 10);
|
|
42
|
+
if (!Number.isFinite(n) || n < 0)
|
|
43
|
+
return DEFAULT_UI_STATE.submitCount;
|
|
44
|
+
return Math.floor(n);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Per-field tolerance: one nonsense value must not take the other field with
|
|
48
|
+
* it. A wrong SCHEMA is different — the shape is no longer known, so the whole
|
|
49
|
+
* object falls back (the rule `skills/usage.ts` already follows).
|
|
50
|
+
*/
|
|
51
|
+
function readFromDisk() {
|
|
52
|
+
const path = getUiStatePath();
|
|
53
|
+
if (!existsSync(path))
|
|
54
|
+
return { ...DEFAULT_UI_STATE };
|
|
55
|
+
try {
|
|
56
|
+
const parsed = JSON.parse(readFileSync(path, 'utf-8'));
|
|
57
|
+
if (!parsed || typeof parsed !== 'object')
|
|
58
|
+
return { ...DEFAULT_UI_STATE };
|
|
59
|
+
if (parsed.schema !== UI_STATE_SCHEMA)
|
|
60
|
+
return { ...DEFAULT_UI_STATE };
|
|
61
|
+
return {
|
|
62
|
+
schema: UI_STATE_SCHEMA,
|
|
63
|
+
submitCount: coerceCount(parsed.submitCount),
|
|
64
|
+
mouseNoticeSeen: typeof parsed.mouseNoticeSeen === 'boolean'
|
|
65
|
+
? parsed.mouseNoticeSeen
|
|
66
|
+
: DEFAULT_UI_STATE.mouseNoticeSeen,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return { ...DEFAULT_UI_STATE };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/** Read the state, caching it for the life of the process. Never throws. */
|
|
74
|
+
export function loadUiState() {
|
|
75
|
+
if (memory === null)
|
|
76
|
+
memory = readFromDisk();
|
|
77
|
+
return memory;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Atomic write: temp file, rename, re-chmod.
|
|
81
|
+
*
|
|
82
|
+
* `mkdirSync` first for the reason spelled out in `prompt-history.ts`: `<home>`
|
|
83
|
+
* is not guaranteed to exist, and without it the counter would silently never
|
|
84
|
+
* persist on a fresh install that also turned file logging off (RV-2).
|
|
85
|
+
* The `chmodSync` matches `store.ts` — rename does not preserve mode on every
|
|
86
|
+
* platform (RV-6).
|
|
87
|
+
*/
|
|
88
|
+
function writeToDisk(state) {
|
|
89
|
+
const path = getUiStatePath();
|
|
90
|
+
const tmp = `${path}.tmp-${process.pid}`;
|
|
91
|
+
try {
|
|
92
|
+
mkdirSync(getHomeRoot(), { recursive: true });
|
|
93
|
+
writeFileSync(tmp, `${JSON.stringify(state, null, 2)}\n`, { encoding: 'utf-8', mode: 0o600 });
|
|
94
|
+
renameSync(tmp, path);
|
|
95
|
+
if (process.platform !== 'win32')
|
|
96
|
+
chmodSync(path, 0o600);
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
try {
|
|
100
|
+
rmSync(tmp, { force: true });
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
// Nothing further to try; the value simply stays in memory (C2).
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
function update(patch) {
|
|
108
|
+
const next = { ...loadUiState(), ...patch, schema: UI_STATE_SCHEMA };
|
|
109
|
+
memory = next;
|
|
110
|
+
writeToDisk(next);
|
|
111
|
+
return next;
|
|
112
|
+
}
|
|
113
|
+
export function readSubmitCount() {
|
|
114
|
+
return loadUiState().submitCount;
|
|
115
|
+
}
|
|
116
|
+
/** Increment, persist, and return the new value. */
|
|
117
|
+
export function bumpSubmitCount() {
|
|
118
|
+
return update({ submitCount: readSubmitCount() + 1 }).submitCount;
|
|
119
|
+
}
|
|
120
|
+
export function getMouseNoticeSeen() {
|
|
121
|
+
return loadUiState().mouseNoticeSeen;
|
|
122
|
+
}
|
|
123
|
+
export function setMouseNoticeSeen(seen) {
|
|
124
|
+
update({ mouseNoticeSeen: seen });
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Migration-only: fill in fields that are still at their default, and never
|
|
128
|
+
* overwrite a value already here. A second import — after a half-finished
|
|
129
|
+
* migration retries — must not walk the counter backwards.
|
|
130
|
+
*/
|
|
131
|
+
export function importLegacyUiState(patch) {
|
|
132
|
+
const current = loadUiState();
|
|
133
|
+
const next = {};
|
|
134
|
+
if (patch.submitCount !== undefined &&
|
|
135
|
+
current.submitCount === DEFAULT_UI_STATE.submitCount) {
|
|
136
|
+
next.submitCount = coerceCount(patch.submitCount);
|
|
137
|
+
}
|
|
138
|
+
if (patch.mouseNoticeSeen !== undefined &&
|
|
139
|
+
current.mouseNoticeSeen === DEFAULT_UI_STATE.mouseNoticeSeen) {
|
|
140
|
+
next.mouseNoticeSeen = patch.mouseNoticeSeen === true;
|
|
141
|
+
}
|
|
142
|
+
if (Object.keys(next).length === 0)
|
|
143
|
+
return;
|
|
144
|
+
update(next);
|
|
145
|
+
}
|
|
146
|
+
/** Test-only: forget the cached state. */
|
|
147
|
+
export function resetUiStateForTests() {
|
|
148
|
+
memory = null;
|
|
149
|
+
}
|
|
@@ -161,3 +161,88 @@ export type AgentLogEvent = {
|
|
|
161
161
|
* (or a future second controller) read another's clock.
|
|
162
162
|
*/
|
|
163
163
|
export declare function attachAgentEvents(logger: Logger, controller: AgentEventSource): () => void;
|
|
164
|
+
/**
|
|
165
|
+
* What `attachTeamEvents` needs from a controller. Structural for the same
|
|
166
|
+
* reason `AgentEventSource` is, and OPTIONAL on the method: a controller built
|
|
167
|
+
* with team mode off has no runtime and therefore no events.
|
|
168
|
+
*/
|
|
169
|
+
export interface TeamEventSource {
|
|
170
|
+
subscribeTeam?(listener: (event: TeamLogEvent) => void): () => void;
|
|
171
|
+
}
|
|
172
|
+
/** The five members of the CLI-local `TeamEvent` union, as this module reads them. */
|
|
173
|
+
export type TeamLogEvent = {
|
|
174
|
+
type: 'dispatch_start';
|
|
175
|
+
dispatchId: string;
|
|
176
|
+
requested: number;
|
|
177
|
+
specs?: Array<{
|
|
178
|
+
label?: string;
|
|
179
|
+
description?: string;
|
|
180
|
+
prompt?: string;
|
|
181
|
+
}>;
|
|
182
|
+
} | {
|
|
183
|
+
type: 'agent_update';
|
|
184
|
+
dispatchId: string;
|
|
185
|
+
run?: {
|
|
186
|
+
label?: string;
|
|
187
|
+
phase?: string;
|
|
188
|
+
lastTool?: string;
|
|
189
|
+
};
|
|
190
|
+
} | {
|
|
191
|
+
type: 'usage';
|
|
192
|
+
dispatchId: string;
|
|
193
|
+
label?: string;
|
|
194
|
+
usage?: unknown;
|
|
195
|
+
} | {
|
|
196
|
+
type: 'message';
|
|
197
|
+
dispatchId: string;
|
|
198
|
+
message?: {
|
|
199
|
+
from?: string;
|
|
200
|
+
to?: string;
|
|
201
|
+
subject?: string;
|
|
202
|
+
body?: string;
|
|
203
|
+
};
|
|
204
|
+
} | {
|
|
205
|
+
type: 'dispatch_end';
|
|
206
|
+
dispatchId: string;
|
|
207
|
+
outcome?: {
|
|
208
|
+
startedAt?: number;
|
|
209
|
+
endedAt?: number;
|
|
210
|
+
aborted?: boolean;
|
|
211
|
+
runs?: Array<{
|
|
212
|
+
phase?: string;
|
|
213
|
+
error?: string;
|
|
214
|
+
filesTouched?: string[];
|
|
215
|
+
}>;
|
|
216
|
+
usage?: {
|
|
217
|
+
inputTokens?: number;
|
|
218
|
+
outputTokens?: number;
|
|
219
|
+
};
|
|
220
|
+
};
|
|
221
|
+
};
|
|
222
|
+
/**
|
|
223
|
+
* Record a dispatch's lifecycle.
|
|
224
|
+
*
|
|
225
|
+
* WITHOUT THIS, A DISPATCH LEAVES NO TRACE IN THE LOG AT ALL (P1-2 / R-19).
|
|
226
|
+
* `TeamEvent` is deliberately CLI-local (D-10), so `attachAgentEvents` cannot
|
|
227
|
+
* see it — which is the wrong outcome for the one feature in this CLI that runs
|
|
228
|
+
* five agents the user cannot watch, and whose only other artefact is a report
|
|
229
|
+
* written by the thing that failed. A separate function rather than a branch in
|
|
230
|
+
* `attachAgentEvents` (D-19): the two consume different unions and want
|
|
231
|
+
* different level policies.
|
|
232
|
+
*
|
|
233
|
+
* TWO RULES, BOTH ABOUT NOT TURNING A LOG INTO A LIABILITY:
|
|
234
|
+
*
|
|
235
|
+
* - A subagent's PROMPT and its SUMMARY are `trace` only, and go through the
|
|
236
|
+
* same redaction every other record does. A `prompt` is up to 8 000
|
|
237
|
+
* characters of whatever the lead decided to say; at `debug` it would
|
|
238
|
+
* dominate the file.
|
|
239
|
+
* - A `team_send` BODY is never recorded at any level. The subject is enough to
|
|
240
|
+
* reconstruct who talked to whom and when, which is the diagnostic question;
|
|
241
|
+
* the body is user content that reached no other sink.
|
|
242
|
+
*
|
|
243
|
+
* `agent_update` is recorded on PHASE TRANSITIONS ONLY, not on the 120 ms
|
|
244
|
+
* coalescer tick: that throttle exists to protect React, and five children at
|
|
245
|
+
* eight events a second each is 40 lines a second of noise that would push the
|
|
246
|
+
* retention window down to minutes.
|
|
247
|
+
*/
|
|
248
|
+
export declare function attachTeamEvents(logger: Logger, controller: TeamEventSource): () => void;
|