@code-yeongyu/senpi 2026.10.16-2 → 2026.10.16-4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/dist/bundle/chunks/{chunk-QQUHMCOM.js → chunk-2NO5ZOYE.js} +6 -6
  3. package/dist/bundle/chunks/chunk-74YFRVGQ.js +2 -0
  4. package/dist/bundle/chunks/{chunk-4R4PRTF5.js → chunk-CJRE5LLR.js} +1 -1
  5. package/dist/bundle/chunks/{chunk-Q7RD3NOC.js → chunk-GY5O7NIQ.js} +1 -1
  6. package/dist/bundle/chunks/{chunk-KRAABTZY.js → chunk-JD4CV3GH.js} +2 -2
  7. package/dist/bundle/chunks/chunk-K2EVQGGT.js +31 -0
  8. package/dist/bundle/chunks/{chunk-SSEGIXRL.js → chunk-KUQ3K33L.js} +1 -1
  9. package/dist/bundle/chunks/{chunk-V5JFWWGJ.js → chunk-KWC3MCLP.js} +1 -1
  10. package/dist/bundle/chunks/chunk-MLL7AGVM.js +3 -0
  11. package/dist/bundle/chunks/chunk-PAJKEG2L.js +8 -0
  12. package/dist/bundle/chunks/{chunk-DPRV3FR7.js → chunk-PWHAOWNH.js} +1 -1
  13. package/dist/bundle/chunks/chunk-WL4Y6XN6.js +18 -0
  14. package/dist/bundle/chunks/{chunk-VY43SSYL.js → chunk-XCRGEMY2.js} +144 -134
  15. package/dist/bundle/chunks/{cli-main-JDL52ADK.js → cli-main-I6JF2GPS.js} +1 -1
  16. package/dist/bundle/chunks/google-generative-ai-AI6PAUPA.js +2 -0
  17. package/dist/bundle/chunks/google-vertex-USNRIFF7.js +2 -0
  18. package/dist/bundle/chunks/google-vertex-adc-JZURL4XM.js +2 -0
  19. package/dist/bundle/chunks/{help-fast-path-SVCMHCYG.js → help-fast-path-APSHUDY6.js} +1 -1
  20. package/dist/bundle/chunks/{host-session-command-46PTZGIB.js → host-session-command-BUJUJHDA.js} +1 -1
  21. package/dist/bundle/chunks/{interactive-mode-7IM7YJYH.js → interactive-mode-4Q3TIXOZ.js} +1 -1
  22. package/dist/bundle/chunks/{multi-session-host-3MEEUMYU.js → multi-session-host-XEZB2XVR.js} +6 -5
  23. package/dist/bundle/chunks/{openai-codex-responses-T26WW2RE.js → openai-codex-responses-NV27Q6VV.js} +1 -1
  24. package/dist/bundle/chunks/{package-manager-cli-UCHXVBQ2.js → package-manager-cli-YWZHOZOY.js} +1 -1
  25. package/dist/bundle/chunks/{rotation-stream-QX7YSKDU.js → rotation-stream-HY3JPQCX.js} +1 -1
  26. package/dist/bundle/chunks/{rpc-mode-D4BJKWFL.js → rpc-mode-SLWXVXXE.js} +1 -1
  27. package/dist/bundle/chunks/{schedule-command-2RHSWBQO.js → schedule-command-JRWZGEDU.js} +1 -1
  28. package/dist/bundle/chunks/{session-control-endpoint-NBSRSAJS.js → session-control-endpoint-E2ZSEWXW.js} +1 -1
  29. package/dist/bundle/chunks/session-picker-5IZY7E3G.js +2 -0
  30. package/dist/bundle/chunks/session-worker.js +266 -255
  31. package/dist/bundle/cli.js +2 -2
  32. package/dist/bundle/index.js +1 -1
  33. package/dist/bundle/rpc-entry.js +1 -1
  34. package/dist/bundle/runtime-manifest.json +1 -1
  35. package/dist/core/agent-session-runtime.d.ts +15 -0
  36. package/dist/core/agent-session-runtime.js +53 -2
  37. package/dist/core/agent-session.d.ts +28 -0
  38. package/dist/core/agent-session.js +88 -3
  39. package/dist/core/context-files.d.ts +21 -0
  40. package/dist/core/context-files.js +175 -0
  41. package/dist/core/diagnostics.d.ts +1 -0
  42. package/dist/core/dynamic-prompt/intent-gate.d.ts +3 -0
  43. package/dist/core/dynamic-prompt/intent-gate.js +4 -3
  44. package/dist/core/dynamic-prompt/policies.d.ts +4 -1
  45. package/dist/core/dynamic-prompt/policies.js +3 -2
  46. package/dist/core/dynamic-prompt/working-task.d.ts +4 -1
  47. package/dist/core/dynamic-prompt/working-task.js +3 -2
  48. package/dist/core/dynamic-prompt/workstation.d.ts +4 -3
  49. package/dist/core/dynamic-prompt/workstation.js +1 -0
  50. package/dist/core/extensions/builtin/anthropic-subscription/account-command.js +26 -3
  51. package/dist/core/extensions/builtin/anthropic-subscription/account-management.d.ts +5 -0
  52. package/dist/core/extensions/builtin/anthropic-subscription/account-management.js +40 -0
  53. package/dist/core/extensions/builtin/anthropic-subscription/accounts.d.ts +3 -0
  54. package/dist/core/extensions/builtin/anthropic-subscription/accounts.js +1 -1
  55. package/dist/core/extensions/builtin/anthropic-subscription/affinity.js +1 -1
  56. package/dist/core/extensions/builtin/anthropic-subscription/auth-lane.js +1 -0
  57. package/dist/core/extensions/builtin/anthropic-subscription/failover.d.ts +10 -5
  58. package/dist/core/extensions/builtin/anthropic-subscription/failover.js +158 -17
  59. package/dist/core/extensions/builtin/anthropic-subscription/usage-limit-block.d.ts +13 -0
  60. package/dist/core/extensions/builtin/anthropic-subscription/usage-limit-block.js +89 -0
  61. package/dist/core/extensions/builtin/goal/index.js +18 -8
  62. package/dist/core/extensions/builtin/goal/monitor-continuation.js +7 -0
  63. package/dist/core/extensions/builtin/goal/reload-reengagement.js +5 -0
  64. package/dist/core/extensions/builtin/index.js +3 -0
  65. package/dist/core/extensions/builtin/permission-system/always-scope.d.ts +34 -0
  66. package/dist/core/extensions/builtin/permission-system/always-scope.js +77 -0
  67. package/dist/core/extensions/builtin/permission-system/index.js +3 -1
  68. package/dist/core/extensions/builtin/permission-system/parsers.d.ts +1 -1
  69. package/dist/core/extensions/builtin/permission-system/parsers.js +24 -3
  70. package/dist/core/extensions/builtin/permission-system/prompt.js +5 -1
  71. package/dist/core/extensions/builtin/permission-system/service.js +1 -1
  72. package/dist/core/extensions/builtin/permission-system/types.d.ts +7 -0
  73. package/dist/core/extensions/builtin/prompt-preset/contract.js +6 -1
  74. package/dist/core/extensions/builtin/prompt-preset/gemini-4-argon.d.ts +7 -0
  75. package/dist/core/extensions/builtin/prompt-preset/gemini-4-argon.js +83 -0
  76. package/dist/core/extensions/builtin/prompt-preset/matchers.d.ts +2 -0
  77. package/dist/core/extensions/builtin/prompt-preset/matchers.js +12 -0
  78. package/dist/core/extensions/builtin/prompt-preset/presets.js +3 -0
  79. package/dist/core/extensions/builtin/prompt-preset/settings.d.ts +1 -1
  80. package/dist/core/extensions/builtin/prompt-preset/settings.js +1 -0
  81. package/dist/core/extensions/builtin/vertex-model-garden/auth.d.ts +16 -0
  82. package/dist/core/extensions/builtin/vertex-model-garden/auth.js +19 -0
  83. package/dist/core/extensions/builtin/vertex-model-garden/index.d.ts +6 -0
  84. package/dist/core/extensions/builtin/vertex-model-garden/index.js +27 -0
  85. package/dist/core/extensions/builtin/vertex-model-garden/models.d.ts +3 -0
  86. package/dist/core/extensions/builtin/vertex-model-garden/models.js +34 -0
  87. package/dist/core/extensions/builtin/vertex-model-garden/transport.d.ts +14 -0
  88. package/dist/core/extensions/builtin/vertex-model-garden/transport.js +35 -0
  89. package/dist/core/extensions/types.d.ts +32 -1
  90. package/dist/core/package-manager.js +4 -32
  91. package/dist/core/project-trust.d.ts +8 -0
  92. package/dist/core/project-trust.js +12 -0
  93. package/dist/core/provider-api-key-auth.d.ts +9 -1
  94. package/dist/core/provider-api-key-auth.js +34 -0
  95. package/dist/core/provider-composer.d.ts +23 -0
  96. package/dist/core/resource-loader.d.ts +5 -7
  97. package/dist/core/resource-loader.js +11 -84
  98. package/dist/core/retry-fallback/circuit-probes.d.ts +1 -0
  99. package/dist/core/retry-fallback/circuit-probes.js +3 -0
  100. package/dist/core/retry-fallback/circuit.d.ts +5 -0
  101. package/dist/core/retry-fallback/circuit.js +27 -4
  102. package/dist/core/retry-fallback/controller.d.ts +9 -4
  103. package/dist/core/retry-fallback/controller.js +21 -4
  104. package/dist/core/settings-manager.d.ts +19 -0
  105. package/dist/core/settings-manager.js +27 -1
  106. package/dist/core/settings-shapes.d.ts +4 -0
  107. package/dist/core/skills-prompt.d.ts +18 -0
  108. package/dist/core/skills-prompt.js +131 -0
  109. package/dist/core/skills.d.ts +1 -9
  110. package/dist/core/skills.js +1 -59
  111. package/dist/core/trust-manager.d.ts +13 -4
  112. package/dist/core/trust-manager.js +52 -21
  113. package/dist/core/turn-deadline.d.ts +92 -0
  114. package/dist/core/turn-deadline.js +179 -0
  115. package/dist/main.js +23 -4
  116. package/dist/modes/interactive/interactive-mode.js +10 -3
  117. package/dist/modes/rpc/abort-grace.d.ts +9 -0
  118. package/dist/modes/rpc/abort-grace.js +11 -0
  119. package/dist/modes/rpc/client-admission-record.d.ts +1 -0
  120. package/dist/modes/rpc/client-admissions.d.ts +6 -3
  121. package/dist/modes/rpc/client-admissions.js +9 -4
  122. package/dist/modes/rpc/connection-handler.js +8 -3
  123. package/dist/modes/rpc/effect-commands.d.ts +1 -3
  124. package/dist/modes/rpc/effect-commands.js +4 -5
  125. package/dist/modes/rpc/effect-outcome-copy.d.ts +4 -4
  126. package/dist/modes/rpc/effect-outcome-copy.js +4 -4
  127. package/dist/modes/rpc/effect-record.d.ts +2 -0
  128. package/dist/modes/rpc/effect-record.js +2 -0
  129. package/dist/modes/rpc/effect-status.js +1 -1
  130. package/dist/modes/rpc/host-handover-parked.d.ts +6 -0
  131. package/dist/modes/rpc/host-handover-parked.js +1 -0
  132. package/dist/modes/rpc/host-handover-reopen.js +11 -2
  133. package/dist/modes/rpc/rpc-client.d.ts +28 -2
  134. package/dist/modes/rpc/rpc-client.js +24 -11
  135. package/dist/modes/rpc/rpc-request-deadline.d.ts +12 -0
  136. package/dist/modes/rpc/rpc-request-deadline.js +15 -0
  137. package/dist/modes/rpc/session-command-router.js +7 -4
  138. package/dist/modes/rpc/session-registry-attach.js +6 -1
  139. package/dist/modes/rpc/session-registry-claims.d.ts +22 -2
  140. package/dist/modes/rpc/session-registry-claims.js +68 -0
  141. package/dist/modes/rpc/session-registry-opening.d.ts +23 -0
  142. package/dist/modes/rpc/session-registry-opening.js +57 -0
  143. package/dist/modes/rpc/session-registry.d.ts +1 -0
  144. package/dist/modes/rpc/session-registry.js +48 -60
  145. package/dist/modes/rpc/session-unload.js +4 -1
  146. package/docs/rpc.md +10 -3
  147. package/docs/settings.md +16 -1
  148. package/package.json +6 -6
  149. package/dist/bundle/chunks/chunk-6MEETY7N.js +0 -8
  150. package/dist/bundle/chunks/chunk-7ASXJ4AD.js +0 -47
  151. package/dist/bundle/chunks/chunk-TEGEPQIA.js +0 -3
  152. package/dist/bundle/chunks/google-generative-ai-YNIOL5FT.js +0 -2
  153. package/dist/bundle/chunks/google-vertex-CF6AYVXL.js +0 -2
  154. package/dist/bundle/chunks/session-picker-6TSBGWOA.js +0 -2
