@namzu/sdk 60.1.0 → 60.2.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.
Files changed (116) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/dist/bridge/a2a/mapper.d.ts.map +1 -1
  3. package/dist/bridge/a2a/mapper.js +3 -0
  4. package/dist/bridge/a2a/mapper.js.map +1 -1
  5. package/dist/bridge/sse/mapper.d.ts.map +1 -1
  6. package/dist/bridge/sse/mapper.js +16 -0
  7. package/dist/bridge/sse/mapper.js.map +1 -1
  8. package/dist/contracts/session/api.d.ts +1 -1
  9. package/dist/contracts/session/api.d.ts.map +1 -1
  10. package/dist/manager/session/turn-recorder.d.ts +7 -1
  11. package/dist/manager/session/turn-recorder.d.ts.map +1 -1
  12. package/dist/manager/session/turn-recorder.js +19 -0
  13. package/dist/manager/session/turn-recorder.js.map +1 -1
  14. package/dist/peers/dir.d.ts.map +1 -1
  15. package/dist/peers/dir.js +3 -0
  16. package/dist/peers/dir.js.map +1 -1
  17. package/dist/public-runtime.d.ts +1 -0
  18. package/dist/public-runtime.d.ts.map +1 -1
  19. package/dist/public-runtime.js +1 -0
  20. package/dist/public-runtime.js.map +1 -1
  21. package/dist/runtime/query/events.d.ts.map +1 -1
  22. package/dist/runtime/query/events.js +17 -0
  23. package/dist/runtime/query/events.js.map +1 -1
  24. package/dist/runtime/query/executor.d.ts +23 -0
  25. package/dist/runtime/query/executor.d.ts.map +1 -1
  26. package/dist/runtime/query/executor.js +88 -9
  27. package/dist/runtime/query/executor.js.map +1 -1
  28. package/dist/runtime/query/index.d.ts.map +1 -1
  29. package/dist/runtime/query/index.js +13 -0
  30. package/dist/runtime/query/index.js.map +1 -1
  31. package/dist/runtime/query/iteration/index.d.ts.map +1 -1
  32. package/dist/runtime/query/iteration/index.js +3 -2
  33. package/dist/runtime/query/iteration/index.js.map +1 -1
  34. package/dist/runtime/query/iteration/phases/tool-review.d.ts.map +1 -1
  35. package/dist/runtime/query/iteration/phases/tool-review.js +6 -0
  36. package/dist/runtime/query/iteration/phases/tool-review.js.map +1 -1
  37. package/dist/runtime/query/request-void-turns.d.ts +19 -0
  38. package/dist/runtime/query/request-void-turns.d.ts.map +1 -0
  39. package/dist/runtime/query/request-void-turns.js +26 -0
  40. package/dist/runtime/query/request-void-turns.js.map +1 -0
  41. package/dist/runtime/query/review-policy.d.ts.map +1 -1
  42. package/dist/runtime/query/review-policy.js +12 -2
  43. package/dist/runtime/query/review-policy.js.map +1 -1
  44. package/dist/runtime/query/tooling.d.ts +2 -0
  45. package/dist/runtime/query/tooling.d.ts.map +1 -1
  46. package/dist/runtime/query/tooling.js +1 -0
  47. package/dist/runtime/query/tooling.js.map +1 -1
  48. package/dist/sandbox/provider/local.d.ts.map +1 -1
  49. package/dist/sandbox/provider/local.js +6 -5
  50. package/dist/sandbox/provider/local.js.map +1 -1
  51. package/dist/session/paths.d.ts +15 -22
  52. package/dist/session/paths.d.ts.map +1 -1
  53. package/dist/session/paths.js +19 -46
  54. package/dist/session/paths.js.map +1 -1
  55. package/dist/store/session-log/core.d.ts +43 -0
  56. package/dist/store/session-log/core.d.ts.map +1 -1
  57. package/dist/store/session-log/core.js +190 -3
  58. package/dist/store/session-log/core.js.map +1 -1
  59. package/dist/store/session-log/disk.d.ts +1 -1
  60. package/dist/store/session-log/disk.d.ts.map +1 -1
  61. package/dist/store/session-log/disk.js +1 -0
  62. package/dist/store/session-log/disk.js.map +1 -1
  63. package/dist/store/session-log/index.d.ts +1 -1
  64. package/dist/store/session-log/index.d.ts.map +1 -1
  65. package/dist/store/session-log/index.js +1 -1
  66. package/dist/store/session-log/index.js.map +1 -1
  67. package/dist/tools/builtins/bash-cwd.d.ts.map +1 -1
  68. package/dist/tools/builtins/bash-cwd.js +2 -3
  69. package/dist/tools/builtins/bash-cwd.js.map +1 -1
  70. package/dist/tools/builtins/read-media.js +3 -3
  71. package/dist/tools/builtins/read-media.js.map +1 -1
  72. package/dist/turn/reporter.d.ts.map +1 -1
  73. package/dist/turn/reporter.js +2 -0
  74. package/dist/turn/reporter.js.map +1 -1
  75. package/dist/types/hitl/index.d.ts +8 -0
  76. package/dist/types/hitl/index.d.ts.map +1 -1
  77. package/dist/types/hitl/index.js.map +1 -1
  78. package/dist/types/session/events.d.ts +38 -2
  79. package/dist/types/session/events.d.ts.map +1 -1
  80. package/dist/types/session/events.js +4 -0
  81. package/dist/types/session/events.js.map +1 -1
  82. package/dist/types/session/records.d.ts +10 -10
  83. package/dist/types/session/records.d.ts.map +1 -1
  84. package/dist/types/session/records.js +2 -0
  85. package/dist/types/session/records.js.map +1 -1
  86. package/dist/utils/temp.d.ts +82 -0
  87. package/dist/utils/temp.d.ts.map +1 -0
  88. package/dist/utils/temp.js +196 -0
  89. package/dist/utils/temp.js.map +1 -0
  90. package/package.json +1 -1
  91. package/src/bridge/a2a/mapper.ts +3 -0
  92. package/src/bridge/sse/mapper.ts +16 -0
  93. package/src/contracts/session/api.ts +2 -0
  94. package/src/manager/session/turn-recorder.ts +21 -0
  95. package/src/peers/dir.ts +3 -0
  96. package/src/public-runtime.ts +13 -0
  97. package/src/runtime/query/events.ts +19 -0
  98. package/src/runtime/query/executor.ts +92 -8
  99. package/src/runtime/query/index.ts +12 -0
  100. package/src/runtime/query/iteration/index.ts +3 -2
  101. package/src/runtime/query/iteration/phases/tool-review.ts +6 -0
  102. package/src/runtime/query/request-void-turns.ts +28 -0
  103. package/src/runtime/query/review-policy.ts +13 -2
  104. package/src/runtime/query/tooling.ts +3 -0
  105. package/src/sandbox/provider/local.ts +8 -5
  106. package/src/session/paths.ts +32 -57
  107. package/src/store/session-log/core.ts +235 -3
  108. package/src/store/session-log/disk.ts +7 -1
  109. package/src/store/session-log/index.ts +4 -0
  110. package/src/tools/builtins/bash-cwd.ts +2 -3
  111. package/src/tools/builtins/read-media.ts +3 -3
  112. package/src/turn/reporter.ts +2 -0
  113. package/src/types/hitl/index.ts +8 -0
  114. package/src/types/session/events.ts +41 -1
  115. package/src/types/session/records.ts +2 -0
  116. package/src/utils/temp.ts +250 -0
