@code-yeongyu/senpi 2026.10.16-3 → 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 (99) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/dist/bundle/chunks/{chunk-I5ZJEXTM.js → chunk-2NO5ZOYE.js} +6 -6
  3. package/dist/bundle/chunks/{chunk-4R4PRTF5.js → chunk-CJRE5LLR.js} +1 -1
  4. package/dist/bundle/chunks/{chunk-X5VCHHXR.js → chunk-GY5O7NIQ.js} +1 -1
  5. package/dist/bundle/chunks/{chunk-AVALXFDW.js → chunk-JD4CV3GH.js} +1 -1
  6. package/dist/bundle/chunks/{chunk-SSEGIXRL.js → chunk-KUQ3K33L.js} +1 -1
  7. package/dist/bundle/chunks/{chunk-FMYLLJBQ.js → chunk-KWC3MCLP.js} +1 -1
  8. package/dist/bundle/chunks/chunk-MLL7AGVM.js +3 -0
  9. package/dist/bundle/chunks/chunk-PAJKEG2L.js +8 -0
  10. package/dist/bundle/chunks/{chunk-DPRV3FR7.js → chunk-PWHAOWNH.js} +1 -1
  11. package/dist/bundle/chunks/{chunk-HJPX7PBL.js → chunk-XCRGEMY2.js} +125 -118
  12. package/dist/bundle/chunks/{cli-main-JVPFJGP5.js → cli-main-I6JF2GPS.js} +1 -1
  13. package/dist/bundle/chunks/{help-fast-path-SVCMHCYG.js → help-fast-path-APSHUDY6.js} +1 -1
  14. package/dist/bundle/chunks/{host-session-command-46PTZGIB.js → host-session-command-BUJUJHDA.js} +1 -1
  15. package/dist/bundle/chunks/{interactive-mode-BROZRY34.js → interactive-mode-4Q3TIXOZ.js} +1 -1
  16. package/dist/bundle/chunks/{multi-session-host-SMQR5SCJ.js → multi-session-host-XEZB2XVR.js} +5 -4
  17. package/dist/bundle/chunks/{package-manager-cli-2QFJY2AS.js → package-manager-cli-YWZHOZOY.js} +1 -1
  18. package/dist/bundle/chunks/{rotation-stream-QX7YSKDU.js → rotation-stream-HY3JPQCX.js} +1 -1
  19. package/dist/bundle/chunks/{rpc-mode-Z45T3NZO.js → rpc-mode-SLWXVXXE.js} +1 -1
  20. package/dist/bundle/chunks/{schedule-command-2RHSWBQO.js → schedule-command-JRWZGEDU.js} +1 -1
  21. package/dist/bundle/chunks/{session-control-endpoint-H7FFWF2A.js → session-control-endpoint-E2ZSEWXW.js} +1 -1
  22. package/dist/bundle/chunks/session-picker-5IZY7E3G.js +2 -0
  23. package/dist/bundle/chunks/session-worker.js +243 -235
  24. package/dist/bundle/cli.js +2 -2
  25. package/dist/bundle/index.js +1 -1
  26. package/dist/bundle/rpc-entry.js +1 -1
  27. package/dist/bundle/runtime-manifest.json +1 -1
  28. package/dist/core/agent-session-runtime.d.ts +15 -0
  29. package/dist/core/agent-session-runtime.js +53 -2
  30. package/dist/core/agent-session.d.ts +12 -0
  31. package/dist/core/agent-session.js +42 -1
  32. package/dist/core/context-files.d.ts +21 -0
  33. package/dist/core/context-files.js +175 -0
  34. package/dist/core/diagnostics.d.ts +1 -0
  35. package/dist/core/extensions/builtin/anthropic-subscription/account-command.js +26 -3
  36. package/dist/core/extensions/builtin/anthropic-subscription/account-management.d.ts +5 -0
  37. package/dist/core/extensions/builtin/anthropic-subscription/account-management.js +40 -0
  38. package/dist/core/extensions/builtin/anthropic-subscription/accounts.d.ts +3 -0
  39. package/dist/core/extensions/builtin/anthropic-subscription/accounts.js +1 -1
  40. package/dist/core/extensions/builtin/anthropic-subscription/affinity.js +1 -1
  41. package/dist/core/extensions/builtin/anthropic-subscription/auth-lane.js +1 -0
  42. package/dist/core/extensions/builtin/anthropic-subscription/failover.d.ts +10 -5
  43. package/dist/core/extensions/builtin/anthropic-subscription/failover.js +158 -17
  44. package/dist/core/extensions/builtin/anthropic-subscription/usage-limit-block.d.ts +13 -0
  45. package/dist/core/extensions/builtin/anthropic-subscription/usage-limit-block.js +89 -0
  46. package/dist/core/extensions/builtin/permission-system/always-scope.d.ts +34 -0
  47. package/dist/core/extensions/builtin/permission-system/always-scope.js +77 -0
  48. package/dist/core/extensions/builtin/permission-system/index.js +3 -1
  49. package/dist/core/extensions/builtin/permission-system/parsers.d.ts +1 -1
  50. package/dist/core/extensions/builtin/permission-system/parsers.js +24 -3
  51. package/dist/core/extensions/builtin/permission-system/prompt.js +5 -1
  52. package/dist/core/extensions/builtin/permission-system/service.js +1 -1
  53. package/dist/core/extensions/builtin/permission-system/types.d.ts +7 -0
  54. package/dist/core/package-manager.js +4 -32
  55. package/dist/core/project-trust.d.ts +8 -0
  56. package/dist/core/project-trust.js +12 -0
  57. package/dist/core/resource-loader.d.ts +5 -7
  58. package/dist/core/resource-loader.js +11 -84
  59. package/dist/core/retry-fallback/circuit-probes.d.ts +1 -0
  60. package/dist/core/retry-fallback/circuit-probes.js +3 -0
  61. package/dist/core/retry-fallback/circuit.d.ts +5 -0
  62. package/dist/core/retry-fallback/circuit.js +27 -4
  63. package/dist/core/retry-fallback/controller.d.ts +9 -4
  64. package/dist/core/retry-fallback/controller.js +21 -4
  65. package/dist/core/settings-manager.d.ts +15 -0
  66. package/dist/core/settings-manager.js +21 -1
  67. package/dist/core/skills-prompt.d.ts +18 -0
  68. package/dist/core/skills-prompt.js +131 -0
  69. package/dist/core/skills.d.ts +1 -9
  70. package/dist/core/skills.js +1 -59
  71. package/dist/core/trust-manager.d.ts +13 -4
  72. package/dist/core/trust-manager.js +52 -21
  73. package/dist/main.js +23 -4
  74. package/dist/modes/interactive/interactive-mode.js +10 -3
  75. package/dist/modes/rpc/abort-grace.d.ts +9 -0
  76. package/dist/modes/rpc/abort-grace.js +11 -0
  77. package/dist/modes/rpc/effect-commands.d.ts +0 -2
  78. package/dist/modes/rpc/effect-commands.js +2 -3
  79. package/dist/modes/rpc/host-handover-parked.d.ts +6 -0
  80. package/dist/modes/rpc/host-handover-parked.js +1 -0
  81. package/dist/modes/rpc/host-handover-reopen.js +11 -2
  82. package/dist/modes/rpc/rpc-client.d.ts +28 -2
  83. package/dist/modes/rpc/rpc-client.js +24 -11
  84. package/dist/modes/rpc/rpc-request-deadline.d.ts +12 -0
  85. package/dist/modes/rpc/rpc-request-deadline.js +15 -0
  86. package/dist/modes/rpc/session-command-router.js +7 -4
  87. package/dist/modes/rpc/session-registry-attach.js +6 -1
  88. package/dist/modes/rpc/session-registry-claims.d.ts +22 -2
  89. package/dist/modes/rpc/session-registry-claims.js +68 -0
  90. package/dist/modes/rpc/session-registry-opening.d.ts +23 -0
  91. package/dist/modes/rpc/session-registry-opening.js +57 -0
  92. package/dist/modes/rpc/session-registry.d.ts +1 -0
  93. package/dist/modes/rpc/session-registry.js +48 -60
  94. package/docs/rpc.md +6 -2
  95. package/docs/settings.md +13 -0
  96. package/package.json +6 -6
  97. package/dist/bundle/chunks/chunk-6MEETY7N.js +0 -8
  98. package/dist/bundle/chunks/chunk-TEGEPQIA.js +0 -3
  99. package/dist/bundle/chunks/session-picker-2O2BNBVE.js +0 -2
