@nathapp/nax-agent 0.91.6 → 0.91.7-canary.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/dist/coding-tools/coding-tool-extras.d.ts +42 -0
  3. package/dist/coding-tools/coding-tool-extras.js +31 -0
  4. package/dist/coding-tools/coding-tool-support.d.ts +6 -0
  5. package/dist/coding-tools/coding-tool-support.js +7 -1
  6. package/dist/config/catalog-overrides.d.ts +5 -0
  7. package/dist/index.d.ts +3 -2
  8. package/dist/index.js +2 -1
  9. package/dist/internal/argv-exec.d.ts +2 -0
  10. package/dist/internal/argv-exec.js +72 -7
  11. package/dist/mcp/connect.js +26 -3
  12. package/dist/mcp/types.d.ts +7 -0
  13. package/dist/native/models.js +1 -0
  14. package/dist/native/session/loop-events/types.d.ts +5 -0
  15. package/dist/native/session-adapter.js +16 -5
  16. package/dist/permissions/approval-memory.d.ts +18 -0
  17. package/dist/permissions/approval-memory.js +88 -0
  18. package/dist/permissions/index.d.ts +1 -0
  19. package/dist/permissions/index.js +1 -0
  20. package/dist/permissions/types.d.ts +6 -0
  21. package/dist/sandbox/claude-settings.d.ts +2 -1
  22. package/dist/sandbox/claude-settings.js +7 -3
  23. package/dist/sandbox/defaults.js +1 -0
  24. package/dist/sandbox/launcher.js +3 -0
  25. package/dist/sandbox/types.d.ts +2 -0
  26. package/dist/session/agent-session-command.d.ts +19 -0
  27. package/dist/session/agent-session-command.js +38 -0
  28. package/dist/session/agent-session-types.d.ts +9 -0
  29. package/dist/session/agent-session.js +49 -1
  30. package/dist/session/native-backend-options.d.ts +18 -2
  31. package/dist/session/native-backend-options.js +26 -3
  32. package/dist/session/native-backend.js +42 -4
  33. package/dist/session/session-ask-link.d.ts +6 -0
  34. package/dist/session/session-ask-link.js +5 -0
  35. package/dist/session/session-ask-port.js +4 -0
  36. package/dist/session/session-backend.d.ts +21 -1
  37. package/dist/session/session-tool-support.d.ts +39 -3
  38. package/dist/session/session-tool-support.js +54 -10
  39. package/dist/session-store/index.d.ts +3 -0
  40. package/dist/session-store/index.js +3 -0
  41. package/dist/session-store/session-lock-guard.d.ts +6 -0
  42. package/dist/session-store/session-lock-guard.js +85 -0
  43. package/dist/session-store/session-lock.d.ts +39 -0
  44. package/dist/session-store/session-lock.js +175 -0
  45. package/dist/session-store/session-meta.d.ts +27 -0
  46. package/dist/session-store/session-meta.js +20 -0
  47. package/dist/session-store/storage.d.ts +39 -0
  48. package/dist/session-store/storage.js +164 -0
  49. package/dist/tools/ask-request.d.ts +18 -0
  50. package/dist/tools/ask-request.js +30 -0
  51. package/dist/tools/bash.d.ts +9 -0
  52. package/dist/tools/bash.js +61 -16
  53. package/dist/tools/git.js +10 -3
  54. package/dist/tools/policy-match.d.ts +2 -2
  55. package/dist/tools/policy-match.js +9 -6
  56. package/dist/tools/policy-paths-branch.js +4 -1
  57. package/dist/tools/provider-advertise.d.ts +7 -0
  58. package/dist/tools/provider-advertise.js +13 -1
  59. package/dist/tools/provider-types.d.ts +6 -0
  60. package/dist/tools/run-command-exec.js +12 -0
  61. package/dist/tools/run-command-surface.d.ts +28 -0
  62. package/dist/tools/run-command-surface.js +109 -0
  63. package/dist/tools/run-command.d.ts +17 -16
  64. package/dist/tools/run-command.js +48 -68
  65. package/dist/tools/runtime-calltool.d.ts +100 -0
  66. package/dist/tools/runtime-calltool.js +112 -3
  67. package/dist/tools/runtime.d.ts +11 -0
  68. package/dist/tools/runtime.js +36 -18
  69. package/dist/tools/types.d.ts +8 -0
  70. package/package.json +3 -3
@@ -4,12 +4,14 @@
4
4
  * buildCodingToolSupport takes, picks the embedder default protected-paths
5
5
  * policy, and enforces the sandbox floor for "full".
6
6
  */
7
+ import { unsandboxedToolOptions } from "#src/coding-tools/coding-tool-extras";
7
8
  import { resolveSessionSandbox } from "#src/coding-tools/coding-tool-sandbox";
