@opengeni/runtime 0.13.0 → 0.13.4

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.
@@ -1,11 +1,9 @@
1
- import type { Computer, Editor, Tool } from "@openai/agents";
2
-
3
1
  import { runWithToolCallCorrelation, sanitizeOpIdToken } from "./op-correlation";
4
2
  import {
5
3
  isExecSessionLostBanner,
6
4
  parseExecBannerExitCode,
7
5
  parseExecBannerSessionId,
8
- } from "./channel-a";
6
+ } from "./exec-banner";
9
7
 
10
8
  const TURN_EXEC_YIELD_MS = 250;
11
9
  const TURN_WRITE_YIELD_MS = 250;
@@ -14,8 +12,39 @@ const SHELL_GRACEFUL_POLLS = 2;
14
12
  const SHELL_POLL_MS = 100;
15
13
  const SHELL_MARKER_DIR = "/tmp/opengeni-turn-shell";
16
14
 
17
- type FunctionTool = Extract<Tool<unknown>, { type: "function" }>;
18
- type FunctionToolInvoke = FunctionTool["invoke"];
15
+ type ToolCallDetails = {
16
+ toolCall?: {
17
+ callId?: string;
18
+ };
19
+ };
20
+
21
+ type FunctionToolInvoke = (
22
+ runContext: unknown,
23
+ input: string,
24
+ details?: ToolCallDetails,
25
+ ) => Promise<unknown>;
26
+
27
+ type FunctionTool = Record<string, unknown> & {
28
+ type: "function";
29
+ name: string;
30
+ invoke: FunctionToolInvoke;
31
+ };
32
+
33
+ type ApplyPatchEditor = {
34
+ createFile(operation: unknown, context?: unknown): Promise<unknown>;
35
+ updateFile(operation: unknown, context?: unknown): Promise<unknown>;
36
+ deleteFile(operation: unknown, context?: unknown): Promise<unknown>;
37
+ };
38
+
39
+ type ApplyPatchTool = Record<string, unknown> & {
40
+ type: "apply_patch";
41
+ editor: ApplyPatchEditor;
42
+ };
43
+
44
+ type ComputerTool = Record<string, unknown> & {
45
+ type: "computer";
46
+ computer: unknown;
47
+ };
19
48
 
20
49
  const delay = async (milliseconds: number): Promise<void> =>
21
50
  await new Promise((resolve) => setTimeout(resolve, milliseconds));
