@gobing-ai/ts-runtime 0.4.8 → 0.4.10

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.
@@ -0,0 +1,143 @@
1
+ /**
2
+ * In-process registry of ProcessExecutor invocations (spur#0264 / M1).
3
+ *
4
+ * Tracks every `run` / `runStreaming` so board UIs (and other observers) can
5
+ * list all harness-launched processes — not only supervisor-owned agent loops.
6
+ * In-memory only; not durable across process restarts.
7
+ */
8
+ /** Who initiated the spawn — used for filtering/grouping in consumers. */
9
+ export type ProcessExecutionSource = 'supervisor' | 'one-shot' | 'other';
10
+ /** Lifecycle status of a tracked execution. */
11
+ export type ProcessExecutionStatus = 'running' | 'exited';
12
+ /**
13
+ * One ProcessExecutor invocation — the minimum metadata Spur's Processes tab
14
+ * (and similar consumers) need for a full watch list.
15
+ */
16
+ export interface ProcessExecution {
17
+ /** Unique id for this execution (stable for the process lifetime). */
18
+ readonly id: string;
19
+ /** Optional human label (e.g. `agent:alpha-claude`). */
20
+ readonly label?: string;
21
+ readonly command: string;
22
+ readonly args: readonly string[];
23
+ /** OS pid when known (may arrive after spawn for buffered runs). */
24
+ readonly pid?: number;
25
+ /** ISO-8601 start time. */
26
+ readonly startedAt: string;
27
+ /** ISO-8601 end time when exited. */
28
+ readonly exitedAt?: string;
29
+ /** Exit code when exited (`null` for signal/timeout/error without code). */
30
+ readonly exitCode?: number | null;
31
+ readonly source: ProcessExecutionSource;
32
+ /** Optional team association for board grouping. */
33
+ readonly teamId?: string;
34
+ /** Optional agent id when the spawn is agent-scoped. */
35
+ readonly agentId?: string;
36
+ readonly status: ProcessExecutionStatus;
37
+ }
38
+ /** Filter for {@link ProcessRegistry.listExecutions}. */
39
+ export interface ProcessExecutionFilter {
40
+ readonly source?: ProcessExecutionSource;
41
+ /** When true, only running; when false, only exited; omit for all. */
42
+ readonly running?: boolean;
43
+ readonly teamId?: string;
44
+ readonly agentId?: string;
45
+ }
46
+ /** Events emitted by {@link ProcessRegistry.subscribe}. */
47
+ export type ProcessRegistryEvent = {
48
+ readonly type: 'started';
49
+ readonly execution: ProcessExecution;
50
+ } | {
51
+ readonly type: 'exited';
52
+ readonly execution: ProcessExecution;
53
+ } | {
54
+ readonly type: 'updated';
55
+ readonly execution: ProcessExecution;
56
+ };
57
+ /** Input for {@link ProcessRegistry.begin}. */
58
+ export interface ProcessExecutionBegin {
59
+ readonly command: string;
60
+ readonly args?: readonly string[];
61
+ readonly label?: string;
62
+ readonly source?: ProcessExecutionSource;
63
+ readonly teamId?: string;
64
+ readonly agentId?: string;
65
+ readonly pid?: number;
66
+ /** Override start timestamp (ISO-8601); defaults to now. */
67
+ readonly startedAt?: string;
68
+ }
69
+ /** Completion patch for {@link ProcessRegistry.complete}. */
70
+ export interface ProcessExecutionComplete {
71
+ readonly exitCode?: number | null;
72
+ readonly pid?: number;
73
+ /** Override end timestamp (ISO-8601); defaults to now. */
74
+ readonly exitedAt?: string;
75
+ }
76
+ /**
77
+ * Queryable registry of process executions.
78
+ *
79
+ * Implementations are process-local. Share one instance across every
80
+ * {@link import('./process-executor').NodeProcessExecutor} that should appear
81
+ * in the same watch list.
82
+ */
83
+ export interface ProcessRegistry {
84
+ /** Snapshot of tracked executions (newest-last), optionally filtered. */
85
+ listExecutions(filter?: ProcessExecutionFilter): readonly ProcessExecution[];
86
+ /** Lookup by id. */
87
+ getExecution(id: string): ProcessExecution | undefined;
88
+ /**
89
+ * Subscribe to start / exit / field-update events.
90
+ * @returns unsubscribe function
91
+ */
92
+ subscribe(listener: (event: ProcessRegistryEvent) => void): () => void;
93
+ /** Record a new execution as running; returns its id. */
94
+ begin(input: ProcessExecutionBegin): string;
95
+ /** Patch mutable fields while running (e.g. pid once available). */
96
+ update(id: string, patch: Pick<Partial<ProcessExecution>, 'pid' | 'label'>): void;
97
+ /** Mark an execution exited (idempotent if already exited). */
98
+ complete(id: string, update?: ProcessExecutionComplete): void;
99
+ /** Drop all tracked executions (tests / shutdown). */
100
+ clear(): void;
101
+ }
102
+ /**
103
+ * Configuration for {@link createInMemoryProcessRegistry}.
104
+ */
105
+ export interface InMemoryProcessRegistryOptions {
106
+ /**
107
+ * Maximum retained executions. When exceeded, oldest **exited** entries
108
+ * are dropped first; if still over limit, oldest entries overall.
109
+ * Default: 1000.
110
+ */
111
+ readonly maxEntries?: number;
112
+ }
113
+ /**
114
+ * Default in-memory {@link ProcessRegistry}.
115
+ *
116
+ * Create one per process (or per spur-serve) and inject it into every
117
+ * `NodeProcessExecutor` that should contribute to the shared watch list:
118
+ *
119
+ * ```ts
120
+ * const registry = createInMemoryProcessRegistry();
121
+ * const exec = new NodeProcessExecutor({ registry });
122
+ * ```
123
+ */
124
+ export declare class InMemoryProcessRegistry implements ProcessRegistry {
125
+ private readonly maxEntries;
126
+ private readonly byId;
127
+ /** Insertion order for retention / list ordering. */
128
+ private readonly order;
129
+ private readonly listeners;
130
+ constructor(options?: InMemoryProcessRegistryOptions);
131
+ listExecutions(filter?: ProcessExecutionFilter): readonly ProcessExecution[];
132
+ getExecution(id: string): ProcessExecution | undefined;
133
+ subscribe(listener: (event: ProcessRegistryEvent) => void): () => void;
134
+ begin(input: ProcessExecutionBegin): string;
135
+ update(id: string, patch: Pick<Partial<ProcessExecution>, 'pid' | 'label'>): void;
136
+ complete(id: string, update?: ProcessExecutionComplete): void;
137
+ clear(): void;
138
+ private emit;
139
+ private enforceCap;
140
+ }
141
+ /** Factory for a default in-memory registry (preferred public constructor). */
142
+ export declare function createInMemoryProcessRegistry(options?: InMemoryProcessRegistryOptions): ProcessRegistry;
143
+ //# sourceMappingURL=process-registry.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"process-registry.d.ts","sourceRoot":"","sources":["../src/process-registry.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,146 @@
1
+ /**
2
+ * In-process registry of ProcessExecutor invocations (spur#0264 / M1).
3
+ *
4
+ * Tracks every `run` / `runStreaming` so board UIs (and other observers) can
5
+ * list all harness-launched processes — not only supervisor-owned agent loops.
6
+ * In-memory only; not durable across process restarts.
7
+ */
8
+ let nextIdSeq = 0;
9
+ function allocateId() {
10
+ nextIdSeq += 1;
11
+ return `pe_${Date.now().toString(36)}_${nextIdSeq.toString(36)}`;
12
+ }
13
+ /**
14
+ * Default in-memory {@link ProcessRegistry}.
15
+ *
16
+ * Create one per process (or per spur-serve) and inject it into every
17
+ * `NodeProcessExecutor` that should contribute to the shared watch list:
18
+ *
19
+ * ```ts
20
+ * const registry = createInMemoryProcessRegistry();
21
+ * const exec = new NodeProcessExecutor({ registry });
22
+ * ```
23
+ */
24
+ export class InMemoryProcessRegistry {
25
+ maxEntries;
26
+ byId = new Map();
27
+ /** Insertion order for retention / list ordering. */
28
+ order = [];
29
+ listeners = new Set();
30
+ constructor(options = {}) {
31
+ this.maxEntries = options.maxEntries ?? 1000;
32
+ }
33
+ listExecutions(filter) {
34
+ const out = [];
35
+ for (const id of this.order) {
36
+ const exec = this.byId.get(id);
37
+ if (!exec)
38
+ continue;
39
+ if (filter?.source !== undefined && exec.source !== filter.source)
40
+ continue;
41
+ if (filter?.running === true && exec.status !== 'running')
42
+ continue;
43
+ if (filter?.running === false && exec.status !== 'exited')
44
+ continue;
45
+ if (filter?.teamId !== undefined && exec.teamId !== filter.teamId)
46
+ continue;
47
+ if (filter?.agentId !== undefined && exec.agentId !== filter.agentId)
48
+ continue;
49
+ out.push(exec);
50
+ }
51
+ return out;
52
+ }
53
+ getExecution(id) {
54
+ return this.byId.get(id);
55
+ }
56
+ subscribe(listener) {
57
+ this.listeners.add(listener);
58
+ return () => {
59
+ this.listeners.delete(listener);
60
+ };
61
+ }
62
+ begin(input) {
63
+ const id = allocateId();
64
+ const execution = {
65
+ id,
66
+ command: input.command,
67
+ args: Object.freeze([...(input.args ?? [])]),
68
+ source: input.source ?? 'other',
69
+ startedAt: input.startedAt ?? new Date().toISOString(),
70
+ status: 'running',
71
+ ...(input.label !== undefined ? { label: input.label } : {}),
72
+ ...(input.teamId !== undefined ? { teamId: input.teamId } : {}),
73
+ ...(input.agentId !== undefined ? { agentId: input.agentId } : {}),
74
+ ...(input.pid !== undefined ? { pid: input.pid } : {}),
75
+ };
76
+ this.byId.set(id, execution);
77
+ this.order.push(id);
78
+ this.enforceCap();
79
+ this.emit({ type: 'started', execution });
80
+ return id;
81
+ }
82
+ update(id, patch) {
83
+ const current = this.byId.get(id);
84
+ if (!current)
85
+ return;
86
+ const next = {
87
+ ...current,
88
+ ...(patch.pid !== undefined ? { pid: patch.pid } : {}),
89
+ ...(patch.label !== undefined ? { label: patch.label } : {}),
90
+ };
91
+ this.byId.set(id, next);
92
+ this.emit({ type: 'updated', execution: next });
93
+ }
94
+ complete(id, update = {}) {
95
+ const current = this.byId.get(id);
96
+ if (!current)
97
+ return;
98
+ if (current.status === 'exited') {
99
+ // Already completed — still allow pid fill-in if missing.
100
+ if (update.pid !== undefined && current.pid === undefined) {
101
+ const patched = { ...current, pid: update.pid };
102
+ this.byId.set(id, patched);
103
+ this.emit({ type: 'updated', execution: patched });
104
+ }
105
+ return;
106
+ }
107
+ const next = {
108
+ ...current,
109
+ status: 'exited',
110
+ exitedAt: update.exitedAt ?? new Date().toISOString(),
111
+ exitCode: update.exitCode !== undefined ? update.exitCode : null,
112
+ ...(update.pid !== undefined ? { pid: update.pid } : {}),
113
+ };
114
+ this.byId.set(id, next);
115
+ this.emit({ type: 'exited', execution: next });
116
+ }
117
+ clear() {
118
+ this.byId.clear();
119
+ this.order.length = 0;
120
+ }
121
+ emit(event) {
122
+ for (const listener of this.listeners) {
123
+ try {
124
+ listener(event);
125
+ }
126
+ catch {
127
+ // Listener errors must not break process tracking.
128
+ }
129
+ }
130
+ }
131
+ enforceCap() {
132
+ while (this.order.length > this.maxEntries) {
133
+ // Prefer dropping oldest exited entries.
134
+ let dropIdx = this.order.findIndex((id) => this.byId.get(id)?.status === 'exited');
135
+ if (dropIdx < 0)
136
+ dropIdx = 0;
137
+ const [removed] = this.order.splice(dropIdx, 1);
138
+ if (removed !== undefined)
139
+ this.byId.delete(removed);
140
+ }
141
+ }
142
+ }
143
+ /** Factory for a default in-memory registry (preferred public constructor). */
144
+ export function createInMemoryProcessRegistry(options) {
145
+ return new InMemoryProcessRegistry(options);
146
+ }
@@ -2,7 +2,7 @@ import { parse as parseYaml } from 'yaml';
2
2
  import { buildConfigFromObject, getProcessEnv } from './config.js';
