@a-t-h-i/bot-lobby 0.6.2 → 0.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/lobby/view.ts CHANGED
@@ -9,7 +9,8 @@
9
9
  * pane toggles, paging) work in both. Everything bot-lobby-specific arrives
10
10
  * through `LobbyHost`, so the view renders and reacts the same in tests.
11
11
  */
12
- import { decodeKittyPrintable, Editor, Input, Key, matchesKey, visibleWidth, type Component, type EditorTheme, type Focusable, type TUI, type TuiMouseEvent, type TuiMouseEventResult } from "@earendil-works/pi-tui";
12
+ import { decodeKittyPrintable, Editor, Input, Key, matchesKey, type Component, type EditorTheme, type Focusable, type TUI, type TuiMouseEvent, type TuiMouseEventResult } from "@earendil-works/pi-tui";
13
+ import { textWidth } from "../width.ts";
13
14
  import { TERMINAL_STATES, type Task } from "../schemas/task.ts";
14
15
  import type { AgentRun } from "../schemas/findings.ts";
15
16
  import type { PlannedTask } from "../state/backlog.ts";
@@ -23,7 +24,7 @@ import { MEMBER_LABELS, ORACLE_LABEL, type PlannerSeed, type PlanningSession } f
23
24
  import { issueText, type IssuesState } from "./issues.ts";
24
25
  import { actionFor, keyLabel, keyMap, LOBBY_ACTIONS, type KeyMap, type LobbyAction } from "./keys.ts";
25
26
  import { beside, bold, box, fit, highlight, paint, rule, selectRow, spinner, spread, windowStart, wrap, wrapHanging, type LobbyTheme, type PaneLayout } from "./layout.ts";
26
- import { chatLines, HOME_PANES, renderHome, type HomePane } from "./tabs/home.ts";
27
+ import { chatTail, HOME_PANES, renderHome, type HomePane } from "./tabs/home.ts";
27
28
  import { filterRows, pips, planDetailLines, renderTasks, taskDetailLines, taskProgress, taskRows, tasksWidths, type TaskRow } from "./tabs/tasks.ts";
28
29
  import { renderPlan, type PlanLayout, type PlanView, type SeatView } from "./tabs/plan.ts";
29
30
  import { filterJobs, newestFirst, renderQuickFix } from "./tabs/quickfix.ts";
@@ -113,6 +114,9 @@ function sameView(a: SessionView, b: SessionView): boolean {
113
114
  return true;
114
115
  }
115
116
 
117
+ /** Tops a conversation whose earlier messages are not in memory. */
118
+ export const OLDER_NOTE = "↑ earlier messages load as you scroll up";
119
+
116
120
  const SECTION_OF: Record<SessionWhere, string> = { "this window": "THIS WINDOW", background: "BACKGROUND", "other terminal": "OTHER TERMINALS", "not running": "NOT RUNNING" };
117
121
  const WHERE_MARKS: Record<SessionWhere, string> = { "this window": "●", background: "◆", "other terminal": "◇", "not running": "○" };
118
122
 
