@ai-sdk/harness 1.0.92 → 1.0.93

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.
@@ -293,8 +293,27 @@ export interface RunBridgeOptions<TStart extends { type: 'start' }> {
293
293
  bridgeType: string;
294
294
  /** Directory for `bridge-meta.json` / `start-config.json`. Created if absent. */
295
295
  bridgeStateDir: string;
296
- /** Drive one prompt turn. Rejections surface to the host as an `error` event. */
296
+ /**
297
+ * Drive one prompt turn. Rejections surface to the host as an `error`
298
+ * event.
299
+ *
300
+ * Contract: once `turn.abortSignal` fires, wind down promptly — turns are
301
+ * serialized, and a replacement `start` waits up to
302
+ * {@link turnTeardownGraceMs} for this promise to settle before it
303
+ * proceeds anyway.
304
+ */
297
305
  onStart(start: TStart, turn: BridgeTurn): Promise<void>;
306
+ /**
307
+ * How long a replacement `start` waits for the previous turn's teardown
308
+ * after aborting it, in milliseconds. Turns are serialized so an aborted
309
+ * turn cannot emit into its replacement's event log or overlap its runtime
310
+ * process — but only within this bound: an adapter that does not settle
311
+ * `onStart` after its abort signal fires forfeits the protection for that
312
+ * boundary, and the new turn proceeds anyway rather than blocking forever.
313
+ * The default of ten seconds exceeds the Claude bridge's five-second
314
+ * hard-abort fallback.
315
+ */
316
+ turnTeardownGraceMs?: number;
298
317
  /**
299
318
  * Produce the adapter-defined runtime resume data for `stop`. Defaults to
300
319
  * `{}`.
@@ -355,6 +374,7 @@ export async function runBridge<TStart extends { type: 'start' }>(
355
374
  options: RunBridgeOptions<TStart>,
356
375
  ): Promise<BridgeHandle> {
357
376
  const { bridgeType, bridgeStateDir, onStart, onStop, onDestroy } = options;
377
+ const teardownGraceMs = options.turnTeardownGraceMs ?? 10_000;
358
378
  const expectedToken = options.token ?? procEnv.BRIDGE_CHANNEL_TOKEN ?? '';
359
379
  const bridgeWsPort =
360
380
  options.port ?? parseInt(procEnv.BRIDGE_WS_PORT ?? '0', 10);
@@ -384,6 +404,12 @@ export async function runBridge<TStart extends { type: 'start' }>(
384
404
  let isFirstTurn = true;
385
405
  let turnAbort: AbortController | undefined;
386
406
  let currentUserMessages: InternalBridgeUserMessageQueue | undefined;
407
+ /**
408
+ * Settles when the in-flight turn has fully wound down — `onStart`
409
+ * returned or threw AND its completion state was recorded. `undefined`
410
+ * between turns. A new `start` fences on this so turns never overlap.
411
+ */
412
+ let activeTurn: Promise<void> | undefined;
387
413
 
388
414
  // Diagnostics. Resolved per turn from `start.debug` with a sandbox-side
389
415
  // env fallback; gates console capture + structured `debug-event`s.
@@ -658,10 +684,44 @@ export async function runBridge<TStart extends { type: 'start' }>(
658
684
  ): Promise<void> => {
659
685
  switch (msg.type) {
660
686
  case 'start': {
687
+ /*
688
+ * A new turn replaces the active one — but only after the active one
689
+ * has fully wound down. Inbound frames are dispatched concurrently,
690
+ * and the host settles a caller abort immediately, so a retry's
691
+ * `start` can arrive while the aborted turn is still tearing down
692
+ * (e.g. a graceful interrupt). Without this fence the old turn would
693
+ * keep emitting into the new turn's cleared event log, two runtime
694
+ * processes would run side by side, and the old turn's completion
695
+ * would mark the bridge `waiting` underneath the new turn. Abort the
696
+ * old turn to hasten its teardown; adapters are expected to bound
697
+ * that teardown themselves (e.g. a hard-abort fallback), but the
698
+ * runtime does not rely on it: the wait is capped by the teardown
699
+ * grace period, after which the new turn proceeds anyway — the
700
+ * pre-fence overlapping behavior — rather than hanging behind a
701
+ * teardown that never settles.
702
+ */
703
+ for (;;) {
704
+ const pendingTurn = activeTurn;
705
+ if (pendingTurn == null) break;
706
+ turnAbort?.abort();
707
+ currentUserMessages?.close(
708
+ new Error('A new bridge turn replaced the active turn.'),
709
+ );
710
+ let graceTimer: ReturnType<typeof setTimeout> | undefined;
711
+ const settled = await Promise.race([
712
+ pendingTurn.then(() => true as const),
713
+ new Promise<false>(resolve => {
714
+ graceTimer = setTimeout(() => resolve(false), teardownGraceMs);
715
+ graceTimer.unref?.();
716
+ }),
717
+ ]);
718
+ clearTimeout(graceTimer);
719
+ if (!settled) break;
720
+ }
721
+ let turnFinished!: () => void;
722
+ const thisTurn = new Promise<void>(resolve => (turnFinished = resolve));
723
+ activeTurn = thisTurn;
661
724
  activeSocket = ws; // asking for a turn claims the event stream
662
- currentUserMessages?.close(
663
- new Error('A new bridge turn replaced the active turn.'),
664
- );
665
725
  const firstTurn = isFirstTurn;
666
726
  isFirstTurn = false;
667
727
  eventLog = []; // clear previous turn; keep seqCounter monotonic
@@ -727,8 +787,15 @@ export async function runBridge<TStart extends { type: 'start' }>(
727
787
  if (currentUserMessages === userMessages) {
728
788
  currentUserMessages = undefined;
729
789
  }
730
- currentTurnState = 'waiting';
731
- void writeBridgeMeta('waiting');
790
+ // Only the still-active turn records completion: after a fence
791
+ // timeout a replacement turn is already running, and this stale
792
+ // completion must not mark the bridge waiting underneath it.
793
+ if (activeTurn === thisTurn) {
794
+ activeTurn = undefined;
795
+ currentTurnState = 'waiting';
796
+ void writeBridgeMeta('waiting');
797
+ }
798
+ turnFinished();
732
799
  }
733
800
  return;
734
801
  }
@@ -30,6 +30,7 @@ export {
30
30
  writeSkills,
31
31
  type SkillFilePathMode,
32
32
  type WriteSkillsOptions,
33
+ type WriteSkillsResult,
33
34
  } from './write-skills';
34
35
  export {
35
36
  markBridgeStarting,
@@ -1,8 +1,16 @@
1
+ import { createHash } from 'node:crypto';
1
2
  import path from 'node:path';
2
- import type { Experimental_SandboxSession } from '@ai-sdk/provider-utils';
3
+ import {
4
+ safeParseJSON,
5
+ type Experimental_SandboxSession,
6
+ } from '@ai-sdk/provider-utils';
3
7
  import type { HarnessV1Skill } from '../v1';
4
8
  import { shellQuote } from './shell-quote';
5
9
 
10
+ const SKILLS_MANIFEST_FILENAME = '.ai-sdk-harness-skills.json';
11
+ const SKILLS_MANIFEST_VERSION = 1;
12
+ const SAFE_MANIFEST_SKILL_NAME = /^[A-Za-z0-9._-]+$/;
13
+
6
14
  export type SkillFilePathMode = 'relative' | 'strip-leading-slashes';
7
15
 
8
16
  export type WriteSkillsOptions = {
@@ -20,6 +28,33 @@ export type WriteSkillsOptions = {
20
28
  trailingNewline?: boolean;
21
29
  };
22
30
 
31
+ export type WriteSkillsResult = {
32
+ changed: boolean;
33
+ written: string[];
34
+ removed: string[];
35
+ unchanged: string[];
36
+ };
37
+
38
+ type ProjectedSkill = {
39
+ readonly name: string;
40
+ readonly hash: string;
41
+ readonly files: ReadonlyArray<{
42
+ readonly path: string;
43
+ readonly content: string;
44
+ }>;
45
+ };
46
+
47
+ type SkillsManifestEntry = {
48
+ readonly name: string;
49
+ readonly hash: string;
50
+ };
51
+
52
+ type SkillsManifest = {
53
+ readonly version: typeof SKILLS_MANIFEST_VERSION;
54
+ readonly state: 'complete' | 'pending';
55
+ readonly skills: ReadonlyArray<SkillsManifestEntry>;
56
+ };
57
+
23
58
  export async function writeSkills({
24
59
  sandbox,
25
60
  rootDir,
@@ -31,50 +66,365 @@ export async function writeSkills({
31
66
  invalidSkillFilePathMessage = ({ skillName, filePath }) =>
32
67
  `Invalid skill file path for ${skillName}: ${filePath}`,
33
68
  trailingNewline = false,
34
- }: WriteSkillsOptions): Promise<void> {
35
- for (const skill of skills) {
36
- validateSkillName({
37
- name: skill.name,
38
- pattern: skillNamePattern,
39
- message: invalidSkillNameMessage,
69
+ }: WriteSkillsOptions): Promise<WriteSkillsResult> {
70
+ const projectedSkills = skills
71
+ .map(skill =>
72
+ projectSkill({
73
+ skill,
74
+ skillNamePattern,
75
+ invalidSkillNameMessage,
76
+ filePathMode,
77
+ invalidSkillFilePathMessage,
78
+ trailingNewline,
79
+ }),
80
+ )
81
+ .sort((a, b) => a.name.localeCompare(b.name));
82
+ assertUniqueSkillNames(projectedSkills);
83
+
84
+ const manifestPath = path.posix.join(rootDir, SKILLS_MANIFEST_FILENAME);
85
+ const existingManifest = await readSkillsManifest({
86
+ sandbox,
87
+ manifestPath,
88
+ abortSignal,
89
+ });
90
+ const nextEntries = projectedSkills.map(({ name, hash }) => ({ name, hash }));
91
+ const nextByName = new Map(nextEntries.map(entry => [entry.name, entry]));
92
+
93
+ /*
94
+ * Delete pending-manifest directories before rewriting the requested skills;
95
+ * any listed directory may contain only part of its intended files.
96
+ */
97
+ if (existingManifest?.state === 'pending') {
98
+ await ensureSkillsDirectory({ sandbox, rootDir, abortSignal });
99
+ const recoveryNames = existingManifest.skills.map(skill => skill.name);
100
+ await removeSkillDirectories({
101
+ sandbox,
102
+ rootDir,
103
+ skillNames: recoveryNames,
104
+ abortSignal,
105
+ });
106
+ const removed = recoveryNames
107
+ .filter(name => !nextByName.has(name))
108
+ .sort((a, b) => a.localeCompare(b));
109
+ await writeProjectedSkills({
110
+ sandbox,
111
+ rootDir,
112
+ skills: projectedSkills,
113
+ abortSignal,
114
+ });
115
+ await writeSkillsManifest({
116
+ sandbox,
117
+ manifestPath,
118
+ manifest: {
119
+ version: SKILLS_MANIFEST_VERSION,
120
+ state: 'complete',
121
+ skills: nextEntries,
122
+ },
123
+ abortSignal,
40
124
  });
41
- for (const file of skill.files ?? []) {
125
+ return {
126
+ changed: recoveryNames.length > 0 || projectedSkills.length > 0,
127
+ written: projectedSkills.map(skill => skill.name),
128
+ removed,
129
+ unchanged: [],
130
+ };
131
+ }
132
+
133
+ const previousEntries = existingManifest?.skills ?? [];
134
+ const previousByName = new Map(
135
+ previousEntries.map(entry => [entry.name, entry]),
136
+ );
137
+ const removed = previousEntries
138
+ .filter(entry => !nextByName.has(entry.name))
139
+ .map(entry => entry.name)
140
+ .sort((a, b) => a.localeCompare(b));
141
+ const written = projectedSkills
142
+ .filter(skill => previousByName.get(skill.name)?.hash !== skill.hash)
143
+ .map(skill => skill.name)
144
+ .sort((a, b) => a.localeCompare(b));
145
+ const unchanged = projectedSkills
146
+ .filter(skill => previousByName.get(skill.name)?.hash === skill.hash)
147
+ .map(skill => skill.name)
148
+ .sort((a, b) => a.localeCompare(b));
149
+ const changed = removed.length > 0 || written.length > 0;
150
+
151
+ if (!changed && existingManifest != null) {
152
+ return { changed: false, written, removed, unchanged };
153
+ }
154
+
155
+ const previouslyOwned = new Set(previousEntries.map(entry => entry.name));
156
+ for (const skillName of written) {
157
+ if (previouslyOwned.has(skillName)) continue;
158
+ await assertSkillDirectoryAvailable({
159
+ sandbox,
160
+ rootDir,
161
+ skillName,
162
+ abortSignal,
163
+ });
164
+ }
165
+
166
+ await ensureSkillsDirectory({ sandbox, rootDir, abortSignal });
167
+
168
+ const pendingNames = Array.from(
169
+ new Set([
170
+ ...previousEntries.map(entry => entry.name),
171
+ ...nextEntries.map(entry => entry.name),
172
+ ]),
173
+ ).sort((a, b) => a.localeCompare(b));
174
+ await writeSkillsManifest({
175
+ sandbox,
176
+ manifestPath,
177
+ manifest: {
178
+ version: SKILLS_MANIFEST_VERSION,
179
+ state: 'pending',
180
+ skills: pendingNames.map(name => ({
181
+ name,
182
+ hash: nextByName.get(name)?.hash ?? previousByName.get(name)!.hash,
183
+ })),
184
+ },
185
+ abortSignal,
186
+ });
187
+
188
+ await removeSkillDirectories({
189
+ sandbox,
190
+ rootDir,
191
+ skillNames: [
192
+ ...removed,
193
+ ...written.filter(name => previouslyOwned.has(name)),
194
+ ],
195
+ abortSignal,
196
+ });
197
+ const writtenSet = new Set(written);
198
+ await writeProjectedSkills({
199
+ sandbox,
200
+ rootDir,
201
+ skills: projectedSkills.filter(skill => writtenSet.has(skill.name)),
202
+ abortSignal,
203
+ });
204
+ await writeSkillsManifest({
205
+ sandbox,
206
+ manifestPath,
207
+ manifest: {
208
+ version: SKILLS_MANIFEST_VERSION,
209
+ state: 'complete',
210
+ skills: nextEntries,
211
+ },
212
+ abortSignal,
213
+ });
214
+
215
+ return { changed, written, removed, unchanged };
216
+ }
217
+
218
+ async function ensureSkillsDirectory({
219
+ sandbox,
220
+ rootDir,
221
+ abortSignal,
222
+ }: {
223
+ sandbox: Experimental_SandboxSession;
224
+ rootDir: string;
225
+ abortSignal?: AbortSignal;
226
+ }): Promise<void> {
227
+ await runSandboxCommand({
228
+ sandbox,
229
+ command: `mkdir -p ${shellQuote(rootDir)}`,
230
+ abortSignal,
231
+ errorMessage: `Failed to create skills directory: ${rootDir}`,
232
+ });
233
+ }
234
+
235
+ function projectSkill({
236
+ skill,
237
+ skillNamePattern,
238
+ invalidSkillNameMessage,
239
+ filePathMode,
240
+ invalidSkillFilePathMessage,
241
+ trailingNewline,
242
+ }: {
243
+ skill: HarnessV1Skill;
244
+ skillNamePattern: RegExp;
245
+ invalidSkillNameMessage: (input: { name: string }) => string;
246
+ filePathMode: SkillFilePathMode;
247
+ invalidSkillFilePathMessage: (input: {
248
+ skillName: string;
249
+ filePath: string;
250
+ }) => string;
251
+ trailingNewline: boolean;
252
+ }): ProjectedSkill {
253
+ const name = validateSkillName({
254
+ name: skill.name,
255
+ pattern: skillNamePattern,
256
+ message: invalidSkillNameMessage,
257
+ });
258
+ const files = new Map<string, string>();
259
+ files.set('SKILL.md', renderSkillFile({ skill, trailingNewline }));
260
+ for (const file of skill.files ?? []) {
261
+ files.set(
42
262
  normalizeSkillFilePath({
43
263
  skillName: skill.name,
44
264
  filePath: file.path,
45
265
  mode: filePathMode,
46
266
  message: invalidSkillFilePathMessage,
47
- });
267
+ }),
268
+ file.content,
269
+ );
270
+ }
271
+ const projectedFiles = Array.from(files, ([filePath, content]) => ({
272
+ path: filePath,
273
+ content,
274
+ })).sort((a, b) => a.path.localeCompare(b.path));
275
+ const hash = createHash('sha256');
276
+ for (const file of projectedFiles) {
277
+ hash.update(String(Buffer.byteLength(file.path)));
278
+ hash.update(':');
279
+ hash.update(file.path);
280
+ hash.update(String(Buffer.byteLength(file.content)));
281
+ hash.update(':');
282
+ hash.update(file.content);
283
+ }
284
+ return { name, hash: hash.digest('hex'), files: projectedFiles };
285
+ }
286
+
287
+ async function readSkillsManifest({
288
+ sandbox,
289
+ manifestPath,
290
+ abortSignal,
291
+ }: {
292
+ sandbox: Experimental_SandboxSession;
293
+ manifestPath: string;
294
+ abortSignal?: AbortSignal;
295
+ }): Promise<SkillsManifest | undefined> {
296
+ const content = await sandbox.readTextFile({
297
+ path: manifestPath,
298
+ abortSignal,
299
+ });
300
+ if (content == null) return undefined;
301
+ const parsed = await safeParseJSON({ text: content });
302
+ if (!parsed.success || !isSkillsManifest(parsed.value)) {
303
+ throw new Error(`Invalid AI SDK harness skills manifest: ${manifestPath}`);
304
+ }
305
+ return parsed.value;
306
+ }
307
+
308
+ function isSkillsManifest(value: unknown): value is SkillsManifest {
309
+ if (value == null || typeof value !== 'object' || Array.isArray(value)) {
310
+ return false;
311
+ }
312
+ const manifest = value as Record<string, unknown>;
313
+ if (
314
+ manifest.version !== SKILLS_MANIFEST_VERSION ||
315
+ (manifest.state !== 'complete' && manifest.state !== 'pending') ||
316
+ !Array.isArray(manifest.skills)
317
+ ) {
318
+ return false;
319
+ }
320
+ const names = new Set<string>();
321
+ for (const entry of manifest.skills) {
322
+ if (entry == null || typeof entry !== 'object' || Array.isArray(entry)) {
323
+ return false;
48
324
  }
325
+ const skill = entry as Record<string, unknown>;
326
+ if (
327
+ typeof skill.name !== 'string' ||
328
+ !isSafeManifestSkillName(skill.name) ||
329
+ typeof skill.hash !== 'string' ||
330
+ !/^[a-f0-9]{64}$/.test(skill.hash) ||
331
+ names.has(skill.name)
332
+ ) {
333
+ return false;
334
+ }
335
+ names.add(skill.name);
49
336
  }
337
+ return true;
338
+ }
50
339
 
51
- await sandbox.run({
52
- command: `mkdir -p ${shellQuote(rootDir)}`,
340
+ async function writeSkillsManifest({
341
+ sandbox,
342
+ manifestPath,
343
+ manifest,
344
+ abortSignal,
345
+ }: {
346
+ sandbox: Experimental_SandboxSession;
347
+ manifestPath: string;
348
+ manifest: SkillsManifest;
349
+ abortSignal?: AbortSignal;
350
+ }): Promise<void> {
351
+ const temporaryPath = `${manifestPath}.tmp`;
352
+ await sandbox.writeTextFile({
353
+ path: temporaryPath,
354
+ content: `${JSON.stringify(manifest, null, 2)}\n`,
355
+ abortSignal,
356
+ });
357
+ await runSandboxCommand({
358
+ sandbox,
359
+ command: `mv -f ${shellQuote(temporaryPath)} ${shellQuote(manifestPath)}`,
53
360
  abortSignal,
361
+ errorMessage: `Failed to update skills manifest: ${manifestPath}`,
54
362
  });
363
+ }
55
364
 
56
- for (const skill of skills) {
57
- const name = validateSkillName({
58
- name: skill.name,
59
- pattern: skillNamePattern,
60
- message: invalidSkillNameMessage,
61
- });
62
- const skillDir = path.posix.join(rootDir, name);
63
- await sandbox.writeTextFile({
64
- path: path.posix.join(skillDir, 'SKILL.md'),
65
- content: renderSkillFile({ skill, trailingNewline }),
66
- abortSignal,
67
- });
365
+ async function assertSkillDirectoryAvailable({
366
+ sandbox,
367
+ rootDir,
368
+ skillName,
369
+ abortSignal,
370
+ }: {
371
+ sandbox: Experimental_SandboxSession;
372
+ rootDir: string;
373
+ skillName: string;
374
+ abortSignal?: AbortSignal;
375
+ }): Promise<void> {
376
+ const skillDir = path.posix.join(rootDir, skillName);
377
+ const result = await sandbox.run({
378
+ command: `test ! -e ${shellQuote(skillDir)}`,
379
+ abortSignal,
380
+ });
381
+ if (result.exitCode !== 0) {
382
+ throw new Error(
383
+ `Cannot write harness skill '${skillName}': ${skillDir} already exists and is not owned by the AI SDK harness.`,
384
+ );
385
+ }
386
+ }
68
387
 
69
- for (const file of skill.files ?? []) {
70
- const filePath = normalizeSkillFilePath({
71
- skillName: skill.name,
72
- filePath: file.path,
73
- mode: filePathMode,
74
- message: invalidSkillFilePathMessage,
75
- });
388
+ async function removeSkillDirectories({
389
+ sandbox,
390
+ rootDir,
391
+ skillNames,
392
+ abortSignal,
393
+ }: {
394
+ sandbox: Experimental_SandboxSession;
395
+ rootDir: string;
396
+ skillNames: ReadonlyArray<string>;
397
+ abortSignal?: AbortSignal;
398
+ }): Promise<void> {
399
+ if (skillNames.length === 0) return;
400
+ const directories = Array.from(new Set(skillNames))
401
+ .sort((a, b) => a.localeCompare(b))
402
+ .map(name => shellQuote(path.posix.join(rootDir, name)))
403
+ .join(' ');
404
+ await runSandboxCommand({
405
+ sandbox,
406
+ command: `rm -rf -- ${directories}`,
407
+ abortSignal,
408
+ errorMessage: `Failed to replace harness skills in: ${rootDir}`,
409
+ });
410
+ }
411
+
412
+ async function writeProjectedSkills({
413
+ sandbox,
414
+ rootDir,
415
+ skills,
416
+ abortSignal,
417
+ }: {
418
+ sandbox: Experimental_SandboxSession;
419
+ rootDir: string;
420
+ skills: ReadonlyArray<ProjectedSkill>;
421
+ abortSignal?: AbortSignal;
422
+ }): Promise<void> {
423
+ for (const skill of skills) {
424
+ const skillDir = path.posix.join(rootDir, skill.name);
425
+ for (const file of skill.files) {
76
426
  await sandbox.writeTextFile({
77
- path: path.posix.join(skillDir, filePath),
427
+ path: path.posix.join(skillDir, file.path),
78
428
  content: file.content,
79
429
  abortSignal,
80
430
  });
@@ -82,6 +432,40 @@ export async function writeSkills({
82
432
  }
83
433
  }
84
434
 
435
+ async function runSandboxCommand({
436
+ sandbox,
437
+ command,
438
+ abortSignal,
439
+ errorMessage,
440
+ }: {
441
+ sandbox: Experimental_SandboxSession;
442
+ command: string;
443
+ abortSignal?: AbortSignal;
444
+ errorMessage: string;
445
+ }): Promise<void> {
446
+ const result = await sandbox.run({ command, abortSignal });
447
+ if (result.exitCode !== 0) {
448
+ throw new Error(
449
+ `${errorMessage} (exit ${result.exitCode})${result.stderr ? `: ${result.stderr}` : ''}`,
450
+ );
451
+ }
452
+ }
453
+
454
+ function assertUniqueSkillNames(skills: ReadonlyArray<ProjectedSkill>): void {
455
+ for (let index = 1; index < skills.length; index++) {
456
+ if (skills[index - 1]!.name === skills[index]!.name) {
457
+ throw new Error(`Duplicate skill name: ${skills[index]!.name}`);
458
+ }
459
+ }
460
+ }
461
+
462
+ function isSafeManifestSkillName(name: string): boolean {
463
+ SAFE_MANIFEST_SKILL_NAME.lastIndex = 0;
464
+ const matches = SAFE_MANIFEST_SKILL_NAME.test(name);
465
+ SAFE_MANIFEST_SKILL_NAME.lastIndex = 0;
466
+ return matches && name !== '.' && name !== '..' && !name.includes('/');
467
+ }
468
+
85
469
  function validateSkillName({
86
470
  name,
87
471
  pattern,
@@ -91,7 +475,10 @@ function validateSkillName({
91
475
  pattern: RegExp;
92
476
  message?: (input: { name: string }) => string;
93
477
  }): string {
94
- if (!pattern.test(name) || name === '.' || name === '..') {
478
+ pattern.lastIndex = 0;
479
+ const matches = pattern.test(name);
480
+ pattern.lastIndex = 0;
481
+ if (!matches || name === '.' || name === '..') {
95
482
  throw new Error(message?.({ name }) ?? `Invalid skill name: ${name}`);
96
483
  }
97
484
  return name;
@@ -1,4 +1,6 @@
1
1
  import type { JSONValue } from '@ai-sdk/provider';
2
+ import type { HarnessV1Skill } from './harness-v1-skill';
3
+ import type { HarnessV1ToolSpec } from './harness-v1-tool-spec';
2
4
 
3
5
  export type HarnessV1PendingToolApproval = {
4
6
  readonly approvalId: string;
@@ -16,6 +18,35 @@ export type HarnessV1PendingToolResult = {
16
18
  readonly input: string;
17
19
  };
18
20
 
21
+ /**
22
+ * Framework-owned settings captured when a turn begins. The same settings are
23
+ * passed to fresh and continued turns and persisted with unfinished-turn state
24
+ * so a resumed continuation cannot pick up configuration from a later turn.
25
+ */
26
+ export type HarnessV1TurnSettings = {
27
+ /**
28
+ * Skills made available to the underlying runtime for this turn. Adapters
29
+ * must replace skills from the preceding completed turn before starting a
30
+ * fresh turn. Rerun-based continuations use them to reconstruct the turn.
31
+ */
32
+ readonly skills: ReadonlyArray<HarnessV1Skill>;
33
+
34
+ /**
35
+ * Free-form instructions for this turn. Adapters should apply them through
36
+ * the runtime's native system or developer instruction mechanism when
37
+ * supported. Rerun-based continuations use them to reconstruct the turn.
38
+ */
39
+ readonly instructions?: string;
40
+
41
+ /**
42
+ * Host-defined tools made available to the underlying runtime for this turn.
43
+ * The harness emits `tool-call` events when the runtime calls one and waits
44
+ * for `submitToolResult`. Rerun-based continuations use them to reconstruct
45
+ * the turn.
46
+ */
47
+ readonly tools: ReadonlyArray<HarnessV1ToolSpec>;
48
+ };
49
+
19
50
  type HarnessV1LifecycleStateBase = {
20
51
  /**
21
52
  * Identifier of the harness that produced this state. Used by adapters to
@@ -70,6 +101,13 @@ export type HarnessV1ContinueTurnState = HarnessV1LifecycleStateBase & {
70
101
  * result before the underlying turn can continue.
71
102
  */
72
103
  readonly pendingToolResults?: readonly HarnessV1PendingToolResult[];
104
+
105
+ /**
106
+ * Framework-owned settings captured when the unfinished turn began. They
107
+ * are persisted outside adapter data so a resumed continuation cannot pick
108
+ * up settings prepared for a later turn.
109
+ */
110
+ readonly turnSettings?: HarnessV1TurnSettings;
73
111
  };
74
112
 
75
113
  export type HarnessV1LifecycleState =