@code-yeongyu/senpi 2026.10.19-2 → 2026.10.20-1

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 (119) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/bundle/chunks/{anthropic-messages-VVRTT7O5.js → anthropic-messages-3QYMAEH2.js} +1 -1
  3. package/dist/bundle/chunks/{args-35XTBPC7.js → args-NHDT5JG3.js} +1 -1
  4. package/dist/bundle/chunks/{azure-openai-responses-BUSMTCQ5.js → azure-openai-responses-2DCY6QKO.js} +1 -1
  5. package/dist/bundle/chunks/chunk-2JGY3V4H.js +395 -0
  6. package/dist/bundle/chunks/{chunk-2RLZXLXS.js → chunk-2LQZJFKQ.js} +1 -1
  7. package/dist/bundle/chunks/{chunk-2IK7QUJX.js → chunk-2SM4TR4H.js} +1 -1
  8. package/dist/bundle/chunks/{chunk-DXOSXUVI.js → chunk-3YKKXTJM.js} +1 -1
  9. package/dist/bundle/chunks/chunk-6L3H4UVN.js +2 -0
  10. package/dist/bundle/chunks/{chunk-46NW3UKO.js → chunk-7R4CJDWT.js} +1 -1
  11. package/dist/bundle/chunks/chunk-AA2L2RHH.js +2 -0
  12. package/dist/bundle/chunks/chunk-DKBJM22C.js +5 -0
  13. package/dist/bundle/chunks/{chunk-Z32CYNHC.js → chunk-F4YDU66P.js} +1 -1
  14. package/dist/bundle/chunks/chunk-FC2YWHEC.js +6 -0
  15. package/dist/bundle/chunks/{chunk-T2EE5G4S.js → chunk-FRFGJC3I.js} +1 -1
  16. package/dist/bundle/chunks/{chunk-57RKQONO.js → chunk-GBAS4FFM.js} +1 -1
  17. package/dist/bundle/chunks/{chunk-FDCYQYBP.js → chunk-JVLIKUBL.js} +1 -1
  18. package/dist/bundle/chunks/{chunk-EKDY4H2J.js → chunk-JXCHRJA7.js} +1 -1
  19. package/dist/bundle/chunks/{chunk-YID5W42N.js → chunk-KXTWMPCP.js} +1 -1
  20. package/dist/bundle/chunks/{chunk-ADNKYKU4.js → chunk-RYQ2FFF5.js} +1 -1
  21. package/dist/bundle/chunks/chunk-SOMFLLPD.js +3 -0
  22. package/dist/bundle/chunks/{chunk-4CJJ4SK6.js → chunk-SOWFP3I7.js} +1 -1
  23. package/dist/bundle/chunks/{chunk-GX7MTG25.js → chunk-TK6RKAB4.js} +4 -4
  24. package/dist/bundle/chunks/{chunk-NN44F5QF.js → chunk-V6RY5A6T.js} +1 -1
  25. package/dist/bundle/chunks/{chunk-77PVTNLJ.js → chunk-W557SQYL.js} +2 -2
  26. package/dist/bundle/chunks/{chunk-F4ZU4RBE.js → chunk-YIRQJCPA.js} +45 -46
  27. package/dist/bundle/chunks/cli-main-JCQTFUHO.js +3 -0
  28. package/dist/bundle/chunks/cloudflare-workers-ai-system-one-4QEQ7MGK.js +1 -1
  29. package/dist/bundle/chunks/{dispatch-R37L3FW5.js → dispatch-YPCDRQYI.js} +1 -1
  30. package/dist/bundle/chunks/{google-generative-ai-4UOGH7RT.js → google-generative-ai-3SKUSCIL.js} +1 -1
  31. package/dist/bundle/chunks/{google-vertex-2A6KZ5TX.js → google-vertex-YHSIIE4G.js} +1 -1
  32. package/dist/bundle/chunks/{help-fast-path-Z5JGXNCS.js → help-fast-path-TUCMQOOF.js} +1 -1
  33. package/dist/bundle/chunks/{host-command-IP3QAZYI.js → host-command-WEOS6VD6.js} +1 -1
  34. package/dist/bundle/chunks/{host-lifecycle-FRQK5GAC.js → host-lifecycle-ZDCO62Z4.js} +1 -1
  35. package/dist/bundle/chunks/host-lifecycle.js +6 -6
  36. package/dist/bundle/chunks/host-session-command-IGTNZ4F6.js +20 -0
  37. package/dist/bundle/chunks/interactive-mode-5KYT7LMW.js +2 -0
  38. package/dist/bundle/chunks/{multi-session-host-EMI54PTJ.js → multi-session-host-25XQFXJC.js} +5 -5
  39. package/dist/bundle/chunks/openai-codex-responses-4BIPACKV.js +10 -0
  40. package/dist/bundle/chunks/{openai-completions-AGTH2RRA.js → openai-completions-NHDMDAS7.js} +1 -1
  41. package/dist/bundle/chunks/{openai-images-5UGZHY27.js → openai-images-PNXQDBSP.js} +1 -1
  42. package/dist/bundle/chunks/{openai-responses-KGXDGHPI.js → openai-responses-2UOPEJLP.js} +1 -1
  43. package/dist/bundle/chunks/{package-manager-cli-TK44VHGK.js → package-manager-cli-CF53LR5X.js} +1 -1
  44. package/dist/bundle/chunks/{role-F6IEUF2N.js → role-OK7RKBGF.js} +1 -1
  45. package/dist/bundle/chunks/{rotation-stream-XX2NXYL3.js → rotation-stream-QRRJ7G5Y.js} +1 -1
  46. package/dist/bundle/chunks/rpc-mode-LOODTK3J.js +2 -0
  47. package/dist/bundle/chunks/{schedule-command-DYHRFIX2.js → schedule-command-LKL6DLOT.js} +1 -1
  48. package/dist/bundle/chunks/session-control-endpoint-MLANJVB2.js +3 -0
  49. package/dist/bundle/chunks/session-picker-RQDVWJRL.js +2 -0
  50. package/dist/bundle/chunks/session-worker.js +240 -240
  51. package/dist/bundle/chunks/session-write-fence-66EMOO4Y.js +2 -0
  52. package/dist/bundle/chunks/typesafe-system-one-W2ZTYF7X.js +1 -1
  53. package/dist/bundle/cli.js +2 -2
  54. package/dist/bundle/host-supervisor.js +6 -6
  55. package/dist/bundle/index.js +1 -1
  56. package/dist/bundle/rpc-entry.js +1 -1
  57. package/dist/bundle/runtime-manifest.json +1 -1
  58. package/dist/core/agent-session.d.ts +0 -2
  59. package/dist/core/agent-session.js +4 -13
  60. package/dist/core/credential-pool/classify.d.ts +7 -0
  61. package/dist/core/credential-pool/classify.js +12 -4
  62. package/dist/core/credential-pool/failover.js +16 -1
  63. package/dist/core/credential-pool/rotation-stream.js +2 -0
  64. package/dist/core/extensions/builtin/anthropic-subscription/auth-lane.js +8 -62
  65. package/dist/core/extensions/builtin/anthropic-subscription/context-window.d.ts +25 -0
  66. package/dist/core/extensions/builtin/anthropic-subscription/context-window.js +38 -0
  67. package/dist/core/extensions/builtin/anthropic-subscription/index.d.ts +2 -2
  68. package/dist/core/extensions/builtin/anthropic-subscription/index.js +4 -6
  69. package/dist/core/extensions/builtin/anthropic-subscription/long-context-retry.d.ts +18 -0
  70. package/dist/core/extensions/builtin/anthropic-subscription/long-context-retry.js +74 -0
  71. package/dist/core/extensions/builtin/anthropic-subscription/long-context.js +13 -5
  72. package/dist/core/extensions/builtin/anthropic-subscription/options.d.ts +0 -10
  73. package/dist/core/extensions/builtin/anthropic-subscription/options.js +8 -16
  74. package/dist/core/extensions/builtin/anthropic-subscription/session-registry-wiring.js +1 -1
  75. package/dist/core/extensions/builtin/anthropic-subscription/session-sync.js +3 -0
  76. package/dist/core/extensions/builtin/terminal/lease-file.d.ts +1 -0
  77. package/dist/core/extensions/builtin/terminal/lease-file.js +5 -3
  78. package/dist/core/foreign-session-holders.d.ts +2 -3
  79. package/dist/core/provider-failure.d.ts +2 -0
  80. package/dist/core/provider-failure.js +3 -0
  81. package/dist/core/reserved-keywords.js +1 -1
  82. package/dist/core/session-hold-errors.d.ts +2 -0
  83. package/dist/core/session-holder-records.d.ts +21 -0
  84. package/dist/core/session-holder-records.js +76 -0
  85. package/dist/core/session-holders.d.ts +3 -3
  86. package/dist/core/session-holders.js +4 -41
  87. package/dist/core/session-manager.d.ts +1 -0
  88. package/dist/core/session-manager.js +8 -1
  89. package/dist/core/session-write-fence.d.ts +18 -0
  90. package/dist/core/session-write-fence.js +71 -0
  91. package/dist/core/user-text-blocks.d.ts +11 -0
  92. package/dist/core/user-text-blocks.js +19 -1
  93. package/dist/modes/rpc/host-daemon-registration.js +2 -1
  94. package/dist/modes/rpc/host-generations.d.ts +6 -0
  95. package/dist/modes/rpc/host-generations.js +35 -4
  96. package/dist/modes/rpc/host-lifecycle-scratch.d.ts +9 -2
  97. package/dist/modes/rpc/host-lifecycle-scratch.js +26 -6
  98. package/dist/modes/rpc/host-lifecycle.js +1 -1
  99. package/dist/modes/rpc/host-reservations.d.ts +5 -0
  100. package/dist/modes/rpc/host-reservations.js +54 -12
  101. package/dist/modes/rpc/host-stalled-evidence.d.ts +27 -1
  102. package/dist/modes/rpc/host-stalled-evidence.js +52 -4
  103. package/dist/modes/rpc/multi-session-host.js +10 -1
  104. package/dist/modes/rpc/session-command-router.js +2 -0
  105. package/dist/modes/rpc/session-held.js +2 -0
  106. package/package.json +6 -6
  107. package/dist/bundle/chunks/chunk-74V6LCWU.js +0 -2
  108. package/dist/bundle/chunks/chunk-DDQJSVIZ.js +0 -5
  109. package/dist/bundle/chunks/chunk-DY2EM44T.js +0 -395
  110. package/dist/bundle/chunks/chunk-HOXW5IHD.js +0 -6
  111. package/dist/bundle/chunks/chunk-UAQW2T7G.js +0 -2
  112. package/dist/bundle/chunks/cli-main-RXZVFXK7.js +0 -3
  113. package/dist/bundle/chunks/host-session-command-KGCQPMES.js +0 -20
  114. package/dist/bundle/chunks/interactive-mode-DQOPIZSI.js +0 -2
  115. package/dist/bundle/chunks/openai-codex-responses-I2NSK6AD.js +0 -10
  116. package/dist/bundle/chunks/rpc-mode-X6J3FC3N.js +0 -2
  117. package/dist/bundle/chunks/session-control-endpoint-XN4BAZTU.js +0 -3
  118. package/dist/bundle/chunks/session-picker-US5YPKC7.js +0 -2
  119. /package/dist/bundle/chunks/{chunk-KW6654UH.js → chunk-RPAHOJ6F.js} +0 -0
