symbiot 0.54.3 → 0.55.0

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/post.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  // symbiot — Post: your week's real work as three draft posts (LinkedIn), in your
2
- // own voice, each waiting on you on the Dashboard: Approve, Edit or Skip.
2
+ // own voice, each waiting on you under Marketing: Approve, Edit or Skip.
3
3
  //
4
4
  // - What it writes from: the last 7 days of commits, the release tags (and gh's
5
5
  // releases, when gh works) and the CHANGELOG.md sections dated in that window,
@@ -21,7 +21,7 @@
21
21
  //
22
22
  // Stored in ~/.config/symbiot/posts.json, readable by you only:
23
23
  // { posts: [{ id, kind, text, show, facts: [n], sources: [fact line], media: [{ id, kind, file, name, from, url?, bytes, added }], platform, status, drafted, edited?, approved?, skipped? }], lastUrl }
24
- // status: waiting (on the Dashboard), approved, skipped or replaced (a newer draft took its place).
24
+ // status: waiting (under Marketing), approved, skipped or replaced (a newer draft took its place).
25
25
  import { join, dirname } from "node:path";
26
26
  import { readFileSync, writeFileSync, appendFileSync, mkdirSync, chmodSync, existsSync, readdirSync, rmSync, readlinkSync } from "node:fs";
27
27
  import { spawn, spawnSync } from "node:child_process";
@@ -32,7 +32,7 @@ import { resolveProvider, write } from "./ai.mjs";
32
32
  import { commits, discoveredRepos } from "./scan.mjs";
33
33
  import { readTexts, pagePicture, pageClip, siteUrl, CLIP } from "./headless.mjs";
34
34
 
35
- const PATHS = { posts: join(CONFIG_DIR, "posts.json"), log: join(CONFIG_DIR, "posts-log.jsonl"), voice: join(CONFIG_DIR, "voice.md"), media: join(CONFIG_DIR, "post-media") };
35
+ const PATHS = { posts: join(CONFIG_DIR, "posts.json"), log: join(CONFIG_DIR, "posts-log.jsonl"), voice: join(CONFIG_DIR, "voice.md"), media: join(CONFIG_DIR, "post-media"), test: join(CONFIG_DIR, "post-test.json") };
36
36
  const PLATFORM = "linkedin";
37
37
  // LinkedIn's share box, opened for you to paste an approved post into.
38
38
  const SHARE_URL = "https://www.linkedin.com/feed/?shareActive=true";
@@ -42,7 +42,7 @@ const MAX_FACTS = 120, MAX_TEXT = 3000, KEEP = 200; // LinkedIn takes 3000 chara
42
42
  const q = (s) => JSON.stringify(String(s));
43
43
 
44
44
  const NO_AI = "Symbiot needs an AI to draft your posts, and none is connected. Connect one with symbiot login (or Settings in the app), then run symbiot post again. Nothing was drafted, saved or logged.";
45
- const noVoice = (file) => `Symbiot drafts posts in your voice, from examples of your own posts, and has none yet. Either link LinkedIn (Dashboard → Links → LinkedIn, sign in once) and click Fill from LinkedIn (or run symbiot post voice --linkedin), or paste 5–10 posts you wrote into ${file}, with a line of --- between each. Nothing was drafted.`;
45
+ const noVoice = (file) => `Symbiot drafts posts in your voice, from examples of your own posts, and has none yet. Either link LinkedIn (Marketing → Link LinkedIn, sign in once) and click Fill from LinkedIn (or run symbiot post voice --linkedin), or paste 5–10 posts you wrote into ${file}, with a line of --- between each. Nothing was drafted.`;
46
46
 
47
47
  // ---- your voice ------------------------------------------------------------------
48
48
  // voice.md's examples: what's between lines of ---, each long enough to be a post.
