@modelprofile.com/flexharness 3.8.0 → 4.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -18,6 +18,7 @@ import {
18
18
  errorToInfo,
19
19
  } from './errors.js';
20
20
  import {
21
+ FLEX_PROJECT_MANAGEMENT_LIMITS,
21
22
  FLEX_REVERSION_MAX_AFFECTED_WORKSPACES,
22
23
  FLEX_REVERSION_DEFAULT_LIMITS,
23
24
  FLEX_REVERSION_MAXIMUM_LIMITS,
@@ -33,6 +34,7 @@ import type {
33
34
  IFlexAttachmentMessagePart,
34
35
  IFlexBackgroundExecution,
35
36
  IFlexCallbackLimits,
37
+ IFlexCreateProjectTaskInput,
36
38
  IFlexCreateSessionOptions,
37
39
  IFlexErrorInfo,
38
40
  IFlexEventArchiveMetadata,
@@ -47,6 +49,15 @@ import type {
47
49
  IFlexPermissionRequest,
48
50
  IFlexPermissionRequestInput,
49
51
  IFlexPermissionSnapshot,
52
+ IFlexProjectGoalResult,
53
+ IFlexProjectManagementSnapshot,
54
+ IFlexProjectManagementTombstone,
55
+ IFlexProjectManagementWriteContext,
56
+ IFlexProjectScratchpadResult,
57
+ IFlexProjectStateResult,
58
+ IFlexProjectTask,
59
+ IFlexProjectTaskResult,
60
+ IFlexProjectTasksResult,
50
61
  IFlexPromptAdmission,
51
62
  IFlexPromptQueueAdmission,
52
63
  IFlexPromptQueueEntry,
@@ -81,6 +92,7 @@ import type {
81
92
  IFlexUncertainToolExecution,
82
93
  IFlexUndoSessionResult,
83
94
  IFlexUpdateSessionOptions,
95
+ IFlexUpdateProjectTaskInput,
84
96
  IFlexUsage,
85
97
  IJsonObject,
86
98
  TFlexAgentRunResult,
@@ -93,6 +105,8 @@ import type {
93
105
  TFlexHarnessEventListener,
94
106
  TFlexMessagePart,
95
107
  TFlexPermissionDecision,
108
+ TFlexProjectTaskPriority,
109
+ TFlexProjectTaskStatus,
96
110
  TFlexPrompt,
97
111
  TFlexPromptPart,
98
112
  TFlexPromptQueueStatus,
@@ -114,6 +128,11 @@ import {
114
128
  normalizeJsonValue,
115
129
  resolveJsonLimits,
116
130
  } from './utils.json.js';
131
+ import {
132
+ assertFlexProjectManagementRecord,
133
+ assertFlexProjectManagementSnapshot,
134
+ createEmptyFlexProjectManagementSnapshot,
135
+ } from './utils.projectmanagement.js';
117
136
  import {
118
137
  hydrateAgentMessages,
119
138
  normalizeFlexPrompt,
@@ -397,6 +416,47 @@ interface IFlexSubagentAcquisition {
397
416
  created: boolean;
398
417
  }
399
418
 
419
+ interface INormalizedProjectManagementTools {
420
+ readonly task: boolean;
421
+ readonly goal: boolean;
422
+ readonly scratchpad: boolean;
423
+ }
424
+
425
+ interface INormalizedBuiltInTools {
426
+ readonly renameSession: boolean;
427
+ readonly projectManagement?: INormalizedProjectManagementTools;
428
+ }
429
+
430
+ type TProjectTaskToolInput =
431
+ | { action: 'list' }
432
+ | {
433
+ action: 'create';
434
+ id?: string;
435
+ content: string;
436
+ status?: TFlexProjectTaskStatus;
437
+ priority?: TFlexProjectTaskPriority;
438
+ }
439
+ | {
440
+ action: 'update';
441
+ id: string;
442
+ content?: string;
443
+ status?: TFlexProjectTaskStatus;
444
+ priority?: TFlexProjectTaskPriority;
445
+ }
446
+ | { action: 'delete'; id: string }
447
+ | { action: 'clear' };
448
+
449
+ type TProjectGoalToolInput =
450
+ | { action: 'get' }
451
+ | { action: 'set'; goal: string }
452
+ | { action: 'clear' };
453
+
454
+ type TProjectScratchpadToolInput =
455
+ | { action: 'get' }
456
+ | { action: 'set'; content: string }
457
+ | { action: 'append'; content: string }
458
+ | { action: 'clear' };
459
+
400
460
  type TEventDetails = Record<string, unknown> & {
401
461
  type: TFlexHarnessEvent['type'];
402
462
  };
@@ -444,6 +504,7 @@ const maxResourceToolNameBytes = 512;
444
504
  const maxSlashCommandRegistrations = 128;
445
505
  const maxSlashCommandDescriptionBytes = 2048;
446
506
  const maxSlashCommandTemplateBytes = FLEX_SLASH_COMMAND_MAX_INPUT_BYTES;
507
+ const maxProjectManagementTombstoneConflicts = 8;
447
508
  const resourceToolStemLength = 16;
448
509
  const repairCancellationMessage = 'The process stopped before this run completed.';
449
510
  const scopeRetirementMessage = 'The scope is being retired.';
@@ -455,6 +516,43 @@ const externalErrorFallback: IFlexErrorInfo = Object.freeze({
455
516
  code: 'FLEX_EXTERNAL_ERROR',
456
517
  });
457
518
 
519
+ function createSessionGenerationId(): string {
520
+ return `generation_${plugins.crypto.randomUUID()}`;
521
+ }
522
+
523
+ function deriveLegacySessionGenerationId(
524
+ storageKey: string,
525
+ sessionId: string,
526
+ createdAt: string,
527
+ ): string {
528
+ const digest = plugins.crypto.createHash('sha256').update(JSON.stringify([
529
+ 'flexharness-legacy-session-generation-v1',
530
+ storageKey,
531
+ sessionId,
532
+ createdAt,
533
+ ])).digest('hex');
534
+ return `legacy_${digest}`;
535
+ }
536
+
537
+ function requireSessionGeneration(
538
+ session: Pick<
539
+ IFlexSession | IFlexSessionTombstone,
540
+ 'sessionGenerationId' | 'sessionGenerationSequence'
541
+ >,
542
+ ): { sessionGenerationId: string; sessionGenerationSequence: number } {
543
+ if (
544
+ !session.sessionGenerationId
545
+ || !Number.isSafeInteger(session.sessionGenerationSequence)
546
+ || Number(session.sessionGenerationSequence) < 1
547
+ ) {
548
+ throw new FlexHarnessValidationError('Session generation metadata is missing.');
549
+ }
550
+ return {
551
+ sessionGenerationId: session.sessionGenerationId,
552
+ sessionGenerationSequence: session.sessionGenerationSequence as number,
553
+ };
554
+ }
555
+
458
556
  function isAsyncIterable(value: unknown): value is AsyncIterable<unknown> {
459
557
  return Boolean(
460
558
  value
@@ -663,6 +761,58 @@ function normalizeSubagents(
663
761
  return Object.freeze(normalized);
664
762
  }
665
763
 
764
+ function normalizeBuiltInTools(
765
+ options: IFlexHarnessOptions<unknown>['builtInTools'],
766
+ ): INormalizedBuiltInTools {
767
+ if (options === undefined) return Object.freeze({ renameSession: false });
768
+ if (
769
+ !options
770
+ || typeof options !== 'object'
771
+ || Array.isArray(options)
772
+ || ![Object.prototype, null].includes(Object.getPrototypeOf(options))
773
+ ) {
774
+ throw new FlexHarnessValidationError('builtInTools must be a plain object.');
775
+ }
776
+ const unsupported = Object.keys(options)
777
+ .find((key) => !['renameSession', 'projectManagement'].includes(key));
778
+ if (unsupported) throw new FlexHarnessValidationError(`builtInTools does not support "${unsupported}".`);
779
+ if (options.renameSession !== undefined && typeof options.renameSession !== 'boolean') {
780
+ throw new FlexHarnessValidationError('builtInTools.renameSession must be a boolean.');
781
+ }
782
+ const projectManagement = options.projectManagement;
783
+ if (projectManagement === undefined) {
784
+ return Object.freeze({ renameSession: options.renameSession ?? false });
785
+ }
786
+ if (
787
+ !projectManagement
788
+ || typeof projectManagement !== 'object'
789
+ || Array.isArray(projectManagement)
790
+ || ![Object.prototype, null].includes(Object.getPrototypeOf(projectManagement))
791
+ ) {
792
+ throw new FlexHarnessValidationError('builtInTools.projectManagement must be a plain object.');
793
+ }
794
+ const unsupportedProjectOption = Object.keys(projectManagement)
795
+ .find((key) => !['task', 'goal', 'scratchpad'].includes(key));
796
+ if (unsupportedProjectOption) {
797
+ throw new FlexHarnessValidationError(
798
+ `builtInTools.projectManagement does not support "${unsupportedProjectOption}".`,
799
+ );
800
+ }
801
+ for (const key of ['task', 'goal', 'scratchpad'] as const) {
802
+ if (projectManagement[key] !== undefined && typeof projectManagement[key] !== 'boolean') {
803
+ throw new FlexHarnessValidationError(`builtInTools.projectManagement.${key} must be a boolean.`);
804
+ }
805
+ }
806
+ return Object.freeze({
807
+ renameSession: options.renameSession ?? false,
808
+ projectManagement: Object.freeze({
809
+ task: projectManagement.task ?? true,
810
+ goal: projectManagement.goal ?? true,
811
+ scratchpad: projectManagement.scratchpad ?? true,
812
+ }),
813
+ });
814
+ }
815
+
666
816
  function normalizeSlashCommands<TScope>(
667
817
  registrations: readonly TFlexSlashCommandRegistration<TScope>[] | undefined,
668
818
  ): ReadonlyMap<string, TRegisteredSlashCommand<TScope>> {
@@ -997,13 +1147,81 @@ function validateUpdateSessionOptions(options: IFlexUpdateSessionOptions): void
997
1147
  const unsupportedKey = keys.find((key) => key !== 'title' && key !== 'archived');
998
1148
  if (unsupportedKey) throw new FlexHarnessValidationError(`updateSession does not support "${unsupportedKey}".`);
999
1149
  if (Object.prototype.hasOwnProperty.call(options, 'title') && options.title !== null) {
1000
- validateIdentifier(options.title!, 'title');
1150
+ validateUtf8String(
1151
+ options.title,
1152
+ 'title',
1153
+ FLEX_PROJECT_MANAGEMENT_LIMITS.maxTitleBytes,
1154
+ true,
1155
+ );
1001
1156
  }
1002
1157
  if (Object.prototype.hasOwnProperty.call(options, 'archived') && typeof options.archived !== 'boolean') {
1003
1158
  throw new FlexHarnessValidationError('archived must be a boolean.');
1004
1159
  }
1005
1160
  }
1006
1161
 
1162
+ function validateProjectTaskStatus(value: unknown): asserts value is TFlexProjectTaskStatus {
1163
+ if (!['pending', 'in_progress', 'completed', 'cancelled'].includes(String(value))) {
1164
+ throw new FlexHarnessValidationError('Project task status is invalid.');
1165
+ }
1166
+ }
1167
+
1168
+ function validateProjectTaskPriority(value: unknown): asserts value is TFlexProjectTaskPriority {
1169
+ if (!['high', 'medium', 'low'].includes(String(value))) {
1170
+ throw new FlexHarnessValidationError('Project task priority is invalid.');
1171
+ }
1172
+ }
1173
+
1174
+ function validateProjectTaskId(value: unknown): asserts value is string {
1175
+ validateUtf8String(
1176
+ value,
1177
+ 'project task id',
1178
+ FLEX_PROJECT_MANAGEMENT_LIMITS.maxTaskIdBytes,
1179
+ true,
1180
+ );
1181
+ }
1182
+
1183
+ function validateProjectTaskContent(value: unknown): asserts value is string {
1184
+ validateUtf8String(
1185
+ value,
1186
+ 'project task content',
1187
+ FLEX_PROJECT_MANAGEMENT_LIMITS.maxTaskContentBytes,
1188
+ true,
1189
+ );
1190
+ }
1191
+
1192
+ function validateCreateProjectTaskInput(input: IFlexCreateProjectTaskInput): void {
1193
+ if (!input || typeof input !== 'object' || Array.isArray(input)) {
1194
+ throw new FlexHarnessValidationError('Project task create input must be a plain object.');
1195
+ }
1196
+ const unsupported = Object.keys(input)
1197
+ .find((key) => !['id', 'content', 'status', 'priority'].includes(key));
1198
+ if (unsupported) {
1199
+ throw new FlexHarnessValidationError(`Project task create input does not support "${unsupported}".`);
1200
+ }
1201
+ validateProjectTaskId(input.id);
1202
+ validateProjectTaskContent(input.content);
1203
+ if (input.status !== undefined) validateProjectTaskStatus(input.status);
1204
+ if (input.priority !== undefined) validateProjectTaskPriority(input.priority);
1205
+ }
1206
+
1207
+ function validateUpdateProjectTaskInput(input: IFlexUpdateProjectTaskInput): void {
1208
+ if (!input || typeof input !== 'object' || Array.isArray(input)) {
1209
+ throw new FlexHarnessValidationError('Project task update input must be a plain object.');
1210
+ }
1211
+ const keys = Object.keys(input);
1212
+ const unsupported = keys.find((key) => !['id', 'content', 'status', 'priority'].includes(key));
1213
+ if (unsupported) {
1214
+ throw new FlexHarnessValidationError(`Project task update input does not support "${unsupported}".`);
1215
+ }
1216
+ if (!keys.some((key) => key !== 'id')) {
1217
+ throw new FlexHarnessValidationError('Project task update requires a changed field.');
1218
+ }
1219
+ validateProjectTaskId(input.id);
1220
+ if (input.content !== undefined) validateProjectTaskContent(input.content);
1221
+ if (input.status !== undefined) validateProjectTaskStatus(input.status);
1222
+ if (input.priority !== undefined) validateProjectTaskPriority(input.priority);
1223
+ }
1224
+
1007
1225
  function validatePromptOptions(options: IFlexPromptOptions, scheduled: boolean): void {
1008
1226
  if (!options || typeof options !== 'object' || Array.isArray(options)) {
1009
1227
  throw new FlexHarnessValidationError('Prompt options must be a plain object.');
@@ -1128,6 +1346,22 @@ function normalizeAgentSessionPolicy<TScope>(
1128
1346
  };
1129
1347
  }
1130
1348
 
1349
+ function requireHarnessStores(stores: IFlexHarnessStores): IFlexHarnessStores {
1350
+ const projectManagement = stores?.projectManagement;
1351
+ if (
1352
+ !projectManagement
1353
+ || typeof projectManagement.load !== 'function'
1354
+ || typeof projectManagement.save !== 'function'
1355
+ || typeof projectManagement.tombstoneSession !== 'function'
1356
+ || typeof projectManagement.purgeNamespace !== 'function'
1357
+ ) {
1358
+ throw new FlexHarnessValidationError(
1359
+ 'stores.projectManagement requires load/save/tombstoneSession/purgeNamespace methods.',
1360
+ );
1361
+ }
1362
+ return stores;
1363
+ }
1364
+
1131
1365
  export class FlexHarness<TScope = unknown> {
1132
1366
  private readonly scopeResolver: IFlexHarnessOptions<TScope>['scopeResolver'];
1133
1367
  private readonly modelResolver: IFlexHarnessOptions<TScope>['modelResolver'];
@@ -1144,6 +1378,7 @@ export class FlexHarness<TScope = unknown> {
1144
1378
  private readonly reversionPolicy: TFlexReversionPolicy;
1145
1379
  private readonly externalErrorProjector?: TFlexExternalErrorProjector;
1146
1380
  private readonly subagents: ReadonlyMap<string, Readonly<IFlexSubagentDefinition>>;
1381
+ private readonly builtInTools: INormalizedBuiltInTools;
1147
1382
  private readonly slashCommands: ReadonlyMap<string, TRegisteredSlashCommand<TScope>>;
1148
1383
  private readonly maxSubagentDepth: number;
1149
1384
  private readonly maxSubagentCallsPerRun: number;
@@ -1164,6 +1399,7 @@ export class FlexHarness<TScope = unknown> {
1164
1399
  private readonly orphanedTombstoneCleanups = new Map<string, IOrphanedTombstoneCleanup>();
1165
1400
  private readonly orphanedTombstoneOwners = new Map<string, IOrphanedTombstoneOwner<TScope>>();
1166
1401
  private readonly pendingPromptAdmissionOwners = new Set<IPendingPromptAdmission>();
1402
+ private readonly projectManagementQueues = new Map<string, Promise<void>>();
1167
1403
  private readonly activeSlashCommandExecutions = new Map<string, IActiveSlashCommandExecution>();
1168
1404
  private readonly activeSlashCommandListings = new Set<IActiveSlashCommandListing>();
1169
1405
  private readonly compactorInvocationContext = new plugins.AsyncLocalStorage<
@@ -1195,7 +1431,7 @@ export class FlexHarness<TScope = unknown> {
1195
1431
  this.toolProvider = options.toolProvider;
1196
1432
  this.resourceToolProviderResolver = options.resourceToolProviderResolver;
1197
1433
  this.executionContextProvider = options.executionContextProvider;
1198
- this.stores = options.stores ?? new InMemoryFlexHarnessStores();
1434
+ this.stores = requireHarnessStores(options.stores ?? new InMemoryFlexHarnessStores());
1199
1435
  this.agentSessionPolicy = normalizeAgentSessionPolicy(options.agentSessionPolicy);
1200
1436
  this.toolOutputLimits = resolveJsonLimits(options.toolOutputLimits);
1201
1437
  this.callbackLimits = resolveCallbackLimits(options.callbackLimits);
@@ -1222,6 +1458,7 @@ export class FlexHarness<TScope = unknown> {
1222
1458
  }
1223
1459
  this.externalErrorProjector = options.externalErrorProjector;
1224
1460
  this.subagents = normalizeSubagents(options.subagents);
1461
+ this.builtInTools = normalizeBuiltInTools(options.builtInTools);
1225
1462
  this.slashCommands = normalizeSlashCommands(options.slashCommands);
1226
1463
  this.maxSubagentDepth = resolveBoundedPositiveInteger(
1227
1464
  options.maxSubagentDepth,
@@ -1261,7 +1498,14 @@ export class FlexHarness<TScope = unknown> {
1261
1498
  const sessionId = options.sessionId ?? plugins.crypto.randomUUID();
1262
1499
  validateIdentifier(sessionId, 'sessionId');
1263
1500
  requireTransferIdentifier(sessionId, 'sessionId');
1264
- if (options.title !== undefined) validateIdentifier(options.title, 'title');
1501
+ if (options.title !== undefined) {
1502
+ validateUtf8String(
1503
+ options.title,
1504
+ 'title',
1505
+ FLEX_PROJECT_MANAGEMENT_LIMITS.maxTitleBytes,
1506
+ true,
1507
+ );
1508
+ }
1265
1509
  await resolved.state.scopeQueue;
1266
1510
  if (
1267
1511
  resolved.state.sessions.has(sessionId)
@@ -1296,6 +1540,8 @@ export class FlexHarness<TScope = unknown> {
1296
1540
  metadata = {
1297
1541
  scopeId,
1298
1542
  sessionId,
1543
+ sessionGenerationId: createSessionGenerationId(),
1544
+ sessionGenerationSequence: resolved.state.revision + 1,
1299
1545
  ...(options.title ? { title: options.title } : {}),
1300
1546
  createdAt: timestamp,
1301
1547
  updatedAt: timestamp,
@@ -1341,6 +1587,7 @@ export class FlexHarness<TScope = unknown> {
1341
1587
  if (!scopeReserved) throw projectedError;
1342
1588
  const tombstone: IFlexSessionTombstone = {
1343
1589
  sessionId,
1590
+ ...requireSessionGeneration(metadata),
1344
1591
  deletedAt: new Date().toISOString(),
1345
1592
  rootSessionId: sessionId,
1346
1593
  depth: 0,
@@ -1468,6 +1715,181 @@ export class FlexHarness<TScope = unknown> {
1468
1715
  return result;
1469
1716
  }
1470
1717
 
1718
+ public async getProjectState(
1719
+ scopeId: string,
1720
+ sessionId: string,
1721
+ ): Promise<IFlexProjectStateResult> {
1722
+ const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId);
1723
+ const snapshot = await this.readProjectManagementState(state, stored);
1724
+ return this.projectStateResult(snapshot);
1725
+ }
1726
+
1727
+ public async listProjectTasks(
1728
+ scopeId: string,
1729
+ sessionId: string,
1730
+ ): Promise<IFlexProjectTasksResult> {
1731
+ const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId);
1732
+ const snapshot = await this.readProjectManagementState(state, stored);
1733
+ return this.projectTasksResult(snapshot, snapshot.tasks);
1734
+ }
1735
+
1736
+ public async createProjectTask(
1737
+ scopeId: string,
1738
+ sessionId: string,
1739
+ input: IFlexCreateProjectTaskInput,
1740
+ ): Promise<IFlexProjectTaskResult> {
1741
+ validateCreateProjectTaskInput(input);
1742
+ const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId);
1743
+ return this.createProjectTaskInState(
1744
+ state,
1745
+ stored,
1746
+ input,
1747
+ Object.freeze({ actor: 'application' }),
1748
+ );
1749
+ }
1750
+
1751
+ public async updateProjectTask(
1752
+ scopeId: string,
1753
+ sessionId: string,
1754
+ input: IFlexUpdateProjectTaskInput,
1755
+ ): Promise<IFlexProjectTaskResult> {
1756
+ validateUpdateProjectTaskInput(input);
1757
+ const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId);
1758
+ return this.updateProjectTaskInState(
1759
+ state,
1760
+ stored,
1761
+ input,
1762
+ Object.freeze({ actor: 'application' }),
1763
+ );
1764
+ }
1765
+
1766
+ public async deleteProjectTask(
1767
+ scopeId: string,
1768
+ sessionId: string,
1769
+ taskId: string,
1770
+ ): Promise<IFlexProjectTaskResult> {
1771
+ validateProjectTaskId(taskId);
1772
+ const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId);
1773
+ return this.deleteProjectTaskInState(
1774
+ state,
1775
+ stored,
1776
+ taskId,
1777
+ Object.freeze({ actor: 'application' }),
1778
+ );
1779
+ }
1780
+
1781
+ public async clearProjectTasks(
1782
+ scopeId: string,
1783
+ sessionId: string,
1784
+ ): Promise<IFlexProjectTasksResult> {
1785
+ const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId);
1786
+ return this.clearProjectTasksInState(
1787
+ state,
1788
+ stored,
1789
+ Object.freeze({ actor: 'application' }),
1790
+ );
1791
+ }
1792
+
1793
+ public async getProjectGoal(
1794
+ scopeId: string,
1795
+ sessionId: string,
1796
+ ): Promise<IFlexProjectGoalResult> {
1797
+ const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId);
1798
+ const snapshot = await this.readProjectManagementState(state, stored);
1799
+ return this.projectGoalResult(snapshot);
1800
+ }
1801
+
1802
+ public async setProjectGoal(
1803
+ scopeId: string,
1804
+ sessionId: string,
1805
+ goal: string,
1806
+ ): Promise<IFlexProjectGoalResult> {
1807
+ validateUtf8String(
1808
+ goal,
1809
+ 'project goal',
1810
+ FLEX_PROJECT_MANAGEMENT_LIMITS.maxGoalBytes,
1811
+ true,
1812
+ );
1813
+ const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId);
1814
+ return this.setProjectGoalInState(
1815
+ state,
1816
+ stored,
1817
+ goal,
1818
+ Object.freeze({ actor: 'application' }),
1819
+ );
1820
+ }
1821
+
1822
+ public async clearProjectGoal(
1823
+ scopeId: string,
1824
+ sessionId: string,
1825
+ ): Promise<IFlexProjectGoalResult> {
1826
+ const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId);
1827
+ return this.clearProjectGoalInState(
1828
+ state,
1829
+ stored,
1830
+ Object.freeze({ actor: 'application' }),
1831
+ );
1832
+ }
1833
+
1834
+ public async getProjectScratchpad(
1835
+ scopeId: string,
1836
+ sessionId: string,
1837
+ ): Promise<IFlexProjectScratchpadResult> {
1838
+ const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId);
1839
+ const snapshot = await this.readProjectManagementState(state, stored);
1840
+ return this.projectScratchpadResult(snapshot);
1841
+ }
1842
+
1843
+ public async setProjectScratchpad(
1844
+ scopeId: string,
1845
+ sessionId: string,
1846
+ content: string,
1847
+ ): Promise<IFlexProjectScratchpadResult> {
1848
+ validateUtf8String(
1849
+ content,
1850
+ 'project scratchpad',
1851
+ FLEX_PROJECT_MANAGEMENT_LIMITS.maxScratchpadBytes,
1852
+ );
1853
+ const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId);
1854
+ return this.setProjectScratchpadInState(
1855
+ state,
1856
+ stored,
1857
+ content,
1858
+ Object.freeze({ actor: 'application' }),
1859
+ );
1860
+ }
1861
+
1862
+ public async appendProjectScratchpad(
1863
+ scopeId: string,
1864
+ sessionId: string,
1865
+ content: string,
1866
+ ): Promise<IFlexProjectScratchpadResult> {
1867
+ validateUtf8String(
1868
+ content,
1869
+ 'project scratchpad append',
1870
+ FLEX_PROJECT_MANAGEMENT_LIMITS.maxScratchpadBytes,
1871
+ );
1872
+ const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId);
1873
+ return this.appendProjectScratchpadInState(
1874
+ state,
1875
+ stored,
1876
+ content,
1877
+ Object.freeze({ actor: 'application' }),
1878
+ );
1879
+ }
1880
+
1881
+ public async clearProjectScratchpad(
1882
+ scopeId: string,
1883
+ sessionId: string,
1884
+ ): Promise<IFlexProjectScratchpadResult> {
1885
+ const { state, stored } = await this.resolveProjectManagementSession(scopeId, sessionId);
1886
+ return this.clearProjectScratchpadInState(
1887
+ state,
1888
+ stored,
1889
+ Object.freeze({ actor: 'application' }),
1890
+ );
1891
+ }
1892
+
1471
1893
  public async deleteSession(scopeId: string, sessionId: string): Promise<void> {
1472
1894
  this.assertNoAmbientSlashCommandTeardown({ scopeId, sessionId });
1473
1895
  const { scope, state } = await this.resolveState(scopeId);
@@ -1536,6 +1958,7 @@ export class FlexHarness<TScope = unknown> {
1536
1958
  });
1537
1959
  state.tombstones.set(entrySessionId, {
1538
1960
  sessionId: entrySessionId,
1961
+ ...requireSessionGeneration(entry.session),
1539
1962
  deletedAt,
1540
1963
  rootSessionId: sessionId,
1541
1964
  depth: (entry.session.depth ?? rootDepth) - rootDepth,
@@ -2007,7 +2430,7 @@ export class FlexHarness<TScope = unknown> {
2007
2430
  )) return 'Session already has an active slash command execution.';
2008
2431
  if (stored.pendingReversion) return 'Session has a pending reversion operation.';
2009
2432
  if (stored.session.agent !== undefined) {
2010
- return 'Subagent sessions can only be prompted through the foreground task tool.';
2433
+ return 'Subagent sessions can only be prompted through the foreground delegate tool.';
2011
2434
  }
2012
2435
  if (stored.outstandingPromptsById.size >= this.promptQueueLimits.maxOutstandingPromptsPerSession) {
2013
2436
  return 'Session has reached its outstanding prompt limit.';
@@ -3287,7 +3710,7 @@ export class FlexHarness<TScope = unknown> {
3287
3710
  }
3288
3711
  if (stored.session.agent !== undefined && !subagentAdmission) {
3289
3712
  throw new FlexHarnessValidationError(
3290
- 'Subagent sessions can only be prompted through the foreground task tool.',
3713
+ 'Subagent sessions can only be prompted through the foreground delegate tool.',
3291
3714
  );
3292
3715
  }
3293
3716
  if (stored.outstandingPromptsById.size >= this.promptQueueLimits.maxOutstandingPromptsPerSession) {
@@ -3894,6 +4317,412 @@ export class FlexHarness<TScope = unknown> {
3894
4317
  throw errors.length > 1 ? combineErrors(errors) : terminalError;
3895
4318
  }
3896
4319
 
4320
+ private requireProjectManagementTools(): INormalizedProjectManagementTools {
4321
+ const projectManagement = this.builtInTools.projectManagement;
4322
+ if (!projectManagement) {
4323
+ throw new FlexHarnessValidationError('Project management is not configured for this FlexHarness.');
4324
+ }
4325
+ return projectManagement;
4326
+ }
4327
+
4328
+ private projectManagementKey(storageKey: string, sessionId: string): string {
4329
+ return JSON.stringify([storageKey, sessionId]);
4330
+ }
4331
+
4332
+ private queueProjectManagementOperation<TResult>(
4333
+ storageKey: string,
4334
+ sessionId: string,
4335
+ operation: () => Promise<TResult>,
4336
+ ): Promise<TResult> {
4337
+ const key = this.projectManagementKey(storageKey, sessionId);
4338
+ const previous = this.projectManagementQueues.get(key) ?? Promise.resolve();
4339
+ const result = previous.then(operation);
4340
+ const barrier = result.then(() => undefined, () => undefined);
4341
+ this.projectManagementQueues.set(key, barrier);
4342
+ void barrier.then(() => {
4343
+ if (this.projectManagementQueues.get(key) === barrier) {
4344
+ this.projectManagementQueues.delete(key);
4345
+ }
4346
+ });
4347
+ return result;
4348
+ }
4349
+
4350
+ private async awaitProjectManagementOperations(
4351
+ storageKey: string,
4352
+ sessionIds: Iterable<string>,
4353
+ ): Promise<void> {
4354
+ const barriers = [...new Set(sessionIds)]
4355
+ .map((sessionId) => this.projectManagementQueues.get(
4356
+ this.projectManagementKey(storageKey, sessionId),
4357
+ ))
4358
+ .filter((barrier): barrier is Promise<void> => barrier !== undefined);
4359
+ await Promise.all(barriers);
4360
+ }
4361
+
4362
+ private async resolveProjectManagementSession(
4363
+ scopeId: string,
4364
+ sessionId: string,
4365
+ ): Promise<{ state: IStorageState; stored: IStoredSessionState }> {
4366
+ this.requireProjectManagementTools();
4367
+ const { state } = await this.resolveState(scopeId);
4368
+ await state.scopeQueue;
4369
+ this.assertOpen();
4370
+ this.assertStateAcceptingWork(state);
4371
+ return { state, stored: this.requireSession(state, sessionId) };
4372
+ }
4373
+
4374
+ private assertProjectManagementSession(
4375
+ state: IStorageState,
4376
+ stored: IStoredSessionState,
4377
+ ): void {
4378
+ this.assertOpen();
4379
+ this.assertStateAcceptingWork(state);
4380
+ if (
4381
+ state.sessions.get(stored.session.sessionId) !== stored
4382
+ || state.tombstones.has(stored.session.sessionId)
4383
+ ) {
4384
+ throw new FlexHarnessNotFoundError('Session', stored.session.sessionId);
4385
+ }
4386
+ }
4387
+
4388
+ private async loadProjectManagementState(
4389
+ state: IStorageState,
4390
+ stored: IStoredSessionState,
4391
+ ): Promise<IFlexProjectManagementSnapshot> {
4392
+ const store = this.stores.projectManagement;
4393
+ const generation = requireSessionGeneration(stored.session);
4394
+ const record = await store.load(state.storageKey, stored.session.sessionId);
4395
+ if (record === undefined) {
4396
+ return createEmptyFlexProjectManagementSnapshot(
4397
+ generation.sessionGenerationId,
4398
+ generation.sessionGenerationSequence,
4399
+ );
4400
+ }
4401
+ assertFlexProjectManagementRecord(record);
4402
+ if (record.sessionGenerationId === generation.sessionGenerationId) {
4403
+ if (record.sessionGenerationSequence !== generation.sessionGenerationSequence) {
4404
+ throw new FlexHarnessStoreConflictError(
4405
+ this.projectManagementKey(state.storageKey, stored.session.sessionId),
4406
+ 0,
4407
+ record.revision,
4408
+ );
4409
+ }
4410
+ if (!('deletedAt' in record)) return cloneSerializable(record);
4411
+ throw new FlexHarnessNotFoundError('Project management state', stored.session.sessionId);
4412
+ }
4413
+ if (
4414
+ 'deletedAt' in record
4415
+ && record.sessionGenerationSequence < generation.sessionGenerationSequence
4416
+ ) {
4417
+ return createEmptyFlexProjectManagementSnapshot(
4418
+ generation.sessionGenerationId,
4419
+ generation.sessionGenerationSequence,
4420
+ );
4421
+ }
4422
+ throw new FlexHarnessStoreConflictError(
4423
+ this.projectManagementKey(state.storageKey, stored.session.sessionId),
4424
+ 0,
4425
+ record.revision,
4426
+ );
4427
+ }
4428
+
4429
+ private readProjectManagementState(
4430
+ state: IStorageState,
4431
+ stored: IStoredSessionState,
4432
+ ): Promise<IFlexProjectManagementSnapshot> {
4433
+ return this.queueProjectManagementOperation(
4434
+ state.storageKey,
4435
+ stored.session.sessionId,
4436
+ async () => {
4437
+ this.assertProjectManagementSession(state, stored);
4438
+ const snapshot = await this.loadProjectManagementState(state, stored);
4439
+ this.assertProjectManagementSession(state, stored);
4440
+ return publicSnapshot(snapshot);
4441
+ },
4442
+ );
4443
+ }
4444
+
4445
+ private mutateProjectManagementState<TResult>(
4446
+ state: IStorageState,
4447
+ stored: IStoredSessionState,
4448
+ writeContext: IFlexProjectManagementWriteContext,
4449
+ mutation: (
4450
+ snapshot: IFlexProjectManagementSnapshot,
4451
+ ) => { changed: boolean; value: TResult },
4452
+ ): Promise<{ snapshot: IFlexProjectManagementSnapshot; value: TResult }> {
4453
+ return this.queueProjectManagementOperation(
4454
+ state.storageKey,
4455
+ stored.session.sessionId,
4456
+ async () => {
4457
+ this.assertProjectManagementSession(state, stored);
4458
+ const snapshot = await this.loadProjectManagementState(state, stored);
4459
+ this.assertProjectManagementSession(state, stored);
4460
+ const expectedRevision = snapshot.revision;
4461
+ const result = mutation(snapshot);
4462
+ if (result.changed) {
4463
+ snapshot.revision = expectedRevision + 1;
4464
+ assertFlexProjectManagementSnapshot(snapshot);
4465
+ this.assertProjectManagementSession(state, stored);
4466
+ await this.stores.projectManagement.save(
4467
+ state.storageKey,
4468
+ stored.session.sessionId,
4469
+ cloneSerializable(snapshot),
4470
+ expectedRevision,
4471
+ Object.freeze(cloneSerializable(writeContext)),
4472
+ );
4473
+ }
4474
+ return {
4475
+ snapshot: publicSnapshot(snapshot),
4476
+ value: result.value === undefined ? result.value : publicSnapshot(result.value),
4477
+ };
4478
+ },
4479
+ );
4480
+ }
4481
+
4482
+ private projectStateResult(snapshot: IFlexProjectManagementSnapshot): IFlexProjectStateResult {
4483
+ return publicSnapshot({ revision: snapshot.revision, state: snapshot });
4484
+ }
4485
+
4486
+ private projectTaskResult(
4487
+ snapshot: IFlexProjectManagementSnapshot,
4488
+ task: IFlexProjectTask,
4489
+ ): IFlexProjectTaskResult {
4490
+ return publicSnapshot({ revision: snapshot.revision, task, state: snapshot });
4491
+ }
4492
+
4493
+ private projectTasksResult(
4494
+ snapshot: IFlexProjectManagementSnapshot,
4495
+ tasks: IFlexProjectTask[],
4496
+ ): IFlexProjectTasksResult {
4497
+ return publicSnapshot({ revision: snapshot.revision, tasks, state: snapshot });
4498
+ }
4499
+
4500
+ private projectGoalResult(snapshot: IFlexProjectManagementSnapshot): IFlexProjectGoalResult {
4501
+ return publicSnapshot({
4502
+ revision: snapshot.revision,
4503
+ goal: snapshot.goal ?? null,
4504
+ state: snapshot,
4505
+ });
4506
+ }
4507
+
4508
+ private projectScratchpadResult(
4509
+ snapshot: IFlexProjectManagementSnapshot,
4510
+ ): IFlexProjectScratchpadResult {
4511
+ return publicSnapshot({
4512
+ revision: snapshot.revision,
4513
+ scratchpad: snapshot.scratchpad,
4514
+ state: snapshot,
4515
+ });
4516
+ }
4517
+
4518
+ private async createProjectTaskInState(
4519
+ state: IStorageState,
4520
+ stored: IStoredSessionState,
4521
+ input: IFlexCreateProjectTaskInput,
4522
+ writeContext: IFlexProjectManagementWriteContext,
4523
+ ): Promise<IFlexProjectTaskResult> {
4524
+ const status = input.status ?? 'pending';
4525
+ const priority = input.priority ?? 'medium';
4526
+ const result = await this.mutateProjectManagementState(
4527
+ state,
4528
+ stored,
4529
+ writeContext,
4530
+ (snapshot) => {
4531
+ const existing = snapshot.tasks.find((task) => task.id === input.id);
4532
+ if (existing) {
4533
+ if (
4534
+ existing.content === input.content
4535
+ && existing.status === status
4536
+ && existing.priority === priority
4537
+ ) return { changed: false, value: existing };
4538
+ throw new FlexHarnessValidationError(
4539
+ `Project task "${input.id}" already exists with different creation data.`,
4540
+ );
4541
+ }
4542
+ if (snapshot.tasks.length >= FLEX_PROJECT_MANAGEMENT_LIMITS.maxTasks) {
4543
+ throw new FlexHarnessValidationError('Project task count has reached its limit.');
4544
+ }
4545
+ const timestamp = new Date().toISOString();
4546
+ const task: IFlexProjectTask = {
4547
+ id: input.id,
4548
+ content: input.content,
4549
+ status,
4550
+ priority,
4551
+ createdAt: timestamp,
4552
+ updatedAt: timestamp,
4553
+ };
4554
+ snapshot.tasks.push(task);
4555
+ return { changed: true, value: task };
4556
+ },
4557
+ );
4558
+ return this.projectTaskResult(result.snapshot, result.value);
4559
+ }
4560
+
4561
+ private async updateProjectTaskInState(
4562
+ state: IStorageState,
4563
+ stored: IStoredSessionState,
4564
+ input: IFlexUpdateProjectTaskInput,
4565
+ writeContext: IFlexProjectManagementWriteContext,
4566
+ ): Promise<IFlexProjectTaskResult> {
4567
+ const result = await this.mutateProjectManagementState(
4568
+ state,
4569
+ stored,
4570
+ writeContext,
4571
+ (snapshot) => {
4572
+ const task = snapshot.tasks.find((entry) => entry.id === input.id);
4573
+ if (!task) throw new FlexHarnessNotFoundError('Project task', input.id);
4574
+ const changed = (input.content !== undefined && input.content !== task.content)
4575
+ || (input.status !== undefined && input.status !== task.status)
4576
+ || (input.priority !== undefined && input.priority !== task.priority);
4577
+ if (!changed) return { changed: false, value: task };
4578
+ if (input.content !== undefined) task.content = input.content;
4579
+ if (input.status !== undefined) task.status = input.status;
4580
+ if (input.priority !== undefined) task.priority = input.priority;
4581
+ task.updatedAt = new Date().toISOString();
4582
+ return { changed: true, value: task };
4583
+ },
4584
+ );
4585
+ return this.projectTaskResult(result.snapshot, result.value);
4586
+ }
4587
+
4588
+ private async deleteProjectTaskInState(
4589
+ state: IStorageState,
4590
+ stored: IStoredSessionState,
4591
+ taskId: string,
4592
+ writeContext: IFlexProjectManagementWriteContext,
4593
+ ): Promise<IFlexProjectTaskResult> {
4594
+ const result = await this.mutateProjectManagementState(
4595
+ state,
4596
+ stored,
4597
+ writeContext,
4598
+ (snapshot) => {
4599
+ const index = snapshot.tasks.findIndex((task) => task.id === taskId);
4600
+ if (index < 0) throw new FlexHarnessNotFoundError('Project task', taskId);
4601
+ const [task] = snapshot.tasks.splice(index, 1);
4602
+ return { changed: true, value: task };
4603
+ },
4604
+ );
4605
+ return this.projectTaskResult(result.snapshot, result.value);
4606
+ }
4607
+
4608
+ private async clearProjectTasksInState(
4609
+ state: IStorageState,
4610
+ stored: IStoredSessionState,
4611
+ writeContext: IFlexProjectManagementWriteContext,
4612
+ ): Promise<IFlexProjectTasksResult> {
4613
+ const result = await this.mutateProjectManagementState(
4614
+ state,
4615
+ stored,
4616
+ writeContext,
4617
+ (snapshot) => {
4618
+ const tasks = snapshot.tasks;
4619
+ if (tasks.length === 0) return { changed: false, value: [] as IFlexProjectTask[] };
4620
+ snapshot.tasks = [];
4621
+ return { changed: true, value: tasks };
4622
+ },
4623
+ );
4624
+ return this.projectTasksResult(result.snapshot, result.value);
4625
+ }
4626
+
4627
+ private async setProjectGoalInState(
4628
+ state: IStorageState,
4629
+ stored: IStoredSessionState,
4630
+ goal: string,
4631
+ writeContext: IFlexProjectManagementWriteContext,
4632
+ ): Promise<IFlexProjectGoalResult> {
4633
+ const result = await this.mutateProjectManagementState(
4634
+ state,
4635
+ stored,
4636
+ writeContext,
4637
+ (snapshot) => {
4638
+ if (snapshot.goal === goal) return { changed: false, value: undefined };
4639
+ snapshot.goal = goal;
4640
+ return { changed: true, value: undefined };
4641
+ },
4642
+ );
4643
+ return this.projectGoalResult(result.snapshot);
4644
+ }
4645
+
4646
+ private async clearProjectGoalInState(
4647
+ state: IStorageState,
4648
+ stored: IStoredSessionState,
4649
+ writeContext: IFlexProjectManagementWriteContext,
4650
+ ): Promise<IFlexProjectGoalResult> {
4651
+ const result = await this.mutateProjectManagementState(
4652
+ state,
4653
+ stored,
4654
+ writeContext,
4655
+ (snapshot) => {
4656
+ if (snapshot.goal === undefined) return { changed: false, value: undefined };
4657
+ delete snapshot.goal;
4658
+ return { changed: true, value: undefined };
4659
+ },
4660
+ );
4661
+ return this.projectGoalResult(result.snapshot);
4662
+ }
4663
+
4664
+ private async setProjectScratchpadInState(
4665
+ state: IStorageState,
4666
+ stored: IStoredSessionState,
4667
+ content: string,
4668
+ writeContext: IFlexProjectManagementWriteContext,
4669
+ ): Promise<IFlexProjectScratchpadResult> {
4670
+ const result = await this.mutateProjectManagementState(
4671
+ state,
4672
+ stored,
4673
+ writeContext,
4674
+ (snapshot) => {
4675
+ if (snapshot.scratchpad === content) return { changed: false, value: undefined };
4676
+ snapshot.scratchpad = content;
4677
+ return { changed: true, value: undefined };
4678
+ },
4679
+ );
4680
+ return this.projectScratchpadResult(result.snapshot);
4681
+ }
4682
+
4683
+ private async appendProjectScratchpadInState(
4684
+ state: IStorageState,
4685
+ stored: IStoredSessionState,
4686
+ content: string,
4687
+ writeContext: IFlexProjectManagementWriteContext,
4688
+ ): Promise<IFlexProjectScratchpadResult> {
4689
+ const result = await this.mutateProjectManagementState(
4690
+ state,
4691
+ stored,
4692
+ writeContext,
4693
+ (snapshot) => {
4694
+ if (content.length === 0) return { changed: false, value: undefined };
4695
+ const appended = `${snapshot.scratchpad}${content}`;
4696
+ validateUtf8String(
4697
+ appended,
4698
+ 'project scratchpad',
4699
+ FLEX_PROJECT_MANAGEMENT_LIMITS.maxScratchpadBytes,
4700
+ );
4701
+ snapshot.scratchpad = appended;
4702
+ return { changed: true, value: undefined };
4703
+ },
4704
+ );
4705
+ return this.projectScratchpadResult(result.snapshot);
4706
+ }
4707
+
4708
+ private async clearProjectScratchpadInState(
4709
+ state: IStorageState,
4710
+ stored: IStoredSessionState,
4711
+ writeContext: IFlexProjectManagementWriteContext,
4712
+ ): Promise<IFlexProjectScratchpadResult> {
4713
+ const result = await this.mutateProjectManagementState(
4714
+ state,
4715
+ stored,
4716
+ writeContext,
4717
+ (snapshot) => {
4718
+ if (snapshot.scratchpad.length === 0) return { changed: false, value: undefined };
4719
+ snapshot.scratchpad = '';
4720
+ return { changed: true, value: undefined };
4721
+ },
4722
+ );
4723
+ return this.projectScratchpadResult(result.snapshot);
4724
+ }
4725
+
3897
4726
  private async prepareGeneration(
3898
4727
  run: IActiveRun,
3899
4728
  options: IFlexPromptOptions,
@@ -4006,17 +4835,8 @@ export class FlexHarness<TScope = unknown> {
4006
4835
  let tools: TFlexAgentToolSet | undefined;
4007
4836
  try {
4008
4837
  const providedTools = toolHandle?.tools;
4009
- if (
4010
- this.subagents.size > 0
4011
- && providedTools
4012
- && Object.prototype.hasOwnProperty.call(providedTools, 'task')
4013
- ) {
4014
- throw new FlexHarnessValidationError('The application tool provider cannot define reserved tool "task".');
4015
- }
4016
4838
  const combinedTools: Record<string, unknown> = { ...(providedTools ?? {}) };
4017
- if (this.subagents.size > 0 && (run.stored.session.depth ?? 0) < this.maxSubagentDepth) {
4018
- combinedTools.task = this.createSubagentTool(run);
4019
- }
4839
+ Object.assign(combinedTools, this.createBuiltInTools(run));
4020
4840
  tools = Object.keys(combinedTools).length > 0
4021
4841
  ? wrapToolSet(
4022
4842
  combinedTools as TFlexAgentToolSet,
@@ -4067,6 +4887,283 @@ export class FlexHarness<TScope = unknown> {
4067
4887
  }
4068
4888
  }
4069
4889
 
4890
+ private enabledBuiltInToolNames(run: IActiveRun): Set<string> {
4891
+ const names = new Set<string>();
4892
+ if (this.subagents.size > 0 && (run.stored.session.depth ?? 0) < this.maxSubagentDepth) {
4893
+ names.add('delegate');
4894
+ }
4895
+ if (this.builtInTools.renameSession) names.add('rename_session');
4896
+ const projectManagement = this.builtInTools.projectManagement;
4897
+ if (projectManagement?.task) names.add('task');
4898
+ if (projectManagement?.goal) names.add('goal');
4899
+ if (projectManagement?.scratchpad) names.add('scratchpad');
4900
+ return names;
4901
+ }
4902
+
4903
+ private assertNoBuiltInToolCollisions(run: IActiveRun, tools: TFlexAgentToolSet): void {
4904
+ const reserved = this.enabledBuiltInToolNames(run);
4905
+ const collision = Object.keys(tools).find((name) => reserved.has(name));
4906
+ if (collision) {
4907
+ throw new FlexHarnessValidationError(
4908
+ `Tool provider cannot define enabled built-in tool "${collision}".`,
4909
+ );
4910
+ }
4911
+ }
4912
+
4913
+ private createBuiltInTools(run: IActiveRun): Record<string, unknown> {
4914
+ const tools: Record<string, unknown> = {};
4915
+ if (this.subagents.size > 0 && (run.stored.session.depth ?? 0) < this.maxSubagentDepth) {
4916
+ tools.delegate = this.createSubagentTool(run);
4917
+ }
4918
+ if (this.builtInTools.renameSession) tools.rename_session = this.createRenameSessionTool(run);
4919
+ const projectManagement = this.builtInTools.projectManagement;
4920
+ if (projectManagement?.task) tools.task = this.createProjectTaskTool(run);
4921
+ if (projectManagement?.goal) tools.goal = this.createProjectGoalTool(run);
4922
+ if (projectManagement?.scratchpad) tools.scratchpad = this.createProjectScratchpadTool(run);
4923
+ return tools;
4924
+ }
4925
+
4926
+ private createRenameSessionTool(run: IActiveRun): unknown {
4927
+ return plugins.tool({
4928
+ description: 'Set the current FlexHarness session title.',
4929
+ inputSchema: plugins.z.object({
4930
+ title: plugins.z.string().min(1).max(FLEX_PROJECT_MANAGEMENT_LIMITS.maxTitleBytes),
4931
+ }).strict(),
4932
+ execute: async ({ title }: { title: string }) => {
4933
+ validateUtf8String(
4934
+ title,
4935
+ 'title',
4936
+ FLEX_PROJECT_MANAGEMENT_LIMITS.maxTitleBytes,
4937
+ true,
4938
+ );
4939
+ let session!: IFlexSession;
4940
+ await this.mutateScope(run.state, () => {
4941
+ if (run.state.sessions.get(run.sessionId) !== run.stored) {
4942
+ throw new FlexHarnessNotFoundError('Session', run.sessionId);
4943
+ }
4944
+ run.stored.session.title = title;
4945
+ run.stored.session.updatedAt = new Date().toISOString();
4946
+ session = publicSnapshot(run.stored.session);
4947
+ });
4948
+ this.emitEvent(run.scopeId, run.sessionId, {
4949
+ type: 'session.updated',
4950
+ session,
4951
+ });
4952
+ return { session };
4953
+ },
4954
+ });
4955
+ }
4956
+
4957
+ private createProjectTaskTool(run: IActiveRun): unknown {
4958
+ const status = plugins.z.enum(['pending', 'in_progress', 'completed', 'cancelled']);
4959
+ const priority = plugins.z.enum(['high', 'medium', 'low']);
4960
+ const id = plugins.z.string().min(1).max(FLEX_PROJECT_MANAGEMENT_LIMITS.maxTaskIdBytes);
4961
+ const content = plugins.z.string().min(1).max(FLEX_PROJECT_MANAGEMENT_LIMITS.maxTaskContentBytes);
4962
+ return plugins.tool({
4963
+ description: 'List, create, update, delete, or clear session-local project tasks.',
4964
+ inputSchema: plugins.z.discriminatedUnion('action', [
4965
+ plugins.z.object({ action: plugins.z.literal('list') }).strict(),
4966
+ plugins.z.object({
4967
+ action: plugins.z.literal('create'),
4968
+ id: id.optional(),
4969
+ content,
4970
+ status: status.optional(),
4971
+ priority: priority.optional(),
4972
+ }).strict(),
4973
+ plugins.z.object({
4974
+ action: plugins.z.literal('update'),
4975
+ id,
4976
+ content: content.optional(),
4977
+ status: status.optional(),
4978
+ priority: priority.optional(),
4979
+ }).strict().refine(
4980
+ (input) => input.content !== undefined
4981
+ || input.status !== undefined
4982
+ || input.priority !== undefined,
4983
+ { message: 'Task update requires content, status, or priority.' },
4984
+ ),
4985
+ plugins.z.object({ action: plugins.z.literal('delete'), id }).strict(),
4986
+ plugins.z.object({ action: plugins.z.literal('clear') }).strict(),
4987
+ ]),
4988
+ execute: (
4989
+ input: TProjectTaskToolInput,
4990
+ options?: { toolCallId?: string },
4991
+ ) => this.executeProjectTaskTool(run, input, options?.toolCallId),
4992
+ });
4993
+ }
4994
+
4995
+ private createProjectGoalTool(run: IActiveRun): unknown {
4996
+ return plugins.tool({
4997
+ description: 'Get, set, or clear the session-local project goal.',
4998
+ inputSchema: plugins.z.discriminatedUnion('action', [
4999
+ plugins.z.object({ action: plugins.z.literal('get') }).strict(),
5000
+ plugins.z.object({
5001
+ action: plugins.z.literal('set'),
5002
+ goal: plugins.z.string().min(1).max(FLEX_PROJECT_MANAGEMENT_LIMITS.maxGoalBytes),
5003
+ }).strict(),
5004
+ plugins.z.object({ action: plugins.z.literal('clear') }).strict(),
5005
+ ]),
5006
+ execute: (
5007
+ input: TProjectGoalToolInput,
5008
+ options?: { toolCallId?: string },
5009
+ ) => this.executeProjectGoalTool(run, input, options?.toolCallId),
5010
+ });
5011
+ }
5012
+
5013
+ private createProjectScratchpadTool(run: IActiveRun): unknown {
5014
+ const content = plugins.z.string().max(FLEX_PROJECT_MANAGEMENT_LIMITS.maxScratchpadBytes);
5015
+ return plugins.tool({
5016
+ description: 'Get, set, append to, or clear the session-local project scratchpad.',
5017
+ inputSchema: plugins.z.discriminatedUnion('action', [
5018
+ plugins.z.object({ action: plugins.z.literal('get') }).strict(),
5019
+ plugins.z.object({ action: plugins.z.literal('set'), content }).strict(),
5020
+ plugins.z.object({ action: plugins.z.literal('append'), content }).strict(),
5021
+ plugins.z.object({ action: plugins.z.literal('clear') }).strict(),
5022
+ ]),
5023
+ execute: (
5024
+ input: TProjectScratchpadToolInput,
5025
+ options?: { toolCallId?: string },
5026
+ ) => this.executeProjectScratchpadTool(run, input, options?.toolCallId),
5027
+ });
5028
+ }
5029
+
5030
+ private projectManagementAgentWriteContext(
5031
+ run: IActiveRun,
5032
+ toolCallId: string | undefined,
5033
+ ): IFlexProjectManagementWriteContext {
5034
+ return Object.freeze({
5035
+ actor: 'agent' as const,
5036
+ runId: run.runId,
5037
+ ...(run.stored.session.agent === undefined ? {} : { agent: run.stored.session.agent }),
5038
+ ...(toolCallId === undefined ? {} : { toolCallId }),
5039
+ });
5040
+ }
5041
+
5042
+ private async executeProjectTaskTool(
5043
+ run: IActiveRun,
5044
+ input: TProjectTaskToolInput,
5045
+ toolCallId: string | undefined,
5046
+ ): Promise<IFlexProjectStateResult | IFlexProjectTaskResult | IFlexProjectTasksResult> {
5047
+ this.assertProjectManagementSession(run.state, run.stored);
5048
+ if (input.action === 'list') {
5049
+ const snapshot = await this.readProjectManagementState(run.state, run.stored);
5050
+ return this.projectTasksResult(snapshot, snapshot.tasks);
5051
+ }
5052
+ const writeContext = this.projectManagementAgentWriteContext(run, toolCallId);
5053
+ if (input.action === 'create') {
5054
+ const id = input.id ?? (() => {
5055
+ validateUtf8String(
5056
+ toolCallId,
5057
+ 'task toolCallId',
5058
+ maxTransferIdentifierBytes,
5059
+ true,
5060
+ );
5061
+ return this.createProjectTaskId(
5062
+ run.state.storageKey,
5063
+ run.sessionId,
5064
+ run.runId,
5065
+ toolCallId,
5066
+ );
5067
+ })();
5068
+ const createInput: IFlexCreateProjectTaskInput = {
5069
+ id,
5070
+ content: input.content,
5071
+ ...(input.status === undefined ? {} : { status: input.status }),
5072
+ ...(input.priority === undefined ? {} : { priority: input.priority }),
5073
+ };
5074
+ validateCreateProjectTaskInput(createInput);
5075
+ return this.createProjectTaskInState(run.state, run.stored, createInput, writeContext);
5076
+ }
5077
+ if (input.action === 'update') {
5078
+ const updateInput: IFlexUpdateProjectTaskInput = {
5079
+ id: input.id,
5080
+ ...(input.content === undefined ? {} : { content: input.content }),
5081
+ ...(input.status === undefined ? {} : { status: input.status }),
5082
+ ...(input.priority === undefined ? {} : { priority: input.priority }),
5083
+ };
5084
+ validateUpdateProjectTaskInput(updateInput);
5085
+ return this.updateProjectTaskInState(run.state, run.stored, updateInput, writeContext);
5086
+ }
5087
+ if (input.action === 'delete') {
5088
+ validateProjectTaskId(input.id);
5089
+ return this.deleteProjectTaskInState(run.state, run.stored, input.id, writeContext);
5090
+ }
5091
+ return this.clearProjectTasksInState(run.state, run.stored, writeContext);
5092
+ }
5093
+
5094
+ private async executeProjectGoalTool(
5095
+ run: IActiveRun,
5096
+ input: TProjectGoalToolInput,
5097
+ toolCallId: string | undefined,
5098
+ ): Promise<IFlexProjectGoalResult> {
5099
+ this.assertProjectManagementSession(run.state, run.stored);
5100
+ if (input.action === 'get') {
5101
+ return this.projectGoalResult(await this.readProjectManagementState(run.state, run.stored));
5102
+ }
5103
+ const writeContext = this.projectManagementAgentWriteContext(run, toolCallId);
5104
+ if (input.action === 'set') {
5105
+ validateUtf8String(
5106
+ input.goal,
5107
+ 'project goal',
5108
+ FLEX_PROJECT_MANAGEMENT_LIMITS.maxGoalBytes,
5109
+ true,
5110
+ );
5111
+ return this.setProjectGoalInState(run.state, run.stored, input.goal, writeContext);
5112
+ }
5113
+ return this.clearProjectGoalInState(run.state, run.stored, writeContext);
5114
+ }
5115
+
5116
+ private async executeProjectScratchpadTool(
5117
+ run: IActiveRun,
5118
+ input: TProjectScratchpadToolInput,
5119
+ toolCallId: string | undefined,
5120
+ ): Promise<IFlexProjectScratchpadResult> {
5121
+ this.assertProjectManagementSession(run.state, run.stored);
5122
+ if (input.action === 'get') {
5123
+ return this.projectScratchpadResult(
5124
+ await this.readProjectManagementState(run.state, run.stored),
5125
+ );
5126
+ }
5127
+ const writeContext = this.projectManagementAgentWriteContext(run, toolCallId);
5128
+ if (input.action === 'set') {
5129
+ validateUtf8String(
5130
+ input.content,
5131
+ 'project scratchpad',
5132
+ FLEX_PROJECT_MANAGEMENT_LIMITS.maxScratchpadBytes,
5133
+ );
5134
+ return this.setProjectScratchpadInState(run.state, run.stored, input.content, writeContext);
5135
+ }
5136
+ if (input.action === 'append') {
5137
+ validateUtf8String(
5138
+ input.content,
5139
+ 'project scratchpad append',
5140
+ FLEX_PROJECT_MANAGEMENT_LIMITS.maxScratchpadBytes,
5141
+ );
5142
+ return this.appendProjectScratchpadInState(
5143
+ run.state,
5144
+ run.stored,
5145
+ input.content,
5146
+ writeContext,
5147
+ );
5148
+ }
5149
+ return this.clearProjectScratchpadInState(run.state, run.stored, writeContext);
5150
+ }
5151
+
5152
+ private createProjectTaskId(
5153
+ storageKey: string,
5154
+ sessionId: string,
5155
+ runId: string,
5156
+ toolCallId: string,
5157
+ ): string {
5158
+ return `task_${sha256Hex(JSON.stringify([
5159
+ 'flexharness-project-task-v1',
5160
+ storageKey,
5161
+ sessionId,
5162
+ runId,
5163
+ toolCallId,
5164
+ ]))}`;
5165
+ }
5166
+
4070
5167
  private createSubagentTool(run: IActiveRun): unknown {
4071
5168
  const available = [...this.subagents.values()]
4072
5169
  .map((definition) => `- ${definition.name}: ${definition.description}`)
@@ -4097,16 +5194,16 @@ export class FlexHarness<TScope = unknown> {
4097
5194
  }
4098
5195
  validateUtf8String(
4099
5196
  input.description,
4100
- 'task description',
5197
+ 'delegate description',
4101
5198
  maxSubagentTaskDescriptionBytes,
4102
5199
  true,
4103
5200
  );
4104
- validateUtf8String(input.prompt, 'task prompt', maxSubagentPromptBytes, true);
5201
+ validateUtf8String(input.prompt, 'delegate prompt', maxSubagentPromptBytes, true);
4105
5202
  validateUtf8String(input.subagentType, 'subagentType', maxSubagentNameBytes, true);
4106
5203
  if (input.taskId !== undefined) {
4107
5204
  validateUtf8String(input.taskId, 'taskId', maxSubagentTaskIdBytes, true);
4108
5205
  }
4109
- validateUtf8String(toolCallId, 'task toolCallId', maxTransferIdentifierBytes, true);
5206
+ validateUtf8String(toolCallId, 'delegate toolCallId', maxTransferIdentifierBytes, true);
4110
5207
  const definition = this.subagents.get(input.subagentType);
4111
5208
  if (!definition || (run.stored.session.depth ?? 0) >= this.maxSubagentDepth) {
4112
5209
  throw new FlexHarnessValidationError(`Subagent "${input.subagentType}" is not available.`);
@@ -4330,6 +5427,8 @@ export class FlexHarness<TScope = unknown> {
4330
5427
  metadata = {
4331
5428
  scopeId: run.scopeId,
4332
5429
  sessionId,
5430
+ sessionGenerationId: createSessionGenerationId(),
5431
+ sessionGenerationSequence: state.revision + 1,
4333
5432
  title: `Subagent: ${definition.name}`,
4334
5433
  createdAt: timestamp,
4335
5434
  updatedAt: timestamp,
@@ -4403,6 +5502,7 @@ export class FlexHarness<TScope = unknown> {
4403
5502
  state.sessions.delete(sessionId);
4404
5503
  state.tombstones.set(sessionId, {
4405
5504
  sessionId,
5505
+ ...requireSessionGeneration(metadata!),
4406
5506
  deletedAt: new Date().toISOString(),
4407
5507
  rootSessionId: sessionId,
4408
5508
  depth: 0,
@@ -4458,12 +5558,12 @@ export class FlexHarness<TScope = unknown> {
4458
5558
  const partId = run.toolPartIds.get(toolCallId);
4459
5559
  const part = run.callbackParts.find((entry) => entry.partId === partId && entry.type === 'tool');
4460
5560
  if (!part || part.type !== 'tool' || part.status !== 'running') {
4461
- throw new FlexHarnessValidationError(`Running task part "${toolCallId}" is unavailable.`);
5561
+ throw new FlexHarnessValidationError(`Running delegate part "${toolCallId}" is unavailable.`);
4462
5562
  }
4463
5563
  const bytes = Buffer.byteLength(childSessionId, 'utf8')
4464
5564
  + (model === undefined ? 0 : jsonBytes(model));
4465
5565
  if (!this.reserveCallbackCapacity(run, 1, bytes, 0)) {
4466
- throw run.callbackError ?? new FlexHarnessCallbackOverflowError('Task metadata exceeded callback limits.');
5566
+ throw run.callbackError ?? new FlexHarnessCallbackOverflowError('Delegate metadata exceeded callback limits.');
4467
5567
  }
4468
5568
  part.childSessionId = childSessionId;
4469
5569
  if (model !== undefined) part.model = cloneSerializable(model);
@@ -6264,6 +7364,7 @@ export class FlexHarness<TScope = unknown> {
6264
7364
  throw new FlexHarnessValidationError('Tool handle close must be a function.');
6265
7365
  }
6266
7366
  const tools = handle.tools;
7367
+ this.assertNoBuiltInToolCollisions(run, tools);
6267
7368
  const closeHandle = close;
6268
7369
  return {
6269
7370
  tools,
@@ -6603,6 +7704,28 @@ export class FlexHarness<TScope = unknown> {
6603
7704
  sessions: [],
6604
7705
  tombstones: [],
6605
7706
  } satisfies IFlexScopeSnapshot);
7707
+ let scopeChanged = false;
7708
+ const repairedGenerationSequence = snapshot.revision + 1;
7709
+ for (const session of snapshot.sessions) {
7710
+ if (session.sessionGenerationId !== undefined) continue;
7711
+ session.sessionGenerationId = deriveLegacySessionGenerationId(
7712
+ storageKey,
7713
+ session.sessionId,
7714
+ session.createdAt,
7715
+ );
7716
+ session.sessionGenerationSequence = repairedGenerationSequence;
7717
+ scopeChanged = true;
7718
+ }
7719
+ for (const tombstone of snapshot.tombstones) {
7720
+ if (tombstone.sessionGenerationId !== undefined) continue;
7721
+ tombstone.sessionGenerationId = deriveLegacySessionGenerationId(
7722
+ storageKey,
7723
+ tombstone.sessionId,
7724
+ tombstone.deletedAt,
7725
+ );
7726
+ tombstone.sessionGenerationSequence = repairedGenerationSequence;
7727
+ scopeChanged = true;
7728
+ }
6606
7729
  const state: IStorageState = {
6607
7730
  storageKey,
6608
7731
  compactorLifecycleController,
@@ -6625,7 +7748,6 @@ export class FlexHarness<TScope = unknown> {
6625
7748
  detachedCleanups: new Set(),
6626
7749
  };
6627
7750
  const loadedSessions: IStoredSessionState[] = [];
6628
- let scopeChanged = false;
6629
7751
  try {
6630
7752
  for (const metadata of snapshot.sessions) {
6631
7753
  if (state.tombstones.has(metadata.sessionId)) continue;
@@ -6677,7 +7799,7 @@ export class FlexHarness<TScope = unknown> {
6677
7799
  scopeId,
6678
7800
  scope,
6679
7801
  );
6680
- await this.cleanupSessionDomains(storageKey, tombstone.sessionId);
7802
+ await this.cleanupSessionDomains(storageKey, tombstone);
6681
7803
  }
6682
7804
  for (const tombstone of group) state.tombstones.delete(tombstone.sessionId);
6683
7805
  scopeChanged = true;
@@ -7637,12 +8759,26 @@ export class FlexHarness<TScope = unknown> {
7637
8759
  stored.projectionReconciliationRequired = false;
7638
8760
  }
7639
8761
 
7640
- private async cleanupSessionDomains(storageKey: string, sessionId: string): Promise<void> {
8762
+ private async cleanupSessionDomains(
8763
+ storageKey: string,
8764
+ tombstone: IFlexSessionTombstone,
8765
+ ): Promise<void> {
8766
+ const sessionId = tombstone.sessionId;
8767
+ const projectManagementCleanup = async (): Promise<void> => {
8768
+ await this.projectManagementQueues.get(
8769
+ this.projectManagementKey(storageKey, sessionId),
8770
+ );
8771
+ await this.tombstoneProjectManagementSession(
8772
+ storageKey,
8773
+ tombstone,
8774
+ );
8775
+ };
7641
8776
  const results = await Promise.allSettled([
7642
8777
  this.stores.agentEvents.deleteSession(storageKey, sessionId),
7643
8778
  this.stores.jobs.deleteSession(storageKey, sessionId),
7644
8779
  this.stores.projections.deleteSession(storageKey, sessionId),
7645
8780
  this.stores.permissions.deleteSession(storageKey, sessionId),
8781
+ projectManagementCleanup(),
7646
8782
  ]);
7647
8783
  const errors = results
7648
8784
  .filter((result): result is PromiseRejectedResult => result.status === 'rejected')
@@ -7650,6 +8786,66 @@ export class FlexHarness<TScope = unknown> {
7650
8786
  if (errors.length > 0) throw combineErrors(errors);
7651
8787
  }
7652
8788
 
8789
+ private async tombstoneProjectManagementSession(
8790
+ storageKey: string,
8791
+ sessionTombstone: IFlexSessionTombstone,
8792
+ ): Promise<void> {
8793
+ const store = this.stores.projectManagement;
8794
+ const sessionId = sessionTombstone.sessionId;
8795
+ const generation = requireSessionGeneration(sessionTombstone);
8796
+ let lastConflict: FlexHarnessStoreConflictError | undefined;
8797
+ for (let attempt = 0; attempt <= maxProjectManagementTombstoneConflicts; attempt++) {
8798
+ const current = await store.load(storageKey, sessionId);
8799
+ if (current !== undefined) {
8800
+ assertFlexProjectManagementRecord(current);
8801
+ if (current.sessionGenerationId === generation.sessionGenerationId) {
8802
+ if (current.sessionGenerationSequence !== generation.sessionGenerationSequence) {
8803
+ throw new FlexHarnessStoreConflictError(
8804
+ this.projectManagementKey(storageKey, sessionId),
8805
+ 0,
8806
+ current.revision,
8807
+ );
8808
+ }
8809
+ if ('deletedAt' in current) return;
8810
+ } else if (
8811
+ !('deletedAt' in current)
8812
+ || current.sessionGenerationSequence >= generation.sessionGenerationSequence
8813
+ ) {
8814
+ throw new FlexHarnessStoreConflictError(
8815
+ this.projectManagementKey(storageKey, sessionId),
8816
+ 0,
8817
+ current.revision,
8818
+ );
8819
+ }
8820
+ }
8821
+ if (attempt === maxProjectManagementTombstoneConflicts) break;
8822
+ const expectedRevision = current === undefined
8823
+ || current.sessionGenerationId !== generation.sessionGenerationId
8824
+ ? 0
8825
+ : current.revision;
8826
+ const tombstone: IFlexProjectManagementTombstone = {
8827
+ schemaVersion: 1,
8828
+ revision: expectedRevision + 1,
8829
+ ...generation,
8830
+ deletedAt: sessionTombstone.deletedAt,
8831
+ };
8832
+ try {
8833
+ await store.tombstoneSession(
8834
+ storageKey,
8835
+ sessionId,
8836
+ tombstone,
8837
+ expectedRevision,
8838
+ );
8839
+ } catch (error) {
8840
+ if (!(error instanceof FlexHarnessStoreConflictError)) throw error;
8841
+ lastConflict = error;
8842
+ }
8843
+ }
8844
+ throw lastConflict ?? new FlexHarnessValidationError(
8845
+ `Project management tombstone for session "${sessionId}" could not be durably confirmed.`,
8846
+ );
8847
+ }
8848
+
7653
8849
  private collectSessionSubtree(
7654
8850
  state: IStorageState,
7655
8851
  rootSessionId: string,
@@ -7845,7 +9041,7 @@ export class FlexHarness<TScope = unknown> {
7845
9041
  releaseContext.scope,
7846
9042
  retained?.stored,
7847
9043
  );
7848
- await this.cleanupSessionDomains(state.storageKey, sessionId);
9044
+ await this.cleanupSessionDomains(state.storageKey, tombstone);
7849
9045
  if (retained) retained.domainsCompleted = true;
7850
9046
  }
7851
9047
  }
@@ -7925,6 +9121,13 @@ export class FlexHarness<TScope = unknown> {
7925
9121
  await Promise.allSettled([...state.sessionInitializations.values()]);
7926
9122
  cleanupErrors.push(...await this.closeOrphanedResources(state.storageKey));
7927
9123
  await state.scopeQueue;
9124
+ await this.awaitProjectManagementOperations(
9125
+ state.storageKey,
9126
+ [
9127
+ ...state.sessions.keys(),
9128
+ ...state.retainedSessionCleanups.keys(),
9129
+ ],
9130
+ );
7928
9131
  const currentTombstoneRoot = state.tombstones.get(currentRun.sessionId)?.rootSessionId
7929
9132
  ?? currentRun.sessionId;
7930
9133
  const currentTombstoneCleanup = state.tombstoneCleanups.get(currentTombstoneRoot);
@@ -8458,6 +9661,13 @@ export class FlexHarness<TScope = unknown> {
8458
9661
  await Promise.allSettled([...state.sessionInitializations.values()]);
8459
9662
  errors.push(...await this.closeOrphanedResources(state.storageKey));
8460
9663
  await state.scopeQueue;
9664
+ await this.awaitProjectManagementOperations(
9665
+ state.storageKey,
9666
+ [
9667
+ ...state.sessions.keys(),
9668
+ ...state.retainedSessionCleanups.keys(),
9669
+ ],
9670
+ );
8461
9671
  await Promise.all([...state.sessions.values()].flatMap((stored) => [
8462
9672
  stored.projectionQueue,
8463
9673
  stored.permissionQueue,