@dbx-tools/appkit 0.9.70 → 0.9.71

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/package.json CHANGED
@@ -20,7 +20,7 @@
20
20
  },
21
21
  "devDependencies": {
22
22
  "@databricks/appkit": "0.81.0",
23
- "@dbx-tools/projen": "^0.9.70",
23
+ "@dbx-tools/projen": "^0.9.71",
24
24
  "@types/node": "^24.6.0",
25
25
  "typescript": "^5.9.3",
26
26
  "vitest": "3.2.4"
@@ -30,11 +30,11 @@
30
30
  },
31
31
  "dependencies": {
32
32
  "@databricks/sdk-experimental": "^0.17.0",
33
- "@dbx-tools/auth": "^0.9.70",
34
- "@dbx-tools/core": "^0.9.70",
35
- "@dbx-tools/lakebase": "^0.9.70",
36
- "@dbx-tools/postgres": "^0.9.70",
37
- "@dbx-tools/shared-core": "^0.9.70",
33
+ "@dbx-tools/auth": "^0.9.71",
34
+ "@dbx-tools/core": "^0.9.71",
35
+ "@dbx-tools/lakebase": "^0.9.71",
36
+ "@dbx-tools/postgres": "^0.9.71",
37
+ "@dbx-tools/shared-core": "^0.9.71",
38
38
  "zod": "4.3.6"
39
39
  },
40
40
  "main": "./lib/index.js",
@@ -115,7 +115,7 @@
115
115
  "./package.json": "./package.json"
116
116
  }
117
117
  },
118
- "version": "0.9.70",
118
+ "version": "0.9.71",
119
119
  "types": "./lib/index.d.ts",
