@code-yeongyu/senpi 2026.10.10-3 → 2026.10.10-5

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.
Files changed (83) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/dist/bundle/chunks/{app-server-command-DVWM37GF.js → app-server-command-6FTH3TK6.js} +1 -1
  3. package/dist/bundle/chunks/{chunk-H4UIUFU5.js → chunk-2PUAD5TD.js} +1 -1
  4. package/dist/bundle/chunks/{chunk-MT2C4I5R.js → chunk-2UUTN6QT.js} +105 -104
  5. package/dist/bundle/chunks/{chunk-3A3C5VVO.js → chunk-3OGEJMZ7.js} +1 -1
  6. package/dist/bundle/chunks/{chunk-J5MUMVWB.js → chunk-64ZVBCTL.js} +8 -7
  7. package/dist/bundle/chunks/chunk-TIMLYJMK.js +14 -0
  8. package/dist/bundle/chunks/{chunk-Z7MMKKIY.js → chunk-TZVDXVHB.js} +1 -1
  9. package/dist/bundle/chunks/{chunk-7GVD54RM.js → chunk-U3IL5N76.js} +1 -1
  10. package/dist/bundle/chunks/{chunk-H67EVDWG.js → chunk-URQSNRHZ.js} +1 -1
  11. package/dist/bundle/chunks/{chunk-M2IHGL3M.js → chunk-VW74BGIC.js} +1 -1
  12. package/dist/bundle/chunks/chunk-YG5UO3E4.js +7 -0
  13. package/dist/bundle/chunks/{chunk-CTERKXVZ.js → chunk-ZSYRWQWG.js} +2 -2
  14. package/dist/bundle/chunks/{cli-main-B4S4DGBX.js → cli-main-UYTUBG64.js} +1 -1
  15. package/dist/bundle/chunks/{host-command-VATE7HRG.js → host-command-TOVYSHYH.js} +1 -1
  16. package/dist/bundle/chunks/{host-lifecycle-GC3GIR2N.js → host-lifecycle-5AV4JXDQ.js} +1 -1
  17. package/dist/bundle/chunks/{interactive-mode-QOFA64JL.js → interactive-mode-KJIOCRS7.js} +1 -1
  18. package/dist/bundle/chunks/{list-tips-IIRFXBTO.js → list-tips-BXAA4FG5.js} +1 -1
  19. package/dist/bundle/chunks/{multi-session-host-E5RDPWQN.js → multi-session-host-TEFLJTTA.js} +5 -5
  20. package/dist/bundle/chunks/{package-manager-cli-YGTWXRG5.js → package-manager-cli-QSJSIHUR.js} +1 -1
  21. package/dist/bundle/chunks/{rpc-mode-HDAGQ4X3.js → rpc-mode-RUKK7VBF.js} +1 -1
  22. package/dist/bundle/chunks/{schedule-command-EQ4PQCRP.js → schedule-command-K6CTAB4T.js} +1 -1
  23. package/dist/bundle/chunks/session-control-endpoint-GZDV2FOZ.js +3 -0
  24. package/dist/bundle/chunks/{session-picker-O7OESJWU.js → session-picker-6D4LC3KR.js} +1 -1
  25. package/dist/bundle/chunks/session-worker.js +178 -176
  26. package/dist/bundle/cli.js +1 -1
  27. package/dist/bundle/index.js +1 -1
  28. package/dist/bundle/rpc-entry.js +1 -1
  29. package/dist/bundle/runtime-manifest.json +1 -1
  30. package/dist/core/agent-session-runtime.d.ts +6 -0
  31. package/dist/core/agent-session-runtime.js +18 -0
  32. package/dist/core/agent-session.js +5 -2
  33. package/dist/core/extensions/builtin/html-render/bootstrap.d.ts +15 -0
  34. package/dist/core/extensions/builtin/html-render/bootstrap.js +123 -0
  35. package/dist/core/extensions/builtin/html-render/images.d.ts +16 -0
  36. package/dist/core/extensions/builtin/html-render/images.js +155 -0
  37. package/dist/core/extensions/builtin/html-render/index.d.ts +3 -0
  38. package/dist/core/extensions/builtin/html-render/index.js +5 -0
  39. package/dist/core/extensions/builtin/html-render/tool.d.ts +19 -0
  40. package/dist/core/extensions/builtin/html-render/tool.js +74 -0
  41. package/dist/core/extensions/builtin/index.js +4 -0
  42. package/dist/core/extensions/builtin/permission-system/index.js +40 -14
  43. package/dist/core/extensions/session-control-types.d.ts +25 -0
  44. package/dist/core/extensions/session-control-types.js +16 -0
  45. package/dist/core/external-admission.js +7 -1
  46. package/dist/core/resource-loader.js +63 -2
  47. package/dist/core/session-rebind.js +16 -1
  48. package/dist/index.d.ts +1 -1
  49. package/dist/index.js +1 -1
  50. package/dist/modes/interactive/components/extension-editor.js +4 -0
  51. package/dist/modes/interactive/components/remote-delivery-message.d.ts +6 -3
  52. package/dist/modes/interactive/components/remote-delivery-message.js +32 -3
  53. package/dist/modes/interactive/interactive-mode.d.ts +17 -0
  54. package/dist/modes/interactive/interactive-mode.js +79 -33
  55. package/dist/modes/interactive/interactive-stderr-guard.d.ts +8 -0
  56. package/dist/modes/interactive/interactive-stderr-guard.js +70 -0
  57. package/dist/modes/interactive/session-control-commands.d.ts +2 -1
  58. package/dist/modes/interactive/session-control-commands.js +21 -7
  59. package/dist/modes/interactive/session-control-session-commands.d.ts +22 -0
  60. package/dist/modes/interactive/session-control-session-commands.js +47 -0
  61. package/dist/modes/interactive/stdout-fd-redirect.d.ts +12 -0
  62. package/dist/modes/interactive/stdout-fd-redirect.js +114 -0
  63. package/dist/modes/rpc/connection-handler.js +6 -15
  64. package/dist/modes/rpc/host-reservations.d.ts +1 -1
  65. package/dist/modes/rpc/host-reservations.js +9 -3
  66. package/dist/modes/rpc/rpc-types.d.ts +13 -0
  67. package/dist/modes/rpc/session-control-actions-data.d.ts +17 -0
  68. package/dist/modes/rpc/session-control-actions-data.js +38 -0
  69. package/dist/modes/rpc/session-registry-attach.js +8 -2
  70. package/dist/modes/rpc/session-registry.js +5 -7
  71. package/dist/modes/rpc/session-teardown.d.ts +6 -1
  72. package/dist/modes/rpc/session-teardown.js +26 -7
  73. package/dist/modes/rpc/session-worker-client.d.ts +2 -0
  74. package/dist/modes/rpc/session-worker-client.js +6 -0
  75. package/dist/modes/rpc/session-worker-protocol.d.ts +4 -0
  76. package/dist/modes/rpc/session-worker.js +6 -0
  77. package/dist/modes/rpc/worker-session-registry.js +6 -1
  78. package/docs/extensions.md +1 -1
  79. package/docs/rpc.md +52 -8
  80. package/package.json +6 -6
  81. package/dist/bundle/chunks/chunk-PDSG6VER.js +0 -14
  82. package/dist/bundle/chunks/chunk-RADHRQ4K.js +0 -7
  83. package/dist/bundle/chunks/session-control-endpoint-NABTZHCZ.js +0 -3
