@tiphareth/dsh-hardssh 0.1.2 → 0.2.3

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 (174) hide show
  1. package/LICENSE +30 -0
  2. package/README.md +77 -50
  3. package/SKILLS.md +140 -0
  4. package/lib/base/index.js +95 -0
  5. package/lib/client.js +1171 -1321
  6. package/lib/fs.js +333 -416
  7. package/lib/index.js +5428 -2991
  8. package/lib/ledger-B-LXlftp.js +43 -0
  9. package/lib/ledger-D2ezq1iW.js +376 -0
  10. package/lib/model-Cp7f70Mb.js +5 -0
  11. package/lib/registry-CViuzKYI.js +421 -0
  12. package/lib/subprocess.js +92 -549
  13. package/lib/types/backend.d.ts +73 -78
  14. package/lib/types/base/capability.d.ts +4 -0
  15. package/lib/types/base/ledger-router.d.ts +42 -26
  16. package/lib/types/base/ledger.d.ts +30 -19
  17. package/lib/types/base/model.d.ts +17 -8
  18. package/lib/types/base/plugin.d.ts +11 -0
  19. package/lib/types/base/registry.d.ts +28 -31
  20. package/lib/types/base/router.d.ts +0 -7
  21. package/lib/types/client/api.d.ts +8 -8
  22. package/lib/types/client/connect-host.d.ts +13 -0
  23. package/lib/types/client/index.d.ts +6 -4
  24. package/lib/types/client/locales.d.ts +15 -1
  25. package/lib/types/client/session-connect-gate.d.ts +71 -0
  26. package/lib/types/client/ssh/api.d.ts +11 -9
  27. package/lib/types/client/ssh/apply.d.ts +14 -8
  28. package/lib/types/client/ssh/locales.d.ts +8 -1
  29. package/lib/types/client/ssh/ops-tab.d.ts +34 -0
  30. package/lib/types/client/ssh/panel/ClusterTab.d.ts +3 -1
  31. package/lib/types/client/ssh/panel/ConnectionErrorDialog.d.ts +8 -0
  32. package/lib/types/client/ssh/panel/SessionSecretDialog.d.ts +4 -1
  33. package/lib/types/client/ssh/panel/SshPanel.d.ts +8 -8
  34. package/lib/types/client/ssh/panel/TerminalTab.d.ts +3 -5
  35. package/lib/types/client/ssh/panel/TransferTab.d.ts +5 -1
  36. package/lib/types/client/ssh/panel/TunnelsTab.d.ts +3 -1
  37. package/lib/types/client/ssh/session-target.d.ts +17 -0
  38. package/lib/types/client/state.d.ts +4 -1
  39. package/lib/types/client/workspace-panel-entry.d.ts +30 -0
  40. package/lib/types/client/workspace-panel.d.ts +24 -0
  41. package/lib/types/client-http.d.ts +77 -6
  42. package/lib/types/core.d.ts +5 -16
  43. package/lib/types/fs.d.ts +42 -25
  44. package/lib/types/index.d.ts +138 -9
  45. package/lib/types/ledger.d.ts +16 -111
  46. package/lib/types/protocol.d.ts +0 -5
  47. package/lib/types/providers/index.d.ts +6 -3
  48. package/lib/types/providers/local/provider.d.ts +83 -23
  49. package/lib/types/providers/ssh/provider.d.ts +24 -57
  50. package/lib/types/remote/environment.d.ts +2 -0
  51. package/lib/types/remote/remote-fs.d.ts +12 -1
  52. package/lib/types/remote/remote-process.d.ts +22 -1
  53. package/lib/types/remote/remote-subprocess.d.ts +13 -1
  54. package/lib/types/remote/remote-terminal.d.ts +4 -0
  55. package/lib/types/remote-search.d.ts +3 -3
  56. package/lib/types/routes.d.ts +36 -8
  57. package/lib/types/runtime/dsh-capabilities.d.ts +18 -0
  58. package/lib/types/runtime/workspace-core.d.ts +73 -38
  59. package/lib/types/runtime/workspace-migration.d.ts +113 -0
  60. package/lib/types/ssh/connection/lease.d.ts +23 -0
  61. package/lib/types/ssh/connection/manager.d.ts +359 -0
  62. package/lib/types/ssh/connection/pool.d.ts +8 -1
  63. package/lib/types/ssh/engine.d.ts +97 -267
  64. package/lib/types/ssh/known-hosts.d.ts +22 -5
  65. package/lib/types/ssh/local-transfer-policy.d.ts +20 -0
  66. package/lib/types/ssh/plugin.d.ts +10 -15
  67. package/lib/types/ssh/protocol.d.ts +18 -0
  68. package/lib/types/ssh/routes.d.ts +15 -7
  69. package/lib/types/ssh/sftp/service.d.ts +216 -0
  70. package/lib/types/ssh/store.d.ts +20 -8
  71. package/lib/types/ssh/terminal/service.d.ts +49 -0
  72. package/lib/types/ssh/tunnel/service.d.ts +26 -0
  73. package/lib/types/ssh/vault.d.ts +51 -4
  74. package/lib/types/subprocess.d.ts +13 -21
  75. package/lib/types/switch/switch-fs.d.ts +52 -2
  76. package/lib/types/switch/switch-subprocess.d.ts +32 -3
  77. package/lib/types/tools.d.ts +7 -6
  78. package/lib/types/workspace-tool-ops.d.ts +33 -0
  79. package/lib/types/workspace.d.ts +18 -0
  80. package/lib/vault-3gpWct2Q.js +559 -0
  81. package/lib/workspace.js +2 -0
  82. package/package.json +59 -26
  83. package/scripts/export-legacy-workspaces.mjs +136 -0
  84. package/src/backend.ts +155 -483
  85. package/src/base/capability.ts +4 -0
  86. package/src/base/ledger-router.ts +214 -65
  87. package/src/base/ledger.ts +175 -48
  88. package/src/base/model.ts +18 -8
  89. package/src/base/plugin.ts +15 -4
  90. package/src/base/registry.ts +49 -41
  91. package/src/base/router.ts +0 -8
  92. package/src/client/api.ts +8 -36
  93. package/src/client/connect-host.ts +199 -0
  94. package/src/client/directory-flow.tsx +91 -36
  95. package/src/client/index.ts +56 -53
  96. package/src/client/locales.ts +14 -0
  97. package/src/client/session-connect-gate.ts +163 -0
  98. package/src/client/ssh/api.ts +245 -108
  99. package/src/client/ssh/apply.ts +23 -24
  100. package/src/client/ssh/locales.ts +16 -2
  101. package/src/client/ssh/ops-tab.tsx +79 -0
  102. package/src/client/ssh/panel/ClusterTab.tsx +9 -20
  103. package/src/client/ssh/panel/ConnectionErrorDialog.tsx +33 -0
  104. package/src/client/ssh/panel/SessionSecretDialog.tsx +5 -1
  105. package/src/client/ssh/panel/SshPanel.tsx +81 -77
  106. package/src/client/ssh/panel/TerminalTab.tsx +18 -37
  107. package/src/client/ssh/panel/TransferTab.tsx +43 -33
  108. package/src/client/ssh/panel/TunnelsTab.tsx +9 -26
  109. package/src/client/ssh/panel/panel.module.css +90 -127
  110. package/src/client/ssh/session-target.ts +69 -0
  111. package/src/client/state.ts +9 -1
  112. package/src/client/workspace-badges.ts +99 -96
  113. package/src/client/workspace-panel-entry.tsx +91 -0
  114. package/src/client/workspace-panel.tsx +227 -0
  115. package/src/client/workspace.module.css +76 -2
  116. package/src/client-http.ts +118 -18
  117. package/src/core.ts +37 -47
  118. package/src/fs.ts +171 -85
  119. package/src/index.ts +535 -244
  120. package/src/ledger.ts +62 -416
  121. package/src/protocol.ts +0 -6
  122. package/src/providers/index.ts +8 -4
  123. package/src/providers/local/provider.ts +259 -87
  124. package/src/providers/ssh/provider.ts +158 -155
  125. package/src/remote/environment.ts +22 -1
  126. package/src/remote/remote-fs.ts +123 -34
  127. package/src/remote/remote-process.ts +265 -203
  128. package/src/remote/remote-subprocess.ts +61 -18
  129. package/src/remote/remote-terminal.ts +106 -21
  130. package/src/remote-search.ts +122 -26
  131. package/src/routes.ts +416 -395
  132. package/src/runtime/dsh-capabilities.ts +19 -0
  133. package/src/runtime/workspace-core.ts +171 -88
  134. package/src/runtime/workspace-migration.ts +472 -0
  135. package/src/ssh/connection/lease.ts +35 -0
  136. package/src/ssh/connection/manager.ts +1083 -0
  137. package/src/ssh/connection/pool.ts +341 -275
  138. package/src/ssh/engine.ts +269 -1477
  139. package/src/ssh/known-hosts.ts +42 -18
  140. package/src/ssh/local-transfer-policy.ts +83 -0
  141. package/src/ssh/plugin.ts +20 -19
  142. package/src/ssh/protocol.ts +22 -1
  143. package/src/ssh/routes.ts +419 -187
  144. package/src/ssh/sftp/service.ts +967 -0
  145. package/src/ssh/store.ts +169 -72
  146. package/src/ssh/terminal/service.ts +177 -0
  147. package/src/ssh/tools.ts +42 -20
  148. package/src/ssh/tunnel/service.ts +217 -0
  149. package/src/ssh/vault.ts +245 -91
  150. package/src/subprocess.ts +88 -71
  151. package/src/switch/switch-fs.ts +141 -10
  152. package/src/switch/switch-subprocess.ts +80 -6
  153. package/src/tools.ts +217 -221
  154. package/src/workspace-tool-ops.ts +101 -0
  155. package/src/workspace.ts +52 -0
  156. package/lib/environment-BL1jddfB.js +0 -449
  157. package/lib/switch-fs-CAJpFY9C.js +0 -193
  158. package/lib/switch-fs-RrZtG2gv.js +0 -210
  159. package/lib/types/client/manager-button.d.ts +0 -32
  160. package/lib/types/client/ssh/mount.d.ts +0 -13
  161. package/lib/types/client/ssh/panel/HostsTab.d.ts +0 -10
  162. package/lib/types/client/ssh/panel/controller.d.ts +0 -23
  163. package/lib/types/client/ssh/sidebar-entry.d.ts +0 -25
  164. package/lib/types/client/workspace-gate.d.ts +0 -15
  165. package/lib/types/remote-runner.d.ts +0 -83
  166. package/lib/types/seam-state.d.ts +0 -69
  167. package/src/client/manager-button.tsx +0 -269
  168. package/src/client/ssh/mount.tsx +0 -83
  169. package/src/client/ssh/panel/HostsTab.tsx +0 -236
  170. package/src/client/ssh/panel/controller.ts +0 -48
  171. package/src/client/ssh/sidebar-entry.ts +0 -123
  172. package/src/client/workspace-gate.ts +0 -225
  173. package/src/remote-runner.ts +0 -201
  174. package/src/seam-state.ts +0 -185
