@agentrhq/webcmd 0.3.2 → 0.3.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -7
- package/clis/producthunt/browse.js +9 -2
- package/clis/producthunt/browser-commands.test.js +66 -0
- package/clis/producthunt/hot.js +2 -1
- package/clis/producthunt/utils.js +27 -0
- package/clis/producthunt/utils.test.js +8 -0
- package/dist/src/browser/ax-snapshot.js +15 -5
- package/dist/src/browser/ax-snapshot.test.js +49 -0
- package/dist/src/browser/daemon-client.d.ts +1 -0
- package/dist/src/browser/daemon-client.js +25 -1
- package/dist/src/browser/daemon-client.test.js +86 -1
- package/dist/src/browser/daemon-transport.d.ts +2 -0
- package/dist/src/browser/protocol.d.ts +12 -1
- package/dist/src/browser/runtime/local-cloak/actions.d.ts +1 -0
- package/dist/src/browser/runtime/local-cloak/actions.js +9 -9
- package/dist/src/browser/runtime/local-cloak/provider.d.ts +1 -0
- package/dist/src/browser/runtime/local-cloak/provider.js +6 -3
- package/dist/src/browser/runtime/local-cloak/provider.test.js +1 -0
- package/dist/src/browser/runtime/local-cloak/session-manager.d.ts +5 -0
- package/dist/src/browser/runtime/local-cloak/session-manager.js +95 -19
- package/dist/src/browser/runtime/local-cloak/session-manager.test.js +235 -0
- package/dist/src/browser/runtime/provider.d.ts +1 -0
- package/dist/src/cli.js +36 -12
- package/dist/src/cli.test.js +5 -0
- package/dist/src/daemon/server.js +65 -24
- package/dist/src/daemon/server.test.js +211 -2
- package/dist/src/docs-sync-review-cli.test.js +1 -1
- package/dist/src/errors.d.ts +5 -0
- package/dist/src/errors.js +23 -0
- package/dist/src/errors.test.js +58 -1
- package/dist/src/execution.js +57 -6
- package/dist/src/execution.test.js +426 -2
- package/dist/src/hosted/client.js +3 -1
- package/dist/src/hosted/client.test.js +62 -0
- package/dist/src/hosted/main-lifecycle.test.js +66 -5
- package/dist/src/hosted/types.d.ts +1 -0
- package/dist/src/main.js +4 -0
- package/dist/src/package-bin-process.d.ts +13 -0
- package/dist/src/package-bin-process.js +24 -0
- package/dist/src/package-bin-process.test.d.ts +1 -0
- package/dist/src/package-bin-process.test.js +22 -0
- package/dist/src/session-lease.d.ts +82 -0
- package/dist/src/session-lease.js +163 -0
- package/dist/src/session-lease.test.d.ts +1 -0
- package/dist/src/session-lease.test.js +217 -0
- package/dist/src/skills.d.ts +8 -4
- package/dist/src/skills.js +30 -1
- package/dist/src/skills.test.js +40 -12
- package/hosted-contract.json +1 -1
- package/package.json +3 -1
- package/scripts/check-package-bin.mjs +7 -6
- package/scripts/collect-ci-diagnostics.ps1 +274 -0
- package/scripts/docs-sync-review.ts +1 -1
- package/skills/smart-search/SKILL.md +20 -6
- package/skills/webcmd-adapter-author/SKILL.md +1 -1
- package/skills/webcmd-adapter-author/references/adapter-template.md +2 -6
- package/skills/webcmd-browser/SKILL.md +16 -2
- package/skills/webcmd-usage/SKILL.md +21 -6
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 {};
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, expectTypeOf, it, vi } from 'vitest';
|
|
2
|
+
import { SESSION_LEASE_TTL_MS, SessionLeaseRegistry, clearDaemonRunContext, generateRunId, getDaemonRunContext, getSessionLeaseKey, isSessionLeaseCommand, isUnknownOutcomeError, runWithDaemonRunContext, setDaemonRunContext, } from './session-lease.js';
|
|
3
|
+
const T0 = 1_000_000;
|
|
4
|
+
describe('logical daemon run context', () => {
|
|
5
|
+
afterEach(() => {
|
|
6
|
+
const current = getDaemonRunContext();
|
|
7
|
+
if (current)
|
|
8
|
+
clearDaemonRunContext(current.runId);
|
|
9
|
+
vi.restoreAllMocks();
|
|
10
|
+
});
|
|
11
|
+
it('keeps one run id stable while a logical run is bound and generates a different id for the next run', () => {
|
|
12
|
+
vi.spyOn(Date, 'now').mockReturnValue(1_763_000_000_000);
|
|
13
|
+
const firstRunId = generateRunId();
|
|
14
|
+
setDaemonRunContext({ runId: firstRunId, command: 'chatgpt ask', access: 'write' });
|
|
15
|
+
expect(getDaemonRunContext()?.runId).toBe(firstRunId);
|
|
16
|
+
expect(getDaemonRunContext()?.runId).toBe(firstRunId);
|
|
17
|
+
clearDaemonRunContext(firstRunId);
|
|
18
|
+
const secondRunId = generateRunId();
|
|
19
|
+
expect(secondRunId).not.toBe(firstRunId);
|
|
20
|
+
expect(firstRunId).toMatch(new RegExp(`^run_${process.pid}_1763000000000_\\d+$`));
|
|
21
|
+
});
|
|
22
|
+
it('does not let deferred cleanup from an older run clear a newer run context', () => {
|
|
23
|
+
setDaemonRunContext({ runId: 'run_111_1_1', command: 'chatgpt ask', access: 'write' });
|
|
24
|
+
setDaemonRunContext({ runId: 'run_222_2_2', command: 'claude ask', access: 'write' });
|
|
25
|
+
clearDaemonRunContext('run_111_1_1');
|
|
26
|
+
expect(getDaemonRunContext()).toEqual({
|
|
27
|
+
runId: 'run_222_2_2',
|
|
28
|
+
command: 'claude ask',
|
|
29
|
+
access: 'write',
|
|
30
|
+
});
|
|
31
|
+
clearDaemonRunContext('run_222_2_2');
|
|
32
|
+
expect(getDaemonRunContext()).toBeUndefined();
|
|
33
|
+
});
|
|
34
|
+
it('only accepts a concrete run context through the setter', () => {
|
|
35
|
+
expectTypeOf(setDaemonRunContext).parameter(0).toEqualTypeOf();
|
|
36
|
+
});
|
|
37
|
+
it('keeps overlapping async run contexts isolated across awaits', async () => {
|
|
38
|
+
let resumeFirst;
|
|
39
|
+
const firstGate = new Promise(resolve => { resumeFirst = resolve; });
|
|
40
|
+
const firstContext = {
|
|
41
|
+
runId: 'run_111_1_1',
|
|
42
|
+
command: 'first write',
|
|
43
|
+
access: 'write',
|
|
44
|
+
};
|
|
45
|
+
const secondContext = {
|
|
46
|
+
runId: 'run_222_2_2',
|
|
47
|
+
command: 'second write',
|
|
48
|
+
access: 'write',
|
|
49
|
+
};
|
|
50
|
+
const first = runWithDaemonRunContext(firstContext, async () => {
|
|
51
|
+
await firstGate;
|
|
52
|
+
return getDaemonRunContext();
|
|
53
|
+
});
|
|
54
|
+
const second = runWithDaemonRunContext(secondContext, async () => getDaemonRunContext());
|
|
55
|
+
expect(await second).toEqual(secondContext);
|
|
56
|
+
resumeFirst();
|
|
57
|
+
expect(await first).toEqual(firstContext);
|
|
58
|
+
expect(getDaemonRunContext()).toBeUndefined();
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
describe('isUnknownOutcomeError', () => {
|
|
62
|
+
it('recognizes public, daemon, and message codes case-insensitively', () => {
|
|
63
|
+
expect(isUnknownOutcomeError({ code: 'COMMAND_RESULT_UNKNOWN' })).toBe(true);
|
|
64
|
+
expect(isUnknownOutcomeError({ errorCode: 'Command_Lost' })).toBe(true);
|
|
65
|
+
expect(isUnknownOutcomeError(new Error('daemon returned RESULT_EVICTED'))).toBe(true);
|
|
66
|
+
expect(isUnknownOutcomeError({ daemonCode: 'attach_failed', message: 'ordinary failure' })).toBe(false);
|
|
67
|
+
});
|
|
68
|
+
it('walks causes and AggregateError members without looping on cycles', () => {
|
|
69
|
+
const cyclic = new Error('outer');
|
|
70
|
+
cyclic.cause = cyclic;
|
|
71
|
+
const aggregate = new AggregateError([
|
|
72
|
+
cyclic,
|
|
73
|
+
new Error('wrapped', { cause: { errorCode: 'COMMAND_LOST' } }),
|
|
74
|
+
]);
|
|
75
|
+
expect(isUnknownOutcomeError(aggregate)).toBe(true);
|
|
76
|
+
expect(isUnknownOutcomeError(cyclic)).toBe(false);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
describe('session lease partitions', () => {
|
|
80
|
+
it('partitions persistent writes by resolved profile, surface, and encoded site', () => {
|
|
81
|
+
const workChatgpt = getSessionLeaseKey('work', 'adapter', 'site:chatgpt');
|
|
82
|
+
expect(workChatgpt).toBe('work␟adapter␟site%3Achatgpt');
|
|
83
|
+
expect(workChatgpt).not.toBe(getSessionLeaseKey('personal', 'adapter', 'site:chatgpt'));
|
|
84
|
+
expect(workChatgpt).not.toBe(getSessionLeaseKey('work', 'adapter', 'site:claude'));
|
|
85
|
+
expect(workChatgpt).not.toBe(getSessionLeaseKey('work', 'browser', 'site:chatgpt'));
|
|
86
|
+
});
|
|
87
|
+
it('does not arbitrate reads, ephemeral sessions, raw browser operations, or incomplete identities', () => {
|
|
88
|
+
const eligible = {
|
|
89
|
+
surface: 'adapter',
|
|
90
|
+
siteSession: 'persistent',
|
|
91
|
+
access: 'write',
|
|
92
|
+
session: 'site:chatgpt',
|
|
93
|
+
runId: 'run_111_1_1',
|
|
94
|
+
};
|
|
95
|
+
expect(isSessionLeaseCommand(eligible)).toBe(true);
|
|
96
|
+
expect(isSessionLeaseCommand({ ...eligible, access: 'read' })).toBe(false);
|
|
97
|
+
expect(isSessionLeaseCommand({ ...eligible, siteSession: 'ephemeral' })).toBe(false);
|
|
98
|
+
expect(isSessionLeaseCommand({ ...eligible, surface: 'browser' })).toBe(false);
|
|
99
|
+
expect(isSessionLeaseCommand({ ...eligible, session: '' })).toBe(false);
|
|
100
|
+
expect(isSessionLeaseCommand({ ...eligible, runId: undefined })).toBe(false);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
describe('SessionLeaseRegistry', () => {
|
|
104
|
+
const KEY = getSessionLeaseKey('work', 'adapter', 'site:chatgpt');
|
|
105
|
+
let now = T0;
|
|
106
|
+
beforeEach(() => {
|
|
107
|
+
now = T0;
|
|
108
|
+
});
|
|
109
|
+
function registry() {
|
|
110
|
+
return new SessionLeaseRegistry(() => now);
|
|
111
|
+
}
|
|
112
|
+
function acquire(registry, runId, key = KEY) {
|
|
113
|
+
return registry.acquire({ key, runId, command: 'chatgpt ask', pid: Number(runId.split('_')[1]) }, () => false);
|
|
114
|
+
}
|
|
115
|
+
it('acquires a free key and returns the live holder on a conflicting run without mutation', () => {
|
|
116
|
+
const leases = registry();
|
|
117
|
+
expect(acquire(leases, 'run_111_1_1')).toEqual({
|
|
118
|
+
acquired: true,
|
|
119
|
+
lease: expect.objectContaining({
|
|
120
|
+
key: KEY,
|
|
121
|
+
runId: 'run_111_1_1',
|
|
122
|
+
command: 'chatgpt ask',
|
|
123
|
+
pid: 111,
|
|
124
|
+
acquiredAt: T0,
|
|
125
|
+
heartbeatAt: T0,
|
|
126
|
+
}),
|
|
127
|
+
});
|
|
128
|
+
now += 1_000;
|
|
129
|
+
const conflict = acquire(leases, 'run_222_2_2');
|
|
130
|
+
expect(conflict).toEqual({
|
|
131
|
+
acquired: false,
|
|
132
|
+
holder: expect.objectContaining({ runId: 'run_111_1_1', heartbeatAt: T0 }),
|
|
133
|
+
});
|
|
134
|
+
expect(leases.list(() => false)).toEqual([
|
|
135
|
+
expect.objectContaining({ runId: 'run_111_1_1', heartbeatAt: T0 }),
|
|
136
|
+
]);
|
|
137
|
+
});
|
|
138
|
+
it('recovers the holder pid from a generated run id when explicit metadata is absent', () => {
|
|
139
|
+
const leases = registry();
|
|
140
|
+
expect(leases.acquire({ key: KEY, runId: 'run_4242_1700000000000_7', command: 'chatgpt ask' }, () => false)).toEqual({
|
|
141
|
+
acquired: true,
|
|
142
|
+
lease: expect.objectContaining({ pid: 4242 }),
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
it.each([
|
|
146
|
+
0,
|
|
147
|
+
-1,
|
|
148
|
+
1.5,
|
|
149
|
+
Number.NaN,
|
|
150
|
+
Number.POSITIVE_INFINITY,
|
|
151
|
+
Number.MAX_SAFE_INTEGER + 1,
|
|
152
|
+
])('omits a non-actionable explicit holder pid (%s)', (pid) => {
|
|
153
|
+
const leases = registry();
|
|
154
|
+
const result = leases.acquire({ key: KEY, runId: 'opaque-run-id', command: 'chatgpt ask', pid }, () => false);
|
|
155
|
+
expect(result.acquired).toBe(true);
|
|
156
|
+
if (result.acquired)
|
|
157
|
+
expect(result.lease).not.toHaveProperty('pid');
|
|
158
|
+
});
|
|
159
|
+
it.each([
|
|
160
|
+
'run_0_1700000000000_1',
|
|
161
|
+
'run_-1_1700000000000_1',
|
|
162
|
+
'run_1.5_1700000000000_1',
|
|
163
|
+
'run_NaN_1700000000000_1',
|
|
164
|
+
'run_Infinity_1700000000000_1',
|
|
165
|
+
`run_${Number.MAX_SAFE_INTEGER + 1}_1700000000000_1`,
|
|
166
|
+
])('does not recover a non-actionable holder pid from %s', (runId) => {
|
|
167
|
+
const leases = registry();
|
|
168
|
+
const result = leases.acquire({ key: KEY, runId, command: 'chatgpt ask' }, () => false);
|
|
169
|
+
expect(result.acquired).toBe(true);
|
|
170
|
+
if (result.acquired)
|
|
171
|
+
expect(result.lease).not.toHaveProperty('pid');
|
|
172
|
+
});
|
|
173
|
+
it('treats same-run acquisition and explicit heartbeat as owner-only liveness refreshes', () => {
|
|
174
|
+
const leases = registry();
|
|
175
|
+
acquire(leases, 'run_111_1_1');
|
|
176
|
+
now += SESSION_LEASE_TTL_MS;
|
|
177
|
+
const refreshed = acquire(leases, 'run_111_1_1');
|
|
178
|
+
expect(refreshed).toEqual({
|
|
179
|
+
acquired: true,
|
|
180
|
+
lease: expect.objectContaining({ acquiredAt: T0, heartbeatAt: now }),
|
|
181
|
+
});
|
|
182
|
+
now += 10;
|
|
183
|
+
expect(leases.heartbeat(KEY, 'run_999_9_9')).toBe(false);
|
|
184
|
+
expect(leases.heartbeat(KEY, 'run_111_1_1')).toBe(true);
|
|
185
|
+
expect(leases.list(() => false)[0]).toMatchObject({ acquiredAt: T0, heartbeatAt: now });
|
|
186
|
+
});
|
|
187
|
+
it('lets a challenger acquire after expiry', () => {
|
|
188
|
+
const leases = registry();
|
|
189
|
+
acquire(leases, 'run_111_1_1');
|
|
190
|
+
now += SESSION_LEASE_TTL_MS + 1;
|
|
191
|
+
expect(acquire(leases, 'run_222_2_2')).toEqual({
|
|
192
|
+
acquired: true,
|
|
193
|
+
lease: expect.objectContaining({ runId: 'run_222_2_2', acquiredAt: now }),
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
it('keeps an expired holder live while the holder still has pending work', () => {
|
|
197
|
+
const leases = registry();
|
|
198
|
+
acquire(leases, 'run_111_1_1');
|
|
199
|
+
now += SESSION_LEASE_TTL_MS + 10_000;
|
|
200
|
+
const conflict = leases.acquire({ key: KEY, runId: 'run_222_2_2', command: 'chatgpt ask', pid: 222 }, (runId) => runId === 'run_111_1_1');
|
|
201
|
+
expect(conflict).toEqual({
|
|
202
|
+
acquired: false,
|
|
203
|
+
holder: expect.objectContaining({ runId: 'run_111_1_1' }),
|
|
204
|
+
});
|
|
205
|
+
expect(leases.list((runId) => runId === 'run_111_1_1')).toHaveLength(1);
|
|
206
|
+
expect(leases.list(() => false)).toEqual([]);
|
|
207
|
+
});
|
|
208
|
+
it('releases only leases owned by the requested run and reports the count', () => {
|
|
209
|
+
const leases = registry();
|
|
210
|
+
acquire(leases, 'run_111_1_1');
|
|
211
|
+
acquire(leases, 'run_111_1_1', getSessionLeaseKey('work', 'adapter', 'site:claude'));
|
|
212
|
+
expect(leases.releaseByRunId('run_999_9_9')).toBe(0);
|
|
213
|
+
expect(leases.list(() => false)).toHaveLength(2);
|
|
214
|
+
expect(leases.releaseByRunId('run_111_1_1')).toBe(2);
|
|
215
|
+
expect(leases.list(() => false)).toEqual([]);
|
|
216
|
+
});
|
|
217
|
+
});
|
package/dist/src/skills.d.ts
CHANGED
|
@@ -4,7 +4,7 @@ export interface WebcmdSkillInfo {
|
|
|
4
4
|
version: string;
|
|
5
5
|
path: string;
|
|
6
6
|
}
|
|
7
|
-
export interface
|
|
7
|
+
export interface WebcmdSkillOptions {
|
|
8
8
|
provider?: string;
|
|
9
9
|
scope?: string;
|
|
10
10
|
customPath?: string;
|
|
@@ -18,15 +18,19 @@ export interface WebcmdSkillLink {
|
|
|
18
18
|
stableLink: string;
|
|
19
19
|
destination?: string;
|
|
20
20
|
}
|
|
21
|
-
export interface
|
|
21
|
+
export interface WebcmdSkillAddResult {
|
|
22
22
|
provider?: SkillProvider;
|
|
23
23
|
scope?: SkillScope;
|
|
24
24
|
skills: WebcmdSkillLink[];
|
|
25
25
|
}
|
|
26
|
+
export interface WebcmdSkillRemoveResult {
|
|
27
|
+
removed: string[];
|
|
28
|
+
}
|
|
26
29
|
type SkillProvider = 'agents' | 'codex' | 'claude';
|
|
27
30
|
type SkillScope = 'user' | 'project';
|
|
28
31
|
export declare function getSkillsRoot(packageRoot?: string): string;
|
|
29
32
|
export declare function listWebcmdSkills(packageRoot?: string): WebcmdSkillInfo[];
|
|
30
|
-
export declare function
|
|
31
|
-
export declare function updateWebcmdSkill(options?:
|
|
33
|
+
export declare function addWebcmdSkills(options?: WebcmdSkillOptions): WebcmdSkillAddResult;
|
|
34
|
+
export declare function updateWebcmdSkill(options?: WebcmdSkillOptions): WebcmdSkillAddResult;
|
|
35
|
+
export declare function removeWebcmdSkills(options?: WebcmdSkillOptions): WebcmdSkillRemoveResult;
|
|
32
36
|
export {};
|
package/dist/src/skills.js
CHANGED
|
@@ -19,7 +19,7 @@ export function listWebcmdSkills(packageRoot) {
|
|
|
19
19
|
.filter((entry) => entry !== null)
|
|
20
20
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
21
21
|
}
|
|
22
|
-
export function
|
|
22
|
+
export function addWebcmdSkills(options = {}) {
|
|
23
23
|
const provider = options.customPath === undefined ? normalizeProvider(options.provider) : undefined;
|
|
24
24
|
const scope = normalizeScope(options.scope);
|
|
25
25
|
const skills = updateStableSkillLinks(options)
|
|
@@ -46,6 +46,35 @@ export function updateWebcmdSkill(options = {}) {
|
|
|
46
46
|
}),
|
|
47
47
|
};
|
|
48
48
|
}
|
|
49
|
+
export function removeWebcmdSkills(options = {}) {
|
|
50
|
+
const homeDir = options.homeDir ?? os.homedir();
|
|
51
|
+
const cwd = options.cwd ?? process.cwd();
|
|
52
|
+
const roots = new Set([
|
|
53
|
+
...['.agents', '.codex', '.claude'].flatMap((dir) => [
|
|
54
|
+
path.join(homeDir, dir, 'skills'),
|
|
55
|
+
path.join(cwd, dir, 'skills'),
|
|
56
|
+
]),
|
|
57
|
+
...(options.customPath === undefined ? [] : [expandHomePath(options.customPath)]),
|
|
58
|
+
path.join(homeDir, '.webcmd', 'skills'),
|
|
59
|
+
]);
|
|
60
|
+
const skills = listWebcmdSkills(options.packageRoot);
|
|
61
|
+
const removed = [];
|
|
62
|
+
for (const root of roots) {
|
|
63
|
+
for (const skill of skills) {
|
|
64
|
+
const linkPath = path.join(root, skill.name);
|
|
65
|
+
const current = safeLstat(linkPath);
|
|
66
|
+
if (!current)
|
|
67
|
+
continue;
|
|
68
|
+
if (!current.isSymbolicLink()) {
|
|
69
|
+
throw new ArgumentError(`Refusing to remove non-symlink path: ${linkPath}`, 'Remove it manually if it is no longer needed.');
|
|
70
|
+
}
|
|
71
|
+
removed.push(linkPath);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
for (const linkPath of removed)
|
|
75
|
+
fs.unlinkSync(linkPath);
|
|
76
|
+
return { removed };
|
|
77
|
+
}
|
|
49
78
|
function updateStableSkillLinks(options) {
|
|
50
79
|
const skillsRoot = getSkillsRoot(options.packageRoot);
|
|
51
80
|
const skills = listWebcmdSkills(options.packageRoot);
|