@@ -58,6 +58,7 @@ export function createSessionPathReservations(options) {
58
58
  const pid = options.pid ?? process.pid;
59
59
  const startTime = readProcessStartTime(pid).then((value) => value ?? null, () => null);
60
60
  const held = new Set();
61
+ const updates = new Map();
61
62
  const report = (message) => options.onFailure?.(message);
62
63
  const publish = async (sessionPath, attached, stillHeld) => {
63
64
  const owner = {
@@ -100,19 +101,24 @@ export function createSessionPathReservations(options) {
100
101
  }
101
102
  return undefined;
102
103
  },
103
- release(sessionPath) {
104
+ async release(sessionPath) {
104
105
  if (!held.delete(sessionPath))
105
106
  return;
106
- void rm(reservationFile(dir, sessionPath), { force: true }).catch((cause) => {
107
+ await updates.get(sessionPath);
108
+ updates.delete(sessionPath);
109
+ await rm(reservationFile(dir, sessionPath), { force: true }).catch((cause) => {
107
110
  report(`session path reservation for ${sessionPath} could not be removed (${errorMessage(cause)})`);
108
111
  });
109
112
  },
110
113
  setAttached(sessionPath, attached) {
111
114
  if (!held.has(sessionPath))
112
115
  return;
113
- void publish(sessionPath, attached, () => held.has(sessionPath)).catch((cause) => {
116
+ const update = (updates.get(sessionPath) ?? Promise.resolve())
117
+ .then(() => publish(sessionPath, attached, () => held.has(sessionPath)))
118
+ .catch((cause) => {
114
119
  report(`session path reservation for ${sessionPath} could not be updated (${errorMessage(cause)})`);
115
120
  });
121
+ updates.set(sessionPath, update);
116
122
  },
117
123
  };
118
124
  }
@@ -73,6 +73,10 @@ type RpcSessionCommand = {
73
73
  } & ClientMessageIdentity) | {
74
74
  id?: string;
75
75
  type: "abort";
76
+ } | {
77
+ id?: string;
78
+ type: "interrupt";
79
+ turnId?: string;
76
80
  } | {
77
81
  id?: string;
78
82
  type: "abort_compaction";
@@ -832,6 +836,15 @@ export type RpcResponse = {
832
836
  type: "response";
833
837
  command: "abort";
834
838
  success: true;
839
+ } | {
840
+ id?: string;
841
+ type: "response";
842
+ command: "interrupt";
843
+ success: true;
844
+ data: {
845
+ interrupted: boolean;
846
+ turnId?: string;
847
+ };
835
848
  } | {
836
849
  id?: string;
837
850
  type: "response";
@@ -0,0 +1,17 @@
1
+ import type { AgentSession } from "../../core/agent-session.ts";
2
+ export declare function availableModelsData(session: AgentSession): Promise<{
3
+ models: readonly object[];
4
+ }>;
5
+ /** The refusal of a level the active model cannot run; callers match this text. */
6
+ export declare function unsupportedThinkingLevel(level: unknown): string;
7
+ export interface InterruptOutcome {
8
+ readonly interrupted: boolean;
9
+ readonly turnId?: string;
10
+ }
11
+ /**
12
+ * `interrupt { turnId? }`: stops the running turn and only that. An idle session answers
13
+ * `interrupted: false`; so does a `turnId` naming a turn that is no longer the running one, which
14
+ * then answers the running turn's id. `stop` resolves once the turn has stopped.
15
+ */
16
+ export declare function interruptRunningTurn(session: AgentSession, requestedTurnId: unknown, stop: () => Promise<void>): Promise<InterruptOutcome>;
17
+ //# sourceMappingURL=session-control-actions-data.d.ts.map
@@ -0,0 +1,38 @@
1
+ /**
2
+ * The session-control answers a multi-session host and a terminal control endpoint share, so a caller
3
+ * reads the same shapes from either: the model catalog, the thinking-level refusal, and the
4
+ * `interrupt` outcome.
5
+ */
6
+ import { modelSupportsAssistantPrefill } from "@earendil-works/pi-ai";
7
+ import { getSupportedThinkingLevels } from "../../core/thinking-levels.js";
8
+ export async function availableModelsData(session) {
9
+ const models = await session.modelRegistry.modelRuntime.getAvailable();
10
+ return {
11
+ models: models.map((model) => ({
12
+ ...model,
13
+ supportedThinkingLevels: getSupportedThinkingLevels(model),
14
+ supportsAssistantPrefill: modelSupportsAssistantPrefill(model, {
15
+ thinkingEnabled: session.thinkingLevel !== "off",
16
+ }),
17
+ })),
18
+ };
19
+ }
20
+ /** The refusal of a level the active model cannot run; callers match this text. */
21
+ export function unsupportedThinkingLevel(level) {
22
+ return `Thinking level ${String(level)} is not supported by the active model.`;
23
+ }
24
+ /**
25
+ * `interrupt { turnId? }`: stops the running turn and only that. An idle session answers
26
+ * `interrupted: false`; so does a `turnId` naming a turn that is no longer the running one, which
27
+ * then answers the running turn's id. `stop` resolves once the turn has stopped.
28
+ */
29
+ export async function interruptRunningTurn(session, requestedTurnId, stop) {
30
+ if (!session.isStreaming && session.retryAttempt === 0)
31
+ return { interrupted: false };
32
+ const turnId = String(session.externalAdmission.turnEpoch);
33
+ if (typeof requestedTurnId === "string" && requestedTurnId !== turnId)
34
+ return { interrupted: false, turnId };
35
+ await stop();
36
+ return { interrupted: true, turnId };
37
+ }
38
+ //# sourceMappingURL=session-control-actions-data.js.map
@@ -16,8 +16,8 @@ export async function attachToOpenSession(entries, sessionPath, profile, options
16
16
  entry.retainOnDisconnect = true;
17
17
  if (!entry.durableSessionId)
18
18
  throw new RpcSessionRegistryError("session_path_in_use");
19
- // The surface follows the client that renders the replies: an attach that names one moves
20
- // the live session to it; an attach without one keeps what the session has.
19
+ // The surface, browser engine and permission preset follow the client: an attach that names one
20
+ // moves the live session to it; an attach without one keeps what the session has.
21
21
  if (profile.promptSurface !== undefined && profile.promptSurface !== entry.profile.promptSurface) {
22
22
  entry.profile = frozenProfile({ ...entry.profile, promptSurface: profile.promptSurface });
23
23
  entry.runtime?.setPromptSurface(profile.promptSurface);
@@ -26,6 +26,12 @@ export async function attachToOpenSession(entries, sessionPath, profile, options
26
26
  entry.profile = frozenProfile({ ...entry.profile, browserEngine: profile.browserEngine });
27
27
  entry.runtime?.setBrowserEngine(profile.browserEngine);
28
28
  }
29
+ // The preset is applied even when the record already names it: the record is not the live session,
30
+ // so a resent attach also repairs a session that drifted from it (senpi#2842).
31
+ if (profile.permissionPreset !== undefined) {
32
+ entry.profile = frozenProfile({ ...entry.profile, permissionPreset: profile.permissionPreset });
33
+ entry.runtime?.setPermissionPreset(profile.permissionPreset);
34
+ }
29
35
  entry.lastCommandAt = now;
30
36
  if (wasParked) {
31
37
  entry.lifecycleMutex = entry.lifecycleMutex.then(() => entry.runtime?.emitAttachmentEvent("session_resumed"));
@@ -11,7 +11,7 @@ import { attachToOpenSession } from "./session-registry-attach.js";
11
11
  import { settleClosingReservation, syncRuntimeMetadata } from "./session-registry-claims.js";
12
12
  import { createEntrySwitchSession } from "./session-registry-switch.js";
13
13
  import { frozenProfile, RpcSessionRegistryError, sessionIdentity, } from "./session-registry-types.js";
14
- import { beginSessionClose, closeMarkedSession, closeSession } from "./session-teardown.js";
14
+ import { beginSessionClose, closeMarkedSession, closeSession, releaseWithinGrace, } from "./session-teardown.js";
15
15
  export { frozenProfile, RpcSessionRegistryError, sessionIdentity, } from "./session-registry-types.js";
16
16
  /** Process-local lifecycle owner for multi-session RPC runtimes. */
17
17
  export class RpcSessionRegistry {
@@ -41,9 +41,9 @@ export class RpcSessionRegistry {
41
41
  this.options.onSizeChange?.(this.entries.size);
42
42
  return deleted;
43
43
  },
44
- releaseReservation: (key) => {
44
+ releaseReservation: async (key) => {
45
+ await this.options.pathReservations?.release(key);
45
46
  this.reservations.delete(key);
46
- this.options.pathReservations?.release(key);
47
47
  },
48
48
  markDetached: (key) => this.options.pathReservations?.setAttached(key, false),
49
49
  now: () => this.now(),
@@ -174,10 +174,8 @@ export class RpcSessionRegistry {
174
174
  finally {
175
175
  this.entries.delete(handle);
176
176
  this.options.onSizeChange?.(this.entries.size);
177
- if (sessionPath) {
178
- this.reservations.delete(sessionPath);
179
- this.options.pathReservations?.release(sessionPath);
180
- }
177
+ if (sessionPath)
178
+ await releaseWithinGrace(this.teardownHost, handle, sessionPath);
181
179
  }
182
180
  }
183
181
  if (error instanceof RpcSessionRegistryError)
@@ -3,7 +3,7 @@ export interface SessionTeardownHost {
3
3
  readonly closeGraceMs: number;
4
4
  get(handle: string): RpcSessionEntry | undefined;
5
5
  delete(handle: string): void;
6
- releaseReservation(key: string): void;
6
+ releaseReservation(key: string): Promise<void>;
7
7
  /** Publishes that this path is retained with no client attached, for the cross-generation claim. */
8
8
  markDetached(key: string): void;
9
9
  /** The registry's clock, so the detach stamp shares the sweep's timeline. */
@@ -13,6 +13,11 @@ export interface SessionTeardownHost {
13
13
  export declare function beginSessionClose(host: SessionTeardownHost, handle: string, onRole?: (finalizer: boolean) => void, options?: {
14
14
  detach?: boolean;
15
15
  }): RpcSessionEntry;
16
+ /**
17
+ * Close waits for the claim file to be gone, but a removal that never settles (a wedged mount)
18
+ * must not hold the close past the grace window: the close completes and the failure is reported.
19
+ */
20
+ export declare function releaseWithinGrace(host: Pick<SessionTeardownHost, "closeGraceMs" | "releaseReservation">, handle: string, key: string): Promise<void>;
16
21
  export declare function closeSession(host: SessionTeardownHost, handle: string): Promise<void>;
17
22
  export declare function closeMarkedSession(host: SessionTeardownHost, handle: string): Promise<void>;
18
23
  //# sourceMappingURL=session-teardown.d.ts.map
@@ -40,6 +40,25 @@ export function beginSessionClose(host, handle, onRole, options) {
40
40
  });
41
41
  return entry;
42
42
  }
43
+ /**
44
+ * Close waits for the claim file to be gone, but a removal that never settles (a wedged mount)
45
+ * must not hold the close past the grace window: the close completes and the failure is reported.
46
+ */
47
+ export async function releaseWithinGrace(host, handle, key) {
48
+ let timer;
49
+ const late = new Promise((resolve) => {
50
+ timer = setTimeout(() => resolve("late"), host.closeGraceMs);
51
+ });
52
+ const removal = host.releaseReservation(key).then(() => "removed", (cause) => {
53
+ reportDetachedFailure(handle, cause);
54
+ return "failed";
55
+ });
56
+ const outcome = await Promise.race([removal, late]);
57
+ clearTimeout(timer);
58
+ if (outcome === "late") {
59
+ reportDetachedFailure(handle, new Error(`session path reservation was not removed within ${host.closeGraceMs} ms; closing anyway`));
60
+ }
61
+ }
43
62
  export function closeSession(host, handle) {
44
63
  const entry = beginSessionClose(host, handle);
45
64
  if (entry.state !== "closing")
@@ -80,16 +99,16 @@ export function closeMarkedSession(host, handle) {
80
99
  await disposeOnce().catch(() => undefined);
81
100
  await entry.scope.close?.();
82
101
  };
83
- const release = () => {
102
+ const release = async () => {
84
103
  if (released)
85
104
  return;
86
105
  released = true;
87
106
  if (releaseTimer)
88
107
  clearTimeout(releaseTimer);
108
+ if (entry.reservationKey)
109
+ await releaseWithinGrace(host, handle, entry.reservationKey);
89
110
  entry.state = "closed";
90
111
  host.delete(handle);
91
- if (entry.reservationKey)
92
- host.releaseReservation(entry.reservationKey);
93
112
  };
94
113
  const graceful = (async () => {
95
114
  await previousLifecycle;
@@ -119,22 +138,22 @@ export function closeMarkedSession(host, handle) {
119
138
  }
120
139
  })();
121
140
  let settled = false;
122
- const finish = () => {
141
+ const finish = async () => {
123
142
  if (settled)
124
143
  return;
125
144
  settled = true;
126
- release();
145
+ await release();
127
146
  entry.closeResolve?.();
128
147
  };
129
148
  releaseTimer = setTimeout(() => {
130
149
  void disposeOnce().catch((cause) => reportDetachedFailure(handle, cause));
131
150
  void closeScopeOnce().catch((cause) => reportDetachedFailure(handle, cause));
132
151
  void graceful.catch((cause) => reportDetachedFailure(handle, cause));
133
- finish();
152
+ void finish();
134
153
  }, host.closeGraceMs);
135
154
  void graceful.then(finish, (cause) => {
136
155
  reportDetachedFailure(handle, cause);
137
- finish();
156
+ return finish();
138
157
  });
139
158
  return completion;
140
159
  }
@@ -40,6 +40,8 @@ export declare class SessionWorkerClient {
40
40
  setPromptSurface(surface: PromptSurface): Promise<void>;
41
41
  /** Moves the worker's live session to another browser engine (a later `open_session.browserEngine`). */
42
42
  setBrowserEngine(engine: BrowserEngine): Promise<void>;
43
+ /** Moves the worker's live session to another permission preset (a later `open_session.permissionPreset`). */
44
+ setPermissionPreset(preset: string): Promise<void>;
43
45
  commit(): Promise<WorkerSnapshot>;
44
46
  bind(sessionId: string, writer: SessionEventWriter, requestClose: () => void, options: Pick<RpcConnectionOptions, "capabilities" | "clientInfo">): Promise<RpcSessionBinding>;
45
47
  get busy(): boolean;
@@ -60,6 +60,12 @@ export class SessionWorkerClient {
60
60
  if (result.type !== "result")
61
61
  throw new Error("Invalid worker browser_engine response");
62
62
  }
63
+ /** Moves the worker's live session to another permission preset (a later `open_session.permissionPreset`). */
64
+ async setPermissionPreset(preset) {
65
+ const result = await this.request({ type: "permission_preset", preset });
66
+ if (result.type !== "result")
67
+ throw new Error("Invalid worker permission_preset response");
68
+ }
63
69
  async commit() {
64
70
  const result = await this.request({ type: "commit" });
65
71
  if (result.type !== "ready")
@@ -60,6 +60,10 @@ export type HostToSessionWorker = {
60
60
  type: "browser_engine";
61
61
  request: number;
62
62
  engine: BrowserEngine;
63
+ } | {
64
+ type: "permission_preset";
65
+ request: number;
66
+ preset: string;
63
67
  } | {
64
68
  type: "cancel_ui";
65
69
  } | {
@@ -183,6 +183,12 @@ async function handle(message) {
183
183
  entry.runtime.setBrowserEngine(message.engine);
184
184
  send({ type: "result", request: message.request });
185
185
  return;
186
+ case "permission_preset":
187
+ if (!entry?.runtime)
188
+ throw new Error("session_closing");
189
+ entry.runtime.setPermissionPreset(message.preset);
190
+ send({ type: "result", request: message.request });
191
+ return;
186
192
  case "cancel_ui":
187
193
  binding?.cancelPendingExtensionUiRequests?.();
188
194
  send({ type: "control_done", control: "cancel_ui" });
@@ -233,7 +233,7 @@ export class WorkerSessionRegistry {
233
233
  return undefined;
234
234
  }
235
235
  async attach(owner, path, options, requested = { cwd: "" }) {
236
- const { promptSurface, browserEngine } = requested;
236
+ const { promptSurface, browserEngine, permissionPreset } = requested;
237
237
  const entry = this.entries.get(owner);
238
238
  if (entry?.state !== "open" || !entry.worker?.bindingReady || entry.worker.snapshot?.sessionPath !== path)
239
239
  throw new RpcSessionRegistryError("session_path_in_use");
@@ -246,6 +246,11 @@ export class WorkerSessionRegistry {
246
246
  entry.profile = frozenProfile({ ...entry.profile, browserEngine });
247
247
  await entry.worker.setBrowserEngine(browserEngine);
248
248
  }
249
+ // Sent even when the record already names it: only the worker sees its live session (senpi#2842).
250
+ if (permissionPreset !== undefined) {
251
+ entry.profile = frozenProfile({ ...entry.profile, permissionPreset });
252
+ await entry.worker.setPermissionPreset(permissionPreset);
253
+ }
249
254
  const result = this.openResult(owner, entry);
250
255
  entry.attachments++;
251
256
  // Retention is a property of the live session: any attach may ask for it, and
@@ -1684,7 +1684,7 @@ runtime's steering or follow-up queue like the user's queued input. The protocol
1684
1684
  |---|---|---|
1685
1685
  | `registerControlEndpoint({ inboxDir, drain, isSessionReferenced? })` | `Promise<SessionControlRegistration>` | POSIX interactive session: binds the `tui` control endpoint and answers `{ status: "registered", socket, dispose }`. On a multi-session host with a public socket it binds nothing and `socket` is the host's. Otherwise `{ status: "unsupported", reason: "unsupported_platform" \| "unsupported_mode" }`, or `{ status: "failed", reason }` when a registration step failed. `drain(event)` runs on every [`session_control_wake`](#session_control_wake) edge and answers `{ admitted: [{ delivery_id, kind }] }`. `inboxDir` is created 0700 and its entry creations and deletions wake the drain. At clean exit a header-only session file is removed unless `isSessionReferenced()` answers `true`. |
1686
1686
  | `admissionGate()` | `{ can_admit, hold_reason?, editor_revision, turn_epoch }` | Read-only pre-check; `hold_reason` is `draft` while the user composes or their submission is still on its way into the runtime. |
1687
- | `admitExternalMessage({ delivery_id, text, deliverAs, expected_turn_id? })` | `{ kind, turn_epoch }` | Decides and acts in one synchronous call. `kind` is `started`, `queued`, `steered`, `turn_conflict` (stale epoch, or a steer without one), `held_draft` (nothing enqueued; retry on the next wake) or `already_admitted` (also for a delivery whose entry the file refused, until the run that refused it has settled and the file's last write succeeded). The delivery becomes a `custom` entry `session_control_delivery` whose `details.delivery_id` proves it was applied. Throws once a host started handing the session over (`release_session`). |
1687
+ | `admitExternalMessage({ delivery_id, text, deliverAs, expected_turn_id?, sender?, display_text? })` | `{ kind, turn_epoch }` | Decides and acts in one synchronous call. `kind` is `started`, `queued`, `steered`, `turn_conflict` (stale epoch, or a steer without one), `held_draft` (nothing enqueued; retry on the next wake) or `already_admitted` (also for a delivery whose entry the file refused, until the run that refused it has settled and the file's last write succeeded). The delivery becomes a `custom` entry `session_control_delivery` whose `details.delivery_id` proves it was applied. Throws once a host started handing the session over (`release_session`). `sender` (`{ kind: "agent", session_id, name? }`, `{ kind: "command_line", user? }` or `{ kind: "external", platform, author? }`) and `display_text` (the message as written) go to the entry's `details`: the terminal labels the message by its sender over `display_text`, while the model reads `text`. |
1688
1688
  | `listAdmittedDeliveries()` | `{ pending, emitted, failed? }` | The process-lifetime ledger: `pending` is held by the runtime, `emitted` has its transcript entry written, `failed` (present only when non-empty, `[{ delivery_id, error }]`) had its entry refused by the session file: no longer held, still with its sender, admissible again once the run that refused it has settled and the file's last write succeeded. |
1689
1689
  | `persistHeaderNow()` | `Promise<void>` | Writes the session header now, so the session id is durable before anything exposes it. |
1690
1690
 
package/docs/rpc.md CHANGED
@@ -870,7 +870,8 @@ session's prompt for the named surface, so one host serves a terminal client and
870
870
  uses the host process's `SENPI_PROMPT_SURFACE` (`app` or `chat` selects that surface; unset or any other value is `terminal`),
871
871
  which remains the only switch for a single-process `--mode rpc` client. A later `open_session` that attaches to a live
872
872
  session with another `promptSurface` rebuilds that session's prompt; an attach without the field keeps the current
873
- surface, and `new_session` / `switch_session` / `fork` inside the session keep it too. Probe `prompt_surface` in
873
+ surface, and `new_session` / `switch_session` / `fork` inside the session keep it too, also when the attach lands while
874
+ one of them is still building the replacement. Probe `prompt_surface` in
874
875
  `get_protocol_info` before sending the field; an older host ignores it. Probe `prompt_surface_chat` before sending
875
876
  `chat`: a host without it refuses `chat` with `invalid_launch_profile`, so a gateway falls back to `app`. Any value other
876
877
  than `terminal`, `app` or `chat` is refused with `invalid_launch_profile`.
@@ -900,9 +901,10 @@ subprocesses (the bash tool and everything it spawns) and its eval kernels, incl
900
901
  variable, even when the host process itself exports one (a daemon never inherits it from the process that ensured it).
901
902
  `BSK_HOME` and `BSK_BIN` are per install, not per session: they pass from the host environment to every session
902
903
  unchanged. A later `open_session` that attaches to a live session with another `browserEngine` moves that session to
903
- it, and an attach without the field keeps the current engine; `new_session` / `switch_session` / `fork` keep it too.
904
- Probe `browser_engine` in `get_protocol_info` before sending the field; the capability is advertised only because the
905
- value reaches every consumer above. Any other value is refused with `invalid_launch_profile`.
904
+ it, and an attach without the field keeps the current engine; `new_session` / `switch_session` / `fork` keep it too,
905
+ also when the attach lands while one of them is still building the replacement. Probe `browser_engine` in
906
+ `get_protocol_info` before sending the field; the capability is advertised only because the value reaches every
907
+ consumer above. Any other value is refused with `invalid_launch_profile`.
906
908
 
907
909
  A skill that must tell the client what the browser is doing publishes it as that session's own event: an extension
908
910
  registers a tool that calls `pi.rpc.emit(name, data)`, an eval cell reaches it as `tool.<name>(...)`, and the host adds
@@ -1147,10 +1149,11 @@ registrant opens no socket and writes no registry directory.
1147
1149
  `endpoint_kind: "tui"`, `owner` and `alive`, probing it for at most 1.5 s. `senpi host gc` reaps a dead one on
1148
1150
  the same evidence as a host. `ensure`, `handoff` and `stop` refuse it with `unsupported_endpoint_kind` (exit 3)
1149
1151
  before connecting, because a terminal endpoint is owned by its terminal process.
1150
- - **Commands.** The command set is read-mostly. Anything not listed below is answered `unsupported` as data. No
1151
- command can prompt, steer, queue a follow-up, open a session, run a command or change a model.
1152
- - `get_protocol_info`: `mode: "tui"`, `capabilities: ["tui_control"]`, `generation: 0` and the endpoint's
1153
- `instanceId`.
1152
+ - **Commands.** Anything not listed below is answered `unsupported` as data. No command can prompt, steer, queue a
1153
+ follow-up, open a session or run a command.
1154
+ - `get_protocol_info`: `mode: "tui"`, `capabilities: ["tui_control"]`, `generation: 0`, the endpoint's
1155
+ `instanceId`, and `commands`: every command this endpoint accepts. A terminal from before the session
1156
+ controls below lists no `commands`.
1154
1157
  - `list_sessions`: exactly one row, `kind: "interactive"`, `surface: "tui"`, `attachments: 1`.
1155
1158
  - `get_state`: the RPC session state plus `turn_epoch`, `blocking_question`, `compacting`,
1156
1159
  `editor_has_draft` and `state_version`.
@@ -1166,6 +1169,21 @@ registrant opens no socket and writes no registry directory.
1166
1169
  question (a pending question id from the `question` feed); without it, `id` does. The answer settles by
1167
1170
  the host's rule (see `question`) and the reply carries the frame's `id`, as on a host (see "Extension
1168
1171
  UI Responses").
1172
+ - The session controls run the pane's own paths, so validation, the footer and persistence are the ones the
1173
+ user gets doing it in that terminal:
1174
+ - `get_available_models` and `get_available_thinking_levels`: the same answers as a host's.
1175
+ - `set_model { provider, modelId }`: the `/model` switch (the footer, the remembered default model, the
1176
+ pane's status line). An unknown model is refused `Model not found: <provider>/<modelId>`; a switch the
1177
+ session refuses answers its reason. Success answers the model, as on a host.
1178
+ - `set_thinking_level { level, scope? }`: the thinking-level selector. `scope: "turn"` sets this session's
1179
+ level; otherwise the level is also remembered for the model (the selector's Ctrl+S). A level the active
1180
+ model cannot run is refused `Thinking level <level> is not supported by the active model.` and nothing
1181
+ changes, for either scope. A host refuses it only for `scope: "turn"`; for the default scope a host clamps it
1182
+ to a level the model runs.
1183
+ - `interrupt { turnId? }`: Esc on a running turn - queued input returns to the editor, the turn aborts and
1184
+ settles before the answer. Answers `{ interrupted: true, turnId }` with the `turn_epoch` it stopped. An idle
1185
+ session answers `{ interrupted: false }`; a `turnId` that is not the running turn answers
1186
+ `{ interrupted: false, turnId }` with the running one and leaves it alone. The session stays open.
1169
1187
  - `prompt`, `steer` and `follow_up` are `unsupported`.
1170
1188
  - **Admission.** A message from another session enters only through the registrant's drain, which calls
1171
1189
  `pi.session.admitExternalMessage({ delivery_id, text, deliverAs, expected_turn_id? })`. One synchronous call
@@ -1470,6 +1488,14 @@ capability must use `ask`, never `workspace`, for an edit-only approval promise.
1470
1488
  Hosts that support `permissionPreset: "auto"` advertise `permission_preset_auto`;
1471
1489
  clients without it must not send `auto`.
1472
1490
  Explicit permission rules and remembered approvals retain their existing precedence, except under `auto`: there settings and CLI rules can only narrow the preset (the more restrictive decision wins), and an "Always" answer (saved in `.senpi/permissions-approved.jsonl`, from this or an earlier session) still allows its pattern.
1491
+ An `open_session` that attaches to a live session with another `permissionPreset` moves that session to it: the next
1492
+ tool call is decided under the new preset, in either direction, and `new_session` / `switch_session` / `fork` keep it,
1493
+ also when the attach lands while one of them or a `reload` (or two overlapping reloads) is still rebuilding the
1494
+ session. An attach that names a preset is applied even when the session was opened or last attached with the same one.
1495
+ An attach without the field keeps the current preset. An attach accepts exactly the values `open_session` accepts and
1496
+ treats them the same way: an unknown preset name is applied like one given to `open_session`, so the session's next
1497
+ tool call is refused with `Permission setup failed: Invalid --permission-preset "<name>". ...` until a later attach
1498
+ names a valid preset.
1473
1499
 
1474
1500
  | Command | Params | Success data | Notes |
1475
1501
  | --- | --- | --- | --- |
@@ -1726,6 +1752,24 @@ Response:
1726
1752
  {"type": "response", "command": "abort", "success": true}
1727
1753
  ```
1728
1754
 
1755
+
1756
+ #### interrupt
1757
+
1758
+ Stop the running turn and only that one, with the terminal control endpoint's contract (see "Interactive sessions
1759
+ expose a control endpoint"). `turnId` (the `turn_epoch` of `get_state`) is optional.
1760
+
1761
+ ```json
1762
+ {"type": "interrupt", "turnId": "3"}
1763
+ ```
1764
+
1765
+ Response: `{ interrupted: true, turnId }` once the turn has stopped; `{ interrupted: false }` when the session is
1766
+ idle; `{ interrupted: false, turnId }` (the running turn) when `turnId` names another turn. Unlike `abort`, it
1767
+ answers after the turn settled.
1768
+
1769
+ ```json
1770
+ {"type": "response", "command": "interrupt", "success": true, "data": {"interrupted": true, "turnId": "3"}}
1771
+ ```
1772
+
1729
1773
  #### clear_queue
1730
1774
 
1731
1775
  Remove queued steering and follow-up messages and return their text.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@code-yeongyu/senpi",
3
- "version": "2026.10.10-3",
3
+ "version": "2026.10.10-5",
4
4
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
5
5
  "type": "module",
6
6
  "piConfig": {
@@ -57,9 +57,9 @@
57
57
  },
58
58
  "dependencies": {
59
59
  "@earendil-works/chord": "0.99.1",
60
- "@earendil-works/pi-agent-core": "npm:@code-yeongyu/senpi-agent-core@2026.10.10-3",
61
- "@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.10.10-3",
62
- "@earendil-works/pi-tui": "npm:@code-yeongyu/senpi-tui@2026.10.10-3",
60
+ "@earendil-works/pi-agent-core": "npm:@code-yeongyu/senpi-agent-core@2026.10.10-5",
61
+ "@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.10.10-5",
62
+ "@earendil-works/pi-tui": "npm:@code-yeongyu/senpi-tui@2026.10.10-5",
63
63
  "@silvia-odwyer/photon-node": "0.3.4",
64
64
  "chalk": "6.0.0",
65
65
  "cross-spawn": "7.0.6",
@@ -78,8 +78,8 @@
78
78
  "yaml": "2.9.1",
79
79
  "@anthropic-ai/claude-agent-sdk": "0.3.289",
80
80
  "@anthropic-ai/sdk": "0.127.0",
81
- "@code-yeongyu/senpi-codemode": "2026.10.10-3",
82
- "@earendil-works/pi-pty": "npm:@code-yeongyu/senpi-pty@2026.10.10-3",
81
+ "@code-yeongyu/senpi-codemode": "2026.10.10-5",
82
+ "@earendil-works/pi-pty": "npm:@code-yeongyu/senpi-pty@2026.10.10-5",
83
83
  "@modelcontextprotocol/sdk": "1.30.0",
84
84
  "@mozilla/readability": "0.6.0",
85
85
  "linkedom": "0.18.13",