symbiot 0.63.7 → 0.63.9

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/CHANGELOG.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  What each Symbiot release brought, newest first. When Approve bumps the version, it writes that release here from the tasks it approved, worded as release notes by your connected AI (or in the tasks' own words without one). The app shows the releases newer than yours under "What's new" when it offers an update, and once more after you update.
4
4
 
5
+ ## 0.63.9
6
+
7
+ - **Answering an agent no longer rereads its whole conversation.** Each answer you gave an agent picked its conversation up again, all of it: a one-word "yes" to an agent that had read 195,000 tokens cost 195,000 again, and resumed runs were three quarters of a week's use. Once an agent's conversation is past 60k, your answer now starts it fresh (about 22k), with a note of where it got to in its own words. Short ones still carry on as before.
8
+ - **See how much an agent carries.** A run on the Workdesk shows its context: what it rereads with every step.
9
+
10
+ ## 0.63.8
11
+
12
+ - **No more file names on permission questions, anywhere.** When an agent asked for a permission, Home said it in plain words with an Allow button, but the same question on the Workdesk, on a project's card and on your phone still read "allow the list in .symbiot/allowlist.proposed.json". Every screen now says what the agent wants to do ("connect to the computer at 13.51.104.186") with Allow.
13
+ - **Allow sticks.** Saying Allow along with a few words of your own that sounded like git ("check the deployed commit, git status and served files") was taken for a git chore and handed back to the agent, which can't give itself permission, so it asked you again. A permission is now always turned on as one.
14
+
5
15
  ## 0.63.7 — 2026-10-12
6
16
 
7
17
  - **`symbiot capture Symbiot` works in one go.** It used to refuse, because your running Symbiot shows your real work, and needed a copy started by hand. It now starts a throwaway Symbiot with a made-up company in it (Acme: its repos, its agents' questions, what arrived, its week, a Marketing draft), captures Home, the Workdesk, the Dashboard, the Week, the Map and Marketing, then stops it and deletes it. Symbiot's own tray can be brought up to date after every release.
package/agents.mjs CHANGED
@@ -12,6 +12,7 @@ import { randomBytes, createHash } from "node:crypto";
12
12
  import { CONFIG_DIR, loadConfig, saveConfig, loadTasks, TASK_MAX, clipWords, sameTask, taskWords, sh, hasCmd } from "./core.mjs";
13
13
  import { parseRun, lastRunText, readRunLog, briefPlan, stepOf, runLine } from "./work.mjs";
14
14
  import { noteDuration, kindOf } from "./estimate.mjs";
15
+ import { plainAsk } from "./plain.mjs";
15
16
  import { parseFacts, leftToYou, gitWork, agentsGitWork, agentsPosting, linkedAsks, linkedChore, PUSH_OK } from "./handover.mjs";
16
17
  import { scanRoots, repoPathMap } from "./scan.mjs";
17
18
  import { knowledgeFolders } from "./knowledge.mjs";
@@ -235,13 +236,28 @@ function sandboxEnv(box, { token = () => (hasCmd("gh") ? sh("gh auth token").tri
235
236
  // For Settings → Handoff: { on, ready, missing } (on: repo runs get it).
236
237
  function sandboxState() { const n = sandboxNeeds(); return { on: loadConfig().agentSandbox !== false && trustFull(), ready: n.ready, missing: n.missing, ...(n.unsupported ? { unsupported: true } : {}) }; }
237
238
  const SESSION = "session.json", RESUME_MAX = 8, RESUME_AGE = 2 * 86400000;
239
+ // A resumed run rereads its whole conversation with every step: a one-word answer
240
+ // to an agent that had read 195k tokens cost 195k again, and 55 resumed runs were
241
+ // three quarters of a week's use (2026-10-12). Past RESUME_CTX, an answer starts
242
+ // it fresh instead (about 22k to start), with its own last words in CARRYON.md.
243
+ const RESUME_CTX = 60000, CARRYON = "CARRYON.md";
244
+ const tooLong = (s) => !!s && (s.ctx || 0) > RESUME_CTX;
245
+ const CARRY_PROMPT = HANDOFF_PROMPT + " The user has just answered your questions: the newest entries in .symbiot/ANSWERS.md. This is a fresh start because the conversation of the last run had grown too long to carry on, so read .symbiot/CARRYON.md first: where that run got to, in its own words. Do not redo what it says is done."; // no quotes, $ or backticks: like HANDOFF_PROMPT it may pass through two shells
246
+ // The last run's own account of where it got to, for the fresh run that carries on.
247
+ function carryOn(path) {
248
+ let said = ""; try { said = parseRun(lastRunText(readSymbiot(path, "agent.log"))).final || ""; } catch {}
249
+ try { writeFileSync(join(path, ".symbiot", CARRYON), `# Where the last run got to\n\nIts conversation had grown too long to resume, so this run starts fresh. What it said at the end:\n\n${said.slice(0, 6000) || "(it didn't say)"}\n`); } catch {}
250
+ return CARRY_PROMPT;
251
+ }
238
252
  const RESUME_PROMPT = "The user has answered: read the newest entries in .symbiot/ANSWERS.md and carry on with .symbiot/TASKS.md from where you were: finish the item you were on, then the next. Same rules as before.";
239
253
  function sessionOf(path) { try { return JSON.parse(readSymbiot(path, SESSION)) || null; } catch { return null; } }
240
254
  // Resume when an answer has come in since its last run, and the conversation is fresh enough
241
255
  function resumeFor(path) {
242
256
  const s = sessionOf(path); if (!s || !s.id || (s.runs || 0) >= RESUME_MAX || Date.now() - (s.at || 0) > RESUME_AGE) return null;
243
257
  let answered = 0; try { answered = statSync(join(path, ".symbiot", "ANSWERS.md")).mtimeMs; } catch {}
244
- return answered > (s.at || 0) ? s : null;
258
+ if (!(answered > (s.at || 0))) return null;
259
+ if (s.ctx == null) { try { s.ctx = parseRun(lastRunText(readSymbiot(path, "agent.log"))).ctx || 0; } catch {} } // a session noted before 0.63.9: its size from the log
260
+ return s;
245
261
  }
246
262
  function noteSession(path, code, resumed) {
247
263
  const f = join(path, ".symbiot", SESSION);
@@ -250,7 +266,7 @@ function noteSession(path, code, resumed) {
250
266
  if (resumed && /No conversation found|session .*not found/i.test(readSymbiot(path, "agent.log").slice(-2000))) { try { unlinkSync(f); } catch {} return "lost"; }
251
267
  return null;
252
268
  }
253
- if (run.session) { const prev = sessionOf(path) || {}; try { writeFileSync(f, JSON.stringify({ id: run.session, at: Date.now(), runs: prev.id === run.session ? (prev.runs || 1) + 1 : 1 })); } catch {} }
269
+ if (run.session) { const prev = sessionOf(path) || {}; try { writeFileSync(f, JSON.stringify({ id: run.session, at: Date.now(), runs: prev.id === run.session ? (prev.runs || 1) + 1 : 1, ...(run.ctx ? { ctx: run.ctx } : {}) })); } catch {} }
254
270
  return run.session || null;
255
271
  }
256
272
  // One agent per folder: two identical runs once started on the same repo 6s
@@ -277,8 +293,8 @@ function runHandoff(repoPath, { force = false, lost = false } = {}) {
277
293
  const idle = !lost && nothingToDo(repoPath); if (idle) return { blocked: true, idle: true, questions: 0, note: idle };
278
294
  const scoped = withScope(withConnectors(tmpl, repoPath), repoPath), box = isClaudeCmd(scoped) && trustFull() ? sandboxFor(repoPath, scoped) : null; // a repo run's sandbox, on top of the membrane
279
295
  tmpl = withStream(withTrust(scoped, box));
280
- const resume = isClaudeCmd(tmpl) ? resumeFor(repoPath) : null;
281
- const runCmd = resume ? tmpl + ` --resume ${resume.id}` : tmpl;
296
+ const resume = isClaudeCmd(tmpl) ? resumeFor(repoPath) : null, fresh = tooLong(resume); // too long to resume: a fresh run carries on
297
+ const runCmd = resume && !fresh ? tmpl + ` --resume ${resume.id}` : tmpl;
282
298
  if (!force) { const w = waitingFor(repoPath); if (w) { askedFor(repoPath); return { blocked: true, waiting: true, questions: 0, note: waitNote(w) }; } }
283
299
  const blocked = !force && blockedAgain(repoPath, tmpl); if (blocked) return blocked;
284
300
  try { unlinkSync(join(repoPath, ".symbiot", WAITING)); } catch {} // the step's done (or this is Start it anyway): this is the run it waited for
@@ -288,8 +304,8 @@ function runHandoff(repoPath, { force = false, lost = false } = {}) {
288
304
  const owner = licenceState().plan === "owner" ? { SYMBIOT_OWNER: "1" } : null; // the guard lets only the owner's runs touch Symbiot's own files
289
305
  // SYMBIOT_AGENT: which run a `symbiot screens` command comes from, so it gets its own tab in the app's hidden browser
290
306
  const env = { ...(own || {}), ...(boxEnv || {}), ...(owner || {}), SYMBIOT_AGENT: repoPath };
291
- const e = track(typeof opts.name === "string" && opts.name ? opts.name.slice(0, 80) : repoPath.split("/").pop(), fillHandoff(runCmd, repoPath, resume ? RESUME_PROMPT : HANDOFF_PROMPT), repoPath, (code) => {
292
- if (noteSession(repoPath, code, !!resume) === "lost") { setTimeout(() => { try { runHandoff(repoPath, { force: true, lost: true }); } catch {} }, 300); return; } // its conversation is gone: start afresh
307
+ const e = track(typeof opts.name === "string" && opts.name ? opts.name.slice(0, 80) : repoPath.split("/").pop(), fillHandoff(runCmd, repoPath, fresh ? carryOn(repoPath) : resume ? RESUME_PROMPT : HANDOFF_PROMPT), repoPath, (code) => {
308
+ if (noteSession(repoPath, code, !!resume && !fresh) === "lost") { setTimeout(() => { try { runHandoff(repoPath, { force: true, lost: true }); } catch {} }, 300); return; } // its conversation is gone: start afresh
293
309
  try { if (JSON.parse(readFileSync(lock, "utf8")).pid === e.pid) unlinkSync(lock); } catch {}
294
310
  noteBlocked(repoPath, tmpl, e.startedAt, code);
295
311
  setTimeout(() => { try { autoAllow(repoPath); } catch {} }, 300); // a list it proposed inside your work: turned on, and on it goes
@@ -1079,6 +1095,13 @@ function skipIdea(path, text) {
1079
1095
  }
1080
1096
  return { ok: true };
1081
1097
  }
1098
+ // A question as every screen shows it (plain.mjs): `shown`, its words with no file in them,
1099
+ // and `labels`, its choices the same way ("Allow" for an allow list it proposed). q and
1100
+ // options stay as the agent wrote them: an answer is saved under the agent's own words.
1101
+ function plainly(path, x) {
1102
+ let p = {}; try { p = plainAsk(path, x, { max: x.options.length }); } catch {}
1103
+ return { ...x, ...(p.raw ? { shown: p.q } : {}), ...(p.labels ? { labels: p.labels } : {}) };
1104
+ }
1082
1105
  function agentQuestions(path, repo) {
1083
1106
  const p = parseQuestions(readSymbiot(path, "QUESTIONS.md"));
1084
1107
  const done = new Set([...readSymbiot(path, "ANSWERS.md").matchAll(/^###\s+(.+)$/gm)].map((m) => qKey(m[1])));
@@ -1087,7 +1110,7 @@ function agentQuestions(path, repo) {
1087
1110
  const ideas = p.suggestions.map(suggestionTarget).filter(({ text }) => !skipped.some((s) => sameTask(s, text)))
1088
1111
  .map(({ repo: to, text }) => ({ text, repo: to || repo, other: !!to && to !== repo, added: tasks.some((t) => t.repo === (to || repo) && sameTask(t.text, text)) })).filter((s) => !s.added);
1089
1112
  return {
1090
- questions: open.slice(0, QUESTIONS_MAX).map((x) => ({ ...x, options: x.options.slice(0, OPTIONS_SHOWN) })), answered: p.questions.length - open.length,
1113
+ questions: open.slice(0, QUESTIONS_MAX).map((x) => plainly(path, { ...x, options: x.options.slice(0, OPTIONS_SHOWN) })), answered: p.questions.length - open.length,
1091
1114
  suggestions: ideas, ideasShown: IDEAS_SHOWN,
1092
1115
  };
1093
1116
  }
@@ -1366,7 +1389,7 @@ function workOf(path, log) {
1366
1389
  const done = (md.match(/^\s*-\s*\[x\]/gim) || []).length, total = done + (md.match(/^\s*-\s*\[ \]/gm) || []).length;
1367
1390
  const tail = w.stream ? (w.final || w.said.join("\n\n") || (w.doing ? w.doing + "…" : "")) : String(log).slice(-1200);
1368
1391
  const plan = w.stream ? briefPlan(md) : [], line = w.stream && !w.final ? runLine({ plan, step: stepOf(plan, w.talk), work: w }) : null;
1369
- const work = w.stream ? { model: w.model, doing: w.doing, moved: w.moved, looping: w.looping, steps: w.steps.slice(-16), todos: w.todos, cost: w.cost, turns: w.turns, tokens: w.tokens, tests: w.tests, files: w.files, pace: w.pace, errors: w.errors, final: w.final.slice(0, 1200), count: w.steps.length } : null;
1392
+ const work = w.stream ? { model: w.model, doing: w.doing, moved: w.moved, looping: w.looping, steps: w.steps.slice(-16), todos: w.todos, cost: w.cost, turns: w.turns, tokens: w.tokens, ctx: w.ctx, tests: w.tests, files: w.files, pace: w.pace, errors: w.errors, final: w.final.slice(0, 1200), count: w.steps.length } : null;
1370
1393
  // its kind (a brief, or answers) from its command line, and the task it's on: what estimate.mjs compares it with
1371
1394
  if (line) { const c = String(log || "").slice(String(log || "").lastIndexOf("\n=== ")).split("\n")[2] || ""; line.kind = kindOf(c); line.task = firstOpen(md).slice(0, 200); }
1372
1395
  return { tail, work, progress: total ? { done, total } : null, ...(line ? { line } : {}) };
@@ -1383,4 +1406,4 @@ function agentsList() {
1383
1406
  }).concat(earlierRuns());
1384
1407
  }
1385
1408
 
1386
- export { CHANGES, toKnownHost, continueLanes, autoPick, autoDecide, FACTS, factsOf, knownRun, withStream, workOf, HANDOFFS, HANDOFF_PROMPT, QUESTIONS_MAX, OPTIONS_SHOWN, IDEAS_SHOWN, shSingle, CLAUDE_CMD, ORCA_CLAUDE_CMD, handoffCmd, setHandoffCmd, grantAgent, grantRule, allowTool, claudeConnectors, withConnectors, linkedConnectors, connectorsLine, connectorsInfo, linkReach, fillHandoff, runHandoff, nothingToDo, PARKED_NOTE, parkedPaths, isParked, parkLane, agentMissing, blockedAgain, runningHandoff, loadRuns, earlierRuns, namedFiles, unchangedStep, waitingFor, startWaiting, noteUntracked, untrackedBefore, forgetUntracked, writeTasks, droppedTasks, releaseHeldTasks, startHeldTasks, detectHandoffs, pickAgent, findOrcaCli, orcaHandoffCmd, migrateOrcaCmd, migrateClaudeCmd, track, agentChanges, parseQuestions, suggestionTarget, skipIdea, agentQuestions, answerQuestions, agentsList, needsOf, settleNeeds, NEEDS_FOR, readLastWords, readNeeds, parseRead, READ_FILE, autoAllow, autoAllowSweep, allowlistInWork, installAllowlist, inWork, withScope, withTrust, sandboxFor, sandboxEnv, sandboxNeeds, sandboxState, sandboxWrites, SANDBOX_DIR, resumeFor, noteSession, trustFull, GUARD_SETTINGS, isUrgent, urgentFirst, urgentUndo, urgentDone, urgentSweep, urgentState , licenceGate };
1409
+ export { CHANGES, toKnownHost, continueLanes, autoPick, autoDecide, FACTS, factsOf, knownRun, withStream, workOf, HANDOFFS, HANDOFF_PROMPT, QUESTIONS_MAX, OPTIONS_SHOWN, IDEAS_SHOWN, shSingle, CLAUDE_CMD, ORCA_CLAUDE_CMD, handoffCmd, setHandoffCmd, grantAgent, grantRule, allowTool, claudeConnectors, withConnectors, linkedConnectors, connectorsLine, connectorsInfo, linkReach, fillHandoff, runHandoff, nothingToDo, PARKED_NOTE, parkedPaths, isParked, parkLane, agentMissing, blockedAgain, runningHandoff, loadRuns, earlierRuns, namedFiles, unchangedStep, waitingFor, startWaiting, noteUntracked, untrackedBefore, forgetUntracked, writeTasks, droppedTasks, releaseHeldTasks, startHeldTasks, detectHandoffs, pickAgent, findOrcaCli, orcaHandoffCmd, migrateOrcaCmd, migrateClaudeCmd, track, agentChanges, parseQuestions, suggestionTarget, skipIdea, agentQuestions, answerQuestions, agentsList, needsOf, settleNeeds, NEEDS_FOR, readLastWords, readNeeds, parseRead, READ_FILE, autoAllow, autoAllowSweep, allowlistInWork, installAllowlist, inWork, withScope, withTrust, sandboxFor, sandboxEnv, sandboxNeeds, sandboxState, sandboxWrites, SANDBOX_DIR, resumeFor, noteSession, tooLong, carryOn, RESUME_CTX, CARRY_PROMPT, trustFull, GUARD_SETTINGS, isUrgent, urgentFirst, urgentUndo, urgentDone, urgentSweep, urgentState , licenceGate };
package/handover.mjs CHANGED
@@ -66,7 +66,11 @@ function parseAwaiting(text) {
66
66
  // is still theirs.
67
67
  const GIT_WORK = /\bgit\s+(?:add|commit|push|pull|fetch|merge|rebase|stash|restore|checkout|switch)\b|\b(?:stage|commit|push|pull)(?:es|ed|ing)?\b(?:[^.;:]{0,60})\b(?:changes?|diff|fix|files?|branch|main|master|origin|upstream|remote|commits?|them|it)\b/i;
68
68
  const IDENTITY = /\bpassword\b|\b2fa\b|\btwo-factor\b|\b(?:api )?token\b|\bsign(?:ing)? in\b|\blog ?in\b|\bssh key\b|\bpassphrase\b|\bsudo\b/i;
69
- const gitWork = (text) => { const t = String(text || ""); return GIT_WORK.test(t) && !IDENTITY.test(t); };
69
+ // A permission is never git work, whatever its words: "allow the list … to check the
70
+ // deployed commit, git status and served files" was taken for a commit, and handed back to
71
+ // an agent that can't give itself permission, which asked again (WinLove, 2026-10-12).
72
+ const PERMISSION = /allowlist\.proposed|only you: a permission/i;
73
+ const gitWork = (text) => { const t = String(text || ""); return GIT_WORK.test(t) && !IDENTITY.test(t) && !PERMISSION.test(t); };
70
74
  const PUSH_OK = "asking for your OK on the Workdesk, with the files, before it pushes";
71
75
  // A "👤 You: …" option that's local git work, as the agent's: "🤖 Agent: … (asking for
72
76
  // your OK … before it pushes)". Dropped instead when another option already has the
package/home.mjs CHANGED
@@ -10,6 +10,7 @@
10
10
  // how you use them; this is the live data around them. Home's talk goes to the
11
11
  // same Symbiot as every chat (mind.mjs converse), told what home shows.
12
12
  import { claudeSetup } from "./claudesetup.mjs";
13
+ import { plainAsk } from "./plain.mjs";
13
14
  import { join } from "node:path";
14
15
  import { homedir } from "node:os";
15
16
  import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
@@ -409,48 +410,8 @@ function noteAnswer(path, heading, text) {
409
410
  const f = join(path, ".symbiot", "ANSWERS.md"); let had = ""; try { had = readFileSync(f, "utf8"); } catch {}
410
411
  try { writeFileSync(f, `${(had || "# Answers\n").replace(/\s*$/, "")}\n\n### ${String(heading).replace(/\s+/g, " ").trim()}\n${text}\n_answered ${new Date().toISOString().slice(0, 10)}_\n`); } catch {}
411
412
  }
412
- // An ask in words for someone who doesn't code: a permission request lists what the agent
413
- // wants to run, with Allow / Don't allow; any file path left in the question or choices
414
- // becomes "a file". The answer sent back is still the choice's own text (labels only change what shows).
415
- const PATHISH = /`?(?:~|\.{0,2})?\/?(?:[\w.-]+\/)+[\w.-]+\.(?:json|md|mjs|js|env|txt|sh)`?|`?\b[\w-]+\.(?:json|mjs)\b`?/g;
416
- // What a rule lets an agent do, said the way you'd say it to someone who doesn't code.
417
- const base = (p) => String(p || "").replace(/[/*]+$/, "").split("/").filter(Boolean).pop() || "your files";
418
- function PLAIN_RULE(r) {
419
- const m = String(r).match(/^(\w+)\((.*)\)$/); if (!m) return "work in " + base(r);
420
- const [, tool, arg] = m, a = arg.replace(/:?\*+$/, "").trim(), first = a.split(/\s+/)[0].replace(/:\*?$/, "").split("/").pop();
421
- if (tool === "Read") return `read files in ${base(a)}`;
422
- if (/^(Edit|Write|MultiEdit)$/.test(tool)) return `change files in ${base(a)}`;
423
- if (tool !== "Bash") return `use ${tool}`;
424
- const host = (a.match(/@([\w.-]+)/) || [])[1];
425
- if (/^(ssh|scp|rsync)$/.test(first)) return host ? `connect to the computer at ${host}` : "connect to another computer";
426
- if (first === "adb") return "use your phone over the USB cable";
427
- if (/^(flutter|gradle|gradlew|godot|xcodebuild)$/.test(first) || /\bbuild\b/.test(a)) return "build the app";
428
- if (first === "ffmpeg" || first === "ffprobe") return "make and trim video clips";
429
- if (first === "git" && /\bpush\b/.test(a)) return "send code to GitHub";
430
- if (/^(npm|yarn|pnpm)$/.test(first) && /\bpublish\b/.test(a)) return "publish a new version";
431
- if (/^(bash|sh|zsh)$/.test(first)) return "run a script it wrote";
432
- if (/^(python\d*|node)$/.test(first)) return "run code in this project";
433
- if (/^(rm|shred)$/.test(first)) return "delete files";
434
- if (first === "sudo") return "make changes as administrator";
435
- return `run ${first}`;
436
- }
437
413
  // a label without its "(recommended)": a second button isn't the recommended one
438
414
  const blobLabel = (l) => String(l || "").replace(/\s*\(recommended\)\s*$/i, "");
439
- function plainAsk(path, q) {
440
- const text = String(q.q || ""), opts = (q.options || []).slice(0, 2);
441
- let ask = text, labels = opts.map((o) => o.replace(PATHISH, "a file"));
442
- if (opts.some((o) => /allowlist\.proposed/i.test(o))) {
443
- let rules = []; try { const p = JSON.parse(readFileSync(join(path, ".symbiot", "allowlist.proposed.json"), "utf8")).permissions || {}; rules = [...new Set([...(p.allow || []).map(PLAIN_RULE), ...(p.additionalDirectories || []).map((d) => `work in ${base(d)}`)])]; } catch {}
444
- ask = `${text.replace(PATHISH, "a file")}${rules.length ? ` It wants to ${rules.slice(0, 6).join(", ")}${rules.length > 6 ? `, and ${rules.length - 6} more things` : ""}.` : ""} If you say no, it skips that part and tells you what it left out.`;
445
- labels = opts.map((o) => (/allowlist\.proposed/i.test(o) ? "Allow" + (/recommended/i.test(o) ? " (recommended)" : "") : o.replace(PATHISH, "a file")));
446
- }
447
- // raw: the question as the agent wrote it, the key its answer is saved under (agents.mjs
448
- // answerQuestions). Sending the plain words back matched nothing, so a permission card's
449
- // Allow, and Send under it, did nothing at all (whatsapp_module, 2026-10-12).
450
- const shown = ask.replace(PATHISH, "a file");
451
- return { q: shown, ...(shown !== text ? { raw: text } : {}), ...(labels.some((l, i) => l !== opts[i]) ? { labels } : {}) };
452
- }
453
-
454
415
  function homeAnswer(id, { pick, text = "" } = {}, deps = {}) {
455
416
  id = String(id || ""); text = String(text || "").trim().slice(0, 2000);
456
417
  const go = text ? !SAYS_NO.test(text) : Number(pick) === 0, ref = id.replace(/^\w+:/, "");
@@ -688,7 +649,7 @@ function workScene({ deps = {}, full = false } = {}) {
688
649
  const line = plain(String(t).split(/\n\s*\n/)[0], 220);
689
650
  if (line) { p.summary = line; p.state = a.status === "running" ? "at work" : qs.length ? "asks you" : "done"; p.path = a.path; }
690
651
  }
691
- for (const q of qs) { p.qs = p.qs || []; if (p.qs.length < 3) p.qs.push({ q: q.q, options: (q.options || []).slice(0, 3), path: a.path, ...signInCard([q.q, ...(q.options || []).slice(0, 3)]) }); }
652
+ for (const q of qs) { p.qs = p.qs || []; if (p.qs.length < 3) p.qs.push({ q: q.shown || q.q, ...(q.shown ? { raw: q.q } : {}), options: (q.options || []).slice(0, 3), ...(q.labels ? { labels: q.labels } : {}), path: a.path, ...signInCard([q.q, ...(q.options || []).slice(0, 3)]) }); }
692
653
  for (const s of ideas) { p.ideas = p.ideas || []; if (p.ideas.length < 4) p.ideas.push({ text: plain(s.text, 200), full: String(s.text), repo: s.repo || l, path: a.path }); }
693
654
  }
694
655
  // what each lane handed over and hasn't heard back on, with where it stands and how far the run on it has got
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "symbiot",
3
- "version": "0.63.7",
3
+ "version": "0.63.9",
4
4
  "description": "Your week, written from your real work. Reads your local git and writes it up with the AI of your choice \u2014 Claude, OpenAI, Gemini, or a local model.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -46,6 +46,7 @@
46
46
  "away.mjs",
47
47
  "marketing.mjs",
48
48
  "tray.mjs",
49
+ "plain.mjs",
49
50
  "capture.mjs",
50
51
  "demo.mjs",
51
52
  "postinstall.mjs",
package/phone.mjs CHANGED
@@ -283,7 +283,7 @@ async function snapshot({ now = Date.now(), fresh = false } = {}) {
283
283
  const asks = [];
284
284
  for (const y of (await tryOr(() => agents.agentsList(), [])).filter((a, i, all) => all.findIndex((b) => b.path === a.path) === i)) {
285
285
  const qs = await tryOr(() => agents.agentQuestions(y.path, "").questions, []);
286
- if (qs.length) asks.push({ id: askId(y.path), path: y.path, name: plainText(String(y.name || "an agent").replace(/^Agent:\s*/, ""), 60), questions: qs.slice(0, 5).map((q) => ({ q: plainText(q.q, 300), options: (q.options || []).slice(0, 2).map((o) => plainText(o, 200)) })) });
286
+ if (qs.length) asks.push({ id: askId(y.path), path: y.path, name: plainText(String(y.name || "an agent").replace(/^Agent:\s*/, ""), 60), questions: qs.slice(0, 5).map((q) => ({ q: plainText(q.q, 300), ...(q.shown ? { shown: plainText(q.shown, 400) } : {}), options: (q.options || []).slice(0, 2).map((o) => plainText(o, 200)), ...(q.labels ? { labels: q.labels.slice(0, 2).map((o) => plainText(o, 200)) } : {}) })) });
287
287
  }
288
288
  const approves = (await tryOr(() => tasksMod.pendingReview(), [])).filter((r) => r.path && !r.running && ((r.tasks || []).length || (r.files || []).length))
289
289
  .map((r) => ({ repo: r.repo, tasks: (r.tasks || []).map((t) => ({ id: t.id, text: plainText(t.text, 200) })), files: (r.files || []).length, stat: plainText(r.stat, 120), ...(r.untasked ? { untasked: true } : {}), sum: approveSum(r) }));
package/plain.mjs ADDED
@@ -0,0 +1,48 @@
1
+ // A question an agent left, in words for someone who doesn't code (plainAsk): Home,
2
+ // the Workdesk's agent blocks, a project's card and the phone all show it this way, so
3
+ // "allow the list in .symbiot/allowlist.proposed.json" never reaches a screen. It was
4
+ // only Home's list: the same ask on the Workdesk still read as a file (2026-10-12).
5
+ import { readFileSync } from "node:fs";
6
+ import { join } from "node:path";
7
+
8
+ // An ask in words for someone who doesn't code: a permission request lists what the agent
9
+ // wants to run, with Allow / Don't allow; any file path left in the question or choices
10
+ // becomes "a file". The answer sent back is still the choice's own text (labels only change what shows).
11
+ const PATHISH = /`?(?:~|\.{0,2})?\/?(?:[\w.-]+\/)+[\w.-]+\.(?:json|md|mjs|js|env|txt|sh)`?|`?\b[\w-]+\.(?:json|mjs)\b`?/g;
12
+ // What a rule lets an agent do, said the way you'd say it to someone who doesn't code.
13
+ const base = (p) => String(p || "").replace(/[/*]+$/, "").split("/").filter(Boolean).pop() || "your files";
14
+ function PLAIN_RULE(r) {
15
+ const m = String(r).match(/^(\w+)\((.*)\)$/); if (!m) return "work in " + base(r);
16
+ const [, tool, arg] = m, a = arg.replace(/:?\*+$/, "").trim(), first = a.split(/\s+/)[0].replace(/:\*?$/, "").split("/").pop();
17
+ if (tool === "Read") return `read files in ${base(a)}`;
18
+ if (/^(Edit|Write|MultiEdit)$/.test(tool)) return `change files in ${base(a)}`;
19
+ if (tool !== "Bash") return `use ${tool}`;
20
+ const host = (a.match(/@([\w.-]+)/) || [])[1];
21
+ if (/^(ssh|scp|rsync)$/.test(first)) return host ? `connect to the computer at ${host}` : "connect to another computer";
22
+ if (first === "adb") return "use your phone over the USB cable";
23
+ if (/^(flutter|gradle|gradlew|godot|xcodebuild)$/.test(first) || /\bbuild\b/.test(a)) return "build the app";
24
+ if (first === "ffmpeg" || first === "ffprobe") return "make and trim video clips";
25
+ if (first === "git" && /\bpush\b/.test(a)) return "send code to GitHub";
26
+ if (/^(npm|yarn|pnpm)$/.test(first) && /\bpublish\b/.test(a)) return "publish a new version";
27
+ if (/^(bash|sh|zsh)$/.test(first)) return "run a script it wrote";
28
+ if (/^(python\d*|node)$/.test(first)) return "run code in this project";
29
+ if (/^(rm|shred)$/.test(first)) return "delete files";
30
+ if (first === "sudo") return "make changes as administrator";
31
+ return `run ${first}`;
32
+ }
33
+ function plainAsk(path, q, { max = 2 } = {}) {
34
+ const text = String(q.q || ""), opts = (q.options || []).slice(0, max);
35
+ let ask = text, labels = opts.map((o) => o.replace(PATHISH, "a file"));
36
+ if (opts.some((o) => /allowlist\.proposed/i.test(o))) {
37
+ let rules = []; try { const p = JSON.parse(readFileSync(join(path, ".symbiot", "allowlist.proposed.json"), "utf8")).permissions || {}; rules = [...new Set([...(p.allow || []).map(PLAIN_RULE), ...(p.additionalDirectories || []).map((d) => `work in ${base(d)}`)])]; } catch {}
38
+ ask = `${text.replace(PATHISH, "a file")}${rules.length ? ` It wants to ${rules.slice(0, 6).join(", ")}${rules.length > 6 ? `, and ${rules.length - 6} more things` : ""}.` : ""} If you say no, it skips that part and tells you what it left out.`;
39
+ labels = opts.map((o) => (/allowlist\.proposed/i.test(o) ? "Allow" + (/recommended/i.test(o) ? " (recommended)" : "") : o.replace(PATHISH, "a file")));
40
+ }
41
+ // raw: the question as the agent wrote it, the key its answer is saved under (agents.mjs
42
+ // answerQuestions). Sending the plain words back matched nothing, so a permission card's
43
+ // Allow, and Send under it, did nothing at all (whatsapp_module, 2026-10-12).
44
+ const shown = ask.replace(PATHISH, "a file");
45
+ return { q: shown, ...(shown !== text ? { raw: text } : {}), ...(labels.some((l, i) => l !== opts[i]) ? { labels } : {}) };
46
+ }
47
+
48
+ export { PATHISH, PLAIN_RULE, plainAsk };
package/ui.mjs CHANGED
@@ -1890,8 +1890,8 @@ function relHtml(rl){return "<div class='qrel"+(rl.waiting?" wait":"")+"'>Needs
1890
1890
  function askHtml(a){var k=a.ask;if(!k)return '';var qs=k.questions||[],ss=k.suggestions||[];if(!qs.length&&!ss.length)return '';
1891
1891
  var h="<div class='aq' data-id='"+esc(a.id)+"'>";
1892
1892
  if(qs.length){h+="<h4><i class=ic-ask></i> "+qs.length+" question"+(qs.length>1?"s":"")+" for you</h4>";
1893
- qs.forEach(function(q,i){var rl=q.release;h+="<div class='q' data-i='"+i+"'><div class='qt'>"+esc(q.q)+"</div>"+(q.context?"<div class='qc'>"+esc(q.context)+"</div>":"")+(rl?relHtml(rl):"");
1894
- (q.options||[]).forEach(function(o,j){var off=rl&&rl.waiting&&DONEOPT.test(o);h+="<label class='opt"+(off?" off":"")+"'><input type='radio' name='q_"+esc(a.id)+"_"+i+"' value='"+j+"'"+(off?" disabled":"")+"><span>"+whoHtml(o)+(off?" <i>(once "+esc(rl.name+" "+rl.needs)+" is installed)</i>":"")+"</span></label>";});
1893
+ qs.forEach(function(q,i){var rl=q.release;h+="<div class='q' data-i='"+i+"'><div class='qt'>"+esc(q.shown||q.q)+"</div>"+(q.context?"<div class='qc'>"+esc(q.context)+"</div>":"")+(rl?relHtml(rl):"");
1894
+ (q.options||[]).forEach(function(o,j){var off=rl&&rl.waiting&&DONEOPT.test(o);h+="<label class='opt"+(off?" off":"")+"'><input type='radio' name='q_"+esc(a.id)+"_"+i+"' value='"+j+"'"+(off?" disabled":"")+"><span>"+whoHtml((q.labels&&q.labels[j])||o)+(off?" <i>(once "+esc(rl.name+" "+rl.needs)+" is installed)</i>":"")+"</span></label>";});
1895
1895
  h+="<input class='qother' placeholder='"+((q.options&&q.options.length)?"or answer in your own words":"your answer")+"'></div>";});
1896
1896
  h+="<div class='row'><button class='act qsend' title='save the answers and hand the repo back to your agent'>Send answers &amp; continue</button><button class='ghost qsave' title='save the answers for the next run'>Save only</button></div>";}
1897
1897
  if(ss.length){var shown=IDEASOPEN[a.path]?ss.length:(k.ideasShown||ss.length);h+="<h4><i class=ic-idea></i> Ideas from the agent</h4>";ss.forEach(function(s,i){if(i>=shown)return;h+="<div class='idea'><span style='flex:1'>"+esc(s.text)+(s.other?" <span class='tag' title='this idea is for another project, so + task adds it to that one'>for "+esc(s.repo)+"</span>":"")+"</span>"+(s.added?"<span class='tag'>in Tasks</span>":"<button class='ghost qidea' data-i='"+i+"' style='padding:3px 9px;font-size:12px'>+ task</button><button class='ghost qskip' data-i='"+i+"' title='turn this idea down: the next one moves up, and the agent is told not to suggest it again' style='padding:3px 9px;font-size:12px'>Skip</button>")+"</div>";});
@@ -1971,7 +1971,7 @@ function wkSpark(p){if(!p||p.length<2)return '';var m=Math.max.apply(null,p)||1,
1971
1971
  function workHtml(a){var w=a.work,pg=a.progress,run=a.status==='running',h='';
1972
1972
  if(!w){if(run)h+="<div class='bar'><i></i></div>";if(pg)h+="<div class='wkstats'><span class='wkstat'><b>"+pg.done+"/"+pg.total+"</b> tasks ticked</span></div>";return h;}
1973
1973
  var stats=[];if(w.model)stats.push("<span class='wkstat'>"+esc(w.model.split('[')[0])+"</span>");stats.push("<span class='wkstat'><b>"+w.count+"</b> step"+(w.count===1?'':'s')+"</span>");
1974
- if(w.turns!=null)stats.push("<span class='wkstat'><b>"+w.turns+"</b> turns</span>");if(w.tokens)stats.push("<span class='wkstat'><b>"+(w.tokens>999999?(w.tokens/1e6).toFixed(1)+'M':w.tokens>999?Math.round(w.tokens/1000)+'k':w.tokens)+"</b> tokens</span>");
1974
+ if(w.turns!=null)stats.push("<span class='wkstat'><b>"+w.turns+"</b> turns</span>");if(w.tokens)stats.push("<span class='wkstat'><b>"+(w.tokens>999999?(w.tokens/1e6).toFixed(1)+'M':w.tokens>999?Math.round(w.tokens/1000)+'k':w.tokens)+"</b> tokens</span>");if(w.ctx)stats.push("<span class='wkstat' title='What it rereads with every step: the conversation so far. Past 60k, your next answer starts it fresh with a summary instead.'><b>"+(w.ctx>999?Math.round(w.ctx/1000)+'k':w.ctx)+"</b> context</span>");
1975
1975
  if(w.cost!=null)stats.push("<span class='wkstat'><b>$"+w.cost.toFixed(2)+"</b></span>");if(w.tests)stats.push("<span class='wktest"+(w.tests.failed?' bad':'')+"'>"+w.tests.passed+" passed &middot; "+w.tests.failed+" failed</span>");if(w.errors)stats.push("<span class='wktest bad'>"+w.errors+" error"+(w.errors>1?'s':'')+"</span>");
1976
1976
  if(run&&w.doing)h+="<div class='wkdoing'>"+esc(w.doing)+"&hellip;</div>";
1977
1977
  var first=(w.final||'').split(String.fromCharCode(10))[0];if(!run&&first)h+="<div class='wkfinal'>"+esc(first.length>180?first.slice(0,179)+'\u2026':first)+"</div>";
@@ -2175,8 +2175,8 @@ var h='',W="<span class='pcwait'> waiting to send</span>";
2175
2175
  (c.notes||[]).forEach(function(n){h+="<div class='task' data-note='"+escQ(n.id)+"'><span class='t err'>"+esc(n.error)+(n.what?" <span class='muted'>("+esc(n.what)+")</span>":"")+"</span><button class='ghost pcnote'>OK</button></div>";});
2176
2176
  var asks=c.asks||[],aps=c.approves||[],needs=c.needs||[];
2177
2177
  if(asks.length||aps.length||needs.length){h+="<div class='mkneeds lit'><div class='pshead'><span>Needs you &middot; "+(asks.length+aps.length+needs.length)+"</span><small>on "+esc(d.name)+"</small></div>";
2178
- asks.forEach(function(a,i){var q=(a.questions||[])[0]||{};h+="<div class='pcq' data-ask='"+i+"'><div class='qn'>"+esc(a.name)+" asks"+((a.questions||[]).length>1?" &middot; 1 of "+a.questions.length:"")+"</div><div class='qq'>"+esc(q.q||'')+"</div>"+
2179
- (a.waiting?"<div class='note' style='margin:0'>You answered: "+esc((a.answered||[])[0]||'')+W+"</div>":"<div class='opts'>"+(q.options||[]).map(function(o,j){return "<button class='"+(j?'ghost':'act')+" pcopt' data-j='"+j+"'>"+esc(o)+"</button>";}).join('')+"</div><form class='pcown'><input placeholder='Or say it in your own words' maxlength='2000'><button class='ghost' type='submit'>Send</button></form>")+"</div>";});
2178
+ asks.forEach(function(a,i){var q=(a.questions||[])[0]||{};h+="<div class='pcq' data-ask='"+i+"'><div class='qn'>"+esc(a.name)+" asks"+((a.questions||[]).length>1?" &middot; 1 of "+a.questions.length:"")+"</div><div class='qq'>"+esc(q.shown||q.q||'')+"</div>"+
2179
+ (a.waiting?"<div class='note' style='margin:0'>You answered: "+esc((a.answered||[])[0]||'')+W+"</div>":"<div class='opts'>"+(q.options||[]).map(function(o,j){return "<button class='"+(j?'ghost':'act')+" pcopt' data-j='"+j+"'>"+esc((q.labels&&q.labels[j])||o)+"</button>";}).join('')+"</div><form class='pcown'><input placeholder='Or say it in your own words' maxlength='2000'><button class='ghost' type='submit'>Send</button></form>")+"</div>";});
2180
2180
  aps.forEach(function(a,i){h+="<div class='pcq' data-ap='"+i+"'><div class='qn'>"+esc(a.repo)+" &middot; waiting for your Approve</div><div class='qq'>"+(a.tasks.length?a.tasks.length+" task"+(a.tasks.length>1?"s":"")+" done":"Changes without a task")+" &middot; <span class='muted'>"+esc(a.stat||(a.files+" file"+(a.files===1?"":"s")+" changed"))+"</span></div>"+(a.tasks.length?"<ul>"+a.tasks.map(function(t){return "<li>"+esc(t.text)+"</li>";}).join('')+"</ul>":"")+
2181
2181
  (a.waiting?"<div class='note' style='margin:0'>Approved here"+W+"</div>":"<div class='row' style='margin:0'><button class='act pcapprove'>Approve</button><span class='muted' style='font-size:12px'>exactly this: if the work changes before your computer gets it, it isn&rsquo;t approved</span></div>")+"</div>";});
2182
2182
  needs.forEach(function(n){h+="<div class='task'><span class='t'>"+esc(n.title)+" <span class='muted' style='font-size:12px'>"+esc(n.sub)+" &middot; on your computer</span></span></div>";});
@@ -3118,13 +3118,13 @@ if(!ps.length){el.innerHTML='';return;}
3118
3118
  el.innerHTML=ps.map(function(p,i){var nm=p.name||p.repo,q=(p.qs||[])[0],st=p.parked?'parked':(p.qs&&p.qs.length)?'asks you':p.ready?'ready for your OK':p.state||'';
3119
3119
  var h="<section class='rly"+((p.qs&&p.qs.length)||p.ready?' lit':'')+"' data-i='"+i+"'><div class='rlh'><b>"+esc(nm)+"</b>"+(st?"<span class='rls'>"+esc(st)+"</span>":"")+"</div>";
3120
3120
  if(p.summary)h+="<p class='rlsum' title='"+escQ(p.summary)+"'>"+esc(p.summary)+"</p>";
3121
- if(q)h+="<div class='lqblob rlask'>"+blobBody({kind:'ask',name:nm,repo:p.repo,q:q.q,options:q.options,path:q.path,fix:q.fix,id:q.id,sub:q.sub,draft:q.draft,signInTo:q.signInTo,more:Math.max(0,(p.asks||p.qs.length)-1)},false)+"</div>";
3121
+ if(q)h+="<div class='lqblob rlask'>"+blobBody({kind:'ask',name:nm,repo:p.repo,q:q.q,raw:q.raw,labels:q.labels,options:q.options,path:q.path,fix:q.fix,id:q.id,sub:q.sub,draft:q.draft,signInTo:q.signInTo,more:Math.max(0,(p.asks||p.qs.length)-1)},false)+"</div>";
3122
3122
  if(p.ready)h+="<button type='button' class='act rlok'>Review and approve</button>";
3123
3123
  if(p.handing&&p.handing.length)h+="<div class='rlk'>Handed over</div>"+p.handing.map(function(x){var pg=handProg(x);return "<p class='rlsum rlhand' title='"+escQ(x.text)+"'><b>"+esc(x.lane)+"</b>: "+esc(x.why)+(pg?" \u00b7 "+esc(pg):"")+"</p>";}).join('');
3124
3124
  if(p.ideas&&p.ideas.length)h+="<div class='rlk'>Extra tasks it suggests</div>"+p.ideas.map(function(d,j){return "<div class='rli' data-j='"+j+"'><span title='"+escQ(d.full)+"'>"+esc(d.text)+"</span><span class='rlia'><button type='button' class='rladd'>+ task</button><button type='button' class='rlskip'>Skip</button></span></div>";}).join('');
3125
3125
  return h+"<div class='rlf'><button type='button' class='rlopen'>Its tasks &rsaquo;</button><button type='button' class='rlag'>Its agents &rsaquo;</button></div></section>";}).join('');
3126
3126
  el.querySelectorAll('.rly').forEach(function(box){var p=ps[+box.getAttribute('data-i')],q=(p.qs||[])[0],nm=p.name||p.repo;
3127
- var ab=box.querySelector('.rlask');if(ab&&q)wireBlob(ab,{kind:'ask',name:nm,repo:p.repo,q:q.q,options:q.options,path:q.path,fix:q.fix,id:q.id},{done:function(){LQ.relaySig='';lqLoadWork();}});
3127
+ var ab=box.querySelector('.rlask');if(ab&&q)wireBlob(ab,{kind:'ask',name:nm,repo:p.repo,q:q.q,raw:q.raw,labels:q.labels,options:q.options,path:q.path,fix:q.fix,id:q.id},{done:function(){LQ.relaySig='';lqLoadWork();}});
3128
3128
  var ok=box.querySelector('.rlok');if(ok)ok.addEventListener('click',function(){lqOpen({kind:'proj',repo:p.repo,lit:true});});
3129
3129
  box.querySelector('.rlopen').addEventListener('click',function(){lqOpen({kind:'proj',repo:p.repo});});
3130
3130
  box.querySelector('.rlag').addEventListener('click',function(){lqPool('agents');});
package/work.mjs CHANGED
@@ -45,12 +45,12 @@ const resultText = (c) => (typeof c === "string" ? c : Array.isArray(c) ? c.map(
45
45
 
46
46
  // The text of one run's log (everything after its "=== name time ===" header and
47
47
  // command line) → { stream, model, steps, todos, doing, said, final, cost, turns,
48
- // tokens, tests, files, pace, errors }.
48
+ // tokens, ctx (what its last step read: the conversation so far), tests, files, pace, errors }.
49
49
  function parseRun(text) {
50
50
  const lines = String(text || "").split("\n");
51
51
  const events = []; let plain = [];
52
52
  for (const l of lines) { if (l.startsWith("{")) { try { events.push(JSON.parse(l)); continue; } catch {} } if (l.trim()) plain.push(l); }
53
- const out = { stream: events.length > 0, model: "", steps: [], todos: [], doing: "", said: [], final: "", cost: null, turns: null, tokens: null, tests: null, files: {}, pace: [], errors: 0 };
53
+ const out = { stream: events.length > 0, model: "", steps: [], todos: [], doing: "", said: [], final: "", cost: null, turns: null, tokens: null, ctx: null, tests: null, files: {}, pace: [], errors: 0 };
54
54
  if (!out.stream) { out.said = plain.slice(-12).map((l) => short(l, 160)); out.final = plain.slice(-40).join("\n").trim(); return out; }
55
55
  const byId = new Map(), tasks = new Map(), talk = []; let thinking = false, moved = 0;
56
56
  for (const e of events) {
@@ -60,6 +60,7 @@ function parseRun(text) {
60
60
  if (e.session_id && !out.session) out.session = String(e.session_id);
61
61
  if (e.type === "system" && e.subtype === "init") { out.model = String(e.model || ""); continue; }
62
62
  if (e.type === "assistant" && e.message && Array.isArray(e.message.content)) {
63
+ const u = e.message.usage; if (u) out.ctx = (u.input_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0) + (u.output_tokens || 0) || out.ctx; // what its next step rereads
63
64
  for (const b of e.message.content) {
64
65
  if (b.type === "thinking") { thinking = true; continue; }
65
66
  if (b.type === "text" && b.text && b.text.trim()) { out.said.push(short(b.text, 220)); talk.push(short(b.text, 400)); thinking = false; continue; }