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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ Notable changes to `@questpie/agent-board` (CLI binaries: `agent-board`, `agent`

## Unreleased

## 0.9.0 — 2026-06-14

- **Unshare from the web viewer**: every task/spec/knowledge/design detail now reflects its live share state. `/api/board` reports each artifact's published link, so an already-shared item opens showing its URL with **Copy link**, **Re-share**, and a new **Unshare** action — a loopback-only `DELETE /api/share` that deletes the backing gist and clears the `shares.json` entry. The board now mirrors the CLI's `share rm`: you can see what's shared and revoke it without leaving the viewer.

## 0.8.0 — 2026-06-14

- **Share a single artifact as a public link**: `agent-board share <design|spec|task|knowledge> <id>` publishes one board artifact as a secret GitHub gist (through your existing `gh` auth, `gist` scope) rendered by a zero-build static viewer (`docs/share`, hosted on the repo's GitHub Pages). A design's HTML bundle is inlined into one self-contained file — local stylesheets, scripts, images, and CSS `url(...)` become inline content and data URLs, while CDN/absolute references are left alone — so a recipient sees the mockup with nothing installed; specs, tasks, and knowledge render as Markdown. Re-sharing updates the same gist, so the link stays stable; shares are tracked in a non-invasive `shares.json` at the project root. `agent-board share list` and `agent-board share rm <kind> <id>` manage them. A secret gist is link-private, not access-controlled; point shares at a different viewer with `AGENT_BOARD_SHARE_VIEWER`. The web viewer (`agent-board web`) also gained a **Share** button on every task, spec, knowledge, and design detail — a single, loopback-only `POST /api/share` endpoint (the board's first mutating route, gated to localhost so binding `--host 0.0.0.0` can't let a LAN peer publish on your behalf).
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,7 @@ agent-board share rm design <id> # delete a share and its backing gist

Each share is stored as a **secret GitHub gist** (created through your existing `gh` auth) and rendered by a small static viewer. A design's HTML bundle is inlined into one file — local stylesheets, scripts, images, and `url(...)` assets become inline content and data URLs, while CDN/absolute references are left alone — so the recipient sees the mockup exactly as the board does, with nothing to install. Re-running `share` on the same artifact updates the same gist, so the URL stays stable.

The web viewer carries the same action: every task, spec, knowledge, and design detail has a **Share** button that posts to a localhost-only endpoint and shows the link inline, so you can publish without leaving the board.
The web viewer carries the same actions: every task, spec, knowledge, and design detail shows its live share state — already-shared items open with their link, **Copy**, **Re-share**, and **Unshare** (revoke). It all runs through a localhost-only endpoint, so you can publish and revoke without leaving the board.

Setup is one-time: authenticate `gh` with the `gist` scope (`gh auth login`), and enable GitHub Pages for the viewer (served from `docs/share`). Until Pages is live the command also prints the raw gist URL as a fallback. Point shares at a different viewer with `AGENT_BOARD_SHARE_VIEWER`.

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@questpie/agent-board",
"version": "0.8.0",
"version": "0.9.0",
"description": "Markdown task board and execution contract for coding agents",
"license": "MIT",
"repository": {
Expand Down
4 changes: 2 additions & 2 deletions src/share.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,7 @@ function indexPath(workspace: Workspace): string {
return join(workspace.projectPath, "shares.json");
}

async function readShareIndex(workspace: Workspace): Promise<Record<string, ShareRecord>> {
export async function readShareIndex(workspace: Workspace): Promise<Record<string, ShareRecord>> {
try {
const parsed = JSON.parse(await readFile(indexPath(workspace), "utf8"));
return parsed && typeof parsed === "object" ? (parsed as Record<string, ShareRecord>) : {};
Expand All @@ -277,7 +277,7 @@ async function writeShareIndex(workspace: Workspace, index: Record<string, Share
await atomicWrite(indexPath(workspace), `${JSON.stringify(index, null, 2)}\n`);
}

function shareKey(kind: ShareKind, scope: string, id: string): string {
export function shareKey(kind: ShareKind, scope: string, id: string): string {
return `${kind}:${scope}:${id}`;
}

Expand Down
40 changes: 35 additions & 5 deletions src/web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { listGoals, listProjects, resolveWorkspace, workspaceForGoal } from "./w
import { listTasks } from "./tasks.js";
import type { Workspace } from "./types.js";
import { listWireframes, wireframeAsset, wireframeWebPath } from "./wireframes.js";
import { parseShareKind, shareArtifact } from "./share.js";
import { parseShareKind, readShareIndex, removeShare, shareArtifact, shareKey } from "./share.js";

export interface WebServerOptions {
port: number;
Expand All @@ -23,6 +23,7 @@ export async function startWebServer(options: WebServerOptions): Promise<void> {
const url = new URL(req.url);
try {
if (req.method === "POST" && url.pathname === "/api/share") return await handleShare(req, url, server);
if (req.method === "DELETE" && url.pathname === "/api/share") return await handleUnshare(req, url, server);
if (url.pathname.startsWith("/api/")) return await handleApi(url);
if (url.pathname.startsWith("/wireframes/")) return await handleWireframeAsset(url);
return await serveStatic(url.pathname);
Expand Down Expand Up @@ -93,17 +94,22 @@ async function board(
listFlowRuns(workspace),
]);
const wireframes = await listWireframes(workspace);
const shares = await readShareIndex(workspace);
const sharedFor = (kind: "design" | "spec" | "task" | "knowledge", scope: string, id: string) => {
const record = shares[shareKey(kind, scope, id)];
return record ? { url: record.url, gistUrl: record.gistUrl, sharedAt: record.sharedAt } : null;
};
const goalSummaries = await Promise.all(
goals.map(async (g) => ({ id: g.id, title: g.title, active: g.active, updated: await goalUpdatedMs(g.path) })),
);
return {
projects: projects.map((p) => ({ slug: p.slug, repo_path: p.repo_path })),
current: { project: workspace.projectSlug, goal: workspace.goalSlug, repo: workspace.repoPath },
goals: goalSummaries,
tasks: tasks.map((t) => ({ meta: t.meta, body: t.body })),
specs: specs.map((s) => ({ scope: s.scope, meta: s.meta, body: s.body })),
knowledge: knowledge.map((k) => ({ scope: k.scope, meta: k.meta, body: k.body })),
wireframes: wireframes.map((w) => ({ scope: w.scope, meta: w.meta, body: w.body, url: wireframeWebPath(workspace, w) })),
tasks: tasks.map((t) => ({ meta: t.meta, body: t.body, shared: sharedFor("task", workspace.goalSlug, t.meta.id) })),
specs: specs.map((s) => ({ scope: s.scope, meta: s.meta, body: s.body, shared: sharedFor("spec", s.scope, s.meta.id) })),
knowledge: knowledge.map((k) => ({ scope: k.scope, meta: k.meta, body: k.body, shared: sharedFor("knowledge", k.scope, k.meta.id) })),
wireframes: wireframes.map((w) => ({ scope: w.scope, meta: w.meta, body: w.body, url: wireframeWebPath(workspace, w), shared: sharedFor("design", w.scope, w.meta.id) })),
flows: flows.map((f) => ({ name: f.name })),
runs,
};
Expand Down Expand Up @@ -431,6 +437,30 @@ async function handleShare(
return json(await shareArtifact(workspace, kind, id));
}

// Revoke a share: delete the backing gist and drop it from the index. Also
// loopback-only — same reasoning as handleShare.
async function handleUnshare(
req: Request,
url: URL,
server: ReturnType<typeof Bun.serve>,
): Promise<Response> {
const peer = server.requestIP(req);
if (peer && !isLoopback(peer.address)) {
return json({ error: "Sharing is only available from localhost." }, 403);
}
const projects = await listProjects();
if (!projects.length) return json({ error: "No agent-board project found." }, 404);
const workspace = resolveWs(
projects,
url.searchParams.get("project") ?? undefined,
url.searchParams.get("goal") ?? undefined,
);
const id = (url.searchParams.get("id") ?? "").trim();
if (!id) return json({ error: "Missing artifact id." }, 400);
const kind = parseShareKind(String(url.searchParams.get("kind") ?? ""));
return json(await removeShare(workspace, kind, id));
}

function isLoopback(address: string): boolean {
return address === "127.0.0.1" || address === "::1" || address === "::ffff:127.0.0.1";
}
Expand Down
44 changes: 33 additions & 11 deletions src/web/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -474,23 +474,43 @@ function TasksTab({ board, selId, setSelId, go }) {
</div>`;
}

function ShareButton({ kind, id, current }) {
const [state, setState] = useState({ status: "idle" });
function ShareButton({ kind, id, current, shared }) {
const [state, setState] = useState(() =>
shared ? { status: "done", viewerUrl: shared.url, gistUrl: shared.gistUrl } : { status: "idle" },
);
const [copied, setCopied] = useState(false);
const [busy, setBusy] = useState(false);
const q = `project=${encodeURIComponent(current.project)}&goal=${encodeURIComponent(current.goal)}`;
const share = useCallback(async () => {
setState({ status: "loading" });
setState((s) => ({ ...s, status: "loading" }));
try {
const res = await fetch(
`/api/share?project=${encodeURIComponent(current.project)}&goal=${encodeURIComponent(current.goal)}`,
{ method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ kind, id }) },
);
const res = await fetch(`/api/share?${q}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ kind, id }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data?.error || res.statusText);
setState({ status: "done", ...data });
} catch (e) {
setState({ status: "error", message: e.message });
}
}, [kind, id, current]);
}, [kind, id, q]);
const unshare = useCallback(async () => {
setBusy(true);
try {
const res = await fetch(`/api/share?${q}&kind=${encodeURIComponent(kind)}&id=${encodeURIComponent(id)}`, {
method: "DELETE",
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data?.error || res.statusText);
setState({ status: "idle" });
} catch (e) {
setState((s) => ({ ...s, warn: e.message }));
} finally {
setBusy(false);
}
}, [kind, id, q]);
const copy = useCallback(async () => {
try {
await navigator.clipboard.writeText(state.viewerUrl);
Expand All @@ -507,7 +527,9 @@ function ShareButton({ kind, id, current }) {
<button class="share-btn sm" onClick=${copy}>${copied ? "Copied" : "Copy link"}</button>
<a class="share-btn sm ghost" href=${state.gistUrl} target="_blank" rel="noreferrer">Gist</a>
<button class="share-btn sm ghost" onClick=${share}>Re-share</button>
<button class="share-btn sm danger" disabled=${busy} onClick=${unshare}>${busy ? "Removing…" : "Unshare"}</button>
</div>
${state.warn ? html`<div class="share-warn">${state.warn}</div>` : null}
${(state.warnings || []).map((w) => html`<div key=${w} class="share-warn">${w}</div>`)}
</div>`;
}
Expand Down Expand Up @@ -544,7 +566,7 @@ function TaskDetail({ board, task, setSelId, go }) {
<h2>${m.title}</h2>
<div class="detail-id">${m.id}</div>
</div>
<div class="detail-actions"><${ShareButton} key=${m.id} kind="task" id=${m.id} current=${board.current} /></div>
<div class="detail-actions"><${ShareButton} key=${m.id} kind="task" id=${m.id} current=${board.current} shared=${task.shared} /></div>
<div class="field-grid">
${fields.map(
([k, v, mono]) => html`<div key=${k} class="field">
Expand Down Expand Up @@ -656,7 +678,7 @@ function DocsTab({ items, kind, selId, setSelId, current }) {
<h2>${selected.meta.title}</h2>
<div class="detail-id">${selected.meta.id} · updated ${fmtDate(selected.meta.updated)}</div>
</div>
<div class="detail-actions"><${ShareButton} key=${selected.meta.id} kind=${kind === "knowledge" ? "knowledge" : "spec"} id=${selected.meta.id} current=${current} /></div>
<div class="detail-actions"><${ShareButton} key=${selected.meta.id} kind=${kind === "knowledge" ? "knowledge" : "spec"} id=${selected.meta.id} current=${current} shared=${selected.shared} /></div>
<${Markdown} source=${selected.body} />
</article>`
: html`<div class="placeholder">Select a ${kind.replace(/s$/, "")} to read it.</div>`}
Expand Down Expand Up @@ -757,7 +779,7 @@ function WireframesTab({ items, selId, setSelId, current }) {
</div>
<a class="frame-link" href=${selected.url} target="_blank" rel="noreferrer">Open</a>
</div>
<div class="detail-actions"><${ShareButton} key=${selected.meta.id} kind="design" id=${selected.meta.id} current=${current} /></div>
<div class="detail-actions"><${ShareButton} key=${selected.meta.id} kind="design" id=${selected.meta.id} current=${current} shared=${selected.shared} /></div>
<div class="wireframe-frame">
<iframe title=${selected.meta.title} src=${selected.url} loading="lazy" />
</div>
Expand Down
8 changes: 8 additions & 0 deletions src/web/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -1520,6 +1520,14 @@ select.control:focus {
background: transparent;
color: var(--foreground-muted);
}
.share-btn.danger {
background: transparent;
color: var(--destructive);
}
.share-btn.danger:hover {
border-color: var(--destructive);
background: color-mix(in srgb, var(--destructive) 12%, transparent);
}
.share-box.done {
display: flex;
flex-direction: column;
Expand Down
Loading