@flowdular/sandbox 0.2.6 → 0.2.8

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.
@@ -257,6 +257,7 @@
257
257
  "chat.role": "Role",
258
258
  "chat.targetModule": "Target module",
259
259
  "chat.auto": "Auto",
260
+ "chat.settings": "Settings",
260
261
  "chat.autoHandoff": "Continue handoffs automatically",
261
262
  "chat.stop": "Stop",
262
263
  "chat.send": "Send",
@@ -480,8 +481,27 @@
480
481
  "models.save": "Save",
481
482
  "models.saved": "Model settings saved.",
482
483
  "models.error": "Could not save model settings.",
484
+ "chat.activity.updated": "Last update {seconds}s ago",
485
+ "chat.activity.quiet": "No update for {seconds}s. The agent may still be working.",
483
486
  "chat.activity.thinking": "Agent is thinking…",
484
487
  "chat.activity.responding": "Agent is preparing a response…",
485
488
  "chat.fresh": "Fresh agent context",
486
- "chat.freshHelp": "The next message starts a fresh context with the brief and recent messages. Draft files, specification and sandbox history are preserved."
489
+ "chat.freshHelp": "The next message starts a fresh context with the brief and recent messages. Draft files, specification and sandbox history are preserved.",
490
+ "chat.tool.name": "Tool",
491
+ "chat.tool.detail": "Target or command",
492
+ "chat.tool.result": "Completion detail",
493
+ "chat.tool.noDetail": "No detail provided",
494
+ "chat.tool.duration": "{seconds}s",
495
+ "chat.tool.action.read": "Read file",
496
+ "chat.tool.action.edit": "Edit file",
497
+ "chat.tool.action.write": "Write file",
498
+ "chat.tool.action.delete": "Delete file",
499
+ "chat.tool.action.list": "Find files",
500
+ "chat.tool.action.search": "Search",
501
+ "chat.tool.action.command": "Run command",
502
+ "chat.tool.action.tool": "Tool call",
503
+ "chat.tool.status.done": "Done",
504
+ "chat.tool.status.failed": "Failed",
505
+ "chat.tool.status.running": "Running",
506
+ "chat.tool.status.unknown": "No result"
487
507
  }
@@ -257,6 +257,7 @@
257
257
  "chat.role": "Rola",
258
258
  "chat.targetModule": "Moduł docelowy",
259
259
  "chat.auto": "Automatycznie",
260
+ "chat.settings": "Ustawienia",
260
261
  "chat.autoHandoff": "Automatycznie kontynuuj przekazania",
261
262
  "chat.stop": "Zatrzymaj",
262
263
  "chat.send": "Wyślij",
@@ -480,8 +481,27 @@
480
481
  "models.save": "Zapisz",
481
482
  "models.saved": "Zapisano ustawienia modeli.",
482
483
  "models.error": "Nie udało się zapisać ustawień modeli.",
484
+ "chat.activity.updated": "Ostatnia aktualizacja {seconds}s temu",
485
+ "chat.activity.quiet": "Brak aktualizacji od {seconds}s. Agent może nadal pracować.",
483
486
  "chat.activity.thinking": "Agent analizuje zadanie…",
484
487
  "chat.activity.responding": "Agent przygotowuje odpowiedź…",
485
488
  "chat.fresh": "Świeży kontekst agenta",
486
- "chat.freshHelp": "Następna wiadomość rozpocznie nowy kontekst z briefem i ostatnimi wiadomościami. Pliki, specyfikacja i historia sandboxa pozostaną zachowane."
489
+ "chat.freshHelp": "Następna wiadomość rozpocznie nowy kontekst z briefem i ostatnimi wiadomościami. Pliki, specyfikacja i historia sandboxa pozostaną zachowane.",
490
+ "chat.tool.name": "Narzędzie",
491
+ "chat.tool.detail": "Plik lub polecenie",
492
+ "chat.tool.result": "Szczegóły zakończenia",
493
+ "chat.tool.noDetail": "Brak szczegółów",
494
+ "chat.tool.duration": "{seconds}s",
495
+ "chat.tool.action.read": "Odczyt pliku",
496
+ "chat.tool.action.edit": "Edycja pliku",
497
+ "chat.tool.action.write": "Zapis pliku",
498
+ "chat.tool.action.delete": "Usunięcie pliku",
499
+ "chat.tool.action.list": "Wyszukiwanie plików",
500
+ "chat.tool.action.search": "Wyszukiwanie",
501
+ "chat.tool.action.command": "Polecenie",
502
+ "chat.tool.action.tool": "Narzędzie",
503
+ "chat.tool.status.done": "Gotowe",
504
+ "chat.tool.status.failed": "Błąd",
505
+ "chat.tool.status.running": "W toku",
506
+ "chat.tool.status.unknown": "Brak wyniku"
487
507
  }
