@modelprofile.com/flexharness 12.4.0 → 13.0.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.
Files changed (35) hide show
  1. package/dist_ts/00_commitinfo_data.js +1 -1
  2. package/dist_ts/classes.flexharness.callbacks.js +10 -3
  3. package/dist_ts/classes.flexharness.core.js +4 -2
  4. package/dist_ts/classes.flexharness.permissions.js +11 -3
  5. package/dist_ts/classes.flexharness.prompts.js +11 -2
  6. package/dist_ts/classes.flexharness.reversions.js +21 -6
  7. package/dist_ts/classes.flexharness.runs.js +13 -7
  8. package/dist_ts/classes.flexharness.subagents.js +4 -5
  9. package/dist_ts/errors.d.ts +8 -2
  10. package/dist_ts/errors.js +13 -5
  11. package/dist_ts/interfaces.d.ts +27 -0
  12. package/dist_ts/interfaces.flexharness.d.ts +3 -1
  13. package/dist_ts/interfaces.js +7 -1
  14. package/dist_ts/utils.flexharness.d.ts +8 -2
  15. package/dist_ts/utils.flexharness.js +13 -3
  16. package/dist_ts/utils.json.js +9 -2
  17. package/dist_ts/utils.sessionpreview.d.ts +7 -0
  18. package/dist_ts/utils.sessionpreview.js +35 -0
  19. package/package.json +3 -3
  20. package/readme.md +8 -4
  21. package/ts/00_commitinfo_data.ts +1 -1
  22. package/ts/classes.flexharness.callbacks.ts +9 -3
  23. package/ts/classes.flexharness.core.ts +2 -1
  24. package/ts/classes.flexharness.permissions.ts +8 -2
  25. package/ts/classes.flexharness.prompts.ts +9 -1
  26. package/ts/classes.flexharness.reversions.ts +21 -5
  27. package/ts/classes.flexharness.runs.ts +11 -4
  28. package/ts/classes.flexharness.subagents.ts +3 -3
  29. package/ts/errors.ts +13 -4
  30. package/ts/interfaces.flexharness.ts +3 -0
  31. package/ts/interfaces.ts +33 -0
  32. package/ts/readme.md +8 -4
  33. package/ts/utils.flexharness.ts +16 -2
  34. package/ts/utils.json.ts +12 -1
  35. package/ts/utils.sessionpreview.ts +32 -0
package/readme.md CHANGED
@@ -693,7 +693,7 @@ await harness.dispose();
693
693
 
694
694
  Only one run may be active in a session. Additional prompts enter a bounded FIFO owned by FlexHarness, while different sessions can run concurrently. `enqueuePrompt()` resolves with `{ queueId, completion }` after the immutable prompt and options have been accepted into that runtime queue and `prompt.queued` has been emitted. `startPrompt()` keeps its durable-admission behavior: it waits for its FIFO turn and resolves with `{ queueId, runId, completion }` only after the canonical generation claim, run ID, and initial public audit messages have been durably reserved and the corresponding start events have been emitted. `prompt()` preserves the simpler behavior by awaiting completion internally.
695
695
 
696
- Queue entries expose `queued`, `starting`, `scheduled`, `running`, `completed`, `failed`, and `cancelled` status through `getPromptQueueEntry()` and `listPromptQueueEntries()`. The list is ordered by process-local `queueSequence`. `getPromptQueueEntry()` throws `FlexHarnessNotFoundError` for an unknown or evicted ID. `cancelPrompt()` cancels one exact queue ID: a waiting entry leaves the FIFO and releases capacity immediately but remains queryable as `cancelled` until terminal retention evicts it; a promoted entry uses the canonical run cancellation path. Cancelling a terminal entry returns `false`, while an unknown ID throws. `abort()` remains scoped to the currently active run.
696
+ Queue entries expose `queued`, `starting`, `scheduled`, `running`, `completed`, `failed`, and `cancelled` status through `getPromptQueueEntry()` and `listPromptQueueEntries()`. The list is ordered by process-local `queueSequence`. `getPromptQueueEntry()` throws `FlexHarnessNotFoundError` for an unknown or evicted ID. `cancelPrompt()` cancels one exact queue ID: a waiting entry leaves the FIFO and releases capacity immediately but remains queryable as `cancelled` until terminal retention evicts it; a promoted entry uses the canonical run cancellation path and is stopped with the same `FlexHarnessAbortError` ("The run was aborted.") as `abort()`; only a waiting entry is rejected with "The queued prompt was cancelled.". A cancelled run, whether stopped or found interrupted on reload, puts its `error` on the assistant message only; its user message is `cancelled` without an `error`, while a failed run marks both messages. Cancelling a terminal entry returns `false`, while an unknown ID throws. `abort()` remains scoped to the currently active run.
697
697
 
698
698
  The displayed queue limits are the defaults. Outstanding count and byte limits apply per session and include every non-terminal queued or active prompt until it settles. Pending-admission limits apply to the complete harness while scope aliases are unresolved. Terminal retention applies per session. Exceeding an admission limit throws `FlexHarnessQueueFullError`.
699
699
 
@@ -705,7 +705,7 @@ The reservation save is the admission point. A save failure produces no start ev
705
705
 
706
706
  `listMessagePage()` returns the newest contiguous page in chronological order. `limit` must be an integer from 1 through 50 and defaults to 50. `nextCursor` is opaque, limited to 4096 UTF-8 bytes, bound to the resolved storage namespace and session, and remains stable when newer messages are appended. Mismatched and stale cursors fail validation. Each page carries `startIndex`, the zero-based index of its first message in the session's visible history (for an empty page, the index the page ends at), so a consumer can order and page by absolute position. Appending messages leaves every index unchanged; a branch after an undo reuses the positions of the history it replaces, so a consumer re-reads its pages after `session.history.changed`. `getMessage()` performs an exact lookup. Transfer identifiers are limited to 512 bytes, text and reasoning parts to 96 KiB, complete messages to 480 KiB, and complete page envelopes to 512 KiB. A page may therefore contain fewer messages than requested. Oversized text is truncated and an otherwise oversized parts collection is replaced with an explicit elision marker; metadata that still cannot fit fails validation. Canonical private Agent events are unchanged.
707
707
 
