@ai-workbench/remote-agent 0.8.0 → 0.8.1

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.
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- 在 DSH「插件 → 添加插件」填 `@ai-workbench/remote-agent@0.8.0`(由编排发布后可用),或运行 `dsh plugin --profile desktop add @ai-workbench/remote-agent@0.8.0`。
1
+ 在 DSH「插件 → 添加插件」填 `@ai-workbench/remote-agent@0.8.1`(由编排发布后可用),或运行 `dsh plugin --profile desktop add @ai-workbench/remote-agent@0.8.1`。
2
2
 
3
3
  # 远程ACP插件
4
4
 
@@ -52,3 +52,8 @@ Codex单次模式首版仅文本,不转接中途审批;图片或系统/开
52
52
  ## 验证范围
53
53
 
54
54
  当前准备结果与限制记入源码仓`docs/evidence/2026-10-21-X-npm-prep/`(不进npm包)。先前真实emp-a协议诊断及固定三件套GUI是不同轮次,不能代替本次新包安装门。L完整绑定/登录、真实模型、物理断网、HanaMesh及leo E以实际已执行证据为准。主机命令安装来自S,X不修改S仓/安装脚本或登录状态。
55
+
56
+
57
+ D19(0.8.1):相邻正文增量合成连续文本块,工作区首次说明合入真实回复,按官方持久记录核对后才回报“已记入可核对回复”。这不代表界面已经显示。
58
+
59
+ 技术详情由 L 0.7.9 的唯一“远程主机连接”设置节展示,正文只放模型文本。命令目录保留完整描述;未知事件和工具输入输出保留字段、JSON类型及处理结果,未获准原文明确脱敏,数字用量可核对。没有 `remoteConversationDetails:1` 时明确提示“技术详情暂不可用”,本轮不发送模型请求。X 在官方当前 profile 的 `ai-workbench-remote-agent/diagnostics/` 保存私有诊断日志(目录700、文件600、落盘同步);写入/归属/顺序/容量失败中止本轮,不自动重发、换代重投、过期或淘汰。结果未知时只读核对原事件完整回执,未找到仍保持未知。X本轮无新增设置/阈值;L的单事件容量和分页设置在L设置节可见。
package/dist/acp.d.ts CHANGED
@@ -2,12 +2,14 @@ import * as stable from '@agentclientprotocol/sdk';
2
2
  import * as acp from '@agentclientprotocol/sdk/unstable';
3
3
  import { NeedError, type Need } from './remote.ts';
4
4
  import type { BrokerTransport } from './broker.ts';
5
+ import { type SanitizedObservation } from './conversation-details.ts';
5
6
  export type ApprovalOutcome = 'allowed-once' | 'rejected' | 'cancelled' | 'unavailable';
6
7
  export interface AcpHooks {
7
8
  onUpdate?: (notification: acp.SessionNotification) => void;
8
9
  onResponse?: (response: acp.PromptResponse) => void;
9
10
  approve?: (request: acp.RequestPermissionRequest, signal?: AbortSignal) => Promise<ApprovalOutcome>;
10
11
  onNeed?: (need: Need) => void;
12
+ onDetail?: (detail: Readonly<SanitizedObservation>) => void;
11
13
  broker?: BrokerTransport;
12
14
  }
