@turns/dev 0.0.8 → 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.
@@ -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
+ }
@@ -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()],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@turns/dev",
3
- "version": "0.0.8",
3
+ "version": "0.0.9",
4
4
  "description": "Upload local AI agent sessions to turns.dev",
5
5
  "license": "MIT",
6
6
  "type": "module",