3
3
  import { DbModuleNotInstalledError } from './db-errors.js';
4
4
  import { createNodeFileSystem } from './file-system-node.js';
5
- import { ProcessExecutor } from './process-executor.js';
5
+ import { NodeProcessExecutor } from './process-executor.js';
6
6
  // Lazy re-initialisable singleton for test isolation.
7
7
  let _nodeFileSystem;
8
8
  function getNodeFileSystem() {
@@ -26,7 +26,7 @@ export const nodeBunFactory = {
26
26
  hasSqlDatabase: true,
27
27
  },
28
28
  createFileSystem: () => getNodeFileSystem(),
29
- createProcessExecutor: (config) => new ProcessExecutor(config),
29
+ createProcessExecutor: (config) => new NodeProcessExecutor(config),
30
30
  async loadConfig(options) {
31
31
  return loadNodeConfig(options);
32
32
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gobing-ai/ts-runtime",
3
- "version": "0.4.8",
3
+ "version": "0.4.10",
4
4
  "description": "@gobing-ai/ts-runtime — Runtime abstractions for Bun, Node, and Cloudflare Workers.",
5
5
  "keywords": [
6
6
  "typescript",
@@ -58,17 +58,17 @@
58
58
  "release": "echo 'Manual publish is disabled. Releases go through GitHub Actions via Trusted Publishing — push a tag: git tag @gobing-ai/ts-runtime-v<version> && git push --tags' && exit 1"
59
59
  },
60
60
  "dependencies": {
61
- "@gobing-ai/ts-utils": "^0.4.8",
61
+ "@gobing-ai/ts-utils": "^0.4.10",
62
62
  "execa": "^9.5.0",
63
63
  "yaml": "^2.7.0",
64
64
  "zod": "^4.1.0"
65
65
  },
66
66
  "devDependencies": {
67
- "@gobing-ai/ts-db": "^0.4.8",
67
+ "@gobing-ai/ts-db": "^0.4.10",
68
68
  "@types/bun": "1.3.14"
69
69
  },
70
70
  "peerDependencies": {
71
- "@gobing-ai/ts-db": "^0.4.8"
71
+ "@gobing-ai/ts-db": "^0.4.10"
72
72
  },
73
73
  "peerDependenciesMeta": {
74
74
  "@gobing-ai/ts-db": {
package/src/context.ts CHANGED
@@ -4,7 +4,7 @@ import type { FileSystem } from './file-system';
4
4
  import { createNodeFileSystem } from './file-system-node';
5
5
  import { loadRuntimeFactory } from './platform';
6
6
  import type { ProcessExecutor as ProcessExecutorService } from './process-executor';
7
- import { ProcessExecutor } from './process-executor';
7
+ import { nodeBunFactory } from './runtime-node-bun';
8
8
  import type { RuntimeCapabilities, RuntimeName } from './types';
9
9
  /** Execution scope of a runtime context — determines service lifecycle and availability. */
10
10
  export type RuntimeScope = 'process' | 'server-request' | 'scheduled-event' | 'test';
@@ -50,7 +50,7 @@ export class RuntimeContext<TServices extends RuntimeServiceMap = RuntimeService
50
50
  (options.services?.fileSystem ?? createNodeFileSystem()) as TServices['fileSystem'],
51
51
  );
52
52
  if (this.capabilities.hasProcessExecution && options.services?.processExecutor === undefined) {
53
- this.register('processExecutor', new ProcessExecutor() as TServices['processExecutor']);
53
+ this.register('processExecutor', nodeBunFactory.createProcessExecutor() as TServices['processExecutor']);
54
54
  }
55
55
 
56
56
  for (const [key, value] of Object.entries(options.services ?? {})) {
package/src/index.ts CHANGED
@@ -30,7 +30,19 @@ export type {
30
30
  ProcessSignal,
31
31
  TracerPort,
32
32
  } from './process-executor';
33
- export { ProcessExecutor } from './process-executor';
33
+ export { NodeProcessExecutor, ProcessExecutor } from './process-executor';
34
+ export type {
35
+ InMemoryProcessRegistryOptions,
36
+ ProcessExecution,
37
+ ProcessExecutionBegin,
38
+ ProcessExecutionComplete,
39
+ ProcessExecutionFilter,
40
+ ProcessExecutionSource,
41
+ ProcessExecutionStatus,
42
+ ProcessRegistry,
43
+ ProcessRegistryEvent,
44
+ } from './process-registry';
45
+ export { createInMemoryProcessRegistry, InMemoryProcessRegistry } from './process-registry';
34
46
  export { cloudflareWorkersFactory } from './runtime-cf';
35
47
  export type { RuntimeFactory } from './runtime-factory';
36
48
  export { _resetNodeFileSystem, nodeBunFactory } from './runtime-node-bun';
@@ -39,7 +51,7 @@ export * from './types';
39
51
 
40
52
  // ── Deprecated re-exports (backward compatibility) ──────────────────────
41
53
 
42
- export { BunPipeProcessSpawner, BunSyncProcessExecutor, NodeProcessExecutor } from './process-executor';
54
+ export { BunPipeProcessSpawner, BunSyncProcessExecutor } from './process-executor';
43
55
 
44
56
  /**
45
57
  * @deprecated Use {@link ProcessExecutor} directly for async execution.
@@ -1,5 +1,6 @@
1
1
  import { isatty } from 'node:tty';
2
2
  import { type Options as ExecaOptions, execa } from 'execa';
3
+ import type { ProcessExecutionSource, ProcessRegistry } from './process-registry';
3
4
 
4
5
  // ── Types ────────────────────────────────────────────────────────────────
5
6
 
@@ -13,6 +14,12 @@ export interface ProcessExecutorConfig {
13
14
  output?: OutputPolicy;
14
15
  events?: ProcessEventSink;
15
16
  tracer?: TracerPort;
17
+ /**
18
+ * Optional process registry (spur#0264). When set, every `run` / `runStreaming`
19
+ * invocation is recorded for list/subscribe consumers (e.g. Spur Processes tab).
20
+ * Share one registry across all executors that should appear in the same watch list.
21
+ */
22
+ registry?: ProcessRegistry;
16
23
  }
17
24
 
18
25
  /** Options for spawning a child process. */
@@ -28,6 +35,14 @@ export interface ProcessOptions {
28
35
  forceBuffered?: boolean;
29
36
  /** AbortSignal forwarded to execa as `cancelSignal` — aborts the child process when fired. */
30
37
  signal?: AbortSignal;
38
+ /**
39
+ * Registry metadata (spur#0264). Defaults: source `'one-shot'` for `run`.
40
+ * Pass `source: 'supervisor'` (and optional teamId/agentId) when the spawn is
41
+ * a supervised team agent loop.
42
+ */
43
+ source?: ProcessExecutionSource;
44
+ teamId?: string;
45
+ agentId?: string;
31
46
  }
32
47
 
33
48
  /** Result of a completed child process, including exit code, captured output, and duration. */
@@ -80,6 +95,13 @@ export interface PipeProcessOptions {
80
95
  cwd?: string;
81
96
  env?: Record<string, string>;
82
97
  label?: string;
98
+ /**
99
+ * Registry metadata (spur#0264). Defaults: source `'other'` for streaming.
100
+ * Supervised agent loops should pass `source: 'supervisor'` + agentId.
101
+ */
102
+ source?: ProcessExecutionSource;
103
+ teamId?: string;
104
+ agentId?: string;
83
105
  }
84
106
 
85
107
  /** Signal values accepted by subprocess kill. */
@@ -99,15 +121,44 @@ export interface PipeProcess {
99
121
  kill(signal?: ProcessSignal): void;
100
122
  }
101
123
 
102
- // ── ProcessExecutor ───────────────────────────────────────────────────────
124
+ // ── ProcessExecutor (canonical interface) ────────────────────────────────
103
125
 
104
126
  /**
105
- * Runtime-agnostic process executor wrapping `execa`.
127
+ * Runtime-agnostic process executor contract.
106
128
  *
107
129
  * Every invocation supports timeout enforcement, output capture, and
108
- * configurable output policy (buffered vs streamed).
130
+ * configurable output policy (buffered vs streamed). Concrete implementations
131
+ * are obtained through `RuntimeFactory.createProcessExecutor`; the Node/Bun
132
+ * implementation is {@link NodeProcessExecutor}. Test doubles implement this
133
+ * interface structurally — no concrete subclassing required.
134
+ */
135
+ export interface ProcessExecutor {
136
+ /**
137
+ * Run a command, buffered by default. Returns a structured {@link ProcessResult}.
138
+ * Does NOT throw on non-zero exit codes unless `rejectOnError` is set.
139
+ */
140
+ run(options: ProcessOptions): Promise<ProcessResult>;
141
+
142
+ /**
143
+ * Spawn a long-running interactive process with streaming I/O.
144
+ *
145
+ * Returns a {@link PipeProcess} handle with streaming stdout/stderr and
146
+ * stdin write support.
147
+ */
148
+ runStreaming(options: PipeProcessOptions): PipeProcess;
149
+ }
150
+
151
+ // ── NodeProcessExecutor (concrete Node/Bun implementation) ───────────────
152
+
153
+ /**
154
+ * Concrete Node/Bun implementation of {@link ProcessExecutor}, wrapping `execa`
155
+ * for buffered execution and `Bun.spawn` for streaming pipe execution.
156
+ *
157
+ * Obtain a default instance through `RuntimeFactory.createProcessExecutor`
158
+ * (e.g. `nodeBunFactory.createProcessExecutor()`); construct directly only in
159
+ * runtime-factory wiring or concrete implementation tests.
109
160
  */
110
- export class ProcessExecutor {
161
+ export class NodeProcessExecutor implements ProcessExecutor {
111
162
  private readonly config: ProcessExecutorConfig;
112
163
 
113
164
  constructor(config: ProcessExecutorConfig = {}) {
@@ -135,18 +186,27 @@ export class ProcessExecutor {
135
186
  signal: options.signal,
136
187
  });
137
188
  const startedAt = Date.now();
189
+ const startedIso = new Date(startedAt).toISOString();
190
+ const registryId = this.beginRegistry(options.command, args, {
191
+ label: options.label,
192
+ source: options.source ?? 'one-shot',
193
+ teamId: options.teamId,
194
+ agentId: options.agentId,
195
+ startedAt: startedIso,
196
+ });
138
197
  this.emitProcessEvent('process.started', {
139
198
  command: options.command,
140
199
  args,
141
200
  exitCode: null,
142
201
  durationMs: 0,
143
202
  reason: 'exit',
144
- timestamp: new Date(startedAt).toISOString(),
203
+ timestamp: startedIso,
145
204
  ...(options.label !== undefined ? { label: options.label } : {}),
146
205
  });
147
206
 
148
207
  try {
149
208
  const result = await execa(options.command, args, execaOptions);
209
+ // execa@9 Result does not expose pid — buffered runs leave pid unset.
150
210
  const processResult = {
151
211
  command: options.command,
152
212
  args,
@@ -156,6 +216,7 @@ export class ProcessExecutor {
156
216
  ...(result.signalDescription !== undefined ? { signal: result.signalDescription } : {}),
157
217
  durationMs: result.durationMs,
158
218
  };
219
+ this.completeRegistry(registryId, processResult.exitCode);
159
220
  this.emitExitedFromResult(options, processResult, result);
160
221
  return processResult;
161
222
  } catch (error) {
@@ -182,6 +243,7 @@ export class ProcessExecutor {
182
243
  : {}),
183
244
  durationMs: failed.durationMs ?? Date.now() - startedAt,
184
245
  };
246
+ this.completeRegistry(registryId, processResult.exitCode);
185
247
  this.emitExitedFromResult(options, processResult, error, error);
186
248
  if (options.rejectOnError) throw error;
187
249
  return processResult;
@@ -197,15 +259,24 @@ export class ProcessExecutor {
197
259
  runStreaming(options: PipeProcessOptions): PipeProcess {
198
260
  const args = options.args ?? [];
199
261
  void this.config.tracer?.traceAsync('process.runStreaming', async () => undefined).catch(() => undefined);
262
+ const startedAt = Date.now();
263
+ const startedIso = new Date(startedAt).toISOString();
264
+ // Begin registry before spawn so failed spawns still appear (then complete as error).
265
+ const registryId = this.beginRegistry(options.command, args, {
266
+ label: options.label,
267
+ source: options.source ?? 'other',
268
+ teamId: options.teamId,
269
+ agentId: options.agentId,
270
+ startedAt: startedIso,
271
+ });
200
272
  try {
201
- const startedAt = Date.now();
202
273
  this.emitProcessEvent('process.started', {
203
274
  command: options.command,
204
275
  args,
205
276
  exitCode: null,
206
277
  durationMs: 0,
207
278
  reason: 'exit',
208
- timestamp: new Date(startedAt).toISOString(),
279
+ timestamp: startedIso,
209
280
  ...(options.label !== undefined ? { label: options.label } : {}),
210
281
  });
211
282
  const subprocess = Bun.spawn({
@@ -216,13 +287,20 @@ export class ProcessExecutor {
216
287
  ...(options.cwd !== undefined ? { cwd: options.cwd } : {}),
217
288
  ...(options.env !== undefined ? { env: options.env } : {}),
218
289
  });
219
- return new ObservedPipeProcess(new BunPipeProcess(subprocess), this.config.events, {
290
+ const pipe = new BunPipeProcess(subprocess);
291
+ if (pipe.pid !== null) {
292
+ this.config.registry?.update(registryId, { pid: pipe.pid });
293
+ }
294
+ return new ObservedPipeProcess(pipe, this.config.events, {
220
295
  command: options.command,
221
296
  args,
222
297
  startedAt,
298
+ registry: this.config.registry,
299
+ registryId,
223
300
  ...(options.label !== undefined ? { label: options.label } : {}),
224
301
  });
225
302
  } catch (error) {
303
+ this.completeRegistry(registryId, null);
226
304
  this.emitProcessEvent('process.exited', {
227
305
  command: options.command,
228
306
  args,
@@ -242,6 +320,38 @@ export class ProcessExecutor {
242
320
  return await this.config.tracer.traceAsync(name, async () => await fn());
243
321
  }
244
322
 
323
+ private beginRegistry(
324
+ command: string,
325
+ args: string[],
326
+ meta: {
327
+ label?: string;
328
+ source: ProcessExecutionSource;
329
+ teamId?: string;
330
+ agentId?: string;
331
+ startedAt: string;
332
+ },
333
+ ): string {
334
+ const registry = this.config.registry;
335
+ if (!registry) return '';
336
+ return registry.begin({
337
+ command,
338
+ args,
339
+ source: meta.source,
340
+ startedAt: meta.startedAt,
341
+ ...(meta.label !== undefined ? { label: meta.label } : {}),
342
+ ...(meta.teamId !== undefined ? { teamId: meta.teamId } : {}),
343
+ ...(meta.agentId !== undefined ? { agentId: meta.agentId } : {}),
344
+ });
345
+ }
346
+
347
+ private completeRegistry(id: string, exitCode: number | null, pid?: number): void {
348
+ if (!id || !this.config.registry) return;
349
+ this.config.registry.complete(id, {
350
+ exitCode,
351
+ ...(pid !== undefined ? { pid } : {}),
352
+ });
353
+ }
354
+
245
355
  private emitExitedFromResult(
246
356
  options: ProcessOptions,
247
357
  result: ProcessResult,
@@ -286,9 +396,17 @@ class ObservedPipeProcess implements PipeProcess {
286
396
  args: string[];
287
397
  startedAt: number;
288
398
  label?: string;
399
+ registry?: ProcessRegistry;
400
+ registryId: string;
289
401
  },
290
402
  ) {
291
403
  this.exited = inner.exited.then((exitCode) => {
404
+ if (context.registry && context.registryId) {
405
+ context.registry.complete(context.registryId, {
406
+ exitCode,
407
+ ...(inner.pid !== null ? { pid: inner.pid } : {}),
408
+ });
409
+ }
292
410
  events?.emit('process.exited', {
293
411
  command: context.command,
294
412
  args: context.args,
@@ -374,13 +492,18 @@ class BunPipeProcess implements PipeProcess {
374
492
  }
375
493
  }
376
494
 
377
- // ── Deprecated backward-compatible subclasses ─────────────────────────────
495
+ // ── Deprecated constructible ProcessExecutor value alias ──────────────────
378
496
 
379
497
  /**
380
- * @deprecated Use {@link ProcessExecutor} directly.
381
- * This subclass is kept for backward compatibility.
498
+ * @deprecated Construct {@link NodeProcessExecutor} directly or obtain a default
499
+ * through `RuntimeFactory.createProcessExecutor` (e.g. `nodeBunFactory.createProcessExecutor()`).
500
+ * This value alias preserves source compatibility for `new ProcessExecutor(...)` callers
501
+ * during the interface extraction release; it will be removed in a future release.
502
+ * `import type { ProcessExecutor }` resolves to the canonical interface, not this alias.
382
503
  */
383
- export class NodeProcessExecutor extends ProcessExecutor {}
504
+ export const ProcessExecutor = NodeProcessExecutor;
505
+
506
+ // ── Deprecated backward-compatible helpers ────────────────────────────────
384
507
 
385
508
  /**
386
509
  * @deprecated Use `Bun.spawnSync` or `child_process.spawnSync` directly.