@ai-workbench/remote-agent 0.8.21 → 0.8.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/README.md +27 -156
  2. package/dist/acp.d.ts +20 -15
  3. package/dist/acp.js +197 -224
  4. package/dist/agents.d.ts +0 -1
  5. package/dist/agents.js +2 -2
  6. package/dist/broker-runtime.d.ts +3 -5
  7. package/dist/broker-runtime.js +45 -342
  8. package/dist/broker.d.ts +6 -31
  9. package/dist/broker.js +42 -113
  10. package/dist/catalog-ssh.d.ts +2 -31
  11. package/dist/catalog-ssh.js +9 -39
  12. package/dist/claude-local.d.ts +1 -1
  13. package/dist/claude-local.js +40 -105
  14. package/dist/client.js +34 -127
  15. package/dist/close-diagnostics.d.ts +6 -20
  16. package/dist/close-diagnostics.js +13 -57
  17. package/dist/connect-contract.d.ts +4 -8
  18. package/dist/conversation-details.d.ts +0 -29
  19. package/dist/conversation-details.js +8 -99
  20. package/dist/employee-endpoint.d.ts +1 -3
  21. package/dist/employee-endpoint.js +8 -39
  22. package/dist/employee-exec.d.ts +4 -3
  23. package/dist/employee-exec.js +25 -13
  24. package/dist/employee-info.d.ts +5 -1
  25. package/dist/employee-info.js +16 -5
  26. package/dist/employee-location.js +3 -4
  27. package/dist/employee-mcp.js +44 -69
  28. package/dist/employee-native.d.ts +1 -3
  29. package/dist/employee-native.js +6 -27
  30. package/dist/employee-presentation.d.ts +0 -5
  31. package/dist/employee-presentation.js +0 -15
  32. package/dist/employee-provider.d.ts +3 -11
  33. package/dist/employee-provider.js +69 -205
  34. package/dist/employee-reasons.js +1 -5
  35. package/dist/employee-shell.d.ts +2 -1
  36. package/dist/employee-shell.js +10 -13
  37. package/dist/employee-ssh.d.ts +1 -3
  38. package/dist/employee-ssh.js +13 -16
  39. package/dist/employee-tools.d.ts +5 -5
  40. package/dist/failure-evidence.d.ts +2 -10
  41. package/dist/failure-evidence.js +12 -61
  42. package/dist/host-workspace.d.ts +4 -2
  43. package/dist/host-workspace.js +1 -1
  44. package/dist/index.d.ts +13 -43
  45. package/dist/index.js +190 -489
  46. package/dist/input.js +2 -3
  47. package/dist/local-user-turn.js +5 -6
  48. package/dist/mapping.d.ts +0 -3
  49. package/dist/mapping.js +0 -34
  50. package/dist/model-catalog.d.ts +2 -41
  51. package/dist/model-catalog.js +24 -282
  52. package/dist/model-configuration.d.ts +0 -3
  53. package/dist/model-configuration.js +8 -47
  54. package/dist/model-presentation.d.ts +1 -0
  55. package/dist/model-presentation.js +2 -2
  56. package/dist/permissions.d.ts +15 -0
  57. package/dist/permissions.js +30 -0
  58. package/dist/preflight-diagnostic.js +11 -22
  59. package/dist/preflight-payload.d.ts +1 -1
  60. package/dist/preflight-payload.js +2 -2
  61. package/dist/presentation.js +21 -41
  62. package/dist/provider.d.ts +12 -34
  63. package/dist/provider.js +17 -92
  64. package/dist/readiness.js +1 -7
  65. package/dist/remote-details.d.ts +41 -17
  66. package/dist/remote-details.js +84 -381
  67. package/dist/workspace-source.d.ts +1 -25
  68. package/dist/workspace-source.js +1 -138
  69. package/package.json +11 -12
  70. package/dist/catalog-diagnostics.d.ts +0 -16
  71. package/dist/catalog-diagnostics.js +0 -108
  72. package/dist/host-layout.d.ts +0 -8
  73. package/dist/host-layout.js +0 -9
  74. package/dist/job.d.ts +0 -17
  75. package/dist/job.js +0 -57
  76. package/dist/model-defaults.d.ts +0 -29
  77. package/dist/model-defaults.js +0 -62
  78. package/dist/oneshot.d.ts +0 -13
  79. package/dist/oneshot.js +0 -78
  80. package/dist/protocol-boundary.d.ts +0 -13
  81. package/dist/protocol-boundary.js +0 -49
  82. package/dist/provider-locale.d.ts +0 -19
  83. package/dist/provider-locale.js +0 -73
  84. package/dist/recovery-checkpoint.d.ts +0 -6
  85. package/dist/recovery-checkpoint.js +0 -71
  86. package/dist/recovery.d.ts +0 -51
  87. package/dist/recovery.js +0 -61
  88. package/dist/sdk-ssh-wrapper.d.ts +0 -2
  89. package/dist/sdk-ssh-wrapper.js +0 -34
  90. package/dist/stage-timing.d.ts +0 -31
  91. package/dist/stage-timing.js +0 -61
  92. package/dist/workspace-notice.d.ts +0 -47
  93. package/dist/workspace-notice.js +0 -273
  94. package/dist/workspace-source-confirmation.d.ts +0 -4
  95. package/dist/workspace-source-confirmation.js +0 -12
