Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 16 additions & 7 deletions docs/context.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,12 @@ how much room the model gives it, and the header draws it as a ring with the
percentage beside it. When the agent compacts its conversation mid-turn, the
timeline gets a divider saying how many tokens went. Both come from the agent
itself: the core never estimates a context size, and a provider whose protocol
says nothing shows an unknown reading. The button inside the ring requests
manual compaction of the native session.
says nothing shows an unfilled ring. Hovering, focusing or tapping the ring
opens exact counts and a separate manual compaction button.

## What is measured

`ThreadSummary.context` is `{ tokens, window, at }` or null:
`ThreadSummary.context` is `{ tokens, window, at, breakdown? }` or null:

- `tokens` is what the last API request of the last turn carried: its input
tokens, plus what it read from the prompt cache and what it wrote there.
Expand All @@ -21,6 +21,9 @@ manual compaction of the native session.
With a window the header shows a percentage and the ring; without one it
shows the count alone.
- `at` is when the core wrote it.
- `breakdown`, when available, contains disjoint input, cached-input and output
counts. The UI displays a single segmented bar and exact counts. Providers
without this detail show used and available capacity only.

The core writes the meter at the end of every turn that reports one, through
`thread.updated`, and keeps it in the journal (`threads.context`, schema 7),
Expand All @@ -36,7 +39,11 @@ finite or below zero writes nothing.
| Codex | `tokenUsage.last.totalTokens` in `thread/tokenUsage/updated` | `tokenUsage.modelContextWindow` when reported | completed `contextCompaction` items |
| OpenCode, Antigravity, Grok, pi | none yet | | pi's manual compaction response |

Codex's count includes the last request's output. Counts remain the last
Codex's count includes the last request's output. When `totalTokens` is absent,
the driver adds the reported input and output counts. Context notifications
arriving after a turn completes are retained while its session stays warm.
Cached input is a subset of input, so the segmented bar subtracts it from the
uncached input segment. Counts remain the last
reported reading, not a prediction of the next request. ACP context updates
are not read yet; an unknown reading never becomes a guessed percentage.

Expand Down Expand Up @@ -73,6 +80,8 @@ the transcript reader keeps prompts, answers and tool calls only.

## The colours

The ring is the muted foreground under three quarters of the window, the
foreground from there, and the danger colour past nine tenths, when the next
compaction is close. The tooltip has the exact counts.
The filled portion always uses the chosen accent, from zero to full capacity.
The smaller ring opens details; it never starts compaction. The popup uses
accent for uncached input, green for cached input, yellow for output and a
neutral remainder for free capacity. These are provider token categories,
not an estimated split between system instructions, files and tools.
20 changes: 20 additions & 0 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -303,3 +303,23 @@ original checkout: outside the worktree, outside the branch, outside the commit,
and invisible to the review that follows. The tracked docs, this page included,
are the ones a worktree can actually change. Check where a file you are about to
edit really lives before editing it.


## Chat readability

`bun test tests/e2e/readability.test.ts` checks the sidebar metadata, process
panel, paragraph buffering, reasoning replacement, goal display and command
highlighting through the fake client. It writes desktop, phone and light-theme
captures under `tests/e2e/.artifacts/`.

Scheduled goal and loop prompts keep their execution instructions in `text` and
carry a separate optional `displayText` on the text part. The journal retains
both; drivers read the execution prompt. The UI shows the command and objective,
including when recalling a sent prompt, and hides standalone goal control markers.

Chat status uses two small receipts: core acceptance and the first nonempty
assistant activity. Agent protocols do not provide a literal read receipt.
The reply has one spinner while running, paused when the document is hidden
and disabled for reduced motion. Finished turns show a check and elapsed time;
usage totals remain in Usage settings. Context details open separately from
compaction. `tests/e2e/chat-context.test.ts` covers these interactions.
13 changes: 10 additions & 3 deletions docs/machines.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,15 +56,22 @@ All machines can be narrowed to one machine from the filter button beside Settin
matches thread titles, project names and machine names. The command palette and
the draft's project picker also include every connected host.

