@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
@@ -211,8 +211,59 @@ export interface SessionLog {
211
211
  /** The folded conversation (spec §4.5), spills read back. */
212
212
  messages(options?: { readonly throughSeq?: number }): Promise<Message[]>
213
213
  readSpill(ref: SpillRef, options?: SpillReadOptions): Promise<string>
214
+ /**
215
+ * Be told when the log stops being able to write (the disk is full) and
216
+ * when it can write again. Returns the function that stops the
217
+ * notifications. A log that cannot degrade does not offer this.
218
+ */
219
+ onWriteHealth?(listener: (change: SessionLogWriteHealth) => void): () => void
220
+ }
221
+
222
+ /** Why a log's writes are failing; each is a full or unwritable volume. */
223
+ export type SessionLogDegradedCause = 'disk_full' | 'quota_exceeded' | 'read_only'
224
+
225
+ /**
226
+ * A change in whether a log can write. While `degraded` the session goes on
227
+ * in memory and the records not yet written wait in a bounded buffer.
228
+ */
229
+ export type SessionLogWriteHealth =
230
+ | {
231
+ readonly state: 'degraded'
232
+ readonly cause: SessionLogDegradedCause
233
+ /** Records waiting to be written, including the one that failed. */
234
+ readonly bufferedRecords: number
235
+ readonly bufferedBytes: number
236
+ /**
237
+ * Set on the one notification sent when the buffer first overflowed
238
+ * and the oldest waiting records were discarded.
239
+ */
240
+ readonly dropping?: true
241
+ }
242
+ | {
243
+ readonly state: 'recovered'
244
+ /** Records written from the buffer, in order. */
245
+ readonly flushedRecords: number
246
+ /** Records discarded while the buffer was full; the log has a gap where they were. */
247
+ readonly droppedRecords: number
248
+ }
249
+
250
+ /** The cause when `error` is the volume refusing writes, else `undefined`. */
251
+ export function sessionLogDegradedCause(error: unknown): SessionLogDegradedCause | undefined {
252
+ switch ((error as NodeJS.ErrnoException | undefined)?.code) {
253
+ case 'ENOSPC':
254
+ return 'disk_full'
255
+ case 'EDQUOT':
256
+ return 'quota_exceeded'
257
+ case 'EROFS':
258
+ return 'read_only'
259
+ default:
260
+ return undefined
261
+ }
214
262
  }
215
263
 
264
+ /** Default bound on records held in memory while a log cannot write. */
265
+ export const DEFAULT_DEGRADED_BUFFER_BYTES = 8 * 1024 * 1024
266
+
216
267
  /** A draft that is not a valid session record once enveloped. */
