@yanlinglabs/winter-agent-runtime 0.0.27 → 0.0.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -49,6 +49,10 @@ each session needs its own JavaScript realm: run one session per Bun `Worker`.
49
49
  and returns a `SpawnedRuntimeProcess`. Hand it to `query()` through `Options.spawnClaudeCodeProcess`.
50
50
  The frames on the wire are exactly the ones the spawned binary writes. This module does not load
51
51
  the engine, so it is safe to import on a host's main thread.
52
+ A session's shell commands, stdio MCP servers and hooks run as their own process groups. A Worker
53
+ that is `terminate()`d or crashes never runs the teardown that kills them, so once `exited` settles,
54
+ SIGKILL every group the returned process still lists in `processGroups()`
55
+ (`process.kill(-pgid, "SIGKILL")`). After a clean exit the list is empty.
52
56
  - `@yanlinglabs/winter-agent-runtime/embedded-worker` — the Worker entry. A compiled
53
57
  (`bun build --compile`) host passes its own one-line worker file as an extra entrypoint and
54
58
  constructs the Worker from that file's plain relative path.
@@ -30,6 +30,29 @@ export interface DateChangeAttachment extends AttachmentPayload {
30
30
  type: "date_change";
31
31
  newDate: string;
32
32
  }
33
+ /**
34
+ * WS-24 (I-1 fix round): Winter's own, no claude equivalent. Plan mode moved OUT of the system
35
+ * prompt's dynamic half and into a persisted attachment at the tail of the conversation -- the block
36
+ * used to sit ahead of the conversation history, so a toggle shifted every downstream token and
37
+ * busted the whole cached prefix on the vendor's own prompt-cache accounting (WS-24 follow-up 8's
38
+ * confirmed live finding, on every provider: OpenAI's byte-exact prefix match and Anthropic's `org`
39
+ * dynamic system block alike). As an attachment it costs exactly ONE cache miss on the turn the mode
40
+ * actually changes, and the history stays a stable, cacheable prefix while the mode holds steady in
41
+ * either direction.
42
+ */
43
+ export interface PlanModeAttachment extends AttachmentPayload {
44
+ type: "plan_mode";
45
+ state: "entered" | "exited";
46
+ /**
47
+ * Present only for `state: "entered"`. Captured ONCE at production time
48
+ * (`SystemPromptAssembler.planModeInput`, the settings/brand precedence `assemble()` used to apply
49
+ * inline) rather than re-derived at render time -- a renderer takes only the payload, never live
50
+ * settings or the session's brand.
51
+ */
52
+ plansDirectory?: string;
53
+ plansDirectoryFallback?: string;
54
+ hostPlanBody?: string;
55
+ }
33
56
  export declare const AGENT_LISTING_INITIAL_HEADER = "Available agent types for the Agent tool:";
34
57
  export declare const AGENT_LISTING_ADDED_HEADER = "New agent types are now available for the Agent tool:";
35
58
  export declare const AGENT_LISTING_REMOVED_HEADER = "The following agent types are no longer available:";
@@ -39,6 +62,13 @@ export declare const AMBIENT_CONTEXT_SENTENCE = "This is ambient context \u2014
39
62
  export declare const AGENT_CONCURRENCY_SENTENCE = "When you launch multiple agents for independent work, send them in a single message with multiple tool uses so they run concurrently.";
40
63
  export declare const SKILL_LISTING_HEADER = "The following skills are available for use with the Skill tool:";
41
64
  export declare function dateChangeText(newDate: string): string;
65
+ /**
66
+ * WS-24 (I-1): kept deliberately MINIMAL. The `ExitPlanMode` tool's own result already announces the
67
+ * mode change in the model-visible function_call_output ("Plan approved; permission mode restored to
68
+ * ..."), so this attachment's job is only to cover the OTHER way the mode can leave plan -- a host or
69
+ * UI action (`set_permission_mode`) with no tool call at all -- without repeating that sentence.
70
+ */
71
+ export declare const PLAN_MODE_EXITED_TEXT = "Plan mode has ended. The write restriction is lifted.";
42
72
  /** claude's attachment wrapper (`Qa`). Nothing is added around it and nothing after it. */
