@ai-workbench/remote-agent 0.8.1 → 0.8.2

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.1`(由编排发布后可用),或运行 `dsh plugin --profile desktop add @ai-workbench/remote-agent@0.8.1`。
1
+ 在 DSH「插件 → 添加插件」填 `@ai-workbench/remote-agent@0.8.2`(由编排发布后可用),或运行 `dsh plugin --profile desktop add @ai-workbench/remote-agent@0.8.2`。
2
2
 
3
3
  # 远程ACP插件
4
4
 
@@ -56,4 +56,9 @@ Codex单次模式首版仅文本,不转接中途审批;图片或系统/开
56
56
 
57
57
  D19(0.8.1):相邻正文增量合成连续文本块,工作区首次说明合入真实回复,按官方持久记录核对后才回报“已记入可核对回复”。这不代表界面已经显示。
58
58
 
59
- 技术详情由 L 0.7.9 的唯一“远程主机连接”设置节展示,正文只放模型文本。命令目录保留完整描述;未知事件和工具输入输出保留字段、JSON类型及处理结果,未获准原文明确脱敏,数字用量可核对。没有 `remoteConversationDetails:1` 时明确提示“技术详情暂不可用”,本轮不发送模型请求。X 在官方当前 profile 的 `ai-workbench-remote-agent/diagnostics/` 保存私有诊断日志(目录700、文件600、落盘同步);写入/归属/顺序/容量失败中止本轮,不自动重发、换代重投、过期或淘汰。结果未知时只读核对原事件完整回执,未找到仍保持未知。X本轮无新增设置/阈值;L的单事件容量和分页设置在L设置节可见。
59
+ 技术详情由 L 0.7.11 的唯一“远程主机连接”设置节展示,正文只放模型文本。命令目录保留完整描述;未知事件和工具输入输出保留字段、JSON类型及处理结果,未获准原文明确脱敏,数字用量可核对。没有 `remoteConversationDetails:1` 时明确提示“技术详情暂不可用”,本轮不发送模型请求。X 在官方当前 profile 的 `ai-workbench-remote-agent/diagnostics/` 保存私有诊断日志(目录700、文件600、落盘同步);写入/归属/顺序/容量失败中止本轮,不自动重发、换代重投、过期或淘汰。结果未知时只读核对原事件完整回执,未找到仍保持未知。X本轮无新增设置/阈值;L的单事件容量和分页设置在L设置节可见。
60
+
61
+
62
+ 0.8.2 登录检查:需要 L `~0.7.11`,失败来源及固定分支保留到 L“登录前技术详情”(`loginDiagnostics:1`),员工正文保持原文。按 L 明确的本品牌 queued/checking/authorizing 状态订阅等待;不以全局checking猜测在途,不新增刷新、轮询、超时或重试。每轮ensureReady仍是合同规定的显式检查,L的新可信结果可以替代旧检查,较早返回不得覆盖它。
63
+
64
+ L安全规范的officialReason原串完整保留;旧/不支持格式明确记OFFICIAL_REASON_UNSUPPORTED与字节数/SHA,秘密原文不保存。L0.7.11的LOGIN_INSTANCE_MISMATCH记录通过observedLoginInstanceId与原始login.instanceId保存实际观测ID,事件及connection仍绑定当前可信ID/代次;L技术详情和X私有日志均保留真实双ID,不归一、不清空快照。复合未知官方原因以固定summary完整保存OFFICIAL_REASON_UNSUPPORTED及bytes/SHA,不交原文。X私有原快照先同步落盘,再追加L;ACK未知只读核实原事件,跨代不重写/重发。此版本的工程验证不替代leo员工路径复验,真实故障根因尚未证实。
@@ -0,0 +1 @@
1
+ export declare function validOfficialReason(value: unknown): value is string;
@@ -0,0 +1,27 @@
1
+ /** L 0.7.10 officialReason v1 consumer validation; preserve canonical bytes only. */
2
+ const markers = ['AW_REMOTE_COMMAND_KILL_FAILED', 'AUTH_STATUS_JSON_INVALID:SyntaxError', 'Permission denied', 'Host key verification failed', 'Connection refused', 'Connection closed', 'command not found', 'No such file or directory'];
3
+ const names = ['Error', 'TypeError', 'SyntaxError', 'RangeError', 'AbortError', 'DOMException', 'UnknownError'];
4
+ const codes = ['ENOENT', 'EPERM', 'ESRCH', 'EACCES', 'EINVAL', 'EPIPE', 'ECONNRESET', 'ECONNREFUSED', 'ETIMEDOUT', 'ABORT_ERR', 'UNKNOWN'];
5
+ const stages = ['ssh-status', 'ssh-result', 'remote-frame', 'auth-status', 'connect-check-login', 'integration-pre-login', 'process-spawn', 'process-stdin', 'process-output', 'process-runner'];
6
+ const reasons = ['CANCELLED', 'SSH_EXIT_NONZERO', 'SSH_STDERR_NONEMPTY', 'FRAME_TRUNCATED', 'FRAME_JSON_INVALID', 'FRAME_OBJECT_INVALID', 'FRAME_VERSION_INVALID', 'FRAME_NONCE_INVALID', 'FRAME_AFTER_END', 'FRAME_KEYS_INVALID', 'FRAME_START_MISSING', 'FRAME_BASE64_INVALID', 'REMOTE_COMMAND_SIGNAL', 'FRAME_END_EXIT_INVALID', 'FRAME_SEQUENCE_OR_VALUE_INVALID', 'FRAME_END_MISSING', 'STATUS_ARGUMENT_INVALID', 'STATUS_EXECUTION_FAILED', 'STATUS_JSON_INVALID', 'STATUS_OBJECT_INVALID', 'STATUS_KEYS_INVALID', 'STATUS_VERSION_INVALID', 'STATUS_PROVIDER_INVALID', 'STATUS_EXIT_MISMATCH', 'STATUS_LOGGED_IN_INVALID', 'STATUS_STDERR_NONEMPTY', 'STATUS_EXIT_INVALID', 'STATUS_EXCEPTION', 'STATUS_SUPERSEDED', 'SSH_PORT_MISSING', 'STDIN_FAILED', 'SPAWN_FAILED', 'OUTPUT_LIMIT_EXCEEDED', 'PROCESS_SIGNAL'];
7
+ const object = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
8
+ const integer = (v) => Number.isSafeInteger(v) && Number(v) >= 0;
9
+ const member = (list, v) => typeof v === 'string' && list.includes(v);
10
+ function summary(v) {
11
+ return object(v) && Object.keys(v).every(k => ['bytes', 'sha256', 'markers', 'cleanupErrors'].includes(k)) && integer(v.bytes) && typeof v.sha256 === 'string' && /^[a-f0-9]{64}$/.test(v.sha256) && Array.isArray(v.markers) && v.markers.every(m => member(markers, m)) && (v.cleanupErrors === undefined || Array.isArray(v.cleanupErrors) && v.cleanupErrors.every(e => object(e) && Object.keys(e).sort().join(',') === 'errorCode,errorName' && member(['Error', 'TypeError', 'RangeError', 'UnknownError'], e.errorName) && member(['EPERM', 'EACCES', 'EINVAL', 'UNKNOWN'], e.errorCode)));
12
+ }
13
+ export function validOfficialReason(value) {
14
+ if (typeof value !== 'string')
15
+ return false;
16
+ let v;
17
+ try {
18
+ v = JSON.parse(value);
19
+ }
20
+ catch {
21
+ return false;
22
+ }
23
+ if (!object(v) || JSON.stringify(v) !== value || v.version !== 1 || !member(stages, v.stage) || !member(reasons, v.reason))
24
+ return false;
25
+ const fields = { version: x => x === 1, stage: x => member(stages, x), reason: x => member(reasons, x), errorName: x => member(names, x), errorCode: x => member(codes, x), exitCode: x => x === null || typeof x === 'number' && Number.isFinite(x) && Number.isInteger(x), signal: x => member(['SIGTERM', 'SIGKILL', 'SIGABRT', 'SIGHUP', 'SIGINT', 'SIGSEGV', 'OTHER'], x), index: integer, unexpectedCount: integer, maxOutputBytes: x => integer(x) && Number(x) > 0, observedBytes: integer, stderr: summary, output: summary, missing: x => Array.isArray(x) && x.every(k => member(['nonce', 'protocolVersion', 'type', 'data', 'exitCode', 'signal', 'loggedIn', 'provider'], k)), loggedInType: x => member(['null', 'undefined', 'object', 'boolean', 'number', 'string', 'function', 'symbol', 'bigint'], x), exitCodeType: x => member(['undefined', 'object', 'boolean', 'number', 'string', 'function', 'symbol', 'bigint'], x) };
26
+ return Object.entries(v).every(([key, item]) => Object.hasOwn(fields, key) && fields[key](item));
27
+ }
@@ -1,3 +1,4 @@
1
+ import type { ConnectNeed } from '@ai-workbench/connect/api';
1
2
  import type { ConnectTarget } from './provider.ts';
2
3
  import type { ContractInfo, Provider, WorkspaceUse, WorkspaceNoticeReceipt as LWorkspaceNoticeReceipt } from '@ai-workbench/connect/api';
3
4
  /** 正式L0.7公开类型;消费子集不表示已安装或已通过真实产品门。 */
@@ -16,7 +17,7 @@ export type ConnectResult<T> = {
16
17
  code: string;
17
18
  message: string;
18
19
  requires: string;
19
- };
20
+ } & Pick<ConnectNeed, 'officialReason'>;
20
21
  };
21
22
  export interface NativeWorkspaceApi {
22
23
  getContractInfo(): ContractInfo;
package/dist/index.d.ts CHANGED
@@ -5,6 +5,7 @@ import { type AcpHooks } from './acp.ts';
5
5
  import { type RuntimeProvider } from './provider.ts';
6
6
  import { type AgentDefinition } from './agents.ts';
7
7
  import { type ImageReader } from './input.ts';
8
+ import type { LoginDiagnostic } from './login-diagnostic.ts';
8
9
  export declare const name = "ai-workbench-remote-agent";
9
10
  export declare const settingsNamespace = "ai-workbench-codex";
10
11
  export declare const inject: string[];
@@ -47,17 +48,22 @@ type LiveConfig = {
47
48
  };
48
49
  /** DSH volatile settings are handles; adapters freeze their values once per invocation. */
49
50
  export declare function readConfig(config: LiveConfig): PluginConfig;
51
+ export type LoginFailureReporter = (sessionId: string | undefined, code: string, diagnostic: LoginDiagnostic) => Promise<{
52
+ eventId: string;
53
+ recorded: true;
54
+ }>;
50
55
  export declare class CodexRemoteAdapter extends LlmAdapter {
51
56
  private resolveRuntime;
52
57
  private approve?;
53
58
  private readonly providerName;
54
59
  private readonly agent;
55
60
  private readonly readImage?;
61
+ private readonly reportLoginFailure?;
56
62
  private config;
57
63
  private sessions;
58
64
  private turns;
59
65
  private lifetime;
60
- constructor(config: PluginConfig | (() => PluginConfig), resolveRuntime: RuntimeProvider, approve?: InvocationApproval | undefined, providerName?: () => string, agent?: AgentDefinition, readImage?: ImageReader | undefined);
66
+ constructor(config: PluginConfig | (() => PluginConfig), resolveRuntime: RuntimeProvider, approve?: InvocationApproval | undefined, providerName?: () => string, agent?: AgentDefinition, readImage?: ImageReader | undefined, reportLoginFailure?: LoginFailureReporter | undefined);
61
67
  providerInfo(provider: string): {
62
68
  id: string;
63
69
  name: string;
@@ -84,7 +90,7 @@ export declare class CodexRemoteAdapter extends LlmAdapter {
84
90
  /** One official registration owns both routes, preserving opaque replay ownership. */
85
91
  export declare class RemoteAgentsAdapter extends LlmAdapter {
86
92
  private adapters;
87
- constructor(config: () => PluginConfig, makeRuntime: (agent: AgentDefinition) => RuntimeProvider, approve: InvocationApproval, nameFor: (agent: AgentDefinition) => string, readImage?: ImageReader);
93
+ constructor(config: () => PluginConfig, makeRuntime: (agent: AgentDefinition) => RuntimeProvider, approve: InvocationApproval, nameFor: (agent: AgentDefinition) => string, readImage?: ImageReader, reportLoginFailure?: LoginFailureReporter);
88
94
  private adapter;
89
95
  providerInfo(provider: string): {
90
96
  id: string;
package/dist/index.js CHANGED
@@ -55,17 +55,19 @@ export class CodexRemoteAdapter extends LlmAdapter {
55
55
  providerName;
56
56
  agent;
57
57
  readImage;
58
+ reportLoginFailure;
58
59
  config;
59
60
  sessions = new Map();
60
61
  turns = new Map();
61
62
  lifetime = new AbortController();
62
- constructor(config, resolveRuntime, approve, providerName = () => PROVIDER_NAMES.en, agent = CODEX_AGENT, readImage) {
63
+ constructor(config, resolveRuntime, approve, providerName = () => PROVIDER_NAMES.en, agent = CODEX_AGENT, readImage, reportLoginFailure) {
63
64
  super();
64
65
  this.resolveRuntime = resolveRuntime;
65
66
  this.approve = approve;
66
67
  this.providerName = providerName;
67
68
  this.agent = agent;
68
69
  this.readImage = readImage;
70
+ this.reportLoginFailure = reportLoginFailure;
69
71
  this.config = typeof config === 'function' ? config : () => config;
70
72
  }
71
73
  providerInfo(provider) { return { id: provider, name: this.providerName() }; }
@@ -95,7 +97,17 @@ export class CodexRemoteAdapter extends LlmAdapter {
95
97
  if (error instanceof NeedError) {
96
98
  const rpc = /RPC 错误码 (-?\d+)/u.exec(error.requires)?.[1];
97
99
  const message = employeeFailure(error.code) + (rpc ? `(技术详情:RPC 错误码 ${rpc})` : '');
98
- throw new LlmError(message, error.code, { cause: new Error(`诊断代码:${error.code};${error.requires}`) });
100
+ let diagnostic = error.technical ? JSON.stringify(error.technical) : '';
101
+ if (error.technical && this.reportLoginFailure) {
102
+ try {
103
+ const receipt = await this.reportLoginFailure(options.sessionId, error.code, error.technical);
104
+ diagnostic += ';诊断 eventId=' + receipt.eventId;
105
+ }
106
+ catch (failure) {
107
+ diagnostic += ';诊断保存失败:' + (failure instanceof NeedError ? failure.code : 'DIAGNOSTIC_WRITE_FAILED');
108
+ }
109
+ }
110
+ throw new LlmError(message, error.code, { cause: new Error(`诊断代码:${error.code};${error.requires}` + (diagnostic ? ';' + diagnostic : '')) });
99
111
  }
100
112
  throw error;
101
113
  }
@@ -281,8 +293,8 @@ export class CodexRemoteAdapter extends LlmAdapter {
281
293
  /** One official registration owns both routes, preserving opaque replay ownership. */
282
294
  export class RemoteAgentsAdapter extends LlmAdapter {
283
295
  adapters = new Map();
284
- constructor(config, makeRuntime, approve, nameFor, readImage) { super(); for (const agent of AGENTS)
285
- this.adapters.set(agent.id, new CodexRemoteAdapter(config, makeRuntime(agent), approve, () => nameFor(agent), agent, readImage)); }
296
+ constructor(config, makeRuntime, approve, nameFor, readImage, reportLoginFailure) { super(); for (const agent of AGENTS)
297
+ this.adapters.set(agent.id, new CodexRemoteAdapter(config, makeRuntime(agent), approve, () => nameFor(agent), agent, readImage, reportLoginFailure)); }
286
298
  adapter(provider) { const adapter = this.adapters.get(provider); if (!adapter)
287
299
  throw new LlmError('远程模型未注册', 'PROVIDER_NOT_FOUND'); return adapter; }
288
300
  providerInfo(provider) { return this.adapter(provider).providerInfo(provider); }
@@ -304,16 +316,19 @@ export function apply(ctx, live) {
304
316
  };
305
317
  const openByAgent = new Map(AGENTS.map(agent => [agent.id, brokerFactory(get, undefined, config)]));
306
318
  const detailManagers = new Map();
319
+ const managerForProfile = () => {
320
+ const profile = ctx.get('profileContext');
321
+ if (typeof profile?.dir !== 'string')
322
+ throw new NeedError('TECHNICAL_DETAILS_UNAVAILABLE', '技术详情暂不可用:缺少官方profile目录。', '官方profileContext.dir');
323
+ let manager = detailManagers.get(profile.dir);
324
+ if (!manager) {
325
+ manager = createRemoteDetails({ profileDir: profile.dir, getApi: get });
326
+ detailManagers.set(profile.dir, manager);
327
+ }
328
+ return manager;
329
+ };
307
330
  const adapter = new RemoteAgentsAdapter(config, agent => connectProvider(get, { agent, notice: createWorkspaceNoticeConsumer(ctx, agent), details: async (request, api, connection, target) => {
308
- const profile = ctx.get('profileContext');
309
- if (typeof profile?.dir !== 'string')
310
- throw new NeedError('TECHNICAL_DETAILS_UNAVAILABLE', '技术详情暂不可用:缺少官方profile目录。', '官方profileContext.dir');
311
- let manager = detailManagers.get(profile.dir);
312
- if (!manager) {
313
- manager = createRemoteDetails({ profileDir: profile.dir, getApi: get });
314
- detailManagers.set(profile.dir, manager);
315
- }
316
- return manager.open({ sessionId: request.sessionId, provider: agent.loginProvider, identity: `${target.username}@${target.host}:${target.port}`, connection });
331
+ return managerForProfile().open({ sessionId: request.sessionId, provider: agent.loginProvider, identity: `${target.username}@${target.host}:${target.port}`, connection });
317
332
  }, open: async (...args) => {
318
333
  const runtime = await openByAgent.get(agent.id)(...args);
319
334
  runtime.detailContext = () => {
@@ -336,7 +351,25 @@ export function apply(ctx, live) {
336
351
  return { turn, step };
337
352
  };
338
353
  return runtime;
339
- } }), dshApproval(ctx), agent => agent.name[language], readImage);
354
+ } }), dshApproval(ctx), agent => agent.name[language], readImage, (sessionId, code, diagnostic) => {
355
+ const events = sessionId ? ctx.get('agents')?.get(SessionId(sessionId))?.session.snapshotEvents() : undefined;
356
+ let turn = null, step = null;
357
+ for (const event of events ?? []) {
358
+ if (event.type === 'turn/start') {
359
+ turn = event.data.turn;
360
+ step = null;
361
+ }
362
+ else if (event.type === 'turn/end') {
363
+ turn = null;
364
+ step = null;
365
+ }
366
+ else if (event.type === 'step/start' && event.data.turn === turn)
367
+ step = event.data.step;
368
+ else if (event.type === 'step/end')
369
+ step = null;
370
+ }
371
+ return managerForProfile().recordLoginFailure({ sessionId, code, diagnostic, context: { turn, step } });
372
+ });
340
373
  const ids = AGENTS.map(agent => agent.id);
341
374
  const entries = () => AGENTS.map(agent => ({ provider: agent.id, displayName: agent.name[language], settingsNs: settingsNamespace, settingsPath: [] }));
342
375
  const directory = ctx.llm.registerConfigurableProviders(entries());
@@ -0,0 +1,38 @@
1
+ import type { ConnectionState, LoginPromptState } from '@ai-workbench/connect/api';
2
+ export type LoginReason = 'LOGIN_INSTANCE_MISMATCH' | 'LOGIN_PHASE_BLOCKED' | 'LOGIN_PHASE_DISPOSED' | 'LOGIN_CHECKING' | 'LOGIN_AUTHORIZING' | 'LOGIN_PROVIDER_NEED' | 'LOGIN_VALUE_INVALID' | 'LOGIN_REQUIRED' | 'L_ENSURE_READY_FAILED' | 'LOGIN_QUEUED';
3
+ /** Allowlisted protocol facts only: never messages, requires, host details or status command output. */
4
+ export interface LoginDiagnostic {
5
+ version: 1;
6
+ source: 'verified-login' | 'readiness' | 'ensure-ready';
7
+ reason: LoginReason;
8
+ provider: 'claude' | 'codex';
9
+ officialReason?: string;
10
+ officialReasonRejected?: {
11
+ bytes: number;
12
+ sha256: string;
13
+ };
14
+ connection: {
15
+ instanceId: string;
16
+ phase: string;
17
+ epoch: number;
18
+ };
19
+ login: {
20
+ instanceId: string;
21
+ phase: string;
22
+ revision: number;
23
+ provider: 'claude' | 'codex' | null;
24
+ logins: {
25
+ claude: boolean | 'absent' | 'invalid';
26
+ codex: boolean | 'absent' | 'invalid';
27
+ };
28
+ queuedProviders: ('claude' | 'codex')[];
29
+ checkingProviders: ('claude' | 'codex')[];
30
+ providerNeeds: {
31
+ claude: string | null;
32
+ codex: string | null;
33
+ };
34
+ code: string | null;
35
+ };
36
+ }
37
+ export declare function loginDiagnostic(source: LoginDiagnostic['source'], reason: LoginReason, provider: 'claude' | 'codex', connection: ConnectionState, state: LoginPromptState, officialReason?: string): LoginDiagnostic;
38
+ export declare function loginReason(state: LoginPromptState, connection: ConnectionState, provider: 'claude' | 'codex'): LoginReason | undefined;
@@ -0,0 +1,29 @@
1
+ import { validOfficialReason } from "./auth-reason.js";
2
+ import { createHash } from 'node:crypto';
3
+ const code = (value) => typeof value === 'string' && /^[A-Z][A-Z0-9_]*$/u.test(value) ? value : null;
4
+ export function loginDiagnostic(source, reason, provider, connection, state, officialReason) {
5
+ const value = (brand) => typeof state.logins?.[brand] === 'boolean' ? state.logins[brand] : state.logins?.[brand] === undefined ? 'absent' : 'invalid';
6
+ const rawReason = officialReason ?? state.providerNeeds?.[provider]?.officialReason;
7
+ const safeReason = typeof rawReason !== 'string' ? {} : validOfficialReason(rawReason) ? { officialReason: rawReason } : { officialReasonRejected: { bytes: Buffer.byteLength(rawReason, 'utf8'), sha256: createHash('sha256').update(rawReason).digest('hex') } };
8
+ return { version: 1, source, reason, provider, ...safeReason, connection: { instanceId: connection.instanceId, phase: connection.phase, epoch: connection.epoch }, login: { instanceId: state.instanceId, phase: state.phase, revision: state.revision, provider: state.provider ?? null, logins: { claude: value('claude'), codex: value('codex') }, queuedProviders: state.queuedProviders ?? [], checkingProviders: Array.isArray(state.checkingProviders) ? state.checkingProviders.filter(p => p === 'claude' || p === 'codex') : [], providerNeeds: { claude: code(state.providerNeeds?.claude?.code), codex: code(state.providerNeeds?.codex?.code) }, code: code(state.technicalDetails?.code) } };
9
+ }
10
+ export function loginReason(state, connection, provider) {
11
+ if (state.instanceId !== connection.instanceId)
12
+ return 'LOGIN_INSTANCE_MISMATCH';
13
+ if (state.phase === 'blocked')
14
+ return 'LOGIN_PHASE_BLOCKED';
15
+ if (state.phase === 'disposed')
16
+ return 'LOGIN_PHASE_DISPOSED';
17
+ if (state.queuedProviders?.includes(provider))
18
+ return 'LOGIN_QUEUED';
19
+ if (state.checkingProviders.includes(provider))
20
+ return 'LOGIN_CHECKING';
21
+ if (state.phase === 'authorizing' && state.provider === provider)
22
+ return 'LOGIN_AUTHORIZING';
23
+ if (state.providerNeeds[provider])
24
+ return 'LOGIN_PROVIDER_NEED';
25
+ if (typeof state.logins?.[provider] !== 'boolean')
26
+ return 'LOGIN_VALUE_INVALID';
27
+ if (!state.logins[provider])
28
+ return 'LOGIN_REQUIRED';
29
+ }
@@ -1,6 +1,6 @@
1
1
  import type { AgentApp } from '@agentclientprotocol/sdk';
2
2
  import type { AgentApp as ExtendedAgentApp } from '@agentclientprotocol/sdk/unstable';
3
- import type { ConnectState, ConnectApi as LConnectApi, ConnectionState, LoginPromptState } from '@ai-workbench/connect/api';
3
+ import type { ConnectState, ConnectNeed, ConnectApi as LConnectApi, ConnectionState, LoginPromptState } from '@ai-workbench/connect/api';
4
4
  export type { ConnectionState, LoginPromptState } from '@ai-workbench/connect/api';
5
5
  import type { OneShotRuntime } from './job.ts';
6
6
  import type { BrokerTransport, BrokerSettings } from './broker.ts';
@@ -85,10 +85,10 @@ type Result<T> = {
85
85
  code: string;
86
86
  message: string;
87
87
  requires: string;
88
- };
88
+ } & Pick<ConnectNeed, 'officialReason'>;
89
89
  };
90
90
  /** 消费正式connect-api 0.7公开子集;X不响应L登录票据、不直接调用登录。 */
91
- export interface ConnectApi extends Pick<LConnectApi, 'getConnectionState' | 'getLoginPromptState' | 'requestLoginPrompt' | 'refreshLoginPrompts'>, Partial<NativeWorkspaceApi>, Partial<Pick<LConnectApi, 'appendRemoteConversationDetail' | 'getRemoteConversationDetailReceipt' | 'listRemoteConversationDetails'>> {
91
+ export interface ConnectApi extends Pick<LConnectApi, 'getConnectionState' | 'getLoginPromptState' | 'requestLoginPrompt' | 'refreshLoginPrompts'>, Partial<NativeWorkspaceApi>, Partial<Pick<LConnectApi, 'onConnectionState' | 'onLoginPromptState' | 'appendRemoteConversationDetail' | 'getRemoteConversationDetailReceipt' | 'listRemoteConversationDetails'>> {
92
92
  getState?(): ConnectState;
93
93
  getSshTarget(): Promise<Result<ConnectTarget>>;
94
94
  checkLogin(provider: LoginProvider): Promise<Result<{
package/dist/provider.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { loginDiagnostic, loginReason } from "./login-diagnostic.js";
1
2
  import { NeedError } from "./remote.js";
2
3
  import { CONNECTION_REQUIRED, TEST_HOST_NOTICE } from "./presentation.js";
3
4
  import { CODEX_AGENT } from "./agents.js";
@@ -8,6 +9,8 @@ export function requireLoginCoordinator(api) {
8
9
  export function requireLoginState(state) {
9
10
  const object = (v) => !!v && typeof v === 'object' && !Array.isArray(v) && (Object.getPrototypeOf(v) === Object.prototype || Object.getPrototypeOf(v) === null);
10
11
  const need = (v) => object(v) && typeof v.code === 'string' && v.code.length > 0 && ['enrollment', 'network', 'ssh', 'auth', 'integration', 'settings', 'projects'].includes(v.layer) && typeof v.message === 'string' && typeof v.requires === 'string' && ['employee', 'administrator', 'ai-administrator', 'plugin'].includes(v.actor) && object(v.action) && typeof v.action.id === 'string' && typeof v.action.label === 'string' && typeof v.action.available === 'boolean';
12
+ if (state.queuedProviders !== undefined && (!Array.isArray(state.queuedProviders) || state.queuedProviders.some(p => p !== 'codex' && p !== 'claude') || new Set(state.queuedProviders).size !== state.queuedProviders.length || state.queuedProviders.some(p => state.checkingProviders?.includes(p))))
13
+ throw new NeedError('CONNECT_API_MISMATCH', '我需要真实的品牌排队与进行中状态', 'L >=0.7.10');
11
14
  if (!Array.isArray(state.checkingProviders) || state.checkingProviders.some(p => p !== 'codex' && p !== 'claude') || !object(state.providerNeeds) || Object.entries(state.providerNeeds).some(([p, v]) => !['codex', 'claude'].includes(p) || !need(v)))
12
15
  throw new NeedError('CONNECT_API_MISMATCH', '我需要按助手区分的真实登录状态', 'L >=0.6.1');
13
16
  }
@@ -32,15 +35,77 @@ function verifiedLogin(api, brand, expected) {
32
35
  if (!connection.instanceId || !Number.isSafeInteger(connection.epoch) || connection.epoch < 0 || connection.phase !== 'connected' || expected && (connection.instanceId !== expected.instanceId || connection.epoch !== expected.epoch))
33
36
  throw new NeedError('CONNECTION_LOST', 'connection binding changed', 'current employee connection');
34
37
  requireLoginState(login);
35
- if (login.instanceId !== connection.instanceId || login.checkingProviders.includes(brand) || login.providerNeeds[brand] || login.phase === 'blocked' || login.phase === 'disposed' || login.phase === 'authorizing' && login.provider === brand || typeof login.logins?.[brand] !== 'boolean')
36
- throw new NeedError('AUTH_STATUS_INVALID', '无法确认当前品牌的登录状态', 'L核验的本品牌登录状态');
37
- if (!login.logins[brand])
38
- throw new NeedError('LOGIN_REQUIRED', '需要登录,请到设置 → 远程主机连接 点登录', '本人登录');
38
+ const reason = loginReason(login, connection, brand);
39
+ if (reason)
40
+ throw Object.assign(new NeedError(reason === 'LOGIN_REQUIRED' ? 'LOGIN_REQUIRED' : 'AUTH_STATUS_INVALID', reason === 'LOGIN_REQUIRED' ? '需要登录,请到设置 → 远程主机连接 点登录' : '无法确认当前品牌的登录状态', 'L核验的本品牌登录状态'), { technical: loginDiagnostic('verified-login', reason, brand, connection, login), ...(typeof login.providerNeeds[brand]?.officialReason === 'string' ? { officialReason: login.providerNeeds[brand].officialReason } : {}) });
39
41
  return { ...connection };
40
42
  }
43
+ /** Wait only for L's explicit in-progress evidence. No timer, polling, refresh or authorization. */
44
+ async function waitForLogin(api, brand, expected, get, signal) {
45
+ const check = () => {
46
+ signal?.throwIfAborted();
47
+ if (get() !== api)
48
+ throw new NeedError('CONNECTION_LOST', '远程主机连接组件已更换', '当前员工连接');
49
+ const connection = api.getConnectionState(), state = api.getLoginPromptState();
50
+ requireLoginState(state);
51
+ if (connection.phase !== 'connected' || connection.instanceId !== expected.instanceId || connection.epoch !== expected.epoch)
52
+ return verifiedLogin(api, brand, expected);
53
+ const pending = state.instanceId === connection.instanceId && state.phase !== 'blocked' && state.phase !== 'disposed' && (state.queuedProviders?.includes(brand) || state.checkingProviders.includes(brand) || state.phase === 'authorizing' && state.provider === brand);
54
+ return pending ? undefined : verifiedLogin(api, brand, expected);
55
+ };
56
+ const ready = check();
57
+ if (ready)
58
+ return ready;
59
+ if (typeof api.onLoginPromptState !== 'function' || typeof api.onConnectionState !== 'function')
60
+ throw new NeedError('CONNECT_API_MISMATCH', '我需要登录检查状态订阅能力', 'L onLoginPromptState / onConnectionState');
61
+ return new Promise((resolve, reject) => {
62
+ let ended = false;
63
+ const disposers = [];
64
+ const clear = () => { ended = true; signal?.removeEventListener('abort', abort); for (const off of disposers)
65
+ off(); };
66
+ const changed = () => { if (ended)
67
+ return; try {
68
+ const value = check();
69
+ if (value) {
70
+ clear();
71
+ resolve(value);
72
+ }
73
+ }
74
+ catch (error) {
75
+ clear();
76
+ reject(error);
77
+ } };
78
+ const abort = () => { if (ended)
79
+ return; clear(); reject(new NeedError('ABORTED', 'consumer wait cancelled', 'employee action')); };
80
+ const register = (off) => { if (ended)
81
+ off();
82
+ else
83
+ disposers.push(off); };
84
+ signal?.addEventListener('abort', abort, { once: true });
85
+ try {
86
+ if (signal?.aborted)
87
+ abort();
88
+ if (!ended)
89
+ register(api.onLoginPromptState(changed));
90
+ if (!ended)
91
+ register(api.onConnectionState(changed));
92
+ changed();
93
+ }
94
+ catch (error) {
95
+ clear();
96
+ reject(error);
97
+ }
98
+ });
99
+ }
100
+ async function ensureReady(api, brand, signal) {
101
+ const result = await awaitL(() => api.ensureReady(brand), signal);
102
+ if (!result.ok)
103
+ throw Object.assign(new NeedError(result.error.code, result.error.message, result.error.requires), { technical: loginDiagnostic('ensure-ready', 'L_ENSURE_READY_FAILED', brand, api.getConnectionState(), api.getLoginPromptState(), result.error.officialReason), ...(typeof result.error.officialReason === 'string' ? { officialReason: result.error.officialReason } : {}) });
104
+ return result.value;
105
+ }
41
106
  export function unwrap(result) {
42
107
  if (!result.ok)
43
- throw new NeedError(result.error.code, result.error.message, result.error.requires);
108
+ throw Object.assign(new NeedError(result.error.code, result.error.message, result.error.requires), typeof result.error.officialReason === 'string' ? { officialReason: result.error.officialReason } : {});
44
109
  return result.value;
45
110
  }
46
111
  export function validateTarget(t) {
@@ -76,12 +141,12 @@ export async function offerLogin(connect, request, currentApi, agent = CODEX_AGE
76
141
  requireNativeWorkspace(connect);
77
142
  request.signal?.throwIfAborted();
78
143
  const before = connect.getConnectionState();
79
- const target = unwrap(await awaitL(() => connect.ensureReady(agent.loginProvider), request.signal));
144
+ const target = await ensureReady(connect, agent.loginProvider, request.signal);
80
145
  validateTarget(target);
81
146
  request.signal?.throwIfAborted();
82
147
  if (currentApi && currentApi() !== connect)
83
148
  throw new NeedError('CONNECTION_LOST', '远程主机连接组件已更换', '当前员工连接');
84
- verifiedLogin(connect, agent.loginProvider, before);
149
+ await waitForLogin(connect, agent.loginProvider, before, currentApi ?? (() => connect), request.signal);
85
150
  }
86
151
  export function sameTarget(a, b) {
87
152
  return identity(a) === identity(b) && a.projectRoot === b.projectRoot && a.knownHostsFile === b.knownHostsFile && a.proxyCommand === b.proxyCommand && a.session.command === b.session.command && a.session.root === b.session.root && a.session.settingsPath === b.session.settingsPath;
@@ -99,12 +164,12 @@ export function connectProvider(get, options = {}) {
99
164
  if (request.mode === 'one-shot' && !agent.oneShot)
100
165
  throw new NeedError('MODE_UNSUPPORTED', '此远程模型仅支持会话模式', '使用会话模式');
101
166
  const before = api.getConnectionState();
102
- const target = unwrap(await awaitL(() => api.ensureReady(agent.loginProvider), request.signal));
167
+ const target = await ensureReady(api, agent.loginProvider, request.signal);
103
168
  validateTarget(target);
104
169
  request.signal?.throwIfAborted();
105
170
  if (get() !== api)
106
171
  throw new NeedError('CONNECTION_LOST', '远程主机连接组件已更换', '当前员工连接');
107
- const connection = verifiedLogin(api, agent.loginProvider, before);
172
+ const connection = await waitForLogin(api, agent.loginProvider, before, get, request.signal);
108
173
  if (request.identity && request.identity !== identity(target))
109
174
  throw new NeedError('SESSION_CONTEXT_MISMATCH', '员工主机与原会话不一致,请新建会话', '原员工主机');
110
175
  admissions.set(request, { api, target, connection });
@@ -115,15 +180,14 @@ export function connectProvider(get, options = {}) {
115
180
  const admitted = admissions.get(request);
116
181
  admissions.delete(request);
117
182
  const { api, target, connection } = admitted;
118
- const stable = () => { request.signal?.throwIfAborted(); if (get() !== api)
119
- throw new NeedError('CONNECTION_LOST', '远程主机连接组件已更换', '当前员工连接'); verifiedLogin(api, agent.loginProvider, connection); };
120
- stable();
183
+ const stable = () => waitForLogin(api, agent.loginProvider, connection, get, request.signal);
184
+ await stable();
121
185
  if (!request.sessionId)
122
186
  throw new NeedError('PROJECT_BINDING_REQUIRED', '我需要当前会话和已选择的工作区,请使用原生工作区入口', 'DSH会话与原生工作区');
123
187
  await options.notice?.reconcile(request, api);
124
- stable();
188
+ await stable();
125
189
  const resolved = unwrap(await awaitL(() => api.resolveSessionProject(request.sessionId, { provider: agent.loginProvider, purpose: 'user-turn' }), request.signal));
126
- stable();
190
+ await stable();
127
191
  if (!resolved || resolved.instanceId !== connection.instanceId || resolved.epoch !== connection.epoch)
128
192
  throw new NeedError('CONNECTION_LOST', '工作区解析回复已失效', '当前连接代次');
129
193
  validateTarget(resolved.target);
@@ -133,19 +197,19 @@ export function connectProvider(get, options = {}) {
133
197
  if (request.cwd && request.cwd !== project.path || request.bindingId && request.bindingId !== resolved.bindingId || request.workspaceId && request.workspaceId !== resolved.workspaceId)
134
198
  throw new NeedError('SESSION_CONTEXT_MISMATCH', '原会话的工作区绑定已改变,请新建会话', '原工作区绑定');
135
199
  const details = await options.details?.(request, api, connection, target);
136
- stable();
200
+ await stable();
137
201
  let workspaceNotice = undefined;
138
202
  if (resolved.notice) {
139
203
  if (!options.notice)
140
204
  throw new NeedError('DEPENDENCY_REQUIRED', '我需要官方会话持久记录来确认工作区说明', '官方会话与持久化服务');
141
205
  workspaceNotice = await options.notice.deliver(resolved.notice, resolved, request, api);
142
- stable();
206
+ await stable();
143
207
  }
144
208
  if (!options.open)
145
209
  throw new NeedError('RUNTIME_REQUIRED', '我需要主机运行适配器', '主机broker适配');
146
210
  const runtime = await options.open(resolved.target, project, { ...request, agent });
147
211
  try {
148
- stable();
212
+ await stable();
149
213
  }
150
214
  catch (error) {
151
215
  await runtime.dispose?.();
package/dist/readiness.js CHANGED
@@ -32,6 +32,7 @@ var __esDecorate = (this && this.__esDecorate) || function (ctor, descriptorIn,
32
32
  if (target) Object.defineProperty(target, contextIn.name, descriptor);
33
33
  done = true;
34
34
  };
35
+ import { loginDiagnostic } from "./login-diagnostic.js";
35
36
  import { randomUUID } from 'node:crypto';
36
37
  import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol';
37
38
  import { unwrap, awaitL, requireLoginCoordinator, requireLoginState } from "./provider.js";
@@ -56,18 +57,19 @@ function snapshot(api, agent = CODEX_AGENT) {
56
57
  if (connection.phase !== 'connected')
57
58
  return denied(connection.need?.code ?? (connection.phase === 'disposed' ? 'DISPOSED' : 'CONNECTION_LOST'));
58
59
  const state = api.getLoginPromptState();
60
+ const diagnostic = (reason) => ({ label: '登录检查快照', value: JSON.stringify(loginDiagnostic('readiness', reason, brand, connection, state)) });
59
61
  if (!connection.instanceId || state.instanceId !== connection.instanceId)
60
- return denied('AUTH_STATUS_INVALID');
62
+ return denied('AUTH_STATUS_INVALID', [diagnostic('LOGIN_INSTANCE_MISMATCH')]);
61
63
  requireLoginState(state);
62
64
  const technical = details({ hostContext: state.technicalDetails?.hostContext });
63
65
  if (state.phase === 'blocked' || state.phase === 'disposed')
64
- return denied(state.technicalDetails?.code ?? 'AUTH_STATUS_INVALID', technical);
65
- if (state.checkingProviders.includes(brand) || state.phase === 'authorizing' && state.provider === brand)
66
+ return denied(state.technicalDetails?.code ?? 'AUTH_STATUS_INVALID', [...technical, diagnostic(state.phase === 'disposed' ? 'LOGIN_PHASE_DISPOSED' : 'LOGIN_PHASE_BLOCKED')]);
67
+ if (state.queuedProviders?.includes(brand) || state.checkingProviders.includes(brand) || state.phase === 'authorizing' && state.provider === brand)
66
68
  return { phase: 'checking', loginStatus: 'unknown', title: `正在检查 ${agent.name.zh} 登录状态`, message: '远程主机连接正在检查登录状态。', technical };
67
69
  if (state.providerNeeds[brand])
68
- return denied(state.providerNeeds[brand].code, technical);
70
+ return denied(state.providerNeeds[brand].code, [...technical, diagnostic('LOGIN_PROVIDER_NEED')]);
69
71
  if (typeof state.logins?.[brand] !== 'boolean')
70
- return denied('AUTH_STATUS_INVALID', technical);
72
+ return denied('AUTH_STATUS_INVALID', [...technical, diagnostic('LOGIN_VALUE_INVALID')]);
71
73
  return state.logins[brand] ? { phase: 'ready', loginStatus: 'logged-in', title: `${agent.name.zh}已登录`, message: '本次远程主机连接和主机登录检查已完成。', technical } : { phase: 'login-required', loginStatus: 'not-logged-in', title: `${agent.name.zh} 尚未登录`, message: '需要登录,请到设置 → 远程主机连接 点登录', technical };
72
74
  }
73
75
  export function createReadiness(get, agent = CODEX_AGENT) {
@@ -1,8 +1,9 @@
1
1
  import { type DetailObservation } from './conversation-details.ts';
2
+ import type { LoginDiagnostic } from './login-diagnostic.ts';
2
3
  import type { ConnectApi as LConnectApi, ConnectionState } from '@ai-workbench/connect/api';
3
4
  export type { RemoteConversationDetail as RemoteDetail } from '@ai-workbench/connect/api';
4
5
  type Connection = Pick<ConnectionState, 'instanceId' | 'epoch' | 'phase'>;
5
- export type RemoteDetailsApi = Pick<LConnectApi, 'getConnectionState'> & Partial<Pick<LConnectApi, 'getContractInfo' | 'appendRemoteConversationDetail' | 'getRemoteConversationDetailReceipt' | 'listRemoteConversationDetails'>>;
6
+ export type RemoteDetailsApi = Pick<LConnectApi, 'getConnectionState'> & Partial<Pick<LConnectApi, 'getContractInfo' | 'appendRemoteConversationDetail' | 'getRemoteConversationDetailReceipt' | 'listRemoteConversationDetails' | 'appendLoginDiagnostic' | 'getLoginDiagnosticReceipt' | 'listLoginDiagnostics'>>;
6
7
  export interface DetailChannel {
7
8
  record(observation: DetailObservation): void;
8
9
  beginTurn(ids: {
@@ -22,5 +23,17 @@ export declare function createRemoteDetails(options: {
22
23
  profileDir: string;
23
24
  getApi: () => RemoteDetailsApi | undefined;
24
25
  }): {
26
+ recordLoginFailure(request: {
27
+ sessionId?: string;
28
+ code: string;
29
+ diagnostic: LoginDiagnostic;
30
+ context?: {
31
+ turn: string | number | null;
32
+ step: string | number | null;
33
+ };
34
+ }): Promise<{
35
+ eventId: string;
36
+ recorded: true;
37
+ }>;
25
38
  open(request: DetailRequest): Promise<DetailChannel>;
26
39
  };
@@ -88,9 +88,139 @@ async function readJournal(path) {
88
88
  }
89
89
  export function createRemoteDetails(options) {
90
90
  const root = join(options.profileDir, 'ai-workbench-remote-agent', 'diagnostics');
91
- const scopes = new Map();
91
+ const scopes = new Map(), loginScopes = new Map();
92
92
  const initialize = async () => { await privateDirectory(join(options.profileDir, 'ai-workbench-remote-agent')); await privateDirectory(root); };
93
- return { async open(request) {
93
+ return {
94
+ async recordLoginFailure(request) {
95
+ await initialize();
96
+ const eventId = randomUUID(), diagnostic = request.diagnostic;
97
+ // The original observed IDs and full approved snapshot are durable before any L call.
98
+ await durableWrite(join(root, eventId + '.json'), { version: 1, eventId, kind: 'login-check', code: request.code, sessionHash: request.sessionId ? hash(request.sessionId) : null, diagnostic, delivery: 'pending' });
99
+ if (!request.sessionId)
100
+ throw failure('REMOTE_DETAIL_SCOPE_INVALID');
101
+ const sessionId = request.sessionId, provider = diagnostic.provider, api = options.getApi(), connection = diagnostic.connection;
102
+ const stable = () => { const now = api?.getConnectionState(); if (!api || options.getApi() !== api || now?.phase !== 'connected' || now.instanceId !== connection.instanceId || now.epoch !== connection.epoch)
103
+ throw failure('CONNECTION_LOST'); };
104
+ stable();
105
+ if (api.getContractInfo?.().loginDiagnostics !== 1 || !['appendLoginDiagnostic', 'getLoginDiagnosticReceipt', 'listLoginDiagnostics'].every(key => typeof api[key] === 'function'))
106
+ throw failure('TECHNICAL_DETAILS_UNAVAILABLE', '技术详情暂不可用,请更新远程主机连接组件。');
107
+ const parent = join(root, 'login');
108
+ await privateDirectory(parent);
109
+ const key = hash(sessionId + '\0' + provider), dir = join(parent, key);
110
+ await privateDirectory(dir);
111
+ const scope = loginScopes.get(key) ?? { tail: Promise.resolve(), maximum: 0, loaded: false };
112
+ loginScopes.set(key, scope);
113
+ const operation = scope.tail.then(async () => {
114
+ stable();
115
+ const generation = connection.instanceId + '\0' + connection.epoch;
116
+ if (!scope.loaded || scope.generation !== generation) {
117
+ const names = await readdir(dir);
118
+ for (const name of names) {
119
+ const match = /^(\d+)-([0-9a-f-]+)\.json$/.exec(name);
120
+ if (!match)
121
+ continue;
122
+ const sequence = Number(match[1]);
123
+ if (!Number.isSafeInteger(sequence))
124
+ throw failure('REMOTE_DETAIL_STORAGE_FAILED');
125
+ scope.maximum = Math.max(scope.maximum, sequence);
126
+ const saved = await readJournal(join(dir, name));
127
+ if (!object(saved) || !object(saved.detail) || saved.detail.sequence !== sequence || saved.detail.eventId !== match[2] || saved.detail.sessionId !== sessionId || saved.detail.provider !== provider)
128
+ throw failure('REMOTE_DETAIL_STORAGE_FAILED');
129
+ const settled = 'settled-' + match[2] + '.json';
130
+ if (names.includes(settled)) {
131
+ const receipt = await readJournal(join(dir, settled));
132
+ if (!object(receipt) || receipt.eventId !== match[2] || !['recorded', 'rejected'].includes(receipt.state))
133
+ throw failure('REMOTE_DETAIL_STORAGE_FAILED');
134
+ continue;
135
+ }
136
+ stable();
137
+ const receipt = unwrap(await api.getLoginDiagnosticReceipt({ eventId: match[2], sessionId, provider }));
138
+ stable();
139
+ if (receipt.eventId !== match[2] || !receipt.recorded)
140
+ throw failure('REMOTE_DETAIL_DELIVERY_UNKNOWN');
141
+ if (!isDeepStrictEqual(receipt.detail, saved.detail))
142
+ throw failure('REMOTE_DETAIL_RECEIPT_INVALID');
143
+ await durableWrite(join(dir, settled), { eventId: match[2], state: 'recorded' });
144
+ }
145
+ let cursor, previous = -1, count = 0, expected;
146
+ const seen = new Set();
147
+ do {
148
+ stable();
149
+ const page = unwrap(await api.listLoginDiagnostics({ sessionId, provider, ...(cursor ? { cursor } : {}) }));
150
+ stable();
151
+ if (!object(page) || !Array.isArray(page.items) || ![page.total, page.unknownCount, page.actionRequiredCount].every(n => Number.isSafeInteger(n) && n >= 0))
152
+ throw failure('REMOTE_DETAIL_RESPONSE_INVALID');
153
+ const counts = { total: page.total, unknownCount: page.unknownCount, actionRequiredCount: page.actionRequiredCount };
154
+ if (expected && !isDeepStrictEqual(expected, counts))
155
+ throw failure('REMOTE_DETAIL_RESPONSE_INVALID');
156
+ expected = counts;
157
+ for (const entry of page.items) {
158
+ if (entry.sessionId !== sessionId || entry.provider !== provider || !Number.isSafeInteger(entry.sequence) || entry.sequence < 0 || entry.sequence <= previous)
159
+ throw failure('REMOTE_DETAIL_RESPONSE_INVALID');
160
+ previous = entry.sequence;
161
+ scope.maximum = Math.max(scope.maximum, entry.sequence);
162
+ count++;
163
+ }
164
+ cursor = page.nextCursor;
165
+ if (cursor !== undefined && (typeof cursor !== 'string' || !cursor || seen.has(cursor)))
166
+ throw failure('REMOTE_DETAIL_CURSOR_INVALID');
167
+ if (cursor)
168
+ seen.add(cursor);
169
+ } while (cursor);
170
+ if (count !== expected?.total)
171
+ throw failure('REMOTE_DETAIL_RESPONSE_INVALID');
172
+ scope.loaded = true;
173
+ scope.generation = generation;
174
+ }
175
+ const mismatch = diagnostic.reason === 'LOGIN_INSTANCE_MISMATCH', unsupported = !!diagnostic.officialReasonRejected;
176
+ const reason = mismatch ? 'LOGIN_INSTANCE_MISMATCH' : unsupported ? 'OFFICIAL_REASON_UNSUPPORTED' : diagnostic.reason;
177
+ // L 0.7.11: observed evidence never changes the current connection scope.
178
+ const login = { phase: diagnostic.login.phase, instanceId: diagnostic.login.instanceId, logins: Object.fromEntries(Object.entries(diagnostic.login.logins).filter(([, v]) => typeof v === 'boolean' || v === 'absent')), queuedProviders: diagnostic.login.queuedProviders, checkingProviders: diagnostic.login.checkingProviders };
179
+ const payload = { reason, connection: { ...connection }, login, ...(mismatch ? { observedLoginInstanceId: diagnostic.login.instanceId } : {}), ...(diagnostic.officialReason ? { officialReason: diagnostic.officialReason } : {}), ...(unsupported && !mismatch ? { officialReasonRejected: diagnostic.officialReasonRejected } : {}) };
180
+ // The two payload reasons are exclusive in L's contract. Preserve both facts
181
+ // using the approved fixed summary and full digest, without inventing a field.
182
+ const rejected = diagnostic.officialReasonRejected;
183
+ const summary = '登录检查:' + diagnostic.reason + (rejected ? '; OFFICIAL_REASON_UNSUPPORTED; bytes=' + rejected.bytes + '; SHA256=' + rejected.sha256 : '');
184
+ const context = request.context ?? { turn: null, step: null };
185
+ if (!identifier(context.turn) || !identifier(context.step))
186
+ throw failure('REMOTE_DETAIL_SCOPE_INVALID');
187
+ const sequence = scope.maximum + 1;
188
+ if (!Number.isSafeInteger(sequence))
189
+ throw failure('REMOTE_DETAIL_SEQUENCE_INVALID');
190
+ scope.maximum = sequence;
191
+ const detail = { version: 1, eventId, instanceId: connection.instanceId, epoch: connection.epoch, sessionId, provider, turn: context.turn, step: context.step, sequence, kind: 'login-check', severity: 'action-required', code: request.code, summary, method: diagnostic.source, payload };
192
+ await durableWrite(join(dir, sequence + '-' + eventId + '.json'), { state: 'pending', detail });
193
+ stable();
194
+ let response;
195
+ try {
196
+ response = await api.appendLoginDiagnostic(structuredClone(detail));
197
+ }
198
+ catch {
199
+ stable();
200
+ const receipt = unwrap(await api.getLoginDiagnosticReceipt({ eventId, sessionId, provider }));
201
+ stable();
202
+ if (receipt.eventId !== eventId)
203
+ throw failure('REMOTE_DETAIL_RECEIPT_INVALID');
204
+ if (!receipt.recorded)
205
+ throw failure('REMOTE_DETAIL_DELIVERY_UNKNOWN');
206
+ if (!isDeepStrictEqual(receipt.detail, detail))
207
+ throw failure('REMOTE_DETAIL_RECEIPT_INVALID');
208
+ await durableWrite(join(dir, 'settled-' + eventId + '.json'), { eventId, state: 'recorded' });
209
+ return;
210
+ }
211
+ stable();
212
+ if (response?.ok === false)
213
+ await durableWrite(join(dir, 'settled-' + eventId + '.json'), { eventId, state: 'rejected', code: response.error.code });
214
+ const ack = unwrap(response);
215
+ if (ack.eventId !== eventId || ack.recorded !== true)
216
+ throw failure('REMOTE_DETAIL_RESPONSE_INVALID');
217
+ await durableWrite(join(dir, 'settled-' + eventId + '.json'), { eventId, state: 'recorded' });
218
+ });
219
+ scope.tail = operation.then(() => { }, () => { scope.loaded = false; });
220
+ await operation;
221
+ return { eventId, recorded: true };
222
+ },
223
+ async open(request) {
94
224
  if (!request.sessionId || !request.identity || !request.connection.instanceId || !Number.isSafeInteger(request.connection.epoch) || request.connection.epoch < 0 || !['claude', 'codex'].includes(request.provider))
95
225
  throw failure('REMOTE_DETAIL_SCOPE_INVALID');
96
226
  try {
@@ -246,5 +376,6 @@ export function createRemoteDetails(options) {
246
376
  async flush() { await tail; if (firstFailure)
247
377
  throw firstFailure.error; stable(); },
248
378
  };
249
- } };
379
+ }
380
+ };
250
381
  }
package/dist/remote.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { LoginDiagnostic } from './login-diagnostic.ts';
1
2
  import type { ChildProcessWithoutNullStreams } from 'node:child_process';
2
3
  /** Development-only direct SSH. Product transports must consume L's exported sshArgs (v0.2). */
3
4
  export interface DevelopmentSshTarget {
@@ -16,6 +17,8 @@ export interface Need {
16
17
  export declare class NeedError extends Error {
17
18
  code: string;
18
19
  requires: string;
20
+ technical?: LoginDiagnostic;
21
+ officialReason?: string;
19
22
  constructor(code: string, message: string, requires: string);
20
23
  }
21
24
  export declare function shellQuote(value: string): string;
package/dist/remote.js CHANGED
@@ -2,6 +2,8 @@ import { spawn } from 'node:child_process';
2
2
  export class NeedError extends Error {
3
3
  code;
4
4
  requires;
5
+ technical;
6
+ officialReason;
5
7
  constructor(code, message, requires) {
6
8
  super(message);
7
9
  this.name = 'NeedError';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-workbench/remote-agent",
3
- "version": "0.8.1",
3
+ "version": "0.8.2",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -36,7 +36,7 @@
36
36
  "zod": "4.6.7"
37
37
  },
38
38
  "devDependencies": {
39
- "@ai-workbench/connect": "file:test/fixtures/ai-workbench-connect-0.7.9.tgz",
39
+ "@ai-workbench/connect": "file:test/fixtures/ai-workbench-connect-0.7.11.tgz",
40
40
  "@deepseek-ai/cordis": "4.0.4",
41
41
  "@deepseek-ai/cordis-plugin-group": "1.0.4",
42
42
  "@deepseek-ai/cordis-plugin-include": "1.0.9",
@@ -56,7 +56,7 @@
56
56
  "@deepseek-ai/dsh-attachment": "0.2.0-rc.2"
57
57
  },
58
58
  "peerDependencies": {
59
- "@ai-workbench/connect": "~0.7.9",
59
+ "@ai-workbench/connect": "~0.7.11",
60
60
  "@deepseek-ai/cordis": "4.0.4",
61
61
  "@deepseek-ai/dsh-llm": "0.2.0-rc.2",
62
62
  "@deepseek-ai/dsh-typert-protocol": "0.2.0-rc.2",