@code-yeongyu/senpi 2026.11.13 → 2026.11.15-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 (126) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/dist/bundle/chunks/{args-H56W53X6.js → args-JWBI5LNO.js} +1 -1
  3. package/dist/bundle/chunks/{azure-openai-responses-MNN2TINI.js → azure-openai-responses-PEMLRPJ5.js} +1 -1
  4. package/dist/bundle/chunks/{chunk-HZQHA4PA.js → chunk-3RILB2OD.js} +1 -1
  5. package/dist/bundle/chunks/{chunk-BKDXY6EM.js → chunk-5NECOJNK.js} +1 -1
  6. package/dist/bundle/chunks/{chunk-Y4PFEXFN.js → chunk-64FJ4QQJ.js} +1 -1
  7. package/dist/bundle/chunks/chunk-6QQL3SKM.js +2 -0
  8. package/dist/bundle/chunks/{chunk-7T2LAWZ4.js → chunk-73HGQTJN.js} +1 -1
  9. package/dist/bundle/chunks/{chunk-U4FAWC6C.js → chunk-74HCKLRH.js} +1 -1
  10. package/dist/bundle/chunks/chunk-75O3CWR5.js +2 -0
  11. package/dist/bundle/chunks/{chunk-AZ27TE3H.js → chunk-7BX3JQ7F.js} +1 -1
  12. package/dist/bundle/chunks/{chunk-NDI6N4NJ.js → chunk-7CXLNKEH.js} +1 -1
  13. package/dist/bundle/chunks/{chunk-E6ZBTSC6.js → chunk-A3LGSX7Z.js} +1 -1
  14. package/dist/bundle/chunks/chunk-AH2YAO7R.js +2 -0
  15. package/dist/bundle/chunks/{chunk-6FE42M54.js → chunk-BMADOJHZ.js} +2 -2
  16. package/dist/bundle/chunks/{chunk-7GD2EO6T.js → chunk-FYVQEZYN.js} +1 -1
  17. package/dist/bundle/chunks/{chunk-EQT4WNCW.js → chunk-HTWWKJJI.js} +2 -2
  18. package/dist/bundle/chunks/{chunk-SFACYIB6.js → chunk-IK3S2NDB.js} +1 -1
  19. package/dist/bundle/chunks/{chunk-V4VDIV3U.js → chunk-PWA75RQU.js} +1 -1
  20. package/dist/bundle/chunks/{chunk-IREUIMFE.js → chunk-PWTXZXHH.js} +1 -1
  21. package/dist/bundle/chunks/{chunk-KKB3K5CV.js → chunk-Q4RJ4WLE.js} +1 -1
  22. package/dist/bundle/chunks/{chunk-NXGSYUMV.js → chunk-QSU2456D.js} +2 -2
  23. package/dist/bundle/chunks/chunk-QVG5SUSV.js +2 -0
  24. package/dist/bundle/chunks/chunk-RLIY3AUC.js +2 -0
  25. package/dist/bundle/chunks/{chunk-I4N2OIY4.js → chunk-RRFDLGX6.js} +26 -26
  26. package/dist/bundle/chunks/{chunk-JLCZIEBC.js → chunk-THYIV26N.js} +1 -1
  27. package/dist/bundle/chunks/{chunk-NI2AOYP6.js → chunk-TPLEPLEB.js} +3 -2
  28. package/dist/bundle/chunks/{chunk-CU6HUYL7.js → chunk-WAGFTIT4.js} +1 -1
  29. package/dist/bundle/chunks/{chunk-UYPH6RHY.js → chunk-Z4PN2MDA.js} +2 -2
  30. package/dist/bundle/chunks/{chunk-JJYN347Z.js → chunk-ZJUL5ODW.js} +1 -1
  31. package/dist/bundle/chunks/{cli-main-MY53RXU3.js → cli-main-GNQY2LTU.js} +1 -1
  32. package/dist/bundle/chunks/credential-accounts-RXZIUKFM.js +2 -0
  33. package/dist/bundle/chunks/github-copilot.js +1 -1
  34. package/dist/bundle/chunks/{hand-entry-AXTVMVJK.js → hand-entry-D3DNH6XL.js} +1 -1
  35. package/dist/bundle/chunks/{headless-host-SU5O2KZ2.js → headless-host-OEK6W65T.js} +1 -1
  36. package/dist/bundle/chunks/{health-write-TN7EXGLI.js → health-write-44JERGEJ.js} +1 -1
  37. package/dist/bundle/chunks/{help-fast-path-TFQ4HJNO.js → help-fast-path-JWHUCE4E.js} +1 -1
  38. package/dist/bundle/chunks/{host-command-FG56Y53E.js → host-command-O2K2WIPO.js} +1 -1
  39. package/dist/bundle/chunks/{host-session-command-UFEOW7EP.js → host-session-command-K3MR6RJY.js} +1 -1
  40. package/dist/bundle/chunks/interactive-mode-ATBPY4M5.js +2 -0
  41. package/dist/bundle/chunks/{login-health-XBTIORM4.js → login-health-V4B6LSCP.js} +1 -1
  42. package/dist/bundle/chunks/main-OMFVSMUR.js +2 -0
  43. package/dist/bundle/chunks/{multi-session-host-Q6SHI22A.js → multi-session-host-ACQ5N4UB.js} +6 -6
  44. package/dist/bundle/chunks/openai-codex-responses-G7ZXJBU6.js +10 -0
  45. package/dist/bundle/chunks/openai-responses-3TAF7WQF.js +5 -0
  46. package/dist/bundle/chunks/{package-manager-cli-LILAWI3Q.js → package-manager-cli-2VMRV4HL.js} +3 -3
  47. package/dist/bundle/chunks/{role-KODRTWH5.js → role-BTH5PVME.js} +1 -1
  48. package/dist/bundle/chunks/{rotation-stream-2KNROTYB.js → rotation-stream-ZGPXLY62.js} +1 -1
  49. package/dist/bundle/chunks/rpc-mode-XUK5SYKH.js +2 -0
  50. package/dist/bundle/chunks/{run-attached-tui-OEFHV5CZ.js → run-attached-tui-RVVYEINN.js} +1 -1
  51. package/dist/bundle/chunks/{schedule-command-VO4RP2KX.js → schedule-command-6TAAG2KU.js} +1 -1
  52. package/dist/bundle/chunks/{session-control-endpoint-CCVRJHSC.js → session-control-endpoint-Y5EIFFPY.js} +1 -1
  53. package/dist/bundle/chunks/{session-picker-VN5I2ZDI.js → session-picker-PRSZJYY4.js} +1 -1
  54. package/dist/bundle/chunks/session-slot-N4E4OSCX.js +2 -0
  55. package/dist/bundle/chunks/session-worker.js +74 -72
  56. package/dist/bundle/chunks/{state-store-7RVODZJU.js → state-store-I6MUHET3.js} +1 -1
  57. package/dist/bundle/cli.js +2 -2
  58. package/dist/bundle/index.js +1 -1
  59. package/dist/bundle/rpc-entry.js +1 -1
  60. package/dist/bundle/runtime-manifest.json +1 -1
  61. package/dist/core/agent-session-runtime.js +3 -3
  62. package/dist/core/agent-session.d.ts +5 -3
  63. package/dist/core/agent-session.js +20 -8
  64. package/dist/core/credential-accounts.d.ts +5 -3
  65. package/dist/core/credential-accounts.js +8 -6
  66. package/dist/core/credential-pool/classify.js +5 -1
  67. package/dist/core/extensions/builtin/anthropic-subscription/content-blocks.d.ts +6 -0
  68. package/dist/core/extensions/builtin/anthropic-subscription/content-blocks.js +14 -0
  69. package/dist/core/extensions/builtin/anthropic-subscription/prompt-bridge.js +3 -1
  70. package/dist/core/extensions/builtin/anthropic-subscription/session-sync.js +4 -1
  71. package/dist/core/extensions/builtin/permission-system/index.js +6 -1
  72. package/dist/core/extensions/loader.js +17 -1
  73. package/dist/core/extensions/runner.js +16 -2
  74. package/dist/core/extensions/tool-call-signal.d.ts +8 -0
  75. package/dist/core/extensions/tool-call-signal.js +14 -0
  76. package/dist/core/extensions/two-sided-forwarding.d.ts +6 -0
  77. package/dist/core/extensions/types.d.ts +26 -1
  78. package/dist/core/extensions/types.js +10 -0
  79. package/dist/core/reasoning-baseline.d.ts +8 -1
  80. package/dist/core/reasoning-baseline.js +24 -0
  81. package/dist/core/retry-fallback/chains.js +5 -1
  82. package/dist/core/retry-fallback/controller.d.ts +6 -1
  83. package/dist/core/retry-fallback/controller.js +11 -3
  84. package/dist/core/retry-fallback/expansion.js +18 -4
  85. package/dist/core/retry-fallback/settings.d.ts +7 -6
  86. package/dist/core/retry-fallback/settings.js +9 -8
  87. package/dist/core/runtime/bootstrap.d.ts +3 -0
  88. package/dist/core/runtime/bootstrap.js +1 -0
  89. package/dist/core/session-cwd.d.ts +15 -1
  90. package/dist/core/session-cwd.js +22 -1
  91. package/dist/core/tools/bash.d.ts +12 -0
  92. package/dist/core/tools/bash.js +30 -1
  93. package/dist/index.d.ts +4 -0
  94. package/dist/index.js +3 -0
  95. package/dist/modes/rpc/connection-handler.js +4 -0
  96. package/dist/modes/rpc/method-classes-schema-session.d.ts +3 -0
  97. package/dist/modes/rpc/method-classes-schema-session.js +6 -2
  98. package/dist/modes/rpc/method-classes-schema.d.ts +3 -0
  99. package/dist/modes/rpc/project-expected-cwd.d.ts +29 -0
  100. package/dist/modes/rpc/project-expected-cwd.js +45 -0
  101. package/dist/modes/rpc/rpc-error-types.d.ts +7 -1
  102. package/dist/modes/rpc/rpc-error-types.js +6 -0
  103. package/dist/modes/rpc/rpc-session-command-types.d.ts +3 -0
  104. package/dist/modes/rpc/session-router-close.js +6 -1
  105. package/dist/modes/rpc/session-router-open.js +5 -0
  106. package/dist/modes/rpc/session-status.d.ts +1 -1
  107. package/dist/modes/rpc/session-status.js +2 -0
  108. package/dist/package-manager-cli.d.ts +14 -0
  109. package/dist/package-manager-cli.js +20 -4
  110. package/dist/utils/version-check.d.ts +10 -7
  111. package/dist/utils/version-check.js +24 -5
  112. package/docs/extensions.md +12 -0
  113. package/docs/rpc.md +28 -13
  114. package/docs/settings.md +1 -1
  115. package/package.json +7 -7
  116. package/dist/bundle/chunks/chunk-GLTOBXZY.js +0 -2
  117. package/dist/bundle/chunks/chunk-PBUV2Z3R.js +0 -2
  118. package/dist/bundle/chunks/chunk-WRBOKYPS.js +0 -2
  119. package/dist/bundle/chunks/chunk-YT2ZSZOY.js +0 -2
  120. package/dist/bundle/chunks/credential-accounts-ZB5IXHIG.js +0 -2
  121. package/dist/bundle/chunks/interactive-mode-PHES47E3.js +0 -2
  122. package/dist/bundle/chunks/main-PR4FI6HQ.js +0 -2
  123. package/dist/bundle/chunks/openai-codex-responses-VRGOAFEG.js +0 -10
  124. package/dist/bundle/chunks/openai-responses-I7PXRWCM.js +0 -4
  125. package/dist/bundle/chunks/rpc-mode-CGVK4SSA.js +0 -2
  126. package/dist/bundle/chunks/session-slot-GHJVOSG7.js +0 -2