120
120
  "dbxToolsConfig": {
121
121
  "tags": [
package/src/appkit.ts CHANGED
@@ -103,8 +103,8 @@ export type CreateAppConfig<T extends AppKitPlugins = AppKitPlugins> = Omit<
103
103
  /**
104
104
  * One or many {@link Interceptor}s handed an {@link InterceptorContext} once
105
105
  * auto-configuration has computed the env. Each receives the resolved env, an
106
- * AppKit-lifecycle hook, and `bindProcess` for concurrently-style supervision -
107
- * see `./interceptor`. The tunnel is the primary consumer.
106
+ * AppKit lifecycle hook. See `./interceptor`. The tunnel is the primary
107
+ * consumer.
108
108
  */
109
109
  interceptor?: Interceptor | Interceptor[];
110
110
  };
@@ -293,7 +293,7 @@ function interceptorList(interceptor: CreateAppConfig["interceptor"]): Intercept
293
293
  * When {@link CreateAppConfig.interceptor}s are given, each is invoked with an
294
294
  * {@link InterceptorContext} AFTER auto-configuration computes the env and BEFORE
295
295
  * AppKit boots - so an interceptor can read the resolved connection, register
296
- * lifecycle handlers, and `bindProcess` a child. A hidden {@link lifecycleBridge}
296
+ * lifecycle handlers, and start auxiliary work. A hidden {@link lifecycleBridge}
297
297
  * plugin is injected so those `onLifecycle` handlers fire on the genuine AppKit
298
298
  * events; it has no exports, so the returned {@link PluginMap} is unchanged.
299
299
  *
@@ -4,20 +4,44 @@
4
4
  * @module
5
5
  */
6
6
  import * as exec from "@dbx-tools/core/exec";
7
+ import { asyncUtils } from "@dbx-tools/shared-core";
8
+
9
+ const DEFAULT_HEALTH_CHECK_INTERVAL_MS = 200;
10
+ const DEFAULT_HEALTH_CHECK_TIMEOUT_MS = 30_000;
11
+ const DEFAULT_RUN_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"] as const;
7
12
 
8
13
  /** Spawn arguments accepted by {@link AppKitChildProcess}. */
9
14
  export type AppKitSpawnArgs = exec.SpawnArgs<exec.ExecOptions>;
10
15
 
11
- /** Graceful and forced shutdown policy for an AppKit child process. */
12
- export interface AppKitChildProcessOptions {
13
- /** Signal sent when shutdown starts. Defaults to `SIGTERM`. */
14
- gracefulSignal?: NodeJS.Signals;
15
- /** Time to wait after the graceful signal. Defaults to 7 seconds. */
16
- gracefulTimeoutMs?: number;
17
- /** Signal sent when graceful shutdown times out. Defaults to `SIGKILL`. */
18
- forceSignal?: NodeJS.Signals;
19
- /** Time to wait after the force signal. Defaults to 1 second. */
20
- forceTimeoutMs?: number;
16
+ /** Context passed to one child-process readiness probe. */
17
+ export interface AppKitChildProcessHealthCheckContext {
18
+ /** Spawned process being checked. */
19
+ readonly process: exec.ChildProcessResult;
20
+ /** Aborts when shutdown begins or the readiness timeout expires. */
21
+ readonly signal: AbortSignal;
22
+ /** Zero-based readiness attempt. */
23
+ readonly attempt: number;
24
+ }
25
+
26
+ /** Return `true` when the spawned process is ready to serve callers. */
27
+ export type AppKitChildProcessHealthCheck = (
28
+ context: AppKitChildProcessHealthCheckContext,
29
+ ) => boolean | PromiseLike<boolean>;
30
+
31
+ /** Readiness and tree-shutdown policy for an AppKit child process. */
32
+ export interface AppKitChildProcessOptions extends exec.KillOptions {
33
+ /** Optional readiness probe polled after the process is spawned. */
34
+ healthCheck?: AppKitChildProcessHealthCheck;
35
+ /** Delay between readiness probes. Defaults to 200 milliseconds. */
36
+ healthCheckIntervalMs?: number;
37
+ /** Maximum time allowed for readiness. Defaults to 30 seconds. */
38
+ healthCheckTimeoutMs?: number;
39
+ }
40
+
41
+ /** Foreground signal policy for {@link AppKitChildProcess.run}. */
42
+ export interface AppKitChildProcessRunOptions {
43
+ /** Parent signals that trigger tree shutdown before being re-raised. */
44
+ signals?: readonly NodeJS.Signals[];
21
45
  }
22
46
 
23
47
  /**
@@ -25,26 +49,36 @@ export interface AppKitChildProcessOptions {
25
49
  *
26
50
  * The spawn arguments use {@link exec.ExecOptions}, including core exec's
27
51
  * stdin, output capture, line handlers, exit checking, and trim policy.
28
- * Shutdown is idempotent and escalates from the graceful signal to the force
29
- * signal when the child does not exit within the configured timeouts.
52
+ * When configured, {@link AppKitChildProcessOptions.healthCheck} is polled
53
+ * until ready before {@link start} resolves. Shutdown aborts any active probe
54
+ * and delegates process-tree termination to {@link exec.kill}.
30
55
  */
31
56
  export class AppKitChildProcess {
32
57
  readonly #spawnArgs: AppKitSpawnArgs;
33
- readonly #options: Required<AppKitChildProcessOptions>;
34
- readonly #detached: boolean;
58
+ readonly #killOptions: exec.KillOptions;
59
+ readonly #healthCheck?: AppKitChildProcessHealthCheck;
60
+ readonly #healthCheckIntervalMs: number;
61
+ readonly #healthCheckTimeoutMs: number;
35
62
 
36
63
  #child?: exec.ChildProcessResult;
64
+ #healthCheckController?: AbortController;
37
65
  #shutdownPromise?: Promise<void>;
38
66
 
39
67
  constructor(spawnArgs: AppKitSpawnArgs, options: AppKitChildProcessOptions = {}) {
40
68
  this.#spawnArgs = spawnArgs;
41
- this.#options = {
42
- gracefulSignal: options.gracefulSignal ?? "SIGTERM",
43
- gracefulTimeoutMs: options.gracefulTimeoutMs ?? 7_000,
44
- forceSignal: options.forceSignal ?? "SIGKILL",
45
- forceTimeoutMs: options.forceTimeoutMs ?? 1_000,
46
- };
47
- this.#detached = spawnOptions(spawnArgs)?.detached === true;
69
+ const {
70
+ healthCheck,
71
+ healthCheckIntervalMs = DEFAULT_HEALTH_CHECK_INTERVAL_MS,
72
+ healthCheckTimeoutMs = DEFAULT_HEALTH_CHECK_TIMEOUT_MS,
73
+ ...killOptions
74
+ } = options;
75
+ this.#healthCheck = healthCheck;
76
+ this.#healthCheckIntervalMs = positiveMilliseconds(
77
+ healthCheckIntervalMs,
78
+ "healthCheckIntervalMs",
79
+ );
80
+ this.#healthCheckTimeoutMs = positiveMilliseconds(healthCheckTimeoutMs, "healthCheckTimeoutMs");
81
+ this.#killOptions = killOptions;
48
82
  }
49
83
 
50
84
  /** Current child handle, or `undefined` when no child is running. */
@@ -57,101 +91,148 @@ export class AppKitChildProcess {
57
91
  return this.#child !== undefined && !hasExited(this.#child);
58
92
  }
59
93
 
60
- /** Start the configured child process. */
61
- start(): exec.ChildProcessResult {
94
+ /** Spawn the child and resolve only after its optional readiness probe succeeds. */
95
+ start(): Promise<void> {
62
96
  if (this.running) throw new Error("Child process is already running");
63
97
  if (this.#shutdownPromise) {
64
98
  throw new Error("Child process is shutting down or has been shut down");
65
99
  }
66
100
 
67
101
  const child = exec.spawn(...this.#spawnArgs);
102
+ const controller = new AbortController();
68
103
  this.#child = child;
104
+ this.#healthCheckController = controller;
69
105
  void child.once("close", () => {
70
106
  if (this.#child === child) this.#child = undefined;
71
107
  });
72
- return child;
108
+ return this.#completeStart(child, controller);
73
109
  }
74
110
 
75
- /** Stop the child once, escalating when graceful shutdown times out. */
76
- shutdown(): Promise<void> {
77
- this.#shutdownPromise ??= this.#performShutdown();
111
+ /**
112
+ * Start, await, and shut down one foreground child while preserving parent
113
+ * termination signals.
114
+ */
115
+ async run(options: AppKitChildProcessRunOptions = {}): Promise<exec.ExecResult> {
116
+ const signals = options.signals ?? DEFAULT_RUN_SIGNALS;
117
+ if (signals.includes("SIGKILL")) {
118
+ throw new Error("SIGKILL cannot be intercepted by a Node process");
119
+ }
120
+ const handlers = new Map<NodeJS.Signals, () => void>();
121
+ let stopping = false;
122
+ const removeHandlers = () => {
123
+ for (const [signal, handler] of handlers) process.off(signal, handler);
124
+ };
125
+ for (const signal of signals) {
126
+ const handler = () => {
127
+ if (stopping) return;
128
+ stopping = true;
129
+ removeHandlers();
130
+ void this.shutdown()
131
+ .finally(() => process.kill(process.pid, signal))
132
+ .catch(() => undefined);
133
+ };
134
+ handlers.set(signal, handler);
135
+ process.once(signal, handler);
136
+ }
137
+
138
+ try {
139
+ await this.start();
140
+ const child = this.#child;
141
+ if (!child) throw new Error("Child process exited after startup");
142
+ return await child;
143
+ } finally {
144
+ removeHandlers();
145
+ await this.shutdown();
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Abort readiness and stop the process tree once.
151
+ *
152
+ * An explicit non-`SIGKILL` signal runs only the graceful polling phase.
153
+ * Explicit `SIGKILL` skips directly to forced polling. Omitting the signal
154
+ * uses the configured graceful and forced policy.
155
+ */
156
+ shutdown(signal?: NodeJS.Signals): Promise<void> {
157
+ this.#healthCheckController?.abort(
158
+ new DOMException("Child process shutdown requested", "AbortError"),
159
+ );
160
+ const killOptions: exec.KillOptions =
161
+ signal === undefined
162
+ ? this.#killOptions
163
+ : signal === "SIGKILL"
164
+ ? { ...this.#killOptions, gracefulSignal: false, forceSignal: signal }
165
+ : { ...this.#killOptions, gracefulSignal: signal, forceSignal: false };
166
+ this.#shutdownPromise ??= this.#performShutdown(killOptions);
78
167
  return this.#shutdownPromise;
79
168
  }
80
169
 
81
- async #performShutdown(): Promise<void> {
82
- const child = this.#child;
83
- if (!child || hasExited(child)) {
84
- this.#child = undefined;
85
- return;
170
+ async #completeStart(child: exec.ChildProcessResult, controller: AbortController): Promise<void> {
171
+ try {
172
+ if (this.#healthCheck) {
173
+ await Promise.race([
174
+ this.#waitUntilHealthy(child, controller.signal),
175
+ child.then((result) => {
176
+ throw new Error(
177
+ `Child process ${child.pid ?? "<unknown>"} exited before becoming ready ` +
178
+ `(exit ${result.exitCode})`,
179
+ );
180
+ }),
181
+ ]);
182
+ }
183
+ } catch (error) {
184
+ try {
185
+ await this.shutdown();
186
+ } catch (shutdownError) {
187
+ throw new AggregateError(
188
+ [error, shutdownError],
189
+ `Child process ${child.pid ?? "<unknown>"} failed readiness and shutdown`,
190
+ );
191
+ }
192
+ throw error;
193
+ } finally {
194
+ if (this.#healthCheckController === controller) {
195
+ this.#healthCheckController = undefined;
196
+ }
86
197
  }
198
+ }
87
199
 
88
- signalChild(child, this.#options.gracefulSignal, this.#detached);
89
- if (await waitForExit(child, this.#options.gracefulTimeoutMs)) {
90
- if (this.#child === child) this.#child = undefined;
91
- return;
200
+ async #waitUntilHealthy(child: exec.ChildProcessResult, signal: AbortSignal): Promise<void> {
201
+ const healthCheck = this.#healthCheck;
202
+ if (!healthCheck) return;
203
+ for await (const healthy of asyncUtils.poll(
204
+ ({ attempt, signal: pollSignal }) =>
205
+ healthCheck({ process: child, signal: pollSignal, attempt }),
206
+ {
207
+ intervalMs: this.#healthCheckIntervalMs,
208
+ timeoutMs: this.#healthCheckTimeoutMs,
209
+ signal,
210
+ predicate: (healthy) => !healthy,
211
+ },
212
+ )) {
213
+ if (healthy) return;
92
214
  }
215
+ }
93
216
 
94
- signalChild(child, this.#options.forceSignal, this.#detached);
95
- if (!(await waitForExit(child, this.#options.forceTimeoutMs))) {
96
- throw new Error(`Child process ${child.pid ?? "<unknown>"} did not terminate`);
217
+ async #performShutdown(options: exec.KillOptions): Promise<void> {
218
+ const child = this.#child;
219
+ if (!child) {
220
+ this.#child = undefined;
221
+ return;
97
222
  }
223
+
224
+ await exec.kill(child, options);
98
225
  if (this.#child === child) this.#child = undefined;
99
226
  }
100
227
  }
101
228
 
102
- function spawnOptions(args: AppKitSpawnArgs): exec.ExecOptions | undefined {
103
- const value = args.at(-1);
104
- return isExecOptions(value) ? value : undefined;
105
- }
106
-
107
- function isExecOptions(value: unknown): value is exec.ExecOptions {
108
- return value !== null && typeof value === "object" && !Array.isArray(value);
109
- }
110
-
111
229
  function hasExited(child: exec.ChildProcessResult): boolean {
112
230
  return child.exitCode !== null || child.signalCode !== null;
113
231
  }
114
232
 
115
- function signalChild(
116
- child: exec.ChildProcessResult,
117
- signal: NodeJS.Signals,
118
- detached: boolean,
119
- ): void {
120
- if (detached && process.platform !== "win32" && child.pid) {
121
- try {
122
- process.kill(-child.pid, signal);
123
- return;
124
- } catch {
125
- // Fall back to the direct child when the process group already exited.
126
- }
233
+ function positiveMilliseconds(value: number, name: string): number {
234
+ if (!Number.isFinite(value) || value <= 0) {
235
+ throw new RangeError(`${name} must be a finite positive number`);
127
236
  }
128
- child.kill(signal);
129
- }
130
-
131
- function waitForExit(child: exec.ChildProcessResult, timeoutMs: number): Promise<boolean> {
132
- if (hasExited(child)) return Promise.resolve(true);
133
-
134
- return new Promise((resolve) => {
135
- let timer: NodeJS.Timeout | undefined;
136
- let settled = false;
137
-
138
- const finish = (exited: boolean) => {
139
- if (settled) return;
140
- settled = true;
141
- if (timer) clearTimeout(timer);
142
- void child.off("exit", onExit);
143
- void child.off("error", onError);
144
- resolve(exited);
145
- };
146
- const onExit = () => finish(true);
147
- const onError = () => finish(true);
148
-
149
- void child.once("exit", onExit);
150
- void child.once("error", onError);
151
- if (hasExited(child)) {
152
- finish(true);
153
- return;
154
- }
155
- timer = setTimeout(() => finish(false), timeoutMs);
156
- });
237
+ return value;
157
238
  }
@@ -1,8 +1,7 @@
1
1
  /**
2
2
  * The `createApp` INTERCEPTOR context: an in-process handle an app hands to
3
3
  * add-ons (the tunnel, chiefly) so they can read the env auto-configuration
4
- * computed, hook AppKit's lifecycle, and supervise sibling child processes as one
5
- * unit - concurrently-style, where any death takes the whole set down.
4
+ * computed and hook AppKit's lifecycle.
6
5
  *
7
6
  * An interceptor is a plain function `(ctx) => void | Promise<void>` passed to
8
7
  * `appkit.createApp`'s `interceptor` option ("one or many"). `appkit.createApp`
@@ -20,7 +19,6 @@
20
19
  * @module
21
20
  */
22
21
 
23
- import type { ChildProcess } from "node:child_process";
24
22
  import { Plugin, toPlugin, type BasePluginConfig, type PluginManifest } from "@databricks/appkit";
25
23
  import { log } from "@dbx-tools/shared-core";
26
24
  import type { LakebaseConnection } from "./lakebase-resolver.ts";
@@ -60,14 +58,6 @@ export interface ResolvedAppEnv {
60
58
  readonly databricksHost?: string;
61
59
  }
62
60
 
63
- /**
64
- * A process {@link InterceptorContext.bindProcess} can supervise. A bare
65
- * `node:child_process` `ChildProcess` (e.g. portr) satisfies this, and so does
66
- * `@dbx-tools/core`'s `spawn()` result (a `ChildProcessResult` IS a
67
- * `ChildProcess`) - one code path handles both.
68
- */
69
- export type BindableProcess = Pick<ChildProcess, "pid" | "kill" | "killed" | "once">;
70
-
71
61
  /**
72
62
  * The handle passed to each interceptor.
73
63
  *
@@ -77,9 +67,8 @@ export type BindableProcess = Pick<ChildProcess, "pid" | "kill" | "killed" | "on
77
67
  * await appkit.createApp({
78
68
  * plugins: [server()],
79
69
  * interceptor: (ctx) => {
80
- * const portr = spawnPortr(ctx.env.databricksHost);
81
- * ctx.bindProcess(portr); // app <-> portr live/die together
82
- * ctx.onLifecycle("shutdown", () => portr.kill("SIGTERM"));
70
+ * const auxiliary = startAuxiliary(ctx.env.databricksHost);
71
+ * ctx.onLifecycle("shutdown", () => auxiliary.stop());
83
72
  * },
84
73
  * });
85
74
  */
@@ -93,66 +82,11 @@ export interface InterceptorContext {
93
82
  * retroactively (same semantics as AppKit).
94
83
  */
95
84
  onLifecycle(event: LifecycleEvent, fn: LifecycleHandler): void;
96
- /** Register synchronous cleanup that runs before any bound process is killed. */
97
- onTeardown(fn: () => void): void;
98
- /**
99
- * Broadcast a termination signal from the main app to every bound process.
100
- * Called automatically when this process receives `SIGINT`/`SIGTERM`/`SIGHUP`;
101
- * exposed so an interceptor can trigger teardown itself.
102
- */
103
- broadcastSignal(signal: NodeJS.Signals): void;
104
- /**
105
- * Supervise a child process alongside the app, concurrently-style: signals pass
106
- * through, and if EITHER the child or the app dies the whole set comes down.
107
- * Safe to call for several children; teardown is idempotent.
108
- */
109
- bindProcess(child: BindableProcess): void;
110
85
  }
111
86
 
112
87
  /** A single interceptor, or several. The `createApp` `interceptor?:` option. */
113
88
  export type Interceptor = (ctx: InterceptorContext) => void | Promise<void>;
114
89
 
115
- /** How long bound children get to exit on `SIGTERM` before the app force-exits. */
116
- const TEARDOWN_GRACE_MS = 10_000;
117
-
118
- /** The signals that trigger teardown when the MAIN process receives them. */
119
- const TEARDOWN_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"] as const;
120
- const boundChildren = new Set<BindableProcess>();
121
- const teardownHandlers = new Set<() => void>();
122
- let processShuttingDown = false;
123
- let processSignalsBound = false;
124
-
125
- function teardownProcess(code: number): void {
126
- if (processShuttingDown) return;
127
- processShuttingDown = true;
128
- for (const handler of teardownHandlers) handler();
129
- teardownHandlers.clear();
130
- const children = [...boundChildren];
131
- for (const child of children) {
132
- if (!child.killed) child.kill("SIGTERM");
133
- }
134
- boundChildren.clear();
135
- setTimeout(() => {
136
- for (const child of children) child.kill("SIGKILL");
137
- process.exit(code);
138
- }, TEARDOWN_GRACE_MS).unref();
139
- }
140
-
141
- function broadcastProcessSignal(signal: NodeJS.Signals): void {
142
- for (const child of boundChildren) {
143
- if (!child.killed) child.kill(signal);
144
- }
145
- teardownProcess(0);
146
- }
147
-
148
- function bindProcessSignals(): void {
149
- if (processSignalsBound) return;
150
- processSignalsBound = true;
151
- for (const signal of TEARDOWN_SIGNALS) {
152
- process.on(signal, () => broadcastProcessSignal(signal));
153
- }
154
- }
155
-
156
90
  /**
157
91
  * The mutable machinery behind an {@link InterceptorContext}. Split out so
158
92
  * {@link createInterceptorContext} can hand the public context to interceptors
@@ -167,31 +101,9 @@ export interface InterceptorRuntime {
167
101
 
168
102
  /**
169
103
  * Build an {@link InterceptorContext} + its {@link InterceptorRuntime}.
170
- *
171
- * Teardown is the generalized `superviseExit`: the first child exit or main-process
172
- * termination signal flips a one-shot guard, `SIGTERM`s the other bound children,
173
- * then `process.exit`s after {@link TEARDOWN_GRACE_MS} (an `unref`'d timer, so it
174
- * never itself holds the loop open). The process-signal listeners are installed
175
- * lazily on the first `bindProcess` so an app that binds nothing is untouched.
176
104
  */
177
105
  export function createInterceptorContext(env: ResolvedAppEnv): InterceptorRuntime {
178
106
  const handlers = new Map<LifecycleEvent, LifecycleHandler[]>();
179
- if (processShuttingDown && boundChildren.size === 0) processShuttingDown = false;
180
- if (
181
- processSignalsBound &&
182
- TEARDOWN_SIGNALS.every((signal) => process.listenerCount(signal) === 0)
183
- ) {
184
- processSignalsBound = false;
185
- }
186
-
187
- const bindProcess = (child: BindableProcess): void => {
188
- bindProcessSignals();
189
- boundChildren.add(child);
190
- child.once("exit", (code) => {
191
- logger.warn("bound process exited; tearing down the app", { code });
192
- teardownProcess(typeof code === "number" ? code : 1);
193
- });
194
- };
195
107
 
196
108
  const onLifecycle = (event: LifecycleEvent, fn: LifecycleHandler): void => {
197
109
  const list = handlers.get(event);
@@ -216,12 +128,6 @@ export function createInterceptorContext(env: ResolvedAppEnv): InterceptorRuntim
216
128
  const context: InterceptorContext = {
217
129
  env,
218
130
  onLifecycle,
219
- onTeardown: (fn) => {
220
- bindProcessSignals();
221
- teardownHandlers.add(fn);
222
- },
223
- broadcastSignal: broadcastProcessSignal,
224
- bindProcess,
225
131
  };
226
132
 
227
133
  return { context, emitLifecycle };
@@ -9,6 +9,16 @@ import {
9
9
  type ToolRegistry,
10
10
  } from "@databricks/appkit/beta";
11
11
 
12
+ /** Optional request state supplied by an agent host when dispatching a plugin tool. */
13
+ export interface AgentToolExecutionContext {
14
+ /** Resource identity associated with the current agent turn. */
15
+ readonly resourceId?: string;
16
+ /** Model endpoint selected for the current agent turn. */
17
+ readonly model?: string;
18
+ /** Best-effort progress sink validated by the hosting integration. */
19
+ readonly writeProgress?: (event: unknown) => Promise<void>;
20
+ }
21
+
12
22
  /**
13
23
  * Implements AppKit's `ToolProvider` contract from one native tool registry.
14
24
  *
@@ -28,7 +38,12 @@ export abstract class ToolRegistryPlugin<TConfig extends BasePluginConfig = Base
28
38
  }
29
39
 
30
40
  /** Validate and dispatch one AppKit agent tool call. */
31
- executeAgentTool(name: string, args: unknown, signal?: AbortSignal): Promise<unknown> {
41
+ executeAgentTool(
42
+ name: string,
43
+ args: unknown,
44
+ signal?: AbortSignal,
45
+ _context?: AgentToolExecutionContext,
46
+ ): Promise<unknown> {
32
47
  return executeFromRegistry(this.toolRegistry, name, args, signal);
33
48
  }
34
49
  }