@ai-sdk/harness 1.0.117 → 1.0.119

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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-sdk/harness",
3
- "version": "1.0.117",
3
+ "version": "1.0.119",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "sideEffects": false,
@@ -46,7 +46,7 @@
46
46
  "dependencies": {
47
47
  "@ai-sdk/provider": "4.0.17",
48
48
  "@ai-sdk/provider-utils": "5.0.45",
49
- "ai": "7.0.107"
49
+ "ai": "7.0.109"
50
50
  },
51
51
  "peerDependencies": {
52
52
  "ws": "^8.21.0",
@@ -58,7 +58,7 @@
58
58
  }
59
59
  },
60
60
  "devDependencies": {
61
- "@ai-sdk/otel": "1.0.107",
61
+ "@ai-sdk/otel": "1.0.109",
62
62
  "@opentelemetry/sdk-trace-base": "2.7.1",
63
63
  "@types/node": "22.19.19",
64
64
  "@types/ws": "^8.5.13",
@@ -1,6 +1,7 @@
1
1
  import type {
2
2
  Context,
3
3
  Experimental_SandboxSession as SandboxSession,
4
+ InferToolSetContext,
4
5
  ToolApprovalResponse,
5
6
  ToolResultPart,
6
7
  ToolSet,
@@ -65,6 +66,7 @@ type ActivePromptControl = {
65
66
  type ActiveTurnSettings = {
66
67
  readonly persisted: HarnessV1TurnSettings;
67
68
  readonly tools: ToolSet;
69
+ readonly toolsContext: Record<string, Context | undefined>;
68
70
  readonly activeTools: ToolSet;
69
71
  readonly builtinToolFiltering: HarnessV1BuiltinToolFiltering | undefined;
70
72
  };
@@ -119,6 +121,9 @@ export class HarnessAgentSession {
119
121
  | undefined;
120
122
  private activeTurnSettings: ActiveTurnSettings | undefined;
121
123
  private persistedTurnSettings: HarnessV1TurnSettings | undefined;
124
+ private readonly resumedToolsContext:
125
+ | Record<string, Context | undefined>
126
+ | undefined;
122
127
 
123
128
  /**
124
129
  * Whether this session was created from `resumeFrom` or `continueFrom`.
@@ -137,6 +142,7 @@ export class HarnessAgentSession {
137
142
  pendingToolApprovals?: readonly HarnessAgentPendingToolApproval[];
138
143
  pendingToolResults?: readonly HarnessAgentPendingToolResult[];
139
144
  turnSettings?: HarnessV1TurnSettings;
145
+ resumedToolsContext?: Record<string, Context | undefined>;
140
146
  turnState?: HarnessAgentTurnState;
141
147
  }) {
142
148
  this.sessionId = options.sessionId;
@@ -153,6 +159,7 @@ export class HarnessAgentSession {
153
159
  this.pendingToolResults.set(pendingResult.toolCallId, pendingResult);
154
160
  }
155
161
  this.persistedTurnSettings = options.turnSettings;
162
+ this.resumedToolsContext = options.resumedToolsContext;
156
163
  this.turnState =
157
164
  options.turnState ??
158
165
  (this.pendingToolApprovals.size > 0
@@ -201,6 +208,7 @@ export class HarnessAgentSession {
201
208
  skills: ReadonlyArray<HarnessV1Skill>;
202
209
  instructions: string | undefined;
203
210
  tools: TOOLS;
211
+ toolsContext: InferToolSetContext<TOOLS>;
204
212
  activeTools: ToolSet;
205
213
  toolSpecs: HarnessAgentToolSpec[];
206
214
  builtinToolFiltering: HarnessV1BuiltinToolFiltering | undefined;
@@ -225,6 +233,7 @@ export class HarnessAgentSession {
225
233
  this.activeTurnSettings = {
226
234
  persisted: this.persistedTurnSettings,
227
235
  tools: options.tools,
236
+ toolsContext: options.toolsContext,
228
237
  activeTools: options.activeTools,
229
238
  builtinToolFiltering: options.builtinToolFiltering,
230
239
  };
@@ -239,6 +248,7 @@ export class HarnessAgentSession {
239
248
  skills: options.skills,
240
249
  instructions: options.instructions,
241
250
  tools: options.tools,
251
+ toolsContext: options.toolsContext,
242
252
  activeTools: options.activeTools,
243
253
  toolSpecs: options.toolSpecs,
244
254
  builtinToolFiltering: options.builtinToolFiltering,
@@ -302,6 +312,7 @@ export class HarnessAgentSession {
302
312
  skills: ReadonlyArray<HarnessV1Skill>;
303
313
  instructions: string | undefined;
304
314
  tools: TOOLS;
315
+ toolsContext: InferToolSetContext<TOOLS>;
305
316
  activeTools: ToolSet;
306
317
  toolSpecs: HarnessAgentToolSpec[];
307
318
  builtinToolFiltering: HarnessV1BuiltinToolFiltering | undefined;
@@ -322,6 +333,7 @@ export class HarnessAgentSession {
322
333
  skills: options.skills,
323
334
  instructions: options.instructions,
324
335
  tools: options.tools,
336
+ toolsContext: options.toolsContext,
325
337
  activeTools: options.activeTools,
326
338
  toolSpecs: options.toolSpecs,
327
339
  builtinToolFiltering: options.builtinToolFiltering,
@@ -337,6 +349,7 @@ export class HarnessAgentSession {
337
349
  skills: turnSettings.persisted.skills,
338
350
  instructions: turnSettings.persisted.instructions,
339
351
  tools: turnSettings.tools as TOOLS,
352
+ toolsContext: turnSettings.toolsContext as InferToolSetContext<TOOLS>,
340
353
  activeTools: turnSettings.activeTools,
341
354
  toolSpecs: [...turnSettings.persisted.tools],
342
355
  builtinToolFiltering: turnSettings.builtinToolFiltering,
@@ -778,6 +791,7 @@ export class HarnessAgentSession {
778
791
  skills: ReadonlyArray<HarnessV1Skill>;
779
792
  instructions: string | undefined;
780
793
  tools: ToolSet;
794
+ toolsContext: Record<string, Context | undefined>;
781
795
  activeTools: ToolSet;
782
796
  toolSpecs: HarnessAgentToolSpec[];
783
797
  builtinToolFiltering: HarnessV1BuiltinToolFiltering | undefined;
@@ -811,6 +825,7 @@ export class HarnessAgentSession {
811
825
  this.activeTurnSettings = {
812
826
  persisted,
813
827
  tools: options.tools,
828
+ toolsContext: this.resumedToolsContext ?? options.toolsContext,
814
829
  activeTools,
815
830
  builtinToolFiltering: options.builtinToolFiltering,
816
831
  };
@@ -13,6 +13,7 @@ import type {
13
13
  Context,
14
14
  Experimental_SandboxSession as SandboxSession,
15
15
  FlexibleSchema,
16
+ InferToolSetContext,
16
17
  MaybePromiseLike,
17
18
  SystemModelMessage,
18
19
  ToolSet,
@@ -34,6 +35,7 @@ import type {
34
35
  TelemetryOptions,
35
36
  ToolApprovalStatus,
36
37
  } from 'ai';
38
+ import type { ToolsContextSettings } from 'ai/internal';
37
39
  import type { HarnessAllTools } from './harness-agent-tool-types';
38
40
 
39
41
  export type HarnessAgentToolApprovalConfiguration = Readonly<
@@ -150,6 +152,13 @@ export type HarnessAgentSettings<
150
152
  */
151
153
  readonly tools?: TUserTools;
152
154
 
155
+ /**
156
+ * Per-tool context passed to host-executed tools. Each entry is validated
157
+ * against the matching tool's `contextSchema` before execution.
158
+ * `prepareCall` can replace it for each new turn.
159
+ */
160
+ readonly toolsContext?: InferToolSetContext<TUserTools>;
161
+
153
162
  /**
154
163
  * Skills made available to the underlying runtime. Each adapter decides how
155
164
  * to surface skills. `prepareCall` can replace them between completed turns.
@@ -225,7 +234,9 @@ export type HarnessAgentSettings<
225
234
  CALL_OPTIONS
226
235
  >,
227
236
  'model' | 'skills' | 'instructions' | 'tools'
228
- >,
237
+ > & {
238
+ toolsContext: InferToolSetContext<TUserTools>;
239
+ },
229
240
  ) => MaybePromiseLike<
230
241
  Pick<
231
242
  HarnessAgentSettings<
@@ -236,8 +247,9 @@ export type HarnessAgentSettings<
236
247
  CALL_OPTIONS
237
248
  >,
238
249
  'model' | 'skills' | 'instructions' | 'tools'
239
- > &
240
- Omit<Prompt, 'system' | 'instructions' | 'allowSystemInMessages'>
250
+ > & {
251
+ toolsContext: InferToolSetContext<TUserTools>;
252
+ } & Omit<Prompt, 'system' | 'instructions' | 'allowSystemInMessages'>
241
253
  >;
242
254
 
243
255
  /**
@@ -376,4 +388,5 @@ export type HarnessAgentSettings<
376
388
  * stderr default — wire this to capture diagnostics in code.
377
389
  */
378
390
  readonly onLog?: (event: HarnessDiagnostic) => void;
379
- } & HarnessAgentToolFilteringSettings<HarnessAllTools<THarness, TUserTools>>;
391
+ } & ToolsContextSettings<TUserTools> &
392
+ HarnessAgentToolFilteringSettings<HarnessAllTools<THarness, TUserTools>>;
@@ -10,6 +10,7 @@ import {
10
10
  asArray,
11
11
  asSchema,
12
12
  generateId,
13
+ type InferToolSetContext,
13
14
  normalizeHeaders,
14
15
  validateTypes,
15
16
  type Context,
@@ -20,7 +21,7 @@ import {
20
21
  type ToolResultPart,
21
22
  type ToolSet,
22
23
  } from '@ai-sdk/provider-utils';
23
- import { mergeCallbacks } from 'ai/internal';
24
+ import { mergeCallbacks, type ToolsContextSettings } from 'ai/internal';
24
25
  import type {
25
26
  Agent,
26
27
  AgentCallParameters,
@@ -99,6 +100,7 @@ type PreparedHarnessAgentTurnSettings<
99
100
  skills: ReadonlyArray<HarnessAgentSkill>;
100
101
  instructions: string | undefined;
101
102
  tools: HarnessAllTools<THarness, TUserTools>;
103
+ toolsContext: InferToolSetContext<HarnessAllTools<THarness, TUserTools>>;
102
104
  activeTools: TUserTools;
103
105
  toolSpecs: HarnessAgentToolSpec[];
104
106
  builtinToolFiltering: HarnessV1BuiltinToolFiltering | undefined;
@@ -314,6 +316,13 @@ export class HarnessAgent<
314
316
  * handing it to the adapter.
315
317
  */
316
318
  continueFrom?: HarnessAgentContinueTurnState;
319
+ /**
320
+ * Rebinds host-only tool context for an unfinished turn resumed with
321
+ * `continueFrom` (directly or through `resumeFrom`). Tool context is not
322
+ * serialized into lifecycle state because it may contain credentials or
323
+ * non-serializable host objects.
324
+ */
325
+ toolsContext?: ToolsContextSettings<TUserTools>['toolsContext'];
317
326
  /**
318
327
  * Existing sandbox session to run the harness in. When provided, the
319
328
  * caller retains ownership of the sandbox lifecycle.
@@ -356,6 +365,11 @@ export class HarnessAgent<
356
365
 
357
366
  const effectiveContinueFrom =
358
367
  validatedContinueFrom ?? validatedResumeFrom?.continueFrom;
368
+ if (options?.toolsContext != null && effectiveContinueFrom == null) {
369
+ throw new Error(
370
+ 'HarnessAgent.createSession: `toolsContext` can only rebind an unfinished turn from `continueFrom` or `resumeFrom`.',
371
+ );
372
+ }
359
373
  const isResumedSession =
360
374
  validatedResumeFrom != null || effectiveContinueFrom != null;
361
375
 
@@ -552,6 +566,7 @@ export class HarnessAgent<
552
566
  pendingToolApprovals: effectiveContinueFrom?.pendingToolApprovals,
553
567
  pendingToolResults: effectiveContinueFrom?.pendingToolResults,
554
568
  turnSettings: effectiveContinueFrom?.turnSettings,
569
+ resumedToolsContext: options?.toolsContext,
555
570
  turnState:
556
571
  effectiveContinueFrom == null
557
572
  ? 'idle'
@@ -785,6 +800,7 @@ export class HarnessAgent<
785
800
  skills: input.turnSettings.skills,
786
801
  instructions: input.turnSettings.instructions,
787
802
  tools: input.turnSettings.tools,
803
+ toolsContext: input.turnSettings.toolsContext,
788
804
  activeTools: input.turnSettings.activeTools,
789
805
  toolSpecs: input.turnSettings.toolSpecs,
790
806
  builtinToolFiltering: input.turnSettings.builtinToolFiltering,
@@ -943,6 +959,8 @@ export class HarnessAgent<
943
959
  skills: this.settings.skills,
944
960
  instructions: this.settings.instructions,
945
961
  tools: this.settings.tools,
962
+ toolsContext:
963
+ this.settings.toolsContext ?? ({} as InferToolSetContext<TUserTools>),
946
964
  ...promptOptions,
947
965
  };
948
966
  const preparedCallArgs =
@@ -972,6 +990,7 @@ export class HarnessAgent<
972
990
  skills: preparedCallArgs.skills,
973
991
  instructions: preparedCallArgs.instructions,
974
992
  tools: preparedCallArgs.tools,
993
+ toolsContext: preparedCallArgs.toolsContext,
975
994
  }),
976
995
  };
977
996
  }
@@ -986,6 +1005,7 @@ export class HarnessAgent<
986
1005
  skills: this.settings.skills,
987
1006
  instructions: this.settings.instructions,
988
1007
  tools: this.settings.tools,
1008
+ toolsContext: this.settings.toolsContext,
989
1009
  }),
990
1010
  toolApprovalContinuations: options.toolApprovalContinuations ?? [],
991
1011
  toolResultContinuations: options.toolResultContinuations ?? [],
@@ -997,6 +1017,7 @@ export class HarnessAgent<
997
1017
  skills?: ReadonlyArray<HarnessAgentSkill>;
998
1018
  instructions?: string | SystemModelMessage;
999
1019
  tools?: TUserTools;
1020
+ toolsContext?: InferToolSetContext<TUserTools>;
1000
1021
  }): PreparedHarnessAgentTurnSettings<THarness, TUserTools> {
1001
1022
  const userTools = options.tools ?? ({} as TUserTools);
1002
1023
  assertNoReservedQuestionTool({
@@ -1022,6 +1043,9 @@ export class HarnessAgent<
1022
1043
  ? options.instructions
1023
1044
  : options.instructions?.content,
1024
1045
  tools,
1046
+ toolsContext: (options.toolsContext ?? {}) as InferToolSetContext<
1047
+ HarnessAllTools<THarness, TUserTools>
1048
+ >,
1025
1049
  activeTools: toolFiltering.activeUserTools,
1026
1050
  toolSpecs: this._toToolSpecs(toolFiltering.activeUserTools),
1027
1051
  builtinToolFiltering: toolFiltering.builtinToolFiltering,
@@ -0,0 +1,8 @@
1
+ export function getOwn<T extends object>(
2
+ obj: T | undefined | null,
3
+ key: string,
4
+ ): T[keyof T] | undefined {
5
+ return obj != null && Object.prototype.hasOwnProperty.call(obj, key)
6
+ ? obj[key as keyof T]
7
+ : undefined;
8
+ }
@@ -32,7 +32,11 @@ import {
32
32
  type LanguageModelV4ToolCall,
33
33
  type LanguageModelV4Usage,
34
34
  } from '@ai-sdk/provider';
35
- import { asLanguageModelUsage, parseToolCall } from 'ai/internal';
35
+ import {
36
+ asLanguageModelUsage,
37
+ parseToolCall,
38
+ validateToolContext,
39
+ } from 'ai/internal';
36
40
  import type {
37
41
  ContentPart,
38
42
  OutputInterface as Output,
@@ -47,6 +51,7 @@ import type {
47
51
  } from 'ai';
48
52
  import type { HarnessAgentToolApprovalConfiguration } from '../harness-agent-settings';
49
53
  import { HarnessStreamTextResult } from './harness-stream-text-result';
54
+ import { getOwn } from './get-own';
50
55
  import { translateStreamPart } from './translate-stream-part';
51
56
  import { createToolInputWorkDirStripper, stripWorkDir } from './strip-work-dir';
52
57
  import {
@@ -112,6 +117,7 @@ export function runPrompt<
112
117
  skills?: ReadonlyArray<HarnessV1Skill>;
113
118
  instructions: string | undefined;
114
119
  tools: TOOLS;
120
+ toolsContext?: InferToolSetContext<TOOLS>;
115
121
  activeTools?: ToolSet;
116
122
  toolSpecs: HarnessV1ToolSpec[];
117
123
  builtinToolFiltering?: HarnessV1BuiltinToolFiltering | undefined;
@@ -147,7 +153,7 @@ export function runPrompt<
147
153
  done: Promise<void>;
148
154
  } {
149
155
  const callId = generateId();
150
- const toolsContext = {} as InferToolSetContext<TOOLS>;
156
+ const toolsContext = input.toolsContext ?? ({} as InferToolSetContext<TOOLS>);
151
157
  const result = new HarnessStreamTextResult<TOOLS, RUNTIME_CONTEXT, OUTPUT>({
152
158
  tools: input.tools,
153
159
  runtimeContext: input.runtimeContext,
@@ -701,6 +707,7 @@ export function runPrompt<
701
707
  const execution = await maybeExecuteHostTool({
702
708
  event: rawToolCall,
703
709
  tools: activeTools,
710
+ toolsContext,
704
711
  wrappedExecuteTool: lifecycle.executeTool,
705
712
  sandboxSession: input.sandboxSession,
706
713
  abortSignal: input.abortSignal,
@@ -1260,6 +1267,7 @@ export function runPrompt<
1260
1267
  const execution = await maybeExecuteHostTool({
1261
1268
  event: toolCall,
1262
1269
  tools: activeTools,
1270
+ toolsContext,
1263
1271
  wrappedExecuteTool: lifecycle.executeTool,
1264
1272
  sandboxSession: input.sandboxSession,
1265
1273
  abortSignal: input.abortSignal,
@@ -1426,6 +1434,7 @@ function hasTool(input: { tools: ToolSet; toolName: string }): boolean {
1426
1434
  async function maybeExecuteHostTool<TOOLS extends ToolSet>(input: {
1427
1435
  event: { toolCallId: string; toolName: string; input: string };
1428
1436
  tools: TOOLS;
1437
+ toolsContext: InferToolSetContext<TOOLS>;
1429
1438
  wrappedExecuteTool: TurnLifecycle<ToolSet, Context>['executeTool'];
1430
1439
  sandboxSession: SandboxSession;
1431
1440
  abortSignal: AbortSignal | undefined;
@@ -1444,6 +1453,25 @@ async function maybeExecuteHostTool<TOOLS extends ToolSet>(input: {
1444
1453
  const parsed = await safeParseJSON({ text: input.event.input });
1445
1454
  const args = parsed.success ? parsed.value : input.event.input;
1446
1455
 
1456
+ let context: unknown;
1457
+ try {
1458
+ context = await validateToolContext({
1459
+ toolName: input.event.toolName,
1460
+ context: getOwn(input.toolsContext, input.event.toolName),
1461
+ contextSchema: tool.contextSchema,
1462
+ });
1463
+ } catch (err) {
1464
+ // Context is host-only and can contain credentials. Keep the detailed
1465
+ // validation error in the host-facing outcome, but never send its value or
1466
+ // schema diagnostics back to the runtime/model.
1467
+ await input.submitToolResult({
1468
+ toolCallId: input.event.toolCallId,
1469
+ output: { error: 'Tool context validation failed.' },
1470
+ isError: true,
1471
+ });
1472
+ return { executed: true, outcome: { ok: false, error: err } };
1473
+ }
1474
+
1447
1475
  try {
1448
1476
  /*
1449
1477
  * Normalize the tool's return value through `executeTool`, the same helper
@@ -1466,7 +1494,7 @@ async function maybeExecuteHostTool<TOOLS extends ToolSet>(input: {
1466
1494
  toolCallId: input.event.toolCallId,
1467
1495
  messages: [],
1468
1496
  abortSignal: input.abortSignal,
1469
- context: undefined as never,
1497
+ context: context as never,
1470
1498
  experimental_sandbox: input.sandboxSession,
1471
1499
  },
1472
1500
  });
@@ -12,36 +12,63 @@ export function createToolInputWorkDirStripper({
12
12
  }: {
13
13
  sessionWorkDir: string;
14
14
  }): (part: ToolInputStreamPart) => ToolInputStreamPart[] {
15
- const pendingByToolCallId = new Map<string, string>();
15
+ const stateByToolCallId = new Map<
16
+ string,
17
+ {
18
+ pending: string;
19
+ precedingCharacter: string | undefined;
20
+ }
21
+ >();
16
22
 
17
23
  return part => {
18
24
  if (sessionWorkDir.length === 0) return [part];
19
25
 
20
26
  if (part.type === 'tool-input-start') {
21
- pendingByToolCallId.set(part.id, '');
27
+ stateByToolCallId.set(part.id, {
28
+ pending: '',
29
+ precedingCharacter: undefined,
30
+ });
22
31
  return [part];
23
32
  }
24
33
 
25
34
  if (part.type === 'tool-input-delta') {
35
+ const state = stateByToolCallId.get(part.id) ?? {
36
+ pending: '',
37
+ precedingCharacter: undefined,
38
+ };
39
+ const value = state.pending + part.delta;
26
40
  const stripped = stripStreamingString({
27
- value: (pendingByToolCallId.get(part.id) ?? '') + part.delta,
41
+ value,
28
42
  workDir: sessionWorkDir,
29
43
  final: false,
44
+ precedingCharacter: state.precedingCharacter,
45
+ });
46
+ const pendingStart = value.length - stripped.pending.length;
47
+ stateByToolCallId.set(part.id, {
48
+ pending: stripped.pending,
49
+ precedingCharacter:
50
+ stripped.pending.length > 0
51
+ ? pendingStart > 0
52
+ ? value[pendingStart - 1]
53
+ : state.precedingCharacter
54
+ : value.length > 0
55
+ ? value[value.length - 1]
56
+ : state.precedingCharacter,
30
57
  });
31
- pendingByToolCallId.set(part.id, stripped.pending);
32
58
  return stripped.output.length === 0
33
59
  ? []
34
60
  : [{ ...part, delta: stripped.output }];
35
61
  }
36
62
 
37
- const pending = pendingByToolCallId.get(part.id);
38
- pendingByToolCallId.delete(part.id);
39
- if (pending == null || pending.length === 0) return [part];
63
+ const state = stateByToolCallId.get(part.id);
64
+ stateByToolCallId.delete(part.id);
65
+ if (state == null || state.pending.length === 0) return [part];
40
66
 
41
67
  const stripped = stripStreamingString({
42
- value: pending,
68
+ value: state.pending,
43
69
  workDir: sessionWorkDir,
44
70
  final: true,
71
+ precedingCharacter: state.precedingCharacter,
45
72
  });
46
73
  return stripped.output.length === 0
47
74
  ? [part]
@@ -62,11 +89,10 @@ export function createToolInputWorkDirStripper({
62
89
  * operates. The absolute paths are correct but noisy in a UI, so this strips
63
90
  * the prefix for the consumer-facing projection only.
64
91
  *
65
- * Blanket prefix replacement (rather than rewriting known path fields) is used
66
- * deliberately: `tool-result` results are free-form text — command stdout, grep
67
- * output — where paths can appear anywhere and field-aware rewriting is
68
- * impossible. The prefix is long and contains the session id, so it is unique
69
- * enough that replacing every occurrence is safe.
92
+ * Boundary-aware prefix replacement (rather than rewriting known path fields)
93
+ * is used deliberately: `tool-result` results are free-form text — command
94
+ * stdout, grep output — where paths can appear anywhere and field-aware
95
+ * rewriting is impossible.
70
96
  */
71
97
  export function stripWorkDir(
72
98
  part: HarnessV1StreamPart,
@@ -101,33 +127,60 @@ export function stripWorkDir(
101
127
  * becomes `.`.
102
128
  */
103
129
  function stripString(value: string, workDir: string): string {
104
- return value.split(`${workDir}/`).join('').split(workDir).join('.');
130
+ return stripStreamingString({
131
+ value,
132
+ workDir,
133
+ final: true,
134
+ precedingCharacter: undefined,
135
+ }).output;
105
136
  }
106
137
 
107
138
  function stripStreamingString({
108
139
  value,
109
140
  workDir,
110
141
  final,
142
+ precedingCharacter,
111
143
  }: {
112
144
  value: string;
113
145
  workDir: string;
114
146
  final: boolean;
147
+ precedingCharacter: string | undefined;
115
148
  }): { output: string; pending: string } {
116
149
  let remaining = value;
117
150
  let output = '';
151
+ let characterBeforeRemaining = precedingCharacter;
118
152
 
119
153
  while (remaining.length > 0) {
120
154
  const matchIndex = remaining.indexOf(workDir);
121
155
  if (matchIndex >= 0) {
122
- output += remaining.slice(0, matchIndex);
123
156
  const followingIndex = matchIndex + workDir.length;
124
- if (followingIndex === remaining.length && !final) {
125
- return { output, pending: remaining.slice(matchIndex) };
157
+ const hasPathBoundary = isPathBoundary(
158
+ remaining,
159
+ matchIndex,
160
+ characterBeforeRemaining,
161
+ );
162
+ if (hasPathBoundary && followingIndex === remaining.length && !final) {
163
+ return {
164
+ output: output + remaining.slice(0, matchIndex),
165
+ pending: remaining.slice(matchIndex),
166
+ };
126
167
  }
127
- if (remaining[followingIndex] === '/') {
168
+
169
+ const isPath =
170
+ hasPathBoundary &&
171
+ (followingIndex === remaining.length ||
172
+ remaining[followingIndex] === '/' ||
173
+ isPathTerminator(remaining[followingIndex]));
174
+ if (!isPath) {
175
+ output += remaining.slice(0, followingIndex);
176
+ characterBeforeRemaining = remaining[followingIndex - 1];
177
+ remaining = remaining.slice(followingIndex);
178
+ } else if (remaining[followingIndex] === '/') {
179
+ output += remaining.slice(0, matchIndex);
180
+ characterBeforeRemaining = remaining[followingIndex];
128
181
  remaining = remaining.slice(followingIndex + 1);
129
182
  } else {
130
- output += '.';
183
+ output += remaining.slice(0, matchIndex) + '.';
131
184
  remaining = remaining.slice(followingIndex);
132
185
  }
133
186
  continue;
@@ -138,7 +191,12 @@ function stripStreamingString({
138
191
  let pendingLength = Math.min(remaining.length, workDir.length - 1);
139
192
  while (
140
193
  pendingLength > 0 &&
141
- !workDir.startsWith(remaining.slice(-pendingLength))
194
+ (!workDir.startsWith(remaining.slice(-pendingLength)) ||
195
+ !isPathBoundary(
196
+ remaining,
197
+ remaining.length - pendingLength,
198
+ characterBeforeRemaining,
199
+ ))
142
200
  ) {
143
201
  pendingLength -= 1;
144
202
  }
@@ -152,6 +210,19 @@ function stripStreamingString({
152
210
  return { output, pending: '' };
153
211
  }
154
212
 
213
+ function isPathBoundary(
214
+ value: string,
215
+ index: number,
216
+ precedingCharacter?: string,
217
+ ): boolean {
218
+ const character = index === 0 ? precedingCharacter : value[index - 1]!;
219
+ return character === undefined || /[\s"'`=]/.test(character);
220
+ }
221
+
222
+ function isPathTerminator(character: string | undefined): boolean {
223
+ return character === undefined || /[\s"'`;|&<>()]/.test(character);
224
+ }
225
+
155
226
  /**
156
227
  * Recursively strip the working directory from every string nested in an
157
228
  * arbitrary JSON-like value. Non-string leaves are returned unchanged.
@@ -1,5 +1,6 @@
1
1
  export {
2
2
  SandboxChannel,
3
+ type SandboxChannelConnectOptions,
3
4
  type SandboxChannelDebugEvent,
4
5
  type SandboxChannelOptions,
5
6
  type SandboxChannelReconnectOptions,
@@ -69,3 +70,4 @@ export {
69
70
  } from './bridge-diagnostics';
70
71
  export { resolveSandboxDefaultWorkingDirectory } from './resolve-sandbox-default-working-directory';
71
72
  export { getRestrictedSandboxSession } from './get-restricted-sandbox-session';
73
+ export { sleep } from './sleep';