atom-agent 1.3.0 → 1.5.0

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 (71) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.md +220 -224
  3. package/dist/App.js +922 -341
  4. package/dist/adapters.js +127 -14
  5. package/dist/agent/goal-evaluator.js +3 -0
  6. package/dist/agent/loop.js +211 -430
  7. package/dist/agent/tool-pipeline.js +398 -0
  8. package/dist/agent/turn-events.js +12 -0
  9. package/dist/cli.js +57 -8
  10. package/dist/compact.js +72 -8
  11. package/dist/config.js +19 -0
  12. package/dist/context-manager.js +6 -2
  13. package/dist/extensions.js +6 -0
  14. package/dist/file-diffs.js +108 -0
  15. package/dist/kilo.js +1 -1
  16. package/dist/local-discovery.js +2 -2
  17. package/dist/media.js +276 -0
  18. package/dist/overflow.js +140 -0
  19. package/dist/policy.js +8 -0
  20. package/dist/scheduler.js +38 -9
  21. package/dist/session-revert.js +125 -0
  22. package/dist/sessions.js +101 -0
  23. package/dist/snapshots.js +69 -0
  24. package/dist/system.js +2 -89
  25. package/dist/telemetry.js +26 -1
  26. package/dist/todos.js +241 -0
  27. package/dist/tools/filesystem.js +102 -22
  28. package/dist/tools/registry.js +184 -45
  29. package/dist/tools/ripgrep.js +7 -6
  30. package/dist/tools/search.js +172 -17
  31. package/dist/tools/shared.js +6 -0
  32. package/dist/tools.js +7 -39
  33. package/dist/ui/diff-panel.js +5 -5
  34. package/dist/ui/diff-view.js +16 -7
  35. package/dist/ui/diff.js +73 -51
  36. package/dist/ui/errors.js +20 -6
  37. package/dist/ui/input.js +24 -20
  38. package/dist/ui/live-tail.js +36 -1
  39. package/dist/ui/markdown.js +9 -4
  40. package/dist/ui/modals.js +6 -4
  41. package/dist/ui/paint-scheduler.js +120 -0
  42. package/dist/ui/palette.js +4 -2
  43. package/dist/ui/pickers.js +4 -1
  44. package/dist/ui/side-by-side.js +88 -27
  45. package/dist/ui/status-bar.js +63 -8
  46. package/dist/ui/stream-store.js +7 -0
  47. package/dist/ui/theme.js +23 -1
  48. package/dist/ui/todo-panel.js +5 -2
  49. package/dist/ui/tool-inspector.js +33 -4
  50. package/dist/ui/transcript.js +9 -6
  51. package/dist/web/events.js +93 -0
  52. package/dist/web/runtime.js +790 -0
  53. package/dist/web/server.js +570 -0
  54. package/dist/web/ui/app.js +1925 -0
  55. package/dist/web/ui/index.html +135 -0
  56. package/dist/web/ui/styles.css +515 -0
  57. package/dist/zen.js +115 -4
  58. package/documentation/cli.md +5 -5
  59. package/documentation/configuration.md +11 -6
  60. package/documentation/development.md +4 -3
  61. package/documentation/extensions.md +1 -1
  62. package/documentation/goals.md +1 -1
  63. package/documentation/index.md +4 -4
  64. package/documentation/providers.md +2 -3
  65. package/documentation/skills.md +3 -3
  66. package/documentation/tools.md +8 -3
  67. package/documentation/troubleshooting.md +1 -1
  68. package/examples/extensions/01-audit-gate.js +2 -2
  69. package/examples/extensions/02-notes-tool.js +2 -2
  70. package/examples/extensions/03-custom-command.js +2 -2
  71. package/package.json +3 -2
package/dist/zen.js CHANGED
@@ -5,7 +5,7 @@
5
5
  // different Zen request shapes and are out of scope.
6
6
  import { existsSync, readFileSync } from "node:fs";
7
7
  import * as path from "node:path";
8
- import { MAX_TOOL_STEPS, allToolDefinitions, getExtensionPromptHints, } from "./tools.js";
8
+ import { MAX_TOOL_STEPS, allToolDefinitions, chatToolDefinitions, getExtensionPromptHints, } from "./tools.js";
9
9
  import { chatEndpointFor, getProvider, isLocalProviderId, modelsUrlForProvider, providerLabel, } from "./providers.js";
10
10
  import { discoverLocalProvider } from "./local-discovery.js";
11
11
  import { ANTHROPIC_MAX_TOKENS, ANTHROPIC_VERSION, anthropicHeaders, anthropicThinkingFor, buildAnthropicBody, buildGeminiBody, geminiChatUrl, geminiGenerateUrl, geminiHeaders, geminiThinkingLevelFor, isEffortRejection, parseAnthropicJson, parseAnthropicModelsList, parseGeminiJson, parseGeminiModelsList, parseOpenAIModelsList, readAnthropicSSEMessage, readGeminiSSEMessage, isStallError, readWithStall, sseStallTimeoutMs, } from "./adapters.js";
@@ -86,6 +86,7 @@ export function reasoningEffortParam(effort, _model) {
86
86
  return undefined;
87
87
  return normalized;
88
88
  }
89
+ import { historyHasMedia, isImageRejection, lowerOpenAIContent, } from "./media.js";
89
90
  export { CHARS_PER_TOKEN, createContextManager, estimateTokensForChars, historyChars, messageChars, } from "./context-manager.js";
90
91
  export { openTodoNeedles } from "./agent/gates.js";