package/dist/agents.d.ts CHANGED
@@ -7,7 +7,6 @@ export interface AgentDefinition {
7
7
  }>;
8
8
  readonly command: string;
9
9
  readonly loginProvider: 'claude' | 'codex';
10
- readonly oneShot: boolean;
11
10
  }
12
11
  export declare const CODEX_AGENT: AgentDefinition;
13
12
  export declare const CLAUDE_AGENT: AgentDefinition;
package/dist/agents.js CHANGED
@@ -1,4 +1,4 @@
1
- export const CODEX_AGENT = Object.freeze({ id: 'codex-remote', name: Object.freeze({ zh: 'Codex(远程主机)', en: 'Codex (Remote Host)' }), command: '/usr/local/aw-host/bin/codex-acp', loginProvider: 'codex', oneShot: true });
2
- export const CLAUDE_AGENT = Object.freeze({ id: 'claude-code-workbench', name: Object.freeze({ zh: 'Claude(远程主机)', en: 'Claude (Remote Host)' }), command: '/usr/local/aw-host/bin/claude-agent-acp', loginProvider: 'claude', oneShot: false });
1
+ export const CODEX_AGENT = Object.freeze({ id: 'codex-remote', name: Object.freeze({ zh: 'Codex(远程主机)', en: 'Codex (Remote Host)' }), command: '/usr/local/aw-host/bin/codex-acp', loginProvider: 'codex' });
2
+ export const CLAUDE_AGENT = Object.freeze({ id: 'claude-code-workbench', name: Object.freeze({ zh: 'Claude(远程主机)', en: 'Claude (Remote Host)' }), command: '/usr/local/aw-host/bin/claude-agent-acp', loginProvider: 'claude' });
3
3
  export const AGENTS = Object.freeze([CODEX_AGENT, CLAUDE_AGENT]);
4
4
  export function agentForProvider(id) { return AGENTS.find(agent => agent.id === id); }
@@ -1,10 +1,8 @@
1
1
  import { type SshArgv, type BrokerIo, type BrokerSettings } from './broker.ts';
2
2
  import { type ConnectApi, type ConnectTarget, type Project, type Runtime, type RuntimeRequest } from './provider.ts';
3
3
  import type { Interaction } from './interaction.ts';
4
- import { type RecoveryJournal } from './recovery.ts';
5
- /** Test ports are explicit; deployed code always supplies the official-profile journal. */
6
- export declare function brokerFactory(get: () => ConnectApi | undefined, _ask: Interaction | undefined, configuration: BrokerSettings | (() => BrokerSettings), provided?: {
4
+ /** Each invocation owns a new agent. Context resumes by native session ID, never by reattaching a broker. */
5
+ export declare function brokerFactory(_get: () => ConnectApi | undefined, _ask: Interaction | undefined, configuration: BrokerSettings | (() => BrokerSettings), provided?: {
7
6
  argv?: SshArgv;
8
7
  io?: (target: ConnectTarget) => BrokerIo;
9
- journal?: (key: string) => RecoveryJournal;
10
- }): (target: ConnectTarget, project: Project, initialRequest: RuntimeRequest) => Promise<Runtime>;
8
+ }): (target: ConnectTarget, project: Project, request: RuntimeRequest) => Promise<Runtime>;
@@ -6,355 +6,58 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
6
6
  }
7
7
  return path;
8
8
  };
9
- import { CloseDiagnostics, closeCode, flushCloseDiagnostics } from "./close-diagnostics.js";
10
- import { BrokerTransport, brokerCommand, brokerId, legacyBrokerId, sshIo, controlJson } from "./broker.js";
9
+ import { randomUUID } from 'node:crypto';
10
+ import { CloseDiagnostics } from "./close-diagnostics.js";
11
+ import { BrokerTransport, brokerCommand, brokerId, sshIo, controlJson } from "./broker.js";
11
12
  import { CODEX_AGENT } from "./agents.js";
12
- import { protectedOneShotLayout } from "./host-layout.js";
13
13
  import { NeedError } from "./remote.js";
