diff --git a/packages/agent-core-v2/docs/di-scope-domains.puml b/packages/agent-core-v2/docs/di-scope-domains.puml
index 8724a3df3..18c141a22 100644
--- a/packages/agent-core-v2/docs/di-scope-domains.puml
+++ b/packages/agent-core-v2/docs/di-scope-domains.puml
@@ -57,6 +57,7 @@ package "Session scope (per session)" #EAFAF1 {
rectangle "workspaceCommand\nSession\n ISessionWorkspaceCommandService" as workspaceCommand #D5F5E3
rectangle "sessionLog\nSession binding\n ILogService" as sessionLog #D5F5E3
rectangle "sessionSkillCatalog\nSession\n ISessionSkillCatalog\n ISkillCatalogSink\n Workspace/PluginSkillSource" as sessionSkillCatalog #D5F5E3
+ rectangle "externalHooks\nSession\n ISessionExternalHooksService" as sessionExternalHooks #D5F5E3
rectangle "sessionFs\nSession\n ISessionFsService" as sessionFs #D5F5E3
rectangle "approval\nSession\n IApprovalService" as approval #D5F5E3
rectangle "question\nSession\n IQuestionService" as question #D5F5E3
@@ -298,6 +299,10 @@ externalHooks --> config #34495E
externalHooks --> bootstrap #34495E
externalHooks --> plugin #34495E
externalHooks --> contextMemory #34495E
+sessionExternalHooks --> session_lifecycle #34495E
+sessionExternalHooks --> config #34495E
+sessionExternalHooks --> plugin #34495E
+sessionExternalHooks --> session_context #34495E
todo --> agent_lifecycle #34495E
usage --> wire #34495E
rpc --> prompt #34495E
@@ -368,7 +373,8 @@ externalHooks ..> turn #16A085 : prompt/end hooks
externalHooks ..> loop #16A085 : stop hook
externalHooks ..> fullCompaction #16A085 : compaction hooks
externalHooks ..> task #16A085 : notification hook
-agent_lifecycle ..> externalHooks #16A085 : SubagentStart/Stop (mirrorAgentRun)
+sessionExternalHooks ..> session_lifecycle #16A085 : SessionStart/End hooks
+externalHooks ..> agent_lifecycle #16A085 : SubagentStart/Stop (observes run hooks)
toolState ..> wire #16A085 : tools.update_store
usage ..> wire #16A085 : usage.record
turn ..> wire #16A085 : turn.launch
diff --git a/packages/agent-core-v2/docs/di-scope-domains.svg b/packages/agent-core-v2/docs/di-scope-domains.svg
index 12d7edce1..307c10bc0 100644
--- a/packages/agent-core-v2/docs/di-scope-domains.svg
+++ b/packages/agent-core-v2/docs/di-scope-domains.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/packages/agent-core-v2/scripts/check-domain-layers.mjs b/packages/agent-core-v2/scripts/check-domain-layers.mjs
index b0686eb22..fa1ff8807 100644
--- a/packages/agent-core-v2/scripts/check-domain-layers.mjs
+++ b/packages/agent-core-v2/scripts/check-domain-layers.mjs
@@ -165,13 +165,13 @@ const DOMAIN_LAYER = new Map([
['agentTask', 5],
['mcp', 5],
['cron', 5],
- ['externalHooks', 5],
// `btw` forks a single side-question sub-agent via `agentLifecycle`,
// parallel to how the `Agent` tool spawns child agents. Agent-scope, L5.
['btw', 5],
// L6 — coordination
['agentLifecycle', 6],
['sessionLifecycle', 6],
+ ['externalHooks', 6],
['sessionExport', 6],
['interaction', 6],
['sessionMetadata', 6],
diff --git a/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts b/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts
index d853e1064..46c0d3054 100644
--- a/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts
+++ b/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts
@@ -5,7 +5,6 @@ import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { IAgentContextMemoryService } from '#/agent/contextMemory';
import { IAgentLoopService } from '#/agent/loop';
import { IAgentSystemReminderService } from '#/agent/systemReminder';
-import { IAgentTurnService } from '#/agent/turn';
import { IEventBus } from '#/app/event/eventBus';
import type { ContextMessage } from '#/agent/contextMemory';
import {
@@ -29,7 +28,6 @@ export class AgentContextInjectorService extends Disposable implements IAgentCon
constructor(
@IAgentContextMemoryService private readonly context: IAgentContextMemoryService,
- @IAgentTurnService turnService: IAgentTurnService,
@IAgentLoopService loopService: IAgentLoopService,
@IAgentSystemReminderService private readonly reminders: IAgentSystemReminderService,
@IEventBus private readonly eventBus: IEventBus,
diff --git a/packages/agent-core-v2/src/agent/externalHooks/externalHooks.ts b/packages/agent-core-v2/src/agent/externalHooks/externalHooks.ts
index 3eec4f23b..dcda7ee4c 100644
--- a/packages/agent-core-v2/src/agent/externalHooks/externalHooks.ts
+++ b/packages/agent-core-v2/src/agent/externalHooks/externalHooks.ts
@@ -1,9 +1,9 @@
/**
- * `externalHooks` domain (L5) — contract for configured external hook
+ * `externalHooks` domain (L6) — contract for configured external hook
* commands.
*
* The service is intentionally observer-shaped: business domains expose their
- * own minimal hook contexts, and the L5 implementation listens to those hooks
+ * own minimal hook contexts, and the L6 implementation listens to those hooks
* to invoke configured external commands.
*/
@@ -22,31 +22,8 @@ export interface ExternalHooksServiceOptions {
| undefined;
}
-export interface AgentTaskStartHookContext {
- readonly agentName: string;
- readonly prompt: string;
- readonly signal: AbortSignal;
-}
-
-export interface AgentTaskStopHookContext {
- readonly agentName: string;
- readonly response: string;
-}
-
export interface IAgentExternalHooksService {
readonly _serviceBrand: undefined;
-
- /**
- * Run the blocking `SubagentStart` external hook for an agent task this
- * agent is launching (via the `Agent` tool / swarm). Called directly by the
- * `agentLifecycle` tool wrapper (`mirrorAgentRun`) — the wrapper is a thin
- * layer over the lifecycle with no hook service of its own, so this is the
- * one context invoked explicitly rather than observed. Throws when a
- * configured hook blocks.
- */
- runAgentTaskStart(ctx: AgentTaskStartHookContext): Promise;
- /** Fire-and-forget `SubagentStop` external hook counterpart. */
- notifyAgentTaskStop(ctx: AgentTaskStopHookContext): void;
}
export const IAgentExternalHooksService =
diff --git a/packages/agent-core-v2/src/agent/externalHooks/externalHooksService.ts b/packages/agent-core-v2/src/agent/externalHooks/externalHooksService.ts
index 4107613af..68e5130a9 100644
--- a/packages/agent-core-v2/src/agent/externalHooks/externalHooksService.ts
+++ b/packages/agent-core-v2/src/agent/externalHooks/externalHooksService.ts
@@ -1,16 +1,15 @@
/**
- * `externalHooks` domain (L5) — Agent-scope adapter for external
+ * `externalHooks` domain (L6) — Agent-scope adapter for external
* hook commands.
*
- * Listens to hook slots owned by the agent behavior/lifecycle domains
- * (`toolExecutor`, `permissionGate`, `prompt`, `turn`, `loop`, `fullCompaction`, and
- * `task`) and translates those minimal contexts into the configured external
- * HookEngine events. Appends UserPromptSubmit hook results and Stop hook
- * continuation prompts through `contextMemory`. The `SubagentStart` /
- * `SubagentStop` pair is the one
- * exception: the `agentLifecycle` tool wrapper has no hook service of its own,
- * so `mirrorAgentRun` invokes `runAgentTaskStart` / `notifyAgentTaskStop` on
- * this service directly.
+ * Listens to hook slots and agent events owned by the agent behavior/lifecycle
+ * domains (`toolExecutor`, `permissionGate`, `prompt`, `turn`, `loop`,
+ * `fullCompaction`, `task`, and the `agentLifecycle` run hooks) and translates
+ * those minimal contexts into the configured external HookEngine events —
+ * including `SubagentStart` / `SubagentStop`, which it now observes via the
+ * `IAgentRunHooksService` slots rather than being invoked directly by the
+ * `agentLifecycle` wrapper. Appends UserPromptSubmit hook results and Stop hook
+ * continuation prompts through `contextMemory`.
*/
import { IInstantiationService } from '#/_base/di/instantiation';
@@ -48,13 +47,16 @@ import { IBootstrapService } from '#/app/bootstrap/bootstrap';
import { IConfigService } from '#/app/config/config';
import { IPluginService } from '#/app/plugin/plugin';
import { toKimiErrorPayload } from '#/errors';
+import {
+ IAgentRunHooksService,
+ type AgentTaskStartHookContext,
+ type AgentTaskStopHookContext,
+} from '#/session/agentLifecycle/runHooks';
import { HOOKS_SECTION, type HookDefConfig } from './configSection';
import { HookEngine } from './engine';
import {
IAgentExternalHooksService,
- type AgentTaskStartHookContext,
- type AgentTaskStopHookContext,
type ExternalHooksServiceOptions,
} from './externalHooks';
import {
@@ -156,6 +158,9 @@ export class AgentExternalHooksService extends Disposable implements IAgentExter
this.instantiation.invokeFunction((accessor) => accessor.get(IAgentTaskService)),
);
+ this.registerAgentTaskHooks(
+ this.instantiation.invokeFunction((accessor) => accessor.get(IAgentRunHooksService)),
+ );
}
private registerToolHooks(toolExecutor: IAgentToolExecutorService): void {
@@ -258,6 +263,21 @@ export class AgentExternalHooksService extends Disposable implements IAgentExter
);
}
+ private registerAgentTaskHooks(runHooks: IAgentRunHooksService): void {
+ this._register(
+ runHooks.hooks.onWillStartAgentTask.register('externalHooks', async (ctx, next) => {
+ await this.runSubagentStart(ctx);
+ await next();
+ }),
+ );
+ this._register(
+ runHooks.hooks.onDidStopAgentTask.register('externalHooks', (ctx, next) => {
+ this.notifySubagentStop(ctx);
+ return next();
+ }),
+ );
+ }
+
private async loadDynamicHooks(): Promise {
await this.config.ready;
const configured = this.config.get(HOOKS_SECTION) as readonly HookDefConfig[] | undefined;
@@ -433,7 +453,7 @@ export class AgentExternalHooksService extends Disposable implements IAgentExter
);
}
- async runAgentTaskStart(ctx: AgentTaskStartHookContext): Promise {
+ private async runSubagentStart(ctx: AgentTaskStartHookContext): Promise {
ctx.signal.throwIfAborted();
const engine = await this.readyEngine();
await engine?.trigger('SubagentStart', {
@@ -447,7 +467,7 @@ export class AgentExternalHooksService extends Disposable implements IAgentExter
ctx.signal.throwIfAborted();
}
- notifyAgentTaskStop(ctx: AgentTaskStopHookContext): void {
+ private notifySubagentStop(ctx: AgentTaskStopHookContext): void {
this.fireAndForget(
'SubagentStop',
{
diff --git a/packages/agent-core-v2/src/agent/turn/turn.ts b/packages/agent-core-v2/src/agent/turn/turn.ts
index 293d90741..adad5298c 100644
--- a/packages/agent-core-v2/src/agent/turn/turn.ts
+++ b/packages/agent-core-v2/src/agent/turn/turn.ts
@@ -1,6 +1,5 @@
import { createDecorator } from "#/_base/di/instantiation";
import type { TurnResult } from '#/agent/loop';
-import type { Hooks } from '#/hooks';
export type { TurnResult } from '#/agent/loop';
diff --git a/packages/agent-core-v2/src/agent/turn/turnService.ts b/packages/agent-core-v2/src/agent/turn/turnService.ts
index 65cd31787..2d417d521 100644
--- a/packages/agent-core-v2/src/agent/turn/turnService.ts
+++ b/packages/agent-core-v2/src/agent/turn/turnService.ts
@@ -20,7 +20,6 @@ import type { TurnEndedEvent, TurnStartedEvent } from '@moonshot-ai/protocol';
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { ErrorCodes, KimiError, toKimiErrorPayload } from '#/errors';
-import { OrderedHookSlot } from '#/hooks';
import { IAgentLoopService } from '#/agent/loop';
import { IEventBus } from '#/app/event/eventBus';
import { IAgentTelemetryContextService } from '#/app/telemetry/agentTelemetryContext';
diff --git a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycle.ts b/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycle.ts
index 81a4e8cd5..9f095599a 100644
--- a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycle.ts
+++ b/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycle.ts
@@ -5,8 +5,9 @@
* `ForkSessionOptions`, and the `ISessionLifecycleService` used to create
* sessions (`create`), look up the live ones (`get` / `list`), close them
* (`close`), archive them (`archive`), and fork them (`fork`). Announces
- * lifecycle transitions through `onDidCreateSession` / `onDidCloseSession` /
- * `onDidArchiveSession` / `onDidForkSession`. App-scoped — a single
+ * lifecycle transitions through ordered hook slots plus
+ * `onDidCreateSession` / `onDidCloseSession` / `onDidArchiveSession` /
+ * `onDidForkSession`. App-scoped — a single
* process-wide instance owns the live session scope tree. Persisted
* sessions (open or closed) are the `sessionIndex` read model; per-session
* behaviour lives in the Session-scoped domains.
@@ -15,6 +16,7 @@
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
import type { ISessionScopeHandle } from '#/_base/di/scope';
import type { Event } from '#/_base/event';
+import type { Hooks } from '#/hooks';
export interface CreateSessionOptions {
readonly sessionId: string;
@@ -33,12 +35,28 @@ export interface ForkSessionOptions {
export interface SessionCreatedEvent {
readonly sessionId: string;
readonly handle: ISessionScopeHandle;
+ readonly source: SessionCreateSource;
}
export interface SessionClosedEvent {
readonly sessionId: string;
}
+export type SessionCreateSource = 'startup' | 'resume' | 'fork';
+
+export type SessionCloseReason = 'exit';
+
+export interface SessionWillCloseEvent {
+ readonly sessionId: string;
+ readonly handle: ISessionScopeHandle;
+ readonly reason: SessionCloseReason;
+}
+
+export type SessionLifecycleHooks = {
+ readonly onDidCreateSession: SessionCreatedEvent;
+ readonly onWillCloseSession: SessionWillCloseEvent;
+};
+
export interface SessionArchivedEvent {
readonly sessionId: string;
}
@@ -56,6 +74,7 @@ export interface ISessionLifecycleService {
readonly onDidCloseSession: Event;
readonly onDidArchiveSession: Event;
readonly onDidForkSession: Event;
+ readonly hooks: Hooks;
create(opts: CreateSessionOptions): Promise;
get(sessionId: string): ISessionScopeHandle | undefined;
list(): readonly ISessionScopeHandle[];
diff --git a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts b/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts
index be15bcaae..d8a1bfb41 100644
--- a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts
+++ b/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts
@@ -3,8 +3,9 @@
*
* Owns the process-wide registry of open Session child scopes, creating them
* through the DI scope tree and seeding each with its identity and storage
- * addressing, and tearing them down on close/archive — archiving flags the
- * session's `sessionMetadata`, removes its `agentLifecycle` agents, and
+ * addressing, running lifecycle hook slots, and tearing them down on
+ * close/archive — archiving flags the session's `sessionMetadata`, removes
+ * its `agentLifecycle` agents, and
* broadcasts through `event`. Materializes the session's initial metadata on
* creation by resolving `sessionMetadata`. Bound at App scope. Persisted
* sessions are the `sessionIndex` read model.
@@ -35,9 +36,11 @@ import { ISessionIndex } from '#/app/sessionIndex/sessionIndex';
import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore';
import { IAppendLogStore } from '#/persistence/interface/appendLogStore';
import { IWorkspaceRegistry } from '#/app/workspaceRegistry/workspaceRegistry';
+import { ISessionExternalHooksService } from '#/session/externalHooks/externalHooks';
import { ISessionContext, sessionContextSeed } from '#/session/sessionContext/sessionContext';
import { ISessionMetadata, type SessionMeta } from '#/session/sessionMetadata/sessionMetadata';
import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog';
+import { createHooks } from '#/hooks';
import {
AGENT_WIRE_PROTOCOL_VERSION,
IAgentWireRecordService,
@@ -54,6 +57,8 @@ import {
type SessionClosedEvent,
type SessionCreatedEvent,
type SessionForkedEvent,
+ type SessionLifecycleHooks,
+ type SessionWillCloseEvent,
ISessionLifecycleService,
} from './sessionLifecycle';
@@ -68,6 +73,10 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec
readonly onDidArchiveSession: Event = this._onDidArchiveSession.event;
private readonly _onDidForkSession = this._register(new Emitter());
readonly onDidForkSession: Event = this._onDidForkSession.event;
+ readonly hooks = createHooks([
+ 'onDidCreateSession',
+ 'onWillCloseSession',
+ ]);
/** In-flight `resume` promises, keyed by session id — de-dupes concurrent
* cold loads so a hot read path (e.g. snapshot retry) cannot materialize
* the same session twice and leak a handle. */
@@ -87,6 +96,12 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec
}
async create(opts: CreateSessionOptions): Promise {
+ const handle = await this.materializeSession(opts);
+ await this.announceCreated({ sessionId: opts.sessionId, handle, source: 'startup' });
+ return handle;
+ }
+
+ private async materializeSession(opts: CreateSessionOptions): Promise {
const workspaceId = encodeWorkDirKey(opts.workDir);
const sessionScope = this.bootstrap.sessionScope(workspaceId, opts.sessionId);
const sessionDir = this.bootstrap.sessionDir(workspaceId, opts.sessionId);
@@ -122,10 +137,15 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec
await handle.accessor.get(ISessionMetadata).ready;
void handle.accessor.get(ISessionSkillCatalog).ready;
await handle.accessor.get(IAgentLifecycleService).ensureMcpReady();
- this._onDidCreateSession.fire({ sessionId: opts.sessionId, handle });
+ handle.accessor.get(ISessionExternalHooksService);
return handle;
}
+ private async announceCreated(event: SessionCreatedEvent): Promise {
+ await this.hooks.onDidCreateSession.run(event);
+ this._onDidCreateSession.fire(event);
+ }
+
get(sessionId: string): ISessionScopeHandle | undefined {
return this.sessions.get(sessionId);
}
@@ -151,7 +171,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec
const workspace = await this.workspaceRegistry.get(summary.workspaceId);
if (workspace === undefined) return undefined;
- const handle = await this.create({ sessionId, workDir: workspace.root });
+ const handle = await this.materializeSession({ sessionId, workDir: workspace.root });
const agents = handle.accessor.get(IAgentLifecycleService);
if (agents.getHandle(MAIN_AGENT_ID) === undefined) {
const main = await ensureMainAgent(handle);
@@ -165,6 +185,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec
.accessor.get(IAgentWireService)
.replay(...(mainWireRecord.getRecords() as readonly PersistedRecord[]));
}
+ await this.announceCreated({ sessionId, handle, source: 'resume' });
return handle;
}
@@ -175,6 +196,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec
async close(sessionId: string): Promise {
const handle = this.sessions.get(sessionId);
if (handle === undefined) return;
+ await this.announceWillClose({ sessionId, handle, reason: 'exit' });
this.sessions.delete(sessionId);
handle.dispose();
this._onDidCloseSession.fire({ sessionId });
@@ -193,11 +215,16 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec
type: 'event.session.archived',
payload: { sessionId },
});
+ await this.announceWillClose({ sessionId, handle, reason: 'exit' });
this.sessions.delete(sessionId);
handle.dispose();
this._onDidArchiveSession.fire({ sessionId });
}
+ private async announceWillClose(event: SessionWillCloseEvent): Promise {
+ await this.hooks.onWillCloseSession.run(event);
+ }
+
async fork(opts: ForkSessionOptions): Promise {
const sourceId = opts.sourceSessionId;
@@ -245,7 +272,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec
}
// 6. Materialize the target session scope (fresh metadata + storage).
- const target = await this.create({ sessionId: targetId, workDir: workspace.root });
+ const target = await this.materializeSession({ sessionId: targetId, workDir: workspace.root });
const targetCtx = target.accessor.get(ISessionContext);
const targetMeta = target.accessor.get(ISessionMetadata);
@@ -299,6 +326,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec
sessionId: targetId,
handle: target,
});
+ await this.announceCreated({ sessionId: targetId, handle: target, source: 'fork' });
return target;
}
diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts
index ddba9050f..834ee7b35 100644
--- a/packages/agent-core-v2/src/index.ts
+++ b/packages/agent-core-v2/src/index.ts
@@ -58,6 +58,7 @@ export * from '#/session/cron';
export * from '#/session/agentLifecycle';
export * from '#/app/sessionLifecycle';
+export * from '#/session/externalHooks';
export * from '#/app/sessionExport';
export * from '#/app/sessionLegacy';
export * from '#/session/interaction';
diff --git a/packages/agent-core-v2/src/session/agentLifecycle/index.ts b/packages/agent-core-v2/src/session/agentLifecycle/index.ts
index 747d6c7f1..3ea14f92e 100644
--- a/packages/agent-core-v2/src/session/agentLifecycle/index.ts
+++ b/packages/agent-core-v2/src/session/agentLifecycle/index.ts
@@ -17,8 +17,8 @@ export * from './tools/subagent-task';
export { AGENT_RUN_PROMPT_ORIGIN } from './runAgentTurn';
export * from './mainAgent';
export * from './mirrorAgentRun';
-// Deliberately last: `tools/agent` reaches `sessionSwarmService` through
-// `mirrorAgentRun` → `externalHooks` → `permissionPolicy` → `agent/swarm`,
-// and `sessionSwarmService` imports this barrel back. The `./agentLifecycle`
-// contract (service decorator) must be evaluated before that cycle re-enters.
+export * from './runHooks';
+// Deliberately last: `tools/agent` transitively reaches `sessionSwarmService`,
+// which imports this barrel back. The `./agentLifecycle` contract (service
+// decorator) must be evaluated before that cycle re-enters.
import './tools/agent';
diff --git a/packages/agent-core-v2/src/session/agentLifecycle/mirrorAgentRun.ts b/packages/agent-core-v2/src/session/agentLifecycle/mirrorAgentRun.ts
index 8571bb035..f5e352d21 100644
--- a/packages/agent-core-v2/src/session/agentLifecycle/mirrorAgentRun.ts
+++ b/packages/agent-core-v2/src/session/agentLifecycle/mirrorAgentRun.ts
@@ -8,9 +8,10 @@
* requester ↔ target association is business data of this wrapper layer — the
* lifecycle registry itself stays flat and knows nothing about it.
*
- * External hooks (`SubagentStart` / `SubagentStop`) are invoked directly on
- * the requester's `IAgentExternalHooksService` — there is no intermediary
- * hook service; the tool wrapper is just a thin layer over the lifecycle.
+ * External hooks (`SubagentStart` / `SubagentStop`) fire by observation, like
+ * every other external hook: this wrapper announces "a run is about to start"
+ * / "...has stopped" through the `IAgentRunHooksService` slots, and the
+ * Agent-scope `externalHooks` adapter registers there to translate them.
*
* Wire shape note: the signals are still named `subagent.spawned / started /
* completed / failed` and telemetry still tracks `subagent_created` so existing
@@ -23,7 +24,7 @@ import { userCancellationReason } from '#/_base/utils/abort';
import { isProviderRateLimitError } from '#/app/llmProtocol/errors';
import { type TokenUsage } from '#/app/llmProtocol/usage';
import { ITelemetryService } from '#/app/telemetry/telemetry';
-import { IAgentExternalHooksService } from '#/agent/externalHooks';
+import { IAgentRunHooksService } from './runHooks';
import type {
SubagentCompletedEvent,
SubagentFailedEvent,
@@ -44,8 +45,6 @@ declare module '#/app/event/eventBus' {
}
}
-const HOOK_TEXT_PREVIEW_LENGTH = 500;
-
export interface AgentRunSpawnedMeta {
readonly profileName: string;
readonly parentToolCallId?: string;
@@ -59,16 +58,17 @@ export interface MirrorAgentRunOptions {
/** Profile the target runs under; only used for hooks / record labels. */
readonly profileName: string;
/**
- * Prompt text submitted to the target. When present the `SubagentStart`
- * external hook fires (and may block the run); omit for retry turns, which
- * skip the hook.
+ * Prompt text submitted to the target. When present the requester-side
+ * `onWillStartAgentTask` hook slot runs (which the external-hooks adapter
+ * translates into the `SubagentStart` external hook); omit for retry turns,
+ * which skip the slot.
*/
readonly prompt?: string;
/** Skip the requester-side `subagent.failed` record for provider-rate-limit / aborted failures. */
readonly suppressRateLimitFailureEvent?: boolean;
- /** The requester's cancellation signal (passed through to the start hook). */
+ /** The requester's cancellation signal (passed through to the start hook slot). */
readonly signal: AbortSignal;
- /** Called to abort the underlying run when the start hook blocks it. */
+ /** Called to abort the underlying run when the start hook slot aborts/rejects it. */
readonly cancel?: (reason?: unknown) => void;
}
@@ -111,13 +111,13 @@ export async function mirrorAgentRun(
options: MirrorAgentRunOptions,
): Promise<{ summary: string; usage?: TokenUsage }> {
const eventBus = requester.accessor.get(IEventBus);
- const externalHooks = requester.accessor.get(IAgentExternalHooksService);
+ const runHooks = requester.accessor.get(IAgentRunHooksService);
eventBus?.publish({ type: 'subagent.started', subagentId: run.agentId });
if (options.prompt !== undefined) {
try {
- await externalHooks?.runAgentTaskStart({
+ await runHooks?.hooks.onWillStartAgentTask.run({
agentName: options.profileName,
- prompt: options.prompt.slice(0, HOOK_TEXT_PREVIEW_LENGTH),
+ prompt: options.prompt,
signal: options.signal,
});
} catch (error) {
@@ -140,10 +140,12 @@ export async function mirrorAgentRun(
resultSummary: result.summary,
usage: result.usage,
});
- externalHooks?.notifyAgentTaskStop({
- agentName: options.profileName,
- response: result.summary.slice(0, HOOK_TEXT_PREVIEW_LENGTH),
- });
+ void runHooks?.hooks
+ .onDidStopAgentTask.run({
+ agentName: options.profileName,
+ response: result.summary,
+ })
+ .catch(() => {});
return result;
} catch (error) {
if (!isAbortError(error) && !shouldSuppressFailure(options, error)) {
diff --git a/packages/agent-core-v2/src/session/agentLifecycle/runHooks.ts b/packages/agent-core-v2/src/session/agentLifecycle/runHooks.ts
new file mode 100644
index 000000000..9c5f8f168
--- /dev/null
+++ b/packages/agent-core-v2/src/session/agentLifecycle/runHooks.ts
@@ -0,0 +1,67 @@
+/**
+ * `agentLifecycle` domain (L6) — hook-slot host for the requester-side agent
+ * run wrapper (`mirrorAgentRun`).
+ *
+ * When one agent drives another (the `Agent` tool, the swarm scheduler),
+ * `mirrorAgentRun` announces "an agent run I am hosting is about to start" and
+ * "...has stopped" through these ordered slots. Observers — most notably the
+ * Agent-scope `externalHooks` adapter, which translates them into the
+ * `SubagentStart` / `SubagentStop` external hook commands — register here
+ * instead of `mirrorAgentRun` calling them directly. Bound at Agent scope so
+ * each requester agent gets its own slot (and only its own observers see its
+ * launches), matching the other Agent-scope hook hosts (`toolExecutor`,
+ * `prompt`, ...).
+ *
+ * The slots carry the raw run facts (`prompt` / `response`); observers apply
+ * their own truncation. `onWillStartAgentTask` is awaited by `mirrorAgentRun`
+ * before the run proceeds, preserving start-before-stop ordering;
+ * `onDidStopAgentTask` is driven fire-and-forget.
+ */
+
+import { InstantiationType } from '#/_base/di/extensions';
+import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
+import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
+import { createHooks, type Hooks } from '#/hooks';
+
+/** Facts announced when an agent run this agent is hosting is about to start. */
+export interface AgentTaskStartHookContext {
+ readonly agentName: string;
+ readonly prompt: string;
+ readonly signal: AbortSignal;
+}
+
+/** Facts announced when an agent run this agent is hosting has stopped. */
+export interface AgentTaskStopHookContext {
+ readonly agentName: string;
+ readonly response: string;
+}
+
+export type AgentTaskHooks = {
+ readonly onWillStartAgentTask: AgentTaskStartHookContext;
+ readonly onDidStopAgentTask: AgentTaskStopHookContext;
+};
+
+export interface IAgentRunHooksService {
+ readonly _serviceBrand: undefined;
+ readonly hooks: Hooks;
+}
+
+export const IAgentRunHooksService: ServiceIdentifier =
+ createDecorator('agentRunHooksService');
+
+export class AgentRunHooksService implements IAgentRunHooksService {
+ declare readonly _serviceBrand: undefined;
+
+ readonly hooks = createHooks([
+ 'onWillStartAgentTask',
+ 'onDidStopAgentTask',
+ ]);
+}
+
+registerScopedService(
+ LifecycleScope.Agent,
+ IAgentRunHooksService,
+ AgentRunHooksService,
+ InstantiationType.Delayed,
+ 'agentLifecycle',
+);
diff --git a/packages/agent-core-v2/src/session/externalHooks/externalHooks.ts b/packages/agent-core-v2/src/session/externalHooks/externalHooks.ts
new file mode 100644
index 000000000..7f70ebe07
--- /dev/null
+++ b/packages/agent-core-v2/src/session/externalHooks/externalHooks.ts
@@ -0,0 +1,17 @@
+/**
+ * `externalHooks` domain (L6) — Session-scope external hook observer contract.
+ *
+ * Exposes an empty Session-scope service whose implementation registers
+ * session lifecycle callbacks from its constructor. The lifecycle owner invokes
+ * its own hook slots; callers never trigger session external hooks through
+ * this contract.
+ */
+
+import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
+
+export interface ISessionExternalHooksService {
+ readonly _serviceBrand: undefined;
+}
+
+export const ISessionExternalHooksService: ServiceIdentifier =
+ createDecorator('sessionExternalHooksService');
diff --git a/packages/agent-core-v2/src/session/externalHooks/externalHooksService.ts b/packages/agent-core-v2/src/session/externalHooks/externalHooksService.ts
new file mode 100644
index 000000000..1491a8415
--- /dev/null
+++ b/packages/agent-core-v2/src/session/externalHooks/externalHooksService.ts
@@ -0,0 +1,116 @@
+/**
+ * `externalHooks` domain (L6) — Session-scope adapter for external hook commands.
+ *
+ * Registers with `sessionLifecycle` hook slots to run `SessionStart` and
+ * `SessionEnd` external commands for the current `sessionContext`, loading
+ * configured hooks through `config` and plugin-contributed hooks through
+ * `plugin`. Bound at Session scope.
+ */
+
+import { InstantiationType } from '#/_base/di/extensions';
+import { Disposable } from '#/_base/di/lifecycle';
+import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
+import { IConfigService } from '#/app/config/config';
+import { IPluginService } from '#/app/plugin/plugin';
+import {
+ ISessionLifecycleService,
+ type SessionCloseReason,
+ type SessionCreateSource,
+} from '#/app/sessionLifecycle/sessionLifecycle';
+import { HOOKS_SECTION, type HookDefConfig } from '#/agent/externalHooks/configSection';
+import { HookEngine } from '#/agent/externalHooks/engine';
+import { ISessionContext } from '#/session/sessionContext/sessionContext';
+
+import { ISessionExternalHooksService } from './externalHooks';
+
+type SessionStartHookSource = Exclude;
+
+export class SessionExternalHooksService
+ extends Disposable
+ implements ISessionExternalHooksService
+{
+ declare readonly _serviceBrand: undefined;
+
+ constructor(
+ @ISessionContext private readonly context: ISessionContext,
+ @ISessionLifecycleService lifecycle: ISessionLifecycleService,
+ @IConfigService config: IConfigService,
+ @IPluginService plugins: IPluginService,
+ ) {
+ super();
+ let dynamicEngine = new HookEngine([], {
+ cwd: this.context.cwd,
+ sessionId: this.context.sessionId,
+ });
+ const loadDynamicHooks = async (): Promise => {
+ await config.ready;
+ const configured = config.get(HOOKS_SECTION) as readonly HookDefConfig[] | undefined;
+ const pluginHooks = await plugins.enabledHooks();
+ dynamicEngine = new HookEngine([...(configured ?? []), ...pluginHooks], {
+ cwd: this.context.cwd,
+ sessionId: this.context.sessionId,
+ });
+ };
+ const loadDynamicHooksSafe = async (): Promise => {
+ try {
+ await loadDynamicHooks();
+ } catch {}
+ };
+ const hooksReady = loadDynamicHooksSafe();
+ const readyEngine = async (): Promise => {
+ await hooksReady;
+ return dynamicEngine;
+ };
+ const triggerSessionStart = async (source: SessionStartHookSource): Promise => {
+ const engine = await readyEngine();
+ await engine.trigger('SessionStart', {
+ matcherValue: source,
+ inputData: {
+ sessionId: this.context.sessionId,
+ cwd: this.context.cwd,
+ source,
+ },
+ });
+ };
+ const triggerSessionEnd = async (reason: SessionCloseReason): Promise => {
+ const engine = await readyEngine();
+ await engine.trigger('SessionEnd', {
+ matcherValue: reason,
+ inputData: {
+ sessionId: this.context.sessionId,
+ cwd: this.context.cwd,
+ reason,
+ },
+ });
+ };
+ this._register(
+ lifecycle.hooks.onDidCreateSession.register('externalHooks', async (event, next) => {
+ if (event.sessionId === this.context.sessionId && event.source !== 'fork') {
+ await triggerSessionStart(event.source);
+ }
+ await next();
+ }),
+ );
+ this._register(
+ lifecycle.hooks.onWillCloseSession.register('externalHooks', async (event, next) => {
+ if (event.sessionId === this.context.sessionId) {
+ await triggerSessionEnd(event.reason);
+ }
+ await next();
+ }),
+ );
+ this._register(
+ plugins.onDidReload(() => {
+ void loadDynamicHooksSafe();
+ }),
+ );
+ }
+}
+
+registerScopedService(
+ LifecycleScope.Session,
+ ISessionExternalHooksService,
+ SessionExternalHooksService,
+ InstantiationType.Eager,
+ 'externalHooks',
+);
diff --git a/packages/agent-core-v2/src/session/externalHooks/index.ts b/packages/agent-core-v2/src/session/externalHooks/index.ts
new file mode 100644
index 000000000..69e6f4db2
--- /dev/null
+++ b/packages/agent-core-v2/src/session/externalHooks/index.ts
@@ -0,0 +1,9 @@
+/**
+ * `externalHooks` domain barrel — re-exports the Session-scope external hooks
+ * contract (`externalHooks`) and its scoped service (`externalHooksService`).
+ * Importing this barrel registers the `ISessionExternalHooksService` binding
+ * into the scope registry.
+ */
+
+export * from './externalHooks';
+export * from './externalHooksService';
diff --git a/packages/agent-core-v2/test/externalHooks/integration.test.ts b/packages/agent-core-v2/test/externalHooks/integration.test.ts
index 9bec226bc..cc71da99c 100644
--- a/packages/agent-core-v2/test/externalHooks/integration.test.ts
+++ b/packages/agent-core-v2/test/externalHooks/integration.test.ts
@@ -1,7 +1,12 @@
+import { existsSync, mkdtempSync, readFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+
import { describe, expect, it } from 'vitest';
import { SyncDescriptor } from '#/_base/di/descriptors';
import { DisposableStore } from '#/_base/di/lifecycle';
+import type { ISessionScopeHandle } from '#/_base/di/scope';
import {
createServices,
type TestInstantiationService,
@@ -22,6 +27,10 @@ import {
AgentExternalHooksService,
IAgentExternalHooksService,
} from '#/agent/externalHooks';
+import {
+ AgentRunHooksService,
+ IAgentRunHooksService,
+} from '#/session/agentLifecycle/runHooks';
import { HookEngine } from '#/agent/externalHooks/engine';
import {
HookDefSchema,
@@ -40,7 +49,16 @@ import { IConfigService } from '#/app/config/config';
import { IEventBus } from '#/app/event/eventBus';
import { EventBusService } from '#/app/event/eventBusService';
import { IPluginService } from '#/app/plugin/plugin';
+import {
+ ISessionLifecycleService,
+ type SessionLifecycleHooks,
+} from '#/app/sessionLifecycle/sessionLifecycle';
import { createHooks } from '#/hooks';
+import { ISessionContext } from '#/session/sessionContext/sessionContext';
+import {
+ ISessionExternalHooksService,
+ SessionExternalHooksService,
+} from '#/session/externalHooks';
import { IAgentWireService, WireService } from '#/wire';
import { stubBootstrap } from '../bootstrap/stubs';
@@ -111,6 +129,60 @@ async function flushMicrotasks(): Promise {
await Promise.resolve();
}
+function hookLogPath(): string {
+ return join(mkdtempSync(join(tmpdir(), 'session-external-hooks-')), 'events.jsonl');
+}
+
+function appendHookLogCommand(path: string): string {
+ return stdinScript([
+ 'const fs = require("node:fs");',
+ 'fs.appendFileSync(',
+ ` ${JSON.stringify(path)},`,
+ ' JSON.stringify({',
+ ' event: parsed.hook_event_name,',
+ ' source: parsed.source,',
+ ' reason: parsed.reason,',
+ ' sessionId: parsed.session_id,',
+ ' cwd: parsed.cwd,',
+ ' }) + "\\n",',
+ ');',
+ ].join('\n'));
+}
+
+function readHookLog(path: string): Array> {
+ if (!existsSync(path)) return [];
+ return readFileSync(path, 'utf8')
+ .trim()
+ .split('\n')
+ .filter((line) => line.length > 0)
+ .map((line) => JSON.parse(line) as Record);
+}
+
+function stubSessionLifecycle(): ISessionLifecycleService {
+ return {
+ _serviceBrand: undefined,
+ hooks: createHooks([
+ 'onDidCreateSession',
+ 'onWillCloseSession',
+ ]),
+ onDidCreateSession: Event.None as ISessionLifecycleService['onDidCreateSession'],
+ onDidCloseSession: Event.None as ISessionLifecycleService['onDidCloseSession'],
+ onDidArchiveSession: Event.None as ISessionLifecycleService['onDidArchiveSession'],
+ onDidForkSession: Event.None as ISessionLifecycleService['onDidForkSession'],
+ create: async () => {
+ throw new Error('not implemented');
+ },
+ get: () => undefined,
+ list: () => [],
+ resume: async () => undefined,
+ close: async () => {},
+ archive: async () => {},
+ fork: async () => {
+ throw new Error('not implemented');
+ },
+ };
+}
+
describe('HookEngine integration', () => {
it('blocks a dangerous Bash command and allows a safe one via a PreToolUse script hook', async () => {
const engine = new HookEngine([
@@ -201,6 +273,7 @@ describe('HookEngine integration', () => {
);
},
});
+ ix.set(IAgentRunHooksService, new SyncDescriptor(AgentRunHooksService));
ix.set(
IAgentExternalHooksService,
new SyncDescriptor(AgentExternalHooksService, [{ hookEngine }]),
@@ -303,6 +376,7 @@ describe('HookEngine integration', () => {
reg.definePartialInstance(IAgentTaskService, {});
},
});
+ ix.set(IAgentRunHooksService, new SyncDescriptor(AgentRunHooksService));
ix.set(
IAgentExternalHooksService,
new SyncDescriptor(AgentExternalHooksService, [{ hookEngine }]),
@@ -354,6 +428,117 @@ describe('HookEngine integration', () => {
}
});
+ it('observes the agent-run hook slots to fire SubagentStart and SubagentStop', async () => {
+ const disposables = new DisposableStore();
+ let ix: TestInstantiationService | undefined;
+ try {
+ const fired: Array<{
+ event: string;
+ matcherValue?: unknown;
+ inputData?: unknown;
+ }> = [];
+ const triggered: Array<{
+ event: string;
+ matcherValue?: unknown;
+ inputData?: unknown;
+ signal?: unknown;
+ }> = [];
+ const hookEngine = {
+ trigger: async (
+ event: string,
+ args: { matcherValue?: unknown; inputData?: unknown; signal?: unknown },
+ ) => {
+ triggered.push({
+ event,
+ matcherValue: args.matcherValue,
+ inputData: args.inputData,
+ signal: args.signal,
+ });
+ return [];
+ },
+ triggerBlock: async () => undefined,
+ fireAndForgetTrigger: async (
+ event: string,
+ args: { matcherValue?: unknown; inputData?: unknown },
+ ) => {
+ fired.push({
+ event,
+ matcherValue: args.matcherValue,
+ inputData: args.inputData,
+ });
+ return [];
+ },
+ };
+
+ ix = createServices(disposables, {
+ strict: true,
+ additionalServices: (reg) => {
+ reg.defineInstance(IBootstrapService, stubBootstrap());
+ reg.definePartialInstance(IConfigService, {});
+ reg.definePartialInstance(IPluginService, {
+ onDidReload: Event.None as IPluginService['onDidReload'],
+ });
+ reg.defineInstance(IAgentContextMemoryService, stubContextMemory());
+ reg.defineInstance(IAgentLoopService, stubLoopWithHooks());
+ reg.define(IEventBus, EventBusService);
+ reg.definePartialInstance(IAgentPromptService, {
+ hooks: createHooks(['onWillSubmitPrompt']),
+ });
+ reg.defineInstance(IAgentTurnService, stubTurnWithHooks());
+ reg.defineInstance(IAgentToolExecutorService, stubToolExecutor());
+ reg.definePartialInstance(IAgentPermissionGate, {});
+ reg.definePartialInstance(IAgentFullCompactionService, {
+ hooks: createHooks(['onWillCompact']),
+ });
+ reg.definePartialInstance(IAgentTaskService, {});
+ },
+ });
+ ix.set(IAgentRunHooksService, new SyncDescriptor(AgentRunHooksService));
+ ix.set(
+ IAgentExternalHooksService,
+ new SyncDescriptor(AgentExternalHooksService, [{ hookEngine }]),
+ );
+
+ // Construct the observer first so it registers on the run-hook slots,
+ // then drive the slots the way `mirrorAgentRun` does.
+ ix.get(IAgentExternalHooksService);
+ const runHooks = ix.get(IAgentRunHooksService);
+
+ await runHooks.hooks.onWillStartAgentTask.run({
+ agentName: 'coder',
+ prompt: 'Fix the bug',
+ signal: new AbortController().signal,
+ });
+ await runHooks.hooks.onDidStopAgentTask.run({
+ agentName: 'coder',
+ response: 'Bug fixed',
+ });
+
+ expect(triggered).toEqual([
+ {
+ event: 'SubagentStart',
+ matcherValue: 'coder',
+ inputData: { agentName: 'coder', prompt: 'Fix the bug' },
+ signal: expect.any(AbortSignal),
+ },
+ ]);
+
+ // SubagentStop is fire-and-forget; flush until it lands.
+ await flushMicrotasks();
+ await flushMicrotasks();
+ expect(fired).toEqual([
+ {
+ event: 'SubagentStop',
+ matcherValue: 'coder',
+ inputData: { agentName: 'coder', response: 'Bug fixed' },
+ },
+ ]);
+ } finally {
+ ix?.dispose();
+ disposables.dispose();
+ }
+ });
+
it('waits for dynamic hooks to load before running the first blocking hook', async () => {
const disposables = new DisposableStore();
let ix: TestInstantiationService | undefined;
@@ -405,6 +590,7 @@ describe('HookEngine integration', () => {
);
},
});
+ ix.set(IAgentRunHooksService, new SyncDescriptor(AgentRunHooksService));
ix.set(
IAgentExternalHooksService,
new SyncDescriptor(AgentExternalHooksService, [{}]),
@@ -619,6 +805,103 @@ describe('HookEngine integration', () => {
expect(unmatched).toHaveLength(0);
});
+ it('runs session external hooks from lifecycle callbacks', async () => {
+ const disposables = new DisposableStore();
+ let ix: TestInstantiationService | undefined;
+ try {
+ const lifecycle = stubSessionLifecycle();
+ const path = hookLogPath();
+ const command = appendHookLogCommand(path);
+ const cwd = mkdtempSync(join(tmpdir(), 'session-external-hooks-cwd-'));
+ const handle = {} as ISessionScopeHandle;
+
+ ix = createServices(disposables, {
+ strict: true,
+ additionalServices: (reg) => {
+ reg.defineInstance(ISessionContext, {
+ _serviceBrand: undefined,
+ sessionId: 'session-1',
+ workspaceId: 'workspace-1',
+ sessionDir: '/tmp/session-1',
+ metaScope: 'sessions/workspace-1/session-1',
+ cwd,
+ scope: (subKey?: string) =>
+ subKey === undefined || subKey === ''
+ ? 'sessions/workspace-1/session-1'
+ : `sessions/workspace-1/session-1/${subKey}`,
+ });
+ reg.defineInstance(ISessionLifecycleService, lifecycle);
+ reg.definePartialInstance(IConfigService, {
+ ready: Promise.resolve(),
+ get: (domain: string): T =>
+ (domain === HOOKS_SECTION
+ ? [
+ { event: 'SessionStart' as const, command, timeout: 5 },
+ { event: 'SessionEnd' as const, command, timeout: 5 },
+ ]
+ : undefined) as T,
+ });
+ reg.definePartialInstance(IPluginService, {
+ enabledHooks: async () => [],
+ onDidReload: Event.None as IPluginService['onDidReload'],
+ });
+ },
+ });
+ ix.set(ISessionExternalHooksService, new SyncDescriptor(SessionExternalHooksService));
+ ix.get(ISessionExternalHooksService);
+
+ await lifecycle.hooks.onDidCreateSession.run({
+ sessionId: 'session-1',
+ handle,
+ source: 'startup',
+ });
+ await lifecycle.hooks.onDidCreateSession.run({
+ sessionId: 'session-1',
+ handle,
+ source: 'resume',
+ });
+ await lifecycle.hooks.onDidCreateSession.run({
+ sessionId: 'session-1',
+ handle,
+ source: 'fork',
+ });
+ await lifecycle.hooks.onDidCreateSession.run({
+ sessionId: 'other-session',
+ handle,
+ source: 'startup',
+ });
+ await lifecycle.hooks.onWillCloseSession.run({
+ sessionId: 'session-1',
+ handle,
+ reason: 'exit',
+ });
+
+ expect(readHookLog(path)).toEqual([
+ {
+ event: 'SessionStart',
+ source: 'startup',
+ sessionId: 'session-1',
+ cwd,
+ },
+ {
+ event: 'SessionStart',
+ source: 'resume',
+ sessionId: 'session-1',
+ cwd,
+ },
+ {
+ event: 'SessionEnd',
+ reason: 'exit',
+ sessionId: 'session-1',
+ cwd,
+ },
+ ]);
+ } finally {
+ ix?.dispose();
+ disposables.dispose();
+ }
+ });
+
it('fires a SubagentStart hook with the agent_name payload field', async () => {
const engine = new HookEngine([
{
diff --git a/packages/agent-core-v2/test/harness/agent.ts b/packages/agent-core-v2/test/harness/agent.ts
index 016287de8..129946a7e 100644
--- a/packages/agent-core-v2/test/harness/agent.ts
+++ b/packages/agent-core-v2/test/harness/agent.ts
@@ -60,6 +60,7 @@ import {
AGENT_WIRE_PROTOCOL_VERSION,
AgentTaskService,
AgentExternalHooksService,
+ AgentRunHooksService,
FileStorageService,
InMemoryStorageService,
AgentFullCompactionService,
@@ -77,6 +78,7 @@ import {
IAgentContextProjectorService,
IAgentContextSizeService,
IAgentExternalHooksService,
+ IAgentRunHooksService,
IAgentFullCompactionService,
IAgentLLMRequesterService,
ILogService,
@@ -532,10 +534,13 @@ export function questionServices(service: ISessionQuestionService): TestAgentSer
export function externalHookServices(
hookEngine: Pick | undefined,
): TestAgentServiceOverride {
- return agentService(
- IAgentExternalHooksService,
- new SyncDescriptor(AgentExternalHooksService, [hookEngine === undefined ? {} : { hookEngine }]),
- );
+ return [
+ agentService(IAgentRunHooksService, new SyncDescriptor(AgentRunHooksService)),
+ agentService(
+ IAgentExternalHooksService,
+ new SyncDescriptor(AgentExternalHooksService, [hookEngine === undefined ? {} : { hookEngine }]),
+ ),
+ ];
}
export function microCompactionServices(options: {
diff --git a/packages/agent-core-v2/test/sessionExport/sessionExport.test.ts b/packages/agent-core-v2/test/sessionExport/sessionExport.test.ts
index 64c076438..7b49e3822 100644
--- a/packages/agent-core-v2/test/sessionExport/sessionExport.test.ts
+++ b/packages/agent-core-v2/test/sessionExport/sessionExport.test.ts
@@ -21,9 +21,13 @@ import {
SessionExportService,
} from '#/app/sessionExport/sessionExportService';
import { ISessionIndex, type SessionSummary } from '#/app/sessionIndex/sessionIndex';
-import { ISessionLifecycleService } from '#/app/sessionLifecycle/sessionLifecycle';
+import {
+ ISessionLifecycleService,
+ type SessionLifecycleHooks,
+} from '#/app/sessionLifecycle/sessionLifecycle';
import { IWorkspaceRegistry } from '#/app/workspaceRegistry/workspaceRegistry';
import { KimiError } from '#/errors';
+import { createHooks } from '#/hooks';
import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle';
import { ISessionMetadata, type SessionMeta } from '#/session/sessionMetadata/sessionMetadata';
@@ -306,6 +310,10 @@ function registerSessionExportServices(
onDidCloseSession: noopEvent,
onDidArchiveSession: noopEvent,
onDidForkSession: noopEvent,
+ hooks: createHooks([
+ 'onDidCreateSession',
+ 'onWillCloseSession',
+ ]),
create: async () => {
throw new Error('create should not be called by session export');
},
diff --git a/packages/agent-core-v2/test/sessionLifecycle/sessionLifecycle.test.ts b/packages/agent-core-v2/test/sessionLifecycle/sessionLifecycle.test.ts
index ac30a7971..2a2b52a53 100644
--- a/packages/agent-core-v2/test/sessionLifecycle/sessionLifecycle.test.ts
+++ b/packages/agent-core-v2/test/sessionLifecycle/sessionLifecycle.test.ts
@@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { InstantiationType } from '#/_base/di/extensions';
+import { Disposable } from '#/_base/di/lifecycle';
import {
type IAgentScopeHandle,
LifecycleScope,
@@ -14,6 +15,7 @@ import { IEventService } from '#/app/event/event';
import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle';
import { ISessionLifecycleService } from '#/app/sessionLifecycle/sessionLifecycle';
import { SessionLifecycleService } from '#/app/sessionLifecycle/sessionLifecycleService';
+import { ISessionExternalHooksService } from '#/session/externalHooks/externalHooks';
import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata';
import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog';
import { ISessionIndex } from '#/app/sessionIndex/sessionIndex';
@@ -161,10 +163,40 @@ function tick(): Promise {
return new Promise((resolve) => setTimeout(resolve, 0));
}
+class NoopSessionExternalHooksService implements ISessionExternalHooksService {
+ declare readonly _serviceBrand: undefined;
+}
+
+let recordedSessionHookEvents: string[] = [];
+
+class RecordingSessionExternalHooksService
+ extends Disposable
+ implements ISessionExternalHooksService
+{
+ declare readonly _serviceBrand: undefined;
+
+ constructor(@ISessionLifecycleService lifecycle: ISessionLifecycleService) {
+ super();
+ this._register(
+ lifecycle.hooks.onDidCreateSession.register('test', async (event, next) => {
+ recordedSessionHookEvents.push(`create:${event.source}:${event.sessionId}`);
+ await next();
+ }),
+ );
+ this._register(
+ lifecycle.hooks.onWillCloseSession.register('test', async (event, next) => {
+ recordedSessionHookEvents.push(`close:${event.reason}:${event.sessionId}`);
+ await next();
+ }),
+ );
+ }
+}
+
describe('SessionLifecycleService', () => {
let host: ScopedTestHost | undefined;
beforeEach(() => {
+ recordedSessionHookEvents = [];
_clearScopedRegistryForTests();
registerScopedService(
LifecycleScope.App,
@@ -173,6 +205,13 @@ describe('SessionLifecycleService', () => {
InstantiationType.Delayed,
'sessionLifecycle',
);
+ registerScopedService(
+ LifecycleScope.Session,
+ ISessionExternalHooksService,
+ NoopSessionExternalHooksService,
+ InstantiationType.Eager,
+ 'externalHooks',
+ );
});
afterEach(() => {
@@ -267,7 +306,23 @@ describe('SessionLifecycleService', () => {
captured = e;
});
const h = await svc.create({ sessionId: 's1', workDir: '/tmp/proj' });
- expect(captured).toMatchObject({ sessionId: 's1', handle: h });
+ expect(captured).toMatchObject({ sessionId: 's1', handle: h, source: 'startup' });
+ });
+
+ it('runs constructor-registered session lifecycle hooks before returning create and close', async () => {
+ registerScopedService(
+ LifecycleScope.Session,
+ ISessionExternalHooksService,
+ RecordingSessionExternalHooksService,
+ InstantiationType.Eager,
+ 'externalHooks',
+ );
+ const svc = build();
+
+ await svc.create({ sessionId: 's1', workDir: '/tmp/proj' });
+ await svc.close('s1');
+
+ expect(recordedSessionHookEvents).toEqual(['create:startup:s1', 'close:exit:s1']);
});
it('waits for MCP initialization before create returns', async () => {