neoctl 0.2.42 → 0.2.44

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 (57) hide show
  1. package/dist/agents/agent-tool.d.ts +1 -0
  2. package/dist/agents/agent-tool.js +4 -3
  3. package/dist/agents/agent-tool.js.map +1 -1
  4. package/dist/context/prompts.js +2 -2
  5. package/dist/context/prompts.js.map +1 -1
  6. package/dist/core/image-registry.js +1 -1
  7. package/dist/core/image-registry.js.map +1 -1
  8. package/dist/core/message-pipeline.js +2 -1
  9. package/dist/core/message-pipeline.js.map +1 -1
  10. package/dist/core/query-engine.d.ts +8 -2
  11. package/dist/core/query-engine.js +28 -19
  12. package/dist/core/query-engine.js.map +1 -1
  13. package/dist/core/query-timing.d.ts +43 -0
  14. package/dist/core/query-timing.js +124 -0
  15. package/dist/core/query-timing.js.map +1 -0
  16. package/dist/core/query.d.ts +13 -0
  17. package/dist/core/query.js +150 -90
  18. package/dist/core/query.js.map +1 -1
  19. package/dist/core/run-agent.d.ts +3 -0
  20. package/dist/core/run-agent.js +12 -0
  21. package/dist/core/run-agent.js.map +1 -1
  22. package/dist/index.d.ts +2 -0
  23. package/dist/index.js +1 -0
  24. package/dist/index.js.map +1 -1
  25. package/dist/model/model-gateway.d.ts +6 -0
  26. package/dist/model/model-gateway.js.map +1 -1
  27. package/dist/model/openai-chat-mapper.js +6 -0
  28. package/dist/model/openai-chat-mapper.js.map +1 -1
  29. package/dist/model/openai-mappers.js +2 -2
  30. package/dist/model/openai-mappers.js.map +1 -1
  31. package/dist/model/openai-responses-mapper.js +6 -0
  32. package/dist/model/openai-responses-mapper.js.map +1 -1
  33. package/dist/plugins/plugin-system.d.ts +6 -0
  34. package/dist/plugins/plugin-system.js +82 -52
  35. package/dist/plugins/plugin-system.js.map +1 -1
  36. package/dist/session/session-store.d.ts +11 -0
  37. package/dist/session/session-store.js +24 -0
  38. package/dist/session/session-store.js.map +1 -1
  39. package/dist/tools/builtins/image-capabilities.d.ts +1 -0
  40. package/dist/tools/builtins/image-capabilities.js +2 -1
  41. package/dist/tools/builtins/image-capabilities.js.map +1 -1
  42. package/dist/tools/builtins/image-generation-tool.js +44 -17
  43. package/dist/tools/builtins/image-generation-tool.js.map +1 -1
  44. package/dist/tools/registry.d.ts +2 -0
  45. package/dist/tools/registry.js +11 -0
  46. package/dist/tools/registry.js.map +1 -1
  47. package/dist/tools/tool-orchestration.d.ts +5 -0
  48. package/dist/tools/tool-orchestration.js +1 -0
  49. package/dist/tools/tool-orchestration.js.map +1 -1
  50. package/dist/types/events.d.ts +10 -0
  51. package/dist/web/agent-content-detail.d.ts +2 -2
  52. package/dist/web/agent-content-detail.js +1 -1
  53. package/dist/web/index.d.ts +31 -2
  54. package/dist/web/index.js +159 -33
  55. package/dist/web/index.js.map +1 -1
  56. package/dist/web/tool-call-detail.js +1 -1
  57. package/package.json +1 -1
@@ -141,7 +141,7 @@ function part(owner, v) {
141
141
  if (v === undefined)
142
142
  return absent();
143
143
  const isTruncated = truncated(v);
144
- return { state: isTruncated ? 'truncated' : 'complete', text: text(sanitize(owner, v)), reason: isTruncated ? '保存的源数据已截断,不是全文' : '已保存内容的脱敏全文' };
144
+ return { state: isTruncated ? 'truncated' : 'complete', text: text(sanitize(owner, v)), reason: isTruncated ? '保存的源数据已截断,不是全文' : '已保存内容的原始全文' };
145
145
  }
