@khorsheed/dsh-file-preview 0.1.0
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/CHANGELOG.md +11 -0
- package/LICENSE +21 -0
- package/README.en.md +88 -0
- package/README.i18n.yaml +6 -0
- package/README.md +88 -0
- package/cordis.patch.yml +7 -0
- package/lib/index.js +1338 -0
- package/lib/invariant.js +25 -0
- package/lib/tsconfig.tsbuildinfo +1 -0
- package/lib/typert.host.d.ts +3 -0
- package/lib/typert.host.js +549 -0
- package/lib/typert.remote-client.d.ts +35 -0
- package/lib/typert.remote-client.d.ts.map +1 -0
- package/lib/typert.remote-client.js +210 -0
- package/lib/types/bash-writes.d.ts +83 -0
- package/lib/types/bash-writes.d.ts.map +1 -0
- package/lib/types/bash-writes.js +367 -0
- package/lib/types/fold.d.ts +77 -0
- package/lib/types/fold.d.ts.map +1 -0
- package/lib/types/fold.js +309 -0
- package/lib/types/index.d.ts +105 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/index.js +517 -0
- package/lib/types/invariant.d.ts +16 -0
- package/lib/types/invariant.d.ts.map +1 -0
- package/lib/types/invariant.js +23 -0
- package/lib/types/reveal.d.ts +36 -0
- package/lib/types/reveal.d.ts.map +1 -0
- package/lib/types/reveal.js +77 -0
- package/lib/types/types.d.ts +124 -0
- package/lib/types/types.d.ts.map +1 -0
- package/lib/types/types.js +2 -0
- package/package.json +81 -0
- package/skills/3d-artifact/SKILL.md +52 -0
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/* Generated by @deepseek-ai/dsh-typert-generator from the Host FaceModel — do not edit. */
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
|
|
4
|
+
const _khorsheed_dsh_file_preview_filePreview_list_parameter_0$schema = z.intersection(z.string(), z.unknown())
|
|
5
|
+
const _khorsheed_dsh_file_preview_filePreview_list_result$schema = z.object({
|
|
6
|
+
'entries': z.array(z.object({
|
|
7
|
+
'path': z.string().readonly(),
|
|
8
|
+
'op': z.union([z.literal("read"), z.literal("write"), z.literal("edit")]).readonly(),
|
|
9
|
+
'seq': z.number().readonly(),
|
|
10
|
+
'turn': z.number().readonly(),
|
|
11
|
+
'step': z.number().readonly(),
|
|
12
|
+
'diffs': z.array(z.object({
|
|
13
|
+
'seq': z.number().readonly(),
|
|
14
|
+
'turn': z.number().readonly(),
|
|
15
|
+
'step': z.number().readonly(),
|
|
16
|
+
'oldText': z.union([z.literal(null), z.string()]).readonly(),
|
|
17
|
+
'newText': z.string().readonly(),
|
|
18
|
+
})).readonly(),
|
|
19
|
+
'lastDiff': z.union([z.undefined(), z.object({
|
|
20
|
+
'oldText': z.union([z.literal(null), z.string()]).readonly(),
|
|
21
|
+
'newText': z.string().readonly(),
|
|
22
|
+
})]).readonly().optional(),
|
|
23
|
+
})).readonly(),
|
|
24
|
+
'asOfSeq': z.number().readonly(),
|
|
25
|
+
'truncated': z.boolean().readonly(),
|
|
26
|
+
})
|
|
27
|
+
const _khorsheed_dsh_file_preview_filePreview_read_parameter_0$schema = z.intersection(z.string(), z.unknown())
|
|
28
|
+
const _khorsheed_dsh_file_preview_filePreview_read_parameter_1$schema = z.string()
|
|
29
|
+
const _khorsheed_dsh_file_preview_filePreview_read_result$schema = z.object({
|
|
30
|
+
'path': z.string().readonly(),
|
|
31
|
+
'kind': z.union([z.literal("text"), z.literal("image"), z.literal("binary"), z.literal("missing"), z.literal("too-large"), z.literal("error")]).readonly(),
|
|
32
|
+
'content': z.string().readonly().optional(),
|
|
33
|
+
'url': z.string().readonly().optional(),
|
|
34
|
+
'truncated': z.boolean().readonly().optional(),
|
|
35
|
+
'htmlScripted': z.boolean().readonly().optional(),
|
|
36
|
+
'size': z.number().readonly().optional(),
|
|
37
|
+
'message': z.string().readonly().optional(),
|
|
38
|
+
})
|
|
39
|
+
const _khorsheed_dsh_file_preview_filePreview_reveal_parameter_0$schema = z.intersection(z.string(), z.unknown())
|
|
40
|
+
const _khorsheed_dsh_file_preview_filePreview_reveal_parameter_1$schema = z.string()
|
|
41
|
+
const _khorsheed_dsh_file_preview_filePreview_reveal_result$schema = z.union([z.object({
|
|
42
|
+
'revealed': z.literal(true).readonly(),
|
|
43
|
+
}), z.object({
|
|
44
|
+
'revealed': z.literal(false).readonly(),
|
|
45
|
+
'reason': z.union([z.literal("missing"), z.literal("select-failed")]).readonly(),
|
|
46
|
+
})])
|
|
47
|
+
const _khorsheed_dsh_file_preview_filePreview_turnFiles_parameter_0$schema = z.intersection(z.string(), z.unknown())
|
|
48
|
+
const _khorsheed_dsh_file_preview_filePreview_turnFiles_result$schema = z.object({
|
|
49
|
+
'turns': z.array(z.object({
|
|
50
|
+
'turn': z.number().readonly(),
|
|
51
|
+
'files': z.array(z.object({
|
|
52
|
+
'path': z.string().readonly(),
|
|
53
|
+
'seq': z.number().readonly(),
|
|
54
|
+
'step': z.number().readonly(),
|
|
55
|
+
'added': z.number().readonly().optional(),
|
|
56
|
+
'removed': z.number().readonly().optional(),
|
|
57
|
+
})).readonly(),
|
|
58
|
+
})).readonly(),
|
|
59
|
+
'asOfSeq': z.number().readonly(),
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
export const TYPERT_REMOTE = {
|
|
63
|
+
package: '@khorsheed/dsh-file-preview',
|
|
64
|
+
descriptors: [
|
|
65
|
+
{
|
|
66
|
+
id: '@khorsheed/dsh-file-preview#filePreview/list',
|
|
67
|
+
service: 'filePreview',
|
|
68
|
+
namespace: 'filePreview',
|
|
69
|
+
method: 'list',
|
|
70
|
+
invocation: { kind: 'direct' },
|
|
71
|
+
scope: {
|
|
72
|
+
context: 'agent',
|
|
73
|
+
wire: 'agentId',
|
|
74
|
+
},
|
|
75
|
+
parameters: [
|
|
76
|
+
{
|
|
77
|
+
name: 'agent',
|
|
78
|
+
wire: 'agentId',
|
|
79
|
+
source: 'lookup',
|
|
80
|
+
lookup: 'agent',
|
|
81
|
+
codec: {
|
|
82
|
+
mode: 'strict',
|
|
83
|
+
typeSymbol: '@deepseek-ai/dsh-session/types#SessionId',
|
|
84
|
+
schema: _khorsheed_dsh_file_preview_filePreview_list_parameter_0$schema,
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
],
|
|
88
|
+
result: {
|
|
89
|
+
mode: 'strict',
|
|
90
|
+
typeSymbol: '@khorsheed/dsh-file-preview/types#FilePreviewList',
|
|
91
|
+
schema: _khorsheed_dsh_file_preview_filePreview_list_result$schema,
|
|
92
|
+
},
|
|
93
|
+
sourceLocation: {"file":"packages/file-preview/src/index.ts","line":316,"column":3},
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
id: '@khorsheed/dsh-file-preview#filePreview/read',
|
|
97
|
+
service: 'filePreview',
|
|
98
|
+
namespace: 'filePreview',
|
|
99
|
+
method: 'read',
|
|
100
|
+
invocation: { kind: 'direct' },
|
|
101
|
+
scope: {
|
|
102
|
+
context: 'agent',
|
|
103
|
+
wire: 'agentId',
|
|
104
|
+
},
|
|
105
|
+
parameters: [
|
|
106
|
+
{
|
|
107
|
+
name: 'agent',
|
|
108
|
+
wire: 'agentId',
|
|
109
|
+
source: 'lookup',
|
|
110
|
+
lookup: 'agent',
|
|
111
|
+
codec: {
|
|
112
|
+
mode: 'strict',
|
|
113
|
+
typeSymbol: '@deepseek-ai/dsh-session/types#SessionId',
|
|
114
|
+
schema: _khorsheed_dsh_file_preview_filePreview_read_parameter_0$schema,
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
name: 'path',
|
|
119
|
+
wire: 'path',
|
|
120
|
+
source: 'json',
|
|
121
|
+
codec: {
|
|
122
|
+
mode: 'strict',
|
|
123
|
+
typeSymbol: '@khorsheed/dsh-file-preview#filePreview/read:path',
|
|
124
|
+
schema: _khorsheed_dsh_file_preview_filePreview_read_parameter_1$schema,
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
],
|
|
128
|
+
cancellation: { parameter: 'signal' },
|
|
129
|
+
result: {
|
|
130
|
+
mode: 'strict',
|
|
131
|
+
typeSymbol: '@khorsheed/dsh-file-preview/types#FilePreviewRead',
|
|
132
|
+
schema: _khorsheed_dsh_file_preview_filePreview_read_result$schema,
|
|
133
|
+
},
|
|
134
|
+
sourceLocation: {"file":"packages/file-preview/src/index.ts","line":392,"column":9},
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
id: '@khorsheed/dsh-file-preview#filePreview/reveal',
|
|
138
|
+
service: 'filePreview',
|
|
139
|
+
namespace: 'filePreview',
|
|
140
|
+
method: 'reveal',
|
|
141
|
+
invocation: { kind: 'direct' },
|
|
142
|
+
scope: {
|
|
143
|
+
context: 'agent',
|
|
144
|
+
wire: 'agentId',
|
|
145
|
+
},
|
|
146
|
+
parameters: [
|
|
147
|
+
{
|
|
148
|
+
name: 'agent',
|
|
149
|
+
wire: 'agentId',
|
|
150
|
+
source: 'lookup',
|
|
151
|
+
lookup: 'agent',
|
|
152
|
+
codec: {
|
|
153
|
+
mode: 'strict',
|
|
154
|
+
typeSymbol: '@deepseek-ai/dsh-session/types#SessionId',
|
|
155
|
+
schema: _khorsheed_dsh_file_preview_filePreview_reveal_parameter_0$schema,
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
name: 'path',
|
|
160
|
+
wire: 'path',
|
|
161
|
+
source: 'json',
|
|
162
|
+
codec: {
|
|
163
|
+
mode: 'strict',
|
|
164
|
+
typeSymbol: '@khorsheed/dsh-file-preview#filePreview/reveal:path',
|
|
165
|
+
schema: _khorsheed_dsh_file_preview_filePreview_reveal_parameter_1$schema,
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
],
|
|
169
|
+
cancellation: { parameter: 'signal' },
|
|
170
|
+
result: {
|
|
171
|
+
mode: 'strict',
|
|
172
|
+
typeSymbol: '@khorsheed/dsh-file-preview/types#FilePreviewReveal',
|
|
173
|
+
schema: _khorsheed_dsh_file_preview_filePreview_reveal_result$schema,
|
|
174
|
+
},
|
|
175
|
+
sourceLocation: {"file":"packages/file-preview/src/index.ts","line":469,"column":9},
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
id: '@khorsheed/dsh-file-preview#filePreview/turnFiles',
|
|
179
|
+
service: 'filePreview',
|
|
180
|
+
namespace: 'filePreview',
|
|
181
|
+
method: 'turnFiles',
|
|
182
|
+
invocation: { kind: 'direct' },
|
|
183
|
+
scope: {
|
|
184
|
+
context: 'agent',
|
|
185
|
+
wire: 'agentId',
|
|
186
|
+
},
|
|
187
|
+
parameters: [
|
|
188
|
+
{
|
|
189
|
+
name: 'agent',
|
|
190
|
+
wire: 'agentId',
|
|
191
|
+
source: 'lookup',
|
|
192
|
+
lookup: 'agent',
|
|
193
|
+
codec: {
|
|
194
|
+
mode: 'strict',
|
|
195
|
+
typeSymbol: '@deepseek-ai/dsh-session/types#SessionId',
|
|
196
|
+
schema: _khorsheed_dsh_file_preview_filePreview_turnFiles_parameter_0$schema,
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
],
|
|
200
|
+
result: {
|
|
201
|
+
mode: 'strict',
|
|
202
|
+
typeSymbol: '@khorsheed/dsh-file-preview/types#FilePreviewTurnMap',
|
|
203
|
+
schema: _khorsheed_dsh_file_preview_filePreview_turnFiles_result$schema,
|
|
204
|
+
},
|
|
205
|
+
sourceLocation: {"file":"packages/file-preview/src/index.ts","line":347,"column":3},
|
|
206
|
+
},
|
|
207
|
+
],
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export default TYPERT_REMOTE
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bash-write capture: the "B channel + A collection" seam (upstream-seam
|
|
3
|
+
* registry S2). The official bash tool writes files (heredocs, redirects,
|
|
4
|
+
* `tee`, `sed -i`) with no log structure a products view can fold — the
|
|
5
|
+
* file-preview fold only knows `read`/`write`/`edit` tool calls. This module
|
|
6
|
+
* watches each session's event stream, extracts high-precision write targets
|
|
7
|
+
* from bash command strings, verifies each with `fs.stat` after the call
|
|
8
|
+
* settles (prefer a miss over a false positive), and keeps a per-session
|
|
9
|
+
* registry the `filePreview.list` fold merges into its entries. It never
|
|
10
|
+
* touches the session log — the official `Session.append` cannot mark an
|
|
11
|
+
* event `ignorable: true`, and the persistence read path refuses unknown
|
|
12
|
+
* event types that lack the marker (a plugin-appended event would poison the
|
|
13
|
+
* log on reload), which is exactly why this seam is a side registry instead.
|
|
14
|
+
* The registry is rebuilt on `session/created` by replaying the session's own
|
|
15
|
+
* history, so a restarted host regains its captures.
|
|
16
|
+
* @module @khorsheed/dsh-file-preview
|
|
17
|
+
*/
|
|
18
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
19
|
+
import type { FileSystem } from '@deepseek-ai/dsh-fs';
|
|
20
|
+
/** One verified captured write, merged into the fold as a `write` entry. */
|
|
21
|
+
export interface CapturedWrite {
|
|
22
|
+
readonly turn: number;
|
|
23
|
+
readonly step: number;
|
|
24
|
+
/** Seq of the issuing `tool/call`, for latest-first ordering. */
|
|
25
|
+
readonly seq: number;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Extract write-target tokens from one bash command string. High-precision
|
|
29
|
+
* forms only (the "minimal set"): a single `>` redirect (`cat > path`, any
|
|
30
|
+
* `cmd > path`), `tee path` (append forms skipped), and `sed -i ... path`
|
|
31
|
+
* (the first non-option token after the inline script). `>>`, fd redirects
|
|
32
|
+
* (`2>`), `>&`, `<>`, and any write form inside a heredoc body are ignored;
|
|
33
|
+
* `cp`/`mv`/`python open()` are deliberately deferred. Quoted targets keep
|
|
34
|
+
* their quotes here — expansion strips them.
|
|
35
|
+
* @param command - the bash tool's command string.
|
|
36
|
+
* @returns raw target tokens, capped per command.
|
|
37
|
+
*/
|
|
38
|
+
export declare function extractWriteTargets(command: string): string[];
|
|
39
|
+
/**
|
|
40
|
+
* Expand one raw target token into an absolute host path. Strips quotes,
|
|
41
|
+
* expands a leading `~` and `$VAR`/`${VAR}` from the given environment, and
|
|
42
|
+
* resolves a relative target against the session cwd. Rejects anything that
|
|
43
|
+
* cannot be a literal path: glob metacharacters, command substitution, an
|
|
44
|
+
* unset variable (rather than guessing a truncated path), or a relative
|
|
45
|
+
* target with no cwd to anchor it.
|
|
46
|
+
* @param token - a raw target from {@link extractWriteTargets}.
|
|
47
|
+
* @param env - the host environment (the collector runs in the host process,
|
|
48
|
+
* so `$DSH_HOME`-style variables resolve here, not in the fold).
|
|
49
|
+
* @param home - the host home directory for `~`.
|
|
50
|
+
* @param cwd - the owning session's cwd for relative targets, or undefined.
|
|
51
|
+
* @returns the absolute path, or null when the token is not statable literally.
|
|
52
|
+
*/
|
|
53
|
+
export declare function expandShellPath(token: string, env: NodeJS.ProcessEnv, home: string, cwd: string | undefined): string | null;
|
|
54
|
+
/** Options for {@link BashWriteCollector}. */
|
|
55
|
+
export interface BashWriteCollectorOptions {
|
|
56
|
+
fs: FileSystem;
|
|
57
|
+
env?: NodeJS.ProcessEnv;
|
|
58
|
+
home?: string;
|
|
59
|
+
/** Cap on captured writes per session (mirrors the fold's maxFiles). */
|
|
60
|
+
maxFiles?: number;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Per-session registry of bash-written files. Subscribes to the session event
|
|
64
|
+
* firehose: `tool/call` (bash) records the command's write candidates keyed by
|
|
65
|
+
* callId; the matching `tool/result` stat-verifies them (files must exist and
|
|
66
|
+
* be files — prefer a miss over a false positive) and stores the survivors.
|
|
67
|
+
* `session/created` replays the session's own history so a restarted host
|
|
68
|
+
* regains captures without any durable state of its own.
|
|
69
|
+
*/
|
|
70
|
+
export declare class BashWriteCollector {
|
|
71
|
+
#private;
|
|
72
|
+
constructor(options: BashWriteCollectorOptions);
|
|
73
|
+
/**
|
|
74
|
+
* Subscribe to the session feed; the returned disposer removes both
|
|
75
|
+
* listeners (fiber disposal also cleans them up — this is belt and braces).
|
|
76
|
+
* @param ctx - the owning composition context.
|
|
77
|
+
* @returns a disposer removing the listeners.
|
|
78
|
+
*/
|
|
79
|
+
attach(ctx: Context): () => void;
|
|
80
|
+
/** Verified captures for one session (the fold-merge source), or undefined. */
|
|
81
|
+
captured(sessionId: string): ReadonlyMap<string, CapturedWrite> | undefined;
|
|
82
|
+
}
|
|
83
|
+
//# sourceMappingURL=bash-writes.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"bash-writes.d.ts","sourceRoot":"","sources":["../../src/bash-writes.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAIH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAA;AAElD,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,qBAAqB,CAAA;AAcrD,4EAA4E;AAC5E,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,iEAAiE;IACjE,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAA;CACrB;AAwDD;;;;;;;;;;GAUG;AACH,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,EAAE,CA4G7D;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,eAAe,CAC7B,KAAK,EAAE,MAAM,EACb,GAAG,EAAE,MAAM,CAAC,UAAU,EACtB,IAAI,EAAE,MAAM,EACZ,GAAG,EAAE,MAAM,GAAG,SAAS,GACtB,MAAM,GAAG,IAAI,CAoBf;AAYD,8CAA8C;AAC9C,MAAM,WAAW,yBAAyB;IACxC,EAAE,EAAE,UAAU,CAAA;IACd,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAA;IACvB,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,wEAAwE;IACxE,QAAQ,CAAC,EAAE,MAAM,CAAA;CAClB;AAED;;;;;;;GAOG;AACH,qBAAa,kBAAkB;;IAU7B,YAAY,OAAO,EAAE,yBAAyB,EAK7C;IAED;;;;;OAKG;IACH,MAAM,CAAC,GAAG,EAAE,OAAO,GAAG,MAAM,IAAI,CAc/B;IAED,+EAA+E;IAC/E,QAAQ,CAAC,SAAS,EAAE,MAAM,GAAG,WAAW,CAAC,MAAM,EAAE,aAAa,CAAC,GAAG,SAAS,CAE1E;CA0DF"}
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bash-write capture: the "B channel + A collection" seam (upstream-seam
|
|
3
|
+
* registry S2). The official bash tool writes files (heredocs, redirects,
|
|
4
|
+
* `tee`, `sed -i`) with no log structure a products view can fold — the
|
|
5
|
+
* file-preview fold only knows `read`/`write`/`edit` tool calls. This module
|
|
6
|
+
* watches each session's event stream, extracts high-precision write targets
|
|
7
|
+
* from bash command strings, verifies each with `fs.stat` after the call
|
|
8
|
+
* settles (prefer a miss over a false positive), and keeps a per-session
|
|
9
|
+
* registry the `filePreview.list` fold merges into its entries. It never
|
|
10
|
+
* touches the session log — the official `Session.append` cannot mark an
|
|
11
|
+
* event `ignorable: true`, and the persistence read path refuses unknown
|
|
12
|
+
* event types that lack the marker (a plugin-appended event would poison the
|
|
13
|
+
* log on reload), which is exactly why this seam is a side registry instead.
|
|
14
|
+
* The registry is rebuilt on `session/created` by replaying the session's own
|
|
15
|
+
* history, so a restarted host regains its captures.
|
|
16
|
+
* @module @khorsheed/dsh-file-preview
|
|
17
|
+
*/
|
|
18
|
+
import { homedir } from 'node:os';
|
|
19
|
+
import { resolve as resolvePath } from 'node:path';
|
|
20
|
+
/** Cap on write candidates extracted from one command (bounds stat load). */
|
|
21
|
+
const MAX_CANDIDATES_PER_COMMAND = 8;
|
|
22
|
+
/** A write-target token stripped of its surrounding quotes. */
|
|
23
|
+
function stripQuotes(token) {
|
|
24
|
+
let t = token;
|
|
25
|
+
while (t.length >= 2
|
|
26
|
+
&& ((t.startsWith("'") && t.endsWith("'")) || (t.startsWith('"') && t.endsWith('"')))) {
|
|
27
|
+
t = t.slice(1, -1);
|
|
28
|
+
}
|
|
29
|
+
return t;
|
|
30
|
+
}
|
|
31
|
+
/** Whether a character ends a shell token (outside quotes). */
|
|
32
|
+
function isTokenBoundary(ch) {
|
|
33
|
+
return ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r'
|
|
34
|
+
|| ch === ';' || ch === '|' || ch === '&' || ch === '<' || ch === '>';
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Read the next shell token starting at `from` (after any whitespace), with
|
|
38
|
+
* quote awareness so `'a b'` stays one token. Stops at a boundary character
|
|
39
|
+
* outside quotes.
|
|
40
|
+
* @param command - the command text.
|
|
41
|
+
* @param from - scan start index.
|
|
42
|
+
* @returns the raw token and the index just past it, or null at end / boundary.
|
|
43
|
+
*/
|
|
44
|
+
function nextToken(command, from) {
|
|
45
|
+
const n = command.length;
|
|
46
|
+
let i = from;
|
|
47
|
+
while (i < n && (command[i] === ' ' || command[i] === '\t'))
|
|
48
|
+
i++;
|
|
49
|
+
if (i >= n || isTokenBoundary(command[i] ?? ''))
|
|
50
|
+
return null;
|
|
51
|
+
let token = '';
|
|
52
|
+
let quote;
|
|
53
|
+
while (i < n) {
|
|
54
|
+
const ch = command[i] ?? '';
|
|
55
|
+
if (quote !== undefined) {
|
|
56
|
+
if (ch === quote)
|
|
57
|
+
quote = undefined;
|
|
58
|
+
token += ch;
|
|
59
|
+
i++;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (ch === "'" || ch === '"') {
|
|
63
|
+
quote = ch;
|
|
64
|
+
token += ch;
|
|
65
|
+
i++;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (isTokenBoundary(ch))
|
|
69
|
+
break;
|
|
70
|
+
token += ch;
|
|
71
|
+
i++;
|
|
72
|
+
}
|
|
73
|
+
return token.length === 0 ? null : { token, end: i };
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Extract write-target tokens from one bash command string. High-precision
|
|
77
|
+
* forms only (the "minimal set"): a single `>` redirect (`cat > path`, any
|
|
78
|
+
* `cmd > path`), `tee path` (append forms skipped), and `sed -i ... path`
|
|
79
|
+
* (the first non-option token after the inline script). `>>`, fd redirects
|
|
80
|
+
* (`2>`), `>&`, `<>`, and any write form inside a heredoc body are ignored;
|
|
81
|
+
* `cp`/`mv`/`python open()` are deliberately deferred. Quoted targets keep
|
|
82
|
+
* their quotes here — expansion strips them.
|
|
83
|
+
* @param command - the bash tool's command string.
|
|
84
|
+
* @returns raw target tokens, capped per command.
|
|
85
|
+
*/
|
|
86
|
+
export function extractWriteTargets(command) {
|
|
87
|
+
const targets = [];
|
|
88
|
+
const n = command.length;
|
|
89
|
+
let i = 0;
|
|
90
|
+
let quote;
|
|
91
|
+
let heredoc;
|
|
92
|
+
while (i < n) {
|
|
93
|
+
const ch = command[i] ?? '';
|
|
94
|
+
// Inside a heredoc body: skip whole lines until the delimiter line, so
|
|
95
|
+
// content like HTML `>` never reads as a redirect.
|
|
96
|
+
if (heredoc !== undefined) {
|
|
97
|
+
const nl = command.indexOf('\n', i);
|
|
98
|
+
const line = command.slice(i, nl === -1 ? n : nl);
|
|
99
|
+
if (line.trim() === heredoc)
|
|
100
|
+
heredoc = undefined;
|
|
101
|
+
i = nl === -1 ? n : nl + 1;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (quote !== undefined) {
|
|
105
|
+
if (ch === quote)
|
|
106
|
+
quote = undefined;
|
|
107
|
+
i++;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (ch === "'" || ch === '"') {
|
|
111
|
+
quote = ch;
|
|
112
|
+
i++;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
// Heredoc start: `<<` / `<<-` followed by a delimiter word.
|
|
116
|
+
if (ch === '<' && command[i + 1] === '<') {
|
|
117
|
+
let j = i + 2;
|
|
118
|
+
if (command[j] === '-')
|
|
119
|
+
j++;
|
|
120
|
+
const q = command[j];
|
|
121
|
+
let delim = '';
|
|
122
|
+
if (q === "'" || q === '"') {
|
|
123
|
+
const end = command.indexOf(q, j + 1);
|
|
124
|
+
delim = end === -1 ? '' : command.slice(j + 1, end);
|
|
125
|
+
j = end === -1 ? n : end + 1;
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
const start = j;
|
|
129
|
+
while (j < n && !/\s/.test(command[j] ?? ''))
|
|
130
|
+
j++;
|
|
131
|
+
delim = command.slice(start, j);
|
|
132
|
+
}
|
|
133
|
+
if (delim.length > 0)
|
|
134
|
+
heredoc = delim;
|
|
135
|
+
i = j;
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
// `tee path` (append forms skipped entirely).
|
|
139
|
+
if (ch === 't' && command.startsWith('tee', i) && (i === 0 || isTokenBoundary(command[i - 1] ?? ''))) {
|
|
140
|
+
const after = command.slice(i + 3);
|
|
141
|
+
if (/^\s*(?:-a\b|--append\b)/.test(after)) {
|
|
142
|
+
i += 3;
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
const consumed = /^\s*(?:-i\b|--ignore-interrupts\s+)*/.exec(after)?.[0].length ?? 0;
|
|
146
|
+
const tok = nextToken(command, i + 3 + consumed);
|
|
147
|
+
if (tok !== null) {
|
|
148
|
+
targets.push(tok.token);
|
|
149
|
+
i = tok.end;
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
i += 3;
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
// `sed -i ... path` — the first non-option token after the inline script.
|
|
156
|
+
if (ch === 's' && command.startsWith('sed', i) && (i === 0 || isTokenBoundary(command[i - 1] ?? ''))) {
|
|
157
|
+
const im = /^\s*-i([A-Za-z0-9.]*)/.exec(command.slice(i + 3));
|
|
158
|
+
if (im === null) {
|
|
159
|
+
i++;
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
let j = i + 3 + im[0].length;
|
|
163
|
+
const opt = nextToken(command, j);
|
|
164
|
+
if (opt !== null && (opt.token === '-e' || opt.token === '--expression')) {
|
|
165
|
+
const script = nextToken(command, opt.end);
|
|
166
|
+
j = script === null ? opt.end : script.end;
|
|
167
|
+
}
|
|
168
|
+
else if (opt !== null) {
|
|
169
|
+
j = opt.end;
|
|
170
|
+
}
|
|
171
|
+
const file = nextToken(command, j);
|
|
172
|
+
if (file !== null && !file.token.startsWith('-')) {
|
|
173
|
+
targets.push(file.token);
|
|
174
|
+
i = file.end;
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
i = j;
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
// Single `>` redirect target (`>>`, `2>`, `>&`, `<>`, `&>` skipped).
|
|
181
|
+
if (ch === '>') {
|
|
182
|
+
const prev = command[i - 1] ?? '';
|
|
183
|
+
const next = command[i + 1] ?? '';
|
|
184
|
+
const skipped = next === '>' || next === '&' || prev === '>' || prev === '<' || prev === '&' || /[0-9]/.test(prev);
|
|
185
|
+
if (skipped) {
|
|
186
|
+
i++;
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
const tok = nextToken(command, i + 1);
|
|
190
|
+
if (tok !== null) {
|
|
191
|
+
targets.push(tok.token);
|
|
192
|
+
i = tok.end;
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
i++;
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
i++;
|
|
199
|
+
}
|
|
200
|
+
return targets.slice(0, MAX_CANDIDATES_PER_COMMAND);
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Expand one raw target token into an absolute host path. Strips quotes,
|
|
204
|
+
* expands a leading `~` and `$VAR`/`${VAR}` from the given environment, and
|
|
205
|
+
* resolves a relative target against the session cwd. Rejects anything that
|
|
206
|
+
* cannot be a literal path: glob metacharacters, command substitution, an
|
|
207
|
+
* unset variable (rather than guessing a truncated path), or a relative
|
|
208
|
+
* target with no cwd to anchor it.
|
|
209
|
+
* @param token - a raw target from {@link extractWriteTargets}.
|
|
210
|
+
* @param env - the host environment (the collector runs in the host process,
|
|
211
|
+
* so `$DSH_HOME`-style variables resolve here, not in the fold).
|
|
212
|
+
* @param home - the host home directory for `~`.
|
|
213
|
+
* @param cwd - the owning session's cwd for relative targets, or undefined.
|
|
214
|
+
* @returns the absolute path, or null when the token is not statable literally.
|
|
215
|
+
*/
|
|
216
|
+
export function expandShellPath(token, env, home, cwd) {
|
|
217
|
+
let t = stripQuotes(token);
|
|
218
|
+
if (t.length === 0)
|
|
219
|
+
return null;
|
|
220
|
+
// Reject glob metacharacters, but not `${VAR}` braces — check after the
|
|
221
|
+
// variable references are removed so `${DSH_HOME}/x` passes.
|
|
222
|
+
if (/[*?[\]{}]/.test(t.replace(/\$\{[A-Za-z_][A-Za-z0-9_]*\}/g, '')))
|
|
223
|
+
return null;
|
|
224
|
+
if (t.includes('$(') || t.includes('`'))
|
|
225
|
+
return null;
|
|
226
|
+
if (t.startsWith('~'))
|
|
227
|
+
t = home + t.slice(1);
|
|
228
|
+
// Reject any variable reference the environment cannot satisfy.
|
|
229
|
+
const varPattern = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g;
|
|
230
|
+
let match;
|
|
231
|
+
while ((match = varPattern.exec(t)) !== null) {
|
|
232
|
+
const name = match[1] ?? match[2];
|
|
233
|
+
if (name === undefined || env[name] === undefined)
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
t = t.replace(varPattern, (_, braced, bare) => env[braced ?? bare ?? ''] ?? '');
|
|
237
|
+
if (t.length === 0)
|
|
238
|
+
return null;
|
|
239
|
+
if (t.startsWith('/'))
|
|
240
|
+
return t;
|
|
241
|
+
if (cwd === undefined || cwd.length === 0)
|
|
242
|
+
return null;
|
|
243
|
+
return resolvePath(cwd, t);
|
|
244
|
+
}
|
|
245
|
+
/** Parse a tool-call arguments JSON payload into a plain object ({} on failure). */
|
|
246
|
+
function parseArguments(argumentsJson) {
|
|
247
|
+
try {
|
|
248
|
+
const value = JSON.parse(argumentsJson);
|
|
249
|
+
return value !== null && typeof value === 'object' ? value : {};
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
return {};
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Per-session registry of bash-written files. Subscribes to the session event
|
|
257
|
+
* firehose: `tool/call` (bash) records the command's write candidates keyed by
|
|
258
|
+
* callId; the matching `tool/result` stat-verifies them (files must exist and
|
|
259
|
+
* be files — prefer a miss over a false positive) and stores the survivors.
|
|
260
|
+
* `session/created` replays the session's own history so a restarted host
|
|
261
|
+
* regains captures without any durable state of its own.
|
|
262
|
+
*/
|
|
263
|
+
export class BashWriteCollector {
|
|
264
|
+
#fs;
|
|
265
|
+
#env;
|
|
266
|
+
#home;
|
|
267
|
+
#maxFiles;
|
|
268
|
+
/** sessionId → callId → pending bash write candidates. */
|
|
269
|
+
#pending = new Map();
|
|
270
|
+
/** sessionId → path → verified capture. */
|
|
271
|
+
#captured = new Map();
|
|
272
|
+
constructor(options) {
|
|
273
|
+
this.#fs = options.fs;
|
|
274
|
+
this.#env = options.env ?? process.env;
|
|
275
|
+
this.#home = options.home ?? homedir();
|
|
276
|
+
this.#maxFiles = options.maxFiles ?? 500;
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Subscribe to the session feed; the returned disposer removes both
|
|
280
|
+
* listeners (fiber disposal also cleans them up — this is belt and braces).
|
|
281
|
+
* @param ctx - the owning composition context.
|
|
282
|
+
* @returns a disposer removing the listeners.
|
|
283
|
+
*/
|
|
284
|
+
attach(ctx) {
|
|
285
|
+
const offs = [
|
|
286
|
+
ctx.on('session/event', (session, event) => {
|
|
287
|
+
void this.#onEvent(session, event);
|
|
288
|
+
}),
|
|
289
|
+
ctx.on('session/created', (session) => {
|
|
290
|
+
this.#replay(session);
|
|
291
|
+
}),
|
|
292
|
+
];
|
|
293
|
+
return () => {
|
|
294
|
+
for (const off of offs) {
|
|
295
|
+
if (typeof off === 'function')
|
|
296
|
+
off();
|
|
297
|
+
}
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
/** Verified captures for one session (the fold-merge source), or undefined. */
|
|
301
|
+
captured(sessionId) {
|
|
302
|
+
return this.#captured.get(sessionId);
|
|
303
|
+
}
|
|
304
|
+
/** Rebuild one session's registry from its own history (host restart). */
|
|
305
|
+
#replay(session) {
|
|
306
|
+
this.#pending.set(session.id, new Map());
|
|
307
|
+
this.#captured.set(session.id, new Map());
|
|
308
|
+
for (const event of session.events)
|
|
309
|
+
this.#onEvent(session, event);
|
|
310
|
+
}
|
|
311
|
+
#onEvent(session, event) {
|
|
312
|
+
if (event.type === 'tool/call') {
|
|
313
|
+
if (event.data.name !== 'bash')
|
|
314
|
+
return;
|
|
315
|
+
const command = parseArguments(event.data.arguments).command;
|
|
316
|
+
if (typeof command !== 'string' || command.length === 0)
|
|
317
|
+
return;
|
|
318
|
+
const candidates = extractWriteTargets(command)
|
|
319
|
+
.map(token => expandShellPath(token, this.#env, this.#home, session.header.cwd))
|
|
320
|
+
.filter((path) => path !== null);
|
|
321
|
+
if (candidates.length === 0)
|
|
322
|
+
return;
|
|
323
|
+
const calls = this.#pending.get(session.id) ?? new Map();
|
|
324
|
+
calls.set(String(event.data.callId), {
|
|
325
|
+
turn: event.data.turn,
|
|
326
|
+
step: event.data.step,
|
|
327
|
+
seq: event.seq,
|
|
328
|
+
candidates,
|
|
329
|
+
});
|
|
330
|
+
this.#pending.set(session.id, calls);
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
if (event.type === 'tool/result') {
|
|
334
|
+
const calls = this.#pending.get(session.id);
|
|
335
|
+
if (calls === undefined)
|
|
336
|
+
return;
|
|
337
|
+
const callId = String(event.data.message?.source?.callId ?? '');
|
|
338
|
+
const pending = calls.get(callId);
|
|
339
|
+
if (pending === undefined)
|
|
340
|
+
return;
|
|
341
|
+
calls.delete(callId);
|
|
342
|
+
return this.#verify(session, pending);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
/** stat-verify the pending candidates and store the survivors. */
|
|
346
|
+
async #verify(session, pending) {
|
|
347
|
+
const captured = this.#captured.get(session.id) ?? new Map();
|
|
348
|
+
for (const candidate of pending.candidates) {
|
|
349
|
+
if (captured.size >= this.#maxFiles || captured.has(candidate))
|
|
350
|
+
continue;
|
|
351
|
+
let ok = false;
|
|
352
|
+
try {
|
|
353
|
+
const target = await this.#fs.resolve(candidate);
|
|
354
|
+
const info = await this.#fs.stat(target);
|
|
355
|
+
ok = info?.type === 'file';
|
|
356
|
+
}
|
|
357
|
+
catch {
|
|
358
|
+
ok = false;
|
|
359
|
+
}
|
|
360
|
+
if (ok) {
|
|
361
|
+
captured.set(candidate, { turn: pending.turn, step: pending.step, seq: pending.seq });
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
if (captured.size > 0)
|
|
365
|
+
this.#captured.set(session.id, captured);
|
|
366
|
+
}
|
|
367
|
+
}
|