@@ -20,7 +20,7 @@ export class RpcCommandError extends Error {
20
20
  }
21
21
  }
22
22
  import { attachJsonlLineReader, serializeJsonLine } from "./jsonl.js";
23
- import { armRequestDeadline, OPEN_AFTER_QUEUED_DEADLINE_MS, openStalledMessage, PROMPT_ACK_MAX_WAIT_MS, PROMPT_AFTER_QUEUED_DEADLINE_MS, PROMPT_COMPACTION_DEADLINE_MS, promptStalledMessage, REQUEST_DEADLINE_MS, RpcRequestTimeoutError, } from "./rpc-request-deadline.js";
23
+ import { armRequestDeadline, OPEN_AFTER_QUEUED_DEADLINE_MS, openStalledMessage, PROMPT_ACK_MAX_WAIT_MS, PROMPT_AFTER_QUEUED_DEADLINE_MS, PROMPT_COMPACTION_DEADLINE_MS, promptStalledMessage, REQUEST_DEADLINE_MS, RpcRequestTimeoutError, requestDeadlineMs, } from "./rpc-request-deadline.js";
24
24
  import { RPC_ERROR_UNKNOWN_COMMAND } from "./rpc-types.js";
25
25
  import { readSocketSecret, resolveSocketTransportAddress, sendSocketHandshake, socketSecretPath, } from "./socket-transport.js";