43
73
  export declare function wrapSystemReminder(body: string): string;
44
74
  /** A renderer returns the UNWRAPPED body, or `undefined` when the attachment has nothing to say. */
@@ -91,6 +121,13 @@ export declare function attachmentsIn(messages: readonly ProviderMessage[]): Att
91
121
  export declare function announcedAgentTypes(messages: readonly ProviderMessage[]): Set<string>;
92
122
  /** claude's `alr` fold: whether a `date_change` for `date` is already in the history. */
93
123
  export declare function dateChangeAnnounced(messages: readonly ProviderMessage[], date: string): boolean;
124
+ /**
125
+ * WS-24 (I-1): the last `plan_mode` attachment's state, or `"exited"` when none exists yet -- a
126
+ * session that never entered plan mode is, correctly, not IN it. This is what the engine's own
127
+ * producer compares against the LIVE `policyStateStore` mode to decide whether anything changed
128
+ * since the history's own last word on it -- emitting only on a genuine difference, never every turn.
129
+ */
130
+ export declare function lastPlanModeState(messages: readonly ProviderMessage[]): "entered" | "exited";
94
131
  /**
95
132
  * claude's `vlr` resume seed for the skill listing: the names every persisted `skill_listing` sent,
96
133
  * and whether a legacy entry without `names` asks the next listing to be suppressed (claude's
@@ -1,5 +1,6 @@
1
1
  import type { RuntimeConfig } from "@yanlinglabs/winter-agent-sdk";
2
2
  import type { ContextEntry } from "./request-layout.js";
3
+ import type { PlanModeInput } from "./plan-mode.js";
3
4
  export type SkillListing = Array<{
4
5
  name: string;
5
6
  description: string;
@@ -43,7 +44,12 @@ export interface SystemPromptInput {
43
44
  modelDisplayName?: string;
44
45
  memoryDir?: string;
45
46
  outputStyle?: OutputStyle;
46
- planMode: boolean;
47
+ /**
48
+ * WS-24 (I-1 fix round): the host's replacement for the plan-mode body's middle section
49
+ * (`planModeInstructions` on the wire, `plan-mode.ts`'s own header on the two-name mapping). Still
50
+ * carried on the snapshot -- `planModeInput()` reads it -- even though the block itself no longer
51
+ * renders inline here; it moved to a persisted attachment (`context/attachments.ts`'s `plan_mode`).
52
+ */
47
53
  hostPlanBody?: string;
48
54
  /**
49
55
  * DISCLOSED WINTER FIELD, not in the brief's list: the child persona a subagent runs with
@@ -118,6 +124,18 @@ export interface SystemPromptAssembler {
118
124
  * per session context rather than once per turn. Absent = no index-0 message.
119
125
  */
120
126
  userContext?(input: SystemPromptInput): ContextEntry[];
127
+ /**
128
+ * WS-24 (I-1 fix round): the plan-mode block's render inputs -- `plansDirectory` (the session's own
129
+ * project dot-dir default, config then settings then the brand's), `plansDirectoryFallback` (the
130
+ * same brand default, for a refused `plansDirectory`) and `hostPlanBody` passed through. `assemble()`
131
+ * used to derive these inline and render the block into the dynamic system half on every request
132
+ * while the mode held; the block moved to a persisted attachment (`context/attachments.ts`'s
133
+ * `plan_mode`) so a toggle costs one cache miss instead of shifting the whole downstream prefix on
134
+ * every request. The ENGINE calls this ONCE, when producing the attachment on a genuine mode change
135
+ * -- never per render -- so the settings/brand precedence stays derived in this one place. Absent =
136
+ * the engine falls back to the SDK's own `DEFAULT_PLANS_DIRECTORY` with no `hostPlanBody`.
137
+ */
138
+ planModeInput?(input: SystemPromptInput): PlanModeInput;
121
139
  }
