termdock 1.4.347 → 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.
@@ -307,6 +307,7 @@ export class IntegrationRuntime {
307
307
  session_launch_reload_fence: true, session_launch_reload_fence_min_version: '1.4.346',
308
308
  session_shell_startup_wait: true, session_shell_startup_wait_min_version: '1.4.346',
309
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',
310
311
  session_launch_diagnostics: true, session_launch_diagnostics_min_version: '1.4.314',
311
312
  session_cold_retry: true, session_cold_retry_min_version: '1.4.314',
312
313
  session_rename: true, session_rename_min_version: '1.4.315',
@@ -570,7 +571,7 @@ export class IntegrationRuntime {
570
571
  const key = `${p.id}:${p.groupId}:${consumer}`;
571
572
  // Resolve the initial cursor before sending headers so retention gaps
572
573
  // and bad consumer names are ordinary machine-readable HTTP errors.
573
- const initial = this.store.page(p, consumer);
574
+ const initial = this.store.streamPage(p, consumer);
574
575
  cursor = initial.cursor;
575
576
  res.setHeader('Content-Type', 'application/x-ndjson');
576
577
  res.setHeader('Cache-Control', 'no-store');
@@ -585,13 +586,13 @@ export class IntegrationRuntime {
585
586
  try {
586
587
  this.authorize(p, 'events.read');
587
588
  for (;;) {
588
- const page = this.store.page(p, consumer, cursor);
589
+ const page = this.store.streamPage(p, consumer, cursor);
589
590
  if (!page.events.length)
590
591
  break;
591
592
  for (const event of page.events) {
592
593
  if (res.writableLength > 1024 * 1024)
593
594
  integrationError('EVENT_CONSUMER_SLOW', 'Reconnect from the last acknowledged cursor', 503);
594
- res.write(`${JSON.stringify({ type: 'event', ...event })}\n`);
595
+ res.write(event.line);
595
596
  cursor = event.cursor;
596
597
  this.sent.set(key, Math.max(this.sent.get(key) ?? 0, event.sequence));
597
598
  }
@@ -714,10 +714,12 @@ export class IntegrationSessions {
714
714
  return record;
715
715
  }));
716
716
  }
717
- async refresh(id, allowBindingReconciliation = true) {
717
+ async refresh(id, allowBindingReconciliation = true, presence) {
718
718
  const record = structuredClone(this.doc.sessions.find(s => s.session_id === id));
719
719
  if (!record)
720
720
  return;
721
+ const runtimePresent = () => presence && JSON.stringify(record) === JSON.stringify(presence.record)
722
+ ? Promise.resolve(presence.present) : this.adapter.runtimePresent?.(record);
721
723
  if (record.rename_pending) {
722
724
  if (this.lifecycle.active(id))
723
725
  return;
@@ -744,7 +746,7 @@ export class IntegrationSessions {
744
746
  integrationError('SESSION_CLOSE_UNSUPPORTED', 'Close reconciliation is unavailable', 409);
745
747
  await this.adapter.closeRuntime(record, true);
746
748
  }
747
- if (!this.adapter.runtimePresent || await this.adapter.runtimePresent(record))
749
+ if (!this.adapter.runtimePresent || await runtimePresent())
748
750
  integrationError('OPERATION_OUTCOME_UNCONFIRMED', 'Runtime exists for a closed or closing session', 409);
749
751
  const next = { ...record, state: 'closed', runtime_present: false, terminal_binding: null, close_intent: undefined,
750
752
  operation_uncertain: false, error_code: null, startup_input: undefined };
@@ -772,7 +774,7 @@ export class IntegrationSessions {
772
774
  try {
773
775
  if (record.state !== 'released')
774
776
  await this.adapter.release?.(record, true);
775
- const present = await this.adapter.runtimePresent?.(record);
777
+ const present = await runtimePresent();
776
778
  if (present === undefined)
777
779
  integrationError('SESSION_RELEASE_UNSUPPORTED', 'Runtime presence is unavailable', 409);
778
780
  if (record.state !== 'released') {
@@ -998,8 +1000,21 @@ export class IntegrationSessions {
998
1000
  this.observing = true;
999
1001
  this.observeAgain = false;
1000
1002
  try {
1001
- for (const record of this.snapshot())
1002
- await this.refresh(record.session_id);
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
+ }
1003
1018
  }
1004
1019
  finally {
1005
1020
  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
- fs.writeFileSync(descriptor, JSON.stringify(value));
21
+ write(descriptor);
16
22
  fs.fsyncSync(descriptor);
17
23
  }
18
24
  finally {
@@ -27,6 +33,42 @@ export function atomicJson(file, value) {
27
33
  fs.closeSync(directory);
28
34
  }
29
35
  }
36
+ function atomicJsonBuffers(file, parts) {
37
+ atomicJsonWrite(file, descriptor => {
38
+ let index = 0, offset = 0;
39
+ while (index < parts.length) {
40
+ // Bound vectors below platform IOV_MAX, and handle short writes without
41
+ // replaying or dropping any byte of the checked JSON document.
42
+ const vectors = parts.slice(index, index + 512);
43
+ if (offset)
44
+ vectors[0] = vectors[0].subarray(offset);
45
+ let written;
46
+ try {
47
+ written = fs.writevSync(descriptor, vectors);
48
+ }
49
+ catch (error) {
50
+ if (error.code === 'EINTR')
51
+ continue;
52
+ throw error;
53
+ }
54
+ if (written <= 0)
55
+ throw new Error('Integration journal write made no progress');
56
+ while (index < parts.length) {
57
+ const available = parts[index].length - offset;
58
+ if (written < available) {
59
+ offset += written;
60
+ break;
61
+ }
62
+ written -= available;
63
+ index++;
64
+ offset = 0;
65
+ if (!written)
66
+ break;
67
+ }
68
+ }
69
+ });
70
+ }
71
+ const JSON_OPEN = Buffer.from('{'), JSON_CLOSE = Buffer.from('}'), JSON_COMMA = Buffer.from(','), ARRAY_OPEN = Buffer.from('['), ARRAY_CLOSE = Buffer.from(']'), EVENT_PREFIX = Buffer.from('{"type":"event",'), NEWLINE = Buffer.from('\n');
30
72
  const hash = (value) => createHash('sha256').update(value).digest('hex');
31
73
  function identifier(value, label) {
32
74
  if (typeof value !== 'string' || !/^[a-zA-Z0-9._-]{1,80}$/.test(value))
@@ -77,6 +119,11 @@ export class IntegrationStore {
77
119
  retention;
78
120
  doc = { version: 1, principals: [], events: [], sequences: {}, prunedThrough: {}, sources: {}, consumers: {} };
79
121
  listeners = new Set();
122
+ // Committed events and metadata are immutable inside the store. Keep the
123
+ // encoded event fragments, not another deep copy of their payloads. Pruned
124
+ // objects become collectible with their encodings.
125
+ encoded = new WeakMap();
126
+ groupEvents = new Map();
80
127
  constructor(file, retention = 10000) {
81
128
  this.file = file;
82
129
  this.retention = retention;
@@ -90,12 +137,52 @@ export class IntegrationStore {
90
137
  if (error.code !== 'ENOENT')
91
138
  throw error;
92
139
  }
140
+ this.indexEvents();
141
+ }
142
+ indexEvents() {
143
+ this.groupEvents = new Map();
144
+ for (const event of this.doc.events) {
145
+ const events = this.groupEvents.get(event.group_id) ?? [];
146
+ events.push(event);
147
+ this.groupEvents.set(event.group_id, events);
148
+ }
149
+ }
150
+ encode(value) {
151
+ if (!value || typeof value !== 'object')
152
+ return Buffer.from(JSON.stringify(value));
153
+ const cached = this.encoded.get(value);
154
+ if (cached !== undefined)
155
+ return cached;
156
+ const serialized = Buffer.from(JSON.stringify(value));
157
+ this.encoded.set(value, serialized);
158
+ return serialized;
93
159
  }
94
160
  write(next) {
95
- if (Buffer.byteLength(JSON.stringify(next)) > 64 * 1024 * 1024)
161
+ const parts = [JSON_OPEN];
162
+ for (const [index, [key, value]] of Object.entries(next).entries()) {
163
+ if (index)
164
+ parts.push(JSON_COMMA);
165
+ parts.push(Buffer.from(`${JSON.stringify(key)}:`));
166
+ if (key === 'events') {
167
+ parts.push(ARRAY_OPEN);
168
+ for (const [index, event] of next.events.entries()) {
169
+ if (index)
170
+ parts.push(JSON_COMMA);
171
+ parts.push(this.encode(event));
172
+ }
173
+ parts.push(ARRAY_CLOSE);
174
+ }
175
+ else
176
+ parts.push(this.encode(value));
177
+ }
178
+ parts.push(JSON_CLOSE);
179
+ if (parts.reduce((bytes, part) => bytes + part.length, 0) > 64 * 1024 * 1024)
96
180
  integrationError('EVENT_STORAGE_FULL', 'Integration journal is full; no event was acknowledged', 503);
97
- atomicJson(this.file, next);
181
+ atomicJsonBuffers(this.file, parts);
182
+ const eventsChanged = next.events !== this.doc.events;
98
183
  this.doc = next;
184
+ if (eventsChanged)
185
+ this.indexEvents();
99
186
  for (const listener of this.listeners) {
100
187
  try {
101
188
  listener();
@@ -110,13 +197,12 @@ export class IntegrationStore {
110
197
  if (this.doc.principals.some(p => p.id === checked.id))
111
198
  integrationError('INTEGRATION_EXISTS', 'Use a new id; credentials cannot be silently replaced', 409);
112
199
  const token = randomBytes(32).toString('base64url');
113
- const next = structuredClone(this.doc);
114
- next.principals.push({ ...checked, tokenHash: hash(token), revoked: false });
200
+ const next = { ...this.doc, principals: [...this.doc.principals, { ...checked, tokenHash: hash(token), revoked: false }] };
115
201
  this.write(next);
116
- return { principal: checked, token };
202
+ return { principal: structuredClone(checked), token };
117
203
  }
118
204
  update(policy) {
119
- const checked = validatePolicy(policy), next = structuredClone(this.doc);
205
+ const checked = validatePolicy(policy), next = { ...this.doc, principals: [...this.doc.principals] };
120
206
  const index = next.principals.findIndex(principal => principal.id === checked.id);
121
207
  if (index < 0)
122
208
  integrationError('INTEGRATION_NOT_FOUND', 'Integration does not exist', 404);
@@ -129,11 +215,12 @@ export class IntegrationStore {
129
215
  this.write(next);
130
216
  }
131
217
  revoke(id) {
132
- const next = structuredClone(this.doc), principal = next.principals.find(p => p.id === id);
218
+ const principal = this.doc.principals.find(p => p.id === id);
133
219
  if (!principal)
134
220
  integrationError('INTEGRATION_NOT_FOUND', 'Integration does not exist', 404);
135
- principal.revoked = true;
136
- this.write(next);
221
+ if (principal.revoked)
222
+ return;
223
+ this.write({ ...this.doc, principals: this.doc.principals.map(p => p.id === id ? { ...p, revoked: true } : p) });
137
224
  }
138
225
  authenticate(id, token) {
139
226
  const principal = this.doc.principals.find(p => p.id === id);
@@ -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 ??= structuredClone(this.doc);
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
- page(principal, consumer, cursor) {
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
- const next = structuredClone(this.doc);
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
- return { events: structuredClone(this.doc.events.filter(e => e.group_id === group && e.sequence > after).slice(0, 100)), cursor: this.cursor(group, after) };
294
+ const events = this.groupEvents.get(group) ?? [];
295
+ let lo = 0, hi = events.length;
296
+ while (lo < hi) {
297
+ const mid = (lo + hi) >>> 1;
298
+ if (events[mid].sequence <= after)
299
+ lo = mid + 1;
300
+ else
301
+ hi = mid;
302
+ }
303
+ return { events: events.slice(lo, lo + 100), cursor: this.cursor(group, after) };
304
+ }
305
+ page(principal, consumer, cursor) {
306
+ return structuredClone(this.selectPage(principal, consumer, cursor));
307
+ }
308
+ /** Push uses immutable encoded facts rather than copying and re-encoding
309
+ * every retained payload for each consumer. No mutable record escapes. */
310
+ streamPage(principal, consumer, cursor) {
311
+ const page = this.selectPage(principal, consumer, cursor);
312
+ return { cursor: page.cursor, events: page.events.map(event => ({ sequence: event.sequence, cursor: event.cursor,
313
+ line: Object.hasOwn(event, 'type') ? Buffer.from(`${JSON.stringify({ type: 'event', ...event })}\n`)
314
+ : Buffer.concat([EVENT_PREFIX, this.encode(event).subarray(1), NEWLINE]) })) };
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
- const next = structuredClone(this.doc);
222
- next.consumers[key] = sequence;
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), relative = path.relative(os.homedir(), directory);
15
- return relative && relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative) ? directory : null;
14
+ const directory = path.resolve(expanded), home = os.homedir();
15
+ const within = (root) => {
16
+ const relative = path.relative(root, directory);
17
+ return !!relative && relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
18
+ };
19
+ if (within(home))
20
+ return directory;
21
+ // cwd is canonicalized before launch. A symlinked home may therefore have
22
+ // two legitimate spellings even before the lock directory is created.
23
+ // Accept only the real home alias; the live provider still verifies the
24
+ // actual directory ownership, realpath, FD/inode, starttime and kernel lock.
25
+ try {
26
+ return within(fs.realpathSync(home)) ? directory : null;
27
+ }
28
+ catch {
29
+ return null;
30
+ }
16
31
  }
17
32
  /** Administrator-declared profile scope, never inferred from process env.
18
33
  * {cwd} is permitted only as a leading directory anchor and cannot escape it. */
@@ -77,6 +77,7 @@ import { CollaborationError } from '../agent/collaborationProtocol.js';
77
77
  import { resolveCollaborationBackend, resolveCollaborationSessionId } from '../agent/sessionBindingRecovery.js';
78
78
  import { CollaborationRoutingStore, selectCollaborationPane, selectDrivePane, assessCollaborationRouteIdentity } from '../agent/collaborationRouting.js';
79
79
  import { selectRecoveredIntegrationBinding } from '../agent/integrationRecoveryBinding.js';
80
+ import { readTmuxLayout, readTmuxLayouts } from '../utils/tmuxLayoutReader.js';
80
81
  import { CollaborationDeliveryWorker } from '../agent/collaborationDeliveryWorker.js';
81
82
  import { approveCollaborationDialog, captureTmuxPaneHistory, captureTmuxPaneText, sendTmuxPaneKey, writeCollaborationTmuxPane } from '../agent/collaborationTmuxDelivery.js';
82
83
  import { listAllHookAgents, refreshStaleHooksAtLaunch, installHooksForSlug, uninstallHooksForSlug, } from '../agent/installers.js';
@@ -1724,9 +1725,9 @@ function formatLocalCollaborationMessages(frontendSessionId, messages) {
1724
1725
  async function inspectCollaborationTmux(binding, requestedPane, allowPlainPane = false, nativeProcessIdentity = false, freshProcess = false, describeNativeFailure = false, identityOverride) {
1725
1726
  if (!binding.tmuxSessionName)
1726
1727
  return { state: 'offline', reason: 'TMUX_BINDING_MISSING' };
1727
- const name = `=${binding.tmuxSessionName}`;
1728
+ let layout;
1728
1729
  try {
1729
- await runTmux(['has-session', '-t', name]);
1730
+ layout = await getTmuxLayout(binding.tmuxSessionName);
1730
1731
  }
1731
1732
  catch (error) {
1732
1733
  if (/can't find session|no server running|no sessions|error connecting.*No such file/i.test(getErrorMessage(error))) {
@@ -1734,10 +1735,9 @@ async function inspectCollaborationTmux(binding, requestedPane, allowPlainPane =
1734
1735
  }
1735
1736
  throw error;
1736
1737
  }
1737
- const layout = await getTmuxLayout(binding.tmuxSessionName);
1738
1738
  if (layout.sessionName !== binding.tmuxSessionName)
1739
1739
  throw new Error('TMUX_SESSION_IDENTITY_CHANGED');
1740
- const serverPid = Number((await runTmux(['display-message', '-p', '#{pid}'])).trim());
1740
+ const serverPid = Number(layout.serverPid);
1741
1741
  if (!Number.isInteger(serverPid) || serverPid <= 0)
1742
1742
  throw new Error('TMUX_SERVER_IDENTITY_UNAVAILABLE');
1743
1743
  if ((nativeProcessIdentity || freshProcess) && process.platform === 'linux')
@@ -2150,6 +2150,11 @@ async function integrationTmuxPresent(record) {
2150
2150
  throw error;
2151
2151
  }
2152
2152
  }
2153
+ function integrationRuntimeRegistered(record) {
2154
+ return !!integrationTerminalRecord(record)
2155
+ || [...terminalSessions.values()].some(s => s.mode === 'tmux' && s.tmuxSessionName === `wt-integration-${record.session_id}`)
2156
+ || !!collaborationStore.getGroup(record.group_id)?.sessionIds.includes(record.session_id);
2157
+ }
2153
2158
  async function verifyIntegrationRuntimeOwnership(record) {
2154
2159
  await integrationSessionAdapter.prepareRelease(record);
2155
2160
  const terminal = integrationTerminalRecord(record);
@@ -2290,14 +2295,22 @@ export const integrationSessionAdapter = {
2290
2295
  resume: getPluginResumeConfig(agent.slug) ?? command, nativeIdentity: integrationNativeIdentity(record) ?? getPluginNativeIdentityConfig(agent.slug) ?? null };
2291
2296
  },
2292
2297
  async runtimePresent(record) {
2293
- if (integrationTerminalRecord(record))
2294
- return true;
2295
- if ([...terminalSessions.values()].some(s => s.mode === 'tmux' && s.tmuxSessionName === `wt-integration-${record.session_id}`))
2296
- return true;
2297
- if (collaborationStore.getGroup(record.group_id)?.sessionIds.includes(record.session_id))
2298
+ if (integrationRuntimeRegistered(record))
2298
2299
  return true;
2299
2300
  return integrationTmuxPresent(record);
2300
2301
  },
2302
+ async runtimePresenceBatch(records) {
2303
+ let names;
2304
+ try {
2305
+ names = new Set((await runTmux(['list-sessions', '-F', '#{session_name}'])).trimEnd().split('\n'));
2306
+ }
2307
+ catch (error) {
2308
+ if (!/no server running|no sessions|error connecting.*No such file/i.test(getErrorMessage(error)))
2309
+ throw error;
2310
+ names = new Set();
2311
+ }
2312
+ return new Map(records.map(record => [record.session_id, integrationRuntimeRegistered(record) || names.has(`wt-integration-${record.session_id}`)]));
2313
+ },
2301
2314
  async release(record, reconcile) {
2302
2315
  await releaseSessionRuntime({
2303
2316
  barrier: action => collaborationDeliveryWorker.reconfigure(record.session_id, action),
@@ -5069,166 +5082,12 @@ async function prepareManagedTmuxSession(sessionName, cwd) {
5069
5082
  await tmuxLifecycle.run(`prepare:${sessionName}`, () => prepareManagedTmuxSessionUnlocked(sessionName, cwd));
5070
5083
  }
5071
5084
  async function getTmuxLayout(sessionName) {
5072
- const sessionInfoRaw = (await runTmux([
5073
- 'display-message',
5074
- '-t',
5075
- sessionName,
5076
- '-p',
5077
- `#{session_id}${TMUX_DELIMITER}#{session_name}${TMUX_DELIMITER}#{window_id}${TMUX_DELIMITER}#{pane_id}${TMUX_DELIMITER}#{pane_in_mode}`,
5078
- ])).trim();
5079
- const sessionInfo = parseDelimitedRow(sessionInfoRaw, 5);
5080
- if (!sessionInfo) {
5081
- throw new Error(`Failed to parse tmux session info: ${sessionInfoRaw}`);
5082
- }
5083
- const [sessionId, resolvedSessionName, activeWindowId, activePaneId, paneInMode] = sessionInfo;
5084
- const windowsRaw = await runTmux([
5085
- 'list-windows',
5086
- '-t',
5087
- sessionName,
5088
- '-F',
5089
- `#{window_id}${TMUX_DELIMITER}#{window_name}${TMUX_DELIMITER}#{window_index}${TMUX_DELIMITER}#{window_active}`,
5090
- ]);
5091
- const windows = [];
5092
- for (const line of windowsRaw.trim().split('\n')) {
5093
- if (!line) {
5094
- continue;
5095
- }
5096
- const row = parseDelimitedRow(line, 4);
5097
- if (!row) {
5098
- continue;
5099
- }
5100
- const [windowId, windowName, windowIndexRaw, windowActiveRaw] = row;
5101
- const panesRaw = await runTmux([
5102
- 'list-panes',
5103
- '-t',
5104
- windowId,
5105
- '-F',
5106
- `#{pane_id}${TMUX_DELIMITER}#{pane_index}${TMUX_DELIMITER}#{pane_active}${TMUX_DELIMITER}#{pane_width}${TMUX_DELIMITER}#{pane_height}${TMUX_DELIMITER}#{pane_top}${TMUX_DELIMITER}#{pane_left}${TMUX_DELIMITER}#{pane_current_command}${TMUX_DELIMITER}#{pane_pid}${TMUX_DELIMITER}#{pane_tty}${TMUX_DELIMITER}#{pane_title}${TMUX_DELIMITER}#{pane_current_path}`,
5107
- ]);
5108
- const panes = panesRaw.trim().split('\n').filter(Boolean).map((paneLine) => {
5109
- const paneRow = parseDelimitedRow(paneLine, 12);
5110
- if (!paneRow) {
5111
- return null;
5112
- }
5113
- const [paneId, paneIndexRaw, paneActiveRaw, widthRaw, heightRaw, topRaw, leftRaw, command, pidRaw, tty, title, currentPath] = paneRow;
5114
- return {
5115
- id: paneId,
5116
- index: parseInt(paneIndexRaw || '0', 10),
5117
- active: paneActiveRaw === '1',
5118
- width: parseInt(widthRaw || '0', 10),
5119
- height: parseInt(heightRaw || '0', 10),
5120
- top: parseInt(topRaw || '0', 10),
5121
- left: parseInt(leftRaw || '0', 10),
5122
- command: command || '',
5123
- pid: parseInt(pidRaw || '0', 10) || 0,
5124
- tty: tty || '',
5125
- title: title || '',
5126
- currentPath: currentPath || '',
5127
- };
5128
- }).filter((pane) => pane !== null);
5129
- windows.push({
5130
- id: windowId,
5131
- name: windowName || '',
5132
- index: parseInt(windowIndexRaw || '0', 10),
5133
- active: windowActiveRaw === '1',
5134
- panes,
5135
- });
5136
- }
5137
- return {
5138
- sessionId,
5139
- sessionName: resolvedSessionName,
5140
- windows,
5141
- activeWindowId,
5142
- activePaneId,
5143
- inCopyMode: paneInMode === '1',
5144
- };
5085
+ return readTmuxLayout(runTmux, sessionName);
5145
5086
  }
5146
5087
  let tmuxLayoutsSnapshot = null;
5147
5088
  let tmuxLayoutsSnapshotPromise = null;
5148
5089
  async function buildTmuxLayoutsSnapshot() {
5149
- const [sessionsRaw, windowsRaw, panesRaw] = await Promise.all([
5150
- runTmux([
5151
- 'list-sessions',
5152
- '-F',
5153
- `#{session_id}${TMUX_DELIMITER}#{session_name}${TMUX_DELIMITER}#{window_id}${TMUX_DELIMITER}#{pane_id}${TMUX_DELIMITER}#{pane_in_mode}`,
5154
- ]),
5155
- runTmux([
5156
- 'list-windows',
5157
- '-a',
5158
- '-F',
5159
- `#{session_name}${TMUX_DELIMITER}#{window_id}${TMUX_DELIMITER}#{window_name}${TMUX_DELIMITER}#{window_index}${TMUX_DELIMITER}#{window_active}`,
5160
- ]),
5161
- runTmux([
5162
- 'list-panes',
5163
- '-a',
5164
- '-F',
5165
- `#{session_name}${TMUX_DELIMITER}#{window_id}${TMUX_DELIMITER}#{pane_id}${TMUX_DELIMITER}#{pane_index}${TMUX_DELIMITER}#{pane_active}${TMUX_DELIMITER}#{pane_width}${TMUX_DELIMITER}#{pane_height}${TMUX_DELIMITER}#{pane_top}${TMUX_DELIMITER}#{pane_left}${TMUX_DELIMITER}#{pane_current_command}${TMUX_DELIMITER}#{pane_pid}${TMUX_DELIMITER}#{pane_tty}${TMUX_DELIMITER}#{pane_title}${TMUX_DELIMITER}#{pane_current_path}`,
5166
- ]),
5167
- ]);
5168
- const layouts = new Map();
5169
- for (const line of sessionsRaw.trim().split('\n')) {
5170
- if (!line)
5171
- continue;
5172
- const row = parseDelimitedRow(line, 5);
5173
- if (!row)
5174
- continue;
5175
- const [sessionId, sessionName, activeWindowId, activePaneId, paneInMode] = row;
5176
- layouts.set(sessionName, {
5177
- sessionId,
5178
- sessionName,
5179
- windows: [],
5180
- activeWindowId,
5181
- activePaneId,
5182
- inCopyMode: paneInMode === '1',
5183
- });
5184
- }
5185
- const windowsById = new Map();
5186
- for (const line of windowsRaw.trim().split('\n')) {
5187
- if (!line)
5188
- continue;
5189
- const row = parseDelimitedRow(line, 5);
5190
- if (!row)
5191
- continue;
5192
- const [sessionName, windowId, windowName, windowIndexRaw, windowActiveRaw] = row;
5193
- const layout = layouts.get(sessionName);
5194
- if (!layout)
5195
- continue;
5196
- const window = {
5197
- id: windowId,
5198
- name: windowName || '',
5199
- index: parseInt(windowIndexRaw || '0', 10),
5200
- active: windowActiveRaw === '1',
5201
- panes: [],
5202
- };
5203
- layout.windows.push(window);
5204
- windowsById.set(`${sessionName}\0${windowId}`, window);
5205
- }
5206
- for (const line of panesRaw.trim().split('\n')) {
5207
- if (!line)
5208
- continue;
5209
- const row = parseDelimitedRow(line, 14);
5210
- if (!row)
5211
- continue;
5212
- const [sessionName, windowId, paneId, paneIndexRaw, paneActiveRaw, widthRaw, heightRaw, topRaw, leftRaw, command, pidRaw, tty, title, currentPath] = row;
5213
- const window = windowsById.get(`${sessionName}\0${windowId}`);
5214
- if (!window)
5215
- continue;
5216
- window.panes.push({
5217
- id: paneId,
5218
- index: parseInt(paneIndexRaw || '0', 10),
5219
- active: paneActiveRaw === '1',
5220
- width: parseInt(widthRaw || '0', 10),
5221
- height: parseInt(heightRaw || '0', 10),
5222
- top: parseInt(topRaw || '0', 10),
5223
- left: parseInt(leftRaw || '0', 10),
5224
- command: command || '',
5225
- pid: parseInt(pidRaw || '0', 10) || 0,
5226
- tty: tty || '',
5227
- title: title || '',
5228
- currentPath: currentPath || '',
5229
- });
5230
- }
5231
- return layouts;
5090
+ return readTmuxLayouts(runTmux);
5232
5091
  }
5233
5092
  async function getCachedTmuxLayout(sessionName) {
5234
5093
  if (tmuxLayoutsSnapshot && Date.now() - tmuxLayoutsSnapshot.fetchedAt < TMUX_LAYOUT_CACHE_TTL_MS) {
@@ -0,0 +1,74 @@
1
+ const separator = '\x1f';
2
+ export const TMUX_LAYOUT_FORMAT = [
3
+ 'pid', 'session_id', 'session_name', 'window_id', 'window_name', 'window_index', 'window_active',
4
+ 'pane_id', 'pane_index', 'pane_active', 'pane_width', 'pane_height', 'pane_top', 'pane_left',
5
+ 'pane_current_command', 'pane_pid', 'pane_tty', 'pane_title', 'pane_in_mode', 'pane_current_path',
6
+ ].map(field => `#{${field}}`).join(separator);
7
+ export function parseTmuxLayout(stdout, requestedName) {
8
+ let layout;
9
+ const windows = new Map(), ids = new Set();
10
+ for (const line of stdout.trimEnd().split('\n')) {
11
+ if (!line)
12
+ continue;
13
+ const columns = line.replaceAll('\\037', separator).replaceAll('\\x1f', separator).split(separator);
14
+ if (columns.length < 20)
15
+ throw new Error('TMUX_LAYOUT_UNAVAILABLE');
16
+ const [server, sessionId, sessionName, windowId, name, windowIndex, windowActive, id, index, active, width, height, top, left, command, pid, tty, title, inMode] = columns;
17
+ const serverPid = Number(server);
18
+ if (!Number.isSafeInteger(serverPid) || serverPid <= 0 || sessionName !== requestedName || !sessionId || !windowId || !id
19
+ || ids.has(id) || layout && (layout.serverPid !== serverPid || layout.sessionId !== sessionId))
20
+ throw new Error('TMUX_SESSION_IDENTITY_CHANGED');
21
+ ids.add(id);
22
+ layout ??= { serverPid, sessionId, sessionName, windows: [], activeWindowId: '', activePaneId: '', inCopyMode: false };
23
+ let window = windows.get(windowId);
24
+ if (!window) {
25
+ window = { id: windowId, name, index: Number(windowIndex), active: windowActive === '1', panes: [] };
26
+ windows.set(windowId, window);
27
+ layout.windows.push(window);
28
+ }
29
+ else if (window.name !== name || window.index !== Number(windowIndex) || window.active !== (windowActive === '1')) {
30
+ throw new Error('TMUX_SESSION_IDENTITY_CHANGED');
31
+ }
32
+ const pane = { id, index: Number(index), active: active === '1', width: Number(width), height: Number(height),
33
+ top: Number(top), left: Number(left), command, pid: Number(pid), tty, title, currentPath: columns.slice(19).join(separator) };
34
+ if (![window.index, pane.index, pane.width, pane.height, pane.top, pane.left, pane.pid].every(Number.isSafeInteger)
35
+ || pane.pid <= 0)
36
+ throw new Error('TMUX_LAYOUT_UNAVAILABLE');
37
+ window.panes.push(pane);
38
+ if (window.active && pane.active) {
39
+ if (layout.activePaneId)
40
+ throw new Error('TMUX_SESSION_IDENTITY_CHANGED');
41
+ layout.activeWindowId = window.id;
42
+ layout.activePaneId = pane.id;
43
+ layout.inCopyMode = inMode === '1';
44
+ }
45
+ }
46
+ if (!layout || !layout.activePaneId)
47
+ throw new Error('TMUX_LAYOUT_UNAVAILABLE');
48
+ layout.windows.sort((a, b) => a.index - b.index);
49
+ for (const window of layout.windows)
50
+ window.panes.sort((a, b) => a.index - b.index);
51
+ return layout;
52
+ }
53
+ export async function readTmuxLayout(run, sessionName) {
54
+ return parseTmuxLayout(await run(['list-panes', '-s', '-t', `=${sessionName}:`, '-F', TMUX_LAYOUT_FORMAT]), sessionName);
55
+ }
56
+ export async function readTmuxLayouts(run) {
57
+ const rows = new Map();
58
+ for (const row of (await run(['list-panes', '-a', '-F', TMUX_LAYOUT_FORMAT])).trimEnd().split('\n')) {
59
+ const name = row.replaceAll('\\037', separator).replaceAll('\\x1f', separator).split(separator)[2];
60
+ if (!name)
61
+ continue;
62
+ const lines = rows.get(name) ?? [];
63
+ lines.push(row);
64
+ rows.set(name, lines);
65
+ }
66
+ const layouts = new Map();
67
+ for (const [name, lines] of rows) {
68
+ try {
69
+ layouts.set(name, parseTmuxLayout(lines.join('\n'), name));
70
+ }
71
+ catch { /* UI discovery can fall back to an exact fresh read of that session. */ }
72
+ }
73
+ return layouts;
74
+ }
@@ -1394,3 +1394,20 @@ 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 与捕获快照需要由接入方串行协调。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "termdock",
3
- "version": "1.4.347",
3
+ "version": "1.4.348",
4
4
  "description": "A complete web-based terminal application with modern UI",
5
5
  "type": "module",
6
6
  "main": ".desktop-dist/main.js",
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
3
  "packageName": "termdock",
4
- "version": "1.4.347",
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-FXUdT0eV3/+rgHBnc0Jg9dn2sAb4hcLY83xScGapcKQ=",
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"