@margay/ccl-core 1.4.0-beta.0 → 1.4.5-rc.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.
@@ -1,4 +1,86 @@
1
+ export declare function sanitizeErrorTextWithMetadata(text: string): {
2
+ text: string;
3
+ truncated: boolean;
4
+ };
1
5
  export declare function sanitizeErrorText(text: string): string;
6
+ /** Read facts without invoking user-supplied accessors or coercion hooks. */
7
+ export declare function diagnosticField(value: unknown, key: string): unknown;
8
+ /** Additive Gateway diagnostic: failed attempt facts, never a successful served identity. */
9
+ export type UpstreamDiagnostic = {
10
+ schema_version: 1;
11
+ model?: string;
12
+ deployment?: string;
13
+ upstream_status?: number;
14
+ upstream_request_id?: string;
15
+ code?: string;
16
+ type?: string;
17
+ message?: string;
18
+ message_truncated?: boolean;
19
+ message_omitted_reason?: string;
20
+ classification?: string;
21
+ };
22
+ export declare function sanitizeUpstreamDiagnostic(value: unknown): UpstreamDiagnostic | undefined;
23
+ export type RecoveryDiagnostic = {
24
+ upstream?: UpstreamDiagnostic;
25
+ schemaVersion: 1;
26
+ id: string;
27
+ observations: {
28
+ stopReason?: string;
29
+ upstreamStopReason?: string;
30
+ code?: string;
31
+ message?: string;
32
+ messageTruncated?: boolean;
33
+ httpStatus?: number;
34
+ };
35
+ assessment: {
36
+ category: string;
37
+ evidence: 'local_fact' | 'contract_report' | 'heuristic' | 'unknown';
38
+ scope: string;
39
+ };
40
+ execution: {
41
+ toolState: 'not_started' | 'executing' | 'completed' | 'unknown';
42
+ };
43
+ suggestions: {
44
+ action: string;
45
+ reason: string;
46
+ }[];
47
+ recovery: {
48
+ owner: 'agent' | 'cli' | 'caller' | 'none';
49
+ phase: 'detected' | 'recovering' | 'recovered' | 'blocked' | 'failed' | 'cancelled';
50
+ attempts: number;
51
+ };
52
+ budget?: {
53
+ requestedMaxTokens?: number;
54
+ maxTokens?: number;
55
+ outputLimit?: number;
56
+ contextLimit?: number;
57
+ estimatedInputTokens?: number;
58
+ thinkingBudget?: number;
59
+ contextSafetyMargin?: number;
60
+ budgetSource?: string;
61
+ limitSource?: string;
62
+ contextAccounting?: string;
63
+ };
64
+ cliVersion?: string;
65
+ turnId?: string;
66
+ model?: string;
67
+ servedModel?: string;
68
+ requestedModel?: string;
69
+ resolvedModel?: string;
70
+ sessionId?: string;
71
+ requestId?: string;
72
+ upstreamRequestId?: string;
73
+ servedDeployment?: string;
74
+ agentId?: string;
75
+ parentAgentId?: string;
76
+ origin?: string;
77
+ protocol?: string;
78
+ timestamp?: string;
79
+ logPath?: string;
80
+ logWriteFailed?: boolean;
81
+ };
82
+ /** A versioned, additive wire extension. Unknown fields never cross this boundary. */
83
+ export declare function sanitizeRecoveryDiagnostic(value: unknown): RecoveryDiagnostic | undefined;
2
84
  export type RecoveryOutcome = {
3
85
  status: 'needs_user' | 'blocked_external';
4
86
  reason: string;
@@ -2,14 +2,17 @@
2
2
  const MAX_DETAIL = 4096;
3
3
  const MAX_SCAN = 65_536;
4
4
  // Credential syntax redaction, NOT semantic diagnosis or retry classification.
5
- export function sanitizeErrorText(text) {
6
- return text.slice(0, MAX_SCAN)
5
+ export function sanitizeErrorTextWithMetadata(text) {
6
+ const redacted = text.slice(0, MAX_SCAN)
7
7
  .replace(/\b(?:Bearer|Basic)\s+[^\s,;"'<>]+/gi, '[REDACTED]')
8
8
  .replace(/\bsk-[a-zA-Z0-9_-]+/g, '[REDACTED]')
9
9
  .replace(/\beyJ[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+/g, '[REDACTED]')
10
10
  .replace(/((?:["']?)(?:api[_-]?key|access[_-]?token|refresh[_-]?token|id[_-]?token|token|password|secret|authorization|cookie)(?:["']?)\s*[:=]\s*)(?:"[^"\n]*"|'[^'\n]*'|[^\s&,;]+)/gi, '$1[REDACTED]')
11
- .replace(/(https?:\/\/)[^\s/@]+:[^\s/@]+@/gi, '$1[REDACTED]@')
12
- .slice(0, MAX_DETAIL);
11
+ .replace(/(https?:\/\/)[^\s/@]+:[^\s/@]+@/gi, '$1[REDACTED]@');
12
+ return { text: redacted.slice(0, MAX_DETAIL), truncated: text.length > MAX_SCAN || redacted.length > MAX_DETAIL };
13
+ }
14
+ export function sanitizeErrorText(text) {
15
+ return sanitizeErrorTextWithMetadata(text).text;
13
16
  }
14
17
  function own(value, key) {
15
18
  // Accessors/custom toString/proxies must not break the error handler itself.
@@ -20,6 +23,119 @@ function own(value, key) {
20
23
  return undefined;
21
24
  }
22
25
  }
26
+ /** Read facts without invoking user-supplied accessors or coercion hooks. */
27
+ export function diagnosticField(value, key) {
28
+ return value && typeof value === 'object' ? own(value, key) : undefined;
29
+ }
30
+ export function sanitizeUpstreamDiagnostic(value) {
31
+ if (diagnosticField(value, 'schema_version') !== 1)
32
+ return undefined;
33
+ const result = { schema_version: 1 };
34
+ for (const name of ['model', 'deployment', 'upstream_request_id', 'code', 'type', 'classification', 'message_omitted_reason']) {
35
+ const raw = diagnosticField(value, name);
36
+ if (typeof raw !== 'string' || !raw || raw.length > (name === 'code' || name === 'type' ? 128 : 256))
37
+ continue;
38
+ if (!/^[a-zA-Z0-9._:/-]+$/.test(raw))
39
+ continue;
40
+ const cleaned = sanitizeErrorText(raw);
41
+ if (cleaned === raw)
42
+ result[name] = raw;
43
+ }
44
+ const status = diagnosticField(value, 'upstream_status');
45
+ if (typeof status === 'number' && Number.isInteger(status) && status >= 100 && status <= 599)
46
+ result.upstream_status = status;
47
+ const raw = diagnosticField(value, 'message');
48
+ if (typeof raw === 'string') {
49
+ if (/["']?(?:messages|system|tools|headers|request_body)["']?\s*[:=]\s*[\[{"']/i.test(raw)) {
50
+ result.message_omitted_reason = 'unsafe';
51
+ }
52
+ else {
53
+ const cleaned = sanitizeErrorText(raw).replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '').replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ');
54
+ result.message = cleaned.slice(0, 1024);
55
+ if (cleaned.length > 1024 || raw.length > 4096 || diagnosticField(value, 'message_truncated') === true)
56
+ result.message_truncated = true;
57
+ }
58
+ }
59
+ return result;
60
+ }
61
+ /** A versioned, additive wire extension. Unknown fields never cross this boundary. */
62
+ export function sanitizeRecoveryDiagnostic(value) {
63
+ const field = diagnosticField;
64
+ const id = field(value, 'id');
65
+ const assessment = field(value, 'assessment'), execution = field(value, 'execution');
66
+ const recovery = field(value, 'recovery'), observations = field(value, 'observations');
67
+ const category = field(assessment, 'category'), evidence = field(assessment, 'evidence'), scope = field(assessment, 'scope');
68
+ const toolState = field(execution, 'toolState'), owner = field(recovery, 'owner'), phase = field(recovery, 'phase'), attempts = field(recovery, 'attempts');
69
+ if (field(value, 'schemaVersion') !== 1 || typeof id !== 'string' || !id ||
70
+ typeof category !== 'string' || typeof scope !== 'string' ||
71
+ !['local_fact', 'contract_report', 'heuristic', 'unknown'].includes(evidence) ||
72
+ !['not_started', 'executing', 'completed', 'unknown'].includes(toolState) ||
73
+ !['agent', 'cli', 'caller', 'none'].includes(owner) ||
74
+ !['detected', 'recovering', 'recovered', 'blocked', 'failed', 'cancelled'].includes(phase) ||
75
+ !Number.isSafeInteger(attempts) || attempts < 0 || !observations || typeof observations !== 'object')
76
+ return undefined;
77
+ const result = {
78
+ schemaVersion: 1, id: sanitizeErrorText(id), observations: {},
79
+ assessment: { category: sanitizeErrorText(category), evidence: evidence, scope: sanitizeErrorText(scope) },
80
+ execution: { toolState: toolState }, suggestions: [],
81
+ recovery: { owner: owner, phase: phase, attempts: attempts },
82
+ };
83
+ for (const key of ['stopReason', 'upstreamStopReason', 'code', 'message']) {
84
+ const raw = field(observations, key);
85
+ if (typeof raw !== 'string')
86
+ continue;
87
+ const cleaned = sanitizeErrorTextWithMetadata(raw);
88
+ result.observations[key] = cleaned.text;
89
+ if (key === 'message' && (cleaned.truncated || field(observations, 'messageTruncated') === true))
90
+ result.observations.messageTruncated = true;
91
+ }
92
+ const httpStatus = field(observations, 'httpStatus');
93
+ if (Number.isInteger(httpStatus) && httpStatus >= 100 && httpStatus <= 599)
94
+ result.observations.httpStatus = httpStatus;
95
+ const suggestions = field(value, 'suggestions');
96
+ let isSuggestionsArray = false;
97
+ try {
98
+ isSuggestionsArray = Array.isArray(suggestions);
99
+ }
100
+ catch { /* revoked proxies carry no usable facts */ }
101
+ if (isSuggestionsArray) {
102
+ // Indexed descriptors also avoid getters on array members.
103
+ const length = field(suggestions, 'length');
104
+ for (let i = 0; i < Math.min(typeof length === 'number' ? length : 0, 8); i++) {
105
+ const suggestion = field(suggestions, String(i));
106
+ const action = field(suggestion, 'action'), reason = field(suggestion, 'reason');
107
+ if (typeof action === 'string' && typeof reason === 'string')
108
+ result.suggestions.push({ action: sanitizeErrorText(action), reason: sanitizeErrorText(reason) });
109
+ }
110
+ }
111
+ const budget = field(value, 'budget');
112
+ if (budget && typeof budget === 'object') {
113
+ const safe = {};
114
+ for (const key of ['requestedMaxTokens', 'maxTokens', 'outputLimit', 'contextLimit', 'estimatedInputTokens', 'thinkingBudget', 'contextSafetyMargin']) {
115
+ const raw = field(budget, key);
116
+ if (typeof raw === 'number' && Number.isFinite(raw))
117
+ safe[key] = raw;
118
+ }
119
+ for (const key of ['budgetSource', 'limitSource', 'contextAccounting']) {
120
+ const raw = field(budget, key);
121
+ if (typeof raw === 'string')
122
+ safe[key] = sanitizeErrorText(raw);
123
+ }
124
+ if (Object.keys(safe).length)
125
+ result.budget = safe;
126
+ }
127
+ for (const key of ['model', 'servedModel', 'requestedModel', 'resolvedModel', 'sessionId', 'requestId', 'upstreamRequestId', 'servedDeployment', 'agentId', 'parentAgentId', 'origin', 'protocol', 'timestamp', 'logPath', 'cliVersion', 'turnId']) {
128
+ const raw = field(value, key);
129
+ if (typeof raw === 'string')
130
+ result[key] = sanitizeErrorText(raw);
131
+ }
132
+ const upstream = sanitizeUpstreamDiagnostic(field(value, 'upstream'));
133
+ if (upstream)
134
+ result.upstream = upstream;
135
+ if (field(value, 'logWriteFailed') === true)
136
+ result.logWriteFailed = true;
137
+ return result;
138
+ }
23
139
  /** Optional wire extension. Omit invalid data; never spread arbitrary payloads. */
24
140
  export function sanitizeRecoveryOutcome(value) {
25
141
  if (!value || typeof value !== 'object')
@@ -57,6 +173,16 @@ export function describeError(value) {
57
173
  parts.push(`${key}=${sanitizeErrorText(String(field))}`);
58
174
  }
59
175
  }
176
+ const upstream = sanitizeUpstreamDiagnostic(own(node, 'upstream'));
177
+ if (upstream) {
178
+ for (const key of ['model', 'deployment', 'upstream_status', 'upstream_request_id', 'code', 'type', 'message', 'message_omitted_reason']) {
179
+ if (upstream[key] !== undefined) {
180
+ const detail = `upstream.${key}=${String(upstream[key])}`;
181
+ if (!parts.some(part => part.includes(detail)))
182
+ parts.push(detail);
183
+ }
184
+ }
185
+ }
60
186
  visit(own(node, 'error'), depth + 1);
61
187
  visit(own(node, 'cause'), depth + 1);
62
188
  }
@@ -69,6 +195,16 @@ export function errorDetailsList(errors) {
69
195
  }
70
196
  /** Preserve successful data and the wire shape; sanitize diagnostic fields only. */
71
197
  export function sanitizeErrorEnvelope(message) {
198
+ if (message.type === 'system' && diagnosticField(message, 'subtype') === 'error_diagnostic') {
199
+ const diagnostic = sanitizeRecoveryDiagnostic(diagnosticField(message, 'diagnostic'));
200
+ const result = { type: 'system', subtype: 'error_diagnostic', ...(diagnostic ? { diagnostic } : {}) };
201
+ for (const key of ['session_id', 'uuid', 'timestamp']) {
202
+ const raw = diagnosticField(message, key);
203
+ if (typeof raw === 'string')
204
+ result[key] = sanitizeErrorText(raw);
205
+ }
206
+ return result;
207
+ }
72
208
  const wire = message;
73
209
  if (wire.type === 'result' && wire.is_error === true) {
74
210
  return {
@@ -10,4 +10,5 @@ export { query, queryHandleWithTransport } from './queryHandle.js';
10
10
  export { createSdkMcpServer, tool, SdkMcpRegistry, type SdkMcpToolDefinition, type McpSdkServerConfigWithInstance, } from './mcpServer.js';
11
11
  export { defaultStoreDir, fileSessionStore } from './store.js';
12
12
  export { spawnCliTransport } from './transport.js';
13
+ export type { RecoveryDiagnostic } from './errorDiagnostics.js';
13
14
  export type { AccumulatedUsage, CanUseToolContext, CanUseToolFn, CanUseToolResult, InitializationResult, LifecycleHooks, PendingApproval, PendingDecision, ProviderConfig, Query, QueryOptions, SDKEvent, SessionStore, Transport, Usage, WireMessage, } from './types.js';
package/dist/sdk/query.js CHANGED
@@ -26,7 +26,7 @@
26
26
  * is deleted after it is applied.
27
27
  */
28
28
  import { existsSync, mkdtempSync, symlinkSync } from 'fs';
29
- import { describeError, errorDetailsList, sanitizeErrorText, sanitizeRecoveryOutcome } from './errorDiagnostics.js';
29
+ import { describeError, errorDetailsList, sanitizeErrorEnvelope, sanitizeErrorText, sanitizeRecoveryDiagnostic, sanitizeRecoveryOutcome } from './errorDiagnostics.js';
30
30
  import { interactionResponseSchema } from './interactionResponseSchema.js';
31
31
  import { tmpdir } from 'os';
32
32
  import { dirname, join, resolve } from 'path';
@@ -87,6 +87,8 @@ export function buildCliArgs(options) {
87
87
  // (Anthropic raw message stream) deltas. Default off keeps the minimal event set unchanged.
88
88
  if (options.includePartialMessages)
89
89
  args.push('--include-partial-messages');
90
+ if (options.includeErrorDiagnostics)
91
+ args.push('--include-error-diagnostics');
90
92
  // slice sdk-input-external-mcp (G2): external MCP servers via --mcp-config (JSON string).
91
93
  if (options.mcpServers && Object.keys(options.mcpServers).length > 0) {
92
94
  args.push('--mcp-config', JSON.stringify({ mcpServers: options.mcpServers }));
@@ -321,7 +323,11 @@ export async function* queryWithTransport(prompt, options, transport) {
321
323
  }
322
324
  // slice sdk-observability-hooks-usage (G5): fire onSessionStart once, on the first session_id.
323
325
  let sessionStarted = false;
324
- for await (const message of transport.read()) {
326
+ for await (const rawMessage of transport.read()) {
327
+ // Keep ordinary raw passthrough unchanged. Diagnostic payloads are an
328
+ // untrusted error boundary even when a host asks for raw wire events.
329
+ const message = rawMessage.type === 'system' && rawMessage.subtype === 'error_diagnostic'
330
+ ? sanitizeErrorEnvelope(rawMessage) : rawMessage;
325
331
  if (typeof message.session_id === 'string') {
326
332
  lastSessionId = message.session_id;
327
333
  if (!sessionStarted) {
@@ -333,6 +339,17 @@ export async function* queryWithTransport(prompt, options, transport) {
333
339
  if (options.includeRaw)
334
340
  yield { type: 'raw', message };
335
341
  switch (message.type) {
342
+ case 'system': {
343
+ if (message.subtype === 'error_diagnostic') {
344
+ const diagnostic = sanitizeRecoveryDiagnostic(message.diagnostic);
345
+ if (diagnostic)
346
+ yield {
347
+ type: 'error_diagnostic', diagnostic,
348
+ sessionId: typeof message.session_id === 'string' ? message.session_id : undefined,
349
+ };
350
+ }
351
+ break;
352
+ }
336
353
  case 'assistant': {
337
354
  const sessionId = message.session_id;
338
355
  for (const block of contentBlocks(message.message)) {
@@ -112,7 +112,7 @@ export type QueryOptions = {
112
112
  execPath?: string;
113
113
  /** Escape hatch: extra argv appended verbatim. */
114
114
  extraArgs?: string[];
115
- /** Emit `raw` passthrough events for every subprocess message. */
115
+ /** Emit raw wire events. Error diagnostics remain sanitized and allowlisted. */
116
116
  includeRaw?: boolean;
117
117
  /**
118
118
  * slice sdk-token-streaming (G3): emit `stream_event` deltas as they arrive (token-level
@@ -121,6 +121,12 @@ export type QueryOptions = {
121
121
  * unchanged behavior; the full-block `assistant_text` events are still emitted alongside.
122
122
  */
123
123
  includePartialMessages?: boolean;
124
+ /**
125
+ * Opt in to nonterminal recovery diagnostics from the CLI. Default off keeps
126
+ * the historical wire event surface. A diagnostic supplies observations and
127
+ * suggestions; it is neither a result nor an instruction for the host to retry.
128
+ */
129
+ includeErrorDiagnostics?: boolean;
124
130
  /**
125
131
  * slice sdk-json-schema-output (G4): a JSON Schema for structured output. When set, the CLI
126
132
  * validates the final result against it (retrying the model) and returns `structuredOutput` on
@@ -280,6 +286,10 @@ export type SDKEvent = {
280
286
  type: 'assistant_text';
281
287
  text: string;
282
288
  sessionId?: string;
289
+ } | {
290
+ type: 'error_diagnostic';
291
+ diagnostic: import('./errorDiagnostics.js').RecoveryDiagnostic;
292
+ sessionId?: string;
283
293
  } | {
284
294
  type: 'tool_use';
285
295
  toolName: string;
@@ -0,0 +1,132 @@
1
+ # Duo:一个 CCL 界面中的对等双 Agent 协作
2
+
3
+ > npm 包的公开命令为 `margay`。下文带日期、提交号和本机 `~/.local/bin/ccl` 的记录属于历史验收;该本机入口不是 npm 安装保证提供的命令,也不代表当前候选已通过回归。
4
+
5
+ > 本文描述截至 2026-09-21 已审核的候选提交 `1387fdae`。npm `next` 仍是更早的 `1.4.1-beta-duo.0`,不包含此后的修复;版本号本身不能确认本机 CLI 身份。历史验收记录见源码检出中的 `docs/ccl-duo-ux-validation.md`(不随 npm 包分发)。
6
+
7
+ > 2026-09-21 的 Buddy 化简与 Duo 生命周期修复已完成代码复核和干净检出核验,仍非 npm 发版验收。RC8/RC10/C790 保持 HOLD。
8
+
9
+ Duo 让一个 Agent 执行任务,另一个独立检查结果。双方各有上下文,必须通过两个不同的实际模型发起请求。执行者也要检查审核者的工作:是否真正看过材料、是否做了声称的测试、结论是否成立。
10
+
11
+ 适用于代码、设计方案、实施计划、产品文档和测试报告。通常只需「执行 → 审查 → 修改或完成」,无需逐轮提交 RLL 的 PLAN 和共识记录。普通 `/buddy` 和 Team 入口继续可用。
12
+
13
+ ## 先进入,再给任务
14
+
15
+ 在 CCL 交互界面中进入 Duo;默认按当前路由偏好自动选择两个不同的可用具体模型:
16
+
17
+ ```text
18
+ /duo-agent
19
+ ```
20
+
21
+ `/duo` 是同一个入口的短名。此时可以没有目标,界面保持待命;不会用空任务启动两个 Agent。需要手动指定审核模型时,可以输入:
22
+
23
+ ```text
24
+ /duo --reviewer-model <固定模型ID>
25
+ ```
26
+
27
+ ```text
28
+ 检查这份数据库迁移方案,重点确认老客户端兼容性和回滚步骤。先给出修订方案,再独立审核。
29
+ ```
30
+
31
+ 也可以一次写完:
32
+
33
+ ```text
34
+ /duo --reviewer-model <固定模型ID> 修复解析器漏掉最后一个 token 的问题,补测试并独立审核
35
+ ```
36
+
37
+ 在主输入框直接输入 `启动双 Agent 协作` 或 `开启双 Agent 协作` 也会进入同一待命入口。粘贴的材料、引用、工具输出和队友消息不会取得启动或控制权限。
38
+
39
+ 两个角色必须解析为不同的具体模型 ID;`auto`、`default` 等选择项会在发出模型请求前解析。若没有两种可用模型或模型验证失败,原任务保留在待命状态并显示原因,可调整配置后重试。
40
+
41
+ ## 看懂角色状态
42
+
43
+ 状态区分别显示 Developer 与 Reviewer。`Auto selected: <模型> · Unconfirmed` 或 `Configured: <模型> · Unconfirmed` 是选择信息,不是请求已发出的证据。只有当前会话中该角色自己的 Agent 请求被观察到,才显示 `Responding:` 或 `Last:`。主会话或其它会话的请求不能确认 Duo 角色,`Last:` 也不等于审核通过或任务完成。
44
+
45
+ ## 一次协作怎样结束
46
+
47
+ | 阶段 | 双方做什么 | 是否完成 |
48
+ |---|---|---|
49
+ | 执行 | 执行者完成工作并提交具体版本 | 否 |
50
+ | 审查 | 审核者检查提交材料、验证相关证据,给出 PASS 或 NEEDS_WORK | 否 |
51
+ | 修改 | 根据问题修改并重新提交;旧结论不能批准新内容 | 否 |
52
+ | 反审 | 执行者检查审核是否真实、充分、正确;认可当前 PASS 后明确接受 | 满足全部条件才完成 |
53
+
54
+ 完成还要求两个不同角色的真实模型请求、审核者针对当前提交和要求版本的意见,以及执行者对该意见的核查与接受。单模型直接回答、旧版本 PASS 和模型自称“已审核”都不能算完成。变化后的要求或材料需要重新提交和审核;如果主动使用正式挑战,未解决时也不能接受。普通讨论和不改变要求的意见不需要逐条确认工具门禁。
55
+
56
+ ### 讨论与挑战有什么区别
57
+
58
+ **讨论**是默认方式:双方通过 Team 消息比较方案、追问细节、提出反证并要求复审,不需要专门的挑战工具序列。
59
+
60
+ **挑战**用于影响结论正确性的关键分歧,例如:
61
+
62
+ - 审核者声称测试通过,却没有实际测试结果。
63
+ - 审核遗漏了明确的验收条件。
64
+ - 双方对某项缺陷是否成立存在足以改变下一步工作的分歧。
65
+
66
+ 正式挑战仍可用于需要明确记录的关键分歧。主动创建的正式挑战在解决前会阻止接受;发起方核对补充证据后才能解决或撤回,历史记录保留。
67
+
68
+ ## 工作中加入意见
69
+
70
+ 在主输入框直接补充内容,意见会记录到当前 Duo 任务,并交给双方处理;通常不需要先暂停。意见已记录、已送达和双方已处理是不同状态,收到消息不等于接受了意见。
71
+
72
+ 当前入口保守地把普通补充视为要求变更,使之前的验收结论失效。例如:
73
+
74
+ ```text
75
+ 还需要兼容上一版客户端,请补充兼容性测试。
76
+ ```
77
+
78
+ 如果只是建议,明确使用以下前缀:
79
+
80
+ ```text
81
+ 仅供讨论,无验收变更:可以比较一下按模块拆分文档是否更易读。
82
+ ```
83
+
84
+ 这样的建议不改变验收版本,可通过双方消息讨论,无需逐条 `acknowledge_input`。`@name` 私信和当前查看的队友输入保持原来的独立路由;要向双方补充意见,请回到主输入框。
85
+
86
+ ## 暂停、恢复、退出
87
+
88
+ | 输入 | 效果 |
89
+ |---|---|
90
+ | `/duo pause` | 请求双方暂停,阻止新工作;可取消的当前操作收到取消请求 |
91
+ | `/duo resume` | 等待当前工作停止后,明确恢复双方协作 |
92
+ | `/duo exit` | 立即解除 Duo 输入状态并返回普通会话;尝试有界取消与团队清理,保留审查记录 |
93
+
94
+ 暂停过程中仍可补充意见,补充本身不会恢复运行。存在暂时不能取消的操作时,状态会保持“正在暂停”;等它结束后才是“已暂停”。暂停前发出的晚到结果不能推进旧轮次;已经产生或结果不明的外部副作用需要核对,不能假设撤销成功或自动重复执行。
95
+
96
+ Duo 退出和任务完成不是一回事。任务被接受后 Duo 回到待命,可直接输入下一项任务;即使旧材料后来变化,仍可接新任务或使用 `/duo exit`。清理失败会显示原因,但不能锁住输入。普通 `/exit` 退出 CCL 会话。
97
+
98
+ ### 必要时由用户裁决
99
+
100
+ 有未解决的正式挑战时,先暂停并等待双方停止,再说明裁决理由:
101
+
102
+ ```text
103
+ /duo pause
104
+ /duo adjudicate 兼容旧客户端是本次必须满足的条件,请按此补实现与测试
105
+ ```
106
+
107
+ 裁决会记录用户理由、解决当前未解决挑战,仍保留任务未完成和暂停状态。它不冒充双方审核通过,也不会自动完成或恢复任务。检查后用 `/duo resume` 继续;后续仍需有效审查和执行者反审。
108
+
109
+ ## 代码与文本材料
110
+
111
+ - **Git 工作目录**:提交时记录代码材料的审核收据;审核者可在普通工作区检查和运行测试,不被锁在快照目录。
112
+ - **非 Git 目录**:采用文本材料,适合设计方案、计划和报告;不需要先创建仓库。提交正文保存为 `material.md`,可包含明确选择的附件。
113
+
114
+ 每次审查绑定具体材料和要求版本。不能把“模型说看过了”当作提交材料一致的证明,也不能用一份旧 PASS 批准后来修改的文件。
115
+
116
+ 提交材料的副本只用于绑定审核版本,不是审核者的默认 cwd 或文件工具白名单,也不提供同一操作系统用户下的恶意 Shell 隔离。当前不承诺关闭 CCL 后自动恢复正在进行的 Duo 任务。
117
+
118
+ ## IDE 和其他入口
119
+
120
+ IDE 先通过集成终端运行同一个 CCL 界面。2026-09-17 已实测连接 VS Code;当前构建需要临时禁用 WebSocket 的可选原生 bufferutil 实现:
121
+
122
+ ```sh
123
+ WS_NO_BUFFER_UTIL=1 ~/.local/bin/ccl --ide
124
+ ```
125
+
126
+ 前提是 IDE 已安装兼容扩展、打开同一项目,并与 CCL 使用同一配置目录。完整步骤、默认连接失败记录和连接范围见 [CCL 在 VS Code 中的使用说明](ccl-ide-usage.md)。此临时参数没有修复产品默认行为,也不涉及 Gateway 升级。
127
+
128
+ `/ide` 接通编辑器能力,聊天仍在 CCL 终端。Claude Code、Codex 各自有官方原生聊天扩展;使用它们的聊天面板运行 CCL,以及迁移当前 Duo 会话,均尚未验证。专用 Duo 面板、tmux 中的完整版 RLL 和 SDK 多用户服务另行处理。
129
+
130
+ 普通 `/buddy` 先创建或复用 Team,再按返回的实际团队名启动 Agent;`/buddy --peer-review` 仍需固定审核模型和开发目标。两条 Buddy 路径与 Duo 的完成条件不同,详见[轻量开发审查说明](peer-development-review.md)。
131
+
132
+ 实现范围与历史测试合同见源码检出中的 `docs/ccl-duo-ux-tdd-plan.md` 和 `docs/ccl-duo-ux-validation.md`(不随 npm 包分发)。当前已审核候选通过 4,081 项源码测试、19 项跳过、102 项 Duo 定向测试、类型检查、36 项构建门禁与 5 项 smoke;隔离安装包的有头终端场景分别通过完整审核与接受 15/15 步、下一任务单模型答复未完成负例 18/18 步。它们使用确定性 provider,不代表真实模型判断质量或发版通过。
@@ -0,0 +1,75 @@
1
+ # 在 VS Code 中使用 CCL 与 Duo
2
+
3
+ 更新:2026-09-17。本机安装构建:`7a75f333`(版本显示 `1.4.0-beta.0`)。当前提供终端界面与 IDE 连接;尚未验证用原生聊天面板运行 CCL/Duo。
4
+
5
+ ## 当前验证到了哪里
6
+
7
+ | 路径 | 状态 |
8
+ |---|---|
9
+ | 已安装 CCL 的终端 Duo | 三项 wezterm-gui 有头场景通过,详见[验收记录](ccl-duo-ux-validation.md) |
10
+ | CCL → VS Code 编辑器连接 | 加临时环境变量后,已验证握手、服务能力及实际 TCP 连接 |
11
+ | 默认 CCL → VS Code 连接 | 已复现 `bufferUtil.mask is not a function`,尚未修复 |
12
+ | 在 VS Code 集成终端启动同一 CCL | 可按下文操作;本轮实际连接客户端运行在外部 WezTerm,未独立完成集成终端中的 Duo 全流程验收 |
13
+ | Trae 连接 | 未实测,不由 VS Code 结果推定兼容 |
14
+ | CCL/Duo 原生聊天面板、终端会话迁移 | 尚未验证,不承诺直接切换或保留正在运行的 Duo 状态 |
15
+
16
+ ## 在已打开的测试 VS Code 窗口使用
17
+
18
+ 1. 打开与 CCL 相同的项目目录。
19
+ 2. 如果另一个 CCL 已连接该 VS Code,先在那个 CCL 的 `/ide` 菜单选择 `None`,或退出该 CCL。当前界面提示同一 VS Code 一次连接一个 CCL。
20
+ 3. 在 VS Code 菜单中选择 **终端 → 新建终端**,运行:
21
+
22
+ ```sh
23
+ WS_NO_BUFFER_UTIL=1 ~/.local/bin/ccl --ide
24
+ ```
25
+
26
+ 4. 若需要手动选择连接,在 CCL 内执行 `/ide` 并选中 Visual Studio Code。自动连接设置可按个人偏好选择。
27
+ 5. 在同一 CCL 界面进入 Duo:
28
+
29
+ ```text
30
+ /model
31
+ /duo-agent
32
+ /duo --reviewer-model <固定模型ID>
33
+ ```
34
+
35
+ `/model` 选择执行模型,最后一行配置审核模型,然后直接输入任务。双方可用不同模型。暂停、恢复及反审规则见 [Duo 使用说明](ccl-duo-agent.md)。
36
+
37
+ `WS_NO_BUFFER_UTIL=1` 让本次进程使用 WebSocket 的 JavaScript 实现,绕过已复现的可选原生模块错误。它不会永久修改环境或安装包;Gateway 不需要升级。
38
+
39
+ ## 扩展与配置目录
40
+
41
+ 本次连接使用官方 `anthropic.claude-code` 扩展 2.1.274 的 IDE 服务。CCL 默认在 `~/.ccl/ide` 发现服务,扩展需要随 IDE 启动时取得 `CLAUDE_CONFIG_DIR=~/.ccl`。如果自定义了 `CCL_CONFIG_DIR`,应使用那个实际目录。不要通过复制锁文件、token 或修改原始会话来拼接连接。
42
+
43
+ 为保留日常 VS Code 配置,本次测试使用独立目录:
44
+
45
+ - IDE 用户数据:`~/.local/share/ccl/ide-vscode-session/user-data`
46
+ - 扩展:`~/.local/share/ccl/ide-vscode-session/extensions`
47
+
48
+ 这两个目录中已完成扩展安装。重新打开同一个测试配置时,先进入项目目录,再执行:
49
+
50
+ ```sh
51
+ CLAUDE_CONFIG_DIR="$HOME/.ccl" CCL_CONFIG_DIR="$HOME/.ccl" \
52
+ "/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code" \
53
+ --new-window \
54
+ --user-data-dir "$HOME/.local/share/ccl/ide-vscode-session/user-data" \
55
+ --extensions-dir "$HOME/.local/share/ccl/ide-vscode-session/extensions" \
56
+ "$PWD"
57
+ ```
58
+
59
+ 已运行的 IDE 进程可能沿用原环境;发现服务目录不一致时,先保存文件并关闭这个测试配置的窗口,再用上面的命令启动。无需关闭其他日常 VS Code 窗口。
60
+
61
+ ## 终端聊天与原生聊天面板
62
+
63
+ `/ide` 接通编辑器服务,不会把终端聊天界面转换成图形聊天面板。
64
+
65
+ - **Claude Code 官方扩展**有原生聊天面板,并与官方 Claude CLI 共享会话历史。这是官方 Claude Code 的能力,不能直接推定 CCL 的自定义 Duo 状态同样兼容。[官方文档](https://code.claude.com/docs/en/vs-code)
66
+ - **Codex 官方扩展**有侧边栏聊天、文件/选区上下文和修改审查界面。[OpenAI 官方文档](https://developers.openai.com/codex/ide/)
67
+ - 本机 Claude 扩展提供 `claudeCode.claudeProcessWrapper` 启动入口设置,但它是否足以接入 CCL、多模型与 Duo 的输入/暂停/挑战/恢复,需要单独验证。当前没有将原生面板后端切换为 CCL。
68
+
69
+ 后续可先验证复用已有面板的可行性,再判断是否需要专用扩展;这不是已交付功能。当前也没有把 CCL 会话复制进官方 Claude 或 Codex 的历史记录。
70
+
71
+ ## 证据与未完成项
72
+
73
+ 原 RLL 工作目录的 `.dual-agent/test-evidence/ccl-ide-manual-connection/` 保存默认失败和临时处理后的成功日志。成功握手耗时 25ms,服务为 `Claude Code VSCode MCP` 2.1.274,返回 `hasTools=true`;未逐个执行 IDE 工具或发送真实模型任务。
74
+
75
+ 待处理:默认 WebSocket `bufferutil` 兼容问题的产品修复、集成终端实际 Duo 流程验收、现有原生面板接 CCL 的可行性验证。Gateway/Margay 未因本次连接测试修改。
@@ -0,0 +1,105 @@
1
+ # CCL Developer Quick Start
2
+
3
+ This guide is for developers validating a source checkout of CCL `1.3.1`. It is
4
+ not a place to store shared credentials or production gateway details.
5
+
6
+ ## 1. Install Dependencies
7
+
8
+ ```bash
9
+ git clone https://github.com/YW1975/ccl.git
10
+ cd ccl
11
+ npm install
12
+ ```
13
+
14
+ ## 2. Build And Smoke Check
15
+
16
+ ```bash
17
+ npm run build
18
+ node dist/cli.js --version
19
+ node dist/cli.js --help
20
+ ```
21
+
22
+ Expected version output:
23
+
24
+ ```text
25
+ 1.3.1 (CCL)
26
+ ```
27
+
28
+ ## 3. Optional Gateway Setup
29
+
30
+ Use placeholders in local notes and docs. Never commit real tokens.
31
+
32
+ ```bash
33
+ export CCL_GATEWAY_URL=<GATEWAY_URL>
34
+ export CCL_GATEWAY_KEY=<GATEWAY_TOKEN>
35
+ ```
36
+
37
+ Or configure through the TUI:
38
+
39
+ ```text
40
+ ccl
41
+ /gateway login <GATEWAY_URL> <GATEWAY_TOKEN>
42
+ /gateway doctor
43
+ ```
44
+
45
+ If a shell already has stale gateway variables, clear them before retesting:
46
+
47
+ ```bash
48
+ unset CCL_GATEWAY_URL CCL_GATEWAY_KEY
49
+ ```
50
+
51
+ ## 4. Run Basic Checks
52
+
53
+ ```bash
54
+ npm run typecheck
55
+ npm test
56
+ npm run test:smoke
57
+ ```
58
+
59
+ For CLI-only validation:
60
+
61
+ ```bash
62
+ node dist/cli.js --version
63
+ node dist/cli.js --help
64
+ node dist/cli.js -p "say hello" --max-turns 1
65
+ ```
66
+
67
+ The final command requires valid authentication.
68
+
69
+ ## 5. Useful Developer Commands
70
+
71
+ | Command | Purpose |
72
+ |---|---|
73
+ | `npm run build` | Build CLI and SDK outputs |
74
+ | `npm run build:sdk` | Build SDK declarations and JS |
75
+ | `npm run typecheck` | TypeScript no-emit check |
76
+ | `npm test` | Vitest suite |
77
+ | `npm run test:smoke` | Smoke test suite |
78
+ | `node dist/cli.js --help` | Verify packaged CLI surface |
79
+ | `node dist/cli.js doctor` | Diagnose a trusted checkout |
80
+
81
+ ## 6. Debugging
82
+
83
+ ```bash
84
+ node dist/cli.js --debug --verbose
85
+ node dist/cli.js --debug-file /tmp/ccl-debug.log
86
+ ```
87
+
88
+ Keep debug logs out of public issues unless you have scrubbed:
89
+
90
+ - API keys and gateway tokens.
91
+ - Real gateway hosts that are not intended to be public.
92
+ - User prompts, file paths, and tool inputs that may contain private data.
93
+
94
+ ## 7. Documentation Rule
95
+
96
+ Developer docs must use safe placeholders:
97
+
98
+ ```text
99
+ <GATEWAY_URL>
100
+ <GATEWAY_TOKEN>
101
+ <ANTHROPIC_API_KEY>
102
+ ```
103
+
104
+ Do not reintroduce concrete public IP addresses, shared team keys, or historical
105
+ gateway endpoint strings into current official documentation.