linkshell-cli 0.6.1 → 0.6.3

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 (108) hide show
  1. package/README.md +47 -132
  2. package/dist/cli/src/auth.js +1 -1
  3. package/dist/cli/src/auth.js.map +1 -1
  4. package/dist/cli/src/commands/doctor.d.ts +1 -1
  5. package/dist/cli/src/commands/doctor.js +76 -36
  6. package/dist/cli/src/commands/doctor.js.map +1 -1
  7. package/dist/cli/src/commands/host.d.ts +15 -0
  8. package/dist/cli/src/commands/host.js +101 -20
  9. package/dist/cli/src/commands/host.js.map +1 -1
  10. package/dist/cli/src/commands/logout.d.ts +1 -1
  11. package/dist/cli/src/commands/logout.js +10 -2
  12. package/dist/cli/src/commands/logout.js.map +1 -1
  13. package/dist/cli/src/commands/upgrade.js +11 -0
  14. package/dist/cli/src/commands/upgrade.js.map +1 -1
  15. package/dist/cli/src/config.js +1 -1
  16. package/dist/cli/src/config.js.map +1 -1
  17. package/dist/cli/src/index.js +105 -120
  18. package/dist/cli/src/index.js.map +1 -1
  19. package/dist/cli/src/utils/daemon.d.ts +2 -0
  20. package/dist/cli/src/utils/daemon.js +4 -0
  21. package/dist/cli/src/utils/daemon.js.map +1 -1
  22. package/dist/cli/tsconfig.tsbuildinfo +1 -1
  23. package/dist/host/src/carry.d.ts +15 -0
  24. package/dist/host/src/carry.js +83 -0
  25. package/dist/host/src/carry.js.map +1 -0
  26. package/dist/host/src/drivers/acp/driver.d.ts +21 -3
  27. package/dist/host/src/drivers/acp/driver.js +99 -13
  28. package/dist/host/src/drivers/acp/driver.js.map +1 -1
  29. package/dist/host/src/drivers/acp/mapper.d.ts +5 -1
  30. package/dist/host/src/drivers/acp/mapper.js +58 -6
  31. package/dist/host/src/drivers/acp/mapper.js.map +1 -1
  32. package/dist/host/src/drivers/claude/driver.d.ts +76 -8
  33. package/dist/host/src/drivers/claude/driver.js +510 -29
  34. package/dist/host/src/drivers/claude/driver.js.map +1 -1
  35. package/dist/host/src/drivers/claude/holders.d.ts +9 -0
  36. package/dist/host/src/drivers/claude/holders.js +61 -0
  37. package/dist/host/src/drivers/claude/holders.js.map +1 -0
  38. package/dist/host/src/drivers/claude/transcript.d.ts +105 -61
  39. package/dist/host/src/drivers/claude/transcript.js +260 -22
  40. package/dist/host/src/drivers/claude/transcript.js.map +1 -1
  41. package/dist/host/src/drivers/codex/commands.d.ts +27 -0
  42. package/dist/host/src/drivers/codex/commands.js +23 -0
  43. package/dist/host/src/drivers/codex/commands.js.map +1 -0
  44. package/dist/host/src/drivers/codex/driver.d.ts +21 -1
  45. package/dist/host/src/drivers/codex/driver.js +140 -1
  46. package/dist/host/src/drivers/codex/driver.js.map +1 -1
  47. package/dist/host/src/drivers/codex/mapper.d.ts +4 -0
  48. package/dist/host/src/drivers/codex/mapper.js +29 -5
  49. package/dist/host/src/drivers/codex/mapper.js.map +1 -1
  50. package/dist/host/src/drivers/types.d.ts +45 -4
  51. package/dist/host/src/fs.d.ts +26 -4
  52. package/dist/host/src/fs.js +71 -17
  53. package/dist/host/src/fs.js.map +1 -1
  54. package/dist/host/src/gateway.d.ts +3 -0
  55. package/dist/host/src/gateway.js +10 -1
  56. package/dist/host/src/gateway.js.map +1 -1
  57. package/dist/host/src/host.d.ts +7 -3
  58. package/dist/host/src/host.js +53 -21
  59. package/dist/host/src/host.js.map +1 -1
  60. package/dist/host/src/hub.d.ts +98 -14
  61. package/dist/host/src/hub.js +639 -27
  62. package/dist/host/src/hub.js.map +1 -1
  63. package/dist/host/src/index.d.ts +1 -1
  64. package/dist/host/src/index.js +1 -1
  65. package/dist/host/src/index.js.map +1 -1
  66. package/dist/host/src/legacy.d.ts +7 -0
  67. package/dist/host/src/legacy.js +36 -0
  68. package/dist/host/src/legacy.js.map +1 -0
  69. package/dist/host/src/rpc/server.d.ts +4 -2
  70. package/dist/host/src/rpc/server.js +62 -5
  71. package/dist/host/src/rpc/server.js.map +1 -1
  72. package/dist/host/src/screen.d.ts +11 -0
  73. package/dist/host/src/screen.js +54 -2
  74. package/dist/host/src/screen.js.map +1 -1
  75. package/dist/host/src/shell-env.d.ts +6 -0
  76. package/dist/host/src/shell-env.js +48 -0
  77. package/dist/host/src/shell-env.js.map +1 -1
  78. package/dist/host/src/slim.d.ts +17 -0
  79. package/dist/host/src/slim.js +66 -0
  80. package/dist/host/src/slim.js.map +1 -0
  81. package/dist/host/src/store.d.ts +81 -3
  82. package/dist/host/src/store.js +249 -16
  83. package/dist/host/src/store.js.map +1 -1
  84. package/dist/host/src/terminals.d.ts +4 -4
  85. package/dist/host/src/terminals.js +19 -4
  86. package/dist/host/src/terminals.js.map +1 -1
  87. package/dist/host/src/worktrees.d.ts +36 -0
  88. package/dist/host/src/worktrees.js +96 -0
  89. package/dist/host/src/worktrees.js.map +1 -0
  90. package/dist/wire/src/model.d.ts +334 -139
  91. package/dist/wire/src/model.js +68 -0
  92. package/dist/wire/src/model.js.map +1 -1
  93. package/dist/wire/src/relay-client.d.ts +2 -0
  94. package/dist/wire/src/relay-client.js +12 -0
  95. package/dist/wire/src/relay-client.js.map +1 -1
  96. package/dist/wire/src/rpc.d.ts +7273 -890
  97. package/dist/wire/src/rpc.js +154 -6
  98. package/dist/wire/src/rpc.js.map +1 -1
  99. package/dist/wire/src/updates.d.ts +498 -498
  100. package/package.json +5 -5
  101. package/src/auth.ts +1 -1
  102. package/src/commands/doctor.ts +76 -34
  103. package/src/commands/host.ts +109 -19
  104. package/src/commands/logout.ts +10 -2
  105. package/src/commands/upgrade.ts +11 -0
  106. package/src/config.ts +1 -1
  107. package/src/index.ts +104 -137
  108. package/src/utils/daemon.ts +5 -0
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "linkshell-cli",
3
- "version": "0.6.1",
4
- "description": "Remote terminal bridge for Claude Code / Codex / Gemini / Copilot — control from your phone",
3
+ "version": "0.6.3",
4
+ "description": "Follow, steer and approve the coding agents on your computer (Claude Code, Codex, Gemini, Copilot and more) from your phone",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
7
7
  "bin": {
@@ -36,10 +36,10 @@
36
36
  "qrcode-terminal": "^0.12.0",
37
37
  "ws": "^8.18.1",
38
38
  "zod": "^3.24.3",
39
- "@linkshell/gateway": "0.5.0",
39
+ "@linkshell/gateway": "0.5.3",
40
40
  "@linkshell/protocol": "0.4.10",
41
- "@linkshell/wire": "0.1.0",
42
- "@linkshell/host": "0.1.0"
41
+ "@linkshell/wire": "0.1.3",
42
+ "@linkshell/host": "0.1.2"
43
43
  },