13
15
  export interface AcpStartContext {
@@ -29,6 +31,12 @@ export declare class AcpSession {
29
31
  private updating;
30
32
  private cwd?;
31
33
  private systemPrompt;
34
+ private reports;
35
+ private reportingFailure;
36
+ get diagnostics(): readonly Readonly<SanitizedObservation>[];
37
+ private detail;
38
+ private need;
39
+ private checkReporting;
32
40
  constructor(transport: acp.Stream | acp.AgentApp | stable.AgentApp, hooks: AcpHooks);
33
41
  private observe;
34
42
  get id(): string | undefined;
package/dist/acp.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import * as acp from '@agentclientprotocol/sdk/unstable';
2
2
  import { NeedError } from "./remote.js";
3
+ import { sanitizeObservation } from "./conversation-details.js";
3
4
  /** ACP v1 with explicitly negotiated SDK 2.0 lifecycle/context extensions. */
4
5
  export class AcpSession {
5
6
  connection;
@@ -15,19 +16,45 @@ export class AcpSession {
15
16
  updating = false;
16
17
  cwd;
17
18
  systemPrompt = '';
19
+ reports = [];
20
+ reportingFailure;
21
+ get diagnostics() { return this.reports.map(entry => sanitizeObservation(entry)); }
22
+ detail(observation) {
23
+ const safe = sanitizeObservation(observation);
24
+ this.reports.push(safe);
25
+ try {
26
+ this.hooks.onDetail?.(safe);
27
+ }
28
+ catch (cause) {
29
+ this.reportingFailure ??= { cause };
30
+ this.connection?.close();
31
+ }
32
+ }
33
+ need(need) {
34
+ this.detail({ kind: 'need', severity: 'action-required', code: need.code, summary: need.message, payload: { requires: need.requires } });
35
+ this.hooks.onNeed?.(need);
36
+ }
37
+ checkReporting() { if (this.reportingFailure)
38
+ throw new NeedError('CONVERSATION_DETAILS_FAILED', '技术详情未能记录;本轮未继续发送', '可用的技术详情记录渠道'); }
18
39
  constructor(transport, hooks) {
19
40
  this.hooks = hooks;
20
41
  const app = acp.client({ name: '@ai-workbench/remote-agent' })
21
42
  .onNotification(acp.methods.client.session.update, ({ params: notification }) => {
22
- if (this.busy && notification.sessionId === this.sessionId) {
23
- // Session metadata is not evidence that the current prompt was accepted.
43
+ if (this.sessionId && notification.sessionId !== this.sessionId) {
44
+ this.detail({ kind: 'session', severity: 'warning', code: 'ACP_FOREIGN_SESSION_UPDATE', summary: '收到不属于当前远端会话的通知,未作为正文处理', method: 'session/update', payload: notification });
45
+ return;
46
+ }
47
+ const meta = record(notification._meta);
48
+ if (meta && Object.keys(meta).length)
49
+ this.detail({ kind: 'extension', severity: 'info', code: 'ACP_NOTIFICATION_META', summary: '远端通知扩展', method: 'session/update', payload: notification._meta });
50
+ if (this.busy) {
51
+ // Only actual turn output acknowledges prompt acceptance.
24
52
  if (['agent_message_chunk', 'agent_thought_chunk', 'tool_call', 'tool_call_update'].includes(notification.update.sessionUpdate))
25
53
  this.hooks.broker?.acknowledge(acp.methods.agent.session.prompt);
26
- const meta = record(notification._meta);
27
- if (meta && Object.keys(meta).length)
28
- this.hooks.onNeed?.({ code: 'ACP_EXTENSION_UNSUPPORTED', message: `首版不支持:远端通知扩展字段 ${Object.keys(meta).map(safeLabel).join('、')};扩展值未展开`, requires: '对应的公开扩展映射' });
29
54
  this.hooks.onUpdate?.(notification);
30
55
  }
56
+ else
57
+ this.detail({ kind: notification.update.sessionUpdate === 'available_commands_update' ? 'commands' : 'session', severity: 'info', code: 'ACP_IDLE_UPDATE', summary: '远端会话建立、恢复或空闲时的轨迹', method: 'session/update', payload: notification });
31
58
  })
32
59
  .onRequest(acp.methods.client.session.requestPermission, async ({ params: request, signal: rpcSignal, requestId }) => {
33
60
  if (request.sessionId !== this.sessionId || !this.busy || this.turnSignal?.aborted)
@@ -35,7 +62,7 @@ export class AcpSession {
35
62
  this.hooks.broker?.acknowledge(acp.methods.agent.session.prompt);
36
63
  const toolName = request.toolCall.name ?? record(request.toolCall._meta?.claudeCode)?.toolName;
37
64
  if (toolName === 'AskUserQuestion') {
38
- this.hooks.onNeed?.({ code: 'INTERACTION_UNSUPPORTED', message: '首版不支持:远端提问;已取消,未提交答案', requires: '可承载此提问的官方交互接口' });
65
+ this.need({ code: 'INTERACTION_UNSUPPORTED', message: '首版不支持:远端提问;已取消,未提交答案', requires: '可承载此提问的官方交互接口' });
39
66
  return { outcome: { outcome: 'cancelled' } };
40
67
  }
41
68
  const eligible = request.options.filter(option => {
@@ -43,11 +70,13 @@ export class AcpSession {
43
70
  const sameKind = request.options.filter(candidate => candidate.kind === option.kind);
44
71
  const unique = single && sameKind.length === 1 && request.options.filter(candidate => candidate.optionId === option.optionId).length === 1;
45
72
  if (!unique)
46
- this.hooks.onNeed?.({ code: 'APPROVAL_OPTION_UNSUPPORTED', message: `首版不支持:${option.name} / ${option.optionId} / ${option.kind}`, requires: '唯一对应的允许一次或拒绝一次选项' });
73
+ this.detail({ kind: 'tool', severity: 'info', code: 'APPROVAL_OPTION_NOT_OFFERED', summary: '此远端审批选项未映射到单次审批', method: 'session/request_permission', payload: option });
47
74
  return unique;
48
75
  });
49
- if (!eligible.length)
76
+ if (!eligible.length) {
77
+ this.need({ code: 'APPROVAL_OPTION_UNSUPPORTED', message: '远端没有可唯一映射的单次审批选项,此动作未获授权', requires: '唯一对应的允许一次或拒绝一次选项' });
50
78
  return { outcome: { outcome: 'cancelled' } };
79
+ }
51
80
  const presented = { ...request, options: eligible };
52
81
  const token = this.turnToken;
53
82
  const id = this.sessionId;
@@ -78,7 +107,7 @@ export class AcpSession {
78
107
  }
79
108
  }
80
109
  catch {
81
- this.hooks.onNeed?.({ code: 'APPROVAL_FAILED', message: '审批渠道失败或连接已结束;此动作未获授权', requires: '可用的 DSH 审批渠道和原连接' });
110
+ this.need({ code: 'APPROVAL_FAILED', message: '审批渠道失败或连接已结束;此动作未获授权', requires: '可用的 DSH 审批渠道和原连接' });
82
111
  }
83
112
  const kind = outcome === 'allowed-once' ? 'allow_once' : outcome === 'rejected' ? 'reject_once' : undefined;
84
113
  const option = kind && eligible.find(item => item.kind === kind);
@@ -87,23 +116,24 @@ export class AcpSession {
87
116
  broker.authorizeResponse(requestId, generation);
88
117
  return { outcome: { outcome: 'selected', optionId: option.optionId } };
89
118
  }
90
- this.hooks.onNeed?.({ code: outcome === 'allowed-once' ? 'APPROVAL_OPTION_UNSUPPORTED' : 'APPROVAL_UNAVAILABLE',
119
+ this.need({ code: outcome === 'allowed-once' ? 'APPROVAL_OPTION_UNSUPPORTED' : 'APPROVAL_UNAVAILABLE',
91
120
  message: '此动作未获授权;审批已取消、不可用或没有可映射的单次选项', requires: '本次动作的有效审批' });
92
121
  return { outcome: { outcome: 'cancelled' } };
93
122
  })
94
123
  .onRequest(acp.methods.client.elicitation.create, () => {
95
- this.hooks.onNeed?.({ code: 'INTERACTION_UNSUPPORTED', message: '首版不支持:远端提问或信息收集;已取消,未提交答案', requires: '可承载此提问的官方交互接口' });
124
+ this.need({ code: 'INTERACTION_UNSUPPORTED', message: '首版不支持:远端提问或信息收集;已取消,未提交答案', requires: '可承载此提问的官方交互接口' });
96
125
  return { action: 'cancel' };
97
126
  })
98
127
  .onNotification(acp.methods.client.elicitation.complete, () => {
99
- this.hooks.onNeed?.({ code: 'INTERACTION_UNSUPPORTED', message: '首版不支持:远端提问完成通知;未提交答案', requires: '可承载此提问的官方交互接口' });
128
+ this.need({ code: 'INTERACTION_UNSUPPORTED', message: '首版不支持:远端提问完成通知;未提交答案', requires: '可承载此提问的官方交互接口' });
100
129
  })
101
- .onRequest('*', () => {
102
- this.hooks.onNeed?.({ code: 'ACP_EXTENSION_UNSUPPORTED', message: '首版不支持:远端请求的扩展交互;未授权此动作', requires: '受支持的公开 ACP 交互接口' });
130
+ .onRequest('*', ({ method, params }) => {
131
+ this.detail({ kind: 'extension', severity: 'action-required', code: 'ACP_UNKNOWN_REQUEST', summary: '未知远端交互请求未获授权', method, payload: params });
132
+ this.need({ code: 'ACP_EXTENSION_UNSUPPORTED', message: '首版不支持:远端请求的扩展交互;未授权此动作', requires: '受支持的公开 ACP 交互接口' });
103
133
  throw new acp.RequestError(-32601, '不支持此扩展交互');
104
134
  })
105
- .onNotification('*', () => {
106
- this.hooks.onNeed?.({ code: 'ACP_EXTENSION_UNSUPPORTED', message: '首版不支持:收到未知的远端扩展通知', requires: '受支持的公开 ACP 通知接口' });
135
+ .onNotification('*', ({ method, params }) => {
136
+ this.detail({ kind: 'extension', severity: 'warning', code: 'ACP_UNKNOWN_NOTIFICATION', summary: '收到未知远端扩展通知,已保留技术记录', method, payload: params });
107
137
  });
108
138
  if ('readable' in transport && 'writable' in transport)
109
139
  this.connection = app.connect(this.observe(transport));
@@ -126,8 +156,7 @@ export class AcpSession {
126
156
  const params = record(raw?.params);
127
157
  const update = record(params?.update);
128
158
  if (raw?.method === acp.methods.client.session.update && typeof update?.sessionUpdate === 'string' && !knownUpdates.has(update.sessionUpdate)) {
129
- if (this.busy && params?.sessionId === this.sessionId)
130
- this.hooks.onNeed?.({ code: 'ACP_UPDATE_UNSUPPORTED', message: `首版不支持:远端事件 ${safeLabel(update.sessionUpdate)};未展开未知事件内容`, requires: '对应的公开 ACP 显示映射' });
159
+ this.detail({ kind: 'extension', severity: 'warning', code: 'ACP_UNKNOWN_UPDATE', summary: '收到未知远端会话事件,已保留结构', method: 'session/update', payload: params });
131
160
  return;
132
161
  }
133
162
  controller.enqueue(message);
@@ -166,12 +195,13 @@ export class AcpSession {
166
195
  signal?.addEventListener('abort', abort, { once: true });
167
196
  try {
168
197
  const hello = await this.connection.agent.request(acp.methods.agent.initialize, {
169
- protocolVersion: acp.PROTOCOL_VERSION, clientInfo: { name: '@ai-workbench/remote-agent', version: '0.8.0' },
198
+ protocolVersion: acp.PROTOCOL_VERSION, clientInfo: { name: '@ai-workbench/remote-agent', version: '0.8.1' },
170
199
  clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false, session: { notices: {} } },
171
200
  }, { cancellationSignal: signal });
172
201
  this.hooks.broker?.acknowledge(acp.methods.agent.initialize);
173
202
  if (hello.protocolVersion !== acp.PROTOCOL_VERSION)
174
203
  throw new NeedError('ACP_VERSION_MISMATCH', 'ACP 协议版本不兼容', '支持 ACP v1 的远端 agent');
204
+ this.checkReporting();
175
205
  this.negotiated = hello.agentCapabilities ?? {};
176
206
  const systemPrompt = context?.systemPrompt;
177
207
  if (systemPrompt && !this.negotiated.sessionCapabilities?.systemPrompt) {
@@ -197,6 +227,7 @@ export class AcpSession {
197
227
  this.method = acp.methods.agent.session.new;
198
228
  }
199
229
  this.hooks.broker?.acknowledge(this.method);
230
+ this.checkReporting();
200
231
  this.ready = true;
201
232
  this.cwd = cwd;
202
233
  this.systemPrompt = systemPrompt ?? '';
@@ -205,6 +236,7 @@ export class AcpSession {
205
236
  catch (error) {
206
237
  if (signal?.aborted)
207
238
  throw new NeedError('ABORTED', '会话建立已取消;尚无本轮输出', '重新连接主机');
239
+ this.checkReporting();
208
240
  throw acpNeed(error);
209
241
  }
210
242
  finally {
@@ -212,6 +244,7 @@ export class AcpSession {
212
244
  }
213
245
  }
214
246
  async updateContext(context, signal) {
247
+ this.checkReporting();
215
248
  if (!this.ready || !this.sessionId || !this.cwd)
216
249
  throw new NeedError('SESSION_REQUIRED', '我需要先建立远端会话', 'ACP 会话');
217
250
  if (this.busy || this.updating)
@@ -233,12 +266,14 @@ export class AcpSession {
233
266
  this.hooks.broker?.acknowledge(method);
234
267
  this.systemPrompt = text;
235
268
  this.ready = true;
236
- this.hooks.onNeed?.({ code: 'SYSTEM_PROMPT_UPDATE_DEFERRED', message: '系统与开发者上下文变更已通过原会话恢复接口传送。Claude 恢复会话沿用首次系统提示,新要求在下次上下文整理后生效;不能据此认定本轮已立即生效。', requires: '远端实际应用新要求;Claude 须完成下一次上下文整理' });
269
+ this.need({ code: 'SYSTEM_PROMPT_UPDATE_DEFERRED', message: '系统与开发者上下文变更已通过原会话恢复接口传送。Claude 恢复会话沿用首次系统提示,新要求在下次上下文整理后生效;不能据此认定本轮已立即生效。', requires: '远端实际应用新要求;Claude 须完成下一次上下文整理' });
270
+ this.checkReporting();
237
271
  return method;
238
272
  }
239
273
  catch (error) {
240
274
  if (signal?.aborted)
241
275
  throw new NeedError('ABORTED', '上下文更新已取消;原会话状态需确认', '重新连接原会话');
276
+ this.checkReporting();
242
277
  throw acpNeed(error);
243
278
  }
244
279
  finally {
@@ -254,6 +289,7 @@ export class AcpSession {
254
289
  signal?.throwIfAborted();
255
290
  const prompt = typeof input === 'string' ? [{ type: 'text', text: input }] : input;
256
291
  this.validatePrompt(prompt);
292
+ this.checkReporting();
257
293
  this.busy = true;
258
294
  this.turnSignal = signal;
259
295
  this.turnToken = Symbol('turn');
@@ -262,7 +298,7 @@ export class AcpSession {
262
298
  const cancel = () => {
263
299
  rejectAbort?.(new NeedError('ABORTED', '已请求取消本轮,远端状态需确认', '确认主机取消状态'));
264
300
  void this.connection.agent.notify(acp.methods.agent.session.cancel, { sessionId: this.sessionId }).catch(() => {
265
- this.hooks.onNeed?.({ code: 'CANCEL_UNCONFIRMED', message: '连接中断,无法确认主机已取消本轮', requires: '恢复连接以确认远端状态' });
301
+ this.need({ code: 'CANCEL_UNCONFIRMED', message: '连接中断,无法确认主机已取消本轮', requires: '恢复连接以确认远端状态' });
266
302
  });
267
303
  };
268
304
  signal?.addEventListener('abort', cancel, { once: true });
@@ -272,12 +308,14 @@ export class AcpSession {
272
308
  this.hooks.broker?.acknowledge(acp.methods.agent.session.prompt);
273
309
  signal?.throwIfAborted();
274
310
  this.hooks.broker?.completePrompt();
311
+ this.checkReporting();
275
312
  this.hooks.onResponse?.(result);
276
313
  return result.stopReason;
277
314
  }
278
315
  catch (error) {
279
316
  if (signal?.aborted)
280
317
  throw new NeedError('ABORTED', '本轮已取消;已请求主机停止', '确认主机取消状态');
318
+ this.checkReporting();
281
319
  throw acpNeed(error);
282
320
  }
283
321
  finally {
@@ -0,0 +1,47 @@
1
+ export type JsonValue = null | boolean | number | string | JsonValue[] | {
2
+ [key: string]: JsonValue;
3
+ };
4
+ export interface DetailScope {
5
+ instanceId: string;
6
+ epoch: number;
7
+ sessionId: string;
8
+ provider: 'claude' | 'codex';
9
+ turn: number;
10
+ step: number;
11
+ }
12
+ export interface DetailObservation {
13
+ kind: 'extension' | 'commands' | 'tool' | 'usage' | 'notice' | 'need' | 'session';
14
+ severity: 'info' | 'warning' | 'action-required';
15
+ code: string;
16
+ summary: string;
17
+ method?: string;
18
+ payload?: unknown;
19
+ }
20
+ export type SanitizedObservation = Omit<DetailObservation, 'payload'> & {
21
+ payload: JsonValue;
22
+ };
23
+ export interface DetailRecord extends Omit<DetailObservation, 'payload'>, DetailScope {
24
+ version: 1;
25
+ eventId: string;
26
+ sequence: number;
27
+ payload: JsonValue;
28
+ }
29
+ export type DetailSink = {
30
+ append(record: Readonly<DetailRecord>): Promise<void>;
31
+ };
32
+ /** Only objects created by this module are trusted as redaction markers. */
33
+ export declare function isSanitizedRedaction(value: unknown): boolean;
34
+ /** Sanitize an internal observation without attaching any L wire semantics. */
35
+ export declare function sanitizeObservation(observation: DetailObservation, protectModelMaterial?: boolean): Readonly<SanitizedObservation>;
36
+ /** Internal diagnostic recorder; a sink is not an assertion that an L wire API exists. */
37
+ export declare class ConversationDetails {
38
+ private readonly scope;
39
+ private readonly sink;
40
+ private readonly records;
41
+ private tail;
42
+ private firstFailure;
43
+ constructor(scope: DetailScope, sink: DetailSink);
44
+ record(observation: DetailObservation): void;
45
+ flush(): Promise<void>;
46
+ snapshot(): readonly DetailRecord[];
47
+ }
@@ -0,0 +1,319 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ const redactionMarkers = new WeakSet();
3
+ function redaction(fields) {
4
+ const marker = { redacted: true, ...fields };
5
+ redactionMarkers.add(marker);
6
+ return marker;
7
+ }
8
+ /** Only objects created by this module are trusted as redaction markers. */
9
+ export function isSanitizedRedaction(value) {
10
+ return value !== null && typeof value === 'object' && redactionMarkers.has(value);
11
+ }
12
+ const sensitiveWords = new Set(['auth', 'token', 'tokens', 'password', 'passwords', 'passwd', 'secret', 'secrets', 'authorization', 'cookie', 'cookies', 'credential', 'credentials', 'key', 'keys']);
13
+ const sensitiveCompounds = new Set([
14
+ 'apikey', 'privatekey', 'publickey', 'accesskey', 'secretkey', 'sshkey', 'encryptionkey', 'signingkey',
15
+ 'devicecode', 'usercode', 'onetimecode', 'verificationcode', 'codeverifier', 'authcode', 'authorizationcode', 'authpayload', 'authorizationpayload',
16
+ 'accesstoken', 'refreshtoken', 'idtoken', 'apitoken', 'authtoken', 'sessiontoken', 'bearertoken',
17
+ 'clientsecret', 'tokenpayload', 'tokenvalue', 'passwordpayload', 'passwordvalue', 'secretpayload', 'secretvalue',
18
+ 'credentialpayload', 'credentialspayload', 'cookiepayload', 'keypayload',
19
+ ]);
20
+ // These whole words are safe despite containing a credential-like substring.
21
+ // redactedKeys is the recorder's explicit entry-wrapper marker and is stable
22
+ // across repeated sanitization. It is not an exception for arbitrary values.
23
+ const safeWholeFieldWords = new Set(['author', 'monkey', 'keyboard', 'secretary', 'keyword', 'keywords', 'tokenizer', 'tokenization', 'redactedkeys']);
24
+ // A recognized suffix supplies a field boundary after case information is lost.
25
+ // Prefixes remain unrestricted: employeePassword and customApiKey must not depend
26
+ // on an inventory of employee/customer/application-specific field prefixes.
27
+ const sensitiveFieldSuffixes = new Set([
28
+ '', 'code', 'payload', 'value', 'values', 'data', 'header', 'headers', 'config', 'configuration',
29
+ 'option', 'options', 'detail', 'details', 'info',
30
+ ]);
31
+ const unsafeKeys = new Set(['__proto__', 'constructor', 'prototype']);
32
+ function isSensitiveFieldWord(word) {
33
+ if (safeWholeFieldWords.has(word))
34
+ return false;
35
+ if (sensitiveWords.has(word) || sensitiveCompounds.has(word))
36
+ return true;
37
+ for (const root of sensitiveWords) {
38
+ for (let index = word.indexOf(root); index !== -1; index = word.indexOf(root, index + 1)) {
39
+ // Match roots at the start, or roots ending a field (possibly followed by
40
+ // a known suffix), rather than fuzzy containment at any word position.
41
+ if (index === 0 || sensitiveFieldSuffixes.has(word.slice(index + root.length)))
42
+ return true;
43
+ }
44
+ }
45
+ return false;
46
+ }
47
+ function isSensitiveKey(key) {
48
+ if (safeWholeFieldWords.has(key.toLowerCase()))
49
+ return false;
50
+ // Preserve the original camel/delimiter rule. Anchored field-shape matching
51
+ // additionally recognizes the same common fields after case normalization.
52
+ const camelWords = key.replace(/([a-z0-9])([A-Z])/g, '$1 $2').toLowerCase().split(/[^a-z0-9]+/);
53
+ const words = key.toLowerCase().split(/[^a-z0-9]+/);
54
+ return camelWords.some(word => sensitiveWords.has(word)) || words.some(isSensitiveFieldWord)
55
+ || sensitiveCompounds.has(key.replace(/[^a-z0-9]/gi, '').toLowerCase());
56
+ }
57
+ function unsupported(type, reason) {
58
+ return { unsupported: true, type, reason };
59
+ }
60
+ function credentialText(value) {
61
+ return /\bBearer\s+\S+/i.test(value)
62
+ || /\bsk-[a-z\d_-]+/i.test(value)
63
+ || /-----BEGIN (?:[A-Z\d]+ )*PRIVATE KEY-----/i.test(value)
64
+ || /\b(?:gh[pousr]_|github_pat_|xox[baprs]-)[a-z\d_-]+/i.test(value);
65
+ }
66
+ function cleanUrls(value) {
67
+ let changed = false;
68
+ const safe = value.replace(/\b[a-z][a-z\d+.-]*:\/\/[^\s<>"'`]+/gi, raw => {
69
+ let url;
70
+ try {
71
+ url = new URL(raw);
72
+ }
73
+ catch {
74
+ changed = true;
75
+ return '[已脱敏:无法解析URL]';
76
+ }
77
+ const userinfo = /^[a-z][a-z\d+.-]*:\/\/[^/?#]*@/i.test(raw);
78
+ if (!userinfo && !raw.includes('?') && !raw.includes('#'))
79
+ return raw;
80
+ url.username = '';
81
+ url.password = '';
82
+ url.search = '';
83
+ url.hash = '';
84
+ changed = true;
85
+ return url.toString();
86
+ });
87
+ return { value: safe, changed };
88
+ }
89
+ function safeText(value) {
90
+ if (credentialText(value))
91
+ return redaction({ reason: '凭据文本' });
92
+ const url = cleanUrls(value);
93
+ return url.changed ? redaction({ reason: 'URL敏感部分', value: url.value }) : value;
94
+ }
95
+ function safeLabel(value) {
96
+ if (typeof value !== 'string')
97
+ throw new TypeError('Detail observation labels must be strings');
98
+ // Inspect credentials before removing control characters, so a bearer newline cannot evade detection.
99
+ if (credentialText(value))
100
+ return '[已脱敏:凭据文本]';
101
+ const cleaned = value.replace(/[\u0000-\u001f\u007f-\u009f]/g, '');
102
+ if (credentialText(cleaned))
103
+ return '[已脱敏:凭据文本]';
104
+ const url = cleanUrls(cleaned);
105
+ return url.changed ? `[已脱敏:URL敏感部分] ${url.value}` : cleaned;
106
+ }
107
+ /** JSON-only snapshot. Non-JSON input is represented, never coerced or executed. */
108
+ function snapshotPayload(value, ancestors = new Set(), protectModelMaterial = false) {
109
+ if (isSanitizedRedaction(value))
110
+ return value;
111
+ if (value === null || typeof value === 'boolean')
112
+ return value;
113
+ if (typeof value === 'string')
114
+ return safeText(value);
115
+ if (typeof value === 'number')
116
+ return Number.isFinite(value) ? value : unsupported('number', '非有限数值不能表示为JSON');
117
+ if (typeof value !== 'object')
118
+ return unsupported(typeof value, '非JSON值');
119
+ if (ancestors.has(value))
120
+ return unsupported('object', '循环引用');
121
+ let descriptors;
122
+ let isArray;
123
+ try {
124
+ isArray = Array.isArray(value);
125
+ const prototype = Object.getPrototypeOf(value);
126
+ if (!isArray && prototype !== Object.prototype && prototype !== null)
127
+ return unsupported('object', '非JSON普通对象');
128
+ if (Object.getOwnPropertySymbols(value).some(symbol => Object.getOwnPropertyDescriptor(value, symbol)?.enumerable)) {
129
+ return unsupported('object', 'JSON不能表示符号字段');
130
+ }
131
+ descriptors = Object.getOwnPropertyDescriptors(value);
132
+ }
133
+ catch {
134
+ return unsupported('object', '无法读取对象结构');
135
+ }
136
+ if (Object.keys(descriptors).some(key => descriptors[key].enumerable && unsafeKeys.has(key))) {
137
+ return unsupported('object', '不允许的原型字段');
138
+ }
139
+ ancestors.add(value);
140
+ try {
141
+ if (isArray) {
142
+ // Array metadata beyond indexed items is not representable in a JSON array.
143
+ const length = descriptors.length?.value;
144
+ if (Object.keys(descriptors).some(key => descriptors[key].enumerable && (!/^(0|[1-9]\d*)$/.test(key) || Number(key) >= length))) {
145
+ return unsupported('array', 'JSON数组不能表示额外字段');
146
+ }
147
+ return Array.from({ length }, (_, index) => {
148
+ const descriptor = descriptors[String(index)];
149
+ return !descriptor ? unsupported('undefined', '数组空位')
150
+ : 'value' in descriptor ? snapshotPayload(descriptor.value, ancestors, protectModelMaterial)
151
+ : unsupported('accessor', '不执行访问器');
152
+ });
153
+ }
154
+ const result = {};
155
+ const entries = [];
156
+ let redactedKeys = false;
157
+ for (const [key, descriptor] of Object.entries(descriptors)) {
158
+ if (!descriptor.enumerable)
159
+ continue;
160
+ const name = safeText(key);
161
+ const value = 'value' in descriptor ? descriptor.value : undefined;
162
+ const normalized = key.replace(/[^a-z0-9]/gi, '').toLowerCase();
163
+ const counterNames = new Set(['inputtokens', 'outputtokens', 'cachedinputtokens', 'cachedreadtokens', 'cachedwritetokens', 'thoughtTokens'.toLowerCase(), 'totaltokens', 'cachewriteinputtokens', 'cachereadinputtokens', 'reasoningoutputtokens']);
164
+ const validCounter = (v) => typeof v === 'number' && Number.isSafeInteger(v) && v >= 0;
165
+ const counter = counterNames.has(normalized) && validCounter(value)
166
+ || normalized === 'tokencount' && value !== null && typeof value === 'object' && Object.values(Object.getOwnPropertyDescriptors(value)).every(d => 'value' in d && validCounter(d.value)) && Object.keys(value).every(k => counterNames.has(k.replace(/[^a-z0-9]/gi, '').toLowerCase()));
167
+ const material = protectModelMaterial && (/(?:headers?|body|oldtext|newtext|aggregatedoutput|stdout|stderr)$/.test(normalized)
168
+ || ['text', 'content', 'prompt', 'input', 'output', 'rawinput', 'rawoutput', 'messages', 'modelinput', 'modeloutput', 'patch', 'diff', 'arguments', 'command', 'commandline', 'filecontent', 'data', 'message', 'usercode', 'devicecode', 'onetimecode'].includes(normalized));
169
+ const child = ((!counter && isSensitiveKey(key)) || material) ? redaction({ reason: '敏感字段' })
170
+ : 'value' in descriptor ? snapshotPayload(descriptor.value, ancestors, protectModelMaterial)
171
+ : unsupported('accessor', '不执行访问器');
172
+ if (typeof name !== 'string')
173
+ redactedKeys = true;
174
+ // Only safe property names may become object keys. The entry form keeps
175
+ // distinct original fields even when multiple names redact identically.
176
+ if (typeof name === 'string')
177
+ result[name] = child;
178
+ entries.push({ name, value: child });
179
+ }
180
+ return redactedKeys ? { redactedKeys: true, entries } : result;
181
+ }
182
+ finally {
183
+ ancestors.delete(value);
184
+ }
185
+ }
186
+ function freezeDeep(value) {
187
+ if (value !== null && typeof value === 'object') {
188
+ for (const child of Object.values(value))
189
+ freezeDeep(child);
190
+ Object.freeze(value);
191
+ }
192
+ return value;
193
+ }
194
+ function cloneFrozen(value) { return freezeDeep(structuredClone(value)); }
195
+ /** No raw values from unclassified events or tool I/O are approved. The
196
+ * structure and original JSON types remain inspectable, including unknown keys. */
197
+ function opaqueStructure(value) {
198
+ if (Array.isArray(value))
199
+ return value.map(opaqueStructure);
200
+ if (value !== null && typeof value === 'object') {
201
+ if (isSanitizedRedaction(value))
202
+ return value;
203
+ return Object.fromEntries(Object.entries(value).map(([name, child]) => [name, opaqueStructure(child)]));
204
+ }
205
+ return redaction({ valueType: value === null ? 'null' : typeof value });
206
+ }
207
+ /** Structural identifiers are approved only at the SDK/ACP envelope and its
208
+ * immediate item/update. Arbitrary result subtrees never inherit that trust. */
209
+ function toolStructure(value, level = 'envelope') {
210
+ if (!value || typeof value !== 'object' || Array.isArray(value))
211
+ return opaqueStructure(value);
212
+ if (isSanitizedRedaction(value))
213
+ return value;
214
+ return Object.fromEntries(Object.entries(value).map(([key, child]) => {
215
+ const field = key.replace(/[^a-z0-9]/gi, '').toLowerCase();
216
+ if (typeof child === 'string' && ['id', 'type', 'kind', 'status', 'code', 'name', 'toolcallid', 'optionid', 'sessionid', 'sessionupdate'].includes(field))
217
+ return [key, child];
218
+ if (typeof child === 'number' && ['exitcode', 'durationms'].includes(field))
219
+ return [key, child];
220
+ if (level === 'envelope' && ['item', 'update', 'toolCall'].includes(key))
221
+ return [key, toolStructure(child, 'item')];
222
+ return [key, opaqueStructure(child)];
223
+ }));
224
+ }
225
+ function catalogStructure(value, level = 'root') {
226
+ if (!value || typeof value !== 'object' || Array.isArray(value))
227
+ return opaqueStructure(value);
228
+ if (isSanitizedRedaction(value))
229
+ return value;
230
+ return Object.fromEntries(Object.entries(value).map(([key, child]) => {
231
+ if (typeof child === 'string' && ((level === 'entry' && ['name', 'description'].includes(key)) || (level === 'input' && key === 'hint') || (level === 'root' && ['sessionId', 'sessionUpdate'].includes(key))))
232
+ return [key, child];
233
+ if (level === 'root' && key === 'update')
234
+ return [key, catalogStructure(child)];
235
+ if (level === 'root' && key === 'availableCommands' && Array.isArray(child))
236
+ return [key, child.map(v => catalogStructure(v, 'entry'))];
237
+ if (level === 'entry' && key === 'input')
238
+ return [key, child === null ? null : catalogStructure(child, 'input')];
239
+ return [key, opaqueStructure(child)];
240
+ }));
241
+ }
242
+ function usageStructure(value, key = '') {
243
+ if (Array.isArray(value))
244
+ return value.map(child => usageStructure(child));
245
+ if (value !== null && typeof value === 'object') {
246
+ if (isSanitizedRedaction(value))
247
+ return value;
248
+ return Object.fromEntries(Object.entries(value).map(([name, child]) => [name, usageStructure(child, name)]));
249
+ }
250
+ const field = key.replace(/[^a-z0-9]/gi, '').toLowerCase();
251
+ if (typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 && ['inputtokens', 'outputtokens', 'cachedinputtokens', 'cachedreadtokens', 'cachedwritetokens', 'thoughttokens', 'totaltokens', 'cachewriteinputtokens', 'cachereadinputtokens', 'reasoningoutputtokens'].includes(field))
252
+ return value;
253
+ if (key === 'stopReason' && typeof value === 'string' && ['end_turn', 'max_tokens', 'max_turn_requests', 'refusal', 'cancelled'].includes(value))
254
+ return value;
255
+ return opaqueStructure(value);
256
+ }
257
+ /** Sanitize an internal observation without attaching any L wire semantics. */
258
+ export function sanitizeObservation(observation, protectModelMaterial = false) {
259
+ const payload = snapshotPayload(observation.payload, new Set(), protectModelMaterial && observation.kind !== 'commands');
260
+ return freezeDeep({
261
+ kind: observation.kind, severity: observation.severity,
262
+ code: safeLabel(observation.code), summary: safeLabel(observation.summary),
263
+ ...(observation.method === undefined ? {} : { method: safeLabel(observation.method) }),
264
+ payload: !protectModelMaterial ? payload
265
+ : observation.kind === 'commands' ? catalogStructure(payload)
266
+ : observation.kind === 'usage' ? usageStructure(payload)
267
+ : observation.kind === 'need' ? payload
268
+ : observation.kind === 'tool' || ['SDK_ITEM', 'SDK_TOOL', 'ACP_SESSION_UPDATE', 'ACP_IDLE_UPDATE'].includes(observation.code) ? toolStructure(payload)
269
+ : opaqueStructure(payload),
270
+ });
271
+ }
272
+ function validatedScope(scope) {
273
+ for (const name of ['instanceId', 'sessionId']) {
274
+ if (typeof scope[name] !== 'string' || scope[name].trim().length === 0)
275
+ throw new TypeError(`Invalid detail scope ${name}`);
276
+ }
277
+ for (const name of ['epoch', 'turn', 'step']) {
278
+ if (!Number.isInteger(scope[name]) || scope[name] < (name === 'epoch' ? 0 : 1))
279
+ throw new TypeError(`Invalid detail scope ${name}`);
280
+ }
281
+ if (scope.provider !== 'claude' && scope.provider !== 'codex')
282
+ throw new TypeError('Invalid detail scope provider');
283
+ return { instanceId: scope.instanceId, epoch: scope.epoch, sessionId: scope.sessionId, provider: scope.provider, turn: scope.turn, step: scope.step };
284
+ }
285
+ /** Internal diagnostic recorder; a sink is not an assertion that an L wire API exists. */
286
+ export class ConversationDetails {
287
+ scope;
288
+ sink;
289
+ records = [];
290
+ tail = Promise.resolve();
291
+ firstFailure;
292
+ constructor(scope, sink) {
293
+ this.scope = freezeDeep(validatedScope(scope));
294
+ this.sink = sink;
295
+ }
296
+ record(observation) {
297
+ const record = freezeDeep({
298
+ ...this.scope, version: 1, eventId: randomUUID(), sequence: this.records.length + 1,
299
+ ...sanitizeObservation(observation),
300
+ });
301
+ this.records.push(record);
302
+ this.tail = this.tail.then(async () => {
303
+ try {
304
+ await this.sink.append(cloneFrozen(record));
305
+ }
306
+ catch (error) {
307
+ // Keep processing later entries in order, but never turn a failed flush green.
308
+ if (!this.firstFailure)
309
+ this.firstFailure = { error };
310
+ }
311
+ });
312
+ }
313
+ async flush() {
314
+ await this.tail;
315
+ if (this.firstFailure)
316
+ throw this.firstFailure.error;
317
+ }
318
+ snapshot() { return cloneFrozen(this.records); }
319
+ }