8
9
  import { buildCodingToolSupport } from "#src/coding-tools/coding-tool-support";
9
10
  import { UNIVERSAL_CODING_TOOLS } from "#src/coding-tools/universal-coding-tools";
10
11
  import { DEFAULT_SANDBOX_CONFIG } from "#src/config/schemas-sandbox";
11
12
  import { credentialsConfig } from "#src/infra/credentials-config";
12
13
  import { EMPTY_OWNED_PATHS_POLICY } from "#src/tools/owned-paths";
14
+ import { renderProviderInstructions } from "#src/tools/provider-instructions";
13
15
  import { AgentSessionError } from "./agent-session-errors.js";
14
16
  /** The pipeline stage the facade's tool calls and asks carry. */
15
17
  export const SESSION_STAGE = "session";
@@ -59,10 +61,17 @@ export function defaultProtectedPaths(ownCredentials) {
59
61
  const credentialDir = ownCredentials ? undefined : configuredCredentialDir();
60
62
  return credentialDir === undefined ? base : { ...base, credentialDir };
61
63
  }
62
- /** The sandbox floor (spec 6.3): "ask", "full" and "review" need a usable sandbox unless gated + allowUnsandboxed. */
64
+ /**
65
+ * The sandbox floor (spec 6.3): "ask", "full" and "review" need a usable sandbox,
66
+ * unless the session explicitly allows running without one — under `gated` (every
67
+ * call still goes to the person) or under `raw` (the person's own standing choice).
68
+ * The resolved state rides back beside the launcher, so the session can report
69
+ * what its commands actually run under.
70
+ */
63
71
  export async function resolveSessionLauncher(args) {
64
- if (args.profile !== "full" && args.profile !== "ask" && args.profile !== "review")
65
- return undefined;
72
+ if (args.profile !== "full" && args.profile !== "ask" && args.profile !== "review") {
73
+ return { launcher: undefined, sandbox: { kind: "disabled" } };
74
+ }
66
75
  const launcher = await resolveSessionSandbox({
67
76
  config: DEFAULT_SANDBOX_CONFIG,
68
77
  root: args.root,
@@ -71,16 +80,43 @@ export async function resolveSessionLauncher(args) {
71
80
  ownedPaths: EMPTY_OWNED_PATHS_POLICY,
72
81
  });
73
82
  if (launcher.state.kind === "available")
74
- return launcher;
75
- if (args.bashApproval === "gated" && args.allowUnsandboxed)
76
- return undefined;
83
+ return { launcher, sandbox: launcher.state };
84
+ if (args.allowUnsandboxed)
85
+ return { launcher: undefined, sandbox: launcher.state };
77
86
  const reason = launcher.state.kind === "unavailable" ? launcher.state.reason : "the sandbox is disabled";
78
- throw new AgentSessionError(`Profile "${args.profile}" needs a usable sandbox (${reason}). Pass bashApproval "gated" with allowUnsandboxed: true to run without one.`, "AGENT_SESSION_SANDBOX_UNAVAILABLE", { reason });
87
+ throw new AgentSessionError(`Profile "${args.profile}" needs a usable sandbox (${reason}). Pass allowUnsandboxed: true to run without one.`, "AGENT_SESSION_SANDBOX_UNAVAILABLE", { reason });
88
+ }
89
+ /**
90
+ * The `admits` predicate for `resolveProviderTools`: a tool `<id>__<local>` is
91
+ * admitted when `allowedTools` names it exactly or names `<id>__*`.
92
+ */
93
+ export function providerAdmits(allowedTools) {
94
+ const allowed = new Set(allowedTools);
95
+ return (providerId, localName) => allowed.has(`${providerId}__${localName}`) || allowed.has(`${providerId}__*`);
96
+ }
97
+ /** The profile's built-ins narrowed by `allowedTools`; the scratchpad trio always survives. */
98
+ function narrowBuiltIns(profileTools, allowedTools) {
99
+ if (allowedTools === undefined)
100
+ return profileTools;
101
+ const allowed = new Set(allowedTools);
102
+ return profileTools.filter((tool) => UNIVERSAL_CODING_TOOLS.includes(tool) || allowed.has(tool));
79
103
  }
80
104
  export function buildSessionToolSupport(args) {
81
- const declared = declaredToolsFor(args.profile, args.protectedPaths);
82
- const grants = grantsFor(declared);
83
- const askRules = askRulesFor(declared, args.bashApproval, args.profile);
105
+ const profileTools = narrowBuiltIns(declaredToolsFor(args.profile, args.protectedPaths), args.allowedTools);
106
+ // CodingToolName enumerates built-ins, but the runtime also accepts provider names.
107
+ const declared = [
108
+ ...new Set([...profileTools, ...(args.providerTools?.tools.map((tool) => tool.name) ?? [])]),
109
+ ];
110
+ const grants = [...grantsFor(profileTools), ...(args.providerTools?.grants ?? [])];
111
+ // Only the "ask" profile puts a discovered, non-read-only tool to the person; "full" never asks.
112
+ const askRules = [
113
+ ...askRulesFor(profileTools, args.bashApproval, args.profile),
114
+ ...(args.profile === "ask" ? (args.providerTools?.askTools ?? []) : []).map((tool) => ({
115
+ tool: tool,
116
+ patterns: ["*"],
117
+ })),
118
+ ];
119
+ const providerInstructions = renderProviderInstructions(args.providerTools?.instructions ?? []);
84
120
  const support = buildCodingToolSupport({
85
121
  root: args.root,
86
122
  commandCwd: args.root,
@@ -88,6 +124,13 @@ export function buildSessionToolSupport(args) {
88
124
  sessionName: args.sessionName,
89
125
  declared,
90
126
  grants,
127
+ ...(args.providerTools !== undefined
128
+ ? {
129
+ extraTools: args.providerTools.tools,
130
+ providerIdByTool: args.providerTools.providerIdByTool,
131
+ }
132
+ : {}),
133
+ ...(providerInstructions !== undefined ? { providerInstructions } : {}),
91
134
  bashApproval: args.bashApproval,
92
135
  protectedPaths: args.protectedPaths,
93
136
  ownedPaths: EMPTY_OWNED_PATHS_POLICY,
@@ -95,6 +138,7 @@ export function buildSessionToolSupport(args) {
95
138
  ...(askRules.length > 0 ? { askRules } : {}),
96
139
  ...(args.interceptor !== undefined ? { interceptor: args.interceptor } : {}),
97
140
  ...(args.launcher !== undefined ? { launcher: args.launcher } : {}),
141
+ ...unsandboxedToolOptions(args),
98
142
  });
99
143
  // Unreachable while the scratchpad trio is always declared and granted; kept for the type.
100
144
  if (support === undefined) {
@@ -0,0 +1,3 @@
1
+ export { LOCK_HEARTBEAT_MS, LOCK_STALE_MS } from "./session-lock.ts";
2
+ export { defaultSessionsDir, SESSION_ID_PATTERN, SESSION_META_MODES, type SessionMeta, type SessionMetaMode, } from "./session-meta.ts";
3
+ export { createSessionStorage, LIST_PAGE_SIZE, processAlive, type SessionListEntry, type SessionListPage, type SessionLockHolder, type SessionLockHolderState, type SessionStorage, type SessionStorageDeps, } from "./storage.ts";
@@ -0,0 +1,3 @@
1
+ export { LOCK_HEARTBEAT_MS, LOCK_STALE_MS } from "./session-lock.js";
2
+ export { defaultSessionsDir, SESSION_ID_PATTERN, SESSION_META_MODES, } from "./session-meta.js";
3
+ export { createSessionStorage, LIST_PAGE_SIZE, processAlive, } from "./storage.js";
@@ -0,0 +1,6 @@
1
+ import { NaxError } from "#src/infra/nax-error";
2
+ /** Distinguishes retryable mutex contention from mutation and filesystem failures. */
3
+ export declare class SessionLockGuardBusy extends NaxError {
4
+ constructor();
5
+ }
6
+ export declare function withSessionLockGuard(path: string, mutate: () => void): void;
@@ -0,0 +1,85 @@
1
+ /** Short interprocess mutex for lock-file mutations, never stolen from a live process. */
2
+ import { randomUUID } from "node:crypto";
3
+ import { mkdirSync, readdirSync, renameSync, rmdirSync, unlinkSync, writeFileSync } from "node:fs";
4
+ import { NaxError } from "#src/infra/nax-error";
5
+ /** Distinguishes retryable mutex contention from mutation and filesystem failures. */
6
+ export class SessionLockGuardBusy extends NaxError {
7
+ constructor() {
8
+ super("session in use by another process", "SESSION_LOCKED", { stage: "session-store" });
9
+ }
10
+ }
11
+ function codeOf(error) {
12
+ return typeof error === "object" && error !== null && "code" in error ? error.code : undefined;
13
+ }
14
+ function removeMarker(guard, marker) {
15
+ try {
16
+ unlinkSync(`${guard}/${marker}`);
17
+ }
18
+ catch (error) {
19
+ if (codeOf(error) !== "ENOENT")
20
+ throw error;
21
+ }
22
+ try {
23
+ // A contender can replace the now-empty directory. rmdir cannot remove its
24
+ // non-empty replacement; never recursively delete the shared guard path.
25
+ rmdirSync(guard);
26
+ }
27
+ catch (error) {
28
+ if (!["ENOENT", "ENOTEMPTY", "EEXIST"].includes(String(codeOf(error))))
29
+ throw error;
30
+ }
31
+ }
32
+ function reapDeadGuard(guard) {
33
+ let markers;
34
+ try {
35
+ markers = readdirSync(guard);
36
+ }
37
+ catch (error) {
38
+ if (codeOf(error) === "ENOENT")
39
+ return;
40
+ throw error;
41
+ }
42
+ for (const marker of markers) {
43
+ const pid = /^(\d+)\.[0-9a-f-]+$/.exec(marker)?.[1];
44
+ if (pid === undefined)
45
+ continue; // Unrecognized guards fail closed.
46
+ try {
47
+ process.kill(Number(pid), 0);
48
+ }
49
+ catch (error) {
50
+ // Only proven death permits recovery, never heartbeat age or EPERM.
51
+ // The unique marker path prevents two reapers from deleting a new owner.
52
+ if (codeOf(error) === "ESRCH")
53
+ removeMarker(guard, marker);
54
+ }
55
+ }
56
+ }
57
+ export function withSessionLockGuard(path, mutate) {
58
+ const guard = `${path}.guard`;
59
+ const marker = `${process.pid}.${randomUUID()}`;
60
+ const prepared = `${guard}.${marker}.tmp`;
61
+ mkdirSync(prepared);
62
+ try {
63
+ writeFileSync(`${prepared}/${marker}`, "", { flag: "wx" });
64
+ reapDeadGuard(guard);
65
+ try {
66
+ // Publish an already non-empty directory atomically. rename can replace
67
+ // an empty (released/reaped) guard, but never a live non-empty guard.
68
+ renameSync(prepared, guard);
69
+ }
70
+ catch (error) {
71
+ if (["ENOTEMPTY", "EEXIST"].includes(String(codeOf(error))))
72
+ throw new SessionLockGuardBusy();
73
+ throw error;
74
+ }
75
+ try {
76
+ mutate();
77
+ }
78
+ finally {
79
+ removeMarker(guard, marker);
80
+ }
81
+ }
82
+ finally {
83
+ removeMarker(prepared, marker);
84
+ }
85
+ }
@@ -0,0 +1,39 @@
1
+ import { linkSync, renameSync, rmSync } from "node:fs";
2
+ import { z } from "zod";
3
+ import type { AgentLogger } from "#src/infra/index";
4
+ export interface SessionStorageDeps {
5
+ readonly dir: string;
6
+ readonly pid: number;
7
+ readonly now: () => Date;
8
+ readonly logger: AgentLogger;
9
+ readonly setInterval?: (callback: () => void, ms: number) => ReturnType<typeof setInterval>;
10
+ readonly isAlive?: (pid: number) => boolean;
11
+ }
12
+ export type SessionLockHolderState = "live" | "stale" | "own";
13
+ export declare const LOCK_HEARTBEAT_MS = 30000;
14
+ export declare const LOCK_STALE_MS = 120000;
15
+ declare const LockSchema: z.ZodUnion<readonly [z.ZodObject<{
16
+ pid: z.ZodNumber;
17
+ startedAt: z.ZodString;
18
+ nonce: z.ZodString;
19
+ heartbeatAt: z.ZodString;
20
+ }, z.core.$strip>, z.ZodObject<{
21
+ pid: z.ZodNumber;
22
+ startedAt: z.ZodString;
23
+ nonce: z.ZodUndefined;
24
+ heartbeatAt: z.ZodUndefined;
25
+ }, z.core.$strip>]>;
26
+ type Body = z.infer<typeof LockSchema>;
27
+ export declare const _lockDeps: {
28
+ rename: typeof renameSync;
29
+ link: typeof linkSync;
30
+ remove: typeof rmSync;
31
+ };
32
+ declare function read(path: string): {
33
+ text?: string;
34
+ body?: Body;
35
+ };
36
+ export { read as readSessionLock };
37
+ /** Shared snapshot classification; ownership takes precedence over liveness. */
38
+ export declare function holderState(body: Body, deps: SessionStorageDeps, isAlive: (pid: number) => boolean): SessionLockHolderState;
39
+ export declare function acquireSessionLock(path: string, deps: SessionStorageDeps, isAlive: (pid: number) => boolean): () => Promise<void>;
@@ -0,0 +1,175 @@
1
+ /** Local-filesystem ownership protocol. All lock mutations share an exclusive directory guard. */
2
+ import { randomUUID } from "node:crypto";
3
+ import { linkSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
4
+ import { setTimeout as sleep } from "node:timers/promises";
5
+ import { z } from "zod";
6
+ import { NaxError } from "#src/infra/nax-error";
7
+ import { withSessionLockGuard as guarded, SessionLockGuardBusy } from "./session-lock-guard.js";
8
+ export const LOCK_HEARTBEAT_MS = 30_000;
9
+ export const LOCK_STALE_MS = 120_000;
10
+ const LockSchema = z.union([
11
+ z.object({
12
+ pid: z.number().int().positive(),
13
+ startedAt: z.string(),
14
+ nonce: z.string(),
15
+ heartbeatAt: z.string().datetime(),
16
+ }),
17
+ z.object({
18
+ pid: z.number().int().positive(),
19
+ startedAt: z.string(),
20
+ nonce: z.undefined(),
21
+ heartbeatAt: z.undefined(),
22
+ }),
23
+ ]);
24
+ export const _lockDeps = { rename: renameSync, link: linkSync, remove: rmSync };
25
+ function codeOf(error) {
26
+ return typeof error === "object" && error !== null && "code" in error ? error.code : undefined;
27
+ }
28
+ function read(path) {
29
+ let text;
30
+ try {
31
+ text = readFileSync(path, "utf8");
32
+ const parsed = LockSchema.safeParse(JSON.parse(text));
33
+ return parsed.success ? { text, body: parsed.data } : { text };
34
+ }
35
+ catch {
36
+ return text === undefined ? {} : { text };
37
+ }
38
+ }
39
+ // Internal shared reader: snapshots and mutation decisions accept the same lock bodies.
40
+ export { read as readSessionLock };
41
+ function same(a, b) {
42
+ if (a.body?.nonce !== undefined)
43
+ return a.body.nonce === b.body?.nonce;
44
+ if (a.body !== undefined)
45
+ return b.body?.nonce === undefined && a.body.pid === b.body?.pid && a.body.startedAt === b.body.startedAt;
46
+ return a.text === b.text;
47
+ }
48
+ const refused = (pid) => new NaxError(`session in use by ${pid === undefined ? "another process" : `pid ${pid}`}`, "SESSION_LOCKED", {
49
+ stage: "session-store",
50
+ ...(pid !== undefined ? { pid } : {}),
51
+ });
52
+ function create(path, body) {
53
+ const temp = `${path}.${body.nonce}.tmp`;
54
+ try {
55
+ writeFileSync(temp, JSON.stringify(body), { flag: "wx" });
56
+ _lockDeps.link(temp, path);
57
+ return true;
58
+ }
59
+ catch (error) {
60
+ if (codeOf(error) === "EEXIST")
61
+ return false;
62
+ throw error;
63
+ }
64
+ finally {
65
+ rmSync(temp, { force: true });
66
+ }
67
+ }
68
+ /** Shared snapshot classification; ownership takes precedence over liveness. */
69
+ export function holderState(body, deps, isAlive) {
70
+ if (body.pid === deps.pid)
71
+ return "own";
72
+ if (isAlive(body.pid) &&
73
+ (body.heartbeatAt === undefined || deps.now().getTime() - Date.parse(body.heartbeatAt) <= LOCK_STALE_MS)) {
74
+ return "live";
75
+ }
76
+ return "stale";
77
+ }
78
+ function takeover(path, body, deps, isAlive) {
79
+ const old = read(path);
80
+ const holder = old.body;
81
+ if (holder !== undefined && holderState(holder, deps, isAlive) === "live")
82
+ throw refused(holder.pid);
83
+ const stale = `${path}.${body.nonce}.stale`;
84
+ try {
85
+ _lockDeps.rename(path, stale);
86
+ }
87
+ catch (error) {
88
+ if (codeOf(error) !== "ENOENT")
89
+ throw error;
90
+ // A failed rename is not permission to overwrite the winner.
91
+ const raced = read(path);
92
+ if (raced.body !== undefined || !create(path, body))
93
+ throw refused(raced.body?.pid);
94
+ return;
95
+ }
96
+ if (!same(old, read(stale))) {
97
+ // Never discard another owner's renamed file if restoration is blocked.
98
+ try {
99
+ _lockDeps.link(stale, path);
100
+ }
101
+ catch (error) {
102
+ if (codeOf(error) !== "EEXIST")
103
+ throw error;
104
+ throw refused();
105
+ }
106
+ rmSync(stale, { force: true });
107
+ throw refused();
108
+ }
109
+ try {
110
+ deps.logger.info("session", "taking over a stale session lock", { pid: holder?.pid });
111
+ if (!create(path, body))
112
+ throw refused();
113
+ }
114
+ finally {
115
+ rmSync(stale, { force: true });
116
+ }
117
+ }
118
+ async function releaseOwnedLock(path, nonce) {
119
+ for (;;) {
120
+ try {
121
+ guarded(path, () => {
122
+ if (read(path).body?.nonce === nonce)
123
+ _lockDeps.remove(path, { force: true });
124
+ });
125
+ return;
126
+ }
127
+ catch (error) {
128
+ if (!(error instanceof SessionLockGuardBusy))
129
+ throw error;
130
+ // Release owns cleanup until it can check the nonce under the guard.
131
+ // Yield to the holder rather than abandoning a fresh lock on contention.
132
+ // Unref'd: a deferred release after bounded shutdown never keeps the process alive.
133
+ await sleep(10, undefined, { ref: false });
134
+ }
135
+ }
136
+ }
137
+ export function acquireSessionLock(path, deps, isAlive) {
138
+ const stamp = deps.now().toISOString();
139
+ const body = { pid: deps.pid, startedAt: stamp, nonce: randomUUID(), heartbeatAt: stamp };
140
+ guarded(path, () => {
141
+ if (!create(path, body))
142
+ takeover(path, body, deps, isAlive);
143
+ });
144
+ const timer = (deps.setInterval ?? setInterval)(() => {
145
+ const temp = `${path}.${body.nonce}.tmp`;
146
+ try {
147
+ guarded(path, () => {
148
+ if (read(path).body?.nonce !== body.nonce) {
149
+ clearInterval(timer);
150
+ return;
151
+ }
152
+ body.heartbeatAt = deps.now().toISOString();
153
+ writeFileSync(temp, JSON.stringify(body));
154
+ _lockDeps.rename(temp, path);
155
+ });
156
+ }
157
+ catch (error) {
158
+ deps.logger.warn("session", "lock heartbeat failed", { path, error: String(error) });
159
+ }
160
+ finally {
161
+ rmSync(temp, { force: true });
162
+ }
163
+ }, LOCK_HEARTBEAT_MS);
164
+ timer.unref();
165
+ return async () => {
166
+ try {
167
+ // The heartbeat stays live while release waits out contention.
168
+ await releaseOwnedLock(path, body.nonce);
169
+ }
170
+ finally {
171
+ // A terminal release failure must let the lock age into stale recovery.
172
+ clearInterval(timer);
173
+ }
174
+ };
175
+ }
@@ -0,0 +1,27 @@
1
+ import { z } from "zod";
2
+ export declare const SESSION_META_MODES: readonly ["none", "read", "ask", "full"];
3
+ export type SessionMetaMode = (typeof SESSION_META_MODES)[number];
4
+ export declare const SESSION_ID_PATTERN: RegExp;
5
+ export declare function defaultSessionsDir(configDir: string): string;
6
+ export declare const MetaSchema: z.ZodObject<{
7
+ schemaVersion: z.ZodLiteral<1>;
8
+ sessionId: z.ZodString;
9
+ cwd: z.ZodString;
10
+ mode: z.ZodEnum<{
11
+ ask: "ask";
12
+ full: "full";
13
+ none: "none";
14
+ read: "read";
15
+ }>;
16
+ model: z.ZodString;
17
+ bashApproval: z.ZodEnum<{
18
+ escalate: "escalate";
19
+ gated: "gated";
20
+ raw: "raw";
21
+ }>;
22
+ title: z.ZodNullable<z.ZodString>;
23
+ createdAt: z.ZodString;
24
+ updatedAt: z.ZodNullable<z.ZodString>;
25
+ costUsd: z.ZodOptional<z.ZodNumber>;
26
+ }, z.core.$strip>;
27
+ export type SessionMeta = Readonly<z.infer<typeof MetaSchema>>;
@@ -0,0 +1,20 @@
1
+ import { join } from "node:path";
2
+ import { z } from "zod";
3
+ import { BashApprovalModeSchema } from "#src/config/bash-approval";
4
+ export const SESSION_META_MODES = ["none", "read", "ask", "full"];
5
+ export const SESSION_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/;
6
+ export function defaultSessionsDir(configDir) {
7
+ return join(configDir, ".agent-server", "sessions");
8
+ }
9
+ export const MetaSchema = z.object({
10
+ schemaVersion: z.literal(1),
11
+ sessionId: z.string().min(1),
12
+ cwd: z.string().min(1),
13
+ mode: z.enum(SESSION_META_MODES),
14
+ model: z.string().min(1),
15
+ bashApproval: BashApprovalModeSchema,
16
+ title: z.string().nullable(),
17
+ createdAt: z.string(),
18
+ updatedAt: z.string().nullable(),
19
+ costUsd: z.number().optional(),
20
+ });
@@ -0,0 +1,39 @@
1
+ import { type SessionLockHolderState, type SessionStorageDeps } from "./session-lock.ts";
2
+ import { type SessionMeta } from "./session-meta.ts";
3
+ export type { SessionLockHolderState, SessionStorageDeps } from "./session-lock.ts";
4
+ export interface SessionLockHolder {
5
+ readonly pid: number;
6
+ readonly startedAt: string;
7
+ readonly heartbeatAt: string | null;
8
+ readonly state: SessionLockHolderState;
9
+ }
10
+ export { LOCK_HEARTBEAT_MS, LOCK_STALE_MS } from "./session-lock.ts";
11
+ export declare const LIST_PAGE_SIZE = 50;
12
+ export interface SessionListEntry {
13
+ readonly sessionId: string;
14
+ readonly cwd: string;
15
+ readonly title: string | null;
16
+ readonly updatedAt: string;
17
+ }
18
+ export interface SessionListPage {
19
+ sessions: SessionListEntry[];
20
+ nextCursor?: string;
21
+ }
22
+ export interface SessionStorage {
23
+ readonly dir: string;
24
+ readMeta(sessionId: string): Promise<SessionMeta | null>;
25
+ writeMeta(meta: SessionMeta): Promise<void>;
26
+ hasMeta(sessionId: string): Promise<boolean>;
27
+ removeMeta(sessionId: string): Promise<void>;
28
+ /** Resolves to the release function; refuses with SESSION_LOCKED when another live process holds it. */
29
+ acquireLock(sessionId: string): Promise<() => Promise<void>>;
30
+ /** Unguarded, read-only snapshot; only acquireLock decides ownership. A concurrent rename may yield null. */
31
+ readLockHolder(sessionId: string): Promise<SessionLockHolder | null>;
32
+ list(query: {
33
+ readonly cwd?: string | null;
34
+ readonly cursor?: string | null;
35
+ }): Promise<SessionListPage>;
36
+ }
37
+ /** Signal 0 checks existence only. EPERM means the process exists under another user. */
38
+ export declare function processAlive(pid: number): boolean;
39
+ export declare function createSessionStorage(deps: SessionStorageDeps): SessionStorage;
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Session files beside the S3 transcript (S5 spec §5.1): `<id>.session.json`
3
+ * metadata, written as a temp file then renamed, and `<id>.lock`, created
4
+ * exclusively. Nonce-owned locks use heartbeats to detect stale live pids;
5
+ * legacy locks remain pid-based. Listing scans the metadata files.
6
+ * Files that cannot be read are never rewritten or deleted here.
7
+ */
8
+ import { mkdir, mkdtemp, readdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
9
+ import { join } from "node:path";
10
+ import { errorMessage } from "#src/infra/errors";
11
+ import { NaxError } from "#src/infra/nax-error";
12
+ import { acquireSessionLock, holderState, readSessionLock, } from "./session-lock.js";
13
+ import { MetaSchema } from "./session-meta.js";
14
+ export { LOCK_HEARTBEAT_MS, LOCK_STALE_MS } from "./session-lock.js";
15
+ export const LIST_PAGE_SIZE = 50;
16
+ const META_SUFFIX = ".session.json";
17
+ function codeOf(error) {
18
+ return typeof error === "object" && error !== null && "code" in error && typeof error.code === "string"
19
+ ? error.code
20
+ : undefined;
21
+ }
22
+ /** Signal 0 checks existence only. EPERM means the process exists under another user. */
23
+ export function processAlive(pid) {
24
+ try {
25
+ process.kill(pid, 0);
26
+ return true;
27
+ }
28
+ catch (error) {
29
+ return codeOf(error) === "EPERM";
30
+ }
31
+ }
32
+ function unreadable(sessionId, reason) {
33
+ return new NaxError(`session metadata for "${sessionId}" is unreadable: ${reason}`, "SESSION_META_UNREADABLE", {
34
+ stage: "session-store",
35
+ sessionId,
36
+ });
37
+ }
38
+ function encodeCursor(offset) {
39
+ return Buffer.from(String(offset), "utf8").toString("base64");
40
+ }
41
+ function decodeCursor(cursor) {
42
+ if (cursor === undefined || cursor === null || cursor === "")
43
+ return 0;
44
+ const text = Buffer.from(cursor, "base64").toString("utf8");
45
+ const offset = Number(text);
46
+ if (!/^\d+$/.test(text) || !Number.isSafeInteger(offset))
47
+ throw new NaxError("invalid cursor", "SESSION_LIST_CURSOR_INVALID", { stage: "session-store" });
48
+ return offset;
49
+ }
50
+ const sortKey = (meta) => meta.updatedAt ?? meta.createdAt;
51
+ function infoOf(meta) {
52
+ return { sessionId: meta.sessionId, cwd: meta.cwd, title: meta.title, updatedAt: sortKey(meta) };
53
+ }
54
+ export function createSessionStorage(deps) {
55
+ const isAlive = deps.isAlive ?? processAlive;
56
+ const metaPath = (id) => join(deps.dir, `${id}${META_SUFFIX}`);
57
+ const lockPath = (id) => join(deps.dir, `${id}.lock`);
58
+ async function readMeta(sessionId) {
59
+ let text;
60
+ try {
61
+ text = await readFile(metaPath(sessionId), "utf8");
62
+ }
63
+ catch (error) {
64
+ if (codeOf(error) === "ENOENT")
65
+ return null;
66
+ throw unreadable(sessionId, errorMessage(error));
67
+ }
68
+ let json;
69
+ try {
70
+ json = JSON.parse(text);
71
+ }
72
+ catch {
73
+ throw unreadable(sessionId, "invalid JSON");
74
+ }
75
+ const version = typeof json === "object" && json !== null && "schemaVersion" in json ? json.schemaVersion : undefined;
76
+ if (version !== 1)
77
+ throw unreadable(sessionId, `schemaVersion ${String(version)}; this build reads 1`);
78
+ const parsed = MetaSchema.safeParse(json);
79
+ if (!parsed.success)
80
+ throw unreadable(sessionId, parsed.error.issues[0]?.message ?? "bad shape");
81
+ return parsed.data;
82
+ }
83
+ async function acquireLock(sessionId) {
84
+ await mkdir(deps.dir, { recursive: true });
85
+ return acquireSessionLock(lockPath(sessionId), deps, isAlive);
86
+ }
87
+ async function list(query) {
88
+ const offset = decodeCursor(query.cursor);
89
+ let names;
90
+ try {
91
+ names = await readdir(deps.dir);
92
+ }
93
+ catch (error) {
94
+ if (codeOf(error) === "ENOENT")
95
+ return { sessions: [] };
96
+ throw error;
97
+ }
98
+ const metas = [];
99
+ for (const name of names.filter((n) => n.endsWith(META_SUFFIX))) {
100
+ try {
101
+ const meta = await readMeta(name.slice(0, -META_SUFFIX.length));
102
+ if (meta !== null)
103
+ metas.push(meta);
104
+ }
105
+ catch (error) {
106
+ deps.logger.warn("session", "skipping unreadable session metadata", { file: name, error: errorMessage(error) });
107
+ }
108
+ }
109
+ const matching = metas
110
+ .filter((meta) => query.cwd === undefined || query.cwd === null || meta.cwd === query.cwd)
111
+ .sort((a, b) => sortKey(b).localeCompare(sortKey(a)));
112
+ const end = offset + LIST_PAGE_SIZE;
113
+ return {
114
+ sessions: matching.slice(offset, end).map(infoOf),
115
+ ...(end < matching.length ? { nextCursor: encodeCursor(end) } : {}),
116
+ };
117
+ }
118
+ return {
119
+ dir: deps.dir,
120
+ readMeta,
121
+ async writeMeta(meta) {
122
+ await mkdir(deps.dir, { recursive: true });
123
+ const path = metaPath(meta.sessionId);
124
+ // An exclusively created directory owns this write's temp file, even when
125
+ // another storage instance in the same process writes the same session.
126
+ const tempDir = await mkdtemp(`${path}.${deps.pid}.`);
127
+ try {
128
+ const temp = join(tempDir, "metadata.tmp");
129
+ await writeFile(temp, `${JSON.stringify(meta, null, 2)}\n`);
130
+ await rename(temp, path);
131
+ }
132
+ finally {
133
+ await rm(tempDir, { recursive: true, force: true });
134
+ }
135
+ },
136
+ async hasMeta(sessionId) {
137
+ try {
138
+ await stat(metaPath(sessionId));
139
+ return true;
140
+ }
141
+ catch (error) {
142
+ if (codeOf(error) === "ENOENT")
143
+ return false;
144
+ throw error;
145
+ }
146
+ },
147
+ async removeMeta(sessionId) {
148
+ await rm(metaPath(sessionId), { force: true });
149
+ },
150
+ acquireLock,
151
+ async readLockHolder(sessionId) {
152
+ const { body } = readSessionLock(lockPath(sessionId));
153
+ if (body === undefined)
154
+ return null;
155
+ return {
156
+ pid: body.pid,
157
+ startedAt: body.startedAt,
158
+ heartbeatAt: body.heartbeatAt ?? null,
159
+ state: holderState(body, deps, isAlive),
160
+ };
161
+ },
162
+ list,
163
+ };
164
+ }