@myagentroam/node 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/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, 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,16 @@ 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;
93
102
  stopped = false;
94
103
  config;
104
+ capabilitiesProvided;
95
105
  capabilities;
106
+ capabilityDetector;
96
107
  configPath;
97
108
  reconnect;
98
109
  claudeApprovalTimeoutMs;
@@ -158,6 +169,18 @@ export class NodeConnector {
158
169
  composerAttachments = new Map();
159
170
  /** Identical refreshes share one Git scan; separate Workspaces use bounded parallelism. */
160
171
  workspaceChangesReads = new Map();
172
+ workspaceChangesCache = new Map();
173
+ workspaceChangesVersions = new Map();
174
+ workspaceFileIndexes = new Map();
175
+ /** Tab-scoped Workspace observation leases; no state survives a Node restart. */
176
+ workspaceWatches = new Map();
177
+ workspaceWatchRevisions = new Map();
178
+ workspaceSessionWatchTimers = new Map();
179
+ workspaceSessionWatchSignatures = new Map();
180
+ workspaceSessionWatchReads = new Map();
181
+ /** Runner-native session discovery is expensive; cache only its short-lived baseline. */
182
+ workspaceSessionCache = new Map();
183
+ workspaceSessionCacheReads = new Map();
161
184
  workspaceChangesLimiter = new AsyncLimiter(3);
162
185
  database;
163
186
  /** Runtime/session state is intentionally not persisted to Node SQLite. */
@@ -166,7 +189,9 @@ export class NodeConnector {
166
189
  constructor(options = {}) {
167
190
  this.config = options.config;
168
191
  this.configPath = options.configPath;
169
- this.capabilities = options.capabilities ?? detectCapabilities();
192
+ this.capabilitiesProvided = options.capabilities !== undefined;
193
+ this.capabilities = options.capabilities ?? unavailableCapabilities();
194
+ this.capabilityDetector = options.capabilityDetector ?? detectCapabilitiesAsync;
170
195
  this.reconnect = options.reconnect ?? true;
171
196
  this.fakeRunner = options.fakeRunner;
172
197
  this.fakeScript = options.fakeScript;
@@ -179,7 +204,7 @@ export class NodeConnector {
179
204
  this.terminalManager =
180
205
  options.terminalManager ??
181
206
  new TerminalManager({
182
- platform: this.capabilities.platform
207
+ platform: this.capabilities.platform === 'windows' ? 'win32' : 'linux'
183
208
  });
184
209
  this.terminalManager.onSummary((summary) => this.persistTerminalSession(summary));
185
210
  this.terminalManager.onFrame((frame) => this.publishTerminalFrame(frame));
@@ -191,12 +216,15 @@ export class NodeConnector {
191
216
  this.database ??= new NodeDatabase(nodeDatabasePath(this.config, this.configPath), () => this.config?.nodeId ?? 'unregistered');
192
217
  this.database.closeRunningTerminalSessions();
193
218
  this.reconcilePersistedRuns();
219
+ this.startCapabilityRefresh();
194
220
  this.connect();
195
221
  }
196
222
  stop() {
197
223
  this.stopped = true;
198
224
  if (this.heartbeat !== undefined)
199
225
  clearInterval(this.heartbeat);
226
+ if (this.capabilityRefreshTimer !== undefined)
227
+ clearInterval(this.capabilityRefreshTimer);
200
228
  if (this.reconnectTimer !== undefined)
201
229
  clearTimeout(this.reconnectTimer);
202
230
  if (this.terminalReconnectTimer !== undefined)
@@ -233,6 +261,22 @@ export class NodeConnector {
233
261
  for (const upload of this.workspaceUploads.values())
234
262
  void this.removeWorkspaceUpload(upload);
235
263
  this.workspaceUploads.clear();
264
+ for (const index of this.workspaceFileIndexes.values())
265
+ index.dispose();
266
+ this.workspaceFileIndexes.clear();
267
+ for (const watches of this.workspaceWatches.values())
268
+ for (const watch of watches.values())
269
+ clearTimeout(watch.timer);
270
+ this.workspaceWatches.clear();
271
+ for (const timer of this.workspaceSessionWatchTimers.values())
272
+ clearInterval(timer);
273
+ this.workspaceSessionWatchTimers.clear();
274
+ this.workspaceSessionWatchSignatures.clear();
275
+ this.workspaceSessionWatchReads.clear();
276
+ this.workspaceSessionCache.clear();
277
+ this.workspaceSessionCacheReads.clear();
278
+ this.workspaceChangesCache.clear();
279
+ this.workspaceChangesVersions.clear();
236
280
  for (const attachment of this.composerAttachments.values())
237
281
  void rm(dirname(attachment.targetPath), { recursive: true, force: true });
238
282
  this.composerAttachments.clear();
@@ -245,6 +289,39 @@ export class NodeConnector {
245
289
  this.database?.close();
246
290
  this.database = undefined;
247
291
  }
292
+ startCapabilityRefresh() {
293
+ if (this.capabilityRefreshTimer !== undefined)
294
+ clearInterval(this.capabilityRefreshTimer);
295
+ if (!this.capabilitiesProvided)
296
+ void this.refreshCapabilities();
297
+ this.capabilityRefreshTimer = setInterval(() => void this.refreshCapabilities(), CAPABILITY_REFRESH_MS);
298
+ }
299
+ async refreshCapabilities() {
300
+ if (this.stopped || this.capabilityRefreshInFlight)
301
+ return;
302
+ this.capabilityRefreshInFlight = true;
303
+ let next;
304
+ try {
305
+ next = await this.capabilityDetector();
306
+ }
307
+ catch {
308
+ // A failed local probe keeps Node metadata but safely disables both Runner capabilities.
309
+ next = {
310
+ ...this.capabilities,
311
+ codex: { available: false },
312
+ claudeCode: { available: false }
313
+ };
314
+ }
315
+ try {
316
+ if (this.stopped || JSON.stringify(next) === JSON.stringify(this.capabilities))
317
+ return;
318
+ this.capabilities = next;
319
+ this.send('capabilities', next);
320
+ }
321
+ finally {
322
+ this.capabilityRefreshInFlight = false;
323
+ }
324
+ }
248
325
  connect() {
249
326
  const config = this.config;
250
327
  if (config === undefined)
@@ -570,7 +647,7 @@ export class NodeConnector {
570
647
  // available. Do not leave the local lease in CANCELLING indefinitely:
571
648
  // an already-created, but not yet controllable, Codex run is checked
572
649
  // again immediately after turn/start resolves.
573
- if (envelope.type === 'run.cancel' && codexRun === undefined && claudeRun === undefined) {
650
+ if (codexRun?.turnId === undefined && claudeRun === undefined) {
574
651
  const run = this.runtime.getRun(runId);
575
652
  if (run?.status === 'CANCELLING')
576
653
  this.completeCancelledRun(runId, undefined);
@@ -612,7 +689,14 @@ export class NodeConnector {
612
689
  return;
613
690
  }
614
691
  try {
615
- const result = await this.executeNodeOperation(payload.operation, payload.data);
692
+ const data = payload.data !== null && typeof payload.data === 'object'
693
+ ? {
694
+ ...payload.data,
695
+ __workspaceWatchSessionHash: payload.userAuthorization
696
+ .sessionHash
697
+ }
698
+ : payload.data;
699
+ const result = await this.executeNodeOperation(payload.operation, data);
616
700
  this.send('node.response', { result }, envelope.id);
617
701
  }
618
702
  catch (error) {
@@ -661,6 +745,12 @@ export class NodeConnector {
661
745
  throw new Error('WORKSPACE_BUSY');
662
746
  const workspace = database.removeWorkspace(input.workspaceId);
663
747
  this.runtime.removeWorkspace(input.workspaceId);
748
+ this.workspaceFileIndexes.get(workspace.id)?.dispose();
749
+ this.workspaceFileIndexes.delete(workspace.id);
750
+ this.invalidateWorkspaceChanges(workspace.id);
751
+ this.workspaceSessionCache.delete(workspace.id);
752
+ this.workspaceSessionCacheReads.delete(workspace.id);
753
+ this.clearWorkspaceWatches(workspace.id);
664
754
  return { workspace };
665
755
  }
666
756
  if (operation === 'workspace.list')
@@ -737,6 +827,10 @@ export class NodeConnector {
737
827
  typeof input.customTitle !== 'string') ||
738
828
  (input.pinned !== undefined && typeof input.pinned !== 'boolean'))
739
829
  throw new Error('SESSION_INVALID');
830
+ if (input.customTitle === null || input.customTitle?.trim().length === 0)
831
+ throw new Error('SESSION_TITLE_REQUIRED');
832
+ if (input.customTitle !== undefined && input.customTitle.trim().length > 160)
833
+ throw new Error('SESSION_TITLE_INVALID');
740
834
  const current = this.runtime.getAgentSession(input.sessionId) ??
741
835
  (await this.createMetadataProjection(input.sessionId));
742
836
  if (current === undefined)
@@ -760,6 +854,13 @@ export class NodeConnector {
760
854
  : updated)
761
855
  };
762
856
  }
857
+ if (operation === 'session.pin.move') {
858
+ if (typeof input.sessionId !== 'string' ||
859
+ (input.direction !== 'up' && input.direction !== 'down'))
860
+ throw new Error('SESSION_PIN_MOVE_INVALID');
861
+ database.movePinnedSession(input.sessionId, input.direction);
862
+ return { moved: true };
863
+ }
763
864
  if (operation === 'session.remove') {
764
865
  if (typeof input.sessionId !== 'string')
765
866
  throw new Error('SESSION_INVALID');
@@ -777,8 +878,16 @@ export class NodeConnector {
777
878
  throw new Error('SESSION_ACTIVE');
778
879
  }
779
880
  const nativeSessionIds = this.nativeSessionIdsForProjection(session);
780
- for (const externalSessionId of nativeSessionIds)
781
- await removeNativeSession(session.runner, session.cwd, externalSessionId);
881
+ for (const externalSessionId of nativeSessionIds) {
882
+ try {
883
+ await removeNativeSession(session.runner, session.cwd, externalSessionId);
884
+ }
885
+ catch (error) {
886
+ if (!(error instanceof Error) || error.message !== 'NATIVE_TRANSCRIPT_UNAVAILABLE')
887
+ throw error;
888
+ }
889
+ }
890
+ database.deleteSessionRecord(session.id);
782
891
  this.forgetNativeSessionProjection(session, nativeSessionIds);
783
892
  return { deleted: true };
784
893
  }
@@ -882,6 +991,50 @@ export class NodeConnector {
882
991
  ...(typeof input.limit === 'number' ? { limit: input.limit } : {})
883
992
  });
884
993
  }
994
+ if (operation === 'workspace.watch') {
995
+ if (typeof input.workspaceId !== 'string' ||
996
+ typeof input.watchId !== 'string' ||
997
+ !/^[A-Za-z0-9_-]{8,128}$/.test(input.watchId) ||
998
+ (input.mode !== 'initial' && input.mode !== 'renew'))
999
+ throw new Error('WORKSPACE_WATCH_INVALID');
1000
+ const workspace = this.requireWorkspace(input.workspaceId);
1001
+ if (input.mode === 'renew') {
1002
+ const watch = this.renewWorkspaceWatch(workspace.id, this.workspaceWatchKey(input));
1003
+ if (watch === undefined)
1004
+ throw new Error('WORKSPACE_WATCH_NOT_FOUND');
1005
+ return { renewed: true };
1006
+ }
1007
+ const topics = this.parseWorkspaceWatchTopics(input);
1008
+ const snapshot = {};
1009
+ if (topics.sessions)
1010
+ snapshot.sessions = await this.executeNodeOperation('session.list', {
1011
+ workspaceId: workspace.id,
1012
+ limit: 20
1013
+ });
1014
+ if (topics.files !== undefined)
1015
+ snapshot.files = {
1016
+ files: await this.listFilesForWorkspace(workspace, topics.files)
1017
+ };
1018
+ if (topics.changes)
1019
+ snapshot.changes =
1020
+ workspace.kind === 'GIT_WORKSPACE'
1021
+ ? await this.currentChangesForWorkspace(workspace)
1022
+ : null;
1023
+ // An initial snapshot is the observation baseline. Do not retain a
1024
+ // background lease when producing that baseline has failed.
1025
+ if (topics.files !== undefined || topics.changes)
1026
+ await this.workspaceFileIndex(workspace).observe();
1027
+ const watch = this.createWorkspaceWatch(workspace.id, input.watchId, this.workspaceWatchSessionHash(input), topics);
1028
+ return { watch: { expiresAt: watch.expiresAt }, snapshot };
1029
+ }
1030
+ if (operation === 'workspace.unwatch') {
1031
+ if (typeof input.workspaceId !== 'string' ||
1032
+ typeof input.watchId !== 'string' ||
1033
+ !/^[A-Za-z0-9_-]{8,128}$/.test(input.watchId))
1034
+ throw new Error('WORKSPACE_WATCH_INVALID');
1035
+ this.removeWorkspaceWatch(input.workspaceId, this.workspaceWatchKey(input));
1036
+ return { stopped: true };
1037
+ }
885
1038
  if (operation === 'workspace.files.list') {
886
1039
  const workspace = this.requireWorkspace(input.workspaceId);
887
1040
  return { files: await this.listFilesForWorkspace(workspace, input) };
@@ -943,7 +1096,7 @@ export class NodeConnector {
943
1096
  throw new Error('WORKSPACE_BUSY');
944
1097
  await restoreCurrentChange(workspace.path, input.path, input.state, this.fileAccessOptions());
945
1098
  // A read started before the write must not satisfy the explicit post-write refresh.
946
- this.workspaceChangesReads.delete(workspace.id);
1099
+ this.invalidateWorkspaceWatchTopics(workspace.id);
947
1100
  return { restored: true };
948
1101
  }
949
1102
  if (operation === 'workspace.diff') {
@@ -999,16 +1152,6 @@ export class NodeConnector {
999
1152
  if (input.bootstrapOnly === true)
1000
1153
  this.bootstrapSessionIds.add(session.id);
1001
1154
  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
1155
  if (input.bootstrapOnly !== true)
1013
1156
  this.emitWorkbenchEvent('session', { session: this.presentSession(session) });
1014
1157
  return {
@@ -1144,11 +1287,14 @@ export class NodeConnector {
1144
1287
  };
1145
1288
  }
1146
1289
  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));
1290
+ const workspaceId = typeof input.workspaceId === 'string' ? input.workspaceId : undefined;
1291
+ // Pagination is only correct after persisted records and the current
1292
+ // Runner discovery have been merged. A selected Workspace therefore
1293
+ // performs discovery inside this authoritative list operation.
1294
+ const nativeSessions = workspaceId === undefined
1295
+ ? [...this.directNativeSessions.values()]
1296
+ : await this.cachedWorkspaceSessions(workspaceId);
1297
+ const native = nativeSessions.map((session) => this.presentSession({ ...session, externalDiscovered: true }));
1152
1298
  const projected = this.runtime
1153
1299
  .listAgentSessions()
1154
1300
  .filter((session) => session.nativeControl === 'MAR_MANAGED' ||
@@ -1160,17 +1306,51 @@ export class NodeConnector {
1160
1306
  : {
1161
1307
  ...session,
1162
1308
  runnerTitle: source.runnerTitle,
1309
+ pinnedAt: source.pinnedAt,
1310
+ pinOrder: source.pinOrder ?? null,
1311
+ externalDiscovered: true,
1163
1312
  titleSource: session.customTitle === null ? source.titleSource : session.titleSource,
1164
1313
  lastActivityAt: source.lastActivityAt
1165
1314
  });
1166
1315
  });
1167
- const allSessions = [
1316
+ const merged = new Map([
1168
1317
  ...projected,
1169
1318
  ...native.filter((native) => native.externalSessionId === null ||
1170
1319
  this.sessionProjectionForNativeRecord(native) === undefined)
1171
- ];
1172
- return sessionPage(typeof input.workspaceId === 'string'
1173
- ? allSessions.filter((session) => session.workspaceId === input.workspaceId)
1320
+ ].map((session) => [session.id, session]));
1321
+ if (workspaceId !== undefined) {
1322
+ const workspace = database.getWorkspace(workspaceId);
1323
+ if (workspace === undefined)
1324
+ throw new Error('WORKSPACE_NOT_FOUND');
1325
+ for (const record of database.listSessionRecords(workspaceId)) {
1326
+ if (merged.has(record.id))
1327
+ continue;
1328
+ merged.set(record.id, this.presentSession({
1329
+ id: record.id,
1330
+ nodeId: this.config?.nodeId ?? 'recorded-session',
1331
+ workspaceId,
1332
+ runner: record.runner,
1333
+ externalSessionId: record.id,
1334
+ nativeControl: 'EXTERNAL',
1335
+ channelToken: null,
1336
+ cwd: workspace.path,
1337
+ model: record.metadata.model,
1338
+ effort: record.metadata.effort,
1339
+ access: record.metadata.access,
1340
+ customTitle: record.metadata.customTitle,
1341
+ runnerTitle: record.metadata.runnerTitle,
1342
+ pinnedAt: record.pinOrder === null ? null : 1,
1343
+ pinOrder: record.pinOrder,
1344
+ externalDiscovered: false,
1345
+ titleSource: record.metadata.customTitle === null ? 'RUNNER' : 'CUSTOM',
1346
+ lastActivityAt: 0,
1347
+ createdAt: 0
1348
+ }));
1349
+ }
1350
+ }
1351
+ const allSessions = [...merged.values()];
1352
+ return sessionPage(workspaceId !== undefined
1353
+ ? allSessions.filter((session) => session.workspaceId === workspaceId)
1174
1354
  : allSessions, input);
1175
1355
  }
1176
1356
  if (operation === 'session.discover') {
@@ -1220,18 +1400,26 @@ export class NodeConnector {
1220
1400
  (await this.resolveDirectNativeSession(input.sessionId));
1221
1401
  if (value === undefined)
1222
1402
  throw new Error('SESSION_NOT_FOUND');
1223
- await this.refreshCodexExternalActivity(value);
1224
1403
  return { session: this.presentSession(value) };
1225
1404
  }
1226
1405
  if (operation === 'session.watch') {
1227
- if (typeof input.sessionId !== 'string')
1406
+ if (typeof input.sessionId !== 'string' ||
1407
+ (input.mode !== 'initial' && input.mode !== 'renew'))
1228
1408
  throw new Error('SESSION_INVALID');
1229
1409
  const session = this.runtime.getAgentSession(input.sessionId) ??
1230
1410
  (await this.resolveDirectNativeSession(input.sessionId));
1231
1411
  if (session === undefined || session.externalSessionId === null)
1232
1412
  throw new Error('SESSION_NOT_FOUND');
1413
+ if (input.mode === 'renew') {
1414
+ const watch = this.renewNativeSessionWatch(session);
1415
+ if (watch === undefined)
1416
+ throw new Error('SESSION_WATCH_NOT_FOUND');
1417
+ return { renewed: true };
1418
+ }
1233
1419
  const watch = this.watchNativeSession(session);
1234
- const snapshot = watch === undefined ? undefined : await this.refreshWatchedSession(session.id);
1420
+ const snapshot = watch !== undefined
1421
+ ? await this.refreshWatchedSession(session.id, SESSION_WATCH_INITIAL_TURN_LIMIT)
1422
+ : undefined;
1235
1423
  if (watch !== undefined && snapshot === undefined)
1236
1424
  throw new Error('NATIVE_TRANSCRIPT_UNAVAILABLE');
1237
1425
  return {
@@ -1650,6 +1838,174 @@ export class NodeConnector {
1650
1838
  throw new Error('WORKSPACE_NOT_FOUND');
1651
1839
  return workspace;
1652
1840
  }
1841
+ parseWorkspaceWatchTopics(input) {
1842
+ if (!isPlainRecord(input.topics))
1843
+ throw new Error('WORKSPACE_WATCH_INVALID');
1844
+ const sessions = input.topics.sessions === true;
1845
+ const changes = input.topics.changes === true;
1846
+ const rawFiles = input.topics.files;
1847
+ let files;
1848
+ if (rawFiles !== undefined) {
1849
+ if (!isPlainRecord(rawFiles) || typeof rawFiles.path !== 'string')
1850
+ throw new Error('WORKSPACE_WATCH_INVALID');
1851
+ const limit = rawFiles.limit;
1852
+ if ((rawFiles.cursor !== undefined && typeof rawFiles.cursor !== 'string') ||
1853
+ (limit !== undefined &&
1854
+ (typeof limit !== 'number' || !Number.isInteger(limit) || limit < 1 || limit > 200)))
1855
+ throw new Error('WORKSPACE_WATCH_INVALID');
1856
+ files = {
1857
+ path: rawFiles.path,
1858
+ ...(typeof rawFiles.cursor === 'string' ? { cursor: rawFiles.cursor } : {}),
1859
+ ...(typeof rawFiles.limit === 'number' ? { limit: rawFiles.limit } : {})
1860
+ };
1861
+ }
1862
+ if (!sessions && !changes && files === undefined)
1863
+ throw new Error('WORKSPACE_WATCH_INVALID');
1864
+ return { sessions, changes, files };
1865
+ }
1866
+ workspaceWatchKey(input) {
1867
+ return `${this.workspaceWatchSessionHash(input)}:${input.watchId}`;
1868
+ }
1869
+ workspaceWatchSessionHash(input) {
1870
+ return typeof input.__workspaceWatchSessionHash === 'string'
1871
+ ? input.__workspaceWatchSessionHash
1872
+ : 'local-test';
1873
+ }
1874
+ createWorkspaceWatch(workspaceId, watchId, sessionHash, topics) {
1875
+ const key = `${sessionHash}:${watchId}`;
1876
+ this.removeWorkspaceWatch(workspaceId, key);
1877
+ const watch = {
1878
+ workspaceId,
1879
+ watchId,
1880
+ sessionHash,
1881
+ expiresAt: Date.now() + WORKSPACE_WATCH_TTL_MS,
1882
+ topics,
1883
+ timer: undefined
1884
+ };
1885
+ watch.timer = setTimeout(() => this.removeWorkspaceWatch(workspaceId, key), WORKSPACE_WATCH_TTL_MS);
1886
+ watch.timer.unref();
1887
+ const watches = this.workspaceWatches.get(workspaceId) ?? new Map();
1888
+ watches.set(key, watch);
1889
+ this.workspaceWatches.set(workspaceId, watches);
1890
+ if (topics.sessions)
1891
+ this.ensureWorkspaceSessionWatch(workspaceId);
1892
+ return watch;
1893
+ }
1894
+ renewWorkspaceWatch(workspaceId, watchId) {
1895
+ const watch = this.workspaceWatches.get(workspaceId)?.get(watchId);
1896
+ if (watch === undefined || watch.expiresAt <= Date.now()) {
1897
+ this.removeWorkspaceWatch(workspaceId, watchId);
1898
+ return undefined;
1899
+ }
1900
+ clearTimeout(watch.timer);
1901
+ watch.expiresAt = Date.now() + WORKSPACE_WATCH_TTL_MS;
1902
+ watch.timer = setTimeout(() => this.removeWorkspaceWatch(workspaceId, watchId), WORKSPACE_WATCH_TTL_MS);
1903
+ watch.timer.unref();
1904
+ return watch;
1905
+ }
1906
+ removeWorkspaceWatch(workspaceId, watchId) {
1907
+ const watches = this.workspaceWatches.get(workspaceId);
1908
+ const watch = watches?.get(watchId);
1909
+ if (watch === undefined)
1910
+ return;
1911
+ clearTimeout(watch.timer);
1912
+ watches?.delete(watchId);
1913
+ if (watches?.size === 0)
1914
+ this.workspaceWatches.delete(workspaceId);
1915
+ if (!this.workspaceWatchTopicActive(workspaceId, 'sessions'))
1916
+ this.stopWorkspaceSessionWatch(workspaceId);
1917
+ }
1918
+ clearWorkspaceWatches(workspaceId) {
1919
+ const watches = this.workspaceWatches.get(workspaceId);
1920
+ if (watches === undefined)
1921
+ return;
1922
+ for (const watch of watches.values())
1923
+ clearTimeout(watch.timer);
1924
+ this.workspaceWatches.delete(workspaceId);
1925
+ this.workspaceWatchRevisions.delete(workspaceId);
1926
+ this.stopWorkspaceSessionWatch(workspaceId);
1927
+ }
1928
+ ensureWorkspaceSessionWatch(workspaceId) {
1929
+ if (this.workspaceSessionWatchTimers.has(workspaceId))
1930
+ return;
1931
+ const timer = setInterval(() => void this.refreshWorkspaceSessionWatch(workspaceId), WORKSPACE_SESSION_WATCH_INTERVAL_MS);
1932
+ timer.unref();
1933
+ this.workspaceSessionWatchTimers.set(workspaceId, timer);
1934
+ void this.refreshWorkspaceSessionWatch(workspaceId);
1935
+ }
1936
+ stopWorkspaceSessionWatch(workspaceId) {
1937
+ const timer = this.workspaceSessionWatchTimers.get(workspaceId);
1938
+ if (timer !== undefined)
1939
+ clearInterval(timer);
1940
+ this.workspaceSessionWatchTimers.delete(workspaceId);
1941
+ this.workspaceSessionWatchSignatures.delete(workspaceId);
1942
+ this.workspaceSessionWatchReads.delete(workspaceId);
1943
+ }
1944
+ async cachedWorkspaceSessions(workspaceId) {
1945
+ const cached = this.workspaceSessionCache.get(workspaceId);
1946
+ if (cached !== undefined && cached.expiresAt > Date.now())
1947
+ return cached.sessions;
1948
+ const inFlight = this.workspaceSessionCacheReads.get(workspaceId);
1949
+ if (inFlight !== undefined)
1950
+ return inFlight;
1951
+ const discovery = this.discoverWorkspaceSessions(workspaceId)
1952
+ .then((sessions) => {
1953
+ this.workspaceSessionCache.set(workspaceId, {
1954
+ sessions,
1955
+ expiresAt: Date.now() + WORKSPACE_SESSION_CACHE_TTL_MS
1956
+ });
1957
+ return sessions;
1958
+ })
1959
+ .finally(() => this.workspaceSessionCacheReads.delete(workspaceId));
1960
+ this.workspaceSessionCacheReads.set(workspaceId, discovery);
1961
+ return discovery;
1962
+ }
1963
+ async refreshWorkspaceSessionWatch(workspaceId) {
1964
+ const current = this.workspaceSessionWatchReads.get(workspaceId);
1965
+ if (current !== undefined)
1966
+ return current;
1967
+ const refresh = this.performWorkspaceSessionWatchRefresh(workspaceId).finally(() => {
1968
+ this.workspaceSessionWatchReads.delete(workspaceId);
1969
+ });
1970
+ this.workspaceSessionWatchReads.set(workspaceId, refresh);
1971
+ return refresh;
1972
+ }
1973
+ async performWorkspaceSessionWatchRefresh(workspaceId) {
1974
+ if (!this.workspaceWatchTopicActive(workspaceId, 'sessions'))
1975
+ return this.stopWorkspaceSessionWatch(workspaceId);
1976
+ try {
1977
+ const page = await this.executeNodeOperation('session.list', { workspaceId, limit: 20 });
1978
+ const signature = JSON.stringify(page);
1979
+ const previous = this.workspaceSessionWatchSignatures.get(workspaceId);
1980
+ this.workspaceSessionWatchSignatures.set(workspaceId, signature);
1981
+ if (previous === undefined || previous === signature)
1982
+ return;
1983
+ const revision = (this.workspaceWatchRevisions.get(workspaceId) ?? 0) + 1;
1984
+ this.workspaceWatchRevisions.set(workspaceId, revision);
1985
+ this.emitWorkbenchEvent('workspace', { workspaceId, topic: 'sessions', revision });
1986
+ }
1987
+ catch {
1988
+ // A later bounded interval retries; no stale session state is fabricated.
1989
+ }
1990
+ }
1991
+ workspaceWatchTopicActive(workspaceId, topic) {
1992
+ return [...(this.workspaceWatches.get(workspaceId)?.values() ?? [])].some((watch) => topic === 'files' ? watch.topics.files !== undefined : watch.topics[topic]);
1993
+ }
1994
+ invalidateWorkspaceWatchTopics(workspaceId) {
1995
+ this.invalidateWorkspaceChanges(workspaceId);
1996
+ for (const topic of ['files', 'changes']) {
1997
+ if (!this.workspaceWatchTopicActive(workspaceId, topic))
1998
+ continue;
1999
+ const revision = (this.workspaceWatchRevisions.get(workspaceId) ?? 0) + 1;
2000
+ this.workspaceWatchRevisions.set(workspaceId, revision);
2001
+ this.emitWorkbenchEvent('workspace', { workspaceId, topic, revision });
2002
+ }
2003
+ }
2004
+ invalidateWorkspaceChanges(workspaceId) {
2005
+ this.workspaceChangesReads.delete(workspaceId);
2006
+ this.workspaceChangesCache.delete(workspaceId);
2007
+ this.workspaceChangesVersions.set(workspaceId, (this.workspaceChangesVersions.get(workspaceId) ?? 0) + 1);
2008
+ }
1653
2009
  fileAccessOptions() {
1654
2010
  if (this.config === undefined)
1655
2011
  return {};
@@ -1688,7 +2044,27 @@ export class NodeConnector {
1688
2044
  input.limit < 1 ||
1689
2045
  input.limit > 200)))
1690
2046
  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());
2047
+ const index = this.workspaceFileIndex(workspace);
2048
+ return index.search(input.query, typeof input.cursor === 'string' ? input.cursor : undefined, typeof input.limit === 'number' ? input.limit : undefined);
2049
+ }
2050
+ workspaceFileIndex(workspace) {
2051
+ let index = this.workspaceFileIndexes.get(workspace.id);
2052
+ if (index === undefined) {
2053
+ if (this.workspaceFileIndexes.size >= WORKSPACE_FILE_INDEX_CACHE_LIMIT) {
2054
+ const oldest = this.workspaceFileIndexes.entries().next().value;
2055
+ if (oldest !== undefined) {
2056
+ const [oldestWorkspaceId, oldestIndex] = oldest;
2057
+ oldestIndex.dispose();
2058
+ this.workspaceFileIndexes.delete(oldestWorkspaceId);
2059
+ }
2060
+ }
2061
+ index = new WorkspaceFileIndex(workspace.path, this.fileAccessOptions(), {
2062
+ onInvalidated: () => this.invalidateWorkspaceWatchTopics(workspace.id)
2063
+ });
2064
+ }
2065
+ this.workspaceFileIndexes.delete(workspace.id);
2066
+ this.workspaceFileIndexes.set(workspace.id, index);
2067
+ return index;
1692
2068
  }
1693
2069
  async preflightWorkspaceUploads(workspace, input) {
1694
2070
  if ((input.directory !== undefined && typeof input.directory !== 'string') ||
@@ -1816,6 +2192,7 @@ export class NodeConnector {
1816
2192
  }
1817
2193
  clearTimeout(upload.timer);
1818
2194
  this.workspaceUploads.delete(upload.id);
2195
+ this.invalidateWorkspaceWatchTopics(upload.workspaceId);
1819
2196
  if (upload.composerAttachment !== undefined) {
1820
2197
  this.composerAttachments.set(upload.composerAttachment.id, {
1821
2198
  id: upload.composerAttachment.id,
@@ -1918,12 +2295,16 @@ export class NodeConnector {
1918
2295
  async currentChangesForWorkspace(workspace) {
1919
2296
  if (workspace.kind !== 'GIT_WORKSPACE')
1920
2297
  throw new Error('WORKSPACE_NOT_GIT');
2298
+ const cached = this.workspaceChangesCache.get(workspace.id);
2299
+ if (cached !== undefined && cached.expiresAt > Date.now())
2300
+ return cached.value;
1921
2301
  const existing = this.workspaceChangesReads.get(workspace.id);
1922
2302
  if (existing !== undefined)
1923
2303
  return existing;
2304
+ const version = this.workspaceChangesVersions.get(workspace.id) ?? 0;
1924
2305
  const request = this.workspaceChangesLimiter.run(async () => {
1925
2306
  const summary = await readCurrentChangesSummary(workspace.path);
1926
- return {
2307
+ const value = {
1927
2308
  branch: summary.branch,
1928
2309
  changes: summary.changes.map((change) => ({
1929
2310
  ...change,
@@ -1936,6 +2317,12 @@ export class NodeConnector {
1936
2317
  diffAvailable: change.state !== 'UNTRACKED'
1937
2318
  }))
1938
2319
  };
2320
+ if ((this.workspaceChangesVersions.get(workspace.id) ?? 0) === version)
2321
+ this.workspaceChangesCache.set(workspace.id, {
2322
+ value,
2323
+ expiresAt: Date.now() + WORKSPACE_CHANGES_CACHE_TTL_MS
2324
+ });
2325
+ return value;
1939
2326
  });
1940
2327
  this.workspaceChangesReads.set(workspace.id, request);
1941
2328
  try {
@@ -2005,34 +2392,19 @@ export class NodeConnector {
2005
2392
  officialCandidates.push({ thread, workspace: targetWorkspace, cwd });
2006
2393
  }
2007
2394
  // 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.
2395
+ // source coverage, but it is independent of App Server discovery. Start
2396
+ // both paths together. The list response is the discovery source; do not
2397
+ // read every thread here because most external threads are not loaded by
2398
+ // this App Server and would be reported as unavailable anyway.
2011
2399
  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);
2400
+ const sessions = officialCandidates.map((candidate) => this.directNativeSession({
2401
+ workspace: candidate.workspace,
2402
+ runner: 'codex',
2403
+ externalSessionId: candidate.thread.id,
2404
+ cwd: candidate.cwd,
2405
+ ...(candidate.thread.title === undefined ? {} : { title: candidate.thread.title }),
2406
+ titleOrigin: 'OFFICIAL'
2407
+ }));
2036
2408
  const native = await nativePromise;
2037
2409
  for (const entry of native) {
2038
2410
  const targetWorkspace = workspace ??
@@ -2132,11 +2504,74 @@ export class NodeConnector {
2132
2504
  this.rememberDirectNativeSessions(sessions);
2133
2505
  return sessions;
2134
2506
  }
2507
+ async discoverWorkspaceSessions(workspaceId) {
2508
+ const database = this.database;
2509
+ if (database === undefined)
2510
+ throw new Error('NODE_DATABASE_UNAVAILABLE');
2511
+ const workspace = database.getWorkspace(workspaceId);
2512
+ if (workspace === undefined)
2513
+ throw new Error('WORKSPACE_NOT_FOUND');
2514
+ const [codex, claude] = await Promise.all([
2515
+ this.discoverCodexSessions(workspaceId).then((result) => result.sessions),
2516
+ (async () => {
2517
+ let official = [];
2518
+ try {
2519
+ official = await this.claudeClient.listSessions(workspace.path);
2520
+ }
2521
+ catch {
2522
+ // Local transcript discovery remains authoritative when the SDK list is unavailable.
2523
+ }
2524
+ const native = await discoverNativeSessions('claude-code', workspace.path);
2525
+ return this.directClaudeSessions(workspace, 'claude-code', [
2526
+ ...official.flatMap((item) => {
2527
+ const parsed = claudeDiscoveredSession(item, workspace.path);
2528
+ return parsed === undefined ? [] : [parsed];
2529
+ }),
2530
+ ...native
2531
+ ]);
2532
+ })()
2533
+ ]);
2534
+ const sessions = deduplicateDirectSessions([...codex, ...claude]);
2535
+ this.rememberDirectNativeSessions(sessions);
2536
+ return sessions;
2537
+ }
2135
2538
  async resolveDirectNativeSession(sessionId) {
2136
2539
  const cached = this.directNativeSessions.get(sessionId);
2137
2540
  if (cached !== undefined)
2138
2541
  return cached;
2139
- return (await this.listDirectNativeSessions()).find((session) => session.id === sessionId);
2542
+ const discovered = (await this.listDirectNativeSessions()).find((session) => session.id === sessionId);
2543
+ if (discovered !== undefined)
2544
+ return discovered;
2545
+ const record = this.database
2546
+ ?.listWorkspaces()
2547
+ .flatMap((workspace) => this.database?.listSessionRecords(workspace.id) ?? [])
2548
+ .find((candidate) => candidate.id === sessionId);
2549
+ if (record === undefined)
2550
+ return undefined;
2551
+ const workspace = this.database?.getWorkspace(record.workspaceId);
2552
+ if (workspace === undefined)
2553
+ return undefined;
2554
+ return {
2555
+ id: record.id,
2556
+ nodeId: this.config?.nodeId ?? 'recorded-session',
2557
+ workspaceId: record.workspaceId,
2558
+ runner: record.runner,
2559
+ externalSessionId: record.id,
2560
+ nativeControl: 'EXTERNAL',
2561
+ channelToken: null,
2562
+ cwd: workspace.path,
2563
+ model: record.metadata.model,
2564
+ effort: record.metadata.effort,
2565
+ access: record.metadata.access,
2566
+ customTitle: record.metadata.customTitle,
2567
+ runnerTitle: record.metadata.runnerTitle,
2568
+ pinnedAt: record.pinOrder === null ? null : 1,
2569
+ pinOrder: record.pinOrder,
2570
+ externalDiscovered: false,
2571
+ titleSource: record.metadata.customTitle === null ? 'RUNNER' : 'CUSTOM',
2572
+ lastActivityAt: 0,
2573
+ createdAt: 0
2574
+ };
2140
2575
  }
2141
2576
  /**
2142
2577
  * A rename is Node-local metadata, so an external transcript receives a
@@ -2419,6 +2854,19 @@ export class NodeConnector {
2419
2854
  this.sessionWatches.set(session.id, watch);
2420
2855
  return { expiresAt: watch.expiresAt };
2421
2856
  }
2857
+ renewNativeSessionWatch(session) {
2858
+ if (this.hasManagedActiveRun(session)) {
2859
+ this.stopNativeSessionWatch(session.id);
2860
+ return undefined;
2861
+ }
2862
+ const watch = this.sessionWatches.get(session.id);
2863
+ if (watch === undefined || watch.expiresAt <= Date.now()) {
2864
+ this.stopNativeSessionWatch(session.id);
2865
+ return undefined;
2866
+ }
2867
+ watch.expiresAt = Date.now() + SESSION_WATCH_TTL_MS;
2868
+ return { expiresAt: watch.expiresAt };
2869
+ }
2422
2870
  stopNativeSessionWatch(sessionId) {
2423
2871
  const watch = this.sessionWatches.get(sessionId);
2424
2872
  if (watch === undefined)
@@ -2426,7 +2874,7 @@ export class NodeConnector {
2426
2874
  clearInterval(watch.timer);
2427
2875
  this.sessionWatches.delete(sessionId);
2428
2876
  }
2429
- async refreshWatchedSession(sessionId) {
2877
+ async refreshWatchedSession(sessionId, limit = 10) {
2430
2878
  const watch = this.sessionWatches.get(sessionId);
2431
2879
  if (watch === undefined)
2432
2880
  return undefined;
@@ -2436,7 +2884,7 @@ export class NodeConnector {
2436
2884
  this.stopNativeSessionWatch(sessionId);
2437
2885
  return undefined;
2438
2886
  }
2439
- const refresh = this.performWatchedSessionRefresh(sessionId, watch);
2887
+ const refresh = this.performWatchedSessionRefresh(sessionId, watch, limit);
2440
2888
  watch.refresh = refresh;
2441
2889
  try {
2442
2890
  return await refresh;
@@ -2446,7 +2894,7 @@ export class NodeConnector {
2446
2894
  watch.refresh = undefined;
2447
2895
  }
2448
2896
  }
2449
- async performWatchedSessionRefresh(sessionId, watch) {
2897
+ async performWatchedSessionRefresh(sessionId, watch, limit = 10) {
2450
2898
  try {
2451
2899
  const session = this.runtime.getAgentSession(sessionId) ??
2452
2900
  (await this.resolveDirectNativeSession(sessionId));
@@ -2463,18 +2911,22 @@ export class NodeConnector {
2463
2911
  watch.sessionSignature = sessionSignature;
2464
2912
  this.emitWorkbenchEvent('session', { session: presentedSession });
2465
2913
  }
2466
- const page = await this.readConversationHistoryPage(session, { limit: 10 });
2914
+ const page = await this.readConversationHistoryPage(session, { limit });
2467
2915
  // A missing transcript is not a harmless empty update: continuing to
2468
2916
  // renew an observer that has lost both official and native history would
2469
2917
  // retain its timer forever. Treat it like every other read failure.
2470
2918
  watch.failures = 0;
2471
- const signature = JSON.stringify(page.turns);
2919
+ // `initial` may return a larger page than the background observer. The
2920
+ // observer's change signature remains fixed to the newest 10 Turns, so
2921
+ // switching back to its normal page size cannot manufacture an update.
2922
+ const observedPage = page.turns.length <= 10 ? page : { ...page, turns: page.turns.slice(0, 10) };
2923
+ const signature = JSON.stringify(observedPage.turns);
2472
2924
  if (signature === watch.signature)
2473
2925
  return page;
2474
2926
  watch.signature = signature;
2475
2927
  // 历史页按最新到最早排列,而 Web 会把单个实时 Turn 插到最新端。
2476
2928
  // 因此逐帧推送必须反向,最终投影仍保持最新到最早。
2477
- for (const turn of [...page.turns].reverse())
2929
+ for (const turn of [...observedPage.turns].reverse())
2478
2930
  this.emitWorkbenchEvent('conversation', { turn });
2479
2931
  return page;
2480
2932
  }
@@ -2679,6 +3131,8 @@ export class NodeConnector {
2679
3131
  customTitle: metadata?.customTitle ?? null,
2680
3132
  runnerTitle,
2681
3133
  pinnedAt: metadata?.pinnedAt ?? null,
3134
+ pinOrder: metadata?.pinOrder ?? null,
3135
+ externalDiscovered: true,
2682
3136
  titleSource: metadata?.customTitle === null || metadata?.customTitle === undefined
2683
3137
  ? runnerTitle === null
2684
3138
  ? 'AUTO'
@@ -2688,7 +3142,7 @@ export class NodeConnector {
2688
3142
  createdAt: timestamp
2689
3143
  };
2690
3144
  }
2691
- /** DB caches confirmed Codex names and shields a pending rename from stale discovery results. */
3145
+ /** A pending Codex rename shields the UI from stale discovery until Runner confirms it. */
2692
3146
  nativeSessionRunnerTitle(metadata, input) {
2693
3147
  if (input.runner !== 'codex')
2694
3148
  return input.title ?? null;
@@ -2707,24 +3161,17 @@ export class NodeConnector {
2707
3161
  }
2708
3162
  return cached ?? input.title;
2709
3163
  }
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
3164
  return input.title;
2720
3165
  }
2721
3166
  /** Runner-specific title persistence: Codex owns its thread name; Claude metadata is Node-local. */
2722
3167
  async renameRunnerSession(session, input) {
2723
3168
  if (session.runner === 'codex') {
2724
- if (session.externalSessionId === null || input.title === undefined)
3169
+ if (session.externalSessionId === null)
2725
3170
  return;
2726
- await this.codexClient.start();
2727
- await this.codexClient.setThreadName(session.externalSessionId, input.title ?? '');
3171
+ if (input.title !== undefined) {
3172
+ await this.codexClient.start();
3173
+ await this.codexClient.setThreadName(session.externalSessionId, input.title ?? '');
3174
+ }
2728
3175
  const database = this.database;
2729
3176
  if (database === undefined)
2730
3177
  throw new Error('NODE_DATABASE_UNAVAILABLE');
@@ -2732,8 +3179,10 @@ export class NodeConnector {
2732
3179
  runner: 'codex',
2733
3180
  externalSessionId: session.externalSessionId,
2734
3181
  workspaceId: session.workspaceId,
2735
- runnerTitle: input.title,
2736
- runnerTitlePending: input.title !== null && input.title.trim().length > 0,
3182
+ ...(input.title === undefined ? {} : { runnerTitle: input.title }),
3183
+ ...(input.title === undefined
3184
+ ? {}
3185
+ : { runnerTitlePending: input.title !== null && input.title.trim().length > 0 }),
2737
3186
  ...(input.pinned === undefined ? {} : { pinned: input.pinned })
2738
3187
  });
2739
3188
  return;
@@ -2912,6 +3361,8 @@ export class NodeConnector {
2912
3361
  this.emitRunEvent(runId, 'run.running', { threadId, turnId }, 'RUNNING');
2913
3362
  }
2914
3363
  catch (error) {
3364
+ if (this.completeCancelledRun(runId, cwd))
3365
+ return;
2915
3366
  this.emitRunEvent(runId, 'run.failed', { code: error instanceof Error ? error.message : 'CODEX_RUN_FAILED' }, 'FAILED');
2916
3367
  this.codexRuns.delete(runId);
2917
3368
  this.activeRuns.delete(runId);
@@ -3236,7 +3687,8 @@ export class NodeConnector {
3236
3687
  if (run === undefined)
3237
3688
  return false;
3238
3689
  if (run.status === 'CANCELLING') {
3239
- this.emitRunEvent(runId, 'run.completed', { status: 'CANCELLED' }, 'CANCELLED');
3690
+ const status = this.interruptRequestedRuns.has(runId) ? 'INTERRUPTED' : 'CANCELLED';
3691
+ this.emitRunEvent(runId, 'run.completed', { status }, status);
3240
3692
  }
3241
3693
  else if (!isTerminalRunStatus(run.status)) {
3242
3694
  return false;
@@ -3248,6 +3700,15 @@ export class NodeConnector {
3248
3700
  return true;
3249
3701
  }
3250
3702
  presentSession(session) {
3703
+ if (session.externalSessionId !== null && this.database !== undefined) {
3704
+ const metadata = this.database.getNativeSessionMetadata(session.runner, session.externalSessionId, session.workspaceId);
3705
+ if (metadata !== undefined)
3706
+ session = {
3707
+ ...session,
3708
+ pinnedAt: metadata.pinnedAt,
3709
+ pinOrder: metadata.pinOrder
3710
+ };
3711
+ }
3251
3712
  if (session.runner !== 'codex') {
3252
3713
  const active = this.hasManagedActiveRun(session);
3253
3714
  if (this.capabilities.claudeCode.available && session.nativeControl === 'MAR_MANAGED') {
@@ -3326,7 +3787,11 @@ export class NodeConnector {
3326
3787
  : this.hasManagedActiveRun(session)
3327
3788
  ? 'MANAGED_ACTIVE'
3328
3789
  : session.nativeControl === 'EXTERNAL'
3329
- ? (this.codexExternalActivity.get(session.id) ?? 'UNAVAILABLE')
3790
+ ? // Discovery deliberately uses thread/list only. Until a selected
3791
+ // session is read, treat activity as idle/unknown; takeover and
3792
+ // message handling still perform the authoritative read immediately
3793
+ // before resuming the external thread.
3794
+ (this.codexExternalActivity.get(session.id) ?? 'IDLE')
3330
3795
  : 'IDLE';
3331
3796
  return {
3332
3797
  ...session,
@@ -3639,7 +4104,7 @@ export class NodeConnector {
3639
4104
  manifestVersion: 1,
3640
4105
  revision,
3641
4106
  observedAt: Date.now(),
3642
- platform: this.capabilities.platform === 'win32' ? 'windows' : 'linux',
4107
+ platform: this.capabilities.platform,
3643
4108
  architecture: this.capabilities.architecture,
3644
4109
  conversation: {
3645
4110
  available: true,
@@ -3673,7 +4138,7 @@ export class NodeConnector {
3673
4138
  available: true,
3674
4139
  reasonCode: null,
3675
4140
  supportsPty: this.capabilities.platform === 'linux',
3676
- supportsConPty: this.capabilities.platform === 'win32',
4141
+ supportsConPty: this.capabilities.platform === 'windows',
3677
4142
  supportsReplay: true,
3678
4143
  supportsReadonlyAttach: true,
3679
4144
  supportsTakeover: true,
@@ -4551,21 +5016,6 @@ function presentClaudeContextUsage(usage) {
4551
5016
  percentage: usage.percentage
4552
5017
  };
4553
5018
  }
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
5019
  function isNativeHistory(value) {
4570
5020
  return (typeof value === 'object' &&
4571
5021
  value !== null &&
@@ -4674,7 +5124,9 @@ function nativeUserClientMessageId(entry, createdAt, runtimeTurns) {
4674
5124
  if (clientMessageId === null || payload.text !== entry.text)
4675
5125
  continue;
4676
5126
  const runtimeCreatedAt = user.startedAt ?? turn.startedAt;
4677
- if (runtimeCreatedAt !== null && runtimeCreatedAt !== undefined && Math.abs(createdAt - runtimeCreatedAt) > 60_000)
5127
+ if (runtimeCreatedAt !== null &&
5128
+ runtimeCreatedAt !== undefined &&
5129
+ Math.abs(createdAt - runtimeCreatedAt) > 60_000)
4678
5130
  continue;
4679
5131
  return clientMessageId;
4680
5132
  }
@@ -5199,38 +5651,74 @@ function sessionPage(sessions, input) {
5199
5651
  input.limit <= MAX_SESSION_PAGE_SIZE
5200
5652
  ? input.limit
5201
5653
  : 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'));
5654
+ const cursor = typeof input.cursor === 'string' ? input.cursor : undefined;
5655
+ const ordered = [...sessions].sort(compareSessionPageEntries);
5656
+ const cursorEntry = cursor === undefined ? undefined : parseSessionPageCursor(cursor);
5204
5657
  const afterCursor = cursor === undefined
5205
5658
  ? 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);
5659
+ : cursorEntry === undefined
5660
+ ? []
5661
+ : ordered.filter((session) => compareSessionPageEntries(session, cursorEntry) > 0);
5662
+ const pinned = ordered.filter((session) => session.pinOrder !== null && session.pinOrder !== undefined);
5663
+ const pinnedIndexes = new Map(pinned.map((session, index) => [session.id, index]));
5664
+ const page = afterCursor.slice(0, limit).map((session) => {
5665
+ const pinIndex = pinnedIndexes.get(session.id);
5666
+ if (pinIndex === undefined)
5667
+ return session;
5668
+ return {
5669
+ ...session,
5670
+ canMovePinUp: pinIndex > 0,
5671
+ canMovePinDown: pinIndex < pinned.length - 1
5672
+ };
5673
+ });
5210
5674
  const last = page.at(-1);
5211
5675
  return {
5212
5676
  sessions: page,
5213
5677
  nextCursor: last !== undefined && afterCursor.length > page.length ? createSessionPageCursor(last) : null
5214
5678
  };
5215
5679
  }
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;
5680
+ function parseSessionPageCursor(cursor) {
5226
5681
  try {
5227
- const id = decodeURIComponent(value.slice(separator + 1));
5228
- return id.length === 0 ? undefined : { lastActivityAt, id };
5682
+ const value = JSON.parse(decodeURIComponent(cursor));
5683
+ if (!Array.isArray(value) || value.length !== 3)
5684
+ return undefined;
5685
+ const [pinOrder, lastActivityAt, id] = value;
5686
+ if ((pinOrder !== null && (typeof pinOrder !== 'number' || !Number.isFinite(pinOrder))) ||
5687
+ (lastActivityAt !== null &&
5688
+ (typeof lastActivityAt !== 'number' || !Number.isFinite(lastActivityAt))) ||
5689
+ typeof id !== 'string')
5690
+ return undefined;
5691
+ return {
5692
+ id,
5693
+ lastActivityAt: lastActivityAt ?? 0,
5694
+ pinOrder,
5695
+ externalDiscovered: lastActivityAt !== null
5696
+ };
5229
5697
  }
5230
5698
  catch {
5231
5699
  return undefined;
5232
5700
  }
5233
5701
  }
5702
+ function createSessionPageCursor(session) {
5703
+ return encodeURIComponent(JSON.stringify([
5704
+ session.pinOrder ?? null,
5705
+ session.externalDiscovered === false ? null : session.lastActivityAt,
5706
+ session.id
5707
+ ]));
5708
+ }
5709
+ function compareSessionPageEntries(left, right) {
5710
+ const leftPinned = left.pinOrder !== null && left.pinOrder !== undefined;
5711
+ const rightPinned = right.pinOrder !== null && right.pinOrder !== undefined;
5712
+ if (leftPinned !== rightPinned)
5713
+ return leftPinned ? -1 : 1;
5714
+ if (leftPinned && rightPinned)
5715
+ return (left.pinOrder - right.pinOrder || left.id.localeCompare(right.id));
5716
+ const leftFound = left.externalDiscovered !== false;
5717
+ const rightFound = right.externalDiscovered !== false;
5718
+ if (leftFound !== rightFound)
5719
+ return leftFound ? -1 : 1;
5720
+ return right.lastActivityAt - left.lastActivityAt || left.id.localeCompare(right.id, 'en');
5721
+ }
5234
5722
  function compactRunnerText(value, limit) {
5235
5723
  return value.length <= limit ? value : `${value.slice(0, Math.max(0, limit - 1))}…`;
5236
5724
  }