@ai-workbench/connect 0.7.7 → 0.7.11

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 (42) hide show
  1. package/README.md +62 -5
  2. package/client/index.cjs +33 -5
  3. package/helper/auth-status.cjs +6 -6
  4. package/helper/device-panel.cjs +4 -0
  5. package/helper/device-panel.css +1 -0
  6. package/helper/device-panel.html +1 -0
  7. package/helper/device-preload.cjs +5 -0
  8. package/helper/device-window.cjs +60 -0
  9. package/helper/remote-command.cjs +3 -2
  10. package/helper/remote-login.cjs +1 -1
  11. package/helper/window.cjs +8 -14
  12. package/lib/api.d.ts +98 -1
  13. package/lib/auth-diagnostics.d.ts +22 -0
  14. package/lib/auth-diagnostics.js +45 -0
  15. package/lib/auth-status.js +21 -8
  16. package/lib/components.d.ts +1 -1
  17. package/lib/components.js +72 -4
  18. package/lib/connect.js +93 -27
  19. package/lib/conversation-detail-settings.d.ts +32 -0
  20. package/lib/conversation-detail-settings.js +119 -0
  21. package/lib/conversation-details.d.ts +24 -0
  22. package/lib/conversation-details.js +382 -0
  23. package/lib/cordis.d.ts +3 -1
  24. package/lib/defaults.d.ts +4 -0
  25. package/lib/defaults.js +1 -0
  26. package/lib/index.d.ts +4 -0
  27. package/lib/index.js +23 -15
  28. package/lib/integration.js +21 -1
  29. package/lib/login-diagnostics.d.ts +26 -0
  30. package/lib/login-diagnostics.js +42 -0
  31. package/lib/login.d.ts +8 -4
  32. package/lib/login.js +16 -25
  33. package/lib/needs.js +8 -0
  34. package/lib/processes.js +34 -7
  35. package/lib/profile-runtime.js +51 -11
  36. package/lib/profile-settings.js +3 -3
  37. package/lib/ssh.js +37 -14
  38. package/lib/startup.d.ts +22 -1
  39. package/lib/startup.js +124 -33
  40. package/lib/workspace-bindings.d.ts +4 -1
  41. package/lib/workspace-bindings.js +51 -1
  42. package/package.json +1 -1
