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
33 changes: 33 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,39 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added

- **Provider extension surface** — the wire-additive groundwork for
non-Claude backends (pi harness next) to expose their full feature set
through codeoid:
- **Provider-initiated dialogs** — new `session.ui_request` /
`session.ui_response` / `session.ui_resolved` messages let a provider (or
its extensions) ask the user something that is not a tool approval
(confirm gates, pick-one lists, free text, editors). Daemon-enforced
timeouts, attach re-delivery, first-answer-wins across clients, interrupt
cancellation, and stall-watchdog integration. Gated on the new
`ui.dialogs` capability; the web UI renders them in a new `UiRequestBar`.
- **Dynamic provider commands** — `session.commands` returns the backing
provider's slash-command catalog (extension commands, prompt templates,
skills). Clients pass unknown-but-catalogued verbs through as prompt
text (`parseSlash` `isProviderCommand` option); the provider expands
them. Gated on the `commands.dynamic` capability.
- **Rich parts, actually rendered** — providers can emit standalone
`custom_message` events with `ContentPart[]`; the web UI now renders
parts (code, diff, table, tree, progress, image, anchor, button) via a
new `PartsView`, and `ButtonPart` gets its missing return path: the new
`session.part_action` verb validates the button against the real message
and forwards it to the provider's `handlePartAction`.
- **Provider-declared approval forms** — `tool_start.patchableKeys` lets
any backend declare which input keys a client may patch on approval,
generalizing the hardcoded AskUserQuestion whitelist (which remains the
fallback).
- **ProviderRegistry wired in** — session backends now come from a
factory registry built once at daemon startup (previously dead code next
to a hardcoded `switch`); adding a backend is one `register()` call.
Unknown provider ids still fall back to the default so resume survives
metas written by newer codeoids.

## [0.2.0] - 2026-07-06

### Added
Expand Down
23 changes: 23 additions & 0 deletions packages/core/src/slash.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,3 +200,26 @@ describe("dispatchSlash", () => {
expect(c.sent).toEqual([]);
});
});

describe("provider-command passthrough", () => {
it("unknown verbs still throw without a passthrough predicate", () => {
expect(() => parseSlash("/review the diff")).toThrow(/unknown slash command/);
});

it("returns null (plain prompt text) when the predicate matches", () => {
const isProviderCommand = (name: string) => name === "review";
expect(parseSlash("/review the diff", { isProviderCommand })).toBeNull();
// Case-insensitive: the verb is lowercased before the predicate runs.
expect(parseSlash("/REVIEW now", { isProviderCommand })).toBeNull();
// Non-matching verbs still throw.
expect(() => parseSlash("/nonsense", { isProviderCommand })).toThrow(
/unknown slash command/,
);
});

it("built-ins always win over provider commands of the same name", () => {
// A provider exposing "/help" must not shadow the client's help modal.
const isProviderCommand = () => true;
expect(parseSlash("/help", { isProviderCommand })).toEqual({ kind: "help" });
});
});
15 changes: 14 additions & 1 deletion packages/core/src/slash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,17 @@ export type SlashCommand =
| { kind: "import" }
| { kind: "fork" };

export function parseSlash(raw: string): SlashCommand | null {
export interface ParseSlashOptions {
/**
* Provider-command passthrough (`session.commands` catalogs). When the
* verb is not a built-in and this predicate matches it, `parseSlash`
* returns null — "not a client command" — so the caller sends the raw
* text as a normal prompt and the session's provider expands it.
*/
isProviderCommand?: (name: string) => boolean;
}

export function parseSlash(raw: string, opts?: ParseSlashOptions): SlashCommand | null {
const trimmed = raw.trim();
if (!trimmed.startsWith("/")) return null;
const head = trimmed.slice(1);
Expand Down Expand Up @@ -122,6 +132,9 @@ export function parseSlash(raw: string): SlashCommand | null {
case "fork":
return { kind: "fork" };
default:
// Not a built-in. Provider commands (pi extensions, prompt templates,
// skills) pass through as plain prompt text — the provider expands them.
if (opts?.isProviderCommand?.(verb.toLowerCase())) return null;
throw new Error(`unknown slash command: /${verb}`);
}
}
Expand Down
54 changes: 54 additions & 0 deletions packages/protocol/src/schemas.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,22 @@ const samples: { [T in ClientTypes]: Extract<ClientMessage, { type: T }> } = {
approved: true,
updatedInput: { answers: { "Which?": "B" } },
},
"session.ui_response": {
type: "session.ui_response",
id: "r25",
sessionId: "s1",
requestId: "u1",
value: "Allow",
},
"session.part_action": {
type: "session.part_action",
id: "r26",
sessionId: "s1",
messageId: "m1",
action: "retry-build",
data: { target: "web" },
},
"session.commands": { type: "session.commands", id: "r27", sessionId: "s1" },
"session.destroy": { type: "session.destroy", id: "r9", sessionId: "s1" },
"session.set_mode": { type: "session.set_mode", id: "r10", sessionId: "s1", mode: "autonomous", maxTurns: 5 },
"session.pin": { type: "session.pin", id: "r11", sessionId: "s1", path: "SPEC.md" },
Expand Down Expand Up @@ -237,3 +253,41 @@ describe("auth handshake", () => {
expect(authMsgSchema.safeParse({ type: "ping", token: "t" }).success).toBe(false);
});
});

