@modelprofile.com/flexharness 12.5.0 → 13.1.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 (49) 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/classes.toolcatalog.d.ts +14 -0
  10. package/dist_ts/classes.toolcatalog.js +50 -28
  11. package/dist_ts/errors.d.ts +8 -2
  12. package/dist_ts/errors.js +13 -5
  13. package/dist_ts/interfaces.d.ts +27 -0
  14. package/dist_ts/interfaces.flexharness.d.ts +3 -1
  15. package/dist_ts/interfaces.js +7 -1
  16. package/dist_ts/utils.flexharness.d.ts +8 -2
  17. package/dist_ts/utils.flexharness.js +13 -3
  18. package/dist_ts/utils.json.js +9 -2
  19. package/dist_ts/utils.sessionpreview.d.ts +7 -0
  20. package/dist_ts/utils.sessionpreview.js +35 -0
  21. package/dist_ts_typedrequest/catalog.d.ts +3 -1
  22. package/dist_ts_typedrequest/catalog.js +8 -4
  23. package/dist_ts_typedrequest/describe.js +2 -22
  24. package/dist_ts_typedrequest/interfaces.d.ts +4 -1
  25. package/dist_ts_typedrequest/references.d.ts +17 -0
  26. package/dist_ts_typedrequest/references.js +65 -0
  27. package/package.json +3 -3
  28. package/readme.md +8 -4
  29. package/ts/00_commitinfo_data.ts +1 -1
  30. package/ts/classes.flexharness.callbacks.ts +9 -3
  31. package/ts/classes.flexharness.core.ts +2 -1
  32. package/ts/classes.flexharness.permissions.ts +8 -2
  33. package/ts/classes.flexharness.prompts.ts +9 -1
  34. package/ts/classes.flexharness.reversions.ts +21 -5
  35. package/ts/classes.flexharness.runs.ts +11 -4
  36. package/ts/classes.flexharness.subagents.ts +3 -3
  37. package/ts/classes.toolcatalog.ts +51 -23
  38. package/ts/errors.ts +13 -4
  39. package/ts/interfaces.flexharness.ts +3 -0
  40. package/ts/interfaces.ts +33 -0
  41. package/ts/readme.md +8 -4
  42. package/ts/utils.flexharness.ts +16 -2
  43. package/ts/utils.json.ts +12 -1
  44. package/ts/utils.sessionpreview.ts +32 -0
  45. package/ts_typedrequest/catalog.ts +7 -3
  46. package/ts_typedrequest/describe.ts +1 -22
  47. package/ts_typedrequest/interfaces.ts +4 -1
  48. package/ts_typedrequest/readme.md +1 -1
  49. package/ts_typedrequest/references.ts +68 -0
@@ -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
@@ -396,6 +396,31 @@ const typeIdentifierOf = (name: string): string => {
396
396
  return reservedWords.has(identifier) ? `${identifier}_` : identifier;
397
397
  };
398
398
 
399
+ /**
400
+ * The identifier each of a catalog's type names is declared under. A name that is an identifier
401
+ * keeps it; the others are mapped in code-point order, so a catalog always declares the same
402
+ * identifiers.
403
+ */
404
+ const typeIdentifiersOf = (typeNames: Iterable<string>): Map<string, string> => {
405
+ const typeIdentifiers = new Map<string, string>();
406
+ const taken = new Set<string>();
407
+ const names = [...new Set(typeNames)].sort(compareCodePoints);
408
+ for (const name of names) {
409
+ if (typeIdentifierOf(name) !== name) continue;
410
+ typeIdentifiers.set(name, name);
411
+ taken.add(name);
412
+ }
413
+ for (const name of names) {
414
+ if (typeIdentifiers.has(name)) continue;
415
+ const base = typeIdentifierOf(name);
416
+ let identifier = base;
417
+ for (let suffix = 2; taken.has(identifier); suffix++) identifier = `${base}_${suffix}`;
418
+ typeIdentifiers.set(name, identifier);
419
+ taken.add(identifier);
420
+ }
421
+ return typeIdentifiers;
422
+ };
423
+
399
424
  const textListOf = (
400
425
  value: unknown,
401
426
  label: string,
@@ -603,24 +628,7 @@ class FlexToolCatalogCore {
603
628
  }
604
629
  namedTypes.set(entry.name, entry);
605
630
  }
