@msvesper/vesper-link 0.1.4 → 0.1.5

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/LICENSE.md CHANGED
@@ -1,44 +1,44 @@
1
- # Vesper Link End User License Agreement (EULA)
2
-
3
- **Copyright (c) 2026 VSWork. All Rights Reserved.**
4
-
5
- ## 1. Grant of License
6
-
7
- This End User License Agreement ("Agreement") grants you ("User") a limited, non-exclusive, non-transferable, revocable license to install and use Vesper Link ("Software") for personal or internal business purposes, subject to the terms below.
8
-
9
- ## 2. Restrictions
10
-
11
- You may NOT:
12
- - Copy, modify, adapt, translate, or create derivative works of the Software
13
- - Reverse engineer, decompile, disassemble, or attempt to derive the source code
14
- - Redistribute, sublicense, rent, lease, or lend the Software to any third party
15
- - Remove or alter any proprietary notices, labels, or marks on the Software
16
- - Use the Software for any unlawful purpose or in violation of any applicable laws
17
-
18
- ## 3. Intellectual Property
19
-
20
- The Software, including all copies, modifications, and derivative works, is the exclusive property of VSWork and is protected by copyright and other intellectual property laws. This Agreement does not grant you any ownership rights to the Software.
21
-
22
- ## 4. No Warranty
23
-
24
- THE SOFTWARE IS PROVIDED "AS IS" WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE SOFTWARE IS WITH YOU.
25
-
26
- ## 5. Limitation of Liability
27
-
28
- IN NO EVENT SHALL VSWORK BE LIABLE FOR ANY INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, LOSS OF DATA, BUSINESS INTERRUPTION, OR LOSS OF PROFITS) ARISING OUT OF OR IN CONNECTION WITH THIS AGREEMENT OR THE USE OF THE SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
29
-
30
- ## 6. Termination
31
-
32
- This license is effective until terminated. Your rights under this license will terminate automatically without notice if you fail to comply with any of its terms. Upon termination, you must destroy all copies of the Software in your possession.
33
-
34
- ## 7. Governing Law
35
-
36
- This Agreement shall be governed by and construed in accordance with the laws of the jurisdiction in which VSWork operates, without regard to its conflict of law provisions.
37
-
38
- ## 8. Entire Agreement
39
-
40
- This Agreement constitutes the entire agreement between you and VSWork regarding the Software and supersedes all prior agreements and understandings, whether written or oral.
41
-
42
- ---
43
-
44
- By installing or using this Software, you acknowledge that you have read, understood, and agree to be bound by the terms of this Agreement.
1
+ # Vesper Link End User License Agreement (EULA)
2
+
3
+ **Copyright (c) 2026 VSWork. All Rights Reserved.**
4
+
5
+ ## 1. Grant of License
6
+
7
+ This End User License Agreement ("Agreement") grants you ("User") a limited, non-exclusive, non-transferable, revocable license to install and use Vesper Link ("Software") for personal or internal business purposes, subject to the terms below.
8
+
9
+ ## 2. Restrictions
10
+
11
+ You may NOT:
12
+ - Copy, modify, adapt, translate, or create derivative works of the Software
13
+ - Reverse engineer, decompile, disassemble, or attempt to derive the source code
14
+ - Redistribute, sublicense, rent, lease, or lend the Software to any third party
15
+ - Remove or alter any proprietary notices, labels, or marks on the Software
16
+ - Use the Software for any unlawful purpose or in violation of any applicable laws
17
+
18
+ ## 3. Intellectual Property
19
+
20
+ The Software, including all copies, modifications, and derivative works, is the exclusive property of VSWork and is protected by copyright and other intellectual property laws. This Agreement does not grant you any ownership rights to the Software.
21
+
22
+ ## 4. No Warranty
23
+
24
+ THE SOFTWARE IS PROVIDED "AS IS" WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE SOFTWARE IS WITH YOU.
25
+
26
+ ## 5. Limitation of Liability
27
+
28
+ IN NO EVENT SHALL VSWORK BE LIABLE FOR ANY INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, LOSS OF DATA, BUSINESS INTERRUPTION, OR LOSS OF PROFITS) ARISING OUT OF OR IN CONNECTION WITH THIS AGREEMENT OR THE USE OF THE SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
29
+
30
+ ## 6. Termination
31
+
32
+ This license is effective until terminated. Your rights under this license will terminate automatically without notice if you fail to comply with any of its terms. Upon termination, you must destroy all copies of the Software in your possession.
33
+
34
+ ## 7. Governing Law
35
+
36
+ This Agreement shall be governed by and construed in accordance with the laws of the jurisdiction in which VSWork operates, without regard to its conflict of law provisions.
37
+
38
+ ## 8. Entire Agreement
39
+
40
+ This Agreement constitutes the entire agreement between you and VSWork regarding the Software and supersedes all prior agreements and understandings, whether written or oral.
41
+
42
+ ---
43
+
44
+ By installing or using this Software, you acknowledge that you have read, understood, and agree to be bound by the terms of this Agreement.
package/README.md CHANGED
@@ -1,26 +1,45 @@
1
- # vesper-link
2
-
3
- **Vesper Memory Link — One Memory across all your devices.**
4
-
5
- `vesper-link` connects a supported Windows or Linux device to Vesper Memory. It can:
6
-
7
- - Sync completed Claude Code, GitHub Copilot CLI, and OpenAI Codex sessions to your Vesper Host.
8
- - Let full-access devices search and add Vesper Memory through MCP.
9
-
10
- ## Connect a Device
11
-
12
- On the Windows device running Vesper, open **Memory Links** and copy a connection command. Run it on the device you want to connect; no global installation is required.
13
-
14
- Node.js 20 or later is required. V1 supports Windows x64 and Linux x64.
15
-
16
- ## Manage the Connection
17
-
18
- ```bash
19
- npx --yes @msvesper/vesper-link@0.1.4 status
20
- npx --yes @msvesper/vesper-link@0.1.4 pause
21
- npx --yes @msvesper/vesper-link@0.1.4 resume
22
- npx --yes @msvesper/vesper-link@0.1.4 doctor
23
- npx --yes @msvesper/vesper-link@0.1.4 uninstall
24
- ```
25
-
26
- Session transcripts are sent to the Vesper Host selected during setup and retained there. They are not uploaded to a Vesper-operated cloud service. Uninstalling the connection does not delete the original transcripts or Memory already saved on the Host.
1
+ # vesper-link
2
+
3
+ **Vesper Memory Link — One Memory across all your devices.**
4
+
5
+ `vesper-link` connects a supported Windows or Linux device to Vesper Memory. It can:
6
+
7
+ - Sync completed Claude Code, GitHub Copilot CLI, VS Code Copilot Chat, and OpenAI Codex sessions to your Vesper Host.
8
+ - Let full-access devices search and add Vesper Memory through MCP.
9
+
10
+ ## Connect a Device
11
+
12
+ On the Windows device running Vesper, open **Memory Links** and copy a connection command. Run it on the device you want to connect; no global installation is required.
13
+
14
+ The Remote must already have Node.js 20+ and npm 9+. V1 supports Windows x64 and Linux x64.
15
+
16
+ Linux also requires the `crontab` command for periodic session sync. Install it before running the copied command, then ensure the service is running:
17
+
18
+ ```bash
19
+ # Ubuntu/Debian
20
+ sudo apt-get install cron
21
+ sudo systemctl enable --now cron
22
+
23
+ # Fedora/RHEL
24
+ sudo dnf install cronie
25
+ sudo systemctl enable --now crond
26
+ ```
27
+
28
+ Vesper Link does not use `sudo` or install operating-system packages for you.
29
+
30
+ VS Code Copilot Chat transcripts are discovered from local VS Code workspace storage and from VS Code Server storage on Remote SSH hosts. On Windows this includes `%APPDATA%\Code\User\workspaceStorage` and the default `%USERPROFILE%\.vscode-server\data\User\workspaceStorage`; on Linux it includes `~/.config/Code/User/workspaceStorage` and `~/.vscode-server/data/User/workspaceStorage`. `VSCODE_AGENT_FOLDER` is honored when set. Vesper Link reads the per-session `GitHub.copilot-chat/transcripts/*.jsonl` files, not Copilot's global `session-store.db`.
31
+
32
+ ## Manage the Connection
33
+
34
+ ```bash
35
+ npx --yes @msvesper/vesper-link@host-v1 upgrade
36
+ npx --yes @msvesper/vesper-link@host-v1 status
37
+ npx --yes @msvesper/vesper-link@host-v1 pause
38
+ npx --yes @msvesper/vesper-link@host-v1 resume
39
+ npx --yes @msvesper/vesper-link@host-v1 doctor
40
+ npx --yes @msvesper/vesper-link@host-v1 uninstall
41
+ ```
42
+
43
+ `upgrade` reads the existing Host and access role from `~/.vesper-link/`, replaces the managed runtime and integrations transactionally, and preserves the Remote identity and queued work. It accepts no Host or auth key. If the existing installation, configuration, or Tailscale identity is missing or inconsistent, it stops without relinking. Resume a paused Link before upgrading.
44
+
45
+ Session transcripts are sent to the Vesper Host selected during setup and retained there. They are not uploaded to a Vesper-operated cloud service. Uninstalling the connection does not delete the original transcripts or Memory already saved on the Host.
package/dist/cli.js CHANGED
@@ -1,17 +1,18 @@
1
1
  #!/usr/bin/env node
