@flowdular/sandbox 0.2.4 → 0.2.6

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/README.md CHANGED
@@ -133,6 +133,38 @@ Every request to the sandbox API is checked before it does anything:
133
133
  sandbox that cannot prove it is loopback never offers a local binary, because a
134
134
  local binary carries the operator's own login.
135
135
 
136
+ ## Model settings
137
+
138
+ Open the workspace menu and choose **AI models · BYOK**. Select a provider,
139
+ enter its model ID and API key, and supply an API base URL for OpenAI-compatible
140
+ servers or a resource name for Azure. Saving makes BYOK available in the agent
141
+ selector. You can make it the default for new sessions; running turns retain
142
+ the configuration they started with.
143
+
144
+ Keys are encrypted in local sandbox configuration and never returned to the
145
+ browser. An empty key field preserves the existing key only when the provider
146
+ and destination are unchanged. The settings also let you clear the key or
147
+ remove BYOK entirely.
148
+
149
+ Provider conversations are scoped to the current specialist, module, task skill,
150
+ write permissions, approved specification and model. A handoff that changes
151
+ that scope starts a fresh conversation with the brief and recent messages;
152
+ continuing the same scope resumes its existing conversation. Legacy shared
153
+ conversations are replaced on the next turn.
154
+
155
+ For a long CLI conversation, select **Fresh agent context** before sending the
156
+ next message. It starts a new CLI conversation with the original brief and
157
+ recent sandbox messages, preserving draft files, the approved specification
158
+ and the complete sandbox transcript. Earlier tool output is not replayed;
159
+ include any older decision that is not recorded in the spec or recent messages.
160
+ Claude activity is shown from the start of streamed response blocks, with
161
+ completed reasoning and tool events following as they arrive. This does not
162
+ reduce provider queue or inference time.
163
+
164
+ Source-change logs group repeated saves into a short summary such as
165
+ `Draft blog (96a64f10) · 4 files changed`. These report file changes, not a
166
+ successful build. Use `--verbose` to see individual paths.
167
+
136
168
  ## Sessions
137
169
 
138
170
  The home dashboard lists the operator's ideas, current stages, recorded token
@@ -2,6 +2,62 @@
2
2
 
3
3
  // ../sandbox/bin/flowdular-sandbox.mjs
4
4
  import "./register-types.mjs";
5
+
6
+ // ../sandbox/src/server/reload-log.ts
7
+ import { relative } from "node:path";
8
+ function watchSandboxReloads(server, appRoot2, write, verbose = false) {
9
+ const groups = /* @__PURE__ */ new Map();
10
+ let timer;
11
+ let pendingFiles = 0;
12
+ const flush = () => {
13
+ if (timer) clearTimeout(timer);
14
+ timer = void 0;
15
+ for (const [label, files] of groups) {
16
+ if (verbose) {
17
+ for (const path of files) write(path);
18
+ } else {
19
+ write(
20
+ `${label} \xB7 ${files.size} ${files.size === 1 ? "file" : "files"} changed`
21
+ );
22
+ }
23
+ }
24
+ groups.clear();
25
+ pendingFiles = 0;
26
+ };
27
+ const changed = (event, path) => {
28
+ if (!["add", "change", "unlink"].includes(event)) return;
29
+ const normalized = path.replaceAll("\\", "/");
30
+ const draft = normalized.match(
31
+ /\/(?:\.flowdular|\.coreloom)\/sandbox\/sessions\/([^/]+)\/workspace\/modules\/([^/]+)\//
32
+ );
33
+ const local = relative(appRoot2, path);
34
+ if (!draft && (local === ".." || local.startsWith("../") || local.startsWith("..\\")))
35
+ return;
36
+ const label = draft ? `Draft ${draft[2]} (${draft[1].slice(0, 8)})` : "Sandbox";
37
+ const files = groups.get(label) ?? /* @__PURE__ */ new Set();
38
+ if (!files.has(path)) pendingFiles += 1;
39
+ files.add(path);
40
+ groups.set(label, files);
41
+ if (!timer) {
42
+ timer = setTimeout(flush, 750);
43
+ timer.unref?.();
44
+ }
45
+ if (pendingFiles >= 256) flush();
46
+ };
47
+ const dispose = () => {
48
+ if (timer) clearTimeout(timer);
49
+ timer = void 0;
50
+ groups.clear();
51
+ pendingFiles = 0;
52
+ server.watcher.off("all", changed);
53
+ server.httpServer?.off("close", dispose);
54
+ };
55
+ server.watcher.on("all", changed);
56
+ server.httpServer?.once("close", dispose);
57
+ return dispose;
58
+ }
59
+
60
+ // ../sandbox/bin/flowdular-sandbox.mjs
5
61
  import process2 from "node:process";
6
62
  import { realpathSync } from "node:fs";
7
63
  import { dirname, resolve } from "node:path";
@@ -168,18 +224,6 @@ function installOctaneConsoleBridge(verbose, useColor) {
168
224
  console.error = originalError;
169
225
  };
170
226
  }
171
- function watchReloads(server, root, useColor) {
172
- let lastChange = "";
173
- let lastChangeAt = 0;
174
- server.watcher.on("change", (path) => {
175
- const changed = path.startsWith(root) ? path.slice(root.length + 1) : path;
176
- const now = Date.now();
177
- if (changed === lastChange && now - lastChangeAt < 100) return;
178
- lastChange = changed;
179
- lastChangeAt = now;
180
- console.log(formatDevEvent("reload", changed, useColor));
181
- });
182
- }
183
227
 
184
228
  // ../sandbox/bin/flowdular-sandbox.mjs
