@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
package/dist/src/cli.js CHANGED
@@ -18,7 +18,7 @@ import { render as renderOutput } from './output.js';
18
18
  import { PKG_VERSION } from './version.js';
19
19
  import { printCompletionScript } from './completion.js';
20
20
  import { loadExternalClis, executeExternalCli, installExternalCli, registerExternalCli, isBinaryInstalled, formatExternalCliLabel } from './external.js';
21
- import { installWebcmdSkill, listWebcmdSkills, updateWebcmdSkill } from './skills.js';
21
+ import { addWebcmdSkills, listWebcmdSkills, removeWebcmdSkills, updateWebcmdSkill } from './skills.js';
22
22
  import { registerAllCommands } from './commanderAdapter.js';
23
23
  import { buildRootHelpPresentation, classifyAdapter, installCommanderNamespaceStructuredHelp, installRootPresentationHelp, leadingPositionalFromUsage, rootHelpData } from './help.js';
24
24
  import { EXIT_CODES, getErrorMessage, BrowserConnectError, CliError, ArgumentError } from './errors.js';
@@ -62,15 +62,15 @@ function parseDurationMs(raw, flagName) {
62
62
  function timestampFromRaw(value) {
63
63
  return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : Date.now();
64
64
  }
65
- function isInteractiveInstall(opts) {
65
+ function isInteractiveSkillAdd(opts) {
66
66
  return !opts.json && process.stdin.isTTY === true && process.stdout.isTTY === true;
67
67
  }
68
- async function resolveSkillInstallOptions(opts) {
69
- if (!isInteractiveInstall(opts))
68
+ async function resolveSkillAddOptions(opts) {
69
+ if (!isInteractiveSkillAdd(opts))
70
70
  return opts;
71
71
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
72
72
  try {
73
- const scope = opts.scope ?? await choosePrompt(rl, 'Where should Webcmd install skills?', [
73
+ const scope = opts.scope ?? await choosePrompt(rl, 'Where should Webcmd add skills?', [
74
74
  { key: '1', label: 'Global', value: 'user', aliases: ['global', 'user', 'g'] },
75
75
  { key: '2', label: 'Local project', value: 'project', aliases: ['local', 'project', 'l'] },
76
76
  ], '1');
@@ -126,6 +126,24 @@ async function handleSkillLinkCommand(action, json, verb) {
126
126
  process.exitCode = err instanceof CliError ? err.exitCode : EXIT_CODES.GENERIC_ERROR;
127
127
  }
128
128
  }
129
+ function handleSkillRemoveCommand(customPath, json) {
130
+ try {
131
+ const result = removeWebcmdSkills({ customPath });
132
+ if (json) {
133
+ console.log(JSON.stringify(result, null, 2));
134
+ return;
135
+ }
136
+ console.log(`Webcmd skill links removed: ${result.removed.length}`);
137
+ for (const linkPath of result.removed)
138
+ console.log(linkPath);
139
+ }
140
+ catch (err) {
141
+ console.error(`Error: ${getErrorMessage(err)}`);
142
+ if (err instanceof CliError && err.hint)
143
+ console.error(`Hint: ${err.hint}`);
144
+ process.exitCode = err instanceof CliError ? err.exitCode : EXIT_CODES.GENERIC_ERROR;
145
+ }
146
+ }
129
147
  function toIsoTimestamp(timestamp) {
130
148
  if (typeof timestamp !== 'number' || !Number.isFinite(timestamp) || timestamp <= 0)
131
149
  return undefined;
@@ -706,7 +724,7 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
706
724
  });
707
725
  const skillsCmd = program
708
726
  .command('skills')
709
- .description('List, install, and update bundled Webcmd skills')
727
+ .description('List, add, update, and remove bundled Webcmd skills')
710
728
  .action(() => {
711
729
  const rows = listWebcmdSkills();
712
730
  renderOutput(rows, {
@@ -732,24 +750,24 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
732
750
  });
733
751
  });
734
752
  skillsCmd
735
- .command('install')
736
- .description('Install bundled Webcmd skills into an agent skills folder')
753
+ .command('add')
754
+ .description('Add bundled Webcmd skills to an agent skills folder')
737
755
  .option('-p, --provider <provider>', 'Agent provider: agents, codex, claude')
738
- .option('-s, --scope <scope>', 'Install scope: user/global or project/local')
756
+ .option('-s, --scope <scope>', 'Add scope: user/global or project/local')
739
757
  .option('--path <path>', 'Custom agent skills directory')
740
758
  .option('--json', 'Output a JSON envelope', false)
741
759
  .action(async (opts) => {
742
760
  await handleSkillLinkCommand(async () => {
743
- const resolved = await resolveSkillInstallOptions(opts);
761
+ const resolved = await resolveSkillAddOptions(opts);
744
762
  if (resolved.provider === 'custom' && !resolved.path) {
745
763
  throw new ArgumentError('Custom skill provider requires --path.', 'Pass --path <skills-dir> or run interactively.');
746
764
  }
747
- return installWebcmdSkill({
765
+ return addWebcmdSkills({
748
766
  provider: resolved.provider,
749
767
  scope: resolved.scope,
750
768
  customPath: resolved.path,
751
769
  });
752
- }, opts.json, 'installed');
770
+ }, opts.json, 'added');
753
771
  });
754
772
  skillsCmd
755
773
  .command('update')
@@ -765,6 +783,12 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
765
783
  customPath: opts.path,
766
784
  }), opts.json, 'updated');
767
785
  });
