@turns/dev 0.0.1 → 0.0.3

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 CHANGED
@@ -14,8 +14,9 @@ npm install --global @turns/dev
14
14
  ## Commands
15
15
 
16
16
  ```text
17
- turns upload sessions for the current directory once
18
- turns upload [directory] watch one directory
17
+ turns watch and stream sessions for the current directory
18
+ turns upload [directory] watch and stream sessions for one directory
19
+ turns --once [directory] upload that directory's sessions once and exit
19
20
  turns install install the all-directory user daemon
20
21
  turns status report daemon state
21
22
  turns uninstall remove the daemon
@@ -23,6 +24,20 @@ turns login replace saved credentials through the browser
23
24
  turns logout remove saved credentials
24
25
  ```
25
26
 
27
+ `turns` and `turns upload DIRECTORY` are the daemon scoped to one directory
28
+ tree: they keep watching, stream live rows as the harness writes them, and
29
+ serve prompts queued from the web until interrupted. That directory is the
30
+ allowlist, so sessions in its subdirectories are followed and agent launches
31
+ cannot start outside it. Run `turns install` instead for an unattended
32
+ all-directory daemon; while that daemon is active a foreground run declines to
33
+ start a second uploader, since both would write the same upload-cursor file.
34
+
35
+ `--once` sweeps the same directory tree and exits as soon as its sessions are
36
+ current, for scripts and CI steps that need an upload to finish rather than a
37
+ process to stay resident. A run narrowed to a directory — watching or `--once` —
38
+ never prunes saved upload cursors, so it cannot make the sessions it filtered
39
+ out replay from their first byte the next time the daemon starts.
40
+
26
41
  The server is selected with `--server URL` or `TURNS_ORIGIN`. Its normalized
27
42
  origin is saved in `~/.config/turns/credentials.json` and reused by later
28
43
  invocations and daemon starts. On Linux the other persistent files are:
@@ -42,7 +57,7 @@ Rows are scanned on their way out for substrings that look like file paths —
42
57
  filesystem confirms is recorded on the session's own log as a resolution: the
43
58
  substring, the absolute path, the size, and whether the contents were sent.
44
59
  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
60
+ before the `--directory` prefixes are enforced, so a symlink out of an
46
61
  allowed tree is not a way to read the disk.
47
62
 
48
63
  Only images under 10 MB are uploaded, to `PUT /api/file/{machine}/{path}`. Everything else is recorded as a name, so
@@ -59,16 +74,16 @@ straight to R2, so neither process retains the complete file in memory.
59
74
  The systemd unit uses `KillMode=process`: restarting the uploader leaves its
60
75
  detached agent-anchor processes running so in-flight harness turns continue.
61
76
  For a foreground daemon restricted to one or more directory trees, repeat
62
- `--allow-directory`. Both transcript discovery and agent launches enforce the
77
+ `--directory`. Both transcript discovery and agent launches enforce the
63
78
  canonical prefix, including protection against symlinks that escape it:
64
79
 
65
80
  ```sh
66
- turns daemon run --server https://turns.example --allow-directory /srv/agents
81
+ turns daemon run --server https://turns.example --directory /srv/agents
67
82
  ```
68
83
 
69
84
  Against local Wrangler development, use the configured `DEV_USER` directly:
70
85
 
71
86
  ```sh
72
87
  turns daemon run --server http://127.0.0.1:8787 \
73
- --dev-user antimatter15 --allow-directory /tmp/turns-driver-workspaces
88
+ --dev-user antimatter15 --directory /tmp/turns-driver-workspaces
74
89
  ```
@@ -682,8 +682,13 @@ class AcpTransport extends LineTransport {
682
682
  await this.request('session/set_mode', { sessionId: this.sessionId, modeId: this.profile.mode }, 30_000);
683
683
  }
684
684
  const model = this.anchor.model && this.anchor.model !== 'auto' ? this.anchor.model : this.profile.model;
685
- if (model)
686
- await this.request('session/set_model', { sessionId: this.sessionId, modelId: model }, 30_000).catch(() => { });
685
+ if (model) {
686
+ const method = this.profile.modelOption ? 'session/set_config_option' : 'session/set_model';
687
+ const params = this.profile.modelOption
688
+ ? { sessionId: this.sessionId, configId: 'model', value: model }
689
+ : { sessionId: this.sessionId, modelId: model };
690
+ await this.request(method, params, 30_000).catch(() => { });
691
+ }
687
692
  this.ready = true;
688
693
  }