146
146
  function preview(p, max = 2048) {
147
147
  return p.text.length <= max ? p : { state: 'truncated', text: p.text.slice(0, max), reason: '摘要明确截断;不作为全文' };
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import type { TimingRecord } from "../core/query-timing.js";
2
3
  import { type ImageResultMetadata } from "./image-result-metadata.js";
3
4
  import { type IncomingMessage, type ServerResponse } from "node:http";
4
5
  import { QueryEngine } from "../core/query-engine.js";
@@ -41,7 +42,14 @@ export interface WebRuntimePluginDefinition extends Pick<NeoPluginResource, "id"
41
42
  promptSections?: readonly PromptSection[];
42
43
  presentToolResult?: NeoPluginResource["presentToolResult"];
43
44
  }
45
+ export interface WebPluginSnapshot {
46
+ plugins: readonly WebRuntimePluginDefinition[];
47
+ release: () => void;
48
+ }
44
49
  interface WebRuntimePluginSupport {
50
+ resolveCatalog?: () => readonly WebRuntimePluginDefinition[];
51
+ acquire?: (overrides: Readonly<Record<string, boolean>>) => WebPluginSnapshot;
52
+ pendingOverrides?: Record<string, boolean>;
45
53
  catalog: WebRuntimePluginDefinition[];
46
54
  overrides: Record<string, boolean>;
47
55
  basePromptSections: PromptSection[];
@@ -161,6 +169,7 @@ interface UiLine {
161
169
  toolPresentation?: ToolPresentation;
162
170
  parentToolName?: string;
163
171
  toolUseId?: string;
172
+ timing?: TimingRecord;
164
173
  parentToolUseId?: string;
165
174
  title?: string;
166
175
  bodyTitle?: string;
@@ -188,6 +197,15 @@ type WebActionResult<T extends object = object> = ({
188
197
  ok: true;
189
198
  } & T) | WebActionFailure;
190
199
  interface UiStatus {
200
+ modelOutput?: {
201
+ kind: "text";
202
+ } | {
203
+ kind: "tool_call";
204
+ callId: string;
205
+ name?: string;
206
+ };
207
+ queryTiming?: TimingRecord;
208
+ toolTimings?: TimingRecord[];
191
209
  phase: string;
192
210
  detail?: string;
193
211
  currentTool?: {
@@ -234,6 +252,9 @@ export interface CreateWebRuntimeOptions {
234
252
  plugins?: readonly string[];
235
253
  /** Switchable plugins supplied by the embedding application. */
236
254
  externalPlugins?: readonly WebRuntimePluginDefinition[];
255
+ resolveExternalPlugins?: WebRuntimePluginSupport["resolveCatalog"];
256
+ acquirePluginSnapshot?: WebRuntimePluginSupport["acquire"];
257
+ reservePluginToolNames?: (names: readonly string[]) => void;
237
258
  /** Per-session plugin overrides. Missing ids inherit the global setting. */
238
259
  sessionPluginOverrides?: Readonly<Record<string, boolean>>;
239
260
  persistSessionPluginOverrides?: WebRuntimePluginSupport["persist"];
@@ -312,6 +333,7 @@ type WebLineOperation = {
312
333
  };
313
334
  export declare function runWebServer(argv?: string[], runtimeOptions?: RunWebServerOptions): Promise<void>;
314
335
  export declare function createWebRuntime(options?: CreateWebRuntimeOptions): Promise<WebRuntime>;
336
+ export declare function refreshRuntimePlugins(runtime: WebRuntime, engine?: QueryEngine): void;
315
337
  /** Called only at a safe turn boundary or while idle, never during tool execution. */
316
338
  export declare function refreshGlobalToolOverrides(runtime: Pick<WebRuntime, "tools" | "toolSupport">): void;
317
339
  /** Bind only the foreground view; never stop work owned by another session. */
@@ -438,7 +460,7 @@ export declare class WebRepl {
438
460
  steps: {
439
461
  id: string;
440
462
  title: string;
441
- status: "completed" | "running" | "failed";
463
+ status: "running" | "completed" | "failed";
442
464
  }[] | undefined;
443
465
  };
444
466
  pendingMessageCount: number;
@@ -566,7 +588,7 @@ export declare class WebRepl {
566
588
  steps: {
567
589
  id: string;
568
590
  title: string;
569
- status: "completed" | "running" | "failed";
591
+ status: "running" | "completed" | "failed";
570
592
  }[] | undefined;
571
593
  };
572
594
  pendingMessageCount: number;
@@ -651,6 +673,12 @@ export declare class WebRepl {
651
673
  sessionPlugins(): {
652
674
  sessionId: string | undefined;
653
675
  busy: boolean;
676
+ items: never[];
677
+ pending?: undefined;
678
+ } | {
679
+ sessionId: string | undefined;
680
+ busy: boolean;
681
+ pending: boolean;
654
682
  items: {
655
683
  id: string;
656
684
  name: string;
@@ -791,6 +819,7 @@ export declare class WebRepl {
791
819
  private backgroundTasks;
792
820
  private detachRunningForeground;
793
821
  private reattachRunningSession;
822
+ private timedStatus;
794
823
  private reduce;
795
824
  private finalizeLiveLine;
796
825
  private finalizeThinkingLine;
package/dist/web/index.js CHANGED
@@ -183,6 +183,9 @@ export async function createWebRuntime(options = {}) {
183
183
  };
184
184
  for (const tool of createSubagentTools(taskStore, resumeHandler))
185
185
  registerTool(tool, { source: "builtin" });
186
+ const pluginToolNames = new Set(pluginCatalog.flatMap(plugin => plugin.tools.map(tool => tool.name)));
187
+ options.reservePluginToolNames?.(tools.list(undefined, { includeDeferred: true }).filter(tool => !pluginToolNames.has(tool.name)).flatMap(tool => [tool.name, ...(tool.aliases ?? [])]).concat(toolCatalog.filter(tool => tool.source !== "plugin").map(tool => tool.name)));
188
+ agentRuntime.acquireTurnResources = (sessionId) => acquireWebPluginTurn(runtime, false, runtime.engine, sessionId);
186
189
  const globalToolOverrides = normalizeToolOverrides(options.globalToolOverrides, toolCatalog);
187
190
  const sessionToolOverrides = normalizeToolOverrides(options.sessionToolOverrides, toolCatalog);
188
191
  applyToolOverrides(tools, toolCatalog, globalToolOverrides, sessionToolOverrides);
@@ -196,7 +199,10 @@ export async function createWebRuntime(options = {}) {
196
199
  modelGateway,
197
200
  tools,
198
201
  contextManagerFactory: (cwd) => new DefaultContextManager({ cwd }),
199
- refreshTools: () => {
202
+ acquireTurnResources: (engine) => acquireWebPluginTurn(runtime, true, engine),
203
+ refreshTools: (engine) => {
204
+ if (runtime)
205
+ refreshRuntimePlugins(runtime, engine);
200
206
  if (!options.resolveGlobalToolOverrides)
201
207
  return;
202
208
  if (runtime)
@@ -239,14 +245,16 @@ export async function createWebRuntime(options = {}) {
239
245
  defaultReasoning: modelConfig?.defaultReasoning,
240
246
  envPath: process.env.NEO_ENV_FILE?.trim() ? path.resolve(process.env.NEO_ENV_FILE.trim()) : envLoad.userDotEnvPath,
241
247
  envNotice: envLoad.createdUserDotEnv ? formatCreatedEnvNotice(envLoad.userDotEnvPath) : undefined,
242
- pluginSupport: pluginCatalog.length ? {
248
+ pluginSupport: {
249
+ resolveCatalog: options.resolveExternalPlugins,
250
+ acquire: options.acquirePluginSnapshot,
243
251
  catalog: pluginCatalog,
244
252
  overrides: pluginOverrides,
245
253
  basePromptSections: [...(options.externalPromptSections ?? [])],
246
254
  basePluginIds: [...(options.plugins ?? [])],
247
255
  persist: options.persistSessionPluginOverrides,
248
256
  resolve: options.resolveSessionPluginOverrides,
249
- } : undefined,
257
+ },
250
258
  toolSupport: {
251
259
  catalog: toolCatalog,
252
260
  globalOverrides: globalToolOverrides,
@@ -259,6 +267,92 @@ export async function createWebRuntime(options = {}) {
259
267
  };
260
268
  return runtime;
261
269
  }
270
+ /** Build first, publish only after all registrations validate. Never called by a child on the parent registry. */
271
+ function buildPluginRegistry(runtime, catalog, overrides) {
272
+ const registry = runtime.tools.clone();
273
+ for (const plugin of runtime.pluginSupport?.catalog ?? [])
274
+ for (const tool of plugin.tools)
275
+ registry.unregister(tool.name);
276
+ const names = new Set(registry.names({ includeDisabled: true }));
277
+ for (const plugin of catalog)
278
+ for (const tool of plugin.tools) {
279
+ if (names.has(tool.name))
280
+ throw new Error(`duplicate plugin/host tool: ${tool.name}`);
281
+ names.add(tool.name);
282
+ }
283
+ for (const plugin of activeWebRuntimePlugins(catalog, overrides))
284
+ for (const tool of plugin.tools)
285
+ registry.register(tool);
286
+ const support = runtime.toolSupport;
287
+ if (support) {
288
+ const global = support.resolveGlobal?.() ?? support.globalOverrides;
289
+ const all = [...support.catalog.filter(tool => tool.source !== "plugin"), ...catalog.flatMap(plugin => plugin.tools.map(tool => ({ name: tool.name })))];
290
+ for (const tool of all)
291
+ registry.setEnabled(tool.name, support.sessionOverrides[tool.name] ?? global[tool.name] ?? isToolEnabledByDefault(tool.name));
292
+ }
293
+ return registry;
294
+ }
295
+ export function refreshRuntimePlugins(runtime, engine = runtime.engine) {
296
+ const support = runtime.pluginSupport;
297
+ if (!support)
298
+ return;
299
+ const catalog = normalizeWebRuntimePlugins(support.resolveCatalog?.() ?? support.catalog);
300
+ const overrides = normalizePluginOverrides(resolveRuntimePluginOverrides(runtime, engine), catalog);
301
+ const registry = buildPluginRegistry(runtime, catalog, overrides);
302
+ // Only plugin registrations change. Built-in objects (including task managers) retain identity.
303
+ for (const plugin of support.catalog)
304
+ for (const tool of plugin.tools)
305
+ runtime.tools.unregister(tool.name);
306
+ const active = activeWebRuntimePlugins(catalog, overrides);
307
+ for (const plugin of active)
308
+ for (const tool of plugin.tools) {
309
+ runtime.tools.register(tool);
310
+ runtime.tools.setEnabled(tool.name, registry.isEnabled(tool.name) === true);
311
+ }
312
+ support.catalog = catalog;
313
+ support.overrides = overrides;
314
+ support.pendingOverrides = undefined;
315
+ if (runtime.toolSupport)
316
+ runtime.toolSupport.catalog = [
317
+ ...runtime.toolSupport.catalog.filter(tool => tool.source !== "plugin"),
318
+ ...catalog.flatMap(plugin => plugin.tools.map(tool => ({ name: tool.name, source: "plugin", pluginId: plugin.id, pluginName: plugin.name, presentation: toolPresentationForSource(tool.name, "plugin") }))),
319
+ ];
320
+ engine.setRuntimePlugins([...support.basePluginIds, ...active.map(plugin => plugin.id)], [...support.basePromptSections, ...active.flatMap(plugin => plugin.promptSections ?? [])]);
321
+ }
322
+ function resolveRuntimePluginOverrides(runtime, engine, sessionId = engine.snapshot().session?.sessionId) {
323
+ const support = runtime.pluginSupport;
324
+ if (!support)
325
+ return {};
326
+ if (engine === runtime.engine && sessionId === engine.snapshot().session?.sessionId && support.pendingOverrides)
327
+ return support.pendingOverrides;
328
+ const resolved = sessionId ? support.resolve?.(sessionId) : undefined;
329
+ // Web hosts publish in-memory settings synchronously. Legacy async embedders retain loaded overrides.
330
+ if (resolved && !(resolved instanceof Promise))
331
+ return resolved;
332
+ if (resolved instanceof Promise)
333
+ void resolved.catch(() => { });
334
+ return support.overrides;
335
+ }
336
+ /** Children acquire independent tools/prompts; they never refresh the parent's in-flight context. */
337
+ function acquireWebPluginTurn(runtime, main, engine = runtime.engine, sessionId) {
338
+ if (main) {
339
+ refreshRuntimePlugins(runtime, engine);
340
+ refreshGlobalToolOverrides(runtime);
341
+ }
342
+ const support = runtime.pluginSupport;
343
+ const overrides = resolveRuntimePluginOverrides(runtime, engine, sessionId);
344
+ const snapshot = support?.acquire?.(overrides);
345
+ try {
346
+ const catalog = normalizeWebRuntimePlugins(snapshot?.plugins ?? support?.resolveCatalog?.() ?? support?.catalog);
347
+ const tools = buildPluginRegistry(runtime, catalog, overrides);
348
+ return { tools, release: snapshot?.release ?? (() => { }),
349
+ ...(!main ? { promptSections: [...(support?.basePromptSections ?? []), ...activeWebRuntimePlugins(catalog, overrides).flatMap(plugin => plugin.promptSections ?? [])] } : {}) };
350
+ }
351
+ catch (error) {
352
+ snapshot?.release();
353
+ throw error;
354
+ }
355
+ }
262
356
  function normalizeWebRuntimePlugins(value) {
263
357
  if (!Array.isArray(value))
264
358
  return [];
@@ -545,7 +639,7 @@ export class WebRepl {
545
639
  }
546
640
  return {
547
641
  lines: this.lines.map(line => line.toolError ? { ...line, toolError: this.runtime.engine.redactDisplayValue?.(line.toolError) ?? line.toolError } : line),
548
- status: this.status,
642
+ status: this.timedStatus(),
549
643
  busy: this.busy,
550
644
  queuedInput: this.queuedInput,
551
645
  backgroundTaskCount: backgroundTasks.length,
@@ -567,6 +661,7 @@ export class WebRepl {
567
661
  return this.buildRuntimeContext(this.runtimeContextRevision);
568
662
  }
569
663
  sessionPlugins() {
664
+ this.runtime.engine.refreshToolsIfIdle();
570
665
  const support = this.runtime.pluginSupport;
571
666
  const sessionId = this.runtime.engine.snapshot().session?.sessionId;
572
667
  if (!support)
@@ -574,8 +669,9 @@ export class WebRepl {
574
669
  return {
575
670
  sessionId,
576
671
  busy: this.busy,
577
- items: support.catalog.map((plugin) => {
578
- const override = support.overrides[plugin.id];
672
+ pending: Boolean(support.pendingOverrides),
673
+ items: (support.resolveCatalog?.() ?? support.catalog).map((plugin) => {
674
+ const override = (support.pendingOverrides ?? support.overrides)[plugin.id];
579
675
  return {
580
676
  id: plugin.id,
581
677
  name: plugin.name,
@@ -684,14 +780,12 @@ export class WebRepl {
684
780
  await this.applySessionToolOverrides(await support.resolveSession(sessionId), false);
685
781
  }
686
782
  async setSessionPlugins(value) {
687
- if (this.busy)
688
- return actionFailure("PLUGIN_UPDATE_BLOCKED", "cannot change session plugins while a response is running");
689
783
  if (!isRecord(value))
690
784
  return actionFailure("INVALID_REQUEST", "plugin overrides must be an object");
691
785
  const support = this.runtime.pluginSupport;
692
786
  if (!support)
693
787
  return actionFailure("PLUGIN_NOT_CONFIGURED", "web plugins are not configured");
694
- const available = new Set(support.catalog.map((plugin) => plugin.id));
788
+ const available = new Set((support.resolveCatalog?.() ?? support.catalog).map((plugin) => plugin.id));
695
789
  const overrides = {};
696
790
  for (const [id, mode] of Object.entries(value)) {
697
791
  if (!available.has(id))
@@ -781,22 +875,18 @@ export class WebRepl {
781
875
  const support = this.runtime.pluginSupport;
782
876
  if (!support)
783
877
  return;
784
- const normalized = normalizePluginOverrides(overrides, support.catalog);
785
- for (const plugin of support.catalog)
786
- for (const tool of plugin.tools)
787
- this.runtime.tools.unregister(tool.name);
788
- const activePlugins = activeWebRuntimePlugins(support.catalog, normalized);
789
- for (const plugin of activePlugins)
790
- for (const tool of plugin.tools)
791
- this.runtime.tools.register(tool);
792
- support.overrides = normalized;
793
- const toolSupport = this.runtime.toolSupport;
794
- if (toolSupport)
795
- applyToolOverrides(this.runtime.tools, toolSupport.catalog, toolSupport.globalOverrides, toolSupport.sessionOverrides);
796
- this.runtime.engine.setRuntimePlugins([...support.basePluginIds, ...activePlugins.map((plugin) => plugin.id)], [...support.basePromptSections, ...activePlugins.flatMap((plugin) => plugin.promptSections ?? [])]);
878
+ const catalog = normalizeWebRuntimePlugins(support.resolveCatalog?.() ?? support.catalog);
879
+ const normalized = normalizePluginOverrides(overrides, catalog);
880
+ buildPluginRegistry(this.runtime, catalog, normalized);
797
881
  const sessionId = this.runtime.engine.snapshot().session?.sessionId;
798
882
  if (persist && sessionId && support.persist)
799
883
  await support.persist(sessionId, normalized);
884
+ support.pendingOverrides = normalized;
885
+ if (this.busy) {
886
+ this.publishRuntimeContext();
887
+ return;
888
+ }
889
+ refreshRuntimePlugins(this.runtime);
800
890
  const metrics = await this.runtime.engine.contextMetrics();
801
891
  this.runtime.initialMetrics = metrics;
802
892
  this.setStatus({ ...this.status, metrics, activityTick: this.status.activityTick + 1 });
@@ -1406,6 +1496,14 @@ export class WebRepl {
1406
1496
  this.setBusy(true);
1407
1497
  this.setStatus({ ...this.status, phase: "running", detail: "working" });
1408
1498
  }
1499
+ timedStatus() {
1500
+ const records = this.runtime.engine.getTimingRecords?.() ?? [];
1501
+ if (records.length === 0)
1502
+ return this.status;
1503
+ const queryTiming = records.filter(record => record.kind === "query").at(-1);
1504
+ return { ...this.status, queryTiming,
1505
+ toolTimings: records.filter(record => record.kind === "tool" && record.runId === queryTiming?.runId) };
1506
+ }
1409
1507
  reduce(event) {
1410
1508
  this.status = reduceStatus(this.status, event);
1411
1509
  if (event.type === "usage")
@@ -1566,6 +1664,18 @@ export class WebRepl {
1566
1664
  }
1567
1665
  handleEvent(event) {
1568
1666
  this.reduce(event);
1667
+ if (event.type === "timing.updated") {
1668
+ if (event.timing.kind === "tool") {
1669
+ const line = this.lines.find(item => item.timing?.id === event.timing.id)
1670
+ ?? this.lines.find(item => item.id === this.liveToolLineIds.get(event.timing.toolUseId));
1671
+ if (line) {
1672
+ this.lines = this.lines.map(item => item.id === line.id ? { ...item, timing: event.timing } : item);
1673
+ this.queueDeltaOperation({ type: "line.patch", id: line.id, patch: { timing: event.timing } });
1674
+ }
1675
+ }
1676
+ this.queueDeltaStatus();
1677
+ return;
1678
+ }
1569
1679
  if (event.type === "message" && this.matchesPendingUserImageEcho(event.message)) {
1570
1680
  const messageId = this.pendingUserImageEchoMessageId;
1571
1681
  this.pendingUserImageEchoIds = undefined;
@@ -1574,7 +1684,7 @@ export class WebRepl {
1574
1684
  this.broadcastSync();
1575
1685
  return;
1576
1686
  }
1577
- if (event.type === "tool_call.delta") {
1687
+ if (event.type === "tool_call.started" || event.type === "tool_call.delta") {
1578
1688
  this.queueDeltaStatus();
1579
1689
  return;
1580
1690
  }
@@ -1819,7 +1929,7 @@ export class WebRepl {
1819
1929
  this.activeAbortController = abortController;
1820
1930
  this.interruptArmed = false;
1821
1931
  this.setBusy(true);
1822
- this.setStatus({ ...this.status, phase: "running", detail: "working", usage: undefined, streamedOutputTokens: 0, inputTokenUpdatedAt: undefined, outputTokenUpdatedAt: undefined, retryCooldownUntil: undefined });
1932
+ this.setStatus({ ...this.status, phase: "running", detail: "working", modelOutput: undefined, usage: undefined, streamedOutputTokens: 0, inputTokenUpdatedAt: undefined, outputTokenUpdatedAt: undefined, retryCooldownUntil: undefined });
1823
1933
  const engine = this.runtime.engine;
1824
1934
  try {
1825
1935
  for await (const event of engine.sendUserText(promptPayload.text, {
@@ -1833,7 +1943,7 @@ export class WebRepl {
1833
1943
  if (this.runtime.engine !== engine)
1834
1944
  continue;
1835
1945
  if (this.suppressReattachedStreaming.has(engine)) {
1836
- if (event.type === "message" || event.type === "terminal" || event.type === "error" || event.type === "context.metrics" || event.type === "usage") {
1946
+ if (event.type === "message" || event.type === "terminal" || event.type === "error" || event.type === "context.metrics" || event.type === "usage" || event.type === "timing.updated") {
1837
1947
  if (event.type === "message" || event.type === "terminal" || event.type === "error")
1838
1948
  this.suppressReattachedStreaming.delete(engine);
1839
1949
  this.handleEvent(event);
@@ -2003,7 +2113,7 @@ export class WebRepl {
2003
2113
  protocolVersion: 2,
2004
2114
  sessionId: this.runtime.engine.snapshot().session?.sessionId,
2005
2115
  operations,
2006
- status: this.status,
2116
+ status: this.timedStatus(),
2007
2117
  };
2008
2118
  for (const subscriber of this.subscribers)
2009
2119
  this.send(subscriber, "delta", payload);
@@ -2377,6 +2487,14 @@ function restoredHistoryLines(runtime) {
2377
2487
  historyToolLineIndexes.set(line.toolUseId, lines.length - 1);
2378
2488
  return lines.length;
2379
2489
  };
2490
+ const toolTimings = new Map();
2491
+ for (const record of runtime.engine.getTimingRecords?.() ?? []) {
2492
+ if (record.kind !== "tool" || !record.toolUseId)
2493
+ continue;
2494
+ const matches = toolTimings.get(record.toolUseId) ?? [];
2495
+ matches.push(record);
2496
+ toolTimings.set(record.toolUseId, matches);
2497
+ }
2380
2498
  const entries = runtime.engine.getDisplayEntries();
2381
2499
  let lastCompactionIndex = -1;
2382
2500
  for (let index = entries.length - 1; index >= 0; index -= 1) {
@@ -2397,6 +2515,12 @@ function restoredHistoryLines(runtime) {
2397
2515
  const report = entry.report ?? legacyCompactionReport(entry.reason);
2398
2516
  append(compactionLine(report, entry.createdAt, entryIndex === lastCompactionIndex));
2399
2517
  }
2518
+ // Legacy histories can reuse call IDs. Ambiguous associations stay unknown.
2519
+ for (const line of lines) {
2520
+ const matches = line.toolUseId ? toolTimings.get(line.toolUseId) : undefined;
2521
+ if (matches?.length === 1)
2522
+ line.timing = matches[0];
2523
+ }
2400
2524
  return lines;
2401
2525
  }
2402
2526
  function initialStatus(runtime, metrics = runtime.initialMetrics) {
@@ -2544,7 +2668,7 @@ function toolStreamStepStatus(phase) {
2544
2668
  }
2545
2669
  function reduceStatus(status, event) {
2546
2670
  if (event.type === "state")
2547
- return { ...status, phase: event.phase, detail: event.detail, currentTool: event.phase === "running_tools" ? status.currentTool : undefined, usage: event.phase === "preparing" ? undefined : status.usage, streamedOutputTokens: event.phase === "preparing" ? 0 : status.streamedOutputTokens, inputTokenUpdatedAt: event.phase === "preparing" ? undefined : status.inputTokenUpdatedAt, outputTokenUpdatedAt: event.phase === "preparing" ? undefined : status.outputTokenUpdatedAt, retryCooldownUntil: event.phase === "preparing" ? undefined : status.retryCooldownUntil, activityTick: status.activityTick + 1 };
2671
+ return { ...status, phase: event.phase, detail: event.detail, modelOutput: undefined, currentTool: event.phase === "running_tools" ? status.currentTool : undefined, usage: event.phase === "preparing" ? undefined : status.usage, streamedOutputTokens: event.phase === "preparing" ? 0 : status.streamedOutputTokens, inputTokenUpdatedAt: event.phase === "preparing" ? undefined : status.inputTokenUpdatedAt, outputTokenUpdatedAt: event.phase === "preparing" ? undefined : status.outputTokenUpdatedAt, retryCooldownUntil: event.phase === "preparing" ? undefined : status.retryCooldownUntil, activityTick: status.activityTick + 1 };
2548
2672
  if (event.type === "context.compacted")
2549
2673
  return { ...status, phase: "compacting", detail: formatCompactionReportSummary(event.compaction), activityTick: status.activityTick + 1 };
2550
2674
  if (event.type === "context.metrics")
@@ -2552,21 +2676,23 @@ function reduceStatus(status, event) {
2552
2676
  if (event.type === "usage")
2553
2677
  return { ...status, usage: event.usage, inputTokenUpdatedAt: event.usage.inputTokens !== undefined ? Date.now() : status.inputTokenUpdatedAt, outputTokenUpdatedAt: event.usage.outputTokens !== undefined ? Date.now() : status.outputTokenUpdatedAt, activityTick: status.activityTick + 1 };
2554
2678
  if (event.type === "assistant.delta")
2555
- return { ...status, phase: "calling_model", streamedOutputTokens: status.streamedOutputTokens + estimateTokens(event.text), outputTokenUpdatedAt: Date.now(), activityTick: status.activityTick + 1 };
2679
+ return { ...status, phase: "calling_model", modelOutput: { kind: "text" }, streamedOutputTokens: status.streamedOutputTokens + estimateTokens(event.text), outputTokenUpdatedAt: Date.now(), activityTick: status.activityTick + 1 };
2556
2680
  if (event.type === "thinking.delta")
2557
- return { ...status, phase: "thinking", streamedOutputTokens: status.streamedOutputTokens + estimateTokens(event.text), outputTokenUpdatedAt: Date.now(), activityTick: status.activityTick + 1 };
2681
+ return { ...status, phase: "thinking", modelOutput: undefined, streamedOutputTokens: status.streamedOutputTokens + estimateTokens(event.text), outputTokenUpdatedAt: Date.now(), activityTick: status.activityTick + 1 };
2682
+ if (event.type === "tool_call.started")
2683
+ return { ...status, phase: "calling_model", modelOutput: { kind: "tool_call", callId: event.callId, name: event.name }, activityTick: status.activityTick + 1 };
2558
2684
  if (event.type === "tool_call.delta")
2559
- return { ...status, phase: "calling_model", streamedOutputTokens: status.streamedOutputTokens + estimateTokens(event.argumentsDelta), outputTokenUpdatedAt: Date.now(), activityTick: status.activityTick + 1 };
2685
+ return { ...status, phase: "calling_model", modelOutput: { kind: "tool_call", callId: event.callId, name: event.name ?? (status.modelOutput?.kind === "tool_call" && status.modelOutput.callId === event.callId ? status.modelOutput.name : undefined) }, streamedOutputTokens: status.streamedOutputTokens + estimateTokens(event.argumentsDelta), outputTokenUpdatedAt: Date.now(), activityTick: status.activityTick + 1 };
2560
2686
  if (event.type === "retrying")
2561
- return { ...status, phase: "calling_model", detail: `retrying in ${(event.delayMs / 1000).toFixed(1)}s`, retryCooldownUntil: Date.now() + event.delayMs, activityTick: status.activityTick + 1 };
2687
+ return { ...status, phase: "calling_model", modelOutput: undefined, detail: `retrying in ${(event.delayMs / 1000).toFixed(1)}s`, retryCooldownUntil: Date.now() + event.delayMs, activityTick: status.activityTick + 1 };
2562
2688
  if (event.type === "tool.started")
2563
- return { ...status, phase: "running_tools", currentTool: { id: event.toolUse.id, name: event.toolUse.name, input: event.toolUse.input, startedAt: Date.now() }, activityTick: status.activityTick + 1 };
2689
+ return { ...status, phase: "running_tools", modelOutput: undefined, currentTool: { id: event.toolUse.id, name: event.toolUse.name, input: event.toolUse.input, startedAt: Date.now() }, activityTick: status.activityTick + 1 };
2564
2690
  if (event.type === "tool.progress")
2565
2691
  return { ...status, phase: "running_tools", detail: event.progress.message || status.detail, currentTool: { id: event.toolUse.id, name: event.toolUse.name, input: event.toolUse.input, startedAt: status.currentTool?.id === event.toolUse.id ? status.currentTool.startedAt : Date.now() }, activityTick: status.activityTick + 1 };
2566
2692
  if (event.type === "tool.result.available" || event.type === "tool.finished" || event.type === "tool.batch.completed")
2567
2693
  return { ...status, currentTool: "toolUse" in event && status.currentTool?.id === event.toolUse.id ? undefined : status.currentTool, activityTick: status.activityTick + 1 };
2568
2694
  if (event.type === "terminal")
2569
- return { ...status, phase: "stopped", detail: event.reason, currentTool: undefined, inputTokenUpdatedAt: undefined, outputTokenUpdatedAt: undefined, retryCooldownUntil: undefined, activityTick: status.activityTick + 1 };
2695
+ return { ...status, phase: "stopped", modelOutput: undefined, detail: event.reason, currentTool: undefined, inputTokenUpdatedAt: undefined, outputTokenUpdatedAt: undefined, retryCooldownUntil: undefined, activityTick: status.activityTick + 1 };
2570
2696
  if (event.type === "message" || event.type === "error")
2571
2697
  return { ...status, activityTick: status.activityTick + 1 };
2572
2698
  return status;