evolcore 0.0.26 → 0.0.28

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 (177) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/bin/codex-managed-acl.ps1 +43 -0
  3. package/dist/agents/baseagent-availability.js +68 -0
  4. package/dist/agents/baseagent.js +10 -5
  5. package/dist/agents/claude-runner.js +123 -22
  6. package/dist/agents/codex/app-server-client.js +70 -26
  7. package/dist/agents/codex/catalog.js +7 -1
  8. package/dist/agents/codex-runner.js +363 -421
  9. package/dist/agents/ecagent-runner.js +170 -25
  10. package/dist/agents/gemini-runner.js +6 -0
  11. package/dist/agents/runner-types.js +50 -2
  12. package/dist/aun/aid/agentmd.js +73 -31
  13. package/dist/aun/aid/client.js +1 -1
  14. package/dist/aun/aid/control-aid.js +180 -1
  15. package/dist/aun/aid/identity.js +3 -3
  16. package/dist/aun/group/history.js +303 -0
  17. package/dist/aun/group/identity.js +43 -0
  18. package/dist/aun/group/index.js +5 -0
  19. package/dist/aun/{msg/group.js → group/operations.js} +161 -38
  20. package/dist/aun/{menu-client.js → menu/client.js} +13 -7
  21. package/dist/aun/menu/index.js +2 -0
  22. package/dist/aun/menu/transport.js +49 -0
  23. package/dist/aun/msg/index.js +2 -1
  24. package/dist/aun/msg/managed-operation.js +84 -18
  25. package/dist/aun/msg/mention-schema.js +51 -0
  26. package/dist/aun/msg/p2p.js +13 -8
  27. package/dist/aun/msg/upload.js +43 -88
  28. package/dist/aun/outbox.js +29 -7
  29. package/dist/aun/rpc/client.js +1 -0
  30. package/dist/aun/storage/client.js +39 -50
  31. package/dist/channels/aun.js +544 -239
  32. package/dist/channels/daemon.js +10 -6
  33. package/dist/cli/agent-command.js +108 -6
  34. package/dist/cli/agent-create.js +515 -2
  35. package/dist/cli/agent.js +109 -8
  36. package/dist/cli/aun-commands.js +191 -42
  37. package/dist/cli/config.js +8 -5
  38. package/dist/cli/contact.js +2 -2
  39. package/dist/cli/ctl-command.js +2 -2
  40. package/dist/cli/daemon-commands.js +111 -162
  41. package/dist/cli/diag.js +3 -3
  42. package/dist/cli/fs-command.js +3 -3
  43. package/dist/cli/handoff-command.js +6 -6
  44. package/dist/cli/index.js +8 -2
  45. package/dist/cli/init.js +34 -1
  46. package/dist/cli/managed-command-guard.js +2 -1
  47. package/dist/cli/menu-client.js +13 -5
  48. package/dist/cli/model.js +3 -3
  49. package/dist/cli/queue-command.js +2 -2
  50. package/dist/cli/response.js +2 -2
  51. package/dist/cli/restart-monitor.js +81 -19
  52. package/dist/cli/task-command.js +59 -0
  53. package/dist/cli/task-context.js +97 -49
  54. package/dist/cli/trigger-command.js +20 -10
  55. package/dist/config/builtin-roles.js +10 -0
  56. package/dist/config/config-field-policy.js +11 -0
  57. package/dist/config/config-manager.js +32 -4
  58. package/dist/config/config-operation-service.js +20 -5
  59. package/dist/config/contact-book-store.js +1 -1
  60. package/dist/config/contact-book.js +1 -1
  61. package/dist/config/contact-operation-service.js +1 -1
  62. package/dist/config/peer-role-resolver.js +1 -1
  63. package/dist/config/resolved-config-op.js +3 -0
  64. package/dist/config-store.js +1 -1
  65. package/dist/core/agent-application-service.js +5 -0
  66. package/dist/core/audit/event-key.js +17 -2
  67. package/dist/core/audit/log-integrity.js +34 -8
  68. package/dist/core/auth/agent-delegation.js +48 -14
  69. package/dist/core/auth/auth-gateway.js +2 -0
  70. package/dist/core/auth/authorization-audit.js +34 -38
  71. package/dist/core/auth/operation-authorizer.js +67 -5
  72. package/dist/core/auth/operation-catalog.js +110 -0
  73. package/dist/core/auth/remote-access-store.js +115 -9
  74. package/dist/core/bootstrap-messages.js +66 -7
  75. package/dist/core/bootstrap-service.js +110 -19
  76. package/dist/core/command/agent-control.js +88 -7
  77. package/dist/core/command/agent-storage-menu.js +443 -0
  78. package/dist/core/command/cli-intent-parser.js +19 -0
  79. package/dist/core/command/command-handler.js +105 -40
  80. package/dist/core/command/data-root-resolver.js +273 -0
  81. package/dist/core/command/diag-service.js +292 -27
  82. package/dist/core/command/group-menu.js +7 -2
  83. package/dist/core/command/menu-catalog.js +34 -4
  84. package/dist/core/command/menu-handler.js +364 -96
  85. package/dist/core/command/menu-protocol.js +43 -6
  86. package/dist/core/command/role-menu.js +2 -2
  87. package/dist/core/command/slash-handler.js +61 -37
  88. package/dist/core/event-catalog.js +4 -0
  89. package/dist/core/evolagent-registry.js +28 -2
  90. package/dist/core/group/invite-service.js +924 -0
  91. package/dist/core/interaction-router.js +22 -8
  92. package/dist/core/message/im-renderer.js +94 -16
  93. package/dist/core/message/message-bridge.js +140 -13
  94. package/dist/core/message/message-log.js +5 -3
  95. package/dist/core/message/message-queue.js +73 -41
  96. package/dist/core/message/response-engine.js +729 -118
  97. package/dist/core/permission/approval-gateway.js +36 -3
  98. package/dist/core/permission/ec-command-parser.js +76 -36
  99. package/dist/core/permission/protected-paths.js +134 -16
  100. package/dist/core/permission/sandbox-runtime.js +72 -3
  101. package/dist/core/permission/tool-error-code.js +15 -0
  102. package/dist/core/permission/tool-policy.js +8 -8
  103. package/dist/core/relation/peer-identity.js +57 -6
  104. package/dist/core/relation/shared-data.js +106 -0
  105. package/dist/core/role/runtime-policy.js +64 -0
  106. package/dist/core/runtime-lock.js +10 -26
  107. package/dist/core/session/runner-task-history.js +170 -0
  108. package/dist/core/session/session-manager.js +38 -6
  109. package/dist/core/session/session-mapper.js +41 -0
  110. package/dist/core/session/session-turn-coordinator.js +37 -7
  111. package/dist/core/session/session-turns.js +48 -7
  112. package/dist/core/task/task-recovery-service.js +176 -0
  113. package/dist/core/task/task-resume-operation.js +32 -0
  114. package/dist/eck/group-rules-sync.js +5 -3
  115. package/dist/eck/kit-renderer.js +1 -0
  116. package/dist/index.js +322 -207
  117. package/dist/ipc.js +141 -15
  118. package/dist/paths.js +17 -4
  119. package/dist/product.js +3 -0
  120. package/dist/trigger/event-source.js +19 -0
  121. package/dist/trigger/feedback.js +233 -40
  122. package/dist/trigger/parser.js +55 -5
  123. package/dist/trigger/patch.js +9 -0
  124. package/dist/trigger/scheduler.js +30 -4
  125. package/dist/trigger/validation.js +20 -3
  126. package/dist/utils/atomic-write.js +98 -0
  127. package/dist/utils/cross-platform.js +87 -16
  128. package/dist/utils/ecweb-utils.js +1 -0
  129. package/dist/utils/error-utils.js +40 -2
  130. package/dist/utils/logger.js +23 -13
  131. package/dist/utils/npm-ops.js +119 -4
  132. package/dist/utils/package-version-info.js +244 -0
  133. package/dist/utils/tool-summary.js +59 -3
  134. package/dist/utils/welcome.js +174 -1
  135. package/dist/utils/windows-shell-trust.js +37 -0
  136. package/ecagent/dist/harness/agent-harness.js +3 -0
  137. package/ecagent/dist/harness/session/jsonl-storage.js +21 -2
  138. package/kits/docs/INDEX.md +0 -6
  139. package/kits/docs/channels/aun.md +23 -2
  140. package/kits/docs/channels/feishu.md +1 -1
  141. package/kits/docs/context-assembly.md +29 -17
  142. package/kits/docs/evolcore/INDEX.md +8 -3
  143. package/kits/docs/evolcore/agent-create.md +39 -7
  144. package/kits/docs/evolcore/agent.md +33 -1
  145. package/kits/docs/evolcore/aid.md +9 -1
  146. package/kits/docs/evolcore/config.md +40 -1
  147. package/kits/docs/evolcore/group-collaboration.md +1 -1
  148. package/kits/docs/evolcore/group.md +117 -2
  149. package/kits/docs/evolcore/menu-client.md +16 -0
  150. package/kits/docs/evolcore/msg.md +3 -0
  151. package/kits/docs/evolcore/storage.md +3 -0
  152. package/kits/docs/evolcore/task.md +14 -0
  153. package/kits/docs/evolcore/trigger.md +51 -3
  154. package/kits/docs/venues/group.md +5 -0
  155. package/kits/eck_manifest.json +27 -0
  156. package/kits/rules/01-overview.md +11 -7
  157. package/kits/rules/04-relation.md +2 -1
  158. package/kits/rules/05-venue.md +1 -1
  159. package/kits/rules/06-channel.md +6 -4
  160. package/kits/schemas/agent-config.schema.12.json +25 -1
  161. package/kits/schemas/daemon.schema.6.json +2 -2
  162. package/kits/schemas/defaults.schema.5.json +1 -1
  163. package/kits/schemas/relation-config.schema.1.json +1 -1
  164. package/kits/schemas/relation-config.schema.5.json +1 -1
  165. package/kits/schemas/role-config.schema.1.json +14 -0
  166. package/kits/templates/roles/member.json +5 -0
  167. package/kits/templates/roles/visitor.json +5 -0
  168. package/kits/templates/system-fragments/baseagent.md +1 -1
  169. package/kits/templates/system-fragments/bootstrap.md +26 -9
  170. package/kits/templates/system-fragments/channel.md +8 -44
  171. package/kits/templates/system-fragments/commands.md +10 -2
  172. package/kits/templates/system-fragments/group-unread-history.md +7 -0
  173. package/kits/templates/system-fragments/reply.md +56 -0
  174. package/package.json +7 -3
  175. package/dist/aun/group-identity.js +0 -9
  176. /package/dist/aun/{group-fs-download.js → group/fs-download.js} +0 -0
  177. /package/dist/aun/{msg/group-index.js → group/index-rpc.js} +0 -0