708
- `updateSession()` supports title replacement, explicit title clearing with `null`, and archive state through `archived`. Title-only updates remain available while prompts are queued or running, while permission is pending, and after archival. Requests containing `archived` are rejected while the session has any outstanding prompt or pending permission; a mixed title-and-archive request is rejected atomically without changing the title. Archived sessions expose `archivedAt`. Deleting a session cascades through its complete descendant subtree. One durable root-keyed tombstone group hides every newly affected live session, and the delete also joins any already-separate descendant cleanup groups without rewriting their roots. FlexHarness then cancels queued and active subtree work, emits terminal queue events, waits for admitted initialization, and purges runtime queue status while cleaning runtime and persisted domains child-first. The requested root tombstone is removed last after every domain confirms cleanup; project-management cleanup confirmation is a retained durable project tombstone rather than physical removal. An imported session can hold no project-management state, so its deletion writes no project tombstone. A successful live `deleteSession()` call emits `session.deleted` for each session it newly tombstoned; retries of an existing tombstone and automatic load, retirement, or disposal cleanup emit no deletion events. Direct deletion of a descendant cascades only through that descendant's subtree. Cleanup authority follows the resolved storage namespace, so scope aliases share the same groups. A partial failure retains durable ownership for retry by a later `deleteSession()`, namespace load, `retireScope()`, or `dispose()` call.
708
+ `updateSession()` supports title replacement, explicit title clearing with `null`, and archive state through `archived`. Title-only updates remain available while prompts are queued or running, while permission is pending, and after archival. Requests containing `archived` are rejected while the session has any outstanding prompt or pending permission; a mixed title-and-archive request is rejected atomically without changing the title. Archived sessions expose `archivedAt`. Every session exposes `preview` once a run with prompt text was admitted: its first prompt as one line (whitespace runs collapsed, cut at a character boundary to `maxTitleBytes`), with no model call, so a host can name a session nobody titled (`title || preview`). It is set once, a session stored before previews existed takes it from its first prompt at its next run, and no caller sets or clears it. Deleting a session cascades through its complete descendant subtree. One durable root-keyed tombstone group hides every newly affected live session, and the delete also joins any already-separate descendant cleanup groups without rewriting their roots. FlexHarness then cancels queued and active subtree work, emits terminal queue events, waits for admitted initialization, and purges runtime queue status while cleaning runtime and persisted domains child-first. The requested root tombstone is removed last after every domain confirms cleanup; project-management cleanup confirmation is a retained durable project tombstone rather than physical removal. An imported session can hold no project-management state, so its deletion writes no project tombstone. A successful live `deleteSession()` call emits `session.deleted` for each session it newly tombstoned; retries of an existing tombstone and automatic load, retirement, or disposal cleanup emit no deletion events. Direct deletion of a descendant cascades only through that descendant's subtree. Cleanup authority follows the resolved storage namespace, so scope aliases share the same groups. A partial failure retains durable ownership for retry by a later `deleteSession()`, namespace load, `retireScope()`, or `dispose()` call.
709
709
 
710
710
  `deleteSessionGenerationCohort()` is the generation-fenced destructive form for controllers. Its `root` and every `authorizedCohort` entry use the exact `{ sessionId, sessionGenerationId, sessionGenerationSequence }` shape. The cohort contains unique entries in strictly ascending `sessionId` order and is limited by `FLEX_SESSION_GENERATION_COHORT_MAX_ENTRIES` (2048). FlexHarness atomically validates the matching root, its complete live subtree, and every retained descendant tombstone group before reserving deletion. A missing or different root generation returns `{ matched: false }` without touching a newer generation; a missing or mismatched cascade entry throws before new tombstoning or cleanup. Extra valid entries do not expand the cascade. A matched partial cleanup remains retryable with the same authority and resolves to `{ matched: true }` when cleanup completes. The existing `deleteSession()` method remains available for callers that intentionally own the complete reusable-ID namespace.
711
711
 
@@ -872,11 +872,13 @@ for (const group of info.groups) {
872
872
  group.visibility,
873
873
  group.affectedWorkspaces,
874
874
  group.affectedWorkspacesTruncated,
875
+ group.barrierCauses,
876
+ group.reasonCodes,
875
877
  );
876
878
  }
