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.
Files changed (126) hide show
  1. package/README.md +68 -37
  2. package/assets/screenshot/accounts.png +0 -0
  3. package/assets/screenshot/history.png +0 -0
  4. package/assets/screenshot/mobile-panes.png +0 -0
  5. package/assets/screenshot/mobile.png +0 -0
  6. package/assets/screenshot/scr.png +0 -0
  7. package/assets/screenshot/subagents.png +0 -0
  8. package/bin/nterminal.js +10 -3
  9. package/dist/client/assets/MarkdownPreview-B-LrTjTM.js +1 -0
  10. package/dist/client/assets/{TranscriptMarkdownBody-H8ZsISc6.js → TranscriptMarkdownBody-3aYVmqQz.js} +1 -1
  11. package/dist/client/assets/index-D3LN5iAH.js +58 -0
  12. package/dist/client/assets/{index-DxfBq-4P.js → index-Dd_o8KCL.js} +1 -1
  13. package/dist/client/assets/index-W-zGfY1a.css +1 -0
  14. package/dist/client/index.html +2 -2
  15. package/dist/scripts/hooks.js +124 -0
  16. package/dist/scripts/hooks.js.map +1 -0
  17. package/dist/server/accounts/accountService.d.ts +23 -1
  18. package/dist/server/accounts/accountService.js +235 -103
  19. package/dist/server/accounts/accountService.js.map +1 -1
  20. package/dist/server/accounts/providerRequests.d.ts +12 -0
  21. package/dist/server/accounts/providerRequests.js +159 -0
  22. package/dist/server/accounts/providerRequests.js.map +1 -0
  23. package/dist/server/agent/agentRoutes.js +11 -0
  24. package/dist/server/agent/agentRoutes.js.map +1 -1
  25. package/dist/server/hooks/codexAppServer.d.ts +30 -0
  26. package/dist/server/hooks/codexAppServer.js +170 -0
  27. package/dist/server/hooks/codexAppServer.js.map +1 -0
  28. package/dist/server/hooks/codexTrust.d.ts +72 -0
  29. package/dist/server/hooks/codexTrust.js +116 -0
  30. package/dist/server/hooks/codexTrust.js.map +1 -0
  31. package/dist/server/hooks/helperJournal.d.ts +16 -0
  32. package/dist/server/hooks/helperJournal.js +74 -0
  33. package/dist/server/hooks/helperJournal.js.map +1 -0
  34. package/dist/server/hooks/hookBinding.d.ts +39 -0
  35. package/dist/server/hooks/hookBinding.js +91 -0
  36. package/dist/server/hooks/hookBinding.js.map +1 -0
  37. package/dist/server/hooks/hookFiles.d.ts +24 -0
  38. package/dist/server/hooks/hookFiles.js +160 -0
  39. package/dist/server/hooks/hookFiles.js.map +1 -0
  40. package/dist/server/hooks/hookInstallation.d.ts +108 -0
  41. package/dist/server/hooks/hookInstallation.js +1074 -0
  42. package/dist/server/hooks/hookInstallation.js.map +1 -0
  43. package/dist/server/hooks/hookProcess.d.ts +5 -0
  44. package/dist/server/hooks/hookProcess.js +85 -0
  45. package/dist/server/hooks/hookProcess.js.map +1 -0
  46. package/dist/server/hooks/hookReceiver.d.ts +66 -0
  47. package/dist/server/hooks/hookReceiver.js +549 -0
  48. package/dist/server/hooks/hookReceiver.js.map +1 -0
  49. package/dist/server/hooks/lifecycle.d.ts +40 -0
  50. package/dist/server/hooks/lifecycle.js +200 -0
  51. package/dist/server/hooks/lifecycle.js.map +1 -0
  52. package/dist/server/hooks/publicationLock.d.ts +3 -0
  53. package/dist/server/hooks/publicationLock.js +137 -0
  54. package/dist/server/hooks/publicationLock.js.map +1 -0
  55. package/dist/server/hooks/verifiedLock.d.ts +24 -0
  56. package/dist/server/hooks/verifiedLock.js +207 -0
  57. package/dist/server/hooks/verifiedLock.js.map +1 -0
  58. package/dist/server/http.d.ts +4 -0
  59. package/dist/server/http.js +6 -0
  60. package/dist/server/http.js.map +1 -1
  61. package/dist/server/index.js +71 -2
  62. package/dist/server/index.js.map +1 -1
  63. package/dist/server/notifications/agentCompletionMonitor.d.ts +16 -1
  64. package/dist/server/notifications/agentCompletionMonitor.js +261 -47
  65. package/dist/server/notifications/agentCompletionMonitor.js.map +1 -1
  66. package/dist/server/notifications/completionNotificationRouter.d.ts +2 -0
  67. package/dist/server/notifications/completionNotificationRouter.js +9 -0
  68. package/dist/server/notifications/completionNotificationRouter.js.map +1 -1
  69. package/dist/server/routes/accountRoutes.js +11 -0
  70. package/dist/server/routes/accountRoutes.js.map +1 -1
  71. package/dist/server/routes/activityRoutes.d.ts +13 -0
  72. package/dist/server/routes/activityRoutes.js +165 -0
  73. package/dist/server/routes/activityRoutes.js.map +1 -0
  74. package/dist/server/routes/agentManagementRoutes.js +8 -1
  75. package/dist/server/routes/agentManagementRoutes.js.map +1 -1
  76. package/dist/server/routes/hookRoutes.d.ts +16 -0
  77. package/dist/server/routes/hookRoutes.js +109 -0
  78. package/dist/server/routes/hookRoutes.js.map +1 -0
  79. package/dist/server/routes/terminalRoutes.js +4 -0
  80. package/dist/server/routes/terminalRoutes.js.map +1 -1
  81. package/dist/server/routes/transcriptQuery.d.ts +2 -1
  82. package/dist/server/routes/transcriptQuery.js +3 -1
  83. package/dist/server/routes/transcriptQuery.js.map +1 -1
  84. package/dist/server/terminal/PtyAdapter.d.ts +19 -0
  85. package/dist/server/terminal/PtyAdapter.js.map +1 -1
  86. package/dist/server/terminal/TerminalManager.d.ts +61 -0
  87. package/dist/server/terminal/TerminalManager.js +672 -12
  88. package/dist/server/terminal/TerminalManager.js.map +1 -1
  89. package/dist/server/terminal/TmuxPtyAdapter.d.ts +14 -2
  90. package/dist/server/terminal/TmuxPtyAdapter.js +101 -15
  91. package/dist/server/terminal/TmuxPtyAdapter.js.map +1 -1
  92. package/dist/server/terminal/activityChannel.d.ts +57 -0
  93. package/dist/server/terminal/activityChannel.js +166 -0
  94. package/dist/server/terminal/activityChannel.js.map +1 -0
  95. package/dist/server/terminal/agentTranscriptStatus.d.ts +12 -0
  96. package/dist/server/terminal/agentTranscriptStatus.js +78 -7
  97. package/dist/server/terminal/agentTranscriptStatus.js.map +1 -1
  98. package/dist/server/terminal/codexTranscriptSource.d.ts +3 -0
  99. package/dist/server/terminal/codexTranscriptSource.js +99 -39
  100. package/dist/server/terminal/codexTranscriptSource.js.map +1 -1
  101. package/dist/server/terminal/subagentSources.js +2 -1
  102. package/dist/server/terminal/subagentSources.js.map +1 -1
  103. package/dist/server/terminal/transcriptHeader.d.ts +85 -0
  104. package/dist/server/terminal/transcriptHeader.js +376 -0
  105. package/dist/server/terminal/transcriptHeader.js.map +1 -0
  106. package/dist/server/terminal/transcriptHistory.d.ts +16 -2
  107. package/dist/server/terminal/transcriptHistory.js +120 -21
  108. package/dist/server/terminal/transcriptHistory.js.map +1 -1
  109. package/dist/server/terminal/transcriptStore.d.ts +6 -0
  110. package/dist/server/terminal/transcriptStore.js +222 -26
  111. package/dist/server/terminal/transcriptStore.js.map +1 -1
  112. package/dist/shared/activityProbe.d.ts +10 -0
  113. package/dist/shared/activityProbe.js +24 -0
  114. package/dist/shared/activityProbe.js.map +1 -0
  115. package/dist/shared/protocol.d.ts +45 -0
  116. package/dist/shared/protocol.js.map +1 -1
  117. package/dist/shared/types.d.ts +9 -1
  118. package/docs/features.md +21 -1
  119. package/docs/provider-hooks.md +340 -0
  120. package/docs/terminal-history.md +10 -0
  121. package/package.json +2 -1
  122. package/scripts/nterminal-hook.cjs +171 -0
  123. package/scripts/nterminalctl +26 -2
  124. package/dist/client/assets/MarkdownPreview-DjDO4lEl.js +0 -1
  125. package/dist/client/assets/index-CIYazAGv.css +0 -1
  126. 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
- const nextAgentStatus = transcript?.status;
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.ptyAdapter.resolveTranscriptSource?.(id);
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
- return !signal?.aborted && await this.transcriptSelectionStillLive(id, live, options) ? page : null;
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.ptyAdapter.resolveTranscriptSource?.(terminalId);
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;