termdock 1.4.347 → 1.4.349
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/collaborationCli.js +2 -0
- package/dist/server/agent/integrationCli.js +20 -0
- package/dist/server/agent/integrationCloseOwnership.js +18 -0
- package/dist/server/agent/integrationServer.js +7 -3
- package/dist/server/agent/integrationSessions.js +34 -5
- package/dist/server/agent/integrationStore.js +125 -20
- package/dist/server/agent/nativeProcessIdentity.js +17 -2
- package/dist/server/routes/terminal.js +82 -174
- package/dist/server/utils/tmuxLayoutReader.js +74 -0
- package/docs/integrations.md +77 -0
- package/package.json +1 -1
- package/runtime-manifest.json +2 -2
|
@@ -95,6 +95,8 @@ export const COLLAB_HELP = `td collab — durable messages; no agent-specific ho
|
|
|
95
95
|
generation递增;原launch operation_id保持。rename_pending=true时用get对账显示同步。
|
|
96
96
|
session close <id> --generation <N> --idempotency-key <key>
|
|
97
97
|
权限 session.close;显式终止精确自有Agent/原失败空shell,移除活动承载;保留历史与诊断。
|
|
98
|
+
1.4.349 缺活动终端登记时,Linux 仅凭唯一持久归属、原单pane及实时writer-lock身份核验关闭;
|
|
99
|
+
管理员用 td integration close-diagnostics <id> 只读核验,不按名称/argv/旧PID强制关闭。
|
|
98
100
|
closed终态拒绝send/reply/restore/retry-cold;release才是保留精确恢复的入口。
|
|
99
101
|
有pending/进行中操作/身份不明确时拒绝;中断只对账不自动再终止,close_pending=true表示结果未确认。
|
|
100
102
|
rename/close最低CLI与实际服务1.4.315;session_rename/session_close=true;两个权限默认不授权。
|
|
@@ -66,6 +66,17 @@ export const INTEGRATION_HELP = `td integration — local scoped integration adm
|
|
|
66
66
|
Minimum CLI/running service 1.4.309; capabilities.session_resume_configuration=true.
|
|
67
67
|
Errors: RESUME_CONFIGURATION_CHANGED, EXACT_RESUME_UNSUPPORTED,
|
|
68
68
|
SESSION_GENERATION_MISMATCH, SESSION_OPERATION_IN_PROGRESS, LAUNCHER_UNAVAILABLE.
|
|
69
|
+
close-diagnostics <TD-session-id> (fresh read-only close target; administrator only)
|
|
70
|
+
Minimum CLI/running service 1.4.349; session_close_diagnostics=true.
|
|
71
|
+
Returns {session_id,operation_id,generation,close_diagnostics:{checked_at,
|
|
72
|
+
terminal_registration_present,binding_present,runtime_present,pending_messages,
|
|
73
|
+
target_confirmed,code,target}}. target has exact pane/native/process instance facts,
|
|
74
|
+
never argv/environment/output. Does not change records, routing or generation,
|
|
75
|
+
close/restore a session, send keys or grant permission for a later close.
|
|
76
|
+
Linux session_close_detached_registration=true supports existing session.close
|
|
77
|
+
when active terminal inventory is missing: unique durable local ownership,
|
|
78
|
+
original single pane and live writer-lock native identity are still required.
|
|
79
|
+
No forced close by tmux name, argv UUID, stale PID or shell-only observation.
|
|
69
80
|
diagnostics <TD-session-id> (read stored launch/restore diagnostics; administrator only)
|
|
70
81
|
1.4.314 adds phase/timestamps, paste/submit write facts and target-change
|
|
71
82
|
evidence; no argv/environment/credentials or application readiness claim.
|
|
@@ -226,6 +237,15 @@ export async function runIntegrationAdmin(argv, port, adminToken, write) {
|
|
|
226
237
|
write(JSON.stringify(parsed(await request(socketPath, body ? 'POST' : 'GET', `/admin/sessions/${encodeURIComponent(id)}/resume-configuration`, body, headers(undefined, adminToken)))));
|
|
227
238
|
return 0;
|
|
228
239
|
}
|
|
240
|
+
if (action === 'close-diagnostics') {
|
|
241
|
+
if (argv.length !== 2 || !/^[a-zA-Z0-9._-]{1,80}$/.test(argv[1]))
|
|
242
|
+
throw new Error('close-diagnostics requires one full TD session id');
|
|
243
|
+
const capabilities = parsed(await request(socketPath, 'GET', '/admin/capabilities', undefined, headers(undefined, adminToken)));
|
|
244
|
+
if (capabilities.session_close_diagnostics !== true)
|
|
245
|
+
throw new CollaborationError('SESSION_CLOSE_DIAGNOSTICS_UNSUPPORTED', 'CLI and running service must support live close diagnostics (minimum 1.4.349)', 409);
|
|
246
|
+
write(JSON.stringify(parsed(await request(socketPath, 'GET', `/admin/sessions/${encodeURIComponent(argv[1])}/close-diagnostics`, undefined, headers(undefined, adminToken)))));
|
|
247
|
+
return 0;
|
|
248
|
+
}
|
|
229
249
|
if (action === 'diagnostics') {
|
|
230
250
|
if (argv.length !== 2 || !/^[a-zA-Z0-9._-]{1,80}$/.test(argv[1]))
|
|
231
251
|
throw new Error('diagnostics requires one TD session id');
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { integrationError } from './integrationStore.js';
|
|
2
|
+
/** Missing UI inventory is not authority to kill a named tmux session. Require
|
|
3
|
+
* independent durable ownership first; live pane/native/process proof follows.
|
|
4
|
+
* No terminal registration, route or group membership is created here. */
|
|
5
|
+
export function assertDetachedCloseOwnership(e) {
|
|
6
|
+
if (!e.localGroup || !e.sessionId || !e.groupId || !e.principalId
|
|
7
|
+
|| e.descriptorOwners.length !== 1 || e.descriptorOwners[0]?.sessionId !== e.sessionId
|
|
8
|
+
|| e.descriptorOwners[0]?.groupId !== e.groupId || e.descriptorOwners[0]?.principalId !== e.principalId
|
|
9
|
+
|| e.principalOwners.length !== 1 || e.principalOwners[0]?.principalId !== e.principalId
|
|
10
|
+
|| e.principalOwners[0]?.groupId !== e.groupId || e.principalOwners[0]?.revoked
|
|
11
|
+
|| e.groupOwners.length !== 1 || e.groupOwners[0] !== e.groupId) {
|
|
12
|
+
integrationError('SESSION_CLOSE_OWNERSHIP_UNCONFIRMED', 'Missing terminal registration requires unique durable local session, principal and group ownership', 409);
|
|
13
|
+
}
|
|
14
|
+
if (e.terminalClaims || e.backendClaims.length > 1 || e.otherPaneOwner
|
|
15
|
+
|| e.backendClaims.some(b => b.foreignTerminal || b.routeOwner !== null && b.routeOwner !== e.sessionId)) {
|
|
16
|
+
integrationError('SESSION_IDENTITY_MISMATCH', 'Another terminal, backend or route claims the close target', 409);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -131,6 +131,7 @@ export class IntegrationRuntime {
|
|
|
131
131
|
}));
|
|
132
132
|
app.get('/admin/capabilities', run(req => { admin(req); return this.capabilities(); }));
|
|
133
133
|
app.get('/admin/sessions/:id/restore-diagnostics', run(req => { admin(req); return { ...this.sessions.restoreDiagnostics(req.params.id), ...this.sessions.launchDiagnostics(req.params.id) }; }));
|
|
134
|
+
app.get('/admin/sessions/:id/close-diagnostics', run(async (req) => { admin(req); return this.sessions.closeDiagnostics(req.params.id); }));
|
|
134
135
|
app.get('/admin/sessions/:id/resume-configuration', run(async (req) => { admin(req); return this.sessions.resumeConfiguration(req.params.id); }));
|
|
135
136
|
app.post('/admin/sessions/:id/resume-configuration', run(async (req) => { admin(req); return { session: publicIntegrationSession(await this.sessions.confirmResumeConfiguration(req.params.id, req.body?.generation, req.body?.fingerprint)) }; }));
|
|
136
137
|
app.post('/admin/sessions/:id/startup-input/clear', run(async (req) => {
|
|
@@ -307,10 +308,13 @@ export class IntegrationRuntime {
|
|
|
307
308
|
session_launch_reload_fence: true, session_launch_reload_fence_min_version: '1.4.346',
|
|
308
309
|
session_shell_startup_wait: true, session_shell_startup_wait_min_version: '1.4.346',
|
|
309
310
|
session_profile_native_identity: process.platform === 'linux', session_profile_native_identity_min_version: '1.4.346',
|
|
311
|
+
session_profile_native_identity_home_aliases: process.platform === 'linux', session_profile_native_identity_home_aliases_min_version: '1.4.348',
|
|
310
312
|
session_launch_diagnostics: true, session_launch_diagnostics_min_version: '1.4.314',
|
|
311
313
|
session_cold_retry: true, session_cold_retry_min_version: '1.4.314',
|
|
312
314
|
session_rename: true, session_rename_min_version: '1.4.315',
|
|
313
315
|
session_close: true, session_close_min_version: '1.4.315',
|
|
316
|
+
session_close_diagnostics: true, session_close_diagnostics_min_version: '1.4.349',
|
|
317
|
+
session_close_detached_registration: process.platform === 'linux', session_close_detached_registration_min_version: '1.4.349',
|
|
314
318
|
admin_local_group_create: true, admin_local_group_create_min_version: '1.4.318',
|
|
315
319
|
admin_local_group_rename: true, admin_local_group_rename_min_version: '1.4.319',
|
|
316
320
|
admin_local_group_archive: true, admin_local_group_archive_min_version: '1.4.323',
|
|
@@ -570,7 +574,7 @@ export class IntegrationRuntime {
|
|
|
570
574
|
const key = `${p.id}:${p.groupId}:${consumer}`;
|
|
571
575
|
// Resolve the initial cursor before sending headers so retention gaps
|
|
572
576
|
// and bad consumer names are ordinary machine-readable HTTP errors.
|
|
573
|
-
const initial = this.store.
|
|
577
|
+
const initial = this.store.streamPage(p, consumer);
|
|
574
578
|
cursor = initial.cursor;
|
|
575
579
|
res.setHeader('Content-Type', 'application/x-ndjson');
|
|
576
580
|
res.setHeader('Cache-Control', 'no-store');
|
|
@@ -585,13 +589,13 @@ export class IntegrationRuntime {
|
|
|
585
589
|
try {
|
|
586
590
|
this.authorize(p, 'events.read');
|
|
587
591
|
for (;;) {
|
|
588
|
-
const page = this.store.
|
|
592
|
+
const page = this.store.streamPage(p, consumer, cursor);
|
|
589
593
|
if (!page.events.length)
|
|
590
594
|
break;
|
|
591
595
|
for (const event of page.events) {
|
|
592
596
|
if (res.writableLength > 1024 * 1024)
|
|
593
597
|
integrationError('EVENT_CONSUMER_SLOW', 'Reconnect from the last acknowledged cursor', 503);
|
|
594
|
-
res.write(
|
|
598
|
+
res.write(event.line);
|
|
595
599
|
cursor = event.cursor;
|
|
596
600
|
this.sent.set(key, Math.max(this.sent.get(key) ?? 0, event.sequence));
|
|
597
601
|
}
|
|
@@ -263,6 +263,20 @@ export class IntegrationSessions {
|
|
|
263
263
|
return structuredClone({ launch_diagnostics: record.launch_diagnostics ?? null,
|
|
264
264
|
launch_diagnostics_history: record.launch_diagnostics_history ?? [] });
|
|
265
265
|
}
|
|
266
|
+
async closeDiagnostics(id) {
|
|
267
|
+
const record = structuredClone(this.doc.sessions.find(s => s.session_id === id));
|
|
268
|
+
if (!record)
|
|
269
|
+
return integrationError('INTEGRATION_SESSION_NOT_FOUND', 'Integration session does not exist', 404);
|
|
270
|
+
if (!this.adapter.closeDiagnostics)
|
|
271
|
+
integrationError('SESSION_CLOSE_DIAGNOSTICS_UNSUPPORTED', 'Live close diagnostics are unavailable', 409);
|
|
272
|
+
const report = await this.adapter.closeDiagnostics(record);
|
|
273
|
+
if (JSON.stringify(this.doc.sessions.find(s => s.session_id === id)) !== JSON.stringify(record)) {
|
|
274
|
+
report.target_confirmed = false;
|
|
275
|
+
report.target = null;
|
|
276
|
+
report.code = 'SESSION_CLOSE_TARGET_CHANGED';
|
|
277
|
+
}
|
|
278
|
+
return { session_id: id, operation_id: record.operation_id, generation: record.generation, close_diagnostics: report };
|
|
279
|
+
}
|
|
266
280
|
assertRuntimeMessage(id) {
|
|
267
281
|
const record = this.doc.sessions.find(s => s.session_id === id);
|
|
268
282
|
if (this.closingOperations.has(id))
|
|
@@ -714,10 +728,12 @@ export class IntegrationSessions {
|
|
|
714
728
|
return record;
|
|
715
729
|
}));
|
|
716
730
|
}
|
|
717
|
-
async refresh(id, allowBindingReconciliation = true) {
|
|
731
|
+
async refresh(id, allowBindingReconciliation = true, presence) {
|
|
718
732
|
const record = structuredClone(this.doc.sessions.find(s => s.session_id === id));
|
|
719
733
|
if (!record)
|
|
720
734
|
return;
|
|
735
|
+
const runtimePresent = () => presence && JSON.stringify(record) === JSON.stringify(presence.record)
|
|
736
|
+
? Promise.resolve(presence.present) : this.adapter.runtimePresent?.(record);
|
|
721
737
|
if (record.rename_pending) {
|
|
722
738
|
if (this.lifecycle.active(id))
|
|
723
739
|
return;
|
|
@@ -744,7 +760,7 @@ export class IntegrationSessions {
|
|
|
744
760
|
integrationError('SESSION_CLOSE_UNSUPPORTED', 'Close reconciliation is unavailable', 409);
|
|
745
761
|
await this.adapter.closeRuntime(record, true);
|
|
746
762
|
}
|
|
747
|
-
if (!this.adapter.runtimePresent || await
|
|
763
|
+
if (!this.adapter.runtimePresent || await runtimePresent())
|
|
748
764
|
integrationError('OPERATION_OUTCOME_UNCONFIRMED', 'Runtime exists for a closed or closing session', 409);
|
|
749
765
|
const next = { ...record, state: 'closed', runtime_present: false, terminal_binding: null, close_intent: undefined,
|
|
750
766
|
operation_uncertain: false, error_code: null, startup_input: undefined };
|
|
@@ -772,7 +788,7 @@ export class IntegrationSessions {
|
|
|
772
788
|
try {
|
|
773
789
|
if (record.state !== 'released')
|
|
774
790
|
await this.adapter.release?.(record, true);
|
|
775
|
-
const present = await
|
|
791
|
+
const present = await runtimePresent();
|
|
776
792
|
if (present === undefined)
|
|
777
793
|
integrationError('SESSION_RELEASE_UNSUPPORTED', 'Runtime presence is unavailable', 409);
|
|
778
794
|
if (record.state !== 'released') {
|
|
@@ -998,8 +1014,21 @@ export class IntegrationSessions {
|
|
|
998
1014
|
this.observing = true;
|
|
999
1015
|
this.observeAgain = false;
|
|
1000
1016
|
try {
|
|
1001
|
-
|
|
1002
|
-
|
|
1017
|
+
const records = this.snapshot();
|
|
1018
|
+
const dormant = records.filter(record => ['closed', 'released'].includes(record.state) && record.runtime_present === false
|
|
1019
|
+
&& !record.operation_uncertain && !record.close_intent && !record.rename_pending && !record.cold_retry_pending);
|
|
1020
|
+
let presence;
|
|
1021
|
+
if (dormant.length && this.adapter.runtimePresenceBatch) {
|
|
1022
|
+
try {
|
|
1023
|
+
presence = await this.adapter.runtimePresenceBatch(dormant);
|
|
1024
|
+
}
|
|
1025
|
+
catch { /* Failed batch observations fall back to fresh per-record checks. */ }
|
|
1026
|
+
}
|
|
1027
|
+
const candidates = new Map(dormant.map(record => [record.session_id, record]));
|
|
1028
|
+
for (const record of records) {
|
|
1029
|
+
const candidate = candidates.get(record.session_id), present = presence?.get(record.session_id);
|
|
1030
|
+
await this.refresh(record.session_id, true, candidate && typeof present === 'boolean' ? { record: candidate, present } : undefined);
|
|
1031
|
+
}
|
|
1003
1032
|
}
|
|
1004
1033
|
finally {
|
|
1005
1034
|
this.observing = false;
|
|
@@ -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);
|
|
@@ -158,9 +245,10 @@ export class IntegrationStore {
|
|
|
158
245
|
for (const update of updates) {
|
|
159
246
|
if ((next ?? this.doc).sources[update.sourceKey] === update.sourceVersion)
|
|
160
247
|
continue;
|
|
161
|
-
next ??=
|
|
248
|
+
next ??= { ...this.doc, events: [...this.doc.events], sequences: { ...this.doc.sequences },
|
|
249
|
+
sources: { ...this.doc.sources }, prunedThrough: { ...this.doc.prunedThrough } };
|
|
162
250
|
const group = update.event.group_id, sequence = (next.sequences[group] ?? 0) + 1;
|
|
163
|
-
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) });
|
|
164
252
|
next.sequences[group] = sequence;
|
|
165
253
|
next.sources[update.sourceKey] = update.sourceVersion;
|
|
166
254
|
}
|
|
@@ -191,21 +279,39 @@ export class IntegrationStore {
|
|
|
191
279
|
return value.sequence;
|
|
192
280
|
}
|
|
193
281
|
key(principal, consumer) { identifier(consumer, 'consumer'); return `${principal.id}:${principal.groupId}:${consumer}`; }
|
|
194
|
-
|
|
282
|
+
selectPage(principal, consumer, cursor) {
|
|
195
283
|
this.authorize(principal, 'events.read');
|
|
196
284
|
const key = this.key(principal, consumer), group = principal.groupId;
|
|
197
285
|
const floor = this.doc.prunedThrough[group] ?? 0;
|
|
198
286
|
// A new consumer explicitly starts at the earliest retained fact. Existing
|
|
199
287
|
// consumers never silently skip a retention gap.
|
|
200
288
|
if (this.doc.consumers[key] === undefined) {
|
|
201
|
-
|
|
202
|
-
next.consumers[key] = floor;
|
|
203
|
-
this.write(next);
|
|
289
|
+
this.write({ ...this.doc, consumers: { ...this.doc.consumers, [key]: floor } });
|
|
204
290
|
}
|
|
205
291
|
const after = cursor ? this.decode(group, cursor) : this.doc.consumers[key];
|
|
206
292
|
if (after < floor)
|
|
207
293
|
integrationError('EVENT_RETENTION_GAP', 'Confirmed cursor is older than retained history; reconcile task/session snapshots and use a new consumer', 409);
|
|
208
|
-
|
|
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]) })) };
|
|
209
315
|
}
|
|
210
316
|
ack(principal, consumer, cursor) {
|
|
211
317
|
this.authorize(principal, 'events.read');
|
|
@@ -218,9 +324,8 @@ export class IntegrationStore {
|
|
|
218
324
|
integrationError('EVENT_RETENTION_GAP', 'Consumer has a retention gap', 409);
|
|
219
325
|
// Cursor is a cumulative declaration that every preceding event is safely
|
|
220
326
|
// in the bridge inbox. The subscription server separately checks it was sent.
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
this.write(next);
|
|
327
|
+
if (sequence !== confirmed)
|
|
328
|
+
this.write({ ...this.doc, consumers: { ...this.doc.consumers, [key]: sequence } });
|
|
224
329
|
return { cursor, ack_semantics: 'durable_received' };
|
|
225
330
|
}
|
|
226
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,8 @@ 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 { assertDetachedCloseOwnership } from '../agent/integrationCloseOwnership.js';
|
|
81
|
+
import { readTmuxLayout, readTmuxLayouts } from '../utils/tmuxLayoutReader.js';
|
|
80
82
|
import { CollaborationDeliveryWorker } from '../agent/collaborationDeliveryWorker.js';
|
|
81
83
|
import { approveCollaborationDialog, captureTmuxPaneHistory, captureTmuxPaneText, sendTmuxPaneKey, writeCollaborationTmuxPane } from '../agent/collaborationTmuxDelivery.js';
|
|
82
84
|
import { listAllHookAgents, refreshStaleHooksAtLaunch, installHooksForSlug, uninstallHooksForSlug, } from '../agent/installers.js';
|
|
@@ -1724,9 +1726,9 @@ function formatLocalCollaborationMessages(frontendSessionId, messages) {
|
|
|
1724
1726
|
async function inspectCollaborationTmux(binding, requestedPane, allowPlainPane = false, nativeProcessIdentity = false, freshProcess = false, describeNativeFailure = false, identityOverride) {
|
|
1725
1727
|
if (!binding.tmuxSessionName)
|
|
1726
1728
|
return { state: 'offline', reason: 'TMUX_BINDING_MISSING' };
|
|
1727
|
-
|
|
1729
|
+
let layout;
|
|
1728
1730
|
try {
|
|
1729
|
-
await
|
|
1731
|
+
layout = await getTmuxLayout(binding.tmuxSessionName);
|
|
1730
1732
|
}
|
|
1731
1733
|
catch (error) {
|
|
1732
1734
|
if (/can't find session|no server running|no sessions|error connecting.*No such file/i.test(getErrorMessage(error))) {
|
|
@@ -1734,10 +1736,9 @@ async function inspectCollaborationTmux(binding, requestedPane, allowPlainPane =
|
|
|
1734
1736
|
}
|
|
1735
1737
|
throw error;
|
|
1736
1738
|
}
|
|
1737
|
-
const layout = await getTmuxLayout(binding.tmuxSessionName);
|
|
1738
1739
|
if (layout.sessionName !== binding.tmuxSessionName)
|
|
1739
1740
|
throw new Error('TMUX_SESSION_IDENTITY_CHANGED');
|
|
1740
|
-
const serverPid = Number(
|
|
1741
|
+
const serverPid = Number(layout.serverPid);
|
|
1741
1742
|
if (!Number.isInteger(serverPid) || serverPid <= 0)
|
|
1742
1743
|
throw new Error('TMUX_SERVER_IDENTITY_UNAVAILABLE');
|
|
1743
1744
|
if ((nativeProcessIdentity || freshProcess) && process.platform === 'linux')
|
|
@@ -2150,11 +2151,16 @@ async function integrationTmuxPresent(record) {
|
|
|
2150
2151
|
throw error;
|
|
2151
2152
|
}
|
|
2152
2153
|
}
|
|
2154
|
+
function integrationRuntimeRegistered(record) {
|
|
2155
|
+
return !!integrationTerminalRecord(record)
|
|
2156
|
+
|| [...terminalSessions.values()].some(s => s.mode === 'tmux' && s.tmuxSessionName === `wt-integration-${record.session_id}`)
|
|
2157
|
+
|| !!collaborationStore.getGroup(record.group_id)?.sessionIds.includes(record.session_id);
|
|
2158
|
+
}
|
|
2153
2159
|
async function verifyIntegrationRuntimeOwnership(record) {
|
|
2154
2160
|
await integrationSessionAdapter.prepareRelease(record);
|
|
2155
2161
|
const terminal = integrationTerminalRecord(record);
|
|
2156
|
-
if (
|
|
2157
|
-
&& (other.tmuxSessionName ===
|
|
2162
|
+
if (globalSessionState.sessions.some(other => other.sessionId !== record.session_id
|
|
2163
|
+
&& (other.tmuxSessionName === `wt-integration-${record.session_id}` || terminal?.backendSessionId && other.backendSessionId === terminal.backendSessionId))) {
|
|
2158
2164
|
integrationError('SESSION_IDENTITY_MISMATCH', 'Runtime has another logical owner', 409);
|
|
2159
2165
|
}
|
|
2160
2166
|
}
|
|
@@ -2174,14 +2180,36 @@ async function forgetIntegrationRuntime(record) {
|
|
|
2174
2180
|
async function observeIntegrationCloseTarget(record) {
|
|
2175
2181
|
await verifyIntegrationRuntimeOwnership(record);
|
|
2176
2182
|
const terminal = integrationTerminalRecord(record);
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2183
|
+
const detached = !terminal;
|
|
2184
|
+
const sessionName = terminal?.tmuxSessionName ?? `wt-integration-${record.session_id}`;
|
|
2185
|
+
let backendSessionId = terminal?.backendSessionId ?? null;
|
|
2186
|
+
if (!record.terminal_binding)
|
|
2187
|
+
integrationError('SESSION_CLOSE_TARGET_UNCONFIRMED', 'Original persisted pane binding is unavailable', 409);
|
|
2188
|
+
if (!terminal) {
|
|
2189
|
+
if (process.platform !== 'linux' || !record.agent_native_session_id)
|
|
2190
|
+
integrationError('SESSION_CLOSE_TARGET_UNCONFIRMED', 'Missing terminal registration requires a current Linux writer-lock native identity', 409);
|
|
2191
|
+
const owners = await integrationRecoveryOwnership(record);
|
|
2192
|
+
collaborationRouting.assertReadable();
|
|
2193
|
+
const backends = [...terminalSessions].filter(([, backend]) => backend.mode === 'tmux' && backend.tmuxSessionName === sessionName);
|
|
2194
|
+
const group = collaborationStore.getGroup(record.group_id);
|
|
2195
|
+
assertDetachedCloseOwnership({ sessionId: record.session_id, groupId: record.group_id, principalId: record.principal_id,
|
|
2196
|
+
...owners, localGroup: !!group && !group.deleted && !group.federated,
|
|
2197
|
+
groupOwners: collaborationStore.groupsForSession(record.session_id).map(g => g.id),
|
|
2198
|
+
terminalClaims: globalSessionState.sessions.filter(s => s.sessionId === record.session_id || s.tmuxSessionName === sessionName).length,
|
|
2199
|
+
backendClaims: backends.map(([id]) => ({ routeOwner: collaborationRouting.ownerOfBackend(id),
|
|
2200
|
+
foreignTerminal: globalSessionState.sessions.some(s => s.backendSessionId === id && s.sessionId !== record.session_id) })),
|
|
2201
|
+
otherPaneOwner: collaborationRouting.hasOtherPaneOwner(record.session_id, record.terminal_binding),
|
|
2202
|
+
});
|
|
2203
|
+
// Local observation context only: never insert/adopt a global terminal or
|
|
2204
|
+
// backend, revive a released session, or alter delivery routing.
|
|
2205
|
+
backendSessionId = backends[0]?.[0] ?? null;
|
|
2206
|
+
}
|
|
2207
|
+
const inspected = await inspectCollaborationTmux({ sessionId: record.session_id, backendSessionId,
|
|
2208
|
+
mode: 'tmux', tmuxSessionName: sessionName, agentSlug: record.agent_slug,
|
|
2209
|
+
nativeSessionId: record.agent_native_session_id, pane: record.terminal_binding }, undefined, false, true, true, false, integrationNativeIdentity(record));
|
|
2182
2210
|
if (inspected.state !== 'ready' || !inspected.pane)
|
|
2183
2211
|
integrationError('SESSION_CLOSE_TARGET_UNCONFIRMED', 'Original pinned pane cannot be verified', 409);
|
|
2184
|
-
const layout = await getTmuxLayout(
|
|
2212
|
+
const layout = await getTmuxLayout(sessionName);
|
|
2185
2213
|
const panes = layout.windows.flatMap(window => window.panes);
|
|
2186
2214
|
if (panes.length !== 1 || layout.activePaneId !== record.terminal_binding.paneId)
|
|
2187
2215
|
integrationError('SESSION_IDENTITY_MISMATCH', 'Only the single original owned pane can be closed', 409);
|
|
@@ -2201,11 +2229,15 @@ async function observeIntegrationCloseTarget(record) {
|
|
|
2201
2229
|
const identity = await observeProgramNativeIdentity(agent, program, integrationNativeIdentity(record));
|
|
2202
2230
|
if (identity.failure || identity.nativeId !== record.agent_native_session_id)
|
|
2203
2231
|
integrationError('SESSION_IDENTITY_MISMATCH', 'Current Agent native identity differs from the owned record', 409);
|
|
2232
|
+
if (detached && identity.source !== 'linux-flock-owner')
|
|
2233
|
+
integrationError('SESSION_CLOSE_TARGET_UNCONFIRMED', 'Missing terminal registration requires independent live writer-lock proof, not argv or a cached UUID', 409);
|
|
2204
2234
|
kind = 'agent';
|
|
2205
2235
|
pid = program.processPid;
|
|
2206
2236
|
nativeId = identity.nativeId;
|
|
2207
2237
|
}
|
|
2208
2238
|
else {
|
|
2239
|
+
if (detached)
|
|
2240
|
+
integrationError('SESSION_CLOSE_TARGET_UNCONFIRMED', 'Missing terminal registration cannot authorize closing an unproven shell or another program', 409);
|
|
2209
2241
|
if (!await integrationPaneIsIdleShell(inspected.pane))
|
|
2210
2242
|
integrationError('SESSION_CLOSE_TARGET_UNCONFIRMED', 'Current runtime is not a verified foreground shell', 409);
|
|
2211
2243
|
kind = 'shell';
|
|
@@ -2246,6 +2278,25 @@ export const integrationSessionAdapter = {
|
|
|
2246
2278
|
await verifyIntegrationRuntimeOwnership(record);
|
|
2247
2279
|
return await integrationTmuxPresent(record) ? observeIntegrationCloseTarget(record) : null;
|
|
2248
2280
|
},
|
|
2281
|
+
async closeDiagnostics(record) {
|
|
2282
|
+
const report = { checked_at: Date.now(), terminal_registration_present: false, binding_present: !!record.terminal_binding,
|
|
2283
|
+
runtime_present: null, target_confirmed: false, pending_messages: collaborationStore.pendingCount(record.session_id),
|
|
2284
|
+
code: null, target: null };
|
|
2285
|
+
try {
|
|
2286
|
+
report.terminal_registration_present = !!integrationTerminalRecord(record);
|
|
2287
|
+
report.runtime_present = await integrationSessionAdapter.runtimePresent(record);
|
|
2288
|
+
if (await integrationTmuxPresent(record)) {
|
|
2289
|
+
report.target = await observeIntegrationCloseTarget(record);
|
|
2290
|
+
report.target_confirmed = true;
|
|
2291
|
+
}
|
|
2292
|
+
else
|
|
2293
|
+
report.code = report.runtime_present ? 'SESSION_CLOSE_TARGET_UNCONFIRMED' : 'SESSION_RUNTIME_ABSENT';
|
|
2294
|
+
}
|
|
2295
|
+
catch (error) {
|
|
2296
|
+
report.code = typeof error.code === 'string' ? error.code : 'SESSION_CLOSE_OBSERVATION_FAILED';
|
|
2297
|
+
}
|
|
2298
|
+
return report;
|
|
2299
|
+
},
|
|
2249
2300
|
async closeRuntime(record, reconcile) {
|
|
2250
2301
|
await closeSessionRuntime({
|
|
2251
2302
|
barrier: action => collaborationDeliveryWorker.reconfigure(record.session_id, action),
|
|
@@ -2290,14 +2341,22 @@ export const integrationSessionAdapter = {
|
|
|
2290
2341
|
resume: getPluginResumeConfig(agent.slug) ?? command, nativeIdentity: integrationNativeIdentity(record) ?? getPluginNativeIdentityConfig(agent.slug) ?? null };
|
|
2291
2342
|
},
|
|
2292
2343
|
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))
|
|
2344
|
+
if (integrationRuntimeRegistered(record))
|
|
2298
2345
|
return true;
|
|
2299
2346
|
return integrationTmuxPresent(record);
|
|
2300
2347
|
},
|
|
2348
|
+
async runtimePresenceBatch(records) {
|
|
2349
|
+
let names;
|
|
2350
|
+
try {
|
|
2351
|
+
names = new Set((await runTmux(['list-sessions', '-F', '#{session_name}'])).trimEnd().split('\n'));
|
|
2352
|
+
}
|
|
2353
|
+
catch (error) {
|
|
2354
|
+
if (!/no server running|no sessions|error connecting.*No such file/i.test(getErrorMessage(error)))
|
|
2355
|
+
throw error;
|
|
2356
|
+
names = new Set();
|
|
2357
|
+
}
|
|
2358
|
+
return new Map(records.map(record => [record.session_id, integrationRuntimeRegistered(record) || names.has(`wt-integration-${record.session_id}`)]));
|
|
2359
|
+
},
|
|
2301
2360
|
async release(record, reconcile) {
|
|
2302
2361
|
await releaseSessionRuntime({
|
|
2303
2362
|
barrier: action => collaborationDeliveryWorker.reconfigure(record.session_id, action),
|
|
@@ -2339,8 +2398,11 @@ export const integrationSessionAdapter = {
|
|
|
2339
2398
|
},
|
|
2340
2399
|
async inspect(record) {
|
|
2341
2400
|
const terminal = integrationTerminalRecord(record);
|
|
2342
|
-
if (!terminal)
|
|
2401
|
+
if (!terminal) {
|
|
2402
|
+
if (await integrationTmuxPresent(record))
|
|
2403
|
+
integrationError('SESSION_TERMINAL_REGISTRATION_MISSING', 'Owned tmux exists but its active TD terminal registration is missing; use administrator close-diagnostics', 409);
|
|
2343
2404
|
return { exists: false, running: false, shell: false, agentSlug: null, nativeId: null };
|
|
2405
|
+
}
|
|
2344
2406
|
const binding = { sessionId: record.session_id, backendSessionId: terminal.backendSessionId,
|
|
2345
2407
|
mode: 'tmux', tmuxSessionName: terminal.tmuxSessionName, agentSlug: record.agent_slug,
|
|
2346
2408
|
nativeSessionId: record.agent_native_session_id, pane: record.terminal_binding ?? null };
|
|
@@ -5069,166 +5131,12 @@ async function prepareManagedTmuxSession(sessionName, cwd) {
|
|
|
5069
5131
|
await tmuxLifecycle.run(`prepare:${sessionName}`, () => prepareManagedTmuxSessionUnlocked(sessionName, cwd));
|
|
5070
5132
|
}
|
|
5071
5133
|
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
|
-
};
|
|
5134
|
+
return readTmuxLayout(runTmux, sessionName);
|
|
5145
5135
|
}
|
|
5146
5136
|
let tmuxLayoutsSnapshot = null;
|
|
5147
5137
|
let tmuxLayoutsSnapshotPromise = null;
|
|
5148
5138
|
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;
|
|
5139
|
+
return readTmuxLayouts(runTmux);
|
|
5232
5140
|
}
|
|
5233
5141
|
async function getCachedTmuxLayout(sessionName) {
|
|
5234
5142
|
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
|
@@ -1394,3 +1394,80 @@ wrapper 负责实际设置 Agent 自己的数据目录、准备 hooks/参数,
|
|
|
1394
1394
|
- 终端输出触发的后台会话观察按 250ms 窗口合并;一轮观察期间的新通知安排到后续一轮,不连续抢占。显式 `session get`、生命周期操作及投递前核验保留原路径,不以此窗口判断 Agent 可输入;不延迟业务输入来猜测应用状态。同一次 integration 观察复用其新鲜 tmux layout,下一次观察重新读取,不把缓存用作身份写入凭据。
|
|
1395
1395
|
|
|
1396
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 与捕获快照需要由接入方串行协调。
|
|
1414
|
+
|
|
1415
|
+
## 1.4.349:活动终端登记缺失时的受限关闭与只读诊断
|
|
1416
|
+
|
|
1417
|
+
问题是三个不同的事实层可能不一致:持久 IntegrationSession 仍有原 native UUID 与完整 pane 绑定,tmux/Agent 仍存在,但全局活动终端登记已经缺失。348 的 close 先依赖该登记,因而在真实身份核验之前拒绝;inspect 也可能将登记缺失表现为 `AGENT_NOT_RUNNING`。349 对仍存在 tmux 的缺登记观察改报 `SESSION_TERMINAL_REGISTRATION_MISSING`,不据此推断程序退出。这不解释或修复所有登记丢失的起因,也不自动重建 UI 记录、恢复会话或认领未知进程。
|
|
1418
|
+
|
|
1419
|
+
最低 CLI / 实际服务 **1.4.349**,管理员和所属 principal capabilities:
|
|
1420
|
+
|
|
1421
|
+
- `session_close_diagnostics=true`、`session_close_diagnostics_min_version="1.4.349"`。
|
|
1422
|
+
- Linux 上 `session_close_detached_registration=true`、`session_close_detached_registration_min_version="1.4.349"`;其它平台 false。没有新增 principal 权限,关闭仍需原 `session.close`,诊断仅管理员。
|
|
1423
|
+
|
|
1424
|
+
管理员正式只读入口:
|
|
1425
|
+
|
|
1426
|
+
```sh
|
|
1427
|
+
td integration close-diagnostics <原TD-session-id>
|
|
1428
|
+
```
|
|
1429
|
+
|
|
1430
|
+
走原本机管理员 Unix socket,`GET /admin/sessions/:id/close-diagnostics`,返回:
|
|
1431
|
+
|
|
1432
|
+
```json
|
|
1433
|
+
{
|
|
1434
|
+
"session_id": "原逻辑ID",
|
|
1435
|
+
"operation_id": "当前operation",
|
|
1436
|
+
"generation": 2,
|
|
1437
|
+
"close_diagnostics": {
|
|
1438
|
+
"checked_at": 1790000000000,
|
|
1439
|
+
"terminal_registration_present": false,
|
|
1440
|
+
"binding_present": true,
|
|
1441
|
+
"runtime_present": true,
|
|
1442
|
+
"pending_messages": 0,
|
|
1443
|
+
"target_confirmed": true,
|
|
1444
|
+
"code": null,
|
|
1445
|
+
"target": {
|
|
1446
|
+
"binding": {"serverPid": 101, "sessionId": "$2", "paneId": "%2", "panePid": 102, "agentSlug": "已登记Agent", "nativeSessionId": "原UUID"},
|
|
1447
|
+
"tmux_server_start_id": "linux:1000",
|
|
1448
|
+
"kind": "agent",
|
|
1449
|
+
"process_pid": 103,
|
|
1450
|
+
"process_start_id": "linux:1001",
|
|
1451
|
+
"native_id": "原UUID"
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
```
|
|
1456
|
+
|
|
1457
|
+
这是字段示例,不代表某一现场已核验。诊断不调用 session get/refresh、不改变 descriptor/代次/路由/组,不启动或终止进程、不输入按键。`target` 只包含原 pane、当前进程实例和已证明 UUID,不读/返回 argv、环境或终端正文。记录在异步观察期间变化时丢弃 target,返回 `SESSION_CLOSE_TARGET_CHANGED`。`target_confirmed` 只表示本次目标核验通过,不等于后续关闭授权/成功;pending、进行中生命周期、当前代次等仍由真正 close 独立检查。观察失败保留 `target=null` 和准确 code;无法观察运行存在性时为 null,不假装已消失。已有 `td integration diagnostics` 仍只读原 launch/restore 持久诊断。
|
|
1458
|
+
|
|
1459
|
+
缺活动登记时,现有显式 `session close` 只在以下证据全部满足后工作:
|
|
1460
|
+
|
|
1461
|
+
1. 全机持久 IntegrationSession 只有一个 descriptor 声明该 session/principal/组;principal 未撤销且归属唯一;当前本机组仍存在且该成员只归属此组。组的 archived 显示元数据不影响此核验;缺组、跨服务组、重复/跨组归属拒绝。
|
|
1462
|
+
2. 无其它全局终端声明该逻辑 ID 或保留 tmux 名,无重复 backend、其它逻辑 owner 或冲突 route/pane owner;routing 不可读也拒绝。
|
|
1463
|
+
3. 保留名精确为 `wt-integration-<原ID>`,实时 server/session/pane/PID 全部等于持久原绑定,只有一个 pane,且未链接到其它 tmux session。不是仅凭名称、cwd、显示名、短 pane ID 或 PID 存在认领。
|
|
1464
|
+
4. 当前实际前台是原 Agent 类型,当前 Linux `linux-flock-owner` provider 独立证明原 UUID,并核验进程 starttime、FD/inode、权限及 kernel flock。argv/resume UUID、旧缓存或单纯 shell 观察不能给缺登记会话授权关闭;未安装相应只读身份 provider 时继续拒绝,不扩大为任意 Agent 黑盒支持。
|
|
1465
|
+
5. 沿原生命周期串行、worker 屏障、pending 检查、generation/CAS 和稳定幂等键,先持久化 close intent,再重新核验完整目标与进程实例,使用不可变 tmux session ID 终止一次。失败/重启结果不明沿现有 close 对账,不重复终止。并发外部 tmux 操作仍有系统级竞争边界,连续核验不能宣称绝对原子。
|
|
1466
|
+
|
|
1467
|
+
无活动登记不再单独阻止已充分证明的目标关闭;未提供 force/裸 UUID/按名清理入口。没有补建 active terminal、backend 或网页客户端,没有修改旧 profile/启动配置、投递正文、重放消息或重新打开归档组。`closed` 仍保留逻辑记录、诊断、消息、工作目录和原生历史并拒绝 send/reply/restore;不是 release。
|
|
1468
|
+
|
|
1469
|
+
现场接入先升级并核验 **实际服务** 两项 capability,再对原 ID 运行管理员 close-diagnostics。确认归属与 `target_confirmed` 后,由原 principal 正式 get 读当前 generation;若仍与原请求一致,沿原 generation/原 key 重试原 close。若 generation 已改变,先对账原请求,不擅自换 key;无历史 intent 的新关闭需据新代次明确生成新操作键。最终 get 必须确认 `closed/runtime_present=false/terminal_binding=null/close_pending=false`,并核对旧 pane/进程确已消失;诊断通过或 API 请求成功本身不代替回收验证。
|
|
1470
|
+
|
|
1471
|
+
错误包括 `SESSION_CLOSE_OWNERSHIP_UNCONFIRMED`(持久 owner/本机组不唯一或缺失)、`SESSION_IDENTITY_MISMATCH`(其它声明或实际 native 冲突)、`SESSION_CLOSE_TARGET_UNCONFIRMED`(原绑定/实时 writer-lock/前台实例不足)、`SESSION_CLOSE_TARGET_CHANGED`、`SESSION_PENDING_MESSAGES` 及原有代次/权限/生命周期错误。管理员报告不能越过这些检查。
|
|
1472
|
+
|
|
1473
|
+
回归包含唯一及重复/撤销/跨组/跨服务归属、缺登记精确关闭、argv-only/错误 UUID/换 pane/换进程拒绝、缺登记准确错误、诊断无副作用及代次变化丢弃、Unix 管理权限与 CLI capability 门控、原关闭意图/幂等/重启和失败 shell 兼容;隔离真实 tmux 验证只终止精确目标并保留邻接会话。该 tmux 测试用可控身份夹具,Linux 原生 writer-lock provider 另有真实内核/FD/starttime 回归;私有 TraeX 和上述远端目标仍须由接入方在维护窗实机验收,不以合成测试冒充。
|
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.349",
|
|
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-2vXkNt6WtThYWY+7lOPI2qTULTqt2YLY6DEiMlTBb7k=",
|
|
10
10
|
"clientBundleHash": "sha256-kn3Tg+BTj+H4rZe0g9OE5JUEQNsBGiUSGew8Epm5A38=",
|
|
11
11
|
"entrypoint": "dist/server/cli.js",
|
|
12
12
|
"clientEntrypoint": "dist/client/index.html"
|