@turns/dev 0.0.10 → 0.0.11

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
@@ -73,10 +73,17 @@ bounds a deliberately broad matcher: prose that merely looks like a path
73
73
  resolves to nothing and is never mentioned to the server, and no directory is
74
74
  ever scanned.
75
75
 
76
- An explicit file browse may upload up to 199 MB to the same private R2 prefix.
76
+ An explicit file browse may upload up to 2 GiB to the same private R2 prefix.
77
+ Files over 100 MiB use retryable 32 MiB multipart requests and report upload
78
+ progress to the requesting browser over the control WebSocket.
77
79
  The daemon streams the file from disk and the Worker hands that request stream
78
80
  straight to R2, so neither process retains the complete file in memory.
79
81
 
82
+ Range-based viewers can bypass that upload. A capable daemon advertises live
83
+ file ranges, validates the path through the same allowlist, and streams a
84
+ bounded window as ordered 32 KiB WebSocket chunks. The browser hex view pages
85
+ 64 KiB viewport windows, including arbitrary offset jumps.
86
+
80
87
  The systemd unit uses `KillMode=process`, and the macOS LaunchAgent uses
81
88
  `AbandonProcessGroup`: restarting the uploader leaves its detached agent-anchor
82
89
  processes running so in-flight harness turns continue.
@@ -17,10 +17,19 @@ import { uploadFileStream } from "./path-resolution.js";
17
17
  */
18
18
  /** Kept in step with `MAX_DIRECTORY_ENTRIES` in the Worker's shared listing. */
19
19
  export const MAX_DIRECTORY_ENTRIES = 2_000;
20
- /** One MB below the Worker request-body ceiling, matching the R2 endpoint. */
21
- export const MAX_BROWSE_FILE_BYTES = 199 * 1024 * 1024;
20
+ /** Kept in step with `MAX_FILE_BYTES` on the server. Multipart transport keeps
21
+ * each individual Worker request below the platform request-body ceiling. */
22
+ export const MAX_BROWSE_FILE_BYTES = 2 * 1024 * 1024 * 1024;
22
23
  /** The derived label used in reader-facing errors. */
23
- export const BROWSE_FILE_LIMIT_LABEL = `${Math.round(MAX_BROWSE_FILE_BYTES / (1024 * 1024))} MB`;
24
+ export const BROWSE_FILE_LIMIT_LABEL = `${MAX_BROWSE_FILE_BYTES / (1024 * 1024 * 1024)} GB`;
25
+ /** Frequent filesystem chunks are useful for accurate accounting but noisy on
26
+ * a control socket. Four frames per second plus phase boundaries stays smooth. */
27
+ export const BROWSE_PROGRESS_INTERVAL_MS = 250;
28
+ /** One browser range request is deliberately small enough to remain cheap to
29
+ * retry or abandon when a reader scrolls quickly through a large file. */
30
+ export const MAX_BROWSE_RANGE_BYTES = 256 * 1024;
31
+ /** Raw bytes per WebSocket payload. Base64 expands this to roughly 43 KiB. */
32
+ export const BROWSE_RANGE_CHUNK_BYTES = 32 * 1024;
24
33
  /** Enough data to distinguish source and prose from arbitrary binary bytes
25
34
  * without reading a large artifact into the daemon's memory. */
26
35
  export const FILE_TYPE_SAMPLE_BYTES = 64 * 1024;
@@ -173,7 +182,50 @@ export function readDirectory(directory) {
173
182
  // clock inside the body would make every read produce different bytes.
174
183
  return { path: directory, entries, truncated: names.length > MAX_DIRECTORY_ENTRIES };
175
184
  }
