termdock 1.4.303 → 1.4.305

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.
@@ -8,6 +8,7 @@ import { executeCollaborationCommand, duration } from './collaborationCli.js';
8
8
  import { getTermdockVersion } from '../utils/version.js';
9
9
  export const INTEGRATION_HELP = `td integration — local scoped integration administration
10
10
  groups | list
11
+ diagnostics <TD-session-id> (read the last restore check; administrator only)
11
12
  create --file <policy.json> --credential-file <private-output.json>
12
13
  update --file <policy.json> (same identity/group; preserves the credential)
13
14
  revoke <principal-id>
@@ -58,13 +59,22 @@ export async function runIntegrationAdmin(argv, port, adminToken, write) {
58
59
  write(INTEGRATION_HELP);
59
60
  return 0;
60
61
  }
62
+ const socketPath = integrationSocketPath(port);
63
+ if (action === 'diagnostics') {
64
+ if (argv.length !== 2 || !/^[a-zA-Z0-9._-]{1,80}$/.test(argv[1]))
65
+ throw new Error('diagnostics requires one TD session id');
66
+ const capabilities = parsed(await request(socketPath, 'GET', '/admin/capabilities', undefined, headers(undefined, adminToken)));
67
+ if (capabilities.session_restore_diagnostics !== true)
68
+ throw new CollaborationError('SESSION_RESTORE_DIAGNOSTICS_UNSUPPORTED', 'Running service does not support stored restore diagnostics', 409);
69
+ write(JSON.stringify(parsed(await request(socketPath, 'GET', `/admin/sessions/${encodeURIComponent(argv[1])}/restore-diagnostics`, undefined, headers(undefined, adminToken)))));
70
+ return 0;
71
+ }
61
72
  for (let index = 1; index < argv.length; index += 2) {
62
73
  const flag = argv[index];
63
74
  if (!['--file', '--credential-file'].includes(flag) || !argv[index + 1] || options[flag])
64
75
  throw new Error('Invalid integration arguments');
65
76
  options[flag] = argv[index + 1];
66
77
  }
67
- const socketPath = integrationSocketPath(port);
68
78
  if (action === 'groups' || action === 'list') {
69
79
  if (argv.length !== 1)
70
80
  throw new Error('Unexpected arguments');
@@ -61,6 +61,7 @@ export class IntegrationRuntime {
61
61
  };
62
62
  app.get('/admin/groups', run(req => { admin(req); return { groups: options.messages.list().filter(g => !g.federated).map(g => ({ id: g.id, name: g.name })) }; }));
63
63
  app.get('/admin/capabilities', run(req => { admin(req); return this.capabilities(); }));
64
+ app.get('/admin/sessions/:id/restore-diagnostics', run(req => { admin(req); return this.sessions.restoreDiagnostics(req.params.id); }));
64
65
  app.get('/admin/principals', run(req => { admin(req); return { integrations: this.store.policies() }; }));
65
66
  app.post('/admin/principals', run(req => { admin(req); this.group(req.body?.groupId); const provisioned = this.store.provision(req.body); try {
66
67
  this.sync();
@@ -145,6 +146,7 @@ export class IntegrationRuntime {
145
146
  idempotent_session_create: true, exact_session_restore: true, external_validation: false,
146
147
  startup_input_conditions: true,
147
148
  integration_policy_update: true,
149
+ session_restore_diagnostics: true,
148
150
  permissions: ['task.read', 'task.create', 'task.assign', 'task.comment', 'task.revise', 'task.answer', 'events.read', 'session.create', 'session.read', 'session.restore'] };
149
151
  }
150
152
  error(error) {
@@ -2,6 +2,7 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { createHash, randomBytes, randomUUID } from 'node:crypto';
4
4
  import { atomicJson, integrationError } from './integrationStore.js';
5
+ import { NativeResumeOwnerError } from './nativeResumeOwner.js';
5
6
  /** Quote each configured argument as literal shell data, including paths. */
6
7
  export function integrationLaunchCommand(record, restore) {
7
8
  const launchArgs = record.profile.argv.map(arg => arg === '{cwd}' ? record.cwd : arg);
@@ -15,8 +16,8 @@ function startupInput(record, now) {
15
16
  return condition ? { state: 'pending', deadline: now + (condition.timeoutMs ?? 120000), matched_since: null, observed_at: null } : undefined;
16
17
  }
17
18
  export function publicIntegrationSession(record) {
18
- const { profile: _, deadline: _deadline, launch_submitted: _submitted, startup_condition: _condition, ...publicRecord } = record;
19
- return publicRecord;
19
+ const { profile: _, deadline: _deadline, launch_submitted: _submitted, startup_condition: _condition, restore_diagnostics: diagnostics, ...publicRecord } = record;
20
+ return { ...publicRecord, ...(diagnostics ? { restore_diagnostics_available: true } : {}) };
20
21
  }
21
22
  export function permittedCwd(profile, cwd) {
22
23
  if (typeof cwd !== 'string' || !path.isAbsolute(cwd) || /[\x00-\x1f\x7f]/.test(cwd))
@@ -168,7 +169,9 @@ export class IntegrationSessions {
168
169
  catch (error) {
169
170
  const current = this.doc.sessions.find(s => s.session_id === record.session_id);
170
171
  const code = error.code;
171
- this.saveRecord({ ...current, state: 'failed', error_code: typeof code === 'string' ? code : 'SESSION_LAUNCH_FAILED', updated_at: Date.now() });
172
+ this.saveRecord({ ...current, state: 'failed', error_code: typeof code === 'string' ? code : 'SESSION_LAUNCH_FAILED',
173
+ restore_diagnostics: error instanceof NativeResumeOwnerError ? { ...structuredClone(error.diagnostics), operation_id: current.operation_id } : undefined,
174
+ updated_at: Date.now() });
172
175
  }
173
176
  return structuredClone(this.doc.sessions.find(s => s.session_id === record.session_id));
174
177
  })().finally(() => this.pending.delete(record.session_id));
@@ -176,6 +179,14 @@ export class IntegrationSessions {
176
179
  return promise;
177
180
  }
178
181
  async get(principal, id) { this.lookup(principal, id); await this.refresh(id); return this.lookup(principal, id); }
182
+ /** Read the stored check only: no process probe, PTY attachment, or new launch. */
183
+ restoreDiagnostics(id) {
184
+ const record = this.doc.sessions.find(session => session.session_id === id);
185
+ if (!record)
186
+ return integrationError('INTEGRATION_SESSION_NOT_FOUND', 'Integration session does not exist', 404);
187
+ return structuredClone({ session_id: record.session_id, operation_id: record.operation_id,
188
+ state: record.state, error_code: record.error_code, diagnostics: record.restore_diagnostics ?? null });
189
+ }
179
190
  async restore(principal, id, idempotencyKey) {
180
191
  const request = this.request(principal, idempotencyKey, ['restore', id]);
181
192
  if (request.previous)
@@ -215,6 +226,7 @@ export class IntegrationSessions {
215
226
  record.operation_id = randomUUID();
216
227
  record.deadline = Date.now() + this.bindingTimeout;
217
228
  record.updated_at = Date.now();
229
+ record.restore_diagnostics = undefined;
218
230
  if (!record.profile.startupInput && !record.startup_condition)
219
231
  record.startup_condition = structuredClone(principal.launchProfiles.find(profile => profile.id === record.launch_profile)?.startupInput);
220
232
  record.startup_input = startupInput(record, Date.now());
@@ -1,29 +1,83 @@
1
- import { integrationError } from './integrationStore.js';
2
- /** Last-known bindings select uncertain conflicts; only process observations
3
- * prove a conversation is running. The caller separately verifies its own pane. */
1
+ import { CollaborationError } from './collaborationProtocol.js';
2
+ /** A tmux remains observable without a browser/PTY attachment, including after
3
+ * service restart. Inventory UUIDs never filter the candidate set. */
4
+ export function collectNativeResumeOwnerCandidates(inventory, backends) {
5
+ const candidates = [];
6
+ const seenTmux = new Set(), seenBackend = new Set();
7
+ for (const entry of inventory) {
8
+ if (entry.mode !== 'tmux' || !entry.tmuxSessionName || seenTmux.has(entry.tmuxSessionName))
9
+ continue;
10
+ const backend = entry.backendSessionId ? backends.get(entry.backendSessionId) : null;
11
+ candidates.push({ backendSessionId: entry.backendSessionId ?? `inventory:${entry.sessionId}`,
12
+ sessionId: entry.sessionId, backendAttached: !!backend,
13
+ tmuxSessionName: entry.tmuxSessionName, cachedSlug: backend?.agent?.slug ?? entry.agentResume?.slug ?? null,
14
+ cachedNativeId: backend?.agentSession?.sessionId ?? entry.agentResume?.sessionId ?? null });
15
+ seenTmux.add(entry.tmuxSessionName);
16
+ if (entry.backendSessionId)
17
+ seenBackend.add(entry.backendSessionId);
18
+ }
19
+ for (const [backendSessionId, backend] of backends) {
20
+ if (seenBackend.has(backendSessionId) || backend.mode === 'tmux' && backend.tmuxSessionName && seenTmux.has(backend.tmuxSessionName))
21
+ continue;
22
+ candidates.push({ backendSessionId, sessionId: inventory.find(entry => entry.backendSessionId === backendSessionId)?.sessionId ?? null,
23
+ backendAttached: true, tmuxSessionName: backend.mode === 'tmux' ? backend.tmuxSessionName : null,
24
+ cachedSlug: backend.agent?.slug ?? null, cachedNativeId: backend.agentSession?.sessionId ?? null });
25
+ if (backend.mode === 'tmux' && backend.tmuxSessionName)
26
+ seenTmux.add(backend.tmuxSessionName);
27
+ }
28
+ return candidates;
29
+ }
30
+ export class NativeResumeOwnerError extends CollaborationError {
31
+ diagnostics;
32
+ constructor(code, blockers) {
33
+ super(code, code === 'NATIVE_SESSION_ALREADY_RUNNING'
34
+ ? 'Another terminal process proves the exact native conversation is running'
35
+ : 'A possible native owner cannot be verified; inspect its terminal before restoring', 409);
36
+ // Preserve the proven owner even when earlier uncertain panes exceed the bound.
37
+ const ordered = code === 'NATIVE_SESSION_ALREADY_RUNNING' ? [blockers.at(-1), ...blockers.slice(0, -1)] : blockers;
38
+ this.diagnostics = { code, checked_at: Date.now(), candidates: ordered.slice(0, 128), total_blockers: blockers.length, truncated: blockers.length > 128 };
39
+ }
40
+ }
41
+ function diagnostic(candidate, process, reason) {
42
+ // Store only named locator/evidence fields. Never serialize a callback error,
43
+ // argv, terminal text, cwd, or plugin/wrapper payload into diagnostics.
44
+ const text = (value) => typeof value === 'string' && value.length <= 256 && !/[\x00-\x1f\x7f]/.test(value) ? value : null;
45
+ const pid = (value) => Number.isInteger(value) && value > 0 ? value : null;
46
+ return { session_id: text(candidate.sessionId), backend_session_id: candidate.backendSessionId.startsWith('inventory:') ? null : text(candidate.backendSessionId),
47
+ backend_attached: candidate.backendAttached ?? null, tmux_session_name: text(candidate.tmuxSessionName),
48
+ tmux_session_id: text(process?.tmuxSessionId), pane_id: text(process?.paneId), pane_pid: pid(process?.panePid),
49
+ program: text(process?.program), process_source: text(process?.processSource), arguments_observed: process?.argumentsObserved === true,
50
+ agent_slug: text(process?.agentSlug), last_known_native_id: text(candidate.cachedNativeId), observed_native_id: text(process?.nativeId),
51
+ pgid: Number.isInteger(process?.pgid) ? process.pgid : null,
52
+ tpgid: Number.isInteger(process?.tpgid) ? process.tpgid : null, reason, observed_at: Date.now() };
53
+ }
54
+ /** Only process observations prove presence or absence of another owner.
55
+ * Cached UUIDs cannot exclude a terminal; the caller verifies its own pane. */
4
56
  export async function assertNativeResumeAvailable(target, excludedBackendSessionId, candidates, observe) {
5
- let uncertain = false;
57
+ const blockers = [];
6
58
  for (const candidate of candidates) {
7
59
  if (candidate.backendSessionId === excludedBackendSessionId)
8
60
  continue;
9
- const cachedMatch = candidate.cachedSlug === target.slug && candidate.cachedNativeId === target.nativeSessionId;
10
61
  let processes;
11
62
  try {
12
63
  processes = await observe(candidate);
13
64
  }
14
65
  catch {
15
- if (cachedMatch)
16
- uncertain = true;
66
+ blockers.push(diagnostic(candidate, null, 'TERMINAL_OBSERVATION_FAILED'));
17
67
  continue;
18
68
  }
19
69
  for (const process of processes) {
20
70
  if (process.confirmed && process.agentSlug === target.slug && process.nativeId === target.nativeSessionId) {
21
- integrationError('NATIVE_SESSION_ALREADY_RUNNING', 'Another terminal process proves the exact native conversation is running', 409);
71
+ blockers.push(diagnostic(candidate, process, 'NATIVE_SESSION_MATCH'));
72
+ throw new NativeResumeOwnerError('NATIVE_SESSION_ALREADY_RUNNING', blockers);
22
73
  }
23
- if (cachedMatch && (!process.confirmed || process.agentSlug === target.slug && !process.nativeId))
24
- uncertain = true;
74
+ if (!process.confirmed)
75
+ blockers.push(diagnostic(candidate, process, process.shellFailure
76
+ ?? (process.argumentsObserved === false ? 'PROCESS_ARGUMENTS_UNAVAILABLE' : 'FOREGROUND_PROCESS_UNCONFIRMED')));
77
+ else if (process.agentSlug === target.slug && !process.nativeId)
78
+ blockers.push(diagnostic(candidate, process, 'NATIVE_SESSION_ID_MISSING'));
25
79
  }
26
80
  }
27
- if (uncertain)
28
- integrationError('NATIVE_SESSION_OWNER_UNCONFIRMED', 'A possible native owner cannot be verified; inspect its terminal before restoring', 409);
81
+ if (blockers.length)
82
+ throw new NativeResumeOwnerError('NATIVE_SESSION_OWNER_UNCONFIRMED', blockers);
29
83
  }
@@ -1,6 +1,6 @@
1
1
  import { collaborationSessionDisplayName } from '../utils/sessionDisplayName.js';
2
2
  import { integrationError } from '../agent/integrationStore.js';
3
- import { assertNativeResumeAvailable } from '../agent/nativeResumeOwner.js';
3
+ import { assertNativeResumeAvailable, collectNativeResumeOwnerCandidates } from '../agent/nativeResumeOwner.js';
4
4
  import { integrationLaunchCommand } from '../agent/integrationSessions.js';
5
5
  import { ensureTeam } from '../agent/collaborationTeam.js';
6
6
  import { androidRecordings } from '../android/recording.js';
@@ -1825,19 +1825,26 @@ async function spawnCollaborationAgentSession(req, group, sourceSessionId, input
1825
1825
  /** Restricted session adapter: only preconfigured argv and the exact owned
1826
1826
  * terminal can be launched; no remote drive or arbitrary shell commands. */
1827
1827
  async function integrationPaneIsIdleShell(pane) {
1828
+ return (await inspectIntegrationShell(pane)).confirmed;
1829
+ }
1830
+ async function inspectIntegrationShell(pane) {
1828
1831
  if (!pane.isShell)
1829
- return false;
1832
+ return { confirmed: false, failure: null, pgid: null, tpgid: null };
1830
1833
  try {
1831
1834
  const { stdout } = await execFileAsync('ps', ['-p', String(pane.panePid), '-o', 'pgid=,tpgid=,args='], { timeout: 3000, maxBuffer: 16384 });
1832
- const row = stdout.trim().match(/^(\d+)\s+(\d+)\s+(.+)$/);
1833
- if (!row || row[1] !== row[2])
1834
- return false;
1835
+ const row = stdout.trim().match(/^(-?\d+)\s+(-?\d+)\s+(.+)$/);
1836
+ if (!row)
1837
+ return { confirmed: false, failure: 'SHELL_PROCESS_NOT_FOUND', pgid: null, tpgid: null };
1838
+ const pgid = Number(row[1]), tpgid = Number(row[2]);
1839
+ if (pgid <= 0 || pgid !== tpgid)
1840
+ return { confirmed: false, failure: 'SHELL_FOREGROUND_MISMATCH', pgid, tpgid };
1835
1841
  const argv = splitCommandToArgv(row[3]);
1836
1842
  const command = normalizeProgramName(argv[0]?.replace(/^-/, ''))?.toLowerCase();
1837
- return !!command && shellNamesBackend.has(command) && argv.slice(1).every(arg => ['-l', '-i', '-il', '-li', '--login', '--interactive'].includes(arg));
1843
+ const confirmed = !!command && shellNamesBackend.has(command) && argv.slice(1).every(arg => ['-l', '-i', '-il', '-li', '--login', '--interactive'].includes(arg));
1844
+ return { confirmed, failure: confirmed ? null : 'SHELL_ARGUMENTS_UNSUPPORTED', pgid, tpgid };
1838
1845
  }
1839
1846
  catch {
1840
- return false;
1847
+ return { confirmed: false, failure: 'SHELL_OBSERVATION_FAILED', pgid: null, tpgid: null };
1841
1848
  }
1842
1849
  }
1843
1850
  function integrationTerminalRecord(record) {
@@ -1910,43 +1917,44 @@ async function assertIntegrationNativeResumeAvailable(record) {
1910
1917
  // Never use a recovered hook or the last-known UUID as live-owner proof.
1911
1918
  if (process.platform === 'linux')
1912
1919
  await getProcessSnapshot(true);
1913
- const candidates = [...terminalSessions].map(([backendSessionId, backend]) => {
1914
- const persisted = globalSessionState.sessions.find(entry => entry.backendSessionId === backendSessionId)?.agentResume;
1915
- return { backendSessionId, cachedSlug: backend.agent?.slug ?? persisted?.slug ?? null,
1916
- cachedNativeId: backend.agentSession?.sessionId ?? persisted?.sessionId ?? null };
1917
- });
1920
+ const candidates = collectNativeResumeOwnerCandidates(globalSessionState.sessions, terminalSessions);
1918
1921
  await assertNativeResumeAvailable({ slug: record.agent_slug, nativeSessionId: record.agent_native_session_id }, own?.backendSessionId ?? null, candidates, async (candidate) => {
1919
1922
  const backend = terminalSessions.get(candidate.backendSessionId);
1920
- if (!backend)
1921
- return [];
1923
+ const tmuxSessionName = candidate.tmuxSessionName;
1922
1924
  // Duplicate inventory attachments to the original owned pane are not
1923
1925
  // another process; launchIntegrationTerminal verifies that pane again.
1924
- if (backend.mode === 'tmux' && backend.tmuxSessionName === own?.tmuxSessionName)
1926
+ if (tmuxSessionName && tmuxSessionName === own?.tmuxSessionName)
1925
1927
  return [];
1926
- if (backend.mode === 'tmux' && backend.tmuxSessionName) {
1928
+ if (tmuxSessionName) {
1927
1929
  try {
1928
- await runTmux(['has-session', '-t', `=${backend.tmuxSessionName}`]);
1930
+ await runTmux(['has-session', '-t', `=${tmuxSessionName}`]);
1929
1931
  }
1930
1932
  catch (error) {
1931
1933
  if (/can't find session|no server running|no sessions|error connecting.*No such file/i.test(getErrorMessage(error)))
1932
1934
  return [];
1933
1935
  throw error;
1934
1936
  }
1935
- const layout = await getTmuxLayout(backend.tmuxSessionName);
1937
+ const layout = await getTmuxLayout(tmuxSessionName);
1936
1938
  return Promise.all(layout.windows.flatMap(window => window.panes).map(async (pane) => {
1937
1939
  const program = await resolveTmuxPaneProgram(pane, true, true);
1938
1940
  const agent = detectAgentFromCommand(program?.rawArgs ?? program?.command ?? '', agentCustomCommands());
1939
- const shell = !agent && await integrationPaneIsIdleShell({ panePid: pane.pid,
1940
- isShell: shellNamesBackend.has(normalizeProgramName(program?.command ?? '')?.toLowerCase() ?? '') });
1941
- return { confirmed: shell || program?.source === 'tmux-tty' && Boolean(program.rawArgs),
1941
+ const shell = await inspectIntegrationShell({ panePid: pane.pid,
1942
+ isShell: !agent && shellNamesBackend.has(normalizeProgramName(program?.command ?? '')?.toLowerCase() ?? '') });
1943
+ return { confirmed: shell.confirmed || program?.source === 'tmux-tty' && Boolean(program.rawArgs),
1942
1944
  agentSlug: agent?.slug ?? null,
1943
- nativeId: agent && program?.rawArgs ? inferResumeSessionId(agent, splitCommandToArgv(program.rawArgs)) : null };
1945
+ nativeId: agent && program?.rawArgs ? inferResumeSessionId(agent, splitCommandToArgv(program.rawArgs)) : null,
1946
+ paneId: pane.id, panePid: pane.pid, tmuxSessionId: layout.sessionId, program: program?.command ?? null,
1947
+ processSource: program?.source, argumentsObserved: !!program?.rawArgs,
1948
+ shellFailure: shell.failure, pgid: shell.pgid, tpgid: shell.tpgid };
1944
1949
  }));
1945
1950
  }
1951
+ if (!backend)
1952
+ return [];
1946
1953
  const program = await detectShellActiveProgram(backend);
1947
1954
  const agent = detectAgentFromCommand(program?.rawArgs ?? program?.command ?? '', agentCustomCommands());
1948
1955
  return [{ confirmed: !!program && program.source !== 'unknown', agentSlug: agent?.slug ?? null,
1949
- nativeId: agent && program?.rawArgs ? inferResumeSessionId(agent, splitCommandToArgv(program.rawArgs)) : null }];
1956
+ nativeId: agent && program?.rawArgs ? inferResumeSessionId(agent, splitCommandToArgv(program.rawArgs)) : null,
1957
+ program: program?.command ?? null, processSource: program?.source, argumentsObserved: !!program?.rawArgs }];
1950
1958
  });
1951
1959
  }
1952
1960
  async function launchIntegrationTerminal(record, restoring, prepared) {
@@ -4,7 +4,9 @@
4
4
  启动输入条件与保留凭据的策略更新要求 **CLI / 实际运行服务均至少 1.4.301**;协议仍为 1。
5
5
  插件原生 ID 的前台 argv 回读及服务重启后的运行中 restore 核验修复要求
6
6
  **实际运行服务至少 1.4.302**。退出后的重复原生会话检查修复要求
7
- **实际运行服务至少 1.4.303**;建议 CLI 与服务同步安装 1.4.303(CLI 最低仍为 1.4.301)。
7
+ **实际运行服务至少 1.4.303**。没有 PTY 附着的已登记 tmux 的重复保护修复要求
8
+ **实际运行服务至少 1.4.304**。管理员恢复诊断要求 **CLI / 实际服务均至少 1.4.305**;
9
+ 建议 CLI 与服务同步安装 1.4.305(既有接入命令的 CLI 最低仍为 1.4.301)。
8
10
  现有 `--session`、消息投递、任务报告与自动协作入口继续兼容;旧的
9
11
  `task create --integration` 仍表示代码集成子任务,新身份使用 `--principal`。
10
12
 
@@ -303,9 +305,61 @@ operation/pane,不启动第二个 Agent;进程携带不同 UUID 时继续拒
303
305
  返回 `NATIVE_SESSION_OWNER_UNCONFIRMED` 并停止恢复;应检查该终端,不绕过保护。
304
306
  此检查覆盖服务已登记终端,不声称枚举其他应用中全部原生会话。
305
307
 
308
+ 1.4.304 起,检查合并持久终端清单和当前 PTY backend;服务重启后无需网页挂接,
309
+ 已登记 tmux 的每个 pane 都会现场核验。旧登记 UUID C 不会排除当前实际运行 UUID A
310
+ 的终端,终端的协作路由为 recovering/offline 或绑定不匹配也不会跳过检查。
311
+ 仅在确认 tmux 已消失或当前进程排除冲突时继续;未知进程、同 Agent 缺少精确 UUID、
312
+ 观察失败均返回 `NATIVE_SESSION_OWNER_UNCONFIRMED`,不以旧 UUID 证明没有冲突。
313
+
314
+ ### 管理员恢复诊断(1.4.305)
315
+
316
+ `capabilities.session_restore_diagnostics=true` 表示服务支持持久恢复诊断。
317
+ 遇到 `NATIVE_SESSION_OWNER_UNCONFIRMED` 或 `NATIVE_SESSION_ALREADY_RUNNING` 后,
318
+ 本机管理员执行:
319
+
320
+ ```sh
321
+ td integration diagnostics <TD-session-id>
322
+ ```
323
+
324
+ 命令经原私有 Unix socket 和本机管理员凭据读取报告,不使用 `--principal`。
325
+ 服务能力缺失时返回 `SESSION_RESTORE_DIAGNOSTICS_UNSUPPORTED`,不会假装诊断成功。
326
+ 命令仅读取已存记录,不重新扫描进程、不挂接 PTY、不试键、不创建恢复意图。
327
+ 304 及更早的失败没有此报告;升级后应使用新的 restore 幂等键执行一次明确检查,
328
+ 再读报告。旧键不会执行一次新检查。
329
+
330
+ 响应包含当前 `session_id / operation_id / state / error_code`,以及可为空的
331
+ `diagnostics`。报告含其自身 `operation_id / checked_at / code`、`candidates`、
332
+ `total_blockers / truncated`;它是该次检查的历史事实,不能代表读取时的当前进程。
333
+ 只有上一次**实际发起**的恢复检查会保存报告;下一次实际恢复清除旧报告,运行中
334
+ no-op restore 不创建新检查。报告跟随会话保存并可在服务重启后回读。
335
+
336
+ 每个阻塞候选含 `session_id / backend_session_id / backend_attached`、
337
+ `tmux_session_name / tmux_session_id / pane_id / pane_pid`、`program / process_source`、
338
+ `arguments_observed / agent_slug / last_known_native_id / observed_native_id`、
339
+ `pgid / tpgid / reason / observed_at`;无法观测的值为 null。backend 不存在或仅有旧
340
+ 登记仍可通过 tmux 核验;`pane_pid` 是承载 pane 的 PID,不冒充 Agent 前台 PID。
341
+ 报告最多保存 128 个阻塞候选;`total_blockers` 保留总数,确定的同 UUID 所有者优先展示。
342
+
343
+ | reason | 实际证据缺口或冲突 |
344
+ | --- | --- |
345
+ | NATIVE_SESSION_MATCH | 当前前台进程明确携带目标 UUID |
346
+ | NATIVE_SESSION_ID_MISSING | 当前同 Agent 的进程已观测,但没有可回读的精确 UUID |
347
+ | PROCESS_ARGUMENTS_UNAVAILABLE | 无法取得可靠的当前进程 argv |
348
+ | FOREGROUND_PROCESS_UNCONFIRMED | 无法可靠核验前台进程 |
349
+ | SHELL_PROCESS_NOT_FOUND | shell 的 ps 输出未形成可解析记录 |
350
+ | SHELL_FOREGROUND_MISMATCH | shell 未持有有效前台进程组;记录 pgid/tpgid |
351
+ | SHELL_ARGUMENTS_UNSUPPORTED | 当前 shell 命令或启动参数未满足既有安全检查 |
352
+ | SHELL_OBSERVATION_FAILED | shell 的进程观察失败 |
353
+ | TERMINAL_OBSERVATION_FAILED | tmux 或终端观察失败,无法排除该候选 |
354
+
355
+ 完整候选可能来自其他组,因此仅管理员接口返回;principal 的 session 响应与推送
356
+ 事件只增加 `restore_diagnostics_available=true`,不含候选标识/UUID。报告不保存或
357
+ 返回原始 argv、cwd、终端正文、包装器异常、凭据。诊断不自动放宽任何拒绝条件,
358
+ 不能把旧 UUID、native ready 或 Agent 忙闲推测当作消除冲突的证据。
359
+
306
360
  常见失败码(每项都是完整字符串):
307
361
 
308
- - 配置/权限:`LAUNCH_PROFILE_DENIED`, `LAUNCHER_UNAVAILABLE`, `COLLAB_AGENT_UNAVAILABLE`, `SESSION_CWD_MISSING`, `SESSION_CWD_DENIED`, `INTEGRATION_SESSION_NOT_FOUND`, `INTEGRATION_SESSION_LIMIT`。
362
+ - 配置/权限:`LAUNCH_PROFILE_DENIED`, `LAUNCHER_UNAVAILABLE`, `COLLAB_AGENT_UNAVAILABLE`, `SESSION_CWD_MISSING`, `SESSION_CWD_DENIED`, `INTEGRATION_SESSION_NOT_FOUND`, `INTEGRATION_SESSION_LIMIT`, `SESSION_RESTORE_DIAGNOSTICS_UNSUPPORTED`。
309
363
  - 终端/身份:`SESSION_BACKEND_UNAVAILABLE`, `SESSION_TARGET_NOT_SHELL`, `SESSION_IDENTITY_MISMATCH`, `NATIVE_SESSION_ID_MISMATCH`, `NATIVE_SESSION_ID_INVALID`, `NATIVE_SESSION_ID_MISSING`。
310
364
  - 启动/恢复:`SESSION_CREATE_OUTCOME_UNKNOWN`, `SESSION_OPERATION_IN_PROGRESS`, `SESSION_BINDING_TIMEOUT`, `EXACT_RESUME_UNSUPPORTED`, `NATIVE_SESSION_ALREADY_RUNNING`, `NATIVE_SESSION_OWNER_UNCONFIRMED`, `AGENT_NOT_RUNNING`。
311
365
  - 未分类异常:`SESSION_LAUNCH_FAILED`, `SESSION_OBSERVATION_FAILED`;错误不包含包装器输出、密钥或原始堆栈。
@@ -325,7 +379,13 @@ operation/pane,不启动第二个 Agent;进程携带不同 UUID 时继续拒
325
379
  不重复启动、不同 UUID 拒绝,以及进程退出后缓存 UUID 不再阻挡恢复。使用方已报告
326
380
  1.4.302 真实 TraeX 服务重启回读、运行中恢复不重启、不同 UUID 拒绝通过;显式恢复
327
381
  原 UUID 也已回读 ready,但配置的模型 footer 条件被暂态提示替换,输入仍保持待投递。
328
- **1.4.303 的真实 TraeX 退出后恢复与稳定输入条件仍须使用方实机复验**。
382
+ 使用方已报告 1.4.303 退出后精确恢复通过,也发现未挂接 PTY 的已登记 tmux 重复保护
383
+ 遗漏;维护机已用“旧 UUID C、实际运行 UUID A、无 PTY 附着”的真实隔离 tmux 复现,
384
+ 并验证 1.4.304 编译后检查在该状态下拒绝再次启动。
385
+ 使用方已报告 1.4.304 在未挂接网页/PTY、旧登记 UUID C 而实际运行 A 的场景下,
386
+ 正确拒绝同 UUID 恢复且没有第二个进程。所有者退出后仍有未确认候选,不能仅凭
387
+ 错误码判断是哪一个 pane;1.4.305 提供管理员持久诊断,并保持原拒绝条件。
388
+ **1.4.305 的真实 TraeX 阻塞候选诊断与稳定输入条件仍须使用方实机复验**。
329
389
  回读 capabilities 后,应核对原 TD ID、真实 TraeX UUID 与原 argv/cwd/包装器快照,
330
390
  并验证 Agent 真正加载原对话。
331
391
  先完成测试环境的创建→追问→关联答复,再验重复键、追加咨询、断线重投和精确恢复。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "termdock",
3
- "version": "1.4.303",
3
+ "version": "1.4.305",
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.303",
4
+ "version": "1.4.305",
5
5
  "runtimeProtocolVersion": 1,
6
6
  "minimumDesktopVersion": "1.4.46",
7
7
  "nodeMajor": 22,
8
8
  "dependencyHash": "sha256-E9GDa4nrCXADRXdyPKufusZt6IcCr4m2mGdx5h34KMQ=",
9
- "serverBundleHash": "sha256-+P0Zwo5Zg52vSfyTrvxcfAIAuwY0+n4i4f0kTf8BC0g=",
9
+ "serverBundleHash": "sha256-XkM6+bMn6xdVpuXe8oaPunofP3J+MgVfh7lwiOhSY4I=",
10
10
  "clientBundleHash": "sha256-HOoNMOYhnph9XwUhSYE5W2CdI9Vb4g0c64ubW49LnXE=",
11
11
  "entrypoint": "dist/server/cli.js",
12
12
  "clientEntrypoint": "dist/client/index.html"