@thincoder/core 0.10.8 → 0.10.9

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.
Files changed (72) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/agent/family-tools.mjs +2 -2
  3. package/agent/helpers.mjs +6 -6
  4. package/agent/post-turn.mjs +34 -0
  5. package/agent/run-stages.mjs +43 -9
  6. package/agent/setup.mjs +10 -2
  7. package/agent/turn-loop.mjs +6 -0
  8. package/agent-tools/audit-block.mjs +4 -2
  9. package/agent-tools/batch-gate.mjs +198 -0
  10. package/agent-tools/batch-lifecycle.mjs +216 -36
  11. package/agent-tools/batch-skeleton.mjs +87 -0
  12. package/agent-tools/batch.mjs +11 -3
  13. package/agent-tools/context.mjs +28 -5
  14. package/agent-tools/design-slots.mjs +7 -33
  15. package/agent-tools/doc-check.mjs +44 -6
  16. package/agent-tools/spawn-gates.mjs +100 -41
  17. package/agent-tools/ssh-session.mjs +108 -61
  18. package/agent-tools/ssh-transport.mjs +17 -4
  19. package/agent-tools/ssh-vault.mjs +291 -0
  20. package/agent-tools/subagent-spawn.mjs +18 -11
  21. package/agent-tools/subagent.mjs +13 -7
  22. package/agent-tools/timer.mjs +2 -2
  23. package/config-defaults.mjs +92 -0
  24. package/config-normalize.mjs +109 -0
  25. package/config.mjs +88 -197
  26. package/doc-check/anchors.mjs +54 -12
  27. package/doc-check/index.mjs +161 -12
  28. package/doc-check/version.mjs +84 -0
  29. package/doc-check/width.mjs +5 -4
  30. package/i18n.mjs +2 -0
  31. package/ledger-cmd.mjs +314 -61
  32. package/ledger-db.mjs +22 -10
  33. package/ledger-migrate.mjs +15 -9
  34. package/ledger-tools.mjs +115 -69
  35. package/ledger.mjs +13 -10
  36. package/log.mjs +2 -2
  37. package/manifest-schema.mjs +15 -1
  38. package/package.json +1 -1
  39. package/permission.mjs +7 -1
  40. package/prompts/advisor-design.md +21 -21
  41. package/prompts/advisor-round1.md +18 -18
  42. package/prompts/advisor-round2.md +22 -22
  43. package/prompts/advisor-round3.md +24 -24
  44. package/prompts/common.md +108 -93
  45. package/prompts/consult-base.md +8 -8
  46. package/prompts/discipline-engineering.md +68 -65
  47. package/prompts/discipline-normal.md +69 -68
  48. package/prompts/persona-coder.md +6 -6
  49. package/prompts/persona-eng-coder.md +19 -18
  50. package/prompts/persona-eng-designer.md +36 -32
  51. package/prompts/persona-engineering.md +114 -98
  52. package/prompts/persona-explore.md +2 -2
  53. package/prompts/persona-normal.md +16 -16
  54. package/prompts/persona-plan.md +7 -7
  55. package/prompts/subagent-base.md +4 -4
  56. package/provider/anthropic.mjs +4 -2
  57. package/provider/core.mjs +4 -2
  58. package/provider/google.mjs +5 -1
  59. package/provider/responses-request.mjs +3 -2
  60. package/session-lifecycle.mjs +6 -6
  61. package/team.mjs +2 -1
  62. package/token-window.mjs +1 -1
  63. package/tool-docs/batch.md +1 -1
  64. package/tool-docs/doc_check.md +30 -7
  65. package/tool-docs/fetch.md +3 -2
  66. package/tool-docs/ledger.md +7 -5
  67. package/tool-docs/ssh.md +23 -6
  68. package/tool-docs/subagent.md +6 -11
  69. package/tool-docs/websearch.md +3 -1
  70. package/tools/ssh.mjs +24 -11
  71. package/tools/web-deepseek.mjs +172 -0
  72. package/tools/web.mjs +29 -4
@@ -1,19 +1,28 @@
1
1
  /**
2
- * agent-tools/doc-check.mjs — `doc_check` 工具:文档纪律机检(产品内建 · 报告态 · 非门禁)。
2
+ * agent-tools/doc-check.mjs — `doc_check` 工具:文档纪律机检(产品内建 · 两种问法)。
3
3
  *
4
- * 设计 = `docs/core/design/DOC-DISCIPLINE.md` §3.20 条目 S(起步两条判据 = 锚 ∥ 行宽 · 声明面驱动)。
5
- * 工具形:**单动作**(无 action 枚举——不预建空枚举)· **只读**(零副作用 · 免审批 · 可并行)·
6
- * 参数 `target` 可选(缺省 = 会话锚);`target` 指向多项目工作区 ⇒ 报明候选并要求指名(同 `manifest` 工具面)。
4
+ * 设计 = `docs/core/design/DOC-DISCIPLINE.md` §3.20 条目 S(判据 = 锚 ∥ 行宽 · 声明面驱动)。
5
+ * 工具形:**无动作枚举**——`mode` = 同一条判据的两种问法(`report` = 全域报告态 ∥ `gate` = 交付时点
6
+ * 的范围化裁定);**只读**(零副作用 · 免审批 · 可并行);参数 `target` / `mode` / `docs`
7
+ * (`target` 指向多项目工作区 ⇒ 报明候选并要求指名,同 `manifest` 工具面)。
7
8
  * 判据参数读目标项目的 `PROJECT-MANIFEST.json` `checkConfig`——不硬编码任何本仓路径(换项目开箱可用)。
8
- * 非门禁(硬):输出不得被读作拒执行的理由(用户令 > 机检——§2 总则族)。
9
+ * `report` 非门禁(硬):输出不得被读作拒执行的理由(用户令 > 机检——§2 总则族);
10
+ * `gate` 的强制面住交付协议 + 批次档 §6 前置,工具本体同样不阻断任何动作。
11
+ * gate 前置(fail-closed)= 引擎版本一致:本会话进程持启动时加载的 ESM 引擎,引擎档晚于启动 ⇒ ERROR
12
+ * (判据单源 = `doc-check/version.mjs`——与 `batch-gate.mjs` 共用一份清单,不各持一份)。
9
13
  * 描述外置:模型可见文本单点 = `tool-docs/doc_check.md`(`DESC` 单一解析面)。
10
14
  * 装配段 = `agent/family-tools.mjs` 三处(depth-0 / eng-coder / eng-designer)——写文档 / 设计档的角色面。
11
15
  */