877
879
  ```
878
880
 
879
- The immutable result exposes `undoAvailable`, `redoAvailable`, and groups classified as `candidate`, `barrier`, or `no-change`. Group metadata contains at most 64 unique affected workspaces; `affectedWorkspacesTruncated` is `true` when additional unique descriptors were omitted. It never includes capture IDs or provider references.
881
+ The immutable result exposes `undoAvailable`, `redoAvailable`, and groups classified as `candidate`, `barrier`, or `no-change`. Group metadata contains at most 64 unique affected workspaces; `affectedWorkspacesTruncated` is `true` when additional unique descriptors were omitted. `barrierCauses` states why a barrier blocks traversal, each cause once in this order: `pending` while a capture runs or its outcome is not final, `nonrevertible` for a workspace outcome that cannot be reverted, `transcript-only` for legacy transcript history without a workspace capture; it is empty for candidate and no-change groups (`FLEX_SESSION_REVERSION_BARRIER_CAUSES` lists the order). `reasonCodes` lists the distinct `reasonCode`s of the group's nonrevertible segments in segment order, so an application can say why a turn is not revertible even when its provider named no affected workspace. It never includes capture IDs or provider references.
880
882
 
881
883
  `workspaceSnapshots` is application-owned. Every context contains `scopeId`, `scope`, `storageKey`, `sessionId`, the required canonical `sessionGenerationId` and `sessionGenerationSequence`, `runId`, deterministic `captureId`, and an `AbortSignal`. Apply contexts additionally contain the normalized `reference`, per-segment `operationId`, and `direction`; release contexts contain the reference. Deleted-session recovery retains the same generation from the durable tombstone, so providers can reject callbacks from an older same-ID generation.
882
884
 
@@ -923,7 +925,7 @@ Resolved model identity contains provider and model IDs plus optional display na
923
925
 
924
926
  Public audit history is safe to send to controllers: attachment parts contain source and size metadata, never inline base64, data URLs, or remote URL payloads. Canonical private Agent events retain those values solely for subsequent model turns.
925
927
 
926
- Sessions expose `idle`, `scheduled`, `running`, `waiting_permission`, `failed`, and `cancelled` status. Persisted non-terminal activity is repaired from canonical Agent generation outcomes after process restart; incomplete messages and parts normalize to `cancelled` unless an accepted hidden terminal stage can be promoted.
928
+ Sessions expose `idle`, `scheduled`, `running`, `waiting_permission`, `failed`, and `cancelled` status. A run stopped by `abort()`, `cancelPrompt()` or a rejected permission leaves its session `cancelled`; only a run that failed leaves it `failed`. Persisted non-terminal activity is repaired from canonical Agent generation outcomes after process restart; incomplete messages and parts normalize to `cancelled` unless an accepted hidden terminal stage can be promoted.
927
929
 
928
930
  ## Conversation Import
929
931
 
@@ -1023,6 +1025,8 @@ Pending requests are runtime-only and queryable with `listPendingPermissions()`.
1023
1025
 
1024
1026
  `always` is invalid when the request has no `rememberKey`. Remembered decisions are persisted before the waiting tool resolves. If persistence fails, the key is rolled back and the request remains pending so the response can be retried. Concurrent response attempts are serialized and exactly one successful response settles a request.
1025
1027
 
1028
+ `reject` stops the run as a decision, not as a failure. The waiting tool call fails with `FlexHarnessPermissionRejectedError`, a `FlexHarnessAbortError` with code `FLEX_PERMISSION_REJECTED` and the rejected `permissionId`, and the run ends the way `abort()` or `cancelPrompt()` ends it; the model is not asked to recover. The run's completion rejects with that error. `run.finished` and the finished queue entry have status `cancelled` and an `error` whose `permissionId` names the rejected request. The run's terminal user and assistant messages are `cancelled`; the assistant message carries `rejectedPermissionId` next to its `error`, and so does the session's `activity`; the session's status is `cancelled`, as after a stop. The refused tool part fails with the rejection as its error and carries the same `rejectedPermissionId`. Other permissions still pending in the run are rejected with it, and runs it delegated stop with "The parent run was aborted.". As with a stop, a run rejected after its model produced output stays in future context, where the refused call's result says the permission was rejected. A permission policy `deny` ends the run the same way.
1029
+
1026
1030
  ## Tool Output Safety
1027
1031
 
1028
1032
  FlexHarness wraps every provided tool `execute` method before AgentSession receives it. Direct outputs and every `AsyncIterable` yield are converted into bounded JSON-safe values. Circular references, functions, symbols, bigint values, dates, URLs, and binary values receive deterministic descriptions or records. Returned error objects and unreadable getter values receive fixed descriptions without their original messages. Thrown errors and iterator failures remain failures but are converted to the safe external-error projection before AgentSession observes them.
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@modelprofile.com/flexharness',
6
- version: '12.4.0',
6
+ version: '13.0.0',
7
7
  description: 'The FlexHarness toolbox source repository and shared dependency catalog.'
8
8
  }
@@ -1,5 +1,5 @@
1
1
  import * as plugins from './plugins.js';
2
- import { FlexHarnessCallbackOverflowError } from './errors.js';
2
+ import { FlexHarnessCallbackOverflowError, FlexHarnessPermissionRejectedError } from './errors.js';
3
3
  import {
4
4
  FLEX_TOOL_CATALOG_TOOL_NAMES,
5
5
  type IFlexReasoningMessagePart,
@@ -26,7 +26,7 @@ import type {
26
26
  import type { FlexHarnessCore } from './classes.flexharness.core.js';
27
27
 
28
28
  /** How a tool call ended, as its part records it. */
29
- type TToolPartOutcome = { output: TJsonValue } | { error: string };
29
+ type TToolPartOutcome = { output: TJsonValue } | { error: string; rejectedPermissionId?: string };
30
30
 
31
31
  const outcomeBytes = (outcome: TToolPartOutcome): number =>
32
32
  'output' in outcome ? jsonBytes(outcome.output) : Buffer.byteLength(outcome.error);
@@ -328,7 +328,12 @@ export class FlexHarnessRunCallbacks<TScope> {
328
328
  if (event.success) {
329
329
  return { output: normalizeJsonValue(event.output, { ...this.core.toolOutputLimits, maxBytes: maxOutputBytes }) };
330
330
  }
331
- return { error: this.core.projectExternalError(run, trustedExecutionError ?? event.error, 'toolCallback').message };
331
+ return {
332
+ error: this.core.projectExternalError(run, trustedExecutionError ?? event.error, 'toolCallback').message,
333
+ ...(trustedExecutionError instanceof FlexHarnessPermissionRejectedError
334
+ ? { rejectedPermissionId: trustedExecutionError.permissionId }
335
+ : {}),
336
+ };
332
337
  }
333
338
 
334
339
  private completeToolPart(
@@ -344,6 +349,7 @@ export class FlexHarnessRunCallbacks<TScope> {
344
349
  part.status = 'failed';
345
350
  delete part.output;
346
351
  part.error = outcome.error;
352
+ if (outcome.rejectedPermissionId) part.rejectedPermissionId = outcome.rejectedPermissionId;
347
353
  }
348
354
  const original = run.deduplicatedToolCalls.get(toolCallId);
349
355
  if (original !== undefined) {
@@ -1125,7 +1125,8 @@ export class FlexHarnessCore<TScope> {
1125
1125
  }
1126
1126
  if (status !== 'completed') {
1127
1127
  const message = status === 'cancelled' ? repairCancellationMessage : externalErrorFallback.message;
1128
- userMessage.error = message;
1128
+ // As for a run that ends live: a failed run marks its prompt too, a cancelled one only its answer.
1129
+ if (status === 'failed') userMessage.error = message;
1129
1130
  assistantMessage.error = message;
1130
1131
  if (stage?.usage) {
1131
1132
  assistantMessage.usage = cloneSerializable(stage.usage);
@@ -115,10 +115,12 @@ export class FlexHarnessPermissions<TScope> {
115
115
  origin: 'policy',
116
116
  });
117
117
  if (policyDecision === 'deny') {
118
+ // A policy deny is the session's decision, not a fault: the run stops as cancelled.
118
119
  const rejection = this.core.trustInternalError(
119
120
  new FlexHarnessPermissionRejectedError(candidate.permissionId),
120
121
  );
121
- this.core.runs.abortRunInternally(run, rejection);
122
+ if (candidate.toolCallId) run.trustedToolExecutionErrors.set(candidate.toolCallId, rejection);
123
+ this.core.runs.cancelRun(run, rejection);
122
124
  throw rejection;
123
125
  }
124
126
  return;
@@ -345,10 +347,14 @@ export class FlexHarnessPermissions<TScope> {
345
347
  decision,
346
348
  });
347
349
  if (decision === 'reject') {
350
+ // A person's rejection stops the run as cancelled, like an owner's stop, not as a failure.
348
351
  const rejection = this.core.trustInternalError(
349
352
  new FlexHarnessPermissionRejectedError(pending.request.permissionId),
350
353
  );
351
- this.core.runs.abortRunInternally(pending.run, rejection, context);
354
+ // The tool part of the refused call names the rejection rather than a generic failure.
355
+ const { toolCallId } = pending.request;
356
+ if (toolCallId) pending.run.trustedToolExecutionErrors.set(toolCallId, rejection);
357
+ this.core.runs.cancelRun(pending.run, rejection, context);
352
358
  pending.reject(rejection);
353
359
  } else {
354
360
  pending.resolve();
@@ -37,6 +37,7 @@ import type {
37
37
  IPendingPromptAdmission,
38
38
  } from './interfaces.flexharness.js';
39
39
  import { combineErrors, isAbortError } from './utils.flexharness.js';
40
+ import { sessionPreview } from './utils.sessionpreview.js';
40
41
  import type { FlexHarnessCore } from './classes.flexharness.core.js';
41
42
 
42
43
  const maxScheduleDebounceMs = 24 * 60 * 60 * 1000;
@@ -188,9 +189,14 @@ export class FlexHarnessPromptQueue<TScope> {
188
189
  if (stored.terminalPromptQueueEntries.has(queueId)) return false;
189
190
  throw new FlexHarnessNotFoundError('Prompt queue entry', queueId);
190
191
  }
192
+ // Only a prompt still waiting in the FIFO is a cancelled queued prompt. One that started is a
193
+ // run the owner stopped, and says so exactly as `abort()` does.
194
+ const reason = queued.status === 'queued'
195
+ ? new FlexHarnessAbortError('The queued prompt was cancelled.')
196
+ : new FlexHarnessAbortError();
191
197
  return this.cancelQueuedPrompt(
192
198
  queued,
193
- this.core.trustInternalError(new FlexHarnessAbortError('The queued prompt was cancelled.')),
199
+ this.core.trustInternalError(reason),
194
200
  this.core.compaction.createCompactorContext(scopeId, scope.scope, state.storageKey, sessionId),
195
201
  );
196
202
  }
@@ -634,6 +640,8 @@ export class FlexHarnessPromptQueue<TScope> {
634
640
  startedAt: reservation.userMessage.createdAt,
635
641
  };
636
642
  run.stored.session.updatedAt = reservation.userMessage.createdAt;
643
+ const preview = run.stored.session.preview ?? sessionPreview(run.stored.messages);
644
+ if (preview !== undefined) run.stored.session.preview = preview;
637
645
  });
638
646
  } catch (error) {
639
647
  if (containsStoreCommitUncertainty(error)) run.state.lifecycle = 'fenced';
@@ -11,6 +11,7 @@ import {
11
11
  FLEX_REVERSION_REASON_CODE_MAX_BYTES,
12
12
  FLEX_REVERSION_REFERENCE_MAX_BYTES,
13
13
  FLEX_REVERSION_WORKSPACE_ID_MAX_BYTES,
14
+ FLEX_SESSION_REVERSION_BARRIER_CAUSES,
14
15
  FLEX_REVERSION_WORKSPACE_LABEL_MAX_BYTES,
15
16
  type IFlexAgentContextInvocation,
16
17
  type IFlexAffectedWorkspace,
@@ -24,6 +25,7 @@ import {
24
25
  type IFlexSessionTombstone,
25
26
  type IFlexUndoSessionResult,
26
27
  type TFlexProjectionSnapshot,
28
+ type TFlexSessionReversionBarrierCause,
27
29
  type TFlexTurnReversionFinalizedOutcome,
28
30
  type TJsonValue,
29
31
  } from './interfaces.js';
@@ -81,6 +83,8 @@ export class FlexHarnessReversions<TScope> {
81
83
  visibility: index < stored.revertCursor ? 'visible' : 'hidden',
82
84
  affectedWorkspaces: cloneSerializable(group.affectedWorkspaces),
83
85
  affectedWorkspacesTruncated: group.affectedWorkspacesTruncated,
86
+ barrierCauses: [...group.barrierCauses],
87
+ reasonCodes: [...group.reasonCodes],
84
88
  })),
85
89
  });
86
90
  }
@@ -143,6 +147,8 @@ export class FlexHarnessReversions<TScope> {
143
147
  kind: 'candidate',
144
148
  affectedWorkspaces: [],
145
149
  affectedWorkspacesTruncated: false,
150
+ barrierCauses: [],
151
+ reasonCodes: [],
146
152
  });
147
153
  } else if (groups.length > 0) {
148
154
  groups[groups.length - 1].segments.push(segment);
@@ -162,13 +168,23 @@ export class FlexHarnessReversions<TScope> {
162
168
  }
163
169
  }
164
170
  group.affectedWorkspaces = [...affected.values()];
171
+ group.reasonCodes = [...new Set(group.segments.flatMap((segment) => (
172
+ segment.disposition === 'nonrevertible' && segment.reasonCode !== undefined
173
+ ? [segment.reasonCode]
174
+ : []
175
+ )))];
165
176
  if (this.core.reversionPolicy === 'transcript-optional') continue;
166
- if (group.segments.some((segment) =>
167
- segment.status === 'capturing'
168
- || segment.disposition === 'pending'
169
- || (segment.protocolVersion === 1 && segment.provenance === 'transcript')
170
- || segment.disposition === 'nonrevertible')) {
177
+ const causes = new Set<TFlexSessionReversionBarrierCause>();
178
+ for (const segment of group.segments) {
179
+ if (segment.status === 'capturing' || segment.disposition === 'pending') causes.add('pending');
180
+ if (segment.disposition === 'nonrevertible') causes.add('nonrevertible');
181
+ if (segment.protocolVersion === 1 && segment.provenance === 'transcript') {
182
+ causes.add('transcript-only');
183
+ }
184
+ }
185
+ if (causes.size > 0) {
171
186
  group.kind = 'barrier';
187
+ group.barrierCauses = FLEX_SESSION_REVERSION_BARRIER_CAUSES.filter((cause) => causes.has(cause));
172
188
  } else if (group.segments.some((segment) => segment.disposition === 'revertible')) {
173
189
  group.kind = 'candidate';
174
190
  } else {
@@ -3,6 +3,7 @@ import {
3
3
  FlexHarnessAbortError,
4
4
  FlexHarnessExternalError,
5
5
  FlexHarnessNotFoundError,
6
+ FlexHarnessPermissionRejectedError,
6
7
  FlexHarnessRunError,
7
8
  FlexHarnessStepLimitError,
8
9
  FlexHarnessValidationError,
@@ -53,6 +54,7 @@ import {
53
54
  externalErrorFallback,
54
55
  requireSessionGeneration,
55
56
  combineErrors,
57
+ delegatedCancellation,
56
58
  terminalActivityError,
57
59
  } from './utils.flexharness.js';
58
60
  import type { FlexHarnessCore } from './classes.flexharness.core.js';
@@ -664,9 +666,8 @@ export class FlexHarnessRuns<TScope> {
664
666
  if (!active || active.runId !== runId || active.phase === 'finalizing' || active.phase === 'promoting') {
665
667
  return false;
666
668
  }
667
- const cancellation = reason instanceof FlexHarnessAbortError
668
- ? reason
669
- : this.core.trustInternalError(new FlexHarnessAbortError('The parent run was aborted.'));
669
+ const cancellation = delegatedCancellation(reason)
670
+ ?? this.core.trustInternalError(new FlexHarnessAbortError('The parent run was aborted.'));
670
671
  this.cancelRun(active, cancellation);
671
672
  return true;
672
673
  }
@@ -914,9 +915,15 @@ export class FlexHarnessRuns<TScope> {
914
915
  assistantMessage.usage = cloneSerializable(run.usage);
915
916
  const message = truncateUtf8(error?.message ?? externalErrorFallback.message, maxTransferMetadataBytes);
916
917
  const limit = error instanceof FlexHarnessExternalError ? error.limit : undefined;
917
- for (const failedMessage of [userMessage, assistantMessage]) {
918
+ const rejectedPermissionId = error instanceof FlexHarnessPermissionRejectedError
919
+ ? error.permissionId
920
+ : undefined;
921
+ // A failed turn fails its prompt too. A stopped turn is no fault of its prompt, so the prompt
922
+ // stays an ordinary user message and only the answer it stopped carries the stop.
923
+ for (const failedMessage of status === 'cancelled' ? [assistantMessage] : [userMessage, assistantMessage]) {
918
924
  failedMessage.error = message;
919
925
  if (limit) failedMessage.limit = cloneSerializable(limit);
926
+ if (rejectedPermissionId) failedMessage.rejectedPermissionId = rejectedPermissionId;
920
927
  }
921
928
  }
922
929
  return {
@@ -41,6 +41,7 @@ import {
41
41
  requireSessionGeneration,
42
42
  sha256Hex,
43
43
  combineErrors,
44
+ delegatedCancellation,
44
45
  } from './utils.flexharness.js';
45
46
  import type { FlexHarnessCore } from './classes.flexharness.core.js';
46
47
 
@@ -379,9 +380,8 @@ export class FlexHarnessSubagents<TScope> {
379
380
  let queued: Awaited<ReturnType<typeof this.core.prompts.enqueuePromptInternal>> | undefined;
380
381
  let admission: IFlexPromptAdmission | undefined;
381
382
  const abortChild = () => {
382
- const reason = run.controller.signal.reason instanceof FlexHarnessAbortError
383
- ? run.controller.signal.reason
384
- : this.core.trustInternalError(new FlexHarnessAbortError('The parent run was aborted.'));
383
+ const reason = delegatedCancellation(run.controller.signal.reason)
384
+ ?? this.core.trustInternalError(new FlexHarnessAbortError('The parent run was aborted.'));
385
385
  const ownedChild = child ?? run.state.sessions.get(reservedSessionId);
386
386
  if (
387
387
  ownedChild
package/ts/errors.ts CHANGED
@@ -112,8 +112,9 @@ export class FlexHarnessQueueFullError extends FlexHarnessError {
112
112
  }
113
113
 
114
114
  export class FlexHarnessAbortError extends FlexHarnessError {
115
- constructor(message = 'The run was aborted.', options?: ErrorOptions) {
116
- super('FLEX_ABORTED', message, options);
115
+ /** `code` names a more specific stop for a subclass, such as a rejected permission. */
116
+ constructor(message = 'The run was aborted.', options?: ErrorOptions, code = 'FLEX_ABORTED') {
117
+ super(code, message, options);
117
118
  this.name = 'FlexHarnessAbortError';
118
119
  }
119
120
  }
@@ -143,10 +144,17 @@ export class FlexHarnessExternalError extends FlexHarnessError {
143
144
  }
144
145
  }
145
146
 
146
- export class FlexHarnessPermissionRejectedError extends FlexHarnessError {
147
+ /**
148
+ * A permission was rejected, by a `reject` response or by the session's permission policy. The
149
+ * rejection is a decision, not a fault: it stops the run like an owner's cancellation.
150
+ */
151
+ export class FlexHarnessPermissionRejectedError extends FlexHarnessAbortError {
152
+ public readonly permissionId: string;
153
+
147
154
  constructor(permissionId: string, options?: ErrorOptions) {
148
- super('FLEX_PERMISSION_REJECTED', `Permission "${permissionId}" was rejected.`, options);
155
+ super(`Permission "${permissionId}" was rejected.`, options, 'FLEX_PERMISSION_REJECTED');
149
156
  this.name = 'FlexHarnessPermissionRejectedError';
157
+ this.permissionId = permissionId;
150
158
  }
151
159
  }
152
160
 
@@ -247,6 +255,7 @@ export function errorToInfo(error: unknown): IFlexErrorInfo {
247
255
  message: error.message,
248
256
  code: error.code,
249
257
  ...(error instanceof FlexHarnessExternalError && error.limit ? { limit: error.limit } : {}),
258
+ ...(error instanceof FlexHarnessPermissionRejectedError ? { permissionId: error.permissionId } : {}),
250
259
  };
251
260
  }
252
261
  if (error instanceof Error) {
@@ -35,6 +35,7 @@ import type {
35
35
  TFlexPromptQueueStatus,
36
36
  TFlexPendingReversion,
37
37
  TFlexProjectionSnapshot,
38
+ TFlexSessionReversionBarrierCause,
38
39
  TFlexSessionReversionGroupKind,
39
40
  TFlexTurnReversionFinalizedOutcome,
40
41
  } from './interfaces.js';
@@ -114,6 +115,8 @@ export interface IReversionGroup {
114
115
  kind: TFlexSessionReversionGroupKind;
115
116
  affectedWorkspaces: IFlexAffectedWorkspace[];
116
117
  affectedWorkspacesTruncated: boolean;
118
+ barrierCauses: TFlexSessionReversionBarrierCause[];
119
+ reasonCodes: string[];
117
120
  }
118
121
 
119
122
  export type TNormalizedCaptureInspection =
package/ts/interfaces.ts CHANGED
@@ -174,6 +174,8 @@ export interface IFlexToolMessagePart {
174
174
  * did not run; its output is the earlier call's.
175
175
  */
176
176
  deduplicatedFrom?: string;
177
+ /** For a failed call whose permission was rejected: the rejected request's `permissionId`. */
178
+ rejectedPermissionId?: string;
177
179
  model?: IFlexModelIdentity;
178
180
  /** ISO time the tool call began. Absent on parts recorded before part times existed. */
179
181
  startedAt?: string;
@@ -216,6 +218,8 @@ export interface IFlexMessage {
216
218
  error?: string;
217
219
  /** The model limit that failed this run, next to its error. */
218
220
  limit?: TFlexModelLimit;
221
+ /** The permission whose rejection stopped this run, next to its error. */
222
+ rejectedPermissionId?: string;
219
223
  /** On a user message that steered its running run: the caller's steer identity. */
220
224
  steerId?: string;
221
225
  }
@@ -228,6 +232,8 @@ export interface IFlexSessionActivity {
228
232
  error?: string;
229
233
  /** The model limit that failed the last run, next to its error. */
230
234
  limit?: TFlexModelLimit;
235
+ /** The permission whose rejection stopped the last run, next to its error. */
236
+ rejectedPermissionId?: string;
231
237
  }
232
238
 
233
239
  export interface IFlexSession {
@@ -238,6 +244,13 @@ export interface IFlexSession {
238
244
  /** Opaque immutable configuration revision bound to this exact session generation. */
239
245
  configurationRef?: string;
240
246
  title?: string;
247
+ /**
248
+ * The session's first prompt as one line, bounded like a title (`maxTitleBytes`), so a session
249
+ * nobody titled can still be named. The harness sets it when the first run with prompt text is
250
+ * admitted, and for a session stored before previews existed at its next run; it never changes
251
+ * afterwards and is never set by a caller. Absent until then.
252
+ */
253
+ preview?: string;
241
254
  createdAt: string;
242
255
  updatedAt: string;
243
256
  archivedAt?: string;
@@ -1696,12 +1709,30 @@ export type TFlexReversionPolicy = 'transcript-optional' | 'workspace-required';
1696
1709
 
1697
1710
  export type TFlexSessionReversionGroupKind = 'candidate' | 'barrier' | 'no-change';
1698
1711
 
1712
+ /**
1713
+ * Why a group is a barrier: `pending` while a capture runs or its outcome is not final,
1714
+ * `nonrevertible` for a workspace outcome that cannot be reverted, `transcript-only` for legacy
1715
+ * transcript history without a workspace capture.
1716
+ */
1717
+ export type TFlexSessionReversionBarrierCause = 'pending' | 'nonrevertible' | 'transcript-only';
1718
+
1719
+ /** The barrier causes in the order a group lists them. */
1720
+ export const FLEX_SESSION_REVERSION_BARRIER_CAUSES: readonly TFlexSessionReversionBarrierCause[] = [
1721
+ 'pending',
1722
+ 'nonrevertible',
1723
+ 'transcript-only',
1724
+ ];
1725
+
1699
1726
  export interface IFlexSessionReversionGroup {
1700
1727
  readonly runId: string;
1701
1728
  readonly kind: TFlexSessionReversionGroupKind;
1702
1729
  readonly visibility: 'visible' | 'hidden';
1703
1730
  readonly affectedWorkspaces: readonly IFlexAffectedWorkspace[];
1704
1731
  readonly affectedWorkspacesTruncated: boolean;
1732
+ /** Each cause of a barrier once, in the order of the type; empty for every other kind. */
1733
+ readonly barrierCauses: readonly TFlexSessionReversionBarrierCause[];
1734
+ /** The distinct reason codes of the group's nonrevertible segments, in segment order. */
1735
+ readonly reasonCodes: readonly string[];
1705
1736
  }
1706
1737
 
1707
1738
  export interface IFlexSessionReversionInfo {
@@ -2128,6 +2159,8 @@ export interface IFlexErrorInfo {
2128
2159
  code?: string;
2129
2160
  /** Set when a model usage or rate limit caused the error. */
2130
2161
  limit?: TFlexModelLimit;
2162
+ /** Set when a rejected permission stopped the run: the rejected request's `permissionId`. */
2163
+ permissionId?: string;
2131
2164
  }
2132
2165
 
2133
2166
  export type TFlexHarnessEvent =
package/ts/readme.md CHANGED
@@ -693,7 +693,7 @@ await harness.dispose();
693
693
 
694
694
  Only one run may be active in a session. Additional prompts enter a bounded FIFO owned by FlexHarness, while different sessions can run concurrently. `enqueuePrompt()` resolves with `{ queueId, completion }` after the immutable prompt and options have been accepted into that runtime queue and `prompt.queued` has been emitted. `startPrompt()` keeps its durable-admission behavior: it waits for its FIFO turn and resolves with `{ queueId, runId, completion }` only after the canonical generation claim, run ID, and initial public audit messages have been durably reserved and the corresponding start events have been emitted. `prompt()` preserves the simpler behavior by awaiting completion internally.
695
695
 
696
- Queue entries expose `queued`, `starting`, `scheduled`, `running`, `completed`, `failed`, and `cancelled` status through `getPromptQueueEntry()` and `listPromptQueueEntries()`. The list is ordered by process-local `queueSequence`. `getPromptQueueEntry()` throws `FlexHarnessNotFoundError` for an unknown or evicted ID. `cancelPrompt()` cancels one exact queue ID: a waiting entry leaves the FIFO and releases capacity immediately but remains queryable as `cancelled` until terminal retention evicts it; a promoted entry uses the canonical run cancellation path. Cancelling a terminal entry returns `false`, while an unknown ID throws. `abort()` remains scoped to the currently active run.
696
+ Queue entries expose `queued`, `starting`, `scheduled`, `running`, `completed`, `failed`, and `cancelled` status through `getPromptQueueEntry()` and `listPromptQueueEntries()`. The list is ordered by process-local `queueSequence`. `getPromptQueueEntry()` throws `FlexHarnessNotFoundError` for an unknown or evicted ID. `cancelPrompt()` cancels one exact queue ID: a waiting entry leaves the FIFO and releases capacity immediately but remains queryable as `cancelled` until terminal retention evicts it; a promoted entry uses the canonical run cancellation path and is stopped with the same `FlexHarnessAbortError` ("The run was aborted.") as `abort()`; only a waiting entry is rejected with "The queued prompt was cancelled.". A cancelled run, whether stopped or found interrupted on reload, puts its `error` on the assistant message only; its user message is `cancelled` without an `error`, while a failed run marks both messages. Cancelling a terminal entry returns `false`, while an unknown ID throws. `abort()` remains scoped to the currently active run.
697
697
 
698
698
  The displayed queue limits are the defaults. Outstanding count and byte limits apply per session and include every non-terminal queued or active prompt until it settles. Pending-admission limits apply to the complete harness while scope aliases are unresolved. Terminal retention applies per session. Exceeding an admission limit throws `FlexHarnessQueueFullError`.
699
699
 
@@ -705,7 +705,7 @@ The reservation save is the admission point. A save failure produces no start ev
705
705
 
706
706
  `listMessagePage()` returns the newest contiguous page in chronological order. `limit` must be an integer from 1 through 50 and defaults to 50. `nextCursor` is opaque, limited to 4096 UTF-8 bytes, bound to the resolved storage namespace and session, and remains stable when newer messages are appended. Mismatched and stale cursors fail validation. Each page carries `startIndex`, the zero-based index of its first message in the session's visible history (for an empty page, the index the page ends at), so a consumer can order and page by absolute position. Appending messages leaves every index unchanged; a branch after an undo reuses the positions of the history it replaces, so a consumer re-reads its pages after `session.history.changed`. `getMessage()` performs an exact lookup. Transfer identifiers are limited to 512 bytes, text and reasoning parts to 96 KiB, complete messages to 480 KiB, and complete page envelopes to 512 KiB. A page may therefore contain fewer messages than requested. Oversized text is truncated and an otherwise oversized parts collection is replaced with an explicit elision marker; metadata that still cannot fit fails validation. Canonical private Agent events are unchanged.
707
707
 
708
- `updateSession()` supports title replacement, explicit title clearing with `null`, and archive state through `archived`. Title-only updates remain available while prompts are queued or running, while permission is pending, and after archival. Requests containing `archived` are rejected while the session has any outstanding prompt or pending permission; a mixed title-and-archive request is rejected atomically without changing the title. Archived sessions expose `archivedAt`. Deleting a session cascades through its complete descendant subtree. One durable root-keyed tombstone group hides every newly affected live session, and the delete also joins any already-separate descendant cleanup groups without rewriting their roots. FlexHarness then cancels queued and active subtree work, emits terminal queue events, waits for admitted initialization, and purges runtime queue status while cleaning runtime and persisted domains child-first. The requested root tombstone is removed last after every domain confirms cleanup; project-management cleanup confirmation is a retained durable project tombstone rather than physical removal. An imported session can hold no project-management state, so its deletion writes no project tombstone. A successful live `deleteSession()` call emits `session.deleted` for each session it newly tombstoned; retries of an existing tombstone and automatic load, retirement, or disposal cleanup emit no deletion events. Direct deletion of a descendant cascades only through that descendant's subtree. Cleanup authority follows the resolved storage namespace, so scope aliases share the same groups. A partial failure retains durable ownership for retry by a later `deleteSession()`, namespace load, `retireScope()`, or `dispose()` call.
708
+ `updateSession()` supports title replacement, explicit title clearing with `null`, and archive state through `archived`. Title-only updates remain available while prompts are queued or running, while permission is pending, and after archival. Requests containing `archived` are rejected while the session has any outstanding prompt or pending permission; a mixed title-and-archive request is rejected atomically without changing the title. Archived sessions expose `archivedAt`. Every session exposes `preview` once a run with prompt text was admitted: its first prompt as one line (whitespace runs collapsed, cut at a character boundary to `maxTitleBytes`), with no model call, so a host can name a session nobody titled (`title || preview`). It is set once, a session stored before previews existed takes it from its first prompt at its next run, and no caller sets or clears it. Deleting a session cascades through its complete descendant subtree. One durable root-keyed tombstone group hides every newly affected live session, and the delete also joins any already-separate descendant cleanup groups without rewriting their roots. FlexHarness then cancels queued and active subtree work, emits terminal queue events, waits for admitted initialization, and purges runtime queue status while cleaning runtime and persisted domains child-first. The requested root tombstone is removed last after every domain confirms cleanup; project-management cleanup confirmation is a retained durable project tombstone rather than physical removal. An imported session can hold no project-management state, so its deletion writes no project tombstone. A successful live `deleteSession()` call emits `session.deleted` for each session it newly tombstoned; retries of an existing tombstone and automatic load, retirement, or disposal cleanup emit no deletion events. Direct deletion of a descendant cascades only through that descendant's subtree. Cleanup authority follows the resolved storage namespace, so scope aliases share the same groups. A partial failure retains durable ownership for retry by a later `deleteSession()`, namespace load, `retireScope()`, or `dispose()` call.
709
709
 
710
710
  `deleteSessionGenerationCohort()` is the generation-fenced destructive form for controllers. Its `root` and every `authorizedCohort` entry use the exact `{ sessionId, sessionGenerationId, sessionGenerationSequence }` shape. The cohort contains unique entries in strictly ascending `sessionId` order and is limited by `FLEX_SESSION_GENERATION_COHORT_MAX_ENTRIES` (2048). FlexHarness atomically validates the matching root, its complete live subtree, and every retained descendant tombstone group before reserving deletion. A missing or different root generation returns `{ matched: false }` without touching a newer generation; a missing or mismatched cascade entry throws before new tombstoning or cleanup. Extra valid entries do not expand the cascade. A matched partial cleanup remains retryable with the same authority and resolves to `{ matched: true }` when cleanup completes. The existing `deleteSession()` method remains available for callers that intentionally own the complete reusable-ID namespace.
711
711
 
@@ -872,11 +872,13 @@ for (const group of info.groups) {
872
872
  group.visibility,
873
873
  group.affectedWorkspaces,
874
874
  group.affectedWorkspacesTruncated,
875
+ group.barrierCauses,
876
+ group.reasonCodes,
875
877
  );
876
878
  }
877
879
  ```
