@modelprofile.com/flexharness 13.1.1 → 13.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 (47) hide show
  1. package/dist_ts/00_commitinfo_data.js +1 -1
  2. package/dist_ts/classes.flexharness.cachewarm.d.ts +64 -0
  3. package/dist_ts/classes.flexharness.cachewarm.js +353 -0
  4. package/dist_ts/classes.flexharness.callbacks.js +2 -1
  5. package/dist_ts/classes.flexharness.core.d.ts +9 -1
  6. package/dist_ts/classes.flexharness.core.js +55 -6
  7. package/dist_ts/classes.flexharness.d.ts +10 -1
  8. package/dist_ts/classes.flexharness.deletion.js +2 -1
  9. package/dist_ts/classes.flexharness.js +14 -1
  10. package/dist_ts/classes.flexharness.lifecycle.js +4 -1
  11. package/dist_ts/classes.flexharness.prompts.d.ts +1 -1
  12. package/dist_ts/classes.flexharness.prompts.js +25 -5
  13. package/dist_ts/classes.flexharness.reversions.d.ts +2 -0
  14. package/dist_ts/classes.flexharness.reversions.js +7 -2
  15. package/dist_ts/classes.flexharness.runs.js +23 -2
  16. package/dist_ts/interfaces.d.ts +87 -1
  17. package/dist_ts/interfaces.flexharness.d.ts +15 -0
  18. package/dist_ts/interfaces.js +1 -1
  19. package/dist_ts/plugins.d.ts +1 -1
  20. package/dist_ts/plugins.js +1 -1
  21. package/dist_ts/utils.harnessoptions.d.ts +8 -1
  22. package/dist_ts/utils.harnessoptions.js +50 -6
  23. package/dist_ts/utils.json.js +11 -2
  24. package/dist_ts/utils.sessionpreview.d.ts +3 -3
  25. package/dist_ts/utils.sessionpreview.js +5 -5
  26. package/dist_ts_chat_harness/classes.transcript.js +17 -4
  27. package/package.json +3 -3
  28. package/readme.md +27 -0
  29. package/ts/00_commitinfo_data.ts +1 -1
  30. package/ts/classes.flexharness.cachewarm.ts +433 -0
  31. package/ts/classes.flexharness.callbacks.ts +1 -0
  32. package/ts/classes.flexharness.core.ts +50 -4
  33. package/ts/classes.flexharness.deletion.ts +1 -0
  34. package/ts/classes.flexharness.lifecycle.ts +3 -0
  35. package/ts/classes.flexharness.prompts.ts +22 -3
  36. package/ts/classes.flexharness.reversions.ts +7 -1
  37. package/ts/classes.flexharness.runs.ts +19 -1
  38. package/ts/classes.flexharness.ts +21 -0
  39. package/ts/interfaces.flexharness.ts +16 -0
  40. package/ts/interfaces.ts +98 -1
  41. package/ts/plugins.ts +1 -0
  42. package/ts/readme.md +27 -0
  43. package/ts/utils.harnessoptions.ts +74 -5
  44. package/ts/utils.json.ts +10 -1
  45. package/ts/utils.sessionpreview.ts +4 -4
  46. package/ts_chat_harness/classes.transcript.ts +14 -3
  47. package/ts_chat_harness/readme.md +1 -0
package/ts/readme.md CHANGED
@@ -418,6 +418,7 @@ The tools use strict action-discriminated inputs:
418
418
  - `goal`: `get`, `set`, or `clear`.
419
419
  - `scratchpad`: `get`, `set`, `append`, or `clear`. Append concatenates the supplied content exactly.
420
420
  - `rename_session`: sets the active session title and returns the authoritative session.
421
+ - `keep_cache_warm` (`cacheWarm: true`, root sessions only): keeps the session's prompt cache warm; see [Keeping the Prompt Cache Warm](#keeping-the-prompt-cache-warm).
421
422
 
422
423
  Every project action returns the authoritative revision and state; task mutations also return the affected task, and clear returns the removed tasks. Reads never save. A set, clear, append, update, idempotent create, or empty task clear that makes no state change returns the current revision without writing. Mutations load once, apply once, validate the complete next snapshot, and issue one compare-and-swap save at `revision + 1`. FlexHarness never retries or merges an external conflict.
423
424
 