12
16
  import { resolve } from "node:path"
13
17
  import { projectRootView } from "../manifest.mjs"
14
18
  import { runDocCheck } from "../doc-check/index.mjs"
19
+ import { staleEngineFiles, gateEngineError, withEngineNote } from "../doc-check/version.mjs"
15
20
  import { DESC } from "../tools/shared.mjs"
16
21
 
22
+ // 版本函数单源 = `doc-check/version.mjs`(两消费面:本档 gate 分支 ∥ `batch-gate.mjs` 两机械点)——
23
+ // 本档只转口,导出面不变(原五档清单已随之六档:`version.mjs` 自身入清单)。
24
+ export { staleEngineFiles, gateEngineError }
25
+
17
26
  export const docCheckTool = {
18
27
  name: "doc_check",
19
28
  description: DESC("doc_check"),
@@ -21,17 +30,46 @@ export const docCheckTool = {
21
30
  type: "object",
22
31
  properties: {
23
32
  target: { type: "string", description: "Explicit project root to check — defaults to the session anchor; relative paths resolve against it. A workspace target (several projects below it) is reported with the full candidate list — pass the project root you mean." },
33
+ mode: { type: "string", enum: ["report", "gate"], description: "How to ask the same two-criteria check. \"report\" (default): the whole declared domain, non-gating report. \"gate\": the delivery gate — only the documents this round touched, whole-file judged, first line a machine-readable verdict (DOC-CHECK GATE: PASS / REFUSED / ERROR)." },
34
+ docs: { type: "array", items: { type: "string" }, description: "Gate only: the documents this round touched (paths relative to the project root, or absolute). Omitted in gate mode ⇒ the documents this session wrote. Passing docs with mode \"report\" is refused, not silently ignored." },
24
35
  },
25
36
  },
26
37
  readonly: true,
27
38
  execute(args, ctx) {
28
39
  const anchor = ctx?.agent?.cwd ?? ctx?.cwd ?? process.cwd()
29
40
  const target = args?.target ? resolve(anchor, String(args.target)) : resolve(anchor)
41
+ const mode = args?.mode === undefined ? "report" : String(args.mode)
42
+ if (mode !== "report" && mode !== "gate") return `doc_check: unknown mode "${mode}" — use "report" (default: the whole declared domain) or "gate" (this round's documents).`
43
+ const explicit = Array.isArray(args?.docs) ? args.docs.map(String) : null
44
+ if (args?.docs !== undefined && !Array.isArray(args.docs)) {
45
+ return `doc_check: \`docs\` must be an array of paths (got ${typeof args.docs}) — a bare string is refused rather than read as a whole-domain request; pass ["<path>"].`
46
+ }
47
+ if (mode === "report" && explicit) {
48
+ return "doc_check: `docs` is only valid with mode:\"gate\" — report mode always checks the whole declared domain; pass mode:\"gate\" to scope the check to the documents this round touched."
49
+ }
30
50
  const view = projectRootView(target)
31
51
  if (view.state === "workspace") {
52
+ if (mode === "gate") {
53
+ return [
54
+ "DOC-CHECK GATE: ERROR(多项目工作区:请指名 target)",
55
+ `候选:${view.candidates.join(" ∥ ")}`,
56
+ "处置:指名项目根(`target`)后复跑",
57
+ ].join("\n")
58
+ }
32
59
  return `doc_check: ${target} is a workspace holding ${view.candidates.length} projects — pass the project root explicitly via target:\n` +
33
60
  view.candidates.map((c) => `- ${c}`).join("\n")
34
61
  }
35
- return runDocCheck(view.state === "ok" ? view.root : target).report
62
+ const root = view.state === "ok" ? view.root : target
63
+ if (mode === "gate") {
64
+ // 引擎版本校验先于引擎(A-DD30 ①):stale 非空 ⇒ 直接 ERROR 串,不进引擎
65
+ const version = staleEngineFiles()
66
+ if (version.stale.length) return gateEngineError(version)
67
+ // 会话候选先取 `.md`(触达记账含代码档——代码档不是文档,不该进范围行);域求交归引擎单源
68
+ const docs = explicit ?? (Array.isArray(ctx?.agent?._touchedFiles) ? ctx.agent._touchedFiles : [])
69
+ .filter((p) => typeof p === "string" && p.toLowerCase().endsWith(".md"))
70
+ return runDocCheck(root, { mode: "gate", docs, docsSource: explicit ? "explicit" : "session" }).report
71
+ }
72
+ // 报告态读数标注(A-DD30 ①):引擎陈旧 ⇒ 首行前一行注记;无陈旧 ⇒ 报面逐字同改前
73
+ return withEngineNote(runDocCheck(root).report, staleEngineFiles())
36
74
  },
37
75
  }
@@ -1,52 +1,40 @@
1
1
  /**
2
2
  * spawn-gates.mjs — M5 委派与 spawn 门新门禁统一落点(ENGINEERING-MODE-V2-MODULE-DELEGATION §2.3):
3
3
  * 全部新增门禁逻辑集中于此——装配点(buildSpawnChild / normalizeFileList)只加 import 调用。
4
- * 已实现:F2(round 结构化参数 + 任务书五段文本字段校验)· F6(files 声明面拦截谓词)。
4
+ * 已实现:F2(round 枚举 + 任务书参数校验/拼装——四项核心 + 第五项按角色)· F6(files 声明面拦截谓词)。
5
5
  * 裁撤(2026-09-17 主 agent 裁定——不落代码):
6
- * - F4 修复轮窄带:全量勘察散文 marker 不可机判(误拒风险不可接受)→ 由 F2 五段字段覆盖;
6
+ * - F4 修复轮窄带:全量勘察散文 marker 不可机判(误拒风险不可接受)→ 由 F2 的 round 参数承载轮次语义;
7
7
  * - F5 advisor 角色:advisor 不入 spawn 通道(评审走 advisor 工具)——enum 收正为
8
8
  * explore/eng-designer/eng-coder。
9
9
  * 零依赖:本模块零 import(叶子——纯谓词层,无环、无反向依赖)。
10
10
  */
11
11
 
