Skip to content

Commit 8327a75

Browse files
ndemiancclaude
andcommitted
Atom++ Agent Sketch: run persistence + prompt-to-flow + connector archetypes
Never lose a paid run: - sketch.js writeSession/readSession (atomic) + init restore; the webview autosaves the whole session (graph + last-run results) to .atompp/.session.json on every mutation (debounced) and immediately on run completion / clear, and restores it on reopen. nodeDone now carries FULL output (not a 400-char preview) so a mid-run/interrupted run never persists truncated text; restore keeps unknown-agent nodes so an extension update can't wipe a run. Export button → a Markdown deliverable. Design + free-vs-paid cloud-sync monetization: docs/atompp-agent-runs-persistence.md. Prompt -> whole flow: - Empty board + a description → generateFlow() designs a complete DAG (populated board still edits via ops). normalizeFlowSpec resolves edges by the model's own labels (first-declared wins; dedup labels it never saw aren't registered) and accepts 1-based index refs, so fan-in/out isn't miswired. - agentCatalog.js: +2 groups (Content & Editorial, Connectors & Triggers) and 13 archetypes (news-collector, press-editor, copywriter, telegram-publisher, email-sender, scheduler, …). SK1 runs them text-only (a connector emits the payload it WOULD send); real side effects + cron = SK2/SK3, designed in docs/atompp-agent-sketch-design.md §4. .gitignore: ignore /.atompp/ (per-workspace runtime state) and local /UNSIGNED-*.app.zip artifacts. Hardened via three adversarial-review passes (Clear TOCTOU, persistence data-loss x3, generator edge-wiring x4) — all fixed. 75 unit tests green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 7c0d7e1 commit 8327a75

5 files changed

Lines changed: 545 additions & 11 deletions

File tree

‎.gitignore‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@
1010
# Distributable disk images (produced by scripts/make-dmg.sh)
1111
/*.dmg
1212

13+
# Agent Sketch runtime state — per-workspace (autosaved session, sketches, exported runs); not source
14+
/.atompp/
15+
16+
# Unsigned app artifacts pulled from the release CI and signed locally (see docs/RELEASING.md §7)
17+
/UNSIGNED-*.app.zip
18+
1319
# Node / OS noise
1420
node_modules/
1521
.DS_Store

‎docs/atompp-agent-sketch-design.md‎

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -118,9 +118,47 @@ files). Costs shown are planning estimates from list prices — the provider bil
118118

119119
| Phase | Deliverable |
120120
|---|---|
121-
| **SK2** | **Tool-using nodes**: a node can run the full `agent.js` loop (read/edit/run with Keep-Undo review + per-run checkpoint) so a sketched flow actually edits the workspace; live streamed output in the inspector; run history. |
122-
| **SK3** | **Topologies**: coordinator/hierarchical patterns (a queen node that dispatches + collects, ruflo-style), conditional edges, retry/verify loops; per-flow token budgets with early stop. |
123-
| **SK4** | **Interop**: import/export ruflo swarm configs; publish sketches as shareable templates; sketch → `atompp.ai` chat-agent handoff ("run this flow on the current repo"). |
121+
| **SK1.3** | **Prompt → whole flow** (shipped): type a plain-English description into the (empty) board and the active model designs a complete DAG — `generateFlow()` returns `{goal, nodes, edges}`, normalized to a loadable sketch and auto-laid-out. Empty canvas = *build*; populated canvas = *edit* (the existing ops command). |
122+
| **SK2** | **Tool-using + connector nodes**: a node runs the full `agent.js` loop (read/edit/run with Keep-Undo review) so a sketched flow edits the workspace; **connector nodes actually act** — Telegram/Slack post, email send, webhook call, file write — behind credentials + per-flow permission (see §4). |
123+
| **SK3** | **Triggers & topologies**: a `scheduler`/`webhook-trigger` root fires the flow on a **cron cadence / inbound event** (Managed-Agents-style scheduled deployment); coordinator/hierarchical patterns, conditional edges, retry/verify loops; per-flow token budgets. |
124+
| **SK4** | **Interop**: import/export ruflo swarm configs; publish sketches as shareable templates; sketch → `atompp.ai` chat-agent handoff. |
125+
126+
## 4. Node tiers: text → tools → connectors → triggers
127+
128+
Not every node is the same *kind* of thing. The catalog now spans four execution tiers; SK1 runs them
129+
all as **text-only** (a connector emits the payload it *would* send; a trigger emits the context it
130+
*would* fire with), and later phases make the side-effecting tiers real — each with the safety it needs.
131+
132+
| Tier | Examples | SK1 (today) | Later — what "real" needs |
133+
|---|---|---|---|
134+
| **Text** | coder, researcher, news-collector, copywriter, editor-in-chief | one LLM turn, in/out text | — (this is the whole SK1 model) |
135+
| **Tool / file** | file-writer, and any node run through `agent.js` | outputs the file + contents it *would* write | SK2: writes via the same **Keep/Undo review + per-run checkpoint** the chat agent uses |
136+
| **Connector (actuator)** | telegram-publisher, email-sender, slack-publisher, webhook-caller | outputs the exact message/email/HTTP payload it *would* send | SK2: real send. Needs **(a)** credentials in SecretStorage/keychain (bot token, SMTP creds — never in the sketch file), **(b)** a **per-flow send permission** the user grants once (sending is outward-facing — mirrors the harness "Explicit permission" rule: publishing/messaging requires a clear yes), **(c)** an **allowlist** (which chat/recipient/host), **(d)** a dry-run preview (show the message, click Send) |
137+
| **Trigger** | scheduler (cron), webhook-trigger | the ROOT node; outputs the run context ("collect news since the last run at <time>") | SK3: a **scheduled/triggered run** — cron cadence or inbound webhook fires the downstream DAG unattended. Maps onto the Managed-Agents *scheduled deployment* pattern (cron + per-firing run records) or a local scheduler; ties into the run-persistence archive so every firing is logged |
138+
139+
**Why the tiering is the point:** it keeps SK1 shippable and safe (nothing sends, nothing runs on a
140+
timer — you can design and cost a Telegram-publishing pipeline today and see exactly what it *would*
141+
post), while giving a clear, honest path to real automation where each dangerous capability (send,
142+
write, schedule) is gated by the credential + permission model it deserves. The BYOK rule is unchanged
143+
— connectors use the *user's own* integration credentials; Atom++ never proxies or stores the content.
144+
145+
### Worked example (what the generator produces)
146+
Prompt: *"5 agents collect recent AI & agentic-AI news; a head-of-press-release edits them; a
147+
copywriter verifies and composes one post; publish it to Telegram; run it daily."* →
148+
149+
```
150+
daily-trigger (scheduler)
151+
→ [ai-research · agentic · products · funding · safety] (5 news-collectors, parallel)
152+
→ press-desk (press-editor)
153+
→ copywriter (copywriter)
154+
→ editor-approval (editor-in-chief)
155+
→ telegram-post (telegram-publisher)
156+
```
157+
158+
Today that runs text-only: the collectors produce cited news summaries, the copywriter composes the
159+
post, and `telegram-post` outputs the exact Markdown message it *would* send — metered, costed, and
160+
persisted. SK2 turns `telegram-post` into a real send (bot token + "allow send to @channel"); SK3
161+
makes `daily-trigger` actually fire the flow every morning.
124162

125163
## 3. Attribution & licensing
126164
Agent names/roles derived from ruflo's `.claude/agents/` library (MIT © 2024–2026 ruvnet) — metadata

‎extensions/atom-ai/media/sketch.html‎

Lines changed: 150 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -787,6 +787,12 @@
787787
placeholder="Goal of this flow — every agent sees it (e.g. “Build and test a rate limiter”)"
788788
/>
789789
<button id="save">Save</button>
790+
<button
791+
id="export"
792+
title="Export this run as a Markdown document you can share"
793+
>
794+
Export
795+
</button>
790796
<select
791797
id="tplSel"
792798
title="Load a ready-made template (or drag one from the palette)"
@@ -929,6 +935,8 @@ <h3 id="modalTitle"></h3>
929935
consensus: "#b48ead",
930936
performance: "#e5a56b",
931937
data: "#61c8af",
938+
content: "#d99ae0",
939+
connectors: "#e0b062",
932940
specialized: "#9aa5ce",
933941
};
934942

@@ -949,6 +957,8 @@ <h3 id="modalTitle"></h3>
949957
let truncByNode = {}; // nodeId -> true when the node hit the per-node token cap
950958
let turnsByNode = {}; // nodeId -> how many turns it took (auto-continuation)
951959
let ACTIVE_TIERS = null; // {fast,balanced,powerful} model ids for the active provider
960+
let persistTimer = null;
961+
let persistReady = false; // gate autosave until the initial session restore is done
952962
let pan = { x: 40, y: 20 };
953963
let zoom = 1;
954964
const clampZoom = (z) => Math.max(0.2, Math.min(2.5, z));
@@ -1329,7 +1339,11 @@ <h3 id="modalTitle"></h3>
13291339
svg.appendChild(p);
13301340
}
13311341
$("tNodes").textContent = nodes.length;
1342+
$("cmd").placeholder = nodes.length
1343+
? "Ask the board… e.g. “add a security-auditor after coder”, “set every agent to Opus 4.8”"
1344+
: "Describe a flow to build… e.g. “5 agents collect AI news, a copywriter composes a post, publish to Telegram”";
13321345
refreshTotals();
1346+
schedulePersist(); // every mutation flows through render(); debounced so drags coalesce
13331347
}
13341348

13351349
function tryConnect(from, to) {
@@ -1616,6 +1630,13 @@ <h3 id="modalTitle"></h3>
16161630
$("stop").onclick = () => vs.postMessage({ type: "stop" });
16171631
$("save").onclick = () =>
16181632
vs.postMessage({ type: "save", sketch: serialize() });
1633+
// name/goal don't go through render(), so persist them directly.
1634+
$("name").oninput = schedulePersist;
1635+
$("goal").oninput = schedulePersist;
1636+
$("export").onclick = () => {
1637+
if (!Object.keys(usageByNode).length && !nodes.length) return;
1638+
vs.postMessage({ type: "exportMarkdown", session: fullSession() });
1639+
};
16191640
$("loadSel").onchange = () => {
16201641
const v = $("loadSel").value;
16211642
$("loadSel").value = "";
@@ -1659,6 +1680,7 @@ <h3 id="modalTitle"></h3>
16591680
turnsByNode = {};
16601681
select(null);
16611682
render();
1683+
persistNow(); // overwrite the saved session so a reopen doesn't restore the cleared graph
16621684
notify("Sketch cleared.", "info");
16631685
};
16641686

@@ -1757,11 +1779,76 @@ <h3 id="modalTitle"></h3>
17571779
if (e.target === $("modal")) closeModal(false); // click the backdrop = cancel
17581780
};
17591781

1782+
// ---------- session autosave (never lose a paid run on reload) ----------
1783+
// Bundle the canvas + the last run's results and mirror it to disk (.atompp/.session.json,
1784+
// written by the extension). This is app-internal restore state, not a deliverable.
1785+
function fullSession() {
1786+
const s = serialize(); // { name, goal, nodes, edges }
1787+
return {
1788+
name: s.name,
1789+
goal: s.goal,
1790+
nodes: s.nodes,
1791+
edges: s.edges,
1792+
results: {
1793+
outputs: outputsByNode,
1794+
usage: usageByNode,
1795+
turns: turnsByNode,
1796+
trunc: truncByNode,
1797+
},
1798+
};
1799+
}
1800+
function schedulePersist() {
1801+
if (!persistReady) return;
1802+
if (persistTimer) clearTimeout(persistTimer);
1803+
persistTimer = setTimeout(() => {
1804+
persistTimer = null;
1805+
vs.postMessage({ type: "persist", session: fullSession() });
1806+
}, 800); // debounce drags/typing into one write
1807+
}
1808+
function persistNow() {
1809+
if (!persistReady) return;
1810+
if (persistTimer) {
1811+
clearTimeout(persistTimer);
1812+
persistTimer = null;
1813+
}
1814+
vs.postMessage({ type: "persist", session: fullSession() });
1815+
}
1816+
// Restore the canvas + last-run meters from a saved session (on reopen). Results are kept
1817+
// only for surviving node ids so a hand-edited/renamed graph can't graft stale outputs.
1818+
function restoreSession(sess) {
1819+
const s = sanitizeSketch(sess, { keepUnknown: true }); // never drop the user's own run
1820+
if (!s.nodes.length) return false;
1821+
$("name").value = s.name;
1822+
$("goal").value = s.goal;
1823+
nodes = s.nodes;
1824+
edges = s.edges;
1825+
seq = nodes.reduce(
1826+
(mx, n) =>
1827+
Math.max(mx, parseInt(String(n.id).replace(/\D/g, "")) || 0),
1828+
0,
1829+
);
1830+
const ids = new Set(nodes.map((n) => n.id));
1831+
const pick = (o) =>
1832+
Object.fromEntries(
1833+
Object.entries(o || {}).filter(([k]) => ids.has(k)),
1834+
);
1835+
const r = sess.results || {};
1836+
usageByNode = pick(r.usage);
1837+
outputsByNode = pick(r.outputs);
1838+
turnsByNode = pick(r.turns);
1839+
truncByNode = pick(r.trunc);
1840+
select(null);
1841+
render();
1842+
fitView();
1843+
return true;
1844+
}
1845+
17601846
// Loaded sketches come from on-disk JSON (hand-editable) or templates — coerce to a safe,
17611847
// renderable shape so a malformed/hostile file can't break the canvas, collide node ids, or
17621848
// inject a prototype-polluting key. Drops unknown-agent nodes and dangling/self/dup edges.
1763-
function sanitizeSketch(s) {
1849+
function sanitizeSketch(s, opts) {
17641850
s = s && typeof s === "object" ? s : {};
1851+
const keepUnknown = !!(opts && opts.keepUnknown);
17651852
const str = (v, d) => (typeof v === "string" ? v : d || "");
17661853
const num = (v) => (typeof v === "number" && isFinite(v) ? v : 0);
17671854
const nodes = [];
@@ -1770,7 +1857,10 @@ <h3 id="modalTitle"></h3>
17701857
if (!n || typeof n !== "object") continue;
17711858
const id = String(n.id == null ? "" : n.id);
17721859
if (!id || id.indexOf("__") === 0 || ids.has(id)) continue; // empty / __proto__ / dup
1773-
if (!CAT.byId.has(n.agentId)) continue; // unknown agent
1860+
// Restoring the user's own session keeps unknown-agent nodes (an extension update may have
1861+
// renamed/removed an agent id) so a catalog change can't wipe a paid run — render() falls
1862+
// back to generic styling. Loading templates/files still drops unknown agents.
1863+
if (!keepUnknown && !CAT.byId.has(n.agentId)) continue;
17741864
ids.add(id);
17751865
nodes.push({
17761866
id,
@@ -1971,8 +2061,14 @@ <h3 id="modalTitle"></h3>
19712061
cmdBusy = true;
19722062
$("cmdbar").classList.add("busy");
19732063
$("cmdRun").disabled = true;
1974-
setStatus("Thinking…", "info");
1975-
vs.postMessage({ type: "command", text, sketch: serialize() });
2064+
// Empty canvas → BUILD a whole flow from the prompt; populated canvas → EDIT it with ops.
2065+
if (nodes.length) {
2066+
setStatus("Thinking…", "info");
2067+
vs.postMessage({ type: "command", text, sketch: serialize() });
2068+
} else {
2069+
setStatus("Designing your flow…", "info");
2070+
vs.postMessage({ type: "generate", text });
2071+
}
19762072
}
19772073
function endCommand() {
19782074
cmdBusy = false;
@@ -2022,7 +2118,17 @@ <h3 id="modalTitle"></h3>
20222118
(t) =>
20232119
'<option value="' + esc(t.id) + '">' + esc(t.name) + "</option>",
20242120
).join("");
2025-
render();
2121+
// Restore the canvas + last run's results so a folder reopen doesn't lose work.
2122+
let restored = false;
2123+
if (m.session) restored = restoreSession(m.session);
2124+
if (!restored) render();
2125+
persistReady = true; // arm autosave only after restore, so it can't clobber the save
2126+
if (restored)
2127+
notify(
2128+
"Restored your last session" +
2129+
(Object.keys(usageByNode).length ? " (with run results)" : ""),
2130+
"info",
2131+
);
20262132
break;
20272133
}
20282134
case "runStart":
@@ -2065,7 +2171,7 @@ <h3 id="modalTitle"></h3>
20652171
const n = nodes.find((n) => n.id === m.id);
20662172
if (n) n._status = "done";
20672173
usageByNode[m.id] = m.usage;
2068-
outputsByNode[m.id] = m.preview || "";
2174+
outputsByNode[m.id] = m.output || ""; // full text; feeds autosave + the inspector
20692175
if (m.truncated) truncByNode[m.id] = true;
20702176
if (m.turns) turnsByNode[m.id] = m.turns;
20712177
if (selId === m.id) select(m.id);
@@ -2110,12 +2216,16 @@ <h3 id="modalTitle"></h3>
21102216
}
21112217
if (selId) select(selId);
21122218
render();
2219+
persistNow(); // the run's results are the expensive data — save them immediately
21132220
break;
21142221
}
21152222
case "runError":
21162223
setRunning(false);
21172224
flashErr(m.message);
21182225
break;
2226+
case "exported":
2227+
notify("Exported run → " + (m.rel || "Markdown"), "success");
2228+
break;
21192229
case "uiError":
21202230
// A save/load/clipboard/command failure — NOT a run outcome, so don't touch run state.
21212231
endCommand();
@@ -2132,6 +2242,40 @@ <h3 id="modalTitle"></h3>
21322242
}
21332243
applyOps(m.ops);
21342244
break;
2245+
case "generatedFlow": {
2246+
endCommand();
2247+
$("cmd").value = "";
2248+
if (running) {
2249+
notify("Flow ignored — a run is in progress.", "warn");
2250+
break;
2251+
}
2252+
const g = sanitizeSketch(m.sketch);
2253+
if (!g.nodes.length) {
2254+
notify("The generated flow was empty — try more detail.", "warn");
2255+
break;
2256+
}
2257+
$("name").value = g.name;
2258+
$("goal").value = g.goal;
2259+
nodes = g.nodes;
2260+
edges = g.edges;
2261+
seq = nodes.reduce(
2262+
(mx, n) =>
2263+
Math.max(mx, parseInt(String(n.id).replace(/\D/g, "")) || 0),
2264+
0,
2265+
);
2266+
usageByNode = {};
2267+
outputsByNode = {};
2268+
truncByNode = {};
2269+
turnsByNode = {};
2270+
select(null);
2271+
autoLayout(); // the generated nodes have no positions — arrange + fit
2272+
persistNow();
2273+
notify(
2274+
"Built a " + nodes.length + "-agent flow from your prompt.",
2275+
"success",
2276+
);
2277+
break;
2278+
}
21352279
case "saved":
21362280
$("loadSel").innerHTML =
21372281
'<option value="">Load…</option>' +

0 commit comments

Comments
 (0)