@@ -1,4 +1,4 @@
1
- import { getRunnerCatalog, catalogEfforts, assertCatalogSelectable } from '../core/model/model-catalog.js';
1
+ import { getRunnerCatalog, catalogEfforts, assertCatalogSelectable, TRANSPORT_EFFORTS, resolveCatalogModel, } from '../core/model/model-catalog.js';
2
2
  // Ecagent/OpenAI API 不认识 Claude Code SDK 的窗口后缀,必须传入基础模型 ID。
3
3
  import { stripContextWindowSuffix } from '../core/model/context-window-suffix.js';
4
4
  import fs from 'fs';
@@ -12,13 +12,14 @@ import { requestDangerousCommandPermission } from '../core/permission/approval-g
12
12
  import { normalizeExecutionPermissionMode, normalizePermissionMode } from '../core/permission/mode.js';
13
13
  import { checkDangerousCommand, checkReadonly, evaluateToolPreflight } from '../core/permission/tool-policy.js';
14
14
  import { logger } from '../utils/logger.js';
15
+ import { ErrorType } from '../utils/error-utils.js';
15
16
  import { summarizeToolInputForAudit } from '../utils/tool-summary.js';
16
17
  import { auditToolPreflightDenial } from '../core/auth/authorization-audit.js';
17
18
  import { resolveEcagentConfig } from './baseagent.js';