12
- /**
13
- * F2 任务书五段文本字段(六强制字段中 round 之外的五段——ENGINEERING-MODE-V2-MODULE-DELEGATION §1.2 F2)。
14
- * 判据 = 段标题在场(标题行或行内 marker——summaryEngTaskBook 同款手法:marker 正则命中
15
- * task ∪ context 文本即可);内容够不够由执行者拒收兜底(需求 §1.14 #9 行为面——不做内容校验)。
16
- * 双语 marker:中文标题(主 agent 派单形)与英文标题(交付报告格式模板形)同收。
17
- */
18
- const TASK_BOOK_FIELDS = [
19
- { label: "目标与理由 (goal & why)", markers: [/目标与理由/, /goal\s*&\s*why/i, /goal\s+and\s+why/i] },
20
- { label: "已知事实 (known facts)", markers: [/已知事实/, /known\s+facts/i] },
21
- { label: "设计要点与禁止范围 (design points & forbidden scope)", markers: [/设计要点/, /design\s+points/i] },
22
- { label: "验收标准 (acceptance criteria)", markers: [/验收标准/, /acceptance\s+criteria/i] },
23
- { label: "交付报告格式 (delivery-report format)", markers: [/交付报告格式/, /交付报告/, /delivery[- ]report\s+format/i] },
24
- ]
25
-
26
12
  /** F2 round 合法值(初始轮 / 修正轮——「派单必带」= 无默认值)。
27
13
  * 单源:本档为唯一字面量处——schema 面(`agent-tools/subagent.mjs`)与谓词面(本档)同引用(D2)。
28
14
  */
29
15
  export const ROUND_VALUES = ["initial", "fix"]
30
16
 
31
- /**
32
- * 五段任务书最小合法样例(F2 契约的正面样例——跨仓测试夹具共用单源;段落名 = 父侧
33
- * 任务书五段模板)。文本字段判据 = 在场性(内容质量由执行者拒收兜底——需求 §1.14 #9)。
34
- */
35
- export const ENG_TASK_BOOK_MIN = [
36
- "目标与理由:实现批准设计(夹具任务书——五段文本字段齐备)。",
37
- "已知事实:设计已评审通过、token 已签发、批次档在册。",
38
- "设计要点与禁止范围:仅落设计档受影响文件清单;禁夹带新语义。",
39
- "验收标准:设计档验收标准逐条可机验、项目验证链全绿。",
40
- "交付报告格式:交付表 + 文件路径 + 验证命令与结果。",
41
- ].join("\n")
17
+ /** 任务书样例内容(供人照抄;不是门的判据——门只判存在 / 类型 / 非空):核心四项一份,
18
+ * 第五项按角色各一份(工程角色 = `designPoints` ∥ explore / plan / coder = `scope`)。 */
19
+ const TASK_BOOK_CORE_MIN = {
20
+ goal: "实现批准设计(样例任务书——四项核心 + 角色第五项齐备)。",
21
+ knownFacts: "设计已评审通过、token 已签发、批次档在册。",
22
+ acceptance: "设计档的验收口径逐条可机验、项目验证链全绿。",
23
+ reportFormat: "交付表 + 文件路径 + 验证命令与结果。",
24
+ }
25
+
26
+ /** 工程角色样例(核心四项 + `designPoints`)。 */
27
+ export const ENG_TASK_BOOK_MIN = { ...TASK_BOOK_CORE_MIN, designPoints: "仅落设计档受影响文件清单;禁夹带新语义。" }
28
+
29
+ /** 非工程角色样例(核心四项 + `scope`)——explore / plan / coder。 */
30
+ export const PLAIN_TASK_BOOK_MIN = { ...TASK_BOOK_CORE_MIN, scope: "只读:相关源 / 设计档;不写任何文件、不碰 bench/。" }
42
31
 
43
32
  /**
44
- * F2 任务书六强制字段校验(ENGINEERING-MODE-V2-MODULE-DELEGATION §2.2):
45
- * round 枚举(initial|fix,必带集合 = eng-coder/eng-designer)+ 五段文本字段在场。
46
- * 缺任一 → 抛错(派单缺陷——spawn 机械拒)。scan 面 = args.task ∪ args.context
47
- * (context 是任务书背景段的携带参数——字段落在任一面均算在场)。
33
+ * F2 round 结构化参数校验(ENGINEERING-MODE-V2-MODULE-DELEGATION §2.2):
34
+ * round 枚举(initial|fix,必带集合 = eng-coder/eng-designer)。
35
+ * 缺 / 非法 → 抛错(派单缺陷——spawn 机械拒)。
48
36
  */
