termdock 1.4.301 → 1.4.303

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.
@@ -0,0 +1,29 @@
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. */
4
+ export async function assertNativeResumeAvailable(target, excludedBackendSessionId, candidates, observe) {
5
+ let uncertain = false;
6
+ for (const candidate of candidates) {
7
+ if (candidate.backendSessionId === excludedBackendSessionId)
8
+ continue;
9
+ const cachedMatch = candidate.cachedSlug === target.slug && candidate.cachedNativeId === target.nativeSessionId;
10
+ let processes;
11
+ try {
12
+ processes = await observe(candidate);
13
+ }
14
+ catch {
15
+ if (cachedMatch)
16
+ uncertain = true;
17
+ continue;
18
+ }
19
+ for (const process of processes) {
20
+ 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);
22
+ }
23
+ if (cachedMatch && (!process.confirmed || process.agentSlug === target.slug && !process.nativeId))
24
+ uncertain = true;
25
+ }
26
+ }
27
+ if (uncertain)
28
+ integrationError('NATIVE_SESSION_OWNER_UNCONFIRMED', 'A possible native owner cannot be verified; inspect its terminal before restoring', 409);
29
+ }
@@ -220,7 +220,7 @@ function replayFlags(agent, argv) {
220
220
  * the only durable source available for a future recovery.
221
221
  */
222
222
  export function inferResumeSessionId(agent, argv) {
223
- const safeId = (value) => value && /^[A-Za-z0-9._-]+$/.test(value) ? value : null;
223
+ const safeId = (value) => value && !value.startsWith('-') && /^[A-Za-z0-9._-]+$/.test(value) ? value : null;
224
224
  let named = -1;
225
225
  for (let index = 0; index < argv.length; index += 1) {
226
226
  if (tokenNamesAgent(argv[index], agent)) {
@@ -231,6 +231,36 @@ export function inferResumeSessionId(agent, argv) {
231
231
  if (named < 0)
232
232
  return null;
233
233
  const tail = argv.slice(named + 1);
234
+ if (agent.isPlugin) {
235
+ const template = getPluginResumeConfig(agent.slug)?.command.trim().split(/\s+/);
236
+ if (!template || !tokenNamesAgent(template[0], agent))
237
+ return null;
238
+ const pattern = template.slice(1);
239
+ if (pattern.join(' ').split('{sessionId}').length !== 2)
240
+ return null;
241
+ const matches = new Set();
242
+ // A positional resume command must start the argument tail. Flag-based
243
+ // templates may follow other launch flags, as for built-in --resume.
244
+ const starts = pattern[0]?.startsWith('-') ? tail.map((_, index) => index) : [0];
245
+ for (const start of starts) {
246
+ let id = null;
247
+ const matched = pattern.every((token, index) => {
248
+ const value = tail[start + index];
249
+ if (value === undefined)
250
+ return false;
251
+ if (!token.includes('{sessionId}'))
252
+ return token === value;
253
+ const [prefix, suffix] = token.split('{sessionId}');
254
+ if (!value.startsWith(prefix) || !value.endsWith(suffix))
255
+ return false;
256
+ id = safeId(value.slice(prefix.length, suffix ? -suffix.length : undefined));
257
+ return !!id;
258
+ });
259
+ if (matched && id)
260
+ matches.add(id);
261
+ }
262
+ return matches.size === 1 ? [...matches][0] : null;
263
+ }
234
264
  if (agent.slug === 'codex') {
235
265
  const resume = tail.indexOf('resume');
236
266
  return resume >= 0 ? safeId(tail[resume + 1]) : null;
@@ -1,5 +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
4
  import { integrationLaunchCommand } from '../agent/integrationSessions.js';
4
5
  import { ensureTeam } from '../agent/collaborationTeam.js';
5
6
  import { androidRecordings } from '../android/recording.js';
@@ -1596,7 +1597,9 @@ async function inspectCollaborationTmux(binding, requestedPane, allowPlainPane =
1596
1597
  if (!Number.isInteger(serverPid) || serverPid <= 0)
1597
1598
  throw new Error('TMUX_SERVER_IDENTITY_UNAVAILABLE');
1598
1599
  const panes = await Promise.all(layout.windows.flatMap((window) => window.panes).map(async (pane) => {
1599
- const program = await resolveTmuxPaneProgram(pane);
1600
+ // Native identity needs the foreground argv even for a named binary;
1601
+ // pane_current_command alone cannot prove an exact resumed conversation.
1602
+ const program = await resolveTmuxPaneProgram(pane, true);
1600
1603
  const agent = detectAgentFromCommand(program?.rawArgs ?? program?.command ?? '', agentCustomCommands());
1601
1604
  const nativeSessionId = agent && program?.rawArgs ? inferResumeSessionId(agent, splitCommandToArgv(program.rawArgs)) : null;
1602
1605
  return { serverPid, sessionId: layout.sessionId, paneId: pane.id, panePid: pane.pid,
@@ -1897,11 +1900,55 @@ export const integrationSessionAdapter = {
1897
1900
  const agent = agentBySlug(record.agent_slug);
1898
1901
  if (!record.agent_native_session_id || !agent || !buildResumeCommand(agent, record.agent_native_session_id, null))
1899
1902
  integrationError('EXACT_RESUME_UNSUPPORTED', 'Agent plugin must support exact native resume');
1900
- if (findActiveAgentResumeOwner('', { slug: record.agent_slug, nativeSessionId: record.agent_native_session_id, command: '' }))
1901
- integrationError('NATIVE_SESSION_ALREADY_RUNNING', 'Native Agent conversation is already running', 409);
1903
+ await assertIntegrationNativeResumeAvailable(record);
1902
1904
  await launchIntegrationTerminal(record, true, prepared);
1903
1905
  },
1904
1906
  };
1907
+ async function assertIntegrationNativeResumeAvailable(record) {
1908
+ const own = integrationTerminalRecord(record);
1909
+ // Refresh the shared Linux process snapshot once for this restore decision.
1910
+ // Never use a recovered hook or the last-known UUID as live-owner proof.
1911
+ if (process.platform === 'linux')
1912
+ 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
+ });
1918
+ await assertNativeResumeAvailable({ slug: record.agent_slug, nativeSessionId: record.agent_native_session_id }, own?.backendSessionId ?? null, candidates, async (candidate) => {
1919
+ const backend = terminalSessions.get(candidate.backendSessionId);
1920
+ if (!backend)
1921
+ return [];
1922
+ // Duplicate inventory attachments to the original owned pane are not
1923
+ // another process; launchIntegrationTerminal verifies that pane again.
1924
+ if (backend.mode === 'tmux' && backend.tmuxSessionName === own?.tmuxSessionName)
1925
+ return [];
1926
+ if (backend.mode === 'tmux' && backend.tmuxSessionName) {
1927
+ try {
1928
+ await runTmux(['has-session', '-t', `=${backend.tmuxSessionName}`]);
1929
+ }
1930
+ catch (error) {
1931
+ if (/can't find session|no server running|no sessions|error connecting.*No such file/i.test(getErrorMessage(error)))
1932
+ return [];
1933
+ throw error;
1934
+ }
1935
+ const layout = await getTmuxLayout(backend.tmuxSessionName);
1936
+ return Promise.all(layout.windows.flatMap(window => window.panes).map(async (pane) => {
1937
+ const program = await resolveTmuxPaneProgram(pane, true, true);
1938
+ 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),
1942
+ agentSlug: agent?.slug ?? null,
1943
+ nativeId: agent && program?.rawArgs ? inferResumeSessionId(agent, splitCommandToArgv(program.rawArgs)) : null };
1944
+ }));
1945
+ }
1946
+ const program = await detectShellActiveProgram(backend);
1947
+ const agent = detectAgentFromCommand(program?.rawArgs ?? program?.command ?? '', agentCustomCommands());
1948
+ return [{ confirmed: !!program && program.source !== 'unknown', agentSlug: agent?.slug ?? null,
1949
+ nativeId: agent && program?.rawArgs ? inferResumeSessionId(agent, splitCommandToArgv(program.rawArgs)) : null }];
1950
+ });
1951
+ }
1905
1952
  async function launchIntegrationTerminal(record, restoring, prepared) {
1906
1953
  await collaborationDeliveryWorker.reconfigure(record.session_id, async () => {
1907
1954
  const group = collaborationStore.getGroup(record.group_id);
@@ -3824,9 +3871,9 @@ function parseProcessSnapshot(stdout) {
3824
3871
  })
3825
3872
  .filter((row) => row !== null);
3826
3873
  }
3827
- async function getProcessSnapshot() {
3874
+ async function getProcessSnapshot(refresh = false) {
3828
3875
  const now = Date.now();
3829
- if (processSnapshot && now - processSnapshot.fetchedAt < PROCESS_SNAPSHOT_CACHE_TTL_MS) {
3876
+ if (!refresh && processSnapshot && now - processSnapshot.fetchedAt < PROCESS_SNAPSHOT_CACHE_TTL_MS) {
3830
3877
  return processSnapshot.rows;
3831
3878
  }
3832
3879
  if (processSnapshotPromise)
@@ -3844,14 +3891,14 @@ async function getProcessSnapshot() {
3844
3891
  });
3845
3892
  return processSnapshotPromise;
3846
3893
  }
3847
- async function resolveTmuxPaneProgram(pane) {
3894
+ async function resolveTmuxPaneProgram(pane, requireArgs = false, refreshTty = false) {
3848
3895
  // If pane command is a known shell, try to find a child foreground process
3849
3896
  const command = normalizeProgramName(pane.command);
3850
3897
  const commandKey = command?.toLowerCase() ?? null;
3851
3898
  const isShell = commandKey ? shellNamesBackend.has(commandKey) : false;
3852
3899
  // If pane command is NOT a shell but also too generic (e.g. "node"), also try
3853
3900
  const isGeneric = commandKey ? genericProgramNames.has(commandKey) : false;
3854
- if (!isShell && !isGeneric) {
3901
+ if (!isShell && !isGeneric && !requireArgs) {
3855
3902
  // Non-shell, non-generic command — pane_current_command is good enough
3856
3903
  return { command, source: 'tmux-pane', rawArgs: null };
3857
3904
  }
@@ -3889,7 +3936,7 @@ async function resolveTmuxPaneProgram(pane) {
3889
3936
  };
3890
3937
  })
3891
3938
  .filter((row) => row !== null);
3892
- });
3939
+ }, refreshTty);
3893
3940
  }
3894
3941
  const selected = selectTmuxForegroundProgram({
3895
3942
  panePid: pane.pid,
@@ -2,6 +2,9 @@
2
2
 
3
3
  最低 CLI / 运行服务版本:**termdock 1.4.300**,两者都必须升级并重启服务。
4
4
  启动输入条件与保留凭据的策略更新要求 **CLI / 实际运行服务均至少 1.4.301**;协议仍为 1。
5
+ 插件原生 ID 的前台 argv 回读及服务重启后的运行中 restore 核验修复要求
6
+ **实际运行服务至少 1.4.302**。退出后的重复原生会话检查修复要求
7
+ **实际运行服务至少 1.4.303**;建议 CLI 与服务同步安装 1.4.303(CLI 最低仍为 1.4.301)。
5
8
  现有 `--session`、消息投递、任务报告与自动协作入口继续兼容;旧的
6
9
  `task create --integration` 仍表示代码集成子任务,新身份使用 `--principal`。
7
10
 
@@ -79,13 +82,19 @@ CLI 使用当前运行服务的端口,默认 9834。每个端口的身份、
79
82
 
80
83
  ```json
81
84
  "startupInput": {
82
- "allOf": ["model: GPT-5.5 (MAX) xhigh", "❯"],
85
+ "allOf": ["GPT-5.5 (MAX) xhigh", "❯"],
83
86
  "noneOf": ["model: loading"],
84
87
  "stableMs": 1000,
85
88
  "timeoutMs": 120000
86
89
  }
87
90
  ```
88
91
 
92
+ TraeX 的恢复画面可能仅在 footer 显示模型,没有 `model:` 标题;因此示例不要求
93
+ 该前缀。管理员须同时核对冷启动和恢复画面,条件不匹配时保持待投递。
94
+ 模型 footer 可能被 Git 错误或键盘提示替换,此时含模型文字的 `allOf` 条件不会
95
+ 匹配。请选择实际画面中稳定且能区分加载阶段的文字;TD 不用原生 UUID ready、
96
+ 历史模型文字或推测的忙闲状态代替配置条件,超时也不会自动放宽。
97
+
89
98
  `allOf` 的每个字面文本都须出现,`noneOf` 的每个文本都须缺席;区分大小写,
90
99
  连续横向空白折叠为一个空格,不使用正则或终端历史。每组最多 16 项,每项
91
100
  1–256 字符,禁止控制字符;`allOf` 不能为空。`stableMs` 默认 1000,允许
@@ -280,11 +289,25 @@ session 事件;get 也会现场核验。120 秒内未形成绑定返回 `SESSI
280
289
  显式精确恢复可以重建承载终端;这本身不证明 Agent 原生会话恢复成功。新参数/profile
281
290
  不能覆盖原启动快照,尚未确定结果的恢复拒绝另一次恢复,防止重复 Agent。
282
291
 
292
+ 1.4.302 起,绑定核验会读取原 pane 的实际前台进程 argv,即使
293
+ `pane_current_command` 已显示具体 Agent 程序名也不省略 argv。插件按已登记的
294
+ `resume.command` 模板回读精确 ID,支持位置参数及独立/内联 ID 标志;不使用
295
+ `--last`、缓存的 UUID 或画面内容替代进程证明。声明模板本身不证明恢复成功。
296
+ 服务重启后,原 pane 中精确 UUID 已在运行时 get 应回读 ready,restore 返回原
297
+ operation/pane,不启动第二个 Agent;进程携带不同 UUID 时继续拒绝。
298
+
299
+ 1.4.303 起,集成 restore 排除自己的已核验终端;其他已登记终端须用当前前台
300
+ 进程证明同一个 UUID 才返回 `NATIVE_SESSION_ALREADY_RUNNING`。已退出到 shell、
301
+ 已消失或实际运行另一 UUID 的终端,其缓存/last-known 记录不算运行中所有者。
302
+ 可能持有原 UUID 的终端无法核验、或相同 Agent 正在运行但没有精确 ID 证据时,
303
+ 返回 `NATIVE_SESSION_OWNER_UNCONFIRMED` 并停止恢复;应检查该终端,不绕过保护。
304
+ 此检查覆盖服务已登记终端,不声称枚举其他应用中全部原生会话。
305
+
283
306
  常见失败码(每项都是完整字符串):
284
307
 
285
308
  - 配置/权限:`LAUNCH_PROFILE_DENIED`, `LAUNCHER_UNAVAILABLE`, `COLLAB_AGENT_UNAVAILABLE`, `SESSION_CWD_MISSING`, `SESSION_CWD_DENIED`, `INTEGRATION_SESSION_NOT_FOUND`, `INTEGRATION_SESSION_LIMIT`。
286
309
  - 终端/身份:`SESSION_BACKEND_UNAVAILABLE`, `SESSION_TARGET_NOT_SHELL`, `SESSION_IDENTITY_MISMATCH`, `NATIVE_SESSION_ID_MISMATCH`, `NATIVE_SESSION_ID_INVALID`, `NATIVE_SESSION_ID_MISSING`。
287
- - 启动/恢复:`SESSION_CREATE_OUTCOME_UNKNOWN`, `SESSION_OPERATION_IN_PROGRESS`, `SESSION_BINDING_TIMEOUT`, `EXACT_RESUME_UNSUPPORTED`, `NATIVE_SESSION_ALREADY_RUNNING`, `AGENT_NOT_RUNNING`。
310
+ - 启动/恢复:`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`。
288
311
  - 未分类异常:`SESSION_LAUNCH_FAILED`, `SESSION_OBSERVATION_FAILED`;错误不包含包装器输出、密钥或原始堆栈。
289
312
 
290
313
  ## 验收边界
@@ -296,8 +319,13 @@ session 事件;get 也会现场核验。120 秒内未形成绑定返回 `SESSI
296
319
  维护机未安装 TraeX;已用真实 tmux/终端程序复现加载期间丢弃输入,并验证配置条件
297
320
  阻止早投、仅写一次,以及无原生 UUID 时仍可放行;这不冒充真实 TraeX 启动验收。
298
321
  使用方已报告 1.4.300 开发机真实 TraeX UUID 捕获与精确恢复通过,原对话记忆、
299
- 原 UUID、TD ID、attempt 和 artifact 保持一致。**1.4.301 的冷启动门控及超时诊断
300
- 仍须使用方升级 CLI/实际运行服务后实机复验**,维护机没有代替完成该项。
322
+ 原 UUID、TD ID、attempt 和 artifact 保持一致。使用方已报告 1.4.301 冷启动首条
323
+ 仅写一次并收到明确 complete,超时保持 pending/0 次写入,策略更新保持凭据。
324
+ 维护机另外用真实 tmux/native fixture 验证插件 argv 回读、记录重载后运行中恢复
325
+ 不重复启动、不同 UUID 拒绝,以及进程退出后缓存 UUID 不再阻挡恢复。使用方已报告
326
+ 1.4.302 真实 TraeX 服务重启回读、运行中恢复不重启、不同 UUID 拒绝通过;显式恢复
327
+ 原 UUID 也已回读 ready,但配置的模型 footer 条件被暂态提示替换,输入仍保持待投递。
328
+ **1.4.303 的真实 TraeX 退出后恢复与稳定输入条件仍须使用方实机复验**。
301
329
  回读 capabilities 后,应核对原 TD ID、真实 TraeX UUID 与原 argv/cwd/包装器快照,
302
330
  并验证 Agent 真正加载原对话。
303
331
  先完成测试环境的创建→追问→关联答复,再验重复键、追加咨询、断线重投和精确恢复。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "termdock",
3
- "version": "1.4.301",
3
+ "version": "1.4.303",
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.301",
4
+ "version": "1.4.303",
5
5
  "runtimeProtocolVersion": 1,
6
6
  "minimumDesktopVersion": "1.4.46",
7
7
  "nodeMajor": 22,
8
8
  "dependencyHash": "sha256-E9GDa4nrCXADRXdyPKufusZt6IcCr4m2mGdx5h34KMQ=",
9
- "serverBundleHash": "sha256-U9YIANwjHQwosOcYtOh1PeorKlMRwpJ51AdTlfY4+H0=",
9
+ "serverBundleHash": "sha256-+P0Zwo5Zg52vSfyTrvxcfAIAuwY0+n4i4f0kTf8BC0g=",
10
10
  "clientBundleHash": "sha256-HOoNMOYhnph9XwUhSYE5W2CdI9Vb4g0c64ubW49LnXE=",
11
11
  "entrypoint": "dist/server/cli.js",
12
12
  "clientEntrypoint": "dist/client/index.html"