evolclaw 2.8.3 → 3.0.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 (105) hide show
  1. package/README.md +21 -12
  2. package/dist/agents/claude-runner.js +102 -38
  3. package/dist/agents/codex-runner.js +11 -14
  4. package/dist/agents/gemini-runner.js +10 -12
  5. package/dist/agents/resolve.js +134 -0
  6. package/dist/agents/templates.js +3 -3
  7. package/dist/aun/aid/agentmd.js +186 -0
  8. package/dist/aun/aid/client.js +134 -0
  9. package/dist/aun/aid/identity.js +131 -0
  10. package/dist/aun/aid/index.js +3 -0
  11. package/dist/aun/aid/types.js +1 -0
  12. package/dist/aun/aid/validation.js +21 -0
  13. package/dist/aun/msg/group.js +291 -0
  14. package/dist/aun/msg/index.js +4 -0
  15. package/dist/aun/msg/p2p.js +144 -0
  16. package/dist/aun/msg/payload-type.js +27 -0
  17. package/dist/aun/msg/upload.js +98 -0
  18. package/dist/aun/outbox.js +138 -0
  19. package/dist/aun/rpc/caller.js +42 -0
  20. package/dist/aun/rpc/connection.js +34 -0
  21. package/dist/aun/rpc/index.js +2 -0
  22. package/dist/aun/storage/download.js +29 -0
  23. package/dist/aun/storage/index.js +3 -0
  24. package/dist/aun/storage/manage.js +10 -0
  25. package/dist/aun/storage/upload.js +35 -0
  26. package/dist/channels/aun.js +1051 -288
  27. package/dist/channels/dingtalk.js +58 -5
  28. package/dist/channels/feishu.js +266 -30
  29. package/dist/channels/qqbot.js +67 -12
  30. package/dist/channels/wechat.js +61 -4
  31. package/dist/channels/wecom.js +58 -5
  32. package/dist/cli/agent.js +800 -0
  33. package/dist/cli/index.js +4253 -0
  34. package/dist/{utils → cli}/init-channel.js +211 -621
  35. package/dist/cli/init.js +178 -0
  36. package/dist/config-store.js +613 -0
  37. package/dist/core/{agent-loader.js → baseagent-loader.js} +6 -12
  38. package/dist/core/channel-loader.js +162 -11
  39. package/dist/core/command-handler.js +858 -847
  40. package/dist/core/evolagent-registry.js +191 -371
  41. package/dist/core/evolagent.js +203 -234
  42. package/dist/core/interaction-router.js +52 -5
  43. package/dist/core/message/im-renderer.js +480 -0
  44. package/dist/core/message/items-formatter.js +61 -0
  45. package/dist/core/message/message-bridge.js +104 -56
  46. package/dist/core/message/message-log.js +91 -0
  47. package/dist/core/message/message-processor.js +309 -142
  48. package/dist/core/message/message-queue.js +3 -3
  49. package/dist/core/permission.js +21 -8
  50. package/dist/core/session/adapters/codex-session-file-adapter.js +24 -2
  51. package/dist/core/session/session-fs-store.js +230 -0
  52. package/dist/core/session/session-manager.js +704 -775
  53. package/dist/core/session/session-mapper.js +87 -0
  54. package/dist/core/trigger/manager.js +122 -0
  55. package/dist/core/trigger/parser.js +128 -0
  56. package/dist/core/trigger/scheduler.js +224 -0
  57. package/dist/{templates → data}/prompts.md +34 -1
  58. package/dist/index.js +431 -275
  59. package/dist/ipc.js +49 -0
  60. package/dist/paths.js +82 -9
  61. package/dist/types.js +8 -2
  62. package/dist/utils/atomic-write.js +79 -0
  63. package/dist/utils/channel-helpers.js +46 -0
  64. package/dist/utils/cross-platform.js +0 -18
  65. package/dist/utils/instance-registry.js +433 -0
  66. package/dist/utils/log-writer.js +216 -0
  67. package/dist/utils/logger.js +24 -77
  68. package/dist/utils/media-cache.js +23 -0
  69. package/dist/utils/{upgrade.js → npm-ops.js} +52 -21
  70. package/dist/utils/process-introspect.js +144 -0
  71. package/dist/utils/stats.js +192 -0
  72. package/dist/watch-msg.js +529 -0
  73. package/evolclaw-install-aun.md +114 -46
  74. package/kits/aun/meta.md +25 -0
  75. package/kits/aun/role.md +25 -0
  76. package/kits/channels/aun.md +25 -0
  77. package/kits/evolclaw/commands.md +31 -0
  78. package/kits/evolclaw/identity-tools.md +26 -0
  79. package/kits/evolclaw/self-summary.md +29 -0
  80. package/kits/evolclaw/tools.md +25 -0
  81. package/kits/templates/group.md +20 -0
  82. package/kits/templates/private.md +9 -0
  83. package/kits/templates/system-fragments/personal-context.md +3 -0
  84. package/kits/templates/system-fragments/self-intro.md +5 -0
  85. package/kits/templates/system-fragments/speaker-intro.md +5 -0
  86. package/kits/templates/system-fragments/venue-intro.md +5 -0
  87. package/package.json +7 -5
  88. package/data/evolclaw.sample.json +0 -60
  89. package/dist/channels/aun-ops.js +0 -275
  90. package/dist/cli.js +0 -2178
  91. package/dist/config.js +0 -591
  92. package/dist/core/agent-registry.js +0 -450
  93. package/dist/core/evolagent-schema.js +0 -72
  94. package/dist/core/message/stream-flusher.js +0 -238
  95. package/dist/core/message/thought-emitter.js +0 -162
  96. package/dist/core/reload-hooks.js +0 -87
  97. package/dist/prompts/templates.js +0 -122
  98. package/dist/templates/skills.md +0 -66
  99. package/dist/utils/channel-fingerprint.js +0 -59
  100. package/dist/utils/error-dict.js +0 -63
  101. package/dist/utils/format.js +0 -32
  102. package/dist/utils/init.js +0 -645
  103. package/dist/utils/migrate-project.js +0 -122
  104. package/dist/utils/reload-hooks.js +0 -87
  105. package/dist/utils/stats-collector.js +0 -99
