termdock 1.4.346 → 1.4.347

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) => {
@@ -461,11 +466,25 @@ export class IntegrationRuntime {
461
466
  if (this.syncing)
462
467
  return;
463
468
  this.syncing = true;
469
+ let sources = new Set();
464
470
  try {
465
471
  const groups = new Set(this.store.policies().filter(p => !p.revoked).map(p => p.groupId)), updates = [];
466
- if (!groups.size)
472
+ const groupVersion = JSON.stringify([...groups].sort());
473
+ // Newly granted groups need a complete source reconciliation, including
474
+ // retained facts that predate the grant. Never advance dirty state until
475
+ // the durable event append succeeds.
476
+ if (groupVersion !== this.syncedGroups)
477
+ for (const source of ['messages', 'tasks', 'sessions'])
478
+ this.dirtySources.add(source);
479
+ sources = this.dirtySources;
480
+ this.dirtySources = new Set();
481
+ if (!sources.size)
482
+ return;
483
+ if (!groups.size) {
484
+ this.syncedGroups = groupVersion;
467
485
  return;
468
- for (const task of this.options.taskStore.snapshot())
486
+ }
487
+ for (const task of sources.has('tasks') ? this.options.taskStore.snapshot() : [])
469
488
  if (groups.has(task.groupId)) {
470
489
  for (const event of task.events)
471
490
  updates.push({ sourceKey: `task:${task.id}:${event.id}`, sourceVersion: '1', eventId: event.id, event: {
@@ -485,31 +504,30 @@ export class IntegrationRuntime {
485
504
  task_id: task.id, attempt_id: delivery.attemptId, message_id: delivery.messageId, source: 'system', payload: { delivery }
486
505
  } });
487
506
  }
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())
507
+ for (const { message, receipt } of sources.has('messages') ? this.options.messages.snapshotMessagesWithReceipts(groups) : []) {
508
+ const link = message.metadata?.termdockTask;
509
+ const context = { group_id: message.groupId, task_id: link?.taskId ?? null, attempt_id: link?.attemptId ?? null,
510
+ reply_to_event_id: link?.replyToEventId ?? null, message_id: message.id, source: message.integrationOrigin ? 'integration' : message.fromSessionId ? 'member' : 'system',
511
+ actor: message.integrationOrigin ? { integration_id: message.integrationOrigin.integrationId } : message.fromSessionId ? { session_id: message.fromSessionId } : null,
512
+ external_actor: message.integrationOrigin?.externalActor ?? null };
513
+ updates.push({ sourceKey: `message:${message.id}`, sourceVersion: '1', event: { ...context,
514
+ kind: message.kind === 'reply' ? 'message.reply' : 'message.queued', created_at: message.createdAt,
515
+ payload: { message_id: message.id, thread_id: message.threadId, reply_to: message.replyTo ?? null,
516
+ from_session_id: message.fromSessionId, to_session_id: message.toPrincipalId ? null : message.toSessionId,
517
+ to_principal_id: message.toPrincipalId ?? null, integration_origin: message.integrationOrigin ?? null,
518
+ response_kind: message.responseKind ?? null, stored_at: message.storedAt ?? null,
519
+ content: message.content, metadata: message.metadata ?? null } } });
520
+ updates.push({ sourceKey: `message-status:${message.id}`, sourceVersion: JSON.stringify(receipt), event: {
521
+ ...context, kind: 'message.delivery', created_at: message.deliveredAt ?? message.createdAt, payload: { receipt }
522
+ } });
523
+ }
524
+ const sessions = sources.has('sessions') ? this.sessions.snapshot() : [];
525
+ for (const session of sessions)
508
526
  if (groups.has(session.group_id))
509
527
  updates.push({ sourceKey: `session:${session.session_id}`, sourceVersion: JSON.stringify(publicIntegrationSession(session)), event: {
510
528
  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
529
  } });
512
- for (const session of this.sessions.snapshot())
530
+ for (const session of sessions)
513
531
  if (groups.has(session.group_id) && session.name_updated_at)
514
532
  updates.push({
515
533
  sourceKey: `session-name:${session.session_id}`, sourceVersion: JSON.stringify([session.name, session.name_updated_at, session.name_generation]), event: {
@@ -519,9 +537,17 @@ export class IntegrationRuntime {
519
537
  });
520
538
  updates.sort((a, b) => a.event.created_at - b.event.created_at);
521
539
  this.store.append(updates);
540
+ this.syncedGroups = groupVersion;
541
+ }
542
+ catch (error) {
543
+ for (const source of sources)
544
+ this.dirtySources.add(source);
545
+ throw error;
522
546
  }
523
547
  finally {
524
548
  this.syncing = false;
549
+ if (this.dirtySources.size)
550
+ this.scheduleRetry();
525
551
  }
526
552
  }
527
553
  failStreams(error) {
@@ -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] : [])])) {
@@ -974,18 +991,29 @@ export class IntegrationSessions {
974
991
  this.observeAgain = true;
975
992
  return;
976
993
  }
994
+ if (this.observationTimer) {
995
+ clearTimeout(this.observationTimer);
996
+ this.observationTimer = undefined;
997
+ }
977
998
  this.observing = true;
999
+ this.observeAgain = false;
978
1000
  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);
