@code-yeongyu/senpi 2026.10.5 → 2026.10.6

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 (115) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/dist/bundle/chunks/{anthropic-messages-G5236QNK.js → anthropic-messages-4MNFPQ66.js} +1 -1
  3. package/dist/bundle/chunks/{app-server-command-3Q5G4CAF.js → app-server-command-GNTX4MZT.js} +1 -1
  4. package/dist/bundle/chunks/{azure-openai-responses-NTA2HGG4.js → azure-openai-responses-A5OEHSJM.js} +1 -1
  5. package/dist/bundle/chunks/bedrock-converse-stream.js +1 -1
  6. package/dist/bundle/chunks/{chunk-EEOKVSNF.js → chunk-3EE6G4G7.js} +180 -176
  7. package/dist/bundle/chunks/{chunk-2WV426OJ.js → chunk-4MJKBXI3.js} +1 -1
  8. package/dist/bundle/chunks/{chunk-ECDLITEY.js → chunk-6EH7QXOG.js} +2 -2
  9. package/dist/bundle/chunks/chunk-6KQGOVQG.js +2 -0
  10. package/dist/bundle/chunks/{chunk-JVSOW4QU.js → chunk-7A3WRAKP.js} +1 -1
  11. package/dist/bundle/chunks/{chunk-WTJU5B2C.js → chunk-BE66QJ3Z.js} +1 -1
  12. package/dist/bundle/chunks/{chunk-UHRXXTFZ.js → chunk-D2W3MXRF.js} +2 -2
  13. package/dist/bundle/chunks/{chunk-U7CI2JGX.js → chunk-DDEAZX22.js} +1 -1
  14. package/dist/bundle/chunks/{chunk-UXIQK5NQ.js → chunk-ESSQUSRC.js} +1 -1
  15. package/dist/bundle/chunks/{chunk-KLBKUBDV.js → chunk-GQ3YLXVZ.js} +1 -1
  16. package/dist/bundle/chunks/{chunk-CTQU2XNX.js → chunk-MLNJNJFE.js} +1 -1
  17. package/dist/bundle/chunks/{chunk-BPX6QWPD.js → chunk-PELNNWGO.js} +1 -1
  18. package/dist/bundle/chunks/{chunk-UUAKEDVS.js → chunk-QLIW3YMI.js} +1 -1
  19. package/dist/bundle/chunks/{chunk-BBK6SSYT.js → chunk-TAV6QZFM.js} +1 -1
  20. package/dist/bundle/chunks/{chunk-QUROHJLP.js → chunk-TFO5JGLR.js} +1 -1
  21. package/dist/bundle/chunks/{cli-main-VZWSUHFB.js → cli-main-2C3YPHRE.js} +1 -1
  22. package/dist/bundle/chunks/{cloudflare-workers-ai-system-one-5ZUHUKMU.js → cloudflare-workers-ai-system-one-TE3OEJEL.js} +1 -1
  23. package/dist/bundle/chunks/{google-generative-ai-OTMG6ZXI.js → google-generative-ai-JBC53AMK.js} +1 -1
  24. package/dist/bundle/chunks/{google-vertex-2LXS3ICC.js → google-vertex-46ODNPM2.js} +1 -1
  25. package/dist/bundle/chunks/{help-fast-path-SHZJ3QWQ.js → help-fast-path-FUMH5NSA.js} +1 -1
  26. package/dist/bundle/chunks/{host-command-7JIGGOWR.js → host-command-N6XS5O67.js} +1 -1
  27. package/dist/bundle/chunks/{interactive-mode-JA7AHYYL.js → interactive-mode-CM4C4ZFY.js} +1 -1
  28. package/dist/bundle/chunks/{llama-cpp-classify-U5XAMKZE.js → llama-cpp-classify-AD6TTKNL.js} +1 -1
  29. package/dist/bundle/chunks/{multi-session-host-34WOINP4.js → multi-session-host-QLLTPWIM.js} +5 -5
  30. package/dist/bundle/chunks/{openai-codex-responses-WNTYIPFZ.js → openai-codex-responses-Q4OWIK7Y.js} +1 -1
  31. package/dist/bundle/chunks/{openai-completions-VSH22FCN.js → openai-completions-6HU7GIGD.js} +1 -1
  32. package/dist/bundle/chunks/{openai-images-XBTUJXPR.js → openai-images-M73YANTR.js} +1 -1
  33. package/dist/bundle/chunks/{openai-responses-WLBHCFYL.js → openai-responses-S4QYZXF3.js} +1 -1
  34. package/dist/bundle/chunks/{openrouter-images-3LVJZY3G.js → openrouter-images-PCJFX2KO.js} +1 -1
  35. package/dist/bundle/chunks/{package-manager-cli-2QLVGQSC.js → package-manager-cli-VRCAO7RV.js} +1 -1
  36. package/dist/bundle/chunks/{rotation-stream-JUCEXHWU.js → rotation-stream-7E37KOCX.js} +1 -1
  37. package/dist/bundle/chunks/{rpc-mode-QEBSN2OH.js → rpc-mode-2OPE6J7P.js} +1 -1
  38. package/dist/bundle/chunks/{session-control-endpoint-UGQIP6ZN.js → session-control-endpoint-SOG4XFF7.js} +1 -1
  39. package/dist/bundle/chunks/{session-picker-HX4LOJZ7.js → session-picker-VTTRFGE7.js} +1 -1
  40. package/dist/bundle/chunks/session-worker.js +370 -366
  41. package/dist/bundle/chunks/{typesafe-system-one-FNI3UASE.js → typesafe-system-one-TF23RUVG.js} +1 -1
  42. package/dist/bundle/cli.js +1 -1
  43. package/dist/bundle/index.js +1 -1
  44. package/dist/bundle/rpc-entry.js +1 -1
  45. package/dist/bundle/runtime-manifest.json +1 -1
  46. package/dist/core/agent-session-runtime.d.ts +12 -0
  47. package/dist/core/agent-session.d.ts +18 -0
  48. package/dist/core/agent-session.js +55 -19
  49. package/dist/core/dynamic-prompt/handoff.d.ts +4 -2
  50. package/dist/core/dynamic-prompt/handoff.js +1 -1
  51. package/dist/core/extensions/builtin/diff.js +14 -9
  52. package/dist/core/extensions/builtin/files.js +6 -2
  53. package/dist/core/extensions/builtin/gpt-apply-patch/parser.js +5 -1
  54. package/dist/core/extensions/builtin/gpt-apply-patch/streaming-parser.d.ts +8 -0
  55. package/dist/core/extensions/builtin/gpt-apply-patch/streaming-parser.js +26 -2
  56. package/dist/core/extensions/builtin/gpt-apply-patch/streaming-render.js +82 -11
  57. package/dist/core/extensions/builtin/gpt-apply-patch/types.d.ts +9 -1
  58. package/dist/core/extensions/builtin/permission-system/auto-credentials.d.ts +2 -0
  59. package/dist/core/extensions/builtin/permission-system/auto-credentials.js +17 -0
  60. package/dist/core/extensions/builtin/permission-system/auto-paths.d.ts +14 -0
  61. package/dist/core/extensions/builtin/permission-system/auto-paths.js +93 -0
  62. package/dist/core/extensions/builtin/permission-system/auto-policy.d.ts +20 -0
  63. package/dist/core/extensions/builtin/permission-system/auto-policy.js +126 -0
  64. package/dist/core/extensions/builtin/permission-system/auto-program-rules.d.ts +6 -0
  65. package/dist/core/extensions/builtin/permission-system/auto-program-rules.js +139 -0
  66. package/dist/core/extensions/builtin/permission-system/auto-shell-grammar.d.ts +29 -0
  67. package/dist/core/extensions/builtin/permission-system/auto-shell-grammar.js +74 -0
  68. package/dist/core/extensions/builtin/permission-system/auto-shell-segments.d.ts +13 -0
  69. package/dist/core/extensions/builtin/permission-system/auto-shell-segments.js +23 -0
  70. package/dist/core/extensions/builtin/permission-system/cli.d.ts +1 -0
  71. package/dist/core/extensions/builtin/permission-system/cli.js +9 -10
  72. package/dist/core/extensions/builtin/permission-system/config.d.ts +4 -1
  73. package/dist/core/extensions/builtin/permission-system/config.js +13 -1
  74. package/dist/core/extensions/builtin/permission-system/index.js +20 -12
  75. package/dist/core/extensions/builtin/permission-system/non-interactive.d.ts +11 -6
  76. package/dist/core/extensions/builtin/permission-system/non-interactive.js +8 -33
  77. package/dist/core/extensions/builtin/permission-system/service.d.ts +20 -1
  78. package/dist/core/extensions/builtin/permission-system/service.js +43 -7
  79. package/dist/core/extensions/builtin/permission-system/settings.d.ts +1 -0
  80. package/dist/core/extensions/builtin/permission-system/settings.js +4 -3
  81. package/dist/core/extensions/builtin/permission-system/types.d.ts +1 -1
  82. package/dist/core/extensions/builtin/prompt-preset/claude-fable-5-1.js +16 -3
  83. package/dist/core/retry-fallback/usage-limit.js +5 -1
  84. package/dist/core/tools/diff-render.d.ts +4 -0
  85. package/dist/core/tools/diff-render.js +13 -0
  86. package/dist/core/tools/renderers/edit.js +6 -4
  87. package/dist/main.js +12 -0
  88. package/dist/modes/interactive/interactive-mode.d.ts +1 -0
  89. package/dist/modes/interactive/interactive-mode.js +55 -11
  90. package/dist/modes/interactive/provider-error-presentation.d.ts +25 -0
  91. package/dist/modes/interactive/provider-error-presentation.js +42 -0
  92. package/dist/modes/interactive/theme/dark.json +5 -5
  93. package/dist/modes/interactive/theme/light.json +5 -5
  94. package/dist/modes/rpc/custom-capability.d.ts +6 -0
  95. package/dist/modes/rpc/custom-capability.js +6 -0
  96. package/dist/modes/rpc/rpc-client.d.ts +3 -0
  97. package/dist/modes/rpc/rpc-input-validation.d.ts +7 -0
  98. package/dist/modes/rpc/rpc-input-validation.js +37 -0
  99. package/dist/modes/rpc/rpc-types.d.ts +9 -0
  100. package/dist/modes/rpc/session-command-activity.d.ts +2 -0
  101. package/dist/modes/rpc/session-command-activity.js +25 -0
  102. package/dist/modes/rpc/session-command-router.js +11 -4
  103. package/dist/modes/rpc/session-registry-attach.js +1 -0
  104. package/dist/modes/rpc/session-registry-types.d.ts +7 -0
  105. package/dist/modes/rpc/session-registry-types.js +11 -0
  106. package/dist/modes/rpc/session-registry.js +6 -3
  107. package/dist/modes/rpc/session-sweep.d.ts +12 -2
  108. package/dist/modes/rpc/session-sweep.js +36 -3
  109. package/dist/modes/rpc/session-teardown.d.ts +2 -0
  110. package/dist/modes/rpc/session-teardown.js +1 -0
  111. package/dist/modes/rpc/worker-session-registry.js +5 -1
  112. package/docs/rpc.md +39 -5
  113. package/docs/settings.md +3 -1
  114. package/package.json +6 -6
  115. package/dist/bundle/chunks/chunk-K3BXFPWZ.js +0 -2
