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.
- package/dist/server/agent/collaborationStore.js +40 -12
- package/dist/server/agent/integrationServer.js +61 -34
- package/dist/server/agent/integrationSessions.js +54 -11
- package/dist/server/agent/integrationStore.js +130 -24
- package/dist/server/agent/nativeProcessIdentity.js +17 -2
- package/dist/server/routes/terminal.js +26 -167
- package/dist/server/utils/tmuxLayoutReader.js +74 -0
- package/docs/integrations.md +28 -0
- package/package.json +1 -1
- package/runtime-manifest.json +2 -2
|
@@ -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
|
-
|
|
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
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
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
|
-
|
|
865
|
-
|
|
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 = () =>
|
|
61
|
-
this.
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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(
|
|
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(() =>
|
|
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
|
|
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
|
|
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
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
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() {
|
|
990
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
|
136
|
-
|
|
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
|
-
|
|
156
|
-
|
|
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 (!
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
221
|
-
|
|
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),
|
|
15
|
-
|
|
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
|
-
|
|
1728
|
+
let layout;
|
|
1728
1729
|
try {
|
|
1729
|
-
await
|
|
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(
|
|
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 (
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/docs/integrations.md
CHANGED
|
@@ -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
package/runtime-manifest.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
3
|
"packageName": "termdock",
|
|
4
|
-
"version": "1.4.
|
|
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-
|
|
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"
|