@myagentroam/node 0.1.0 → 0.1.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/dist/connector.js CHANGED
@@ -8,8 +8,8 @@ import { createEnvelope, nodeCapabilitiesSchema, nodeEnvelopeSchema, runnerDefau
8
8
  import { loadNodeConfig, nodeDatabasePath, saveNodeConfig } from './config.js';
9
9
  import { NodeMetrics, nodeLog } from './operational.js';
10
10
  import { assertLocalSqlitePath } from './storage.js';
11
- import { detectCapabilities } from './capabilities.js';
12
- import { inspectWorkspace, listWorkspaceDirectories, listWorkspaceFiles, preflightWorkspaceUpload, readCurrentChangeDiff, readCurrentChangesSummary, readGitSummary, readRestrictedDiff, readAllowedTextFile, readWorkspaceTextFile, restoreCurrentChange, searchWorkspaceFiles, workspaceUploadTemporaryName } from './workspace.js';
11
+ import { detectCapabilitiesAsync, unavailableCapabilities } from './capabilities.js';
12
+ import { inspectWorkspace, isWorkspaceChangeVisible, listWorkspaceDirectories, listWorkspaceFiles, preflightWorkspaceUpload, readCurrentChangeDiff, readCurrentChangesSummary, readGitSummary, readRestrictedDiff, readAllowedTextFile, readWorkspaceTextFile, restoreCurrentChange, WorkspaceFileIndex, workspaceUploadTemporaryName } from './workspace.js';
13
13
  import { ReliableWorkbenchEventBuffer } from './event-buffer.js';
14
14
  import { CodexAppServerClient, codexSessionControl } from './codex-app-server.js';
15
15
  import { ClaudeAgentSdkAdapter, denyPermission } from './claude-agent-sdk.js';
@@ -22,9 +22,16 @@ import { NodeRuntimeState } from './runtime-state.js';
22
22
  import { CommandStateStore, parseCommandInvocation } from './runner-command-engine.js';
23
23
  import { RunnerUsageReader } from './runner-usage.js';
24
24
  const HEARTBEAT_MS = 15_000;
25
+ const WORKSPACE_FILE_INDEX_CACHE_LIMIT = 8;
26
+ const WORKSPACE_WATCH_TTL_MS = 45_000;
27
+ const WORKSPACE_SESSION_WATCH_INTERVAL_MS = 30_000;
28
+ const WORKSPACE_SESSION_CACHE_TTL_MS = 15_000;
29
+ const WORKSPACE_CHANGES_CACHE_TTL_MS = 5_000;
30
+ const CAPABILITY_REFRESH_MS = 60_000;
25
31
  const SESSION_WATCH_INTERVAL_MS = 2_000;
26
32
  const SESSION_WATCH_TTL_MS = 45_000;
27
33
  const SESSION_WATCH_MAX_FAILURES = 3;
34
+ const SESSION_WATCH_INITIAL_TURN_LIMIT = 30;
28
35
  const RETRY_MIN_MS = 500;
29
36
  const RETRY_MAX_MS = 30_000;
30
37
  const CLAUDE_APPROVAL_TIMEOUT_MS = 10 * 60_000;
@@ -87,12 +94,17 @@ export class NodeConnector {
87
94
  socket;
88
95
  terminalSocket;
89
96
  heartbeat;
97
+ capabilityRefreshTimer;
98
+ capabilityRefreshInFlight = false;
90
99
  reconnectTimer;
91
100
  terminalReconnectTimer;
92
101
  attempts = 0;
102
+ terminalAttempts = 0;
93
103
  stopped = false;
94
104
  config;
105
+ capabilitiesProvided;
95
106
  capabilities;
107
+ capabilityDetector;
96
108
  configPath;
97
109
  reconnect;
98
110
  claudeApprovalTimeoutMs;
@@ -158,6 +170,18 @@ export class NodeConnector {
158
170
  composerAttachments = new Map();
159
171
  /** Identical refreshes share one Git scan; separate Workspaces use bounded parallelism. */
160
172
  workspaceChangesReads = new Map();
173
+ workspaceChangesCache = new Map();
174
+ workspaceChangesVersions = new Map();
175
+ workspaceFileIndexes = new Map();
176
+ /** Tab-scoped Workspace observation leases; no state survives a Node restart. */
177
+ workspaceWatches = new Map();
178
+ workspaceWatchRevisions = new Map();
179
+ workspaceSessionWatchTimers = new Map();
180
+ workspaceSessionWatchSignatures = new Map();
181
+ workspaceSessionWatchReads = new Map();
182
+ /** Runner-native session discovery is expensive; cache only its short-lived baseline. */
183
+ workspaceSessionCache = new Map();
184
+ workspaceSessionCacheReads = new Map();
161
185
  workspaceChangesLimiter = new AsyncLimiter(3);
162
186
  database;
163
187
  /** Runtime/session state is intentionally not persisted to Node SQLite. */
@@ -166,7 +190,9 @@ export class NodeConnector {
166
190
  constructor(options = {}) {
167
191
  this.config = options.config;
168
192
  this.configPath = options.configPath;
169
- this.capabilities = options.capabilities ?? detectCapabilities();
193
+ this.capabilitiesProvided = options.capabilities !== undefined;
194
+ this.capabilities = options.capabilities ?? unavailableCapabilities();
195
+ this.capabilityDetector = options.capabilityDetector ?? detectCapabilitiesAsync;
170
196
  this.reconnect = options.reconnect ?? true;
171
197
  this.fakeRunner = options.fakeRunner;
172
198
  this.fakeScript = options.fakeScript;
@@ -179,7 +205,7 @@ export class NodeConnector {
179
205
  this.terminalManager =
180
206
  options.terminalManager ??
181
207
  new TerminalManager({
182
- platform: this.capabilities.platform
208
+ platform: this.capabilities.platform === 'windows' ? 'win32' : 'linux'
183
209
  });
184
210
  this.terminalManager.onSummary((summary) => this.persistTerminalSession(summary));
185
211
  this.terminalManager.onFrame((frame) => this.publishTerminalFrame(frame));
@@ -191,12 +217,15 @@ export class NodeConnector {
191
217
  this.database ??= new NodeDatabase(nodeDatabasePath(this.config, this.configPath), () => this.config?.nodeId ?? 'unregistered');
192
218
  this.database.closeRunningTerminalSessions();
193
219
  this.reconcilePersistedRuns();
220
+ this.startCapabilityRefresh();
194
221
  this.connect();
195
222
  }
196
223
  stop() {
197
224
  this.stopped = true;
198
225
  if (this.heartbeat !== undefined)
199
226
  clearInterval(this.heartbeat);
227
+ if (this.capabilityRefreshTimer !== undefined)
228
+ clearInterval(this.capabilityRefreshTimer);
200
229
  if (this.reconnectTimer !== undefined)
201
230
  clearTimeout(this.reconnectTimer);
202
231
  if (this.terminalReconnectTimer !== undefined)
@@ -233,6 +262,22 @@ export class NodeConnector {
233
262
  for (const upload of this.workspaceUploads.values())
234
263
  void this.removeWorkspaceUpload(upload);
235
264
  this.workspaceUploads.clear();
265
+ for (const index of this.workspaceFileIndexes.values())
266
+ index.dispose();
267
+ this.workspaceFileIndexes.clear();
268
+ for (const watches of this.workspaceWatches.values())
269
+ for (const watch of watches.values())
270
+ clearTimeout(watch.timer);
271
+ this.workspaceWatches.clear();
272
+ for (const timer of this.workspaceSessionWatchTimers.values())
273
+ clearInterval(timer);
274
+ this.workspaceSessionWatchTimers.clear();
275
+ this.workspaceSessionWatchSignatures.clear();
276
+ this.workspaceSessionWatchReads.clear();
277
+ this.workspaceSessionCache.clear();
278
+ this.workspaceSessionCacheReads.clear();
279
+ this.workspaceChangesCache.clear();
280
+ this.workspaceChangesVersions.clear();
236
281
  for (const attachment of this.composerAttachments.values())
237
282
  void rm(dirname(attachment.targetPath), { recursive: true, force: true });
238
283
  this.composerAttachments.clear();
@@ -245,6 +290,39 @@ export class NodeConnector {
245
290
  this.database?.close();
246
291
  this.database = undefined;
247
292
  }
293
+ startCapabilityRefresh() {
294
+ if (this.capabilityRefreshTimer !== undefined)
295
+ clearInterval(this.capabilityRefreshTimer);
296
+ if (!this.capabilitiesProvided)
297
+ void this.refreshCapabilities();
298
+ this.capabilityRefreshTimer = setInterval(() => void this.refreshCapabilities(), CAPABILITY_REFRESH_MS);
299
+ }
300
+ async refreshCapabilities() {
301
+ if (this.stopped || this.capabilityRefreshInFlight)
302
+ return;
303
+ this.capabilityRefreshInFlight = true;
304
+ let next;
305
+ try {
306
+ next = await this.capabilityDetector();
307
+ }
308
+ catch {
309
+ // A failed local probe keeps Node metadata but safely disables both Runner capabilities.
310
+ next = {
311
+ ...this.capabilities,
312
+ codex: { available: false },
313
+ claudeCode: { available: false }
314
+ };
315
+ }
316
+ try {
317
+ if (this.stopped || JSON.stringify(next) === JSON.stringify(this.capabilities))
318
+ return;
319
+ this.capabilities = next;
320
+ this.send('capabilities', next);
321
+ }
322
+ finally {
323
+ this.capabilityRefreshInFlight = false;
324
+ }
325
+ }
248
326
  connect() {
249
327
  const config = this.config;
250
328
  if (config === undefined)
@@ -333,7 +411,7 @@ export class NodeConnector {
333
411
  this.terminalReconnectTimer = setTimeout(() => {
334
412
  this.terminalReconnectTimer = undefined;
335
413
  this.connectTerminal();
336
- }, RETRY_MIN_MS);
414
+ }, nextReconnectDelay(this.terminalAttempts++));
337
415
  }
338
416
  handleTerminalMessage(raw) {
339
417
  let message;
@@ -353,6 +431,9 @@ export class NodeConnector {
353
431
  Object.keys(message).some((key) => key !== 'type' && key !== 'nodeId')) {
354
432
  this.terminalSocket?.close(4002, 'TERMINAL_MESSAGE_INVALID');
355
433
  }
434
+ else {
435
+ this.terminalAttempts = 0;
436
+ }
356
437
  return;
357
438
  }
358
439
  if (message.type === 'attach') {
@@ -570,7 +651,7 @@ export class NodeConnector {
570
651
  // available. Do not leave the local lease in CANCELLING indefinitely:
571
652
  // an already-created, but not yet controllable, Codex run is checked
572
653
  // again immediately after turn/start resolves.
573
- if (envelope.type === 'run.cancel' && codexRun === undefined && claudeRun === undefined) {
654
+ if (codexRun?.turnId === undefined && claudeRun === undefined) {
574
655
  const run = this.runtime.getRun(runId);
575
656
  if (run?.status === 'CANCELLING')
576
657
  this.completeCancelledRun(runId, undefined);
@@ -612,7 +693,14 @@ export class NodeConnector {
612
693
  return;
613
694
  }
614
695
  try {
615
- const result = await this.executeNodeOperation(payload.operation, payload.data);
696
+ const data = payload.data !== null && typeof payload.data === 'object'
697
+ ? {
698
+ ...payload.data,
699
+ __workspaceWatchSessionHash: payload.userAuthorization
700
+ .sessionHash
701
+ }
702
+ : payload.data;
703
+ const result = await this.executeNodeOperation(payload.operation, data);
616
704
  this.send('node.response', { result }, envelope.id);
617
705
  }
618
706
  catch (error) {
@@ -661,6 +749,12 @@ export class NodeConnector {
661
749
  throw new Error('WORKSPACE_BUSY');
662
750
  const workspace = database.removeWorkspace(input.workspaceId);
663
751
  this.runtime.removeWorkspace(input.workspaceId);
752
+ this.workspaceFileIndexes.get(workspace.id)?.dispose();
753
+ this.workspaceFileIndexes.delete(workspace.id);
754
+ this.invalidateWorkspaceChanges(workspace.id);
755
+ this.workspaceSessionCache.delete(workspace.id);
756
+ this.workspaceSessionCacheReads.delete(workspace.id);
757
+ this.clearWorkspaceWatches(workspace.id);
664
758
  return { workspace };
665
759
  }
666
760
  if (operation === 'workspace.list')
@@ -737,6 +831,10 @@ export class NodeConnector {
737
831
  typeof input.customTitle !== 'string') ||
738
832
  (input.pinned !== undefined && typeof input.pinned !== 'boolean'))
739
833
  throw new Error('SESSION_INVALID');
834
+ if (input.customTitle === null || input.customTitle?.trim().length === 0)
835
+ throw new Error('SESSION_TITLE_REQUIRED');
836
+ if (input.customTitle !== undefined && input.customTitle.trim().length > 160)
837
+ throw new Error('SESSION_TITLE_INVALID');
740
838
  const current = this.runtime.getAgentSession(input.sessionId) ??
741
839
  (await this.createMetadataProjection(input.sessionId));
742
840
  if (current === undefined)
@@ -760,6 +858,13 @@ export class NodeConnector {
760
858
  : updated)
761
859
  };
762
860
  }
861
+ if (operation === 'session.pin.move') {
862
+ if (typeof input.sessionId !== 'string' ||
863
+ (input.direction !== 'up' && input.direction !== 'down'))
864
+ throw new Error('SESSION_PIN_MOVE_INVALID');
865
+ database.movePinnedSession(input.sessionId, input.direction);
866
+ return { moved: true };
867
+ }
763
868
  if (operation === 'session.remove') {
764
869
  if (typeof input.sessionId !== 'string')
765
870
  throw new Error('SESSION_INVALID');
@@ -777,8 +882,16 @@ export class NodeConnector {
777
882
  throw new Error('SESSION_ACTIVE');
778
883
  }
779
884
  const nativeSessionIds = this.nativeSessionIdsForProjection(session);
780
- for (const externalSessionId of nativeSessionIds)
781
- await removeNativeSession(session.runner, session.cwd, externalSessionId);
885
+ for (const externalSessionId of nativeSessionIds) {
886
+ try {
887
+ await removeNativeSession(session.runner, session.cwd, externalSessionId);
888
+ }
889
+ catch (error) {
890
+ if (!(error instanceof Error) || error.message !== 'NATIVE_TRANSCRIPT_UNAVAILABLE')
891
+ throw error;
892
+ }
893
+ }
894
+ database.deleteSessionRecord(session.id);
782
895
  this.forgetNativeSessionProjection(session, nativeSessionIds);
783
896
  return { deleted: true };
784
897
  }
@@ -882,6 +995,50 @@ export class NodeConnector {
882
995
  ...(typeof input.limit === 'number' ? { limit: input.limit } : {})
883
996
  });
884
997
  }
998
+ if (operation === 'workspace.watch') {
999
+ if (typeof input.workspaceId !== 'string' ||
1000
+ typeof input.watchId !== 'string' ||
1001
+ !/^[A-Za-z0-9_-]{8,128}$/.test(input.watchId) ||
1002
+ (input.mode !== 'initial' && input.mode !== 'renew'))
1003
+ throw new Error('WORKSPACE_WATCH_INVALID');
1004
+ const workspace = this.requireWorkspace(input.workspaceId);
1005
+ if (input.mode === 'renew') {
1006
+ const watch = this.renewWorkspaceWatch(workspace.id, this.workspaceWatchKey(input));
1007
+ if (watch === undefined)
1008
+ throw new Error('WORKSPACE_WATCH_NOT_FOUND');
1009
+ return { renewed: true };
1010
+ }
1011
+ const topics = this.parseWorkspaceWatchTopics(input);
1012
+ const snapshot = {};
1013
+ if (topics.sessions)
1014
+ snapshot.sessions = await this.executeNodeOperation('session.list', {
1015
+ workspaceId: workspace.id,
1016
+ limit: 20
1017
+ });
1018
+ if (topics.files !== undefined)
1019
+ snapshot.files = {
1020
+ files: await this.listFilesForWorkspace(workspace, topics.files)
1021
+ };
1022
+ if (topics.changes)
1023
+ snapshot.changes =
1024
+ workspace.kind === 'GIT_WORKSPACE'
1025
+ ? await this.currentChangesForWorkspace(workspace)
1026
+ : null;
1027
+ // An initial snapshot is the observation baseline. Do not retain a
1028
+ // background lease when producing that baseline has failed.
1029
+ if (topics.files !== undefined || topics.changes)
1030
+ await this.workspaceFileIndex(workspace).observe();
1031
+ const watch = this.createWorkspaceWatch(workspace.id, input.watchId, this.workspaceWatchSessionHash(input), topics);
1032
+ return { watch: { expiresAt: watch.expiresAt }, snapshot };
1033
+ }
1034
+ if (operation === 'workspace.unwatch') {
1035
+ if (typeof input.workspaceId !== 'string' ||
1036
+ typeof input.watchId !== 'string' ||
1037
+ !/^[A-Za-z0-9_-]{8,128}$/.test(input.watchId))
1038
+ throw new Error('WORKSPACE_WATCH_INVALID');
1039
+ this.removeWorkspaceWatch(input.workspaceId, this.workspaceWatchKey(input));
1040
+ return { stopped: true };
1041
+ }
885
1042
  if (operation === 'workspace.files.list') {
886
1043
  const workspace = this.requireWorkspace(input.workspaceId);
887
1044
  return { files: await this.listFilesForWorkspace(workspace, input) };
@@ -941,9 +1098,9 @@ export class NodeConnector {
941
1098
  throw new Error('WORKSPACE_NOT_GIT');
942
1099
  if (this.runtime.hasActiveWorkspaceLease(workspace.id))
943
1100
  throw new Error('WORKSPACE_BUSY');
944
- await restoreCurrentChange(workspace.path, input.path, input.state, this.fileAccessOptions());
1101
+ await restoreCurrentChange(workspace.path, input.path, input.state);
945
1102
  // A read started before the write must not satisfy the explicit post-write refresh.
946
- this.workspaceChangesReads.delete(workspace.id);
1103
+ this.invalidateWorkspaceWatchTopics(workspace.id);
947
1104
  return { restored: true };
948
1105
  }
949
1106
  if (operation === 'workspace.diff') {
@@ -999,16 +1156,6 @@ export class NodeConnector {
999
1156
  if (input.bootstrapOnly === true)
1000
1157
  this.bootstrapSessionIds.add(session.id);
1001
1158
  database.saveRunnerDefaults(configuration);
1002
- if (session.externalSessionId !== null) {
1003
- database.saveNativeSessionMetadata({
1004
- runner: session.runner,
1005
- externalSessionId: session.externalSessionId,
1006
- workspaceId: session.workspaceId,
1007
- model: configuration.model,
1008
- effort: configuration.effort,
1009
- access: configuration.access
1010
- });
1011
- }
1012
1159
  if (input.bootstrapOnly !== true)
1013
1160
  this.emitWorkbenchEvent('session', { session: this.presentSession(session) });
1014
1161
  return {
@@ -1144,11 +1291,14 @@ export class NodeConnector {
1144
1291
  };
1145
1292
  }
1146
1293
  if (operation === 'session.list') {
1147
- // Native discovery is deliberately deferred until a Workspace is
1148
- // selected. A node selection must not block on every user's historical
1149
- // transcript directory; selected-workspace discovery populates this
1150
- // process-local cache before a direct session can be opened.
1151
- const native = [...this.directNativeSessions.values()].map((session) => this.presentSession(session));
1294
+ const workspaceId = typeof input.workspaceId === 'string' ? input.workspaceId : undefined;
1295
+ // Pagination is only correct after persisted records and the current
1296
+ // Runner discovery have been merged. A selected Workspace therefore
1297
+ // performs discovery inside this authoritative list operation.
1298
+ const nativeSessions = workspaceId === undefined
1299
+ ? [...this.directNativeSessions.values()]
1300
+ : await this.cachedWorkspaceSessions(workspaceId);
1301
+ const native = nativeSessions.map((session) => this.presentSession({ ...session, externalDiscovered: true }));
1152
1302
  const projected = this.runtime
1153
1303
  .listAgentSessions()
1154
1304
  .filter((session) => session.nativeControl === 'MAR_MANAGED' ||
@@ -1160,17 +1310,51 @@ export class NodeConnector {
1160
1310
  : {
1161
1311
  ...session,
1162
1312
  runnerTitle: source.runnerTitle,
1313
+ pinnedAt: source.pinnedAt,
1314
+ pinOrder: source.pinOrder ?? null,
1315
+ externalDiscovered: true,
1163
1316
  titleSource: session.customTitle === null ? source.titleSource : session.titleSource,
1164
1317
  lastActivityAt: source.lastActivityAt
1165
1318
  });
1166
1319
  });
1167
- const allSessions = [
1320
+ const merged = new Map([
1168
1321
  ...projected,
1169
1322
  ...native.filter((native) => native.externalSessionId === null ||
1170
1323
  this.sessionProjectionForNativeRecord(native) === undefined)
1171
- ];
1172
- return sessionPage(typeof input.workspaceId === 'string'
1173
- ? allSessions.filter((session) => session.workspaceId === input.workspaceId)
1324
+ ].map((session) => [session.id, session]));
1325
+ if (workspaceId !== undefined) {
1326
+ const workspace = database.getWorkspace(workspaceId);
1327
+ if (workspace === undefined)
1328
+ throw new Error('WORKSPACE_NOT_FOUND');
1329
+ for (const record of database.listSessionRecords(workspaceId)) {
1330
+ if (merged.has(record.id))
1331
+ continue;
1332
+ merged.set(record.id, this.presentSession({
1333
+ id: record.id,
1334
+ nodeId: this.config?.nodeId ?? 'recorded-session',
1335
+ workspaceId,
1336
+ runner: record.runner,
1337
+ externalSessionId: record.id,
1338
+ nativeControl: 'EXTERNAL',
1339
+ channelToken: null,
1340
+ cwd: workspace.path,
1341
+ model: record.metadata.model,
1342
+ effort: record.metadata.effort,
1343
+ access: record.metadata.access,
1344
+ customTitle: record.metadata.customTitle,
1345
+ runnerTitle: record.metadata.runnerTitle,
1346
+ pinnedAt: record.pinOrder === null ? null : 1,
1347
+ pinOrder: record.pinOrder,
1348
+ externalDiscovered: false,
1349
+ titleSource: record.metadata.customTitle === null ? 'RUNNER' : 'CUSTOM',
1350
+ lastActivityAt: 0,
1351
+ createdAt: 0
1352
+ }));
1353
+ }
1354
+ }
1355
+ const allSessions = [...merged.values()];
1356
+ return sessionPage(workspaceId !== undefined
1357
+ ? allSessions.filter((session) => session.workspaceId === workspaceId)
1174
1358
  : allSessions, input);
1175
1359
  }
1176
1360
  if (operation === 'session.discover') {
@@ -1220,18 +1404,26 @@ export class NodeConnector {
1220
1404
  (await this.resolveDirectNativeSession(input.sessionId));
1221
1405
  if (value === undefined)
1222
1406
  throw new Error('SESSION_NOT_FOUND');
1223
- await this.refreshCodexExternalActivity(value);
1224
1407
  return { session: this.presentSession(value) };
1225
1408
  }
1226
1409
  if (operation === 'session.watch') {
1227
- if (typeof input.sessionId !== 'string')
1410
+ if (typeof input.sessionId !== 'string' ||
1411
+ (input.mode !== 'initial' && input.mode !== 'renew'))
1228
1412
  throw new Error('SESSION_INVALID');
1229
1413
  const session = this.runtime.getAgentSession(input.sessionId) ??
1230
1414
  (await this.resolveDirectNativeSession(input.sessionId));
1231
1415
  if (session === undefined || session.externalSessionId === null)
1232
1416
  throw new Error('SESSION_NOT_FOUND');
1417
+ if (input.mode === 'renew') {
1418
+ const watch = this.renewNativeSessionWatch(session);
1419
+ if (watch === undefined)
1420
+ throw new Error('SESSION_WATCH_NOT_FOUND');
1421
+ return { renewed: true };
1422
+ }
1233
1423
  const watch = this.watchNativeSession(session);
1234
- const snapshot = watch === undefined ? undefined : await this.refreshWatchedSession(session.id);
1424
+ const snapshot = watch !== undefined
1425
+ ? await this.refreshWatchedSession(session.id, SESSION_WATCH_INITIAL_TURN_LIMIT)
1426
+ : undefined;
1235
1427
  if (watch !== undefined && snapshot === undefined)
1236
1428
  throw new Error('NATIVE_TRANSCRIPT_UNAVAILABLE');
1237
1429
  return {
@@ -1650,12 +1842,173 @@ export class NodeConnector {
1650
1842
  throw new Error('WORKSPACE_NOT_FOUND');
1651
1843
  return workspace;
1652
1844
  }
1653
- fileAccessOptions() {
1654
- if (this.config === undefined)
1655
- return {};
1656
- // The database directory is Node-private even when an operator has
1657
- // accidentally placed it below an otherwise permitted Workspace root.
1658
- return { privateRoots: [dirname(nodeDatabasePath(this.config, this.configPath))] };
1845
+ parseWorkspaceWatchTopics(input) {
1846
+ if (!isPlainRecord(input.topics))
1847
+ throw new Error('WORKSPACE_WATCH_INVALID');
1848
+ const sessions = input.topics.sessions === true;
1849
+ const changes = input.topics.changes === true;
1850
+ const rawFiles = input.topics.files;
1851
+ let files;
1852
+ if (rawFiles !== undefined) {
1853
+ if (!isPlainRecord(rawFiles) || typeof rawFiles.path !== 'string')
1854
+ throw new Error('WORKSPACE_WATCH_INVALID');
1855
+ const limit = rawFiles.limit;
1856
+ if ((rawFiles.cursor !== undefined && typeof rawFiles.cursor !== 'string') ||
1857
+ (limit !== undefined &&
1858
+ (typeof limit !== 'number' || !Number.isInteger(limit) || limit < 1 || limit > 200)))
1859
+ throw new Error('WORKSPACE_WATCH_INVALID');
1860
+ files = {
1861
+ path: rawFiles.path,
1862
+ ...(typeof rawFiles.cursor === 'string' ? { cursor: rawFiles.cursor } : {}),
1863
+ ...(typeof rawFiles.limit === 'number' ? { limit: rawFiles.limit } : {})
1864
+ };
1865
+ }
1866
+ if (!sessions && !changes && files === undefined)
1867
+ throw new Error('WORKSPACE_WATCH_INVALID');
1868
+ return { sessions, changes, files };
1869
+ }
1870
+ workspaceWatchKey(input) {
1871
+ return `${this.workspaceWatchSessionHash(input)}:${input.watchId}`;
1872
+ }
1873
+ workspaceWatchSessionHash(input) {
1874
+ return typeof input.__workspaceWatchSessionHash === 'string'
1875
+ ? input.__workspaceWatchSessionHash
1876
+ : 'local-test';
1877
+ }
1878
+ createWorkspaceWatch(workspaceId, watchId, sessionHash, topics) {
1879
+ const key = `${sessionHash}:${watchId}`;
1880
+ this.removeWorkspaceWatch(workspaceId, key);
1881
+ const watch = {
1882
+ workspaceId,
1883
+ watchId,
1884
+ sessionHash,
1885
+ expiresAt: Date.now() + WORKSPACE_WATCH_TTL_MS,
1886
+ topics,
1887
+ timer: undefined
1888
+ };
1889
+ watch.timer = setTimeout(() => this.removeWorkspaceWatch(workspaceId, key), WORKSPACE_WATCH_TTL_MS);
1890
+ watch.timer.unref();
1891
+ const watches = this.workspaceWatches.get(workspaceId) ?? new Map();
1892
+ watches.set(key, watch);
1893
+ this.workspaceWatches.set(workspaceId, watches);
1894
+ if (topics.sessions)
1895
+ this.ensureWorkspaceSessionWatch(workspaceId);
1896
+ return watch;
1897
+ }
1898
+ renewWorkspaceWatch(workspaceId, watchId) {
1899
+ const watch = this.workspaceWatches.get(workspaceId)?.get(watchId);
1900
+ if (watch === undefined || watch.expiresAt <= Date.now()) {
1901
+ this.removeWorkspaceWatch(workspaceId, watchId);
1902
+ return undefined;
1903
+ }
1904
+ clearTimeout(watch.timer);
1905
+ watch.expiresAt = Date.now() + WORKSPACE_WATCH_TTL_MS;
1906
+ watch.timer = setTimeout(() => this.removeWorkspaceWatch(workspaceId, watchId), WORKSPACE_WATCH_TTL_MS);
1907
+ watch.timer.unref();
1908
+ return watch;
1909
+ }
1910
+ removeWorkspaceWatch(workspaceId, watchId) {
1911
+ const watches = this.workspaceWatches.get(workspaceId);
1912
+ const watch = watches?.get(watchId);
1913
+ if (watch === undefined)
1914
+ return;
1915
+ clearTimeout(watch.timer);
1916
+ watches?.delete(watchId);
1917
+ if (watches?.size === 0)
1918
+ this.workspaceWatches.delete(workspaceId);
1919
+ if (!this.workspaceWatchTopicActive(workspaceId, 'sessions'))
1920
+ this.stopWorkspaceSessionWatch(workspaceId);
1921
+ }
1922
+ clearWorkspaceWatches(workspaceId) {
1923
+ const watches = this.workspaceWatches.get(workspaceId);
1924
+ if (watches === undefined)
1925
+ return;
1926
+ for (const watch of watches.values())
1927
+ clearTimeout(watch.timer);
1928
+ this.workspaceWatches.delete(workspaceId);
1929
+ this.workspaceWatchRevisions.delete(workspaceId);
1930
+ this.stopWorkspaceSessionWatch(workspaceId);
1931
+ }
1932
+ ensureWorkspaceSessionWatch(workspaceId) {
1933
+ if (this.workspaceSessionWatchTimers.has(workspaceId))
1934
+ return;
1935
+ const timer = setInterval(() => void this.refreshWorkspaceSessionWatch(workspaceId), WORKSPACE_SESSION_WATCH_INTERVAL_MS);
1936
+ timer.unref();
1937
+ this.workspaceSessionWatchTimers.set(workspaceId, timer);
1938
+ void this.refreshWorkspaceSessionWatch(workspaceId);
1939
+ }
1940
+ stopWorkspaceSessionWatch(workspaceId) {
1941
+ const timer = this.workspaceSessionWatchTimers.get(workspaceId);
1942
+ if (timer !== undefined)
1943
+ clearInterval(timer);
1944
+ this.workspaceSessionWatchTimers.delete(workspaceId);
1945
+ this.workspaceSessionWatchSignatures.delete(workspaceId);
1946
+ this.workspaceSessionWatchReads.delete(workspaceId);
1947
+ }
1948
+ async cachedWorkspaceSessions(workspaceId) {
1949
+ const cached = this.workspaceSessionCache.get(workspaceId);
1950
+ if (cached !== undefined && cached.expiresAt > Date.now())
1951
+ return cached.sessions;
1952
+ const inFlight = this.workspaceSessionCacheReads.get(workspaceId);
1953
+ if (inFlight !== undefined)
1954
+ return inFlight;
1955
+ const discovery = this.discoverWorkspaceSessions(workspaceId)
1956
+ .then((sessions) => {
1957
+ this.workspaceSessionCache.set(workspaceId, {
1958
+ sessions,
1959
+ expiresAt: Date.now() + WORKSPACE_SESSION_CACHE_TTL_MS
1960
+ });
1961
+ return sessions;
1962
+ })
1963
+ .finally(() => this.workspaceSessionCacheReads.delete(workspaceId));
1964
+ this.workspaceSessionCacheReads.set(workspaceId, discovery);
1965
+ return discovery;
1966
+ }
1967
+ async refreshWorkspaceSessionWatch(workspaceId) {
1968
+ const current = this.workspaceSessionWatchReads.get(workspaceId);
1969
+ if (current !== undefined)
1970
+ return current;
1971
+ const refresh = this.performWorkspaceSessionWatchRefresh(workspaceId).finally(() => {
1972
+ this.workspaceSessionWatchReads.delete(workspaceId);
1973
+ });
1974
+ this.workspaceSessionWatchReads.set(workspaceId, refresh);
1975
+ return refresh;
1976
+ }
1977
+ async performWorkspaceSessionWatchRefresh(workspaceId) {
1978
+ if (!this.workspaceWatchTopicActive(workspaceId, 'sessions'))
1979
+ return this.stopWorkspaceSessionWatch(workspaceId);
1980
+ try {
1981
+ const page = await this.executeNodeOperation('session.list', { workspaceId, limit: 20 });
1982
+ const signature = JSON.stringify(page);
1983
+ const previous = this.workspaceSessionWatchSignatures.get(workspaceId);
1984
+ this.workspaceSessionWatchSignatures.set(workspaceId, signature);
1985
+ if (previous === undefined || previous === signature)
1986
+ return;
1987
+ const revision = (this.workspaceWatchRevisions.get(workspaceId) ?? 0) + 1;
1988
+ this.workspaceWatchRevisions.set(workspaceId, revision);
1989
+ this.emitWorkbenchEvent('workspace', { workspaceId, topic: 'sessions', revision });
1990
+ }
1991
+ catch {
1992
+ // A later bounded interval retries; no stale session state is fabricated.
1993
+ }
1994
+ }
1995
+ workspaceWatchTopicActive(workspaceId, topic) {
1996
+ return [...(this.workspaceWatches.get(workspaceId)?.values() ?? [])].some((watch) => topic === 'files' ? watch.topics.files !== undefined : watch.topics[topic]);
1997
+ }
1998
+ invalidateWorkspaceWatchTopics(workspaceId) {
1999
+ this.invalidateWorkspaceChanges(workspaceId);
2000
+ for (const topic of ['files', 'changes']) {
2001
+ if (!this.workspaceWatchTopicActive(workspaceId, topic))
2002
+ continue;
2003
+ const revision = (this.workspaceWatchRevisions.get(workspaceId) ?? 0) + 1;
2004
+ this.workspaceWatchRevisions.set(workspaceId, revision);
2005
+ this.emitWorkbenchEvent('workspace', { workspaceId, topic, revision });
2006
+ }
2007
+ }
2008
+ invalidateWorkspaceChanges(workspaceId) {
2009
+ this.workspaceChangesReads.delete(workspaceId);
2010
+ this.workspaceChangesCache.delete(workspaceId);
2011
+ this.workspaceChangesVersions.set(workspaceId, (this.workspaceChangesVersions.get(workspaceId) ?? 0) + 1);
1659
2012
  }
1660
2013
  async listFilesForWorkspace(workspace, input) {
1661
2014
  if ((input.path !== undefined && typeof input.path !== 'string') ||
@@ -1665,7 +2018,7 @@ export class NodeConnector {
1665
2018
  input.limit < 1 ||
1666
2019
  input.limit > 200)))
1667
2020
  throw new Error('DIRECTORY_CURSOR_INVALID');
1668
- return listWorkspaceFiles(workspace.path, typeof input.path === 'string' ? input.path : '.', typeof input.cursor === 'string' ? input.cursor : undefined, typeof input.limit === 'number' ? input.limit : undefined, this.fileAccessOptions());
2021
+ return listWorkspaceFiles(workspace.path, typeof input.path === 'string' ? input.path : '.', typeof input.cursor === 'string' ? input.cursor : undefined, typeof input.limit === 'number' ? input.limit : undefined);
1669
2022
  }
1670
2023
  async readFileForWorkspace(workspace, input) {
1671
2024
  if (typeof input.path !== 'string' ||
@@ -1677,8 +2030,8 @@ export class NodeConnector {
1677
2030
  input.limit > 512 * 1024)))
1678
2031
  throw new Error('FILE_RANGE_INVALID');
1679
2032
  if (isAbsolute(input.path))
1680
- return readAllowedTextFile(input.path, this.config?.allowedRoots ?? [], typeof input.offset === 'number' ? input.offset : 0, typeof input.limit === 'number' ? input.limit : 512 * 1024, this.fileAccessOptions());
1681
- return readWorkspaceTextFile(workspace.path, input.path, typeof input.offset === 'number' ? input.offset : 0, typeof input.limit === 'number' ? input.limit : 512 * 1024, this.fileAccessOptions());
2033
+ return readAllowedTextFile(input.path, this.config?.allowedRoots ?? [], typeof input.offset === 'number' ? input.offset : 0, typeof input.limit === 'number' ? input.limit : 512 * 1024);
2034
+ return readWorkspaceTextFile(workspace.path, input.path, typeof input.offset === 'number' ? input.offset : 0, typeof input.limit === 'number' ? input.limit : 512 * 1024);
1682
2035
  }
1683
2036
  async searchFilesForWorkspace(workspace, input) {
1684
2037
  if (typeof input.query !== 'string' ||
@@ -1688,7 +2041,27 @@ export class NodeConnector {
1688
2041
  input.limit < 1 ||
1689
2042
  input.limit > 200)))
1690
2043
  throw new Error('FILE_SEARCH_LIMIT_EXCEEDED');
1691
- return searchWorkspaceFiles(workspace.path, input.query, typeof input.cursor === 'string' ? input.cursor : undefined, typeof input.limit === 'number' ? input.limit : undefined, this.fileAccessOptions());
2044
+ const index = this.workspaceFileIndex(workspace);
2045
+ return index.search(input.query, typeof input.cursor === 'string' ? input.cursor : undefined, typeof input.limit === 'number' ? input.limit : undefined);
2046
+ }
2047
+ workspaceFileIndex(workspace) {
2048
+ let index = this.workspaceFileIndexes.get(workspace.id);
2049
+ if (index === undefined) {
2050
+ if (this.workspaceFileIndexes.size >= WORKSPACE_FILE_INDEX_CACHE_LIMIT) {
2051
+ const oldest = this.workspaceFileIndexes.entries().next().value;
2052
+ if (oldest !== undefined) {
2053
+ const [oldestWorkspaceId, oldestIndex] = oldest;
2054
+ oldestIndex.dispose();
2055
+ this.workspaceFileIndexes.delete(oldestWorkspaceId);
2056
+ }
2057
+ }
2058
+ index = new WorkspaceFileIndex(workspace.path, {
2059
+ onInvalidated: () => this.invalidateWorkspaceWatchTopics(workspace.id)
2060
+ });
2061
+ }
2062
+ this.workspaceFileIndexes.delete(workspace.id);
2063
+ this.workspaceFileIndexes.set(workspace.id, index);
2064
+ return index;
1692
2065
  }
1693
2066
  async preflightWorkspaceUploads(workspace, input) {
1694
2067
  if ((input.directory !== undefined && typeof input.directory !== 'string') ||
@@ -1702,7 +2075,7 @@ export class NodeConnector {
1702
2075
  if (seen.has(name))
1703
2076
  return { name, status: 'INVALID_NAME', path: null };
1704
2077
  seen.add(name);
1705
- const target = await preflightWorkspaceUpload(workspace.path, typeof input.directory === 'string' ? input.directory : '.', name, this.fileAccessOptions());
2078
+ const target = await preflightWorkspaceUpload(workspace.path, typeof input.directory === 'string' ? input.directory : '.', name);
1706
2079
  return { name, status: target.status, path: target.path || null };
1707
2080
  }));
1708
2081
  }
@@ -1725,7 +2098,7 @@ export class NodeConnector {
1725
2098
  throw new Error('COMPOSER_ATTACHMENT_INVALID');
1726
2099
  }
1727
2100
  const target = composerAttachment === undefined
1728
- ? await preflightWorkspaceUpload(workspace.path, typeof input.directory === 'string' ? input.directory : '.', input.name, this.fileAccessOptions())
2101
+ ? await preflightWorkspaceUpload(workspace.path, typeof input.directory === 'string' ? input.directory : '.', input.name)
1729
2102
  : await this.composerAttachmentTarget(workspace.path, composerAttachment.id, input.name);
1730
2103
  if (target.status === 'INVALID_NAME')
1731
2104
  throw new Error('UPLOAD_NAME_INVALID');
@@ -1816,6 +2189,7 @@ export class NodeConnector {
1816
2189
  }
1817
2190
  clearTimeout(upload.timer);
1818
2191
  this.workspaceUploads.delete(upload.id);
2192
+ this.invalidateWorkspaceWatchTopics(upload.workspaceId);
1819
2193
  if (upload.composerAttachment !== undefined) {
1820
2194
  this.composerAttachments.set(upload.composerAttachment.id, {
1821
2195
  id: upload.composerAttachment.id,
@@ -1918,14 +2292,24 @@ export class NodeConnector {
1918
2292
  async currentChangesForWorkspace(workspace) {
1919
2293
  if (workspace.kind !== 'GIT_WORKSPACE')
1920
2294
  throw new Error('WORKSPACE_NOT_GIT');
2295
+ const cached = this.workspaceChangesCache.get(workspace.id);
2296
+ if (cached !== undefined && cached.expiresAt > Date.now())
2297
+ return cached.value;
1921
2298
  const existing = this.workspaceChangesReads.get(workspace.id);
1922
2299
  if (existing !== undefined)
1923
2300
  return existing;
2301
+ const version = this.workspaceChangesVersions.get(workspace.id) ?? 0;
1924
2302
  const request = this.workspaceChangesLimiter.run(async () => {
1925
2303
  const summary = await readCurrentChangesSummary(workspace.path);
1926
- return {
2304
+ const visibleChanges = (await Promise.all(summary.changes.map(async (change) => ({
2305
+ change,
2306
+ visible: await isWorkspaceChangeVisible(workspace.path, change.path)
2307
+ }))))
2308
+ .filter(({ visible }) => visible)
2309
+ .map(({ change }) => change);
2310
+ const value = {
1927
2311
  branch: summary.branch,
1928
- changes: summary.changes.map((change) => ({
2312
+ changes: visibleChanges.map((change) => ({
1929
2313
  ...change,
1930
2314
  additions: null,
1931
2315
  deletions: null,
@@ -1936,6 +2320,12 @@ export class NodeConnector {
1936
2320
  diffAvailable: change.state !== 'UNTRACKED'
1937
2321
  }))
1938
2322
  };
2323
+ if ((this.workspaceChangesVersions.get(workspace.id) ?? 0) === version)
2324
+ this.workspaceChangesCache.set(workspace.id, {
2325
+ value,
2326
+ expiresAt: Date.now() + WORKSPACE_CHANGES_CACHE_TTL_MS
2327
+ });
2328
+ return value;
1939
2329
  });
1940
2330
  this.workspaceChangesReads.set(workspace.id, request);
1941
2331
  try {
@@ -2005,34 +2395,19 @@ export class NodeConnector {
2005
2395
  officialCandidates.push({ thread, workspace: targetWorkspace, cwd });
2006
2396
  }
2007
2397
  // A selected Workspace still needs the complete native directory scan for
2008
- // source coverage, but it is independent of App Server status reads. Start
2009
- // both paths together and bound JSON-RPC reads so dozens of old threads do
2010
- // not serialize the visible Codex list or overload one App Server.
2398
+ // source coverage, but it is independent of App Server discovery. Start
2399
+ // both paths together. The list response is the discovery source; do not
2400
+ // read every thread here because most external threads are not loaded by
2401
+ // this App Server and would be reported as unavailable anyway.
2011
2402
  const nativePromise = discoverAllNativeSessions('codex');
2012
- const official = await mapWithConcurrency(officialCandidates, 8, async (candidate) => {
2013
- try {
2014
- // Discovery only verifies that the official thread remains readable
2015
- // and obtains its activity state. Loading every historical turn here
2016
- // makes the list cost proportional to the full conversation corpus;
2017
- // the selected Chat fetches `includeTurns: true` separately.
2018
- const detail = await this.codexClient.readThread(candidate.thread.id, false);
2019
- this.codexExternalActivity.set(candidate.thread.id, codexThreadActivity(detail));
2020
- }
2021
- catch {
2022
- // A listing can race archival/deletion. It must not manufacture a
2023
- // session entry whose native history is no longer readable.
2024
- return undefined;
2025
- }
2026
- return this.directNativeSession({
2027
- workspace: candidate.workspace,
2028
- runner: 'codex',
2029
- externalSessionId: candidate.thread.id,
2030
- cwd: candidate.cwd,
2031
- ...(candidate.thread.title === undefined ? {} : { title: candidate.thread.title }),
2032
- titleOrigin: 'OFFICIAL'
2033
- });
2034
- });
2035
- const sessions = official.filter((session) => session !== undefined);
2403
+ const sessions = officialCandidates.map((candidate) => this.directNativeSession({
2404
+ workspace: candidate.workspace,
2405
+ runner: 'codex',
2406
+ externalSessionId: candidate.thread.id,
2407
+ cwd: candidate.cwd,
2408
+ ...(candidate.thread.title === undefined ? {} : { title: candidate.thread.title }),
2409
+ titleOrigin: 'OFFICIAL'
2410
+ }));
2036
2411
  const native = await nativePromise;
2037
2412
  for (const entry of native) {
2038
2413
  const targetWorkspace = workspace ??
@@ -2132,11 +2507,74 @@ export class NodeConnector {
2132
2507
  this.rememberDirectNativeSessions(sessions);
2133
2508
  return sessions;
2134
2509
  }
2510
+ async discoverWorkspaceSessions(workspaceId) {
2511
+ const database = this.database;
2512
+ if (database === undefined)
2513
+ throw new Error('NODE_DATABASE_UNAVAILABLE');
2514
+ const workspace = database.getWorkspace(workspaceId);
2515
+ if (workspace === undefined)
2516
+ throw new Error('WORKSPACE_NOT_FOUND');
2517
+ const [codex, claude] = await Promise.all([
2518
+ this.discoverCodexSessions(workspaceId).then((result) => result.sessions),
2519
+ (async () => {
2520
+ let official = [];
2521
+ try {
2522
+ official = await this.claudeClient.listSessions(workspace.path);
2523
+ }
2524
+ catch {
2525
+ // Local transcript discovery remains authoritative when the SDK list is unavailable.
2526
+ }
2527
+ const native = await discoverNativeSessions('claude-code', workspace.path);
2528
+ return this.directClaudeSessions(workspace, 'claude-code', [
2529
+ ...official.flatMap((item) => {
2530
+ const parsed = claudeDiscoveredSession(item, workspace.path);
2531
+ return parsed === undefined ? [] : [parsed];
2532
+ }),
2533
+ ...native
2534
+ ]);
2535
+ })()
2536
+ ]);
2537
+ const sessions = deduplicateDirectSessions([...codex, ...claude]);
2538
+ this.rememberDirectNativeSessions(sessions);
2539
+ return sessions;
2540
+ }
2135
2541
  async resolveDirectNativeSession(sessionId) {
2136
2542
  const cached = this.directNativeSessions.get(sessionId);
2137
2543
  if (cached !== undefined)
2138
2544
  return cached;
2139
- return (await this.listDirectNativeSessions()).find((session) => session.id === sessionId);
2545
+ const discovered = (await this.listDirectNativeSessions()).find((session) => session.id === sessionId);
2546
+ if (discovered !== undefined)
2547
+ return discovered;
2548
+ const record = this.database
2549
+ ?.listWorkspaces()
2550
+ .flatMap((workspace) => this.database?.listSessionRecords(workspace.id) ?? [])
2551
+ .find((candidate) => candidate.id === sessionId);
2552
+ if (record === undefined)
2553
+ return undefined;
2554
+ const workspace = this.database?.getWorkspace(record.workspaceId);
2555
+ if (workspace === undefined)
2556
+ return undefined;
2557
+ return {
2558
+ id: record.id,
2559
+ nodeId: this.config?.nodeId ?? 'recorded-session',
2560
+ workspaceId: record.workspaceId,
2561
+ runner: record.runner,
2562
+ externalSessionId: record.id,
2563
+ nativeControl: 'EXTERNAL',
2564
+ channelToken: null,
2565
+ cwd: workspace.path,
2566
+ model: record.metadata.model,
2567
+ effort: record.metadata.effort,
2568
+ access: record.metadata.access,
2569
+ customTitle: record.metadata.customTitle,
2570
+ runnerTitle: record.metadata.runnerTitle,
2571
+ pinnedAt: record.pinOrder === null ? null : 1,
2572
+ pinOrder: record.pinOrder,
2573
+ externalDiscovered: false,
2574
+ titleSource: record.metadata.customTitle === null ? 'RUNNER' : 'CUSTOM',
2575
+ lastActivityAt: 0,
2576
+ createdAt: 0
2577
+ };
2140
2578
  }
2141
2579
  /**
2142
2580
  * A rename is Node-local metadata, so an external transcript receives a
@@ -2419,6 +2857,19 @@ export class NodeConnector {
2419
2857
  this.sessionWatches.set(session.id, watch);
2420
2858
  return { expiresAt: watch.expiresAt };
2421
2859
  }
2860
+ renewNativeSessionWatch(session) {
2861
+ if (this.hasManagedActiveRun(session)) {
2862
+ this.stopNativeSessionWatch(session.id);
2863
+ return undefined;
2864
+ }
2865
+ const watch = this.sessionWatches.get(session.id);
2866
+ if (watch === undefined || watch.expiresAt <= Date.now()) {
2867
+ this.stopNativeSessionWatch(session.id);
2868
+ return undefined;
2869
+ }
2870
+ watch.expiresAt = Date.now() + SESSION_WATCH_TTL_MS;
2871
+ return { expiresAt: watch.expiresAt };
2872
+ }
2422
2873
  stopNativeSessionWatch(sessionId) {
2423
2874
  const watch = this.sessionWatches.get(sessionId);
2424
2875
  if (watch === undefined)
@@ -2426,7 +2877,7 @@ export class NodeConnector {
2426
2877
  clearInterval(watch.timer);
2427
2878
  this.sessionWatches.delete(sessionId);
2428
2879
  }
2429
- async refreshWatchedSession(sessionId) {
2880
+ async refreshWatchedSession(sessionId, limit = 10) {
2430
2881
  const watch = this.sessionWatches.get(sessionId);
2431
2882
  if (watch === undefined)
2432
2883
  return undefined;
@@ -2436,7 +2887,7 @@ export class NodeConnector {
2436
2887
  this.stopNativeSessionWatch(sessionId);
2437
2888
  return undefined;
2438
2889
  }
2439
- const refresh = this.performWatchedSessionRefresh(sessionId, watch);
2890
+ const refresh = this.performWatchedSessionRefresh(sessionId, watch, limit);
2440
2891
  watch.refresh = refresh;
2441
2892
  try {
2442
2893
  return await refresh;
@@ -2446,7 +2897,7 @@ export class NodeConnector {
2446
2897
  watch.refresh = undefined;
2447
2898
  }
2448
2899
  }
2449
- async performWatchedSessionRefresh(sessionId, watch) {
2900
+ async performWatchedSessionRefresh(sessionId, watch, limit = 10) {
2450
2901
  try {
2451
2902
  const session = this.runtime.getAgentSession(sessionId) ??
2452
2903
  (await this.resolveDirectNativeSession(sessionId));
@@ -2463,18 +2914,22 @@ export class NodeConnector {
2463
2914
  watch.sessionSignature = sessionSignature;
2464
2915
  this.emitWorkbenchEvent('session', { session: presentedSession });
2465
2916
  }
2466
- const page = await this.readConversationHistoryPage(session, { limit: 10 });
2917
+ const page = await this.readConversationHistoryPage(session, { limit });
2467
2918
  // A missing transcript is not a harmless empty update: continuing to
2468
2919
  // renew an observer that has lost both official and native history would
2469
2920
  // retain its timer forever. Treat it like every other read failure.
2470
2921
  watch.failures = 0;
2471
- const signature = JSON.stringify(page.turns);
2922
+ // `initial` may return a larger page than the background observer. The
2923
+ // observer's change signature remains fixed to the newest 10 Turns, so
2924
+ // switching back to its normal page size cannot manufacture an update.
2925
+ const observedPage = page.turns.length <= 10 ? page : { ...page, turns: page.turns.slice(0, 10) };
2926
+ const signature = JSON.stringify(observedPage.turns);
2472
2927
  if (signature === watch.signature)
2473
2928
  return page;
2474
2929
  watch.signature = signature;
2475
2930
  // 历史页按最新到最早排列,而 Web 会把单个实时 Turn 插到最新端。
2476
2931
  // 因此逐帧推送必须反向,最终投影仍保持最新到最早。
2477
- for (const turn of [...page.turns].reverse())
2932
+ for (const turn of [...observedPage.turns].reverse())
2478
2933
  this.emitWorkbenchEvent('conversation', { turn });
2479
2934
  return page;
2480
2935
  }
@@ -2679,6 +3134,8 @@ export class NodeConnector {
2679
3134
  customTitle: metadata?.customTitle ?? null,
2680
3135
  runnerTitle,
2681
3136
  pinnedAt: metadata?.pinnedAt ?? null,
3137
+ pinOrder: metadata?.pinOrder ?? null,
3138
+ externalDiscovered: true,
2682
3139
  titleSource: metadata?.customTitle === null || metadata?.customTitle === undefined
2683
3140
  ? runnerTitle === null
2684
3141
  ? 'AUTO'
@@ -2688,7 +3145,7 @@ export class NodeConnector {
2688
3145
  createdAt: timestamp
2689
3146
  };
2690
3147
  }
2691
- /** DB caches confirmed Codex names and shields a pending rename from stale discovery results. */
3148
+ /** A pending Codex rename shields the UI from stale discovery until Runner confirms it. */
2692
3149
  nativeSessionRunnerTitle(metadata, input) {
2693
3150
  if (input.runner !== 'codex')
2694
3151
  return input.title ?? null;
@@ -2707,24 +3164,17 @@ export class NodeConnector {
2707
3164
  }
2708
3165
  return cached ?? input.title;
2709
3166
  }
2710
- if (input.title !== cached) {
2711
- this.database?.saveNativeSessionMetadata({
2712
- runner: 'codex',
2713
- externalSessionId: input.externalSessionId,
2714
- workspaceId: input.workspace.id,
2715
- runnerTitle: input.title,
2716
- runnerTitlePending: false
2717
- });
2718
- }
2719
3167
  return input.title;
2720
3168
  }
2721
3169
  /** Runner-specific title persistence: Codex owns its thread name; Claude metadata is Node-local. */
2722
3170
  async renameRunnerSession(session, input) {
2723
3171
  if (session.runner === 'codex') {
2724
- if (session.externalSessionId === null || input.title === undefined)
3172
+ if (session.externalSessionId === null)
2725
3173
  return;
2726
- await this.codexClient.start();
2727
- await this.codexClient.setThreadName(session.externalSessionId, input.title ?? '');
3174
+ if (input.title !== undefined) {
3175
+ await this.codexClient.start();
3176
+ await this.codexClient.setThreadName(session.externalSessionId, input.title ?? '');
3177
+ }
2728
3178
  const database = this.database;
2729
3179
  if (database === undefined)
2730
3180
  throw new Error('NODE_DATABASE_UNAVAILABLE');
@@ -2732,8 +3182,10 @@ export class NodeConnector {
2732
3182
  runner: 'codex',
2733
3183
  externalSessionId: session.externalSessionId,
2734
3184
  workspaceId: session.workspaceId,
2735
- runnerTitle: input.title,
2736
- runnerTitlePending: input.title !== null && input.title.trim().length > 0,
3185
+ ...(input.title === undefined ? {} : { runnerTitle: input.title }),
3186
+ ...(input.title === undefined
3187
+ ? {}
3188
+ : { runnerTitlePending: input.title !== null && input.title.trim().length > 0 }),
2737
3189
  ...(input.pinned === undefined ? {} : { pinned: input.pinned })
2738
3190
  });
2739
3191
  return;
@@ -2912,6 +3364,8 @@ export class NodeConnector {
2912
3364
  this.emitRunEvent(runId, 'run.running', { threadId, turnId }, 'RUNNING');
2913
3365
  }
2914
3366
  catch (error) {
3367
+ if (this.completeCancelledRun(runId, cwd))
3368
+ return;
2915
3369
  this.emitRunEvent(runId, 'run.failed', { code: error instanceof Error ? error.message : 'CODEX_RUN_FAILED' }, 'FAILED');
2916
3370
  this.codexRuns.delete(runId);
2917
3371
  this.activeRuns.delete(runId);
@@ -3236,7 +3690,8 @@ export class NodeConnector {
3236
3690
  if (run === undefined)
3237
3691
  return false;
3238
3692
  if (run.status === 'CANCELLING') {
3239
- this.emitRunEvent(runId, 'run.completed', { status: 'CANCELLED' }, 'CANCELLED');
3693
+ const status = this.interruptRequestedRuns.has(runId) ? 'INTERRUPTED' : 'CANCELLED';
3694
+ this.emitRunEvent(runId, 'run.completed', { status }, status);
3240
3695
  }
3241
3696
  else if (!isTerminalRunStatus(run.status)) {
3242
3697
  return false;
@@ -3248,6 +3703,15 @@ export class NodeConnector {
3248
3703
  return true;
3249
3704
  }
3250
3705
  presentSession(session) {
3706
+ if (session.externalSessionId !== null && this.database !== undefined) {
3707
+ const metadata = this.database.getNativeSessionMetadata(session.runner, session.externalSessionId, session.workspaceId);
3708
+ if (metadata !== undefined)
3709
+ session = {
3710
+ ...session,
3711
+ pinnedAt: metadata.pinnedAt,
3712
+ pinOrder: metadata.pinOrder
3713
+ };
3714
+ }
3251
3715
  if (session.runner !== 'codex') {
3252
3716
  const active = this.hasManagedActiveRun(session);
3253
3717
  if (this.capabilities.claudeCode.available && session.nativeControl === 'MAR_MANAGED') {
@@ -3326,7 +3790,11 @@ export class NodeConnector {
3326
3790
  : this.hasManagedActiveRun(session)
3327
3791
  ? 'MANAGED_ACTIVE'
3328
3792
  : session.nativeControl === 'EXTERNAL'
3329
- ? (this.codexExternalActivity.get(session.id) ?? 'UNAVAILABLE')
3793
+ ? // Discovery deliberately uses thread/list only. Until a selected
3794
+ // session is read, treat activity as idle/unknown; takeover and
3795
+ // message handling still perform the authoritative read immediately
3796
+ // before resuming the external thread.
3797
+ (this.codexExternalActivity.get(session.id) ?? 'IDLE')
3330
3798
  : 'IDLE';
3331
3799
  return {
3332
3800
  ...session,
@@ -3639,7 +4107,7 @@ export class NodeConnector {
3639
4107
  manifestVersion: 1,
3640
4108
  revision,
3641
4109
  observedAt: Date.now(),
3642
- platform: this.capabilities.platform === 'win32' ? 'windows' : 'linux',
4110
+ platform: this.capabilities.platform,
3643
4111
  architecture: this.capabilities.architecture,
3644
4112
  conversation: {
3645
4113
  available: true,
@@ -3673,7 +4141,7 @@ export class NodeConnector {
3673
4141
  available: true,
3674
4142
  reasonCode: null,
3675
4143
  supportsPty: this.capabilities.platform === 'linux',
3676
- supportsConPty: this.capabilities.platform === 'win32',
4144
+ supportsConPty: this.capabilities.platform === 'windows',
3677
4145
  supportsReplay: true,
3678
4146
  supportsReadonlyAttach: true,
3679
4147
  supportsTakeover: true,
@@ -4551,21 +5019,6 @@ function presentClaudeContextUsage(usage) {
4551
5019
  percentage: usage.percentage
4552
5020
  };
4553
5021
  }
4554
- async function mapWithConcurrency(values, concurrency, map) {
4555
- const result = new Array(values.length);
4556
- let next = 0;
4557
- const worker = async () => {
4558
- while (true) {
4559
- const index = next;
4560
- next += 1;
4561
- if (index >= values.length)
4562
- return;
4563
- result[index] = await map(values[index]);
4564
- }
4565
- };
4566
- await Promise.all(Array.from({ length: Math.min(concurrency, values.length) }, worker));
4567
- return result;
4568
- }
4569
5022
  function isNativeHistory(value) {
4570
5023
  return (typeof value === 'object' &&
4571
5024
  value !== null &&
@@ -4674,7 +5127,9 @@ function nativeUserClientMessageId(entry, createdAt, runtimeTurns) {
4674
5127
  if (clientMessageId === null || payload.text !== entry.text)
4675
5128
  continue;
4676
5129
  const runtimeCreatedAt = user.startedAt ?? turn.startedAt;
4677
- if (runtimeCreatedAt !== null && runtimeCreatedAt !== undefined && Math.abs(createdAt - runtimeCreatedAt) > 60_000)
5130
+ if (runtimeCreatedAt !== null &&
5131
+ runtimeCreatedAt !== undefined &&
5132
+ Math.abs(createdAt - runtimeCreatedAt) > 60_000)
4678
5133
  continue;
4679
5134
  return clientMessageId;
4680
5135
  }
@@ -5199,38 +5654,74 @@ function sessionPage(sessions, input) {
5199
5654
  input.limit <= MAX_SESSION_PAGE_SIZE
5200
5655
  ? input.limit
5201
5656
  : DEFAULT_SESSION_PAGE_SIZE;
5202
- const cursor = typeof input.cursor === 'string' ? parseSessionPageCursor(input.cursor) : undefined;
5203
- const ordered = [...sessions].sort((left, right) => right.lastActivityAt - left.lastActivityAt || left.id.localeCompare(right.id, 'en'));
5657
+ const cursor = typeof input.cursor === 'string' ? input.cursor : undefined;
5658
+ const ordered = [...sessions].sort(compareSessionPageEntries);
5659
+ const cursorEntry = cursor === undefined ? undefined : parseSessionPageCursor(cursor);
5204
5660
  const afterCursor = cursor === undefined
5205
5661
  ? ordered
5206
- : ordered.filter((session) => session.lastActivityAt < cursor.lastActivityAt ||
5207
- (session.lastActivityAt === cursor.lastActivityAt &&
5208
- session.id.localeCompare(cursor.id, 'en') > 0));
5209
- const page = afterCursor.slice(0, limit);
5662
+ : cursorEntry === undefined
5663
+ ? []
5664
+ : ordered.filter((session) => compareSessionPageEntries(session, cursorEntry) > 0);
5665
+ const pinned = ordered.filter((session) => session.pinOrder !== null && session.pinOrder !== undefined);
5666
+ const pinnedIndexes = new Map(pinned.map((session, index) => [session.id, index]));
5667
+ const page = afterCursor.slice(0, limit).map((session) => {
5668
+ const pinIndex = pinnedIndexes.get(session.id);
5669
+ if (pinIndex === undefined)
5670
+ return session;
5671
+ return {
5672
+ ...session,
5673
+ canMovePinUp: pinIndex > 0,
5674
+ canMovePinDown: pinIndex < pinned.length - 1
5675
+ };
5676
+ });
5210
5677
  const last = page.at(-1);
5211
5678
  return {
5212
5679
  sessions: page,
5213
5680
  nextCursor: last !== undefined && afterCursor.length > page.length ? createSessionPageCursor(last) : null
5214
5681
  };
5215
5682
  }
5216
- function createSessionPageCursor(session) {
5217
- return `${session.lastActivityAt}:${encodeURIComponent(session.id)}`;
5218
- }
5219
- function parseSessionPageCursor(value) {
5220
- const separator = value.indexOf(':');
5221
- if (separator <= 0 || separator === value.length - 1)
5222
- return undefined;
5223
- const lastActivityAt = Number(value.slice(0, separator));
5224
- if (!Number.isFinite(lastActivityAt))
5225
- return undefined;
5683
+ function parseSessionPageCursor(cursor) {
5226
5684
  try {
5227
- const id = decodeURIComponent(value.slice(separator + 1));
5228
- return id.length === 0 ? undefined : { lastActivityAt, id };
5685
+ const value = JSON.parse(decodeURIComponent(cursor));
5686
+ if (!Array.isArray(value) || value.length !== 3)
5687
+ return undefined;
5688
+ const [pinOrder, lastActivityAt, id] = value;
5689
+ if ((pinOrder !== null && (typeof pinOrder !== 'number' || !Number.isFinite(pinOrder))) ||
5690
+ (lastActivityAt !== null &&
5691
+ (typeof lastActivityAt !== 'number' || !Number.isFinite(lastActivityAt))) ||
5692
+ typeof id !== 'string')
5693
+ return undefined;
5694
+ return {
5695
+ id,
5696
+ lastActivityAt: lastActivityAt ?? 0,
5697
+ pinOrder,
5698
+ externalDiscovered: lastActivityAt !== null
5699
+ };
5229
5700
  }
5230
5701
  catch {
5231
5702
  return undefined;
5232
5703
  }
5233
5704
  }
5705
+ function createSessionPageCursor(session) {
5706
+ return encodeURIComponent(JSON.stringify([
5707
+ session.pinOrder ?? null,
5708
+ session.externalDiscovered === false ? null : session.lastActivityAt,
5709
+ session.id
5710
+ ]));
5711
+ }
5712
+ function compareSessionPageEntries(left, right) {
5713
+ const leftPinned = left.pinOrder !== null && left.pinOrder !== undefined;
5714
+ const rightPinned = right.pinOrder !== null && right.pinOrder !== undefined;
5715
+ if (leftPinned !== rightPinned)
5716
+ return leftPinned ? -1 : 1;
5717
+ if (leftPinned && rightPinned)
5718
+ return (left.pinOrder - right.pinOrder || left.id.localeCompare(right.id));
5719
+ const leftFound = left.externalDiscovered !== false;
5720
+ const rightFound = right.externalDiscovered !== false;
5721
+ if (leftFound !== rightFound)
5722
+ return leftFound ? -1 : 1;
5723
+ return right.lastActivityAt - left.lastActivityAt || left.id.localeCompare(right.id, 'en');
5724
+ }
5234
5725
  function compactRunnerText(value, limit) {
5235
5726
  return value.length <= limit ? value : `${value.slice(0, Math.max(0, limit - 1))}…`;
5236
5727
  }