dsh-reference-anything 0.2.0 → 0.2.1

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/lib/contract.d.ts CHANGED
@@ -99,6 +99,17 @@ export declare const openCliDiscoverySchema: z.ZodReadonly<z.ZodObject<{
99
99
  version: z.ZodString;
100
100
  error: z.ZodOptional<z.ZodString>;
101
101
  }, z.core.$strip>>;
102
+ export declare const packageUpdateStatusSchema: z.ZodReadonly<z.ZodObject<{
103
+ currentVersion: z.ZodString;
104
+ latestVersion: z.ZodString;
105
+ updateAvailable: z.ZodBoolean;
106
+ checkedAt: z.ZodNumber;
107
+ error: z.ZodOptional<z.ZodString>;
108
+ }, z.core.$strip>>;
109
+ export declare const packageUpdateResultSchema: z.ZodReadonly<z.ZodObject<{
110
+ version: z.ZodString;
111
+ restartRequired: z.ZodBoolean;
112
+ }, z.core.$strip>>;
102
113
  export declare const providerStatsSchema: z.ZodReadonly<z.ZodObject<{
103
114
  provider: z.ZodEnum<{
104
115
  chatgpt: "chatgpt";
package/lib/contract.js CHANGED
@@ -26,6 +26,13 @@ export const browserProfileSchema = z.object({ id: z.string(), alias: z.string()
26
26
  export const openCliDiscoverySchema = z.object({
27
27
  found: z.boolean(), executable: z.string(), version: z.string(), error: z.string().optional(),
28
28
  }).readonly();
29
+ export const packageUpdateStatusSchema = z.object({
30
+ currentVersion: z.string(), latestVersion: z.string(), updateAvailable: z.boolean(),
31
+ checkedAt: z.number().int().nonnegative(), error: z.string().optional(),
32
+ }).readonly();
33
+ export const packageUpdateResultSchema = z.object({
34
+ version: z.string(), restartRequired: z.boolean(),
35
+ }).readonly();
29
36
  export const providerStatsSchema = z.object({
30
37
  provider: providerSchema, conversations: z.number().int().nonnegative(), lastSyncedAt: z.string(),
31
38
  status: z.enum(['ready', 'syncing', 'error', 'empty']), error: z.string().optional(),
@@ -74,6 +81,9 @@ export const REFERENCE_ANYTHING_INVOCATIONS = [
74
81
  descriptor('installOpenCli', [], strict('OpenCliDiscovery', openCliDiscoverySchema), true),
75
82
  descriptor('installAdapter', [], strict('Boolean', z.boolean()), true),
76
83
  descriptor('restartDaemon', [], strict('Boolean', z.boolean()), true),
84
+ descriptor('updateStatus', [], strict('PackageUpdateStatus', packageUpdateStatusSchema), true),
85
+ descriptor('checkUpdate', [], strict('PackageUpdateStatus', packageUpdateStatusSchema), true),
86
+ descriptor('installUpdate', [], strict('PackageUpdateResult', packageUpdateResultSchema), true),
77
87
  descriptor('stats', [], strict('ProviderStats[]', z.array(providerStatsSchema))),
78
88
  descriptor('syncStart', [{ name: 'input', wire: 'input', source: 'json', codec: strict('SyncStart', syncStartSchema) }], strict('JobId', z.string())),
79
89
  descriptor('syncStatus', [{ name: 'input', wire: 'input', source: 'json', codec: strict('JobInput', jobInputSchema) }], strict('SyncStatus?', syncStatusSchema.optional())),
package/lib/host.d.ts CHANGED
@@ -26,6 +26,9 @@ export declare class ReferenceAnythingRemote extends TypertRemoteService {
26
26
  installOpenCli(signal: AbortSignal): Promise<import("./opencli.ts").OpenCliDiscovery>;
27
27
  installAdapter(signal: AbortSignal): Promise<boolean>;
28
28
  restartDaemon(signal: AbortSignal): Promise<boolean>;
29
+ updateStatus(signal: AbortSignal): Promise<import("./update.ts").PackageUpdateStatus>;
30
+ checkUpdate(signal: AbortSignal): Promise<import("./update.ts").PackageUpdateStatus>;
31
+ installUpdate(signal: AbortSignal): Promise<import("./update.ts").PackageUpdateResult>;
29
32
  stats(): import("./store/store.ts").ProviderStats[];
30
33
  syncStart(input: {
31
34
  providers: ChatProvider[];
package/lib/host.js CHANGED
@@ -18,6 +18,9 @@ export class ReferenceAnythingRemote extends TypertRemoteService {
18
18
  installOpenCli(signal) { return this.ctx.referenceChatHistory.installOpenCli(signal); }
19
19
  installAdapter(signal) { return this.ctx.referenceChatHistory.installAdapter(signal); }
20
20
  restartDaemon(signal) { return this.ctx.referenceChatHistory.restartDaemon(signal); }
21
+ updateStatus(signal) { return this.ctx.referenceChatHistory.updateStatus(signal); }
22
+ checkUpdate(signal) { return this.ctx.referenceChatHistory.checkUpdate(signal); }
23
+ installUpdate(signal) { return this.ctx.referenceChatHistory.installUpdate(signal); }
21
24
  stats() { return this.ctx.referenceChatHistory.stats(); }
22
25
  syncStart(input) {
23
26
  return this.ctx.referenceChatHistory.sync.start(input.providers, input.mode);
package/lib/opencli.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { ChatProvider } from './store/spec.ts';
2
2
  import type { ProviderConversationRow, ProviderTurnRow } from './store/store.ts';
3
3
  export declare const MIN_OPENCLI_VERSION = "1.8.6";
4
- export declare const MIN_ADAPTER_VERSION = "0.2.0";
4
+ export declare const MIN_ADAPTER_VERSION = "0.2.1";
5
5
  export declare const OPENCLI_NPM_PACKAGE = "@jackwener/opencli";
6
6
  export type OpenCliErrorCode = 'EXTENSION_NOT_CONNECTED' | 'PROVIDER_TIMEOUT' | 'PROVIDER_NOT_LOGGED_IN' | 'PROVIDER_ACCOUNT_MISMATCH' | 'OPENCLI_CONFIGURATION' | 'OPENCLI_OUTPUT_TOO_LARGE' | 'OPENCLI_FAILED' | 'OPENCLI_INSTALL_FAILED';
7
7
  export declare class OpenCliError extends Error {
package/lib/opencli.js CHANGED
@@ -5,13 +5,20 @@ import { promisify } from 'node:util';
5
5
  import { createHash } from 'node:crypto';
6
6
  const execFile = promisify(nodeExecFile);
7
7
  export const MIN_OPENCLI_VERSION = '1.8.6';
8
- export const MIN_ADAPTER_VERSION = '0.2.0';
8
+ export const MIN_ADAPTER_VERSION = '0.2.1';
9
9
  export const OPENCLI_NPM_PACKAGE = '@jackwener/opencli';
10
10
  const SITE = {
11
11
  chatgpt: 'dsh-chatgpt', claude: 'dsh-claude', gemini: 'dsh-gemini',
12
12
  deepseek: 'dsh-deepseek', grok: 'dsh-grok', kimi: 'dsh-kimi',
13
13
  };
14
14
  const REQUIRED_ADAPTER_COMMANDS = ['chatgpt', 'claude', 'gemini', 'deepseek', 'grok', 'kimi'];
15
+ /**
16
+ * A persistent OpenCLI site session owns one mutable browser tab. Serialize
17
+ * commands that target the same executable/profile/site across runner
18
+ * instances so concurrent detail workers cannot navigate that tab over one
19
+ * another. Different provider sites still run in parallel.
20
+ */
21
+ const SITE_COMMAND_TAILS = new Map();
15
22
  export class OpenCliError extends Error {
16
23
  code;
17
24
  constructor(message, code, options) {
@@ -185,19 +192,25 @@ export class OpenCliRunner {
185
192
  }
186
193
  }
187
194
  async json(site, operation, args, signal) {
188
- // Pin adapter traffic to a background tab. OpenCLI otherwise lets the
189
- // process-wide OPENCLI_WINDOW environment variable override the adapter's
190
- // defaultWindowMode, which could make an unattended sync steal focus.
191
- const stdout = await this.raw([site, operation, ...args, '--window', 'background', '-f', 'json'], signal);
192
- try {
193
- const parsed = JSON.parse(stdout);
194
- if (!Array.isArray(parsed) || parsed.some(row => !row || typeof row !== 'object' || Array.isArray(row)))
195
- throw new Error('expected object array');
196
- return parsed;
197
- }
198
- catch (error) {
199
- throw new OpenCliError('OpenCLI returned malformed JSON', 'OPENCLI_CONFIGURATION', { cause: error });
200
- }
195
+ const queueKey = [this.executable, ...this.prefixArgs, this.profile, site].join('\0');
196
+ return serializeSiteCommand(queueKey, async () => {
197
+ // Pin adapter traffic to one persistent background session. OpenCLI
198
+ // otherwise lets caller flags override the adapter lifecycle metadata,
199
+ // which could release its tab or make an unattended sync steal focus.
200
+ const stdout = await this.raw([
201
+ site, operation, ...args,
202
+ '--site-session', 'persistent', '--window', 'background', '-f', 'json',
203
+ ], signal);
204
+ try {
205
+ const parsed = JSON.parse(stdout);
206
+ if (!Array.isArray(parsed) || parsed.some(row => !row || typeof row !== 'object' || Array.isArray(row)))
207
+ throw new Error('expected object array');
208
+ return parsed;
209
+ }
210
+ catch (error) {
211
+ throw new OpenCliError('OpenCLI returned malformed JSON', 'OPENCLI_CONFIGURATION', { cause: error });
212
+ }
213
+ });
201
214
  }
202
215
  async raw(args, signal) {
203
216
  const invocation = resolveInvocation(this.executable);
@@ -224,6 +237,19 @@ export class OpenCliRunner {
224
237
  }
225
238
  }
226
239
  }
240
+ async function serializeSiteCommand(key, task) {
241
+ const previous = SITE_COMMAND_TAILS.get(key) ?? Promise.resolve();
242
+ const current = previous.then(task, task);
243
+ const settled = current.then(() => undefined, () => undefined);
244
+ SITE_COMMAND_TAILS.set(key, settled);
245
+ try {
246
+ return await current;
247
+ }
248
+ finally {
249
+ if (SITE_COMMAND_TAILS.get(key) === settled)
250
+ SITE_COMMAND_TAILS.delete(key);
251
+ }
252
+ }
227
253
  /** Locate a working OpenCLI without asking a command-line newcomer for PATH details. */
228
254
  export async function discoverOpenCli(configured = 'opencli', signal) {
229
255
  const candidates = openCliCandidates(configured);
@@ -5,6 +5,7 @@ import type { ChatProvider, SettingsRecord } from '../../store/spec.ts';
5
5
  import { ConversationStore, type MatchedVia } from '../../store/store.ts';
6
6
  import { ConversationSyncManager } from '../../sync/index.ts';
7
7
  import { type OpenCliDiscovery } from '../../opencli.ts';
8
+ import { type PackageUpdateResult, type PackageUpdateStatus } from '../../update.ts';
8
9
  import { parseProviderQuery } from '../../search.ts';
9
10
  export { parseProviderQuery };
10
11
  export declare const name = "reference-web-chat";
@@ -51,6 +52,7 @@ export default class WebChatHistoryService extends Service implements ReferenceS
51
52
  private syncValue?;
52
53
  private autoSyncInterval?;
53
54
  private autoSyncCatchUp?;
55
+ private readonly packageUpdates;
54
56
  private readonly liveAttachments;
55
57
  constructor(ctx: Context, config?: Config);
56
58
  protected [Service.init](): Promise<void>;
@@ -84,6 +86,9 @@ export default class WebChatHistoryService extends Service implements ReferenceS
84
86
  /** Durable per-provider sync status, independent of any single in-flight job. */
85
87
  syncStates(): import("../../store/store.ts").ProviderSyncState[];
86
88
  getSettings(): SettingsRecord;
89
+ updateStatus(signal?: AbortSignal): Promise<PackageUpdateStatus>;
90
+ checkUpdate(signal?: AbortSignal): Promise<PackageUpdateStatus>;
91
+ installUpdate(signal?: AbortSignal): Promise<PackageUpdateResult>;
87
92
  restartDaemon(signal?: AbortSignal): Promise<boolean>;
88
93
  discoverOpenCli(signal?: AbortSignal): Promise<OpenCliDiscovery>;
89
94
  installOpenCli(signal?: AbortSignal): Promise<OpenCliDiscovery>;
@@ -4,6 +4,7 @@ import { providerSchema, referenceAnythingDomainSpec, settingsRecordSchema } fro
4
4
  import { ConversationStore } from "../../store/store.js";
5
5
  import { ConversationSyncManager } from "../../sync/index.js";
6
6
  import { OpenCliError, OpenCliRunner, discoverOpenCli, installOpenCli as installOpenCliPackage } from "../../opencli.js";
7
+ import { PackageUpdateManager } from "../../update.js";
7
8
  import { ReferenceAnythingError } from "../../errors.js";
8
9
  import { parseProviderQuery } from "../../search.js";
9
10
  export { parseProviderQuery };
@@ -26,6 +27,7 @@ export default class WebChatHistoryService extends Service {
26
27
  syncValue;
27
28
  autoSyncInterval;
28
29
  autoSyncCatchUp;
30
+ packageUpdates = new PackageUpdateManager();
29
31
  liveAttachments = new Map();
30
32
  constructor(ctx, config = {}) {
31
33
  super(ctx, 'referenceChatHistory');
@@ -47,6 +49,13 @@ export default class WebChatHistoryService extends Service {
47
49
  timeoutMs: this.config.timeoutMs, maxStdoutBytes: this.config.maxStdoutBytes,
48
50
  }), this.ctx.logger);
49
51
  await this.store.collectExpired();
52
+ const updateAbort = new AbortController();
53
+ this.ctx.effect(() => () => { updateAbort.abort(); }, 'reference-web-chat.updateCheckCleanup');
54
+ void this.packageUpdates.check(updateAbort.signal).then(status => {
55
+ if (status.updateAvailable) {
56
+ this.ctx.logger.info(`reference anything update available: ${status.currentVersion} -> ${status.latestVersion}`);
57
+ }
58
+ });
50
59
  this.ctx.references.registerSource(this);
51
60
  this.ctx.effect(() => () => { this.clearAutoSync(); }, 'reference-web-chat.autoSyncCleanup');
52
61
  this.armAutoSync();
@@ -182,6 +191,13 @@ export default class WebChatHistoryService extends Service {
182
191
  /** Durable per-provider sync status, independent of any single in-flight job. */
183
192
  syncStates() { return this.store.syncStateSummary(); }
184
193
  getSettings() { return this.store.settings; }
194
+ updateStatus(signal) { return this.packageUpdates.status(signal); }
195
+ checkUpdate(signal) { return this.packageUpdates.check(signal); }
196
+ async installUpdate(signal) {
197
+ if (this.sync.isRunning())
198
+ throw new ReferenceAnythingError('wait for the current conversation sync to finish before updating', 'REFERENCE_SYNC_IN_PROGRESS');
199
+ return this.packageUpdates.update(signal);
200
+ }
185
201
  async restartDaemon(signal) { await this.runner().restartDaemon(signal); return true; }
186
202
  async discoverOpenCli(signal) {
187
203
  return discoverOpenCli(this.store.settings.opencliPath, signal);
package/lib/typert.js CHANGED
@@ -14,6 +14,9 @@ export const TYPERT_MANIFEST = {
14
14
  { kind: 'method', name: 'installOpenCli', signature: 'installOpenCli(signal: AbortSignal): Promise<OpenCliDiscovery>' },
15
15
  { kind: 'method', name: 'installAdapter', signature: 'installAdapter(signal: AbortSignal): Promise<boolean>' },
16
16
  { kind: 'method', name: 'restartDaemon', signature: 'restartDaemon(signal: AbortSignal): Promise<boolean>' },
17
+ { kind: 'method', name: 'updateStatus', signature: 'updateStatus(signal: AbortSignal): Promise<PackageUpdateStatus>' },
18
+ { kind: 'method', name: 'checkUpdate', signature: 'checkUpdate(signal: AbortSignal): Promise<PackageUpdateStatus>' },
19
+ { kind: 'method', name: 'installUpdate', signature: 'installUpdate(signal: AbortSignal): Promise<PackageUpdateResult>' },
17
20
  { kind: 'method', name: 'stats', signature: 'stats(): ProviderStats[]' },
18
21
  { kind: 'method', name: 'syncStart', signature: 'syncStart(input: SyncStart): string' },
19
22
  { kind: 'method', name: 'syncStatus', signature: 'syncStatus(input: JobInput): SyncStatus | undefined' },
@@ -0,0 +1,43 @@
1
+ export declare const PACKAGE_NAME = "dsh-reference-anything";
2
+ export declare const NPM_LATEST_URL = "https://registry.npmjs.org/dsh-reference-anything/latest";
3
+ export interface PackageUpdateStatus {
4
+ currentVersion: string;
5
+ latestVersion: string;
6
+ updateAvailable: boolean;
7
+ checkedAt: number;
8
+ error?: string;
9
+ }
10
+ export interface PackageUpdateResult {
11
+ version: string;
12
+ restartRequired: boolean;
13
+ }
14
+ interface UpdateManagerOptions {
15
+ packageRoot?: string;
16
+ dshHome?: string;
17
+ fetchLatest?: (signal?: AbortSignal) => Promise<string>;
18
+ install?: (profileDir: string, version: string, signal?: AbortSignal) => Promise<void>;
19
+ }
20
+ /** One startup/manual-check cache and the confirmed profile-local updater. */
21
+ export declare class PackageUpdateManager {
22
+ private readonly packageRoot;
23
+ private readonly dshHome?;
24
+ private readonly fetchLatest;
25
+ private readonly install;
26
+ private cached?;
27
+ private checking?;
28
+ constructor(options?: UpdateManagerOptions);
29
+ /** Return the startup result, waiting for it when it is still in flight. */
30
+ status(signal?: AbortSignal): Promise<PackageUpdateStatus>;
31
+ /** Force a registry read, while collapsing concurrent startup/manual clicks. */
32
+ check(signal?: AbortSignal): Promise<PackageUpdateStatus>;
33
+ /** Install only the exact version returned by a fresh registry check. */
34
+ update(signal?: AbortSignal): Promise<PackageUpdateResult>;
35
+ private runCheck;
36
+ }
37
+ export declare function fetchLatestVersion(signal?: AbortSignal): Promise<string>;
38
+ export declare function readPackageVersion(packageRoot?: string): Promise<string>;
39
+ /** Find the profile that owns this exact installed package, including pnpm links. */
40
+ export declare function findOwningProfileDir(packageRoot?: string, configuredDshHome?: string): Promise<string>;
41
+ export declare function installPackageVersion(profileDir: string, version: string, signal?: AbortSignal): Promise<void>;
42
+ export declare function compareVersions(left: string, right: string): number;
43
+ export {};
package/lib/update.js ADDED
@@ -0,0 +1,203 @@
1
+ import { execFile as nodeExecFile } from 'node:child_process';
2
+ import { homedir } from 'node:os';
3
+ import { dirname, join, resolve } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { access, readFile, readdir, realpath } from 'node:fs/promises';
6
+ import { promisify } from 'node:util';
7
+ const execFile = promisify(nodeExecFile);
8
+ export const PACKAGE_NAME = 'dsh-reference-anything';
9
+ export const NPM_LATEST_URL = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
10
+ const PACKAGE_ROOT = resolve(fileURLToPath(new URL('../', import.meta.url)));
11
+ const UPDATE_TIMEOUT_MS = 5 * 60_000;
12
+ const CHECK_TIMEOUT_MS = 10_000;
13
+ /** One startup/manual-check cache and the confirmed profile-local updater. */
14
+ export class PackageUpdateManager {
15
+ packageRoot;
16
+ dshHome;
17
+ fetchLatest;
18
+ install;
19
+ cached;
20
+ checking;
21
+ constructor(options = {}) {
22
+ this.packageRoot = resolve(options.packageRoot ?? PACKAGE_ROOT);
23
+ this.dshHome = options.dshHome;
24
+ this.fetchLatest = options.fetchLatest ?? fetchLatestVersion;
25
+ this.install = options.install ?? installPackageVersion;
26
+ }
27
+ /** Return the startup result, waiting for it when it is still in flight. */
28
+ status(signal) {
29
+ if (this.checking)
30
+ return this.checking;
31
+ if (this.cached)
32
+ return Promise.resolve(this.cached);
33
+ return this.check(signal);
34
+ }
35
+ /** Force a registry read, while collapsing concurrent startup/manual clicks. */
36
+ check(signal) {
37
+ if (this.checking)
38
+ return this.checking;
39
+ const request = this.runCheck(signal).finally(() => {
40
+ if (this.checking === request)
41
+ this.checking = undefined;
42
+ });
43
+ this.checking = request;
44
+ return request;
45
+ }
46
+ /** Install only the exact version returned by a fresh registry check. */
47
+ async update(signal) {
48
+ const status = await this.check(signal);
49
+ if (status.error)
50
+ throw new Error(status.error);
51
+ if (!status.updateAvailable)
52
+ return { version: status.currentVersion, restartRequired: false };
53
+ const profileDir = await findOwningProfileDir(this.packageRoot, this.dshHome);
54
+ await this.install(profileDir, status.latestVersion, signal);
55
+ this.cached = {
56
+ currentVersion: status.latestVersion,
57
+ latestVersion: status.latestVersion,
58
+ updateAvailable: false,
59
+ checkedAt: Date.now(),
60
+ };
61
+ return { version: status.latestVersion, restartRequired: true };
62
+ }
63
+ async runCheck(signal) {
64
+ const checkedAt = Date.now();
65
+ let currentVersion = '0.0.0';
66
+ try {
67
+ currentVersion = await readPackageVersion(this.packageRoot);
68
+ const latestVersion = await this.fetchLatest(signal);
69
+ const status = {
70
+ currentVersion,
71
+ latestVersion,
72
+ updateAvailable: compareVersions(latestVersion, currentVersion) > 0,
73
+ checkedAt,
74
+ };
75
+ this.cached = status;
76
+ return status;
77
+ }
78
+ catch (error) {
79
+ const status = {
80
+ currentVersion,
81
+ latestVersion: '',
82
+ updateAvailable: false,
83
+ checkedAt,
84
+ error: `Unable to check npm for updates: ${error instanceof Error ? error.message : String(error)}`,
85
+ };
86
+ this.cached = status;
87
+ return status;
88
+ }
89
+ }
90
+ }
91
+ export async function fetchLatestVersion(signal) {
92
+ const timeout = AbortSignal.timeout(CHECK_TIMEOUT_MS);
93
+ const response = await fetch(NPM_LATEST_URL, {
94
+ headers: { accept: 'application/vnd.npm.install-v1+json' },
95
+ signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
96
+ });
97
+ if (!response.ok)
98
+ throw new Error(`npm registry returned HTTP ${String(response.status)}`);
99
+ const body = await response.json();
100
+ if (typeof body.version !== 'string' || !isVersion(body.version))
101
+ throw new Error('npm registry returned an invalid version');
102
+ return body.version;
103
+ }
104
+ export async function readPackageVersion(packageRoot = PACKAGE_ROOT) {
105
+ const manifest = await readJson(join(packageRoot, 'package.json'));
106
+ if (typeof manifest.version !== 'string' || !isVersion(manifest.version))
107
+ throw new Error('installed package has an invalid version');
108
+ return manifest.version;
109
+ }
110
+ /** Find the profile that owns this exact installed package, including pnpm links. */
111
+ export async function findOwningProfileDir(packageRoot = PACKAGE_ROOT, configuredDshHome) {
112
+ const resolvedRoot = resolve(packageRoot);
113
+ for (let cursor = resolvedRoot; dirname(cursor) !== cursor; cursor = dirname(cursor)) {
114
+ if (await isOwningProfile(cursor, resolvedRoot))
115
+ return cursor;
116
+ }
117
+ const dshHome = resolve(configuredDshHome || process.env.DSH_HOME || join(homedir(), '.dsh'));
118
+ const profilesRoot = join(dshHome, 'profiles');
119
+ let entries = [];
120
+ try {
121
+ entries = await readdir(profilesRoot, { withFileTypes: true });
122
+ }
123
+ catch { /* handled below */ }
124
+ const matches = [];
125
+ for (const entry of entries) {
126
+ if (!entry.isDirectory())
127
+ continue;
128
+ const profileDir = join(profilesRoot, entry.name);
129
+ if (await isOwningProfile(profileDir, resolvedRoot))
130
+ matches.push(profileDir);
131
+ }
132
+ if (matches.length === 1)
133
+ return matches[0];
134
+ if (matches.length > 1)
135
+ throw new Error('more than one DSH profile points to this installation; update it from the command line');
136
+ throw new Error('the owning DSH profile could not be identified; update it with dsh plugin --profile <name> add dsh-reference-anything@latest');
137
+ }
138
+ export async function installPackageVersion(profileDir, version, signal) {
139
+ if (!isVersion(version))
140
+ throw new Error('refusing to install an invalid package version');
141
+ try {
142
+ await execFile('pnpm', ['add', `${PACKAGE_NAME}@${version}`], {
143
+ cwd: profileDir,
144
+ encoding: 'utf8',
145
+ timeout: UPDATE_TIMEOUT_MS,
146
+ maxBuffer: 8 * 1024 * 1024,
147
+ windowsHide: true,
148
+ shell: process.platform === 'win32',
149
+ signal,
150
+ });
151
+ }
152
+ catch (error) {
153
+ const detail = error;
154
+ const output = String(detail.stderr || detail.stdout || '').trim().slice(0, 2_000);
155
+ throw new Error(output || 'pnpm could not update the DSH profile package', { cause: error });
156
+ }
157
+ }
158
+ export function compareVersions(left, right) {
159
+ if (!isVersion(left) || !isVersion(right))
160
+ throw new Error('cannot compare an invalid package version');
161
+ const [leftCore, leftPre] = left.split('-', 2);
162
+ const [rightCore, rightPre] = right.split('-', 2);
163
+ const a = leftCore.split('.').map(Number);
164
+ const b = rightCore.split('.').map(Number);
165
+ for (let index = 0; index < 3; index++) {
166
+ if (a[index] !== b[index])
167
+ return (a[index] ?? 0) > (b[index] ?? 0) ? 1 : -1;
168
+ }
169
+ if (leftPre === rightPre)
170
+ return 0;
171
+ if (leftPre === undefined)
172
+ return 1;
173
+ if (rightPre === undefined)
174
+ return -1;
175
+ return leftPre.localeCompare(rightPre, 'en', { numeric: true });
176
+ }
177
+ function isVersion(value) {
178
+ return /^\d+\.\d+\.\d+(?:-[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?$/.test(value);
179
+ }
180
+ async function isOwningProfile(profileDir, packageRoot) {
181
+ let manifest;
182
+ try {
183
+ manifest = await readJson(join(profileDir, 'package.json'));
184
+ }
185
+ catch {
186
+ return false;
187
+ }
188
+ const dsh = manifest.dsh;
189
+ const dependencies = manifest.dependencies;
190
+ if (!dsh?.profile || typeof dependencies?.[PACKAGE_NAME] !== 'string')
191
+ return false;
192
+ const installedRoot = join(profileDir, 'node_modules', PACKAGE_NAME);
193
+ try {
194
+ await access(join(installedRoot, 'package.json'));
195
+ return await realpath(installedRoot) === await realpath(packageRoot);
196
+ }
197
+ catch {
198
+ return false;
199
+ }
200
+ }
201
+ async function readJson(path) {
202
+ return JSON.parse(await readFile(path, 'utf8'));
203
+ }
@@ -49,23 +49,6 @@ function assertResult(result, domain, operation) {
49
49
  return result.rows
50
50
  }
51
51
 
52
- /**
53
- * Release only the one-shot task tab. Browser Bridge deliberately keeps its
54
- * empty adapter container alive so the next sync can reuse it; if the user
55
- * closes that container, Browser Bridge recreates it on demand.
56
- */
57
- async function inTemporaryTab(page, task) {
58
- try {
59
- return await task()
60
- } finally {
61
- try {
62
- await page.closeTab?.()
63
- } catch {
64
- // Preserve the provider result/error if the browser closed first.
65
- }
66
- }
67
- }
68
-
69
52
  export function registerProvider(config) {
70
53
  const browserSession = SYNC_BROWSER_SESSION
71
54
 
@@ -81,15 +64,15 @@ export function registerProvider(config) {
81
64
  navigateBefore: false,
82
65
  args: [],
83
66
  columns: ['identity'],
84
- func: async (page) => inTemporaryTab(page, async () => {
85
- await page.goto(config.home, { settleMs: 500 })
86
- const result = await evaluate(page, config.whoamiScript, {}, `${config.site} whoami`)
87
- if (result?.code === 'AUTH') throw new AuthRequiredError(config.domain)
67
+ func: async (page) => {
68
+ await page.goto(config.home, { settleMs: 500 })
69
+ const result = await evaluate(page, config.whoamiScript, {}, `${config.site} whoami`)
70
+ if (result?.code === 'AUTH') throw new AuthRequiredError(config.domain)
88
71
  if (result?.ok !== true || typeof result.identity !== 'string' || !result.identity) {
89
72
  throw new CommandExecutionError(`${config.site} whoami: ${result?.message || 'stable account identity unavailable'}`)
90
- }
91
- return [{ identity: result.identity }]
92
- }),
73
+ }
74
+ return [{ identity: result.identity }]
75
+ },
93
76
  })
94
77
 
95
78
  cli({
@@ -107,7 +90,7 @@ export function registerProvider(config) {
107
90
  help: 'ISO 8601 instant; stop paging once the listing predates it. Honoured only while the provider pages newest-first',
108
91
  }, { name: 'accountScope', help: 'Previously observed hashed account scope; mismatch forces a full listing' }],
109
92
  columns: SYNC_INDEX_COLUMNS,
110
- func: async (page, kwargs) => inTemporaryTab(page, async () => {
93
+ func: async (page, kwargs) => {
111
94
  const since = String(kwargs.since || '').trim()
112
95
  if (since && Number.isNaN(Date.parse(since))) throw new ArgumentError('since must be an ISO 8601 instant')
113
96
  await page.goto(config.home, { settleMs: 1200 })
@@ -127,7 +110,7 @@ export function registerProvider(config) {
127
110
  { kind: 'identity', identity: identity.identity, sinceApplied: effectiveSince },
128
111
  ...rows.map(row => ({ kind: 'conversation', identity: '', sinceApplied: '', ...row })),
129
112
  ]
130
- }),
113
+ },
131
114
  })
132
115
 
133
116
  cli({
@@ -145,17 +128,17 @@ export function registerProvider(config) {
145
128
  help: 'ISO 8601 instant; stop paging once the listing predates it. Honoured only while the provider pages newest-first',
146
129
  }],
147
130
  columns: HISTORY_COLUMNS,
148
- func: async (page, kwargs) => inTemporaryTab(page, async () => {
149
- const since = String(kwargs.since || '').trim()
131
+ func: async (page, kwargs) => {
132
+ const since = String(kwargs.since || '').trim()
150
133
  if (since && Number.isNaN(Date.parse(since))) throw new ArgumentError('since must be an ISO 8601 instant')
151
134
  await page.goto(config.home, { settleMs: 1200 })
152
135
  const rows = assertResult(
153
136
  await evaluate(page, config.historyScript, { since }, `${config.site} history-all`),
154
137
  config.domain,
155
138
  `${config.site} history-all`,
156
- )
139
+ )
157
140
  return rows
158
- }),
141
+ },
159
142
  })
160
143
 
161
144
  cli({
@@ -173,7 +156,7 @@ export function registerProvider(config) {
173
156
  { name: 'accountScope', help: 'Expected hashed account scope; mismatch refuses the read' },
174
157
  ],
175
158
  columns: DETAIL_COLUMNS,
176
- func: async (page, kwargs) => inTemporaryTab(page, async () => {
159
+ func: async (page, kwargs) => {
177
160
  const id = String(kwargs.id || '').trim()
178
161
  if (!id) throw new ArgumentError('id must be a non-empty conversation id')
179
162
  await page.goto(config.home, { settleMs: 600 })
@@ -194,10 +177,10 @@ export function registerProvider(config) {
194
177
  await page.goto(config.conversationUrl(id), { settleMs: 1800 })
195
178
  result = await evaluate(page, config.fallbackScript, { id }, `${config.site} detail fallback`)
196
179
  }
197
- const rows = assertResult(result, config.domain, `${config.site} detail`)
198
- if (rows.length === 0) throw new EmptyResultError(`${config.site} detail`, `No messages found for ${id}`)
199
- return rows
200
- }),
180
+ const rows = assertResult(result, config.domain, `${config.site} detail`)
181
+ if (rows.length === 0) throw new EmptyResultError(`${config.site} detail`, `No messages found for ${id}`)
182
+ return rows
183
+ },
201
184
  })
202
185
 
203
186
  cli({
@@ -216,8 +199,8 @@ export function registerProvider(config) {
216
199
  { name: 'maxBytes', type: 'int', default: 26214400, help: 'Maximum decoded bytes' },
217
200
  ],
218
201
  columns: ['attachmentId', 'name', 'mimeType', 'size', 'status', 'localPath'],
219
- func: async (page, kwargs) => inTemporaryTab(page, async () => {
220
- const locator = String(kwargs.locator || '').trim()
202
+ func: async (page, kwargs) => {
203
+ const locator = String(kwargs.locator || '').trim()
221
204
  const output = resolve(String(kwargs.output || ''))
222
205
  const maxBytes = Number(kwargs.maxBytes ?? 26_214_400)
223
206
  if (!locator) throw new ArgumentError('locator must not be empty')
@@ -243,16 +226,21 @@ export function registerProvider(config) {
243
226
  name: result.name || output.split(/[\\/]/).pop() || 'attachment',
244
227
  mimeType: result.mimeType || 'application/octet-stream',
245
228
  size: bytes.byteLength,
246
- status: 'available',
247
- localPath: output,
248
- }]
249
- }),
229
+ status: 'available',
230
+ localPath: output,
231
+ }]
232
+ },
250
233
  })
251
234
  }
252
235
 
253
- /** Browser Bridge lifecycle contract shared by every web-chat sync command. */
236
+ /**
237
+ * Keep one stable, background Browser Bridge session per provider site.
238
+ * OpenCLI maps persistent sessions to `site:<config.site>` and keeps their tab
239
+ * lease after both successful and failed commands, so later sync work reuses
240
+ * the same tab. If the user closes it manually, OpenCLI recreates it on demand.
241
+ */
254
242
  export const SYNC_BROWSER_SESSION = Object.freeze({
255
- siteSession: 'ephemeral',
243
+ siteSession: 'persistent',
256
244
  defaultWindowMode: 'background',
257
245
  })
258
246
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-chat-history",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "opencli": ">=1.8.6",
5
5
  "description": "Full-history read adapters used by dsh-reference-anything"
6
6
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencli-plugin-dsh-chat-history",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "type": "module",
5
5
  "peerDependencies": {
6
6
  "@jackwener/opencli": ">=1.8.6"