@@ -729,6 +730,32 @@ The run takes a steer in at its next step boundary: after the tool results of it
729
730
 
730
731
  `steerPrompt()` resolves with `{ steerId, queueId, runId }` once the run accepted the steer and `prompt.steer.accepted` was emitted; `prompt.steer.applied` carries the `messageId` of the user message the steer became. Every accepted steer ends in exactly one of two ways: it is in the model context of later turns, or it is listed in `unappliedSteerIds`, in arrival order, on `IFlexPromptResult` and `run.finished`, and on the finished queue entry when that list is not empty. A steer is listed when the run did not take it in before it ended, because its last model step was already running or because it was stopped or failed, and also when the run itself does not stay in context. `keptInContext` on `run.finished` and on the finished queue entry says whether it does: it is `false` for a failed run and for a run stopped before any model output, and such a run lists every steer it accepted, including the ones it took in and reported with `prompt.steer.applied`. Send its prompt and its unapplied steers again to have them answered. The run stops taking steers atomically when its generation ends or `abort()`/`cancelPrompt()` stops it. A steer for a prompt without a running run, or whose run is ending, is refused with `FlexHarnessSteerRejectedError` reason `not-running`; the caller sends it as the next turn instead. Reusing a `steerId` within a run is refused with reason `duplicate`, an unknown `queueId` throws `FlexHarnessNotFoundError`, and child sessions reject this general API (their exact parent run uses `sendSubagentInput()` or `delegate_send` instead). A run holds at most `maxOutstandingPromptsPerSession` pending steers, and their bytes count against the session's outstanding prompt byte limit until the run takes them in or ends, so `startPrompt()` and `enqueuePrompt()` are refused with `FlexHarnessQueueFullError` while pending steers fill it. They are process-local like queue entries: a process restart ends the run and drops its pending steers, while steers it took in are durable parts of the turn.
731
732
 
733
+ ### Keeping the Prompt Cache Warm
734
+
735
+ A provider's prompt cache expires when nothing reads it for its TTL, and the next turn then writes the whole prefix again. `keepCacheWarm()` keeps a root session's cache warm until a deadline:
736
+
737
+ ```typescript
738
+ await harness.keepCacheWarm(scopeId, sessionId, { durationMs: 60 * 60 * 1000 }); // warm until 1 h from now
739
+ await harness.keepCacheWarm(scopeId, sessionId, { durationMs: 0 }); // stop
740
+ const state = await harness.getCacheWarm(scopeId, sessionId); // { sessionId, warmUntil?, nextWarmAt?, idleMs?, basis? }
741
+ ```
742
+
743
+ Each call replaces the deadline with `durationMs` from now, capped at `cacheWarm.maxDurationMs` (default 4 h, at most 24 h); `0` stops it. While the deadline has not passed and the session is idle, the harness sends a cache-warm prompt once the session has had no model call for the cache TTL less one minute: 4 minutes for a 5-minute cache, 29 for a 30-minute one, 59 for a 1-hour one. The TTL comes from the request the provider actually sent last, its `basis` in `getCacheWarm()`:
744
+
745
+ | `basis` | TTL | Sent request |
746
+ | --- | --- | --- |
747
+ | `anthropic-5m`, `anthropic-1h` | 5 min, 1 h | Anthropic: the shortest `cache_control` lifetime of the request |
748
+ | `openai-30m` | 30 min | OpenAI's API, GPT-5.6 and later: `prompt_cache_options.ttl` is 30 minutes after the last write or reuse |
749
+ | `openai-24h` | 30 min | OpenAI's API, an earlier model with `prompt_cache_retention: '24h'` (`cache: { retention: '24h' }`): OpenAI keeps the prefix typically around 30 minutes and up to 24 hours, without promising more |
750
+ | `openai-in-memory` | 5 min | OpenAI's API, an earlier model with `in_memory` or no retention; or the ChatGPT backend of ChatGPT accounts, which takes no retention and documents none. In-memory entries last 5 to 10 minutes of inactivity |
751
+ | `configured` | `cache.warmDurationMs` | any |
752
+
753
+ With no basis (another provider or endpoint, or a custom transport that may change the request) the TTL is 5 minutes. No OpenAI retention promises a hit beyond about 30 minutes, so none makes warming unnecessary; extended retention only spaces warm turns further apart. Source: OpenAI's [prompt caching guide](https://developers.openai.com/api/docs/guides/prompt-caching), read 2026-10-11. A prompt, a steer or a running turn resets the wait, and no warm prompt is sent while a turn runs, a prompt is queued, an undo's redo history is open, a reversion is pending or a slash command runs. The warm prompt is the fixed text `FLEX_CACHE_WARM_PROMPT`, starting with `[cache-warm]`, which asks for the single word `ok`. Its turn uses the model and system of the session's last turn and offers the same tools, so it reads the cached prefix, but it refuses every tool call, takes at most two steps, caps each model call at `cacheWarm.maxOutputTokens` (default 1024) and takes no steer: `steerPrompt()` refuses one with reason `not-running`. Keeping a session warm costs a cache read of its prefix and a few new tokens per warm turn; the warm exchanges stay in the model context.
754
+
755
+ Warm turns are marked so hosts can leave them out of what they show and of what they count as work: both messages, the session `activity`, the prompt queue entry, `run.started`, `run.finished` and `model.usage` carry `cacheWarm: true`. A warm turn changes neither the session's `updatedAt` nor its `preview`, and adds no undo step. Three events report the keeper: `cache.warm.changed` (`source` `host` or `agent`, `warmUntil` absent once stopped), `cache.warm.completed` with the warm turn's `usage`, whose `cacheReadTokens` and `cacheWriteTokens` are the provider's own counts and show whether it hit the cache (OpenAI's `input_tokens_details.cached_tokens` and `cache_write_tokens`, Anthropic's `cache_read_input_tokens` and `cache_creation_input_tokens`), its `idleForMs` since the session's previous model call and its `basis`, and `cache.warm.stopped` with `reason` `deadline`, `cleared`, `failed` (the warm turn failed; `error` set) or `unavailable` (the session or its scope went away).
756
+
757
+ `builtInTools: { cacheWarm: true }` gives root sessions the `keep_cache_warm` tool, so the agent can keep its own cache warm: `{ duration: '1h' }` sets the deadline (amounts of `s`, `m` or `h`), `'off'` or `'0'` stops it. The keeper is process-local: it ends with `dispose()` and does not survive a restart. It warms only the loaded scope it was set in: retiring the scope or deleting the session stops it with `unavailable`, and it never loads a scope again. Tests pass `cacheWarm.clock` (`now`, `setTimeout`, `clearTimeout`) to drive it with a fake clock.
758
+
732
759
  ## Slash Commands