@@ -1,6 +1,6 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
- import { dirname, join } from "node:path";
3
+ import { dirname, join, sep } from "node:path";
4
4
  import lockfile from "proper-lockfile";
5
5
  import { CONFIG_DIR_NAME } from "../config.js";
6
6
  import { canonicalizePath, canonicalizePathStrict, resolvePath } from "../utils/paths.js";
@@ -201,35 +201,66 @@ function withTrustFileLock(path, fn) {
201
201
  release();
202
202
  }
203
203
  }
204
+ /** True when `dir` is the user's home or a folder above it: never a project (#2465). */
205
+ function isHomeOrAboveDir(dir, homeDir) {
206
+ return dir === homeDir || homeDir.startsWith(dir.endsWith(sep) ? dir : `${dir}${sep}`);
207
+ }
208
+ /** The nearest folder at or above `dir` that holds `.git`, as spelled; undefined outside a repository. */
209
+ function findGitRepoRoot(dir) {
210
+ let currentDir = dir;
211
+ while (!existsSync(join(currentDir, ".git"))) {
212
+ const parentDir = dirname(currentDir);
213
+ if (parentDir === currentDir)
214
+ return undefined;
215
+ currentDir = parentDir;
216
+ }
217
+ return currentDir;
218
+ }
219
+ /**
220
+ * The project `.agents/skills` directories for `cwd`, nearest first: one per folder from cwd up to
221
+ * the git root (or the filesystem root), stopping below the user's home. Home and every folder
222
+ * above it are never a project; `~/.agents/skills` is the user's own global skills. The ONE walk
223
+ * both the trust check and the loader use, over the same spelling and the same git root (#3420
224
+ * review): paths stay as spelled (resources are compared as strings), and each folder's real path
225
+ * decides whether it is home or above, so a symlink can't carry the walk past home either way.
226
+ */
227
+ export function projectAgentsSkillDirs(cwd) {
228
+ const homeDir = canonicalizePath(resolvePath(process.env.HOME || homedir()));
229
+ const startDir = resolvePath(cwd);
230
+ const gitRepoRoot = findGitRepoRoot(startDir);
231
+ const dirs = [];
232
+ let currentDir = startDir;
233
+ while (!isHomeOrAboveDir(canonicalizePath(currentDir), homeDir)) {
234
+ dirs.push(join(currentDir, ".agents", "skills"));
235
+ if (currentDir === gitRepoRoot)
236
+ break;
237
+ const parentDir = dirname(currentDir);
238
+ if (parentDir === currentDir)
239
+ break;
240
+ currentDir = parentDir;
241
+ }
242
+ return dirs;
243
+ }
204
244
  /**
205
245
  * Returns true when cwd has project-local resources that must be gated by
206
246
  * project trust: trust-requiring entries under the project config dir or the
207
- * legacy cwd/.pi dir (whose resources are also discovered), or .agents/skills in
208
- * cwd or one of its ancestors. Returns false when no such project resources
209
- * exist. The user/global ~/.agents/skills directory is always treated as a
210
- * trusted user resource and is ignored here, even when cwd is $HOME.
247
+ * legacy cwd/.pi dir (whose resources are also discovered), or a project
248
+ * `.agents/skills` (`projectAgentsSkillDirs`, the same walk the loader uses).
249
+ * The user's home and every folder above it are never a project: their config
250
+ * dirs and .agents/skills are the user's own global resources (#2465).
211
251
  */
