handmux 0.26.0 → 0.27.2

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 (48) hide show
  1. package/README.md +20 -93
  2. package/README.zh-CN.md +20 -93
  3. package/dist/package.json +19 -19
  4. package/dist/public/assets/index-B4pKf3IW.js +356 -0
  5. package/dist/public/assets/index-Cdrh2CUp.css +32 -0
  6. package/dist/public/assets/pcm-worklet-NzlBQ6-t.js +1 -0
  7. package/dist/public/index.html +2 -2
  8. package/dist/src/agent-runtime/adapter.js +2 -0
  9. package/dist/src/agent-runtime/builtinRuntime.js +3 -1
  10. package/dist/src/agent-runtime/conversationActivation.js +136 -4
  11. package/dist/src/agent-runtime/run.js +23 -2
  12. package/dist/src/agent-runtime/runtime.js +174 -15
  13. package/dist/src/agent-runtime/tmuxRuntime.js +3 -7
  14. package/dist/src/agents/codex.js +78 -10
  15. package/dist/src/agents/codexActivationReceipt.js +149 -0
  16. package/dist/src/agents/codexConversation.js +59 -20
  17. package/dist/src/agents/codexConversationActivation.js +265 -65
  18. package/dist/src/agents/codexOpenSession.js +164 -0
  19. package/dist/src/agents/nativeInbox.js +24 -56
  20. package/dist/src/agents/scanUtils.js +7 -1
  21. package/dist/src/apiErrors.js +10 -1
  22. package/dist/src/asr/config.js +29 -0
  23. package/dist/src/asr/providerRegistry.js +19 -0
  24. package/dist/src/asr/providers/tencent.js +135 -0
  25. package/dist/src/asr/providers/xfyun.js +73 -0
  26. package/dist/src/asr/tencentSentence.js +111 -0
  27. package/dist/src/asr/tencentSign.js +20 -0
  28. package/dist/src/asr/verify.js +80 -0
  29. package/dist/src/cli/codexManaged.js +13 -2
  30. package/dist/src/cli/i18n/en.js +16 -1
  31. package/dist/src/cli/i18n/zh.js +16 -1
  32. package/dist/src/cli/options.js +29 -2
  33. package/dist/src/cli/setupModel.js +49 -5
  34. package/dist/src/cli/setupWizard.js +149 -29
  35. package/dist/src/cli/supervisor.js +24 -8
  36. package/dist/src/cli/supervisorLaunch.js +26 -0
  37. package/dist/src/codexAppServer.js +41 -4
  38. package/dist/src/routes/agents.js +51 -14
  39. package/dist/src/routes/system.js +109 -10
  40. package/dist/src/server.js +11 -2
  41. package/dist/src/terminalStream.js +8 -30
  42. package/dist/src/tmux/commands.js +14 -0
  43. package/dist/src/tmux/controlProtocol.js +26 -0
  44. package/dist/src/tmux/paneOutputCapture.js +180 -0
  45. package/package.json +5 -5
  46. package/dist/public/assets/index-BVFKs5dN.css +0 -32
  47. package/dist/public/assets/index-KDa5PCoi.js +0 -356
  48. package/dist/public/assets/pcm-worklet-CxcDy7PB.js +0 -1