@@ -1,5 +1,5 @@
1
1
  import { createHash } from 'node:crypto'
2
- import { join, resolve } from 'node:path'
2
+ import { isAbsolute, join, resolve } from 'node:path'
3
3
  import type { Span } from '@opentelemetry/api'
4
4
  import type { AuthorizationGate } from '../../authorization/gate.js'
5
5
  import { unknownProgramInLine } from '../../authorization/program.js'
@@ -24,7 +24,7 @@ import { fingerprintContent } from '../../tools/builtins/content-fingerprint.js'
24
24
  import { sandboxShellSpawn, withoutBashStartup } from '../../tools/command-shell.js'
25
25
  import { isAlwaysDestructive } from '../../tools/defineTool.js'
26
26
  import { createFileReadTracker } from '../../tools/file-read-tracker.js'
27
- import { pathOutsideRoots, toolRoots } from '../../tools/paths.js'
27
+ import { pathOutsideRoots, resolveWithinAnyReal, toolRoots } from '../../tools/paths.js'
28
28
  import { resolveRetainedOutput } from '../../tools/retained-output.js'
29
29
  import type { ToolManager } from '../../toolsets/manager.js'
30
30
  import type { ToolSourceRef } from '../../toolsets/types.js'
@@ -70,6 +70,7 @@ import { generateToolCallId } from '../../utils/id.js'
70
70
  import type { Logger } from '../../utils/logger.js'
71
71
  import { compressShellOutput } from '../../utils/shell-compress.js'
72
72
  import { parseStructuredResultJson } from '../../utils/structured-result-json.js'
73
+ import { ensureScratchpadDirectory } from '../../utils/temp.js'
73
74
  import { type BackgroundJobRegistry, type JobProcess, bindOwner } from '../jobs/registry.js'
74
75
  import { type BatchDetach, stillRunningResult } from './detached-calls.js'
75
76
  import {
@@ -155,12 +156,15 @@ export interface PreparedToolBatch {
155
156
  readonly escalation?: ToolCallEscalation
156
157
  /** What a `pre_tool_use` hook asked for this call's approval; see `ToolCallSummary.hookPermission`. */
157
158
  readonly hookPermission?: PluginHookPermission
159
+ /** The call writes inside the session's scratch folder; see `ToolCallSummary.scratchpadWrite`. */
160
+ readonly scratchpadWrite?: true
158
161
  }[]
159
162
  }
