@electric-ax/agents-server 0.4.17 → 0.4.19

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.
@@ -2235,7 +2235,10 @@ async function authorizeDurableStreamAccess(request, ctx) {
2235
2235
  }
2236
2236
  if (method === `PUT` || method === `POST`) {
2237
2237
  const ownerEntityUrl = request.headers.get(SHARED_STATE_OWNER_ENTITY_HEADER)?.trim() || void 0;
2238
- if (await canAccessSharedState(ctx, sharedStateId, `write`, request, ownerEntityUrl)) return void 0;
2238
+ if (await canAccessSharedState(ctx, sharedStateId, `write`, request, ownerEntityUrl)) {
2239
+ if (ownerEntityUrl) await ctx.entityManager.registry.replaceSharedStateLink(ownerEntityUrl, `shared-state:${sharedStateId}`, sharedStateId);
2240
+ return void 0;
2241
+ }
2239
2242
  return apiError(401, ErrCodeUnauthorized, `Principal is not allowed to write shared state`);
2240
2243
  }
2241
2244
  return apiError(401, ErrCodeUnauthorized, `Principal is not allowed to access shared state`);
@@ -3973,9 +3976,10 @@ var EntityManager = class {
3973
3976
  const workLocks = new Set();
3974
3977
  const writeEntityLocks = new Set();
3975
3978
  const writeStreamLocks = new Set();
3979
+ const usePointerPath = opts.forkPointer !== void 0 || opts.anchor !== void 0;
3976
3980
  try {
3977
3981
  let sourceTree;
3978
- if (opts.forkPointer) {
3982
+ if (usePointerPath) {
3979
3983
  const rootEntity = await this.registry.getEntity(rootUrl);
3980
3984
  if (!rootEntity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
3981
3985
  if (isTerminalEntityStatus(rootEntity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Cannot fork terminal entity "${rootEntity.url}"`, 409);
@@ -3984,10 +3988,13 @@ var EntityManager = class {
3984
3988
  const sourceRoot = sourceTree[0];
3985
3989
  if (sourceRoot.parent) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Only top-level entities can be forked`, 400);
3986
3990
  let preFilteredRoot;
3987
- if (opts.forkPointer) {
3991
+ let effectiveForkPointer = opts.forkPointer;
3992
+ if (usePointerPath) {
3988
3993
  const sourceEvents = await this.streamClient.readJson(sourceRoot.streams.main);
3989
3994
  const flat = sourceEvents.flatMap((item) => Array.isArray(item) ? item : [item]);
3990
- const target = this.resolveForkPointerTarget(flat, opts.forkPointer, sourceRoot.streams.main);
3995
+ if (!effectiveForkPointer && opts.anchor === `latest_completed_run`) effectiveForkPointer = this.resolveLatestCompletedRunPointer(flat, sourceRoot.streams.main);
3996
+ if (!effectiveForkPointer) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Internal: pointer-path fork with no resolved pointer`, 500);
3997
+ const target = this.resolveForkPointerTarget(flat, effectiveForkPointer, sourceRoot.streams.main);
3991
3998
  const filteredEvents = flat.slice(0, target);
3992
3999
  const rootManifests = this.reduceStateRows(filteredEvents, `manifest`);
3993
4000
  const sharedStateIds = new Set();
@@ -4000,7 +4007,7 @@ var EntityManager = class {
4000
4007
  };
4001
4008
  }
4002
4009
  const effectiveSubtree = preFilteredRoot ? this.computeEffectiveSubtree(sourceTree, sourceRoot.url, preFilteredRoot.manifests) : sourceTree;
4003
- if (opts.forkPointer) {
4010
+ if (usePointerPath) {
4004
4011
  const descendants = effectiveSubtree.filter((entity) => entity.url !== sourceRoot.url);
4005
4012
  if (descendants.length > 0) await this.waitForGivenEntitiesIdle(descendants, opts, workLocks);
4006
4013
  }
@@ -4025,6 +4032,14 @@ var EntityManager = class {
4025
4032
  const sharedStateIdMap = await this.buildForkSharedStateIdMap(snapshot.sharedStateIds, suffix);
4026
4033
  const stringMap = this.buildForkStringMap(entityUrlMap, sharedStateIdMap);
4027
4034
  const entityPlans = this.buildForkEntityPlans(effectiveSubtree, entityUrlMap, stringMap, opts.createdBy);
4035
+ const rootPlanForOverrides = entityPlans.find((plan) => plan.source.url === rootUrl);
4036
+ if (rootPlanForOverrides) {
4037
+ if (opts.parent !== void 0) rootPlanForOverrides.fork.parent = opts.parent;
4038
+ if (opts.tags !== void 0) rootPlanForOverrides.fork.tags = {
4039
+ ...rootPlanForOverrides.fork.tags ?? {},
4040
+ ...opts.tags
4041
+ };
4042
+ }
4028
4043
  this.addForkLocks(this.forkWriteLockedEntities, effectiveSubtree.map((entity) => entity.url), writeEntityLocks);
4029
4044
  this.addForkLocks(this.forkWriteLockedStreams, [...snapshot.sharedStateIds].map((id) => getSharedStateStreamPath(id)), writeStreamLocks);
4030
4045
  const createdStreams = [];
@@ -4033,7 +4048,7 @@ var EntityManager = class {
4033
4048
  try {
4034
4049
  for (const plan of entityPlans) {
4035
4050
  const isRoot = plan.source.url === rootUrl;
4036
- await this.streamClient.fork(plan.fork.streams.main, plan.source.streams.main, isRoot && opts.forkPointer ? { forkPointer: opts.forkPointer } : void 0);
4051
+ await this.streamClient.fork(plan.fork.streams.main, plan.source.streams.main, isRoot && effectiveForkPointer ? { forkPointer: effectiveForkPointer } : void 0);
4037
4052
  createdStreams.push(plan.fork.streams.main);
4038
4053
  }
4039
4054
  for (const [sourceId, forkId] of sharedStateIdMap) {
@@ -4060,6 +4075,20 @@ var EntityManager = class {
4060
4075
  await this.registry.createEntity(plan.fork);
4061
4076
  createdEntities.push(plan.fork.url);
4062
4077
  }
4078
+ if (opts.wake !== void 0) {
4079
+ const rootPlan = entityPlans.find((plan) => plan.source.url === rootUrl);
4080
+ if (rootPlan) await this.wakeRegistry.register({
4081
+ tenantId: this.tenantId,
4082
+ subscriberUrl: opts.wake.subscriberUrl,
4083
+ sourceUrl: rootPlan.fork.url,
4084
+ condition: opts.wake.condition,
4085
+ debounceMs: opts.wake.debounceMs,
4086
+ timeoutMs: opts.wake.timeoutMs,
4087
+ oneShot: false,
4088
+ includeResponse: opts.wake.includeResponse,
4089
+ manifestKey: opts.wake.manifestKey
4090
+ });
4091
+ }
4063
4092
  for (const plan of entityPlans) {
4064
4093
  const manifests = activeManifestsByEntity.get(plan.fork.url) ?? new Map();
4065
4094
  await this.materializeForkManifestSideEffects(plan.fork.url, manifests);
@@ -4220,6 +4249,45 @@ var EntityManager = class {
4220
4249
  return positionAtAnchor + pointer.subOffset;
4221
4250
  }
4222
4251
  /**
4252
+ * Find an `EventPointer` that addresses the most recent `runs` row on
4253
+ * the source's `main` stream with `status === 'completed'`. Mirrors the
4254
+ * eligibility rule the per-row "Fork from here" UI applies — only
4255
+ * completed runs are valid fork anchors; `started`/`failed` rows are
4256
+ * skipped.
4257
+ *
4258
+ * The pointer is computed in the same coordinate system the runtime
4259
+ * mints pointers in (see entity-stream-db's onBeforeBatch): for a row
4260
+ * R in log entry E, the pointer is `{ offset: P, subOffset: K }` where
4261
+ * `P` is the END offset of the log entry preceding E (or `null` for
4262
+ * stream start) and `K` is R's 1-indexed position within E.
4263
+ */
4264
+ resolveLatestCompletedRunPointer(events, streamPath) {
4265
+ let priorEntryOffset = null;
4266
+ let currentEntryOffset = null;
4267
+ let positionInEntry = 0;
4268
+ let latest = null;
4269
+ for (const event of events) {
4270
+ const headers = isRecord(event.headers) ? event.headers : void 0;
4271
+ const eventOffset = typeof headers?.offset === `string` ? headers.offset : null;
4272
+ if (eventOffset !== currentEntryOffset) {
4273
+ priorEntryOffset = currentEntryOffset;
4274
+ currentEntryOffset = eventOffset;
4275
+ positionInEntry = 0;
4276
+ }
4277
+ positionInEntry++;
4278
+ if (event.type !== `run`) continue;
4279
+ if (headers?.operation === `delete`) continue;
4280
+ if (!isRecord(event.value)) continue;
4281
+ if (event.value.status !== `completed`) continue;
4282
+ latest = {
4283
+ offset: priorEntryOffset,
4284
+ subOffset: positionInEntry
4285
+ };
4286
+ }
4287
+ if (!latest) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Source ${streamPath} has no completed run to fork from`, 400);
4288
+ return latest;
4289
+ }
4290
+ /**
4223
4291
  * Compute the subset of `sourceTree` that survives the manifest filter
4224
4292
  * applied at the root. After filtering the root's manifest at the fork
4225
4293
  * pointer, only children whose manifest entries landed at or before the
@@ -5726,6 +5794,14 @@ function agentUrlPath(value) {
5726
5794
  return value;
5727
5795
  }
5728
5796
  }
5797
+ async function hasValidAgentWriteToken(request, ctx, fromAgent) {
5798
+ const agentUrl = agentUrlPath(fromAgent);
5799
+ const token = writeTokenFromRequest(request);
5800
+ if (!token) return false;
5801
+ const agentEntity = await ctx.entityManager.registry.getEntity(agentUrl);
5802
+ if (!agentEntity) return false;
5803
+ return ctx.entityManager.isValidWriteToken(agentEntity, token);
5804
+ }
5729
5805
  const inboxMessageBodySchema = Type.Object({
5730
5806
  payload: Type.Optional(Type.Unknown()),
5731
5807
  position: Type.Optional(Type.String()),
@@ -5747,7 +5823,19 @@ const forkBodySchema = Type.Object({
5747
5823
  fork_pointer: Type.Optional(Type.Object({
5748
5824
  offset: Type.Union([Type.String(), Type.Null()]),
5749
5825
  sub_offset: Type.Number()
5750
- }))
5826
+ })),
5827
+ anchor: Type.Optional(Type.Literal(`latest_completed_run`)),
5828
+ parent: Type.Optional(Type.String()),
5829
+ wake: Type.Optional(Type.Object({
5830
+ subscriberUrl: Type.String(),
5831
+ condition: wakeConditionSchema,
5832
+ debounceMs: Type.Optional(Type.Number()),
5833
+ timeoutMs: Type.Optional(Type.Number()),
5834
+ includeResponse: Type.Optional(Type.Boolean()),
5835
+ manifestKey: Type.Optional(Type.String())
5836
+ })),
5837
+ initialMessage: Type.Optional(Type.Unknown()),
5838
+ tags: Type.Optional(stringRecordSchema$1)
5751
5839
  });
5752
5840
  const setTagBodySchema = Type.Object({ value: Type.String() });
5753
5841
  const entitySignalSchema = Type.Union([
@@ -6134,8 +6222,18 @@ async function forkEntity(request, ctx) {
6134
6222
  const principalMutationError = rejectPrincipalEntityMutation(request, `forked`);
6135
6223
  if (principalMutationError) return principalMutationError;
6136
6224
  const parsed = routeBody(request);
6225
+ if (parsed.fork_pointer && parsed.anchor) return apiError(400, ErrCodeInvalidRequest, `fork_pointer and anchor are mutually exclusive`);
6137
6226
  const { entityUrl, entity } = requireExistingEntityRoute(request);
6138
6227
  await assertDispatchPolicyAllowed(ctx, entity.dispatch_policy);
6228
+ if (parsed.parent !== void 0) {
6229
+ const parent = await ctx.entityManager.registry.getEntity(parsed.parent);
6230
+ if (!parent) return apiError(404, ErrCodeNotFound, `Parent entity not found`);
6231
+ if (!await canAccessEntity(ctx, parent, `spawn`, request)) return apiError(401, ErrCodeUnauthorized, `Principal is not allowed to spawn children from ${parent.url}`);
6232
+ }
6233
+ if (parsed.wake !== void 0) {
6234
+ if (parsed.parent === void 0) return apiError(400, ErrCodeInvalidRequest, `wake requires parent (the fork's wake fires to its parent)`);
6235
+ if (parsed.wake.subscriberUrl !== parsed.parent) return apiError(401, ErrCodeUnauthorized, `wake.subscriberUrl must match parent`);
6236
+ }
6139
6237
  const result = await ctx.entityManager.forkSubtree(entityUrl, {
6140
6238
  rootInstanceId: parsed.instance_id,
6141
6239
  waitTimeoutMs: parsed.waitTimeoutMs,
@@ -6143,9 +6241,17 @@ async function forkEntity(request, ctx) {
6143
6241
  ...parsed.fork_pointer && { forkPointer: {
6144
6242
  offset: parsed.fork_pointer.offset,
6145
6243
  subOffset: parsed.fork_pointer.sub_offset
6146
- } }
6244
+ } },
6245
+ ...parsed.anchor && { anchor: parsed.anchor },
6246
+ ...parsed.parent !== void 0 && { parent: parsed.parent },
6247
+ ...parsed.wake !== void 0 && { wake: parsed.wake },
6248
+ ...parsed.tags !== void 0 && { tags: parsed.tags }
6147
6249
  });
6148
6250
  for (const forkedEntity of result.entities) await linkEntityDispatchSubscription(ctx, forkedEntity);
6251
+ if (parsed.initialMessage !== void 0) await ctx.entityManager.send(result.root.url, {
6252
+ from: parsed.parent ?? ctx.principal.url,
6253
+ payload: parsed.initialMessage
6254
+ });
6149
6255
  return json({
6150
6256
  root: toPublicEntity(result.root),
6151
6257
  entities: result.entities.map((entity$1) => toPublicEntity(entity$1))
@@ -6158,7 +6264,10 @@ async function sendEntity(request, ctx) {
6158
6264
  if (parsed.from_principal !== void 0 && parsed.from_principal !== principal.url) return apiError(400, ErrCodeInvalidRequest, `Request from_principal must match Electric-Principal`);
6159
6265
  if (parsed.from_agent !== void 0) {
6160
6266
  const principalAgentUrl = agentUrlForPrincipal(principal);
6161
- if (agentUrlPath(parsed.from_agent) !== principalAgentUrl) return apiError(400, ErrCodeInvalidRequest, `Request from_agent must match authenticated agent principal`);
6267
+ const fromAgentUrl = agentUrlPath(parsed.from_agent);
6268
+ const matchesPrincipalAgent = fromAgentUrl === principalAgentUrl;
6269
+ const hasAgentWriteToken = await hasValidAgentWriteToken(request, ctx, parsed.from_agent);
6270
+ if (!matchesPrincipalAgent && !hasAgentWriteToken) return apiError(400, ErrCodeInvalidRequest, `Request from_agent must match authenticated agent principal`);
6162
6271
  }
6163
6272
  await ctx.entityManager.ensurePrincipal(principal);
6164
6273
  const { entityUrl, entity } = requireExistingEntityRoute(request);
package/dist/index.cjs CHANGED
@@ -3664,9 +3664,10 @@ var EntityManager = class {
3664
3664
  const workLocks = new Set();
3665
3665
  const writeEntityLocks = new Set();
3666
3666
  const writeStreamLocks = new Set();
3667
+ const usePointerPath = opts.forkPointer !== void 0 || opts.anchor !== void 0;
3667
3668
  try {
3668
3669
  let sourceTree;
3669
- if (opts.forkPointer) {
3670
+ if (usePointerPath) {
3670
3671
  const rootEntity = await this.registry.getEntity(rootUrl);
3671
3672
  if (!rootEntity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
3672
3673
  if (isTerminalEntityStatus(rootEntity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Cannot fork terminal entity "${rootEntity.url}"`, 409);
@@ -3675,10 +3676,13 @@ var EntityManager = class {
3675
3676
  const sourceRoot = sourceTree[0];
3676
3677
  if (sourceRoot.parent) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Only top-level entities can be forked`, 400);
3677
3678
  let preFilteredRoot;
3678
- if (opts.forkPointer) {
3679
+ let effectiveForkPointer = opts.forkPointer;
3680
+ if (usePointerPath) {
3679
3681
  const sourceEvents = await this.streamClient.readJson(sourceRoot.streams.main);
3680
3682
  const flat = sourceEvents.flatMap((item) => Array.isArray(item) ? item : [item]);
3681
- const target = this.resolveForkPointerTarget(flat, opts.forkPointer, sourceRoot.streams.main);
3683
+ if (!effectiveForkPointer && opts.anchor === `latest_completed_run`) effectiveForkPointer = this.resolveLatestCompletedRunPointer(flat, sourceRoot.streams.main);
3684
+ if (!effectiveForkPointer) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Internal: pointer-path fork with no resolved pointer`, 500);
3685
+ const target = this.resolveForkPointerTarget(flat, effectiveForkPointer, sourceRoot.streams.main);
3682
3686
  const filteredEvents = flat.slice(0, target);
3683
3687
  const rootManifests = this.reduceStateRows(filteredEvents, `manifest`);
3684
3688
  const sharedStateIds = new Set();
@@ -3691,7 +3695,7 @@ var EntityManager = class {
3691
3695
  };
3692
3696
  }
3693
3697
  const effectiveSubtree = preFilteredRoot ? this.computeEffectiveSubtree(sourceTree, sourceRoot.url, preFilteredRoot.manifests) : sourceTree;
3694
- if (opts.forkPointer) {
3698
+ if (usePointerPath) {
3695
3699
  const descendants = effectiveSubtree.filter((entity) => entity.url !== sourceRoot.url);
3696
3700
  if (descendants.length > 0) await this.waitForGivenEntitiesIdle(descendants, opts, workLocks);
3697
3701
  }
@@ -3716,6 +3720,14 @@ var EntityManager = class {
3716
3720
  const sharedStateIdMap = await this.buildForkSharedStateIdMap(snapshot.sharedStateIds, suffix);
3717
3721
  const stringMap = this.buildForkStringMap(entityUrlMap, sharedStateIdMap);
3718
3722
  const entityPlans = this.buildForkEntityPlans(effectiveSubtree, entityUrlMap, stringMap, opts.createdBy);
3723
+ const rootPlanForOverrides = entityPlans.find((plan) => plan.source.url === rootUrl);
3724
+ if (rootPlanForOverrides) {
3725
+ if (opts.parent !== void 0) rootPlanForOverrides.fork.parent = opts.parent;
3726
+ if (opts.tags !== void 0) rootPlanForOverrides.fork.tags = {
3727
+ ...rootPlanForOverrides.fork.tags ?? {},
3728
+ ...opts.tags
3729
+ };
3730
+ }
3719
3731
  this.addForkLocks(this.forkWriteLockedEntities, effectiveSubtree.map((entity) => entity.url), writeEntityLocks);
3720
3732
  this.addForkLocks(this.forkWriteLockedStreams, [...snapshot.sharedStateIds].map((id) => (0, __electric_ax_agents_runtime.getSharedStateStreamPath)(id)), writeStreamLocks);
3721
3733
  const createdStreams = [];
@@ -3724,7 +3736,7 @@ var EntityManager = class {
3724
3736
  try {
3725
3737
  for (const plan of entityPlans) {
3726
3738
  const isRoot = plan.source.url === rootUrl;
3727
- await this.streamClient.fork(plan.fork.streams.main, plan.source.streams.main, isRoot && opts.forkPointer ? { forkPointer: opts.forkPointer } : void 0);
3739
+ await this.streamClient.fork(plan.fork.streams.main, plan.source.streams.main, isRoot && effectiveForkPointer ? { forkPointer: effectiveForkPointer } : void 0);
3728
3740
  createdStreams.push(plan.fork.streams.main);
3729
3741
  }
3730
3742
  for (const [sourceId, forkId] of sharedStateIdMap) {
@@ -3751,6 +3763,20 @@ var EntityManager = class {
3751
3763
  await this.registry.createEntity(plan.fork);
3752
3764
  createdEntities.push(plan.fork.url);
3753
3765
  }
3766
+ if (opts.wake !== void 0) {
3767
+ const rootPlan = entityPlans.find((plan) => plan.source.url === rootUrl);
3768
+ if (rootPlan) await this.wakeRegistry.register({
3769
+ tenantId: this.tenantId,
3770
+ subscriberUrl: opts.wake.subscriberUrl,
3771
+ sourceUrl: rootPlan.fork.url,
3772
+ condition: opts.wake.condition,
3773
+ debounceMs: opts.wake.debounceMs,
3774
+ timeoutMs: opts.wake.timeoutMs,
3775
+ oneShot: false,
3776
+ includeResponse: opts.wake.includeResponse,
3777
+ manifestKey: opts.wake.manifestKey
3778
+ });
3779
+ }
3754
3780
  for (const plan of entityPlans) {
3755
3781
  const manifests = activeManifestsByEntity.get(plan.fork.url) ?? new Map();
3756
3782
  await this.materializeForkManifestSideEffects(plan.fork.url, manifests);
@@ -3911,6 +3937,45 @@ var EntityManager = class {
3911
3937
  return positionAtAnchor + pointer.subOffset;
3912
3938
  }
3913
3939
  /**
3940
+ * Find an `EventPointer` that addresses the most recent `runs` row on
3941
+ * the source's `main` stream with `status === 'completed'`. Mirrors the
3942
+ * eligibility rule the per-row "Fork from here" UI applies — only
3943
+ * completed runs are valid fork anchors; `started`/`failed` rows are
3944
+ * skipped.
3945
+ *
3946
+ * The pointer is computed in the same coordinate system the runtime
3947
+ * mints pointers in (see entity-stream-db's onBeforeBatch): for a row
3948
+ * R in log entry E, the pointer is `{ offset: P, subOffset: K }` where
3949
+ * `P` is the END offset of the log entry preceding E (or `null` for
3950
+ * stream start) and `K` is R's 1-indexed position within E.
3951
+ */
3952
+ resolveLatestCompletedRunPointer(events, streamPath) {
3953
+ let priorEntryOffset = null;
3954
+ let currentEntryOffset = null;
3955
+ let positionInEntry = 0;
3956
+ let latest = null;
3957
+ for (const event of events) {
3958
+ const headers = isRecord(event.headers) ? event.headers : void 0;
3959
+ const eventOffset = typeof headers?.offset === `string` ? headers.offset : null;
3960
+ if (eventOffset !== currentEntryOffset) {
3961
+ priorEntryOffset = currentEntryOffset;
3962
+ currentEntryOffset = eventOffset;
3963
+ positionInEntry = 0;
3964
+ }
3965
+ positionInEntry++;
3966
+ if (event.type !== `run`) continue;
3967
+ if (headers?.operation === `delete`) continue;
3968
+ if (!isRecord(event.value)) continue;
3969
+ if (event.value.status !== `completed`) continue;
3970
+ latest = {
3971
+ offset: priorEntryOffset,
3972
+ subOffset: positionInEntry
3973
+ };
3974
+ }
3975
+ if (!latest) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Source ${streamPath} has no completed run to fork from`, 400);
3976
+ return latest;
3977
+ }
3978
+ /**
3914
3979
  * Compute the subset of `sourceTree` that survives the manifest filter
3915
3980
  * applied at the root. After filtering the root's manifest at the fork
3916
3981
  * pointer, only children whose manifest entries landed at or before the
@@ -7857,7 +7922,10 @@ async function authorizeDurableStreamAccess(request, ctx) {
7857
7922
  }
7858
7923
  if (method === `PUT` || method === `POST`) {
7859
7924
  const ownerEntityUrl = request.headers.get(SHARED_STATE_OWNER_ENTITY_HEADER)?.trim() || void 0;
7860
- if (await canAccessSharedState(ctx, sharedStateId, `write`, request, ownerEntityUrl)) return void 0;
7925
+ if (await canAccessSharedState(ctx, sharedStateId, `write`, request, ownerEntityUrl)) {
7926
+ if (ownerEntityUrl) await ctx.entityManager.registry.replaceSharedStateLink(ownerEntityUrl, `shared-state:${sharedStateId}`, sharedStateId);
7927
+ return void 0;
7928
+ }
7861
7929
  return apiError(401, ErrCodeUnauthorized, `Principal is not allowed to write shared state`);
7862
7930
  }
7863
7931
  return apiError(401, ErrCodeUnauthorized, `Principal is not allowed to access shared state`);
@@ -8017,6 +8085,14 @@ function agentUrlPath(value) {
8017
8085
  return value;
8018
8086
  }
8019
8087
  }
8088
+ async function hasValidAgentWriteToken(request, ctx, fromAgent) {
8089
+ const agentUrl = agentUrlPath(fromAgent);
8090
+ const token = writeTokenFromRequest(request);
8091
+ if (!token) return false;
8092
+ const agentEntity = await ctx.entityManager.registry.getEntity(agentUrl);
8093
+ if (!agentEntity) return false;
8094
+ return ctx.entityManager.isValidWriteToken(agentEntity, token);
8095
+ }
8020
8096
  const inboxMessageBodySchema = __sinclair_typebox.Type.Object({
8021
8097
  payload: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Unknown()),
8022
8098
  position: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.String()),
@@ -8038,7 +8114,19 @@ const forkBodySchema = __sinclair_typebox.Type.Object({
8038
8114
  fork_pointer: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Object({
8039
8115
  offset: __sinclair_typebox.Type.Union([__sinclair_typebox.Type.String(), __sinclair_typebox.Type.Null()]),
8040
8116
  sub_offset: __sinclair_typebox.Type.Number()
8041
- }))
8117
+ })),
8118
+ anchor: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Literal(`latest_completed_run`)),
8119
+ parent: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.String()),
8120
+ wake: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Object({
8121
+ subscriberUrl: __sinclair_typebox.Type.String(),
8122
+ condition: wakeConditionSchema,
8123
+ debounceMs: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Number()),
8124
+ timeoutMs: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Number()),
8125
+ includeResponse: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Boolean()),
8126
+ manifestKey: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.String())
8127
+ })),
8128
+ initialMessage: __sinclair_typebox.Type.Optional(__sinclair_typebox.Type.Unknown()),
8129
+ tags: __sinclair_typebox.Type.Optional(stringRecordSchema$1)
8042
8130
  });
8043
8131
  const setTagBodySchema = __sinclair_typebox.Type.Object({ value: __sinclair_typebox.Type.String() });
8044
8132
  const entitySignalSchema = __sinclair_typebox.Type.Union([
@@ -8425,8 +8513,18 @@ async function forkEntity(request, ctx) {
8425
8513
  const principalMutationError = rejectPrincipalEntityMutation(request, `forked`);
8426
8514
  if (principalMutationError) return principalMutationError;
8427
8515
  const parsed = routeBody(request);
8516
+ if (parsed.fork_pointer && parsed.anchor) return apiError(400, ErrCodeInvalidRequest, `fork_pointer and anchor are mutually exclusive`);
8428
8517
  const { entityUrl, entity } = requireExistingEntityRoute(request);
8429
8518
  await assertDispatchPolicyAllowed(ctx, entity.dispatch_policy);
8519
+ if (parsed.parent !== void 0) {
8520
+ const parent = await ctx.entityManager.registry.getEntity(parsed.parent);
8521
+ if (!parent) return apiError(404, ErrCodeNotFound, `Parent entity not found`);
8522
+ if (!await canAccessEntity(ctx, parent, `spawn`, request)) return apiError(401, ErrCodeUnauthorized, `Principal is not allowed to spawn children from ${parent.url}`);
8523
+ }
8524
+ if (parsed.wake !== void 0) {
8525
+ if (parsed.parent === void 0) return apiError(400, ErrCodeInvalidRequest, `wake requires parent (the fork's wake fires to its parent)`);
8526
+ if (parsed.wake.subscriberUrl !== parsed.parent) return apiError(401, ErrCodeUnauthorized, `wake.subscriberUrl must match parent`);
8527
+ }
8430
8528
  const result = await ctx.entityManager.forkSubtree(entityUrl, {
8431
8529
  rootInstanceId: parsed.instance_id,
8432
8530
  waitTimeoutMs: parsed.waitTimeoutMs,
@@ -8434,9 +8532,17 @@ async function forkEntity(request, ctx) {
8434
8532
  ...parsed.fork_pointer && { forkPointer: {
8435
8533
  offset: parsed.fork_pointer.offset,
8436
8534
  subOffset: parsed.fork_pointer.sub_offset
8437
- } }
8535
+ } },
8536
+ ...parsed.anchor && { anchor: parsed.anchor },
8537
+ ...parsed.parent !== void 0 && { parent: parsed.parent },
8538
+ ...parsed.wake !== void 0 && { wake: parsed.wake },
8539
+ ...parsed.tags !== void 0 && { tags: parsed.tags }
8438
8540
  });
8439
8541
  for (const forkedEntity of result.entities) await linkEntityDispatchSubscription(ctx, forkedEntity);
8542
+ if (parsed.initialMessage !== void 0) await ctx.entityManager.send(result.root.url, {
8543
+ from: parsed.parent ?? ctx.principal.url,
8544
+ payload: parsed.initialMessage
8545
+ });
8440
8546
  return (0, itty_router.json)({
8441
8547
  root: toPublicEntity(result.root),
8442
8548
  entities: result.entities.map((entity$1) => toPublicEntity(entity$1))
@@ -8449,7 +8555,10 @@ async function sendEntity(request, ctx) {
8449
8555
  if (parsed.from_principal !== void 0 && parsed.from_principal !== principal.url) return apiError(400, ErrCodeInvalidRequest, `Request from_principal must match Electric-Principal`);
8450
8556
  if (parsed.from_agent !== void 0) {
8451
8557
  const principalAgentUrl = agentUrlForPrincipal(principal);
8452
- if (agentUrlPath(parsed.from_agent) !== principalAgentUrl) return apiError(400, ErrCodeInvalidRequest, `Request from_agent must match authenticated agent principal`);
8558
+ const fromAgentUrl = agentUrlPath(parsed.from_agent);
8559
+ const matchesPrincipalAgent = fromAgentUrl === principalAgentUrl;
8560
+ const hasAgentWriteToken = await hasValidAgentWriteToken(request, ctx, parsed.from_agent);
8561
+ if (!matchesPrincipalAgent && !hasAgentWriteToken) return apiError(400, ErrCodeInvalidRequest, `Request from_agent must match authenticated agent principal`);
8453
8562
  }
8454
8563
  await ctx.entityManager.ensurePrincipal(principal);
8455
8564
  const { entityUrl, entity } = requireExistingEntityRoute(request);
package/dist/index.d.cts CHANGED
@@ -5245,6 +5245,38 @@ type ForkSubtreeOptions = {
5245
5245
  * `main` stream; shared-state streams clone at HEAD regardless.
5246
5246
  */
5247
5247
  forkPointer?: EventPointer;
5248
+ /**
5249
+ * Named server-resolved anchor. Resolves to a concrete `forkPointer`
5250
+ * by reading the source root's `main` history. Mutually exclusive with
5251
+ * `forkPointer`. Use this when the caller doesn't have access to the
5252
+ * source's per-row pointer side-table (e.g. an agent forking a session
5253
+ * via a tool).
5254
+ *
5255
+ * - `latest_completed_run` — the most recent `runs` row on the source
5256
+ * with `status === 'completed'`. Errors if no such row exists. This
5257
+ * is the eligibility rule the per-row "Fork from here" UI uses.
5258
+ */
5259
+ anchor?: `latest_completed_run`;
5260
+ /**
5261
+ * Optional parent URL for the new root fork entity. When set, the
5262
+ * new fork is a CHILD of this URL — its `parent` field is overridden
5263
+ * (rather than inherited from the source, which is `null` for the
5264
+ * only allowed source: a top-level entity). Pairs with `wake` to give
5265
+ * the parent reply-delivery the same way spawn does.
5266
+ */
5267
+ parent?: string;
5268
+ /**
5269
+ * Optional wake subscription registered on the new root fork at fork
5270
+ * time. Same shape and semantics as `spawn`'s `wake` (see
5271
+ * `TypedSpawnRequest.wake`). Typically set by the agent `fork` tool
5272
+ * to wire reply delivery via the parent's manifest-anchored wake.
5273
+ */
5274
+ wake?: TypedSpawnRequest[`wake`];
5275
+ /**
5276
+ * Optional tags stamped onto the new root fork entity in addition
5277
+ * to those copied from the source. Mirrors `spawn`'s `tags`.
5278
+ */
5279
+ tags?: Record<string, string>;
5248
5280
  };
5249
5281
  type ForkResult = {
5250
5282
  root: ElectricAgentsEntity;
@@ -5333,6 +5365,20 @@ declare class EntityManager {
5333
5365
  * - `pointer.subOffset` is in `[1, total events past the anchor]`.
5334
5366
  */
5335
5367
  private resolveForkPointerTarget;
5368
+ /**
5369
+ * Find an `EventPointer` that addresses the most recent `runs` row on
5370
+ * the source's `main` stream with `status === 'completed'`. Mirrors the
5371
+ * eligibility rule the per-row "Fork from here" UI applies — only
5372
+ * completed runs are valid fork anchors; `started`/`failed` rows are
5373
+ * skipped.
5374
+ *
5375
+ * The pointer is computed in the same coordinate system the runtime
5376
+ * mints pointers in (see entity-stream-db's onBeforeBatch): for a row
5377
+ * R in log entry E, the pointer is `{ offset: P, subOffset: K }` where
5378
+ * `P` is the END offset of the log entry preceding E (or `null` for
5379
+ * stream start) and `K` is R's 1-indexed position within E.
5380
+ */
5381
+ private resolveLatestCompletedRunPointer;
5336
5382
  /**
5337
5383
  * Compute the subset of `sourceTree` that survives the manifest filter
5338
5384
  * applied at the root. After filtering the root's manifest at the fork
package/dist/index.d.ts CHANGED
@@ -5246,6 +5246,38 @@ type ForkSubtreeOptions = {
5246
5246
  * `main` stream; shared-state streams clone at HEAD regardless.
5247
5247
  */
5248
5248
  forkPointer?: EventPointer;
5249
+ /**
5250
+ * Named server-resolved anchor. Resolves to a concrete `forkPointer`
5251
+ * by reading the source root's `main` history. Mutually exclusive with
5252
+ * `forkPointer`. Use this when the caller doesn't have access to the
5253
+ * source's per-row pointer side-table (e.g. an agent forking a session
5254
+ * via a tool).
5255
+ *
5256
+ * - `latest_completed_run` — the most recent `runs` row on the source
5257
+ * with `status === 'completed'`. Errors if no such row exists. This
5258
+ * is the eligibility rule the per-row "Fork from here" UI uses.
5259
+ */
5260
+ anchor?: `latest_completed_run`;
5261
+ /**
5262
+ * Optional parent URL for the new root fork entity. When set, the
5263
+ * new fork is a CHILD of this URL — its `parent` field is overridden
5264
+ * (rather than inherited from the source, which is `null` for the
5265
+ * only allowed source: a top-level entity). Pairs with `wake` to give
5266
+ * the parent reply-delivery the same way spawn does.
5267
+ */
5268
+ parent?: string;
5269
+ /**
5270
+ * Optional wake subscription registered on the new root fork at fork
5271
+ * time. Same shape and semantics as `spawn`'s `wake` (see
5272
+ * `TypedSpawnRequest.wake`). Typically set by the agent `fork` tool
5273
+ * to wire reply delivery via the parent's manifest-anchored wake.
5274
+ */
5275
+ wake?: TypedSpawnRequest[`wake`];
5276
+ /**
5277
+ * Optional tags stamped onto the new root fork entity in addition
5278
+ * to those copied from the source. Mirrors `spawn`'s `tags`.
5279
+ */
5280
+ tags?: Record<string, string>;
5249
5281
  };
5250
5282
  type ForkResult = {
5251
5283
  root: ElectricAgentsEntity;
@@ -5334,6 +5366,20 @@ declare class EntityManager {
5334
5366
  * - `pointer.subOffset` is in `[1, total events past the anchor]`.
5335
5367
  */
5336
5368
  private resolveForkPointerTarget;
5369
+ /**
5370
+ * Find an `EventPointer` that addresses the most recent `runs` row on
5371
+ * the source's `main` stream with `status === 'completed'`. Mirrors the
5372
+ * eligibility rule the per-row "Fork from here" UI applies — only
5373
+ * completed runs are valid fork anchors; `started`/`failed` rows are
5374
+ * skipped.
5375
+ *
5376
+ * The pointer is computed in the same coordinate system the runtime
5377
+ * mints pointers in (see entity-stream-db's onBeforeBatch): for a row
5378
+ * R in log entry E, the pointer is `{ offset: P, subOffset: K }` where
5379
+ * `P` is the END offset of the log entry preceding E (or `null` for
5380
+ * stream start) and `K` is R's 1-indexed position within E.
5381
+ */
5382
+ private resolveLatestCompletedRunPointer;
5337
5383
  /**
5338
5384
  * Compute the subset of `sourceTree` that survives the manifest filter
5339
5385
  * applied at the root. After filtering the root's manifest at the fork
package/dist/index.js CHANGED
@@ -3635,9 +3635,10 @@ var EntityManager = class {
3635
3635
  const workLocks = new Set();
3636
3636
  const writeEntityLocks = new Set();
3637
3637
  const writeStreamLocks = new Set();
3638
+ const usePointerPath = opts.forkPointer !== void 0 || opts.anchor !== void 0;
3638
3639
  try {
3639
3640
  let sourceTree;
3640
- if (opts.forkPointer) {
3641
+ if (usePointerPath) {
3641
3642
  const rootEntity = await this.registry.getEntity(rootUrl);
3642
3643
  if (!rootEntity) throw new ElectricAgentsError(ErrCodeNotFound, `Entity not found`, 404);
3643
3644
  if (isTerminalEntityStatus(rootEntity.status)) throw new ElectricAgentsError(ErrCodeNotRunning, `Cannot fork terminal entity "${rootEntity.url}"`, 409);
@@ -3646,10 +3647,13 @@ var EntityManager = class {
3646
3647
  const sourceRoot = sourceTree[0];
3647
3648
  if (sourceRoot.parent) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Only top-level entities can be forked`, 400);
3648
3649
  let preFilteredRoot;
3649
- if (opts.forkPointer) {
3650
+ let effectiveForkPointer = opts.forkPointer;
3651
+ if (usePointerPath) {
3650
3652
  const sourceEvents = await this.streamClient.readJson(sourceRoot.streams.main);
3651
3653
  const flat = sourceEvents.flatMap((item) => Array.isArray(item) ? item : [item]);
3652
- const target = this.resolveForkPointerTarget(flat, opts.forkPointer, sourceRoot.streams.main);
3654
+ if (!effectiveForkPointer && opts.anchor === `latest_completed_run`) effectiveForkPointer = this.resolveLatestCompletedRunPointer(flat, sourceRoot.streams.main);
3655
+ if (!effectiveForkPointer) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Internal: pointer-path fork with no resolved pointer`, 500);
3656
+ const target = this.resolveForkPointerTarget(flat, effectiveForkPointer, sourceRoot.streams.main);
3653
3657
  const filteredEvents = flat.slice(0, target);
3654
3658
  const rootManifests = this.reduceStateRows(filteredEvents, `manifest`);
3655
3659
  const sharedStateIds = new Set();
@@ -3662,7 +3666,7 @@ var EntityManager = class {
3662
3666
  };
3663
3667
  }
3664
3668
  const effectiveSubtree = preFilteredRoot ? this.computeEffectiveSubtree(sourceTree, sourceRoot.url, preFilteredRoot.manifests) : sourceTree;
3665
- if (opts.forkPointer) {
3669
+ if (usePointerPath) {
3666
3670
  const descendants = effectiveSubtree.filter((entity) => entity.url !== sourceRoot.url);
3667
3671
  if (descendants.length > 0) await this.waitForGivenEntitiesIdle(descendants, opts, workLocks);
3668
3672
  }
@@ -3687,6 +3691,14 @@ var EntityManager = class {
3687
3691
  const sharedStateIdMap = await this.buildForkSharedStateIdMap(snapshot.sharedStateIds, suffix);
3688
3692
  const stringMap = this.buildForkStringMap(entityUrlMap, sharedStateIdMap);
3689
3693
  const entityPlans = this.buildForkEntityPlans(effectiveSubtree, entityUrlMap, stringMap, opts.createdBy);
3694
+ const rootPlanForOverrides = entityPlans.find((plan) => plan.source.url === rootUrl);
3695
+ if (rootPlanForOverrides) {
3696
+ if (opts.parent !== void 0) rootPlanForOverrides.fork.parent = opts.parent;
3697
+ if (opts.tags !== void 0) rootPlanForOverrides.fork.tags = {
3698
+ ...rootPlanForOverrides.fork.tags ?? {},
3699
+ ...opts.tags
3700
+ };
3701
+ }
3690
3702
  this.addForkLocks(this.forkWriteLockedEntities, effectiveSubtree.map((entity) => entity.url), writeEntityLocks);
3691
3703
  this.addForkLocks(this.forkWriteLockedStreams, [...snapshot.sharedStateIds].map((id) => getSharedStateStreamPath(id)), writeStreamLocks);
3692
3704
  const createdStreams = [];
@@ -3695,7 +3707,7 @@ var EntityManager = class {
3695
3707
  try {
3696
3708
  for (const plan of entityPlans) {
3697
3709
  const isRoot = plan.source.url === rootUrl;
3698
- await this.streamClient.fork(plan.fork.streams.main, plan.source.streams.main, isRoot && opts.forkPointer ? { forkPointer: opts.forkPointer } : void 0);
3710
+ await this.streamClient.fork(plan.fork.streams.main, plan.source.streams.main, isRoot && effectiveForkPointer ? { forkPointer: effectiveForkPointer } : void 0);
3699
3711
  createdStreams.push(plan.fork.streams.main);
3700
3712
  }
3701
3713
  for (const [sourceId, forkId] of sharedStateIdMap) {
@@ -3722,6 +3734,20 @@ var EntityManager = class {
3722
3734
  await this.registry.createEntity(plan.fork);
3723
3735
  createdEntities.push(plan.fork.url);
3724
3736
  }
3737
+ if (opts.wake !== void 0) {
3738
+ const rootPlan = entityPlans.find((plan) => plan.source.url === rootUrl);
3739
+ if (rootPlan) await this.wakeRegistry.register({
3740
+ tenantId: this.tenantId,
3741
+ subscriberUrl: opts.wake.subscriberUrl,
3742
+ sourceUrl: rootPlan.fork.url,
3743
+ condition: opts.wake.condition,
3744
+ debounceMs: opts.wake.debounceMs,
3745
+ timeoutMs: opts.wake.timeoutMs,
3746
+ oneShot: false,
3747
+ includeResponse: opts.wake.includeResponse,
3748
+ manifestKey: opts.wake.manifestKey
3749
+ });
3750
+ }
3725
3751
  for (const plan of entityPlans) {
3726
3752
  const manifests = activeManifestsByEntity.get(plan.fork.url) ?? new Map();
3727
3753
  await this.materializeForkManifestSideEffects(plan.fork.url, manifests);
@@ -3882,6 +3908,45 @@ var EntityManager = class {
3882
3908
  return positionAtAnchor + pointer.subOffset;
3883
3909
  }
3884
3910
  /**
3911
+ * Find an `EventPointer` that addresses the most recent `runs` row on
3912
+ * the source's `main` stream with `status === 'completed'`. Mirrors the
3913
+ * eligibility rule the per-row "Fork from here" UI applies — only
3914
+ * completed runs are valid fork anchors; `started`/`failed` rows are
3915
+ * skipped.
3916
+ *
3917
+ * The pointer is computed in the same coordinate system the runtime
3918
+ * mints pointers in (see entity-stream-db's onBeforeBatch): for a row
3919
+ * R in log entry E, the pointer is `{ offset: P, subOffset: K }` where
3920
+ * `P` is the END offset of the log entry preceding E (or `null` for
3921
+ * stream start) and `K` is R's 1-indexed position within E.
3922
+ */
3923
+ resolveLatestCompletedRunPointer(events, streamPath) {
3924
+ let priorEntryOffset = null;
3925
+ let currentEntryOffset = null;
3926
+ let positionInEntry = 0;
3927
+ let latest = null;
3928
+ for (const event of events) {
3929
+ const headers = isRecord(event.headers) ? event.headers : void 0;
3930
+ const eventOffset = typeof headers?.offset === `string` ? headers.offset : null;
3931
+ if (eventOffset !== currentEntryOffset) {
3932
+ priorEntryOffset = currentEntryOffset;
3933
+ currentEntryOffset = eventOffset;
3934
+ positionInEntry = 0;
3935
+ }
3936
+ positionInEntry++;
3937
+ if (event.type !== `run`) continue;
3938
+ if (headers?.operation === `delete`) continue;
3939
+ if (!isRecord(event.value)) continue;
3940
+ if (event.value.status !== `completed`) continue;
3941
+ latest = {
3942
+ offset: priorEntryOffset,
3943
+ subOffset: positionInEntry
3944
+ };
3945
+ }
3946
+ if (!latest) throw new ElectricAgentsError(ErrCodeInvalidRequest, `Source ${streamPath} has no completed run to fork from`, 400);
3947
+ return latest;
3948
+ }
3949
+ /**
3885
3950
  * Compute the subset of `sourceTree` that survives the manifest filter
3886
3951
  * applied at the root. After filtering the root's manifest at the fork
3887
3952
  * pointer, only children whose manifest entries landed at or before the
@@ -7828,7 +7893,10 @@ async function authorizeDurableStreamAccess(request, ctx) {
7828
7893
  }
7829
7894
  if (method === `PUT` || method === `POST`) {
7830
7895
  const ownerEntityUrl = request.headers.get(SHARED_STATE_OWNER_ENTITY_HEADER)?.trim() || void 0;
7831
- if (await canAccessSharedState(ctx, sharedStateId, `write`, request, ownerEntityUrl)) return void 0;
7896
+ if (await canAccessSharedState(ctx, sharedStateId, `write`, request, ownerEntityUrl)) {
7897
+ if (ownerEntityUrl) await ctx.entityManager.registry.replaceSharedStateLink(ownerEntityUrl, `shared-state:${sharedStateId}`, sharedStateId);
7898
+ return void 0;
7899
+ }
7832
7900
  return apiError(401, ErrCodeUnauthorized, `Principal is not allowed to write shared state`);
7833
7901
  }
7834
7902
  return apiError(401, ErrCodeUnauthorized, `Principal is not allowed to access shared state`);
@@ -7988,6 +8056,14 @@ function agentUrlPath(value) {
7988
8056
  return value;
7989
8057
  }
7990
8058
  }
8059
+ async function hasValidAgentWriteToken(request, ctx, fromAgent) {
8060
+ const agentUrl = agentUrlPath(fromAgent);
8061
+ const token = writeTokenFromRequest(request);
8062
+ if (!token) return false;
8063
+ const agentEntity = await ctx.entityManager.registry.getEntity(agentUrl);
8064
+ if (!agentEntity) return false;
8065
+ return ctx.entityManager.isValidWriteToken(agentEntity, token);
8066
+ }
7991
8067
  const inboxMessageBodySchema = Type.Object({
7992
8068
  payload: Type.Optional(Type.Unknown()),
7993
8069
  position: Type.Optional(Type.String()),
@@ -8009,7 +8085,19 @@ const forkBodySchema = Type.Object({
8009
8085
  fork_pointer: Type.Optional(Type.Object({
8010
8086
  offset: Type.Union([Type.String(), Type.Null()]),
8011
8087
  sub_offset: Type.Number()
8012
- }))
8088
+ })),
8089
+ anchor: Type.Optional(Type.Literal(`latest_completed_run`)),
8090
+ parent: Type.Optional(Type.String()),
8091
+ wake: Type.Optional(Type.Object({
8092
+ subscriberUrl: Type.String(),
8093
+ condition: wakeConditionSchema,
8094
+ debounceMs: Type.Optional(Type.Number()),
8095
+ timeoutMs: Type.Optional(Type.Number()),
8096
+ includeResponse: Type.Optional(Type.Boolean()),
8097
+ manifestKey: Type.Optional(Type.String())
8098
+ })),
8099
+ initialMessage: Type.Optional(Type.Unknown()),
8100
+ tags: Type.Optional(stringRecordSchema$1)
8013
8101
  });
8014
8102
  const setTagBodySchema = Type.Object({ value: Type.String() });
8015
8103
  const entitySignalSchema = Type.Union([
@@ -8396,8 +8484,18 @@ async function forkEntity(request, ctx) {
8396
8484
  const principalMutationError = rejectPrincipalEntityMutation(request, `forked`);
8397
8485
  if (principalMutationError) return principalMutationError;
8398
8486
  const parsed = routeBody(request);
8487
+ if (parsed.fork_pointer && parsed.anchor) return apiError(400, ErrCodeInvalidRequest, `fork_pointer and anchor are mutually exclusive`);
8399
8488
  const { entityUrl, entity } = requireExistingEntityRoute(request);
8400
8489
  await assertDispatchPolicyAllowed(ctx, entity.dispatch_policy);
8490
+ if (parsed.parent !== void 0) {
8491
+ const parent = await ctx.entityManager.registry.getEntity(parsed.parent);
8492
+ if (!parent) return apiError(404, ErrCodeNotFound, `Parent entity not found`);
8493
+ if (!await canAccessEntity(ctx, parent, `spawn`, request)) return apiError(401, ErrCodeUnauthorized, `Principal is not allowed to spawn children from ${parent.url}`);
8494
+ }
8495
+ if (parsed.wake !== void 0) {
8496
+ if (parsed.parent === void 0) return apiError(400, ErrCodeInvalidRequest, `wake requires parent (the fork's wake fires to its parent)`);
8497
+ if (parsed.wake.subscriberUrl !== parsed.parent) return apiError(401, ErrCodeUnauthorized, `wake.subscriberUrl must match parent`);
8498
+ }
8401
8499
  const result = await ctx.entityManager.forkSubtree(entityUrl, {
8402
8500
  rootInstanceId: parsed.instance_id,
8403
8501
  waitTimeoutMs: parsed.waitTimeoutMs,
@@ -8405,9 +8503,17 @@ async function forkEntity(request, ctx) {
8405
8503
  ...parsed.fork_pointer && { forkPointer: {
8406
8504
  offset: parsed.fork_pointer.offset,
8407
8505
  subOffset: parsed.fork_pointer.sub_offset
8408
- } }
8506
+ } },
8507
+ ...parsed.anchor && { anchor: parsed.anchor },
8508
+ ...parsed.parent !== void 0 && { parent: parsed.parent },
8509
+ ...parsed.wake !== void 0 && { wake: parsed.wake },
8510
+ ...parsed.tags !== void 0 && { tags: parsed.tags }
8409
8511
  });
8410
8512
  for (const forkedEntity of result.entities) await linkEntityDispatchSubscription(ctx, forkedEntity);
8513
+ if (parsed.initialMessage !== void 0) await ctx.entityManager.send(result.root.url, {
8514
+ from: parsed.parent ?? ctx.principal.url,
8515
+ payload: parsed.initialMessage
8516
+ });
8411
8517
  return json({
8412
8518
  root: toPublicEntity(result.root),
8413
8519
  entities: result.entities.map((entity$1) => toPublicEntity(entity$1))
@@ -8420,7 +8526,10 @@ async function sendEntity(request, ctx) {
8420
8526
  if (parsed.from_principal !== void 0 && parsed.from_principal !== principal.url) return apiError(400, ErrCodeInvalidRequest, `Request from_principal must match Electric-Principal`);
8421
8527
  if (parsed.from_agent !== void 0) {
8422
8528
  const principalAgentUrl = agentUrlForPrincipal(principal);
8423
- if (agentUrlPath(parsed.from_agent) !== principalAgentUrl) return apiError(400, ErrCodeInvalidRequest, `Request from_agent must match authenticated agent principal`);
8529
+ const fromAgentUrl = agentUrlPath(parsed.from_agent);
8530
+ const matchesPrincipalAgent = fromAgentUrl === principalAgentUrl;
8531
+ const hasAgentWriteToken = await hasValidAgentWriteToken(request, ctx, parsed.from_agent);
8532
+ if (!matchesPrincipalAgent && !hasAgentWriteToken) return apiError(400, ErrCodeInvalidRequest, `Request from_agent must match authenticated agent principal`);
8424
8533
  }
8425
8534
  await ctx.entityManager.ensurePrincipal(principal);
8426
8535
  const { entityUrl, entity } = requireExistingEntityRoute(request);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@electric-ax/agents-server",
3
- "version": "0.4.17",
3
+ "version": "0.4.19",
4
4
  "description": "Electric Agents entity runtime server",
5
5
  "author": "Durable Stream contributors",
6
6
  "bin": {
@@ -54,7 +54,7 @@
54
54
  "pino-pretty": "^13.0.0",
55
55
  "postgres": "^3.4.0",
56
56
  "undici": "^7.24.7",
57
- "@electric-ax/agents-runtime": "0.3.10"
57
+ "@electric-ax/agents-runtime": "0.3.12"
58
58
  },
59
59
  "devDependencies": {
60
60
  "@types/node": "^22.19.15",
@@ -65,9 +65,9 @@
65
65
  "tsx": "^4.19.0",
66
66
  "typescript": "^5.0.0",
67
67
  "vitest": "^4.1.0",
68
- "@electric-ax/agents": "0.4.14",
69
- "@electric-ax/agents-server-ui": "0.4.17",
70
- "@electric-ax/agents-server-conformance-tests": "0.1.11"
68
+ "@electric-ax/agents": "0.4.16",
69
+ "@electric-ax/agents-server-conformance-tests": "0.1.11",
70
+ "@electric-ax/agents-server-ui": "0.4.19"
71
71
  },
72
72
  "files": [
73
73
  "dist",
@@ -175,6 +175,38 @@ type ForkSubtreeOptions = {
175
175
  * `main` stream; shared-state streams clone at HEAD regardless.
176
176
  */
177
177
  forkPointer?: EventPointer
178
+ /**
179
+ * Named server-resolved anchor. Resolves to a concrete `forkPointer`
180
+ * by reading the source root's `main` history. Mutually exclusive with
181
+ * `forkPointer`. Use this when the caller doesn't have access to the
182
+ * source's per-row pointer side-table (e.g. an agent forking a session
183
+ * via a tool).
184
+ *
185
+ * - `latest_completed_run` — the most recent `runs` row on the source
186
+ * with `status === 'completed'`. Errors if no such row exists. This
187
+ * is the eligibility rule the per-row "Fork from here" UI uses.
188
+ */
189
+ anchor?: `latest_completed_run`
190
+ /**
191
+ * Optional parent URL for the new root fork entity. When set, the
192
+ * new fork is a CHILD of this URL — its `parent` field is overridden
193
+ * (rather than inherited from the source, which is `null` for the
194
+ * only allowed source: a top-level entity). Pairs with `wake` to give
195
+ * the parent reply-delivery the same way spawn does.
196
+ */
197
+ parent?: string
198
+ /**
199
+ * Optional wake subscription registered on the new root fork at fork
200
+ * time. Same shape and semantics as `spawn`'s `wake` (see
201
+ * `TypedSpawnRequest.wake`). Typically set by the agent `fork` tool
202
+ * to wire reply delivery via the parent's manifest-anchored wake.
203
+ */
204
+ wake?: TypedSpawnRequest[`wake`]
205
+ /**
206
+ * Optional tags stamped onto the new root fork entity in addition
207
+ * to those copied from the source. Mirrors `spawn`'s `tags`.
208
+ */
209
+ tags?: Record<string, string>
178
210
  }
179
211
 
180
212
  type ForkEntityPlan = {
@@ -909,6 +941,13 @@ export class EntityManager {
909
941
  const writeEntityLocks = new Set<string>()
910
942
  const writeStreamLocks = new Set<string>()
911
943
 
944
+ // Anchor-forks resolve to a concrete pointer server-side by reading the
945
+ // source's `main` history. Below this point the resolved pointer (or
946
+ // an explicit one from `opts.forkPointer`) drives the same code path —
947
+ // anchor handling collapses entirely into the pointer-fork flow.
948
+ const usePointerPath =
949
+ opts.forkPointer !== undefined || opts.anchor !== undefined
950
+
912
951
  try {
913
952
  // For pointer-forks we read the source root HISTORICALLY at a
914
953
  // frozen offset, so concurrent activity on the root past the
@@ -920,7 +959,7 @@ export class EntityManager {
920
959
  // those are HEAD-cloned and need a stable snapshot. For HEAD-forks
921
960
  // the old all-idle requirement still applies.
922
961
  let sourceTree: Array<ElectricAgentsEntity>
923
- if (opts.forkPointer) {
962
+ if (usePointerPath) {
924
963
  const rootEntity = await this.registry.getEntity(rootUrl)
925
964
  if (!rootEntity) {
926
965
  throw new ElectricAgentsError(
@@ -949,11 +988,11 @@ export class EntityManager {
949
988
  )
950
989
  }
951
990
 
952
- // When forking at a pointer, pre-read the root's main, validate the
953
- // pointer against the source's true history, and materialise the
954
- // root-at-pointer snapshot fragments. The pointer only applies to
955
- // the root's `main` stream. Descendants kept by the manifest filter
956
- // are forked at HEAD.
991
+ // When forking at a pointer (or anchor), pre-read the root's main,
992
+ // validate the pointer against the source's true history, and
993
+ // materialise the root-at-pointer snapshot fragments. The pointer
994
+ // only applies to the root's `main` stream. Descendants kept by
995
+ // the manifest filter are forked at HEAD.
957
996
  //
958
997
  // Pointer→position translation: the runtime mints pointers as
959
998
  // `{ offset: previousBatchOffset, subOffset: itemIndex+1 }`, where
@@ -972,16 +1011,32 @@ export class EntityManager {
972
1011
  sharedStateIds: Set<string>
973
1012
  }
974
1013
  | undefined
975
- if (opts.forkPointer) {
1014
+ let effectiveForkPointer: EventPointer | undefined = opts.forkPointer
1015
+ if (usePointerPath) {
976
1016
  const sourceEvents = await this.streamClient.readJson<
977
1017
  Record<string, unknown>
978
1018
  >(sourceRoot.streams.main)
979
1019
  const flat = sourceEvents.flatMap((item) =>
980
1020
  Array.isArray(item) ? item : [item]
981
1021
  ) as Array<Record<string, unknown>>
1022
+ if (!effectiveForkPointer && opts.anchor === `latest_completed_run`) {
1023
+ effectiveForkPointer = this.resolveLatestCompletedRunPointer(
1024
+ flat,
1025
+ sourceRoot.streams.main
1026
+ )
1027
+ }
1028
+ if (!effectiveForkPointer) {
1029
+ // Defensive — would only fire on a future anchor variant we
1030
+ // forget to handle above.
1031
+ throw new ElectricAgentsError(
1032
+ ErrCodeInvalidRequest,
1033
+ `Internal: pointer-path fork with no resolved pointer`,
1034
+ 500
1035
+ )
1036
+ }
982
1037
  const target = this.resolveForkPointerTarget(
983
1038
  flat,
984
- opts.forkPointer,
1039
+ effectiveForkPointer,
985
1040
  sourceRoot.streams.main
986
1041
  )
987
1042
  const filteredEvents = flat.slice(0, target)
@@ -1013,7 +1068,7 @@ export class EntityManager {
1013
1068
  // subtree except the root) are HEAD-cloned, so they must be idle
1014
1069
  // before we read their snapshots. Wait+lock only those — the root
1015
1070
  // was skipped above.
1016
- if (opts.forkPointer) {
1071
+ if (usePointerPath) {
1017
1072
  const descendants = effectiveSubtree.filter(
1018
1073
  (entity) => entity.url !== sourceRoot.url
1019
1074
  )
@@ -1066,6 +1121,28 @@ export class EntityManager {
1066
1121
  opts.createdBy
1067
1122
  )
1068
1123
 
1124
+ // Override fields on the new root fork's plan from caller opts.
1125
+ // Plumbed through from `forkBodySchema` — descendants in
1126
+ // `effectiveSubtree` keep what `buildForkEntityPlans` copied.
1127
+ //
1128
+ // - `parent`: child-fork relationship (vs the default inheritance
1129
+ // from the source, which is `null` for a top-level source).
1130
+ // - `tags`: merged on top of source tags (caller-supplied wins).
1131
+ const rootPlanForOverrides = entityPlans.find(
1132
+ (plan) => plan.source.url === rootUrl
1133
+ )
1134
+ if (rootPlanForOverrides) {
1135
+ if (opts.parent !== undefined) {
1136
+ rootPlanForOverrides.fork.parent = opts.parent
1137
+ }
1138
+ if (opts.tags !== undefined) {
1139
+ rootPlanForOverrides.fork.tags = {
1140
+ ...(rootPlanForOverrides.fork.tags ?? {}),
1141
+ ...opts.tags,
1142
+ }
1143
+ }
1144
+ }
1145
+
1069
1146
  this.addForkLocks(
1070
1147
  this.forkWriteLockedEntities,
1071
1148
  effectiveSubtree.map((entity) => entity.url),
@@ -1090,8 +1167,8 @@ export class EntityManager {
1090
1167
  await this.streamClient.fork(
1091
1168
  plan.fork.streams.main,
1092
1169
  plan.source.streams.main,
1093
- isRoot && opts.forkPointer
1094
- ? { forkPointer: opts.forkPointer }
1170
+ isRoot && effectiveForkPointer
1171
+ ? { forkPointer: effectiveForkPointer }
1095
1172
  : undefined
1096
1173
  )
1097
1174
  createdStreams.push(plan.fork.streams.main)
@@ -1146,6 +1223,30 @@ export class EntityManager {
1146
1223
  createdEntities.push(plan.fork.url)
1147
1224
  }
1148
1225
 
1226
+ // Register a wake subscription on the new root fork when the
1227
+ // caller asked for one. Mirrors the spawn flow's wake handling
1228
+ // (entity-manager.spawnInner). Rollback below already calls
1229
+ // `wakeRegistry.unregisterBySource(forkUrl)` for every created
1230
+ // entity, so this cleans up automatically on failure.
1231
+ if (opts.wake !== undefined) {
1232
+ const rootPlan = entityPlans.find(
1233
+ (plan) => plan.source.url === rootUrl
1234
+ )
1235
+ if (rootPlan) {
1236
+ await this.wakeRegistry.register({
1237
+ tenantId: this.tenantId,
1238
+ subscriberUrl: opts.wake.subscriberUrl,
1239
+ sourceUrl: rootPlan.fork.url,
1240
+ condition: opts.wake.condition,
1241
+ debounceMs: opts.wake.debounceMs,
1242
+ timeoutMs: opts.wake.timeoutMs,
1243
+ oneShot: false,
1244
+ includeResponse: opts.wake.includeResponse,
1245
+ manifestKey: opts.wake.manifestKey,
1246
+ })
1247
+ }
1248
+ }
1249
+
1149
1250
  for (const plan of entityPlans) {
1150
1251
  const manifests =
1151
1252
  activeManifestsByEntity.get(plan.fork.url) ?? new Map()
@@ -1461,6 +1562,58 @@ export class EntityManager {
1461
1562
  return positionAtAnchor + pointer.subOffset
1462
1563
  }
1463
1564
 
1565
+ /**
1566
+ * Find an `EventPointer` that addresses the most recent `runs` row on
1567
+ * the source's `main` stream with `status === 'completed'`. Mirrors the
1568
+ * eligibility rule the per-row "Fork from here" UI applies — only
1569
+ * completed runs are valid fork anchors; `started`/`failed` rows are
1570
+ * skipped.
1571
+ *
1572
+ * The pointer is computed in the same coordinate system the runtime
1573
+ * mints pointers in (see entity-stream-db's onBeforeBatch): for a row
1574
+ * R in log entry E, the pointer is `{ offset: P, subOffset: K }` where
1575
+ * `P` is the END offset of the log entry preceding E (or `null` for
1576
+ * stream start) and `K` is R's 1-indexed position within E.
1577
+ */
1578
+ private resolveLatestCompletedRunPointer(
1579
+ events: ReadonlyArray<Record<string, unknown>>,
1580
+ streamPath: string
1581
+ ): EventPointer {
1582
+ let priorEntryOffset: string | null = null
1583
+ let currentEntryOffset: string | null = null
1584
+ let positionInEntry = 0
1585
+ let latest: EventPointer | null = null
1586
+
1587
+ for (const event of events) {
1588
+ const headers = isRecord(event.headers) ? event.headers : undefined
1589
+ const eventOffset =
1590
+ typeof headers?.offset === `string` ? headers.offset : null
1591
+
1592
+ if (eventOffset !== currentEntryOffset) {
1593
+ priorEntryOffset = currentEntryOffset
1594
+ currentEntryOffset = eventOffset
1595
+ positionInEntry = 0
1596
+ }
1597
+ positionInEntry++
1598
+
1599
+ if (event.type !== `run`) continue
1600
+ if (headers?.operation === `delete`) continue
1601
+ if (!isRecord(event.value)) continue
1602
+ if (event.value.status !== `completed`) continue
1603
+
1604
+ latest = { offset: priorEntryOffset, subOffset: positionInEntry }
1605
+ }
1606
+
1607
+ if (!latest) {
1608
+ throw new ElectricAgentsError(
1609
+ ErrCodeInvalidRequest,
1610
+ `Source ${streamPath} has no completed run to fork from`,
1611
+ 400
1612
+ )
1613
+ }
1614
+ return latest
1615
+ }
1616
+
1464
1617
  /**
1465
1618
  * Compute the subset of `sourceTree` that survives the manifest filter
1466
1619
  * applied at the root. After filtering the root's manifest at the fork
@@ -717,6 +717,19 @@ async function authorizeDurableStreamAccess(
717
717
  ownerEntityUrl
718
718
  )
719
719
  ) {
720
+ // Bootstrap the link synchronously so subsequent reads on this stream
721
+ // (e.g. the runtime's preload right after `mkdb`) can resolve the owner
722
+ // without waiting for the entity's manifest stream event to be
723
+ // processed eventually-consistently. Without this, a brand-new
724
+ // shared-state always 401s on the first preload because the link row
725
+ // is only created later via `applyManifestEntitySource`.
726
+ if (ownerEntityUrl) {
727
+ await ctx.entityManager.registry.replaceSharedStateLink(
728
+ ownerEntityUrl,
729
+ `shared-state:${sharedStateId}`,
730
+ sharedStateId
731
+ )
732
+ }
720
733
  return undefined
721
734
  }
722
735
  return apiError(
@@ -187,6 +187,19 @@ function agentUrlPath(value: string): string {
187
187
  }
188
188
  }
189
189
 
190
+ async function hasValidAgentWriteToken(
191
+ request: AgentsRouteRequest,
192
+ ctx: TenantContext,
193
+ fromAgent: string
194
+ ): Promise<boolean> {
195
+ const agentUrl = agentUrlPath(fromAgent)
196
+ const token = writeTokenFromRequest(request)
197
+ if (!token) return false
198
+ const agentEntity = await ctx.entityManager.registry.getEntity(agentUrl)
199
+ if (!agentEntity) return false
200
+ return ctx.entityManager.isValidWriteToken(agentEntity, token)
201
+ }
202
+
190
203
  const inboxMessageBodySchema = Type.Object({
191
204
  payload: Type.Optional(Type.Unknown()),
192
205
  position: Type.Optional(Type.String()),
@@ -219,6 +232,41 @@ const forkBodySchema = Type.Object({
219
232
  sub_offset: Type.Number(),
220
233
  })
221
234
  ),
235
+ // Named server-resolved anchor. Resolves to a concrete fork pointer on
236
+ // the source root's `main` server-side, so callers don't need access
237
+ // to the source's per-row pointer side-table. Mutually exclusive with
238
+ // `fork_pointer`.
239
+ anchor: Type.Optional(Type.Literal(`latest_completed_run`)),
240
+ // Optional parent URL. When set, the new root fork is a CHILD of this
241
+ // URL (rather than inheriting the source's parent, which is `null`
242
+ // for the only allowed source — a top-level entity). Used by the
243
+ // agent `fork` tool to make a forking entity own its forks the same
244
+ // way a spawning entity owns its workers.
245
+ parent: Type.Optional(Type.String()),
246
+ // Optional wake subscription registered on the new root fork at fork
247
+ // time. Mirrors the `wake` field on spawn — the subscriber URL gets
248
+ // woken when the fork meets the named condition, with the response
249
+ // optionally inlined. Used to wire fork-as-child reply delivery
250
+ // through the same manifest-anchored mechanism spawn uses.
251
+ wake: Type.Optional(
252
+ Type.Object({
253
+ subscriberUrl: Type.String(),
254
+ condition: wakeConditionSchema,
255
+ debounceMs: Type.Optional(Type.Number()),
256
+ timeoutMs: Type.Optional(Type.Number()),
257
+ includeResponse: Type.Optional(Type.Boolean()),
258
+ manifestKey: Type.Optional(Type.String()),
259
+ })
260
+ ),
261
+ // Optional initial inbox message for the new root fork. Folds
262
+ // fork+send into one round-trip for the caller. Not atomic with
263
+ // fork creation: the server sends it after the fork is created
264
+ // and dispatch-linked (see the `entityManager.send` call below),
265
+ // so a failed send can leave an idle dispatched fork.
266
+ initialMessage: Type.Optional(Type.Unknown()),
267
+ // Optional tags stamped on the new root fork entity in addition to
268
+ // those copied from the source. Mirrors spawn's `tags`.
269
+ tags: Type.Optional(stringRecordSchema),
222
270
  })
223
271
 
224
272
  const setTagBodySchema = Type.Object({
@@ -1088,8 +1136,57 @@ async function forkEntity(
1088
1136
  if (principalMutationError) return principalMutationError
1089
1137
 
1090
1138
  const parsed = routeBody<ForkBody>(request)
1139
+ if (parsed.fork_pointer && parsed.anchor) {
1140
+ return apiError(
1141
+ 400,
1142
+ ErrCodeInvalidRequest,
1143
+ `fork_pointer and anchor are mutually exclusive`
1144
+ )
1145
+ }
1091
1146
  const { entityUrl, entity } = requireExistingEntityRoute(request)
1092
1147
  await assertDispatchPolicyAllowed(ctx, entity.dispatch_policy)
1148
+
1149
+ // Validate `parent` and `wake.subscriberUrl` before forking — mirrors
1150
+ // the spawn route's parent-validation flow. Without these checks, a
1151
+ // direct HTTP caller could attach a fork under an arbitrary parent or
1152
+ // register a wake firing to an arbitrary subscriber.
1153
+ if (parsed.parent !== undefined) {
1154
+ const parent = await ctx.entityManager.registry.getEntity(parsed.parent)
1155
+ if (!parent) {
1156
+ return apiError(404, ErrCodeNotFound, `Parent entity not found`)
1157
+ }
1158
+ if (!(await canAccessEntity(ctx, parent, `spawn`, request as Request))) {
1159
+ return apiError(
1160
+ 401,
1161
+ ErrCodeUnauthorized,
1162
+ `Principal is not allowed to spawn children from ${parent.url}`
1163
+ )
1164
+ }
1165
+ }
1166
+ if (parsed.wake !== undefined) {
1167
+ // The only sensible target for a fork's wake is the new fork's
1168
+ // parent (so the parent gets woken when the fork's run finishes
1169
+ // — same model as spawn). Require parent + matching subscriber to
1170
+ // prevent a caller from registering a wake firing to an entity
1171
+ // they don't own. If subscriber-flexibility ever becomes a real
1172
+ // use case, replace this with a proper canAccessEntity check on
1173
+ // the subscriber.
1174
+ if (parsed.parent === undefined) {
1175
+ return apiError(
1176
+ 400,
1177
+ ErrCodeInvalidRequest,
1178
+ `wake requires parent (the fork's wake fires to its parent)`
1179
+ )
1180
+ }
1181
+ if (parsed.wake.subscriberUrl !== parsed.parent) {
1182
+ return apiError(
1183
+ 401,
1184
+ ErrCodeUnauthorized,
1185
+ `wake.subscriberUrl must match parent`
1186
+ )
1187
+ }
1188
+ }
1189
+
1093
1190
  const result = await ctx.entityManager.forkSubtree(entityUrl, {
1094
1191
  rootInstanceId: parsed.instance_id,
1095
1192
  waitTimeoutMs: parsed.waitTimeoutMs,
@@ -1100,10 +1197,24 @@ async function forkEntity(
1100
1197
  subOffset: parsed.fork_pointer.sub_offset,
1101
1198
  },
1102
1199
  }),
1200
+ ...(parsed.anchor && { anchor: parsed.anchor }),
1201
+ ...(parsed.parent !== undefined && { parent: parsed.parent }),
1202
+ ...(parsed.wake !== undefined && { wake: parsed.wake }),
1203
+ ...(parsed.tags !== undefined && { tags: parsed.tags }),
1103
1204
  })
1104
1205
  for (const forkedEntity of result.entities) {
1105
1206
  await linkEntityDispatchSubscription(ctx, forkedEntity)
1106
1207
  }
1208
+ // Deliver the initial message via entityManager.send AFTER the
1209
+ // dispatch subscription is linked — same ordering spawn uses. Sending
1210
+ // before linking would land the inbox row on the stream before the
1211
+ // dispatcher is subscribed, and the dispatcher would never pick it up.
1212
+ if (parsed.initialMessage !== undefined) {
1213
+ await ctx.entityManager.send(result.root.url, {
1214
+ from: parsed.parent ?? ctx.principal.url,
1215
+ payload: parsed.initialMessage,
1216
+ })
1217
+ }
1107
1218
  return json(
1108
1219
  {
1109
1220
  root: toPublicEntity(result.root),
@@ -1138,7 +1249,14 @@ async function sendEntity(
1138
1249
  }
1139
1250
  if (parsed.from_agent !== undefined) {
1140
1251
  const principalAgentUrl = agentUrlForPrincipal(principal)
1141
- if (agentUrlPath(parsed.from_agent) !== principalAgentUrl) {
1252
+ const fromAgentUrl = agentUrlPath(parsed.from_agent)
1253
+ const matchesPrincipalAgent = fromAgentUrl === principalAgentUrl
1254
+ const hasAgentWriteToken = await hasValidAgentWriteToken(
1255
+ request,
1256
+ ctx,
1257
+ parsed.from_agent
1258
+ )
1259
+ if (!matchesPrincipalAgent && !hasAgentWriteToken) {
1142
1260
  return apiError(
1143
1261
  400,
1144
1262
  ErrCodeInvalidRequest,