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
@@ -0,0 +1,398 @@
1
+ // One per-call pipeline for the SERIAL tool path: every serial tool call runs
2
+ // through hook → validate → approve → execute → post-hook → commit in this
3
+ // order, speaking one decision vocabulary and emitting one receipt.
4
+ //
5
+ // Ordering (the serial driver in ./loop.js calls runSerialToolPipeline, then
6
+ // its shared commit funnel, then reports telemetry from the receipt):
7
+ // 1. unknown-name gate (registry toolNames() — the intercepted tools are
8
+ // members, so no exemption exists).
9
+ // 2. before-hooks (fail CLOSED to blocked; rewrites flow downstream).
10
+ // 3. validation of the effective (post-rewrite) args (fail → inline error).
11
+ // 4. registry-intercepted stage (validated, never needs approval, resolved
12
+ // without an executor via runInterceptedTool — dispatched by roster
13
+ // lookup, never by name).
14
+ // 5. approval via the existing hook as-is (ticket 04 owns the policy).
15
+ // 6. execution with timeout + normalization (throw/cancel propagates —
16
+ // never a receipt, never retried).
17
+ // 7. post-hooks + result hook inside the commit funnel (fail OPEN to the
18
+ // original result; veto skips the commit).
19
+ //
20
+ // Decision vocabulary — each shape documented once, here:
21
+ // - BeforeOutcome { args, blocked }: pre-hook verdict. `blocked !== null`
22
+ // commits without executing (first block wins, approval skipped).
23
+ // - string | null from validateToolArgs: non-null detail = invalid args,
24
+ // committed as an `Error: invalid call: …` result, never executes.
25
+ // - ApprovalDecision | null ("once" | "always" | "no" | null): "no" (or a
26
+ // throwing approver) denies without executing; null = no approval applies.
27
+ // - string execution result, or a THROWN error (cancel → LoopCancelledError,
28
+ // aborts the turn; anything else aborts the turn too — never a result).
29
+ // - AfterOutcome { content, isError }: post-hook patch verdict.
30
+ // - ToolResultHookDecision | string | null | void: `content` replaces the
31
+ // result, `isError` overrides the error flag, `veto: true` skips the
32
+ // commit; string replaces content; anything else (or a throw) passes the
33
+ // original through.
34
+ // - PipelineDecisionKind: the serial path's terminal label per call.
35
+ // - ToolCallReceipt: the single receipt telemetry, history, and the
36
+ // transcript all observe (see the truth choice below).
37
+ //
38
+ // Receipt truth choice: the receipt carries the EFFECTIVE post-before-hook
39
+ // args (what validated, approved, and ran — pre-hook args never executed,
40
+ // so measuring them would bill work that never happened) and the FINAL
41
+ // post-after-hook result (what history/activity/the model see — measuring
42
+ // the pre-patch result would split "what ran" from "what was recorded").
43
+ // What the user sees is what got measured.
44
+ //
45
+ // Parallel batches keep their own pre-pass + ordered commit with pre-commit
46
+ // telemetry (ticket 05): the pre-pass plans each member via planToolCall
47
+ // above (serially, in call order) and members execute through
48
+ // runPlannedToolCall — the same plan/run pair the serial path uses. The
49
+ // batch planner, the serial approval pre-pass, and the ordered commit stay
50
+ // in ./loop.js untouched, so this module never changes parallel behavior.
51
+ //
52
+ // Dependency direction: agent/tool-pipeline -> {config, goal, tools,
53
+ // tools/intercept, agent/normalize, agent/types} and NOT agent/loop or zen
54
+ // (loop.js imports this module; the graph stays acyclic — see
55
+ // tests/architecture.test.ts).
56
+ import { invalidCall, isInterceptedTool, needsApproval, runInterceptedTool, toolNames, validateToolArgs, } from "../tools.js";
57
+ import { afterToolInterceptors, applyAfterInterceptors, applyBeforeInterceptors, beforeToolInterceptors, blockedToolResult, } from "../tools/intercept.js";
58
+ import { normalizeToolResult } from "./normalize.js";
59
+ // Whole-turn cancellation: thrown when the user cancels (Ctrl+C) mid-loop.
60
+ // The App catches it, rolls the partial turn back (same splice contract as
61
+ // POST failure), renders one dim `(cancelled)` line, and returns to a clean
62
+ // input state. Never retried, never a tool result.
63
+ export class LoopCancelledError extends Error {
64
+ constructor() {
65
+ super("(cancelled)");
66
+ this.name = "LoopCancelledError";
67
+ }
68
+ }
69
+ export function isCancelError(e) {
70
+ if (e instanceof LoopCancelledError)
71
+ return true;
72
+ if (e instanceof Error && e.name === "LoopCancelledError")
73
+ return true;
74
+ // fetch abort surfaces as DOMException AbortError (or Error with that name
75
+ // in mocks). Treat any AbortError as a cancellation, never a retry.
76
+ if (e instanceof Error && e.name === "AbortError")
77
+ return true;
78
+ if (typeof DOMException !== "undefined" && e instanceof DOMException && e.name === "AbortError") {
79
+ return true;
80
+ }
81
+ return false;
82
+ }
83
+ export function throwIfCancelled(signal) {
84
+ if (signal?.aborted)
85
+ throw new LoopCancelledError();
86
+ }
87
+ // Per-tool outer timeout (ms): undefined → default 60s (enabled); explicit
88
+ // <=0/NaN → disabled (direct await, zero overhead). Clamped 1s–120s when
89
+ // enabled so a stuck executor can never hang the turn past the bash ceiling.
90
+ export const DEFAULT_TOOL_TIMEOUT_MS = 60_000;
91
+ export function resolveToolTimeoutMs(raw) {
92
+ if (raw === undefined)
93
+ return DEFAULT_TOOL_TIMEOUT_MS;
94
+ if (typeof raw !== "number" || !Number.isFinite(raw))
95
+ return DEFAULT_TOOL_TIMEOUT_MS;
96
+ if (raw <= 0)
97
+ return null;
98
+ return Math.min(Math.max(Math.floor(raw), 1000), 120_000);
99
+ }
100
+ // Race one execution against the outer timeout. Timeout resolves to an
101
+ // `Error:` result (the model adapts); the underlying promise is left to
102
+ // settle — executors own their own cleanup.
103
+ //
104
+ // Cancellation is DELIBERATELY not raced here: the pinned contract is that
105
+ // an in-flight tool runs to completion and its result IS recorded, with the
106
+ // cancel stopping the turn before the next batch/POST (see the
107
+ // throwIfCancelled checks between batches and before each POST). Racing
108
+ // abort against the execution would drop the in-flight result and break
109
+ // assistant/tool pairing guarantees the tests pin. A hung tool + cancel
110
+ // therefore waits for the timeout (≤60s), commits the timeout error, then
111
+ // the next boundary check throws LoopCancelledError.
112
+ export async function executeWithTimeout(execute, name, parsed, timeoutMs, signal) {
113
+ // No new executions after a cancel: refuse to start when already aborted.
114
+ if (signal?.aborted)
115
+ throw new LoopCancelledError();
116
+ if (timeoutMs === null) {
117
+ return execute(name, parsed);
118
+ }
119
+ let timer = null;
120
+ try {
121
+ const execP = execute(name, parsed);
122
+ const timeoutP = new Promise((_resolve, reject) => {
123
+ timer = setTimeout(() => {
124
+ const err = new Error(`timeout after ${timeoutMs}ms`);
125
+ err.code = "ToolTimeout";
126
+ reject(err);
127
+ }, timeoutMs);
128
+ });
129
+ try {
130
+ return await Promise.race([execP, timeoutP]);
131
+ }
132
+ catch (e) {
133
+ if (isCancelError(e))
134
+ throw e;
135
+ if (e?.code === "ToolTimeout" || e?.message?.startsWith("timeout after ")) {
136
+ return `Error: ${name} timed out after ${timeoutMs}ms — retry with a narrower scope or smaller input.`;
137
+ }
138
+ throw e;
139
+ }
140
+ }
141
+ finally {
142
+ if (timer)
143
+ clearTimeout(timer);
144
+ }
145
+ }
146
+ // Permission-gate resolution shared by the serial path and the parallel
147
+ // pre-pass: returns the hook's decision, or null when no approval applies
148
+ // (no hook, or a tool that never needs it). A "no" (or a throwing hook)
149
+ // denies without executing; cancellation always propagates.
150
+ export async function resolveApproval(name, parsed, opts) {
151
+ if (!opts?.approve || !needsApproval(name))
152
+ return null;
153
+ try {
154
+ const decision = await opts.approve(name, parsed);
155
+ // Abort that lands as a resolved denial still cancels the whole turn.
156
+ throwIfCancelled(opts?.signal);
157
+ return decision;
158
+ }
159
+ catch (e) {
160
+ // Whole-turn cancellation must propagate (Ctrl+C cancels the turn,
161
+ // not just deny one call). Anything else is a denial.
162
+ if (isCancelError(e) || opts?.signal?.aborted)
163
+ throw new LoopCancelledError();
164
+ return "no";
165
+ }
166
+ }
167
+ // Shared JSON-arguments parse for the serial driver: malformed arguments
168
+ // never reach hooks or executors — the caller routes the null case through
169
+ // the commit funnel as an invalid-call error. Returns the parsed record, or
170
+ // null when the raw arguments are not a JSON object.
171
+ export function parseToolArguments(raw) {
172
+ try {
173
+ const text = typeof raw === "string" ? raw : "{}";
174
+ const value = JSON.parse(text);
175
+ if (typeof value === "object" && value !== null && !Array.isArray(value)) {
176
+ return value;
177
+ }
178
+ return null;
179
+ }
180
+ catch {
181
+ return null;
182
+ }
183
+ }
184
+ // Model-visible result for arguments that are not a JSON object (an
185
+ // `Error: invalid call: …` result, committed normally — the turn continues).
186
+ export function invalidJsonArgsResult(name) {
187
+ return (`Error: invalid call: invalid JSON arguments for tool "${name}" ` +
188
+ `(arguments must be valid JSON). Fix the arguments and retry.`);
189
+ }
190
+ // Extension tool-call interception: global pre/post hooks registered via
191
+ // ExtensionAPI.onBeforeToolCall/onAfterToolCall. Both helpers snapshot the
192
+ // live handler list and never throw — a throwing before handler fails
193
+ // closed (blocked outcome), a throwing after handler fails open (original
194
+ // content). Cancellation checks stay where they are; hooks are in-process
195
+ // policy, not executions, so they run even when the signal is armed and the
196
+ // existing boundaries still stop the turn.
197
+ export async function runBeforeIntercept(name, parsed) {
198
+ try {
199
+ return await applyBeforeInterceptors(beforeToolInterceptors(), name, parsed);
200
+ }
201
+ catch {
202
+ return { args: parsed, blocked: blockedToolResult(name, "(unknown)", "interception failed") };
203
+ }
204
+ }
205
+ export async function runAfterIntercept(name, parsed, result, isError) {
206
+ try {
207
+ return await applyAfterInterceptors(afterToolInterceptors(), { name, args: parsed, result, isError });
208
+ }
209
+ catch {
210
+ return { content: result, isError };
211
+ }
212
+ }
213
+ // After-tool-call result hook (issue 06): the single rewrite seam between
214
+ // execution and commit. Absent hook (or null/undefined/void/non-object
215
+ // return) → the original result, byte-identical. A string return replaces
216
+ // content (error flag kept); an object may replace content, override
217
+ // isError, or veto the commit. Hook failures degrade to the original — the
218
+ // turn never breaks. The committed receipt carries the rewrite, so
219
+ // telemetry, history, and activity all observe it.
220
+ export async function applyToolResultHook(hook, name, parsed, result, isError) {
221
+ if (!hook)
222
+ return { content: result, isError, veto: false };
223
+ try {
224
+ const decision = await hook({ name, args: parsed, result, isError });
225
+ if (decision === null || decision === undefined)
226
+ return { content: result, isError, veto: false };
227
+ if (typeof decision === "string")
228
+ return { content: decision, isError, veto: false };
229
+ if (typeof decision === "object") {
230
+ if (decision.veto === true)
231
+ return { content: result, isError, veto: true };
232
+ const content = typeof decision.content === "string" ? decision.content : result;
233
+ const nextIsError = typeof decision.isError === "boolean" ? decision.isError : isError;
234
+ return { content, isError: nextIsError, veto: false };
235
+ }
236
+ return { content: result, isError, veto: false };
237
+ }
238
+ catch {
239
+ return { content: result, isError, veto: false };
240
+ }
241
+ }
242
+ // The commit-funnel patch sequence (post-hooks, then the result hook),
243
+ // shared by the serial and parallel commits: every committed result passes
244
+ // through both, in commit order, so tool_call_id re-pairing and ordering
245
+ // are untouched. Never throws (both stages fail open — see above).
246
+ export async function applyCommitPatches(name, parsed, result, isError, hook) {
247
+ const after = await runAfterIntercept(name, parsed, result, isError);
248
+ return applyToolResultHook(hook, name, parsed, after.content, after.isError);
249
+ }
250
+ // Model-visible result for an unknown tool name (an `Error: unknown tool:
251
+ // … Available: …` result, committed normally — the turn continues).
252
+ export function unknownToolResult(name) {
253
+ return `Error: unknown tool "${name}". Available: ${toolNames().join(", ")}`;
254
+ }
255
+ export async function planToolCall(name, parsed, opts) {
256
+ // Unknown-name gate: the registry's toolNames() is the single source — it
257
+ // already includes the intercepted tools, so no exemption is needed.
258
+ if (!toolNames().includes(name)) {
259
+ return {
260
+ plan: { args: parsed, blocked: null, invalid: null, unknown: unknownToolResult(name) },
261
+ preDecision: null,
262
+ };
263
+ }
264
+ const pre = await runBeforeIntercept(name, parsed);
265
+ if (pre.blocked !== null) {
266
+ return {
267
+ plan: { args: pre.args, blocked: pre.blocked, invalid: null, unknown: null },
268
+ preDecision: null,
269
+ };
270
+ }
271
+ const invalid = validateToolArgs(name, pre.args);
272
+ if (invalid) {
273
+ return {
274
+ plan: { args: pre.args, blocked: null, invalid, unknown: null },
275
+ preDecision: null,
276
+ };
277
+ }
278
+ const preDecision = await resolveApproval(name, pre.args, opts);
279
+ return {
280
+ plan: { args: pre.args, blocked: null, invalid: null, unknown: null },
281
+ preDecision,
282
+ };
283
+ }
284
+ // The ONE per-call runner: a planned call either yields its inline result
285
+ // (unknown/blocked/invalid — never executes; denial resolves inside the
286
+ // shared stages via the pre-decision) or executes through the shared
287
+ // validate → intercepted → approve-or-preDecision → execute stages. Serial
288
+ // calls plan+run inline; parallel members plan serially in the pre-pass,
289
+ // then run here concurrently.
290
+ export async function runPlannedToolCall(call, plan, preDecision, opts, execute, onUpdateGoal) {
291
+ if (plan.unknown !== null) {
292
+ return { result: plan.unknown, args: plan.args, decision: "unknown-tool" };
293
+ }
294
+ if (plan.blocked !== null) {
295
+ return { result: plan.blocked, args: plan.args, decision: "blocked" };
296
+ }
297
+ if (plan.invalid !== null) {
298
+ return { result: invalidCall(plan.invalid), args: plan.args, decision: "invalid-args" };
299
+ }
300
+ return runStagesWithDecision(call, plan.args, opts, execute, preDecision, onUpdateGoal);
301
+ }
302
+ // Inner execution after pre-interception: validate (rewritten) args, then
303
+ // the registry-intercepted stage, then permission, then the executor. Kept
304
+ // for import compatibility; new code prefers planToolCall +
305
+ // runPlannedToolCall.
306
+ export async function runOneToolWithArgs(call, parsed, opts, execute, preDecision, onUpdateGoal) {
307
+ const outcome = await runStagesWithDecision(call, parsed, opts, execute, preDecision, onUpdateGoal);
308
+ return { result: outcome.result, args: outcome.args };
309
+ }
310
+ async function runStagesWithDecision(call, parsed, opts, execute, preDecision, onUpdateGoal) {
311
+ const name = call?.function?.name ?? "(unknown)";
312
+ // Argument validation BEFORE approval/execution: model mistake, never runs.
313
+ // Pre-hook rewrites arrive here already applied, so they re-validate on
314
+ // exactly what would execute — invalid rewrites never reach an executor.
315
+ const detail = validateToolArgs(name, parsed);
316
+ if (detail) {
317
+ return { result: invalidCall(detail), args: parsed, decision: "invalid-args" };
318
+ }
319
+ // Intercepted tools (ask_question/update_goal): validated above, never need
320
+ // approval, resolved without an executor. Dispatched by registry roster
321
+ // lookup — never by name — so visibility and executability stay one list.
322
+ if (isInterceptedTool(name)) {
323
+ throwIfCancelled(opts?.signal);
324
+ // If the signal aborts during the modal, the runner rethrows raw and the
325
+ // mapping below raises LoopCancelledError (no result). If it resolves
326
+ // just as the signal aborts, return the result — the loop records it,
327
+ // then stops before the next POST (no new POSTs, pairing stays valid
328
+ // until rollback).
329
+ try {
330
+ const resolved = await runInterceptedTool(name, parsed, {
331
+ askUser: opts?.askUser,
332
+ signal: opts?.signal,
333
+ onUpdateGoal,
334
+ });
335
+ // Non-null by roster contract (isInterceptedTool just matched); the
336
+ // fallthrough keeps this total if the roster ever drifts — the call
337
+ // then flows into approval/execution like any other known tool.
338
+ if (resolved !== null) {
339
+ return { result: resolved.result, args: parsed, decision: resolved.decision };
340
+ }
341
+ }
342
+ catch (e) {
343
+ if (isCancelError(e) || opts?.signal?.aborted)
344
+ throw new LoopCancelledError();
345
+ throw e;
346
+ }
347
+ }
348
+ const decision = preDecision ?? (await resolveApproval(name, parsed, opts));
349
+ if (decision === "no") {
350
+ return { result: `Error: denied by user: ${name}`, args: parsed, decision: "denied" };
351
+ }
352
+ // "once"/"always" run this call (the caller caches the always-allowed set
353
+ // session-wide so later calls skip the prompt).
354
+ // No new executions after a cancel: stop after the current tool finishes.
355
+ // The current tool (if already running) is awaited to completion and its
356
+ // result IS recorded — the loop then stops before the next tool/POST, so
357
+ // assistant/tool pairing stays valid until the caller rolls back.
358
+ throwIfCancelled(opts?.signal);
359
+ const timeoutMs = resolveToolTimeoutMs(opts?.toolTimeoutMs);
360
+ const doNormalize = opts?.normalizeResults !== false;
361
+ try {
362
+ const raw = await executeWithTimeout(execute, name, parsed, timeoutMs, opts?.signal);
363
+ if (doNormalize)
364
+ return { result: normalizeToolResult(raw), args: parsed, decision: "executed" };
365
+ return { result: typeof raw === "string" ? raw : normalizeToolResult(raw), args: parsed, decision: "executed" };
366
+ }
367
+ catch (e) {
368
+ if (isCancelError(e) || opts?.signal?.aborted)
369
+ throw new LoopCancelledError();
370
+ throw e;
371
+ }
372
+ }
373
+ // The ONE serial per-call pipeline: unknown-name gate → before-hooks →
374
+ // validate → approve → execute (stages 1–6 above), returning the execution
375
+ // result with the effective args and the terminal decision. Post-hooks and
376
+ // the commit run in the shared commit funnel (via applyCommitPatches), so
377
+ // the serial and parallel commits patch identically; the serial driver
378
+ // reports telemetry from the funnel's receipt, unifying all three observers.
379
+ // Hook-vs-approval ordering: pre-hooks run BEFORE validation and approval.
380
+ // The hook sees the call pre-approval and may rewrite args before the
381
+ // approval prompt shows them; a block short-circuits approval entirely (no
382
+ // prompt for a call that never runs). Rewrites always re-validate before
383
+ // execution, so a hook can never smuggle unvalidated args into an executor.
384
+ // Unknown names never reach hooks (model mistake — nothing would run).
385
+ // Registry-intercepted tools (ask_question/update_goal) pass the gate like
386
+ // any known tool: the stages below validate them and resolve them without
387
+ // an executor (never needs approval). ask_question never needs approval;
388
+ // without an askUser hook it resolves to "Error: ask_question has no UI
389
+ // hook". update_goal never needs approval either; without the per-turn
390
+ // recorder it resolves to the outside-turn error. Model mistakes (unknown
391
+ // name, invalid args) return repair-oriented results WITHOUT executing;
392
+ // cancellations propagate as LoopCancelledError (never a result, never
393
+ // retried).
394
+ export async function runSerialToolPipeline(call, parsed, opts, execute, onUpdateGoal) {
395
+ const name = call?.function?.name ?? "(unknown)";
396
+ const { plan, preDecision } = await planToolCall(name, parsed, opts);
397
+ return runPlannedToolCall(call, plan, preDecision, opts, execute, onUpdateGoal);
398
+ }
@@ -0,0 +1,12 @@
1
+ // Guarded emission: runs emit against the sink, swallowing observer errors so
2
+ // a throwing sink can never break the turn. No-op when the sink is absent.
3
+ export function emitTurnEvent(sink, emit) {
4
+ if (!sink)
5
+ return;
6
+ try {
7
+ emit(sink);
8
+ }
9
+ catch {
10
+ // observer errors never break the loop
11
+ }
12
+ }
package/dist/cli.js CHANGED
@@ -22,7 +22,39 @@ if (args.includes("--dashboard")) {
22
22
  console.error("Dashboard failed to write — telemetry store unavailable.");
23
23
  process.exit(1);
24
24
  }