@@ -189,8 +193,12 @@ export interface LobbyHost {
189
193
  archiveTask(taskId: string): string;
190
194
  restoreTask(taskId: string): string;
191
195
  deleteTask(taskId: string, where: "list" | "archive"): string;
192
- /** Another session's conversation, read from its saved session file. */
196
+ /** Another session's conversation, read from its saved session file (its newest messages). */
193
197
  sessionChat(sessionId: string): ChatEntry[];
198
+ /** Whether another session has messages earlier than `sessionChat` returns. */
199
+ hasOlderChat(sessionId: string): boolean;
200
+ /** A session's whole conversation, oldest first: this window's (no id) or another's; loaded only while scrolled back to it. */
201
+ chatHistory(sessionId?: string): readonly ChatEntry[];
194
202
  /** A task's status without animations, for a session other than this window's. */
195
203
  taskScene(task: Task, width: number, height: number): string[];
196
204
  profileLabel(kind: LobbyAgentKind): string;
@@ -216,7 +224,7 @@ class LobbyEditor extends Editor {
216
224
  if (!this.label) return super.renderTopBorder(width, hiddenLineCount);
217
225
  const label = ` ${this.label} `;
218
226
  const more = hiddenLineCount > 0 ? ` ↑${hiddenLineCount} ` : "";
219
- const rest = width - 2 - visibleWidth(label) - visibleWidth(more);
227
+ const rest = width - 2 - textWidth(label) - textWidth(more);
220
228
  if (rest < 1) return super.renderTopBorder(width, hiddenLineCount);
221
229
  return `${this.borderColor("──")}${this.paintLabel(label)}${this.borderColor("─".repeat(rest))}${more ? this.borderColor(more) : ""}`;
222
230
  }
@@ -430,6 +438,7 @@ export class LobbyView implements Component, Focusable {
430
438
  refreshData(force: boolean): void {
431
439
  const now = this.now();
432
440
  if (!force && now - this.data.at < DATA_REFRESH_MS) return;
441
+ this.dataVersion += 1;
433
442
  this.data = {
434
443
  tasks: this.host.tasks(),
435
444
  plans: this.host.plans(),
@@ -838,6 +847,7 @@ export class LobbyView implements Component, Focusable {
838
847
  break;
839
848
  }
840
849
  this.clampScroll();
850
+ if (pane === "conversation") this.followHistory();
841
851
  }
842
852
 
843
853
  /** Home and End: the oldest or first line of the focused pane, or back to its newest or last. */
@@ -861,6 +871,8 @@ export class LobbyView implements Component, Focusable {
861
871
  return;
862
872
  }
863
873
  this.scrollPane(pane, far);
874
+ // Home loads earlier messages at the top; go on to the very first of them.
875
+ if (start && pane === "conversation" && this.history) this.homeOffsets.conversation = Number.MAX_SAFE_INTEGER;
864
876
  }
865
877
 
866
878
  /** The wheel over the draft scrolls it; the cursor follows only when it would leave the screen. */
@@ -954,7 +966,9 @@ export class LobbyView implements Component, Focusable {
954
966
  const rows = this.taskRowList();
955
967
  this.tasksSelected = Math.max(0, Math.min(rows.length - 1, index));
956
968
  this.tasksDetailOffset = 0;
957
- this.refreshData(true);
969
+ // Only the newly selected task's comments: moving through the list must not reread every task from disk.
970
+ const row = rows[this.tasksSelected];
971
+ if (row?.kind === "task" && !this.data.comments.has(row.id)) this.data.comments.set(row.id, this.host.comments(row.id));
958
972
  }
959
973
 
960
974
  private async loadIssueDetail(): Promise<void> {
@@ -1205,6 +1219,50 @@ export class LobbyView implements Component, Focusable {
1205
1219
  void this.host.answerDialog(session).then(() => this.host.requestRender());
1206
1220
  }
1207
1221
 
1222
+ /* -------------------------------------------------------------- history */
1223
+
1224
+ /**
1225
+ * The whole conversation of the session in view, loaded when the
1226
+ * conversation pane is scrolled back past the messages kept in memory, and
1227
+ * let go once it is back at the newest.
1228
+ */
1229
+ private history?: { view: SessionView; entries: readonly ChatEntry[] };
1230
+
1231
+ /** The conversation to draw for the session in view, and whether earlier messages wait to be loaded. */
1232
+ private conversationFor(entry: SessionEntry): { chat: readonly ChatEntry[]; older: boolean } {
1233
+ if (this.history && sameView(this.history.view, entry.view)) return { chat: this.history.entries, older: false };
1234
+ if (entry.view.kind === "here") return { chat: this.host.feed.chat, older: this.host.feed.chatOlder };
1235
+ if (entry.view.kind === "background") {
1236
+ const session = this.viewedSessionFor(entry.view.key);
1237
+ return { chat: session?.feed.chat ?? [], older: Boolean(session?.feed.chatOlder && session.sessionId) };
1238
+ }
1239
+ return entry.sessionId ? { chat: this.host.sessionChat(entry.sessionId), older: this.host.hasOlderChat(entry.sessionId) } : { chat: [], older: false };
1240
+ }
1241
+
1242
+ /**
1243
+ * After the conversation pane scrolls: at its top, with earlier messages
1244
+ * waiting, load the whole conversation; back at its newest, let it go.
1245
+ * True when history was loaded now.
1246
+ */
1247
+ private followHistory(): boolean {
1248
+ if (this.tab !== "lobby") return false;
1249
+ if (this.homeOffsets.conversation === 0) {
1250
+ this.history = undefined;
1251
+ return false;
1252
+ }
1253
+ if (this.history && sameView(this.history.view, this.viewing)) return false;
1254
+ const pane = this.panes.get("conversation");
1255
+ if (!pane || this.homeOffsets.conversation < pane.total - pane.rows) return false;
1256
+ const entry = this.viewedEntry();
1257
+ if (!this.conversationFor(entry).older) return false;
1258
+ const sessionId = entry.view.kind === "here" ? undefined : entry.view.kind === "background" ? this.viewedSessionFor(entry.view.key)?.sessionId : entry.sessionId;
1259
+ if (entry.view.kind !== "here" && !sessionId) return false;
1260
+ const entries = this.host.chatHistory(sessionId);
1261
+ if (entries.length === 0) return false;
1262
+ this.history = { view: entry.view, entries };
1263
+ return true;
1264
+ }
1265
+
1208
1266
  /* ------------------------------------------------------------ sessions */
1209
1267
 
1210
1268
  /** Every session the lobby can show: this window, the background sessions it started, then tasks other terminals drive. */
@@ -1270,6 +1328,7 @@ export class LobbyView implements Component, Focusable {
1270
1328
  this.viewing = target;
1271
1329
  this.picking = false;
1272
1330
  this.newSession = false;
1331
+ this.history = undefined;
1273
1332
  for (const pane of HOME_PANES) this.homeOffsets[pane] = 0;
1274
1333
  if (this.tab !== "lobby") this.setTab("lobby");
1275
1334
  this.setMode("type");
@@ -1657,8 +1716,41 @@ export class LobbyView implements Component, Focusable {
1657
1716
 
1658
1717
  /* -------------------------------------------------------------- render */
1659
1718
 
1719
+ /** Bumped each time the data is reread. */
1720
+ private dataVersion = 0;
1721
+ /** Task rows as last built: several parts of a frame ask, and they only change with the data, the search or the clock. */
1722
+ private rowsMemo?: { key: string; rows: TaskRow[] };
1723
+
1724
+ /** The Metrics tab's figures as last computed: they change only with the data, the grouping, the order or the search. */
1725
+ private metricsMemo?: { key: string; figures: ReturnType<LobbyView["computeMetrics"]> };
1726
+
1727
+ private metricsFigures(query: string | undefined) {
1728
+ const key = `${this.dataVersion}|${this.metricsBy}|${this.metricsSort}|${query ?? ""}`;
1729
+ if (this.metricsMemo?.key !== key) this.metricsMemo = { key, figures: this.computeMetrics(query) };
1730
+ return this.metricsMemo.figures;
1731
+ }
1732
+
1733
+ private computeMetrics(query: string | undefined) {
1734
+ const records = filterRecords(collectMetrics(this.data.metrics, this.data.tasks), query);
1735
+ return {
1736
+ records,
1737
+ groups: sortGroups(aggregateMetrics(records, this.metricsBy), this.metricsSort),
1738
+ taskTimes: taskTimesByModel(this.data.tasks, records),
1739
+ stats: taskStats(this.data.tasks),
1740
+ jev: summarizeClassifier(this.data.classifierMetrics, records),
1741
+ };
1742
+ }
1743
+
1660
1744
  /** Task rows, narrowed by the Tasks tab's search. */
1661
1745
  private taskRowList(): TaskRow[] {
1746
+ const key = `${this.dataVersion}|${this.tick}|${this.showArchived}|${this.queries.tasks ?? ""}`;
1747
+ if (this.rowsMemo?.key === key) return this.rowsMemo.rows;
1748
+ const rows = this.buildTaskRows();
1749
+ this.rowsMemo = { key, rows };
1750
+ return rows;
1751
+ }
1752
+
1753
+ private buildTaskRows(): TaskRow[] {
1662
1754
  const names = new Map<string, string>();
1663
1755
  for (const session of this.host.sessions()) if (session.sessionId && session.alive) names.set(session.sessionId, session.name);
1664
1756
  const auto = new Set(this.data.tasks.filter((task) => !TERMINAL_STATES.includes(task.state) && this.host.isAuto(task.id)).map((task) => task.id));
@@ -1691,12 +1783,12 @@ export class LobbyView implements Component, Focusable {
1691
1783
  const badge = badges[tab] ? ` ${paint(theme, tab === "plan" && session?.awaitingAnswers ? "warning" : "accent", badges[tab]!)}` : "";
1692
1784
  let cell = ` ${number} ${name}${badge} `;
1693
1785
  if (tab === this.tab) cell = theme.bg ? theme.bg("selectedBg", cell) : `[${cell}]`;
1694
- const from = visibleWidth(left);
1786
+ const from = textWidth(left);
1695
1787
  left += cell;
1696
- spans.push({ tab, from, to: visibleWidth(left) });
1788
+ spans.push({ tab, from, to: textWidth(left) });
1697
1789
  }
1698
1790
  this.tabSpans = spans;
1699
- const room = width - visibleWidth(left) - 1;
1791
+ const room = width - textWidth(left) - 1;
1700
1792
  const entry = this.viewedEntry();
1701
1793
  const help = paint(theme, "dim", `${keyLabel(this.keys.help)} keys `);
1702
1794
  const auto = entry.auto ? ` ${bold(theme, paint(theme, "success", "⟳ AUTO"))}` : "";
@@ -1717,8 +1809,8 @@ export class LobbyView implements Component, Focusable {
1717
1809
  ? [`${dot} ${zen.task.id} ${state}${auto}${asking} ${help}`, `${dot} ${zen.task.id} ${state}${auto}${asking} `, `${dot} ${state}${auto} `, `${dot}${auto} `]
1718
1810
  : [`${paint(theme, "dim", "no task in this session")}${asking} ${help}`, `${paint(theme, "dim", "no task in this session")}${asking} `, help, ""];
1719
1811
  }
1720
- const status = choices.find((choice) => visibleWidth(choice) <= room) ?? "";
1721
- const gap = width - visibleWidth(left) - visibleWidth(status);
1812
+ const status = choices.find((choice) => textWidth(choice) <= room) ?? "";
1813
+ const gap = width - textWidth(left) - textWidth(status);
1722
1814
  return gap >= 1 ? `${left}${" ".repeat(gap)}${status}` : fit(left, width);
1723
1815
  }
1724
1816
 
@@ -1780,7 +1872,7 @@ export class LobbyView implements Component, Focusable {
1780
1872
  const tail = this.searching
1781
1873
  ? paint(theme, "dim", " enter keep · esc clear · ↑↓ browse results ")
1782
1874
  : paint(theme, "dim", ` ${keyLabel(this.keys.search)} or / edits · esc clears `);
1783
- const room = Math.max(4, width - visibleWidth(lead) - visibleWidth(tail));
1875
+ const room = Math.max(4, width - textWidth(lead) - textWidth(tail));
1784
1876
  const field = this.searching ? this.search.render(room)[0] ?? "" : bold(theme, fit(query ?? "", room));
1785
1877
  return [fit(`${lead}${fit(field, room)}${tail}`, width)];
1786
1878
  }
@@ -2018,12 +2110,9 @@ export class LobbyView implements Component, Focusable {
2018
2110
  }, width, height, theme);
2019
2111
  }
2020
2112
  case "metrics": {
2021
- const records = filterRecords(collectMetrics(this.data.metrics, this.data.tasks), query);
2022
- const groups = sortGroups(aggregateMetrics(records, this.metricsBy), this.metricsSort);
2113
+ const { records, groups, taskTimes, stats, jev } = this.metricsFigures(query);
2023
2114
  this.metricsSelected = Math.min(this.metricsSelected, Math.max(0, groups.length - 1));
2024
- const taskTimes = taskTimesByModel(this.data.tasks, records);
2025
- const jev = summarizeClassifier(this.data.classifierMetrics, records);
2026
- return renderMetrics({ groups, taskTimes, records, stats: taskStats(this.data.tasks), by: this.metricsBy, sort: this.metricsSort, selected: this.metricsSelected, ...(query ? { query } : {}), panes: this.panes, ...(jev ? { classifier: jev } : {}) }, width, height, theme);
2115
+ return renderMetrics({ groups, taskTimes, records, stats, by: this.metricsBy, sort: this.metricsSort, selected: this.metricsSelected, ...(query ? { query } : {}), panes: this.panes, ...(jev ? { classifier: jev } : {}) }, width, height, theme);
2027
2116
  }
2028
2117
  }
2029
2118
  }
@@ -2150,7 +2239,7 @@ export class LobbyView implements Component, Focusable {
2150
2239
  const room = height - lines.length - 2;
2151
2240
  if (room > 2) {
2152
2241
  lines.push("", rule(width, "Conversation", theme));
2153
- const talk = chat.length > 0 ? chatLines(chat, width, theme) : [paint(theme, "dim", "Nothing said yet.")];
2242
+ const talk = chat.length > 0 ? chatTail(chat, width, theme, room - 1).lines : [paint(theme, "dim", "Nothing said yet.")];
2154
2243
  lines.push(...talk.slice(-(room - 1)));
2155
2244
  }
2156
2245
  return lines;
@@ -2175,9 +2264,11 @@ export class LobbyView implements Component, Focusable {
2175
2264
  const query = this.query();
2176
2265
  const entry = this.viewedEntry();
2177
2266
  if (entry.view.kind !== "here") return this.otherSessionBody(entry, width, height, theme, now);
2267
+ const talk = this.conversationFor(entry);
2178
2268
  return renderHome({
2179
2269
  ...(zen.task ? { task: zen.task, scene: (w: number, h: number, animated: boolean) => this.host.scene(w, h, animated) } : {}),
2180
- chat: feed.chat,
2270
+ chat: talk.chat,
2271
+ ...(talk.older ? { olderNote: OLDER_NOTE } : {}),
2181
2272
  ...(feed.reply ? { liveReply: feed.reply } : {}),
2182
2273
  activity: feed.activity,
2183
2274
  thoughts: feed.thoughts,
@@ -2207,6 +2298,7 @@ export class LobbyView implements Component, Focusable {
2207
2298
  */
2208
2299
  private otherSessionBody(entry: SessionEntry, width: number, height: number, theme: LobbyTheme, now: number): string[] {
2209
2300
  const session = this.viewedSession();
2301
+ const talk = this.conversationFor(entry);
2210
2302
  const task = entry.task;
2211
2303
  const query = this.query();
2212
2304
  const common = {
@@ -2232,7 +2324,8 @@ export class LobbyView implements Component, Focusable {
2232
2324
  const emptyNote = session.status === "starting" ? `starting ${session.name}…` : !session.alive ? `${session.name} has ended.` : `${session.name} has not said anything yet.`;
2233
2325
  return renderHome({
2234
2326
  ...common,
2235
- chat: session.feed.chat,
2327
+ chat: talk.chat,
2328
+ ...(talk.older ? { olderNote: OLDER_NOTE } : {}),
2236
2329
  ...(session.feed.reply ? { liveReply: session.feed.reply } : {}),
2237
2330
  activity: session.feed.activity,
2238
2331
  thoughts: session.feed.thoughts,
@@ -2243,7 +2336,8 @@ export class LobbyView implements Component, Focusable {
2243
2336
  const running = entry.where === "other terminal";
2244
2337
  return renderHome({
2245
2338
  ...common,
2246
- chat: entry.sessionId ? this.host.sessionChat(entry.sessionId) : [],
2339
+ chat: talk.chat,
2340
+ ...(talk.older ? { olderNote: OLDER_NOTE } : {}),
2247
2341
  activity: [],
2248
2342
  thoughts: [],
2249
2343
  busy: false,
package/src/pi/events.ts CHANGED
@@ -15,6 +15,7 @@ import { taskRequest, type Task, type TaskState } from "../schemas/task.ts";
15
15
  import { pendingComments, readPlanComments, type PlanComment } from "../state/comments.ts";
16
16
  import { isAutoMode } from "../state/auto.ts";
17
17
  import { triageContext } from "../classifier/triage.ts";
18
+ import { previousTaskNote } from "./fresh-context.ts";
18
19
 
19
20
  /** States in which the triage still helps the Master shape the task; once it is planned, the hints are noise. */
20
21
  const SHAPING_STATES: ReadonlySet<TaskState> = new Set(["created", "clarifying", "scouting", "synthesizing", "awaiting_approval"]);
@@ -92,9 +93,13 @@ export function registerLifecycle(pi: ExtensionAPI, configDir: string): void {
92
93
  pi.on("before_agent_start", (event, ctx) => {
93
94
  const root = detectProjectRoot(ctx.cwd, configDir);
94
95
  const sessionId = ctx.sessionManager.getSessionId();
95
- const task = isSubagentProcess() || isMinimized() ? undefined : activeTask(root, configDir, sessionId);
96
+ const hidden = isSubagentProcess() || isMinimized();
97
+ const task = hidden ? undefined : activeTask(root, configDir, sessionId);
96
98
  if (!task) {
97
- delete event.systemPromptOptions.sections["bot-lobby"];
99
+ // After a task ended and cleared the context, a line on where its record is, in case the user refers back to it.
100
+ const previous = hidden ? undefined : previousTaskNote(ctx, configDir);
101
+ if (previous) event.systemPromptOptions.sections["bot-lobby"] = previous;
102
+ else delete event.systemPromptOptions.sections["bot-lobby"];
98
103
  return;
99
104
  }
100
105
  const slices = readAgentKnowledge(readDataRoots(root, configDir), "master");
@@ -0,0 +1,134 @@
1
+ /**
2
+ * A clean slate for the oracle at every task. The session keeps its whole
3
+ * conversation (the lobby, /tree and /resume still show all of it), but the
4
+ * model is sent only what came after the latest task boundary: a task
5
+ * starting in this session, or this session's task ending. So one long
6
+ * session no longer carries every past task into every call; the task in
7
+ * hand reaches the oracle through bot-lobby's system prompt section and its
8
+ * task folder, which do not depend on the conversation.
9
+ *
10
+ * A boundary is a custom entry in the session file, which is never sent to
11
+ * the model: it survives /resume and belongs to the branch it was made on.
12
+ * Subagents (scouts, workers, reviewers, the planning panel, quick fixes)
13
+ * already run as pi processes of their own, each with its own context, and
14
+ * are not touched here.
15
+ */
16
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
17
+ import { relative } from "node:path";
18
+ import { TERMINAL_STATES, type Task } from "../schemas/task.ts";
19
+ import { peekTasks, taskDirFor } from "../state/persistence.ts";
20
+ import { detectProjectRoot, loadConfig } from "../state/project.ts";
21
+ import { CLEARED_NOTE, CONTEXT_MARK, lobbyFeed } from "../lobby/feed.ts";
22
+ import { isSubagentProcess } from "./quiet.ts";
23
+
24
+ export { CLEARED_NOTE, CONTEXT_MARK };
25
+
26
+ export interface ContextMark {
27
+ kind: "start" | "end";
28
+ taskId: string;
29
+ /** Messages from this time on (ms) are what the model is sent. */
30
+ at: number;
31
+ }
32
+
33
+ /** The boundaries on a session branch, oldest first. */
34
+ export function contextMarks(entries: readonly unknown[]): ContextMark[] {
35
+ const marks: ContextMark[] = [];
36
+ for (const entry of entries) {
37
+ const mark = asMark(entry);
38
+ if (mark) marks.push(mark);
39
+ }
40
+ return marks;
41
+ }
42
+
43
+ /** A session entry as a boundary, when it is one. */
44
+ export function asMark(entry: unknown): ContextMark | undefined {
45
+ const record = entry as { type?: string; customType?: string; data?: Partial<ContextMark> } | undefined;
46
+ if (record?.type !== "custom" || record.customType !== CONTEXT_MARK) return undefined;
47
+ const data = record.data;
48
+ if (!data || typeof data.at !== "number" || typeof data.taskId !== "string" || (data.kind !== "start" && data.kind !== "end")) return undefined;
49
+ return { kind: data.kind, taskId: data.taskId, at: data.at };
50
+ }
51
+
52
+ /** Replies and tool results answer what came before them, so what the model is sent never opens with one. */
53
+ const ANSWERS = new Set(["assistant", "toolResult"]);
54
+
55
+ /**
56
+ * The messages from the boundary on: from the first one at or after it that
57
+ * can open a conversation (a request, a summary, an extension's message).
58
+ * All of them when nothing has come since the boundary (a task that ended
59
+ * mid-run still gets its closing turn).
60
+ */
61
+ export function fromBoundary<T extends { role: string; timestamp?: number }>(messages: T[], boundary: number | undefined): T[] {
62
+ if (boundary === undefined) return messages;
63
+ const start = messages.findIndex((message) => typeof message.timestamp === "number" && message.timestamp >= boundary && !ANSWERS.has(message.role));
64
+ return start <= 0 ? messages : messages.slice(start);
65
+ }
66
+
67
+ /** Whether the oracle starts clean at each task (`workflow.freshContext`, on by default). */
68
+ export function freshContextOn(): boolean {
69
+ return loadConfig().workflow.freshContext;
70
+ }
71
+
72
+ /** Record a boundary in this session. */
73
+ export function markContext(pi: ExtensionAPI, mark: ContextMark): void {
74
+ pi.appendEntry<ContextMark>(CONTEXT_MARK, mark);
75
+ if (mark.kind === "end") lobbyFeed.say("note", CLEARED_NOTE, mark.at);
76
+ }
77
+
78
+ /** The session's newest task: the one its conversation was last about. */
79
+ function lastTask(ctx: ExtensionContext, configDir: string): Task | undefined {
80
+ const sessionId = ctx.sessionManager.getSessionId();
81
+ return peekTasks(detectProjectRoot(ctx.cwd, configDir), configDir).find((task) => task.ownerSessionId === sessionId);
82
+ }
83
+
84
+ /** Close the session's task once it has ended (completed or abandoned): the next request starts clean. */
85
+ export function closeEndedTask(pi: ExtensionAPI, ctx: ExtensionContext, configDir: string, now = Date.now()): boolean {
86
+ if (isSubagentProcess() || !freshContextOn()) return false;
87
+ const task = lastTask(ctx, configDir);
88
+ if (!task || !TERMINAL_STATES.includes(task.state)) return false;
89
+ if (contextMarks(ctx.sessionManager.getBranch()).some((mark) => mark.kind === "end" && mark.taskId === task.id)) return false;
90
+ markContext(pi, { kind: "end", taskId: task.id, at: now });
91
+ return true;
92
+ }
93
+
94
+ /**
95
+ * The note bot-lobby's system prompt section carries after a task ended and
96
+ * cleared the context: which task it was and where its record is, for when
97
+ * the user refers back to it.
98
+ */
99
+ export function previousTaskNote(ctx: ExtensionContext, configDir: string): string | undefined {
100
+ if (!freshContextOn()) return undefined;
101
+ const last = contextMarks(ctx.sessionManager.getBranch()).at(-1);
102
+ if (last?.kind !== "end") return undefined;
103
+ const root = detectProjectRoot(ctx.cwd, configDir);
104
+ const task = peekTasks(root, configDir).find((entry) => entry.id === last.taskId);
105
+ if (!task) return undefined;
106
+ const record = relative(ctx.cwd, taskDirFor(root, configDir, task.id)) || ".";
107
+ return [
108
+ `Your previous task, ${task.id} "${task.title}", ended (${task.state}). Its conversation was cleared from your context so the next piece of work starts fresh.`,
109
+ `Its record (request, proposal, plan, decisions, runs) is in ${record}/. Read it only if the user refers back to that task.`,
110
+ ].join("\n");
111
+ }
112
+
113
+ /** Trim what the oracle is sent to its latest boundary, and close tasks as they end. */
114
+ export function registerFreshContext(pi: ExtensionAPI, configDir: string): void {
115
+ if (isSubagentProcess()) return;
116
+ pi.on("context", (event, ctx) => {
117
+ if (!freshContextOn()) return undefined;
118
+ const boundary = contextMarks(ctx.sessionManager.getBranch()).at(-1)?.at;
119
+ const messages = fromBoundary(event.messages, boundary);
120
+ return messages.length === event.messages.length ? undefined : { messages };
121
+ });
122
+ // A task's last turn is over: whatever is asked next starts clean.
123
+ pi.on("agent_end", (_event, ctx) => void closeEndedTask(pi, ctx, configDir));
124
+ // It may have ended while the oracle was idle (abandoned or archived from the lobby, finished in another window).
125
+ pi.on("before_agent_start", (_event, ctx) => void closeEndedTask(pi, ctx, configDir));
126
+ // Compacting a conversation the next request drops anyway would only cost a model call.
127
+ pi.on("session_before_compact", (event) => {
128
+ if (event.reason === "manual" || !freshContextOn()) return undefined;
129
+ const last = contextMarks(event.branchEntries).at(-1);
130
+ if (!last) return undefined;
131
+ const since = event.branchEntries.some((entry) => entry.type === "message" && Date.parse(entry.timestamp) >= last.at);
132
+ return since ? undefined : { cancel: true };
133
+ });
134
+ }
package/src/pi/owner.ts CHANGED
@@ -10,7 +10,7 @@
10
10
  import type { KeyId } from "@earendil-works/pi-tui";
11
11
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
12
12
  import { TERMINAL_STATES, type Task } from "../schemas/task.ts";
13
- import { activeTask, loadTask } from "../state/persistence.ts";
13
+ import { peekOwnedTask } from "../state/persistence.ts";
14
14
  import { detectProjectRoot, loadConfig } from "../state/project.ts";
15
15
  import { commentMessage, markCommentsDelivered, readPlanComments, undeliveredComments } from "../state/comments.ts";
16
16
  import { inboxMessage, markInboxDelivered, markSessionInboxDelivered, readInbox, readSessionInbox } from "../state/inbox.ts";
@@ -42,6 +42,8 @@ interface Owner {
42
42
  configDir: string;
43
43
  timer?: ReturnType<typeof setInterval>;
44
44
  auto?: AutoTrack;
45
+ /** The session's task as this tick read it: one read serves the whole tick. */
46
+ tick?: { task: Task | undefined };
45
47
  }
46
48
 
47
49
  let owner: Owner | undefined;
@@ -53,7 +55,8 @@ export function onOwnerEvent(fn: ((event: OwnerEvent) => void) | undefined): voi
53
55
  }
54
56
 
55
57
  function ownTask(state: Owner): Task | undefined {
56
- return activeTask(state.root, state.configDir, state.ctx.sessionManager.getSessionId());
58
+ if (state.tick) return state.tick.task;
59
+ return peekOwnedTask(state.root, state.configDir, state.ctx.sessionManager.getSessionId());
57
60
  }
58
61
 
59
62
  function live(task: Task | undefined): task is Task {
@@ -68,8 +71,7 @@ export function deliverComments(): number {
68
71
  if (!live(task) || task.paused) return 0;
69
72
  const fresh = undeliveredComments(readPlanComments(state.root, state.configDir, task.id));
70
73
  if (fresh.length === 0) return 0;
71
- const current = loadTask(state.root, state.configDir, task.id) ?? task;
72
- state.pi.sendUserMessage(commentMessage(task.id, fresh, Boolean(current.plan)), state.ctx.isIdle() ? undefined : { deliverAs: "steer" });
74
+ state.pi.sendUserMessage(commentMessage(task.id, fresh, Boolean(task.plan)), state.ctx.isIdle() ? undefined : { deliverAs: "steer" });
73
75
  markCommentsDelivered(state.root, state.configDir, task.id, fresh.map((comment) => comment.id));
74
76
  listener?.({ kind: "comments", taskId: task.id, count: fresh.length });
75
77
  return fresh.length;
@@ -211,11 +213,18 @@ export function toggleOwnAuto(): string {
211
213
 
212
214
  /** One tick of the owner's clock. */
213
215
  export function ownerTick(): void {
214
- heartbeat();
215
- deliverComments();
216
- deliverInbox();
217
- deliverSessionInbox();
218
- driveAuto();
216
+ const state = owner;
217
+ if (!state) return;
218
+ state.tick = { task: ownTask(state) };
219
+ try {
220
+ heartbeat();
221
+ deliverComments();
222
+ deliverInbox();
223
+ deliverSessionInbox();
224
+ driveAuto();
225
+ } finally {
226
+ state.tick = undefined;
227
+ }
219
228
  }
220
229
 
221
230
  function stop(): void {
@@ -254,7 +263,7 @@ export function registerOwner(pi: ExtensionAPI, configDir: string): void {
254
263
  // Auto mode has nobody to answer a question: tell the oracle to decide instead.
255
264
  pi.on("tool_call", (event, ctx) => {
256
265
  if (event.toolName !== "ask_user_question" || !owner) return undefined;
257
- const task = activeTask(owner.root, owner.configDir, ctx.sessionManager.getSessionId());
266
+ const task = peekOwnedTask(owner.root, owner.configDir, ctx.sessionManager.getSessionId());
258
267
  if (!live(task) || !isAutoMode(owner.root, owner.configDir, task.id)) return undefined;
259
268
  return { block: true, reason: `Auto mode is on for ${task.id}: nobody will answer. Decide this yourself from the request, the plan and your reconnaissance, record the decision, and continue.` };
260
269
  });
@@ -15,6 +15,7 @@ import { applyMasterModel } from "./settings-ui.ts";
15
15
  import { setAutoMode } from "../state/auto.ts";
16
16
  import { loadPlannedTask, markPlannedTaskStarted, plannedTaskRequest } from "../state/backlog.ts";
17
17
  import { triageFor } from "../classifier/instance.ts";
18
+ import { freshContextOn, markContext } from "./fresh-context.ts";
18
19
 
19
20
  function uniqueTaskId(root: string, configDir: string, request: string): string {
20
21
  const base = nextTaskId(request);
@@ -76,6 +77,8 @@ export async function startTask(pi: ExtensionAPI, ctx: ExtensionContext, configD
76
77
  applyStatus(ctx, root, configDir);
77
78
  await applyMasterModel(pi, ctx, loadConfig());
78
79
  ctx.ui.notify(`bot-lobby ${task.id} started`, "info");
80
+ // The oracle takes the task on with a clean context: nothing said before the kickoff is sent to its model.
81
+ if (freshContextOn()) markContext(pi, { kind: "start", taskId: task.id, at: Date.now() });
79
82
  // A kickoff while pi is still busy (another turn) queues behind it instead of throwing.
80
83
  pi.sendUserMessage(kickoff(task), ctx.isIdle() ? undefined : { deliverAs: "followUp" });
81
84
  return task;
package/src/pi/ui.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
2
- import { Key, truncateToWidth, type Component, type TUI } from "@earendil-works/pi-tui";
2
+ import { Key, type Component, type TUI } from "@earendil-works/pi-tui";
3
+ import { clip } from "../width.ts";
3
4
  import type { AgentRun } from "../schemas/findings.ts";
4
5
  import { TERMINAL_STATES, type Task } from "../schemas/task.ts";
5
6
  import { activeTask } from "../state/persistence.ts";
@@ -255,7 +256,7 @@ export class ZenScene {
255
256
  const key = `${width}|${rows}|${this.tick}|${frameKey}|${motion.phase}|${motion.talk}|${zenVersion}|${Math.floor(now / 1000)}|${quiet}|${still}`;
256
257
  if (this.cache && this.cache.key === key && this.cache.theme === theme) return this.cache.lines;
257
258
  const opts = { width, rows, tick: this.tick, theme, expressions, variants, oracleActivity, oracleMotion: motion, still };
258
- const lines = panelLines(zenState.task, zenState.runs, now, quiet, opts).map((line) => truncateToWidth(line, width));
259
+ const lines = panelLines(zenState.task, zenState.runs, now, quiet, opts).map((line) => clip(line, width));
259
260
  this.cache = { key, theme, lines };
260
261
  return lines;
261
262
  }
@@ -1,4 +1,4 @@
1
- import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
1
+ import { clip, textWidth } from "../width.ts";
2
2
  import {
3
3
  BAR,
4
4
  BUBBLE,
@@ -243,11 +243,11 @@ function labelPaint(status: SlotState, theme?: PanelTheme): ((text: string) => s
243
243
  }
244
244
 
245
245
  function padTo(text: string, width: number): string {
246
- return truncateToWidth(text, width, "", true);
246
+ return clip(text, width, "", true);
247
247
  }
248
248
 
249
249
  function centre(text: string, width: number): string {
250
- return padTo(" ".repeat(Math.max(0, Math.floor((width - visibleWidth(text)) / 2))) + text, width);
250
+ return padTo(" ".repeat(Math.max(0, Math.floor((width - textWidth(text)) / 2))) + text, width);
251
251
  }
252
252
 
253
253
  function mod(index: number, length: number): number {
@@ -273,7 +273,7 @@ function sceneCentre(width: number): number {
273
273
 
274
274
  /** Place a row of at most `SCENE_WIDTH` columns on the scene's centre column. */
275
275
  function place(row: string, width: number): string {
276
- return " ".repeat(Math.max(0, sceneLeft(width) + Math.floor((SCENE_WIDTH - visibleWidth(row)) / 2))) + row;
276
+ return " ".repeat(Math.max(0, sceneLeft(width) + Math.floor((SCENE_WIDTH - textWidth(row)) / 2))) + row;
277
277
  }
278
278
 
279
279
  function alertLine(
@@ -283,7 +283,7 @@ function alertLine(
283
283
  kind: "warning" | "error" = "warning",
284
284
  ): string {
285
285
  const left = sceneLeft(width);
286
- const line = paint(truncateToWidth(`! ${alert}`, width - left, "…"), kind, theme, true);
286
+ const line = paint(clip(`! ${alert}`, width - left, "…"), kind, theme, true);
287
287
  return " ".repeat(left) + line;
288
288
  }
289
289
 
@@ -294,8 +294,8 @@ function boxLines(input: LargeSceneInput, width: number, theme?: PanelTheme): st
294
294
 
295
295
  function topBorder(input: LargeSceneInput, theme?: PanelTheme): string {
296
296
  const lead = "┌─ BOT-LOBBY ── ";
297
- const label = truncateToWidth(`${input.taskTitle} · ${input.state} `, SCENE_WIDTH - 18, "…");
298
- const tail = "─".repeat(Math.max(0, SCENE_WIDTH - 1 - visibleWidth(lead) - visibleWidth(label)));
297
+ const label = clip(`${input.taskTitle} · ${input.state} `, SCENE_WIDTH - 18, "…");
298
+ const tail = "─".repeat(Math.max(0, SCENE_WIDTH - 1 - textWidth(lead) - textWidth(label)));
299
299
  return paint(lead, "muted", theme) + paint(label, "accent", theme, true) + paint(`${tail}┐`, "muted", theme);
300
300
  }
301
301
 
@@ -349,7 +349,7 @@ function bodyLines(
349
349
  /** One dim line under the agent strip; warnings take the warning colour. */
350
350
  function feedRow(feed: FeedLine, width: number, theme?: PanelTheme): string {
351
351
  const left = sceneLeft(width) + 1;
352
- const text = truncateToWidth(feed.text, Math.max(0, Math.min(SCENE_WIDTH - 2, width - left)), "…");
352
+ const text = clip(feed.text, Math.max(0, Math.min(SCENE_WIDTH - 2, width - left)), "…");
353
353
  return " ".repeat(left) + (feed.kind === "warning" ? paint(text, "warning", theme) : paint(text, "dim", theme));
354
354
  }
355
355
 
@@ -494,7 +494,7 @@ export function oracleAside(input: LargeSceneInput): string {
494
494
  function bubbleRows(input: LargeSceneInput, theme?: PanelTheme): string[] {
495
495
  const accent = ORACLE_COLORS[input.oracle.pose];
496
496
  const border = (text: string) => paint(text, "muted", theme);
497
- const text = (value: string) => padTo(truncateToWidth(value, BUBBLE_TEXT, "…"), BUBBLE_TEXT);
497
+ const text = (value: string) => padTo(clip(value, BUBBLE_TEXT, "…"), BUBBLE_TEXT);
498
498
  const rule = "─".repeat(BUBBLE.width - 2);
499
499
  return [
500
500
  border(`╭${rule}╮`),
@@ -661,7 +661,7 @@ function assemble(end: number, cells: readonly Cell[]): string {
661
661
  let cursor = 0;
662
662
  for (const cell of cells) {
663
663
  row += " ".repeat(Math.max(0, cell.offset - cursor)) + (cell.paint ? cell.paint(cell.text) : cell.text);
664
- cursor = cell.offset + visibleWidth(cell.text);
664
+ cursor = cell.offset + textWidth(cell.text);
665
665
  }
666
666
  return row + " ".repeat(Math.max(0, end - cursor));
667
667
  }