Skip to content

Commit dfc077e

Browse files
ymansurozerclaude
andauthored
feat: ephemeral agent status line and agent-presence signal in the desk (#14)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent cac07c0 commit dfc077e

13 files changed

Lines changed: 316 additions & 21 deletions

File tree

scripts/smoke.mjs

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,12 +76,27 @@ try {
7676
side: "additions",
7777
body: "why this change?",
7878
});
79+
// No await is parked yet, so the question sits queued — the presence signal the
80+
// UI renders as "No agent attached — question queued".
81+
const queuedState = await getJson("/api/state");
82+
assert.equal(queuedState.queuedQuestions, 1, "question queued with no listener");
83+
console.log("✓ queuedQuestions reflects an undelivered question");
7984
const ev1 = JSON.parse(cli("await", "--repo", tmp, "--session", ID, "--timeout", "5"));
8085
assert.equal(ev1.kind, "question");
8186
assert.equal(ev1.question.body, "why this change?");
8287
assert.equal(ev1.question.lineNumber, 2);
8388
console.log("✓ await → question event");
8489

90+
// agent posts ephemeral activity while working → visible in the state payload
91+
const status = JSON.parse(
92+
cli("status", "--repo", tmp, "--session", ID, "--body", "Reading a.txt…"),
93+
);
94+
assert.ok(status.ok && status.live, "status accepted by the live desk");
95+
const activeState = await getJson("/api/state");
96+
assert.equal(activeState.agentActivity?.body, "Reading a.txt…");
97+
assert.equal(activeState.queuedQuestions, 0, "question was delivered");
98+
console.log("✓ status → ephemeral agentActivity in state");
99+
85100
// agent answers via `galley comment`
86101
const reply = JSON.parse(
87102
cli(
@@ -103,7 +118,9 @@ try {
103118
),
104119
);
105120
assert.ok(reply.ok && reply.commentId, "comment posted");
106-
console.log("✓ comment posted (answer)");
121+
const answeredState = await getJson("/api/state");
122+
assert.equal(answeredState.agentActivity, null, "agent answer cleared the activity line");
123+
console.log("✓ comment posted (answer) — activity cleared");
107124

108125
// human clicks Send → `galley await` yields a review event with a ReviewResult
109126
await post("/api/send", await getJson("/api/state"));

skills/galley/SKILL.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ The review is the human's; the agent acts on the decisions and answers questions
1818

1919
## Review modes
2020

21-
`galley` reviews in one of three modes. `await`/`comment`/`reload` auto-target the lone live desk for the repo, so you usually don't pass `--session`.
21+
`galley` reviews in one of three modes. `await`/`comment`/`status`/`reload` auto-target the lone live desk for the repo, so you usually don't pass `--session`.
2222

2323
| Start command | Mode | What's reviewed | staging |
2424
| -------------------- | -------------- | ---------------------------------------------------------------------------- | ------------------------------------------------------------------------- |
@@ -38,6 +38,7 @@ The review is the human's; the agent acts on the decisions and answers questions
3838
| `galley …` (a mode above) | Start the persistent desk (opens the tab, stays up until Ctrl-C). The banner prints to **stderr**; nothing is written to stdout. **Idempotent**: if a desk is already live for the repo+session, this reuses it — the open tab gets a reload (and the new `--guide`, if passed) and no second tab/port is opened. |
3939
| `galley await [--session <id>] [--timeout <s>]` | Block until the next desk **event**, print it as a tagged JSON envelope on stdout, and exit. Loop and branch on `kind`. |
4040
| `galley comment [--session <id>] --path <f> --line <n> [--side additions\|deletions] --body "…"` | Post an agent reply (an answer to a question, or a note). Appears in the open tab within ~1.5s, threaded under the matching human comment. |
41+
| `galley status [--session <id>] --body "…"` | Post an **ephemeral** one-line "what I'm doing now" (e.g. "Reading state.ts…") that shows live next to the reviewer's waiting indicator. Optional but recommended before long operations while answering a question or acting on a Send. Cleared by your next `galley comment`; goes stale after ~90s (keep posting during long work); never persisted; exits 0 even with no live desk. |
4142
| `galley reload [--session <id>] [--guide <file>]` | Re-diff the working tree into the **live** desk so your code edits appear in the same tab — no restart needed. Decisions reconcile against the new diff (a hunk whose content you changed resets to pending; untouched decisions carry over). `--guide` swaps the attached review guide in the same round-trip. |
4243

4344
`galley await` yields one of two events:
@@ -55,6 +56,7 @@ while ev=$(galley await --session <id>); do
5556
case "$(printf '%s' "$ev" | jq -r .kind)" in
5657
question) # answer now, threaded at the question's location
5758
q=$(printf '%s' "$ev" | jq .question)
59+
galley status --session <id> --body "Reading the file to answer…" # live feedback while you work
5860
galley comment --session <id> \
5961
--path "$(jq -r .path <<<"$q")" --line "$(jq -r .lineNumber <<<"$q")" \
6062
--side "$(jq -r .side <<<"$q")" --body "…your answer…" ;;
@@ -109,7 +111,7 @@ Without `--timeout`, `await` holds the connection open until an event arrives. P
109111
}
110112
```
111113

112-
Read the file/diff for context (you have the repo), then answer with `galley comment` matching `path`/`lineNumber`/`side` so the reply threads under the question. Answer promptly — the human is waiting in the tab. Questions are resolved live and never appear in a `ReviewResult`.
114+
Read the file/diff for context (you have the repo), then answer with `galley comment` matching `path`/`lineNumber`/`side` so the reply threads under the question. Answer promptly — the human is waiting in the tab; if gathering the answer takes more than a moment, post `galley status --body "…"` lines so they see progress instead of a static spinner. Questions are resolved live and never appear in a `ReviewResult`.
113115

114116
## ReviewResult
115117

skills/galley/agents-snippet.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ When the user should review a plan, a PR, or code changes you've made, hand it t
1515
Optionally attach an AI **guided review** so the human gets an overview + a logical, annotated file order: add `--guide guide.json` to the start command (see the skill for the JSON schema).
1616

1717
Then loop: `galley await --session <task-id>` blocks for the next event and prints a tagged JSON envelope.
18-
- `{"kind":"question",…}` → answer **now** with `galley comment --path … --line … --side … --body "…"` at the question's location.
18+
- `{"kind":"question",…}` → answer **now** with `galley comment --path … --line … --side … --body "…"` at the question's location. While gathering the answer (or acting on a review), post brief `galley status --session <task-id> --body "Reading X…"` lines — they show live next to the reviewer's waiting indicator so they're not staring at a static spinner.
1919
- `{"kind":"review","result":{…}}` → act on `result`: revert **rejected**, make **requestedChanges**, leave **accepted** alone, leave **approvedFiles** (signed off as-is) untouched, don't touch **stagedFiles** unless a change requires it. (Editing an approved file invalidates its approval → it needs re-review next round.)
2020

2121
After editing code, run `galley reload --session <task-id>` so your edits show in the open tab (add `--guide <file>` to swap in a regenerated guide), then `galley await` again for the next round. The desk stays open across rounds and the reviewer keeps **one tab**: starting is idempotent (a live desk is reused, never duplicated) and the port is stable per session, so even after a desk process dies, re-running the start command brings the same tab back to life — never open a second desk for the same session.

src/cli.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,38 @@ async function runComment(args: Record<string, string | boolean>) {
161161
);
162162
}
163163

164+
// `galley status --body "..."` — post an ephemeral "what I'm doing now" line that
165+
// shows next to the reviewer's waiting indicator. Unlike comment there is no
166+
// offline fallback: ephemeral status is meaningless without a live desk, and it
167+
// must never fail the agent loop — no desk just reports { live: false }, exit 0.
168+
async function runStatus(args: Record<string, string | boolean>) {
169+
const root = await getGitRoot(resolveRepo(args)).catch(() => resolveRepo(args));
170+
const session = await resolveActionSession(root, args);
171+
const body = typeof args.body === "string" ? args.body.trim() : "";
172+
if (!body) {
173+
console.error('Usage: galley status --body "..." [--session <id>] [--repo <path>]');
174+
process.exitCode = 1;
175+
return;
176+
}
177+
const lock = await readDeskLock(root, session);
178+
if (lock) {
179+
try {
180+
const res = await fetch(`${lock.url}api/status`, {
181+
method: "POST",
182+
headers: { "content-type": "application/json" },
183+
body: JSON.stringify({ body }),
184+
});
185+
if (res.ok) {
186+
process.stdout.write(JSON.stringify({ ok: true, live: true, session }) + "\n");
187+
return;
188+
}
189+
} catch {
190+
/* desk not reachable; report not-live below */
191+
}
192+
}
193+
process.stdout.write(JSON.stringify({ ok: false, live: false, session }) + "\n");
194+
}
195+
164196
// `galley await --session <id>` — block until the next desk event, then print it
165197
// to stdout as a tagged envelope and exit. The event is either
166198
// {"kind":"question","question":{path,lineNumber,side,body,mode,session}} — answer it now
@@ -462,6 +494,7 @@ Usage:
462494
galley file <path> Review a single file or artifact (tracked or not)
463495
galley pr <ref|number|url> Review a branch's commits vs its merge-base
464496
galley comment --path <f> --line <n> --body "..." Post an agent reply into the desk
497+
galley status --body "..." Post an ephemeral "what I'm doing" line into the desk
465498
galley await [--timeout <s>] Block for the next desk event (question | review)
466499
galley reload [--guide <file>] Re-diff the working tree into the open desk
467500
(--guide swaps the attached review guide too)
@@ -497,6 +530,7 @@ async function main() {
497530
const positional = rest[0] && !rest[0].startsWith("--") ? rest[0] : undefined;
498531
const args = parseArgs(rest);
499532
if (sub === "comment") return runComment(args);
533+
if (sub === "status") return runStatus(args);
500534
if (sub === "await") return runAwait(args);
501535
if (sub === "reload") return runReload(args);
502536
if (sub === "guide-spec") {
@@ -514,7 +548,7 @@ async function main() {
514548
if (sub === "pr") return runDesk("pr", positional, args);
515549
if (sub) {
516550
console.error(
517-
`Unknown command "${sub}". Use: galley | galley file <path> | galley pr <ref|number|url> | comment | await | reload | guide-spec.`,
551+
`Unknown command "${sub}". Use: galley | galley file <path> | galley pr <ref|number|url> | comment | status | await | reload | guide-spec.`,
518552
);
519553
process.exitCode = 1;
520554
return;

src/server.test.ts

Lines changed: 95 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,15 +38,23 @@ function state(root: string): ReviewState {
3838
// Each test points HOME at a temp dir so the global ~/.galley/settings.json the
3939
// open-editor handler reads is isolated from the developer's real one.
4040
async function withServer(
41-
run: (handle: Awaited<ReturnType<typeof startServer>>, root: string) => Promise<void>,
42-
options: { runEditorCommand?: (command: string, args: string[]) => Promise<void> } = {},
41+
run: (
42+
handle: Awaited<ReturnType<typeof startServer>>,
43+
root: string,
44+
st: ReviewState,
45+
) => Promise<void>,
46+
options: {
47+
runEditorCommand?: (command: string, args: string[]) => Promise<void>;
48+
statusTtlMs?: number;
49+
} = {},
4350
) {
4451
const root = await mkdtemp(path.join(tmpdir(), "galley-server-"));
4552
const oldHome = process.env.HOME;
4653
process.env.HOME = root;
47-
const handle = await startServer({ state: state(root), open: false, ...options });
54+
const st = state(root);
55+
const handle = await startServer({ state: st, open: false, ...options });
4856
try {
49-
await run(handle, root);
57+
await run(handle, root, st);
5058
} finally {
5159
handle.server.close();
5260
process.env.HOME = oldHome;
@@ -117,6 +125,89 @@ test("open-editor runs the editor command from global settings", async () => {
117125
);
118126
});
119127

128+
type StatePayload = ReviewState & {
129+
agentActivity: { body: string; at: string } | null;
130+
agentListening: boolean;
131+
queuedQuestions: number;
132+
queuedReviews: number;
133+
};
134+
135+
const getState = (url: string) =>
136+
fetch(`${url}api/state`).then((r) => r.json() as Promise<StatePayload>);
137+
138+
const post = (url: string, pathname: string, body: unknown) =>
139+
fetch(`${url}${pathname}`, {
140+
method: "POST",
141+
headers: { "content-type": "application/json" },
142+
body: JSON.stringify(body),
143+
});
144+
145+
test("status posts ephemeral agent activity into the state payload", async () => {
146+
await withServer(async (handle) => {
147+
const res = await post(handle.url, "api/status", { body: "Reading a.ts…" });
148+
assert.equal(res.status, 200);
149+
const st = await getState(handle.url);
150+
assert.equal(st.agentActivity?.body, "Reading a.ts…");
151+
assert.equal(st.agentListening, false);
152+
assert.equal(st.queuedQuestions, 0);
153+
assert.equal(st.queuedReviews, 0);
154+
});
155+
});
156+
157+
test("status rejects an empty body", async () => {
158+
await withServer(async (handle) => {
159+
const res = await post(handle.url, "api/status", { body: " " });
160+
const body = (await res.json()) as { code?: string };
161+
assert.equal(res.status, 422);
162+
assert.equal(body.code, "INVALID_STATUS");
163+
});
164+
});
165+
166+
test("an agent comment clears the activity line", async () => {
167+
await withServer(async (handle) => {
168+
await post(handle.url, "api/status", { body: "Running tests…" });
169+
await post(handle.url, "api/comment", { path: "a.ts", lineNumber: 1, body: "Done — answer." });
170+
const st = await getState(handle.url);
171+
assert.equal(st.agentActivity, null);
172+
});
173+
});
174+
175+
test("activity goes stale past the TTL (checked on read, no timers)", async () => {
176+
await withServer(
177+
async (handle) => {
178+
await post(handle.url, "api/status", { body: "Reading…" });
179+
const st = await getState(handle.url);
180+
assert.equal(st.agentActivity, null);
181+
},
182+
{ statusTtlMs: 0 },
183+
);
184+
});
185+
186+
test("an unconsumed question surfaces as queuedQuestions", async () => {
187+
await withServer(async (handle) => {
188+
await post(handle.url, "api/ask", { path: "a.ts", lineNumber: 1, body: "why?" });
189+
const st = await getState(handle.url);
190+
assert.equal(st.queuedQuestions, 1);
191+
assert.equal(st.agentListening, false);
192+
});
193+
});
194+
195+
test("save strips transient desk-status keys so they never persist on state", async () => {
196+
await withServer(async (handle, _root, st) => {
197+
const payload = await getState(handle.url);
198+
// The UI posts its copy of the /api/state payload back — transient keys ride along.
199+
await post(handle.url, "api/save", {
200+
...payload,
201+
agentActivity: { body: "leak", at: "t" },
202+
queuedQuestions: 9,
203+
});
204+
assert.equal("agentActivity" in st, false);
205+
assert.equal("agentListening" in st, false);
206+
assert.equal("queuedQuestions" in st, false);
207+
assert.equal("queuedReviews" in st, false);
208+
});
209+
});
210+
120211
test("settings API round-trips editorCommand", async () => {
121212
await withServer(async (handle) => {
122213
await fetch(`${handle.url}api/settings`, {

0 commit comments

Comments
 (0)