@@ -832,6 +832,7 @@ export declare const SESSION_RECORD_SCHEMAS: {
832
832
  readonly sessionId: Type.TOptional<Type.TString>;
833
833
  } & {
834
834
  readonly choice: Type.TUnion<[Type.TLiteral<string>, ...Type.TLiteral<string>[]]>;
835
+ readonly expectedCwd: Type.TOptional<Type.TString>;
835
836
  }>;
836
837
  get_project_settings: Type.TObject<{
837
838
  id: Type.TOptional<Type.TString>;
@@ -849,6 +850,7 @@ export declare const SESSION_RECORD_SCHEMAS: {
849
850
  } & {
850
851
  readonly key: Type.TString;
851
852
  readonly value: Type.TUnknown;
853
+ readonly expectedCwd: Type.TOptional<Type.TString>;
852
854
  }>;
853
855
  read_project_file: Type.TObject<{
854
856
  id: Type.TOptional<Type.TString>;
@@ -869,6 +871,7 @@ export declare const SESSION_RECORD_SCHEMAS: {
869
871
  readonly path: Type.TString;
870
872
  readonly content: Type.TString;
871
873
  readonly ifEtag: Type.TUnion<[Type.TString, Type.TNull]>;
874
+ readonly expectedCwd: Type.TOptional<Type.TString>;
872
875
  }>;
873
876
  extension_request: Type.TObject<{
874
877
  id: Type.TOptional<Type.TString>;
@@ -194,14 +194,18 @@ const SURFACE_AND_ACCOUNT_SCHEMAS = {
194
194
  get_commands: session("get_commands", {}),
195
195
  get_loaded_surfaces: session("get_loaded_surfaces", {}),
196
196
  get_project_trust: session("get_project_trust", {}),
197
- set_project_trust: session("set_project_trust", { choice: literals("trust", "trust_parent", "do_not_trust") }),
197
+ set_project_trust: session("set_project_trust", {
198
+ choice: literals("trust", "trust_parent", "do_not_trust"),
199
+ expectedCwd: OptStr,
200
+ }),
198
201
  get_project_settings: session("get_project_settings", {}),
199
- set_project_setting: session("set_project_setting", { key: Id, value: Type.Unknown() }),
202
+ set_project_setting: session("set_project_setting", { key: Id, value: Type.Unknown(), expectedCwd: OptStr }),
200
203
  read_project_file: session("read_project_file", { path: PathStr }),
201
204
  write_project_file: session("write_project_file", {
202
205
  path: PathStr,
203
206
  content: Str,
204
207
  ifEtag: Type.Union([Id, Type.Null()]),
208
+ expectedCwd: OptStr,
205
209
  }),
206
210
  extension_request: session("extension_request", { name: Id, data: Opt(Type.Unknown()) }),
207
211
  "eval_cells.list": session("eval_cells.list", {}),
@@ -1305,6 +1305,7 @@ export declare const RPC_INBOUND_SCHEMAS: {
1305
1305
  readonly sessionId: import("typebox").TOptional<import("typebox").TString>;
1306
1306
  } & {
1307
1307
  readonly choice: import("typebox").TUnion<[import("typebox").TLiteral<string>, ...import("typebox").TLiteral<string>[]]>;
1308
+ readonly expectedCwd: import("typebox").TOptional<import("typebox").TString>;
1308
1309
  }>;
1309
1310
  readonly get_project_settings: import("typebox").TObject<{
1310
1311
  id: import("typebox").TOptional<import("typebox").TString>;
@@ -1322,6 +1323,7 @@ export declare const RPC_INBOUND_SCHEMAS: {
1322
1323
  } & {
1323
1324
  readonly key: import("typebox").TString;
1324
1325
  readonly value: import("typebox").TUnknown;
1326
+ readonly expectedCwd: import("typebox").TOptional<import("typebox").TString>;
1325
1327
  }>;
1326
1328
  readonly read_project_file: import("typebox").TObject<{
1327
1329
  id: import("typebox").TOptional<import("typebox").TString>;
@@ -1342,6 +1344,7 @@ export declare const RPC_INBOUND_SCHEMAS: {
1342
1344
  readonly path: import("typebox").TString;
1343
1345
  readonly content: import("typebox").TString;
1344
1346
  readonly ifEtag: import("typebox").TUnion<[import("typebox").TString, import("typebox").TNull]>;
1347
+ readonly expectedCwd: import("typebox").TOptional<import("typebox").TString>;
1345
1348
  }>;
1346
1349
  readonly extension_request: import("typebox").TObject<{
1347
1350
  id: import("typebox").TOptional<import("typebox").TString>;
@@ -0,0 +1,29 @@
1
+ /**
2
+ * A project write may state the folder its caller means (`expectedCwd`). A client that reaches a session by a key that
3
+ * can since name another session (a remote desktop, through a relay) states it, so the write lands only in the project
4
+ * it means: when the session's working directory is another folder, the write is refused and nothing is written.
5
+ * Absent, the write goes as it always has.
6
+ */
7
+ import { RPC_ERROR_INVALID_ARGUMENT, RPC_ERROR_PROJECT_CWD_MISMATCH } from "./rpc-error-types.ts";
8
+ /**
9
+ * A folder path for comparison. Trailing separators of the path's own style are dropped (only `/` for a POSIX path;
10
+ * `\\` or `/` for a drive or UNC path), but a bare POSIX or drive root keeps one: `"/"` stays `"/"`, and a bare drive
11
+ * root becomes `"X:\\"` (`"C:"`, `"C:/"` and `"c:\\"` all become `"C:\\"`). A Windows drive letter is compared
12
+ * without case (`c:` and `C:` are one drive). Nothing else is folded: the rest of the path is compared as written,
13
+ * because whether a file system ignores case depends on the volume and cannot be assumed; and nothing is trimmed,
14
+ * since on POSIX `"/w/app "` and `"/w/app\\"` are other directories.
15
+ */
16
+ export declare function comparableFolder(path: string): string;
17
+ export interface CwdMismatch {
18
+ readonly message: string;
19
+ readonly errorCode: typeof RPC_ERROR_PROJECT_CWD_MISMATCH | typeof RPC_ERROR_INVALID_ARGUMENT;
20
+ }
21
+ /**
22
+ * The refusal of a project write whose stated folder is not the session's, or whose `expectedCwd` is present but not a
23
+ * string, or undefined when it may go.
24
+ */
25
+ export declare function projectCwdMismatch(command: {
26
+ readonly type: string;
27
+ readonly expectedCwd?: unknown;
28
+ }, sessionCwd: string): CwdMismatch | undefined;
29
+ //# sourceMappingURL=project-expected-cwd.d.ts.map
@@ -0,0 +1,45 @@
1
+ /**
2
+ * A project write may state the folder its caller means (`expectedCwd`). A client that reaches a session by a key that
3
+ * can since name another session (a remote desktop, through a relay) states it, so the write lands only in the project
4
+ * it means: when the session's working directory is another folder, the write is refused and nothing is written.
5
+ * Absent, the write goes as it always has.
6
+ */
7
+ import { RPC_ERROR_INVALID_ARGUMENT, RPC_ERROR_PROJECT_CWD_MISMATCH } from "./rpc-error-types.js";
8
+ /** The project writes that may state the folder they mean. */
9
+ const PROJECT_WRITES = new Set(["set_project_trust", "set_project_setting", "write_project_file"]);
10
+ /**
11
+ * A folder path for comparison. Trailing separators of the path's own style are dropped (only `/` for a POSIX path;
12
+ * `\\` or `/` for a drive or UNC path), but a bare POSIX or drive root keeps one: `"/"` stays `"/"`, and a bare drive
13
+ * root becomes `"X:\\"` (`"C:"`, `"C:/"` and `"c:\\"` all become `"C:\\"`). A Windows drive letter is compared
14
+ * without case (`c:` and `C:` are one drive). Nothing else is folded: the rest of the path is compared as written,
15
+ * because whether a file system ignores case depends on the volume and cannot be assumed; and nothing is trimmed,
16
+ * since on POSIX `"/w/app "` and `"/w/app\\"` are other directories.
17
+ */
18
+ export function comparableFolder(path) {
19
+ if (path.startsWith("/"))
20
+ return path.replace(/\/+$/u, "") || "/";
21
+ const hasDrive = /^[A-Za-z]:/u.test(path);
22
+ const folded = hasDrive ? `${path.slice(0, 1).toUpperCase()}${path.slice(1)}` : path;
23
+ const stripped = folded.replace(/[/\\]+$/u, "");
24
+ return hasDrive && stripped.length === 2 ? `${stripped}\\` : stripped;
25
+ }
26
+ /**
27
+ * The refusal of a project write whose stated folder is not the session's, or whose `expectedCwd` is present but not a
28
+ * string, or undefined when it may go.
29
+ */
30
+ export function projectCwdMismatch(command, sessionCwd) {
31
+ if (!PROJECT_WRITES.has(command.type) || command.expectedCwd === undefined)
32
+ return undefined;
33
+ if (typeof command.expectedCwd !== "string")
34
+ return {
35
+ message: "expectedCwd must be a string: the folder this write is for; nothing was written.",
36
+ errorCode: RPC_ERROR_INVALID_ARGUMENT,
37
+ };
38
+ if (comparableFolder(command.expectedCwd) === comparableFolder(sessionCwd))
39
+ return undefined;
40
+ return {
41
+ message: "This session is in another folder than the one this write is for; nothing was written.",
42
+ errorCode: RPC_ERROR_PROJECT_CWD_MISMATCH,
43
+ };
44
+ }
45
+ //# sourceMappingURL=project-expected-cwd.js.map
@@ -22,6 +22,12 @@ export declare const RPC_ERROR_RESPONSE_TOO_LARGE = "response_too_large";
22
22
  * throwing `toJSON`); `error` names the cause. Sent in place of the reply so the caller is never left waiting (senpi#5702).
23
23
  */
24
24
  export declare const RPC_ERROR_RESPONSE_UNWRITABLE = "response_unwritable";
25
+ /**
26
+ * A project write stated the folder its caller means (`expectedCwd`) and the session is in another one: nothing is
27
+ * written. A client that names a session by a key that may since point at another session states the folder it
28
+ * means, so a write never lands in the wrong project.
29
+ */
30
+ export declare const RPC_ERROR_PROJECT_CWD_MISMATCH = "project_cwd_mismatch";
25
31
  export declare const RPC_ERROR_OPEN_FAILED = "open_failed";
26
32
  export declare const RPC_ERROR_OWNER_CHANGED = "owner_changed";
27
33
  export declare const RPC_ERROR_SESSION_NOT_HELD = "session_not_held";
@@ -131,5 +137,5 @@ export declare const RPC_ERROR_RELEASE_FAILED = "release_failed";
131
137
  export declare const RPC_ERROR_HOST_DRAINING = "host_draining";
132
138
  /** The supervisor has closed admission; a launch-owning caller may ensure a successor. */
133
139
  export declare const RPC_ERROR_HOST_STOPPING = "host_stopping";
134
- export type RpcErrorCode = typeof RPC_ERROR_SESSION_IDENTITY_MISMATCH | typeof RPC_ERROR_SESSION_BUSY_LIFECYCLE | typeof RPC_ERROR_ATTACHED_ELSEWHERE | typeof RPC_ERROR_OWNER_CHANGED | typeof RPC_ERROR_SESSION_NOT_HELD | typeof RPC_ERROR_HOST_STOPPING | typeof RPC_ERROR_UNKNOWN_SESSION | typeof RPC_ERROR_SESSION_NOT_ATTACHED | typeof RPC_ERROR_SESSION_CLOSING | typeof RPC_ERROR_SESSION_PATH_IN_USE | typeof RPC_ERROR_SESSION_HELD | typeof RPC_ERROR_SESSION_RESERVATION_LIMIT | typeof RPC_ERROR_MISSING_SESSION_ID | typeof RPC_ERROR_MULTI_SESSION_DISABLED | typeof RPC_ERROR_INVALID_PATH | typeof RPC_ERROR_OPEN_FAILED | typeof RPC_ERROR_MEDIA_NOT_FOUND | typeof RPC_ERROR_PROJECT_TRUST_LOCKED | typeof RPC_ERROR_PROJECT_TRUST_EXTENSION_DECIDED | typeof RPC_ERROR_PROJECT_SETTINGS_WRITE_FAILED | typeof RPC_ERROR_PROJECT_NOT_TRUSTED | typeof RPC_ERROR_PROJECT_SETTINGS_UNREADABLE | typeof RPC_ERROR_PROJECT_FILE_NOT_ALLOWED | typeof RPC_ERROR_PROJECT_FILE_CONFLICT | typeof RPC_ERROR_PROJECT_FILE_TOO_LARGE | typeof RPC_ERROR_PROJECT_FILE_WRITE_FAILED | typeof RPC_ERROR_INVALID_SESSION_CONTEXT | typeof RPC_ERROR_INVALID_SUBSCRIPTION | typeof RPC_ERROR_UNKNOWN_SUBSCRIPTION | typeof RPC_ERROR_SUBSCRIPTION_LIMIT | typeof RPC_ERROR_UNSUPPORTED_TRANSPORT | typeof RPC_ERROR_INVALID_SESSION_KIND | typeof RPC_ERROR_INVALID_LAUNCH_PROFILE | typeof RPC_ERROR_INVALID_SESSION_ID | typeof RPC_ERROR_SESSION_ID_IN_USE | typeof RPC_ERROR_HOST_MEMORY_PRESSURE | typeof RPC_ERROR_WARM_FAILED | typeof RPC_ERROR_STREAMING | typeof RPC_ERROR_ENTRY_NOT_FOUND | typeof RPC_ERROR_MODEL_NOT_FOUND | typeof RPC_ERROR_EFFECT_ID_CONFLICT | typeof RPC_ERROR_TRANSCRIPT_UNREADABLE | typeof RPC_ERROR_PROVIDER_UNAVAILABLE | typeof RPC_ERROR_PROVIDER_CREDENTIALS_MISSING | typeof RPC_ERROR_PROVIDER_CREDENTIALS_EXPIRED | typeof RPC_ERROR_NO_MODEL_SELECTED | typeof RPC_ERROR_TURN_CONFLICT | typeof RPC_ERROR_CONTEXT_BUDGET | typeof RPC_ERROR_EVAL_CELL_NOT_FOUND | typeof RPC_ERROR_INVALID_EVAL_CELL_REQUEST | typeof RPC_ERROR_EVAL_CELLS_UNAVAILABLE | typeof RPC_ERROR_NOT_ASSISTANT | typeof RPC_ERROR_NOT_USER | typeof RPC_ERROR_EMPTY_TEXT | typeof RPC_ERROR_UNKNOWN_COMMAND | typeof RPC_ERROR_STALE_LEAF | typeof RPC_ERROR_TURN_ACTIVE | typeof RPC_ERROR_SESSION_BUSY | typeof RPC_ERROR_ATTACHED | typeof RPC_ERROR_INVALID_RELEASE_REASON | typeof RPC_ERROR_RELEASE_UNSUPPORTED | typeof RPC_ERROR_RELEASE_FAILED | typeof RPC_ERROR_HOST_DRAINING | typeof RPC_ERROR_RESPONSE_UNWRITABLE;
140
+ export type RpcErrorCode = typeof RPC_ERROR_SESSION_IDENTITY_MISMATCH | typeof RPC_ERROR_SESSION_BUSY_LIFECYCLE | typeof RPC_ERROR_ATTACHED_ELSEWHERE | typeof RPC_ERROR_OWNER_CHANGED | typeof RPC_ERROR_SESSION_NOT_HELD | typeof RPC_ERROR_HOST_STOPPING | typeof RPC_ERROR_UNKNOWN_SESSION | typeof RPC_ERROR_SESSION_NOT_ATTACHED | typeof RPC_ERROR_SESSION_CLOSING | typeof RPC_ERROR_SESSION_PATH_IN_USE | typeof RPC_ERROR_SESSION_HELD | typeof RPC_ERROR_SESSION_RESERVATION_LIMIT | typeof RPC_ERROR_MISSING_SESSION_ID | typeof RPC_ERROR_MULTI_SESSION_DISABLED | typeof RPC_ERROR_INVALID_PATH | typeof RPC_ERROR_OPEN_FAILED | typeof RPC_ERROR_MEDIA_NOT_FOUND | typeof RPC_ERROR_PROJECT_TRUST_LOCKED | typeof RPC_ERROR_PROJECT_TRUST_EXTENSION_DECIDED | typeof RPC_ERROR_PROJECT_SETTINGS_WRITE_FAILED | typeof RPC_ERROR_PROJECT_NOT_TRUSTED | typeof RPC_ERROR_PROJECT_SETTINGS_UNREADABLE | typeof RPC_ERROR_PROJECT_FILE_NOT_ALLOWED | typeof RPC_ERROR_PROJECT_FILE_CONFLICT | typeof RPC_ERROR_PROJECT_FILE_TOO_LARGE | typeof RPC_ERROR_PROJECT_FILE_WRITE_FAILED | typeof RPC_ERROR_INVALID_SESSION_CONTEXT | typeof RPC_ERROR_INVALID_SUBSCRIPTION | typeof RPC_ERROR_UNKNOWN_SUBSCRIPTION | typeof RPC_ERROR_SUBSCRIPTION_LIMIT | typeof RPC_ERROR_UNSUPPORTED_TRANSPORT | typeof RPC_ERROR_INVALID_SESSION_KIND | typeof RPC_ERROR_INVALID_LAUNCH_PROFILE | typeof RPC_ERROR_INVALID_SESSION_ID | typeof RPC_ERROR_SESSION_ID_IN_USE | typeof RPC_ERROR_HOST_MEMORY_PRESSURE | typeof RPC_ERROR_WARM_FAILED | typeof RPC_ERROR_STREAMING | typeof RPC_ERROR_ENTRY_NOT_FOUND | typeof RPC_ERROR_MODEL_NOT_FOUND | typeof RPC_ERROR_EFFECT_ID_CONFLICT | typeof RPC_ERROR_TRANSCRIPT_UNREADABLE | typeof RPC_ERROR_PROVIDER_UNAVAILABLE | typeof RPC_ERROR_PROVIDER_CREDENTIALS_MISSING | typeof RPC_ERROR_PROVIDER_CREDENTIALS_EXPIRED | typeof RPC_ERROR_NO_MODEL_SELECTED | typeof RPC_ERROR_TURN_CONFLICT | typeof RPC_ERROR_CONTEXT_BUDGET | typeof RPC_ERROR_EVAL_CELL_NOT_FOUND | typeof RPC_ERROR_INVALID_EVAL_CELL_REQUEST | typeof RPC_ERROR_EVAL_CELLS_UNAVAILABLE | typeof RPC_ERROR_NOT_ASSISTANT | typeof RPC_ERROR_NOT_USER | typeof RPC_ERROR_EMPTY_TEXT | typeof RPC_ERROR_UNKNOWN_COMMAND | typeof RPC_ERROR_STALE_LEAF | typeof RPC_ERROR_TURN_ACTIVE | typeof RPC_ERROR_SESSION_BUSY | typeof RPC_ERROR_ATTACHED | typeof RPC_ERROR_INVALID_RELEASE_REASON | typeof RPC_ERROR_RELEASE_UNSUPPORTED | typeof RPC_ERROR_RELEASE_FAILED | typeof RPC_ERROR_HOST_DRAINING | typeof RPC_ERROR_RESPONSE_UNWRITABLE | typeof RPC_ERROR_PROJECT_CWD_MISMATCH;
135
141
  //# sourceMappingURL=rpc-error-types.d.ts.map
@@ -22,6 +22,12 @@ export const RPC_ERROR_RESPONSE_TOO_LARGE = "response_too_large";
22
22
  * throwing `toJSON`); `error` names the cause. Sent in place of the reply so the caller is never left waiting (senpi#5702).
23
23
  */
24
24
  export const RPC_ERROR_RESPONSE_UNWRITABLE = "response_unwritable";
25
+ /**
26
+ * A project write stated the folder its caller means (`expectedCwd`) and the session is in another one: nothing is
27
+ * written. A client that names a session by a key that may since point at another session states the folder it
28
+ * means, so a write never lands in the wrong project.
29
+ */
30
+ export const RPC_ERROR_PROJECT_CWD_MISMATCH = "project_cwd_mismatch";
25
31
  export const RPC_ERROR_OPEN_FAILED = "open_failed";
26
32
  export const RPC_ERROR_OWNER_CHANGED = "owner_changed";
27
33
  export const RPC_ERROR_SESSION_NOT_HELD = "session_not_held";
@@ -480,6 +480,7 @@ export type RpcSessionCommand = {
480
480
  id?: string;
481
481
  type: "set_project_trust";
482
482
  choice: "trust" | "trust_parent" | "do_not_trust";
483
+ expectedCwd?: string;
483
484
  } | {
484
485
  id?: string;
485
486
  type: "get_project_settings";
@@ -488,6 +489,7 @@ export type RpcSessionCommand = {
488
489
  type: "set_project_setting";
489
490
  key: string;
490
491
  value: unknown;
492
+ expectedCwd?: string;
491
493
  } | {
492
494
  id?: string;
493
495
  type: "read_project_file";
@@ -498,6 +500,7 @@ export type RpcSessionCommand = {
498
500
  path: string;
499
501
  content: string;
500
502
  ifEtag: string | null;
503
+ expectedCwd?: string;
501
504
  } | {
502
505
  id?: string;
503
506
  type: "extension_request";
@@ -180,8 +180,13 @@ export async function tearDownReleased(host, sessionId, sessionPath) {
180
180
  const binding = host.bindings.get(sessionId);
181
181
  binding?.cancelPendingExtensionUiRequests?.();
182
182
  forgetSessionOwnership(host, sessionId);
183
- if (claim.finalizer)
183
+ if (claim.finalizer) {
184
+ // The file is another runtime's now: drop its handover-parked record entry as an explicit close does, or the
185
+ // next host on this endpoint would reopen it (senpi#6444).
186
+ if (host.handoverParkedDir !== undefined)
187
+ await clearRetainedRecord(host, host.registry.peek(sessionId));
184
188
  await finalizeClose(host, sessionId, binding, () => host.writer.releaseSession(sessionId, sessionPath));
189
+ }
185
190
  else
186
191
  await host.finalizations.get(sessionId)?.promise;
187
192
  host.writer.forgetSession(sessionId);
@@ -1,3 +1,4 @@
1
+ import { stat } from "node:fs/promises";
1
2
  import { compactUpdatesEnabled } from "../../core/engine-socket-flag.js";
2
3
  import { startOpenTimer } from "../../core/open-timing.js";
3
4
  import { compactOpen } from "./compact-open-admission.js";
@@ -69,6 +70,10 @@ export async function open(host, command, owner, options) {
69
70
  // An open that names no folder resumes a session file where its header says, not where this host runs
70
71
  // (senpi#6427). Inside the try: a file it cannot read refuses the open, and that refusal is this open's answer.
71
72
  const profile = { ...validated, cwd: await openSessionFolder(command, validated.cwd) };
73
+ // A folder that exists but is a file (or a socket) is refused, typed, before anything is opened or created. A missing
74
+ // folder, or one this host cannot stat, keeps today's answer from the registry.
75
+ if ((await stat(profile.cwd).catch(() => undefined))?.isDirectory() === false)
76
+ return error(command.id, "open_session", "session_cwd_not_directory", { cwd: profile.cwd });
72
77
  await assertSessionNotHeld(command.sessionPath);
73
78
  timer.mark("held_check");
74
79
  options?.signal?.throwIfAborted();
@@ -10,7 +10,7 @@
10
10
  import type { SessionKind } from "../../core/extensions/types.ts";
11
11
  import type { RpcSessionState } from "./session-registry-types.ts";
12
12
  export type SessionStatusValue = "opening" | "idle" | "running" | "waiting_input" | "compacting" | "unloaded" | "closed";
13
- export type SessionStatusCause = "open" | "prompt" | "agent_end" | "agent_idle" | "abort" | "question" | "compaction" | "set_model" | "queue" | "attach" | "detach" | "replace" | "park" | "close" | "context";
13
+ export type SessionStatusCause = "open" | "prompt" | "agent_end" | "agent_idle" | "abort" | "question" | "compaction" | "set_model" | "queue" | "attach" | "detach" | "replace" | "park" | "close" | "context" | "rename";
14
14
  export type SessionStatusActor = "client" | "agent" | "host";
15
15
  /** A session's published labels: the open-time context overlaid with every `set_session_context` patch. */
16
16
  export type PublishedSessionContext = Readonly<Record<string, string | number | boolean>>;
@@ -85,6 +85,8 @@ export function causeOfRecord(record) {
85
85
  return "set_model";
86
86
  case "session_replaced":
87
87
  return "replace";
88
+ case "session_info_changed":
89
+ return "rename";
88
90
  case "session_parked":
89
91
  return "park";
90
92
  case "session_closed":
@@ -1,6 +1,20 @@
1
+ import { type InstallMethod } from "./config.ts";
1
2
  import type { InlineExtension } from "./core/extensions/types.ts";
2
3
  export type PackageCommand = "install" | "remove" | "update" | "list";
3
4
  export declare function cleanupManagedInstall(): void;
5
+ export interface SelfUpdatePlan {
6
+ packageName: string;
7
+ installSpec: string;
8
+ version: string;
9
+ shouldRun: boolean;
10
+ note?: string;
11
+ }
12
+ /**
13
+ * The latest-version lookup failed. A standalone binary cannot update itself in place, and since senpi#6633 its lookup
14
+ * asks the unauthenticated GitHub API (60 requests per hour per IP), so a failed lookup points it at the download page
15
+ * instead of failing `update --self`. Every other install still fails, because it would run an install command.
16
+ */
17
+ export declare function selfUpdatePlanAfterLookupFailure(installMethod: InstallMethod, reason: string): SelfUpdatePlan;
4
18
  export interface PackageCommandRuntimeOptions {
5
19
  extensionFactories?: InlineExtension[];
6
20
  }
@@ -576,6 +576,19 @@ function printSelfUpdateNote(note) {
576
576
  }
577
577
  console.log();
578
578
  }
579
+ /**
580
+ * The latest-version lookup failed. A standalone binary cannot update itself in place, and since senpi#6633 its lookup
581
+ * asks the unauthenticated GitHub API (60 requests per hour per IP), so a failed lookup points it at the download page
582
+ * instead of failing `update --self`. Every other install still fails, because it would run an install command.
583
+ */
584
+ export function selfUpdatePlanAfterLookupFailure(installMethod, reason) {
585
+ if (installMethod !== "bun-binary") {
586
+ throw new Error(`Could not determine latest ${APP_NAME} version${reason ? `: ${reason}` : "."}`);
587
+ }
588
+ console.log(chalk.yellow(`Could not check the latest ${APP_NAME} release${reason ? ` (${reason})` : ""}.`));
589
+ console.log(chalk.cyan("Download from: https://github.com/code-yeongyu/senpi/releases/latest"));
590
+ return { packageName: PACKAGE_NAME, installSpec: PACKAGE_NAME, version: VERSION, shouldRun: false };
591
+ }
579
592
  async function getSelfUpdatePlan(force) {
580
593
  const brandUpdate = brandProfile()?.update;
581
594
  if (brandUpdate) {
@@ -593,12 +606,15 @@ async function getSelfUpdatePlan(force) {
593
606
  latestRelease = await getLatestPiRelease(VERSION, { retry: true });
594
607
  }
595
608
  catch (error) {
596
- throw new Error(`Could not determine latest ${APP_NAME} version: ${formatVersionCheckError(error)}`, {
597
- cause: error,
598
- });
609
+ try {
610
+ return selfUpdatePlanAfterLookupFailure(detectInstallMethod(), formatVersionCheckError(error));
611
+ }
612
+ catch (planError) {
613
+ throw planError instanceof Error ? Object.assign(planError, { cause: error }) : planError;
614
+ }
599
615
  }
600
616
  if (!latestRelease) {
601
- throw new Error(`Could not determine latest ${APP_NAME} version.`);
617
+ return selfUpdatePlanAfterLookupFailure(detectInstallMethod(), "");
602
618
  }
603
619
  const packageName = latestRelease.packageName ?? PACKAGE_NAME;
604
620
  const installSpec = `${packageName}@${latestRelease.version}`;
@@ -1,4 +1,7 @@
1
+ import { type InstallMethod } from "../config.ts";
1
2
  import { type BrandUpdateChannel } from "../core/brand.ts";
3
+ /** Version of the latest GitHub Release, from its tag; drafts and prereleases are not installable. */
4
+ export declare function readLatestReleaseVersion(payload: unknown): string | undefined;
2
5
  /** Reads the available version out of whichever registry document was fetched. */
3
6
  export declare function readAvailableVersion(payload: unknown, channel: BrandUpdateChannel | undefined): string | undefined;
4
7
  export interface LatestPiRelease {
@@ -11,13 +14,13 @@ export declare function formatVersionCheckError(error: unknown): string;
11
14
  export declare function comparePackageVersions(leftVersion: string, rightVersion: string): number | undefined;
12
15
  export declare function isNewerPackageVersion(candidateVersion: string, currentVersion: string): boolean;
13
16
  export declare function getReleaseChangelogUrl(version: string): string;
14
- export declare function getLatestPiRelease(currentVersion: string, options?: {
17
+ export interface VersionCheckOptions {
15
18
  timeoutMs?: number;
16
19
  retry?: boolean;
17
- }): Promise<LatestPiRelease | undefined>;
18
- export declare function getLatestPiVersion(currentVersion: string, options?: {
19
- timeoutMs?: number;
20
- retry?: boolean;
21
- }): Promise<string | undefined>;
22
- export declare function checkForNewPiVersion(currentVersion: string): Promise<LatestPiRelease | undefined>;
20
+ /** Defaults to the running install; tests pass it explicitly. */
21
+ installMethod?: InstallMethod;
22
+ }
23
+ export declare function getLatestPiRelease(currentVersion: string, options?: VersionCheckOptions): Promise<LatestPiRelease | undefined>;
24
+ export declare function getLatestPiVersion(currentVersion: string, options?: VersionCheckOptions): Promise<string | undefined>;
25
+ export declare function checkForNewPiVersion(currentVersion: string, options?: Pick<VersionCheckOptions, "installMethod">): Promise<LatestPiRelease | undefined>;
23
26
  //# sourceMappingURL=version-check.d.ts.map
@@ -1,5 +1,5 @@
1
1
  import { compare, valid } from "semver";
2
- import { PACKAGE_NAME } from "../config.js";
2
+ import { detectInstallMethod, PACKAGE_NAME } from "../config.js";
3
3
  import { brandProfile, envValue } from "../core/brand.js";
4
4
  import { fetchWithRetry } from "./management-http.js";
5
5
  import { getPiUserAgent } from "./pi-user-agent.js";
@@ -11,6 +11,20 @@ function latestVersionUrl(channel) {
11
11
  ? `${REGISTRY_BASE_URL}/-/package/${encodeURIComponent(channel.packageName)}/dist-tags`
12
12
  : LATEST_VERSION_URL;
13
13
  }
14
+ // A standalone binary can only be updated from a GitHub Release ("Download from: .../releases/latest" in config.ts),
15
+ // and npm can publish before that release exists (senpi#6633), so a binary reads the latest release, never npm.
16
+ const LATEST_RELEASE_URL = "https://api.github.com/repos/code-yeongyu/senpi/releases/latest";
17
+ /** Version of the latest GitHub Release, from its tag; drafts and prereleases are not installable. */
18
+ export function readLatestReleaseVersion(payload) {
19
+ if (typeof payload !== "object" || payload === null)
20
+ return undefined;
21
+ const release = payload;
22
+ if (release.draft === true || release.prerelease === true)
23
+ return undefined;
24
+ const tag = typeof release.tag_name === "string" ? release.tag_name.trim() : "";
25
+ const version = tag.startsWith("v") ? tag.slice(1) : tag;
26
+ return version || undefined;
27
+ }
14
28
  /** Reads the available version out of whichever registry document was fetched. */
15
29
  export function readAvailableVersion(payload, channel) {
16
30
  if (typeof payload !== "object" || payload === null)
@@ -96,10 +110,11 @@ export async function getLatestPiRelease(currentVersion, options = {}) {
96
110
  // advertising them would send the user after an update they cannot apply.
97
111
  return undefined;
98
112
  }
99
- const response = await fetchWithRetry(latestVersionUrl(channel), {
113
+ const fromRelease = !channel && (options.installMethod ?? detectInstallMethod()) === "bun-binary";
114
+ const response = await fetchWithRetry(fromRelease ? LATEST_RELEASE_URL : latestVersionUrl(channel), {
100
115
  headers: {
101
116
  "User-Agent": getPiUserAgent(currentVersion),
102
- accept: "application/json",
117
+ accept: fromRelease ? "application/vnd.github+json" : "application/json",
103
118
  },
104
119
  }, {
105
120
  maxRetries: options.retry ? 2 : 0,
@@ -107,6 +122,10 @@ export async function getLatestPiRelease(currentVersion, options = {}) {
107
122
  });
108
123
  if (!response.ok)
109
124
  return undefined;
125
+ if (fromRelease) {
126
+ const releaseVersion = readLatestReleaseVersion(await response.json());
127
+ return releaseVersion ? { version: releaseVersion } : undefined;
128
+ }
110
129
  const data = (await response.json());
111
130
  const availableVersion = readAvailableVersion(data, channel);
112
131
  if (!availableVersion) {
@@ -122,11 +141,11 @@ export async function getLatestPiRelease(currentVersion, options = {}) {
122
141
  export async function getLatestPiVersion(currentVersion, options = {}) {
123
142
  return (await getLatestPiRelease(currentVersion, options))?.version;
124
143
  }
125
- export async function checkForNewPiVersion(currentVersion) {
144
+ export async function checkForNewPiVersion(currentVersion, options = {}) {
126
145
  if (envValue("SKIP_VERSION_CHECK"))
127
146
  return undefined;
128
147
  try {
129
- const latestRelease = await getLatestPiRelease(currentVersion);
148
+ const latestRelease = await getLatestPiRelease(currentVersion, options);
130
149
  if (latestRelease && isNewerPackageVersion(latestRelease.version, currentVersion)) {
131
150
  return latestRelease;
132
151
  }
@@ -2569,6 +2569,18 @@ pi.registerShortcut("ctrl+shift+p", {
2569
2569
  });
2570
2570
  ```
2571
2571
 
2572
+ ### pi.registerDeviceMachine(machine)
2573
+
2574
+ Register the operation layer of the device this session's tools run on (a `ToolMachine`, for example from `createToolMachine({ ops })`). Only tools named in `OMO_TOOLS_VIA_DAEMON` use it, today `bash`, and the bash tool reads it when a command runs. With the flag on and no machine registered, bash fails with `DeviceUnavailableError` and never runs locally.
2575
+
2576
+ One registration per session: a second one, from any extension, throws `DeviceMachineConflictError` (`code: "device_machine_conflict"`, with `registeredBy` and `attemptedBy`). A registration made by an extension whose factory then throws is discarded with the rest of that load.
2577
+
2578
+ ```typescript
2579
+ import { createToolMachine } from "@code-yeongyu/senpi";
2580
+
2581
+ pi.registerDeviceMachine(createToolMachine({ ops: myDeviceOps }));
2582
+ ```
2583
+
2572
2584
  ### pi.registerFlag(name, options)
2573
2585
 
2574
2586
  Register a CLI flag.
package/docs/rpc.md CHANGED
@@ -2624,7 +2624,7 @@ A connection without an attachment receives `errorCode: "session_not_attached"`
2624
2624
  ```
2625
2625
 
2626
2626
  - `status`: `opening`, `idle`, `running`, `waiting_input` (a question is pending), `compacting`, `unloaded` (the routing handle ended and the session reopens by `sessionPath`; `reason` is `idle`, `unload_session` or `handoff`), or `closed` (`reason` is the `session_closed.reason`). After an abort ends a run, `idle` carries `reason: "aborted"`.
2627
- - `lastChange.cause`: `open`, `prompt`, `agent_end`, `agent_idle`, `abort`, `question`, `compaction`, `set_model`, `queue`, `attach`, `detach`, `replace`, `park`, `close` or `context`; `by` is `client`, `agent` or `host`.
2627
+ - `lastChange.cause`: `open`, `prompt`, `agent_end`, `agent_idle`, `abort`, `question`, `compaction`, `set_model`, `queue`, `attach`, `detach`, `replace`, `park`, `close`, `context` or `rename` (the session's name changed: `set_session_name` or a generated title); `by` is `client`, `agent` or `host`.
2628
2628
  - `context` is present only with `include_context`. `lastEffect: { effectId, kind, state }` names the session's most recent effect receipt (an input with a `clientMessageId`, an abort, a model or thinking-level switch) and is absent on hosts without the `effect_ledger` capability or before the session's first effect.
2629
2629
  - On `new_session`, `switch_session` or `fork` the routing id stays; the record carries the new `durableSessionId` and `sessionPath` with cause `replace`.
2630
2630
 
@@ -3403,10 +3403,11 @@ What the host does enforce are lifecycle windows, and they only ever return memo
3403
3403
  endpoint's daemon directory (`handover-parked/`, one entry per session path, the same record a handoff park writes)
3404
3404
  as soon as its open succeeds; the entry follows the session's `set_session_context` patches and its park, and a
3405
3405
  transcript switch (`new_session`, `switch_session`, a fork) moves it to the new file, so only the transcript the
3406
- session is on comes back. An explicit `close_session` or a reclaim removes every key the session was recorded
3407
- under. While the session is open the entry names the process that holds it; a park drops that, and a host never
3408
- reopens an entry whose holding process is still alive. So during a generation handoff the successor does not open
3409
- a session its draining predecessor still has loaded, idle and detached ones included: it reopens the predecessor's
3406
+ session is on comes back. An explicit `close_session`, a `release_session` (the file is another runtime's then) or
3407
+ a reclaim removes every key the session was recorded under. While the session is open the entry names the process
3408
+ that holds it; a park drops that, and a host never reopens an entry whose holding process is still alive. So during
3409
+ a generation handoff the successor does not open a session its draining predecessor still has loaded, idle and
3410
+ detached ones included: it reopens the predecessor's
3410
3411
  park once the drain writes it (or the entry, if the predecessor dies without parking). A host
3411
3412
  that starts on the same socket and agent dir - after a handoff, a clean stop, a crash, or a kill in the middle of a
3412
3413
  turn - reopens, in the background right after it binds, every entry an earlier process wrote, replaying the
@@ -4023,7 +4024,7 @@ names a valid preset.
4023
4024
 
4024
4025
  ### The folder an open_session works in
4025
4026
 
4026
- `open_session.cwd` is the folder the session works in: its tools run there and its project files are read from there. An open that names one works in it. An open that names none and a `sessionPath` at which a session file exists works in the folder that file's header names, as `--session` does; the host maps a folder the desktop moved to where it lives now. A header folder that is not on this host refuses the open with `open_failed` (`Stored session working directory does not exist: <folder>`): the session is never opened in the host's own folder instead. Name a `cwd` to open it somewhere else. Any other open without `cwd` (no `sessionPath`, a `sessionPath` no file is at yet, or a file whose header names no folder) works in the folder the host was started in. An open that attaches to a session this host already holds leaves its folder as it is.
4027
+ `open_session.cwd` is the folder the session works in: its tools run there and its project files are read from there. An open that names one works in it. An open that names none and a `sessionPath` at which a session file exists works in the folder that file's header names, as `--session` does; the host maps a folder the desktop moved to where it lives now. A header folder that is not on this host refuses the open with `open_failed` (`Stored session working directory does not exist: <folder>`): the session is never opened in the host's own folder instead. A folder that exists but is not a directory (a file, a socket), named or taken from a header, refuses the open with `session_cwd_not_directory` (`errorData { cwd }`) before any session is opened or created. Name a `cwd` to open it somewhere else. Any other open without `cwd` (no `sessionPath`, a `sessionPath` no file is at yet, or a file whose header names no folder) works in the folder the host was started in. An open that attaches to a session this host already holds leaves its folder as it is.
4027
4028
 
4028
4029
  An open that names no folder never falls back to the host's own for a session file it could not read: a file that exists but cannot be read, and a moved-path probe whose step timed out, refuse the open (`open_failed`) instead. A `cwd` of `null` means no folder was named, as the host's launch profile already reads it.
4029
4030
 
@@ -4231,6 +4232,7 @@ codes or code prefixes in `error`:
4231
4232
  - `release_unsupported` (`release_session` for a session this host cannot hand over; `errorData.detail` is `worker_runtime` - a worker isolate owns the runtime - or `no_session_file`)
4232
4233
  - `release_failed` (`release_session` whose hand-over failed after it began - the release entry could not be written or the teardown threw; `errorData.detail` is the error message, plus `interrupted`/`dropped` after an interrupt)
4233
4234
  - `invalid_path` (relative `sessionPath`/`cwd`)
4235
+ - `session_cwd_not_directory` (`open_session` whose `cwd`, named or taken from a session file's header, exists but is not a directory; `errorData { cwd }`; nothing is opened or created)
4234
4236
  - `open_failed: <detail>`. `open_failed: model_unavailable: <reason>` means the open named a model (`provider`/`modelId`) that could not be resolved, for example an unknown provider; the session is not opened, rather than running on the default model. The in-process runtime adds `errorData { reason: "model_unavailable", requestedModel }`; a worker runtime relays the same error text, so match on the prefix
4235
4237
  - `invalid_session_context: <detail>` (`open_session.context` past a documented cap: more than 32 keys, a key that does not match `^[a-z][a-z0-9_]*$`, a non-string or >16 KiB value, or more than 32 KiB of JSON in total; the detail names the cap and its byte budget)
4236
4238
  - `invalid_subscription: <reason>` (`subscribe_sessions` with a malformed `sessions`, `mirror` or flag; `errorData { reason, action }`)
@@ -6287,12 +6289,12 @@ as they would on `reload`, and the next session opened in the folder gets the sa
6287
6289
  implied by `open_session` or by a permission preset; only this explicit request saves a decision.
6288
6290
 
6289
6291
  ```json
6290
- {"type": "set_project_trust", "choice": "trust"}
6292
+ {"type": "set_project_trust", "choice": "trust", "expectedCwd": "/home/me/project"}
6291
6293
  ```
6292
6294
 
6293
- `choice` is `trust` (this folder), `trust_parent` (the parent folder; clears this folder's own entry, as `/trust`
6295
+ `expectedCwd` is optional, as on every project write: see [The folder a project write is for](#the-folder-a-project-write-is-for). `choice` is `trust` (this folder), `trust_parent` (the parent folder; clears this folder's own entry, as `/trust`
6294
6296
  does) or `do_not_trust`. The response carries the same `data` as `get_project_trust`, read after the change.
6295
- Errors: `invalid_argument` for an unknown choice, `project_trust_locked` when the launch named the trust, and
6297
+ Errors: `invalid_argument` for an unknown choice or an `expectedCwd` that is not a string, `project_cwd_mismatch` when `expectedCwd` names another folder than the session's (nothing is saved or applied), `project_trust_locked` when the launch named the trust, and
6296
6298
  `project_trust_extension_decided` when a `project_trust` handler of an extension loaded before trust (never a
6297
6299
  project extension) decides the other way: such a handler decides before any saved choice when a session opens, so
6298
6300
  the request is refused and nothing is saved or applied. A folder with nothing trust gates stays trusted whatever is
@@ -6332,11 +6334,12 @@ the file: values the session reads when it starts (default model and thinking le
6332
6334
  modes) apply after `reload` or in the next session.
6333
6335
 
6334
6336
  ```json
6335
- {"type": "set_project_setting", "key": "defaultThinkingLevel", "value": "high"}
6337
+ {"type": "set_project_setting", "key": "defaultThinkingLevel", "value": "high", "expectedCwd": "/home/me/project"}
6336
6338
  ```
6337
6339
 
6338
6340
  The response carries the same `data` as `get_project_settings`, read after the write. Errors:
6339
- `invalid_argument` when `key` is not an identifier (`[A-Za-z][A-Za-z0-9_]*`) or `value` is missing,
6341
+ `invalid_argument` when `key` is not an identifier (`[A-Za-z][A-Za-z0-9_]*`), `value` is missing or `expectedCwd` is
6342
+ not a string, `project_cwd_mismatch` when `expectedCwd` names another folder (nothing is written),
6340
6343
  `project_not_trusted` while the project is untrusted (trust it first), `project_settings_unreadable` when the
6341
6344
  existing file does not parse (nothing is written), and `project_settings_write_failed` when writing the file fails
6342
6345
  (permissions, a held lock).
@@ -6377,19 +6380,31 @@ Errors: `invalid_argument` when `path` is not a string, `project_file_not_allowe
6377
6380
  other path, or when a folder on the way or the file itself is a symlink or not a regular file and folder, and
6378
6381
  `project_file_too_large` (`errorData: { limitBytes }`) for a stored file over 512 KiB.
6379
6382
 
6383
+ #### The folder a project write is for
6384
+
6385
+ `set_project_trust`, `set_project_setting` and `write_project_file` take an optional `expectedCwd`: the folder the
6386
+ caller means. A client that reaches a session by a key that can since name another session (a remote desktop, through
6387
+ a relay) states it, so the write lands only in that folder's project. When it is given and the session's working
6388
+ directory is another folder, the write is refused with `project_cwd_mismatch` and nothing is written or applied. Both
6389
+ paths are compared with only trailing separators of the path's own style dropped (`/` for a POSIX path; `\` or `/`
6390
+ for a drive or UNC path) and a Windows drive letter in either case; nothing is trimmed and the rest of the path is
6391
+ compared as written. A present `expectedCwd` that is not a string is refused with `invalid_argument`. Without it, a
6392
+ write behaves as before.
6393
+
6380
6394
  #### write_project_file
6381
6395
 
6382
6396
  Replaces (or creates) one of the files `read_project_file` reads, naming the version it replaces.
6383
6397
 
6384
6398
  ```json
6385
- {"type": "write_project_file", "path": "AGENTS.md", "content": "# Project rules\n", "ifEtag": "sha256-…"}
6399
+ {"type": "write_project_file", "path": "AGENTS.md", "content": "# Project rules\n", "ifEtag": "sha256-…", "expectedCwd": "/home/me/project"}
6386
6400
  ```
6387
6401
 
6388
6402
  `ifEtag` is required: the `etag` last read, or `null` to create a file that must not exist yet. Missing parent
6389
6403
  folders are created. The file lands by a temporary file and a rename, so a reader never sees a partial write, and
6390
6404
  writes to one file are applied one at a time. The response carries the same `data` as `read_project_file`, read
6391
6405
  after the write. The session reads its instruction files when they are next loaded (a new session or `reload`).
6392
- Errors: `invalid_argument` when `content` is not a string or `ifEtag` is neither a string nor `null`,
6406
+ Errors: `invalid_argument` when `content` is not a string, `ifEtag` is neither a string nor `null`, or `expectedCwd` is
6407
+ not a string, `project_cwd_mismatch` when `expectedCwd` names another folder (nothing is written),
6393
6408
  `project_not_trusted` while the project is untrusted, `project_file_not_allowed` as for `read_project_file`,
6394
6409
  `project_file_too_large` (`errorData: { limitBytes }`) for content over 512 KiB or a stored file over 512 KiB (its
6395
6410
  version cannot be checked without reading it), `project_file_conflict` when
package/docs/settings.md CHANGED
@@ -332,7 +332,7 @@ Keep `retry.provider.maxRetries` at `0` unless provider-level retries are explic
332
332
 
333
333
  #### Model fallback chains
334
334
 
335
- `retry.fallbackChains` maps a primary-model selector to an ordered list of fallback selectors. A selector is `provider/model` with an optional `:thinking-level` suffix, or a bare `model` id that applies to that model vendor's own providers - for example `anthropic-subscription` and `anthropic` for Claude, `chatgpt-subscription` and `openai` for GPT, `google` and `google-vertex` for Gemini. A bare selector never expands onto a reseller, gateway, cloud re-host or custom provider that also serves the model; name one as `provider/model` to use it. A bare id from a vendor senpi does not know stays on the provider the session is already using. Within the vendor's providers, those holding an OAuth credential are preferred, then a fixed precedence order. Senpi ships bare default chains for `claude-fable-5-2`, `claude-fable-5-1` and `claude-fable-5` - `claude-opus-5-5`, then `claude-opus-5` - and for `claude-opus-5-5` - `claude-fable-5-2`, then `claude-opus-5`, because a rate limit on one Opus model holds the whole Opus quota - with no thinking level of their own, so a shipped rung inherits the session's thinking level (clamped to what the model supports), so Fable 5.2, Fable 5.1, Fable 5 and Opus 5.5 keep a fallback chain whichever provider serves them, and a fallback never leaves the Anthropic family; set a key to `[]` to opt out entirely, or set one `provider/claude-fable-5-1` key to override just that provider. For example, this switches Fable 5.1 to Kimi K3 at `max` thinking when an eligible failure occurs:
335
+ `retry.fallbackChains` maps a primary-model selector to an ordered list of fallback selectors. A selector is `provider/model` with an optional `:thinking-level` suffix, or a bare `model` id that applies to that model vendor's own providers - for example `anthropic-subscription` and `anthropic` for Claude, `chatgpt-subscription` and `openai` for GPT, `google` and `google-vertex` for Gemini. A bare selector never expands onto a reseller, gateway, cloud re-host or custom provider that also serves the model; name one as `provider/model` to use it. A bare id from a vendor senpi does not know stays on the provider the session is already using. Within the vendor's providers, those holding an OAuth credential are preferred, then a fixed precedence order. Senpi ships bare default chains for `claude-fable-5-2`, `claude-fable-5-1` and `claude-fable-5` - `claude-opus-5-5`, then `claude-opus-5`, with no thinking level of their own, so those rungs inherit the session's thinking level (clamped to what the model supports) and a Fable fallback never leaves the Anthropic family - and for `claude-opus-5-5` - `claude-fable-5-2:medium`, then `kimi-k3.1-pro:max`, and nothing else: each of those rungs runs at its own level whatever level the Opus 5.5 session was on, and no Opus rung follows because a rate limit on one Opus model holds the whole Opus quota. The bare `kimi-k3.1-pro` rung reaches Kimi Code (which serves it as `k3.1-pro`) and Moonshot. A fallback keeps walking the chain it started on, so a session that stepped from Opus 5.5 to Fable 5.2 moves on to Kimi K3.1 Pro rather than to Fable's own chain. These chains apply whichever provider serves the model; set a key to `[]` to opt out entirely, or set one `provider/claude-fable-5-1` key to override just that provider. For example, this switches Fable 5.1 to Kimi K3 at `max` thinking when an eligible failure occurs:
336
336
 
337
337
  ```json
338
338
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@code-yeongyu/senpi",
3
- "version": "2026.11.13",
3
+ "version": "2026.11.15-2",
4
4
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
5
5
  "type": "module",
6
6
  "piConfig": {
@@ -92,10 +92,10 @@
92
92
  },
93
93
  "dependencies": {
94
94
  "@earendil-works/chord": "0.99.1",
95
- "@earendil-works/pi-agent-core": "npm:@code-yeongyu/senpi-agent-core@2026.11.13",
96
- "@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.11.13",
97
- "@earendil-works/pi-realm-registry": "npm:@code-yeongyu/senpi-realm-registry@2026.11.13",
98
- "@earendil-works/pi-tui": "npm:@code-yeongyu/senpi-tui@2026.11.13",
95
+ "@earendil-works/pi-agent-core": "npm:@code-yeongyu/senpi-agent-core@2026.11.15-2",
96
+ "@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.11.15-2",
97
+ "@earendil-works/pi-realm-registry": "npm:@code-yeongyu/senpi-realm-registry@2026.11.15-2",
98
+ "@earendil-works/pi-tui": "npm:@code-yeongyu/senpi-tui@2026.11.15-2",
99
99
  "@silvia-odwyer/photon-node": "0.3.4",
100
100
  "ajv": "8.20.0",
101
101
  "chalk": "6.0.0",
@@ -116,8 +116,8 @@
116
116
  "yaml": "2.9.1",
117
117
  "@anthropic-ai/claude-agent-sdk": "0.3.321",
118
118
  "@anthropic-ai/sdk": "0.127.0",
119
- "@code-yeongyu/senpi-codemode": "2026.11.13",
120
- "@earendil-works/pi-pty": "npm:@code-yeongyu/senpi-pty@2026.11.13",
119
+ "@code-yeongyu/senpi-codemode": "2026.11.15-2",
120
+ "@earendil-works/pi-pty": "npm:@code-yeongyu/senpi-pty@2026.11.15-2",
121
121
  "@modelcontextprotocol/sdk": "1.32.1",
122
122
  "@mozilla/readability": "0.6.0",
123
123
  "linkedom": "0.18.13",