176
- export async function browseMachinePath({ requested, machine, baseUrl, options, workingDirectory = '', contentEtag = '', fetchImpl = fetch, }) {
185
+ /** Stream one bounded file window without creating or updating its R2 object. */
186
+ export async function streamMachineFileRange({ requested, offset, length, workingDirectory = '', allowedDirectories = [], onReady, onChunk, }) {
187
+ if (!Number.isSafeInteger(offset) || offset < 0)
188
+ throw new Error('Invalid file range offset.');
189
+ if (!Number.isSafeInteger(length) || length <= 0 || length > MAX_BROWSE_RANGE_BYTES) {
190
+ throw new Error('Invalid file range length.');
191
+ }
192
+ const resolved = resolveBrowsePath(requested, { workingDirectory, allowedDirectories });
193
+ if (!resolved.stat.isFile())
194
+ throw new Error(`Not a readable file: ${resolved.path}`);
195
+ if (resolved.stat.size > MAX_BROWSE_FILE_BYTES) {
196
+ throw new Error(`File is larger than the ${BROWSE_FILE_LIMIT_LABEL} browse limit.`);
197
+ }
198
+ if (offset > resolved.stat.size)
199
+ throw new Error('File range starts beyond the end of the file.');
200
+ const boundedLength = Math.min(length, resolved.stat.size - offset);
201
+ const sample = readFileTypeSample(resolved.path, resolved.stat.size);
202
+ const range = {
203
+ path: resolved.path,
204
+ offset,
205
+ length: boundedLength,
206
+ totalBytes: resolved.stat.size,
207
+ contentType: browseContentType(resolved.path, sample, sample.byteLength === resolved.stat.size),
208
+ modifiedAt: isoTime(resolved.stat.mtimeMs),
209
+ };
210
+ onReady(range);
211
+ if (!boundedLength)
212
+ return range;
213
+ const stream = fs.createReadStream(resolved.path, {
214
+ start: offset,
215
+ end: offset + boundedLength - 1,
216
+ highWaterMark: BROWSE_RANGE_CHUNK_BYTES,
217
+ });
218
+ let chunkOffset = offset;
219
+ for await (const value of stream) {
220
+ const chunk = value;
221
+ onChunk(chunkOffset, chunk);
222
+ chunkOffset += chunk.byteLength;
223
+ }
224
+ if (chunkOffset !== offset + boundedLength)
225
+ throw new Error('The file changed while its byte range was being read.');
226
+ return range;
227
+ }
228
+ export async function browseMachinePath({ requested, machine, baseUrl, options, workingDirectory = '', contentEtag = '', onUploadProgress, fetchImpl = fetch, }) {
177
229
  const resolved = resolveBrowsePath(requested, {
178
230
  workingDirectory,
179
231
  allowedDirectories: options.allowedDirectories || [],
@@ -201,6 +253,20 @@ export async function browseMachinePath({ requested, machine, baseUrl, options,
201
253
  const modifiedAt = isoTime(resolved.stat.mtimeMs);
202
254
  if (resolved.stat.size === 0)
203
255
  return { path: resolved.path, kind: 'file', bytes: 0, contentType, modifiedAt };
256
+ let lastProgressAt = 0;
257
+ let lastUploadedBytes = -1;
258
+ const reportProgress = (phase, uploadedBytes, force = false) => {
259
+ if (!onUploadProgress)
260
+ return;
261
+ const bounded = Math.max(lastUploadedBytes, Math.min(resolved.stat.size, Math.max(0, uploadedBytes)));
262
+ const now = Date.now();
263
+ if (!force && now - lastProgressAt < BROWSE_PROGRESS_INTERVAL_MS && bounded < resolved.stat.size)
264
+ return;
265
+ lastProgressAt = now;
266
+ lastUploadedBytes = bounded;
267
+ onUploadProgress({ path: resolved.path, phase, uploadedBytes: bounded, totalBytes: resolved.stat.size });
268
+ };
269
+ reportProgress('starting', 0, true);
204
270
  await uploadFileStream({
205
271
  baseUrl,
206
272
  options,
@@ -210,6 +276,8 @@ export async function browseMachinePath({ requested, machine, baseUrl, options,
210
276
  bytes: resolved.stat.size,
211
277
  modifiedAt,
212
278
  ...(contentEtag ? { contentEtag } : {}),
279
+ onProgress: (uploadedBytes) => reportProgress('uploading', uploadedBytes),
280
+ onFinalizing: () => reportProgress('finalizing', resolved.stat.size, true),
213
281
  fetchImpl,
214
282
  });
215
283
  return { path: resolved.path, kind: 'file', bytes: resolved.stat.size, contentType, modifiedAt };
@@ -1,6 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
+ import { Transform } from 'node:stream';
4
5
  /**
5
6
  * Turning the paths an agent writes into things a reader can open.
6
7
  *
@@ -180,6 +181,178 @@ function fileUploadUrl(baseUrl, machine, absolutePath) {
180
181
  : encoded;
181
182
  return new URL(`/api/file/${encodeURIComponent(machine)}/${encodedPath}`, String(baseUrl));
182
183
  }
184
+ /** Kept in step with the explicit-browse transport constants in shared/files.ts. */
185
+ export const MAX_SINGLE_BROWSE_UPLOAD_BYTES = 100 * 1024 * 1024;
186
+ export const BROWSE_MULTIPART_PART_BYTES = 32 * 1024 * 1024;
187
+ export const MAX_BROWSE_MULTIPART_PARTS = 64;
188
+ export function filePartRanges(bytes, partSize = BROWSE_MULTIPART_PART_BYTES) {
189
+ if (!Number.isSafeInteger(bytes) || bytes <= 0 || !Number.isSafeInteger(partSize) || partSize <= 0)
190
+ return [];
191
+ const ranges = [];
192
+ for (let start = 0, partNumber = 1; start < bytes; start += partSize, partNumber += 1) {
193
+ ranges.push({ partNumber, start, end: Math.min(bytes, start + partSize) });
194
+ }
195
+ return ranges;
196
+ }
197
+ function fileMultipartUrl(baseUrl, machine, absolutePath, suffix = '') {
198
+ const url = new URL(`/api/file-multipart/${encodeURIComponent(machine)}${suffix}`, String(baseUrl));
199
+ url.searchParams.set('path', absolutePath);
200
+ return url;
201
+ }
202
+ function progressStream(absolutePath, start, end, onProgress) {
203
+ const source = fs.createReadStream(absolutePath, { start, end: end - 1 });
204
+ let loaded = 0;
205
+ const body = new Transform({
206
+ transform(chunk, _encoding, callback) {
207
+ loaded += chunk.byteLength;
208
+ onProgress?.(loaded);
209
+ callback(null, chunk);
210
+ },
211
+ });
212
+ source.pipe(body);
213
+ return {
214
+ body,
215
+ destroy() {
216
+ source.destroy();
217
+ body.destroy();
218
+ },
219
+ };
220
+ }
221
+ async function uploadFailure(response, label) {
222
+ const detail = (await response.text().catch(() => '')).trim();
223
+ return new Error(`${label} (${response.status})${detail ? `: ${detail.slice(0, 500)}` : '.'}`);
224
+ }
225
+ function retryDelay(milliseconds) {
226
+ return new Promise((resolve) => setTimeout(resolve, milliseconds));
227
+ }
228
+ async function uploadMultipartFile({ baseUrl, options, machine, absolutePath, contentType, bytes, modifiedAt, contentEtag, onProgress, onFinalizing, fetchImpl, }) {
229
+ const createUrl = fileMultipartUrl(baseUrl, machine, absolutePath);
230
+ const createBody = JSON.stringify({
231
+ size: bytes,
232
+ contentType,
233
+ ...(modifiedAt ? { modifiedAt } : {}),
234
+ ...(contentEtag ? { contentEtag } : {}),
235
+ });
236
+ const created = await fetchImpl(createUrl, {
237
+ method: 'POST',
238
+ headers: {
239
+ ...uploadHeaders(options),
240
+ 'content-type': 'application/json',
241
+ 'content-length': String(Buffer.byteLength(createBody)),
242
+ },
243
+ body: createBody,
244
+ });
245
+ if (!created.ok)
246
+ throw await uploadFailure(created, 'Multipart file upload could not start');
247
+ const payload = (await created.json().catch(() => ({})));
248
+ const uploadId = String(payload.upload?.uploadId || '');
249
+ const partSize = Number(payload.upload?.partSize);
250
+ const receiptToken = String(payload.upload?.receiptToken || '');
251
+ if (!uploadId ||
252
+ !receiptToken ||
253
+ !Number.isSafeInteger(partSize) ||
254
+ partSize <= 0 ||
255
+ partSize > BROWSE_MULTIPART_PART_BYTES) {
256
+ throw new Error('Multipart file upload returned an invalid session.');
257
+ }
258
+ const ranges = filePartRanges(bytes, partSize);
259
+ if (!ranges.length || ranges.length > MAX_BROWSE_MULTIPART_PARTS) {
260
+ throw new Error('Multipart file upload returned an invalid part size.');
261
+ }
262
+ const parts = [];
263
+ let completed = 0;
264
+ try {
265
+ for (const range of ranges) {
266
+ const length = range.end - range.start;
267
+ let receipt = null;
268
+ let greatestLoaded = 0;
269
+ let lastError = null;
270
+ for (let attempt = 0; attempt < 3 && !receipt; attempt += 1) {
271
+ const stream = progressStream(absolutePath, range.start, range.end, (loaded) => {
272
+ greatestLoaded = Math.max(greatestLoaded, loaded);
273
+ onProgress?.(Math.min(bytes, completed + greatestLoaded));
274
+ });
275
+ try {
276
+ const partUrl = fileMultipartUrl(baseUrl, machine, absolutePath, `/parts/${range.partNumber}`);
277
+ partUrl.searchParams.set('upload_id', uploadId);
278
+ const response = await fetchImpl(partUrl, {
279
+ method: 'PUT',
280
+ headers: {
281
+ ...uploadHeaders(options),
282
+ 'content-type': 'application/octet-stream',
283
+ 'content-length': String(length),
284
+ },
285
+ body: stream.body,
286
+ duplex: 'half',
287
+ });
288
+ if (!response.ok)
289
+ throw await uploadFailure(response, `Multipart file part ${range.partNumber} failed`);
290
+ const answer = (await response.json().catch(() => ({})));
291
+ if (answer.part?.partNumber !== range.partNumber || !answer.part.etag) {
292
+ throw new Error(`Multipart file part ${range.partNumber} returned no receipt.`);
293
+ }
294
+ receipt = { partNumber: range.partNumber, etag: String(answer.part.etag) };
295
+ }
296
+ catch (error) {
297
+ lastError = error;
298
+ if (attempt < 2)
299
+ await retryDelay(250 * 2 ** attempt);
300
+ }
301
+ finally {
302
+ stream.destroy();
303
+ }
304
+ }
305
+ if (!receipt)
306
+ throw lastError instanceof Error ? lastError : new Error(String(lastError || 'File part failed.'));
307
+ parts.push(receipt);
308
+ completed += length;
309
+ onProgress?.(completed);
310
+ }
311
+ const completeUrl = fileMultipartUrl(baseUrl, machine, absolutePath, '/complete');
312
+ completeUrl.searchParams.set('upload_id', uploadId);
313
+ const completeBody = JSON.stringify({ parts, size: bytes, receiptToken });
314
+ onFinalizing?.();
315
+ let completedUpload = false;
316
+ let completionError = null;
317
+ for (let attempt = 0; attempt < 3 && !completedUpload; attempt += 1) {
318
+ try {
319
+ const response = await fetchImpl(completeUrl, {
320
+ method: 'POST',
321
+ headers: {
322
+ ...uploadHeaders(options),
323
+ 'content-type': 'application/json',
324
+ 'content-length': String(Buffer.byteLength(completeBody)),
325
+ },
326
+ body: completeBody,
327
+ });
328
+ if (!response.ok) {
329
+ const failure = await uploadFailure(response, 'Multipart file upload could not finish');
330
+ if (response.status < 500 && response.status !== 408 && response.status !== 429)
331
+ throw failure;
332
+ completionError = failure;
333
+ }
334
+ else {
335
+ completedUpload = true;
336
+ }
337
+ }
338
+ catch (error) {
339
+ completionError = error;
340
+ }
341
+ if (!completedUpload && attempt < 2)
342
+ await retryDelay(250 * 2 ** attempt);
343
+ }
344
+ if (!completedUpload) {
345
+ throw completionError instanceof Error ? completionError : new Error('Multipart file upload could not finish.');
346
+ }
347
+ return true;
348
+ }
349
+ catch (error) {
350
+ const abortUrl = fileMultipartUrl(baseUrl, machine, absolutePath);
351
+ abortUrl.searchParams.set('upload_id', uploadId);
352
+ await fetchImpl(abortUrl, { method: 'DELETE', headers: uploadHeaders(options) }).catch(() => { });
353
+ throw error;
354
+ }
355
+ }
183
356
  /**
184
357
  * Put bytes in the account's private prefix, under the path they came from.
185
358
  *
@@ -204,9 +377,31 @@ export async function uploadFileBytes({ baseUrl, options, machine, absolutePath,
204
377
  return true;
205
378
  }
206
379
  /** Stream one on-disk file to R2 without retaining its body in the daemon. */
207
- export async function uploadFileStream({ baseUrl, options, machine, absolutePath, contentType, bytes, modifiedAt, contentEtag, fetchImpl = fetch, }) {
380
+ export async function uploadFileStream({ baseUrl, options, machine, absolutePath, contentType, bytes, modifiedAt, contentEtag, onProgress, onFinalizing, fetchImpl = fetch, }) {
381
+ if (bytes > MAX_SINGLE_BROWSE_UPLOAD_BYTES) {
382
+ return uploadMultipartFile({
383
+ baseUrl,
384
+ options,
385
+ machine,
386
+ absolutePath,
387
+ contentType,
388
+ bytes,
389
+ modifiedAt,
390
+ contentEtag,
391
+ onProgress,
392
+ onFinalizing,
393
+ fetchImpl,
394
+ });
395
+ }
208
396
  const url = fileUploadUrl(baseUrl, machine, absolutePath);
209
- const body = fs.createReadStream(absolutePath);
397
+ let finalizing = false;
398
+ const stream = progressStream(absolutePath, 0, bytes, (uploadedBytes) => {
399
+ onProgress?.(uploadedBytes);
400
+ if (!finalizing && uploadedBytes >= bytes) {
401
+ finalizing = true;
402
+ onFinalizing?.();
403
+ }
404
+ });
210
405
  try {
211
406
  const response = await fetchImpl(url, {
212
407
  method: 'PUT',
@@ -217,7 +412,7 @@ export async function uploadFileStream({ baseUrl, options, machine, absolutePath
217
412
  ...(modifiedAt ? { 'x-turns-file-modified-at': modifiedAt } : {}),
218
413
  ...(contentEtag ? { 'x-turns-content-etag': contentEtag } : {}),
219
414
  },
220
- body,
415
+ body: stream.body,
221
416
  // Node requires this opt-in for a streaming request body.
222
417
  duplex: 'half',
223
418
  });
@@ -226,7 +421,7 @@ export async function uploadFileStream({ baseUrl, options, machine, absolutePath
226
421
  return true;
227
422
  }
228
423
  finally {
229
- body.destroy();
424
+ stream.destroy();
230
425
  }
231
426
  }
232
427
  /** Send one resolved file to the account's private prefix. */
@@ -6,7 +6,7 @@ import { Readable } from 'node:stream';
6
6
  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
- import { browseMachinePath } from "./file-browsing.js";
9
+ import { browseMachinePath, streamMachineFileRange } from "./file-browsing.js";
10
10
  import { applyFileEdit } from "./file-edits.js";
11
11
  import { searchMachinePaths } from "./file-search.js";
12
12
  import { readMachineGitBaseBlob, readMachineGitPatch, readMachineGitStatus } from "./git-status.js";
@@ -1052,6 +1052,14 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
1052
1052
  baseUrl,
1053
1053
  options,
1054
1054
  workingDirectory: await browseWorkingDirectory(sessionId),
1055
+ onUploadProgress: (progress) => {
1056
+ replyRpc.notify({
1057
+ type: 'session.file.browse.progress',
1058
+ requestId: message.requestId,
1059
+ sessionId,
1060
+ ...progress,
1061
+ });
1062
+ },
1055
1063
  });
1056
1064
  replyRpc.notify({
1057
1065
  type: 'session.file.browse.result',
@@ -1071,6 +1079,57 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
1071
1079
  });
1072
1080
  }
1073
1081
  };
1082
+ /** Stream a small file window over the control socket. This never writes R2;
1083
+ * range-based viewers can therefore paint before a multi-gigabyte upload. */
1084
+ const handleFileRange = async (message, replyRpc) => {
1085
+ const sessionId = String(message.sessionId || '');
1086
+ let canonicalPath = '';
1087
+ try {
1088
+ await streamMachineFileRange({
1089
+ requested: String(message.path || ''),
1090
+ offset: Number(message.offset),
1091
+ length: Number(message.length),
1092
+ workingDirectory: await browseWorkingDirectory(sessionId),
1093
+ allowedDirectories: options.allowedDirectories || [],
1094
+ onReady: (range) => {
1095
+ canonicalPath = range.path;
1096
+ replyRpc.notify({
1097
+ type: 'session.file.range.ready',
1098
+ requestId: message.requestId,
1099
+ sessionId,
1100
+ ...range,
1101
+ });
1102
+ },
1103
+ onChunk: (offset, bytes) => {
1104
+ replyRpc.notify({
1105
+ type: 'session.file.range.chunk',
1106
+ requestId: message.requestId,
1107
+ sessionId,
1108
+ path: canonicalPath,
1109
+ offset,
1110
+ data: Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString('base64'),
1111
+ });
1112
+ },
1113
+ });
1114
+ replyRpc.notify({
1115
+ type: 'session.file.range.result',
1116
+ requestId: message.requestId,
1117
+ sessionId,
1118
+ path: canonicalPath,
1119
+ ok: true,
1120
+ });
1121
+ }
1122
+ catch (error) {
1123
+ replyRpc.notify({
1124
+ type: 'session.file.range.result',
1125
+ requestId: message.requestId,
1126
+ sessionId,
1127
+ ...(canonicalPath ? { path: canonicalPath } : {}),
1128
+ ok: false,
1129
+ error: redactor.redactText(error?.message || String(error)),
1130
+ });
1131
+ }
1132
+ };
1074
1133
  /** Refresh recursive path-name matches for directory typeahead.
1075
1134
  *
1076
1135
  * Like browsing, this is a machine filesystem operation rather than a
@@ -1217,6 +1276,8 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
1217
1276
  void handleInteractionAnswer(message, rpc);
1218
1277
  if (message.type === 'session.file.browse.request')
1219
1278
  void handleFileBrowse(message, rpc);
1279
+ if (message.type === 'session.file.range.request')
1280
+ void handleFileRange(message, rpc);
1220
1281
  if (message.type === 'session.file.search.request')
1221
1282
  void handleFileSearch(message, rpc);
1222
1283
  if (message.type === 'session.git.read.request')
@@ -1235,7 +1296,7 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
1235
1296
  type: 'machine.register',
1236
1297
  machineId: machine,
1237
1298
  machineHostname: os.hostname(),
1238
- capabilities: { fileSearch: true, git: true },
1299
+ capabilities: { fileSearch: true, git: true, fileRanges: true },
1239
1300
  workerId,
1240
1301
  workerInstanceId,
1241
1302
  anchorSessionIds: [...anchors.keys()],
@@ -946,8 +946,9 @@ function gitAssociationForSession(cwd, redactor) {
946
946
  * @param {number} [options.maxBatches]
947
947
  * @param {() => boolean} [options.shouldYield]
948
948
  * @param {boolean} [options.recoverRemoteAhead]
949
+ * @param {boolean} [options.forceReset]
949
950
  */
950
- export async function uploadNativeSession(rpc, machine, descriptor, cursor = {}, onProgress = () => { }, redactor = DEFAULT_REDACTOR, { maxBatches = Number.POSITIVE_INFINITY, shouldYield = () => false, recoverRemoteAhead = true, onPathCandidates = (_sessionId, _workingDirectory, _candidates) => { }, } = {}) {
951
+ export async function uploadNativeSession(rpc, machine, descriptor, cursor = {}, onProgress = () => { }, redactor = DEFAULT_REDACTOR, { maxBatches = Number.POSITIVE_INFINITY, shouldYield = () => false, recoverRemoteAhead = true, forceReset = false, onPathCandidates = (_sessionId, _workingDirectory, _candidates) => { }, } = {}) {
951
952
  const workingDirectory = await nativeDescriptorWorkingDirectory(descriptor);
952
953
  const agentSessionId = await nativeDescriptorAgentSessionId(descriptor);
953
954
  const git = gitAssociationForSession(workingDirectory, redactor);
@@ -976,7 +977,14 @@ export async function uploadNativeSession(rpc, machine, descriptor, cursor = {},
976
977
  const uploadSessionId = String(opened.sessionId || sessionId);
977
978
  let nextRow = Number(opened.nextRow) || 0;
978
979
  let initialUpload = nextRow === 0 || cursor.initialUpload === true;
979
- if (Number(opened.redactionVersion || 0) !== REDACTION_VERSION) {
980
+ if (forceReset && nextRow > 0) {
981
+ await rpc.request({ type: 'session.reset', sessionId: uploadSessionId, redactionVersion: REDACTION_VERSION });
982
+ nextRow = 0;
983
+ for (const key of Object.keys(cursor))
984
+ delete cursor[key];
985
+ initialUpload = true;
986
+ }
987
+ else if (Number(opened.redactionVersion || 0) !== REDACTION_VERSION) {
980
988
  await rpc.request({ type: 'session.reset', sessionId: uploadSessionId, redactionVersion: REDACTION_VERSION });
981
989
  nextRow = 0;
982
990
  for (const key of Object.keys(cursor))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@turns/dev",
3
- "version": "0.0.10",
3
+ "version": "0.0.11",
4
4
  "description": "Upload local AI agent sessions to turns.dev",
5
5
  "license": "MIT",
6
6
  "type": "module",