@code-yeongyu/senpi 2026.11.6-1 → 2026.11.6-2

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 (167) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/dist/bundle/chunks/{chunk-5MWHZH2O.js → chunk-2FDW2LQL.js} +1 -1
  3. package/dist/bundle/chunks/{chunk-GG2JO3CL.js → chunk-4E7GDG4H.js} +1 -1
  4. package/dist/bundle/chunks/{chunk-K5JLYACP.js → chunk-4GKU755E.js} +1 -1
  5. package/dist/bundle/chunks/{chunk-437VLZQZ.js → chunk-7III44PV.js} +1 -1
  6. package/dist/bundle/chunks/chunk-7PSBD5KS.js +9 -0
  7. package/dist/bundle/chunks/{chunk-KFWHLVVH.js → chunk-C7OKUC7L.js} +1 -1
  8. package/dist/bundle/chunks/{chunk-CX32K5KD.js → chunk-CF5OGBC4.js} +7 -7
  9. package/dist/bundle/chunks/{chunk-7TGZJH6Z.js → chunk-D5MRJWUK.js} +40 -39
  10. package/dist/bundle/chunks/{chunk-E6L5VZSM.js → chunk-DSWDJ7HT.js} +1 -1
  11. package/dist/bundle/chunks/{chunk-6NMTFURP.js → chunk-ECW7VBMB.js} +1 -1
  12. package/dist/bundle/chunks/{chunk-2CSLJLM6.js → chunk-GKAYPJ4E.js} +2 -2
  13. package/dist/bundle/chunks/{chunk-KCGUUIUS.js → chunk-GNSMNSL7.js} +1 -1
  14. package/dist/bundle/chunks/{chunk-V3JSQ3PG.js → chunk-GSKCWXXG.js} +1 -1
  15. package/dist/bundle/chunks/{chunk-USKWGPA6.js → chunk-GTJJ73DE.js} +1 -1
  16. package/dist/bundle/chunks/{chunk-HHSYTDKO.js → chunk-HLM46XEB.js} +2 -2
  17. package/dist/bundle/chunks/{chunk-A4F4J2KH.js → chunk-I3PHVJAS.js} +2 -2
  18. package/dist/bundle/chunks/{chunk-LAMRXFHZ.js → chunk-LNPAT22L.js} +1 -1
  19. package/dist/bundle/chunks/chunk-NH7SYIGN.js +2 -0
  20. package/dist/bundle/chunks/{chunk-ZIIDIVTG.js → chunk-R2Z7CX77.js} +1 -1
  21. package/dist/bundle/chunks/{chunk-32I42CEY.js → chunk-RQ6CHXQ2.js} +1 -1
  22. package/dist/bundle/chunks/chunk-SJI6UB23.js +2 -0
  23. package/dist/bundle/chunks/{chunk-KUIEHAPR.js → chunk-T2SMQPRE.js} +1 -1
  24. package/dist/bundle/chunks/{chunk-IPG434JT.js → chunk-TIPKAMSS.js} +1 -1
  25. package/dist/bundle/chunks/{chunk-RI5ABX7A.js → chunk-TMXRR3N7.js} +2 -2
  26. package/dist/bundle/chunks/chunk-VD7AKTGP.js +3 -0
  27. package/dist/bundle/chunks/{chunk-22JICYKS.js → chunk-WAOGBBZR.js} +1 -1
  28. package/dist/bundle/chunks/{chunk-6MHCFJDB.js → chunk-WGRKM3N6.js} +2 -2
  29. package/dist/bundle/chunks/{chunk-WF5R2VSP.js → chunk-X5ZVYNOL.js} +1 -1
  30. package/dist/bundle/chunks/{chunk-H2FBGZNP.js → chunk-XCMII2VQ.js} +2 -2
  31. package/dist/bundle/chunks/{chunk-5JUCTKYS.js → chunk-YK3ZZCHI.js} +1 -1
  32. package/dist/bundle/chunks/{cli-main-ZQBSX5H5.js → cli-main-T74YJGIF.js} +1 -1
  33. package/dist/bundle/chunks/{hand-entry-7U7354GG.js → hand-entry-AYRUGE2A.js} +2 -2
  34. package/dist/bundle/chunks/{headless-host-7LKTG76U.js → headless-host-ES74ZB6M.js} +1 -1
  35. package/dist/bundle/chunks/{help-fast-path-NLSWH2CA.js → help-fast-path-TMTGLOBG.js} +1 -1
  36. package/dist/bundle/chunks/{host-command-3YRFN35E.js → host-command-YRXZE4TV.js} +1 -1
  37. package/dist/bundle/chunks/{host-lifecycle-6XLVIECJ.js → host-lifecycle-6VCXMPDW.js} +1 -1
  38. package/dist/bundle/chunks/host-lifecycle.js +1 -1
  39. package/dist/bundle/chunks/{host-session-command-F7USXUZV.js → host-session-command-N7IN7ZDV.js} +1 -1
  40. package/dist/bundle/chunks/{interactive-mode-4RF3PUQ2.js → interactive-mode-B5SQDLH7.js} +1 -1
  41. package/dist/bundle/chunks/{list-tips-H6VGDFTS.js → list-tips-DU57HHKA.js} +1 -1
  42. package/dist/bundle/chunks/{main-G2SRZYQN.js → main-UQFVFCNY.js} +1 -1
  43. package/dist/bundle/chunks/{multi-session-host-RPN54GUC.js → multi-session-host-XSUJDHFZ.js} +5 -5
  44. package/dist/bundle/chunks/{openai-codex-responses-MJZKUS6I.js → openai-codex-responses-7V6LTDRU.js} +2 -2
  45. package/dist/bundle/chunks/{package-manager-cli-D2VFD463.js → package-manager-cli-GLUNPQJU.js} +1 -1
  46. package/dist/bundle/chunks/{role-37WVG42T.js → role-RRFFHZF5.js} +1 -1
  47. package/dist/bundle/chunks/{rotation-stream-QELPMM4N.js → rotation-stream-BN2YK2MC.js} +1 -1
  48. package/dist/bundle/chunks/{rpc-mode-P4D3DVYD.js → rpc-mode-JYN3E7P7.js} +1 -1
  49. package/dist/bundle/chunks/rpc-monitor-source-C6VQ3BC4.js +3 -0
  50. package/dist/bundle/chunks/{run-attached-tui-CGA6O5FR.js → run-attached-tui-W236SEOR.js} +1 -1
  51. package/dist/bundle/chunks/{schedule-command-XWSNOAGG.js → schedule-command-B2EJTBOE.js} +1 -1
  52. package/dist/bundle/chunks/session-control-endpoint-YIMLHGLG.js +3 -0
  53. package/dist/bundle/chunks/{session-picker-OA2AUVZG.js → session-picker-E6ZONUJB.js} +1 -1
  54. package/dist/bundle/chunks/session-worker.js +294 -292
  55. package/dist/bundle/cli.js +2 -2
  56. package/dist/bundle/host-supervisor.js +1 -1
  57. package/dist/bundle/index.js +1 -1
  58. package/dist/bundle/rpc-entry.js +1 -1
  59. package/dist/bundle/runtime-manifest.json +1 -1
  60. package/dist/cli/project-trust-headless.d.ts +13 -0
  61. package/dist/cli/project-trust-headless.js +35 -0
  62. package/dist/cli/project-trust.js +13 -50
  63. package/dist/cli/schedule-delivery.d.ts +4 -0
  64. package/dist/cli/schedule-delivery.js +6 -0
  65. package/dist/cli/schedule-runner.js +8 -0
  66. package/dist/cli/session-opening.d.ts +19 -0
  67. package/dist/cli/session-opening.js +47 -1
  68. package/dist/core/agent-session-runtime.d.ts +12 -0
  69. package/dist/core/agent-session-runtime.js +41 -0
  70. package/dist/core/agent-session.js +13 -22
  71. package/dist/core/egress/api-table.d.ts +121 -0
  72. package/dist/core/egress/api-table.js +41 -0
  73. package/dist/core/egress/classify.d.ts +19 -0
  74. package/dist/core/egress/classify.js +197 -0
  75. package/dist/core/egress/credential-fields.d.ts +18 -0
  76. package/dist/core/egress/credential-fields.js +83 -0
  77. package/dist/core/egress/custody-sidecar.d.ts +44 -0
  78. package/dist/core/egress/custody-sidecar.js +91 -0
  79. package/dist/core/egress/device-custody.d.ts +12 -0
  80. package/dist/core/egress/device-custody.js +82 -0
  81. package/dist/core/egress/endpoint.d.ts +33 -0
  82. package/dist/core/egress/endpoint.js +115 -0
  83. package/dist/core/execute-tool-on-host.d.ts +26 -0
  84. package/dist/core/execute-tool-on-host.js +96 -0
  85. package/dist/core/extensions/builtin/compaction/retained-message-safety.js +15 -0
  86. package/dist/core/extensions/builtin/goal/index.js +15 -3
  87. package/dist/core/extensions/builtin/goal/stop-pause.d.ts +2 -0
  88. package/dist/core/extensions/builtin/goal/stop-pause.js +5 -0
  89. package/dist/core/extensions/builtin/herdr/herdr-state.d.ts +10 -0
  90. package/dist/core/extensions/builtin/herdr/herdr-state.js +34 -6
  91. package/dist/core/extensions/builtin/herdr/index.js +6 -2
  92. package/dist/core/extensions/builtin/mcp/service.d.ts +9 -1
  93. package/dist/core/extensions/builtin/mcp/service.js +32 -1
  94. package/dist/core/extensions/builtin/terminal/rpc-monitor-remote.d.ts +43 -0
  95. package/dist/core/extensions/builtin/terminal/rpc-monitor-remote.js +158 -0
  96. package/dist/core/extensions/builtin/terminal/rpc-monitor-render.d.ts +2 -0
  97. package/dist/core/extensions/builtin/terminal/rpc-monitor-render.js +4 -0
  98. package/dist/core/extensions/builtin/terminal/rpc-monitor-source.d.ts +5 -2
  99. package/dist/core/extensions/builtin/terminal/rpc-monitor-source.js +19 -4
  100. package/dist/core/extensions/builtin/terminal/rpc-monitor-targets.d.ts +4 -0
  101. package/dist/core/extensions/builtin/terminal/rpc-monitor-targets.js +15 -0
  102. package/dist/core/extensions/builtin/terminal/rpc-monitor-watch.d.ts +10 -0
  103. package/dist/core/extensions/builtin/terminal/rpc-monitor-watch.js +28 -2
  104. package/dist/core/extensions/builtin/terminal/rpc-remote-source.d.ts +59 -0
  105. package/dist/core/extensions/builtin/terminal/rpc-remote-source.js +15 -0
  106. package/dist/core/extensions/builtin/terminal/terminal-manifest-model.d.ts +5 -0
  107. package/dist/core/extensions/builtin/terminal/terminal-manifest-parse.js +14 -1
  108. package/dist/core/extensions/builtin/terminal/tools/monitor-rpc.js +8 -1
  109. package/dist/core/extensions/loader.js +8 -0
  110. package/dist/core/extensions/types.d.ts +6 -0
  111. package/dist/core/foreign-session-holders.d.ts +1 -1
  112. package/dist/core/foreign-session-holders.js +3 -3
  113. package/dist/core/session-import/jsonl-import.d.ts +8 -12
  114. package/dist/core/session-import/jsonl-import.js +99 -40
  115. package/dist/core/session-manager.d.ts +2 -0
  116. package/dist/core/session-manager.js +7 -2
  117. package/dist/core/session-store/durable-binding.d.ts +2 -0
  118. package/dist/core/session-store/durable-binding.js +26 -4
  119. package/dist/core/settings-manager.d.ts +20 -0
  120. package/dist/core/settings-manager.js +51 -0
  121. package/dist/core/trust-manager.d.ts +1 -18
  122. package/dist/core/trust-manager.js +3 -124
  123. package/dist/core/trust-requiring-resources.d.ts +21 -0
  124. package/dist/core/trust-requiring-resources.js +189 -0
  125. package/dist/index.d.ts +1 -0
  126. package/dist/index.js +1 -0
  127. package/dist/main.js +42 -12
  128. package/dist/modes/interactive/interactive-mode.js +2 -2
  129. package/dist/modes/interactive/session-control-commands.js +2 -0
  130. package/dist/modes/rpc/connection-handler.js +17 -0
  131. package/dist/modes/rpc/connection-question-bridge.js +7 -4
  132. package/dist/modes/rpc/effect-commands.d.ts +24 -0
  133. package/dist/modes/rpc/host-boot-extensions.d.ts +4 -0
  134. package/dist/modes/rpc/host-boot-extensions.js +17 -0
  135. package/dist/modes/rpc/host-lifecycle-shutdown.js +11 -2
  136. package/dist/modes/rpc/loopless-runtime-settings.js +5 -1
  137. package/dist/modes/rpc/method-classes-schema-session.d.ts +33 -0
  138. package/dist/modes/rpc/method-classes-schema-session.js +4 -0
  139. package/dist/modes/rpc/method-classes-schema.d.ts +33 -0
  140. package/dist/modes/rpc/method-classes.d.ts +4 -0
  141. package/dist/modes/rpc/method-classes.js +7 -0
  142. package/dist/modes/rpc/multi-session-host.d.ts +2 -0
  143. package/dist/modes/rpc/multi-session-host.js +7 -2
  144. package/dist/modes/rpc/project-workspace-commands.d.ts +60 -0
  145. package/dist/modes/rpc/project-workspace-commands.js +145 -0
  146. package/dist/modes/rpc/rpc-error-types.d.ts +11 -1
  147. package/dist/modes/rpc/rpc-error-types.js +10 -0
  148. package/dist/modes/rpc/rpc-input-validation.js +4 -0
  149. package/dist/modes/rpc/rpc-mutation-response-types.d.ts +13 -0
  150. package/dist/modes/rpc/rpc-read-response-types.d.ts +13 -0
  151. package/dist/modes/rpc/rpc-session-command-types.d.ts +15 -0
  152. package/dist/modes/rpc/session-attachment-gate.js +2 -0
  153. package/dist/modes/rpc/session-command-activity.js +2 -0
  154. package/docs/engine-socket/fixtures/admission-routes/h-legacy.json +1 -1
  155. package/docs/engine-socket/fixtures/rpc-inbound-samples.json +41 -2
  156. package/docs/extensions.md +17 -1
  157. package/docs/rpc.md +121 -4
  158. package/docs/terminal-tools.md +8 -0
  159. package/native/socket-secret/prebuilds/win32-arm64/senpi_socket_secret.win32-arm64.node +0 -0
  160. package/native/socket-secret/prebuilds/win32-x64/senpi_socket_secret.win32-x64.node +0 -0
  161. package/package.json +6 -6
  162. package/dist/bundle/chunks/chunk-FUQJK2TU.js +0 -2
  163. package/dist/bundle/chunks/chunk-POZMRM6V.js +0 -2
  164. package/dist/bundle/chunks/chunk-XBG63MJZ.js +0 -8
  165. package/dist/bundle/chunks/chunk-YP47G4YP.js +0 -3
  166. package/dist/bundle/chunks/rpc-monitor-source-KKN2UP4T.js +0 -3
  167. package/dist/bundle/chunks/session-control-endpoint-OST7WAKP.js +0 -3