@@ -32,6 +61,7 @@ type ActiveShellSession = {
32
61
  runContext: Parameters<FunctionToolInvoke>[0];
33
62
  execInvoke: FunctionToolInvoke;
34
63
  writeInvoke: FunctionToolInvoke | null;
64
+ processSession: CommandCancellationSession | null;
35
65
  identity: ShellProcessIdentity | null;
36
66
  identityValidated: boolean;
37
67
  cancellation: Promise<void> | null;
@@ -40,6 +70,23 @@ type ActiveShellSession = {
40
70
  type CommandCancellationSession = {
41
71
  cancelExecCommand?(opId: string): Promise<boolean>;
42
72
  supportsPty?(): boolean;
73
+ hasRetainedProcess?(providerSessionId: number): boolean;
74
+ writeStdinForProcessMutation?(args: {
75
+ sessionId: number;
76
+ chars?: string;
77
+ yieldTimeMs?: number;
78
+ maxOutputTokens?: number;
79
+ }): Promise<string>;
80
+ writeStdinForProcessControl?(args: {
81
+ sessionId: number;
82
+ chars?: string;
83
+ yieldTimeMs?: number;
84
+ maxOutputTokens?: number;
85
+ }): Promise<string>;
86
+ execCommandForProcessControl?(
87
+ providerSessionId: number,
88
+ args: TurnSandboxCommandArgs,
89
+ ): Promise<string>;
43
90
  };
44
91
 
45
92
  export type TurnSandboxCommandArgs = {
@@ -53,6 +100,13 @@ export type TurnSandboxCommandArgs = {
53
100
  runAs?: string;
54
101
  };
55
102
 
103
+ export type TurnSandboxCommandResult = {
104
+ stdout: string;
105
+ stderr: string;
106
+ exitCode: number;
107
+ wallTimeSeconds: number;
108
+ };
109
+
56
110
  export type TurnSandboxCommandSession = CommandCancellationSession & {
57
111
  exec?(args: TurnSandboxCommandArgs): Promise<unknown>;
58
112
  execCommand?(args: TurnSandboxCommandArgs): Promise<string>;
@@ -85,10 +139,14 @@ export type TurnToolCancellationFence = {
85
139
  session: TurnSandboxCommandSession,
86
140
  args: TurnSandboxCommandArgs,
87
141
  ): Promise<unknown>;
142
+ runSandboxCommandStructured(
143
+ session: TurnSandboxCommandSession,
144
+ args: TurnSandboxCommandArgs,
145
+ ): Promise<TurnSandboxCommandResult>;
88
146
  };
89
147
 
90
148
  export type TurnToolCancellationController = TurnToolCancellationFence & {
91
- wrapTools(tools: Tool<unknown>[], session?: CommandCancellationSession): Tool<unknown>[];
149
+ wrapTools<T>(tools: T[], session?: CommandCancellationSession): T[];
92
150
  };
93
151
 
94
152
  export class TurnSandboxCommandCancelledError extends Error {
@@ -173,6 +231,64 @@ function nativeCommandBanner(result: unknown): string {
173
231
  throw new Error("Sandbox command did not report a session id or exit code");
174
232
  }
175
233
 
234
+ type NativeCommandResult = {
235
+ stdout: string;
236
+ stderr: string;
237
+ exitCode: number | null;
238
+ sessionId: number | null;
239
+ wallTimeSeconds: number;
240
+ };
241
+
242
+ function nativeCommandResult(result: unknown): NativeCommandResult {
243
+ if (typeof result === "string") {
244
+ return {
245
+ stdout: execOutput(result),
246
+ stderr: "",
247
+ exitCode: parseExecBannerExitCode(result),
248
+ sessionId: parseExecBannerSessionId(result),
249
+ wallTimeSeconds: 0,
250
+ };
251
+ }
252
+ if (!result || typeof result !== "object") {
253
+ throw new Error("Sandbox command returned an invalid result");
254
+ }
255
+ const value = result as {
256
+ output?: unknown;
257
+ stdout?: unknown;
258
+ stderr?: unknown;
259
+ exitCode?: unknown;
260
+ exit_code?: unknown;
261
+ sessionId?: unknown;
262
+ session_id?: unknown;
263
+ wallTimeSeconds?: unknown;
264
+ };
265
+ return {
266
+ stdout:
267
+ typeof value.stdout === "string"
268
+ ? value.stdout
269
+ : typeof value.output === "string"
270
+ ? value.output
271
+ : "",
272
+ stderr: typeof value.stderr === "string" ? value.stderr : "",
273
+ exitCode:
274
+ typeof value.exitCode === "number"
275
+ ? value.exitCode
276
+ : typeof value.exit_code === "number"
277
+ ? value.exit_code
278
+ : null,
279
+ sessionId:
280
+ typeof value.sessionId === "number"
281
+ ? value.sessionId
282
+ : typeof value.session_id === "number"
283
+ ? value.session_id
284
+ : null,
285
+ wallTimeSeconds:
286
+ typeof value.wallTimeSeconds === "number" && value.wallTimeSeconds >= 0
287
+ ? value.wallTimeSeconds
288
+ : 0,
289
+ };
290
+ }
291
+
176
292
  function completedCommandBanner(exitCode: number, output: string): string {
177
293
  return `Process exited with code ${exitCode}\n\nOutput:\n${output}`;
178
294
  }
@@ -231,13 +347,31 @@ export function cancellableShellCommand(command: string, markerPath: string): st
231
347
  }
232
348
 
233
349
  function shellHelperInput(command: string): string {
350
+ const args = shellHelperArgs(command);
234
351
  return JSON.stringify({
352
+ cmd: args.cmd,
353
+ login: args.login,
354
+ tty: args.tty,
355
+ yield_time_ms: args.yieldTimeMs,
356
+ max_output_tokens: args.maxOutputTokens,
357
+ });
358
+ }
359
+
360
+ function shellHelperArgs(command: string): TurnSandboxCommandArgs {
361
+ return {
235
362
  cmd: command,
236
363
  login: false,
237
364
  tty: false,
238
- yield_time_ms: SHELL_HELPER_YIELD_MS,
239
- max_output_tokens: 128,
240
- });
365
+ yieldTimeMs: SHELL_HELPER_YIELD_MS,
366
+ maxOutputTokens: 128,
367
+ };
368
+ }
369
+
370
+ function retainedProcessSession(
371
+ session: CommandCancellationSession | undefined,
372
+ providerSessionId: number,
373
+ ): CommandCancellationSession | null {
374
+ return session?.hasRetainedProcess?.(providerSessionId) === true ? session : null;
241
375
  }
242
376
 
243
377
  function safeProcessIdentity(raw: string): ShellProcessIdentity | null {
@@ -279,9 +413,12 @@ function identityGuardScript(
279
413
  ].join("\n");
280
414
  }
281
415
 
282
- function wrapComputer(computer: Computer, track: <T>(operation: () => Promise<T>) => Promise<T>) {
283
- const wrapped = Object.create(Object.getPrototypeOf(computer)) as Computer &
284
- Record<string, unknown>;
416
+ function wrapComputer<T extends object>(
417
+ computer: T,
418
+ track: <U>(operation: () => Promise<U>) => Promise<U>,
419
+ ): T {
420
+ const wrapped = Object.create(Object.getPrototypeOf(computer)) as T & Record<string, unknown>;
421
+ const wrappedRecord = wrapped as Record<string, unknown>;
285
422
  Object.assign(wrapped, computer);
286
423
  for (const method of [
287
424
  "initRun",
@@ -295,10 +432,10 @@ function wrapComputer(computer: Computer, track: <T>(operation: () => Promise<T>
295
432
  "keypress",
296
433
  "drag",
297
434
  ] as const) {
298
- const original = (computer as unknown as Record<string, unknown>)[method];
435
+ const original = (computer as Record<string, unknown>)[method];
299
436
  if (typeof original !== "function") continue;
300
- wrapped[method] = (...args: unknown[]) =>
301
- track(async () => await original.apply(computer, args)) as never;
437
+ wrappedRecord[method] = (...args: unknown[]) =>
438
+ track(async () => await original.apply(computer, args));
302
439
  }
303
440
  return wrapped;
304
441
  }
@@ -341,14 +478,30 @@ class TurnToolCancellationControllerImpl implements TurnToolCancellationControll
341
478
  runSandboxCommand(
342
479
  session: TurnSandboxCommandSession,
343
480
  args: TurnSandboxCommandArgs,
481
+ ): Promise<unknown> {
482
+ return this.runSandboxCommandInternal(session, args, false);
483
+ }
484
+
485
+ async runSandboxCommandStructured(
486
+ session: TurnSandboxCommandSession,
487
+ args: TurnSandboxCommandArgs,
488
+ ): Promise<TurnSandboxCommandResult> {
489
+ return (await this.runSandboxCommandInternal(session, args, true)) as TurnSandboxCommandResult;
490
+ }
491
+
492
+ private runSandboxCommandInternal(
493
+ session: TurnSandboxCommandSession,
494
+ args: TurnSandboxCommandArgs,
495
+ structured: boolean,
344
496
  ): Promise<unknown> {
345
497
  return this.track(async () => {
498
+ const startedAt = performance.now();
346
499
  if (!session.exec && !session.execCommand) {
347
500
  throw new Error("Sandbox session does not support command execution");
348
501
  }
349
502
  const lifecycleRunContext = {} as Parameters<FunctionToolInvoke>[0];
350
503
 
351
- const invokeExec: FunctionToolInvoke = async (_runContext, input) => {
504
+ const invokeExecNative = async (input: string): Promise<unknown> => {
352
505
  const parsed = parsedObject(input);
353
506
  if (!parsed || typeof parsed.cmd !== "string") {
354
507
  throw new Error("Sandbox lifecycle command input was invalid");
@@ -379,26 +532,41 @@ class TurnToolCancellationControllerImpl implements TurnToolCancellationControll
379
532
  : session.exec
380
533
  ? await session.exec(commandArgs)
381
534
  : await session.execCommand!(commandArgs);
382
- return nativeCommandBanner(result);
535
+ return result;
383
536
  };
384
- const invokeWrite: FunctionToolInvoke | null = session.writeStdin
385
- ? async (_runContext, input) => {
386
- const parsed = parsedObject(input);
387
- if (!parsed || typeof parsed.session_id !== "number") {
388
- throw new Error("Sandbox lifecycle stdin input was invalid");
537
+ const invokeExec: FunctionToolInvoke = async (_runContext, input) =>
538
+ nativeCommandBanner(await invokeExecNative(input));
539
+ const invokeWrite: FunctionToolInvoke | null =
540
+ session.writeStdin || session.writeStdinForProcessControl
541
+ ? async (_runContext, input) => {
542
+ const parsed = parsedObject(input);
543
+ if (!parsed || typeof parsed.session_id !== "number") {
544
+ throw new Error("Sandbox lifecycle stdin input was invalid");
545
+ }
546
+ const directArgs = {
547
+ sessionId: parsed.session_id,
548
+ ...(typeof parsed.chars === "string" ? { chars: parsed.chars } : {}),
549
+ ...(typeof parsed.yield_time_ms === "number"
550
+ ? { yieldTimeMs: parsed.yield_time_ms }
551
+ : {}),
552
+ ...(typeof parsed.max_output_tokens === "number"
553
+ ? { maxOutputTokens: parsed.max_output_tokens }
554
+ : {}),
555
+ };
556
+ if (
557
+ session.hasRetainedProcess?.(parsed.session_id) === true &&
558
+ session.writeStdinForProcessControl
559
+ ) {
560
+ return await session.writeStdinForProcessControl(directArgs);
561
+ }
562
+ if (!session.writeStdin) {
563
+ throw new Error(
564
+ "Sandbox lifecycle command yielded an unretained session without stdin support",
565
+ );
566
+ }
567
+ return await session.writeStdin!(directArgs);
389
568
  }
390
- return await session.writeStdin!({
391
- sessionId: parsed.session_id,
392
- ...(typeof parsed.chars === "string" ? { chars: parsed.chars } : {}),
393
- ...(typeof parsed.yield_time_ms === "number"
394
- ? { yieldTimeMs: parsed.yield_time_ms }
395
- : {}),
396
- ...(typeof parsed.max_output_tokens === "number"
397
- ? { maxOutputTokens: parsed.max_output_tokens }
398
- : {}),
399
- });
400
- }
401
- : null;
569
+ : null;
402
570
  const correlationId = `turn_lifecycle_${crypto.randomUUID()}`;
403
571
  const useRemoteOpCancellation =
404
572
  Boolean(session.cancelExecCommand) && session.supportsPty?.() === false;
@@ -423,19 +591,49 @@ class TurnToolCancellationControllerImpl implements TurnToolCancellationControll
423
591
  ...(args.maxOutputTokens !== undefined ? { max_output_tokens: args.maxOutputTokens } : {}),
424
592
  });
425
593
 
426
- let initial: Awaited<ReturnType<FunctionToolInvoke>>;
594
+ let initialNative: Awaited<ReturnType<FunctionToolInvoke>>;
427
595
  try {
428
- initial = await runWithToolCallCorrelation(
596
+ initialNative = await runWithToolCallCorrelation(
429
597
  correlationId,
430
- async () => await invokeExec(lifecycleRunContext, commandInput, undefined),
598
+ async () => await invokeExecNative(commandInput),
431
599
  );
432
600
  } finally {
433
601
  remoteExec?.settle();
434
602
  }
435
- if (typeof initial !== "string" || useRemoteOpCancellation) return initial;
603
+ const initial = nativeCommandBanner(initialNative);
604
+ const initialResult = nativeCommandResult(initialNative);
605
+ if (useRemoteOpCancellation) {
606
+ if (!structured) return initial;
607
+ if (initialResult.exitCode === null) {
608
+ throw new Error("Sandbox command did not report a terminal exit code");
609
+ }
610
+ return {
611
+ stdout: initialResult.stdout,
612
+ stderr: initialResult.stderr,
613
+ exitCode: initialResult.exitCode,
614
+ wallTimeSeconds: Math.max(
615
+ initialResult.wallTimeSeconds,
616
+ (performance.now() - startedAt) / 1_000,
617
+ ),
618
+ } satisfies TurnSandboxCommandResult;
619
+ }
436
620
 
437
621
  const sessionId = parseExecBannerSessionId(initial);
438
- if (sessionId === null) return initial;
622
+ if (sessionId === null) {
623
+ if (!structured) return initial;
624
+ if (initialResult.exitCode === null) {
625
+ throw new Error("Sandbox command did not report a terminal exit code");
626
+ }
627
+ return {
628
+ stdout: initialResult.stdout,
629
+ stderr: initialResult.stderr,
630
+ exitCode: initialResult.exitCode,
631
+ wallTimeSeconds: Math.max(
632
+ initialResult.wallTimeSeconds,
633
+ (performance.now() - startedAt) / 1_000,
634
+ ),
635
+ } satisfies TurnSandboxCommandResult;
636
+ }
439
637
  if (!invokeWrite) {
440
638
  throw new Error("Sandbox lifecycle command yielded without stdin support");
441
639
  }
@@ -447,6 +645,7 @@ class TurnToolCancellationControllerImpl implements TurnToolCancellationControll
447
645
  runContext: lifecycleRunContext,
448
646
  execInvoke: invokeExec,
449
647
  writeInvoke: invokeWrite,
648
+ processSession: retainedProcessSession(session, sessionId),
450
649
  identity: null,
451
650
  identityValidated: false,
452
651
  cancellation: null,
@@ -455,6 +654,8 @@ class TurnToolCancellationControllerImpl implements TurnToolCancellationControll
455
654
  const maxOutputTokens = args.maxOutputTokens ?? 20_000;
456
655
  const initialOutput = execOutput(initial);
457
656
  let output = initialOutput ? appendBoundedOutput("", initialOutput, maxOutputTokens) : "";
657
+ let stdout = appendBoundedOutput("", initialResult.stdout, maxOutputTokens);
658
+ const stderr = appendBoundedOutput("", initialResult.stderr, maxOutputTokens);
458
659
  while (true) {
459
660
  if (this.cancelled) throw cancellationError(this.reason);
460
661
  const next = await invokeWrite(
@@ -471,11 +672,20 @@ class TurnToolCancellationControllerImpl implements TurnToolCancellationControll
471
672
  throw new Error("Sandbox lifecycle stdin returned an invalid result");
472
673
  }
473
674
  const nextOutput = execOutput(next);
474
- if (nextOutput) output = appendBoundedOutput(output, nextOutput, maxOutputTokens);
675
+ if (nextOutput) {
676
+ output = appendBoundedOutput(output, nextOutput, maxOutputTokens);
677
+ stdout = appendBoundedOutput(stdout, nextOutput, maxOutputTokens);
678
+ }
475
679
  const exitCode = parseExecBannerExitCode(next);
476
680
  if (exitCode !== null) {
477
681
  this.shellSessions.delete(sessionId);
478
- return completedCommandBanner(exitCode, output);
682
+ if (!structured) return completedCommandBanner(exitCode, output);
683
+ return {
684
+ stdout,
685
+ stderr,
686
+ exitCode,
687
+ wallTimeSeconds: (performance.now() - startedAt) / 1_000,
688
+ } satisfies TurnSandboxCommandResult;
479
689
  }
480
690
  // A conforming provider blocks for the requested yield. This fallback
481
691
  // prevents a non-conforming implementation from turning a long setup
@@ -485,19 +695,21 @@ class TurnToolCancellationControllerImpl implements TurnToolCancellationControll
485
695
  });
486
696
  }
487
697
 
488
- wrapTools(tools: Tool<unknown>[], session?: CommandCancellationSession): Tool<unknown>[] {
489
- for (const tool of tools) {
698
+ wrapTools<T>(tools: T[], session?: CommandCancellationSession): T[] {
699
+ const structuralTools = tools as unknown as Array<Record<string, unknown>>;
700
+ for (const tool of structuralTools) {
490
701
  if (tool.type !== "function") continue;
491
- if (tool.name === "exec_command") this.rawExecInvoke = tool.invoke;
492
- if (tool.name === "write_stdin") this.rawWriteInvoke = tool.invoke;
702
+ const functionTool = tool as FunctionTool;
703
+ if (functionTool.name === "exec_command") this.rawExecInvoke = functionTool.invoke;
704
+ if (functionTool.name === "write_stdin") this.rawWriteInvoke = functionTool.invoke;
493
705
  }
494
706
 
495
- return tools.map((tool) => {
496
- if (tool.type === "function") return this.wrapFunctionTool(tool, session);
497
- if (tool.type === "apply_patch") return this.wrapApplyPatchTool(tool);
498
- if (tool.type === "computer") return this.wrapComputerTool(tool);
707
+ return structuralTools.map((tool) => {
708
+ if (tool.type === "function") return this.wrapFunctionTool(tool as FunctionTool, session);
709
+ if (tool.type === "apply_patch") return this.wrapApplyPatchTool(tool as ApplyPatchTool);
710
+ if (tool.type === "computer") return this.wrapComputerTool(tool as ComputerTool);
499
711
  return tool;
500
- });
712
+ }) as unknown as T[];
501
713
  }
502
714
 
503
715
  private wrapFunctionTool(
@@ -560,6 +772,7 @@ class TurnToolCancellationControllerImpl implements TurnToolCancellationControll
560
772
  runContext,
561
773
  execInvoke: tool.invoke,
562
774
  writeInvoke: this.rawWriteInvoke,
775
+ processSession: retainedProcessSession(cancellationSession, sessionId),
563
776
  identity: null,
564
777
  identityValidated: false,
565
778
  cancellation: null,
@@ -588,7 +801,19 @@ class TurnToolCancellationControllerImpl implements TurnToolCancellationControll
588
801
  yield_time_ms: cappedYield(parsed.yield_time_ms, TURN_WRITE_YIELD_MS),
589
802
  })
590
803
  : input;
591
- const output = await tool.invoke(runContext, cappedInput, details);
804
+ const directProcessSession =
805
+ sessionId === null ? null : retainedProcessSession(cancellationSession, sessionId);
806
+ const output =
807
+ sessionId !== null && directProcessSession?.writeStdinForProcessMutation
808
+ ? await directProcessSession.writeStdinForProcessMutation({
809
+ sessionId,
810
+ ...(typeof parsed?.chars === "string" ? { chars: parsed.chars } : {}),
811
+ yieldTimeMs: cappedYield(parsed?.yield_time_ms, TURN_WRITE_YIELD_MS),
812
+ ...(typeof parsed?.max_output_tokens === "number"
813
+ ? { maxOutputTokens: parsed.max_output_tokens }
814
+ : {}),
815
+ })
816
+ : await tool.invoke(runContext, cappedInput, details);
592
817
  if (sessionId !== null && typeof output === "string") {
593
818
  if (isExecSessionLostBanner(output, sessionId)) {
594
819
  // Modal reports a vanished exec session as a non-throwing
@@ -605,6 +830,7 @@ class TurnToolCancellationControllerImpl implements TurnToolCancellationControll
605
830
  runContext,
606
831
  execInvoke: this.rawExecInvoke ?? tool.invoke,
607
832
  writeInvoke: tool.invoke,
833
+ processSession: directProcessSession,
608
834
  identity: null,
609
835
  identityValidated: false,
610
836
  cancellation: null,
@@ -626,9 +852,9 @@ class TurnToolCancellationControllerImpl implements TurnToolCancellationControll
626
852
  };
627
853
  }
628
854
 
629
- private wrapApplyPatchTool(tool: Extract<Tool<unknown>, { type: "apply_patch" }>) {
855
+ private wrapApplyPatchTool(tool: ApplyPatchTool): ApplyPatchTool {
630
856
  const editor = tool.editor;
631
- const wrappedEditor: Editor = {
857
+ const wrappedEditor: ApplyPatchEditor = {
632
858
  createFile: (operation, context) =>
633
859
  this.track(async () => await editor.createFile(operation, context)),
634
860
  updateFile: (operation, context) =>
@@ -639,33 +865,37 @@ class TurnToolCancellationControllerImpl implements TurnToolCancellationControll
639
865
  return { ...tool, editor: wrappedEditor };
640
866
  }
641
867
 
642
- private wrapComputerTool(tool: Extract<Tool<unknown>, { type: "computer" }>) {
868
+ private wrapComputerTool(tool: ComputerTool): ComputerTool {
643
869
  const computer = tool.computer;
644
870
  if (typeof computer === "function") {
871
+ const createComputer = computer as (...args: unknown[]) => object | Promise<object>;
645
872
  return {
646
873
  ...tool,
647
- computer: async (...args: Parameters<typeof computer>) =>
648
- wrapComputer(await this.track(async () => await computer(...args)), (operation) =>
874
+ computer: async (...args: unknown[]) =>
875
+ wrapComputer(await this.track(async () => await createComputer(...args)), (operation) =>
649
876
  this.track(operation),
650
877
  ),
651
878
  };
652
879
  }
653
- if ("create" in computer && typeof computer.create === "function") {
880
+ if (computer && typeof computer === "object" && "create" in computer) {
881
+ const provider = computer as Record<string, unknown>;
882
+ if (typeof provider.create !== "function") return tool;
883
+ const createComputer = provider.create as (...args: unknown[]) => object | Promise<object>;
654
884
  return {
655
885
  ...tool,
656
886
  computer: {
657
- ...computer,
658
- create: async (...args: Parameters<typeof computer.create>) =>
659
- wrapComputer(
660
- await this.track(async () => await computer.create(...args)),
661
- (operation) => this.track(operation),
887
+ ...provider,
888
+ create: async (...args: unknown[]) =>
889
+ wrapComputer(await this.track(async () => await createComputer(...args)), (operation) =>
890
+ this.track(operation),
662
891
  ),
663
892
  },
664
893
  };
665
894
  }
895
+ if (!computer || typeof computer !== "object") return tool;
666
896
  return {
667
897
  ...tool,
668
- computer: wrapComputer(computer as Computer, (operation) => this.track(operation)),
898
+ computer: wrapComputer(computer, (operation) => this.track(operation)),
669
899
  };
670
900
  }
671
901
 
@@ -776,8 +1006,11 @@ class TurnToolCancellationControllerImpl implements TurnToolCancellationControll
776
1006
  // authority available for that legacy shape.
777
1007
  if (!state.markerPath) {
778
1008
  while (true) {
779
- if (state.writeInvoke && (await this.rawWrite(state, "\u0003", SHELL_POLL_MS))) {
780
- this.shellSessions.delete(state.sessionId);
1009
+ if (
1010
+ (state.writeInvoke || state.processSession?.writeStdinForProcessControl) &&
1011
+ (await this.rawWrite(state, "\u0003", SHELL_POLL_MS))
1012
+ ) {
1013
+ await this.forgetShellSessionAfterExactSettlement(state);
781
1014
  return;
782
1015
  }
783
1016
  await delay(SHELL_POLL_MS);
@@ -790,10 +1023,10 @@ class TurnToolCancellationControllerImpl implements TurnToolCancellationControll
790
1023
  while (!state.identity) {
791
1024
  state.identity = await this.readIdentity(state);
792
1025
  if (state.identity) break;
793
- if (state.writeInvoke) {
1026
+ if (state.writeInvoke || state.processSession?.writeStdinForProcessControl) {
794
1027
  const completed = await this.rawWrite(state, "", SHELL_POLL_MS);
795
1028
  if (completed) {
796
- this.shellSessions.delete(state.sessionId);
1029
+ await this.forgetShellSessionAfterExactSettlement(state);
797
1030
  return;
798
1031
  }
799
1032
  }
@@ -801,35 +1034,55 @@ class TurnToolCancellationControllerImpl implements TurnToolCancellationControll
801
1034
  }
802
1035
 
803
1036
  if (!(await this.identityAlive(state))) {
804
- this.shellSessions.delete(state.sessionId);
1037
+ await this.forgetShellSessionAfterExactSettlement(state);
805
1038
  return;
806
1039
  }
807
1040
 
808
- if (state.writeInvoke) {
1041
+ // A retained provider session must remain pinned until its process group is
1042
+ // absent. Polling it first could settle/delete the durable route while an
1043
+ // ignored-signal descendant still lives, making the exact helper backend
1044
+ // unavailable. Generic legacy sessions retain their Ctrl-C fast path.
1045
+ if (!state.processSession && state.writeInvoke) {
809
1046
  await this.rawWrite(state, "\u0003", SHELL_POLL_MS);
810
1047
  if (!(await this.identityAlive(state))) {
811
- this.shellSessions.delete(state.sessionId);
1048
+ await this.forgetShellSessionAfterExactSettlement(state);
812
1049
  return;
813
1050
  }
814
1051
  }
815
1052
 
816
1053
  await this.signalIdentity(state, "TERM");
817
1054
  for (let attempt = 0; attempt < SHELL_GRACEFUL_POLLS; attempt++) {
818
- if (state.writeInvoke) await this.rawWrite(state, "", SHELL_POLL_MS);
1055
+ if (!state.processSession && state.writeInvoke) {
1056
+ await this.rawWrite(state, "", SHELL_POLL_MS);
1057
+ }
819
1058
  if (!(await this.identityAlive(state))) {
820
- this.shellSessions.delete(state.sessionId);
1059
+ await this.forgetShellSessionAfterExactSettlement(state);
821
1060
  return;
822
1061
  }
823
1062
  }
824
1063
 
825
1064
  await this.signalIdentity(state, "KILL");
826
1065
  while (await this.identityAlive(state)) {
827
- if (state.writeInvoke) await this.rawWrite(state, "", SHELL_POLL_MS);
1066
+ if (!state.processSession && state.writeInvoke) {
1067
+ await this.rawWrite(state, "", SHELL_POLL_MS);
1068
+ }
828
1069
  // A transient helper failure must not turn the first KILL into a single
829
1070
  // best-effort shot. Re-issue it until the process group is provably gone.
830
1071
  await this.signalIdentity(state, "KILL");
831
1072
  await delay(SHELL_POLL_MS);
832
1073
  }
1074
+ await this.forgetShellSessionAfterExactSettlement(state);
1075
+ }
1076
+
1077
+ private async forgetShellSessionAfterExactSettlement(state: ActiveShellSession): Promise<void> {
1078
+ // OS process-group absence is necessary but not sufficient for a routed
1079
+ // retained process. One final exact provider-session poll must publish an
1080
+ // exit/loss proof and durably settle the process holder before tracking is
1081
+ // removed. Settlement failure keeps hasRetainedProcess true and retries.
1082
+ while (state.processSession?.hasRetainedProcess?.(state.sessionId) === true) {
1083
+ if (await this.rawWrite(state, "", SHELL_POLL_MS)) break;
1084
+ await delay(SHELL_POLL_MS);
1085
+ }
833
1086
  this.shellSessions.delete(state.sessionId);
834
1087
  }
835
1088
 
@@ -838,18 +1091,25 @@ class TurnToolCancellationControllerImpl implements TurnToolCancellationControll
838
1091
  chars: string,
839
1092
  yieldTimeMs: number,
840
1093
  ): Promise<boolean> {
841
- if (!state.writeInvoke) return false;
1094
+ if (!state.writeInvoke && !state.processSession?.writeStdinForProcessControl) return false;
842
1095
  try {
843
- const output = await state.writeInvoke(
844
- state.runContext,
845
- JSON.stringify({
846
- session_id: state.sessionId,
847
- chars,
848
- yield_time_ms: yieldTimeMs,
849
- max_output_tokens: 128,
850
- }),
851
- undefined,
852
- );
1096
+ const output = state.processSession?.writeStdinForProcessControl
1097
+ ? await state.processSession.writeStdinForProcessControl({
1098
+ sessionId: state.sessionId,
1099
+ chars,
1100
+ yieldTimeMs,
1101
+ maxOutputTokens: 128,
1102
+ })
1103
+ : await state.writeInvoke!(
1104
+ state.runContext,
1105
+ JSON.stringify({
1106
+ session_id: state.sessionId,
1107
+ chars,
1108
+ yield_time_ms: yieldTimeMs,
1109
+ max_output_tokens: 128,
1110
+ }),
1111
+ undefined,
1112
+ );
853
1113
  if (typeof output !== "string") return false;
854
1114
  // Marker files can already be gone when a process exited outside the
855
1115
  // model-facing write path. Modal's exact matching lost-session banner is
@@ -868,11 +1128,13 @@ class TurnToolCancellationControllerImpl implements TurnToolCancellationControll
868
1128
  private async readIdentity(state: ActiveShellSession): Promise<ShellProcessIdentity | null> {
869
1129
  if (!state.markerPath) return null;
870
1130
  try {
871
- const output = await state.execInvoke(
872
- state.runContext,
873
- shellHelperInput(`command cat ${singleQuote(state.markerPath)} 2>/dev/null`),
874
- undefined,
875
- );
1131
+ const command = `command cat ${singleQuote(state.markerPath)} 2>/dev/null`;
1132
+ const output = state.processSession?.execCommandForProcessControl
1133
+ ? await state.processSession.execCommandForProcessControl(
1134
+ state.sessionId,
1135
+ shellHelperArgs(command),
1136
+ )
1137
+ : await state.execInvoke(state.runContext, shellHelperInput(command), undefined);
876
1138
  return typeof output === "string" ? safeProcessIdentity(output) : null;
877
1139
  } catch {
878
1140
  return null;
@@ -892,7 +1154,12 @@ class TurnToolCancellationControllerImpl implements TurnToolCancellationControll
892
1154
  76,
893
1155
  );
894
1156
  try {
895
- const output = await state.execInvoke(state.runContext, shellHelperInput(script), undefined);
1157
+ const output = state.processSession?.execCommandForProcessControl
1158
+ ? await state.processSession.execCommandForProcessControl(
1159
+ state.sessionId,
1160
+ shellHelperArgs(script),
1161
+ )
1162
+ : await state.execInvoke(state.runContext, shellHelperInput(script), undefined);
896
1163
  if (typeof output !== "string") return true;
897
1164
  const exitCode = parseExecBannerExitCode(output);
898
1165
  if (exitCode === 75) {
@@ -914,7 +1181,14 @@ class TurnToolCancellationControllerImpl implements TurnToolCancellationControll
914
1181
  76,
915
1182
  );
916
1183
  try {
917
- await state.execInvoke(state.runContext, shellHelperInput(script), undefined);
1184
+ if (state.processSession?.execCommandForProcessControl) {
1185
+ await state.processSession.execCommandForProcessControl(
1186
+ state.sessionId,
1187
+ shellHelperArgs(script),
1188
+ );
1189
+ } else {
1190
+ await state.execInvoke(state.runContext, shellHelperInput(script), undefined);
1191
+ }
918
1192
  } catch {
919
1193
  // Confirmation is performed by identityAlive(). A failed signal helper
920
1194
  // never opens the fence; it is retried/escalated until the process is gone.
@@ -930,7 +1204,7 @@ export function createTurnToolCancellationController(
930
1204
 
931
1205
  /** Wrap a capability instance without depending on SDK-private subclasses. */
932
1206
  export function wrapCapabilityToolsForTurnCancellation(
933
- capability: { tools(): Tool<unknown>[]; _session?: CommandCancellationSession },
1207
+ capability: { tools(): unknown[]; _session?: CommandCancellationSession },
934
1208
  controller: TurnToolCancellationController,
935
1209
  ): void {
936
1210
  const originalTools = capability.tools;