878
880
 
879
- The immutable result exposes `undoAvailable`, `redoAvailable`, and groups classified as `candidate`, `barrier`, or `no-change`. Group metadata contains at most 64 unique affected workspaces; `affectedWorkspacesTruncated` is `true` when additional unique descriptors were omitted. It never includes capture IDs or provider references.
881
+ The immutable result exposes `undoAvailable`, `redoAvailable`, and groups classified as `candidate`, `barrier`, or `no-change`. Group metadata contains at most 64 unique affected workspaces; `affectedWorkspacesTruncated` is `true` when additional unique descriptors were omitted. `barrierCauses` states why a barrier blocks traversal, each cause once in this order: `pending` while a capture runs or its outcome is not final, `nonrevertible` for a workspace outcome that cannot be reverted, `transcript-only` for legacy transcript history without a workspace capture; it is empty for candidate and no-change groups (`FLEX_SESSION_REVERSION_BARRIER_CAUSES` lists the order). `reasonCodes` lists the distinct `reasonCode`s of the group's nonrevertible segments in segment order, so an application can say why a turn is not revertible even when its provider named no affected workspace. It never includes capture IDs or provider references.
880
882
 
881
883
  `workspaceSnapshots` is application-owned. Every context contains `scopeId`, `scope`, `storageKey`, `sessionId`, the required canonical `sessionGenerationId` and `sessionGenerationSequence`, `runId`, deterministic `captureId`, and an `AbortSignal`. Apply contexts additionally contain the normalized `reference`, per-segment `operationId`, and `direction`; release contexts contain the reference. Deleted-session recovery retains the same generation from the durable tombstone, so providers can reject callbacks from an older same-ID generation.
