@ai-workbench/remote-agent 0.8.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/provider.js CHANGED
@@ -132,10 +132,13 @@ export function connectProvider(get, options = {}) {
132
132
  throw new NeedError('PROJECT_BINDING_INVALID', '工作区绑定与当前会话或可信主机不一致', 'L核验的原生工作区绑定');
133
133
  if (request.cwd && request.cwd !== project.path || request.bindingId && request.bindingId !== resolved.bindingId || request.workspaceId && request.workspaceId !== resolved.workspaceId)
134
134
  throw new NeedError('SESSION_CONTEXT_MISMATCH', '原会话的工作区绑定已改变,请新建会话', '原工作区绑定');
135
+ const details = await options.details?.(request, api, connection, target);
136
+ stable();
137
+ let workspaceNotice = undefined;
135
138
  if (resolved.notice) {
136
139
  if (!options.notice)
137
140
  throw new NeedError('DEPENDENCY_REQUIRED', '我需要官方会话持久记录来确认工作区说明', '官方会话与持久化服务');
138
- await options.notice.deliver(resolved.notice, resolved, request, api);
141
+ workspaceNotice = await options.notice.deliver(resolved.notice, resolved, request, api);
139
142
  stable();
140
143
  }
141
144
  if (!options.open)
@@ -148,6 +151,9 @@ export function connectProvider(get, options = {}) {
148
151
  await runtime.dispose?.();
149
152
  throw error;
150
153
  }
154
+ // The transport can outlive a turn; presentation belongs only to this invocation.
155
+ runtime.workspaceNotice = workspaceNotice || undefined;
156
+ runtime.details = details ?? runtime.details;
151
157
  runtime.binding = { bindingId: resolved.bindingId, workspaceId: resolved.workspaceId, revision: resolved.bindingRevision };
152
158
  runtime.connection = { ...connection };
153
159
  runtime.loginRequired = () => offerLogin(api, request, get, agent);
@@ -0,0 +1,26 @@
1
+ import { type DetailObservation } from './conversation-details.ts';
2
+ import type { ConnectApi as LConnectApi, ConnectionState } from '@ai-workbench/connect/api';
3
+ export type { RemoteConversationDetail as RemoteDetail } from '@ai-workbench/connect/api';
4
+ type Connection = Pick<ConnectionState, 'instanceId' | 'epoch' | 'phase'>;
5
+ export type RemoteDetailsApi = Pick<LConnectApi, 'getConnectionState'> & Partial<Pick<LConnectApi, 'getContractInfo' | 'appendRemoteConversationDetail' | 'getRemoteConversationDetailReceipt' | 'listRemoteConversationDetails'>>;
6
+ export interface DetailChannel {
7
+ record(observation: DetailObservation): void;
8
+ beginTurn(ids: {
9
+ turn: string | number | null;
10
+ step: string | number | null;
11
+ }): void;
12
+ endTurn(): void;
13
+ flush(): Promise<void>;
14
+ }
15
+ export interface DetailRequest {
16
+ sessionId: string;
17
+ provider: 'claude' | 'codex';
18
+ identity: string;
19
+ connection: Connection;
20
+ }
21
+ export declare function createRemoteDetails(options: {
22
+ profileDir: string;
23
+ getApi: () => RemoteDetailsApi | undefined;
24
+ }): {
25
+ open(request: DetailRequest): Promise<DetailChannel>;
26
+ };
@@ -0,0 +1,250 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { constants } from 'node:fs';
3
+ import { mkdir, open, lstat, realpath, readdir } from 'node:fs/promises';
4
+ import { isAbsolute, join, parse } from 'node:path';
5
+ import { isDeepStrictEqual } from 'node:util';
6
+ import { sanitizeObservation, isSanitizedRedaction } from "./conversation-details.js";
7
+ import { NeedError } from "./remote.js";
8
+ const failure = (code, message = '技术详情未能持久记录,本轮已停止。') => new NeedError(code, message, '可用的远程主机连接技术详情');
9
+ const hash = (value) => createHash('sha256').update(value).digest('hex');
10
+ const object = (value) => !!value && typeof value === 'object' && !Array.isArray(value);
11
+ function unwrap(result) {
12
+ if (!object(result))
13
+ throw failure('REMOTE_DETAIL_RESPONSE_INVALID');
14
+ if (result.ok === false && object(result.error) && typeof result.error.code === 'string')
15
+ throw failure(result.error.code);
16
+ if (result.ok !== true)
17
+ throw failure('REMOTE_DETAIL_RESPONSE_INVALID');
18
+ return result.value;
19
+ }
20
+ function identifier(value) { return value === null || typeof value === 'string' && value.trim().length > 0 || typeof value === 'number' && Number.isSafeInteger(value) && value >= 0; }
21
+ function kind(observation) {
22
+ if (/UNKNOWN|UNSUPPORTED|FOREIGN/.test(observation.code))
23
+ return observation.severity === 'action-required' ? 'action-required' : 'unknown';
24
+ return { extension: 'extensions', tool: 'tools', need: 'action-required', notice: 'extensions', session: 'extensions' }[observation.kind] ?? observation.kind;
25
+ }
26
+ async function privateDirectory(path) {
27
+ if (!isAbsolute(path))
28
+ throw failure('REMOTE_DETAIL_STORAGE_FAILED');
29
+ // Existing parent/profile ownership is the host's responsibility; reject symlinks
30
+ // along the full path before creating our own private child directories.
31
+ let part = parse(path).root;
32
+ for (const member of path.slice(part.length).split('/').filter(Boolean)) {
33
+ part = join(part, member);
34
+ try {
35
+ const info = await lstat(part);
36
+ if (!info.isDirectory() || info.isSymbolicLink())
37
+ throw failure('REMOTE_DETAIL_STORAGE_FAILED');
38
+ }
39
+ catch (error) {
40
+ if (error?.code !== 'ENOENT')
41
+ throw error;
42
+ try {
43
+ await mkdir(part, { mode: 0o700 });
44
+ }
45
+ catch (creation) {
46
+ if (creation?.code !== 'EEXIST')
47
+ throw creation;
48
+ const info = await lstat(part);
49
+ if (!info.isDirectory() || info.isSymbolicLink())
50
+ throw failure('REMOTE_DETAIL_STORAGE_FAILED');
51
+ }
52
+ }
53
+ }
54
+ if (await realpath(path) !== path)
55
+ throw failure('REMOTE_DETAIL_STORAGE_FAILED');
56
+ const info = await lstat(path);
57
+ if ((info.mode & 0o077) !== 0 || (process.getuid && info.uid !== process.getuid()))
58
+ throw failure('REMOTE_DETAIL_STORAGE_FAILED');
59
+ }
60
+ async function durableWrite(path, value) {
61
+ const file = await open(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
62
+ try {
63
+ await file.writeFile(JSON.stringify(value) + '\n');
64
+ await file.sync();
65
+ }
66
+ finally {
67
+ await file.close();
68
+ }
69
+ const parent = await open(join(path, '..'), 'r');
70
+ try {
71
+ await parent.sync();
72
+ }
73
+ finally {
74
+ await parent.close();
75
+ }
76
+ }
77
+ async function readJournal(path) {
78
+ const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
79
+ try {
80
+ const info = await file.stat();
81
+ if (!info.isFile() || (info.mode & 0o077) !== 0 || (process.getuid && info.uid !== process.getuid()))
82
+ throw failure('REMOTE_DETAIL_STORAGE_FAILED');
83
+ return JSON.parse(await file.readFile('utf8'));
84
+ }
85
+ finally {
86
+ await file.close();
87
+ }
88
+ }
89
+ export function createRemoteDetails(options) {
90
+ const root = join(options.profileDir, 'ai-workbench-remote-agent', 'diagnostics');
91
+ const scopes = new Map();
92
+ const initialize = async () => { await privateDirectory(join(options.profileDir, 'ai-workbench-remote-agent')); await privateDirectory(root); };
93
+ return { async open(request) {
94
+ if (!request.sessionId || !request.identity || !request.connection.instanceId || !Number.isSafeInteger(request.connection.epoch) || request.connection.epoch < 0 || !['claude', 'codex'].includes(request.provider))
95
+ throw failure('REMOTE_DETAIL_SCOPE_INVALID');
96
+ try {
97
+ await initialize();
98
+ }
99
+ catch {
100
+ throw failure('REMOTE_DETAIL_STORAGE_FAILED');
101
+ }
102
+ const api = options.getApi();
103
+ const stable = () => {
104
+ const now = api?.getConnectionState();
105
+ if (!api || options.getApi() !== api || now?.phase !== 'connected' || now.instanceId !== request.connection.instanceId || now.epoch !== request.connection.epoch)
106
+ throw failure('CONNECTION_LOST');
107
+ };
108
+ stable();
109
+ const capability = api.getContractInfo?.();
110
+ if (!object(capability) || capability.remoteConversationDetails !== 1 || !['appendRemoteConversationDetail', 'getRemoteConversationDetailReceipt', 'listRemoteConversationDetails'].every(key => typeof api[key] === 'function')) {
111
+ await durableWrite(join(root, randomUUID() + '.json'), { version: 1, code: 'TECHNICAL_DETAILS_UNAVAILABLE', summary: '技术详情暂不可用', session: hash(request.sessionId), provider: request.provider });
112
+ throw failure('TECHNICAL_DETAILS_UNAVAILABLE', '技术详情暂不可用,请更新远程主机连接组件。');
113
+ }
114
+ const key = hash(request.identity + '\0' + request.sessionId + '\0' + request.provider), dir = join(root, key);
115
+ await privateDirectory(dir);
116
+ const scope = scopes.get(key) ?? { tail: Promise.resolve(), maximum: 0, loaded: false };
117
+ scopes.set(key, scope);
118
+ // Serialize open and appends in this host-user/session/brand scope.
119
+ const load = scope.tail.then(async () => {
120
+ stable();
121
+ const generation = request.connection.instanceId + '\0' + request.connection.epoch;
122
+ if (scope.loaded && scope.generation === generation)
123
+ return;
124
+ const names = await readdir(dir);
125
+ for (const name of names) {
126
+ const match = /^(\d+)-([0-9a-f-]+)\.json$/.exec(name);
127
+ if (!match)
128
+ continue;
129
+ const n = Number(match[1]);
130
+ if (!Number.isSafeInteger(n))
131
+ throw failure('REMOTE_DETAIL_STORAGE_FAILED');
132
+ scope.maximum = Math.max(scope.maximum, n);
133
+ const saved = await readJournal(join(dir, name));
134
+ if (!object(saved) || !object(saved.detail) || saved.detail.eventId !== match[2] || saved.detail.sequence !== n || saved.detail.sessionId !== request.sessionId || saved.detail.provider !== request.provider)
135
+ throw failure('REMOTE_DETAIL_STORAGE_FAILED');
136
+ const settled = 'settled-' + match[2] + '.json';
137
+ if (names.includes(settled)) {
138
+ const result = await readJournal(join(dir, settled));
139
+ if (!object(result) || result.eventId !== match[2] || !['recorded', 'rejected'].includes(result.state))
140
+ throw failure('REMOTE_DETAIL_STORAGE_FAILED');
141
+ continue;
142
+ }
143
+ // An interrupted append is reconciled by pure read, with the original
144
+ // event's epoch intact. Missing receipts stay unknown and never trigger append.
145
+ stable();
146
+ const receipt = unwrap(await api.getRemoteConversationDetailReceipt({ eventId: match[2], sessionId: request.sessionId, provider: request.provider }));
147
+ stable();
148
+ if (receipt.eventId !== match[2])
149
+ throw failure('REMOTE_DETAIL_RECEIPT_INVALID');
150
+ if (!receipt.recorded)
151
+ throw failure('REMOTE_DETAIL_DELIVERY_UNKNOWN');
152
+ if (!isDeepStrictEqual(receipt.detail, saved.detail))
153
+ throw failure('REMOTE_DETAIL_RECEIPT_INVALID');
154
+ await durableWrite(join(dir, settled), { eventId: match[2], state: 'recorded' });
155
+ }
156
+ let cursor, previous = -1;
157
+ const seen = new Set();
158
+ let expected;
159
+ let count = 0;
160
+ do {
161
+ stable();
162
+ const page = unwrap(await api.listRemoteConversationDetails({ sessionId: request.sessionId, provider: request.provider, ...(cursor ? { cursor } : {}) }));
163
+ stable();
164
+ if (!object(page) || !Array.isArray(page.items) || ![page.total, page.unknownCount, page.actionRequiredCount].every(n => Number.isSafeInteger(n) && n >= 0))
165
+ throw failure('REMOTE_DETAIL_RESPONSE_INVALID');
166
+ const counts = { total: page.total, unknownCount: page.unknownCount, actionRequiredCount: page.actionRequiredCount };
167
+ if (expected && !isDeepStrictEqual(expected, counts))
168
+ throw failure('REMOTE_DETAIL_RESPONSE_INVALID');
169
+ expected = counts;
170
+ for (const entry of page.items) {
171
+ if (entry.sessionId !== request.sessionId || entry.provider !== request.provider || !Number.isSafeInteger(entry.sequence) || entry.sequence <= previous)
172
+ throw failure('REMOTE_DETAIL_RESPONSE_INVALID');
173
+ previous = entry.sequence;
174
+ scope.maximum = Math.max(scope.maximum, entry.sequence);
175
+ count++;
176
+ }
177
+ cursor = page.nextCursor;
178
+ if (cursor !== undefined && (typeof cursor !== 'string' || !cursor || seen.has(cursor)))
179
+ throw failure('REMOTE_DETAIL_CURSOR_INVALID');
180
+ if (cursor)
181
+ seen.add(cursor);
182
+ } while (cursor);
183
+ if (count !== expected?.total)
184
+ throw failure('REMOTE_DETAIL_RESPONSE_INVALID');
185
+ scope.loaded = true;
186
+ scope.generation = generation;
187
+ });
188
+ scope.tail = load.then(() => { }, () => { });
189
+ await load;
190
+ let turn = null, step = null;
191
+ let tail = Promise.resolve(), firstFailure;
192
+ const enqueue = (operation) => {
193
+ const task = scope.tail.then(operation);
194
+ scope.tail = task.then(() => { }, () => { });
195
+ tail = task.catch(error => { scope.loaded = false; firstFailure ??= { error }; });
196
+ };
197
+ return {
198
+ beginTurn(ids) { if (!identifier(ids.turn) || !identifier(ids.step))
199
+ throw failure('REMOTE_DETAIL_SCOPE_INVALID'); turn = ids.turn; step = ids.step; },
200
+ endTurn() { turn = null; step = null; },
201
+ record(observation) {
202
+ const safe = sanitizeObservation(observation, true);
203
+ const wire = (value) => Array.isArray(value) ? value.map(wire) : object(value) ? isSanitizedRedaction(value) ? (typeof value.valueType === 'string' ? { redacted: true, valueType: value.valueType, value: '已脱敏' } : '已脱敏') : Object.fromEntries(Object.entries(value).map(([key, child]) => [key, wire(child)])) : value;
204
+ const sequence = scope.maximum + 1;
205
+ if (!Number.isSafeInteger(sequence))
206
+ throw failure('REMOTE_DETAIL_SEQUENCE_INVALID');
207
+ scope.maximum = sequence;
208
+ const detail = Object.freeze({ ...safe, payload: wire(safe.payload), version: 1, eventId: randomUUID(), instanceId: request.connection.instanceId, epoch: request.connection.epoch, sessionId: request.sessionId, provider: request.provider, turn, step, sequence, kind: kind(observation) });
209
+ enqueue(async () => {
210
+ // A private X journal is retained even if L is disconnected or rejects it.
211
+ const file = join(dir, sequence + '-' + detail.eventId + '.json');
212
+ try {
213
+ await durableWrite(file, { state: 'pending', detail });
214
+ }
215
+ catch {
216
+ throw failure('REMOTE_DETAIL_STORAGE_FAILED');
217
+ }
218
+ stable();
219
+ let response;
220
+ try {
221
+ response = await api.appendRemoteConversationDetail(structuredClone(detail));
222
+ }
223
+ catch {
224
+ stable();
225
+ const receipt = unwrap(await api.getRemoteConversationDetailReceipt({ eventId: detail.eventId, sessionId: detail.sessionId, provider: detail.provider }));
226
+ stable();
227
+ if (receipt.eventId !== detail.eventId)
228
+ throw failure('REMOTE_DETAIL_RECEIPT_INVALID');
229
+ if (!receipt.recorded)
230
+ throw failure('REMOTE_DETAIL_DELIVERY_UNKNOWN');
231
+ if (!isDeepStrictEqual(receipt.detail, detail))
232
+ throw failure('REMOTE_DETAIL_RECEIPT_INVALID');
233
+ await durableWrite(join(dir, 'settled-' + detail.eventId + '.json'), { eventId: detail.eventId, state: 'recorded' });
234
+ return;
235
+ }
236
+ stable();
237
+ if (response?.ok === false) {
238
+ await durableWrite(join(dir, 'settled-' + detail.eventId + '.json'), { eventId: detail.eventId, state: 'rejected', code: response.error.code });
239
+ }
240
+ const ack = unwrap(response);
241
+ if (ack.eventId !== detail.eventId || ack.recorded !== true)
242
+ throw failure('REMOTE_DETAIL_RESPONSE_INVALID');
243
+ await durableWrite(join(dir, 'settled-' + detail.eventId + '.json'), { eventId: detail.eventId, state: 'recorded' });
244
+ });
245
+ },
246
+ async flush() { await tail; if (firstFailure)
247
+ throw firstFailure.error; stable(); },
248
+ };
249
+ } };
250
+ }
@@ -1,4 +1,5 @@
1
1
  import type { Context } from '@deepseek-ai/cordis';
2
+ import { type RequestMessage } from '@deepseek-ai/dsh-llm';
2
3
  import { type AgentDefinition } from './agents.ts';
3
4
  import type { ConnectApi, RuntimeRequest } from './provider.ts';
4
5
  import type { WorkspaceNotice, SessionProjectTarget } from './connect-contract.ts';
@@ -20,7 +21,27 @@ export interface WorkspaceNoticeMarker {
20
21
  export declare function parseWorkspaceNoticeMarker(message: unknown): WorkspaceNoticeMarker | undefined;
21
22
  /** Only this plugin's complete, source-bound notice may be excluded from remote model history. */
22
23
  export declare function isWorkspaceNoticeMessage(message: unknown): boolean;
24
+ /** A verified first block in the actual model reply, never a second final message. */
25
+ export interface WorkspaceNoticePresentation {
26
+ readonly kind: 'workspace-notice';
27
+ readonly version: 2;
28
+ readonly provider: string;
29
+ readonly loginProvider: 'claude' | 'codex';
30
+ readonly sessionId: string;
31
+ readonly noticeId: string;
32
+ readonly claimId: string;
33
+ readonly workspaceName: string;
34
+ readonly bindingId: string;
35
+ readonly workspaceId: string;
36
+ readonly turn: number;
37
+ readonly step: number;
38
+ readonly blockIndex: 0;
39
+ readonly text: string;
40
+ }
41
+ export declare function workspaceNoticeFromMessage(message: unknown): WorkspaceNoticePresentation | undefined;
42
+ /** Preserve the model answer and exact remote replay pointer; ordinary text is never searched. */
43
+ export declare function withoutWorkspaceNotice<T extends RequestMessage>(message: T): T;
23
44
  export declare function createWorkspaceNoticeConsumer(ctx: Context, agent: AgentDefinition): {
24
45
  reconcile: (request: RuntimeRequest, api: ConnectApi) => Promise<void>;
25
- deliver: (notice: WorkspaceNotice, resolved: SessionProjectTarget, request: RuntimeRequest, api: ConnectApi) => Promise<void>;
46
+ deliver: (notice: WorkspaceNotice, resolved: SessionProjectTarget, request: RuntimeRequest, api: ConnectApi) => Promise<WorkspaceNoticePresentation | undefined>;
26
47
  };