@@ -50,6 +50,13 @@ export interface RpcSessionEntry {
50
50
  retainOnDisconnect?: boolean;
51
51
  /** Timestamp of the last routed command / observed activity; drives idle eviction. */
52
52
  lastCommandAt: number;
53
+ /**
54
+ * When the last attachment left (a retained session now standing detached), on the registry's
55
+ * clock; `undefined` while anyone is attached. A detached observational read does not refresh
56
+ * `lastCommandAt`, and the early-retirement sweep honors a minimum detach age so a transient
57
+ * reconnect keeps its runtime instead of re-opening cold.
58
+ */
59
+ detachedAt?: number;
53
60
  lifecycleMutex: Promise<void>;
54
61
  closeCompletion?: Promise<void>;
55
62
  closeResolve?: () => void;
@@ -14,6 +14,17 @@ export function frozenProfile(profile) {
14
14
  ...profile,
15
15
  ...(profile.creationModel ? { creationModel: Object.freeze({ ...profile.creationModel }) } : {}),
16
16
  ...(profile.sessionContext ? { sessionContext: Object.freeze({ ...profile.sessionContext }) } : {}),
17
+ ...(profile.retryFallback
18
+ ? {
19
+ retryFallback: Object.freeze({
20
+ modelFallback: profile.retryFallback.modelFallback,
21
+ fallbackChains: Object.freeze(Object.fromEntries(Object.entries(profile.retryFallback.fallbackChains).map(([key, entries]) => [
22
+ key,
23
+ Object.freeze([...entries]),
24
+ ]))),
25
+ }),
26
+ }
27
+ : {}),
17
28
  });