14
- import { identity, unwrap, validateTarget, sameTarget } from "./provider.js";
15
- import { StageTiming } from "./stage-timing.js";
16
- import { recoveryKey } from "./recovery.js";
17
- const need = (code) => new NeedError(code, '原主机会话暂时无法安全接续,本轮尚未发送', '技术详情中的原会话状态');
18
- const modern = (entry) => Object.hasOwn(entry, 'instanceId');
19
- function validEntry(entry) {
20
- if (!entry || typeof entry.id !== 'string' || !Number.isSafeInteger(entry.pid) || entry.pid < 1 || !['running', 'exited'].includes(entry.state) || ![entry.firstSequence, entry.lastSequence].every(Number.isSafeInteger) || entry.lastSequence < 0 || entry.firstSequence < 1 || entry.firstSequence > entry.lastSequence + 1)
21
- throw need('BROKER_PROTOCOL_INVALID');
22
- if (modern(entry) && (typeof entry.instanceId !== 'string' || !/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(entry.instanceId) || !Number.isSafeInteger(entry.controlMaxBytes) || entry.controlMaxBytes < 1))
23
- throw need('BROKER_PROTOCOL_INVALID');
24
- }
25
- async function leaseWait(ms, signal) {
26
- if (!Number.isSafeInteger(ms) || ms < 0 || ms > 2147483647)
27
- throw need('LEASE_POLICY_INVALID');
28
- signal?.throwIfAborted();
29
- await new Promise((resolve, reject) => { const clear = () => signal?.removeEventListener('abort', abort); const timer = setTimeout(() => { clear(); resolve(); }, ms); const abort = () => { clearTimeout(timer); clear(); reject(need('ABORTED')); }; signal?.addEventListener('abort', abort, { once: true }); if (signal?.aborted)
30
- abort(); });
31
- }
32
- /** Test ports are explicit; deployed code always supplies the official-profile journal. */
33
- export function brokerFactory(get, _ask, configuration, provided) {
34
- const active = new Set();
35
- const retained = new Map();
36
- const testJournals = new Map();
37
- const open = async (target, project, initialRequest) => {
38
- let request = initialRequest;
39
- const settings = request.brokerSettings ?? (typeof configuration === 'function' ? configuration() : configuration);
40
- const agent = request.agent ?? CODEX_AGENT;
41
- if (!request.sessionId)
42
- throw need('SESSION_REQUIRED');
43
- const employeeScope = request.employeeScope;
44
- if (employeeScope && (request.mode !== 'session' || request.resumeSessionId || request.recoveryCheckpointId))
45
- throw need('EMPLOYEE_OPERATION_UNSUPPORTED');
46
- if (request.mode === 'one-shot') {
47
- const key = 'one-shot/' + identity(target) + '/' + agent.id + '/' + request.sessionId;
48
- if (active.has(key))
49
- throw need('TURN_BUSY');
50
- const layout = protectedOneShotLayout(target);
51
- active.add(key);
52
- return { identity: identity(target), cwd: project.path, oneShot: { target, cwd: project.path, ...layout }, dispose: async () => { active.delete(key); } };
14
+ import { identity } from "./provider.js";
15
+ /** Each invocation owns a new agent. Context resumes by native session ID, never by reattaching a broker. */
16
+ export function brokerFactory(_get, _ask, configuration, provided) {
17
+ return async (target, project, request) => {
18
+ const agent = request.agent ?? CODEX_AGENT, settings = request.brokerSettings ?? (typeof configuration === 'function' ? configuration() : configuration);
19
+ let argv = provided?.argv;
20
+ if (!provided?.io && !argv) {
21
+ const module = '@ai-workbench/connect';
22
+ argv = (await import(__rewriteRelativeImportExtension(module))).sshArgs;
53
23
  }
54
- if (![settings.brokerMaxFrameBytes, settings.brokerControlMaxBytes].every(n => Number.isSafeInteger(n) && n > 0))
55
- throw need('SETTINGS_REQUIRED');
56
- const key = recoveryKey([identity(target), agent.id, request.sessionId, ...(employeeScope ? [employeeScope] : [])]);
57
- // New employee scopes compare execution values, independent of JSON key insertion order.
58
- // Legacy journals retain their historical serialization bytes.
59
- const scopedSession = employeeScope ? { command: target.session.command, root: target.session.root, settingsPath: target.session.settingsPath, protocolVersion: target.session.protocolVersion } : target.session;
60
- const scopedSettings = employeeScope ? { brokerMaxFrameBytes: settings.brokerMaxFrameBytes, brokerControlMaxBytes: settings.brokerControlMaxBytes } : settings;
61
- const scopeParts = [target.host, target.username, target.port, target.proxyCommand, target.knownHostsFile, target.projectRoot, scopedSession, project.path, agent.id, scopedSettings, request.bindingId, request.workspaceId];
62
- if (employeeScope)
63
- scopeParts.push(employeeScope);
64
- const contextBoundScope = recoveryKey([...scopeParts, request.contextSha256]);
65
- // A Codex ACP process can own several immutable native sessions. D18's durable
66
- // pointer controls their context; the broker still binds every transport identity.
67
- const scope = recoveryKey([...scopeParts, undefined]);
68
- const previousContextScope = agent.loginProvider === 'claude' && request.previousContextSha256 ? recoveryKey([...scopeParts, request.previousContextSha256]) : contextBoundScope;
69
- const existing = request.retainProcess ? retained.get(key) : undefined;
70
- if (existing) {
71
- if (existing.scope !== scope)
72
- throw need('SESSION_CONTEXT_MISMATCH');
73
- if (existing.runtime.broker?.error)
74
- throw existing.runtime.broker.error;
75
- if (request.requireFreshSession) {
76
- if (existing.runtime.broker?.promptInFlight)
77
- throw need('INPUT_DELIVERY_UNCONFIRMED');
78
- await existing.runtime.dispose?.({ layer: 'broker', initiator: 'broker.fresh-session-required', reason: 'PRIVATE_RECOVERY_POINTER_ABSENT', action: 'dispose' });
79
- return open(target, project, request);
80
- }
81
- existing.update(request);
82
- existing.runtime.restartNotice = undefined;
83
- existing.runtime.restartInputIds = undefined;
84
- existing.runtime.freshSessionConfirmed = undefined;
85
- existing.runtime.recoveredSession = undefined;
86
- return existing.runtime;
87
- }
88
- if (active.has(key))
89
- throw need('TURN_BUSY');
90
- active.add(key);
24
+ const io = provided?.io?.(target) ?? sshIo(target, argv, settings.brokerControlMaxBytes);
25
+ const command = (args) => brokerCommand(target, args);
26
+ const diagnostics = request.closeDiagnostics ?? new CloseDiagnostics(detail => request.detailChannel?.record(detail));
27
+ const id = brokerId(request.sessionId + '/' + randomUUID(), agent.id);
28
+ const startCommand = command(['start', id, '--cwd', project.path, '--settings', target.session.settingsPath, '--', agent.command]);
29
+ // environments.toml overrides CODEX_EXEC_SERVER_URL. Refuse that host default rather
30
+ // than allowing a local-workspace resume to select a host executor.
31
+ const employeeStart = request.employeeScope && agent.loginProvider === 'codex';
32
+ const guardedStart = employeeStart ? `if [ -e "\${CODEX_HOME:-$HOME/.codex}/environments.toml" ] || [ -L "\${CODEX_HOME:-$HOME/.codex}/environments.toml" ]; then printf '%s\\n' '{"code":"AW_EMPLOYEE_ENVIRONMENTS_CONFIG_UNSUPPORTED"}'; else CODEX_EXEC_SERVER_URL=none ${startCommand}; fi` : startCommand;
33
+ const started = controlJson(await io.run(guardedStart, request.signal));
34
+ if (started.code === 'AW_EMPLOYEE_ENVIRONMENTS_CONFIG_UNSUPPORTED')
35
+ throw new NeedError(started.code, '主机的环境配置会覆盖本机执行位置,本轮已停止;请由管理员移开该配置后在原会话重试。', '主机 CODEX_HOME/environments.toml 不存在');
36
+ if (started.protocolVersion !== 1 || started.id !== id || !started.instanceId)
37
+ throw new NeedError('BROKER_PROTOCOL_INVALID', '主机会话未能启动,请恢复连接后再试。', '有效的主机会话启动回应');
38
+ const channel = new BrokerTransport({ observeClose: diagnostics.capture(), maxFrameBytes: settings.brokerMaxFrameBytes, instanceId: started.instanceId, leased: true, controlMaxBytes: started.controlMaxBytes, attach: () => io.attach(command(['attach', id, '--after', '0', '--framed', '--leased', '--if-instance', started.instanceId])) });
39
+ let ended = false;
40
+ const dispose = async (cause = { layer: 'broker', initiator: 'broker.dispose', reason: 'BROKER_DISPOSE', action: 'dispose' }) => {
41
+ if (ended)
42
+ return;
43
+ ended = true;
44
+ diagnostics.record(cause);
45
+ channel.close(cause);
46
+ void io.run(command(['kill', id, '--if-instance', started.instanceId])).catch(() => console.warn('[remote-agent] 旧主机会话暂未清理,将由主机自动回收'));
47
+ };
48
+ const aborted = () => channel.fail(new NeedError('ABORTED', '本轮已取消。', '确认主机状态'), { layer: 'broker', initiator: 'broker.open-signal', reason: 'ABORTED', action: 'abort' });
49
+ request.signal?.addEventListener('abort', aborted, { once: true });
91
50
  try {
92
- const journal = request.recoveryJournal ?? provided?.journal?.(key) ?? (provided?.io ? { read: async () => structuredClone(testJournals.get(key)), write: async (value) => { testJournals.set(key, structuredClone(value)); } } : undefined);
93
- if (!journal)
94
- throw need('RECOVERY_STORAGE_REQUIRED');
95
- let state = await journal.read();
96
- const legacyContextScope = !!state && state.scope !== scope && state.scope === previousContextScope;
97
- if (state && state.scope !== scope && !legacyContextScope)
98
- throw need(agent.loginProvider === 'codex' ? 'RECOVERY_CONTEXT_POLICY_UNSUPPORTED' : 'SESSION_CONTEXT_MISMATCH');
99
- if (legacyContextScope && state?.phase !== 'clean')
100
- throw need('RECOVERY_CONTEXT_POLICY_UNSUPPORTED');
101
- let argv = provided?.argv;
102
- if (!provided?.io && !argv) {
103
- const name = '@ai-workbench/connect';
104
- const supplied = await import(__rewriteRelativeImportExtension(name));
105
- if (typeof supplied.sshArgs !== 'function')
106
- throw need('DEPENDENCY_REQUIRED');
107
- argv = supplied.sshArgs;
108
- }
109
- const ioFor = (t) => provided?.io?.(t) ?? sshIo(t, argv, settings.brokerControlMaxBytes);
110
- let io = ioFor(target);
111
- const command = (args) => brokerCommand(target, args);
112
- let timing = request.timing ?? new StageTiming();
113
- const validate = async (cleanup = false) => { if (!cleanup)
114
- request.signal?.throwIfAborted(); await request.validateContext?.(cleanup); if (!cleanup)
115
- request.signal?.throwIfAborted(); };
116
- const report = async (code, extra = {}, cleanup = false) => { request.detailChannel?.record({ kind: 'need', severity: 'warning', code, summary: '远端会话恢复状态:' + code, payload: extra }); await request.detailChannel?.flush(); await validate(cleanup); };
117
- const list = async (cleanup = false) => timing.run('broker-list', async () => { const rows = controlJson(await io.run(command(['list']), cleanup ? undefined : request.signal)); if (!Array.isArray(rows) || rows.some(row => typeof row?.id !== 'string'))
118
- throw need('BROKER_PROTOCOL_INVALID'); return rows; });
119
- const detailSink = request.detailChannel;
120
- const closeDiagnostics = request.closeDiagnostics ?? new CloseDiagnostics(event => detailSink?.record(event));
121
- let rows = await list();
122
- const id = brokerId(request.sessionId, employeeScope ? agent.id + '/employee/' + employeeScope : agent.id), oldId = legacyBrokerId(request.sessionId, agent.id);
123
- let restarted = !!request.requireFreshSession;
124
- const attachArgs = (entry, after) => ['attach', entry.id, '--after', String(after), '--framed', ...(modern(entry) ? ['--leased', '--if-instance', entry.instanceId] : [])];
125
- const connectWithLease = async (transport, entry, cleanup = false) => {
126
- try {
127
- await transport.connect();
128
- }
129
- catch (error) {
130
- if (!(error instanceof NeedError) || error.code !== 'ATTACH_BUSY' || !modern(entry))
131
- throw error;
132
- const current = (await list(cleanup)).find(row => row.id === entry.id);
133
- if (!current || current.instanceId !== entry.instanceId)
134
- throw need('SESSION_INSTANCE_MISMATCH');
135
- validEntry(current);
136
- const policy = current.lifecycle;
137
- if (!policy || typeof policy.enabled !== 'boolean' || typeof policy.attached !== 'boolean')
138
- throw need('LEASE_POLICY_INVALID');
139
- if (policy.attached && !policy.enabled)
140
- throw need('SESSION_IN_USE');
141
- const remaining = policy.attached ? policy.remainingLeaseMs : 0;
142
- if (remaining === undefined || remaining === null || !Number.isSafeInteger(remaining) || remaining < 0)
143
- throw need('LEASE_POLICY_INVALID');
144
- await report('WAITING_PRIOR_CLIENT_LEASE', { brokerId: entry.id, instanceId: entry.instanceId, remainingLeaseMs: remaining }, cleanup);
145
- await leaseWait(remaining, cleanup ? undefined : request.signal);
146
- await validate(cleanup);
147
- try {
148
- await transport.connect();
149
- }
150
- catch (next) {
151
- if (next instanceof NeedError && next.code === 'ATTACH_BUSY')
152
- throw need('SESSION_IN_USE');
153
- throw next;
154
- }
155
- }
156
- };
157
- const claim = async (entry, cleanup = false) => {
158
- const probe = new BrokerTransport({ observeClose: closeDiagnostics.child().capture(), discardOutput: true, onNeed: n => request.detailChannel?.record({ kind: 'need', severity: 'warning', code: n.code, summary: '主机会话连接状态', payload: { brokerId: entry.id, instanceId: entry.instanceId ?? null } }), maxFrameBytes: settings.brokerMaxFrameBytes, initialSequence: entry.lastSequence, instanceId: entry.instanceId, leased: modern(entry), controlMaxBytes: entry.controlMaxBytes, attach: after => io.attach(command(attachArgs(entry, after))) });
159
- try {
160
- await connectWithLease(probe, entry, cleanup);
161
- }
162
- catch (error) {
163
- if (!(entry.state === 'exited' && error instanceof NeedError && error.code === 'AGENT_EXITED')) {
164
- probe.close({ layer: 'broker', initiator: 'broker.probe-close', reason: 'BROKER_PROBE_FINISHED', action: 'close' });
165
- throw error;
166
- }
167
- }
168
- return probe;
169
- };
170
- const stop = async (entry, code, owned, cleanup = false, freshOwned = false) => {
171
- validEntry(entry);
172
- if (!modern(entry))
173
- throw need('BROKER_CAPABILITY_REQUIRED');
174
- if (!owned?.online && !freshOwned) {
175
- const current = (await list(cleanup)).find(row => row.id === entry.id);
176
- if (!current) {
177
- await report('SESSION_ALREADY_ABSENT', { brokerId: entry.id, instanceId: entry.instanceId ?? null }, cleanup);
178
- return;
179
- }
180
- validEntry(current);
181
- if (modern(current) !== modern(entry) || current.instanceId !== entry.instanceId)
182
- throw need('SESSION_INSTANCE_MISMATCH');
183
- entry = current;
184
- }
185
- const probe = owned?.online || freshOwned ? owned : await claim(entry, cleanup);
186
- try {
187
- await report(code, { brokerId: entry.id, ...(entry.instanceId ? { instanceId: entry.instanceId } : {}), observedPid: entry.pid, legacySessionSlot: !modern(entry) }, cleanup);
188
- if (entry.state === 'running' && !probe.online && !freshOwned)
189
- throw need('MIGRATION_CLAIM_LOST');
190
- await validate(cleanup);
191
- const ack = controlJson(await io.run(command(['kill', entry.id, ...(entry.instanceId ? ['--if-instance', entry.instanceId] : [])]), cleanup ? undefined : request.signal));
192
- if (ack?.type !== 'killed' || ack.id !== entry.id || modern(entry) && ack.instanceId !== entry.instanceId)
193
- throw need('REMOTE_CLEANUP_UNCONFIRMED');
194
- if ((await list(cleanup)).some(row => row.id === entry.id))
195
- throw need('REMOTE_CLEANUP_UNCONFIRMED');
196
- }
197
- finally {
198
- probe.close({ layer: 'broker', initiator: 'broker.probe-close', reason: 'BROKER_PROBE_FINISHED', action: 'close' });
199
- }
200
- };
201
- const legacy = employeeScope ? undefined : rows.find(row => row.id === oldId);
202
- if (legacy)
203
- await report('PRE_0410_TEST_SESSION_NOT_RESUMED', { brokerId: legacy.id, observedPid: legacy.pid, state: legacy.state, instanceId: legacy.instanceId ?? null, reason: 'OLD_TEST_SESSION_COORDINATOR_CLEANUP', decision: '2026-10-22-00:05', approvalReplies: 0 });
204
- let entry = rows.find(row => row.id === id);
205
- if (entry)
206
- validEntry(entry);
207
- if (legacyContextScope && !entry && !request.requireFreshSession)
208
- throw need('RECOVERY_CONTEXT_POLICY_UNSUPPORTED');
209
- if (legacy && !entry)
210
- restarted = true;
211
- let transport;
212
- let resume;
213
- const build = (entry, after, recovery) => new BrokerTransport({ observeClose: closeDiagnostics.capture(), onNeed: n => request.detailChannel?.record({ kind: 'need', severity: 'warning', code: n.code, summary: '主机会话连接状态', payload: { brokerId: entry.id, instanceId: entry.instanceId } }), maxFrameBytes: settings.brokerMaxFrameBytes, maxMessageBytes: request.acpMaxMessageBytes ?? settings.brokerMaxFrameBytes, initialSequence: after, instanceId: entry.instanceId, leased: true, controlMaxBytes: entry.controlMaxBytes, beforeInput: async () => { await validate(); if (state?.phase !== 'dirty' || state.instanceId !== entry.instanceId) {
214
- const dirty = { version: 1, scope, brokerId: id, instanceId: entry.instanceId, pid: entry.pid, phase: 'dirty' };
215
- await journal.write(dirty);
216
- state = dirty;
217
- } await validate(); }, ...(recovery ? { recoverySessionId: recovery.sessionId, onRecoveryUpdate: (message) => { const params = message.params; if (params.update?.sessionUpdate === 'config_option_update' && recovery.modelConfiguration) {
218
- if (!Array.isArray(params.update.configOptions))
219
- throw need('RECOVERY_MODEL_CONFIGURATION_INVALID');
220
- (recovery.configUpdates ??= []).push(structuredClone(params.update.configOptions));
221
- } request.detailChannel?.record({ kind: 'session', severity: 'info', code: 'ACP_IDLE_UPDATE', summary: '恢复期间的远端状态轨迹', method: 'session/update', payload: message.params }); } } : {}), attach: after => io.attach(command(attachArgs(entry, after))), onDisconnect: async () => {
222
- if (employeeScope)
223
- throw need('EMPLOYEE_EXEC_UNKNOWN');
224
- const api = get();
225
- if (!provided?.io) {
226
- if (!api)
227
- throw need('DEPENDENCY_REQUIRED');
228
- const next = unwrap(await api.getSshTarget());
229
- validateTarget(next);
230
- if (!sameTarget(next, target))
231
- throw need('SESSION_CONTEXT_MISMATCH');
232
- io = ioFor(next);
233
- }
234
- await validate();
235
- await connectWithLease(transport, entry);
236
- } });
237
- if (entry) {
238
- if (!modern(entry))
239
- throw need('BROKER_CAPABILITY_REQUIRED');
240
- if (request.requireFreshSession) {
241
- if (!state || !['clean', 'ended'].includes(state.phase) || state.instanceId !== entry.instanceId)
242
- throw need('INPUT_DELIVERY_UNCONFIRMED');
243
- await stop(entry, 'PRIVATE_RECOVERY_POINTER_ABSENT');
244
- restarted = true;
245
- entry = undefined;
246
- }
247
- }
248
- if (entry) {
249
- const clean = !employeeScope && state?.phase === 'clean' && state.instanceId === entry.instanceId && state.acp?.sessionId === request.resumeSessionId && state.checkpointId === request.recoveryCheckpointId && state.cursor >= entry.firstSequence - 1 && state.cursor <= entry.lastSequence && entry.state === 'running' && (legacyContextScope ? await request.verifyRecovery?.() === true : await request.verifyRecovery?.() !== false);
250
- if (legacyContextScope && !clean)
251
- throw need('RECOVERY_CONTEXT_POLICY_UNSUPPORTED');
252
- if (clean) {
253
- transport = build(entry, state.cursor, state.acp);
254
- try {
255
- await timing.run('broker-attach', () => connectWithLease(transport, entry));
256
- await transport.replayThrough(entry.lastSequence, request.signal);
257
- await request.detailChannel?.flush();
258
- await validate();
259
- if (legacyContextScope) {
260
- const migrated = { ...state, scope };
261
- await report(agent.loginProvider === 'codex' ? 'CODEX_RECOVERY_SCOPE_MIGRATED' : 'CLAUDE_RECOVERY_SCOPE_MIGRATED', { oldScope: state.scope, newScope: scope, sessionId: state.acp.sessionId, checkpointId: state.checkpointId, instanceId: entry.instanceId, cursor: state.cursor });
262
- await journal.write(migrated);
263
- state = migrated;
264
- }
265
- resume = state.acp;
266
- }
267
- catch (error) {
268
- transport.close({ layer: 'broker', initiator: 'broker.replay-failure', reason: closeCode(error, 'RECOVERY_UNCONFIRMED'), action: 'close' });
269
- if (legacyContextScope || error instanceof NeedError && ['ATTACH_BUSY', 'SESSION_IN_USE', 'SESSION_INSTANCE_MISMATCH', 'ABORTED'].includes(error.code))
270
- throw error;
271
- await report('SESSION_REPLAY_UNRECOVERABLE', { brokerId: id, instanceId: entry.instanceId, reason: error instanceof NeedError ? error.code : 'UNKNOWN_FAILURE', recordedCursor: state?.cursor ?? null, firstSequence: entry.firstSequence, lastSequence: entry.lastSequence });
272
- }
273
- }
274
- if (!resume) {
275
- await stop(entry, 'SESSION_CHECKPOINT_UNRECOVERABLE', transport);
276
- restarted = true;
277
- entry = undefined;
278
- transport = undefined;
279
- }
280
- }
281
- if (!entry) {
282
- if (request.resumeSessionId) {
283
- restarted = true;
284
- await report('SESSION_BROKER_ABSENT', { brokerId: id });
285
- }
286
- await validate();
287
- state = { version: 1, scope, brokerId: id, phase: 'dirty' };
288
- await journal.write(state);
289
- const started = await timing.run('broker-start', async () => controlJson(await io.run(command(['start', id, '--cwd', project.path, '--settings', target.session.settingsPath, '--', agent.command]), request.signal)));
290
- if (started?.protocolVersion !== 1 || started.id !== id || typeof started.socket !== 'string' || !started.instanceId || !Number.isSafeInteger(started.controlMaxBytes) || !Number.isSafeInteger(started.pid) || started.pid < 1)
291
- throw need('BROKER_START_UNCONFIRMED');
292
- entry = { ...started, state: 'running', firstSequence: 1, lastSequence: 0 };
293
- state = { ...state, instanceId: entry.instanceId, pid: entry.pid };
294
- await journal.write(state);
295
- validEntry(entry);
296
- transport = build(entry, 0);
297
- const opening = transport;
298
- const aborted = () => opening.fail(need('ABORTED'), { layer: 'broker', initiator: 'broker.open-signal', reason: 'ABORTED', action: 'abort' });
299
- request.signal?.addEventListener('abort', aborted, { once: true });
300
- try {
301
- request.signal?.throwIfAborted();
302
- await timing.run('broker-attach', () => connectWithLease(opening, entry));
303
- await validate();
304
- }
305
- catch (error) {
306
- if (!(error instanceof NeedError && ['ATTACH_BUSY', 'SESSION_IN_USE', 'SESSION_INSTANCE_MISMATCH'].includes(error.code)))
307
- await stop(entry, 'SESSION_OPEN_FAILED', opening, true, true);
308
- opening.close({ layer: 'broker', initiator: 'broker.open-failure', reason: closeCode(error), action: 'close' });
309
- throw error;
310
- }
311
- finally {
312
- request.signal?.removeEventListener('abort', aborted);
313
- }
314
- }
315
- const ownedEntry = entry, ownedTransport = transport;
316
- let disposed = false;
317
- let disposal;
318
- const runtime = { closeDiagnostics, identity: identity(target), cwd: project.path, broker: ownedTransport, acpTransport: ownedTransport,
319
- ...(resume ? { recoveredSession: resume } : {}), ...(restarted ? { restartNotice: '上次会话无法接回,已新建会话;本次从你的这条请求继续。', restartInputIds: request.currentInputIds } : {}), ...(request.requireFreshSession ? { freshSessionConfirmed: true } : {}),
320
- saveCheckpoint: async (checkpointId, acp) => { await validate(); const saved = await ownedTransport.commitCheckpoint(async (cursor) => { await validate(); const clean = { version: 1, phase: 'clean', scope, brokerId: id, instanceId: ownedEntry.instanceId, pid: ownedEntry.pid, cursor, checkpointId, acp }; try {
321
- await journal.write(clean);
322
- state = clean;
323
- }
324
- catch (error) {
325
- state = undefined;
326
- throw error;
327
- } }); if (!saved)
328
- await report('SESSION_CHECKPOINT_NOT_CLEAN', { brokerId: id, instanceId: ownedEntry.instanceId }); return saved; },
329
- detach: async (cause = { layer: 'broker', initiator: 'broker.runtime-detach', reason: 'BROKER_DETACH', action: 'detach' }) => { const sink = request.detailChannel; closeDiagnostics.record(cause); ownedTransport.close(cause); active.delete(key); retained.delete(key); await flushCloseDiagnostics(closeDiagnostics, sink ? () => sink.flush() : undefined); },
330
- dispose: (cause = { layer: 'broker', initiator: 'broker.runtime-dispose', reason: 'BROKER_DISPOSE', action: 'dispose' }) => { const sink = request.detailChannel; closeDiagnostics.record(cause); return disposal ??= (async () => { if (disposed)
331
- return; let primary; try {
332
- await stop(ownedEntry, 'SESSION_ENDED', ownedTransport, true);
333
- await journal.write({ version: 1, phase: 'ended', scope, brokerId: id, instanceId: ownedEntry.instanceId, pid: ownedEntry.pid });
334
- disposed = true;
335
- }
336
- catch (error) {
337
- primary = { error };
338
- }
339
- finally {
340
- ownedTransport.close({ layer: 'broker', initiator: 'broker.dispose-finally', reason: 'BROKER_DISPOSE_CLEANUP', action: 'close' });
341
- active.delete(key);
342
- retained.delete(key);
343
- } await flushCloseDiagnostics(closeDiagnostics, sink ? () => sink.flush() : undefined, primary); })(); },
344
- };
345
- if (employeeScope) {
346
- runtime.saveCheckpoint = undefined;
347
- runtime.detach = runtime.dispose;
348
- }
349
- if (request.retainProcess)
350
- retained.set(key, { runtime, scope, update: next => { const sink = next.detailChannel; if (sink)
351
- closeDiagnostics.bind(event => sink.record(event)); request = next; timing = next.timing ?? new StageTiming(); } });
352
- return runtime;
51
+ request.signal?.throwIfAborted();
52
+ await channel.connect();
353
53
  }
354
54
  catch (error) {
355
- active.delete(key);
55
+ await dispose();
356
56
  throw error;
357
57
  }
58
+ finally {
59
+ request.signal?.removeEventListener('abort', aborted);
60
+ }
61
+ return { identity: identity(target), cwd: project.path, closeDiagnostics: diagnostics, broker: channel, acpTransport: channel, dispose, detach: dispose };
358
62
  };
359
- return open;
360
63
  }
package/dist/broker.d.ts CHANGED
@@ -1,25 +1,18 @@
1
1
  import { type CloseCause, type CloseObserver } from './close-diagnostics.ts';
2
2
  import { type ChildProcessWithoutNullStreams } from 'node:child_process';
3
3
  import { NeedError, type Need } from './remote.ts';
4
- import { type ConnectTarget } from './provider.ts';
4
+ import type { ConnectTarget } from './provider.ts';
5
5
  export type Attachment = ChildProcessWithoutNullStreams;
6
6
  export interface BrokerOptions {
7
7
  observeClose?: CloseObserver;
8
8
  maxFrameBytes: number;
9
- initialSequence?: number;
10
9
  instanceId?: string;
11
10
  leased?: boolean;
12
11
  controlMaxBytes?: number;
13
- maxMessageBytes?: number;
14
- beforeInput?: () => Promise<void>;
15
- recoverySessionId?: string;
16
- onRecoveryUpdate?: (message: Record<string, unknown>) => void;
17
- discardOutput?: boolean;
18
- attach: (after: number) => Promise<Attachment>;
12
+ attach: () => Promise<Attachment>;
19
13
  onNeed?: (need: Need) => void;
20
- onDisconnect?: () => Promise<void>;
21
14
  }
22
- /** One byte stream / SDK app, multiple SSH attachments. Input is never replayed. */
15
+ /** One byte stream / SDK app over one SSH attachment of a new agent. Input is never replayed. */
23
16
  export declare class BrokerTransport {
24
17
  readonly readable: ReadableStream<Uint8Array>;
25
18
  readonly writable: WritableStream<Uint8Array>;
@@ -34,41 +27,24 @@ export declare class BrokerTransport {
34
27
  private lease;
35
28
  private listeners;
36
29
  private permits;
37
- private approvalSent;
38
- private unconfirmed;
30
+ private promptPermit?;
39
31
  private failure?;
40
32
  private heartbeat?;
41
- private boundary?;
42
- private recovering;
43
- private promptSent;
44
- private preparingInputs;
45
33
  private inputTail;
46
34
  private exclusive;
47
35
  constructor(options: BrokerOptions);
48
36
  get online(): boolean;
49
37
  get generation(): number;
50
38
  get signal(): AbortSignal;
51
- get lastSequence(): number;
52
39
  get error(): NeedError | undefined;
53
- get promptInFlight(): boolean;
54
- processedMessage(message: Record<string, unknown>): void;
55
- checkpointSequence(): number | null;
56
- commitCheckpoint(commit: (cursor: number) => Promise<void>): Promise<boolean>;
57
- finishRecovery(): void;
58
- replayThrough(sequence: number, signal?: AbortSignal): Promise<void>;
59
40
  private stopHeartbeat;
60
- private recoveryMessage;
61
- authorizeResponse(id: string | number | null, generation: number): void;
62
- /** Called only by the ACP protocol layer after a matching result or current-turn output. */
63
- acknowledge(method: string): void;
64
- /** A matching terminal prompt response settles all approval replies in that turn. */
65
- completePrompt(): void;
41
+ authorizeResponse(id: string | number | null, generation: number, valid?: () => boolean): void;
42
+ authorizePrompt(valid: () => boolean): void;
66
43
  onChange(fn: () => void): () => void;
67
44
  private changed;
68
45
  private notice;
69
46
  fail(error: NeedError, cause?: CloseCause): void;
70
47
  close(cause?: CloseCause): void;
71
- waitOnline(signal?: AbortSignal): Promise<void>;
72
48
  connect(): Promise<void>;
73
49
  private attach;
74
50
  private write;
@@ -82,7 +58,6 @@ export interface BrokerIo {
82
58
  export declare function controlNeed(stderr: string): NeedError;
83
59
  export declare function sshIo(target: ConnectTarget, argv: SshArgv, maxControlBytes: number): BrokerIo;
84
60
  export declare function brokerCommand(t: ConnectTarget, args: string[]): string;
85
- export declare function legacyBrokerId(sessionId: string, agentId?: string): string;
86
61
  export declare function brokerId(sessionId: string, agentId?: string): string;
87
62
  export declare function controlJson(text: string): unknown;
88
63
  export interface BrokerSettings {