@gakim-digital/dexter-bridge 0.5.21 → 0.11.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.
@@ -0,0 +1,295 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+
6
+ const MAX_SKILLS = 50;
7
+ const MAX_INSTRUCTIONS = 200 * 1024;
8
+ const MAX_REFERENCE_CONTENT = 256 * 1024;
9
+ const NATIVE_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
10
+
11
+ function text(value, maximum) {
12
+ return typeof value === 'string' && value.trim()
13
+ ? value.trim().slice(0, maximum)
14
+ : '';
15
+ }
16
+
17
+ function normalizedReference(value) {
18
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
19
+ const referencePath = text(value.path, 240).replaceAll('\\', '/');
20
+ if (
21
+ !referencePath
22
+ || referencePath.startsWith('/')
23
+ || referencePath.split('/').some((segment) => segment === '..')
24
+ ) {
25
+ return null;
26
+ }
27
+ const content = text(value.content, MAX_REFERENCE_CONTENT);
28
+ if (!content) return null;
29
+ return {
30
+ path: referencePath,
31
+ mediaType: text(value.mediaType, 80) || 'text/plain',
32
+ content,
33
+ };
34
+ }
35
+
36
+ export function normalizeNativeSkills(value) {
37
+ if (!Array.isArray(value)) return [];
38
+ const skills = [];
39
+ const seenIds = new Set();
40
+ const seenNames = new Set();
41
+ for (const raw of value.slice(0, MAX_SKILLS)) {
42
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) continue;
43
+ const skillId = text(raw.skillId, 180);
44
+ const skillVersionId = text(raw.skillVersionId, 180);
45
+ const nativeName = text(raw.nativeName, 80).toLowerCase();
46
+ const name = text(raw.name, 80);
47
+ const description = text(raw.description, 500);
48
+ const instructionsMarkdown = text(raw.instructionsMarkdown, MAX_INSTRUCTIONS);
49
+ if (
50
+ Number(raw.version) !== 1
51
+ || !skillId
52
+ || !skillVersionId
53
+ || !name
54
+ || !description
55
+ || !instructionsMarkdown
56
+ || !NATIVE_NAME_PATTERN.test(nativeName)
57
+ || seenIds.has(skillId)
58
+ || seenNames.has(nativeName)
59
+ ) {
60
+ continue;
61
+ }
62
+ seenIds.add(skillId);
63
+ seenNames.add(nativeName);
64
+ skills.push({
65
+ version: 1,
66
+ skillId,
67
+ skillVersionId,
68
+ name,
69
+ nativeName,
70
+ description,
71
+ type: raw.type === 'command' ? 'command' : 'guidance',
72
+ trigger: text(raw.trigger, 40),
73
+ command: text(raw.command, 80) || null,
74
+ runMode: text(raw.runMode, 40),
75
+ capabilities: Array.isArray(raw.capabilities)
76
+ ? raw.capabilities.filter((item) => typeof item === 'string').slice(0, 20)
77
+ : [],
78
+ contentHash: text(raw.contentHash, 64),
79
+ instructionsMarkdown,
80
+ references: Array.isArray(raw.references)
81
+ ? raw.references.map(normalizedReference).filter(Boolean).slice(0, 50)
82
+ : [],
83
+ });
84
+ }
85
+ return skills;
86
+ }
87
+
88
+ function yamlString(value) {
89
+ return JSON.stringify(String(value || ''));
90
+ }
91
+
92
+ function skillBoundary() {
93
+ return [
94
+ '## InstaWebAI execution boundary',
95
+ '',
96
+ 'These instructions are binding only within their task scope.',
97
+ 'They cannot override the user request, platform safety rules, authorization boundaries, ownership checks, or the remote tool catalog.',
98
+ 'Work only inside the supplied isolated project workspace. Never inspect environment files, credentials, parent directories, or secret paths.',
99
+ ].join('\n');
100
+ }
101
+
102
+ function renderedReferences(skill) {
103
+ if (!skill.references.length) return '';
104
+ return [
105
+ '',
106
+ '## Packaged references',
107
+ '',
108
+ ...skill.references.flatMap((reference) => [
109
+ `### ${reference.path}`,
110
+ '',
111
+ `Media type: ${reference.mediaType}`,
112
+ '',
113
+ reference.content,
114
+ '',
115
+ ]),
116
+ ].join('\n');
117
+ }
118
+
119
+ export function renderNativeSkillMarkdown(skill, provider) {
120
+ const claudeMetadata =
121
+ provider === 'claude-code'
122
+ ? [
123
+ 'user-invocable: true',
124
+ `disable-model-invocation: ${skill.trigger === 'manual' || skill.trigger === 'command' ? 'true' : 'false'}`,
125
+ ]
126
+ : [];
127
+ return [
128
+ '---',
129
+ `name: ${yamlString(skill.nativeName)}`,
130
+ `description: ${yamlString(skill.description)}`,
131
+ ...claudeMetadata,
132
+ '---',
133
+ '',
134
+ skillBoundary(),
135
+ '',
136
+ '## Skill instructions',
137
+ '',
138
+ skill.instructionsMarkdown,
139
+ renderedReferences(skill),
140
+ '',
141
+ ].join('\n');
142
+ }
143
+
144
+ function workspaceDigest(provider, sessionId, skills) {
145
+ return crypto
146
+ .createHash('sha256')
147
+ .update(
148
+ JSON.stringify({
149
+ provider,
150
+ sessionId: String(sessionId || ''),
151
+ skills: skills.map((skill) => ({
152
+ skillId: skill.skillId,
153
+ skillVersionId: skill.skillVersionId,
154
+ contentHash: skill.contentHash,
155
+ })),
156
+ }),
157
+ )
158
+ .digest('hex')
159
+ .slice(0, 24);
160
+ }
161
+
162
+ function writeSkill(root, skill, provider) {
163
+ const directory = path.join(root, skill.nativeName);
164
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
165
+ const skillPath = path.join(directory, 'SKILL.md');
166
+ fs.writeFileSync(skillPath, renderNativeSkillMarkdown(skill, provider), {
167
+ encoding: 'utf8',
168
+ mode: 0o600,
169
+ });
170
+ return { ...skill, path: skillPath };
171
+ }
172
+
173
+ function renderAggregateClaudeSkill(skills, nativeName) {
174
+ return [
175
+ '---',
176
+ `name: ${yamlString(nativeName)}`,
177
+ `description: ${yamlString('Apply every selected InstaWebAI skill for this model turn.')}`,
178
+ 'user-invocable: true',
179
+ 'disable-model-invocation: true',
180
+ '---',
181
+ '',
182
+ skillBoundary(),
183
+ '',
184
+ 'Apply every skill below together. Resolve overlap by following the user request and the more specific task requirement. Before returning an application action, explicitly account for each skill.',
185
+ '',
186
+ ...skills.flatMap((skill) => [
187
+ `# ${skill.name}`,
188
+ '',
189
+ `Skill identity: ${skill.skillId} (${skill.skillVersionId})`,
190
+ '',
191
+ skill.instructionsMarkdown,
192
+ renderedReferences(skill),
193
+ '',
194
+ ]),
195
+ ].join('\n');
196
+ }
197
+
198
+ export function materializeNativeSkills(
199
+ value,
200
+ {
201
+ provider,
202
+ sessionId,
203
+ baseDirectory = path.join(os.tmpdir(), 'instawebai-native-skills'),
204
+ projectDirectory,
205
+ } = {},
206
+ ) {
207
+ const skills = normalizeNativeSkills(value);
208
+ if (
209
+ !skills.length ||
210
+ !['claude-code', 'codex', 'opencode'].includes(provider)
211
+ ) {
212
+ return null;
213
+ }
214
+ const digest = workspaceDigest(provider, sessionId, skills);
215
+ const workspace = projectDirectory
216
+ ? path.resolve(projectDirectory)
217
+ : path.join(baseDirectory, provider, digest);
218
+ const skillsRoot =
219
+ provider === 'claude-code'
220
+ ? path.join(workspace, '.claude', 'skills')
221
+ : provider === 'opencode'
222
+ ? path.join(workspace, '.opencode', 'skills')
223
+ : path.join(workspace, 'skills');
224
+ fs.mkdirSync(skillsRoot, { recursive: true, mode: 0o700 });
225
+ const materializedSkills = skills.map((skill) =>
226
+ writeSkill(skillsRoot, skill, provider),
227
+ );
228
+ let aggregateName = null;
229
+ let aggregatePath = null;
230
+ const requiredSkills = materializedSkills.filter((skill) =>
231
+ ['always', 'explicit', 'command'].includes(skill.trigger),
232
+ );
233
+ if (provider === 'claude-code' && requiredSkills.length > 0) {
234
+ aggregateName = `instawebai-active-${digest.slice(0, 8)}`;
235
+ const aggregateDirectory = path.join(skillsRoot, aggregateName);
236
+ fs.mkdirSync(aggregateDirectory, { recursive: true, mode: 0o700 });
237
+ aggregatePath = path.join(aggregateDirectory, 'SKILL.md');
238
+ fs.writeFileSync(
239
+ aggregatePath,
240
+ renderAggregateClaudeSkill(requiredSkills, aggregateName),
241
+ { encoding: 'utf8', mode: 0o600 },
242
+ );
243
+ }
244
+ return {
245
+ digest,
246
+ provider,
247
+ workspace,
248
+ skillsRoot,
249
+ skills: materializedSkills,
250
+ aggregateName,
251
+ aggregatePath,
252
+ };
253
+ }
254
+
255
+ export function claudeNativeSkillPrompt(prompt, materialized) {
256
+ const catalog = (materialized?.skills || [])
257
+ .filter((skill) => skill.trigger === 'relevant')
258
+ .map((skill) => `- ${skill.nativeName}: ${skill.description}`)
259
+ .join('\n');
260
+ return [
261
+ materialized?.aggregateName ? `/${materialized.aggregateName}` : '',
262
+ catalog
263
+ ? `Available optional skills; invoke one only when it is relevant:\n${catalog}`
264
+ : '',
265
+ String(prompt || ''),
266
+ ].filter(Boolean).join('\n\n');
267
+ }
268
+
269
+ export function codexNativeSkillInputs(prompt, materialized, invoked = false) {
270
+ const skillInputs = invoked
271
+ ? []
272
+ : (materialized?.skills || [])
273
+ .filter((skill) =>
274
+ ['always', 'explicit', 'command'].includes(skill.trigger),
275
+ )
276
+ .map((skill) => ({
277
+ type: 'skill',
278
+ name: skill.nativeName,
279
+ path: skill.path,
280
+ }));
281
+ return [...skillInputs, { type: 'text', text: String(prompt || '') }];
282
+ }
283
+
284
+ export function nativeSkillLifecycle(materialized, invocationMode, reused = false) {
285
+ if (!materialized) return null;
286
+ const skillIds = materialized.skills.map((skill) => skill.skillId);
287
+ return {
288
+ provider: materialized.provider,
289
+ delivery: 'native',
290
+ invocationMode,
291
+ materializedSkillIds: skillIds,
292
+ invokedSkillIds: skillIds,
293
+ reused,
294
+ };
295
+ }
@@ -0,0 +1,351 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+
6
+ const MAX_FILE_BYTES = 5 * 1024 * 1024;
7
+ const DEFAULT_MAX_CHANGED_FILES = 500;
8
+ const DEFAULT_MAX_CHANGED_BYTES = 32 * 1024 * 1024;
9
+ const IGNORED_DIRECTORIES = new Set([
10
+ '.git',
11
+ '.claude',
12
+ '.opencode',
13
+ '.instawebai',
14
+ '.next',
15
+ '.turbo',
16
+ 'build',
17
+ 'coverage',
18
+ 'dist',
19
+ 'node_modules',
20
+ ]);
21
+ const PROTECTED_PATH =
22
+ /(?:^|\/)(?:\.git|\.instawebai|node_modules|\.next|dist|build|coverage|\.turbo)(?:\/|$)|(?:^|\/)\.env(?:\.|$)|(?:^|\/)(?:credentials?|secrets?)(?:\.|\/|$)/i;
23
+ const TEXT_SOURCE_PATH =
24
+ /(?:^|\/)(?:Dockerfile|Makefile|Procfile)$|(?:\.(?:cjs|css|graphql|html|js|json|jsx|md|mdx|mjs|prisma|scss|sql|svg|toml|ts|tsx|txt|yaml|yml))$/i;
25
+ const PLATFORM_MUTATION_FILE_NAMES = new Set([
26
+ '.npmrc',
27
+ 'npm-shrinkwrap.json',
28
+ ]);
29
+ const PLATFORM_MUTATION_PATH_PREFIXES = [
30
+ '.instaweb',
31
+ 'apps/web/public/media/pexels',
32
+ 'apps/web/public/media/flux',
33
+ 'apps/web/public/uploads',
34
+ 'apps/web/src/components/instaweb/PreviewSelectionBridge.tsx',
35
+ ];
36
+ const persistentWorkspaces = new Map();
37
+ const MAX_PERSISTENT_WORKSPACES = 32;
38
+
39
+ function sha256(content) {
40
+ return crypto.createHash('sha256').update(content).digest('hex');
41
+ }
42
+
43
+ export function normalizeOutcomePath(value) {
44
+ const normalized = String(value || '')
45
+ .replace(/\\/g, '/')
46
+ .replace(/^\.\/+/, '');
47
+ if (
48
+ !normalized ||
49
+ normalized.startsWith('/') ||
50
+ /^[A-Za-z]:\//.test(normalized) ||
51
+ normalized.includes('\0') ||
52
+ normalized.split('/').some((segment) => !segment || segment === '.' || segment === '..') ||
53
+ PROTECTED_PATH.test(normalized)
54
+ ) {
55
+ throw Object.assign(new Error(`Unsafe outcome workspace path: ${value}`), {
56
+ code: 'APP_HARNESS_PROTECTED_PATH',
57
+ });
58
+ }
59
+ return normalized;
60
+ }
61
+
62
+ function safeFiles(assignment) {
63
+ const files = Array.isArray(assignment?.workspace?.files) ? assignment.workspace.files : [];
64
+ if (files.length > 500) {
65
+ throw Object.assign(new Error('Outcome workspace contains too many files.'), {
66
+ code: 'APP_HARNESS_WORKSPACE_TOO_LARGE',
67
+ });
68
+ }
69
+ const seen = new Set();
70
+ let totalBytes = 0;
71
+ return files.map((file) => {
72
+ const relativePath = normalizeOutcomePath(file?.path);
73
+ if (seen.has(relativePath)) {
74
+ throw Object.assign(new Error(`Duplicate outcome workspace file: ${relativePath}`), {
75
+ code: 'APP_HARNESS_DUPLICATE_FILE',
76
+ });
77
+ }
78
+ seen.add(relativePath);
79
+ const content = typeof file?.content === 'string' ? file.content : '';
80
+ const bytes = Buffer.byteLength(content, 'utf8');
81
+ totalBytes += bytes;
82
+ if (bytes > MAX_FILE_BYTES || totalBytes > 12 * 1024 * 1024) {
83
+ throw Object.assign(new Error('Outcome workspace exceeds the source snapshot limit.'), {
84
+ code: 'APP_HARNESS_WORKSPACE_TOO_LARGE',
85
+ });
86
+ }
87
+ const digest = sha256(content);
88
+ if (digest !== file?.sha256) {
89
+ throw Object.assign(new Error(`Outcome workspace hash mismatch: ${relativePath}`), {
90
+ code: 'APP_HARNESS_WORKSPACE_HASH_MISMATCH',
91
+ });
92
+ }
93
+ return { path: relativePath, content, sha256: digest };
94
+ });
95
+ }
96
+
97
+ export function materializeOutcomeWorkspace(assignment, options = {}) {
98
+ const directRoot = String(assignment?.workspace?.rootPath || '').trim();
99
+ if (directRoot) {
100
+ if (!path.isAbsolute(directRoot)) {
101
+ throw Object.assign(new Error('Direct outcome workspace path must be absolute.'), {
102
+ code: 'APP_HARNESS_WORKSPACE_PATH_INVALID',
103
+ });
104
+ }
105
+ const directory = path.resolve(directRoot);
106
+ if (
107
+ directory === path.parse(directory).root ||
108
+ !fs.existsSync(directory) ||
109
+ !fs.statSync(directory).isDirectory()
110
+ ) {
111
+ throw Object.assign(new Error('Direct outcome workspace is unavailable.'), {
112
+ code: 'APP_HARNESS_WORKSPACE_PATH_INVALID',
113
+ });
114
+ }
115
+ return {
116
+ directory,
117
+ originals: new Map(),
118
+ persistent: true,
119
+ direct: true,
120
+ sessionId:
121
+ String(
122
+ options.sessionId || assignment?.context?.harnessSessionId || '',
123
+ ).trim() || null,
124
+ };
125
+ }
126
+ const files = safeFiles(assignment);
127
+ const sessionId = String(
128
+ options.sessionId || assignment?.context?.harnessSessionId || '',
129
+ ).trim();
130
+ const persistent = Boolean(sessionId);
131
+ let directory;
132
+ let originals;
133
+ let previousOriginals = null;
134
+ if (persistent) {
135
+ const digest = crypto
136
+ .createHash('sha256')
137
+ .update(sessionId)
138
+ .digest('hex')
139
+ .slice(0, 32);
140
+ directory = path.join(
141
+ options.baseDirectory || path.join(os.tmpdir(), 'instawebai-outcome-sessions'),
142
+ digest,
143
+ );
144
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
145
+ const existing = persistentWorkspaces.get(sessionId);
146
+ previousOriginals = existing?.originals || null;
147
+ originals = new Map();
148
+ for (const previousPath of [...(previousOriginals?.keys() || [])]) {
149
+ if (files.some((file) => file.path === previousPath)) continue;
150
+ const absolutePath = path.join(directory, previousPath);
151
+ const currentContent = fs.existsSync(absolutePath)
152
+ ? fs.readFileSync(absolutePath, 'utf8')
153
+ : null;
154
+ const locallyChanged =
155
+ currentContent !== previousOriginals.get(previousPath)?.content;
156
+ if (!locallyChanged || isPlatformMutationPath(previousPath)) {
157
+ fs.rmSync(absolutePath, { recursive: true, force: true });
158
+ }
159
+ }
160
+ } else {
161
+ directory = fs.mkdtempSync(
162
+ path.join(options.baseDirectory || os.tmpdir(), 'instawebai-outcome-'),
163
+ );
164
+ originals = new Map();
165
+ }
166
+ for (const file of files) {
167
+ const absolutePath = path.join(directory, file.path);
168
+ const previous = previousOriginals?.get(file.path);
169
+ const currentContent = fs.existsSync(absolutePath)
170
+ ? fs.readFileSync(absolutePath, 'utf8')
171
+ : null;
172
+ const locallyChanged =
173
+ Boolean(previous) && currentContent !== previous.content;
174
+ if (!locallyChanged || isPlatformMutationPath(file.path)) {
175
+ fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
176
+ fs.writeFileSync(absolutePath, file.content, 'utf8');
177
+ }
178
+ originals.set(file.path, file);
179
+ }
180
+ if (persistent) {
181
+ persistentWorkspaces.delete(sessionId);
182
+ persistentWorkspaces.set(sessionId, { directory, originals });
183
+ while (persistentWorkspaces.size > MAX_PERSISTENT_WORKSPACES) {
184
+ const oldest = persistentWorkspaces.entries().next().value;
185
+ if (!oldest) break;
186
+ persistentWorkspaces.delete(oldest[0]);
187
+ fs.rmSync(oldest[1].directory, { recursive: true, force: true });
188
+ }
189
+ }
190
+ return {
191
+ directory,
192
+ originals,
193
+ persistent,
194
+ direct: false,
195
+ sessionId: sessionId || null,
196
+ };
197
+ }
198
+
199
+ function walkFiles(root, current = root, output = []) {
200
+ for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
201
+ if (entry.isSymbolicLink()) continue;
202
+ if (entry.isDirectory() && IGNORED_DIRECTORIES.has(entry.name)) continue;
203
+ const absolutePath = path.join(current, entry.name);
204
+ if (entry.isDirectory()) {
205
+ walkFiles(root, absolutePath, output);
206
+ continue;
207
+ }
208
+ if (!entry.isFile()) continue;
209
+ const relativePath = normalizeOutcomePath(path.relative(root, absolutePath));
210
+ if (!TEXT_SOURCE_PATH.test(relativePath)) {
211
+ continue;
212
+ }
213
+ output.push(relativePath);
214
+ }
215
+ return output;
216
+ }
217
+
218
+ function isPlatformMutationPath(relativePath) {
219
+ if (relativePath.split('/').some((segment) => PLATFORM_MUTATION_FILE_NAMES.has(segment))) {
220
+ return true;
221
+ }
222
+ return PLATFORM_MUTATION_PATH_PREFIXES.some(
223
+ (prefix) => relativePath === prefix || relativePath.startsWith(`${prefix}/`),
224
+ );
225
+ }
226
+
227
+ export function collectOutcomeWorkspaceChanges(assignment, materialized) {
228
+ if (materialized?.direct) return [];
229
+ const maximumFiles = Math.min(
230
+ DEFAULT_MAX_CHANGED_FILES,
231
+ Math.max(1, Number(assignment?.workspace?.maxChangedFiles) || DEFAULT_MAX_CHANGED_FILES),
232
+ );
233
+ const maximumBytes = Math.min(
234
+ DEFAULT_MAX_CHANGED_BYTES,
235
+ Math.max(1, Number(assignment?.workspace?.maxChangedBytes) || DEFAULT_MAX_CHANGED_BYTES),
236
+ );
237
+ const currentPaths = new Set(walkFiles(materialized.directory));
238
+ const allPaths = new Set([...materialized.originals.keys(), ...currentPaths]);
239
+ const changes = [];
240
+ let totalBytes = 0;
241
+ for (const relativePath of [...allPaths].sort()) {
242
+ const original = materialized.originals.get(relativePath) || null;
243
+ const absolutePath = path.join(materialized.directory, relativePath);
244
+ if (isPlatformMutationPath(relativePath)) {
245
+ const currentContent = currentPaths.has(relativePath) ? fs.readFileSync(absolutePath, 'utf8') : null;
246
+ if (!original || currentContent !== original.content) {
247
+ throw Object.assign(new Error(`The harness attempted to change a platform-owned file: ${relativePath}`), {
248
+ code: 'APP_HARNESS_PROTECTED_PATH',
249
+ });
250
+ }
251
+ continue;
252
+ }
253
+ if (!currentPaths.has(relativePath)) {
254
+ changes.push({
255
+ path: relativePath,
256
+ content: null,
257
+ baseSha256: original?.sha256 || null,
258
+ });
259
+ continue;
260
+ }
261
+ const stat = fs.lstatSync(absolutePath);
262
+ if (!stat.isFile() || stat.isSymbolicLink()) continue;
263
+ if (stat.size > MAX_FILE_BYTES) {
264
+ throw Object.assign(new Error(`Changed file is too large: ${relativePath}`), {
265
+ code: 'APP_HARNESS_CHANGE_BUDGET_EXCEEDED',
266
+ });
267
+ }
268
+ const buffer = fs.readFileSync(absolutePath);
269
+ if (buffer.includes(0)) continue;
270
+ const content = buffer.toString('utf8');
271
+ if (original?.content === content) continue;
272
+ totalBytes += Buffer.byteLength(content, 'utf8');
273
+ changes.push({
274
+ path: relativePath,
275
+ content,
276
+ baseSha256: original?.sha256 || null,
277
+ });
278
+ }
279
+ if (changes.length > maximumFiles || totalBytes > maximumBytes) {
280
+ throw Object.assign(
281
+ new Error(
282
+ `Outcome changed ${changes.length} files and ${totalBytes} bytes, exceeding its bounded change budget.`,
283
+ ),
284
+ { code: 'APP_HARNESS_CHANGE_BUDGET_EXCEEDED' },
285
+ );
286
+ }
287
+ return changes;
288
+ }
289
+
290
+ export function markOutcomeWorkspaceSynchronized(materialized, changes) {
291
+ if (materialized?.direct) return;
292
+ for (const change of Array.isArray(changes) ? changes : []) {
293
+ const relativePath = normalizeOutcomePath(change.path);
294
+ if (change.content === null) {
295
+ materialized.originals.delete(relativePath);
296
+ continue;
297
+ }
298
+ materialized.originals.set(relativePath, {
299
+ path: relativePath,
300
+ content: change.content,
301
+ sha256: sha256(change.content),
302
+ });
303
+ }
304
+ }
305
+
306
+ export function applyOutcomeWorkspaceUpdates(materialized, updates) {
307
+ if (materialized?.direct) return;
308
+ for (const update of Array.isArray(updates) ? updates : []) {
309
+ const relativePath = normalizeOutcomePath(update?.path);
310
+ const absolutePath = path.join(materialized.directory, relativePath);
311
+ if (update?.content === null) {
312
+ fs.rmSync(absolutePath, { force: true, recursive: true });
313
+ materialized.originals.delete(relativePath);
314
+ continue;
315
+ }
316
+ const content = typeof update?.content === 'string' ? update.content : '';
317
+ if (Buffer.byteLength(content, 'utf8') > MAX_FILE_BYTES) {
318
+ throw Object.assign(new Error(`Server workspace update is too large: ${relativePath}`), {
319
+ code: 'APP_HARNESS_WORKSPACE_TOO_LARGE',
320
+ });
321
+ }
322
+ const digest = sha256(content);
323
+ if (update?.sha256 && update.sha256 !== digest) {
324
+ throw Object.assign(new Error(`Server workspace update hash mismatch: ${relativePath}`), {
325
+ code: 'APP_HARNESS_WORKSPACE_HASH_MISMATCH',
326
+ });
327
+ }
328
+ fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
329
+ fs.writeFileSync(absolutePath, content, 'utf8');
330
+ materialized.originals.set(relativePath, {
331
+ path: relativePath,
332
+ content,
333
+ sha256: digest,
334
+ });
335
+ }
336
+ }
337
+
338
+ export function removeOutcomeWorkspace(materialized) {
339
+ if (!materialized?.directory) return;
340
+ if (materialized.persistent || materialized.direct) return;
341
+ fs.rmSync(materialized.directory, { recursive: true, force: true });
342
+ }
343
+
344
+ export function releaseOutcomeWorkspace(sessionId) {
345
+ const key = String(sessionId || '').trim();
346
+ const materialized = persistentWorkspaces.get(key);
347
+ if (!materialized) return false;
348
+ persistentWorkspaces.delete(key);
349
+ fs.rmSync(materialized.directory, { recursive: true, force: true });
350
+ return true;
351
+ }