package/src/tools.ts CHANGED
@@ -1,221 +1,217 @@
1
- /**
2
- * Agent tools: the remote-workspace counterpart of the local fs tools. Every
3
- * tool is bound to the SSH workspace OF THE CALLING SESSION: the tool's
4
- * `exec` carries `agent.session.header.cwd` (the session's workspace), which
5
- * the ledger resolves to a bound SSH workspace (alias + remote root). A
6
- * session in a local workspace gets a clear "this workspace is not SSH-bound"
7
- * error; sessions in SSH-bound workspaces operate on the remote host.
8
- *
9
- * Plain file operations (read / write / edit / mkdir / rm / rename) are NOT
10
- * duplicated here: the fs seam routes them automatically by the session cwd
11
- * (SFTP on the remote host). This surface keeps only the tools the seam does
12
- * not cover: status (self-description), directory listing, and search.
13
- *
14
- * @module dsh-hardssh/tools
15
- */
16
- import { defineTool } from '@deepseek-ai/dsh-tools'
17
- import type { ContentBlock } from '@deepseek-ai/dsh-llm'
18
- import type { ToolRunContext } from '@deepseek-ai/dsh-tools'
19
- import type { SshEngine } from './ssh/engine.ts'
20
- import { isInside } from './backend.ts'
21
- import { RemoteSearchService } from './remote-search.ts'
22
- import type { SshWorkspaceLedger } from './ledger.ts'
23
- import type { SshWorkspaceRecord } from './protocol.ts'
24
-
25
- /** One text content block (the only render shape these tools emit). */
26
- function text(value: string): ContentBlock[] {
27
- return [{ type: 'text', text: value }]
28
- }
29
-
30
- /** Tool-set dependencies. */
31
- export interface WorkspaceToolsDeps {
32
- engine: SshEngine
33
- ledger: SshWorkspaceLedger
34
- search: RemoteSearchService
35
- }
36
-
37
- /** Failure envelope shared by every tool. */
38
- interface ToolFailure {
39
- ok: false
40
- error: string
41
- }
42
-
43
- /** The SSH bound workspace for the calling session's cwd. */
44
- function boundWorkspace(ledger: SshWorkspaceLedger, exec: ToolRunContext): Promise<SshWorkspaceRecord | undefined> {
45
- const cwd = exec.agent?.session?.header?.cwd
46
- if (cwd === undefined || cwd === '') return Promise.resolve(undefined)
47
- return ledger.findByAnchor(cwd)
48
- }
49
-
50
- /** True when `abs` is inside (or equals) the remote root. */
51
- function insideRoot(root: string, abs: string): boolean {
52
- return isInside(root, abs)
53
- }
54
-
55
- /** Build every remote_* tool (registered by the host half). */
56
- export function makeWorkspaceTools(deps: WorkspaceToolsDeps) {
57
- const { engine, ledger, search } = deps
58
-
59
- /** The bound workspace for this call, or a failure when the session's
60
- * workspace is not SSH-bound. */
61
- const bound = async (exec: ToolRunContext): Promise<{ workspace: SshWorkspaceRecord } | ToolFailure> => {
62
- const workspace = await boundWorkspace(ledger, exec)
63
- if (workspace === undefined) {
64
- return { ok: false, error: 'this session\'s workspace is not SSH-bound — create an SSH workspace and open a session in it first' }
65
- }
66
- return { workspace }
67
- }
68
-
69
- /** Gate one absolute remote path to the workspace's remote root. */
70
- const gatePath = (workspace: SshWorkspaceRecord, abs: string): string | undefined => {
71
- if (!insideRoot(workspace.remoteRoot, abs)) {
72
- return `path '${abs}' is outside the remote root '${workspace.remoteRoot}' of workspace '${workspace.title}'`
73
- }
74
- return undefined
75
- }
76
-
77
- /** Run one remote op, catching errors into the failure envelope. */
78
- const run = async <T>(operation: () => Promise<T>): Promise<T | ToolFailure> => {
79
- try {
80
- return await operation()
81
- } catch (error) {
82
- const message = error instanceof Error ? error.message : String(error)
83
- return { ok: false, error: message }
84
- }
85
- }
86
-
87
- return [
88
- defineTool({
89
- name: 'remote_status',
90
- description: 'List the SSH-bound workspaces and report whether the CALLING SESSION\'s workspace is SSH-bound (and to which host/root). Call this before any remote_* tool. Triggers: SSH mode, remote workspace, where am I working, which servers are bound.',
91
- parameters: {},
92
- output: {
93
- schema: {
94
- type: 'object',
95
- additionalProperties: false,
96
- properties: {
97
- ok: { type: 'boolean', required: true },
98
- bound: { type: 'boolean', required: true },
99
- workspaceTitle: { type: 'string' },
100
- alias: { type: 'string' },
101
- remoteRoot: { type: 'string' },
102
- anchorPath: { type: 'string' },
103
- workspaces: { type: 'array', items: { type: 'object', additionalProperties: false, properties: {
104
- id: { type: 'string', required: true }, title: { type: 'string', required: true }, alias: { type: 'string', required: true }, remoteRoot: { type: 'string', required: true }, anchorPath: { type: 'string', required: true },
105
- } } },
106
- error: { type: 'string' },
107
- },
108
- },
109
- render: (_args, value) => {
110
- if (value.ok !== true) return text(`remote_status failed: ${value.error ?? 'unknown error'}`)
111
- const list = (value.workspaces ?? []).map((w: { title: string; alias: string; remoteRoot: string; anchorPath: string }) =>
112
- `- ${w.title} (${w.alias} @ ${w.remoteRoot}, anchor ${w.anchorPath})`)
113
- const bound = value.bound === true
114
- ? `current session: BOUND -> ${value.workspaceTitle ?? ''} (${value.alias ?? ''} @ ${value.remoteRoot ?? ''})`
115
- : 'current session: NOT bound (this workspace is local)'
116
- return text([bound, '', 'SSH workspaces:', ...(list.length > 0 ? list : ['(none)'])].join('\n'))
117
- },
118
- },
119
- async execute(_args, exec) {
120
- const workspaces = await ledger.list()
121
- const workspace = await boundWorkspace(ledger, exec)
122
- return {
123
- ok: true,
124
- bound: workspace !== undefined,
125
- workspaceTitle: workspace?.title,
126
- alias: workspace?.alias,
127
- remoteRoot: workspace?.remoteRoot,
128
- anchorPath: workspace?.anchorPath,
129
- workspaces: workspaces.map((w) => ({ id: w.id, title: w.title, alias: w.alias, remoteRoot: w.remoteRoot, anchorPath: w.anchorPath })),
130
- }
131
- },
132
- }),
133
-
134
- defineTool({
135
- name: 'remote_ls',
136
- description: 'List a directory on the SSH host bound to the CALLING SESSION\'s workspace (the path must be inside that workspace\'s remote root). Triggers: list remote directory, remote files, ls on the server.',
137
- parameters: {
138
- path: { type: 'string', required: true, description: 'Absolute remote directory path inside the workspace\'s remote root (e.g. /home/user/project/src).' },
139
- },
140
- output: {
141
- schema: {
142
- type: 'object',
143
- additionalProperties: false,
144
- properties: {
145
- ok: { type: 'boolean', required: true },
146
- path: { type: 'string' },
147
- entries: { type: 'array', items: { type: 'object', additionalProperties: false, properties: {
148
- name: { type: 'string', required: true }, type: { type: 'string', required: true }, size: { type: 'integer', required: true }, mtimeMs: { type: 'integer', required: true },
149
- } } },
150
- error: { type: 'string' },
151
- },
152
- },
153
- render: (_args, value) => {
154
- if (value.ok !== true) return text(`remote_ls failed: ${value.error ?? 'unknown error'}`)
155
- const rows = (value.entries ?? []).map((entry: { name: string; type: string; size: number }) =>
156
- `${entry.type === 'dir' ? 'dir ' : 'file'} ${entry.name}${entry.type === 'file' ? ` (${entry.size} bytes)` : ''}`)
157
- return text([`${value.path}`, ...(rows.length > 0 ? rows : ['(empty)'])].join('\n'))
158
- },
159
- },
160
- async execute(args, exec) {
161
- const check = await bound(exec)
162
- if ('error' in check) return check
163
- const gate = gatePath(check.workspace, args.path)
164
- if (gate !== undefined) return { ok: false, error: gate }
165
- return run(async () => {
166
- const entries = await engine.ls(check.workspace.alias, args.path)
167
- return { ok: true, path: args.path, entries: entries.map((entry) => ({ name: entry.name, type: entry.type, size: entry.size, mtimeMs: entry.mtimeMs })) }
168
- })
169
- },
170
- }),
171
-
172
- defineTool({
173
- name: 'remote_search',
174
- description: 'Search the SSH workspace bound to the CALLING SESSION. mode="glob" matches FILE NAMES by pattern (root-relative, e.g. src/**/*.ts or *.log; max depth 6, capped at 200 hits); mode="grep" searches file CONTENTS for a FIXED STRING (not a regex; skips .git and node_modules; capped at 200 matches per file and 200KB of output). Triggers: find remote files by pattern, glob on the server, grep remote code, search remote contents.',
175
- parameters: {
176
- mode: { type: 'string', enum: ['glob', 'grep'], required: true, description: 'glob = match file names by pattern; grep = search file contents for a fixed string.' },
177
- pattern: { type: 'string', required: true, description: 'glob: root-relative pattern like src/**/*.ts or *.log; grep: the literal text to find (not a regular expression).' },
178
- },
179
- output: {
180
- schema: {
181
- type: 'object',
182
- additionalProperties: false,
183
- properties: {
184
- ok: { type: 'boolean', required: true },
185
- mode: { type: 'string' },
186
- hits: { type: 'array', items: { type: 'string' } },
187
- lines: { type: 'array', items: { type: 'string' } },
188
- truncated: { type: 'boolean' },
189
- error: { type: 'string' },
190
- },
191
- },
192
- render: (_args, value) => {
193
- if (value.ok !== true) return text(`remote_search failed: ${value.error ?? 'unknown error'}`)
194
- const tail = value.truncated === true
195
- ? value.mode === 'grep' ? '\n[output truncated at 200KB]' : '\n[truncated at 200 hits]'
196
- : ''
197
- const body = value.mode === 'grep' ? (value.lines ?? []) : (value.hits ?? [])
198
- return text(body.length > 0 ? body.join('\n') + tail : '(no matches)')
199
- },
200
- },
201
- async execute(args, exec) {
202
- const check = await bound(exec)
203
- if ('error' in check) return check
204
- return run(async () => {
205
- if (args.mode === 'grep') {
206
- const found = await search.grepFixed(
207
- { alias: check.workspace.alias, root: check.workspace.remoteRoot },
208
- args.pattern,
209
- )
210
- return { ok: true, mode: 'grep', lines: found.lines, hits: [], truncated: found.truncated }
211
- }
212
- const found = await search.glob(
213
- { alias: check.workspace.alias, root: check.workspace.remoteRoot },
214
- args.pattern,
215
- )
216
- return { ok: true, mode: 'glob', hits: found.hits, lines: [], truncated: found.truncated }
217
- })
218
- },
219
- }),
220
- ]
221
- }
1
+ /**
2
+ * Agent tools: the remote-workspace counterpart of the local fs tools. Every
3
+ * tool is bound to the SSH workspace OF THE CALLING SESSION: the tool's
4
+ * `exec` carries `agent.session.header.cwd` (the session's workspace), which
5
+ * the SSH-workspace record source resolves to a bound SSH workspace (alias +
6
+ * remote root). A session in a local workspace gets a clear "this workspace is
7
+ * not SSH-bound" error; sessions in SSH-bound workspaces operate on the remote
8
+ * host.
9
+ *
10
+ * Plain file operations (read / write / edit / mkdir / rm / rename) are NOT
11
+ * duplicated here: the fs seam routes them automatically by the session cwd
12
+ * (SFTP on the remote host). This surface keeps only the tools the seam does
13
+ * not cover: status (self-description), directory listing, and search.
14
+ *
15
+ * The bound-workspace operations (listing / glob / grep) come from the
16
+ * `ops` resolver supplied at mount: it opens the logical connection via
17
+ * `WorkspaceCore` and uses its `workspace.fs` / `workspace.search`
18
+ * capabilities (provider-neutral). The tools never branch on `provider.id`.
19
+ *
20
+ * @module dsh-hardssh/tools
21
+ */
22
+ import { defineTool } from '@deepseek-ai/dsh-tools'
23
+ import type { ContentBlock } from '@deepseek-ai/dsh-llm'
24
+ import type { ToolRunContext } from '@deepseek-ai/dsh-tools'
25
+ import { isInside, type WorkspaceStoreView } from './backend.ts'
26
+ import type { SshWorkspaceRecord } from './protocol.ts'
27
+ import type { ToolOpsResolver } from './workspace-tool-ops.ts'
28
+
29
+ /** One text content block (the only render shape these tools emit). */
30
+ function text(value: string): ContentBlock[] {
31
+ return [{ type: 'text', text: value }]
32
+ }
33
+
34
+ /** Tool-set dependencies. */
35
+ export interface WorkspaceToolsDeps {
36
+ /** SSH-workspace record source (the generic projection store). */
37
+ workspaces: WorkspaceStoreView
38
+ /** Bound-workspace operations provider (generic capability seam). Resolves
39
+ * the calling session's cwd to its ops. */
40
+ ops: ToolOpsResolver
41
+ }
42
+
43
+ /** Failure envelope shared by every tool. */
44
+ interface ToolFailure {
45
+ ok: false
46
+ error: string
47
+ }
48
+
49
+ /** The bound workspace record for the calling session's cwd, if any. */
50
+ async function boundWorkspace(workspaces: WorkspaceStoreView, exec: ToolRunContext): Promise<SshWorkspaceRecord | undefined> {
51
+ const cwd = exec.agent?.session?.header?.cwd
52
+ if (cwd === undefined || cwd === '') return undefined
53
+ return workspaces.findByAnchor(cwd)
54
+ }
55
+
56
+ /** Build every remote_* tool (registered by the host half). */
57
+ export function makeWorkspaceTools(deps: WorkspaceToolsDeps) {
58
+ const { workspaces, ops } = deps
59
+
60
+ /** The bound workspace + ops for this call, or a failure when the session's
61
+ * workspace is not SSH-bound. */
62
+ const bound = async (exec: ToolRunContext): Promise<{ record: SshWorkspaceRecord; ops: import('./workspace-tool-ops.ts').WorkspaceToolOps; resolve: (abs: string) => string | undefined } | ToolFailure> => {
63
+ const cwd = exec.agent?.session?.header?.cwd
64
+ const outcome = await ops(cwd)
65
+ if (outcome === null) {
66
+ return { ok: false, error: 'this session\'s workspace is not SSH-bound — create an SSH workspace and open a session in it first' }
67
+ }
68
+ const root = outcome.record.remoteRoot
69
+ return {
70
+ record: outcome.record,
71
+ ops: outcome.ops,
72
+ // Return undefined when the absolute path escapes the workspace root.
73
+ resolve: (abs: string): string | undefined => (isInside(root, abs) ? abs : undefined),
74
+ }
75
+ }
76
+
77
+ /** Run one remote op, catching errors into the failure envelope. */
78
+ const run = async <T>(operation: () => Promise<T>): Promise<T | ToolFailure> => {
79
+ try {
80
+ return await operation()
81
+ } catch (error) {
82
+ const message = error instanceof Error ? error.message : String(error)
83
+ return { ok: false, error: message }
84
+ }
85
+ }
86
+
87
+ return [
88
+ defineTool({
89
+ name: 'remote_status',
90
+ description: 'List the SSH-bound workspaces and report whether the CALLING SESSION\'s workspace is SSH-bound (and to which host/root). Call this before any remote_* tool. Triggers: SSH mode, remote workspace, where am I working, which servers are bound.',
91
+ parameters: {},
92
+ output: {
93
+ schema: {
94
+ type: 'object',
95
+ additionalProperties: false,
96
+ properties: {
97
+ ok: { type: 'boolean', required: true },
98
+ bound: { type: 'boolean', required: true },
99
+ workspaceTitle: { type: 'string' },
100
+ alias: { type: 'string' },
101
+ remoteRoot: { type: 'string' },
102
+ anchorPath: { type: 'string' },
103
+ workspaces: { type: 'array', items: { type: 'object', additionalProperties: false, properties: {
104
+ id: { type: 'string', required: true }, title: { type: 'string', required: true }, alias: { type: 'string', required: true }, remoteRoot: { type: 'string', required: true }, anchorPath: { type: 'string', required: true },
105
+ } } },
106
+ error: { type: 'string' },
107
+ },
108
+ },
109
+ render: (_args, value) => {
110
+ if (value.ok !== true) return text(`remote_status failed: ${value.error ?? 'unknown error'}`)
111
+ const list = (value.workspaces ?? []).map((w: { title: string; alias: string; remoteRoot: string; anchorPath: string }) =>
112
+ `- ${w.title} (${w.alias} @ ${w.remoteRoot}, anchor ${w.anchorPath})`)
113
+ const boundText = value.bound === true
114
+ ? `current session: BOUND -> ${value.workspaceTitle ?? ''} (${value.alias ?? ''} @ ${value.remoteRoot ?? ''})`
115
+ : 'current session: NOT bound (this workspace is local)'
116
+ return text([boundText, '', 'SSH workspaces:', ...(list.length > 0 ? list : ['(none)'])].join('\n'))
117
+ },
118
+ },
119
+ async execute(_args, exec) {
120
+ const records = await workspaces.list()
121
+ const workspace = await boundWorkspace(workspaces, exec)
122
+ return {
123
+ ok: true,
124
+ bound: workspace !== undefined,
125
+ workspaceTitle: workspace?.title,
126
+ alias: workspace?.alias,
127
+ remoteRoot: workspace?.remoteRoot,
128
+ anchorPath: workspace?.anchorPath,
129
+ workspaces: records.map((w) => ({ id: w.id, title: w.title, alias: w.alias, remoteRoot: w.remoteRoot, anchorPath: w.anchorPath })),
130
+ }
131
+ },
132
+ }),
133
+
134
+ defineTool({
135
+ name: 'remote_ls',
136
+ description: 'List a directory on the SSH host bound to the CALLING SESSION\'s workspace (the path must be inside that workspace\'s remote root). Triggers: list remote directory, remote files, ls on the server.',
137
+ parameters: {
138
+ path: { type: 'string', required: true, description: 'Absolute remote directory path inside the workspace\'s remote root (e.g. /home/user/project/src).' },
139
+ },
140
+ output: {
141
+ schema: {
142
+ type: 'object',
143
+ additionalProperties: false,
144
+ properties: {
145
+ ok: { type: 'boolean', required: true },
146
+ path: { type: 'string' },
147
+ entries: { type: 'array', items: { type: 'object', additionalProperties: false, properties: {
148
+ name: { type: 'string', required: true }, type: { type: 'string', required: true }, size: { type: 'integer', required: true }, mtimeMs: { type: 'integer', required: true },
149
+ } } },
150
+ error: { type: 'string' },
151
+ },
152
+ },
153
+ render: (_args, value) => {
154
+ if (value.ok !== true) return text(`remote_ls failed: ${value.error ?? 'unknown error'}`)
155
+ const rows = (value.entries ?? []).map((entry: { name: string; type: string; size: number }) =>
156
+ `${entry.type === 'dir' ? 'dir ' : 'file'} ${entry.name}${entry.type === 'file' ? ` (${entry.size} bytes)` : ''}`)
157
+ return text([`${value.path}`, ...(rows.length > 0 ? rows : ['(empty)'])].join('\n'))
158
+ },
159
+ },
160
+ async execute(args, exec) {
161
+ const check = await bound(exec)
162
+ if ('error' in check) return check
163
+ const abs = check.resolve(args.path)
164
+ if (abs === undefined) {
165
+ return { ok: false, error: `path '${args.path}' is outside the remote root '${check.record.remoteRoot}' of workspace '${check.record.title}'` }
166
+ }
167
+ return run(async () => {
168
+ const entries = await check.ops.listDir(abs)
169
+ return { ok: true, path: abs, entries }
170
+ })
171
+ },
172
+ }),
173
+
174
+ defineTool({
175
+ name: 'remote_search',
176
+ description: 'Search the SSH workspace bound to the CALLING SESSION. mode="glob" matches FILE NAMES by pattern (root-relative, e.g. src/**/*.ts or *.log; capped at 200 hits); mode="grep" searches file CONTENTS for a FIXED STRING (not a regex; skips .git and node_modules) and returns matched `path:line:content` records. Triggers: find remote files by pattern, glob on the server, grep remote code, search remote contents.',
177
+ parameters: {
178
+ mode: { type: 'string', enum: ['glob', 'grep'], required: true, description: 'glob = match file names by pattern; grep = search file contents for a fixed string.' },
179
+ pattern: { type: 'string', required: true, description: 'glob: root-relative pattern like src/**/*.ts or *.log; grep: the literal text to find (not a regular expression).' },
180
+ },
181
+ output: {
182
+ schema: {
183
+ type: 'object',
184
+ additionalProperties: false,
185
+ properties: {
186
+ ok: { type: 'boolean', required: true },
187
+ mode: { type: 'string' },
188
+ hits: { type: 'array', items: { type: 'string' } },
189
+ lines: { type: 'array', items: { type: 'string' } },
190
+ truncated: { type: 'boolean' },
191
+ error: { type: 'string' },
192
+ },
193
+ },
194
+ render: (_args, value) => {
195
+ if (value.ok !== true) return text(`remote_search failed: ${value.error ?? 'unknown error'}`)
196
+ const tail = value.truncated === true
197
+ ? value.mode === 'grep' ? '\n[output truncated]' : '\n[truncated at 200 hits]'
198
+ : ''
199
+ const body = value.mode === 'grep' ? (value.lines ?? []) : (value.hits ?? [])
200
+ return text(body.length > 0 ? body.join('\n') + tail : '(no matches)')
201
+ },
202
+ },
203
+ async execute(args, exec) {
204
+ const check = await bound(exec)
205
+ if ('error' in check) return check
206
+ return run(async () => {
207
+ if (args.mode === 'grep') {
208
+ const found = await check.ops.grep(args.pattern)
209
+ return { ok: true, mode: 'grep', lines: found.lines, hits: [], truncated: found.truncated }
210
+ }
211
+ const found = await check.ops.glob(args.pattern)
212
+ return { ok: true, mode: 'glob', hits: found.hits, lines: [], truncated: found.truncated }
213
+ })
214
+ },
215
+ }),
216
+ ]
217
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Bound-workspace operations for the `remote_*` agent tools.
3
+ *
4
+ * The tools resolve the CALLING SESSION's bound workspace from its cwd, then
5
+ * run a small set of operations (directory listing, glob / content-grep)
6
+ * against it. The resolver opens the bound record through WorkspaceCore and
7
+ * uses its `workspace.fs` / `workspace.search` capabilities. It fails closed
8
+ * when the provider omits a required capability rather than falling back to a
9
+ * second engine-backed implementation.
10
+ */
11
+
12
+ import type { FileSystem } from '@deepseek-ai/dsh-fs'
13
+ import type { WorkspaceSearchService } from './base/capability.ts'
14
+ import { isInside, type WorkspaceStoreView } from './backend.ts'
15
+ import type { SshWorkspaceRecord, WorkspaceEntry } from './protocol.ts'
16
+ import type { WorkspaceCore } from './runtime/workspace-core.ts'
17
+ import type { RemoteGlobResult, RemoteGrepResult } from './remote-search.ts'
18
+
19
+ /** Operations one bound workspace exposes to the remote_* tools. */
20
+ export interface WorkspaceToolOps {
21
+ /** List one absolute directory path inside the workspace remote root. */
22
+ listDir(abs: string): Promise<WorkspaceEntry[]>
23
+ /** glob filename search (pattern keeps glob semantics). */
24
+ glob(pattern: string): Promise<RemoteGlobResult>
25
+ /** fixed-string content grep (returns matched `path:line:content` records). */
26
+ grep(fixedPhrase: string): Promise<RemoteGrepResult>
27
+ }
28
+
29
+ /** The bound record + its ops for one session cwd. */
30
+ export interface BoundToolOps {
31
+ record: SshWorkspaceRecord
32
+ ops: WorkspaceToolOps
33
+ }
34
+
35
+ /** Resolve the calling session cwd to a bound workspace, or null when local. */
36
+ export type ToolOpsResolver = (cwd: string | undefined) => Promise<BoundToolOps | null>
37
+
38
+ /** workspace-relative path for an absolute path known to be inside the root. */
39
+ function relOf(root: string, abs: string): string {
40
+ const normalized = root.endsWith('/') ? root : `${root}/`
41
+ if (abs === root) return ''
42
+ if (!abs.startsWith(normalized)) throw new Error(`path '${abs}' is outside remote root '${root}'`)
43
+ return abs.slice(normalized.length)
44
+ }
45
+
46
+ /** map a DSH directory entry to the tool DTO shape (mtime is opaque on capabilities). */
47
+ function toEntry(name: string, type: 'directory' | 'file' | 'other'): WorkspaceEntry {
48
+ return { name, type: type === 'directory' ? 'dir' : type, size: 0, mtimeMs: 0 }
49
+ }
50
+
51
+ function outsideError(root: string, abs: string): Error {
52
+ return new Error(`path '${abs}' is outside remote root '${root}'`)
53
+ }
54
+
55
+ /**
56
+ * Capability-backed ops over one open connection. `root` confines every
57
+ * operation; search operations fail closed when the provider offers no
58
+ * `workspace.search` capability (a limited silent fallback would hide the gap).
59
+ */
60
+ function capabilityOps(root: string, fs: FileSystem, search: WorkspaceSearchService | undefined): WorkspaceToolOps {
61
+ return {
62
+ async listDir(abs) {
63
+ if (!isInside(root, abs)) throw outsideError(root, abs)
64
+ const target = await fs.resolve(relOf(root, abs) === '' ? '.' : relOf(root, abs))
65
+ const info = await fs.stat(target)
66
+ if (info === undefined) throw new Error(`remote_ls: '${abs}' not found`)
67
+ if (info.type !== 'directory') throw new Error(`remote_ls: '${abs}' is not a directory`)
68
+ const entries = await fs.listDir(target)
69
+ return entries.map(entry => toEntry(entry.name, entry.type))
70
+ },
71
+ async glob(pattern) {
72
+ if (search === undefined) throw new Error('remote_search (glob): this workspace provider exposes no workspace.search capability')
73
+ const found = await search.glob(pattern)
74
+ return { hits: found.hits.map(hit => hit.path), truncated: found.truncated }
75
+ },
76
+ async grep(fixedPhrase) {
77
+ if (search === undefined) throw new Error('remote_search (grep): this workspace provider exposes no workspace.search capability')
78
+ const found = await search.grep(fixedPhrase)
79
+ return { lines: found.hits.map(hit => hit.match ?? hit.path), truncated: found.truncated }
80
+ },
81
+ }
82
+ }
83
+
84
+ /** Resolver that opens each bound record through the generic WorkspaceCore. */
85
+ export function capabilityToolOpsResolver(store: WorkspaceStoreView, core: WorkspaceCore): ToolOpsResolver {
86
+ return async (cwd) => {
87
+ if (cwd === undefined || cwd === '') return null
88
+ const record = await store.findByAnchor(cwd)
89
+ if (record === undefined) return null
90
+ const connection = await core.openById(record.id)
91
+ if (connection === undefined) {
92
+ throw new Error(`remote workspace '${record.id}' is not open (failed closed)`)
93
+ }
94
+ const fs = connection.get('workspace.fs') as FileSystem | undefined
95
+ if (fs === undefined) {
96
+ throw new Error(`remote workspace '${record.id}' provides no workspace.fs capability`)
97
+ }
98
+ const search = connection.get('workspace.search') as WorkspaceSearchService | undefined
99
+ return { record, ops: capabilityOps(record.remoteRoot, fs, search) }
100
+ }
101
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Public workspace API entry — the stable surface third-party plugins consume.
3
+ * Exports only platform-neutral record/provider/connection/capability types,
4
+ * the `WorkspaceCore` consumer interface, and the stable provider API version.
5
+ *
6
+ * Deliberately NOT exported from here: `SshEngine`, concrete SSH connection /
7
+ * provider classes, the ledger/router/registry internals, and migration or
8
+ * legacy compatibility code — so implementation swaps never leak through this
9
+ * boundary (migration plan Phase 6, §「稳定导出」).
10
+ *
11
+ * @module @tiphareth/dsh-hardssh/workspace
12
+ */
13
+
14
+ // Platform-neutral model: records, provider/connection contracts, manifest.
15
+ export type {
16
+ ConnectionRef,
17
+ WorkspaceAnchor,
18
+ WorkspaceCapabilityMap,
19
+ WorkspaceConnection,
20
+ WorkspaceCreateInput,
21
+ WorkspaceId,
22
+ WorkspaceLocation,
23
+ WorkspaceProvider,
24
+ WorkspaceProviderManifest,
25
+ WorkspaceProviderRef,
26
+ WorkspaceRecord,
27
+ WorkspaceUpdate,
28
+ } from './base/model.ts'
29
+ export { WORKSPACE_PROVIDER_API_VERSION } from './base/model.ts'
30
+
31
+ // The capability resource contracts a provider may serve (path-style fs,
32
+ // search, and DSH FileSystem/SubprocessRuntime live on the runtime map above).
33
+ export type {
34
+ WorkspaceDirEntry,
35
+ WorkspaceFileSystem,
36
+ WorkspaceProcessResult,
37
+ WorkspaceProcessRuntime,
38
+ WorkspaceSearchHit,
39
+ WorkspaceSearchService,
40
+ WorkspaceStat,
41
+ } from './base/capability.ts'
42
+
43
+ // The high-level consumer surface (ready/openById/openByAnchor/CRUD/
44
+ // registerProvider/subscribe/closeAll). The concrete workspace-core
45
+ // implementation stays internal; consumers depend on this interface only.
46
+ export type {
47
+ WorkspaceCore,
48
+ WorkspaceListener,
49
+ } from './runtime/workspace-core.ts'
50
+
51
+ // The registry surface used to register third-party providers.
52
+ export type { WorkspaceRegistry } from './base/registry.ts'