786
+ skillsCmd
787
+ .command('remove')
788
+ .description('Remove bundled Webcmd skill symlinks from supported locations')
789
+ .option('--path <path>', 'Also remove links from a custom agent skills directory')
790
+ .option('--json', 'Output a JSON envelope', false)
791
+ .action((opts) => handleSkillRemoveCommand(opts.path, opts.json));
768
792
  const authCmd = registerAuthCommands(program);
769
793
  program
770
794
  .command('convention-audit')
@@ -60,6 +60,11 @@ describe('createProgram root help descriptions', () => {
60
60
  expect(descriptionFor(program, 'daemon')).toBe('restart, status, stop');
61
61
  expect(descriptionFor(program, 'external')).toBe('install, list, register');
62
62
  });
63
+ it('exposes add and remove without an install command', () => {
64
+ const skills = createProgram('', '').commands.find((command) => command.name() === 'skills');
65
+ expect(skills.commands.map((command) => command.name())).toEqual(['list', 'add', 'update', 'remove']);
66
+ expect(skills.commands.find((command) => command.name() === 'add')?.aliases()).toEqual([]);
67
+ });
63
68
  it('renders auth namespace structured help', () => {
64
69
  const argv = process.argv;
65
70
  try {
@@ -1,8 +1,37 @@
1
1
  import { createServer } from 'node:http';
2
2
  import { DAEMON_HEADER_NAME, DEFAULT_DAEMON_PORT } from '../constants.js';
3
3
  import { buildCommandTimeoutFailure, getResponseCorsHeaders } from '../daemon-utils.js';
4
+ import { getSessionLeaseKey, isSessionLeaseCommand, SessionLeaseRegistry } from '../session-lease.js';
4
5
  const MAX_BODY = 1024 * 1024;
5
6
  const LOG_BUFFER_SIZE = 200;
7
+ function commandTimeoutMs(command) {
8
+ return typeof command.deadlineAt === 'number' && command.deadlineAt > 0
9
+ ? Math.max(1000, command.deadlineAt - Date.now())
10
+ : (typeof command.timeout === 'number' && command.timeout > 0 ? command.timeout * 1000 : 120_000);
11
+ }
12
+ function waitForCommandResult(command, providerPromise) {
13
+ const timeoutMs = commandTimeoutMs(command);
14
+ let timeoutId;
15
+ const responsePromise = Promise.race([
16
+ providerPromise,
17
+ new Promise((resolve) => {
18
+ timeoutId = setTimeout(() => {
19
+ const failure = buildCommandTimeoutFailure(command.action, timeoutMs);
20
+ resolve({
21
+ id: command.id,
22
+ ok: false,
23
+ errorCode: failure.errorCode,
24
+ error: failure.message,
25
+ errorHint: failure.errorHint,
26
+ });
27
+ }, timeoutMs);
28
+ }),
29
+ ]);
30
+ return responsePromise.finally(() => {
31
+ if (timeoutId)
32
+ clearTimeout(timeoutId);
33
+ });
34
+ }
6
35
  function readBody(req) {
7
36
  return new Promise((resolve, reject) => {
8
37
  const chunks = [];
@@ -37,6 +66,8 @@ export function createDaemonServer(provider, opts) {
37
66
  const host = opts.host ?? '127.0.0.1';
38
67
  const logBuffer = [];
39
68
  const pending = new Map();
69
+ const leases = new SessionLeaseRegistry();
70
+ const hasPendingWork = (runId) => [...pending.values()].some((entry) => entry.runId === runId);
40
71
  let shutdownStarted = false;
41
72
  function pushLog(level, msg) {
42
73
  logBuffer.push({ level, msg, ts: Date.now() });
@@ -82,6 +113,7 @@ export function createDaemonServer(provider, opts) {
82
113
  daemonVersion: opts.version,
83
114
  ...runtime,
84
115
  pending: pending.size + runtime.pending,
116
+ sessionLeases: leases.list(hasPendingWork).map(({ runId: _runId, ...lease }) => lease),
85
117
  memoryMB: Math.round(mem.rss / 1024 / 1024 * 10) / 10,
86
118
  port,
87
119
  });
@@ -117,35 +149,44 @@ export function createDaemonServer(provider, opts) {
117
149
  }
118
150
  const existing = pending.get(body.id);
119
151
  if (existing) {
120
- const result = await existing;
152
+ const result = await waitForCommandResult(body, existing.promise);
121
153
  jsonResponse(res, result.ok ? 200 : result.errorCode === 'command_result_unknown' ? 408 : 400, result);
122
154
  return;
123
155
  }
124
- const timeoutMs = typeof body.deadlineAt === 'number' && body.deadlineAt > 0
125
- ? Math.max(1000, body.deadlineAt - Date.now())
126
- : (typeof body.timeout === 'number' && body.timeout > 0 ? body.timeout * 1000 : 120_000);
127
- let timeoutId;
128
- const commandPromise = Promise.race([
129
- provider.dispatch(body),
130
- new Promise((resolve) => {
131
- timeoutId = setTimeout(() => {
132
- const failure = buildCommandTimeoutFailure(body.action, timeoutMs);
133
- resolve({
134
- id: body.id,
135
- ok: false,
136
- errorCode: failure.errorCode,
137
- error: failure.message,
138
- errorHint: failure.errorHint,
139
- });
140
- }, timeoutMs);
141
- }),
142
- ]).finally(() => {
143
- if (timeoutId)
144
- clearTimeout(timeoutId);
156
+ if (body.action === 'lease-release') {
157
+ const released = typeof body.runId === 'string' ? leases.releaseByRunId(body.runId) : 0;
158
+ jsonResponse(res, 200, { id: body.id, ok: true, data: { released } });
159
+ return;
160
+ }
161
+ let leaseKey;
162
+ let runId;
163
+ if (isSessionLeaseCommand(body)) {
164
+ const profileId = provider.resolveProfileId?.(body)
165
+ ?? body.profileId
166
+ ?? body.contextId
167
+ ?? body.preferredContextId
168
+ ?? 'default';
169
+ leaseKey = getSessionLeaseKey(profileId, body.surface, body.session);
170
+ runId = body.runId;
171
+ const acquired = leases.acquire({
172
+ key: leaseKey,
173
+ runId,
174
+ command: body.command ?? body.action,
175
+ pid: body.pid,
176
+ }, hasPendingWork);
177
+ if (!acquired.acquired) {
178
+ const { key: _key, runId: _runId, ...holder } = acquired.holder;
179
+ jsonResponse(res, 409, { ok: false, code: 'session_busy', holder });
180
+ return;
181
+ }
182
+ }
183
+ const commandPromise = provider.dispatch(body).finally(() => {
184
+ if (leaseKey && runId)
185
+ leases.heartbeat(leaseKey, runId);
145
186
  pending.delete(body.id);
146
187
  });
147
- pending.set(body.id, commandPromise);
148
- const result = await commandPromise;
188
+ pending.set(body.id, { promise: commandPromise, runId, leaseKey });
189
+ const result = await waitForCommandResult(body, commandPromise);
149
190
  if (!result.ok)
150
191
  pushLog('warn', `Command ${body.id} failed: ${result.error ?? result.errorCode ?? 'unknown error'}`);
151
192
  jsonResponse(res, result.ok ? 200 : result.errorCode === 'command_result_unknown' ? 408 : 400, result);
@@ -5,6 +5,11 @@ class FakeProvider {
5
5
  commands = [];
6
6
  shutdownCalled = false;
7
7
  delayMs = 0;
8
+ dispatchImpl;
9
+ resolveProfileId;
10
+ result(command) {
11
+ return { id: command.id, ok: true, data: { action: command.action }, page: 'page-1' };
12
+ }
8
13
  async status() {
9
14
  return {
10
15
  runtimeConnected: true,
@@ -16,10 +21,12 @@ class FakeProvider {
16
21
  };
17
22
  }
18
23
  async dispatch(command) {
24
+ this.commands.push(command);
25
+ if (this.dispatchImpl)
26
+ return this.dispatchImpl(command);
19
27
  if (this.delayMs > 0)
20
28
  await new Promise((resolve) => setTimeout(resolve, this.delayMs));
21
- this.commands.push(command);
22
- return { id: command.id, ok: true, data: { action: command.action }, page: 'page-1' };
29
+ return this.result(command);
23
30
  }
24
31
  async shutdown() {
25
32
  this.shutdownCalled = true;
@@ -30,6 +37,7 @@ describe('createDaemonServer', () => {
30
37
  afterEach(async () => {
31
38
  while (servers.length)
32
39
  await servers.pop().close();
40
+ vi.useRealTimers();
33
41
  });
34
42
  async function start(provider = new FakeProvider()) {
35
43
  const daemon = createDaemonServer(provider, { port: 0, host: '127.0.0.1', version: 'test' });
@@ -38,6 +46,28 @@ describe('createDaemonServer', () => {
38
46
  const address = daemon.server.address();
39
47
  return { provider, baseUrl: `http://127.0.0.1:${address.port}` };
40
48
  }
49
+ function postCommand(baseUrl, body) {
50
+ return fetch(`${baseUrl}/command`, {
51
+ method: 'POST',
52
+ headers: { [DAEMON_HEADER_NAME]: '1', 'Content-Type': 'application/json' },
53
+ body: JSON.stringify(body),
54
+ });
55
+ }
56
+ function persistentWrite(id, runId, overrides = {}) {
57
+ return {
58
+ id,
59
+ action: 'exec',
60
+ code: '1',
61
+ surface: 'adapter',
62
+ siteSession: 'persistent',
63
+ access: 'write',
64
+ session: 'site:example',
65
+ runId,
66
+ command: 'example write',
67
+ pid: 4242,
68
+ ...overrides,
69
+ };
70
+ }
41
71
  it('returns runtime-named status fields without extension aliases', async () => {
42
72
  const { baseUrl } = await start();
43
73
  const res = await fetch(`${baseUrl}/status`, { headers: { [DAEMON_HEADER_NAME]: '1' } });
@@ -145,6 +175,185 @@ describe('createDaemonServer', () => {
145
175
  await expect(second.json()).resolves.toMatchObject({ id: 'same-id', ok: true });
146
176
  expect(provider.commands).toHaveLength(1);
147
177
  });
178
+ it('rejects a second persistent adapter writer for the same resolved profile and session', async () => {
179
+ const provider = new FakeProvider();
180
+ provider.resolveProfileId = () => 'resolved-work';
181
+ const { baseUrl } = await start(provider);
182
+ const first = await postCommand(baseUrl, persistentWrite('first', 'run_100_1_1'));
183
+ expect(first.status).toBe(200);
184
+ const second = await postCommand(baseUrl, persistentWrite('second', 'run_200_2_2'));
185
+ expect(second.status).toBe(409);
186
+ await expect(second.json()).resolves.toMatchObject({
187
+ ok: false,
188
+ code: 'session_busy',
189
+ holder: {
190
+ command: 'example write',
191
+ pid: 4242,
192
+ acquiredAt: expect.any(Number),
193
+ heartbeatAt: expect.any(Number),
194
+ },
195
+ });
196
+ expect(provider.commands.map((command) => command.id)).toEqual(['first']);
197
+ });
198
+ it('lets one logical run issue multiple operations and heartbeat its lease', async () => {
199
+ let now = 1_000;
200
+ vi.spyOn(Date, 'now').mockImplementation(() => now);
201
+ const { provider, baseUrl } = await start();
202
+ expect((await postCommand(baseUrl, persistentWrite('first', 'run_100_1_1'))).status).toBe(200);
203
+ now = 20_000;
204
+ expect((await postCommand(baseUrl, persistentWrite('second', 'run_100_1_1'))).status).toBe(200);
205
+ const status = await fetch(`${baseUrl}/status`, { headers: { [DAEMON_HEADER_NAME]: '1' } });
206
+ await expect(status.json()).resolves.toMatchObject({
207
+ sessionLeases: [{
208
+ key: 'default␟adapter␟site%3Aexample',
209
+ command: 'example write',
210
+ acquiredAt: 1_000,
211
+ heartbeatAt: 20_000,
212
+ }],
213
+ });
214
+ expect(provider.commands.map((command) => command.id)).toEqual(['first', 'second']);
215
+ });
216
+ it.each([
217
+ ['read access', { access: 'read' }],
218
+ ['ephemeral sessions', { siteSession: 'ephemeral' }],
219
+ ['raw browser surface', { surface: 'browser' }],
220
+ ['different sites', { session: 'site:other' }],
221
+ ['different resolved profiles', { profileId: 'other' }],
222
+ ])('does not conflict across %s', async (_case, overrides) => {
223
+ const provider = new FakeProvider();
224
+ provider.resolveProfileId = (command) => command.profileId ?? 'default';
225
+ const { baseUrl } = await start(provider);
226
+ expect((await postCommand(baseUrl, persistentWrite('owner', 'run_100_1_1'))).status).toBe(200);
227
+ expect((await postCommand(baseUrl, persistentWrite('other', 'run_200_2_2', overrides))).status).toBe(200);
228
+ expect(provider.commands.map((command) => command.id)).toEqual(['owner', 'other']);
229
+ });
230
+ it('keeps pending holders live past the TTL and heartbeats them before pending removal', async () => {
231
+ let now = 1_000;
232
+ vi.spyOn(Date, 'now').mockImplementation(() => now);
233
+ let settle;
234
+ const provider = new FakeProvider();
235
+ provider.dispatchImpl = (command) => new Promise((resolve) => {
236
+ settle = () => resolve({ id: command.id, ok: true, data: 'done' });
237
+ });
238
+ const { baseUrl } = await start(provider);
239
+ const pending = postCommand(baseUrl, persistentWrite('pending', 'run_100_1_1'));
240
+ try {
241
+ await vi.waitFor(() => expect(provider.commands).toHaveLength(1));
242
+ now = 46_001;
243
+ const whilePending = await fetch(`${baseUrl}/status`, { headers: { [DAEMON_HEADER_NAME]: '1' } });
244
+ await expect(whilePending.json()).resolves.toMatchObject({
245
+ sessionLeases: [{ command: 'example write', heartbeatAt: 1_000 }],
246
+ });
247
+ const conflict = await postCommand(baseUrl, persistentWrite('conflict', 'run_200_2_2'));
248
+ expect(conflict.status).toBe(409);
249
+ expect(provider.commands).toHaveLength(1);
250
+ now = 50_000;
251
+ settle?.();
252
+ expect((await pending).status).toBe(200);
253
+ const afterSettle = await fetch(`${baseUrl}/status`, { headers: { [DAEMON_HEADER_NAME]: '1' } });
254
+ const statusBody = await afterSettle.json();
255
+ expect(statusBody.sessionLeases).toEqual([expect.objectContaining({ heartbeatAt: 50_000 })]);
256
+ expect(statusBody.sessionLeases[0]).not.toHaveProperty('runId');
257
+ }
258
+ finally {
259
+ settle?.();
260
+ await pending.catch(() => undefined);
261
+ }
262
+ });
263
+ it('keeps timed-out provider work pending until provider settlement', async () => {
264
+ let now = 1_000;
265
+ const dateNow = vi.spyOn(Date, 'now').mockImplementation(() => now);
266
+ let settleProvider;
267
+ let providerStarted;
268
+ const providerStartedPromise = new Promise((resolve) => {
269
+ providerStarted = resolve;
270
+ });
271
+ const provider = new FakeProvider();
272
+ provider.dispatchImpl = (command) => {
273
+ if (command.id !== 'timed-out') {
274
+ return Promise.resolve({ id: command.id, ok: true, data: 'done' });
275
+ }
276
+ if (provider.commands.filter(({ id }) => id === command.id).length > 1) {
277
+ return Promise.resolve({ id: command.id, ok: true, data: 'duplicate dispatch' });
278
+ }
279
+ providerStarted();
280
+ return new Promise((resolve) => {
281
+ settleProvider = () => resolve({ id: command.id, ok: true, data: 'done' });
282
+ });
283
+ };
284
+ const { baseUrl } = await start(provider);
285
+ const firstRequest = postCommand(baseUrl, persistentWrite('timed-out', 'run_100_1_1', { timeout: 0.01 }));
286
+ try {
287
+ await providerStartedPromise;
288
+ const timedOut = await firstRequest;
289
+ expect(timedOut.status).toBe(408);
290
+ await expect(timedOut.json()).resolves.toMatchObject({
291
+ id: 'timed-out',
292
+ ok: false,
293
+ errorCode: 'command_result_unknown',
294
+ });
295
+ now = 47_001;
296
+ const duplicateRequest = postCommand(baseUrl, persistentWrite('timed-out', 'run_100_1_1', { timeout: 120 }));
297
+ const whileProviderPending = await fetch(`${baseUrl}/status`, { headers: { [DAEMON_HEADER_NAME]: '1' } });
298
+ await expect(whileProviderPending.json()).resolves.toMatchObject({
299
+ pending: 1,
300
+ sessionLeases: [{ command: 'example write', heartbeatAt: 1_000 }],
301
+ });
302
+ expect(provider.commands.map((command) => command.id)).toEqual(['timed-out']);
303
+ const conflict = await postCommand(baseUrl, persistentWrite('conflict', 'run_200_2_2'));
304
+ expect(conflict.status).toBe(409);
305
+ expect(provider.commands.map((command) => command.id)).toEqual(['timed-out']);
306
+ settleProvider?.();
307
+ const duplicate = await duplicateRequest;
308
+ expect(duplicate.status).toBe(200);
309
+ await expect(duplicate.json()).resolves.toMatchObject({ data: 'done' });
310
+ const afterProviderSettle = await fetch(`${baseUrl}/status`, { headers: { [DAEMON_HEADER_NAME]: '1' } });
311
+ await expect(afterProviderSettle.json()).resolves.toMatchObject({
312
+ pending: 0,
313
+ sessionLeases: [{ command: 'example write', heartbeatAt: 47_001 }],
314
+ });
315
+ now = 92_002;
316
+ const reclaimed = await postCommand(baseUrl, persistentWrite('reclaimed', 'run_200_2_2'));
317
+ expect(reclaimed.status).toBe(200);
318
+ expect(provider.commands.map((command) => command.id)).toEqual(['timed-out', 'reclaimed']);
319
+ }
320
+ finally {
321
+ settleProvider?.();
322
+ await firstRequest.catch(() => undefined);
323
+ dateNow.mockRestore();
324
+ }
325
+ });
326
+ it('handles lease-release locally and permits a new owner', async () => {
327
+ const { provider, baseUrl } = await start();
328
+ expect((await postCommand(baseUrl, persistentWrite('owner', 'run_100_1_1'))).status).toBe(200);
329
+ const released = await postCommand(baseUrl, {
330
+ id: 'release',
331
+ action: 'lease-release',
332
+ runId: 'run_100_1_1',
333
+ });
334
+ expect(released.status).toBe(200);
335
+ await expect(released.json()).resolves.toMatchObject({
336
+ id: 'release',
337
+ ok: true,
338
+ data: { released: 1 },
339
+ });
340
+ expect(provider.commands.map((command) => command.id)).toEqual(['owner']);
341
+ expect((await postCommand(baseUrl, persistentWrite('next-owner', 'run_200_2_2'))).status).toBe(200);
342
+ expect(provider.commands.map((command) => command.id)).toEqual(['owner', 'next-owner']);
343
+ });
344
+ it('returns only sanitized current holders from status', async () => {
345
+ let now = 1_000;
346
+ vi.spyOn(Date, 'now').mockImplementation(() => now);
347
+ const { baseUrl } = await start();
348
+ expect((await postCommand(baseUrl, persistentWrite('owner', 'run_100_1_1'))).status).toBe(200);
349
+ const current = await fetch(`${baseUrl}/status`, { headers: { [DAEMON_HEADER_NAME]: '1' } });
350
+ const currentBody = await current.json();
351
+ expect(currentBody.sessionLeases).toHaveLength(1);
352
+ expect(currentBody.sessionLeases[0]).not.toHaveProperty('runId');
353
+ now = 46_001;
354
+ const expired = await fetch(`${baseUrl}/status`, { headers: { [DAEMON_HEADER_NAME]: '1' } });
355
+ await expect(expired.json()).resolves.toMatchObject({ sessionLeases: [] });
356
+ });
148
357
  it('requires X-Webcmd on non-ping endpoints', async () => {
149
358
  const { baseUrl } = await start();
150
359
  const res = await fetch(`${baseUrl}/status`);
@@ -310,7 +310,7 @@ describe('documentation sync workflow', () => {
310
310
  expect(workflow).toContain(event);
311
311
  }
312
312
  expect(workflow).toContain('contents: read');
313
- expect(workflow).toContain('pull-requests: read');
313
+ expect(workflow).toContain('pull-requests: write');
314
314
  expect(workflow).toContain('issues: write');
315
315
  expect(workflow).toContain('ref: ${{ github.event.repository.default_branch }}');
316
316
  expect(workflow).toContain('GH_TOKEN: ${{ github.token }}');
@@ -20,6 +20,7 @@
20
20
  * 130 Interrupted by Ctrl-C (set by tui.ts SIGINT handler)
21
21
  */
22
22
  import type { ObservationTraceReceipt } from './observation/events.js';
23
+ import { type SessionLeaseHolder } from './session-lease.js';
23
24
  export declare const EXIT_CODES: {
24
25
  readonly SUCCESS: 0;
25
26
  readonly GENERIC_ERROR: 1;
@@ -72,6 +73,10 @@ export declare class TimeoutError extends CliError {
72
73
  export declare class ArgumentError extends CliError {
73
74
  constructor(message: string, hint?: string);
74
75
  }
76
+ /** A persistent write session is temporarily owned by another logical run. */
77
+ export declare class SessionBusyError extends CliError {
78
+ constructor(holder: SessionLeaseHolder, platform?: string);
79
+ }
75
80
  export declare class EmptyResultError extends CliError {
76
81
  constructor(command: string, hint?: string);
77
82
  }
@@ -1,3 +1,4 @@
1
+ import { isActionablePid } from './session-lease.js';
1
2
  // ── Exit code table ──────────────────────────────────────────────────────────
2
3
  export const EXIT_CODES = {
3
4
  SUCCESS: 0,
@@ -81,6 +82,28 @@ export class ArgumentError extends CliError {
81
82
  super('ARGUMENT', message, hint, EXIT_CODES.USAGE_ERROR);
82
83
  }
83
84
  }
85
+ function formatBusyMessage(holder) {
86
+ const owner = !isActionablePid(holder.pid)
87
+ ? holder.command
88
+ : `${holder.command} (pid ${holder.pid})`;
89
+ return `Session is busy: ${owner} is already driving it.`;
90
+ }
91
+ function formatBusyHint(holder, platform) {
92
+ if (platform === 'win32') {
93
+ return !isActionablePid(holder.pid)
94
+ ? 'Wait for it to finish, or use Task Manager to stop the owning process if it is stuck.'
95
+ : `Wait for it to finish, or run \`Stop-Process -Id ${holder.pid}\` in PowerShell if it is stuck.`;
96
+ }
97
+ return !isActionablePid(holder.pid)
98
+ ? 'Wait for it to finish, or stop the owning process if it is stuck.'
99
+ : `Wait for it to finish, or run \`kill ${holder.pid}\` if it is stuck.`;
100
+ }
101
+ /** A persistent write session is temporarily owned by another logical run. */
102
+ export class SessionBusyError extends CliError {
103
+ constructor(holder, platform = process.platform) {
104
+ super('SESSION_BUSY', formatBusyMessage(holder), formatBusyHint(holder, platform), EXIT_CODES.TEMPFAIL);
105
+ }
106
+ }
84
107
  export class EmptyResultError extends CliError {
85
108
  constructor(command, hint) {
86
109
  super('EMPTY_RESULT', `${command} returned no data`, hint ?? 'The page structure may have changed, or you may need to log in', EXIT_CODES.EMPTY_RESULT);
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { CliError, BrowserConnectError, adapterLoadError, CommandExecutionError, ConfigError, AuthRequiredError, TimeoutError, ArgumentError, EmptyResultError, selectorError, attachTraceReceipt, toEnvelope, } from './errors.js';
2
+ import { CliError, BrowserConnectError, adapterLoadError, CommandExecutionError, ConfigError, AuthRequiredError, TimeoutError, ArgumentError, EmptyResultError, selectorError, SessionBusyError, attachTraceReceipt, toEnvelope, } from './errors.js';
3
3
  describe('Error type hierarchy', () => {
4
4
  it('all error types extend CliError', () => {
5
5
  const errors = [
@@ -71,6 +71,23 @@ describe('toEnvelope', () => {
71
71
  },
72
72
  });
73
73
  });
74
+ it('serializes SessionBusyError with the public code and temporary-failure exit code', () => {
75
+ const err = new SessionBusyError({
76
+ command: 'chatgpt ask',
77
+ pid: 4242,
78
+ acquiredAt: 1_000,
79
+ heartbeatAt: 2_000,
80
+ });
81
+ expect(toEnvelope(err)).toEqual({
82
+ ok: false,
83
+ error: {
84
+ code: 'SESSION_BUSY',
85
+ message: expect.stringContaining('chatgpt ask'),
86
+ help: expect.any(String),
87
+ exitCode: 75,
88
+ },
89
+ });
90
+ });
74
91
  it('converts CliError without hint (omits help field)', () => {
75
92
  const err = new CommandExecutionError('Something broke');
76
93
  const envelope = toEnvelope(err);
@@ -120,3 +137,43 @@ describe('toEnvelope', () => {
120
137
  });
121
138
  });
122
139
  });
140
+ describe('SessionBusyError platform hints', () => {
141
+ const holder = {
142
+ command: 'chatgpt ask',
143
+ pid: 4242,
144
+ acquiredAt: 1_000,
145
+ heartbeatAt: 2_000,
146
+ };
147
+ it('uses PowerShell process guidance on Windows when the holder pid is known', () => {
148
+ const err = new SessionBusyError(holder, 'win32');
149
+ expect(err.hint).toContain('Stop-Process -Id 4242');
150
+ expect(err.hint).not.toContain('kill 4242');
151
+ });
152
+ it('uses Task Manager guidance on Windows when the holder pid is unavailable', () => {
153
+ const err = new SessionBusyError({ ...holder, pid: undefined }, 'win32');
154
+ expect(err.hint).toMatch(/wait/i);
155
+ expect(err.hint).toContain('Task Manager');
156
+ expect(err.hint).not.toContain('Stop-Process');
157
+ });
158
+ it('uses kill guidance on POSIX when the holder pid is known', () => {
159
+ const err = new SessionBusyError(holder, 'linux');
160
+ expect(err.hint).toContain('kill 4242');
161
+ expect(err.hint).not.toContain('Stop-Process');
162
+ });
163
+ it.each([
164
+ 0,
165
+ -1,
166
+ 1.5,
167
+ Number.NaN,
168
+ Number.POSITIVE_INFINITY,
169
+ Number.MAX_SAFE_INTEGER + 1,
170
+ ])('does not expose non-actionable pid %s in process guidance', (pid) => {
171
+ const windowsError = new SessionBusyError({ ...holder, pid }, 'win32');
172
+ expect(windowsError.message).not.toContain(`pid ${pid}`);
173
+ expect(windowsError.hint).toContain('Task Manager');
174
+ expect(windowsError.hint).not.toContain('Stop-Process');
175
+ const posixError = new SessionBusyError({ ...holder, pid }, 'linux');
176
+ expect(posixError.message).not.toContain(`pid ${pid}`);
177
+ expect(posixError.hint).not.toContain('kill');
178
+ });
179
+ });