atom-agent 1.2.0 → 1.3.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 (54) hide show
  1. package/CHANGELOG.md +75 -0
  2. package/README.md +13 -4
  3. package/atom.example.json +11 -0
  4. package/dist/App.js +923 -200
  5. package/dist/adapters.js +82 -13
  6. package/dist/agent/goal-evaluator.js +69 -0
  7. package/dist/agent/loop.js +517 -76
  8. package/dist/cli.js +11 -3
  9. package/dist/compact.js +41 -15
  10. package/dist/config.js +43 -7
  11. package/dist/context-manager.js +16 -198
  12. package/dist/context-windows.js +4 -2
  13. package/dist/env-block.js +5 -5
  14. package/dist/extension-commands.js +196 -0
  15. package/dist/extension-ui.js +153 -0
  16. package/dist/extensions.js +1571 -0
  17. package/dist/goal.js +583 -0
  18. package/dist/project-trust.js +96 -0
  19. package/dist/providers.js +6 -6
  20. package/dist/scheduler.js +74 -36
  21. package/dist/session.js +23 -5
  22. package/dist/sessions.js +25 -6
  23. package/dist/telemetry-dashboard.js +28 -0
  24. package/dist/telemetry.js +39 -0
  25. package/dist/tools/compaction-hooks.js +165 -0
  26. package/dist/tools/custom.js +189 -0
  27. package/dist/tools/intercept.js +145 -0
  28. package/dist/tools/overrides.js +105 -0
  29. package/dist/tools/provider-hooks.js +224 -0
  30. package/dist/tools/registry.js +246 -17
  31. package/dist/tools.js +44 -0
  32. package/dist/ui/palette.js +1 -1
  33. package/dist/ui/status-bar.js +80 -5
  34. package/dist/zen.js +305 -75
  35. package/documentation/architecture.md +114 -0
  36. package/documentation/cli.md +82 -0
  37. package/documentation/compaction.md +50 -0
  38. package/documentation/configuration.md +111 -0
  39. package/documentation/development.md +62 -0
  40. package/documentation/extensions.md +160 -0
  41. package/documentation/getting-started.md +63 -0
  42. package/documentation/goals.md +41 -0
  43. package/documentation/index.md +41 -0
  44. package/documentation/observability.md +70 -0
  45. package/documentation/permissions.md +66 -0
  46. package/documentation/providers.md +78 -0
  47. package/documentation/sessions.md +92 -0
  48. package/documentation/skills.md +57 -0
  49. package/documentation/tools.md +94 -0
  50. package/documentation/troubleshooting.md +54 -0
  51. package/examples/extensions/01-audit-gate.js +24 -0
  52. package/examples/extensions/02-notes-tool.js +32 -0
  53. package/examples/extensions/03-custom-command.js +32 -0
  54. package/package.json +6 -2
package/dist/providers.js CHANGED
@@ -68,7 +68,7 @@ export const PROVIDERS = [
68
68
  "minimax-m2.7",
69
69
  "big-pickle",
70
70
  ],
