nterminal 1.3.4 → 1.3.6
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 +68 -37
- package/assets/screenshot/accounts.png +0 -0
- package/assets/screenshot/history.png +0 -0
- package/assets/screenshot/mobile-panes.png +0 -0
- package/assets/screenshot/mobile.png +0 -0
- package/assets/screenshot/scr.png +0 -0
- package/assets/screenshot/subagents.png +0 -0
- package/bin/nterminal.js +10 -3
- package/dist/client/assets/MarkdownPreview-B-LrTjTM.js +1 -0
- package/dist/client/assets/{TranscriptMarkdownBody-H8ZsISc6.js → TranscriptMarkdownBody-3aYVmqQz.js} +1 -1
- package/dist/client/assets/index-D3LN5iAH.js +58 -0
- package/dist/client/assets/{index-DxfBq-4P.js → index-Dd_o8KCL.js} +1 -1
- package/dist/client/assets/index-W-zGfY1a.css +1 -0
- package/dist/client/index.html +2 -2
- package/dist/scripts/hooks.js +124 -0
- package/dist/scripts/hooks.js.map +1 -0
- package/dist/server/accounts/accountService.d.ts +23 -1
- package/dist/server/accounts/accountService.js +235 -103
- package/dist/server/accounts/accountService.js.map +1 -1
- package/dist/server/accounts/providerRequests.d.ts +12 -0
- package/dist/server/accounts/providerRequests.js +159 -0
- package/dist/server/accounts/providerRequests.js.map +1 -0
- package/dist/server/agent/agentRoutes.js +11 -0
- package/dist/server/agent/agentRoutes.js.map +1 -1
- package/dist/server/hooks/codexAppServer.d.ts +30 -0
- package/dist/server/hooks/codexAppServer.js +170 -0
- package/dist/server/hooks/codexAppServer.js.map +1 -0
- package/dist/server/hooks/codexTrust.d.ts +72 -0
- package/dist/server/hooks/codexTrust.js +116 -0
- package/dist/server/hooks/codexTrust.js.map +1 -0
- package/dist/server/hooks/helperJournal.d.ts +16 -0
- package/dist/server/hooks/helperJournal.js +74 -0
- package/dist/server/hooks/helperJournal.js.map +1 -0
- package/dist/server/hooks/hookBinding.d.ts +39 -0
- package/dist/server/hooks/hookBinding.js +91 -0
- package/dist/server/hooks/hookBinding.js.map +1 -0
- package/dist/server/hooks/hookFiles.d.ts +24 -0
- package/dist/server/hooks/hookFiles.js +160 -0
- package/dist/server/hooks/hookFiles.js.map +1 -0
- package/dist/server/hooks/hookInstallation.d.ts +108 -0
- package/dist/server/hooks/hookInstallation.js +1074 -0
- package/dist/server/hooks/hookInstallation.js.map +1 -0
- package/dist/server/hooks/hookProcess.d.ts +5 -0
- package/dist/server/hooks/hookProcess.js +85 -0
- package/dist/server/hooks/hookProcess.js.map +1 -0
- package/dist/server/hooks/hookReceiver.d.ts +66 -0
- package/dist/server/hooks/hookReceiver.js +549 -0
- package/dist/server/hooks/hookReceiver.js.map +1 -0
- package/dist/server/hooks/lifecycle.d.ts +40 -0
- package/dist/server/hooks/lifecycle.js +200 -0
- package/dist/server/hooks/lifecycle.js.map +1 -0
- package/dist/server/hooks/publicationLock.d.ts +3 -0
- package/dist/server/hooks/publicationLock.js +137 -0
- package/dist/server/hooks/publicationLock.js.map +1 -0
- package/dist/server/hooks/verifiedLock.d.ts +24 -0
- package/dist/server/hooks/verifiedLock.js +207 -0
- package/dist/server/hooks/verifiedLock.js.map +1 -0
- package/dist/server/http.d.ts +4 -0
- package/dist/server/http.js +6 -0
- package/dist/server/http.js.map +1 -1
- package/dist/server/index.js +71 -2
- package/dist/server/index.js.map +1 -1
- package/dist/server/notifications/agentCompletionMonitor.d.ts +16 -1
- package/dist/server/notifications/agentCompletionMonitor.js +261 -47
- package/dist/server/notifications/agentCompletionMonitor.js.map +1 -1
- package/dist/server/notifications/completionNotificationRouter.d.ts +2 -0
- package/dist/server/notifications/completionNotificationRouter.js +9 -0
- package/dist/server/notifications/completionNotificationRouter.js.map +1 -1
- package/dist/server/routes/accountRoutes.js +11 -0
- package/dist/server/routes/accountRoutes.js.map +1 -1
- package/dist/server/routes/activityRoutes.d.ts +13 -0
- package/dist/server/routes/activityRoutes.js +165 -0
- package/dist/server/routes/activityRoutes.js.map +1 -0
- package/dist/server/routes/agentManagementRoutes.js +8 -1
- package/dist/server/routes/agentManagementRoutes.js.map +1 -1
- package/dist/server/routes/hookRoutes.d.ts +16 -0
- package/dist/server/routes/hookRoutes.js +109 -0
- package/dist/server/routes/hookRoutes.js.map +1 -0
- package/dist/server/routes/terminalRoutes.js +4 -0
- package/dist/server/routes/terminalRoutes.js.map +1 -1
- package/dist/server/routes/transcriptQuery.d.ts +2 -1
- package/dist/server/routes/transcriptQuery.js +3 -1
- package/dist/server/routes/transcriptQuery.js.map +1 -1
- package/dist/server/terminal/PtyAdapter.d.ts +19 -0
- package/dist/server/terminal/PtyAdapter.js.map +1 -1
- package/dist/server/terminal/TerminalManager.d.ts +61 -0
- package/dist/server/terminal/TerminalManager.js +672 -12
- package/dist/server/terminal/TerminalManager.js.map +1 -1
- package/dist/server/terminal/TmuxPtyAdapter.d.ts +14 -2
- package/dist/server/terminal/TmuxPtyAdapter.js +101 -15
- package/dist/server/terminal/TmuxPtyAdapter.js.map +1 -1
- package/dist/server/terminal/activityChannel.d.ts +57 -0
- package/dist/server/terminal/activityChannel.js +166 -0
- package/dist/server/terminal/activityChannel.js.map +1 -0
- package/dist/server/terminal/agentTranscriptStatus.d.ts +12 -0
- package/dist/server/terminal/agentTranscriptStatus.js +78 -7
- package/dist/server/terminal/agentTranscriptStatus.js.map +1 -1
- package/dist/server/terminal/codexTranscriptSource.d.ts +3 -0
- package/dist/server/terminal/codexTranscriptSource.js +99 -39
- package/dist/server/terminal/codexTranscriptSource.js.map +1 -1
- package/dist/server/terminal/subagentSources.js +2 -1
- package/dist/server/terminal/subagentSources.js.map +1 -1
- package/dist/server/terminal/transcriptHeader.d.ts +85 -0
- package/dist/server/terminal/transcriptHeader.js +376 -0
- package/dist/server/terminal/transcriptHeader.js.map +1 -0
- package/dist/server/terminal/transcriptHistory.d.ts +16 -2
- package/dist/server/terminal/transcriptHistory.js +120 -21
- package/dist/server/terminal/transcriptHistory.js.map +1 -1
- package/dist/server/terminal/transcriptStore.d.ts +6 -0
- package/dist/server/terminal/transcriptStore.js +222 -26
- package/dist/server/terminal/transcriptStore.js.map +1 -1
- package/dist/shared/activityProbe.d.ts +10 -0
- package/dist/shared/activityProbe.js +24 -0
- package/dist/shared/activityProbe.js.map +1 -0
- package/dist/shared/protocol.d.ts +45 -0
- package/dist/shared/protocol.js.map +1 -1
- package/dist/shared/types.d.ts +9 -1
- package/docs/features.md +21 -1
- package/docs/provider-hooks.md +340 -0
- package/docs/terminal-history.md +10 -0
- package/package.json +2 -1
- package/scripts/nterminal-hook.cjs +171 -0
- package/scripts/nterminalctl +26 -2
- package/dist/client/assets/MarkdownPreview-DjDO4lEl.js +0 -1
- package/dist/client/assets/index-CIYazAGv.css +0 -1
- package/dist/client/assets/index-DwvLN_WJ.js +0 -56
|
@@ -7,7 +7,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
7
7
|
import * as nodePty from 'node-pty';
|
|
8
8
|
import path from 'node:path';
|
|
9
9
|
import { normalizeTerminalTitle, PtySessionUnavailableError } from './PtyAdapter.js';
|
|
10
|
-
import {
|
|
10
|
+
import { lookupCodexTranscriptFromDaemon, lookupCodexTranscriptFromProcessLogs, lookupCodexTranscriptFromOpenFds, resolveCodexTranscriptCandidates, processBinding, TRANSCRIPT_CANDIDATE_LIMIT } from './codexTranscriptSource.js';
|
|
11
11
|
import { isSessionId } from './sessionId.js';
|
|
12
12
|
import { processStartTicksToSecondsAsync, readProcessCwdAsync, readProcessEnvValuesAsync, readProcessStartTicksAsync } from './procfs.js';
|
|
13
13
|
import { TtlCache } from './ttlCache.js';
|
|
@@ -26,6 +26,8 @@ const TRANSCRIPT_FILE_NEGATIVE_CACHE_TTL_MS = 20_000;
|
|
|
26
26
|
// letting a history-paging burst (several page requests within seconds)
|
|
27
27
|
// reuse one resolution instead of re-forking tmux/ps per page.
|
|
28
28
|
const TRANSCRIPT_RESOLUTION_CACHE_TTL_MS = 3_000;
|
|
29
|
+
// Diagnostics outlive a few status polls, not the pane's next resolution.
|
|
30
|
+
const TRANSCRIPT_AMBIGUITY_TTL_MS = 90_000;
|
|
29
31
|
const TRANSCRIPT_CANDIDATE_CACHE_TTL_MS = 500;
|
|
30
32
|
const PROCESS_TREE_CACHE_TTL_MS = 1_000;
|
|
31
33
|
const CLAUDE_TRANSCRIPT_START_SKEW_MS = 10_000;
|
|
@@ -75,6 +77,9 @@ export class TmuxPtyAdapter {
|
|
|
75
77
|
commandRunner;
|
|
76
78
|
ptySpawner;
|
|
77
79
|
transcriptResolutionCache = new TtlCache();
|
|
80
|
+
transcriptResolutionGeneration = new Map();
|
|
81
|
+
// Last discovery outcome per pane, for status reporting only (bounded by panes).
|
|
82
|
+
transcriptAmbiguity = new Map();
|
|
78
83
|
transcriptResolutionInflight = new KeyedSingleFlight();
|
|
79
84
|
transcriptCandidateInflight = new KeyedSingleFlight();
|
|
80
85
|
processTreeCache = new TtlCache();
|
|
@@ -397,6 +402,8 @@ export class TmuxPtyAdapter {
|
|
|
397
402
|
this.historyOutputGenerationBySession.delete(sessionId);
|
|
398
403
|
this.historyOutputUnreliableSessions.delete(sessionId);
|
|
399
404
|
this.transcriptResolutionCache.delete(sessionId);
|
|
405
|
+
this.transcriptResolutionGeneration.delete(sessionId);
|
|
406
|
+
this.transcriptAmbiguity.delete(sessionId);
|
|
400
407
|
}
|
|
401
408
|
isInflightFinalCreation(sessionId, marker) {
|
|
402
409
|
const inflightMarker = this.inflightFinalCreationMarkers.get(sessionId);
|
|
@@ -1039,16 +1046,50 @@ export class TmuxPtyAdapter {
|
|
|
1039
1046
|
}
|
|
1040
1047
|
}
|
|
1041
1048
|
async resolveTranscriptSource(sessionId) {
|
|
1049
|
+
return (await this.resolveTranscriptSourceEvidence(sessionId))?.source ?? null;
|
|
1050
|
+
}
|
|
1051
|
+
async resolveTranscriptSourceEvidence(sessionId, options = {}) {
|
|
1052
|
+
const resolved = options.fresh ? await this.resolveTranscriptSourceUncached(sessionId, true)
|
|
1053
|
+
: await this.resolveTranscriptSourceCached(sessionId);
|
|
1054
|
+
if (!resolved)
|
|
1055
|
+
return null;
|
|
1056
|
+
const { evidence = 'inferred', client, ...source } = resolved;
|
|
1057
|
+
return { source, evidence, ...(client ? { client } : {}) };
|
|
1058
|
+
}
|
|
1059
|
+
getTranscriptAmbiguity(sessionId) {
|
|
1060
|
+
const entry = this.transcriptAmbiguity.get(sessionId);
|
|
1061
|
+
if (!entry)
|
|
1062
|
+
return null;
|
|
1063
|
+
if (Date.now() - entry.at > TRANSCRIPT_AMBIGUITY_TTL_MS) {
|
|
1064
|
+
this.transcriptAmbiguity.delete(sessionId);
|
|
1065
|
+
return null;
|
|
1066
|
+
}
|
|
1067
|
+
return entry.reason;
|
|
1068
|
+
}
|
|
1069
|
+
noteTranscriptAmbiguity(sessionId, ambiguous) {
|
|
1070
|
+
if (ambiguous)
|
|
1071
|
+
this.transcriptAmbiguity.set(sessionId, { reason: 'codex_shared_daemon_same_cwd', at: Date.now() });
|
|
1072
|
+
else
|
|
1073
|
+
this.transcriptAmbiguity.delete(sessionId);
|
|
1074
|
+
}
|
|
1075
|
+
async resolveTranscriptSourceCached(sessionId) {
|
|
1042
1076
|
const cached = this.transcriptResolutionCache.get(sessionId);
|
|
1043
1077
|
if (cached) {
|
|
1044
1078
|
return cached.value;
|
|
1045
1079
|
}
|
|
1046
|
-
|
|
1080
|
+
const generation = this.transcriptResolutionGeneration.get(sessionId) ?? 0;
|
|
1081
|
+
return this.transcriptResolutionInflight.run(`${sessionId}:${generation}`, async () => {
|
|
1047
1082
|
const source = await this.resolveTranscriptSourceUncached(sessionId);
|
|
1048
|
-
this.
|
|
1083
|
+
if ((this.transcriptResolutionGeneration.get(sessionId) ?? 0) === generation) {
|
|
1084
|
+
this.transcriptResolutionCache.set(sessionId, source, TRANSCRIPT_RESOLUTION_CACHE_TTL_MS);
|
|
1085
|
+
}
|
|
1049
1086
|
return source;
|
|
1050
1087
|
});
|
|
1051
1088
|
}
|
|
1089
|
+
invalidateTranscriptSource(sessionId) {
|
|
1090
|
+
this.transcriptResolutionCache.delete(sessionId);
|
|
1091
|
+
this.transcriptResolutionGeneration.set(sessionId, (this.transcriptResolutionGeneration.get(sessionId) ?? 0) + 1);
|
|
1092
|
+
}
|
|
1052
1093
|
async resolveTranscriptCandidates(sessionId) {
|
|
1053
1094
|
return this.transcriptCandidateInflight.run(sessionId, async () => {
|
|
1054
1095
|
const result = await this.resolveTranscriptCandidatesUncached(sessionId);
|
|
@@ -1119,7 +1160,13 @@ export class TmuxPtyAdapter {
|
|
|
1119
1160
|
candidates: [...candidates.values()].slice(0, TRANSCRIPT_CANDIDATE_LIMIT)
|
|
1120
1161
|
};
|
|
1121
1162
|
}
|
|
1122
|
-
async resolveTranscriptSourceUncached(sessionId) {
|
|
1163
|
+
async resolveTranscriptSourceUncached(sessionId, fresh = false) {
|
|
1164
|
+
const state = { daemonAmbiguous: false };
|
|
1165
|
+
const resolved = await this.resolveTranscriptSourceUncachedInner(sessionId, fresh, state);
|
|
1166
|
+
this.noteTranscriptAmbiguity(sessionId, !resolved && state.daemonAmbiguous);
|
|
1167
|
+
return resolved;
|
|
1168
|
+
}
|
|
1169
|
+
async resolveTranscriptSourceUncachedInner(sessionId, fresh, state) {
|
|
1123
1170
|
const panePid = this.panePidBySession.get(sessionId) ?? await this.readPanePidAsync(sessionId);
|
|
1124
1171
|
if (panePid === null) {
|
|
1125
1172
|
return null;
|
|
@@ -1140,6 +1187,7 @@ export class TmuxPtyAdapter {
|
|
|
1140
1187
|
}
|
|
1141
1188
|
});
|
|
1142
1189
|
let entries = [];
|
|
1190
|
+
const pendingCodexDepths = [];
|
|
1143
1191
|
let claudeSessionPending = false;
|
|
1144
1192
|
for (const [pid, args] of argsByPid) {
|
|
1145
1193
|
const processDepth = processDepthByPid.get(pid);
|
|
@@ -1170,8 +1218,10 @@ export class TmuxPtyAdapter {
|
|
|
1170
1218
|
entries.push({ candidate: live.source, rank: 'live', processDepth });
|
|
1171
1219
|
continue;
|
|
1172
1220
|
}
|
|
1173
|
-
if (live.status === 'pending' || live.status === 'ambiguous')
|
|
1174
|
-
|
|
1221
|
+
if (live.status === 'pending' || live.status === 'ambiguous') {
|
|
1222
|
+
pendingCodexDepths.push(processDepth);
|
|
1223
|
+
continue;
|
|
1224
|
+
}
|
|
1175
1225
|
// Launcher pids (the npm node wrapper) merely relay the binary's
|
|
1176
1226
|
// argv; after /new their stale `resume <id>` args would conflict
|
|
1177
1227
|
// with the live session the binary itself reports, so only the
|
|
@@ -1181,13 +1231,23 @@ export class TmuxPtyAdapter {
|
|
|
1181
1231
|
}
|
|
1182
1232
|
// A connected daemon is authoritative, even when its current
|
|
1183
1233
|
// thread is pending/ambiguous. Never replace that with stale argv.
|
|
1184
|
-
const
|
|
1185
|
-
|
|
1186
|
-
|
|
1234
|
+
const daemon = await lookupCodexTranscriptFromDaemon(pid, fresh ? { bypassCache: true } : undefined);
|
|
1235
|
+
const lookup = daemon.status === 'absent' || daemon.status === 'unavailable'
|
|
1236
|
+
? await lookupCodexTranscriptFromProcessLogs(pid) : daemon;
|
|
1237
|
+
if (lookup.status === 'pending' || lookup.status === 'ambiguous') {
|
|
1238
|
+
if (daemon.status === 'ambiguous')
|
|
1239
|
+
state.daemonAmbiguous = true;
|
|
1240
|
+
pendingCodexDepths.push(processDepth);
|
|
1241
|
+
continue;
|
|
1242
|
+
}
|
|
1187
1243
|
const stated = lookup.status === 'matched' ? lookup.source
|
|
1188
1244
|
: lookup.status === 'absent' ? extractCodexArgsCandidate(args) : null;
|
|
1189
1245
|
if (stated) {
|
|
1190
|
-
|
|
1246
|
+
const described = daemon.status === 'matched' ? await describeTranscriptClient(pid, args) : null;
|
|
1247
|
+
const client = described && daemon.status === 'matched' && daemon.soleClientInCwd
|
|
1248
|
+
? { ...described, soleDaemonClientInCwd: true } : described;
|
|
1249
|
+
entries.push({ candidate: stated, rank: daemon.status === 'matched' ? 'daemon' : 'stated', processDepth,
|
|
1250
|
+
...(client ? { client } : {}) });
|
|
1191
1251
|
}
|
|
1192
1252
|
}
|
|
1193
1253
|
}
|
|
@@ -1198,15 +1258,23 @@ export class TmuxPtyAdapter {
|
|
|
1198
1258
|
// other session's file) is wrong by construction.
|
|
1199
1259
|
entries = entries.filter((entry) => entry.candidate.provider !== 'claude' || entry.rank === 'live');
|
|
1200
1260
|
}
|
|
1201
|
-
|
|
1202
|
-
|
|
1261
|
+
// Uncertain child jobs cannot veto a closer write-open TUI owner. At
|
|
1262
|
+
// the same or shallower depth uncertainty still blocks all fallback.
|
|
1263
|
+
const closestLiveCodex = Math.min(...entries.filter(entry => entry.rank === 'live' && entry.candidate.provider === 'codex')
|
|
1264
|
+
.map(entry => entry.processDepth ?? Infinity));
|
|
1265
|
+
if (pendingCodexDepths.some(depth => depth <= closestLiveCodex))
|
|
1266
|
+
return null;
|
|
1267
|
+
const selected = selectTranscriptCandidateEntry(entries);
|
|
1268
|
+
if (!selected) {
|
|
1203
1269
|
return null;
|
|
1204
1270
|
}
|
|
1271
|
+
const candidate = selected.candidate;
|
|
1205
1272
|
const matches = candidate.filePath ? [candidate.filePath] : await findTranscriptFilesAsync(candidate.provider, candidate.sessionId);
|
|
1206
1273
|
if (matches.length !== 1) {
|
|
1207
1274
|
return null;
|
|
1208
1275
|
}
|
|
1209
|
-
return { provider: candidate.provider, sessionId: candidate.sessionId, filePath: matches[0]
|
|
1276
|
+
return { provider: candidate.provider, sessionId: candidate.sessionId, filePath: matches[0], evidence: selected.rank,
|
|
1277
|
+
...(selected.client ? { client: selected.client } : {}) };
|
|
1210
1278
|
}
|
|
1211
1279
|
async readPaneMetricsAsync(sessionId) {
|
|
1212
1280
|
try {
|
|
@@ -1296,6 +1364,10 @@ export class TmuxPtyAdapter {
|
|
|
1296
1364
|
return false;
|
|
1297
1365
|
}
|
|
1298
1366
|
}
|
|
1367
|
+
getSessionPid(sessionId) {
|
|
1368
|
+
const cached = this.panePidBySession.get(sessionId);
|
|
1369
|
+
return cached ? Promise.resolve(cached) : this.readPanePidAsync(sessionId);
|
|
1370
|
+
}
|
|
1299
1371
|
async readPanePidAsync(sessionId) {
|
|
1300
1372
|
try {
|
|
1301
1373
|
const result = await this.commandRunner('tmux', [
|
|
@@ -1718,6 +1790,17 @@ async function readProcessArgsAsync(pid) {
|
|
|
1718
1790
|
return [];
|
|
1719
1791
|
}
|
|
1720
1792
|
}
|
|
1793
|
+
// Launch facts for the TUI behind a daemon association. Missing facts stay
|
|
1794
|
+
// missing (never guessed), so corroboration fails closed.
|
|
1795
|
+
async function describeTranscriptClient(pid, args) {
|
|
1796
|
+
const startTicks = await readProcessStartTicksAsync(pid);
|
|
1797
|
+
if (startTicks === null)
|
|
1798
|
+
return null;
|
|
1799
|
+
const startedAtSeconds = await processStartTicksToSecondsAsync(startTicks);
|
|
1800
|
+
const stated = extractCodexArgsCandidate(args)?.sessionId;
|
|
1801
|
+
return { pid, startTicks, startedAtMs: startedAtSeconds === null ? null : Math.round(startedAtSeconds * 1000),
|
|
1802
|
+
...(stated ? { statedSessionId: stated } : {}) };
|
|
1803
|
+
}
|
|
1721
1804
|
function extractCodexArgsCandidate(args) {
|
|
1722
1805
|
const codexIndex = findCommandArg(args, 'codex');
|
|
1723
1806
|
if (codexIndex === -1) {
|
|
@@ -1826,7 +1909,10 @@ function hasCodexDescendant(tree, root, argsByPid) {
|
|
|
1826
1909
|
// tier returns null — falling back to a lower tier would resolve precisely
|
|
1827
1910
|
// when the best information says the answer is uncertain.
|
|
1828
1911
|
export function selectTranscriptCandidate(entries) {
|
|
1829
|
-
|
|
1912
|
+
return selectTranscriptCandidateEntry(entries)?.candidate ?? null;
|
|
1913
|
+
}
|
|
1914
|
+
function selectTranscriptCandidateEntry(entries) {
|
|
1915
|
+
for (const rank of ['live', 'daemon', 'stated', 'inferred']) {
|
|
1830
1916
|
let pool = entries.filter((entry) => entry.rank === rank);
|
|
1831
1917
|
if (pool.length === 0) {
|
|
1832
1918
|
continue;
|
|
@@ -1852,7 +1938,7 @@ export function selectTranscriptCandidate(entries) {
|
|
|
1852
1938
|
if (unique.size !== 1) {
|
|
1853
1939
|
return null;
|
|
1854
1940
|
}
|
|
1855
|
-
return [...unique.values()][0];
|
|
1941
|
+
return { candidate: [...unique.values()][0], rank };
|
|
1856
1942
|
}
|
|
1857
1943
|
return null;
|
|
1858
1944
|
}
|