@code-yeongyu/senpi 2026.10.25-3 → 2026.10.25-4

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 (76) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/bundle/chunks/{chunk-WLHNEUGD.js → chunk-2RB5EL4X.js} +1 -1
  3. package/dist/bundle/chunks/{chunk-MJ5NNWX4.js → chunk-3QCYBOKN.js} +1 -1
  4. package/dist/bundle/chunks/{chunk-FFQOEC76.js → chunk-4MOZXOJX.js} +3 -3
  5. package/dist/bundle/chunks/{chunk-ODTGC6YU.js → chunk-5JJDR6XI.js} +1 -1
  6. package/dist/bundle/chunks/chunk-6VWIMHMH.js +2 -0
  7. package/dist/bundle/chunks/{chunk-SWJXRC2I.js → chunk-BZ7726QO.js} +1 -1
  8. package/dist/bundle/chunks/{chunk-YRF4I43T.js → chunk-C2MQ4QXN.js} +12 -12
  9. package/dist/bundle/chunks/{chunk-7W7OJDIY.js → chunk-DODH3N6U.js} +4 -4
  10. package/dist/bundle/chunks/{chunk-EHQXLHRB.js → chunk-E6D4RIT7.js} +2 -2
  11. package/dist/bundle/chunks/{chunk-6O4SEW2W.js → chunk-J37GRED2.js} +1 -1
  12. package/dist/bundle/chunks/chunk-JMOUU75H.js +2 -0
  13. package/dist/bundle/chunks/{chunk-Z3UYNSVR.js → chunk-KKD4CYCI.js} +1 -1
  14. package/dist/bundle/chunks/{chunk-JA2R2ZSE.js → chunk-KNVNKDER.js} +1 -1
  15. package/dist/bundle/chunks/{chunk-CNL6QSUB.js → chunk-LOIF6PHI.js} +2 -2
  16. package/dist/bundle/chunks/{chunk-P3M2PF6K.js → chunk-N7IN46Q5.js} +1 -1
  17. package/dist/bundle/chunks/{chunk-C5UFJ7B3.js → chunk-PT6JJUN2.js} +1 -1
  18. package/dist/bundle/chunks/{chunk-O3Q2ET6W.js → chunk-SBGIXMX3.js} +1 -1
  19. package/dist/bundle/chunks/{chunk-HVJI5T75.js → chunk-STMXIBXF.js} +2 -2
  20. package/dist/bundle/chunks/{chunk-H3HNMY2W.js → chunk-TBU6VYHN.js} +1 -1
  21. package/dist/bundle/chunks/{chunk-UQNRW3VU.js → chunk-V2UYXTRR.js} +1 -1
  22. package/dist/bundle/chunks/chunk-XMJKZK5H.js +2 -0
  23. package/dist/bundle/chunks/cli-main-YYH3IBC6.js +3 -0
  24. package/dist/bundle/chunks/{dispatch-NYAULNVW.js → dispatch-MXG5YWLV.js} +1 -1
  25. package/dist/bundle/chunks/{host-command-Z3WPHJOW.js → host-command-TDLLHXVI.js} +1 -1
  26. package/dist/bundle/chunks/{host-lifecycle-ONQIMWMX.js → host-lifecycle-3VHHIGDL.js} +1 -1
  27. package/dist/bundle/chunks/host-lifecycle.js +1 -1
  28. package/dist/bundle/chunks/{host-session-command-J65ENSV5.js → host-session-command-56XF6ACS.js} +1 -1
  29. package/dist/bundle/chunks/{host-socket-request-3CWE4265.js → host-socket-request-QL2WEZTN.js} +1 -1
  30. package/dist/bundle/chunks/interactive-mode-OEPULLZK.js +2 -0
  31. package/dist/bundle/chunks/{multi-session-host-YI2WZOET.js → multi-session-host-Z2CW4SDM.js} +2 -2
  32. package/dist/bundle/chunks/{package-manager-cli-GHZPJMRF.js → package-manager-cli-N55U6PEO.js} +1 -1
  33. package/dist/bundle/chunks/rotation-stream-CBGCBYHV.js +2 -0
  34. package/dist/bundle/chunks/rpc-mode-ETXQWEB2.js +2 -0
  35. package/dist/bundle/chunks/{rpc-monitor-source-FFEQS744.js → rpc-monitor-source-AE3C6EQV.js} +1 -1
  36. package/dist/bundle/chunks/{schedule-command-YWG6D6ZF.js → schedule-command-VS7CZC2J.js} +1 -1
  37. package/dist/bundle/chunks/session-control-endpoint-4LEIDIVI.js +3 -0
  38. package/dist/bundle/chunks/session-picker-E4KUJDLO.js +2 -0
  39. package/dist/bundle/chunks/session-worker.js +45 -45
  40. package/dist/bundle/cli.js +2 -2
  41. package/dist/bundle/host-supervisor.js +1 -1
  42. package/dist/bundle/index.js +1 -1
  43. package/dist/bundle/rpc-entry.js +1 -1
  44. package/dist/bundle/runtime-manifest.json +1 -1
  45. package/dist/core/extensions/builtin/mcp/elicitation.js +2 -4
  46. package/dist/core/session-control-actions.js +0 -2
  47. package/dist/modes/interactive/session-control-attach.d.ts +20 -0
  48. package/dist/modes/interactive/session-control-attach.js +13 -0
  49. package/dist/modes/interactive/session-control-commands.js +16 -6
  50. package/dist/modes/interactive/session-control-registry.d.ts +2 -1
  51. package/dist/modes/interactive/session-control-registry.js +3 -2
  52. package/dist/modes/interactive/session-control-rpc-commands.d.ts +4 -2
  53. package/dist/modes/interactive/session-control-rpc-commands.js +114 -4
  54. package/dist/modes/interactive/session-control-server.js +9 -3
  55. package/dist/modes/rpc/connection-handler.js +6 -40
  56. package/dist/modes/rpc/custom-capability.d.ts +6 -0
  57. package/dist/modes/rpc/custom-capability.js +6 -0
  58. package/dist/modes/rpc/get-entries-command.d.ts +13 -0
  59. package/dist/modes/rpc/get-entries-command.js +61 -0
  60. package/dist/modes/rpc/get-entries-page.d.ts +22 -0
  61. package/dist/modes/rpc/get-entries-page.js +41 -2
  62. package/dist/modes/rpc/host-capabilities.js +4 -1
  63. package/dist/modes/rpc/host-observe-request.d.ts +2 -1
  64. package/dist/modes/rpc/host-observe-request.js +4 -1
  65. package/dist/modes/rpc/rpc-read-response-types.d.ts +2 -0
  66. package/dist/modes/rpc/rpc-session-command-types.d.ts +8 -1
  67. package/docs/rpc.md +43 -11
  68. package/package.json +6 -6
  69. package/dist/bundle/chunks/chunk-3LNSLJ7N.js +0 -2
  70. package/dist/bundle/chunks/chunk-MSQCTH6V.js +0 -2
  71. package/dist/bundle/chunks/cli-main-HEKFFB5S.js +0 -3
  72. package/dist/bundle/chunks/interactive-mode-DEPQC6TX.js +0 -2
  73. package/dist/bundle/chunks/rotation-stream-ZPVCFOVD.js +0 -2
  74. package/dist/bundle/chunks/rpc-mode-ORHBGBBV.js +0 -2
  75. package/dist/bundle/chunks/session-control-endpoint-36HAQSQO.js +0 -3
  76. package/dist/bundle/chunks/session-picker-25H5KNYP.js +0 -2
