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
|
@@ -1,13 +1,37 @@
|
|
|
1
1
|
import { createHash, randomUUID } from 'node:crypto';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import { stat as statFile } from 'node:fs/promises';
|
|
3
4
|
import { normalizeTerminalTitle } from './PtyAdapter.js';
|
|
5
|
+
import { codexSessionCreatedAtMs } from './codexTranscriptSource.js';
|
|
4
6
|
import { resolveProcessCwd } from './cwdResolver.js';
|
|
5
7
|
import { OutputBuffer } from './outputBuffer.js';
|
|
6
8
|
import { readTranscriptHistoryPage } from './transcriptHistory.js';
|
|
7
9
|
import { TranscriptStore } from './transcriptStore.js';
|
|
8
|
-
import { AgentTranscriptTracker } from './agentTranscriptStatus.js';
|
|
10
|
+
import { AgentTranscriptTracker, readAgentTranscriptConfirmation } from './agentTranscriptStatus.js';
|
|
9
11
|
import { listTerminalSubagents, resolveSubagentTranscriptSource, subagentSummary } from './subagentSources.js';
|
|
10
12
|
import { mapWithConcurrency } from './asyncCommand.js';
|
|
13
|
+
import { isLowPriorityLifecycleEvent, reduceLifecycle, reconcileLifecycle } from '../hooks/lifecycle.js';
|
|
14
|
+
import { readHookOwner, validateHookTranscript } from '../hooks/hookBinding.js';
|
|
15
|
+
import { verifyHookPaneOwner } from '../hooks/hookProcess.js';
|
|
16
|
+
// Manager queue: tool events are dropped first; stop/completion events keep
|
|
17
|
+
// a reserved share. The receiver in front of it applies the same policy.
|
|
18
|
+
const HOOK_QUEUE_LOW_PRIORITY_LIMIT = 64;
|
|
19
|
+
const HOOK_QUEUE_LIMIT = 160;
|
|
20
|
+
// Failed shared-daemon resolutions for a session are reused this long unless
|
|
21
|
+
// a SessionStart/UserPromptSubmit for it arrives.
|
|
22
|
+
const SHARED_HOOK_MISS_TTL_MS = 5_000;
|
|
23
|
+
const HOOK_SESSION_MEMORY_LIMIT = 256;
|
|
24
|
+
// A fresh TUI creates its thread while starting; a daemon cold start and
|
|
25
|
+
// MCP initialization can delay that by several seconds.
|
|
26
|
+
const SHARED_LAUNCH_SKEW_MS = 5_000;
|
|
27
|
+
const SHARED_LAUNCH_WINDOW_MS = 30_000;
|
|
28
|
+
const HOOK_UNBOUND_REPORT_LIMIT = 16;
|
|
29
|
+
function boundedSet(map, key, value, limit) {
|
|
30
|
+
map.delete(key);
|
|
31
|
+
while (map.size >= limit)
|
|
32
|
+
map.delete(map.keys().next().value);
|
|
33
|
+
map.set(key, value);
|
|
34
|
+
}
|
|
11
35
|
// Window for the output-rate component of busy detection. Slightly larger
|
|
12
36
|
// than the poll interval so a tab that just stopped emitting output
|
|
13
37
|
// transitions to idle on the very next poll (rather than lagging a tick),
|
|
@@ -44,6 +68,23 @@ export class TerminalManager {
|
|
|
44
68
|
shellBasename;
|
|
45
69
|
terminalCapabilities;
|
|
46
70
|
agentTranscriptTracker = new AgentTranscriptTracker();
|
|
71
|
+
hookBindings = new Map();
|
|
72
|
+
retiredHookSessions = new Map();
|
|
73
|
+
activitySubscribers = new Set();
|
|
74
|
+
hookEpoch = randomUUID();
|
|
75
|
+
hookRevision = 0;
|
|
76
|
+
hookTail = Promise.resolve();
|
|
77
|
+
hookPending = 0;
|
|
78
|
+
queuedToolEvents = new Map();
|
|
79
|
+
// Keyed by `${daemonPid}:${startTicks}:${sessionId}`; bounded LRU maps.
|
|
80
|
+
sharedHookMisses = new Map();
|
|
81
|
+
sharedSessionStarts = new Map();
|
|
82
|
+
// The TUI process identity behind each pane's shared bindings, and the time
|
|
83
|
+
// of its last bound event. Distinguishes a first binding from a switch.
|
|
84
|
+
paneHookClients = new Map();
|
|
85
|
+
hookUnbound = new Map();
|
|
86
|
+
hookDependencies;
|
|
87
|
+
isHookProviderEnabled;
|
|
47
88
|
transcriptStore;
|
|
48
89
|
transcriptQueue = [];
|
|
49
90
|
lastDiagnosticAt = new Map();
|
|
@@ -70,6 +111,10 @@ export class TerminalManager {
|
|
|
70
111
|
this.now = options.now ?? (() => new Date());
|
|
71
112
|
this.onCompletion = options.onCompletion ?? null;
|
|
72
113
|
this.onDiagnostic = options.onDiagnostic ?? null;
|
|
114
|
+
this.hookDependencies = options.hookDependencies ?? {
|
|
115
|
+
readOwner: readHookOwner, verifyPaneOwner: verifyHookPaneOwner, validateTranscript: validateHookTranscript
|
|
116
|
+
};
|
|
117
|
+
this.isHookProviderEnabled = options.isHookProviderEnabled ?? (async () => true);
|
|
73
118
|
this.shellBasename = path.basename(this.config.shell);
|
|
74
119
|
this.terminalCapabilities =
|
|
75
120
|
typeof this.ptyAdapter.scrollbackScroll === 'function' &&
|
|
@@ -106,11 +151,17 @@ export class TerminalManager {
|
|
|
106
151
|
}
|
|
107
152
|
async pollOnce() {
|
|
108
153
|
const poll = this.ptyAdapter.pollSessions;
|
|
109
|
-
if (!poll)
|
|
154
|
+
if (!poll) {
|
|
155
|
+
for (const id of this.hookBindings.keys())
|
|
156
|
+
this.scheduleTranscriptRefresh(id);
|
|
110
157
|
return;
|
|
158
|
+
}
|
|
159
|
+
const startedAt = Date.now();
|
|
111
160
|
const result = await poll.call(this.ptyAdapter);
|
|
112
161
|
const now = Date.now();
|
|
113
162
|
for (const [terminalId, snapshot] of result) {
|
|
163
|
+
if ((this.hookBindings.get(terminalId)?.observedAt ?? 0) > startedAt)
|
|
164
|
+
continue;
|
|
114
165
|
try {
|
|
115
166
|
this.applyProcessSnapshot(terminalId, snapshot, now);
|
|
116
167
|
}
|
|
@@ -136,6 +187,9 @@ export class TerminalManager {
|
|
|
136
187
|
let summaryChanged = commandChanged;
|
|
137
188
|
record.summary.currentCommand = nextCommand;
|
|
138
189
|
if (!nextCommand) {
|
|
190
|
+
this.hookBindings.delete(terminalId);
|
|
191
|
+
this.paneHookClients.delete(terminalId);
|
|
192
|
+
this.hookUnbound.delete(terminalId);
|
|
139
193
|
this.agentTranscriptTracker.reset(terminalId);
|
|
140
194
|
record.transcriptSourceMissingSince = null;
|
|
141
195
|
if (record.summary.agentStatus) {
|
|
@@ -175,6 +229,11 @@ export class TerminalManager {
|
|
|
175
229
|
const record = this.terminals.get(terminalId);
|
|
176
230
|
if (!record || record.summary.status !== 'running' || record.closing || !record.summary.currentCommand)
|
|
177
231
|
return;
|
|
232
|
+
const hook = this.hookBindings.get(terminalId);
|
|
233
|
+
if (hook && hook.state.status.state !== 'finishing' && Date.now() - hook.transcriptReadAt < 30_000)
|
|
234
|
+
return;
|
|
235
|
+
if (hook)
|
|
236
|
+
hook.transcriptReadAt = Date.now();
|
|
178
237
|
if (record.transcriptRefreshState === 'running') {
|
|
179
238
|
record.transcriptRerun = true;
|
|
180
239
|
return;
|
|
@@ -237,16 +296,41 @@ export class TerminalManager {
|
|
|
237
296
|
if (!record || record.summary.status !== 'running' || record.closing || !currentCommand)
|
|
238
297
|
return;
|
|
239
298
|
const generation = record.transcriptGeneration;
|
|
299
|
+
const hook = this.hookBindings.get(terminalId);
|
|
240
300
|
const isCurrent = () => !this.disposed &&
|
|
241
301
|
this.terminals.get(terminalId) === record &&
|
|
242
302
|
record.summary.status === 'running' &&
|
|
243
303
|
!record.closing &&
|
|
244
304
|
record.transcriptGeneration === generation &&
|
|
245
|
-
record.summary.currentCommand === currentCommand
|
|
305
|
+
record.summary.currentCommand === currentCommand &&
|
|
306
|
+
this.hookBindings.get(terminalId) === hook;
|
|
246
307
|
const transcript = await this.resolveAgentTranscript(terminalId, currentCommand, isCurrent);
|
|
247
308
|
if (transcript === null || !isCurrent())
|
|
248
309
|
return;
|
|
249
|
-
|
|
310
|
+
if (hook) {
|
|
311
|
+
const paneOwned = await this.hookPaneStillOwned(terminalId, record, hook);
|
|
312
|
+
const owner = await this.hookDependencies.readOwner(hook.owner, hook.owner.provider);
|
|
313
|
+
if (!isCurrent() || this.hookBindings.get(terminalId) !== hook)
|
|
314
|
+
return;
|
|
315
|
+
if (!hookOwnerMatches(owner, hook.owner)) {
|
|
316
|
+
this.forgetHookBinding(terminalId, hook);
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
if (!paneOwned)
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
let confirmedCompletion;
|
|
323
|
+
if (hook && transcript && transcript.status.sessionId === hook.state.status.sessionId) {
|
|
324
|
+
const state = reconcileLifecycle(hook.state, transcript.completion);
|
|
325
|
+
if (state !== hook.state) {
|
|
326
|
+
const previousCompletion = hook.state.status.completion;
|
|
327
|
+
state.status = { ...state.status, epoch: this.hookEpoch, revision: ++this.hookRevision };
|
|
328
|
+
hook.state = state;
|
|
329
|
+
if (state.status.completion?.id !== previousCompletion?.id)
|
|
330
|
+
confirmedCompletion = state.status.completion;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
const nextAgentStatus = this.hookBindings.get(terminalId)?.state.status ?? transcript?.status;
|
|
250
334
|
const nextAgentCache = transcript?.cache;
|
|
251
335
|
if (agentSessionChanged(record.summary.agentStatus?.sessionId ?? null, nextAgentStatus?.sessionId ?? null)) {
|
|
252
336
|
record.transcriptGeneration += 1;
|
|
@@ -277,12 +361,24 @@ export class TerminalManager {
|
|
|
277
361
|
record.summary.updatedAt = this.timestamp();
|
|
278
362
|
this.publish(record, { type: 'terminal_updated', terminal: cloneSummary(record.summary) });
|
|
279
363
|
this.trackCompletion(record.summary);
|
|
364
|
+
if (confirmedCompletion && hook) {
|
|
365
|
+
this.emitCompletion({ terminalId, terminalTitle: record.summary.title, provider: hook.owner.provider,
|
|
366
|
+
completionId: confirmedCompletion.id, occurredAt: confirmedCompletion.occurredAt,
|
|
367
|
+
outcome: confirmedCompletion.outcome, commandName: currentCommand });
|
|
368
|
+
}
|
|
280
369
|
}
|
|
281
370
|
// Allow the process to shut down cleanly without leaking the polling
|
|
282
371
|
// interval — called by the server's graceful-shutdown path.
|
|
283
372
|
dispose() {
|
|
284
373
|
this.disposed = true;
|
|
285
374
|
this.transcriptStore.dispose();
|
|
375
|
+
this.hookBindings.clear();
|
|
376
|
+
this.retiredHookSessions.clear();
|
|
377
|
+
this.paneHookClients.clear();
|
|
378
|
+
this.hookUnbound.clear();
|
|
379
|
+
this.sharedHookMisses.clear();
|
|
380
|
+
this.sharedSessionStarts.clear();
|
|
381
|
+
this.activitySubscribers.clear();
|
|
286
382
|
this.shutdownRequested = true;
|
|
287
383
|
if (this.statusPollTimer) {
|
|
288
384
|
clearInterval(this.statusPollTimer);
|
|
@@ -601,6 +697,9 @@ export class TerminalManager {
|
|
|
601
697
|
delete record.summary.agentCache;
|
|
602
698
|
this.agentTranscriptTracker.reset(id);
|
|
603
699
|
this.completionStates.delete(id);
|
|
700
|
+
this.hookBindings.delete(id);
|
|
701
|
+
this.paneHookClients.delete(id);
|
|
702
|
+
this.hookUnbound.delete(id);
|
|
604
703
|
this.publish(record, { type: 'exit', terminalId: id, exitCode });
|
|
605
704
|
}
|
|
606
705
|
// Spawn a fresh local client for a restored session that hasn't been
|
|
@@ -623,6 +722,456 @@ export class TerminalManager {
|
|
|
623
722
|
listTerminals() {
|
|
624
723
|
return [...this.terminals.values()].map((record) => cloneSummary(record.summary));
|
|
625
724
|
}
|
|
725
|
+
subscribeActivity(listener) {
|
|
726
|
+
this.activitySubscribers.add(listener);
|
|
727
|
+
return () => { this.activitySubscribers.delete(listener); };
|
|
728
|
+
}
|
|
729
|
+
getLifecycleHookStatus(provider) {
|
|
730
|
+
const bindings = [...this.hookBindings.values()].filter(binding => binding.owner.provider === provider);
|
|
731
|
+
const latest = bindings.reduce((at, binding) => Math.max(at, binding.observedAt), 0);
|
|
732
|
+
const unboundPanes = [];
|
|
733
|
+
// Only Codex has a shared daemon whose sessions can be unattributable.
|
|
734
|
+
if (provider === 'codex') {
|
|
735
|
+
for (const [id, record] of this.terminals) {
|
|
736
|
+
if (unboundPanes.length >= HOOK_UNBOUND_REPORT_LIMIT)
|
|
737
|
+
break;
|
|
738
|
+
if (this.hookBindings.has(id) || record.summary.status !== 'running' || record.closing)
|
|
739
|
+
continue;
|
|
740
|
+
if (this.ptyAdapter.pollSessions && !foregroundMayRunProvider(record.summary, 'codex'))
|
|
741
|
+
continue;
|
|
742
|
+
const reason = this.ptyAdapter.getTranscriptAmbiguity?.(id) === 'codex_shared_daemon_same_cwd'
|
|
743
|
+
? 'shared_daemon_same_cwd' : this.hookUnbound.get(id)?.reason;
|
|
744
|
+
if (reason)
|
|
745
|
+
unboundPanes.push({ terminalId: id, title: record.summary.title, reason });
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
return { observed: bindings.length > 0, activeSessions: bindings.length, lastEventAt: latest ? new Date(latest).toISOString() : null,
|
|
749
|
+
...(unboundPanes.length ? { unboundPanes } : {}) };
|
|
750
|
+
}
|
|
751
|
+
invalidateLifecycleHooks(provider) {
|
|
752
|
+
this.sharedHookMisses.clear();
|
|
753
|
+
for (const [id, binding] of this.hookBindings) {
|
|
754
|
+
if (binding.owner.provider !== provider)
|
|
755
|
+
continue;
|
|
756
|
+
this.hookBindings.delete(id);
|
|
757
|
+
this.paneHookClients.delete(id);
|
|
758
|
+
this.completionStates.delete(id);
|
|
759
|
+
const record = this.terminals.get(id);
|
|
760
|
+
if (!record)
|
|
761
|
+
continue;
|
|
762
|
+
record.transcriptGeneration++;
|
|
763
|
+
record.summary.agentStatus = { provider, state: 'unknown', source: 'transcript' };
|
|
764
|
+
record.summary.updatedAt = this.timestamp();
|
|
765
|
+
this.publish(record, { type: 'terminal_updated', terminal: cloneSummary(record.summary) });
|
|
766
|
+
this.scheduleTranscriptRefresh(id);
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
receiveLifecycle(event) {
|
|
770
|
+
if (this.disposed || event.agentId || event.background || event.event.startsWith('Subagent'))
|
|
771
|
+
return Promise.resolve();
|
|
772
|
+
// Tool events are coalescible and dropped first; completion/stop events
|
|
773
|
+
// keep a reserved share of the bounded queue.
|
|
774
|
+
const low = isLowPriorityLifecycleEvent(event);
|
|
775
|
+
if (this.hookPending >= (low ? HOOK_QUEUE_LOW_PRIORITY_LIMIT : HOOK_QUEUE_LIMIT))
|
|
776
|
+
return Promise.resolve();
|
|
777
|
+
const coalesceKey = low ? `${event.provider}:${event.sessionId}` : null;
|
|
778
|
+
if (coalesceKey)
|
|
779
|
+
this.queuedToolEvents.set(coalesceKey, (this.queuedToolEvents.get(coalesceKey) ?? 0) + 1);
|
|
780
|
+
this.hookPending += 1;
|
|
781
|
+
const result = this.hookTail.then(() => {
|
|
782
|
+
if (coalesceKey) {
|
|
783
|
+
const remaining = (this.queuedToolEvents.get(coalesceKey) ?? 1) - 1;
|
|
784
|
+
if (remaining > 0) {
|
|
785
|
+
// A later tool event for this session is already queued and has
|
|
786
|
+
// the same effect (working). Skip this one without any lookup.
|
|
787
|
+
this.queuedToolEvents.set(coalesceKey, remaining);
|
|
788
|
+
return;
|
|
789
|
+
}
|
|
790
|
+
this.queuedToolEvents.delete(coalesceKey);
|
|
791
|
+
}
|
|
792
|
+
return this.applyLifecycleEvent(event);
|
|
793
|
+
});
|
|
794
|
+
this.hookTail = result.catch(() => undefined);
|
|
795
|
+
return result.finally(() => { this.hookPending -= 1; });
|
|
796
|
+
}
|
|
797
|
+
async applyLifecycleEvent(event) {
|
|
798
|
+
// Fence all awaits, including owner/config/path validation, against a pane
|
|
799
|
+
// switching commands or losing its binding while this event is in flight.
|
|
800
|
+
const targets = [...this.terminals].map(([id, record]) => ({
|
|
801
|
+
id, record, generation: record.transcriptGeneration, old: this.hookBindings.get(id)
|
|
802
|
+
}));
|
|
803
|
+
if (this.disposed || !await this.isHookProviderEnabled(event.provider))
|
|
804
|
+
return;
|
|
805
|
+
let owner = null;
|
|
806
|
+
for (const identity of event.ancestors.slice(0, 32)) {
|
|
807
|
+
owner = await this.hookDependencies.readOwner(identity, event.provider);
|
|
808
|
+
if (owner)
|
|
809
|
+
break;
|
|
810
|
+
}
|
|
811
|
+
if (!owner || (!owner.foreground && !owner.shared) || this.disposed)
|
|
812
|
+
return;
|
|
813
|
+
const ownerKey = `${owner.pid}:${owner.startTicks}`;
|
|
814
|
+
const sharedSessionKey = `${ownerKey}:${event.sessionId}`;
|
|
815
|
+
if (!owner.foreground && event.event === 'SessionStart') {
|
|
816
|
+
boundedSet(this.sharedSessionStarts, sharedSessionKey, Date.parse(event.occurredAt), HOOK_SESSION_MEMORY_LIMIT);
|
|
817
|
+
}
|
|
818
|
+
let sourceFromHook = null;
|
|
819
|
+
let sourceValidated = false;
|
|
820
|
+
const knownObservers = new Set(!owner.foreground && event.event !== 'SessionStart'
|
|
821
|
+
? [...this.hookBindings].filter(([, binding]) => binding.owner.pid === owner.pid
|
|
822
|
+
&& binding.owner.startTicks === owner.startTicks && binding.state.status.sessionId === event.sessionId).map(([id]) => id)
|
|
823
|
+
: []);
|
|
824
|
+
const lowPriority = isLowPriorityLifecycleEvent(event);
|
|
825
|
+
if (!owner.foreground && knownObservers.size === 0) {
|
|
826
|
+
// No pane owns this shared-daemon session. Tool events never pay for a
|
|
827
|
+
// daemon resolution; they cannot establish a binding. A recent failed
|
|
828
|
+
// resolution is reused until a prompt/start can change the answer.
|
|
829
|
+
if (lowPriority)
|
|
830
|
+
return;
|
|
831
|
+
if (event.event === 'SessionStart' || event.event === 'UserPromptSubmit')
|
|
832
|
+
this.sharedHookMisses.delete(sharedSessionKey);
|
|
833
|
+
else if ((this.sharedHookMisses.get(sharedSessionKey) ?? 0) > Date.now())
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
836
|
+
const proposals = [];
|
|
837
|
+
const commit = async ({ id, record, generation, old }, panePid, paneIdentity, source, options) => {
|
|
838
|
+
const sameOwner = old && hookOwnerMatches(old.owner, owner);
|
|
839
|
+
if (!sourceValidated) {
|
|
840
|
+
sourceValidated = true;
|
|
841
|
+
if (event.transcriptPath)
|
|
842
|
+
sourceFromHook = await this.hookDependencies.validateTranscript(owner, event.sessionId, event.transcriptPath);
|
|
843
|
+
}
|
|
844
|
+
if (!owner.foreground && !await this.verifySharedHookAssociation(id, owner, event.sessionId, lowPriority && !options.created))
|
|
845
|
+
return;
|
|
846
|
+
const currentPanePid = await this.ptyAdapter.getSessionPid?.(id) ?? record.summary.pid;
|
|
847
|
+
const currentOwner = await this.hookDependencies.readOwner(owner, event.provider);
|
|
848
|
+
if (currentPanePid !== panePid || !hookOwnerMatches(currentOwner, owner)
|
|
849
|
+
|| (owner.foreground && (!paneIdentity || !await this.hookDependencies.verifyPaneOwner(owner, paneIdentity)))
|
|
850
|
+
|| !this.transcriptRequestStillLive(id, record, generation) || this.hookBindings.get(id) !== old)
|
|
851
|
+
return;
|
|
852
|
+
if (options.created && !owner.foreground && this.sharedSessionBoundElsewhere(id, owner, event.sessionId))
|
|
853
|
+
return;
|
|
854
|
+
const previous = sameOwner ? old.state : undefined;
|
|
855
|
+
let state = reduceLifecycle(previous, event);
|
|
856
|
+
if (!state || state === previous)
|
|
857
|
+
return;
|
|
858
|
+
if (options.created && !owner.foreground && state.status.completion
|
|
859
|
+
&& state.status.completion.id !== previous?.status.completion?.id) {
|
|
860
|
+
// A shared binding established by this very event did not observe
|
|
861
|
+
// the turn; its outcome is not attributable to this pane.
|
|
862
|
+
const { completion: _unobserved, ...status } = state.status;
|
|
863
|
+
state = { ...state, status: { ...status, state: 'idle' } };
|
|
864
|
+
}
|
|
865
|
+
if (options.wasRetired)
|
|
866
|
+
this.retiredHookSessions.get(id)?.sessions.delete(event.sessionId);
|
|
867
|
+
const sessionChanged = record.summary.agentStatus?.sessionId !== event.sessionId;
|
|
868
|
+
if (sessionChanged) {
|
|
869
|
+
if (old)
|
|
870
|
+
this.retireHookSession(id, old);
|
|
871
|
+
this.ptyAdapter.invalidateTranscriptSource?.(id);
|
|
872
|
+
record.transcriptGeneration += 1;
|
|
873
|
+
this.agentTranscriptTracker.reset(id);
|
|
874
|
+
delete record.summary.agentCache;
|
|
875
|
+
}
|
|
876
|
+
const statusChanged = record.summary.agentStatus?.source !== 'hook' || sessionChanged
|
|
877
|
+
|| record.summary.agentStatus.state !== state.status.state
|
|
878
|
+
|| record.summary.agentStatus.completion?.id !== state.status.completion?.id;
|
|
879
|
+
state.status = { ...state.status, epoch: this.hookEpoch,
|
|
880
|
+
revision: statusChanged ? ++this.hookRevision : record.summary.agentStatus.revision };
|
|
881
|
+
this.hookBindings.set(id, {
|
|
882
|
+
owner: owner,
|
|
883
|
+
...(owner.foreground && paneIdentity ? { paneIdentity } : {}),
|
|
884
|
+
state,
|
|
885
|
+
source: sourceFromHook ?? source ?? (!sessionChanged ? old?.source ?? null : null),
|
|
886
|
+
transcriptPath: event.transcriptPath ?? (!sessionChanged ? old?.transcriptPath : undefined),
|
|
887
|
+
observedAt: Date.now(),
|
|
888
|
+
transcriptReadAt: sessionChanged ? 0 : old?.transcriptReadAt ?? 0,
|
|
889
|
+
cacheReadAt: sessionChanged ? 0 : old?.cacheReadAt ?? 0,
|
|
890
|
+
...(!sessionChanged && old?.confirmation ? { confirmation: old.confirmation } : {})
|
|
891
|
+
});
|
|
892
|
+
this.hookUnbound.delete(id);
|
|
893
|
+
if (!owner.foreground) {
|
|
894
|
+
const history = this.paneHookClients.get(id);
|
|
895
|
+
const clientKey = options.client ? `${options.client.pid}:${options.client.startTicks}` : history?.client;
|
|
896
|
+
if (clientKey)
|
|
897
|
+
this.paneHookClients.set(id, { client: clientKey, lastBoundAt: Date.parse(event.occurredAt) });
|
|
898
|
+
}
|
|
899
|
+
record.summary.agentStatus = state.status;
|
|
900
|
+
// The hook may arrive before the next process catalog poll.
|
|
901
|
+
if (!record.summary.currentCommand)
|
|
902
|
+
record.summary.currentCommand = event.provider;
|
|
903
|
+
record.summary.activity = activityFromSignals({ busy: false, recentOutput: false, agentStatus: state.status });
|
|
904
|
+
if (statusChanged) {
|
|
905
|
+
record.summary.updatedAt = this.timestamp();
|
|
906
|
+
this.publish(record, { type: 'terminal_updated', terminal: cloneSummary(record.summary) });
|
|
907
|
+
}
|
|
908
|
+
this.completionStates.delete(id);
|
|
909
|
+
if (state.status.completion && state.status.completion.id !== previous?.status.completion?.id) {
|
|
910
|
+
this.emitCompletion({
|
|
911
|
+
terminalId: id,
|
|
912
|
+
terminalTitle: record.summary.title,
|
|
913
|
+
provider: event.provider,
|
|
914
|
+
outcome: state.status.completion.outcome,
|
|
915
|
+
occurredAt: state.status.completion.occurredAt,
|
|
916
|
+
completionId: state.status.completion.id,
|
|
917
|
+
commandName: record.summary.currentCommand
|
|
918
|
+
});
|
|
919
|
+
}
|
|
920
|
+
if (event.event === 'SessionEnd')
|
|
921
|
+
this.forgetHookBinding(id, this.hookBindings.get(id));
|
|
922
|
+
else {
|
|
923
|
+
this.startStatusPoller();
|
|
924
|
+
this.scheduleTranscriptRefresh(id);
|
|
925
|
+
}
|
|
926
|
+
};
|
|
927
|
+
for (const target of targets) {
|
|
928
|
+
const { id, record, generation, old } = target;
|
|
929
|
+
if (knownObservers.size > 0 && !knownObservers.has(id))
|
|
930
|
+
continue;
|
|
931
|
+
if (!this.transcriptRequestStillLive(id, record, generation) || this.hookBindings.get(id) !== old)
|
|
932
|
+
continue;
|
|
933
|
+
const panePid = await this.ptyAdapter.getSessionPid?.(id) ?? record.summary.pid;
|
|
934
|
+
const paneIdentity = event.ancestors.find((identity) => identity.pid === panePid);
|
|
935
|
+
let matched = owner.foreground && !!paneIdentity
|
|
936
|
+
&& await this.hookDependencies.verifyPaneOwner(owner, paneIdentity);
|
|
937
|
+
let resolution = null;
|
|
938
|
+
let source = null;
|
|
939
|
+
const established = !owner.foreground && !!old && hookOwnerMatches(old.owner, owner) && old.state.status.sessionId === event.sessionId;
|
|
940
|
+
if (!matched) {
|
|
941
|
+
if (owner.foreground)
|
|
942
|
+
continue;
|
|
943
|
+
if (record.summary.agentStatus?.provider && record.summary.agentStatus.provider !== event.provider)
|
|
944
|
+
continue;
|
|
945
|
+
// A pane at its shell prompt, or running the other provider, cannot
|
|
946
|
+
// own one of this provider's daemon sessions: skip it without a lookup.
|
|
947
|
+
if (!established && this.ptyAdapter.pollSessions && !foregroundMayRunProvider(record.summary, event.provider))
|
|
948
|
+
continue;
|
|
949
|
+
// A shared app-server may not descend from its TUI. Only an existing
|
|
950
|
+
// exact native binding can bridge it; cwd/candidate lists cannot.
|
|
951
|
+
resolution = established ? await this.resolveHookNativeResolution(id, false) : await this.resolveFreshHookNativeResolution(id);
|
|
952
|
+
if (established && (resolution?.source.provider !== event.provider || resolution.source.sessionId !== event.sessionId)) {
|
|
953
|
+
resolution = await this.resolveFreshHookNativeResolution(id);
|
|
954
|
+
}
|
|
955
|
+
source = resolution?.source ?? null;
|
|
956
|
+
matched = source?.provider === event.provider && source.sessionId === event.sessionId;
|
|
957
|
+
}
|
|
958
|
+
if (!matched || !this.transcriptRequestStillLive(id, record, generation))
|
|
959
|
+
continue;
|
|
960
|
+
const sameOwner = old && hookOwnerMatches(old.owner, owner);
|
|
961
|
+
const retired = this.retiredHookSessions.get(id);
|
|
962
|
+
const wasRetired = retired?.owner === ownerKey && retired.sessions.has(event.sessionId);
|
|
963
|
+
if (wasRetired && event.event !== 'SessionStart')
|
|
964
|
+
continue;
|
|
965
|
+
if (wasRetired || (sameOwner && old.state.status.sessionId !== event.sessionId && event.event !== 'SessionStart')) {
|
|
966
|
+
if (owner.foreground)
|
|
967
|
+
this.ptyAdapter.invalidateTranscriptSource?.(id);
|
|
968
|
+
const native = owner.foreground ? await this.resolveHookNativeSource(id, true)
|
|
969
|
+
: await this.resolveFreshHookNativeSource(id);
|
|
970
|
+
if (native?.provider !== event.provider || native.sessionId !== event.sessionId
|
|
971
|
+
|| !this.transcriptRequestStillLive(id, record, generation))
|
|
972
|
+
continue;
|
|
973
|
+
}
|
|
974
|
+
if (!owner.foreground && !established) {
|
|
975
|
+
// A daemon cwd match is not tied to this TUI. A new shared binding
|
|
976
|
+
// needs independent corroboration, and is committed only when this
|
|
977
|
+
// is the sole pane proposing it.
|
|
978
|
+
if (!this.corroborateSharedBinding(id, event, sharedSessionKey, resolution)) {
|
|
979
|
+
this.noteHookUnbound(id, 'shared_daemon_unverified');
|
|
980
|
+
continue;
|
|
981
|
+
}
|
|
982
|
+
proposals.push({ target, panePid, source: source, client: resolution?.client });
|
|
983
|
+
continue;
|
|
984
|
+
}
|
|
985
|
+
await commit(target, panePid, paneIdentity, source, { created: false, wasRetired });
|
|
986
|
+
}
|
|
987
|
+
if (proposals.length === 1) {
|
|
988
|
+
const [{ target, panePid, source, client }] = proposals;
|
|
989
|
+
const retired = this.retiredHookSessions.get(target.id);
|
|
990
|
+
await commit(target, panePid, undefined, source, {
|
|
991
|
+
created: true, client, wasRetired: retired?.owner === ownerKey && retired.sessions.has(event.sessionId)
|
|
992
|
+
});
|
|
993
|
+
}
|
|
994
|
+
else if (proposals.length > 1) {
|
|
995
|
+
for (const { target } of proposals)
|
|
996
|
+
this.noteHookUnbound(target.id, 'shared_daemon_same_cwd');
|
|
997
|
+
}
|
|
998
|
+
if (!owner.foreground && !lowPriority && ![...this.hookBindings.values()].some(binding => binding.owner.pid === owner.pid
|
|
999
|
+
&& binding.owner.startTicks === owner.startTicks && binding.state.status.sessionId === event.sessionId)) {
|
|
1000
|
+
boundedSet(this.sharedHookMisses, sharedSessionKey, Date.now() + SHARED_HOOK_MISS_TTL_MS, HOOK_SESSION_MEMORY_LIMIT);
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
// Corroboration for a NEW shared-daemon binding of `event.sessionId` on a
|
|
1004
|
+
// pane whose fresh daemon lookup already names it uniquely:
|
|
1005
|
+
// - live: the pane's own process holds the rollout open (exact);
|
|
1006
|
+
// - argv: first binding for this TUI process and `resume <id>` names it;
|
|
1007
|
+
// - launch: first binding for this TUI process and the UUIDv7 thread was
|
|
1008
|
+
// created while that process was starting (fresh `codex`/`codex fork`);
|
|
1009
|
+
// - session-start: a later switch in the same TUI process (/new, /resume),
|
|
1010
|
+
// where this session's SessionStart arrived after the pane's last bound
|
|
1011
|
+
// event;
|
|
1012
|
+
// - sole-client: every other daemon client is an identified codex process
|
|
1013
|
+
// in another cwd, so the one thread matched in this cwd can only be this
|
|
1014
|
+
// TUI's (covers picker `codex resume` and switches whose SessionStart was
|
|
1015
|
+
// missed). Several clients in one cwd still fail closed; the history
|
|
1016
|
+
// picker lets the user choose there. Anything else fails closed.
|
|
1017
|
+
corroborateSharedBinding(id, event, sharedSessionKey, resolution) {
|
|
1018
|
+
if (!resolution)
|
|
1019
|
+
return null;
|
|
1020
|
+
if (resolution.evidence === 'live')
|
|
1021
|
+
return 'live';
|
|
1022
|
+
const client = resolution.client;
|
|
1023
|
+
if (resolution.evidence !== 'daemon' || !client)
|
|
1024
|
+
return null;
|
|
1025
|
+
const history = this.paneHookClients.get(id);
|
|
1026
|
+
if (history?.client !== `${client.pid}:${client.startTicks}`) {
|
|
1027
|
+
if (client.statedSessionId === event.sessionId)
|
|
1028
|
+
return 'argv';
|
|
1029
|
+
const created = event.provider === 'codex' ? codexSessionCreatedAtMs(event.sessionId) : null;
|
|
1030
|
+
if (created !== null && client.startedAtMs !== null && created >= client.startedAtMs - SHARED_LAUNCH_SKEW_MS
|
|
1031
|
+
&& created <= client.startedAtMs + SHARED_LAUNCH_WINDOW_MS)
|
|
1032
|
+
return 'launch';
|
|
1033
|
+
return client.soleDaemonClientInCwd ? 'sole-client' : null;
|
|
1034
|
+
}
|
|
1035
|
+
const started = this.sharedSessionStarts.get(sharedSessionKey);
|
|
1036
|
+
if (started !== undefined && started > history.lastBoundAt)
|
|
1037
|
+
return 'session-start';
|
|
1038
|
+
return client.soleDaemonClientInCwd ? 'sole-client' : null;
|
|
1039
|
+
}
|
|
1040
|
+
sharedSessionBoundElsewhere(id, owner, sessionId) {
|
|
1041
|
+
for (const [other, binding] of this.hookBindings) {
|
|
1042
|
+
if (other !== id && binding.owner.pid === owner.pid && binding.owner.startTicks === owner.startTicks
|
|
1043
|
+
&& binding.state.status.sessionId === sessionId)
|
|
1044
|
+
return true;
|
|
1045
|
+
}
|
|
1046
|
+
return false;
|
|
1047
|
+
}
|
|
1048
|
+
noteHookUnbound(id, reason) {
|
|
1049
|
+
if (this.hookBindings.has(id) || !this.terminals.has(id))
|
|
1050
|
+
return;
|
|
1051
|
+
this.hookUnbound.set(id, { reason, at: Date.now() });
|
|
1052
|
+
}
|
|
1053
|
+
retireHookSession(id, binding) {
|
|
1054
|
+
const owner = `${binding.owner.pid}:${binding.owner.startTicks}`;
|
|
1055
|
+
const previous = this.retiredHookSessions.get(id);
|
|
1056
|
+
const sessions = previous?.owner === owner ? previous.sessions : new Set();
|
|
1057
|
+
if (binding.state.status.sessionId)
|
|
1058
|
+
sessions.add(binding.state.status.sessionId);
|
|
1059
|
+
while (sessions.size > 128)
|
|
1060
|
+
sessions.delete(sessions.values().next().value);
|
|
1061
|
+
this.retiredHookSessions.set(id, { owner, sessions });
|
|
1062
|
+
}
|
|
1063
|
+
forgetHookBinding(id, binding, replacement) {
|
|
1064
|
+
if (this.hookBindings.get(id) !== binding)
|
|
1065
|
+
return;
|
|
1066
|
+
this.retireHookSession(id, binding);
|
|
1067
|
+
this.hookBindings.delete(id);
|
|
1068
|
+
this.completionStates.delete(id);
|
|
1069
|
+
this.agentTranscriptTracker.reset(id);
|
|
1070
|
+
const record = this.terminals.get(id);
|
|
1071
|
+
if (!record)
|
|
1072
|
+
return;
|
|
1073
|
+
record.transcriptGeneration++;
|
|
1074
|
+
delete record.summary.agentStatus;
|
|
1075
|
+
delete record.summary.agentCache;
|
|
1076
|
+
if (replacement)
|
|
1077
|
+
record.summary.agentStatus = { provider: replacement.provider, sessionId: replacement.sessionId, state: 'unknown', source: 'transcript' };
|
|
1078
|
+
if (!this.ptyAdapter.pollSessions)
|
|
1079
|
+
record.summary.currentCommand = replacement?.provider ?? null;
|
|
1080
|
+
record.summary.activity = activityFromSignals({ busy: record.lastKernelBusy, recentOutput: false, agentStatus: undefined });
|
|
1081
|
+
record.summary.updatedAt = this.timestamp();
|
|
1082
|
+
this.publish(record, { type: 'terminal_updated', terminal: cloneSummary(record.summary) });
|
|
1083
|
+
}
|
|
1084
|
+
async liveHookSource(id) {
|
|
1085
|
+
const binding = this.hookBindings.get(id);
|
|
1086
|
+
const record = this.terminals.get(id);
|
|
1087
|
+
if (!binding || !record)
|
|
1088
|
+
return null;
|
|
1089
|
+
const generation = record.transcriptGeneration;
|
|
1090
|
+
const isCurrent = () => this.transcriptRequestStillLive(id, record, generation) && this.hookBindings.get(id) === binding;
|
|
1091
|
+
if (!await this.isHookProviderEnabled(binding.owner.provider)) {
|
|
1092
|
+
this.invalidateLifecycleHooks(binding.owner.provider);
|
|
1093
|
+
return null;
|
|
1094
|
+
}
|
|
1095
|
+
const owner = await this.hookDependencies.readOwner(binding.owner, binding.owner.provider);
|
|
1096
|
+
if (!isCurrent())
|
|
1097
|
+
return null;
|
|
1098
|
+
if (!hookOwnerMatches(owner, binding.owner)) {
|
|
1099
|
+
this.forgetHookBinding(id, binding);
|
|
1100
|
+
return null;
|
|
1101
|
+
}
|
|
1102
|
+
if (binding.owner.foreground && (!await this.hookPaneStillOwned(id, record, binding) || !isCurrent()))
|
|
1103
|
+
return null;
|
|
1104
|
+
const native = await this.resolveHookNativeSource(id, binding.owner.foreground, { provider: binding.owner.provider, sessionId: binding.state.status.sessionId });
|
|
1105
|
+
if (!isCurrent())
|
|
1106
|
+
return null;
|
|
1107
|
+
// A discovery gap is not evidence of a new session. Shared owners stay
|
|
1108
|
+
// bound but cannot supply transcript data until their association returns.
|
|
1109
|
+
if (!binding.owner.foreground && !native)
|
|
1110
|
+
return null;
|
|
1111
|
+
const changed = native && (native.provider !== binding.owner.provider || native.sessionId !== binding.state.status.sessionId);
|
|
1112
|
+
const filePath = native?.filePath ?? binding.source?.filePath ?? binding.transcriptPath;
|
|
1113
|
+
const source = !changed && filePath
|
|
1114
|
+
? await this.hookDependencies.validateTranscript(binding.owner, binding.state.status.sessionId, filePath) : null;
|
|
1115
|
+
// Native discovery and path validation can both outlive the provider.
|
|
1116
|
+
const currentNative = !binding.owner.foreground ? await this.resolveFreshHookNativeSource(id) : null;
|
|
1117
|
+
const paneOwned = binding.owner.foreground ? await this.hookPaneStillOwned(id, record, binding)
|
|
1118
|
+
: !!native && currentNative?.provider === native.provider && currentNative.sessionId === native.sessionId
|
|
1119
|
+
&& currentNative.filePath === native.filePath;
|
|
1120
|
+
const currentOwner = await this.hookDependencies.readOwner(binding.owner, binding.owner.provider);
|
|
1121
|
+
if (!isCurrent())
|
|
1122
|
+
return null;
|
|
1123
|
+
if (!hookOwnerMatches(currentOwner, binding.owner)) {
|
|
1124
|
+
this.forgetHookBinding(id, binding);
|
|
1125
|
+
return null;
|
|
1126
|
+
}
|
|
1127
|
+
if (!paneOwned)
|
|
1128
|
+
return null;
|
|
1129
|
+
if (changed) {
|
|
1130
|
+
this.forgetHookBinding(id, binding, native);
|
|
1131
|
+
return null;
|
|
1132
|
+
}
|
|
1133
|
+
binding.source = source;
|
|
1134
|
+
return source;
|
|
1135
|
+
}
|
|
1136
|
+
async hookPaneStillOwned(id, record, binding) {
|
|
1137
|
+
if (!binding.owner.foreground)
|
|
1138
|
+
return this.verifySharedHookAssociation(id, binding.owner, binding.state.status.sessionId);
|
|
1139
|
+
const pane = binding.paneIdentity;
|
|
1140
|
+
return !!pane && (await this.ptyAdapter.getSessionPid?.(id) ?? record.summary.pid) === pane.pid
|
|
1141
|
+
&& await this.hookDependencies.verifyPaneOwner(binding.owner, pane);
|
|
1142
|
+
}
|
|
1143
|
+
// `allowCached` is only for tool events of an already-established binding:
|
|
1144
|
+
// they can mark the pane working but never publish a completion.
|
|
1145
|
+
async verifySharedHookAssociation(id, owner, sessionId, allowCached = false) {
|
|
1146
|
+
if (!owner.shared)
|
|
1147
|
+
return false;
|
|
1148
|
+
if (allowCached) {
|
|
1149
|
+
const cached = await this.resolveHookNativeSource(id, false);
|
|
1150
|
+
if (cached?.provider === owner.provider && cached.sessionId === sessionId)
|
|
1151
|
+
return true;
|
|
1152
|
+
}
|
|
1153
|
+
const current = await this.resolveFreshHookNativeSource(id);
|
|
1154
|
+
return current?.provider === owner.provider && current.sessionId === sessionId;
|
|
1155
|
+
}
|
|
1156
|
+
async resolveFreshHookNativeSource(id) {
|
|
1157
|
+
return (await this.resolveFreshHookNativeResolution(id))?.source ?? null;
|
|
1158
|
+
}
|
|
1159
|
+
async resolveFreshHookNativeResolution(id) {
|
|
1160
|
+
const resolution = await this.ptyAdapter.resolveTranscriptSourceEvidence?.(id, { fresh: true });
|
|
1161
|
+
return resolution && (resolution.evidence === 'live' || resolution.evidence === 'daemon') ? resolution : null;
|
|
1162
|
+
}
|
|
1163
|
+
async resolveHookNativeSource(id, foreground, expected) {
|
|
1164
|
+
return (await this.resolveHookNativeResolution(id, foreground, expected))?.source ?? null;
|
|
1165
|
+
}
|
|
1166
|
+
async resolveHookNativeResolution(id, foreground, expected) {
|
|
1167
|
+
const resolution = await this.ptyAdapter.resolveTranscriptSourceEvidence?.(id);
|
|
1168
|
+
// Weak evidence can locate an already-authoritative foreground session,
|
|
1169
|
+
// but cannot establish a shared owner or change the hook's session ID.
|
|
1170
|
+
const locator = foreground && expected && resolution?.source.provider === expected.provider
|
|
1171
|
+
&& resolution.source.sessionId === expected.sessionId;
|
|
1172
|
+
return resolution && (resolution.evidence === 'live' || (!foreground && resolution.evidence === 'daemon') || locator)
|
|
1173
|
+
? resolution : null;
|
|
1174
|
+
}
|
|
626
1175
|
getTerminal(id) {
|
|
627
1176
|
const record = this.terminals.get(id);
|
|
628
1177
|
return record ? cloneSummary(record.summary) : null;
|
|
@@ -688,11 +1237,18 @@ export class TerminalManager {
|
|
|
688
1237
|
if (selection.candidateId) {
|
|
689
1238
|
const result = await this.ptyAdapter.resolveTranscriptCandidates?.(id);
|
|
690
1239
|
source = result?.candidates.find((candidate) => this.transcriptCandidateId(id, result.binding, candidate) === selection.candidateId);
|
|
1240
|
+
const binding = this.hookBindings.get(id);
|
|
1241
|
+
if (source && binding) {
|
|
1242
|
+
const live = await this.liveHookSource(id);
|
|
1243
|
+
source = live && live.provider === source.provider && live.sessionId === source.sessionId
|
|
1244
|
+
&& live.filePath === source.filePath ? live : null;
|
|
1245
|
+
}
|
|
691
1246
|
}
|
|
692
1247
|
else {
|
|
693
|
-
source = await this.
|
|
1248
|
+
source = await this.resolveTranscriptWithHooks(id);
|
|
694
1249
|
}
|
|
695
1250
|
if (!source ||
|
|
1251
|
+
!this.hookSourceMatches(id, source) ||
|
|
696
1252
|
(selection.expectedSessionId !== undefined && source.sessionId !== selection.expectedSessionId) ||
|
|
697
1253
|
!transcriptSourceMatchesForeground(record.summary, source.provider) ||
|
|
698
1254
|
!this.transcriptRequestStillLive(id, record, generation)) {
|
|
@@ -703,6 +1259,16 @@ export class TerminalManager {
|
|
|
703
1259
|
transcriptCandidateId(id, binding, source) {
|
|
704
1260
|
return createHash('sha256').update(JSON.stringify([id, binding, source.provider, source.sessionId, source.filePath])).digest('hex');
|
|
705
1261
|
}
|
|
1262
|
+
hookSourceMatches(id, source) {
|
|
1263
|
+
const hook = this.hookBindings.get(id);
|
|
1264
|
+
return !hook || (source.provider === hook.owner.provider && source.sessionId === hook.state.status.sessionId);
|
|
1265
|
+
}
|
|
1266
|
+
async resolveTranscriptWithHooks(id) {
|
|
1267
|
+
const source = await this.liveHookSource(id);
|
|
1268
|
+
if (source || this.hookBindings.has(id))
|
|
1269
|
+
return source;
|
|
1270
|
+
return await this.ptyAdapter.resolveTranscriptSource?.(id) ?? null;
|
|
1271
|
+
}
|
|
706
1272
|
async transcriptSelectionStillLive(id, live, selection) {
|
|
707
1273
|
if (!this.transcriptRequestStillLive(id, live.record, live.generation))
|
|
708
1274
|
return false;
|
|
@@ -711,7 +1277,7 @@ export class TerminalManager {
|
|
|
711
1277
|
current.source.sessionId === live.source.sessionId && current.source.filePath === live.source.filePath);
|
|
712
1278
|
}
|
|
713
1279
|
transcriptRequestStillLive(id, record, generation) {
|
|
714
|
-
return this.terminals.get(id) === record &&
|
|
1280
|
+
return !this.disposed && this.terminals.get(id) === record &&
|
|
715
1281
|
record.summary.status === 'running' &&
|
|
716
1282
|
!record.closing &&
|
|
717
1283
|
record.transcriptGeneration === generation;
|
|
@@ -721,10 +1287,29 @@ export class TerminalManager {
|
|
|
721
1287
|
return null;
|
|
722
1288
|
const live = await this.resolveLiveTranscriptSource(id, options);
|
|
723
1289
|
if (!live || signal?.aborted) {
|
|
724
|
-
return null;
|
|
1290
|
+
return live || signal?.aborted ? null : await this.transcriptSessionChangedPage(id, options);
|
|
725
1291
|
}
|
|
726
1292
|
const page = await this.transcriptStore.readPage(id, live.source, { ...options, includeTools: options.includeTools ?? false }, signal);
|
|
727
|
-
|
|
1293
|
+
if (signal?.aborted)
|
|
1294
|
+
return null;
|
|
1295
|
+
return await this.transcriptSelectionStillLive(id, live, options) ? page : await this.transcriptSessionChangedPage(id, options);
|
|
1296
|
+
}
|
|
1297
|
+
// A reader pinned to one session gets a bare identity of the pane's current
|
|
1298
|
+
// session (never its content) so the client can say the session changed
|
|
1299
|
+
// instead of reporting the transcript as temporarily unavailable.
|
|
1300
|
+
async transcriptSessionChangedPage(id, options) {
|
|
1301
|
+
if (options.expectedSessionId === undefined || options.candidateId)
|
|
1302
|
+
return null;
|
|
1303
|
+
const current = await this.resolveLiveTranscriptSource(id);
|
|
1304
|
+
if (!current || current.source.sessionId === options.expectedSessionId)
|
|
1305
|
+
return null;
|
|
1306
|
+
return {
|
|
1307
|
+
terminalId: id,
|
|
1308
|
+
source: { provider: current.source.provider, sessionId: current.source.sessionId },
|
|
1309
|
+
cursor: null,
|
|
1310
|
+
hasMore: false,
|
|
1311
|
+
output: []
|
|
1312
|
+
};
|
|
728
1313
|
}
|
|
729
1314
|
async searchTerminalTranscript(id, options, signal) {
|
|
730
1315
|
if (signal?.aborted)
|
|
@@ -751,7 +1336,7 @@ export class TerminalManager {
|
|
|
751
1336
|
return {
|
|
752
1337
|
terminalId: id,
|
|
753
1338
|
source: live ? { provider: live.source.provider, sessionId: live.source.sessionId } : null,
|
|
754
|
-
candidates: (choices?.candidates ?? []).filter((source) => transcriptSourceMatchesForeground(record.summary, source.provider)).map((source) => ({
|
|
1339
|
+
candidates: (choices?.candidates ?? []).filter((source) => this.hookSourceMatches(id, source) && transcriptSourceMatchesForeground(record.summary, source.provider)).map((source) => ({
|
|
755
1340
|
id: this.transcriptCandidateId(id, choices.binding, source),
|
|
756
1341
|
provider: source.provider, sessionId: source.sessionId,
|
|
757
1342
|
title: (source.title ?? '').slice(0, 240), cwd: source.cwd ?? '', updatedAt: source.updatedAt ?? null
|
|
@@ -1096,6 +1681,7 @@ export class TerminalManager {
|
|
|
1096
1681
|
}
|
|
1097
1682
|
this.disposeRecord(record);
|
|
1098
1683
|
this.terminals.delete(id);
|
|
1684
|
+
this.publishRemovedActivity(record);
|
|
1099
1685
|
return true;
|
|
1100
1686
|
}, 0, true);
|
|
1101
1687
|
const close = queuedClose.catch(async (error) => {
|
|
@@ -1136,6 +1722,7 @@ export class TerminalManager {
|
|
|
1136
1722
|
if (persistentState === false) {
|
|
1137
1723
|
this.disposeRecord(record);
|
|
1138
1724
|
this.terminals.delete(record.summary.id);
|
|
1725
|
+
this.publishRemovedActivity(record);
|
|
1139
1726
|
return true;
|
|
1140
1727
|
}
|
|
1141
1728
|
record.summary.status = 'running';
|
|
@@ -1359,8 +1946,32 @@ export class TerminalManager {
|
|
|
1359
1946
|
for (const subscriber of record.subscribers) {
|
|
1360
1947
|
subscriber(message);
|
|
1361
1948
|
}
|
|
1949
|
+
if (message.type === 'terminal_updated' || message.type === 'exit')
|
|
1950
|
+
this.publishActivity(record.summary);
|
|
1951
|
+
}
|
|
1952
|
+
publishActivity(summary) {
|
|
1953
|
+
for (const subscriber of this.activitySubscribers) {
|
|
1954
|
+
try {
|
|
1955
|
+
subscriber(cloneSummary(summary));
|
|
1956
|
+
}
|
|
1957
|
+
catch { /* A failed observer cannot stop a terminal. */ }
|
|
1958
|
+
}
|
|
1959
|
+
}
|
|
1960
|
+
publishRemovedActivity(record) {
|
|
1961
|
+
const removed = { ...cloneSummary(record.summary), status: 'exited', pid: null, updatedAt: this.timestamp() };
|
|
1962
|
+
delete removed.agentStatus;
|
|
1963
|
+
delete removed.agentCache;
|
|
1964
|
+
delete removed.activity;
|
|
1965
|
+
delete removed.currentCommand;
|
|
1966
|
+
this.publishActivity(removed);
|
|
1362
1967
|
}
|
|
1363
1968
|
trackCompletion(summary) {
|
|
1969
|
+
// Verified hooks emit one semantic completion below, never a second alarm
|
|
1970
|
+
// inferred from process activity or a transcript poll.
|
|
1971
|
+
if (summary.agentStatus?.source === 'hook') {
|
|
1972
|
+
this.completionStates.delete(summary.id);
|
|
1973
|
+
return;
|
|
1974
|
+
}
|
|
1364
1975
|
if (summary.status !== 'running') {
|
|
1365
1976
|
this.completionStates.delete(summary.id);
|
|
1366
1977
|
return;
|
|
@@ -1420,9 +2031,13 @@ export class TerminalManager {
|
|
|
1420
2031
|
const record = this.terminals.get(terminalId);
|
|
1421
2032
|
const inferredProvider = inferAgentProviderFromCommand(currentCommand);
|
|
1422
2033
|
const graceProvider = expectedAgentProvider(currentCommand, record?.summary.agentStatus?.provider);
|
|
1423
|
-
const source = await this.
|
|
2034
|
+
const source = await this.resolveTranscriptWithHooks(terminalId);
|
|
1424
2035
|
if (!isCurrent())
|
|
1425
2036
|
return null;
|
|
2037
|
+
if (source && !this.hookSourceMatches(terminalId, source)) {
|
|
2038
|
+
const hook = this.hookBindings.get(terminalId);
|
|
2039
|
+
return { status: hook.state.status, cache: null };
|
|
2040
|
+
}
|
|
1426
2041
|
const providerMismatch = Boolean(source && graceProvider && source.provider !== graceProvider);
|
|
1427
2042
|
if (!source || providerMismatch) {
|
|
1428
2043
|
const now = Date.now();
|
|
@@ -1460,6 +2075,29 @@ export class TerminalManager {
|
|
|
1460
2075
|
if (record)
|
|
1461
2076
|
record.transcriptSourceMissingSince = null;
|
|
1462
2077
|
try {
|
|
2078
|
+
const hook = this.hookBindings.get(terminalId);
|
|
2079
|
+
if (hook) {
|
|
2080
|
+
// Confirm near the file tail so megabytes of tool output cannot hold
|
|
2081
|
+
// up a completion while the usage/cache reader catches up separately.
|
|
2082
|
+
const info = await statFile(source.filePath);
|
|
2083
|
+
const fingerprint = JSON.stringify([source.filePath, info.dev, info.ino, info.size, info.mtimeMs, info.ctimeMs]);
|
|
2084
|
+
if (hook.confirmation?.fingerprint !== fingerprint) {
|
|
2085
|
+
hook.confirmation = { fingerprint, value: await readAgentTranscriptConfirmation(source) };
|
|
2086
|
+
}
|
|
2087
|
+
const completion = hook.confirmation.value;
|
|
2088
|
+
if (!isCurrent())
|
|
2089
|
+
return null;
|
|
2090
|
+
let snapshot;
|
|
2091
|
+
if (Date.now() - hook.cacheReadAt < 30_000) {
|
|
2092
|
+
snapshot = { status: hook.state.status, cache: record?.summary.agentCache ?? null };
|
|
2093
|
+
}
|
|
2094
|
+
else {
|
|
2095
|
+
hook.cacheReadAt = Date.now();
|
|
2096
|
+
snapshot = await this.agentTranscriptTracker.readSnapshot(terminalId, source);
|
|
2097
|
+
}
|
|
2098
|
+
const { completion: staleCompletion, ...current } = snapshot;
|
|
2099
|
+
return isCurrent() ? { ...current, ...(completion ? { completion } : {}) } : null;
|
|
2100
|
+
}
|
|
1463
2101
|
const snapshot = await this.agentTranscriptTracker.readSnapshot(terminalId, source);
|
|
1464
2102
|
return isCurrent() ? snapshot : null;
|
|
1465
2103
|
}
|
|
@@ -1468,6 +2106,10 @@ export class TerminalManager {
|
|
|
1468
2106
|
}
|
|
1469
2107
|
}
|
|
1470
2108
|
disposeRecord(record) {
|
|
2109
|
+
this.hookBindings.delete(record.summary.id);
|
|
2110
|
+
this.paneHookClients.delete(record.summary.id);
|
|
2111
|
+
this.hookUnbound.delete(record.summary.id);
|
|
2112
|
+
this.retiredHookSessions.delete(record.summary.id);
|
|
1471
2113
|
record.transcriptGeneration += 1;
|
|
1472
2114
|
this.agentTranscriptTracker.reset(record.summary.id);
|
|
1473
2115
|
this.completionStates.delete(record.summary.id);
|
|
@@ -1538,6 +2180,11 @@ export class TerminalManager {
|
|
|
1538
2180
|
return tracked;
|
|
1539
2181
|
}
|
|
1540
2182
|
}
|
|
2183
|
+
function hookOwnerMatches(current, expected) {
|
|
2184
|
+
return current?.pid === expected.pid && current.startTicks === expected.startTicks
|
|
2185
|
+
&& current.provider === expected.provider && current.foreground === expected.foreground
|
|
2186
|
+
&& current.shared === expected.shared;
|
|
2187
|
+
}
|
|
1541
2188
|
function agentCompletionOutcome(state) {
|
|
1542
2189
|
if (state === 'done')
|
|
1543
2190
|
return 'finished';
|
|
@@ -1580,11 +2227,15 @@ function cloneSummary(summary) {
|
|
|
1580
2227
|
return {
|
|
1581
2228
|
...summary,
|
|
1582
2229
|
...(summary.capabilities ? { capabilities: { ...summary.capabilities } } : {}),
|
|
1583
|
-
...(summary.agentStatus ? { agentStatus: { ...summary.agentStatus
|
|
2230
|
+
...(summary.agentStatus ? { agentStatus: { ...summary.agentStatus,
|
|
2231
|
+
...(summary.agentStatus.completion ? { completion: { ...summary.agentStatus.completion } } : {}) } } : {}),
|
|
1584
2232
|
...(summary.agentCache ? { agentCache: { ...summary.agentCache, source: { ...summary.agentCache.source } } } : {})
|
|
1585
2233
|
};
|
|
1586
2234
|
}
|
|
1587
2235
|
function activityFromSignals(options) {
|
|
2236
|
+
if (options.agentStatus?.source === 'hook') {
|
|
2237
|
+
return options.agentStatus.state === 'working' || options.agentStatus.state === 'finishing' ? 'busy' : 'idle';
|
|
2238
|
+
}
|
|
1588
2239
|
if (options.busy || options.recentOutput || options.agentStatus?.state === 'working') {
|
|
1589
2240
|
return 'busy';
|
|
1590
2241
|
}
|
|
@@ -1595,7 +2246,8 @@ function activityFromSignals(options) {
|
|
|
1595
2246
|
return 'idle';
|
|
1596
2247
|
}
|
|
1597
2248
|
function agentStatusEquals(left, right) {
|
|
1598
|
-
return left?.provider === right?.provider && left?.sessionId === right?.sessionId && left?.state === right?.state
|
|
2249
|
+
return left?.provider === right?.provider && left?.sessionId === right?.sessionId && left?.state === right?.state
|
|
2250
|
+
&& left?.source === right?.source && left?.revision === right?.revision && left?.epoch === right?.epoch;
|
|
1599
2251
|
}
|
|
1600
2252
|
function agentCacheEquals(left, right) {
|
|
1601
2253
|
if (!left || !right) {
|
|
@@ -1626,6 +2278,14 @@ function isAgentRuntimeWrapperCommand(command) {
|
|
|
1626
2278
|
function expectedAgentProvider(command, existingProvider) {
|
|
1627
2279
|
return inferAgentProviderFromCommand(command) ?? (isAgentRuntimeWrapperCommand(command) ? existingProvider ?? null : null);
|
|
1628
2280
|
}
|
|
2281
|
+
// Wrappers other than node keep their own name, so only an idle shell or the
|
|
2282
|
+
// other agent provider rules a pane out.
|
|
2283
|
+
function foregroundMayRunProvider(summary, provider) {
|
|
2284
|
+
if (!summary.currentCommand)
|
|
2285
|
+
return false;
|
|
2286
|
+
const inferred = inferAgentProviderFromCommand(summary.currentCommand);
|
|
2287
|
+
return inferred === null || inferred === provider;
|
|
2288
|
+
}
|
|
1629
2289
|
function transcriptSourceMatchesForeground(summary, sourceProvider) {
|
|
1630
2290
|
if (!summary.currentCommand) {
|
|
1631
2291
|
return false;
|