@agentrhq/webcmd 0.3.1 → 0.3.3

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 (56) hide show
  1. package/README.md +13 -6
  2. package/cli-manifest.json +11 -11
  3. package/clis/producthunt/browse.js +9 -2
  4. package/clis/producthunt/browser-commands.test.js +66 -0
  5. package/clis/producthunt/hot.js +3 -3
  6. package/clis/producthunt/utils.js +27 -0
  7. package/clis/producthunt/utils.test.js +8 -0
  8. package/clis/spotify/spotify.js +11 -11
  9. package/dist/src/browser/daemon-client.d.ts +1 -0
  10. package/dist/src/browser/daemon-client.js +25 -1
  11. package/dist/src/browser/daemon-client.test.js +86 -1
  12. package/dist/src/browser/daemon-transport.d.ts +2 -0
  13. package/dist/src/browser/protocol.d.ts +12 -1
  14. package/dist/src/browser/runtime/local-cloak/actions.d.ts +1 -0
  15. package/dist/src/browser/runtime/local-cloak/actions.js +9 -9
  16. package/dist/src/browser/runtime/local-cloak/provider.d.ts +1 -0
  17. package/dist/src/browser/runtime/local-cloak/provider.js +6 -3
  18. package/dist/src/browser/runtime/local-cloak/provider.test.js +1 -0
  19. package/dist/src/browser/runtime/local-cloak/session-manager.d.ts +5 -0
  20. package/dist/src/browser/runtime/local-cloak/session-manager.js +95 -19
  21. package/dist/src/browser/runtime/local-cloak/session-manager.test.js +235 -0
  22. package/dist/src/browser/runtime/provider.d.ts +1 -0
  23. package/dist/src/cli.js +36 -12
  24. package/dist/src/cli.test.js +5 -0
  25. package/dist/src/daemon/server.js +65 -24
  26. package/dist/src/daemon/server.test.js +211 -2
  27. package/dist/src/docs-sync-review-cli.test.js +1 -1
  28. package/dist/src/errors.d.ts +5 -0
  29. package/dist/src/errors.js +23 -0
  30. package/dist/src/errors.test.js +58 -1
  31. package/dist/src/execution.js +57 -6
  32. package/dist/src/execution.test.js +426 -2
  33. package/dist/src/hosted/availability.test.js +12 -1
  34. package/dist/src/hosted/client.js +3 -1
  35. package/dist/src/hosted/client.test.js +62 -0
  36. package/dist/src/hosted/main-lifecycle.test.js +66 -5
  37. package/dist/src/hosted/types.d.ts +1 -0
  38. package/dist/src/main.js +4 -0
  39. package/dist/src/package-bin-process.d.ts +13 -0
  40. package/dist/src/package-bin-process.js +24 -0
  41. package/dist/src/package-bin-process.test.d.ts +1 -0
  42. package/dist/src/package-bin-process.test.js +22 -0
  43. package/dist/src/session-lease.d.ts +82 -0
  44. package/dist/src/session-lease.js +163 -0
  45. package/dist/src/session-lease.test.d.ts +1 -0
  46. package/dist/src/session-lease.test.js +217 -0
  47. package/dist/src/skills.d.ts +8 -4
  48. package/dist/src/skills.js +30 -1
  49. package/dist/src/skills.test.js +40 -12
  50. package/hosted-contract.json +45 -34
  51. package/package.json +3 -2
  52. package/scripts/check-package-bin.mjs +7 -6
  53. package/scripts/collect-ci-diagnostics.ps1 +274 -0
  54. package/scripts/docs-sync-review.ts +1 -1
  55. package/skills/webcmd-adapter-author/SKILL.md +1 -1
  56. package/skills/webcmd-adapter-author/references/adapter-template.md +2 -6
@@ -103,6 +103,68 @@ describe('HostedClient', () => {
103
103
  });
104
104
  expect(requests).toEqual([{ url: 'https://api.example.com/v1/manifest', authorization: 'Bearer wcmd_live_test' }]);
105
105
  });