733
760
 
734
761
  `parseSlashCommand()` is the public strict pure parser. It accepts at most 768 KiB, returns `not-command` for input not starting with `/`, `malformed` for invalid slash syntax, and `parsed` with the exact input, lowercase command name, separator-stripped raw argument text, and OpenCode-compatible quoted tokenization. Command names match `[a-z][a-z0-9_-]{0,63}`. The public `isValidSlashCommandName(name)` and `isReservedSlashCommandName(name)` helpers let applications validate registration names against the same rules. Single and double quotes group tokens and are stripped; escapes are not interpreted.
@@ -12,6 +12,8 @@ import {
12
12
  import type {
13
13
  IFlexAgentSessionPolicy,
14
14
  IFlexAttachmentResolver,
15
+ IFlexCacheWarmClock,
16
+ IFlexCacheWarmOptions,
15
17
  IFlexCallbackLimits,
16
18
  IFlexHarnessOptions,
17
19
  IFlexHarnessStores,
@@ -46,8 +48,21 @@ export interface INormalizedProjectManagementTools {
46
48
  export interface INormalizedBuiltInTools {
47
49
  readonly renameSession: boolean;
48
50
  readonly projectManagement?: INormalizedProjectManagementTools;
51
+ readonly cacheWarm: boolean;
49
52
  }
50
53
 
54
+ export interface INormalizedCacheWarmOptions {
55
+ readonly maxDurationMs: number;
56
+ readonly maxOutputTokens: number;
57
+ readonly clock: IFlexCacheWarmClock;
58
+ }
59
+
60
+ const defaultCacheWarmMaxDurationMs = 4 * 60 * 60 * 1000;
61
+ // One call warms for at most a day; a host extends it by calling again.
62
+ const maximumCacheWarmMaxDurationMs = 24 * 60 * 60 * 1000;
63
+ const defaultCacheWarmMaxOutputTokens = 1024;
64
+ const maximumCacheWarmMaxOutputTokens = 32_768;
65
+
51
66
  const DEFAULT_CALLBACK_LIMITS: Required<IFlexCallbackLimits> = {
52
67
  maxEvents: 10_000,
53
68
  maxOutputBytes: 1024 * 1024,
@@ -242,7 +257,7 @@ function normalizeCodeModeLimits(codeMode: unknown): Readonly<IFlexCodeModeLimit
242
257
  export function normalizeBuiltInTools(
243
258
  options: IFlexHarnessOptions<unknown>['builtInTools'],
244
259
  ): INormalizedBuiltInTools {
245
- if (options === undefined) return Object.freeze({ renameSession: false });
260
+ if (options === undefined) return Object.freeze({ renameSession: false, cacheWarm: false });
246
261
  if (
247
262
  !options
248
263
  || typeof options !== 'object'
@@ -252,14 +267,19 @@ export function normalizeBuiltInTools(
252
267
  throw new FlexHarnessValidationError('builtInTools must be a plain object.');
253
268
  }
254
269
  const unsupported = Object.keys(options)
255
- .find((key) => !['renameSession', 'projectManagement'].includes(key));
270
+ .find((key) => !['renameSession', 'projectManagement', 'cacheWarm'].includes(key));
256
271
  if (unsupported) throw new FlexHarnessValidationError(`builtInTools does not support "${unsupported}".`);
257
- if (options.renameSession !== undefined && typeof options.renameSession !== 'boolean') {
258
- throw new FlexHarnessValidationError('builtInTools.renameSession must be a boolean.');
272
+ for (const key of ['renameSession', 'cacheWarm'] as const) {
273
+ if (options[key] !== undefined && typeof options[key] !== 'boolean') {
274
+ throw new FlexHarnessValidationError(`builtInTools.${key} must be a boolean.`);
275
+ }
259
276
  }
260
277
  const projectManagement = options.projectManagement;
261
278
  if (projectManagement === undefined) {
262
- return Object.freeze({ renameSession: options.renameSession ?? false });
279
+ return Object.freeze({
280
+ renameSession: options.renameSession ?? false,
281
+ cacheWarm: options.cacheWarm ?? false,
282
+ });
263
283
  }
264
284
  if (
265
285
  !projectManagement
@@ -283,6 +303,7 @@ export function normalizeBuiltInTools(
283
303
  }
284
304
  return Object.freeze({
285
305
  renameSession: options.renameSession ?? false,
306
+ cacheWarm: options.cacheWarm ?? false,
286
307
  projectManagement: Object.freeze({
287
308
  task: projectManagement.task ?? true,
288
309
  goal: projectManagement.goal ?? true,
@@ -374,6 +395,54 @@ export function normalizeSlashCommands<TScope>(
374
395
  return Object.freeze(normalized);
375
396
  }
376
397
 
398
+ const systemCacheWarmClock: IFlexCacheWarmClock = Object.freeze({
399
+ now: () => Date.now(),
400
+ setTimeout: (callback: () => void, delayMs: number) => {
401
+ const handle = setTimeout(callback, delayMs);
402
+ // A warm period never holds the process open.
403
+ handle.unref?.();
404
+ return handle;
405
+ },
406
+ clearTimeout: (handle: unknown) => clearTimeout(handle as ReturnType<typeof setTimeout>),
407
+ });
408
+
409
+ export function normalizeCacheWarmOptions(
410
+ input: IFlexHarnessOptions<unknown>['cacheWarm'],
411
+ ): INormalizedCacheWarmOptions {
412
+ if (input !== undefined && !isPlainOptions(input as unknown)) {
413
+ throw new FlexHarnessValidationError('cacheWarm must be a plain object.');
414
+ }
415
+ const options: IFlexCacheWarmOptions = input ?? {};
416
+ const unsupported = Object.keys(options)
417
+ .find((key) => !['maxDurationMs', 'maxOutputTokens', 'clock'].includes(key));
418
+ if (unsupported) throw new FlexHarnessValidationError(`cacheWarm does not support "${unsupported}".`);
419
+ const clock: IFlexCacheWarmClock = options.clock ?? systemCacheWarmClock;
420
+ if (
421
+ !clock
422
+ || typeof clock !== 'object'
423
+ || typeof clock.now !== 'function'
424
+ || typeof clock.setTimeout !== 'function'
425
+ || typeof clock.clearTimeout !== 'function'
426
+ ) {
427
+ throw new FlexHarnessValidationError('cacheWarm.clock must provide now, setTimeout and clearTimeout.');
428
+ }
429
+ return Object.freeze({
430
+ maxDurationMs: resolveBoundedPositiveInteger(
431
+ options.maxDurationMs,
432
+ 'cacheWarm.maxDurationMs',
433
+ defaultCacheWarmMaxDurationMs,
434
+ maximumCacheWarmMaxDurationMs,
435
+ ),
436
+ maxOutputTokens: resolveBoundedPositiveInteger(
437
+ options.maxOutputTokens,
438
+ 'cacheWarm.maxOutputTokens',
439
+ defaultCacheWarmMaxOutputTokens,
440
+ maximumCacheWarmMaxOutputTokens,
441
+ ),
442
+ clock,
443
+ });
444
+ }
445
+
377
446
  export function resolveBoundedPositiveInteger(
378
447
  value: number | undefined,
379
448
  name: string,
package/ts/utils.json.ts CHANGED
@@ -566,7 +566,7 @@ function validateSession(value: unknown, path: string): IValidatedSessionIdentit
566
566
  const activity = requireRecord(session.activity, `${path}.activity`);
567
567
  requireOnlyKeys(
568
568
  activity,
569
- ['runId', 'status', 'startedAt', 'completedAt', 'error', 'limit', 'rejectedPermissionId'],
569
+ ['runId', 'status', 'startedAt', 'completedAt', 'error', 'limit', 'rejectedPermissionId', 'cacheWarm'],
570
570
  `${path}.activity`,
571
571
  );
572
572
  if (!activityStatuses.includes(activity.status as (typeof activityStatuses)[number])) {
@@ -578,6 +578,11 @@ function validateSession(value: unknown, path: string): IValidatedSessionIdentit
578
578
  requireOptionalString(activity.error, `${path}.activity.error`);
579
579
  requireOptionalModelLimit(activity.limit, `${path}.activity.limit`);
580
580
  requireOptionalString(activity.rejectedPermissionId, `${path}.activity.rejectedPermissionId`);
581
+ // FlexHarness 13.2.0 stored a warm turn's activity with `cacheWarm`; session repair on load
582
+ // removes it and restores the latest real turn's activity.
583
+ if (activity.cacheWarm !== undefined && activity.cacheWarm !== true) {
584
+ throw new FlexHarnessStoreFormatError(`${path}.activity.cacheWarm must be true when present.`);
585
+ }
581
586
  const relationship = [
582
587
  session.parentSessionId,
583
588
  session.parentRunId,
@@ -776,6 +781,7 @@ function validateMessage(value: unknown, path: string): IValidatedMessageIdentit
776
781
  'limit',
777
782
  'rejectedPermissionId',
778
783
  'steerId',
784
+ 'cacheWarm',
779
785
  ],
780
786
  path,
781
787
  );
@@ -799,6 +805,9 @@ function validateMessage(value: unknown, path: string): IValidatedMessageIdentit
799
805
  throw new FlexHarnessStoreFormatError(`${path}.steerId is only valid on a user message.`);
800
806
  }
801
807
  }
808
+ if (message.cacheWarm !== undefined && message.cacheWarm !== true) {
809
+ throw new FlexHarnessStoreFormatError(`${path}.cacheWarm must be true when present.`);
810
+ }
802
811
  if (message.model !== undefined) validateModel(message.model, `${path}.model`);
803
812
  if (message.usage !== undefined) validateUsage(message.usage, `${path}.usage`);
804
813
  if (!Array.isArray(message.parts)) {
@@ -1,13 +1,13 @@
1
1
  import { FLEX_PROJECT_MANAGEMENT_LIMITS, type IFlexMessage } from './interfaces.js';
2
2
 
3
3
  /**
4
- * What a session is about before anyone titles it: the text of its first prompt that has any, as
5
- * one line (every run of whitespace becomes one space), cut to the title bound at a character
6
- * boundary. Undefined while no prompt carries text.
4
+ * What a session is about before anyone titles it: the text of its first prompt that has any,
5
+ * cache-warm prompts aside, as one line (every run of whitespace becomes one space), cut to the
6
+ * title bound at a character boundary. Undefined while no prompt carries text.
7
7
  */
8
8
  export function sessionPreview(messagesArg: readonly IFlexMessage[]): string | undefined {
9
9
  for (const message of messagesArg) {
10
- if (message.role !== 'user') continue;
10
+ if (message.role !== 'user' || message.cacheWarm) continue;
11
11
  const text = message.parts
12
12
  .map((part) => (part.type === 'text' ? part.text : ''))
13
13
  .join(' ')
@@ -180,7 +180,7 @@ export class FlexHarnessTranscript {
180
180
  public view(): IFlexHarnessTranscriptView {
181
181
  return structuredClone({
182
182
  messages: this.flexMessages.flatMap((message, index) =>
183
- flexMessageToHarnessRows(message, index, this.timings)),
183
+ message.cacheWarm ? [] : flexMessageToHarnessRows(message, index, this.timings)),
184
184
  permissions: [...this.permissionRows.values()],
185
185
  questions: [],
186
186
  status: this.status,
@@ -217,6 +217,11 @@ export class FlexHarnessTranscript {
217
217
  }
218
218
  if (event.scopeId !== this.scopeId || event.sessionId !== this.sessionId) return change;
219
219
  const timestamp = Date.parse(event.timestamp);
220
+ // A cache-warm turn's messages are held but not shown, so their events change no row.
221
+ const warmMessage = 'message' in event
222
+ ? event.message.cacheWarm === true
223
+ : 'messageId' in event && this.flexMessages.some((message) =>
224
+ message.messageId === event.messageId && message.cacheWarm);
220
225
  switch (event.type) {
221
226
  case 'session.created':
222
227
  case 'session.updated':
@@ -227,7 +232,7 @@ export class FlexHarnessTranscript {
227
232
  change.messages = change.permissions = change.status = change.usage = true;
228
233
  break;
229
234
  case 'run.started':
230
- this.setStatus(change, { type: 'busy' });
235
+ if (!event.cacheWarm) this.setStatus(change, { type: 'busy' });
231
236
  break;
232
237
  case 'message.created':
233
238
  case 'message.updated':
@@ -280,13 +285,15 @@ export class FlexHarnessTranscript {
280
285
  this.usage = sumUsage(this.usage, flexUsageToHarness(event.message.usage));
281
286
  change.usage = true;
282
287
  }
288
+ if (event.cacheWarm) break;
283
289
  this.setStatus(change, event.status === 'failed'
284
290
  ? { type: 'error', ...(event.error ? { message: event.error.message } : {}) }
285
291
  : { type: 'idle' });
286
292
  break;
287
293
  case 'prompt.started':
288
294
  case 'prompt.running':
289
- this.runningQueueId = event.queueId;
295
+ // A steer cannot join a cache-warm turn.
296
+ if (!event.entry.cacheWarm) this.runningQueueId = event.queueId;
290
297
  break;
291
298
  case 'prompt.finished':
292
299
  if (this.runningQueueId === event.queueId) this.runningQueueId = undefined;
@@ -297,6 +304,10 @@ export class FlexHarnessTranscript {
297
304
  default:
298
305
  break;
299
306
  }
307
+ if (warmMessage) {
308
+ change.messages = false;
309
+ change.deltas = [];
310
+ }
300
311
  return change;
301
312
  }
302
313
 
@@ -78,6 +78,7 @@ The host forwards every event of the harness instance, of every session, in orde
78
78
  - Permission rows stay in the transcript once answered, showing the decision. A request its run leaves unanswered, because the run was stopped or failed, is removed when the run finishes.
79
79
  - FlexHarness emits no event for project state. After a run, or after a project tool's row completes, read `getProjectState()` and pass it to `applyProjectState()` for the task list and the scratchpad.
80
80
  - `questions` is always empty: FlexHarness has no question requests.
81
+ - A cache-warm turn (see the FlexHarness readme, Keeping the Prompt Cache Warm) has no rows and leaves the status and the session card as the last real turn left them and `activeQueueId` unset, so a steer never joins it. Its usage still counts in `usage`: it is spent.
81
82
  - Messages the session has not taken in yet (a queued prompt, a steer) carry no text in FlexHarness events. The host that sent them shows them as pending rows.
82
83
  - An attachment row needs a name, a media type and a size. The public projection keeps no payload and states these only when the prompt did, so an attachment without all three, such as a remote URL of unknown size, has no row.
83
84