606
- // A name that is an identifier keeps it; the others are mapped in code-point order, so a
607
- // catalog always declares the same identifiers.
608
- const typeIdentifiers = new Map<string, string>();
609
- const taken = new Set<string>();
610
- const names = [...namedTypes.keys()].sort(compareCodePoints);
611
- for (const name of names) {
612
- if (typeIdentifierOf(name) !== name) continue;
613
- typeIdentifiers.set(name, name);
614
- taken.add(name);
615
- }
616
- for (const name of names) {
617
- if (typeIdentifiers.has(name)) continue;
618
- const base = typeIdentifierOf(name);
619
- let identifier = base;
620
- for (let suffix = 2; taken.has(identifier); suffix++) identifier = `${base}_${suffix}`;
621
- typeIdentifiers.set(name, identifier);
622
- taken.add(identifier);
623
- }
631
+ const typeIdentifiers = typeIdentifiersOf(namedTypes.keys());
624
632
  /** Catalog types by the name their `$ref`s use. */
625
633
  const catalogTypes = new Map<string, ITypeEntry>();
626
634
  for (const [name, entry] of namedTypes) {
@@ -949,11 +957,7 @@ class FlexToolCatalogCore {
949
957
 
950
958
  /** The catalog types a declaration's code names; its comments and string literals name none. */
951
959
  private scanTypes(text: string, self?: string): string[] {
952
- const found = new Set<string>();
953
- for (const match of codeOf(text).matchAll(identifierScanPattern)) {
954
- if (match[0] !== self && this.types.has(match[0])) found.add(match[0]);
955
- }
956
- return [...found].sort(compareCodePoints);
960
+ return identifiersIn(text).filter((name) => name !== self && this.types.has(name));
957
961
  }
958
962
 
959
963
  /** The named types a method's parameters and result use. */
@@ -1059,6 +1063,26 @@ export class FlexToolCatalog<TScope = unknown> {
1059
1063
  return typeIdentifierOf(name);
1060
1064
  }
1061
1065
 
1066
+ /**
1067
+ * The identifier each type name of a catalog of exactly these types is declared under: a name that
1068
+ * is an identifier keeps it, and each other takes `identifierOf()`'s, in code-point order with
1069
+ * `_2`, `_3` … appended where that is taken (`a_b` and `a-b` are declared as `a_b` and `a_b_2`).
1070
+ * As a name's identifier depends on the other names, a host that hands a catalog only some of its
1071
+ * types keeps the ones that took the lower identifiers along.
1072
+ */
1073
+ public static identifiersOf(names: Iterable<string>): Map<string, string> {
1074
+ return typeIdentifiersOf(names);
1075
+ }
1076
+
1077
+ /**
1078
+ * The identifiers a declaration's code names, sorted; its comments and the contents of its string
1079
+ * literals name none. The catalog takes the named types a declaration uses from these, so a host
1080
+ * that hands a catalog only some of its types finds the ones a declaration needs the same way.
1081
+ */
1082
+ public static identifiersIn(declaration: string): string[] {
1083
+ return identifiersIn(declaration);
1084
+ }
1085
+
1062
1086
  /**
1063
1087
  * Prepares `catalog`. Preparing the same object again returns the cached preparation, so a host
1064
1088
  * that returns one catalog object for every run pays for it once.
@@ -1824,6 +1848,10 @@ const codeOf = (text: string): string => {
1824
1848
  return code;
1825
1849
  };
1826
1850
 
1851
+ /** The identifiers a declaration's code names, sorted; its comments and string literals name none. */
1852
+ const identifiersIn = (text: string): string[] =>
1853
+ [...new Set(codeOf(text).match(identifierScanPattern))].sort(compareCodePoints);
1854
+
1827
1855
  /** The index of the bracket that closes the one at `open`, or -1. */
1828
1856
  const closingBracket = (code: string, open: number): number => {
1829
1857
  const pairs: Record<string, string> = { '(': ')', '<': '>', '{': '}', '[': ']' };
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`);
@@ -0,0 +1,32 @@
1
+ import { FLEX_PROJECT_MANAGEMENT_LIMITS, type IFlexMessage } from './interfaces.js';
2
+
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.
7
+ */
8
+ export function sessionPreview(messagesArg: readonly IFlexMessage[]): string | undefined {
9
+ for (const message of messagesArg) {
10
+ if (message.role !== 'user') continue;
11
+ const text = message.parts
12
+ .map((part) => (part.type === 'text' ? part.text : ''))
13
+ .join(' ')
14
+ .replace(/\s+/gu, ' ')
15
+ .trim();
16
+ if (text) return utf8Prefix(text, FLEX_PROJECT_MANAGEMENT_LIMITS.maxTitleBytes);
17
+ }
18
+ return undefined;
19
+ }
20
+
21
+ /** The longest prefix of whole characters within `maxBytesArg` UTF-8 bytes. */
22
+ function utf8Prefix(valueArg: string, maxBytesArg: number): string {
23
+ if (Buffer.byteLength(valueArg, 'utf8') <= maxBytesArg) return valueArg;
24
+ let bytes = 0;
25
+ let end = 0;
26
+ for (const character of valueArg) {
27
+ bytes += Buffer.byteLength(character, 'utf8');
28
+ if (bytes > maxBytesArg) break;
29
+ end += character.length;
30
+ }
31
+ return valueArg.slice(0, end).trimEnd();
32
+ }
@@ -6,6 +6,7 @@ import type {
6
6
  IFlexTypedRequestSurface,
7
7
  } from './interfaces.js';
8
8
  import { normalizeOmissionPaths, withoutRequestPaths, withRequestPaths } from './omissions.js';
9
+ import { typesReachedBy } from './references.js';
9
10
 
10
11
  /**
11
12
  * A typed request the router refused: the `text` and `data` of its error, as a client of the API
@@ -31,7 +32,9 @@ export class FlexTypedRequestError extends Error {
31
32
  * `router.routeAndAddResponse()` in process, its request what `params` makes of the model's
32
33
  * parameters with the omitted properties its request has as `request` fills them, and its result
33
34
  * what `response` makes of the answer. A refusal rejects with `FlexTypedRequestError`. A method
34
- * `readOnly` marks is declared read-only, including when `onCall` decides its calls.
35
+ * `readOnly` marks is declared read-only, including when `onCall` decides its calls. The catalog's
36
+ * types are the surface's types the included methods reach: one only methods left out use is
37
+ * neither declared nor described.
35
38
  */
36
39
  export function catalogFromTypedRequests<TScope = unknown>(
37
40
  surface: IFlexTypedRequestSurface,
@@ -108,8 +111,9 @@ export function catalogFromTypedRequests<TScope = unknown>(
108
111
  ? await options.response(method, answer.response as Record<string, unknown>, context)
109
112
  : answer.response;
110
113
  };
114
+ const included = surface.methods.filter((method) => include(method));
111
115
  return {
112
- methods: surface.methods.filter((method) => include(method)).map((method): IFlexCatalogMethod<TScope> => ({
116
+ methods: included.map((method): IFlexCatalogMethod<TScope> => ({
113
117
  method: method.path,
114
118
  summary: method.summary,
115
119
  ...(method.description ? { description: method.description } : {}),
@@ -123,7 +127,7 @@ export function catalogFromTypedRequests<TScope = unknown>(
123
127
  ? { onCall: (params: Record<string, unknown>, context: IFlexCatalogRunContext<TScope>) => options.onCall!(method, params, context) }
124
128
  : {}),
125
129
  })),
126
- types: surface.types,
130
+ types: typesReachedBy(included, surface.types),
127
131
  ...(options.vocabulary ? { vocabulary: options.vocabulary } : {}),
128
132
  ...(options.onProposals ? { onProposals: options.onProposals } : {}),
129
133
  };
@@ -7,14 +7,11 @@ import type {
7
7
  TFlexTypedRequestSchema,
8
8
  } from './interfaces.js';
9
9
  import { normalizeOmissionPaths } from './omissions.js';
10
+ import { definitionsPrefix, isRecord, referencedType, referencesIn } from './references.js';
10
11
 
11
12
  const componentsPrefix = '#/components/schemas/';
12
- const definitionsPrefix = '#/$defs/';
13
13
  const maxSummaryCharacters = 120;
14
14
 
15
- const isRecord = (value: unknown): value is Record<string, unknown> =>
16
- typeof value === 'object' && value !== null && !Array.isArray(value);
17
-
18
15
  /** A schema whose references name `#/$defs/<type>` instead of the OpenAPI components. */
19
16
  const withDefinitionRefs = (value: unknown): unknown => {
20
17
  if (Array.isArray(value)) return value.map(withDefinitionRefs);
@@ -27,12 +24,6 @@ const withDefinitionRefs = (value: unknown): unknown => {
27
24
  ]));
28
25
  };
29
26
 
30
- /** The type a `#/$defs/<type>` reference names. */
31
- const referencedType = (schema: TFlexTypedRequestSchema): string | undefined =>
32
- typeof schema.$ref === 'string' && schema.$ref.startsWith(definitionsPrefix)
33
- ? schema.$ref.slice(definitionsPrefix.length)
34
- : undefined;
35
-
36
27
  /**
37
28
  * A request schema without the omitted properties: an object loses them, and a reference to a
38
29
  * named type that holds one becomes the type's own schema without them.
@@ -167,18 +158,6 @@ const requiresNothing = (
167
158
  || schema.allOf.every((member) => !isRecord(member) || requiresNothing(member, types));
168
159
  };
169
160
 
170
- /** The named types a schema refers to. */
171
- const referencesIn = (value: unknown, found: Set<string>): Set<string> => {
172
- if (Array.isArray(value)) {
173
- for (const entry of value) referencesIn(entry, found);
174
- } else if (isRecord(value)) {
175
- const target = referencedType(value);
176
- if (target !== undefined) found.add(target);
177
- for (const child of Object.values(value)) referencesIn(child, found);
178
- }
179
- return found;
180
- };
181
-
182
161
  /** The first sentence of a contract's JSDoc, its tags left out, within the summary's length. */
183
162
  const summaryOf = (documentation: string | undefined, method: string): string => {
184
163
  const prose = (documentation ?? '').split(/\n(?=@)/)[0].replace(/\s+/g, ' ').trim();
@@ -125,7 +125,10 @@ export interface IFlexTypedRequestCatalogOptions<TScope = unknown> {
125
125
  method: IFlexTypedRequestMethod,
126
126
  context: IFlexCatalogRunContext<TScope>,
127
127
  ): Record<string, unknown> | PromiseLike<Record<string, unknown>>;
128
- /** The methods the model may use. Default: every method that is not deprecated. */
128
+ /**
129
+ * The methods the model may use. Default: every method that is not deprecated. The catalog keeps
130
+ * only the named types these methods reach.
131
+ */
129
132
  include?(method: IFlexTypedRequestMethod): boolean;
130
133
  /**
131
134
  * The methods that only read: a call has no effect but its result. Such a method is declared
@@ -41,7 +41,7 @@ The surface is plain JSON, so a build step can write it next to the server and t
41
41
  - `request` fills the omitted properties. A call takes from what it returns only the omitted properties its method's request has, so one answer serves every method (`{ identity, csrfToken }`), and a method whose request has none is routed without asking it. A value the model passes for any omitted property never reaches the router.
42
42
  - Method-specific fills copy only the selected leaves, retaining public siblings. Array wildcards fill existing elements at corresponding indexes without adding or removing elements. An absent object parent can be created for a supplied leaf; an explicit null or scalar parent is refused. Input objects are not mutated. A surface with additional paths requires `request`; a serialized global-only surface without `omittedPaths` remains valid.
43
43
  - A refusal rejects with `FlexTypedRequestError`: the typed method, the error's `text` and `data`, as a client of the API would receive them. A handler's own failure arrives as the router's internal error. A host's `externalErrorProjector` decides what of it the model sees.
44
- - `include` picks the methods the model may use, by default every method that is not deprecated; `aliases` gives methods other names by typed method; `vocabulary`, `onCall` and `onProposals` are the catalog's own (`onCall` receives the typed method first).
44
+ - `include` picks the methods the model may use, by default every method that is not deprecated, and the catalog's types are the surface's types those methods reach, through their schemas' `$ref`s and the names their declarations' code uses: a type only methods left out use is neither declared nor described, unless it holds a lower identifier of a kept type's name, which keeps that type's identifier as the whole surface declares it (`a_b` stays for `a-b`, declared `a_b_2`; `FlexToolCatalog.identifiersOf()`). `aliases` gives methods other names by typed method; `vocabulary`, `onCall` and `onProposals` are the catalog's own (`onCall` receives the typed method first).
45
45
  - `readOnly` marks the methods that only read, including their policy hook. A marked method is declared `readOnly` and its `onCall` still decides every validated call, so reads run concurrently, may start while the model still streams, and a turn answers identical ones once after each call's hook allows it; every other method's calls, gated by `onCall` or not, run in the order the model made them. A policy that proposes changes must return `run` for the reads it allows, rather than relying on reads skipping the hook. Mark nothing that asks for permission.
46
46
  - `params` makes the request a call routes of the model's validated parameters, such as the parameters with the short names the model was shown for records turned back into their IDs; by default the parameters themselves. It runs when the call runs, after `onCall` decided it where the method has one, so `onCall` and a proposal see the model's parameters. The omitted properties are dropped from what it returns as from the model's, and only `request` fills them. A throw refuses the call with its error, and so does a result that is no object.
47
47
  - The same validation, policy, stripping and filling apply to `execute` and every call inside `run_code`. Private inputs are rejected before `onCall`; a proposed change does not run `params`, `request` or the router. Omissions cover inputs only: use `response` to remove secrets from answers before they reach the model.