From b821d980add47adea975f5a87d246db85ca587ca Mon Sep 17 00:00:00 2001
From: piggidragon
Date: Sat, 19 Sep 2026 21:00:36 +0200
Subject: [PATCH 1/5] Resend, edit and delete sent messages; rename and delete
Agent chats
Each message you send now has three actions on hover.
Resend sends the same text again. Edit replaces the message and everything
after it. Delete removes the message and the agent's answer to it.
Edit and delete change pi's own session file as well as the portal's
transcript. Trimming only the transcript would leave the model answering to a
message that is gone from the screen. The file is a tree of entries linked by
parent id, so removing a turn means dropping its entries and re-parenting the
next one; that lives in pi/session-edit.ts as a function over the file's text,
tested against synthetic sessions and checked against a real one loaded with
pi's own SessionManager. It refuses instead of guessing when a message sits
under a compaction summary or the file has branches it cannot reason about.
The server refuses both while a run is in progress. A portal_removed event
tells open pages which stretch of transcript to drop.
The Agent tab lists conversations that could already be renamed and deleted
through the session routes, but had no way to do it. Rows now have both.
Co-Authored-By: Claude Sonnet 5
---
docs/channels/index.md | 7 ++
docs/guide/sessions.md | 28 +++++
server/src/db.ts | 15 +++
server/src/index.ts | 31 +++++
server/src/pi/session-edit.ts | 202 ++++++++++++++++++++++++++++++
server/src/session-manager.ts | 59 ++++++++-
server/test/session-edit.test.mjs | 128 +++++++++++++++++++
web/src/App.tsx | 14 +++
web/src/api.ts | 9 ++
web/src/components/AgentPage.tsx | 66 +++++++++-
web/src/components/Chat.tsx | 164 +++++++++++++++++++++++-
web/src/transcript.ts | 4 +-
12 files changed, 720 insertions(+), 7 deletions(-)
create mode 100644 server/src/pi/session-edit.ts
create mode 100644 server/test/session-edit.test.mjs
diff --git a/docs/channels/index.md b/docs/channels/index.md
index 2b210eb5..1c53e113 100644
--- a/docs/channels/index.md
+++ b/docs/channels/index.md
@@ -38,6 +38,13 @@ on the Agent tab marked "no channel" until something claims that slug again. The
same replay, same model handling — and they are listed on the **Agent** tab,
where clicking one opens it in the normal chat view.
+From that list a conversation can be renamed or deleted. Deleting one does not
+block the chat it belonged to: the next message in it starts a new conversation,
+with none of the old memory. That makes it the way to reset a chat the agent has
+got wrong. The messages themselves can be edited or deleted as in any session —
+see [Sent messages](/guide/sessions#sent-messages) — but that changes what the
+agent remembers, not what was already delivered to the channel.
+
## Channel types are packages
Nothing about Telegram is hardcoded. A channel type is a package with a
diff --git a/docs/guide/sessions.md b/docs/guide/sessions.md
index c933af37..82e14abd 100644
--- a/docs/guide/sessions.md
+++ b/docs/guide/sessions.md
@@ -25,6 +25,32 @@ not wait for the work to finish. Close the tab if you like.
While a run is in progress you can keep typing; further messages are queued.
**Stop** aborts the current run.
+## Sent messages
+
+Hovering one of your messages gives it three actions:
+
+- **Send again** sends the same text as a new message. While a run is going it
+ queues, like anything else you type.
+- **Edit** rewrites the message in place. It replaces that message *and
+ everything after it* — the agent's answers were to a question that is no
+ longer the same one — and sends the new text.
+- **Delete** removes the message and the agent's answer to it, tool calls
+ included, and leaves the rest of the conversation as it was.
+
+Edit and delete change what the agent remembers, not just what the page shows:
+pi's own record of the conversation is edited too, and the agent's next turn
+reads the version without the message. They are unavailable while a run is in
+progress, so stop it first.
+
+They do not undo what the agent *did*. Files it changed, commands it ran and
+messages it sent stay as they are; only the memory of having done them goes.
+
+Some cases are refused rather than guessed at, with a message saying why. A
+message that a compaction has already folded into its summary cannot be deleted
+on its own, since the summary would go on describing it — edit it instead, which
+drops the summary with everything after. A conversation with branches from pi's
+`/tree` cannot be trimmed cleanly either. Nothing is changed when this happens.
+
## Sidebar and the sessions page
The sidebar opens with New, Sessions and Agents, then **Pinned**, then
@@ -36,6 +62,8 @@ Pinning is stored server-side and drives the ordering (`pinned DESC,
updated_at DESC`), so the sidebar and the Sessions page never disagree.
Hovering a session gives you pin and delete. Double-clicking its name renames it.
+The Agent tab's conversations can be renamed and deleted the same way, from the
+row.
## Model and effort
diff --git a/server/src/db.ts b/server/src/db.ts
index 336f6e9b..8372c297 100644
--- a/server/src/db.ts
+++ b/server/src/db.ts
@@ -554,6 +554,21 @@ export function replayStart(sessionId: string, keep: number): number {
return row?.seq ?? 0;
}
+/** Every message the portal sent to the agent in this session, oldest first. */
+export function sentMessages(sessionId: string): { seq: number; message: string }[] {
+ const rows = getDb()
+ .prepare("SELECT seq, payload FROM events WHERE session_id = ? AND type = 'portal_prompt' ORDER BY seq ASC")
+ .all(sessionId) as { seq: number; payload: string }[];
+ return rows.map((r) => ({ seq: r.seq, message: String(JSON.parse(r.payload)?.message ?? "") }));
+}
+
+/** Drops a stretch of a session's transcript: `from` up to, not including, `to` — or to the end. */
+export function deleteEventsBetween(sessionId: string, from: number, to: number | null): void {
+ getDb()
+ .prepare("DELETE FROM events WHERE session_id = ? AND seq >= ? AND (? IS NULL OR seq < ?)")
+ .run(sessionId, from, to, to);
+}
+
/** The page before a cursor, oldest first — what a transcript scrolls back into. */
export function eventsBefore(sessionId: string, before: number, limit = 1500): EventRow[] {
return getDb()
diff --git a/server/src/index.ts b/server/src/index.ts
index 19aa9bae..c8b24c34 100644
--- a/server/src/index.ts
+++ b/server/src/index.ts
@@ -51,6 +51,7 @@ import {
} from "./pi-settings.js";
import { eventTime, getDb } from "./db.js";
import { getBuiltinCommands } from "./pi/builtins.js";
+import { SessionEditError } from "./pi/session-edit.js";
import { isValidSlug, slugify } from "./slug.js";
import { getSettingDefaults, getSettings, getStoredSettings, setSettings } from "./db.js";
@@ -387,6 +388,36 @@ app.post("/api/sessions/:id/prompt", async (req, res) => {
}
});
+// --- editing the conversation ---
+
+const editStatus = { busy: 409, missing: 404 } as const;
+
+/** Removes a message and the agent's answer to it. */
+app.delete("/api/sessions/:id/messages/:seq", async (req, res) => {
+ try {
+ await sessions.removeMessage(req.params.id, Number(req.params.seq), "turn");
+ res.json({ ok: true });
+ } catch (e) {
+ if (!(e instanceof SessionEditError)) return res.status(500).json({ error: (e as Error).message });
+ res.status(editStatus[e.code as keyof typeof editStatus] ?? 422).json({ error: e.message });
+ }
+});
+
+/** Replaces a message: it and everything after it are dropped, and the new text is sent. */
+app.post("/api/sessions/:id/messages/:seq/edit", async (req, res) => {
+ const message = req.body?.message;
+ if (typeof message !== "string" || !message.trim()) {
+ return res.status(400).json({ error: "message required" });
+ }
+ try {
+ await sessions.editMessage(req.params.id, Number(req.params.seq), message);
+ res.json({ ok: true, status: "running" });
+ } catch (e) {
+ if (!(e instanceof SessionEditError)) return res.status(500).json({ error: (e as Error).message });
+ res.status(editStatus[e.code as keyof typeof editStatus] ?? 422).json({ error: e.message });
+ }
+});
+
/** The browser answering a dialog an extension is waiting on. */
app.post("/api/sessions/:id/ui-response", (req, res) => {
const session = getSession(req.params.id);
diff --git a/server/src/pi/session-edit.ts b/server/src/pi/session-edit.ts
new file mode 100644
index 00000000..b4fe0754
--- /dev/null
+++ b/server/src/pi/session-edit.ts
@@ -0,0 +1,202 @@
+import { AUDIO_MESSAGE_PREFIX } from "./voice-first.js";
+
+/**
+ * Taking a message back out of pi's own record of the conversation.
+ *
+ * The portal's transcript is its own log, so trimming that is easy — and does
+ * nothing for the model, which reads pi's session file. A message deleted from
+ * the screen but still in the file is one the agent goes on answering to, which
+ * is worse than not offering delete at all. So the file is edited too.
+ *
+ * pi's file is a tree: every entry names its parent, and the conversation is
+ * the path from the last entry back to the root. Removing entries means
+ * removing them from that path and stitching the rest together, which is all
+ * this does. It works on the text of the file and touches nothing else, so it
+ * can be checked against a copy without a live session.
+ */
+
+export type SessionEditCode =
+ | "busy"
+ | "missing"
+ | "unsupported"
+ | "unmatched"
+ | "branched"
+ | "compacted";
+
+export class SessionEditError extends Error {
+ constructor(
+ readonly code: SessionEditCode,
+ message: string,
+ ) {
+ super(message);
+ }
+}
+
+/**
+ * `turn`: this message and what the agent did about it, and nothing else.
+ * `tail`: this message and everything after it — what editing does, since a
+ * conversation cannot keep answers to a question that is no longer the same.
+ */
+export type Scope = "turn" | "tail";
+
+interface Entry {
+ type: string;
+ id?: string;
+ parentId?: string | null;
+ message?: { role?: string; content?: unknown };
+ targetId?: string;
+ [key: string]: unknown;
+}
+
+const textOf = (content: unknown): string =>
+ typeof content === "string"
+ ? content
+ : Array.isArray(content)
+ ? content.map((c) => (c?.type === "text" ? (c.text ?? "") : "")).join("")
+ : "";
+
+const isUser = (e: Entry) => e.type === "message" && e.message?.role === "user";
+
+/** Root to leaf, following parent links from the last entry. */
+function pathTo(byId: Map, leaf: string): Entry[] {
+ const out: Entry[] = [];
+ const seen = new Set();
+ for (let id: string | null | undefined = leaf; id && !seen.has(id); ) {
+ seen.add(id);
+ const entry = byId.get(id);
+ if (!entry) break;
+ out.push(entry);
+ id = entry.parentId;
+ }
+ return out.reverse();
+}
+
+/**
+ * Which of pi's user entries a message the portal sent corresponds to.
+ *
+ * Matched by text, in order. The portal logs every message it sends and pi
+ * stores every one it receives, but they are not the same list: a slash command
+ * is not a chat message to the portal and can expand into one for pi, and a
+ * message that failed before reaching pi has no entry at all. So each sent
+ * message looks forward from the last one matched, and one that finds nothing
+ * is skipped rather than stopping the count — only the message being asked
+ * about has to be found.
+ */
+function locate(path: Entry[], sent: string[], ordinal: number): string {
+ const users = path.filter(isUser).map((e) => ({ id: e.id!, text: textOf(e.message?.content) }));
+ let from = 0;
+ let found = -1;
+ for (let i = 0; i <= ordinal; i++) {
+ const want = sent[i] ?? "";
+ let hit = users.findIndex(
+ (u, j) => j >= from && (u.text === want || u.text === AUDIO_MESSAGE_PREFIX + want),
+ );
+ if (hit < 0 && want) hit = users.findIndex((u, j) => j >= from && u.text.includes(want));
+ if (hit < 0) {
+ if (i === ordinal) {
+ throw new SessionEditError(
+ "unmatched",
+ "This message could not be found in the agent's history, so nothing was changed.",
+ );
+ }
+ continue;
+ }
+ from = hit + 1;
+ found = i === ordinal ? hit : found;
+ }
+ return users[found].id;
+}
+
+/**
+ * The session file with one message taken out.
+ *
+ * Refuses rather than guesses. If the tree has branches this cannot reason
+ * about, or the message sits inside a summary the agent has already written,
+ * the honest answer is that it cannot be done cleanly — a file left half-edited
+ * would corrupt every later turn, and a refusal costs nothing.
+ *
+ * @param sent every message the portal sent up to and including this one, oldest first
+ * @param ordinal which of them this is
+ */
+export function dropMessage(raw: string, sent: string[], ordinal: number, scope: Scope): string {
+ const rows = raw
+ .split("\n")
+ .filter((line) => line.trim())
+ .map((line) => ({ line, entry: JSON.parse(line) as Entry }));
+ const header = rows.filter((r) => r.entry.type === "session");
+ const body = rows.filter((r) => r.entry.type !== "session");
+ const byId = new Map(body.map((r) => [r.entry.id!, r.entry]));
+
+ // pi opens a file with its last entry as the leaf.
+ const leaf = body.at(-1)?.entry.id;
+ if (!leaf) throw new SessionEditError("unmatched", "The agent has no history to edit.");
+ const path = pathTo(byId, leaf);
+ const onPath = new Set(path.map((e) => e.id!));
+
+ const target = locate(path, sent, ordinal);
+ const at = path.findIndex((e) => e.id === target);
+
+ const removed = new Set();
+ if (scope === "tail") {
+ for (const e of path.slice(at)) removed.add(e.id!);
+ } else {
+ // Up to the next thing the person said. What lies between is the agent's
+ // answer: its replies, its tool calls, and their results.
+ const next = path.findIndex((e, i) => i > at && isUser(e));
+ const span = path.slice(at, next < 0 ? path.length : next);
+
+ // A summary already stands in for these messages. Deleting them leaves the
+ // summary describing something that never happened.
+ if (path.slice(at).some((e) => e.type === "compaction")) {
+ throw new SessionEditError(
+ "compacted",
+ "This message was already folded into a compacted summary and can no longer be removed on its own.",
+ );
+ }
+ // Only messages. A model or thinking-level change that fell in between is
+ // still true of the conversation, so it stays.
+ for (const e of span) if (e.type === "message" || e.type === "custom_message") removed.add(e.id!);
+ }
+
+ // Whatever hangs off a removed entry off the main path goes with it.
+ for (const { entry } of body) {
+ if (entry.parentId && removed.has(entry.parentId) && !onPath.has(entry.id!)) removed.add(entry.id!);
+ }
+ // A label on something that no longer exists.
+ for (const { entry } of body) {
+ if (entry.type === "label" && entry.targetId && removed.has(entry.targetId)) removed.add(entry.id!);
+ }
+
+ const parentOf = (id: string | null | undefined): string | null => {
+ let cur = id ?? null;
+ while (cur && removed.has(cur)) cur = byId.get(cur)?.parentId ?? null;
+ return cur;
+ };
+
+ const kept = body
+ .filter((r) => !removed.has(r.entry.id!))
+ .map((r) => {
+ const parent = parentOf(r.entry.parentId);
+ return parent === (r.entry.parentId ?? null)
+ ? r
+ : { line: JSON.stringify({ ...r.entry, parentId: parent }), entry: { ...r.entry, parentId: parent } };
+ });
+
+ // The result must be the old conversation minus exactly what was removed.
+ // If a side branch made the last entry something else, opening the file
+ // would silently land on a different conversation — so check before writing.
+ const after = new Map(kept.map((r) => [r.entry.id!, r.entry]));
+ const newLeaf = kept.at(-1)?.entry.id;
+ const actual = newLeaf ? pathTo(after, newLeaf).map((e) => e.id) : [];
+ const expected = (scope === "tail" ? path.slice(0, at) : path.filter((e) => !removed.has(e.id!))).map(
+ (e) => e.id,
+ );
+ if (actual.length !== expected.length || actual.some((id, i) => id !== expected[i])) {
+ throw new SessionEditError(
+ "branched",
+ "This conversation has branches, so a message cannot be removed cleanly. Nothing was changed.",
+ );
+ }
+
+ return [...header, ...kept].map((r) => r.line).join("\n") + "\n";
+}
diff --git a/server/src/session-manager.ts b/server/src/session-manager.ts
index 38f55a7f..e64d84b8 100644
--- a/server/src/session-manager.ts
+++ b/server/src/session-manager.ts
@@ -1,14 +1,17 @@
import { LiveEvents } from "./live-events.js";
import { EventEmitter } from "node:events";
import type { PersonRow, Role } from "./people.js";
-import { mkdirSync } from "node:fs";
+import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import path from "node:path";
import type { PiClient } from "./pi/types.js";
import { findServerBuiltin, runBuiltin } from "./pi/builtins.js";
+import { dropMessage, SessionEditError, type Scope } from "./pi/session-edit.js";
import { buildExecutor, type Executor, type ExecutorKind } from "./executors/index.js";
import {
appendEvent,
+ deleteEventsBetween,
getSession,
+ sentMessages,
getSettings,
markOrphanedSessionsInterrupted,
browserAllowed,
@@ -60,6 +63,9 @@ const EPHEMERAL_EVENTS = new Set([
// Prefill progress: a hundred rows per long prompt, and meaningless once the
// answer has arrived. Delivered to whoever is watching, never stored.
"portal_prefill",
+ // Tells a page which stretch of its transcript is gone. Stored, it would be
+ // replayed to a reader who never saw what it refers to.
+ "portal_removed",
]);
interface LiveSession {
@@ -386,6 +392,57 @@ class SessionManager extends EventEmitter {
if (client.isIdle?.()) this.mark(sessionId, "idle");
}
+ /**
+ * Take a message back out of the conversation.
+ *
+ * Out of pi's record as well as the transcript: see session-edit.ts for why
+ * the second half is the point. Refused while a run is going — the agent
+ * would be answering something that is being removed underneath it.
+ */
+ async removeMessage(sessionId: string, seq: number, scope: Scope): Promise {
+ const session = getSession(sessionId);
+ if (!session) throw new SessionEditError("missing", "Unknown session");
+ if (this.isBusy(sessionId) || this.compacting.has(sessionId)) {
+ throw new SessionEditError("busy", "Stop the run first — the agent is still working.");
+ }
+
+ const sent = sentMessages(sessionId);
+ const ordinal = sent.findIndex((m) => m.seq === seq);
+ if (ordinal < 0) throw new SessionEditError("missing", "That message is not in this conversation");
+
+ // Released before the file changes: a live pi holds the conversation in
+ // memory and would write its own version back over the edit. The next
+ // prompt reopens it from the file.
+ await this.stop(sessionId);
+
+ const file = session.pi_session_file;
+ if (file && existsSync(file)) {
+ const edited = dropMessage(
+ readFileSync(file, "utf8"),
+ sent.slice(0, ordinal + 1).map((m) => m.message),
+ ordinal,
+ scope,
+ );
+ // Beside it, then renamed over it, so a crash mid-write leaves the
+ // original rather than half of each.
+ const tmp = `${file}.edit`;
+ writeFileSync(tmp, edited);
+ renameSync(tmp, file);
+ } else if (session.executor !== "host") {
+ throw new SessionEditError("unsupported", "Messages cannot be edited in a container session.");
+ }
+
+ const to = scope === "tail" ? null : (sent[ordinal + 1]?.seq ?? null);
+ deleteEventsBetween(sessionId, seq, to);
+ this.record(sessionId, "portal_removed", { from: seq, to });
+ }
+
+ /** Replace a message: everything from it onwards goes, and the new text is sent in its place. */
+ async editMessage(sessionId: string, seq: number, message: string): Promise {
+ await this.removeMessage(sessionId, seq, "tail");
+ await this.prompt(sessionId, message);
+ }
+
/**
* Prompt and wait for the answer.
*
diff --git a/server/test/session-edit.test.mjs b/server/test/session-edit.test.mjs
new file mode 100644
index 00000000..96010392
--- /dev/null
+++ b/server/test/session-edit.test.mjs
@@ -0,0 +1,128 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+const { dropMessage, SessionEditError } = await import('../dist/pi/session-edit.js');
+
+const user = (id, parentId, text) => ({ type: 'message', id, parentId, message: { role: 'user', content: [{ type: 'text', text }] } });
+const asst = (id, parentId, text = 'ok') => ({ type: 'message', id, parentId, message: { role: 'assistant', content: [{ type: 'text', text }] } });
+const tool = (id, parentId) => ({ type: 'message', id, parentId, message: { role: 'toolResult', content: [{ type: 'text', text: 'out' }] } });
+const file = (...entries) => [{ type: 'session', version: 3, id: 'hdr' }, ...entries].map((e) => JSON.stringify(e)).join('\n') + '\n';
+const parse = (raw) => raw.split('\n').filter(Boolean).map((l) => JSON.parse(l));
+const ids = (raw) => parse(raw).filter((e) => e.type !== 'session').map((e) => e.id);
+/** What pi would open: the last entry, walked back to the root. */
+const conversation = (raw) => {
+ const body = parse(raw).filter((e) => e.type !== 'session');
+ const byId = new Map(body.map((e) => [e.id, e]));
+ const out = [];
+ for (let id = body.at(-1)?.id; id; id = byId.get(id).parentId) out.unshift(id);
+ return out;
+};
+
+const three = file(
+ user('u1', null, 'first'), asst('a1', 'u1'),
+ user('u2', 'a1', 'second'), asst('t2', 'u2'), tool('r2', 't2'), asst('a2', 'r2'),
+ user('u3', 'a2', 'third'), asst('a3', 'u3'),
+);
+
+test('deleting a middle turn removes its answer and joins the neighbours', () => {
+ const out = dropMessage(three, ['first', 'second'], 1, 'turn');
+ assert.deepEqual(conversation(out), ['u1', 'a1', 'u3', 'a3']);
+ assert.equal(parse(out).find((e) => e.id === 'u3').parentId, 'a1');
+ // The header is untouched and the untouched lines are byte-for-byte the same.
+ assert.equal(parse(out)[0].type, 'session');
+ assert.ok(out.includes(JSON.stringify(user('u1', null, 'first'))));
+});
+
+test('deleting the last turn leaves the earlier conversation as it was', () => {
+ const out = dropMessage(three, ['first', 'second', 'third'], 2, 'turn');
+ assert.deepEqual(conversation(out), ['u1', 'a1', 'u2', 't2', 'r2', 'a2']);
+});
+
+test('deleting the first turn makes the second the root', () => {
+ const out = dropMessage(three, ['first'], 0, 'turn');
+ assert.deepEqual(conversation(out), ['u2', 't2', 'r2', 'a2', 'u3', 'a3']);
+ assert.equal(parse(out).find((e) => e.id === 'u2').parentId, null);
+});
+
+test('editing drops the message and everything after it', () => {
+ const out = dropMessage(three, ['first', 'second'], 1, 'tail');
+ assert.deepEqual(conversation(out), ['u1', 'a1']);
+ assert.deepEqual(ids(out), ['u1', 'a1']);
+});
+
+test('editing the first message empties the conversation', () => {
+ const out = dropMessage(three, ['first'], 0, 'tail');
+ assert.deepEqual(ids(out), []);
+ assert.equal(parse(out).length, 1);
+});
+
+test('the same words sent twice are told apart by order', () => {
+ const twice = file(user('u1', null, 'again'), asst('a1', 'u1'), user('u2', 'a1', 'again'), asst('a2', 'u2'));
+ assert.deepEqual(conversation(dropMessage(twice, ['again', 'again'], 1, 'turn')), ['u1', 'a1']);
+ assert.deepEqual(conversation(dropMessage(twice, ['again', 'again'], 0, 'turn')), ['u2', 'a2']);
+});
+
+test('a message pi never received does not throw the count off', () => {
+ // "lost" failed before reaching pi, so it has a portal event and no entry.
+ const out = dropMessage(three, ['first', 'lost', 'second'], 2, 'turn');
+ assert.deepEqual(conversation(out), ['u1', 'a1', 'u3', 'a3']);
+});
+
+test('a voice message is found under its audio prefix', () => {
+ const voiced = file(user('u1', null, '[Audio mode]\nhello'), asst('a1', 'u1'));
+ assert.deepEqual(ids(dropMessage(voiced, ['hello'], 0, 'turn')), []);
+});
+
+test('a model change between turns survives the delete', () => {
+ const changed = file(
+ user('u1', null, 'first'), asst('a1', 'u1'), { type: 'model_change', id: 'm1', parentId: 'a1' },
+ user('u2', 'm1', 'second'), asst('a2', 'u2'),
+ );
+ const out = dropMessage(changed, ['first', 'second'], 0, 'turn');
+ assert.deepEqual(conversation(out), ['m1', 'u2', 'a2']);
+});
+
+test('a label on a removed message goes with it', () => {
+ const labelled = file(user('u1', null, 'first'), asst('a1', 'u1'), { type: 'label', id: 'l1', parentId: 'a1', targetId: 'u1' }, user('u2', 'l1', 'second'));
+ const out = dropMessage(labelled, ['first', 'second'], 0, 'turn');
+ assert.ok(!ids(out).includes('l1'));
+ assert.deepEqual(conversation(out), ['u2']);
+});
+
+test('a message under a later compaction cannot be deleted alone, but can be edited away', () => {
+ const compacted = file(
+ user('u1', null, 'first'), asst('a1', 'u1'),
+ user('u2', 'a1', 'second'), asst('a2', 'u2'),
+ { type: 'compaction', id: 'c1', parentId: 'a2', firstKeptEntryId: 'u2', summary: 's' },
+ user('u3', 'c1', 'third'),
+ );
+ assert.throws(() => dropMessage(compacted, ['first', 'second', 'third'], 1, 'turn'), (e) => e instanceof SessionEditError && e.code === 'compacted');
+ assert.deepEqual(conversation(dropMessage(compacted, ['first', 'second'], 1, 'tail')), ['u1', 'a1']);
+ // Before the compaction is another matter: nothing summarises what came after.
+ assert.deepEqual(conversation(dropMessage(compacted, ['first', 'second', 'third'], 2, 'turn')), ['u1', 'a1', 'u2', 'a2', 'c1']);
+});
+
+test('a message that is not in the history is refused, not guessed at', () => {
+ assert.throws(() => dropMessage(three, ['first', 'never sent'], 1, 'turn'), (e) => e.code === 'unmatched');
+});
+
+test('a side branch makes the last entry ambiguous, so nothing is changed', () => {
+ const branched = file(
+ user('u1', null, 'first'), asst('a1', 'u1'),
+ user('u2', 'a1', 'second'), asst('a2', 'u2'),
+ asst('side', 'u1'), // a branch off the first turn, written last
+ );
+ // The conversation pi opens is u1 → side; asking about 'second' finds nothing on it.
+ assert.throws(() => dropMessage(branched, ['first', 'second'], 1, 'turn'), SessionEditError);
+});
+
+test('a dead branch hanging off a removed message is removed with it', () => {
+ const dead = file(
+ user('u1', null, 'first'), asst('dead', 'u1'), asst('a1', 'u1'),
+ user('u2', 'a1', 'second'), asst('a2', 'u2'),
+ );
+ // 'dead' is written before the live answer, so the path is u1 → a1 → u2 → a2.
+ const out = dropMessage(dead, ['first', 'second'], 0, 'turn');
+ assert.ok(!ids(out).includes('a1'));
+ assert.ok(!ids(out).includes('dead'));
+ assert.deepEqual(conversation(out), ['u2', 'a2']);
+});
diff --git a/web/src/App.tsx b/web/src/App.tsx
index 15bce4ff..c5c34b0e 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -139,6 +139,13 @@ function Shell({
es.addEventListener("live-reset", () => setEvents(resetLiveEvents));
es.onmessage = (m) => {
const ev: PortalEvent = JSON.parse(m.data);
+ // A message was taken out of the conversation: drop what it covered,
+ // rather than reloading everything to find out what is left.
+ if (ev.type === "portal_removed") {
+ const { from, to } = ev.payload as { from: number; to: number | null };
+ setEvents((prev) => prev.filter((e) => !(e.seq >= from && (to == null || e.seq < to))));
+ return;
+ }
// Live-only events (dialogs) use a negative seq and must not move the
// resume cursor, or reconnecting would skip real history.
if (ev.seq > 0) seq = ev.seq;
@@ -308,6 +315,13 @@ function Shell({
await api.prompt(active.id, msg, options);
refreshSessions();
}}
+ onEditMessage={async (seq, message) => {
+ await api.editMessage(active.id, seq, message);
+ refreshSessions();
+ }}
+ onDeleteMessage={async (seq) => {
+ await api.deleteMessage(active.id, seq);
+ }}
onAbort={async () => {
await api.abort(active.id);
refreshSessions();
diff --git a/web/src/api.ts b/web/src/api.ts
index e0dd005f..65f294e5 100644
--- a/web/src/api.ts
+++ b/web/src/api.ts
@@ -175,6 +175,15 @@ export const api = {
method: "POST",
body: JSON.stringify({ message, ...(options?.voice ? { voice: true } : {}) }),
}),
+ /** Removes a message and the agent's answer to it — from the agent's memory too. */
+ deleteMessage: (id: string, seq: number) =>
+ json<{ ok: true }>(`/api/sessions/${id}/messages/${seq}`, { method: "DELETE" }),
+ /** Replaces a message: it and everything after it are dropped, and the new text is sent. */
+ editMessage: (id: string, seq: number, message: string) =>
+ json<{ ok: true }>(`/api/sessions/${id}/messages/${seq}/edit`, {
+ method: "POST",
+ body: JSON.stringify({ message }),
+ }),
respondUi: (sessionId: string, id: string, payload: { value?: unknown; cancelled?: boolean }) =>
json<{ ok: boolean }>(`/api/sessions/${sessionId}/ui-response`, {
method: "POST",
diff --git a/web/src/components/AgentPage.tsx b/web/src/components/AgentPage.tsx
index 433d5176..44b6944d 100644
--- a/web/src/components/AgentPage.tsx
+++ b/web/src/components/AgentPage.tsx
@@ -6,9 +6,11 @@ import {
LuFolder,
LuMessageSquare,
LuMonitor,
+ LuPencil,
LuPlus,
LuRadio,
LuRefreshCw,
+ LuTrash2,
} from "react-icons/lu";
import { api, type AgentSession, type AgentSetup as Setup, type SessionStatus } from "../api";
import { AgentSetup } from "./AgentSetup";
@@ -47,6 +49,7 @@ export function AgentPage({ onSelect }: { onSelect: (id: string) => void }) {
const [setup, setSetup] = useState(null);
const [loading, setLoading] = useState(true);
const [starting, setStarting] = useState(false);
+ const [error, setError] = useState("");
const load = () =>
api
@@ -65,6 +68,41 @@ export function AgentPage({ onSelect }: { onSelect: (id: string) => void }) {
return () => clearInterval(t);
}, []);
+ /**
+ * Rename and delete, as the sidebar does them: these are the same sessions,
+ * and the same two routes. Deleting one that arrived through a channel does
+ * not block the chat — the next message in it simply starts a new
+ * conversation, which is the reason to say so first.
+ */
+ const rename = async (s: AgentSession) => {
+ const next = prompt("Rename conversation", s.title)?.trim();
+ if (!next || next === s.title) return;
+ setError("");
+ try {
+ await api.renameSession(s.id, next);
+ await load();
+ } catch (e) {
+ setError((e as Error).message);
+ }
+ };
+
+ const remove = async (s: AgentSession) => {
+ const fresh = s.channel && s.channel.slug !== BROWSER;
+ const ok = confirm(
+ fresh
+ ? `Delete "${s.title}"? The agent forgets this conversation, and the next message in that chat starts a new one.`
+ : `Delete "${s.title}"? This stops it if it is running.`,
+ );
+ if (!ok) return;
+ setError("");
+ try {
+ await api.deleteSession(s.id);
+ await load();
+ } catch (e) {
+ setError((e as Error).message);
+ }
+ };
+
// Grouped by the door each conversation came through.
const groups = useMemo(() => {
const out = new Map<
@@ -156,6 +194,10 @@ export function AgentPage({ onSelect }: { onSelect: (id: string) => void }) {
{setup?.initialised && }
+ {error && (
+
+ {/* Only where it can be done: taking a message out from under a
+ run that is answering it leaves the agent replying to
+ something that no longer exists. Sending it again is fine —
+ it just queues, like any other message. */}
+
+ attempt(() => onSend(text))}
+ >
+
+
+ setEditing(item.seq)}
+ >
+
+
+ {
+ if (confirm("Delete this message and the agent's reply to it? The agent forgets it too."))
+ attempt(() => onDeleteMessage(item.seq));
+ }}
+ >
+
+
+
+ );
+}
+
/**
* The line that says what the agent is doing.
*
diff --git a/web/src/transcript.ts b/web/src/transcript.ts
index 142f924c..64e22d1e 100644
--- a/web/src/transcript.ts
+++ b/web/src/transcript.ts
@@ -1,7 +1,7 @@
import type { PortalEvent } from "./api";
export type Item =
- | { kind: "user"; id: string; text: string; audio?: boolean }
+ | { kind: "user"; id: string; seq: number; text: string; audio?: boolean }
| { kind: "assistant"; id: string; text: string; thinking: string; done: boolean; audio?: boolean }
| { kind: "tool"; id: string; name: string; status: "running" | "done" | "error"; detail?: string }
| { kind: "notice"; id: string; text: string; tone: "info" | "error" };
@@ -34,7 +34,7 @@ export function buildTranscript(events: PortalEvent[]): Item[] {
const raw = String(p.message ?? "");
const tagged = raw.startsWith("[Audio mode]\n");
audioReply = p.voice === true || tagged;
- items.push({ kind: "user", id: `u${ev.seq}`, text: tagged ? raw.slice("[Audio mode]\n".length) : raw, audio: p.voice === true || tagged });
+ items.push({ kind: "user", id: `u${ev.seq}`, seq: ev.seq, text: tagged ? raw.slice("[Audio mode]\n".length) : raw, audio: p.voice === true || tagged });
break;
}
From d4165df83b6edad8b37f80dda52c7d207e07d9f9 Mon Sep 17 00:00:00 2001
From: piggidragon
Date: Sat, 19 Sep 2026 21:05:30 +0200
Subject: [PATCH 2/5] Make Retry on the last message replace it instead of
sending twice
Sending the same text again after a Stop left the aborted, half-written
answer in the agent's memory with a second copy of the question after it.
On the last message the action now edits without changing the text: it and
what came of it are removed from pi's record and the message is sent again.
Older messages keep sending their text as a new message, since replacing one
of those would drop everything after it.
Co-Authored-By: Claude Sonnet 5
---
docs/guide/sessions.md | 13 +++++++++----
web/src/components/Chat.tsx | 39 +++++++++++++++++++++++++++++++------
2 files changed, 42 insertions(+), 10 deletions(-)
diff --git a/docs/guide/sessions.md b/docs/guide/sessions.md
index 82e14abd..47508026 100644
--- a/docs/guide/sessions.md
+++ b/docs/guide/sessions.md
@@ -27,10 +27,15 @@ While a run is in progress you can keep typing; further messages are queued.
## Sent messages
-Hovering one of your messages gives it three actions:
-
-- **Send again** sends the same text as a new message. While a run is going it
- queues, like anything else you type.
+Hovering one of your messages gives it these actions:
+
+- **Retry**, on your last message, drops the agent's reply to it — a half-finished
+ one after a Stop, say — and sends the same text again. It is editing without
+ changing a word, so the agent's memory ends up as if the first attempt never
+ happened rather than holding it and a second copy of the question.
+- **Send again**, on an older message, sends its text as a new message at the
+ end. Retrying one of those would drop everything since. While a run is going
+ it queues, like anything else you type.
- **Edit** rewrites the message in place. It replaces that message *and
everything after it* — the agent's answers were to a question that is no
longer the same one — and sends the new text.
diff --git a/web/src/components/Chat.tsx b/web/src/components/Chat.tsx
index 334a94e0..ac3c109c 100644
--- a/web/src/components/Chat.tsx
+++ b/web/src/components/Chat.tsx
@@ -167,6 +167,15 @@ export function Chat({
const bottomRef = useRef(null);
const settled = useRef(false);
const items = useMemo(() => buildTranscript(events), [events]);
+ // The last thing the person said. Retrying it replaces it and what came of
+ // it, which is only safe where nothing follows that would go too.
+ const lastSaid = useMemo(() => {
+ for (let i = items.length - 1; i >= 0; i--) {
+ const it = items[i];
+ if (it.kind === "user" && splitContext(it.text).text) return it.id;
+ }
+ return undefined;
+ }, [items]);
// Diagrams: the plugin is only fetched once a reply actually contains a
@@ -401,12 +410,30 @@ export function Chat({
something that no longer exists. Sending it again is fine —
it just queues, like any other message. */}
- attempt(() => onSend(text))}
- >
-
-
+ {item.id === lastSaid ? (
+ // Retry: the same as editing without changing a word. After
+ // a Stop this is what clears the half-finished answer out of
+ // the agent's memory instead of stacking a second question
+ // on top of it.
+ attempt(() => onEditMessage(item.seq, text))}
+ >
+
+
+ ) : (
+ attempt(() => onSend(text))}
+ >
+
+
+ )}
Date: Sat, 19 Sep 2026 22:29:25 +0200
Subject: [PATCH 3/5] Address review: exact matching, recoverable edits, no-op
edit guard
- Match a sent message to pi's entry only exactly or as a voice turn. The
substring fallback could take a message pi never received for a later one
containing its words, and remove the wrong turn.
- Edit no longer loses the conversation when the replacement is refused: the
file and the transcript are restored and the replacement's event retracted.
portal_removed is published only once the replacement is accepted, and only
for the range that went. If the transcript update itself fails after the
file was rewritten, the file is put back.
- Enter on an unchanged edit does nothing instead of removing the tail and
sending the same text again.
Co-Authored-By: Claude Sonnet 5
---
docs/guide/sessions.md | 8 ++-
server/src/db.ts | 51 ++++++++++++++++--
server/src/pi/session-edit.ts | 20 +++----
server/src/session-manager.ts | 86 ++++++++++++++++++++++++++-----
server/test/session-edit.test.mjs | 7 +++
web/node_modules | 1 +
web/src/components/Chat.tsx | 2 +-
7 files changed, 145 insertions(+), 30 deletions(-)
create mode 120000 web/node_modules
diff --git a/docs/guide/sessions.md b/docs/guide/sessions.md
index 47508026..21b6b281 100644
--- a/docs/guide/sessions.md
+++ b/docs/guide/sessions.md
@@ -54,7 +54,13 @@ Some cases are refused rather than guessed at, with a message saying why. A
message that a compaction has already folded into its summary cannot be deleted
on its own, since the summary would go on describing it — edit it instead, which
drops the summary with everything after. A conversation with branches from pi's
-`/tree` cannot be trimmed cleanly either. Nothing is changed when this happens.
+`/tree` cannot be trimmed cleanly either. A message is only matched to the agent's
+record by its exact text (or as a voice turn), never by a fragment of it. Nothing
+is changed when this happens.
+
+If an edit's replacement is refused — the model is unreachable, say — the
+conversation is put back as it was, rather than left without the messages the
+edit meant to replace.
## Sidebar and the sessions page
diff --git a/server/src/db.ts b/server/src/db.ts
index 8372c297..a384a069 100644
--- a/server/src/db.ts
+++ b/server/src/db.ts
@@ -562,11 +562,52 @@ export function sentMessages(sessionId: string): { seq: number; message: string
return rows.map((r) => ({ seq: r.seq, message: String(JSON.parse(r.payload)?.message ?? "") }));
}
-/** Drops a stretch of a session's transcript: `from` up to, not including, `to` — or to the end. */
-export function deleteEventsBetween(sessionId: string, from: number, to: number | null): void {
- getDb()
- .prepare("DELETE FROM events WHERE session_id = ? AND seq >= ? AND (? IS NULL OR seq < ?)")
- .run(sessionId, from, to, to);
+/**
+ * Drops a stretch of a session's transcript: `from` up to, not including, `to` — or to the end.
+ * Returns what it removed, so the caller can put it back.
+ */
+export function deleteEventsBetween(sessionId: string, from: number, to: number | null): EventRow[] {
+ const db = getDb();
+ return db.transaction(() => {
+ const rows = db
+ .prepare("SELECT * FROM events WHERE session_id = ? AND seq >= ? AND (? IS NULL OR seq < ?) ORDER BY seq ASC")
+ .all(sessionId, from, to, to) as EventRow[];
+ db.prepare("DELETE FROM events WHERE session_id = ? AND seq >= ? AND (? IS NULL OR seq < ?)").run(
+ sessionId,
+ from,
+ to,
+ to,
+ );
+ return rows;
+ })();
+}
+
+/** Puts events back under the seq they had — the inverse of deleteEventsBetween. */
+export function restoreEvents(rows: EventRow[]): void {
+ const db = getDb();
+ const insert = db.prepare(
+ "INSERT OR REPLACE INTO events (seq, session_id, type, payload, created_at) VALUES (?, ?, ?, ?, ?)",
+ );
+ db.transaction(() => {
+ for (const r of rows) insert.run(r.seq, r.session_id, r.type, r.payload, r.created_at);
+ })();
+}
+
+/**
+ * The highest seq ever handed out, deleted events included: everything recorded
+ * from now on is greater. Read from the sequence rather than the table, because
+ * the newest rows may be the ones just removed.
+ */
+export function latestSeq(): number {
+ const row = getDb().prepare("SELECT seq FROM sqlite_sequence WHERE name = 'events'").get() as
+ | { seq: number }
+ | undefined;
+ return row?.seq ?? 0;
+}
+
+/** Drops one event. */
+export function deleteEvent(seq: number): void {
+ getDb().prepare("DELETE FROM events WHERE seq = ?").run(seq);
}
/** The page before a cursor, oldest first — what a transcript scrolls back into. */
diff --git a/server/src/pi/session-edit.ts b/server/src/pi/session-edit.ts
index b4fe0754..60995efa 100644
--- a/server/src/pi/session-edit.ts
+++ b/server/src/pi/session-edit.ts
@@ -74,13 +74,16 @@ function pathTo(byId: Map, leaf: string): Entry[] {
/**
* Which of pi's user entries a message the portal sent corresponds to.
*
- * Matched by text, in order. The portal logs every message it sends and pi
- * stores every one it receives, but they are not the same list: a slash command
- * is not a chat message to the portal and can expand into one for pi, and a
- * message that failed before reaching pi has no entry at all. So each sent
- * message looks forward from the last one matched, and one that finds nothing
- * is skipped rather than stopping the count — only the message being asked
- * about has to be found.
+ * Matched by text, in order, and only exactly — or with the one prefix the
+ * portal adds to a voice turn. A looser match would take a message pi never
+ * received for a later one that merely contains its words, and remove the wrong
+ * turn from the file. The portal logs every message it sends and pi stores every
+ * one it receives, but they are not the same list: a slash command is not a chat
+ * message to the portal and can expand into one for pi, and a message that
+ * failed before reaching pi has no entry at all. So each sent message looks
+ * forward from the last one matched, and one that finds nothing is skipped
+ * rather than stopping the count — only the message being asked about has to be
+ * found.
*/
function locate(path: Entry[], sent: string[], ordinal: number): string {
const users = path.filter(isUser).map((e) => ({ id: e.id!, text: textOf(e.message?.content) }));
@@ -88,10 +91,9 @@ function locate(path: Entry[], sent: string[], ordinal: number): string {
let found = -1;
for (let i = 0; i <= ordinal; i++) {
const want = sent[i] ?? "";
- let hit = users.findIndex(
+ const hit = users.findIndex(
(u, j) => j >= from && (u.text === want || u.text === AUDIO_MESSAGE_PREFIX + want),
);
- if (hit < 0 && want) hit = users.findIndex((u, j) => j >= from && u.text.includes(want));
if (hit < 0) {
if (i === ordinal) {
throw new SessionEditError(
diff --git a/server/src/session-manager.ts b/server/src/session-manager.ts
index e64d84b8..7637ec11 100644
--- a/server/src/session-manager.ts
+++ b/server/src/session-manager.ts
@@ -9,8 +9,11 @@ import { dropMessage, SessionEditError, type Scope } from "./pi/session-edit.js"
import { buildExecutor, type Executor, type ExecutorKind } from "./executors/index.js";
import {
appendEvent,
+ deleteEvent,
deleteEventsBetween,
getSession,
+ latestSeq,
+ restoreEvents,
sentMessages,
getSettings,
markOrphanedSessionsInterrupted,
@@ -18,6 +21,7 @@ import {
browserAllowlist,
routineGuards,
updateSession,
+ type EventRow,
} from "./db.js";
/**
@@ -400,6 +404,24 @@ class SessionManager extends EventEmitter {
* would be answering something that is being removed underneath it.
*/
async removeMessage(sessionId: string, seq: number, scope: Scope): Promise {
+ const { removed } = await this.cut(sessionId, seq, scope);
+ this.record(sessionId, "portal_removed", removed);
+ }
+
+ /**
+ * The removal itself, without telling anyone, and with a way back.
+ *
+ * Two things are changed — pi's file and the transcript — and they have to
+ * stay in step. The file goes first, since a message gone from the screen but
+ * still remembered by the agent is the worse of the two ways to be wrong. If
+ * the transcript then fails to update, the file is put back. `undo` does the
+ * same later, for a caller whose next step failed.
+ */
+ private async cut(
+ sessionId: string,
+ seq: number,
+ scope: Scope,
+ ): Promise<{ removed: { from: number; to: number | null }; undo: () => Promise }> {
const session = getSession(sessionId);
if (!session) throw new SessionEditError("missing", "Unknown session");
if (this.isBusy(sessionId) || this.compacting.has(sessionId)) {
@@ -416,31 +438,67 @@ class SessionManager extends EventEmitter {
await this.stop(sessionId);
const file = session.pi_session_file;
+ // Beside it, then renamed over it, so a crash mid-write leaves the
+ // original rather than half of each.
+ const write = (text: string) => {
+ const tmp = `${file}.edit`;
+ writeFileSync(tmp, text);
+ renameSync(tmp, file!);
+ };
+ let original: string | undefined;
if (file && existsSync(file)) {
- const edited = dropMessage(
- readFileSync(file, "utf8"),
- sent.slice(0, ordinal + 1).map((m) => m.message),
- ordinal,
- scope,
+ original = readFileSync(file, "utf8");
+ write(
+ dropMessage(
+ original,
+ sent.slice(0, ordinal + 1).map((m) => m.message),
+ ordinal,
+ scope,
+ ),
);
- // Beside it, then renamed over it, so a crash mid-write leaves the
- // original rather than half of each.
- const tmp = `${file}.edit`;
- writeFileSync(tmp, edited);
- renameSync(tmp, file);
} else if (session.executor !== "host") {
throw new SessionEditError("unsupported", "Messages cannot be edited in a container session.");
}
const to = scope === "tail" ? null : (sent[ordinal + 1]?.seq ?? null);
- deleteEventsBetween(sessionId, seq, to);
- this.record(sessionId, "portal_removed", { from: seq, to });
+ let gone: EventRow[];
+ try {
+ gone = deleteEventsBetween(sessionId, seq, to);
+ } catch (e) {
+ if (original !== undefined) write(original);
+ throw e;
+ }
+ const undo = async () => {
+ // A client started since would hold the edited conversation in memory.
+ await this.stop(sessionId);
+ if (original !== undefined) write(original);
+ restoreEvents(gone);
+ };
+ return { removed: { from: seq, to }, undo };
}
/** Replace a message: everything from it onwards goes, and the new text is sent in its place. */
async editMessage(sessionId: string, seq: number, message: string): Promise {
- await this.removeMessage(sessionId, seq, "tail");
- await this.prompt(sessionId, message);
+ const { removed, undo } = await this.cut(sessionId, seq, "tail");
+ const before = latestSeq();
+ try {
+ await this.prompt(sessionId, message);
+ } catch (e) {
+ // The replacement never got to the agent, so the conversation it was
+ // meant to replace is still the conversation: nothing may be lost to a
+ // model that was down or a client that would not start.
+ await undo();
+ // The transcript recorded the replacement before pi refused it.
+ for (const m of sentMessages(sessionId)) {
+ if (m.seq <= before) continue;
+ deleteEvent(m.seq);
+ this.record(sessionId, "portal_removed", { from: m.seq, to: m.seq + 1 });
+ }
+ throw e;
+ }
+ // Told only now, and only about what was removed: the replacement's own
+ // events are newer than everything that went, so a browser keeps them.
+ this.record(sessionId, "portal_removed", { from: removed.from, to: before + 1 });
}
/**
diff --git a/server/test/session-edit.test.mjs b/server/test/session-edit.test.mjs
index 96010392..7b493ace 100644
--- a/server/test/session-edit.test.mjs
+++ b/server/test/session-edit.test.mjs
@@ -126,3 +126,10 @@ test('a dead branch hanging off a removed message is removed with it', () => {
assert.ok(!ids(out).includes('dead'));
assert.deepEqual(conversation(out), ['u2', 'a2']);
});
+
+test('a later message that merely contains the words is not taken for a message pi never got', () => {
+ // "foo" failed before reaching pi; "foobar" is a different message that happens to contain it.
+ const partial = file(user('u1', null, 'first'), asst('a1', 'u1'), user('u2', 'a1', 'foobar'), asst('a2', 'u2'));
+ assert.throws(() => dropMessage(partial, ['first', 'foo'], 1, 'turn'), (e) => e instanceof SessionEditError && e.code === 'unmatched');
+ assert.throws(() => dropMessage(partial, ['first', 'foo'], 1, 'tail'), (e) => e.code === 'unmatched');
+});
diff --git a/web/node_modules b/web/node_modules
new file mode 120000
index 00000000..50fad60a
--- /dev/null
+++ b/web/node_modules
@@ -0,0 +1 @@
+/tmp/claude-1000/-home-piggidragon-Services-pithagoras/592e7a03-0438-4f91-94a2-64c0a729ccc9/scratchpad/main-wt/web/node_modules
\ No newline at end of file
diff --git a/web/src/components/Chat.tsx b/web/src/components/Chat.tsx
index ac3c109c..26b70083 100644
--- a/web/src/components/Chat.tsx
+++ b/web/src/components/Chat.tsx
@@ -719,7 +719,7 @@ function MessageEditor({
const changed = value.trim() !== initial.trim();
const save = async () => {
- if (!value.trim() || saving) return;
+ if (!value.trim() || !changed || saving) return;
setSaving(true);
try {
await onSave(value.trim());
From 98e38e6c07280591959f394f174ef838b2cf1799 Mon Sep 17 00:00:00 2001
From: Piggidragon
Date: Sat, 19 Sep 2026 22:31:09 +0200
Subject: [PATCH 4/5] Remove a stray node_modules symlink committed by mistake
Co-Authored-By: Claude Sonnet 5
---
web/node_modules | 1 -
1 file changed, 1 deletion(-)
delete mode 120000 web/node_modules
diff --git a/web/node_modules b/web/node_modules
deleted file mode 120000
index 50fad60a..00000000
--- a/web/node_modules
+++ /dev/null
@@ -1 +0,0 @@
-/tmp/claude-1000/-home-piggidragon-Services-pithagoras/592e7a03-0438-4f91-94a2-64c0a729ccc9/scratchpad/main-wt/web/node_modules
\ No newline at end of file
From 9fa4dfc6b5965f67ae3880dbf7210bd9aaf7b449 Mon Sep 17 00:00:00 2001
From: Piggidragon
Date: Sat, 19 Sep 2026 22:44:59 +0200
Subject: [PATCH 5/5] Serialize message edits with prompts
An edit rewrites the file pi reads and deletes events. A prompt arriving
while stop() was still cleaning up could start a client on the old file, or
have its own portal_prompt deleted with the tail.
A per-session lease is now held from before the busy check to after the
replacement is sent or the old conversation is restored. Prompts and client
starts from anywhere else wait for it instead of failing; a second edit is
refused as busy. The replacement prompt goes through the lease.
Co-Authored-By: Claude Sonnet 5
---
server/src/session-manager.ts | 99 +++++++++++++++++++++++++----------
1 file changed, 72 insertions(+), 27 deletions(-)
diff --git a/server/src/session-manager.ts b/server/src/session-manager.ts
index 7637ec11..a1a95fa4 100644
--- a/server/src/session-manager.ts
+++ b/server/src/session-manager.ts
@@ -208,9 +208,12 @@ class SessionManager extends EventEmitter {
* before it prompts, and any two requests landing together on a session
* nobody has opened yet will do it.
*/
- private ensureClient(sessionId: string): Promise {
+ private async ensureClient(sessionId: string, insideEdit = false): Promise {
+ // A client started now would read the file the edit is about to rewrite, and
+ // go on holding the conversation as it was.
+ if (!insideEdit) await this.whenEditable(sessionId);
const existing = this.live.get(sessionId);
- if (existing?.client.running) return Promise.resolve(existing.client);
+ if (existing?.client.running) return existing.client;
const starting = this.starting.get(sessionId);
if (starting) return starting;
@@ -338,7 +341,9 @@ class SessionManager extends EventEmitter {
* for the first message in a session takes seconds and the composer has
* nothing to show for them otherwise.
*/
- async prompt(sessionId: string, message: string, options?: { voice?: boolean }): Promise {
+ async prompt(sessionId: string, message: string, options?: { voice?: boolean }, insideEdit = false): Promise {
+ // Behind an edit in progress, not through it: see withEdit.
+ if (!insideEdit) await this.whenEditable(sessionId);
this.mark(sessionId, "running");
// Same reason as in abort(): a session mid-compaction is detached from
// agent events, and a prompt started there is invisible.
@@ -348,7 +353,7 @@ class SessionManager extends EventEmitter {
// and isBusy() reads false for however long pi takes to answer.
this.mark(sessionId, "running");
try {
- await this.submit(sessionId, message, options);
+ await this.submit(sessionId, message, options, insideEdit);
} catch (e) {
const failure = (e as Error).message;
updateSession(sessionId, { status: "error", last_error: failure });
@@ -357,8 +362,13 @@ class SessionManager extends EventEmitter {
}
}
- private async submit(sessionId: string, message: string, options?: { voice?: boolean }): Promise {
- const client = await this.ensureClient(sessionId);
+ private async submit(
+ sessionId: string,
+ message: string,
+ options?: { voice?: boolean },
+ insideEdit = false,
+ ): Promise {
+ const client = await this.ensureClient(sessionId, insideEdit);
// A slash command is an instruction to the agent, not something said in the
// conversation, so it should not appear as a chat message — its dialog or
@@ -404,8 +414,41 @@ class SessionManager extends EventEmitter {
* would be answering something that is being removed underneath it.
*/
async removeMessage(sessionId: string, seq: number, scope: Scope): Promise {
- const { removed } = await this.cut(sessionId, seq, scope);
- this.record(sessionId, "portal_removed", removed);
+ await this.withEdit(sessionId, async () => {
+ const { removed } = await this.cut(sessionId, seq, scope);
+ this.record(sessionId, "portal_removed", removed);
+ });
+ }
+
+ /** Edits waiting to finish, by session. */
+ private editing = new Map>();
+
+ /**
+ * One edit at a time on a conversation, and nothing else starting pi on it.
+ *
+ * An edit rewrites the file pi reads and drops events; a prompt arriving in
+ * the middle would open a client on the old conversation, or have its own
+ * event deleted with the tail. Held from before the busy check to after the
+ * replacement is sent or the old conversation is back. Other callers wait for
+ * it rather than fail — a message from a channel arrives a moment late instead
+ * of not at all — while a second edit is refused.
+ */
+ private async withEdit(sessionId: string, work: () => Promise): Promise {
+ if (this.editing.has(sessionId)) {
+ throw new SessionEditError("busy", "This conversation is already being edited.");
+ }
+ let release!: () => void;
+ this.editing.set(sessionId, new Promise((resolve) => (release = resolve)));
+ try {
+ return await work();
+ } finally {
+ this.editing.delete(sessionId);
+ release();
+ }
+ }
+
+ private async whenEditable(sessionId: string): Promise {
+ for (let edit = this.editing.get(sessionId); edit; edit = this.editing.get(sessionId)) await edit;
}
/**
@@ -479,26 +522,28 @@ class SessionManager extends EventEmitter {
/** Replace a message: everything from it onwards goes, and the new text is sent in its place. */
async editMessage(sessionId: string, seq: number, message: string): Promise {
- const { removed, undo } = await this.cut(sessionId, seq, "tail");
- const before = latestSeq();
- try {
- await this.prompt(sessionId, message);
- } catch (e) {
- // The replacement never got to the agent, so the conversation it was
- // meant to replace is still the conversation: nothing may be lost to a
- // model that was down or a client that would not start.
- await undo();
- // The transcript recorded the replacement before pi refused it.
- for (const m of sentMessages(sessionId)) {
- if (m.seq <= before) continue;
- deleteEvent(m.seq);
- this.record(sessionId, "portal_removed", { from: m.seq, to: m.seq + 1 });
+ await this.withEdit(sessionId, async () => {
+ const { removed, undo } = await this.cut(sessionId, seq, "tail");
+ const before = latestSeq();
+ try {
+ await this.prompt(sessionId, message, undefined, true);
+ } catch (e) {
+ // The replacement never got to the agent, so the conversation it was
+ // meant to replace is still the conversation: nothing may be lost to a
+ // model that was down or a client that would not start.
+ await undo();
+ // The transcript recorded the replacement before pi refused it.
+ for (const m of sentMessages(sessionId)) {
+ if (m.seq <= before) continue;
+ deleteEvent(m.seq);
+ this.record(sessionId, "portal_removed", { from: m.seq, to: m.seq + 1 });
+ }
+ throw e;
}
- throw e;
- }
- // Told only now, and only about what was removed: the replacement's own
- // events are newer than everything that went, so a browser keeps them.
- this.record(sessionId, "portal_removed", { from: removed.from, to: before + 1 });
+ // Told only now, and only about what was removed: the replacement's own
+ // events are newer than everything that went, so a browser keeps them.
+ this.record(sessionId, "portal_removed", { from: removed.from, to: before + 1 });
+ });
}
/**