18
29
  }
19
30
  /**
@@ -5,6 +5,7 @@ import { createAgentSessionRuntime } from "../../core/agent-session-runtime.js";
5
5
  import { assertValidSessionId, SessionManager } from "../../core/session-manager.js";
6
6
  import { SESSION_PATH_RETRY_AFTER_MS } from "./host-reservations.js";
7
7
  import { createRegistryWarm } from "./host-warm.js";
8
+ import { refreshesSessionActivity } from "./session-command-activity.js";
8
9
  import { canonicalSessionPath } from "./session-path-key.js";
9
10
  import { attachToOpenSession } from "./session-registry-attach.js";
10
11
  import { settleClosingReservation, syncRuntimeMetadata } from "./session-registry-claims.js";
@@ -45,6 +46,7 @@ export class RpcSessionRegistry {
45
46
  this.options.pathReservations?.release(key);
46
47
  },
47
48
  markDetached: (key) => this.options.pathReservations?.setAttached(key, false),
49
+ now: () => this.now(),
48
50
  sync: () => this.syncRuntimeMetadata(),
49
51
  };
50
52
  }
@@ -202,9 +204,10 @@ export class RpcSessionRegistry {
202
204
  }
203
205
  if (entry.state !== "open" && entry.state !== "closing")
204
206
  throw new RpcSessionRegistryError("unknown_session");
205
- // Every routed command counts as activity for idle eviction. Lookups that
206
- // must not refresh idleness (sweeps, listing) use peek()/list() instead.
207
- entry.lastCommandAt = this.now();
207
+ // Polling a session nobody holds is observation, not work that needs its runtime;
208
+ // an attached client's polling keeps its session alive exactly as before.
209
+ if (entry.attachments > 0 || refreshesSessionActivity(command))
210
+ entry.lastCommandAt = this.now();
208
211
  return entry;
209
212
  }
210
213
  /**
@@ -8,11 +8,21 @@ export interface SweepVerdicts {
8
8
  */
9
9
  readonly orphaned: readonly string[];
10
10
  }
11
+ /**
12
+ * How long a session must stand detached before the early-retirement branch may
13
+ * park it: a disconnect that overlaps a sweep is a client about to come back,
14
+ * and re-opening its runtime cold is the expensive path.
15
+ */
16
+ export declare const DETACHED_RETIREMENT_GRACE_MS = 5000;
11
17
  /**
12
18
  * One occupancy sweep's decisions. "Idle" is the COMPLETE session-owned activity contract
13
19
  * (`AgentSession.isSessionBusy`: agent run, bash, background terminal jobs and other published
14
20
  * wake sources, compaction, barrier-held session work): a busy session restarts its idle clock
15
- * instead, so work that outlives a turn is never killed. A non-finite window evicts nothing idle.
21
+ * instead, so work that outlives a turn is never killed. A quiet detached in-process worker
22
+ * with flushed history can reopen by path and need not keep its runtime for the idle window -
23
+ * once it has been detached for at least `DETACHED_RETIREMENT_GRACE_MS`, so a transient
24
+ * reconnect keeps the runtime it already owns.
25
+ * A non-finite window evicts nothing idle.
16
26
  */
17
- export declare function selectSweepEvictions(registry: Pick<RpcSessionRegistry, "list" | "peek">, now: number, idleEvictionMs: number): SweepVerdicts;
27
+ export declare function selectSweepEvictions(registry: Pick<RpcSessionRegistry, "list" | "peek">, now: number, idleEvictionMs: number, hasPendingRequest?: (sessionId: string) => boolean): SweepVerdicts;
18
28
  //# sourceMappingURL=session-sweep.d.ts.map
@@ -1,11 +1,21 @@
1
1
  import { sessionDirectoryRemoved } from "./session-path-key.js";
