nterminal 1.3.3 → 1.3.5

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 (114) hide show
  1. package/bin/nterminal.js +8 -3
  2. package/dist/client/assets/MarkdownPreview-BIrwUAaa.js +1 -0
  3. package/dist/client/assets/TranscriptMarkdownBody-CI0ztvMX.js +3 -0
  4. package/dist/client/assets/{index-DDCpqDNb.js → index-EN8ngMgv.js} +1 -1
  5. package/dist/client/assets/index-JQJ1Eet1.js +58 -0
  6. package/dist/client/assets/index-xkL4h3qt.css +1 -0
  7. package/dist/client/index.html +2 -2
  8. package/dist/scripts/hooks.js +83 -0
  9. package/dist/scripts/hooks.js.map +1 -0
  10. package/dist/server/accounts/accountService.d.ts +23 -1
  11. package/dist/server/accounts/accountService.js +235 -103
  12. package/dist/server/accounts/accountService.js.map +1 -1
  13. package/dist/server/accounts/providerRequests.d.ts +12 -0
  14. package/dist/server/accounts/providerRequests.js +159 -0
  15. package/dist/server/accounts/providerRequests.js.map +1 -0
  16. package/dist/server/agent/agentRoutes.js +11 -0
  17. package/dist/server/agent/agentRoutes.js.map +1 -1
  18. package/dist/server/hooks/helperJournal.d.ts +16 -0
  19. package/dist/server/hooks/helperJournal.js +74 -0
  20. package/dist/server/hooks/helperJournal.js.map +1 -0
  21. package/dist/server/hooks/hookBinding.d.ts +39 -0
  22. package/dist/server/hooks/hookBinding.js +91 -0
  23. package/dist/server/hooks/hookBinding.js.map +1 -0
  24. package/dist/server/hooks/hookFiles.d.ts +24 -0
  25. package/dist/server/hooks/hookFiles.js +160 -0
  26. package/dist/server/hooks/hookFiles.js.map +1 -0
  27. package/dist/server/hooks/hookInstallation.d.ts +79 -0
  28. package/dist/server/hooks/hookInstallation.js +978 -0
  29. package/dist/server/hooks/hookInstallation.js.map +1 -0
  30. package/dist/server/hooks/hookProcess.d.ts +5 -0
  31. package/dist/server/hooks/hookProcess.js +85 -0
  32. package/dist/server/hooks/hookProcess.js.map +1 -0
  33. package/dist/server/hooks/hookReceiver.d.ts +66 -0
  34. package/dist/server/hooks/hookReceiver.js +549 -0
  35. package/dist/server/hooks/hookReceiver.js.map +1 -0
  36. package/dist/server/hooks/lifecycle.d.ts +40 -0
  37. package/dist/server/hooks/lifecycle.js +200 -0
  38. package/dist/server/hooks/lifecycle.js.map +1 -0
  39. package/dist/server/hooks/publicationLock.d.ts +3 -0
  40. package/dist/server/hooks/publicationLock.js +137 -0
  41. package/dist/server/hooks/publicationLock.js.map +1 -0
  42. package/dist/server/hooks/verifiedLock.d.ts +24 -0
  43. package/dist/server/hooks/verifiedLock.js +207 -0
  44. package/dist/server/hooks/verifiedLock.js.map +1 -0
  45. package/dist/server/http.d.ts +4 -0
  46. package/dist/server/http.js +6 -0
  47. package/dist/server/http.js.map +1 -1
  48. package/dist/server/index.js +69 -2
  49. package/dist/server/index.js.map +1 -1
  50. package/dist/server/notifications/agentCompletionMonitor.d.ts +16 -1
  51. package/dist/server/notifications/agentCompletionMonitor.js +261 -47
  52. package/dist/server/notifications/agentCompletionMonitor.js.map +1 -1
  53. package/dist/server/notifications/completionNotificationRouter.d.ts +2 -0
  54. package/dist/server/notifications/completionNotificationRouter.js +9 -0
  55. package/dist/server/notifications/completionNotificationRouter.js.map +1 -1
  56. package/dist/server/routes/accountRoutes.js +11 -0
  57. package/dist/server/routes/accountRoutes.js.map +1 -1
  58. package/dist/server/routes/activityRoutes.d.ts +13 -0
  59. package/dist/server/routes/activityRoutes.js +165 -0
  60. package/dist/server/routes/activityRoutes.js.map +1 -0
  61. package/dist/server/routes/agentManagementRoutes.js +8 -1
  62. package/dist/server/routes/agentManagementRoutes.js.map +1 -1
  63. package/dist/server/routes/hookRoutes.d.ts +16 -0
  64. package/dist/server/routes/hookRoutes.js +77 -0
  65. package/dist/server/routes/hookRoutes.js.map +1 -0
  66. package/dist/server/routes/terminalRoutes.js +4 -0
  67. package/dist/server/routes/terminalRoutes.js.map +1 -1
  68. package/dist/server/routes/transcriptQuery.d.ts +2 -1
  69. package/dist/server/routes/transcriptQuery.js +3 -1
  70. package/dist/server/routes/transcriptQuery.js.map +1 -1
  71. package/dist/server/terminal/PtyAdapter.d.ts +18 -0
  72. package/dist/server/terminal/PtyAdapter.js.map +1 -1
  73. package/dist/server/terminal/TerminalManager.d.ts +61 -0
  74. package/dist/server/terminal/TerminalManager.js +665 -12
  75. package/dist/server/terminal/TerminalManager.js.map +1 -1
  76. package/dist/server/terminal/TmuxPtyAdapter.d.ts +14 -2
  77. package/dist/server/terminal/TmuxPtyAdapter.js +104 -16
  78. package/dist/server/terminal/TmuxPtyAdapter.js.map +1 -1
  79. package/dist/server/terminal/activityChannel.d.ts +57 -0
  80. package/dist/server/terminal/activityChannel.js +166 -0
  81. package/dist/server/terminal/activityChannel.js.map +1 -0
  82. package/dist/server/terminal/agentTranscriptStatus.d.ts +12 -0
  83. package/dist/server/terminal/agentTranscriptStatus.js +78 -7
  84. package/dist/server/terminal/agentTranscriptStatus.js.map +1 -1
  85. package/dist/server/terminal/codexTranscriptSource.d.ts +2 -0
  86. package/dist/server/terminal/codexTranscriptSource.js +53 -46
  87. package/dist/server/terminal/codexTranscriptSource.js.map +1 -1
  88. package/dist/server/terminal/subagentSources.js +2 -1
  89. package/dist/server/terminal/subagentSources.js.map +1 -1
  90. package/dist/server/terminal/transcriptHeader.d.ts +85 -0
  91. package/dist/server/terminal/transcriptHeader.js +376 -0
  92. package/dist/server/terminal/transcriptHeader.js.map +1 -0
  93. package/dist/server/terminal/transcriptHistory.d.ts +16 -2
  94. package/dist/server/terminal/transcriptHistory.js +120 -21
  95. package/dist/server/terminal/transcriptHistory.js.map +1 -1
  96. package/dist/server/terminal/transcriptStore.d.ts +6 -0
  97. package/dist/server/terminal/transcriptStore.js +222 -26
  98. package/dist/server/terminal/transcriptStore.js.map +1 -1
  99. package/dist/shared/activityProbe.d.ts +10 -0
  100. package/dist/shared/activityProbe.js +24 -0
  101. package/dist/shared/activityProbe.js.map +1 -0
  102. package/dist/shared/protocol.d.ts +45 -0
  103. package/dist/shared/protocol.js.map +1 -1
  104. package/dist/shared/types.d.ts +9 -1
  105. package/docs/features.md +10 -0
  106. package/docs/provider-hooks.md +287 -0
  107. package/docs/terminal-history.md +10 -0
  108. package/package.json +2 -1
  109. package/scripts/nterminal-hook.cjs +171 -0
  110. package/scripts/nterminalctl +26 -2
  111. package/dist/client/assets/MarkdownPreview-CWK2qIZp.js +0 -1
  112. package/dist/client/assets/TranscriptMarkdownBody-Ddi-rw6X.js +0 -3
  113. package/dist/client/assets/index-CcAQThtj.js +0 -56
  114. package/dist/client/assets/index-DTTX24tu.css +0 -1
