@rjfan/dsh-presence 0.19.0 → 0.20.1

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.
package/README.md CHANGED
@@ -22,6 +22,14 @@ forwards for coding agents — with **nothing installed on the remote host**.
22
22
 
23
23
  ---
24
24
 
25
+ > ⚠️ **A local session is not bound to a remote workspace.** Opening a remote workspace in
26
+ > Presence does not turn a local agent session into a remote one. When your session's
27
+ > working directory is outside the presence mounts root, `presence_exec` and every other
28
+ > `presence_*` tool require an explicit `host` (or `PRESENCE_HOST`); without one they refuse
29
+ > with the session's cwd instead of silently running on the last-activated machine. Sessions
30
+ > started inside a remote workspace resolve to that machine automatically. See
31
+ > [Known limits](#known-limits).
32
+
25
33
  ## Why
26
34
 
27
35
  Running an agent on every remote machine means installing it N times, upgrading it N
@@ -44,6 +52,10 @@ connection per host**. The remote side is still plain `sshd`: SFTP, exec and a P
44
52
  - **Native remote tools** — `rg`/`grep` runs on the target, commands run in the target shell; path, permission and encoding semantics are the remote ones.
45
53
  - **Local-like edits** — atomic writes, mode preservation, ambiguity refusal, read-before-write guard, `presence_edit` with dry-run diffs.
46
54
  - **Real workspaces** — a remote directory becomes a normal DSH workspace with a file tree and terminals that open **on the remote machine**.
55
+ - **Sessions are scoped (0.20 hotfix)** — a local session no longer silently borrows the last-activated remote workspace: `presence_*` needs an explicit `host` or `PRESENCE_HOST`, and the system prompt tells each session whether it is local or remote.
56
+ - **Conditions instead of sleeps** — `presence_wait` blocks on a file (`exists`/`minBytes`/`mtimeAfter`), a grep match count, a process (`pid`/`pattern`), a port state or a job id; a timeout is `met:false`, not an error.
57
+ - **Jobs that survive a restart** — background jobs are persisted to `~/.dsh/presence/jobs.json` with pid/pgid. `logPath` is the job log to tail; `commandLogPath` is the command's own log and equals `logPath` on current kernels (a separate wrapper log only exists when a kernel records one) — use `commandLogPath ?? logPath`. After a restart their state is `unknown` until probed — never a false `running`, and an exit code may be missing.
58
+ - **Batch transfer and append** — `presence_import`/`presence_export` take `paths[]` (1–50) or a glob and return per-file results; `presence_write { mode:"append" }` appends without rewriting the file.
47
59
  - **Recoverable deletes** — destructive shell commands dry-run first; `presence_trash` moves paths to a remote trash with restore.
48
60
  - **`@` completes remote files** — inside a remote workspace, file-reference completion indexes the machine itself (`rg --files`, `find` fallback), not a local mirror.
49
61
  - **File operations, not just reads** — `presence_mkdir` / `move` / `copy` / `chmod` plus a right-click menu in the file tree; deletes go to the recoverable trash.
@@ -95,7 +107,7 @@ higher median interaction latency.</sub>
95
107
  ### 1. Install the DSH plugin
96
108
 
97
109
  ```sh
98
- dsh plugin add @rjfan/dsh-presence@0.19.0
110
+ dsh plugin add @rjfan/dsh-presence@0.20.1
99
111
  ```
100
112
 
101
113
  From a source checkout:
@@ -110,6 +122,10 @@ Open **Settings → Presence → New**, choose a host from `~/.ssh/config` (or p
110
122
  manual `ssh` command), then pick a remote folder. It appears as a workspace; the
111
123
  left sidebar, file tree, terminal and agent tools all use that remote directory.
112
124
 
125
+ A session started in that workspace is remote. A session started anywhere else (for
126
+ example a normal local project) stays local: it must pass `host` explicitly — or set
127
+ `PRESENCE_HOST` — before calling `presence_exec` / `presence_*`.
128
+
113
129
  ### 3. Work
114
130
 
115
131
  ```text
@@ -184,25 +200,27 @@ Measured on a Linux host:
184
200
 
185
201
  ## Tools
186
202
 
187
- The DSH plugin exposes 24 tools. Relative paths resolve against the active remote
188
- workspace; `host` is optional inside a workspace. Argument aliases are accepted at
189
- execute time (`path`/`file_path`/`filePath`, `old`/`oldString`, `new`/`newString`,
190
- `cwd`/`workdir`, …), so an agent's habitual spelling still works.
203
+ The DSH plugin exposes 25 tools. Inside a remote-workspace session relative paths
204
+ resolve against that workspace and `host` is optional; a **local** session must pass
205
+ `host` explicitly (or configure `PRESENCE_HOST`) — there is no fallback to the globally
206
+ last-activated workspace. Argument aliases are accepted at execute time
207
+ (`path`/`file_path`/`filePath`, `old`/`oldString`, `new`/`newString`, `cwd`/`workdir`, …),
208
+ so an agent's habitual spelling still works.
191
209
 
192
210
  <details>
193
211
  <summary><b>Show all tools</b></summary>
194
212
 
195
213
  | Tool | What it does |
196
214
  | --- | --- |
197
- | `presence_hosts` | List SSH hosts, connection state and default host (`format:"json"` supported); `workspace:true` marks this session's machine and `degraded` rows carry the last error. |
215
+ | `presence_hosts` | List SSH hosts, connection state and default host (`format:"json"` supported); `workspace:true` marks this session's machine and `degraded` rows carry the last error. `default` is session-scoped: this session's mounted host or `PRESENCE_HOST`, never a global last-active workspace. |
198
216
  | `presence_connect` | Open/reuse a host connection and report the remote identity. |
199
- | `presence_exec` | Run a remote command; `pipefail`, `background`, `waitForPort`, structured JSON, `saveStdout`; timeout defaults to 300 s; `progress` normalizes `\r` progress bars; long output spills to a remote file whose path is returned (`tailLines`); destructive commands dry-run first — `confirm:true` runs the whole command, `confirm:"partial"` runs only the safe segments. |
200
- | `presence_jobs` | Manage background jobs: `list`, `status`, `wait` (with `timedOut`), `stop` (with `alive`/`verified`/`portReleased`). |
217
+ | `presence_exec` | Run a remote command; `pipefail`, `background`, `waitForPort`, structured JSON, `saveStdout`; timeout defaults to 300 s; `progress` normalizes `\r` progress bars; long output spills to a remote file whose path is returned (`tailLines`); text reports start with `host: <alias> remote cwd: <abs>`; destructive commands dry-run first — `confirm:true` runs the whole command, `confirm:"partial"` runs only the safe segments. A local session must pass `host`. |
218
+ | `presence_jobs` | Background jobs: `list`, `status`, `wait` (with `timedOut`), `stop` (with `alive`/`verified`/`portReleased`), `purge`. Jobs persist to `~/.dsh/presence/jobs.json` with `pid`/`pgid`; `logPath` is the job log to tail (the command's stdout/stderr on current kernels) and `commandLogPath` the command's own log — equal to `logPath` on current kernels, so use `commandLogPath ?? logPath`. After a restart, state is `unknown` until a liveness probe succeeds and the exit code may be missing. `/presence/api/state` job rows also carry `pid`, `pgid`, `commandLogPath` and `persisted`. |
219
+ | `presence_wait` | Block until exactly one remote condition is true: `file {path, exists?, minBytes?, mtimeAfter?}`, `grep {path, pattern, count?}`, `process {pid\|pattern, alive?}`, `port {port, state:"open"\|"closed"}` or `job {jobId}` (a job condition needs no `host` — the registry knows where it ran). Returns `{ met, timedOut, elapsedMs, observed, condition, polls }`; a timeout is `met:false` and **never an error**. `timeoutMs` default 120 s (cap 1 h), `pollMs`/`intervalMs` default 1000 ms. |
201
220
  | `presence_read` | Read a remote file with line numbers, `offset`/`limit`, or `raw:true` for verbatim content. |
202
221
  | `presence_read_many` | Read 1–50 remote files in one call, with per-file byte/line caps. |
203
- | `presence_write` | Atomic write; preserves mode; returns real mode, size and full sha256; `ifMatchSha256` optimistic lock. |
222
+ | `presence_write` | Atomic write; preserves mode; returns real mode, size and full sha256; `ifMatchSha256` optimistic lock; `mode:"append"` appends at EOF and reports appended bytes + total size (`ifMatchSha256` is overwrite-only and append rejects it); `mode` also accepts an octal string such as `"755"` to set permissions on an overwrite. |
204
223
  | `presence_edit` | Exact-text replacement or line-range replacement, with compact diff, `dryRun`, JSON fields. |
205
- | `presence_ls` | List a directory; `recursive`, `totalBytes`, JSON output. |
206
224
  | `presence_stat` | Batch `stat` for 1–100 paths: type, size, mode, mtime, optional sha256; missing paths return `exists:false`. |
207
225
  | `presence_mkdir` | Create a remote directory (recursive by default); idempotent when it already exists. |
208
226
  | `presence_move` | Rename or move a path inside the same machine; refuses to replace an existing file unless `overwrite:true`. |
@@ -212,8 +230,8 @@ execute time (`path`/`file_path`/`filePath`, `old`/`oldString`, `new`/`newString
212
230
  | `presence_tail` | Read the last N lines of a remote file, or follow it for a bounded time (`follow:true`, `timeoutMs`). |
213
231
  | `presence_search` | Remote `rg`/`grep` search, count-only mode, JSON matches. |
214
232
  | `presence_glob` | Find remote files by glob with size/mtime. |
215
- | `presence_import` | Local → remote binary-safe upload with mode and sha256. |
216
- | `presence_export` | Remote → local real file (content-addressed), for preview or native apps. |
233
+ | `presence_import` | Local → remote binary-safe upload with mode and sha256; batch `paths[]` (1–50) and/or a local `glob` into a `to` directory, with one result per file. |
234
+ | `presence_export` | Remote → local real file (content-addressed), for preview or native apps; batch `paths[]` or a remote `glob`; the local name defaults to the basename and a collision becomes `name-<sha8>.ext`; one result per file. |
217
235
  | `presence_forward` | Forward a remote port to `127.0.0.1`; open/list/close with JSON. |
218
236
  | `presence_port_probe` | Check listeners and TCP reachability for up to 20 ports; `expect:open|closed`, polling. |
219
237
  | `presence_http_probe` | Wait for port → forward → local HTTP assertions → close → verify the local port was released. |
@@ -222,14 +240,29 @@ execute time (`path`/`file_path`/`filePath`, `old`/`oldString`, `new`/`newString
222
240
 
223
241
  </details>
224
242
 
243
+ ### Batch transfer and append
244
+
245
+ ```text
246
+ presence_export { paths: ["/data/a.pt", "/data/b.pt"] } # files:[{remotePath, localPath, bytes, sha256}]
247
+ presence_export { glob: "/data/ckpt-*.pt" } # every match; name-<sha8>.ext on collision
248
+ presence_import { glob: "dist/*.whl", to: "/data/wheels" } # local → remote, one result per file
249
+ presence_write { path: "/data/train.log", mode: "append", content: "epoch 6 done\n" }
250
+ ```
251
+
252
+ Single-path calls keep the existing `path`/`to` behaviour, so none of this is required for
253
+ one file.
254
+
225
255
  ## Which tool for which path
226
256
 
227
257
  | Where the path lives | Use | Why |
228
258
  | --- | --- | --- |
229
259
  | under the workspace mount (`~/.dsh/presence/mounts/...`) | built-in `read` / `write` / `edit` / `grep` / `glob` | they reach the remote machine; `grep`/`glob` are routed to it automatically |
230
- | remote absolute path outside the workspace (`/data`, `/scratch`, GPFS) | `presence_read` / `write` / `edit` / `ls` / `stat` / `search` / `glob` | the built-in tools only see the mount, not the rest of the machine |
260
+ | remote absolute path outside the workspace (`/data`, `/scratch`, GPFS) | `presence_read` / `write` / `edit` / `stat` / `search` / `glob` (list a directory with `presence_exec`) | the built-in tools only see the mount, not the rest of the machine |
231
261
  | local file, for preview or Office | `presence_export` first, then `present` / native open | the mount path is virtual; only a real local file can be opened natively |
232
262
 
263
+ In a **local** session (cwd outside the mounts root) the built-in tools only touch local
264
+ files; every `presence_*` call needs `host` explicitly, or configure `PRESENCE_HOST` once.
265
+
233
266
  ## Long-running tasks
234
267
 
235
268
  `presence_exec` waits for the command, so it is the wrong tool for a training run, a
@@ -237,13 +270,23 @@ build or a server. The recipe:
237
270
 
238
271
  ```text
239
272
  1. presence_exec { command: "python train.py --lr 3e-4", background: true, label: "train-3e-4" }
240
- → { id: "j7", pid: 4242, logPath: "/tmp/presence-jobs/....log" }
241
- 2. presence_jobs { action: "wait", jobId: "j7" } # blocks; no sleep+poll loop
242
- 3. presence_tail { path: "/tmp/presence-jobs/....log", follow: true } # watch progress
273
+ → { id: "j7", pid: 4242, pgid: 4242, logPath: ".../job.log", commandLogPath: ".../job.out" }
274
+ 2. presence_jobs { action: "wait", jobId: "j7" } # or presence_wait { job: { jobId: "j7" } }
275
+ 3. presence_tail { path: <logPath>, follow: true } # watch progress
276
+ presence_wait { grep: { path: <logPath>, pattern: "epoch 6", count: 1 } } # readiness
243
277
  ```
244
278
 
245
279
  - `presence_jobs { action: "status" }` gives a snapshot; `stop` terminates the process
246
- group and verifies the port was released.
280
+ group and verifies the port was released. Jobs persist to `~/.dsh/presence/jobs.json`:
281
+ after a DSH restart a job shows `unknown` until a liveness probe succeeds (never a false
282
+ `running`) and its exit code may be missing.
283
+ - `logPath` is the job log to tail — the command's stdout/stderr on current kernels.
284
+ `commandLogPath` is the command's own log; it equals `logPath` unless the kernel records
285
+ a separate wrapper log, so tail `commandLogPath ?? logPath`.
286
+ - `presence_wait` replaces hand-written sleep+tail loops: wait for a port
287
+ (`presence_wait { port: { port: 8000, state: "open" } }`), a checkpoint
288
+ (`presence_wait { file: { path: "/data/ckpt.pt", minBytes: 1000000 } }`), a log line or
289
+ a process. A timeout returns `met:false` — a normal answer, not an error.
247
290
  - Interactive work: `presence_terminal { action: "open", session: "train" }` — the
248
291
  tmux-backed session survives a reconnect.
249
292
  - **Do not** start `nohup`, `tmux new-session` or a trailing `&` inside a foreground
@@ -267,7 +310,11 @@ build or a server. The recipe:
267
310
  - **Writes are atomic** and keep the original file mode; `presence_edit` refuses
268
311
  ambiguous anchors instead of guessing.
269
312
  - **Read before write**: built-in `read`/`write`/`edit` share a read-before-write
270
- guard; `presence_write` records the observation so both APIs can be mixed.
313
+ guard. Path keys are canonicalised across `presence://`, mount paths, workspace-relative
314
+ and absolute remote paths, and successful `presence_read` / `presence_write` /
315
+ `presence_edit` calls count as a read (a write counts too). `presence_exec` running
316
+ `cat file` does **not** count — the guard cannot see shell output; read with
317
+ `presence_read` or the built-in `read` before editing.
271
318
 
272
319
  ## Configuration
273
320
 
@@ -275,8 +322,9 @@ The plugin reads these environment variables on the host process:
275
322
 
276
323
  | Variable | Default | Meaning |
277
324
  | --- | --- | --- |
278
- | `PRESENCE_HOST` | — | Default SSH host (alias from `~/.ssh/config` or a manual host). |
325
+ | `PRESENCE_HOST` | — | Default SSH host for **local** sessions (explicit opt-in; also used by the MCP server). Remote-workspace sessions ignore it in favour of their mounted host. |
279
326
  | `PRESENCE_WORKSPACE` | — | `host:/abs/path` to bind a remote workspace. |
327
+ | `PRESENCE_JOBS_FILE` | `~/.dsh/presence/jobs.json` | Override the persisted background-job registry path. |
280
328
  | `PRESENCE_PASSWORD_<ALIAS>` | — | Password for a host; `<ALIAS>` is uppercase with non-alphanumerics replaced by `_`. |
281
329
  | `PRESENCE_MAX_CHANNELS` | 8 | Concurrent channels per connection. |
282
330
  | `PRESENCE_CONNECT_TIMEOUT_MS` | 25000 | Connection attempt timeout. |
@@ -294,8 +342,16 @@ npm install --prefix .dev --ignore-scripts rollup @rollup/plugin-node-resolve \
294
342
  @rollup/plugin-commonjs @rollup/plugin-terser codemirror @codemirror/language-data \
295
343
  @codemirror/theme-one-dark
296
344
  node scripts/build-client.mjs
297
- powershell -NoProfile -ExecutionPolicy Bypass -File scripts/package.ps1 -Version 0.19.0
298
- # → dist/presence-plugin-0.19.0.zip
345
+ powershell -NoProfile -ExecutionPolicy Bypass -File scripts/package.ps1 -Version 0.20.1
346
+ # → dist/presence-plugin-0.20.1.zip
347
+ ```
348
+
349
+ After a DSH update, prove the plugin still activates against the shipped runtime
350
+ (Electron-as-Node; this is what caught the 0.2.1-rc.5 tool-schema tightening):
351
+
352
+ ```powershell
353
+ $env:ELECTRON_RUN_AS_NODE = '1'
354
+ & 'C:\...\DeepSeek Harness.exe' scripts\host-compat-check.mjs # ACTIVATED OK with 25 tools
299
355
  ```
300
356
 
301
357
  Tests need one SSH-reachable host unless noted:
@@ -307,17 +363,30 @@ node scripts/feature-probe.mjs server # wait, sessions, pgid, readiness, search
307
363
  node scripts/import-probe.mjs server # local→remote binary, mode, sha256
308
364
  node scripts/safety-probe.mjs server # destructive guard + observation store
309
365
  node scripts/p0-guard-probe.mjs # guard split/partial/single-rm (no host needed)
366
+ node scripts/p1-docs-probe.mjs # README/zh/RELEASE anchors (no host needed)
310
367
  ```
311
368
 
312
369
  ## Known limits
313
370
 
371
+ - **Local sessions.** A local session calling `presence_*` without `host` now raises
372
+ `this session is local (cwd=...); pass an explicit host ...`. That is the intended 0.20
373
+ behavior, not a bug: the old silent fallback to the last-activated remote workspace could
374
+ run commands on another session's machine. Pass `host` per call, or set `PRESENCE_HOST`
375
+ once as the explicit opt-in default; a local session still never inherits another
376
+ session's workspace, and `presence_hosts`' `default` is session-scoped for the same reason.
377
+ - The read-before-write guard cannot observe shell output: `presence_exec { command:
378
+ "cat file" }` (or `sed -n`) does **not** mark the file as read; `presence_read` and the
379
+ built-in `read` do, and `presence_write`/`presence_edit` count as reads. A guarded
380
+ built-in `edit` after only `exec cat` can still fail.
314
381
  - The mount path (`~/.dsh/presence/mounts/...`) is virtual: it works through
315
382
  `ctx.fs` / agent tools, but native apps (Explorer, Office, host Python) cannot
316
383
  see it — use `presence_export` first.
317
384
  - `presence_exec` defaults to a 300 s timeout; longer work must use `background: true`
318
385
  (foreground output over the cap is spilled to a remote file, and `tailLines` keeps the
319
- end of a long report). Background jobs are tracked per session, not persisted across a
320
- DSH restart.
386
+ end of a long report). Background jobs persist to `~/.dsh/presence/jobs.json`; after a
387
+ DSH restart their state is `unknown` until a liveness probe succeeds — never assume a
388
+ persisted job is still running, and its exit code may be missing. Tail
389
+ `commandLogPath ?? logPath` (`logPath` holds the command output on current kernels).
321
390
  - A login node may clean up processes left behind when a session closes: do not rely on
322
391
  `nohup` / `tmux new-session` / `&` started inside a foreground `presence_exec` for long
323
392
  jobs. Use `background: true` (detached with `setsid`) or `presence_terminal` (tmux),
package/README.zh-CN.md CHANGED
@@ -22,6 +22,12 @@
22
22
 
23
23
  ---
24
24
 
25
+ > ⚠️ **本地会话不会绑定远端工作区。** 在 Presence 里打开一个远程工作区,并不会把本地 agent
26
+ > 会话变成远端会话。当会话工作目录不在 presence 挂载根下时,`presence_exec` 及其他
27
+ > `presence_*` 工具必须显式传 `host`(或配置 `PRESENCE_HOST`);否则会带着当前 cwd 报错,
28
+ > 而不是静默跑到上一次激活的机器上。在远程工作区内启动的会话则自动解析到该机器。
29
+ > 详见[已知边界](#已知边界)。
30
+
25
31
  ## 为什么不是「agent 跑在远端」
26
32
 
27
33
  在每台服务器上装 agent,意味着装 N 次、升级 N 次、逐台配代理;让本地 agent 每次
@@ -43,6 +49,10 @@ Presence 让 agent 留在本机,**通过每台机器一条常驻 SSH 连接把
43
49
  - **用目标机的原生工具** —— `rg`/`grep` 在目标机跑,命令在目标 shell 里执行;路径、权限、编码都是远端的语义。
44
50
  - **接近本地的编辑** —— 原子写、保留 mode、锚点歧义拒绝、读后写守卫、`presence_edit` 的 dry-run diff。
45
51
  - **真正的远程工作区** —— 远端目录注册成普通 DSH 工作区,文件树、终端都**开在远端机器上**。
52
+ - **会话有作用域(0.20 热修)** —— 本地会话不再静默借用上一次激活的远程工作区:`presence_*` 必须显式传 `host` 或配置 `PRESENCE_HOST`;系统提示会告诉每个会话自己是本地还是远程工作区。
53
+ - **条件等待,不用 sleep** —— `presence_wait` 等文件(`exists`/`minBytes`/`mtimeAfter`)、grep 命中数、进程(`pid`/`pattern`)、端口状态或任务 id;超时返回 `met:false`,不是错误。
54
+ - **任务跨重启存活** —— 后台任务持久化到 `~/.dsh/presence/jobs.json`,带 pid/pgid;`logPath` 是要 tail 的任务日志,`commandLogPath` 是命令自己的日志(当前内核里等于 `logPath`;只有内核提供独立包装日志时才不同),用 `commandLogPath ?? logPath`;重启后状态是 `unknown` 直到探活,不会假装 `running`,退出码可能缺失。
55
+ - **批量传输与追加写** —— `presence_import`/`presence_export` 支持 `paths[]`(1–50)或 glob,逐文件返回;`presence_write { mode:"append" }` 追加写。
46
56
  - **可恢复的删除** —— 危险 shell 命令先 dry-run;`presence_trash` 移入远端回收站并可恢复。
47
57
  - **`@` 能补全远端文件** —— 远程工作区里,文件引用补全直接索引目标机(`rg --files`,回退 `find`),不是本地镜像。
48
58
  - **文件操作不只是读** —— `presence_mkdir` / `move` / `copy` / `chmod`,文件树右键菜单直接操作;删除走可恢复回收站。
@@ -56,7 +66,7 @@ Presence 让 agent 留在本机,**通过每台机器一条常驻 SSH 连接把
56
66
  ### 1. 安装 DSH 插件
57
67
 
58
68
  ```sh
59
- dsh plugin add @rjfan/dsh-presence@0.19.0
69
+ dsh plugin add @rjfan/dsh-presence@0.20.1
60
70
  ```
61
71
 
62
72
  从源码安装:
@@ -71,6 +81,9 @@ dsh plugin add /path/to/dsh-presence
71
81
  `ssh` 命令),再选一个远端目录。它会以工作区出现:左侧文件树、终端和 agent 工具
72
82
  都使用这个远端目录。
73
83
 
84
+ 在该工作区里启动的会话就是远程会话;在其它地方(例如普通本地项目)启动的会话仍是本地
85
+ 会话:调用 `presence_exec` / `presence_*` 前必须显式传 `host`,或配置 `PRESENCE_HOST`。
86
+
74
87
  ### 3. 开始用
75
88
 
76
89
  ```text
@@ -143,8 +156,9 @@ Linux 主机上的实测:
143
156
 
144
157
  ## 工具
145
158
 
146
- DSH 插件提供 24 个工具。相对路径相对当前远程工作区;在工作区里 `host` 可省略。执行时接受
147
- 参数别名(`path`/`file_path`/`filePath`、`old`/`oldString`、`new`/`newString`、
159
+ DSH 插件提供 25 个工具。在远程工作区会话里,相对路径相对该工作区、`host` 可省略;**本地**
160
+ 会话必须显式传 `host`(或配置 `PRESENCE_HOST`)—— 不会回退到全局最后激活的工作区。执行时
161
+ 接受参数别名(`path`/`file_path`/`filePath`、`old`/`oldString`、`new`/`newString`、
148
162
  `cwd`/`workdir` 等),习惯写法都能用。
149
163
 
150
164
  <details>
@@ -152,15 +166,15 @@ DSH 插件提供 24 个工具。相对路径相对当前远程工作区;在工
152
166
 
153
167
  | 工具 | 说明 |
154
168
  | --- | --- |
155
- | `presence_hosts` | 列出 SSH 主机、连接状态和默认主机(支持 `format:"json"`);`workspace:true` 标记当前会话的机器,`degraded` 行带最后一次错误。 |
169
+ | `presence_hosts` | 列出 SSH 主机、连接状态和默认主机(支持 `format:"json"`);`workspace:true` 标记当前会话的机器,`degraded` 行带最后一次错误。`default` 是会话作用域:当前会话的挂载主机或 `PRESENCE_HOST`,绝不是全局最后激活的工作区。 |
156
170
  | `presence_connect` | 建立/复用连接并返回远端身份。 |
157
- | `presence_exec` | 执行远端命令;`pipefail`、`background`、`waitForPort`、结构化 JSON、`saveStdout`;超时默认 300 秒;`progress` 归一化 `\r` 进度条;超长输出落盘到远端文件并返回路径(`tailLines`);危险命令先 dry-run —— `confirm:true` 执行整条命令,`confirm:"partial"` 只执行安全分段。 |
158
- | `presence_jobs` | 后台任务:`list`、`status`、`wait`(含 `timedOut`)、`stop`(`alive`/`verified`/`portReleased`)。 |
171
+ | `presence_exec` | 执行远端命令;`pipefail`、`background`、`waitForPort`、结构化 JSON、`saveStdout`;超时默认 300 秒;`progress` 归一化 `\r` 进度条;超长输出落盘到远端文件并返回路径(`tailLines`);文本报告以 `host: <alias> remote cwd: <abs>` 开头;危险命令先 dry-run —— `confirm:true` 执行整条命令,`confirm:"partial"` 只执行安全分段。本地会话必须传 `host`。 |
172
+ | `presence_jobs` | 后台任务:`list`、`status`、`wait`(含 `timedOut`)、`stop`(`alive`/`verified`/`portReleased`)、`purge`。任务持久化到 `~/.dsh/presence/jobs.json`,含 `pid`/`pgid`;`logPath` 是要 tail 的任务日志(当前内核里即命令 stdout/stderr),`commandLogPath` 是命令自己的日志(当前内核里等于 `logPath`,用 `commandLogPath ?? logPath`)。重启后状态为 `unknown` 直到探活成功,退出码可能缺失。`/presence/api/state` 的 job 行还带 `pid`、`pgid`、`commandLogPath`、`persisted`。 |
173
+ | `presence_wait` | 阻塞直到某个远端条件成立(五选一):`file {path, exists?, minBytes?, mtimeAfter?}`、`grep {path, pattern, count?}`、`process {pid\|pattern, alive?}`、`port {port, state:"open"\|"closed"}`、`job {jobId}`(job 条件不需要 `host`,注册表知道它跑在哪台机器)。返回 `{ met, timedOut, elapsedMs, observed, condition, polls }`;超时是 `met:false`,**不会报错**。`timeoutMs` 默认 120 秒(上限 1 小时),`pollMs`/`intervalMs` 默认 1000 ms。 |
159
174
  | `presence_read` | 读远端文件,带行号、`offset`/`limit`,`raw:true` 返回逐字节原样内容。 |
160
175
  | `presence_read_many` | 一次读 1–50 个文件,支持每文件字节/行数上限。 |
161
- | `presence_write` | 原子写;保留 mode;返回真实 mode、字节数和完整 sha256;`ifMatchSha256` 乐观锁。 |
176
+ | `presence_write` | 原子写;保留 mode;返回真实 mode、字节数和完整 sha256;`ifMatchSha256` 乐观锁;`mode:"append"` 在 EOF 追加,返回追加字节数和总大小(`ifMatchSha256` 仅 overwrite 有效,append 传入会直接报错);`mode` 也可传八进制字符串(如 `"755"`)设置 overwrite 权限。 |
162
177
  | `presence_edit` | 精确文本替换或行号区间替换,带紧凑 diff、`dryRun` 和 JSON 字段。 |
163
- | `presence_ls` | 列目录;`recursive`、`totalBytes`、JSON 输出。 |
164
178
  | `presence_stat` | 批量 `stat` 1–100 个路径:类型、大小、mode、mtime、可选 sha256;缺失路径返回 `exists:false`。 |
165
179
  | `presence_mkdir` | 创建远端目录(默认递归);已存在时幂等成功。 |
166
180
  | `presence_move` | 同一台机器内重命名/移动;目标已存在时除非 `overwrite:true` 否则拒绝。 |
@@ -170,8 +184,8 @@ DSH 插件提供 24 个工具。相对路径相对当前远程工作区;在工
170
184
  | `presence_tail` | 读远端文件最后 N 行,或在限定时间内跟随(`follow:true`、`timeoutMs`)。 |
171
185
  | `presence_search` | 远端 `rg`/`grep` 搜索,`countOnly`,JSON matches。 |
172
186
  | `presence_glob` | 远端 glob 找文件,带 size/mtime。 |
173
- | `presence_import` | 本地 → 远端二进制安全上传,可设 mode,返回 sha256。 |
174
- | `presence_export` | 远端 → 本地真实文件(内容寻址),供预览或原生程序打开。 |
187
+ | `presence_import` | 本地 → 远端二进制安全上传,可设 mode,返回 sha256;支持批量 `paths[]`(1–50)和/或本地 `glob` 到 `to` 目录,逐文件返回结果。 |
188
+ | `presence_export` | 远端 → 本地真实文件(内容寻址),供预览或原生程序打开;支持批量 `paths[]` 或远端 `glob`;默认本地文件名取 basename,重名变成 `name-<sha8>.ext`;逐文件返回结果。 |
175
189
  | `presence_forward` | 远端端口转发到 `127.0.0.1`;open/list/close 支持 JSON。 |
176
190
  | `presence_port_probe` | 检查最多 20 个端口的监听与 TCP 可达性;`expect:open|closed` 可轮询。 |
177
191
  | `presence_http_probe` | 等端口 → 开转发 → 本地 HTTP 断言 → 关转发 → 验证本地端口已释放。 |
@@ -180,26 +194,49 @@ DSH 插件提供 24 个工具。相对路径相对当前远程工作区;在工
180
194
 
181
195
  </details>
182
196
 
197
+ ### 批量传输与追加写
198
+
199
+ ```text
200
+ presence_export { paths: ["/data/a.pt", "/data/b.pt"] } # files:[{remotePath, localPath, bytes, sha256}]
201
+ presence_export { glob: "/data/ckpt-*.pt" } # 全部匹配;重名变成 name-<sha8>.ext
202
+ presence_import { glob: "dist/*.whl", to: "/data/wheels" } # 本地 → 远端,逐文件结果
203
+ presence_write { path: "/data/train.log", mode: "append", content: "epoch 6 done\n" }
204
+ ```
205
+
206
+ 单文件仍走原来的 `path`/`to`,上面的写法都不是必需的。
207
+
183
208
  ## 路径与工具怎么选
184
209
 
185
210
  | 路径在哪 | 用什么 | 说明 |
186
211
  | --- | --- | --- |
187
212
  | 工作区挂载目录下(`~/.dsh/presence/mounts/...`) | 内置 `read` / `write` / `edit` / `grep` / `glob` | 都直达远端机器;`grep`/`glob` 会自动路由过去 |
188
- | 工作区之外的远端绝对路径(`/data`、`/scratch`、GPFS) | `presence_read` / `write` / `edit` / `ls` / `stat` / `search` / `glob` | 内置工具只看得到挂载目录,看不到整台机器 |
213
+ | 工作区之外的远端绝对路径(`/data`、`/scratch`、GPFS) | `presence_read` / `write` / `edit` / `stat` / `search` / `glob`(列目录用 `presence_exec`) | 内置工具只看得到挂载目录,看不到整台机器 |
189
214
  | 本地文件(预览或 Office) | 先 `presence_export`,再 `present` / 原生打开 | 挂载路径是虚拟的,只有真实本地文件能被原生程序打开 |
190
215
 
216
+ **本地**会话(cwd 不在挂载根下)里内置工具只操作本地文件;每个 `presence_*` 调用都需要显式
217
+ `host`,或一次性配置 `PRESENCE_HOST`。
218
+
191
219
  ## 长时间任务
192
220
 
193
221
  `presence_exec` 会等命令结束,所以训练、构建、起服务都不该直接用它。推荐流程:
194
222
 
195
223
  ```text
196
224
  1. presence_exec { command: "python train.py --lr 3e-4", background: true, label: "train-3e-4" }
197
- → { id: "j7", pid: 4242, logPath: "/tmp/presence-jobs/....log" }
198
- 2. presence_jobs { action: "wait", jobId: "j7" } # 阻塞等待,不要 sleep+poll
199
- 3. presence_tail { path: "/tmp/presence-jobs/....log", follow: true } # 看进度
225
+ → { id: "j7", pid: 4242, pgid: 4242, logPath: ".../job.log", commandLogPath: ".../job.out" }
226
+ 2. presence_jobs { action: "wait", jobId: "j7" } # 或 presence_wait { job: { jobId: "j7" } }
227
+ 3. presence_tail { path: <logPath>, follow: true } # 看进度
228
+ presence_wait { grep: { path: <logPath>, pattern: "epoch 6", count: 1 } } # 等就绪
200
229
  ```
201
230
 
202
- - `presence_jobs { action: "status" }` 取快照;`stop` 结束进程组并验证端口已释放。
231
+ - `presence_jobs { action: "status" }` 取快照;`stop` 结束进程组并验证端口已释放。任务持久化到
232
+ `~/.dsh/presence/jobs.json`:DSH 重启后先显示 `unknown`,直到探活成功(不会假装 `running`),
233
+ 退出码可能缺失。
234
+ - `logPath` 是要 tail 的任务日志(当前内核里就是命令输出);`commandLogPath` 是命令自己的日志,
235
+ 没有独立包装日志时等于 `logPath`,所以用 `commandLogPath ?? logPath`。
236
+ - `presence_wait` 取代手写 sleep+tail 循环:等端口
237
+ (`presence_wait { port: { port: 8000, state: "open" } }`)、等 checkpoint
238
+ (`presence_wait { file: { path: "/data/ckpt.pt", minBytes: 1000000 } }`)、等日志行或等进程。
239
+ 超时返回 `met:false`,是正常结果而不是错误。
203
240
  - 交互式任务:`presence_terminal { action: "open", session: "train" }`,tmux 会话跨重连存活。
204
241
  - **不要**在前台 `presence_exec` 里用 `nohup`、`tmux new-session` 或结尾 `&` 跑长任务:登录节点
205
242
  会话结束时,集群可能清理残留进程。`background: true` 本身用 `setsid` 脱离;调度器管理的集群
@@ -215,7 +252,10 @@ DSH 插件提供 24 个工具。相对路径相对当前远程工作区;在工
215
252
  (它不可能删掉一棵树)。
216
253
  - **工作区内删除优先 `presence_trash`**:移动到 `~/.dsh/presence-trash/<时间戳>/` 并返回恢复提示。
217
254
  - **写入是原子的**,保留原文件 mode;`presence_edit` 锚点不唯一时报错而不是猜。
218
- - **读后写守卫**:内置 `read`/`write`/`edit` 共享读后写守卫;`presence_write` 也会记账,两套 API 可混用。
255
+ - **读后写守卫**:内置 `read`/`write`/`edit` 共享读后写守卫。路径 key 会在 `presence://`、挂载路径、
256
+ 工作区相对路径、远端绝对路径之间归一;`presence_read` / `presence_write` / `presence_edit` 成功都算
257
+ 「已读」(写也算)。`presence_exec` 里 `cat file` **不算** —— 守卫看不到 shell 输出;编辑前请用
258
+ `presence_read` 或内置 `read`。
219
259
 
220
260
  ## 配置
221
261
 
@@ -223,8 +263,9 @@ DSH 插件提供 24 个工具。相对路径相对当前远程工作区;在工
223
263
 
224
264
  | 变量 | 默认 | 含义 |
225
265
  | --- | --- | --- |
226
- | `PRESENCE_HOST` | — | 默认 SSH 主机(`~/.ssh/config` 别名或手动主机)。 |
266
+ | `PRESENCE_HOST` | — | **本地**会话的默认 SSH 主机(显式 opt-in;MCP server 也用)。远程工作区会话优先使用自己的挂载主机。 |
227
267
  | `PRESENCE_WORKSPACE` | — | `host:/abs/path`,绑定一个远程工作区。 |
268
+ | `PRESENCE_JOBS_FILE` | `~/.dsh/presence/jobs.json` | 覆盖后台任务持久化文件路径。 |
228
269
  | `PRESENCE_PASSWORD_<ALIAS>` | — | 某台机器的密码;`<ALIAS>` 大写、非字母数字换成 `_`。 |
229
270
  | `PRESENCE_MAX_CHANNELS` | 8 | 每连接并发通道数。 |
230
271
  | `PRESENCE_CONNECT_TIMEOUT_MS` | 25000 | 连接尝试超时。 |
@@ -242,8 +283,16 @@ npm install --prefix .dev --ignore-scripts rollup @rollup/plugin-node-resolve \
242
283
  @rollup/plugin-commonjs @rollup/plugin-terser codemirror @codemirror/language-data \
243
284
  @codemirror/theme-one-dark
244
285
  node scripts/build-client.mjs
245
- powershell -NoProfile -ExecutionPolicy Bypass -File scripts/package.ps1 -Version 0.19.0
246
- # → dist/presence-plugin-0.19.0.zip
286
+ powershell -NoProfile -ExecutionPolicy Bypass -File scripts/package.ps1 -Version 0.20.1
287
+ # → dist/presence-plugin-0.20.1.zip
288
+ ```
289
+
290
+ DSH 升级后,用 Electron-as-Node 对着真实 Harness 运行时验证插件仍能激活
291
+ (0.2.1-rc.5 的工具 schema 收紧就是这一步发现的):
292
+
293
+ ```powershell
294
+ $env:ELECTRON_RUN_AS_NODE = '1'
295
+ & 'C:\...\DeepSeek Harness.exe' scripts\host-compat-check.mjs # ACTIVATED OK with 25 tools
247
296
  ```
248
297
 
249
298
  验证脚本需要一台可 SSH 的主机(标注“不需要真机”的除外):
@@ -255,14 +304,26 @@ node scripts/feature-probe.mjs server # wait、会话、pgid、就绪、搜索
255
304
  node scripts/import-probe.mjs server # 本地→远端二进制、mode、sha256
256
305
  node scripts/safety-probe.mjs server # 危险命令护栏 + 观测状态
257
306
  node scripts/p0-guard-probe.mjs # 危险命令分段/单文件豁免(不需要真机)
307
+ node scripts/p1-docs-probe.mjs # README/zh/RELEASE 锚点(不需要真机)
258
308
  ```
259
309
 
260
310
  ## 已知边界
261
311
 
312
+ - **本地会话(有意行为)。** 本地会话不传 `host` 调 `presence_*` 现在会报
313
+ `this session is local (cwd=...); pass an explicit host ...` —— 这是 0.20 有意的行为,不是 bug:
314
+ 旧逻辑会静默复用上一次激活的远程工作区,可能在别的会话的机器上执行命令。每次调用传 `host`,
315
+ 或一次性配置 `PRESENCE_HOST` 作为显式默认值;本地会话仍然不会继承别的工作区,
316
+ `presence_hosts` 的 `default` 同样是会话作用域。
317
+ - 读后写守卫看不到 shell 输出:`presence_exec { command: "cat file" }`(或 `sed -n`)**不会**把
318
+ 文件标记为已读;`presence_read` 和内置 `read` 才算,`presence_write`/`presence_edit` 也算已读。
319
+ 只 `exec cat` 过再让内置 `edit` 编辑,仍可能触发守卫。
262
320
  - 挂载路径(`~/.dsh/presence/mounts/...`)是虚拟的:对 `ctx.fs` / agent 工具有效,
263
321
  但 Explorer、Office、宿主 Python 看不到 —— 需要先用 `presence_export`。
264
322
  - `presence_exec` 超时默认 300 秒;更长的任务必须用 `background: true`(前台输出超限会
265
- 落盘到远端文件,`tailLines` 保留长报告末尾)。后台任务按会话跟踪,不跨 DSH 重启持久化。
323
+ 落盘到远端文件,`tailLines` 保留长报告末尾)。后台任务持久化到
324
+ `~/.dsh/presence/jobs.json`;DSH 重启后状态是 `unknown` 直到探活成功 —— 不要把持久化记录
325
+ 当成还在运行,退出码可能缺失。要 tail 的日志用 `commandLogPath ?? logPath`(当前内核里
326
+ `logPath` 就是命令输出)。
266
327
  - 登录节点可能在会话结束时清理残留进程:不要在前台 `presence_exec` 里用 `nohup` /
267
328
  `tmux new-session` / `&` 跑长任务。用 `background: true`(`setsid` 脱离)或
268
329
  `presence_terminal`(tmux);集群上让调度器持有任务(`rlaunch -d`、`salloc`/`sbatch`)。