44
44
  "optionalDependencies": {
45
45
  "@anthropic-ai/claude-agent-sdk": "^0.2.128",
package/src/auth.ts CHANGED
@@ -2,7 +2,7 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { homedir } from "node:os";
4
4
 
5
- const AUTH_DIR = join(homedir(), ".linkshell");
5
+ const AUTH_DIR = process.env.LINKSHELL_HOME || join(homedir(), ".linkshell");
6
6
  const AUTH_FILE = join(AUTH_DIR, "auth.json");
7
7
 
8
8
  export interface AuthTokens {
@@ -1,6 +1,7 @@
1
1
  import { execSync } from "node:child_process";
2
2
  import { createRequire } from "node:module";
3
- import { loadConfig, getConfigPath } from "../config.js";
3
+ import { loadAuth } from "../auth.js";
4
+ import { hostRuntimeOk, resolveGateway, withRunningHost } from "./host.js";
4
5
 
5
6
  const requireFromCli = createRequire(import.meta.url);
6
7
 
@@ -26,32 +27,32 @@ function which(bin: string): string | undefined {
26
27
  }
27
28
  }
28
29
 
29
- async function checkGateway(url: string): Promise<CheckResult> {
30
+ async function checkGateway(name: string, url: string): Promise<CheckResult> {
30
31
  try {
31
- const httpUrl = url.replace(/\/ws\/?$/, "").replace(/^wss:/, "https:").replace(/^ws:/, "http:");
32
+ const httpUrl = url.replace(/\/(ws|v2\/connect)\/?$/, "").replace(/\/$/, "").replace(/^wss:/, "https:").replace(/^ws:/, "http:");
32
33
  const start = Date.now();
33
34
  const res = await fetch(`${httpUrl}/healthz`, { signal: AbortSignal.timeout(5000) });
34
35
  const latency = Date.now() - start;
35
- if (!res.ok) return { name: "Gateway", ok: false, detail: `HTTP ${res.status}` };
36
- return { name: "Gateway", ok: true, detail: `reachable (${latency}ms)` };
36
+ if (!res.ok) return { name, ok: false, detail: `${url}: HTTP ${res.status}` };
37
+ return { name, ok: true, detail: `${url} answers (${latency}ms)` };
37
38
  } catch (e) {
38
- return { name: "Gateway", ok: false, detail: e instanceof Error ? e.message : "unreachable" };
39
+ return { name, ok: false, detail: `${url}: ${e instanceof Error ? e.message : "unreachable"}` };
39
40
  }
40
41
  }
41
42
 
42
- export async function runDoctor(gatewayUrl?: string): Promise<void> {
43
- const config = loadConfig();
44
- const gateway = gatewayUrl ?? config.gateway;
43
+ const AGENT_INSTALL: Record<string, string> = {
44
+ claude: "npm i -g @anthropic-ai/claude-code",
45
+ codex: "npm i -g @openai/codex",
46
+ };
45
47
 
48
+ export async function runDoctor(version: string, gatewayUrl?: string): Promise<void> {
46
49
  process.stdout.write("\n LinkShell Doctor\n\n");
47
50
 
48
51
  const results: CheckResult[] = [];
49
52
 
50
53
  results.push(check("Node.js", () => {
51
- const ver = process.versions.node;
52
- const major = Number(ver.split(".")[0]);
53
- if (major < 18) throw new Error(`v${ver} (need >= 18)`);
54
- return `v${ver}`;
54
+ if (!hostRuntimeOk()) throw new Error(`v${process.versions.node} (the host needs 22.13 or newer)`);
55
+ return `v${process.versions.node}`;
55
56
  }));
56
57
 
57
58
  results.push(check("node-pty", () => {
@@ -69,32 +70,73 @@ export async function runDoctor(gatewayUrl?: string): Promise<void> {
69
70
  }
70
71
  }));
71
72
 
72
- // Optional CLI hints — not required, since LinkShell now bridges a plain shell.
73
- for (const [label, bin, hint] of [
74
- ["Claude CLI (optional)", "claude", "npm i -g @anthropic-ai/claude-code"],
75
- ["Codex CLI (optional)", "codex", "npm i -g @openai/codex"],
76
- ] as const) {
77
- const path = which(bin);
78
- if (path) {
79
- results.push({ name: label, ok: true, detail: `found (${path})` });
80
- } else {
81
- results.push({ name: label, ok: true, detail: `not installed — ${hint}` });
73
+ const host = await withRunningHost(async (client) => ({
74
+ info: await client.call("machine.info", {}),
75
+ gateway: await client.call("gateway.status", {}),
76
+ })).catch(() => undefined);
77
+
78
+ if (!host) {
79
+ results.push({ name: "Host", ok: false, detail: "not running — start it with: linkshell host --daemon" });
80
+ // Without the host, at least say whether the two main agents are on the PATH.
81
+ for (const [bin, hint] of Object.entries(AGENT_INSTALL)) {
82
+ const path = which(bin);
83
+ results.push({ name: `${bin} (optional)`, ok: true, detail: path ? `found (${path})` : `not installed — ${hint}` });
84
+ }
85
+ } else {
86
+ results.push(
87
+ host.info.hostVersion === version
88
+ ? { name: "Host", ok: true, detail: `running, ${version}` }
89
+ : {
90
+ name: "Host",
91
+ ok: false,
92
+ detail: `running ${host.info.hostVersion}, but this CLI is ${version} — update it with: linkshell host stop && linkshell host --daemon`,
93
+ },
94
+ );
95
+ // Agents as the host sees them: what the phone can actually start.
96
+ for (const agent of host.info.agents) {
97
+ if (!agent.installed) {
98
+ const hint = AGENT_INSTALL[agent.id];
99
+ if (hint) results.push({ name: `${agent.label} (optional)`, ok: true, detail: `not installed — ${hint}` });
100
+ continue;
101
+ }
102
+ const signedOut = agent.auth?.state === "missing";
103
+ const problem = agent.problem ?? (signedOut ? (agent.auth?.hint ?? "not logged in") : undefined);
104
+ results.push({ name: agent.label, ok: !problem, detail: `v${agent.version ?? "?"}${problem ? ` — ${problem}` : ""}` });
82
105
  }
83
106
  }
84
107
 
85
- results.push(check("Config", () => {
86
- const path = getConfigPath();
87
- const cfg = loadConfig();
88
- const keys = Object.keys(cfg).filter((k) => cfg[k as keyof typeof cfg] !== undefined);
89
- if (keys.length === 0) return `${path} (empty — run: linkshell setup)`;
90
- return `${path} (${keys.join(", ")})`;
91
- }));
108
+ const auth = loadAuth();
109
+ results.push({
110
+ name: "Account",
111
+ ok: true,
112
+ detail: auth?.accessToken ? `logged in as ${auth.email || auth.userId}` : "not logged in (only the official gateway needs it: linkshell login)",
113
+ });
92
114
 
93
- if (gateway) {
94
- results.push(await checkGateway(gateway));
95
- } else {
96
- results.push({ name: "Gateway", ok: false, detail: "no gateway configured — run: linkshell setup" });
115
+ if (host) {
116
+ const gateway = host.gateway;
117
+ if (gateway.status === "online") {
118
+ const account = gateway.account ? `, account ${gateway.account.email ?? gateway.account.userId}` : "";
119
+ const paired = gateway.devices.length ? `, ${gateway.devices.length} paired device(s)` : "";
120
+ results.push({ name: "Gateway", ok: true, detail: `online ${gateway.url}${account}${paired}` });
121
+ } else if (gateway.status === "off") {
122
+ results.push({
123
+ name: "Gateway",
124
+ ok: false,
125
+ detail: "off, so a phone can't reach this computer — linkshell login (official gateway), or linkshell host --gateway <url>",
126
+ });
127
+ } else {
128
+ results.push({ name: "Gateway", ok: false, detail: `${gateway.status} ${gateway.url}${gateway.error ? ` — ${gateway.error.message}` : ""}` });
129
+ }
130
+ } else if (!gatewayUrl && hostRuntimeOk()) {
131
+ const { defaultHome } = await import("@linkshell/host");
132
+ const chosen = resolveGateway(defaultHome());
133
+ results.push(
134
+ chosen
135
+ ? await checkGateway("Gateway", chosen)
136
+ : { name: "Gateway", ok: false, detail: "none chosen — linkshell login (official gateway), or linkshell host --gateway <url>" },
137
+ );
97
138
  }
139
+ if (gatewayUrl) results.push(await checkGateway(host ? "Gateway (--gateway)" : "Gateway", gatewayUrl));
98
140
 
99
141
  for (const r of results) {
100
142
  const icon = r.ok ? "\x1b[32m✓\x1b[0m" : "\x1b[31m✗\x1b[0m";
@@ -1,12 +1,13 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
+ import type { GatewayStatus } from "@linkshell/wire";
4
5
  import { getValidToken, isLoggedIn } from "../auth.js";
5
6
  import * as daemon from "../utils/daemon.js";
6
7
 
7
8
  // node:sqlite prints an ExperimentalWarning on load; it is expected here and
8
9
  // only confuses users. Must run before the host (and node:sqlite) is imported.
9
- function silenceSqliteWarning(): void {
10
+ export function silenceSqliteWarning(): void {
10
11
  const emit = process.emitWarning.bind(process);
11
12
  process.emitWarning = ((warning: string | Error, ...rest: unknown[]) => {
12
13
  const message = typeof warning === "string" ? warning : warning.message;
@@ -30,7 +31,8 @@ export const OFFICIAL_GATEWAY = "wss://gateway.itool.tech";
30
31
  export function resolveGateway(home: string): string | undefined {
31
32
  const chosen = process.env.LINKSHELL_GATEWAY || readHostConfig(home).gateway;
32
33
  if (chosen === "off") return undefined;
33
- if (chosen) return chosen;
34
+ // config.json is shared with the v1 bridge, whose gateway URLs end in /ws.
35
+ if (chosen) return chosen.replace(/\/ws\/?$/, "");
34
36
  return isLoggedIn() ? OFFICIAL_GATEWAY : undefined;
35
37
  }
36
38
 
@@ -52,9 +54,13 @@ export function writeHostConfig(home: string, config: HostConfig): void {
52
54
  }
53
55
 
54
56
  /** The host keeps its state in node:sqlite, unflagged from Node 22.13. */
55
- export function assertHostRuntime(): void {
57
+ export function hostRuntimeOk(): boolean {
56
58
  const [major = 0, minor = 0] = process.versions.node.split(".").map(Number);
57
- if (major > 22 || (major === 22 && minor >= 13)) return;
59
+ return major > 22 || (major === 22 && minor >= 13);
60
+ }
61
+
62
+ export function assertHostRuntime(): void {
63
+ if (hostRuntimeOk()) return;
58
64
  process.stderr.write(
59
65
  `\n LinkShell's host needs Node.js 22.13 or newer (this is ${process.version}).\n` +
60
66
  " Upgrade Node (for example: nvm install 22, or brew upgrade node), then try again.\n\n",
@@ -83,12 +89,13 @@ export async function runHostForeground(version: string): Promise<void> {
83
89
  const devPort = process.env.LINKSHELL_DEV_PORT ? Number(process.env.LINKSHELL_DEV_PORT) : undefined;
84
90
  // Where devices reach this machine from outside the LAN. The account from
85
91
  // `linkshell login`, if any, lets that account's devices in without pairing.
86
- const gatewayUrl = resolveGateway(host.defaultHome());
92
+ // Asked again on `gateway.refresh`, so login, logout and `--gateway` apply to a running host.
93
+ const home = host.defaultHome();
87
94
  const running = await host.startHost({
88
95
  version,
89
96
  env,
90
97
  log,
91
- gateway: gatewayUrl ? { url: gatewayUrl, token: async () => (await getValidToken()) ?? undefined } : undefined,
98
+ gateway: { url: () => resolveGateway(home), token: async () => (await getValidToken()) ?? undefined },
92
99
  tcpPort: devPort,
93
100
  claudeCommand: process.env.LINKSHELL_CLAUDE_COMMAND || undefined,
94
101
  claudeAdapter: adapter ? { command: adapter, args: [] } : undefined,
@@ -127,6 +134,89 @@ export async function ensureHostRunning(): Promise<string> {
127
134
  throw new Error(`LinkShell host did not start; see ${daemon.getLogFile("host")}`);
128
135
  }
129
136
 
137
+ type HostClient = Awaited<ReturnType<Awaited<ReturnType<typeof loadHost>>["connectHost"]>>;
138
+
139
+ /** Runs `use` against the running host; undefined when there is none. */
140
+ export async function withRunningHost<T>(use: (client: HostClient) => Promise<T>): Promise<T | undefined> {
141
+ if (!hostRuntimeOk()) return undefined;
142
+ const host = await loadHost();
143
+ const { hostSocket } = host.hostPaths();
144
+ if (!(await host.isHostRunning(hostSocket))) return undefined;
145
+ const client = await host.connectHost(hostSocket);
146
+ try {
147
+ return await use(client);
148
+ } finally {
149
+ client.close();
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Has the running host apply the current gateway choice and account, then
155
+ * waits (up to `waitMs`) for it to come online or be refused.
156
+ */
157
+ export async function refreshGateway(waitMs: number): Promise<GatewayStatus | undefined> {
158
+ return withRunningHost(async (client) => {
159
+ let status: GatewayStatus;
160
+ try {
161
+ status = await client.call("gateway.refresh", {});
162
+ } catch {
163
+ // A host from before 0.6.3 (still running after an upgrade) only reads its gateway at start.
164
+ process.stderr.write(" The running host is older than this CLI. Restart it to apply this (stops running agent turns):\n");
165
+ process.stderr.write(" linkshell host stop && linkshell host --daemon\n");
166
+ return client.call("gateway.status", {});
167
+ }
168
+ const deadline = Date.now() + waitMs;
169
+ while (status.status === "connecting" && Date.now() < deadline) {
170
+ await new Promise((resolve) => setTimeout(resolve, 200));
171
+ status = await client.call("gateway.status", {});
172
+ }
173
+ return status;
174
+ });
175
+ }
176
+
177
+ export function describeGateway(gateway: GatewayStatus): string {
178
+ if (gateway.status === "off") return "off (linkshell login for the official one, or linkshell host --gateway <url>)";
179
+ const account = gateway.account ? `, account ${gateway.account.email ?? gateway.account.userId}` : "";
180
+ const problem = gateway.error ? ` ⚠ ${gateway.error.message}` : "";
181
+ return `${gateway.status} ${gateway.url}${account}${problem}`;
182
+ }
183
+
184
+ /** After `linkshell login`: this computer joins the gateway its account is for. */
185
+ export async function joinGatewayAfterLogin(plan: "pro" | "free"): Promise<void> {
186
+ const out = (line: string) => process.stderr.write(` ${line}\n`);
187
+ if (!hostRuntimeOk()) {
188
+ out(`The LinkShell host needs Node.js 22.13 or newer (this is ${process.version}); upgrade Node, then: linkshell host --daemon\n`);
189
+ return;
190
+ }
191
+ const host = await loadHost();
192
+ const chosen = process.env.LINKSHELL_GATEWAY || readHostConfig(host.defaultHome()).gateway;
193
+ if (chosen === "off") {
194
+ out("The gateway is turned off on this computer. To use the official one: linkshell host --gateway default\n");
195
+ return;
196
+ }
197
+ if (!chosen && plan !== "pro") {
198
+ // A running host now gets the gateway's own answer, instead of an old login's.
199
+ await refreshGateway(0);
200
+ out("The official gateway needs a Pro subscription: https://itool.tech");
201
+ out("Or use your own gateway: linkshell host --gateway <url>, then linkshell pair\n");
202
+ return;
203
+ }
204
+ await ensureHostRunning();
205
+ const gateway = await refreshGateway(15_000);
206
+ const where = chosen ? (gateway?.url ?? chosen) : "the official gateway";
207
+ if (gateway?.status === "online") {
208
+ out(`\x1b[32m✓\x1b[0m This computer is online on ${where}.`);
209
+ out(
210
+ gateway.account
211
+ ? " Open LinkShell on your phone and sign in with the same account: it shows up there, no pairing needed.\n"
212
+ : " Pair your phone with: linkshell pair\n",
213
+ );
214
+ } else {
215
+ out(`\x1b[31m✗\x1b[0m Not connected to ${where}${gateway?.error ? `: ${gateway.error.message}` : " yet"}`);
216
+ out(" Check again with: linkshell host status\n");
217
+ }
218
+ }
219
+
130
220
  export async function printHostStatus(): Promise<void> {
131
221
  const host = await loadHost();
132
222
  const { hostSocket } = host.hostPaths();
@@ -153,15 +243,9 @@ export async function printHostStatus(): Promise<void> {
153
243
  const active = sessions.filter((s) => s.state === "running" || s.state === "waiting");
154
244
  process.stdout.write(` Sessions: ${sessions.length} known, ${active.length} active\n`);
155
245
  const gateway = await client.call("gateway.status", {});
156
- if (gateway.status === "off") {
157
- process.stdout.write(" Gateway: off (linkshell login for the official one, or linkshell host --gateway <url>)\n");
158
- } else {
159
- const account = gateway.account ? `, account ${gateway.account.email ?? gateway.account.userId}` : "";
160
- const problem = gateway.error ? ` ⚠ ${gateway.error.message}` : "";
161
- process.stdout.write(` Gateway: ${gateway.status} ${gateway.url}${account}${problem}\n`);
162
- for (const device of gateway.devices) {
163
- process.stdout.write(` ${device.online ? "●" : "○"} ${device.name} paired ${new Date(device.pairedAt).toLocaleDateString()}\n`);
164
- }
246
+ process.stdout.write(` Gateway: ${describeGateway(gateway)}\n`);
247
+ for (const device of gateway.devices) {
248
+ process.stdout.write(` ${device.online ? "●" : "○"} ${device.name} paired ${new Date(device.pairedAt).toLocaleDateString()}\n`);
165
249
  }
166
250
  } finally {
167
251
  client.close();
@@ -174,10 +258,14 @@ interface LaunchSpec {
174
258
  env?: Record<string, string>;
175
259
  }
176
260
 
177
- function runChild(launch: LaunchSpec, onSpawn?: (child: import("node:child_process").ChildProcess) => void): Promise<number> {
261
+ function runChild(
262
+ launch: LaunchSpec,
263
+ baseEnv: NodeJS.ProcessEnv,
264
+ onSpawn?: (child: import("node:child_process").ChildProcess) => void,
265
+ ): Promise<number> {
178
266
  const child = spawn(launch.command, launch.args, {
179
267
  stdio: "inherit",
180
- env: { ...process.env, ...launch.env },
268
+ env: { ...baseEnv, ...launch.env },
181
269
  });
182
270
  onSpawn?.(child);
183
271
  // The terminal delivers Ctrl-C to the whole foreground group; the agent handles it.
@@ -231,6 +319,8 @@ export async function runAgentShim(agent: string, args: string[]): Promise<void>
231
319
  const host = await loadHost();
232
320
  const socket = await ensureHostRunning();
233
321
  const client = await host.connectHost(socket);
322
+ // Run from inside a Claude Code session, the agent must not become its child.
323
+ const env = host.withoutClaudeSession(process.env);
234
324
  let launch: LaunchSpec & { sessionId?: string };
235
325
  try {
236
326
  launch = await client.call("desktop.launch", { agent, args, cwd: process.cwd() });
@@ -242,7 +332,7 @@ export async function runAgentShim(agent: string, args: string[]): Promise<void>
242
332
  if (!launch.sessionId) {
243
333
  // Multi-client agents (Codex): the TUI simply joins the shared server.
244
334
  client.close();
245
- process.exit(await runChild(launch));
335
+ process.exit(await runChild(launch, env));
246
336
  }
247
337
 
248
338
  const sessionId = launch.sessionId;
@@ -260,7 +350,7 @@ export async function runAgentShim(agent: string, args: string[]): Promise<void>
260
350
 
261
351
  for (;;) {
262
352
  yielded = false;
263
- const code = await runChild(launch, (spawned) => (child = spawned));
353
+ const code = await runChild(launch, env, (spawned) => (child = spawned));
264
354
  if (!yielded) {
265
355
  // The user quit the TUI: the session stays available on every device.
266
356
  client.close();
@@ -1,12 +1,20 @@
1
1
  import { clearAuth, loadAuth } from "../auth.js";
2
+ import { refreshGateway, withRunningHost } from "./host.js";
2
3
 
3
- export function runLogout(): void {
4
+ export async function runLogout(): Promise<void> {
4
5
  const auth = loadAuth();
5
6
  if (!auth || !auth.accessToken) {
6
7
  process.stderr.write("\n Not currently logged in.\n\n");
7
8
  return;
8
9
  }
9
10
 
11
+ const before = await withRunningHost((client) => client.call("gateway.status", {}));
10
12
  clearAuth();
11
- process.stderr.write(`\n \x1b[32m✓\x1b[0m Logged out${auth.email ? ` (${auth.email})` : ""}.\n\n`);
13
+ process.stderr.write(`\n \x1b[32m✓\x1b[0m Logged out${auth.email ? ` (${auth.email})` : ""}.\n`);
14
+ // A running host leaves the account's gateway now, not at its next reconnect.
15
+ const after = await refreshGateway(0);
16
+ if (before && before.status !== "off" && after?.status === "off") {
17
+ process.stderr.write(" This computer is off the official gateway; the host and its sessions keep running.\n");
18
+ }
19
+ process.stderr.write("\n");
12
20
  }
@@ -1,5 +1,6 @@
1
1
  import { execSync } from "node:child_process";
2
2
  import { createRequire } from "node:module";
3
+ import { withRunningHost } from "./host.js";
3
4
 
4
5
  const require = createRequire(import.meta.url);
5
6
  const pkg = require("../../../../package.json") as { version: string };
@@ -31,6 +32,14 @@ async function fetchLatestVersion(): Promise<string | null> {
31
32
  }
32
33
  }
33
34
 
35
+ /** The background host keeps running the version it was started with. */
36
+ async function noteRunningHost(latest: string): Promise<void> {
37
+ const running = await withRunningHost((client) => client.call("machine.info", {})).catch(() => undefined);
38
+ if (!running || running.hostVersion === latest) return;
39
+ process.stderr.write(` The running host is still ${running.hostVersion}. Restart it to use ${latest} (stops running agent turns):\n`);
40
+ process.stderr.write(" linkshell host stop && linkshell host --daemon\n\n");
41
+ }
42
+
34
43
  export async function runUpgrade(): Promise<void> {
35
44
  const current = pkg.version;
36
45
  process.stderr.write(`\n Current version: ${current}\n`);
@@ -55,6 +64,7 @@ export async function runUpgrade(): Promise<void> {
55
64
  try {
56
65
  execSync("brew upgrade linkshell", { stdio: "inherit", timeout: 120_000 });
57
66
  process.stderr.write("\n \x1b[32m✓\x1b[0m Upgraded successfully.\n\n");
67
+ await noteRunningHost(latest);
58
68
  } catch {
59
69
  process.stderr.write("\n \x1b[31m✗\x1b[0m Homebrew upgrade failed. Try manually:\n");
60
70
  process.stderr.write(" brew upgrade linkshell\n\n");
@@ -65,6 +75,7 @@ export async function runUpgrade(): Promise<void> {
65
75
  try {
66
76
  execSync("npm install -g linkshell-cli@latest", { stdio: "inherit", timeout: 120_000 });
67
77
  process.stderr.write("\n \x1b[32m✓\x1b[0m Upgraded successfully.\n\n");
78
+ await noteRunningHost(latest);
68
79
  } catch {
69
80
  process.stderr.write("\n \x1b[31m✗\x1b[0m npm upgrade failed. Try manually:\n");
70
81
  process.stderr.write(" npm install -g linkshell-cli@latest\n\n");
package/src/config.ts CHANGED
@@ -14,7 +14,7 @@ export interface LinkShellConfig {
14
14
  rows?: number;
15
15
  }
16
16
 
17
- const CONFIG_DIR = join(homedir(), ".linkshell");
17
+ const CONFIG_DIR = process.env.LINKSHELL_HOME || join(homedir(), ".linkshell");
18
18
  const CONFIG_FILE = join(CONFIG_DIR, "config.json");
19
19
 
20
20
  export function loadConfig(): LinkShellConfig {