@@ -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,449 @@ 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. Anything else fails closed.
1012
+ corroborateSharedBinding(id, event, sharedSessionKey, resolution) {
1013
+ if (!resolution)
1014
+ return null;
1015
+ if (resolution.evidence === 'live')
1016
+ return 'live';
1017
+ const client = resolution.client;
1018
+ if (resolution.evidence !== 'daemon' || !client)
1019
+ return null;
1020
+ const history = this.paneHookClients.get(id);
1021
+ if (history?.client !== `${client.pid}:${client.startTicks}`) {
1022
+ if (client.statedSessionId === event.sessionId)
1023
+ return 'argv';
1024
+ const created = event.provider === 'codex' ? codexSessionCreatedAtMs(event.sessionId) : null;
1025
+ if (created !== null && client.startedAtMs !== null && created >= client.startedAtMs - SHARED_LAUNCH_SKEW_MS
1026
+ && created <= client.startedAtMs + SHARED_LAUNCH_WINDOW_MS)
1027
+ return 'launch';
1028
+ return null;
1029
+ }
1030
+ const started = this.sharedSessionStarts.get(sharedSessionKey);
1031
+ return started !== undefined && started > history.lastBoundAt ? 'session-start' : null;
1032
+ }
1033
+ sharedSessionBoundElsewhere(id, owner, sessionId) {
1034
+ for (const [other, binding] of this.hookBindings) {
1035
+ if (other !== id && binding.owner.pid === owner.pid && binding.owner.startTicks === owner.startTicks
1036
+ && binding.state.status.sessionId === sessionId)
1037
+ return true;
1038
+ }
1039
+ return false;
1040
+ }
1041
+ noteHookUnbound(id, reason) {
1042
+ if (this.hookBindings.has(id) || !this.terminals.has(id))
1043
+ return;
1044
+ this.hookUnbound.set(id, { reason, at: Date.now() });
1045
+ }
1046
+ retireHookSession(id, binding) {
1047
+ const owner = `${binding.owner.pid}:${binding.owner.startTicks}`;
1048
+ const previous = this.retiredHookSessions.get(id);
1049
+ const sessions = previous?.owner === owner ? previous.sessions : new Set();
1050
+ if (binding.state.status.sessionId)
1051
+ sessions.add(binding.state.status.sessionId);
1052
+ while (sessions.size > 128)
1053
+ sessions.delete(sessions.values().next().value);
1054
+ this.retiredHookSessions.set(id, { owner, sessions });
1055
+ }
1056
+ forgetHookBinding(id, binding, replacement) {
1057
+ if (this.hookBindings.get(id) !== binding)
1058
+ return;
1059
+ this.retireHookSession(id, binding);
1060
+ this.hookBindings.delete(id);
1061
+ this.completionStates.delete(id);
1062
+ this.agentTranscriptTracker.reset(id);
1063
+ const record = this.terminals.get(id);
1064
+ if (!record)
1065
+ return;
1066
+ record.transcriptGeneration++;
1067
+ delete record.summary.agentStatus;
1068
+ delete record.summary.agentCache;
1069
+ if (replacement)
1070
+ record.summary.agentStatus = { provider: replacement.provider, sessionId: replacement.sessionId, state: 'unknown', source: 'transcript' };
1071
+ if (!this.ptyAdapter.pollSessions)
1072
+ record.summary.currentCommand = replacement?.provider ?? null;
1073
+ record.summary.activity = activityFromSignals({ busy: record.lastKernelBusy, recentOutput: false, agentStatus: undefined });
1074
+ record.summary.updatedAt = this.timestamp();
1075
+ this.publish(record, { type: 'terminal_updated', terminal: cloneSummary(record.summary) });
1076
+ }
1077
+ async liveHookSource(id) {
1078
+ const binding = this.hookBindings.get(id);
1079
+ const record = this.terminals.get(id);
1080
+ if (!binding || !record)
1081
+ return null;
1082
+ const generation = record.transcriptGeneration;
1083
+ const isCurrent = () => this.transcriptRequestStillLive(id, record, generation) && this.hookBindings.get(id) === binding;
1084
+ if (!await this.isHookProviderEnabled(binding.owner.provider)) {
1085
+ this.invalidateLifecycleHooks(binding.owner.provider);
1086
+ return null;
1087
+ }
1088
+ const owner = await this.hookDependencies.readOwner(binding.owner, binding.owner.provider);
1089
+ if (!isCurrent())
1090
+ return null;
1091
+ if (!hookOwnerMatches(owner, binding.owner)) {
1092
+ this.forgetHookBinding(id, binding);
1093
+ return null;
1094
+ }
1095
+ if (binding.owner.foreground && (!await this.hookPaneStillOwned(id, record, binding) || !isCurrent()))
1096
+ return null;
1097
+ const native = await this.resolveHookNativeSource(id, binding.owner.foreground, { provider: binding.owner.provider, sessionId: binding.state.status.sessionId });
1098
+ if (!isCurrent())
1099
+ return null;
1100
+ // A discovery gap is not evidence of a new session. Shared owners stay
1101
+ // bound but cannot supply transcript data until their association returns.
1102
+ if (!binding.owner.foreground && !native)
1103
+ return null;
1104
+ const changed = native && (native.provider !== binding.owner.provider || native.sessionId !== binding.state.status.sessionId);
1105
+ const filePath = native?.filePath ?? binding.source?.filePath ?? binding.transcriptPath;
1106
+ const source = !changed && filePath
1107
+ ? await this.hookDependencies.validateTranscript(binding.owner, binding.state.status.sessionId, filePath) : null;
1108
+ // Native discovery and path validation can both outlive the provider.
1109
+ const currentNative = !binding.owner.foreground ? await this.resolveFreshHookNativeSource(id) : null;
1110
+ const paneOwned = binding.owner.foreground ? await this.hookPaneStillOwned(id, record, binding)
1111
+ : !!native && currentNative?.provider === native.provider && currentNative.sessionId === native.sessionId
1112
+ && currentNative.filePath === native.filePath;
1113
+ const currentOwner = await this.hookDependencies.readOwner(binding.owner, binding.owner.provider);
1114
+ if (!isCurrent())
1115
+ return null;
1116
+ if (!hookOwnerMatches(currentOwner, binding.owner)) {
1117
+ this.forgetHookBinding(id, binding);
1118
+ return null;
1119
+ }
1120
+ if (!paneOwned)
1121
+ return null;
1122
+ if (changed) {
1123
+ this.forgetHookBinding(id, binding, native);
1124
+ return null;
1125
+ }
1126
+ binding.source = source;
1127
+ return source;
1128
+ }
1129
+ async hookPaneStillOwned(id, record, binding) {
1130
+ if (!binding.owner.foreground)
1131
+ return this.verifySharedHookAssociation(id, binding.owner, binding.state.status.sessionId);
1132
+ const pane = binding.paneIdentity;
1133
+ return !!pane && (await this.ptyAdapter.getSessionPid?.(id) ?? record.summary.pid) === pane.pid
1134
+ && await this.hookDependencies.verifyPaneOwner(binding.owner, pane);
1135
+ }
1136
+ // `allowCached` is only for tool events of an already-established binding:
1137
+ // they can mark the pane working but never publish a completion.
1138
+ async verifySharedHookAssociation(id, owner, sessionId, allowCached = false) {
1139
+ if (!owner.shared)
1140
+ return false;
1141
+ if (allowCached) {
1142
+ const cached = await this.resolveHookNativeSource(id, false);
1143
+ if (cached?.provider === owner.provider && cached.sessionId === sessionId)
1144
+ return true;
1145
+ }
1146
+ const current = await this.resolveFreshHookNativeSource(id);
1147
+ return current?.provider === owner.provider && current.sessionId === sessionId;
1148
+ }
1149
+ async resolveFreshHookNativeSource(id) {
1150
+ return (await this.resolveFreshHookNativeResolution(id))?.source ?? null;
1151
+ }
1152
+ async resolveFreshHookNativeResolution(id) {
1153
+ const resolution = await this.ptyAdapter.resolveTranscriptSourceEvidence?.(id, { fresh: true });
1154
+ return resolution && (resolution.evidence === 'live' || resolution.evidence === 'daemon') ? resolution : null;
1155
+ }
1156
+ async resolveHookNativeSource(id, foreground, expected) {
1157
+ return (await this.resolveHookNativeResolution(id, foreground, expected))?.source ?? null;
1158
+ }
1159
+ async resolveHookNativeResolution(id, foreground, expected) {
1160
+ const resolution = await this.ptyAdapter.resolveTranscriptSourceEvidence?.(id);
1161
+ // Weak evidence can locate an already-authoritative foreground session,
1162
+ // but cannot establish a shared owner or change the hook's session ID.
1163
+ const locator = foreground && expected && resolution?.source.provider === expected.provider
1164
+ && resolution.source.sessionId === expected.sessionId;
1165
+ return resolution && (resolution.evidence === 'live' || (!foreground && resolution.evidence === 'daemon') || locator)
1166
+ ? resolution : null;
1167
+ }
626
1168
  getTerminal(id) {
627
1169
  const record = this.terminals.get(id);
628
1170
  return record ? cloneSummary(record.summary) : null;
@@ -688,11 +1230,18 @@ export class TerminalManager {
688
1230
  if (selection.candidateId) {
689
1231
  const result = await this.ptyAdapter.resolveTranscriptCandidates?.(id);
690
1232
  source = result?.candidates.find((candidate) => this.transcriptCandidateId(id, result.binding, candidate) === selection.candidateId);
1233
+ const binding = this.hookBindings.get(id);
1234
+ if (source && binding) {
1235
+ const live = await this.liveHookSource(id);
1236
+ source = live && live.provider === source.provider && live.sessionId === source.sessionId
1237
+ && live.filePath === source.filePath ? live : null;
1238
+ }
691
1239
  }
692
1240
  else {
693
- source = await this.ptyAdapter.resolveTranscriptSource?.(id);
1241
+ source = await this.resolveTranscriptWithHooks(id);
694
1242
  }
695
1243
  if (!source ||
1244
+ !this.hookSourceMatches(id, source) ||
696
1245
  (selection.expectedSessionId !== undefined && source.sessionId !== selection.expectedSessionId) ||
697
1246
  !transcriptSourceMatchesForeground(record.summary, source.provider) ||
698
1247
  !this.transcriptRequestStillLive(id, record, generation)) {
@@ -703,6 +1252,16 @@ export class TerminalManager {
703
1252
  transcriptCandidateId(id, binding, source) {
704
1253
  return createHash('sha256').update(JSON.stringify([id, binding, source.provider, source.sessionId, source.filePath])).digest('hex');
705
1254
  }
1255
+ hookSourceMatches(id, source) {
1256
+ const hook = this.hookBindings.get(id);
1257
+ return !hook || (source.provider === hook.owner.provider && source.sessionId === hook.state.status.sessionId);
1258
+ }
1259
+ async resolveTranscriptWithHooks(id) {
1260
+ const source = await this.liveHookSource(id);
1261
+ if (source || this.hookBindings.has(id))
1262
+ return source;
1263
+ return await this.ptyAdapter.resolveTranscriptSource?.(id) ?? null;
1264
+ }
706
1265
  async transcriptSelectionStillLive(id, live, selection) {
707
1266
  if (!this.transcriptRequestStillLive(id, live.record, live.generation))
708
1267
  return false;
@@ -711,7 +1270,7 @@ export class TerminalManager {
711
1270
  current.source.sessionId === live.source.sessionId && current.source.filePath === live.source.filePath);
712
1271
  }
713
1272
  transcriptRequestStillLive(id, record, generation) {
714
- return this.terminals.get(id) === record &&
1273
+ return !this.disposed && this.terminals.get(id) === record &&
715
1274
  record.summary.status === 'running' &&
716
1275
  !record.closing &&
717
1276
  record.transcriptGeneration === generation;
@@ -721,10 +1280,29 @@ export class TerminalManager {
721
1280
  return null;
722
1281
  const live = await this.resolveLiveTranscriptSource(id, options);
723
1282
  if (!live || signal?.aborted) {
724
- return null;
1283
+ return live || signal?.aborted ? null : await this.transcriptSessionChangedPage(id, options);
725
1284
  }
726
1285
  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;
1286
+ if (signal?.aborted)
1287
+ return null;
1288
+ return await this.transcriptSelectionStillLive(id, live, options) ? page : await this.transcriptSessionChangedPage(id, options);
1289
+ }
1290
+ // A reader pinned to one session gets a bare identity of the pane's current
1291
+ // session (never its content) so the client can say the session changed
1292
+ // instead of reporting the transcript as temporarily unavailable.
1293
+ async transcriptSessionChangedPage(id, options) {
1294
+ if (options.expectedSessionId === undefined || options.candidateId)
1295
+ return null;
1296
+ const current = await this.resolveLiveTranscriptSource(id);
1297
+ if (!current || current.source.sessionId === options.expectedSessionId)
1298
+ return null;
1299
+ return {
1300
+ terminalId: id,
1301
+ source: { provider: current.source.provider, sessionId: current.source.sessionId },
1302
+ cursor: null,
1303
+ hasMore: false,
1304
+ output: []
1305
+ };
728
1306
  }
729
1307
  async searchTerminalTranscript(id, options, signal) {
730
1308
  if (signal?.aborted)
@@ -751,7 +1329,7 @@ export class TerminalManager {
751
1329
  return {
752
1330
  terminalId: id,
753
1331
  source: live ? { provider: live.source.provider, sessionId: live.source.sessionId } : null,
754
- candidates: (choices?.candidates ?? []).filter((source) => transcriptSourceMatchesForeground(record.summary, source.provider)).map((source) => ({
1332
+ candidates: (choices?.candidates ?? []).filter((source) => this.hookSourceMatches(id, source) && transcriptSourceMatchesForeground(record.summary, source.provider)).map((source) => ({
755
1333
  id: this.transcriptCandidateId(id, choices.binding, source),
756
1334
  provider: source.provider, sessionId: source.sessionId,
757
1335
  title: (source.title ?? '').slice(0, 240), cwd: source.cwd ?? '', updatedAt: source.updatedAt ?? null
@@ -1096,6 +1674,7 @@ export class TerminalManager {
1096
1674
  }
1097
1675
  this.disposeRecord(record);
1098
1676
  this.terminals.delete(id);
1677
+ this.publishRemovedActivity(record);
1099
1678
  return true;
1100
1679
  }, 0, true);
1101
1680
  const close = queuedClose.catch(async (error) => {
@@ -1136,6 +1715,7 @@ export class TerminalManager {
1136
1715
  if (persistentState === false) {
1137
1716
  this.disposeRecord(record);
1138
1717
  this.terminals.delete(record.summary.id);
1718
+ this.publishRemovedActivity(record);
1139
1719
  return true;
1140
1720
  }
1141
1721
  record.summary.status = 'running';
@@ -1359,8 +1939,32 @@ export class TerminalManager {
1359
1939
  for (const subscriber of record.subscribers) {
1360
1940
  subscriber(message);
1361
1941
  }
1942
+ if (message.type === 'terminal_updated' || message.type === 'exit')
1943
+ this.publishActivity(record.summary);
1944
+ }
1945
+ publishActivity(summary) {
1946
+ for (const subscriber of this.activitySubscribers) {
1947
+ try {
1948
+ subscriber(cloneSummary(summary));
1949
+ }
1950
+ catch { /* A failed observer cannot stop a terminal. */ }
1951
+ }
1952
+ }
1953
+ publishRemovedActivity(record) {
1954
+ const removed = { ...cloneSummary(record.summary), status: 'exited', pid: null, updatedAt: this.timestamp() };
1955
+ delete removed.agentStatus;
1956
+ delete removed.agentCache;
1957
+ delete removed.activity;
1958
+ delete removed.currentCommand;
1959
+ this.publishActivity(removed);
1362
1960
  }
1363
1961
  trackCompletion(summary) {
1962
+ // Verified hooks emit one semantic completion below, never a second alarm
1963
+ // inferred from process activity or a transcript poll.
1964
+ if (summary.agentStatus?.source === 'hook') {
1965
+ this.completionStates.delete(summary.id);
1966
+ return;
1967
+ }
1364
1968
  if (summary.status !== 'running') {
1365
1969
  this.completionStates.delete(summary.id);
1366
1970
  return;
@@ -1420,9 +2024,13 @@ export class TerminalManager {
1420
2024
  const record = this.terminals.get(terminalId);
1421
2025
  const inferredProvider = inferAgentProviderFromCommand(currentCommand);
1422
2026
  const graceProvider = expectedAgentProvider(currentCommand, record?.summary.agentStatus?.provider);
1423
- const source = await this.ptyAdapter.resolveTranscriptSource?.(terminalId);
2027
+ const source = await this.resolveTranscriptWithHooks(terminalId);
1424
2028
  if (!isCurrent())
1425
2029
  return null;
2030
+ if (source && !this.hookSourceMatches(terminalId, source)) {
2031
+ const hook = this.hookBindings.get(terminalId);
2032
+ return { status: hook.state.status, cache: null };
2033
+ }
1426
2034
  const providerMismatch = Boolean(source && graceProvider && source.provider !== graceProvider);
1427
2035
  if (!source || providerMismatch) {
1428
2036
  const now = Date.now();
@@ -1460,6 +2068,29 @@ export class TerminalManager {
1460
2068
  if (record)
1461
2069
  record.transcriptSourceMissingSince = null;
1462
2070
  try {
2071
+ const hook = this.hookBindings.get(terminalId);
2072
+ if (hook) {
2073
+ // Confirm near the file tail so megabytes of tool output cannot hold
2074
+ // up a completion while the usage/cache reader catches up separately.
2075
+ const info = await statFile(source.filePath);
2076
+ const fingerprint = JSON.stringify([source.filePath, info.dev, info.ino, info.size, info.mtimeMs, info.ctimeMs]);
2077
+ if (hook.confirmation?.fingerprint !== fingerprint) {
2078
+ hook.confirmation = { fingerprint, value: await readAgentTranscriptConfirmation(source) };
2079
+ }
2080
+ const completion = hook.confirmation.value;
2081
+ if (!isCurrent())
2082
+ return null;
2083
+ let snapshot;
2084
+ if (Date.now() - hook.cacheReadAt < 30_000) {
2085
+ snapshot = { status: hook.state.status, cache: record?.summary.agentCache ?? null };
2086
+ }
2087
+ else {
2088
+ hook.cacheReadAt = Date.now();
2089
+ snapshot = await this.agentTranscriptTracker.readSnapshot(terminalId, source);
2090
+ }
2091
+ const { completion: staleCompletion, ...current } = snapshot;
2092
+ return isCurrent() ? { ...current, ...(completion ? { completion } : {}) } : null;
2093
+ }
1463
2094
  const snapshot = await this.agentTranscriptTracker.readSnapshot(terminalId, source);
1464
2095
  return isCurrent() ? snapshot : null;
1465
2096
  }
@@ -1468,6 +2099,10 @@ export class TerminalManager {
1468
2099
  }
1469
2100
  }
1470
2101
  disposeRecord(record) {
2102
+ this.hookBindings.delete(record.summary.id);
2103
+ this.paneHookClients.delete(record.summary.id);
2104
+ this.hookUnbound.delete(record.summary.id);
2105
+ this.retiredHookSessions.delete(record.summary.id);
1471
2106
  record.transcriptGeneration += 1;
1472
2107
  this.agentTranscriptTracker.reset(record.summary.id);
1473
2108
  this.completionStates.delete(record.summary.id);
@@ -1538,6 +2173,11 @@ export class TerminalManager {
1538
2173
  return tracked;
1539
2174
  }
1540
2175
  }
2176
+ function hookOwnerMatches(current, expected) {
2177
+ return current?.pid === expected.pid && current.startTicks === expected.startTicks
2178
+ && current.provider === expected.provider && current.foreground === expected.foreground
2179
+ && current.shared === expected.shared;
2180
+ }
1541
2181
  function agentCompletionOutcome(state) {
1542
2182
  if (state === 'done')
1543
2183
  return 'finished';
@@ -1580,11 +2220,15 @@ function cloneSummary(summary) {
1580
2220
  return {
1581
2221
  ...summary,
1582
2222
  ...(summary.capabilities ? { capabilities: { ...summary.capabilities } } : {}),
1583
- ...(summary.agentStatus ? { agentStatus: { ...summary.agentStatus } } : {}),
2223
+ ...(summary.agentStatus ? { agentStatus: { ...summary.agentStatus,
2224
+ ...(summary.agentStatus.completion ? { completion: { ...summary.agentStatus.completion } } : {}) } } : {}),
1584
2225
  ...(summary.agentCache ? { agentCache: { ...summary.agentCache, source: { ...summary.agentCache.source } } } : {})
1585
2226
  };
1586
2227
  }
1587
2228
  function activityFromSignals(options) {
2229
+ if (options.agentStatus?.source === 'hook') {
2230
+ return options.agentStatus.state === 'working' || options.agentStatus.state === 'finishing' ? 'busy' : 'idle';
2231
+ }
1588
2232
  if (options.busy || options.recentOutput || options.agentStatus?.state === 'working') {
1589
2233
  return 'busy';
1590
2234
  }
@@ -1595,7 +2239,8 @@ function activityFromSignals(options) {
1595
2239
  return 'idle';
1596
2240
  }
1597
2241
  function agentStatusEquals(left, right) {
1598
- return left?.provider === right?.provider && left?.sessionId === right?.sessionId && left?.state === right?.state;
2242
+ return left?.provider === right?.provider && left?.sessionId === right?.sessionId && left?.state === right?.state
2243
+ && left?.source === right?.source && left?.revision === right?.revision && left?.epoch === right?.epoch;
1599
2244
  }
1600
2245
  function agentCacheEquals(left, right) {
1601
2246
  if (!left || !right) {
@@ -1626,6 +2271,14 @@ function isAgentRuntimeWrapperCommand(command) {
1626
2271
  function expectedAgentProvider(command, existingProvider) {
1627
2272
  return inferAgentProviderFromCommand(command) ?? (isAgentRuntimeWrapperCommand(command) ? existingProvider ?? null : null);
1628
2273
  }
2274
+ // Wrappers other than node keep their own name, so only an idle shell or the
2275
+ // other agent provider rules a pane out.
2276
+ function foregroundMayRunProvider(summary, provider) {
2277
+ if (!summary.currentCommand)
2278
+ return false;
2279
+ const inferred = inferAgentProviderFromCommand(summary.currentCommand);
2280
+ return inferred === null || inferred === provider;
2281
+ }
1629
2282
  function transcriptSourceMatchesForeground(summary, sourceProvider) {
1630
2283
  if (!summary.currentCommand) {
1631
2284
  return false;