termdock 1.4.346 → 1.4.348

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.
@@ -467,6 +467,12 @@ export class CollaborationStore {
467
467
  const group = this.getGroup(groupId);
468
468
  if (!group || group.deleted || !context || typeof context !== 'object')
469
469
  return;
470
+ if (this.applyContext(group, context))
471
+ this.persist();
472
+ }
473
+ applyContext(group, context) {
474
+ if (group.deleted)
475
+ return false;
470
476
  let changed = false;
471
477
  const rules = context.instructions;
472
478
  if (rules && validRevision(rules) && typeof rules.text === 'string' && Buffer.byteLength(rules.text) <= 8192
@@ -488,8 +494,7 @@ export class CollaborationStore {
488
494
  delete group.roles[id];
489
495
  changed = true;
490
496
  }
491
- if (changed)
492
- this.persist();
497
+ return changed;
493
498
  }
494
499
  removeSession(sessionId) {
495
500
  const count = this.document.groups.filter(group => group.sessionIds.includes(sessionId)).length;
@@ -712,12 +717,17 @@ export class CollaborationStore {
712
717
  if (existing && (!existing.federated || (existing.deleted && (!group.deleted || existing.updatedAt >= group.updatedAt))
713
718
  || (!group.deleted && existing.updatedAt > group.updatedAt)))
714
719
  return;
715
- const incoming = group.deleted && existing && !existing.deleted
716
- ? { ...group, updatedAt: Math.max(group.updatedAt, existing.updatedAt + 1) } : group;
717
- const priorContext = existing ? this.context(group.id) : undefined;
718
- this.document.groups = [...this.document.groups.filter((item) => item.id !== group.id), { ...incoming }];
719
- if (priorContext)
720
- this.mergeContext(group.id, priorContext);
720
+ // Directory refreshes repeat the same group every few seconds. Compare
721
+ // content as well as the timestamp: equal timestamps can carry real edits.
722
+ if (existing && stableJson(existing) === stableJson(group))
723
+ return;
724
+ const incoming = structuredClone(group.deleted && existing && !existing.deleted
725
+ ? { ...group, updatedAt: Math.max(group.updatedAt, existing.updatedAt + 1) } : group);
726
+ if (existing)
727
+ this.applyContext(incoming, this.context(group.id));
728
+ if (existing && stableJson(existing) === stableJson(incoming))
729
+ return;
730
+ this.document.groups = [...this.document.groups.filter((item) => item.id !== group.id), incoming];
721
731
  if (group.deleted)
722
732
  this.document.messages = this.document.messages.filter((item) => item.groupId !== group.id);
723
733
  this.persist();
@@ -861,16 +871,34 @@ export class CollaborationStore {
861
871
  const message = this.getMessage(id);
862
872
  if (!message)
863
873
  throw new CollaborationError('MESSAGE_NOT_FOUND', 'Message not found or no longer retained', 404);
864
- const replies = this.document.messages.filter(reply => reply.replyTo === id
865
- && (message.toPrincipalId ? reply.integrationOrigin?.integrationId === message.toPrincipalId : reply.fromSessionId === message.toSessionId)
874
+ return this.messageReceipt(message, this.document.messages.filter(reply => reply.replyTo === id), raw);
875
+ }
876
+ /** Reconcile message events in one pass without rescanning the complete
877
+ * history for every receipt or formatting snapshots which events omit. */
878
+ snapshotMessagesWithReceipts(groups) {
879
+ const replies = new Map();
880
+ for (const message of this.document.messages)
881
+ if (message.replyTo) {
882
+ const list = replies.get(message.replyTo) ?? [];
883
+ list.push(message);
884
+ replies.set(message.replyTo, list);
885
+ }
886
+ return structuredClone(this.document.messages.filter(message => groups.has(message.groupId)).map(message => {
887
+ const { snapshot: _, ...receipt } = this.messageReceipt(message, replies.get(message.id) ?? [], false, false);
888
+ return { message, receipt };
889
+ }));
890
+ }
891
+ messageReceipt(message, candidates, raw, includeSnapshot = true) {
892
+ const id = message.id;
893
+ const replies = candidates.filter(reply => (message.toPrincipalId ? reply.integrationOrigin?.integrationId === message.toPrincipalId : reply.fromSessionId === message.toSessionId)
866
894
  && (message.integrationOrigin ? reply.toPrincipalId === message.integrationOrigin.integrationId : reply.toSessionId === message.fromSessionId));
867
895
  const diagnostic = this.diagnostic(id);
868
- const facts = terminalMessage(message, raw);
896
+ const facts = terminalMessage(includeSnapshot ? message : { ...message, snapshot: null }, raw);
869
897
  return { message_id: id, thread_id: message.threadId, status: facts.status, queued_at: message.status === 'stored' ? null : message.createdAt, stored_at: message.storedAt ?? null,
870
898
  from_principal_id: message.integrationOrigin?.integrationId ?? null, to_principal_id: message.toPrincipalId ?? null,
871
899
  delivered_at: facts.deliveredAt, expires_at: message.expiresAt ?? null,
872
900
  failure_reason: message.failureReason ?? null, delivery_semantics: message.status === 'stored' ? 'durable_storage' : facts.deliverySource ?? (facts.deliveredAt === null ? 'not_delivered' : 'legacy_or_unspecified'),
873
- snapshot: message.snapshot ? (raw ? message.snapshot : plainCollaborationSnapshot(message.snapshot)) : null,
901
+ snapshot: includeSnapshot && message.snapshot ? (raw ? message.snapshot : plainCollaborationSnapshot(message.snapshot)) : null,
874
902
  delivery: { status: facts.status,
875
903
  stage: message.status === 'stored' ? 'stored' : facts.deliveredAt !== null ? 'terminal_written' : diagnostic?.route_state === 'identity-mismatch' || diagnostic?.last_error === 'TMUX_PANE_CHANGED' ? 'blocked' : diagnostic?.remote_received_at ? 'remote_received' : 'queued',
876
904
  ...(!diagnostic?.route_diagnostics && diagnostic?.last_error === 'TMUX_PANE_CHANGED' ? { requires_confirmation: true } : {}),
@@ -29,6 +29,8 @@ export class IntegrationRuntime {
29
29
  streams = new Set();
30
30
  sent = new Map();
31
31
  syncing = false;
32
+ dirtySources = new Set(['messages', 'tasks', 'sessions']);
33
+ syncedGroups = '';
32
34
  retry;
33
35
  releaseBatchFile;
34
36
  releaseBatchOperations = [];
@@ -57,14 +59,17 @@ export class IntegrationRuntime {
57
59
  throw error;
58
60
  }
59
61
  this.stops.push(options.messages.registerRuntimeGuard(id => this.sessions.assertRuntimeMessage(id)));
60
- const changed = () => { try {
61
- this.sync();
62
- }
63
- catch (error) {
64
- this.failStreams(error);
65
- this.scheduleRetry();
66
- } };
67
- this.stops.push(options.messages.subscribe(changed), options.taskStore.subscribe(changed), this.sessions.subscribe(changed));
62
+ const changed = (source) => () => {
63
+ this.dirtySources.add(source);
64
+ try {
65
+ this.sync();
66
+ }
67
+ catch (error) {
68
+ this.failStreams(error);
69
+ this.scheduleRetry();
70
+ }
71
+ };
72
+ this.stops.push(options.messages.subscribe(changed('messages')), options.taskStore.subscribe(changed('tasks')), this.sessions.subscribe(changed('sessions')));
68
73
  const app = express();
69
74
  app.use(express.json({ limit: '2mb' }));
70
75
  app.use((req, res, next) => {
@@ -302,6 +307,7 @@ export class IntegrationRuntime {
302
307
  session_launch_reload_fence: true, session_launch_reload_fence_min_version: '1.4.346',
303
308
  session_shell_startup_wait: true, session_shell_startup_wait_min_version: '1.4.346',
304
309
  session_profile_native_identity: process.platform === 'linux', session_profile_native_identity_min_version: '1.4.346',
310
+ session_profile_native_identity_home_aliases: process.platform === 'linux', session_profile_native_identity_home_aliases_min_version: '1.4.348',
305
311
  session_launch_diagnostics: true, session_launch_diagnostics_min_version: '1.4.314',
306
312
  session_cold_retry: true, session_cold_retry_min_version: '1.4.314',
307
313
  session_rename: true, session_rename_min_version: '1.4.315',
@@ -461,11 +467,25 @@ export class IntegrationRuntime {
461
467
  if (this.syncing)
462
468
  return;
463
469
  this.syncing = true;
470
+ let sources = new Set();
464
471
  try {
465
472
  const groups = new Set(this.store.policies().filter(p => !p.revoked).map(p => p.groupId)), updates = [];
466
- if (!groups.size)
473
+ const groupVersion = JSON.stringify([...groups].sort());
474
+ // Newly granted groups need a complete source reconciliation, including
475
+ // retained facts that predate the grant. Never advance dirty state until
476
+ // the durable event append succeeds.
477
+ if (groupVersion !== this.syncedGroups)
478
+ for (const source of ['messages', 'tasks', 'sessions'])
479
+ this.dirtySources.add(source);
480
+ sources = this.dirtySources;
481
+ this.dirtySources = new Set();
482
+ if (!sources.size)
483
+ return;
484
+ if (!groups.size) {
485
+ this.syncedGroups = groupVersion;
467
486
  return;
468
- for (const task of this.options.taskStore.snapshot())
487
+ }
488
+ for (const task of sources.has('tasks') ? this.options.taskStore.snapshot() : [])
469
489
  if (groups.has(task.groupId)) {
470
490
  for (const event of task.events)
471
491
  updates.push({ sourceKey: `task:${task.id}:${event.id}`, sourceVersion: '1', eventId: event.id, event: {
@@ -485,31 +505,30 @@ export class IntegrationRuntime {
485
505
  task_id: task.id, attempt_id: delivery.attemptId, message_id: delivery.messageId, source: 'system', payload: { delivery }
486
506
  } });
487
507
  }
488
- for (const message of this.options.messages.snapshotMessages())
489
- if (groups.has(message.groupId)) {
490
- const link = message.metadata?.termdockTask;
491
- const context = { group_id: message.groupId, task_id: link?.taskId ?? null, attempt_id: link?.attemptId ?? null,
492
- reply_to_event_id: link?.replyToEventId ?? null, message_id: message.id, source: message.integrationOrigin ? 'integration' : message.fromSessionId ? 'member' : 'system',
493
- actor: message.integrationOrigin ? { integration_id: message.integrationOrigin.integrationId } : message.fromSessionId ? { session_id: message.fromSessionId } : null,
494
- external_actor: message.integrationOrigin?.externalActor ?? null };
495
- updates.push({ sourceKey: `message:${message.id}`, sourceVersion: '1', event: { ...context,
496
- kind: message.kind === 'reply' ? 'message.reply' : 'message.queued', created_at: message.createdAt,
497
- payload: { message_id: message.id, thread_id: message.threadId, reply_to: message.replyTo ?? null,
498
- from_session_id: message.fromSessionId, to_session_id: message.toPrincipalId ? null : message.toSessionId,
499
- to_principal_id: message.toPrincipalId ?? null, integration_origin: message.integrationOrigin ?? null,
500
- response_kind: message.responseKind ?? null, stored_at: message.storedAt ?? null,
501
- content: message.content, metadata: message.metadata ?? null } } });
502
- const { snapshot: _, ...receipt } = this.options.messages.receipt(message.id);
503
- updates.push({ sourceKey: `message-status:${message.id}`, sourceVersion: JSON.stringify(receipt), event: {
504
- ...context, kind: 'message.delivery', created_at: message.deliveredAt ?? message.createdAt, payload: { receipt }
505
- } });
506
- }
507
- for (const session of this.sessions.snapshot())
508
+ for (const { message, receipt } of sources.has('messages') ? this.options.messages.snapshotMessagesWithReceipts(groups) : []) {
509
+ const link = message.metadata?.termdockTask;
510
+ const context = { group_id: message.groupId, task_id: link?.taskId ?? null, attempt_id: link?.attemptId ?? null,
511
+ reply_to_event_id: link?.replyToEventId ?? null, message_id: message.id, source: message.integrationOrigin ? 'integration' : message.fromSessionId ? 'member' : 'system',
512
+ actor: message.integrationOrigin ? { integration_id: message.integrationOrigin.integrationId } : message.fromSessionId ? { session_id: message.fromSessionId } : null,
513
+ external_actor: message.integrationOrigin?.externalActor ?? null };
514
+ updates.push({ sourceKey: `message:${message.id}`, sourceVersion: '1', event: { ...context,
515
+ kind: message.kind === 'reply' ? 'message.reply' : 'message.queued', created_at: message.createdAt,
516
+ payload: { message_id: message.id, thread_id: message.threadId, reply_to: message.replyTo ?? null,
517
+ from_session_id: message.fromSessionId, to_session_id: message.toPrincipalId ? null : message.toSessionId,
518
+ to_principal_id: message.toPrincipalId ?? null, integration_origin: message.integrationOrigin ?? null,
519
+ response_kind: message.responseKind ?? null, stored_at: message.storedAt ?? null,
520
+ content: message.content, metadata: message.metadata ?? null } } });
521
+ updates.push({ sourceKey: `message-status:${message.id}`, sourceVersion: JSON.stringify(receipt), event: {
522
+ ...context, kind: 'message.delivery', created_at: message.deliveredAt ?? message.createdAt, payload: { receipt }
523
+ } });
524
+ }
525
+ const sessions = sources.has('sessions') ? this.sessions.snapshot() : [];
526
+ for (const session of sessions)
508
527
  if (groups.has(session.group_id))
509
528
  updates.push({ sourceKey: `session:${session.session_id}`, sourceVersion: JSON.stringify(publicIntegrationSession(session)), event: {
510
529
  group_id: session.group_id, kind: `session.${session.state}`, created_at: session.updated_at, source: 'system', actor: { integration_id: session.principal_id }, payload: { session: publicIntegrationSession(session) }
511
530
  } });
512
- for (const session of this.sessions.snapshot())
531
+ for (const session of sessions)
513
532
  if (groups.has(session.group_id) && session.name_updated_at)
514
533
  updates.push({
515
534
  sourceKey: `session-name:${session.session_id}`, sourceVersion: JSON.stringify([session.name, session.name_updated_at, session.name_generation]), event: {
@@ -519,9 +538,17 @@ export class IntegrationRuntime {
519
538
  });
520
539
  updates.sort((a, b) => a.event.created_at - b.event.created_at);
521
540
  this.store.append(updates);
541
+ this.syncedGroups = groupVersion;
542
+ }
543
+ catch (error) {
544
+ for (const source of sources)
545
+ this.dirtySources.add(source);
546
+ throw error;
522
547
  }
523
548
  finally {
524
549
  this.syncing = false;
550
+ if (this.dirtySources.size)
551
+ this.scheduleRetry();
525
552
  }
526
553
  }
527
554
  failStreams(error) {
@@ -544,7 +571,7 @@ export class IntegrationRuntime {
544
571
  const key = `${p.id}:${p.groupId}:${consumer}`;
545
572
  // Resolve the initial cursor before sending headers so retention gaps
546
573
  // and bad consumer names are ordinary machine-readable HTTP errors.
547
- const initial = this.store.page(p, consumer);
574
+ const initial = this.store.streamPage(p, consumer);
548
575
  cursor = initial.cursor;
549
576
  res.setHeader('Content-Type', 'application/x-ndjson');
550
577
  res.setHeader('Cache-Control', 'no-store');
@@ -559,13 +586,13 @@ export class IntegrationRuntime {
559
586
  try {
560
587
  this.authorize(p, 'events.read');
561
588
  for (;;) {
562
- const page = this.store.page(p, consumer, cursor);
589
+ const page = this.store.streamPage(p, consumer, cursor);
563
590
  if (!page.events.length)
564
591
  break;
565
592
  for (const event of page.events) {
566
593
  if (res.writableLength > 1024 * 1024)
567
594
  integrationError('EVENT_CONSUMER_SLOW', 'Reconnect from the last acknowledged cursor', 503);
568
- res.write(`${JSON.stringify({ type: 'event', ...event })}\n`);
595
+ res.write(event.line);
569
596
  cursor = event.cursor;
570
597
  this.sent.set(key, Math.max(this.sent.get(key) ?? 0, event.sequence));
571
598
  }
@@ -68,6 +68,8 @@ export class IntegrationSessions {
68
68
  stopDeliveryGuard;
69
69
  observing = false;
70
70
  observeAgain = false;
71
+ observationTimer;
72
+ stopped = false;
71
73
  constructor(file, adapter, bindingTimeout = 120000) {
72
74
  this.file = file;
73
75
  this.adapter = adapter;
@@ -94,7 +96,22 @@ export class IntegrationSessions {
94
96
  if (record.startup_input && record.startup_input.state !== 'observed')
95
97
  record.startup_input.matched_since = null;
96
98
  this.stopDeliveryGuard = adapter.registerDeliveryGuard?.(id => this.deliveryReadiness(id));
97
- this.stopObservation = adapter.subscribe(() => { void this.refreshAll().catch(() => { }); });
99
+ this.stopObservation = adapter.subscribe(() => this.scheduleObservation());
100
+ }
101
+ scheduleObservation() {
102
+ if (this.stopped)
103
+ return;
104
+ if (this.observing) {
105
+ this.observeAgain = true;
106
+ return;
107
+ }
108
+ if (this.observationTimer)
109
+ return;
110
+ this.observationTimer = setTimeout(() => {
111
+ this.observationTimer = undefined;
112
+ void this.refreshAll().catch(() => { });
113
+ }, 250);
114
+ this.observationTimer.unref();
98
115
  }
99
116
  scheduleRefresh(record) {
100
117
  for (const deadline of new Set([record.deadline, ...(record.startup_input ? [record.startup_input.deadline] : [])])) {
@@ -697,10 +714,12 @@ export class IntegrationSessions {
697
714
  return record;
698
715
  }));
699
716
  }
700
- async refresh(id, allowBindingReconciliation = true) {
717
+ async refresh(id, allowBindingReconciliation = true, presence) {
701
718
  const record = structuredClone(this.doc.sessions.find(s => s.session_id === id));
702
719
  if (!record)
703
720
  return;
721
+ const runtimePresent = () => presence && JSON.stringify(record) === JSON.stringify(presence.record)
722
+ ? Promise.resolve(presence.present) : this.adapter.runtimePresent?.(record);
704
723
  if (record.rename_pending) {
705
724
  if (this.lifecycle.active(id))
706
725
  return;
@@ -727,7 +746,7 @@ export class IntegrationSessions {
727
746
  integrationError('SESSION_CLOSE_UNSUPPORTED', 'Close reconciliation is unavailable', 409);
728
747
  await this.adapter.closeRuntime(record, true);
729
748
  }
730
- if (!this.adapter.runtimePresent || await this.adapter.runtimePresent(record))
749
+ if (!this.adapter.runtimePresent || await runtimePresent())
731
750
  integrationError('OPERATION_OUTCOME_UNCONFIRMED', 'Runtime exists for a closed or closing session', 409);
732
751
  const next = { ...record, state: 'closed', runtime_present: false, terminal_binding: null, close_intent: undefined,
733
752
  operation_uncertain: false, error_code: null, startup_input: undefined };
@@ -755,7 +774,7 @@ export class IntegrationSessions {
755
774
  try {
756
775
  if (record.state !== 'released')
757
776
  await this.adapter.release?.(record, true);
758
- const present = await this.adapter.runtimePresent?.(record);
777
+ const present = await runtimePresent();
759
778
  if (present === undefined)
760
779
  integrationError('SESSION_RELEASE_UNSUPPORTED', 'Runtime presence is unavailable', 409);
761
780
  if (record.state !== 'released') {
@@ -974,18 +993,42 @@ export class IntegrationSessions {
974
993
  this.observeAgain = true;
975
994
  return;
976
995
  }
996
+ if (this.observationTimer) {
997
+ clearTimeout(this.observationTimer);
998
+ this.observationTimer = undefined;
999
+ }
977
1000
  this.observing = true;
1001
+ this.observeAgain = false;
978
1002
  try {
979
- do {
980
- this.observeAgain = false;
981
- for (const record of this.snapshot())
982
- await this.refresh(record.session_id);
983
- } while (this.observeAgain);
1003
+ const records = this.snapshot();
1004
+ const dormant = records.filter(record => ['closed', 'released'].includes(record.state) && record.runtime_present === false
1005
+ && !record.operation_uncertain && !record.close_intent && !record.rename_pending && !record.cold_retry_pending);
1006
+ let presence;
1007
+ if (dormant.length && this.adapter.runtimePresenceBatch) {
1008
+ try {
1009
+ presence = await this.adapter.runtimePresenceBatch(dormant);
1010
+ }
1011
+ catch { /* Failed batch observations fall back to fresh per-record checks. */ }
1012
+ }
1013
+ const candidates = new Map(dormant.map(record => [record.session_id, record]));
1014
+ for (const record of records) {
1015
+ const candidate = candidates.get(record.session_id), present = presence?.get(record.session_id);
1016
+ await this.refresh(record.session_id, true, candidate && typeof present === 'boolean' ? { record: candidate, present } : undefined);
1017
+ }
984
1018
  }
985
1019
  finally {
986
1020
  this.observing = false;
1021
+ if (this.observeAgain)
1022
+ this.scheduleObservation();
987
1023
  }
988
1024
  }
989
- close() { this.stopObservation(); this.stopDeliveryGuard?.(); for (const timer of this.timers)
990
- clearTimeout(timer); }
1025
+ close() {
1026
+ this.stopped = true;
1027
+ this.stopObservation();
1028
+ this.stopDeliveryGuard?.();
1029
+ if (this.observationTimer)
1030
+ clearTimeout(this.observationTimer);
1031
+ for (const timer of this.timers)
1032
+ clearTimeout(timer);
1033
+ }
991
1034
  }
@@ -7,12 +7,18 @@ export const INTEGRATION_PROTOCOL = 1;
7
7
  export const INTEGRATION_OPERATIONS = ['message.send', 'message.read', 'task.read', 'task.create', 'task.configure', 'task.assign', 'task.comment', 'task.revise', 'task.answer', 'events.read', 'session.create', 'session.read', 'session.restore', 'session.release', 'session.rename', 'session.close'];
8
8
  export function integrationError(code, message, status = 400) { throw new CollaborationError(code, message, status); }
9
9
  export function atomicJson(file, value) {
10
+ atomicJsonText(file, JSON.stringify(value));
11
+ }
12
+ function atomicJsonText(file, serialized) {
13
+ atomicJsonWrite(file, descriptor => fs.writeFileSync(descriptor, serialized));
14
+ }
15
+ function atomicJsonWrite(file, write) {
10
16
  fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
11
17
  const temporary = `${file}.${process.pid}.tmp`;
12
18
  const descriptor = fs.openSync(temporary, 'w', 0o600);
13
19
  try {
14
20
  fs.fchmodSync(descriptor, 0o600);
15
- fs.writeFileSync(descriptor, JSON.stringify(value));
21
+ write(descriptor);
16
22
  fs.fsyncSync(descriptor);
17
23
  }
18
24
  finally {
@@ -27,6 +33,42 @@ export function atomicJson(file, value) {
27
33
  fs.closeSync(directory);
28
34
  }
29
35
  }
36
+ function atomicJsonBuffers(file, parts) {
37
+ atomicJsonWrite(file, descriptor => {
38
+ let index = 0, offset = 0;
39
+ while (index < parts.length) {
40
+ // Bound vectors below platform IOV_MAX, and handle short writes without
41
+ // replaying or dropping any byte of the checked JSON document.
42
+ const vectors = parts.slice(index, index + 512);
43
+ if (offset)
44
+ vectors[0] = vectors[0].subarray(offset);
45
+ let written;
46
+ try {
47
+ written = fs.writevSync(descriptor, vectors);
48
+ }
49
+ catch (error) {
50
+ if (error.code === 'EINTR')
51
+ continue;
52
+ throw error;
53
+ }
54
+ if (written <= 0)
55
+ throw new Error('Integration journal write made no progress');
56
+ while (index < parts.length) {
57
+ const available = parts[index].length - offset;
58
+ if (written < available) {
59
+ offset += written;
60
+ break;
61
+ }
62
+ written -= available;
63
+ index++;
64
+ offset = 0;
65
+ if (!written)
66
+ break;
67
+ }
68
+ }
69
+ });
70
+ }
71
+ const JSON_OPEN = Buffer.from('{'), JSON_CLOSE = Buffer.from('}'), JSON_COMMA = Buffer.from(','), ARRAY_OPEN = Buffer.from('['), ARRAY_CLOSE = Buffer.from(']'), EVENT_PREFIX = Buffer.from('{"type":"event",'), NEWLINE = Buffer.from('\n');
30
72
  const hash = (value) => createHash('sha256').update(value).digest('hex');
31
73
  function identifier(value, label) {
32
74
  if (typeof value !== 'string' || !/^[a-zA-Z0-9._-]{1,80}$/.test(value))
@@ -77,6 +119,11 @@ export class IntegrationStore {
77
119
  retention;
78
120
  doc = { version: 1, principals: [], events: [], sequences: {}, prunedThrough: {}, sources: {}, consumers: {} };
79
121
  listeners = new Set();
122
+ // Committed events and metadata are immutable inside the store. Keep the
123
+ // encoded event fragments, not another deep copy of their payloads. Pruned
124
+ // objects become collectible with their encodings.
125
+ encoded = new WeakMap();
126
+ groupEvents = new Map();
80
127
  constructor(file, retention = 10000) {
81
128
  this.file = file;
82
129
  this.retention = retention;
@@ -90,12 +137,52 @@ export class IntegrationStore {
90
137
  if (error.code !== 'ENOENT')
91
138
  throw error;
92
139
  }
140
+ this.indexEvents();
141
+ }
142
+ indexEvents() {
143
+ this.groupEvents = new Map();
144
+ for (const event of this.doc.events) {
145
+ const events = this.groupEvents.get(event.group_id) ?? [];
146
+ events.push(event);
147
+ this.groupEvents.set(event.group_id, events);
148
+ }
149
+ }
150
+ encode(value) {
151
+ if (!value || typeof value !== 'object')
152
+ return Buffer.from(JSON.stringify(value));
153
+ const cached = this.encoded.get(value);
154
+ if (cached !== undefined)
155
+ return cached;
156
+ const serialized = Buffer.from(JSON.stringify(value));
157
+ this.encoded.set(value, serialized);
158
+ return serialized;
93
159
  }
94
160
  write(next) {
95
- if (Buffer.byteLength(JSON.stringify(next)) > 64 * 1024 * 1024)
161
+ const parts = [JSON_OPEN];
162
+ for (const [index, [key, value]] of Object.entries(next).entries()) {
163
+ if (index)
164
+ parts.push(JSON_COMMA);
165
+ parts.push(Buffer.from(`${JSON.stringify(key)}:`));
166
+ if (key === 'events') {
167
+ parts.push(ARRAY_OPEN);
168
+ for (const [index, event] of next.events.entries()) {
169
+ if (index)
170
+ parts.push(JSON_COMMA);
171
+ parts.push(this.encode(event));
172
+ }
173
+ parts.push(ARRAY_CLOSE);
174
+ }
175
+ else
176
+ parts.push(this.encode(value));
177
+ }
178
+ parts.push(JSON_CLOSE);
179
+ if (parts.reduce((bytes, part) => bytes + part.length, 0) > 64 * 1024 * 1024)
96
180
  integrationError('EVENT_STORAGE_FULL', 'Integration journal is full; no event was acknowledged', 503);
97
- atomicJson(this.file, next);
181
+ atomicJsonBuffers(this.file, parts);
182
+ const eventsChanged = next.events !== this.doc.events;
98
183
  this.doc = next;
184
+ if (eventsChanged)
185
+ this.indexEvents();
99
186
  for (const listener of this.listeners) {
100
187
  try {
101
188
  listener();
@@ -110,13 +197,12 @@ export class IntegrationStore {
110
197
  if (this.doc.principals.some(p => p.id === checked.id))
111
198
  integrationError('INTEGRATION_EXISTS', 'Use a new id; credentials cannot be silently replaced', 409);
112
199
  const token = randomBytes(32).toString('base64url');
113
- const next = structuredClone(this.doc);
114
- next.principals.push({ ...checked, tokenHash: hash(token), revoked: false });
200
+ const next = { ...this.doc, principals: [...this.doc.principals, { ...checked, tokenHash: hash(token), revoked: false }] };
115
201
  this.write(next);
116
- return { principal: checked, token };
202
+ return { principal: structuredClone(checked), token };
117
203
  }
118
204
  update(policy) {
119
- const checked = validatePolicy(policy), next = structuredClone(this.doc);
205
+ const checked = validatePolicy(policy), next = { ...this.doc, principals: [...this.doc.principals] };
120
206
  const index = next.principals.findIndex(principal => principal.id === checked.id);
121
207
  if (index < 0)
122
208
  integrationError('INTEGRATION_NOT_FOUND', 'Integration does not exist', 404);
@@ -129,11 +215,12 @@ export class IntegrationStore {
129
215
  this.write(next);
130
216
  }
131
217
  revoke(id) {
132
- const next = structuredClone(this.doc), principal = next.principals.find(p => p.id === id);
218
+ const principal = this.doc.principals.find(p => p.id === id);
133
219
  if (!principal)
134
220
  integrationError('INTEGRATION_NOT_FOUND', 'Integration does not exist', 404);
135
- principal.revoked = true;
136
- this.write(next);
221
+ if (principal.revoked)
222
+ return;
223
+ this.write({ ...this.doc, principals: this.doc.principals.map(p => p.id === id ? { ...p, revoked: true } : p) });
137
224
  }
138
225
  authenticate(id, token) {
139
226
  const principal = this.doc.principals.find(p => p.id === id);
@@ -152,18 +239,20 @@ export class IntegrationStore {
152
239
  integrationError('INTEGRATION_PERMISSION_DENIED', 'Operation is outside the integration grant', 403);
153
240
  }
154
241
  append(updates) {
155
- const next = structuredClone(this.doc);
156
- let changed = false;
242
+ // Most reconciliations repeat already-journaled facts. Do not copy the
243
+ // retained event history until the first actual source-version change.
244
+ let next;
157
245
  for (const update of updates) {
158
- if (next.sources[update.sourceKey] === update.sourceVersion)
246
+ if ((next ?? this.doc).sources[update.sourceKey] === update.sourceVersion)
159
247
  continue;
248
+ next ??= { ...this.doc, events: [...this.doc.events], sequences: { ...this.doc.sequences },
249
+ sources: { ...this.doc.sources }, prunedThrough: { ...this.doc.prunedThrough } };
160
250
  const group = update.event.group_id, sequence = (next.sequences[group] ?? 0) + 1;
161
- next.events.push({ ...update.event, event_id: update.eventId ?? randomUUID(), sequence, cursor: this.cursor(group, sequence) });
251
+ next.events.push({ ...structuredClone(update.event), event_id: update.eventId ?? randomUUID(), sequence, cursor: this.cursor(group, sequence) });
162
252
  next.sequences[group] = sequence;
163
253
  next.sources[update.sourceKey] = update.sourceVersion;
164
- changed = true;
165
254
  }
166
- if (!changed)
255
+ if (!next)
167
256
  return;
168
257
  const counts = new Map();
169
258
  next.events = next.events.reverse().filter(event => {
@@ -190,21 +279,39 @@ export class IntegrationStore {
190
279
  return value.sequence;
191
280
  }
192
281
  key(principal, consumer) { identifier(consumer, 'consumer'); return `${principal.id}:${principal.groupId}:${consumer}`; }
193
- page(principal, consumer, cursor) {
282
+ selectPage(principal, consumer, cursor) {
194
283
  this.authorize(principal, 'events.read');
195
284
  const key = this.key(principal, consumer), group = principal.groupId;
196
285
  const floor = this.doc.prunedThrough[group] ?? 0;
197
286
  // A new consumer explicitly starts at the earliest retained fact. Existing
198
287
  // consumers never silently skip a retention gap.
199
288
  if (this.doc.consumers[key] === undefined) {
200
- const next = structuredClone(this.doc);
201
- next.consumers[key] = floor;
202
- this.write(next);
289
+ this.write({ ...this.doc, consumers: { ...this.doc.consumers, [key]: floor } });
203
290
  }
204
291
  const after = cursor ? this.decode(group, cursor) : this.doc.consumers[key];
205
292
  if (after < floor)
206
293
  integrationError('EVENT_RETENTION_GAP', 'Confirmed cursor is older than retained history; reconcile task/session snapshots and use a new consumer', 409);
207
- return { events: structuredClone(this.doc.events.filter(e => e.group_id === group && e.sequence > after).slice(0, 100)), cursor: this.cursor(group, after) };
294
+ const events = this.groupEvents.get(group) ?? [];
295
+ let lo = 0, hi = events.length;
296
+ while (lo < hi) {
297
+ const mid = (lo + hi) >>> 1;
298
+ if (events[mid].sequence <= after)
299
+ lo = mid + 1;
300
+ else
301
+ hi = mid;
302
+ }
303
+ return { events: events.slice(lo, lo + 100), cursor: this.cursor(group, after) };
304
+ }
305
+ page(principal, consumer, cursor) {
306
+ return structuredClone(this.selectPage(principal, consumer, cursor));
307
+ }
308
+ /** Push uses immutable encoded facts rather than copying and re-encoding
309
+ * every retained payload for each consumer. No mutable record escapes. */
310
+ streamPage(principal, consumer, cursor) {
311
+ const page = this.selectPage(principal, consumer, cursor);
312
+ return { cursor: page.cursor, events: page.events.map(event => ({ sequence: event.sequence, cursor: event.cursor,
313
+ line: Object.hasOwn(event, 'type') ? Buffer.from(`${JSON.stringify({ type: 'event', ...event })}\n`)
314
+ : Buffer.concat([EVENT_PREFIX, this.encode(event).subarray(1), NEWLINE]) })) };
208
315
  }
209
316
  ack(principal, consumer, cursor) {
210
317
  this.authorize(principal, 'events.read');
@@ -217,9 +324,8 @@ export class IntegrationStore {
217
324
  integrationError('EVENT_RETENTION_GAP', 'Consumer has a retention gap', 409);
218
325
  // Cursor is a cumulative declaration that every preceding event is safely
219
326
  // in the bridge inbox. The subscription server separately checks it was sent.
220
- const next = structuredClone(this.doc);
221
- next.consumers[key] = sequence;
222
- this.write(next);
327
+ if (sequence !== confirmed)
328
+ this.write({ ...this.doc, consumers: { ...this.doc.consumers, [key]: sequence } });
223
329
  return { cursor, ack_semantics: 'durable_received' };
224
330
  }
225
331
  cursorSequence(group, cursor) { return this.decode(group, cursor); }
@@ -11,8 +11,23 @@ export function nativeIdentityDirectory(config) {
11
11
  const expanded = c.directory.startsWith('~/') ? path.join(os.homedir(), c.directory.slice(2)) : c.directory;
12
12
  if (!path.isAbsolute(expanded))
13
13
  return null;
14
- const directory = path.resolve(expanded), relative = path.relative(os.homedir(), directory);
15
- return relative && relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative) ? directory : null;
14
+ const directory = path.resolve(expanded), home = os.homedir();
15
+ const within = (root) => {
16
+ const relative = path.relative(root, directory);
17
+ return !!relative && relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
18
+ };
19
+ if (within(home))
20
+ return directory;
21
+ // cwd is canonicalized before launch. A symlinked home may therefore have
22
+ // two legitimate spellings even before the lock directory is created.
23
+ // Accept only the real home alias; the live provider still verifies the
24
+ // actual directory ownership, realpath, FD/inode, starttime and kernel lock.
25
+ try {
26
+ return within(fs.realpathSync(home)) ? directory : null;
27
+ }
28
+ catch {
29
+ return null;
30
+ }
16
31
  }
17
32
  /** Administrator-declared profile scope, never inferred from process env.
18
33
  * {cwd} is permitted only as a leading directory anchor and cannot escape it. */
@@ -77,6 +77,7 @@ import { CollaborationError } from '../agent/collaborationProtocol.js';
77
77
  import { resolveCollaborationBackend, resolveCollaborationSessionId } from '../agent/sessionBindingRecovery.js';
78
78
  import { CollaborationRoutingStore, selectCollaborationPane, selectDrivePane, assessCollaborationRouteIdentity } from '../agent/collaborationRouting.js';
79
79
  import { selectRecoveredIntegrationBinding } from '../agent/integrationRecoveryBinding.js';
80
+ import { readTmuxLayout, readTmuxLayouts } from '../utils/tmuxLayoutReader.js';
80
81
  import { CollaborationDeliveryWorker } from '../agent/collaborationDeliveryWorker.js';
81
82
  import { approveCollaborationDialog, captureTmuxPaneHistory, captureTmuxPaneText, sendTmuxPaneKey, writeCollaborationTmuxPane } from '../agent/collaborationTmuxDelivery.js';
82
83
  import { listAllHookAgents, refreshStaleHooksAtLaunch, installHooksForSlug, uninstallHooksForSlug, } from '../agent/installers.js';
@@ -1724,9 +1725,9 @@ function formatLocalCollaborationMessages(frontendSessionId, messages) {
1724
1725
  async function inspectCollaborationTmux(binding, requestedPane, allowPlainPane = false, nativeProcessIdentity = false, freshProcess = false, describeNativeFailure = false, identityOverride) {
1725
1726
  if (!binding.tmuxSessionName)
1726
1727
  return { state: 'offline', reason: 'TMUX_BINDING_MISSING' };
1727
- const name = `=${binding.tmuxSessionName}`;
1728
+ let layout;
1728
1729
  try {
1729
- await runTmux(['has-session', '-t', name]);
1730
+ layout = await getTmuxLayout(binding.tmuxSessionName);
1730
1731
  }
1731
1732
  catch (error) {
1732
1733
  if (/can't find session|no server running|no sessions|error connecting.*No such file/i.test(getErrorMessage(error))) {
@@ -1734,10 +1735,9 @@ async function inspectCollaborationTmux(binding, requestedPane, allowPlainPane =
1734
1735
  }
1735
1736
  throw error;
1736
1737
  }
1737
- const layout = await getTmuxLayout(binding.tmuxSessionName);
1738
1738
  if (layout.sessionName !== binding.tmuxSessionName)
1739
1739
  throw new Error('TMUX_SESSION_IDENTITY_CHANGED');
1740
- const serverPid = Number((await runTmux(['display-message', '-p', '#{pid}'])).trim());
1740
+ const serverPid = Number(layout.serverPid);
1741
1741
  if (!Number.isInteger(serverPid) || serverPid <= 0)
1742
1742
  throw new Error('TMUX_SERVER_IDENTITY_UNAVAILABLE');
1743
1743
  if ((nativeProcessIdentity || freshProcess) && process.platform === 'linux')
@@ -1765,7 +1765,7 @@ async function inspectCollaborationTmux(binding, requestedPane, allowPlainPane =
1765
1765
  // pane so a plain shell member is drivable (run/capture are shell ops).
1766
1766
  return selectDrivePane(binding, candidates, layout.activePaneId);
1767
1767
  }
1768
- return { ...selectCollaborationPane(binding, candidates), candidates };
1768
+ return { ...selectCollaborationPane(binding, candidates), candidates, layout };
1769
1769
  }
1770
1770
  function routeOwnershipConfirmed(frontendSessionId, binding, panes) {
1771
1771
  const record = globalSessionState.sessions.find(candidate => candidate.sessionId === frontendSessionId);
@@ -2150,6 +2150,11 @@ async function integrationTmuxPresent(record) {
2150
2150
  throw error;
2151
2151
  }
2152
2152
  }
2153
+ function integrationRuntimeRegistered(record) {
2154
+ return !!integrationTerminalRecord(record)
2155
+ || [...terminalSessions.values()].some(s => s.mode === 'tmux' && s.tmuxSessionName === `wt-integration-${record.session_id}`)
2156
+ || !!collaborationStore.getGroup(record.group_id)?.sessionIds.includes(record.session_id);
2157
+ }
2153
2158
  async function verifyIntegrationRuntimeOwnership(record) {
2154
2159
  await integrationSessionAdapter.prepareRelease(record);
2155
2160
  const terminal = integrationTerminalRecord(record);
@@ -2290,14 +2295,22 @@ export const integrationSessionAdapter = {
2290
2295
  resume: getPluginResumeConfig(agent.slug) ?? command, nativeIdentity: integrationNativeIdentity(record) ?? getPluginNativeIdentityConfig(agent.slug) ?? null };
2291
2296
  },
2292
2297
  async runtimePresent(record) {
2293
- if (integrationTerminalRecord(record))
2294
- return true;
2295
- if ([...terminalSessions.values()].some(s => s.mode === 'tmux' && s.tmuxSessionName === `wt-integration-${record.session_id}`))
2296
- return true;
2297
- if (collaborationStore.getGroup(record.group_id)?.sessionIds.includes(record.session_id))
2298
+ if (integrationRuntimeRegistered(record))
2298
2299
  return true;
2299
2300
  return integrationTmuxPresent(record);
2300
2301
  },
2302
+ async runtimePresenceBatch(records) {
2303
+ let names;
2304
+ try {
2305
+ names = new Set((await runTmux(['list-sessions', '-F', '#{session_name}'])).trimEnd().split('\n'));
2306
+ }
2307
+ catch (error) {
2308
+ if (!/no server running|no sessions|error connecting.*No such file/i.test(getErrorMessage(error)))
2309
+ throw error;
2310
+ names = new Set();
2311
+ }
2312
+ return new Map(records.map(record => [record.session_id, integrationRuntimeRegistered(record) || names.has(`wt-integration-${record.session_id}`)]));
2313
+ },
2301
2314
  async release(record, reconcile) {
2302
2315
  await releaseSessionRuntime({
2303
2316
  barrier: action => collaborationDeliveryWorker.reconfigure(record.session_id, action),
@@ -2350,7 +2363,7 @@ export const integrationSessionAdapter = {
2350
2363
  if (inspected.state !== 'ready' || !inspected.pane)
2351
2364
  integrationError('SESSION_IDENTITY_MISMATCH', 'Owned tmux pane cannot be uniquely verified', 409);
2352
2365
  const pane = inspected.pane;
2353
- const layout = await getTmuxLayout(terminal.tmuxSessionName);
2366
+ const layout = inspected.layout;
2354
2367
  if (layout.windows.flatMap(window => window.panes).length !== 1 || layout.activePaneId !== pane.paneId)
2355
2368
  integrationError('SESSION_IDENTITY_MISMATCH', 'Integration native binding requires the single owned pane', 409);
2356
2369
  const backend = terminal.backendSessionId ? terminalSessions.get(terminal.backendSessionId) : null;
@@ -5069,166 +5082,12 @@ async function prepareManagedTmuxSession(sessionName, cwd) {
5069
5082
  await tmuxLifecycle.run(`prepare:${sessionName}`, () => prepareManagedTmuxSessionUnlocked(sessionName, cwd));
5070
5083
  }
5071
5084
  async function getTmuxLayout(sessionName) {
5072
- const sessionInfoRaw = (await runTmux([
5073
- 'display-message',
5074
- '-t',
5075
- sessionName,
5076
- '-p',
5077
- `#{session_id}${TMUX_DELIMITER}#{session_name}${TMUX_DELIMITER}#{window_id}${TMUX_DELIMITER}#{pane_id}${TMUX_DELIMITER}#{pane_in_mode}`,
5078
- ])).trim();
5079
- const sessionInfo = parseDelimitedRow(sessionInfoRaw, 5);
5080
- if (!sessionInfo) {
5081
- throw new Error(`Failed to parse tmux session info: ${sessionInfoRaw}`);
5082
- }
5083
- const [sessionId, resolvedSessionName, activeWindowId, activePaneId, paneInMode] = sessionInfo;
5084
- const windowsRaw = await runTmux([
5085
- 'list-windows',
5086
- '-t',
5087
- sessionName,
5088
- '-F',
5089
- `#{window_id}${TMUX_DELIMITER}#{window_name}${TMUX_DELIMITER}#{window_index}${TMUX_DELIMITER}#{window_active}`,
5090
- ]);
5091
- const windows = [];
5092
- for (const line of windowsRaw.trim().split('\n')) {
5093
- if (!line) {
5094
- continue;
5095
- }
5096
- const row = parseDelimitedRow(line, 4);
5097
- if (!row) {
5098
- continue;
5099
- }
5100
- const [windowId, windowName, windowIndexRaw, windowActiveRaw] = row;
5101
- const panesRaw = await runTmux([
5102
- 'list-panes',
5103
- '-t',
5104
- windowId,
5105
- '-F',
5106
- `#{pane_id}${TMUX_DELIMITER}#{pane_index}${TMUX_DELIMITER}#{pane_active}${TMUX_DELIMITER}#{pane_width}${TMUX_DELIMITER}#{pane_height}${TMUX_DELIMITER}#{pane_top}${TMUX_DELIMITER}#{pane_left}${TMUX_DELIMITER}#{pane_current_command}${TMUX_DELIMITER}#{pane_pid}${TMUX_DELIMITER}#{pane_tty}${TMUX_DELIMITER}#{pane_title}${TMUX_DELIMITER}#{pane_current_path}`,
5107
- ]);
5108
- const panes = panesRaw.trim().split('\n').filter(Boolean).map((paneLine) => {
5109
- const paneRow = parseDelimitedRow(paneLine, 12);
5110
- if (!paneRow) {
5111
- return null;
5112
- }
5113
- const [paneId, paneIndexRaw, paneActiveRaw, widthRaw, heightRaw, topRaw, leftRaw, command, pidRaw, tty, title, currentPath] = paneRow;
5114
- return {
5115
- id: paneId,
5116
- index: parseInt(paneIndexRaw || '0', 10),
5117
- active: paneActiveRaw === '1',
5118
- width: parseInt(widthRaw || '0', 10),
5119
- height: parseInt(heightRaw || '0', 10),
5120
- top: parseInt(topRaw || '0', 10),
5121
- left: parseInt(leftRaw || '0', 10),
5122
- command: command || '',
5123
- pid: parseInt(pidRaw || '0', 10) || 0,
5124
- tty: tty || '',
5125
- title: title || '',
5126
- currentPath: currentPath || '',
5127
- };
5128
- }).filter((pane) => pane !== null);
5129
- windows.push({
5130
- id: windowId,
5131
- name: windowName || '',
5132
- index: parseInt(windowIndexRaw || '0', 10),
5133
- active: windowActiveRaw === '1',
5134
- panes,
5135
- });
5136
- }
5137
- return {
5138
- sessionId,
5139
- sessionName: resolvedSessionName,
5140
- windows,
5141
- activeWindowId,
5142
- activePaneId,
5143
- inCopyMode: paneInMode === '1',
5144
- };
5085
+ return readTmuxLayout(runTmux, sessionName);
5145
5086
  }
5146
5087
  let tmuxLayoutsSnapshot = null;
5147
5088
  let tmuxLayoutsSnapshotPromise = null;
5148
5089
  async function buildTmuxLayoutsSnapshot() {
5149
- const [sessionsRaw, windowsRaw, panesRaw] = await Promise.all([
5150
- runTmux([
5151
- 'list-sessions',
5152
- '-F',
5153
- `#{session_id}${TMUX_DELIMITER}#{session_name}${TMUX_DELIMITER}#{window_id}${TMUX_DELIMITER}#{pane_id}${TMUX_DELIMITER}#{pane_in_mode}`,
5154
- ]),
5155
- runTmux([
5156
- 'list-windows',
5157
- '-a',
5158
- '-F',
5159
- `#{session_name}${TMUX_DELIMITER}#{window_id}${TMUX_DELIMITER}#{window_name}${TMUX_DELIMITER}#{window_index}${TMUX_DELIMITER}#{window_active}`,
5160
- ]),
5161
- runTmux([
5162
- 'list-panes',
5163
- '-a',
5164
- '-F',
5165
- `#{session_name}${TMUX_DELIMITER}#{window_id}${TMUX_DELIMITER}#{pane_id}${TMUX_DELIMITER}#{pane_index}${TMUX_DELIMITER}#{pane_active}${TMUX_DELIMITER}#{pane_width}${TMUX_DELIMITER}#{pane_height}${TMUX_DELIMITER}#{pane_top}${TMUX_DELIMITER}#{pane_left}${TMUX_DELIMITER}#{pane_current_command}${TMUX_DELIMITER}#{pane_pid}${TMUX_DELIMITER}#{pane_tty}${TMUX_DELIMITER}#{pane_title}${TMUX_DELIMITER}#{pane_current_path}`,
5166
- ]),
5167
- ]);
5168
- const layouts = new Map();
5169
- for (const line of sessionsRaw.trim().split('\n')) {
5170
- if (!line)
5171
- continue;
5172
- const row = parseDelimitedRow(line, 5);
5173
- if (!row)
5174
- continue;
5175
- const [sessionId, sessionName, activeWindowId, activePaneId, paneInMode] = row;
5176
- layouts.set(sessionName, {
5177
- sessionId,
5178
- sessionName,
5179
- windows: [],
5180
- activeWindowId,
5181
- activePaneId,
5182
- inCopyMode: paneInMode === '1',
5183
- });
5184
- }
5185
- const windowsById = new Map();
5186
- for (const line of windowsRaw.trim().split('\n')) {
5187
- if (!line)
5188
- continue;
5189
- const row = parseDelimitedRow(line, 5);
5190
- if (!row)
5191
- continue;
5192
- const [sessionName, windowId, windowName, windowIndexRaw, windowActiveRaw] = row;
5193
- const layout = layouts.get(sessionName);
5194
- if (!layout)
5195
- continue;
5196
- const window = {
5197
- id: windowId,
5198
- name: windowName || '',
5199
- index: parseInt(windowIndexRaw || '0', 10),
5200
- active: windowActiveRaw === '1',
5201
- panes: [],
5202
- };
5203
- layout.windows.push(window);
5204
- windowsById.set(`${sessionName}\0${windowId}`, window);
5205
- }
5206
- for (const line of panesRaw.trim().split('\n')) {
5207
- if (!line)
5208
- continue;
5209
- const row = parseDelimitedRow(line, 14);
5210
- if (!row)
5211
- continue;
5212
- const [sessionName, windowId, paneId, paneIndexRaw, paneActiveRaw, widthRaw, heightRaw, topRaw, leftRaw, command, pidRaw, tty, title, currentPath] = row;
5213
- const window = windowsById.get(`${sessionName}\0${windowId}`);
5214
- if (!window)
5215
- continue;
5216
- window.panes.push({
5217
- id: paneId,
5218
- index: parseInt(paneIndexRaw || '0', 10),
5219
- active: paneActiveRaw === '1',
5220
- width: parseInt(widthRaw || '0', 10),
5221
- height: parseInt(heightRaw || '0', 10),
5222
- top: parseInt(topRaw || '0', 10),
5223
- left: parseInt(leftRaw || '0', 10),
5224
- command: command || '',
5225
- pid: parseInt(pidRaw || '0', 10) || 0,
5226
- tty: tty || '',
5227
- title: title || '',
5228
- currentPath: currentPath || '',
5229
- });
5230
- }
5231
- return layouts;
5090
+ return readTmuxLayouts(runTmux);
5232
5091
  }
5233
5092
  async function getCachedTmuxLayout(sessionName) {
5234
5093
  if (tmuxLayoutsSnapshot && Date.now() - tmuxLayoutsSnapshot.fetchedAt < TMUX_LAYOUT_CACHE_TTL_MS) {
@@ -0,0 +1,74 @@
1
+ const separator = '\x1f';
2
+ export const TMUX_LAYOUT_FORMAT = [
3
+ 'pid', 'session_id', 'session_name', 'window_id', 'window_name', 'window_index', 'window_active',
4
+ 'pane_id', 'pane_index', 'pane_active', 'pane_width', 'pane_height', 'pane_top', 'pane_left',
5
+ 'pane_current_command', 'pane_pid', 'pane_tty', 'pane_title', 'pane_in_mode', 'pane_current_path',
6
+ ].map(field => `#{${field}}`).join(separator);
7
+ export function parseTmuxLayout(stdout, requestedName) {
8
+ let layout;
9
+ const windows = new Map(), ids = new Set();
10
+ for (const line of stdout.trimEnd().split('\n')) {
11
+ if (!line)
12
+ continue;
13
+ const columns = line.replaceAll('\\037', separator).replaceAll('\\x1f', separator).split(separator);
14
+ if (columns.length < 20)
15
+ throw new Error('TMUX_LAYOUT_UNAVAILABLE');
16
+ const [server, sessionId, sessionName, windowId, name, windowIndex, windowActive, id, index, active, width, height, top, left, command, pid, tty, title, inMode] = columns;
17
+ const serverPid = Number(server);
18
+ if (!Number.isSafeInteger(serverPid) || serverPid <= 0 || sessionName !== requestedName || !sessionId || !windowId || !id
19
+ || ids.has(id) || layout && (layout.serverPid !== serverPid || layout.sessionId !== sessionId))
20
+ throw new Error('TMUX_SESSION_IDENTITY_CHANGED');
21
+ ids.add(id);
22
+ layout ??= { serverPid, sessionId, sessionName, windows: [], activeWindowId: '', activePaneId: '', inCopyMode: false };
23
+ let window = windows.get(windowId);
24
+ if (!window) {
25
+ window = { id: windowId, name, index: Number(windowIndex), active: windowActive === '1', panes: [] };
26
+ windows.set(windowId, window);
27
+ layout.windows.push(window);
28
+ }
29
+ else if (window.name !== name || window.index !== Number(windowIndex) || window.active !== (windowActive === '1')) {
30
+ throw new Error('TMUX_SESSION_IDENTITY_CHANGED');
31
+ }
32
+ const pane = { id, index: Number(index), active: active === '1', width: Number(width), height: Number(height),
33
+ top: Number(top), left: Number(left), command, pid: Number(pid), tty, title, currentPath: columns.slice(19).join(separator) };
34
+ if (![window.index, pane.index, pane.width, pane.height, pane.top, pane.left, pane.pid].every(Number.isSafeInteger)
35
+ || pane.pid <= 0)
36
+ throw new Error('TMUX_LAYOUT_UNAVAILABLE');
37
+ window.panes.push(pane);
38
+ if (window.active && pane.active) {
39
+ if (layout.activePaneId)
40
+ throw new Error('TMUX_SESSION_IDENTITY_CHANGED');
41
+ layout.activeWindowId = window.id;
42
+ layout.activePaneId = pane.id;
43
+ layout.inCopyMode = inMode === '1';
44
+ }
45
+ }
46
+ if (!layout || !layout.activePaneId)
47
+ throw new Error('TMUX_LAYOUT_UNAVAILABLE');
48
+ layout.windows.sort((a, b) => a.index - b.index);
49
+ for (const window of layout.windows)
50
+ window.panes.sort((a, b) => a.index - b.index);
51
+ return layout;
52
+ }
53
+ export async function readTmuxLayout(run, sessionName) {
54
+ return parseTmuxLayout(await run(['list-panes', '-s', '-t', `=${sessionName}:`, '-F', TMUX_LAYOUT_FORMAT]), sessionName);
55
+ }
56
+ export async function readTmuxLayouts(run) {
57
+ const rows = new Map();
58
+ for (const row of (await run(['list-panes', '-a', '-F', TMUX_LAYOUT_FORMAT])).trimEnd().split('\n')) {
59
+ const name = row.replaceAll('\\037', separator).replaceAll('\\x1f', separator).split(separator)[2];
60
+ if (!name)
61
+ continue;
62
+ const lines = rows.get(name) ?? [];
63
+ lines.push(row);
64
+ rows.set(name, lines);
65
+ }
66
+ const layouts = new Map();
67
+ for (const [name, lines] of rows) {
68
+ try {
69
+ layouts.set(name, parseTmuxLayout(lines.join('\n'), name));
70
+ }
71
+ catch { /* UI discovery can fall back to an exact fresh read of that session. */ }
72
+ }
73
+ return layouts;
74
+ }
@@ -1383,3 +1383,31 @@ wrapper 负责实际设置 Agent 自己的数据目录、准备 hooks/参数,
1383
1383
  用 stdin 文件给启动命令附带业务 prompt 是接入方的 bootstrap:TD 不为其中业务副作用提供消息幂等或执行确认;无 TD message 历史也不等于没有业务执行。冷启动与精确 resume 应分开处理,恢复时不得重放初始业务文件。host 收取 final/hook receipt 是 host 侧证据,不伪造成 TD 明确 reply。仍只粘贴一次、等待 50ms、写一次 CR。
1384
1384
 
1385
1385
  回归包含确定性交错的旧读盘覆盖/串行写入/删除不复活、同 pane shell 初始化等待与超时/换 pane/已有 Agent 拒绝、CLI 对旧服务拒绝,以及 Linux 真实 tmux/内核 flock 的独立目录核验。私有 TraeX/DevBox 的启动兼容仍需接入方维护窗实机验证;维护机合成测试不冒充该项通过。
1386
+
1387
+ ## 1.4.347:无变化同步与后台观察的开销
1388
+
1389
+ 这是服务运行时优化,不增加接口、权限或配置。需要更新实际运行服务到 **1.4.347**;仅安装新 CLI 不会更新已运行进程。
1390
+
1391
+ - 跨服务目录重复合并相同群内容时不写盘、不通知事件同步。比较包含内容而非仅时间戳;同时间戳的真实变化仍处理。合并独立群规/角色版本后如有效内容未变也不写盘;有效群更新与保留的新上下文一次持久化。
1392
+ - 空事件追加及 source/version 全重复的追加在克隆事件历史之前返回。真实更新继续持久提交,失败保留可重试源版本,不改变事件 ID、顺序、ACK 或保留策略。
1393
+ - 事件同步按消息、任务、会话三个变化来源分别扫描;会话通知不再复制消息历史和生成全部消息回执。新授权组及服务重启仍对保留事实完整对账;失败来源继续重试。消息变化时一次建立回复索引,避免每条回执重扫历史;回执仍核验双方身份,并省去事件中不包含的终端快照格式化。这是按来源增量同步,真实变化仍会扫描该来源的保留记录。
1394
+ - 终端输出触发的后台会话观察按 250ms 窗口合并;一轮观察期间的新通知安排到后续一轮,不连续抢占。显式 `session get`、生命周期操作及投递前核验保留原路径,不以此窗口判断 Agent 可输入;不延迟业务输入来猜测应用状态。同一次 integration 观察复用其新鲜 tmux layout,下一次观察重新读取,不把缓存用作身份写入凭据。
1395
+
1396
+ 无删除消息/事件、缩短历史、停用消费者、额外 Enter 或正文重发。`pty_written` 仍只代表终端写入调用完成,不代表应用提交或任务完成。回归包含约 26 MB journal 的空/重复追加零克隆、相同群同步零写入、真实变化/持久化失败重试、授权后历史补收、通知洪峰合并、换 pane/原生身份冲突拒绝。生产 CPU、事件循环延迟和 health 改善须在实际维护窗升级后对比采样,不能用隔离基准代替实机验收。
1397
+
1398
+ ## 1.4.348:真实提交、事件推送与 tmux 查询
1399
+
1400
+ 本轮透明性能优化不改变现有接口、JSON journal 格式、保留策略、权限、消息/ACK 语义或默认交互。实际运行服务需升级到 **1.4.348**;不是仅替换 CLI 文件。性能修复无新增权限或配置。
1401
+
1402
+ - 真实事件/ACK/授权变更采用 copy-on-write,仅复制发生变化的容器与新事件,不再深拷贝整个 journal。内部已提交事实由 store 独占且不修改,公共 page/policy 返回独立副本;调用者修改输入或返回值不影响历史或编码缓存。
1403
+ - 未变化事件与元数据复用 UTF-8 编码,按有界向量写入临时文件,不再为大小检查和落盘重复生成全库 JSON,也避免临时巨型 UTF-16 字符串。64 MiB 上限检查覆盖实际待写全部字节。短写、Unicode 字节边界与 EINTR 均保留位置;文件 fsync → 原子 rename → 目录 fsync 后才发布提交结果。中途失败不推进内存 source/consumer 状态,不向订阅者报告成功;重试仍沿原来源/游标。
1404
+ - 同 cursor 重复 ACK 在权限、consumer、cursor、rewind、retention 检查后 no-op;HTTP 的已发送连续前缀检查仍先执行。此前已持久确认的进度不再次克隆/写盘/fsync,不能借相等游标绕过撤权或保留缺口。重复 revoke 也不重写未变化状态。
1405
+ - 事件按组建引用索引,并按 sequence 二分定位 page;仅公开 page 复制最多 100 条返回事实。主动推送复用已编码事实,不为每个 consumer 重复制/重编码历史 payload,保留原 NDJSON 字段、顺序、背压、授权复核及断线重放边界。
1406
+ - 新鲜 session layout 由一次只读 `list-panes -s` 获取 server/session/window/pane 元数据;常见单窗口的 integration 观察从 5 次 tmux 命令降到 1 次,另行原生进程/锁核验保持。侧栏原有多会话 layout 缓存的数据获取也由 3 次全局查询改为 1 次;缓存仍只服务原有发现用途。没有启用持久 attach/control client,不新增 client、改尺寸、焦点、copy-mode 或发送输入。
1407
+ - 同轮已关闭/释放且已确认零承载的后台存在性观察用一次新鲜会话名查询合并。结果仅用于该轮、且 descriptor 完整快照不变时才复用;批量查询失败、记录改变、操作不确定或进行中的生命周期走原逐项核验。显式 get、close/release/restore、实际投递前身份核验不使用该后台结果。
1408
+
1409
+ 仍是兼容的全量 JSON 快照落盘:真实提交的物理写入量和同步 fsync 等待未变为增量日志或异步存储,慢磁盘仍可能阻塞。不能把隔离基准解释为所有 HTTPS 长尾已解决。服务重启会重建内存索引/编码缓存,首次提交需编码保留事实;不会删除历史换取速度,也不会暂停业务消费者。
1410
+
1411
+ 回归覆盖重复 ACK 零克隆/编码/写入、真实持久提交与中途写入/fsync 失败后重试、短写与 Unicode、64 MiB 精确字节上限、输入/输出不可修改历史、分页/保留缺口/撤权、推送事实等价,以及隔离真实 tmux 的多窗口/分屏/激活 pane/copy-mode 与 client/PID/尺寸不变。Linux 维护机验证不代替 macOS 实机或私有 Agent 的业务验收。生产升级须与共享服务的在途任务和维护窗协调,不能因为收到版本通知就自动重启别人的实验。
1412
+
1413
+ 同时修复独立 profile 目录的 home 别名误拒:当逻辑 home 指向真实 home,规范化 cwd 下的 `{cwd}` 路径可匹配逻辑 home 或 `realpath(home)` 的严格子目录;不会将同名前缀兄弟目录视为 home,不创建尚不存在的锁目录。实际 provider 继续核验 canonicalHome、目录权限、精确进程实例、FD/inode 和内核持锁关系。Linux capabilities 新增 `session_profile_native_identity_home_aliases=true` 及 `session_profile_native_identity_home_aliases_min_version="1.4.348"`。旧 profile 快照不迁移,不自动 retry/create;347 的 `~/` 每 job 精确路径声明仍可沿原契约使用,更新同一 profile 与捕获快照需要由接入方串行协调。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "termdock",
3
- "version": "1.4.346",
3
+ "version": "1.4.348",
4
4
  "description": "A complete web-based terminal application with modern UI",
5
5
  "type": "module",
6
6
  "main": ".desktop-dist/main.js",
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
3
  "packageName": "termdock",
4
- "version": "1.4.346",
4
+ "version": "1.4.348",
5
5
  "runtimeProtocolVersion": 1,
6
6
  "minimumDesktopVersion": "1.4.46",
7
7
  "nodeMajor": 22,
8
8
  "dependencyHash": "sha256-ObG1OGq7e7fMFQmYoPxPp3jRodEusYHulVNa5mT1aW8=",
9
- "serverBundleHash": "sha256-14J5QvRzetON18qbhx5HXdLAx08WrjndjMlOCMpgfOc=",
9
+ "serverBundleHash": "sha256-Jai7Kpw3RvG8IR/qVeAVGADGQlxRldYgpIR5+wLY9so=",
10
10
  "clientBundleHash": "sha256-kn3Tg+BTj+H4rZe0g9OE5JUEQNsBGiUSGew8Epm5A38=",
11
11
  "entrypoint": "dist/server/cli.js",
12
12
  "clientEntrypoint": "dist/client/index.html"