Skip to content

Commit 27571db

Browse files
grypezclaude
andcommitted
feat(session): surface provisioned requests in TUI timeline
Add a 'provisioned' status for auto-accepted requests and expose the standing provision that was set by the user when accepting with keybind 2. - `SessionHistoryEntry` gains status 'provisioned' and optional `provision` field - `Channel.record()` inserts a pre-decided entry without blocking subscribers - `Session.recordProvisioned()` records an auto-accepted request synchronously - `session.record` RPC dispatches to `recordProvisioned` in the daemon - hook fires-and-forgets `recordProvisioned` when the vat returns 'allow' - TUI: ◆ for user-accepted-with-provision, → for auto-provisioned - TUI: expanded view shows provision patterns; provisioned entries show '→ standing provision' instead of decided time Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent b0e78db commit 27571db

9 files changed

Lines changed: 184 additions & 6 deletions

File tree

packages/caprock/bin/hook.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import {
3838
decodeCapData,
3939
createKernelSession,
4040
authorizeRequest,
41+
recordProvisioned,
4142
} from '../src/rpc.ts';
4243
import {
4344
loadSessionState,
@@ -517,6 +518,15 @@ async function onPreToolUse(payload: PreToolUsePayload): Promise<void> {
517518
});
518519

519520
if (vatResponse === 'allow') {
521+
if (state.kernelSessionId) {
522+
const autoDescription = `Allow ${tool_name}(${JSON.stringify(tool_input)})`;
523+
recordProvisioned(
524+
SOCKET_PATH,
525+
state.kernelSessionId,
526+
autoDescription,
527+
invocations === null ? undefined : { invocations },
528+
).catch(() => undefined);
529+
}
520530
process.stdout.write(JSON.stringify({ continue: true }));
521531
return;
522532
}

packages/caprock/src/rpc.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,28 @@ export async function authorizeRequest(
265265
return response.result as Decision;
266266
}
267267