@@ -1,18 +1,31 @@
1
- import { fail, ok } from "./needs.js";
1
+ import { ok } from "./needs.js";
2
+ import { authFailure, outputSummary } from "./auth-diagnostics.js";
2
3
  export function parseLoginStatus(provider, result) {
4
+ const invalid = (reason, details = {}) => authFailure('auth-status', reason, { exitCode: result.exitCode, ...(result.stderr !== '' ? { stderr: outputSummary(result.stderr) } : {}), ...details });
3
5
  let v;
4
6
  try {
5
7
  v = JSON.parse(result.stdout);
6
8
  }
7
- catch {
8
- return fail('AUTH_STATUS_INVALID');
9
+ catch (error) {
10
+ return invalid('STATUS_JSON_INVALID', { output: outputSummary(result.stdout), errorName: error instanceof SyntaxError ? 'SyntaxError' : 'UnknownError' });
9
11
  }
10
- if (!v || typeof v !== 'object' || Array.isArray(v) || Object.keys(v).sort().join(',') !== 'exitCode,loggedIn,protocolVersion,provider')
11
- return fail('AUTH_STATUS_INVALID');
12
+ if (!v || typeof v !== 'object' || Array.isArray(v))
13
+ return invalid('STATUS_OBJECT_INVALID');
14
+ const expected = ['exitCode', 'loggedIn', 'protocolVersion', 'provider'];
15
+ if (Object.keys(v).sort().join(',') !== expected.join(','))
16
+ return invalid('STATUS_KEYS_INVALID', { missing: expected.filter(key => !Object.hasOwn(v, key)), unexpectedCount: Object.keys(v).filter(key => !expected.includes(key)).length });
12
17
  const status = v;
13
- if (status.protocolVersion !== 1 || status.provider !== provider || status.exitCode !== result.exitCode || typeof status.loggedIn !== 'boolean' || result.stderr !== '')
14
- return fail('AUTH_STATUS_INVALID');
18
+ if (status.protocolVersion !== 1)
19
+ return invalid('STATUS_VERSION_INVALID');
20
+ if (status.provider !== provider)
21
+ return invalid('STATUS_PROVIDER_INVALID');
22
+ if (status.exitCode !== result.exitCode)
23
+ return invalid('STATUS_EXIT_MISMATCH');
24
+ if (typeof status.loggedIn !== 'boolean')
25
+ return invalid('STATUS_LOGGED_IN_INVALID', { loggedInType: status.loggedIn === null ? 'null' : typeof status.loggedIn });
26
+ if (result.stderr !== '')
27
+ return invalid('STATUS_STDERR_NONEMPTY', { stderr: outputSummary(result.stderr) });
15
28
  if (status.loggedIn ? result.exitCode !== 0 : provider === 'codex' ? result.exitCode !== 1 : result.exitCode !== 0 && result.exitCode !== 1)
16
- return fail('AUTH_STATUS_INVALID');
29
+ return invalid('STATUS_EXIT_INVALID');
17
30
  return ok({ provider, loggedIn: status.loggedIn });
18
31
  }
@@ -7,4 +7,4 @@ export interface ComponentInventory {
7
7
  claude: string;
8
8
  codex?: string;
9
9
  }
10
- export declare function registerComponents(ctx: Context, inventory?: ComponentInventory, temporaryDataParent?: string, component?: ComponentProgressStore, sources?: ComponentSources, observationIntervalMs?: number): void;
10
+ export declare function registerComponents(ctx: Context, inventory?: ComponentInventory, temporaryDataParent?: string, component?: ComponentProgressStore, sources?: ComponentSources, observationIntervalMs?: number, metadataMaxBytes?: number): void;
package/lib/components.js CHANGED
@@ -2,11 +2,12 @@ import { ComponentDownloadError } from "./component-download.js";
2
2
  import { L_DEFAULTS } from "./defaults.js";
3
3
  import { fail } from "./needs.js";
4
4
  import { employeeMessage } from "./startup.js";
5
- export function registerComponents(ctx, inventory, temporaryDataParent, component, sources, observationIntervalMs = L_DEFAULTS.componentObservationIntervalMs) {
5
+ export function registerComponents(ctx, inventory, temporaryDataParent, component, sources, observationIntervalMs = L_DEFAULTS.componentObservationIntervalMs, metadataMaxBytes = L_DEFAULTS.enrollmentMetadataMaxBytes) {
6
+ const detailSettings = () => ctx.get('aiWorkbenchConversationDetailSettings');
6
7
  const api = () => ctx.get('aiWorkbenchConnect');
7
8
  const getState = () => {
8
9
  const network = ctx.get('aiWorkbenchNetwork'), connect = api(), state = connect?.getState(), networkState = network?.getState();
9
- return { workspaceBindings: connect?.getWorkspaceBindingState?.() ?? null, loginComponent: { temporaryDataParent: temporaryDataParent ?? null, download: component?.getState() ?? null, source: sources?.getState() ?? null, observationIntervalMs }, startup: connect?.getLoginPromptState?.() ?? null, inventory: inventory ?? null, network: { serviceProvided: !!network, phase: networkState?.phase ?? 'missing', needCode: networkState?.error?.code ?? networkState?.need?.code }, enrollment: connect?.getEnrollmentState?.() ?? null, connection: connect?.getConnectionState?.() ?? null, connect: { serviceProvided: !!connect, phase: state?.phase ?? 'missing', message: state?.need ? employeeMessage(state.need.code) : '连接服务等待就绪' }, codex: inventory?.codex ? { installed: true, message: 'Codex 组件已安装' } : { installed: null, message: '组件清单未装配' } };
10
+ return { loginDiagnostics: connect?.getLoginDiagnosticState?.() ?? null, conversationDetails: connect?.getRemoteConversationDetailState?.() ?? null, conversationDetailSettings: detailSettings() ? { ...detailSettings().getState(), metadataMaxBytes } : null, workspaceBindings: connect?.getWorkspaceBindingState?.() ?? null, loginComponent: { temporaryDataParent: temporaryDataParent ?? null, download: component?.getState() ?? null, source: sources?.getState() ?? null, observationIntervalMs }, startup: connect?.getLoginPromptState?.() ?? null, inventory: inventory ?? null, network: { serviceProvided: !!network, phase: networkState?.phase ?? 'missing', needCode: networkState?.error?.code ?? networkState?.need?.code }, enrollment: connect?.getEnrollmentState?.() ?? null, connection: connect?.getConnectionState?.() ?? null, connect: { serviceProvided: !!connect, phase: state?.phase ?? 'missing', message: state?.need ? employeeMessage(state.need.code) : '连接服务等待就绪' }, codex: inventory?.codex ? { installed: true, message: 'Codex 组件已安装' } : { installed: null, message: '组件清单未装配' } };
10
11
  };
11
12
  ctx.provide('aiWorkbenchComponents', { getState });
12
13
  ctx.inject(['webServer', 'connection'], host => {
@@ -27,9 +28,9 @@ export function registerComponents(ctx, inventory, temporaryDataParent, componen
27
28
  res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
28
29
  res.statusCode = 200;
29
30
  let closed = false;
30
- let unprompt = () => { }, unconnection = () => { }, unenrollment = () => { }, unnative = () => { }, undownload = () => { }, unsource = () => { };
31
+ let unprompt = () => { }, unconnection = () => { }, unenrollment = () => { }, unnative = () => { }, undownload = () => { }, unsource = () => { }, undetails = () => { }, unloginDetails = () => { }, undetailsettings = () => { };
31
32
  const close = () => { if (closed)
32
- return; closed = true; unprompt(); unconnection(); unenrollment(); unnative(); undownload(); unsource(); streams.delete(close); res.end(); };
33
+ return; closed = true; unprompt(); unconnection(); unenrollment(); unnative(); undownload(); unsource(); undetails(); unloginDetails(); undetailsettings(); streams.delete(close); res.end(); };
33
34
  const send = () => { if (closed)
34
35
  return; try {
35
36
  if (!res.write('data: ' + JSON.stringify(getState()) + '\n\n'))
@@ -44,11 +45,78 @@ export function registerComponents(ctx, inventory, temporaryDataParent, componen
44
45
  unnative = service.onWorkspaceBindingState?.(send) ?? (() => { });
45
46
  undownload = component?.onState(send) ?? (() => { });
46
47
  unsource = sources?.onState(send) ?? (() => { });
48
+ undetails = service.onRemoteConversationDetailState?.(send) ?? (() => { });
49
+ unloginDetails = service.onLoginDiagnosticState?.(send) ?? (() => { });
50
+ undetailsettings = detailSettings()?.onState(send) ?? (() => { });
47
51
  streams.add(close);
48
52
  res.once('close', close);
49
53
  send();
50
54
  return;
51
55
  }
56
+ if (req.method === 'GET' && ['/ai-workbench-connect/login-diagnostics', '/ai-workbench-connect/login-diagnostic-receipt'].includes(path)) {
57
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
58
+ const unavailable = () => { res.statusCode = 503; res.end(JSON.stringify(fail('DEPENDENCY_REQUIRED'))); };
59
+ try {
60
+ const isReceipt = path.endsWith('-receipt');
61
+ if (!service || typeof service.getContractInfo !== 'function' || service.getContractInfo()?.loginDiagnostics !== 1 || typeof (isReceipt ? service.getLoginDiagnosticReceipt : service.listLoginDiagnostics) !== 'function') {
62
+ unavailable();
63
+ return;
64
+ }
65
+ const params = new URL(req.url ?? '/', 'http://dsh').searchParams, allowed = isReceipt ? ['sessionId', 'provider', 'eventId'] : ['sessionId', 'provider', 'cursor'];
66
+ if ([...params.keys()].some(k => !allowed.includes(k) || params.getAll(k).length !== 1)) {
67
+ res.statusCode = 400;
68
+ res.end(JSON.stringify(fail('REMOTE_DETAIL_INVALID')));
69
+ return;
70
+ }
71
+ const query = { sessionId: params.get('sessionId') ?? '', provider: params.get('provider') };
72
+ const result = isReceipt ? await service.getLoginDiagnosticReceipt({ ...query, eventId: params.get('eventId') ?? '' }) : await service.listLoginDiagnostics({ ...query, ...(params.has('cursor') ? { cursor: params.get('cursor') } : {}) });
73
+ res.statusCode = result.ok ? 200 : 409;
74
+ res.end(JSON.stringify(result));
75
+ }
76
+ catch {
77
+ unavailable();
78
+ }
79
+ return;
80
+ }
81
+ if (req.method === 'GET' && ['/ai-workbench-connect/conversation-details', '/ai-workbench-connect/conversation-detail-receipt'].includes(path) && service) {
82
+ const params = new URL(req.url ?? '/', 'http://dsh').searchParams, isReceipt = path.endsWith('/conversation-detail-receipt'), allowed = isReceipt ? ['sessionId', 'provider', 'eventId'] : ['sessionId', 'provider', 'cursor'];
83
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
84
+ if ([...params.keys()].some(k => !allowed.includes(k) || params.getAll(k).length !== 1)) {
85
+ res.statusCode = 400;
86
+ res.end(JSON.stringify(fail('REMOTE_DETAIL_INVALID')));
87
+ return;
88
+ }
89
+ const query = { sessionId: params.get('sessionId') ?? '', provider: params.get('provider') };
90
+ const result = isReceipt ? await service.getRemoteConversationDetailReceipt({ ...query, eventId: params.get('eventId') ?? '' }) : await service.listRemoteConversationDetails({ ...query, ...(params.has('cursor') ? { cursor: params.get('cursor') } : {}) });
91
+ res.statusCode = result.ok ? 200 : 409;
92
+ res.end(JSON.stringify(result));
93
+ return;
94
+ }
95
+ if (req.method === 'POST' && path === '/ai-workbench-connect/conversation-detail-settings' && detailSettings()) {
96
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
97
+ try {
98
+ const chunks = [];
99
+ let bytes = 0;
100
+ for await (const chunk of req) {
101
+ const data = Buffer.from(chunk);
102
+ bytes += data.length;
103
+ if (!Number.isSafeInteger(metadataMaxBytes) || metadataMaxBytes < 1 || bytes > metadataMaxBytes) {
104
+ res.statusCode = 400;
105
+ res.end(JSON.stringify({ ...getState(), actionErrorCode: 'SETTINGS_REQUIRED', actionError: '设置未保存:输入超出 enrollmentMetadataMaxBytes,请仅提交两项正安全整数。' }));
106
+ return;
107
+ }
108
+ chunks.push(data);
109
+ }
110
+ const result = await detailSettings().save(JSON.parse(Buffer.concat(chunks).toString('utf8')));
111
+ res.statusCode = result.ok ? 200 : 409;
112
+ res.end(JSON.stringify({ ...getState(), ...(!result.ok ? { actionErrorCode: result.error.code, actionError: result.error.message } : {}) }));
113
+ }
114
+ catch {
115
+ res.statusCode = 400;
116
+ res.end(JSON.stringify({ ...getState(), actionErrorCode: 'SETTINGS_REQUIRED', actionError: '设置未保存:请输入两项正安全整数。' }));
117
+ }
118
+ return;
119
+ }
52
120
  if (req.method === 'POST' && /^\/ai-workbench-connect\/component-download\/cancel\/[a-zA-Z0-9-]+$/.test(path) && component) {
53
121
  res.setHeader('Content-Type', 'application/json; charset=utf-8');
54
122
  if (Number(req.headers?.['content-length'] ?? 0) > 0 || req.headers?.['transfer-encoding']) {
package/lib/connect.js CHANGED
@@ -3,6 +3,7 @@ import { dependency, fail, ok, need } from "./needs.js";
3
3
  import { isProjectName } from "./projects.js";
4
4
  import { createStartup } from "./startup.js";
5
5
  import { parseLoginStatus } from "./auth-status.js";
6
+ import { authFailure } from "./auth-diagnostics.js";
6
7
  export function validEnrollment(e) {
7
8
  return typeof e === 'object' && e !== null && typeof e.host === 'string' && /^[a-zA-Z0-9][a-zA-Z0-9.-]*$/.test(e.host)
8
9
  && typeof e.username === 'string' && /^emp-[a-z0-9][a-z0-9-]*$/.test(e.username)
@@ -14,6 +15,7 @@ export function validEnrollment(e) {
14
15
  export function createConnect(ports, emit, options = {}) {
15
16
  let state = { phase: 'idle' }, generation = 0, disposed = false;
16
17
  let flight;
18
+ let trustedTarget;
17
19
  const instanceId = randomUUID();
18
20
  let connection = { instanceId, phase: 'unavailable', epoch: 0 };
19
21
  const connectionListeners = new Set();
@@ -22,6 +24,8 @@ export function createConnect(ports, emit, options = {}) {
22
24
  const next = { instanceId, phase, epoch: generation, ...(need ? { need } : {}) };
23
25
  if (JSON.stringify(next) === JSON.stringify(connection))
24
26
  return;
27
+ if (phase !== 'connected')
28
+ trustedTarget = undefined;
25
29
  connection = structuredClone(next);
26
30
  startup?.connectionChanged(next);
27
31
  options.onConnectionState?.(structuredClone(next));
@@ -48,21 +52,27 @@ export function createConnect(ports, emit, options = {}) {
48
52
  loginFlights.clear();
49
53
  } const value = block(r); if (!r.ok && !disposed)
50
54
  setConnection('unavailable', r.error); return value; };
51
- const connect = () => {
55
+ const connect = (reuseTrusted = false) => {
52
56
  if (disposed)
53
57
  return Promise.resolve(fail('DISPOSED'));
54
- if (state.target)
55
- return Promise.resolve(ok(structuredClone(state.target)));
58
+ if ((state.target || reuseTrusted) && connection.phase === 'connected' && trustedTarget)
59
+ return Promise.resolve(ok(structuredClone(trustedTarget)));
56
60
  if (flight)
57
61
  return flight;
62
+ // Replacing an established transport is a new trusted generation, even without a network-lost event.
63
+ if (connection.phase === 'connected') {
64
+ generation++;
65
+ controller.abort();
66
+ controller = new AbortController();
67
+ loginFlights.clear();
68
+ }
58
69
  const g = generation, signal = controller.signal;
59
- if (connection.phase !== 'connected')
60
- setConnection('connecting');
70
+ setConnection('connecting');
61
71
  const run = async () => {
62
72
  if (!ports.enrollment)
63
- return transportBlock(dependency('S host-layout/enroll.json 适配器'));
73
+ return transportBlock(dependency('管理员发放的主机连接资料'));
64
74
  if (!ports.network)
65
- return transportBlock(dependency('N net-helper 生命周期/状态适配器'));
75
+ return transportBlock(dependency('远程网络连接服务'));
66
76
  if (!ports.ssh)
67
77
  return transportBlock(dependency('系统 SSH 适配器'));
68
78
  try {
@@ -83,7 +93,7 @@ export function createConnect(ports, emit, options = {}) {
83
93
  if (!n.ok)
84
94
  return transportBlock(n);
85
95
  if (typeof n.value.proxyCommand !== 'string' || n.value.proxyCommand.length === 0 || /[\r\n\x00]/.test(n.value.proxyCommand))
86
- return transportBlock(dependency('有效的 N ProxyCommand'));
96
+ return transportBlock(dependency('有效的远程网络连接通道'));
87
97
  const { host, username, port, projectRoot, knownHostsFile, session, hostContext } = e.value;
88
98
  const target = { host, username, port, projectRoot, knownHostsFile, session, ...(hostContext ? { hostContext } : {}), proxyCommand: n.value.proxyCommand };
89
99
  publish({ phase: 'ssh' });
@@ -94,11 +104,12 @@ export function createConnect(ports, emit, options = {}) {
94
104
  if (!verified.ok)
95
105
  return transportBlock(verified);
96
106
  publish({ phase: 'connected', target });
107
+ trustedTarget = structuredClone(target);
97
108
  setConnection('connected');
98
109
  return ok(structuredClone(target));
99
110
  }
100
111
  catch {
101
- return invalid(g) ?? transportBlock(dependency('提供方调用失败,根因未查明;请检查 S/N/SSH 适配器'));
112
+ return invalid(g) ?? transportBlock(dependency('连接服务调用失败,根因未查明;请管理员检查连接资料、网络连接服务和 SSH 通道'));
102
113
  }
103
114
  };
104
115
  const promise = track(run());
@@ -108,30 +119,46 @@ export function createConnect(ports, emit, options = {}) {
108
119
  void promise.then(clear, clear);
109
120
  return promise;
110
121
  };
111
- const checkLogin = async (provider) => {
122
+ const checkLogin = async (provider, reuseTrusted = false) => {
112
123
  if (provider !== 'claude' && provider !== 'codex')
113
124
  return fail('AUTH_STATUS_INVALID');
114
- const g = generation;
115
- const connected = await connect();
125
+ const connecting = connect(reuseTrusted), g = generation;
126
+ const connected = await connecting;
116
127
  const staleConnection = invalid(g);
117
128
  if (staleConnection)
118
129
  return staleConnection;
119
130
  if (!connected.ok)
120
131
  return connected;
132
+ const signal = controller.signal;
121
133
  publish({ phase: 'checking-login', target: connected.value, provider });
134
+ const stalePublish = invalid(g);
135
+ if (stalePublish)
136
+ return stalePublish;
137
+ const ticket = startup.beginCheck(provider);
138
+ const staleTicket = invalid(g);
139
+ if (staleTicket)
140
+ return staleTicket;
122
141
  try {
123
- const raw = await ports.ssh.status(connected.value, provider, controller.signal);
142
+ const raw = await ports.ssh.status(connected.value, provider, signal);
124
143
  const stale = invalid(g);
125
144
  if (stale)
126
145
  return stale;
127
146
  const status = raw.ok ? parseLoginStatus(provider, raw.value) : raw;
147
+ if (!startup.finishCheck(ticket, status))
148
+ return invalid(g) ?? authFailure('connect-check-login', 'STATUS_SUPERSEDED');
128
149
  if (!status.ok)
129
150
  return ['network', 'ssh', 'enrollment'].includes(status.error.layer) ? transportBlock(status) : block(status);
130
151
  publish(status.value.loggedIn ? { phase: 'ready', target: connected.value, provider } : { phase: 'connected', target: connected.value, provider });
131
- return status;
152
+ return invalid(g) ?? status;
132
153
  }
133
- catch {
134
- return invalid(g) ?? block(fail('AUTH_STATUS_INVALID'));
154
+ catch (error) {
155
+ const stale = invalid(g);
156
+ if (stale)
157
+ return stale;
158
+ const failure = authFailure('connect-check-login', 'STATUS_EXCEPTION', {}, error);
159
+ if (!startup.finishCheck(ticket, failure))
160
+ return authFailure('connect-check-login', 'STATUS_SUPERSEDED');
161
+ return block(failure);
135
162
  }
136
163
  };
137
164
  const login = (provider) => {
@@ -143,8 +170,8 @@ export function createConnect(ports, emit, options = {}) {
143
170
  if (prior)
144
171
  return prior;
145
172
  const run = async () => {
146
- const g = generation;
147
- const connected = await connect();
173
+ const connecting = connect(), g = generation;
174
+ const connected = await connecting;
148
175
  const staleConnection = invalid(g);
149
176
  if (staleConnection)
150
177
  return staleConnection;
@@ -152,19 +179,39 @@ export function createConnect(ports, emit, options = {}) {
152
179
  return connected;
153
180
  if (!ports.login)
154
181
  return block(dependency('Electron 登录助手及已确认设置'));
182
+ const owner = startup.captureCheck(provider), signal = controller.signal;
155
183
  publish({ phase: 'login', target: connected.value, provider });
184
+ const stalePublish = invalid(g);
185
+ if (stalePublish)
186
+ return stalePublish;
187
+ if (!startup.currentTicket(owner))
188
+ return authFailure('connect-check-login', 'STATUS_SUPERSEDED');
156
189
  try {
157
- const r = await ports.login.run(connected.value, provider, controller.signal);
190
+ const r = await ports.login.run(connected.value, provider, signal);
158
191
  const stale = invalid(g);
159
192
  if (stale)
160
193
  return stale;
194
+ if (!startup.currentTicket(owner))
195
+ return authFailure('connect-check-login', 'STATUS_SUPERSEDED');
161
196
  if (!r.ok)
162
197
  return ['network', 'ssh', 'enrollment'].includes(r.error.layer) ? transportBlock(r) : block(r);
163
- const checked = await checkLogin(provider);
164
- return checked.ok && !checked.value.loggedIn ? block(fail('LOGIN_FAILED')) : checked;
198
+ const checked = await checkLogin(provider), staleChecked = invalid(g);
199
+ if (staleChecked)
200
+ return staleChecked;
201
+ if (checked.ok && !checked.value.loggedIn) {
202
+ const failed = block(fail('LOGIN_FAILED'));
203
+ startup.inheritCheck(checked, failed);
204
+ return failed;
205
+ }
206
+ return checked;
165
207
  }
166
208
  catch {
167
- return invalid(g) ?? block(fail('LOGIN_FAILED'));
209
+ const stale = invalid(g);
210
+ if (stale)
211
+ return stale;
212
+ if (!startup.currentTicket(owner))
213
+ return authFailure('connect-check-login', 'STATUS_SUPERSEDED');
214
+ return block(fail('LOGIN_FAILED'));
168
215
  }
169
216
  };
170
217
  const promise = track(run());
@@ -179,7 +226,7 @@ export function createConnect(ports, emit, options = {}) {
179
226
  return fail('DISPOSED');
180
227
  if (operation === 'create' && !isProjectName(name))
181
228
  return fail('PROJECT_NAME_INVALID');
182
- const g = generation, connected = await connect();
229
+ const connecting = connect(), g = generation, connected = await connecting;
183
230
  const stale = invalid(g);
184
231
  if (stale)
185
232
  return stale;
@@ -212,9 +259,27 @@ export function createConnect(ports, emit, options = {}) {
212
259
  }
213
260
  });
214
261
  const operations = { getState: () => structuredClone(state), connect, checkLogin: provider => track(checkLogin(provider)), login };
215
- startup = createStartup(operations, options.providers ?? ['codex', 'claude'], options.onPromptState, options.onPromptResult, instanceId);
262
+ startup = createStartup({ ...operations, connect: () => connect(true), checkLogin: provider => track(checkLogin(provider, true)) }, options.providers ?? ['codex', 'claude'], options.onPromptState, options.onPromptResult, instanceId);
216
263
  return {
217
264
  ...operations,
265
+ getModelCatalogTarget: provider => {
266
+ if (disposed)
267
+ return fail('DISPOSED');
268
+ if (provider !== 'claude' && provider !== 'codex')
269
+ return fail('MODEL_CATALOG_CONTEXT_INVALID');
270
+ if (connection.phase !== 'connected' || !trustedTarget)
271
+ return fail('CONNECTION_LOST');
272
+ return ok({ version: 1, purpose: 'model-catalog', scope: 'host-user', instanceId, epoch: connection.epoch, provider, target: structuredClone(trustedTarget) });
273
+ },
274
+ appendRemoteConversationDetail: async () => dependency('官方会话存储与持久诊断记录'),
275
+ appendLoginDiagnostic: async () => dependency('官方会话存储与持久登录诊断记录'),
276
+ getLoginDiagnosticReceipt: async () => dependency('官方会话存储与持久登录诊断记录'),
277
+ listLoginDiagnostics: async () => dependency('官方会话存储与持久登录诊断记录'),
278
+ getLoginDiagnosticState: () => ({ instanceId, epoch: connection.epoch, revision: 0, phase: disposed ? 'disposed' : 'unavailable', sessions: [], need: need(disposed ? 'DISPOSED' : 'DEPENDENCY_REQUIRED') }),
279
+ onLoginDiagnosticState: () => () => { },
280
+ getRemoteConversationDetailReceipt: async () => dependency('官方会话存储与持久诊断记录'),
281
+ getRemoteConversationDetailState: () => ({ instanceId, epoch: connection.epoch, revision: 0, phase: disposed ? 'disposed' : 'unavailable', sessions: [], need: need(disposed ? 'DISPOSED' : 'DEPENDENCY_REQUIRED') }),
282
+ onRemoteConversationDetailState: () => () => { }, listRemoteConversationDetails: async () => dependency('官方会话存储与持久诊断记录'),
218
283
  getContractInfo: () => ({ apiVersion: '0.7.0', status: 'implemented', workspaceBinding: 'native-local-v1' }),
219
284
  getEnrollmentState: () => ({ instanceId, revision: 0, phase: 'blocked', need: need('DEPENDENCY_REQUIRED') }), onEnrollmentState: () => () => { },
220
285
  importEnrollment: async () => dependency('官方profileContext与原生目录选择服务'),
@@ -227,8 +292,8 @@ export function createConnect(ports, emit, options = {}) {
227
292
  login: async (provider) => { const request = await startup.request(provider, 'automatic'); if (!request.ok)
228
293
  return request; return request.value.logins[provider] === true ? ok({ provider, loggedIn: true }) : fail('LOGIN_REQUIRED'); },
229
294
  ensureReady: provider => track((async () => {
230
- const g = generation;
231
- const r = await checkLogin(provider);
295
+ const checking = checkLogin(provider), g = generation;
296
+ const r = await checking;
232
297
  const stale = invalid(g);
233
298
  if (stale)
234
299
  return stale;
@@ -238,7 +303,8 @@ export function createConnect(ports, emit, options = {}) {
238
303
  const request = await startup.request(provider, 'automatic');
239
304
  return invalid(g) ?? (!request.ok ? request : fail('LOGIN_REQUIRED'));
240
305
  }
241
- return connect();
306
+ const connected = await connect();
307
+ return invalid(g) ?? connected;
242
308
  })()),
243
309
  dispose: () => { if (disposal)
244
310
  return disposal; startup.stop(); disposed = true; generation++; controller.abort(); unsubscribe?.(); publish({ phase: 'disposed' }); setConnection('disposed'); connectionListeners.clear(); disposal = Promise.allSettled([...pending]).then(() => startup.dispose()); return disposal; },
@@ -0,0 +1,32 @@
1
+ export interface ConversationDetailSettingsValues {
2
+ maxEventBytes: number;
3
+ pageSize: number;
4
+ }
5
+ export interface ConversationDetailSettingsState {
6
+ instanceId: string;
7
+ revision: number;
8
+ phase: 'loading' | 'ready' | 'saving' | 'failed' | 'disposed';
9
+ values?: ConversationDetailSettingsValues;
10
+ source: 'config' | 'saved';
11
+ errorCode?: 'SETTINGS_REQUIRED' | 'REMOTE_DETAIL_STORAGE_FAILED';
12
+ }
13
+ export declare function validDetailSettings(v: any): v is ConversationDetailSettingsValues;
14
+ export declare function createConversationDetailSettings(options: {
15
+ profileDir?: string;
16
+ initial?: Partial<ConversationDetailSettingsValues>;
17
+ metadataMaxBytes?: number;
18
+ }): {
19
+ ready: Promise<void>;
20
+ getState: () => ConversationDetailSettingsState;
21
+ values: () => ConversationDetailSettingsValues | undefined;
22
+ onState(fn: () => void): () => void;
23
+ save: (input: ConversationDetailSettingsValues) => Promise<{
24
+ ok: false;
25
+ error: import("./api.ts").ConnectNeed;
26
+ } | {
27
+ ok: true;
28
+ value: undefined;
29
+ }>;
30
+ dispose(): Promise<void>;
31
+ };
32
+ export type ConversationDetailSettings = ReturnType<typeof createConversationDetailSettings>;
@@ -0,0 +1,119 @@
1
+ import { join, isAbsolute } from 'node:path';
2
+ import { lstat, open, realpath, rename, rm } from 'node:fs/promises';
3
+ import { constants } from 'node:fs';
4
+ import { randomUUID } from 'node:crypto';
5
+ import { CONVERSATION_DETAIL_DEFAULTS, L_DEFAULTS } from "./defaults.js";
6
+ import { privateDirectory, readPrivate } from "./private-files.js";
7
+ import { fail, ok } from "./needs.js";
8
+ const queues = new Map();
9
+ export function validDetailSettings(v) { return !!v && typeof v === 'object' && Object.keys(v).sort().join(',') === 'maxEventBytes,pageSize' && [v.maxEventBytes, v.pageSize].every(n => Number.isSafeInteger(n) && n > 0); }
10
+ export function createConversationDetailSettings(options) {
11
+ const root = options.profileDir ? join(options.profileDir, 'ai-workbench-connect') : undefined, file = root ? join(root, 'conversation-detail-settings.json') : undefined;
12
+ let state = { instanceId: randomUUID(), revision: 0, phase: 'loading', source: 'config' }, disposed = false, tail = Promise.resolve();
13
+ const listeners = new Set();
14
+ function publish(next) { state = { ...state, ...next, revision: state.revision + 1 }; for (const fn of listeners)
15
+ fn(); }
16
+ async function directory() { if (!options.profileDir || !root || !isAbsolute(options.profileDir) || await realpath(options.profileDir) !== options.profileDir)
17
+ throw Error('SETTINGS_PROFILE_INVALID'); await privateDirectory(root); if (await realpath(root) !== root)
18
+ throw Error('SETTINGS_PATH_INVALID'); const st = await lstat(root); if ((st.mode & 0o777) !== 0o700)
19
+ throw Error('SETTINGS_PATH_INVALID'); const parent = await open(options.profileDir, constants.O_RDONLY | constants.O_NOFOLLOW); try {
20
+ await parent.sync();
21
+ }
22
+ finally {
23
+ await parent.close();
24
+ } }
25
+ async function existing() { const st = await lstat(file); if (!st.isFile() || st.isSymbolicLink() || st.nlink !== 1 || (st.mode & 0o777) !== 0o600)
26
+ throw Error('SETTINGS_PATH_INVALID'); }
27
+ const ready = (async () => {
28
+ try {
29
+ await directory();
30
+ let values = { ...CONVERSATION_DETAIL_DEFAULTS, ...options.initial }, source = 'config';
31
+ try {
32
+ await existing();
33
+ const saved = JSON.parse(await readPrivate(file, options.metadataMaxBytes ?? L_DEFAULTS.enrollmentMetadataMaxBytes));
34
+ if (saved.version !== 1 || !validDetailSettings(saved.values))
35
+ throw Error('SETTINGS_INVALID');
36
+ values = saved.values;
37
+ source = 'saved';
38
+ }
39
+ catch (e) {
40
+ if (e.code !== 'ENOENT')
41
+ throw e;
42
+ }
43
+ if (!validDetailSettings(values))
44
+ throw Error('SETTINGS_INVALID');
45
+ if (!disposed)
46
+ publish({ phase: 'ready', values, source });
47
+ }
48
+ catch {
49
+ if (!disposed)
50
+ publish({ phase: 'failed', values: undefined, errorCode: 'REMOTE_DETAIL_STORAGE_FAILED' });
51
+ }
52
+ })();
53
+ function save(input) {
54
+ if (!validDetailSettings(input))
55
+ return Promise.resolve(fail('SETTINGS_REQUIRED'));
56
+ const values = structuredClone(input);
57
+ const task = (queues.get(file ?? '') ?? Promise.resolve()).then(async () => {
58
+ await ready;
59
+ if (disposed)
60
+ return fail('DISPOSED');
61
+ if (state.phase === 'failed')
62
+ return fail('REMOTE_DETAIL_STORAGE_FAILED');
63
+ publish({ phase: 'saving', errorCode: undefined });
64
+ let temp;
65
+ try {
66
+ await directory();
67
+ try {
68
+ await existing();
69
+ }
70
+ catch (e) {
71
+ if (e.code !== 'ENOENT')
72
+ throw e;
73
+ }
74
+ const bytes = JSON.stringify({ version: 1, values }) + '\n';
75
+ if (Buffer.byteLength(bytes) > (options.metadataMaxBytes ?? L_DEFAULTS.enrollmentMetadataMaxBytes))
76
+ throw Error('SETTINGS_LIMIT');
77
+ temp = join(root, '.conversation-detail-settings-' + randomUUID());
78
+ const h = await open(temp, 'wx', 0o600);
79
+ try {
80
+ await h.writeFile(bytes);
81
+ await h.sync();
82
+ }
83
+ finally {
84
+ await h.close();
85
+ }
86
+ if (disposed)
87
+ return fail('DISPOSED');
88
+ await rename(temp, file);
89
+ temp = undefined;
90
+ const dir = await open(root, constants.O_RDONLY | constants.O_NOFOLLOW);
91
+ try {
92
+ await dir.sync();
93
+ }
94
+ finally {
95
+ await dir.close();
96
+ }
97
+ if (disposed)
98
+ return fail('DISPOSED');
99
+ publish({ phase: 'ready', values, source: 'saved', errorCode: undefined });
100
+ return ok(undefined);
101
+ }
102
+ catch {
103
+ publish({ phase: 'failed', values: undefined, errorCode: 'REMOTE_DETAIL_STORAGE_FAILED' });
104
+ return fail('REMOTE_DETAIL_STORAGE_FAILED');
105
+ }
106
+ finally {
107
+ if (temp)
108
+ await rm(temp, { force: true });
109
+ }
110
+ });
111
+ const done = task.then(() => { }, () => { });
112
+ queues.set(file ?? '', done);
113
+ tail = done;
114
+ void done.then(() => { if (queues.get(file ?? '') === done)
115
+ queues.delete(file ?? ''); });
116
+ return task;
117
+ }
118
+ return { ready, getState: () => structuredClone(state), values: () => state.phase === 'ready' ? structuredClone(state.values) : undefined, onState(fn) { listeners.add(fn); return () => { listeners.delete(fn); }; }, save, async dispose() { disposed = true; await ready; await tail; publish({ phase: 'disposed', values: undefined }); listeners.clear(); } };
119
+ }
@@ -0,0 +1,24 @@
1
+ import type { ConnectApi, ConnectionState, Provider, RemoteConversationDetail, RemoteConversationDetailState, Result } from './api.ts';
2
+ export interface ConversationDetailOptions {
3
+ profileDir: string;
4
+ maxEventBytes: number;
5
+ pageSize: number;
6
+ storageName?: 'conversation-details' | 'login-diagnostics';
7
+ validate?: (detail: RemoteConversationDetail) => boolean;
8
+ knownKinds?: readonly string[];
9
+ getConnectionState: () => ConnectionState;
10
+ context: () => Promise<Result<{
11
+ identity: string;
12
+ }>>;
13
+ scope: (sessionId: string, provider: Provider) => Promise<Result<{
14
+ identity: string;
15
+ }>>;
16
+ onState?: (state: RemoteConversationDetailState) => void;
17
+ }
18
+ export declare function validRemoteConversationDetail(v: any): v is RemoteConversationDetail;
19
+ type DetailsApi = Pick<ConnectApi, 'appendRemoteConversationDetail' | 'getRemoteConversationDetailReceipt' | 'getRemoteConversationDetailState' | 'onRemoteConversationDetailState' | 'listRemoteConversationDetails'>;
20
+ export declare function createConversationDetails(options: ConversationDetailOptions): DetailsApi & {
21
+ observeConnection: (state: ConnectionState) => void;
22
+ dispose: () => Promise<void>;
23
+ };
24
+ export {};