@thincoder/core 0.10.6 → 0.10.8

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