268+
/**
269+
* Record a request that was auto-accepted by a standing provision.
270+
*
271+
* @param socketPath - The UNIX socket path.
272+
* @param sessionId - The kernel session ID.
273+
* @param description - Human-readable description of the auto-accepted operation.
274+
* @param options - Optional parameters.
275+
* @param options.invocations - Parsed invocations to forward to the TUI.
276+
*/
277+
export async function recordProvisioned(
278+
socketPath: string,
279+
sessionId: string,
280+
description: string,
281+
options?: { invocations?: ParsedInvocation[] },
282+
): Promise<void> {
283+
const params: Record<string, unknown> = { sessionId, description };
284+
if (options?.invocations !== undefined) {
285+
params.invocations = options.invocations;
286+
}
287+
await sendCommand({ socketPath, method: 'session.record', params });
288+
}
289+
268290
/**
269291
* Decode a CapData body to a JavaScript value.
270292
*

packages/kernel-node-runtime/src/daemon/rpc-socket-server.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ function makeTestSession(overrides: Partial<Session> = {}): Session {
7373
verdict: 'accept' as const,
7474
feedback: '',
7575
}),
76+
recordProvisioned: vi.fn(),
7677
subscribe: vi.fn(),
7778
...overrides,
7879
};
@@ -343,6 +344,38 @@ describe('startRpcSocketServer — session.* methods', () => {
343344
});
344345
});
345346

347+
it('session.record calls recordProvisioned with description and invocations', async () => {
348+
const { startRpcSocketServer } = await import('./rpc-socket-server.ts');
349+
const socketPath = makeSocketPath();
350+
const existing = makeTestSession({
351+
sessionId: 'alice',
352+
ocapUrl: 'ocap://alice',
353+
startedAt: '2026-01-01T00:00:00.000Z',
354+
});
355+
const registry = makeTestRegistry([existing]);
356+
357+
handle = await startRpcSocketServer({
358+
socketPath,
359+
kernel: {} as never,
360+
kernelDatabase: { executeQuery: vi.fn() } as never,
361+
channelFactory: {} as never,
362+
sessionRegistry: registry,
363+
});
364+
365+
const invocations = [{ name: 'git', argv: ['status'] }];
366+
const response = await sendRequest(socketPath, 'session.record', {
367+
sessionId: 'alice',
368+
description: 'Allow Bash({"command":"git status"})',
369+
invocations,
370+
});
371+
372+
expect(response.result).toBeNull();
373+
expect(existing.recordProvisioned).toHaveBeenCalledWith(
374+
'Allow Bash({"command":"git status"})',
375+
{ invocations },
376+
);
377+
});
378+
346379
it('session.authorize returns the decision from authorizeRequest()', async () => {
347380
const { startRpcSocketServer } = await import('./rpc-socket-server.ts');
348381
const socketPath = makeSocketPath();

packages/kernel-node-runtime/src/daemon/rpc-socket-server.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,22 @@ async function handleSessionRequest(
316316
return ok(decision);
317317
}
318318

319+
case 'session.record': {
320+
const session = requireSession(args.sessionId);
321+
const description =
322+
typeof args.description === 'string'
323+
? args.description
324+
: 'Auto-accepted request';
325+
const invocations = Array.isArray(args.invocations)
326+
? (args.invocations as ParsedInvocation[])
327+
: undefined;
328+
session.recordProvisioned(
329+
description,
330+
invocations === undefined ? undefined : { invocations },
331+
);
332+
return ok(null);
333+
}
334+
319335
case 'session.decide': {
320336
const session = requireSession(args.sessionId);
321337
const { token } = args;

packages/kernel-tui/src/components/session-detail-view.tsx

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ const STATUS_ICON: Record<SessionHistoryEntry['status'], string> = {
2929
pending: '…',
3030
accepted: '✓',
3131
rejected: '✗',
32+
provisioned: '→',
3233
};
3334

3435
const STATUS_COLOR: Record<
@@ -38,6 +39,7 @@ const STATUS_COLOR: Record<
3839
pending: 'yellow',
3940
accepted: 'green',
4041
rejected: 'red',
42+
provisioned: 'green',
4143
};
4244

4345
/**
@@ -339,9 +341,12 @@ function entryRowCount(
339341
const contentRows = contentLines.reduce((sum, line) => {
340342
return sum + Math.max(1, Math.ceil(line.length / effectiveWidth));
341343
}, 0);
344+
const provisionRows =
345+
entry.provision === undefined ? 0 : 1 + entry.provision.patterns.length;
342346
const extras =
343347
(entry.decidedAt === undefined ? 0 : 1) +
344-
(entry.guard.body === '#{}' ? 0 : 1);
348+
(entry.guard.body === '#{}' ? 0 : 1) +
349+
provisionRows;
345350
return 1 + contentRows + extras;
346351
}
347352

@@ -819,8 +824,12 @@ export function SessionDetailView({
819824
const isExpanded = expanded.has(entry.token);
820825
const isEditingThis =
821826
editingProvision && isFocused && entry.status === 'pending';
822-
const icon = STATUS_ICON[entry.status];
827+
const icon =
828+
entry.status === 'accepted' && entry.provision !== undefined
829+
? '◆'
830+
: STATUS_ICON[entry.status];
823831
const color = STATUS_COLOR[entry.status];
832+
const isDimStatus = entry.status === 'provisioned';
824833

825834
const { label } = parseDescription(entry.description);
826835

@@ -838,7 +847,9 @@ export function SessionDetailView({
838847
<Box key={entry.token} flexDirection="column" marginTop={0}>
839848
<Box gap={1}>
840849
<Text>{isFocused ? '►' : ' '}</Text>
841-
<Text color={color}>{icon}</Text>
850+
<Text color={color} dimColor={isDimStatus}>
851+
{icon}
852+
</Text>
842853
<Text color="cyan" dimColor>
843854
{formatTime(entry.queuedAt)}
844855
</Text>
@@ -866,7 +877,32 @@ export function SessionDetailView({
866877
))}
867878
{isExpanded &&
868879
!isEditingThis &&
869-
entry.decidedAt !== undefined && (
880+
entry.provision !== undefined && (
881+
<Box paddingLeft={4} flexDirection="column">
882+
<Text dimColor>provision:</Text>
883+
{entry.provision.patterns.map((pattern, patIdx) => (
884+
<Box key={patIdx} paddingLeft={2} gap={1} flexWrap="wrap">
885+
<Text color="yellow">{pattern.name}</Text>
886+
{pattern.argPatterns.map((argPat, argIdx) => (
887+
<Text key={argIdx} dimColor>
888+
{argPatternDisplay(argPat)}
889+
</Text>
890+
))}
891+
</Box>
892+
))}
893+
</Box>
894+
)}
895+
{isExpanded &&
896+
!isEditingThis &&
897+
entry.status === 'provisioned' && (
898+
<Box paddingLeft={4}>
899+
<Text dimColor>→ standing provision</Text>
900+
</Box>
901+
)}
902+
{isExpanded &&
903+
!isEditingThis &&
904+
entry.decidedAt !== undefined &&
905+
entry.status !== 'provisioned' && (
870906
<Box paddingLeft={4}>
871907
<Text dimColor>decided {formatTime(entry.decidedAt)}</Text>
872908
</Box>

packages/kernel-utils/src/session/channel.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { PromiseKit } from '@endo/promise-kit';
44
import { ifDefined } from '../misc.ts';
55
import type {
66
Decision,
7+
Provision,
78
SectionNotification,
89
SessionHistoryEntry,
910
} from './types.ts';
@@ -63,6 +64,14 @@ export type Channel = {
6364
* @param decision - The decision to apply.
6465
*/
6566
decide(decision: Decision): void;
67+
68+
/**
69+
* Record a notification as already decided by a standing provision, without
70+
* routing it through `pending` or notifying subscribers.
71+
*
72+
* @param notification - The section notification to record.
73+
*/
74+
record(notification: SectionNotification): void;
6675
};
6776