106
+ it('accepts boolean freshPage command metadata', async () => {
107
+ const client = new HostedClient({
108
+ apiBaseUrl: 'https://api.example.com',
109
+ apiKey: 'key',
110
+ fetchImpl: async () => new Response(JSON.stringify({
111
+ ok: true,
112
+ manifest: {
113
+ userId: 'user_demo',
114
+ metadata: {
115
+ contractSchemaVersion: 1,
116
+ webcmdPackageVersion: '0.3.0',
117
+ generatedAt: 'now',
118
+ },
119
+ commands: [{
120
+ site: 'district',
121
+ name: 'checkout',
122
+ command: 'district/checkout',
123
+ description: 'Checkout',
124
+ access: 'write',
125
+ strategy: 'UI',
126
+ browser: true,
127
+ args: [],
128
+ columns: [],
129
+ freshPage: true,
130
+ }],
131
+ },
132
+ }), { status: 200 }),
133
+ });
134
+ await expect(client.getManifest()).resolves.toMatchObject({
135
+ commands: [expect.objectContaining({ freshPage: true })],
136
+ });
137
+ });
138
+ it('rejects non-boolean freshPage command metadata', async () => {
139
+ const client = new HostedClient({
140
+ apiBaseUrl: 'https://api.example.com',
141
+ apiKey: 'key',
142
+ fetchImpl: async () => new Response(JSON.stringify({
143
+ ok: true,
144
+ manifest: {
145
+ userId: 'user_demo',
146
+ metadata: {
147
+ contractSchemaVersion: 1,
148
+ webcmdPackageVersion: '0.3.0',
149
+ generatedAt: 'now',
150
+ },
151
+ commands: [{
152
+ site: 'district',
153
+ name: 'checkout',
154
+ command: 'district/checkout',
155
+ description: 'Checkout',
156
+ access: 'write',
157
+ strategy: 'UI',
158
+ browser: true,
159
+ args: [],
160
+ columns: [],
161
+ freshPage: 'yes',
162
+ }],
163
+ },
164
+ }), { status: 200 }),
165
+ });
166
+ await expect(client.getManifest()).rejects.toMatchObject({ code: 'HOSTED_PROTOCOL' });
167
+ });
106
168
  it('parses hosted profile rows without provider identifiers', async () => {
107
169
  const client = new HostedClient({
108
170
  apiBaseUrl: 'https://api.example.com',
@@ -24,6 +24,18 @@ const command = {
24
24
  columns: ['value'],
25
25
  defaultFormat: 'plain',
26
26
  };
27
+ const authCommand = {
28
+ site: 'auth',
29
+ name: 'status',
30
+ command: 'auth/status',
31
+ description: 'Show hosted login status',
32
+ access: 'read',
33
+ strategy: 'PUBLIC',
34
+ browser: false,
35
+ args: [],
36
+ columns: ['value'],
37
+ defaultFormat: 'plain',
38
+ };
27
39
  afterEach(async () => {
28
40
  await Promise.all(servers.splice(0).map(server => new Promise((resolve, reject) => {
29
41
  server.close(error => error ? reject(error) : resolve());
@@ -77,6 +89,44 @@ describe('hosted CLI process lifecycle', () => {
77
89
  expect(result.stdout).toContain('Local-only commands:');
78
90
  await expect(readFile(fixture.discoverySentinel, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' });
79
91
  }, 20_000);
92
+ it('runs skills add locally without contacting Cloud when hosted mode is configured', async () => {
93
+ const fixture = await createHostedFixture('success');
94
+ const installDir = path.join(fixture.root, 'agent-skills');
95
+ const result = await runCli(['skills', 'add', '--path', installDir, '--json'], fixture.env);
96
+ expect(result.status).toBe(0);
97
+ expect(result.stderr).toBe('');
98
+ const body = JSON.parse(result.stdout);
99
+ expect(body.skills.map(skill => skill.name)).toEqual([
100
+ 'smart-search',
101
+ 'webcmd-adapter-author',
102
+ 'webcmd-autofix',
103
+ 'webcmd-browser',
104
+ 'webcmd-browser-sitemap',
105
+ 'webcmd-sitemap-author',
106
+ 'webcmd-usage',
107
+ ]);
108
+ expect(body.skills.every(skill => skill.destination?.startsWith(installDir))).toBe(true);
109
+ await expect(readFile(path.join(installDir, 'webcmd-usage', 'SKILL.md'), 'utf8'))
110
+ .resolves.toContain('webcmd-usage');
111
+ await expect(readFile(fixture.discoverySentinel, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' });
112
+ expect(fixture.requests).toEqual([]);
113
+ }, 20_000);
114
+ it('keeps hosted auth on Cloud without local discovery', async () => {
115
+ const fixture = await createHostedFixture('success');
116
+ const result = await runCli(['auth', 'status', '-f', 'plain'], fixture.env);
117
+ expect(result.stderr).toBe('');
118
+ expect(result.status).toBe(0);
119
+ expect(fixture.requests).toEqual(['GET /v1/manifest', 'POST /v1/execute']);
120
+ await expect(readFile(fixture.discoverySentinel, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' });
121
+ }, 20_000);
122
+ it('rejects daemon commands in hosted mode without local discovery', async () => {
123
+ const fixture = await createHostedFixture('success');
124
+ const result = await runCli(['daemon', 'status'], fixture.env);
125
+ expect(result.status).toBe(78);
126
+ expect(result.stderr).toContain('Hosted mode has no local daemon.');
127
+ expect(fixture.requests).toEqual([]);
128
+ await expect(readFile(fixture.discoverySentinel, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' });
129
+ }, 20_000);
80
130
  });
81
131
  async function createHostedFixture(outcome) {
82
132
  const root = await mkdtemp(path.join(tmpdir(), 'webcmd-hosted-lifecycle-'));
@@ -84,6 +134,7 @@ async function createHostedFixture(outcome) {
84
134
  const configDir = path.join(root, 'config');
85
135
  const userClis = path.join(root, '.webcmd', 'clis', 'lifecycle-sentinel');
86
136
  const discoverySentinel = path.join(root, 'local-discovery-ran');
137
+ const requests = [];
87
138
  await mkdir(configDir, { recursive: true });
88
139
  await mkdir(userClis, { recursive: true });
89
140
  await writeFile(path.join(userClis, 'sentinel.js'), [
@@ -92,7 +143,8 @@ async function createHostedFixture(outcome) {
92
143
  "export const sentinel = 'cli(';",
93
144
  '',
94
145
  ].join('\n'));
95
- const server = createServer((request, response) => {
146
+ const server = createServer(async (request, response) => {
147
+ requests.push(`${request.method ?? 'GET'} ${request.url ?? '/'}`);
96
148
  if (request.url === '/v1/manifest') {
97
149
  sendChunkedJson(response, {
98
150
  ok: true,
@@ -103,12 +155,16 @@ async function createHostedFixture(outcome) {
103
155
  webcmdPackageVersion: '0.3.0',
104
156
  generatedAt: '2026-07-14T00:00:00.000Z',
105
157
  },
106
- commands: [command],
158
+ commands: [command, authCommand],
107
159
  },
108
160
  });
109
161
  return;
110
162
  }
111
163
  if (request.url === '/v1/execute' && request.method === 'POST') {
164
+ let requestBody = '';
165
+ for await (const chunk of request)
166
+ requestBody += chunk;
167
+ const invocation = JSON.parse(requestBody);
112
168
  if (outcome === 'failure') {
113
169
  sendChunkedJson(response, {
114
170
  ok: false,
@@ -118,7 +174,7 @@ async function createHostedFixture(outcome) {
118
174
  help: 'Retry the lifecycle fixture.',
119
175
  exitCode: 69,
120
176
  },
121
- execution: { id: 'exec_lifecycle', command: command.command, status: 'failed' },
177
+ execution: { id: 'exec_lifecycle', command: invocation.command, status: 'failed' },
122
178
  }, 422);
123
179
  return;
124
180
  }
@@ -126,8 +182,10 @@ async function createHostedFixture(outcome) {
126
182
  ok: true,
127
183
  result: { value: largeOutput },
128
184
  columns: ['value'],
129
- execution: { id: 'exec_lifecycle', command: command.command, status: 'succeeded' },
130
- trace: { executionId: 'exec_lifecycle', receipt: traceReceipt },
185
+ execution: { id: 'exec_lifecycle', command: invocation.command, status: 'succeeded' },
186
+ ...(invocation.trace === 'on'
187
+ ? { trace: { executionId: 'exec_lifecycle', receipt: traceReceipt } }
188
+ : {}),
131
189
  });
132
190
  return;
133
191
  }
@@ -156,10 +214,13 @@ async function createHostedFixture(outcome) {
156
214
  updatedAt: '2026-07-14T00:00:00.000Z',
157
215
  })}\n`, { mode: 0o600 });
158
216
  return {
217
+ root,
159
218
  discoverySentinel,
219
+ requests,
160
220
  env: {
161
221
  ...process.env,
162
222
  HOME: root,
223
+ USERPROFILE: root,
163
224
  WEBCMD_CONFIG_DIR: configDir,
164
225
  WEBCMD_NO_UPDATE_CHECK: '1',
165
226
  },
@@ -28,6 +28,7 @@ export interface HostedCommand extends CommandSurfaceMetadata {
28
28
  columns: string[];
29
29
  domain?: string | null;
30
30
  defaultFormat?: string | null;
31
+ freshPage?: boolean;
31
32
  }
32
33
  export interface HostedManifest {
33
34
  userId: string;
package/dist/src/main.js CHANGED
@@ -65,6 +65,10 @@ if (!fastPathHandled) {
65
65
  const { runHostedSetup } = await import('./hosted/setup.js');
66
66
  process.exitCode = await runHostedSetup();
67
67
  }
68
+ else if (argv[0] === 'skills') {
69
+ const { createProgram } = await import('./cli.js');
70
+ await createProgram(BUILTIN_CLIS, USER_CLIS).parseAsync(argv, { from: 'user' });
71
+ }
68
72
  else {
69
73
  const { shouldUseHostedMode } = await import('./hosted/config.js');
70
74
  if (shouldUseHostedMode()) {
@@ -0,0 +1,13 @@
1
+ type SpawnOutput = string | Buffer | null | undefined;
2
+ export interface PackageBinSpawnFailure {
3
+ error?: Error;
4
+ signal?: NodeJS.Signals | null;
5
+ status: number | null;
6
+ stdout?: SpawnOutput;
7
+ stderr?: SpawnOutput;
8
+ }
9
+ export declare function packageBinSpawnOptions(platform: NodeJS.Platform, command: string): {
10
+ shell?: true;
11
+ };
12
+ export declare function formatPackageBinSpawnFailure(command: string, args: string[], result: PackageBinSpawnFailure): string;
13
+ export {};
@@ -0,0 +1,24 @@
1
+ import path from 'node:path';
2
+ export function packageBinSpawnOptions(platform, command) {
3
+ if (platform !== 'win32')
4
+ return {};
5
+ const basename = path.win32.basename(command).toLowerCase();
6
+ return basename === 'npm' || basename.endsWith('.cmd') ? { shell: true } : {};
7
+ }
8
+ function outputText(output) {
9
+ return output == null ? '' : output.toString().trim();
10
+ }
11
+ export function formatPackageBinSpawnFailure(command, args, result) {
12
+ const invocation = [command, ...args].join(' ');
13
+ if (result.error) {
14
+ return `${invocation} failed to start: ${result.error.message}`;
15
+ }
16
+ const outcome = result.status === null
17
+ ? `terminated by ${result.signal ?? 'an unknown signal'}`
18
+ : `exited ${result.status}`;
19
+ return [
20
+ `${invocation} ${outcome}`,
21
+ outputText(result.stdout),
22
+ outputText(result.stderr),
23
+ ].filter(Boolean).join('\n');
24
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,22 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { formatPackageBinSpawnFailure, packageBinSpawnOptions, } from './package-bin-process.js';
3
+ describe('package bin process options', () => {
4
+ it('uses a shell for npm and cmd shims on Windows', () => {
5
+ expect(packageBinSpawnOptions('win32', 'npm')).toEqual({ shell: true });
6
+ expect(packageBinSpawnOptions('win32', 'C:\\prefix\\webcmd.cmd')).toEqual({ shell: true });
7
+ });
8
+ it('keeps native executables shell-free', () => {
9
+ expect(packageBinSpawnOptions('linux', 'npm')).toEqual({});
10
+ expect(packageBinSpawnOptions('darwin', '/tmp/webcmd')).toEqual({});
11
+ expect(packageBinSpawnOptions('win32', 'node.exe')).toEqual({});
12
+ });
13
+ it('reports launch errors without assuming output streams exist', () => {
14
+ const error = Object.assign(new Error('spawnSync npm EINVAL'), { code: 'EINVAL' });
15
+ expect(formatPackageBinSpawnFailure('npm', ['pack'], {
16
+ error,
17
+ status: null,
18
+ stdout: undefined,
19
+ stderr: undefined,
20
+ })).toBe('npm pack failed to start: spawnSync npm EINVAL');
21
+ });
22
+ });
@@ -0,0 +1,82 @@
1
+ /** Inactivity window after which an unowned session lease expires. */
2
+ export declare const SESSION_LEASE_TTL_MS = 45000;
3
+ /** Generate an id for one complete logical CLI command run. */
4
+ export declare function generateRunId(): string;
5
+ export interface DaemonRunContext {
6
+ runId: string;
7
+ command: string;
8
+ access: 'read' | 'write';
9
+ }
10
+ /** Run one logical execution with context isolated across its async chain. */
11
+ export declare function runWithDaemonRunContext<T>(context: DaemonRunContext, callback: () => T): T;
12
+ export declare function setDaemonRunContext(context: DaemonRunContext): void;
13
+ export declare function getDaemonRunContext(): DaemonRunContext | undefined;
14
+ /**
15
+ * Clear only the context still owned by `runId`. Deferred cleanup from an old
16
+ * command must not clear a newer command's run identity.
17
+ */
18
+ export declare function clearDaemonRunContext(runId: string): void;
19
+ /**
20
+ * Detect errors whose browser-side result is unknown. The traversal includes
21
+ * wrapper causes and AggregateError members and tolerates cyclic error graphs.
22
+ */
23
+ export declare function isUnknownOutcomeError(error: unknown): boolean;
24
+ export interface SessionLeaseHolder {
25
+ command: string;
26
+ pid?: number;
27
+ acquiredAt: number;
28
+ heartbeatAt: number;
29
+ }
30
+ export interface SessionLease extends SessionLeaseHolder {
31
+ key: string;
32
+ runId: string;
33
+ }
34
+ /** Public status shape: the internal ownership token is intentionally omitted. */
35
+ export type SessionLeaseStatus = Omit<SessionLease, 'runId'>;
36
+ export interface AcquireSessionLeaseInput {
37
+ key: string;
38
+ runId: string;
39
+ command: string;
40
+ pid?: number;
41
+ }
42
+ export type AcquireResult = {
43
+ acquired: true;
44
+ lease: SessionLease;
45
+ } | {
46
+ acquired: false;
47
+ holder: SessionLease;
48
+ };
49
+ /**
50
+ * Lease key for a site session after the daemon has resolved its actual Cloak
51
+ * profile. Encoding the session keeps key partitions unambiguous.
52
+ */
53
+ export declare function getSessionLeaseKey(profileId: string, surface: string, session: string): string;
54
+ /** Whether a process id is safe to interpolate into local process guidance. */
55
+ export declare function isActionablePid(pid: unknown): pid is number;
56
+ export interface SessionLeaseCommand {
57
+ surface?: unknown;
58
+ siteSession?: unknown;
59
+ access?: unknown;
60
+ session?: unknown;
61
+ runId?: unknown;
62
+ }
63
+ /** Only persistent adapter writes with a complete owner identity need a lease. */
64
+ export declare function isSessionLeaseCommand<T extends SessionLeaseCommand>(command: T): command is T & {
65
+ surface: 'adapter';
66
+ siteSession: 'persistent';
67
+ access: 'write';
68
+ session: string;
69
+ runId: string;
70
+ };
71
+ export declare class SessionLeaseRegistry {
72
+ private readonly now;
73
+ private readonly leases;
74
+ constructor(now?: () => number);
75
+ acquire(input: AcquireSessionLeaseInput, hasPendingWork: (runId: string) => boolean): AcquireResult;
76
+ /** Refresh a lease only when both its key and logical owner match. */
77
+ heartbeat(key: string, runId: string): boolean;
78
+ /** Release all leases owned by one logical run. */
79
+ releaseByRunId(runId: string): number;
80
+ /** Return a snapshot of currently live leases without exposing mutable state. */
81
+ list(hasPendingWork: (runId: string) => boolean): SessionLease[];
82
+ }
@@ -0,0 +1,163 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+ /** Inactivity window after which an unowned session lease expires. */
3
+ export const SESSION_LEASE_TTL_MS = 45_000;
4
+ let runIdCounter = 0;
5
+ /** Generate an id for one complete logical CLI command run. */
6
+ export function generateRunId() {
7
+ return `run_${process.pid}_${Date.now()}_${++runIdCounter}`;
8
+ }
9
+ let activeRun;
10
+ const daemonRunContextStorage = new AsyncLocalStorage();
11
+ /** Run one logical execution with context isolated across its async chain. */
12
+ export function runWithDaemonRunContext(context, callback) {
13
+ return daemonRunContextStorage.run(context, callback);
14
+ }
15
+ export function setDaemonRunContext(context) {
16
+ activeRun = context;
17
+ }
18
+ export function getDaemonRunContext() {
19
+ return daemonRunContextStorage.getStore() ?? activeRun;
20
+ }
21
+ /**
22
+ * Clear only the context still owned by `runId`. Deferred cleanup from an old
23
+ * command must not clear a newer command's run identity.
24
+ */
25
+ export function clearDaemonRunContext(runId) {
26
+ if (activeRun?.runId === runId)
27
+ activeRun = undefined;
28
+ }
29
+ const UNKNOWN_OUTCOME_CODES = [
30
+ 'command_result_unknown',
31
+ 'command_lost',
32
+ 'result_evicted',
33
+ ];
34
+ function containsUnknownOutcomeCode(value) {
35
+ if (typeof value !== 'string')
36
+ return false;
37
+ const normalized = value.toLowerCase();
38
+ return UNKNOWN_OUTCOME_CODES.some((code) => {
39
+ const start = normalized.indexOf(code);
40
+ if (start < 0)
41
+ return false;
42
+ const before = normalized[start - 1];
43
+ const after = normalized[start + code.length];
44
+ return (!before || !/[a-z0-9_]/.test(before)) && (!after || !/[a-z0-9_]/.test(after));
45
+ });
46
+ }
47
+ /**
48
+ * Detect errors whose browser-side result is unknown. The traversal includes
49
+ * wrapper causes and AggregateError members and tolerates cyclic error graphs.
50
+ */
51
+ export function isUnknownOutcomeError(error) {
52
+ const queue = [error];
53
+ const seen = new Set();
54
+ while (queue.length > 0) {
55
+ const current = queue.pop();
56
+ if (!current || (typeof current !== 'object' && typeof current !== 'function') || seen.has(current)) {
57
+ continue;
58
+ }
59
+ seen.add(current);
60
+ const record = current;
61
+ if (containsUnknownOutcomeCode(record.code)
62
+ || containsUnknownOutcomeCode(record.errorCode)
63
+ || containsUnknownOutcomeCode(record.daemonCode)
64
+ || containsUnknownOutcomeCode(record.message)
65
+ || containsUnknownOutcomeCode(record.error)) {
66
+ return true;
67
+ }
68
+ if (record.cause !== undefined)
69
+ queue.push(record.cause);
70
+ if (Array.isArray(record.errors))
71
+ queue.push(...record.errors);
72
+ }
73
+ return false;
74
+ }
75
+ /**
76
+ * Lease key for a site session after the daemon has resolved its actual Cloak
77
+ * profile. Encoding the session keeps key partitions unambiguous.
78
+ */
79
+ export function getSessionLeaseKey(profileId, surface, session) {
80
+ return `${profileId}␟${surface}␟${encodeURIComponent(session)}`;
81
+ }
82
+ /** Whether a process id is safe to interpolate into local process guidance. */
83
+ export function isActionablePid(pid) {
84
+ return typeof pid === 'number' && Number.isSafeInteger(pid) && pid > 0;
85
+ }
86
+ function pidFromRunId(runId) {
87
+ const match = /^run_(\d+)_/.exec(runId);
88
+ if (!match)
89
+ return undefined;
90
+ const pid = Number(match[1]);
91
+ return isActionablePid(pid) ? pid : undefined;
92
+ }
93
+ /** Only persistent adapter writes with a complete owner identity need a lease. */
94
+ export function isSessionLeaseCommand(command) {
95
+ return command.surface === 'adapter'
96
+ && command.siteSession === 'persistent'
97
+ && command.access === 'write'
98
+ && typeof command.session === 'string'
99
+ && command.session.length > 0
100
+ && typeof command.runId === 'string'
101
+ && command.runId.length > 0;
102
+ }
103
+ export class SessionLeaseRegistry {
104
+ now;
105
+ leases = new Map();
106
+ constructor(now = Date.now) {
107
+ this.now = now;
108
+ }
109
+ acquire(input, hasPendingWork) {
110
+ const now = this.now();
111
+ const current = this.leases.get(input.key);
112
+ const currentIsLive = current !== undefined
113
+ && (now - current.heartbeatAt <= SESSION_LEASE_TTL_MS || hasPendingWork(current.runId));
114
+ if (current && currentIsLive && current.runId !== input.runId) {
115
+ return { acquired: false, holder: { ...current } };
116
+ }
117
+ const pid = input.pid === undefined
118
+ ? pidFromRunId(input.runId)
119
+ : (isActionablePid(input.pid) ? input.pid : undefined);
120
+ const lease = current?.runId === input.runId
121
+ ? { ...current, heartbeatAt: now }
122
+ : {
123
+ key: input.key,
124
+ runId: input.runId,
125
+ command: input.command,
126
+ ...(pid === undefined ? {} : { pid }),
127
+ acquiredAt: now,
128
+ heartbeatAt: now,
129
+ };
130
+ this.leases.set(input.key, lease);
131
+ return { acquired: true, lease: { ...lease } };
132
+ }
133
+ /** Refresh a lease only when both its key and logical owner match. */
134
+ heartbeat(key, runId) {
135
+ const current = this.leases.get(key);
136
+ if (!current || current.runId !== runId)
137
+ return false;
138
+ current.heartbeatAt = this.now();
139
+ return true;
140
+ }
141
+ /** Release all leases owned by one logical run. */
142
+ releaseByRunId(runId) {
143
+ let released = 0;
144
+ for (const [key, lease] of this.leases) {
145
+ if (lease.runId !== runId)
146
+ continue;
147
+ this.leases.delete(key);
148
+ released += 1;
149
+ }
150
+ return released;
151
+ }
152
+ /** Return a snapshot of currently live leases without exposing mutable state. */
153
+ list(hasPendingWork) {
154
+ const now = this.now();
155
+ const active = [];
156
+ for (const lease of this.leases.values()) {
157
+ if (now - lease.heartbeatAt <= SESSION_LEASE_TTL_MS || hasPendingWork(lease.runId)) {
158
+ active.push({ ...lease });
159
+ }
160
+ }
161
+ return active;
162
+ }
163
+ }
@@ -0,0 +1 @@
1
+ export {};