Cards have a title row and a second row for the PR, project and machine. PR
Cards have a title row and a second row for the project and machine icon. The
machine name remains in the tooltip and accessible label. An associated PR
appears as a green underlined number immediately before the machine icon; no
placeholder appears when there is no PR. PR
metadata comes from the execution machine's `gh pr list`, using the worktree
branch or the current branch of the working directory. Non-repositories and
detached checkouts have no PR. The core caches results and errors for one minute,
coalesces duplicate requests, and runs at most two lookups at once. Each command
has a ten-second deadline and runs through the process registry under
`pull-request:<threadId>`. The thread menu can request a refresh. Missing `gh`,
authentication failures and malformed responses appear as unavailable metadata,
not as a claim that no PR exists. A PR link opens in the system browser.
authentication failures and malformed responses use the error notification. A PR link opens in the system browser.

Older cores that do not implement PR lookup are probed once per connection.
Their cards omit the PR link. A manual refresh explains that the hosting
machine needs an update; other RPC errors still appear in a notification.
Reconnecting clears the capability check so an updated core is detected.

## Isolation and tests

Expand Down
8 changes: 8 additions & 0 deletions docs/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -295,3 +295,11 @@ question card in the timeline and answers the agent with what the user picked;
Codex's `item/tool/requestUserInput` is the one that does today. A question is
not a permission mode and is never gated by one: an agent whose approvals are
off can still ask.

### Codex task tracking

Boite enables `tools.update_plan.enabled` through the per-thread configuration
on both `thread/start` and `thread/resume`. Codex disables this tool by default;
listening for `turn/plan/updated` alone does not make it available to the agent.
The native plan populates the thread's activity tasks. Goal instructions explain
this mapping so the agent uses its planning tools instead of legacy Boite todos.
12 changes: 7 additions & 5 deletions docs/trace.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,13 @@ totals.
many processes, what percentage of CPU, how many bytes of memory. It is what
the gauge in the trace panel reads.

The trace panel shows six columns at a fixed width, executable, duration, CPU,
peak memory, I/O and exit, the four measurements right-aligned on tabular
figures. It shows the base name of the executable, with the full path, the pid
and the command line in the cell's tooltip. The Protection page under Settings
keeps a wider table with a pid column of its own.
The trace panel lists active processes first, then the most recently started.
Each expandable row shows the executable name, running or exit status, duration
and peak memory. Expanding it reveals the command, full path, PID, parent PID,
CPU time and I/O. Unmeasured values remain unknown. The top summary separates
recorded processes from active ones and labels current CPU and memory usage.
The tracking capability disclosure explains the limits of polling hosts. The
Protection page under Settings keeps its wider resource table.

## Caps

