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

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 (79) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/dist/coding-tools/coding-tool-extras.d.ts +52 -0
  3. package/dist/coding-tools/coding-tool-extras.js +56 -0
  4. package/dist/coding-tools/coding-tool-support.d.ts +9 -0
  5. package/dist/coding-tools/coding-tool-support.js +12 -1
  6. package/dist/config/catalog-overrides.d.ts +9 -0
  7. package/dist/index.d.ts +4 -2
  8. package/dist/index.js +2 -1
  9. package/dist/internal/argv-exec.d.ts +6 -0
  10. package/dist/internal/argv-exec.js +158 -63
  11. package/dist/mcp/connect.js +26 -3
  12. package/dist/mcp/types.d.ts +7 -0
  13. package/dist/native/adapter-deps.d.ts +2 -0
  14. package/dist/native/adapter-deps.js +2 -0
  15. package/dist/native/models.js +2 -0
  16. package/dist/native/session/loop-events/types.d.ts +5 -0
  17. package/dist/native/session-adapter.js +19 -5
  18. package/dist/permissions/approval-memory.d.ts +18 -0
  19. package/dist/permissions/approval-memory.js +88 -0
  20. package/dist/permissions/index.d.ts +1 -0
  21. package/dist/permissions/index.js +1 -0
  22. package/dist/permissions/types.d.ts +6 -0
  23. package/dist/runtime/node-runtime.js +5 -0
  24. package/dist/sandbox/claude-settings.d.ts +2 -1
  25. package/dist/sandbox/claude-settings.js +7 -3
  26. package/dist/sandbox/defaults.js +1 -0
  27. package/dist/sandbox/launcher.js +7 -0
  28. package/dist/sandbox/types.d.ts +6 -0
  29. package/dist/session/agent-session-command.d.ts +19 -0
  30. package/dist/session/agent-session-command.js +38 -0
  31. package/dist/session/agent-session-types.d.ts +26 -0
  32. package/dist/session/agent-session.js +86 -1
  33. package/dist/session/native-backend-options.d.ts +37 -2
  34. package/dist/session/native-backend-options.js +39 -3
  35. package/dist/session/native-backend.js +55 -5
  36. package/dist/session/native-posture-rules.d.ts +18 -0
  37. package/dist/session/native-posture-rules.js +33 -0
  38. package/dist/session/session-ask-link.d.ts +6 -0
  39. package/dist/session/session-ask-link.js +5 -0
  40. package/dist/session/session-ask-port.js +4 -0
  41. package/dist/session/session-backend.d.ts +23 -1
  42. package/dist/session/session-interaction.d.ts +2 -0
  43. package/dist/session/session-interaction.js +8 -1
  44. package/dist/session/session-tool-support.d.ts +49 -3
  45. package/dist/session/session-tool-support.js +66 -11
  46. package/dist/session-store/index.d.ts +3 -0
  47. package/dist/session-store/index.js +3 -0
  48. package/dist/session-store/session-lock-guard.d.ts +6 -0
  49. package/dist/session-store/session-lock-guard.js +85 -0
  50. package/dist/session-store/session-lock.d.ts +39 -0
  51. package/dist/session-store/session-lock.js +178 -0
  52. package/dist/session-store/session-meta.d.ts +27 -0
  53. package/dist/session-store/session-meta.js +20 -0
  54. package/dist/session-store/storage.d.ts +39 -0
  55. package/dist/session-store/storage.js +164 -0
  56. package/dist/tools/ask-request.d.ts +18 -0
  57. package/dist/tools/ask-request.js +30 -0
  58. package/dist/tools/bash-background.d.ts +74 -0
  59. package/dist/tools/bash-background.js +151 -0
  60. package/dist/tools/bash.d.ts +11 -0
  61. package/dist/tools/bash.js +77 -17
  62. package/dist/tools/git.js +10 -3
  63. package/dist/tools/policy-match.d.ts +2 -2
  64. package/dist/tools/policy-match.js +9 -6
  65. package/dist/tools/policy-paths-branch.js +4 -1
  66. package/dist/tools/provider-advertise.d.ts +7 -0
  67. package/dist/tools/provider-advertise.js +13 -1
  68. package/dist/tools/provider-types.d.ts +6 -0
  69. package/dist/tools/run-command-exec.js +12 -0
  70. package/dist/tools/run-command-surface.d.ts +28 -0
  71. package/dist/tools/run-command-surface.js +109 -0
  72. package/dist/tools/run-command.d.ts +17 -16
  73. package/dist/tools/run-command.js +48 -68
  74. package/dist/tools/runtime-calltool.d.ts +101 -0
  75. package/dist/tools/runtime-calltool.js +112 -3
  76. package/dist/tools/runtime.d.ts +12 -0
  77. package/dist/tools/runtime.js +36 -18
  78. package/dist/tools/types.d.ts +8 -0
  79. 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 { backgroundToolOptions, 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,28 +61,71 @@ 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
- config: DEFAULT_SANDBOX_CONFIG,
76
+ config: args.config ?? DEFAULT_SANDBOX_CONFIG,
68
77
  root: args.root,
69
78
  needsLauncher: true,
70
79
  protectedPaths: args.protectedPaths,
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));
103
+ }
104
+ /** Optional overlays share one forwarding point for future posture knobs. */
105
+ function postureToolOptions(args) {
106
+ return {
107
+ ...(args.denyRules !== undefined ? { denyRules: args.denyRules } : {}),
108
+ ...(args.askOnUnsandboxed !== undefined ? { askOnUnsandboxed: args.askOnUnsandboxed } : {}),
109
+ ...(args.commandShadow !== undefined ? { commandShadow: args.commandShadow } : {}),
110
+ };
79
111
  }