71
- notes: "OpenAI-compatible chat/completions. reasoning_effort only here.",
71
+ notes: "OpenAI-compatible chat/completions. /effort sends reasoning_effort (Auto omits it).",
72
72
  cache: {
73
73
  explicitBreakpoints: false,
74
74
  implicitPrefix: true,
@@ -90,7 +90,7 @@ export const PROVIDERS = [
90
90
  "gpt-5.6-terra",
91
91
  "gpt-5.6-luna",
92
92
  ],
93
- notes: "OpenAI-compatible chat/completions. reasoning_effort never sent.",
93
+ notes: "OpenAI-compatible chat/completions. /effort sends reasoning_effort (Auto omits it).",
94
94
  cache: {
95
95
  explicitBreakpoints: false,
96
96
  implicitPrefix: true,
@@ -112,7 +112,7 @@ export const PROVIDERS = [
112
112
  "claude-3-5-sonnet-20241022",
113
113
  "claude-3-5-haiku-20241022",
114
114
  ],
115
- notes: "Messages API with tool_use blocks. max_tokens 4096.",
115
+ notes: "Messages API with tool_use blocks. max_tokens 4096. /effort maps to the thinking budget (Auto omits it).",
116
116
  cache: {
117
117
  explicitBreakpoints: true,
118
118
  implicitPrefix: true,
@@ -134,7 +134,7 @@ export const PROVIDERS = [
134
134
  "deepseek-v4-flash",
135
135
  "deepseek-v4-pro",
136
136
  ],
137
- notes: "OpenAI-compatible (no /v1 prefix). reasoning_effort never sent.",
137
+ notes: "OpenAI-compatible (no /v1 prefix). /effort sends reasoning_effort (Auto omits it).",
138
138
  cache: {
139
139
  explicitBreakpoints: false,
140
140
  implicitPrefix: true,
@@ -156,7 +156,7 @@ export const PROVIDERS = [
156
156
  "mistral-small-latest",
157
157
  "open-mistral-nemo",
158
158
  ],
159
- notes: "OpenAI-compatible chat/completions. reasoning_effort never sent.",
159
+ notes: "OpenAI-compatible chat/completions. /effort sends reasoning_effort (Auto omits it).",
160
160
  cache: {
161
161
  explicitBreakpoints: false,
162
162
  implicitPrefix: false,
@@ -179,7 +179,7 @@ export const PROVIDERS = [
179
179
  "gemini-2.0-flash",
180
180
  "gemini-1.5-flash",
181
181
  ],
182
- notes: "streamGenerateContent SSE; :generateContent fallback.",
182
+ notes: "streamGenerateContent SSE; :generateContent fallback. /effort maps to thinkingLevel (Auto omits it; Max rides high).",
183
183
  cache: {
184
184
  explicitBreakpoints: false,
185
185
  implicitPrefix: true,
package/dist/scheduler.js CHANGED
@@ -1,11 +1,16 @@
1
- // Effect-aware tool scheduler: data-driven parallelism without Promise.all-
2
- // ing every tool.
1
+ // Effect-aware tool scheduler: data-driven parallelism, parallel by default.
3
2
  //
4
3
  // The contract: batchable calls run concurrently, conflicting calls stay
5
4
  // serialized, results commit in original call order, cancel stops between
6
5
  // batches, approval happens per call in order before execution. This module
7
6
  // only PLANS batches; execution (agent/loop.ts runLoopWithChat) is untouched.
8
7
  //
8
+ // Parallel-by-default posture: side-effect-free reads batch unconditionally
9
+ // (same target included — two reads can never race each other); only real
10
+ // conflicts serialize: same-file write order, read/write on the same file,
11
+ // invisible footprints (bash), shared ambient state (todos), interactive
12
+ // prompts, and calls the planner cannot see (unknown/malformed/invalid).
13
+ //
9
14
  // How it reasons (per tool, from the TOOL_EFFECTS table — the single
10
15
  // coupling point; the algorithm below has no per-tool branches):
11
16
  // - missing metadata → serial singleton (new tools fail safe, like the old
@@ -22,17 +27,18 @@
22
27
  // (see canonicalFileKey): same-file mutations never share a batch, so they
23
28
  // stay strictly ordered in program order; different files run concurrently.
24
29
  // An unresolvable target stays serial (never batch what you cannot see).
25
- // - reads batch with pairwise-disjoint keys, where the key is tool + target.
26
- // Same tool + same target serializes (the old overlap rule, kept verbatim:
27
- // e.g. two reads of one path). A path read also conflicts with an open
28
- // write to the same canonical file (read-after-write stays ordered), and a
29
- // write conflicts with any open member on its key (write-after-read stays
30
- // ordered). Directory-scoped scans (grep/glob) vs concurrent writes to
31
- // unscanned-listed files are out of scope — same exposure as an editor
32
- // saving mid-scan; only same-canonical-path pairs are ordered.
33
- // - deterministic is declared per tool; its current enforcement is the
34
- // same-key rule (a re-poll of the same background task, whose output can
35
- // grow, never runs concurrently with itself).
30
+ // - reads (filesystem or network) always batch: a pure read has no observable
31
+ // footprint, so even the same tool + same target runs concurrently. A path
32
+ // read against an open write to the same canonical file still stays ordered
33
+ // (read-after-write): it splits the batch and a write conflicts with any
34
+ // open read on its key (write-after-read stays ordered). Directory-scoped
35
+ // scans (grep/glob) vs concurrent writes to unscanned-listed files are out
36
+ // of scope — same exposure as an editor saving mid-scan; only
37
+ // same-canonical-path pairs are ordered.
38
+ // - deterministic is informational only (same output for same args); it no
39
+ // longer drives batching not even same-task bash_output polls serialize,
40
+ // since concurrent polls are side-effect-free reads whose results commit in
41
+ // call order anyway.
36
42
  //
37
43
  // Pure module except the shared arg validators (same imports zen.ts already
38
44
  // carries — no new coupling class) plus best-effort path canonicalization
@@ -43,7 +49,7 @@
43
49
  // tests/parallel-writes.test.ts.
44
50
  import * as fs from "node:fs";
45
51
  import * as path from "node:path";
46
- import { toolNames, validateToolArgs } from "./tools.js";
52
+ import { isCustomTool, toolExecutionMode, toolNames, validateToolArgs } from "./tools.js";
47
53
  const str = (v) => (typeof v === "string" ? v : "");
48
54
  const targetOf = (key) => (args) => {
49
55
  const t = str(args[key]);
@@ -93,8 +99,8 @@ export const TOOL_EFFECTS = {
93
99
  process: "none",
94
100
  interactive: false,
95
101
  exclusive: false,
96
- // Live search results vary call to call; same-query conflict is still
97
- // serialized by the same-key rule.
102
+ // Live search results vary call to call; concurrent same-query searches
103
+ // are merely redundant, never incorrect — reads always batch.
98
104
  deterministic: false,
99
105
  target: targetOf("query"),
100
106
  },
@@ -105,8 +111,8 @@ export const TOOL_EFFECTS = {
105
111
  process: "none",
106
112
  interactive: false,
107
113
  exclusive: false,
108
- // A running task's output grows between polls — same-task polls
109
- // serialize via the same-key rule.
114
+ // A running task's output grows between polls — concurrent same-task
115
+ // polls are side-effect-free reads whose results commit in call order.
110
116
  deterministic: false,
111
117
  target: targetOf("taskId"),
112
118
  },
@@ -213,27 +219,55 @@ export function canonicalFileKey(rawPath, cwd = process.cwd()) {
213
219
  return abs;
214
220
  }
215
221
  // Partition one assistant message's tool_calls into commit batches,
216
- // preserving program order: consecutive batchable calls with pairwise
217
- // disjoint keys form one batch; any serial-only call and any call whose
218
- // key already appears in the open batch closes the batch and runs as a
219
- // strict serial singleton. Filesystem writes join a batch only on a
220
- // disjoint canonical file key (same-file mutations split into sequential
221
- // batches, never concurrent); a path read conflicting with an open write
222
- // or a write conflicting with any open member on the same canonical key
223
- // also splits, so per-file program order always holds. A later batch never
222
+ // preserving program order: consecutive batchable calls form one batch; any
223
+ // serial-only call closes the batch and runs as a strict serial singleton.
224
+ // Filesystem writes join a batch only on a disjoint canonical file key
225
+ // (same-file mutations split into sequential batches, never concurrent); a
226
+ // path read conflicting with an open write or a write conflicting with any
227
+ // open member on the same canonical key also splits, so per-file program
228
+ // order always holds. Reads never split on each other. A later batch never
224
229
  // moves ahead of an earlier serial call, and batches never span the block
225
230
  // boundary.
226
231
  export function planBatches(calls) {
227
232
  const batches = [];
228
233
  let open = [];
229
- const keys = new Set();
230
234
  // Canonical file keys of the open batch ("read" and/or "write" per key).
235
+ // This is the ONLY cross-call ordering state: same-file read/write pairs
236
+ // stay in program order; everything else batches freely.
231
237
  const openFiles = new Map();
238
+ // Lenient parse for classification/fallback only (mirrors the loop: {}
239
+ // when the JSON is malformed — the error result commits downstream).
240
+ const lenientParse = (call) => {
241
+ try {
242
+ const raw = call?.function?.arguments ?? "{}";
243
+ const v = JSON.parse(typeof raw === "string" ? raw : "{}");
244
+ return typeof v === "object" && v !== null ? v : {};
245
+ }
246
+ catch {
247
+ return {};
248
+ }
249
+ };
250
+ // Extension sequential hint (ticket 06): a tool declaring sequential
251
+ // execution forces its whole sibling batch one-at-a-time (Pi-style). This
252
+ // only ever ADDS serialization — same-file order, bash/todo/prompt
253
+ // isolation, ordered commits, and the approval pre-pass are untouched
254
+ // (singletons satisfy every one of them; the loop below handles them
255
+ // exactly as before, one call per batch).
256
+ if (calls.some((call) => {
257
+ const name = typeof call?.function?.name === "string" ? call.function.name : "";
258
+ try {
259
+ return toolExecutionMode(name) === "sequential";
260
+ }
261
+ catch {
262
+ return false;
263
+ }
264
+ })) {
265
+ return calls.map((call) => [{ call, parsed: lenientParse(call), parallelKey: null }]);
266
+ }
232
267
  const flush = () => {
233
268
  if (open.length > 0) {
234
269
  batches.push(open);
235
270
  open = [];
236
- keys.clear();
237
271
  openFiles.clear();
238
272
  }
239
273
  };
@@ -254,6 +288,15 @@ export function planBatches(calls) {
254
288
  malformed = true;
255
289
  }
256
290
  const name = typeof call?.function?.name === "string" ? call.function.name : "(unknown)";
291
+ // Extension tools carry no scheduler effect metadata: their footprint is
292
+ // invisible, so they always run as serial singletons (fail safe, exactly
293
+ // like the old allowlist-miss). Batch planning is never corrupted by
294
+ // what it cannot see; validation still runs in the loop, where failures
295
+ // become inline-error results.
296
+ if (isCustomTool(name)) {
297
+ singleton(call, parsed);
298
+ continue;
299
+ }
257
300
  // Missing metadata fails safe to serial (never batch the unknown).
258
301
  const meta = TOOL_EFFECTS[name];
259
302
  if (malformed || !meta || !toolNames().includes(name)) {
@@ -287,8 +330,9 @@ export function planBatches(calls) {
287
330
  open.push({ call, parsed, parallelKey: `${name} ${fileKey}` });
288
331
  continue;
289
332
  }
290
- // Reads batch on disjoint tool+target keys; empty target = unknown
291
- // footprint = serial.
333
+ // Reads always batch (parallel by default): a pure read has no
334
+ // observable footprint, so even the same tool + same target runs
335
+ // concurrently. Empty target = unknown footprint = serial.
292
336
  let target = null;
293
337
  try {
294
338
  target = meta.target(parsed);
@@ -301,10 +345,6 @@ export function planBatches(calls) {
301
345
  continue;
302
346
  }
303
347
  const key = `${name} ${target}`;
304
- if (keys.has(key)) {
305
- singleton(call, parsed);
306
- continue;
307
- }
308
348
  // A path read against an open write to the same canonical file stays
309
349
  // ordered (read-after-write): split the batch.
310
350
  if (name === "read") {
@@ -313,13 +353,11 @@ export function planBatches(calls) {
313
353
  singleton(call, parsed);
314
354
  continue;
315
355
  }
316
- keys.add(key);
317
356
  if (fileKey && !openFiles.has(fileKey))
318
357
  openFiles.set(fileKey, "read");
319
358
  open.push({ call, parsed, parallelKey: key });
320
359
  continue;
321
360
  }
322
- keys.add(key);
323
361
  open.push({ call, parsed, parallelKey: key });
324
362
  }
325
363
  flush();
package/dist/session.js CHANGED
@@ -6,10 +6,13 @@
6
6
  // cannot corrupt or clobber the last good save.
7
7
  //
8
8
  // Shape: {version:1, savedAt, provider, model, effort, mode, usageTotals,
9
+ // goal (ticket 07: the live session goal plus cumulative stats, or null),
9
10
  // history (full API history incl. system + tool pairs), turns (display
10
11
  // transcript)}. Writes are atomic (temp file + rename) to survive kills
11
12
  // mid-write. Loads never throw: missing -> "missing", anything malformed ->
12
- // "corrupt" (caller shows a one-line notice and starts fresh).
13
+ // "corrupt" (caller shows a one-line notice and starts fresh). A missing or
14
+ // corrupt goal degrades to no-goal (null) WITHOUT failing the load — the
15
+ // conversation still restores.
13
16
  //
14
17
  // Privacy: the file can contain pasted secrets if the user typed them as
15
18
  // chat. Never print its contents; never commit it (it lives under ~/.atom,
@@ -19,6 +22,7 @@ import * as path from "node:path";
19
22
  import { atomDir, getStoredBaseURL, loadAuth, resolveApiKey } from "./auth.js";
20
23
  import { chatEndpointFor, isProviderId, openaiCompatibleChatEndpoint, } from "./providers.js";
21
24
  import { EFFORT_OPTIONS, } from "./zen.js";
25
+ import { restoreGoalFromPersist, serializeGoalForPersist, } from "./goal.js";
22
26
  export const SESSION_VERSION = 1;
23
27
  export const SESSION_FILENAME = "session.json";
24
28
  export function sessionFilePath(home) {
@@ -74,6 +78,9 @@ export function saveSession(snapshot, home) {
74
78
  effort: snapshot.effort,
75
79
  mode: snapshot.mode,
76
80
  usageTotals: snapshot.usageTotals,
81
+ // Piggyback: the live goal rides every completed-turn save (no new save
82
+ // cadence — compaction and clean exit flow through here too).
83
+ goal: serializeGoalForPersist(snapshot.goal ?? null),
77
84
  history: snapshot.history.map((m) => ({ ...m })),
78
85
  turns: snapshot.turns.map((t) => ({ ...t })),
79
86
  };
@@ -210,9 +217,16 @@ function validateSession(data) {
210
217
  const model = data["model"];
211
218
  if (!isNonEmptyString(model))
212
219
  return null;
213
- const effort = data["effort"];
214
- if (typeof effort !== "string" ||
215
- !EFFORT_OPTIONS.includes(effort)) {
220
+ // "default" is the pre-auto name for the same level: old saves map to
221
+ // "auto" instead of failing the load.
222
+ const rawEffort = data["effort"];
223
+ const effort = rawEffort === "default"
224
+ ? "auto"
225
+ : typeof rawEffort === "string" &&
226
+ EFFORT_OPTIONS.includes(rawEffort)
227
+ ? rawEffort
228
+ : null;
229
+ if (effort === null) {
216
230
  return null;
217
231
  }
218
232
  const mode = data["mode"];
@@ -241,9 +255,13 @@ function validateSession(data) {
241
255
  savedAt,
242
256
  provider,
243
257
  model,
244
- effort: effort,
258
+ effort,
245
259
  mode,
246
260
  usageTotals: validateUsageTotals(data["usageTotals"]),
261
+ // Tolerant: a trashed goal degrades to no-goal (null) without failing
262
+ // the load — the conversation still restores. Re-serialized so the
263
+ // loaded record always carries concrete stats.
264
+ goal: serializeGoalForPersist(restoreGoalFromPersist(data["goal"])),
247
265
  history: history,
248
266
  turns: turns,
249
267
  };
package/dist/sessions.js CHANGED
@@ -19,18 +19,30 @@
19
19
  // import LLM clients.
20
20
  // - No transient UI state (scroll, cursor, picker, queue) is stored.
21
21
  //
22
- // Import budget: value imports are node:fs, node:path, node:crypto and
23
- // ./auth.js only (type-only imports from ./zen.js / ./providers.js are
24
- // erased at compile, so the runtime DAG stays acyclic and React-free).
22
+ // Import budget: value imports are node:fs, node:path, node:crypto,
23
+ // ./auth.js, and ./goal.js (goal persistence-shape helpers only —
24
+ // serialize/restore/validate; goal.js itself is React-free and imports no
25
+ // session module, so the runtime DAG stays acyclic) plus type-only imports
26
+ // from ./zen.js / ./providers.js / ./goal.js (erased at compile).
25
27
  import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
26
28
  import { randomUUID } from "node:crypto";
27
29
  import * as path from "node:path";
28
30
  import { atomDir } from "./auth.js";
31
+ import { restoreGoalFromPersist, serializeGoalForPersist, } from "./goal.js";
29
32
  // Literal defaults for new sessions (opaque carried fields — see header).
30
33
  export const SESSION_DEFAULT_PROVIDER = "opencode-zen";
31
34
  export const SESSION_DEFAULT_MODEL = "";
32
- export const SESSION_DEFAULT_EFFORT = "default";
35
+ export const SESSION_DEFAULT_EFFORT = "auto";
33
36
  export const SESSION_DEFAULT_MODE = "normal";
37
+ // "default" is the pre-auto name for the same level: old records map to
38
+ // "auto" on load/create instead of carrying a dead value. Local one-liner
39
+ // (not imported from zen.js) to honor this module's import budget above.
40
+ function canonicalSessionEffort(value) {
41
+ if (value === "low" || value === "medium" || value === "high" || value === "max") {
42
+ return value;
43
+ }
44
+ return "auto";
45
+ }
34
46
  export const SESSIONS_DIRNAME = "sessions";
35
47
  export const ACTIVE_FILENAME = "active";
36
48
  export function sessionsDir(home) {
@@ -254,9 +266,13 @@ function validateSessionRecord(data) {
254
266
  cwd: data["cwd"],
255
267
  provider: data["provider"],
256
268
  model: data["model"],
257
- effort: data["effort"],
269
+ effort: canonicalSessionEffort(data["effort"]),
258
270
  mode: data["mode"],
259
271
  usageTotals: validateUsageTotals(data["usageTotals"]),
272
+ // Tolerant: a missing or trashed goal reads as no-goal (null) — the
273
+ // record still loads, so one corrupt field can never strand a session.
274
+ // Re-serialized so the loaded record always carries concrete stats.
275
+ goal: serializeGoalForPersist(restoreGoalFromPersist(data["goal"])),
260
276
  history: history,
261
277
  turns: turns,
262
278
  metadata: isRecord(metadata) ? { ...metadata } : {},
@@ -311,11 +327,14 @@ export function createSession(opts = {}, home) {
311
327
  cwd: typeof opts.cwd === "string" ? opts.cwd : safeCwd(),
312
328
  provider: opts.provider ?? SESSION_DEFAULT_PROVIDER,
313
329
  model: opts.model ?? SESSION_DEFAULT_MODEL,
314
- effort: opts.effort ?? SESSION_DEFAULT_EFFORT,
330
+ effort: canonicalSessionEffort(opts.effort ?? SESSION_DEFAULT_EFFORT),
315
331
  mode: opts.mode ?? SESSION_DEFAULT_MODE,
316
332
  usageTotals: opts.usageTotals === undefined || opts.usageTotals === null
317
333
  ? null
318
334
  : validateUsageTotals(opts.usageTotals),
335
+ // Fresh sessions start with no goal unless the caller restores one
336
+ // (tolerantly validated — corrupt input reads as no-goal, never throws).
337
+ goal: serializeGoalForPersist(restoreGoalFromPersist(opts.goal ?? null)),
319
338
  history: (opts.history ?? []).map((m) => ({ ...m })),
320
339
  turns: (opts.turns ?? []).map((t) => ({ ...t })),
321
340
  metadata: isRecord(opts.metadata) ? { ...opts.metadata } : {},
@@ -243,6 +243,26 @@ function iterationTimeline(s) {
243
243
  });
244
244
  return `<div class="timeline">${rows.join("")}</div>`;
245
245
  }
246
+ // Goal fragment for one turn (ticket 09): rendered only when the trace
247
+ // carries a goal snapshot — absent or malformed reads as no-goal and renders
248
+ // nothing (never a fake claim). Counters show only when reported.
249
+ function goalFragment(t) {
250
+ const g = t.goal;
251
+ if (!g || typeof g.objective !== "string" || g.objective.length === 0)
252
+ return "";
253
+ const state = g.active === true ? "active" : "paused";
254
+ const bits = [];
255
+ if (typeof g.turns === "number")
256
+ bits.push(`${fmtCount(g.turns)} turn(s)`);
257
+ if (typeof g.requests === "number")
258
+ bits.push(`${fmtCount(g.requests)} request(s)`);
259
+ if (typeof g.tokens === "number")
260
+ bits.push(`${fmtCount(g.tokens)} tokens`);
261
+ if (typeof g.workMs === "number")
262
+ bits.push(fmtMs(g.workMs));
263
+ const counters = bits.length > 0 ? ` <span class="mute">(${bits.join(" · ")})</span>` : "";
264
+ return `<p><strong>Goal:</strong> <span class="mono">${escapeHtml(g.objective)}</span> <span class="pill">${state}</span>${counters}</p>`;
265
+ }
246
266
  function turnBlock(t) {
247
267
  const outcomeCls = t.outcome === "completed" ? "ok" : t.outcome === "failed" ? "err" : "warn";
248
268
  const total = turnTokensTotal(t);
@@ -272,6 +292,7 @@ function turnBlock(t) {
272
292
  <summary><span class="mono">${escapeHtml(t.id)}</span> · ${fmtTime(t.startedAt)} · ${escapeHtml(t.provider)} · ${escapeHtml(t.model)} · ${head}</summary>
273
293
  <div class="turnbody">
274
294
  <p><strong>Input:</strong> <span class="mono">${escapeHtml(t.inputPreview)}</span> <span class="mute">(${fmtCount(t.inputChars)} chars)</span></p>
295
+ ${goalFragment(t)}
275
296
  ${t.error ? `<p><strong>Error:</strong> <span class="err">${escapeHtml(t.error)}</span></p>` : ""}
276
297
  ${t.replyPreview ? `<p><strong>Reply:</strong> <span class="mono">${escapeHtml(t.replyPreview)}</span></p>` : ""}
277
298
  <h5>Timeline</h5>
@@ -361,6 +382,12 @@ export function buildDashboardHtml(sessions, opts = {}) {
361
382
  const successRate = agg.toolSuccessRate !== null
362
383
  ? `${(agg.toolSuccessRate * 100).toFixed(1)}%`
363
384
  : `<span class="na" title="No tool calls recorded — a rate over zero calls would be fake.">n/a</span>`;
385
+ // Goal overview (ticket 09): present only when at least one stored turn
386
+ // carried a goal snapshot — zero goal turns omit the card entirely (same
387
+ // conditional-render precedent as the corrupt-file note above).
388
+ const goalCard = agg.goalTurns > 0
389
+ ? `<div class="card"><div class="k">Goal turns</div><div class="v">${fmtCount(agg.goalTurns)}</div></div>`
390
+ : "";
364
391
  const refreshSeconds = typeof opts.refreshSeconds === "number" && Number.isFinite(opts.refreshSeconds)
365
392
  ? Math.floor(opts.refreshSeconds)
366
393
  : 0;
@@ -504,6 +531,7 @@ ${corruptNote}
504
531
  <div class="card"><div class="k">Completion tokens (reported)</div><div class="v">${fmtTokens(agg.usage.completion_tokens, agg.usageReported, "No model call reported completion_tokens.")}</div></div>
505
532
  <div class="card"><div class="k">Cache read / write (reported)</div><div class="v">${fmtTokens(agg.usage.cacheReadTokens, agg.usageReported, "No provider reported cache-read counters.")} / ${fmtTokens(agg.usage.cacheWriteTokens, agg.usageReported, "No provider reported cache-write counters.")}</div></div>
506
533
  <div class="card"><div class="k">Retries</div><div class="v">${fmtCount(agg.retries)}</div></div>
534
+ ${goalCard}
507
535
  <div class="card"><div class="k">Read-cache hits (local)</div><div class="v">${fmtCount(agg.cacheHits)}</div></div>
508
536
  <div class="card"><div class="k">Loop-guard hits</div><div class="v">${fmtCount(agg.repetitionHits)}</div></div>
509
537
  <div class="card"><div class="k">Avg model latency</div><div class="v">${fmtMs(agg.avgModelLatencyMs)}</div></div>
package/dist/telemetry.js CHANGED
@@ -111,6 +111,38 @@ export function cleanUsage(value) {
111
111
  ? out
112
112
  : undefined;
113
113
  }
114
+ // Keep only a well-formed goal snapshot (tolerant reader/writer pair with
115
+ // the dashboard: absent or malformed reads as no-goal, never a throw).
116
+ // Returns undefined when no goal was live. The objective is capped so a
117
+ // pasted paragraph cannot bloat the trace; counters copy finite values only.
118
+ export function cleanGoalSnapshot(value) {
119
+ if (typeof value !== "object" || value === null || Array.isArray(value))
120
+ return undefined;
121
+ const o = value;
122
+ const objective = o["objective"];
123
+ if (typeof objective !== "string" || objective.length === 0)
124
+ return undefined;
125
+ const snap = {
126
+ objective: objective.length > TELEMETRY_INPUT_PREVIEW_CHARS
127
+ ? objective.slice(0, TELEMETRY_INPUT_PREVIEW_CHARS)
128
+ : objective,
129
+ active: o["active"] === true,
130
+ };
131
+ const counter = (v) => typeof v === "number" && Number.isFinite(v) && v >= 0 ? Math.floor(v) : undefined;
132
+ const turns = counter(o["turns"]);
133
+ if (turns !== undefined)
134
+ snap.turns = turns;
135
+ const requests = counter(o["requests"]);
136
+ if (requests !== undefined)
137
+ snap.requests = requests;
138
+ const tokens = counter(o["tokens"]);
139
+ if (tokens !== undefined)
140
+ snap.tokens = tokens;
141
+ const workMs = counter(o["workMs"]);
142
+ if (workMs !== undefined)
143
+ snap.workMs = workMs;
144
+ return snap;
145
+ }
114
146
  export function addUsageInto(target, extra) {
115
147
  if (!extra)
116
148
  return false;
@@ -438,6 +470,7 @@ export function summarizeTelemetry(sessions) {
438
470
  retries: 0,
439
471
  cacheHits: 0,
440
472
  repetitionHits: 0,
473
+ goalTurns: 0,
441
474
  outcomes: emptyOutcomes(),
442
475
  byTool: [],
443
476
  avgModelLatencyMs: null,
@@ -462,6 +495,9 @@ export function summarizeTelemetry(sessions) {
462
495
  agg.turns += 1;
463
496
  if (t.outcome in agg.outcomes)
464
497
  agg.outcomes[t.outcome] += 1;
498
+ if (t.goal && typeof t.goal.objective === "string" && t.goal.objective.length > 0) {
499
+ agg.goalTurns += 1;
500
+ }
465
501
  if (t.usageReported) {
466
502
  if (addUsageInto(agg.usage, t.usage))
467
503
  agg.usageReported = true;
@@ -657,6 +693,9 @@ export class TelemetryRecorder {
657
693
  usageReported: false,
658
694
  retryCount: 0,
659
695
  };
696
+ const goalSnap = cleanGoalSnapshot(meta.goal);
697
+ if (goalSnap)
698
+ turn.goal = goalSnap;
660
699
  this.session.turns.push(turn);
661
700
  this.openTurns.set(id, turn);
662
701
  this.pendingRetries = [];