49
- export function validateTaskBookFields(args) {
37
+ export function validateRoundParam(args) {
50
38
  const a = args ?? {}
51
39
  const missing = []
52
40
  if (!ROUND_VALUES.includes(a.round)) {
@@ -54,17 +42,88 @@ export function validateTaskBookFields(args) {
54
42
  ? `round (${ROUND_VALUES.join("|")}) — the structured round parameter`
55
43
  : `round ∈ {${ROUND_VALUES.join(", ")}} (got ${JSON.stringify(a.round)})`)
56
44
  }
57
- const text = [a.context, a.task].filter((s) => typeof s === "string").join("\n")
58
- for (const f of TASK_BOOK_FIELDS) {
59
- if (!f.markers.some((m) => m.test(text))) missing.push(f.label)
60
- }
61
45
  if (missing.length === 0) return
62
46
  throw new Error(
63
- `Engineering spawn task book is missing mandatory field(s): ${missing.join(" · ")}. ` +
64
- `The six-field task book — 目标与理由 (goal & why), round (initial|fix), 已知事实 (known facts), ` +
65
- `设计要点与禁止范围 (design points & forbidden scope), 验收标准 (acceptance criteria), ` +
66
- `交付报告格式 (delivery-report format) — is the spawn contract; a missing field is a dispatch ` +
67
- `defect and the spawn is refused.`)
47
+ `Engineering spawn round gate: ${missing.join(" · ")}. ` +
48
+ `The spawn contract requires the structured round parameter (initial|fix); ` +
49
+ `the five task-book parameters are enforced by a separate gate (validateTaskBookParams) — ` +
50
+ `a missing or invalid round is a dispatch defect and the spawn is refused.`)
51
+ }
52
+
53
+ /**
54
+ * F2 任务书名集(**所有 spawn 角色**):core = 四项核心(全员必带);fifth = 第五项两名
55
+ * ——工程角色 = `designPoints` ∥ explore / plan / coder = `scope`。
56
+ * 单源:本档为唯一名集处——校验 / 拼装 / 探针夹具校验一律经 `taskBookParamsFor(role)` 派生(D2)。
57
+ * 样例对象 = `ENG_TASK_BOOK_MIN` / `PLAIN_TASK_BOOK_MIN`(供人照抄)。
58
+ */
59
+ export const TASK_BOOK_PARAMS = {
60
+ core: ["goal", "knownFacts", "acceptance", "reportFormat"],
61
+ fifth: { engineering: "designPoints", plain: "scope" },
62
+ }
63
+
64
+ /** 角色 → 第五项类别(唯一判据;**与模式无关**——工程模式里的 explore 也取 plain)。
65
+ * 未知角色取 plain:spawn 路径上角色 enum 门先拒收(`agent-tools/subagent.mjs` 的 `ROLES`),到不了本门。 */
66
+ function fifthClassOf(role) {
67
+ return role === "eng-coder" || role === "eng-designer" ? "engineering" : "plain"
68
+ }
69
+
70
+ /** 角色 → 该角色的任务书名集(四项核心 + 第五项;序 = 拼装序:第五项插在 `knownFacts` 之后,
71
+ * 与 C 之前 eng 角色的拼装序逐字相同——审计摘要 marker `/^Design points/i` 不受影响)。 */
72
+ export function taskBookParamsFor(role) {
73
+ const core = TASK_BOOK_PARAMS.core
74
+ return [...core.slice(0, 2), TASK_BOOK_PARAMS.fifth[fifthClassOf(role)], ...core.slice(2)]
75
+ }
76
+
77
+ /** 拼装标签(不导出;仅词面,不是判据——无任何匹配读它)。 */
78
+ const TASK_BOOK_LABELS = {
79
+ goal: "Goal & why",
80
+ knownFacts: "Known facts",
81
+ designPoints: "Design points & forbidden scope",
82
+ scope: "Scope (what to read / what not to touch)",
83
+ acceptance: "Acceptance criteria",
84
+ reportFormat: "Delivery report format",
85
+ }
86
+
87
+ /**
88
+ * F2 任务书参数校验(ENGINEERING-MODE-V2-MODULE-DELEGATION §2.2)· **所有 spawn 角色**:
89
+ * 名集按角色取(`taskBookParamsFor(role)`)。判据只有三条——参数存在 ∧ typeof string ∧
90
+ * trim() 非空。不读内容:无正则、无标题匹配、无双语 marker。缺 / 空白 / 非字符串 → 合并抛,
91
+ * 文案逐项点名该参数;第五项缺失而另一名在场时,文案附一句指路(只作文案,不加判据)。
92
+ */
93
+ export function validateTaskBookParams(args, role) {
94
+ const a = args ?? {}
95
+ const names = taskBookParamsFor(role)
96
+ const fifth = TASK_BOOK_PARAMS.fifth[fifthClassOf(role)]
97
+ const otherFifth = fifth === "designPoints" ? "scope" : "designPoints"
98
+ const failed = []
99
+ for (const name of names) {
100
+ const v = a[name]
101
+ if (v === undefined || v === null) {
102
+ const hint = name === fifth && a[otherFifth] !== undefined && a[otherFifth] !== null
103
+ ? ` (got ${otherFifth} instead — that name belongs to the ${fifthClassOf(role) === "engineering" ? "explore/plan/coder" : "eng-coder/eng-designer"} spawns)`
104
+ : ""
105
+ failed.push(`${name} — missing (the task book is four core parameters + the role's fifth)${hint}`)
106
+ }
107
+ else if (typeof v !== "string") failed.push(`${name} — must be a string (got ${typeof v})`)
108
+ else if (v.trim() === "") failed.push(`${name} — blank (trim() is empty)`)
109
+ }
110
+ if (failed.length === 0) return
111
+ throw new Error(
112
+ `Spawn task-book gate (role='${role}'): ${failed.join(" · ")}. ` +
113
+ `The task book is four core parameters (${TASK_BOOK_PARAMS.core.join(" / ")}) plus ${fifth} ` +
114
+ `(the fifth parameter for role='${role}'); a missing, blank or non-string value is a dispatch defect and the spawn is refused.`)
115
+ }
116
+
117
+ /** 五参 → 子代理任务书文本(**非门禁**,纯拼装):按 `taskBookParamsFor(role)` 固定序 + 标签,
118
+ * 空值跳过,`\n\n` 分隔。拼装单源 = 本函数——子代理 input 与探针判官的「任务书全文」同源。 */
119
+ export function composeTaskBookBrief(args, role) {
120
+ const a = args ?? {}
121
+ const parts = []
122
+ for (const name of taskBookParamsFor(role)) {
123
+ const v = a[name]
124
+ if (typeof v === "string" && v.trim() !== "") parts.push(`${TASK_BOOK_LABELS[name]}: ${v.trim()}`)
125
+ }
126
+ return parts.join("\n\n")
68
127
  }
69
128
 
70
129
  /**
@@ -7,14 +7,17 @@
7
7
  * - 池:`_sshSessions`(照 `bash-async.mjs` 池先例——载体经 `carrierField` 吸收);帽 `SSH_SESSION_MAX = 4`
8
8
  * 条存活会话;超限**显式拒**(零静默关旧)。
9
9
  * - 连接(预探路——父侧 2026-10-14 裁定 A):探连 = **全会话形** + `NumberOfPasswordPrompts=0`;探连成功
10
- * ⇒ **该连接直接升格为会话**(密钥机全程恰 1 次连接);认证类失败 ⇒ 终止探连 → 弹凭据窗 → **单次**
11
- * 带密会话(`NumberOfPasswordPrompts=1`);指纹 / 不可达 / 连接超时 ⇒ **弹窗之前硬停**(E3 语义)。
10
+ * ⇒ **该连接直接升格为会话**(密钥机全程恰 1 次连接);认证类失败 ⇒ 终止探连 → **先读保存件**(⑪
11
+ * 命中 ⇒ 直接带密会话,零窗)→ 无 / 不可用 ⇒ 弹凭据窗 → **单次**带密会话(`NumberOfPasswordPrompts=1`);
12
+ * 指纹 / 不可达 / 连接超时 ⇒ **弹窗之前硬停**(E3 语义)。
13
+ * - 保存面(用户裁定 A+B · 台账 #1602):⑩ 身份面(`identityFile` / `auth` 随 ⑨ 记录)∥ ⑪ 保存件读写 /
14
+ * 解密 / `lastUsedAt` / fail-soft / 窗内勾选 ⇒ 单点 `agent-tools/ssh-vault.mjs`;⑫ `credentials` / `forget`。
12
15
  * - 生命周期:闲置 600 s 自动关(digest 注记)· `close` = stdin 结束 → 1.5 s 宽限 → 杀树 · 回合中断存活
13
16
  * (F2 豁免)· 会话中止逐条杀 + 清池(`agent-tools/async-discard.mjs` `SSH_SPEC`)· depth>0 排除
14
17
  * (`agent/helpers.mjs` `SUBAGENT_TOOL_EXCLUSIONS`)。
15
- * 模块图:→ ssh-transport.mjs(运行面)· agent/helpers.mjs(轮转清理)· tools/process-tree.mjs(树杀)·
16
- * context.mjs(pushReal)· async-settle.mjs(池 ∥ 载体 ∥ 墓碑 ∥ 控制器链)· subagent-scheduler.mjs
17
- * (取号 ∥ 令牌 ∥ 入池守卫)——叶子向、无环。
18
+ * 模块图:→ ssh-transport.mjs(运行面)· ssh-vault.mjs(保存件单点)· agent/helpers.mjs(轮转清理)·
19
+ * tools/process-tree.mjs(树杀)· context.mjs(pushReal)· async-settle.mjs(池 ∥ 载体 ∥ 墓碑 ∥ 控制器链)·
20
+ * subagent-scheduler.mjs(取号 ∥ 令牌 ∥ 入池守卫)——叶子向、无环。
18
21
  */
