@devicelink/dlink 0.2.2 → 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 +113 -26
  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
@@ -962,6 +962,13 @@ var Engine2 = class {
962
962
  getBus() {
963
963
  return this.bus;
964
964
  }
965
+ hasRunningJobs() {
966
+ for (const [, count] of this.laneActive) {
967
+ if (count > 0)
968
+ return true;
969
+ }
970
+ return false;
971
+ }
965
972
  register(jobType) {
966
973
  const name = jobType.options.name;
967
974
  if (this.registry.has(name)) {
@@ -1588,7 +1595,7 @@ var LoadConfigJob = class extends JobType {
1588
1595
  );
1589
1596
  }
1590
1597
  async run(input, ctx) {
1591
- ctx.notifier.info("Loading configuration...");
1598
+ ctx.notifier.info("Loading configuration...", { scope: "system" /* System */ });
1592
1599
  const configLoader = ctx.engine.configurationContext.loader;
1593
1600
  const refreshRes = await ctx.engine.secrets.get("devicePrivateKey");
1594
1601
  configLoader.setPrivateKeyCache(refreshRes.ok && refreshRes.value ? refreshRes.value : void 0);
@@ -1611,13 +1618,14 @@ var LoadConfigJob = class extends JobType {
1611
1618
  return new LoadConfigOutput(true, config);
1612
1619
  }
1613
1620
  updateUi(config, ctx) {
1614
- ctx.notifier.info(`Control base URL: ${config.controlBaseUrl}`);
1615
- ctx.notifier.info(`Bridge WebSocket URL: ${config.bridgeWsUrl}`);
1616
- ctx.notifier.info(`Directories: ${config.directories.map((directory) => directory.path).join(", ")}`);
1617
- ctx.notifier.info(`Device: ${config.device?.id}`);
1618
- ctx.notifier.info(`Device name: ${config.device?.name}`);
1619
- ctx.notifier.info(`Device tag: ${config.device?.tag}`);
1620
- 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);
1621
1629
  }
1622
1630
  };
1623
1631
  var loadConfigJob = new LoadConfigJob();
@@ -3312,6 +3320,9 @@ var ConsoleOrchestrator = class {
3312
3320
  ctx.bus.emit("runtime:debug:changed", { enabled: false });
3313
3321
  ctx.notifier.success("Debug mode disabled");
3314
3322
  break;
3323
+ case "replace" /* Replace */:
3324
+ ctx.bus.emit("auth:session_conflict:resolve", { choice: "USE_NEW" });
3325
+ break;
3315
3326
  default:
3316
3327
  ctx.notifier.warn(`Command not implemented: ${input.kind}`);
3317
3328
  break;
@@ -3361,6 +3372,9 @@ var ConsoleOrchestrator = class {
3361
3372
  if (cmd === "doctor") {
3362
3373
  return { handled: true, kind: "doctor" /* Doctor */ };
3363
3374
  }
3375
+ if (cmd === "replace" || cmd === "yes" || cmd === "y") {
3376
+ return { handled: true, kind: "replace" /* Replace */ };
3377
+ }
3364
3378
  }
3365
3379
  return { handled: false };
3366
3380
  });
@@ -3810,7 +3824,6 @@ var WebSocketOrchestrator = class {
3810
3824
  };
