@ai-sdk/harness-claude-code 1.0.0-canary.2 → 1.0.0-canary.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.
- package/CHANGELOG.md +22 -0
- package/dist/bridge/index.mjs +9 -0
- package/dist/bridge/index.mjs.map +1 -1
- package/dist/index.js +111 -78
- package/dist/index.js.map +1 -1
- package/package.json +4 -6
- package/src/bridge/claude-skills-option.ts +11 -0
- package/src/bridge/index.ts +3 -0
- package/src/claude-code-bridge-protocol.ts +1 -0
- package/src/claude-code-harness.ts +138 -88
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { randomBytes } from 'node:crypto';
|
|
2
2
|
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
3
4
|
import { fileURLToPath } from 'node:url';
|
|
4
5
|
import {
|
|
5
6
|
commonTool,
|
|
@@ -19,9 +20,13 @@ import {
|
|
|
19
20
|
type HarnessV1Skill,
|
|
20
21
|
type HarnessV1StreamPart,
|
|
21
22
|
} from '@ai-sdk/harness';
|
|
22
|
-
import { classifyDiskLog, SandboxChannel } from '@ai-sdk/harness/utils';
|
|
23
23
|
import {
|
|
24
|
-
|
|
24
|
+
classifyDiskLog,
|
|
25
|
+
markBridgeStarting,
|
|
26
|
+
SandboxChannel,
|
|
27
|
+
waitForBridgeReady,
|
|
28
|
+
} from '@ai-sdk/harness/utils';
|
|
29
|
+
import {
|
|
25
30
|
tool,
|
|
26
31
|
type Experimental_SandboxSession,
|
|
27
32
|
type Experimental_SandboxProcess,
|
|
@@ -33,7 +38,6 @@ import {
|
|
|
33
38
|
type ClaudeCodeAuthOptions,
|
|
34
39
|
} from './claude-code-auth';
|
|
35
40
|
import {
|
|
36
|
-
bridgeReadySchema,
|
|
37
41
|
outboundMessageSchema,
|
|
38
42
|
type InboundMessage,
|
|
39
43
|
type OutboundMessage,
|
|
@@ -506,6 +510,7 @@ export function createClaudeCode(
|
|
|
506
510
|
sandboxId,
|
|
507
511
|
debug: startOpts.observability?.debug,
|
|
508
512
|
permissionMode: startOpts.permissionMode,
|
|
513
|
+
skills: startOpts.skills ?? [],
|
|
509
514
|
});
|
|
510
515
|
} catch {
|
|
511
516
|
// Bridge no longer reachable — recover by respawning below.
|
|
@@ -536,12 +541,20 @@ export function createClaudeCode(
|
|
|
536
541
|
}
|
|
537
542
|
}
|
|
538
543
|
|
|
544
|
+
const sandboxHomeDir =
|
|
545
|
+
startOpts.skills && startOpts.skills.length > 0
|
|
546
|
+
? await resolveSandboxHomeDir({
|
|
547
|
+
sandbox: session,
|
|
548
|
+
abortSignal: startOpts.abortSignal,
|
|
549
|
+
})
|
|
550
|
+
: undefined;
|
|
539
551
|
const port = resolveBridgePort(sandboxSession, settings.port);
|
|
540
552
|
const token = randomBytes(32).toString('hex');
|
|
541
553
|
const env = {
|
|
542
554
|
...resolveClaudeCodeEnv(settings.auth),
|
|
543
555
|
BRIDGE_CHANNEL_TOKEN: token,
|
|
544
556
|
BRIDGE_WS_PORT: String(port),
|
|
557
|
+
...(sandboxHomeDir ? { HOME: sandboxHomeDir } : {}),
|
|
545
558
|
...(respawnStrategy === 'replay'
|
|
546
559
|
? { BRIDGE_REPLAY_FROM_DISK: '1' }
|
|
547
560
|
: {}),
|
|
@@ -560,15 +573,25 @@ export function createClaudeCode(
|
|
|
560
573
|
});
|
|
561
574
|
|
|
562
575
|
if (startOpts.skills && startOpts.skills.length > 0) {
|
|
576
|
+
if (!sandboxHomeDir) {
|
|
577
|
+
throw new Error('Unable to resolve sandbox HOME directory.');
|
|
578
|
+
}
|
|
563
579
|
await writeSkills({
|
|
564
580
|
sandbox: session,
|
|
565
|
-
|
|
581
|
+
homeDir: sandboxHomeDir,
|
|
566
582
|
skills: startOpts.skills,
|
|
567
583
|
abortSignal: startOpts.abortSignal,
|
|
568
584
|
});
|
|
569
585
|
}
|
|
570
586
|
}
|
|
571
587
|
|
|
588
|
+
await markBridgeStarting({
|
|
589
|
+
sandbox: session,
|
|
590
|
+
bridgeStateDir,
|
|
591
|
+
bridgeType: 'claude-code',
|
|
592
|
+
abortSignal: startOpts.abortSignal,
|
|
593
|
+
});
|
|
594
|
+
|
|
572
595
|
const proc = await session.spawn({
|
|
573
596
|
command: `node ${BOOTSTRAP_DIR}/bridge.mjs --workdir ${workDir} --bridge-state-dir ${bridgeStateDir}`,
|
|
574
597
|
env,
|
|
@@ -589,10 +612,27 @@ export function createClaudeCode(
|
|
|
589
612
|
void bridgeStartupStderrDone;
|
|
590
613
|
const { port: boundPort } = await waitForBridgeReady({
|
|
591
614
|
proc,
|
|
615
|
+
sandbox: session,
|
|
616
|
+
bridgeStateDir,
|
|
617
|
+
bridgeType: 'claude-code',
|
|
592
618
|
timeoutMs,
|
|
593
619
|
abortSignal: startOpts.abortSignal,
|
|
594
|
-
|
|
595
|
-
|
|
620
|
+
createTimeoutError: ({ proc, stdoutTail }) =>
|
|
621
|
+
createBridgeStartupError({
|
|
622
|
+
message: 'claude-code bridge did not become ready in time.',
|
|
623
|
+
proc,
|
|
624
|
+
stdoutTail,
|
|
625
|
+
stderrTail: bridgeStartupStderr,
|
|
626
|
+
stderrDone: bridgeStartupStderrDone,
|
|
627
|
+
}),
|
|
628
|
+
createExitError: ({ proc, stdoutTail }) =>
|
|
629
|
+
createBridgeStartupError({
|
|
630
|
+
message: 'claude-code bridge exited before becoming ready.',
|
|
631
|
+
proc,
|
|
632
|
+
stdoutTail,
|
|
633
|
+
stderrTail: bridgeStartupStderr,
|
|
634
|
+
stderrDone: bridgeStartupStderrDone,
|
|
635
|
+
}),
|
|
596
636
|
});
|
|
597
637
|
void drainRest(proc.stdout);
|
|
598
638
|
|
|
@@ -632,6 +672,7 @@ export function createClaudeCode(
|
|
|
632
672
|
sandboxId,
|
|
633
673
|
debug: startOpts.observability?.debug,
|
|
634
674
|
permissionMode: startOpts.permissionMode,
|
|
675
|
+
skills: startOpts.skills ?? [],
|
|
635
676
|
});
|
|
636
677
|
},
|
|
637
678
|
};
|
|
@@ -652,31 +693,106 @@ function resolveBridgePort(
|
|
|
652
693
|
}
|
|
653
694
|
|
|
654
695
|
/**
|
|
655
|
-
* Materialise skill files into
|
|
656
|
-
* `claude` CLI
|
|
657
|
-
*
|
|
658
|
-
* the
|
|
696
|
+
* Materialise skill files into
|
|
697
|
+
* `$HOME/.claude/skills/<name>/SKILL.md`. The `claude` CLI
|
|
698
|
+
* auto-discovers skills from that directory on startup, so the files have to
|
|
699
|
+
* be in place before the bridge is spawned without mutating the session
|
|
700
|
+
* workdir. Each file uses the YAML-frontmatter shape the CLI expects.
|
|
659
701
|
*/
|
|
660
702
|
async function writeSkills({
|
|
661
703
|
sandbox,
|
|
662
|
-
|
|
704
|
+
homeDir,
|
|
663
705
|
skills,
|
|
664
706
|
abortSignal,
|
|
665
707
|
}: {
|
|
666
708
|
sandbox: Experimental_SandboxSession;
|
|
667
|
-
|
|
709
|
+
homeDir: string;
|
|
668
710
|
skills: ReadonlyArray<HarnessV1Skill>;
|
|
669
711
|
abortSignal?: AbortSignal;
|
|
670
712
|
}): Promise<void> {
|
|
713
|
+
for (const skill of skills) {
|
|
714
|
+
safeClaudeSkillName(skill.name);
|
|
715
|
+
for (const file of skill.files ?? []) {
|
|
716
|
+
safeClaudeSkillFilePath({
|
|
717
|
+
skillName: skill.name,
|
|
718
|
+
filePath: file.path,
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
671
723
|
await sandbox.run({
|
|
672
|
-
command: `mkdir -p ${
|
|
724
|
+
command: `mkdir -p ${shellQuote(homeDir)}/.claude/skills`,
|
|
673
725
|
abortSignal,
|
|
674
726
|
});
|
|
675
727
|
for (const skill of skills) {
|
|
676
|
-
const
|
|
728
|
+
const name = safeClaudeSkillName(skill.name);
|
|
729
|
+
const skillDir = `${homeDir}/.claude/skills/${name}`;
|
|
730
|
+
const path = `${skillDir}/SKILL.md`;
|
|
677
731
|
const content = `---\nname: ${skill.name}\ndescription: ${skill.description}\n---\n\n${skill.content}\n`;
|
|
678
732
|
await sandbox.writeTextFile({ path, content, abortSignal });
|
|
733
|
+
for (const file of skill.files ?? []) {
|
|
734
|
+
const filePath = safeClaudeSkillFilePath({
|
|
735
|
+
skillName: skill.name,
|
|
736
|
+
filePath: file.path,
|
|
737
|
+
});
|
|
738
|
+
await sandbox.writeTextFile({
|
|
739
|
+
path: `${skillDir}/${filePath}`,
|
|
740
|
+
content: file.content,
|
|
741
|
+
abortSignal,
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
async function resolveSandboxHomeDir({
|
|
748
|
+
sandbox,
|
|
749
|
+
abortSignal,
|
|
750
|
+
}: {
|
|
751
|
+
sandbox: Experimental_SandboxSession;
|
|
752
|
+
abortSignal?: AbortSignal;
|
|
753
|
+
}): Promise<string> {
|
|
754
|
+
const result = await sandbox.run({
|
|
755
|
+
command: 'printf "%s" "$HOME"',
|
|
756
|
+
abortSignal,
|
|
757
|
+
});
|
|
758
|
+
const homeDir = result.stdout.trim();
|
|
759
|
+
if (result.exitCode !== 0 || !homeDir || !path.posix.isAbsolute(homeDir)) {
|
|
760
|
+
throw new Error(
|
|
761
|
+
`Unable to resolve sandbox HOME directory: ${result.stderr || result.stdout}`,
|
|
762
|
+
);
|
|
763
|
+
}
|
|
764
|
+
return homeDir;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
function safeClaudeSkillName(name: string): string {
|
|
768
|
+
if (!/^[A-Za-z0-9._-]+$/.test(name) || name === '.' || name === '..') {
|
|
769
|
+
throw new Error(`Invalid Claude Code skill name: ${name}`);
|
|
679
770
|
}
|
|
771
|
+
return name;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
function safeClaudeSkillFilePath({
|
|
775
|
+
skillName,
|
|
776
|
+
filePath,
|
|
777
|
+
}: {
|
|
778
|
+
skillName: string;
|
|
779
|
+
filePath: string;
|
|
780
|
+
}): string {
|
|
781
|
+
const normalized = path.posix.normalize(filePath);
|
|
782
|
+
if (
|
|
783
|
+
normalized === '.' ||
|
|
784
|
+
normalized.startsWith('../') ||
|
|
785
|
+
path.posix.isAbsolute(normalized)
|
|
786
|
+
) {
|
|
787
|
+
throw new Error(
|
|
788
|
+
`Invalid Claude Code skill file path for ${skillName}: ${filePath}`,
|
|
789
|
+
);
|
|
790
|
+
}
|
|
791
|
+
return normalized;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
function shellQuote(value: string): string {
|
|
795
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
680
796
|
}
|
|
681
797
|
|
|
682
798
|
async function readBridgeAsset(name: string): Promise<string> {
|
|
@@ -697,80 +813,6 @@ async function readBridgeAsset(name: string): Promise<string> {
|
|
|
697
813
|
throw lastErr ?? new Error(`bridge asset not found: ${name}`);
|
|
698
814
|
}
|
|
699
815
|
|
|
700
|
-
async function waitForBridgeReady({
|
|
701
|
-
proc,
|
|
702
|
-
timeoutMs,
|
|
703
|
-
abortSignal,
|
|
704
|
-
stderrTail,
|
|
705
|
-
stderrDone,
|
|
706
|
-
}: {
|
|
707
|
-
proc: Experimental_SandboxProcess;
|
|
708
|
-
timeoutMs: number;
|
|
709
|
-
abortSignal: AbortSignal | undefined;
|
|
710
|
-
stderrTail: string[];
|
|
711
|
-
stderrDone: Promise<void>;
|
|
712
|
-
}): Promise<{ port: number }> {
|
|
713
|
-
const reader = proc.stdout.pipeThrough(new TextDecoderStream()).getReader();
|
|
714
|
-
|
|
715
|
-
const decoder = lineDecoder();
|
|
716
|
-
const stdoutTail: string[] = [];
|
|
717
|
-
|
|
718
|
-
const deadline = Date.now() + timeoutMs;
|
|
719
|
-
try {
|
|
720
|
-
while (true) {
|
|
721
|
-
if (abortSignal?.aborted) {
|
|
722
|
-
await proc.kill();
|
|
723
|
-
throw abortSignal.reason ?? new DOMException('Aborted', 'AbortError');
|
|
724
|
-
}
|
|
725
|
-
const remaining = deadline - Date.now();
|
|
726
|
-
if (remaining <= 0) {
|
|
727
|
-
await proc.kill();
|
|
728
|
-
throw await createBridgeStartupError({
|
|
729
|
-
message: 'claude-code bridge did not become ready in time.',
|
|
730
|
-
proc,
|
|
731
|
-
stdoutTail,
|
|
732
|
-
stderrTail,
|
|
733
|
-
stderrDone,
|
|
734
|
-
});
|
|
735
|
-
}
|
|
736
|
-
const { value, done } = (await Promise.race([
|
|
737
|
-
reader.read(),
|
|
738
|
-
new Promise(resolve =>
|
|
739
|
-
setTimeout(
|
|
740
|
-
() => resolve({ value: undefined, done: false }),
|
|
741
|
-
remaining,
|
|
742
|
-
),
|
|
743
|
-
),
|
|
744
|
-
])) as ReadableStreamReadResult<string>;
|
|
745
|
-
if (done) {
|
|
746
|
-
for (const line of decoder.flush()) {
|
|
747
|
-
stdoutTail.push(line);
|
|
748
|
-
if (stdoutTail.length > 20) stdoutTail.shift();
|
|
749
|
-
}
|
|
750
|
-
throw await createBridgeStartupError({
|
|
751
|
-
message: 'claude-code bridge exited before becoming ready.',
|
|
752
|
-
proc,
|
|
753
|
-
stdoutTail,
|
|
754
|
-
stderrTail,
|
|
755
|
-
stderrDone,
|
|
756
|
-
});
|
|
757
|
-
}
|
|
758
|
-
if (value === undefined) continue;
|
|
759
|
-
for (const line of decoder.push(value)) {
|
|
760
|
-
stdoutTail.push(line);
|
|
761
|
-
if (stdoutTail.length > 20) stdoutTail.shift();
|
|
762
|
-
const parsed = await safeParseJSON({
|
|
763
|
-
text: line,
|
|
764
|
-
schema: bridgeReadySchema,
|
|
765
|
-
});
|
|
766
|
-
if (parsed.success) return { port: parsed.value.port };
|
|
767
|
-
}
|
|
768
|
-
}
|
|
769
|
-
} finally {
|
|
770
|
-
reader.releaseLock();
|
|
771
|
-
}
|
|
772
|
-
}
|
|
773
|
-
|
|
774
816
|
async function createBridgeStartupError({
|
|
775
817
|
message,
|
|
776
818
|
proc,
|
|
@@ -1063,6 +1105,7 @@ function createSession({
|
|
|
1063
1105
|
sandboxId,
|
|
1064
1106
|
debug,
|
|
1065
1107
|
permissionMode,
|
|
1108
|
+
skills,
|
|
1066
1109
|
}: {
|
|
1067
1110
|
sessionId: string;
|
|
1068
1111
|
channel: ClaudeCodeChannel;
|
|
@@ -1079,6 +1122,7 @@ function createSession({
|
|
|
1079
1122
|
sandboxId: string;
|
|
1080
1123
|
debug: HarnessV1DebugConfig | undefined;
|
|
1081
1124
|
permissionMode: HarnessV1PermissionMode | undefined;
|
|
1125
|
+
skills: ReadonlyArray<HarnessV1Skill>;
|
|
1082
1126
|
}): HarnessV1Session {
|
|
1083
1127
|
let stopped = false;
|
|
1084
1128
|
let stopPromise: Promise<void> | undefined;
|
|
@@ -1259,6 +1303,9 @@ function createSession({
|
|
|
1259
1303
|
model,
|
|
1260
1304
|
maxTurns,
|
|
1261
1305
|
thinking,
|
|
1306
|
+
...(skills.length > 0
|
|
1307
|
+
? { skills: skills.map(skill => skill.name) }
|
|
1308
|
+
: {}),
|
|
1262
1309
|
...(permissionMode ? { permissionMode } : {}),
|
|
1263
1310
|
...(debug ? { debug } : {}),
|
|
1264
1311
|
...(pendingResumeFlag ? { continue: true } : {}),
|
|
@@ -1307,6 +1354,9 @@ function createSession({
|
|
|
1307
1354
|
model,
|
|
1308
1355
|
maxTurns,
|
|
1309
1356
|
thinking,
|
|
1357
|
+
...(skills.length > 0
|
|
1358
|
+
? { skills: skills.map(skill => skill.name) }
|
|
1359
|
+
: {}),
|
|
1310
1360
|
...(permissionMode ? { permissionMode } : {}),
|
|
1311
1361
|
...(debug ? { debug } : {}),
|
|
1312
1362
|
continue: true,
|