@agentick/core 0.7.0 → 0.9.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 (120) hide show
  1. package/dist/.tsbuildinfo.build +1 -1
  2. package/dist/agentick-instance.d.ts.map +1 -1
  3. package/dist/agentick-instance.js +28 -0
  4. package/dist/agentick-instance.js.map +1 -1
  5. package/dist/app/inbox-storage.d.ts +24 -0
  6. package/dist/app/inbox-storage.d.ts.map +1 -0
  7. package/dist/app/inbox-storage.js +99 -0
  8. package/dist/app/inbox-storage.js.map +1 -0
  9. package/dist/app/session.d.ts +23 -2
  10. package/dist/app/session.d.ts.map +1 -1
  11. package/dist/app/session.js +308 -26
  12. package/dist/app/session.js.map +1 -1
  13. package/dist/app/types.d.ts +97 -0
  14. package/dist/app/types.d.ts.map +1 -1
  15. package/dist/app.d.ts +2 -1
  16. package/dist/app.d.ts.map +1 -1
  17. package/dist/app.js +1 -0
  18. package/dist/app.js.map +1 -1
  19. package/dist/com/object-model.d.ts +9 -3
  20. package/dist/com/object-model.d.ts.map +1 -1
  21. package/dist/com/object-model.js +52 -24
  22. package/dist/com/object-model.js.map +1 -1
  23. package/dist/compiler/collector.d.ts.map +1 -1
  24. package/dist/compiler/collector.js +179 -11
  25. package/dist/compiler/collector.js.map +1 -1
  26. package/dist/component/component.d.ts +6 -0
  27. package/dist/component/component.d.ts.map +1 -1
  28. package/dist/component/component.js.map +1 -1
  29. package/dist/engine/tool-executor.d.ts +15 -1
  30. package/dist/engine/tool-executor.d.ts.map +1 -1
  31. package/dist/engine/tool-executor.js +64 -6
  32. package/dist/engine/tool-executor.js.map +1 -1
  33. package/dist/hooks/execution-context.d.ts +23 -0
  34. package/dist/hooks/execution-context.d.ts.map +1 -0
  35. package/dist/hooks/execution-context.js +33 -0
  36. package/dist/hooks/execution-context.js.map +1 -0
  37. package/dist/hooks/expandable.d.ts +29 -2
  38. package/dist/hooks/expandable.d.ts.map +1 -1
  39. package/dist/hooks/expandable.js +28 -7
  40. package/dist/hooks/expandable.js.map +1 -1
  41. package/dist/hooks/gate.d.ts +27 -0
  42. package/dist/hooks/gate.d.ts.map +1 -0
  43. package/dist/hooks/gate.js +59 -0
  44. package/dist/hooks/gate.js.map +1 -0
  45. package/dist/hooks/index.d.ts +2 -0
  46. package/dist/hooks/index.d.ts.map +1 -1
  47. package/dist/hooks/index.js +4 -0
  48. package/dist/hooks/index.js.map +1 -1
  49. package/dist/index.d.ts +3 -3
  50. package/dist/index.d.ts.map +1 -1
  51. package/dist/index.js +7 -3
  52. package/dist/index.js.map +1 -1
  53. package/dist/jsx/components/auto-summary.d.ts +31 -0
  54. package/dist/jsx/components/auto-summary.d.ts.map +1 -0
  55. package/dist/jsx/components/auto-summary.js +83 -0
  56. package/dist/jsx/components/auto-summary.js.map +1 -0
  57. package/dist/jsx/components/collapsed.d.ts +12 -2
  58. package/dist/jsx/components/collapsed.d.ts.map +1 -1
  59. package/dist/jsx/components/collapsed.js +16 -1
  60. package/dist/jsx/components/collapsed.js.map +1 -1
  61. package/dist/jsx/components/content.d.ts +26 -13
  62. package/dist/jsx/components/content.d.ts.map +1 -1
  63. package/dist/jsx/components/content.js +64 -15
  64. package/dist/jsx/components/content.js.map +1 -1
  65. package/dist/jsx/components/index.d.ts +1 -0
  66. package/dist/jsx/components/index.d.ts.map +1 -1
  67. package/dist/jsx/components/index.js +1 -0
  68. package/dist/jsx/components/index.js.map +1 -1
  69. package/dist/jsx/components/primitives.d.ts +34 -2
  70. package/dist/jsx/components/primitives.d.ts.map +1 -1
  71. package/dist/jsx/components/primitives.js +82 -24
  72. package/dist/jsx/components/primitives.js.map +1 -1
  73. package/dist/local-transport.d.ts.map +1 -1
  74. package/dist/local-transport.js +4 -0
  75. package/dist/local-transport.js.map +1 -1
  76. package/dist/model/adapter.d.ts +9 -1
  77. package/dist/model/adapter.d.ts.map +1 -1
  78. package/dist/model/adapter.js +11 -4
  79. package/dist/model/adapter.js.map +1 -1
  80. package/dist/model/embedding.d.ts +55 -0
  81. package/dist/model/embedding.d.ts.map +1 -0
  82. package/dist/model/embedding.js +43 -0
  83. package/dist/model/embedding.js.map +1 -0
  84. package/dist/model/index.d.ts +1 -0
  85. package/dist/model/index.d.ts.map +1 -1
  86. package/dist/model/index.js +2 -0
  87. package/dist/model/index.js.map +1 -1
  88. package/dist/model/model.d.ts +6 -0
  89. package/dist/model/model.d.ts.map +1 -1
  90. package/dist/model/model.js.map +1 -1
  91. package/dist/renderers/base.d.ts +9 -0
  92. package/dist/renderers/base.d.ts.map +1 -1
  93. package/dist/renderers/base.js +31 -0
  94. package/dist/renderers/base.js.map +1 -1
  95. package/dist/renderers/markdown.d.ts.map +1 -1
  96. package/dist/renderers/markdown.js +21 -3
  97. package/dist/renderers/markdown.js.map +1 -1
  98. package/dist/renderers/xml.d.ts.map +1 -1
  99. package/dist/renderers/xml.js +9 -1
  100. package/dist/renderers/xml.js.map +1 -1
  101. package/dist/testing/mock-app.d.ts.map +1 -1
  102. package/dist/testing/mock-app.js +12 -0
  103. package/dist/testing/mock-app.js.map +1 -1
  104. package/dist/tool/index.d.ts +1 -0
  105. package/dist/tool/index.d.ts.map +1 -1
  106. package/dist/tool/index.js +1 -0
  107. package/dist/tool/index.js.map +1 -1
  108. package/dist/tool/tool-procedure.d.ts +17 -0
  109. package/dist/tool/tool-procedure.d.ts.map +1 -0
  110. package/dist/tool/tool-procedure.js +39 -0
  111. package/dist/tool/tool-procedure.js.map +1 -0
  112. package/dist/tool/tool.d.ts +10 -0
  113. package/dist/tool/tool.d.ts.map +1 -1
  114. package/dist/tool/tool.js +7 -6
  115. package/dist/tool/tool.js.map +1 -1
  116. package/dist/utils/classify-error.d.ts +1 -1
  117. package/dist/utils/classify-error.d.ts.map +1 -1
  118. package/dist/utils/classify-error.js +8 -0
  119. package/dist/utils/classify-error.js.map +1 -1
  120. package/package.json +51 -3