80
112
  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);
113
+ const profileTools = narrowBuiltIns(declaredToolsFor(args.profile, args.protectedPaths), args.allowedTools);
114
+ // CodingToolName enumerates built-ins, but the runtime also accepts provider names.
115
+ const declared = [
116
+ ...new Set([...profileTools, ...(args.providerTools?.tools.map((tool) => tool.name) ?? [])]),
117
+ ];
118
+ const grants = [...grantsFor(profileTools), ...(args.providerTools?.grants ?? [])];
119
+ // Provider asks remain opt-in outside "ask"; configured rules follow them.
120
+ const askRules = [
121
+ ...askRulesFor(profileTools, args.bashApproval, args.profile),
122
+ ...(args.profile === "ask" || args.askDiscoveredTools ? (args.providerTools?.askTools ?? []) : []).map((tool) => ({
123
+ tool: tool,
124
+ patterns: ["*"],
125
+ })),
126
+ ...(args.askRules ?? []),
127
+ ];
128
+ const providerInstructions = renderProviderInstructions(args.providerTools?.instructions ?? []);
84
129
  const support = buildCodingToolSupport({
85
130
  root: args.root,
86
131
  commandCwd: args.root,
@@ -88,6 +133,13 @@ export function buildSessionToolSupport(args) {
88
133
  sessionName: args.sessionName,
89
134
  declared,
90
135
  grants,
136
+ ...(args.providerTools !== undefined
137
+ ? {
138
+ extraTools: args.providerTools.tools,
139
+ providerIdByTool: args.providerTools.providerIdByTool,
140
+ }
141
+ : {}),
142
+ ...(providerInstructions !== undefined ? { providerInstructions } : {}),
91
143
  bashApproval: args.bashApproval,
92
144
  protectedPaths: args.protectedPaths,
93
145
  ownedPaths: EMPTY_OWNED_PATHS_POLICY,
@@ -95,6 +147,9 @@ export function buildSessionToolSupport(args) {
95
147
  ...(askRules.length > 0 ? { askRules } : {}),
96
148
  ...(args.interceptor !== undefined ? { interceptor: args.interceptor } : {}),
97
149
  ...(args.launcher !== undefined ? { launcher: args.launcher } : {}),
150
+ ...unsandboxedToolOptions(args),
151
+ ...backgroundToolOptions(args),
152
+ ...postureToolOptions(args),
98
153
  });
99
154
  // Unreachable while the scratchpad trio is always declared and granted; kept for the type.
100
155
  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,178 @@
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
+ // Bun returns a number and Node a Timeout; only the latter can be unref'd, and only it
165
+ // needs to be (a number handle never holds the loop open on its own).
166
+ if (typeof timer !== "number")
167
+ timer.unref();
168
+ return async () => {
169
+ try {
170
+ // The heartbeat stays live while release waits out contention.
171
+ await releaseOwnedLock(path, body.nonce);
172
+ }
173
+ finally {
174
+ // A terminal release failure must let the lock age into stale recovery.
175
+ clearInterval(timer);
176
+ }
177
+ };
178
+ }
@@ -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;