@turns/dev 0.0.1
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 +74 -0
- package/dist/agent-anchor-client.js +319 -0
- package/dist/agent-anchor.js +1685 -0
- package/dist/auth.js +258 -0
- package/dist/daemon.js +134 -0
- package/dist/deepseek-trajectories.js +196 -0
- package/dist/directory-policy.js +41 -0
- package/dist/file-browsing.js +241 -0
- package/dist/git-association.js +136 -0
- package/dist/harness-registry.js +323 -0
- package/dist/index.js +300 -0
- package/dist/local-session-sources.js +1448 -0
- package/dist/native-record-compaction.js +84 -0
- package/dist/path-resolution.js +298 -0
- package/dist/question-mcp.js +126 -0
- package/dist/queue-worker.js +1053 -0
- package/dist/secret-redaction.js +181 -0
- package/dist/upload-sessions.js +2016 -0
- package/package.json +35 -0
package/README.md
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# Turns CLI
|
|
2
|
+
|
|
3
|
+
`turns` uploads native agent transcripts directly from this machine to the
|
|
4
|
+
current user's Durable Object on turns.dev. Transcript rows are secret-filtered
|
|
5
|
+
on the machine immediately before the WebSocket boundary; local transcript
|
|
6
|
+
files are never rewritten.
|
|
7
|
+
|
|
8
|
+
Install the CLI with Node.js 22.18 or newer:
|
|
9
|
+
|
|
10
|
+
```sh
|
|
11
|
+
npm install --global @turns/dev
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Commands
|
|
15
|
+
|
|
16
|
+
```text
|
|
17
|
+
turns upload sessions for the current directory once
|
|
18
|
+
turns upload [directory] watch one directory
|
|
19
|
+
turns install install the all-directory user daemon
|
|
20
|
+
turns status report daemon state
|
|
21
|
+
turns uninstall remove the daemon
|
|
22
|
+
turns login replace saved credentials through the browser
|
|
23
|
+
turns logout remove saved credentials
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
The server is selected with `--server URL` or `TURNS_ORIGIN`. Its normalized
|
|
27
|
+
origin is saved in `~/.config/turns/credentials.json` and reused by later
|
|
28
|
+
invocations and daemon starts. On Linux the other persistent files are:
|
|
29
|
+
|
|
30
|
+
- `~/.config/turns/machine-id`
|
|
31
|
+
- `~/.local/state/turns/native-upload-state.json`
|
|
32
|
+
- `~/.config/systemd/user/turns.service`
|
|
33
|
+
|
|
34
|
+
The credential file contains a seven-day access token and a signed refresh
|
|
35
|
+
token. The CLI refreshes the access token at least once every 24 hours. A new
|
|
36
|
+
machine opens a one-time turns.dev URL, waits on an authentication WebSocket,
|
|
37
|
+
and saves the resulting pair with mode `0600`. Set `TURNS_NO_BROWSER=1` to only
|
|
38
|
+
print the URL.
|
|
39
|
+
|
|
40
|
+
Rows are scanned on their way out for substrings that look like file paths —
|
|
41
|
+
`out/chart.png`, `` `src/app.tsx` ``, `~/shots/a.png` — and each one the
|
|
42
|
+
filesystem confirms is recorded on the session's own log as a resolution: the
|
|
43
|
+
substring, the absolute path, the size, and whether the contents were sent.
|
|
44
|
+
Paths resolve against the session's working directory and are canonicalised
|
|
45
|
+
before the `--allow-directory` prefixes are enforced, so a symlink out of an
|
|
46
|
+
allowed tree is not a way to read the disk.
|
|
47
|
+
|
|
48
|
+
Only images under 10 MB are uploaded, to `PUT /api/file/{machine}/{path}`. Everything else is recorded as a name, so
|
|
49
|
+
a reader learns what a response was pointing at without the machine shipping
|
|
50
|
+
source files. Nothing is claimed until the filesystem confirms it, which is what
|
|
51
|
+
bounds a deliberately broad matcher: prose that merely looks like a path
|
|
52
|
+
resolves to nothing and is never mentioned to the server, and no directory is
|
|
53
|
+
ever scanned.
|
|
54
|
+
|
|
55
|
+
An explicit file browse may upload up to 199 MB to the same private R2 prefix.
|
|
56
|
+
The daemon streams the file from disk and the Worker hands that request stream
|
|
57
|
+
straight to R2, so neither process retains the complete file in memory.
|
|
58
|
+
|
|
59
|
+
The systemd unit uses `KillMode=process`: restarting the uploader leaves its
|
|
60
|
+
detached agent-anchor processes running so in-flight harness turns continue.
|
|
61
|
+
For a foreground daemon restricted to one or more directory trees, repeat
|
|
62
|
+
`--allow-directory`. Both transcript discovery and agent launches enforce the
|
|
63
|
+
canonical prefix, including protection against symlinks that escape it:
|
|
64
|
+
|
|
65
|
+
```sh
|
|
66
|
+
turns daemon run --server https://turns.example --allow-directory /srv/agents
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Against local Wrangler development, use the configured `DEV_USER` directly:
|
|
70
|
+
|
|
71
|
+
```sh
|
|
72
|
+
turns daemon run --server http://127.0.0.1:8787 \
|
|
73
|
+
--dev-user antimatter15 --allow-directory /tmp/turns-driver-workspaces
|
|
74
|
+
```
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import net from 'node:net';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import readline from 'node:readline';
|
|
7
|
+
import { spawn } from 'node:child_process';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
function withTimeout(promise, timeoutMs, label) {
|
|
11
|
+
let timer;
|
|
12
|
+
return Promise.race([
|
|
13
|
+
promise,
|
|
14
|
+
new Promise((_, reject) => {
|
|
15
|
+
timer = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms.`)), timeoutMs);
|
|
16
|
+
timer.unref?.();
|
|
17
|
+
}),
|
|
18
|
+
]).finally(() => clearTimeout(timer));
|
|
19
|
+
}
|
|
20
|
+
function wait(milliseconds) {
|
|
21
|
+
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
22
|
+
}
|
|
23
|
+
export class AgentAnchorConnection {
|
|
24
|
+
socketPath;
|
|
25
|
+
socket;
|
|
26
|
+
lines;
|
|
27
|
+
nextId;
|
|
28
|
+
pending;
|
|
29
|
+
listeners;
|
|
30
|
+
recentMessages;
|
|
31
|
+
lastEventCursor;
|
|
32
|
+
snapshot;
|
|
33
|
+
closed;
|
|
34
|
+
constructor(socketPath) {
|
|
35
|
+
this.socketPath = socketPath;
|
|
36
|
+
this.socket = null;
|
|
37
|
+
this.lines = null;
|
|
38
|
+
this.nextId = 1;
|
|
39
|
+
this.pending = new Map();
|
|
40
|
+
this.listeners = new Set();
|
|
41
|
+
this.recentMessages = [];
|
|
42
|
+
this.lastEventCursor = 0;
|
|
43
|
+
this.snapshot = null;
|
|
44
|
+
this.closed = false;
|
|
45
|
+
}
|
|
46
|
+
async connect(timeoutMs = 2_000) {
|
|
47
|
+
const socket = net.createConnection(this.socketPath);
|
|
48
|
+
this.socket = socket;
|
|
49
|
+
socket.setEncoding('utf8');
|
|
50
|
+
this.lines = readline.createInterface({ input: socket });
|
|
51
|
+
this.lines.on('line', (line) => this.handleLine(line));
|
|
52
|
+
this.lines.on('error', (error) => this.handleDisconnect(error));
|
|
53
|
+
socket.on('error', (error) => this.handleDisconnect(error));
|
|
54
|
+
socket.on('close', () => this.handleDisconnect(new Error(`Agent anchor disconnected: ${this.socketPath}`)));
|
|
55
|
+
await withTimeout(new Promise((resolve, reject) => {
|
|
56
|
+
socket.once('connect', resolve);
|
|
57
|
+
socket.once('error', reject);
|
|
58
|
+
}), timeoutMs, `Agent anchor connection ${this.socketPath}`);
|
|
59
|
+
this.snapshot = await this.request('snapshot', {}, timeoutMs);
|
|
60
|
+
return this;
|
|
61
|
+
}
|
|
62
|
+
handleLine(line) {
|
|
63
|
+
if (!line.trim())
|
|
64
|
+
return;
|
|
65
|
+
let message;
|
|
66
|
+
try {
|
|
67
|
+
message = JSON.parse(line);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (message.id !== undefined) {
|
|
73
|
+
const pending = this.pending.get(message.id);
|
|
74
|
+
if (!pending)
|
|
75
|
+
return;
|
|
76
|
+
this.pending.delete(message.id);
|
|
77
|
+
if (message.ok === false)
|
|
78
|
+
pending.reject(new Error(message.error || 'Agent anchor request failed.'));
|
|
79
|
+
else
|
|
80
|
+
pending.resolve(message.result);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (message.type === 'anchor_hello' && message.anchor)
|
|
84
|
+
this.snapshot = message.anchor;
|
|
85
|
+
const cursor = ++this.lastEventCursor;
|
|
86
|
+
this.recentMessages.push({ cursor, message });
|
|
87
|
+
if (this.recentMessages.length > 512)
|
|
88
|
+
this.recentMessages.shift();
|
|
89
|
+
for (const listener of this.listeners)
|
|
90
|
+
listener(message, cursor);
|
|
91
|
+
}
|
|
92
|
+
handleDisconnect(error) {
|
|
93
|
+
if (this.closed)
|
|
94
|
+
return;
|
|
95
|
+
this.closed = true;
|
|
96
|
+
try {
|
|
97
|
+
this.lines?.close();
|
|
98
|
+
}
|
|
99
|
+
catch { }
|
|
100
|
+
try {
|
|
101
|
+
this.socket?.destroy();
|
|
102
|
+
}
|
|
103
|
+
catch { }
|
|
104
|
+
for (const pending of this.pending.values())
|
|
105
|
+
pending.reject(error);
|
|
106
|
+
this.pending.clear();
|
|
107
|
+
for (const listener of this.listeners)
|
|
108
|
+
listener({
|
|
109
|
+
type: 'anchor_disconnect',
|
|
110
|
+
error: error?.message || String(error),
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
request(type, params = {}, timeoutMs = 15_000) {
|
|
114
|
+
if (this.closed || !this.socket?.writable)
|
|
115
|
+
return Promise.reject(new Error('Agent anchor is not connected.'));
|
|
116
|
+
const id = this.nextId++;
|
|
117
|
+
return withTimeout(new Promise((resolve, reject) => {
|
|
118
|
+
this.pending.set(id, { resolve, reject });
|
|
119
|
+
this.socket.write(`${JSON.stringify({ id, type, ...params })}\n`, (error) => {
|
|
120
|
+
if (!error)
|
|
121
|
+
return;
|
|
122
|
+
this.pending.delete(id);
|
|
123
|
+
reject(error);
|
|
124
|
+
});
|
|
125
|
+
}), timeoutMs, `Agent anchor request ${type}`).finally(() => this.pending.delete(id));
|
|
126
|
+
}
|
|
127
|
+
onEvent(listener) {
|
|
128
|
+
this.listeners.add(listener);
|
|
129
|
+
return () => this.listeners.delete(listener);
|
|
130
|
+
}
|
|
131
|
+
recentEvents() {
|
|
132
|
+
return this.recentMessages.map((entry) => entry.message);
|
|
133
|
+
}
|
|
134
|
+
eventCursor() {
|
|
135
|
+
return this.lastEventCursor;
|
|
136
|
+
}
|
|
137
|
+
recentEventsAfter(cursor) {
|
|
138
|
+
return this.recentMessages.filter((entry) => entry.cursor > cursor).map((entry) => ({ ...entry }));
|
|
139
|
+
}
|
|
140
|
+
close() {
|
|
141
|
+
if (this.closed)
|
|
142
|
+
return;
|
|
143
|
+
this.closed = true;
|
|
144
|
+
try {
|
|
145
|
+
this.lines?.close();
|
|
146
|
+
}
|
|
147
|
+
catch { }
|
|
148
|
+
try {
|
|
149
|
+
this.socket?.end();
|
|
150
|
+
this.socket?.unref();
|
|
151
|
+
}
|
|
152
|
+
catch { }
|
|
153
|
+
for (const pending of this.pending.values())
|
|
154
|
+
pending.reject(new Error('Agent anchor connection closed.'));
|
|
155
|
+
this.pending.clear();
|
|
156
|
+
this.listeners.clear();
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
export function agentAnchorDirectory() {
|
|
160
|
+
return path.resolve(process.env.TURNS_AGENT_ANCHOR_DIR || path.join(os.homedir(), '.local', 'state', 'turns-dev', 'agent-anchors'));
|
|
161
|
+
}
|
|
162
|
+
function anchorEntryPoint() {
|
|
163
|
+
const configured = process.env.TURNS_AGENT_ANCHOR_BIN;
|
|
164
|
+
const extension = path.extname(fileURLToPath(import.meta.url));
|
|
165
|
+
const candidate = path.resolve(configured || path.join(HERE, `agent-anchor${extension}`));
|
|
166
|
+
if (!fs.existsSync(candidate)) {
|
|
167
|
+
throw new Error(`The Turns agent anchor was not found at ${candidate}. Set TURNS_AGENT_ANCHOR_BIN.`);
|
|
168
|
+
}
|
|
169
|
+
return candidate;
|
|
170
|
+
}
|
|
171
|
+
function socketPathFor(sessionId) {
|
|
172
|
+
const directory = agentAnchorDirectory();
|
|
173
|
+
const sessionHash = crypto.createHash('sha256').update(String(sessionId)).digest('hex').slice(0, 12);
|
|
174
|
+
const nonce = crypto.randomUUID().replaceAll('-', '').slice(0, 12);
|
|
175
|
+
const socketPath = path.join(directory, `a-${sessionHash}-${nonce}.sock`);
|
|
176
|
+
if (Buffer.byteLength(socketPath) > 107)
|
|
177
|
+
throw new Error(`Agent anchor socket path is too long: ${socketPath}`);
|
|
178
|
+
return socketPath;
|
|
179
|
+
}
|
|
180
|
+
export function anchorId(connection) {
|
|
181
|
+
return path.basename(connection.socketPath, '.sock');
|
|
182
|
+
}
|
|
183
|
+
export async function launchAgentAnchor(sessionId, timeoutMs = 15_000) {
|
|
184
|
+
const directory = agentAnchorDirectory();
|
|
185
|
+
const logDirectory = path.join(directory, 'logs');
|
|
186
|
+
fs.mkdirSync(logDirectory, { recursive: true, mode: 0o700 });
|
|
187
|
+
const socketPath = socketPathFor(sessionId);
|
|
188
|
+
const logPath = path.join(logDirectory, `${path.basename(socketPath, '.sock')}.log`);
|
|
189
|
+
const logFd = fs.openSync(logPath, 'a', 0o600);
|
|
190
|
+
const entry = anchorEntryPoint();
|
|
191
|
+
const child = spawn(process.execPath, [entry, '--socket', socketPath], {
|
|
192
|
+
cwd: path.dirname(entry),
|
|
193
|
+
env: { ...process.env, TURNS_AGENT_ANCHOR_DIR: directory },
|
|
194
|
+
detached: true,
|
|
195
|
+
stdio: ['ignore', logFd, logFd],
|
|
196
|
+
});
|
|
197
|
+
fs.closeSync(logFd);
|
|
198
|
+
child.unref();
|
|
199
|
+
const deadline = Date.now() + timeoutMs;
|
|
200
|
+
let lastError;
|
|
201
|
+
while (Date.now() < deadline) {
|
|
202
|
+
if (child.exitCode !== null || child.signalCode !== null) {
|
|
203
|
+
throw new Error(`Agent anchor exited before opening its socket (${child.signalCode || child.exitCode}). See ${logPath}`);
|
|
204
|
+
}
|
|
205
|
+
const connection = new AgentAnchorConnection(socketPath);
|
|
206
|
+
try {
|
|
207
|
+
await connection.connect(Math.min(1_500, Math.max(100, deadline - Date.now())));
|
|
208
|
+
return connection;
|
|
209
|
+
}
|
|
210
|
+
catch (error) {
|
|
211
|
+
lastError = error;
|
|
212
|
+
connection.close();
|
|
213
|
+
await wait(100);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
try {
|
|
217
|
+
process.kill(child.pid, 'SIGTERM');
|
|
218
|
+
}
|
|
219
|
+
catch { }
|
|
220
|
+
throw lastError || new Error(`Timed out starting agent anchor. See ${logPath}`);
|
|
221
|
+
}
|
|
222
|
+
export async function connectExistingAgentAnchors() {
|
|
223
|
+
const directory = agentAnchorDirectory();
|
|
224
|
+
let paths = [];
|
|
225
|
+
try {
|
|
226
|
+
paths = fs
|
|
227
|
+
.readdirSync(directory, { withFileTypes: true })
|
|
228
|
+
.filter((entry) => entry.isSocket?.() || entry.name.endsWith('.sock'))
|
|
229
|
+
.map((entry) => path.join(directory, entry.name));
|
|
230
|
+
}
|
|
231
|
+
catch { }
|
|
232
|
+
const anchors = [];
|
|
233
|
+
await Promise.all(paths.map(async (socketPath) => {
|
|
234
|
+
const connection = new AgentAnchorConnection(socketPath);
|
|
235
|
+
try {
|
|
236
|
+
await connection.connect();
|
|
237
|
+
const snapshot = connection.snapshot || {};
|
|
238
|
+
if (snapshot.protocol !== 'turns.agent-anchor.v1' || !snapshot.sessionId || !snapshot.agentSessionId) {
|
|
239
|
+
connection.close();
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
anchors.push({ connection, snapshot });
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
connection.close();
|
|
246
|
+
try {
|
|
247
|
+
fs.unlinkSync(socketPath);
|
|
248
|
+
}
|
|
249
|
+
catch { }
|
|
250
|
+
}
|
|
251
|
+
}));
|
|
252
|
+
return anchors;
|
|
253
|
+
}
|
|
254
|
+
export function waitForAgentTurn(connection, messageId, timeoutMs = 12 * 60 * 60_000, snapshot = null, afterEventCursor = null) {
|
|
255
|
+
let unsubscribe;
|
|
256
|
+
let settled = false;
|
|
257
|
+
let resolveCompletion;
|
|
258
|
+
let rejectCompletion;
|
|
259
|
+
const resultFromSnapshot = (value) => {
|
|
260
|
+
const turn = value?.currentTurn;
|
|
261
|
+
if (turn?.messageId !== messageId || turn.status === 'running' || value?.status === 'running')
|
|
262
|
+
return null;
|
|
263
|
+
return (turn.result || {
|
|
264
|
+
exitCode: turn.status === 'completed' || turn.status === 'interrupted' ? 0 : 1,
|
|
265
|
+
signal: null,
|
|
266
|
+
timedOut: false,
|
|
267
|
+
error: turn.status === 'completed' || turn.status === 'interrupted'
|
|
268
|
+
? null
|
|
269
|
+
: { message: 'Anchored agent turn failed.' },
|
|
270
|
+
});
|
|
271
|
+
};
|
|
272
|
+
const settle = (result, error) => {
|
|
273
|
+
if (settled)
|
|
274
|
+
return;
|
|
275
|
+
settled = true;
|
|
276
|
+
if (error)
|
|
277
|
+
rejectCompletion(error);
|
|
278
|
+
else
|
|
279
|
+
resolveCompletion(result);
|
|
280
|
+
};
|
|
281
|
+
const consume = (message, cursor = null) => {
|
|
282
|
+
if (message.type === 'anchor_disconnect')
|
|
283
|
+
return settle(null, new Error(message.error || 'Agent anchor disconnected.'));
|
|
284
|
+
if (afterEventCursor !== null && cursor !== null && cursor <= afterEventCursor)
|
|
285
|
+
return;
|
|
286
|
+
if (String(message.messageId || '') !== String(messageId))
|
|
287
|
+
return;
|
|
288
|
+
if (message.type === 'turn_complete')
|
|
289
|
+
settle(message.result || { exitCode: 0, error: null });
|
|
290
|
+
if (message.type === 'turn_error')
|
|
291
|
+
settle(message.result || { exitCode: 1, error: { message: 'Agent turn failed.' } });
|
|
292
|
+
};
|
|
293
|
+
const completion = new Promise((resolve, reject) => {
|
|
294
|
+
resolveCompletion = resolve;
|
|
295
|
+
rejectCompletion = reject;
|
|
296
|
+
unsubscribe = connection.onEvent(consume);
|
|
297
|
+
if (afterEventCursor === null) {
|
|
298
|
+
for (const message of connection.recentEvents())
|
|
299
|
+
consume(message);
|
|
300
|
+
}
|
|
301
|
+
else {
|
|
302
|
+
for (const entry of connection.recentEventsAfter(afterEventCursor))
|
|
303
|
+
consume(entry.message, entry.cursor);
|
|
304
|
+
}
|
|
305
|
+
const initial = resultFromSnapshot(snapshot);
|
|
306
|
+
if (initial)
|
|
307
|
+
settle(initial);
|
|
308
|
+
if (!settled)
|
|
309
|
+
connection
|
|
310
|
+
.request('snapshot', {}, 5_000)
|
|
311
|
+
.then((latest) => {
|
|
312
|
+
const result = resultFromSnapshot(latest);
|
|
313
|
+
if (result)
|
|
314
|
+
settle(result);
|
|
315
|
+
})
|
|
316
|
+
.catch(() => { });
|
|
317
|
+
});
|
|
318
|
+
return withTimeout(completion, timeoutMs, 'Anchored agent turn').finally(() => unsubscribe?.());
|
|
319
|
+
}
|