@@ -3,16 +3,23 @@
3
3
  * attaches by path or id, `subscribe_sessions`/`unsubscribe_sessions` run the host's status stream over
4
4
  * this one session, `steer`/`follow_up`/`prompt` go through the session's external admission (the
5
5
  * terminal's one delivery authority, so the user's draft still holds them) with the host's `whenIdle`
6
- * meaning, and `get_effect_status` reads the same effect ledger. A frame this file does not own answers
6
+ * meaning, and `get_effect_status` reads the same effect ledger. The rest of the host's drive set answers with the
7
+ * host's contract (senpi#4267): `set_client_info` declares the connection's roles, `attach_session` and
8
+ * `release_attachment` take and drop a live attachment on the session's identity, `abort` runs the host's effect
9
+ * path and its role rule (a Stop needs `interactive_user`), and input sent with a `clientMessageId` is answered
10
+ * with an admission receipt that a retry gets again, marked `replayed`. A frame this file does not own answers
7
11
  * `undefined` and falls through to the endpoint's other commands.
8
12
  */
9
13
  import { randomUUID } from "node:crypto";
10
14
  import { realpathSync } from "node:fs";
11
15
  import { resolve } from "node:path";
12
- import { runGetEffectStatus } from "../rpc/effect-commands.js";
16
+ import { CONNECTION_ROLE_FIXED } from "../rpc/connection-roles.js";
17
+ import { INTERACTIVE_USER_CAPABILITY } from "../rpc/custom-capability.js";
18
+ import { runAbort, runGetEffectStatus } from "../rpc/effect-commands.js";
13
19
  import { buildRpcSessionState } from "../rpc/rpc-session-state.js";
14
20
  import { answerSubscribeSessions, answerUnsubscribeSessions, refusal } from "../rpc/session-status-commands.js";
15
21
  import { keyOf } from "./session-control-attach.js";