3811
3825
  return {
3812
3826
  "config:cache:validated": async (_, ctx) => initSocket("config:cache:validated", ctx),
3813
- "auth:signin:completed": async (_, ctx) => initSocket("auth:signin:completed", ctx),
3814
3827
  "auth:signout:requested": async (_, ctx) => {
3815
3828
  ctx.notifier.info("WS: sign-out requested -> disconnecting");
3816
3829
  this.stopped = true;
@@ -3829,7 +3842,10 @@ var WebSocketOrchestrator = class {
3829
3842
  ctx.notifier.info("WS: reconnect requested by auth", { scope: "system" /* System */ });
3830
3843
  this.retryAttempt = 0;
3831
3844
  this.clearCountdown();
3832
- this.socket?.disconnect(false);
3845
+ if (this.socket) {
3846
+ this.socket.stop();
3847
+ this.socket = null;
3848
+ }
3833
3849
  this.ensureStarted(ctx, "auth:ws:reconnect");
3834
3850
  },
3835
3851
  // the only sender
@@ -3848,6 +3864,11 @@ var WebSocketOrchestrator = class {
3848
3864
  ctx.notifier.info(`WS: connect loop already running (reason=${reason})`, { scope: "system" /* System */ });
3849
3865
  return;
3850
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
+ }
3851
3872
  this.connectLoopInFlight = this.startOrJoinConnectLoop(ctx, reason).finally(() => {
3852
3873
  this.connectLoopInFlight = null;
3853
3874
  });
@@ -3862,32 +3883,47 @@ var WebSocketOrchestrator = class {
3862
3883
  this.lastUrl = url;
3863
3884
  ctx.notifier.info(`WS: ensure connection (reason=${reason}) url=${url}`, { scope: "system" /* System */ });
3864
3885
  if (!this.socket) {
3865
- this.socket = new RecoverableSocket(url, {
3886
+ const sock = new RecoverableSocket(url, {
3866
3887
  abortSignal: ctx.signal,
3867
3888
  connectionTimeoutMs: 1e4,
3868
3889
  heartbeatIntervalMs: 2e4,
3869
3890
  pongTimeoutMs: 1e4
3870
3891
  });
3871
- this.socket.registerOnStateChangeCallback((state) => this.handleStateChange(state, ctx));
3872
- this.socket.registerOnErrorCallback((error) => {
3892
+ this.socket = sock;
3893
+ sock.registerOnStateChangeCallback((state) => this.handleStateChange(state, ctx));
3894
+ sock.registerOnErrorCallback((error) => {
3873
3895
  ctx.notifier.error(`WS: transport error: ${error.message}`);
3874
3896
  });
3875
- this.socket.registerOnConnectedCallback((_ws) => {
3897
+ sock.registerOnConnectedCallback((_ws) => {
3898
+ if (this.socket !== sock)
3899
+ return;
3876
3900
  this.retryAttempt = 0;
3877
3901
  this.clearCountdown();
3878
- ctx.notifier.success(`WS: connected (${url})`);
3902
+ ctx.notifier.success(ctx.flags.debug ? `WS: connected (${url})` : `WS: connected`);
3879
3903
  ctx.bus.emit("ws:connected", { url, attempt: 0 });
3880
3904
  });
3881
- 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
+ }
3882
3910
  ctx.notifier.warn(`WS: disconnected code=${code} reason=${reasonStr || "-"}`);
3883
3911
  ctx.bus.emit("ws:disconnected", { code, reason: reasonStr });
3884
3912
  if (code === 4001) {
3885
3913
  ctx.notifier.info("WS: auth disconnect (4001) -> waiting for auth", { scope: "system" /* System */ });
3886
3914
  return;
3887
3915
  }
3916
+ if (code === 4011) {
3917
+ this.stopped = true;
3918
+ this.socket?.stop();
3919
+ this.socket = null;
3920
+ return;
3921
+ }
3888
3922
  this.ensureStarted(ctx, "socket:disconnected");
3889
3923
  });
3890
- this.socket.registerOnMessageCallback((data) => {
3924
+ sock.registerOnMessageCallback((data) => {
3925
+ if (this.socket !== sock)
3926
+ return;
3891
3927
  const text = typeof data === "string" ? data : data.toString("utf8");
3892
3928
  ctx.bus.emit("ws:rx", { text });
3893
3929
  });
@@ -4558,6 +4594,7 @@ var AuthOrchestrator = class {
4558
4594
  connectionId = null;
4559
4595
  sessionId = null;
4560
4596
  accessToken;
4597
+ rejectedByConflict = false;
4561
4598
  refreshGroupKey = "auth:refresh";
4562
4599
  refreshCycle = 0;
4563
4600
  refreshScheduler = null;
@@ -4591,6 +4628,11 @@ var AuthOrchestrator = class {
4591
4628
  "Auth: signin completed; requesting ws reconnect",
4592
4629
  { scope: "system" /* System */ }
4593
4630
  );
4631
+ this.rejectedByConflict = false;
4632
+ if (this.refreshRetryScheduler) {
4633
+ this.refreshRetryScheduler.stop();
4634
+ this.refreshRetryScheduler = null;
4635
+ }
4594
4636
  this.ensureAccessExpPersisted(ctx, "signin");
4595
4637
  await this.ensureRefreshScheduling(ctx, "signin");
4596
4638
  ctx.bus.emit("auth:ws:reconnect", void 0);
@@ -4647,6 +4689,14 @@ var AuthOrchestrator = class {
4647
4689
  { scope: "system" /* System */ }
4648
4690
  );
4649
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
+ }
4650
4700
  if (p.code === 4001) {
4651
4701
  const refreshRes = await ctx.engine.secrets.get("refreshToken");
4652
4702
  if (!refreshRes.ok || !refreshRes.value) {
@@ -4669,7 +4719,8 @@ var AuthOrchestrator = class {
4669
4719
  // --- boot path unchanged except it must schedule refresh always ---
4670
4720
  async onEventConfigLoaded(config, ctx) {
4671
4721
  ctx.notifier.info(
4672
- "Initializing with cached configuration"
4722
+ "Initializing with cached configuration",
4723
+ { scope: "system" /* System */ }
4673
4724
  );
4674
4725
  if (!config.device?.id) {
4675
4726
  ctx.notifier.error("Device ID is not available");
@@ -4698,7 +4749,6 @@ var AuthOrchestrator = class {
4698
4749
  ctx.notifier.warn("Auth: not signed in; run signin");
4699
4750
  return;
4700
4751
  }
4701
- ctx.bus.emit("auth:ws:reconnect", void 0);
4702
4752
  }
4703
4753
  isAccessLikelyUsable(ctx) {
4704
4754
  const jwt = this.accessToken;
@@ -4715,7 +4765,8 @@ var AuthOrchestrator = class {
4715
4765
  async waitForApproval(api, startResponse, ctx) {
4716
4766
  const deadline = Date.now() + startResponse.expires_in * 1e3;
4717
4767
  ctx.notifier.info(
4718
- "Waiting for approval... (streaming)"
4768
+ "Waiting for approval... (streaming)",
4769
+ { scope: "system" /* System */ }
4719
4770
  );
4720
4771
  try {
4721
4772
  return await api.deviceAuthStream({
@@ -4812,7 +4863,8 @@ var AuthOrchestrator = class {
4812
4863
  }
4813
4864
  if (streamResp.needs_proof) {
4814
4865
  ctx.notifier.info(
4815
- "Sending proof-of-possession signature..."
4866
+ "Sending proof-of-possession signature...",
4867
+ { scope: "system" /* System */ }
4816
4868
  );
4817
4869
  const privKeyObj = crypto8.createPrivateKey(privateKeyPem);
4818
4870
  const challengeBytes = Buffer.from(
@@ -4827,9 +4879,9 @@ var AuthOrchestrator = class {
4827
4879
  challenge: startResponse.challenge,
4828
4880
  signature_b64: signatureB64
4829
4881
  });
4830
- ctx.notifier.info("Proof-of-possession verified");
4882
+ ctx.notifier.info("Proof-of-possession verified", { scope: "system" /* System */ });
4831
4883
  }
4832
- ctx.notifier.info("Fetching tokens...");
4884
+ ctx.notifier.info("Fetching tokens...", { scope: "system" /* System */ });
4833
4885
  const tokenResp = await api.deviceAuthToken({
4834
4886
  device_code: startResponse.device_code
4835
4887
  });
@@ -4963,6 +5015,9 @@ var AuthOrchestrator = class {
4963
5015
  ctx.bus.emit("auth:session_conflict", msg);
4964
5016
  void this.handleSessionConflict(ctx, msg);
4965
5017
  return;
5018
+ case "quiesce":
5019
+ void this.handleQuiesce(ctx, msg);
5020
+ return;
4966
5021
  default:
4967
5022
  return;
4968
5023
  }
@@ -4973,14 +5028,41 @@ var AuthOrchestrator = class {
4973
5028
  this.conflictTimer = null;
4974
5029
  }
4975
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
+ );
4976
5034
  const deadlineMs = Math.min(msg.deadline_ms, 15e3);
4977
5035
  this.conflictTimer = setTimeout(() => {
4978
5036
  this.conflictTimer = null;
4979
5037
  if (this.pendingConflictRequest?.new_session_id === msg.new_session_id) {
5038
+ ctx.notifier.info("No response - keeping existing device connected.");
4980
5039
  this.sendSessionConflictResolve(ctx, "KEEP_OLD");
4981
5040
  }
4982
5041
  }, deadlineMs);
4983
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
+ }
4984
5066
  sendSessionConflictResolve(ctx, choice) {
4985
5067
  const req = this.pendingConflictRequest;
4986
5068
  if (!req)
@@ -5242,6 +5324,10 @@ var AuthOrchestrator = class {
5242
5324
  if (this.accessToken && !ctx.engine.configurationContext.configuration.tokens?.accessTokenExpSec) {
5243
5325
  this.ensureAccessExpPersisted(ctx, reason);
5244
5326
  }
5327
+ if (this.refreshRetryScheduler) {
5328
+ this.refreshRetryScheduler.stop();
5329
+ this.refreshRetryScheduler = null;
5330
+ }
5245
5331
  this.scheduleSilentRefresh(ctx, reason);
5246
5332
  }
5247
5333
  async scheduleSilentRefresh(ctx, reason) {
@@ -7677,6 +7763,7 @@ var ToolsOrchestrator = class {
7677
7763
  const availableDirectoryIds = config.directories.map((r) => r.id).join(", ") || "none";
7678
7764
  const availableDirectoryPaths = config.directories.map((r) => r.path).join(", ") || "none";
7679
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}`);
7680
7767
  const toolStartTime = Date.now();
7681
7768
  try {
7682
7769
  const toolRegistry = new ToolExecuterRegistry(config);
@@ -7687,9 +7774,9 @@ var ToolsOrchestrator = class {
7687
7774
  const timing = calculateTiming(toolStartTime);
7688
7775
  const result = createToolResult(toolName, jobId, raw, timing.execution_sec);
7689
7776
  if (result.ok) {
7690
- ctx.notifier.success(result.toString(), { ts: Date.now(), kind: "success" /* Success */, scope: "system" /* System */ });
7777
+ ctx.notifier.success(result.toString());
7691
7778
  } else {
7692
- ctx.notifier.error(result.toString(), { scope: "system" /* System */ });
7779
+ ctx.notifier.error(result.toString());
7693
7780
  }
7694
7781
  } catch (e) {
7695
7782
  const toolResult = formatToolErrorResponse(jobId, toolName, e, toolStartTime);
@@ -7701,7 +7788,7 @@ var ToolsOrchestrator = class {
7701
7788
  error: e instanceof Error ? { code: "INTERNAL", message: e.message } : { code: "INTERNAL", message: String(e) }
7702
7789
  };
7703
7790
  const result = createToolResult(toolName, jobId, errorResult, timing.execution_sec);
7704
- ctx.notifier.error(result.toString(), { scope: "system" /* System */ });
7791
+ ctx.notifier.error(result.toString());
7705
7792
  }
7706
7793
  }
7707
7794
  };