dsh-gitbash-shell 0.42.2 → 0.43.0

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.
@@ -117,6 +117,8 @@ export class McpStdio {
117
117
  /** Spawn the server and run the MCP initialize handshake. */
118
118
  async start() {
119
119
  const { spawn } = await import('node:child_process')
120
+ // close() can run while the dynamic import is pending; never resurrect it.
121
+ if (this.dead) throw new Error('the FastCtx bridge was stopped')
120
122
  this.child = spawn(this.file, this.args, { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true })
121
123
  this.child.stdout.setEncoding('utf8')
122
124
  this.child.stderr.setEncoding('utf8')
@@ -323,43 +325,102 @@ const FASTCTX_SANDBOX_NOTE = 'FastCtx reads the real filesystem with no workspac
323
325
  * - echo(text) -> result dialect echo, or undefined (off)
324
326
  * - log(message) -> the plugin's tagged logger
325
327
  *
326
- * The surface publishes at the FIRST agent creation and re-decides on every
327
- * later one: dsh-ops appearing (ops_* tools in the face) withdraws gb_* for
328
- * the rest of the process — duplicates would only teach the model two names
329
- * for one job.
328
+ * The surface publishes at the first agent creation or live switch update.
329
+ * User OFF/ON is reversible; dsh-ops appearing (ops_* tools in the face)
330
+ * independently withdraws gb_* for the rest of the process. Every call also
331
+ * checks the live switch and its publication generation, including old SDKs.
330
332
  */
331
333
  export function mountFastctx(ctx, deps) {
332
334
  const { platform, enabled, createRequireFrom, tools, msysToWin, echo, log } = deps
333
335
  if (platform !== 'win32') return
334
336
  let disposer = undefined
335
337
  let client = undefined
336
- let decided = false
338
+ let unavailable = false
337
339
  let withdrawnByOps = false
340
+ let switchOn
341
+ let generation = 0
338
342
  let closed = false
339
343
  let publishing
340
344
  let connecting
341
345
  const starting = new Set()
346
+ const pending = new Set()
347
+ const agents = new Set()
348
+
349
+ const track = (promise) => {
350
+ pending.add(promise)
351
+ promise.then(() => pending.delete(promise), () => pending.delete(promise))
352
+ return promise
353
+ }
342
354
 
343
355
  const unpublish = (reason) => {
344
356
  if (disposer) {
345
- try { disposer() } catch { /* already gone */ }
357
+ const dispose = disposer
346
358
  disposer = undefined
359
+ try { dispose() } catch { /* already gone */ }
347
360
  }
348
361
  if (reason) log(reason)
349
362
  }
350
363
 
351
- const callWithReconnect = async (rawName, args) => {
352
- if (closed || withdrawnByOps) throw new Error('the FastCtx bridge was stopped')
364
+ const stopConnections = () => {
365
+ const owned = new Set([...starting, client].filter(Boolean))
366
+ client = undefined
367
+ // Calling close immediately rejects pending requests and kills the child;
368
+ // track completion as well so disposal owns even superseded generations.
369
+ track(Promise.allSettled([...owned].map((connection) => connection.close())))
370
+ }
371
+
372
+ const live = (agent) => {
373
+ if (closed) return false
374
+ const next = !!enabled()
375
+ if (switchOn !== next) {
376
+ switchOn = next
377
+ generation++
378
+ unavailable = false
379
+ if (!next) {
380
+ unpublish('FastCtx bridge disabled by the fastctxTools switch')
381
+ stopConnections()
382
+ }
383
+ }
384
+ if (!withdrawnByOps) {
385
+ const registry = tools(ctx)
386
+ const faces = new Set(agent ? [agent, ...agents] : agents.size ? agents : [undefined])
387
+ for (const face of faces) {
388
+ const names = (registry?.schemas?.(face) ?? []).map((schema) => schema?.name)
389
+ // Any ops_* name means dsh-ops owns the file surface. Matching the
390
+ // prefix (rather than only the listed markers) covers the 0.4.0
391
+ // progressive residents AND the names the marker list does not carry
392
+ // yet (ops_batch / ops_glob / ops_replace / ops_run): a later caller
393
+ // can hand the face a name that was never resident in face 1.
394
+ if (names.some((name) => typeof name === 'string'
395
+ && (name.startsWith('ops_') || OPS_PRESENCE_MARKERS.includes(name)))) {
396
+ withdrawnByOps = true
397
+ generation++
398
+ unpublish('dsh-ops detected (ops_* tools present): the gb_* surface stands down')
399
+ stopConnections()
400
+ break
401
+ }
402
+ }
403
+ }
404
+ return switchOn && !withdrawnByOps
405
+ }
406
+
407
+ const assertLive = (owner, agent) => {
408
+ if (!live(agent) || owner !== generation || !disposer) throw new Error('the FastCtx bridge was stopped')
409
+ }
410
+
411
+ const callWithReconnect = async (rawName, args, owner, agent) => {
412
+ assertLive(owner, agent)
353
413
  if (client === undefined || client.isDead) {
354
- if (!connecting) {
355
- connecting = (async () => {
414
+ if (!connecting || connecting.owner !== owner) {
415
+ const task = { owner }
416
+ task.promise = track((async () => {
356
417
  const { file } = resolveFastctx(ctx, createRequireFrom)
357
418
  if (file === '') throw new Error('the FastCtx payload is not installed; repair the profile dependencies (frozen install) and retry')
358
419
  const fresh = new McpStdio({ file, log })
359
420
  starting.add(fresh)
360
421
  try {
361
422
  await fresh.start()
362
- if (closed || withdrawnByOps) throw new Error('the FastCtx bridge was stopped')
423
+ assertLive(owner, agent)
363
424
  client = fresh
364
425
  } catch (error) {
365
426
  await fresh.close()
@@ -367,23 +428,25 @@ export function mountFastctx(ctx, deps) {
367
428
  } finally {
368
429
  starting.delete(fresh)
369
430
  }
370
- })().finally(() => { connecting = undefined })
431
+ })().finally(() => { if (connecting === task) connecting = undefined }))
432
+ connecting = task
371
433
  }
372
- await connecting
434
+ await connecting.promise
373
435
  }
374
- if (closed || withdrawnByOps) throw new Error('the FastCtx bridge was stopped')
436
+ assertLive(owner, agent)
437
+ // Reconnect only BEFORE dispatch. Never replay a failed tools/call: it may
438
+ // already have written files even when its response is an ordinary error.
375
439
  return client.callTool(rawName, args)
376
440
  }
377
441
 
378
- const publishOnce = async () => {
379
- if (closed || disposer || decided && withdrawnByOps) return
442
+ const publishOnce = async (owner) => {
443
+ if (!live() || owner !== generation || disposer || unavailable) return
380
444
  const registry = tools(ctx)
381
445
  if (!registry || typeof registry.register !== 'function') return
382
446
  const { file, source } = resolveFastctx(ctx, createRequireFrom)
383
447
  if (file === '') {
384
448
  log('FastCtx payload not found (probed ' + PAYLOAD_PACKAGES.map((p) => p.pkg).join(', ') + '); gb_* tools stay unpublished')
385
- decided = true
386
- withdrawnByOps = true // nothing to publish either way; stop re-deciding
449
+ unavailable = true
387
450
  return
388
451
  }
389
452
  const fresh = new McpStdio({ file, log })
@@ -391,23 +454,30 @@ export function mountFastctx(ctx, deps) {
391
454
  let toolList
392
455
  try {
393
456
  await fresh.start()
457
+ if (!live() || owner !== generation) return
394
458
  toolList = await fresh.listTools()
395
459
  } catch (error) {
396
- try { await fresh.close() } catch { /* best effort */ }
397
- log(`FastCtx server start failed (${source}): ${error?.message ?? error}; gb_* tools stay unpublished`)
398
- decided = true
399
- withdrawnByOps = true
460
+ if (live() && owner === generation) {
461
+ log(`FastCtx server start failed (${source}): ${error?.message ?? error}; gb_* tools stay unpublished`)
462
+ unavailable = true
463
+ }
400
464
  return
401
465
  } finally {
466
+ try { await fresh.close() } catch { /* best effort */ }
402
467
  starting.delete(fresh)
403
468
  }
404
- await fresh.close()
405
- if (closed || withdrawnByOps) return
469
+ if (!live() || owner !== generation) return
406
470
  // Discovery never overwrites the independently owned lazy call client.
407
471
  const wanted = toolList.filter((tool) => FASTCTX_FILE_TOOLS.includes(String(tool?.name)))
408
472
  const registered = []
409
473
  const registeredNames = []
474
+ const release = () => {
475
+ for (const dispose of registered) {
476
+ try { dispose() } catch (error) { log(`FastCtx unregister failed: ${error?.message ?? error}`) }
477
+ }
478
+ }
410
479
  for (const tool of wanted) {
480
+ if (!live() || owner !== generation) break
411
481
  const rawName = String(tool.name)
412
482
  const schema = tool.inputSchema && typeof tool.inputSchema === 'object' ? tool.inputSchema : { type: 'object', properties: {} }
413
483
  // Ignore-rule transparency (v0.38.5, the field smoke report): the three
@@ -438,6 +508,7 @@ export function mountFastctx(ctx, deps) {
438
508
  render: (_args, value) => renderTextContent(value),
439
509
  },
440
510
  async execute(args, execution) {
511
+ assertLive(owner, execution?.agent)
441
512
  // Preserve v0.39.1 / #21: glob accepts real string arrays,
442
513
  // while stringified arrays and malformed patterns fail loudly.
443
514
  if (args !== null && typeof args === 'object' && 'pattern' in args) {
@@ -445,7 +516,7 @@ export function mountFastctx(ctx, deps) {
445
516
  if (invalid) throw new Error(invalid)
446
517
  }
447
518
  const translated = translateFastctxArgs(args, msysToWin ? (path) => msysToWin(path, execution) : undefined)
448
- const result = await callWithReconnect(rawName, translated)
519
+ const result = await callWithReconnect(rawName, translated, owner, execution?.agent)
449
520
  const text = renderFastctxResult(rawName, result, echo ? (value) => echo(value, execution) : undefined)
450
521
  // The host treats a returned value as success; MCP failures must
451
522
  // throw, just as its official MCP client does.
@@ -461,52 +532,51 @@ export function mountFastctx(ctx, deps) {
461
532
  log(`${gbName(rawName)} registration failed: ${error?.message ?? error}`)
462
533
  }
463
534
  }
535
+ if (!live() || owner !== generation) {
536
+ release()
537
+ return
538
+ }
464
539
  if (registered.length === 0) {
465
540
  log('FastCtx published none of the known file tools; nothing registered')
466
- decided = true
467
- withdrawnByOps = true
541
+ unavailable = true
468
542
  return
469
543
  }
470
- disposer = () => { for (const dispose of registered) dispose() }
544
+ disposer = release
471
545
  log(`FastCtx bridge active (${source}): ${registeredNames.join(', ')}`)
472
546
  }
473
547
 
474
548
  const publish = () => {
475
- if (!publishing) publishing = publishOnce().finally(() => { publishing = undefined })
476
- return publishing
549
+ if (!publishing || publishing.owner !== generation) {
550
+ const task = { owner: generation }
551
+ task.promise = track(publishOnce(task.owner).finally(() => { if (publishing === task) publishing = undefined }))
552
+ publishing = task
553
+ }
554
+ return publishing.promise
477
555
  }
478
556
 
479
557
  const reconcile = (agent) => {
480
- if (closed) return
481
- if (!enabled()) { unpublish('FastCtx bridge disabled by the fastctxTools switch'); decided = true; withdrawnByOps = true; return }
482
- if (decided && withdrawnByOps) return
483
- let names = []
484
558
  try {
485
- names = (tools(ctx)?.schemas?.(agent) ?? []).map((schema) => schema.name).filter((name) => typeof name === 'string')
486
- } catch { /* a broken face reads as empty */ }
487
- if (names.some((name) => OPS_PRESENCE_MARKERS.includes(name))) {
488
- if (!withdrawnByOps) {
489
- withdrawnByOps = true
490
- decided = true
491
- unpublish('dsh-ops detected (ops_* tools present): the gb_* surface stands down')
492
- }
493
- return
559
+ if (!live(agent)) return
560
+ void publish().catch((error) => log(`FastCtx bridge mount failed: ${error?.message ?? error}`))
561
+ } catch (error) {
562
+ log(`FastCtx bridge reconcile failed: ${error?.message ?? error}`)
494
563
  }
495
- void publish().catch((error) => log(`FastCtx bridge mount failed: ${error?.message ?? error}`))
496
564
  }
497
565
 
498
- try {
499
- ctx.on('agent/created', ({ agent } = {}) => { try { reconcile(agent) } catch { /* never break agent creation */ } })
500
- } catch (error) {
501
- log(`FastCtx bridge wiring failed: ${error?.message ?? error}`)
502
- return
503
- }
566
+ // Install disposal before listeners: partial wiring still owns every child.
504
567
  ctx.effect(() => async () => {
505
568
  closed = true
569
+ generation++
570
+ agents.clear()
506
571
  unpublish()
507
- const owned = new Set([...starting, client].filter(Boolean))
508
- await Promise.allSettled([...owned].map((connection) => connection.close()))
509
- await Promise.allSettled([publishing, connecting].filter(Boolean))
510
- client = undefined
572
+ stopConnections()
573
+ await Promise.allSettled([...pending])
511
574
  }, 'dsh-gitbash-shell: FastCtx bridge')
575
+ try {
576
+ ctx.on('agent/created', ({ agent } = {}) => { if (!closed && agent) agents.add(agent); reconcile(agent) })
577
+ ctx.on('agent/disposed', ({ agent } = {}) => { agents.delete(agent) })
578
+ ctx.on('loader/volatile-update', () => { reconcile() })
579
+ } catch (error) {
580
+ log(`FastCtx bridge wiring failed: ${error?.message ?? error}`)
581
+ }
512
582
  }
package/src/index.js CHANGED
@@ -308,7 +308,7 @@ import {
308
308
  restoreOpsBash,
309
309
  } from './ops-adopt.js'
310
310
  import {
311
- clearToolJob, currentToolJob, defaultRunnerIo, fenceToolRequest,
311
+ cancelToolJob, clearToolJob, currentToolJob, defaultRunnerIo, fenceToolRequest,
312
312
  probeToolchain, readJsonBody, startToolJob,
313
313
  } from './tool-runner.js'
314
314
 
@@ -766,7 +766,12 @@ function pathEcho(value, env) {
766
766
  const out = driveToMsys(value)
767
767
  if (typeof out !== 'string' || out.length === 0) return out
768
768
  const tempRoot = env && env.tmpDir ? driveToMsys(env.tmpDir) : ''
769
- return tempRoot ? mountTempRoot(out, tempRoot) : out
769
+ // A whole path has exactly one root; an embedded copy in a backup tree is data.
770
+ if (tempRoot && out.slice(0, tempRoot.length).toLowerCase() === tempRoot.toLowerCase()
771
+ && (out.length === tempRoot.length || out[tempRoot.length] === '/')) {
772
+ return '/tmp' + out.slice(tempRoot.length)
773
+ }
774
+ return out
770
775
  }
771
776
 
772
777
  /**
@@ -1251,6 +1256,12 @@ function regexCanStart(previous) {
1251
1256
  return previous.length === 1 && '(,=:[!&|?{;+-*%<>~^'.includes(previous)
1252
1257
  }
1253
1258
 
1259
+ /** A newline may end a declaration before a statement regexp; do not guess ASI. */
1260
+ function ambiguousSlash(code, index, previous, previousEnd) {
1261
+ return previous === ')' || previous === '}'
1262
+ || (!regexCanStart(previous) && /[\r\n\u2028\u2029]/.test(code.slice(previousEnd, index)))
1263
+ }
1264
+
1254
1265
  /** Read an identifier as ONE token: keyword-led regexes are not division. */
1255
1266
  function codeWordAt(code, index) {
1256
1267
  return /^[A-Za-z_$][A-Za-z0-9_$]*/.exec(code.slice(index))?.[0]
@@ -1263,7 +1274,7 @@ function skipQuotedLiteral(code, index) {
1263
1274
  while (i < code.length) {
1264
1275
  const c = code[i]
1265
1276
  if (c === '\\') { i += 2; continue }
1266
- if (c === '\n') return -1
1277
+ if (/[\r\n\u2028\u2029]/.test(c)) return -1
1267
1278
  if (c === quote) return i + 1
1268
1279
  i += 1
1269
1280
  }
@@ -1277,7 +1288,7 @@ function skipRegexLiteral(code, index) {
1277
1288
  while (i < code.length) {
1278
1289
  const c = code[i]
1279
1290
  if (c === '\\') { i += 2; continue }
1280
- if (c === '\n') return -1
1291
+ if (/[\r\n\u2028\u2029]/.test(c)) return -1
1281
1292
  if (c === '[') inClass = true
1282
1293
  else if (c === ']') inClass = false
1283
1294
  else if (c === '/' && inClass === false) {
@@ -1295,11 +1306,13 @@ function skipBracedExpression(code, index) {
1295
1306
  let depth = 1
1296
1307
  let i = index
1297
1308
  let last = ''
1309
+ let previousEnd = 0
1298
1310
  while (i < code.length) {
1299
1311
  const c = code[i]
1300
1312
  const next = code[i + 1]
1301
1313
  if (c === '/' && next === '/') {
1302
- const nl = code.indexOf('\n', i)
1314
+ const lineEnd = /[\r\n\u2028\u2029]/.exec(code.slice(i))
1315
+ const nl = lineEnd ? i + lineEnd.index : -1
1303
1316
  if (nl === -1) return -1
1304
1317
  i = nl
1305
1318
  continue
@@ -1315,6 +1328,7 @@ function skipBracedExpression(code, index) {
1315
1328
  if (end === -1) return -1
1316
1329
  i = end
1317
1330
  last = 'x'
1331
+ previousEnd = i
1318
1332
  continue
1319
1333
  }
1320
1334
  if (c === '`') {
@@ -1322,15 +1336,17 @@ function skipBracedExpression(code, index) {
1322
1336
  if (inner === null) return -1
1323
1337
  i = inner.end
1324
1338
  last = 'x'
1339
+ previousEnd = i
1325
1340
  continue
1326
1341
  }
1327
1342
  // A slash after a closing group may start a statement regex OR division.
1328
1343
  // Without a parser that distinction is uncertain: abort the WHOLE scan.
1329
- if (c === '/' && (last === ')' || last === '}')) return -1
1344
+ if (c === '/' && ambiguousSlash(code, i, last, previousEnd)) return -1
1330
1345
  const word = codeWordAt(code, i)
1331
1346
  if (word) {
1332
1347
  last = last === '.' ? 'x' : word
1333
1348
  i += word.length
1349
+ previousEnd = i
1334
1350
  continue
1335
1351
  }
1336
1352
  if (c === '/' && regexCanStart(last)) {
@@ -1338,6 +1354,7 @@ function skipBracedExpression(code, index) {
1338
1354
  if (end === -1) return -1
1339
1355
  i = end
1340
1356
  last = 'x'
1357
+ previousEnd = i
1341
1358
  continue
1342
1359
  }
1343
1360
  if (c === '{') depth += 1
@@ -1345,7 +1362,7 @@ function skipBracedExpression(code, index) {
1345
1362
  depth -= 1
1346
1363
  if (depth === 0) return i + 1
1347
1364
  }
1348
- if (c.trim() !== '') last = c
1365
+ if (c.trim() !== '') { last = c; previousEnd = i + 1 }
1349
1366
  i += 1
1350
1367
  }
1351
1368
  return -1
@@ -1383,11 +1400,13 @@ export function scanCodeLiterals(code) {
1383
1400
  const spans = []
1384
1401
  let i = 0
1385
1402
  let last = ''
1403
+ let previousEnd = 0
1386
1404
  while (i < code.length) {
1387
1405
  const c = code[i]
1388
1406
  const next = code[i + 1]
1389
1407
  if (c === '/' && next === '/') {
1390
- const nl = code.indexOf('\n', i)
1408
+ const lineEnd = /[\r\n\u2028\u2029]/.exec(code.slice(i))
1409
+ const nl = lineEnd ? i + lineEnd.index : -1
1391
1410
  // A line comment may legally terminate at EOF (outside interpolation).
1392
1411
  if (nl === -1) break
1393
1412
  i = nl
@@ -1405,6 +1424,7 @@ export function scanCodeLiterals(code) {
1405
1424
  spans.push({ start: i + 1, end: end - 1 })
1406
1425
  i = end
1407
1426
  last = 'x'
1427
+ previousEnd = i
1408
1428
  continue
1409
1429
  }
1410
1430
  if (c === '`') {
@@ -1418,15 +1438,17 @@ export function scanCodeLiterals(code) {
1418
1438
  }
1419
1439
  i = template.end
1420
1440
  last = 'x'
1441
+ previousEnd = i
1421
1442
  continue
1422
1443
  }
1423
1444
  // A slash after a closing group may start a statement regex OR division.
1424
1445
  // Without a parser that distinction is uncertain: abort the WHOLE scan.
1425
- if (c === '/' && (last === ')' || last === '}')) return null
1446
+ if (c === '/' && ambiguousSlash(code, i, last, previousEnd)) return null
1426
1447
  const word = codeWordAt(code, i)
1427
1448
  if (word) {
1428
1449
  last = last === '.' ? 'x' : word
1429
1450
  i += word.length
1451
+ previousEnd = i
1430
1452
  continue
1431
1453
  }
1432
1454
  if (c === '/' && regexCanStart(last)) {
@@ -1434,9 +1456,10 @@ export function scanCodeLiterals(code) {
1434
1456
  if (end === -1) return null
1435
1457
  i = end
1436
1458
  last = 'x'
1459
+ previousEnd = i
1437
1460
  continue
1438
1461
  }
1439
- if (c.trim() !== '') last = c
1462
+ if (c.trim() !== '') { last = c; previousEnd = i + 1 }
1440
1463
  i += 1
1441
1464
  }
1442
1465
  return spans
@@ -1512,7 +1535,7 @@ export function rewriteCodePaths(code, env) {
1512
1535
  // path WITHOUT wildcards splits into directory + basename. Runs only for
1513
1536
  // the glob tool and only for patterns that start absolute.
1514
1537
 
1515
- const GLOB_META = /[*?\[\]]/
1538
+ const GLOB_META = /[*?\[\]{}]/
1516
1539
 
1517
1540
  /** Split an absolute glob pattern into { prefix, rest }; null when relative. */
1518
1541
  function splitGlobPrefix(pattern) {
@@ -2637,6 +2660,10 @@ export async function apply(ctx, config = {}) {
2637
2660
  return {
2638
2661
  id: job.id, action: job.action, total: job.total, done: job.done,
2639
2662
  current: job.current, finished: job.finished, results: job.results.slice(),
2663
+ // v0.43.0 (issue #35): elapsed time, the freshest winget line,
2664
+ // and the cancelled state, so the card can show them.
2665
+ startedAt: job.startedAt, currentSince: job.currentSince,
2666
+ lastOutput: job.lastOutput, cancelled: job.cancelled === true,
2640
2667
  }
2641
2668
  }
2642
2669
  const handler = async (req, res) => {
@@ -2652,6 +2679,14 @@ export async function apply(ctx, config = {}) {
2652
2679
  try {
2653
2680
  if (method === 'POST') {
2654
2681
  const action = query.get('action') || ''
2682
+ if (action === 'cancel') {
2683
+ // v0.43.0 (issue #35): kill the in-flight tree, mark the rest
2684
+ // skipped. The cancelled job stays current until finished so
2685
+ // the card can show what happened; `clear` then forgets it.
2686
+ cancelToolJob('')
2687
+ send(res, 200, { ok: true, job: snapshot() })
2688
+ return
2689
+ }
2655
2690
  if (action === 'clear') {
2656
2691
  clearToolJob('')
2657
2692
  send(res, 200, { ok: true, job: null })
@@ -25,7 +25,8 @@ export function nativePath(input, translateMsysPath, platform = process.platform
25
25
  object(input, label, new Set(['kind', 'value']))
26
26
  if (input.kind !== 'path') throw new TypeError(`${label}: kind must be path`)
27
27
  const value = string(input.value, label)
28
- if (/^[~$]|%[^%]+%/.test(value)) throw new TypeError(`${label}: variable and home expansion are not supported; use an absolute path`)
28
+ // Reject expansion prefixes, not literal characters inside absolute paths.
29
+ if (/^(?:[~$]|%[^%]+%)/.test(value)) throw new TypeError(`${label}: variable and home expansion are not supported; use an absolute path`)
29
30
  if (platform !== 'win32') throw new Error('gitbash_native currently supports only the Windows local execution world')
30
31
  // A rooted Windows path without a drive is not absolute in this contract.
31
32
  const absolute = v => /^[a-z]:[\\/]/i.test(v) || /^\\\\[^\\]+\\[^\\]+/.test(v) || /^\/\/[^/]+\/[^/]+/.test(v)
@@ -1,6 +1,6 @@
1
1
  import { registerExperimentalTool } from './experimental-tools.js'
2
2
  import { createNativeExecutor } from './native-executor.js'
3
- import { processJob, processSources, ringDelta } from './bash-render.js'
3
+ import { processJob, processSources, ringDelta, renderJobRead } from './bash-render.js'
4
4
 
5
5
  const typedPath = () => ({ type: 'object', additionalProperties: false, properties: {
6
6
  kind: { type: 'string', const: 'path', required: true }, value: { type: 'string', required: true },
@@ -165,8 +165,11 @@ export function createNativeToolModule(deps) {
165
165
  throw new Error(`native preparation timed out; job ${attached.id} was cancelled, inspect job_output before retrying`)
166
166
  }
167
167
  const read = jobs.read(attached.id, owner)
168
+ // A ring eviction can report gapBefore even when its pull source
169
+ // was lossless. This consuming read must preserve both loss facts.
170
+ const lostOutput = read.lossy || read.chunks.some(chunk => chunk.gapBefore)
168
171
  return { kind: 'promoted', jobId: attached.id, cwd: request.cwd, timeoutMs: request.timeoutMs,
169
- output: ringDelta(read.chunks), encoding: request.output }
172
+ output: renderJobRead(ringDelta(read.chunks), lostOutput, read.job?.output?.spillPaths), encoding: request.output }
170
173
  }
171
174
  jobs.remove(attached.id, owner)
172
175
  if (!attached.process()) throw new Error(view.detail ?? 'native preparation failed before a process handle was available')