@@ -0,0 +1,110 @@
1
+ import type { ChatEntry } from '../server/sessions.ts';
2
+
3
+ export interface TranscriptRow {
4
+ readonly key: number;
5
+ entry: ChatEntry;
6
+ started?: ChatEntry;
7
+ pending: boolean;
8
+ }
9
+
10
+ /* Pair calls by provider identity, including parallel calls to the same file.
11
+ Old transcripts without ids are paired only with one unambiguous pending call.
12
+ This is presentation only: persisted events and their full details stay intact. */
13
+ export function transcriptRows(entries: readonly ChatEntry[]): TranscriptRow[] {
14
+ const rows: TranscriptRow[] = [];
15
+ const calls = new Map<string, TranscriptRow>();
16
+ const pending = new Set<TranscriptRow>();
17
+ const closeTurn = () => {
18
+ for (const row of pending) row.pending = false;
19
+ pending.clear();
20
+ calls.clear();
21
+ };
22
+ for (const entry of entries) {
23
+ const event = entry.event;
24
+ if (
25
+ entry.kind === 'user' ||
26
+ entry.handoff ||
27
+ event?.type === 'turn.started' ||
28
+ event?.type === 'turn.completed'
29
+ )
30
+ closeTurn();
31
+ if (event?.type === 'reasoning' && !event.text.trim()) continue;
32
+ if (event?.type === 'tool.completed') {
33
+ let start = event.callId ? calls.get(event.callId) : undefined;
34
+ if (!event.callId && pending.size === 1) {
35
+ const candidate = pending.values().next().value!;
36
+ const original = candidate.entry.event;
37
+ if (
38
+ original?.type === 'tool.started' &&
39
+ !original.callId &&
40
+ (event.tool === original.tool || event.tool === 'tool') &&
41
+ (!event.detail || event.detail === original.detail)
42
+ )
43
+ start = candidate;
44
+ }
45
+ if (
46
+ start &&
47
+ start.entry.role === entry.role &&
48
+ start.entry.module === entry.module
49
+ ) {
50
+ start.started = start.entry;
51
+ start.entry = entry;
52
+ start.pending = false;
53
+ pending.delete(start);
54
+ if (event.callId) calls.delete(event.callId);
55
+ continue;
56
+ }
57
+ }
58
+ const row: TranscriptRow = {
59
+ key: entry.sequence,
60
+ entry,
61
+ pending: event?.type === 'tool.started',
62
+ };
63
+ rows.push(row);
64
+ if (event?.type === 'tool.started') {
65
+ pending.add(row);
66
+ if (event.callId) calls.set(event.callId, row);
67
+ }
68
+ }
69
+ return rows;
70
+ }
71
+
72
+ export function shortToolDetail(detail: string): string {
73
+ if (!/^(?:\/|[A-Za-z]:[\\/]|modules[\\/])/.test(detail)) return detail;
74
+ return detail
75
+ .replaceAll('\\', '/')
76
+ .replace(
77
+ /^.*?\/(?:\.flowdular|\.coreloom)\/sandbox\/sessions\/[^/]+\/workspace\//,
78
+ '',
79
+ )
80
+ .replace(/^modules\//, '');
81
+ }
82
+
83
+ export function toolAction(name: string): string {
84
+ switch (name.toLowerCase()) {
85
+ case 'read':
86
+ case 'read_file':
87
+ return 'read';
88
+ case 'edit':
89
+ case 'multiedit':
90
+ case 'update':
91
+ return 'edit';
92
+ case 'write':
93
+ case 'write_file':
94
+ case 'create':
95
+ return 'write';
96
+ case 'delete_file':
97
+ case 'delete':
98
+ return 'delete';
99
+ case 'glob':
100
+ case 'list_files':
101
+ return 'list';
102
+ case 'grep':
103
+ return 'search';
104
+ case 'bash':
105
+ case 'command':
106
+ return 'command';
107
+ default:
108
+ return 'tool';
109
+ }
110
+ }
@@ -1,5 +1,6 @@
1
+ import { withSessionLock } from './session-lock.ts';
1
2
  import { randomUUID } from 'node:crypto';
2
- import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
3
+ import { lstat, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
3
4
  import { isAbsolute, join, relative, resolve } from 'node:path';
4
5
  import {
5
6
  readSession,
@@ -210,28 +211,26 @@ export async function addAttachment(
210
211
  `Attachments are limited to ${MAX_ATTACHMENT_BYTES / (1024 * 1024)} MB.`,
211
212
  );
212
213
  }
213
- const current = await readSession(workspaceRoot, session.id);
214
- if (current.attachments.length >= MAX_ATTACHMENTS) {
215
- throw new SandboxSetupError(
216
- 'ATTACHMENT_LIMIT_REACHED',
217
- `A session may hold at most ${MAX_ATTACHMENTS} attachments.`,
218
- );
219
- }
220
- const type = resolveType(input.name, input.bytes);
221
- const meta: SessionAttachment = {
222
- id: randomUUID(),
223
- name: uniqueName(safeAttachmentName(input.name), current.attachments),
224
- kind: type.kind,
225
- size: input.bytes.byteLength,
226
- addedAt: Date.now(),
227
- };
228
- const paths = sessionPaths(workspaceRoot, current.id, current.moduleSuffix);
229
- await mkdir(paths.attachments, { recursive: true });
230
- await mkdir(paths.workspaceAttachments, { recursive: true });
231
- await writeFile(storedPath(paths, meta), input.bytes);
232
- await writeFile(workspacePath(paths, meta), input.bytes);
233
- await updateSession(workspaceRoot, current.id, {
234
- attachments: [...current.attachments, meta],
214
+ let meta!: SessionAttachment;
215
+ await updateSession(workspaceRoot, session.id, async (current) => {
216
+ if (current.attachments.length >= MAX_ATTACHMENTS) {
217
+ throw new SandboxSetupError(
218
+ 'ATTACHMENT_LIMIT_REACHED',
219
+ `A session may hold at most ${MAX_ATTACHMENTS} attachments.`,
220
+ );
221
+ }
222
+ const type = resolveType(input.name, input.bytes);
223
+ meta = {
224
+ id: randomUUID(),
225
+ name: uniqueName(safeAttachmentName(input.name), current.attachments),
226
+ kind: type.kind,
227
+ size: input.bytes.byteLength,
228
+ addedAt: Date.now(),
229
+ };
230
+ const paths = sessionPaths(workspaceRoot, current.id, current.moduleSuffix);
231
+ await mkdir(paths.attachments, { recursive: true });
232
+ await writeFile(storedPath(paths, meta), input.bytes);
233
+ return { attachments: [...current.attachments, meta] };
235
234
  });
236
235
  return meta;
237
236
  }
@@ -263,13 +262,15 @@ export async function removeAttachment(
263
262
  attachmentId: string,
264
263
  ): Promise<void> {
265
264
  assertAttachmentId(attachmentId);
266
- const current = await readSession(workspaceRoot, session.id);
267
- const meta = findAttachment(current.attachments, attachmentId);
268
- const paths = sessionPaths(workspaceRoot, current.id, current.moduleSuffix);
269
- await rm(storedPath(paths, meta), { force: true });
270
- await rm(workspacePath(paths, meta), { force: true });
271
- await updateSession(workspaceRoot, current.id, {
272
- attachments: current.attachments.filter((item) => item.id !== attachmentId),
265
+ await updateSession(workspaceRoot, session.id, async (current) => {
266
+ const meta = findAttachment(current.attachments, attachmentId);
267
+ const paths = sessionPaths(workspaceRoot, current.id, current.moduleSuffix);
268
+ await rm(storedPath(paths, meta), { force: true });
269
+ return {
270
+ attachments: current.attachments.filter(
271
+ (item) => item.id !== attachmentId,
272
+ ),
273
+ };
273
274
  });
274
275
  }
275
276
 
@@ -294,6 +295,42 @@ export async function readAttachment(
294
295
  };
295
296
  }
296
297
 
298
+ /* The host prepares a stable turn snapshot before path enforcement begins.
299
+ Uploads/removals only change the private store, never an active workspace. */
300
+ export async function materializeAttachments(
301
+ workspaceRoot: string,
302
+ session: SandboxSession,
303
+ ): Promise<readonly SessionAttachment[]> {
304
+ return withSessionLock(workspaceRoot, session.id, async () => {
305
+ const current = await readSession(workspaceRoot, session.id);
306
+ const paths = sessionPaths(workspaceRoot, current.id, current.moduleSuffix);
307
+ const reference = join(paths.workspace, 'reference');
308
+ const info = await lstat(reference).catch(
309
+ (error: NodeJS.ErrnoException) => {
310
+ if (error.code === 'ENOENT') return null;
311
+ throw error;
312
+ },
313
+ );
314
+ if (info && !info.isDirectory())
315
+ throw new SandboxSetupError(
316
+ 'ATTACHMENT_REFERENCE_INVALID',
317
+ 'The session reference directory is not a regular directory.',
318
+ );
319
+ await mkdir(reference, { recursive: true });
320
+ // rm unlinks a planted attachment symlink instead of following it.
321
+ await rm(paths.workspaceAttachments, { recursive: true, force: true });
322
+ await mkdir(paths.workspaceAttachments, { recursive: true });
323
+ for (const meta of current.attachments) {
324
+ await writeFile(
325
+ workspacePath(paths, meta),
326
+ await readFile(storedPath(paths, meta)),
327
+ { flag: 'wx' },
328
+ );
329
+ }
330
+ return current.attachments;
331
+ });
332
+ }
333
+
297
334
  /* The note prepended to a turn's instruction when the session has attachments.
298
335
  The files are already in reference/attachments/, so the agent opens them with
299
336
  its normal file tools; the path plus this note is the whole contract. */
@@ -9,6 +9,7 @@ import {
9
9
  writeFile,
10
10
  } from 'node:fs/promises';
11
11
  import { basename, join } from 'node:path';
12
+ import { referenceSource } from './reference.ts';
12
13
  import {
13
14
  basePathOf,
14
15
  modulePathOf,
@@ -107,7 +108,7 @@ export async function prepareAutoReview(
107
108
  const skill = join(paths.workspace, 'reference', 'skills', 'auto-review');
108
109
  await mkdir(skill, { recursive: true });
109
110
  await cp(
110
- join(workspaceRoot, '.ai', 'skills', 'auto-review', 'SKILL.md'),
111
+ await referenceSource(workspaceRoot, '.ai/skills/auto-review/SKILL.md'),
111
112
  join(skill, 'SKILL.md'),
112
113
  );
113
114
  }
@@ -0,0 +1,88 @@
1
+ import { matchesGlob } from 'node:path';
2
+ import type { AgentRoleDefinition } from '#coding-agent';
3
+ import type { GateResult } from './gates.ts';
4
+
5
+ interface DiagnosticPath {
6
+ path: string;
7
+ module: string;
8
+ }
9
+
10
+ /* Read diagnostic locations, never instructions embedded in output. Manifest
11
+ reports name the module; their issues name the file that actually failed. */
12
+ function locations(gate: GateResult, activeModule: string): DiagnosticPath[] {
13
+ const module = gate.module ?? activeModule;
14
+ const output = gate.output.slice(0, 16_000);
15
+ try {
16
+ const result = JSON.parse(
17
+ output.slice(output.indexOf('{'), output.lastIndexOf('}') + 1),
18
+ );
19
+ const reports = result?.error?.details?.reports;
20
+ if (Array.isArray(reports)) {
21
+ return reports.flatMap((report) => {
22
+ const target =
23
+ typeof report.file === 'string'
24
+ ? (/(?:^|\/)modules\/([a-z0-9-]+)\/module\.json$/.exec(
25
+ report.file,
26
+ )?.[1] ?? module)
27
+ : module;
28
+ return Array.isArray(report.issues)
29
+ ? report.issues
30
+ .filter(
31
+ (issue: { severity?: string; path?: unknown }) =>
32
+ issue.severity === 'error' && typeof issue.path === 'string',
33
+ )
34
+ .map((issue: { path: string }) => ({
35
+ path: issue.path,
36
+ module: target,
37
+ }))
38
+ : [];
39
+ });
40
+ }
41
+ } catch {
42
+ /* Compiler and formatter output is plain text. */
43
+ }
44
+ return output.split('\n').flatMap((line) => {
45
+ const match =
46
+ /^\s*(?:FAIL\s+)?((?:modules\/[a-z0-9-]+\/)?(?:src|tests|translations|migrations)\/[^\s:(]+)(?:\(\d+,\d+\)|:\d+|\s|$)/.exec(
47
+ line,
48
+ );
49
+ if (!match) return [];
50
+ const qualified = /^modules\/([a-z0-9-]+)\/(.+)$/.exec(match[1]!);
51
+ return [
52
+ { path: qualified?.[2] ?? match[1]!, module: qualified?.[1] ?? module },
53
+ ];
54
+ });
55
+ }
56
+
57
+ export function gateRepairOwner(
58
+ gate: GateResult,
59
+ activeModule: string,
60
+ roles: readonly AgentRoleDefinition[],
61
+ modules: readonly string[],
62
+ ): { role: string; module: string } | null {
63
+ for (const location of locations(gate, activeModule)) {
64
+ if (
65
+ !modules.includes(location.module) ||
66
+ location.path.split('/').includes('..')
67
+ )
68
+ continue;
69
+ const preferred =
70
+ location.path.startsWith('src/client/') ||
71
+ location.path.startsWith('translations/')
72
+ ? 'frontend-engineer'
73
+ : location.path.startsWith('spec/')
74
+ ? 'business-manager'
75
+ : location.path.startsWith('src/agent/') ||
76
+ location.path.startsWith('src/tools/')
77
+ ? 'agentic-engineer'
78
+ : 'backend-engineer';
79
+ const candidates = roles.filter((role) =>
80
+ role.allowedPaths.some((pattern) => matchesGlob(location.path, pattern)),
81
+ );
82
+ const owner =
83
+ candidates.find((role) => role.id === preferred) ??
84
+ (candidates.length === 1 ? candidates[0] : undefined);
85
+ if (owner) return { role: owner.id, module: location.module };
86
+ }
87
+ return null;
88
+ }
@@ -8,6 +8,7 @@ import type {
8
8
  HandoffDeclaration,
9
9
  } from '#coding-agent';
10
10
  import type { GateResult } from './gates.ts';
11
+ import { gateRepairOwner } from './gate-repair.ts';
11
12
  import { SandboxSetupError } from './workspace-root.ts';
12
13
  import {
13
14
  moduleSuffixOf,
@@ -573,20 +574,29 @@ export function planHandoff(context: HandoffContext): HandoffPlan {
573
574
  there even when the finished turn worked somewhere else. */
574
575
  const failedGate = context.gates.find((gate) => gate.status !== 'passed');
575
576
  if (failedGate) {
576
- const repairRole = context.reviewing
577
- ? (validateDeclared(context).role ?? context.role)
578
- : context.role;
577
+ const owner = gateRepairOwner(
578
+ failedGate,
579
+ context.module,
580
+ roles,
581
+ context.routing.session.modules.map((module) => module.directory),
582
+ );
583
+ const repairRole =
584
+ owner?.role ??
585
+ (context.reviewing
586
+ ? (validateDeclared(context).role ?? context.role)
587
+ : context.role);
579
588
  return plan(
580
589
  'continue',
581
590
  repairRole,
582
591
  `The ${gateLabel(failedGate)} gate failed, so the responsible specialist fixes it before delivery.`,
583
592
  [
584
- `The ${gateLabel(failedGate)} gate failed after your change. Fix exactly what it reports, change nothing else, and end with your handoff line.`,
593
+ `Continue as ${roleName(roles, repairRole)}. The ${gateLabel(failedGate)} gate failed. Fix the reported files within your role, preserve other work, and end with your handoff line.`,
594
+ `Recorded gate results:\n${context.gates.map((gate) => `${gateLabel(gate)}: ${gate.status}`).join('\n')}`,
585
595
  `Gate command: ${failedGate.command}`,
586
596
  `Gate output (first ${GATE_PROMPT_OUTPUT} characters; the transcript holds the rest):`,
587
597
  failedGate.output.slice(0, GATE_PROMPT_OUTPUT),
588
598
  ].join('\n\n'),
589
- failedGate.module ?? context.module,
599
+ owner?.module ?? failedGate.module ?? context.module,
590
600
  );
591
601
  }
592
602
 
@@ -0,0 +1,40 @@
1
+ import { relative } from 'node:path';
2
+ import type { Plugin } from 'vite';
3
+
4
+ /* Octane broadcasts full-reload for every TSRX change, including modules used
5
+ only by preview iframes. Draft saves must invalidate cached transforms without
6
+ navigating the operator's page. The preview refreshes at turn completion or
7
+ on request, when its isolated worker also selects the current revision. */
8
+ export function isolatePreviewHotUpdates(
9
+ plugins: Plugin[],
10
+ workspaceRoot: string,
11
+ ): Plugin[] {
12
+ return plugins.map((plugin) => {
13
+ const hook = plugin.hotUpdate;
14
+ if (!hook) return plugin;
15
+ const original = typeof hook === 'function' ? hook : hook.handler;
16
+ return {
17
+ ...plugin,
18
+ hotUpdate: {
19
+ ...(typeof hook === 'function' ? {} : hook),
20
+ async handler(options) {
21
+ const path = relative(workspaceRoot, options.file).replaceAll(
22
+ '\\',
23
+ '/',
24
+ );
25
+ if (/^(?:\.flowdular|\.coreloom)\/sandbox\/sessions\//.test(path)) {
26
+ for (const environment of Object.values(
27
+ options.server.environments,
28
+ )) {
29
+ const graph = environment.moduleGraph;
30
+ for (const module of graph.getModulesByFile(options.file) ?? [])
31
+ graph.invalidateModule(module);
32
+ }
33
+ return [];
34
+ }
35
+ return original.call(this, options);
36
+ },
37
+ },
38
+ };
39
+ });
40
+ }
@@ -1,3 +1,4 @@
1
+ import { materializeSdkReference } from './sdk-reference.ts';
1
2
  import { createRequire } from 'node:module';
2
3
  import {
3
4
  access,
@@ -9,7 +10,7 @@ import {
9
10
  } from 'node:fs/promises';
10
11
  import { dirname, join, relative } from 'node:path';
11
12
 
12
- async function referenceSource(
13
+ export async function referenceSource(
13
14
  workspaceRoot: string,
14
15
  path: string,
15
16
  ): Promise<string> {
@@ -104,6 +105,7 @@ Read-only copies of the platform contracts this session must implement against.
104
105
  Never edit anything in this directory: it is not part of the module and it is
105
106
  not ejected.
106
107
 
108
+ - sdk: complete installed SDK package and module sources, with package.json exports. Read these real files instead of following node_modules symlinks outside the workspace. Present when the host application has an installed SDK.
107
109
  - packages/server: defineEndpoint, HTTP helpers, and the endpoint identity contract.
108
110
  - packages/client: the client contribution contract (createClientContribution, ModuleClientContext), shell slots, and shell state.
109
111
  - packages/contracts: module manifest, spec, and blueprint schemas.
@@ -166,6 +168,7 @@ export async function materializeReference(
166
168
  .some((segment) => EXCLUDED.has(segment)),
167
169
  }).catch(() => undefined);
168
170
  }
171
+ await materializeSdkReference(workspaceRoot, sessionWorkspace);
169
172
  const skills = await listSkills(workspaceRoot);
170
173
  await writeFile(
171
174
  join(sessionWorkspace, 'reference/README.md'),
@@ -183,6 +186,7 @@ export async function writeAgentPointer(
183
186
  fileName: 'CLAUDE.md' | 'AGENTS.md',
184
187
  roleName: string,
185
188
  skill?: string | null,
189
+ hasSdk = false,
186
190
  ): Promise<void> {
187
191
  await writeFile(
188
192
  join(sessionWorkspace, fileName),
@@ -194,6 +198,11 @@ export async function writeAgentPointer(
194
198
  skill
195
199
  ? `- Read only reference/skills/${skill}/SKILL.md for this task. Do not load other skills or the whole reference catalog.`
196
200
  : '- No matching task skill is installed. Do not load unrelated skills.',
201
+ ...(hasSdk
202
+ ? [
203
+ '- Read installed SDK sources at reference/sdk; use its package.json exports to locate an API. Do not follow external node_modules symlinks or scan the whole SDK.',
204
+ ]
205
+ : []),
197
206
  '- Write only inside your module directory under modules/, in the paths the instruction allows. Everything under reference/ is read-only.',
198
207
  '- End your final message with the HANDOFF line the instruction describes.',
199
208
  '',
@@ -1266,9 +1266,8 @@ export function createSandboxRoutes(
1266
1266
  },
1267
1267
  });
1268
1268
 
1269
- /* Attachments are copied into the session workspace at upload time, so the
1270
- coding agent, which may only read inside the workspace, opens them by name
1271
- from reference/attachments/. The bytes never leave this origin. */
1269
+ /* Uploads stay outside the active workspace. Each turn copies its attachment
1270
+ snapshot into reference/attachments/ before the path guard starts. */
1272
1271
  const addSandboxAttachment = new ServerRoute({
1273
1272
  path: '/sandbox/api/sessions/:id/attachments',
1274
1273
  methods: ['POST'],
@@ -0,0 +1,99 @@
1
+ import { createRequire } from 'node:module';
2
+ import {
3
+ cp,
4
+ lstat,
5
+ mkdir,
6
+ mkdtemp,
7
+ readFile,
8
+ realpath,
9
+ rename,
10
+ rm,
11
+ writeFile,
12
+ } from 'node:fs/promises';
13
+ import { dirname, join, relative } from 'node:path';
14
+ import { SandboxSetupError } from './workspace-root.ts';
15
+
16
+ const EXCLUDED = new Set([
17
+ 'node_modules',
18
+ 'dist',
19
+ '.git',
20
+ '.flowdular',
21
+ '.turbo',
22
+ ]);
23
+
24
+ /* Published SDK contents are immutable. Cache a real copy per session and SDK
25
+ installation, outside the module graph. Never widen agent filesystem access. */
26
+ export async function materializeSdkReference(
27
+ workspaceRoot: string,
28
+ sessionWorkspace: string,
29
+ ): Promise<boolean> {
30
+ let source: string;
31
+ try {
32
+ const require = createRequire(join(workspaceRoot, 'platform/package.json'));
33
+ source = await realpath(
34
+ dirname(require.resolve('@flowdular/sdk/package.json')),
35
+ );
36
+ } catch (error) {
37
+ if ((error as NodeJS.ErrnoException).code === 'MODULE_NOT_FOUND')
38
+ return false;
39
+ throw error;
40
+ }
41
+ const manifest = await readFile(join(source, 'package.json'), 'utf8');
42
+ const reference = join(sessionWorkspace, 'reference');
43
+ const info = await lstat(reference).catch((error: NodeJS.ErrnoException) => {
44
+ if (error.code === 'ENOENT') return null;
45
+ throw error;
46
+ });
47
+ if (info && !info.isDirectory())
48
+ throw new SandboxSetupError(
49
+ 'SDK_REFERENCE_INVALID',
50
+ 'The session reference directory is not a regular directory.',
51
+ );
52
+ await mkdir(reference, { recursive: true });
53
+ const target = join(reference, 'sdk');
54
+ // Metadata belongs to the host, outside the agent workspace and its guard.
55
+ const marker = join(dirname(sessionWorkspace), 'sdk-reference.json');
56
+ const identity = JSON.stringify({ source, manifest });
57
+ try {
58
+ if (
59
+ (await lstat(target)).isDirectory() &&
60
+ (await lstat(join(target, 'package.json'))).isFile() &&
61
+ (await readFile(marker, 'utf8')) === identity &&
62
+ (await readFile(join(target, 'package.json'), 'utf8')) === manifest
63
+ )
64
+ return true;
65
+ } catch (error) {
66
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
67
+ }
68
+ const staging = await mkdtemp(
69
+ join(dirname(sessionWorkspace), 'sdk-reference-'),
70
+ );
71
+ try {
72
+ await writeFile(join(staging, 'package.json'), manifest);
73
+ for (const member of ['packages', 'modules', 'modules.json']) {
74
+ const from = join(source, member);
75
+ try {
76
+ await lstat(from);
77
+ } catch (error) {
78
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue;
79
+ throw error;
80
+ }
81
+ await cp(from, join(staging, member), {
82
+ recursive: true,
83
+ filter: async (path) => {
84
+ const parts = relative(source, path).split('/');
85
+ if (parts.some((part) => EXCLUDED.has(part) || part.startsWith('.')))
86
+ return false;
87
+ // A dependency symlink must never survive inside the snapshot.
88
+ return !(await lstat(path)).isSymbolicLink();
89
+ },
90
+ });
91
+ }
92
+ await rm(target, { recursive: true, force: true });
93
+ await rename(staging, target);
94
+ await writeFile(marker, identity);
95
+ } finally {
96
+ await rm(staging, { recursive: true, force: true });
97
+ }
98
+ return true;
99
+ }