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
5 changes: 5 additions & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@

### Added

- RPC clients can invoke generation-owned extension request handlers directly through
`pi.rpc.handle()` and `RpcClient.requestExtension()` without turning controls into
model prompts; request routing rejects unknown, duplicate, cross-session, stale,
and stale-in-flight generations ([#822](https://github.com/code-yeongyu/senpi/pull/822)).

### Changed

### Removed
Expand Down
23 changes: 23 additions & 0 deletions packages/coding-agent/docs/extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -1416,6 +1416,29 @@ Use this for extension-to-client state such as progress snapshots. It is separat
extension-local event-bus communication and must not carry secrets, prompts, transcripts, or other
data the client did not explicitly opt into receiving.

### pi.rpc.handle(name, handler)

Register one structured request handler that an RPC client can invoke without turning the request
into a model prompt:

```typescript
pi.rpc.handle("acme.job.cancel", async (data) => {
const input = CancelJobInput.parse(data);
await cancelJob(input.jobId);
return { cancelled: true };
});
```

`name` must be non-empty and unique across the loaded extension generation. The handler receives
opaque `unknown` data and may return any JSON-serializable value synchronously or asynchronously.
Extensions own validation for their request names; Senpi owns request correlation, multi-session
routing, stale-generation rejection, and bounded unknown/duplicate-name errors.

Use this for trusted RPC-client controls over extension-owned state. Do not use slash-command
prompts as a control transport: `pi.rpc.handle` executes directly without invoking the model. An
older Senpi host that does not expose `handle` can be supported by feature-detecting the method
before registration.

### pi.registerTool(definition)

Register a custom tool callable by the LLM. See [Custom Tools](#custom-tools) for full details.
Expand Down
37 changes: 37 additions & 0 deletions packages/coding-agent/docs/rpc.md
Original file line number Diff line number Diff line change
Expand Up @@ -948,6 +948,43 @@ Extension rows come directly from the session's loaded resource inventory, not f

In multi-session mode this is a session-scoped command and requires the routing `sessionId`.

### extension_request

Invoke a request handler registered by an extension through `pi.rpc.handle(name, handler)`:

```json
{
"id": "req-42",
"type": "extension_request",
"name": "acme.job.cancel",
"data": {
"jobId": "job-42"
}
}
```

Success returns the extension-owned structured value:

```json
{
"id": "req-42",
"type": "response",
"command": "extension_request",
"success": true,
"data": {
"cancelled": true
}
}
```

The request `name` must resolve to exactly one handler in the active extension generation.
Unknown names, duplicate names, stale generations, handler failures, and empty names return the
normal `{ type: "response", success: false, error }` envelope. Senpi treats request and response
data as opaque; the owning extension and client must validate their payloads.

In multi-session mode this command requires the owning routing `sessionId`. The response receives
the same `sessionId`, and another session's extension handlers are never consulted.

## Events

Events are streamed to stdout as JSON lines during agent operation. Events do not generally include an `id` field; `bash_execution_update` includes the `id` of its originating `bash` command when one was provided.
Expand Down
8 changes: 6 additions & 2 deletions packages/coding-agent/src/core/agent-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5740,8 +5740,12 @@ export class AgentSession {
const removed = oldExtensionIdentities.filter(
(extension) => !newExtensionResolvedPaths.has(extension.resolvedPath),
);
if (removed.length > 0) {
await oldExtensionRunner.emit({ type: "session_extensions_removed", reason: "reload", removed });
try {
if (removed.length > 0) {
await oldExtensionRunner.emit({ type: "session_extensions_removed", reason: "reload", removed });
}
} finally {
oldExtensionRunner.invalidate("stale extension generation after reload");
}
time("runtime", "reload");
}
Expand Down
17 changes: 17 additions & 0 deletions packages/coding-agent/src/core/changes.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,22 @@
# changes

## Retire extension generations after reload notifications (2026-08-12)

### What changed

- Session reload invalidates the previous `ExtensionRunner` after removed-extension notifications
have been delivered, including when notification delivery throws.

### Why

- Reload replaced the active runner but left captured references to the previous generation callable.
Invalidating after the final old-generation lifecycle event preserves notification behavior while
closing later request registration, emission, and dispatch.

### Expected merge conflict zones

- MEDIUM: `agent-session.ts` reload lifecycle ordering.

## Standalone binary codemode sidecar resolution (2026-08-11)

### What changed
Expand Down
38 changes: 38 additions & 0 deletions packages/coding-agent/src/core/extensions/changes.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,43 @@
# Core Extensions Changes

## 2026-08-12 - Reject stale in-flight RPC request results

### What changed

- `ExtensionRunner.requestRpc()` re-checks generation liveness after an asynchronous handler
completes, so a result cannot escape after reload or replacement invalidates its owner.
- Focused tests cover both explicit mid-flight invalidation and a real session reload.

### Why

- Entry-time liveness alone allowed a slow handler from generation N to return successfully after
generation N+1 became active.

### Expected merge conflict zones

- LOW: `runner.ts` request dispatch and its RPC request suite.

## 2026-08-12 - Extension-owned RPC request handlers

### What changed

- `pi.rpc.handle(name, handler)` registers structured client-to-extension request handlers on the
loaded extension generation.
- `ExtensionRunner.requestRpc()` requires exactly one active handler and rejects unknown,
duplicate, empty, and stale-generation requests without invoking the model.
- `pi.rpc.emit()` now also checks generation liveness before publishing.

### Why

- RPC clients need a direct, typed control path for extension-owned runtime state; encoding controls
as slash-command prompts would involve the model and lose request/response semantics.
- Per-generation ownership prevents captured handlers from surviving extension replacement or
reload.

### Expected merge conflict zones

- LOW: `types.ts`, `loader.ts`, and `runner.ts` RPC extension surfaces.

## 2026-08-11 - Native-deferred catalog tools activate at call time

### What changed
Expand Down
1 change: 1 addition & 0 deletions packages/coding-agent/src/core/extensions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ export type {
ExtensionFlag,
ExtensionHandler,
ExtensionMode,
ExtensionRpcRequestHandler,
// Runtime
ExtensionRuntime,
ExtensionShortcut,
Expand Down
13 changes: 13 additions & 0 deletions packages/coding-agent/src/core/extensions/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -566,13 +566,25 @@ function createExtensionAPI(

rpc: {
emit(name, data) {
runtime.assertActive();
const normalizedName = name.trim();
if (normalizedName.length === 0) throw new Error("RPC extension event name must not be empty");
eventBus.emit(EXTENSION_RPC_EVENT_CHANNEL, {
name: normalizedName,
data,
} satisfies ExtensionRpcEvent);
},
handle(name, handler) {
runtime.assertActive();
const normalizedName = name.trim();
if (normalizedName.length === 0) throw new Error("RPC extension request name must not be empty");
const handlers = extension.rpcHandlers ?? new Map();
if (handlers.has(normalizedName)) {
throw new Error(`RPC extension request handler already registered: ${normalizedName}`);
}
handlers.set(normalizedName, handler);
extension.rpcHandlers = handlers;
},
},
events: eventBus,
} as ExtensionAPI;
Expand Down Expand Up @@ -644,6 +656,7 @@ function createExtension(extensionPath: string, resolvedPath: string, registrati
messageRenderers: new Map(),
entryRenderers: undefined,
commands: new Map(),
rpcHandlers: new Map(),
flags: new Map(),
shortcuts: new Map(),
mcpServers: new Map(),
Expand Down
21 changes: 21 additions & 0 deletions packages/coding-agent/src/core/extensions/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -958,6 +958,27 @@ export class ExtensionRunner {
return this.resolveRegisteredCommands().find((command) => command.invocationName === name);
}

async requestRpc(name: string, data: unknown): Promise<unknown> {
this.assertActive();
const normalizedName = name.trim();
if (normalizedName.length === 0) {
throw new Error("Extension RPC request name must not be empty");
}
const matches = this.extensions.flatMap((extension) => {
const handler = extension.rpcHandlers?.get(normalizedName);
return handler === undefined ? [] : [handler];
});
if (matches.length === 0) {
throw new Error(`Unknown extension RPC request: ${normalizedName}`);
}
if (matches.length > 1) {
throw new Error(`Multiple extension RPC request handlers registered: ${normalizedName}`);
}
const result = await matches[0]?.(data);
this.assertActive();
return result;
}

/**
* Request a graceful shutdown. Called by extension tools and event handlers.
* The actual shutdown behavior is provided by the mode via bindExtensions().
Expand Down
12 changes: 11 additions & 1 deletion packages/coding-agent/src/core/extensions/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1846,15 +1846,23 @@ export interface ExtensionAPI {
*/
unregisterProvider(name: string): void;

/** Emit structured extension data to RPC clients that explicitly opt in. */
/**
* Exchange structured extension-owned data with RPC clients.
*
* `emit` is fire-and-forget server -> client delivery. `handle` registers a
* client -> extension request handler owned by this extension generation.
*/
rpc: {
emit(name: string, data: unknown): void;
handle(name: string, handler: ExtensionRpcRequestHandler): void;
};

/** Shared event bus for extension communication. */
events: EventBus;
}

export type ExtensionRpcRequestHandler = (data: unknown) => unknown | Promise<unknown>;

// ============================================================================
// Provider Registration Types
// ============================================================================
Expand Down Expand Up @@ -2254,6 +2262,8 @@ export interface Extension {
markdownTransformer?: MarkdownTransformer;
entryRenderers?: Map<string, EntryRenderer>;
commands: Map<string, RegisteredCommand>;
/** Optional for compatibility with extension records created before RPC requests. */
rpcHandlers?: Map<string, ExtensionRpcRequestHandler>;
flags: Map<string, ExtensionFlag>;
shortcuts: Map<KeyId, ExtensionShortcut>;
mcpServers: Map<string, RegisteredMcpServerDeclaration>;
Expand Down
1 change: 1 addition & 0 deletions packages/coding-agent/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ export type {
ExtensionFactory,
ExtensionFlag,
ExtensionHandler,
ExtensionRpcRequestHandler,
ExtensionRuntime,
ExtensionShortcut,
ExtensionUIContext,
Expand Down
23 changes: 23 additions & 0 deletions packages/coding-agent/src/modes/rpc/changes.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,28 @@
# changes

## Extension request RPC command (2026-08-12)

### What changed

- Added the session-scoped `extension_request` command and structured success/error response.
- `RpcClient.requestExtension()` exposes the command through the public client.
- Existing multi-session routing tags the response with the owning `sessionId`.

### Why

- Capability-gated `extension_event` records cover extension-to-client state, but interactive
extension controls also need a direct client-to-extension request path that does not become a
model prompt.

### Why extension system couldn't handle this

- Request ids, multi-session routing, JSONL response serialization, and public client correlation
are owned by the built-in RPC transport.

### Expected merge conflict zones

- MEDIUM: `rpc-types.ts`, `connection-handler.ts`, and `rpc-client.ts`.

## Multi-session open failure details (2026-08-07)

### What changed
Expand Down
15 changes: 10 additions & 5 deletions packages/coding-agent/src/modes/rpc/connection-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,11 +258,7 @@ export function createRpcConnectionHandler(
await sink.waitForBackpressure();
};

const success = <T extends RpcCommand["type"]>(
id: string | undefined,
command: T,
data?: object | null,
): RpcResponse => {
const success = <T extends RpcCommand["type"]>(id: string | undefined, command: T, data?: unknown): RpcResponse => {
if (data === undefined) {
return { id, type: "response", command, success: true } as RpcResponse;
}
Expand Down Expand Up @@ -1018,6 +1014,15 @@ export function createRpcConnectionHandler(
return success(id, "get_loaded_surfaces", inventory.data);
}

case "extension_request": {
const name = command.name.trim();
if (name.length === 0) {
return error(id, "extension_request", "Extension RPC request name cannot be empty");
}
const data = await session.extensionRunner.requestRpc(name, command.data);
return success(id, "extension_request", data);
}

// =================================================================
// Auth (task 13)
// =================================================================
Expand Down
10 changes: 10 additions & 0 deletions packages/coding-agent/src/modes/rpc/rpc-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -460,6 +460,16 @@ export class RpcClient {
return this.getData<{ commands: RpcSlashCommand[] }>(response).commands;
}

/** Invoke one extension-owned RPC request handler and return its structured result. */
async requestExtension<T = unknown>(name: string, data?: unknown): Promise<T> {
const response = await this.send({
type: "extension_request",
name,
...(data === undefined ? {} : { data }),
});
return this.getData<T>(response);
}

/** List safe metadata for the named provider's configured account slots. */
async getProviderAccounts(provider: string): Promise<RpcProviderAccount[]> {
const response = await this.send({ type: "get_provider_accounts", provider });
Expand Down
8 changes: 8 additions & 0 deletions packages/coding-agent/src/modes/rpc/rpc-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ type RpcSessionCommand =
// Commands and loaded runtime surfaces
| { id?: string; type: "get_commands" }
| { id?: string; type: "get_loaded_surfaces" }
| { id?: string; type: "extension_request"; name: string; data?: unknown }

// Auth (task 13) is additive. get_auth_providers, login_api_key and logout
// answer synchronously. login_start responds immediately (flow-started) and
Expand Down Expand Up @@ -390,6 +391,13 @@ export type RpcResponse =
success: true;
data: { extensions: RpcLoadedExtension[]; mcpServers: RpcLoadedMcpServer[] };
}
| {
id?: string;
type: "response";
command: "extension_request";
success: true;
data: unknown;
}

// Auth (task 13)
| {
Expand Down
Loading