882
884
 
@@ -923,7 +925,7 @@ Resolved model identity contains provider and model IDs plus optional display na
923
925
 
924
926
  Public audit history is safe to send to controllers: attachment parts contain source and size metadata, never inline base64, data URLs, or remote URL payloads. Canonical private Agent events retain those values solely for subsequent model turns.
925
927
 
926
- Sessions expose `idle`, `scheduled`, `running`, `waiting_permission`, `failed`, and `cancelled` status. Persisted non-terminal activity is repaired from canonical Agent generation outcomes after process restart; incomplete messages and parts normalize to `cancelled` unless an accepted hidden terminal stage can be promoted.
928
+ Sessions expose `idle`, `scheduled`, `running`, `waiting_permission`, `failed`, and `cancelled` status. A run stopped by `abort()`, `cancelPrompt()` or a rejected permission leaves its session `cancelled`; only a run that failed leaves it `failed`. Persisted non-terminal activity is repaired from canonical Agent generation outcomes after process restart; incomplete messages and parts normalize to `cancelled` unless an accepted hidden terminal stage can be promoted.
927
929
 
928
930
  ## Conversation Import
929
931
 
@@ -1023,6 +1025,8 @@ Pending requests are runtime-only and queryable with `listPendingPermissions()`.
1023
1025
 