160
163
 
161
164
  interface OwnedPreparedToolBatch extends PreparedToolBatch {
162
165
  readonly calls: ReadonlyMap<string, PreparedDirectCall>
163
166
  readonly escalations: ReadonlyMap<string, ToolCallEscalation>
167
+ readonly scratchpadWrites: ReadonlySet<string>
164
168
  }
165
169
 
166
170
  /**
@@ -363,6 +367,13 @@ export interface ToolExecutorConfig {
363
367
  workingDirectory: string
364
368
  /** See `ToolContext.additionalDirectories`. */
365
369
  additionalDirectories?: readonly string[]
370
+ /**
371
+ * Where this session's own scratch folder is (it need not exist). It becomes
372
+ * a root of the file tools, and a write inside it needs no review, once a call
373
+ * names it and the folder has been made and checked. Unset on a sandboxed
374
+ * turn and on a session with no layout on disk.
375
+ */
376
+ scratchpadDirectory?: string
366
377
  /** See `QueryParams.outsideRootAccess`. Default `'refuse'`. */
367
378
  outsideRootAccess?: 'refuse' | 'review'
368
379
  /** See `QueryParams.sandboxEscape`. Default `'refuse'`. */
@@ -814,6 +825,68 @@ export class ToolExecutor {
814
825
  }
815
826
 
816
827
  /** True once the turn has a sandbox, or is certain to get one before a tool runs. */
828
+ /** The scratch folder, once a call has named it and it has been made and checked. */
829
+ private scratchpadReady: string | undefined
830
+
831
+ /** The tools' roots now: the turn's, plus the scratch folder once it is ready. */
832
+ private rootsNow(): readonly string[] {
833
+ return toolRoots({
834
+ workingDirectory: this.config.workingDirectory,
835
+ additionalDirectories: this.additionalDirectoriesNow(),
836
+ })
837
+ }
838
+
839
+ private additionalDirectoriesNow(): readonly string[] | undefined {
840
+ if (this.scratchpadReady === undefined) return this.config.additionalDirectories
841
+ return [...(this.config.additionalDirectories ?? []), this.scratchpadReady]
842
+ }
843
+
844
+ /**
845
+ * Make the session's scratch folder a root when a call names it. Lazy: a
846
+ * session that never uses it leaves nothing on disk. The folder must be
847
+ * inside the private temporary root with no link on the way; otherwise it
848
+ * stays outside the roots and a path in it is asked about like any other.
849
+ */
850
+ private admitScratchpad(call: PreparedSafetyCall): void {
851
+ const directory = this.config.scratchpadDirectory
852
+ if (directory === undefined || this.scratchpadReady !== undefined || this.sandboxed) return
853
+ if (call.kind === 'synthetic') return
854
+ let named = false
855
+ try {
856
+ named = JSON.stringify(call.input)?.includes(JSON.stringify(directory).slice(1, -1)) === true
857
+ } catch {
858
+ // An input that cannot be serialised names nothing.
859
+ }
860
+ if (!named) return
861
+ try {
862
+ this.scratchpadReady = ensureScratchpadDirectory(directory)
863
+ } catch (error) {
864
+ this.log.warn('The session scratch folder could not be made, so it stays outside the roots', {
865
+ 'exception.message': error instanceof Error ? error.message : String(error),
866
+ })
867
+ }
868
+ }
869
+
870
+ /** Whether `call` writes a file inside the ready scratch folder and nothing else about it is notable. */
871
+ private async writesScratchpad(
872
+ call: PreparedSafetyCall,
873
+ escalation: ToolCallEscalation | undefined,
874
+ ): Promise<boolean> {
875
+ const root = this.scratchpadReady
876
+ if (root === undefined || escalation !== undefined || call.kind === 'synthetic') return false
877
+ if (call.toolName !== 'write' && call.toolName !== 'edit') return false
878
+ const tool = this.config.tools.get(call.toolName)
879
+ if (!tool?.pathArgument || call.input === null || typeof call.input !== 'object') return false
880
+ const value = (call.input as Record<string, unknown>)[tool.pathArgument]
881
+ if (typeof value !== 'string' || !isAbsolute(value)) return false
882
+ try {
883
+ await resolveWithinAnyReal([root], value)
884
+ return true
885
+ } catch {
886
+ return false
887
+ }
888
+ }
889
+
817
890
  private get sandboxed(): boolean {
818
891
  return this.config.sandbox !== undefined || this.sandboxGate !== undefined
819
892
  }
