@margay/ccl-core 1.5.1-alpha.2 → 1.5.1-alpha.3

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.
@@ -0,0 +1,15 @@
1
+ import { z } from 'zod/v4';
2
+ /** Control-channel provenance, not the identity or intent of a person. */
3
+ export declare const hostCancellationSchema: z.ZodObject<{
4
+ origin: z.ZodLiteral<"host">;
5
+ reason: z.ZodEnum<{
6
+ cancelled: "cancelled";
7
+ timeout: "timeout";
8
+ connection_closed: "connection_closed";
9
+ callback_error: "callback_error";
10
+ permission_denied: "permission_denied";
11
+ }>;
12
+ }, z.core.$strict>;
13
+ export type HostCancellation = z.infer<typeof hostCancellationSchema>;
14
+ export declare function readHostCancellation(value: unknown): HostCancellation | undefined;
15
+ export declare function hostCancellationMessage(cancellation: HostCancellation, execution?: 'not_started' | 'unknown'): string;
@@ -0,0 +1,18 @@
1
+ import { z } from 'zod/v4';
2
+ /** Control-channel provenance, not the identity or intent of a person. */
3
+ export const hostCancellationSchema = z.object({
4
+ origin: z.literal('host'),
5
+ reason: z.enum(['cancelled', 'timeout', 'connection_closed', 'callback_error', 'permission_denied']),
6
+ }).strict();
7
+ export function readHostCancellation(value) {
8
+ const parsed = hostCancellationSchema.safeParse(value);
9
+ return parsed.success ? parsed.data : undefined;
10
+ }
11
+ export function hostCancellationMessage(cancellation, execution = 'unknown') {
12
+ return `Host control channel ended this turn (${JSON.stringify(cancellation)}). ` +
13
+ (cancellation.reason === 'permission_denied'
14
+ ? 'The host ended the turn after a permission denial; preserve the recorded decision without inferring who made it. '
15
+ : 'This lifecycle cancellation is not a user permission refusal or a user preference. ') +
16
+ (execution === 'not_started' ? 'This tool call was not executed. ' : 'A missing result does not establish whether an in-flight operation had effects. ') +
17
+ 'Stop this turn. On a later authorized continuation, retain completed results and check existing execution evidence before repeating side effects. This cancellation grants no permission and does not revoke unrelated existing authorization.';
18
+ }
@@ -9,6 +9,7 @@ export { buildCliArgs, buildCliEnv, decideFailClosed, getSessionUsage, prepareSk
9
9
  export { query, queryHandleWithTransport } from './queryHandle.js';
10
10
  export { createSession, Session, type SessionOptions, type SessionControls, type Turn, type TurnResult, type InputMetadata } from './session.js';
11
11
  export type { InputProvenance } from './inputProvenance.js';
12
+ export type { HostCancellation } from './cancellation.js';
12
13
  export { readQueryLifecycle, type QueryLifecycleRead } from './queryLifecycle.js';
13
14
  export type { QueryLifecycleRecord, QueryLifecycleObservation, QueryLifecycleWrite } from './queryLifecycleTypes.js';
14
15
  export { RuntimeResourcesSchema, RuntimeResourceSchema, type RuntimeResources, type RuntimeResource } from './runtimeResources.js';
@@ -20,6 +20,7 @@ type PermissionDecisionWire = {
20
20
  message: string;
21
21
  interaction?: InteractionResponse;
22
22
  interrupt?: boolean;
23
+ cancellation?: import('./cancellation.js').HostCancellation;
23
24
  toolUseID: string;
24
25
  };
25
26
  type CallbackOutcome = {
package/dist/sdk/query.js CHANGED
@@ -34,6 +34,7 @@ import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, statSync, sym
34
34
  import { createHash } from 'crypto';
35
35
  import { describeError, errorDetailsList, sanitizeErrorEnvelope, sanitizeErrorText, sanitizeRecoveryDiagnostic, sanitizeRecoveryOutcome } from './errorDiagnostics.js';
36
36
  import { interactionResponseSchema } from './interactionResponseSchema.js';
37
+ import { hostCancellationSchema } from './cancellation.js';
37
38
  import { tmpdir } from 'os';
38
39
  import { dirname, join, resolve } from 'path';
39
40
  import { fileURLToPath } from 'url';
@@ -196,6 +197,7 @@ export async function decideFailClosed(callback, toolName, input, context, timeo
196
197
  const deny = (message, failClosedReason) => ({
197
198
  kind: 'decision',
198
199
  decision: { behavior: 'deny', message, toolUseID: context.toolUseId,
200
+ cancellation: { origin: 'host', reason: timedOut ? 'timeout' : 'callback_error' },
199
201
  ...(toolName === 'AskUserQuestion' ? { interaction: { outcome: timedOut ? 'timed_out' : 'interaction_unavailable' } } : {}),
200
202
  },
201
203
  failClosedReason,
@@ -239,6 +241,10 @@ export async function decideFailClosed(callback, toolName, input, context, timeo
239
241
  };
240
242
  }
241
243
  case 'deny': {
244
+ const cancellation = hostCancellationSchema.safeParse(raw.cancellation);
245
+ if (raw.cancellation !== undefined && !cancellation.success) {
246
+ return deny('Cancellation provenance was invalid; no user choice was inferred.', 'unrecognized_result');
247
+ }
242
248
  const interaction = interactionResponseSchema.safeParse(raw.interaction);
243
249
  if (raw.interaction !== undefined && !interaction.success) {
244
250
  return deny('Interaction response was invalid; no user choice was inferred.', 'unrecognized_result');
@@ -250,6 +256,8 @@ export async function decideFailClosed(callback, toolName, input, context, timeo
250
256
  message: sanitizeErrorText(raw.message ?? 'Denied by host'),
251
257
  ...(toolName === 'AskUserQuestion' && interaction.success ? { interaction: interaction.data } : {}),
252
258
  ...(raw.interrupt !== undefined && { interrupt: raw.interrupt }),
259
+ ...(cancellation.success ? { cancellation: cancellation.data } : raw.interrupt
260
+ ? { cancellation: { origin: 'host', reason: 'permission_denied' } } : {}),
253
261
  toolUseID: context.toolUseId,
254
262
  },
255
263
  };
@@ -699,6 +707,7 @@ export async function* queryWithTransport(prompt, options, transport) {
699
707
  toolUseId,
700
708
  behavior: outcome.decision.behavior,
701
709
  ...(outcome.decision.behavior === 'deny' && outcome.decision.interaction ? { interaction: outcome.decision.interaction } : {}),
710
+ ...(outcome.decision.behavior === 'deny' && outcome.decision.cancellation ? { cancellation: outcome.decision.cancellation } : {}),
702
711
  source: 'callback',
703
712
  ...(outcome.failClosedReason && {
704
713
  failClosedReason: outcome.failClosedReason,
@@ -25,6 +25,7 @@
25
25
  */
26
26
  import { queryWithTransport, spawnForOptions } from './query.js';
27
27
  import { isAsyncInput } from './queryInput.js';
28
+ import { hostCancellationSchema } from './cancellation.js';
28
29
  let requestCounter = 0;
29
30
  const nextRequestId = () => `ccl-sdk-req-${++requestCounter}-${Math.random().toString(36).slice(2, 10)}`;
30
31
  class QueryHandle {
@@ -81,14 +82,15 @@ class QueryHandle {
81
82
  // The init promise is settled internally; hosts that never ask for the
82
83
  // initialization result must not see an unhandled rejection.
83
84
  this.initPromise.catch(() => { });
84
- void transport.exited.then(() => this.cancelPermission?.(), () => this.cancelPermission?.());
85
+ const retired = () => this.cancelPermission?.({ origin: 'host', reason: 'connection_closed' });
86
+ void transport.exited.then(retired, retired);
85
87
  const callback = options.canUseTool;
86
88
  void this.pump(prompt, callback ? {
87
89
  ...options,
88
90
  canUseTool: async (...args) => {
89
91
  let cancel;
90
92
  const cancelled = new Promise(resolve => {
91
- cancel = () => resolve({ behavior: 'deny', message: 'Permission wait cancelled by host', interrupt: true });
93
+ cancel = cancellation => resolve({ behavior: 'deny', message: 'Permission wait cancelled by host', interrupt: true, cancellation });
92
94
  });
93
95
  this.cancelPermission = cancel;
94
96
  try {
@@ -220,9 +222,12 @@ class QueryHandle {
220
222
  if (response.subtype === 'error') {
221
223
  entry.reject(new Error(String(response.error ?? 'control request failed')));
222
224
  }
223
- else {
225
+ else if (response.subtype === 'success') {
224
226
  entry.resolve(response.response);
225
227
  }
228
+ else {
229
+ entry.reject(new Error('ccl-sdk: invalid control acknowledgement'));
230
+ }
226
231
  }
227
232
  trackInitialize(requestId) {
228
233
  this.pending.set(requestId, {
@@ -278,19 +283,34 @@ class QueryHandle {
278
283
  return Promise.reject(new Error(this.endReason));
279
284
  const requestId = nextRequestId();
280
285
  return new Promise((resolve, reject) => {
281
- this.pending.set(requestId, { resolve, reject });
286
+ const timer = opts.timeoutMs ? setTimeout(() => {
287
+ const entry = this.pending.get(requestId);
288
+ this.pending.delete(requestId);
289
+ entry?.reject(new Error('ccl-sdk: interrupt acknowledgement timed out; stop outcome is unknown'));
290
+ }, opts.timeoutMs) : undefined;
291
+ this.pending.set(requestId, {
292
+ resolve: value => { if (timer)
293
+ clearTimeout(timer); resolve(value); },
294
+ reject: error => { if (timer)
295
+ clearTimeout(timer); reject(error); },
296
+ });
282
297
  this.transport
283
298
  .write({ type: 'control_request', request_id: requestId, request })
284
299
  .catch(error => {
285
300
  if (this.pending.delete(requestId)) {
301
+ if (timer)
302
+ clearTimeout(timer);
286
303
  reject(error instanceof Error ? error : new Error(String(error)));
287
304
  }
288
305
  });
289
306
  });
290
307
  }
291
- interrupt() {
292
- this.cancelPermission?.();
293
- return this.controlRequest({ subtype: 'interrupt' });
308
+ interrupt(cancellation = { origin: 'host', reason: 'cancelled' }) {
309
+ const parsed = hostCancellationSchema.safeParse(cancellation);
310
+ if (!parsed.success)
311
+ return Promise.reject(new Error('ccl-sdk: invalid cancellation provenance'));
312
+ this.cancelPermission?.(parsed.data);
313
+ return this.controlRequest({ subtype: 'interrupt', cancellation: parsed.data }, { timeoutMs: 10000 });
294
314
  }
295
315
  setPermissionMode(mode) {
296
316
  return this.controlRequest({ subtype: 'set_permission_mode', mode });
@@ -372,7 +392,7 @@ class QueryHandle {
372
392
  return (await this.initializationResult()).account;
373
393
  }
374
394
  async close() {
375
- this.cancelPermission?.();
395
+ this.cancelPermission?.({ origin: 'host', reason: 'connection_closed' });
376
396
  this.markDead('ccl-sdk: query closed');
377
397
  await this.transport.close();
378
398
  }
@@ -28,8 +28,8 @@ export declare const QueryLifecycleSchema: z.ZodObject<{
28
28
  actor: z.ZodEnum<{
29
29
  unknown: "unknown";
30
30
  agent: "agent";
31
- human: "human";
32
31
  host: "host";
32
+ human: "human";
33
33
  }>;
34
34
  source: z.ZodString;
35
35
  session_id: z.ZodOptional<z.ZodString>;
@@ -113,8 +113,8 @@ export declare const QueryLifecycleObservationSchema: z.ZodObject<{
113
113
  actor: z.ZodEnum<{
114
114
  unknown: "unknown";
115
115
  agent: "agent";
116
- human: "human";
117
116
  host: "host";
117
+ human: "human";
118
118
  }>;
119
119
  source: z.ZodString;
120
120
  session_id: z.ZodOptional<z.ZodString>;
@@ -29,6 +29,8 @@ export type CanUseToolResult = {
29
29
  interaction?: import('./interactionResponseSchema.js').InteractionResponse;
30
30
  /** Also interrupt the current turn instead of letting the agent continue. */
31
31
  interrupt?: boolean;
32
+ /** Host control-channel provenance; not a human refusal or a new authorization. */
33
+ cancellation?: import('./cancellation.js').HostCancellation;
32
34
  } | {
33
35
  /**
34
36
  * Suspend the session at this tool call (issue P0-2 pending): the tool
@@ -403,6 +405,7 @@ type SDKEventPayload = {
403
405
  behavior: 'allow' | 'deny';
404
406
  /** Why the SDK decided this way when the host callback did not decide. */
405
407
  failClosedReason?: string;
408
+ cancellation?: import('./cancellation.js').HostCancellation;
406
409
  interaction?: import('./interactionResponseSchema.js').InteractionResponse;
407
410
  /** 'pending_decision' when resolved by resumeWithDecision, not the live callback. */
408
411
  source?: 'callback' | 'pending_decision';
@@ -479,7 +482,7 @@ export type InitializationResult = {
479
482
  * the stream is identical to the bare queryWithTransport generator.
480
483
  */
481
484
  export interface Query extends AsyncGenerator<SDKEvent, void, unknown> {
482
- interrupt(): Promise<unknown>;
485
+ interrupt(cancellation?: import('./cancellation.js').HostCancellation): Promise<unknown>;
483
486
  setPermissionMode(mode: string): Promise<unknown>;
484
487
  setModel(model?: string): Promise<unknown>;
485
488
  setMaxThinkingTokens(n: number | null): Promise<unknown>;
@@ -0,0 +1,23 @@
1
+ # 1.5.1-alpha.3
2
+
3
+ 本预发布版在 [1.5.1-alpha.2](1.5.1-alpha.2.md) 基础上增加宿主取消来源契约(#58)和视觉输出预算配置及有界恢复(#59)。使用明确版本或 `alpha` 标签安装;npm 发布状态以 registry 为准,`latest` 保持不变。
4
+
5
+ ## 宿主停止与权限拒绝
6
+
7
+ SDK `handle.interrupt()`、权限回调超时/异常和会话退役会保留宿主取消来源,不再被误写成用户拒绝或用户偏好。普通工具 Deny 继续保留拒绝依据;取消不撤销其他授权,也不授予新权限。
8
+
9
+ `interrupt()` 可接收 `HostCancellation`,权限回调 deny 可附带 `cancellation`;来源也会出现在 `permission_resolved` 和原生 transcript。旧客户端只返回 `deny('Conversation stopped')` 时仍按拒绝处理,需迁移到 interrupt 或显式 cancellation。AskUserQuestion 的非终止 unavailable/timeout 交互保持原行为,不制造用户答案。
10
+
11
+ 取消与结果返回竞态时保留已完成事实,迟到的 allow 不启动已取消的工具。成功停止 ACK 只确认控制请求被接受;拒绝、畸形 ACK、断连或10秒无 ACK 会明确失败,停止结果可能未知。取消不回滚已发生的外部效果,强杀或close也不保证最后消息持久化;恢复前仍需核对事实,不能盲目重放。
12
+
13
+ ## 视觉输出预算
14
+
15
+ CLI 环境变量 `CCL_VISUAL_MAX_OUTPUT_TOKENS` 默认8192;可选 `CCL_VISUAL_RECOVERY_MAX_OUTPUT_TOKENS` 显式授权一次更高预算恢复,默认不启用。SDK通过已有 `options.env` 配置。值必须是正安全整数,恢复预算必须大于初始预算;非法配置在读图或模型请求前失败。Agent模型不能通过工具参数改预算或覆盖视觉模型。
16
+
17
+ 现有模型输出上限和共享上下文限制仍会约束请求。只有完整终态的输出限额失败、无拒绝/过滤、有效预算确实增加且恢复额度未用时,才会用原模型、协议、提示和已授权图片字节重试。预算被模型上限截平时不发无效重试。空响应与输出限额恢复共用一次内容重试,另允许此前一次能力上下文刷新;至多3次推理共用900秒期限。
18
+
19
+ 成功和失败均记录尝试历史、request ID、请求/有效预算、限制来源、重试关联、耗时及累计已知usage。缺少input/output用量时 `usageComplete=false`,不把未知成本当作零。最终未完成不会报视觉通过。成功结果提示也不再无条件暗示此前发生过失败和恢复。
20
+
21
+ 提高预算可能增加成本,不保证提升视觉准确性。原三图同提示的真实模型对照中,8192和16384都单次完成,未复现旧截断;本次真实运行未触发自动恢复,恢复边界由CLI/SDK接线及单元测试验证。模型仍有精确颜色判断误差,完整输出不能替代业务验收。
22
+
23
+ 使用方式及兼容边界见 [SDK API](../../sdk/API.md)。CLI与SDK保持同版本;已运行进程不会热更新,正常结束后新会话使用新版。未改默认模型、权限策略或业务数据,无需数据迁移。仍不承诺跨外部系统exactly-once、模型始终正确理解图像或任意终止都能回收已发生的副作用。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@margay/ccl-core",
3
- "version": "1.5.1-alpha.2",
3
+ "version": "1.5.1-alpha.3",
4
4
  "description": "Core runtime extracted from Claude Code (ccl) for Margay integration",
5
5
  "license": "SEE LICENSE IN README.md",
6
6
  "type": "module",
@@ -54,7 +54,8 @@
54
54
  "docs/releases/1.5.0-alpha.6.md",
55
55
  "docs/releases/1.5.1-alpha.md",
56
56
  "docs/releases/1.5.1-alpha.1.md",
57
- "docs/releases/1.5.1-alpha.2.md"
57
+ "docs/releases/1.5.1-alpha.2.md",
58
+ "docs/releases/1.5.1-alpha.3.md"
58
59
  ],
59
60
  "engines": {
60
61
  "node": ">=22.19.0"
package/sdk/API.md CHANGED
@@ -213,7 +213,7 @@ type CanUseToolFn = (
213
213
 
214
214
  type CanUseToolResult =
215
215
  | { behavior: 'allow'; updatedInput?: Record<string, unknown> } // 放行;updatedInput=改写输入(modify)
216
- | { behavior: 'deny'; message?: string; interrupt?: boolean } // 拒绝;interrupt=true 同时中断本回合
216
+ | { behavior: 'deny'; message?: string; interrupt?: boolean; cancellation?: HostCancellation }
217
217
  | { behavior: 'pending' } // 挂起走异步审批(§5)
218
218
  ```
219
219
 
@@ -222,6 +222,12 @@ type CanUseToolResult =
222
222
  - `allow` 未提供 `updatedInput`(或提供空对象)时按原始 input 执行。
223
223
  - 未配置 `canUseTool` 时 SDK 不启用权限转发(工具按 CLI 自身权限规则走);若此时 CLI 仍发来请求,按 deny 兜底。
224
224
 
225
+ 宿主停止与权限拒绝是不同事件。停止生成可直接 `await handle.interrupt()`;它取消未完成权限等待,默认来源为 `{origin:'host',reason:'cancelled'}`。也可传完整的 `HostCancellation`,reason 支持 `cancelled`、`timeout`、`connection_closed`、`callback_error`、`permission_denied`。`origin` 表示控制通道来源,不推断是谁点击了停止。原始 stream-json 请求为 `{type:'control_request',request_id,request:{subtype:'interrupt',cancellation}}`,省略 cancellation 同样表示宿主取消。
226
+
227
+ 需要在权限回调结束等待时,可返回 `{behavior:'deny',message:'Conversation stopped',cancellation:{origin:'host',reason:'cancelled'}}`。这结束当前轮,工具不执行;`permission_resolved.cancellation` 与原生 transcript 的 `toolUseResult.cancellation` 保留来源。回调超时/异常自动携带 timeout/callback_error。AskUserQuestion 的 timed_out/interaction_unavailable 继续按已有非终止交互失败处理,不制造用户答案。未带 cancellation 的普通 deny 仍是拒绝;deny+interrupt 记录 permission_denied 并保留原拒绝依据,不推断人工决定。旧客户端的 `deny('Conversation stopped')` 仍是拒绝,宿主应迁移到 interrupt 或显式 cancellation,CCL 不通过文字猜测来源。
228
+
229
+ interrupt 的 Promise 只在匹配成功 ACK 后完成;拒绝、畸形 ACK、断连会 reject,10 秒没有 ACK 也会 reject,表示停止结果未知。还须观察当前轮的 result,不能把 ACK 当作工具已回滚。迟到的 allow 不会启动已取消的工具。已完成结果保留;在途操作缺少结果不能证明没有副作用,恢复时先核对事实,不能盲目重放。取消本身不撤销其他已存在授权,也不授予新权限。`end_session` 支持优雅退役并记录 connection_closed;直接强杀进程或 `close()` 是资源清理,不保证最后一条消息已持久化,需按未知结果恢复。
230
+
225
231
  ---
226
232
 
227
233
  ## 5. pending 挂起 / 跨进程恢复(审批闭环)
@@ -673,3 +679,56 @@ operation-replay guarantee, and does not introduce a new permission gate.
673
679
  Check the actual tool schema for `mode`; an older installation with the same
674
680
  candidate version string may not have it. An existence precheck followed by normal
675
681
  Write is not an equivalent no-replacement contract.
682
+
683
+ ## Visual specialist output budget (1.5.1-alpha.3)
684
+
685
+ The synchronous `Agent` specialist `visual-ui-analysis` accepts its existing
686
+ `prompt` and `image_paths`. The host controls its output allowance; the model
687
+ cannot set it through Agent arguments or override the selected vision model.
688
+ CLI processes read `CCL_VISUAL_MAX_OUTPUT_TOKENS` (default `8192`) and optional
689
+ `CCL_VISUAL_RECOVERY_MAX_OUTPUT_TOKENS` (disabled by default). SDK hosts use the
690
+ same variables through `options.env`:
691
+
692
+ ```ts
693
+ const handle = query({
694
+ prompt: 'Inspect the supplied rendered slides with the visual specialist.',
695
+ options: {
696
+ env: {
697
+ CCL_VISUAL_MAX_OUTPUT_TOKENS: '16384',
698
+ CCL_VISUAL_RECOVERY_MAX_OUTPUT_TOKENS: '32768',
699
+ },
700
+ // Supply your normal cwd, credentials and permission callback as appropriate.
701
+ },
702
+ })
703
+ ```
704
+
705
+ Values must be decimal positive safe integers; the recovery value must exceed
706
+ the initial value. Invalid configuration fails before image reads or model I/O.
707
+ The existing provider/configured output limits and shared context budget still
708
+ cap each request. Unknown limits remain unknown; a larger allowance is not a
709
+ claim that the model supports it. Reasoning and visible text share the allowance.
710
+
711
+ A terminal output-limit failure can use the higher allowance once, only when
712
+ its effective budget increases. If a model caps both requests at 8192, no recovery
713
+ request is sent. Recovery reuses the selected model, protocol, prompt and already
714
+ authorized image bytes. It shares one content retry with normal-terminal empty
715
+ response recovery. A capability-context refresh is separately allowed once before
716
+ content recovery, so at most three inference attempts share one 900-second
717
+ operation deadline. Cancellation, refusal, filtering, incomplete streams and an
718
+ exhausted recovery do not trigger a further content retry.
719
+
720
+ The returned visual facts retain every attempt's request ID, requested and
721
+ effective budget, safe `requestBudget` limit snapshot, duration, and one-based
722
+ `retryOfAttempt` / `retryOfRequestId` links. Both completed and failed inspections
723
+ include `visualDiagnostics.usage`, `usageComplete`, and total `durationMs` (from
724
+ post-image-load resolution to terminal outcome). `usageComplete` concerns reported
725
+ input/output counts; optional cache breakdowns may be absent. Missing usage is
726
+ not proof of zero cost: totals sum known values, and `usageComplete: false` marks
727
+ the incomplete accounting. Successful SDK tool usage retains the same numeric
728
+ totals for compatibility. A final failure remains an incomplete inspection,
729
+ preserves earlier failures, and asks the Owner to stop same-condition retries.
730
+
731
+ Increasing output allowance can increase cost and does not establish visual
732
+ accuracy. Verify page observations separately; a complete response alone is not
733
+ business acceptance. These variables require a build containing this feature;
734
+ the previously published alpha.2 does not support them.