@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/README.md +19 -2
- package/dist/capabilities.d.ts +5 -0
- package/dist/capabilities.js +58 -3
- package/dist/capabilities.js.map +1 -1
- package/dist/claude-agent-sdk.d.ts +1 -0
- package/dist/claude-agent-sdk.js +36 -2
- package/dist/claude-agent-sdk.js.map +1 -1
- package/dist/codex-app-server.d.ts +1 -0
- package/dist/codex-app-server.js +17 -2
- package/dist/codex-app-server.js.map +1 -1
- package/dist/connector.d.ts +40 -2
- package/dist/connector.js +608 -120
- package/dist/connector.js.map +1 -1
- package/dist/database.d.ts +13 -0
- package/dist/database.js +284 -239
- package/dist/database.js.map +1 -1
- package/dist/migrations/v001.d.ts +3 -0
- package/dist/migrations/v001.js +249 -0
- package/dist/migrations/v001.js.map +1 -0
- package/dist/migrations/v002.d.ts +4 -0
- package/dist/migrations/v002.js +144 -0
- package/dist/migrations/v002.js.map +1 -0
- package/dist/operational.d.ts +9 -0
- package/dist/operational.js +30 -0
- package/dist/operational.js.map +1 -1
- package/dist/workspace.d.ts +30 -0
- package/dist/workspace.js +264 -75
- package/dist/workspace.js.map +1 -1
- package/package.json +3 -2
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 {
|
|
12
|
-
import { inspectWorkspace, listWorkspaceDirectories, listWorkspaceFiles, preflightWorkspaceUpload, readCurrentChangeDiff, readCurrentChangesSummary, readGitSummary, readRestrictedDiff, readAllowedTextFile, readWorkspaceTextFile, restoreCurrentChange,
|
|
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.
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
1148
|
-
//
|
|
1149
|
-
//
|
|
1150
|
-
//
|
|
1151
|
-
const
|
|
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
|
|
1316
|
+
const merged = new Map([
|
|
1168
1317
|
...projected,
|
|
1169
1318
|
...native.filter((native) => native.externalSessionId === null ||
|
|
1170
1319
|
this.sessionProjectionForNativeRecord(native) === undefined)
|
|
1171
|
-
];
|
|
1172
|
-
|
|
1173
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
2009
|
-
// both paths together
|
|
2010
|
-
//
|
|
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
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 [...
|
|
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
|
-
/**
|
|
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
|
|
3169
|
+
if (session.externalSessionId === null)
|
|
2725
3170
|
return;
|
|
2726
|
-
|
|
2727
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
?
|
|
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
|
|
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 === '
|
|
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 &&
|
|
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' ?
|
|
5203
|
-
const ordered = [...sessions].sort(
|
|
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
|
-
:
|
|
5207
|
-
|
|
5208
|
-
|
|
5209
|
-
const
|
|
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
|
|
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
|
|
5228
|
-
|
|
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
|
}
|