@turns/dev 0.0.7 → 0.0.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -4
- package/dist/daemon.js +123 -12
- package/dist/file-search.js +182 -0
- package/dist/queue-worker.js +37 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -39,14 +39,19 @@ never prunes saved upload cursors, so it cannot make the sessions it filtered
|
|
|
39
39
|
out replay from their first byte the next time the daemon starts.
|
|
40
40
|
|
|
41
41
|
The server is selected with `--server URL` or `TURNS_ORIGIN`. Its normalized
|
|
42
|
-
origin is saved
|
|
43
|
-
|
|
42
|
+
origin is saved and reused by later invocations and daemon starts. On Linux,
|
|
43
|
+
Turns uses the XDG config/state directories and installs this user service:
|
|
44
44
|
|
|
45
45
|
- `~/.config/turns/machine-id`
|
|
46
46
|
- `~/.local/state/turns/native-upload-state.json`
|
|
47
47
|
- `~/.local/state/turns/letta-sessions/*.json` (Letta conversation-to-project associations)
|
|
48
48
|
- `~/.config/systemd/user/turns.service`
|
|
49
49
|
|
|
50
|
+
On macOS, credentials and daemon state live under
|
|
51
|
+
`~/Library/Application Support/turns/`, and `turns install` creates the per-user
|
|
52
|
+
LaunchAgent `~/Library/LaunchAgents/dev.turns.cli.plist`. Its output is written
|
|
53
|
+
to `~/Library/Application Support/turns/daemon.log`.
|
|
54
|
+
|
|
50
55
|
The credential file contains a seven-day access token and a signed refresh
|
|
51
56
|
token. The CLI refreshes the access token at least once every 24 hours. A new
|
|
52
57
|
machine opens a one-time turns.dev URL, waits on an authentication WebSocket,
|
|
@@ -72,8 +77,9 @@ An explicit file browse may upload up to 199 MB to the same private R2 prefix.
|
|
|
72
77
|
The daemon streams the file from disk and the Worker hands that request stream
|
|
73
78
|
straight to R2, so neither process retains the complete file in memory.
|
|
74
79
|
|
|
75
|
-
The systemd unit uses `KillMode=process
|
|
76
|
-
|
|
80
|
+
The systemd unit uses `KillMode=process`, and the macOS LaunchAgent uses
|
|
81
|
+
`AbandonProcessGroup`: restarting the uploader leaves its detached agent-anchor
|
|
82
|
+
processes running so in-flight harness turns continue.
|
|
77
83
|
For a foreground daemon restricted to one or more directory trees, repeat
|
|
78
84
|
`--directory`. Both transcript discovery and agent launches enforce the
|
|
79
85
|
canonical prefix, including protection against symlinks that escape it:
|
package/dist/daemon.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
import process from 'node:process';
|
|
4
5
|
import { spawnSync } from 'node:child_process';
|
|
@@ -36,6 +37,7 @@ export function canonicalDaemonCliEntry(cliDirectory = HERE) {
|
|
|
36
37
|
}
|
|
37
38
|
export const SYSTEMD_SERVICE_NAME = 'turns.service';
|
|
38
39
|
export const LEGACY_SYSTEMD_SERVICE_NAME = 'turns-native-uploader.service';
|
|
40
|
+
export const MAC_LAUNCH_AGENT_LABEL = 'dev.turns.cli';
|
|
39
41
|
function systemdQuote(value) {
|
|
40
42
|
return `"${String(value).replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('%', '%%')}"`;
|
|
41
43
|
}
|
|
@@ -59,6 +61,62 @@ Environment=NODE_NO_WARNINGS=1
|
|
|
59
61
|
WantedBy=default.target
|
|
60
62
|
`;
|
|
61
63
|
}
|
|
64
|
+
function xmlEscape(value) {
|
|
65
|
+
return String(value)
|
|
66
|
+
.replaceAll('&', '&')
|
|
67
|
+
.replaceAll('<', '<')
|
|
68
|
+
.replaceAll('>', '>')
|
|
69
|
+
.replaceAll('"', '"')
|
|
70
|
+
.replaceAll("'", ''');
|
|
71
|
+
}
|
|
72
|
+
export function macLaunchAgentPlist({ cliEntry = canonicalDaemonCliEntry(), execPath = process.execPath, workingDirectory = os.homedir(), logPath = applicationPaths().daemonLog, pathEnvironment = process.env.PATH || '/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin', } = {}) {
|
|
73
|
+
const arguments_ = [
|
|
74
|
+
execPath,
|
|
75
|
+
'--max-old-space-size=256',
|
|
76
|
+
'--max-semi-space-size=8',
|
|
77
|
+
'--experimental-strip-types',
|
|
78
|
+
cliEntry,
|
|
79
|
+
'daemon',
|
|
80
|
+
'run',
|
|
81
|
+
];
|
|
82
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
83
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
84
|
+
<plist version="1.0">
|
|
85
|
+
<dict>
|
|
86
|
+
<key>Label</key>
|
|
87
|
+
<string>${MAC_LAUNCH_AGENT_LABEL}</string>
|
|
88
|
+
<key>ProgramArguments</key>
|
|
89
|
+
<array>
|
|
90
|
+
${arguments_.map((value) => ` <string>${xmlEscape(value)}</string>`).join('\n')}
|
|
91
|
+
</array>
|
|
92
|
+
<key>WorkingDirectory</key>
|
|
93
|
+
<string>${xmlEscape(workingDirectory)}</string>
|
|
94
|
+
<key>RunAtLoad</key>
|
|
95
|
+
<true/>
|
|
96
|
+
<key>KeepAlive</key>
|
|
97
|
+
<dict>
|
|
98
|
+
<key>SuccessfulExit</key>
|
|
99
|
+
<false/>
|
|
100
|
+
</dict>
|
|
101
|
+
<key>ThrottleInterval</key>
|
|
102
|
+
<integer>5</integer>
|
|
103
|
+
<key>AbandonProcessGroup</key>
|
|
104
|
+
<true/>
|
|
105
|
+
<key>EnvironmentVariables</key>
|
|
106
|
+
<dict>
|
|
107
|
+
<key>NODE_NO_WARNINGS</key>
|
|
108
|
+
<string>1</string>
|
|
109
|
+
<key>PATH</key>
|
|
110
|
+
<string>${xmlEscape(pathEnvironment)}</string>
|
|
111
|
+
</dict>
|
|
112
|
+
<key>StandardOutPath</key>
|
|
113
|
+
<string>${xmlEscape(logPath)}</string>
|
|
114
|
+
<key>StandardErrorPath</key>
|
|
115
|
+
<string>${xmlEscape(logPath)}</string>
|
|
116
|
+
</dict>
|
|
117
|
+
</plist>
|
|
118
|
+
`;
|
|
119
|
+
}
|
|
62
120
|
function systemctl(arguments_, { spawnSyncImpl = spawnSync, allowFailure = false } = {}) {
|
|
63
121
|
const result = spawnSyncImpl('systemctl', ['--user', ...arguments_], { encoding: 'utf8', stdio: 'pipe' });
|
|
64
122
|
if (!allowFailure && result.status !== 0) {
|
|
@@ -66,6 +124,16 @@ function systemctl(arguments_, { spawnSyncImpl = spawnSync, allowFailure = false
|
|
|
66
124
|
}
|
|
67
125
|
return result;
|
|
68
126
|
}
|
|
127
|
+
function launchctl(arguments_, { spawnSyncImpl = spawnSync, allowFailure = false } = {}) {
|
|
128
|
+
const result = spawnSyncImpl('/bin/launchctl', arguments_, { encoding: 'utf8', stdio: 'pipe' });
|
|
129
|
+
if (!allowFailure && result.status !== 0) {
|
|
130
|
+
throw new Error(String(result.stderr || result.stdout || `launchctl ${arguments_.join(' ')} failed`).trim());
|
|
131
|
+
}
|
|
132
|
+
return result;
|
|
133
|
+
}
|
|
134
|
+
function macLaunchTarget(userId) {
|
|
135
|
+
return `gui/${userId}/${MAC_LAUNCH_AGENT_LABEL}`;
|
|
136
|
+
}
|
|
69
137
|
export function installLinuxDaemon({ paths = applicationPaths(), spawnSyncImpl = spawnSync, } = {}) {
|
|
70
138
|
fs.mkdirSync(path.dirname(paths.linuxService), { recursive: true, mode: 0o700 });
|
|
71
139
|
const unit = linuxServiceUnit();
|
|
@@ -115,20 +183,63 @@ export function linuxDaemonStatus({ spawnSyncImpl = spawnSync } = {}) {
|
|
|
115
183
|
status: String(result.stdout || result.stderr || 'unknown').trim(),
|
|
116
184
|
};
|
|
117
185
|
}
|
|
118
|
-
export function
|
|
119
|
-
if (
|
|
120
|
-
throw new Error('
|
|
121
|
-
}
|
|
122
|
-
|
|
186
|
+
export function installMacDaemon({ paths = applicationPaths(), spawnSyncImpl = spawnSync, userId = process.getuid?.(), } = {}) {
|
|
187
|
+
if (userId === undefined)
|
|
188
|
+
throw new Error('Could not determine the macOS user ID for launchd.');
|
|
189
|
+
fs.mkdirSync(path.dirname(paths.macLaunchAgent), { recursive: true, mode: 0o700 });
|
|
190
|
+
fs.mkdirSync(path.dirname(paths.daemonLog), { recursive: true, mode: 0o700 });
|
|
191
|
+
const target = macLaunchTarget(userId);
|
|
192
|
+
const temporary = `${paths.macLaunchAgent}.${process.pid}.tmp`;
|
|
193
|
+
fs.writeFileSync(temporary, macLaunchAgentPlist({ workingDirectory: os.homedir(), logPath: paths.daemonLog }), { mode: 0o644 });
|
|
194
|
+
fs.renameSync(temporary, paths.macLaunchAgent);
|
|
195
|
+
// A loaded job retains its previous definition. Boot it out after the atomic
|
|
196
|
+
// replacement so a reinstall always starts the command from the current CLI.
|
|
197
|
+
launchctl(['bootout', target], { spawnSyncImpl, allowFailure: true });
|
|
198
|
+
launchctl(['enable', target], { spawnSyncImpl });
|
|
199
|
+
launchctl(['bootstrap', `gui/${userId}`, paths.macLaunchAgent], { spawnSyncImpl });
|
|
200
|
+
launchctl(['kickstart', '-k', target], { spawnSyncImpl });
|
|
201
|
+
return paths.macLaunchAgent;
|
|
123
202
|
}
|
|
124
|
-
export function
|
|
125
|
-
if (
|
|
126
|
-
throw new Error('
|
|
203
|
+
export function uninstallMacDaemon({ paths = applicationPaths(), spawnSyncImpl = spawnSync, userId = process.getuid?.(), } = {}) {
|
|
204
|
+
if (userId === undefined)
|
|
205
|
+
throw new Error('Could not determine the macOS user ID for launchd.');
|
|
206
|
+
launchctl(['bootout', macLaunchTarget(userId)], { spawnSyncImpl, allowFailure: true });
|
|
207
|
+
try {
|
|
208
|
+
fs.unlinkSync(paths.macLaunchAgent);
|
|
209
|
+
}
|
|
210
|
+
catch (error) {
|
|
211
|
+
if (error?.code !== 'ENOENT')
|
|
212
|
+
throw error;
|
|
127
213
|
}
|
|
128
|
-
return uninstallLinuxDaemon(options);
|
|
129
214
|
}
|
|
130
|
-
export function
|
|
131
|
-
if (
|
|
215
|
+
export function macDaemonStatus({ spawnSyncImpl = spawnSync, userId = process.getuid?.(), } = {}) {
|
|
216
|
+
if (userId === undefined)
|
|
132
217
|
return { active: false, status: 'unsupported' };
|
|
133
|
-
|
|
218
|
+
const result = launchctl(['print', macLaunchTarget(userId)], { spawnSyncImpl, allowFailure: true });
|
|
219
|
+
if (result.status !== 0)
|
|
220
|
+
return { active: false, status: 'not running' };
|
|
221
|
+
const output = String(result.stdout || result.stderr || '');
|
|
222
|
+
const state = /^\s*state = (.+?)\s*$/mu.exec(output)?.[1] || 'loaded';
|
|
223
|
+
return { active: state === 'running', status: state };
|
|
224
|
+
}
|
|
225
|
+
export function installDaemon({ platform = process.platform, ...options } = {}) {
|
|
226
|
+
if (platform === 'linux')
|
|
227
|
+
return installLinuxDaemon(options);
|
|
228
|
+
if (platform === 'darwin')
|
|
229
|
+
return installMacDaemon(options);
|
|
230
|
+
throw new Error('Automatic daemon installation is supported on Linux with systemd and macOS with launchd.');
|
|
231
|
+
}
|
|
232
|
+
export function uninstallDaemon({ platform = process.platform, ...options } = {}) {
|
|
233
|
+
if (platform === 'linux')
|
|
234
|
+
return uninstallLinuxDaemon(options);
|
|
235
|
+
if (platform === 'darwin')
|
|
236
|
+
return uninstallMacDaemon(options);
|
|
237
|
+
throw new Error('Automatic daemon removal is supported on Linux with systemd and macOS with launchd.');
|
|
238
|
+
}
|
|
239
|
+
export function daemonStatus({ platform = process.platform, ...options } = {}) {
|
|
240
|
+
if (platform === 'linux')
|
|
241
|
+
return linuxDaemonStatus(options);
|
|
242
|
+
if (platform === 'darwin')
|
|
243
|
+
return macDaemonStatus(options);
|
|
244
|
+
return { active: false, status: 'unsupported' };
|
|
134
245
|
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { resolveBrowsePath } from "./file-browsing.js";
|
|
5
|
+
/** Kept in step with the Durable Object's recursive search result limit. */
|
|
6
|
+
export const MAX_FILE_SEARCH_RESULTS = 200;
|
|
7
|
+
/** Typeahead must not leave a filesystem walk running after its browser query
|
|
8
|
+
* has already been superseded. A timed-out walk returns the useful prefix it
|
|
9
|
+
* found before it was stopped. */
|
|
10
|
+
export const FILE_SEARCH_TIMEOUT_MS = 1_000;
|
|
11
|
+
function isoTime(value) {
|
|
12
|
+
return Number.isFinite(value) ? new Date(value).toISOString() : '';
|
|
13
|
+
}
|
|
14
|
+
function pathIsInside(directory, candidate) {
|
|
15
|
+
const relative = path.relative(directory, candidate);
|
|
16
|
+
return relative !== '' && relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
|
|
17
|
+
}
|
|
18
|
+
function fileSearchEntry(directory, candidate) {
|
|
19
|
+
if (!pathIsInside(directory, candidate))
|
|
20
|
+
return null;
|
|
21
|
+
let link;
|
|
22
|
+
let stat;
|
|
23
|
+
try {
|
|
24
|
+
link = fs.lstatSync(candidate);
|
|
25
|
+
stat = fs.statSync(candidate);
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
const kind = stat.isDirectory() ? 'directory' : stat.isFile() ? 'file' : 'other';
|
|
31
|
+
const relative = path.relative(directory, candidate).split(path.sep).join('/');
|
|
32
|
+
return {
|
|
33
|
+
name: path.basename(candidate),
|
|
34
|
+
path: candidate,
|
|
35
|
+
displayName: relative,
|
|
36
|
+
kind,
|
|
37
|
+
...(stat.isFile() ? { size: stat.size } : {}),
|
|
38
|
+
...(Number.isFinite(stat.mtimeMs) ? { modifiedAt: isoTime(stat.mtimeMs) } : {}),
|
|
39
|
+
...(link.isSymbolicLink() ? { symlink: true } : {}),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Run one NUL-delimited path enumerator without involving a shell.
|
|
44
|
+
*
|
|
45
|
+
* Queries are matched literally in this process, so punctuation in a browser
|
|
46
|
+
* query cannot become a glob, regular expression, command option, or shell
|
|
47
|
+
* fragment. Killing after the result cap also prevents a broad query from
|
|
48
|
+
* walking a huge tree merely to return entries the client cannot display.
|
|
49
|
+
*/
|
|
50
|
+
function enumeratePaths({ command, args, directory, query, limit, timeoutMs }) {
|
|
51
|
+
return new Promise((resolve, reject) => {
|
|
52
|
+
const entries = new Map();
|
|
53
|
+
const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
54
|
+
let pending = '';
|
|
55
|
+
let stopped = false;
|
|
56
|
+
let timedOut = false;
|
|
57
|
+
let killTimer;
|
|
58
|
+
const stop = () => {
|
|
59
|
+
if (stopped)
|
|
60
|
+
return;
|
|
61
|
+
stopped = true;
|
|
62
|
+
child.kill('SIGTERM');
|
|
63
|
+
killTimer = setTimeout(() => child.kill('SIGKILL'), 100);
|
|
64
|
+
killTimer.unref?.();
|
|
65
|
+
};
|
|
66
|
+
const consider = (rawPath) => {
|
|
67
|
+
const emitted = String(rawPath || '');
|
|
68
|
+
if (!emitted)
|
|
69
|
+
return;
|
|
70
|
+
const absolute = path.normalize(path.isAbsolute(emitted) ? emitted : path.resolve(directory, emitted));
|
|
71
|
+
if (!pathIsInside(directory, absolute))
|
|
72
|
+
return;
|
|
73
|
+
const relativeParts = path.relative(directory, absolute).split(path.sep);
|
|
74
|
+
// `rg --files` emits regular files rather than directories. Considering
|
|
75
|
+
// every prefix reconstructs matching directory observations too, and it
|
|
76
|
+
// preserves the cache's existing "match anywhere in the relative path"
|
|
77
|
+
// semantics for descendants of a matching directory.
|
|
78
|
+
for (let length = 1; length <= relativeParts.length; length += 1) {
|
|
79
|
+
const relative = relativeParts.slice(0, length).join('/');
|
|
80
|
+
if (!relative.toLowerCase().includes(query))
|
|
81
|
+
continue;
|
|
82
|
+
const candidate = path.join(directory, ...relativeParts.slice(0, length));
|
|
83
|
+
if (!entries.has(candidate)) {
|
|
84
|
+
const entry = fileSearchEntry(directory, candidate);
|
|
85
|
+
if (entry)
|
|
86
|
+
entries.set(candidate, entry);
|
|
87
|
+
}
|
|
88
|
+
if (entries.size >= limit) {
|
|
89
|
+
stop();
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
const consume = (chunk, final = false) => {
|
|
95
|
+
pending += chunk;
|
|
96
|
+
let boundary = pending.indexOf('\0');
|
|
97
|
+
while (boundary !== -1) {
|
|
98
|
+
consider(pending.slice(0, boundary));
|
|
99
|
+
pending = pending.slice(boundary + 1);
|
|
100
|
+
boundary = pending.indexOf('\0');
|
|
101
|
+
}
|
|
102
|
+
if (final && pending)
|
|
103
|
+
consider(pending);
|
|
104
|
+
};
|
|
105
|
+
child.stdout.setEncoding('utf8');
|
|
106
|
+
child.stdout.on('data', (chunk) => consume(chunk));
|
|
107
|
+
child.on('error', (error) => {
|
|
108
|
+
clearTimeout(killTimer);
|
|
109
|
+
reject(error);
|
|
110
|
+
});
|
|
111
|
+
const timeout = setTimeout(() => {
|
|
112
|
+
timedOut = true;
|
|
113
|
+
stop();
|
|
114
|
+
}, timeoutMs);
|
|
115
|
+
timeout.unref?.();
|
|
116
|
+
child.on('close', (code) => {
|
|
117
|
+
clearTimeout(timeout);
|
|
118
|
+
clearTimeout(killTimer);
|
|
119
|
+
consume('', true);
|
|
120
|
+
const sorted = [...entries.values()]
|
|
121
|
+
.sort((left, right) => {
|
|
122
|
+
if (left.kind === 'directory' && right.kind !== 'directory')
|
|
123
|
+
return -1;
|
|
124
|
+
if (left.kind !== 'directory' && right.kind === 'directory')
|
|
125
|
+
return 1;
|
|
126
|
+
return left.path.localeCompare(right.path);
|
|
127
|
+
})
|
|
128
|
+
.slice(0, limit);
|
|
129
|
+
// Missing tools are tried by the caller. Permission races and partial
|
|
130
|
+
// walks remain useful if they produced any safe entries.
|
|
131
|
+
if (code === 0 || stopped || timedOut || sorted.length)
|
|
132
|
+
resolve(sorted);
|
|
133
|
+
else
|
|
134
|
+
reject(new Error(`${command} exited with status ${code}.`));
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Find recursive path-name matches on the machine for directory typeahead.
|
|
140
|
+
*
|
|
141
|
+
* Ripgrep is preferred because its parallel directory walker is normally the
|
|
142
|
+
* quickest way to enumerate a project tree. Minimal installations fall back
|
|
143
|
+
* to POSIX `find`; both emit NUL-delimited names and are filtered literally.
|
|
144
|
+
*/
|
|
145
|
+
export async function searchMachinePaths({ requested, query, workingDirectory = '', allowedDirectories = [], limit = MAX_FILE_SEARCH_RESULTS, timeoutMs = FILE_SEARCH_TIMEOUT_MS, tools = ['rg', 'find'], }) {
|
|
146
|
+
const needle = String(query || '')
|
|
147
|
+
.trim()
|
|
148
|
+
.toLowerCase()
|
|
149
|
+
.slice(0, 500);
|
|
150
|
+
const resolved = resolveBrowsePath(String(requested || ''), { workingDirectory, allowedDirectories });
|
|
151
|
+
if (!resolved.stat.isDirectory())
|
|
152
|
+
throw new Error(`Not a searchable directory: ${resolved.path}`);
|
|
153
|
+
if (!needle)
|
|
154
|
+
return { directory: resolved.path, entries: [] };
|
|
155
|
+
const boundedLimit = Math.max(1, Math.min(MAX_FILE_SEARCH_RESULTS, Number(limit) || MAX_FILE_SEARCH_RESULTS));
|
|
156
|
+
const boundedTimeout = Math.max(25, Number(timeoutMs) || FILE_SEARCH_TIMEOUT_MS);
|
|
157
|
+
let lastError = null;
|
|
158
|
+
for (const tool of tools) {
|
|
159
|
+
try {
|
|
160
|
+
const args = tool === 'rg'
|
|
161
|
+
? ['--files', '--hidden', '--no-ignore', '--no-messages', '--null', '--', resolved.path]
|
|
162
|
+
: tool === 'find'
|
|
163
|
+
? [resolved.path, '-print0']
|
|
164
|
+
: [];
|
|
165
|
+
if (!args.length)
|
|
166
|
+
continue;
|
|
167
|
+
const entries = await enumeratePaths({
|
|
168
|
+
command: tool,
|
|
169
|
+
args,
|
|
170
|
+
directory: resolved.path,
|
|
171
|
+
query: needle,
|
|
172
|
+
limit: boundedLimit,
|
|
173
|
+
timeoutMs: boundedTimeout,
|
|
174
|
+
});
|
|
175
|
+
return { directory: resolved.path, entries };
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
lastError = error;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
throw lastError instanceof Error ? lastError : new Error('No filesystem search tool is available.');
|
|
182
|
+
}
|
package/dist/queue-worker.js
CHANGED
|
@@ -8,6 +8,7 @@ import { anchorId, connectExistingAgentAnchors, launchAgentAnchor, waitForAgentT
|
|
|
8
8
|
import { assertAllowedWorkingDirectory, workingDirectoryIsAllowed } from "./directory-policy.js";
|
|
9
9
|
import { browseMachinePath } from "./file-browsing.js";
|
|
10
10
|
import { applyFileEdit } from "./file-edits.js";
|
|
11
|
+
import { searchMachinePaths } from "./file-search.js";
|
|
11
12
|
import { nativeDescriptorAgentSessionId, nativeDescriptorWorkingDirectory } from "./local-session-sources.js";
|
|
12
13
|
import { compactRuntimeValue } from "./native-record-compaction.js";
|
|
13
14
|
import { createSecretRedactor } from "./secret-redaction.js";
|
|
@@ -1069,6 +1070,39 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
|
|
|
1069
1070
|
});
|
|
1070
1071
|
}
|
|
1071
1072
|
};
|
|
1073
|
+
/** Refresh recursive path-name matches for directory typeahead.
|
|
1074
|
+
*
|
|
1075
|
+
* Like browsing, this is a machine filesystem operation rather than a
|
|
1076
|
+
* harness operation, so stopped sessions remain searchable. The helper
|
|
1077
|
+
* canonicalizes and allowlists the directory before starting a bounded
|
|
1078
|
+
* ripgrep/find walk; only metadata crosses the control socket. */
|
|
1079
|
+
const handleFileSearch = async (message, replyRpc) => {
|
|
1080
|
+
const sessionId = String(message.sessionId || '');
|
|
1081
|
+
try {
|
|
1082
|
+
const searched = await searchMachinePaths({
|
|
1083
|
+
requested: String(message.directory || ''),
|
|
1084
|
+
query: String(message.query || ''),
|
|
1085
|
+
workingDirectory: await browseWorkingDirectory(sessionId),
|
|
1086
|
+
allowedDirectories: options.allowedDirectories || [],
|
|
1087
|
+
});
|
|
1088
|
+
replyRpc.notify({
|
|
1089
|
+
type: 'session.file.search.result',
|
|
1090
|
+
requestId: message.requestId,
|
|
1091
|
+
sessionId,
|
|
1092
|
+
ok: true,
|
|
1093
|
+
...searched,
|
|
1094
|
+
});
|
|
1095
|
+
}
|
|
1096
|
+
catch (error) {
|
|
1097
|
+
replyRpc.notify({
|
|
1098
|
+
type: 'session.file.search.result',
|
|
1099
|
+
requestId: message.requestId,
|
|
1100
|
+
sessionId,
|
|
1101
|
+
ok: false,
|
|
1102
|
+
error: redactor.redactText(error?.message || String(error)),
|
|
1103
|
+
});
|
|
1104
|
+
}
|
|
1105
|
+
};
|
|
1072
1106
|
/**
|
|
1073
1107
|
* Apply one queued browser edit, and report how it went.
|
|
1074
1108
|
*
|
|
@@ -1139,6 +1173,8 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
|
|
|
1139
1173
|
void handleInteractionAnswer(message, rpc);
|
|
1140
1174
|
if (message.type === 'session.file.browse.request')
|
|
1141
1175
|
void handleFileBrowse(message, rpc);
|
|
1176
|
+
if (message.type === 'session.file.search.request')
|
|
1177
|
+
void handleFileSearch(message, rpc);
|
|
1142
1178
|
if (message.type === 'session.file.edit.request')
|
|
1143
1179
|
void handleFileEdit(message, rpc);
|
|
1144
1180
|
});
|
|
@@ -1153,6 +1189,7 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
|
|
|
1153
1189
|
type: 'machine.register',
|
|
1154
1190
|
machineId: machine,
|
|
1155
1191
|
machineHostname: os.hostname(),
|
|
1192
|
+
capabilities: { fileSearch: true },
|
|
1156
1193
|
workerId,
|
|
1157
1194
|
workerInstanceId,
|
|
1158
1195
|
anchorSessionIds: [...anchors.keys()],
|