2
+ /**
3
+ * How long a session must stand detached before the early-retirement branch may
4
+ * park it: a disconnect that overlaps a sweep is a client about to come back,
5
+ * and re-opening its runtime cold is the expensive path.
6
+ */
7
+ export const DETACHED_RETIREMENT_GRACE_MS = 5_000;
2
8
  /**
3
9
  * One occupancy sweep's decisions. "Idle" is the COMPLETE session-owned activity contract
4
10
  * (`AgentSession.isSessionBusy`: agent run, bash, background terminal jobs and other published
5
11
  * wake sources, compaction, barrier-held session work): a busy session restarts its idle clock
6
- * instead, so work that outlives a turn is never killed. A non-finite window evicts nothing idle.
12
+ * instead, so work that outlives a turn is never killed. A quiet detached in-process worker
13
+ * with flushed history can reopen by path and need not keep its runtime for the idle window -
14
+ * once it has been detached for at least `DETACHED_RETIREMENT_GRACE_MS`, so a transient
15
+ * reconnect keeps the runtime it already owns.
16
+ * A non-finite window evicts nothing idle.
7
17
  */
8
- export function selectSweepEvictions(registry, now, idleEvictionMs) {
18
+ export function selectSweepEvictions(registry, now, idleEvictionMs, hasPendingRequest = () => false) {
9
19
  const idle = [];
10
20
  const orphaned = [];
11
21
  for (const { sessionId, status, sessionPath } of registry.list()) {
@@ -20,10 +30,33 @@ export function selectSweepEvictions(registry, now, idleEvictionMs) {
20
30
  }
21
31
  if (!Number.isFinite(idleEvictionMs))
22
32
  continue;
23
- if (entry.worker?.busy || entry.runtime?.session.isSessionBusy) {
33
+ if (hasPendingRequest(sessionId))
34
+ continue;
35
+ const session = entry.runtime?.session;
36
+ if (entry.worker?.busy || session?.isSessionBusy) {
24
37
  entry.lastCommandAt = now;
25
38
  continue;
26
39
  }
40
+ if (entry.attachments > 0) {
41
+ // An attached client holds the live routing handle; only the ordinary deadline applies.
42
+ if (now - entry.lastCommandAt >= idleEvictionMs)
43
+ idle.push(sessionId);
44
+ continue;
45
+ }
46
+ if (entry.kind === "worker" && entry.retainOnDisconnect && session?.sessionManager.isTranscriptFlushed()) {
47
+ // Delivery debt is still owed a turn, even after the ordinary idle deadline.
48
+ if (session.pendingMessageCount > 0 || session.externalAdmission.list().pending.length > 0)
49
+ continue;
50
+ // A just-detached worker is a client about to reconnect; park it only once the
51
+ // disconnect has stood longer than the grace age. An entry at zero attachments
52
+ // with no detach stamp was never disconnected, so it keeps its normal window.
53
+ if (entry.detachedAt !== undefined) {
54
+ if (now - entry.detachedAt < DETACHED_RETIREMENT_GRACE_MS)
55
+ continue;
56
+ idle.push(sessionId);
57
+ continue;
58
+ }
59
+ }
27
60
  if (now - entry.lastCommandAt >= idleEvictionMs)
28
61
  idle.push(sessionId);
29
62
  }
@@ -6,6 +6,8 @@ export interface SessionTeardownHost {
6
6
  releaseReservation(key: string): void;
7
7
  /** Publishes that this path is retained with no client attached, for the cross-generation claim. */
8
8
  markDetached(key: string): void;
9
+ /** The registry's clock, so the detach stamp shares the sweep's timeline. */
10
+ now(): number;
9
11
  sync(): void;
10
12
  }
11
13
  export declare function beginSessionClose(host: SessionTeardownHost, handle: string, onRole?: (finalizer: boolean) => void, options?: {
@@ -25,6 +25,7 @@ export function beginSessionClose(host, handle, onRole, options) {
25
25
  // reservation, and is torn down only by an explicit close or the idle window.
26
26
  if (options?.detach && entry.retainOnDisconnect) {
27
27
  entry.attachments = 0;
28
+ entry.detachedAt ??= host.now();
28
29
  // A retained session nobody is attached to is what another generation may reclaim the path
29
30
  // from (#1893) - but only once nothing is still WRITING it. A session mid-turn keeps its claim
30
31
  // until it parks, because reclaiming it would put two writers on one transcript.
@@ -1,6 +1,7 @@
1
1
  import { isAbsolute } from "node:path";
2
2
  import { ProviderScope } from "@earendil-works/pi-ai/node/provider-scope";
3
3
  import { assertValidSessionId } from "../../core/session-manager.js";
4
+ import { refreshesSessionActivity } from "./session-command-activity.js";
4
5
  import { RESERVATION_DENIAL_CODES, SessionPathReservations, } from "./session-path-reservations.js";
5
6
  import { frozenProfile, RpcSessionRegistryError, sessionIdentity, } from "./session-registry.js";
6
7
  import { SessionWorkerClient } from "./session-worker-client.js";
@@ -139,7 +140,10 @@ export class WorkerSessionRegistry {
139
140
  throw new RpcSessionRegistryError("session_closing");
140
141
  if (entry.state !== "open" && entry.state !== "closing")
141
142
  throw new RpcSessionRegistryError("unknown_session");
142
- entry.lastCommandAt = this.now();
143
+ // Polling a session nobody holds is observation, not work that needs its runtime;
144
+ // an attached client's polling keeps its session alive exactly as before.
145
+ if (entry.attachments > 0 || refreshesSessionActivity(command))
146
+ entry.lastCommandAt = this.now();
143
147
  return entry;
144
148
  }
145
149
  beginClose(handle, onRole, options) {
package/docs/rpc.md CHANGED
@@ -867,6 +867,22 @@ surface, and `new_session` / `switch_session` / `fork` inside the session keep i
867
867
  `chat`: a host without it refuses `chat` with `invalid_launch_profile`, so a gateway falls back to `app`. Any value other
868
868
  than `terminal`, `app` or `chat` is refused with `invalid_launch_profile`.
869
869
 
870
+ #### Fallback chain per session (`retry_fallback_profile`)
871
+
872
+ `open_session.retryFallback: { modelFallback: boolean, fallbackChains: Record<string, string[]> }` is the fallback
873
+ policy THIS session runs with, e.g. a task child's own chain. It overrides that session's `retry.modelFallback` and
874
+ `retry.fallbackChains` in memory only: it is never written to a settings file, and no other session on the host sees
875
+ it. A session opened without the field keeps the host's settings. It is applied when the open creates the session; an
876
+ attach to a live session keeps the policy that session was created with. Probe `retry_fallback_profile` in
877
+ `get_protocol_info` before sending the field. A malformed profile (a missing `modelFallback`, a chain that is not an
878
+ array of non-empty selectors, a selector longer than 512 characters, more than 32 chains or 32 entries per chain) is
879
+ refused with `invalid_launch_profile` rather than opening a session without the chain its caller asked for; an empty
880
+ chain (`[]`) is accepted and means no fallback for that key. If something inside that session later changes a
881
+ fallback setting through the settings setters, that write follows the setters' own rules (it persists to the global
882
+ settings file) and replaces the session's override for that one key only: a `setFallbackChain` drops the profile's
883
+ chains, while `modelFallback` keeps the profile's value. The policy is not stored with the session, so a client that
884
+ reopens a session after it was closed sends the profile again.
885
+
870
886
  #### Browser engine per session (`browser_engine`)
871
887
 
872
888
  `open_session.browserEngine: "connected" | "builtin" | "none"` names the browser THIS session's skills drive: the
@@ -1029,7 +1045,7 @@ extensions and contents; neither runtime provides process-fatal OOM containment.
1029
1045
 
1030
1046
  What the host does enforce are lifecycle windows, and they only ever return memory from work nobody is doing:
1031
1047
 
1032
- - **Idle eviction**: a session with no routed command and no session-owned work for
1048
+ - **Idle eviction**: a session with no activity-refreshing command and no session-owned work for
1033
1049
  `SENPI_RPC_SESSION_IDLE_EVICTION_MS` (default 30 minutes) is closed through the exact `close_session` sequence
1034
1050
  (abort → waitForIdle → dispose, all attachments drained, path reservation released) and every attached connection
1035
1051
  receives that handle's `session_closed { reason: "idle_evicted" }` broadcast plus a final `close_session` response
@@ -1048,6 +1064,21 @@ What the host does enforce are lifecycle windows, and they only ever return memo
1048
1064
  background terminal jobs and any other published wake source (terminal monitors, loop-guard holds), compaction,
1049
1065
  and barrier-held session work all defer eviction, and the idle clock restarts when that work settles. An evicted
1050
1066
  session resumes like any other: the next `open_session` with the same `sessionPath` reopens it.
1067
+ - **Observational reads**: session state, history, model/auth inventory, loaded surfaces, and `memory_report`
1068
+ do not restart the idle clock of a **detached** session in either runtime. With any client still attached,
1069
+ these reads keep refreshing activity as before: an attached client polling `get_state` keeps the same live
1070
+ routing handle and runtime. An attached session receiving no commands can still idle-evict as before.
1071
+ Commands that change the session restart the clock even when detached. An in-flight request or prompt
1072
+ preflight prevents ordinary idle parking while the request is running; directory-removal teardown is separate.
1073
+ - **Completed detached workers**: on the in-process runtime, a retained `kind: "worker"` session with zero
1074
+ attachments is parked on the next occupancy sweep once its transcript is flushed and it has no session-owned
1075
+ work, queued input, admitted unwritten delivery, pending prompt, or in-flight request - provided the disconnect
1076
+ has already stood for at least five seconds (a short grace age, so a transient reconnect keeps the live runtime
1077
+ instead of re-opening cold). It does not wait out the normal idle window. This releases its runtime and eval
1078
+ kernels, not its durable history: reopen its
1079
+ `sessionPath` to continue under a new routing handle. Active monitors and other wake sources still prevent
1080
+ early parking. Unflushed workers, worker-isolate runtimes, and interactive sessions keep their normal window.
1081
+ A zero-attachment worker without a recorded disconnect time also keeps its normal idle deadline.
1051
1082
  - **Worker capacity** (worker runtime ONLY - a stdio host, `--listen stdio://`, an embedder, or a socket host that
1052
1083
  passed `--session-runtime worker` explicitly): at most 20 workers may be preparing, open, closing, or quarantined
1053
1084
  together, because each one is an isolate the host must keep alive. Admission beyond this bound fails explicitly
@@ -1064,7 +1095,8 @@ What the host does enforce are lifecycle windows, and they only ever return memo
1064
1095
  `open_session` with that `sessionPath` — from any connection — attaches to the same routing handle and returns
1065
1096
  `attached: true`. Retention never outranks an explicit teardown: a `close_session` from an attached connection still
1066
1097
  reaches zero attachments and closes the session, host shutdown closes it, and the idle-eviction window above still
1067
- parks it — announced as `session_parked { sessionId, sessionPath }`, after which the file reopens by path like any
1098
+ parks it (or the completed-detached-worker rule above parks it sooner) — announced as
1099
+ `session_parked { sessionId, sessionPath }`, after which the file reopens by path like any
1068
1100
  evicted session, and the parked session counts as gone for the empty-host exit below. It is also bounded by the empty-host exit
1069
1101
  and the supervisor's idle-exit window below: retention survives a client, not the host. Any attach may turn retention
1070
1102
  on for a live session; no attach turns it off for clients that already rely on it. Omitting the flag is byte-identical
@@ -1427,12 +1459,14 @@ Hosts that support `open_session.permissionPreset: "accept-edits"` advertise
1427
1459
  `permission_preset_accept_edits`. This preset allows project read/list/grep/edit
1428
1460
  and asks for bash, external_directory and other tools. Clients without that
1429
1461
  capability must use `ask`, never `workspace`, for an edit-only approval promise.
1430
- Explicit permission rules and remembered approvals retain their existing precedence.
1462
+ Hosts that support `permissionPreset: "auto"` advertise `permission_preset_auto`;
1463
+ clients without it must not send `auto`.
1464
+ Explicit permission rules and remembered approvals retain their existing precedence, except under `auto`: there settings and CLI rules can only narrow the preset (the more restrictive decision wins), and an "Always" answer (saved in `.senpi/permissions-approved.jsonl`, from this or an earlier session) still allows its pattern.
1431
1465
 
1432
1466
  | Command | Params | Success data | Notes |
1433
1467
  | --- | --- | --- | --- |
1434
1468
  | `get_protocol_info` | - | `{ protocolVersion: 1, serverVersion: string, capabilities: string[], mode: "classic"\|"multi", instanceId: string, generation: number, engineVersion: string, engineOrdinal: [y, m, d, n, epoch], launch_profile: { profile_id, core }, memory_pressure?: boolean }` | Answered in BOTH modes; side-effect-free; the capability probe. Multi-session hosts include `multi_session`, `retain_on_disconnect`, `session_kind`, `session_context`, `auto_title_per_session`, `durable_session_id`, `prompt_surface` and `prompt_surface_chat` plus the negotiated launch capabilities. Those are HOST capabilities (a client never sends them) and are advertised only in multi-session mode, where the host owns the attachment refcount and the per-session launch profile. The identity fields are described under "Host identity" above; compatibility is decided from `protocolVersion`, `capabilities` and `engineOrdinal`, NEVER from `serverVersion`. |
1435
- | `open_session` | `sessionPath?`, `cwd?`, `provider?`, `modelId?`, `thinkingLevel?`, `permissionPreset?`, `retain_on_disconnect?`, `kind?`, `context?`, `auto_title?`, `durableSessionId?`, `promptSurface?`, `browserEngine?` (all optional; paths MUST be absolute) | `{ sessionId, state: RpcSessionState, attached?: true }` | `sessionPath` = today's `--session` semantics (open-if-exists else create persisting there, `session-manager.ts:926-940`); `provider`/`modelId` applied only on create (resume restores the session's model — mirrors `SenpiSessionRuntime.ts:198-200`); params form the immutable launch profile (D8). When the path is already held by a fully-open session, the open ATTACHES to it: same routing handle, `attached: true`, one more attachment counted; the runtime is torn down only when the last attachment closes. Idle sessions past the eviction window are closed by the host itself. `retain_on_disconnect: true` (default false) makes a dropped connection DETACH from this session instead of closing it — see "Retained sessions" below. `kind` (default `interactive`) and the opaque `context` map are described under "Session kind and context" above; both are stored frozen for the session's life and never influence auth, model or resource resolution. |
1469
+ | `open_session` | `sessionPath?`, `cwd?`, `provider?`, `modelId?`, `thinkingLevel?`, `permissionPreset?`, `retain_on_disconnect?`, `kind?`, `context?`, `auto_title?`, `durableSessionId?`, `promptSurface?`, `browserEngine?`, `retryFallback?` (all optional; paths MUST be absolute) | `{ sessionId, state: RpcSessionState, attached?: true }` | `sessionPath` = today's `--session` semantics (open-if-exists else create persisting there, `session-manager.ts:926-940`); `provider`/`modelId` applied only on create (resume restores the session's model — mirrors `SenpiSessionRuntime.ts:198-200`); params form the immutable launch profile (D8). When the path is already held by a fully-open session, the open ATTACHES to it: same routing handle, `attached: true`, one more attachment counted; the runtime is torn down only when the last attachment closes. Idle sessions past the eviction window are closed by the host itself. `retain_on_disconnect: true` (default false) makes a dropped connection DETACH from this session instead of closing it — see "Retained sessions" below. `kind` (default `interactive`) and the opaque `context` map are described under "Session kind and context" above; both are stored frozen for the session's life and never influence auth, model or resource resolution. |
1436
1470
  | `close_session` | `sessionId` | `{}` | Refused with `unknown_session` when the requesting connection never attached to that handle (a close releases the CALLER's attachment, and `list_sessions` publishes every handle). Otherwise aborts active work, awaits agent idle + settled persistence for up to the host grace window (default 10s), then quarantines any worker that has not exited without releasing its path reservation; its response is the LAST record tagged with that handle for the first closer — no events after (test-pinned). An admitted concurrent close joins the same teardown and receives its own successful response; output saturation rejects admission with the bounded close-overflow/resync notice described above. |
1437
1471
  | `list_sessions` | `include_workers?` (default false) | `{ sessions: [{ sessionId, durableSessionId, sessionPath, cwd, name, status, attachments, kind, context? }] }` | Includes `opening`/`closing` entries. Internally quarantined workers remain externally `closing` until exit. `attachments` is the session's live client attachment count; `0` on an `open` row is a retained session with no client attached. Every row carries `kind`. Rows with `kind: "worker"` are omitted unless `include_workers: true`, and `context` is published ONLY on that listing — a default listing carries no `context` at all. |
1438
1472
  | `release_session` | `sessionId`, `reason: "takeover"`, `interrupt?`, `force?` | `{ released: true, session_path, attachments }` | Hands the session to a runtime outside this host. See "Handing a session over (`release_session`)" below. |
@@ -1465,7 +1499,7 @@ In the response `error` field, machine-matchable:
1465
1499
  - `open_failed: <detail>`
1466
1500
  - `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)
1467
1501
  - `invalid_session_kind: <detail>` (`open_session.kind` other than `interactive` or `worker`)
1468
- - `invalid_launch_profile: <detail>` (`open_session.auto_title` present but not a boolean, `open_session.promptSurface` other than `terminal`, `app` or `chat`, or `open_session.browserEngine` other than `connected`, `builtin` or `none`)
1502
+ - `invalid_launch_profile: <detail>` (`open_session.auto_title` present but not a boolean, `open_session.promptSurface` other than `terminal`, `app` or `chat`, `open_session.browserEngine` other than `connected`, `builtin` or `none`, or a malformed `open_session.retryFallback`)
1469
1503
  - `host_memory_pressure` (sent only by hosts released before #2207, which declined to CREATE a `kind: "worker"` session above `SENPI_RPC_HOST_RSS_REFUSE_MB`; `errorData { rssMb, retry_after_ms }` says when to ask again. Current hosts never refuse an open for memory; a client talking to an older generation waits and retries, it never starts a second host or a per-child process)
1470
1504
  - `warm_failed: <detail>` (`warm` could not load its profile, for example a `cwd` that does not exist; not remembered, so a retry loads again)
1471
1505
  - `media_not_found` (`get_media` for an unknown `toolCallId`, or a `contentIndex` that does not point at an image block)
package/docs/settings.md CHANGED
@@ -27,7 +27,7 @@ Senpi includes a built-in permission system for tool calls. It evaluates a prese
27
27
 
28
28
  | Setting | Type | Default | Description |
29
29
  |---------|------|---------|-------------|
30
- | `permissionPreset` | string | `"full-access"` | Permission preset: `"full-access"`, `"workspace"`, `"read-only"`, or `"ask"` |
30
+ | `permissionPreset` | string | `"full-access"` | Permission preset: `"full-access"`, `"workspace"`, `"accept-edits"`, `"auto"`, `"read-only"`, or `"ask"` |
31
31
  | `permission` | object | - | Explicit permission rules that override the selected preset |
32
32
 
33
33
  Presets:
@@ -36,6 +36,8 @@ Presets:
36
36
  |--------|----------|
37
37
  | `full-access` | Allow all permission checks without prompting |
38
38
  | `workspace` | Allow `read`, `list`, `grep`, `edit`, and `bash`; ask for `external_directory` |
39
+ | `accept-edits` | Allow project `read`, `list`, `grep`, and `edit`; ask for `bash`, `external_directory`, and other tools |
40
+ | `auto` | Asks for everything except what it can prove stays inside the project, judged on the exact file the tool will open (the tool's own path resolution, symlinks followed): reads, listings and writes of project files through the file tools (`read`, `ls`, `find`, `write`, `edit`), `apply_patch` adding, changing or moving project files (a delete asks), a content search of one or more project files, and a small set of read-only shell commands (`ls cat head tail wc diff stat file cut grep rg echo pwd true which`, read-only git) with plain in-project arguments. Files count only when they land inside the project with no hidden component (except repository and formatter files such as `.gitignore`, `.github/`) and no credential-shaped name. Everything else asks: anything outside the project, `.env` and other dotfiles, `.git/`, `.vscode/`, keys, directory-wide content searches, any shell command with quotes, pipes, redirects, `cd` or a write (`cp`, `mv`, `rm`, `mkdir`), `git show`, text sent to a running process (`bash_input`), test runners, builds, installs and network tools. A session rooted at `/`, your home directory, any directory above it, or inside a hidden directory (such as `~/.config`) asks for everything. Each call is decided as the more restrictive of `auto`'s decision and your own rules' decision (deny over ask over allow), whatever order or layer (settings, CLI, RPC) your rules come from: your rules can narrow `auto`, never widen it. <br>`auto` prevents accidents; it is not a sandbox. Credential detection is by name, so a secret in a plainly named project file (for example `secrets.yml`, `terraform.tfvars`) can be read. A path the judge approved can be swapped for a symlink before the tool runs. An "Always" answer you gave (saved in `.senpi/permissions-approved.jsonl`, from this or an earlier session) still allows its pattern. |
39
41
  | `read-only` | Allow `read`, `list`, and `grep`; ask for `edit`, `bash`, and `external_directory` |
40
42
  | `ask` | Restore prompt-on-unknown behavior |
41
43
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@code-yeongyu/senpi",
3
- "version": "2026.10.5",
3
+ "version": "2026.10.6",
4
4
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
5
5
  "type": "module",
6
6
  "piConfig": {
@@ -57,9 +57,9 @@
57
57
  },
58
58
  "dependencies": {
59
59
  "@earendil-works/chord": "0.99.1",
60
- "@earendil-works/pi-agent-core": "npm:@code-yeongyu/senpi-agent-core@2026.10.5",
61
- "@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.10.5",
62
- "@earendil-works/pi-tui": "npm:@code-yeongyu/senpi-tui@2026.10.5",
60
+ "@earendil-works/pi-agent-core": "npm:@code-yeongyu/senpi-agent-core@2026.10.6",
61
+ "@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.10.6",
62
+ "@earendil-works/pi-tui": "npm:@code-yeongyu/senpi-tui@2026.10.6",
63
63
  "@silvia-odwyer/photon-node": "0.3.4",
64
64
  "chalk": "6.0.0",
65
65
  "cross-spawn": "7.0.6",
@@ -78,8 +78,8 @@
78
78
  "yaml": "2.9.1",
79
79
  "@anthropic-ai/claude-agent-sdk": "0.3.288",
80
80
  "@anthropic-ai/sdk": "0.127.0",
81
- "@code-yeongyu/senpi-codemode": "2026.10.5",
82
- "@earendil-works/pi-pty": "npm:@code-yeongyu/senpi-pty@2026.10.5",
81
+ "@code-yeongyu/senpi-codemode": "2026.10.6",
82
+ "@earendil-works/pi-pty": "npm:@code-yeongyu/senpi-pty@2026.10.6",
83
83
  "@modelcontextprotocol/sdk": "1.30.0",
84
84
  "@mozilla/readability": "0.6.0",
85
85
  "linkedom": "0.18.13",
@@ -1,2 +0,0 @@
1
- import { createRequire as __piCreateRequire } from "node:module"; const require = __piCreateRequire(import.meta.url); { const __piWorkerThreads = require("node:worker_threads"); if (typeof __piWorkerThreads.markAsUncloneable !== "function") { __piWorkerThreads.markAsUncloneable = () => {}; } }
2
- import{HOST_GENERATION_ENV,HOST_INSTANCE_ID_ENV}from"./chunk-CIRZTP7W.js";import{engineBuildIdentity}from"./chunk-XYDZ3LIE.js";import{parseArgs,resolveSessionRuntime}from"./chunk-MH2BFYGN.js";import*as crypto from"node:crypto";var CUSTOM_UNSUPPORTED_CAPABILITY="custom_unsupported",EXTENSION_EVENTS_CAPABILITY="extension_events",AUTO_TITLE_SESSIONS_CAPABILITY="auto_title_sessions",CONTINUE_FROM_LEAF_CAPABILITY="continue_from_leaf",MEDIA_PLACEHOLDERS_CAPABILITY="media_placeholders",QUESTION_CAPABILITY="question",RETAIN_ON_DISCONNECT_CAPABILITY="retain_on_disconnect",SESSION_CONTEXT_CAPABILITY="session_context",SESSION_KIND_CAPABILITY="session_kind",AUTO_TITLE_PER_SESSION_CAPABILITY="auto_title_per_session",WARM_CAPABILITY="warm",DURABLE_SESSION_ID_CAPABILITY="durable_session_id",PROMPT_SURFACE_CAPABILITY="prompt_surface",BROWSER_ENGINE_CAPABILITY="browser_engine",PROMPT_SURFACE_CHAT_CAPABILITY="prompt_surface_chat",RPC_CLIENT_CAPABILITIES_ENV="SENPI_RPC_CLIENT_CAPABILITIES";function parseClientCapabilities(value){return value?value.split(",").map(part=>part.trim()).filter(part=>part.length>0):[]}var DEFAULT_CUSTOM_EXTENSION_LABEL="custom UI component";function buildCustomUnsupportedRequest(capabilities,extensionName){if(!capabilities?.includes(CUSTOM_UNSUPPORTED_CAPABILITY))return;let name=extensionName.trim()||DEFAULT_CUSTOM_EXTENSION_LABEL;return{type:"extension_ui_request",id:crypto.randomUUID(),method:"custom_unsupported",extensionName:name}}import{createHash,randomUUID as randomUUID2}from"node:crypto";import{resolve}from"node:path";var INSTANCE_ID=resolveInstanceId(process.env[HOST_INSTANCE_ID_ENV]);function resolveInstanceId(value){return value!==void 0&&value.trim()!==""?value:randomUUID2()}function hostInstanceId(){return INSTANCE_ID}var cachedProfile;function hostGeneration(env){let value=env[HOST_GENERATION_ENV];return value!==void 0&&/^\d+$/.test(value)?Number(value):0}function hostLaunchProfile(argv,cwd){let parsed=parseArgs([...argv],{grokNeoEnabled:!1}),core={extensions:[...new Set((parsed.extensions??[]).map(path=>resolve(cwd,path)))].sort(),multi_session:parsed.multiSession===!0,session_runtime:resolveSessionRuntime(parsed)},canonical=JSON.stringify({extensions:core.extensions,multi_session:core.multi_session,session_runtime:core.session_runtime});return{profile_id:createHash("sha256").update(canonical).digest("hex"),core}}function socketHostLaunchProfile(hostArgs,cwd){return hostLaunchProfile(["--mode","rpc","--multi-session","--listen","unix://",...hostArgs],cwd)}function protocolIdentity(){cachedProfile??=hostLaunchProfile(process.argv.slice(2),process.cwd());let build=engineBuildIdentity();return{instanceId:INSTANCE_ID,generation:hostGeneration(process.env),engineVersion:build.text,engineOrdinal:build.ordinal,launch_profile:cachedProfile}}export{CUSTOM_UNSUPPORTED_CAPABILITY,EXTENSION_EVENTS_CAPABILITY,AUTO_TITLE_SESSIONS_CAPABILITY,CONTINUE_FROM_LEAF_CAPABILITY,MEDIA_PLACEHOLDERS_CAPABILITY,QUESTION_CAPABILITY,RETAIN_ON_DISCONNECT_CAPABILITY,SESSION_CONTEXT_CAPABILITY,SESSION_KIND_CAPABILITY,AUTO_TITLE_PER_SESSION_CAPABILITY,WARM_CAPABILITY,DURABLE_SESSION_ID_CAPABILITY,PROMPT_SURFACE_CAPABILITY,BROWSER_ENGINE_CAPABILITY,PROMPT_SURFACE_CHAT_CAPABILITY,RPC_CLIENT_CAPABILITIES_ENV,parseClientCapabilities,DEFAULT_CUSTOM_EXTENSION_LABEL,buildCustomUnsupportedRequest,hostInstanceId,hostGeneration,hostLaunchProfile,socketHostLaunchProfile,protocolIdentity};