describe("session.ui_response payload exclusivity", () => {
test("ambiguous payloads are rejected", () => {
// Two payload fields at once.
expect(
parseClientMessage({
type: "session.ui_response",
id: "r1",
sessionId: "s1",
requestId: "u1",
value: "x",
cancelled: true,
}).ok,
).toBe(false);
// No payload field at all.
expect(
parseClientMessage({
type: "session.ui_response",
id: "r1",
sessionId: "s1",
requestId: "u1",
}).ok,
).toBe(false);
});

test("each single-field payload is accepted", () => {
for (const payload of [{ value: "x" }, { confirmed: false }, { cancelled: true }]) {
const result = parseClientMessage({
type: "session.ui_response",
id: "r1",
sessionId: "s1",
requestId: "u1",
...payload,
});
expect(result.ok).toBe(true);
}
});
});
35 changes: 35 additions & 0 deletions packages/protocol/src/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,38 @@ export const sessionApproveSchema = z.object({
updatedInput: z.record(z.string(), z.unknown()).optional(),
});

export const sessionUiResponseSchema = z
.object({
...base,
type: z.literal("session.ui_response"),
sessionId: sessionIdField,
requestId: z.string().min(1).max(LIMITS.ID_MAX),
value: z.string().max(LIMITS.UI_TEXT_MAX).optional(),
confirmed: z.boolean().optional(),
cancelled: z.boolean().optional(),
})
.refine(
(r) =>
[r.value, r.confirmed, r.cancelled].filter((v) => v !== undefined)
.length === 1,
{ message: "exactly one of value, confirmed, or cancelled must be set" },
);

export const sessionPartActionSchema = z.object({
...base,
type: z.literal("session.part_action"),
sessionId: sessionIdField,
messageId: z.string().min(1).max(LIMITS.ID_MAX),
action: z.string().min(1).max(256),
data: z.record(z.string(), z.unknown()).optional(),
});

export const sessionCommandsSchema = z.object({
...base,
type: z.literal("session.commands"),
sessionId: sessionIdField,
});