19
22
  import { createWriteStream, mkdirSync, readFileSync, writeFileSync } from "node:fs"
20
23
  import { join } from "node:path"
@@ -27,10 +30,11 @@ import {
27
30
  } from "./async-settle.mjs"
28
31
  import { assertPoolKeyFree, consumeSubagentToken, nextSubagentId } from "./subagent-scheduler.mjs"
29
32
  import {
30
- SSH_RETURN_CAP, SSH_SETTLE_MS, SSH_TIMEOUT_MS, bodyText, capText, classifyFailure, createMask, drain,
33
+ SSH_KEEPALIVE_MS, SSH_RETURN_CAP, SSH_SETTLE_MS, SSH_TIMEOUT_MS, bodyText, capText, classifyFailure, createMask, drain,
31
34
  failureText, idLabel, settleNote, sshConfigDir, spawnSession, statusLine, unlinkAskpass, waitSettled,
32
35
  writeAskpassWrapper,
33
36
  } from "./ssh-transport.mjs"
37
+ import { askCredential, deleteEntry, hostKey, resolveSecret, saveEntry, touchEntry, vaultAvailable } from "./ssh-vault.mjs"
34
38
 
35
39
  /** 并存活会话帽(照 `BG_TASK_MAX` 族)。超限显式拒。 */
36
40
  export const SSH_SESSION_MAX = 4
@@ -42,10 +46,16 @@ const CLOSE_GRACE_MS = 1500
42
46
  /** 非密预填档(§6.22 ⑨):`configDir/ssh-hosts.json`——白名单字段,密码零字段。 */
43
47
  const HOSTS_FILE = "ssh-hosts.json"
44
48
 
45
- /** 测试缝(闲置窗缩短——生产从不调用)。 */
46
- let _idleMs = null
47
- export function _setSshIdleMsForTest(ms) { _idleMs = ms }
48
- const idleMs = () => _idleMs ?? SSH_IDLE_MS
49
+ /** 测试态时间缝(B3 ∥ S2 ∥ S4 判据落形——§6.22 ④):闲置 `idleMs` ∥ 保活 `keepAliveMs`;
50
+ * 缺省取常量、传值 `??` 回落、`finally` 复位(`_resetSshTimeoutsForTest`);**不进用户参数面**。 */
51
+ let _timeouts = { idleMs: null, keepAliveMs: null }
52
+ export function _setSshTimeoutsForTest({ idleMs, keepAliveMs } = {}) {
53
+ if (idleMs !== undefined) _timeouts.idleMs = idleMs ?? null
54
+ if (keepAliveMs !== undefined) _timeouts.keepAliveMs = keepAliveMs ?? null
55
+ }
56
+ export function _resetSshTimeoutsForTest() { _timeouts = { idleMs: null, keepAliveMs: null } }
57
+ const idleMs = () => _timeouts.idleMs ?? SSH_IDLE_MS
58
+ const keepAliveMs = () => _timeouts.keepAliveMs ?? SSH_KEEPALIVE_MS
49
59
  const toolResultsDir = () => join(sshConfigDir(), "tool-results")
50
60
 
51
61
  /** 池(写侧:父字段优先 / 借用 history 载体——`ensureBgPool` 同式)。 */
