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
11 changes: 7 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,10 +147,13 @@ and open it.
than something the reader has to infer.
- **Map**: systems, containers, components and code, with what the change
added, removed or touched, linked to files and code.
- **Threads**: _Ask now_ sends a question to the agent immediately and the
answer lands in the same thread. _Add to review_ holds a comment until you
submit with _Approve_ or _Request changes_. _Close_ ends the review
without approving it.
- **Threads**: _Send to the agent_ delivers a question at once and the answer
lands in the same thread. _Add to the review_ holds a comment until you
submit with _Approve_ or _Request changes_. _Close_ ends the review without
approving it. Each thread says where it stands - held, queued, delivered or
answered - and the panel says whether an agent is listening at all. Nothing
claims a reader is there when none is: a question asked with no agent
attached is queued, not lost, and reaches the agent the next time it checks.
- **Revisions**: every publish is sealed; switch back to earlier ones.
- **Theme**: the agent reads the project's design tokens and fonts and
publishes them with the review, so each review looks like the code it
Expand Down
3 changes: 2 additions & 1 deletion scripts/demo/record-browser.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,8 @@ await type(
await sleep(500);
await sleep(400);
await shot("files");
await click(".comment-popover button.ok");
// `.ok` is now "Send to the agent"; this comment is meant to be held for the review.
await clickByText(".comment-popover button", "Add to the review");
await sleep(1600);

// 3. Map: what the change added.
Expand Down
13 changes: 11 additions & 2 deletions skills/thurview/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,9 @@ Tell the user, in a few lines and nothing more:
- which theme source you used: the user's request, the project's design
system (name the files), or the default skin
- when the review has no map, why not, in one clause
- that you are now waiting for their questions and their decision
- that you are now waiting for their questions and their decision, and that
a question asked after you stop waiting is queued rather than lost - the
page tells them which of the two it is

The page explains its own controls; do not describe them.

Expand All @@ -265,9 +267,16 @@ nothing: keep `--timeout` under that limit and run `wait` again on `timeout`.
When the tool can run a command in the background and wake you when it exits,
run `wait` that way, so the user has the terminal back while they read.

While `wait` runs the reader's page says an agent is listening, and says the
opposite within seconds of it returning. Do not loop it to look present: a
question asked with nobody waiting is queued, not lost, and `thurview`
reports it as `needsAgent` the next time you run any command in the
worktree.

`wait.reason` says what happened, with the threads that need you:

- `question`: an "Ask now" thread. Answer each thread in `threads` with
- `question`: a thread the reader sent to you. Answer each thread in
`threads` with
`thurview threads reply <threadId> --review <id> --body "<answer>"`. Do
not change the document for a question. Wait again.
- `awaiting-agent-updates`: the reader submitted with "Request changes".
Expand Down
61 changes: 54 additions & 7 deletions skills/thurview/references/lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ warns when the branch moved past them.
| `accepted` | Terminal. Cannot be republished. |
| `closed` | Terminal. Ended without approval. Cannot be republished. |

"Ask now" does not change the status. "Submit review" with "Request changes"
Asking the agent a question does not change the status. "Submit review" with "Request changes"
sets `awaiting-agent-updates`; with "Approve" sets `accepted`; with "Close"
sets `closed`.

Expand All @@ -36,21 +36,62 @@ Dismissal is separate: the reader removes the review from the active list and

## Threads

Two kinds, chosen by the reader when creating one:
Two kinds, chosen by the reader when creating one. In the browser these are
the two buttons on the comment box, one click each:

- `ask` mode (a question): delivered at once. `wait` returns `question`.
Answer with `threads reply`. It stays open until the reader resolves it;
open questions never block a republish.
- `review` mode (a comment): held as pending until the reader submits. Then
`wait` returns `awaiting-agent-updates` with the submitted threads.
- `ask` mode (a question, "Send to the agent"): submitted on creation and
delivered at once. `wait` returns `question`. Answer with `threads reply`.
Open questions never block a republish.
- `review` mode (a comment, "Add to the review"): held as pending until the
reader submits. Then `wait` returns `awaiting-agent-updates` with the
submitted threads.

Targets: a document block (with an optional quoted selection), a file line
on the base or head side, a map node, or the whole review.

### Status, `submitted` and `needsAgent`

Two flags and one derived predicate decide whether a thread reaches you.
`needsAgent` is the whole queue: `wait` reports it, `threads list --open`
counts it, and a thread outside it will not be delivered to anyone.

```text
needsAgent = status is open AND submitted AND the last message is the reader's
```

| Transition | status | submitted |
| ------------------------------ | -------------------- | ------------- |
| reader creates an `ask` thread | `open` | `true` |
| reader creates a `review` one | `open` | `false` |
| reader submits the review | unchanged | `true` (all) |
| **reader writes in a thread** | **forced to `open`** | `true` if ask |
| agent replies | unchanged | unchanged |
| `threads resolve` / Resolve | `resolved` | unchanged |
| Reopen | `open` | unchanged |

A message from the reader always reopens the thread. It has to: a reply that
left the thread resolved would sit at `needsAgent: false`, invisible to
`wait` and to `threads list --open`, and the reader would be writing to
nobody while the page still offered them a Reply button. Publishing a new
revision never touches a thread's status.

`publish` after the first revision requires zero open submitted comment
threads. Resolve a thread only when its requested change is present. Do not
rewrite or merge threads.

### Presence: what the reader is told

While `thurview wait` runs it writes a heartbeat to
`${THURVIEW_HOME:-~/.thurview}/agents/<reviewId>.json`, and the browser reads
it back as one of two sentences: an agent is listening now, or nothing is
listening and what you send is queued until one checks in. Nothing else
writes it, so presence is never inferred and never faked. A heartbeat older
than 15 seconds is a dead `wait`, not an agent.

That is why a question asked while you are away is not lost and does not need
you to sit in `wait`: it is queued, `thurview` reports it as `needsAgent` the
next time you run any command in the worktree, and you answer it then.

```sh
thurview threads list --review <id> [--open]
thurview threads get <threadId> --review <id>
Expand All @@ -64,6 +105,7 @@ thurview threads resolve <threadId> --review <id>
${THURVIEW_HOME:-~/.thurview}/
├── THURVIEW.md user guidance (optional)
├── server.json running server, if any
├── agents/<id>.json heartbeat of a running `wait`, removed when it ends
└── reviews/<id>/
├── review.md you edit
├── data.yaml you edit
Expand All @@ -90,3 +132,8 @@ again.

`thurview threads get <id>` truncates bodies over 1500 characters; pass
`--full` when the hint says so.

While `wait` runs, the reader's page says an agent is listening; when it
returns, the page says the opposite within seconds. Do not leave `wait`
running to look present when you are not going to answer, and do not loop it
to keep a queue drained: the queue survives you, and the reader is told so.
134 changes: 71 additions & 63 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import type { InterfaceDelta } from "./interfaces.js";
import { parseTheme, compileTheme, type CompiledTheme } from "./theme.js";
import { registerTheme } from "./highlight.js";
import { replyThread, setThreadStatus, needsAgent } from "./threads.js";
import { attach } from "./presence.js";
import { startServer } from "./server/server.js";
import { parseFlags, helpFor, str, bool, type FlagSpec } from "./flags.js";
import { VERSION } from "./version.js";
Expand Down Expand Up @@ -1140,78 +1141,85 @@ const commands: Record<string, (args: string[]) => Promise<Out>> = {
]);
const deadline = Date.now() + seconds * 1000;
const id = short(review.id);
// While this loop runs the reader is told an agent is listening, and told
// the opposite the moment it stops.
const listening = attach(review.id);
const rows = (ts: Thread[]) =>
ts.map((t) => ({
id: t.id,
kind: t.kind,
target: targetLabel(t.target),
last: lastMessage(t),
}));
while (Date.now() < deadline) {
const r = await readReview(review.id);
if (!r)
return {
wait: { reason: "review-deleted", id },
help: ["Stop the loop; the review no longer exists"],
};
const t = await readThreads(review.id);
const last = t.decisions[t.decisions.length - 1];
if (r.status === "awaiting-agent-updates") {
const need = t.threads.filter(needsAgent);
return {
wait: {
reason: "awaiting-agent-updates",
id,
status: r.status,
decision: last
? `${last.decision}${last.body ? `: ${truncate(last.body, 300)}` : ""}`
: "",
},
count: need.length,
threads: rows(need),
help: [
`Run \`thurview threads get <threadId> --review ${id}\` for the full thread`,
`Run \`thurview threads resolve <threadId> --review ${id}\` after addressing each`,
`Run \`thurview publish --review ${id}\` when every open comment is resolved`,
],
};
try {
while (Date.now() < deadline) {
const r = await readReview(review.id);
if (!r)
return {
wait: { reason: "review-deleted", id },
help: ["Stop the loop; the review no longer exists"],
};
const t = await readThreads(review.id);
const last = t.decisions[t.decisions.length - 1];
if (r.status === "awaiting-agent-updates") {
const need = t.threads.filter(needsAgent);
return {
wait: {
reason: "awaiting-agent-updates",
id,
status: r.status,
decision: last
? `${last.decision}${last.body ? `: ${truncate(last.body, 300)}` : ""}`
: "",
},
count: need.length,
threads: rows(need),
help: [
`Run \`thurview threads get <threadId> --review ${id}\` for the full thread`,
`Run \`thurview threads resolve <threadId> --review ${id}\` after addressing each`,
`Run \`thurview publish --review ${id}\` when every open comment is resolved`,
],
};
}
if (r.status === "accepted" || r.status === "closed")
return {
wait: {
reason: r.status,
id,
status: r.status,
decision: last
? `${last.decision}${last.body ? `: ${truncate(last.body, 300)}` : ""}`
: "",
},
help: ["The review is complete; report it and stop the loop"],
};
if (r.dismissed)
return {
wait: { reason: "review-dismissed", id, status: r.status },
help: ["Stop the loop; the reader dismissed the review"],
};
const asks = t.threads.filter((x) => needsAgent(x) && x.mode === "ask");
if (asks.length)
return {
wait: { reason: "question", id, status: r.status },
count: asks.length,
threads: rows(asks),
help: [
`Run \`thurview threads reply <threadId> --review ${id} --body "<answer>"\``,
`Run \`thurview wait --review ${id}\` again afterwards`,
],
};
await new Promise((res) => setTimeout(res, 700));
}
if (r.status === "accepted" || r.status === "closed")
return {
wait: {
reason: r.status,
id,
status: r.status,
decision: last
? `${last.decision}${last.body ? `: ${truncate(last.body, 300)}` : ""}`
: "",
},
help: ["The review is complete; report it and stop the loop"],
};
if (r.dismissed)
return {
wait: { reason: "review-dismissed", id, status: r.status },
help: ["Stop the loop; the reader dismissed the review"],
};
const asks = t.threads.filter((x) => needsAgent(x) && x.mode === "ask");
if (asks.length)
return {
wait: { reason: "question", id, status: r.status },
count: asks.length,
threads: rows(asks),
help: [
`Run \`thurview threads reply <threadId> --review ${id} --body "<answer>"\``,
`Run \`thurview wait --review ${id}\` again afterwards`,
],
};
await new Promise((res) => setTimeout(res, 700));
return {
wait: { reason: "timeout", id, status: review.status, seconds },
help: [
`Run \`thurview wait --review ${id}\` again, or report that the reader has not responded`,
],
};
} finally {
await listening.stop();
}
return {
wait: { reason: "timeout", id, status: review.status, seconds },
help: [
`Run \`thurview wait --review ${id}\` again, or report that the reader has not responded`,
],
};
},