212
252
  export function hasTrustRequiringProjectResources(cwd) {
213
253
  const homeDir = canonicalizePath(resolvePath(process.env.HOME || homedir()));
214
- const userAgentsSkillsDir = join(homeDir, ".agents", "skills");
215
- let currentDir = canonicalizePath(resolvePath(cwd));
216
- const configDirs = [join(currentDir, CONFIG_DIR_NAME), join(currentDir, LEGACY_PROJECT_CONFIG_DIR_NAME)];
217
- if (configDirs.some((configDir) => TRUST_REQUIRING_PROJECT_CONFIG_RESOURCES.some((entry) => existsSync(join(configDir, entry))))) {
218
- return true;
219
- }
220
- if (projectCodemodeNamesExecutable(join(currentDir, CONFIG_DIR_NAME)))
221
- return true;
222
- while (true) {
223
- const agentsSkillsDir = join(currentDir, ".agents", "skills");
224
- if (agentsSkillsDir !== userAgentsSkillsDir && existsSync(agentsSkillsDir)) {
254
+ const currentDir = canonicalizePath(resolvePath(cwd));
255
+ if (!isHomeOrAboveDir(currentDir, homeDir)) {
256
+ const configDirs = [join(currentDir, CONFIG_DIR_NAME), join(currentDir, LEGACY_PROJECT_CONFIG_DIR_NAME)];
257
+ if (configDirs.some((configDir) => TRUST_REQUIRING_PROJECT_CONFIG_RESOURCES.some((entry) => existsSync(join(configDir, entry))))) {
225
258
  return true;
226
259
  }
227
- const parentDir = dirname(currentDir);
228
- if (parentDir === currentDir) {
229
- return false;
230
- }
231
- currentDir = parentDir;
260
+ if (projectCodemodeNamesExecutable(join(currentDir, CONFIG_DIR_NAME)))
261
+ return true;
232
262
  }
263
+ return projectAgentsSkillDirs(cwd).some((dir) => existsSync(dir));
233
264
  }
234
265
  export class ProjectTrustStore {
235
266
  constructor(agentDir) {
package/dist/main.js CHANGED
@@ -47,7 +47,7 @@ import { ModelRuntime } from "./core/model-runtime.js";
47
47
  import { markMovedSessions, movedHereSessionToContinue, withMovedSessions } from "./core/moved-sessions.js";
48
48
  import { restoreStdout, takeOverStdout } from "./core/output-guard.js";
49
49
  import { recordProcessLifetime } from "./core/process-crash-record.js";
50
- import { resolveProjectTrusted } from "./core/project-trust.js";
50
+ import { presetTrustsProject, resolveProjectTrusted } from "./core/project-trust.js";
51
51
  import { resolveResumeTarget } from "./core/resume-target.js";
52
52
  import { formatMissingSessionCwdPrompt, getMissingSessionCwdIssue, MissingSessionCwdError, } from "./core/session-cwd.js";
53
53
  import { sessionExtensionFlagValues } from "./core/session-extension-flags.js";
@@ -677,13 +677,26 @@ export function createCliRuntimeFactory(configuration, local = {}) {
677
677
  const projectTrustDiagnostics = [];
678
678
  const cachedProjectTrust = projectTrustByCwd.get(cwd);
679
679
  const hasTrustRequiringResources = hasTrustRequiringProjectResources(cwd);
680
- const shouldResolveProjectTrust = parsed.projectTrustOverride === undefined && cachedProjectTrust === undefined && hasTrustRequiringResources;
680
+ // A full-access session trusts its project without asking, for that session only: nothing is
681
+ // cached here or saved to the trust store, and a saved "do not trust" still wins (#2465).
682
+ const fullAccessTrust = presetTrustsProject(sessionExtensionFlagValues(parsed.unknownFlags, launchProfile).get("permission-preset"), startupSettingsManager.getGlobalSettings().permissionPreset) && trustStore.get(cwd) !== false;
683
+ const shouldResolveProjectTrust = parsed.projectTrustOverride === undefined &&
684
+ cachedProjectTrust === undefined &&
685
+ !fullAccessTrust &&
686
+ hasTrustRequiringResources;
681
687
  const projectTrusted = shouldResolveProjectTrust
682
688
  ? false
683
689
  : (cachedProjectTrust ??
684
690
  parsed.projectTrustOverride ??
685
- (!hasTrustRequiringResources || trustStore.get(cwd) === true));
686
- const runtimeSettingsManager = SettingsManager.create(cwd, agentDir, { projectTrusted });
691
+ (fullAccessTrust || !hasTrustRequiringResources || trustStore.get(cwd) === true));
692
+ // Trusted only because of Full access (resources need trust and nothing else granted it): leaving
693
+ // Full access withdraws it (#3420 review T5).
694
+ const trustFromFullAccess = fullAccessTrust &&
695
+ hasTrustRequiringResources &&
696
+ cachedProjectTrust === undefined &&
697
+ parsed.projectTrustOverride === undefined &&
698
+ trustStore.get(cwd) !== true;
699
+ const runtimeSettingsManager = SettingsManager.create(cwd, agentDir, { projectTrusted, trustFromFullAccess });
687
700
  // The opener's per-session fallback policy is an in-memory override: never saved, never shared.
688
701
  if (launchProfile?.retryFallback)
689
702
  applyRetryFallbackProfile(runtimeSettingsManager, launchProfile.retryFallback);
@@ -1194,6 +1207,12 @@ export async function main(args, options) {
1194
1207
  reportDiagnostics([{ type: "warning", message: formatThinkingClampWarning(startupThinkingClamp) }]);
1195
1208
  }
1196
1209
  if (appMode !== "interactive") {
1210
+ reportDiagnostics([...(resourceLoader.getAgentsFiles().diagnostics ?? []), ...resourceLoader.getSkills().diagnostics]
1211
+ .filter((diagnostic) => diagnostic.type === "warning" && diagnostic.category === "size-limit")
1212
+ .map((diagnostic) => ({
1213
+ type: "warning",
1214
+ message: diagnostic.path ? `${diagnostic.path}: ${diagnostic.message}` : diagnostic.message,
1215
+ })));
1197
1216
  reportDiagnostics(collectAuthDiagnostics(services.authStorage, "runtime creation"));
1198
1217
  }
1199
1218
  if (runtime.diagnostics.some((diagnostic) => diagnostic.type === "error")) {
@@ -1952,16 +1952,23 @@ export class InteractiveMode {
1952
1952
  }
1953
1953
  }
1954
1954
  if (showDiagnostics) {
1955
- const addDiagnosticNotice = (title, diagnostics) => {
1955
+ const addDiagnosticNotice = (title, diagnostics, why = "Conflicting or invalid loaded resources were found.") => {
1956
1956
  this.loadedResourcesContainer.addChild(buildNoticeBox({
1957
1957
  title,
1958
1958
  tone: "warning",
1959
- why: "Conflicting or invalid loaded resources were found.",
1959
+ why,
1960
1960
  extra: this.formatDiagnostics(diagnostics, sourceInfos),
1961
1961
  }, { expanded: this.toolOutputExpanded }, theme));
1962
1962
  this.loadedResourcesContainer.addChild(new Spacer(1));
1963
1963
  };
1964
- const skillDiagnostics = skillsResult.diagnostics;
1964
+ const contextDiagnostics = [
1965
+ ...(this.session.resourceLoader.getAgentsFiles().diagnostics ?? []),
1966
+ ...skillsResult.diagnostics.filter((diagnostic) => diagnostic.category === "size-limit"),
1967
+ ];
1968
+ if (contextDiagnostics.length > 0) {
1969
+ addDiagnosticNotice("Context size limits", contextDiagnostics, "Context files or skill catalogs were limited to keep requests bounded.");
1970
+ }
1971
+ const skillDiagnostics = skillsResult.diagnostics.filter((diagnostic) => diagnostic.category !== "size-limit");
1965
1972
  if (skillDiagnostics.length > 0) {
1966
1973
  addDiagnosticNotice("Skill conflicts", skillDiagnostics);
1967
1974
  }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * The `abort { awaitIdle }` grace window, shared by the host that enforces it and the client that sizes its
3
+ * request deadline from it (docs/rpc.md `abort`). Kept free of host imports so the client can load it.
4
+ */
5
+ export declare const ABORT_DEFAULT_GRACE_MS = 10000;
6
+ export declare const ABORT_MAX_GRACE_MS = 60000;
7
+ /** A `graceMs` the host accepts: a whole number of milliseconds from 0 to {@link ABORT_MAX_GRACE_MS}. */
8
+ export declare function isAbortGraceInRange(graceMs: unknown): graceMs is number;
9
+ //# sourceMappingURL=abort-grace.d.ts.map
@@ -0,0 +1,11 @@
1
+ /**
2
+ * The `abort { awaitIdle }` grace window, shared by the host that enforces it and the client that sizes its
3
+ * request deadline from it (docs/rpc.md `abort`). Kept free of host imports so the client can load it.
4
+ */
5
+ export const ABORT_DEFAULT_GRACE_MS = 10_000;
6
+ export const ABORT_MAX_GRACE_MS = 60_000;
7
+ /** A `graceMs` the host accepts: a whole number of milliseconds from 0 to {@link ABORT_MAX_GRACE_MS}. */
8
+ export function isAbortGraceInRange(graceMs) {
9
+ return typeof graceMs === "number" && Number.isInteger(graceMs) && graceMs >= 0 && graceMs <= ABORT_MAX_GRACE_MS;
10
+ }
11
+ //# sourceMappingURL=abort-grace.js.map
@@ -18,8 +18,6 @@ export declare function beginEffect(ctx: EffectCommandContext, kind: EffectComma
18
18
  readonly effectId: string;
19
19
  }): Promise<EffectBegin | RpcResponse>;
20
20
  export declare function isEffectBegin(value: EffectBegin | RpcResponse): value is EffectBegin;
21
- export declare const ABORT_DEFAULT_GRACE_MS = 10000;
22
- export declare const ABORT_MAX_GRACE_MS = 60000;
23
21
  /**
24
22
  * `abort`: with `clearQueue` the queued inputs are drained (each gets a `cleared` receipt) before
25
23
  * the abort; with `awaitIdle` the answer waits until the session is idle or `graceMs` passes. Without
@@ -1,5 +1,6 @@
1
1
  import { ModelUsabilityBudgetError } from "../../core/extensions/builtin/compaction/model-usability-budget.js";
2
2
  import { ModelSwitchAuthError } from "../../core/model-switch-auth-error.js";
3
+ import { ABORT_DEFAULT_GRACE_MS, ABORT_MAX_GRACE_MS, isAbortGraceInRange } from "./abort-grace.js";
3
4
  import { ClientAdmissions } from "./client-admissions.js";
4
5
  import { EffectLedger } from "./effect-ledger.js";
5
6
  import { contextBudgetOutcome, EFFECT_OUTCOMES, modelSwitchErrorOutcome, providerUnavailableOutcome, unknownModelOutcome, unsupportedThinkingLevelOutcome, } from "./effect-outcome-copy.js";
@@ -45,13 +46,11 @@ function isResponse(value) {
45
46
  export function isEffectBegin(value) {
46
47
  return !isResponse(value);
47
48
  }
48
- export const ABORT_DEFAULT_GRACE_MS = 10_000;
49
- export const ABORT_MAX_GRACE_MS = 60_000;
50
49
  function invalidGrace(command) {
51
50
  const { graceMs } = command;
52
51
  if (graceMs === undefined)
53
52
  return undefined;
54
- if (Number.isInteger(graceMs) && graceMs >= 0 && graceMs <= ABORT_MAX_GRACE_MS)
53
+ if (isAbortGraceInRange(graceMs))
55
54
  return undefined;
56
55
  const reason = "invalid_grace_ms";
57
56
  const action = `send graceMs as a whole number of milliseconds from 0 to ${ABORT_MAX_GRACE_MS}`;
@@ -30,6 +30,12 @@ export interface HandoverParkedEntry {
30
30
  readonly sessionContext?: SessionContext;
31
31
  readonly autoTitle?: boolean;
32
32
  readonly retryFallback?: SessionRetryFallbackProfile;
33
+ /**
34
+ * The host instance that wrote the park. Its own reopen watch skips it: the park is for the NEXT
35
+ * generation, and a host reopening what it just idle-parked races the client's reopen (senpi#3485).
36
+ * Absent on entries an older generation wrote, which every reader treats as a predecessor's park.
37
+ */
38
+ readonly parkedBy?: string;
33
39
  }
34
40
  export declare function recordHandoverParkedSession(daemonDir: string, entry: HandoverParkedEntry): Promise<void>;
35
41
  export declare function readHandoverParkedSessions(daemonDir: string): Promise<readonly HandoverParkedEntry[]>;
@@ -35,6 +35,7 @@ function isEntry(value) {
35
35
  typeof record.retainOnDisconnect === "boolean" &&
36
36
  (record.durableSessionId === undefined || typeof record.durableSessionId === "string") &&
37
37
  (record.cwd === undefined || typeof record.cwd === "string") &&
38
+ (record.parkedBy === undefined || typeof record.parkedBy === "string") &&
38
39
  (record.thinkingLevel === undefined || typeof record.thinkingLevel === "string") &&
39
40
  (record.permissionPreset === undefined ||
40
41
  (typeof record.permissionPreset === "string" &&
@@ -46,12 +46,12 @@ export async function reopenHandoverParkedSessions(open, agentDir, socket, holde
46
46
  // it by path with the same durable id.
47
47
  if (holder?.shouldReopen !== undefined && !holder.shouldReopen())
48
48
  return;
49
- const parked = await readHandoverParkedSessions(daemonDir);
49
+ const parked = await inheritedParks(daemonDir, holder);
50
50
  for (const entry of parked) {
51
51
  sawAnyPark = true;
52
52
  await reopenOne(daemonDir, open, entry, reopenAttempts, heldPolls, holder);
53
53
  }
54
- const remaining = (await readHandoverParkedSessions(daemonDir)).length;
54
+ const remaining = (await inheritedParks(daemonDir, holder)).length;
55
55
  emptyStreak = remaining === 0 ? emptyStreak + 1 : 0;
56
56
  if (sawAnyPark && emptyStreak >= HANDOVER_PARKED_REOPEN_QUIET_STREAK)
57
57
  return;
@@ -60,6 +60,15 @@ export async function reopenHandoverParkedSessions(open, agentDir, socket, holde
60
60
  await new Promise((resolve) => setTimeout(resolve, HANDOVER_PARKED_REOPEN_POLL_MS));
61
61
  }
62
62
  }
63
+ /**
64
+ * The parks a PREVIOUS generation left for this one. A park this host wrote itself (its idle sweep
65
+ * parks retained sessions into the same record) is for its successor: reopening it here would put a
66
+ * second open of the path in flight against the client reopening it (senpi#3485).
67
+ */
68
+ async function inheritedParks(daemonDir, holder) {
69
+ const parked = await readHandoverParkedSessions(daemonDir);
70
+ return parked.filter((entry) => entry.parkedBy === undefined || entry.parkedBy !== holder?.hostInstance);
71
+ }
63
72
  async function reopenOne(daemonDir, open, entry, attempts, heldPolls, holder) {
64
73
  await dropHandoverParkedSessionIfFileGone(daemonDir, entry.sessionPath);
65
74
  if (!existsSync(entry.sessionPath)) {
@@ -22,7 +22,7 @@ export declare class RpcCommandError extends Error {
22
22
  }
23
23
  import type { SessionEntry, SessionTreeNode } from "../../core/session-manager.ts";
24
24
  import type { JsonAgentSessionEvent } from "../json-event.ts";
25
- import type { EditAssistantMessageResult, EditUserMessageResult, RpcAccountFailoverEvent, RpcAuthAccountsChangedEvent, RpcCommand, RpcEvalCell, RpcEvalCellSnapshot, RpcExtensionEvent, RpcExtensionUIProgress, RpcExtensionUIRequest, RpcExtensionUIResponse, RpcOpenQueuedEvent, RpcProviderAccount, RpcSessionModelEntry, RpcSessionParkedEvent, RpcSessionReplacedEvent, RpcSessionState, RpcSlashCommand, RpcUnloadAnswer } from "./rpc-types.ts";
25
+ import type { EditAssistantMessageResult, EditUserMessageResult, RpcAccountFailoverEvent, RpcAuthAccountsChangedEvent, RpcCommand, RpcEvalCell, RpcEvalCellSnapshot, RpcExtensionEvent, RpcExtensionUIProgress, RpcExtensionUIRequest, RpcExtensionUIResponse, RpcOpenQueuedEvent, RpcProviderAccount, RpcResponse, RpcSessionModelEntry, RpcSessionParkedEvent, RpcSessionReplacedEvent, RpcSessionState, RpcSlashCommand, RpcUnloadAnswer } from "./rpc-types.ts";
26
26
  export interface RpcClientOptions {
27
27
  /** Connect to an existing multi-session Unix socket instead of spawning a child. */
28
28
  socketPath?: string;
@@ -72,6 +72,20 @@ export declare class RpcTransportGoneError extends Error {
72
72
  constructor(message?: string);
73
73
  }
74
74
  export declare function isTransportGoneError(error: unknown): error is RpcTransportGoneError;
75
+ /** The options of a typed `abort` (see `docs/rpc.md`, `abort`). */
76
+ export type RpcAbortOptions = Omit<Extract<RpcCommand, {
77
+ type: "abort";
78
+ }>, "type" | "id" | "sessionId">;
79
+ /** The host's answer to a typed `abort`; an older host answers with no data. */
80
+ export type RpcAbortAnswer = Extract<RpcResponse, {
81
+ command: "abort";
82
+ success: true;
83
+ }>["data"];
84
+ /** `get_effect_status`: the receipt, or proof the whole transcript holds none. */
85
+ export type RpcEffectStatusAnswer = Extract<RpcResponse, {
86
+ command: "get_effect_status";
87
+ success: true;
88
+ }>["data"];
75
89
  export declare class RpcClientOpenInFlightError extends Error {
76
90
  readonly code: "open_session_in_flight";
77
91
  constructor();
@@ -222,9 +236,21 @@ export declare class RpcClient {
222
236
  enqueueOrder?: number;
223
237
  }): Promise<QueuedInputDisposition>;
224
238
  /**
225
- * Abort current operation.
239
+ * Abort current operation. With no options this is the fire-and-forget abort it always was (transport
240
+ * loss resolves). With options it is the typed abort: `effectId` records a receipt, `awaitIdle` answers
241
+ * once the turn stopped (or `graceMs` passed), `clearQueue` drains queued input first; the host's
242
+ * answer (`effectId`, `state`, `effect`, `stopped`, `idle`, `cleared`) is returned.
226
243
  */
227
244
  abort(): Promise<void>;
245
+ abort(options: RpcAbortOptions): Promise<RpcAbortAnswer>;
246
+ abort(options?: RpcAbortOptions): Promise<RpcAbortAnswer | undefined>;
247
+ /**
248
+ * The newest receipt of one effect (`get_effect_status`, capability `effect_ledger`). With `sessionPath`
249
+ * the host reads that transcript from disk, so a session no host holds is answered without loading it.
250
+ */
251
+ getEffectStatus(effectId: string, options?: {
252
+ readonly sessionPath?: string;
253
+ }): Promise<RpcEffectStatusAnswer>;
228
254
  /** Abort with host refusals and transport loss surfaced to machine callers. */
229
255
  abortStrict(): Promise<void>;
230
256
  abortCompaction(): Promise<void>;
@@ -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