@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 +32 -0
- package/bin/flowdular-sandbox.mjs +62 -13
- package/internal/coding-agent/src/drivers/claude-code.ts +22 -3
- package/internal/coding-agent/src/roles/contract.ts +3 -1
- package/internal/coding-agent/src/roles/registry.ts +1 -7
- package/internal/coding-agent/src/types.ts +1 -0
- package/package.json +1 -1
- package/src/App.tsrx +42 -3
- package/src/client/ChatPane.tsrx +15 -2
- package/src/client/ModelSettingsModal.tsrx +161 -0
- package/src/client/WorkspaceMenu.tsrx +7 -0
- package/src/client/api.ts +3 -0
- package/src/client/locales/en.json +22 -1
- package/src/client/locales/pl.json +22 -1
- package/src/server/byok-settings.ts +81 -0
- package/src/server/config.ts +8 -0
- package/src/server/reload-log.ts +68 -0
- package/src/server/routes.ts +9 -18
- package/src/server/turns.ts +34 -5
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
|
-
|
|
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.
|
|
204
|
-
yield { type: 'reasoning', text: block.
|
|
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.
|
|
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
|
/>
|
package/src/client/ChatPane.tsrx
CHANGED
|
@@ -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
|
+
}
|
package/src/server/config.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/server/routes.ts
CHANGED
|
@@ -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
|
-
...(
|
|
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
|
: {}),
|
package/src/server/turns.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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'
|