2
- import { writeConfig } from './config.js';
2
+ import * as fs from 'node:fs/promises';
3
+ import { readConfig, writeConfig } from './config.js';
3
4
  import { getDoctorReport, getStatusReport } from './diagnostics.js';
4
5
  import { resolveHookReport } from './hook-report.js';
5
6
  import { pauseIntegrations, resetRemoteIdentity, resumeIntegrations, uninstallIntegrations } from './installation.js';
6
7
  import { parseInstallArguments } from './install-args.js';
7
- import { installVesperLink } from './installer.js';
8
+ import { installVesperLink, upgradeVesperLink } from './installer.js';
8
9
  import { runMemoryMcp } from './memory-mcp.js';
9
10
  import { appendSyncLog } from './operational-log.js';
10
11
  import { writePendingMarker } from './pending.js';
11
12
  import { resolveLinkPaths } from './paths.js';
12
13
  import { runSyncOnce } from './sync.js';
13
14
  import { VESPER_LINK_VERSION } from './version.js';
14
- const USAGE = 'Usage: vesper-link <configure|report-session|sync-once|scheduled-sync|mcp|install|pause|resume|uninstall|reset-identity|status|doctor>';
15
+ const USAGE = 'Usage: vesper-link <configure|report-session|sync-once|scheduled-sync|mcp|install|upgrade|pause|resume|uninstall|reset-identity|status|doctor>';
15
16
  function flags(args) {
16
17
  const result = new Map();
17
18
  for (let index = 0; index < args.length; index += 1) {
@@ -51,7 +52,15 @@ async function main() {
51
52
  if (!host) {
52
53
  throw new Error('Usage: vesper-link configure --host <url>');
53
54
  }
54
- const config = await writeConfig(paths.configPath, host);
55
+ const existing = await fs.access(paths.configPath)
56
+ .then(() => readConfig(paths.configPath))
57
+ .catch((error) => {
58
+ if (error.code === 'ENOENT') {
59
+ return undefined;
60
+ }
61
+ throw error;
62
+ });
63
+ const config = await writeConfig(paths.configPath, host, process.env.VSCODE_AGENT_FOLDER ?? existing?.vscodeAgentFolder);
55
64
  process.stdout.write(`${JSON.stringify(config)}\n`);
56
65
  return;
57
66
  }
@@ -116,6 +125,17 @@ async function main() {
116
125
  })}\n`);
117
126
  return;
118
127
  }
128
+ if (command === 'upgrade') {
129
+ if (args.length > 0) {
130
+ throw new Error('Usage: vesper-link upgrade');
131
+ }
132
+ const result = await upgradeVesperLink({ paths });
133
+ process.stdout.write(`${JSON.stringify({
134
+ upgraded: true, runtimeVersion: result.state.runtime.version,
135
+ sourceTrust: result.state.sourceTrust, firstSyncStarted: result.firstSyncStarted,
136
+ })}\n`);
137
+ return;
138
+ }
119
139
  if (command === 'uninstall') {
120
140
  process.stdout.write(`${JSON.stringify({ removed: await uninstallIntegrations(paths) })}\n`);
121
141
  return;
@@ -16,6 +16,42 @@ function parseYamlScalars(text) {
16
16
  }
17
17
  return values;
18
18
  }
19
+ async function readVscodeWorkspace(workspaceDir, context) {
20
+ try {
21
+ const workspace = JSON.parse(await fs.readFile(path.join(workspaceDir, 'workspace.json'), 'utf8'));
22
+ const cwdUri = typeof workspace.folder === 'string'
23
+ ? workspace.folder
24
+ : typeof workspace.workspace === 'string' ? workspace.workspace : undefined;
25
+ if (!cwdUri) {
26
+ return {};
27
+ }
28
+ let projectPath = cwdUri;
29
+ try {
30
+ projectPath = decodeURIComponent(new URL(cwdUri).pathname).replace(/^\/([A-Za-z]:\/)/, '$1');
31
+ }
32
+ catch {
33
+ // Keep the opaque location for project grouping when it is not a valid URL.
34
+ }
35
+ return { cwdUri, projectKey: pathToProjectKey(projectPath) };
36
+ }
37
+ catch (error) {
38
+ if (error.code !== 'ENOENT') {
39
+ context.reportIssue?.({ provider: 'github-copilot', code: 'workspace_metadata_unreadable', message: String(error) });
40
+ }
41
+ return {};
42
+ }
43
+ }
44
+ function preferNewest(candidates) {
45
+ const byId = new Map();
46
+ for (const candidate of candidates) {
47
+ const current = byId.get(candidate.providerSessionId);
48
+ if (!current || candidate.mtimeMs > current.mtimeMs
49
+ || (candidate.mtimeMs === current.mtimeMs && candidate.transcriptPath < current.transcriptPath)) {
50
+ byId.set(candidate.providerSessionId, candidate);
51
+ }
52
+ }
53
+ return [...byId.values()];
54
+ }
19
55
  export class CopilotCollector {
20
56
  async collect(context) {
21
57
  const files = await walkFiles(context.copilotSessionsDir, (filePath) => path.basename(filePath) === 'events.jsonl');
@@ -59,6 +95,51 @@ export class CopilotCollector {
59
95
  context.reportIssue?.({ provider: 'github-copilot', code: 'source_unreadable', message: String(error) });
60
96
  }
61
97
  }
62
- return candidates;
98
+ for (const workspaceStorageDir of context.copilotVscodeWorkspaceStorageDirs) {
99
+ let workspaces;
100
+ try {
101
+ workspaces = await fs.readdir(workspaceStorageDir, { withFileTypes: true });
102
+ }
103
+ catch (error) {
104
+ if (['ENOENT', 'EACCES', 'EPERM'].includes(String(error.code))) {
105
+ continue;
106
+ }
107
+ throw error;
108
+ }
109
+ for (const workspace of workspaces) {
110
+ if (!workspace.isDirectory()) {
111
+ continue;
112
+ }
113
+ const workspaceDir = path.join(workspaceStorageDir, workspace.name);
114
+ const transcriptDir = path.join(workspaceDir, 'GitHub.copilot-chat', 'transcripts');
115
+ const transcripts = await walkFiles(transcriptDir, (filePath) => path.extname(filePath).toLowerCase() === '.jsonl');
116
+ if (transcripts.length === 0) {
117
+ continue;
118
+ }
119
+ const metadata = await readVscodeWorkspace(workspaceDir, context);
120
+ for (const transcriptPath of transcripts) {
121
+ try {
122
+ const stat = await fs.stat(transcriptPath);
123
+ candidates.push({
124
+ provider: 'github-copilot',
125
+ format: 'github-copilot-events-jsonl',
126
+ formatVersion: 'observed-v1',
127
+ providerSessionId: 'vscode:' + path.basename(transcriptPath, path.extname(transcriptPath)),
128
+ transcriptPath,
129
+ ...metadata,
130
+ sourceCreatedAt: stat.birthtime.toISOString(),
131
+ sourceUpdatedAt: stat.mtime.toISOString(),
132
+ size: stat.size,
133
+ mtimeMs: stat.mtimeMs,
134
+ discovery: 'scan',
135
+ });
136
+ }
137
+ catch (error) {
138
+ context.reportIssue?.({ provider: 'github-copilot', code: 'source_unreadable', message: String(error) });
139
+ }
140
+ }
141
+ }
142
+ }
143
+ return preferNewest(candidates);
63
144
  }
64
145
  }
@@ -3,6 +3,7 @@ export interface CollectorContext {
3
3
  nowMs: number;
4
4
  claudeProjectsDir: string;
5
5
  copilotSessionsDir: string;
6
+ copilotVscodeWorkspaceStorageDirs: string[];
6
7
  codexHome: string;
7
8
  reportIssue?: (issue: CollectorIssue) => void;
8
9
  }
package/dist/config.d.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  export interface LinkConfig {
2
2
  schemaVersion: 1;
3
3
  host: string;
4
+ vscodeAgentFolder?: string;
4
5
  }
6
+ export declare function normalizeVscodeAgentFolder(value: string | undefined): string | undefined;
5
7
  export declare function normalizeHost(value: string): string;
6
8
  export declare function readConfig(configPath: string): Promise<LinkConfig>;
7
- export declare function writeConfig(configPath: string, host: string): Promise<LinkConfig>;
9
+ export declare function writeConfig(configPath: string, host: string, vscodeAgentFolder?: string): Promise<LinkConfig>;
package/dist/config.js CHANGED
@@ -1,5 +1,16 @@
1
1
  import { isIPv4 } from 'node:net';
2
+ import * as path from 'node:path';
2
3
  import { readJsonFile, writeJsonAtomic } from './atomic-json.js';
4
+ export function normalizeVscodeAgentFolder(value) {
5
+ if (value === undefined) {
6
+ return undefined;
7
+ }
8
+ const normalized = path.normalize(value).replace(/[\\/]+$/, '');
9
+ if (!normalized || !path.isAbsolute(normalized) || normalized === path.parse(normalized).root) {
10
+ throw new Error('VSCODE_AGENT_FOLDER must be an absolute non-root path');
11
+ }
12
+ return normalized;
13
+ }
3
14
  function isTailscaleIpv4(hostname) {
4
15
  if (!isIPv4(hostname)) {
5
16
  return false;
@@ -32,10 +43,20 @@ export async function readConfig(configPath) {
32
43
  if (!config || config.schemaVersion !== 1 || typeof config.host !== 'string') {
33
44
  throw new Error(`Missing or invalid config at ${configPath}; run vesper-link configure --host <url>`);
34
45
  }
35
- return { schemaVersion: 1, host: normalizeHost(config.host) };
46
+ if (config.vscodeAgentFolder !== undefined && typeof config.vscodeAgentFolder !== 'string') {
47
+ throw new Error(`Invalid VSCODE_AGENT_FOLDER in config at ${configPath}`);
48
+ }
49
+ return {
50
+ schemaVersion: 1,
51
+ host: normalizeHost(config.host),
52
+ vscodeAgentFolder: normalizeVscodeAgentFolder(config.vscodeAgentFolder),
53
+ };
36
54
  }
37
- export async function writeConfig(configPath, host) {
38
- const config = { schemaVersion: 1, host: normalizeHost(host) };
55
+ export async function writeConfig(configPath, host, vscodeAgentFolder) {
56
+ const config = {
57
+ schemaVersion: 1, host: normalizeHost(host),
58
+ vscodeAgentFolder: normalizeVscodeAgentFolder(vscodeAgentFolder),
59
+ };
39
60
  await writeJsonAtomic(configPath, config);
40
61
  return config;
41
62
  }
@@ -51,6 +51,7 @@ export declare function installManagedRuntime(options: {
51
51
  now?: Date;
52
52
  postInstallDoctor?: () => Promise<void>;
53
53
  faults?: InstallFaults;
54
+ vscodeAgentFolder?: string;
54
55
  }): Promise<InstalledState>;
55
56
  export declare function uninstallIntegrations(paths: LinkPaths, runner?: ProcessRunner, probeProcess?: (pid: number) => 'alive' | 'absent' | 'unknown'): Promise<boolean>;
56
57
  export declare function pauseIntegrations(paths: LinkPaths, runner?: ProcessRunner): Promise<boolean>;
@@ -2,6 +2,7 @@ import * as fs from 'node:fs/promises';
2
2
  import * as path from 'node:path';
3
3
  import { randomUUID } from 'node:crypto';
4
4
  import { readJsonFile, writeJsonAtomic } from './atomic-json.js';
5
+ import { normalizeVscodeAgentFolder } from './config.js';
5
6
  import { stableLauncherSource } from './managed-runtime.js';
6
7
  import { acquireSyncLock } from './lock.js';
7
8
  import { NodeProcessRunner } from './process-runner.js';
@@ -150,6 +151,14 @@ async function prepareInstall(options) {
150
151
  JSON.parse(snapshot.content.toString('utf8'));
151
152
  }
152
153
  }
154
+ let previousAgentFolder;
155
+ if (config.existed) {
156
+ const previousConfig = JSON.parse(config.content.toString('utf8'));
157
+ if (typeof previousConfig.vscodeAgentFolder === 'string') {
158
+ previousAgentFolder = previousConfig.vscodeAgentFolder;
159
+ }
160
+ }
161
+ const vscodeAgentFolder = normalizeVscodeAgentFolder(options.vscodeAgentFolder ?? previousAgentFolder);
153
162
  const installationId = previous?.installationId ?? randomUUID();
154
163
  const minuteOffset = previous?.minuteOffset ?? stableMinuteOffset(options.paths.homeDir);
155
164
  const scheduler = await prepareScheduler({
@@ -174,6 +183,7 @@ async function prepareInstall(options) {
174
183
  ...(previous?.createdProviderFiles ?? []),
175
184
  ...providerFiles.filter((snapshot) => !snapshot.existed).map((snapshot) => snapshot.filePath),
176
185
  ])],
186
+ vscodeAgentFolder,
177
187
  };
178
188
  }
179
189
  export async function installManagedRuntime(options) {
@@ -185,6 +195,7 @@ export async function installManagedRuntime(options) {
185
195
  paths: options.paths, runtime: options.runtime, host: options.host,
186
196
  sourceTrust: options.capabilities.sourceTrust, providers: options.providers,
187
197
  nodePath, platform, runner, now,
198
+ vscodeAgentFolder: options.vscodeAgentFolder,
188
199
  });
189
200
  const installationId = prepared.installationId;
190
201
  const pointer = {
@@ -196,7 +207,9 @@ export async function installManagedRuntime(options) {
196
207
  await ensureLauncher(options.paths);
197
208
  await writeJsonAtomic(options.paths.currentPath, pointer);
198
209
  await options.faults?.afterStep?.('current');
199
- await writeJsonAtomic(options.paths.configPath, { schemaVersion: 1, host: options.host });
210
+ await writeJsonAtomic(options.paths.configPath, {
211
+ schemaVersion: 1, host: options.host, vscodeAgentFolder: prepared.vscodeAgentFolder,
212
+ });
200
213
  await options.faults?.afterStep?.('config');
201
214
  providersApplied = true;
202
215
  const integrations = await applyProviderIntegrations({
@@ -38,6 +38,7 @@ export interface InstallerDependencies {
38
38
  onNeedsLogin?: (url: string) => void;
39
39
  requiredCompanion?: ManagedRuntime['companion'];
40
40
  bootstrapAuthKey?: string;
41
+ failIfLoginRequired?: boolean;
41
42
  }): Promise<RemoteTransport>;
42
43
  getHealth: typeof getHealth;
43
44
  getCapabilities: typeof getCapabilities;
@@ -56,4 +57,10 @@ export declare function installVesperLink(options: {
56
57
  pollIntervalMs?: number;
57
58
  signal?: AbortSignal;
58
59
  dependencies?: InstallerDependencies;
60
+ requireExistingIdentity?: boolean;
61
+ }): Promise<InstallResult>;
62
+ export declare function upgradeVesperLink(options: {
63
+ paths: LinkPaths;
64
+ runner?: ProcessRunner;
65
+ dependencies?: InstallerDependencies;
59
66
  }): Promise<InstallResult>;
package/dist/installer.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import * as fs from 'node:fs/promises';
2
2
  import { spawn } from 'node:child_process';
3
- import { normalizeHost } from './config.js';
3
+ import { normalizeHost, readConfig } from './config.js';
4
4
  import { getCapabilities, getHealth, SystemicUploadError } from './http-client.js';
5
- import { installManagedRuntime } from './installation.js';
5
+ import { installManagedRuntime, readInstalledState } from './installation.js';
6
6
  import { acquireSyncLock } from './lock.js';
7
7
  import { prepareManagedRuntime, validateInstallPrerequisites, validateManagedRuntime, } from './managed-runtime.js';
8
8
  import { NodeProcessRunner } from './process-runner.js';
@@ -58,6 +58,9 @@ export async function installVesperLink(options) {
58
58
  let bootstrapAuthKey = consumeBootstrapAuthKey(options.bootstrapAuthKey);
59
59
  options.bootstrapAuthKey = undefined;
60
60
  const automaticEnrollment = bootstrapAuthKey !== undefined;
61
+ if (options.requireExistingIdentity && automaticEnrollment) {
62
+ throw new Error('upgrade does not accept a Tailscale auth key');
63
+ }
61
64
  const host = normalizeHost(options.host);
62
65
  const companion = await dependencies.validatePrerequisites({ hostUrl: host, runner });
63
66
  const releaseCandidateLock = await acquireSyncLock(options.paths.lockPath);
@@ -72,13 +75,16 @@ export async function installVesperLink(options) {
72
75
  const progress = {
73
76
  schemaVersion: 1, state: 'enrolling', host, expectedRole: options.expectedRole, updatedAt: new Date().toISOString(),
74
77
  };
75
- await writeProgress(options.paths, progress);
78
+ if (!options.requireExistingIdentity) {
79
+ await writeProgress(options.paths, progress);
80
+ }
76
81
  let transport;
77
82
  try {
78
83
  transport = await dependencies.acquireTransport({
79
84
  paths: options.paths, hostUrl: host,
80
85
  requiredCompanion: runtime.companion,
81
86
  bootstrapAuthKey,
87
+ failIfLoginRequired: options.requireExistingIdentity,
82
88
  onNeedsLogin: (url) => {
83
89
  options.onNeedsLogin?.(url);
84
90
  },
@@ -102,6 +108,9 @@ export async function installVesperLink(options) {
102
108
  if (!(error instanceof SystemicUploadError) || error.httpStatus !== 403) {
103
109
  throw error;
104
110
  }
111
+ if (options.requireExistingIdentity) {
112
+ throw new Error('The existing Tailscale identity is no longer authorized; upgrade stopped without relinking');
113
+ }
105
114
  if (automaticEnrollment) {
106
115
  throw new AutomaticEnrollmentConflictError(progress);
107
116
  }
@@ -144,6 +153,7 @@ export async function installVesperLink(options) {
144
153
  await dependencies.validateRuntime(runtime);
145
154
  const candidateTransport = await dependencies.acquireTransport({
146
155
  paths: options.paths, hostUrl: host, requiredCompanion: runtime.companion,
156
+ failIfLoginRequired: options.requireExistingIdentity,
147
157
  });
148
158
  try {
149
159
  const [candidateHealth, candidateCapabilities] = await Promise.all([
@@ -160,10 +170,12 @@ export async function installVesperLink(options) {
160
170
  }
161
171
  installed = await dependencies.installManaged({
162
172
  paths: options.paths, runtime, host, capabilities, runner,
173
+ vscodeAgentFolder: process.env.VSCODE_AGENT_FOLDER,
163
174
  postInstallDoctor: async () => {
164
175
  await dependencies.validateRuntime(runtime);
165
176
  const postTransport = await dependencies.acquireTransport({
166
177
  paths: options.paths, hostUrl: host, requiredCompanion: runtime.companion,
178
+ failIfLoginRequired: options.requireExistingIdentity,
167
179
  });
168
180
  try {
169
181
  const [health, currentCapabilities] = await Promise.all([
@@ -190,6 +202,37 @@ export async function installVesperLink(options) {
190
202
  await dependencies.triggerFirstSync(options.paths);
191
203
  return { state: installed, firstSyncStarted: true };
192
204
  }
205
+ export async function upgradeVesperLink(options) {
206
+ const inheritedAuthKey = consumeBootstrapAuthKey(undefined);
207
+ if (inheritedAuthKey !== undefined) {
208
+ throw new Error('upgrade does not accept a Tailscale auth key');
209
+ }
210
+ const installed = await readInstalledState(options.paths);
211
+ if (!installed) {
212
+ throw new Error('No existing Vesper Link installation; use a Host-generated install command first');
213
+ }
214
+ if (installed.paused) {
215
+ throw new Error('Vesper Link is paused; resume it before upgrading');
216
+ }
217
+ const config = await readConfig(options.paths.configPath);
218
+ if (installed.host !== config.host) {
219
+ throw new Error('Installed Host does not match Vesper Link config; refusing to guess during upgrade');
220
+ }
221
+ const identityEntries = await fs.readdir(options.paths.tsnetDir).catch((error) => {
222
+ if (error.code === 'ENOENT') {
223
+ return [];
224
+ }
225
+ throw error;
226
+ });
227
+ if (identityEntries.length === 0) {
228
+ throw new Error('No existing Tailscale identity; upgrade stopped without relinking');
229
+ }
230
+ return installVesperLink({
231
+ paths: options.paths, host: config.host, expectedRole: installed.sourceTrust,
232
+ runner: options.runner, dependencies: options.dependencies, requireExistingIdentity: true,
233
+ pollAuthorization: false,
234
+ });
235
+ }
193
236
  async function waitForPoll(milliseconds, signal) {
194
237
  if (signal?.aborted) {
195
238
  throw signal.reason ?? new Error('Installation cancelled');
@@ -4,6 +4,7 @@ import { createRequire } from 'node:module';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { assertCompanionProtocol, resolveCompanion } from './companion.js';
6
6
  import { NodeProcessRunner } from './process-runner.js';
7
+ import { validateLinuxSchedulerPrerequisite } from './scheduler.js';
7
8
  async function metadata(filePath) {
8
9
  return JSON.parse(await fs.readFile(filePath, 'utf8'));
9
10
  }
@@ -34,21 +35,21 @@ async function copyDirectory(source, destination) {
34
35
  await fs.cp(source, destination, { recursive: true, force: false, errorOnExist: true });
35
36
  }
36
37
  export function stableLauncherSource() {
37
- return `#!/usr/bin/env node
38
- import fs from 'node:fs/promises';
39
- import path from 'node:path';
40
- import { fileURLToPath, pathToFileURL } from 'node:url';
41
- const stateDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
42
- const current = JSON.parse(await fs.readFile(path.join(stateDir, 'current.json'), 'utf8'));
43
- if (current.schemaVersion !== 1 || typeof current.runtimePath !== 'string') {
44
- throw new Error('vesper-link current runtime pointer is malformed');
45
- }
46
- const runtimeRoot = path.join(stateDir, 'runtime');
47
- const relative = path.relative(runtimeRoot, current.runtimePath);
48
- if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
49
- throw new Error('vesper-link current runtime escaped the managed runtime root');
50
- }
51
- await import(pathToFileURL(path.join(current.runtimePath, 'package', 'dist', 'cli.js')).href);
38
+ return `#!/usr/bin/env node
39
+ import fs from 'node:fs/promises';
40
+ import path from 'node:path';
41
+ import { fileURLToPath, pathToFileURL } from 'node:url';
42
+ const stateDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
43
+ const current = JSON.parse(await fs.readFile(path.join(stateDir, 'current.json'), 'utf8'));
44
+ if (current.schemaVersion !== 1 || typeof current.runtimePath !== 'string') {
45
+ throw new Error('vesper-link current runtime pointer is malformed');
46
+ }
47
+ const runtimeRoot = path.join(stateDir, 'runtime');
48
+ const relative = path.relative(runtimeRoot, current.runtimePath);
49
+ if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
50
+ throw new Error('vesper-link current runtime escaped the managed runtime root');
51
+ }
52
+ await import(pathToFileURL(path.join(current.runtimePath, 'package', 'dist', 'cli.js')).href);
52
53
  `;