689
694
  prompt(text, timeoutMs) {
@@ -1,3 +1,4 @@
1
+ import crypto from 'node:crypto';
1
2
  import fs from 'node:fs';
2
3
  import os from 'node:os';
3
4
  import path from 'node:path';
@@ -7,16 +8,14 @@ import { uploadFileBytes, uploadFileStream } from "./path-resolution.js";
7
8
  * Answering "what is in that directory?" from the machine that knows.
8
9
  *
9
10
  * A reader's browser cannot reach this machine, and this machine will not open
10
- * a port to let it try. So a browse is served the way an embedded image already
11
- * is: the daemon reads the path, puts what it found in the account's own
12
- * prefix, and the server hands the reader a signed URL for that object. Regular
13
- * files stream from disk to that object; nothing stays open once the one browse
14
- * upload finishes.
11
+ * a port to let it try. Directories are small structured answers, so the daemon
12
+ * sends their listing on the control socket immediately and stores the same
13
+ * bytes in the background for offline browsing. Regular files still stream to
14
+ * object storage; nothing stays open once the one browse upload finishes.
15
15
  *
16
- * A directory has no bytes, so what is uploaded for one is a listing of it,
17
- * stored at `_index.json` beneath the directory. Every path is canonicalised
18
- * before the allowlist is consulted, so a symlink pointing out of an allowed
19
- * tree is not a way to read the rest of the disk.
16
+ * A directory's fallback is stored at `_index.json` beneath it. Every path is
17
+ * canonicalised before the allowlist is consulted, so a symlink pointing out
18
+ * of an allowed tree is not a way to read the rest of the disk.
20
19
  */
21
20
  /** Kept in step with `MAX_DIRECTORY_ENTRIES` in the Worker's shared listing. */
22
21
  export const MAX_DIRECTORY_ENTRIES = 2_000;
@@ -26,62 +25,9 @@ export const DIRECTORY_INDEX_NAME = '_index.json';
26
25
  export const MAX_BROWSE_FILE_BYTES = 199 * 1024 * 1024;
27
26
  /** The derived label used in reader-facing errors. */
28
27
  export const BROWSE_FILE_LIMIT_LABEL = `${Math.round(MAX_BROWSE_FILE_BYTES / (1024 * 1024))} MB`;
29
- /** Extensions a reader can be shown as text, whatever produced them. */
30
- const TEXT_EXTENSIONS = new Set([
31
- '.bash',
32
- '.c',
33
- '.cc',
34
- '.cfg',
35
- '.conf',
36
- '.cpp',
37
- '.cs',
38
- '.css',
39
- '.csv',
40
- '.diff',
41
- '.env',
42
- '.go',
43
- '.gradle',
44
- '.h',
45
- '.hpp',
46
- '.htm',
47
- '.html',
48
- '.ini',
49
- '.java',
50
- '.jsonl',
51
- '.jsx',
52
- '.kt',
53
- '.less',
54
- '.lock',
55
- '.log',
56
- '.lua',
57
- '.m',
58
- '.md',
59
- '.mjs',
60
- '.cjs',
61
- '.mts',
62
- '.patch',
63
- '.php',
64
- '.pl',
65
- '.properties',
66
- '.py',
67
- '.r',
68
- '.rb',
69
- '.rs',
70
- '.scss',
71
- '.sh',
72
- '.sql',
73
- '.swift',
74
- '.tf',
75
- '.toml',
76
- '.ts',
77
- '.tsx',
78
- '.txt',
79
- '.vue',
80
- '.xml',
81
- '.yaml',
82
- '.yml',
83
- '.zsh',
84
- ]);
28
+ /** Enough data to distinguish source and prose from arbitrary binary bytes
29
+ * without reading a large artifact into the daemon's memory. */
30
+ export const FILE_TYPE_SAMPLE_BYTES = 64 * 1024;
85
31
  /** Extensions carrying their own type, where guessing text would be wrong. */
86
32
  const BINARY_CONTENT_TYPES = new Map([
87
33
  ['.apng', 'image/apng'],
@@ -104,23 +50,55 @@ const BINARY_CONTENT_TYPES = new Map([
104
50
  ['.zip', 'application/zip'],
105
51
  ]);
106
52
  /**
107
- * How one browsed file should be served back.
108
- *
109
- * A name with no extension at all — `README`, `Makefile`, `Dockerfile` — is
110
- * nearly always text, and is the one guess worth making. Everything unrecognised
111
- * is served as bytes: the Worker sends it with `nosniff` under a sandboxing
112
- * policy, so an unhelpful type is only ever unhelpful.
53
+ * Whether a bounded sample can safely be decoded in the browser's text editor.
54
+ * NUL and uncommon ASCII control bytes are strong binary signals; strict UTF-8
55
+ * keeps arbitrary binary from being rendered as replacement-character noise.
56
+ * An incomplete final code point is allowed only when the sample is a prefix.
113
57
  */
114
- export function browseContentType(file) {
58
+ export function fileSampleIsText(sample, complete) {
59
+ let controls = 0;
60
+ for (const byte of sample) {
61
+ if (byte === 0)
62
+ return false;
63
+ if (byte < 0x20 && byte !== 0x09 && byte !== 0x0a && byte !== 0x0c && byte !== 0x0d && byte !== 0x1b) {
64
+ controls += 1;
65
+ }
66
+ }
67
+ if (controls / Math.max(1, sample.byteLength) > 0.01)
68
+ return false;
69
+ try {
70
+ new TextDecoder('utf-8', { fatal: true }).decode(sample, { stream: !complete });
71
+ return true;
72
+ }
73
+ catch {
74
+ return false;
75
+ }
76
+ }
77
+ /** How one browsed file should be served back. Explicit media containers keep
78
+ * their MIME type; source, prose, and unknown extensions are decided by bytes.
79
+ * The extension only refines textual JSON after the sample has passed. */
80
+ export function browseContentType(file, sample, complete) {
115
81
  const extension = path.extname(file).toLowerCase();
116
82
  const known = BINARY_CONTENT_TYPES.get(extension);
117
83
  if (known)
118
84
  return known;
119
- if (extension === '.json')
120
- return 'application/json';
121
- if (!extension || TEXT_EXTENSIONS.has(extension))
122
- return 'text/plain; charset=utf-8';
123
- return 'application/octet-stream';
85
+ if (!fileSampleIsText(sample, complete))
86
+ return 'application/octet-stream';
87
+ return extension === '.json' ? 'application/json' : 'text/plain; charset=utf-8';
88
+ }
89
+ function readFileTypeSample(file, bytes) {
90
+ const length = Math.min(bytes, FILE_TYPE_SAMPLE_BYTES);
91
+ if (!length)
92
+ return new Uint8Array();
93
+ const sample = Buffer.allocUnsafe(length);
94
+ const handle = fs.openSync(file, 'r');
95
+ try {
96
+ const read = fs.readSync(handle, sample, 0, length, 0);
97
+ return sample.subarray(0, read);
98
+ }
99
+ finally {
100
+ fs.closeSync(handle);
101
+ }
124
102
  }
125
103
  function isoTime(value) {
126
104
  return Number.isFinite(value) ? new Date(value).toISOString() : '';
@@ -193,39 +171,42 @@ export function readDirectory(directory) {
193
171
  export function directoryIndexPath(directory) {
194
172
  return `${String(directory || '').replace(/\/+$/u, '')}/${DIRECTORY_INDEX_NAME}`;
195
173
  }
196
- /**
197
- * Store one path for a reader, and say what was stored.
198
- *
199
- * An empty file is a legitimate thing to open and has no bytes to upload, so it
200
- * is reported rather than sent — the reader is told the file is empty instead of
201
- * being handed a URL for an object that was never written. A file past the
202
- * upload ceiling is refused here, where the size is known, rather than by the
203
- * Worker after the bytes have crossed the network.
204
- */
205
174
  export async function browseMachinePath({ requested, machine, baseUrl, options, workingDirectory = '', fetchImpl = fetch, }) {
206
175
  const resolved = resolveBrowsePath(requested, {
207
176
  workingDirectory,
208
177
  allowedDirectories: options.allowedDirectories || [],
209
178
  });
210
179
  if (resolved.stat.isDirectory()) {
211
- const body = new TextEncoder().encode(JSON.stringify(readDirectory(resolved.path)));
180
+ const listing = readDirectory(resolved.path);
181
+ const body = new TextEncoder().encode(JSON.stringify(listing));
212
182
  if (body.byteLength > MAX_BROWSE_FILE_BYTES)
213
183
  throw new Error(`Directory listing is too large: ${resolved.path}`);
214
- await uploadFileBytes({
184
+ const etag = crypto.createHash('sha256').update(body).digest('hex');
185
+ const cache = uploadFileBytes({
215
186
  baseUrl,
216
187
  options,
217
188
  machine,
218
189
  absolutePath: directoryIndexPath(resolved.path),
219
190
  contentType: 'application/json',
191
+ contentEtag: etag,
220
192
  body,
221
193
  fetchImpl,
222
194
  });
223
- return { path: resolved.path, kind: 'directory', bytes: body.byteLength, contentType: 'application/json' };
195
+ return {
196
+ path: resolved.path,
197
+ kind: 'directory',
198
+ bytes: body.byteLength,
199
+ contentType: 'application/json',
200
+ listing,
201
+ etag,
202
+ cache,
203
+ };
224
204
  }
225
- const contentType = browseContentType(resolved.path);
226
205
  if (resolved.stat.size > MAX_BROWSE_FILE_BYTES) {
227
206
  throw new Error(`File is larger than the ${BROWSE_FILE_LIMIT_LABEL} browse limit.`);
228
207
  }
208
+ const sample = readFileTypeSample(resolved.path, resolved.stat.size);
209
+ const contentType = browseContentType(resolved.path, sample, sample.byteLength === resolved.stat.size);
229
210
  if (resolved.stat.size === 0)
230
211
  return { path: resolved.path, kind: 'file', bytes: 0, contentType };
231
212
  await uploadFileStream({
@@ -144,6 +144,17 @@ export const ACP_PROFILES = {
144
144
  mcp: true,
145
145
  },
146
146
  vibe: { command: 'vibe-acp', args: [], mcp: true },
147
+ fx: {
148
+ command: 'fx',
149
+ args: ['acp'],
150
+ // Fx reads the process-wide policy when the ACP server starts. Setting a
151
+ // session mode afterwards would replace `yolo` with code mode's `auto`, so
152
+ // bypass deliberately has no session/set_mode call.
153
+ env: (permissionMode) => ({ FX_PERMISSION_MODE: permissionMode === 'bypassPermissions' ? 'yolo' : 'ask' }),
154
+ mode: (permissionMode) => (permissionMode === 'bypassPermissions' ? undefined : 'ask'),
155
+ modelOption: true,
156
+ mcp: true,
157
+ },
147
158
  };
148
159
  // These harnesses keep one bidirectional process attached to the anchor. They
149
160
  // are listed separately from process harnesses so queued turns cannot
package/dist/index.js CHANGED
@@ -62,8 +62,9 @@ function help() {
62
62
  return `Turns ${pkg.version}
63
63
 
64
64
  Usage:
65
- turns Upload sessions for the current directory once
66
- turns upload [directory] Watch sessions belonging to one directory
65
+ turns Watch and stream sessions for the current directory
66
+ turns upload [directory] Watch and stream sessions for one directory
67
+ turns --once [directory] Upload the directory's sessions once and exit
67
68
  turns install Install and start the all-directory daemon
68
69
  turns status Show daemon status
69
70
  turns uninstall Stop and uninstall the background daemon
@@ -73,7 +74,8 @@ Usage:
73
74
 
74
75
  Options:
75
76
  --server URL Use a Turns server (saved with credentials)
76
- --allow-directory PATH Restrict daemon uploads/launches to this prefix (repeatable)
77
+ --once Upload once and exit instead of watching
78
+ --directory PATH Restrict daemon uploads/launches to this prefix (repeatable)
77
79
  --dev-user LOGIN Authenticate to a local Wrangler DEV_USER server
78
80
  --no-browser Print the authentication URL without opening it
79
81
 
@@ -86,6 +88,7 @@ function parseGlobalArguments(argv) {
86
88
  const remaining = [];
87
89
  let origin = process.env.TURNS_ORIGIN || process.env.TURNS_SERVER || '';
88
90
  let devUser = '';
91
+ let once = false;
89
92
  const allowedDirectories = [];
90
93
  for (let index = 0; index < argv.length; index += 1) {
91
94
  const argument = argv[index];
@@ -95,7 +98,7 @@ function parseGlobalArguments(argv) {
95
98
  throw new Error(`${argument} requires a URL.`);
96
99
  origin = value;
97
100
  }
98
- else if (argument === '--allow-directory') {
101
+ else if (argument === '--directory') {
99
102
  const value = argv[++index];
100
103
  if (!value)
101
104
  throw new Error(`${argument} requires a path.`);
@@ -107,6 +110,9 @@ function parseGlobalArguments(argv) {
107
110
  throw new Error(`${argument} requires a login.`);
108
111
  devUser = value.trim().toLowerCase();
109
112
  }
113
+ else if (argument === '--once') {
114
+ once = true;
115
+ }
110
116
  else if (argument === '--no-browser') {
111
117
  process.env.TURNS_NO_BROWSER = '1';
112
118
  }
@@ -114,7 +120,24 @@ function parseGlobalArguments(argv) {
114
120
  remaining.push(argument);
115
121
  }
116
122
  }
117
- return { argv: remaining, origin: origin || undefined, devUser, allowedDirectories };
123
+ return { argv: remaining, origin: origin || undefined, devUser, once, allowedDirectories };
124
+ }
125
+ /** A foreground run is the daemon scoped to one directory: it keeps watching,
126
+ * streams live rows, and serves queued prompts. The directory becomes the
127
+ * allowlist rather than an upload filter, so sessions in its subdirectories are
128
+ * followed too and launches arriving from the server cannot escape it. */
129
+ export function scopedWatchOptions(directory, { once = false } = {}) {
130
+ const resolved = path.resolve(directory || process.cwd());
131
+ let allowedDirectories;
132
+ try {
133
+ allowedDirectories = normalizeAllowedDirectories([resolved]);
134
+ }
135
+ catch {
136
+ throw new Error(`Not a directory: ${resolved}`);
137
+ }
138
+ // `--once` keeps the same tree, so a single sweep covers subdirectories the
139
+ // way the watch does; it only stops the process from staying resident.
140
+ return { directory: resolved, watch: !once, allowedDirectories };
118
141
  }
119
142
  function credentialProvider(initial, { paths, interactive }) {
120
143
  let credentials = initial;
@@ -166,14 +189,18 @@ function credentialProvider(initial, { paths, interactive }) {
166
189
  * @param {object} credentials
167
190
  * @param {object} [options]
168
191
  * @param {import('./auth.ts').AuthPaths} [options.paths]
169
- * @param {string} [options.directory]
170
192
  * @param {boolean} [options.watch]
171
193
  * @param {boolean} [options.all]
172
194
  * @param {boolean} [options.interactive]
173
195
  * @param {string} [options.devUser]
174
196
  * @param {string[]} [options.allowedDirectories]
175
197
  */
176
- async function runUpload(credentials, { paths, directory = '', watch = false, all = false, interactive = true, devUser = '', allowedDirectories = [], } = {}) {
198
+ async function runUpload(credentials, { paths, watch = false, all = false, interactive = true, devUser = '', allowedDirectories = [], } = {}) {
199
+ if (!all && !allowedDirectories.length) {
200
+ // Every caller either sweeps all directories or names the prefixes it may
201
+ // touch. An unscoped run would also accept launches anywhere on the machine.
202
+ throw new Error('runUpload requires either all directories or an explicit allowlist.');
203
+ }
177
204
  migrateLegacyUploaderState(paths);
178
205
  const provider = devUser ? null : credentialProvider(credentials, { paths, interactive });
179
206
  const { runUploader } = await import("./upload-sessions.js");
@@ -186,9 +213,9 @@ async function runUpload(credentials, { paths, directory = '', watch = false, al
186
213
  '--machine-id-file',
187
214
  paths.machineId,
188
215
  ...(devUser ? ['--dev-user', devUser] : []),
189
- ...allowedDirectories.flatMap((directory) => ['--allow-directory', directory]),
216
+ ...allowedDirectories.flatMap((directory) => ['--directory', directory]),
190
217
  ...(watch ? ['--watch'] : []),
191
- ...(all ? ['--all'] : allowedDirectories.length ? [] : ['--directory', path.resolve(directory || process.cwd())]),
218
+ ...(all ? ['--all'] : []),
192
219
  ];
193
220
  try {
194
221
  await runUploader(arguments_, { credentialProvider: provider });
@@ -242,6 +269,8 @@ async function main(rawArguments = process.argv.slice(2)) {
242
269
  if (argv[1] !== 'run')
243
270
  throw new Error(`Unknown daemon command: ${argv[1] || ''}\n\n${help()}`);
244
271
  const allowedDirectories = normalizeAllowedDirectories(parsed.allowedDirectories);
272
+ if (parsed.once)
273
+ throw new Error('--once cannot be combined with `turns daemon run`.');
245
274
  if (parsed.devUser && !parsed.origin)
246
275
  throw new Error('--dev-user requires an explicit --server URL.');
247
276
  let credentials;
@@ -266,24 +295,31 @@ async function main(rawArguments = process.argv.slice(2)) {
266
295
  return;
267
296
  }
268
297
  if (parsed.devUser || parsed.allowedDirectories.length) {
269
- throw new Error('--dev-user and --allow-directory are supported only by `turns daemon run`.');
298
+ throw new Error('--dev-user and --directory are supported only by `turns daemon run`.');
270
299
  }
271
300
  if (command && command !== 'upload')
272
301
  throw new Error(`Unknown command: ${command}\n\n${help()}`);
273
302
  const runningDaemon = daemonStatus();
303
+ // Including `--once`: the daemon holds the same upload-cursor file, and a
304
+ // second writer would persist its own load-time copy over the daemon's
305
+ // concurrent progress. The daemon already keeps this directory current.
274
306
  if (runningDaemon.active) {
275
307
  process.stdout.write('The Turns daemon is already syncing this directory as part of the all-directory watch.\n');
276
308
  return;
277
309
  }
278
310
  await migrateLegacyCredentials(paths, parsed.origin);
279
311
  const credentials = await authenticate({ origin: parsed.origin, paths });
280
- const selectedDirectory = path.resolve(command === 'upload' ? argv[1] || process.cwd() : process.cwd());
281
- process.stdout.write(`${command === 'upload' ? 'Watching' : 'Uploading'} sessions in ${selectedDirectory} as @${credentials.user.login}.\n`);
312
+ const selected = scopedWatchOptions(command === 'upload' ? argv[1] || process.cwd() : process.cwd(), {
313
+ once: parsed.once,
314
+ });
315
+ process.stdout.write(selected.watch
316
+ ? `Watching sessions in ${selected.directory} as @${credentials.user.login}. Press Ctrl-C to stop.\n`
317
+ : `Uploading sessions in ${selected.directory} as @${credentials.user.login}.\n`);
282
318
  await runUpload(credentials, {
283
319
  paths,
284
- directory: selectedDirectory,
285
- watch: command === 'upload',
320
+ watch: selected.watch,
286
321
  interactive: true,
322
+ allowedDirectories: selected.allowedDirectories,
287
323
  });
288
324
  }
289
325
  export { credentialProvider, help, main, parseGlobalArguments, runUpload };
@@ -87,6 +87,10 @@ const EXTRA_FILE_STORES = {
87
87
  root: path.join(HOME, '.local', 'state', 'poolside', 'trajectories'),
88
88
  include: (file) => /^trajectory-[^/]+\.ndjson$/i.test(path.basename(file)),
89
89
  },
90
+ fx: {
91
+ root: path.join(HOME, '.fx', 'sessions'),
92
+ include: (file) => path.basename(file) === 'events.jsonl',
93
+ },
90
94
  };
91
95
  function safeStat(file) {
92
96
  try {
@@ -248,6 +252,7 @@ function nativeSessionIdFromPath(harness, sourcePath) {
248
252
  qwen: /\/chats\/([^/]+)\.jsonl$/iu,
249
253
  cline: /\/([^/]+)\.messages\.json$/iu,
250
254
  poolside: /trajectory-(?:standalone_)?([0-9a-f-]{20,})\.ndjson$/iu,
255
+ fx: /\/sessions\/([^/]+)\/events\.jsonl$/iu,
251
256
  };
252
257
  return patterns[harness]?.exec(normalized)?.[1] || '';
253
258
  }
@@ -269,6 +274,28 @@ function fileDescriptor(harness, file) {
269
274
  createdAt: (stat.birthtimeMs > 0 ? stat.birthtime : stat.mtime).toISOString(),
270
275
  };
271
276
  }
277
+ /** Fx's ledger path identifies the session; its adjacent projections provide
278
+ * the workspace and display title without requiring a conversation scan. */
279
+ function fxDescriptor(file) {
280
+ const descriptor = fileDescriptor('fx', file);
281
+ if (!descriptor)
282
+ return null;
283
+ const directory = path.dirname(file);
284
+ const session = jsonFile(path.join(directory, 'session.json'));
285
+ const display = jsonFile(path.join(directory, 'display.json'));
286
+ const createdAtMs = Number(session?.created_at_ms);
287
+ const createdAt = Number.isFinite(createdAtMs) ? new Date(createdAtMs).toISOString() : descriptor.createdAt;
288
+ const title = typeof display?.title === 'string' ? display.title.trim() : '';
289
+ return {
290
+ ...descriptor,
291
+ createdAt,
292
+ nativeSessionId: String(session?.id || path.basename(directory)).trim(),
293
+ ...(absolutePathCandidate([session?.workspace_root, session?.origin_workspace_root])
294
+ ? { workingDirectory: absolutePathCandidate([session?.workspace_root, session?.origin_workspace_root]) }
295
+ : {}),
296
+ ...(title ? { nativeTitle: title } : {}),
297
+ };
298
+ }
272
299
  /** Discover top-level and subagent DeepSeek trajectories with header metadata. */
273
300
  export function deepseekTrajectoryDescriptors(root = DEEPSEEK_SESSIONS_ROOT) {
274
301
  const descriptors = [];
@@ -1070,6 +1097,10 @@ export function nativeDescriptorWorkingDirectory(descriptor) {
1070
1097
  cursor: cursorMetadataWorkingDirectory,
1071
1098
  gemini: geminiMetadataWorkingDirectory,
1072
1099
  kimi: kimiMetadataWorkingDirectory,
1100
+ fx: (candidate) => absolutePathCandidate([
1101
+ sidecarJson(candidate, 'session.json')?.workspace_root,
1102
+ sidecarJson(candidate, 'session.json')?.origin_workspace_root,
1103
+ ]),
1073
1104
  };
1074
1105
  const metadataCandidate = metadataResolvers[descriptor.harness]?.(descriptor) || '';
1075
1106
  if (metadataCandidate)
@@ -1287,6 +1318,7 @@ const HARNESS_PROCESS_NAMES = {
1287
1318
  prime: ['prime', 'prime-agent'],
1288
1319
  qwen: ['qwen'],
1289
1320
  vibe: ['vibe', 'vibe-acp'],
1321
+ fx: ['fx'],
1290
1322
  };
1291
1323
  const SCRIPT_RUNTIMES = new Set(['node', 'nodejs', 'bun', 'deno', 'python', 'python3']);
1292
1324
  function executableStem(value) {
@@ -1392,7 +1424,7 @@ function discoverLocalSessionInventory(only = '') {
1392
1424
  walk(store.root, (file) => {
1393
1425
  if (!store.include(file))
1394
1426
  return;
1395
- const descriptor = fileDescriptor(harness, file);
1427
+ const descriptor = harness === 'fx' ? fxDescriptor(file) : fileDescriptor(harness, file);
1396
1428
  if (descriptor)
1397
1429
  descriptors.push(descriptor);
1398
1430
  });
@@ -173,7 +173,7 @@ function uploadHeaders(options) {
173
173
  * addressed by machine and path alone — the session that happened to prompt the
174
174
  * upload is not part of what was stored, and nothing records it.
175
175
  */
176
- export async function uploadFileBytes({ baseUrl, options, machine, absolutePath, contentType, body, fetchImpl = fetch, }) {
176
+ export async function uploadFileBytes({ baseUrl, options, machine, absolutePath, contentType, contentEtag, body, fetchImpl = fetch, }) {
177
177
  const url = new URL(`/api/file/${encodeURIComponent(machine)}/${encodeURIComponent(absolutePath)}`, String(baseUrl));
178
178
  const response = await fetchImpl(url, {
179
179
  method: 'PUT',
@@ -181,6 +181,7 @@ export async function uploadFileBytes({ baseUrl, options, machine, absolutePath,
181
181
  ...uploadHeaders(options),
182
182
  'content-type': contentType,
183
183
  'content-length': String(body.byteLength),
184
+ ...(contentEtag ? { 'x-turns-content-etag': contentEtag } : {}),
184
185
  },
185
186
  body,
186
187
  });
@@ -952,22 +952,24 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
952
952
  return recorded || path.dirname(descriptor.file);
953
953
  };
954
954
  /**
955
- * Store one path this machine holds, so a reader can open it.
955
+ * Read one path this machine holds, so a reader can open it.
956
956
  *
957
957
  * Unlike the rest of these handlers this touches no harness: browsing is a
958
958
  * property of the machine, so it answers for a stopped session as readily as
959
- * for a running one.
959
+ * for a running one. Directory bytes answer directly; their offline copy is
960
+ * stored behind that reply.
960
961
  */
961
962
  const handleFileBrowse = async (message, replyRpc) => {
962
963
  const sessionId = String(message.sessionId || '');
963
964
  try {
964
- const stored = await browseMachinePath({
965
+ const browsed = await browseMachinePath({
965
966
  requested: String(message.path || ''),
966
967
  machine,
967
968
  baseUrl,
968
969
  options,
969
970
  workingDirectory: await browseWorkingDirectory(sessionId),
970
971
  });
972
+ const { cache, ...stored } = browsed.kind === 'directory' ? browsed : { ...browsed, cache: undefined };
971
973
  replyRpc.notify({
972
974
  type: 'session.file.browse.result',
973
975
  requestId: message.requestId,
@@ -975,6 +977,11 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
975
977
  ok: true,
976
978
  ...stored,
977
979
  });
980
+ // The direct listing is already useful. Its R2 copy is only the fallback
981
+ // for a later offline browse, so storage latency must not hold this reply.
982
+ void cache?.catch((error) => {
983
+ console.warn(`Directory browse fallback upload failed: ${redactor.redactText(error?.message || String(error))}`);
984
+ });
978
985
  }
979
986
  catch (error) {
980
987
  replyRpc.notify({
@@ -53,6 +53,7 @@ const TIME_FIELDS = [
53
53
  'time_created',
54
54
  'recorded_at',
55
55
  'occurred_at_ms',
56
+ 'timestamp_ms',
56
57
  ];
57
58
  const TIME_CONTAINERS = ['data', 'payload', 'message', 'chat_message', 'node'];
58
59
  function epochToIso(value) {
@@ -149,7 +150,7 @@ function argumentsFrom(argv, { allowMissingToken = false } = {}) {
149
150
  rewriteSession: '',
150
151
  nativeStateFile: process.env.TURNS_NATIVE_STATE_FILE || path.join(HERE, '.native-upload-state.json'),
151
152
  machineIdFile: process.env.TURNS_MACHINE_ID_FILE || path.join(HERE, '.machine-id'),
152
- directory: '',
153
+ onlyDirectory: '',
153
154
  all: false,
154
155
  devUser: '',
155
156
  allowedDirectories: [],
@@ -180,13 +181,13 @@ function argumentsFrom(argv, { allowMissingToken = false } = {}) {
180
181
  options.nativeStateFile = path.resolve(next());
181
182
  else if (argument === '--machine-id-file')
182
183
  options.machineIdFile = path.resolve(next());
183
- else if (argument === '--directory')
184
- options.directory = path.resolve(next());
184
+ else if (argument === '--only-directory')
185
+ options.onlyDirectory = path.resolve(next());
185
186
  else if (argument === '--all')
186
187
  options.all = true;
187
188
  else if (argument === '--dev-user')
188
189
  options.devUser = next().trim().toLowerCase();
189
- else if (argument === '--allow-directory')
190
+ else if (argument === '--directory')
190
191
  options.allowedDirectories.push(next());
191
192
  else if (argument === '--watch')
192
193
  options.watch = true;
@@ -198,9 +199,9 @@ function argumentsFrom(argv, { allowMissingToken = false } = {}) {
198
199
  ` --dry-run inventory native sessions without uploading\n` +
199
200
  ` --runs PATH capture root (default: ${DEFAULT_RUNS})\n` +
200
201
  ` --only TEXT upload paths containing TEXT\n` +
201
- ` --directory PATH upload sessions whose working directory is PATH\n` +
202
+ ` --directory PATH upload/launch only inside directory prefix PATH (repeatable)\n` +
203
+ ` --only-directory PATH upload only sessions whose working directory is exactly PATH\n` +
202
204
  ` --all upload sessions across all working directories\n` +
203
- ` --allow-directory P upload/launch only inside directory prefix P (repeatable)\n` +
204
205
  ` --dev-user LOGIN authenticate as a local Wrangler DEV_USER uploader\n` +
205
206
  ` --url WS_URL websocket endpoint\n` +
206
207
  ` --token TOKEN internal/test bearer token override\n` +
@@ -214,10 +215,10 @@ function argumentsFrom(argv, { allowMissingToken = false } = {}) {
214
215
  if (options.rewriteSession && !options.native) {
215
216
  throw new Error('--rewrite-session requires --native.');
216
217
  }
217
- if (options.directory && options.all)
218
- throw new Error('--directory cannot be combined with --all.');
219
- if (options.allowedDirectories.length && (options.directory || options.all)) {
220
- throw new Error('--allow-directory cannot be combined with --directory or --all.');
218
+ if (options.onlyDirectory && options.all)
219
+ throw new Error('--only-directory cannot be combined with --all.');
220
+ if (options.allowedDirectories.length && (options.onlyDirectory || options.all)) {
221
+ throw new Error('--directory cannot be combined with --only-directory or --all.');
221
222
  }
222
223
  options.allowedDirectories = normalizeAllowedDirectories(options.allowedDirectories);
223
224
  if (!options.dryRun && !options.token && !options.devUser && !allowMissingToken) {
@@ -845,9 +846,11 @@ async function uploadFile(rpc, machine, root, capture, redactor = DEFAULT_REDACT
845
846
  if (opened.type === 'session_tombstoned')
846
847
  return { rows: 0, sent: 0, skipped: true, tombstoned: true };
847
848
  let nextRow = Number(opened.nextRow) || 0;
849
+ let initialUpload = nextRow === 0;
848
850
  if (Number(opened.redactionVersion || 0) !== REDACTION_VERSION) {
849
851
  await rpc.request({ type: 'session.reset', sessionId, redactionVersion: REDACTION_VERSION });
850
852
  nextRow = 0;
853
+ initialUpload = true;
851
854
  }
852
855
  if (nextRow > rows.length)
853
856
  throw new Error(`${capture.relative}: remote has ${nextRow} rows, local file has ${rows.length}`);
@@ -857,7 +860,12 @@ async function uploadFile(rpc, machine, root, capture, redactor = DEFAULT_REDACT
857
860
  for (const batch of batches(rows, raw, nextRow, redactor)) {
858
861
  let acknowledgement;
859
862
  try {
860
- acknowledgement = await rpc.request({ type: 'ingest', sessionId, ...batch });
863
+ acknowledgement = await rpc.request({
864
+ type: 'ingest',
865
+ sessionId,
866
+ ...batch,
867
+ ...(initialUpload ? { deferReduction: true } : {}),
868
+ });
861
869
  }
862
870
  catch (error) {
863
871
  const offset = /^ROW_OFFSET:(\d+)$/u.exec(String(error.code || ''));
@@ -946,12 +954,16 @@ export async function uploadNativeSession(rpc, machine, descriptor, cursor = {},
946
954
  }
947
955
  const uploadSessionId = String(opened.sessionId || sessionId);
948
956
  let nextRow = Number(opened.nextRow) || 0;
957
+ let initialUpload = nextRow === 0 || cursor.initialUpload === true;
949
958
  if (Number(opened.redactionVersion || 0) !== REDACTION_VERSION) {
950
959
  await rpc.request({ type: 'session.reset', sessionId: uploadSessionId, redactionVersion: REDACTION_VERSION });
951
960
  nextRow = 0;
952
961
  for (const key of Object.keys(cursor))
953
962
  delete cursor[key];
963
+ initialUpload = true;
954
964
  }
965
+ if (initialUpload)
966
+ cursor.initialUpload = true;
955
967
  const resumable = canResumeNativeCursor(descriptor, cursor, nextRow);
956
968
  const scanCursor = resumable
957
969
  ? { offset: Number(cursor.offset), rowCount: Number(cursor.rowCount), lineNumber: Number(cursor.lineNumber) || 0 }
@@ -981,6 +993,7 @@ export async function uploadNativeSession(rpc, machine, descriptor, cursor = {},
981
993
  sessionId: uploadSessionId,
982
994
  startRow: rows[0].index,
983
995
  rows,
996
+ ...(initialUpload ? { deferReduction: true } : {}),
984
997
  });
985
998
  nextRow = Number(acknowledgement.nextRow);
986
999
  sent += rows.length;
@@ -1072,8 +1085,10 @@ export async function uploadNativeSession(rpc, machine, descriptor, cursor = {},
1072
1085
  });
1073
1086
  }
1074
1087
  if (!sawRows && scanCursor.rowCount === 0) {
1088
+ delete cursor.initialUpload;
1075
1089
  return { rows: 0, sent: 0, skipped: true, empty: true };
1076
1090
  }
1091
+ delete cursor.initialUpload;
1077
1092
  return { rows: scanCursor.rowCount, sent, skipped: sent === 0 };
1078
1093
  }
1079
1094
  export function nativeFailureRetryDelay(attempts) {
@@ -1245,7 +1260,7 @@ export function watchNativeSessionDirectories({ roots, shallowRoots = [], files
1245
1260
  };
1246
1261
  }
1247
1262
  const nativeWorkingDirectoryCache = new Map();
1248
- export async function filterNativeSessionsByDirectory(sessions, directory, resolveWorkingDirectory = nativeDescriptorWorkingDirectory) {
1263
+ export async function filterNativeSessionsByOnlyDirectory(sessions, directory, resolveWorkingDirectory = nativeDescriptorWorkingDirectory) {
1249
1264
  if (!directory)
1250
1265
  return sessions;
1251
1266
  const selected = path.resolve(directory);
@@ -1263,7 +1278,7 @@ export async function filterNativeSessionsByDirectory(sessions, directory, resol
1263
1278
  }
1264
1279
  return scoped;
1265
1280
  }
1266
- export async function filterNativeSessionsByAllowedDirectories(sessions, allowedDirectories, resolveWorkingDirectory = nativeDescriptorWorkingDirectory) {
1281
+ export async function filterNativeSessionsByDirectories(sessions, allowedDirectories, resolveWorkingDirectory = nativeDescriptorWorkingDirectory) {
1267
1282
  if (!allowedDirectories?.length)
1268
1283
  return sessions;
1269
1284
  const scoped = [];
@@ -1280,10 +1295,18 @@ export async function filterNativeSessionsByAllowedDirectories(sessions, allowed
1280
1295
  }
1281
1296
  return scoped;
1282
1297
  }
1298
+ /** Whether this run's inventory speaks for the whole machine. A run narrowed by
1299
+ * `--only`, `--rewrite-session`, `--only-directory`, or `--directory` sees a
1300
+ * subset, so it must not prune upload cursors for the sessions it filtered out:
1301
+ * the next unscoped run would find no cursor and replay those transcripts from
1302
+ * their first byte. */
1303
+ export function inventoryIsAuthoritative(options) {
1304
+ return !(options.only || options.rewriteSession || options.onlyDirectory || options.allowedDirectories?.length);
1305
+ }
1283
1306
  async function nativeInventory(options, machine) {
1284
1307
  let sessions = linkNativeSessionParents(machine, discoverLocalSessions(options.rewriteSession ? '' : options.only));
1285
- sessions = await filterNativeSessionsByDirectory(sessions, options.directory);
1286
- sessions = await filterNativeSessionsByAllowedDirectories(sessions, options.allowedDirectories);
1308
+ sessions = await filterNativeSessionsByOnlyDirectory(sessions, options.onlyDirectory);
1309
+ sessions = await filterNativeSessionsByDirectories(sessions, options.allowedDirectories);
1287
1310
  if (!options.rewriteSession)
1288
1311
  return sessions;
1289
1312
  const exact = sessions.filter((descriptor) => stableNativeSessionId(machine, descriptor.harness, descriptor.sourcePath) === options.rewriteSession);
@@ -1399,6 +1422,7 @@ async function uploadNative(options, machine, redactor = DEFAULT_REDACTOR) {
1399
1422
  const livePriority = new Set();
1400
1423
  const tombstoned = new Set();
1401
1424
  let nativeMonitor = null;
1425
+ const authoritativeInventory = inventoryIsAuthoritative(options);
1402
1426
  const rememberInventory = (sessions, retainState = false, forceMetadataDirectories = new Set()) => {
1403
1427
  const previousFingerprints = new Map(fingerprints);
1404
1428
  descriptorBySession.clear();
@@ -1459,7 +1483,7 @@ async function uploadNative(options, machine, redactor = DEFAULT_REDACTOR) {
1459
1483
  redactionStarted.add(sessionId);
1460
1484
  }
1461
1485
  }
1462
- if (retainState && !options.only && !options.rewriteSession)
1486
+ if (retainState && authoritativeInventory)
1463
1487
  state.retain(descriptorBySession.keys());
1464
1488
  descriptorRegistry.sessions = sessions;
1465
1489
  nativeMonitor?.addFiles(sessions.map((descriptor) => descriptor.file));
@@ -1725,7 +1749,7 @@ async function uploadNative(options, machine, redactor = DEFAULT_REDACTOR) {
1725
1749
  rememberInventory(sessions);
1726
1750
  const unsettled = changedSessions();
1727
1751
  if (unsettled.length) {
1728
- process.stderr.write(`${unsettled.length} native sessions kept changing; use --watch to follow them continuously.\n`);
1752
+ process.stderr.write(`${unsettled.length} native sessions kept changing; run \`turns\` without --once to follow them continuously.\n`);
1729
1753
  }
1730
1754
  break;
1731
1755
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@turns/dev",
3
- "version": "0.0.1",
3
+ "version": "0.0.3",
4
4
  "description": "Upload local AI agent sessions to turns.dev",
5
5
  "license": "MIT",
6
6
  "type": "module",