26
26
  import { socketNeedsHandshake } from "./tui-socket.js";
@@ -455,17 +455,30 @@ export class RpcClient {
455
455
  // Older hosts omit the disposition; degrade like prompt() so clients render only canonical events.
456
456
  return this.getData(response)?.disposition ?? "handled";
457
457
  }
458
+ async abort(options) {
459
+ if (options === undefined) {
460
+ try {
461
+ await this.send({ type: "abort" });
462
+ }
463
+ catch (error) {
464
+ if (!isTransportGoneError(error))
465
+ throw error;
466
+ }
467
+ return;
468
+ }
469
+ return this.getData(await this.send({ ...options, type: "abort" }));
470
+ }
458
471
  /**
459
- * Abort current operation.
472
+ * The newest receipt of one effect (`get_effect_status`, capability `effect_ledger`). With `sessionPath`
473
+ * the host reads that transcript from disk, so a session no host holds is answered without loading it.
460
474
  */
461
- async abort() {
462
- try {
463
- await this.send({ type: "abort" });
464
- }
465
- catch (error) {
466
- if (!isTransportGoneError(error))
467
- throw error;
468
- }
475
+ async getEffectStatus(effectId, options = {}) {
476
+ const command = {
477
+ type: "get_effect_status",
478
+ effectId,
479
+ ...(options.sessionPath === undefined ? {} : { sessionPath: options.sessionPath }),
480
+ };
481
+ return this.getData(await this.send(command, options.sessionPath === undefined));
469
482
  }
470
483
  /** Abort with host refusals and transport loss surfaced to machine callers. */
471
484
  async abortStrict() {
@@ -1018,7 +1031,7 @@ export class RpcClient {
1018
1031
  };
1019
1032
  const deadline = armRequestDeadline(command.type === "prompt" && sessionId === this.sessionId && this.isCompacting()
1020
1033
  ? PROMPT_COMPACTION_DEADLINE_MS
1021
- : REQUEST_DEADLINE_MS, timeoutMessage, expire);
1034
+ : requestDeadlineMs(command), timeoutMessage, expire);
1022
1035
  // Compaction events extend a prompt's wait, but never past this cap from when it was sent.
1023
1036
  if (command.type === "prompt") {
1024
1037
  promptCap = setTimeout(() => {
@@ -1,5 +1,17 @@
1
1
  /** How long any request waits for the host's answer - for an open, for its `queued` acknowledgement. */
2
2
  export declare const REQUEST_DEADLINE_MS = 30000;
3
+ /**
4
+ * How long one request may wait for its answer before the client gives up. An awaited abort answers only
5
+ * after the turn stopped or its `graceMs` ran out (up to 60 s), so its wait is that grace plus the
6
+ * ordinary allowance; every other command keeps the plain request deadline. A `graceMs` the host would
7
+ * refuse (out of range, fractional, NaN, Infinity) gets the plain deadline too: the host answers it at once
8
+ * with `invalid_argument`, and the client must still be listening for that refusal.
9
+ */
10
+ export declare function requestDeadlineMs(command: {
11
+ readonly type: string;
12
+ readonly awaitIdle?: unknown;
13
+ readonly graceMs?: unknown;
14
+ }): number;
3
15
  /** Remote compaction may exhaust its budget before a local summary uses its maximum override. */
4
16
  export declare const PROMPT_COMPACTION_DEADLINE_MS: number;
5
17
  /**
@@ -1,6 +1,21 @@
1
1
  import { SUMMARIZATION_MAX_DURATION_CAP_MS, SUMMARIZATION_TOTAL_BUDGET_MS, } from "../../core/compaction/stream-watchdog.js";
2
+ import { ABORT_DEFAULT_GRACE_MS, isAbortGraceInRange } from "./abort-grace.js";
2
3
  /** How long any request waits for the host's answer - for an open, for its `queued` acknowledgement. */
3
4
  export const REQUEST_DEADLINE_MS = 30_000;
5
+ /**
6
+ * How long one request may wait for its answer before the client gives up. An awaited abort answers only
7
+ * after the turn stopped or its `graceMs` ran out (up to 60 s), so its wait is that grace plus the
8
+ * ordinary allowance; every other command keeps the plain request deadline. A `graceMs` the host would
9
+ * refuse (out of range, fractional, NaN, Infinity) gets the plain deadline too: the host answers it at once
10
+ * with `invalid_argument`, and the client must still be listening for that refusal.
11
+ */
12
+ export function requestDeadlineMs(command) {
13
+ if (command.type !== "abort" || command.awaitIdle !== true)
14
+ return REQUEST_DEADLINE_MS;
15
+ if (command.graceMs === undefined)
16
+ return ABORT_DEFAULT_GRACE_MS + REQUEST_DEADLINE_MS;
17
+ return isAbortGraceInRange(command.graceMs) ? command.graceMs + REQUEST_DEADLINE_MS : REQUEST_DEADLINE_MS;
18
+ }
4
19
  /** Remote compaction may exhaust its budget before a local summary uses its maximum override. */
5
20
  export const PROMPT_COMPACTION_DEADLINE_MS = SUMMARIZATION_TOTAL_BUDGET_MS + SUMMARIZATION_MAX_DURATION_CAP_MS + REQUEST_DEADLINE_MS;
6
21
  /**
@@ -476,10 +476,13 @@ export class SessionCommandRouter {
476
476
  parkedPath !== undefined &&
477
477
  retained?.retainOnDisconnect === true &&
478
478
  retained.sessionPath !== undefined) {
479
- await recordHandoverParkedSession(this.handoverParkedDir, parkedEntryFromProfile({
480
- sessionPath: retained.sessionPath,
481
- ...(retained.durableSessionId !== undefined ? { durableSessionId: retained.durableSessionId } : {}),
482
- }, retained.profile, Object.keys(this.defaults.hostContext ?? {}))).catch((cause) => {
479
+ await recordHandoverParkedSession(this.handoverParkedDir, {
480
+ ...parkedEntryFromProfile({
481
+ sessionPath: retained.sessionPath,
482
+ ...(retained.durableSessionId !== undefined ? { durableSessionId: retained.durableSessionId } : {}),
483
+ }, retained.profile, Object.keys(this.defaults.hostContext ?? {})),
484
+ parkedBy: this.defaults.hostContext?.host_instance ?? hostInstanceId(),
485
+ }).catch((cause) => {
483
486
  process.stderr.write(`senpi rpc handover park record ${sessionId} failed: ${String(cause)}\n`);
484
487
  });
485
488
  }
@@ -72,7 +72,12 @@ export async function attachToOpenSession(entries, sessionPath, profile, options
72
72
  }
73
73
  return attachToOpenSession(entries, sessionPath, profile, options, pathReservations, now);
74
74
  }
75
- throw new RpcSessionRegistryError("session_path_in_use");
75
+ // The path is still reserved by a session that is not serving (a teardown past its grace
76
+ // window): never a bare refusal, so the caller knows to retry (senpi#3485).
77
+ throw new RpcSessionRegistryError("session_path_in_use", "the session is still being released", {
78
+ reason: "path_still_held",
79
+ retry_after_ms: OPEN_IN_PROGRESS_WAIT_RETRY_MS,
80
+ });
76
81
  }
77
82
  const [handle, entry] = existing;
78
83
  const wasParked = entry.retainOnDisconnect === true && entry.attachments === 0;
@@ -2,8 +2,28 @@
2
2
  * The registry's path claims: the in-process reservation set and the cross-generation claim
3
3
  * (`host-reservations.ts`) follow whatever file each entry's runtime is actually writing.
4
4
  */
5
- import type { SessionPathReservations } from "./host-reservations.ts";
6
- import type { RpcSessionEntry } from "./session-registry-types.ts";
5
+ import { type SessionPathReservations } from "./host-reservations.ts";
6
+ import { type RpcSessionEntry } from "./session-registry-types.ts";
7
+ /** Opens between their in-process reservation and their entry, by path: their cross-generation claim is in flight. */
8
+ export type PendingClaims = Map<string, Promise<void>>;
9
+ /**
10
+ * Marks `sessionPath` as mid-claim. The returned settle is called once the open has either recorded
11
+ * its entry or given the reservation back, so a waiter that wakes always finds one or the other.
12
+ */
13
+ export declare function beginPendingClaim(pending: PendingClaims, sessionPath: string): () => void;
14
+ /**
15
+ * Takes the cross-generation claim for an open that already holds the in-process reservation, and
16
+ * returns the settle `beginPendingClaim` handed out: the caller settles it once its entry is recorded.
17
+ * A live claim another generation holds gives the reservation back and refuses with the owner.
18
+ */
19
+ export declare function takeCrossGenerationClaim(reservations: Set<string>, pending: PendingClaims, pathReservations: SessionPathReservations, sessionPath: string): Promise<() => void>;
20
+ /**
21
+ * Waits out another open of this path that is still taking its claim (senpi#3485). Without the wait
22
+ * the path looked reserved by no session at all and a reopen was refused for a session about to be
23
+ * served. Bounded by the close grace window: a claim that never settles (a wedged daemon directory)
24
+ * ends this open with a retryable `session_path_in_use { reason: "claim_in_progress" }`.
25
+ */
26
+ export declare function settlePendingClaim(pending: ReadonlyMap<string, Promise<void>>, sessionPath: string, graceMs: number): Promise<void>;
7
27
  /** Reconcile path and durable identity after runtime replacement. */
8
28
  export declare function syncRuntimeMetadata(entries: Iterable<RpcSessionEntry>, reservations: Set<string>, pathReservations: SessionPathReservations | undefined): void;
9
29
  /**
@@ -1,4 +1,72 @@
1
+ /**
2
+ * The registry's path claims: the in-process reservation set and the cross-generation claim
3
+ * (`host-reservations.ts`) follow whatever file each entry's runtime is actually writing.
4
+ */
5
+ import { SESSION_PATH_RETRY_AFTER_MS } from "./host-reservations.js";
1
6
  import { canonicalSessionPath } from "./session-path-key.js";
7
+ import { RpcSessionRegistryError } from "./session-registry-types.js";
8
+ /**
9
+ * Marks `sessionPath` as mid-claim. The returned settle is called once the open has either recorded
10
+ * its entry or given the reservation back, so a waiter that wakes always finds one or the other.
11
+ */
12
+ export function beginPendingClaim(pending, sessionPath) {
13
+ const settled = Promise.withResolvers();
14
+ pending.set(sessionPath, settled.promise);
15
+ return () => {
16
+ if (pending.get(sessionPath) === settled.promise)
17
+ pending.delete(sessionPath);
18
+ settled.resolve();
19
+ };
20
+ }
21
+ /**
22
+ * Takes the cross-generation claim for an open that already holds the in-process reservation, and
23
+ * returns the settle `beginPendingClaim` handed out: the caller settles it once its entry is recorded.
24
+ * A live claim another generation holds gives the reservation back and refuses with the owner.
25
+ */
26
+ export async function takeCrossGenerationClaim(reservations, pending, pathReservations, sessionPath) {
27
+ const settle = beginPendingClaim(pending, sessionPath);
28
+ let holder;
29
+ try {
30
+ holder = await pathReservations.claim(sessionPath);
31
+ }
32
+ catch (cause) {
33
+ reservations.delete(sessionPath);
34
+ settle();
35
+ throw cause;
36
+ }
37
+ if (holder === undefined)
38
+ return settle;
39
+ reservations.delete(sessionPath);
40
+ settle();
41
+ throw new RpcSessionRegistryError("session_path_in_use", undefined, {
42
+ owner: holder,
43
+ retry_after_ms: SESSION_PATH_RETRY_AFTER_MS,
44
+ });
45
+ }
46
+ /**
47
+ * Waits out another open of this path that is still taking its claim (senpi#3485). Without the wait
48
+ * the path looked reserved by no session at all and a reopen was refused for a session about to be
49
+ * served. Bounded by the close grace window: a claim that never settles (a wedged daemon directory)
50
+ * ends this open with a retryable `session_path_in_use { reason: "claim_in_progress" }`.
51
+ */
52
+ export async function settlePendingClaim(pending, sessionPath, graceMs) {
53
+ let timer;
54
+ const late = new Promise((resolve) => {
55
+ timer = setTimeout(() => resolve("late"), graceMs);
56
+ });
57
+ try {
58
+ for (let claim = pending.get(sessionPath); claim !== undefined; claim = pending.get(sessionPath)) {
59
+ if ((await Promise.race([claim.then(() => "settled"), late])) === "late")
60
+ throw new RpcSessionRegistryError("session_path_in_use", "another open of this path is still taking its claim", {
61
+ reason: "claim_in_progress",
62
+ retry_after_ms: SESSION_PATH_RETRY_AFTER_MS,
63
+ });
64
+ }
65
+ }
66
+ finally {
67
+ clearTimeout(timer);
68
+ }
69
+ }
2
70
  /** Reconcile path and durable identity after runtime replacement. */
3
71
  export function syncRuntimeMetadata(entries, reservations, pathReservations) {
4
72
  for (const entry of entries) {
@@ -0,0 +1,23 @@
1
+ import type { SessionStartEvent } from "../../core/extensions/types.ts";
2
+ import type { PreparableRuntimeFactory } from "./host-warm.ts";
3
+ import { type RpcSessionEntry, type RpcSessionLaunchProfile, type RpcSessionOpenOptions } from "./session-registry-types.ts";
4
+ export interface OpeningEntry {
5
+ readonly entry: RpcSessionEntry;
6
+ readonly storedProfile: RpcSessionLaunchProfile;
7
+ readonly runtimeProfile: RpcSessionLaunchProfile;
8
+ readonly sessionStartEvent: SessionStartEvent | undefined;
9
+ readonly settleOpen: {
10
+ readonly resolve: () => void;
11
+ readonly reject: (cause: unknown) => void;
12
+ };
13
+ }
14
+ export declare function buildOpeningEntry(input: {
15
+ readonly profile: RpcSessionLaunchProfile;
16
+ readonly sessionPath: string | undefined;
17
+ readonly requestedDurableId: string | undefined;
18
+ readonly options: RpcSessionOpenOptions | undefined;
19
+ readonly now: number;
20
+ readonly createRuntime: PreparableRuntimeFactory;
21
+ readonly sync: () => void;
22
+ }): OpeningEntry;
23
+ //# sourceMappingURL=session-registry-opening.d.ts.map
@@ -0,0 +1,57 @@
1
+ /**
2
+ * The "opening" entry an `openSession` records once its path is reserved and claimed. Built in one
3
+ * synchronous step so the registry can give the reservation back if building it throws, instead of
4
+ * leaving a reserved path no entry backs (senpi#3485).
5
+ */
6
+ import { existsSync } from "node:fs";
7
+ import { ProviderScope } from "@earendil-works/pi-ai/node/provider-scope";
8
+ import { createEntrySwitchSession } from "./session-registry-switch.js";
9
+ import { frozenProfile, sessionIdentity, } from "./session-registry-types.js";
10
+ export function buildOpeningEntry(input) {
11
+ const { profile, sessionPath, requestedDurableId, options } = input;
12
+ // Resume vs create parity (D1 + omo SenpiSessionRuntime.ts:198-200):
13
+ // Create-only launch semantics mirror classic startup flags. A resumed
14
+ // session restores its persisted model and thinking level instead of being
15
+ // overridden by the new open_session request.
16
+ const isResume = sessionPath !== undefined && existsSync(sessionPath);
17
+ // Re-opening an existing session file is a resume, exactly like interactive
18
+ // /resume (AgentSessionRuntime.switchSession). Without the event the session
19
+ // starts with reason "startup" and every extension that only rebuilds state
20
+ // on a resume - the ask-user dangling-question hook - stays unreachable from
21
+ // the RPC restart path. A session created by this open stays "startup".
22
+ const sessionStartEvent = isResume
23
+ ? { type: "session_start", reason: "resume" }
24
+ : undefined;
25
+ const storedProfile = frozenProfile({ ...profile, ...(sessionPath ? { sessionPath } : {}) });
26
+ const runtimeProfile = isResume
27
+ ? frozenProfile({ ...storedProfile, creationModel: undefined, initialThinkingLevel: undefined })
28
+ : storedProfile;
29
+ let settleOpen;
30
+ const openCompletion = new Promise((resolve, reject) => {
31
+ settleOpen = { resolve, reject };
32
+ });
33
+ // A concurrent explicit open for the same path waits on this to attach, never to time it out.
34
+ openCompletion.catch(() => { });
35
+ const entry = {
36
+ state: "opening",
37
+ scope: new ProviderScope(),
38
+ profile: storedProfile,
39
+ ...sessionIdentity(storedProfile),
40
+ sessionPath,
41
+ reservationKey: sessionPath,
42
+ cwd: storedProfile.cwd,
43
+ attachments: 1,
44
+ retainOnDisconnect: options?.retainOnDisconnect === true,
45
+ lastCommandAt: input.now,
46
+ lifecycleMutex: Promise.resolve(),
47
+ openCompletion,
48
+ };
49
+ entry.switchSession = createEntrySwitchSession(entry, input.createRuntime, input.sync);
50
+ // Recorded before the first await so the synchronous collision guard sees an open that is still
51
+ // being built. `manager.getSessionId()` overwrites it later with the authoritative value, which on
52
+ // a resume is the header's id, not the requested one.
53
+ if (requestedDurableId !== undefined)
54
+ entry.durableSessionId = requestedDurableId;
55
+ return { entry, storedProfile, runtimeProfile, sessionStartEvent, settleOpen };
56
+ }
57
+ //# sourceMappingURL=session-registry-opening.js.map
@@ -5,6 +5,7 @@ export { frozenProfile, type OpenRpcSession, type RpcSessionEntry, type RpcSessi
5
5
  export declare class RpcSessionRegistry {
6
6
  private readonly entries;
7
7
  private readonly reservations;
8
+ private readonly pendingClaims;
8
9
  private readonly teardownHost;
9
10
  private nextHandle;
10
11
  private readonly options;
@@ -1,21 +1,19 @@
1
- import { existsSync } from "node:fs";
2
1
  import { isAbsolute } from "node:path";
3
- import { ProviderScope, runWithProviderScope } from "@earendil-works/pi-ai/node/provider-scope";
2
+ import { runWithProviderScope } from "@earendil-works/pi-ai/node/provider-scope";
4
3
  import { createAgentSessionRuntime } from "../../core/agent-session-runtime.js";
5
4
  import { assertValidSessionId, SessionManager } from "../../core/session-manager.js";
6
5
  import { ClientAdmissions } from "./client-admissions.js";
7
6
  import { EffectLedger } from "./effect-ledger.js";
8
- import { SESSION_PATH_RETRY_AFTER_MS } from "./host-reservations.js";
9
7
  import { createRegistryWarm } from "./host-warm.js";
10
8
  import { assertRequestedModelResolved } from "./open-session-model.js";
11
9
  import { refreshesSessionActivity } from "./session-command-activity.js";
12
10
  import { publishedSessionContext } from "./session-context-patch.js";
13
11
  import { canonicalSessionPath } from "./session-path-key.js";
14
12
  import { attachToOpenSession } from "./session-registry-attach.js";
15
- import { settleClosingReservation, syncRuntimeMetadata } from "./session-registry-claims.js";
13
+ import { settleClosingReservation, settlePendingClaim, syncRuntimeMetadata, takeCrossGenerationClaim, } from "./session-registry-claims.js";
16
14
  import { resolveMovedProfile } from "./session-registry-moved-path.js";
17
- import { createEntrySwitchSession } from "./session-registry-switch.js";
18
- import { frozenProfile, RpcSessionRegistryError, sessionIdentity, } from "./session-registry-types.js";
15
+ import { buildOpeningEntry } from "./session-registry-opening.js";
16
+ import { RpcSessionRegistryError, } from "./session-registry-types.js";
19
17
  import { beginSessionClose, closeMarkedSession, closeSession, releaseWithinGrace, } from "./session-teardown.js";
20
18
  export { frozenProfile, RpcSessionRegistryError, sessionIdentity, } from "./session-registry-types.js";
21
19
  /** Process-local lifecycle owner for multi-session RPC runtimes. */
@@ -23,6 +21,7 @@ export class RpcSessionRegistry {
23
21
  constructor(options) {
24
22
  this.entries = new Map();
25
23
  this.reservations = new Set();
24
+ this.pendingClaims = new Map();
26
25
  this.nextHandle = 0;
27
26
  if (options.createRuntime.prepare)
28
27
  this.warm = createRegistryWarm(options.createRuntime.prepare, {
@@ -85,8 +84,14 @@ export class RpcSessionRegistry {
85
84
  throw new RpcSessionRegistryError("session_id_in_use");
86
85
  }
87
86
  }
88
- if (sessionPath)
87
+ if (sessionPath) {
89
88
  await settleClosingReservation(this.entries.values(), sessionPath, this.closeGraceMs);
89
+ // Re-checked right before the reservation decision, with no await in between: a waiter woken
90
+ // by a FAILED claim must see the next open's claim, not a bare reservation (senpi#3485 review).
91
+ const deadline = Date.now() + this.closeGraceMs;
92
+ while (this.pendingClaims.has(sessionPath))
93
+ await settlePendingClaim(this.pendingClaims, sessionPath, Math.max(0, deadline - Date.now()));
94
+ }
90
95
  if (sessionPath && this.reservations.has(sessionPath)) {
91
96
  return attachToOpenSession(this.entries, sessionPath, profile, options, this.options.pathReservations, this.now());
92
97
  }
@@ -95,6 +100,7 @@ export class RpcSessionRegistry {
95
100
  reason: "session_not_held",
96
101
  action: "rediscover_holder",
97
102
  });
103
+ let claimSettled;
98
104
  if (sessionPath) {
99
105
  // Taken SYNCHRONOUSLY, before any await: a concurrent open for the same path must find the
100
106
  // reservation already held, not a window between the decision and the record of it.
@@ -104,62 +110,44 @@ export class RpcSessionRegistry {
104
110
  // Only AWAIT when there is a cross-generation claim to take: `await undefined` still costs a
105
111
  // microtask, and an embedded registry (no second generation) must reach the "opening" entry
106
112
  // in the same tick a caller that raced it would look, exactly as it did before generations.
107
- const holder = this.options.pathReservations
108
- ? await this.options.pathReservations.claim(sessionPath)
109
- : undefined;
110
- if (holder) {
111
- this.reservations.delete(sessionPath);
112
- throw new RpcSessionRegistryError("session_path_in_use", undefined, {
113
- owner: holder,
114
- retry_after_ms: SESSION_PATH_RETRY_AFTER_MS,
115
- });
116
- }
113
+ // A concurrent open of this path waits on `claimSettled` instead of finding a reservation no
114
+ // entry backs (senpi#3485); it is settled once the entry is recorded or the reservation is back.
115
+ if (this.options.pathReservations)
116
+ claimSettled = await takeCrossGenerationClaim(this.reservations, this.pendingClaims, this.options.pathReservations, sessionPath);
117
117
  }
118
- // Resume vs create parity (D1 + omo SenpiSessionRuntime.ts:198-200):
119
- // Create-only launch semantics mirror classic startup flags. A resumed
120
- // session restores its persisted model and thinking level instead of being
121
- // overridden by the new open_session request.
122
- const isResume = sessionPath !== undefined && existsSync(sessionPath);
123
- // Re-opening an existing session file is a resume, exactly like interactive
124
- // /resume (AgentSessionRuntime.switchSession). Without the event the session
125
- // starts with reason "startup" and every extension that only rebuilds state
126
- // on a resume - the ask-user dangling-question hook - stays unreachable from
127
- // the RPC restart path. A session created by this open stays "startup".
128
- const sessionStartEvent = isResume
129
- ? { type: "session_start", reason: "resume" }
130
- : undefined;
131
- const storedProfile = frozenProfile({ ...profile, ...(sessionPath ? { sessionPath } : {}) });
132
- const runtimeProfile = isResume
133
- ? frozenProfile({ ...storedProfile, creationModel: undefined, initialThinkingLevel: undefined })
134
- : storedProfile;
135
118
  const handle = `rpc-${++this.nextHandle}`;
136
- let settleOpen;
137
- const openCompletion = new Promise((resolve, reject) => {
138
- settleOpen = { resolve, reject };
139
- });
140
- // A concurrent explicit open for the same path waits on this to attach, never to time it out.
141
- openCompletion.catch(() => { });
142
- const entry = {
143
- state: "opening",
144
- scope: new ProviderScope(),
145
- profile: storedProfile,
146
- ...sessionIdentity(storedProfile),
147
- sessionPath,
148
- reservationKey: sessionPath,
149
- cwd: storedProfile.cwd,
150
- attachments: 1,
151
- retainOnDisconnect: options?.retainOnDisconnect === true,
152
- lastCommandAt: this.now(),
153
- lifecycleMutex: Promise.resolve(),
154
- openCompletion,
155
- };
156
- entry.switchSession = createEntrySwitchSession(entry, this.options.createRuntime, () => this.syncRuntimeMetadata());
157
- // Recorded before the first await so the synchronous collision guard above sees an open
158
- // that is still being built. `manager.getSessionId()` overwrites it below with the
159
- // authoritative value, which on a resume is the header's id, not the requested one.
160
- if (requestedDurableId !== undefined)
161
- entry.durableSessionId = requestedDurableId;
119
+ let opening;
120
+ try {
121
+ opening = buildOpeningEntry({
122
+ profile,
123
+ sessionPath,
124
+ requestedDurableId,
125
+ options,
126
+ now: this.now(),
127
+ createRuntime: this.options.createRuntime,
128
+ sync: () => this.syncRuntimeMetadata(),
129
+ });
130
+ }
131
+ catch (cause) {
132
+ // Give the path back before waking waiters, or they find a reservation no entry backs. A failed
133
+ // claim release is reported, never allowed to strand the waiters or replace the build error.
134
+ try {
135
+ if (sessionPath) {
136
+ this.reservations.delete(sessionPath);
137
+ await this.options.pathReservations?.release(sessionPath);
138
+ }
139
+ }
140
+ catch (releaseCause) {
141
+ process.stderr.write(`senpi rpc open of ${sessionPath} could not release its claim: ${String(releaseCause)}\n`);
142
+ }
143
+ finally {
144
+ claimSettled?.();
145
+ }
146
+ throw cause;
147
+ }
148
+ const { entry, storedProfile, runtimeProfile, sessionStartEvent, settleOpen } = opening;
162
149
  this.entries.set(handle, entry);
150
+ claimSettled?.();
163
151
  this.options.onSizeChange?.(this.entries.size);
164
152
  try {
165
153
  const newSessionOptions = requestedDurableId !== undefined ? { id: requestedDurableId } : undefined;
@@ -1,3 +1,4 @@
1
+ import { ClientAdmissions } from "./client-admissions.js";
1
2
  import { beginEffect, effectAnswer, isEffectBegin } from "./effect-commands.js";
2
3
  import { EffectLedger } from "./effect-ledger.js";
3
4
  import { abortAndSettle } from "./session-release-interrupt.js";
@@ -49,8 +50,10 @@ async function claimAndUnload(port, command) {
49
50
  const stillBlocked = blockers(port, command, ready.session, ready.durableSessionId);
50
51
  if (stillBlocked.length > 0)
51
52
  return fail(command, ready.session, stillBlocked[0]);
52
- if (command.clearQueue === true)
53
+ if (command.clearQueue === true) {
54
+ ClientAdmissions.clearQueuedFor(ready.session);
53
55
  ready.session.clearQueue();
56
+ }
54
57
  const effect = command.effectId === undefined
55
58
  ? undefined
56
59
  : EffectLedger.forSession(ready.session).transition(command.effectId, "done", { disposition: "unloaded" });
package/docs/rpc.md CHANGED
@@ -1585,7 +1585,9 @@ What the host does enforce are lifecycle windows, and they only ever return memo
1585
1585
  reaches zero attachments and closes the session, host shutdown closes it, and the idle-eviction window above still
1586
1586
  parks it (or the completed-detached-worker rule above parks it sooner) — announced as
1587
1587
  `session_parked { sessionId, sessionPath }`, after which the file reopens by path like any
1588
- evicted session, and the parked session counts as gone for the empty-host exit below. It is also bounded by the empty-host exit
1588
+ evicted session, and the parked session counts as gone for the empty-host exit below. The park is also recorded
1589
+ for the NEXT generation, which reopens it on startup; the host that parked it never reopens it on its own, so a
1590
+ client reopening the session right after `session_parked` is the only open of that path (senpi#3485). It is also bounded by the empty-host exit
1589
1591
  and the supervisor's idle-exit window below: retention survives a client, not the host. Any attach may turn retention
1590
1592
  on for a live session; no attach turns it off for clients that already rely on it. Omitting the flag is byte-identical
1591
1593
  to the previous behavior — the session is closed with its last connection. Probe `retain_on_disconnect` in
@@ -2066,7 +2068,7 @@ codes or code prefixes in `error`:
2066
2068
  - **Mid-turn policy:** a turn admitted before the foreign holder appeared finishes and persists its results; subsequent writing RPCs are refused. Read/control requests, including abort, queue clearing, `extension_ui_response` and `extension_ui_progress`, remain available.
2067
2069
  - **Conservative admission:** `new_session`, `fork`, `clone`, and settings or account commands carrying this session's routing handle are also refused while its current file is held. Close the handle and open another session to leave a held file.
2068
2070
  - Holder records are admission evidence, not an exclusive filesystem lock. SessionManager's append API is synchronous and fresh process-identity checks are asynchronous; changing append to asynchronous admission would change the SDK contract and could discard results of already-executed tools.
2069
- - `session_path_in_use` (path held by an opening or quarantined owner; a fully-open current owner is attached instead, an owner whose teardown is already in flight is waited out on the in-process runtime, and a path a live owner has superseded is released rather than held). A path held by ANOTHER GENERATION of the daemon carries `errorData { owner: { instanceId, pid, processStartTime, sessionPath, current }, retry_after_ms: 2000 }`: that generation is still writing the file and is parking it, so the open is a retry, not a failure. Only a claim whose owner serves the socket (`current: true`) or still has a client attached refuses an open at all - a superseded, attachment-less claim is reclaimed instead
2071
+ - `session_path_in_use` (path held by an opening or quarantined owner; a fully-open current owner is attached instead, an owner whose teardown is already in flight is waited out on the in-process runtime, and a path a live owner has superseded is released rather than held). A path held by ANOTHER GENERATION of the daemon carries `errorData { owner: { instanceId, pid, processStartTime, sessionPath, current }, retry_after_ms: 2000 }`: that generation is still writing the file and is parking it, so the open is a retry, not a failure. An open that arrives while ANOTHER open of the same path is still taking that cross-generation claim waits for it - bounded by the close grace window - and then attaches to the session that open is building; a claim that does not settle in that window answers `errorData { reason: "claim_in_progress", retry_after_ms: 2000 }`. When the claim it waited on is refused, the next waiting open takes the claim itself and later ones wait on that, so no racing open answers a bare refusal; a path still reserved by a session past its teardown grace answers `errorData { reason: "path_still_held", retry_after_ms: 2000 }` (senpi#3485). Only a claim whose owner serves the socket (`current: true`) or still has a client attached refuses an open at all - a superseded, attachment-less claim is reclaimed instead
2070
2072
  - `session_reservation_limit` (this worker already holds 64 live session paths; the open or session replacement was refused without disturbing the existing session)
2071
2073
  - `missing_session_id` (session-scoped command without `sessionId` in multi mode)
2072
2074
  - `multi_session_disabled` (`open_session` in classic mode)
@@ -2410,7 +2412,10 @@ Quiesce and queue options (both optional, combinable with `effectId`):
2410
2412
  `{ reason: "invalid_grace_ms", action }`.
2411
2413
  - `clearQueue: true` drains the steering and follow-up queues atomically before the abort, emits
2412
2414
  `queue_update`, and gives each drained input with a `clientMessageId` a `failed` receipt with outcome
2413
- `cleared_by_abort` (with `reason`, `detail` and `action`). The answer's `cleared` counts the drained inputs.
2415
+ `cleared` (with `reason`, `detail` and `action`). 2026.10.16-2 wrote this outcome as `cleared_by_abort`;
2416
+ receipts stored under that name are still read and reported unchanged, so accept both. The answer's `cleared` counts the drained inputs.
2417
+ `clear_queue` and `unload_session { clearQueue: true }` close drained input with the same `cleared` outcome; a
2418
+ session release does not (its queued deliveries stay unwritten so the sender redelivers them).
2414
2419
  It also empties the queue of an idle session.
2415
2420
 
2416
2421
  ```json
@@ -2418,6 +2423,8 @@ Quiesce and queue options (both optional, combinable with `effectId`):
2418
2423
  {"type": "response", "command": "abort", "success": true, "data": {"effectId": "stop-2", "state": "done", "effect": {"effectId": "stop-2", "kind": "abort", "state": "done", "durableSessionId": "session-id"}, "stopped": true, "idle": true, "cleared": 1}}
2419
2424
  ```
2420
2425
 
2426
+ The typed `RpcClient` sends these with `abort({ effectId, awaitIdle, clearQueue, graceMs })` and returns the answer; `abort()` with no options keeps the old fire-and-forget contract. `getEffectStatus(effectId, { sessionPath? })` reads a receipt.
2427
+
2421
2428
  An aborted turn's `agent_end` carries `aborted: true`, `abortSource` and, when an RPC client aborted it,
2422
2429
  `abortedBy: { connectionId, effectId? }`; a `session_abort` caused by a client carries the same `abortedBy`. `interrupt` records its caller the same way. An abort that reached no turn is forgotten once it settles, and a new turn starts with none, so a later system or extension abort never inherits an old client's `abortedBy`.
2423
2430
  Every attached client sees it. `abortSource` is `user` for a client or user abort, `provider` when the
package/docs/settings.md CHANGED
@@ -103,7 +103,7 @@ Permission rules are a confirmation policy, not a sandbox. Senpi, extensions, pa
103
103
  | `defaultModel` | string | - | Startup model ID (saved with Ctrl+S in `/model`, or edited manually) |
104
104
  | `recommendedModels` | string[] | `kimi-k3`, `gpt-6-astra`, `gpt-5.6-sol`, `claude-fable-5-1`, `claude-opus-5-5`, `glm-5.2` | Preferred default model ids in priority order. Built-in thinking levels are kimi-k3/`max`, GPT-6 Astra/`high`, GPT-5.6 Sol/`medium`, claude-fable-5-1/`high`, claude-opus-5-5/`max`, glm-5.2/`max`. Override the list or disable auto-switch with `--no-recommended-models` / `warnings.offRecommendedModel`. |
105
105
  | `defaultThinkingLevel` | string | - | Startup thinking level (saved with Ctrl+S in `/thinking`, or edited manually): `"off"`, `"minimal"`, `"low"`, `"medium"`, `"high"`, `"xhigh"`, `"max"` |
106
- | `promptPreset` | string | `"auto"` | Force a system prompt preset: `"auto"`, `"kimi-k2-6"`, `"kimi-k2-7"`, `"kimi-k2-8"`, `"kimi-k3"`, `"glm-5.2"`, `"glm-5.3"`, `"grok-4.5"`, `"grok-4.6"`, `"grok-4.7"`, `"claude-fable-5"`, `"claude-fable-5-1"`, `"claude-opus-5-5"`, `"claude-opus-5"`, `"claude-sonnet-5-5"`, `"claude-haiku-5-5"`, `"claude-opus-4-5"`, `"claude-opus-4-6"`, `"claude-opus-4-7"`, `"claude-opus-4-8"`, `"deepseek-v4-flash"`, `"deepseek-v4-flash-0731"`, `"deepseek-v4-1-flash"`, `"deepseek-v4-pro"`, `"gpt-5"`, `"gpt-5.2"`, `"gpt-5.3-codex"`, `"gpt-5.4"`, `"gpt-5.5"`, `"gpt-5.6"`, or `"gpt-6-astra"` |
106
+ | `promptPreset` | string | `"auto"` | Force a system prompt preset: `"auto"`, `"kimi-k2-6"`, `"kimi-k2-7"`, `"kimi-k2-8"`, `"kimi-k3"`, `"glm-5.2"`, `"glm-5.3"`, `"grok-4.5"`, `"grok-4.6"`, `"grok-4.7"`, `"claude-fable-5"`, `"claude-fable-5-1"`, `"claude-opus-5-5"`, `"claude-opus-5"`, `"claude-sonnet-5-5"`, `"claude-haiku-5-5"`, `"claude-opus-4-5"`, `"claude-opus-4-6"`, `"claude-opus-4-7"`, `"claude-opus-4-8"`, `"deepseek-v4-flash"`, `"deepseek-v4-flash-0731"`, `"deepseek-v4-1-flash"`, `"deepseek-v4-pro"`, `"gemini-4-argon"`, `"gpt-5"`, `"gpt-5.2"`, `"gpt-5.3-codex"`, `"gpt-5.4"`, `"gpt-5.5"`, `"gpt-5.6"`, or `"gpt-6-astra"` |
107
107
  | `hideThinkingBlock` | boolean | `false` | Hide thinking blocks in output |
108
108
  | `showCacheMissNotices` | boolean | `false` | Show transcript notices for significant prompt-cache misses, compaction or branch-summary usage, and provider recovery diagnostics such as dropped Anthropic thinking blocks |
109
109
  | `thinkingBudgets` | object | - | Custom token budgets per thinking level. Anthropic, Google, and Bedrock use these natively. OpenAI-compatible models use them when `compat.thinkingTokenBudgetField` (or `supportsThinkingTokenBudget`) is set. |
@@ -199,6 +199,19 @@ Set `PI_SKIP_VERSION_CHECK=1` to disable the senpi version update check. Use `--
199
199
  }
200
200
  ```
201
201
 
202
+ ### Context Files and Skills
203
+
204
+ | Setting | Type | Default | Description |
205
+ |---------|------|---------|-------------|
206
+ | `contextFiles.maxBytesPerFile` | number | `65536` | Maximum UTF-8 bytes loaded from each AGENTS.md / CLAUDE.md, including the truncation note and any closing code fence |
207
+ | `contextFiles.maxTotalBytes` | number | `131072` | Maximum combined UTF-8 bytes of loaded context files, including truncation notes and closing code fences |
208
+
209
+ Files receive budget nearest-first: the current directory, its ancestors, then the global file. A cut prefers a newline in the last quarter of the available content budget; otherwise it uses a UTF-8 character boundary. An open backtick or tilde fence is closed with the same marker before the truncation note; the closing marker's bytes count against the budget. Files with less than 1024 bytes of budget left are skipped when they would need truncation, with a warning distinguishing an undersized per-file limit from budget used by nearer files. Both limits accept positive numbers (rounded down); invalid values use the defaults.
210
+
211
+ The skills section has a fixed 48 KiB rendered UTF-8 byte cap, including its roots table and markup. Above 100 visible skills or 64 KiB of descriptions, descriptions become one line of at most 160 characters. If needed to meet the section cap, smaller catalogs also use compact descriptions, then all descriptions are dropped. If names and locations still exceed the cap, only the first skills that fit are listed, with the omitted count inside the section. Skills remain available for explicit invocation.
212
+
213
+ Context cuts, skipped files, and compacted or omitted skills share the "Context size limits" resource notice in interactive mode and appear once on stderr at startup in print, JSON, and RPC modes, with the affected path when present. Ordinary skill validation warnings keep their existing diagnostic behavior and are not printed to stderr by this size check.
214
+
202
215
  ### Compaction
203
216
 
204
217
  | Setting | Type | Default | Description |
@@ -545,6 +558,8 @@ This is the default and has no setting. Tools may declare `exposure: "eval"` to
545
558
  |---------|------|---------|-------------|
546
559
  | `engineTurns.maxPerUserInput` | number | `150` | Turns the agent may start on its own (stream-rule nudges, goal continuations, other extension follow-ups) after one user message before it pauses; `0` turns the limit off |
547
560
  | `engineTurns.maxToolFreePerMinute` | number | `12` | Turns started on its own within 60 s that called no tool before it pauses; `0` turns the limit off |
561
+ | `turn_inactivity_ms` | number | `3600000` | Milliseconds a turn may run with no tool progress before it is stopped as a deadline; renewed only by tool execution events and admitted user input, never by streamed text. Minimum `60000`; `0` disables |
562
+ | `turn_max_runtime_ms` | number | `21600000` | Hard cap on one turn's runtime from prompt accept; `0` disables |
548
563
 
549
564
  When a limit is reached the session pauses with a notice instead of starting another turn; any message you send continues it, because only turns since your last message count.
550
565
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@code-yeongyu/senpi",
3
- "version": "2026.10.16-2",
3
+ "version": "2026.10.16-4",
4
4
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
5
5
  "type": "module",
6
6
  "piConfig": {
@@ -60,9 +60,9 @@
60
60
  },
61
61
  "dependencies": {
62
62
  "@earendil-works/chord": "0.99.1",
63
- "@earendil-works/pi-agent-core": "npm:@code-yeongyu/senpi-agent-core@2026.10.16-2",
64
- "@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.10.16-2",
65
- "@earendil-works/pi-tui": "npm:@code-yeongyu/senpi-tui@2026.10.16-2",
63
+ "@earendil-works/pi-agent-core": "npm:@code-yeongyu/senpi-agent-core@2026.10.16-4",
64
+ "@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.10.16-4",
65
+ "@earendil-works/pi-tui": "npm:@code-yeongyu/senpi-tui@2026.10.16-4",
66
66
  "@silvia-odwyer/photon-node": "0.3.4",
67
67
  "chalk": "6.0.0",
68
68
  "cross-spawn": "7.0.6",
@@ -81,8 +81,8 @@
81
81
  "yaml": "2.9.1",
82
82
  "@anthropic-ai/claude-agent-sdk": "0.3.296",
83
83
  "@anthropic-ai/sdk": "0.127.0",
84
- "@code-yeongyu/senpi-codemode": "2026.10.16-2",
85
- "@earendil-works/pi-pty": "npm:@code-yeongyu/senpi-pty@2026.10.16-2",
84
+ "@code-yeongyu/senpi-codemode": "2026.10.16-4",
85
+ "@earendil-works/pi-pty": "npm:@code-yeongyu/senpi-pty@2026.10.16-4",
86
86
  "@modelcontextprotocol/sdk": "1.32.1",
87
87
  "@mozilla/readability": "0.6.0",
88
88
  "linkedom": "0.18.13",