dsh-over-ssh 0.0.0-stage → 0.1.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.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +119 -2
  3. package/agents/agent-manifest.json +20 -0
  4. package/agents/linux-amd64/dsh-over-ssh-agent +0 -0
  5. package/agents/linux-arm64/dsh-over-ssh-agent +0 -0
  6. package/cordis.patch.yml +84 -0
  7. package/lib/client.js +6660 -0
  8. package/lib/file-references.js +411 -0
  9. package/lib/fs.js +1451 -0
  10. package/lib/index.js +19740 -0
  11. package/lib/local-bridge-refs.js +16 -0
  12. package/lib/local-bridge.js +16 -0
  13. package/lib/terminal-wrapper.js +299 -0
  14. package/lib/types/src/client/index.d.ts +49 -0
  15. package/lib/types/src/config.d.ts +383 -0
  16. package/lib/types/src/file-references.d.ts +24 -0
  17. package/lib/types/src/host/app/backend.d.ts +178 -0
  18. package/lib/types/src/host/app/recycle-bin/recycle-bin-contract.d.ts +246 -0
  19. package/lib/types/src/host/app/recycle-bin/recycle-bin-service.d.ts +44 -0
  20. package/lib/types/src/host/app/session-admin/session-admin-contract.d.ts +232 -0
  21. package/lib/types/src/host/app/session-admin/session-admin-service.d.ts +39 -0
  22. package/lib/types/src/host/app/transport/transport-selector.d.ts +97 -0
  23. package/lib/types/src/host/app/transport/transport-service.d.ts +81 -0
  24. package/lib/types/src/host/domain/capability.d.ts +77 -0
  25. package/lib/types/src/host/domain/errors.d.ts +54 -0
  26. package/lib/types/src/host/domain/roots/connection.d.ts +105 -0
  27. package/lib/types/src/host/domain/roots/root.d.ts +123 -0
  28. package/lib/types/src/host/domain/roots/session-roots.d.ts +150 -0
  29. package/lib/types/src/host/domain/roots/ssh-config.d.ts +72 -0
  30. package/lib/types/src/host/domain/session/session-admin.d.ts +344 -0
  31. package/lib/types/src/host/domain/transfer/recycle-bin.d.ts +336 -0
  32. package/lib/types/src/host/domain/transfer/transfer.d.ts +259 -0
  33. package/lib/types/src/host/infra/transport/agent/agent-installer.d.ts +120 -0
  34. package/lib/types/src/host/infra/transport/ssh/ssh2-transport.d.ts +327 -0
  35. package/lib/types/src/host/infra/transport/transport-base.d.ts +33 -0
  36. package/lib/types/src/host/infra/transport/transport.d.ts +541 -0
  37. package/lib/types/src/host/integration/agent/agent-preflight.d.ts +55 -0
  38. package/lib/types/src/host/integration/fs/file-reference-provider.d.ts +141 -0
  39. package/lib/types/src/host/integration/fs/fs-provider.d.ts +366 -0
  40. package/lib/types/src/host/integration/transfer/transfer-service.d.ts +211 -0
  41. package/lib/types/src/index.d.ts +88 -0
  42. package/lib/types/src/local-bridge-refs.d.ts +25 -0
  43. package/lib/types/src/local-bridge.d.ts +35 -0
  44. package/lib/types/src/shared/format.d.ts +13 -0
  45. package/lib/types/src/shared/root-keys.d.ts +120 -0
  46. package/lib/types/src/wiring/helpers.d.ts +143 -0
  47. package/package.json +122 -4
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Multi-root implementation of the core `ctx.fileReferences` seam.
3
+ *
4
+ * The core ships [`@deepseek-ai/dsh-file-reference-local`] for **one** local workspace. This plugin's whole
5
+ * point is several roots of two kinds, so this provider answers the same contract for both:
6
+ *
7
+ * - a **remote** root is enumerated by {@link RemoteIndex} (Tier 2 `index.scan` when the connection has an
8
+ * agent, else one bounded `find`/`git ls-files`), cached per root;
9
+ * - a **local** root is enumerated here with a bounded walk, because installing this provider **replaces**
10
+ * the core's local one — returning nothing for local agents would be a regression, not a feature.
11
+ *
12
+ * Candidate paths use the router's display spelling (`ssh://<connection>/abs` for remote roots) so a token
13
+ * stays unambiguous in a multi-root workspace; the local case keeps ordinary host paths.
14
+ *
15
+ * @module dsh-over-ssh/integration/file-reference-provider
16
+ */
17
+ import { Context } from '@deepseek-ai/cordis';
18
+ import FileReferenceService, { type FileReferenceCandidate } from '@deepseek-ai/dsh-file-reference';
19
+ import type { Agent } from '@deepseek-ai/dsh-agent';
20
+ import type { SessionIdentity, SessionRootOutcome } from '../../domain/roots/session-roots.ts';
21
+ /** The ADR-0013 session-root rule, published as `ctx.sshDevSessionRoots` by the main entry. */
22
+ export interface FileReferenceSessionRoots {
23
+ /** Which remote root this session added with 「添加目录」, when its cwd names none. */
24
+ outcomeFor(workspacePath: string | undefined, identity?: SessionIdentity): SessionRootOutcome;
25
+ }
26
+ declare module '@deepseek-ai/cordis' {
27
+ interface Context {
28
+ /** Session-added remote roots, shared with the tool surfaces (ADR-0013). */
29
+ sshDevSessionRoots?: FileReferenceSessionRoots;
30
+ }
31
+ }
32
+ /** What the provider needs from the index layer (provided as `ctx.sshDevIndex`). */
33
+ export interface FileReferenceIndex {
34
+ /**
35
+ * Rank candidates for a partially typed `@` query inside one remote root.
36
+ *
37
+ * @param connectionId - connection that owns the root.
38
+ * @param root - backend-absolute remote root path.
39
+ * @param query - text after `@`.
40
+ * @param signal - caller cancellation.
41
+ * @returns ranked candidates whose `path` is a router-resolvable spelling
42
+ * (`ssh://<connection>/abs`), so a token stays unambiguous across roots.
43
+ */
44
+ complete(connectionId: string, root: string, query: string, signal: AbortSignal): Promise<readonly FileReferenceCandidate[]>;
45
+ }
46
+ declare module '@deepseek-ai/cordis' {
47
+ interface Context {
48
+ /** Remote index used by the file-reference provider. */
49
+ sshDevIndex: FileReferenceIndex;
50
+ }
51
+ }
52
+ /** Directory names never traversed by the local walk (same intent as the core's exclusion list). */
53
+ export declare const LOCAL_EXCLUDED_DIRECTORIES: readonly string[];
54
+ /** Default cap for the local walk; the core uses a comparable bound for its own index. */
55
+ export declare const LOCAL_MAX_ENTRIES = 20000;
56
+ /** How deep the local walk may go before it gives up (protects against pathological trees). */
57
+ export declare const LOCAL_MAX_DEPTH = 12;
58
+ /**
59
+ * How long a local enumeration may be reused before the tree is walked again.
60
+ *
61
+ * **Short on purpose, and much shorter than the remote branch's 30 s** (`RemoteIndex` `ttlMs`). That cache
62
+ * exists to avoid a *network round trip*; this one exists only to collapse **one typing burst** into a single
63
+ * walk. A burst is a few keystrokes a couple of hundred milliseconds apart, so a quarter second folds the
64
+ * whole burst while keeping staleness below the threshold where a user could notice — creating a file and
65
+ * typing `@` again within 250 ms is not something a person does.
66
+ *
67
+ * The failure mode is deliberately the cheap one: a miss costs a walk, i.e. exactly today's behaviour, so
68
+ * this can never be *slower* than not caching, only less fresh for at most this long.
69
+ *
70
+ * Measured (`.verify/probe-local-fref-cache.ts`, 10 000-entry tree): typing `@module` walked the whole tree
71
+ * **6 times** and cost 413 ms; the walk is ~68 ms each, and it is paid per keystroke, on the host's event loop.
72
+ */
73
+ export declare const LOCAL_CACHE_TTL_MS = 250;
74
+ /**
75
+ * Cap for the local enumeration cache.
76
+ *
77
+ * Keys are registry root paths, so the set is bounded by how many roots the user registered — but it is
78
+ * capped anyway, for the same reason `RemoteIndex` bounds its own caches: a bound that depends on "the
79
+ * caller will not do that" is not a bound.
80
+ */
81
+ export declare const LOCAL_MAX_CACHED_ROOTS = 8;
82
+ /** How many candidates one query returns (the core's local provider uses the same order of magnitude). */
83
+ export declare const DEFAULT_MAX_RESULTS = 50;
84
+ /** One locally enumerated entry, with the kind completion needs. */
85
+ export interface LocalEntry {
86
+ readonly path: string;
87
+ readonly kind: 'file' | 'directory';
88
+ }
89
+ /** Construction options; `walk` is injected so tests never touch a real filesystem. */
90
+ export interface RemoteFileReferenceOptions {
91
+ /** Maximum entries collected in one local walk. Default {@link LOCAL_MAX_ENTRIES}. */
92
+ readonly maxEntries?: number;
93
+ /** Maximum depth of the local walk. Default {@link LOCAL_MAX_DEPTH}. */
94
+ readonly maxDepth?: number;
95
+ /** Maximum candidates returned per query. Default {@link DEFAULT_MAX_RESULTS}. */
96
+ readonly maxResults?: number;
97
+ /** Local enumerator, injected for tests. Defaults to a real bounded `readdir` walk. */
98
+ readonly walk?: (root: string, signal: AbortSignal) => Promise<readonly LocalEntry[]>;
99
+ /** How long a local enumeration stays reusable. Default {@link LOCAL_CACHE_TTL_MS}. */
100
+ readonly localTtlMs?: number;
101
+ /** Cap for the local enumeration cache. Default {@link LOCAL_MAX_CACHED_ROOTS}. */
102
+ readonly maxCachedRoots?: number;
103
+ /** Injectable clock, so the TTL is testable without sleeping. */
104
+ readonly now?: () => number;
105
+ }
106
+ /**
107
+ * The provider.
108
+ *
109
+ * Lifecycle note: it is registered as a **separate module** (`dsh-over-ssh/file-references`) so the
110
+ * composition decides whether to replace the core's local service — enabling it is an explicit choice.
111
+ */
112
+ export declare class RemoteFileReferenceService extends FileReferenceService {
113
+ #private;
114
+ static inject: string[];
115
+ constructor(ctx: Context, options?: RemoteFileReferenceOptions);
116
+ /**
117
+ * List candidates for one agent's working directory.
118
+ *
119
+ * **The session cwd always answers, even when no registered root covers it** (FR-1.9 / ADR-0012).
120
+ * Stage 2 mounts this provider as `ctx.fs`, and the workspace a session was opened on need not be a
121
+ * registered root — the shape that broke it is a root registered *underneath* the cwd (session
122
+ * `D:\project\x`, the plugin's own root `D:\project\x\dsh-over-ssh`), where no root contains
123
+ * the cwd and the router answers `ROOT_OUTSIDE`. `@` completion must not go dark in that workspace,
124
+ * so the cwd itself is used as an **implicit local root** — the same rule `routed-fs` applies to the
125
+ * filesystem: local only, only the cwd subtree, remote roots unchanged.
126
+ *
127
+ * The implicit root is **this session's own directory**, so session isolation (ADR-0012) is not
128
+ * weakened: the walk starts at the cwd and cannot reach a sibling root, and a path outside the cwd is
129
+ * still not offered. Containment comes from the walk's root argument rather than a per-entry filter —
130
+ * a filter would re-normalize up to {@link LOCAL_MAX_ENTRIES} paths on **every keystroke**, which is
131
+ * exactly the cost audit 0057 removed.
132
+ *
133
+ * @param agent - target agent; its session cwd decides which root answers.
134
+ * @param query - text after `@` (may be empty: "show the shallowest entries").
135
+ * @param signal - caller cancellation.
136
+ * @returns ranked candidates, best first; empty when nothing can answer — no registry, a remote cwd
137
+ * without an index, or a cwd that is not an absolute host path.
138
+ */
139
+ list(agent: Agent, query: string, signal: AbortSignal): Promise<FileReferenceCandidate[]>;
140
+ }
141
+ export default RemoteFileReferenceService;
@@ -0,0 +1,366 @@
1
+ /**
2
+ * The `ctx.fs` provider: the core filesystem seam served by this plugin's router.
3
+ *
4
+ * Core components (the model's `read`/`write`/`edit` tools, the workspace file panel, agent
5
+ * instructions) reach the filesystem only through `ctx.fs`, so registering this provider is what
6
+ * makes remote roots usable everywhere without touching a single consumer.
7
+ *
8
+ * Mapping rules that matter:
9
+ *
10
+ * - `targetKey` is the router's namespaced key, wrapped in the core's branded type. Consumers treat
11
+ * it as opaque; this provider is the owner that may parse it back.
12
+ * - Core error codes are produced here, not leaked from the domain layer: `FS_STALE_VERSION`,
13
+ * `FS_NOT_OBSERVED`, `FS_EDIT_NOT_FOUND`, `FS_AMBIGUOUS_EDIT`, `FS_NOT_TEXT`, `FS_TOO_LARGE`,
14
+ * `FS_SANDBOX_DENIED`, `FS_IO_ERROR`.
15
+ * - The fence is the plugin's own multi-root confinement (ADR-0003): `read-only` denies every
16
+ * mutation, a root registered `writable: false` is denied, and `workspace-write` admits every
17
+ * registered root — the core's single-root fence cannot express several roots.
18
+ * - A failed resolution first **syncs the live sessions' working directories** into session-scoped local
19
+ * roots and retries once. The platform reads the workspace by absolute path *without* a cwd while a turn
20
+ * starts (`agent-instructions`), so a session whose cwd no root covered used to lose the whole turn; the
21
+ * sync is what makes its own directory resolvable. It costs nothing on the happy path.
22
+ *
23
+ * @module dsh-over-ssh/integration/fs-provider
24
+ */
25
+ import { Context } from '@deepseek-ai/cordis';
26
+ import { FileSystem, FsError, FsVersion } from '@deepseek-ai/dsh-fs';
27
+ import type { FsDirEntry, FsEditOutcome, FsEditRequest, FsInfo, FsPathInfo, FsTarget, FsWriteIntent, FsWriteOutcome } from '@deepseek-ai/dsh-fs';
28
+ import type { SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox';
29
+ import type { Backend } from '../../app/backend.ts';
30
+ import { SshDevError, type SshDevErrorCode } from '../../domain/errors.ts';
31
+ /**
32
+ * One true window read: the bytes at `[offset, offset + length)`, fetched by the backend that owns
33
+ * the path **without** buffering the prefix before `offset` (the defect recorded in audit 0016's Lead
34
+ * list, item 1: `readByteRange` used to read `[0, end)` and slice).
35
+ */
36
+ export interface RangeWindow {
37
+ readonly bytes: Uint8Array;
38
+ /** Full size of the file when the backend can report it cheaply; `undefined` when it cannot. */
39
+ readonly size?: number;
40
+ /**
41
+ * Whether `bytes` stops before the end of the file.
42
+ *
43
+ * `false` means the window reached EOF and is the **only** signal
44
+ * {@link RoutedFsProvider.readByteRange} needs to stop paging: without it a window that ends exactly
45
+ * at EOF would cost one extra empty round trip, and a window that ends past EOF would look identical
46
+ * to one that stopped short. Every reader must report it truthfully — a reader that keeps saying
47
+ * `true` makes the provider page on past the end.
48
+ */
49
+ readonly truncated: boolean;
50
+ }
51
+ /**
52
+ * Offset-read seam.
53
+ *
54
+ * This is an **injected optional function**, not a `Backend` method: `Backend` (and
55
+ * `RemoteBackend`, the only backend that could implement it) is deliberately outside this change's
56
+ * write scope, so the capability arrives as a constructor dependency instead. It is handed the
57
+ * resolved backend and its root-absolute path, so one injected function can serve every root.
58
+ *
59
+ * Return `null` — never throw — to say "this backend/transport has no offset read". The provider then
60
+ * falls back to the bounded prefix read and does **not** pretend the read was chunked.
61
+ */
62
+ export type BackendRangeReader = (backend: Backend, request: {
63
+ readonly absolute: string;
64
+ readonly offset: number;
65
+ readonly length: number;
66
+ }, signal?: AbortSignal) => Promise<RangeWindow | null>;
67
+ /** Options for {@link RoutedFsProvider}. */
68
+ export interface RoutedFsProviderOptions {
69
+ /**
70
+ * How to read a byte window where the backend can seek.
71
+ *
72
+ * Omitted (the default, and what `ctx.plugin(RoutedFsProvider)` gets) means every read keeps the
73
+ * old semantics. Wire it at the point the remote backend is built (`src/index.ts`
74
+ * `createRemoteBackend`, currently lines 355-365), where the transport — and therefore
75
+ * `Transport.readFileRange` — is reachable; see {@link createBackendRangeReader}.
76
+ */
77
+ readonly readBytesRange?: BackendRangeReader;
78
+ /** Largest window one underlying request may ask for; the memory bound for a chunked read. */
79
+ readonly rangeChunkBytes?: number;
80
+ /**
81
+ * Reads the working directories of the live sessions, for the session-root sync.
82
+ *
83
+ * Defaults to the platform's session service (`ctx.get('sessions')`, see {@link readSessionCwds}); injected
84
+ * here so a test can drive the sync without a Cordis host. Returning `[]` is always safe: it disables the
85
+ * sync and leaves every existing behaviour untouched.
86
+ */
87
+ readonly sessionWorkspaceCwds?: () => readonly string[];
88
+ /**
89
+ * The fork lineage of one session, nearest first (ADR-0013).
90
+ *
91
+ * Defaults to the platform's session service (`ctx.get('sessions')`, see {@link readSessionAncestors});
92
+ * injected here so a test can drive the rule without a Cordis host. Returning `[]` is always safe: a fork
93
+ * then simply does not inherit, which is the pre-ADR-0013 behaviour.
94
+ */
95
+ readonly sessionAncestors?: (sessionId: string) => readonly string[];
96
+ }
97
+ /**
98
+ * Filesystem provider over the plugin's roots.
99
+ *
100
+ * Registers as `ctx.fs`; the composition replaces the core `fs-sandbox` row with this one so the
101
+ * model's file tools follow the router (see `cordis.patch.yml`).
102
+ */
103
+ export declare class RoutedFsProvider extends FileSystem {
104
+ static inject: string[];
105
+ protected readonly rangeReader: BackendRangeReader | undefined;
106
+ protected readonly rangeChunkBytes: number;
107
+ /** Reads the live sessions' working directories; see {@link RoutedFsProviderOptions.sessionWorkspaceCwds}. */
108
+ protected readonly sessionWorkspaceCwds: () => readonly string[];
109
+ /** Reads one session's fork lineage; see {@link RoutedFsProviderOptions.sessionAncestors}. */
110
+ protected readonly sessionAncestors: (sessionId: string) => readonly string[];
111
+ /**
112
+ * Change detector for {@link syncSessionRoots}: the state the last sync produced.
113
+ *
114
+ * A small mutable holder rather than a `string` field: every method here may run with the Cordis service Proxy
115
+ * as `this`, and mutating a property through that Proxy is the one thing this class must not rely on — mutating
116
+ * a property of an object it *read* through the Proxy is fine.
117
+ */
118
+ protected readonly sessionSync: {
119
+ lastSignature: string;
120
+ };
121
+ constructor(ctx: Context, options?: RoutedFsProviderOptions);
122
+ /** This provider confines writes to the registered roots. */
123
+ get sandboxMode(): SandboxMode;
124
+ /** Resolve a model-facing path to the router's target and wrap it for the core. */
125
+ resolve(path: string, opts?: {
126
+ cwd?: string;
127
+ signal?: AbortSignal;
128
+ }): Promise<FsTarget>;
129
+ /** Path a subprocess in the target's world can open (the remote absolute path for remote roots). */
130
+ processPath(target: FsTarget): string;
131
+ /**
132
+ * Map a host path into the process world: for a local root the two are the same string.
133
+ *
134
+ * **This is a mapping, not an access check.** It used to answer `undefined` for any path outside the
135
+ * registered local roots, which broke the platform's own machinery: the PTC worker maps its runtime into the
136
+ * subprocess world through this method, so every `run_code` call died with
137
+ * `PTC runtime bootstrap is unavailable in the subprocess execution world:
138
+ * …/@deepseek-ai/dsh-ptc-runtime-node/lib/process.js` — a file that exists and that the harness has every
139
+ * right to read (2026-10-31, reported by the user as "the session cannot read the workspace").
140
+ *
141
+ * The core's own `dsh-fs-local` answers `resolve(hostPath)` unconditionally for exactly this reason: the
142
+ * question is "what is this host path called in the process world", not "may this caller open it". Access
143
+ * control lives in {@link resolve}/{@link readFile}/{@link writeText}, where the roots are enforced.
144
+ */
145
+ processPathFromHostPath(hostPath: string): string | undefined;
146
+ /** Canonical `file:` URI in the target's world. */
147
+ fileUrl(target: FsTarget): string;
148
+ /** Containment; two targets in different worlds never contain one another. */
149
+ contains(parent: FsTarget, child: FsTarget): boolean;
150
+ /** Metadata, or `undefined` for an absent target. */
151
+ stat(target: FsTarget, signal?: AbortSignal): Promise<FsInfo | undefined>;
152
+ /** Path metadata without following a final symlink, where the backend can tell. */
153
+ lstat(path: string, opts?: {
154
+ cwd?: string;
155
+ }, signal?: AbortSignal): Promise<FsPathInfo | undefined>;
156
+ /**
157
+ * Whole-file UTF-8 text; binary and oversize files fail with typed codes.
158
+ *
159
+ * Reads **bytes** rather than calling `backend.readText`, so the binary sniff happens before decoding: the
160
+ * previous shape asked the backend for the decoded text and then re-encoded the whole string
161
+ * (`Buffer.from(text, 'utf8')`) only to look at its first {@link BINARY_SNIFF_BYTES} bytes. Measured on a
162
+ * 9 MiB file (audit 2026-11-01b follow-up): `open`+`stat`+`read` 2.65 ms, `+` decode 3.95 ms, `+` the
163
+ * redundant re-encode **5.66 ms** — 1.71 ms, ~75% of the plugin's overhead, spent to encode bytes the caller
164
+ * had just decoded. `TextDecoder` then decodes straight off the read buffer without the extra copy.
165
+ */
166
+ readText(target: FsTarget, signal?: AbortSignal): Promise<string>;
167
+ /**
168
+ * Stream text in chunks, paging through the offset reader when the composition injected one.
169
+ *
170
+ * The platform calls this for every file at or above **its own** streaming threshold —
171
+ * `@deepseek-ai/dsh-tool-fs/lib/index.js:217` (`STREAM_MIN_SIZE = 10 * 1024 * 1024`) feeding `:338` — and
172
+ * `@deepseek-ai/dsh-fs/lib/types/index.d.ts:164-172` makes the backend own both the chunking and the
173
+ * cross-chunk UTF-8 decoding. Answering by reading the whole file first, which is what this did, made that
174
+ * contract a lie twice over: it buffered the entire file *and* it refused anything past the whole-file cap
175
+ * with `FS_TOO_LARGE`, so the `read` tool could not open a large remote log at all even when the caller had
176
+ * asked for one window of it.
177
+ *
178
+ * Without the offset seam the previous semantics are kept exactly: one bounded whole read, one chunk, and
179
+ * `FS_TOO_LARGE` past the cap. Nothing pretends to be chunked when it is not.
180
+ */
181
+ streamText(target: FsTarget, signal?: AbortSignal): Promise<AsyncIterable<string>>;
182
+ /** Whole-file bytes, refusing oversized targets instead of truncating. */
183
+ readBytes(target: FsTarget, signal: AbortSignal | undefined, maxBytes: number): Promise<Uint8Array>;
184
+ /**
185
+ * One byte window, bounded by the caller's window rather than the file size.
186
+ *
187
+ * Two paths, and they are deliberately distinguishable:
188
+ *
189
+ * - the backend has an offset read (the injected {@link BackendRangeReader} answers with a window):
190
+ * the window is fetched as a sequence of requests of at most the configured chunk size, each
191
+ * starting at the file offset it needs. Nothing before `offset` is ever transferred or held, so
192
+ * the memory bound is the chunk size plus the returned window, not the file size. The window
193
+ * itself is capped at {@link MAX_RANGE_WINDOW_BYTES} — it is returned whole, so it is allocated
194
+ * whole, and an unbounded `length` would be an unbounded allocation;
195
+ * - the backend has none (a local backend, a transport without `readFileRange`, or no injected
196
+ * reader at all): the previous semantics are kept exactly — one bounded prefix read `[0, end)`
197
+ * capped at 8 MiB, then a slice — and a window past that cap still fails with `FS_TOO_LARGE`
198
+ * instead of silently buffering the file.
199
+ */
200
+ readByteRange(target: FsTarget, range: {
201
+ offset: number;
202
+ length: number;
203
+ }, signal?: AbortSignal): Promise<Uint8Array>;
204
+ /** Direct children, in stable name order, with resolved child targets. */
205
+ listDir(target: FsTarget, signal?: AbortSignal): Promise<FsDirEntry[]>;
206
+ /** Atomic write, honouring the write intent and the plugin's fence. */
207
+ writeText(target: FsTarget, content: string, expected?: FsWriteIntent, signal?: AbortSignal, sandboxPolicy?: SandboxExecutionPolicy): Promise<FsWriteOutcome>;
208
+ /** Literal edit with the core's three-state semantics and an optional version guard. */
209
+ editText(target: FsTarget, edit: FsEditRequest, expected?: {
210
+ version: FsVersion;
211
+ }, signal?: AbortSignal, sandboxPolicy?: SandboxExecutionPolicy): Promise<FsEditOutcome>;
212
+ /**
213
+ * Watch one target through its backend.
214
+ *
215
+ * The route decides the world, then the backend owns the mechanism:
216
+ *
217
+ * - **local roots** delegate to the platform's own `FileSystem.watch` (chokidar) via `PlatformLocalBackend`.
218
+ * Before this method was wired, taking over `ctx.fs` made **local** files stop reporting changes: the
219
+ * platform's `dsh-api-workspace-files` got `workspace-file/watch-unsupported` for every target, because
220
+ * `RoutedFsProvider` replaced the sandboxed provider (a `LocalFileSystem` subclass) and this method only
221
+ * ever threw. Delegating restores the pre-takeover behaviour without a second chokidar implementation.
222
+ * - **remote roots** with a native agent watcher use `watch.start/poll/stop`; Tier 1 remains unsupported
223
+ * and keeps failing honestly (the caller falls back to revalidation on read).
224
+ *
225
+ * A backend that has **no** `watch` method keeps the historical `FS_IO_ERROR` refusal — never a silent
226
+ * no-op, which would leave a consumer believing it is subscribed.
227
+ *
228
+ * @param target - the target whose changes were requested.
229
+ * @param changed - platform callback: re-stat on no argument, subscription-failure on an error.
230
+ * @param signal - cancels watcher initialization and subscription.
231
+ * @returns the disposer (`() => Promise<void>`), idempotent.
232
+ * @throws FsError `FS_IO_ERROR` when the backend cannot watch, plus the mapped backend failures
233
+ * (`FS_NOT_FOUND`, `AbortError` for cancellation, …).
234
+ */
235
+ watch(target: FsTarget, changed: (error?: Error) => void, signal: AbortSignal): Promise<() => Promise<void>>;
236
+ private rootsService;
237
+ private router;
238
+ /**
239
+ * Resolve a path, syncing the live sessions' working directories once if it fails.
240
+ *
241
+ * Why on failure rather than before every call: the sync costs a walk of the live-session list plus a
242
+ * containment scan over the roots, and the overwhelming majority of resolutions succeed against a registered
243
+ * root. Why it must exist at all: the platform reads the workspace through `ctx.fs` by **absolute path without
244
+ * a cwd** while a turn starts, so a session whose own directory no root covers lost the entire turn before the
245
+ * model was called — there was no cwd to key a fallback on.
246
+ *
247
+ * One sync, then one retry: a session that appeared a moment ago is therefore picked up on its **first** read
248
+ * rather than failing once and working on the next call, and a second failure means the sync genuinely had
249
+ * nothing to add (the path is outside every root), so the caller gets the original error.
250
+ *
251
+ * @param path - model-facing path.
252
+ * @param cwd - the caller's session cwd, when it has one.
253
+ * @returns the resolution; when nothing changed the caller keeps the **original** failure.
254
+ */
255
+ private resolveWithSessionSync;
256
+ /**
257
+ * The route options for one call: the cwd the platform supplied, plus the session identity this call runs
258
+ * under when the tool dispatcher carried one down (`integration/session-scope.ts`, ADR-0013).
259
+ *
260
+ * Both are kept because they answer different questions. The cwd is what the platform's contract provides
261
+ * (relative spellings, the workspace-level rule, the anchor rule); the session is the only thing that can
262
+ * separate two conversations opened on the same directory. A call with neither is an agentless one and keeps
263
+ * the plugin-wide view, exactly as before.
264
+ *
265
+ * @param cwd - the caller's session cwd, when it has one.
266
+ * @returns the options to hand the route table.
267
+ */
268
+ private sessionRouteOptions;
269
+ /**
270
+ * The working directory of one live session, or `undefined` when it is not listed.
271
+ *
272
+ * @param sessionId - the asking session.
273
+ * @returns its cwd, when the platform reports one.
274
+ */
275
+ private sessionWorkspaceCwdOf;
276
+ /**
277
+ * Re-read the live sessions and register their working directories as session roots.
278
+ *
279
+ * Guarded by **change detection**, not by a time window: reading the session list and comparing it with the
280
+ * state the last sync produced costs microseconds, while a time throttle would have a window in which a session
281
+ * created a moment ago is not registered yet — and that session's very first read is exactly the one that used
282
+ * to kill the turn. The signature includes the session roots that currently exist, so a registry reset (which
283
+ * drops them) is noticed and repaired too.
284
+ */
285
+ private syncSessionRoots;
286
+ private routedTarget;
287
+ /** Enforce the per-call policy and the root's own writability. */
288
+ private assertWritable;
289
+ /**
290
+ * Previous text content, when it is readable text inside the cap; `undefined` otherwise.
291
+ *
292
+ * `undefined` here means **"no previous text to report"**, never "the target is absent" — the caller
293
+ * already decided existence from {@link BackendStat.exists}. Keeping those two apart is what stops a
294
+ * directory / oversized / binary file from slipping past a `createIfAbsent` guard.
295
+ */
296
+ private readPriorText;
297
+ /** Run a backend call, translating domain errors into core codes. */
298
+ private call;
299
+ }
300
+ /**
301
+ * The plugin callback {@link createRoutedFsProvider} answers.
302
+ *
303
+ * `inject` is part of the shape because Cordis reads it off the plugin it is handed; typing it here keeps
304
+ * the factory's contract honest instead of relying on a cast at the call site.
305
+ */
306
+ export interface FsProviderPlugin {
307
+ (ctx: Context): void;
308
+ inject: readonly string[];
309
+ }
310
+ /**
311
+ * Build the `ctx.fs` plugin with an injected offset-read seam.
312
+ *
313
+ * Cordis registers a plugin by calling it with the context, so a deployment that has the range reader
314
+ * available can register `createRoutedFsProvider({ readBytesRange })` instead of the bare class and
315
+ * get true chunked window reads; registering the class itself keeps the old semantics. Both are the
316
+ * same provider, so nothing else in the composition changes.
317
+ *
318
+ * Two details are load-bearing, and both were wrong before (found by loading the real `./fs` row in a
319
+ * packaged host — the provider never activated, so every `fs` consumer stayed pending):
320
+ *
321
+ * - the callback must **not return the instance**. Cordis treats a function plugin's return value as
322
+ * an effect to apply, and a provider instance is not one: `ctx.plugin(fn)` fails with
323
+ * `Invalid effect` and the service is never provided. Constructing the instance inside the call is
324
+ * what registers it (it extends `FileSystem`, which registers itself in the constructor).
325
+ * - the callback must **carry the class's `inject`**. Cordis reads `inject` off the plugin it is
326
+ * given, and a bare arrow function has none, so the provider would install before `sshDevRoots`
327
+ * exists and silently read an undefined service.
328
+ *
329
+ * @param options - the seam plus the chunk size; see {@link RoutedFsProviderOptions}.
330
+ * @returns a plugin callback that constructs the provider on registration.
331
+ */
332
+ export declare function createRoutedFsProvider(options: RoutedFsProviderOptions): FsProviderPlugin;
333
+ /**
334
+ * Build the {@link BackendRangeReader} the provider injects.
335
+ *
336
+ * The Lead wires the pieces that live outside this change's write scope: a store that already holds a
337
+ * backend's bytes (tests), or — in production — the transport behind the backend, calling
338
+ * `Transport.readFileRange({ path, offset, maxBytes })` and returning `null` when that optional method
339
+ * is absent. The provider owns everything else: chunking, the memory bound, EOF short-circuiting.
340
+ *
341
+ * @param store - reads `[offset, offset + length)` from a backend, or `undefined` for a missing path.
342
+ * @param options - `chunkSizeHint` advertises the largest window worth asking for; it is **not** how the
343
+ * provider bounds memory (the provider chunks by its own `rangeChunkBytes`).
344
+ * @returns a reader that answers `null` (never throws) when the store has no offset read.
345
+ */
346
+ export declare function createBackendRangeReader(store: (backend: Backend, offset: number, length: number) => {
347
+ readonly bytes: Uint8Array;
348
+ readonly size: number;
349
+ } | undefined, options?: {
350
+ readonly chunkSizeHint?: number;
351
+ }): BackendRangeReader;
352
+ /**
353
+ * Translate a domain failure into the core's filesystem vocabulary.
354
+ *
355
+ * Two mappings are deliberately *not* the tempting ones:
356
+ *
357
+ * - **`CANCELLED` becomes an `AbortError`, not `FS_IO_ERROR`.** A cancelled tool call is not an I/O failure,
358
+ * and the platform already recognises the abort shape (the same one {@link assertNotAborted} produces), so
359
+ * reporting it as `FS_IO_ERROR` told the UI "the filesystem broke" (audit 2026-10-31 P2).
360
+ * - **`REMOTE_UNSUPPORTED` becomes `FS_IO_ERROR`, not `FS_NOT_REGULAR_FILE`.** It means "this host/agent
361
+ * cannot do that", never "this path is not a regular file"; the old mapping sent the caller to inspect the
362
+ * path instead of the capability.
363
+ */
364
+ export declare function toFsError(error: SshDevError): FsError;
365
+ /** Codes this provider can surface, exported for tests that assert the mapping table. */
366
+ export declare const MAPPED_DOMAIN_CODES: readonly SshDevErrorCode[];