Expand Down
4 changes: 3 additions & 1 deletion packages/contracts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,8 @@ export type TitleSource = 'prompt' | 'agent' | 'user';
* Null on a thread whose agent never reported it.
*/
export interface ContextUse {
/** Disjoint counts from the last request, when the provider reports them. */
breakdown?: { input: number; cache: number; output: number };
tokens: number;
window: number | null;
at: Timestamp;
Expand Down Expand Up @@ -436,7 +438,7 @@ export interface ImageAttachment {
}

export type MessagePart =
| { type: 'text'; text: string }
| { type: 'text'; text: string; /** User-facing command for internally scheduled prompts. */ displayText?: string }
/** An image the user sent with the prompt, journalled with the message. */
| { type: 'image'; mimeType: ImageMimeType; data: string; alt: string | null }
/** The model's reasoning as the provider streams it, folded in the UI. */
Expand Down
5 changes: 4 additions & 1 deletion packages/core/src/activity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,8 +152,11 @@ export class ActivityStore {
const prompt = kind === 'goal'
? `Work toward this goal: ${state.goal!.objective}\nContinue until the objective is achieved. When you have verified completion, write [BOITE_GOAL_COMPLETE] alone on its own line. If blocked or waiting for user input, explain what is missing and write [BOITE_GOAL_BLOCKED] alone on its own line.`
: state.loop!.prompt;
const taskGuidance = kind === 'goal'
? '\nTrack the work with your native planning tool (Codex: update_plan; Claude: TodoWrite or TaskCreate/TaskUpdate). Boite displays those task updates in this thread. Create the plan before working and update its statuses as you verify results. The Boite goal already exists; do not create a second goal or use legacy Boite todo tools.'
: '';
try {
const turn = this.core.threads.startTurn(threadId, prompt);
const turn = this.core.threads.startTurn(threadId, prompt + taskGuidance, [], undefined, undefined, kind === 'goal' ? `/goal ${state.goal!.objective}` : `/loop ${state.loop!.intervalMs / 1000}s ${state.loop!.prompt}`);
this.ownTurns.set(turn.id, { kind, generation: this.generations.get(threadId) ?? 0 });
// Drivers may synchronously report tasks while startTurn runs.
const current = this.states.get(threadId)!;
Expand Down
32 changes: 21 additions & 11 deletions packages/core/src/drivers/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -515,8 +515,9 @@ class CodexSession {
private exitCode: number | null = null;
private exited: Promise<number | null> | null = null;
private idle: Timer | null = null;
/** The turn whose `turn/start` is in flight; notifications outside one are dropped. */
/** The turn whose `turn/start` is in flight. Context updates also arrive while idle. */
private current: CodexTurn | null = null;
private contextSink: CodexTurn['ctx']['context'] | null = null;
private queue: Promise<void> = Promise.resolve();
private running = 0;
private closing = false;
Expand Down Expand Up @@ -597,6 +598,7 @@ class CodexSession {

turn.noteSession(threadId);
this.current = turn;
this.contextSink = turn.ctx.context;
const ctx = turn.ctx;
try {
// The thread as it stands for this turn, not as it stood when the
Expand Down Expand Up @@ -690,13 +692,15 @@ class CodexSession {
approvalPolicy: policy.approvalPolicy,
sandbox: policy.sandbox,
...(model === null ? {} : { model }),
config: { 'tools.update_plan.enabled': true },
excludeTurns: true,
});
this.threadId = resumed.thread.id;
return;
}

const created = await rpc.request<{ thread: { id: string } }>('thread/start', {
config: { 'tools.update_plan.enabled': true },
cwd: ctx.thread.cwd,
approvalPolicy: policy.approvalPolicy,
sandbox: policy.sandbox,
Expand Down Expand Up @@ -800,9 +804,24 @@ class CodexSession {
// -- what the agent sends -------------------------------------------------

private onNotification(method: string, raw: unknown): void {
const params = (raw ?? {}) as Record<string, unknown>;
if (method === 'thread/tokenUsage/updated') {
if (params['threadId'] !== this.threadId) return;
const usage = params['tokenUsage'] as { last?: CodexTokenUsage; modelContextWindow?: number } | undefined;
const last = usage?.last;
if (!last) return;
if (this.current && params['turnId'] === this.current.turnId) this.current.usage = mapUsage(last);
const tokens = last.totalTokens ?? (typeof last.inputTokens === 'number' && typeof last.outputTokens === 'number' ? last.inputTokens + last.outputTokens : null);
if (tokens !== null) {
const cache = last.cachedInputTokens ?? 0;
const breakdown = typeof last.inputTokens === 'number' && typeof last.outputTokens === 'number'
? { input: last.inputTokens - cache, cache, output: last.outputTokens } : undefined;
this.contextSink?.({tokens, window: usage?.modelContextWindow ?? null, ...(breakdown ? {breakdown} : {})});
}
return;
}
const turn = this.current;
if (turn === null) return;
const params = (raw ?? {}) as Record<string, unknown>;
switch (method) {
case 'turn/started': {
const record = params['turn'] as CodexTurnRecord | undefined;
Expand Down Expand Up @@ -836,15 +855,6 @@ class CodexSession {
if (view !== null) turn.upsertTool(item.id, view);
break;
}
case 'thread/tokenUsage/updated': {
const usage = params['tokenUsage'] as { last?: CodexTokenUsage; modelContextWindow?: number } | undefined;
const last = usage?.last;
if (last !== undefined) turn.usage = mapUsage(last);
if (last !== undefined && typeof last.totalTokens === 'number') {
turn.ctx.context({ tokens: last.totalTokens, window: usage?.modelContextWindow ?? null });
}
break;
}
case 'turn/completed': {
const record = params['turn'] as CodexTurnRecord | undefined;
if (record !== undefined) turn.finish(record);
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/drivers/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ export interface TurnContext {
* window when the agent names it. The core writes it on the thread and
* tells the clients; a driver calls it once per turn, at the end.
*/
context(use: { tokens: number; window: number | null }): void;
context(use: Omit<import('@boite/contracts').ContextUse, 'at'>): void;
requestPermission(toolName: string, input: unknown, description: string | null): PermissionTicket;
/** The inline question card. One call per question, and they are asked in order. */
askQuestion(ask: QuestionAsk): QuestionTicket;
Expand Down
10 changes: 6 additions & 4 deletions packages/core/src/threads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -570,7 +570,7 @@ export class ThreadStore {
return this.startTurn(threadId, protocol === 'echo' ? '[compact]' : '/compact', [], expectedSelectionVersion, 'compact');
}

startTurn(threadId: ThreadId, prompt: string, attachments: ImageAttachment[] = [], expectedSelectionVersion?: number, operation?: 'compact'): Turn {
startTurn(threadId: ThreadId, prompt: string, attachments: ImageAttachment[] = [], expectedSelectionVersion?: number, operation?: 'compact', displayPrompt?: string): Turn {
const thread = this.require(threadId);
this.checkSelection(thread, expectedSelectionVersion);
if (thread.archived) throw refused('cannot start a turn on an archived thread', { threadId });
Expand Down Expand Up @@ -610,7 +610,7 @@ export class ThreadStore {
turnId: turn.id,
role: 'user',
parts: [
{ type: 'text', text: prompt },
{ type: 'text', text: prompt, ...(displayPrompt ? { displayText: displayPrompt } : {}) },
...attachments.map((attachment): MessagePart => ({
type: 'image',
mimeType: attachment.mimeType,
Expand Down Expand Up @@ -1163,13 +1163,15 @@ export class ThreadStore {
}

/** The context meter, whole numbers only: a driver that misreads its agent writes nothing. */
private noteContext(threadId: ThreadId, use: { tokens: number; window: number | null }): void {
private noteContext(threadId: ThreadId, use: Omit<import('@boite/contracts').ContextUse, 'at'>): void {
const tokens = Number.isFinite(use.tokens) && use.tokens >= 0 ? Math.round(use.tokens) : null;
if (tokens === null) return;
const window = use.window !== null && Number.isFinite(use.window) && use.window > 0 ? Math.round(use.window) : null;
const thread = this.core.journal.getThread(threadId);
if (thread === null) return;
this.save({ ...thread, context: { tokens, window, at: Date.now() } }, 'thread.context');
const breakdown = use.breakdown && Object.values(use.breakdown).every(n => Number.isFinite(n) && n >= 0)
&& Math.abs(use.breakdown.input + use.breakdown.cache + use.breakdown.output - tokens) <= 1 ? use.breakdown : undefined;
this.save({ ...thread, context: { tokens, window, ...(breakdown ? {breakdown} : {}), at: Date.now() } }, 'thread.context');
}

private setStatus(threadId: ThreadId, status: ThreadStatus): void {
Expand Down
18 changes: 18 additions & 0 deletions packages/core/test/activity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,3 +142,21 @@ test('replacing an in-flight goal cannot complete the replacement with the old a
expect(h.core.activity.get(threadId).goal?.objective).toBe('second');
await client.call('turns.stop', { threadId });
});


test('goal messages expose a display command while the driver receives its instructions', async () => {
const client = await h.connect();
const { threadId } = await echoThread(h, client);
let received = '';
restore = setDriver('echo', { protocol: 'echo', startTurn(ctx) {
received = ctx.prompt;
return { stop() {}, done: Promise.resolve({ status: 'error', error: 'test stop', sessionId: null, usage: null }) };
} });
await client.call('threads.activity.set', { threadId, goal: { objective: 'Check two tasks' } });
await waitFor(() => received.length > 0);
const thread = await client.call('threads.get', { threadId });
expect(received).toContain('[BOITE_GOAL_COMPLETE]');
expect(received).toContain('Codex: update_plan');
expect(received).toContain('Boite displays those task updates');
expect(thread.messages.find(m => m.role === 'user')?.parts[0]).toMatchObject({ text: received, displayText: '/goal Check two tasks' });
});
Loading