@devicelink/dlink 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/README.md +24 -37
  2. package/index.js +115 -27
  3. package/index.js.map +2 -2
  4. package/package.json +1 -1
package/README.md CHANGED
@@ -1,8 +1,6 @@
1
1
  # @devicelink/dlink
2
2
 
3
- DeviceLink CLI agent — secure, local-first device access with explicit user consent.
4
-
5
- Share files from your machine with AI assistants (ChatGPT, Claude, etc.) through the Model Context Protocol (MCP). Your files never leave your device — they're streamed in real-time with your explicit permission.
3
+ DeviceLink lets you share folders from your computer with AI assistants (like ChatGPT) safely. Nothing is uploaded to the cloud to sit on a server: files stay on your machine until you allow access, and you stay in control.
6
4
 
7
5
  ## Install
8
6
 
@@ -10,58 +8,47 @@ Share files from your machine with AI assistants (ChatGPT, Claude, etc.) through
10
8
  npm install -g @devicelink/dlink
11
9
  ```
12
10
 
13
- ## Quick Start
11
+ ## Quick start
14
12
 
15
13
  ```bash
16
14
  # Sign in with your DeviceLink account
17
15
  dlink signin
18
16
 
19
- # Share a directory
17
+ # Pick a folder to share
20
18
  dlink share /path/to/project
21
19
 
22
- # That's it — ChatGPT can now access your shared files via MCP
20
+ # You're set: your AI can use that folder when you connect it through DeviceLink
23
21
  ```
24
22
 
25
23
  ## Commands
26
24
 
27
- | Command | Description |
28
- |---------|-------------|
29
- | `dlink signin` | Authenticate with DeviceLink |
30
- | `dlink signout` | Sign out and revoke device access |
31
- | `dlink share <path>` | Share a directory with MCP clients |
32
- | `dlink status` | Show connection status |
33
- | `dlink debug on/off` | Toggle debug logging |
34
-
35
- ## How It Works
36
-
37
- 1. **Sign in** — authenticate via Google OAuth using a device code flow
38
- 2. **Share directories** — choose which folders to make available
39
- 3. **Connect** — the agent maintains a secure WebSocket connection to the DeviceLink bridge
40
- 4. **Use** — AI assistants call tools (list files, read files, search, grep) through MCP
41
- 5. **Control** — you see every tool call in real-time and can disconnect anytime
42
-
43
- ## Security
25
+ | Command | What it does |
26
+ |---------|----------------|
27
+ | `dlink signin` | Sign in to DeviceLink |
28
+ | `dlink signout` | Sign out on this computer |
29
+ | `dlink share <path>` | Share a folder with your AI |
30
+ | `dlink status` | See whether you're connected |
44
31
 
45
- - **Files never stored on servers** — streamed in real-time only
46
- - **Ed25519 proof-of-possession** — device identity is cryptographically verified
47
- - **Encrypted credential storage** — AES-256-GCM with machine-bound keys
48
- - **Shared directory boundaries** — path traversal protection, symlink escape prevention
49
- - **Short-lived tokens** — access tokens expire in 30 seconds, auto-refreshed
32
+ ## How it works
50
33
 
51
- ## Configuration
34
+ 1. **Sign in** with your account (you'll use your browser when asked).
35
+ 2. **Pick folders** you want the AI to be able to read.
36
+ 3. **Stay connected** so requests can reach your computer securely.
37
+ 4. **Use your AI** as usual: it can list and read files in the folders you shared.
38
+ 5. **Stop anytime**: sign out or disconnect if you want access to end.
52
39
 
53
- Environment variables (optional):
40
+ ## Security & privacy
54
41
 
55
- | Variable | Default | Description |
56
- |----------|---------|-------------|
57
- | `DLINK_CONTROL_URL` | `https://api.devicelink.ai` | Controller API URL |
58
- | `DLINK_BRIDGE_URL` | `wss://api.devicelink.ai/ws` | Bridge WebSocket URL |
59
- | `DLINK_DEBUG` | `false` | Enable debug logging |
42
+ - Your files are not kept on our servers for browsing; access happens when you're connected and allow it.
43
+ - Your device is checked so only your account can use your shared folders.
44
+ - Sign-in details are stored encrypted on this computer.
45
+ - Shared folders are limited to what you picked, so the AI can't wander the rest of your disk.
46
+ - Access is short-lived and refreshed automatically so old keys don't stick around.
60
47
 