122
140
  /**
123
141
  * The spine's own test double. NOT a minimal prompt and never a stand-in for one (R5-16): it echoes
@@ -39,6 +39,20 @@ export interface EmbeddedWorkerProcess extends SpawnedRuntimeProcess {
39
39
  terminate(): void;
40
40
  /** `running` → (`kill()`) `stopping` → `closed`. `closed` means `exited` has settled. */
41
41
  readonly state: "running" | "stopping" | "closed";
42
+ /**
43
+ * WS-24: the process groups the session reported live and has not reported ended
44
+ * (`EmbeddedProcessGroupMessage`), oldest first. Still readable once `exited` has settled -- which is
45
+ * when it matters: a Worker that closed WITHOUT its own teardown (`terminate()` of a spinning Worker,
46
+ * a crash) leaves here exactly the groups it orphaned, and nothing but the host can reap them (each
47
+ * was `setsid`-detached). A healthy close leaves it empty, because every removal is posted before
48
+ * `exit`.
49
+ *
50
+ * The host OWNS the reaping, deliberately: SIGKILL `-pgid` for each entry once `exited` settles
51
+ * (Winter's daemon does, in `runtime-sdk/embedded.ts`). Residual: a group that ended in the instant
52
+ * between its last message and the kill leaves a pgid the OS could in principle hand to a new group
53
+ * leader -- pids are not reused while any member of the group lives, so only that race window remains.
54
+ */
55
+ processGroups(): readonly number[];
42
56
  }