53
54
  }
54
55
  export async function validateInstallPrerequisites(options) {
@@ -70,6 +71,9 @@ export async function validateInstallPrerequisites(options) {
70
71
  if (npm.code !== 0 || majorVersion(npm.stdout.trim()) < 9) {
71
72
  throw new Error(`A working npm 9 or newer is required: ${npm.stderr.trim() || npm.stdout.trim()}`);
72
73
  }
74
+ if (platform === 'linux') {
75
+ await validateLinuxSchedulerPrerequisite(runner);
76
+ }
73
77
  const companion = await (options.resolveSourceCompanion ?? (() => resolveCompanion(platform, architecture)))();
74
78
  await (options.probeCompanion ?? assertCompanionProtocol)(companion, protocolProbeEnvironment(process.env));
75
79
  return companion;
package/dist/paths.d.ts CHANGED
@@ -18,6 +18,8 @@ export interface LinkPaths {
18
18
  logsDir: string;
19
19
  claudeProjectsDir: string;
20
20
  copilotSessionsDir: string;
21
+ copilotVscodeWorkspaceStorageDirs: string[];
21
22
  codexHome: string;
22
23
  }
23
- export declare function resolveLinkPaths(environment?: NodeJS.ProcessEnv): LinkPaths;
24
+ export declare function vscodeAgentWorkspaceStorageDir(agentFolder: string): string;
25
+ export declare function resolveLinkPaths(environment?: NodeJS.ProcessEnv, platform?: NodeJS.Platform): LinkPaths;
package/dist/paths.js CHANGED
@@ -1,6 +1,9 @@
1
1
  import * as os from 'node:os';
2
2
  import * as path from 'node:path';
3
- export function resolveLinkPaths(environment = process.env) {
3
+ export function vscodeAgentWorkspaceStorageDir(agentFolder) {
4
+ return path.join(agentFolder, 'data', 'User', 'workspaceStorage');
5
+ }
6
+ export function resolveLinkPaths(environment = process.env, platform = process.platform) {
4
7
  const homeDir = environment.HOME || environment.USERPROFILE || os.homedir();
5
8
  const rawStateDir = path.normalize(environment.VESPER_LINK_HOME || path.join(homeDir, '.vesper-link'));
6
9
  const root = path.parse(rawStateDir).root;
@@ -9,6 +12,23 @@ export function resolveLinkPaths(environment = process.env) {
9
12
  }
10
13
  const stateDir = rawStateDir.replace(/[\\/]+$/, '');
11
14
  const codexHome = environment.CODEX_HOME || path.join(homeDir, '.codex');
15
+ const appData = environment.APPDATA || path.join(homeDir, 'AppData', 'Roaming');
16
+ const localWorkspaceStorageDirs = platform === 'win32'
17
+ ? [
18
+ path.join(appData, 'Code', 'User', 'workspaceStorage'),
19
+ path.join(appData, 'Code - Insiders', 'User', 'workspaceStorage'),
20
+ ]
21
+ : [
22
+ path.join(homeDir, '.config', 'Code', 'User', 'workspaceStorage'),
23
+ path.join(homeDir, '.config', 'Code - Insiders', 'User', 'workspaceStorage'),
24
+ ];
25
+ const agentRoots = environment.VSCODE_AGENT_FOLDER
26
+ ? [environment.VSCODE_AGENT_FOLDER]
27
+ : [path.join(homeDir, '.vscode-server'), path.join(homeDir, '.vscode-server-insiders')];
28
+ const copilotVscodeWorkspaceStorageDirs = [
29
+ ...localWorkspaceStorageDirs,
30
+ ...agentRoots.map(vscodeAgentWorkspaceStorageDir),
31
+ ];
12
32
  return {
13
33
  homeDir,
14
34
  stateDir,
@@ -31,6 +51,7 @@ export function resolveLinkPaths(environment = process.env) {
31
51
  logsDir: path.join(stateDir, 'logs'),
32
52
  claudeProjectsDir: path.join(homeDir, '.claude', 'projects'),
33
53
  copilotSessionsDir: path.join(homeDir, '.copilot', 'session-state'),
54
+ copilotVscodeWorkspaceStorageDirs,
34
55
  codexHome,
35
56
  };
36
57
  }
@@ -22,6 +22,7 @@ export type SchedulerSnapshot = {
22
22
  content: string;
23
23
  };
24
24
  export declare function stableMinuteOffset(homeDir: string, machineName?: string): number;
25
+ export declare function validateLinuxSchedulerPrerequisite(runner: ProcessRunner): Promise<void>;
25
26
  export declare function prepareScheduler(options: {
26
27
  platform?: NodeJS.Platform;
27
28
  runner?: ProcessRunner;
package/dist/scheduler.js CHANGED
@@ -14,12 +14,31 @@ function shellQuote(value) {
14
14
  function ownerToken(installationId) {
15
15
  return '--owner ' + installationId;
16
16
  }
17
+ async function runCron(runner, args, input) {
18
+ const environment = { ...process.env, LC_ALL: 'C', LANG: 'C' };
19
+ try {
20
+ return await runner.run('crontab', args, input, environment);
21
+ }
22
+ catch (error) {
23
+ const code = error.code;
24
+ if (code === 'ENOENT') {
25
+ throw new Error('Linux requires the crontab command for periodic session sync.\n'
26
+ + 'Ubuntu/Debian: sudo apt-get install cron\n'
27
+ + 'Fedora/RHEL: sudo dnf install cronie\n'
28
+ + 'Ensure the cron service is running, then rerun this command.');
29
+ }
30
+ if (code === 'EACCES') {
31
+ throw new Error('Cannot execute crontab. Check that the crontab executable has execute permission and is not on a filesystem '
32
+ + 'mounted with noexec, then rerun this command.');
33
+ }
34
+ throw error;
35
+ }
36
+ }
17
37
  export function stableMinuteOffset(homeDir, machineName = os.hostname()) {
18
38
  return Number.parseInt(createHash('sha256').update(machineName).update('\0').update(homeDir).digest('hex').slice(0, 8), 16) % 60;
19
39
  }
20
40
  async function readCron(runner) {
21
- const environment = { ...process.env, LC_ALL: 'C', LANG: 'C' };
22
- const result = await runner.run('crontab', ['-l'], undefined, environment);
41
+ const result = await runCron(runner, ['-l']);
23
42
  if (result.code === 0) {
24
43
  return result.stdout;
25
44
  }
@@ -33,12 +52,14 @@ function withoutOwnedCron(content) {
33
52
  return content.split(/\r?\n/).filter((line) => !line.trimEnd().endsWith(CRON_MARKER)).join('\n');
34
53
  }
35
54
  async function writeCron(runner, content) {
36
- const environment = { ...process.env, LC_ALL: 'C', LANG: 'C' };
37
- const result = await runner.run('crontab', ['-'], content, environment);
55
+ const result = await runCron(runner, ['-'], content);
38
56
  if (result.code !== 0) {
39
57
  throw new Error('Cannot update user crontab: ' + result.stderr.trim());
40
58
  }
41
59
  }
60
+ export async function validateLinuxSchedulerPrerequisite(runner) {
61
+ await readCron(runner);
62
+ }
42
63
  async function queryWindowsTask(runner, name) {
43
64
  return runner.run('schtasks.exe', ['/Query', '/TN', name, '/XML']);
44
65
  }
package/dist/sync.js CHANGED
@@ -6,7 +6,7 @@ import { getHealth, postManifest, putContent, SystemicUploadError, TaskUploadErr
6
6
  import { readInstalledState } from './installation.js';
7
7
  import { acquireSyncLock, SyncAlreadyRunningError } from './lock.js';
8
8
  import { readPendingMarkers, removePendingMarker } from './pending.js';
9
- import { resolveLinkPaths } from './paths.js';
9
+ import { resolveLinkPaths, vscodeAgentWorkspaceStorageDir } from './paths.js';
10
10
  import { isStableForUpload, selectQueueItems } from './queue.js';
11
11
  import { fingerprintStableSource, LocalTaskError, assertSourceUnchanged } from './source.js';
12
12
  import { clearSyncState, readSessionStates, readSyncState, writeSessionState, writeSyncState } from './state.js';
@@ -101,6 +101,10 @@ export async function runSyncOnce(options = {}) {
101
101
  nowMs,
102
102
  claudeProjectsDir: paths.claudeProjectsDir,
103
103
  copilotSessionsDir: paths.copilotSessionsDir,
104
+ copilotVscodeWorkspaceStorageDirs: [...new Set([
105
+ ...paths.copilotVscodeWorkspaceStorageDirs,
106
+ ...(config.vscodeAgentFolder ? [vscodeAgentWorkspaceStorageDir(config.vscodeAgentFolder)] : []),
107
+ ])],
104
108
  codexHome: paths.codexHome,
105
109
  reportIssue: (issue) => summary.collectorIssues.push(issue),
106
110
  };
@@ -20,6 +20,7 @@ export interface AcquireTransportOptions {
20
20
  probeCompanion?: (companion: CompanionInfo, environment: NodeJS.ProcessEnv) => Promise<void>;
21
21
  spawnChild?: (executable: string, args: string[], env: NodeJS.ProcessEnv) => ChildProcessWithoutNullStreams;
22
22
  onNeedsLogin?: (url: string) => void;
23
+ failIfLoginRequired?: boolean;
23
24
  bootstrapAuthKey?: string;
24
25
  startTimeoutMs?: number;
25
26
  }
package/dist/transport.js CHANGED
@@ -344,6 +344,10 @@ export async function acquireRemoteTransport(options) {
344
344
  fatalMessage = 'automatic enrollment key was not accepted; copy a fresh Memory Link command';
345
345
  child.kill('SIGTERM');
346
346
  }
347
+ else if (options.failIfLoginRequired) {
348
+ fatalMessage = 'the existing Tailscale identity is unavailable; upgrade stopped without relinking';
349
+ child.kill('SIGTERM');
350
+ }
347
351
  else {
348
352
  options.onNeedsLogin?.(event.loginUrl);
349
353
  }
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const VESPER_LINK_VERSION = "0.1.4";
1
+ export declare const VESPER_LINK_VERSION = "0.1.5";
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const VESPER_LINK_VERSION = '0.1.4';
1
+ export const VESPER_LINK_VERSION = '0.1.5';
package/package.json CHANGED
@@ -1,39 +1,39 @@
1
- {
2
- "name": "@msvesper/vesper-link",
3
- "version": "0.1.4",
4
- "description": "Connect agent sessions and tools to Vesper Memory",
5
- "type": "module",
6
- "bin": {
7
- "vesper-link": "dist/cli.js"
8
- },
9
- "files": [
10
- "dist/**/*.js",
11
- "dist/**/*.d.ts",
12
- "README.md",
13
- "LICENSE.md"
14
- ],
15
- "scripts": {
16
- "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
17
- "build": "npm run clean && tsc -p tsconfig.json",
18
- "typecheck": "tsc -p tsconfig.json --noEmit",
19
- "test": "npm run build && vitest run",
20
- "pack-check": "npm run build && npm pack --dry-run",
21
- "prepack": "npm run build"
22
- },
23
- "engines": {
24
- "node": ">=20"
25
- },
26
- "license": "SEE LICENSE IN LICENSE.md",
27
- "publishConfig": {
28
- "access": "public"
29
- },
30
- "optionalDependencies": {
31
- "@msvesper/vesper-net-linux-x64": "0.1.4",
32
- "@msvesper/vesper-net-win32-x64": "0.1.4"
33
- },
34
- "devDependencies": {
35
- "@types/node": "^22.15.0",
36
- "typescript": "^5.8.3",
37
- "vitest": "^3.2.4"
38
- }
39
- }
1
+ {
2
+ "name": "@msvesper/vesper-link",
3
+ "version": "0.1.5",
4
+ "description": "Connect agent sessions and tools to Vesper Memory",
5
+ "type": "module",
6
+ "bin": {
7
+ "vesper-link": "dist/cli.js"
8
+ },
9
+ "files": [
10
+ "dist/**/*.js",
11
+ "dist/**/*.d.ts",
12
+ "README.md",
13
+ "LICENSE.md"
14
+ ],
15
+ "scripts": {
16
+ "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
17
+ "build": "npm run clean && tsc -p tsconfig.json",
18
+ "typecheck": "tsc -p tsconfig.json --noEmit",
19
+ "test": "npm run build && vitest run",
20
+ "pack-check": "npm run build && npm pack --dry-run",
21
+ "prepack": "npm run build"
22
+ },
23
+ "engines": {
24
+ "node": ">=20"
25
+ },
26
+ "license": "SEE LICENSE IN LICENSE.md",
27
+ "publishConfig": {
28
+ "access": "public"
29
+ },
30
+ "optionalDependencies": {
31
+ "@msvesper/vesper-net-linux-x64": "0.1.5",
32
+ "@msvesper/vesper-net-win32-x64": "0.1.5"
33
+ },
34
+ "devDependencies": {
35
+ "@types/node": "^22.15.0",
36
+ "typescript": "^5.8.3",
37
+ "vitest": "^3.2.4"
38
+ }
39
+ }