91
92
  function finiteCount(value) {
@@ -782,6 +783,9 @@ opts, errorLabel = "Zen") {
782
783
  // Server-authoritative unsupported: when a 400 names the effort knob, the
783
784
  // flag below drops it and the loop retries without it (once per call).
784
785
  let effortDropped = false;
786
+ // Same contract for vision input (see src/media.ts): a 400 naming image
787
+ // input retries once with descriptors stripped to prose markers.
788
+ let mediaStripped = false;
785
789
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
786
790
  try {
787
791
  throwIfCancelled(signal);
@@ -795,13 +799,27 @@ opts, errorLabel = "Zen") {
795
799
  ? undefined
796
800
  : reasoningEffortParam(opts?.reasoningEffort, model);
797
801
  const summaryOpts = opts;
802
+ const mediaMode = opts?.stripMedia === true || mediaStripped
803
+ ? "strip"
804
+ : "send";
798
805
  // Stable-prefix split (prompt-cache architecture): history[0]'s env
799
806
  // tail becomes its own system message so the stable head + tools stay
800
807
  // byte-identical across POSTs for implicit prefix caching. Consecutive
801
808
  // system messages concatenate on every OpenAI-protocol server, so this
802
809
  // is content-neutral. No env tail (tests, old saves) → history passes
803
810
  // through untouched, byte-identical to before.
804
- const messages = splitSystemHead(outgoingHistory);
811
+ // Media lowering runs after the split: histories without descriptors
812
+ // lower byte-identically (lowerOpenAIContent returns the string as-is).
813
+ const messages = splitSystemHead(outgoingHistory).map((m) => {
814
+ const c = m.content;
815
+ if (typeof c !== "string")
816
+ return m;
817
+ const lowered = lowerOpenAIContent(m.role, c, mediaMode);
818
+ // Identity means untouched (no descriptors): keep the original ref
819
+ // so media-free payloads stay byte-identical. Anything else
820
+ // (stripped string or parts array) replaces the content.
821
+ return lowered === c ? m : { ...m, content: lowered };
822
+ });
805
823
  const payload = {
806
824
  model,
807
825
  messages,
@@ -810,9 +828,15 @@ opts, errorLabel = "Zen") {
810
828
  // Compaction path only: tools disabled means NO `tools` key at all
811
829
  // (asserted in tests); the normal loop always sends the schema —
812
830
  // builtins plus extension-registered custom tools, so the model can
813
- // discover and call them exactly like builtins.
831
+ // discover and call them exactly like builtins. update_goal rides
832
+ // along only for live goal turns (includeUpdateGoal, set per POST by
833
+ // the runAgenticLoop* entry points) — otherwise the model cannot
834
+ // misuse what it cannot see.
814
835
  if (!summaryOpts?.disableTools) {
815
- payload["tools"] = allToolDefinitions();
836
+ payload["tools"] =
837
+ opts?.includeUpdateGoal === false
838
+ ? chatToolDefinitions(false)
839
+ : allToolDefinitions();
816
840
  }
817
841
  // Compaction path only: cap output (openai-chat kind uses max_tokens).
818
842
  if (typeof summaryOpts?.maxOutputTokens === "number" &&
@@ -871,6 +895,25 @@ opts, errorLabel = "Zen") {
871
895
  }
872
896
  if (!res.ok) {
873
897
  const errText = await safeErrorText(res);
898
+ // The server is the authority on vision support: a 400 naming
899
+ // image input means this model/deployment takes no images — warn,
900
+ // strip descriptors to markers, and retry without them (once per
901
+ // call). Checked before effort so a joint rejection still strips.
902
+ if (res.status === 400 &&
903
+ !mediaStripped &&
904
+ opts?.stripMedia !== true &&
905
+ historyHasMedia(outgoingHistory) &&
906
+ isImageRejection(errText)) {
907
+ mediaStripped = true;
908
+ try {
909
+ opts?.onWarning?.(`image input is not supported by ${model} — continuing without images`);
910
+ }
911
+ catch {
912
+ // ignore observer errors
913
+ }
914
+ throwIfCancelled(signal);
915
+ continue;
916
+ }
874
917
  // The server is the authority on effort support: a 400 naming the
875
918
  // knob means this model/deployment has no such control — warn,
876
919
  // drop the knob, and retry without it (setting kept). Any other
@@ -1007,6 +1050,20 @@ opts, errorLabel = "Zen") {
1007
1050
  // on display). A length-truncated response (`finish_reason: "length"`) does
1008
1051
  // not throw: the loop fails each carried tool call inline with a repair
1009
1052
  // error and continues to the next model round.
1053
+ // Per-POST goal-tool visibility: update_goal rides the schema only while a
1054
+ // live goal turn is engaged (guarded — a throwing accessor reads as no
1055
+ // goal, exactly like the loop's readLiveGoal). Evaluated per POST so a goal
1056
+ // set, paused, or cleared mid-turn reshapes the very next schema; callers
1057
+ // without a goal hook (compaction, web, tests) read as no-goal and send the
1058
+ // legacy full surface only when they leave includeUpdateGoal undefined.
1059
+ function isGoalTurnLive(opts) {
1060
+ try {
1061
+ return opts?.goal?.getGoal?.()?.active === true;
1062
+ }
1063
+ catch {
1064
+ return false;
1065
+ }
1066
+ }
1010
1067
  export async function runAgenticLoop(endpoint, apiKey, model, history, opts) {
1011
1068
  return runLoopWithChat((h, o) => chatCompletion(endpoint, apiKey, model, h, {
1012
1069
  onToken: o?.onToken,
@@ -1017,6 +1074,7 @@ export async function runAgenticLoop(endpoint, apiKey, model, history, opts) {
1017
1074
  sleep: o?.sleep,
1018
1075
  reasoningEffort: o?.reasoningEffort,
1019
1076
  signal: o?.signal,
1077
+ includeUpdateGoal: isGoalTurnLive(opts),
1020
1078
  }), history, opts);
1021
1079
  }
1022
1080
  // AGENTS.md loading: <cwd>/AGENTS.md (or $OPENCODE_AGENTS_PATH when set)
@@ -1068,6 +1126,9 @@ export async function chatCompletionAnthropic(apiKey, model, history, opts) {
1068
1126
  // Same server-authoritative unsupported contract as the openai-chat path:
1069
1127
  // a 400 naming the thinking knob drops it for the rest of the call.
1070
1128
  let anthropicEffortDropped = false;
1129
+ // Vision fallback (see src/media.ts): a 400 naming image input retries
1130
+ // once with descriptors stripped to markers.
1131
+ let anthropicMediaStripped = false;
1071
1132
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
1072
1133
  try {
1073
1134
  throwIfCancelled(signal);
@@ -1080,6 +1141,8 @@ export async function chatCompletionAnthropic(apiKey, model, history, opts) {
1080
1141
  const summaryOpts = opts;
1081
1142
  const base = buildAnthropicBody(outgoingHistory, model, {
1082
1143
  includeTools: !summaryOpts?.disableTools,
1144
+ includeUpdateGoal: opts?.includeUpdateGoal !== false,
1145
+ stripMedia: opts?.stripMedia === true || anthropicMediaStripped,
1083
1146
  });
1084
1147
  const body = { ...base, stream: true };
1085
1148
  // Compaction cap (anthropic kind uses max_tokens; default is already
@@ -1131,6 +1194,23 @@ export async function chatCompletionAnthropic(apiKey, model, history, opts) {
1131
1194
  }
1132
1195
  if (!res.ok) {
1133
1196
  const errText = await safeErrorText(res);
1197
+ // Vision fallback (see src/media.ts): a 400 naming image input
1198
+ // retries once with descriptors stripped to markers.
1199
+ if (res.status === 400 &&
1200
+ !anthropicMediaStripped &&
1201
+ opts?.stripMedia !== true &&
1202
+ historyHasMedia(outgoingHistory) &&
1203
+ isImageRejection(errText)) {
1204
+ anthropicMediaStripped = true;
1205
+ try {
1206
+ opts?.onWarning?.(`image input is not supported by ${model} — continuing without images`);
1207
+ }
1208
+ catch {
1209
+ // ignore observer errors
1210
+ }
1211
+ throwIfCancelled(signal);
1212
+ continue;
1213
+ }
1134
1214
  if (res.status === 400 &&
1135
1215
  anthropicBudget !== undefined &&
1136
1216
  !anthropicEffortDropped &&
@@ -1215,6 +1295,9 @@ export async function chatCompletionGemini(apiKey, model, history, opts) {
1215
1295
  // Same server-authoritative unsupported contract as the other paths: a
1216
1296
  // 400 naming the thinking knob drops it for the rest of the call.
1217
1297
  let geminiEffortDropped = false;
1298
+ // Vision fallback (see src/media.ts): a 400 naming image input retries
1299
+ // once with descriptors stripped to markers.
1300
+ let geminiMediaStripped = false;
1218
1301
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
1219
1302
  try {
1220
1303
  throwIfCancelled(signal);
@@ -1227,11 +1310,13 @@ export async function chatCompletionGemini(apiKey, model, history, opts) {
1227
1310
  const summaryOpts = opts;
1228
1311
  const body = buildGeminiBody(outgoingHistory, model, {
1229
1312
  includeTools: !summaryOpts?.disableTools,
1313
+ includeUpdateGoal: opts?.includeUpdateGoal !== false,
1230
1314
  ...(typeof summaryOpts?.maxOutputTokens === "number" &&
1231
1315
  Number.isFinite(summaryOpts.maxOutputTokens) &&
1232
1316
  summaryOpts.maxOutputTokens > 0
1233
1317
  ? { maxOutputTokens: Math.floor(summaryOpts.maxOutputTokens) }
1234
1318
  : {}),
1319
+ stripMedia: opts?.stripMedia === true || geminiMediaStripped,
1235
1320
  });
1236
1321
  // /effort maps to the native thinkingLevel (Auto omits it; Max rides
1237
1322
  // high, the deepest level the API offers). Merged into
@@ -1278,6 +1363,23 @@ export async function chatCompletionGemini(apiKey, model, history, opts) {
1278
1363
  }
1279
1364
  if (!res.ok) {
1280
1365
  const errText = await safeErrorText(res);
1366
+ // Vision fallback (see src/media.ts): a 400 naming image input
1367
+ // retries once with descriptors stripped to markers.
1368
+ if (res.status === 400 &&
1369
+ !geminiMediaStripped &&
1370
+ opts?.stripMedia !== true &&
1371
+ historyHasMedia(outgoingHistory) &&
1372
+ isImageRejection(errText)) {
1373
+ geminiMediaStripped = true;
1374
+ try {
1375
+ opts?.onWarning?.(`image input is not supported by ${model} — continuing without images`);
1376
+ }
1377
+ catch {
1378
+ // ignore observer errors
1379
+ }
1380
+ throwIfCancelled(signal);
1381
+ continue;
1382
+ }
1281
1383
  if (res.status === 400 &&
1282
1384
  geminiLevel !== undefined &&
1283
1385
  !geminiEffortDropped &&
@@ -1430,9 +1532,17 @@ export async function chatCompletionForProvider(provider, apiKey, model, history
1430
1532
  ...effortOpts,
1431
1533
  // Compaction path only (undefined for the normal loop → tools sent).
1432
1534
  ...(opts?.disableTools !== undefined ? { disableTools: opts.disableTools } : {}),
1535
+ // Goal-tool visibility (undefined for compaction/summary callers →
1536
+ // legacy full surface; the loop entry points always set it per POST).
1537
+ ...(opts?.includeUpdateGoal !== undefined
1538
+ ? { includeUpdateGoal: opts.includeUpdateGoal }
1539
+ : {}),
1433
1540
  ...(opts?.maxOutputTokens !== undefined
1434
1541
  ? { maxOutputTokens: opts.maxOutputTokens }
1435
1542
  : {}),
1543
+ // Media strip (compaction/summarization callers set it; the normal
1544
+ // loop leaves it undefined → images expand natively).
1545
+ ...(opts?.stripMedia !== undefined ? { stripMedia: opts.stripMedia } : {}),
1436
1546
  };
1437
1547
  // Kilo rides the shared OpenAI-chat path (streaming, tool reconstruction,
1438
1548
  // retry, effort with server-rejection fallback) with its registry
@@ -1486,6 +1596,7 @@ export async function runAgenticLoopForProvider(provider, apiKey, model, history
1486
1596
  reasoningEffort: o?.reasoningEffort,
1487
1597
  baseURL: opts?.baseURL,
1488
1598
  endpointOverride: opts?.endpointOverride,
1599
+ includeUpdateGoal: isGoalTurnLive(opts),
1489
1600
  }), history, opts);
1490
1601
  }
1491
1602
  // Per-provider model list: live list per kind with curated fallback on ANY
@@ -10,9 +10,11 @@ atom # run the installed binary (runs dist/cli.js)
10
10
  atom --help # usage, env vars, commands, providers (exits, no TUI)
11
11
  atom --dashboard # write ~/.atom/telemetry/dashboard.html and exit (no TUI)
12
12
  atom --serve [--port <n>] # serve the live observability webUI on loopback (no TUI, Ctrl+C stops)
13
+ atom --web [--port <n>] # serve the local agentic Web UI on loopback (no TUI, Ctrl+C stops)
14
+ atom --no-extensions # boot with zero third-party extensions (alias: --lockdown)
13
15
  ```
14
16
 
15
- `--help` (or `-h`) prints usage and exits. `--dashboard` and `--serve` handle local observability without starting the TUI (see [Observability](observability.md)). Any other invocation starts the TUI, even without a key.
17
+ `--help` (or `-h`) prints usage and exits. `--dashboard` and `--serve` handle local observability without starting the TUI (see [Observability](observability.md)). `--web` starts the agentic Web UI over the same runtime as the TUI (loopback-only; JSON API at `/api/health`, `/api/providers`, `/api/sessions`). Extension flags (`--no-extensions` / `--lockdown`, repeatable `--enable-extension <glob>` / `--disable-extension <glob>`) control third-party extension loading and win over `atom.json` (see [Extensions](extensions.md) and [Configuration](configuration.md)). Any other invocation starts the TUI, even without a key.
16
18
 
17
19
  ## Slash commands
18
20
 
@@ -20,16 +22,14 @@ Type `/` to autocomplete as you type. Full registry (`src/App.tsx`):
20
22
 
21
23
  | Command | What it does |
22
24
  |---|---|
23
- | `/model` | Unified model picker: active provider first, then other keyed providers plus the always-visible keyless Kilo list (free models badged `(free)`, `free` filters them). Cross-provider pick switches provider |
24
- | `/models [refresh]` | Local discovery status; `refresh` re-probes local servers (or the Kilo gateway catalog while Kilo is active) |
25
+ | `/model [filter\|refresh]` | Unified model picker: active provider first, then other keyed providers plus the always-visible keyless Kilo list (free models badged `(free)`, `free` filters them). Cross-provider pick switches provider. `refresh` re-probes local servers (or the Kilo gateway catalog while Kilo is active); plain text pre-filters the picker |
25
26
  | `/provider` | Provider plus key picker; validates and stores in `~/.atom/auth.json` (Kilo key optional — empty Enter continues anonymously) |
26
27
  | `/new` | Start a brand-new session (conversation plus counters reset, previous kept for `/resume`) |
27
28
  | `/rename <name>` | Rename the current session (id and history untouched; quotes optional) |
28
29
  | `/plan`, `/yolo` | Retired as typed commands — `Tab` is the only mode switcher (normal → yolo → plan → normal); typing them explains this instead of switching |
29
30
  | `/effort` | Reasoning-effort picker (`Auto`/`Low`/`Medium`/`High`/`Max`; sent for every model on every provider — `reasoning_effort` on OpenAI-chat, thinking budget on Anthropic, thinking level on Gemini; `Auto` omits it) |
30
31
  | `/tools` | List tools with one-line descriptions |
31
- | `/skills` | List installed skills (project plus global) |
32
- | `/skill` | Invoke a skill by name (`/skill:name`; skills also complete in the `/` menu) |
32
+ | `/skill [name]` | Skill picker (list, filter, invoke); `/skill:name` invokes directly (skills also complete in the `/` menu) |
33
33
  | `/context` | Show context usage by source (system, tools, history, skills, config, prefix-cache) |
34
34
  | `/queue` | List queued follow-ups (`/queue clear` wipes; cap 10, in-memory only) |
35
35
  | `/steer` | Steer the running turn, or send when idle (`/steer <text>`) |
@@ -24,9 +24,12 @@ Template lives in `.env.example`. Never commit a real key.
24
24
  | `ATOM_TELEMETRY` | Local observability recording (`0`/`false`/`no`/`off` disables; `1`/`true`/`yes`/`on` forces on) | on (wins over `atom.json`) |
25
25
  | `ATOM_TELEMETRY_PORT` | Pinned port for the observability webUI (`atom --serve`; `--port` wins over this) | ephemeral (OS-assigned, printed on start) |
26
26
  | `ATOM_EXTENSIONS` | Extra extension directory for discovery (project, global, then this; see [Extensions](extensions.md)) | none |
27
- | `ATOM_OLLAMA_URL` | Ollama base URL override for local discovery | `http://localhost:11434` |
28
- | `ATOM_LMSTUDIO_URL` | LM Studio base URL override for local discovery | `http://localhost:1234` |
29
- | `ATOM_LLAMACPP_URL` | llama.cpp base URL override for local discovery | `http://localhost:8080` |
27
+ | `ATOM_OLLAMA_URL` | Ollama base URL override for local discovery | `http://127.0.0.1:11434` |
28
+ | `ATOM_LMSTUDIO_URL` | LM Studio base URL override for local discovery | `http://127.0.0.1:1234` |
29
+ | `ATOM_LLAMACPP_URL` | llama.cpp base URL override for local discovery | `http://127.0.0.1:8080` |
30
+ | `ATOM_STALL_TIMEOUT_MS` | Silent-stream stall guard for streaming readers | `60000` (60s; fails fast instead of hanging a silent 200-OK stream) |
31
+ | `ATOM_FAST_LIST` | File-listing fast path (`0` forces the legacy walker) | fast path on |
32
+ | `ATOM_INCREMENTAL` | Incremental Ink rendering (`0` restores full-frame rendering) | incremental on |
30
33
 
31
34
  `openai-compatible` uses stored key plus baseURL only. No env vars.
32
35
 
@@ -44,8 +47,10 @@ Precedence overall: env vars > saved session picks (`/model`, `/provider`, `/eff
44
47
  | `provider` | First-run default provider (needs its key, except keyless Kilo/local) | known provider id |
45
48
  | `model` | Default model id | non-empty string |
46
49
  | `reasoningEffort` | Default reasoning effort | `auto`/`low`/`medium`/`high`/`max` (`default` still accepted as an alias for `auto`) |
47
- | `maxToolSteps` | Tool rounds per turn | 5-100 (default 30) |
50
+ | `maxToolSteps` | Optional cap on tool rounds per turn (`ATOM_MAX_TOOL_STEPS` wins over this) | 5-100 (default uncapped; the shipped `atom.example.json` sets `30` as a starting point) |
48
51
  | `compactPct` | Auto-compact percent of verified window | 50-95 (default 83) |
52
+ | `compactAuto` | Master switch for automatic compaction (manual `/compact` always works) | boolean (default on) |
53
+ | `compactReserve` | Reserved output buffer in tokens for the usable-limit calculation | 4096-100000 (tokens) |
49
54
  | `network` | Webfetch SSRF policy: which network zones the model may retrieve | object with boolean `allowPublic` (default true), `allowLocalhost` (default true), `allowPrivate` (default false), `allowLinkLocal` (default false) |
50
55
  | `telemetry` | Local observability recording (see [Observability](observability.md)) | `{enabled?: boolean}` (default on; `ATOM_TELEMETRY=0` wins) |
51
56
  | `extensions` | Extension enable/disable patterns by name (see [Extensions](extensions.md); CLI `--enable-extension`/`--disable-extension` win over this) | `{enabled?: string[], disabled?: string[]}` (default load all; `disabled` wins over `enabled`) |
@@ -90,7 +95,7 @@ The allowance is informational only: history is never truncated — there are no
90
95
 
91
96
  ## Session file
92
97
 
93
- `~/.atom/session.json`, version 1, atomic temp-plus-rename saves, `0600` POSIX. See [Sessions](sessions.md).
98
+ Single-turn save: `~/.atom/session.json`, version 1, atomic temp-plus-rename saves, `0600` POSIX. Durable multi-session records live under `~/.atom/sessions/<id>.json` plus a plaintext `active` pointer. See [Sessions](sessions.md).
94
99
 
95
100
  ## AGENTS.md and system prompt
96
101
 
@@ -104,7 +109,7 @@ Final system prompt is two layers (`src/system.ts`, `src/zen.ts`):
104
109
  - Repo overlay: `AGENTS.md` in cwd, or `OPENCODE_AGENTS_PATH` override. Capped at 12KB
105
110
  - To change bot identity, edit the one-liner. To add project instructions, edit `AGENTS.md`
106
111
 
107
- ATOM loads the project `AGENTS.md` at startup so it knows tools, rules, and permission model. This repo own instructions live in `AGENTS.md` at the root.
112
+ ATOM loads the project `AGENTS.md` at startup when present, so it picks up repo tools, rules, and permission model. This checkout ships no `AGENTS.md` (per-project overlay only).
108
113
 
109
114
  ## Context windows
110
115
 
@@ -17,7 +17,8 @@ Build output goes to `dist/` (`atom` runs `dist/cli.js`). `dist/` is gitignored
17
17
  npm start # tsx src/cli.tsx
18
18
  npm test # vitest run (fully mocked, never hits live APIs)
19
19
  npm run typecheck # tsc --noEmit
20
- npm run build # tsc -p tsconfig.build.json (src -> dist)
20
+ npm run build # tsc -p tsconfig.build.json (src -> dist) plus copy-web-ui (web UI assets into dist/)
21
+ npm run bench # node scripts/bench-render.mjs (render-throughput benchmark)
21
22
  ```
22
23
 
23
24
  Tests use `"test-key"` placeholders. Never paste a real key into fixtures, logs, or commits.
@@ -27,7 +28,7 @@ Tests use `"test-key"` placeholders. Never paste a real key into fixtures, logs,
27
28
  ```text
28
29
  .
29
30
  ├── src/
30
- │ ├── cli.tsx # entry: --help/--dashboard/--serve, always starts TUI (missing key guides to /provider)
31
+ │ ├── cli.tsx # entry: --help/--dashboard/--serve/--web, extension flags; always starts TUI otherwise (missing key guides to /provider)
31
32
  │ ├── App.tsx # Ink TUI: transcript, pickers (/model /provider /effort), modes, status line
32
33
  │ ├── context-windows.ts # curated per-model context windows + `token: (P%) NK` format
33
34
  │ ├── compact.ts # context compaction: load/trigger math, split, summary POST (tools off, 4096 cap)
@@ -59,4 +60,4 @@ Add or update tests for behavior changes. A fix without a test that would have c
59
60
 
60
61
  ## Agent workflow in this repo
61
62
 
62
- The repo `AGENTS.md` defines the loop the agent follows: read before edit, 30 tool rounds per turn by default, todowrite list for 3 or more steps with exactly one `in_progress`, verify every change with the suite. Issues live as local markdown under `.scratch/` (see [Issue tracker](agents/issue-tracker.md)).
63
+ The repo `AGENTS.md` defines the loop the agent follows: read before edit, uncapped tool rounds per turn by default (optional cap via `ATOM_MAX_TOOL_STEPS` / `maxToolSteps`, clamped 5-100), todowrite list for 3 or more steps with exactly one `in_progress`, verify every change with the suite. Issues live as local markdown under `.scratch/` (see [Issue tracker](../docs/agents/issue-tracker.md)).
@@ -146,7 +146,7 @@ Exact `ExtensionAPI` methods — nothing else exists:
146
146
  | `isProjectTrusted()` | Whether this load is trusted (degrade gracefully when false) |
147
147
  | `setStatusSegment(text)` | One status-bar slot per extension (upsert by owner) |
148
148
  | `setWidget(def)` | Panel widget (`placement: "panel"`, keyed by owner + id) |
149
- | `notify(message)` | Transient `(name) message` transcript line |
149
+ | `notify(message)` | Transient `(name) message` transcript line (staged queue caps at 100, drop-oldest) |
150
150
  | `promptUser(question, options?, allowCustom?)` | Modal dialog; rejects headless, during activation, or while one is open |
151
151
 
152
152
  Stores behind the API: `src/tools/custom.ts`, `intercept.ts`, `overrides.ts`, `provider-hooks.ts`, `compaction-hooks.ts`, `src/extension-commands.ts`, `src/extension-ui.ts`, `src/project-trust.ts`.
@@ -30,7 +30,7 @@ One pinned session goal that keeps the agent working turn-to-turn until it is do
30
30
 
31
31
  ## Known limits
32
32
 
33
- - The `update_goal` schema is not in the chat-payload `tools` list the model discovers it through the continuation message prose, not a tool definition.
33
+ - The `update_goal` schema rides the chat-payload `tools` list only while a goal turn is live (hidden on non-goal turns so it cannot be misused); the model discovers it through the continuation message prose plus the live tool definition.
34
34
  - Multi-turn goal behavior against live models is unproven; the loop, judge, and gate paths are covered by mocked suites.
35
35
 
36
36
  ## Code
@@ -35,7 +35,7 @@ This index is the entry point. The README stays focused on evaluate, install, an
35
35
 
36
36
  Project conventions the agent itself loads at runtime:
37
37
 
38
- - [AGENTS.md](../AGENTS.md) - agent instructions loaded into the system prompt
39
- - [Issue tracker](agents/issue-tracker.md) - local markdown issues under `.scratch/`
40
- - [Triage labels](agents/triage-labels.md) - canonical triage roles
41
- - [Domain docs](agents/domain.md) - CONTEXT.md plus ADR conventions
38
+ - `AGENTS.md` (per-project, when present) - agent instructions loaded into the system prompt
39
+ - [Issue tracker](../docs/agents/issue-tracker.md) - local markdown issues under `.scratch/`
40
+ - [Triage labels](../docs/agents/triage-labels.md) - canonical triage roles
41
+ - [Domain docs](../docs/agents/domain.md) - CONTEXT.md plus ADR conventions
@@ -20,7 +20,7 @@ Keys are never printed full (masked as last4), never logged, never in fixtures (
20
20
  ## Kilo Gateway (default)
21
21
 
22
22
  - Kilo is ATOM's default provider: fresh installs start on Kilo with no key required
23
- - The model catalog is discovered live via `GET https://api.kilo.ai/api/gateway/models` (cached for 5 minutes; `/models refresh` re-fetches while Kilo is active). Nothing is hardcoded — the catalog is authoritative, and free-model availability can change as Kilo updates it
23
+ - The model catalog is discovered live via `GET https://api.kilo.ai/api/gateway/models` (cached for 5 minutes; `/model refresh` re-fetches while Kilo is active). Nothing is hardcoded — the catalog is authoritative, and free-model availability can change as Kilo updates it
24
24
  - Anonymous access covers eligible free models (ids ending in `:free`, including the `kilo-auto/free` dynamic routing model, which Kilo resolves server-side). Without a key ATOM prefers `kilo-auto/free` when exposed, else the first free model, else the first live id
25
25
  - Configure a key with `/provider` (validated, stored in `~/.atom/auth.json`) or `KILO_API_KEY` to unlock the full catalog; authenticated requests send `Authorization: Bearer <key>`, anonymous requests send no auth header at all
26
26
  - Free models show a `(free)` badge in `/model` and match the `free` filter
@@ -59,8 +59,7 @@ File lives at `~/.atom/auth.json` (`ATOM_HOME` overrides the home dir). `0600` o
59
59
  ## Switching
60
60
 
61
61
  - `/provider`: pick provider, paste key once (validated, stored), chat. Kilo's key is optional — without one the prompt offers anonymous free-model use. Switching provider keeps session history text. System prompt stays
62
- - `/model`: unified picker — active provider's live models first (fallback on any failure), then every other keyed provider's models plus the always-visible keyless Kilo and local lists (cached live list when warm, else fallback). `openai-compatible` joins only with both a key and a stored baseURL. Type to filter (`free` matches free Kilo models), list windows to 10 rows, picking another provider's model switches provider too
63
- - `/models refresh`: re-probes local servers; while Kilo is active it refreshes the Kilo gateway catalog instead
62
+ - `/model`: unified picker — active provider's live models first (fallback on any failure), then every other keyed provider's models plus the always-visible keyless Kilo and local lists (cached live list when warm, else fallback). `openai-compatible` joins only with both a key and a stored baseURL. Type to filter (`free` matches free Kilo models), list windows to 10 rows, picking another provider's model switches provider too. `/model <text>` opens pre-filtered; `/model refresh` re-probes local servers (Kilo gateway catalog while Kilo is active)
64
63
  - `/effort`: reasoning-effort picker (`Auto`/`Low`/`Medium`/`High`/`Max`). Sent for every model on every provider: `reasoning_effort` on OpenAI-chat kinds (zen, OpenAI, DeepSeek, Mistral, Kilo, openai-compatible, locals), a `thinking` budget on Anthropic, a `thinkingConfig.thinkingLevel` on Gemini. `Auto` omits the knob. A model that truly lacks the knob fails the POST with a 400 naming it — the turn warns and retries once without it, so `(unsupported)` only ever reflects an actual server rejection
65
64
 
66
65
  Custom server: pick `openai-compatible`, paste the baseURL (validated as http/https, trailing slashes trimmed) and key. Endpoint helper appends `/chat/completions` when missing.
@@ -34,7 +34,7 @@ Support files: `references/<...>` and `scripts/<...>` mentions inside the body a
34
34
 
35
35
  ## Listing and precedence
36
36
 
37
- `/skills` opens the searchable picker (names only, type to filter, arrows to browse, `Enter` stages for confirm). `skillsListText` (headless use) prints `Skills (N):` with one runnable `/skill:name` plus source per line — no descriptions in either surface. Model-only skills show `[auto-only]` instead of hiding. Notes and warnings ride along visibly. Empty with no warnings prints the install hint (`add SKILL.md skills under .claude/skills/, .agents/skills/, or the ~/. counterparts`).
37
+ `/skill` opens the searchable picker (names only, type to filter, arrows to browse, `Enter` stages for confirm). `skillsListText` (headless use) prints `Skills (N):` with one runnable `/skill:name` plus source per line — no descriptions in either surface. Model-only skills show `[auto-only]` instead of hiding. Notes and warnings ride along visibly. Empty with no warnings prints the install hint (`add SKILL.md skills under .claude/skills/, .agents/skills/, or the ~/. counterparts`).
38
38
 
39
39
  Name clashes: global (personal) wins over project on exact-name matches, with a visible note. Same-level duplicates keep the first with a note. Pure function `resolveSkills`, covered by `tests/skills.test.ts`.
40
40
 
@@ -42,7 +42,7 @@ Name clashes: global (personal) wins over project on exact-name matches, with a
42
42
 
43
43
  Three tiers: (1) name plus description of every skill is known to the matcher at all times; (2) the `SKILL.md` body loads only on activation; (3) `references/` and `scripts/` files load on demand (inlined for explicit manual loads; the model reads them via `read` for auto loads). The transcript always shows one plain line per load (`deploy loaded`), never the body.
44
44
 
45
- - Manual: `/skill:name` (canonical; legacy `/skill-name` still works) loads the full body plus inlined references into context for that turn. Discovery without dispatch: the `/skills` picker (type to filter, arrows to browse, `Enter` stages `/skill:name` into the input — nothing is sent) and the `/` slash menu (skill rows complete on first `Enter`, run on the second) both confirm before loading; a fully typed `/skill:name` or `/skill-name` runs on first `Enter`. `allowed-tools` in frontmatter become turn-scoped auto-approvals. User-invocable `false` entries reject manual invocation
45
+ - Manual: `/skill:name` or `/skill <name>` (canonical; legacy `/skill-name` still works) loads the full body plus inlined references into context for that turn. Discovery without dispatch: the `/skill` picker (type to filter, arrows to browse, `Enter` stages `/skill:name` into the input — nothing is sent) and the `/` slash menu (skill rows complete on first `Enter`, run on the second) both confirm before loading; a fully typed `/skill:name`, `/skill <name>`, or `/skill-name` runs on first `Enter`. `allowed-tools` in frontmatter become turn-scoped auto-approvals. User-invocable `false` entries reject manual invocation
46
46
  - Auto: deterministic whole-word description match with a high bar — distinct `name` plus `description` words (length 3 or more, stopwords dropped) appearing as whole message words, at least 3 hits, best score first, at most 1 skill per turn. Auto loads Tier 2 only (body without inlined references, truncated at 12KB with a read pointer). `disable-model-invocation` skills never match. See `matchSkills` in `src/skills.ts`
47
47
  - Deny rules still win over skill grants. See [Permissions](permissions.md)
48
48
 
@@ -54,4 +54,4 @@ Grant trust (`skillGrantsFor` in `src/policy.ts`, covered by `tests/policy.test.
54
54
  2. Keep the body self-contained; reference large helpers via `references/...` so they inline only when needed
55
55
  3. Declare least-privilege `allowed-tools`
56
56
  4. Set `user-invocable: false` for auto-only helpers, `disable-model-invocation: true` for manual-only helpers
57
- 5. Verify with `/skills` listing plus `tests/skills*.test.ts` patterns
57
+ 5. Verify with `/skill` listing plus `tests/skills*.test.ts` patterns
@@ -12,11 +12,11 @@ Extensions can register brand-new model-callable tools via `api.registerTool({ n
12
12
 
13
13
  | Tool | What it does | Permission in normal mode |
14
14
  |---|---|---|
15
- | `read` | Read files, list directories. Args: `path`, optional 1-based `offset`/`limit` | auto |
15
+ | `read` | Read files (UTF-8 text, or PNG/JPEG/GIF/WebP as vision input), list directories. Args: `path`, optional 1-based `offset`/`limit` (text only) | auto |
16
16
  | `write` | Create or overwrite files (creates parent dirs). Silent pre-mutation snapshot for rewind | asks |
17
17
  | `edit` | Exact-match patch. Fails on no match, on multiple matches without `replaceAll`, on stale read | asks |
18
- | `grep` | Line-regex search under `dir`. `include` glob, `outputMode`: `content`, `files_with_matches`, `count` | auto |
19
- | `glob` | List paths matching pattern under `dir`, newest-first | auto |
18
+ | `grep` | Line-regex search under `dir` (a directory, or a single file to search just it). Case-sensitive; `(?i)` prefix = case-insensitive. `include` glob with `{a,b}` (e.g. `*.{ts,tsx}`), `outputMode`: `content`, `files_with_matches`, `count` | auto |
19
+ | `glob` | List paths matching pattern (`*`, `?`, `**`, `{a,b}`) under `dir` (a directory, or a single file to test just it), newest-first | auto |
20
20
  | `bash` | Shell command. JSON result with `exitCode`, `stdout`, `stderr`. Optional `runInBackground` | asks |
21
21
  | `bash_output` | Poll a background shell task by `taskId` | auto |
22
22
  | `webfetch` | Fetch a page as `markdown`, `text`, or `html`. http upgrades to https. Gated by the network SSRF policy (see below) | auto |
@@ -33,6 +33,7 @@ Read-only set: `read`, `grep`, `glob`, `webfetch`, `websearch`, `bash_output`, `
33
33
  | Path | Cap | Behavior |
34
34
  |---|---|---|
35
35
  | `read` output | ~64KB | Head plus truncation note. Full text spills to `<tmpdir>/atom-overflow/` with a `read` pointer |
36
+ | `read` image | 8 MiB per image | PNG/JPEG/GIF/WebP attach as vision input (see below); larger images refused with downscale guidance |
36
37
  | `bash` stdout/stderr | ~8KB each | Each stream capped independently, JSON flags `stdoutTruncated`/`stderrTruncated`, overflow pointer on spill |
37
38
  | `bash_output` streams | ~8KB each | Same spill behavior for background stdout/stderr |
38
39
  | `webfetch` download | ~1MB | Noted as `[truncated: download exceeded ~1MB]` |
@@ -53,6 +54,10 @@ Count-cap notes (`grep`/`glob` over-cap) and prompt-assembly caps (skills, compa
53
54
 
54
55
  No path sandbox. Relative paths resolve against cwd. Absolute paths and `..` escapes are allowed anywhere on the machine, including sensitive locations like `~/.ssh/`. Treat those contents as untrusted. Never exfiltrate or commit secrets. The permission mode is the control plane. See [Permissions](permissions.md).
55
56
 
57
+ ## Image input (vision)
58
+
59
+ `read` on a PNG, JPEG, GIF, or WebP file (detected by magic bytes, up to 8 MiB) attaches it as vision input: the result reads `Image read successfully: <path> (<mime>, <bytes> bytes, attached as vision input)` plus a `[media:<id> ...]` token. History, transcript, and `session.json` carry only that short token (images live under `~/.atom/media/`, pruned after 7 days); at POST time the token expands to provider-native image blocks (OpenAI `image_url`, Anthropic base64 `image` blocks, Gemini `inline_data`). Context accounting charges the deterministic base64 wire cost per token, so the load stays honest. Compaction summaries and the goal judge always strip images to `[image omitted: ...]` markers (text-only, cheap). A model that rejects image input (400 naming images) retries once automatically with images stripped. Anything else binary — PDF, AVIF, BMP, audio, video — is refused with convert-first guidance (e.g. `pdftoppm`/`pdftotext`); oversize images are refused with downscale guidance. Covered by `tests/media.test.ts`.
60
+
56
61
  ## Network policy (`webfetch` SSRF gate)
57
62
 
58
63
  Every URL — the initial one and every redirect hop — classifies into a zone (`public`, `localhost`, `private` RFC1918/CGNAT/TEST-NET, `link-local` incl. cloud metadata `169.254.169.254`, `blocked` for unparseable/unresolvable) and is checked against the `network` policy from `atom.json` (defaults: public + localhost allowed). Redirects are followed manually (cap 5, loop-detected, credentialed/scheme-changing targets refused) so a public URL can never bounce to metadata or the LAN unseen; each hop re-resolves DNS and the worst zone wins for multi-address hosts. IP-literal tricks (octal/hex forms, IPv4-mapped IPv6) classify by their real address. Known limitation: DNS rebind between check and fetch (TOCTOU) would need connection-level IP pinning, which global fetch does not offer. See [Configuration](configuration.md). Covered by `tests/policy.test.ts`.
@@ -15,7 +15,7 @@ Never print full keys, never commit them, never put them in fixtures.
15
15
 
16
16
  ## Model list fails
17
17
 
18
- `/model` falls back to the offline list when the live `/models` call fails (Kilo falls back to the `kilo-auto/free` routing placeholder). That is expected offline. Check endpoint override (`OPENCODE_ZEN_ENDPOINT`), network, and key validity before assuming a bug. While Kilo is active, `/models refresh` re-fetches the gateway catalog.
18
+ `/model` falls back to the offline list when the live `/models` call fails (Kilo falls back to the `kilo-auto/free` routing placeholder). That is expected offline. Check endpoint override (`OPENCODE_ZEN_ENDPOINT`), network, and key validity before assuming a bug. While Kilo is active, `/model refresh` re-fetches the gateway catalog.
19
19
 
20
20
  Effort (`/effort`: `Auto`/`Low`/`Medium`/`High`/`Max`) is sent for every model on every provider. If a turn warns that an effort level "is not supported by" a model, the server rejected the knob with a 400 and the turn continued without it — the setting is kept, so switching back to a supporting model re-applies it.
21
21
 
@@ -4,8 +4,8 @@
4
4
  // as the normal model-visible result (approval is skipped, the turn
5
5
  // continues); anything this hook does not block runs untouched.
6
6
  //
7
- // Gallery sample for documentation/extensions.md — that guide references
8
- // this file by name and never duplicates it. Covered by
7
+ // Gallery sample for documentation/extensions.md — that guide excerpts
8
+ // this file and references it by name. Covered by
9
9
  // tests/extension-gallery.test.ts, which loads this exact file through the
10
10
  // real loadExtensions path.
11
11
  module.exports = function auditGate(api) {
@@ -5,8 +5,8 @@
5
5
  // `Error: invalid call: ...` result and the implementation never runs), and
6
6
  // dispatches through the shared loop.
7
7
  //
8
- // Gallery sample for documentation/extensions.md — that guide references
9
- // this file by name and never duplicates it. Covered by
8
+ // Gallery sample for documentation/extensions.md — that guide excerpts
9
+ // this file and references it by name. Covered by
10
10
  // tests/extension-gallery.test.ts, which loads this exact file through the
11
11
  // real loadExtensions path.
12
12
  const notes = [];
@@ -6,8 +6,8 @@
6
6
  // transcript) so the dialog flow is testable headless — tests drive the
7
7
  // command with a stub askUser and unit-test the helper directly.
8
8
  //
9
- // Gallery sample for documentation/extensions.md — that guide references
10
- // this file by name and never duplicates it. Covered by
9
+ // Gallery sample for documentation/extensions.md — that guide excerpts
10
+ // this file and references it by name. Covered by
11
11
  // tests/extension-gallery.test.ts, which loads this exact file through the
12
12
  // real loadExtensions path.
13
13
  function summarizeChoice(choice, extra) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atom-agent",
3
- "version": "1.3.0",
3
+ "version": "1.5.0",
4
4
  "description": "Agentic terminal coding assistant: multi-provider LLM loop with local file/shell/web tools in an Ink (React) TUI.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -38,8 +38,9 @@
38
38
  "scripts": {
39
39
  "start": "tsx src/cli.tsx",
40
40
  "test": "vitest run",
41
+ "bench": "node scripts/bench-render.mjs",
41
42
  "typecheck": "tsc --noEmit",
42
- "build": "tsc -p tsconfig.build.json",
43
+ "build": "tsc -p tsconfig.build.json && node scripts/copy-web-ui.mjs",
43
44
  "prepublishOnly": "npm run build"
44
45
  },
45
46
  "dependencies": {