43
57
  /**
44
58
  * Construct the Worker, send `start`, and return the process handle. Never throws for a Worker that
@@ -28,6 +28,7 @@ function spawnEmbeddedWorker(opts) {
28
28
  let stdinEnded = false;
29
29
  let graceTimer;
30
30
  let closeTimer;
31
+ const processGroups = new Set;
31
32
  let settleExited;
32
33
  const exited = new Promise((resolve) => {
33
34
  settleExited = resolve;
@@ -77,6 +78,14 @@ function spawnEmbeddedWorker(opts) {
77
78
  case "stderr":
78
79
  stderr.write(message.chunk);
79
80
  return;
81
+ case "process-group":
82
+ if (!Number.isInteger(message.pgid) || message.pgid <= 1 || message.pgid === process.pid)
83
+ return;
84
+ if (message.op === "add")
85
+ processGroups.add(message.pgid);
86
+ else
87
+ processGroups.delete(message.pgid);
88
+ return;
80
89
  case "exit":
81
90
  exitCode = message.code;
82
91
  closeTimer = setTimeout(terminate, CLOSE_AFTER_EXIT_MS);
@@ -146,7 +155,8 @@ function spawnEmbeddedWorker(opts) {
146
155
  pid: null,
147
156
  get state() {
148
157
  return state;
149
- }
158
+ },
159
+ processGroups: () => [...processGroups]
150
160
  };
151
161
  }
152
162
  export {
@@ -31,10 +31,26 @@ export type EmbeddedWorkerMessage = {
31
31
  } | {
32
32
  kind: "stderr";
33
33
  chunk: string;
34
- } | {
34
+ } | EmbeddedProcessGroupMessage | {
35
35
  kind: "exit";
36
36
  code: number;
37
37
  };
38
+ /**
39
+ * WS-24: a process group the session's realm started (`add`) or saw end (`remove`) -- Bash/Monitor
40
+ * commands, stdio MCP servers, command hooks, the workflow worker (`process-groups.ts`). The host keeps
41
+ * the live set so that a Worker which dies WITHOUT running its own kill doors (a `terminate()` of a
42
+ * spinning Worker, a crash) does not orphan them: `setsid` detached every one from the host process,
43
+ * so nothing else will ever reap them. Posted in order with `stdout`, so by the time a healthy Worker's
44
+ * `exit` arrives every group its teardown ended has already been removed.
45
+ *
46
+ * ADDITIVE: a host that predates it ignores an unknown `kind` (`embedded-host.ts`'s switch has no
47
+ * default arm), and a runtime that predates it simply never posts one.
48
+ */
49
+ export interface EmbeddedProcessGroupMessage {
50
+ kind: "process-group";
51
+ op: "add" | "remove";
52
+ pgid: number;
53
+ }
38
54
  /**
39
55
  * The request ids of the two control frames an ABORT synthesizes (`embedded.ts`). Fixed strings, not
40
56
  * UUIDs, so the output filter can recognise their acknowledgements without state: no host ever
@@ -1,15 +1,25 @@
1
- import"./index-9qgkpv56.js";
1
+ import"./index-mftq279y.js";
2
+ import {
3
+ liveProcessGroups,
4
+ onProcessGroupChange
5
+ } from "./index-8jgwp1px.js";
2
6
  import"./index-bef62z3r.js";
3
- import"./index-584yahed.js";
7
+ import"./index-mqj4x0fn.js";
4
8
  import {
5
9
  runEmbeddedSession2
6
- } from "./index-rkhh0457.js";
10
+ } from "./index-d255xsgm.js";
7
11
  import {
8
12
  Queue
9
13
  } from "./index-97t2rmtf.js";
10
14
 
11
15
  // src/embedded-worker.ts
12
16
  import { isMainThread } from "node:worker_threads";
17
+ var EXIT_AFTER_GROUPS_SETTLE_MS = 250;
18
+ async function groupsSettled() {
19
+ const deadline = Date.now() + EXIT_AFTER_GROUPS_SETTLE_MS;
20
+ while (liveProcessGroups().length > 0 && Date.now() < deadline)
21
+ await new Promise((resolve) => setTimeout(resolve, 10));
22
+ }
13
23
  function post(message) {
14
24
  postMessage(message);
15
25
  }
@@ -24,8 +34,14 @@ function fenceProcessWideState() {
24
34
  return readUmask();
25
35
  };
26
36
  }
37
+ function mirrorProcessGroups() {
38
+ for (const { pgid } of liveProcessGroups())
39
+ post({ kind: "process-group", op: "add", pgid });
40
+ onProcessGroupChange((change) => post({ kind: "process-group", op: change.op, pgid: change.pgid }));
41
+ }
27
42
  function installEmbeddedWorker() {
28
43
  fenceProcessWideState();
44
+ mirrorProcessGroups();
29
45
  const input = new Queue;
30
46
  const abort = new AbortController;
31
47
  let started = false;
@@ -46,6 +62,7 @@ function installEmbeddedWorker() {
46
62
  ` });
47
63
  code = 1;
48
64
  }
65
+ await groupsSettled();
49
66
  post({ kind: "exit", code });
50
67
  process.exit(code);
51
68
  };
package/dist/embedded.js CHANGED
@@ -1,9 +1,10 @@
1
1
  import {
2
2
  runEmbeddedSession2
3
- } from "./index-rkhh0457.js";
4
- import"./index-584yahed.js";
5
- import"./index-9qgkpv56.js";
3
+ } from "./index-d255xsgm.js";
4
+ import"./index-mqj4x0fn.js";
5
+ import"./index-mftq279y.js";
6
6
  import"./index-bef62z3r.js";
7
+ import"./index-8jgwp1px.js";
7
8
  export {
8
9
  runEmbeddedSession2 as runEmbeddedSession
9
10
  };
package/dist/engine.d.ts CHANGED
@@ -64,6 +64,13 @@ export type ContentBlock = {
64
64
  data: string;
65
65
  };
66
66
  };
67
+ /**
68
+ * WS-24: the text a FORK's ToolSearch result carries for loaded tools its frozen `tools` does not declare
69
+ * (the engine's `forkLoadedDefinitionsText`, on a row with `undeclaredToolCalls` evidence). Exported so
70
+ * the live probe that gathers that evidence (`scripts/probe-fork-undeclared-tool.ts`) sends these exact
71
+ * bytes rather than a lookalike.
72
+ */
73
+ export declare function undeclaredToolDefinitionsText(definitions: readonly LoadedToolDefinition[]): string;
67
74
  /** WS-23 (midconv, review I-2): one loaded tool's definition as it stood at load time (see `tool_result.loadedToolDefinitions`). */