1024
1026
  `always` is invalid when the request has no `rememberKey`. Remembered decisions are persisted before the waiting tool resolves. If persistence fails, the key is rolled back and the request remains pending so the response can be retried. Concurrent response attempts are serialized and exactly one successful response settles a request.
1025
1027
 
1028
+ `reject` stops the run as a decision, not as a failure. The waiting tool call fails with `FlexHarnessPermissionRejectedError`, a `FlexHarnessAbortError` with code `FLEX_PERMISSION_REJECTED` and the rejected `permissionId`, and the run ends the way `abort()` or `cancelPrompt()` ends it; the model is not asked to recover. The run's completion rejects with that error. `run.finished` and the finished queue entry have status `cancelled` and an `error` whose `permissionId` names the rejected request. The run's terminal user and assistant messages are `cancelled`; the assistant message carries `rejectedPermissionId` next to its `error`, and so does the session's `activity`; the session's status is `cancelled`, as after a stop. The refused tool part fails with the rejection as its error and carries the same `rejectedPermissionId`. Other permissions still pending in the run are rejected with it, and runs it delegated stop with "The parent run was aborted.". As with a stop, a run rejected after its model produced output stays in future context, where the refused call's result says the permission was rejected. A permission policy `deny` ends the run the same way.
1029
+
1026
1030
  ## Tool Output Safety
