@flowdular/sandbox 0.2.5 → 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
|
@@ -146,6 +146,12 @@ browser. An empty key field preserves the existing key only when the provider
|
|
|
146
146
|
and destination are unchanged. The settings also let you clear the key or
|
|
147
147
|
remove BYOK entirely.
|
|
148
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
|
+
|
|
149
155
|
For a long CLI conversation, select **Fresh agent context** before sending the
|
|
150
156
|
next message. It starts a new CLI conversation with the original brief and
|
|
151
157
|
recent sandbox messages, preserving draft files, the approved specification
|
|
@@ -155,6 +161,10 @@ Claude activity is shown from the start of streamed response blocks, with
|
|
|
155
161
|
completed reasoning and tool events following as they arrive. This does not
|
|
156
162
|
reduce provider queue or inference time.
|
|
157
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
|
+
|
|
158
168
|
## Sessions
|
|
159
169
|
|
|
160
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)}`);
|
|
@@ -3,6 +3,7 @@ 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.
|
|
7
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.
|
|
8
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.
|
|
@@ -40,7 +41,7 @@ export function composeSessionFacts(context: InstructionContext): string {
|
|
|
40
41
|
`- Module directory in this workspace: ${context.modulePath}`,
|
|
41
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'}`,
|
|
42
43
|
`- Blueprint: ${context.blueprint}`,
|
|
43
|
-
`- Paths you may write: ${context.allowedPaths.join(', ')}`,
|
|
44
|
+
`- Paths you may write: ${context.allowedPaths.length ? context.allowedPaths.join(', ') : 'none (read-only)'}`,
|
|
44
45
|
];
|
|
45
46
|
if (context.skill) {
|
|
46
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
|
}
|
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",
|
|
@@ -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/turns.ts
CHANGED
|
@@ -688,14 +688,31 @@ export async function* runTurn(
|
|
|
688
688
|
);
|
|
689
689
|
|
|
690
690
|
const history = historyFrom(await readChat(context.workspaceRoot, session));
|
|
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
|
+
);
|
|
691
708
|
const resumeId = input.freshContext
|
|
692
709
|
? null
|
|
693
|
-
: (session.resumeIds[
|
|
694
|
-
if (input.freshContext) {
|
|
710
|
+
: (session.resumeIds[resumeKey] ?? null);
|
|
711
|
+
if (input.freshContext || (!resumeId && previousKeys.length > 0)) {
|
|
695
712
|
yield await appendChatEntry(context.workspaceRoot, session, {
|
|
696
713
|
kind: 'system',
|
|
697
714
|
role: roleId,
|
|
698
|
-
text:
|
|
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.`,
|
|
699
716
|
});
|
|
700
717
|
}
|
|
701
718
|
let nextResumeId = resumeId;
|
|
@@ -973,8 +990,9 @@ export async function* runTurn(
|
|
|
973
990
|
}
|
|
974
991
|
|
|
975
992
|
const resumeIds = { ...session.resumeIds };
|
|
976
|
-
|
|
977
|
-
|
|
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;
|
|
978
996
|
const updated = await updateSession(context.workspaceRoot, session.id, {
|
|
979
997
|
resumeIds,
|
|
980
998
|
state: failed
|