@opengeni/runtime 0.13.12 → 0.14.0

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.
@@ -170,7 +170,7 @@ import {
170
170
  withToolspaceTokenClient,
171
171
  withToolspaceTokenEnvironment,
172
172
  withToolspaceTokenSession
173
- } from "../chunk-FLRCDJRI.js";
173
+ } from "../chunk-3NUSY22C.js";
174
174
  import "../chunk-4YJ5Z52Z.js";
175
175
  import "../chunk-K4V4QYCL.js";
176
176
  import "../chunk-PZ5AY32C.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opengeni/runtime",
3
- "version": "0.13.12",
3
+ "version": "0.14.0",
4
4
  "license": "Apache-2.0",
5
5
  "repository": {
6
6
  "type": "git",
@@ -50,8 +50,8 @@
50
50
  "@openai/agents-extensions": "0.13.3",
51
51
  "@opengeni/agent-proto": "^0.3.0",
52
52
  "@opengeni/codex": "^0.2.7",
53
- "@opengeni/config": "^0.7.8",
54
- "@opengeni/contracts": "^0.20.1",
53
+ "@opengeni/config": "^0.7.10",
54
+ "@opengeni/contracts": "^0.21.0",
55
55
  "@opengeni/network": "^0.1.1",
56
56
  "debug": "4.4.3",
57
57
  "modal": "0.7.6",
package/src/index.ts CHANGED
@@ -187,6 +187,7 @@ import {
187
187
  } from "./sandbox/turn-tool-cancellation";
188
188
  import { computerUse, type ComputerToolMode } from "./sandbox-computer";
189
189
  import type { RuntimeMetricsHooks } from "./metrics";
190
+ import { workspaceSkills, type WorkspaceSkillSearchPath } from "./workspace-skills";
190
191
 
191
192
  // The Agents SDK's debug namespaces can otherwise serialize complete model
192
193
  // inputs/outputs and tool arguments/results. These getters read process.env on
@@ -1260,7 +1261,7 @@ export type ToolspaceTokenWriterSession = SandboxSessionLike;
1260
1261
  export type EffectiveSkillSelection = Readonly<{
1261
1262
  id: string;
1262
1263
  name: string;
1263
- source: "bundled" | "library" | "pack";
1264
+ source: "bundled" | "library" | "pack" | "session";
1264
1265
  version: string | null;
1265
1266
  contentSha256: string | null;
1266
1267
  reason: string;
@@ -1430,6 +1431,9 @@ export type BuildAgentOptions = {
1430
1431
  // skills in the sandbox skill index (mounted under .agents/) so
1431
1432
  // skills/<name> references resolve like any other indexed skill.
1432
1433
  packSkills?: PackSkill[];
1434
+ // Inline skills fixed onto this session at creation. They use the SDK's lazy
1435
+ // skill source and are materialized only when the model loads one.
1436
+ sessionSkills?: PackSkill[];
1433
1437
  // Explicitly selected, immutable curated-library skill content. These are
1434
1438
  // separate from packSkills so repository-local/pack compatibility does not
1435
1439
  // turn a curated entry into a mutable override.
@@ -1460,7 +1464,7 @@ export type PackSkillFile = {
1460
1464
 
1461
1465
  export type PackSkill = {
1462
1466
  name: string;
1463
- description?: string | null;
1467
+ description?: string | null | undefined;
1464
1468
  files: PackSkillFile[];
1465
1469
  };
1466
1470
 
@@ -1903,6 +1907,8 @@ export function buildOpenGeniAgent(
1903
1907
  ...(options.skillLibrarySkills?.length
1904
1908
  ? { skillLibrarySkills: options.skillLibrarySkills }
1905
1909
  : {}),
1910
+ ...(options.sessionSkills?.length ? { sessionSkills: options.sessionSkills } : {}),
1911
+ ...repositoryWorkspaceSkillPathsOption(resources),
1906
1912
  ...(options.structuredToolTransport !== undefined
1907
1913
  ? { structuredToolTransport: options.structuredToolTransport }
1908
1914
  : {}),
@@ -1925,6 +1931,7 @@ export function buildOpenGeniAgent(
1925
1931
  options.skillLibrarySelections ?? [],
1926
1932
  options.skillLibrarySkills ?? [],
1927
1933
  options.packSkills ?? [],
1934
+ options.sessionSkills ?? [],
1928
1935
  ).map((selection) => Object.freeze(selection)),
1929
1936
  ),
1930
1937
  );
@@ -2240,6 +2247,8 @@ export function buildAgentCapabilities(
2240
2247
  packSkills: PackSkill[],
2241
2248
  options: {
2242
2249
  skillLibrarySkills?: PackSkill[];
2250
+ sessionSkills?: PackSkill[];
2251
+ workspaceSkillPaths?: readonly WorkspaceSkillSearchPath[];
2243
2252
  structuredToolTransport?: boolean;
2244
2253
  // EXPLICIT computer-use transport (see BuildAgentOptions.computerToolMode). When
2245
2254
  // present, computerUse() is handed the mode directly and its tools() obeys it
@@ -2276,11 +2285,29 @@ export function buildAgentCapabilities(
2276
2285
  filesystemCapability,
2277
2286
  shell({ ...(toolCancellation ? {} : { configureTools: withExecOpCorrelation }) }),
2278
2287
  ];
2288
+ const sessionSkills = sessionSkillsForMaterialization(
2289
+ packSkills,
2290
+ options.skillLibrarySkills ?? [],
2291
+ options.sessionSkills ?? [],
2292
+ );
2279
2293
  caps.push(
2280
2294
  skills({
2281
- lazyFrom: lazySkillSourceWithPackSkills(packSkills, options.skillLibrarySkills ?? []),
2295
+ lazyFrom: lazySkillSourceWithPackSkills(
2296
+ [...packSkills, ...sessionSkills],
2297
+ options.skillLibrarySkills ?? [],
2298
+ ),
2282
2299
  }),
2283
2300
  );
2301
+ if (options.workspaceSkillPaths?.length) {
2302
+ caps.push(
2303
+ workspaceSkills(options.workspaceSkillPaths, [
2304
+ ...bundledSkillDirNames(bundledSkillsDir()),
2305
+ ...(options.skillLibrarySkills ?? []).map((skill) => skill.name),
2306
+ ...packSkills.map((skill) => skill.name),
2307
+ ...sessionSkills.map((skill) => skill.name),
2308
+ ]),
2309
+ );
2310
+ }
2284
2311
  // P4.3 computer-use: the agent drives the SAME :0 humans watch (xdotool/XTEST +
2285
2312
  // scrot), but only when the desktop tier is ON, computer-use is enabled, and the
2286
2313
  // backend is one whose image carries the X stack (descriptorgate — honest about
@@ -2373,6 +2400,9 @@ export type PrepareToolsOptions = {
2373
2400
  executionGeneration?: number;
2374
2401
  subjectId?: string;
2375
2402
  subjectLabel?: string;
2403
+ // Immutable human authority used only for subject-owned connection lookup.
2404
+ // This is intentionally separate from the worker's first-party MCP identity.
2405
+ credentialSubjectId?: string;
2376
2406
  // Overrides the fixed first-party MCP permission set for this session's
2377
2407
  // delegated token (manager-style sessions). The caller is responsible for
2378
2408
  // having validated the set against the session creator's grant.
@@ -2465,6 +2495,14 @@ export async function prepareAgentTools(
2465
2495
  return { mcpServers: [], close: async () => {}, codexConnectorNamespaces };
2466
2496
  }
2467
2497
  const registry = new Map(settings.mcpServers.map((server) => [server.id, server]));
2498
+ const subjectOwnedTool = tools.find(
2499
+ (tool) => registry.get(tool.id)?.connectionRef?.subjectScope === "subject",
2500
+ );
2501
+ if (subjectOwnedTool && !options.credentialSubjectId) {
2502
+ throw new Error(
2503
+ `subject-owned connection for MCP server ${subjectOwnedTool.id} requires a human turn initiator`,
2504
+ );
2505
+ }
2468
2506
  const aggregateToolBudget = new McpAggregateToolListBudget();
2469
2507
  // npm Undici's dispatcher transport can hang indefinitely under Bun even
2470
2508
  // after an AbortSignal fires. Bun's native fetch is the supported runtime
@@ -2738,7 +2776,7 @@ async function resolveConnectionForRequest(
2738
2776
  destinationUrl,
2739
2777
  forceRefresh,
2740
2778
  ...(toolName ? { toolName } : {}),
2741
- ...(options.subjectId ? { subjectId: options.subjectId } : {}),
2779
+ ...(options.credentialSubjectId ? { subjectId: options.credentialSubjectId } : {}),
2742
2780
  };
2743
2781
  try {
2744
2782
  return await options.resolveCredential(request);
@@ -5278,6 +5316,32 @@ export function buildManifest(
5278
5316
  });
5279
5317
  }
5280
5318
 
5319
+ export function repositoryWorkspaceSkillPathsOption(resources: readonly ResourceRef[]): {
5320
+ workspaceSkillPaths?: readonly WorkspaceSkillSearchPath[];
5321
+ } {
5322
+ const repositories = resources.filter(
5323
+ (resource): resource is Extract<ResourceRef, { kind: "repository" }> =>
5324
+ resource.kind === "repository",
5325
+ );
5326
+ if (repositories.length === 0) return {};
5327
+
5328
+ const paths = new Map<string, WorkspaceSkillSearchPath>();
5329
+ const add = (path: string, source: string): void => {
5330
+ if (!paths.has(path)) paths.set(path, { path, source });
5331
+ };
5332
+ // A Connected Machine maps /workspace to its configured working directory,
5333
+ // which may itself be the selected repository. Managed sandboxes normally use
5334
+ // the repository mount paths below. Checking both keeps the rule portable.
5335
+ add(".agents/skills", "workspace .agents/skills");
5336
+ add(".claude/skills", "workspace .claude/skills");
5337
+ for (const repository of repositories) {
5338
+ const mountPath = resourceMountPath(repository);
5339
+ add(`${mountPath}/.agents/skills`, `${mountPath}/.agents/skills`);
5340
+ add(`${mountPath}/.claude/skills`, `${mountPath}/.claude/skills`);
5341
+ }
5342
+ return { workspaceSkillPaths: [...paths.values()] };
5343
+ }
5344
+
5281
5345
  function sandboxDownloadDirectory(download: SandboxFileDownload, mountPath: string): any {
5282
5346
  if (download.mountPath !== mountPath) {
5283
5347
  throw new Error(
@@ -7331,6 +7395,7 @@ function effectiveSkillSelections(
7331
7395
  librarySelections: readonly EffectiveSkillSelection[],
7332
7396
  librarySkills: readonly PackSkill[],
7333
7397
  packSkills: readonly PackSkill[],
7398
+ sessionSkills: readonly PackSkill[],
7334
7399
  ): readonly EffectiveSkillSelection[] {
7335
7400
  const defaultSelections = bundledSkillDirNames(bundledSkillsDir()).map((name) => ({
7336
7401
  id: `bundled:${name}`,
@@ -7342,6 +7407,12 @@ function effectiveSkillSelections(
7342
7407
  }));
7343
7408
  const libraryNameKeys = new Set(librarySkills.map((skill) => skill.name.toLowerCase()));
7344
7409
  const packNameKeys = new Set(packSkills.map((skill) => skill.name.toLowerCase()));
7410
+ const effectiveSessionSkills = sessionSkillsForMaterialization(
7411
+ packSkills,
7412
+ librarySkills,
7413
+ sessionSkills,
7414
+ );
7415
+ const sessionNameKeys = new Set(effectiveSessionSkills.map((skill) => skill.name.toLowerCase()));
7345
7416
  const selected = librarySelections.filter((selection) =>
7346
7417
  libraryNameKeys.has(selection.name.toLowerCase()),
7347
7418
  );
@@ -7349,9 +7420,14 @@ function effectiveSkillSelections(
7349
7420
  ...defaultSelections.filter(
7350
7421
  (selection) =>
7351
7422
  !libraryNameKeys.has(selection.name.toLowerCase()) &&
7352
- !packNameKeys.has(selection.name.toLowerCase()),
7423
+ !packNameKeys.has(selection.name.toLowerCase()) &&
7424
+ !sessionNameKeys.has(selection.name.toLowerCase()),
7425
+ ),
7426
+ ...selected.filter(
7427
+ (selection) =>
7428
+ !packNameKeys.has(selection.name.toLowerCase()) &&
7429
+ !sessionNameKeys.has(selection.name.toLowerCase()),
7353
7430
  ),
7354
- ...selected.filter((selection) => !packNameKeys.has(selection.name.toLowerCase())),
7355
7431
  ...packSkills.map((skill) => ({
7356
7432
  id: `pack:${skill.name}`,
7357
7433
  name: skill.name,
@@ -7360,9 +7436,56 @@ function effectiveSkillSelections(
7360
7436
  contentSha256: null,
7361
7437
  reason: "enabled capability pack",
7362
7438
  })),
7439
+ ...effectiveSessionSkills.map((skill) => ({
7440
+ id: `session:${skill.name}`,
7441
+ name: skill.name,
7442
+ source: "session" as const,
7443
+ version: null,
7444
+ contentSha256: null,
7445
+ reason: "attached to session",
7446
+ })),
7363
7447
  ];
7364
7448
  }
7365
7449
 
7450
+ function sessionSkillsForMaterialization(
7451
+ packSkills: readonly PackSkill[],
7452
+ librarySkills: readonly PackSkill[],
7453
+ sessionSkills: readonly PackSkill[],
7454
+ ): PackSkill[] {
7455
+ const configured = new Map<string, PackSkill>();
7456
+ for (const skill of [...librarySkills, ...packSkills]) {
7457
+ configured.set(skill.name.toLowerCase(), skill);
7458
+ }
7459
+ const bundledNames = new Set(
7460
+ bundledSkillDirNames(bundledSkillsDir()).map((name) => name.toLowerCase()),
7461
+ );
7462
+ const selected = new Map<string, PackSkill>();
7463
+ for (const skill of sessionSkills) {
7464
+ assertSafePackSkillName(skill.name);
7465
+ const key = skill.name.toLowerCase();
7466
+ if (bundledNames.has(key)) {
7467
+ throw new Error(`Session skill "${skill.name}" conflicts with a bundled skill`);
7468
+ }
7469
+ const existing = selected.get(key) ?? configured.get(key);
7470
+ if (existing) {
7471
+ if (skillFingerprint(existing) !== skillFingerprint(skill)) {
7472
+ throw new Error(`Conflicting skill definitions for "${skill.name}"`);
7473
+ }
7474
+ continue;
7475
+ }
7476
+ selected.set(key, skill);
7477
+ }
7478
+ return [...selected.values()];
7479
+ }
7480
+
7481
+ function skillFingerprint(skill: PackSkill): string {
7482
+ return JSON.stringify(
7483
+ [...skill.files]
7484
+ .sort((left, right) => left.path.localeCompare(right.path))
7485
+ .map(({ path, content }) => ({ path, content })),
7486
+ );
7487
+ }
7488
+
7366
7489
  function removeSkillChildByNameKey(children: Record<string, Entry>, nameKey: string): void {
7367
7490
  for (const name of Object.keys(children)) {
7368
7491
  if (name.toLowerCase() === nameKey) {
@@ -536,14 +536,14 @@ function withDockerNetwork(client: SandboxClient, network: string | undefined):
536
536
  }
537
537
 
538
538
  async function connectDockerNetwork(network: string, containerId: string): Promise<void> {
539
- const result = Bun.spawnSync(["docker", "network", "connect", network, containerId], {
540
- stdout: "pipe",
541
- stderr: "pipe",
539
+ const { spawnSync } = await import("node:child_process");
540
+ const result = spawnSync("docker", ["network", "connect", network, containerId], {
541
+ encoding: "utf8",
542
542
  });
543
- if (result.exitCode === 0) {
543
+ if (result.status === 0) {
544
544
  return;
545
545
  }
546
- const stderr = new TextDecoder().decode(result.stderr);
546
+ const stderr = result.stderr ?? result.error?.message ?? "";
547
547
  if (stderr.includes("already exists")) {
548
548
  return;
549
549
  }
@@ -0,0 +1,235 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ import { Capability, type SandboxSessionLike } from "@openai/agents/sandbox";
4
+
5
+ const SKILL_FILE = "SKILL.md";
6
+ const MAX_DISCOVERED_SKILLS = 256;
7
+ const MAX_SKILL_ENTRIES = 1_024;
8
+ const MAX_SKILL_BYTES = 32 * 1024 * 1024;
9
+ const SAFE_SKILL_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
10
+
11
+ export type WorkspaceSkillSearchPath = Readonly<{
12
+ path: string;
13
+ source: string;
14
+ }>;
15
+
16
+ type WorkspaceSkill = Readonly<{
17
+ name: string;
18
+ description: string;
19
+ path: string;
20
+ fingerprint: string;
21
+ source: string;
22
+ }>;
23
+
24
+ /**
25
+ * Index skills that already exist in the live workspace.
26
+ *
27
+ * Unlike the SDK's `skills({ from })` and `skills({ lazyFrom })` sources, this
28
+ * capability deliberately contributes no manifest entries and materializes
29
+ * nothing. Repository resources and Connected Machines already own these
30
+ * files; the bound sandbox session is the one portable way to inspect them.
31
+ */
32
+ export class WorkspaceSkillsCapability extends Capability {
33
+ readonly type = "workspace-skills";
34
+ private discovery?: Promise<readonly WorkspaceSkill[]>;
35
+
36
+ constructor(
37
+ private readonly searchPaths: readonly WorkspaceSkillSearchPath[],
38
+ private readonly reservedNames: ReadonlySet<string> = new Set(),
39
+ ) {
40
+ super();
41
+ }
42
+
43
+ override async instructions(): Promise<string | null> {
44
+ const session = requireWorkspaceSkillSession(this._session);
45
+ this.discovery ??= discoverWorkspaceSkills(
46
+ session,
47
+ this.searchPaths,
48
+ this.reservedNames,
49
+ this._runAs,
50
+ );
51
+ const skills = await this.discovery;
52
+ if (skills.length === 0) return null;
53
+
54
+ const available = skills
55
+ .map((skill) => `- ${skill.name}: ${skill.description} (file: ${skill.path})`)
56
+ .join("\n");
57
+ return `## Repository skills
58
+
59
+ Repository skills are instructions already present in the live workspace. They do not need to be loaded or copied.
60
+
61
+ ### Available repository skills
62
+ ${available}
63
+
64
+ ### How to use repository skills
65
+ - If the user names a skill, or the task clearly matches a skill description, read its complete SKILL.md before acting.
66
+ - Resolve referenced scripts, references, assets, and templates relative to that skill directory.
67
+ - Use only the minimum relevant skills for the turn, and say briefly which ones you are using.
68
+ - Do not assume a skill remains applicable on later turns; evaluate the current request again.`;
69
+ }
70
+ }
71
+
72
+ export function workspaceSkills(
73
+ searchPaths: readonly WorkspaceSkillSearchPath[],
74
+ reservedNames: Iterable<string> = [],
75
+ ): WorkspaceSkillsCapability {
76
+ return new WorkspaceSkillsCapability(
77
+ searchPaths,
78
+ new Set([...reservedNames].map((name) => name.toLowerCase())),
79
+ );
80
+ }
81
+
82
+ export async function discoverWorkspaceSkills(
83
+ session: SandboxSessionLike,
84
+ searchPaths: readonly WorkspaceSkillSearchPath[],
85
+ reservedNames: ReadonlySet<string> = new Set(),
86
+ runAs?: string,
87
+ ): Promise<readonly WorkspaceSkill[]> {
88
+ if (!session.listDir || !session.readFile) {
89
+ throw new Error("Workspace skill discovery requires sandbox listDir() and readFile() support");
90
+ }
91
+ const discovered = new Map<string, WorkspaceSkill>();
92
+ let candidates = 0;
93
+ for (const searchPath of searchPaths) {
94
+ let entries;
95
+ try {
96
+ entries = await session.listDir({ path: searchPath.path, ...(runAs ? { runAs } : {}) });
97
+ } catch {
98
+ continue;
99
+ }
100
+ for (const entry of [...entries].sort((left, right) => left.name.localeCompare(right.name))) {
101
+ if (entry.type !== "dir") continue;
102
+ candidates += 1;
103
+ if (candidates > MAX_DISCOVERED_SKILLS) {
104
+ throw new Error(`Repository skill discovery exceeds ${MAX_DISCOVERED_SKILLS} directories`);
105
+ }
106
+ const skillMarkdownPath = joinWorkspacePath(entry.path, SKILL_FILE);
107
+ let markdown: string;
108
+ try {
109
+ const content = await session.readFile({
110
+ path: skillMarkdownPath,
111
+ ...(runAs ? { runAs } : {}),
112
+ });
113
+ markdown = typeof content === "string" ? content : new TextDecoder().decode(content);
114
+ } catch {
115
+ continue;
116
+ }
117
+ const frontmatter = parseSkillFrontmatter(markdown);
118
+ const name = frontmatter.name?.trim() || entry.name;
119
+ if (name.length > 64 || !SAFE_SKILL_NAME.test(name)) {
120
+ throw new Error(`Repository skill has an invalid name: ${name.slice(0, 64)}`);
121
+ }
122
+ const description = frontmatter.description?.trim() || "No description provided.";
123
+ if (description.length > 2_048 || /[\r\n]/.test(description)) {
124
+ throw new Error(`Repository skill "${name}" has an invalid description`);
125
+ }
126
+ const key = name.toLowerCase();
127
+ if (reservedNames.has(key)) {
128
+ throw new Error(`Workspace skill "${name}" conflicts with a configured OpenGeni skill`);
129
+ }
130
+ const candidate: WorkspaceSkill = {
131
+ name,
132
+ description,
133
+ path: skillMarkdownPath,
134
+ fingerprint: await fingerprintWorkspaceDirectory(session, entry.path, runAs),
135
+ source: searchPath.source,
136
+ };
137
+ const existing = discovered.get(key);
138
+ if (!existing) {
139
+ discovered.set(key, candidate);
140
+ continue;
141
+ }
142
+ if (existing.fingerprint !== candidate.fingerprint) {
143
+ throw new Error(
144
+ `Workspace skill "${name}" has conflicting definitions in ${existing.source} and ${candidate.source}`,
145
+ );
146
+ }
147
+ }
148
+ }
149
+ return [...discovered.values()].sort((left, right) => left.name.localeCompare(right.name));
150
+ }
151
+
152
+ async function fingerprintWorkspaceDirectory(
153
+ session: SandboxSessionLike,
154
+ root: string,
155
+ runAs?: string,
156
+ ): Promise<string> {
157
+ const hash = createHash("sha256");
158
+ let entryCount = 0;
159
+ let byteCount = 0;
160
+
161
+ async function visit(path: string, relativePath: string): Promise<void> {
162
+ const entries = await session.listDir!({ path, ...(runAs ? { runAs } : {}) });
163
+ for (const entry of [...entries].sort((left, right) => left.name.localeCompare(right.name))) {
164
+ entryCount += 1;
165
+ if (entryCount > MAX_SKILL_ENTRIES) {
166
+ throw new Error(`Workspace skill exceeds ${MAX_SKILL_ENTRIES} filesystem entries`);
167
+ }
168
+ const childRelativePath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
169
+ hash.update(entry.type);
170
+ hash.update("\0");
171
+ hash.update(childRelativePath);
172
+ hash.update("\0");
173
+ if (entry.type === "dir") {
174
+ await visit(entry.path, childRelativePath);
175
+ continue;
176
+ }
177
+ if (entry.type !== "file") continue;
178
+ const content = await session.readFile!({
179
+ path: entry.path,
180
+ ...(runAs ? { runAs } : {}),
181
+ });
182
+ const bytes = typeof content === "string" ? new TextEncoder().encode(content) : content;
183
+ byteCount += bytes.byteLength;
184
+ if (byteCount > MAX_SKILL_BYTES) {
185
+ throw new Error(`Workspace skill exceeds ${MAX_SKILL_BYTES} bytes`);
186
+ }
187
+ hash.update(bytes);
188
+ hash.update("\0");
189
+ }
190
+ }
191
+
192
+ await visit(root, "");
193
+ return hash.digest("hex");
194
+ }
195
+
196
+ function parseSkillFrontmatter(markdown: string): {
197
+ name?: string;
198
+ description?: string;
199
+ } {
200
+ const lines = markdown.split(/\r?\n/);
201
+ if (lines[0]?.trim() !== "---") return {};
202
+ const end = lines.findIndex((line, index) => index > 0 && line.trim() === "---");
203
+ if (end === -1) return {};
204
+ const values: Record<string, string> = {};
205
+ for (const line of lines.slice(1, end)) {
206
+ const separator = line.indexOf(":");
207
+ if (separator === -1) continue;
208
+ const key = line.slice(0, separator).trim();
209
+ if (key !== "name" && key !== "description") continue;
210
+ values[key] = unquote(line.slice(separator + 1).trim());
211
+ }
212
+ return values;
213
+ }
214
+
215
+ function unquote(value: string): string {
216
+ if (
217
+ value.length >= 2 &&
218
+ value[0] === value[value.length - 1] &&
219
+ (value[0] === '"' || value[0] === "'")
220
+ ) {
221
+ return value.slice(1, -1);
222
+ }
223
+ return value;
224
+ }
225
+
226
+ function joinWorkspacePath(parent: string, child: string): string {
227
+ return `${parent.replace(/\/+$/, "")}/${child}`;
228
+ }
229
+
230
+ function requireWorkspaceSkillSession(session: SandboxSessionLike | undefined): SandboxSessionLike {
231
+ if (!session) {
232
+ throw new Error('capability "workspace-skills" used before bind(session)');
233
+ }
234
+ return session;
235
+ }