@@ -14,7 +14,7 @@
14
14
  */
15
15
  import { EventEmitter } from "node:events";
16
16
  import { randomUUID } from "node:crypto";
17
- import { Context, createProcedure, Channel, EventBuffer, ExecutionHandleBrand, Logger, } from "@agentick/kernel";
17
+ import { Context, createProcedure, Channel, EventBuffer, ExecutionHandleBrand, Logger, parseSchema, } from "@agentick/kernel";
18
18
  import { FiberCompiler, StructureRenderer, ReconciliationScheduler, } from "../compiler";
19
19
  import { COM } from "../com/object-model";
20
20
  import { MarkdownRenderer } from "../renderers/index";
@@ -71,6 +71,7 @@ export class SessionImpl extends EventEmitter {
71
71
  _tick = 1;
72
72
  _isAborted = false;
73
73
  _currentExecutionId = null;
74
+ _currentToolCallId = null;
74
75
  // Compilation infrastructure (no intermediate layer)
75
76
  compiler = null;
76
77
  ctx = null;
@@ -117,8 +118,14 @@ export class SessionImpl extends EventEmitter {
117
118
  sessionOptions;
118
119
  // Last props for hot-update support
119
120
  _lastProps = null;
121
+ _mountPromise = null;
120
122
  // Captured context from session creation
121
123
  _capturedContext;
124
+ // Inbox (durable external message delivery)
125
+ _inboxStorage = null;
126
+ _inboxUnsubscribe = null;
127
+ _draining = false;
128
+ _drainRequested = false;
122
129
  // Channels for pub/sub communication
123
130
  _channels = new Map();
124
131
  // Current execution handle (for concurrent send idempotency)
@@ -238,6 +245,7 @@ export class SessionImpl extends EventEmitter {
238
245
  send;
239
246
  render;
240
247
  spawn;
248
+ dispatch;
241
249
  initProcedures() {
242
250
  // Queue procedure - queues messages and notifies components
243
251
  this.queue = createProcedure({
@@ -401,8 +409,15 @@ export class SessionImpl extends EventEmitter {
401
409
  inheritDefaults: this.appOptions.inheritDefaults,
402
410
  runner: spawnOptions?.runner ?? this.appOptions.runner,
403
411
  };
412
+ // Child abort signal: merge session-level (parent dies → child dies)
413
+ // and execution-level (parent execution aborted → child aborted).
414
+ // Session signal always present; execution signal only during active tick.
415
+ const abortSignals = [this.sessionAbortController.signal];
416
+ if (this.executionAbortController) {
417
+ abortSignals.push(this.executionAbortController.signal);
418
+ }
404
419
  const childOptions = {
405
- signal: this.executionAbortController?.signal,
420
+ signal: AbortSignal.any(abortSignals),
406
421
  devTools: this.sessionOptions.devTools ?? this.appOptions.devTools,
407
422
  };
408
423
  const child = new SessionImpl(Component, childAppOptions, childOptions);
@@ -414,15 +429,108 @@ export class SessionImpl extends EventEmitter {
414
429
  ...resolvedInput,
415
430
  props: mergedProps,
416
431
  });
417
- // 4. Cleanup on completion
418
- handle.result
432
+ // 4. Spawn lifecycle events & child event forwarding
433
+ const spawnId = randomUUID();
434
+ this.emitEvent({
435
+ type: "spawn_start",
436
+ spawnId,
437
+ parentExecutionId: this._currentExecutionId ?? "",
438
+ childExecutionId: handle.sessionId,
439
+ componentName: Component.displayName || Component.name,
440
+ label: spawnOptions?.label,
441
+ originCallId: this._currentToolCallId ?? undefined,
442
+ });
443
+ // Forward child events to parent's buffer, tagged with spawnPath.
444
+ // Recursive spawns work automatically: child events already have
445
+ // spawnPath from grandchildren, we prepend our spawnId.
446
+ const childCallIds = new Set();
447
+ // Forward child events, then emit spawn_end after forwarding completes.
448
+ // This guarantees spawn_end comes AFTER all forwarded child events —
449
+ // no interleaving of spawn_end with trailing content deltas.
450
+ const forwardingPromise = (async () => {
451
+ for await (const event of handle) {
452
+ if (event.type === "tool_confirmation_required") {
453
+ childCallIds.add(event.callId);
454
+ }
455
+ this.emitEvent({
456
+ ...event,
457
+ spawnPath: [spawnId, ...(event.spawnPath ?? [])],
458
+ });
459
+ }
460
+ })();
461
+ // Prevent unhandled rejection if parent aborts mid-forward
462
+ forwardingPromise.catch(() => { });
463
+ // Route parent confirmation responses to child's channel
464
+ const parentConfirmChannel = this.channel("tool_confirmation");
465
+ const unsubConfirm = parentConfirmChannel.subscribe((event) => {
466
+ if (event.type === "response" && event.id && childCallIds.has(event.id)) {
467
+ child.channel("tool_confirmation").publish(event);
468
+ childCallIds.delete(event.id);
469
+ }
470
+ });
471
+ // spawn_end after all child events are forwarded (not racing with them).
472
+ // We await forwardingPromise to ensure ordering, then use handle.result
473
+ // for the final output/error.
474
+ forwardingPromise
475
+ .then(() => handle.result)
476
+ .then((result) => {
477
+ this.emitEvent({
478
+ type: "spawn_end",
479
+ spawnId,
480
+ parentExecutionId: this._currentExecutionId ?? "",
481
+ childExecutionId: handle.sessionId,
482
+ output: result.response,
483
+ usage: result.usage,
484
+ });
485
+ })
486
+ .catch((error) => {
487
+ this.emitEvent({
488
+ type: "spawn_end",
489
+ spawnId,
490
+ parentExecutionId: this._currentExecutionId ?? "",
491
+ childExecutionId: handle.sessionId,
492
+ output: error?.message ?? "spawn failed",
493
+ isError: true,
494
+ });
495
+ });
496
+ // 5. Cleanup after spawn_end is emitted
497
+ forwardingPromise
498
+ .then(() => handle.result)
419
499
  .finally(async () => {
500
+ unsubConfirm();
501
+ childCallIds.clear();
420
502
  this._children = this._children.filter((c) => c !== child);
421
503
  await child.close();
422
504
  })
423
505
  .catch(() => { });
424
506
  return handle;
425
507
  });
508
+ // Dispatch procedure - dispatches tools by name/alias from the user side
509
+ this.dispatch = createProcedure({
510
+ name: "session:dispatch",
511
+ metadata: { operation: "dispatch" },
512
+ handleFactory: false,
513
+ executionBoundary: false,
514
+ }, async (name, input) => {
515
+ if (this.isTerminal)
516
+ throw new Error(this.terminalError);
517
+ await this.mount();
518
+ const tool = this.ctx.getTool(name) ?? this.ctx.getToolByAlias(name);
519
+ if (!tool)
520
+ throw new Error(`Unknown command: ${name}`);
521
+ if (!tool.run)
522
+ throw new Error(`Command "${name}" has no handler`);
523
+ // Validate input against tool's schema
524
+ const validatedInput = tool.metadata.input
525
+ ? await parseSchema(tool.metadata.input, input)
526
+ : input;
527
+ const result = await tool.run.exec(validatedInput).result;
528
+ if (Array.isArray(result))
529
+ return result;
530
+ if (typeof result === "string")
531
+ return [{ type: "text", text: result }];
532
+ throw new Error(`Unexpected tool result type: ${typeof result}. Expected ContentBlock[] or string.`);
533
+ });
426
534
  }
427
535
  // ════════════════════════════════════════════════════════════════════════
428
536
  // SessionExecutionHandle Creation
@@ -690,6 +798,40 @@ export class SessionImpl extends EventEmitter {
690
798
  clearAbort() {
691
799
  this._isAborted = false;
692
800
  }
801
+ async mount() {
802
+ // Order matters: _mountPromise must be checked FIRST.
803
+ // _doMount() sets this.compiler synchronously (via ensureCompilationInfrastructure)
804
+ // before tool registration completes. A concurrent caller seeing compiler=truthy
805
+ // would skip the mount and fail tool lookup.
806
+ if (this._mountPromise)
807
+ return this._mountPromise;
808
+ if (this.compiler)
809
+ return; // Mounted via render() or previous mount()
810
+ this._mountPromise = this._doMount();
811
+ try {
812
+ await this._mountPromise;
813
+ }
814
+ catch (e) {
815
+ this._mountPromise = null; // Allow retry on failure
816
+ throw e;
817
+ }
818
+ }
819
+ async _doMount() {
820
+ const rootElement = jsx(this.Component, this._lastProps ?? {});
821
+ await this.ensureCompilationInfrastructure(rootElement);
822
+ // Single compile pass — no notifyTickStart (no catch-up), no compileUntilStable (no afterCompile).
823
+ // This only renders the tree to collect tools. No tick lifecycle hooks fire.
824
+ const tickState = {
825
+ tick: 0,
826
+ queuedMessages: [],
827
+ timeline: [],
828
+ stop: () => { },
829
+ };
830
+ const compiled = await this.compiler.compile(rootElement, tickState);
831
+ // Register collected tools so they're available for dispatch
832
+ const mergedTools = this.mergeTools(this.appOptions.tools ?? [], this.sessionOptions.tools ?? [], [], compiled.tools);
833
+ await Promise.all(mergedTools.map((tool) => this.ctx.addTool(tool)));
834
+ }
693
835
  startExecutionAbort(signal) {
694
836
  this.executionAbortCleanup.forEach((cleanup) => cleanup());
695
837
  this.executionAbortCleanup = [];
@@ -778,16 +920,22 @@ export class SessionImpl extends EventEmitter {
778
920
  devToolsEnabled: sessionCtx.devToolsEnabled ?? this.sessionOptions.devTools ?? false,
779
921
  });
780
922
  // Invoke lifecycle callbacks
923
+ // Forwarded child events (with spawnPath) only trigger onEvent, not
924
+ // lifecycle-specific callbacks like onTickStart/onTickEnd — those
925
+ // semantics belong to the parent's own tick lifecycle.
781
926
  const cb = this.callbacks;
927
+ const isForwarded = enrichedEvent.spawnPath?.length > 0;
782
928
  try {
783
929
  cb.onEvent?.(enrichedEvent);
784
- // Call specific callbacks based on event type
785
- const eventType = enrichedEvent.type;
786
- if (eventType === "tick_start") {
787
- cb.onTickStart?.(enrichedEvent.tick, enrichedEvent.executionId);
788
- }
789
- else if (eventType === "tick_end") {
790
- cb.onTickEnd?.(enrichedEvent.tick, enrichedEvent.usage);
930
+ if (!isForwarded) {
931
+ // Call specific callbacks based on event type
932
+ const eventType = enrichedEvent.type;
933
+ if (eventType === "tick_start") {
934
+ cb.onTickStart?.(enrichedEvent.tick, enrichedEvent.executionId);
935
+ }
936
+ else if (eventType === "tick_end") {
937
+ cb.onTickEnd?.(enrichedEvent.tick, enrichedEvent.usage);
938
+ }
791
939
  }
792
940
  }
793
941
  catch {
@@ -829,6 +977,78 @@ export class SessionImpl extends EventEmitter {
829
977
  setPersistCallback(callback) {
830
978
  this._persistCallback = callback;
831
979
  }
980
+ /**
981
+ * Connect this session to an inbox storage backend.
982
+ * Subscribes to notifications and drains any pre-existing pending messages.
983
+ * @internal
984
+ */
985
+ setInboxStorage(storage) {
986
+ this._inboxStorage = storage;
987
+ this._inboxUnsubscribe = storage.subscribe(this.id, () => {
988
+ this.drainInbox().catch((err) => {
989
+ this.log.warn({ error: err }, "Inbox drain failed");
990
+ });
991
+ });
992
+ // Drain pre-existing pending messages
993
+ this.drainInbox().catch((err) => {
994
+ this.log.warn({ error: err }, "Initial inbox drain failed");
995
+ });
996
+ }
997
+ /**
998
+ * Process all pending inbox messages. Called by App.processInbox().
999
+ * @internal
1000
+ */
1001
+ async processInboxMessages() {
1002
+ await this.drainInbox();
1003
+ }
1004
+ async drainInbox() {
1005
+ const storage = this._inboxStorage;
1006
+ if (this.isTerminal || !storage)
1007
+ return;
1008
+ if (this._draining) {
1009
+ // Signal that new messages arrived; current drain will re-check on exit
1010
+ this._drainRequested = true;
1011
+ return;
1012
+ }
1013
+ this._draining = true;
1014
+ try {
1015
+ do {
1016
+ this._drainRequested = false;
1017
+ const messages = await storage.pending(this.id);
1018
+ for (const msg of messages) {
1019
+ if (this.isTerminal)
1020
+ return;
1021
+ try {
1022
+ if (msg.type === "message") {
1023
+ const handle = await this.send({ messages: [msg.payload] });
1024
+ await handle.result;
1025
+ }
1026
+ else {
1027
+ await this.dispatch.exec(msg.payload.tool, msg.payload.input).result;
1028
+ }
1029
+ if (this.isTerminal)
1030
+ return;
1031
+ await storage.markDone(this.id, msg.id);
1032
+ }
1033
+ catch (err) {
1034
+ this.log.warn({ error: err, messageId: msg.id }, "Inbox message processing failed");
1035
+ break; // Preserve FIFO — stop on first failure
1036
+ }
1037
+ }
1038
+ } while (this._drainRequested && !this.isTerminal);
1039
+ }
1040
+ finally {
1041
+ this._draining = false;
1042
+ // Do NOT clear _drainRequested here — a notification may have arrived
1043
+ // between the while-check and finally. If so, re-enter.
1044
+ if (this._drainRequested && !this.isTerminal) {
1045
+ this._drainRequested = false;
1046
+ this.drainInbox().catch((err) => {
1047
+ this.log.warn({ error: err }, "Inbox re-drain failed");
1048
+ });
1049
+ }
1050
+ }
1051
+ }
832
1052
  /**
833
1053
  * Set a snapshot to be applied/resolved when compilation infrastructure is created.
834
1054
  * @internal
@@ -1222,6 +1442,10 @@ export class SessionImpl extends EventEmitter {
1222
1442
  if (this.isTerminal)
1223
1443
  return;
1224
1444
  this._status = "closed";
1445
+ // Unsubscribe from inbox before anything else
1446
+ this._inboxUnsubscribe?.();
1447
+ this._inboxUnsubscribe = null;
1448
+ this._inboxStorage = null;
1225
1449
  // Notify execution runner of destroy
1226
1450
  if (this._runnerInitialized && this.appOptions.runner?.onDestroy) {
1227
1451
  try {
@@ -1474,12 +1698,25 @@ export class SessionImpl extends EventEmitter {
1474
1698
  }
1475
1699
  // Stream model output if supported
1476
1700
  let modelOutput;
1701
+ let streamed = false;
1477
1702
  if (model.stream) {
1703
+ streamed = true;
1478
1704
  const streamIterable = await model.stream(modelInput);
1479
1705
  for await (const event of streamIterable) {
1480
1706
  if (signal.aborted) {
1481
1707
  throw new AbortError("Execution aborted", signal.reason);
1482
1708
  }
1709
+ // Enrich tool_call with displaySummary — the stream accumulator
1710
+ // doesn't have access to tool definitions, so we add it here.
1711
+ if (event.type === "tool_call" && "name" in event && compiled.tools) {
1712
+ const toolDef = compiled.tools.find((t) => t.metadata?.name === event.name);
1713
+ if (toolDef) {
1714
+ const summary = tryDisplaySummary(toolDef, event.input);
1715
+ if (summary) {
1716
+ event.summary = summary;
1717
+ }
1718
+ }
1719
+ }
1483
1720
  this.emitEvent(event);
1484
1721
  if (event.type === "message" && "message" in event) {
1485
1722
  const messageEvent = event;
@@ -1561,20 +1798,24 @@ export class SessionImpl extends EventEmitter {
1561
1798
  let toolStartTime;
1562
1799
  if (response.toolCalls?.length && this.ctx) {
1563
1800
  toolStartTime = Date.now();
1564
- for (const call of response.toolCalls) {
1565
- const toolCallTimestamp = timestamp();
1566
- const toolDef = compiled.tools?.find((t) => t.metadata?.name === call.name);
1567
- const summary = tryDisplaySummary(toolDef, call.input);
1568
- this.emitEvent({
1569
- type: "tool_call",
1570
- callId: call.id,
1571
- blockIndex: 0,
1572
- name: call.name,
1573
- input: call.input,
1574
- summary,
1575
- startedAt: toolCallTimestamp,
1576
- completedAt: toolCallTimestamp,
1577
- });
1801
+ // Only emit tool_call events for non-streaming path.
1802
+ // The streaming path already emitted these via stream accumulator.
1803
+ if (!streamed) {
1804
+ for (const call of response.toolCalls) {
1805
+ const toolCallTimestamp = timestamp();
1806
+ const toolDef = compiled.tools?.find((t) => t.metadata?.name === call.name);
1807
+ const summary = tryDisplaySummary(toolDef, call.input);
1808
+ this.emitEvent({
1809
+ type: "tool_call",
1810
+ callId: call.id,
1811
+ blockIndex: 0,
1812
+ name: call.name,
1813
+ input: call.input,
1814
+ summary,
1815
+ startedAt: toolCallTimestamp,
1816
+ completedAt: toolCallTimestamp,
1817
+ });
1818
+ }
1578
1819
  }
1579
1820
  toolResults = await this.executeTools(toolExecutor, response.toolCalls, compiled.tools, outputs, currentTick, timestamp);
1580
1821
  }
@@ -1623,6 +1864,7 @@ export class SessionImpl extends EventEmitter {
1623
1864
  // via _resolveCurrentShouldContinue, so each sees the accumulated decision.
1624
1865
  const tickEndState = {
1625
1866
  tick: currentTick,
1867
+ executionId: this._currentExecutionId ?? undefined,
1626
1868
  current: this._currentOutput,
1627
1869
  queuedMessages: [],
1628
1870
  timeline: this._timeline,
@@ -1807,7 +2049,7 @@ export class SessionImpl extends EventEmitter {
1807
2049
  this.scheduler.schedule(reason ?? "COM recompile request");
1808
2050
  });
1809
2051
  // Wire COM spawn delegate to session's spawn Procedure
1810
- this.ctx.setSpawnCallback((agent, input) => this.spawn(agent, input));
2052
+ this.ctx.setSpawnCallback((agent, input, options) => this.spawn(agent, input, options));
1811
2053
  this.structureRenderer = new StructureRenderer(this.ctx);
1812
2054
  this.structureRenderer.setDefaultRenderer(new MarkdownRenderer());
1813
2055
  // Tools are registered in compileTick() after merging all sources
@@ -1904,6 +2146,7 @@ export class SessionImpl extends EventEmitter {
1904
2146
  // Prepare tick state
1905
2147
  const tickState = {
1906
2148
  tick: this._tick,
2149
+ executionId: this._currentExecutionId ?? undefined,
1907
2150
  current: this._currentOutput,
1908
2151
  queuedMessages: queuedMessages,
1909
2152
  timeline: [...this._timeline],
@@ -2009,6 +2252,11 @@ export class SessionImpl extends EventEmitter {
2009
2252
  }
2010
2253
  }
2011
2254
  });
2255
+ // Cancel pending confirmations when execution is aborted (e.g. Ctrl+C).
2256
+ // Without this, waitForConfirmation() hangs forever on abort.
2257
+ const abortSignal = this.executionAbortController?.signal;
2258
+ const onAbort = () => coordinator.cancelAll();
2259
+ abortSignal?.addEventListener("abort", onAbort);
2012
2260
  // Confirmation callbacks for stream event emission
2013
2261
  const confirmationCallbacks = {
2014
2262
  onConfirmationRequired: async (call, message, metadata) => {
@@ -2033,7 +2281,16 @@ export class SessionImpl extends EventEmitter {
2033
2281
  };
2034
2282
  try {
2035
2283
  for (const call of toolCalls) {
2284
+ if (abortSignal?.aborted)
2285
+ break;
2036
2286
  const startedAt = timestamp();
2287
+ this._currentToolCallId = call.id;
2288
+ // Signal tool execution beginning (fills the gap between tool_call and tool_result)
2289
+ this.emitEvent({
2290
+ type: "tool_result_start",
2291
+ callId: call.id,
2292
+ name: call.name,
2293
+ });
2037
2294
  // Check if OUTPUT tool
2038
2295
  const tool = executableTools.find((t) => t.metadata?.name === call.name);
2039
2296
  const isOutputTool = tool && tool.metadata?.type === "output";
@@ -2108,6 +2365,8 @@ export class SessionImpl extends EventEmitter {
2108
2365
  }
2109
2366
  }
2110
2367
  finally {
2368
+ this._currentToolCallId = null;
2369
+ abortSignal?.removeEventListener("abort", onAbort);
2111
2370
  unsubscribe();
2112
2371
  }
2113
2372
  return results;
@@ -2178,17 +2437,34 @@ export class SessionImpl extends EventEmitter {
2178
2437
  };
2179
2438
  // Add entries to COM and session timeline - user entries first, then assistant response
2180
2439
  for (const entry of newUserEntries) {
2440
+ if (!entry.id)
2441
+ entry.id = randomUUID();
2181
2442
  this.ctx.addTimelineEntry(entry);
2182
2443
  this._timeline.push(entry);
2444
+ this.emitEvent({
2445
+ type: "entry_committed",
2446
+ entry,
2447
+ executionId: this._currentExecutionId,
2448
+ timelineIndex: this._timeline.length - 1,
2449
+ });
2183
2450
  }
2184
2451
  if (response.newTimelineEntries) {
2185
2452
  for (const entry of response.newTimelineEntries) {
2453
+ if (!entry.id)
2454
+ entry.id = randomUUID();
2186
2455
  this.ctx.addTimelineEntry(entry);
2187
2456
  this._timeline.push(entry);
2457
+ this.emitEvent({
2458
+ type: "entry_committed",
2459
+ entry,
2460
+ executionId: this._currentExecutionId,
2461
+ timelineIndex: this._timeline.length - 1,
2462
+ });
2188
2463
  }
2189
2464
  }
2190
2465
  if (toolResults.length > 0) {
2191
2466
  const toolResultEntry = {
2467
+ id: randomUUID(),
2192
2468
  kind: "message",
2193
2469
  message: {
2194
2470
  role: "tool",
@@ -2204,6 +2480,12 @@ export class SessionImpl extends EventEmitter {
2204
2480
  };
2205
2481
  this.ctx.addTimelineEntry(toolResultEntry);
2206
2482
  this._timeline.push(toolResultEntry);
2483
+ this.emitEvent({
2484
+ type: "entry_committed",
2485
+ entry: toolResultEntry,
2486
+ executionId: this._currentExecutionId,
2487
+ timelineIndex: this._timeline.length - 1,
2488
+ });
2207
2489
  }
2208
2490
  // Apply maxTimelineEntries trim
2209
2491
  if (this._maxTimelineEntries && this._timeline.length > this._maxTimelineEntries) {