@@ -155,8 +165,6 @@ export function killSshTree(entry) {
155
165
  // ─── 非密预填档(§6.22 ⑨)──────────────────────────────────────────────────
156
166
 
157
167
  const hostsFile = () => join(sshConfigDir(), HOSTS_FILE)
158
- /** 读键 = `host[:port]`(缺省 22)。 */
159
- const hostKey = (host, port) => (port && Number(port) !== 22 ? `${host}:${port}` : String(host))
160
168
 
161
169
  function lookupHostRecord(host, port) {
162
170
  try {
@@ -166,15 +174,20 @@ function lookupHostRecord(host, port) {
166
174
  } catch { return null }
167
175
  }
168
176
 
169
- /** 写入 = **认证成功后**;白名单 = 非密字段(host / port / user / lastUsedAt——密码零字段)。 */
170
- function saveHostRecord(host, port, user) {
171
- if (!user) return
177
+ /** 写入 = **认证成功后**;白名单 = 非密字段(host / port / user / lastUsedAt / `identityFile` / `auth`——
178
+ * 密码零字段;⑩ 身份面)。无 `user` 但有 `identityFile` 时也写(身份面复用不依赖 user 预填)。 */
179
+ function saveHostRecord(host, port, user, { identity = null, auth = null } = {}) {
180
+ if (!user && !identity) return
172
181
  try {
173
182
  mkdirSync(sshConfigDir(), { recursive: true })
174
183
  let all = {}
175
184
  try { all = JSON.parse(readFileSync(hostsFile(), "utf8")) ?? {} } catch { all = {} }
176
185
  if (typeof all !== "object" || all === null) all = {}
177
- all[hostKey(host, port)] = { host, port: Number(port) || 22, user, lastUsedAt: new Date().toISOString() }
186
+ all[hostKey(host, port)] = {
187
+ host, port: Number(port) || 22, user: user ?? null, lastUsedAt: new Date().toISOString(),
188
+ ...(identity ? { identityFile: identity } : {}),
189
+ ...(auth ? { auth } : {}),
190
+ }
178
191
  writeFileSync(hostsFile(), JSON.stringify(all, null, 2), "utf8")
179
192
  } catch { /* 预填 = 增益面——写失败不进连接语义 */ }
180
193
  }
@@ -182,10 +195,12 @@ function saveHostRecord(host, port, user) {
182
195
  // ─── 动作(工具面薄接线消费)────────────────────────────────────────────────
183
196
 
184
197
  /**
185
- * 连接(§6.22 ⑤ + 裁定 A 预探路):探连 ⇒ 升格 ∥ 认证失败 ⇒ 凭据窗 ⇒ 单次带密会话。
198
+ * 连接(§6.22 ⑤ + 裁定 A 预探路 + 保存面):探连 ⇒ 升格 ∥ 认证失败 ⇒ **先读保存件**(命中 ⇒ 带密
199
+ * 会话零窗)⇒ 无 / 不可用 / 被拒 ⇒ 凭据窗(窗内可勾选保存)⇒ 单次带密会话。
200
+ * 优先级铁序(⑩):保存件**恒不先于探连**——密钥可用 ⇒ 保存件零读取(S6/N6/B7)。
186
201
  * @returns {{ text?: string, error?: string }}
187
202
  */
188
- export async function connectSsh(parent, ctx, { host, user, port, label, promptPattern } = {}) {
203
+ export async function connectSsh(parent, ctx, { host, user, port, label, promptPattern, identityFile } = {}) {
189
204
  if (!parent) return { error: "no agent context — ssh sessions need a session-level pool (depth-0 only)" }
190
205
  if (typeof host !== "string" || !host.trim()) return { error: "connect requires host" }
191
206
  const pool = sshPool(parent)
@@ -201,8 +216,9 @@ export async function connectSsh(parent, ctx, { host, user, port, label, promptP
201
216
  try { promptRe = new RegExp(String(promptPattern)) } catch (e) { return { error: `promptPattern is not a valid regex: ${e.message}` } }
202
217
  }
203
218
  const record = lookupHostRecord(host, port)
204
- const effectiveUser = user || record?.user || null
219
+ const effectiveUser = user || record?.user || null // 判定序:connect 参数 → ⑨ 记录 → 皆缺视同无
205
220
  const userFromRecord = !user && record?.user ? record.user : null
221
+ const identity = identityFile || record?.identityFile || null // ⑩:参数 → ⑨ 记录
206
222
  const id = nextSubagentId(parent)
207
223
  consumeSubagentToken(parent, id, "ssh connect", "ssh")
208
224
  await cleanupOldToolResults(toolResultsDir())
@@ -222,25 +238,37 @@ export async function connectSsh(parent, ctx, { host, user, port, label, promptP
222
238
  const reason = await waitSettled(entry, { signal: ctx?.signal ?? null })
223
239
  return { reason, waitedMs: Date.now() - t0 }
224
240
  }
225
- /** 升格为会话(探连成功 ∥ 带密会话成功同径)。 */
226
- const promote = (reason, waitedMs) => {
241
+ /** 起跑(探连 ∥ 带密会话同径;`-i` 两径同带——追加候选身份,不夺既有尝试序列)。
242
+ * 每次起跑前清该轮缓冲:分类 / 沉降只看**本次连接**的输出(探连错误的残渣不得误判下一次尝试)。 */
243
+ const spawn = (sessionUser, secret) => {
244
+ entry.buf = ""
245
+ entry.bufDropped = false
246
+ return spawnSession(entry, { user: sessionUser, port, secret, identityFile: identity, keepAliveMs: keepAliveMs() })
247
+ }
248
+ /** 升格为会话(探连成功 ∥ 带密会话成功同径);⑩ 身份面(auth)随 ⑨ 记录回写。 */
249
+ const promote = (reason, waitedMs, { auth = "key", extra = [] } = {}) => {
227
250
  entry.state = "ready"
228
251
  entry.lastActivityAt = Date.now()
229
252
  armIdle(entry)
230
- saveHostRecord(entry.host, entry.port, entry.user)
253
+ saveHostRecord(entry.host, entry.port, entry.user, { identity, auth })
231
254
  const { text, dropped } = drain(entry)
232
255
  const notes = [
233
256
  `— connected (${settleNote(entry, reason, waitedMs)})`,
234
257
  `log: ${entry.logPath}`,
235
258
  userFromRecord ? `user "${userFromRecord}" came from the local ssh-hosts record` : "",
259
+ ...extra,
236
260
  dropped ? "in-memory buffer overflowed — full output in the log" : "",
237
261
  ].filter(Boolean)
238
262
  return { text: `${statusLine(entry)}\n${bodyText(capText(text, SSH_RETURN_CAP, entry))}\n${notes.join("; ")}` }
239
263
  }
264
+ /** 认证类失败分类(spawn 错误 ∥ 输出判据——两径同式)。 */
265
+ const classify = () => (entry.spawnError
266
+ ? { kind: "spawn", message: `ssh client not available (${entry.spawnError}) — install system OpenSSH or check PATH` }
267
+ : classifyFailure(entry.buf))
240
268
  try {
241
269
  assertPoolKeyFree(pool, id, "ssh")
242
270
  pool.set(String(id), entry)
243
- spawnSession(entry, { user: effectiveUser, port, secret: null }) // 探连:全会话形 + NumberOfPasswordPrompts=0
271
+ spawn(effectiveUser, null) // 探连:全会话形 + NumberOfPasswordPrompts=0(不读保存件——铁序)
244
272
  let { reason, waitedMs } = await settle()
245
273
  // 「已就绪」判据要求进程尚在(静默窗与退出同刻的竞争面——升格一条死连接不成立)。
246
274
  if ((reason === "prompt" || reason === "silence") && entry.exitInfo) reason = "exited"
@@ -252,20 +280,54 @@ export async function connectSsh(parent, ctx, { host, user, port, label, promptP
252
280
  hardClose(entry, "closed", "consumed")
253
281
  return { error: `ssh connect did not settle within ${SSH_TIMEOUT_MS}ms (${entry.host}) — the connection is closed; retry or increase the wait` }
254
282
  }
283
+ let auth = "key"
284
+ const flowNotes = []
255
285
  if (reason === "exited") {
256
- const cls = entry.spawnError
257
- ? { kind: "spawn", message: `ssh client not available (${entry.spawnError}) — install system OpenSSH or check PATH` }
258
- : classifyFailure(entry.buf)
286
+ const cls = classify()
259
287
  if (!cls || cls.kind !== "auth") {
260
288
  const text = failureText(entry, cls)
261
289
  hardClose(entry, "closed", "connect-failed") // 杀树收尾(finalize 只出池——活子进程会成无句柄悬挂)
262
290
  return { error: text }
263
291
  }
264
- // 认证类失败 ⇒ 凭据窗(值只走回调返回路 + 子进程环境——NS4:零入参 ∥ 零入结果 ∥ 零落盘)。
265
- const input = await askCredential(ctx, { host, user: effectiveUser, port, sessionId: id })
292
+ // 认证类失败 ⇒ **先读保存件**(⑪;有效 user 判定序缺 ⇒ 零消费零删除——直接落窗)。
293
+ let saved = null
294
+ let failNote = ""
295
+ if (effectiveUser) {
296
+ const got = await resolveSecret(host, port, effectiveUser)
297
+ if (got.state === "ok") saved = got
298
+ else if (got.state === "damaged") failNote = "the saved-credential store is damaged — it was left untouched"
299
+ else if (got.state === "undecryptable") failNote = "the saved credential could not be decrypted — the entry was kept"
300
+ else if (got.state === "unavailable") failNote = "saved credentials are not available on this platform"
301
+ // missing ⇒ 静默(正常首连态)
302
+ }
303
+ if (saved) {
304
+ entry.user = effectiveUser
305
+ entry.mask.add(saved.value) // 遮蔽先注册后写入(回显面零泄)
306
+ spawn(entry.user, saved.value) // 带密会话(PP=1)——命中保存件 ⇒ 零窗
307
+ ;({ reason, waitedMs } = await settle())
308
+ if ((reason === "prompt" || reason === "silence") && entry.exitInfo) reason = "exited"
309
+ if (reason === "prompt" || reason === "silence") {
310
+ touchEntry(host, port) // lastUsedAt = 复用成功时更新(写失败 = 增益面静默)
311
+ return promote(reason, waitedMs, { auth: "password", extra: ["saved credential used (no credential window)"] })
312
+ }
313
+ const clsSaved = classify()
314
+ // 只有**本次进程正常退出且为认证类**才算「服务端拒绝」;超时 / 中断 / 不可达 ⇒ 条目保留、硬停。
315
+ if (reason !== "exited" || clsSaved?.kind !== "auth") {
316
+ const text = failureText(entry, clsSaved ?? { message: "authentication rejected" }, " — the session was not established")
317
+ hardClose(entry, "closed", "connect-failed")
318
+ return { error: text }
319
+ }
320
+ // 条目被服务端拒 ⇒ 删该条目(拒绝即值已失效——避免每次重试旧值)+ 回落窗;删失败时注记如实。
321
+ const del = deleteEntry(host, port)
322
+ failNote = del.ok
323
+ ? "the saved credential was rejected by the server — the entry was deleted"
324
+ : "the saved credential was rejected by the server — the store could not be updated (the entry may still be there)"
325
+ }
326
+ // 凭据窗(值只走回调返回路 + 子进程环境——NS4:零入参 ∥ 零入结果 ∥ 零落盘)。
327
+ const input = await askCredential(ctx, { host, user: effectiveUser, port, sessionId: id, offerSave: vaultAvailable() })
266
328
  if (!input) {
267
329
  hardClose(entry, "closed", "consumed")
268
- return { error: `ssh connect to ${entry.host} cancelled — no credential was provided (no connection was attempted with one)` }
330
+ return { error: `ssh connect to ${entry.host} cancelled — no credential was provided (no connection was attempted with one)${failNote ? ` (${failNote}; the credential window was shown)` : ""}` }
269
331
  }
270
332
  if (!String(input.password ?? "").length) {
271
333
  hardClose(entry, "closed", "consumed")
@@ -273,7 +335,7 @@ export async function connectSsh(parent, ctx, { host, user, port, label, promptP
273
335
  }
274
336
  entry.user = input.user || effectiveUser || null
275
337
  entry.mask.add(input.password) // 遮蔽先注册后写入(回显面零泄)
276
- spawnSession(entry, { user: entry.user, port, secret: input.password }) // 单次带密会话(PP=1)
338
+ spawn(entry.user, input.password) // 单次带密会话(PP=1)
277
339
  ;({ reason, waitedMs } = await settle())
278
340
  if ((reason === "prompt" || reason === "silence") && entry.exitInfo) reason = "exited"
279
341
  if (reason !== "prompt" && reason !== "silence") {
@@ -283,8 +345,19 @@ export async function connectSsh(parent, ctx, { host, user, port, label, promptP
283
345
  hardClose(entry, "closed", "connect-failed")
284
346
  return { error: text }
285
347
  }
348
+ // 认证成功后才写(opt-in——严格布尔;写失败不影响连接:增益面语义)
349
+ if (input.save === true && entry.user) {
350
+ const savedRes = await saveEntry(host, port, { user: entry.user, value: input.password })
351
+ flowNotes.push(savedRes.ok
352
+ ? "the credential was saved for this host (encrypted with this Windows user account)"
353
+ : "the credential could not be saved (the connection is unaffected)")
354
+ } else if (input.save === true) {
355
+ flowNotes.push("the credential was not saved (no login user was given for this host)")
356
+ }
357
+ if (failNote) flowNotes.unshift(`${failNote}; the credential window was shown`)
358
+ auth = "password"
286
359
  }
287
- return promote(reason, waitedMs)
360
+ return promote(reason, waitedMs, { auth, extra: flowNotes })
288
361
  } catch (e) {
289
362
  hardClose(entry, "closed", "connect-failed") // 异常径同走杀树(子进程可能仍在飞)
290
363
  return { error: e?.noUi ? `Error: ${e.message}` : `ssh connect failed: ${e?.message ?? String(e)}` }
@@ -293,36 +366,6 @@ export async function connectSsh(parent, ctx, { host, user, port, label, promptP
293
366
  }
294
367
  }
295
368
 
296
- /** 凭据窗(§6.22 ⑧):无 UI ⇒ 可读错(不挂起、不静默——`question.mjs` 无 UI 抛例同旨);词面 = i18n 键。
297
- * 端侧回调自身拒绝(CLI `refuseSecretInput()` 形:抛可读句)⇒ 同族直出(`noUi` 标记 = 失败句不另加前缀)。 */
298
- async function askCredential(ctx, { host, user, port, sessionId }) {
299
- if (typeof ctx?.onSecretInput !== "function") {
300
- const err = new Error(t("ssh.no_ui"))
301
- err.noUi = true // 可读句直出(catch 面不另加前缀——失败句单源 = 核字典)
302
- throw err
303
- }
304
- let res
305
- try {
306
- res = await ctx.onSecretInput({
307
- kind: "ssh-credential",
308
- sessionId: sessionId != null ? `ssh#${sessionId}` : null,
309
- host, user: user ?? null, port: port ?? null,
310
- fields: [
311
- { name: "user", masked: false, required: false, prefill: user ?? "" },
312
- { name: "password", masked: true, required: true, prefill: "" },
313
- ],
314
- keys: { title: "ssh.credentialTitle", prompt: "ssh.credentialPrompt" },
315
- })
316
- } catch (e) {
317
- // 端侧拒绝(无交互面 / 载荷成形外)⇒ 同族可读句直出(不裹「connect failed」前缀)。
318
- const err = new Error(e?.message ?? String(e))
319
- err.noUi = true
320
- throw err
321
- }
322
- if (!res || typeof res !== "object") return null
323
- return res
324
- }
325
-
326
369
  /** 缓冲溢出注记(三动作同式——丢弃面显式化;全量恒在 log)。 */
327
370
  const droppedNote = (dropped) => (dropped ? "\n— in-memory buffer overflowed — full output in the log" : "")
328
371
 
@@ -437,3 +480,7 @@ export function listSsh(parent) {
437
480
  // 常量转口(工具面 / 测试档经本档取——机制档不重复导出)。
438
481
  export { SSH_SETTLE_MS, SSH_TIMEOUT_MS, SSH_RETURN_CAP }
439
482
  export { _setSshBinaryForTest, _setSshConfigDirForTest } from "./ssh-transport.mjs"
483
+
484
+ // 保存凭据管理面(§6.22 ⑫):`credentials` / `forget` 实体 = `ssh-vault.mjs`(核单点——本档只转口;
485
+ // 拆档由 = 机制档触 500 顾问线,§6.22 落点表候选拆分)。
486
+ export { credentialsSsh, forgetSsh } from "./ssh-vault.mjs"
@@ -17,6 +17,7 @@
17
17
  */
18
18
  import { chmodSync, mkdirSync, unlinkSync, writeFileSync } from "node:fs"
19
19
  import { spawn } from "node:child_process"
20
+ import { homedir } from "node:os"
20
21
  import { fileURLToPath } from "node:url"
21
22
  import { configDir } from "../config.mjs"
22
23
  import { makeDecoder, sanitizeOutput } from "../tools/shared.mjs"
@@ -28,6 +29,8 @@ export const SSH_SETTLE_MS = 800
28
29
  export const SSH_TIMEOUT_MS = 10_000
29
30
  /** 单次返回帽(§6.22 ⑥):保尾 + 截断注记;全量恒在 log。 */
30
31
  export const SSH_RETURN_CAP = 8000
32
+ /** 保活缺省(§6.22 ④:`ServerAliveInterval=30`;测试态时间缝按 ms 传入后换算)。 */
33
+ export const SSH_KEEPALIVE_MS = 30_000
31
34
  /** 秘密环境变量名(§6.22 ②「秘密传递」行)。 */
32
35
  export const SECRET_ENV = "TC_SSH_SECRET"
33
36
  /** 内存缓冲保留量(仅覆盖返回帽;全量恒在 log)。 */
@@ -81,18 +84,28 @@ export function buildSshEnv({ wrapper, secret }) {
81
84
  return nodeChildEnv(env)
82
85
  }
83
86
 
87
+ /** `~` 展开(`identityFile` 记录 / 参数——`~/.ssh/id_ed25519` 同式;非 `~` 开头原样)。 */
88
+ function expandHome(file) {
89
+ const raw = String(file ?? "")
90
+ if (!/^~(?=$|[\\/])/.test(raw)) return raw
91
+ return `${homedir()}${raw.slice(1)}`
92
+ }
93
+
84
94
  /** 会话命令形(§6.22 ⑤):`-tt` 强制远端 PTY + 保活三件 + 单提示;`port`/`user` 未给 ⇒ 不下发。
95
+ * `identityFile` 显式给出 / 记录有值 ⇒ `-i <路径>`(追加候选身份,不夺本机配置与 agent 的既有尝试序列)。
85
96
  * 预探 = `NumberOfPasswordPrompts=0`(password ∥ keyboard-interactive 两径同被短路——
86
97
  * `sshconnect2.c` `userauth_passwd` / `userauth_kbdint` 两处同判 `attempt++ >= n`;私钥口令提示
87
98
  * 亦被短路:`load_identity_file` 循环 `i <= number_of_password_prompts`);带密 = 1(单提示)。 */
88
- export function buildSshArgs({ host, user, port, probe }) {
99
+ export function buildSshArgs({ host, user, port, probe, identityFile = null, keepAliveMs = SSH_KEEPALIVE_MS }) {
100
+ const keepAliveS = Math.max(1, Math.round((Number(keepAliveMs) || SSH_KEEPALIVE_MS) / 1000))
89
101
  return [
90
102
  "-tt",
91
103
  "-o", "ConnectTimeout=10",
92
- "-o", "ServerAliveInterval=30",
104
+ "-o", `ServerAliveInterval=${keepAliveS}`,
93
105
  "-o", "ServerAliveCountMax=4",
94
106
  "-o", `NumberOfPasswordPrompts=${probe ? 0 : 1}`,
95
107
  ...(port ? ["-p", String(port)] : []),
108
+ ...(identityFile ? ["-i", expandHome(identityFile)] : []),
96
109
  user ? `${user}@${host}` : host,
97
110
  ]
98
111
  }
@@ -184,10 +197,10 @@ export function attachChild(entry, child) {
184
197
 
185
198
  /** 起跑一条 ssh(探连 ∥ 带密会话同径)。重起(探连 → 带密会话)⇒ 判定面复位:exitInfo ∥
186
199
  * spawnError 属**上一次**进程(残留会让新连接的成功判定被误读成「已退出」)。 */
187
- export function spawnSession(entry, { user, port, secret }) {
200
+ export function spawnSession(entry, { user, port, secret, identityFile = null, keepAliveMs = SSH_KEEPALIVE_MS }) {
188
201
  entry.exitInfo = null
189
202
  entry.spawnError = null
190
- const child = spawn(sshBinary(), [...sshArgPrefix(), ...buildSshArgs({ host: entry.host, user, port, probe: secret == null })], {
203
+ const child = spawn(sshBinary(), [...sshArgPrefix(), ...buildSshArgs({ host: entry.host, user, port, probe: secret == null, identityFile, keepAliveMs })], {
191
204
  stdio: ["pipe", "pipe", "pipe"],
192
205
  windowsHide: true,
193
206
  detached: process.platform !== "win32",