@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
- 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)}`);
@@ -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.5",
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
+ }
@@ -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[driverId] ?? null);
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: 'Starting with fresh agent context. Draft files, approved specification and sandbox history are preserved; the agent receives the brief and recent messages.',
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
- if (nextResumeId) resumeIds[driverId] = nextResumeId;
977
- else if (input.freshContext) delete resumeIds[driverId];
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