185
229
  var appRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
@@ -362,7 +406,12 @@ async function startSandbox(argv = process2.argv.slice(2)) {
362
406
  ]
363
407
  ]
364
408
  });
365
- watchReloads(server, appRoot, useColor);
409
+ watchSandboxReloads(
410
+ server,
411
+ appRoot,
412
+ (message) => console.log(formatDevEvent("reload", message, useColor)),
413
+ options.verbose
414
+ );
366
415
  const close = async () => {
367
416
  console.log(`
368
417
  ${formatDevEvent("process", "Sandbox stopped.", useColor)}`);
@@ -30,6 +30,7 @@ const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000;
30
30
  interface ContentBlock {
31
31
  readonly type?: string;
32
32
  readonly text?: string;
33
+ readonly thinking?: string;
33
34
  readonly name?: string;
34
35
  readonly input?: Record<string, unknown>;
35
36
  readonly content?: unknown;
@@ -116,7 +117,7 @@ export function createClaudeCodeDriver(
116
117
  ): AsyncIterable<CodingAgentEvent> {
117
118
  const resumeId = request.resumeId ?? null;
118
119
  if (!resumeId) {
119
- yield* runSession(request, null);
120
+ yield* runSession(request, null, Boolean(request.history?.length));
120
121
  return;
121
122
  }
122
123
  try {
@@ -145,6 +146,7 @@ export function createClaudeCodeDriver(
145
146
  '--output-format',
146
147
  'stream-json',
147
148
  '--verbose',
149
+ '--include-partial-messages',
148
150
  '--permission-mode',
149
151
  'acceptEdits',
150
152
  '--restricted',
@@ -193,6 +195,23 @@ export function createClaudeCodeDriver(
193
195
  continue;
194
196
  }
195
197
 
198
+ if (type === 'stream_event') {
199
+ const partial = message.event as Record<string, unknown> | undefined;
200
+ const block = partial?.content_block as
201
+ | Record<string, unknown>
202
+ | undefined;
203
+ if (
204
+ partial?.type === 'content_block_start' &&
205
+ (block?.type === 'thinking' || block?.type === 'text')
206
+ ) {
207
+ yield {
208
+ type: 'activity',
209
+ phase: block.type === 'thinking' ? 'thinking' : 'responding',
210
+ };
211
+ }
212
+ continue;
213
+ }
214
+
196
215
  if (type === 'assistant') {
197
216
  const content = ((message.message ?? {}) as Record<string, unknown>)
198
217
  .content;
@@ -200,8 +219,8 @@ export function createClaudeCodeDriver(
200
219
  if (block.type === 'text' && block.text?.trim()) {
201
220
  yield { type: 'assistant.message', text: block.text };
202
221
  }
203
- if (block.type === 'thinking' && typeof block.text === 'string') {
204
- yield { type: 'reasoning', text: block.text };
222
+ if (block.type === 'thinking' && typeof block.thinking === 'string') {
223
+ yield { type: 'reasoning', text: block.thinking };
205
224
  }
206
225
  if (block.type === 'tool_use' && block.name) {
207
226
  yield {
@@ -3,7 +3,9 @@ export const SANDBOX_AGENT_CONTRACT = `You are one coding specialist in a Flowdu
3
3
 
4
4
  Always-active invariants
5
5
  - Write only to the active module and the allowed Session paths. reference/ is read-only. Preserve unrelated work. Never edit platform composition or flowdular.json.
6
+ - Implement only the portions of the selected skill that belong to your current role and Session write paths. Other sections describe your teammates' work; hand those parts off instead of editing their files.
6
7
  - Read the one task skill named under Session before editing. Do not load other SKILL.md files or the full skill catalog. Read only the owning code and references needed for this task; copy the example-module shape where relevant.
8
+ - Batch independent reads and searches when the tools allow it. Reuse files already read in this conversation unless they changed. Search for a symbol in its owning package before widening the search. Do not read whole reference trees or node_modules to discover an API. If a required public API is absent, report that blocker rather than repeating broad searches.
7
9
  - Module implementation requires operator approval of the exact current spec hash. Agents never approve specs. Any later spec edit, request for changes, or added module invalidates the approval. Stop implementation until it is renewed.
8
10
  - Deny by default: explicit endpoint permissions, trusted principal identity, CSRF and bounded inputs. Never accept tenant identity from request input.
9
11
  - Use bound SQL and tenant-scoped transactions. Tenant predicates and forced RLS with USING and WITH CHECK are mandatory. No runtime superuser/BYPASSRLS. DDL uses a short migration lease; applied SQL is immutable and mirrored byte for byte.
@@ -39,7 +41,7 @@ export function composeSessionFacts(context: InstructionContext): string {
39
41
  `- Module directory in this workspace: ${context.modulePath}`,
40
42
  `- Session kind: ${context.sessionKind === 'new-module' ? 'new module; author its specification first, then wait for operator approval of the exact spec hash before implementation' : 'change to an existing module; author its spec delta first, then wait for operator approval of the exact spec hash before implementation'}`,
41
43
  `- Blueprint: ${context.blueprint}`,
42
- `- Paths you may write: ${context.allowedPaths.join(', ')}`,
44
+ `- Paths you may write: ${context.allowedPaths.length ? context.allowedPaths.join(', ') : 'none (read-only)'}`,
43
45
  ];
44
46
  if (context.skill) {
45
47
  lines.push(`- Task skill: reference/skills/${context.skill}/SKILL.md`);
@@ -145,12 +145,6 @@ export function composeInstruction(
145
145
  '',
146
146
  role.instruction,
147
147
  '',
148
- composeSessionFacts({
149
- ...context,
150
- allowedPaths:
151
- context.allowedPaths.length > 0
152
- ? context.allowedPaths
153
- : role.allowedPaths,
154
- }),
148
+ composeSessionFacts(context),
155
149
  ].join('\n');
156
150
  }
@@ -17,6 +17,7 @@ export type CodingAgentEvent =
17
17
  }
18
18
  | { readonly type: 'assistant.message'; readonly text: string }
19
19
  | { readonly type: 'reasoning'; readonly text: string }
20
+ | { readonly type: 'activity'; readonly phase: 'thinking' | 'responding' }
20
21
  | {
21
22
  readonly type: 'tool.started';
22
23
  readonly tool: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flowdular/sandbox",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
4
4
  "description": "The Flowdular sandbox: chat a change, build it behind the gates, preview it in the real application, deliver it as code.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/flowdular/flowdular/tree/main/packages/sandbox#readme",
package/src/App.tsrx CHANGED
@@ -1,4 +1,6 @@
1
- import { useEffect, useMemo } from 'octane';
1
+ import { useEffect, useMemo, useState } from 'octane';
2
+ import { ModelSettingsModal } from './client/ModelSettingsModal.tsrx';
3
+ import type { ConfigurationPatch } from './client/api.ts';
2
4
  import { Head, Seo } from '@octanejs/seo';
3
5
  import { Alert, BrandMark, Button, Icon, Tag } from '@flowdular/sdk/ui';
4
6
  import { useValue } from 'segment-state';
@@ -118,6 +120,7 @@ export function App(props?: RenderRouteProps) @{
118
120
  const [ejectError, setEjectError] = useValue(sandbox.state.ejectError);
119
121
  const [ejecting, setEjecting] = useValue(sandbox.state.ejecting);
120
122
  const [ejectBuild, setEjectBuild] = useValue(sandbox.state.ejectBuild);
123
+ const [modelSettingsOpen, setModelSettingsOpen] = useState(false);
121
124
  const [menuOpen, setMenuOpen] = useValue(sandbox.state.menuOpen);
122
125
  const [githubSettingsOpen, setGitHubSettingsOpen] = useValue(
123
126
  sandbox.state.githubSettingsOpen,
@@ -280,6 +283,7 @@ export function App(props?: RenderRouteProps) @{
280
283
  roleId: string,
281
284
  moduleDirectory = sandbox.store.get(sandbox.state.turnModule),
282
285
  submittedDraft?: string,
286
+ freshContext = false,
283
287
  ) => {
284
288
  detach();
285
289
  setRunning(true);
@@ -289,6 +293,7 @@ export function App(props?: RenderRouteProps) @{
289
293
  session.id,
290
294
  {
291
295
  message: text,
296
+ freshContext,
292
297
  role: roleId,
293
298
  ...(moduleDirectory ? { module: moduleDirectory } : {}),
294
299
  driver: sandbox.store.get(sandbox.state.driver) || session.driver,
@@ -355,7 +360,7 @@ export function App(props?: RenderRouteProps) @{
355
360
  }
356
361
  };
357
362
 
358
- const send = (explicit?: string) => {
363
+ const send = (explicit?: string, freshContext = false) => {
359
364
  const session = currentSession();
360
365
  if (!session || sandbox.store.get(sandbox.state.running)) return;
361
366
  const text = (explicit ?? draft).trim();
@@ -371,6 +376,7 @@ export function App(props?: RenderRouteProps) @{
371
376
  role,
372
377
  undefined,
373
378
  explicit === undefined ? draft : undefined,
379
+ freshContext,
374
380
  );
375
381
  };
376
382
 
@@ -592,6 +598,22 @@ export function App(props?: RenderRouteProps) @{
592
598
  }
593
599
  };
594
600
 
601
+ const saveModels = async (input: ConfigurationPatch) => {
602
+ setBusy(true);
603
+ setError('');
604
+ try {
605
+ await saveConfiguration(input);
606
+ await refreshState();
607
+ if (input.driver && !currentSession()) setDriver(input.driver);
608
+ setModelSettingsOpen(false);
609
+ setNotice(t('sandbox.models.saved'));
610
+ } catch (error) {
611
+ setError(message(error, t('sandbox.models.error')));
612
+ } finally {
613
+ setBusy(false);
614
+ }
615
+ };
616
+
595
617
  const saveGitHub = async (input: GitHubSettingsInput) => {
596
618
  setBusy(true);
597
619
  setError('');
@@ -700,6 +722,11 @@ export function App(props?: RenderRouteProps) @{
700
722
  <span class="sandbox__spacer"></span>
701
723
  <WorkspaceMenu
702
724
  state={state}
725
+ onModels={() => {
726
+ setMenuOpen(false);
727
+ setError('');
728
+ setModelSettingsOpen(true);
729
+ }}
703
730
  open={menuOpen}
704
731
  onToggle={() => setMenuOpen(!menuOpen)}
705
732
  onChangeConnection={() => {
@@ -734,6 +761,18 @@ export function App(props?: RenderRouteProps) @{
734
761
  onClose={closeEject}
735
762
  />
736
763
  }
764
+ @if (modelSettingsOpen) {
765
+ <ModelSettingsModal
766
+ configuration={state.configuration}
767
+ busy={busy}
768
+ error={error}
769
+ onSave={(input) => void saveModels(input)}
770
+ onClose={() => {
771
+ setModelSettingsOpen(false);
772
+ setError('');
773
+ }}
774
+ />
775
+ }
737
776
  @if (githubSettingsOpen) {
738
777
  <GitHubSettingsModal
739
778
  configuration={state.configuration}
@@ -853,7 +892,7 @@ export function App(props?: RenderRouteProps) @{
853
892
  onModule={setTurnModule}
854
893
  onDriver={setDriver}
855
894
  onMessage={setDraft}
856
- onSend={() => send()}
895
+ onSend={(fresh) => send(undefined, fresh)}
857
896
  onStop={stop}
858
897
  onClearSelection={() => setSelection(null)}
859
898
  />
@@ -56,7 +56,7 @@ export interface ChatPaneProps {
56
56
  readonly onModule: (module: string) => void;
57
57
  readonly onDriver: (driver: string) => void;
58
58
  readonly onMessage: (message: string) => void;
59
- readonly onSend: () => void;
59
+ readonly onSend: (freshContext?: boolean) => void;
60
60
  readonly onStop: () => void;
61
61
  readonly onClearSelection: () => void;
62
62
  }
@@ -70,6 +70,8 @@ function eventLabel(entry: ChatEntry): string {
70
70
  driver: event.driver,
71
71
  role: roleLabel(event.role),
72
72
  });
73
+ case 'activity':
74
+ return t('sandbox.chat.activity.' + event.phase);
73
75
  case 'reasoning':
74
76
  return event.text.slice(0, 160);
75
77
  case 'tool.started':
@@ -381,9 +383,11 @@ export function ChatPane(props: ChatPaneProps) @{
381
383
  </div>;
382
384
  };
383
385
 
386
+ const [freshContext, setFreshContext] = useState(false);
384
387
  const submit = (event: SubmitEvent) => {
385
388
  event.preventDefault();
386
- props.onSend();
389
+ props.onSend(freshContext);
390
+ setFreshContext(false);
387
391
  };
388
392
 
389
393
  <section class="sandbox__chat">
@@ -602,6 +606,15 @@ export function ChatPane(props: ChatPaneProps) @{
602
606
  onInput={(event) => props.onMessage(event.currentTarget.value)}
603
607
  onPaste={onPaste}
604
608
  ></textarea>
609
+ <label class="ui-checkbox" title={t('sandbox.chat.freshHelp')}>
610
+ <input
611
+ type="checkbox"
612
+ checked={freshContext}
613
+ disabled={props.running}
614
+ onChange={(event) => setFreshContext(event.currentTarget.checked)}
615
+ />
616
+ <span>{t('sandbox.chat.fresh')}</span>
617
+ </label>
605
618
  <div class="chat__composer-row">
606
619
  <button
607
620
  class="chat__attach"
@@ -0,0 +1,161 @@
1
+ import { useState } from 'octane';
2
+ import { Alert, Button, Drawer, FormField } from '@flowdular/sdk/ui';
3
+ import {
4
+ AI_PROVIDER_CATALOG,
5
+ AI_PROVIDER_KINDS,
6
+ } from '@flowdular/sdk/ai-provider/catalog';
7
+ import type { ConfigurationPatch } from './api.ts';
8
+ import type { SafeSandboxConfiguration } from '../server/config.ts';
9
+ import { useTranslation } from './i18n.ts';
10
+
11
+ export function ModelSettingsModal(props: {
12
+ readonly configuration: SafeSandboxConfiguration;
13
+ readonly busy: boolean;
14
+ readonly error: string;
15
+ readonly onSave: (input: ConfigurationPatch) => void;
16
+ readonly onClose: () => void;
17
+ }) @{
18
+ const { t } = useTranslation();
19
+ const saved = props.configuration.byok;
20
+ const [kind, setKind] = useState(saved?.kind ?? 'anthropic');
21
+ const submit = (event: SubmitEvent) => {
22
+ event.preventDefault();
23
+ if (props.busy) return;
24
+ const data = new FormData(event.currentTarget as HTMLFormElement);
25
+ props.onSave({
26
+ byokKind: kind,
27
+ byokModel: String(data.get('model') ?? '').trim(),
28
+ byokCredential: String(data.get('credential') ?? '').trim(),
29
+ byokBaseUrl: String(data.get('baseURL') ?? '').trim(),
30
+ byokResourceName: String(data.get('resourceName') ?? '').trim(),
31
+ byokClearCredential: data.get('clear') === 'on',
32
+ ...(data.get('default') === 'on' ||
33
+ props.configuration.mode === 'self-hosted'
34
+ ? { driver: 'byok', driverModel: null }
35
+ : props.configuration.driver === 'byok' &&
36
+ props.configuration.mode === 'loopback'
37
+ ? { driver: 'claude-code', driverModel: null }
38
+ : {}),
39
+ });
40
+ };
41
+ <Drawer
42
+ open={true}
43
+ title={t('sandbox.models.title')}
44
+ subtitle={t('sandbox.models.description')}
45
+ width="lg"
46
+ onClose={() => {
47
+ if (!props.busy) props.onClose();
48
+ }}
49
+ >
50
+ <form class="ui-drawer__form" onSubmit={submit}>
51
+ <div class="ui-drawer__body">
52
+ <div class="ui-form">
53
+ <Alert tone="info">{t('sandbox.models.help')}</Alert>
54
+ <FormField label={t('sandbox.models.provider')}>
55
+ <select
56
+ class="ui-select"
57
+ value={kind}
58
+ onChange={(event) => setKind(
59
+ event.currentTarget.value as typeof kind,
60
+ )}
61
+ >
62
+ @for (const option of AI_PROVIDER_KINDS; key option) {
63
+ <option
64
+ value={option}
65
+ >{AI_PROVIDER_CATALOG[option].label}</option>
66
+ }
67
+ </select>
68
+ </FormField>
69
+ <FormField label={t('sandbox.models.model')}>
70
+ <input
71
+ class="ui-input"
72
+ name="model"
73
+ required
74
+ maxlength={160}
75
+ defaultValue={saved?.model ?? ''}
76
+ />
77
+ </FormField>
78
+ <FormField
79
+ label={t('sandbox.models.key')}
80
+ help={saved?.credentialFingerprint
81
+ ? t('sandbox.models.keySaved')
82
+ : t('sandbox.models.keyHelp')}
83
+ >
84
+ <input
85
+ class="ui-input"
86
+ name="credential"
87
+ type="password"
88
+ autocomplete="new-password"
89
+ maxlength={16384}
90
+ />
91
+ </FormField>
92
+ <FormField label={t('sandbox.models.url')}>
93
+ <input
94
+ class="ui-input"
95
+ name="baseURL"
96
+ type="url"
97
+ required={kind === 'openai-compatible'}
98
+ maxlength={2048}
99
+ defaultValue={saved?.baseURL ?? ''}
100
+ />
101
+ </FormField>
102
+ @if (kind === 'azure') {
103
+ <FormField label={t('sandbox.models.resource')}>
104
+ <input
105
+ class="ui-input"
106
+ name="resourceName"
107
+ required
108
+ maxlength={160}
109
+ defaultValue={saved?.resourceName ?? ''}
110
+ />
111
+ </FormField>
112
+ }
113
+ <label class="ui-checkbox">
114
+ <input
115
+ type="checkbox"
116
+ name="default"
117
+ disabled={props.configuration.mode === 'self-hosted'}
118
+ defaultChecked={props.configuration.driver === 'byok' ||
119
+ props.configuration.mode === 'self-hosted'}
120
+ />
121
+ <span>{t('sandbox.models.default')}</span>
122
+ </label>
123
+ @if (saved?.credentialFingerprint) {
124
+ <label class="ui-checkbox">
125
+ <input type="checkbox" name="clear" />
126
+ <span>{t('sandbox.models.clear')}</span>
127
+ </label>
128
+ }
129
+ @if (props.error) {
130
+ <Alert tone="danger">{props.error}</Alert>
131
+ }
132
+ </div>
133
+ </div>
134
+ <div class="ui-drawer__foot">
135
+ <Button disabled={props.busy} onClick={props.onClose}>{t(
136
+ 'sandbox.models.cancel',
137
+ )}</Button>
138
+ @if (saved) {
139
+ <Button
140
+ variant="danger"
141
+ disabled={props.busy}
142
+ onClick={() => props.onSave({
143
+ byokRemove: true,
144
+ ...(props.configuration.driver === 'byok'
145
+ ? {
146
+ driver: props.configuration.mode === 'loopback'
147
+ ? 'claude-code'
148
+ : 'byok',
149
+ driverModel: null,
150
+ }
151
+ : {}),
152
+ })}
153
+ >{t('sandbox.models.remove')}</Button>
154
+ }
155
+ <Button type="submit" variant="primary" disabled={props.busy}>{t(
156
+ 'sandbox.models.save',
157
+ )}</Button>
158
+ </div>
159
+ </form>
160
+ </Drawer>
161
+ }
@@ -5,6 +5,7 @@ import { setActiveLocale, SUPPORTED_LOCALES, useTranslation } from './i18n.ts';
5
5
  export interface WorkspaceMenuProps {
6
6
  readonly state: SandboxState;
7
7
  readonly open: boolean;
8
+ readonly onModels: () => void;
8
9
  readonly onToggle: () => void;
9
10
  readonly onChangeConnection: () => void;
10
11
  readonly onDisconnect: () => void;
@@ -60,6 +61,12 @@ export function WorkspaceMenu(props: WorkspaceMenuProps) @{
60
61
  </Tag>
61
62
  </div>
62
63
  <div class="ui-menu__sep"></div>
64
+ <button
65
+ class="ui-menu__item"
66
+ type="button"
67
+ role="menuitem"
68
+ onClick={props.onModels}
69
+ >{t('sandbox.models.title')}</button>
63
70
  <label class="ui-menu__item workspace-menu__language">
64
71
  <Icon name="globe" size={16} />
65
72
  <span>{t('sandbox.workspace.language')}</span>
package/src/client/api.ts CHANGED
@@ -242,6 +242,8 @@ export interface ConfigurationPatch {
242
242
  readonly driver?: string;
243
243
  readonly driverModel?: string | null;
244
244
  readonly previewData?: 'fixtures' | 'bridge';
245
+ readonly byokRemove?: boolean;
246
+ readonly byokClearCredential?: boolean;
245
247
  readonly byokKind?: string;
246
248
  readonly byokModel?: string;
247
249
  readonly byokCredential?: string;
@@ -705,6 +707,7 @@ export function streamTurn(
705
707
  sessionId: string,
706
708
  input: {
707
709
  readonly message: string;
710
+ readonly freshContext?: boolean;
708
711
  readonly role: string;
709
712
  /* The draft module directory the turn works in; absent lets the sandbox
710
713
  decide from the last handoff. */
@@ -462,5 +462,26 @@
462
462
  "eject.explanation.officialModules": "The sandbox will repeat all gates, verify the module in the registry repository and open a pull request. Maintainers decide publication. New modules only; existing registry modules are never overwritten.",
463
463
  "delivery.step.provider": "Check GitHub access",
464
464
  "delivery.step.review": "Record reviewed source",
465
- "delivery.step.pack": "Prepare module release"
465
+ "delivery.step.pack": "Prepare module release",
466
+ "models.title": "AI models · BYOK",
467
+ "models.description": "Use your own API key for sandbox coding agents.",
468
+ "models.help": "Save a provider to make BYOK available in the agent selector. Running turns keep their current configuration.",
469
+ "models.provider": "Provider",
470
+ "models.model": "Model ID",
471
+ "models.key": "API key",
472
+ "models.keySaved": "A key is saved. Leave empty to keep it for the same provider and address.",
473
+ "models.keyHelp": "The key is encrypted on the server. Enter a new key when changing provider or address.",
474
+ "models.url": "API base URL",
475
+ "models.resource": "Azure resource name",
476
+ "models.default": "Use BYOK by default for new sessions",
477
+ "models.clear": "Delete the saved key",
478
+ "models.cancel": "Cancel",
479
+ "models.remove": "Remove BYOK",
480
+ "models.save": "Save",
481
+ "models.saved": "Model settings saved.",
482
+ "models.error": "Could not save model settings.",
483
+ "chat.activity.thinking": "Agent is thinking…",
484
+ "chat.activity.responding": "Agent is preparing a response…",
485
+ "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."
466
487
  }
@@ -462,5 +462,26 @@
462
462
  "eject.explanation.officialModules": "Sandbox powtórzy wszystkie kontrole, zweryfikuje moduł w repozytorium rejestru i otworzy PR. Maintainerzy zdecydują o publikacji. Tylko nowe moduły; istniejące moduły rejestru nie będą nadpisywane.",
463
463
  "delivery.step.provider": "Sprawdzanie dostępu do GitHub",
464
464
  "delivery.step.review": "Zapisywanie dowodów review",
465
- "delivery.step.pack": "Przygotowanie wydania modułu"
465
+ "delivery.step.pack": "Przygotowanie wydania modułu",
466
+ "models.title": "Modele AI · BYOK",
467
+ "models.description": "Używaj własnego klucza API dla agentów sandboxa.",
468
+ "models.help": "Zapisz dostawcę, aby BYOK pojawił się na liście agentów. Trwające tury zachowają obecną konfigurację.",
469
+ "models.provider": "Dostawca",
470
+ "models.model": "Identyfikator modelu",
471
+ "models.key": "Klucz API",
472
+ "models.keySaved": "Klucz jest zapisany. Puste pole zachowa go dla tego samego dostawcy i adresu.",
473
+ "models.keyHelp": "Klucz jest szyfrowany na serwerze. Przy zmianie dostawcy lub adresu podaj nowy klucz.",
474
+ "models.url": "Adres bazowy API",
475
+ "models.resource": "Nazwa zasobu Azure",
476
+ "models.default": "Używaj BYOK domyślnie w nowych sesjach",
477
+ "models.clear": "Usuń zapisany klucz",
478
+ "models.cancel": "Anuluj",
479
+ "models.remove": "Usuń BYOK",
480
+ "models.save": "Zapisz",
481
+ "models.saved": "Zapisano ustawienia modeli.",
482
+ "models.error": "Nie udało się zapisać ustawień modeli.",
483
+ "chat.activity.thinking": "Agent analizuje zadanie…",
484
+ "chat.activity.responding": "Agent przygotowuje odpowiedź…",
485
+ "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."
466
487
  }
@@ -0,0 +1,81 @@
1
+ import { isAiProviderKind, AI_PROVIDER_CATALOG } from '@flowdular/sdk/ai-provider';
2
+ import { sealSecret, type ByokProviderConfiguration } from './config.ts';
3
+ import { SandboxSetupError } from './workspace-root.ts';
4
+
5
+ export async function byokSettings(
6
+ root: string,
7
+ value: Record<string, unknown>,
8
+ previous: ByokProviderConfiguration | null,
9
+ ): Promise<ByokProviderConfiguration | null | undefined> {
10
+ if (value.byokRemove === true) return null;
11
+ if (value.byokKind === undefined) return undefined;
12
+ const field = (name: string, max: number): string => {
13
+ const valueAt = value[name];
14
+ if (valueAt === undefined || valueAt === null) return '';
15
+ if (typeof valueAt !== 'string' || valueAt.length > max)
16
+ throw new SandboxSetupError('INVALID_INPUT', `Invalid ${name}.`);
17
+ return valueAt.trim();
18
+ };
19
+ const kind = field('byokKind', 40);
20
+ if (!isAiProviderKind(kind))
21
+ throw new SandboxSetupError('INVALID_INPUT', 'Unknown AI provider.');
22
+ const model = field('byokModel', 160);
23
+ if (!model)
24
+ throw new SandboxSetupError('INVALID_INPUT', 'A model is required.');
25
+ const baseURL = field('byokBaseUrl', 2048);
26
+ const resourceName = field('byokResourceName', 160);
27
+ if (AI_PROVIDER_CATALOG[kind].requires.includes('baseURL') && !baseURL)
28
+ throw new SandboxSetupError(
29
+ 'INVALID_INPUT',
30
+ 'An API base URL is required.',
31
+ );
32
+ if (
33
+ AI_PROVIDER_CATALOG[kind].requires.includes('resourceName') &&
34
+ !resourceName
35
+ )
36
+ throw new SandboxSetupError(
37
+ 'INVALID_INPUT',
38
+ 'An Azure resource name is required.',
39
+ );
40
+ if (baseURL) {
41
+ let url: URL;
42
+ try {
43
+ url = new URL(baseURL);
44
+ } catch {
45
+ throw new SandboxSetupError('INVALID_INPUT', 'Invalid API base URL.');
46
+ }
47
+ if (
48
+ !(
49
+ url.protocol === 'https:' ||
50
+ (url.protocol === 'http:' &&
51
+ (url.hostname === 'localhost' ||
52
+ url.hostname === '[::1]' ||
53
+ /^127(?:\.\d{1,3}){3}$/.test(url.hostname)))
54
+ ) ||
55
+ url.username ||
56
+ url.password ||
57
+ url.search ||
58
+ url.hash
59
+ )
60
+ throw new SandboxSetupError(
61
+ 'INVALID_INPUT',
62
+ 'API base URL must use HTTPS (HTTP is allowed on loopback), without credentials, query or fragment.',
63
+ );
64
+ }
65
+ const credential = field('byokCredential', 16384);
66
+ const sameDestination =
67
+ previous?.kind === kind &&
68
+ (previous.baseURL ?? '') === baseURL &&
69
+ (previous.resourceName ?? '') === resourceName;
70
+ return {
71
+ kind,
72
+ model,
73
+ ...(baseURL ? { baseURL } : {}),
74
+ ...(resourceName ? { resourceName } : {}),
75
+ credential: credential
76
+ ? await sealSecret(root, credential)
77
+ : value.byokClearCredential === true || !sameDestination
78
+ ? null
79
+ : (previous?.credential ?? null),
80
+ };
81
+ }
@@ -426,6 +426,8 @@ export interface SafeSandboxConfiguration {
426
426
  readonly kind: AiProviderKind;
427
427
  readonly model: string;
428
428
  readonly credentialFingerprint: string | null;
429
+ readonly baseURL?: string;
430
+ readonly resourceName?: string;
429
431
  } | null;
430
432
  readonly github: GitHubDeliveryConfiguration & {
431
433
  readonly tokenFingerprint: string | null;
@@ -446,6 +448,12 @@ export function safeConfiguration(
446
448
  byok: configuration.byok
447
449
  ? {
448
450
  kind: configuration.byok.kind,
451
+ ...(configuration.byok.baseURL
452
+ ? { baseURL: configuration.byok.baseURL }
453
+ : {}),
454
+ ...(configuration.byok.resourceName
455
+ ? { resourceName: configuration.byok.resourceName }
456
+ : {}),
449
457
  model: configuration.byok.model,
450
458
  credentialFingerprint: secretFingerprint(
451
459
  configuration.byok.credential,
@@ -0,0 +1,68 @@
1
+ import { relative } from 'node:path';
2
+ import type { ViteDevServer } from 'vite';
3
+
4
+ /* Watch notifications describe source changes, not successful preview builds.
5
+ Collect one bounded burst so atomic saves do not flood the terminal. */
6
+ export function watchSandboxReloads(
7
+ server: Pick<ViteDevServer, 'watcher' | 'httpServer'>,
8
+ appRoot: string,
9
+ write: (message: string) => void,
10
+ verbose = false,
11
+ ): () => void {
12
+ const groups = new Map<string, Set<string>>();
13
+ let timer: ReturnType<typeof setTimeout> | undefined;
14
+ let pendingFiles = 0;
15
+ const flush = () => {
16
+ if (timer) clearTimeout(timer);
17
+ timer = undefined;
18
+ for (const [label, files] of groups) {
19
+ if (verbose) {
20
+ for (const path of files) write(path);
21
+ } else {
22
+ write(
23
+ `${label} · ${files.size} ${files.size === 1 ? 'file' : 'files'} changed`,
24
+ );
25
+ }
26
+ }
27
+ groups.clear();
28
+ pendingFiles = 0;
29
+ };
30
+ const changed = (event: string, path: string) => {
31
+ if (!['add', 'change', 'unlink'].includes(event)) return;
32
+ const normalized = path.replaceAll('\\', '/');
33
+ const draft = normalized.match(
34
+ /\/(?:\.flowdular|\.coreloom)\/sandbox\/sessions\/([^/]+)\/workspace\/modules\/([^/]+)\//,
35
+ );
36
+ const local = relative(appRoot, path);
37
+ if (
38
+ !draft &&
39
+ (local === '..' || local.startsWith('../') || local.startsWith('..\\'))
40
+ )
41
+ return;
42
+ const label = draft
43
+ ? `Draft ${draft[2]} (${draft[1]!.slice(0, 8)})`
44
+ : 'Sandbox';
45
+ const files = groups.get(label) ?? new Set<string>();
46
+ if (!files.has(path)) pendingFiles += 1;
47
+ files.add(path);
48
+ groups.set(label, files);
49
+ // Fixed window, not a reset-on-every-event debounce: continuous writes
50
+ // remain visible and retained notifications cannot grow without a flush.
51
+ if (!timer) {
52
+ timer = setTimeout(flush, 750);
53
+ timer.unref?.();
54
+ }
55
+ if (pendingFiles >= 256) flush();
56
+ };
57
+ const dispose = () => {
58
+ if (timer) clearTimeout(timer);
59
+ timer = undefined;
60
+ groups.clear();
61
+ pendingFiles = 0;
62
+ server.watcher.off('all', changed);
63
+ server.httpServer?.off('close', dispose);
64
+ };
65
+ server.watcher.on('all', changed);
66
+ server.httpServer?.once('close', dispose);
67
+ return dispose;
68
+ }
@@ -1,3 +1,4 @@
1
+ import { byokSettings } from './byok-settings.ts';
1
2
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
3
  import { dirname } from 'node:path';
3
4
  import { ServerRoute, type Context } from '@octanejs/app-core';
@@ -665,6 +666,7 @@ export function createSandboxRoutes(
665
666
  sessionId: string,
666
667
  input: {
667
668
  message: string;
669
+ freshContext?: boolean;
668
670
  role?: string;
669
671
  module?: string;
670
672
  driver?: string;
@@ -866,7 +868,6 @@ export function createSandboxRoutes(
866
868
  });
867
869
  const value = await body(context.request);
868
870
  const token = optionalText(value, 'platformToken', 4_096);
869
- const byokCredential = optionalText(value, 'byokCredential', 16_384);
870
871
  const githubToken = optionalText(value, 'githubToken', 16_384);
871
872
  const configuration = runtime.configuration();
872
873
  if (value.disconnect === true) {
@@ -999,6 +1000,11 @@ export function createSandboxRoutes(
999
1000
  }),
1000
1001
  };
1001
1002
  }
1003
+ const byok = await byokSettings(
1004
+ runtime.workspaceRoot,
1005
+ value,
1006
+ configuration.byok,
1007
+ );
1002
1008
  const connection = await runtime.update({
1003
1009
  ...(platformUrl === null ? {} : { platformUrl }),
1004
1010
  ...(token
@@ -1015,23 +1021,7 @@ export function createSandboxRoutes(
1015
1021
  ...(value.previewData === 'fixtures' || value.previewData === 'bridge'
1016
1022
  ? { previewData: value.previewData }
1017
1023
  : {}),
1018
- ...(value.byokKind === undefined
1019
- ? {}
1020
- : {
1021
- byok: {
1022
- kind: text(value, 'byokKind', 40) as never,
1023
- model: text(value, 'byokModel', 160),
1024
- ...(optionalText(value, 'byokResourceName', 160)
1025
- ? { resourceName: text(value, 'byokResourceName', 160) }
1026
- : {}),
1027
- ...(optionalText(value, 'byokBaseUrl', 2_048)
1028
- ? { baseURL: text(value, 'byokBaseUrl', 2_048) }
1029
- : {}),
1030
- credential: byokCredential
1031
- ? await sealSecret(runtime.workspaceRoot, byokCredential)
1032
- : (configuration.byok?.credential ?? null),
1033
- },
1034
- }),
1024
+ ...(byok === undefined ? {} : { byok }),
1035
1025
  ...(githubPatchRequested ? { github } : {}),
1036
1026
  ...(githubToken
1037
1027
  ? {
@@ -1537,6 +1527,7 @@ export function createSandboxRoutes(
1537
1527
  sessionId,
1538
1528
  {
1539
1529
  message: text(value, 'message', 20_000),
1530
+ freshContext: value.freshContext === true,
1540
1531
  ...(optionalText(value, 'role', 64)
1541
1532
  ? { role: text(value, 'role', 64) }
1542
1533
  : {}),
@@ -90,6 +90,7 @@ export interface TurnContext {
90
90
  }
91
91
 
92
92
  export interface TurnInput {
93
+ readonly freshContext?: boolean;
93
94
  readonly sessionId: string;
94
95
  readonly message: string;
95
96
  readonly role?: string;
@@ -687,7 +688,33 @@ export async function* runTurn(
687
688
  );
688
689
 
689
690
  const history = historyFrom(await readChat(context.workspaceRoot, session));
690
- const resumeId = session.resumeIds[driverId] ?? null;
691
+ // Provider conversations retain instructions and tool history. Reuse one only
692
+ // while its role, module, skill, write ceiling, specification and model match.
693
+ const resumePrefix = `${driverId}:scope:`;
694
+ const resumeKey =
695
+ resumePrefix +
696
+ createHash('sha256')
697
+ .update(
698
+ JSON.stringify([
699
+ instruction,
700
+ active.specHash ?? null,
701
+ session.model ?? context.configuration.driverModel,
702
+ ]),
703
+ )
704
+ .digest('hex');
705
+ const previousKeys = Object.keys(session.resumeIds).filter(
706
+ (key) => key === driverId || key.startsWith(resumePrefix),
707
+ );
708
+ const resumeId = input.freshContext
709
+ ? null
710
+ : (session.resumeIds[resumeKey] ?? null);
711
+ if (input.freshContext || (!resumeId && previousKeys.length > 0)) {
712
+ yield await appendChatEntry(context.workspaceRoot, session, {
713
+ kind: 'system',
714
+ role: roleId,
715
+ text: `Starting fresh agent context for ${role.name} in ${active.id} with the current task and write scope. Draft files, approved specification and sandbox history are preserved; the agent receives the brief and recent messages.`,
716
+ });
717
+ }
691
718
  let nextResumeId = resumeId;
692
719
  let failed = false;
693
720
  let closing = '';
@@ -709,7 +736,7 @@ export async function* runTurn(
709
736
  systemInstruction: instruction,
710
737
  prompt: attachmentNote ? `${attachmentNote}\n\n${message}` : message,
711
738
  resumeId,
712
- history: history.slice(0, -1),
739
+ history: [{ role: 'user', text: session.brief }, ...history.slice(0, -1)],
713
740
  model: session.model,
714
741
  signal: input.signal,
715
742
  })) {
@@ -962,10 +989,12 @@ export async function* runTurn(
962
989
  );
963
990
  }
964
991
 
992
+ const resumeIds = { ...session.resumeIds };
993
+ // Retain at most one conversation per driver, including after many handoffs.
994
+ for (const key of previousKeys) delete resumeIds[key];
995
+ if (nextResumeId) resumeIds[resumeKey] = nextResumeId;
965
996
  const updated = await updateSession(context.workspaceRoot, session.id, {
966
- resumeIds: nextResumeId
967
- ? { ...session.resumeIds, [driverId]: nextResumeId }
968
- : session.resumeIds,
997
+ resumeIds,
969
998
  state: failed
970
999
  ? 'failed'
971
1000
  : handoff.kind === 'approval' || handoff.kind === 'question'