Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions docs/GUI.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,8 @@ A question from a run (an unknown form field, an option to pick, "did it submit?
stays. Esc clears the search. A text search shows every match as its own card (8 at a time, "Show
more"; the posting cut short), each with its own Approve / Not for me buttons (y / n keys are off there;
a decided card turns into one line with Change). The job you were on is left undecided; clearing the
search comes back to it. A kind-of-work choice alone stays one job at a time.
search comes back to it. A kind-of-work choice alone stays one job at a time, and is picked from the
list the page already has (2026-10-28, my request: it took ~10 s): no trip to the server, instant.
Apply (2026-10-16, my request: one place per sheet tab; the Sheet screen merged in) three tabs, each with its
count, the one you picked remembered for the session: NUworks co-ops (the sheet's NUworks tab: the weekly
limit), "<term> internships" ("Other jobs" when internships are off: the sheet's Other jobs tab, no
Expand Down Expand Up @@ -255,7 +256,11 @@ A question from a run (an unknown form field, an option to pick, "did it submit?
confirmed, the screen when it asked; Approve / Decline with a note), the end question (Yes / No / No: do
it on the laptop), a question Claude Code asks in its own terminal (answer it there; the reply box stays
off), Reply, End session, End: do it on the laptop. It asks again every 4 s; what you type is kept
(docs/REMOTE.md, AN ASSISTANT'S SCREEN)
(docs/REMOTE.md, AN ASSISTANT'S SCREEN). The screen keeps the session it opened on (its key,
review.key): a session that ended, a slot nobody runs in, or the next session started in that slot
goes back to Apply's Assistants tab with a toast (2026-10-28, my request: it showed "No session there",
or switched to the new session as if it were the same). Its taps carry the key; the server refuses one
for a session no longer in that slot (gui.assistant_post).

SETUP WIZARD (onboard.py + gui_static/setup.js; each step checks itself; a step opens when you click it, the first one
not done is open at the start; "You're set up" + Go to Today at the end; Settings reuses some steps' bodies)
Expand Down Expand Up @@ -298,6 +303,9 @@ SPEED (2026-10-22, my request: "the apply page takes quite a while to load")
gui.other_rows); opening the screen (recheck=1) reads both again in the background, and a change reloads the lists.
- Review: the list (pool, taste model, order) is kept until data/, your settings or the sheet's rows change
(gui.review_lists, keyed by every data/ file's size and time: a few ms); searches and filters only narrow it. The
page sends no kind of work: it picks that from the list itself; and it never waits for decisions still being sent (the
server keeps every list's jobs decidable, APP.queue; the page leaves out what it decided, app.js pick), so a decision
that writes the sheet (an Approve: 3-4 Google calls) never holds up the next list. The
taste model trains again only when your ratings or a job's text change (jobs.taste_for). The lists are made ahead at
start and a few seconds after your last decision (warm_review). (2026-10-22, my request: Review still took 0.5-3 s)
Review asks with quick=1: the list made last comes at once (about 10 ms), never waiting on Google or a rebuild; when
Expand Down
4 changes: 4 additions & 0 deletions docs/REMOTE.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,10 @@ AN ASSISTANT'S SCREEN (#/assistant/<slot> in the homelab's app; review.view / ac
back to Apply > Assistants, and the screen says your answer until the run closes. A session you
answered is no longer listed or counted there (gui.assistants), though it holds its slot a few
seconds more while it writes the sheet and cleans up (2026-10-23, my request).
- The screen is for the session it opened on, not the slot (2026-10-28, my request: once one ended, the next session
started in its slot showed there as if it were the same one): review.key (its run folder, hashed) comes with the view
and goes with every tap; a different key, or none in the slot, sends the screen back to Apply > Assistants with a
toast, and gui.assistant_post refuses a tap whose key is not the slot's session now ("That session has ended").
- Each answer goes in the run's actions.log with the paired device's name.
- The agent on the homelab never opens your own network: localhost, the LAN, Tailscale addresses (so not this page,
not the browser view) are refused in its browser and WebFetch (assist.own_network).
Expand Down
3 changes: 2 additions & 1 deletion src/nuauto/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
nuauto intern ... Summer 2027 internships from Simplify's and vanshb03's lists (update, approve, rate, stats, pool,
fetch <url>)
nuauto accounts ... the logins the assistant made on job sites (list; export: Bitwarden's import file)
nuauto workday [<text>] your statuses on each company's Workday (read-only); offers to mark rejections Rejected
nuauto review ... NUauto's page on the homelab: pair a phone, list / unpair devices, demo (docs/REMOTE.md)
nuauto link ... this laptop's link to that page (pair, status, forget)
nuauto answers ... answer bank (init, list)
Expand All @@ -43,7 +44,7 @@
TOOLS = {"jobs": "jobs", "answers": "answers", "sheet": "sheet", "setup-sheet": "setup_sheet",
"inspect": "inspect_form", "daily": "daily", "web": "web", "health": "health", "demo": "demo",
"onboard": "onboard", "selftest": "selftest", "insights": "insights", "intern": "intern", "accounts": "accounts",
"review": "review", "link": "link"}
"review": "review", "link": "link", "workday": "workday"}
COMMANDS = ("approve", "rate", "apply", "status", "update", "login", "test", "doctor", "assist", "gui", "deploy")


Expand Down
7 changes: 4 additions & 3 deletions src/nuauto/gui.py
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ def __init__(self):
self.first_round_done = False
self.rows, self.rows_at, self.rows_error, self.rows_try_at = None, 0, None, 0
self.rows_version = 0 # sheet_version when these rows were read (the Apply screen's answer says it)
self.queue = {} # job id -> pool entry, from the last review queue
self.queue = {} # job id -> pool entry, from every review queue made this session
self.other_urls = None # the Other jobs tab's links (not Proposed), as last read: the internship count
self.other_cache = None # (when, rows, sheet_version then) of the Other jobs tab (gui.other_rows)
self.other_last, self.other_exists = None, None # the tab's rows as last read (kept by other_changed), is there one
Expand Down Expand Up @@ -933,7 +933,8 @@ def gone(job_id): # decided here after the list was made (approve / no in Appro
todo = [r for r in todo if not gone(r["id"])]
urgent = [r for r in urgent if not gone(r["id"])]
ratings = jobs.load("ratings.json", {})
APP.queue = {r["id"]: r for r in todo}
APP.queue.update((r["id"], r) for r in todo) # (kept from earlier lists too: a decision sent behind the screen
# may arrive after the page asked for a newer list without that job)
counts = {}
for r in todo:
counts[r["category"]] = counts.get(r["category"], 0) + 1
Expand Down Expand Up @@ -2474,7 +2475,7 @@ def assistant_post(body):
raise Refused("An assistant on this computer runs in its own terminal window: answer it there.")
slot = body.get("slot")
s = review.session(int(slot)) if str(slot).isdigit() else None
if not s:
if not s or ("key" in body and body["key"] != review.key(s)): # (key: the page's session; its slot may hold the next)
raise Refused("That session has ended.")
return review.act(s, body, device_name())

Expand Down
56 changes: 41 additions & 15 deletions src/nuauto/gui_static/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -699,6 +699,7 @@ screens.review = async (view) => {
const interns = (S.state && S.state.internships) || { on: false };
let source = interns.on && sessionStorage.getItem("reviewSource") === "intern" ? "intern" : "nuworks";
let jobs = [], i = 0, card = null, history = {}, shown = 0, total = 0, resumeId = null, listed = 0, loadSeq = 0;
let all = []; // the list the server sent (every kind of work); jobs: the part of it the kind-of-work menu picks
let moved = false; // you went to another job (or decided one) since the list came: a newer list keeps your place
let q = sessionStorage.getItem("reviewQ") || "", category = sessionStorage.getItem("reviewCat") || "";
const cards = new Map(); // job id -> promise of its card (the next ones are fetched ahead)
Expand Down Expand Up @@ -737,10 +738,22 @@ screens.review = async (view) => {
function setFilter(newQ, newCat) {
if (newQ === q && newCat === category) return;
if (!q && newQ && jobs[i]) resumeId = jobs[i].id; // the job you were on stays undecided; clearing the search comes back to it
const same = newQ === q;
q = newQ; category = newCat;
sessionStorage.setItem("reviewQ", q);
sessionStorage.setItem("reviewCat", category);
load();
if (!same) return load();
// only the kind of work changed: picked from the list already here, with no trip to the server
shown++; relist(); moved = false; show();
}

// Decided on this screen in a way that takes the job out of this list (as the server does: gui.review's gone):
// in Rate only, any rating; in Approve, a decision made in Approve. A decision still being sent counts already.
const decidedHere = (j) => { const h = history[j.id]; return !!h && !h.failed && (mode === "rate" || h.mode === "approve"); };
const pick = () => all.filter((j) => (!category || j.category === category) && !decidedHere(j));
function relist() {
jobs = pick(); i = 0; listed = 0;
if (!q && resumeId) { i = Math.max(0, jobs.findIndex((x) => x.id === resumeId)); resumeId = null; }
}

function drawCategories(list) {
Expand Down Expand Up @@ -772,15 +785,16 @@ screens.review = async (view) => {
const mine = ++loadSeq; // only the newest load may show its list (an older, slower answer is dropped)
jobs = []; i = 0; card = null; shown++; // while it loads, no key decides on the old list (and no card of it comes back)
const slow = setTimeout(() => { if (mine === loadSeq && body.isConnected) fill(body, el("p", { class: "empty", text: "Loading the review list…" })); }, 150);
await queue; // decisions still being written go first: the server decides on its last list, which this one replaces
const params = new URLSearchParams({ mode, q, category, source });
// (no waiting for decisions still being sent: the server keeps every list's jobs decidable, and the ones decided
// here are left out by pick; the kind of work is picked here too, so the server sends every kind)
const params = new URLSearchParams({ mode, q, source });
let r;
try { r = await api.get(`/api/review?${params}&quick=1`); }
catch (e) { clearTimeout(slow); if (mine === loadSeq && body.isConnected) fill(body, el("div", { class: "note fail", text: e.message })); return; }
clearTimeout(slow);
if (mine !== loadSeq || !body.isConnected) return;
jobs = r.jobs; total = r.total; i = 0; listed = 0; cards.clear(); drawCategories(r.categories);
if (!q && resumeId) { i = Math.max(0, jobs.findIndex((x) => x.id === resumeId)); resumeId = null; }
all = r.jobs; total = r.total; cards.clear(); drawCategories(r.categories);
relist();
moved = i > 0;
show();
if (r.stale) refresh(mine, params);
Expand All @@ -791,14 +805,16 @@ screens.review = async (view) => {
// Not moved yet: the new list from its first job (the quick list's first may no longer come first).
async function refresh(mine, params) {
let r;
try { await queue; r = await api.get(`/api/review?${params}`); } catch (e) { return; }
try { r = await api.get(`/api/review?${params}`); } catch (e) { return; }
if (mine !== loadSeq || !body.isConnected) return;
const old = jobs, was = new Map(old.map((j) => [j.id, JSON.stringify({ ...j, rating: null })]));
const changed = (j) => was.get(j.id) !== JSON.stringify({ ...j, rating: null }); // (a rating made here is not a change)
total = r.total; drawCategories(r.categories);
if (r.jobs.length === old.length && r.jobs.every((j, k) => j.id === old[k].id && !changed(j))) return position();
r.jobs.forEach((j) => { if (changed(j)) cards.delete(j.id); }); // (its card may say something new too)
jobs = r.jobs;
all = r.jobs;
const fresh = pick();
if (fresh.length === old.length && fresh.every((j, k) => j.id === old[k].id && !changed(j))) return position();
fresh.forEach((j) => { if (changed(j)) cards.delete(j.id); }); // (its card may say something new too)
jobs = fresh;
if (listMode()) { const had = listed, y = window.scrollY; showList(true, had); window.scrollTo(0, y); return; }
const at = new Map(jobs.map((j, k) => [j.id, k]));
const next = moved ? old.slice(i).find((j) => at.has(j.id)) : jobs[0];
Expand Down Expand Up @@ -923,7 +939,7 @@ screens.review = async (view) => {
function submit(j, decision, onfail) {
const before = history[j.id], oldRating = j.rating;
if (before && before.decision === "approve" && decision === "approve") return false; // already in the sheet
const entry = { decision, row: null, tab: null, previous: before ? before.previous : null };
const entry = { decision, mode, row: null, tab: null, previous: before ? before.previous : null };
history[j.id] = entry;
j.rating = decision === "no" ? 0 : 1;
let undone = false;
Expand Down Expand Up @@ -2019,6 +2035,7 @@ screens.assistant = async (view) => {
const there = String(S.param || "").startsWith("h"), slot = String(S.param || "").replace(/^h/, "");
const api_ = there ? "/api/homelab/assistant" : "/api/assistant";
let d = null, version = null, timer = null, first = true;
let who = null; // the session this screen opened on (its key): the slot is reused by the next one once it ends
const note = el("input", { type: "text", placeholder: "A note for the agent (when you decline)", "aria-label": "Note", testid: "as-note" });
const reply = el("textarea", { rows: "4", maxlength: "4000", "aria-label": "Reply", testid: "as-reply" });
const term = el("pre", { class: "log term", testid: "as-terminal", tabindex: "0", "aria-label": "Its terminal" });
Expand All @@ -2030,7 +2047,7 @@ screens.assistant = async (view) => {
async function send(body, busy) {
if (busy) { busy.disabled = true; busy.textContent = busy.dataset.busy || "Working…"; }
try {
const r = await api.post(api_, { slot: Number(slot), ...body });
const r = await api.post(api_, { slot: Number(slot), key: who, ...body });
toast(r.message);
if (body.do === "reply") reply.value = "";
if (r.closing) { sessionStorage.setItem("applyTab", "assistants"); location.hash = "#/apply"; return; }
Expand Down Expand Up @@ -2106,18 +2123,27 @@ screens.assistant = async (view) => {
}
}

// it ended (or never ran in that slot): back to Apply's Assistants tab, where the others are (2026-10-28, my request;
// it showed "No session there", or the next session started in the slot, as if it were this one)
function ended() {
toast(d ? `${d.company || "That assistant"}: the session has ended.` : `No assistant is running in slot ${slot}.`);
sessionStorage.setItem("applyTab", "assistants");
location.replace("#/apply");
}

async function load() {
clearTimeout(timer);
let now;
try {
d = await api.get(`${api_}/${encodeURIComponent(slot)}`);
now = await api.get(`${api_}/${encodeURIComponent(slot)}`);
} catch (e) {
if (!view.isConnected || S.gone) return;
if (S.offline) { timer = setTimeout(load, 4000); return; } // the homelab can't be reached a moment: ask again
fill(view, el("div", { class: "card", testid: "as-gone" }, el("h2", { text: "No session there" }),
el("p", { class: "muted", text: "It has ended (or it never ran in that slot)." }), el("a", { class: "btn", href: "#/apply", text: "Apply", onclick: () => sessionStorage.setItem("applyTab", "assistants") })));
return;
return ended();
}
if (!view.isConnected) return;
if (who && now.key !== who) return ended();
d = now; who = d.key;
if (d.version !== version) { version = d.version; draw(); } else { term.textContent = d.terminal || "(not readable)"; }
if (d.browser && ["working", "approve", "waiting"].includes(d.status)) live.src = `${api_}/${slot}/shot.png?live=${Date.now()}`;
timer = setTimeout(load, 4000);
Expand Down
8 changes: 7 additions & 1 deletion src/nuauto/review.py
Original file line number Diff line number Diff line change
Expand Up @@ -392,11 +392,17 @@ def read_answer(s):
return assist.read_json(os.path.join(s["run"], assist.DECISION))


def key(s):
"""Which session this is (its run folder's, hashed): a slot is reused by the next session once one ends, so the
screen keeps this and a tap carrying another one is refused (assistant_post)."""
return hashlib.sha256(s["run"].encode()).hexdigest()[:16]


def summary(s):
"""One running session, for the lists (Company sites, Today)."""
word, text = status(s)
st, info = s["state"], s["info"]
return {"slot": s["slot"], "tab": info.get("tab"), "row": info.get("row"),
return {"slot": s["slot"], "key": key(s), "tab": info.get("tab"), "row": info.get("row"),
"company": st.get("company") or info.get("company") or "", "title": st.get("job_title") or "",
"where": st.get("where") or "", "host": info.get("host") or "", "since": info.get("since"),
"status": word, "text": text}
Expand Down
3 changes: 2 additions & 1 deletion src/nuauto/sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@ def watch_unit(unit="nuauto-daily.service"):
PACKAGE = ["__init__.py", "__main__.py", "cli.py", "config.py", "sheet.py", "jobs.py", "fields.py", "daily.py", "browser.py",
"apply.py", "answers.py", "inspect_form.py", "setup_sheet.py", "sync.py", "web.py", "doctor.py", "assist.py",
"health.py", "demo.py", "gui.py", "onboard.py", "window.py", "window_gtk.py", "selftest.py", "deploy.py", "insights.py",
"manage.py", "intern.py", "postings.py", "accounts.py", "mail.py", "screen.py", "review.py", "remote.py", "link.py", "letter.py"]
"manage.py", "intern.py", "postings.py", "accounts.py", "mail.py", "screen.py", "review.py", "remote.py", "link.py", "letter.py",
"workday.py"]
CODE = (["pyproject.toml", "README.md", "local_config.example.json", "CLAUDE.md", "CONTRIBUTING.md",
"docs/DEPLOY.md", "docs/PIPELINE.md", "docs/ARCHITECTURE.md", "docs/SAFETY.md", "docs/GUI.md",
"docs/REMOTE.md", "docs/ASSIST.md"]
Expand Down
Loading
Loading