18
19
  import { buildModelRequestHeaders } from './request-identity.js';
19
20
  import { appendRequestPath } from './request-identity.js';
20
21
  import { getManagedTaskTempDir } from '../cli/task-context.js';
21
- import { hasTrustedFullAccessContext } from './runner-types.js';
22
+ import { unsettledInterruptError, withInterruptDeadline, hasTrustedFullAccessContext } from './runner-types.js';
22
23
  const PROVIDER_ID = 'evolcore-gateway';
23
24
  const DEFAULT_SYSTEM_PROMPT = `You are ecagent, the coding agent built into EvolCore.
24
25
  Work directly in the current project. Inspect relevant files before changing them, keep edits scoped, and verify the result.
@@ -679,6 +680,8 @@ function extractToolResult(result) {
679
680
  }
680
681
  function errorType(message) {
681
682
  const normalized = message.toLowerCase();
683
+ if (normalized.includes('invalid jsonl session file') || normalized.includes('session file'))
684
+ return ErrorType.SESSION_UNAVAILABLE;
682
685
  if (normalized.includes('[auth]') || normalized.includes('401') || normalized.includes('403'))
683
686
  return 'auth';
684
687
  if (normalized.includes('context') && (normalized.includes('long') || normalized.includes('length')))
@@ -690,6 +693,7 @@ function errorType(message) {
690
693
  export class EcagentRunner {
691
694
  name = 'ecagent';
692
695
  capabilities = {
696
+ taskRecovery: true,
693
697
  fullaccess: true,
694
698
  clear: true, compact: true, fork: true, askUserQuestion: false, planApproval: false, fileRewind: 'unsupported',
695
699
  };
@@ -700,6 +704,9 @@ export class EcagentRunner {
700
704
  effort;
701
705
  permissionMode = DEFAULT_PERMISSION_MODE;
702
706
  runtimes = new Map();
707
+ /** Serialize harness lifecycles that share one durable ecagent session file. */
708
+ sessionRunTails = new Map();
709
+ sessionRuns = new Map();
703
710
  activeStreams = new Map();
704
711
  /** Queue identity prevents an old run's finally from consuming a new run's interrupt. */
705
712
  pendingInterrupts = new Map();
@@ -755,7 +762,15 @@ export class EcagentRunner {
755
762
  return catalog.models.map(m => m.id);
756
763
  }
757
764
  async getAvailableEfforts(model) {
758
- return catalogEfforts(await this.getModelCatalog(), model, 'ecagent');
765
+ const catalog = await this.getModelCatalog();
766
+ const efforts = catalogEfforts(catalog, model, 'ecagent');
767
+ if (efforts.length > 0)
768
+ return efforts;
769
+ // The embedded transport advertises reasoning support for every model it
770
+ // constructs. Older gateways may omit reasoningLevels from /models; keep
771
+ // menu.options aligned with the actual runner capability in that case.
772
+ const entry = catalog.models.find(item => item.id === resolveCatalogModel(catalog, model));
773
+ return entry && entry.reasoningLevels === undefined ? [...TRANSPORT_EFFORTS.ecagent] : efforts;
759
774
  }
760
775
  registerStream(key, stream) { this.activeStreams.set(key, stream); }
761
776
  cleanupStream(key) {
@@ -799,6 +814,7 @@ export class EcagentRunner {
799
814
  // Keep managed tasks from silently inheriting the daemon-wide TMPDIR when
800
815
  // their session-owned runtime directory is unavailable.
801
816
  getManagedTaskTempDir(runtimeEnv);
817
+ this.assertInterruptSettled(sessionId);
802
818
  const topicBinding = modelOverride?.backend?.kind === 'topic';
803
819
  if (topicBinding && (!modelOverride?.turn || modelOverride.turn.sessionId !== sessionId || !this.onSessionIdUpdate)) {
804
820
  const error = new Error('topic backend run requires an active TurnLease and binding callback');
@@ -806,20 +822,43 @@ export class EcagentRunner {
806
822
  throw error;
807
823
  }
808
824
  const queue = new AsyncEventQueue();
825
+ const lifecycle = { queue, cancelled: false };
826
+ const runs = this.sessionRuns.get(sessionId) ?? new Set();
827
+ runs.add(lifecycle);
828
+ this.sessionRuns.set(sessionId, runs);
809
829
  this.activeStreams.set(sessionId, queue);
810
- void this.executeRun(queue, {
830
+ const request = {
811
831
  sessionId, prompt, projectPath: path.resolve(projectPath), initialAgentSessionId, images,
812
832
  systemPromptAppend, modelOverride, runtimeEnv,
833
+ };
834
+ const previous = this.sessionRunTails.get(sessionId) ?? Promise.resolve();
835
+ const run = previous.catch(() => undefined).then(() => {
836
+ if (!lifecycle.cancelled)
837
+ return this.executeRun(queue, request, lifecycle);
813
838
  });
839
+ lifecycle.done = run;
840
+ this.sessionRunTails.set(sessionId, run);
841
+ const release = () => {
842
+ runs.delete(lifecycle);
843
+ if (this.sessionRuns.get(sessionId) === runs && runs.size === 0)
844
+ this.sessionRuns.delete(sessionId);
845
+ if (this.sessionRunTails.get(sessionId) === run)
846
+ this.sessionRunTails.delete(sessionId);
847
+ };
848
+ void run.then(release, release);
814
849
  return queue;
815
850
  }
816
- async executeRun(queue, request) {
817
- const stats = { startedAt: Date.now(), text: '', numTurns: 0, usage: {}, modelCalls: [] };
851
+ async executeRun(queue, request, lifecycle) {
852
+ const stats = {
853
+ startedAt: Date.now(), text: '', textBlocks: new Map(), numTurns: 0, usage: {}, modelCalls: [],
854
+ };
818
855
  let completeSent = false;
819
856
  try {
820
857
  await fs.promises.mkdir(request.projectPath, { recursive: true });
821
858
  const previousRuntime = this.runtimes.get(request.sessionId);
822
859
  const runtime = await this.getOrCreateRuntime(request.sessionId, request.projectPath, request.initialAgentSessionId, request.modelOverride);
860
+ if (lifecycle.cancelled)
861
+ throw new Error('ecagent run interrupted');
823
862
  if (previousRuntime !== runtime) {
824
863
  queue.push({ type: 'session_id', sessionId: runtime.agentSessionId });
825
864
  }
@@ -870,13 +909,16 @@ export class EcagentRunner {
870
909
  throw new Error('ecagent run interrupted');
871
910
  }
872
911
  harness.on('tool_call', async (event, signal) => {
912
+ if (lifecycle.cancelled || signal?.aborted)
913
+ return { block: true, reason: '工具调用已取消' };
873
914
  const pauseController = this.permissionContexts.get(request.sessionId)?.pauseController;
874
915
  if (pauseController) {
875
916
  const pauseResult = await pauseController.waitAtToolBoundary(signal);
876
917
  if (pauseResult === 'cancelled')
877
918
  return { block: true, reason: '工具调用已取消' };
878
919
  }
879
- return this.authorizeTool(request.sessionId, request.projectPath, permissionMode, event.toolName, event.input);
920
+ const decision = await this.authorizeTool(request.sessionId, request.projectPath, permissionMode, event.toolName, event.input);
921
+ return lifecycle.cancelled || signal?.aborted ? { block: true, reason: '工具调用已取消' } : decision;
880
922
  });
881
923
  const unsubscribe = harness.subscribe(event => this.mapHarnessEvent(queue, event, stats, model, effort));
882
924
  queue.push({ type: 'state_changed', state: 'running' });
@@ -890,20 +932,36 @@ export class EcagentRunner {
890
932
  }
891
933
  finally {
892
934
  unsubscribe();
893
- runtime.harness = undefined;
935
+ this.finishTextBlocks(queue, stats);
936
+ if (runtime.harness === harness)
937
+ runtime.harness = undefined;
894
938
  }
895
- if (result.stopReason === 'error' || result.stopReason === 'aborted') {
896
- const message = result.errorMessage || (result.stopReason === 'aborted' ? 'ecagent run interrupted' : 'ecagent run failed');
897
- queue.push({ type: 'error', error: message, errorType: errorType(message) });
939
+ const outputLimit = result.stopReason === 'length';
940
+ if (result.stopReason === 'error' || result.stopReason === 'aborted' || outputLimit) {
941
+ const message = result.errorMessage || (result.stopReason === 'aborted'
942
+ ? 'ecagent run interrupted'
943
+ : outputLimit
944
+ ? 'ecagent response reached the output limit (max_output_tokens)'
945
+ : 'ecagent run failed');
946
+ queue.push({ type: 'error', error: message, errorType: outputLimit ? ErrorType.MAX_OUTPUT_TOKENS : errorType(message) });
898
947
  }
899
948
  const title = await runtime.session.getSessionName();
900
949
  queue.push({ type: 'state_changed', state: 'idle' });
901
950
  queue.push({
902
951
  type: 'complete',
903
952
  result: stats.text,
904
- isError: result.stopReason === 'error' || result.stopReason === 'aborted',
905
- errors: result.errorMessage ? [result.errorMessage] : undefined,
906
- terminalReason: result.stopReason === 'aborted' ? 'interrupted' : result.stopReason,
953
+ ...(outputLimit ? { subtype: 'max_output_tokens' } : {}),
954
+ isError: result.stopReason === 'error' || result.stopReason === 'aborted' || outputLimit,
955
+ errors: result.errorMessage
956
+ ? [result.errorMessage]
957
+ : outputLimit
958
+ ? ['max_output_tokens']
959
+ : undefined,
960
+ terminalReason: result.stopReason === 'aborted'
961
+ ? 'interrupted'
962
+ : outputLimit
963
+ ? 'max_output_tokens'
964
+ : result.stopReason,
907
965
  durationMs: Date.now() - stats.startedAt,
908
966
  ttftMs: stats.firstTextAt ? stats.firstTextAt - stats.startedAt : undefined,
909
967
  sessionTitle: title,
@@ -916,6 +974,7 @@ export class EcagentRunner {
916
974
  completeSent = true;
917
975
  }
918
976
  catch (error) {
977
+ this.finishTextBlocks(queue, stats);
919
978
  const message = error instanceof Error ? error.message : String(error);
920
979
  const interrupted = message === 'ecagent run interrupted';
921
980
  logger.error(`[EcagentRunner] run failed for session ${request.sessionId}:`, error);
@@ -948,7 +1007,11 @@ export class EcagentRunner {
948
1007
  const targetId = topicBinding
949
1008
  ? (requestedTopicAgentSessionId || undefined)
950
1009
  : requestedId || current?.agentSessionId;
951
- if (current
1010
+ const recoverySessionId = modelOverride?.recovery?.agentSessionId;
1011
+ if (modelOverride?.recovery && (!recoverySessionId || targetId !== recoverySessionId)) {
1012
+ throw new Error('Task recovery requires the original ecagent session binding');
1013
+ }
1014
+ if (current && !recoverySessionId
952
1015
  && current.projectPath === projectPath
953
1016
  && (!targetId || current.agentSessionId === targetId)
954
1017
  && (!topicBinding || (!!targetId && current.agentSessionId === targetId)))
@@ -959,6 +1022,9 @@ export class EcagentRunner {
959
1022
  if (targetId) {
960
1023
  const metadata = (await repo.list({ cwd: projectPath })).find(item => item.id === targetId);
961
1024
  if (!metadata) {
1025
+ if (recoverySessionId) {
1026
+ throw new Error(`Task recovery backend is unavailable: ${recoverySessionId}`);
1027
+ }
962
1028
  if (topicBinding) {
963
1029
  const error = new Error(`bound topic backend is unavailable: ${targetId}`);
964
1030
  error.code = 'TOPIC_BACKEND_UNAVAILABLE';
@@ -979,6 +1045,9 @@ export class EcagentRunner {
979
1045
  : await repo.create({ cwd: projectPath, id: sessionId });
980
1046
  }
981
1047
  const metadata = await session.getMetadata();
1048
+ if (recoverySessionId && metadata.id !== recoverySessionId) {
1049
+ throw new Error('Task recovery cannot replace the original ecagent session');
1050
+ }
982
1051
  const runtime = { agentSessionId: metadata.id, projectPath, repo, session };
983
1052
  if ((topicBinding && metadata.id !== requestedTopicAgentSessionId) || metadata.id !== targetId) {
984
1053
  const turn = modelOverride?.turn;
@@ -997,6 +1066,29 @@ export class EcagentRunner {
997
1066
  this.runtimes.set(sessionId, runtime);
998
1067
  return runtime;
999
1068
  }
1069
+ endTextBlock(queue, stats, contentIndex, content) {
1070
+ if (stats.textBlocks.get(contentIndex)?.ended)
1071
+ return;
1072
+ // Commit synchronously before enqueueing. message_end/finally may close the
1073
+ // same block again, but cannot append or project its text a second time.
1074
+ stats.textBlocks.set(contentIndex, { text: content, ended: true });
1075
+ stats.text += content;
1076
+ // Match Codex: only the completed body enters the common runner protocol.
1077
+ queue.push({ type: 'text', text: content, turn: stats.numTurns + 1 });
1078
+ }
1079
+ /** Recover a missing text_end from the final message, or the received deltas. */
1080
+ finishTextBlocks(queue, stats, message) {
1081
+ if (message) {
1082
+ message.content.forEach((block, index) => {
1083
+ if (block.type === 'text')
1084
+ this.endTextBlock(queue, stats, index, block.text);
1085
+ });
1086
+ }
1087
+ for (const [index, block] of stats.textBlocks) {
1088
+ if (!block.ended)
1089
+ this.endTextBlock(queue, stats, index, block.text);
1090
+ }
1091
+ }
1000
1092
  mapHarnessEvent(queue, event, stats, model, effort) {
1001
1093
  if (event.type === 'message_update'
1002
1094
  && (event.assistantMessageEvent.type === 'thinking_start'
@@ -1005,14 +1097,38 @@ export class EcagentRunner {
1005
1097
  queue.push({ type: 'heartbeat', source: 'reasoning', turn: stats.numTurns + 1 });
1006
1098
  return;
1007
1099
  }
1008
- if (event.type === 'message_update' && event.assistantMessageEvent.type === 'text_delta') {
1009
- if (!stats.firstTextAt)
1010
- stats.firstTextAt = Date.now();
1011
- stats.text += event.assistantMessageEvent.delta;
1012
- queue.push({ type: 'text', text: event.assistantMessageEvent.delta, turn: stats.numTurns + 1 });
1100
+ if (event.type === 'message_update') {
1101
+ const update = event.assistantMessageEvent;
1102
+ if (update.type === 'text_start') {
1103
+ if (!stats.textBlocks.has(update.contentIndex)) {
1104
+ stats.textBlocks.set(update.contentIndex, { text: '', ended: false });
1105
+ queue.push({ type: 'text_start', contentIndex: update.contentIndex, turn: stats.numTurns + 1 });
1106
+ }
1107
+ return;
1108
+ }
1109
+ if (update.type === 'text_delta') {
1110
+ const block = stats.textBlocks.get(update.contentIndex) ?? { text: '', ended: false };
1111
+ if (block.ended)
1112
+ return;
1113
+ if (!stats.firstTextAt)
1114
+ stats.firstTextAt = Date.now();
1115
+ block.text += update.delta;
1116
+ stats.textBlocks.set(update.contentIndex, block);
1117
+ // Keep the stream alive without exposing partial text to the renderer.
1118
+ queue.push({ type: 'heartbeat', source: 'text', turn: stats.numTurns + 1 });
1119
+ return;
1120
+ }
1121
+ if (update.type === 'text_end') {
1122
+ this.endTextBlock(queue, stats, update.contentIndex, update.content);
1123
+ return;
1124
+ }
1125
+ }
1126
+ if (event.type === 'message_end' && event.message.role === 'assistant') {
1127
+ this.finishTextBlocks(queue, stats, event.message);
1013
1128
  return;
1014
1129
  }
1015
1130
  if (event.type === 'tool_execution_start') {
1131
+ this.finishTextBlocks(queue, stats);
1016
1132
  queue.push({ type: 'tool_use', name: event.toolName, input: event.args, callId: event.toolCallId, turn: stats.numTurns + 1 });
1017
1133
  return;
1018
1134
  }
@@ -1025,6 +1141,8 @@ export class EcagentRunner {
1025
1141
  return;
1026
1142
  }
1027
1143
  if (event.type === 'turn_end' && event.message.role === 'assistant') {
1144
+ this.finishTextBlocks(queue, stats, event.message);
1145
+ stats.textBlocks.clear();
1028
1146
  stats.numTurns++;
1029
1147
  const usage = event.message.usage;
1030
1148
  stats.usage.input_tokens = Number(stats.usage.input_tokens ?? 0) + usage.input;
@@ -1150,13 +1268,36 @@ export class EcagentRunner {
1150
1268
  this.permissionContexts.get(sessionKey)?.pauseController?.cancel();
1151
1269
  this.permissionGateway?.cancelAll(sessionKey, 'interrupted');
1152
1270
  const harness = this.runtimes.get(sessionKey)?.harness;
1153
- if (harness) {
1154
- await harness.abort();
1271
+ const runs = [...(this.sessionRuns.get(sessionKey) ?? [])];
1272
+ for (const run of runs) {
1273
+ if (run.cancelled)
1274
+ continue;
1275
+ run.cancelled = true;
1276
+ run.queue.push({ type: 'complete', isError: true, terminalReason: 'interrupted' });
1277
+ run.queue.end();
1278
+ }
1279
+ if (runs.length === 0 && !harness) {
1280
+ const stream = this.activeStreams.get(sessionKey);
1281
+ if (stream)
1282
+ this.pendingInterrupts.set(sessionKey, stream);
1155
1283
  return;
1156
1284
  }
1157
- const stream = this.activeStreams.get(sessionKey);
1158
- if (stream)
1159
- this.pendingInterrupts.set(sessionKey, stream);
1285
+ // Ending the event stream releases the message queue, not the durable
1286
+ // session writer. Keep the run tail until all old code has actually exited.
1287
+ try {
1288
+ await withInterruptDeadline(Promise.all([
1289
+ harness?.abort(),
1290
+ ...runs.map(run => run.done),
1291
+ ]), 'ecagent');
1292
+ }
1293
+ catch (error) {
1294
+ logger.warn(`[EcagentRunner] Interrupted execution still requires quiescence: session=${sessionKey} error=${String(error)}`);
1295
+ }
1296
+ }
1297
+ assertInterruptSettled(sessionId) {
1298
+ if ([...(this.sessionRuns.get(sessionId) ?? [])].some(run => run.cancelled)) {
1299
+ throw unsettledInterruptError('ecagent');
1300
+ }
1160
1301
  }
1161
1302
  injectUserMessage(sessionId, text) {
1162
1303
  const harness = this.runtimes.get(sessionId)?.harness;
@@ -1169,6 +1310,7 @@ export class EcagentRunner {
1169
1310
  const runtime = this.runtimes.get(sessionId);
1170
1311
  const stream = this.activeStreams.get(sessionId);
1171
1312
  await this.interrupt(sessionId).catch(() => { });
1313
+ this.assertInterruptSettled(sessionId);
1172
1314
  if (runtime && this.runtimes.get(sessionId) === runtime)
1173
1315
  this.runtimes.delete(sessionId);
1174
1316
  if (stream && this.activeStreams.get(sessionId) === stream) {
@@ -1183,6 +1325,7 @@ export class EcagentRunner {
1183
1325
  try {
1184
1326
  const currentRuntime = this.runtimes.get(sessionId);
1185
1327
  await this.interrupt(sessionId);
1328
+ this.assertInterruptSettled(sessionId);
1186
1329
  const env = new NodeExecutionEnv({ cwd: projectPath });
1187
1330
  const repo = new JsonlSessionRepo({ fs: env, sessionsRoot: this.sessionsRoot() });
1188
1331
  const metadata = (await repo.list({ cwd: path.resolve(projectPath) })).find(item => item.id === agentSessionId);
@@ -1328,6 +1471,8 @@ export class EcagentRunner {
1328
1471
  async dispose() {
1329
1472
  await Promise.allSettled([...this.runtimes.keys()].map(sessionId => this.closeSession(sessionId)));
1330
1473
  this.runtimes.clear();
1474
+ this.sessionRuns.clear();
1475
+ this.sessionRunTails.clear();
1331
1476
  this.activeStreams.clear();
1332
1477
  this.pendingInterrupts.clear();
1333
1478
  }
@@ -12,6 +12,7 @@ import { stripContextWindowSuffix } from '../core/model/context-window-suffix.js
12
12
  * GeminiRunner → spawn `gemini -p ...` → stdout JSONL stream
13
13
  */
14
14
  import { spawn } from 'child_process';
15
+ import { MAIN_CLI_BIN } from '../product.js';
15
16
  import { createInterface } from 'readline';
16
17
  import fs from 'fs';
17
18
  import path from 'path';
@@ -143,6 +144,10 @@ const GEMINI_EC_COMMAND_PATTERNS = [
143
144
  String.raw `\x00"command":"[ ]*ec[ ]+(?:[^"\x00;&|$()<>\x60\r\n\\]|\\(?:\\|"))*"\x00`,
144
145
  ];
145
146
  const GEMINI_BOOTSTRAP_PERSONA_COMMAND_PATTERN = String.raw `^\{\x00"command":"[ ]*ec[ ]+agent[ ]+persona(?:[ ]+show|[ ]+--(?:text|file)[ ]+(?:[^"\x00;&|$()<>\x60\r\n\\]|\\(?:\\|"))+)[ ]*"\x00\}$`;
147
+ // A single-quoted shell argument can safely carry multiline Markdown, backticks,
148
+ // dollar signs and semicolons. Outside that argument only --format json is legal.
149
+ const GEMINI_WELCOME_ESCAPED_QUOTE = escapeGeminiArgsRegex(JSON.stringify("'\\''").slice(1, -1));
150
+ const GEMINI_BOOTSTRAP_WELCOME_COMMAND_PATTERN = String.raw `^\{\x00"command":"[ ]*${escapeGeminiArgsRegex(MAIN_CLI_BIN)}[ ]+agent[ ]+welcome[ ]+(?:show|--text[ ]+'(?:[^'"\x00\\]|\\(?:\\|"|n|r|t)|${GEMINI_WELCOME_ESCAPED_QUOTE})*')(?:[ ]+--format[ ]+json)?[ ]*"\x00\}$`;
146
151
  export function resolveGeminiPermissionProfile(value, bootstrap = false) {
147
152
  if (value === 'fullaccess')
148
153
  return { mode: 'fullaccess', approvalMode: 'yolo' };
@@ -212,6 +217,7 @@ export function buildGeminiAdminPolicy(profile, bootstrapAgentMdPath, personalMd
212
217
  appendGeminiPolicyRule(lines, 'run_shell_command', 'allow', 990, geminiExactShellCommandPattern(`ec aid agentmd put ${bootstrap.aid}`));
213
218
  appendGeminiPolicyRule(lines, 'run_shell_command', 'allow', 990, geminiExactShellCommandPattern(`ec agent ready ${bootstrap.aid}`));
214
219
  appendGeminiPolicyRule(lines, 'run_shell_command', 'allow', 990, GEMINI_BOOTSTRAP_PERSONA_COMMAND_PATTERN);
220
+ appendGeminiPolicyRule(lines, 'run_shell_command', 'allow', 990, GEMINI_BOOTSTRAP_WELCOME_COMMAND_PATTERN);
215
221
  if (personalMdPath) {
216
222
  const personalPathPattern = geminiManagedIdentityPathPattern(personalMdPath);
217
223
  appendGeminiPolicyRule(lines, 'read_file', 'allow', 989, personalPathPattern);
@@ -13,6 +13,30 @@ export class BaseagentRunnerUnavailableError extends Error {
13
13
  this.name = 'BaseagentRunnerUnavailableError';
14
14
  }
15
15
  }
16
+ /** Codex's native asynchronous reply envelope used by its rich clients. */
17
+ export function formatAsyncQuestionAnswer(answer) {
18
+ return '<send_user_message_question_reply>' + JSON.stringify({
19
+ questionItemId: answer.questionItemId,
20
+ question: answer.question,
21
+ answer: answer.answer,
22
+ }) + '</send_user_message_question_reply>';
23
+ }
24
+ export function parseAsyncUserQuestions(value) {
25
+ if (!Array.isArray(value) || value.length === 0 || value.length > 20) {
26
+ throw new Error('Invalid asynchronous question list');
27
+ }
28
+ return value.map(question => {
29
+ if (!question || typeof question !== 'object' || typeof question.title !== 'string'
30
+ || !question.title.trim() || question.title.length > 16_384) {
31
+ throw new Error('Invalid asynchronous question title');
32
+ }
33
+ if (question.options != null && (!Array.isArray(question.options) || !question.options.length
34
+ || question.options.length > 20 || question.options.some((option) => typeof option !== 'string' || !option.trim() || option.length > 4_096))) {
35
+ throw new Error('Invalid asynchronous question options');
36
+ }
37
+ return { title: question.title, ...(question.options ? { options: [...question.options] } : {}) };
38
+ });
39
+ }
16
40
  export function hasTrustedFullAccessContext(context) {
17
41
  const authorization = context?.executionPermission;
18
42
  if (!authorization
@@ -20,8 +44,10 @@ export function hasTrustedFullAccessContext(context) {
20
44
  || authorization.processRole !== 'fullaccess-run'
21
45
  || authorization.dataScope !== 'daemon')
22
46
  return false;
23
- if (authorization.source === 'fullaccess-command') {
24
- return typeof authorization.authorizedBy === 'string' && authorization.authorizedBy.length > 0;
47
+ if (authorization.source === 'fullaccess-command' || authorization.source === 'agent-policy') {
48
+ return typeof authorization.authorizedBy === 'string' && authorization.authorizedBy.length > 0
49
+ && (authorization.source !== 'agent-policy'
50
+ || (typeof authorization.agentAid === 'string' && authorization.agentAid.length > 0));
25
51
  }
26
52
  return authorization.source === 'trigger'
27
53
  && typeof authorization.authorizedBy === 'string' && authorization.authorizedBy.length > 0
@@ -32,6 +58,28 @@ export function hasTrustedFullAccessContext(context) {
32
58
  // being mistaken for a runnable privileged invocation.
33
59
  && typeof authorization.attemptId === 'string' && authorization.attemptId.length > 0;
34
60
  }
61
+ /** Bounds waiting only; the caller must retain ownership until execution stops. */
62
+ export async function withInterruptDeadline(operation, label, timeoutMs = 5_000) {
63
+ let timer;
64
+ try {
65
+ return await Promise.race([
66
+ operation,
67
+ new Promise((_, reject) => {
68
+ timer = setTimeout(() => reject(new Error(`${label}: interrupt deadline exceeded`)), timeoutMs);
69
+ timer.unref?.();
70
+ }),
71
+ ]);
72
+ }
73
+ finally {
74
+ if (timer)
75
+ clearTimeout(timer);
76
+ }
77
+ }
78
+ export function unsettledInterruptError(runner) {
79
+ return Object.assign(new Error(`${runner} 的上一次执行尚未确认停止,本次任务未启动;旧执行结束后可在原会话继续。`), {
80
+ code: 'RUNNER_INTERRUPT_UNCONFIRMED',
81
+ });
82
+ }
35
83
  // ── 类型守卫 ──
36
84
  export function hasModelSwitcher(agent) {
37
85
  return typeof agent.setModel === 'function' && typeof agent.listModels === 'function';