217
268
  export class InvalidSessionRecordError extends Error {
218
269
  override readonly name = 'InvalidSessionRecordError'
@@ -271,6 +322,12 @@ export interface SessionLogCoreOptions {
271
322
  * and decisions. `all` or `none` as named.
272
323
  */
273
324
  readonly sync?: 'boundaries' | 'all' | 'none'
325
+ /**
326
+ * Bytes of records kept in memory while the volume refuses writes (full,
327
+ * over quota, read-only). Past it the oldest are discarded. Default
328
+ * {@link DEFAULT_DEGRADED_BUFFER_BYTES}.
329
+ */
330
+ readonly degradedBufferBytes?: number
274
331
  }
275
332
 
276
333
  const BOUNDARY_TYPES: ReadonlySet<string> = new Set([
@@ -289,6 +346,8 @@ const BOUNDARY_TYPES: ReadonlySet<string> = new Set([
289
346
 
290
347
  /** Characters of a spilled message kept inline as its preview. */
291
348
  const PREVIEW_CHARS = 2000
349
+ /** Added to a preview whose full body could not be written. */
350
+ const NOT_SAVED_NOTE = '[… the rest of this output could not be saved: the disk is full]'
292
351
  /** Default byte bound above which a `compaction` record's summary is spilled. */
293
352
  const COMPACTION_SPILL_ABOVE_BYTES = 256 * 1024
294
353
 
@@ -339,6 +398,17 @@ export class SessionLogCore implements SessionLog {
339
398
  /** Bytes of the log this instance has verified and applied. */
340
399
  #synced = 0
341
400
  #torn = 0
401
+ readonly #degradedBufferBytes: number
402
+ /** Records accepted into the chain that the medium has not taken yet, oldest first. */
403
+ #pending: { readonly bytes: Buffer; readonly sync: boolean }[] = []
404
+ #pendingBytes = 0
405
+ /** Bytes the medium holds while records are pending. */
406
+ #diskBytes = 0
407
+ #degradedCause: SessionLogDegradedCause | undefined
408
+ #droppedRecords = 0
409
+ #droppedNoticeSent = false
410
+ #flushedWhileDegraded = 0
411
+ readonly #healthListeners = new Set<(change: SessionLogWriteHealth) => void>()
342
412
 
343
413
  constructor(options: SessionLogCoreOptions) {
344
414
  this.sessionId = options.sessionId
@@ -362,12 +432,40 @@ export class SessionLogCore implements SessionLog {
362
432
  this.#spillAbove,
363
433
  )
364
434
  this.#sync = options.sync ?? 'boundaries'
435
+ this.#degradedBufferBytes = Math.max(
436
+ 0,
437
+ options.degradedBufferBytes ?? DEFAULT_DEGRADED_BUFFER_BYTES,
438
+ )
439
+ }
440
+
441
+ onWriteHealth(listener: (change: SessionLogWriteHealth) => void): () => void {
442
+ this.#healthListeners.add(listener)
443
+ return () => {
444
+ this.#healthListeners.delete(listener)
445
+ }
446
+ }
447
+
448
+ #notify(change: SessionLogWriteHealth): void {
449
+ for (const listener of this.#healthListeners) {
450
+ try {
451
+ listener(change)
452
+ } catch {
453
+ // A listener that throws must not turn a survived write into a failed one.
454
+ }
455
+ }
456
+ }
457
+
458
+ /** True while records wait in memory because the volume refuses writes. */
459
+ get degraded(): boolean {
460
+ return this.#degradedCause !== undefined
365
461
  }
366
462
 
367
463
  // ── state ──
368
464
 
369
465
  /** Catch up with the medium: verify and apply whatever was appended since the last sync. */
370
466
  async #catchUp(): Promise<void> {
467
+ // Records waiting for the disk are ahead of it; the lease keeps others out.
468
+ if (this.#pending.length > 0) return
371
469
  const size = await this.#medium.size()
372
470
  if (size === this.#synced + this.#torn && size >= this.#synced) return
373
471
  if (size < this.#synced) this.#reset()
@@ -395,6 +493,8 @@ export class SessionLogCore implements SessionLog {
395
493
  this.#turns = new SessionTurnState()
396
494
  this.#synced = 0
397
495
  this.#torn = 0
496
+ this.#pending = []
497
+ this.#pendingBytes = 0
398
498
  }
399
499
 
400
500
  async #requireCurrent(lease: SessionLease): Promise<void> {
@@ -461,6 +561,8 @@ export class SessionLogCore implements SessionLog {
461
561
  */
462
562
  release(lease: SessionLease): Promise<void> {
463
563
  return this.#mutex.run(async () => {
564
+ // One last try to save what the disk refused earlier.
565
+ if (this.#pending.length > 0) await this.#flushPending().catch(() => undefined)
464
566
  if (this.#held?.fence === lease.fence) {
465
567
  this.#held = undefined
466
568
  untrackHeldSessionLease(this.#holding)
@@ -655,13 +757,126 @@ export class SessionLogCore implements SessionLog {
655
757
  }
656
758
  const bytes = Buffer.from(line, 'utf8')
657
759
  const sync = this.#sync === 'all' || (this.#sync === 'boundaries' && BOUNDARY_TYPES.has(type))
658
- await this.#medium.append(bytes, this.#synced, sync)
760
+ await this.#persist(bytes, sync)
659
761
  const entry = this.#chain.accept(bytes, this.#synced)
660
762
  this.#turns.apply(entry.record)
661
763
  this.#synced += bytes.byteLength
662
764
  return entry
663
765
  }
664
766
 
767
+ /**
768
+ * Hand a record's bytes to the medium. A volume that refuses writes (full,
769
+ * over quota, read-only) does not fail the record: it waits in a bounded
770
+ * buffer and goes out, in order, with the next record that can be written.
771
+ * Any other error is thrown as before. Caller holds the mutex.
772
+ */
773
+ async #persist(bytes: Buffer, sync: boolean): Promise<void> {
774
+ if (this.#pending.length === 0) {
775
+ try {
776
+ await this.#medium.append(bytes, this.#synced, sync)
777
+ return
778
+ } catch (error) {
779
+ const cause = sessionLogDegradedCause(error)
780
+ if (cause === undefined) throw error
781
+ this.#diskBytes = this.#synced
782
+ this.#degradedCause = cause
783
+ this.#bufferRecord(bytes, sync)
784
+ this.#notify({
785
+ state: 'degraded',
786
+ cause,
787
+ bufferedRecords: this.#pending.length,
788
+ bufferedBytes: this.#pendingBytes,
789
+ })
790
+ return
791
+ }
792
+ }
793
+ const queued = this.#bufferRecord(bytes, sync)
794
+ try {
795
+ await this.#flushPending()
796
+ } catch (error) {
797
+ // Only the volume refusing writes keeps the session going; anything else
798
+ // fails this record, which must then not stay queued.
799
+ if (sessionLogDegradedCause(error) === undefined) {
800
+ const at = this.#pending.indexOf(queued)
801
+ if (at !== -1) {
802
+ this.#pending.splice(at, 1)
803
+ this.#pendingBytes -= queued.bytes.byteLength
804
+ }
805
+ throw error
806
+ }
807
+ }
808
+ }
809
+
810
+ #bufferRecord(bytes: Buffer, sync: boolean): { readonly bytes: Buffer; readonly sync: boolean } {
811
+ const queued = { bytes, sync }
812
+ this.#pending.push(queued)
813
+ this.#pendingBytes += bytes.byteLength
814
+ let dropped = false
815
+ // The newest record always stays, whatever the bound.
816
+ while (this.#pendingBytes > this.#degradedBufferBytes && this.#pending.length > 1) {
817
+ const oldest = this.#pending.shift()
818
+ if (oldest === undefined) break
819
+ this.#pendingBytes -= oldest.bytes.byteLength
820
+ this.#droppedRecords += 1
821
+ dropped = true
822
+ }
823
+ if (dropped && !this.#droppedNoticeSent && this.#degradedCause !== undefined) {
824
+ this.#droppedNoticeSent = true
825
+ this.#notify({
826
+ state: 'degraded',
827
+ cause: this.#degradedCause,
828
+ bufferedRecords: this.#pending.length,
829
+ bufferedBytes: this.#pendingBytes,
830
+ dropping: true,
831
+ })
832
+ }
833
+ return queued
834
+ }
835
+
836
+ /** Write the buffer in order. Throws the error that stopped it; clears the degraded mark when it empties. */
837
+ async #flushPending(): Promise<void> {
838
+ // A refused write may have left part of a record behind; cut it back.
839
+ const size = await this.#medium.size()
840
+ if (size > this.#diskBytes) await this.#medium.truncate(this.#diskBytes, size)
841
+ while (this.#pending.length > 0) {
842
+ const next = this.#pending[0]
843
+ if (next === undefined) break
844
+ await this.#medium.append(next.bytes, this.#diskBytes, next.sync)
845
+ this.#diskBytes += next.bytes.byteLength
846
+ this.#pendingBytes -= next.bytes.byteLength
847
+ this.#pending.shift()
848
+ this.#flushedWhileDegraded += 1
849
+ }
850
+ const flushed = this.#flushedWhileDegraded
851
+ this.#flushedWhileDegraded = 0
852
+ const dropped = this.#droppedRecords
853
+ this.#degradedCause = undefined
854
+ this.#droppedRecords = 0
855
+ this.#droppedNoticeSent = false
856
+ this.#pendingBytes = 0
857
+ this.#notify({ state: 'recovered', flushedRecords: flushed, droppedRecords: dropped })
858
+ }
859
+
860
+ /**
861
+ * Write a message body beside the log. When the volume refuses it the body
862
+ * is given up (`undefined`) rather than the record,; the record's own write
863
+ * then meets the same refusal and degrades the log.
864
+ */
865
+ async #spillOrLose(
866
+ key: string,
867
+ content: Parameters<SpillStore['write']>[1],
868
+ text: string,
869
+ ): Promise<SpillRef | undefined> {
870
+ try {
871
+ return await this.#spills.write(key, content, text)
872
+ } catch (error) {
873
+ const cause = sessionLogDegradedCause(error)
874
+ if (cause === undefined) throw error
875
+ // The record's own write, next, finds the volume refusing and degrades the log.
876
+ return undefined
877
+ }
878
+ }
879
+
665
880
  async #spillIfLarge(original: Record<string, unknown>): Promise<Record<string, unknown>> {
666
881
  const candidate = this.#structuredResultSpilling
667
882
  ? await this.#spillStructuredResults(original)
@@ -684,11 +899,17 @@ export class SessionLogCore implements SessionLog {
684
899
  if (size <= spillAbove) return candidate
685
900
  if (type === 'turn_completed') {
686
901
  if (typeof candidate.result !== 'string') return candidate
687
- const ref = await this.#spills.write(
902
+ const ref = await this.#spillOrLose(
688
903
  `record:${String(candidate.id)}`,
689
904
  'message',
690
905
  candidate.result,
691
906
  )
907
+ if (ref === undefined) {
908
+ return {
909
+ ...candidate,
910
+ result: `${candidate.result.slice(0, PREVIEW_CHARS)}\n${NOT_SAVED_NOTE}`,
911
+ }
912
+ }
692
913
  const preview = `${candidate.result.slice(0, PREVIEW_CHARS)}\n[… ${ref.bytes} bytes spilled to ${ref.path}]`
693
914
  return { ...candidate, result: preview, resultSpill: ref }
694
915
  }
@@ -708,9 +929,20 @@ export class SessionLogCore implements SessionLog {
708
929
  : type === 'message_replaced'
709
930
  ? `record:${String(candidate.id)}`
710
931
  : `message:${String(candidate.messageId)}`
711
- const ref = await this.#spills.write(key, 'message', JSON.stringify(content))
932
+ const ref = await this.#spillOrLose(key, 'message', JSON.stringify(content))
712
933
  const text =
713
934
  typeof content.content === 'string' ? content.content : JSON.stringify(content.content)
935
+ if (ref === undefined) {
936
+ // The body cannot be kept: the record carries its preview alone.
937
+ return {
938
+ ...candidate,
939
+ content: {
940
+ role: content.role,
941
+ content: `${text.slice(0, PREVIEW_CHARS)}\n${NOT_SAVED_NOTE}`,
942
+ ...(typeof content.toolCallId === 'string' ? { toolCallId: content.toolCallId } : {}),
943
+ },
944
+ }
945
+ }
714
946
  const preview = {
715
947
  role: content.role,
716
948
  content: `${text.slice(0, PREVIEW_CHARS)}\n[… ${ref.bytes} bytes spilled to ${ref.path}]`,
@@ -135,7 +135,12 @@ export class DiskLogMedium implements LogMedium {
135
135
  export interface DiskSessionLogOptions
136
136
  extends Pick<
137
137
  SessionLogCoreOptions,
138
- 'now' | 'spillAboveBytes' | 'compactionSpillAboveBytes' | 'structuredResultSpilling' | 'sync'
138
+ | 'now'
139
+ | 'spillAboveBytes'
140
+ | 'compactionSpillAboveBytes'
141
+ | 'structuredResultSpilling'
142
+ | 'sync'
143
+ | 'degradedBufferBytes'
139
144
  > {
140
145
  readonly sessionId: SessionId
141
146
  /** `<session-id>.jsonl`. */
@@ -174,6 +179,7 @@ export class DiskSessionLog extends SessionLogCore {
174
179
  compactionSpillAboveBytes: options.compactionSpillAboveBytes,
175
180
  structuredResultSpilling: options.structuredResultSpilling,
176
181
  sync: options.sync,
182
+ degradedBufferBytes: options.degradedBufferBytes,
177
183
  })
178
184
  this.file = options.file
179
185
  this.sessionDir = options.sessionDir
@@ -21,6 +21,7 @@ export {
21
21
  type ActiveTurnOptions,
22
22
  type AbandonTurnLogOptions,
23
23
  type BeginTurnOptions,
24
+ DEFAULT_DEGRADED_BUFFER_BYTES,
24
25
  InvalidSessionRecordError,
25
26
  type LogMedium,
26
27
  type ReadSessionLogOptions,
@@ -29,10 +30,13 @@ export {
29
30
  SessionLogConflictError,
30
31
  SessionLogCore,
31
32
  type SessionLogCoreOptions,
33
+ type SessionLogDegradedCause,
32
34
  type SessionLogHead,
33
35
  type SessionLogRead,
34
36
  type SessionLogReadSummary,
37
+ type SessionLogWriteHealth,
35
38
  type SessionRecordDraft,
39
+ sessionLogDegradedCause,
36
40
  type TurnStartedDraft,
37
41
  walkSessionLog,
38
42
  } from './core.js'
@@ -18,9 +18,8 @@
18
18
  import { randomUUID } from 'node:crypto'
19
19
  import { statSync } from 'node:fs'
20
20
  import { rm } from 'node:fs/promises'
21
- import { tmpdir } from 'node:os'
22
- import { join } from 'node:path'
23
21
 
22
+ import { tempFilePath } from '../../utils/temp.js'
24
23
  import { type CommandShell, hostCommandShell } from '../command-shell.js'
25
24
  import { pathOutsideRoots, toolRoots } from '../paths.js'
26
25
  import { readBoundedFile } from './bounded-file-read.js'
@@ -55,7 +54,7 @@ export function withCwdCapture(command: string, file: string): string {
55
54
  }
56
55
 
57
56
  export function newCwdFile(): string {
58
- return join(tmpdir(), `namzu-cwd-${randomUUID()}`)
57
+ return tempFilePath(`cwd-${randomUUID()}`)
59
58
  }
60
59
 
61
60
  /** Where this session's next foreground call starts. Synchronous: the spawn must not wait on it. */
@@ -1,7 +1,7 @@
1
1
  import { spawn } from 'node:child_process'
2
- import { mkdtemp, readdir, rm } from 'node:fs/promises'
3
- import { tmpdir } from 'node:os'
2
+ import { readdir, rm } from 'node:fs/promises'
4
3
  import { join } from 'node:path'
4
+ import { createTempDir } from '../../utils/temp.js'
5
5
  import { readBoundedFile } from './bounded-file-read.js'
6
6
  import { screenshotTargetSize } from './computer-use-image.js'
7
7
  import { describeRaster } from './view-image.js'
@@ -254,7 +254,7 @@ export async function readPdfPages(
254
254
  }
255
255
  last = total === null ? PDF_WHOLE_READ_MAX_PAGES : total
256
256
  }
257
- const directory = await mkdtemp(join(tmpdir(), 'namzu-pdf-'))
257
+ const directory = await createTempDir('pdf-')
258
258
  try {
259
259
  const rendered = await renderPages(bytes, first, last, directory, signal)
260
260
  if (!rendered.ok) return { ok: false, message: rendered.message }
@@ -455,6 +455,8 @@ export function createTurnReporter(parentLogger?: Logger): TurnReporter {
455
455
  case 'tool_calls_admitted':
456
456
  case 'compaction_shed':
457
457
  case 'compaction_started':
458
+ case 'session_log_degraded':
459
+ case 'session_log_recovered':
458
460
  // Deliberately silent. The report is what a human reads about a
459
461
  // run; replaying every shed message into it would bury the
460
462
  // summary line that says the pass happened, in exactly the
@@ -300,6 +300,14 @@ export interface ToolCallSummary {
300
300
  * and `strict`. A host's own handler may ignore it.
301
301
  */
302
302
  skillGrant?: { readonly skill: string }
303
+ /**
304
+ * Set when this call writes a file inside the session's own scratch folder
305
+ * and nothing else about it needs a person: the folder is the session's, so
306
+ * the review policy does not ask about it (outside `strict`, which asks about
307
+ * everything). A rule that asked for review, a protected path and a tool's
308
+ * own `requiresApproval` all keep the call in review whatever this says.
309
+ */
310
+ scratchpadWrite?: true
303
311
  /**
304
312
  * What a `pre_tool_use` hook answered about approving this call, as the
305
313
  * hooks reported it. `ask` is acted on by the kernel (it makes the call an
@@ -211,6 +211,40 @@ type CoreSessionEvent =
211
211
  /** The window the trigger measured against. */
212
212
  contextWindowTokens: number
213
213
  }
214
+ /**
215
+ * The session log can no longer write: the volume holding it is full, over
216
+ * its quota or read-only. The session goes on in memory and the records not
217
+ * yet written wait in a bounded buffer, so a reply is not lost, but a
218
+ * restart before the log recovers would resume from an older point. Emitted
219
+ * once on entering that state, and once more with `dropping` when the
220
+ * buffer first overflowed and its oldest records were discarded.
221
+ *
222
+ * Live only, never recorded: the log is what cannot be written.
223
+ */
224
+ | {
225
+ type: 'session_log_degraded'
226
+ sessionId: SessionId
227
+ turnId?: TurnId
228
+ cause: 'disk_full' | 'quota_exceeded' | 'read_only'
229
+ /** Records waiting to be written. */
230
+ bufferedRecords: number
231
+ bufferedBytes: number
232
+ /** The buffer overflowed and the oldest waiting records were discarded. */
233
+ dropping?: boolean
234
+ }
235
+ /**
236
+ * The session log writes again after `session_log_degraded`: the waiting
237
+ * records were written, in order. `droppedRecords` is how many were
238
+ * discarded while the buffer was full; the log has a gap where they were.
239
+ * Live only, never recorded.
240
+ */
241
+ | {
242
+ type: 'session_log_recovered'
243
+ sessionId: SessionId
244
+ turnId?: TurnId
245
+ flushedRecords: number
246
+ droppedRecords: number
247
+ }
214
248
  | {
215
249
  type: 'compaction_completed'
216
250
  sessionId: SessionId
@@ -1370,6 +1404,8 @@ export type SessionEventListener = (event: SessionEvent) => void | Promise<void>
1370
1404
  * runtime-context message or tool result that carried it, and a noisy watch
1371
1405
  * would write tens of megabytes an hour of raw process output a second time.
1372
1406
  * `background_job_exited.monitor` is the durable record of how a watch ended.
1407
+ * `session_log_degraded` and `session_log_recovered` are ephemeral because the
1408
+ * log is what cannot be written while they matter.
1373
1409
  * `compaction_started` is ephemeral too: it only says a pass is about to run,
1374
1410
  * and the `compaction` record or `compaction_failed` that ends it is the
1375
1411
  * durable fact.
@@ -1381,13 +1417,15 @@ export const EPHEMERAL_EVENT_TYPES: ReadonlySet<SessionEventType> = new Set<Sess
1381
1417
  'tool_progress',
1382
1418
  'monitor_event',
1383
1419
  'compaction_started',
1420
+ 'session_log_degraded',
1421
+ 'session_log_recovered',
1384
1422
  ])
1385
1423
 
1386
1424
  export function isEphemeralEvent(event: { readonly type: string }): boolean {
1387
1425
  return EPHEMERAL_EVENT_TYPES.has(event.type as SessionEventType)
1388
1426
  }
1389
1427
 
1390
- /** The event types a session log records: every literal except the ephemeral six. */
1428
+ /** The event types a session log records: every literal except the ephemeral eight. */
1391
1429
  export type PersistedSessionEventType = Exclude<
1392
1430
  SessionEventType,
1393
1431
  | 'text_delta'
@@ -1396,4 +1434,6 @@ export type PersistedSessionEventType = Exclude<
1396
1434
  | 'tool_progress'
1397
1435
  | 'monitor_event'
1398
1436
  | 'compaction_started'
1437
+ | 'session_log_degraded'
1438
+ | 'session_log_recovered'
1399
1439
  >
@@ -647,6 +647,8 @@ const SESSION_EVENT_TYPE_TABLE: Record<SessionEventType, true> = {
647
647
  request_envelope: true,
648
648
  iteration_completed: true,
649
649
  compaction_started: true,
650
+ session_log_degraded: true,
651
+ session_log_recovered: true,
650
652
  compaction_shed: true,
651
653
  compaction_completed: true,
652
654
  compaction_tool_results_cleared: true,