Kaynağa Gözat

feat(agent): await initialization through agent/created

_Kerman 1 hafta önce
ebeveyn
işleme
9b7a8ccc9f
62 değiştirilmiş dosya ile 690 ekleme ve 315 silme
  1. 3 3
      packages/api/session-controller/tests/agent.host.spec.ts
  2. 1 1
      packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts
  3. 1 1
      packages/api/session-controller/tests/control-jobs.host.spec.ts
  4. 1 1
      packages/api/session-controller/tests/controller.host.spec.ts
  5. 9 9
      packages/api/session-controller/tests/session-cold.host.spec.ts
  6. 12 12
      packages/api/session-controller/tests/session-fork.host.spec.ts
  7. 2 2
      packages/api/session-controller/tests/session-list-blank.host.spec.ts
  8. 1 1
      packages/api/session-controller/tests/session-models.host.spec.ts
  9. 1 1
      packages/api/session-controller/tests/session-presets.host.spec.ts
  10. 9 9
      packages/api/session-controller/tests/session-rename.host.spec.ts
  11. 1 1
      packages/bundle/headless/tests/headless.spec.ts
  12. 5 3
      packages/context/file-reference-local/src/index.ts
  13. 20 1
      packages/context/file-reference-local/tests/service.spec.ts
  14. 39 27
      packages/core/agent-loop/src/index.ts
  15. 24 0
      packages/core/agent-loop/tests/fixtures/serial-created.mjs
  16. 91 0
      packages/core/agent-loop/tests/scope-lifecycle.spec.ts
  17. 119 0
      packages/core/agent-loop/tests/serial-listener-review.spec.ts
  18. 18 24
      packages/core/agent/src/index.ts
  19. 6 19
      packages/core/agent/src/runtime-types.ts
  20. 45 16
      packages/core/agent/tests/agent.spec.ts
  21. 0 1
      packages/core/scope/src/scoped-events.generated.ts
  22. 1 1
      packages/e2b/e2b/tests/fixtures/composition/bin.ts
  23. 1 1
      packages/experimental/agent-team/src/index.ts
  24. 1 1
      packages/feedback/command-feedback/tests/command-feedback.spec.ts
  25. 3 3
      packages/feedback/command-feedback/tests/loader-composition.spec.ts
  26. 3 3
      packages/fs/tool-str-replace-editor/tests/tools.spec.ts
  27. 1 1
      packages/goal/command-goal/tests/command-goal.spec.ts
  28. 1 2
      packages/goal/goal-round-driver/src/index.ts
  29. 1 1
      packages/goal/goal/src/index.ts
  30. 8 8
      packages/goal/goal/tests/goal.spec.ts
  31. 3 3
      packages/goal/goal/tests/projection.spec.ts
  32. 4 4
      packages/goal/tool-goal/tests/tool-goal.spec.ts
  33. 6 6
      packages/hooks/hooks-claude-code/src/index.ts
  34. 2 2
      packages/hooks/hooks-claude-code/tests/coverage-cases.ts
  35. 6 6
      packages/hooks/hooks-codex/src/index.ts
  36. 26 26
      packages/jobs/jobs-local/tests/jobs.spec.ts
  37. 22 22
      packages/jobs/tool-jobs/tests/tool-jobs.spec.ts
  38. 2 2
      packages/llm/plugin-package-inventory-deepseek/tests/inventory.spec.ts
  39. 1 1
      packages/plan/plan-mode/tests/projection.spec.ts
  40. 1 1
      packages/schedule/schedule/tests/runtime.spec.ts
  41. 2 2
      packages/schedule/schedule/tests/tools.spec.ts
  42. 3 3
      packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts
  43. 5 5
      packages/shell/tool-bash-persistent/tests/tools.spec.ts
  44. 9 9
      packages/shell/tool-bash/tests/tools.spec.ts
  45. 3 3
      packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts
  46. 5 5
      packages/shell/tool-pwsh-persistent/tests/tools.spec.ts
  47. 8 8
      packages/shell/tool-pwsh/tests/tools.spec.ts
  48. 7 4
      packages/subagent/tool-subagent/src/index.ts
  49. 12 12
      packages/subagent/tool-subagent/tests/tool-subagent.spec.ts
  50. 2 2
      packages/terminal/terminal-bash/tests/index.spec.ts
  51. 1 1
      packages/terminal/terminal-bash/tests/local.spec.ts
  52. 21 21
      packages/terminal/terminal/tests/service.spec.ts
  53. 3 3
      packages/terminal/tool-terminal/tests/loader-composition.spec.ts
  54. 3 3
      packages/terminal/tool-terminal/tests/tools.spec.ts
  55. 4 4
      packages/todo/tool-todo/tests/loader-composition.spec.ts
  56. 1 1
      packages/todo/tool-todo/tests/projection.spec.ts
  57. 2 2
      scripts/gen-tool-catalog.ts
  58. 7 1
      scripts/smoke-python-runtime.py
  59. 20 0
      snapshots/sdk/serial-created/cordis.snapshot.yml
  60. 38 0
      snapshots/sdk/serial-created/cordis.yml
  61. 25 0
      snapshots/sdk/serial-created/session.jsonl
  62. 8 0
      snapshots/sdk/serial-created/snapshot.yml

+ 3 - 3
packages/api/session-controller/tests/agent.host.spec.ts