61
48
  ## Requirements
62
49
 
63
- - Node.js 20 or later
64
- - Internet connection for authentication and MCP bridge
50
+ - [Node.js](https://nodejs.org/) version 20 or newer
51
+ - Internet access to sign in and stay connected
65
52
 
66
53
  ## License
67
54
 
package/index.js CHANGED
@@ -692,7 +692,8 @@ var ConfigLoader = class {
692
692
  } catch {
693
693
  }
694
694
  }
695
- return [];
695
+ const cwd = process.cwd();
696
+ return [this.createDirectoryFromPath(cwd)];
696
697
  }
697
698
  getDevice() {
698
699
  const deviceId = this.getDeviceId();
@@ -961,6 +962,13 @@ var Engine2 = class {
961
962
  getBus() {
962
963
  return this.bus;
963
964
  }
965
+ hasRunningJobs() {
966
+ for (const [, count] of this.laneActive) {
967
+ if (count > 0)
968
+ return true;
969
+ }
970
+ return false;
971
+ }
964
972
  register(jobType) {
965
973
  const name = jobType.options.name;
966
974
  if (this.registry.has(name)) {
@@ -1587,7 +1595,7 @@ var LoadConfigJob = class extends JobType {
1587
1595
  );
1588
1596
  }
1589
1597
  async run(input, ctx) {
1590
- ctx.notifier.info("Loading configuration...");
1598
+ ctx.notifier.info("Loading configuration...", { scope: "system" /* System */ });
1591
1599
  const configLoader = ctx.engine.configurationContext.loader;
1592
1600
  const refreshRes = await ctx.engine.secrets.get("devicePrivateKey");
1593
1601
  configLoader.setPrivateKeyCache(refreshRes.ok && refreshRes.value ? refreshRes.value : void 0);
@@ -1610,13 +1618,14 @@ var LoadConfigJob = class extends JobType {
1610
1618
  return new LoadConfigOutput(true, config);
1611
1619
  }
1612
1620
  updateUi(config, ctx) {
1613
- ctx.notifier.info(`Control base URL: ${config.controlBaseUrl}`);
1614
- ctx.notifier.info(`Bridge WebSocket URL: ${config.bridgeWsUrl}`);
1615
- ctx.notifier.info(`Directories: ${config.directories.map((directory) => directory.path).join(", ")}`);
1616
- ctx.notifier.info(`Device: ${config.device?.id}`);
1617
- ctx.notifier.info(`Device name: ${config.device?.name}`);
1618
- ctx.notifier.info(`Device tag: ${config.device?.tag}`);
1619
- ctx.notifier.info(`Device keys public: ${config.device?.keys?.public ? "***" : "(none)"}`);
1621
+ const sys = { scope: "system" /* System */ };
1622
+ ctx.notifier.info(`Control base URL: ${config.controlBaseUrl}`, sys);
1623
+ ctx.notifier.info(`Bridge WebSocket URL: ${config.bridgeWsUrl}`, sys);
1624
+ ctx.notifier.info(`Directories: ${config.directories.map((directory) => directory.path).join(", ")}`, sys);
1625
+ ctx.notifier.info(`Device: ${config.device?.id}`, sys);
1626
+ ctx.notifier.info(`Device name: ${config.device?.name}`, sys);
1627
+ ctx.notifier.info(`Device tag: ${config.device?.tag}`, sys);
1628
+ ctx.notifier.info(`Device keys public: ${config.device?.keys?.public ? "***" : "(none)"}`, sys);
1620
1629
  }
1621
1630
  };
1622
1631
  var loadConfigJob = new LoadConfigJob();
@@ -3311,6 +3320,9 @@ var ConsoleOrchestrator = class {
3311
3320
  ctx.bus.emit("runtime:debug:changed", { enabled: false });
3312
3321
  ctx.notifier.success("Debug mode disabled");
3313
3322
  break;
3323
+ case "replace" /* Replace */:
3324
+ ctx.bus.emit("auth:session_conflict:resolve", { choice: "USE_NEW" });
3325
+ break;
3314
3326
  default:
3315
3327
  ctx.notifier.warn(`Command not implemented: ${input.kind}`);
3316
3328
  break;
@@ -3360,6 +3372,9 @@ var ConsoleOrchestrator = class {
3360
3372
  if (cmd === "doctor") {
3361
3373
  return { handled: true, kind: "doctor" /* Doctor */ };
3362
3374
  }
3375
+ if (cmd === "replace" || cmd === "yes" || cmd === "y") {
3376
+ return { handled: true, kind: "replace" /* Replace */ };
3377
+ }
3363
3378
  }
3364
3379
  return { handled: false };
3365
3380
  });
@@ -3809,7 +3824,6 @@ var WebSocketOrchestrator = class {
3809
3824
  };
3810
3825
  return {
3811
3826
  "config:cache:validated": async (_, ctx) => initSocket("config:cache:validated", ctx),
3812
- "auth:signin:completed": async (_, ctx) => initSocket("auth:signin:completed", ctx),
3813
3827
  "auth:signout:requested": async (_, ctx) => {
3814
3828
  ctx.notifier.info("WS: sign-out requested -> disconnecting");
3815
3829
  this.stopped = true;
@@ -3828,7 +3842,10 @@ var WebSocketOrchestrator = class {
3828
3842
  ctx.notifier.info("WS: reconnect requested by auth", { scope: "system" /* System */ });
3829
3843
  this.retryAttempt = 0;
3830
3844
  this.clearCountdown();
3831
- this.socket?.disconnect(false);
3845
+ if (this.socket) {
3846
+ this.socket.stop();
3847
+ this.socket = null;
3848
+ }
3832
3849
  this.ensureStarted(ctx, "auth:ws:reconnect");
3833
3850
  },
3834
3851
  // the only sender
@@ -3847,6 +3864,11 @@ var WebSocketOrchestrator = class {
3847
3864
  ctx.notifier.info(`WS: connect loop already running (reason=${reason})`, { scope: "system" /* System */ });
3848
3865
  return;
3849
3866
  }
3867
+ const ws = this.socket?.getSocket();
3868
+ if (ws && ws.readyState <= 1) {
3869
+ ctx.notifier.info(`WS: already connected/connecting, skipping (reason=${reason})`, { scope: "system" /* System */ });
3870
+ return;
3871
+ }
3850
3872
  this.connectLoopInFlight = this.startOrJoinConnectLoop(ctx, reason).finally(() => {
3851
3873
  this.connectLoopInFlight = null;
3852
3874
  });
@@ -3861,32 +3883,47 @@ var WebSocketOrchestrator = class {
3861
3883
  this.lastUrl = url;
3862
3884
  ctx.notifier.info(`WS: ensure connection (reason=${reason}) url=${url}`, { scope: "system" /* System */ });
3863
3885
  if (!this.socket) {
3864
- this.socket = new RecoverableSocket(url, {
3886
+ const sock = new RecoverableSocket(url, {
3865
3887
  abortSignal: ctx.signal,
3866
3888
  connectionTimeoutMs: 1e4,
3867
3889
  heartbeatIntervalMs: 2e4,
3868
3890
  pongTimeoutMs: 1e4
3869
3891
  });
3870
- this.socket.registerOnStateChangeCallback((state) => this.handleStateChange(state, ctx));
3871
- this.socket.registerOnErrorCallback((error) => {
3892
+ this.socket = sock;
3893
+ sock.registerOnStateChangeCallback((state) => this.handleStateChange(state, ctx));
3894
+ sock.registerOnErrorCallback((error) => {
3872
3895
  ctx.notifier.error(`WS: transport error: ${error.message}`);
3873
3896
  });
3874
- this.socket.registerOnConnectedCallback((_ws) => {
3897
+ sock.registerOnConnectedCallback((_ws) => {
3898
+ if (this.socket !== sock)
3899
+ return;
3875
3900
  this.retryAttempt = 0;
3876
3901
  this.clearCountdown();
3877
- ctx.notifier.success(`WS: connected (${url})`);
3902
+ ctx.notifier.success(ctx.flags.debug ? `WS: connected (${url})` : `WS: connected`);
3878
3903
  ctx.bus.emit("ws:connected", { url, attempt: 0 });
3879
3904
  });
3880
- this.socket.registerOnDisconnectedCallback((code, reasonStr) => {
3905
+ sock.registerOnDisconnectedCallback((code, reasonStr) => {
3906
+ if (this.socket !== sock) {
3907
+ ctx.notifier.info(`WS: ignoring stale disconnect (code=${code})`, { scope: "system" /* System */ });
3908
+ return;
3909
+ }
3881
3910
  ctx.notifier.warn(`WS: disconnected code=${code} reason=${reasonStr || "-"}`);
3882
3911
  ctx.bus.emit("ws:disconnected", { code, reason: reasonStr });
3883
3912
  if (code === 4001) {
3884
3913
  ctx.notifier.info("WS: auth disconnect (4001) -> waiting for auth", { scope: "system" /* System */ });
3885
3914
  return;
3886
3915
  }
3916
+ if (code === 4011) {
3917
+ this.stopped = true;
3918
+ this.socket?.stop();
3919
+ this.socket = null;
3920
+ return;
3921
+ }
3887
3922
  this.ensureStarted(ctx, "socket:disconnected");
3888
3923
  });
3889
- this.socket.registerOnMessageCallback((data) => {
3924
+ sock.registerOnMessageCallback((data) => {
3925
+ if (this.socket !== sock)
3926
+ return;
3890
3927
  const text = typeof data === "string" ? data : data.toString("utf8");
3891
3928
  ctx.bus.emit("ws:rx", { text });
3892
3929
  });
@@ -4557,6 +4594,7 @@ var AuthOrchestrator = class {
4557
4594
  connectionId = null;
4558
4595
  sessionId = null;
4559
4596
  accessToken;
4597
+ rejectedByConflict = false;
4560
4598
  refreshGroupKey = "auth:refresh";
4561
4599
  refreshCycle = 0;
4562
4600
  refreshScheduler = null;
@@ -4590,6 +4628,11 @@ var AuthOrchestrator = class {
4590
4628
  "Auth: signin completed; requesting ws reconnect",
4591
4629
  { scope: "system" /* System */ }
4592
4630
  );
4631
+ this.rejectedByConflict = false;
4632
+ if (this.refreshRetryScheduler) {
4633
+ this.refreshRetryScheduler.stop();
4634
+ this.refreshRetryScheduler = null;
4635
+ }
4593
4636
  this.ensureAccessExpPersisted(ctx, "signin");
4594
4637
  await this.ensureRefreshScheduling(ctx, "signin");
4595
4638
  ctx.bus.emit("auth:ws:reconnect", void 0);
@@ -4646,6 +4689,14 @@ var AuthOrchestrator = class {
4646
4689
  { scope: "system" /* System */ }
4647
4690
  );
4648
4691
  this.resetConnectionState();
4692
+ if (p.code === 4011) {
4693
+ ctx.notifier.warn(
4694
+ 'Connection rejected - another device is active on this workspace. Run "signin" to try again.'
4695
+ );
4696
+ this.rejectedByConflict = true;
4697
+ ctx.bus.emit("ws:disconnect", { reason: "REPLACEMENT_REJECTED" });
4698
+ return;
4699
+ }
4649
4700
  if (p.code === 4001) {
4650
4701
  const refreshRes = await ctx.engine.secrets.get("refreshToken");
4651
4702
  if (!refreshRes.ok || !refreshRes.value) {
@@ -4668,7 +4719,8 @@ var AuthOrchestrator = class {
4668
4719
  // --- boot path unchanged except it must schedule refresh always ---
4669
4720
  async onEventConfigLoaded(config, ctx) {
4670
4721
  ctx.notifier.info(
4671
- "Initializing with cached configuration"
4722
+ "Initializing with cached configuration",
4723
+ { scope: "system" /* System */ }
4672
4724
  );
4673
4725
  if (!config.device?.id) {
4674
4726
  ctx.notifier.error("Device ID is not available");
@@ -4697,7 +4749,6 @@ var AuthOrchestrator = class {
4697
4749
  ctx.notifier.warn("Auth: not signed in; run signin");
4698
4750
  return;
4699
4751
  }
4700
- ctx.bus.emit("auth:ws:reconnect", void 0);
4701
4752
  }
4702
4753
  isAccessLikelyUsable(ctx) {
4703
4754
  const jwt = this.accessToken;
@@ -4714,7 +4765,8 @@ var AuthOrchestrator = class {
4714
4765
  async waitForApproval(api, startResponse, ctx) {
4715
4766
  const deadline = Date.now() + startResponse.expires_in * 1e3;
4716
4767
  ctx.notifier.info(
4717
- "Waiting for approval... (streaming)"
4768
+ "Waiting for approval... (streaming)",
4769
+ { scope: "system" /* System */ }
4718
4770
  );
4719
4771
  try {
4720
4772
  return await api.deviceAuthStream({
@@ -4811,7 +4863,8 @@ var AuthOrchestrator = class {
4811
4863
  }
4812
4864
  if (streamResp.needs_proof) {
4813
4865
  ctx.notifier.info(
4814
- "Sending proof-of-possession signature..."
4866
+ "Sending proof-of-possession signature...",
4867
+ { scope: "system" /* System */ }
4815
4868
  );
4816
4869
  const privKeyObj = crypto8.createPrivateKey(privateKeyPem);
4817
4870
  const challengeBytes = Buffer.from(
@@ -4826,9 +4879,9 @@ var AuthOrchestrator = class {
4826
4879
  challenge: startResponse.challenge,
4827
4880
  signature_b64: signatureB64
4828
4881
  });
4829
- ctx.notifier.info("Proof-of-possession verified");
4882
+ ctx.notifier.info("Proof-of-possession verified", { scope: "system" /* System */ });
4830
4883
  }
4831
- ctx.notifier.info("Fetching tokens...");
4884
+ ctx.notifier.info("Fetching tokens...", { scope: "system" /* System */ });
4832
4885
  const tokenResp = await api.deviceAuthToken({
4833
4886
  device_code: startResponse.device_code
4834
4887
  });
@@ -4962,6 +5015,9 @@ var AuthOrchestrator = class {
4962
5015
  ctx.bus.emit("auth:session_conflict", msg);
4963
5016
  void this.handleSessionConflict(ctx, msg);
4964
5017
  return;
5018
+ case "quiesce":
5019
+ void this.handleQuiesce(ctx, msg);
5020
+ return;
4965
5021
  default:
4966
5022
  return;
4967
5023
  }
@@ -4972,14 +5028,41 @@ var AuthOrchestrator = class {
4972
5028
  this.conflictTimer = null;
4973
5029
  }
4974
5030
  this.pendingConflictRequest = msg;
5031
+ ctx.notifier.warn(
5032
+ `Another device is already connected to this workspace. Type "replace" to disconnect it, or this connection will be rejected in ${Math.round(msg.deadline_ms / 1e3)}s.`
5033
+ );
4975
5034
  const deadlineMs = Math.min(msg.deadline_ms, 15e3);
4976
5035
  this.conflictTimer = setTimeout(() => {
4977
5036
  this.conflictTimer = null;
4978
5037
  if (this.pendingConflictRequest?.new_session_id === msg.new_session_id) {
5038
+ ctx.notifier.info("No response - keeping existing device connected.");
4979
5039
  this.sendSessionConflictResolve(ctx, "KEEP_OLD");
4980
5040
  }
4981
5041
  }, deadlineMs);
4982
5042
  }
5043
+ async handleQuiesce(ctx, msg) {
5044
+ ctx.notifier.warn(
5045
+ `This device is being replaced by another connection. Finishing in-flight operations...`
5046
+ );
5047
+ const hasActiveJobs = ctx.engine.hasRunningJobs?.() ?? false;
5048
+ if (hasActiveJobs) {
5049
+ const deadline = Math.min(msg.deadline_ms - 2e3, 12e3);
5050
+ const start = Date.now();
5051
+ while (Date.now() - start < deadline) {
5052
+ if (!(ctx.engine.hasRunningJobs?.() ?? false))
5053
+ break;
5054
+ await new Promise((r) => setTimeout(r, 500));
5055
+ }
5056
+ }
5057
+ const status = ctx.engine.hasRunningJobs?.() ?? false ? "BUSY" : "IDLE";
5058
+ this.send(ctx, {
5059
+ type: "quiesce_ack",
5060
+ session_id: msg.session_id,
5061
+ status
5062
+ });
5063
+ ctx.notifier.info("Device replaced by new connection. Disconnecting.");
5064
+ this.socket?.stop();
5065
+ }
4983
5066
  sendSessionConflictResolve(ctx, choice) {
4984
5067
  const req = this.pendingConflictRequest;
4985
5068
  if (!req)
@@ -5241,6 +5324,10 @@ var AuthOrchestrator = class {
5241
5324
  if (this.accessToken && !ctx.engine.configurationContext.configuration.tokens?.accessTokenExpSec) {
5242
5325
  this.ensureAccessExpPersisted(ctx, reason);
5243
5326
  }
5327
+ if (this.refreshRetryScheduler) {
5328
+ this.refreshRetryScheduler.stop();
5329
+ this.refreshRetryScheduler = null;
5330
+ }
5244
5331
  this.scheduleSilentRefresh(ctx, reason);
5245
5332
  }
5246
5333
  async scheduleSilentRefresh(ctx, reason) {
@@ -7676,6 +7763,7 @@ var ToolsOrchestrator = class {
7676
7763
  const availableDirectoryIds = config.directories.map((r) => r.id).join(", ") || "none";
7677
7764
  const availableDirectoryPaths = config.directories.map((r) => r.path).join(", ") || "none";
7678
7765
  ctx.notifier.info(`Tool call: ${toolName} (job=${jobId}, directory_id=${directoryId || "MISSING"}, available_ids=[${availableDirectoryIds}], available_paths=[${availableDirectoryPaths}], directory_count=${config.directories.length})`, { scope: "system" /* System */ });
7766
+ ctx.notifier.info(`Executing: ${toolName}`);
7679
7767
  const toolStartTime = Date.now();
7680
7768
  try {
7681
7769
  const toolRegistry = new ToolExecuterRegistry(config);
@@ -7686,9 +7774,9 @@ var ToolsOrchestrator = class {
7686
7774
  const timing = calculateTiming(toolStartTime);
7687
7775
  const result = createToolResult(toolName, jobId, raw, timing.execution_sec);
7688
7776
  if (result.ok) {
7689
- ctx.notifier.success(result.toString(), { ts: Date.now(), kind: "success" /* Success */, scope: "system" /* System */ });
7777
+ ctx.notifier.success(result.toString());
7690
7778
  } else {
7691
- ctx.notifier.error(result.toString(), { scope: "system" /* System */ });
7779
+ ctx.notifier.error(result.toString());
7692
7780
  }
7693
7781
  } catch (e) {
7694
7782
  const toolResult = formatToolErrorResponse(jobId, toolName, e, toolStartTime);
@@ -7700,7 +7788,7 @@ var ToolsOrchestrator = class {
7700
7788
  error: e instanceof Error ? { code: "INTERNAL", message: e.message } : { code: "INTERNAL", message: String(e) }
7701
7789
  };
7702
7790
  const result = createToolResult(toolName, jobId, errorResult, timing.execution_sec);
7703
- ctx.notifier.error(result.toString(), { scope: "system" /* System */ });
7791
+ ctx.notifier.error(result.toString());
7704
7792
  }
7705
7793
  }
7706
7794
  };