@@ -1126,6 +1199,7 @@ export class ToolExecutor {
1126
1199
  if ((response.message.toolCalls ?? []).length > 0) await this.awaitSandbox()
1127
1200
  const calls = new Map<string, PreparedDirectCall>()
1128
1201
  const escalations = new Map<string, ToolCallEscalation>()
1202
+ const scratchpadWrites = new Set<string>()
1129
1203
  const earlierPaths: string[] = []
1130
1204
  for (const toolCall of response.message.toolCalls ?? []) {
1131
1205
  const prepared = await prepareDirectCall(
@@ -1137,11 +1211,13 @@ export class ToolExecutor {
1137
1211
  const path = inputPathOf(prepared)
1138
1212
  if (path !== undefined) earlierPaths.push(path)
1139
1213
  calls.set(toolCall.id, call)
1214
+ this.admitScratchpad(call)
1140
1215
  const escalation = await this.escalationOrUnfinished(call)
1141
1216
  if (escalation) escalations.set(toolCall.id, escalation)
1217
+ if (await this.writesScratchpad(call, escalation)) scratchpadWrites.add(toolCall.id)
1142
1218
  }
1143
1219
  this.config.abortSignal.throwIfAborted()
1144
- return this.publishPreparedBatch(calls, escalations)
1220
+ return this.publishPreparedBatch(calls, escalations, scratchpadWrites)
1145
1221
  }
1146
1222
 
1147
1223
  /**
@@ -1250,7 +1326,7 @@ export class ToolExecutor {
1250
1326
  // result that was cut told the model to look.
1251
1327
  const retained =
1252
1328
  tool.name === 'read' && (await resolveRetainedOutput(this.outputDirectory(), value))
1253
- const outside = retained ? undefined : await pathOutsideRoots(toolRoots(this.config), value)
1329
+ const outside = retained ? undefined : await pathOutsideRoots(this.rootsNow(), value)
1254
1330
  if (outside !== undefined) outsidePaths = [outside]
1255
1331
  }
1256
1332
  }
@@ -1307,7 +1383,7 @@ export class ToolExecutor {
1307
1383
  protectedPaths: readonly string[]
1308
1384
  dangerousRemoval?: string
1309
1385
  }> {
1310
- const roots = toolRoots(this.config)
1386
+ const roots = this.rootsNow()
1311
1387
  const protectedPaths: string[] = []
1312
1388
  let outsidePaths: readonly string[] = []
1313
1389
  let removal: string | undefined
@@ -1376,6 +1452,7 @@ export class ToolExecutor {
1376
1452
  }
1377
1453
  const calls = new Map((previous as OwnedPreparedToolBatch).calls)
1378
1454
  const escalations = new Map((previous as OwnedPreparedToolBatch).escalations)
1455
+ const scratchpadWrites = new Set((previous as OwnedPreparedToolBatch).scratchpadWrites)
1379
1456
  const earlierPaths: string[] = []
1380
1457
  for (const toolCall of response.message.toolCalls ?? []) {
1381
1458
  if (changedCallIds.has(toolCall.id)) {
@@ -1384,21 +1461,25 @@ export class ToolExecutor {
1384
1461
  earlierPaths,
1385
1462
  )
1386
1463
  calls.set(toolCall.id, call)
1464
+ this.admitScratchpad(call)
1387
1465
  const escalation = await this.escalationOrUnfinished(call)
1388
1466
  if (escalation) escalations.set(toolCall.id, escalation)
1389
1467
  else escalations.delete(toolCall.id)
1468
+ if (await this.writesScratchpad(call, escalation)) scratchpadWrites.add(toolCall.id)
1469
+ else scratchpadWrites.delete(toolCall.id)
1390
1470
  }
1391
1471
  const prepared = calls.get(toolCall.id)
1392
1472
  const path = prepared ? inputPathOf(prepared) : undefined
1393
1473
  if (path !== undefined) earlierPaths.push(path)
1394
1474
  }
1395
1475
  this.config.abortSignal.throwIfAborted()
1396
- return this.publishPreparedBatch(calls, escalations)
1476
+ return this.publishPreparedBatch(calls, escalations, scratchpadWrites)
1397
1477
  }
1398
1478
 
1399
1479
  private publishPreparedBatch(
1400
1480
  calls: ReadonlyMap<string, PreparedDirectCall>,
1401
1481
  escalations: ReadonlyMap<string, ToolCallEscalation>,
1482
+ scratchpadWrites: ReadonlySet<string>,
1402
1483
  ): PreparedToolBatch {
1403
1484
  const reviewCalls = [...calls.values()]
1404
1485
  .filter(
@@ -1414,12 +1495,14 @@ export class ToolExecutor {
1414
1495
  input: call.input,
1415
1496
  ...(escalation ? { escalation } : {}),
1416
1497
  ...(hookPermission ? { hookPermission } : {}),
1498
+ ...(scratchpadWrites.has(call.toolCall.id) ? { scratchpadWrite: true as const } : {}),
1417
1499
  }
1418
1500
  })
1419
1501
  const batch: OwnedPreparedToolBatch = Object.freeze({
1420
1502
  reviewCalls: Object.freeze(reviewCalls),
1421
1503
  calls: new Map(calls),
1422
1504
  escalations: new Map(escalations),
1505
+ scratchpadWrites: new Set(scratchpadWrites),
1423
1506
  })
1424
1507
  this.preparedBatches.add(batch)
1425
1508
  return batch
@@ -2032,6 +2115,7 @@ export class ToolExecutor {
2032
2115
  } catch {
2033
2116
  needsApproval = true
2034
2117
  }
2118
+ this.admitScratchpad(preparedCall)
2035
2119
  const nestedEscalation = await this.escalationOrUnfinished(preparedCall)
2036
2120
  if (nestedEscalation || needsApproval || (gateResult && gateResult.decision !== 'allow')) {
2037
2121
  const reason = nestedEscalation
@@ -2276,8 +2360,8 @@ export class ToolExecutor {
2276
2360
  sessionId: this.config.sessionId,
2277
2361
  turnId: this.config.turnId,
2278
2362
  workingDirectory: this.config.workingDirectory,
2279
- ...(this.config.additionalDirectories?.length
2280
- ? { additionalDirectories: this.config.additionalDirectories }
2363
+ ...(this.additionalDirectoriesNow()?.length
2364
+ ? { additionalDirectories: this.additionalDirectoriesNow() }
2281
2365
  : {}),
2282
2366
  ...(this.config.authorizationGate
2283
2367
  ? {
@@ -1542,6 +1542,17 @@ export async function* query(params: QueryParams): AsyncGenerator<SessionEvent,
1542
1542
  // pre-approves ends with the turn. Shared by the executor, where the
1543
1543
  // `skill` tool records a grant, and the review phase, which reads it.
1544
1544
  const skillGrants = new SkillGrantSet()
1545
+ // The session's own scratch folder, a root of the file tools once a call
1546
+ // names it. Not for a sandboxed turn (it is not mounted there), and not
1547
+ // for a session whose state is not on disk.
1548
+ let scratchpadDirectory: string | undefined
1549
+ if (!params.sandboxProvider) {
1550
+ try {
1551
+ scratchpadDirectory = ctx.storage.paths?.scratchpadPath(ctx.sessionId)
1552
+ } catch {
1553
+ scratchpadDirectory = undefined
1554
+ }
1555
+ }
1545
1556
  const toolExecutor = ToolingBootstrap.init(
1546
1557
  {
1547
1558
  skillGrants,
@@ -1555,6 +1566,7 @@ export async function* query(params: QueryParams): AsyncGenerator<SessionEvent,
1555
1566
  ...(params.additionalDirectories?.length
1556
1567
  ? { additionalDirectories: params.additionalDirectories }
1557
1568
  : {}),
1569
+ ...(scratchpadDirectory ? { scratchpadDirectory } : {}),
1558
1570
  ...(params.outsideRootAccess ? { outsideRootAccess: params.outsideRootAccess } : {}),
1559
1571
  ...(params.sandboxEscape ? { sandboxEscape: params.sandboxEscape } : {}),
1560
1572
  permissionMode: () => ctx.permissionMode.current,
@@ -73,6 +73,7 @@ import {
73
73
  markProviderRejectedImage,
74
74
  projectRequestRichContent,
75
75
  } from '../request-rich-content.js'
76
+ import { withoutVoidAssistantTurns } from '../request-void-turns.js'
76
77
  import { formatSteeringNote, isOperatorUserMessage } from '../steering.js'
77
78
  import { effortForModel } from './effort-clamp.js'
78
79
  import { parseNativeCandidate } from './native-output.js'
@@ -929,7 +930,7 @@ export class IterationOrchestrator {
929
930
  // and a mode change would re-key the whole conversation.
930
931
  if (policyNotice) requestHistory.push(stepContextMessage(policyNotice))
931
932
  const projected = projectRequestRichContent(
932
- this.projectObservations(requestHistory),
933
+ withoutVoidAssistantTurns(this.projectObservations(requestHistory)),
933
934
  this.ctx.turnConfig.maxRequestRichContentBytes ??
934
935
  DEFAULT_MAX_REQUEST_RICH_CONTENT_BYTES,
935
936
  { imagesReadable: this.modelReadsImages(stepModel) },
@@ -3155,7 +3156,7 @@ export class IterationOrchestrator {
3155
3156
  ...(workingMemory.context ? [workingMemory.context] : []),
3156
3157
  ]
3157
3158
  const finalMessages = projectRequestRichContent(
3158
- this.projectObservations(finalHistory),
3159
+ withoutVoidAssistantTurns(this.projectObservations(finalHistory)),
3159
3160
  this.ctx.turnConfig.maxRequestRichContentBytes ?? DEFAULT_MAX_REQUEST_RICH_CONTENT_BYTES,
3160
3161
  { imagesReadable: this.modelReadsImages(model) },
3161
3162
  )
@@ -238,6 +238,12 @@ export async function* runToolReview(
238
238
  ...(requiresApproval ? { requiresApproval: true as const } : {}),
239
239
  ...(escalation ? { escalation } : {}),
240
240
  ...(hookPermission ? { hookPermission } : {}),
241
+ ...('scratchpadWrite' in tc && tc.scratchpadWrite
242
+ ? { scratchpadWrite: true as const }
243
+ : {}),
244
+ ...('scratchpadWrite' in tc && tc.scratchpadWrite
245
+ ? { scratchpadWrite: true as const }
246
+ : {}),
241
247
  }
242
248
  })
243
249
  }
@@ -0,0 +1,28 @@
1
+ import type { Message } from '../../types/message/index.js'
2
+
3
+ /**
4
+ * The provider-bound view of a conversation without assistant entries that
5
+ * carry nothing at all: no text, no tool call, no reasoning, no citation.
6
+ *
7
+ * An empty reply (a refused or cut-off model, or a completion that produced
8
+ * nothing) is recorded in the history, and the loop goes on to ask again, so
9
+ * the next request would carry it. Chat Completions endpoints reject such an
10
+ * entry ("content or tool_calls must be set"), and no wire gives it a meaning.
11
+ * The stored history is never edited; only the request leaves it out.
12
+ *
13
+ * An entry that still holds reasoning stays: a driver whose wire replays signed
14
+ * or native reasoning needs it, and each driver decides what a reasoning-only
15
+ * entry looks like on its own wire. An entry with tool calls always stays, so a
16
+ * tool result still follows the call it answers. Returns the original array
17
+ * when nothing is dropped.
18
+ */
19
+ export function withoutVoidAssistantTurns(messages: Message[]): Message[] {
20
+ const isVoid = (message: Message): boolean =>
21
+ message.role === 'assistant' &&
22
+ !message.content &&
23
+ (message.toolCalls?.length ?? 0) === 0 &&
24
+ (message.reasoning?.length ?? 0) === 0 &&
25
+ (message.citations?.length ?? 0) === 0 &&
26
+ (message.textParts?.length ?? 0) === 0
27
+ return messages.some(isVoid) ? messages.filter((message) => !isVoid(message)) : messages
28
+ }
@@ -1051,8 +1051,19 @@ export function createReviewHandler(options: ReviewPolicyOptions = {}): ResumeHa
1051
1051
  mode === 'plan' &&
1052
1052
  options.planModeWrite?.(tc) === true &&
1053
1053
  tc.authorization?.decision !== 'deny'
1054
+ // A file written inside the session's own scratch folder, in any mode but
1055
+ // `strict`: the folder is the session's, so it is not a change anyone
1056
+ // reviews, and not "outside the roots". A rule that asked for review keeps
1057
+ // the call in the batch; a deny still refuses it.
1058
+ const isScratchpadWrite = (tc: ToolCallSummary): boolean =>
1059
+ mode !== 'strict' &&
1060
+ tc.scratchpadWrite === true &&
1061
+ tc.escalation === undefined &&
1062
+ tc.requiresApproval !== true &&
1063
+ !tc.authorization?.explicitReview &&
1064
+ tc.authorization?.decision !== 'deny'
1054
1065
  const reviewable = request.toolCalls.filter(
1055
- (tc) => !(isPlanFileWrite(tc) && !tc.authorization?.explicitReview),
1066
+ (tc) => !(isPlanFileWrite(tc) && !tc.authorization?.explicitReview) && !isScratchpadWrite(tc),
1056
1067
  )
1057
1068
  if (!batchNeedsReview(reviewable, exempt)) {
1058
1069
  return { action: 'approve_tools' }
@@ -1089,7 +1100,7 @@ export function createReviewHandler(options: ReviewPolicyOptions = {}): ResumeHa
1089
1100
  if (
1090
1101
  mode === 'plan' &&
1091
1102
  !onlyReadsOutsideRoots(
1092
- reviewable.filter((tc) => !isPlanFileWrite(tc)),
1103
+ reviewable.filter((tc) => !isPlanFileWrite(tc) && !isScratchpadWrite(tc)),
1093
1104
  exempt,
1094
1105
  )
1095
1106
  ) {
@@ -28,6 +28,8 @@ export interface ToolingBootstrapConfig {
28
28
  workingDirectory: string
29
29
  /** See `QueryParams.additionalDirectories`. */
30
30
  additionalDirectories?: readonly string[]
31
+ /** See `ToolExecutorConfig.scratchpadDirectory`. */
32
+ scratchpadDirectory?: string
31
33
  /** See `QueryParams.outsideRootAccess`. */
32
34
  outsideRootAccess?: 'refuse' | 'review'
33
35
  /** See `QueryParams.sandboxEscape`. */
@@ -99,6 +101,7 @@ export class ToolingBootstrap {
99
101
  ...(config.additionalDirectories?.length
100
102
  ? { additionalDirectories: config.additionalDirectories }
101
103
  : {}),
104
+ ...(config.scratchpadDirectory ? { scratchpadDirectory: config.scratchpadDirectory } : {}),
102
105
  ...(config.outsideRootAccess ? { outsideRootAccess: config.outsideRootAccess } : {}),
103
106
  ...(config.sandboxEscape ? { sandboxEscape: config.sandboxEscape } : {}),
104
107
  permissionMode: config.permissionMode,
@@ -53,6 +53,7 @@ import {
53
53
  applyEnvironmentOverrides,
54
54
  pickEnvironmentEntries,
55
55
  } from '../../utils/process-environment.js'
56
+ import { createTempDir, resolveTempBase } from '../../utils/temp.js'
56
57
  import { walkFilesLocally } from '../file-walk.js'
57
58
  import { assertIsolation, describeIsolation } from '../isolation.js'
58
59
  import type { PtyLoader } from '../terminal.js'
@@ -317,7 +318,7 @@ function detectEnvironment(): DetectedEnvironment {
317
318
  // hosts that mediate namespace helpers and can claim a tier production
318
319
  // cannot actually drive.
319
320
  const bwrap = resolveTrustedWrapper('bwrap')
320
- if (bwrap !== undefined && probeBwrapSpawn(bwrap, buildBwrapArgs(tmpdir()))) {
321
+ if (bwrap !== undefined && probeBwrapSpawn(bwrap, buildBwrapArgs(resolveTempBase()))) {
321
322
  return { environment: 'linux-bwrap', wrapperCommand: bwrap }
322
323
  }
323
324
 
@@ -329,7 +330,10 @@ function detectEnvironment(): DetectedEnvironment {
329
330
 
330
331
  if (platform === 'darwin') {
331
332
  const seatbelt = resolveTrustedWrapper('sandbox-exec')
332
- if (seatbelt !== undefined && probeSandboxSpawn(seatbelt, buildSeatbeltArgs(tmpdir()))) {
333
+ if (
334
+ seatbelt !== undefined &&
335
+ probeSandboxSpawn(seatbelt, buildSeatbeltArgs(resolveTempBase()))
336
+ ) {
333
337
  return { environment: 'macos-seatbelt', wrapperCommand: seatbelt }
334
338
  }
335
339
  }
@@ -1091,7 +1095,7 @@ class LocalSandbox implements Sandbox {
1091
1095
  exitCode,
1092
1096
  stderr: stderr.text,
1093
1097
  timedOut,
1094
- allowedRoots: [...this.roots, tmpdir(), '/tmp', '/var/tmp', '/dev'],
1098
+ allowedRoots: [...this.roots, tmpdir(), resolveTempBase(), '/tmp', '/var/tmp', '/dev'],
1095
1099
  })
1096
1100
  resolvePromise({
1097
1101
  exitCode,
@@ -1229,8 +1233,7 @@ export class LocalSandboxProvider implements SandboxProvider {
1229
1233
  }
1230
1234
  } else {
1231
1235
  // No workspace was named: this allocation owns a fresh temporary root.
1232
- const { mkdtemp } = await import('node:fs/promises')
1233
- const rawDir = await mkdtemp(join(tmpdir(), SANDBOX_TEMP_DIR_PREFIX))
1236
+ const rawDir = await createTempDir(SANDBOX_TEMP_DIR_PREFIX)
1234
1237
  rootDir = canonicalizePath(rawDir)
1235
1238
  removeRootOnDestroy = true
1236
1239
  }
@@ -1,7 +1,5 @@
1
1
  import { createHash } from 'node:crypto'
2
- import { lstatSync, mkdirSync } from 'node:fs'
3
2
  import { link, mkdir, open, readFile, realpath, unlink } from 'node:fs/promises'
4
- import { tmpdir, userInfo } from 'node:os'
5
3
  import { basename, dirname, join } from 'node:path'
6
4
  import type {
7
5
  CheckpointId,
@@ -14,6 +12,12 @@ import type {
14
12
  } from '../types/ids/index.js'
15
13
  import { type ProjectDocument, ProjectDocumentSchema } from '../types/session/records.js'
16
14
  import { type EntityIdKind, asSessionId, generateProjectId, isEntityId } from '../utils/id.js'
15
+ import {
16
+ type PrivateTempRootOptions,
17
+ TempDirectoryError,
18
+ privateTempRoot,
19
+ privateTempRootPath,
20
+ } from '../utils/temp.js'
17
21
  import { uuidv7 } from '../utils/uuidv7.js'
18
22
 
19
23
  /**
@@ -243,68 +247,26 @@ export async function ensureProject(options: EnsureProjectOptions): Promise<Ensu
243
247
  )
244
248
  }
245
249
 
246
- export interface TempRootOptions {
247
- /** Base directory. Default `os.tmpdir()`. */
248
- readonly tmpdir?: string
249
- /** Default `process.platform`. */
250
- readonly platform?: NodeJS.Platform
251
- /**
252
- * Default `process.getuid`. Pass `undefined` explicitly to model a
253
- * platform without it (win32).
254
- */
255
- readonly getuid?: (() => number) | undefined
256
- /** Default `os.userInfo().username`. */
257
- readonly username?: () => string
258
- }
250
+ /** See {@link PrivateTempRootOptions}; `tmpdir` is the base. */
251
+ export type TempRootOptions = PrivateTempRootOptions
259
252
 
260
253
  /**
261
- * The per-user temporary root, `$TMPDIR/namzu-<user>`, created if missing.
262
- *
263
- * `<user>` is the numeric uid where the platform has one; otherwise the first
264
- * 12 hex digits of the SHA-256 of the user name; otherwise `user`. On POSIX
265
- * the directory is created with mode 0700 and refused if it is a symlink, not
266
- * a directory, owned by another uid, or open to group or others. On win32
267
- * `%TEMP%` is already per user: a symlink or junction is refused and the
268
- * owner check is skipped.
254
+ * The per-user temporary root, `<base>/namzu-<user>`, created if missing,
255
+ * where the base is `NAMZU_TMPDIR` or `os.tmpdir()`. A root that is a symlink,
256
+ * not a directory, owned by another uid or open to others is refused with a
257
+ * {@link SessionPathError}; one that cannot be created raises the
258
+ * {@link TempDirectoryError} that names the directory and `NAMZU_TMPDIR`.
259
+ * See {@link privateTempRoot}.
269
260
  */
270
261
  export function tempRoot(options: TempRootOptions = {}): string {
271
- const platform = options.platform ?? process.platform
272
- const getuid = 'getuid' in options ? options.getuid : process.getuid?.bind(process)
273
- let user = 'user'
274
- if (typeof getuid === 'function') {
275
- user = String(getuid())
276
- } else {
277
- try {
278
- const name = (options.username ?? (() => userInfo().username))()
279
- if (name.length > 0) user = sha256Hex(name).slice(0, 12)
280
- } catch {
281
- // No user database entry: fall back to the fixed segment.
282
- }
283
- }
284
- const root = join(options.tmpdir ?? tmpdir(), `namzu-${user}`)
285
262
  try {
286
- mkdirSync(root, { mode: 0o700 })
263
+ return privateTempRoot(options)
287
264
  } catch (error) {
288
- if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
289
- }
290
- const entry = lstatSync(root)
291
- if (entry.isSymbolicLink()) {
292
- throw new SessionPathError(`Refusing temporary root ${root}: it is a symbolic link.`)
293
- }
294
- if (!entry.isDirectory()) {
295
- throw new SessionPathError(`Refusing temporary root ${root}: it is not a directory.`)
296
- }
297
- if (platform !== 'win32' && typeof getuid === 'function') {
298
- if (entry.uid !== getuid()) {
299
- throw new SessionPathError(`Refusing temporary root ${root}: it belongs to uid ${entry.uid}.`)
300
- }
301
- if ((entry.mode & 0o077) !== 0) {
302
- throw new SessionPathError(
303
- `Refusing temporary root ${root}: mode ${(entry.mode & 0o777).toString(8)} lets other users in; it must be 700.`,
304
- )
265
+ if (error instanceof TempDirectoryError && error.reason === 'refused') {
266
+ throw new SessionPathError(error.message)
305
267
  }
268
+ throw error
306
269
  }
307
- return root
308
270
  }
309
271
 
310
272
  /** Every path of one project's layout. Pure: nothing is created or read. */
@@ -451,9 +413,22 @@ export class SessionPaths {
451
413
  return join(this.sessionDir(locator), 'lease.json')
452
414
  }
453
415
 
454
- /** `$TMPDIR/namzu-<user>/<slug>/<session-id>/scratchpad`. Not created; the per-user root is. */
416
+ /** `<temp base>/namzu-<user>/<slug>/<session-id>/scratchpad`. Not created; the per-user root is. */
455
417
  tempDir(sessionId: SessionId): string {
456
418
  this.#tempRoot ??= tempRoot()
457
419
  return join(this.#tempRoot, this.slug, checkId(sessionId, 'session'), 'scratchpad')
458
420
  }
421
+
422
+ /**
423
+ * Where {@link tempDir} is, creating and checking nothing, not even the
424
+ * per-user root: for a caller that makes the folder only when it is first used.
425
+ */
426
+ scratchpadPath(sessionId: SessionId): string {
427
+ return join(
428
+ this.#tempRoot ?? privateTempRootPath(),
429
+ this.slug,
430
+ checkId(sessionId, 'session'),
431
+ 'scratchpad',
432
+ )
433
+ }
459
434
  }