@thincoder/core 0.10.7 → 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 (149) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/advisor/compaction.mjs +1 -1
  3. package/advisor/run.mjs +1 -1
  4. package/agent/chat-call.mjs +40 -27
  5. package/agent/child-marks.mjs +1 -1
  6. package/agent/dispatch-gates.mjs +7 -4
  7. package/agent/dispatch-run.mjs +1 -0
  8. package/agent/family-tools.mjs +13 -8
  9. package/agent/helpers.mjs +12 -10
  10. package/agent/image-heal.mjs +65 -0
  11. package/agent/light-round.mjs +4 -4
  12. package/agent/live-beat.mjs +4 -4
  13. package/agent/post-turn.mjs +34 -0
  14. package/agent/record-results.mjs +9 -1
  15. package/agent/run-stages.mjs +45 -10
  16. package/agent/setup-reminders.mjs +14 -13
  17. package/agent/setup.mjs +72 -48
  18. package/agent/spawn-child.mjs +1 -1
  19. package/agent/subagent-event.mjs +1 -1
  20. package/agent/suspension.mjs +2 -1
  21. package/agent/timers.mjs +8 -8
  22. package/agent/turn-loop.mjs +7 -1
  23. package/agent-tools/advisor.mjs +1 -1
  24. package/agent-tools/async-discard.mjs +40 -5
  25. package/agent-tools/async-settle.mjs +3 -1
  26. package/agent-tools/audit-block.mjs +5 -3
  27. package/agent-tools/batch-gate.mjs +198 -0
  28. package/agent-tools/batch-lifecycle.mjs +216 -36
  29. package/agent-tools/batch-paths.mjs +20 -20
  30. package/agent-tools/batch-skeleton.mjs +87 -0
  31. package/agent-tools/batch.mjs +11 -3
  32. package/agent-tools/checkpoint.mjs +1 -1
  33. package/agent-tools/context.mjs +28 -5
  34. package/agent-tools/design-slots.mjs +20 -7
  35. package/agent-tools/doc-check.mjs +75 -0
  36. package/agent-tools/eng.mjs +5 -3
  37. package/agent-tools/manifest.mjs +18 -18
  38. package/agent-tools/parent-channel.mjs +3 -3
  39. package/agent-tools/review-facts.mjs +2 -2
  40. package/agent-tools/settings.mjs +1 -1
  41. package/agent-tools/spawn-gates.mjs +108 -47
  42. package/agent-tools/ssh-session.mjs +486 -0
  43. package/agent-tools/ssh-transport.mjs +324 -0
  44. package/agent-tools/ssh-vault.mjs +291 -0
  45. package/agent-tools/subagent-scheduler.mjs +6 -5
  46. package/agent-tools/subagent-spawn.mjs +18 -11
  47. package/agent-tools/subagent.mjs +17 -9
  48. package/agent-tools/task.mjs +1 -1
  49. package/agent-tools/timer.mjs +8 -2
  50. package/agent-tools/zero-write-watch.mjs +2 -2
  51. package/agent-tools.mjs +3 -2
  52. package/attachments.mjs +4 -4
  53. package/browser/cdp.mjs +2 -2
  54. package/browser/launch.mjs +50 -4
  55. package/browser/snapshot.mjs +1 -1
  56. package/config-defaults.mjs +92 -0
  57. package/config-io.mjs +7 -2
  58. package/config-normalize.mjs +109 -0
  59. package/config-watch.mjs +2 -2
  60. package/config.mjs +88 -190
  61. package/declaration.mjs +5 -1
  62. package/doc-check/anchors.mjs +142 -0
  63. package/doc-check/index.mjs +210 -0
  64. package/doc-check/version.mjs +84 -0
  65. package/doc-check/width.mjs +132 -0
  66. package/file-refs.mjs +6 -6
  67. package/generate-title.mjs +1 -1
  68. package/hooks.mjs +1 -1
  69. package/i18n.mjs +13 -1
  70. package/ledger-cmd.mjs +314 -62
  71. package/ledger-db.mjs +122 -31
  72. package/ledger-migrate.mjs +18 -12
  73. package/ledger-read.mjs +6 -7
  74. package/ledger-tools.mjs +138 -77
  75. package/ledger.mjs +34 -62
  76. package/log.mjs +17 -3
  77. package/manifest-discovery.mjs +21 -18
  78. package/manifest-schema.mjs +17 -14
  79. package/manifest.mjs +37 -22
  80. package/mcp/transport-stdio.mjs +1 -1
  81. package/mcp.mjs +2 -2
  82. package/memory/core.mjs +1 -1
  83. package/memory/scan.mjs +1 -1
  84. package/memory/sweep.mjs +1 -1
  85. package/model-specs.mjs +7 -7
  86. package/notify-policy.mjs +6 -6
  87. package/package.json +2 -1
  88. package/permission.mjs +21 -1
  89. package/prompts/advisor-design.md +53 -23
  90. package/prompts/advisor-round1.md +34 -14
  91. package/prompts/advisor-round2.md +32 -19
  92. package/prompts/advisor-round3.md +34 -21
  93. package/prompts/common.md +148 -79
  94. package/prompts/consult-base.md +14 -8
  95. package/prompts/discipline-engineering.md +70 -66
  96. package/prompts/discipline-normal.md +86 -77
  97. package/prompts/persona-coder.md +6 -6
  98. package/prompts/persona-eng-coder.md +19 -18
  99. package/prompts/persona-eng-designer.md +40 -31
  100. package/prompts/persona-engineering.md +124 -92
  101. package/prompts/persona-explore.md +2 -2
  102. package/prompts/persona-normal.md +20 -17
  103. package/prompts/persona-plan.md +7 -7
  104. package/prompts/subagent-base.md +4 -4
  105. package/provider/anthropic.mjs +4 -2
  106. package/provider/core.mjs +4 -2
  107. package/provider/errors.mjs +8 -0
  108. package/provider/google.mjs +8 -1
  109. package/provider/normalize.mjs +42 -12
  110. package/provider/responses-request.mjs +4 -3
  111. package/provider/responses.mjs +2 -2
  112. package/provider/wait-status.mjs +1 -1
  113. package/provider-flows.mjs +1 -1
  114. package/queued.mjs +2 -2
  115. package/rules.mjs +4 -4
  116. package/session-gc.mjs +1 -1
  117. package/session-lifecycle.mjs +7 -7
  118. package/session-slot-write.mjs +2 -2
  119. package/session-slots-manifest.mjs +1 -1
  120. package/session.mjs +1 -1
  121. package/shell-candidates.mjs +2 -2
  122. package/shell-identity.mjs +1 -1
  123. package/team.mjs +63 -5
  124. package/think-off.mjs +3 -3
  125. package/token-ttl.mjs +1 -1
  126. package/token-window.mjs +1 -1
  127. package/tool-docs/bash.md +1 -1
  128. package/tool-docs/batch.md +1 -1
  129. package/tool-docs/browser.md +1 -1
  130. package/tool-docs/doc_check.md +54 -0
  131. package/tool-docs/fetch.md +3 -2
  132. package/tool-docs/ledger.md +7 -5
  133. package/tool-docs/manifest.md +14 -2
  134. package/tool-docs/ssh.md +51 -0
  135. package/tool-docs/subagent.md +6 -9
  136. package/tool-docs/timer.md +2 -1
  137. package/tool-docs/websearch.md +3 -1
  138. package/tools/browser.mjs +1 -1
  139. package/tools/execute.mjs +1 -1
  140. package/tools/git-run.mjs +2 -2
  141. package/tools/git.mjs +8 -8
  142. package/tools/index.mjs +3 -0
  143. package/tools/ops.mjs +1 -1
  144. package/tools/process-tree.mjs +2 -2
  145. package/tools/ssh-askpass.mjs +35 -0
  146. package/tools/ssh.mjs +105 -0
  147. package/tools/web-deepseek.mjs +172 -0
  148. package/tools/web.mjs +29 -4
  149. package/vision-reader.mjs +4 -4