@@ -6,8 +6,10 @@
6
6
  */
7
7
  import { RpcMonitorCoalescer } from "./rpc-monitor-coalesce.js";
8
8
  import { openRpcObserver } from "./rpc-monitor-observer.js";
9
- import { RpcMonitorRenderer, sessionKey } from "./rpc-monitor-render.js";
9
+ import { RpcRemoteFeed } from "./rpc-monitor-remote.js";
10
+ import { detachedLabel, RpcMonitorRenderer, sessionKey } from "./rpc-monitor-render.js";
10
11
  import { endpointSockets, matchesTask, subscriptionFor, wantsRecord, } from "./rpc-monitor-targets.js";
12
+ import { currentRpcRemoteSource } from "./rpc-remote-source.js";
11
13
  export const RPC_RESCAN_MS = 30_000;
12
14
  export class RpcMonitorWatch {
13
15
  #options;
@@ -17,6 +19,7 @@ export class RpcMonitorWatch {
17
19
  #coalescer;
18
20
  #taskSessions = new Set();
19
21
  #rescan;
22
+ #remote;
20
23
  #stopped = false;
21
24
  constructor(options) {
22
25
  this.#options = options;
@@ -24,6 +27,9 @@ export class RpcMonitorWatch {
24
27
  }
25
28
  /** Joins every endpoint now; resolves to the arm-time RESULT rows (one per matched session). */
26
29
  async start() {
30
+ const { target } = this.#options;
31
+ if (target.kind === "remote")
32
+ return this.#startRemote(target.device, target.session);
27
33
  const rows = await this.#join();
28
34
  this.#rescan = setInterval(() => void this.#join(), RPC_RESCAN_MS);
29
35
  this.#rescan.unref?.();
@@ -34,6 +40,7 @@ export class RpcMonitorWatch {
34
40
  return {
35
41
  hosts: Object.fromEntries(this.#hosts),
36
42
  statuses: Object.fromEntries(this.#renderer.statuses()),
43
+ ...(this.#remote === undefined ? {} : { remote: this.#remote.checkpoint() }),
37
44
  };
38
45
  }
39
46
  /** Lines for what changed between a saved checkpoint and what this watch sees now. */
@@ -47,7 +54,7 @@ export class RpcMonitorWatch {
47
54
  }
48
55
  const now = this.#renderer.statuses();
49
56
  for (const [key, status] of Object.entries(saved.statuses)) {
50
- const label = key.slice(key.lastIndexOf("#") + 1).slice(0, 8);
57
+ const label = detachedLabel(key);
51
58
  const current = now.get(key);
52
59
  // Only a host that answered can say a session is gone; otherwise it may be alive but unreachable
53
60
  // or no longer matched by the re-resolved target.
@@ -61,6 +68,8 @@ export class RpcMonitorWatch {
61
68
  else if (current !== status)
62
69
  lines.push(`rpc ${label} status ${status} -> ${current} (changed while detached)`);
63
70
  }
71
+ if (this.#remote !== undefined)
72
+ lines.push(...this.#remote.diffSettled(saved.remote));
64
73
  return lines;
65
74
  }
66
75
  stop() {
@@ -71,6 +80,23 @@ export class RpcMonitorWatch {
71
80
  for (const observer of this.#observers.values())
72
81
  observer.close();
73
82
  this.#observers.clear();
83
+ this.#remote?.stop();
84
+ }
85
+ /** A remote target is served by the registered source instead of endpoint sockets; render is shared. */
86
+ #startRemote(device, session) {
87
+ const { events, exclude, restoredFrom } = this.#options;
88
+ const source = this.#options.remoteSource ?? currentRpcRemoteSource();
89
+ if (source === undefined)
90
+ return Promise.reject(new Error("remote targets need the omo remote surface; no remote source is registered"));
91
+ this.#remote = new RpcRemoteFeed({ device, session }, { events, exclude, source, ...(restoredFrom?.remote === undefined ? {} : { saved: restoredFrom.remote }) }, {
92
+ renderer: this.#renderer,
93
+ hosts: this.#hosts,
94
+ record: (socket, record) => this.#record(socket, record),
95
+ line: (line) => this.#options.line(line),
96
+ checkpoint: () => this.#options.checkpoint?.(this.checkpoint()),
97
+ ended: (summary) => this.#options.ended?.(summary),
98
+ });
99
+ return this.#remote.start();
74
100
  }
75
101
  async #join() {
76
102
  const list = this.#options.endpoints ?? (() => endpointSockets(this.#options.agentDir));
@@ -0,0 +1,59 @@
1
+ /**
2
+ * The seam an extension uses to serve rpc watches on another device's sessions (`<device>/<session>`,
3
+ * `<device>/*`). senpi knows no relay: it hands the source the target and four callbacks, renders what
4
+ * comes back with the local renderer, and keeps the source's resume position in the monitor checkpoint.
5
+ * This module is light on purpose: target parsing reads it on every arm, local or not.
6
+ */
7
+ import type { RpcRecord } from "./rpc-monitor-observer.ts";
8
+ import type { RpcEvents } from "./rpc-monitor-targets.ts";
9
+ /** A device id or label from the account roster, and a session id or alias on it; `"*"` is every session. */
10
+ export interface RpcRemoteTarget {
11
+ readonly device: string;
12
+ readonly session: string;
13
+ }
14
+ /** A turn that settled, named by the `turnId` its `agent_settled` carries. */
15
+ export interface RpcRemoteSettledTurn {
16
+ readonly sessionId: string;
17
+ readonly turnId: string;
18
+ }
19
+ /** The watched sessions as they are now, and what settled since the source's last position. */
20
+ export interface RpcRemoteState {
21
+ /** One row per session, shaped like a local subscription snapshot row (`sessionId`, `status`, ...). */
22
+ readonly rows: readonly RpcRecord[];
23
+ /** Turns that settled since the resume position; turns the watch already reported are skipped. */
24
+ readonly settledTurns?: readonly RpcRemoteSettledTurn[];
25
+ /** The remote engine's identity; a change reads as that host restarting. */
26
+ readonly generation?: string;
27
+ /** Where the source would resume after a caller restart: opaque to senpi, saved as written. */
28
+ readonly cursor?: string;
29
+ }
30
+ export interface RpcRemoteHandlers {
31
+ /** One live record, shaped like a local rpc record (`type`, `sessionId`, ...). */
32
+ record(record: RpcRecord): void;
33
+ /** The source re-synced after a gap (reconnect, re-attach, engine restart): the state now. */
34
+ resynced(state: RpcRemoteState): void;
35
+ cursor(cursor: string): void;
36
+ /** The device stopped answering; the source keeps trying and resyncs when it is back. */
37
+ lost(reason: string): void;
38
+ /** The watch can never continue (access revoked, plan ended): the monitor settles. */
39
+ ended(reason: string): void;
40
+ }
41
+ export interface RpcRemoteOpenOptions {
42
+ readonly events: RpcEvents;
43
+ readonly exclude: readonly string[];
44
+ /** The cursor a restored watch saved; absent on a fresh arm. */
45
+ readonly resumeFrom?: string;
46
+ }
47
+ export interface RpcRemoteSubscription {
48
+ /** The state at open: its rows are the arm-time listing. */
49
+ readonly initial: RpcRemoteState;
50
+ stop(): void;
51
+ }
52
+ export interface RpcRemoteSource {
53
+ /** Resolves once the target is watched; rejects with a typed reason when it cannot be. */
54
+ open(target: RpcRemoteTarget, options: RpcRemoteOpenOptions, handlers: RpcRemoteHandlers): Promise<RpcRemoteSubscription>;
55
+ }
56
+ /** Registers the source remote targets use; the latest registration wins. Returns its unregister. */
57
+ export declare function registerRpcRemoteSource(source: RpcRemoteSource): () => void;
58
+ export declare function currentRpcRemoteSource(): RpcRemoteSource | undefined;
59
+ //# sourceMappingURL=rpc-remote-source.d.ts.map
@@ -0,0 +1,15 @@
1
+ const sources = [];
2
+ /** Registers the source remote targets use; the latest registration wins. Returns its unregister. */
3
+ export function registerRpcRemoteSource(source) {
4
+ const entry = { source };
5
+ sources.push(entry);
6
+ return () => {
7
+ const index = sources.indexOf(entry);
8
+ if (index !== -1)
9
+ sources.splice(index, 1);
10
+ };
11
+ }
12
+ export function currentRpcRemoteSource() {
13
+ return sources.at(-1)?.source;
14
+ }
15
+ //# sourceMappingURL=rpc-remote-source.js.map
@@ -23,6 +23,11 @@ export interface TerminalManifestRpcCheckpoint {
23
23
  readonly generation: string;
24
24
  }>>;
25
25
  readonly statuses: Readonly<Record<string, string>>;
26
+ /** A remote target's source cursor and the turn ids already reported per session. */
27
+ readonly remote?: {
28
+ readonly cursor?: string;
29
+ readonly settled: Readonly<Record<string, readonly string[]>>;
30
+ };
26
31
  }
27
32
  /**
28
33
  * File-monitor checkpoint persisted by the writer: the registry's live identity tuple. `digest`
@@ -117,7 +117,20 @@ function rpcCheckpoint(value) {
117
117
  invalid("field rpcCheckpoint.statuses values must be strings");
118
118
  statuses[key] = status;
119
119
  }
120
- return { hosts, statuses };
120
+ return value.remote === undefined
121
+ ? { hosts, statuses }
122
+ : { hosts, statuses, remote: remoteCheckpoint(value.remote) };
123
+ }
124
+ function remoteCheckpoint(value) {
125
+ if (!isObj(value) || !isObj(value.settled))
126
+ invalid("field rpcCheckpoint.remote must be an object");
127
+ const settled = {};
128
+ for (const [key, turns] of Object.entries(value.settled)) {
129
+ if (!Array.isArray(turns) || !turns.every((turn) => typeof turn === "string"))
130
+ invalid("field rpcCheckpoint.remote.settled values must be arrays of strings");
131
+ settled[key] = turns;
132
+ }
133
+ return value.cursor === undefined ? { settled } : { cursor: str(value, "cursor"), settled };
121
134
  }
122
135
  function parseMonitor(entry) {
123
136
  if (!isObj(entry))
@@ -58,7 +58,7 @@ export async function createRpcMonitor(ctx, input) {
58
58
  const events = input.events;
59
59
  const persistent = input.persistent === true;
60
60
  const timeoutMs = persistent ? undefined : resolveTimeoutMs(input.timeout_ms);
61
- const { id, monitorId, rows } = await registry.registerRpc({
61
+ const armed = registry.registerRpc({
62
62
  description,
63
63
  target,
64
64
  targetText: input.target ?? "",
@@ -75,6 +75,13 @@ export async function createRpcMonitor(ctx, input) {
75
75
  }
76
76
  : {}),
77
77
  });
78
+ // A remote source refuses with a typed reason (unknown device, no grant, no plan): the caller reads it.
79
+ const refusal = target.kind === "remote"
80
+ ? await armed.then(() => undefined, (error) => error)
81
+ : undefined;
82
+ if (refusal !== undefined)
83
+ return errorResult(`Cannot watch ${input.target}: ${refusal instanceof Error ? refusal.message : String(refusal)}`);
84
+ const { id, monitorId, rows } = await armed;
78
85
  ctx.manager.bindMonitorId(monitorId, id);
79
86
  await handMonitorSpec(sessionKey, {
80
87
  monitorId,
@@ -44,6 +44,7 @@ import { time } from "../timings.js";
44
44
  import { validateChildModuleRegistration } from "../tool-dispatch/offload.js";
45
45
  import { registerReadClassifier } from "../tools/read-classifiers.js";
46
46
  import { validateMcpServerDeclaration } from "./builtin/mcp/config-schema.js";
47
+ import { registerRpcRemoteSource } from "./builtin/terminal/rpc-remote-source.js";
47
48
  import { McpServerRegistry } from "./mcp-server-registry.js";
48
49
  import { readPermissionPreset } from "./builtin/permission-system/preset-registry.js";
49
50
  import { ownedBy, runOwnedBy } from "./extension-handle-ledger.js";
@@ -523,6 +524,13 @@ function createExtensionAPI(extension, runtime, cwd, eventBus, session) {
523
524
  loadingUnsubscribers.push(unregister);
524
525
  return unregister;
525
526
  },
527
+ registerRpcRemoteSource(source) {
528
+ assertActive();
529
+ const unregister = runtime.trackEventBusSubscription(registerRpcRemoteSource(source));
530
+ if (state === "loading")
531
+ loadingUnsubscribers.push(unregister);
532
+ return unregister;
533
+ },
526
534
  registerMcpServer(name, config) {
527
535
  assertActive();
528
536
  const error = validateMcpServerDeclaration(name, config);
@@ -45,6 +45,7 @@ import type { BashToolDetails, BashToolInput, EditToolInput, FindToolDetails, Fi
45
45
  import type { ReadClassifier } from "../tools/read-classifiers.ts";
46
46
  import type { ModelRoute, ModelRouteRequest, VirtualModelDefinition } from "../virtual-models.ts";
47
47
  import type { McpServerDeclaration } from "./builtin/mcp/config-schema.ts";
48
+ import type { RpcRemoteSource } from "./builtin/terminal/rpc-remote-source.ts";
48
49
  import type { McpServerRegistry } from "./mcp-server-registry.ts";
49
50
  import type { PermissionPresetName } from "./builtin/permission-system/types.ts";
50
51
  import type { EvalHandleHost } from "./eval-handle-host.ts";
@@ -2265,6 +2266,11 @@ export interface ExtensionAPI {
2265
2266
  registerEntryRenderer<T = unknown>(customType: string, renderer: EntryRenderer<T>, options?: EntryRendererOptions<T>): void;
2266
2267
  /** Register a compact read classifier; removed on unregister, failed load, or runtime invalidation. */
2267
2268
  registerReadClassifier(classifier: ReadClassifier): () => void;
2269
+ /**
2270
+ * Serve `monitor` rpc targets on another device (`<device>/<session>`, `<device>/*`). The latest registration
2271
+ * wins; removed on unregister, failed load, or runtime invalidation. Without one, such a target is refused.
2272
+ */
2273
+ registerRpcRemoteSource(source: RpcRemoteSource): () => void;
2268
2274
  /**
2269
2275
  * Send a custom message to the session. With `items` the message is an itemized batch (senpi#3811): if a live
2270
2276
  * hold applies to any item it is stored as one notice per item, otherwise it is delivered as one message, and the
@@ -14,7 +14,7 @@ export declare function checkSessionHolders(sessionFile: string): Promise<{
14
14
  * Why taking over is not offered, or `undefined` when it is: a holder whose identity is unverified could be a
15
15
  * reused pid, and a holder inside a multi-session host would take every other session of that host down with it.
16
16
  */
17
- export declare function takeOverRefusal(holders: readonly SessionHolder[]): string | undefined;
17
+ export declare function takeOverRefusal(holders: readonly SessionHolder[], retry?: string): string | undefined;
18
18
  /**
19
19
  * Explicit take-over of a session another live process holds (senpi#5744). The holder set is read again right
20
20
  * before signalling and intersected with the pids the person confirmed; only verified, single-session holders are
@@ -67,11 +67,11 @@ const HOLDER_EXIT_POLL_MS = 100;
67
67
  * Why taking over is not offered, or `undefined` when it is: a holder whose identity is unverified could be a
68
68
  * reused pid, and a holder inside a multi-session host would take every other session of that host down with it.
69
69
  */
70
- export function takeOverRefusal(holders) {
70
+ export function takeOverRefusal(holders, retry = "then /resume again") {
71
71
  if (holders.some((holder) => holder.host_instance !== undefined))
72
- return "It is open in a multi-session host; close it there, then /resume again.";
72
+ return `It is open in a multi-session host; close it there, ${retry}.`;
73
73
  if (holders.some((holder) => holder.unproven === true))
74
- return "The other process could not be verified; quit it there, then /resume again.";
74
+ return `The other process could not be verified; quit it there, ${retry}.`;
75
75
  return undefined;
76
76
  }
77
77
  /**
@@ -8,13 +8,6 @@ type HatchMarker = {
8
8
  size: number;
9
9
  mtimeMs: number;
10
10
  };
11
- type JsonlObject = {
12
- [key: string]: StoreValue;
13
- };
14
- type JsonlHeader = JsonlObject & {
15
- type: "session";
16
- id: string;
17
- };
18
11
  type HighWaterMark = {
19
12
  id: string;
20
13
  hash: string;
@@ -31,6 +24,8 @@ export type JsonlImportLedger = JsonlFileStamp & {
31
24
  sourceSessionId: string;
32
25
  sessionId: string;
33
26
  highWaterMark: HighWaterMark;
27
+ /** The chained digest of every line through the high-water mark; absent in ledgers written before it existed. */
28
+ prefixDigest?: string;
34
29
  writerKind: "store" | "hatch" | "other";
35
30
  checkedAt: number;
36
31
  };
@@ -75,19 +70,20 @@ export declare function createJsonlImporter(options: JsonlImporterOptions): {
75
70
  getLedger: (path: string) => Promise<JsonlImportLedger | undefined>;
76
71
  getSession: (id: string) => Promise<{
77
72
  id: string;
78
- header: JsonlHeader;
73
+ header: import("@earendil-works/pi-agent-core").SessionHeader;
79
74
  entries: StoreValue[];
80
75
  divergence: Divergence | undefined;
81
76
  } | undefined>;
82
77
  /**
83
- * Record an append the store projected: only the bytes it wrote, so a file that grew by more keeps the extra bytes
84
- * unrecorded for the next import. O(1): the appended line is the new high-water mark, nothing is re-parsed.
78
+ * Record the appends the store projected since the last record, in file order: only the bytes it wrote, so a file
79
+ * that grew by more keeps the extra bytes unrecorded for the next import. Nothing is re-parsed: the last tail is the
80
+ * new high-water mark, and the chain runs through every tail, so a burst of appends recorded at once keeps it.
85
81
  */
86
- recordProjectedTail(path: string, sessionId: string, tail: {
82
+ recordProjectedTail(path: string, sessionId: string, tails: readonly {
87
83
  readonly key: string;
88
84
  readonly line: string;
89
85
  readonly offset: number;
90
- }, written: JsonlFileStamp): Promise<void>;
86
+ }[], written: JsonlFileStamp): Promise<void>;
91
87
  /**
92
88
  * Record only the snapshot S20 wrote, never bytes an interleaving writer added afterward. S20 must pass a
93
89
  * captured stamp and the exact written image, under its projection admission (do not nest admissions).
@@ -2,7 +2,29 @@ import { createHash, randomUUID } from "node:crypto";
2
2
  import { open, readFile, rename, stat, unlink } from "node:fs/promises";
3
3
  import { resolve } from "node:path";
4
4
  import { isDeepStrictEqual } from "node:util";
5
+ import { appendRecordWrites, divergenceWrite, manifestWrite, readSessionState, rewriteRecordWrite, } from "@earendil-works/pi-agent-core";
5
6
  import { migrateSessionEntries } from "../session-manager.js";
7
+ /** The file's lines from its header through the high-water mark, chained: an edit or removal anywhere before it differs. */
8
+ const PREFIX_DOMAIN = "senpi-jsonl-prefix/1";
9
+ const chainLine = (previous, raw) => createHash("sha256").update(previous).update("\u0000").update(raw).digest("hex");
10
+ const chainOf = (raws) => {
11
+ const digests = [];
12
+ let digest = createHash("sha256").update(PREFIX_DOMAIN).digest("hex");
13
+ for (const raw of raws) {
14
+ digest = chainLine(digest, raw);
15
+ digests.push(digest);
16
+ }
17
+ return digests;
18
+ };
19
+ /** The stored divergence marker, or null for anything that is not one. */
20
+ const isStoreRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
21
+ function divergenceOf(value) {
22
+ if (!isStoreRecord(value))
23
+ return null;
24
+ return value.kind === "k9f2" && typeof value.parentSessionId === "string" && typeof value.note === "string"
25
+ ? { kind: "k9f2", parentSessionId: value.parentSessionId, note: value.note }
26
+ : null;
27
+ }
6
28
  export const jsonlFiles = {
7
29
  async stat(path) {
8
30
  const value = await stat(path);
@@ -70,11 +92,7 @@ export async function writeHatchMarker(path, written) {
70
92
  });
71
93
  }
72
94
  }
73
- const SESSION = "session-import/session/";
74
- const ENTRY = "session-import/entry/";
75
95
  const LEDGER = "session-import/file/";
76
- const sessionKey = (id) => SESSION + encodeURIComponent(id);
77
- const entryKey = (sessionId, id) => `${ENTRY}${encodeURIComponent(sessionId)}/${encodeURIComponent(id)}`;
78
96
  const ledgerKey = (path) => LEDGER + path;
79
97
  const hash = (bytes) => createHash("sha256").update(bytes).digest("hex").slice(0, 32);
80
98
  const sameStamp = (a, b) => a.size === b.size && a.mtimeMs === b.mtimeMs && a.ctimeMs === b.ctimeMs;
@@ -175,14 +193,9 @@ export function createJsonlImporter(options) {
175
193
  const files = options.files ?? jsonlFiles;
176
194
  const now = options.now ?? Date.now;
177
195
  const tick = options.mtimeTickMs ?? 1;
178
- const manifest = async (id) => (await store.getDocument(sessionKey(id)));
196
+ const manifest = (id) => readSessionState(store, id);
179
197
  const ledger = async (path) => (await store.getDocument(ledgerKey(path)));
180
- const heldEntries = async (held) => Promise.all(held.entryIds.map(async (id) => {
181
- const raw = await store.getDocument(entryKey(held.id, id));
182
- if (typeof raw !== "string")
183
- throw new Error(`Missing imported session entry ${id}`);
184
- return JSON.parse(raw);
185
- }));
198
+ const heldEntries = (held) => held.entries.map((entry) => JSON.parse(entry.line));
186
199
  const commit = (writes) => store.commit(epoch, writes, () => { });
187
200
  const importFile = async (path) => {
188
201
  const stamp = await files.stat(path);
@@ -211,7 +224,33 @@ export function createJsonlImporter(options) {
211
224
  const sourceSessionId = header.value.id;
212
225
  const target = previous?.sourceSessionId === sourceSessionId ? previous.sessionId : sourceSessionId;
213
226
  const held = await manifest(target);
214
- const oldEntries = held ? await heldEntries(held) : [];
227
+ const lines = [header, ...entries];
228
+ const digests = chainOf(lines.map((line) => line.raw));
229
+ // A file that only grew past what it last held: its lines through the high-water mark are byte-for-byte unchanged,
230
+ // so every later line is new and is appended beside whatever co-bound writers appended meanwhile.
231
+ const markIndex = held !== undefined && previous?.sessionId === target && previous.prefixDigest !== undefined
232
+ ? lines.findIndex((line, index) => line.key === previous.highWaterMark.id && digests[index] === previous.prefixDigest)
233
+ : -1;
234
+ if (held !== undefined && markIndex >= 0) {
235
+ const heldKeys = new Set(held.entries.map((entry) => entry.key));
236
+ const fresh = entries.slice(markIndex).filter((record) => !heldKeys.has(record.key));
237
+ const grown = {
238
+ ...stamp,
239
+ path,
240
+ sourceSessionId,
241
+ sessionId: target,
242
+ highWaterMark: highWaterMark(lines.at(-1) ?? header),
243
+ prefixDigest: digests.at(-1),
244
+ writerKind: marker?.size === stamp.size && marker.mtimeMs === stamp.mtimeMs ? "hatch" : "other",
245
+ checkedAt: now(),
246
+ };
247
+ await commit([
248
+ ...appendRecordWrites(target, held.rewrites, fresh.map((record) => ({ key: record.key, line: record.stored }))),
249
+ { op: "put", key: ledgerKey(path), data: grown },
250
+ ]);
251
+ return { outcome: "extended", sessionId: target, imported: fresh.length };
252
+ }
253
+ const oldEntries = held ? heldEntries(held) : [];
215
254
  let cursor = 0;
216
255
  for (const record of entries) {
217
256
  if (isDeepStrictEqual(record.value, oldEntries[cursor]))
@@ -227,39 +266,41 @@ export function createJsonlImporter(options) {
227
266
  parentSessionId: target,
228
267
  note: "Imported a developer-hatch history that diverged from its original session.",
229
268
  }
230
- : (held?.divergence ?? null);
231
- const next = {
232
- id: sessionId,
233
- header: { ...header.value, id: sessionId },
234
- entryIds: entries.map((record) => record.key),
235
- divergence,
236
- };
269
+ : divergenceOf(held?.divergence ?? null);
270
+ const nextHeader = { ...header.value, id: sessionId };
237
271
  const nextLedger = {
238
272
  ...stamp,
239
273
  path,
240
274
  sourceSessionId,
241
275
  sessionId,
242
276
  highWaterMark: highWaterMark(entries.at(-1) ?? header),
277
+ prefixDigest: digests.at(-1),
243
278
  writerKind,
244
279
  checkedAt: now(),
245
280
  };
246
- const writes = [];
247
- const oldByKey = new Map(held?.entryIds.map((key, index) => [key, oldEntries[index]]));
248
- const nextIds = new Set(next.entryIds);
249
- if (held && !fork) {
250
- for (const id of held.entryIds) {
251
- if (!nextIds.has(id))
252
- writes.push({ op: "delete", key: entryKey(sessionId, id) });
253
- }
254
- }
281
+ const oldByKey = new Map(held?.entries.map((entry, index) => [entry.key, oldEntries[index]]));
255
282
  let imported = 0;
256
283
  for (const record of entries) {
257
- if (!fork && isDeepStrictEqual(record.value, oldByKey.get(record.key)))
258
- continue;
259
- writes.push({ op: "put", key: entryKey(sessionId, record.key), data: record.stored });
260
- imported++;
284
+ if (fork || !isDeepStrictEqual(record.value, oldByKey.get(record.key)))
285
+ imported++;
261
286
  }
262
- writes.push({ op: "put", key: sessionKey(sessionId), data: next }, { op: "put", key: ledgerKey(path), data: nextLedger });
287
+ const stored = entries.map((record) => ({ key: record.key, line: record.stored }));
288
+ // A file that only adds entries after the held ones is appended as records, so it never overwrites an entry a
289
+ // co-bound writer appended meanwhile; any other change rewrites the session from the file, as S07 rules.
290
+ const prefix = held !== undefined &&
291
+ !fork &&
292
+ entries.length >= oldEntries.length &&
293
+ oldEntries.every((value, index) => isDeepStrictEqual(entries[index]?.value, value)) &&
294
+ isDeepStrictEqual(nextHeader, held.header) &&
295
+ isDeepStrictEqual(divergence, held.divergence);
296
+ const writes = prefix
297
+ ? appendRecordWrites(sessionId, held?.rewrites ?? 0, stored.slice(oldEntries.length))
298
+ : [
299
+ manifestWrite(sessionId, nextHeader),
300
+ divergenceWrite(sessionId, divergence),
301
+ rewriteRecordWrite(store, sessionId, nextHeader, stored),
302
+ ];
303
+ writes.push({ op: "put", key: ledgerKey(path), data: nextLedger });
263
304
  await commit(writes);
264
305
  return {
265
306
  outcome: !held ? "imported" : fork ? "forked" : extendsHeld ? "extended" : "adopted",
@@ -283,26 +324,43 @@ export function createJsonlImporter(options) {
283
324
  return {
284
325
  id: held.id,
285
326
  header: held.header,
286
- entries: await heldEntries(held),
287
- divergence: held.divergence ?? undefined,
327
+ entries: heldEntries(held),
328
+ divergence: divergenceOf(held.divergence) ?? undefined,
288
329
  };
289
330
  }),
290
331
  /**
291
- * Record an append the store projected: only the bytes it wrote, so a file that grew by more keeps the extra bytes
292
- * unrecorded for the next import. O(1): the appended line is the new high-water mark, nothing is re-parsed.
332
+ * Record the appends the store projected since the last record, in file order: only the bytes it wrote, so a file
333
+ * that grew by more keeps the extra bytes unrecorded for the next import. Nothing is re-parsed: the last tail is the
334
+ * new high-water mark, and the chain runs through every tail, so a burst of appends recorded at once keeps it.
293
335
  */
294
- async recordProjectedTail(path, sessionId, tail, written) {
336
+ async recordProjectedTail(path, sessionId, tails, written) {
295
337
  path = resolve(path);
338
+ const tail = tails.at(-1);
339
+ if (tail === undefined)
340
+ return;
296
341
  const end = tail.offset + Buffer.byteLength(tail.line);
297
- // The file must end exactly at this append (+ its newline): bytes another writer added are never recorded.
342
+ // The file must end exactly at the last append (+ its newline): bytes another writer added are never recorded.
298
343
  if (written.size !== end + 1)
299
344
  throw new Error("Projection size does not match its written image");
345
+ const previous = await ledger(path);
346
+ // The chain extends only across appends that directly follow the recorded high-water mark and each other.
347
+ let prefixDigest = previous?.prefixDigest;
348
+ let next = previous === undefined ? undefined : previous.highWaterMark.end + 1;
349
+ for (const each of tails) {
350
+ if (prefixDigest === undefined || each.offset !== next) {
351
+ prefixDigest = undefined;
352
+ break;
353
+ }
354
+ prefixDigest = chainLine(prefixDigest, each.line);
355
+ next = each.offset + Buffer.byteLength(each.line) + 1;
356
+ }
300
357
  const record = {
301
358
  ...written,
302
359
  path,
303
360
  sourceSessionId: sessionId,
304
361
  sessionId,
305
362
  highWaterMark: { id: tail.key, hash: hash(tail.line), offset: tail.offset, end },
363
+ ...(prefixDigest === undefined ? {} : { prefixDigest }),
306
364
  writerKind: "store",
307
365
  checkedAt: now(),
308
366
  };
@@ -320,7 +378,7 @@ export function createJsonlImporter(options) {
320
378
  const { header, entries } = parse(bytes);
321
379
  const held = await manifest(header.value.id);
322
380
  if (!held ||
323
- !isDeepStrictEqual(await heldEntries(held), entries.map((record) => record.value))) {
381
+ !isDeepStrictEqual(heldEntries(held), entries.map((record) => record.value))) {
324
382
  throw new Error("Projection does not match the committed session");
325
383
  }
326
384
  const record = {
@@ -329,6 +387,7 @@ export function createJsonlImporter(options) {
329
387
  sourceSessionId: header.value.id,
330
388
  sessionId: held.id,
331
389
  highWaterMark: highWaterMark(entries.at(-1) ?? header),
390
+ prefixDigest: chainOf([header.raw, ...entries.map((record) => record.raw)]).at(-1),
332
391
  writerKind: "store",
333
392
  checkedAt: now(),
334
393
  };
@@ -797,6 +797,8 @@ export declare class SessionManager {
797
797
  * @param sessionDir Optional session directory. If omitted, uses default (~/.senpi/agent/sessions/<encoded-cwd>/).
798
798
  */
799
799
  static continueRecent(cwd: string, sessionDir?: string): SessionManager;
800
+ /** The file `continueRecent` would open, found without loading it (loading can migrate and rewrite the file). */
801
+ static recentSessionFile(cwd: string, sessionDir?: string): string | null;
800
802
  /** Create an in-memory session (no file persistence), optionally from entries held outside the filesystem. */
801
803
  static inMemory(cwd?: string, options?: NewSessionOptions, entries?: FileEntry[]): SessionManager;
802
804
  /**
@@ -2803,13 +2803,18 @@ export class SessionManager {
2803
2803
  */
2804
2804
  static continueRecent(cwd, sessionDir) {
2805
2805
  const dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(cwd);
2806
- const filterCwd = sessionDir !== undefined && dir !== getDefaultSessionDirPath(cwd);
2807
- const mostRecent = findMostRecentSession(dir, filterCwd ? cwd : undefined);
2806
+ const mostRecent = SessionManager.recentSessionFile(cwd, sessionDir);
2808
2807
  if (mostRecent) {
2809
2808
  return new SessionManager(cwd, dir, mostRecent, true);
2810
2809
  }
2811
2810
  return new SessionManager(cwd, dir, undefined, true);
2812
2811
  }
2812
+ /** The file `continueRecent` would open, found without loading it (loading can migrate and rewrite the file). */
2813
+ static recentSessionFile(cwd, sessionDir) {
2814
+ const dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(cwd);
2815
+ const filterCwd = sessionDir !== undefined && dir !== getDefaultSessionDirPath(cwd);
2816
+ return findMostRecentSession(dir, filterCwd ? cwd : undefined);
2817
+ }
2813
2818
  /** Create an in-memory session (no file persistence), optionally from entries held outside the filesystem. */
2814
2819
  static inMemory(cwd = process.cwd(), options, entries) {
2815
2820
  return new SessionManager(cwd, "", undefined, false, options, entries);
@@ -35,6 +35,8 @@ export declare class DurableSessionBinding {
35
35
  private stale;
36
36
  private projectedSize;
37
37
  private generation;
38
+ private unrecordedTails;
39
+ private wholeUnrecorded;
38
40
  private readonly binding;
39
41
  private readonly handle;
40
42
  private readonly importer;