22
+ import { failure, success } from "./session-control-server.js";
16
23
  export const TUI_RPC_COMMANDS = [
17
24
  "open_session",
18
25
  "subscribe_sessions",
@@ -21,7 +28,13 @@ export const TUI_RPC_COMMANDS = [
21
28
  "follow_up",
22
29
  "prompt",
23
30
  "get_effect_status",
31
+ "set_client_info",
32
+ "attach_session",
33
+ "release_attachment",
34
+ "abort",
24
35
  ];
36
+ /** The host's answer to a Stop from a connection that never declared the user's role (senpi#3811). */
37
+ const STOP_REQUIRES_INTERACTIVE_CLIENT = "stop_requires_interactive_client: declare interactive_user with set_client_info first";
25
38
  /** `null` = answered on the writer already; `undefined` = not a command this file owns. */
26
39
  export async function runTuiRpcCommand(context, connection, command) {
27
40
  switch (command.type) {
@@ -36,6 +49,14 @@ export async function runTuiRpcCommand(context, connection, command) {
36
49
  return admitInput(context, command, command.type === "steer" ? "steer" : "followUp", command.whenIdle);
37
50
  case "prompt":
38
51
  return admitPrompt(context, command);
52
+ case "set_client_info":
53
+ return setClientInfo(context, connection, command);
54
+ case "attach_session":
55
+ return attachSession(context, connection, command);
56
+ case "release_attachment":
57
+ return releaseAttachment(context, connection, command);
58
+ case "abort":
59
+ return abort(context, connection, frame(command));
39
60
  case "get_effect_status":
40
61
  return runGetEffectStatus({
41
62
  session: context.session,
@@ -74,6 +95,68 @@ function openSession(context, connection, command) {
74
95
  });
75
96
  return null;
76
97
  }
98
+ /** Declares this connection's roles, fixed once declared, exactly as on a host; the other fields change nothing here. */
99
+ function setClientInfo(context, connection, command) {
100
+ const capabilities = command.capabilities;
101
+ if (capabilities === undefined)
102
+ return success(command.id, "set_client_info");
103
+ if (!Array.isArray(capabilities) || !capabilities.every((entry) => typeof entry === "string"))
104
+ return failure(command.id, "set_client_info", "capabilities must be an array of strings");
105
+ if (!context.attachments.roles.declare(keyOf(connection), capabilities))
106
+ return failure(command.id, "set_client_info", CONNECTION_ROLE_FIXED);
107
+ return success(command.id, "set_client_info");
108
+ }
109
+ /**
110
+ * A live attachment on the session's identity, as on a host: `sessionId` is this terminal's routing handle,
111
+ * `durableSessionId` its session id, and `expectedHostInstance`, when sent, this endpoint's instance id. The answer
112
+ * follows the replay of the in-flight snapshot, then the session's records reach the connection.
113
+ */
114
+ function attachSession(context, connection, command) {
115
+ const { session, attachments } = context;
116
+ const id = idOf(command);
117
+ if (command.sessionId !== attachments.handle)
118
+ return refusal(id, "attach_session", "unknown_session", {
119
+ reason: "this terminal hosts one session and the request names another",
120
+ action: "attach with this terminal's sessionId from list_sessions",
121
+ });
122
+ const instanceMatches = command.expectedHostInstance === undefined || command.expectedHostInstance === context.instanceId;
123
+ if (command.durableSessionId !== session.sessionId || !instanceMatches)
124
+ return refusal(id, "attach_session", "session_identity_mismatch", {
125
+ reason: "durableSessionId or expectedHostInstance does not name this terminal's session",
126
+ action: "read list_sessions and get_protocol_info again, then attach with their ids",
127
+ });
128
+ attachments.run(connection, () => {
129
+ attachments.attach(connection);
130
+ attachments.writer.enqueue(attachments.handle, {
131
+ id: command.id,
132
+ type: "response",
133
+ command: "attach_session",
134
+ success: true,
135
+ data: { sessionId: attachments.handle, attached: true },
136
+ });
137
+ });
138
+ return null;
139
+ }
140
+ function releaseAttachment(context, connection, command) {
141
+ if (command.sessionId !== context.attachments.handle || !context.attachments.detach(connection))
142
+ return refusal(idOf(command), "release_attachment", "session_not_attached", {
143
+ reason: "this connection is not attached to that session",
144
+ action: "attach first with attach_session or open_session",
145
+ });
146
+ // Nothing outlives the terminal's own attachment, so a release is never deferred.
147
+ return success(command.id, "release_attachment", { deferred: false });
148
+ }
149
+ /** The host's `abort`: the same effect receipts, and a Stop only from a connection that declared `interactive_user`. */
150
+ async function abort(context, connection, command) {
151
+ const key = keyOf(connection);
152
+ if (command.intent === "stop" && !context.attachments.roles.has(key, INTERACTIVE_USER_CAPABILITY))
153
+ return failure(command.id, "abort", STOP_REQUIRES_INTERACTIVE_CLIENT);
154
+ const { session } = context;
155
+ return runAbort({ session, by: { connectionId: key }, sessionId: context.attachments.handle }, command, () => (command.intent === "stop" ? session.stop() : session.abort()).catch((cause) => {
156
+ // Reported out of band, as on a host; the answer never waits on it.
157
+ connection.send({ type: "rpc_error", error: String(cause) });
158
+ }));
159
+ }
77
160
  function subscribe(context, connection, command) {
78
161
  const { writer } = context.attachments;
79
162
  const key = keyOf(connection);
@@ -145,6 +228,22 @@ function admitInput(context, command, deliverAs, whenIdle) {
145
228
  });
146
229
  const admission = context.session.externalAdmission;
147
230
  const clientMessageId = typeof command.clientMessageId === "string" ? command.clientMessageId : undefined;
231
+ const fingerprint = JSON.stringify([command.type, message, deliverAs, whenIdle ?? null]);
232
+ const prior = clientMessageId === undefined ? undefined : context.attachments.receipts.get(clientMessageId);
233
+ if (prior !== undefined) {
234
+ if (prior.fingerprint !== fingerprint)
235
+ return refusal(id, command.type, "client_message_id_conflict", {
236
+ reason: `clientMessageId ${clientMessageId} was already used for different input`,
237
+ action: "send new input with a new clientMessageId",
238
+ });
239
+ return answered(id, command.type, {
240
+ disposition: prior.disposition,
241
+ clientMessageId,
242
+ replayed: true,
243
+ duplicate: true,
244
+ admission: prior.admission,
245
+ });
246
+ }
148
247
  const expected = typeof command.expected_turn_id === "number" ? command.expected_turn_id : admission.turnEpoch;
149
248
  const result = admission.admit({
150
249
  delivery_id: clientMessageId ?? `tui-rpc-${randomUUID()}`,
@@ -155,12 +254,23 @@ function admitInput(context, command, deliverAs, whenIdle) {
155
254
  whenIdle: whenIdle === "start" ? "start" : "queue",
156
255
  });
157
256
  const identity = clientMessageId === undefined ? {} : { clientMessageId };
257
+ const admitted = (disposition) => {
258
+ if (clientMessageId === undefined)
259
+ return answered(id, command.type, { disposition });
260
+ const receipt = {
261
+ fingerprint,
262
+ disposition,
263
+ admission: { clientMessageId, durableSessionId: context.session.sessionId, disposition },
264
+ };
265
+ context.attachments.receipts.set(clientMessageId, receipt);
266
+ return answered(id, command.type, { disposition, clientMessageId, admission: receipt.admission });
267
+ };
158
268
  switch (result.kind) {
159
269
  case "started":
160
- return answered(id, command.type, { disposition: "started", ...identity });
270
+ return admitted("started");
161
271
  case "steered":
162
272
  case "queued":
163
- return answered(id, command.type, { disposition: "queued", ...identity });
273
+ return admitted("queued");
164
274
  case "already_admitted":
165
275
  return answered(id, command.type, { disposition: "queued", duplicate: true, ...identity });
166
276
  case "held_draft":
@@ -3,13 +3,17 @@
3
3
  * (`authenticateSocket`, the handshake every RPC transport shares); a wrong secret is disconnected
4
4
  * before a single JSONL line is read. After that it is the RPC framing: one LF-terminated JSON
5
5
  * object per request, one response per request carrying its `id`, plus pushed feed events.
6
+ *
7
+ * The socket path is the endpoint's logical address on every platform (its secret lives beside it); on win32 the
8
+ * listener binds the named pipe the host transport derives from that path and the secret (senpi#4267), which is
9
+ * the address every client already connects to.
6
10
  */
7
11
  import { chmod } from "node:fs/promises";
8
12
  import { createServer } from "node:net";
9
13
  import { attachJsonlLineReader, MAX_RPC_LINE_CHARACTERS, serializeJsonLine } from "../rpc/jsonl.js";
10
14
  import { rpcCommandShapeError } from "../rpc/rpc-input-validation.js";
11
15
  import { socketSink } from "../rpc/socket-sink.js";
12
- import { authenticateSocket } from "../rpc/socket-transport.js";
16
+ import { authenticateSocket, resolveSocketTransportAddress } from "../rpc/socket-transport.js";
13
17
  export async function listenControlSocket(socketPath, secret, handlers) {
14
18
  const clients = new Set();
15
19
  let nextConnection = 0;
@@ -21,13 +25,15 @@ export async function listenControlSocket(socketPath, secret, handlers) {
21
25
  });
22
26
  await new Promise((resolve, reject) => {
23
27
  server.once("error", reject);
24
- server.listen(socketPath, () => {
28
+ server.listen(resolveSocketTransportAddress(socketPath, process.platform, secret), () => {
25
29
  server.off("error", reject);
26
30
  resolve();
27
31
  });
28
32
  });
29
33
  server.on("error", (error) => handlers.listenerError(error));
30
- await chmod(socketPath, 0o600);
34
+ // A named pipe has no file to restrict: the secret, 0600 beside the logical path, is its gate.
35
+ if (process.platform !== "win32")
36
+ await chmod(socketPath, 0o600);
31
37
  return { close: () => closeServer(server, clients) };
32
38
  }
33
39
  function acceptConnection(socket, id, handlers) {
@@ -42,13 +42,13 @@ import { ClientAdmissions } from "./client-admissions.js";
42
42
  import { handleClientInput } from "./client-input-handler.js";
43
43
  import { ConnectionQuestionBridge, degradeQuestion, sessionQuestionBridges } from "./connection-question-bridge.js";
44
44
  import { CONNECTION_ROLE_FIXED, ConnectionRoles, roleConnection } from "./connection-roles.js";
45
- import { AUTH_KEY_VERIFY_CAPABILITY, AUTO_TITLE_SESSIONS_CAPABILITY, buildCustomUnsupportedRequest, CONTINUE_FROM_LEAF_CAPABILITY, DEFAULT_CUSTOM_EXTENSION_LABEL, EVAL_CELLS_CAPABILITY, EXTENSION_EVENTS_CAPABILITY, INTERACTIVE_USER_CAPABILITY, MEDIA_PLACEHOLDERS_CAPABILITY, QUESTION_CAPABILITY, QUEUED_MESSAGE_REVISION_CAPABILITY, RESUME_SESSION_CAPABILITY, RETRY_FALLBACK_COMMAND_CAPABILITY, SESSION_HELD_CAPABILITY, TOOL_BARRIER_CAPABILITY, TASK_PARENT_CAPABILITY, } from "./custom-capability.js";
45
+ import { AUTH_KEY_VERIFY_CAPABILITY, AUTO_TITLE_SESSIONS_CAPABILITY, buildCustomUnsupportedRequest, CONTINUE_FROM_LEAF_CAPABILITY, DEFAULT_CUSTOM_EXTENSION_LABEL, EVAL_CELLS_CAPABILITY, EXTENSION_EVENTS_CAPABILITY, ENTRIES_TAIL_CAPABILITY, INTERACTIVE_USER_CAPABILITY, MEDIA_PLACEHOLDERS_CAPABILITY, QUESTION_CAPABILITY, QUEUED_MESSAGE_REVISION_CAPABILITY, RESUME_SESSION_CAPABILITY, RETRY_FALLBACK_COMMAND_CAPABILITY, SESSION_HELD_CAPABILITY, TOOL_BARRIER_CAPABILITY, TASK_PARENT_CAPABILITY, } from "./custom-capability.js";
46
46
  import { runAbort, runGetEffectStatus, runQueuedInputChange, runSetModel, runSetThinkingLevel, } from "./effect-commands.js";
47
47
  import { EffectLedger } from "./effect-ledger.js";
48
48
  import { EFFECT_LEDGER_CAPABILITY } from "./effect-record.js";
49
49
  import { createRpcEventOutputBuffer } from "./event-output-buffer.js";
50
50
  import { settleExtensionUiResponse } from "./extension-ui-response.js";
51
- import { currentResponseByteBudget, endEntryWalk, entryPageBounds, holdEntryWalk, pageEntries, } from "./get-entries-page.js";
51
+ import { answerGetEntries } from "./get-entries-command.js";
52
52
  import { runHoldInput } from "./held-input-commands.js";
53
53
  import { HostSessionControl } from "./host-session-control.js";
54
54
  import { createRpcLoginPromptCallbacks } from "./login-prompts.js";
@@ -57,7 +57,7 @@ import { protocolIdentity } from "./protocol-identity.js";
57
57
  import { buildRpcCommandsForSession, createCommandsChangedEvent, rpcCommandListDigest } from "./rpc-command-surface.js";
58
58
  import { rpcCommandPayloadError, rpcCommandShapeError, rpcMessageLengthError, sessionRetryFallbackError, } from "./rpc-input-validation.js";
59
59
  import { buildRpcSessionState } from "./rpc-session-state.js";
60
- import { RPC_ERROR_ENTRY_NOT_FOUND, RPC_ERROR_EVAL_CELL_NOT_FOUND, RPC_ERROR_EVAL_CELLS_UNAVAILABLE, RPC_ERROR_INVALID_ARGUMENT, RPC_ERROR_INVALID_EVAL_CELL_REQUEST, RPC_ERROR_MEDIA_NOT_FOUND, RPC_ERROR_MODEL_NOT_FOUND, RPC_ERROR_RESPONSE_TOO_LARGE, STEER_WHEN_IDLE_CAPABILITY, USER_ACTIVITY_CAPABILITY, } from "./rpc-types.js";
60
+ import { RPC_ERROR_EVAL_CELL_NOT_FOUND, RPC_ERROR_EVAL_CELLS_UNAVAILABLE, RPC_ERROR_INVALID_ARGUMENT, RPC_ERROR_INVALID_EVAL_CELL_REQUEST, RPC_ERROR_MEDIA_NOT_FOUND, RPC_ERROR_MODEL_NOT_FOUND, STEER_WHEN_IDLE_CAPABILITY, USER_ACTIVITY_CAPABILITY, } from "./rpc-types.js";
61
61
  import { runPauseForRun, runResumeForRun } from "./run-hold-commands.js";
62
62
  import { availableModelsData, interruptRunningTurn } from "./session-control-actions-data.js";
63
63
  import { SessionExtensionUiRequests, sessionDialogRequests } from "./session-extension-ui-requests.js";
@@ -871,6 +871,7 @@ export function createRpcConnectionHandler(runtimeHost, sink, options = {}) {
871
871
  TOOL_BARRIER_CAPABILITY,
872
872
  USER_ACTIVITY_CAPABILITY,
873
873
  RESUME_SESSION_CAPABILITY,
874
+ ENTRIES_TAIL_CAPABILITY,
874
875
  ...(options.retryFallbackCommand ? [RETRY_FALLBACK_COMMAND_CAPABILITY] : []),
875
876
  ...(options.toolRuntime ? toolRuntimeCapabilities(options.toolRuntime) : []),
876
877
  // Client flags cannot advertise a host guard this classic binding does not run, nor a tool runtime.
@@ -1457,43 +1458,8 @@ export function createRpcConnectionHandler(runtimeHost, sink, options = {}) {
1457
1458
  const messages = session.getUserMessagesForForking();
1458
1459
  return success(id, "get_fork_messages", { messages });
1459
1460
  }
1460
- case "get_entries": {
1461
- const sessionManager = session.sessionManager;
1462
- let entries = sessionManager.getEntries();
1463
- if (command.since !== undefined) {
1464
- const sinceIndex = entries.findIndex((e) => e.id === command.since);
1465
- if (sinceIndex === -1) {
1466
- return error(id, "get_entries", `Entry not found: ${command.since}`, RPC_ERROR_ENTRY_NOT_FOUND);
1467
- }
1468
- entries = entries.slice(sinceIndex + 1);
1469
- }
1470
- const bounds = entryPageBounds(command);
1471
- if (!bounds.ok)
1472
- return error(id, "get_entries", bounds.message, RPC_ERROR_INVALID_ARGUMENT);
1473
- const page = pageEntries(entries, describeUsageLimitEntry, bounds);
1474
- // A client walking pages asks again at once; holding the view until the last page spares each page a
1475
- // full re-read of the file. The hold expires on its own, so a walk the client abandons on an idle
1476
- // session does not pin the whole history; any other answer lets the view go now.
1477
- if (bounds.paged && page.hasMore)
1478
- holdEntryWalk(session, () => session.releaseFullHistoryView());
1479
- else {
1480
- endEntryWalk(session);
1481
- session.releaseFullHistoryView();
1482
- }
1483
- const budget = currentResponseByteBudget();
1484
- if (page.firstEntryBytes > budget) {
1485
- return error(id, "get_entries", `Entry ${entries[0]?.id ?? ""} alone is larger than one answer can carry (${budget} bytes), so it cannot be sent over RPC.`, RPC_ERROR_RESPONSE_TOO_LARGE);
1486
- }
1487
- if (!bounds.paged && page.hasMore) {
1488
- return error(id, "get_entries", `The session history is larger than one answer can carry (${budget} bytes). Read it in pages: pass limit or maxBytes, then since: nextSince until hasMore is false.`, RPC_ERROR_RESPONSE_TOO_LARGE);
1489
- }
1490
- return success(id, "get_entries", {
1491
- entries: page.entries,
1492
- leafId: sessionManager.getLeafId(),
1493
- ...(bounds.paged ? { hasMore: page.hasMore } : {}),
1494
- ...(page.nextSince === undefined ? {} : { nextSince: page.nextSince }),
1495
- });
1496
- }
1461
+ case "get_entries":
1462
+ return answerGetEntries(session, command);
1497
1463
  case "get_tree": {
1498
1464
  const sessionManager = session.sessionManager;
1499
1465
  const tree = sessionManager.getTree();
@@ -60,6 +60,12 @@ export declare const DETACH_CONNECTION_CAPABILITY = "detach_connection";
60
60
  * client's own `set_client_info` and fixed at that first declaration (see `connection-roles.ts`).
61
61
  */
62
62
  export declare const INTERACTIVE_USER_CAPABILITY = "interactive_user";
63
+ /** `get_state` and `get_entries` accept `observe: true` and then never count as an attachment (senpi#4267). */
64
+ export declare const OBSERVE_READS_CAPABILITY = "observe_reads";
65
+ /** `get_entries` accepts `before` and `tail` and answers `next_before` (senpi#4267). */
66
+ export declare const ENTRIES_TAIL_CAPABILITY = "entries_tail";
67
+ /** A terminal control endpoint serves the host's observe-and-drive command set (senpi#4267). */
68
+ export declare const TUI_OBSERVER_CAPABILITY = "tui_observer";
63
69
  /** The connection owns this session as a task child; only it may pause or resume the session for its run (senpi#3811). */
64
70
  export declare const TASK_PARENT_CAPABILITY = "task_parent";
65
71
  /**
@@ -60,6 +60,12 @@ export const DETACH_CONNECTION_CAPABILITY = "detach_connection";
60
60
  * client's own `set_client_info` and fixed at that first declaration (see `connection-roles.ts`).
61
61
  */
62
62
  export const INTERACTIVE_USER_CAPABILITY = "interactive_user";
63
+ /** `get_state` and `get_entries` accept `observe: true` and then never count as an attachment (senpi#4267). */
64
+ export const OBSERVE_READS_CAPABILITY = "observe_reads";
65
+ /** `get_entries` accepts `before` and `tail` and answers `next_before` (senpi#4267). */
66
+ export const ENTRIES_TAIL_CAPABILITY = "entries_tail";
67
+ /** A terminal control endpoint serves the host's observe-and-drive command set (senpi#4267). */
68
+ export const TUI_OBSERVER_CAPABILITY = "tui_observer";
63
69
  /** The connection owns this session as a task child; only it may pause or resume the session for its run (senpi#3811). */
64
70
  export const TASK_PARENT_CAPABILITY = "task_parent";
65
71
  /**
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The one `get_entries` answer: a host connection and a terminal control endpoint both read history through it, so
3
+ * the range rules (`since`, `before`), the forward (`limit`) and backward (`tail`, senpi#4267) pages and the byte
4
+ * budget (senpi#3534) are the same on every surface.
5
+ */
6
+ import type { AgentSession } from "../../core/agent-session.ts";
7
+ import { type RpcCommand, type RpcResponse } from "./rpc-types.ts";
8
+ type GetEntries = Extract<RpcCommand, {
9
+ type: "get_entries";
10
+ }>;
11
+ export declare function answerGetEntries(session: AgentSession, command: GetEntries): RpcResponse;
12
+ export {};
13
+ //# sourceMappingURL=get-entries-command.d.ts.map
@@ -0,0 +1,61 @@
1
+ import { describeUsageLimitEntry } from "../../core/retry-fallback/usage-limit.js";
2
+ import { currentResponseByteBudget, endEntryWalk, entryPageBounds, holdEntryWalk, pageEntries, pageEntriesFromEnd, } from "./get-entries-page.js";
3
+ import { RPC_ERROR_ENTRY_NOT_FOUND, RPC_ERROR_INVALID_ARGUMENT, RPC_ERROR_RESPONSE_TOO_LARGE, } from "./rpc-types.js";
4
+ function refuse(id, message, errorCode) {
5
+ return { id, type: "response", command: "get_entries", success: false, error: message, errorCode };
6
+ }
7
+ export function answerGetEntries(session, command) {
8
+ const { id } = command;
9
+ const sessionManager = session.sessionManager;
10
+ let entries = sessionManager.getEntries();
11
+ if (command.before !== undefined) {
12
+ const beforeIndex = entries.findIndex((e) => e.id === command.before);
13
+ if (beforeIndex === -1)
14
+ return refuse(id, `Entry not found: ${command.before}`, RPC_ERROR_ENTRY_NOT_FOUND);
15
+ entries = entries.slice(0, beforeIndex);
16
+ }
17
+ if (command.since !== undefined) {
18
+ const sinceIndex = sessionManager.getEntries().findIndex((e) => e.id === command.since);
19
+ if (sinceIndex === -1)
20
+ return refuse(id, `Entry not found: ${command.since}`, RPC_ERROR_ENTRY_NOT_FOUND);
21
+ entries = entries.slice(sinceIndex + 1);
22
+ }
23
+ const bounds = entryPageBounds(command);
24
+ if (!bounds.ok)
25
+ return refuse(id, bounds.message, RPC_ERROR_INVALID_ARGUMENT);
26
+ const backward = bounds.tail !== undefined;
27
+ const page = backward
28
+ ? pageEntriesFromEnd(entries, describeUsageLimitEntry, bounds)
29
+ : pageEntries(entries, describeUsageLimitEntry, bounds);
30
+ // A client walking pages asks again at once; holding the view until the last page spares each page a
31
+ // full re-read of the file. The hold expires on its own, so a walk the client abandons on an idle
32
+ // session does not pin the whole history; any other answer lets the view go now.
33
+ if (bounds.paged && page.hasMore)
34
+ holdEntryWalk(session, () => session.releaseFullHistoryView());
35
+ else {
36
+ endEntryWalk(session);
37
+ session.releaseFullHistoryView();
38
+ }
39
+ const budget = currentResponseByteBudget();
40
+ if (page.firstEntryBytes > budget) {
41
+ const entry = backward ? entries.at(-1) : entries[0];
42
+ return refuse(id, `Entry ${entry?.id ?? ""} alone is larger than one answer can carry (${budget} bytes), so it cannot be sent over RPC.`, RPC_ERROR_RESPONSE_TOO_LARGE);
43
+ }
44
+ if (!bounds.paged && page.hasMore) {
45
+ return refuse(id, `The session history is larger than one answer can carry (${budget} bytes). Read it in pages: pass limit or maxBytes, then since: nextSince until hasMore is false.`, RPC_ERROR_RESPONSE_TOO_LARGE);
46
+ }
47
+ return {
48
+ id,
49
+ type: "response",
50
+ command: "get_entries",
51
+ success: true,
52
+ data: {
53
+ entries: page.entries,
54
+ leafId: sessionManager.getLeafId(),
55
+ ...(bounds.paged ? { hasMore: page.hasMore } : {}),
56
+ ...("nextSince" in page && page.nextSince !== undefined ? { nextSince: page.nextSince } : {}),
57
+ ...("nextBefore" in page && page.nextBefore !== undefined ? { next_before: page.nextBefore } : {}),
58
+ },
59
+ };
60
+ }
61
+ //# sourceMappingURL=get-entries-command.js.map
@@ -14,6 +14,8 @@ export declare function currentResponseByteBudget(): number;
14
14
  export declare function setResponseByteBudgetForTesting(bytes: number): () => void;
15
15
  export interface EntryPageRequest {
16
16
  readonly limit?: number;
17
+ /** The newest `tail` entries of the range, read backwards (senpi#4267); never combined with `limit`. */
18
+ readonly tail?: number;
17
19
  readonly maxBytes?: number;
18
20
  }
19
21
  export interface EntryPage<T> {
@@ -27,6 +29,7 @@ export type EntryPageBounds = {
27
29
  readonly ok: true;
28
30
  readonly paged: boolean;
29
31
  readonly limit?: number;
32
+ readonly tail?: number;
30
33
  readonly maxBytes: number;
31
34
  } | {
32
35
  readonly ok: false;
@@ -44,6 +47,25 @@ export declare function pageEntries<S extends {
44
47
  readonly limit?: number;
45
48
  readonly maxBytes: number;
46
49
  }): EntryPage<T>;
50
+ export interface TailEntryPage<T> {
51
+ /** In append order: the oldest of the page first. */
52
+ readonly entries: T[];
53
+ readonly hasMore: boolean;
54
+ /** The oldest entry id the page returned, while older entries remain: pass it as the next `before`. */
55
+ readonly nextBefore?: string;
56
+ /** Serialized size of the page's newest entry (0 for an empty page). */
57
+ readonly firstEntryBytes: number;
58
+ }
59
+ /**
60
+ * The newest entries first, until the count or the byte size would pass the bounds, answered in append order. The
61
+ * newest entry is always taken, so a backward walk always moves even when one entry alone is larger than `maxBytes`.
62
+ */
63
+ export declare function pageEntriesFromEnd<S extends {
64
+ readonly id: string;
65
+ }, T>(entries: readonly S[], project: (entry: S) => T, bounds: {
66
+ readonly tail?: number;
67
+ readonly maxBytes: number;
68
+ }): TailEntryPage<T>;
47
69
  /**
48
70
  * Keeps a page walk's full-history view for the next page, and lets it go after `ENTRY_WALK_HOLD_MS` without
49
71
  * one: a client that stops mid-walk must not pin the whole history of a session that is already idle.
@@ -22,17 +22,22 @@ export function setResponseByteBudgetForTesting(bytes) {
22
22
  };
23
23
  }
24
24
  export function entryPageBounds(request) {
25
- const { limit, maxBytes } = request;
25
+ const { limit, tail, maxBytes } = request;
26
26
  if (limit !== undefined && !(Number.isInteger(limit) && limit > 0))
27
27
  return { ok: false, message: "limit must be a positive integer" };
28
+ if (tail !== undefined && !(Number.isInteger(tail) && tail > 0))
29
+ return { ok: false, message: "tail must be a positive integer" };
30
+ if (limit !== undefined && tail !== undefined)
31
+ return { ok: false, message: "limit and tail cannot be combined: limit pages forward, tail backward" };
28
32
  if (maxBytes !== undefined && !(Number.isInteger(maxBytes) && maxBytes > 0))
29
33
  return { ok: false, message: "maxBytes must be a positive integer" };
30
- if (limit === undefined && maxBytes === undefined)
34
+ if (limit === undefined && tail === undefined && maxBytes === undefined)
31
35
  return { ok: true, paged: false, maxBytes: responseByteBudget };
32
36
  return {
33
37
  ok: true,
34
38
  paged: true,
35
39
  ...(limit === undefined ? {} : { limit }),
40
+ ...(tail === undefined ? {} : { tail }),
36
41
  maxBytes: Math.min(maxBytes ?? DEFAULT_ENTRY_PAGE_BYTES, responseByteBudget),
37
42
  };
38
43
  }
@@ -68,6 +73,40 @@ export function pageEntries(entries, project, bounds) {
68
73
  firstEntryBytes,
69
74
  };
70
75
  }
76
+ /**
77
+ * The newest entries first, until the count or the byte size would pass the bounds, answered in append order. The
78
+ * newest entry is always taken, so a backward walk always moves even when one entry alone is larger than `maxBytes`.
79
+ */
80
+ export function pageEntriesFromEnd(entries, project, bounds) {
81
+ const page = [];
82
+ let bytes = 0;
83
+ let firstEntryBytes = 0;
84
+ let index = entries.length - 1;
85
+ for (; index >= 0; index--) {
86
+ if (bounds.tail !== undefined && page.length >= bounds.tail)
87
+ break;
88
+ const entry = entries[index];
89
+ if (entry === undefined)
90
+ break;
91
+ const projected = project(entry);
92
+ const size = Buffer.byteLength(JSON.stringify(projected)) + 1;
93
+ if (page.length > 0 && bytes + size > bounds.maxBytes)
94
+ break;
95
+ if (page.length === 0)
96
+ firstEntryBytes = size;
97
+ page.push(projected);
98
+ bytes += size;
99
+ }
100
+ page.reverse();
101
+ const oldest = entries[index + 1];
102
+ const hasMore = index >= 0;
103
+ return {
104
+ entries: page,
105
+ hasMore,
106
+ ...(hasMore && oldest !== undefined ? { nextBefore: oldest.id } : {}),
107
+ firstEntryBytes,
108
+ };
109
+ }
71
110
  const heldWalks = new WeakMap();
72
111
  /**
73
112
  * Keeps a page walk's full-history view for the next page, and lets it go after `ENTRY_WALK_HOLD_MS` without
@@ -1,7 +1,7 @@
1
1
  import { isHostOwnedToolRuntimeCapability, toolRuntimeCapabilities, } from "../../cli/tool-runtime.js";
2
2
  import { ACCEPT_EDITS_PERMISSION_PRESET_CAPABILITY, AUTO_PERMISSION_PRESET_CAPABILITY, } from "../../core/extensions/builtin/permission-system/config.js";
3
3
  import { DURABLE_CLIENT_MESSAGE_ID_CAPABILITY } from "./client-admission-record.js";
4
- import { ATTENDED_CAPABILITY, AUTH_KEY_VERIFY_CAPABILITY, AUTO_TITLE_PER_SESSION_CAPABILITY, AUTO_TITLE_SESSIONS_CAPABILITY, BROWSER_ENGINE_CAPABILITY, CONTINUE_FROM_LEAF_CAPABILITY, DETACH_CONNECTION_CAPABILITY, DURABLE_SESSION_ID_CAPABILITY, EVAL_CELLS_CAPABILITY, MEDIA_PLACEHOLDERS_CAPABILITY, MOVED_PATH_GUARD_CAPABILITY, PROMPT_SURFACE_CAPABILITY, PROMPT_SURFACE_CHAT_CAPABILITY, QUEUED_MESSAGE_REVISION_CAPABILITY, RESUME_SESSION_CAPABILITY, RETAIN_ON_DISCONNECT_CAPABILITY, RETRY_FALLBACK_PROFILE_CAPABILITY, SESSION_CONTEXT_CAPABILITY, SESSION_HELD_CAPABILITY, SESSION_KIND_CAPABILITY, SESSION_RELOCATION_CAPABILITY, SESSION_STATUS_CAPABILITY, TOOL_BARRIER_CAPABILITY, WARM_CAPABILITY, } from "./custom-capability.js";
4
+ import { ATTENDED_CAPABILITY, AUTH_KEY_VERIFY_CAPABILITY, AUTO_TITLE_PER_SESSION_CAPABILITY, AUTO_TITLE_SESSIONS_CAPABILITY, BROWSER_ENGINE_CAPABILITY, CONTINUE_FROM_LEAF_CAPABILITY, DETACH_CONNECTION_CAPABILITY, DURABLE_SESSION_ID_CAPABILITY, ENTRIES_TAIL_CAPABILITY, EVAL_CELLS_CAPABILITY, MEDIA_PLACEHOLDERS_CAPABILITY, MOVED_PATH_GUARD_CAPABILITY, OBSERVE_READS_CAPABILITY, PROMPT_SURFACE_CAPABILITY, PROMPT_SURFACE_CHAT_CAPABILITY, QUEUED_MESSAGE_REVISION_CAPABILITY, RESUME_SESSION_CAPABILITY, RETAIN_ON_DISCONNECT_CAPABILITY, RETRY_FALLBACK_PROFILE_CAPABILITY, SESSION_CONTEXT_CAPABILITY, SESSION_HELD_CAPABILITY, SESSION_KIND_CAPABILITY, SESSION_RELOCATION_CAPABILITY, SESSION_STATUS_CAPABILITY, TOOL_BARRIER_CAPABILITY, WARM_CAPABILITY, } from "./custom-capability.js";
5
5
  import { EFFECT_LEDGER_CAPABILITY } from "./effect-record.js";
6
6
  import { STEER_WHEN_IDLE_CAPABILITY, USER_ACTIVITY_CAPABILITY } from "./rpc-types.js";
7
7
  import { MOVE_SESSION_CAPABILITY, MOVE_SESSION_REVOKE_CAPABILITY } from "./session-move-wire.js";
@@ -44,6 +44,9 @@ export function multiSessionHostCapabilities(options) {
44
44
  SESSION_HELD_CAPABILITY,
45
45
  RESUME_SESSION_CAPABILITY,
46
46
  ATTENDED_CAPABILITY,
47
+ // `get_state`/`get_entries` marked `observe` never attach, and history reads page backward too (senpi#4267).
48
+ OBSERVE_READS_CAPABILITY,
49
+ ENTRIES_TAIL_CAPABILITY,
47
50
  // Host capabilities, not client opt-ins: only a multi-session host owns the
48
51
  // attachment refcount `open_session.retain_on_disconnect` detaches from, the
49
52
  // per-session launch profile `context`/`auto_title` travel on, and the session
@@ -7,7 +7,8 @@
7
7
  * itself with `observe: true`; the supervisor classifies a connection by the requests it sends, and
8
8
  * only a connection that sends something other than an observing read becomes an attachment.
9
9
  *
10
- * Only the side-effect-free reads can observe (`get_effect_status` answers a receipt and changes nothing): `observe: true` on any other command is ignored
10
+ * Only the side-effect-free reads can observe (`get_effect_status` answers a receipt and changes nothing; `get_state` and
11
+ * `get_entries` read a session's state and history, senpi#4267): `observe: true` on any other command is ignored
11
12
  * and the connection attaches, so a client cannot hold a session while its host idles out under it.
12
13
  * `warm` never attaches, marked or not: it opens nothing, so a host that only received it idles out
13
14
  * on its normal deadline (senpi#2314).
@@ -7,7 +7,8 @@
7
7
  * itself with `observe: true`; the supervisor classifies a connection by the requests it sends, and
8
8
  * only a connection that sends something other than an observing read becomes an attachment.
9
9
  *
10
- * Only the side-effect-free reads can observe (`get_effect_status` answers a receipt and changes nothing): `observe: true` on any other command is ignored
10
+ * Only the side-effect-free reads can observe (`get_effect_status` answers a receipt and changes nothing; `get_state` and
11
+ * `get_entries` read a session's state and history, senpi#4267): `observe: true` on any other command is ignored
11
12
  * and the connection attaches, so a client cannot hold a session while its host idles out under it.
12
13
  * `warm` never attaches, marked or not: it opens nothing, so a host that only received it idles out
13
14
  * on its normal deadline (senpi#2314).
@@ -33,6 +34,8 @@ const OBSERVING_COMMANDS = new Set([
33
34
  "get_effect_status",
34
35
  "get_pending_inputs",
35
36
  "get_subscription_limits",
37
+ "get_state",
38
+ "get_entries",
36
39
  ]);
37
40
  export function isObservingRequest(line) {
38
41
  let request;
@@ -120,6 +120,8 @@ export type RpcReadResponse = {
120
120
  leafId: string | null;
121
121
  hasMore?: boolean;
122
122
  nextSince?: string;
123
+ /** A `tail` page's oldest entry id while older entries remain (senpi#4267). */
124
+ next_before?: string;
123
125
  };
124
126
  } | {
125
127
  id?: string;
@@ -388,11 +388,18 @@ export type RpcSessionCommand = {
388
388
  } | {
389
389
  id?: string;
390
390
  type: "get_fork_messages";
391
- } | {
391
+ }
392
+ /**
393
+ * `since`/`before` bound the range (strictly after / strictly before an entry id); `limit` pages it forward from
394
+ * its start, `tail` backward from its end (`next_before` names the next page, senpi#4267).
395
+ */
396
+ | {
392
397
  id?: string;
393
398
  type: "get_entries";
394
399
  since?: string;
400
+ before?: string;
395
401
  limit?: number;
402
+ tail?: number;
396
403
  maxBytes?: number;
397
404
  } | {
398
405
  id?: string;