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
@@ -1,15 +1,60 @@
1
1
  import { execFile } from "node:child_process";
2
- import { accessSync, constants, statSync } from "node:fs";
2
+ import { accessSync, constants, existsSync, mkdirSync, renameSync, rmSync, statSync } from "node:fs";
3
3
  import { randomUUID } from "node:crypto";
4
4
  import { createRequire } from "node:module";
5
5
  import { delimiter, isAbsolute, join } from "node:path";
6
6
  import { promisify } from "node:util";
7
7
  import { RpcError } from "@linkshell/wire";
8
- import { AcpDriver } from "../acp/driver.js";
8
+ import { AcpDriver, inOrder } from "../acp/driver.js";
9
9
  import { AcpItemTracker, toConfigOptions, toHistory } from "../acp/mapper.js";
10
10
  import { parseClaudeAuthStatus, runStatusCommand } from "../auth.js";
11
- import { claudeConfigDir, findTranscript, readTranscript, transcriptLine, transcriptTimes, TranscriptTail } from "./transcript.js";
11
+ import { descendsFrom, sessionHolders } from "./holders.js";
12
+ import { claudeConfigDir, encodeProjectDir, findTranscript, turnEndUuid, mergeSettings, readTail, readTranscript, settingsOf, transcriptLine, transcriptTimes, TranscriptTail, } from "./transcript.js";
12
13
  const execFileAsync = promisify(execFile);
14
+ /** How the adapter's Claude marks the transcript lines it writes. */
15
+ const REMOTE_ENTRYPOINT = '"entrypoint":"sdk-ts"';
16
+ /** A message in the transcript that some other Claude (the desktop app, a terminal) wrote. */
17
+ function writtenByAnotherClaude(line) {
18
+ return line.includes('"entrypoint":"') && !line.includes(REMOTE_ENTRYPOINT) && /"type":"(user|assistant)"/.test(line);
19
+ }
20
+ /**
21
+ * Whether another Claude is in the middle of a turn, going by the transcript's
22
+ * last message: a prompt or a tool result with no final reply after it. A turn
23
+ * that has written nothing for `stalledMs` no longer counts (it waits on its
24
+ * own approval prompt, or that Claude is gone).
25
+ */
26
+ function turnInProgress(path, stalledMs) {
27
+ const { size, mtimeMs } = statSync(path);
28
+ if (Date.now() - mtimeMs > stalledMs)
29
+ return false;
30
+ // Lines can be megabytes (a screenshot in a tool result): widen the look until one is whole.
31
+ for (const bytes of [512 * 1024, 8 * 1024 * 1024, size]) {
32
+ const tail = readTail(path, Math.min(size, bytes));
33
+ const lines = (bytes >= size ? tail : tail.slice(tail.indexOf("\n") + 1)).split("\n");
34
+ for (let i = lines.length - 1; i >= 0; i--) {
35
+ const line = lines[i];
36
+ if (!/"type":"(user|assistant)"/.test(line))
37
+ continue;
38
+ let entry;
39
+ try {
40
+ entry = JSON.parse(line);
41
+ }
42
+ catch {
43
+ continue;
44
+ }
45
+ if (entry.isSidechain || (entry.type !== "user" && entry.type !== "assistant"))
46
+ continue;
47
+ if (line.includes(REMOTE_ENTRYPOINT))
48
+ return false;
49
+ if (entry.type === "assistant")
50
+ return entry.message?.stop_reason == null || entry.message.stop_reason === "tool_use";
51
+ return !JSON.stringify(entry.message?.content ?? "").includes("[Request interrupted");
52
+ }
53
+ if (bytes >= size)
54
+ break;
55
+ }
56
+ return false;
57
+ }
13
58
  /** Finds an executable on the given PATH (the adapter needs an absolute path). */
