handmux 0.27.2 → 0.29.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/bin/handmux.js +2 -2
  2. package/dist/bin/handmux.js +51 -21
  3. package/dist/connectors/bridgeClient.js +30 -14
  4. package/dist/connectors/claude/index.js +122 -25
  5. package/dist/hooks/handmux-notify.sh +14 -2
  6. package/dist/hooks/handmux-write.cjs +15 -1
  7. package/dist/package.json +19 -19
  8. package/dist/public/assets/index-BjnnFTLC.css +32 -0
  9. package/dist/public/assets/index-aBgNeovq.js +356 -0
  10. package/dist/public/index.html +2 -2
  11. package/dist/src/agent-runtime/bridge.js +63 -1
  12. package/dist/src/agent-runtime/bridgeTransport.js +31 -4
  13. package/dist/src/agent-runtime/bridgeTypes.js +2 -0
  14. package/dist/src/agent-runtime/builtinRuntime.js +10 -2
  15. package/dist/src/agent-runtime/conversation.js +3 -3
  16. package/dist/src/agent-runtime/conversationStore.js +2 -2
  17. package/dist/src/agent-runtime/inbox.js +20 -2
  18. package/dist/src/agent-runtime/run.js +5 -1
  19. package/dist/src/agent-runtime/runtime.js +1 -0
  20. package/dist/src/agent-runtime/tmuxRuntime.js +27 -3
  21. package/dist/src/agents/claudeConversation.js +69 -6
  22. package/dist/src/agents/claudeLocalCommand.js +54 -0
  23. package/dist/src/agents/claudeNativeTail.js +287 -0
  24. package/dist/src/agents/claudePaneInput.js +47 -0
  25. package/dist/src/agents/codexConversation.js +46 -0
  26. package/dist/src/agents/piInboxBridge.js +64 -5
  27. package/dist/src/agents/processIdentity.js +23 -7
  28. package/dist/src/apiAccountProviders.js +5 -4
  29. package/dist/src/browser/bootstrap.js +5 -0
  30. package/dist/src/browser/credentials.js +68 -0
  31. package/dist/src/browser/manager.js +40 -5
  32. package/dist/src/browser/publicProxy.js +15 -5
  33. package/dist/src/browser/routes.js +7 -0
  34. package/dist/src/browser/workerClient.js +174 -9
  35. package/dist/src/browser/workerServer.js +13 -1
  36. package/dist/src/claudeEvents.js +94 -38
  37. package/dist/src/cli/authCmd.js +202 -0
  38. package/dist/src/cli/authDefaults.js +18 -0
  39. package/dist/src/cli/claudeHooks.js +3 -3
  40. package/dist/src/cli/hookScaffold.js +5 -1
  41. package/dist/src/cli/i18n/en.js +66 -11
  42. package/dist/src/cli/i18n/zh.js +66 -11
  43. package/dist/src/cli/options.js +7 -13
  44. package/dist/src/cli/pushCmd.js +24 -11
  45. package/dist/src/cli/setupModel.js +28 -6
  46. package/dist/src/cli/setupWizard.js +47 -25
  47. package/dist/src/cli/shortcutEditor.js +20 -2
  48. package/dist/src/cli/supervisor.js +5 -7
  49. package/dist/src/codexAppServer.js +25 -15
  50. package/dist/src/codexTranscriptParse.js +40 -1
  51. package/dist/src/codexUsageSnapshot.js +1 -1
  52. package/dist/src/deviceAccess.js +134 -0
  53. package/dist/src/deviceAuth/control.js +154 -0
  54. package/dist/src/deviceAuth/http.js +327 -0
  55. package/dist/src/deviceAuth/service.js +626 -0
  56. package/dist/src/docs.js +3 -0
  57. package/dist/src/httpApi.js +8 -3
  58. package/dist/src/paneInput.js +16 -2
  59. package/dist/src/previewServer.js +26 -2
  60. package/dist/src/previews.js +37 -13
  61. package/dist/src/projectTask/backup.js +28 -0
  62. package/dist/src/projectTask/lock.js +1 -1
  63. package/dist/src/projectTask/migrations.js +30 -1
  64. package/dist/src/projectTask/runtime.js +7 -1
  65. package/dist/src/projectTask/schema.js +2 -2
  66. package/dist/src/push.js +48 -13
  67. package/dist/src/requestAuthority.js +8 -0
  68. package/dist/src/requestOrigin.js +62 -0
  69. package/dist/src/routes/agents.js +3 -1
  70. package/dist/src/routes/files.js +2 -0
  71. package/dist/src/routes/previews.js +9 -3
  72. package/dist/src/routes/push.js +55 -58
  73. package/dist/src/routes/system.js +4 -4
  74. package/dist/src/routes/terminal.js +9 -1
  75. package/dist/src/server.js +88 -12
  76. package/dist/src/terminalStream.js +90 -5
  77. package/dist/src/tmux/commands.js +2 -0
  78. package/dist/src/transcriptParse.js +4 -1
  79. package/dist/src/workspace/environment.js +14 -5
  80. package/dist/src/workspace/runtime.js +16 -1
  81. package/package.json +5 -5
  82. package/dist/public/assets/index-B4pKf3IW.js +0 -356
  83. package/dist/public/assets/index-Cdrh2CUp.css +0 -32