68
75
  export interface LoadedToolDefinition {
69
76
  name: string;
@@ -184,6 +191,8 @@ export interface ModelWireFeatures {
184
191
  additionalToolsItem?: true;
185
192
  /** WS-23 (midconv): OpenAI's `tool_choice: allowed_tools` restricts the callable set without editing `tools` (`allowedToolsChoice`). */
186
193
  allowedToolsChoice?: true;
194
+ /** WS-24: the endpoint takes a call (and its history) to a tool absent from `tools` (`undeclaredToolCalls`, live-probe-proven) -- what lets a fork run a self-loaded tool its frozen `tools` lacks. */
195
+ undeclaredToolCalls?: true;
187
196
  }
188
197
  /** What `EngineOptions.describeModel` knows about a model: its display name, its verified effort vocabulary, and its wire features. */
189
198
  export interface ModelDescription {
@@ -458,6 +467,11 @@ export interface ProviderUsage {
458
467
  };
459
468
  /** WS-23: replayed thinking blocks the provider dropped (Anthropic's `input_transformations`). */
460
469
  thinkingBlocksDropped?: number;
470
+ /**
471
+ * WS-24 (follow-up 1, lane `providers`): the Responses family's reasoning-token count -- a SUBSET
472
+ * of `outputTokens`. Anthropic reports no separate count and leaves this absent.
473
+ */
474
+ reasoningTokens?: number;
461
475
  }
462
476
  export type ProviderTurn = {
463
477
  kind: "text";
@@ -809,6 +823,15 @@ export interface EngineOptions {
809
823
  * Round 20 carried the parent's board only, and only to a child with object-form servers.
810
824
  */
811
825
  inheritedMcpServerNames?: () => readonly string[];
826
+ /**
827
+ * WS-24: a SUBAGENT's own MCP servers connected under a name other than the one its definition
828
+ * declared (subagents/child-engine.ts's `allocateChildScopedServers` renames one that collides),
829
+ * as `{ actual: declared }`. Set by child-engine.ts only. A call to `mcp__<actual>__<tool>` is then
830
+ * ALSO governed by every permission rule and hook matcher written against `mcp__<declared>__<tool>`
831
+ * (strictest-of, like a tool alias), so a rename never lets a call escape a rule or hook that named
832
+ * the server as the author declared it.
833
+ */
834
+ mcpServerRenames?: Readonly<Record<string, string>>;
812
835
  mcpControlSeam?: McpControlSeam;
813
836
  contextAccountant?: ContextAccountant;
814
837
  onChildRosterReady?: (getChildren: () => readonly ChildHandle[]) => void;
@@ -1,6 +1,8 @@
1
1
  import { type AttachmentPayload } from "../context/attachments.js";
2
2
  export declare const HOOK_ADDITIONAL_CONTEXT_ATTACHMENT = "hook_additional_context";
3
3
  export declare const HOOK_FEEDBACK_ATTACHMENT = "hook_feedback";
4
+ /** WS-24: what a BACKGROUND (async) hook said once it finished -- hooks/async-hooks.ts. */
5
+ export declare const ASYNC_HOOK_RESPONSE_ATTACHMENT = "async_hook_response";
4
6
  export interface HookAdditionalContextAttachment extends AttachmentPayload {
5
7
  type: typeof HOOK_ADDITIONAL_CONTEXT_ATTACHMENT;
6
8
  hookName: string;
@@ -12,6 +14,24 @@ export interface HookFeedbackAttachment extends AttachmentPayload {
12
14
  hookName: string;
13
15
  content: string[];
14
16
  }
17
+ export interface AsyncHookResponseAttachment extends AttachmentPayload {
18
+ type: typeof ASYNC_HOOK_RESPONSE_ATTACHMENT;
19
+ hookName: string;
20
+ systemMessage?: string;
21
+ content: string[];
22
+ toolUseID?: string;
23
+ /** Set on the notice that `dropped` finished outputs never reached the model (the pending cap). */
24
+ dropped?: number;
25
+ }
26
+ /** WS-24: the attachment for one finished async hook -- `undefined` when it said nothing. */
27
+ export declare function asyncHookResponseAttachment(output: {
28
+ hookName: string;
29
+ systemMessage?: string;
30
+ additionalContext?: string;
31
+ toolUseID?: string;
32
+ }): AsyncHookResponseAttachment | undefined;
33
+ /** WS-24: the notice that `dropped` finished async-hook outputs were discarded for the pending cap. */
34
+ export declare function asyncHookDroppedAttachment(dropped: number): AsyncHookResponseAttachment | undefined;
15
35
  /** The `additionalContext` strings a composite accumulated, in evaluation order (non-string or empty entries dropped). */
16
36
  export declare function contextStrings(extraContext: ReadonlyArray<{
17
37
  context: unknown;
@@ -0,0 +1,64 @@
1
+ import type { HookEvent } from "@yanlinglabs/winter-agent-sdk";
2
+ /** Ten minutes: background work (a test run after an edit, a lint of the tree) is minutes, not seconds -- the gating hooks' 60 s would cut it off. */
3
+ export declare const DEFAULT_ASYNC_HOOK_TIMEOUT_MS = 600000;
4
+ /**
5
+ * Concurrent background hooks per ENGINE. A PostToolUse hook on every call of a 50-call burst must not
6
+ * become 50 processes. Per engine, not per session: a subagent's engine builds its own command invoker
7
+ * and so its own queue (killed at that engine's teardown), so a session running N subagents at once can
8
+ * hold up to (N + 1) x this many -- each still bounded, and each gone with its engine.
9
+ */
10
+ export declare const MAX_RUNNING_ASYNC_HOOKS = 16;
11
+ /** Finished outputs waiting for the next safe point. At `MAX_HOOK_TEXT_CHARS` each, one delivery stays far below the per-message cap. */
12
+ export declare const MAX_PENDING_ASYNC_HOOK_OUTPUTS = 16;
13
+ /** One finished async hook's model-facing output. */
14
+ export interface AsyncHookOutput {
15
+ /** `<Event>` or `<Event>:<tool name>` -- the same naming the synchronous context attachments use. */
16
+ hookName: string;
17
+ systemMessage?: string;
18
+ additionalContext?: string;
19
+ toolUseID?: string;
20
+ }
21
+ /** What `drain()` returns: finished outputs, oldest first, plus how many were dropped for the pending cap. */
22
+ export interface AsyncHookDrain {
23
+ outputs: AsyncHookOutput[];
24
+ dropped: number;
25
+ }
26
+ /** A backgrounded invocation as the command invoker hands it over. */
27
+ export interface AsyncHookJob {
28
+ event: HookEvent;
29
+ hookName: string;
30
+ toolUseID?: string;
31
+ /** Settles when the process has exited, with what `finishedOutput` needs. Never rejects. */
32
+ finished: Promise<FinishedHookProcess>;
33
+ /** SIGKILL the job's whole process group. Idempotent. */
34
+ kill: () => void;
35
+ timeoutMs: number;
36
+ }
37
+ /** A background process's end state. */
38
+ export interface FinishedHookProcess {
39
+ exitCode: number | null;
40
+ stdout: string;
41
+ }
42
+ export interface AsyncHookQueue {
43
+ /** Take ownership of a backgrounded job. `false` (and the job killed) when the concurrency cap is reached or the queue is disposed. */
44
+ adopt(job: AsyncHookJob): boolean;
45
+ /** Finished outputs since the last drain, oldest first. Empties the queue. */
46
+ drain(): AsyncHookDrain;
47
+ /** Jobs still running. */
48
+ running(): number;
49
+ /** Session end: kill every running job, forget every undelivered output. Idempotent. */
50
+ dispose(): void;
51
+ }
52
+ /**
53
+ * What a finished background hook SAYS -- `systemMessage` and `additionalContext`, nothing else (see this
54
+ * file's header). Only a clean exit counts: a non-zero exit (2 included -- a block it can no longer
55
+ * perform) or a kill says nothing. `stdout` is everything AFTER an announced `{"async": true}` line.
56
+ */
57
+ export declare function finishedOutput(event: HookEvent, finished: FinishedHookProcess): Pick<AsyncHookOutput, "systemMessage" | "additionalContext">;
58
+ export interface AsyncHookQueueOptions {
59
+ maxRunning?: number;
60
+ maxPending?: number;
61
+ /** Where a refused, failed or timed-out job is reported (one line each). Defaults to stderr. */
62
+ warn?: (line: string) => void;
63
+ }
64
+ export declare function createAsyncHookQueue(opts?: AsyncHookQueueOptions): AsyncHookQueue;
@@ -1,6 +1,7 @@
1
1
  import { type BrandProfile } from "@yanlinglabs/winter-agent-sdk";
2
2
  import { type HookInvocationRequest, type HookInvoker } from "./runner.js";
3
3
  import type { SourcedHookEntry } from "./registry.js";
4
+ import { type AsyncHookQueue } from "./async-hooks.js";
4
5
  /** Grace between SIGTERM and SIGKILL. Short: by the time this fires the runner has already given up on the hook. */
5
6
  export declare const COMMAND_HOOK_KILL_GRACE_MS = 250;
6
7
  export declare class CommandHookError extends Error {
@@ -28,6 +29,12 @@ export interface CommandHookInvokerOptions {
28
29
  transcriptPath?: string;
29
30
  /** WS-23: the stdin input's `permission_mode`, read at invocation time (it changes mid-session). */
30
31
  permissionMode?: () => string | undefined;
32
+ /** WS-24: where backgrounded (async) hooks go. Defaults to a fresh `createAsyncHookQueue()`, exposed as the invoker's `asyncHooks`. */
33
+ asyncHooks?: AsyncHookQueue;
34
+ }
35
+ /** WS-24: the command invoker also OWNS the session's background hooks (hooks/async-hooks.ts). */
36
+ export interface CommandHookInvoker extends HookInvoker {
37
+ readonly asyncHooks: AsyncHookQueue;
31
38
  }
32
39
  /**
33
40
  * Wraps `opts.next`, executing any invocation whose `hookId` belongs to a command-bearing entry as a
@@ -36,7 +43,7 @@ export interface CommandHookInvokerOptions {
36
43
  * The entry list is snapshotted at construction, matching `buildHookRegistry`'s own "a registry is
37
44
  * immutable for the life of a run" contract -- a run builds both from the same entries.
38
45
  */
39
- export declare function createCommandHookInvoker(entries: readonly SourcedHookEntry[], opts: CommandHookInvokerOptions): HookInvoker;
46
+ export declare function createCommandHookInvoker(entries: readonly SourcedHookEntry[], opts: CommandHookInvokerOptions): CommandHookInvoker;
40
47
  /**
41
48
  * claude's command-hook stdin: the snake_case `HookInput` for this event, built from the runner's
42
49
  * camelCase request. `payload` already carries each event's own fields in claude's spelling (the
@@ -21,6 +21,12 @@ export interface SourcedHookEntry extends HookParticipant {
21
21
  * (command-invoker.ts). Absent on every non-plugin entry.
22
22
  */
23
23
  pluginRoot?: string;
24
+ /**
25
+ * WS-24: a settings/plugin command handler declared `async: true` -- it runs in the BACKGROUND and
26
+ * never blocks its event (hooks/async-hooks.ts). Never set together with `failClosed` on a
27
+ * PreToolUse/PermissionRequest entry: from-config.ts refuses the flag there (a floor must gate).
28
+ */
29
+ async?: boolean;
24
30
  }
25
31
  export interface HookRegistry {
26
32
  matching(event: HookEvent, toolName?: string): SourcedHookEntry[];
@@ -1,6 +1,7 @@
1
1
  import type { HookEvent, HookPermissionDecision } from "@yanlinglabs/winter-agent-sdk";
2
2
  import { type HookRegistry } from "./registry.js";
3
3
  import { type HookComposite } from "./reducer.js";
4
+ import type { AsyncHookQueue } from "./async-hooks.js";
4
5
  export interface HookInvocationRequest {
5
6
  event: HookEvent;
6
7
  matchedMatcher?: string;
@@ -8,6 +9,14 @@ export interface HookInvocationRequest {
8
9
  agentID?: string;
9
10
  toolUseID?: string;
10
11
  toolName?: string;
12
+ /**
13
+ * WS-24: for an MCP tool, the server that registered `toolName` and the tool's own name there
14
+ * (`mcpToolProvenance` below). Both builders of a hook's input -- `commandHookInput`
15
+ * (command-invoker.ts) and the wrapper's `buildHookInput` (sdk query.ts) -- turn them into
16
+ * `mcp_server_name`/`mcp_tool_name`. Absent for every non-MCP tool and every tool-less event.
17
+ */
18
+ mcpServerName?: string;
19
+ mcpToolName?: string;
11
20
  input?: Record<string, unknown>;
12
21
  payload?: unknown;
13
22
  policyVersion: string;
@@ -15,10 +24,21 @@ export interface HookInvocationRequest {
15
24
  hookId: string;
16
25
  hookName?: string;
17
26
  }
27
+ export interface McpToolProvenanceInfo {
28
+ server: string;
29
+ tool: string;
30
+ }
31
+ export declare function mcpToolProvenance(toolName: string): McpToolProvenanceInfo | undefined;
18
32
  export interface HookInvoker {
19
33
  invoke(request: HookInvocationRequest, opts: {
20
34
  signal: AbortSignal;
21
35
  }): Promise<unknown>;
36
+ /**
37
+ * WS-24: the session's background (async) hooks, when this invoker can run any -- only the command
38
+ * invoker can (hooks/async-hooks.ts). The engine drains it at each safe point and disposes it at
39
+ * session end; a callback-only session has none.
40
+ */
41
+ readonly asyncHooks?: AsyncHookQueue;
22
42
  }
23
43
  export type HookAuditOutcome = "decision" | "none" | "error" | "timeout" | "skipped";
24
44
  export interface HookAuditRecord {
@@ -100,6 +120,8 @@ export interface RunHooksContext {
100
120
  timeouts?: HookTimeoutConfig;
101
121
  validator?: ToolInputValidator;
102
122
  lifecycle?: HookLifecycleSink;
123
+ /** WS-24: the MCP provenance lookup. Defaults to `mcpToolProvenance` (the live registry); a test seam. */
124
+ mcpProvenance?: (toolName: string) => McpToolProvenanceInfo | undefined;
103
125
  }
104
126
  export declare const FAIL_CLOSED_EVENTS: ReadonlySet<HookEvent>;
105
127
  export declare function runHooks(event: HookEvent, call: RunHooksCallInfo, ctx: RunHooksContext): Promise<HookComposite>;