@@ -62,7 +62,7 @@ const tidyPost = (t) => noTagLabels(t).replace(/\s*…\s*(see )?more\s*$/i, "").
62
62
  async function voiceFromLinkedIn({ read = readTexts, paths = PATHS, max = 10 } = {}) {
63
63
  const r = (await read(LINKEDIN_ACTIVITY, LINKEDIN_POST_TEXT)) || { error: "Nothing came back from LinkedIn." };
64
64
  if (r.busy) return { error: "Symbiot's browser is busy (a sign-in window, or a map). Close it, then try again." };
65
- if (r.login || /linkedin\.com\/(login|authwall|uas\/|checkpoint|signup)/i.test(r.url || "")) return { error: "You're not signed in to LinkedIn in Symbiot's browser. Link LinkedIn on the Dashboard (Links → LinkedIn), sign in in the window that opens, close it, then try again." };
65
+ if (r.login || /linkedin\.com\/(login|authwall|uas\/|checkpoint|signup)/i.test(r.url || "")) return { error: "You're not signed in to LinkedIn in Symbiot's browser. Link LinkedIn (Marketing → Link LinkedIn, or Connections), sign in in the window that opens, close it, then try again." };
66
66
  if (r.error) return { error: r.error };
67
67
  const found = []; for (const t of (r.texts || []).map(tidyPost)) if (t.length >= 40 && !found.includes(t)) found.push(t);
68
68
  if (!found.length) return { error: `Found no posts of yours on LinkedIn's activity page. Paste 5–10 of your posts into ${paths.voice} instead, with a line of --- between each.` };
@@ -200,7 +200,7 @@ const SYSTEM = `You draft LinkedIn posts for a developer, from their real work t
200
200
  `Answer with JSON only: {"posts":[{"kind":"shipped","text":"…","show":"…","facts":[1,4]},{"kind":"learned","text":"…","show":"…","facts":[…]},{"kind":"long","text":"…","show":"…","facts":[…]}]}, ` +
201
201
  `where "facts" lists the numbers of every fact the post uses, and "show" is one short line (not posted) saying what picture or short video would show it best, ` +
202
202
  `for them to take: the screen, page or command the post is about, as the facts name it (e.g. "a picture of the new Reports view", "a clip of the install running").`;
203
- // Draft this week's 3 posts and put them on the Dashboard (waiting on you). With
203
+ // Draft this week's 3 posts and put them under Marketing (waiting on you). With
204
204
  // no AI connected it says so and does nothing else: nothing is read, written or
205
205
  // logged. `ask` is ai.mjs write (the tests pass their own); `repos`, `gh`, `now`
206
206
  // and `paths` likewise. Gives { ok, posts, dropped, facts } or { error, code }.
@@ -265,9 +265,10 @@ function logAction(action, p, now = Date.now(), paths = PATHS, extra = {}) {
265
265
  } catch { return false; }
266
266
  }
267
267
  function postLog(paths = PATHS) { try { return readFileSync(paths.log, "utf8").split("\n").filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean); } catch { return []; } }
268
- // What the Dashboard shows: the drafts waiting on you, the last few you dealt with,
268
+ // What Marketing shows: the drafts waiting on you, the last few you dealt with,
269
269
  // and whether it can draft: an AI connected, and your voice (examples in voice.md,
270
- // or LinkedIn linked to fill it from). Until then the Dashboard says so in a line.
270
+ // or LinkedIn linked to fill it from). Until then Marketing says so in a line, and
271
+ // Home shows no Marketing orb (home.mjs).
271
272
  function postsState(paths = PATHS, { linked = () => (loadConfig().linked || {}) } = {}) {
272
273
  const d = loadPosts(paths), voice = loadVoice(paths.voice).length, connected = !!resolveProvider();
273
274
  let linkedin = false; try { linkedin = !!linked().linkedin; } catch {}
@@ -520,14 +521,71 @@ function openUrl(url) {
520
521
 
521
522
  // ---- replies: who might be a customer ------------------------------------------------
522
523
  // A comment or mention that asks how to install it, what it costs or about its
523
- // licence, or about using it in a team: "maybe a customer" on the Dashboard.
524
+ // licence, or about using it in a team: "maybe a customer" under Marketing and on the Dashboard.
524
525
  // Gives why ("install", "pricing", "team") or "".
525
526
  const CUSTOMER = [
526
527
  ["pricing", /\b(?:pric(?:e|es|ed|ing)|costs?|how much|licen[cs](?:e|es|ing)|subscriptions?|paid (?:plan|version|tier)|free tier|pay(?:ing)? for|per (?:seat|user|month))\b/i],
527
528
  ["install", /\b(?:install(?:ing|ed|ation)?|npm i|npx|download(?:ing)?)\b|\bhow (?:do|can|would|could) (?:i|we|you|one) (?:set (?:it |this )?up|get started|start|use|try|get|run)\b|\bwhere (?:can|do) (?:i|we) (?:get|find)\b/i],
528
529
  ["team", /\b(?:my|our|whole)\s+(?:dev\s+|engineering\s+)?(?:team|teams|company|org|organi[sz]ation|startup)\b|\bteam (?:use|plan|licen[cs]e|version|pricing)\b|\bfor (?:a |small )?teams\b|\b(?:enterprise|seats)\b/i],
529
530
  ];
531
+ // ---- the 4-week test: a row a week (Marketing) -------------------------------------
532
+ // Does posting bring anyone? Four weeks, a row each. Weeks run from the day of your
533
+ // first draft; week 1 is the first of them a post went out in (until one has, the one
534
+ // you're in: a week with nothing posted doesn't count), or the day post-test.json's
535
+ // start names, when you've moved it ({ "start": "2026-10-13" }). Each row: posts published, and how many
536
+ // carried a picture or video; replies on LinkedIn (its watched notifications, marked
537
+ // by watch.mjs markNews), the ones that may be a customer and those asking what it
538
+ // costs; and npm installs of the packages your posts are about. Profile visits are
539
+ // only in LinkedIn's own analytics. null before there's a first draft.
540
+ const TEST_WEEKS = 4;
541
+ const dayStart = (ts) => { const d = new Date(ts); d.setHours(0, 0, 0, 0); return d.getTime(); };
542
+ const addDays = (ts, n) => { const d = new Date(ts); d.setDate(d.getDate() + n); return d.getTime(); };
543
+ const ymd = (ts) => { const d = new Date(ts); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; };
544
+ function testPlan(paths = PATHS, now = Date.now()) {
545
+ const posts = loadPosts(paths).posts;
546
+ let set = NaN; try { set = Date.parse(String(JSON.parse(readFileSync(paths.test, "utf8")).start || "").slice(0, 10) + "T00:00:00"); } catch {}
547
+ const first = posts.reduce((m, p) => Math.min(m, Number(p.drafted) || Infinity), Infinity);
548
+ if (!set && first === Infinity) return null;
549
+ let start = set ? dayStart(set) : dayStart(first);
550
+ if (!set) { const out = postLog(paths).filter((l) => l.action === "approved").map((l) => Number(l.ts)).sort((a, b) => a - b)[0], to = out || now; while (addDays(start, 7) <= to) start = addDays(start, 7); }
551
+ return { start, weeks: Array.from({ length: TEST_WEEKS }, (_, i) => [addDays(start, 7 * i), addDays(start, 7 * (i + 1))]), posts };
552
+ }
553
+ // The npm packages your posts are about: the repos their git facts came from
554
+ // ("[21] symbiot · 2026-10-06 · changelog: …"), each one's package.json name unless private.
555
+ function testPackages(posts, map = {}) {
556
+ const repos = new Set(posts.flatMap((p) => (Array.isArray(p.sources) ? p.sources : []).map((x) => (String(x).match(/^(?:\[\d+\]\s*)?(.+?)\s+·\s/) || [])[1]).filter(Boolean)));
557
+ const out = [];
558
+ for (const r of repos) { if (!map[r]) continue; try { const j = JSON.parse(readFileSync(join(map[r], "package.json"), "utf8")); if (j.name && !j.private && !out.includes(j.name)) out.push(j.name); } catch {} }
559
+ return out.slice(0, 3);
560
+ }
561
+ function testWeeks({ paths = PATHS, now = Date.now(), news = [], installs = null, map = {} } = {}) {
562
+ const plan = testPlan(paths, now); if (!plan) return null;
563
+ const log = postLog(paths);
564
+ const rows = plan.weeks.map(([from, to], i) => {
565
+ const inW = (t) => Number(t) >= from && Number(t) < to, pub = log.filter((l) => l.action === "approved" && inW(l.ts)), rep = news.filter((n) => n.social && inW(n.ts));
566
+ return { week: i + 1, from: ymd(from), to: ymd(addDays(to, -1)), started: now >= from, over: now >= to, published: pub.length, media: pub.filter((l) => (l.media || []).length).length,
567
+ replies: rep.length, customers: rep.filter((n) => n.customer).length, pricing: rep.filter((n) => n.customer === "pricing").length, installs: installs && installs[i] != null ? installs[i] : null };
568
+ });
569
+ return { start: ymd(plan.start), end: ymd(addDays(plan.weeks[TEST_WEEKS - 1][1], -1)), rows, packages: testPackages(plan.posts, map), week: rows.filter((r) => r.started).length };
570
+ }
571
+ // npm's daily downloads for those packages, summed per test week (weeks not begun:
572
+ // null): [n, …] or null. get(url) reads npm's JSON (the app passes it: nothing here
573
+ // goes on the network by itself). One read per package for the whole test, kept a few hours.
574
+ const NPM_DAYS = "https://api.npmjs.org/downloads/range/";
575
+ let npmCache = { key: "", at: 0, counts: null };
576
+ async function testInstalls(t, { now = Date.now(), get = null } = {}) {
577
+ if (!t || !t.packages.length || !t.rows[0].started || typeof get !== "function") return null;
578
+ const key = t.packages.join(",") + "|" + t.start; if (npmCache.key === key && now - npmCache.at < 3 * 3600000) return npmCache.counts;
579
+ const counts = t.rows.map((r) => (r.started ? 0 : null));
580
+ for (const p of t.packages) {
581
+ let d = null; try { d = await get(`${NPM_DAYS}${t.start}:${t.end}/${encodeURIComponent(p)}`); } catch {}
582
+ if (!d || !Array.isArray(d.downloads)) return null;
583
+ for (const x of d.downloads) { const r = t.rows.findIndex((w) => x.day >= w.from && x.day <= w.to); if (r >= 0 && counts[r] != null) counts[r] += Number(x.downloads) || 0; }
584
+ }
585
+ npmCache = { key, at: now, counts };
586
+ return counts;
587
+ }
530
588
  function maybeCustomer(text) { const s = String(text || ""); const hit = CUSTOMER.find(([, re]) => re.test(s)); return hit ? hit[0] : ""; }
531
589
 
532
- export { PATHS, PLATFORM, SHARE_URL, KINDS, KIND_LABEL, LINKEDIN_ACTIVITY, NO_AI, voiceOf, loadVoice, voiceFromLinkedIn, tagsIn, changelogIn, gatherFacts, factLine, numbersIn, namesIn, checkClaims, parseDrafts, vetDraft, draftPosts, loadPosts, postLog, postsState, editPost, skipPost, approvePost, copyText, openUrl, maybeCustomer,
590
+ export { TEST_WEEKS, testWeeks, testInstalls, testPackages, PATHS, PLATFORM, SHARE_URL, KINDS, KIND_LABEL, LINKEDIN_ACTIVITY, NO_AI, voiceOf, loadVoice, voiceFromLinkedIn, tagsIn, changelogIn, gatherFacts, factLine, numbersIn, namesIn, checkClaims, parseDrafts, vetDraft, draftPosts, loadPosts, postLog, postsState, editPost, skipPost, approvePost, copyText, openUrl, maybeCustomer,
533
591
  mediaType, mediaDir, mediaWords, addMedia, removeMedia, mediaFile, pictureOfPage, clipOfPage, concatList, toVideo, canClip, NO_FFMPEG, localApps, screenFor, picturesFor, listening };
package/server.mjs CHANGED
@@ -3,6 +3,7 @@
3
3
  // work, on 127.0.0.1 only and behind the per-install token. (Watch on your phone
4
4
  // listens on your network separately, only for that: phone.mjs.)
5
5
  import { spawn, spawnSync } from "node:child_process";
6
+ import { toggleAway, closeAway } from "./away.mjs";
6
7
  import { homedir } from "node:os";
7
8
  import { join } from "node:path";
8
9
  import { fileURLToPath } from "node:url";
@@ -13,7 +14,7 @@ import { createServer } from "node:http";
13
14
  import { randomBytes } from "node:crypto";
14
15
  import { EMBEDDED_UI } from "./ui.mjs";
15
16
  import { VERSION, LATEST_VERSION, REGISTRY, semverGt, checkLatest, CONFIG_PATH, loadConfig, saveConfig, loadTasks, hasCmd, chromeBinary, CONFIG_DIR } from "./core.mjs";
16
- import { shSingle, handoffCmd, setHandoffCmd, grantAgent, runHandoff, track, detectHandoffs, connectorsInfo, answerQuestions, skipIdea, agentsList, startWaiting, parkLane, parkedPaths, autoAllowSweep, trustFull } from "./agents.mjs";
17
+ import { shSingle, handoffCmd, setHandoffCmd, grantAgent, runHandoff, track, detectHandoffs, connectorsInfo, linkReach, answerQuestions, skipIdea, agentsList, startWaiting, parkLane, parkedPaths, autoAllowSweep, trustFull } from "./agents.mjs";
17
18
  import { PROVIDERS, resolveProvider, connectProvider, detectHardware, recommendModels, hasOllama, ollamaInstall, ensureOllama, useOllamaModel } from "./ai.mjs";
18
19
  import { SCAN, SCAN_TIMEOUT_MS, scanRoots, scanHome, addScanRoot, removeScanRoot, buildMap, nodeDetail, repoPathMap } from "./scan.mjs";
19
20
  import { computeDrift } from "./drift.mjs";
@@ -22,9 +23,9 @@ import { repoReview, repoSuggest, folderSuggest, taskChat, clearTaskChat, mailSt
22
23
  import { loadScreens, screenImage, captureScreen, splitScreen, listMonitors, allowScreenshots, importScreen, setRegions, renameScreen, removeScreen, blueprint, clickRegion } from "./screens.mjs";
23
24
  import { mapPage, wholePage, pressRegion, typeRegion, scrollPage, signIn, keepBrowserOpen, isTrusted, trustedSites, trustSite, untrustSite } from "./headless.mjs";
24
25
  import { weeklyState, setWeekly, runWeekly, startWeekly, autostartState, setAutostart } from "./desktop.mjs";
25
- import { watchState, addWatch, setEvery, removeWatch, clearNews, seenWatch, checkWatch, startWatches, setBrief, draftReply, openChat, watchBoard, boardChat, clearBoardChat } from "./watch.mjs";
26
+ import { markNews, newsSince, watchState, addWatch, setEvery, removeWatch, clearNews, seenWatch, checkWatch, startWatches, setBrief, draftReply, openChat, watchBoard, boardChat, clearBoardChat } from "./watch.mjs";
26
27
  import { linksState, linkSite, checkLink, unlinkSite } from "./links.mjs";
27
- import { postsState, draftPosts, approvePost, editPost, skipPost, voiceFromLinkedIn, addMedia, removeMedia, mediaFile, mediaDir, pictureOfPage, clipOfPage, openUrl } from "./post.mjs";
28
+ import { testWeeks, testInstalls, postsState, draftPosts, approvePost, editPost, skipPost, voiceFromLinkedIn, addMedia, removeMedia, mediaFile, mediaDir, pictureOfPage, clipOfPage, openUrl } from "./post.mjs";
28
29
  import { mindState, forget } from "./mind.mjs";
29
30
  import { lanesTick, lanesState, partlyDone, orcaRelink } from "./lanes.mjs";
30
31
  import { keepFacts, skipFacts, awaitTick, awaitingState, stopWaiting } from "./handback.mjs";
@@ -213,6 +214,7 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
213
214
  const json = (res, obj) => { res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify(obj)); };
214
215
  const screenOut =(s) => (s && s.id ? { ...s, blueprint: blueprint(s), ...(s.page ? { trusted: isTrusted(s.page.url) } : {}) } : s && s.screens ? { ...s, screens: s.screens.map(screenOut) } : s);
215
216
  let UPDATING = null; // an update in flight: { target, attempt, retrying? }
217
+ process.on("exit", () => closeAway()); // Away's windows go with the app (a quit, an update's restart)
216
218
  const server = createServer(async (req, res) => {
217
219
  const u = new URL(req.url, "http://127.0.0.1");
218
220
  if (req.method === "GET" && u.pathname === "/") { res.writeHead(200, { "content-type": "text/html; charset=utf-8" }); res.end(EMBEDDED_UI); return; }
@@ -332,13 +334,21 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
332
334
  if (u.pathname === "/api/mind") return json(res, mindState());
333
335
  if (u.pathname === "/api/mind/forget" && req.method === "POST") { const b = await readBody(req); return json(res, forget(String(b.id || ""))); }
334
336
  // Links (links.mjs): one click per standard work site: sign in, trust it, watch it.
335
- if (u.pathname === "/api/links") return json(res, linksState());
337
+ if (u.pathname === "/api/links") { let reach = null; try { reach = linkReach(); } catch {} return json(res, { ...linksState(), ...(reach ? { reach } : {}) }); } // reach: which sites agent runs can use too (agents.mjs)
336
338
  if (u.pathname === "/api/links/link" && req.method === "POST") { const b = await readBody(req); return json(res, await linkSite(String(b.id || ""))); }
337
339
  if (u.pathname === "/api/links/check" && req.method === "POST") { const b = await readBody(req); return json(res, await checkLink(String(b.id || ""))); }
338
340
  if (u.pathname === "/api/links/unlink" && req.method === "POST") { const b = await readBody(req); return json(res, unlinkSite(String(b.id || ""))); }
339
341
  // Posts (post.mjs): the week's drafts, waiting on you. Approve only records your yes:
340
342
  // the page copies the post itself and opens LinkedIn's share box; nothing is posted.
341
343
  if (u.pathname === "/api/posts") return json(res, postsState());
344
+ // Marketing: replies on LinkedIn (its watched notifications, marked "maybe a customer"),
345
+ // and the 4-week test's table, with npm installs of the packages the posts are about
346
+ if (u.pathname === "/api/marketing") {
347
+ const ws = watchState(), news = markNews(newsSince(24 * 60), ws.watches), p = postsState(), t = testWeeks({ news, map: repoPathMap() });
348
+ let n = null; try { n = await testInstalls(t, { get: (url) => fetch(url, { signal: AbortSignal.timeout(8000) }).then((r) => (r.ok ? r.json() : null)) }); } catch {}
349
+ if (t && n) t.rows.forEach((r, i) => { r.installs = n[i]; });
350
+ return json(res, { linkedin: p.linkedin, replies: news.filter((x) => x.social && x.ts >= Date.now() - 30 * 86400000).slice(0, 30), test: t });
351
+ }
342
352
  if (u.pathname === "/api/posts/draft" && req.method === "POST") return json(res, await draftPosts());
343
353
  if (u.pathname === "/api/posts/approve" && req.method === "POST") { const b = await readBody(req); return json(res, approvePost(String(b.id || ""), { copy: null })); }
344
354
  if (u.pathname === "/api/posts/edit" && req.method === "POST") { const b = await readBody(req); return json(res, editPost(String(b.id || ""), b.text)); }
@@ -363,6 +373,7 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
363
373
  if (u.pathname === "/api/work/go" && req.method === "POST") return json(res, workGo());
364
374
  if (u.pathname === "/api/firststeps") return json(res, firstSteps()); // Settings' first steps: what's set up, in order
365
375
  if (u.pathname === "/api/home/answer" && req.method === "POST") { const b = await readBody(req); return json(res, homeAnswer(b.id, { pick: b.pick, text: b.text })); }
376
+ if (u.pathname === "/api/away" && req.method === "POST") { const b = await readBody(req); return json(res, toggleAway(`http://127.0.0.1:${server.address().port}/?t=${TOKEN}`, b.open)); }
366
377
  if (u.pathname === "/api/home/ask" && req.method === "POST") { const b = await readBody(req); return json(res, await homeAsk(b.question, { images: saveShots(b.images) })); }
367
378
  if (u.pathname === "/api/adapt") return json(res, adaptState({ from: String(u.searchParams.get("from") || ""), commit: u.searchParams.get("commit") === "1", ...(u.searchParams.has("touch") ? { touch: u.searchParams.get("touch") === "1" } : {}) }));
368
379
  if (u.pathname === "/api/adapt/use" && req.method === "POST") { const b = await readBody(req); return json(res, noteUse(b)); }
package/tasks.mjs CHANGED
@@ -433,7 +433,10 @@ function shipChanges(path, texts, opts = {}) {
433
433
  const base = gitDefaultBranch(path), day = new Date().toISOString().slice(0, 10); let branch = ch.branch, fresh = "";
434
434
  const newBranch = () => {
435
435
  const stem = "symbiot/" + branchSlug(!texts.length ? `changes-${day}` : texts.length === 1 ? texts[0] : `${texts.length}-tasks-${day}`);
436
- let b = stem; for (let i = 2; git(path, ["rev-parse", "--verify", "-q", "refs/heads/" + b]).ok; i++) b = `${stem}-${i}`;
436
+ // a name free here and on origin: an earlier day's "7 tasks" may be merged and gone
437
+ // locally but still there, and pushing onto it is refused
438
+ const taken = (b) => git(path, ["rev-parse", "--verify", "-q", "refs/heads/" + b]).ok || git(path, ["rev-parse", "--verify", "-q", "refs/remotes/origin/" + b]).ok;
439
+ let b = stem; for (let i = 2; taken(b); i++) b = `${stem}-${i}`;
437
440
  return b;
438
441
  };
439
442
  if (branch === base) {
@@ -462,7 +465,13 @@ function shipChanges(path, texts, opts = {}) {
462
465
  const noted = (note) => ({ ...out, note: (fresh ? fresh + " " : "") + note });
463
466
  if (opts.push === false) return out;
464
467
  if (!git(path, ["remote", "get-url", "origin"]).ok) return noted("No origin remote — committed locally.");
465
- const ps = git(path, ["push", "-u", "origin", branch], 120000);
468
+ let ps = git(path, ["push", "-u", "origin", branch], 120000);
469
+ // refused because origin's branch of that name went another way (an earlier PR of
470
+ // that name, merged): the commit moves to a fresh name and goes up on that
471
+ if (!ps.ok && /rejected|non-fast-forward|fetch first/i.test(ps.err)) {
472
+ const next = newBranch();
473
+ if (git(path, ["branch", "-m", branch, next]).ok) { branch = next; out.branch = next; ps = git(path, ["push", "-u", "origin", branch], 120000); }
474
+ }
466
475
  if (!ps.ok) return noted("Committed, but the push failed: " + (ps.err.split("\n").filter(Boolean).pop() || "unknown error"));
467
476
  out.pushed = true;
468
477
  if (opts.pr === false) return out;