@@ -2,25 +2,11 @@ import { existsSync, readFileSync } from "node:fs";
2
2
  import { dirname, join, relative, resolve } from "node:path";
3
3
  import { CONFIG_DIR_NAME, getAgentDir } from "../../../../config.js";
4
4
  import { PROJECT_RULES_END_MARKER, PROJECT_RULES_HEADING, PROJECT_RULES_REGION_END_MARKER, PROJECT_RULES_REGION_START_MARKER, PROJECT_RULES_START_MARKER, } from "../rules/rules/constants.js";
5
+ import { plannedContextWindow, sdkModelId } from "./context-window.js";
5
6
  import { presetAppendDeprecationGuidance } from "./guidance.js";
6
- import { longContextAvailable } from "./long-context.js";
7
7
  import { loadAnthropicSubscriptionProviderSettingsFromDisk, resolveCompactionOwner, resolveSystemPromptMode, } from "./settings.js";
8
8
  import { loadOverrideSystemPrompt, resolveCustomSystemPrompt } from "./system-prompt.js";
9
9
  import { BUILTIN_SDK_TOOLS, canUseTool, HOST_TOOL_DENIAL_HOOKS } from "./tools.js";
10
- /** Claude Code's default window when a request does not ask for the long context (its `A68`). */
11
- export const CLAUDE_CODE_DEFAULT_CONTEXT_WINDOW = 200_000;
12
- /**
13
- * The model id Claude Code is given. A subscription (OAuth) session ignores `options.betas` ("Custom
14
- * betas are only available for API key users"); the `[1m]` suffix is Claude Code's own way to ask for
15
- * the 1M window, and it then sends `context-1m-2025-08-07` on the wire. A model above the default
16
- * window gets the suffix while the account has not refused it, so the window senpi plans against is
17
- * the window the request gets (senpi#3408).
18
- */
19
- export function sdkModelId(model) {
20
- return model.contextWindow > CLAUDE_CODE_DEFAULT_CONTEXT_WINDOW && longContextAvailable()
21
- ? `${model.id}[1m]`
22
- : model.id;
23
- }
24
10
  const ADAPTIVE_THINKING_MODEL_MARKERS = [
25
11
  "opus-4-6",
26
12
  "opus-4.6",
@@ -241,8 +227,14 @@ export function buildAnthropicSubscriptionQueryOptions(input) {
241
227
  // Claude Code's per-turn token-budget reminder changes the prompt every turn and defeats
242
228
  // prompt-cache reuse; senpi's compaction carries its own budget reminder. The auth lane
243
229
  // layers this over the subprocess environment it builds.
230
+ // With DISABLE_COMPACT set, Claude Code takes CLAUDE_CODE_MAX_CONTEXT_TOKENS over every model rule of
231
+ // its own, so it plans the window senpi plans (senpi#3801), and its native compaction stays off.
244
232
  if (senpiOwnsCompaction)
245
- queryOptions.env = { CLAUDE_CODE_TOTAL_TOKENS_REMINDER: "off" };
233
+ queryOptions.env = {
234
+ CLAUDE_CODE_TOTAL_TOKENS_REMINDER: "off",
235
+ CLAUDE_CODE_MAX_CONTEXT_TOKENS: String(plannedContextWindow(input.model)),
236
+ DISABLE_COMPACT: "1",
237
+ };
246
238
  if (input.pathToClaudeCodeExecutable)
247
239
  queryOptions.pathToClaudeCodeExecutable = input.pathToClaudeCodeExecutable;
248
240
  if (toolLessRequest)
@@ -1,7 +1,7 @@
1
1
  import { convertToLlm } from "../../../messages.js";
2
2
  import { ANTHROPIC_SUBSCRIPTION_PROVIDER_ID } from "./account-management.js";
3
3
  import { restoreColdSeedCalibration } from "./cold-seed-budget.js";
4
- import { sdkModelId } from "./options.js";
4
+ import { sdkModelId } from "./context-window.js";
5
5
  import { BINDING_ENTRY_TYPE, BINDING_MARKER, bindingFromStoredBranch, invalidationReasonFromBranch, storedBindingFromBinding, storedBindingFromEntry, } from "./session-binding.js";
6
6
  import { deleteStoredBinding, readStoredBinding, writeStoredBinding } from "./session-binding-store.js";
7
7
  import { AssistantCommitBoundary, assistantContentHash, isResidentAssistant, isTerminalFailure, } from "./session-commit-boundary.js";
@@ -117,6 +117,9 @@ export function configFingerprint(options, context, authLane, accountName) {
117
117
  // A refused 1M window must restart the resident process at the standard one (senpi#3408). Only
118
118
  // the refusal state is hashed: a live model switch reaches the process through setModel.
119
119
  longContextRefused: !longContextAvailable(),
120
+ // Claude Code reads its window once at spawn, so a switch to a model planned at another window
121
+ // restarts the resident process at that window instead of keeping the old one (senpi#3801).
122
+ contextWindow: options.env?.CLAUDE_CODE_MAX_CONTEXT_TOKENS,
120
123
  extraArgs: options.extraArgs,
121
124
  pathToClaudeCodeExecutable: options.pathToClaudeCodeExecutable,
122
125
  includePartialMessages: options.includePartialMessages,
@@ -34,6 +34,7 @@ type LockState = {
34
34
  } | {
35
35
  readonly state: "held";
36
36
  readonly holder?: ReclaimLockHolder;
37
+ readonly unproven?: true;
37
38
  } | {
38
39
  readonly state: "stale";
39
40
  readonly raw: string;
@@ -131,9 +131,11 @@ export async function reclaimLockState(lock, probes = {}) {
131
131
  if (!(probes.isProcessAlive ?? pidAlive)(holder.pid))
132
132
  return { state: "stale", raw };
133
133
  const observed = await (probes.readProcessStartMs ?? defaultReadProcessStartMs)(holder.pid).catch(() => undefined);
134
- if (observed === undefined || sameProcessStart(holder.processStartedAtMs, observed))
135
- return { state: "held", holder };
136
- return { state: "stale", raw };
134
+ // An unreadable start time cannot tell the holder from a process wearing its reused pid: held, but
135
+ // `unproven`, so a caller with positive evidence of the holder's death may still reclaim it.
136
+ if (observed === undefined)
137
+ return { state: "held", holder, unproven: true };
138
+ return sameProcessStart(holder.processStartedAtMs, observed) ? { state: "held", holder } : { state: "stale", raw };
137
139
  }
138
140
  /** Remove a stale lock only if it still holds `raw`; a lock someone took meanwhile is put back. */
139
141
  export async function breakStaleLock(lock, raw) {
@@ -1,7 +1,6 @@
1
- import { type LockProbes } from "./extensions/builtin/terminal/lease-file.ts";
2
- import { type SessionHolder } from "./session-holders.ts";
1
+ import { type SessionHolder, type SessionHolderProbes } from "./session-holders.ts";
3
2
  /** Fresh lease evidence for each admission; a clean exit or stale identity clears the next retry. */
4
- export declare function foreignSessionHolders(sessionFile: string | undefined, sessionId?: string, probes?: LockProbes): Promise<SessionHolder[]>;
3
+ export declare function foreignSessionHolders(sessionFile: string | undefined, sessionId?: string, probes?: SessionHolderProbes): Promise<SessionHolder[]>;
5
4
  /** One terminal-safe line; cwd can contain newlines or escape sequences. */
6
5
  export declare function sessionHolderWarning(sessionFile: string | undefined, sessionId?: string): Promise<string | undefined>;
7
6
  //# sourceMappingURL=foreign-session-holders.d.ts.map
@@ -10,6 +10,8 @@ export interface ProviderFailure {
10
10
  readonly provider: string;
11
11
  readonly httpStatus?: number;
12
12
  readonly code?: string;
13
+ /** When the limit lifts (epoch ms), for a `quota` or `rate_limit` failure whose provider named it. */
14
+ readonly resetsAt?: number;
13
15
  readonly reason: string;
14
16
  readonly action: string;
15
17
  }
@@ -26,12 +26,15 @@ export function describeProviderFailure(diagnostic, provider) {
26
26
  action: "retry once; if it repeats, switch the model or report the code",
27
27
  }
28
28
  : ACTIONS[category];
29
+ const resetsAt = diagnostic?.resetsAt;
29
30
  return {
30
31
  category,
31
32
  provider,
32
33
  ...(diagnostic?.httpStatus === undefined ? {} : { httpStatus: diagnostic.httpStatus }),
33
34
  ...(diagnostic?.code === undefined ? {} : { code: diagnostic.code }),
35
+ ...(resetsAt === undefined ? {} : { resetsAt }),
34
36
  ...detail,
37
+ ...(resetsAt === undefined ? {} : { reason: `${detail.reason} until ${new Date(resetsAt).toISOString()}` }),
35
38
  };
36
39
  }
37
40
  /**
@@ -5,7 +5,7 @@ const NOT_AFTER_IDENTIFIER = String.raw `(?<![A-Za-z0-9][-_.]|[/\\])`;
5
5
  const NOT_BEFORE_PATH = String.raw `(?![-_.]?[A-Za-z0-9]*[/\\])`;
6
6
  // The name must end its own word (`ulw-plan-refactor`, `ulw-plan.md` do not count); a chained
7
7
  // skill word may follow if it, too, ends there (`mass ulw-loop`).
8
- const SEGMENT_END = String.raw `(?![-_.]?[A-Za-z0-9])`;
8
+ const SEGMENT_END = "(?![-_.]?[A-Za-z0-9])";
9
9
  const NOT_INTO_IDENTIFIER = String.raw `(?![-_](?!(?:loop|plan|research|execute|mass)${SEGMENT_END})[A-Za-z0-9]|\.[A-Za-z0-9])`;
10
10
  const keywordPattern = (body) => new RegExp(String.raw `${NOT_AFTER_IDENTIFIER}\b${body}\b${NOT_INTO_IDENTIFIER}${NOT_BEFORE_PATH}`, "gi");
11
11
  const RESERVED_KEYWORD_PATTERNS = [
@@ -8,6 +8,8 @@ export interface SessionHolder {
8
8
  export interface SessionHolderEndpoint {
9
9
  readonly host_socket?: string;
10
10
  readonly host_instance: string;
11
+ /** The daemon directory of that generation; kept in the holder record only, never reported. */
12
+ readonly host_daemon_dir?: string;
11
13
  }
12
14
  export declare class SessionHeldError extends Error {
13
15
  readonly code: "ESESSIONHELD";
@@ -0,0 +1,21 @@
1
+ import { type LockProbes } from "./extensions/builtin/terminal/lease-file.ts";
2
+ import type { SessionHolder } from "./session-hold-errors.ts";
3
+ /** The host generation a holder record names, as written by `holdSessionFile`. */
4
+ export interface HolderGeneration {
5
+ readonly host_instance?: string;
6
+ readonly host_daemon_dir?: string;
7
+ }
8
+ export interface SessionHolderProbes extends LockProbes {
9
+ /**
10
+ * Positive evidence that the holder's host generation has ended. Asked only about a holder whose pid
11
+ * is alive but whose start time cannot be compared, so a reused pid is not mistaken for it forever.
12
+ */
13
+ readonly holderEnded?: (generation: HolderGeneration) => Promise<boolean>;
14
+ }
15
+ /**
16
+ * `fenceSession` advances the session file's write fence (senpi#3652). A holder released on `holderEnded` may only be
17
+ * frozen, so it is released only after the fence moved: if it resumes, its writes are refused. Without a fence, or when
18
+ * it cannot be written, the holder stands.
19
+ */
20
+ export declare function liveHolders(dir: string, probes?: SessionHolderProbes, fenceSession?: () => Promise<void>): Promise<SessionHolder[]>;
21
+ //# sourceMappingURL=session-holder-records.d.ts.map
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Reading the holder records of one session: which processes hold it right now. A record of a dead or
3
+ * reused pid is reclaimed on the way, and so is one whose pid is alive but unprovable once the caller
4
+ * has positive evidence that its host generation ended (senpi#3658).
5
+ */
6
+ import { readdir } from "node:fs/promises";
7
+ import { join } from "node:path";
8
+ import { breakStaleLock, readLeaseText, reclaimLockState, } from "./extensions/builtin/terminal/lease-file.js";
9
+ const HOLDER_RECORD = /^\d+\.json$/;
10
+ function parseCwd(raw) {
11
+ const parsed = JSON.parse(raw);
12
+ if (typeof parsed !== "object" || parsed === null || !("cwd" in parsed))
13
+ return undefined;
14
+ return typeof parsed.cwd === "string" ? parsed.cwd : undefined;
15
+ }
16
+ async function fenced(fenceSession) {
17
+ if (fenceSession === undefined)
18
+ return false;
19
+ return fenceSession().then(() => true, () => false);
20
+ }
21
+ function holderGeneration(parsed) {
22
+ if (typeof parsed !== "object" || parsed === null)
23
+ return {};
24
+ return {
25
+ ...("host_instance" in parsed && typeof parsed.host_instance === "string"
26
+ ? { host_instance: parsed.host_instance }
27
+ : {}),
28
+ ...("host_daemon_dir" in parsed && typeof parsed.host_daemon_dir === "string"
29
+ ? { host_daemon_dir: parsed.host_daemon_dir }
30
+ : {}),
31
+ };
32
+ }
33
+ /**
34
+ * `fenceSession` advances the session file's write fence (senpi#3652). A holder released on `holderEnded` may only be
35
+ * frozen, so it is released only after the fence moved: if it resumes, its writes are refused. Without a fence, or when
36
+ * it cannot be written, the holder stands.
37
+ */
38
+ export async function liveHolders(dir, probes = {}, fenceSession) {
39
+ const holders = [];
40
+ for (const name of await readdir(dir)) {
41
+ if (!HOLDER_RECORD.test(name))
42
+ continue;
43
+ const path = join(dir, name);
44
+ const before = await readLeaseText(path);
45
+ const state = await reclaimLockState(path, probes);
46
+ if (state.state === "stale")
47
+ await breakStaleLock(path, state.raw);
48
+ if (state.state !== "held" || state.holder === undefined)
49
+ continue;
50
+ const raw = await readLeaseText(path);
51
+ // Never publish endpoint labels from a record replaced during the identity probe.
52
+ const stable = raw !== undefined && raw === before;
53
+ const parsed = stable ? JSON.parse(raw) : undefined;
54
+ if (stable &&
55
+ state.unproven === true &&
56
+ (await probes.holderEnded?.(holderGeneration(parsed))) === true &&
57
+ (await fenced(fenceSession))) {
58
+ await breakStaleLock(path, raw);
59
+ continue;
60
+ }
61
+ const endpoint = typeof parsed === "object" &&
62
+ parsed !== null &&
63
+ "host_instance" in parsed &&
64
+ typeof parsed.host_instance === "string"
65
+ ? {
66
+ host_instance: parsed.host_instance,
67
+ ...("host_socket" in parsed && typeof parsed.host_socket === "string"
68
+ ? { host_socket: parsed.host_socket }
69
+ : {}),
70
+ }
71
+ : {};
72
+ holders.push({ pid: state.holder.pid, cwd: stable ? parseCwd(raw) : undefined, ...endpoint });
73
+ }
74
+ return holders;
75
+ }
76
+ //# sourceMappingURL=session-holder-records.js.map
@@ -1,6 +1,6 @@
1
- import { type LockProbes } from "./extensions/builtin/terminal/lease-file.ts";
2
1
  import { SessionHeldError, type SessionHolder, type SessionHolderEndpoint, SessionMovedError, SessionMovingError } from "./session-hold-errors.ts";
3
- export { SessionHeldError, type SessionHolder, SessionMovedError, SessionMovingError };
2
+ import { type HolderGeneration, type SessionHolderProbes } from "./session-holder-records.ts";
3
+ export { type HolderGeneration, SessionHeldError, type SessionHolder, type SessionHolderProbes, SessionMovedError, SessionMovingError, };
4
4
  export interface SessionHold {
5
5
  release(): void;
6
6
  }
@@ -18,7 +18,7 @@ export declare function holdSessionFile(sessionFile: string, sessionId: string,
18
18
  * Live processes that have `sessionFile` open right now (stale records of dead or reused pids are
19
19
  * reclaimed on the way). A caller about to start another writer uses it to wait instead of racing one.
20
20
  */
21
- export declare function liveSessionHolders(sessionFile: string, sessionId: string, probes?: LockProbes): Promise<SessionHolder[]>;
21
+ export declare function liveSessionHolders(sessionFile: string, sessionId: string, probes?: SessionHolderProbes): Promise<SessionHolder[]>;
22
22
  /**
23
23
  * Runs `move` holding the session's move lock, after proving no live process holds the session.
24
24
  * Waits up to `waitMs` for a concurrent move to finish, then fails with `SessionMovingError`.
@@ -1,13 +1,14 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { existsSync, mkdirSync, readFileSync, renameSync, rmdirSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
3
- import { readdir } from "node:fs/promises";
4
3
  import { dirname, join } from "node:path";
5
4
  import { resolveMovedPath } from "./extensions/builtin/moved-path-guard/resolve.js";
6
5
  import { breakStaleLock, errorCode, publishExclusive, readLeaseText, reclaimLockState, unlinkIfPresent, } from "./extensions/builtin/terminal/lease-file.js";
7
6
  import { ownProcessStartedAtMs, processBootAtMs } from "./extensions/builtin/terminal/process-identity.js";
8
7
  import { SessionHeldError, SessionMovedError, SessionMovingError, } from "./session-hold-errors.js";
8
+ import { liveHolders } from "./session-holder-records.js";
9
9
  import { encodedSessionId } from "./session-sidecar-store.js";
10
- export { SessionHeldError, SessionMovedError, SessionMovingError };
10
+ import { advanceSessionFence } from "./session-write-fence.js";
11
+ export { SessionHeldError, SessionMovedError, SessionMovingError, };
11
12
  /**
12
13
  * Cross-process ownership of a session file. Every process that has a session open publishes a holder
13
14
  * record `<sessionDir>/session-holders/<id>/<pid>.json` carrying its process identity; moving the session
@@ -19,7 +20,6 @@ export { SessionHeldError, SessionMovedError, SessionMovingError };
19
20
  */
20
21
  const HOLDERS_DIR = "session-holders";
21
22
  const MOVE_LOCK = "move.lock";
22
- const HOLDER_RECORD = /^\d+\.json$/;
23
23
  // A move rewrites one file and a few sidecars; a lock older than this belongs to a mover that died.
24
24
  const MOVE_LOCK_STALE_MS = 30_000;
25
25
  const DEFAULT_MOVE_LOCK_WAIT_MS = 5_000;
@@ -108,12 +108,6 @@ function parsePid(raw) {
108
108
  throw error;
109
109
  }
110
110
  }
111
- function parseCwd(raw) {
112
- const parsed = JSON.parse(raw);
113
- if (typeof parsed !== "object" || parsed === null || !("cwd" in parsed))
114
- return undefined;
115
- return typeof parsed.cwd === "string" ? parsed.cwd : undefined;
116
- }
117
111
  /**
118
112
  * Publishes this process as a holder of `sessionFile` until `release()`. Throws `SessionMovingError`
119
113
  * while another process is moving the session, and `SessionMovedError` when a session that was read
@@ -169,44 +163,13 @@ function publishHolderRecord(dir, record, content) {
169
163
  }
170
164
  }
171
165
  }
172
- async function liveHolders(dir, probes = {}) {
173
- const holders = [];
174
- for (const name of await readdir(dir)) {
175
- if (!HOLDER_RECORD.test(name))
176
- continue;
177
- const path = join(dir, name);
178
- const before = await readLeaseText(path);
179
- const state = await reclaimLockState(path, probes);
180
- if (state.state === "stale")
181
- await breakStaleLock(path, state.raw);
182
- if (state.state !== "held" || state.holder === undefined)
183
- continue;
184
- const raw = await readLeaseText(path);
185
- // Never publish endpoint labels from a record replaced during the identity probe.
186
- const stable = raw !== undefined && raw === before;
187
- const parsed = stable ? JSON.parse(raw) : undefined;
188
- const endpoint = typeof parsed === "object" &&
189
- parsed !== null &&
190
- "host_instance" in parsed &&
191
- typeof parsed.host_instance === "string"
192
- ? {
193
- host_instance: parsed.host_instance,
194
- ...("host_socket" in parsed && typeof parsed.host_socket === "string"
195
- ? { host_socket: parsed.host_socket }
196
- : {}),
197
- }
198
- : {};
199
- holders.push({ pid: state.holder.pid, cwd: stable ? parseCwd(raw) : undefined, ...endpoint });
200
- }
201
- return holders;
202
- }
203
166
  /**
204
167
  * Live processes that have `sessionFile` open right now (stale records of dead or reused pids are
205
168
  * reclaimed on the way). A caller about to start another writer uses it to wait instead of racing one.
206
169
  */
207
170
  export async function liveSessionHolders(sessionFile, sessionId, probes = {}) {
208
171
  try {
209
- return await liveHolders(holdersDir(sessionFile, sessionId), probes);
172
+ return await liveHolders(holdersDir(sessionFile, sessionId), probes, () => advanceSessionFence(sessionFile));
210
173
  }
211
174
  catch (error) {
212
175
  if (errorCode(error) === "ENOENT")
@@ -312,6 +312,7 @@ export declare class SessionManager {
312
312
  private cwd;
313
313
  private persist;
314
314
  private readonly writeGuard;
315
+ private readonly writeFence;
315
316
  private flushed;
316
317
  private headerWrite;
317
318
  private ownedHeaderFile;
@@ -18,6 +18,7 @@ import { HistoryReductions } from "./session-history-reduce.js";
18
18
  import { planLoadedTail } from "./session-loaded-tail.js";
19
19
  import { SessionWriteGuard } from "./session-move/write-guard.js";
20
20
  import { ResidentStringStore } from "./session-resident-store.js";
21
+ import { SessionWriteFence } from "./session-write-fence.js";
21
22
  import { discardFailedFirstFlush, discardFailedFirstFlushAsync, truncateToLastCompleteLine, } from "./session-write-recovery.js";
22
23
  import { hasOtherLiveSessionWriter, registerSessionWriter, reserveSessionWrite, unregisterSessionWriter, } from "./session-write-reservation.js";
23
24
  import { isVirtualModel } from "./virtual-models.js";
@@ -680,6 +681,7 @@ export class SessionManager {
680
681
  constructor(cwd, sessionDir, sessionFile, persist, newSessionOptions, preloadedFileEntries) {
681
682
  this.sessionId = "";
682
683
  this.writeGuard = new SessionWriteGuard();
684
+ this.writeFence = new SessionWriteFence();
683
685
  this.flushed = false;
684
686
  // Set when an append to the flushed file failed and may have left a partial last line.
685
687
  this.tailMayBeTorn = false;
@@ -839,8 +841,10 @@ export class SessionManager {
839
841
  /** Points this manager at `sessionFile`; the path when it exists, undefined when a new session was started there. */
840
842
  _beginSessionFile(sessionFile, newSessionOptions) {
841
843
  const resolvedFile = resolvePath(sessionFile);
842
- if (this.persist)
844
+ if (this.persist) {
843
845
  reserveSessionWrite(resolvedFile);
846
+ this.writeFence.opened(resolvedFile);
847
+ }
844
848
  this.sessionFile = resolvedFile;
845
849
  this.mirrorTrimmed = false;
846
850
  this.trimmedIds.clear();
@@ -1056,6 +1060,7 @@ export class SessionManager {
1056
1060
  if (!this.persist || !this.sessionFile)
1057
1061
  return;
1058
1062
  this.writeGuard.assertRewritable(this.fileEntries, this.sessionFile);
1063
+ this.writeFence.assertOwned(this.sessionFile);
1059
1064
  reserveSessionWrite(this.sessionFile);
1060
1065
  replaceFileAtomically(this.sessionFile, this._serializedFileEntries());
1061
1066
  }
@@ -1134,6 +1139,7 @@ export class SessionManager {
1134
1139
  return true;
1135
1140
  }
1136
1141
  async _writeHeaderAsync(sessionFile) {
1142
+ this.writeFence.assertOwned(sessionFile);
1137
1143
  reserveSessionWrite(sessionFile);
1138
1144
  const entries = this.fileEntries;
1139
1145
  const serialize = (batch) => batch.map((e) => `${JSON.stringify(this.residentStore.materialize(e))}\n`).join("");
@@ -1185,6 +1191,7 @@ export class SessionManager {
1185
1191
  if (!this.persist || !this.sessionFile)
1186
1192
  return;
1187
1193
  this.writeGuard.assertWritable(this.fileEntries, entry, this.sessionFile);
1194
+ this.writeFence.assertOwned(this.sessionFile);
1188
1195
  reserveSessionWrite(this.sessionFile);
1189
1196
  const persistedEntry = this.residentStore.materialize(entry);
1190
1197
  if (this.flushed) {
@@ -0,0 +1,18 @@
1
+ export declare function sessionFenceFile(sessionFile: string): string;
2
+ /** The fence token now on disk; empty when the file was never taken over. Any other read failure throws. */
3
+ export declare function readSessionFence(sessionFile: string): string;
4
+ /** Gives the file a new token, so every writer that opened it before this call refuses its next write. */
5
+ export declare function advanceSessionFence(sessionFile: string): Promise<void>;
6
+ /** One writer's view of its file's fence: the token seen when the file was opened, re-checked before every write. */
7
+ export declare class SessionWriteFence {
8
+ private seen;
9
+ private fencedFile;
10
+ opened(sessionFile: string): void;
11
+ assertOwned(sessionFile: string): void;
12
+ }
13
+ export declare class SessionFencedError extends Error {
14
+ readonly code = "session_fenced";
15
+ readonly sessionFile: string;
16
+ constructor(sessionFile: string);
17
+ }
18
+ //# sourceMappingURL=session-write-fence.d.ts.map
@@ -0,0 +1,71 @@
1
+ /**
2
+ * The write fence of one session file (senpi#3652).
3
+ *
4
+ * A host may take a session file over from another generation whose claim it could not prove alive, for example a
5
+ * Windows generation that stopped heartbeating while an unrelated process wears its recycled pid. That verdict can be
6
+ * wrong: a generation whose event loop froze, or whose machine slept, also stops heartbeating, and it would resume
7
+ * writing the same file. The fence makes that second writer impossible instead of unlikely. A takeover replaces the
8
+ * token in `<session file>.fence` before the new owner opens the file; every writer remembers the token it saw when it
9
+ * opened the file and re-reads it, synchronously, right before each write. A writer that finds another token refuses
10
+ * the write and every later one: the file is no longer its own.
11
+ */
12
+ import { randomUUID } from "node:crypto";
13
+ import { readFileSync } from "node:fs";
14
+ import { rename, rm, writeFile } from "node:fs/promises";
15
+ export function sessionFenceFile(sessionFile) {
16
+ return `${sessionFile}.fence`;
17
+ }
18
+ /** The fence token now on disk; empty when the file was never taken over. Any other read failure throws. */
19
+ export function readSessionFence(sessionFile) {
20
+ try {
21
+ return readFileSync(sessionFenceFile(sessionFile), "utf8").trim();
22
+ }
23
+ catch (cause) {
24
+ if (cause.code === "ENOENT")
25
+ return "";
26
+ throw cause;
27
+ }
28
+ }
29
+ /** Gives the file a new token, so every writer that opened it before this call refuses its next write. */
30
+ export async function advanceSessionFence(sessionFile) {
31
+ const file = sessionFenceFile(sessionFile);
32
+ const temporary = `${file}.${process.pid}.${randomUUID().slice(0, 8)}.tmp`;
33
+ try {
34
+ await writeFile(temporary, `${randomUUID()}\n`, { mode: 0o600 });
35
+ await rename(temporary, file);
36
+ }
37
+ catch (cause) {
38
+ await rm(temporary, { force: true }).catch(() => undefined);
39
+ throw cause;
40
+ }
41
+ }
42
+ /** One writer's view of its file's fence: the token seen when the file was opened, re-checked before every write. */
43
+ export class SessionWriteFence {
44
+ opened(sessionFile) {
45
+ if (this.fencedFile !== sessionFile)
46
+ this.fencedFile = undefined;
47
+ this.seen = { file: sessionFile, token: readSessionFence(sessionFile) };
48
+ }
49
+ assertOwned(sessionFile) {
50
+ if (this.fencedFile === sessionFile)
51
+ throw new SessionFencedError(sessionFile);
52
+ // A file this writer starts itself (a new session, a fork) has no earlier owner to fence out.
53
+ if (this.seen?.file !== sessionFile) {
54
+ this.opened(sessionFile);
55
+ return;
56
+ }
57
+ if (readSessionFence(sessionFile) === this.seen.token)
58
+ return;
59
+ this.fencedFile = sessionFile;
60
+ throw new SessionFencedError(sessionFile);
61
+ }
62
+ }
63
+ export class SessionFencedError extends Error {
64
+ constructor(sessionFile) {
65
+ super(`${sessionFile} was taken over by another host generation; this writer no longer owns it and will not write to it`);
66
+ this.code = "session_fenced";
67
+ this.name = "SessionFencedError";
68
+ this.sessionFile = sessionFile;
69
+ }
70
+ }
71
+ //# sourceMappingURL=session-write-fence.js.map
@@ -1,4 +1,15 @@
1
1
  import type { TextContent } from "@earendil-works/pi-ai";
2
+ /** The one text a user message's text blocks spell: the blocks joined by a newline. */
3
+ export declare function joinUserTextBlocks(blocks: readonly string[]): string;
4
+ /**
5
+ * The text a queued user message is tracked by. The queue records it on enqueue and matches the
6
+ * started message's content against it on delivery, so both sides must read it here (senpi#3588):
7
+ * `userMessageText(userTextContent(text, textBlocks)) === text` for every queued `text`.
8
+ */
9
+ export declare function userMessageText(content: string | readonly {
10
+ type: string;
11
+ text?: string;
12
+ }[]): string;
2
13
  /**
3
14
  * The text content of a user message. Text that arrived as several blocks keeps them while they
4
15
  * still spell the final text exactly (joined by a newline); text that an input handler or a
@@ -1,4 +1,22 @@
1
1
  import { parseAskUserAnswerFrame } from "./extensions/builtin/ask-user/format.js";
2
+ /** The one text a user message's text blocks spell: the blocks joined by a newline. */
3
+ export function joinUserTextBlocks(blocks) {
4
+ return blocks.join("\n");
5
+ }
6
+ /**
7
+ * The text a queued user message is tracked by. The queue records it on enqueue and matches the
8
+ * started message's content against it on delivery, so both sides must read it here (senpi#3588):
9
+ * `userMessageText(userTextContent(text, textBlocks)) === text` for every queued `text`.
10
+ */
11
+ export function userMessageText(content) {
12
+ if (typeof content === "string")
13
+ return content;
14
+ const blocks = [];
15
+ for (const part of content)
16
+ if (part.type === "text" && part.text !== undefined)
17
+ blocks.push(part.text);
18
+ return joinUserTextBlocks(blocks);
19
+ }
2
20
  /**
3
21
  * The text content of a user message. Text that arrived as several blocks keeps them while they
4
22
  * still spell the final text exactly (joined by a newline); text that an input handler or a
@@ -6,7 +24,7 @@ import { parseAskUserAnswerFrame } from "./extensions/builtin/ask-user/format.js
6
24
  * words in separate blocks (senpi#2920).
7
25
  */
8
26
  export function userTextContent(text, textBlocks) {
9
- if (textBlocks !== undefined && textBlocks.length > 1 && textBlocks.join("\n") === text) {
27
+ if (textBlocks !== undefined && textBlocks.length > 1 && joinUserTextBlocks(textBlocks) === text) {
10
28
  return textBlocks.map((block) => ({ type: "text", text: block }));
11
29
  }
12
30
  return [{ type: "text", text }];
@@ -26,7 +26,7 @@ import { rename, rm } from "node:fs/promises";
26
26
  import { ProcessIdentityUnreadableError, parseDaemonPidFile, processIsLive, processMatchesPidFile, processStartTimeMs, readProcessStartTime, sameProcessStartMs, } from "../../utils/process-identity.js";
27
27
  import { createGenerationDirectory, generationPaths, HOST_DAEMON_LAYOUT, sameEndpoint, } from "./host-daemon-paths.js";
28
28
  import { isRecord, parseJson, readFileOrUndefined, writeStateFile } from "./host-daemon-state.js";
29
- import { pruneDeadGenerations } from "./host-generations.js";
29
+ import { endClaimsOfEndedGenerations, pruneDeadGenerations } from "./host-generations.js";
30
30
  import { hostChildAlive } from "./host-stalled-evidence.js";
31
31
  import { logUnknownHostIdentity } from "./host-supervisor-log.js";
32
32
  /**
@@ -112,6 +112,7 @@ export async function clearHostRegistration(paths) {
112
112
  if (typeof pointer?.instance_id === "string") {
113
113
  if (await hostChildAlive(generationPaths(paths, pointer.instance_id)))
114
114
  throw new Error(`refusing to clear generation ${pointer.instance_id} with a live host child`);
115
+ await endClaimsOfEndedGenerations(paths, new Set([pointer.instance_id]));
115
116
  await rm(generationPaths(paths, pointer.instance_id).dir, { recursive: true, force: true });
116
117
  }
117
118
  await rm(paths.settingsFile, { force: true });
@@ -30,6 +30,12 @@ export interface PrunedDaemonState {
30
30
  * LEFT - an ensure writing one right now must not be mistaken for a generation that ended.
31
31
  */
32
32
  export declare function pruneDeadGenerations(paths: HostDaemonPaths): Promise<PrunedDaemonState>;
33
+ /**
34
+ * Removes every claim whose owner is gone, and every claim of a generation in `ended` (being removed by the caller)
35
+ * whose owner identity cannot be proven. Returns the session paths released. Every path that removes a generation
36
+ * directory calls this first, so no generation leaves claims behind that its directory no longer explains.
37
+ */
38
+ export declare function endClaimsOfEndedGenerations(paths: HostDaemonDirectory, ended: ReadonlySet<string>): Promise<string[]>;
33
39
  /**
34
40
  * Every generation of this daemon that is still running, newest ordinal last. A record naming a
35
41
  * dead pid is omitted rather than reported as history: `host status` answers who is alive now.