async graph(args) {
Expand Down
47 changes: 47 additions & 0 deletions src/presence.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { rm } from "node:fs/promises";
import { agentFile, readJson, writeJson, now } from "./store.js";

/**
* Whether an agent is listening to a review right now, so the reader is told
* the truth about where their question went: an agent in `thurview wait` reads
* it within a second; with nobody attached it is queued until one next checks.
* Presence is never inferred - only a live `wait` writes it.
*/
export interface Presence {
attached: boolean;
/** when the attached agent last checked in, null when none ever has */
lastSeen: string | null;
}

/** A heartbeat older than this is a dead `wait`, not a listening agent. */
const TTL_MS = 15_000;
const BEAT_MS = 3_000;

export const NOBODY: Presence = { attached: false, lastSeen: null };

export async function presenceOf(reviewId: string): Promise<Presence> {
const rec = await readJson<{ at: string; pid: number }>(agentFile(reviewId));
if (!rec) return NOBODY;
const at = Date.parse(rec.at);
if (!Number.isFinite(at)) return NOBODY;
return { attached: Date.now() - at < TTL_MS, lastSeen: rec.at };
}

/** Announce that this process is waiting on the review until `stop()` is called. */
export function attach(reviewId: string): { stop: () => Promise<void> } {
let stopped = false;
const beat = () =>
writeJson(agentFile(reviewId), { at: now(), pid: process.pid }).catch(() => {});
void beat();
const timer = setInterval(() => {
if (!stopped) void beat();
}, BEAT_MS);
timer.unref();
return {
stop: async () => {
stopped = true;
clearInterval(timer);
await rm(agentFile(reviewId), { force: true });
},
};
}
3 changes: 3 additions & 0 deletions src/server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
submitReview,
deleteThread,
} from "../threads.js";
import { presenceOf } from "../presence.js";

const UI_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "ui");

Expand Down Expand Up @@ -207,12 +208,14 @@ export async function startServer(
...data,
threads: threads.threads,
decisions: threads.decisions,
agent: await presenceOf(id),
};
}

if (sub === "events") {
throw new HttpError(500, "handled elsewhere");
}
if (sub === "presence") return (await presenceOf(id)) as unknown as Json;
if (sub === "revisions") {
const out = [];
for (let n = 1; n <= review.revision; n++)
Expand Down
Loading
Loading