@turns/dev 0.0.6 → 0.0.8
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-browsing.js +2 -1
- package/dist/file-edits.js +82 -0
- package/dist/path-resolution.js +2 -1
- package/dist/queue-worker.js +53 -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
|
}
|
package/dist/file-browsing.js
CHANGED
|
@@ -173,7 +173,7 @@ export function readDirectory(directory) {
|
|
|
173
173
|
// clock inside the body would make every read produce different bytes.
|
|
174
174
|
return { path: directory, entries, truncated: names.length > MAX_DIRECTORY_ENTRIES };
|
|
175
175
|
}
|
|
176
|
-
export async function browseMachinePath({ requested, machine, baseUrl, options, workingDirectory = '', fetchImpl = fetch, }) {
|
|
176
|
+
export async function browseMachinePath({ requested, machine, baseUrl, options, workingDirectory = '', contentEtag = '', fetchImpl = fetch, }) {
|
|
177
177
|
const resolved = resolveBrowsePath(requested, {
|
|
178
178
|
workingDirectory,
|
|
179
179
|
allowedDirectories: options.allowedDirectories || [],
|
|
@@ -209,6 +209,7 @@ export async function browseMachinePath({ requested, machine, baseUrl, options,
|
|
|
209
209
|
contentType,
|
|
210
210
|
bytes: resolved.stat.size,
|
|
211
211
|
modifiedAt,
|
|
212
|
+
...(contentEtag ? { contentEtag } : {}),
|
|
212
213
|
fetchImpl,
|
|
213
214
|
});
|
|
214
215
|
return { path: resolved.path, kind: 'file', bytes: resolved.stat.size, contentType, modifiedAt };
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { Readable } from 'node:stream';
|
|
5
|
+
import { pipeline } from 'node:stream/promises';
|
|
6
|
+
import { workingDirectoryIsAllowed } from "./directory-policy.js";
|
|
7
|
+
/**
|
|
8
|
+
* Applying a browser edit to a file this machine holds.
|
|
9
|
+
*
|
|
10
|
+
* The server's queue row says what the edit expects (`beforeHash`) and what it
|
|
11
|
+
* produces (`afterHash`); the bytes themselves are downloaded from the
|
|
12
|
+
* account's bucket only once the file on disk has been shown to still match
|
|
13
|
+
* what the editor started from. The replacement is written beside the target
|
|
14
|
+
* and renamed over it, so a crash mid-write leaves the original untouched, and
|
|
15
|
+
* the downloaded bytes are verified against `afterHash` before the rename —
|
|
16
|
+
* corrupt or truncated downloads never reach the file.
|
|
17
|
+
*
|
|
18
|
+
* A redelivered edit is settled by content: a file that already hashes to
|
|
19
|
+
* `afterHash` reports success without touching the disk, which is what lets
|
|
20
|
+
* the server re-offer an edit whose earlier answer was lost.
|
|
21
|
+
*/
|
|
22
|
+
/** SHA-256 of one file's bytes, streamed so large files cost no memory. */
|
|
23
|
+
export async function hashFileSha256(file) {
|
|
24
|
+
const hash = crypto.createHash('sha256');
|
|
25
|
+
await pipeline(fs.createReadStream(file), hash);
|
|
26
|
+
return hash.digest('hex');
|
|
27
|
+
}
|
|
28
|
+
export async function applyFileEdit({ edit, baseUrl, options, fetchImpl = fetch, }) {
|
|
29
|
+
const requested = String(edit.path || '').trim();
|
|
30
|
+
if (!requested || !path.isAbsolute(requested)) {
|
|
31
|
+
throw new Error(`File edits need an absolute path: ${requested || '(empty)'}`);
|
|
32
|
+
}
|
|
33
|
+
let canonical;
|
|
34
|
+
let stat;
|
|
35
|
+
try {
|
|
36
|
+
canonical = fs.realpathSync(requested);
|
|
37
|
+
stat = fs.statSync(canonical);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
throw new Error(`No such file: ${requested}`);
|
|
41
|
+
}
|
|
42
|
+
if (!stat.isFile())
|
|
43
|
+
throw new Error(`Not an editable file: ${canonical}`);
|
|
44
|
+
// The same allowlist that bounds browsing bounds writing: a file is editable
|
|
45
|
+
// exactly when the directory holding it could be browsed.
|
|
46
|
+
if (!workingDirectoryIsAllowed(path.dirname(canonical), options.allowedDirectories || [])) {
|
|
47
|
+
throw new Error(`Path is outside the daemon allowlist: ${canonical}`);
|
|
48
|
+
}
|
|
49
|
+
const current = await hashFileSha256(canonical);
|
|
50
|
+
if (current === edit.afterHash)
|
|
51
|
+
return { path: canonical, alreadyApplied: true };
|
|
52
|
+
if (current !== edit.beforeHash) {
|
|
53
|
+
throw new Error('The file changed on this machine after the edit was made, so it was not applied.');
|
|
54
|
+
}
|
|
55
|
+
const url = new URL(`/api/file-edits/${encodeURIComponent(String(edit.id || ''))}`, String(baseUrl));
|
|
56
|
+
const response = await fetchImpl(url, {
|
|
57
|
+
headers: { authorization: `Bearer ${options.token}` },
|
|
58
|
+
});
|
|
59
|
+
if (!response.ok || !response.body) {
|
|
60
|
+
throw new Error(`The edited file could not be downloaded (${response.status}).`);
|
|
61
|
+
}
|
|
62
|
+
const temporary = path.join(path.dirname(canonical), `.${path.basename(canonical)}.turns-edit-${process.pid}.tmp`);
|
|
63
|
+
try {
|
|
64
|
+
await pipeline(Readable.fromWeb(response.body), fs.createWriteStream(temporary, { mode: 0o600 }));
|
|
65
|
+
const written = fs.statSync(temporary);
|
|
66
|
+
if (Number(edit.bytes) !== written.size) {
|
|
67
|
+
throw new Error('The downloaded edit did not match its recorded size.');
|
|
68
|
+
}
|
|
69
|
+
if ((await hashFileSha256(temporary)) !== edit.afterHash) {
|
|
70
|
+
throw new Error('The downloaded edit did not match its recorded content hash.');
|
|
71
|
+
}
|
|
72
|
+
// The temporary file was created 0600 regardless of umask; give the
|
|
73
|
+
// replacement the permissions the original had before it takes its name.
|
|
74
|
+
fs.chmodSync(temporary, stat.mode & 0o7777);
|
|
75
|
+
fs.renameSync(temporary, canonical);
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
fs.rmSync(temporary, { force: true });
|
|
79
|
+
throw error;
|
|
80
|
+
}
|
|
81
|
+
return { path: canonical, alreadyApplied: false };
|
|
82
|
+
}
|
package/dist/path-resolution.js
CHANGED
|
@@ -193,7 +193,7 @@ export async function uploadFileBytes({ baseUrl, options, machine, absolutePath,
|
|
|
193
193
|
return true;
|
|
194
194
|
}
|
|
195
195
|
/** Stream one on-disk file to R2 without retaining its body in the daemon. */
|
|
196
|
-
export async function uploadFileStream({ baseUrl, options, machine, absolutePath, contentType, bytes, modifiedAt, fetchImpl = fetch, }) {
|
|
196
|
+
export async function uploadFileStream({ baseUrl, options, machine, absolutePath, contentType, bytes, modifiedAt, contentEtag, fetchImpl = fetch, }) {
|
|
197
197
|
const url = new URL(`/api/file/${encodeURIComponent(machine)}/${encodeURIComponent(absolutePath)}`, String(baseUrl));
|
|
198
198
|
const body = fs.createReadStream(absolutePath);
|
|
199
199
|
try {
|
|
@@ -204,6 +204,7 @@ export async function uploadFileStream({ baseUrl, options, machine, absolutePath
|
|
|
204
204
|
'content-type': contentType,
|
|
205
205
|
'content-length': String(bytes),
|
|
206
206
|
...(modifiedAt ? { 'x-turns-file-modified-at': modifiedAt } : {}),
|
|
207
|
+
...(contentEtag ? { 'x-turns-content-etag': contentEtag } : {}),
|
|
207
208
|
},
|
|
208
209
|
body,
|
|
209
210
|
// Node requires this opt-in for a streaming request body.
|
package/dist/queue-worker.js
CHANGED
|
@@ -7,6 +7,7 @@ import { pipeline } from 'node:stream/promises';
|
|
|
7
7
|
import { anchorId, connectExistingAgentAnchors, launchAgentAnchor, waitForAgentTurn } from "./agent-anchor-client.js";
|
|
8
8
|
import { assertAllowedWorkingDirectory, workingDirectoryIsAllowed } from "./directory-policy.js";
|
|
9
9
|
import { browseMachinePath } from "./file-browsing.js";
|
|
10
|
+
import { applyFileEdit } from "./file-edits.js";
|
|
10
11
|
import { nativeDescriptorAgentSessionId, nativeDescriptorWorkingDirectory } from "./local-session-sources.js";
|
|
11
12
|
import { compactRuntimeValue } from "./native-record-compaction.js";
|
|
12
13
|
import { createSecretRedactor } from "./secret-redaction.js";
|
|
@@ -1068,6 +1069,55 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
|
|
|
1068
1069
|
});
|
|
1069
1070
|
}
|
|
1070
1071
|
};
|
|
1072
|
+
/**
|
|
1073
|
+
* Apply one queued browser edit, and report how it went.
|
|
1074
|
+
*
|
|
1075
|
+
* Edits are serialized through one chain: the server sends every pending
|
|
1076
|
+
* edit for this machine in queue order, and applying them strictly in
|
|
1077
|
+
* arrival order is what lets several edits to one file chain — each one's
|
|
1078
|
+
* before-hash is the previous one's after-hash. After a real application the
|
|
1079
|
+
* file is re-browsed so the stored copy readers open reflects the new bytes.
|
|
1080
|
+
*/
|
|
1081
|
+
let fileEditChain = Promise.resolve();
|
|
1082
|
+
const handleFileEdit = (message, replyRpc) => {
|
|
1083
|
+
fileEditChain = fileEditChain.then(async () => {
|
|
1084
|
+
const sessionId = String(message.sessionId || '');
|
|
1085
|
+
const edit = message.edit || {};
|
|
1086
|
+
try {
|
|
1087
|
+
const applied = await applyFileEdit({ edit, baseUrl, options });
|
|
1088
|
+
if (!applied.alreadyApplied) {
|
|
1089
|
+
await browseMachinePath({
|
|
1090
|
+
requested: applied.path,
|
|
1091
|
+
machine,
|
|
1092
|
+
baseUrl,
|
|
1093
|
+
options,
|
|
1094
|
+
// The applied bytes were verified against this hash, so store it
|
|
1095
|
+
// as their identity: a pane already showing them recognizes the
|
|
1096
|
+
// refresh instead of being offered its own save as a new version.
|
|
1097
|
+
contentEtag: String(edit.afterHash || ''),
|
|
1098
|
+
}).catch((error) => process.stderr.write(`Post-edit refresh failed for ${applied.path}: ${error?.message || error}\n`));
|
|
1099
|
+
}
|
|
1100
|
+
replyRpc.notify({
|
|
1101
|
+
type: 'session.file.edit.result',
|
|
1102
|
+
requestId: message.requestId,
|
|
1103
|
+
sessionId,
|
|
1104
|
+
editId: String(edit.id || ''),
|
|
1105
|
+
ok: true,
|
|
1106
|
+
});
|
|
1107
|
+
}
|
|
1108
|
+
catch (error) {
|
|
1109
|
+
replyRpc.notify({
|
|
1110
|
+
type: 'session.file.edit.result',
|
|
1111
|
+
requestId: message.requestId,
|
|
1112
|
+
sessionId,
|
|
1113
|
+
editId: String(edit.id || ''),
|
|
1114
|
+
ok: false,
|
|
1115
|
+
error: redactor.redactText(error?.message || String(error)),
|
|
1116
|
+
});
|
|
1117
|
+
}
|
|
1118
|
+
});
|
|
1119
|
+
return fileEditChain;
|
|
1120
|
+
};
|
|
1071
1121
|
const heartbeat = setInterval(() => {
|
|
1072
1122
|
for (const [sessionId, entry] of anchors) {
|
|
1073
1123
|
if (entry.connection.closed)
|
|
@@ -1089,6 +1139,8 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
|
|
|
1089
1139
|
void handleInteractionAnswer(message, rpc);
|
|
1090
1140
|
if (message.type === 'session.file.browse.request')
|
|
1091
1141
|
void handleFileBrowse(message, rpc);
|
|
1142
|
+
if (message.type === 'session.file.edit.request')
|
|
1143
|
+
void handleFileEdit(message, rpc);
|
|
1092
1144
|
});
|
|
1093
1145
|
const registered = new WeakSet();
|
|
1094
1146
|
const registering = new WeakSet();
|
|
@@ -1100,6 +1152,7 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
|
|
|
1100
1152
|
const ready = await control.request({
|
|
1101
1153
|
type: 'machine.register',
|
|
1102
1154
|
machineId: machine,
|
|
1155
|
+
machineHostname: os.hostname(),
|
|
1103
1156
|
workerId,
|
|
1104
1157
|
workerInstanceId,
|
|
1105
1158
|
anchorSessionIds: [...anchors.keys()],
|