25
- if (args.includes("--serve")) {
25
+ if (args.includes("--web")) {
26
+ // ATOM WebUI: local agentic frontend over the same runtime as the TUI
27
+ // (loopback-only, Ctrl+C stops). The TUI below never starts alongside it.
28
+ const flagValue = (name) => {
29
+ const eq = args.find((a) => a.startsWith(`${name}=`));
30
+ if (eq !== undefined)
31
+ return eq.slice(name.length + 1);
32
+ const i = args.indexOf(name);
33
+ if (i !== -1 && i + 1 < args.length) {
34
+ const next = args[i + 1];
35
+ if (!next.startsWith("-"))
36
+ return next;
37
+ }
38
+ return undefined;
39
+ };
40
+ (async () => {
41
+ const { resolveWebPort, startWebServer } = await import("./web/server.js");
42
+ const server = await startWebServer({ port: resolveWebPort(process.env, flagValue("--port")) });
43
+ console.log(`ATOM WebUI at ${server.url} (loopback-only — Ctrl+C to stop).`);
44
+ console.log(`JSON API: ${server.url}api/health · ${server.url}api/providers · ${server.url}api/sessions`);
45
+ const stop = () => {
46
+ server.close().then(() => process.exit(0), () => process.exit(0));
47
+ };
48
+ process.on("SIGINT", stop);
49
+ process.on("SIGTERM", stop);
50
+ await new Promise(() => { });
51
+ })().catch((e) => {
52
+ const detail = e instanceof Error ? e.message : String(e);
53
+ console.error(`ATOM WebUI failed to start (${detail}). Is the port already in use? Try --port <n>.`);
54
+ process.exit(1);
55
+ });
56
+ }
57
+ else if (args.includes("--serve")) {
26
58
  // Local observability webUI: serve the live dashboard + read-only JSON API
27
59
  // on loopback (Ctrl+C stops). The static --dashboard file is untouched.
28
60
  const flagValue = (name) => {
@@ -58,7 +90,8 @@ else if (args.includes("--help") || args.includes("-h")) {
58
90
  console.log(`Atom chatbot (Ink TUI)
59
91
  Usage: npm start
60
92
  Flags: --dashboard (write ~/.atom/telemetry/dashboard.html and exit)
61
- --serve [--port <n>] (serve the live dashboard webUI on loopback and keep running)
93
+ --serve [--port <n>] (serve the live dashboard webUI on loopback and keep running)
94
+ --web [--port <n>] (serve the agentic WebUI on loopback and keep running)
62
95
  --no-extensions (--lockdown alias: boot with zero third-party extensions; builtins unchanged)
63
96
  --enable-extension <glob> (repeatable; only matching extensions load)
64
97
  --disable-extension <glob> (repeatable; wins over --enable-extension)
@@ -68,7 +101,7 @@ Env:
68
101
  OPENAI_API_KEY / ANTHROPIC_API_KEY / DEEPSEEK_API_KEY / MISTRAL_API_KEY / GEMINI_API_KEY (GOOGLE_API_KEY alias) optional per provider (env wins over stored)
69
102
  OPENCODE_ZEN_MODEL optional (default: ${DEFAULT_MODEL}; when set, wins over the saved /model)
70
103
  OPENCODE_ZEN_ENDPOINT optional (default: ${DEFAULT_ENDPOINT})
71
- Commands: /model (model picker) | /models [refresh] (local discovery refresh; Kilo catalog refresh when Kilo is active) | /provider (provider + key picker) | /effort (reasoning-effort picker) | /goal <objective> (pin one session objective; bare shows it, pause/resume/clear manage it) | /compact [focus] (summarize older turns) | /tools | /skills (list installed skills) | /skill:name (invoke) | /context (context usage) | /queue + /steer (follow-ups while busy) | /autoscroll (toggle follow new output) | /thinking (toggle reasoning visibility) | /mode | /trust | /allow | /deny | /rules | /clear | /new (fresh conversation, previous kept) | /rename <name> (rename current session) | /session (switch session picker) | /resume (restore last saved session) | /telemetry | /dashboard | /rewind | /help | /exit | /quit — Tab cycles the permission mode normal → yolo → plan → normal (extension slash commands appear in the / menu and palette, not in this static list)
104
+ Commands: /model [filter|refresh] (model picker; refresh re-probes local servers, Kilo catalog refresh when Kilo is active) | /provider (provider + key picker) | /effort (reasoning-effort picker) | /goal <objective> (pin one session objective; bare shows it, pause/resume/clear manage it) | /compact [focus] (summarize older turns) | /tools | /skill (skill picker) | /skill:name (invoke) | /context (context usage) | /queue + /steer (follow-ups while busy) | /autoscroll (toggle follow new output) | /thinking (toggle reasoning visibility) | /mode | /trust | /allow | /deny | /rules | /clear | /new (fresh conversation, previous kept) | /rename <name> (rename current session) | /session (switch session picker) | /resume (restore last saved session) | /telemetry | /dashboard | /rewind | /help | /exit | /quit — Tab cycles the permission mode normal → yolo → plan → normal (extension slash commands appear in the / menu and palette, not in this static list)
72
105
  Providers: kilo (default; anonymous free models, key optional)/opencode-zen/openai/anthropic/deepseek/mistral/google-gemini/openai-compatible (keys in ~/.atom/auth.json, 0600 POSIX; use /provider to paste one) + local auto-discovery: ollama (:11434), lmstudio (:1234), llamacpp (:8080) — no keys needed, overrides via ATOM_OLLAMA_URL/ATOM_LMSTUDIO_URL/ATOM_LLAMACPP_URL.
73
106
  Effort (Auto/Low/Medium/High/Max) applies on every provider: reasoning_effort for OpenAI-chat kinds, thinking budgets for Anthropic, thinking levels for Gemini. Auto omits the knob.`);
74
107
  process.exit(0);
@@ -93,16 +126,32 @@ const envModel = process.env.OPENCODE_ZEN_MODEL?.trim() || undefined;
93
126
  // /provider pointer; nothing is POSTed.
94
127
  // --serve parks above (the server holds the event loop), so the TUI must
95
128
  // never start alongside it: serve is a standalone mode like --dashboard.
96
- if (!args.includes("--serve")) {
97
- // Production frame policy (Ink 7.1.1):
129
+ // Same for --web (the agentic WebUI server owns the process instead).
130
+ if (!args.includes("--serve") && !args.includes("--web")) {
131
+ // Production frame policy (Ink 7.1.1), measured with
132
+ // scripts/bench-render.mjs (real render root, fake TTY, 100x30):
98
133
  // - incrementalRendering: only changed terminal lines rewrite per frame.
99
- // Streaming paints touch the live tail + status bar, not the scrollback,
100
- // so this cuts flicker and stdout bytes on every token flush.
134
+ // Streaming paints touch the live tail + status bar, not the scrollback.
135
+ // Measured ~3x fewer stdout bytes than full-frame on sustained
136
+ // streaming (paced 300-token run: ~66KB vs ~195KB) with identical
137
+ // frames — strictly less flicker, zero behavior change. Compatible
138
+ // with the whole UI (no full-screen chrome depends on reprints).
101
139
  // Escape hatch: ATOM_INCREMENTAL=0 restores full-frame rendering.
102
140
  // - maxFps: 30 keeps keystroke-to-paint latency low; token paints already
103
- // coalesce to ~15fps via DRAFT_THROTTLE_MS, so Ink never does extra work.
141
+ // coalesce to ~15fps via the paint scheduler, so Ink never does extra
142
+ // work. Measured 15fps: fewer total bytes but the same clears-per-frame
143
+ // (clears are driven by frame HEIGHT on win32, not rate) — halving the
144
+ // cap would trade responsiveness for no structural gain. FPS limiting
145
+ // is deliberately NOT used as a flicker fix.
104
146
  // - concurrent: enables React concurrent features (useTransition /
105
147
  // useDeferredValue) for future deferral of expensive subtrees.
148
+ // Measured neutral vs sync mode (frames/bytes within noise), kept for
149
+ // the deferral option, not for current gains.
150
+ // Not configurable here (and intentionally so): full-screen clears come
151
+ // from frame geometry (win32 clears whenever the frame fills the
152
+ // viewport), so they are fixed by keeping the live frame short
153
+ // (windowed thinking, capped approval preview, coalesced paints) —
154
+ // no render flag can substitute for that.
106
155
  // Tests are unaffected: they render via ink-testing-library, not here.
107
156
  render(_jsx(App, { apiKey: apiKey, endpoint: endpoint, initialModel: envModel, restorePrefs: true, extensionsLockdown: extFlags.lockdown, enableExtensions: extFlags.enable, disableExtensions: extFlags.disable }), {
108
157
  incrementalRendering: process.env.ATOM_INCREMENTAL !== "0",
package/dist/compact.js CHANGED
@@ -20,12 +20,23 @@ import { chatCompletionForProvider, } from "./zen.js";
20
20
  // ContextManager module; compact.ts imports what its splitter needs and
21
21
  // re-exports the stable surface so existing importers keep working untouched.
22
22
  import { estimateTokensForChars, messageChars } from "./context-manager.js";
23
+ import { usableLimitFor } from "./overflow.js";
23
24
  import { truncateHead } from "./tools/shared.js";
24
25
  export { COMPACT_PCT_DEFAULT, compactPct, computeContextLoad, estimateTokensForChars, historyChars, shouldAutoCompact, } from "./context-manager.js";
25
26
  // ---- Constants ----
26
27
  export const COMPACT_KEEP_TOKENS = 20000;
27
28
  export const COMPACT_SUMMARY_MAX_TOKENS = 4096;
28
29
  export const COMPACT_TOOL_OUTPUT_CAP = 2000;
30
+ // Budgeted tail band (issue 04, opencode reference): the retained tail is
31
+ // 25% of the model's usable limit (verified window minus reserve), clamped
32
+ // to [MIN, MAX]. Models with no verified window never fabricate one — they
33
+ // fall back to COMPACT_KEEP_TOKENS (the pre-existing fixed tail).
34
+ export const COMPACT_TAIL_MIN_TOKENS = 2000;
35
+ export const COMPACT_TAIL_MAX_TOKENS = 15000;
36
+ // Cleared marker for pruned old tool outputs. Same `[truncated: ...]`
37
+ // family as capToolOutputsInTail — never a second convention. Short by
38
+ // design: the summary carries the story, not this placeholder.
39
+ export const COMPACT_PRUNED_TOOL_OUTPUT = "[truncated: old tool output cleared]";
29
40
  // opencode's 4ch/token heuristic (V2 preflight estimate): chars/4 floors to
30
41
  // estimated tokens. Used for load fallback + tail split only, never for the
31
42
  // `token: n/a` honesty rule or the NK cumulative spend.
@@ -74,18 +85,53 @@ export function capToolOutputsInTail(tail) {
74
85
  return { ...m };
75
86
  });
76
87
  }
88
+ // Tail budget for a model (issue 04): 25% of the usable limit from
89
+ // overflow.ts (read-only — trigger semantics untouched), clamped to the
90
+ // [MIN, MAX] band. Unknown/blank models fall back to COMPACT_KEEP_TOKENS.
91
+ export function tailKeepTokensForModel(model) {
92
+ if (typeof model !== "string" || model.length === 0)
93
+ return COMPACT_KEEP_TOKENS;
94
+ const usable = usableLimitFor(model);
95
+ if (usable === undefined)
96
+ return COMPACT_KEEP_TOKENS;
97
+ const quarter = Math.floor(usable * 0.25);
98
+ return Math.min(COMPACT_TAIL_MAX_TOKENS, Math.max(COMPACT_TAIL_MIN_TOKENS, quarter));
99
+ }
100
+ // Prune pass for old tool outputs (issue 04): bulky tool results OUTSIDE the
101
+ // protected newest window (i.e. in the head being summarized) collapse to the
102
+ // short cleared marker so the summary POST stays small even when the session
103
+ // holds huge dumps. Small outputs pass through verbatim so the summary keeps
104
+ // fidelity; the retained tail is NEVER passed here — its outputs stay
105
+ // intact, and the newest turn is never pruned. Idempotent (the marker itself
106
+ // is far below the cap).
107
+ export function pruneOldToolOutputs(head) {
108
+ return head.map((m) => {
109
+ if (m?.role === "tool" &&
110
+ typeof m.content === "string" &&
111
+ m.content.length > COMPACT_TOOL_OUTPUT_CAP) {
112
+ return { ...m, content: COMPACT_PRUNED_TOOL_OUTPUT };
113
+ }
114
+ return { ...m };
115
+ });
116
+ }
77
117
  // Split history (after system) into head + retained newest tail of whole
78
- // user-turns up to KEEP_TOKENS estimated tokens (chars/4). Tool outputs in
79
- // the tail are capped at 2000 chars each. Never drops history[0]; always
80
- // keeps at least the newest turn; when everything fits but there is more
81
- // than one turn, keeps only the newest turn in the tail so manual /compact
82
- // still has an older turn to summarize.
83
- export function splitHistoryForCompaction(history, keepTokens = COMPACT_KEEP_TOKENS) {
118
+ // user-turns up to a token budget estimated at chars/4. The budget is
119
+ // keepTokens by default (COMPACT_KEEP_TOKENS fallback); pass a model id to
120
+ // scale it with that model's usable limit via tailKeepTokensForModel (the
121
+ // model wins when given unknown models fall back to the same fixed tail).
122
+ // Tool outputs in the tail are capped at 2000 chars each. Never drops
123
+ // history[0]; always keeps at least the newest turn; when everything fits
124
+ // but there is more than one turn, keeps only the newest turn in the tail
125
+ // so manual /compact still has an older turn to summarize.
126
+ export function splitHistoryForCompaction(history, keepTokens = COMPACT_KEEP_TOKENS, model) {
84
127
  if (history.length <= 1)
85
128
  return { head: [], tail: [], olderTurnCount: 0 };
86
129
  const starts = turnStarts(history);
87
130
  if (starts.length === 0)
88
131
  return { head: [], tail: [], olderTurnCount: 0 };
132
+ const budget = typeof model === "string" && model.length > 0
133
+ ? tailKeepTokensForModel(model)
134
+ : keepTokens;
89
135
  let totalChars = 0;
90
136
  let tailStart = starts[starts.length - 1];
91
137
  for (let s = starts.length - 1; s >= 0; s--) {
@@ -93,7 +139,7 @@ export function splitHistoryForCompaction(history, keepTokens = COMPACT_KEEP_TOK
93
139
  const end = turnEnd(history, start, starts);
94
140
  totalChars += turnChars(history, start, end);
95
141
  const est = estimateTokensForChars(totalChars);
96
- if (est <= keepTokens) {
142
+ if (est <= budget) {
97
143
  tailStart = start;
98
144
  }
99
145
  else {
@@ -122,6 +168,11 @@ export function splitHistoryForCompaction(history, keepTokens = COMPACT_KEEP_TOK
122
168
  // and next steps inside its prose (the canonical `Goal:` block is appended
123
169
  // separately after the POST). Absent/blank reads exactly as before, so
124
170
  // non-goal compaction output stays byte-identical.
171
+ // Chaining (ticket 03): on a second or later compaction the head opens with
172
+ // a prior "[Compacted context ...]" summary message. The summarizer merges
173
+ // it forward — Objective, key decisions, and Relevant Files survive every
174
+ // link in the chain — and when the prior summary conflicts with newer
175
+ // conversation, the newer conversation wins.
125
176
  export function buildCompactionInstruction(focusText, goalObjective) {
126
177
  const focus = typeof focusText === "string" && focusText.trim().length > 0
127
178
  ? `\nFocus for this summary: ${focusText.trim()}\n`
@@ -142,6 +193,12 @@ export function buildCompactionInstruction(focusText, goalObjective) {
142
193
  `### Blocked\n` +
143
194
  `## Next Move\n` +
144
195
  `## Relevant Files\n` +
196
+ `Chaining: the history may open with a prior compaction summary ("[Compacted context ...]"). ` +
197
+ `Merge it forward — carry its Objective, key decisions, and Relevant Files into this summary ` +
198
+ `so a chain of compactions never loses the original goal. ` +
199
+ `When the prior summary conflicts with newer conversation, the newer conversation wins; ` +
200
+ `discard the stale fact and keep only what the newer turns confirm or what is carried forward explicitly. ` +
201
+ `Preserve file paths and identifiers verbatim.\n` +
145
202
  `Rules: no tools are available for this request — answer with the summary text only, no tool calls, no preamble beyond the headings.`);
146
203
  }
147
204
  export function buildSummaryMessages(systemContent, head, focusText, goalObjective) {
@@ -203,12 +260,19 @@ export function truncateHeadForRetry(head) {
203
260
  // throw immediately with history untouched (caller must not swap).
204
261
  export async function requestCompactSummary(req) {
205
262
  const attempt = async (head) => {
206
- const messages = buildSummaryMessages(req.systemContent, head, req.focusText, req.goalObjective);
263
+ // Issue 04: bulky tool outputs outside the protected tail collapse to
264
+ // the cleared marker before the POST, so a session full of huge dumps
265
+ // still summarizes in one cheap request. The tail never flows through
266
+ // here — only the head — so newest-turn outputs stay intact.
267
+ const messages = buildSummaryMessages(req.systemContent, pruneOldToolOutputs(head), req.focusText, req.goalObjective);
207
268
  const res = await chatCompletionForProvider(req.provider, req.apiKey, req.model, messages, {
208
269
  baseURL: req.baseURL,
209
270
  endpointOverride: req.endpointOverride,
210
271
  disableTools: true,
211
272
  maxOutputTokens: COMPACT_SUMMARY_MAX_TOKENS,
273
+ // The summarizer needs prose, not pixels: media descriptors stay
274
+ // as markers so the summary POST stays text-only and cheap.
275
+ stripMedia: true,
212
276
  ...(req.signal ? { signal: req.signal } : {}),
213
277
  });
214
278
  // Totals keep accumulating: forward real summary usage when present.