1001
+ for (const record of this.snapshot())
1002
+ await this.refresh(record.session_id);
984
1003
  }
985
1004
  finally {
986
1005
  this.observing = false;
1006
+ if (this.observeAgain)
1007
+ this.scheduleObservation();
987
1008
  }
988
1009
  }
989
- close() { this.stopObservation(); this.stopDeliveryGuard?.(); for (const timer of this.timers)
990
- clearTimeout(timer); }
1010
+ close() {
1011
+ this.stopped = true;
1012
+ this.stopObservation();
1013
+ this.stopDeliveryGuard?.();
1014
+ if (this.observationTimer)
1015
+ clearTimeout(this.observationTimer);
1016
+ for (const timer of this.timers)
1017
+ clearTimeout(timer);
1018
+ }
991
1019
  }
@@ -152,18 +152,19 @@ export class IntegrationStore {
152
152
  integrationError('INTEGRATION_PERMISSION_DENIED', 'Operation is outside the integration grant', 403);
153
153
  }
154
154
  append(updates) {
155
- const next = structuredClone(this.doc);
156
- let changed = false;
155
+ // Most reconciliations repeat already-journaled facts. Do not copy the
156
+ // retained event history until the first actual source-version change.
157
+ let next;
157
158
  for (const update of updates) {
158
- if (next.sources[update.sourceKey] === update.sourceVersion)
159
+ if ((next ?? this.doc).sources[update.sourceKey] === update.sourceVersion)
159
160
  continue;
161
+ next ??= structuredClone(this.doc);
160
162
  const group = update.event.group_id, sequence = (next.sequences[group] ?? 0) + 1;
161
163
  next.events.push({ ...update.event, event_id: update.eventId ?? randomUUID(), sequence, cursor: this.cursor(group, sequence) });
162
164
  next.sequences[group] = sequence;
163
165
  next.sources[update.sourceKey] = update.sourceVersion;
164
- changed = true;
165
166
  }
166
- if (!changed)
167
+ if (!next)
167
168
  return;
168
169
  const counts = new Map();
169
170
  next.events = next.events.reverse().filter(event => {
@@ -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);
@@ -2350,7 +2350,7 @@ export const integrationSessionAdapter = {
2350
2350
  if (inspected.state !== 'ready' || !inspected.pane)
2351
2351
  integrationError('SESSION_IDENTITY_MISMATCH', 'Owned tmux pane cannot be uniquely verified', 409);
2352
2352
  const pane = inspected.pane;
2353
- const layout = await getTmuxLayout(terminal.tmuxSessionName);
2353
+ const layout = inspected.layout;
2354
2354
  if (layout.windows.flatMap(window => window.panes).length !== 1 || layout.activePaneId !== pane.paneId)
2355
2355
  integrationError('SESSION_IDENTITY_MISMATCH', 'Integration native binding requires the single owned pane', 409);
2356
2356
  const backend = terminal.backendSessionId ? terminalSessions.get(terminal.backendSessionId) : null;
@@ -1383,3 +1383,14 @@ 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 改善须在实际维护窗升级后对比采样,不能用隔离基准代替实机验收。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "termdock",
3
- "version": "1.4.346",
3
+ "version": "1.4.347",
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.347",
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-FXUdT0eV3/+rgHBnc0Jg9dn2sAb4hcLY83xScGapcKQ=",
10
10
  "clientBundleHash": "sha256-kn3Tg+BTj+H4rZe0g9OE5JUEQNsBGiUSGew8Epm5A38=",
11
11
  "entrypoint": "dist/server/cli.js",
12
12
  "clientEntrypoint": "dist/client/index.html"