6877
type PendingEntry = {
@@ -74,8 +83,9 @@ type PendingEntry = {
7483
type HistoryEntry = {
7584
notification: SectionNotification;
7685
queuedAt: string;
77-
verdict: 'accepted' | 'rejected';
86+
verdict: 'accepted' | 'rejected' | 'provisioned';
7887
decidedAt: string;
88+
provision?: Provision;
7989
};
8090

8191
/**
@@ -110,6 +120,7 @@ export function makeChannel(): Channel {
110120
queuedAt: entry.queuedAt,
111121
verdict: decision.verdict === 'accept' ? 'accepted' : 'rejected',
112122
decidedAt: new Date().toISOString(),
123+
...ifDefined({ provision: decision.provision }),
113124
});
114125
entry.kit.resolve(decision);
115126
}
@@ -183,6 +194,7 @@ export function makeChannel(): Channel {
183194
status: hist.verdict,
184195
decidedAt: hist.decidedAt,
185196
...ifDefined({ invocations: hist.notification.invocations }),
197+
...ifDefined({ provision: hist.provision }),
186198
}));
187199
const stillPending: SessionHistoryEntry[] = Array.from(
188200
pending.values(),
@@ -209,5 +221,15 @@ export function makeChannel(): Channel {
209221
decide(decision: Decision): void {
210222
routeDecision(decision);
211223
},
224+
225+
record(notification: SectionNotification): void {
226+
const stamp = new Date().toISOString();
227+
history.push({
228+
notification,
229+
queuedAt: stamp,
230+
verdict: 'provisioned',
231+
decidedAt: stamp,
232+
});
233+
},
212234
});
213235
}

packages/kernel-utils/src/session/session-registry.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,27 @@ describe('makeSessionRegistry', () => {
155155
expect(result).toStrictEqual(decision);
156156
});
157157

158+
it('recordProvisioned adds a provisioned entry to history', async () => {
159+
const registry = makeSessionRegistry(makeChannelBundle());
160+
const session = await registry.createSession();
161+
162+
session.recordProvisioned('Allow Bash({"command":"git status"})', {
163+
invocations: [{ name: 'git', argv: ['status'] }],
164+
});
165+
166+
const history = session.listHistory();
167+
expect(history).toHaveLength(1);
168+
expect(history[0]).toMatchObject({
169+
description: 'Allow Bash({"command":"git status"})',
170+
reason: 'Auto-accepted by provision',
171+
status: 'provisioned',
172+
invocations: [{ name: 'git', argv: ['status'] }],
173+
});
174+
expect(typeof history[0]?.token).toBe('string');
175+
expect(typeof history[0]?.queuedAt).toBe('string');
176+
expect(history[0]?.queuedAt).toBe(history[0]?.decidedAt);
177+
});
178+
158179
it('authorizeRequest rejects with timeout error after timeoutMs elapses', async () => {
159180
vi.useFakeTimers();
160181
try {

packages/kernel-utils/src/session/session-registry.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@ export type Session = {
3535
invocations?: ParsedInvocation[];
3636
},
3737
): Promise<Decision>;
38+
recordProvisioned(
39+
description: string,
40+
options?: { invocations?: ParsedInvocation[] },
41+
): void;
3842
subscribe(stream: ModalStream): void;
3943
};
4044

@@ -141,6 +145,18 @@ function makeSession(
141145
]);
142146
},
143147

148+
recordProvisioned(
149+
description: string,
150+
options: { invocations?: ParsedInvocation[] } = {},
151+
): void {
152+
const notification = makeNotification(
153+
description,
154+
'Auto-accepted by provision',
155+
options.invocations,
156+
);
157+
channel.record(notification);
158+
},
159+
144160
subscribe(stream: ModalStream): void {
145161
channel.subscribe(stream);
146162
},

packages/kernel-utils/src/session/types.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,10 +109,12 @@ export type SessionHistoryEntry = {
109109
reason: string;
110110
guard: { body: string; slots: string[] };
111111
queuedAt: string;
112-
status: 'pending' | 'accepted' | 'rejected';
112+
status: 'pending' | 'accepted' | 'rejected' | 'provisioned';
113113
decidedAt?: string;
114114
/** Parsed invocations — present when routed through the PreToolUse hook. */
115115
invocations?: ParsedInvocation[];
116+
/** Standing provision that was granted — present when the user accepted with a provision. */
117+
provision?: Provision;
116118
};
117119

118120
/**

0 commit comments

Comments
 (0)