14
59
  export function resolveExecutable(name, env) {
15
60
  if (isAbsolute(name))
@@ -53,9 +98,26 @@ function driverOf(mode) {
53
98
  export class ClaudeDriver extends AcpDriver {
54
99
  modes = new Map();
55
100
  tails = new Map();
101
+ /**
102
+ * The settings Claude offers (model, effort, permission mode…), as the
103
+ * adapter reported them for any session: what a desktop-driven session
104
+ * shows before the phone has it open over ACP.
105
+ */
106
+ template;
107
+ templateLoading;
108
+ /** Desktop-driven sessions: the model of the latest reply, from the transcript. */
109
+ /** Per session: the settings its transcript shows it running with (what the Claude on the computer is set to). */
110
+ observed = new Map();
111
+ /** Per session: TodoWrite calls shown as the plan, whose results the tail skips. */
112
+ hiddenTools = new Map();
113
+ /** Per session: calls that started an agent in the background and haven't heard back (see transcriptLine). */
114
+ backgroundAgents = new Map();
115
+ /** Ids of the transcript lines read lately: Claude writes some again after compacting. */
116
+ seenLines = new Map();
56
117
  claudeCommand;
57
118
  configDir;
58
119
  busyWindowMs;
120
+ holderCheckMs;
59
121
  claudePath;
60
122
  constructor(options) {
61
123
  const env = options.env ?? process.env;
@@ -79,11 +141,16 @@ export class ClaudeDriver extends AcpDriver {
79
141
  this.claudeCommand = claudeCommand;
80
142
  this.claudePath = claudePath;
81
143
  this.configDir = claudeConfigDir(env);
82
- this.busyWindowMs = options.busyWindowMs ?? 60_000;
144
+ this.busyWindowMs = options.busyWindowMs ?? 10 * 60_000;
145
+ this.holderCheckMs = options.holderCheckMs ?? 3000;
83
146
  this.adapterMissing = adapterMissing;
84
147
  this.capabilities = { ...this.capabilities, models: true, modes: true };
85
148
  }
86
149
  adapterMissing;
150
+ /** Sent from a device while another Claude was mid-turn in the session; goes out when that turn ends. */
151
+ waiting = new Map();
152
+ /** Checks that sessions driven from a device haven't been reopened on the computer. */
153
+ remoteWatch;
87
154
  async start(host) {
88
155
  if (!this.adapterMissing)
89
156
  return super.start(host);
@@ -96,6 +163,8 @@ export class ClaudeDriver extends AcpDriver {
96
163
  return this.current;
97
164
  }
98
165
  async stop() {
166
+ clearInterval(this.remoteWatch);
167
+ this.remoteWatch = undefined;
99
168
  for (const tail of this.tails.values())
100
169
  tail.stop();
101
170
  this.tails.clear();
@@ -109,8 +178,23 @@ export class ClaudeDriver extends AcpDriver {
109
178
  if (path) {
110
179
  const transcript = readTranscript(path);
111
180
  state.tracker = new AcpItemTracker();
112
- history = toHistory(transcript.updates, state.tracker, (update) => transcriptTimes.get(update));
181
+ // What is under way on the computer isn't history yet: a turn's start and
182
+ // the tool calls still running, and agents working in the background
183
+ // (which outlive the turn that started them). They go out as what is
184
+ // happening now (after the history; the hub holds them until then), so
185
+ // the session shows as working and results have a card to land on.
186
+ const underway = [];
187
+ history = toHistory(transcript.updates, state.tracker, (update) => transcriptTimes.get(update), underway);
188
+ const working = turnInProgress(path, this.busyWindowMs);
189
+ for (const update of underway) {
190
+ const call = update.parentToolCallId ?? update.toolCallId;
191
+ if (working || (call && transcript.agents.has(call)))
192
+ this.host?.update(this.id, nativeId, update, undefined, transcriptTimes.get(update));
193
+ }
113
194
  offset = transcript.size;
195
+ this.backgroundAgents.set(nativeId, transcript.agents);
196
+ this.seenLines.set(nativeId, transcript.seen);
197
+ this.observed.set(nativeId, transcript.settings);
114
198
  if (transcript.title)
115
199
  this.host?.update(this.id, nativeId, { sessionUpdate: "session_info_update", title: transcript.title });
116
200
  }
@@ -119,10 +203,65 @@ export class ClaudeDriver extends AcpDriver {
119
203
  this.host?.update(this.id, nativeId, { sessionUpdate: "ls_driver", driver: driverOf(mode) });
120
204
  if (mode === "remote")
121
205
  this.emitConfig(nativeId, state);
206
+ else
207
+ void this.loadTemplate(context.cwd).then(() => this.emitDesktopConfig(nativeId));
122
208
  return history;
123
209
  }
210
+ /**
211
+ * Claude's settings list without a session to ask: a throwaway session that
212
+ * never gets a message (Claude writes no transcript for it), closed at once.
213
+ */
214
+ loadTemplate(cwd) {
215
+ if (this.template)
216
+ return Promise.resolve(this.template);
217
+ this.templateLoading ??= (async () => {
218
+ try {
219
+ const response = await this.rpc("session/new", { cwd, mcpServers: [] });
220
+ const options = toConfigOptions(response);
221
+ if (options.length > 0)
222
+ this.template ??= options;
223
+ if (typeof response.sessionId === "string" && this.connection?.capabilities.sessionCapabilities?.close) {
224
+ void this.rpc("session/close", { sessionId: response.sessionId }).catch(() => { });
225
+ }
226
+ return this.template;
227
+ }
228
+ catch (error) {
229
+ this.host?.log(`[claude] couldn't read Claude's settings: ${error instanceof Error ? error.message : String(error)}`);
230
+ return undefined;
231
+ }
232
+ finally {
233
+ this.templateLoading = undefined;
234
+ }
235
+ })();
236
+ return this.templateLoading;
237
+ }
238
+ pendingConfig(nativeId) {
239
+ try {
240
+ return JSON.parse(this.host?.state(this.id, nativeId).get("pendingConfig") ?? "{}");
241
+ }
242
+ catch {
243
+ return {};
244
+ }
245
+ }
246
+ /** A desktop-driven session's settings: the template, its model, and choices waiting for the phone. */
247
+ emitDesktopConfig(nativeId) {
248
+ if (!this.template || this.modes.get(nativeId) === "remote")
249
+ return;
250
+ const pending = this.pendingConfig(nativeId);
251
+ const observed = this.observed.get(nativeId) ?? {};
252
+ const options = this.template.map(({ source: _source, ...option }) => {
253
+ const current = pending[option.id] ?? observedValue(option, observed);
254
+ return { ...option, current: current !== undefined && option.values.some((value) => value.value === current) ? current : option.current };
255
+ });
256
+ this.host?.update(this.id, nativeId, { sessionUpdate: "ls_config", options });
257
+ }
124
258
  async createSession(options) {
125
259
  const created = await super.createSession(options);
260
+ // A new session's settings are Claude's defaults: the freshest template.
261
+ // (Not a resumed or changed one's: those are that session's own.)
262
+ const config = this.sessions.get(created.nativeId)?.config;
263
+ if (config?.length)
264
+ this.template = config;
126
265
  // Born on a device: this host is the writer from the start.
127
266
  this.modes.set(created.nativeId, "remote");
128
267
  this.setWriter(created.nativeId, "remote");
@@ -138,20 +277,195 @@ export class ClaudeDriver extends AcpDriver {
138
277
  this.tails.delete(nativeId);
139
278
  await super.detach(nativeId);
140
279
  }
280
+ /**
281
+ * A copy of the session's transcript under a new id (the SDK's fork), all of
282
+ * it or through a turn. A fork that works somewhere else (a worktree) has
283
+ * its transcript moved to that directory's project folder, where Claude
284
+ * looks for the sessions of a directory.
285
+ */
286
+ async fork(nativeId, options) {
287
+ const source = findTranscript(this.configDir, nativeId, options.sourceCwd);
288
+ if (!source)
289
+ throw RpcError.app("not_ready", "这个 Claude 会话还没有任何消息,没有可分叉的内容");
290
+ const upToMessageId = options.upTo ? turnEndUuid(source, options.upTo.itemId, options.upTo.turn) : undefined;
291
+ if (options.upTo && !upToMessageId)
292
+ throw RpcError.app("not_found", "找不到要分叉的那一轮");
293
+ const { forkSession } = await import("@anthropic-ai/claude-agent-sdk");
294
+ const previous = process.env.CLAUDE_CONFIG_DIR;
295
+ process.env.CLAUDE_CONFIG_DIR = this.configDir;
296
+ let forked;
297
+ try {
298
+ forked = (await forkSession(nativeId, { dir: options.sourceCwd, upToMessageId })).sessionId;
299
+ }
300
+ finally {
301
+ if (previous === undefined)
302
+ delete process.env.CLAUDE_CONFIG_DIR;
303
+ else
304
+ process.env.CLAUDE_CONFIG_DIR = previous;
305
+ }
306
+ if (options.cwd !== options.sourceCwd) {
307
+ const made = findTranscript(this.configDir, forked, options.sourceCwd);
308
+ if (!made)
309
+ throw RpcError.app("not_found", "分叉出的会话没有写出来");
310
+ const folder = join(this.configDir, "projects", encodeProjectDir(options.cwd));
311
+ mkdirSync(folder, { recursive: true });
312
+ renameSync(made, join(folder, `${forked}.jsonl`));
313
+ }
314
+ const now = Date.now();
315
+ return { nativeId: forked, cwd: options.cwd, createdAt: now, updatedAt: now };
316
+ }
317
+ /** Claude's own title record (what /rename writes), so `claude --resume` shows it too. */
318
+ async rename(nativeId, title) {
319
+ const { renameSession } = await import("@anthropic-ai/claude-agent-sdk");
320
+ const previous = process.env.CLAUDE_CONFIG_DIR;
321
+ // The SDK finds transcripts through CLAUDE_CONFIG_DIR, as Claude does.
322
+ process.env.CLAUDE_CONFIG_DIR = this.configDir;
323
+ try {
324
+ await renameSession(nativeId, title);
325
+ }
326
+ finally {
327
+ if (previous === undefined)
328
+ delete process.env.CLAUDE_CONFIG_DIR;
329
+ else
330
+ process.env.CLAUDE_CONFIG_DIR = previous;
331
+ }
332
+ }
333
+ async delete(nativeId) {
334
+ const transcript = findTranscript(this.configDir, nativeId);
335
+ // No transcript (never messaged): Claude has nothing to delete.
336
+ if (transcript)
337
+ await super.delete(nativeId);
338
+ else
339
+ this.sessions.delete(nativeId);
340
+ this.modes.delete(nativeId);
341
+ this.hiddenTools.delete(nativeId);
342
+ this.backgroundAgents.delete(nativeId);
343
+ if (transcript)
344
+ sweepTranscript(transcript);
345
+ }
141
346
  async prompt(nativeId, content, clientMessageId) {
347
+ // Another Claude is in the middle of a turn here: the message waits for it to finish.
348
+ if (this.waiting.get(nativeId)?.length || (await this.elsewhere(nativeId))?.working) {
349
+ const waiting = this.waiting.get(nativeId) ?? [];
350
+ waiting.push({ content, clientMessageId });
351
+ this.waiting.set(nativeId, waiting);
352
+ this.reportWaiting(nativeId);
353
+ this.startWatch();
354
+ return "queued";
355
+ }
142
356
  // Sending from a device is taking the session over.
143
357
  await this.ensureRemote(nativeId);
144
358
  return super.prompt(nativeId, content, clientMessageId);
145
359
  }
360
+ unqueue(nativeId, clientMessageId) {
361
+ const waiting = this.waiting.get(nativeId);
362
+ const index = waiting?.findIndex((entry) => entry.clientMessageId === clientMessageId) ?? -1;
363
+ if (!waiting || index < 0)
364
+ return super.unqueue(nativeId, clientMessageId);
365
+ waiting.splice(index, 1);
366
+ if (waiting.length === 0)
367
+ this.waiting.delete(nativeId);
368
+ this.reportWaiting(nativeId);
369
+ return true;
370
+ }
371
+ reorderQueue(nativeId, clientMessageIds) {
372
+ const waiting = this.waiting.get(nativeId);
373
+ if (!waiting)
374
+ return super.reorderQueue(nativeId, clientMessageIds);
375
+ this.waiting.set(nativeId, inOrder(waiting, clientMessageIds));
376
+ this.reportWaiting(nativeId);
377
+ }
378
+ reportWaiting(nativeId) {
379
+ this.host?.queue(this.id, nativeId, (this.waiting.get(nativeId) ?? []).map((entry) => ({
380
+ clientMessageId: entry.clientMessageId,
381
+ text: entry.content.map((block) => (block.type === "text" ? block.text : "")).join("").trim(),
382
+ images: entry.content.filter((block) => block.type === "image").length,
383
+ })));
384
+ }
385
+ /** Sends what waited for another Claude's turn, now that it has ended. */
386
+ async sendWaiting(nativeId) {
387
+ const waiting = this.waiting.get(nativeId);
388
+ if (!waiting?.length || (await this.elsewhere(nativeId))?.working)
389
+ return;
390
+ this.waiting.delete(nativeId);
391
+ this.reportWaiting(nativeId);
392
+ try {
393
+ await this.ensureRemote(nativeId);
394
+ for (const entry of waiting)
395
+ await super.prompt(nativeId, entry.content, entry.clientMessageId);
396
+ }
397
+ catch (error) {
398
+ this.emit(nativeId, {
399
+ sessionUpdate: "ls_error",
400
+ code: error instanceof RpcError ? (error.appCode ?? "send_failed") : "send_failed",
401
+ message: `排队的消息没能发出:${error instanceof Error ? error.message : String(error)}`,
402
+ });
403
+ }
404
+ }
146
405
  async cancel(nativeId) {
147
- if (this.modes.get(nativeId) !== "remote") {
148
- throw RpcError.app("not_supported", "Claude 正在电脑上运行:先接管,再停止");
406
+ if (this.modes.get(nativeId) === "remote")
407
+ return super.cancel(nativeId);
408
+ // Stopping stops what's waiting too; the apps put queued text back in the composer.
409
+ if (this.waiting.delete(nativeId))
410
+ this.reportWaiting(nativeId);
411
+ // The turn runs in a Claude on the computer. A `linkshell claude` terminal
412
+ // steps aside (which ends its turn); any other Claude is interrupted.
413
+ if (this.host?.desktop(this.id, nativeId))
414
+ await this.ensureRemote(nativeId);
415
+ else
416
+ await this.interruptElsewhere(nativeId);
417
+ }
418
+ async sendQueuedNow(nativeId) {
419
+ if (!this.waiting.get(nativeId)?.length)
420
+ return super.sendQueuedNow(nativeId);
421
+ await this.interruptElsewhere(nativeId);
422
+ await this.sendWaiting(nativeId);
423
+ }
424
+ /**
425
+ * Stops the turn another Claude (the desktop app, a `claude` in a terminal)
426
+ * is running in this session, the way Ctrl-C would: asked to stop first,
427
+ * told to quit if it doesn't. Only ever the processes that have this session open.
428
+ */
429
+ async interruptElsewhere(nativeId) {
430
+ if (!(await this.elsewhere(nativeId))?.working)
431
+ return;
432
+ const recorded = (await this.foreignHolders(nativeId)).map((holder) => holder.pid);
433
+ const pids = recorded.length > 0 || this.hasHolderRecords() ? recorded : await this.commandLineHolders(nativeId);
434
+ const stopped = async (ms) => {
435
+ const deadline = Date.now() + ms;
436
+ while (Date.now() < deadline) {
437
+ if (!(await this.elsewhere(nativeId))?.working)
438
+ return true;
439
+ await new Promise((resolve) => setTimeout(resolve, 150));
440
+ }
441
+ return false;
442
+ };
443
+ for (const signal of ["SIGINT", "SIGTERM"]) {
444
+ for (const pid of new Set(pids)) {
445
+ try {
446
+ process.kill(pid, signal);
447
+ }
448
+ catch {
449
+ // Already gone.
450
+ }
451
+ }
452
+ if (await stopped(signal === "SIGINT" ? 6000 : 4000))
453
+ return;
149
454
  }
150
- await super.cancel(nativeId);
455
+ throw RpcError.app("busy", "没能停下电脑上的这一轮:请在电脑上按 Esc 停止它");
151
456
  }
152
457
  async setConfig(nativeId, optionId, value) {
153
- await this.ensureRemote(nativeId);
154
- await super.setConfig(nativeId, optionId, value);
458
+ if (this.modes.get(nativeId) === "remote" && this.sessions.get(nativeId)?.loaded) {
459
+ await super.setConfig(nativeId, optionId, value);
460
+ return;
461
+ }
462
+ // The desktop has it (or nobody): choosing a model mustn't take the session
463
+ // from the terminal. Remember it; it applies when the phone next drives.
464
+ const option = this.template?.find((entry) => entry.id === optionId);
465
+ if (!option || !option.values.some((entry) => entry.value === value))
466
+ throw RpcError.app("invalid_params", `unknown setting ${optionId}=${value}`);
467
+ this.host?.state(this.id, nativeId).set("pendingConfig", JSON.stringify({ ...this.pendingConfig(nativeId), [optionId]: value }));
468
+ this.emitDesktopConfig(nativeId);
155
469
  }
156
470
  async takeover(nativeId) {
157
471
  await this.ensureRemote(nativeId);
@@ -232,11 +546,26 @@ export class ClaudeDriver extends AcpDriver {
232
546
  }
233
547
  /** Desktop activity: what the TUI (or a plain `claude`) writes to the transcript. */
234
548
  onTranscriptLine(nativeId, line) {
235
- const result = transcriptLine(line);
549
+ let hidden = this.hiddenTools.get(nativeId);
550
+ if (!hidden)
551
+ this.hiddenTools.set(nativeId, (hidden = new Set()));
552
+ let agents = this.backgroundAgents.get(nativeId);
553
+ if (!agents)
554
+ this.backgroundAgents.set(nativeId, (agents = new Set()));
555
+ let seen = this.seenLines.get(nativeId);
556
+ if (!seen)
557
+ this.seenLines.set(nativeId, (seen = new Set()));
558
+ const result = transcriptLine(line, { hidden, agents, seen });
236
559
  if (result.title)
237
560
  this.host?.update(this.id, nativeId, { sessionUpdate: "session_info_update", title: result.title });
238
561
  for (const update of result.updates)
239
562
  this.emit(nativeId, update);
563
+ // Changed on the computer (another model, effort, permission mode): the phone shows it.
564
+ const settings = mergeSettings(this.observed.get(nativeId) ?? {}, settingsOf(line));
565
+ if (settings) {
566
+ this.observed.set(nativeId, settings);
567
+ this.emitDesktopConfig(nativeId);
568
+ }
240
569
  }
241
570
  /** Makes this host the session's only writer, taking it from the desktop if needed. */
242
571
  async ensureRemote(nativeId) {
@@ -244,12 +573,22 @@ export class ClaudeDriver extends AcpDriver {
244
573
  if (this.modes.get(nativeId) === "remote" && state?.loaded)
245
574
  return;
246
575
  const cwd = state?.cwd ?? "";
576
+ // Claude writes nothing until the first message, and there is nothing to
577
+ // resume without it: say so before the terminal gives the session up.
578
+ if (!state?.loaded && !findTranscript(this.configDir, nativeId, cwd)) {
579
+ throw RpcError.app("not_ready", "这个 Claude 会话还没有任何消息:先在电脑上发一条");
580
+ }
247
581
  const desktop = this.host?.desktop(this.id, nativeId);
582
+ let alsoOpenIn;
248
583
  if (desktop) {
249
584
  await desktop.yield();
250
585
  }
251
586
  else if (this.modes.get(nativeId) !== "remote") {
252
- await this.refuseIfTuiMayBeActive(nativeId, cwd);
587
+ const other = await this.elsewhere(nativeId);
588
+ // Two Claudes writing one turn at once would garble it; a message sent now waits instead (see prompt).
589
+ if (other?.working)
590
+ throw RpcError.app("busy", "电脑上的 Claude 正在跑这一轮。直接发消息即可:会排队,等它结束后发出。");
591
+ alsoOpenIn = other?.where;
253
592
  }
254
593
  // Take in the TUI's last lines, then stop following: ACP streams from here on.
255
594
  const tail = this.tails.get(nativeId);
@@ -265,9 +604,75 @@ export class ClaudeDriver extends AcpDriver {
265
604
  target.config = toConfigOptions(response);
266
605
  target.loaded = true;
267
606
  }
607
+ // The session keeps the settings it ran with on the computer, except where
608
+ // the phone chose otherwise while the desktop had it.
609
+ const observed = this.observed.get(nativeId) ?? {};
610
+ const kept = Object.fromEntries(target.config.flatMap((option) => [[option.id, observedValue(option, observed)]]).filter(([, value]) => value !== undefined));
611
+ const pending = this.pendingConfig(nativeId);
612
+ for (const [optionId, value] of Object.entries({ ...kept, ...pending })) {
613
+ const option = target.config.find((entry) => entry.id === optionId);
614
+ if (option && option.current !== value && option.values.some((entry) => entry.value === value)) {
615
+ await super.setConfig(nativeId, optionId, value).catch((error) => this.host?.log(`[claude] couldn't apply ${optionId}: ${String(error)}`));
616
+ }
617
+ }
618
+ if (Object.keys(pending).length)
619
+ this.host?.state(this.id, nativeId).set("pendingConfig", "{}");
268
620
  this.setWriter(nativeId, "remote");
269
621
  this.setMode(nativeId, "remote");
270
622
  this.emitConfig(nativeId, target);
623
+ if (alsoOpenIn) {
624
+ // That Claude keeps its own copy of the conversation and can't be told about this one.
625
+ this.emit(nativeId, alsoOpenIn === "app"
626
+ ? {
627
+ sessionUpdate: "ls_notice",
628
+ level: "info",
629
+ title: "已在手机上接着做",
630
+ detail: "这个会话在 Claude 桌面 App 里也开着:App 要重启后才会显示手机上的消息。",
631
+ }
632
+ : {
633
+ sessionUpdate: "ls_notice",
634
+ level: "info",
635
+ title: "已在手机上接着做",
636
+ detail: `这个会话在电脑终端里也开着:那边要重新打开才会显示手机上的消息(用 linkshell claude --resume ${nativeId} 打开,之后可以随时接力)。`,
637
+ });
638
+ }
639
+ this.startWatch();
640
+ }
641
+ startWatch() {
642
+ this.remoteWatch ??= setInterval(() => void this.watchRemoteSessions(), this.holderCheckMs);
643
+ this.remoteWatch.unref?.();
644
+ }
645
+ /**
646
+ * A session driven from a device that another Claude wrote to (the desktop
647
+ * app, or a `claude` in a terminal, continued it): that process has the
648
+ * conversation now, so stop writing to it and follow what it does instead.
649
+ */
650
+ async watchRemoteSessions() {
651
+ for (const nativeId of [...this.waiting.keys()])
652
+ await this.sendWaiting(nativeId).catch(() => { });
653
+ for (const [nativeId, mode] of this.modes) {
654
+ if (mode !== "remote" || this.host?.desktop(this.id, nativeId))
655
+ continue;
656
+ if (!this.tails.get(nativeId)?.writtenMeanwhile(writtenByAnotherClaude))
657
+ continue;
658
+ await this.leaveRemote(nativeId).catch(() => { });
659
+ this.emit(nativeId, {
660
+ sessionUpdate: "ls_notice",
661
+ level: "info",
662
+ title: "电脑上继续了这个会话",
663
+ detail: "这里转为实时显示电脑上的进展;再发消息会重新接管。",
664
+ });
665
+ }
666
+ }
667
+ /** Running Claude processes on this session that this host didn't start. */
668
+ async foreignHolders(nativeId) {
669
+ const foreign = [];
670
+ for (const holder of sessionHolders(this.configDir, nativeId)) {
671
+ // The adapter's own Claude process is a child of this host.
672
+ if (!(await descendsFrom(holder.pid, process.pid)))
673
+ foreign.push(holder);
674
+ }
675
+ return foreign;
271
676
  }
272
677
  /** Hands the session back: stop remote driving and resume following the transcript. */
273
678
  async leaveRemote(nativeId) {
@@ -282,33 +687,61 @@ export class ClaudeDriver extends AcpDriver {
282
687
  }
283
688
  await super.detach(nativeId);
284
689
  this.setMode(nativeId, "idle");
285
- // Skip what the remote turns wrote; those were already streamed live.
286
- this.tails.get(nativeId)?.resumeAtEnd();
690
+ // What the remote turns wrote was already streamed live; anything another
691
+ // Claude wrote in between (it was reopened on the computer) is new.
692
+ this.tails.get(nativeId)?.resumeSkipping((line) => line.includes(REMOTE_ENTRYPOINT));
287
693
  }
288
694
  /**
289
- * With no `linkshell claude` terminal to ask, make sure no other `claude` is
290
- * writing the session before becoming a second writer. If this host or a
291
- * managed terminal wrote it last, only a live process counts; otherwise a
292
- * transcript written moments ago does too (a plain `claude` may be mid-turn).
695
+ * With no `linkshell claude` terminal to ask: another Claude (the desktop
696
+ * app, a `claude` in some terminal) that has the session open, and whether
697
+ * it is in the middle of a turn. It can't be made to step aside, and it won't
698
+ * show what a device does until it is reopened; a device continues the
699
+ * session all the same, once that turn is over.
293
700
  */
294
- async refuseIfTuiMayBeActive(nativeId, cwd) {
295
- const path = findTranscript(this.configDir, nativeId, cwd);
701
+ async elsewhere(nativeId) {
702
+ if (this.modes.get(nativeId) === "remote" || this.host?.desktop(this.id, nativeId))
703
+ return undefined;
704
+ const path = findTranscript(this.configDir, nativeId, this.sessions.get(nativeId)?.cwd ?? "");
296
705
  if (!path)
297
- return;
298
- const known = this.writer(nativeId) !== undefined;
299
- const recentlyWritten = !known && Date.now() - statSync(path).mtimeMs < this.busyWindowMs;
300
- if (!recentlyWritten && !(await this.tuiProcessHolds(nativeId)))
301
- return;
302
- throw RpcError.app("busy", `这个会话正在电脑上用 claude 直接运行,无法安全接管。在电脑终端用 linkshell claude --resume ${nativeId} 打开后即可接力。`);
706
+ return undefined;
707
+ // Claude's own record of who has the session open.
708
+ const holders = await this.foreignHolders(nativeId);
709
+ if (holders.length > 0) {
710
+ const where = holders.some((holder) => holder.entrypoint !== "claude-desktop") ? "terminal" : "app";
711
+ return { where, working: turnInProgress(path, this.busyWindowMs) };
712
+ }
713
+ if (this.hasHolderRecords())
714
+ return undefined;
715
+ // An older Claude keeps no such record: its command line, or a transcript it wrote moments ago, is the sign.
716
+ if (await this.tuiProcessHolds(nativeId))
717
+ return { where: "terminal", working: turnInProgress(path, this.busyWindowMs) };
718
+ const justWritten = Date.now() - statSync(path).mtimeMs < Math.min(this.busyWindowMs, 60_000);
719
+ return justWritten && this.writer(nativeId) === undefined && turnInProgress(path, this.busyWindowMs) ? { where: "terminal", working: true } : undefined;
303
720
  }
304
721
  async tuiProcessHolds(nativeId) {
722
+ return (await this.commandLineHolders(nativeId)).length > 0;
723
+ }
724
+ /** Whether this Claude records who has which session open (see holders.ts). */
725
+ hasHolderRecords() {
726
+ return existsSync(join(this.configDir, "sessions"));
727
+ }
728
+ /** Processes with the session on their command line (`claude --resume <id>`): how an older Claude shows it. */
729
+ async commandLineHolders(nativeId) {
305
730
  try {
306
731
  // -ww: never truncate; the session id is at the end of long command lines.
307
- const { stdout } = await execFileAsync("/bin/ps", ["-Aww", "-o", "command="], { timeout: 5000, maxBuffer: 16 * 1024 * 1024 });
308
- return stdout.split("\n").some((command) => command.includes(nativeId) && !command.includes("claude-agent-acp"));
732
+ const { stdout } = await execFileAsync("/bin/ps", ["-Aww", "-o", "pid=,command="], { timeout: 5000, maxBuffer: 16 * 1024 * 1024 });
733
+ const pids = [];
734
+ for (const line of stdout.split("\n")) {
735
+ if (!line.includes(nativeId) || line.includes("claude-agent-acp"))
736
+ continue;
737
+ const pid = Number(line.trim().split(/\s+/, 1)[0]);
738
+ if (Number.isInteger(pid) && pid !== process.pid)
739
+ pids.push(pid);
740
+ }
741
+ return pids;
309
742
  }
310
743
  catch {
311
- return false;
744
+ return [];
312
745
  }
313
746
  }
314
747
  /** Mirrors remote progress to the terminal that stepped aside. */
@@ -328,4 +761,52 @@ export class ClaudeDriver extends AcpDriver {
328
761
  desktop.activity(" (回合结束)");
329
762
  }
330
763
  }
764
+ /**
765
+ * The option value for a model id from a transcript ("claude-opus-5-5"):
766
+ * "default" when that's what default resolves to, else the entry with that
767
+ * name ("Opus 5.5").
768
+ */
769
+ /**
770
+ * A closing Claude process writes its exit record (last prompt, title, cost)
771
+ * after the adapter has deleted the transcript, bringing the file back. Removes
772
+ * it again for a while after a delete.
773
+ */
774
+ function sweepTranscript(path, forMs = 15_000) {
775
+ const sessionDir = path.replace(/\.jsonl$/, "");
776
+ const until = Date.now() + forMs;
777
+ const sweep = () => {
778
+ for (const target of [path, sessionDir]) {
779
+ if (existsSync(target))
780
+ rmSync(target, { recursive: true, force: true });
781
+ }
782
+ if (Date.now() < until)
783
+ setTimeout(sweep, 250).unref();
784
+ };
785
+ sweep();
786
+ }
787
+ /** The value of a setting that matches what the session was seen running with, if it says. */
788
+ function observedValue(option, observed) {
789
+ if (option.category === "model")
790
+ return observed.model ? matchModel(option, observed.model) : undefined;
791
+ if (option.category === "mode")
792
+ return observed.mode;
793
+ if (option.id === "effort" || option.category === "effort")
794
+ return observed.effort;
795
+ if (option.id === "fast")
796
+ return observed.fast === undefined ? undefined : observed.fast ? "on" : "off";
797
+ return undefined;
798
+ }
799
+ export function matchModel(option, modelId) {
800
+ if (option.values.some((value) => value.value === modelId))
801
+ return modelId;
802
+ const parts = modelId.replace(/^claude-/, "").replace(/-\d{8}$/, "").split("-");
803
+ const family = parts.shift();
804
+ if (!family || parts.length === 0)
805
+ return undefined;
806
+ const name = `${family[0].toUpperCase()}${family.slice(1)} ${parts.join(".")}`;
807
+ const byDefault = option.values.find((value) => value.value === "default" && value.description === name);
808
+ if (byDefault)
809
+ return byDefault.value;
810
+ return option.values.find((value) => value.name === name)?.value;
811
+ }
331
812
  //# sourceMappingURL=driver.js.map