1027
1031
 
1028
1032
  FlexHarness wraps every provided tool `execute` method before AgentSession receives it. Direct outputs and every `AsyncIterable` yield are converted into bounded JSON-safe values. Circular references, functions, symbols, bigint values, dates, URLs, and binary values receive deterministic descriptions or records. Returned error objects and unreadable getter values receive fixed descriptions without their original messages. Thrown errors and iterator failures remain failures but are converted to the safe external-error projection before AgentSession observes them.
@@ -1,6 +1,7 @@
1
1
  import * as plugins from './plugins.js';
2
2
  import {
3
3
  FlexHarnessAbortError,
4
+ FlexHarnessPermissionRejectedError,
4
5
  FlexHarnessRunError,
5
6
  FlexHarnessValidationError,
6
7
  } from './errors.js';
@@ -106,14 +107,27 @@ export function combineErrors(errors: unknown[]): unknown {
106
107
  return Object.freeze(combined);
107
108
  }
108
109
 
109
- /** The error and model limit a finished run leaves on its session activity. */
110
- export function terminalActivityError(message: IFlexMessage): Pick<IFlexSessionActivity, 'error' | 'limit'> {
110
+ /** The error, model limit and rejected permission a finished run leaves on its session activity. */
111
+ export function terminalActivityError(
112
+ message: IFlexMessage,
113
+ ): Pick<IFlexSessionActivity, 'error' | 'limit' | 'rejectedPermissionId'> {
111
114
  return {
112
115
  ...(message.error ? { error: message.error } : {}),
113
116
  ...(message.limit ? { limit: message.limit } : {}),
117
+ ...(message.rejectedPermissionId ? { rejectedPermissionId: message.rejectedPermissionId } : {}),
114
118
  };
115
119
  }
116
120
 
121
+ /**
122
+ * The parent run's cancellation when the runs it delegated share it: a stop stops them alike, but a
123
+ * permission the parent rejected is the parent's own.
124
+ */
125
+ export function delegatedCancellation(reason: unknown): FlexHarnessAbortError | undefined {
126
+ return reason instanceof FlexHarnessAbortError && !(reason instanceof FlexHarnessPermissionRejectedError)
127
+ ? reason
128
+ : undefined;
129
+ }
130
+
117
131
  export function isAbortError(error: unknown): boolean {
118
132
  return error instanceof FlexHarnessAbortError
119
133
  || (error instanceof DOMException && error.name === 'AbortError')
package/ts/utils.json.ts CHANGED
@@ -533,6 +533,7 @@ function validateSession(value: unknown, path: string): IValidatedSessionIdentit
533
533
  'sessionGenerationSequence',
534
534
  'configurationRef',
535
535
  'title',
536
+ 'preview',
536
537
  'createdAt',
537
538
  'updatedAt',
538
539
  'archivedAt',
@@ -555,6 +556,7 @@ function validateSession(value: unknown, path: string): IValidatedSessionIdentit
555
556
  throw new FlexHarnessStoreFormatError(`${path}.configurationRef is invalid.`);
556
557
  }
557
558
  requireOptionalString(session.title, `${path}.title`);
559
+ requireOptionalString(session.preview, `${path}.preview`);
558
560
  requireString(session.createdAt, `${path}.createdAt`);
559
561
  requireString(session.updatedAt, `${path}.updatedAt`);
560
562
  requireOptionalString(session.archivedAt, `${path}.archivedAt`);
@@ -562,7 +564,11 @@ function validateSession(value: unknown, path: string): IValidatedSessionIdentit
562
564
  throw new FlexHarnessStoreFormatError(`${path}.status is invalid.`);
563
565
  }