@@ -1,5 +1,8 @@
1
+ import { randomBytes } from 'crypto';
1
2
  import { logger } from '../../utils/logger.js';
2
3
  import { StreamDebouncer } from './stream-debouncer.js';
4
+ import { appendMessageLog, buildInboundEntry } from './message-log.js';
5
+ import { buildEnvelope } from './message-processor.js';
3
6
  /**
4
7
  * MessageBridge — Channel 与 Core 之间的消息桥梁
5
8
  *
@@ -8,7 +11,7 @@ import { StreamDebouncer } from './stream-debouncer.js';
8
11
  * 出站:命令响应通过 sendReply 回调直接发送到渠道
9
12
  */
10
13
  export class MessageBridge {
11
- config;
14
+ defaultProjectPath;
12
15
  sessionManager;
13
16
  processor;
14
17
  messageQueue;
@@ -17,14 +20,14 @@ export class MessageBridge {
17
20
  debouncers = new Map();
18
21
  defaultDebounce;
19
22
  agentRegistry;
20
- constructor(config, sessionManager, processor, messageQueue, cmdHandler, eventBus) {
21
- this.config = config;
23
+ constructor(defaultProjectPath, sessionManager, processor, messageQueue, cmdHandler, eventBus, defaultDebounce) {
24
+ this.defaultProjectPath = defaultProjectPath;
22
25
  this.sessionManager = sessionManager;
23
26
  this.processor = processor;
24
27
  this.messageQueue = messageQueue;
25
28
  this.cmdHandler = cmdHandler;
26
29
  this.eventBus = eventBus;
27
- this.defaultDebounce = config.debounce ?? 2;
30
+ this.defaultDebounce = defaultDebounce ?? 2;
28
31
  }
29
32
  /** Inject EvolAgentRegistry so owner lookups/writes route to agent.json for agent-owned channels. */
30
33
  setAgentRegistry(registry) {
@@ -33,19 +36,13 @@ export class MessageBridge {
33
36
  getDebouncer(channelName, channelType) {
34
37
  let d = this.debouncers.get(channelName);
35
38
  if (!d) {
39
+ // 从 owning agent 的 channel 配置取 debounce,找不到用全局默认
36
40
  let seconds = this.defaultDebounce;
37
- // 查找渠道级 debounce 配置:先用 channelType(如 'feishu')在 config.channels 里查
38
- const type = channelType || channelName;
39
- const raw = this.config.channels?.[type];
40
- if (raw) {
41
- if (Array.isArray(raw)) {
42
- const inst = raw.find((i) => (i.name || type) === channelName);
43
- if (inst?.debounce !== undefined)
44
- seconds = inst.debounce;
45
- }
46
- else if (raw.debounce !== undefined) {
47
- seconds = raw.debounce;
48
- }
41
+ const agent = this.agentRegistry?.resolveByChannel(channelName);
42
+ if (agent) {
43
+ const merged = agent.config;
44
+ if (merged?.debounce !== undefined)
45
+ seconds = merged.debounce;
49
46
  }
50
47
  d = new StreamDebouncer(seconds);
51
48
  this.debouncers.set(channelName, d);
@@ -78,7 +75,7 @@ export class MessageBridge {
78
75
  if (this.cmdHandler.isCommand(cmdContent)) {
79
76
  logger.debug(`[MessageBridge] Command detected: "${cmdContent}", routing to handler`);
80
77
  }
81
- if (await this.handleCommand(cmdContent, channelName, msg.channelId, (text) => sendReply(msg.channelId, text, msg.replyContext), msg.peerId, msg.threadId))
78
+ if (await this.handleCommand(cmdContent, channelName, msg.channelId, (text) => sendReply(msg.channelId, text, msg.replyContext), msg.peerId, msg.threadId, msg.chatType, msg.source, msg.replyContext))
82
79
  return;
83
80
  // 3. session 解析(使用 Channel 层填充的 chatType)
84
81
  const chatType = msg.chatType || 'private';
@@ -93,13 +90,21 @@ export class MessageBridge {
93
90
  if (msg.peerName)
94
91
  metadata.peerName = msg.peerName;
95
92
  }
96
- // Resolve effective project path: agent's projectPath when channel is agent-owned,
97
- // otherwise fall back to global config.projects.defaultPath
93
+ if (chatType === 'group') {
94
+ // 群聊:peerId 是当前消息发送者;groupId channel 提供时存到 metadata
95
+ if (msg.peerId)
96
+ metadata.peerId = msg.peerId;
97
+ if (msg.peerName)
98
+ metadata.peerName = msg.peerName;
99
+ if (msg.groupId)
100
+ metadata.groupId = msg.groupId;
101
+ }
102
+ // Resolve effective project path: 用通道所属 agent 的 projectPath;
103
+ // 通道找不到归属时退回到 globalConfig(一般是测试场景)
98
104
  const owningAgent = this.agentRegistry?.resolveByChannel(channelName);
99
- const effectiveProjectPath = (owningAgent && !owningAgent.isDefault)
100
- ? owningAgent.projectPath
101
- : (this.config.projects?.defaultPath || process.cwd());
102
- const session = await this.sessionManager.getOrCreateSession(channelName, msg.channelId, effectiveProjectPath, msg.threadId, Object.keys(metadata).length ? metadata : undefined, undefined, msg.peerId, chatType);
105
+ const effectiveProjectPath = owningAgent?.projectPath
106
+ ?? this.defaultProjectPath;
107
+ const session = await this.sessionManager.getOrCreateSession(channelName, msg.channelId, effectiveProjectPath, msg.threadId, Object.keys(metadata).length ? metadata : undefined, undefined, msg.peerId, chatType, undefined, msg.selfId, msg.channelType || effectiveChannelType);
103
108
  // 4. 消息前缀(由 policy 决定)
104
109
  const channelInfo = this.processor.getChannelInfo?.(channelName);
105
110
  if (channelInfo?.policy) {
@@ -109,7 +114,10 @@ export class MessageBridge {
109
114
  }
110
115
  // 5. 构造完整消息(channel 字段存实例名,用于 session 精确匹配)
111
116
  const fullMessage = {
112
- channel: channelName, channelId: msg.channelId, content,
117
+ channel: channelName,
118
+ channelType: msg.channelType || effectiveChannelType,
119
+ channelId: msg.channelId, content,
120
+ selfId: msg.selfId,
113
121
  chatType,
114
122
  images: msg.images, timestamp: Date.now(),
115
123
  peerId: msg.peerId, peerName: msg.peerName,
@@ -118,6 +126,19 @@ export class MessageBridge {
118
126
  mentions: msg.mentions, threadId: msg.threadId,
119
127
  replyContext: msg.replyContext,
120
128
  };
129
+ // 5.5 写入消息记录(入方向)
130
+ const chatDir = this.sessionManager.getChatDir(session);
131
+ appendMessageLog(chatDir, buildInboundEntry({
132
+ from: msg.peerId || 'unknown',
133
+ to: msg.selfId || 'self',
134
+ chatType,
135
+ groupId: msg.groupId ?? null,
136
+ msgId: msg.messageId ?? null,
137
+ content,
138
+ replyTo: msg.replyContext?.replyToMessageId ?? null,
139
+ permMode: session.identity?.role ?? null,
140
+ timestamp: fullMessage.timestamp,
141
+ }));
121
142
  // 6. ACK + debounce/enqueue
122
143
  // ACK 在到达时立即做(每条独立 ACK),不等合并
123
144
  // Interrupt 模式(单聊)→ 入队前 debounce 合并
@@ -125,7 +146,7 @@ export class MessageBridge {
125
146
  if (fullMessage.messageId)
126
147
  adapter?.acknowledge?.(fullMessage.messageId).catch(() => { });
127
148
  const isInterrupt = chatType !== 'group';
128
- const enqueueAgentName = (owningAgent && !owningAgent.isDefault) ? owningAgent.name : '[default]';
149
+ const enqueueAgentName = owningAgent?.name ?? '<unknown>';
129
150
  const doEnqueue = async (m) => {
130
151
  return this.messageQueue.enqueue(session.id, m, session.projectPath, {
131
152
  interruptible: isInterrupt,
@@ -169,71 +190,98 @@ export class MessageBridge {
169
190
  const result = await this.cmdHandler.execMenu(parsed.cmd, parsed.mode, channel, msg.channelId, msg.peerId);
170
191
  const base = { type: 'menu.response', cmd: parsed.cmd };
171
192
  const response = JSON.stringify('error' in result ? { ...base, error: result.error } : { ...base, data: result.data });
172
- if (adapter?.sendCustomPayload) {
173
- adapter.sendCustomPayload(msg.channelId, response);
174
- }
175
- else {
176
- await sendReply(msg.channelId, response);
177
- }
193
+ await this.sendCustomResponse(adapter, channel, msg.channelId, response, sendReply);
178
194
  }
179
195
  else if (parsed.cmd) {
180
196
  // 动态子菜单查询
181
197
  const items = await this.cmdHandler.getSubMenuItems(parsed.cmd, channel, msg.channelId, msg.peerId);
182
198
  const response = JSON.stringify({ type: 'menu.response', cmd: parsed.cmd, items: items ?? [] });
183
- if (adapter?.sendCustomPayload) {
184
- adapter.sendCustomPayload(msg.channelId, response);
185
- }
186
- else {
187
- await sendReply(msg.channelId, response);
188
- }
199
+ await this.sendCustomResponse(adapter, channel, msg.channelId, response, sendReply);
189
200
  }
190
201
  else {
191
202
  // 全量菜单
192
203
  const identity = this.sessionManager.resolveIdentity(channel, msg.peerId);
193
204
  const items = this.cmdHandler.getMenuItems(identity.role, msg.chatType || 'private');
194
205
  const response = JSON.stringify({ type: 'menu.response', items });
195
- if (adapter?.sendCustomPayload) {
196
- adapter.sendCustomPayload(msg.channelId, response);
197
- }
198
- else {
199
- await sendReply(msg.channelId, response);
200
- }
206
+ await this.sendCustomResponse(adapter, channel, msg.channelId, response, sendReply);
201
207
  }
202
208
  return true;
203
209
  }
204
210
  return false;
205
211
  }
206
- /** 首次交互自动绑定 owner */
212
+ /** menu.query 响应:优先走 adapter.send(custom),降级 sendReply */
213
+ async sendCustomResponse(adapter, channel, channelId, response, sendReply) {
214
+ if (adapter?.send) {
215
+ const agentName = this.agentRegistry?.resolveByChannel(channel)?.name ?? '<unknown>';
216
+ const envelope = buildEnvelope({
217
+ taskId: `menu-${randomBytes(4).toString('hex')}`,
218
+ channel,
219
+ channelId,
220
+ agentName,
221
+ });
222
+ await adapter.send(envelope, { kind: 'custom', channelType: channel, payload: response });
223
+ }
224
+ else {
225
+ await sendReply(channelId, response);
226
+ }
227
+ }
228
+ /** 首次交互自动绑定 owner —— 通过 channel-routed self-agent 完成 */
207
229
  async autoBindOwner(channel, userId) {
208
- // Registry-first: route owner queries/writes to the agent that owns this channel.
209
- // Falls back to evolclaw.json for default-agent channels.
210
- const { getOwner, setOwner } = await import('../../config.js');
211
- const currentOwner = this.agentRegistry?.getOwner?.(channel) ?? getOwner(this.config, channel);
212
- // currentOwner === undefined means either no owner set, or instance not found
213
- // In both cases, try to set — setOwner is a no-op for unknown instances
230
+ const currentOwner = this.agentRegistry?.getOwner?.(channel);
214
231
  if (currentOwner === undefined) {
215
232
  if (this.agentRegistry?.setChannelOwner) {
216
233
  this.agentRegistry.setChannelOwner(channel, userId);
217
234
  }
218
235
  else {
219
- setOwner(this.config, channel, userId);
236
+ logger.warn(`[Owner] no agentRegistry; skip auto-bind for ${channel}`);
237
+ return;
220
238
  }
221
239
  logger.info(`[Owner] Auto-bound ${channel} owner: ${userId}`);
222
240
  this.eventBus.publish({ type: 'channel:owner-bound', channel, userId });
223
241
  }
224
242
  }
225
243
  /** 命令快速路径:返回 true 表示已处理 */
226
- async handleCommand(content, channel, channelId, sendReply, userId, threadId) {
244
+ async handleCommand(content, channel, channelId, sendReply, userId, threadId, chatType, source, replyContext) {
227
245
  if (!this.cmdHandler.isCommand(content))
228
246
  return false;
229
- logger.info(`[${channel}] ${channelId}: ${content}`);
230
- const cmdResult = await this.cmdHandler.handle(content, channel, channelId, (_cid, text, opts) => sendReply(text), userId, threadId);
231
- logger.debug(`[MessageBridge] handleCommand: result type=${typeof cmdResult}, value=${cmdResult === null ? 'null' : cmdResult === undefined ? 'undefined' : 'string'}`);
247
+ logger.info(`[${channel}] ${channelId}: ${content}${source === 'card-trigger' ? ' [card]' : ''}`);
248
+ const cmdResult = await this.cmdHandler.handle(content, channel, channelId, (_cid, text, opts) => sendReply(text), userId, threadId, chatType, source);
249
+ logger.debug(`[MessageBridge] handleCommand: result type=${typeof cmdResult}`);
232
250
  if (cmdResult === undefined)
233
251
  return false;
234
252
  if (cmdResult) {
253
+ // 规范化为 OutboundPayload:string → command.result 包装;object → 透传
254
+ let payload;
255
+ if (typeof cmdResult === 'string') {
256
+ payload = { kind: 'command.result', text: cmdResult };
257
+ }
258
+ else if (typeof cmdResult === 'object' && cmdResult !== null && 'kind' in cmdResult) {
259
+ payload = cmdResult;
260
+ }
261
+ else {
262
+ // 不识别的返回值,按已处理但无回显处理
263
+ return true;
264
+ }
265
+ // 出站走 adapter.send 统一入口
266
+ const adapter = this.processor.getChannelInfo?.(channel)?.adapter;
267
+ const envelope = buildEnvelope({
268
+ taskId: `cmd-${randomBytes(5).toString('hex')}`,
269
+ channel,
270
+ channelId,
271
+ agentName: this.agentRegistry?.resolveByChannel(channel)?.name ?? '<unknown>',
272
+ chatmode: 'interactive',
273
+ replyContext,
274
+ });
235
275
  try {
236
- await sendReply(cmdResult);
276
+ if (adapter?.send) {
277
+ await adapter.send(envelope, payload);
278
+ }
279
+ else {
280
+ // 降级路径:渠道未实现 send 时回退到原有 sendReply(仅文本)
281
+ const fallbackText = ('text' in payload && typeof payload.text === 'string') ? payload.text : '';
282
+ if (fallbackText)
283
+ await sendReply(fallbackText);
284
+ }
237
285
  }
238
286
  catch (error) {
239
287
  logger.error(`[${channel}] Failed to send command response:`, error);
@@ -0,0 +1,91 @@
1
+ import path from 'path';
2
+ import { appendJsonl, chatDirPath } from '../session/session-fs-store.js';
3
+ import { logger } from '../../utils/logger.js';
4
+ const MESSAGE_LOG_FILE = 'messages.jsonl';
5
+ // 入方向去重:最近 msgId 缓存(LRU 风格,最多 200 条)
6
+ const recentMsgIds = new Set();
7
+ const DEDUP_MAX = 200;
8
+ function isDuplicate(msgId) {
9
+ if (!msgId)
10
+ return false;
11
+ if (recentMsgIds.has(msgId))
12
+ return true;
13
+ if (recentMsgIds.size >= DEDUP_MAX) {
14
+ const first = recentMsgIds.values().next().value;
15
+ recentMsgIds.delete(first);
16
+ }
17
+ recentMsgIds.add(msgId);
18
+ return false;
19
+ }
20
+ function formatTimestampMs(epochMs) {
21
+ const d = new Date(epochMs);
22
+ const yyyy = d.getFullYear();
23
+ const mo = String(d.getMonth() + 1).padStart(2, '0');
24
+ const dd = String(d.getDate()).padStart(2, '0');
25
+ const hh = String(d.getHours()).padStart(2, '0');
26
+ const mi = String(d.getMinutes()).padStart(2, '0');
27
+ const ss = String(d.getSeconds()).padStart(2, '0');
28
+ const ms = String(d.getMilliseconds()).padStart(3, '0');
29
+ return `${yyyy}-${mo}-${dd} ${hh}:${mi}:${ss}.${ms}`;
30
+ }
31
+ export function messageLogPath(chatDir) {
32
+ return path.join(chatDir, MESSAGE_LOG_FILE);
33
+ }
34
+ export function resolveChatDir(sessionsDir, channelType, channelId, selfId) {
35
+ return chatDirPath(sessionsDir, channelType, channelId, selfId);
36
+ }
37
+ export function appendMessageLog(chatDir, entry) {
38
+ if (entry.dir === 'in' && isDuplicate(entry.msgId)) {
39
+ logger.debug(`[MessageLog] Duplicate msgId skipped: ${entry.msgId}`);
40
+ return;
41
+ }
42
+ try {
43
+ appendJsonl(messageLogPath(chatDir), entry);
44
+ }
45
+ catch (e) {
46
+ logger.warn(`[MessageLog] Failed to write message log: ${e}`);
47
+ }
48
+ }
49
+ export function buildInboundEntry(opts) {
50
+ const ts = opts.timestamp || Date.now();
51
+ const isCommand = opts.content.startsWith('/');
52
+ return {
53
+ ts,
54
+ time: formatTimestampMs(ts),
55
+ dir: 'in',
56
+ from: opts.from,
57
+ to: opts.to,
58
+ chatType: opts.chatType,
59
+ groupId: opts.groupId ?? null,
60
+ msgId: opts.msgId ?? null,
61
+ msgType: isCommand ? 'command' : 'text',
62
+ content: opts.content,
63
+ replyTo: opts.replyTo ?? null,
64
+ agent: null,
65
+ model: null,
66
+ permMode: opts.permMode ?? null,
67
+ cmdParsed: isCommand ? opts.content.split(/\s/)[0] : null,
68
+ durationMs: null,
69
+ };
70
+ }
71
+ export function buildOutboundEntry(opts) {
72
+ const ts = opts.timestamp || Date.now();
73
+ return {
74
+ ts,
75
+ time: formatTimestampMs(ts),
76
+ dir: 'out',
77
+ from: opts.from,
78
+ to: opts.to,
79
+ chatType: opts.chatType,
80
+ groupId: opts.groupId ?? null,
81
+ msgId: opts.msgId ?? null,
82
+ msgType: 'text',
83
+ content: opts.content,
84
+ replyTo: opts.replyTo ?? null,
85
+ agent: opts.agent ?? null,
86
+ model: opts.model ?? null,
87
+ permMode: null,
88
+ cmdParsed: null,
89
+ durationMs: opts.durationMs ?? null,
90
+ };
91
+ }