export const sessionDestroySchema = z.object({
...base,
type: z.literal("session.destroy"),
Expand Down Expand Up @@ -247,6 +279,9 @@ export const clientMessageSchema = z.discriminatedUnion("type", [
sessionSendSchema,
sessionInterruptSchema,
sessionApproveSchema,
sessionUiResponseSchema,
sessionPartActionSchema,
sessionCommandsSchema,
sessionDestroySchema,
sessionSetModeSchema,
sessionPinSchema,
Expand Down
144 changes: 144 additions & 0 deletions packages/protocol/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,17 @@ export const CAPABILITIES = {
SEQ_RESUME: "replay.resume",
/** Duplicate-send suppression via `session.send.clientMsgId`. */
SEND_IDEMPOTENCY: "send.idempotency",
/**
* Provider-initiated dialogs (`session.ui_request` / `session.ui_response`).
* Declared by clients that can render the request methods; the daemon only
* targets `ui_request` frames at connections that declared it.
*/
UI_DIALOGS: "ui.dialogs",
/**
* Session-scoped provider command discovery (`session.commands`). Declared
* by the daemon; clients feature-detect before fetching.
*/
DYNAMIC_COMMANDS: "commands.dynamic",
} as const;

export type Capability = (typeof CAPABILITIES)[keyof typeof CAPABILITIES];
Expand Down Expand Up @@ -79,6 +90,10 @@ export const LIMITS = {
ID_MAX: 128,
/** Max model id / alias length (`session.set_model`). */
MODEL_MAX: 256,
/** Max free-text length on a `session.ui_response` (`value`). */
UI_TEXT_MAX: 65_536,
/** Max number of options on a `session.ui_request` select. */
UI_OPTIONS_MAX: 64,
} as const;

// =============================================================================
Expand Down Expand Up @@ -672,6 +687,9 @@ export type ClientMessage =
| SessionSendMsg
| SessionInterruptMsg
| SessionApproveMsg
| SessionUiResponseMsg
| SessionPartActionMsg
| SessionCommandsMsg
| SessionDestroyMsg
| SessionSetModeMsg
| SessionPinMsg
Expand Down Expand Up @@ -826,6 +844,70 @@ export interface SessionApproveMsg extends BaseClientMsg {
updatedInput?: Record<string, unknown>;
}

/**
* Answer a provider-initiated dialog (`session.ui_request`). Exactly one of
* the payload fields applies per method:
* - select / input / editor → `value` (the chosen option / entered text)
* - confirm → `confirmed`
* - any method → `cancelled: true` to dismiss
* The first response for a `requestId` wins; the daemon broadcasts
* `session.ui_resolved` so every other attached client dismisses its copy.
* A response for a request that is no longer pending gets `not_found`.
*/
export interface SessionUiResponseMsg extends BaseClientMsg {
type: "session.ui_response";
sessionId: string;
/** Echoes `SessionUiRequestMsg.requestId`. */
requestId: string;
value?: string;
confirmed?: boolean;
cancelled?: boolean;
}

/**
* Activate a `ButtonPart` the daemon previously sent in a message's
* `parts[]`. The daemon validates that `messageId` really carries a button
* with this `action` (clients can't mint arbitrary provider calls) and
* forwards it to the session's provider. Providers that don't handle
* actions reject with `invalid_request`.
*/
export interface SessionPartActionMsg extends BaseClientMsg {
type: "session.part_action";
sessionId: string;
/** The message whose `parts[]` contains the button. */
messageId: string;
/** `ButtonPart.action`, verbatim. */
action: string;
/** `ButtonPart.data`, verbatim (optional). */
data?: Record<string, unknown>;
}

/**
* Fetch the session's provider-defined command catalog — slash commands
* contributed by the backing provider (e.g. pi extension commands, prompt
* templates, skills). Invocation needs no dedicated verb: send the command
* as plain `session.send` text (`"/name args"`); the provider expands it.
* Gated on the daemon capability `commands.dynamic`.
*/
export interface SessionCommandsMsg extends BaseClientMsg {
type: "session.commands";
sessionId: string;
}

/** One provider-defined slash command (see `SessionCommandsMsg`). */
export interface ProviderCommand {
/** Invokable name without the leading slash. */
name: string;
description?: string;
/**
* Provider-specific origin taxonomy (e.g. "extension" | "prompt" |
* "skill"). Open string — clients display it verbatim, never switch on it.
*/
source?: string;
/** Optional argument hint for palette display (e.g. "<env>"). */
argumentHint?: string;
}

export interface SessionDestroyMsg extends BaseClientMsg {
type: "session.destroy";
sessionId: string;
Expand Down Expand Up @@ -1240,6 +1322,65 @@ export interface SessionImportResultMsg {
warnings: string[];
}

// =============================================================================
// Provider-initiated UI — generic dialogs any backend can raise.
//
// A provider (or one of its extensions) may need an answer from the human
// mid-session: a confirmation gate, a pick-one list, a line of text. These
// are NOT tool approvals — they carry no tool input to audit — so they get
// their own request/response pair instead of piggybacking on
// `waiting_confirmation`.
//
// Lifecycle: daemon broadcasts `session.ui_request` to attached clients that
// declared the `ui.dialogs` capability (and re-sends pending requests on
// attach). The first `session.ui_response` wins; the daemon then broadcasts
// `session.ui_resolved` so every client dismisses its copy. `timeoutMs`
// requests auto-resolve as cancelled on expiry — the daemon enforces the
// deadline, clients only display the countdown.
// =============================================================================

export type UiRequestMethod = "select" | "confirm" | "input" | "editor";

export interface SessionUiRequestMsg {
type: "session.ui_request";
sessionId: string;
/** Unique id — clients echo it on `session.ui_response`. */
requestId: string;
method: UiRequestMethod;
/** Short prompt title (always present). */
title: string;
/** Longer body text (confirm dialogs; optional elsewhere). */
message?: string;
/** Choices for `method: "select"`. */
options?: string[];
/** Input placeholder for `method: "input"`. */
placeholder?: string;
/** Prefilled text for `method: "editor"` (and optionally "input"). */
prefill?: string;
/** Auto-cancel deadline in ms from `timestamp`. Absent = waits for a user. */
timeoutMs?: number;
timestamp: string;
}

export interface SessionUiResolvedMsg {
type: "session.ui_resolved";
sessionId: string;
requestId: string;
/** Why it settled. Open string — clients treat unknown values as "dismiss". */
reason: "answered" | "cancelled" | "timeout" | "interrupted";
timestamp: string;
}

/** Reply to `session.commands` — the provider's current command catalog. */
export interface SessionCommandsResultMsg {
type: "session.commands.result";
requestId: string;
sessionId: string;
/** Provider these commands belong to (e.g. "pi"). */
providerId: string;
commands: ProviderCommand[];
}

// =============================================================================
// Daemon → Client messages
// =============================================================================
Expand All @@ -1253,6 +1394,9 @@ export type DaemonMessage =
| SessionMessageDelta
| SessionStatusChangeMsg
| SessionInfoUpdateMsg
| SessionUiRequestMsg
| SessionUiResolvedMsg
| SessionCommandsResultMsg
| ScrollbackReplayMsg
| SessionSearchResultMsg
| FsListResultMsg
Expand Down
Loading
Loading