@@ -172,7 +172,7 @@ describe('ApiSession Agent lookup and recovery', () => {
   it('projects live Agent contexts and maps missing cold identities through Typert lookup failures', async () => {
     const { ctx } = await harness()
     const live = agent(ctx, header('live'))
-    ctx.agents.register(live)
+    await ctx.agents.register(live)
     providePersistence(ctx, {
       list: () => Promise.resolve([]),
       inspect: vi.fn(),
@@ -193,7 +193,7 @@ describe('ApiSession Agent lookup and recovery', () => {
     })
     const winner = agent(ordinary.ctx, ordinaryMeta)
     vi.spyOn(ordinary.ctx.agents, 'resume').mockImplementation(async () => {
-      ordinary.ctx.agents.register(winner)
+      await ordinary.ctx.agents.register(winner)
       throw new Error('raced publication')
     })
     await expect(ordinary.agents.resolveAgent(ordinaryMeta.id)).resolves.toEqual({ agent: winner })
@@ -326,7 +326,7 @@ describe('ApiSession create or adoption', () => {
     const ordinaryMeta = header('create-race', cwd)
     const winner = agent(ordinary.ctx, ordinaryMeta)
     vi.spyOn(ordinary.ctx.agents, 'create').mockImplementation(async () => {
-      ordinary.ctx.agents.register(winner)
+      await ordinary.ctx.agents.register(winner)
       throw new Error('raced creation')
     })
     await expect(ordinary.agents.ensureSession(ordinaryMeta.id, cwd, false))

+ 1 - 1
packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts

@@ -82,7 +82,7 @@ async function commandHarness(
     followup: vi.fn(),
     cancel,
   } as unknown as Agent
-  ctx.agents.register(agent)
+  await ctx.agents.register(agent)
   ctx.provide('workspaceRegistry', { get: () => undefined, list: () => [] } as never)
   ctx.provide('agentDefaultModel', {
     currentSelection: () => ({ provider: 'fixture', model: 'fixture-model' }),

+ 1 - 1
packages/api/session-controller/tests/control-jobs.host.spec.ts

@@ -59,7 +59,7 @@ async function harness(withJobs: boolean): Promise<{
     runMaintenance: task => task(new AbortController().signal),
     whenIdle: () => Promise.resolve(),
   }
-  ctx.agents.register(agent)
+  await ctx.agents.register(agent)
   const control = new SessionControlController(ctx)
   await new Promise(resolve => setTimeout(resolve, 0))
   return { ctx, session, agent, control }

+ 1 - 1
packages/api/session-controller/tests/controller.host.spec.ts

@@ -74,7 +74,7 @@ describe('SessionController facade', () => {
       status: 'idle',
       ctx,
     } as Agent
-    ctx.agents.register(agent)
+    await ctx.agents.register(agent)
     const resolveUploadAgent = (id: SessionId): Promise<Agent> => {
       if (uploadResolver === undefined) throw new Error('file upload resolver was not registered')
       return uploadResolver(id)

+ 9 - 9
packages/api/session-controller/tests/session-cold.host.spec.ts

@@ -202,7 +202,7 @@ describe('attached updatedAt tracks human prompts', () => {
       ],
       meta: { cwd: '/proj', createdAt: 500 },
     })
-    ctx.agents.register({ id: resumed.id, session: resumed, status: 'idle', ctx } as Agent)
+    await ctx.agents.register({ id: resumed.id, session: resumed, status: 'idle', ctx } as Agent)
     const boundary = resumed.snapshotEvents().at(-1)
     expect(boundary?.type).toBe('session/end-seed')
     expect(boundary?.time).toBeGreaterThan(worked)
@@ -330,7 +330,7 @@ describe('Remote Agent and Session lookup policy', () => {
       meta: { cwd: '/proj', parentSession: sid('session-parent'), origin: 'subagent' },
     })
     const liveAgent = { id: liveSession.id, session: liveSession, status: 'idle', ctx } as Agent
-    ctx.agents.register(liveAgent)
+    await ctx.agents.register(liveAgent)
     const resume = vi.spyOn(ctx.agents, 'resume')
     const defaultAgentLookup = ctx.typert.lookups.get('agent')
     const defaultSessionLookup = ctx.typert.lookups.get('session')
@@ -478,7 +478,7 @@ describe('subagent ownership fence', () => {
     await ctx.plugin(AgentRegistry)
     const parentSession = ctx.sessions.create(sid('session-parent'), { meta: { cwd: '/proj' } })
     const parent = { id: parentSession.id, session: parentSession, status: 'idle', ctx } as Agent
-    ctx.agents.register(parent)
+    await ctx.agents.register(parent)
 
     const originSession = ctx.sessions.create(sid('session-origin-child'), {
       meta: { cwd: '/proj', parentSession: parent.id, origin: 'subagent' },
@@ -493,7 +493,7 @@ describe('subagent ownership fence', () => {
       cancel,
       updateInbox,
     } as unknown as Agent
-    ctx.agents.register(originChild)
+    await ctx.agents.register(originChild)
 
     const startingSession = ctx.sessions.create(sid('session-starting-child'), {
       meta: { cwd: '/proj', parentSession: parent.id },
@@ -549,7 +549,7 @@ describe('subagent ownership fence', () => {
     const agent = {
       id: session.id, session, inbox: inboxFor(), status: 'idle', ctx, followup,
     } as unknown as Agent
-    ctx.agents.register(agent)
+    await ctx.agents.register(agent)
     const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
 
     const response = await remote.prompt(promptRequest({
@@ -570,7 +570,7 @@ describe('subagent ownership fence', () => {
     const agent = {
       id: session.id, session, inbox: inboxFor(), status: 'idle', ctx, followup,
     } as unknown as Agent
-    ctx.agents.register(agent)
+    await ctx.agents.register(agent)
     const remote = createSessionTestRemote(ctx, {
       defaultModelSelection: () => ({ provider: 'p', model: 'm' }),
       cwd: '/tmp',
@@ -781,7 +781,7 @@ describe('sessions.prompt synchronous rejection', () => {
     const session = ctx.sessions.create(sid('session-throwing'))
     // A live structural stub whose delivery verbs throw synchronously, the
     // shape a disposed loop presents at this gateway boundary.
-    ctx.agents.register({
+    await ctx.agents.register({
       id: session.id,
       session,
       inbox: inboxFor(),
@@ -820,7 +820,7 @@ describe('sessions.prompt synchronous rejection', () => {
     // while the generic cold resume is in flight, so the resume collides.
     const parentSession = ctx.sessions.create(sid('race-parent'), { meta: { cwd: '/proj' } })
     const parent = { id: parentSession.id, session: parentSession, status: 'idle', ctx } as Agent
-    ctx.agents.register(parent)
+    await ctx.agents.register(parent)
     const childSession = ctx.sessions.create(sessionId, {
       meta: { cwd: '/proj', parentSession: parent.id, origin: 'subagent' },
     })
@@ -828,7 +828,7 @@ describe('sessions.prompt synchronous rejection', () => {
     vi.spyOn(ctx.agents, 'resume').mockImplementationOnce(async () => {
       // The parent's `enter()` wins the identity between the pre-resume
       // re-check and publication; the generic resume then collides.
-      ctx.agents.register(child)
+      await ctx.agents.register(child)
       throw new Error('session id already published')
     })
     const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })

+ 12 - 12
packages/api/session-controller/tests/session-fork.host.spec.ts

@@ -40,7 +40,7 @@ async function composed(workspaces: readonly Workspace[] = []): Promise<Context>
       const agentCtx = ownerCtx
       Object.assign(agent, { id: session.id, session, status: 'idle', ctx: agentCtx })
       await options.setup?.(agentCtx, agent)
-      ctx.agents.register(agent)
+      await ctx.agents.register(agent)
       return { agent, dispose: () => Promise.resolve() }
     },
     resume: () => Promise.reject(new Error('fork test sources are live')),
@@ -51,13 +51,13 @@ async function composed(workspaces: readonly Workspace[] = []): Promise<Context>
 /** Tail turn appended after the completed ones: left open, or closed as aborted (a stopped turn). */
 type Tail = 'none' | 'open' | 'aborted'
 
-function liveAgent(
+async function liveAgent(
   ctx: Context,
   id: string,
   turns: number,
   tail: Tail = 'none',
   lineage: { parentSession?: SessionId; origin?: 'subagent' } = {},
-): Session {
+): Promise<Session> {
   const session = ctx.sessions.create(sid(id), { meta: { cwd: '/proj', ...lineage } })
   for (let turn = 1; turn <= turns; turn++) {
     session.append('turn/start', { turn })
@@ -78,7 +78,7 @@ function liveAgent(
       reason: { kind: 'aborted', reason: { kind: 'user' } },
     })
   }
-  ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
+  await ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
   return session
 }
 
@@ -90,7 +90,7 @@ const remote = (ctx: Context) => createSessionTestRemote(ctx, {
 describe('sessions.fork', () => {
   it('cuts at the anchored completed turn and records lineage and cwd', async () => {
     const ctx = await composed()
-    const source = liveAgent(ctx, 'session-source', 2)
+    const source = await liveAgent(ctx, 'session-source', 2)
     const response = await remote(ctx).fork(request({ sessionId: source.id, atSeq: 1 }))
     expect(response.ok ? null : response.error).toBeNull()
     if (!response.ok) return
@@ -112,13 +112,13 @@ describe('sessions.fork', () => {
       attachSession,
     } as unknown as Workspace
     const ctx = await composed([workspace])
-    const owner = liveAgent(ctx, 'session-owner', 1)
+    const owner = await liveAgent(ctx, 'session-owner', 1)
     accounted.push(owner.id)
-    const child = liveAgent(ctx, 'session-child', 1, 'none', {
+    const child = await liveAgent(ctx, 'session-child', 1, 'none', {
       parentSession: owner.id,
       origin: 'subagent',
     })
-    const grandchild = liveAgent(ctx, 'session-grandchild', 1, 'none', {
+    const grandchild = await liveAgent(ctx, 'session-grandchild', 1, 'none', {
       parentSession: child.id,
       origin: 'subagent',
     })
@@ -204,7 +204,7 @@ describe('sessions.fork', () => {
 
   it('uses the last completed turn only for omitted and past-end anchors', async () => {
     const ctx = await composed()
-    const source = liveAgent(ctx, 'session-tail', 2, 'open')
+    const source = await liveAgent(ctx, 'session-tail', 2, 'open')
     const proxy = remote(ctx)
     const expectedTypes = [
       'turn/start', 'user/message', 'turn/end',
@@ -240,7 +240,7 @@ describe('sessions.fork', () => {
 
   it('cuts through an aborted turn: stopped is closed, not open', async () => {
     const ctx = await composed()
-    const source = liveAgent(ctx, 'session-aborted', 1, 'aborted')
+    const source = await liveAgent(ctx, 'session-aborted', 1, 'aborted')
     // What a stopped message's fork button anchors on: the frozen node sits
     // one event before its turn/end, floored client-side to that event's seq.
     const anchor = (source.snapshotEvents().at(-1)?.seq ?? 0) - 1
@@ -257,7 +257,7 @@ describe('sessions.fork', () => {
 
   it('rejects an in-log anchor whose turn is still open', async () => {
     const ctx = await composed()
-    const source = liveAgent(ctx, 'session-open', 1, 'open')
+    const source = await liveAgent(ctx, 'session-open', 1, 'open')
     const anchor = source.snapshotEvents().at(-1)?.seq ?? 0
     const response = await remote(ctx).fork(request({ sessionId: source.id, atSeq: anchor }))
     expect(response).toMatchObject({
@@ -270,7 +270,7 @@ describe('sessions.fork', () => {
 
   it('installs the latest logged model selection before the child can run', async () => {
     const ctx = await composed()
-    const source = liveAgent(ctx, 'session-routed', 1)
+    const source = await liveAgent(ctx, 'session-routed', 1)
     source.append('request/header', {
       header: {
         config: {

+ 2 - 2
packages/api/session-controller/tests/session-list-blank.host.spec.ts

@@ -26,8 +26,8 @@ async function harness(): Promise<{ ctx: Context; remote: TestSessionRemote; att
   return {
     ctx,
     remote: createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }),
-    attach: (session) => {
-      ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
+    attach: async (session) => {
+      await ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
     },
   }
 }

+ 1 - 1
packages/api/session-controller/tests/session-models.host.spec.ts

@@ -132,7 +132,7 @@ async function harness(logged?: {
     ctx,
     inbox: { nextTurn: [], nextStep: [] },
   } as unknown as Agent
-  ctx.agents.register(agent)
+  await ctx.agents.register(agent)
   return { ctx, agent, sessionId: session.id }
 }
 

+ 1 - 1
packages/api/session-controller/tests/session-presets.host.spec.ts

@@ -68,7 +68,7 @@ async function harness(presets?: readonly string[]) {
       const agent = stubAgent(session)
       ;(agent as { ctx?: Context }).ctx = ctx
       await options.setup?.(ctx, agent)
-      const unregister = ctx.agents.register(agent)
+      const unregister = await ctx.agents.register(agent)
       return { agent, dispose: () => { unregister(); return Promise.resolve() } }
     },
     async resume() {

+ 9 - 9
packages/api/session-controller/tests/session-rename.host.spec.ts

@@ -36,13 +36,13 @@ async function composed(withTitles = true): Promise<Context> {
   // forwarded seed/meta (the store validates the balanced prefix) and
   // registers an idle agent stub over it.
   ctx.agents.setFactory({
-    createAgent: (ownerCtx: Context, options: CreateAgentOptions): Promise<AgentHandle> => {
+    createAgent: async (ownerCtx: Context, options: CreateAgentOptions): Promise<AgentHandle> => {
       const session = ctx.sessions.create(options.sessionId, {
         ...options.seed === undefined ? {} : { seed: [...options.seed] },
         ...options.meta === undefined ? {} : { meta: options.meta },
       })
       const agent = { id: session.id, session, status: 'idle', ctx: ownerCtx } as Agent
-      ctx.agents.register(agent)
+      await ctx.agents.register(agent)
       return Promise.resolve({ agent, dispose: () => Promise.resolve() })
     },
     resume: () => Promise.reject(new Error('resume must not run: every source is attached')),
@@ -51,7 +51,7 @@ async function composed(withTitles = true): Promise<Context> {
 }
 
 /** Register one live agent whose log holds `turns` completed turns. */
-function liveAgent(ctx: Context, id: string, turns: number): Session {
+async function liveAgent(ctx: Context, id: string, turns: number): Promise<Session> {
   const session = ctx.sessions.create(sid(id), { meta: { cwd: '/proj' } })
   for (let turn = 1; turn <= turns; turn++) {
     session.append('turn/start', { turn })
@@ -61,7 +61,7 @@ function liveAgent(ctx: Context, id: string, turns: number): Session {
     }), { surfaceOp: 'append' })
     session.append('turn/end', { turn, reason: { kind: 'completed' } })
   }
-  ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
+  await ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
   return session
 }
 
@@ -70,7 +70,7 @@ const remote = (ctx: Context) => createSessionTestRemote(ctx, { defaultModelSele
 describe('sessions.rename', () => {
   it('accepts through the composed title service: normalized user-source event, echoed seq', async () => {
     const ctx = await composed()
-    const source = liveAgent(ctx, 'session-rename', 1)
+    const source = await liveAgent(ctx, 'session-rename', 1)
 
     const renamed = await remote(ctx).rename(request({ sessionId: source.id, title: '  new   name  ' }))
     expect(renamed.ok).toBe(true)
@@ -83,7 +83,7 @@ describe('sessions.rename', () => {
 
   it('maps only an empty-normalizing title to title-invalid, with a presentable message', async () => {
     const ctx = await composed()
-    const source = liveAgent(ctx, 'session-rename-bad', 1)
+    const source = await liveAgent(ctx, 'session-rename-bad', 1)
 
     // U+200B passes a client-side trim gate but normalizes to empty host-side.
     const response = await remote(ctx).rename(request({ sessionId: source.id, title: ' ​ ' }))
@@ -104,8 +104,8 @@ describe('sessions.rename', () => {
     // title service's liveness check throws a plain Error, which must not
     // read as the user's fault.
     const foreign = await composed(false)
-    const stale = liveAgent(foreign, 'session-rename-stale', 1)
-    ctx.agents.register({ id: stale.id, session: stale, status: 'idle', ctx } as Agent)
+    const stale = await liveAgent(foreign, 'session-rename-stale', 1)
+    await ctx.agents.register({ id: stale.id, session: stale, status: 'idle', ctx } as Agent)
 
     const response = await remote(ctx).rename(request({ sessionId: stale.id, title: 'name' }))
     expect(response.ok).toBe(false)
@@ -114,7 +114,7 @@ describe('sessions.rename', () => {
 
   it('answers internal when the composition mounts no session-title service', async () => {
     const ctx = await composed(false)
-    const source = liveAgent(ctx, 'session-no-titles', 1)
+    const source = await liveAgent(ctx, 'session-no-titles', 1)
 
     const response = await remote(ctx).rename(request({ sessionId: source.id, title: 'name' }))
     expect(response.ok).toBe(false)

+ 1 - 1
packages/bundle/headless/tests/headless.spec.ts

@@ -114,7 +114,7 @@ async function bench(script: Script): Promise<{
       }
       await options.setup?.(ownerCtx, agent)
       script.before?.(session)
-      ctx.agents.register(agent)
+      await ctx.agents.register(agent)
       return { agent, dispose: () => Promise.resolve() }
     },
     resume: () => Promise.reject(new Error('not used')),

+ 5 - 3
packages/context/file-reference-local/src/index.ts

@@ -63,8 +63,9 @@ export class LocalFileReferenceService extends FileReferenceService {
     }
     validateConfig(this.config)
 
-    const installPrompt = (agent: Agent): void => {
-      if (this.promptFibers.has(agent)) return
+    const installPrompt = (agent: Agent): ReturnType<Context['inject']> => {
+      const existing = this.promptFibers.get(agent)
+      if (existing !== undefined) return existing
       const fiber = agent.ctx.inject(['systemPrompt', 'tools'], (scope) => {
         scope.systemPrompt.section({
           name: 'context:file-reference',
@@ -73,6 +74,7 @@ export class LocalFileReferenceService extends FileReferenceService {
         })
       })
       this.promptFibers.set(agent, fiber)
+      return fiber
     }
     const disposePrompt = (agent: Agent): void => {
       const fiber = this.promptFibers.get(agent)
@@ -87,7 +89,7 @@ export class LocalFileReferenceService extends FileReferenceService {
       })
     }
     for (const agent of ctx.agents.list()) installPrompt(agent)
-    ctx.on('agent/created', ({ agent }) => { installPrompt(agent) })
+    ctx.on('agent/created', async ({ agent }) => { await installPrompt(agent) })
     ctx.on('agent/disposed', ({ agent }) => {
       this.searches.get(agent)?.dispose()
       this.searches.delete(agent)

+ 20 - 1
packages/context/file-reference-local/tests/service.spec.ts

@@ -51,10 +51,29 @@ async function stubAgent(
     cancel() {},
     whenIdle: () => Promise.resolve(),
   } as unknown as Agent
-  return { agent, dispose: ctx.agents.register(agent) }
+  return { agent, dispose: await ctx.agents.register(agent) }
 }
 
 describe('LocalFileReferenceService', () => {
+  it('defers guidance until optional prompt and tool services become available', async () => {
+    const ctx = new Context()
+    await ctx.plugin(SessionStore)
+    await ctx.plugin(AgentRegistry)
+    await ctx.plugin(LocalFileReferenceService)
+    try {
+      const { agent } = await stubAgent(ctx, 'deferred-prompt')
+      expect(ctx.agents.get(agent.id)).toBe(agent)
+      await ctx.plugin(SystemPrompt, { personaPrefix: '' })
+      await ctx.plugin(ToolRegistry)
+      ctx.tools.register(defineContentToolFixture({
+        name: 'read', description: 'read a file', parameters: {}, execute: () => Promise.resolve([]),
+      }))
+      expect(renderPrompt(await ctx.systemPrompt.assemble())).toContain(FILE_REFERENCE_PROMPT)
+    } finally {
+      await ctx.fiber.dispose()
+    }
+  })
+
   it('serves the addressed workspace and installs read-tool guidance for existing agents', async () => {
     const ctx = await harness()
     const { agent } = await stubAgent(ctx)

+ 39 - 27
packages/core/agent-loop/src/index.ts

@@ -10,7 +10,6 @@ import { randomUUID } from 'node:crypto'
 import z from '@deepseek-ai/schemastery'
 import { z as zod } from 'zod'
 import { brandString } from '@deepseek-ai/dsh-brand'
-import { emitAgentEvent } from '@deepseek-ai/dsh-agent'
 import type {
   Agent,
   AgentFactory,
@@ -215,7 +214,7 @@ interface PreparedAgent {
   /** Aborts when the factory unloads, the caller cancels, or teardown begins — ends any setup await. */
   signal: AbortSignal
   /** Enter registries, announce, notify session-start, and start the machine. */
-  publish(source: SessionStartSource): AgentHandle
+  publish(source: SessionStartSource): Promise<AgentHandle>
   /** Reverse teardown: stop the machine, unregister, unwind the scope. Memoized. */
   dispose(): Promise<void>
 }
@@ -569,6 +568,7 @@ export class AgentLoop extends Service implements AgentFactory {
     let detachSession: (() => void) | undefined
     let detachAgent: (() => void) | undefined
     let disposing: Promise<void> | undefined
+    let publication: ReturnType<typeof Promise.withResolvers<void>> | undefined
     const machineReady = Promise.withResolvers<void>()
     // Reverse teardown, memoized so every racing owner awaits one quiescence:
     // stop the machine, drain and close the session's write path, leave the
@@ -582,6 +582,8 @@ export class AgentLoop extends Service implements AgentFactory {
       // disposal rejects with what failed so every racing owner observes it.
       const failures: unknown[] = []
       try {
+        // Creation listeners retain the session and scope through their awaits.
+        if (publication !== undefined) await publication.promise
         // Disposal IS a disposed-cause cancel followed by quiescence. New work
         // sent after this point is the sender's bug — the registries are about
         // to drop the agent, so nothing should still hold it.
@@ -659,22 +661,23 @@ export class AgentLoop extends Service implements AgentFactory {
       return {
         agent,
         signal: abort.signal,
-        publish: (source) => {
-          assertLive()
-          detachSession = agent.ctx.sessions.enter(session)
-          // The mounted backend routes announced live events into the active
-          // write handle by session id; the loop only owns the handle itself.
-          detachAgent = loopCtx.agents.enter(agent, parentAgent)
-          agent.ctx.sessions.announce(session)
-          assertLive()
-          loopCtx.agents.announce(agent)
-          assertLive()
-          // A synchronous announce/session-start listener may have started
-          // teardown; the machine is already live (delivery works from the
-          // session-start extension point), so only the liveness recheck is owed.
-          emitAgentEvent(loopCtx, agent, 'agent/session-start', { source })
-          assertLive()
-          return { agent, dispose }
+        publish: async (source) => {
+          publication = Promise.withResolvers<void>()
+          try {
+            assertLive()
+            detachSession = agent.ctx.sessions.enter(session)
+            // The mounted backend routes announced live events into the active
+            // write handle by session id; the loop only owns the handle itself.
+            detachAgent = loopCtx.agents.enter(agent, parentAgent)
+            agent.ctx.sessions.announce(session)
+            assertLive()
+            await loopCtx.agents.announce(agent, source, abort.signal)
+            assertLive()
+            return { agent, dispose }
+          } finally {
+            publication.resolve()
+            publication = undefined
+          }
         },
         dispose,
       }
@@ -706,14 +709,10 @@ export class AgentLoop extends Service implements AgentFactory {
       await stored?.handle.close().catch(() => {})
       throw error
     }
-    try {
+    return (await this.initializeAgent(prepared, async () => {
       await this.appendUnstoredSuffix(stored, preparation.session)
-      return prepared.publish('startup').agent
-    } catch (error: unknown) {
-      // Rollback swallows a disposal rejection: the setup failure is primary.
-      void prepared.dispose().catch(() => {})
-      throw error
-    }
+      return await prepared.publish('startup')
+    })).agent
   }
 
   /**
@@ -821,11 +820,24 @@ export class AgentLoop extends Service implements AgentFactory {
       await stored?.handle.close().catch(() => {})
       throw error
     }
-    try {
+    return await this.initializeAgent(prepared, async () => {
       const setupCommit = await raceAbort(setup?.(prepared.agent.ctx, prepared.agent), prepared.signal, id)
       setupCommit?.commit()
       await this.appendUnstoredSuffix(stored, session)
-      return prepared.publish(source)
+      return await prepared.publish(source)
+    })
+  }
+
+  private async initializeAgent(prepared: PreparedAgent, initialize: () => Promise<AgentHandle>): Promise<AgentHandle> {
+    try {
+      return await prepared.agent.runMaintenance(async () => {
+        try {
+          return await initialize()
+        } catch (error: unknown) {
+          prepared.agent.cancel({ kind: 'disposed' })
+          throw error
+        }
+      })
     } catch (error: unknown) {
       // Rollback swallows a disposal rejection (a failing final handle close):
       // the setup failure is the primary error the caller must see.

+ 24 - 0
packages/core/agent-loop/tests/fixtures/serial-created.mjs

@@ -0,0 +1,24 @@
+/** Awaited creation context shared by TypeScript and Python SDK snapshots. */
+import { randomUUID } from 'node:crypto'
+import { setImmediate } from 'node:timers/promises'
+
+export const name = 'serial-created-fixture'
+export const inject = ['agents']
+
+/** Install ordered creation listeners whose context must precede the first turn. */
+export function apply(ctx) {
+  const ready = new WeakSet()
+  ctx.on('agent/created', async ({ agent }) => {
+    await setImmediate()
+    agent.inject({
+      id: randomUUID(),
+      role: 'user',
+      content: [{ type: 'text', text: 'Serial agent creation completed.' }],
+      source: { kind: 'plugin', plugin: name },
+    })
+    ready.add(agent)
+  })
+  ctx.on('agent/created', ({ agent }) => {
+    if (!ready.has(agent)) throw new Error('creation listeners ran out of order')
+  })
+}

+ 91 - 0
packages/core/agent-loop/tests/scope-lifecycle.spec.ts

@@ -61,6 +61,97 @@ function disposeCurrentLifecycle(ownerCtx: Context): void {
 }
 
 describe('agent scope lifecycle', () => {
+  it('awaits serial creation before session-start and caller readiness', async () => {
+    const ctx = await harness()
+    const entered = Promise.withResolvers<void>()
+    const release = Promise.withResolvers<void>()
+    const order: string[] = []
+    ctx.on('agent/created', async () => {
+      order.push('created:start')
+      entered.resolve()
+      await release.promise
+      order.push('created:end')
+    })
+    ctx.on('agent/created', () => { order.push('created:second') })
+    ctx.on('agent/session-start', () => { order.push('session-start') })
+    const creating = ctx.agentLoop.create(SessionId('serial-start')).then(agent => {
+      order.push('ready')
+      return agent
+    })
+    try {
+      await entered.promise
+      expect(order).toEqual(['created:start'])
+      release.resolve()
+      await creating
+      expect(order).toEqual(['created:start', 'created:end', 'created:second', 'session-start', 'ready'])
+    } finally {
+      release.resolve()
+      await ctx.fiber.dispose()
+    }
+  })
+
+  it('retains both entries and scoped effects until an asynchronous creation listener settles on owner unload', async () => {
+    const ctx = await harness()
+    const entered = Promise.withResolvers<void>()
+    const release = Promise.withResolvers<void>()
+    let ownerCtx!: Context
+    const owner = await ctx.plugin(Object.assign((inner: Context) => { ownerCtx = inner }, { inject: ['agents'] }))
+    const order: string[] = []
+    const id = SessionId('serial-owner-dispose')
+    ctx.on('agent/created', async ({ agent }) => {
+      entered.resolve()
+      await release.promise
+      expect(ctx.agents.get(id)).toBe(agent)
+      expect(ctx.sessions.get(id)).toBe(agent.session)
+      expect(order).toEqual([])
+      order.push('created:end')
+    })
+    ctx.on('agent/session-start', () => { order.push('session-start') })
+    ctx.on('agent/disposed', () => { order.push('agent-disposed') })
+    ctx.on('session/disposed', () => { order.push('session-disposed') })
+    const creating = ownerCtx.agents.create({
+      sessionId: id,
+      setup(agentCtx) { agentCtx.effect(() => () => { order.push('scope-disposed') }) },
+    })
+    const rejected = expect(creating).rejects.toThrow('owner disposed during setup')
+    try {
+      await entered.promise
+      const disposing = owner.dispose()
+      expect(ctx.agents.get(id)).toBeDefined()
+      release.resolve()
+      await rejected
+      await disposing
+      expect(order).toEqual(['created:end', 'scope-disposed', 'agent-disposed', 'session-disposed'])
+      expect(ctx.agents.get(id)).toBeUndefined()
+      expect(ctx.sessions.get(id)).toBeUndefined()
+    } finally {
+      release.resolve()
+      await ctx.fiber.dispose()
+    }
+  })
+
+  it('rolls back both registries and the scope when asynchronous creation fails', async () => {
+    const ctx = await harness()
+    const reason = new Error('async creation veto')
+    const order: string[] = []
+    ctx.on('agent/created', async () => {
+      await Promise.resolve()
+      throw reason
+    })
+    ctx.on('agent/created', () => { order.push('later-listener') })
+    ctx.on('agent/session-start', () => { order.push('session-start') })
+    ctx.on('agent/disposed', () => { order.push('agent-disposed') })
+    ctx.on('session/disposed', () => { order.push('session-disposed') })
+    await expect(ctx.agents.create({
+      sessionId: SessionId('async-veto'),
+      setup(agentCtx) { agentCtx.effect(() => () => { order.push('scope-disposed') }) },
+    })).rejects.toBe(reason)
+    expect(order).toEqual(['scope-disposed', 'agent-disposed', 'session-disposed'])
+    expect(ctx.agents.list()).toEqual([])
+    expect(ctx.sessions.list()).toEqual([])
+    await ctx.fiber.dispose()
+  })
+
   it('rejects an already-aborted creation signal before publishing either object', async () => {
     const ctx = await harness()
     const reason = new Error('cancelled before creation')

+ 119 - 0
packages/core/agent-loop/tests/serial-listener-review.spec.ts

@@ -0,0 +1,119 @@
+import { setImmediate } from 'node:timers/promises'
+import { afterEach, describe, expect, it } from 'vitest'
+import { mkdtemp, rm } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { Context } from '@deepseek-ai/cordis'
+import AgentLoop from '@deepseek-ai/dsh-agent-loop'
+import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
+import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
+import { SessionId } from '@deepseek-ai/dsh-session'
+import { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
+import { bindScopeParent, createScope, scopeOf } from '@deepseek-ai/dsh-scope'
+import LocalFileReference from '@deepseek-ai/dsh-file-reference-local'
+import SubagentRuntime from '@deepseek-ai/dsh-subagent'
+import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn-in-process'
+import * as SubagentTool from '@deepseek-ai/dsh-tool-subagent'
+import Selection from '@deepseek-ai/dsh-tool-subagent/model-selection-settings'
+import * as Schedule from '@deepseek-ai/dsh-schedule'
+
+const roots: string[] = []
+afterEach(async () => { for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) })
+
+async function core(persistenceRoot?: string) {
+  const ctx = new Context()
+  await mountAgentLoopTestDependencies(ctx)
+  if (persistenceRoot !== undefined) await ctx.plugin(JsonlSessionPersistence, { root: persistenceRoot, compression: 'none' })
+  await ctx.plugin(AgentLoop, { agents: [] })
+  return ctx
+}
+
+describe('serial creation listener integrations', () => {
+  it('rolls back creation when file-reference prompt installation fails', async () => {
+    const ctx = await core()
+    const errors: string[] = []
+    ctx.logger.error = ((error: unknown) => { errors.push(String(error)) }) as typeof ctx.logger.error
+    await ctx.plugin(LocalFileReference)
+    try {
+      await expect(ctx.agents.create({
+        sessionId: SessionId('review-file-reference'),
+        setup(agentCtx) {
+          agentCtx.systemPrompt.section({ name: 'context:file-reference', order: 0, text: 'occupied' })
+        },
+      })).rejects.toThrow('context:file-reference')
+      expect(errors.some(error => error.includes('context:file-reference'))).toBe(true)
+      expect(ctx.agents.list()).toEqual([])
+      expect(ctx.sessions.list()).toEqual([])
+    } finally {
+      await ctx.fiber.dispose()
+    }
+  })
+
+  it('rolls back creation when a shared preset tool installation fails', async () => {
+    const ctx = await core()
+    const errors: string[] = []
+    ctx.logger.error = ((error: unknown) => { errors.push(String(error)) }) as typeof ctx.logger.error
+    await ctx.plugin(Selection)
+    await ctx.plugin(SubagentRuntime)
+    await ctx.plugin(SubagentSpawn, { providerName: 'spawn' })
+    const preset = createScope(ctx, { preset: 'review' })
+    await preset.ctx.plugin(SubagentTool, { provider: 'spawn', modelSelectionSettings: true, backgroundMode: 'continuable' })
+    try {
+      await expect(ctx.agents.create({
+        sessionId: SessionId('review-tool'),
+        setup(agentCtx) {
+          const binding = bindScopeParent(scopeOf(agentCtx)!, scopeOf(preset.ctx)!)
+          agentCtx.effect(() => () => { binding.dispose() })
+          agentCtx.tools.register(defineContentToolFixture({ name: 'subagent', description: 'occupied', parameters: {}, execute: () => Promise.resolve([]) }))
+        },
+      })).rejects.toThrow('subagent')
+      expect(errors.some(error => error.includes('subagent'))).toBe(true)
+      expect(ctx.agents.list()).toEqual([])
+      expect(ctx.sessions.list()).toEqual([])
+    } finally {
+      await ctx.fiber.dispose()
+    }
+  })
+
+  it('starts due Schedule work only after every creation listener finishes', async () => {
+    const root = await mkdtemp(join(tmpdir(), 'dsh-serial-schedule-'))
+    roots.push(root)
+    const ctx = await core(root)
+    await ctx.plugin(Schedule)
+    const entered = Promise.withResolvers<void>()
+    const release = Promise.withResolvers<void>()
+    const turn = Promise.withResolvers<void>()
+    let turns = 0
+    let created = false
+    let sessionStarted = false
+    ctx.on('agent/created', async () => { entered.resolve(); await release.promise; created = true })
+    ctx.on('agent/session-start', () => { sessionStarted = true })
+    ctx.on('session/event', (_session, event) => { if (event.type === 'turn/start') { turns += 1; turn.resolve() } })
+    const creating = ctx.agents.create({
+      sessionId: SessionId('review-schedule'),
+      setup(_agentCtx, agent) {
+        agent.session.append('schedule/change', {
+          version: 1,
+          operation: 'create',
+          schedule: Schedule.createAfterScheduleRecord(Schedule.ScheduleId('schedule-1'), 'due reminder', 1, Date.now() - 2_000),
+        })
+      },
+    })
+    try {
+      await entered.promise
+      // A full event-loop turn lets the due runtime run if it starts during creation.
+      await setImmediate()
+      expect(turns).toBe(0)
+      expect(sessionStarted).toBe(false)
+      release.resolve()
+      await creating
+      await turn.promise
+      expect(created).toBe(true)
+      expect(sessionStarted).toBe(true)
+    } finally {
+      release.resolve()
+      await creating
+      await ctx.fiber.dispose()
+    }
+  })
+})

+ 18 - 24
packages/core/agent/src/index.ts

@@ -13,7 +13,7 @@ import { scopeTarget } from '@deepseek-ai/dsh-scope'
 import type { Scoped } from '@deepseek-ai/dsh-scope'
 import type { SessionEvent, SessionId, SessionLogOffset } from '@deepseek-ai/dsh-session'
 import type { Agent } from './types.ts'
-import type { AgentOptions } from './runtime-types.ts'
+import type { AgentOptions, SessionStartSource } from './runtime-types.ts'
 
 export * from './runtime-types.ts'
 export * from './types.ts'
@@ -413,16 +413,16 @@ export class AgentRegistry extends Service {
   }
 
   /**
-   * Register a live agent. Throws if an agent with the same id is already
-   * registered. Emits `agent/created` on registration and `agent/disposed`
+   * Register a live agent. Rejects if the id is already registered or a
+   * serial `agent/created` listener fails. Emits `agent/disposed`
    * when the calling fiber is disposed — both with the agent's scope carrier
    * (`scopeTarget(agent, agent)`): the subject is the agent in hand, so the
    * emits are scope-filtered regardless of which context invoked `register`
    * (calling through `agent.ctx` scopes EFFECTS; dispatch scoping always
    * requires passing the carrier). The entry is a runtime root; factory-backed
-   * creation uses `options.parentAgent` for child ownership. Returns the disposer.
+   * creation uses `options.parentAgent` for child ownership. Await the registration before using the agent.
    * @param agent - the already-constructed agent to record in the store.
-   * @returns the EXACT Cordis effect disposer (single-shot; a repeat call
+   * @returns the awaitable Cordis effect disposer (single-shot; a repeat call
    *   returns undefined without awaiting an in-flight teardown). Exact
    *   identity is load-bearing: a composite (generator) effect that owns a
    *   teardown ORDER — the agent factory's lifecycle chain — must yield THIS
@@ -431,13 +431,11 @@ export class AgentRegistry extends Service {
    *   owner unload, unregistering the agent (and emitting `agent/disposed`)
    *   while its final turn is still draining.
    */
-  register(agent: Agent): () => void {
-    const dispose = this.ctx.effect(function* (this: AgentRegistry) {
+  register(agent: Agent, source: SessionStartSource = 'startup'): ReturnType<Context['effect']> {
+    return this.ctx.effect(async function* (this: AgentRegistry) {
       yield this.enter(agent, undefined)
-      this.announce(agent)
+      await this.announce(agent, source)
     }.bind(this), 'agents.register()')
-    // oxlint-disable-next-line typescript/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
-    return dispose
   }
 
   /**
@@ -452,8 +450,8 @@ export class AgentRegistry extends Service {
    *   the resumed session's durable parent lineage.
    * @returns an idempotent closure that removes this exact entry and emits
    *   `agent/disposed` with listener failures contained. When called from a
-   *   synchronous `agent/created` listener, removal and disposal wait until
-   *   that creation dispatch unwinds.
+   *   `agent/created` listener, removal and disposal wait until the serial
+   *   creation dispatch settles.
    */
   enter(agent: Agent, owner: Agent | undefined): () => void {
     const id = agent.id
@@ -482,7 +480,7 @@ export class AgentRegistry extends Service {
       // live entry, and disposal must follow creation. A listener may own
       // the advanced detach capability, so make that ordering structural:
       // visibility and the paired disposal are deferred until announce()'s
-      // synchronous dispatch has unwound.
+      // serial dispatch has settled.
       if (entry.announcing) {
         entry.detachRequested = true
         return
@@ -526,11 +524,12 @@ export class AgentRegistry extends Service {
   /**
    * Announce an agent previously inserted with {@link enter}.
    * @param agent - the live inserted agent to announce.
+   * @returns completion of the serial creation listeners; a listener failure rejects.
    * @throws if `agent` is not the exact live registry entry for its id, or its
    *   creation announcement already began (including a reentrant call from a
    *   creation listener).
    */
-  announce(agent: Agent): void {
+  async announce(agent: Agent, source: SessionStartSource, signal?: AbortSignal): Promise<void> {
     const entry = this.store.get(agent.id)
     if (entry === undefined || entry.agent !== agent) {
       throw new Error(`agent "${agent.id}" is not live in this registry`)
@@ -542,17 +541,12 @@ export class AgentRegistry extends Service {
     // lifecycle edge; detach still pairs a partially delivered first edge.
     entry.announcing = true
     entry.announced = true
-    const args: unknown[] = [entry.carrier, 'agent/created', { agent: entry.agent }]
     try {
-      for (const callback of this.ctx.events.dispatch('emit', args)) {
-        // A synchronous creation failure vetoes publication and rolls back.
-        // Returned-promise rejection happens after this synchronous boundary, so
-        // observe and report it instead of leaking an unhandled rejection.
-        const returned: unknown = callback(...args)
-        void Promise.resolve(returned).catch((error: unknown) => {
-          this.ctx.logger.warn(`agent "${entry.id}": agent/created listener rejected: ${String(error)}`)
-        })
-      }
+      await this.ctx.serial(entry.carrier, 'agent/created', {
+        agent: entry.agent,
+        source,
+        ...signal === undefined ? {} : { signal },
+      })
     } finally {
       entry.announcing = false
       if (entry.detachRequested) this.detachEntered(entry)

+ 6 - 19
packages/core/agent/src/runtime-types.ts

@@ -244,18 +244,18 @@ declare module './types.ts' {
 
 declare module '@deepseek-ai/cordis' {
   interface Events {
-    // ---- lifecycle (emit) ----
+    // ---- lifecycle ----
     /**
      * A fully configured agent and live session were published. Setup is
      * composition-only; `agent/session-start` is the first startup-driving extension point.
-     * Synchronous listener failure vetoes publication, while returned-promise
-     * rejection is reported. Detach requested during dispatch waits until every
-     * creation listener has observed the stable entry.
+     * Listeners run in order and are awaited before session-start. A throw or
+     * rejection vetoes publication and skips later listeners. Disposal waits
+     * for dispatch to settle; a listener must not await its own owner's disposal.
      * @param payload.agent - the newly registered agent with its live session and completed setup.
      * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
-     * @mode emit
+     * @mode serial
      */
-    'agent/created'(this: Scoped<Agent>, payload: { agent: Agent }): void
+    'agent/created'(this: Scoped<Agent>, payload: { agent: Agent; source: SessionStartSource; signal?: AbortSignal }): undefined | Promise<undefined>
     /**
      * An agent left the registry; AgentLoop emits this after driver quiescence
      * and scoped-registration unwind, but before session detachment. Custom
@@ -302,19 +302,6 @@ declare module '@deepseek-ai/cordis' {
      * @mode emit
      */
     'agent/inbox/discarded'(this: Scoped<Agent>, payload: { agent: Agent; message: UserMessage }): void
-    // ---- session lifecycle (emit) ----
-    /**
-     * The session lifecycle began, once before the first turn. Use
-     * `agent.inject()` to seed model-facing context. This is a notification, not
-     * a veto; disposal requested by a lifecycle owner is rechecked before the
-     * driver starts.
-     * @param payload.agent - the agent whose session lifecycle began.
-     * @param payload.source - why the session started (fresh startup, resume, …).
-     * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
-     * @mode emit
-     */
-    'agent/session-start'(this: Scoped<Agent>, payload: { agent: Agent; source: SessionStartSource }): void
-
     // ---- the machine's extension points ----
     /**
      * Reject a proposed step or replace the messages that enter it. Calling

+ 45 - 16
packages/core/agent/tests/agent.spec.ts

@@ -45,7 +45,7 @@ describe('AgentRegistry', () => {
     await agentFiber
     await ctx.plugin(TypertRegistry)
     const agent = stubAgent('remote-agent')
-    const disposeAgent = ctx.agents.register(agent)
+    const disposeAgent = await ctx.agents.register(agent)
 
     const lookup = ctx.typert.lookups.get('agent')
     expect(lookup).toMatchObject({
@@ -73,11 +73,11 @@ describe('AgentRegistry', () => {
     ctx.on('agent/disposed', ({ agent }) => void lifecycle.push(`disposed:${agent.id}`))
 
     const agent = stubAgent('a1')
-    const dispose = ctx.agents.register(agent)
+    const dispose = await ctx.agents.register(agent)
     expect(ctx.agents.get(agent.id)).toBe(agent)
     expect(ctx.agents.list()).toEqual([agent])
     expect(ctx.agents.roots()).toEqual([agent])
-    expect(() => ctx.agents.register(stubAgent('a1'))).toThrow(/already registered/)
+    await expect(Promise.resolve(ctx.agents.register(stubAgent('a1')))).rejects.toThrow(/already registered/)
 
     dispose()
     expect(ctx.agents.get(agent.id)).toBeUndefined()
@@ -100,9 +100,9 @@ describe('AgentRegistry', () => {
     const root = stubAgent('root')
     const child = stubAgent('child')
     const detachRoot = ctx.agents.enter(root, undefined)
-    ctx.agents.announce(root)
+    await ctx.agents.announce(root)
     const detachChild = ctx.agents.enter(child, root)
-    ctx.agents.announce(child)
+    await ctx.agents.announce(child)
 
     expect(ctx.agents.list()).toEqual([root, child])
     expect(ctx.agents.roots()).toEqual([root])
@@ -123,35 +123,64 @@ describe('AgentRegistry', () => {
     ctx.on('agent/created', () => { throw new Error('creation veto') })
     ctx.on('agent/disposed', ({ agent }) => void lifecycle.push(`disposed:${agent.id}`))
 
-    expect(() => ctx.agents.register(stubAgent('vetoed'))).toThrow('creation veto')
+    await expect(Promise.resolve(ctx.agents.register(stubAgent('vetoed')))).rejects.toThrow('creation veto')
     expect(ctx.agents.get(SessionId('vetoed'))).toBeUndefined()
     expect(lifecycle).toEqual(['created:vetoed', 'disposed:vetoed'])
   })
 
-  it('contains asynchronous creation rejection and every disposal-listener failure', async () => {
+  it('rolls back asynchronous creation rejection and contains every disposal-listener failure', async () => {
     const ctx = new Context()
     await ctx.plugin(AgentRegistry)
     const warnings: string[] = []
     const heard: string[] = []
     ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn
-    ctx.on('agent/created', () => Promise.reject(new Error('created async')) as never)
+    ctx.on('agent/created', () => Promise.reject(new Error('created async')))
+    ctx.on('agent/created', () => { heard.push('unreachable') })
     ctx.on('agent/disposed', () => { throw new Error('disposed sync') })
     ctx.on('agent/disposed', () => Promise.reject(new Error('disposed async')) as never)
     ctx.on('agent/disposed', ({ agent }) => void heard.push(agent.id))
 
-    const dispose = ctx.agents.register(stubAgent('contained'))
-    await Promise.resolve()
-    dispose()
+    await expect(Promise.resolve(ctx.agents.register(stubAgent('contained')))).rejects.toThrow('created async')
     await Promise.resolve()
 
     expect(heard).toEqual(['contained'])
+    expect(ctx.agents.list()).toEqual([])
     expect(warnings).toEqual([
-      'agent "contained": agent/created listener rejected: Error: created async',
       'agent "contained": agent/disposed listener threw: Error: disposed sync',
       'agent "contained": agent/disposed listener rejected: Error: disposed async',
     ])
   })
 
+  it('awaits each creation listener before the next listener and registration completion', async () => {
+    const ctx = new Context()
+    await ctx.plugin(AgentRegistry)
+    const entered = Promise.withResolvers<void>()
+    const release = Promise.withResolvers<void>()
+    const order: string[] = []
+    ctx.on('agent/created', async () => {
+      order.push('first:start')
+      entered.resolve()
+      await release.promise
+      order.push('first:end')
+    })
+    ctx.on('agent/created', () => { order.push('second') })
+    const agent = stubAgent('serial')
+    const registration = ctx.agents.register(agent)
+    const ready = Promise.resolve(registration).then(() => { order.push('ready') })
+    try {
+      await entered.promise
+      expect(order).toEqual(['first:start'])
+      await expect(ctx.agents.announce(agent)).rejects.toThrow('already announced')
+      release.resolve()
+      await ready
+      expect(order).toEqual(['first:start', 'first:end', 'second', 'ready'])
+    } finally {
+      release.resolve()
+      await registration()
+      await ctx.fiber.dispose()
+    }
+  })
+
   it('separates entry from announcement and stale/idempotent detach cannot remove a replacement', async () => {
     const ctx = new Context()
     await ctx.plugin(AgentRegistry)
@@ -162,8 +191,8 @@ describe('AgentRegistry', () => {
     const first = stubAgent('split')
     const detachFirst = ctx.agents.enter(first, undefined)
     expect(lifecycle).toEqual([])
-    ctx.agents.announce(first)
-    expect(() => { ctx.agents.announce(first) }).toThrow(/already announced/)
+    await ctx.agents.announce(first)
+    await expect(ctx.agents.announce(first)).rejects.toThrow(/already announced/)
     detachFirst()
     detachFirst()
 
@@ -171,7 +200,7 @@ describe('AgentRegistry', () => {
     const detachReplacement = ctx.agents.enter(replacement, undefined)
     detachFirst()
     expect(ctx.agents.get(replacement.id)).toBe(replacement)
-    expect(() => { ctx.agents.announce(first) }).toThrow(/not live/)
+    await expect(ctx.agents.announce(first)).rejects.toThrow(/not live/)
     detachReplacement()
     expect(lifecycle).toEqual(['created:split', 'disposed:split'])
   })
@@ -189,7 +218,7 @@ describe('AgentRegistry', () => {
     ctx.on('agent/created', () => void order.push(`second:${ctx.agents.get(agent.id) === agent}`))
     ctx.on('agent/disposed', () => void order.push('disposed'))
     const detach = ctx.agents.enter(agent, undefined)
-    ctx.agents.announce(agent)
+    await ctx.agents.announce(agent)
     expect(order).toEqual(['first:true', 'after-detach:true', 'second:true', 'disposed'])
     expect(ctx.agents.get(agent.id)).toBeUndefined()
   })

+ 0 - 1
packages/core/scope/src/scoped-events.generated.ts

@@ -18,7 +18,6 @@ const scopedSubjectResolvers: Readonly<Record<string, ScopedSubjectResolver | nu
   'agent/pre-step': args => (args[0] as Record<string, unknown>)['agent'],
   'agent/request': args => (args[0] as Record<string, unknown>)['agent'],
   'agent/request-error': args => (args[0] as Record<string, unknown>)['agent'],
-  'agent/session-start': args => (args[0] as Record<string, unknown>)['agent'],
   'agent/status': args => (args[0] as Record<string, unknown>)['agent'],
   'agent/turn-stopping': args => (args[0] as Record<string, unknown>)['agent'],
   'approval/request': args => (args[0] as Record<string, unknown>)['agent'],

+ 1 - 1
packages/e2b/e2b/tests/fixtures/composition/bin.ts

@@ -42,7 +42,7 @@ const owner: Agent = {
   runMaintenance: task => task(new AbortController().signal),
   whenIdle: () => Promise.resolve(),
 }
-const unregisterOwner = ctx.agents.register(owner)
+const unregisterOwner = await ctx.agents.register(owner)
 let terminalId: Awaited<ReturnType<typeof ctx.terminals.spawn>>['sessionId'] | undefined
 try {
   const sandbox = await ctx.e2b.getSandbox()

+ 1 - 1
packages/experimental/agent-team/src/index.ts

@@ -108,7 +108,7 @@ export class TeamService extends TypertRemoteService {
     this.tasks = new TeamTaskBoard(this.journal, this.config.maxTasks)
 
     ctx.on('session/event', (session, event) => { this.mailbox.observeSessionEvent(session, event) })
-    ctx.on('agent/session-start', ({ agent }) => { this.scheduleRecovery(agent) })
+    ctx.on('agent/created', ({ agent }) => { this.scheduleRecovery(agent) })
     ctx.on('agent/status', ({ agent }) => {
       const membership = this.roster.tryMembership(agent)
       if (membership !== undefined) this.activity.notify(membership.id)

+ 1 - 1
packages/feedback/command-feedback/tests/command-feedback.spec.ts

@@ -55,7 +55,7 @@ async function harness(): Promise<Harness> {
   await ctx.plugin(SessionStore)
   const plugin = await ctx.plugin(commandFeedback)
   const { agent, session } = stubAgent(ctx, `command-feedback-${Math.random()}`)
-  ctx.agents.register(agent)
+  await ctx.agents.register(agent)
   return { ctx, agent, session, plugin }
 }
 

+ 3 - 3
packages/feedback/command-feedback/tests/loader-composition.spec.ts

@@ -26,7 +26,7 @@ afterEach(async () => {
 })
 
 /** Register one idle agent over a store-owned session, as an app's spine does. */
-function agent(ctx: Context): Agent {
+async function agent(ctx: Context): Promise<Agent> {
   const scope = ctx.plugin(() => {})
   const id = SessionId('feedback-loader-agent')
   const session = ctx.sessions.create(id)
@@ -46,7 +46,7 @@ function agent(ctx: Context): Agent {
     runMaintenance: task => task(new AbortController().signal),
     whenIdle: () => Promise.resolve(),
   }
-  ctx.agents.register(value)
+  await ctx.agents.register(value)
   return value
 }
 
@@ -83,7 +83,7 @@ describe('/feedback real Loader composition through cordis.yml', () => {
     await context.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(configPath).href } })
     await context.loader.await()
 
-    const owner = agent(context)
+    const owner = await agent(context)
     const signal = new AbortController().signal
 
     // Discoverable through the composed registry, as a UI adapter finds it.

+ 3 - 3
packages/fs/tool-str-replace-editor/tests/tools.spec.ts

@@ -27,7 +27,7 @@ afterEach(async () => {
   for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true })
 })
 
-function agent(ctx: Context, cwd: string): Agent {
+async function agent(ctx: Context, cwd: string): Promise<Agent> {
   const id = SessionId(`str-replace-editor-owner-${callNumber}`)
   const scope = ctx.plugin(() => {})
   const session = Session.create(id, [], {
@@ -48,7 +48,7 @@ function agent(ctx: Context, cwd: string): Agent {
     runMaintenance: task => task(new AbortController().signal),
     whenIdle: () => Promise.resolve(),
   }
-  ctx.agents.register(value)
+  await ctx.agents.register(value)
   return value
 }
 
@@ -88,7 +88,7 @@ async function setup(
   }
   if (options.fsPolicy === true) await ctx.plugin(FsPolicy)
   const fiber = await ctx.plugin(ToolStrReplaceEditor, config)
-  return { ctx, root, fiber, owner: agent(ctx, root) }
+  return { ctx, root, fiber, owner: await agent(ctx, root) }
 }
 
 describe('tool-str-replace-editor', () => {

+ 1 - 1
packages/goal/command-goal/tests/command-goal.spec.ts

@@ -52,7 +52,7 @@ async function harness(): Promise<Harness> {
   await ctx.plugin(GoalService)
   const plugin = await ctx.plugin(commandGoal)
   const { agent, session } = stubAgent(ctx, `command-goal-${Math.random()}`)
-  ctx.agents.register(agent)
+  await ctx.agents.register(agent)
   return { ctx, agent, session, plugin }
 }
 

+ 1 - 2
packages/goal/goal-round-driver/src/index.ts

@@ -248,9 +248,8 @@ export function apply(ctx: Context): void {
       disarm(state)
     })
 
-    ctx.on('agent/created', ({ agent }) => { stateFor(agent) })
     ctx.on('agent/disposed', ({ agent }) => { states.delete(agent) })
-    ctx.on('agent/session-start', ({ agent }) => {
+    ctx.on('agent/created', ({ agent }) => {
       const state = stateFor(agent)
       state.attempt = undefined
       state.competingQueued = false

+ 1 - 1
packages/goal/goal/src/index.ts

@@ -252,7 +252,7 @@ export class GoalService extends TypertRemoteService {
     this.resolved = {
       defaultMaxGoalRounds: resolveMaxGoalRounds(config.defaultMaxGoalRounds ?? 256),
     }
-    ctx.on('agent/session-start', ({ agent }) => {
+    ctx.on('agent/created', ({ agent }) => {
       this.setActivation(agent.session, 'disarmed')
     })
     ctx.sessionProjections.register(goalProjectionDefinition)

+ 8 - 8
packages/goal/goal/tests/goal.spec.ts

@@ -87,7 +87,7 @@ async function harness(config: { defaultMaxGoalRounds?: number } = {}) {
   await ctx.plugin(AgentRegistry)
   await ctx.plugin(GoalService, config)
   const stub = stubAgent(`goal-test-${Math.random()}`, undefined, ctx)
-  ctx.agents.register(stub.agent)
+  await ctx.agents.register(stub.agent)
   return { ctx, ...stub }
 }
 
@@ -165,7 +165,7 @@ describe('GoalService creation and replay', () => {
     await ctx.plugin(AgentRegistry)
     await ctx.plugin(SessionProjectionRegistry)
     const stub = stubAgent('goal-direct-construction')
-    ctx.agents.register(stub.agent)
+    await ctx.agents.register(stub.agent)
     const goals = new GoalService(ctx)
     await new Promise(resolve => setImmediate(resolve))
     expect(goals.create(stub.agent, { objective: 'direct' })).toMatchObject({
@@ -193,7 +193,7 @@ describe('GoalService creation and replay', () => {
     await ctx.plugin(SessionProjectionRegistry)
     await ctx.plugin(GoalService)
     const resumed = stubAgent('seeded-goal', first.session.snapshotEvents())
-    ctx.agents.register(resumed.agent)
+    await ctx.agents.register(resumed.agent)
     expect(ctx.goals.get(resumed.agent)).toMatchObject({
       id: created.id,
       roundsStarted: 2,
@@ -208,12 +208,12 @@ describe('GoalService creation and replay', () => {
     await ctx.plugin(AgentRegistry)
     await ctx.plugin(GoalService)
     const parent = stubAgentForSession(ctx.sessions.create(SessionId('goal-fork-parent')), ctx)
-    ctx.agents.register(parent.agent)
+    await ctx.agents.register(parent.agent)
     const goal = ctx.goals.create(parent.agent, { objective: 'inherit through fork', maxGoalRounds: 5 })
     appendRound(parent.session, goal, 1)
 
     const child = stubAgentForSession(ctx.sessions.fork(parent.session), ctx)
-    ctx.agents.register(child.agent)
+    await ctx.agents.register(child.agent)
     expect(ctx.goals.get(child.agent)).toMatchObject({
       id: goal.id,
       objective: goal.objective,
@@ -264,7 +264,7 @@ describe('GoalService creation and replay', () => {
     const fiber = await ctx.plugin(GoalService)
     const first = ctx.goals
     const stub = stubAgent('goal-hmr')
-    ctx.agents.register(stub.agent)
+    await ctx.agents.register(stub.agent)
     const goal = ctx.goals.create(stub.agent, { objective: 'survive service reload' })
 
     await fiber.dispose()
@@ -472,7 +472,7 @@ describe('GoalService mutations', () => {
     await ctx.plugin(AgentRegistry)
     await ctx.plugin(GoalService)
     const stub = stubAgentForSession(ctx.sessions.create(SessionId('goal-reentrant-observer')), ctx)
-    ctx.agents.register(stub.agent)
+    await ctx.agents.register(stub.agent)
     let observed: ReturnType<GoalService['get']>
     ctx.on('session/event', (session, event) => {
       if (session === stub.session && event.type === 'goal/change') observed = ctx.goals.get(stub.agent)
@@ -492,7 +492,7 @@ describe('GoalService mutations', () => {
     await ctx.plugin(GoalService)
     const stub = stubAgent('goal-independent-injection')
     stub.agent.inject = () => { throw new Error('injection must not be called') }
-    ctx.agents.register(stub.agent)
+    await ctx.agents.register(stub.agent)
 
     expect(ctx.goals.create(stub.agent, { objective: 'persist directly' })).toMatchObject({
       objective: 'persist directly',

+ 3 - 3
packages/goal/goal/tests/projection.spec.ts

@@ -29,7 +29,7 @@ interface Bench {
 }
 
 /** Register a minimal registry-compatible live agent over a store session. */
-function liveAgent(ctx: Context, session: Session): Agent {
+async function liveAgent(ctx: Context, session: Session): Promise<Agent> {
   const status: AgentStatus = 'idle'
   const agent: Agent = {
     id: session.id,
@@ -46,7 +46,7 @@ function liveAgent(ctx: Context, session: Session): Agent {
     runMaintenance: task => task(new AbortController().signal),
     whenIdle() { return Promise.resolve() },
   }
-  ctx.agents.register(agent)
+  await ctx.agents.register(agent)
   return agent
 }
 
@@ -57,7 +57,7 @@ async function harness(withGoal: boolean): Promise<Bench> {
   await ctx.plugin(SessionProjectionRegistry)
   if (withGoal) await ctx.plugin(GoalService)
   const session = ctx.sessions.create()
-  const agent = liveAgent(ctx, session)
+  const agent = await liveAgent(ctx, session)
   return {
     ctx,
     session,

+ 4 - 4
packages/goal/tool-goal/tests/tool-goal.spec.ts

@@ -101,7 +101,7 @@ async function harness(config: toolGoal.Config = {}) {
   await ctx.plugin(GoalService)
   const fiber = await ctx.plugin(toolGoal, config)
   const root = stubAgent(`goal-tool-root-${Math.random()}`, undefined, ctx)
-  ctx.agents.register(root.agent)
+  await ctx.agents.register(root.agent)
   return { ctx, fiber, root }
 }
 
@@ -256,7 +256,7 @@ describe('goal tool execution authority', () => {
 
     const child = stubAgent('goal-tool-child')
     ctx.agents.enter(child.agent, root.agent)
-    ctx.agents.announce(child.agent)
+    await ctx.agents.announce(child.agent)
     openTurn(child, { kind: 'user' })
     const childResult = await execute(ctx, 'create_goal', { objective: 'child goal' }, child.agent)
     expect(childResult.error?.info?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED')
@@ -290,7 +290,7 @@ describe('goal tool execution authority', () => {
       isSeeded: true,
     }, SessionLogOffset(root.session.seq))
     const fork = stubAgent(forkId, forkSession)
-    ctx.agents.register(fork.agent)
+    await ctx.agents.register(fork.agent)
     expect(ctx.goals.get(fork.agent)).toMatchObject({ id: created.id, activation: 'disarmed' })
 
     openTurn(fork, { kind: 'user' }, '继续这个目标')
@@ -345,7 +345,7 @@ describe('goal tool execution authority', () => {
   it('rejects an initiator different from exec.agent', async () => {
     const { ctx, root } = await harness()
     const other = stubAgent('goal-tool-other')
-    ctx.agents.register(other.agent)
+    await ctx.agents.register(other.agent)
     openTurn(other, { kind: 'user' })
     const result = await execute(ctx, 'get_goal', {}, other.agent, root.agent)
     expect(result.error?.info?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED')

+ 6 - 6
packages/hooks/hooks-claude-code/src/index.ts

@@ -199,18 +199,18 @@ export function apply(ctx: Context, config: Config): void {
     return [ours, ...theirs ?? []]
   }
 
-  // SessionStart injects context when its detached hook resolves; a slow hook
-  // may miss the first request.
-  // TODO(session-start-gating): add a startup gate before promising first-turn delivery.
-  ctx.on('agent/session-start', ({ agent, source }) => {
-    detached.track(runPoint('SessionStart', source, sessionStartPayload(agent, source), { agent, signal: detached.signal })
+  ctx.on('agent/created', async ({ agent, source, signal }) => {
+    const ownerSignal = signal === undefined ? detached.signal : AbortSignal.any([signal, detached.signal])
+    const run = runPoint('SessionStart', source, sessionStartPayload(agent, source), { agent, signal: ownerSignal })
       .then((merged) => {
         const context = contextFrom(merged)
         if (context) agent.inject(context)
       })
       .catch((error: unknown) => {
         ctx.logger.warn(`hooks-claude-code: SessionStart hook failed: ${String(error)}`)
-      }))
+      })
+    detached.track(run)
+    await run
   })
 
   // --- UserPromptSubmit → PreStepDecision. The prompt text is the payload; no

+ 2 - 2
packages/hooks/hooks-claude-code/tests/coverage-cases.ts

@@ -238,7 +238,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
         },
         session: { id: SessionId('child-x'), header: { id: 'child-x' } },
       } as unknown as Parameters<typeof ctx.agents.register>[0]
-      ctx.agents.register(child)
+      await ctx.agents.register(child)
       ctx.emit(subagentCarrier(ctx), 'subagent/start', { runId: SubagentRunId('run-x'), provider: 'p', id: SessionId('child-x'), local: true })
       await waitFor(() => injected.includes('child guidance'))
       expect(injected).toContain('child guidance')
@@ -254,7 +254,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
       const ctx = await harness(path, new MockAdapter([]))
       const warn = vi.fn(); ctx.logger.warn = warn as never
       const child = { id: SessionId('child-y'), inject: () => { throw new Error('inject boom') }, session: { id: SessionId('child-y'), header: { id: 'child-y' } } } as unknown as Parameters<typeof ctx.agents.register>[0]
-      ctx.agents.register(child)
+      await ctx.agents.register(child)
       ctx.emit(subagentCarrier(ctx), 'subagent/start', { runId: SubagentRunId('run-y'), provider: 'p', id: SessionId('child-y'), local: true })
       await waitFor(() => warn.mock.calls.some(c => String(c[0]).includes('SubagentStart hook failed')))
       expect(warn).toHaveBeenCalledWith(expect.stringContaining('SubagentStart hook failed'))

+ 6 - 6
packages/hooks/hooks-codex/src/index.ts

@@ -181,16 +181,16 @@ export function apply(ctx: Context, config: Config): void {
     return [ours, ...theirs ?? []]
   }
 
-  // SessionStart injects plain stdout when its detached hook resolves; a slow
-  // hook may miss the first request.
-  // TODO(session-start-gating): add a startup gate before promising first-turn delivery.
-  ctx.on('agent/session-start', ({ agent, source }) => {
-    detached.track(runPoint('SessionStart', source, { ...base(agent, 'SessionStart', model), source }, { agent, plainStdoutAsContext: true, signal: detached.signal })
+  ctx.on('agent/created', async ({ agent, source, signal }) => {
+    const ownerSignal = signal === undefined ? detached.signal : AbortSignal.any([signal, detached.signal])
+    const run = runPoint('SessionStart', source, { ...base(agent, 'SessionStart', model), source }, { agent, plainStdoutAsContext: true, signal: ownerSignal })
       .then((merged) => {
         const context = contextFrom(merged)
         if (context) agent.inject(context)
       })
-      .catch((error: unknown) => { ctx.logger.warn(`hooks-codex: SessionStart hook failed: ${String(error)}`) }))
+      .catch((error: unknown) => { ctx.logger.warn(`hooks-codex: SessionStart hook failed: ${String(error)}`) })
+    detached.track(run)
+    await run
     /* jscpd:ignore-end */
   })
 

+ 26 - 26
packages/jobs/jobs-local/tests/jobs.spec.ts

@@ -135,8 +135,8 @@ describe('LocalJobRegistry.start', () => {
 
     const served = stubAgent(ctx, 'served', scopeOf(withControls.ctx))
     const unserved = stubAgent(ctx, 'unserved', scopeOf(withoutControls.ctx))
-    ctx.agents.register(served)
-    ctx.agents.register(unserved)
+    await ctx.agents.register(served)
+    await ctx.agents.register(unserved)
 
     expect(() => ctx.jobs.start(producer({ owner: served }).spec)).not.toThrow()
     expect(() => ctx.jobs.start(producer({ owner: unserved }).spec))
@@ -154,7 +154,7 @@ describe('LocalJobRegistry.start', () => {
     // holds them and every owner's read includes it.
     await attachControllerIn(ctx)
     const scoped = stubAgent(ctx, 'scoped', scopeOf(createScope(ctx, {}).ctx))
-    ctx.agents.register(scoped)
+    await ctx.agents.register(scoped)
 
     expect(() => ctx.jobs.start(producer({ owner: scoped }).spec)).not.toThrow()
     expect(() => ctx.jobs.start(producer().spec)).not.toThrow()
@@ -239,17 +239,17 @@ describe('LocalJobRegistry.start', () => {
   it('isolates exact owners, replacement objects with the same session id, and the unowned bucket', async () => {
     const ctx = await harness({ maxConcurrentJobsPerOwner: 1 })
     const oldOwner = stubAgent(ctx, 'shared-session')
-    const detachOld = ctx.agents.register(oldOwner)
+    const detachOld = await ctx.agents.register(oldOwner)
     const oldTask = producer({ owner: oldOwner })
     ctx.jobs.start(oldTask.spec)
 
     const otherOwner = stubAgent(ctx, 'other-session')
-    ctx.agents.register(otherOwner)
+    await ctx.agents.register(otherOwner)
     expect(() => ctx.jobs.start(producer({ owner: otherOwner }).spec)).not.toThrow()
 
     detachOld()
     const replacement = stubAgent(ctx, 'shared-session')
-    ctx.agents.register(replacement)
+    await ctx.agents.register(replacement)
     expect(() => ctx.jobs.start(producer({ owner: replacement }).spec)).not.toThrow()
 
     ctx.jobs.start(producer().spec)
@@ -561,7 +561,7 @@ describe('LocalJobRegistry owner isolation', () => {
   it('fences read/kill/wait to the owning session and keeps unowned jobs open', async () => {
     const ctx = await harness()
     const owner = stubAgent(ctx, 'owner')
-    ctx.agents.register(owner)
+    await ctx.agents.register(owner)
     const other = stubAgent(ctx, 'other')
 
     const owned = ctx.jobs.start(producer({ owner }).spec)
@@ -582,8 +582,8 @@ describe('LocalJobRegistry owner isolation', () => {
     const ctx = await harness()
     const alice = stubAgent(ctx, 'alice')
     const bob = stubAgent(ctx, 'bob')
-    ctx.agents.register(alice)
-    ctx.agents.register(bob)
+    await ctx.agents.register(alice)
+    await ctx.agents.register(bob)
 
     const aliceTask = ctx.jobs.start(producer({ owner: alice }).spec)
     const bobTask = ctx.jobs.start(producer({ owner: bob }).spec)
@@ -615,7 +615,7 @@ describe('LocalJobRegistry owner isolation', () => {
     expect(ctx.jobs.list(ghost)).toEqual([])
 
     // A later valid registration must still attach cleanup for the same object.
-    ctx.agents.register(ghost)
+    await ctx.agents.register(ghost)
     const cancels: (string | undefined)[] = []
     let settle!: (outcome: JobOutcome) => void
     const id = ctx.jobs.start({
@@ -636,11 +636,11 @@ describe('LocalJobRegistry owner isolation', () => {
   it('rejects a stale owner instance after another agent reuses its id', async () => {
     const ctx = await harness()
     const staleOwner = stubAgent(ctx, 'owner')
-    const unregisterStale = ctx.agents.register(staleOwner)
+    const unregisterStale = await ctx.agents.register(staleOwner)
     unregisterStale()
 
     const currentOwner = stubAgent(ctx, 'owner')
-    ctx.agents.register(currentOwner)
+    await ctx.agents.register(currentOwner)
     const current = producer({ owner: currentOwner })
     ctx.jobs.start(current.spec) // Attach the current owner's cleanup first.
 
@@ -665,7 +665,7 @@ describe('LocalJobRegistry owner cleanup', () => {
   it('drains the owner: cancels live jobs, awaits settlement, drops snapshots', async () => {
     const ctx = await harness()
     const owner = stubAgent(ctx, 'owner')
-    ctx.agents.register(owner)
+    await ctx.agents.register(owner)
 
     // The producer settles only when cancelled — models a child that stops on request.
     let settle!: (outcome: JobOutcome) => void
@@ -693,7 +693,7 @@ describe('LocalJobRegistry owner cleanup', () => {
   it('publishes the settled visible set before announcing completion', async () => {
     const ctx = await harness()
     const owner = stubAgent(ctx, 'owner')
-    ctx.agents.register(owner)
+    await ctx.agents.register(owner)
     const p = producer({ owner })
     ctx.jobs.start(p.spec)
     // Registered after start so only the settlement's notifications are ordered.
@@ -713,7 +713,7 @@ describe('LocalJobRegistry owner cleanup', () => {
   it('reports a teardown-cancelled record so completion reporters stay quiet', async () => {
     const ctx = await harness()
     const owner = stubAgent(ctx, 'owner')
-    ctx.agents.register(owner)
+    await ctx.agents.register(owner)
     const seen: JobSnapshot[] = []
     ctx.jobs.onJobDone(snapshot => void seen.push(snapshot))
 
@@ -738,7 +738,7 @@ describe('LocalJobRegistry owner cleanup', () => {
   it('attaches one cleanup per owner and drains all owned jobs with the scope', async () => {
     const ctx = await harness()
     const owner = stubAgent(ctx, 'owner')
-    ctx.agents.register(owner)
+    await ctx.agents.register(owner)
 
     const first = producer({ owner })
     const second = producer({ owner })
@@ -755,7 +755,7 @@ describe('LocalJobRegistry owner cleanup', () => {
   it('does not let an old scope cleanup cancel a same-id/session replacement job', async () => {
     const ctx = await harness()
     const oldOwner = stubAgent(ctx, 'owner')
-    const detachOld = ctx.agents.register(oldOwner)
+    const detachOld = await ctx.agents.register(oldOwner)
     const cancels: string[] = []
 
     function start(owner: Agent, label: string): JobId {
@@ -774,7 +774,7 @@ describe('LocalJobRegistry owner cleanup', () => {
     start(oldOwner, 'old job')
     detachOld()
     const replacement = stubAgent(ctx, 'owner')
-    ctx.agents.register(replacement)
+    await ctx.agents.register(replacement)
     const replacementId = start(replacement, 'replacement job')
 
     await disposeAgentScope(oldOwner)
@@ -791,7 +791,7 @@ describe('LocalJobRegistry owner cleanup', () => {
     const tasksFiber = await ctx.plugin(LocalJobRegistry)
     ctx.jobs.attachController('test-controller')
     const owner = stubAgent(ctx, 'owner')
-    ctx.agents.register(owner)
+    await ctx.agents.register(owner)
     const ownerCleanupEffects = () => owner.ctx.fiber.getEffects()
       .filter(effect => effect.label === 'jobs.ownerCleanup()')
 
@@ -815,7 +815,7 @@ describe('LocalJobRegistry owner cleanup', () => {
     const ctx = await harness()
     const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
     const owner = stubAgent(ctx, 'owner')
-    ctx.agents.register(owner)
+    await ctx.agents.register(owner)
     const seen: JobSnapshot[] = []
     ctx.jobs.onJobDone(snapshot => void seen.push(snapshot))
 
@@ -927,7 +927,7 @@ describe('LocalJobRegistry disposal', () => {
     const tasksFiber = await ctx.plugin(LocalJobRegistry)
     ctx.jobs.attachController('test-controller')
     const owner = stubAgent(ctx, 'owner')
-    ctx.agents.register(owner)
+    await ctx.agents.register(owner)
     let settle!: (outcome: JobOutcome) => void
     ctx.jobs.start({
       kind: 'bash',
@@ -962,7 +962,7 @@ describe('LocalJobRegistry disposal', () => {
       },
     })
     const owner = stubAgent(ctx, 'joined', scopeOf(standing.ctx))
-    ctx.agents.register(owner)
+    await ctx.agents.register(owner)
     expect(() => ctx.jobs.start(producer({ owner }).spec)).not.toThrow()
 
     await mount.dispose()
@@ -994,7 +994,7 @@ describe('LocalJobRegistry.onJobsChanged', () => {
   it('fires after registration, the stopping transition, and settlement', async () => {
     const ctx = await harness()
     const owner = stubAgent(ctx, 'alice')
-    ctx.agents.register(owner)
+    await ctx.agents.register(owner)
     const seen: (string | undefined)[] = []
     ctx.jobs.onJobsChanged(changed => void seen.push(changed?.id))
 
@@ -1028,8 +1028,8 @@ describe('LocalJobRegistry.onJobsChanged', () => {
     const ctx = await harness()
     const owner = stubAgent(ctx, 'alice')
     const bystander = stubAgent(ctx, 'bob')
-    ctx.agents.register(owner)
-    ctx.agents.register(bystander)
+    await ctx.agents.register(owner)
+    await ctx.agents.register(bystander)
     const p = producer({ owner })
     ctx.jobs.start(p.spec)
 
@@ -1087,7 +1087,7 @@ describe('LocalJobRegistry teardown change notifications', () => {
   it('announces the stopping transition during owner teardown, before settlement', async () => {
     const ctx = await harness()
     const owner = stubAgent(ctx, 'alice')
-    ctx.agents.register(owner)
+    await ctx.agents.register(owner)
     const p = producer({ owner })
     const id = ctx.jobs.start(p.spec)
 

+ 22 - 22
packages/jobs/tool-jobs/tests/tool-jobs.spec.ts

@@ -41,7 +41,7 @@ interface FakeDelivery {
  * A fake agent with the shared agent/session identity, registered in
  * `ctx.agents` with a dedicated lifecycle scope.
  */
-function fakeAgent(ctx: Context, sessionId: string, delivery: FakeDelivery = {}): Agent {
+async function fakeAgent(ctx: Context, sessionId: string, delivery: FakeDelivery = {}): Promise<Agent> {
   const scopeFiber = ctx.plugin(() => {})
   const id = SessionId(sessionId)
   const agent = {
@@ -52,7 +52,7 @@ function fakeAgent(ctx: Context, sessionId: string, delivery: FakeDelivery = {})
     status: delivery.status ?? 'running',
     session: { id, header: { version: 0, id, createdAt: 0 } },
   } as unknown as Agent
-  agentRegistryDisposers.set(agent, ctx.agents.register(agent))
+  agentRegistryDisposers.set(agent, await ctx.agents.register(agent))
   agentScopeFibers.set(agent, scopeFiber)
   return agent
 }
@@ -353,7 +353,7 @@ describe('job_list', () => {
     const { ctx } = await setup()
     expect(text(await call(ctx, 'job_list', {}))).toBe('(no background jobs)')
 
-    const alice = fakeAgent(ctx, 'sess-alice')
+    const alice = await fakeAgent(ctx, 'sess-alice')
     ctx.jobs.start(producer({ owner: alice, label: 'pnpm test' }).spec)
     ctx.jobs.start(producer({ kind: 'subagent', label: 'open research' }).spec)
     const p = producer({ owner: alice, label: 'build' })
@@ -377,7 +377,7 @@ describe('job_list', () => {
       'bash-2 [bash] completed — build',
     ].join('\n'))
     // A different caller sees only the unowned job.
-    const bob = fakeAgent(ctx, 'sess-bob')
+    const bob = await fakeAgent(ctx, 'sess-bob')
     expect(text(await call(ctx, 'job_list', {}, bob))).toBe('subagent-1 [subagent] running — open research')
   })
 })
@@ -536,7 +536,7 @@ describe('completion notices across scoped mounts', () => {
       inject,
       session: { id: SessionId('sess-scoped'), header: { version: 0, id: SessionId('sess-scoped'), createdAt: 0 } },
     } as unknown as Agent
-    const dispose = ctx.agents.register(owner)
+    const dispose = await ctx.agents.register(owner)
 
     try {
       // No waiter: `settle()` leaves `reported` false, which is the only path
@@ -558,7 +558,7 @@ describe('completion notice delivery', () => {
     const { ctx } = await setup()
     const inject = vi.fn()
     const followup = vi.fn()
-    const owner = fakeAgent(ctx, 'sess-1', { inject, followup, status: 'idle' })
+    const owner = await fakeAgent(ctx, 'sess-1', { inject, followup, status: 'idle' })
     const p = producer({ owner, label: 'pnpm test' })
     ctx.jobs.start(p.spec)
 
@@ -572,7 +572,7 @@ describe('completion notice delivery', () => {
     const { ctx } = await setup({ completionDelivery: 'quiet' })
     const inject = vi.fn()
     const followup = vi.fn()
-    const owner = fakeAgent(ctx, 'sess-1', { inject, followup, status: 'idle' })
+    const owner = await fakeAgent(ctx, 'sess-1', { inject, followup, status: 'idle' })
     const p = producer({ owner })
     ctx.jobs.start(p.spec)
 
@@ -586,7 +586,7 @@ describe('completion notice delivery', () => {
     const { ctx } = await setup({ maxConsecutiveWakes: 2 })
     const inject = vi.fn()
     const followup = vi.fn()
-    const owner = fakeAgent(ctx, 'sess-1', { inject, followup, status: 'idle' })
+    const owner = await fakeAgent(ctx, 'sess-1', { inject, followup, status: 'idle' })
 
     await settleTasks(ctx, owner, 3)
     // A woken turn that starts another job is the self-exciting case: the
@@ -599,7 +599,7 @@ describe('completion notice delivery', () => {
     const { ctx } = await setup({ maxConsecutiveWakes: 1 })
     const inject = vi.fn()
     const followup = vi.fn()
-    const owner = fakeAgent(ctx, 'sess-1', { inject, followup, status: 'idle' })
+    const owner = await fakeAgent(ctx, 'sess-1', { inject, followup, status: 'idle' })
 
     await settleTasks(ctx, owner, 2)
     expect(followup).toHaveBeenCalledTimes(1)
@@ -616,7 +616,7 @@ describe('completion notice delivery', () => {
     const { ctx } = await setup()
     const inject = vi.fn()
     const followup = vi.fn()
-    const owner = fakeAgent(ctx, 'sess-1', { inject, followup, status: 'idle' })
+    const owner = await fakeAgent(ctx, 'sess-1', { inject, followup, status: 'idle' })
     let settle!: (outcome: JobOutcome) => void
     ctx.jobs.start({
       kind: 'bash',
@@ -641,7 +641,7 @@ describe('completion notice delivery', () => {
     const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
     const inject = vi.fn()
     const followup = vi.fn()
-    const owner = fakeAgent(ctx, 'sess-1', { inject, followup, status: 'idle' })
+    const owner = await fakeAgent(ctx, 'sess-1', { inject, followup, status: 'idle' })
     ctx.jobs.start({
       kind: 'bash',
       label: 'broken producer',
@@ -666,7 +666,7 @@ describe('completion notice delivery', () => {
   it('keeps the budget spent when the owner only claims plugin notices', async () => {
     const { ctx } = await setup({ maxConsecutiveWakes: 1 })
     const followup = vi.fn()
-    const owner = fakeAgent(ctx, 'sess-1', { followup, status: 'idle' })
+    const owner = await fakeAgent(ctx, 'sess-1', { followup, status: 'idle' })
 
     await settleTasks(ctx, owner, 1)
     emitAgentEvent(ctx, owner, 'agent/inbox/claimed', {
@@ -685,7 +685,7 @@ describe('completion notices', () => {
   it('injects a notice into the owning agent when an unreported job settles', async () => {
     const { ctx } = await setup()
     const inject = vi.fn()
-    const owner = fakeAgent(ctx, 'sess-1', { inject })
+    const owner = await fakeAgent(ctx, 'sess-1', { inject })
     const p = producer({ owner, label: 'pnpm test' })
     ctx.jobs.start(p.spec)
 
@@ -708,7 +708,7 @@ describe('completion notices', () => {
   it('preserves job ids and collection guidance in bounded completion notices', async () => {
     const { ctx } = await setup()
     const inject = vi.fn()
-    const owner = fakeAgent(ctx, 'sess-1', { inject })
+    const owner = await fakeAgent(ctx, 'sess-1', { inject })
     const first = producer({
       owner,
       kind: 'subagent',
@@ -762,7 +762,7 @@ describe('completion notices', () => {
       await tick()
     }
     const inject = vi.fn()
-    const owner = fakeAgent(ctx, 'sess-1', { inject })
+    const owner = await fakeAgent(ctx, 'sess-1', { inject })
     const target = producer({
       owner,
       kind: 'pty-send',
@@ -783,7 +783,7 @@ describe('completion notices', () => {
   it('reserves the collection-action tail when a producer supplies a smaller budget', async () => {
     const { ctx } = await setup()
     const inject = vi.fn()
-    const owner = fakeAgent(ctx, 'sess-1', { inject })
+    const owner = await fakeAgent(ctx, 'sess-1', { inject })
     const tiny = producer({ owner, kind: 'pty-send', label: 'x'.repeat(100), outputLimitBytes: 8 })
     const short = producer({ owner, kind: 'pty-send', label: 'x'.repeat(100), outputLimitBytes: 32 })
     ctx.jobs.start(tiny.spec)
@@ -804,7 +804,7 @@ describe('completion notices', () => {
   it('suppresses the notice for a job the model already killed', async () => {
     const { ctx } = await setup()
     const inject = vi.fn()
-    const owner = fakeAgent(ctx, 'sess-1', { inject })
+    const owner = await fakeAgent(ctx, 'sess-1', { inject })
     const p = producer({ owner })
     ctx.jobs.start(p.spec)
 
@@ -817,7 +817,7 @@ describe('completion notices', () => {
   it('suppresses the notice when a wait returned the terminal state', async () => {
     const { ctx } = await setup()
     const inject = vi.fn()
-    const owner = fakeAgent(ctx, 'sess-1', { inject })
+    const owner = await fakeAgent(ctx, 'sess-1', { inject })
     const p = producer({ owner, kind: 'subagent' })
     ctx.jobs.start(p.spec)
 
@@ -842,13 +842,13 @@ describe('completion notices', () => {
     // terminal state, so the notice lands in the old owner's (detached)
     // session instead of throwing or re-routing.
     const oldInject = vi.fn()
-    const oldOwner = fakeAgent(ctx, 'shared', { inject: oldInject })
+    const oldOwner = await fakeAgent(ctx, 'shared', { inject: oldInject })
     const p = producer({ owner: oldOwner })
     ctx.jobs.start(p.spec)
 
     detachAgent(oldOwner)
     const replacementInject = vi.fn()
-    fakeAgent(ctx, 'shared', { inject: replacementInject })
+    await fakeAgent(ctx, 'shared', { inject: replacementInject })
     p.settle({ status: 'completed' })
     await tick()
 
@@ -859,7 +859,7 @@ describe('completion notices', () => {
   it('surfaces an inject failure through listener containment (a real bug must be visible)', async () => {
     const { ctx } = await setup()
     const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
-    const owner = fakeAgent(ctx, 'sess-1', { inject: () => { throw new Error('unexpected inject bug') } })
+    const owner = await fakeAgent(ctx, 'sess-1', { inject: () => { throw new Error('unexpected inject bug') } })
     const p = producer({ owner })
     ctx.jobs.start(p.spec)
     p.settle({ status: 'completed' })
@@ -872,7 +872,7 @@ describe('completion notices', () => {
   it('keeps using the exact owner after the agent registry is gone', async () => {
     const { ctx, agentsFiber } = await setup()
     const inject = vi.fn()
-    const owner = fakeAgent(ctx, 'sess-1', { inject })
+    const owner = await fakeAgent(ctx, 'sess-1', { inject })
 
     // Settlement must not depend on a later registry lookup: the exact owner
     // supplied at start remains the destination while its own scope is live.

+ 2 - 2
packages/llm/plugin-package-inventory-deepseek/tests/inventory.spec.ts

@@ -119,7 +119,7 @@ describe('DeepSeek plugin package inventory', () => {
 
     const id = SessionId('bare-agent')
     const agentScope = createScope(ctx, {})
-    ctx.agents.register({ id, ctx: agentScope.ctx, session: { id } } as unknown as Agent)
+    await ctx.agents.register({ id, ctx: agentScope.ctx, session: { id } } as unknown as Agent)
     const bare = await ctx.deepseekLlmApiExtensions.prepare({ body: { messages: [] }, signal: SIGNAL, sessionId: id })
     expect(bare.fields.dsh_plugin_packages?.packages).toEqual([{ name: 'host-only', version: '3.0.0' }])
   })
@@ -218,7 +218,7 @@ describe('DeepSeek plugin package inventory', () => {
     const agentScope = createScope(ctx, agentKey, { parent: standingKey })
     const id = SessionId('preset-agent')
     const agent = { id, ctx: agentScope.ctx, session: { id } } as unknown as Agent
-    ctx.agents.register(agent)
+    await ctx.agents.register(agent)
 
     const prepared = await ctx.deepseekLlmApiExtensions.prepare({ body: { messages: [] }, signal: SIGNAL, sessionId: id })
     expect(prepared.fields.dsh_plugin_packages?.packages).toEqual([{ name: 'preset-only', version: '4.0.0' }])

+ 1 - 1
packages/plan/plan-mode/tests/projection.spec.ts

@@ -29,7 +29,7 @@ async function harness(withPlanMode: boolean): Promise<Bench> {
   await ctx.plugin(SessionProjectionRegistry)
   if (withPlanMode) await ctx.plugin(PlanModeController, { section: 'plan policy' })
   const session = ctx.sessions.create()
-  ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
+  await ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
   return {
     ctx,
     session,

+ 1 - 1
packages/schedule/schedule/tests/runtime.spec.ts

@@ -97,7 +97,7 @@ async function harness(): Promise<RuntimeHarness> {
     steer(_message: UserMessage) {},
     inject(_message: UserMessage) {},
   }
-  const disposeAgent = ctx.agents.register(agent)
+  const disposeAgent = await ctx.agents.register(agent)
   ctx.on('session/event', (_session, event) => {
     if (event.type === 'schedule/change' && event.data.operation === 'dispatch') order.push('dispatch')
   })

+ 2 - 2
packages/schedule/schedule/tests/tools.spec.ts

@@ -51,7 +51,7 @@ async function harness(withPersistence = true): Promise<ToolHarness> {
   await ctx.plugin(SystemPrompt, {})
   await ctx.plugin(ToolRuntime)
   const agent = stubAgent(ctx, `schedule-tools-${Math.random()}`)
-  ctx.agents.register(agent)
+  await ctx.agents.register(agent)
   const flushes = { count: 0, outcomes: [] as Array<'resolve' | 'reject' | Promise<'resolve' | 'reject'>> }
   if (withPersistence) {
     ctx.on('session/flush', async () => {
@@ -550,7 +550,7 @@ describe('Schedule persistence failure boundaries', () => {
     stop()
 
     const other = stubAgent(test.ctx, `other-${Math.random()}`)
-    test.ctx.agents.register(other)
+    await test.ctx.agents.register(other)
     expect(value(await execute(test, 'schedule_create', { prompt: 'x', after_seconds: 1 }, other)))
       .toEqual({ code: 'internal_error', message: 'The schedule operation failed.' })
     expect(value(await execute(test, 'schedule_list', {}, other)))

+ 3 - 3
packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts

@@ -38,7 +38,7 @@ class PassthroughSandbox extends SandboxProvider {
   }
 }
 
-function agent(ctx: Context, cwd: string): Agent {
+async function agent(ctx: Context, cwd: string): Promise<Agent> {
   const id = SessionId('persistent-bash-loader-agent')
   const scope = ctx.plugin(() => {})
   const session = Session.create(id, [], {
@@ -59,7 +59,7 @@ function agent(ctx: Context, cwd: string): Agent {
     runMaintenance: task => task(new AbortController().signal),
     whenIdle: () => Promise.resolve(),
   }
-  ctx.agents.register(value)
+  await ctx.agents.register(value)
   return value
 }
 
@@ -129,7 +129,7 @@ suite('persistent Bash through a real cordis.yml Loader composition', () => {
     await context.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(configPath).href } })
     await context.loader.await()
 
-    const owner = agent(context, root)
+    const owner = await agent(context, root)
     const signal = new AbortController().signal
     const execute = (id: string, command: string) => context!.tools.execute({
       signal,

+ 5 - 5
packages/shell/tool-bash-persistent/tests/tools.spec.ts

@@ -27,7 +27,7 @@ afterEach(async () => {
   for (const ctx of contexts.splice(0)) await ctx.fiber.dispose()
 })
 
-function agent(ctx: Context, cwd: string | undefined): Agent {
+async function agent(ctx: Context, cwd: string | undefined): Promise<Agent> {
   const id = SessionId(`persistent-bash-owner-${callNumber}`)
   const scope = ctx.plugin(() => {})
   const session = Session.create(id, [], {
@@ -52,7 +52,7 @@ function agent(ctx: Context, cwd: string | undefined): Agent {
     runMaintenance: task => task(new AbortController().signal),
     whenIdle: () => Promise.resolve(),
   }
-  ctx.agents.register(value)
+  await ctx.agents.register(value)
   return value
 }
 
@@ -309,7 +309,7 @@ async function setup(
   const stub = stubBackend(initialMode)
   ctx.terminals.registerBackend(stub.backend)
   const fiber = await ctx.plugin(ToolBashPersistent, config)
-  return { ctx, stub, fiber, owner: agent(ctx, '/workspace') }
+  return { ctx, stub, fiber, owner: await agent(ctx, '/workspace') }
 }
 
 describe('tool-bash-persistent', () => {
@@ -333,7 +333,7 @@ describe('tool-bash-persistent', () => {
     expect(stub.sessions).toHaveLength(1)
     expect(stub.sessions[0]?.sends).toBe(3)
 
-    const ownerWithoutCwd = agent(ctx, undefined)
+    const ownerWithoutCwd = await agent(ctx, undefined)
     expect(text(await call(ctx, ownerWithoutCwd, 'pwd'))).toBe('hello from stub\n[Command finished with exit code 0]')
     expect(stub.sessions).toHaveLength(2)
 
@@ -569,7 +569,7 @@ describe('tool-bash-persistent', () => {
       }),
     })
     const fiber = await ctx.plugin(ToolBashPersistent, { backendType: 'slow' })
-    const owner = agent(ctx, '/workspace')
+    const owner = await agent(ctx, '/workspace')
     const running = call(ctx, owner, 'pwd')
     await spawnStarted.promise
     await fiber.dispose()

+ 9 - 9
packages/shell/tool-bash/tests/tools.spec.ts

@@ -67,7 +67,7 @@ async function setupWithTasks() {
  * Build a fake {@link Agent} with the shared agent/session identity, give it a
  * dedicated lifecycle fiber for `Agent.ctx`, and register it in `ctx.agents`.
  */
-function registerFakeAgent(ctx: Context, sessionId: string, inject: (...args: unknown[]) => void = () => {}): Agent {
+async function registerFakeAgent(ctx: Context, sessionId: string, inject: (...args: unknown[]) => void = () => {}): Promise<Agent> {
   const scopeFiber = ctx.plugin(() => {})
   const id = SessionId(sessionId)
   const agent = {
@@ -76,7 +76,7 @@ function registerFakeAgent(ctx: Context, sessionId: string, inject: (...args: un
     inject,
     session: { id, header: { version: 0, id, createdAt: 0 } },
   } as unknown as Agent
-  ctx.agents.register(agent)
+  await ctx.agents.register(agent)
   return agent
 }
 let callCounter = 0
@@ -493,7 +493,7 @@ describe('background execution through the job runtime', () => {
   it('a background job started by an agent is registered with that agent as owner', async () => {
     // The producer must forward exec.agent as the job owner.
     const ctx = await setupWithTasks()
-    const agent = registerFakeAgent(ctx, 'sess-owner')
+    const agent = await registerFakeAgent(ctx, 'sess-owner')
     const started = await call(ctx, 'bash', { command: 'sleep 60', description: 'test command', run_in_background: true }, agent)
     expect(text(started)).toBe('started background job bash-1')
 
@@ -663,7 +663,7 @@ describe('sandbox escalation through the generic task producer', () => {
     const { ctx, bash } = await setupSandboxed(true)
     ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('allowed-once'))
     const agent = sandboxAgent(undefined, ctx)
-    ctx.agents.register(agent)
+    await ctx.agents.register(agent)
     const foreground = await ctx.tools.execute({
       callId: ToolCallId('sandbox-signal'),
       name: 'bash',
@@ -683,7 +683,7 @@ describe('sandbox escalation through the generic task producer', () => {
     const agent = sandboxAgent(undefined, ctx, (type) => {
       if (type === 'approval/decided') controller.abort()
     })
-    ctx.agents.register(agent)
+    await ctx.agents.register(agent)
     ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('allowed-once'))
     const start = vi.spyOn(bash, 'start')
 
@@ -1140,7 +1140,7 @@ describe('the model-facing bash tool builds its request from named args only (no
 
   it('injects built-ins and the stable session id into a foreground request', async () => {
     const { ctx, bash } = await setupRecording()
-    const agent = registerFakeAgent(ctx, 'request-fg', () => undefined)
+    const agent = await registerFakeAgent(ctx, 'request-fg', () => undefined)
     const ambient = process.env.DSH_SESSION_ID
 
     await ctx.tools.execute({
@@ -1161,7 +1161,7 @@ describe('the model-facing bash tool builds its request from named args only (no
 
   it('injects the same trusted variables into a background request without forwarding model env', async () => {
     const { ctx, bash } = await setupRecording()
-    const agent = registerFakeAgent(ctx, 'request-bg', () => undefined)
+    const agent = await registerFakeAgent(ctx, 'request-bg', () => undefined)
 
     await ctx.tools.execute({
       signal: testToolSignal,
@@ -1186,8 +1186,8 @@ describe('the model-facing bash tool builds its request from named args only (no
 
   it('keeps parent and child agent session environments isolated', async () => {
     const { ctx, bash } = await setupRecording()
-    const parent = registerFakeAgent(ctx, 'request-parent', () => undefined)
-    const child = registerFakeAgent(ctx, 'request-child', () => undefined)
+    const parent = await registerFakeAgent(ctx, 'request-parent', () => undefined)
+    const child = await registerFakeAgent(ctx, 'request-child', () => undefined)
 
     for (const [callId, agent] of [['parent', parent], ['child', child]] as const) {
       await ctx.tools.execute({

+ 3 - 3
packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts

@@ -45,7 +45,7 @@ class PassthroughSandbox extends SandboxProvider {
   }
 }
 
-function agent(ctx: Context, cwd: string): Agent {
+async function agent(ctx: Context, cwd: string): Promise<Agent> {
   const id = SessionId('persistent-pwsh-loader-agent')
   const scope = ctx.plugin(() => {})
   const session = Session.create(id, [], {
@@ -66,7 +66,7 @@ function agent(ctx: Context, cwd: string): Agent {
     runMaintenance: task => task(new AbortController().signal),
     whenIdle: () => Promise.resolve(),
   }
-  ctx.agents.register(value)
+  await ctx.agents.register(value)
   return value
 }
 
@@ -140,7 +140,7 @@ describe.skipIf(!hasPwsh)('persistent pwsh through a real cordis.yml Loader comp
     await context.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(configPath).href } })
     await context.loader.await()
 
-    const owner = agent(context, root)
+    const owner = await agent(context, root)
     const signal = new AbortController().signal
     const execute = (id: string, command: string) => context!.tools.execute({
       signal,

+ 5 - 5
packages/shell/tool-pwsh-persistent/tests/tools.spec.ts

@@ -27,7 +27,7 @@ afterEach(async () => {
   for (const ctx of contexts.splice(0)) await ctx.fiber.dispose()
 })
 
-function agent(ctx: Context, cwd: string | undefined): Agent {
+async function agent(ctx: Context, cwd: string | undefined): Promise<Agent> {
   const id = SessionId(`persistent-pwsh-owner-${callNumber}`)
   const scope = ctx.plugin(() => {})
   const session = Session.create(id, [], {
@@ -52,7 +52,7 @@ function agent(ctx: Context, cwd: string | undefined): Agent {
     runMaintenance: task => task(new AbortController().signal),
     whenIdle: () => Promise.resolve(),
   }
-  ctx.agents.register(value)
+  await ctx.agents.register(value)
   return value
 }
 
@@ -323,7 +323,7 @@ async function setup(
   const stub = stubBackend(initialMode)
   ctx.terminals.registerBackend(stub.backend)
   const fiber = await ctx.plugin(ToolPwshPersistent, config)
-  return { ctx, stub, fiber, owner: agent(ctx, '/workspace') }
+  return { ctx, stub, fiber, owner: await agent(ctx, '/workspace') }
 }
 
 describe('tool-pwsh-persistent', () => {
@@ -347,7 +347,7 @@ describe('tool-pwsh-persistent', () => {
     expect(stub.sessions).toHaveLength(1)
     expect(stub.sessions[0]?.sends).toBe(3)
 
-    const ownerWithoutCwd = agent(ctx, undefined)
+    const ownerWithoutCwd = await agent(ctx, undefined)
     expect(text(await call(ctx, ownerWithoutCwd, 'pwd'))).toBe('hello from stub')
     expect(stub.sessions).toHaveLength(2)
 
@@ -603,7 +603,7 @@ describe('tool-pwsh-persistent', () => {
       }),
     })
     const fiber = await ctx.plugin(ToolPwshPersistent, { backendType: 'slow' })
-    const owner = agent(ctx, '/workspace')
+    const owner = await agent(ctx, '/workspace')
     const running = call(ctx, owner, 'pwd')
     await spawnStarted.promise
     await fiber.dispose()

+ 8 - 8
packages/shell/tool-pwsh/tests/tools.spec.ts

@@ -289,7 +289,7 @@ function sandboxAgent(
  * The fake session carries an empty event log (the sandbox-policy resolver
  * folds the log for mode overrides, mirroring a real session).
  */
-function registerFakeAgent(ctx: Context, sessionId: string): Agent {
+async function registerFakeAgent(ctx: Context, sessionId: string): Promise<Agent> {
   const scopeFiber = ctx.plugin(() => {})
   const id = SessionId(sessionId)
   const agent = {
@@ -305,7 +305,7 @@ function registerFakeAgent(ctx: Context, sessionId: string): Agent {
       snapshotEvents: () => [],
     },
   } as unknown as Agent
-  ctx.agents.register(agent)
+  await ctx.agents.register(agent)
   return agent
 }
 
@@ -397,7 +397,7 @@ describe('execution through the bash seam', () => {
     tempDirs.push(dshHome)
     const { ctx, bash } = await setup({}, dshHome)
     bash.handler = () => runResult('hi\n')
-    const agent = registerFakeAgent(ctx, 'session-1')
+    const agent = await registerFakeAgent(ctx, 'session-1')
     Object.assign(agent.session.header, { cwd: '/sessions/s1' })
     const result = await call(ctx, 'pwsh', {
       command: 'Write-Output hi',
@@ -420,7 +420,7 @@ describe('execution through the bash seam', () => {
   it('resolves a relative workdir against the session cwd, absolute ones verbatim', async () => {
     const { ctx, bash } = await setup()
     bash.handler = () => runResult('ok\n')
-    const agent = registerFakeAgent(ctx, 'session-cwd')
+    const agent = await registerFakeAgent(ctx, 'session-cwd')
     Object.assign(agent.session.header, { cwd: '/sessions/s1' })
     await call(ctx, 'pwsh', { command: 'pwd', description: 'cwd', workdir: 'sub/dir' }, agent)
     expect(bash.requests[0]?.workdir).toBe(resolvePath('/sessions/s1', 'sub/dir'))
@@ -546,7 +546,7 @@ describe('per-call sandbox policy resolution', () => {
     const { ctx, bash } = await setupSandboxed()
     const sessionCwd = mkdtempSync(join(tmpdir(), 'dsh-tool-pwsh-policy-'))
     tempDirs.push(sessionCwd)
-    const agent = registerFakeAgent(ctx, 'policy-session')
+    const agent = await registerFakeAgent(ctx, 'policy-session')
     Object.assign(agent.session.header, { cwd: sessionCwd })
     const result = await call(ctx, 'pwsh', { command: 'Write-Output hi', description: 'say hi' }, agent)
     expect(result.isError).toBe(false)
@@ -668,7 +668,7 @@ describe('sandbox escalation through ctx.approval', () => {
     const { ctx, bash } = await setupSandboxed(true)
     ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('allowed-once'))
     const agent = sandboxAgent(undefined, ctx)
-    ctx.agents.register(agent)
+    await ctx.agents.register(agent)
     const foreground = await ctx.tools.execute({
       callId: ToolCallId('sandbox-signal'),
       name: 'pwsh',
@@ -688,7 +688,7 @@ describe('sandbox escalation through ctx.approval', () => {
     const agent = sandboxAgent(undefined, ctx, (type) => {
       if (type === 'approval/decided') controller.abort()
     })
-    ctx.agents.register(agent)
+    await ctx.agents.register(agent)
     ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('allowed-once'))
     const start = vi.spyOn(bash, 'start')
 
@@ -771,7 +771,7 @@ describe('background execution through the job runtime', () => {
 
   it('a background job started by an agent is registered with that agent as owner', async () => {
     const { ctx } = await setupWithTasks()
-    const agent = registerFakeAgent(ctx, 'sess-owner')
+    const agent = await registerFakeAgent(ctx, 'sess-owner')
     const started = await call(ctx, 'pwsh', { command: 'Start-Sleep -Seconds 60', description: 'test command', run_in_background: true }, agent)
     expect(text(started)).toBe('started background job pwsh-1')
 

+ 7 - 4
packages/subagent/tool-subagent/src/index.ts

@@ -661,8 +661,10 @@ export function apply(ctx: Context, config: Config, session?: Session): void {
   const installing = new WeakSet<Agent>()
   const belongsToComposition = (candidate: Agent): boolean =>
     scopeChainOf(scopeOf(candidate.ctx)).includes(compositionScope)
-  const installScoped = (candidate: Agent): void => {
-    if (scopedInstalls.has(candidate) || installing.has(candidate)) return
+  const installScoped = (candidate: Agent): ReturnType<Context['inject']> | undefined => {
+    const existing = scopedInstalls.get(candidate)
+    if (existing !== undefined) return existing
+    if (installing.has(candidate)) return
     // Reserve before the injected fiber runs: tool registration emits
     // `tools/change` synchronously, which re-enters the reconciliation below.
     installing.add(candidate)
@@ -676,6 +678,7 @@ export function apply(ctx: Context, config: Config, session?: Session): void {
       installing.delete(candidate)
     }
     scopedInstalls.set(candidate, fiber)
+    return fiber
   }
   const removeScoped = (candidate: Agent): void => {
     const fiber = scopedInstalls.get(candidate)
@@ -695,8 +698,8 @@ export function apply(ctx: Context, config: Config, session?: Session): void {
   // The preset-scoped listener admits descendant Agents and installs the
   // sampled tool definition in each Agent's own scope, so a later settings
   // change cannot mutate a live session.
-  ctx.on('agent/created', ({ agent: created }) => {
-    installScoped(created)
+  ctx.on('agent/created', async ({ agent: created }) => {
+    await installScoped(created)
   })
   ctx.on('agent/disposed', ({ agent: disposed }) => { removeScoped(disposed) })
   // Reparenting an Agent between standing presets changes its inherited tool

+ 12 - 12
packages/subagent/tool-subagent/tests/tool-subagent.spec.ts

@@ -801,7 +801,7 @@ describe('dsh-tool-subagent', () => {
 
 describe('dsh-tool-subagent background mode', () => {
   /** A live parent with a dedicated scope fiber for structural task cleanup. */
-  function ownerAgent(ctx: Context, sessionId: string, inject: (...args: unknown[]) => void = () => {}): Agent {
+  async function ownerAgent(ctx: Context, sessionId: string, inject: (...args: unknown[]) => void = () => {}): Promise<Agent> {
     const scopeFiber = ctx.plugin(() => {})
     const id = SessionId(sessionId)
     const agent = {
@@ -811,7 +811,7 @@ describe('dsh-tool-subagent background mode', () => {
       options: {},
       session: Session.create(id),
     } as unknown as Agent
-    ctx.agents.register(agent)
+    await ctx.agents.register(agent)
     return agent
   }
 
@@ -825,7 +825,7 @@ describe('dsh-tool-subagent background mode', () => {
 
   it('keeps a continuable-capable provider one-shot when backgroundMode selects one-shot', async () => {
     const ctx = await backgroundSetup({ provider: 'mock' })
-    const parent = ownerAgent(ctx, 'sess-parent')
+    const parent = await ownerAgent(ctx, 'sess-parent')
     let prepareCalls = 0
     ctx.subagents.registerProvider({
       name: 'resumable',
@@ -866,7 +866,7 @@ describe('dsh-tool-subagent background mode', () => {
 
   it('returns a job id immediately and the answer is collected through job_output', async () => {
     const ctx = await backgroundSetup({ provider: 'mock' }, { reply: 'background answer' })
-    const parent = ownerAgent(ctx, 'sess-parent')
+    const parent = await ownerAgent(ctx, 'sess-parent')
 
     const start = await callSubagent(ctx, { description: 'deep research', prompt: 'dig in', run_in_background: true }, { agent: parent })
     expect(start.isError).toBe(false)
@@ -900,7 +900,7 @@ describe('dsh-tool-subagent background mode', () => {
       diagnostic: 'Claude Code cancelled an unattended dialog',
       stopReason: 'error',
     })
-    const parent = ownerAgent(ctx, 'sess-parent')
+    const parent = await ownerAgent(ctx, 'sess-parent')
 
     const started = await ctx.tools.execute({
       signal: testToolSignal,
@@ -933,7 +933,7 @@ describe('dsh-tool-subagent background mode', () => {
 
   it('skips background startup when the tool signal is already aborted', async () => {
     const ctx = await backgroundSetup({ provider: 'mock' })
-    const parent = ownerAgent(ctx, 'sess-parent')
+    const parent = await ownerAgent(ctx, 'sess-parent')
     const controller = new AbortController()
     controller.abort()
     const result = await callSubagent(ctx, { description: 'd', prompt: 'p', run_in_background: true }, { agent: parent, signal: controller.signal })
@@ -950,7 +950,7 @@ describe('dsh-tool-subagent background mode', () => {
       provider: 'mock',
       agentOptions: { provider: 'alpha', model: 'selected-model' },
     })
-    const parent = ownerAgent(ctx, 'sess-parent')
+    const parent = await ownerAgent(ctx, 'sess-parent')
     const adapter = new MockAdapter([])
     let releasePreflight!: () => void
     const preflightGate = new Promise<void>((resolve) => { releasePreflight = resolve })
@@ -1021,7 +1021,7 @@ describe('dsh-tool-subagent background mode', () => {
 
   it('settles an asynchronous provider-start failure as a failed task', async () => {
     const ctx = await backgroundSetup({ provider: 'mock' })
-    const parent = ownerAgent(ctx, 'sess-parent')
+    const parent = await ownerAgent(ctx, 'sess-parent')
     ctx.subagents.registerProvider({
       name: 'broken-start',
       capabilities: { agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
@@ -1050,7 +1050,7 @@ describe('dsh-tool-subagent background mode', () => {
 
   it('kills a subagent task while provider readiness is still pending', async () => {
     const ctx = await backgroundSetup({ provider: 'mock' })
-    const parent = ownerAgent(ctx, 'sess-parent')
+    const parent = await ownerAgent(ctx, 'sess-parent')
     ctx.subagents.registerProvider({
       name: 'pending-start',
       capabilities: { agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
@@ -1087,7 +1087,7 @@ describe('dsh-tool-subagent background mode', () => {
 
   it('reports startup rollback failure after cancellation as a failed job', async () => {
     const ctx = await backgroundSetup({ provider: 'mock' })
-    const parent = ownerAgent(ctx, 'sess-parent')
+    const parent = await ownerAgent(ctx, 'sess-parent')
     ctx.subagents.registerProvider({
       name: 'broken-start-rollback',
       capabilities: { agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
@@ -1130,7 +1130,7 @@ describe('dsh-tool-subagent background mode', () => {
   it('forwards job_kill reasons through the run signal (and defaults one when absent)', async () => {
     // Use a provider that remains live until its signal is aborted.
     const ctx = await backgroundSetup({ provider: 'mock', agentOptions: { model: 'child-model' } })
-    const parent = ownerAgent(ctx, 'sess-parent')
+    const parent = await ownerAgent(ctx, 'sess-parent')
     const cancels: (string | undefined)[] = []
     let starts = 0
     ctx.subagents.registerProvider({
@@ -1354,7 +1354,7 @@ describe('background preflight failure (no orphaned child, by construction)', ()
       options: {},
       session: Session.create(id),
     } as unknown as Agent
-    ctx.agents.register(parent)
+    await ctx.agents.register(parent)
 
     let starts = 0
     ctx.subagents.registerProvider({

+ 2 - 2
packages/terminal/terminal-bash/tests/index.spec.ts

@@ -602,7 +602,7 @@ describe('terminal-bash plugin shape', () => {
       runMaintenance: task => task(new AbortController().signal),
       whenIdle: () => Promise.resolve(),
     }
-    ctx.agents.register(owner)
+    await ctx.agents.register(owner)
     const providerFiber = await registerStubLocalBackend(ctx, () => stubLocalSession())
     const created = await ctx.terminals.spawn(owner, { type: 'stub' })
 
@@ -652,7 +652,7 @@ describe('terminal-bash plugin shape', () => {
       runMaintenance: task => task(new AbortController().signal),
       whenIdle: () => Promise.resolve(),
     }
-    ctx.agents.register(owner)
+    await ctx.agents.register(owner)
     const gate = Promise.withResolvers<undefined>()
     await registerStubLocalBackend(ctx, () => stubLocalSession(() => gate.promise))
     const spawning = ctx.terminals.spawn(owner, { type: 'stub' })

+ 1 - 1
packages/terminal/terminal-bash/tests/local.spec.ts

@@ -79,7 +79,7 @@ async function harness(
     maxReadBytes: 16_384,
   })
   const agent = stubAgent(ctx, `agent-${mode}`)
-  ctx.agents.register(agent)
+  await ctx.agents.register(agent)
   return { ctx, root, agent, fiber, sandbox: ctx.sandbox as PassthroughSandbox }
 }
 

+ 21 - 21
packages/terminal/terminal/tests/service.spec.ts

@@ -160,8 +160,8 @@ describe('TerminalSessionService ownership and lifecycle', () => {
     ctx.terminals.registerBackend(b.provider)
     const owner = stubAgent(ctx, 'owner')
     const foreign = stubAgent(ctx, 'foreign')
-    ctx.agents.register(owner)
-    ctx.agents.register(foreign)
+    await ctx.agents.register(owner)
+    await ctx.agents.register(foreign)
 
     const created = await ctx.terminals.spawn(owner, { type: 'stub', name: 'main', cwd: '/tmp' })
     expect(created).toMatchObject({ sessionId: 'pty-1', name: 'main', type: 'stub', pid: 123, motd: 'stub ready', status: { kind: 'running' } })
@@ -177,7 +177,7 @@ describe('TerminalSessionService ownership and lifecycle', () => {
     const ctx = await harness()
     const owner = stubAgent(ctx, 'owner')
     await expect(ctx.terminals.spawn(owner, { type: 'missing' })).rejects.toMatchObject({ code: 'OWNER_NOT_LIVE' })
-    ctx.agents.register(owner)
+    await ctx.agents.register(owner)
     await expect(ctx.terminals.spawn(owner, { type: 'missing' })).rejects.toMatchObject({ code: 'NO_BACKEND' })
     const b = backend()
     ctx.terminals.registerBackend(b.provider)
@@ -209,7 +209,7 @@ describe('TerminalSessionService ownership and lifecycle', () => {
     const session = new StubSession()
     ctx.terminals.registerBackend({ type: 'slow', spawn: () => gate.promise })
     const owner = stubAgent(ctx, 'owner')
-    ctx.agents.register(owner)
+    await ctx.agents.register(owner)
     const pending = ctx.terminals.spawn(owner, { type: 'slow', name: 'main' })
     await expect(ctx.terminals.spawn(owner, { type: 'slow', name: 'main' })).rejects.toMatchObject({ code: 'DUPLICATE_NAME' })
     const disposal = disposeAgentScope(owner)
@@ -225,7 +225,7 @@ describe('TerminalSessionService ownership and lifecycle', () => {
     const session = new StubSession()
     ctx.terminals.registerBackend({ type: 'slow', spawn: () => gate.promise })
     const owner = stubAgent(ctx, 'owner')
-    ctx.agents.register(owner)
+    await ctx.agents.register(owner)
     const controller = new AbortController()
     const reason = new Error('cancelled by caller')
 
@@ -245,7 +245,7 @@ describe('TerminalSessionService ownership and lifecycle', () => {
     session.rejectClose = true
     ctx.terminals.registerBackend({ type: 'slow', spawn: () => gate.promise })
     const owner = stubAgent(ctx, 'owner')
-    ctx.agents.register(owner)
+    await ctx.agents.register(owner)
     const controller = new AbortController()
     const reason = new Error('cancelled by caller')
 
@@ -274,7 +274,7 @@ describe('TerminalSessionService ownership and lifecycle', () => {
       }),
     })
     const owner = stubAgent(ctx, 'owner')
-    ctx.agents.register(owner)
+    await ctx.agents.register(owner)
     const controller = new AbortController()
     const reason = new Error('cancelled by caller')
 
@@ -300,7 +300,7 @@ describe('TerminalSessionService ownership and lifecycle', () => {
       }),
     })
     const owner = stubAgent(ctx, 'owner')
-    ctx.agents.register(owner)
+    await ctx.agents.register(owner)
     const controller = new AbortController()
     const reason = new Error('cancelled by caller')
 
@@ -337,7 +337,7 @@ describe('TerminalSessionService ownership and lifecycle', () => {
       },
     })
     const owner = stubAgent(ctx, 'owner')
-    ctx.agents.register(owner)
+    await ctx.agents.register(owner)
 
     const pending = ctx.terminals.spawn(owner, { type: 'slow' })
     const pendingFailure = pending.then(
@@ -369,7 +369,7 @@ describe('TerminalSessionService ownership and lifecycle', () => {
     session.rejectClose = true
     ctx.terminals.registerBackend({ type: 'slow', spawn: () => gate.promise })
     const owner = stubAgent(ctx, 'owner')
-    ctx.agents.register(owner)
+    await ctx.agents.register(owner)
 
     const pending = ctx.terminals.spawn(owner, { type: 'slow' })
     const pendingFailure = expect(pending).rejects.toThrow('PTY spawn and rollback both failed')
@@ -402,7 +402,7 @@ describe('TerminalSessionService ownership and lifecycle', () => {
       }),
     })
     const owner = stubAgent(ctx, 'owner')
-    ctx.agents.register(owner)
+    await ctx.agents.register(owner)
 
     const pending = ctx.terminals.spawn(owner, { type: 'cleanup-failing' })
     await started.promise
@@ -438,7 +438,7 @@ describe('TerminalSessionService ownership and lifecycle', () => {
       spawn: () => ++count === 1 ? firstGate.promise : secondGate.promise,
     })
     const owner = stubAgent(ctx, 'owner')
-    ctx.agents.register(owner)
+    await ctx.agents.register(owner)
     const first = ctx.terminals.spawn(owner, { type: 'slow', name: 'one' })
     const second = ctx.terminals.spawn(owner, { type: 'slow', name: 'two' })
     firstGate.resolve(new StubSession())
@@ -458,7 +458,7 @@ describe('TerminalSessionService ownership and lifecycle', () => {
   it('omits optional pid metadata when a backend has no process id', async () => {
     const ctx = await harness()
     const owner = stubAgent(ctx, 'owner')
-    ctx.agents.register(owner)
+    await ctx.agents.register(owner)
     const session = new StubSession()
     Object.defineProperty(session, 'pid', { value: undefined })
     ctx.terminals.registerBackend({ type: 'virtual', spawn: () => Promise.resolve(session) })
@@ -468,7 +468,7 @@ describe('TerminalSessionService ownership and lifecycle', () => {
   it('reports rollback and close failures without publishing false success', async () => {
     const ctx = await harness()
     const owner = stubAgent(ctx, 'owner')
-    ctx.agents.register(owner)
+    await ctx.agents.register(owner)
     const failedSpawn = new StubSession()
     failedSpawn.rejectClose = true
     let ownerDisposal = Promise.resolve()
@@ -494,7 +494,7 @@ describe('TerminalSessionService ownership and lifecycle', () => {
     await expect(ownerDisposal).rejects.toThrow('failed to clean up PTY lifecycle')
 
     const nextOwner = stubAgent(ctx, 'next')
-    ctx.agents.register(nextOwner)
+    await ctx.agents.register(nextOwner)
     const b = backend('bad-close')
     ctx.terminals.registerBackend(b.provider)
     const created = await ctx.terminals.spawn(nextOwner, { type: 'bad-close' })
@@ -506,7 +506,7 @@ describe('TerminalSessionService ownership and lifecycle', () => {
   it('joins an already-running close and refuses new sends while closing', async () => {
     const ctx = await harness()
     const owner = stubAgent(ctx, 'owner')
-    ctx.agents.register(owner)
+    await ctx.agents.register(owner)
     const b = backend()
     ctx.terminals.registerBackend(b.provider)
     const created = await ctx.terminals.spawn(owner, { type: 'stub' })
@@ -525,7 +525,7 @@ describe('TerminalSessionService ownership and lifecycle', () => {
     const b = backend()
     const disposeBackend = ctx.terminals.registerBackend(b.provider)
     const owner = stubAgent(ctx, 'owner')
-    ctx.agents.register(owner)
+    await ctx.agents.register(owner)
     const created = await ctx.terminals.spawn(owner, { type: 'stub' })
     disposeBackend()
     expect(ctx.terminals.listBackends()).toEqual([])
@@ -542,8 +542,8 @@ describe('TerminalSessionService ownership and lifecycle', () => {
     ctx.terminals.registerBackend(b.provider)
     const first = stubAgent(ctx, 'first')
     const second = stubAgent(ctx, 'second')
-    ctx.agents.register(first)
-    ctx.agents.register(second)
+    await ctx.agents.register(first)
+    await ctx.agents.register(second)
     const a = await ctx.terminals.spawn(first, { type: 'stub' })
     await ctx.terminals.spawn(second, { type: 'stub' })
     expect(await ctx.terminals.kill(first, a.sessionId)).toBe(true)
@@ -561,7 +561,7 @@ describe('TerminalSessionService ownership and lifecycle', () => {
     const b = backend()
     ctx.terminals.registerBackend(b.provider)
     const owner = stubAgent(ctx, 'owner')
-    ctx.agents.register(owner)
+    await ctx.agents.register(owner)
     await ctx.terminals.spawn(owner, { type: 'stub' })
     b.sessions[0]!.rejectClose = true
     const internal = service as unknown as {
@@ -587,7 +587,7 @@ describe('TerminalSessionService ownership and lifecycle', () => {
     const b = backend()
     service.registerBackend(b.provider)
     const owner = stubAgent(ctx, 'owner')
-    ctx.agents.register(owner)
+    await ctx.agents.register(owner)
     await service.spawn(owner, { type: 'stub' })
     b.sessions[0]!.rejectClose = true
     const internal = service as unknown as {

+ 3 - 3
packages/terminal/tool-terminal/tests/loader-composition.spec.ts

@@ -38,7 +38,7 @@ class PassthroughSandbox extends SandboxProvider {
   }
 }
 
-function agent(ctx: Context): Agent {
+async function agent(ctx: Context): Promise<Agent> {
   const scope = ctx.plugin(() => {})
   const id = SessionId('pty-loader-agent')
   const session = Session.create(id)
@@ -51,7 +51,7 @@ function agent(ctx: Context): Agent {
     runMaintenance: job => job(new AbortController().signal),
     whenIdle: () => Promise.resolve(),
   }
-  ctx.agents.register(value)
+  await ctx.agents.register(value)
   return value
 }
 
@@ -115,7 +115,7 @@ suite('terminal real Loader composition through cordis.yml', () => {
     await context.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(configPath).href } })
     await context.loader.await()
 
-    const owner = agent(context)
+    const owner = await agent(context)
     const signal = new AbortController().signal
     const spawn = await context.tools.execute({
       signal, callId: ToolCallId('spawn'), name: 'terminal_open', arguments: { type: 'shell', name: 'main', cwd: root }, agent: owner,

+ 3 - 3
packages/terminal/tool-terminal/tests/tools.spec.ts

@@ -14,7 +14,7 @@ import * as ToolTasks from '@deepseek-ai/dsh-tool-jobs'
 import * as ToolPty from '@deepseek-ai/dsh-tool-terminal'
 import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit'
 
-function fakeAgent(ctx: Context, rawId: string): Agent {
+async function fakeAgent(ctx: Context, rawId: string): Promise<Agent> {
   const scope = ctx.plugin(() => {})
   const id = SessionId(rawId)
   const session = Session.create(id)
@@ -27,7 +27,7 @@ function fakeAgent(ctx: Context, rawId: string): Agent {
     runMaintenance: job => job(new AbortController().signal),
     whenIdle: () => Promise.resolve(),
   }
-  ctx.agents.register(agent)
+  await ctx.agents.register(agent)
   return agent
 }
 
@@ -116,7 +116,7 @@ async function setupBase(jobs: boolean) {
     await ctx.plugin(LocalJobRegistry)
     await ctx.plugin(ToolTasks)
   }
-  return { ctx, stub, agent: fakeAgent(ctx, jobs ? 'with-tasks' : 'foreground') }
+  return { ctx, stub, agent: await fakeAgent(ctx, jobs ? 'with-tasks' : 'foreground') }
 }
 
 let callNumber = 0

+ 4 - 4
packages/todo/tool-todo/tests/loader-composition.spec.ts

@@ -29,7 +29,7 @@ afterEach(async () => {
   root = undefined
 })
 
-function agent(ctx: Context): Agent {
+async function agent(ctx: Context): Promise<Agent> {
   const scope = ctx.plugin(() => {})
   const id = SessionId('todo-loader-agent')
   const session = Session.create(id)
@@ -40,7 +40,7 @@ function agent(ctx: Context): Agent {
     runMaintenance: task => task(new AbortController().signal),
     whenIdle: () => Promise.resolve(),
   }
-  ctx.agents.register(value)
+  await ctx.agents.register(value)
   return value
 }
 
@@ -102,7 +102,7 @@ describe('tool-todo real Loader composition through cordis.yml', () => {
     expect(description).toContain('Keep AT MOST ONE todo `in_progress`')
     expect(description).not.toContain('several at once')
 
-    const owner = agent(ctx)
+    const owner = await agent(ctx)
     const result = await ctx.tools.execute({
       signal: new AbortController().signal,
       callId: ToolCallId('parallel'),
@@ -120,7 +120,7 @@ describe('tool-todo real Loader composition through cordis.yml', () => {
     const description = ctx.tools.schemas().find(s => s.name === 'todo_write')?.description ?? ''
     expect(description).toContain('several at once when work genuinely runs in parallel')
 
-    const owner = agent(ctx)
+    const owner = await agent(ctx)
     const result = await ctx.tools.execute({
       signal: new AbortController().signal,
       callId: ToolCallId('parallel-enabled'),

+ 1 - 1
packages/todo/tool-todo/tests/projection.spec.ts

@@ -37,7 +37,7 @@ async function harness(withTodoTool: boolean): Promise<Bench> {
   await ctx.plugin(SessionProjectionRegistry)
   if (withTodoTool) await ctx.plugin(ToolTodo, { allowParallelInProgress: true })
   const session = ctx.sessions.create()
-  ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
+  await ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
   return {
     ctx,
     session,

+ 2 - 2
scripts/gen-tool-catalog.ts

@@ -541,7 +541,7 @@ const TOOL_PACKAGES: ToolPackage[] = [
         tryMembership: (candidate: Agent) => candidate === agent ? membership : undefined,
         membership: () => membership,
       } as unknown as TeamService)
-      await ctx.plugin(Object.assign((inner: Context) => {
+      await ctx.plugin(Object.assign(async (inner: Context) => {
         agent = {
           id: session.id,
           session,
@@ -549,7 +549,7 @@ const TOOL_PACKAGES: ToolPackage[] = [
           status: 'idle',
         } as unknown as Agent
         Object.assign(agent, { ctx: createScope(inner, agent).ctx })
-        inner.agents.register(agent)
+        await inner.agents.register(agent)
       }, { inject: ['tools', 'systemPrompt', 'agents', 'agentTeams'] }))
       await ctx.plugin(ToolTeam)
       catalogChildScopes.set(ctx, agent)

+ 7 - 1
scripts/smoke-python-runtime.py

@@ -1301,13 +1301,19 @@ def smoke_sdk_snapshot(base_url: str, executable: Path, update_snapshots: bool)
                 Path(__file__).resolve().parent.parent / "snapshots/sdk/text-turn/feedback-producer.mjs"
             ).as_uri()},
         ]}])
+        creation_patch = write_profile_patch(root, "creation.patch.yml", sessions, [
+            {"insert": [{
+                "id": "serial-created-fixture",
+                "name": str(Path(__file__).resolve().parents[1] / "packages/core/agent-loop/tests/fixtures/serial-created.mjs"),
+            }]},
+        ])
         with DeepSeekHarness(
             provider="deepseek-official",
             model="smoke-model",
             cwd=str(root),
             dsh_bin=str(executable),
             dsh_home=str(dsh_home),
-            patches=(str(patch), str(feedback_patch)),
+            patches=(str(patch), str(feedback_patch), str(creation_patch)),
             env={
                 "DSH_PERMISSION_MODE": "danger-full-access",
                 "DSH_TELEMETRY_DISABLED": "1",

+ 20 - 0
snapshots/sdk/serial-created/cordis.snapshot.yml

@@ -0,0 +1,20 @@
+# Keyless TypeScript SDK replay patch over the live profile patch.
+
+- id: llm-deepseek
+  name: '@deepseek-ai/dsh-llm-deepseek'
+  disabled: true
+
+- insert:
+    - id: llm-replay
+      name: '@deepseek-ai/dsh-llm-replay'
+      config:
+        providers:
+          - id: deepseek-official
+            name: DeepSeek
+            models:
+              - id: deepseek-v4-flash
+
+- id: session-log-deepseek
+  name: '@deepseek-ai/dsh-session-log-deepseek'
+  config:
+    enabled: true

+ 38 - 0
snapshots/sdk/serial-created/cordis.yml

@@ -0,0 +1,38 @@
+# TypeScript SDK snapshot-record patch over `dsh --profile sdk`.
+
+# The fixture corpus must not depend on the developer's personal skill roots.
+# Ordinary product launches retain the sdk profile's default skill discovery.
+- id: skill-filesystem
+  name: '@deepseek-ai/dsh-skill-filesystem'
+  config:
+    includeDefaultRoots: false
+
+- id: llm-deepseek
+  name: '@deepseek-ai/dsh-llm-deepseek'
+  config:
+    thinking: enabled
+    reasoningEffort: max
+
+- id: session-persistence-jsonl
+  name: '@deepseek-ai/dsh-session-persistence-jsonl'
+  config:
+    root: !!js dshHomePath('sessions')
+    compression: !!js "process.env.DSH_SNAPSHOT === undefined ? 'zstd' : 'none'"
+
+- id: tool-subagent
+  name: '@deepseek-ai/dsh-tool-subagent'
+  config:
+    provider: spawn
+    toolName: subagent
+    backgroundMode: one-shot
+    enableRunInBackground: false
+    maxDepth: 1
+
+- id: session-log-deepseek
+  name: '@deepseek-ai/dsh-session-log-deepseek'
+  config:
+    enabled: true
+
+- insert:
+    - id: serial-created-fixture
+      name: '../../../packages/core/agent-loop/tests/fixtures/serial-created.mjs'

+ 25 - 0
snapshots/sdk/serial-created/session.jsonl

@@ -0,0 +1,25 @@
+{"type":"session","version":0,"id":"{{session:1}}","createdAt":1785097381464,"cwd":"{{cwd}}","delegationDepth":0}
+{"type":"permission/preset","data":{"preset":"workspace-write"}}
+{"type":"sandbox/mode","data":{"mode":"workspace-write"}}
+{"type":"approval/policy","data":{"policy":"ask"}}
+{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly: SDK snapshot OK"}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"}]}}
+{"type":"turn/start","data":{"turn":1}}
+{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
+{"type":"step/start","data":{"turn":1,"step":1}}
+{"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly: SDK snapshot OK"}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"}
+{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{message:2}}"},"surfaceOp":"append"}
+{"type":"session/title","data":{"title":"Reply with exactly: SDK snapshot","messageSeqs":[7],"source":{"kind":"fallback"}}}
+{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
+{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}
+{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{session:1}}","throughSeq":11}}
+{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
+{"type":"reasoning-chunks","data":{"turn":1,"step":1,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," \"","SD","K"," snapshot"," OK","\"."," Let"," me"," do"," that","."]}}
+{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}}
+{"type":"text-chunks","data":{"turn":1,"step":1,"index":1,"dt":[0,0,0],"texts":["SD","K"," snapshot"," OK"]}}
+{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly \"SDK snapshot OK\". Let me do that."}}}}
+{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"SDK snapshot OK"}}}}
+{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":1769,"outputTokens":24,"cacheReadTokens":0,"reasoningTokens":19}}}}
+{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
+{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with exactly \"SDK snapshot OK\". Let me do that."},{"type":"text","text":"SDK snapshot OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:3}}"},"usage":{"inputTokens":1769,"outputTokens":24,"cacheReadTokens":0,"reasoningTokens":19}},"sourceEventSeqs":[13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41],"surfaceOp":"append"}
+{"type":"step/end","data":{"turn":1,"step":1}}
+{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}}

+ 8 - 0
snapshots/sdk/serial-created/snapshot.yml

@@ -0,0 +1,8 @@
+version: 1
+scenario: serial-created
+profile: sdk
+composition: sdk-serial-created
+recording: authored
+header:
+  class: sdk-serial-created
+  pin: true