framework
Version:
The (AI) Framework: turnkey, zero-config AI orchestration that wraps a coding-agent CLI (Claude Code) as a black box and takes you from an idea to a running app. Vite for AI.
70 lines • 3.22 kB
JavaScript
import { appendFile, mkdir, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { isHandoffLevel } from './handoff-level.js';
import { FRAMEWORK_DIR } from './store/index.js';
import { JsonlTailer, followFile } from './jsonl-tail.js';
/**
* The dashboard-to-agent control channel (#344): the reverse of the event log.
* Events flow run -> `.the-framework/events.jsonl` -> daemon -> browser; steering
* flows browser -> daemon -> `.the-framework/control.jsonl` -> run. The daemon
* appends a {@link ControlEntry} per Stop click / choice pick, and the agent tails
* the file, aborting or resolving its parked gate. Same file-is-the-seam design
* as the forward direction — no run<->daemon IPC.
*/
/** The control log filename under `.the-framework/`. */
export const CONTROL_FILE = 'control.jsonl';
/** The control log path for a workspace. */
export function controlPath(cwd) {
return join(cwd, FRAMEWORK_DIR, CONTROL_FILE);
}
/** Append one entry to the workspace's control log, creating it as needed. */
export async function appendControl(cwd, entry) {
await mkdir(join(cwd, FRAMEWORK_DIR), { recursive: true });
await appendFile(controlPath(cwd), JSON.stringify(entry) + '\n');
}
/**
* Truncate the control log. An agent calls this at start so a previous agent's picks
* can never fire into this one (gate ids like `plan-approval` repeat across runs).
*/
export async function resetControl(cwd) {
await mkdir(join(cwd, FRAMEWORK_DIR), { recursive: true });
await writeFile(controlPath(cwd), '');
}
/**
* Tail the workspace's control log, dispatching each well-formed entry as it is
* appended. An `fs.watch` on `.the-framework/` plus a poll backstop, mirroring the
* daemon's event tail (`fs.watch` is unreliable across platforms). Malformed or
* unknown lines are skipped so a bad write can never crash an agent.
*/
export function watchControl(cwd, onEntry, pollMs = 300) {
const tailer = new JsonlTailer(controlPath(cwd), entry => {
if (isControlEntry(entry))
onEntry(entry);
});
// unref: never keep the process alive just for steering.
const stop = followFile(join(cwd, FRAMEWORK_DIR), () => tailer.pull(), { pollMs, unref: true });
return { close: stop };
}
/** Shape-check a parsed line. A multi-select pick may legitimately be `[]`. */
function isControlEntry(value) {
if (!value || typeof value !== 'object')
return false;
const v = value;
if (v['kind'] === 'stop')
return true;
if (v['kind'] === 'merge')
return true;
// The rung must be one of the four: a half-written entry would otherwise disarm by accident,
// and this decides whether the session's work reaches the remote at all.
if (v['kind'] === 'handoff')
return isHandoffLevel(v['level']);
if (v['kind'] === 'message')
return typeof v['text'] === 'string' && v['text'].length > 0;
if (v['kind'] !== 'choice')
return false;
if (typeof v['id'] !== 'string' || !v['id'])
return false;
const pick = v['pick'];
return typeof pick === 'string' || (Array.isArray(pick) && pick.every(p => typeof p === 'string'));
}
//# sourceMappingURL=control.js.map