@@ -0,0 +1,486 @@
1
+ /**
2
+ * ssh-session.mjs — SSH 会话面单点(池 ∥ 生命周期 ∥ 动作 ∥ 凭据窗 ∥ 非密预填)。
3
+ * 设计 = `docs/core/design/TOOLS.md` §6.22(D-TO17/D-TO18 · 台账 #1436);需求 = `docs/core/requirements/TOOLS.md` §4.8。
4
+ * 工具面 = `tools/ssh.mjs`(薄接线);传输面 = `agent-tools/ssh-transport.mjs`(askpass 链 / 沉降 / 遮蔽 / 帽
5
+ * ——拆档由 = 机制档触 500 顾问线,§6.22 落点表「实施轮先审拆分」)。
6
+ *
7
+ * - 池:`_sshSessions`(照 `bash-async.mjs` 池先例——载体经 `carrierField` 吸收);帽 `SSH_SESSION_MAX = 4`
8
+ * 条存活会话;超限**显式拒**(零静默关旧)。
9
+ * - 连接(预探路——父侧 2026-10-14 裁定 A):探连 = **全会话形** + `NumberOfPasswordPrompts=0`;探连成功
10
+ * ⇒ **该连接直接升格为会话**(密钥机全程恰 1 次连接);认证类失败 ⇒ 终止探连 → **先读保存件**(⑪
11
+ * 命中 ⇒ 直接带密会话,零窗)→ 无 / 不可用 ⇒ 弹凭据窗 → **单次**带密会话(`NumberOfPasswordPrompts=1`);
12
+ * 指纹 / 不可达 / 连接超时 ⇒ **弹窗之前硬停**(E3 语义)。
13
+ * - 保存面(用户裁定 A+B · 台账 #1602):⑩ 身份面(`identityFile` / `auth` 随 ⑨ 记录)∥ ⑪ 保存件读写 /
14
+ * 解密 / `lastUsedAt` / fail-soft / 窗内勾选 ⇒ 单点 `agent-tools/ssh-vault.mjs`;⑫ `credentials` / `forget`。
15
+ * - 生命周期:闲置 600 s 自动关(digest 注记)· `close` = stdin 结束 → 1.5 s 宽限 → 杀树 · 回合中断存活
16
+ * (F2 豁免)· 会话中止逐条杀 + 清池(`agent-tools/async-discard.mjs` `SSH_SPEC`)· depth>0 排除
17
+ * (`agent/helpers.mjs` `SUBAGENT_TOOL_EXCLUSIONS`)。
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(取号 ∥ 令牌 ∥ 入池守卫)——叶子向、无环。
21
+ */
22
+ import { createWriteStream, mkdirSync, readFileSync, writeFileSync } from "node:fs"
23
+ import { join } from "node:path"
24
+ import { cleanupOldToolResults } from "../agent/helpers.mjs"
25
+ import { pushReal } from "../context.mjs"
26
+ import { t } from "../i18n.mjs"
27
+ import { killProcessTree } from "../tools/process-tree.mjs"
28
+ import {
29
+ bindChildController, buildChildSignal, carrierField, getAsyncPool, writeTombstone, tombstoneOf,
30
+ } from "./async-settle.mjs"
31
+ import { assertPoolKeyFree, consumeSubagentToken, nextSubagentId } from "./subagent-scheduler.mjs"
32
+ import {
33
+ SSH_KEEPALIVE_MS, SSH_RETURN_CAP, SSH_SETTLE_MS, SSH_TIMEOUT_MS, bodyText, capText, classifyFailure, createMask, drain,
34
+ failureText, idLabel, settleNote, sshConfigDir, spawnSession, statusLine, unlinkAskpass, waitSettled,
35
+ writeAskpassWrapper,
36
+ } from "./ssh-transport.mjs"
37
+ import { askCredential, deleteEntry, hostKey, resolveSecret, saveEntry, touchEntry, vaultAvailable } from "./ssh-vault.mjs"
38
+
39
+ /** 并存活会话帽(照 `BG_TASK_MAX` 族)。超限显式拒。 */
40
+ export const SSH_SESSION_MAX = 4
41
+ /** 闲置自动关闭(§6.22 ④):600 s 无 send/read ⇒ 关闭 + 注记;不做配置项。 */
42
+ export const SSH_IDLE_MS = 600_000
43
+
44
+ /** `close` 宽限:stdin 结束 → 1.5 s → 杀树(§6.22 ④)。 */
45
+ const CLOSE_GRACE_MS = 1500
46
+ /** 非密预填档(§6.22 ⑨):`configDir/ssh-hosts.json`——白名单字段,密码零字段。 */
47
+ const HOSTS_FILE = "ssh-hosts.json"
48
+
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
59
+ const toolResultsDir = () => join(sshConfigDir(), "tool-results")
60
+
61
+ /** 池(写侧:父字段优先 / 借用 history 载体——`ensureBgPool` 同式)。 */
62
+ function sshPool(parent) {
63
+ if (parent?._sshSessions instanceof Map) return parent._sshSessions
64
+ const borrowed = carrierField(parent, "_sshSessions")
65
+ if (borrowed instanceof Map) { parent._sshSessions = borrowed; return borrowed }
66
+ parent._sshSessions = new Map()
67
+ if (parent?.history && typeof parent.history === "object") parent.history._sshSessions = parent._sshSessions
68
+ return parent._sshSessions
69
+ }
70
+
71
+ /** 条目查找(id 形归一:`ssh#N` ∥ `N`;键形单源 `String(id)`)。 */
72
+ function findEntry(parent, id) {
73
+ const pool = getAsyncPool(parent, "ssh")
74
+ if (!(pool instanceof Map)) return null
75
+ const key = String(id ?? "").trim().replace(/^ssh#/i, "")
76
+ if (!key) return null
77
+ return pool.get(key) ?? pool.get(Number(key)) ?? null
78
+ }
79
+
80
+ /** 会话不存在 / 已终结 ⇒ 可读句(墓碑区分「结束过」与「从未存在」——E5 语义)。 */
81
+ function missingEntry(parent, id) {
82
+ const key = String(id ?? "").trim().replace(/^ssh#/i, "")
83
+ const t = tombstoneOf(parent, key)
84
+ if (t?.role === "ssh") {
85
+ const why = t.status === "discarded" ? "was closed when the run was stopped"
86
+ : t.status === "connect-failed" ? "was never established (the connect attempt failed)"
87
+ : t.status === "failed" ? "ended (the remote closed the connection)"
88
+ : "is closed"
89
+ return `Error: ssh session ssh#${key} ${why} — open a new session with action:'connect'`
90
+ }
91
+ return `Error: unknown ssh session id: ${id ?? "(missing)"}`
92
+ }
93
+
94
+ // ─── 生命周期 ───────────────────────────────────────────────────────────────
95
+
96
+ function clearIdle(entry) {
97
+ if (entry.idleTimer) { clearTimeout(entry.idleTimer); entry.idleTimer = null }
98
+ }
99
+
100
+ /** 终态单点:状态 / 计时器 / log / 包装档 / 出池 / 墓碑(幂等)。 */
101
+ function finalize(entry, { state = "dead", status = "failed" } = {}) {
102
+ if (entry.state === "dead" || entry.state === "closed") return
103
+ entry.state = state
104
+ clearIdle(entry)
105
+ try { entry.logStream?.end() } catch { /* 尽力面 */ }
106
+ unlinkAskpass(entry)
107
+ getAsyncPool(entry.parent, "ssh")?.delete(String(entry.id))
108
+ writeTombstone(entry.parent, entry.id, status, "ssh")
109
+ }
110
+
111
+ /** 硬收尾(杀树 + 出池 + 墓碑;退出 / 闲置 / 中止三径共用)。 */
112
+ function hardClose(entry, state, status) {
113
+ clearIdle(entry)
114
+ try { killProcessTree(entry.child) } catch { /* 尽力面 */ }
115
+ finalize(entry, { state, status })
116
+ }
117
+
118
+ /** 闲置自动关闭(§6.22 ④):关闭 + 注记(注入面照异步结算先例)。 */
119
+ function armIdle(entry) {
120
+ clearIdle(entry)
121
+ entry.idleTimer = setTimeout(() => {
122
+ const idleS = Math.round((Date.now() - entry.lastActivityAt) / 1000)
123
+ const parent = entry.parent
124
+ hardClose(entry, "closed", "consumed")
125
+ if (parent?.history) {
126
+ pushReal(parent, {
127
+ role: "user",
128
+ content: `[System reminder: ssh session ${idLabel(entry)} (${escapeLite(entry.host)}) was closed after ${idleS}s idle (no send/read). Open a new session if you still need it.]`,
129
+ })
130
+ }
131
+ }, idleMs())
132
+ entry.idleTimer.unref?.()
133
+ }
134
+
135
+ /** 转义(`agent/helpers.mjs escapeXml` 同式——本档只此一处插值,自持小实现避免多一跳)。 */
136
+ function escapeLite(s) {
137
+ return String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
138
+ }
139
+
140
+ /** 新建条目(领取 id → 令牌消费同步配对 → 入池守卫——ED-5 同式,逐字照 `bash-async.mjs`)。 */
141
+ function createEntry(parent, id, { host, user, port, label }) {
142
+ const entry = {
143
+ id, host, user: user ?? null, port: port ?? null, label: label ?? null,
144
+ parent, child: null, state: "connecting",
145
+ buf: "", bufDropped: false, bytes: 0, _notify: null, _waiters: new Set(),
146
+ promptRe: null, mask: createMask(),
147
+ askpassFile: null, logPath: join(toolResultsDir(), `${Date.now()}-ssh-${id}.log`), logStream: null,
148
+ startedAt: Date.now(), lastActivityAt: Date.now(), lastDataAt: Date.now(),
149
+ idleTimer: null, closing: false, controller: null, exitInfo: null, spawnError: null,
150
+ // 远端断开(会话态子进程退出)⇒ 终态 + 墓碑(E5);探连期退出不走此钩(由 connect 判定径处置)。
151
+ onRemoteExit: (e) => finalize(e, { state: "dead", status: "failed" }),
152
+ }
153
+ return entry
154
+ }
155
+
156
+ /** 杀单点(会话中止收尾面共用——`killBgTree` 同式)。 */
157
+ export function killSshTree(entry) {
158
+ entry.discarded = true
159
+ clearIdle(entry)
160
+ try { killProcessTree(entry.child) } catch { /* 尽力面 */ }
161
+ try { entry.logStream?.end() } catch { /* 尽力面 */ }
162
+ unlinkAskpass(entry)
163
+ }
164
+
165
+ // ─── 非密预填档(§6.22 ⑨)──────────────────────────────────────────────────
166
+
167
+ const hostsFile = () => join(sshConfigDir(), HOSTS_FILE)
168
+
169
+ function lookupHostRecord(host, port) {
170
+ try {
171
+ const all = JSON.parse(readFileSync(hostsFile(), "utf8"))
172
+ const rec = all?.[hostKey(host, port)]
173
+ return rec && typeof rec === "object" ? rec : null
174
+ } catch { return null }
175
+ }
176
+
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
181
+ try {
182
+ mkdirSync(sshConfigDir(), { recursive: true })
183
+ let all = {}
184
+ try { all = JSON.parse(readFileSync(hostsFile(), "utf8")) ?? {} } catch { all = {} }
185
+ if (typeof all !== "object" || all === null) all = {}
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
+ }
191
+ writeFileSync(hostsFile(), JSON.stringify(all, null, 2), "utf8")
192
+ } catch { /* 预填 = 增益面——写失败不进连接语义 */ }
193
+ }
194
+
195
+ // ─── 动作(工具面薄接线消费)────────────────────────────────────────────────
196
+
197
+ /**
198
+ * 连接(§6.22 ⑤ + 裁定 A 预探路 + 保存面):探连 ⇒ 升格 ∥ 认证失败 ⇒ **先读保存件**(命中 ⇒ 带密
199
+ * 会话零窗)⇒ 无 / 不可用 / 被拒 ⇒ 凭据窗(窗内可勾选保存)⇒ 单次带密会话。
200
+ * 优先级铁序(⑩):保存件**恒不先于探连**——密钥可用 ⇒ 保存件零读取(S6/N6/B7)。
201
+ * @returns {{ text?: string, error?: string }}
202
+ */
203
+ export async function connectSsh(parent, ctx, { host, user, port, label, promptPattern, identityFile } = {}) {
204
+ if (!parent) return { error: "no agent context — ssh sessions need a session-level pool (depth-0 only)" }
205
+ if (typeof host !== "string" || !host.trim()) return { error: "connect requires host" }
206
+ const pool = sshPool(parent)
207
+ const live = [...pool.values()].filter((e) => e.state === "connecting" || e.state === "ready")
208
+ if (live.length >= SSH_SESSION_MAX) {
209
+ return {
210
+ error: `ssh session cap reached (${live.length}/${SSH_SESSION_MAX} live: ${live.map(idLabel).join(", ")})` +
211
+ " — close one (action:'close') before opening another",
212
+ }
213
+ }
214
+ let promptRe = null
215
+ if (promptPattern) {
216
+ try { promptRe = new RegExp(String(promptPattern)) } catch (e) { return { error: `promptPattern is not a valid regex: ${e.message}` } }
217
+ }
218
+ const record = lookupHostRecord(host, port)
219
+ const effectiveUser = user || record?.user || null // 判定序:connect 参数 → ⑨ 记录 → 皆缺视同无
220
+ const userFromRecord = !user && record?.user ? record.user : null
221
+ const identity = identityFile || record?.identityFile || null // ⑩:参数 → ⑨ 记录
222
+ const id = nextSubagentId(parent)
223
+ consumeSubagentToken(parent, id, "ssh connect", "ssh")
224
+ await cleanupOldToolResults(toolResultsDir())
225
+ mkdirSync(toolResultsDir(), { recursive: true })
226
+ const entry = createEntry(parent, id, { host, user: effectiveUser, port, label })
227
+ entry.promptRe = promptRe
228
+ entry._notify = ctx?.onOutput ?? null
229
+ const ctrl = new AbortController()
230
+ entry.controller = ctrl
231
+ bindChildController(ctrl, buildChildSignal(parent, ctx)) // 回合中断(Ctrl+I)不中止 ⇒ 会话存活(F2 豁免)
232
+ entry.askpassFile = writeAskpassWrapper(id)
233
+ entry.logStream = createWriteStream(entry.logPath, { flags: "a" })
234
+ entry.logStream.on("error", () => { /* 尽力面 */ })
235
+ /** 沉降等待(探连 ∥ 带密会话同径):返回 reason + 已等时长。 */
236
+ const settle = async () => {
237
+ const t0 = Date.now()
238
+ const reason = await waitSettled(entry, { signal: ctx?.signal ?? null })
239
+ return { reason, waitedMs: Date.now() - t0 }
240
+ }
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 = [] } = {}) => {
250
+ entry.state = "ready"
251
+ entry.lastActivityAt = Date.now()
252
+ armIdle(entry)
253
+ saveHostRecord(entry.host, entry.port, entry.user, { identity, auth })
254
+ const { text, dropped } = drain(entry)
255
+ const notes = [
256
+ `— connected (${settleNote(entry, reason, waitedMs)})`,
257
+ `log: ${entry.logPath}`,
258
+ userFromRecord ? `user "${userFromRecord}" came from the local ssh-hosts record` : "",
259
+ ...extra,
260
+ dropped ? "in-memory buffer overflowed — full output in the log" : "",
261
+ ].filter(Boolean)
262
+ return { text: `${statusLine(entry)}\n${bodyText(capText(text, SSH_RETURN_CAP, entry))}\n${notes.join("; ")}` }
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))
268
+ try {
269
+ assertPoolKeyFree(pool, id, "ssh")
270
+ pool.set(String(id), entry)
271
+ spawn(effectiveUser, null) // 探连:全会话形 + NumberOfPasswordPrompts=0(不读保存件——铁序)
272
+ let { reason, waitedMs } = await settle()
273
+ // 「已就绪」判据要求进程尚在(静默窗与退出同刻的竞争面——升格一条死连接不成立)。
274
+ if ((reason === "prompt" || reason === "silence") && entry.exitInfo) reason = "exited"
275
+ if (reason === "aborted") {
276
+ hardClose(entry, "closed", "consumed")
277
+ return { error: `ssh connect interrupted before the session was ready (${entry.host})` }
278
+ }
279
+ if (reason === "timeout") {
280
+ hardClose(entry, "closed", "consumed")
281
+ return { error: `ssh connect did not settle within ${SSH_TIMEOUT_MS}ms (${entry.host}) — the connection is closed; retry or increase the wait` }
282
+ }
283
+ let auth = "key"
284
+ const flowNotes = []
285
+ if (reason === "exited") {
286
+ const cls = classify()
287
+ if (!cls || cls.kind !== "auth") {
288
+ const text = failureText(entry, cls)
289
+ hardClose(entry, "closed", "connect-failed") // 杀树收尾(finalize 只出池——活子进程会成无句柄悬挂)
290
+ return { error: text }
291
+ }
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() })
328
+ if (!input) {
329
+ hardClose(entry, "closed", "consumed")
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)` : ""}` }
331
+ }
332
+ if (!String(input.password ?? "").length) {
333
+ hardClose(entry, "closed", "consumed")
334
+ return { error: "ssh connect cancelled — an empty password is never submitted (submit a password or cancel the window)" }
335
+ }
336
+ entry.user = input.user || effectiveUser || null
337
+ entry.mask.add(input.password) // 遮蔽先注册后写入(回显面零泄)
338
+ spawn(entry.user, input.password) // 单次带密会话(PP=1)
339
+ ;({ reason, waitedMs } = await settle())
340
+ if ((reason === "prompt" || reason === "silence") && entry.exitInfo) reason = "exited"
341
+ if (reason !== "prompt" && reason !== "silence") {
342
+ const cls2 = entry.spawnError ? { message: "ssh client not available" } : classifyFailure(entry.buf)
343
+ const text = failureText(entry, cls2 ?? { message: "authentication rejected" }, " — the session was not established")
344
+ // 带密会话未就绪(aborted ∥ timeout)时子进程可能仍活——必走杀树径(否则出池后无句柄可收)。
345
+ hardClose(entry, "closed", "connect-failed")
346
+ return { error: text }
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"
359
+ }
360
+ return promote(reason, waitedMs, { auth, extra: flowNotes })
361
+ } catch (e) {
362
+ hardClose(entry, "closed", "connect-failed") // 异常径同走杀树(子进程可能仍在飞)
363
+ return { error: e?.noUi ? `Error: ${e.message}` : `ssh connect failed: ${e?.message ?? String(e)}` }
364
+ } finally {
365
+ entry._notify = null
366
+ }
367
+ }
368
+
369
+ /** 缓冲溢出注记(三动作同式——丢弃面显式化;全量恒在 log)。 */
370
+ const droppedNote = (dropped) => (dropped ? "\n— in-memory buffer overflowed — full output in the log" : "")
371
+
372
+ /** `send`(§6.22 动作表):不结尾换行 ⇒ 补 `\r`(设备 Enter 语义);沉降到点返回该轮输出。 */
373
+ export async function sendSsh(parent, ctx, { id, data, settle = true, timeoutMs } = {}) {
374
+ const entry = findEntry(parent, id)
375
+ if (!entry) return { error: missingEntry(parent, id) }
376
+ if (entry.state !== "ready") return { error: `ssh session ${idLabel(entry)} is not ready (state: ${entry.state})` }
377
+ const payload = String(data ?? "")
378
+ if (!payload) return { error: "send requires data" }
379
+ const line = /[\r\n]$/.test(payload) ? payload : `${payload}\r`
380
+ entry.lastActivityAt = Date.now()
381
+ armIdle(entry)
382
+ try { entry.child.stdin.write(line) } catch (e) { return { error: `ssh session ${idLabel(entry)} write failed: ${e?.message ?? String(e)}` } }
383
+ if (settle === false) {
384
+ return { text: `${statusLine(entry)}\n(written without waiting for settle — use action:'read' to collect the output)` }
385
+ }
386
+ entry._notify = ctx?.onOutput ?? null
387
+ const t0 = Date.now()
388
+ const reason = await waitSettled(entry, { timeoutMs, signal: ctx?.signal ?? null })
389
+ entry._notify = null
390
+ const { text, dropped } = drain(entry)
391
+ return {
392
+ text: `${statusLine(entry)}\n${bodyText(capText(text, SSH_RETURN_CAP, entry))}\n— ${settleNote(entry, reason, Date.now() - t0)}${droppedNote(dropped)}`,
393
+ }
394
+ }
395
+
396
+ /** `read`:上次读后新增输出(存量即返;无则等至静默/超时——§6.22 ③/⑥)。 */
397
+ export async function readSsh(parent, ctx, { id, timeoutMs, maxChars } = {}) {
398
+ const entry = findEntry(parent, id)
399
+ if (!entry) return { error: missingEntry(parent, id) }
400
+ if (entry.state !== "ready") return { error: `ssh session ${idLabel(entry)} is not ready (state: ${entry.state})` }
401
+ const cap = Number.isFinite(Number(maxChars)) && Number(maxChars) > 0 ? Math.floor(Number(maxChars)) : SSH_RETURN_CAP
402
+ entry.lastActivityAt = Date.now()
403
+ armIdle(entry)
404
+ if (!entry.buf) {
405
+ entry._notify = ctx?.onOutput ?? null
406
+ const t0 = Date.now()
407
+ const reason = await waitSettled(entry, { timeoutMs, signal: ctx?.signal ?? null })
408
+ entry._notify = null
409
+ const { text, dropped } = drain(entry)
410
+ const body = text ? bodyText(capText(text, cap, entry)) : "(no new output)"
411
+ return { text: `${statusLine(entry)}\n${body}\n— ${settleNote(entry, reason, Date.now() - t0)}${droppedNote(dropped)}` }
412
+ }
413
+ const { text, dropped } = drain(entry)
414
+ return { text: `${statusLine(entry)}\n${bodyText(capText(text, cap, entry))}\n— buffered output since the last read${droppedNote(dropped)}` }
415
+ }
416
+
417
+ /** `secret_input`(§6.22 动作表 + ⑦):凭据窗 → 掩码写入 + `\r`;值永不出现于返回。 */
418
+ export async function secretInputSsh(parent, ctx, { id, label } = {}) {
419
+ const entry = findEntry(parent, id)
420
+ if (!entry) return { error: missingEntry(parent, id) }
421
+ if (entry.state !== "ready") return { error: `ssh session ${idLabel(entry)} is not ready (state: ${entry.state})` }
422
+ if (typeof ctx?.onSecretInput !== "function") {
423
+ return { error: `Error: ${t("ssh.no_ui")}` }
424
+ }
425
+ const res = await ctx.onSecretInput({
426
+ kind: "ssh-secret",
427
+ sessionId: idLabel(entry),
428
+ host: entry.host, user: entry.user, port: entry.port,
429
+ fields: [{ name: "secret", masked: true, required: true, prefill: "" }],
430
+ keys: { title: "ssh.secretTitle", prompt: "ssh.secretPrompt" },
431
+ })
432
+ const value = res && typeof res === "object" ? String(res.secret ?? "") : ""
433
+ if (!value) return { text: `${statusLine(entry)}\n(secret input cancelled — nothing was written)` }
434
+ entry.mask.add(value) // 先注册遮蔽,后写入(回显面零泄)
435
+ entry.lastActivityAt = Date.now()
436
+ armIdle(entry)
437
+ try { entry.child.stdin.write(`${value}\r`) } catch (e) { return { error: `ssh session ${idLabel(entry)} write failed: ${e?.message ?? String(e)}` } }
438
+ entry._notify = ctx?.onOutput ?? null
439
+ const t0 = Date.now()
440
+ const reason = await waitSettled(entry, { signal: ctx?.signal ?? null })
441
+ entry._notify = null
442
+ const { text, dropped } = drain(entry)
443
+ const tag = label ? ` (${escapeLite(label)})` : ""
444
+ return { text: `${statusLine(entry)}\nsecret written${tag} — value never echoed\n${bodyText(capText(text, SSH_RETURN_CAP, entry))}\n— ${settleNote(entry, reason, Date.now() - t0)}${droppedNote(dropped)}` }
445
+ }
446
+
447
+ /** `close`:stdin 结束 → 1.5 s 宽限 → 杀树;返回末段输出 + 关闭确认。 */
448
+ export async function closeSsh(parent, ctx, { id } = {}) {
449
+ const entry = findEntry(parent, id)
450
+ if (!entry) return { error: missingEntry(parent, id) }
451
+ const { text, dropped } = drain(entry)
452
+ if (entry.state !== "ready") {
453
+ const was = entry.state
454
+ finalize(entry, { state: "closed", status: "consumed" })
455
+ return { text: `${statusLine(entry)}\n${bodyText(capText(text, SSH_RETURN_CAP, entry))}\n— closed (it was already ${was})` }
456
+ }
457
+ entry.closing = true
458
+ try { entry.child.stdin.end() } catch { /* 进程可能已退出——宽限后杀树 */ }
459
+ const exited = await new Promise((resolve) => {
460
+ const t = setTimeout(() => resolve(false), CLOSE_GRACE_MS)
461
+ entry.child.once("exit", () => { clearTimeout(t); resolve(true) })
462
+ })
463
+ if (!exited) killProcessTree(entry.child)
464
+ finalize(entry, { state: "closed", status: "consumed" })
465
+ return { text: `${statusLine(entry)}\n${bodyText(capText(text, SSH_RETURN_CAP, entry))}\n— closed${exited ? "" : " (the remote did not exit within 1.5s — the process tree was killed)"}${droppedNote(dropped)}` }
466
+ }
467
+
468
+ /** `list`:在活会话清单(id / host / user / 状态 / 空闲秒)——上下文压缩后的恢复路。 */
469
+ export function listSsh(parent) {
470
+ const pool = getAsyncPool(parent, "ssh")
471
+ const live = [...(pool?.values() ?? [])].filter((e) => e.state === "connecting" || e.state === "ready")
472
+ if (live.length === 0) return { text: "(no live ssh sessions)" }
473
+ const lines = live.sort((a, b) => a.id - b.id).map((e) => {
474
+ const idle = Math.max(0, Math.round((Date.now() - e.lastActivityAt) / 1000))
475
+ return `[ssh (${idLabel(e)}) ${e.host}${e.user ? ` user ${e.user}` : ""} — ${e.state} · idle ${idle}s]`
476
+ })
477
+ return { text: lines.join("\n") }
478
+ }
479
+
480
+ // 常量转口(工具面 / 测试档经本档取——机制档不重复导出)。
481
+ export { SSH_SETTLE_MS, SSH_TIMEOUT_MS, SSH_RETURN_CAP }
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"