@@ -0,0 +1,149 @@
1
+ import crypto from 'node:crypto';
2
+ import path from 'node:path';
3
+ import { fsyncDirectorySync, PrivateStateStore } from '../privateStateStore.js';
4
+ import { isSessionUuid } from './scanUtils.js';
5
+ const CODEX_AGENT_ID = 'codex';
6
+ const MAX_RECEIPTS = 128;
7
+ const OPERATION_ID_RE = /^[0-9a-f]{64}$/;
8
+ const PANE_ID_RE = /^%\d+$/;
9
+ const WINDOW_ID_RE = /^@\d+$/;
10
+ function isRecord(value) {
11
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
12
+ }
13
+ function validReceipt(value) {
14
+ if (!isRecord(value) || !isRecord(value.pane) || !isRecord(value.process))
15
+ return false;
16
+ const pane = value.pane;
17
+ const process = value.process;
18
+ return typeof value.operationId === 'string' && OPERATION_ID_RE.test(value.operationId)
19
+ && value.agentId === CODEX_AGENT_ID
20
+ && typeof pane.paneId === 'string' && PANE_ID_RE.test(pane.paneId)
21
+ && typeof pane.sessionName === 'string' && pane.sessionName.length > 0 && pane.sessionName.length <= 1_024
22
+ && typeof pane.windowId === 'string' && WINDOW_ID_RE.test(pane.windowId)
23
+ && typeof pane.tmuxEpoch === 'string' && pane.tmuxEpoch.length > 0 && pane.tmuxEpoch.length <= 1_024
24
+ && typeof process.pid === 'number' && Number.isSafeInteger(process.pid) && process.pid > 0
25
+ && typeof process.startedAt === 'number' && Number.isSafeInteger(process.startedAt) && process.startedAt > 0
26
+ && typeof process.tty === 'string' && process.tty.length > 0 && process.tty.length <= 1_024
27
+ && typeof process.executable === 'string' && process.executable.length > 0 && process.executable.length <= 8_192
28
+ && typeof value.sessionId === 'string' && isSessionUuid(value.sessionId)
29
+ && value.command === `handmux codex resume ${value.sessionId}`
30
+ && (value.phase === 'prepared' || value.phase === 'interrupted' || value.phase === 'resuming')
31
+ && typeof value.createdAt === 'number' && Number.isSafeInteger(value.createdAt) && value.createdAt > 0
32
+ && typeof value.updatedAt === 'number' && Number.isSafeInteger(value.updatedAt)
33
+ && value.updatedAt >= value.createdAt;
34
+ }
35
+ function parseState(value) {
36
+ if (value === null)
37
+ return { version: 1, receipts: [] };
38
+ if (!isRecord(value) || value.version !== 1 || !Array.isArray(value.receipts)
39
+ || value.receipts.length > MAX_RECEIPTS || !value.receipts.every(validReceipt)) {
40
+ throw new Error('Invalid Codex activation recovery state');
41
+ }
42
+ return { version: 1, receipts: structuredClone(value.receipts) };
43
+ }
44
+ function operationId(input) {
45
+ return crypto.createHash('sha256').update(JSON.stringify({
46
+ agentId: CODEX_AGENT_ID,
47
+ ...input,
48
+ })).digest('hex');
49
+ }
50
+ function sameProcess(first, second) {
51
+ return first.pid === second.pid && first.startedAt === second.startedAt
52
+ && first.tty === second.tty && first.executable === second.executable;
53
+ }
54
+ export class CodexActivationReceiptStore {
55
+ #store;
56
+ #now;
57
+ #write;
58
+ #fsyncDirectory;
59
+ #state;
60
+ #available = true;
61
+ constructor(file, { now = Date.now, write, fsyncDirectory = fsyncDirectorySync, } = {}) {
62
+ this.#store = new PrivateStateStore(file);
63
+ try {
64
+ this.#state = parseState(this.#store.readStrict());
65
+ }
66
+ catch {
67
+ this.#state = { version: 1, receipts: [] };
68
+ this.#available = false;
69
+ }
70
+ this.#now = now;
71
+ this.#write = write ?? ((state) => this.#store.write(state));
72
+ this.#fsyncDirectory = fsyncDirectory;
73
+ }
74
+ list() {
75
+ this.#assertAvailable();
76
+ return structuredClone(this.#state.receipts);
77
+ }
78
+ latestForPane(paneId) {
79
+ this.#assertAvailable();
80
+ return structuredClone([...this.#state.receipts].reverse().find((receipt) => (receipt.pane.paneId === paneId)) ?? null);
81
+ }
82
+ get(operationId) {
83
+ this.#assertAvailable();
84
+ return structuredClone(this.#state.receipts.find((receipt) => (receipt.operationId === operationId)) ?? null);
85
+ }
86
+ prepare(input) {
87
+ this.#assertAvailable();
88
+ const id = operationId(input);
89
+ const existing = this.#state.receipts.find((receipt) => receipt.operationId === id);
90
+ if (existing)
91
+ return structuredClone(existing);
92
+ const now = Math.trunc(this.#now());
93
+ const receipt = {
94
+ operationId: id,
95
+ agentId: CODEX_AGENT_ID,
96
+ ...structuredClone(input),
97
+ phase: 'prepared',
98
+ createdAt: now,
99
+ updatedAt: now,
100
+ };
101
+ if (!validReceipt(receipt))
102
+ throw new Error('Invalid Codex activation recovery receipt');
103
+ const next = {
104
+ version: 1,
105
+ receipts: [...this.#state.receipts, receipt].slice(-MAX_RECEIPTS),
106
+ };
107
+ this.#flush(next);
108
+ this.#state = next;
109
+ return structuredClone(receipt);
110
+ }
111
+ transition(id, expected, phase) {
112
+ this.#assertAvailable();
113
+ const index = this.#state.receipts.findIndex((candidate) => candidate.operationId === id);
114
+ const receipt = this.#state.receipts[index];
115
+ const allowed = Array.isArray(expected) ? expected : [expected];
116
+ if (!receipt || !allowed.includes(receipt.phase)) {
117
+ throw new Error('Codex activation recovery receipt changed');
118
+ }
119
+ if (receipt.phase === phase)
120
+ return structuredClone(receipt);
121
+ const updated = { ...receipt, phase, updatedAt: Math.trunc(this.#now()) };
122
+ const next = {
123
+ version: 1,
124
+ receipts: this.#state.receipts.map((candidate, candidateIndex) => (candidateIndex === index ? updated : candidate)),
125
+ };
126
+ this.#flush(next);
127
+ this.#state = next;
128
+ return structuredClone(updated);
129
+ }
130
+ clearManaged(paneId, sessionId, process) {
131
+ this.#assertAvailable();
132
+ const receipts = this.#state.receipts.filter((receipt) => !(receipt.pane.paneId === paneId && receipt.sessionId === sessionId
133
+ && (process === undefined || sameProcess(receipt.process, process))));
134
+ if (receipts.length === this.#state.receipts.length)
135
+ return false;
136
+ const next = { version: 1, receipts };
137
+ this.#flush(next);
138
+ this.#state = next;
139
+ return true;
140
+ }
141
+ #flush(next) {
142
+ this.#write(next);
143
+ this.#fsyncDirectory(path.dirname(this.#store.file));
144
+ }
145
+ #assertAvailable() {
146
+ if (!this.#available)
147
+ throw new Error('Codex activation recovery state is unavailable');
148
+ }
149
+ }
@@ -307,7 +307,16 @@ function itemBase(message, sessionId, id = stableId(message)) {
307
307
  ? { correlationId: message.correlationId } : {}),
308
308
  };
309
309
  }
310
- function projectDurableMessage(message, sessionId) {
310
+ function compactorItemKey(message) {
311
+ return typeof message.turnId === 'string' && message.turnId
312
+ && typeof message.itemId === 'string' && message.itemId
313
+ ? `${message.turnId}\0${message.itemId}` : null;
314
+ }
315
+ function projectDurableMessage(message, sessionId, suppressedCompactorItems) {
316
+ const sourceKey = compactorItemKey(message);
317
+ if (message.type === 'text' && message.role === 'assistant'
318
+ && sourceKey && suppressedCompactorItems.has(sourceKey))
319
+ return [];
311
320
  if (message.type === 'text' && message.role && typeof message.text === 'string') {
312
321
  const output = clipConversationText(message.text);
313
322
  return [{
@@ -342,17 +351,15 @@ function projectDurableMessage(message, sessionId) {
342
351
  const diffProjection = message.tool.diff === undefined
343
352
  ? undefined : sanitizeToolInputWithMetadata(message.tool.diff);
344
353
  const safeDiff = diffProjection?.value;
345
- const toolProjectionBytes = inputProjection.originalBytes + (diffProjection?.originalBytes ?? 0);
346
- const toolProjectionTruncated = inputProjection.truncated || diffProjection?.truncated === true;
347
354
  const outcomes = ['running', 'success', 'failed', 'declined', 'completed'];
348
355
  const outcome = typeof message.tool.outcome === 'string' && outcomes.includes(message.tool.outcome)
349
356
  ? message.tool.outcome : undefined;
350
357
  const items = [{
351
358
  ...itemBase(message, sessionId, callId), kind: 'tool_call', callId,
352
359
  name, ...(input === undefined ? {} : { input }),
353
- ...(toolProjectionTruncated ? {
360
+ ...(inputProjection.truncated ? {
354
361
  status: 'truncated',
355
- truncation: { reason: 'size_limit', originalBytes: toolProjectionBytes },
362
+ truncation: { reason: 'size_limit', originalBytes: inputProjection.originalBytes },
356
363
  } : {}),
357
364
  extensions: {
358
365
  'conversation.tool': {
@@ -512,6 +519,7 @@ export function createCodexConversationAdapter({ app, sessionsRoot = sessionsDir
512
519
  // read at the same byte cutoff as the adapter baseline so later rollout suffixes cannot appear in both
513
520
  // the page and the already-buffered native stream.
514
521
  const openingSnapshots = new Map();
522
+ const suppressedCompactorItemsBySession = new Map();
515
523
  function cloneCheckpoint(checkpoint) {
516
524
  return {
517
525
  ...checkpoint,
@@ -520,6 +528,19 @@ export function createCodexConversationAdapter({ app, sessionsRoot = sessionsDir
520
528
  snapshot: { ...checkpoint.snapshot, items: [...checkpoint.snapshot.items] },
521
529
  };
522
530
  }
531
+ const suppressionKeyOf = (values) => [...values].sort().join('\n');
532
+ async function readCompactorItemKeys(paneId, sessionId) {
533
+ const values = new Set((await app.compactorItemKeys(paneId, sessionId)).filter((value) => (typeof value === 'string' && value.includes('\0'))));
534
+ suppressedCompactorItemsBySession.delete(sessionId);
535
+ suppressedCompactorItemsBySession.set(sessionId, values);
536
+ while (suppressedCompactorItemsBySession.size > MAX_DURABLE_CHECKPOINTS) {
537
+ const oldest = suppressedCompactorItemsBySession.keys().next().value;
538
+ if (typeof oldest !== 'string')
539
+ break;
540
+ suppressedCompactorItemsBySession.delete(oldest);
541
+ }
542
+ return values;
543
+ }
523
544
  function rememberCheckpoint(sessionId, checkpoint) {
524
545
  durableCheckpoints.delete(sessionId);
525
546
  durableCheckpoints.set(sessionId, checkpoint);
@@ -530,17 +551,20 @@ export function createCodexConversationAdapter({ app, sessionsRoot = sessionsDir
530
551
  durableCheckpoints.delete(oldest);
531
552
  }
532
553
  }
533
- function snapshotFromMessages(session, parsed, file, version, readerGeneration, changedFrom, checkpoint) {
554
+ function snapshotFromMessages(session, parsed, file, version, readerGeneration, changedFrom, checkpoint, suppressedCompactorItems) {
555
+ const suppressionKey = suppressionKeyOf(suppressedCompactorItems);
534
556
  const sameReader = readerGeneration === undefined || checkpoint?.readerGeneration === undefined
535
557
  || checkpoint.readerGeneration === readerGeneration;
536
558
  const reusable = checkpoint?.file === file && sameReader && changedFrom !== null
559
+ && checkpoint.suppressionKey === suppressionKey
537
560
  && changedFrom >= 0 && changedFrom <= checkpoint.messageCount
538
561
  && changedFrom <= parsed.length;
539
562
  // Build transactionally. A backwards/truncated read must not corrupt the last readable checkpoint
540
563
  // before the floor check rejects it.
541
564
  const next = reusable && checkpoint ? cloneCheckpoint(checkpoint) : undefined;
542
565
  const from = next ? changedFrom : 0;
543
- const durableFloor = checkpoint?.snapshot.items.length ?? 0;
566
+ const durableFloor = checkpoint?.suppressionKey === suppressionKey
567
+ ? checkpoint.snapshot.items.length : 0;
544
568
  const items = next?.snapshot.items ?? [];
545
569
  const itemOffsets = next?.itemOffsets ?? [0];
546
570
  const historyHashes = next?.historyHashes ?? [];
@@ -550,14 +574,18 @@ export function createCodexConversationAdapter({ app, sessionsRoot = sessionsDir
550
574
  historyHashes.length = from;
551
575
  let historyHash = historyHashes.at(-1) ?? HISTORY_HASH_SEED;
552
576
  for (let index = from; index < parsed.length; index++) {
553
- const projected = projectDurableMessage(parsed[index], session.sessionId);
577
+ const projected = projectDurableMessage(parsed[index], session.sessionId, suppressedCompactorItems);
554
578
  items.push(...projected);
555
579
  itemOffsets.push(items.length);
556
- historyHash = createHash('sha256')
557
- .update(historyHash)
558
- .update('\0')
559
- .update(JSON.stringify(projected))
560
- .digest('hex');
580
+ // A suppressed compactor record is not a visible history revision. Keep the token unchanged until
581
+ // the parser replaces it with the durable compact marker, avoiding an identical-page refresh.
582
+ if (projected.length > 0) {
583
+ historyHash = createHash('sha256')
584
+ .update(historyHash)
585
+ .update('\0')
586
+ .update(JSON.stringify(projected))
587
+ .digest('hex');
588
+ }
561
589
  historyHashes.push(historyHash);
562
590
  }
563
591
  if (items.length < durableFloor) {
@@ -567,6 +595,7 @@ export function createCodexConversationAdapter({ app, sessionsRoot = sessionsDir
567
595
  file,
568
596
  ...(version === undefined ? {} : { version }),
569
597
  ...(readerGeneration === undefined ? {} : { readerGeneration }),
598
+ suppressionKey,
570
599
  messageCount: parsed.length,
571
600
  itemOffsets,
572
601
  historyHashes,
@@ -578,7 +607,7 @@ export function createCodexConversationAdapter({ app, sessionsRoot = sessionsDir
578
607
  },
579
608
  };
580
609
  }
581
- async function readDurableSnapshot(session) {
610
+ async function readDurableSnapshot(session, suppressionProvider) {
582
611
  const file = await findRollout(sessionsRoot, session.sessionId);
583
612
  const read = file && reader.readSnapshot
584
613
  ? await reader.readSnapshot(file, createCodexTranscriptParser)
@@ -588,8 +617,13 @@ export function createCodexConversationAdapter({ app, sessionsRoot = sessionsDir
588
617
  changedFrom: 0,
589
618
  generation: undefined,
590
619
  };
620
+ const suppressedCompactorItems = suppressionProvider
621
+ ? await suppressionProvider()
622
+ : suppressedCompactorItemsBySession.get(session.sessionId) ?? new Set();
623
+ const suppressionKey = suppressionKeyOf(suppressedCompactorItems);
591
624
  const known = durableCheckpoints.get(session.sessionId);
592
- if (known && known.file === file && read.version !== undefined && known.version === read.version) {
625
+ if (known && known.file === file && known.suppressionKey === suppressionKey
626
+ && read.version !== undefined && known.version === read.version) {
593
627
  rememberCheckpoint(session.sessionId, known);
594
628
  return known.snapshot;
595
629
  }
@@ -603,7 +637,7 @@ export function createCodexConversationAdapter({ app, sessionsRoot = sessionsDir
603
637
  rememberCheckpoint(session.sessionId, known);
604
638
  return known.snapshot;
605
639
  }
606
- const checkpoint = snapshotFromMessages(session, read.messages, file, read.version, read.generation, read.changedFrom, known);
640
+ const checkpoint = snapshotFromMessages(session, read.messages, file, read.version, read.generation, read.changedFrom, known, suppressedCompactorItems);
607
641
  rememberCheckpoint(session.sessionId, checkpoint);
608
642
  return checkpoint.snapshot;
609
643
  }
@@ -616,7 +650,7 @@ export function createCodexConversationAdapter({ app, sessionsRoot = sessionsDir
616
650
  ? { file, endExclusive }
617
651
  : { file: null, endExclusive: 0 };
618
652
  }
619
- async function readDurablePrefix(session, cutoff) {
653
+ async function readDurablePrefix(session, cutoff, suppressionProvider) {
620
654
  const known = durableCheckpoints.get(session.sessionId);
621
655
  const working = known ? cloneCheckpoint(known) : undefined;
622
656
  const read = cutoff.file && reader.readPrefixSnapshot
@@ -628,10 +662,15 @@ export function createCodexConversationAdapter({ app, sessionsRoot = sessionsDir
628
662
  changedFrom: 0,
629
663
  generation: undefined,
630
664
  };
665
+ const suppressedCompactorItems = suppressionProvider
666
+ ? await suppressionProvider()
667
+ : suppressedCompactorItemsBySession.get(session.sessionId) ?? new Set();
668
+ const suppressionKey = suppressionKeyOf(suppressedCompactorItems);
631
669
  const checkpoint = working && working.file === cutoff.file
670
+ && working.suppressionKey === suppressionKey
632
671
  && read.version !== undefined && working.version === read.version
633
672
  ? working
634
- : snapshotFromMessages(session, read.messages, cutoff.file, read.version, read.generation, read.changedFrom, working);
673
+ : snapshotFromMessages(session, read.messages, cutoff.file, read.version, read.generation, read.changedFrom, working, suppressedCompactorItems);
635
674
  return {
636
675
  checkpoint,
637
676
  snapshot: { ...checkpoint.snapshot, items: [...checkpoint.snapshot.items] },
@@ -751,7 +790,7 @@ export function createCodexConversationAdapter({ app, sessionsRoot = sessionsDir
751
790
  : now - pending.settledAt >= durableSettleTimeoutMs);
752
791
  let page;
753
792
  try {
754
- page = await readDurableSnapshot({ agentId: 'codex', sessionId });
793
+ page = await readDurableSnapshot({ agentId: 'codex', sessionId }, () => readCompactorItemKeys(run.ref.paneId, sessionId));
755
794
  }
756
795
  catch (error) {
757
796
  // A writer can transiently rotate or replace the rollout. Periodic reconciliation retries
@@ -1022,7 +1061,7 @@ export function createCodexConversationAdapter({ app, sessionsRoot = sessionsDir
1022
1061
  // Capture the append-only byte boundary only after the observer owns the native suffix. Anything
1023
1062
  // appended later belongs to that buffered suffix or the first post-open durable reconciliation.
1024
1063
  const cutoff = await captureDurableCutoff(sessionId);
1025
- const openingRead = await readDurablePrefix({ agentId: 'codex', sessionId }, cutoff);
1064
+ const openingRead = await readDurablePrefix({ agentId: 'codex', sessionId }, cutoff, () => readCompactorItemKeys(run.ref.paneId, sessionId));
1026
1065
  const baseline = openingRead.snapshot;
1027
1066
  live.durableToken = baseline.sourceHistoryToken;
1028
1067
  live.durableItems = [...baseline.items];