@@ -48,8 +48,8 @@
48
48
  @keyframes bootDot { 0%,100% { opacity: .22; transform: translateY(0); } 40% { opacity: 1; transform: translateY(-3px); } }
49
49
  @media (prefers-reduced-motion: reduce) { .boot-glow, .boot-dots i { animation: none; opacity: .8; } }
50
50
  </style>
51
- <script type="module" crossorigin src="/assets/index-B4pKf3IW.js"></script>
52
- <link rel="stylesheet" crossorigin href="/assets/index-Cdrh2CUp.css" media="print" onload="this.media='all'"><noscript><link rel="stylesheet" crossorigin href="/assets/index-Cdrh2CUp.css"></noscript>
51
+ <script type="module" crossorigin src="/assets/index-aBgNeovq.js"></script>
52
+ <link rel="stylesheet" crossorigin href="/assets/index-BjnnFTLC.css" media="print" onload="this.media='all'"><noscript><link rel="stylesheet" crossorigin href="/assets/index-BjnnFTLC.css"></noscript>
53
53
  </head>
54
54
  <body>
55
55
  <div id="boot-splash" aria-hidden="true">
@@ -83,6 +83,9 @@ function channelKey(agentId, runId, name) {
83
83
  function consumerKey(agentId, name) {
84
84
  return `${agentId}\0${name}`;
85
85
  }
86
+ // Retention sweep cadence. Reclaims are also attempted at construction (a restart is when the largest
87
+ // dead set is present), so this only bounds growth during a long-lived server.
88
+ const RECLAIM_INTERVAL_MS = 5 * 60_000;
86
89
  function mergeLimits(overrides) {
87
90
  const limits = { ...DEFAULT_BRIDGE_LIMITS, ...overrides };
88
91
  for (const [name, value] of Object.entries(limits)) {
@@ -198,9 +201,11 @@ export class LocalAgentBridge {
198
201
  #newRequestId;
199
202
  #now;
200
203
  #retryDelayMs;
204
+ #onReclaim;
201
205
  #writeTail = Promise.resolve();
206
+ #reclaimTimer;
202
207
  #closed = false;
203
- constructor({ runs, adapterIds, store = new MemoryBridgeStateStore(), limits, newConnectionId = randomUUID, newRequestId = randomUUID, now = Date.now, retryDelayMs = 250, }) {
208
+ constructor({ runs, adapterIds, store = new MemoryBridgeStateStore(), limits, newConnectionId = randomUUID, newRequestId = randomUUID, now = Date.now, retryDelayMs = 250, onReclaim, }) {
204
209
  if (!runs || !Array.isArray(adapterIds) || adapterIds.length === 0) {
205
210
  throw new TypeError('LocalAgentBridge requires a run registry and static adapter ids');
206
211
  }
@@ -216,6 +221,7 @@ export class LocalAgentBridge {
216
221
  this.#newRequestId = newRequestId;
217
222
  this.#now = now;
218
223
  this.#retryDelayMs = Math.max(1, retryDelayMs);
224
+ this.#onReclaim = onReclaim;
219
225
  const persisted = parsePersistedState(this.#store.load());
220
226
  const spoolBytes = new Map();
221
227
  for (const channel of persisted.channels) {
@@ -244,9 +250,15 @@ export class LocalAgentBridge {
244
250
  pumping: false,
245
251
  pumpAgain: false,
246
252
  retryTimer: undefined,
253
+ touchedAt: this.#now(),
247
254
  durableWaiters: [],
248
255
  });
249
256
  }
257
+ // A restart is the natural point to drop channels whose runs died before it: nothing can attach to
258
+ // that run again, so their undelivered events are unreachable either way.
259
+ this.reclaimChannels();
260
+ this.#reclaimTimer = setInterval(() => this.reclaimChannels(), RECLAIM_INTERVAL_MS);
261
+ this.#reclaimTimer.unref?.();
250
262
  }
251
263
  connect(run) {
252
264
  this.#assertLive(run);
@@ -359,6 +371,10 @@ export class LocalAgentBridge {
359
371
  if (this.#closed)
360
372
  return;
361
373
  this.#closed = true;
374
+ if (this.#reclaimTimer) {
375
+ clearInterval(this.#reclaimTimer);
376
+ this.#reclaimTimer = undefined;
377
+ }
362
378
  for (const connection of this.#connections.values())
363
379
  connection.close();
364
380
  this.#connections.clear();
@@ -418,10 +434,12 @@ export class LocalAgentBridge {
418
434
  pumping: false,
419
435
  pumpAgain: false,
420
436
  retryTimer: undefined,
437
+ touchedAt: this.#now(),
421
438
  durableWaiters: [],
422
439
  };
423
440
  this.#channels.set(key, state);
424
441
  }
442
+ state.touchedAt = this.#now();
425
443
  return state;
426
444
  }
427
445
  async #withWrite(operation) {
@@ -442,6 +460,50 @@ export class LocalAgentBridge {
442
460
  channels: [...this.#channels.values()].map((state) => structuredClone(state.persisted)),
443
461
  });
444
462
  }
463
+ // Drops channels that can never be consumed again, so a reconnecting or flapping attachment cannot grow
464
+ // the bridge state without bound. A channel of a live run is never touched — it may reconnect and its
465
+ // undelivered events are still reachable. Returns what was dropped so a caller can log it.
466
+ reclaimChannels() {
467
+ const now = this.#now();
468
+ const dead = (state) => !state.subscription
469
+ && state.durableWaiters.length === 0 && state.live.length === 0
470
+ // The registry contract returns null for a run it no longer holds; treat any falsy result the same
471
+ // so a registry that omits the value cannot silently disable retention.
472
+ && !this.#runs.resolve(state.persisted.run);
473
+ let channels = 0;
474
+ let droppedEvents = 0;
475
+ const drop = (key, state) => {
476
+ if (state.retryTimer)
477
+ clearTimeout(state.retryTimer);
478
+ droppedEvents += state.persisted.durable.length;
479
+ this.#channels.delete(key);
480
+ channels += 1;
481
+ };
482
+ for (const [key, state] of [...this.#channels]) {
483
+ if (!dead(state))
484
+ continue;
485
+ // Drained ⇒ nothing can be lost. Still holding events ⇒ wait out the retention window first, so a
486
+ // transient gap in run bookkeeping cannot drop a notification that is still deliverable.
487
+ if (state.persisted.durable.length > 0 && now - state.touchedAt < this.limits.channelRetentionMs)
488
+ continue;
489
+ drop(key, state);
490
+ }
491
+ // Hard ceiling: retention must bound memory even while every dead channel is inside its window.
492
+ if (this.#channels.size > this.limits.maxChannels) {
493
+ const oldest = [...this.#channels].filter(([, state]) => dead(state))
494
+ .sort((left, right) => left[1].touchedAt - right[1].touchedAt);
495
+ for (const [key, state] of oldest) {
496
+ if (this.#channels.size <= this.limits.maxChannels)
497
+ break;
498
+ drop(key, state);
499
+ }
500
+ }
501
+ if (channels > 0) {
502
+ this.#save();
503
+ this.#onReclaim?.({ channels, droppedEvents });
504
+ }
505
+ return { channels, droppedEvents };
506
+ }
445
507
  #nextSequence(state) {
446
508
  if (state.persisted.highWatermark >= Number.MAX_SAFE_INTEGER) {
447
509
  throw new BridgeContractError('Bridge channel sequence is exhausted');
@@ -3,6 +3,7 @@ import fs from 'node:fs';
3
3
  import net from 'node:net';
4
4
  import path from 'node:path';
5
5
  import { DEFAULT_BRIDGE_LIMITS } from './bridgeTypes.js';
6
+ import { AgentRunError } from './run.js';
6
7
  const PROTOCOL_VERSION = 1;
7
8
  const NAME_RE = /^[a-z][a-z0-9._-]{0,63}$/;
8
9
  const MAX_HANDSHAKE_BYTES = 64 * 1024;
@@ -46,8 +47,6 @@ function wirePeer(socket, maxBytes, onMessage) {
46
47
  socket.write(encoded);
47
48
  },
48
49
  close() {
49
- if (peer.closed)
50
- return;
51
50
  peer.closed = true;
52
51
  socket.destroy();
53
52
  },
@@ -251,12 +250,28 @@ export class LocalAgentBridgeTransportServer {
251
250
  const peer = wirePeer(socket, MAX_HANDSHAKE_BYTES, (value) => {
252
251
  const operation = session.tail.then(() => this.#message(session, nonce, value));
253
252
  session.tail = operation.catch((error) => {
253
+ if (!session.ready) {
254
+ if (!session.authenticated || peer.closed) {
255
+ this.#closeSession(session);
256
+ return;
257
+ }
258
+ // Flush the rejection before closing so old clients still observe EOF and new clients get
259
+ // the actual cause. Only a validated, authenticated hello may receive diagnostics.
260
+ try {
261
+ peer.send({ type: 'handshake-error', error: errorMessage(error),
262
+ ...(error instanceof AgentRunError ? { code: error.code } : {}) });
263
+ peer.closed = true;
264
+ peer.socket.end(() => this.#closeSession(session));
265
+ }
266
+ catch {
267
+ this.#closeSession(session);
268
+ }
269
+ return;
270
+ }
254
271
  try {
255
272
  failure(peer, record(value)?.id, error);
256
273
  }
257
274
  catch { /* closing */ }
258
- if (!session.connection)
259
- this.#closeSession(session);
260
275
  });
261
276
  });
262
277
  const handshakeTimer = setTimeout(() => this.#closeSession(session), this.#handshakeTimeoutMs);
@@ -270,6 +285,8 @@ export class LocalAgentBridgeTransportServer {
270
285
  peer.send({ type: 'challenge', protocolVersion: PROTOCOL_VERSION, nonce });
271
286
  }
272
287
  async #message(session, nonce, raw) {
288
+ if (session.peer.closed)
289
+ return;
273
290
  const value = record(raw);
274
291
  if (!value || typeof value.type !== 'string')
275
292
  throw new Error('Invalid Bridge transport frame');
@@ -285,6 +302,7 @@ export class LocalAgentBridgeTransportServer {
285
302
  || (!replacing && value.generationId !== undefined)) {
286
303
  throw new Error('Invalid Bridge transport generation');
287
304
  }
305
+ session.authenticated = true;
288
306
  const lease = await this.#authorize(value.adapterId, structuredClone(value.candidate), replacing ? { id: value.generationId, replace: true } : undefined);
289
307
  if (lease.ref.agentId !== value.adapterId)
290
308
  throw new Error('Bridge transport authorization mismatch');
@@ -302,6 +320,7 @@ export class LocalAgentBridgeTransportServer {
302
320
  clearTimeout(session.handshakeTimer);
303
321
  session.handshakeTimer = undefined;
304
322
  session.peer.maxBytes = Math.max(session.connection.limits.maxFrameBytes, session.connection.limits.maxSnapshotBytes) + WIRE_OVERHEAD_BYTES;
323
+ session.ready = true;
305
324
  session.peer.send({
306
325
  type: 'ready', protocolVersion: PROTOCOL_VERSION,
307
326
  connectionId: session.connection.connectionId,
@@ -483,6 +502,14 @@ export async function connectBridgeTransport({ socketPath, authToken, adapterId,
483
502
  });
484
503
  return;
485
504
  }
505
+ if (value.type === 'handshake-error' && !settled) {
506
+ const error = new Error(bounded(value.error, 4096) ? value.error : 'Bridge handshake rejected');
507
+ if (bounded(value.code, 128))
508
+ Object.assign(error, { code: value.code });
509
+ finish({ error });
510
+ peer.close();
511
+ return;
512
+ }
486
513
  if (value.type === 'ready') {
487
514
  finish({ value });
488
515
  return;
@@ -10,4 +10,6 @@ export const DEFAULT_BRIDGE_LIMITS = Object.freeze({
10
10
  maxRequestTimeoutMs: 5 * 60_000,
11
11
  sustainedEventsPerSecond: 200,
12
12
  burstEvents: 500,
13
+ maxChannels: 2_000,
14
+ channelRetentionMs: 24 * 60 * 60_000,
13
15
  });
@@ -20,13 +20,16 @@ import { AgentRuntime, } from './runtime.js';
20
20
  export function createClaudeConversationActivityReader(events) {
21
21
  return {
22
22
  async read(run) {
23
- const kind = events?.paneKind?.(run.ref.paneId) ?? null;
23
+ if (events?.paneSession && events.paneSession(run.ref.paneId)?.sessionId !== run.ref.sessionId) {
24
+ return { activity: 'unknown', activeTurn: { state: 'unknown' } };
25
+ }
26
+ const kind = events?.paneKind?.(run.ref.paneId, run.process) ?? null;
24
27
  const activity = kind === 'working' ? 'working'
25
28
  : kind === 'permission' ? 'waiting'
26
29
  : kind === 'compacting' ? 'compacting'
27
30
  : kind === 'done' || kind === 'idle' || kind === 'end' || kind === 'error'
28
31
  ? 'idle' : 'unknown';
29
- const completionToken = events?.paneCompletionToken?.(run.ref.paneId) ?? null;
32
+ const completionToken = events?.paneCompletionToken?.(run.ref.paneId, run.process) ?? null;
30
33
  return {
31
34
  activity,
32
35
  activeTurn: activity === 'idle' ? { state: 'none' }
@@ -89,6 +92,11 @@ export function createBuiltinAgentRuntime({ home, claudeEvents, claudeProjectsRo
89
92
  agentId: 'claude',
90
93
  sourceId: 'claude.hooks',
91
94
  label: 'Claude',
95
+ sourceEventSequence: (eventId) => {
96
+ const match = /^claude-hook-([1-9]\d*)$/.exec(eventId);
97
+ const sequence = match ? Number(match[1]) : NaN;
98
+ return Number.isSafeInteger(sequence) ? sequence : null;
99
+ },
92
100
  });
93
101
  const conversationSessions = claudeEvents && typeof claudeEvents.paneSession === 'function'
94
102
  ? claudeEvents
@@ -100,7 +100,7 @@ function normalizeInterrupt(raw) {
100
100
  }
101
101
  const PUBLIC_REASONS = new Set([
102
102
  'invalid_request', 'unsupported', 'stale_run', 'conflict',
103
- 'provider_rejected', 'temporarily_unavailable', 'delivery_unconfirmed',
103
+ 'provider_rejected', 'terminal_draft_conflict', 'temporarily_unavailable', 'delivery_unconfirmed',
104
104
  ]);
105
105
  const LEGACY_REASON_MAP = Object.freeze({
106
106
  stale_lease: 'stale_run',
@@ -1648,7 +1648,7 @@ export class ConversationService {
1648
1648
  delete detached.steerAnchor;
1649
1649
  delete detached.steerDispatchPlan;
1650
1650
  if (receipt.outcome === 'rejected')
1651
- detached.autoDispatchBlockedReason = 'provider_rejected';
1651
+ detached.autoDispatchBlockedReason = receipt.reason === 'terminal_draft_conflict' ? 'terminal_draft_conflict' : 'provider_rejected';
1652
1652
  else
1653
1653
  delete detached.autoDispatchBlockedReason;
1654
1654
  detached.revision = ++this.#state.ledgerRevision;
@@ -1755,7 +1755,7 @@ export class ConversationService {
1755
1755
  current.state = 'queued';
1756
1756
  delete current.dispatchOrigin;
1757
1757
  if (receipt.outcome === 'rejected') {
1758
- current.autoDispatchBlockedReason = 'provider_rejected';
1758
+ current.autoDispatchBlockedReason = receipt.reason === 'terminal_draft_conflict' ? 'terminal_draft_conflict' : 'provider_rejected';
1759
1759
  }
1760
1760
  else
1761
1761
  delete current.autoDispatchBlockedReason;
@@ -32,7 +32,7 @@ function validBaseline(value) {
32
32
  }
33
33
  const SEND_STATUSES = new Set(['accepted', 'queued', 'rejected', 'unknown']);
34
34
  const SEND_REASONS = new Set([
35
- 'invalid_request', 'unsupported', 'stale_run', 'conflict', 'provider_rejected',
35
+ 'invalid_request', 'unsupported', 'stale_run', 'conflict', 'provider_rejected', 'terminal_draft_conflict',
36
36
  'temporarily_unavailable', 'delivery_unconfirmed',
37
37
  ]);
38
38
  function validLegacyReceipt(value) {
@@ -120,7 +120,7 @@ export function parseConversationState(value) {
120
120
  throw new Error('Corrupt Conversation queue order');
121
121
  }
122
122
  if (row.autoDispatchBlockedReason !== undefined
123
- && row.autoDispatchBlockedReason !== 'provider_rejected') {
123
+ && row.autoDispatchBlockedReason !== 'provider_rejected' && row.autoDispatchBlockedReason !== 'terminal_draft_conflict') {
124
124
  throw new Error('Corrupt Conversation dispatch barrier');
125
125
  }
126
126
  if (row.autoDispatchBlockedReason !== undefined && state !== 'queued') {
@@ -834,7 +834,9 @@ export class InboxService {
834
834
  async #submitTerminalReplay(agentId, replay) {
835
835
  const receivedAt = this.#now();
836
836
  if (!isRecord(replay) || !validRunRef(replay.run) || replay.run.agentId !== agentId
837
- || this.#runs.status(replay.run) !== 'revoked'
837
+ || typeof replay.eventId !== 'string' || !EVENT_ID_RE.test(replay.eventId)
838
+ || (replay.historyOnly !== undefined && typeof replay.historyOnly !== 'boolean')
839
+ || this.#runs.status(replay.run) !== (replay.historyOnly ? 'current' : 'revoked')
838
840
  || (replay.state !== 'done' && replay.state !== 'error')) {
839
841
  return this.#result(receivedAt, false, { reason: 'invalid_operation' });
840
842
  }
@@ -852,7 +854,12 @@ export class InboxService {
852
854
  return this.#result(receivedAt, false, { reason: 'invalid_operation' });
853
855
  }
854
856
  const operation = normalized.operation;
857
+ const currentLease = replay.historyOnly ? this.#runs.resolve(replay.run) : undefined;
855
858
  return this.#withWrite(async () => {
859
+ if (replay.historyOnly && (!currentLease || currentLease.signal.aborted
860
+ || this.#runs.resolve(replay.run) !== currentLease)) {
861
+ return this.#result(receivedAt, false, { reason: 'stale_lease' });
862
+ }
856
863
  const before = structuredClone(this.#state);
857
864
  const run = this.#ensureRun(replay.run);
858
865
  const sourceDuplicate = this.#sourceReceipt(run, operation.source);
@@ -906,7 +913,12 @@ export class InboxService {
906
913
  }
907
914
  try {
908
915
  const inboxSequence = this.#nextSequence(run);
909
- const acceptedAt = receivedAt;
916
+ const historicalEvents = replay.historyOnly ? this.#crossRunEventReceipts(replay.run, operation.eventId) : [];
917
+ if (historicalEvents.some((receipt) => receipt.operationHash !== normalized.hash)) {
918
+ throw new InboxContractError('Historical terminal event payload changed');
919
+ }
920
+ const acceptedAt = historicalEvents.length
921
+ ? Math.min(...historicalEvents.map((receipt) => receipt.acceptedAt)) : receivedAt;
910
922
  run.events.push({
911
923
  eventId: operation.eventId,
912
924
  operationHash: normalized.hash,
@@ -919,6 +931,12 @@ export class InboxService {
919
931
  acceptedAt,
920
932
  ...(operation.eventId === undefined ? {} : { eventId: operation.eventId }),
921
933
  });
934
+ if (replay.historyOnly || historicalEvents.length) {
935
+ this.#pruneExpired(receivedAt);
936
+ this.#save();
937
+ this.#revision += 1;
938
+ return this.#result(receivedAt, true, { inboxSequence, acceptedAt });
939
+ }
922
940
  const terminal = this.#terminalNotification(replay.run, {
923
941
  state: operation.state,
924
942
  ...(operation.eventId === undefined ? {} : { eventId: operation.eventId }),
@@ -159,12 +159,13 @@ export class AgentRunRuntime {
159
159
  }
160
160
  async #verify(verify, candidate) {
161
161
  let accepted = false;
162
+ let timedOut = false;
162
163
  let timer;
163
164
  try {
164
165
  accepted = await Promise.race([
165
166
  Promise.resolve(verify(candidate)).then(Boolean, () => false),
166
167
  new Promise((resolve) => {
167
- timer = setTimeout(() => resolve(false), this.#verifyTimeoutMs);
168
+ timer = setTimeout(() => { timedOut = true; resolve(false); }, this.#verifyTimeoutMs);
168
169
  }),
169
170
  ]);
170
171
  }
@@ -173,6 +174,9 @@ export class AgentRunRuntime {
173
174
  if (timer)
174
175
  clearTimeout(timer);
175
176
  }
177
+ if (timedOut) {
178
+ throw new AgentRunError('attachment-verification-timeout', `Agent process verification timed out after ${this.#verifyTimeoutMs} ms; check slow process queries and retry`);
179
+ }
176
180
  if (!accepted) {
177
181
  throw new AgentRunError('attachment-unverified', 'Agent attachment no longer matches the live process');
178
182
  }
@@ -219,6 +219,7 @@ export class AgentRuntime {
219
219
  runs: this.runs,
220
220
  adapterIds,
221
221
  store: new FileBridgeStateStore(bridgeStateFile),
222
+ onReclaim: (result) => logger.info('Reclaimed Bridge channels for Agent runs that are no longer live', result),
222
223
  ...(newBridgeConnectionId === undefined ? {} : { newConnectionId: newBridgeConnectionId }),
223
224
  }),
224
225
  });
@@ -1,3 +1,4 @@
1
+ import { promises as fsp } from 'node:fs';
1
2
  import { executablePath } from '../agents/processIdentity.js';
2
3
  import { defaultRun, etimeToMs, normTty } from '../agents/scanUtils.js';
3
4
  function pane(value) {
@@ -124,6 +125,30 @@ function foregroundLeaf(rows, tty, paneCommand) {
124
125
  .sort((first, second) => second.elapsedMs - first.elapsedMs
125
126
  || depth(first) - depth(second) || second.pid - first.pid)[0] ?? null;
126
127
  }
128
+ // Process start time, in a form that only depends on the process itself.
129
+ //
130
+ // `ps -o lstart` is a wall-clock derivation (boot time + start ticks). A host that steps the guest clock
131
+ // (WSL2 syncs from Hyper-V, moving the guest forward every few tens of seconds) therefore makes the same
132
+ // live process report a *different* start time after every step, which every identity comparison reads as
133
+ // "the process was replaced". Linux exposes the raw start tick instead, and macOS does not step its clock
134
+ // this way, so it keeps lstart. The Hook records the same value per platform — keep the two in step
135
+ // (server/hooks/handmux-notify.sh).
136
+ // Exported so the platform split can be unit-tested; callers use inspectForeground.
137
+ export async function processStartedAt(run, pid) {
138
+ if (process.platform === 'linux') {
139
+ try {
140
+ const stat = await fsp.readFile(`/proc/${pid}/stat`, 'utf8');
141
+ // comm may contain spaces and parentheses: start from the last ')' to reach field 3 (state).
142
+ const fields = stat.slice(stat.lastIndexOf(')') + 2).trim().split(/\s+/);
143
+ const startTicks = Number(fields[19]); // field 22 of /proc/<pid>/stat
144
+ if (Number.isSafeInteger(startTicks) && startTicks >= 0)
145
+ return startTicks;
146
+ }
147
+ catch { /* fall back to the portable lstart read */ }
148
+ }
149
+ const parsed = Date.parse(String(await run('ps', ['-p', String(pid), '-o', 'lstart='])).trim());
150
+ return Number.isFinite(parsed) ? parsed : undefined;
151
+ }
127
152
  export function createLocalAgentProcessContext({ run = defaultRun, } = {}) {
128
153
  return {
129
154
  async inspectForeground(pane) {
@@ -137,12 +162,11 @@ export function createLocalAgentProcessContext({ run = defaultRun, } = {}) {
137
162
  const row = foregroundLeaf(foregroundRows(output), tty, pane.currentCommand);
138
163
  if (!row)
139
164
  return null;
140
- const rawStartedAt = String(await run('ps', ['-p', String(row.pid), '-o', 'lstart='])).trim();
141
- const parsedStartedAt = Date.parse(rawStartedAt);
165
+ const startedAt = await processStartedAt(run, row.pid);
142
166
  const executable = await executablePath(run, row.pid);
143
167
  return {
144
168
  pid: row.pid,
145
- ...(Number.isFinite(parsedStartedAt) ? { startedAt: parsedStartedAt } : {}),
169
+ ...(startedAt === undefined ? {} : { startedAt }),
146
170
  tty: pane.tty ?? `/dev/${tty}`,
147
171
  ...(executable ? { executable } : {}),
148
172
  ...(row.command ? { commandLine: row.command } : {}),
@@ -79,8 +79,9 @@ export async function findClaudeSessionFile(root, sessionId) {
79
79
  }
80
80
  return matches[0] ?? null;
81
81
  }
82
- function sourceViewId(sessionId) {
83
- return `claude-session:${sessionId}`;
82
+ function sourceViewId(sessionId, reordered) {
83
+ const suffix = reordered.length ? `:${createHash('sha256').update(JSON.stringify(reordered)).digest('hex')}` : '';
84
+ return `claude-session:${sessionId}${suffix}`;
84
85
  }
85
86
  function sourceTime(value) {
86
87
  if (typeof value !== 'string')
@@ -167,11 +168,28 @@ function toolItems(message, sessionId, id, tool) {
167
168
  }
168
169
  return items;
169
170
  }
171
+ // Read only the provider's human-facing scalar fields. This is not an XML document loader:
172
+ // nested markup, output-file paths, internal IDs and external entities are never interpreted.
173
+ function taskNotificationText(value) {
174
+ const entities = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" };
175
+ const field = (name) => {
176
+ const text = new RegExp(`<${name}>([^<>]*)</${name}>`).exec(value)?.[1] ?? '';
177
+ return text.replace(/&(amp|lt|gt|quot|apos);/g, (_match, entity) => entities[entity])
178
+ .trim();
179
+ };
180
+ const summary = field('summary') || 'Background task update';
181
+ const details = [...new Set([field('status'), field('event')].filter(Boolean))];
182
+ return clipped(`${summary}${details.length ? ` (${details.join(', ')})` : ''}`, 4096).text;
183
+ }
170
184
  function projectMessage(message, sessionId, ordinal) {
171
185
  const id = `claude:${message.i}:${ordinal}:${message.type}`;
172
186
  if (message.type === 'thinking')
173
187
  return [];
174
188
  if (message.type === 'text' && message.role && typeof message.text === 'string' && message.text) {
189
+ if (message.source === 'task-notification') {
190
+ return [{ ...itemBase(message, sessionId, id), kind: 'notice', level: 'info',
191
+ code: 'background_task', message: taskNotificationText(message.text) }];
192
+ }
175
193
  return [textItem(message, sessionId, id, message.role, message.text)];
176
194
  }
177
195
  if (message.type === 'tool' && message.tool)
@@ -196,6 +214,11 @@ function projectMessage(message, sessionId, ordinal) {
196
214
  }
197
215
  if (message.type === 'slash') {
198
216
  const command = `${message.name ?? '/command'}${message.args ? ` ${message.args}` : ''}`;
217
+ // /compact starts as the user's submitted message. Keep that role when its canonical scaffold
218
+ // arrives so the canonical user occurrence replaces the local bubble; the result stays separate.
219
+ if (message.name?.toLowerCase() === '/compact') {
220
+ return [textItem(message, sessionId, id, 'user', command)];
221
+ }
199
222
  return [{
200
223
  ...itemBase(message, sessionId, id), kind: 'notice', level: 'info',
201
224
  code: 'slash_command', message: clipped(command, 4096).text,
@@ -203,8 +226,36 @@ function projectMessage(message, sessionId, ordinal) {
203
226
  }
204
227
  return [];
205
228
  }
229
+ function normalizedOrder(messages) {
230
+ // Assign IDs in native append order, before normalizing display order. A late command must not
231
+ // rename a summary already shown to a client (or any of the following records).
232
+ const groups = messages.map((message, ordinal) => ({ message, ordinal }));
233
+ const reordered = [];
234
+ for (let index = 1; index < groups.length; index++) {
235
+ const current = groups[index];
236
+ const previous = groups[index - 1];
237
+ if (current.message.type !== 'slash' || current.message.name?.toLowerCase() !== '/compact'
238
+ || previous.message.type !== 'compact')
239
+ continue;
240
+ const commandTime = sourceTime(current.message.ts);
241
+ const summaryTime = sourceTime(previous.message.ts);
242
+ if (commandTime === undefined || summaryTime === undefined || commandTime >= summaryTime)
243
+ continue;
244
+ // Claude may append the command scaffold after its summary, retaining the earlier command time.
245
+ // Only this adjacent, proven inversion is normalized; ordinary history is never time-sorted.
246
+ groups[index - 1] = current;
247
+ groups[index] = previous;
248
+ reordered.push(`claude:${current.message.i}:${current.ordinal}:${current.message.type}`);
249
+ index++;
250
+ }
251
+ return { groups, reordered };
252
+ }
206
253
  function project(messages, sessionId) {
207
- return messages.flatMap((message, ordinal) => projectMessage(message, sessionId, ordinal));
254
+ const { groups, reordered } = normalizedOrder(messages);
255
+ return {
256
+ items: groups.flatMap(({ message, ordinal }) => projectMessage(message, sessionId, ordinal)),
257
+ viewId: sourceViewId(sessionId, reordered),
258
+ };
208
259
  }
209
260
  export function createClaudeConversationAdapter({ projectsRoot = projectsDir(), sessions = null, reader = transcriptReader, findSessionFile = findClaudeSessionFile, control, livePollMs = 750, } = {}) {
210
261
  if (!path.isAbsolute(projectsRoot)) {
@@ -242,7 +293,7 @@ export function createClaudeConversationAdapter({ projectsRoot = projectsDir(),
242
293
  async function readPage(session, request) {
243
294
  const file = await fileForSession(session.sessionId);
244
295
  const parsed = file ? await reader.read(file, createTranscriptParser) : [];
245
- const all = project(parsed, session.sessionId);
296
+ const { items: all, viewId } = project(parsed, session.sessionId);
246
297
  let end = all.length;
247
298
  if (request.beforeSourceCursor !== undefined) {
248
299
  if (!/^\d+$/.test(request.beforeSourceCursor))
@@ -255,7 +306,7 @@ export function createClaudeConversationAdapter({ projectsRoot = projectsDir(),
255
306
  const start = Math.max(0, end - request.limit);
256
307
  return {
257
308
  sessionId: session.sessionId,
258
- sourceViewId: sourceViewId(session.sessionId),
309
+ sourceViewId: viewId,
259
310
  sourceHistoryToken: createHash('sha256').update(JSON.stringify(all)).digest('hex'),
260
311
  items: all.slice(start, end),
261
312
  ...(start > 0 ? { previousSourceCursor: String(start) } : {}),
@@ -271,10 +322,13 @@ export function createClaudeConversationAdapter({ projectsRoot = projectsDir(),
271
322
  if (!file)
272
323
  return null;
273
324
  boundFiles.set(target.sessionId, file);
325
+ // Discovery needs only the order epoch, not a second projection/hash of every tool result.
326
+ const parsed = await reader.read(file, createTranscriptParser);
327
+ const viewId = sourceViewId(target.sessionId, normalizedOrder(parsed).reordered);
274
328
  return {
275
329
  session: { agentId: 'claude', sessionId: target.sessionId },
276
330
  ...(isRun(target) ? { run: target } : {}),
277
- sourceViewId: sourceViewId(target.sessionId),
331
+ sourceViewId: viewId,
278
332
  capabilities: isRun(target) && control ? {
279
333
  history: true, live: 'settled', sendable: true, send: ['prompt'], interrupt: true,
280
334
  } : { history: true, live: 'poll' },
@@ -307,6 +361,12 @@ export function createClaudeConversationAdapter({ projectsRoot = projectsDir(),
307
361
  if (closed || run.signal.aborted)
308
362
  return;
309
363
  const next = await readPage(session, { limit: 1 });
364
+ if (next.sourceViewId !== baseline.sourceViewId) {
365
+ close();
366
+ await sink({ type: 'stream.gap', sourceSequence: ++sourceSequence,
367
+ afterSourceSequence: sourceSequence - 1 });
368
+ return;
369
+ }
310
370
  if (next.sourceHistoryToken !== historyToken) {
311
371
  historyToken = next.sourceHistoryToken;
312
372
  await sink({
@@ -345,6 +405,9 @@ export function createClaudeConversationAdapter({ projectsRoot = projectsDir(),
345
405
  : control.sendPrompt(run.ref.paneId, request.text));
346
406
  if (result && typeof result === 'object'
347
407
  && result.nativeMutation === false) {
408
+ if (result.reason === 'terminal_draft_conflict') {
409
+ return { outcome: 'rejected', nativeMutation: false, reason: 'terminal_draft_conflict' };
410
+ }
348
411
  return { outcome: 'busy', nativeMutation: false };
349
412
  }
350
413
  return { outcome: 'accepted' };
@@ -0,0 +1,54 @@
1
+ import path from 'node:path';
2
+ import { isSessionUuid } from './scanUtils.js';
3
+ const METADATA = new Set(['last-prompt', 'ai-title', 'mode', 'permission-mode', 'file-history-snapshot']);
4
+ const record = (value) => (value !== null && typeof value === 'object' && !Array.isArray(value) ? value : null);
5
+ // undefined: no native evidence newer than the Hook. null: newer history is not a proven completion.
6
+ // Keeping those separate prevents falling back from a native completion token to an older Hook token
7
+ // while another user turn is being appended, which would look like an additional completion to Core.
8
+ export function claudeLocalCommandCompletion(payload, after, now, rows) {
9
+ const sessionId = payload.session_id;
10
+ const file = payload.transcript_path;
11
+ if (!isSessionUuid(sessionId) || typeof file !== 'string'
12
+ || path.basename(file) !== `${sessionId}.jsonl`)
13
+ return undefined;
14
+ const native = [];
15
+ for (let index = rows.length - 1; index >= 0 && native.length < 2; index--) {
16
+ const item = rows[index];
17
+ if (!item)
18
+ return null;
19
+ if (typeof item.type === 'string' && METADATA.has(item.type))
20
+ continue;
21
+ const timestamp = typeof item.timestamp === 'string' ? Date.parse(item.timestamp) : NaN;
22
+ if (!Number.isFinite(timestamp))
23
+ return null;
24
+ if (timestamp <= after && !native.length)
25
+ return undefined;
26
+ if (timestamp > now)
27
+ return null;
28
+ native.unshift(item);
29
+ }
30
+ if (!native.length)
31
+ return undefined;
32
+ if (native.length !== 2)
33
+ return null;
34
+ const [command, output] = native;
35
+ for (const item of native) {
36
+ if (item.type !== 'user' || item.sessionId !== sessionId || item.isMeta === true
37
+ || item.isCompactSummary === true || item.isSidechain === true || !isSessionUuid(item.uuid)
38
+ || record(item.message)?.role !== 'user')
39
+ return null;
40
+ }
41
+ const commandText = record(command.message)?.content;
42
+ const outputText = record(output.message)?.content;
43
+ if (typeof commandText !== 'string' || typeof outputText !== 'string'
44
+ || !/^\s*<command-name>\/[a-z][\w-]*<\/command-name>(?:\s*<command-message>[^<>]*<\/command-message>)?(?:\s*<command-args>[^<>]*<\/command-args>)?\s*$/i.test(commandText)
45
+ || !/^\s*<local-command-stdout>[\s\S]+<\/local-command-stdout>\s*$/.test(outputText)
46
+ || output.parentUuid !== command.uuid
47
+ || Date.parse(String(output.timestamp)) < Date.parse(String(command.timestamp)))
48
+ return null;
49
+ if (Date.parse(String(command.timestamp)) <= after) {
50
+ // PostCompact already supplies the completion edge before /compact's stdout is appended.
51
+ return /^\s*<command-name>\/compact<\/command-name>/i.test(commandText) ? undefined : null;
52
+ }
53
+ return `claude-local-command:${sessionId}:${output.uuid}`;
54
+ }