564
566
  const activity = requireRecord(session.activity, `${path}.activity`);
565
- requireOnlyKeys(activity, ['runId', 'status', 'startedAt', 'completedAt', 'error', 'limit'], `${path}.activity`);
567
+ requireOnlyKeys(
568
+ activity,
569
+ ['runId', 'status', 'startedAt', 'completedAt', 'error', 'limit', 'rejectedPermissionId'],
570
+ `${path}.activity`,
571
+ );
566
572
  if (!activityStatuses.includes(activity.status as (typeof activityStatuses)[number])) {
567
573
  throw new FlexHarnessStoreFormatError(`${path}.activity.status is invalid.`);
568
574
  }
@@ -571,6 +577,7 @@ function validateSession(value: unknown, path: string): IValidatedSessionIdentit
571
577
  requireOptionalString(activity.completedAt, `${path}.activity.completedAt`);
572
578
  requireOptionalString(activity.error, `${path}.activity.error`);
573
579
  requireOptionalModelLimit(activity.limit, `${path}.activity.limit`);
580
+ requireOptionalString(activity.rejectedPermissionId, `${path}.activity.rejectedPermissionId`);
574
581
  const relationship = [
575
582
  session.parentSessionId,
576
583
  session.parentRunId,
@@ -767,6 +774,7 @@ function validateMessage(value: unknown, path: string): IValidatedMessageIdentit
767
774
  'usage',
768
775
  'error',
769
776
  'limit',
777
+ 'rejectedPermissionId',
770
778
  'steerId',
771
779
  ],
772
780
  path,
@@ -784,6 +792,7 @@ function validateMessage(value: unknown, path: string): IValidatedMessageIdentit
784
792
  requireOptionalString(message.completedAt, `${path}.completedAt`);
785
793
  requireOptionalString(message.error, `${path}.error`);
786
794
  requireOptionalModelLimit(message.limit, `${path}.limit`);
795
+ requireOptionalString(message.rejectedPermissionId, `${path}.rejectedPermissionId`);
787
796
  if (message.steerId !== undefined) {
788
797
  requireString(message.steerId, `${path}.steerId`);
789
798
  if (message.role !== 'user') {
@@ -834,6 +843,7 @@ function validateMessage(value: unknown, path: string): IValidatedMessageIdentit
834
843
  'childSessionId',
835
844
  'parentToolCallId',
836
845
  'deduplicatedFrom',
846
+ 'rejectedPermissionId',
837
847
  'model',
838
848
  'startedAt',
839
849
  'completedAt',
@@ -852,6 +862,7 @@ function validateMessage(value: unknown, path: string): IValidatedMessageIdentit
852
862
  requireOptionalString(part.childSessionId, `${partPath}.childSessionId`);
853
863
  requireOptionalString(part.parentToolCallId, `${partPath}.parentToolCallId`);
854
864
  requireOptionalString(part.deduplicatedFrom, `${partPath}.deduplicatedFrom`);
865
+ requireOptionalString(part.rejectedPermissionId, `${partPath}.rejectedPermissionId`);
855
866
  if (part.model !== undefined) validateModel(part.model, `${partPath}.model`);
856
867
  requireOptionalString(part.startedAt, `${partPath}.startedAt`);
857
868
  requireOptionalString(part.completedAt, `${partPath}.completedAt`);