linkshell-cli 0.6.2 → 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 (93) 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 +14 -0
  8. package/dist/cli/src/commands/host.js +94 -15
  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 +94 -114
  18. package/dist/cli/src/index.js.map +1 -1
  19. package/dist/cli/tsconfig.tsbuildinfo +1 -1
  20. package/dist/host/src/carry.d.ts +15 -0
  21. package/dist/host/src/carry.js +83 -0
  22. package/dist/host/src/carry.js.map +1 -0
  23. package/dist/host/src/drivers/acp/driver.d.ts +13 -3
  24. package/dist/host/src/drivers/acp/driver.js +43 -8
  25. package/dist/host/src/drivers/acp/driver.js.map +1 -1
  26. package/dist/host/src/drivers/acp/mapper.d.ts +5 -1
  27. package/dist/host/src/drivers/acp/mapper.js +53 -5
  28. package/dist/host/src/drivers/acp/mapper.js.map +1 -1
  29. package/dist/host/src/drivers/claude/driver.d.ts +53 -8
  30. package/dist/host/src/drivers/claude/driver.js +357 -40
  31. package/dist/host/src/drivers/claude/driver.js.map +1 -1
  32. package/dist/host/src/drivers/claude/holders.d.ts +9 -0
  33. package/dist/host/src/drivers/claude/holders.js +61 -0
  34. package/dist/host/src/drivers/claude/holders.js.map +1 -0
  35. package/dist/host/src/drivers/claude/transcript.d.ts +100 -62
  36. package/dist/host/src/drivers/claude/transcript.js +246 -26
  37. package/dist/host/src/drivers/claude/transcript.js.map +1 -1
  38. package/dist/host/src/drivers/codex/commands.d.ts +27 -0
  39. package/dist/host/src/drivers/codex/commands.js +23 -0
  40. package/dist/host/src/drivers/codex/commands.js.map +1 -0
  41. package/dist/host/src/drivers/codex/driver.d.ts +15 -1
  42. package/dist/host/src/drivers/codex/driver.js +110 -1
  43. package/dist/host/src/drivers/codex/driver.js.map +1 -1
  44. package/dist/host/src/drivers/codex/mapper.d.ts +4 -0
  45. package/dist/host/src/drivers/codex/mapper.js +29 -5
  46. package/dist/host/src/drivers/codex/mapper.js.map +1 -1
  47. package/dist/host/src/drivers/types.d.ts +34 -3
  48. package/dist/host/src/fs.d.ts +26 -4
  49. package/dist/host/src/fs.js +71 -17
  50. package/dist/host/src/fs.js.map +1 -1
  51. package/dist/host/src/gateway.d.ts +3 -0
  52. package/dist/host/src/gateway.js +9 -0
  53. package/dist/host/src/gateway.js.map +1 -1
  54. package/dist/host/src/host.d.ts +7 -3
  55. package/dist/host/src/host.js +42 -18
  56. package/dist/host/src/host.js.map +1 -1
  57. package/dist/host/src/hub.d.ts +91 -15
  58. package/dist/host/src/hub.js +556 -33
  59. package/dist/host/src/hub.js.map +1 -1
  60. package/dist/host/src/rpc/server.d.ts +4 -2
  61. package/dist/host/src/rpc/server.js +51 -6
  62. package/dist/host/src/rpc/server.js.map +1 -1
  63. package/dist/host/src/screen.d.ts +11 -0
  64. package/dist/host/src/screen.js +54 -2
  65. package/dist/host/src/screen.js.map +1 -1
  66. package/dist/host/src/slim.d.ts +17 -0
  67. package/dist/host/src/slim.js +66 -0
  68. package/dist/host/src/slim.js.map +1 -0
  69. package/dist/host/src/store.d.ts +75 -3
  70. package/dist/host/src/store.js +216 -16
  71. package/dist/host/src/store.js.map +1 -1
  72. package/dist/host/src/terminals.d.ts +4 -4
  73. package/dist/host/src/worktrees.d.ts +36 -0
  74. package/dist/host/src/worktrees.js +96 -0
  75. package/dist/host/src/worktrees.js.map +1 -0
  76. package/dist/wire/src/model.d.ts +301 -153
  77. package/dist/wire/src/model.js +55 -0
  78. package/dist/wire/src/model.js.map +1 -1
  79. package/dist/wire/src/relay-client.d.ts +2 -0
  80. package/dist/wire/src/relay-client.js +12 -0
  81. package/dist/wire/src/relay-client.js.map +1 -1
  82. package/dist/wire/src/rpc.d.ts +6740 -1022
  83. package/dist/wire/src/rpc.js +130 -7
  84. package/dist/wire/src/rpc.js.map +1 -1
  85. package/dist/wire/src/updates.d.ts +498 -498
  86. package/package.json +5 -5
  87. package/src/auth.ts +1 -1
  88. package/src/commands/doctor.ts +76 -34
  89. package/src/commands/host.ts +98 -14
  90. package/src/commands/logout.ts +10 -2
  91. package/src/commands/upgrade.ts +11 -0
  92. package/src/config.ts +1 -1
  93. package/src/index.ts +93 -131
@@ -1,6 +1,6 @@
1
1
  import { type ContentBlock, type SessionConfigOption, type SessionUpdate } from "@linkshell/wire";
2
2
  import { AcpDriver } from "../acp/driver.js";
3
- import type { AttachContext, DesktopLaunch, DesktopLaunchContext, DiscoveredSession, HistoryItem, LaunchSpec } from "../types.js";
3
+ import type { AttachContext, DesktopLaunch, DesktopLaunchContext, DiscoveredSession, ForkOptions, HistoryItem, LaunchSpec } from "../types.js";
4
4
  /** Finds an executable on the given PATH (the adapter needs an absolute path). */
5
5
  export declare function resolveExecutable(name: string, env: NodeJS.ProcessEnv): string | undefined;
6
6
  export interface ClaudeDriverOptions {
@@ -13,8 +13,10 @@ export interface ClaudeDriverOptions {
13
13
  command: string;
14
14
  args: string[];
15
15
  };
16
- /** A transcript written within this window counts as "in use" by an unmanaged TUI. */
16
+ /** Another Claude's turn that has written nothing for this long no longer counts as running. */
17
17
  busyWindowMs?: number;
18
+ /** How often to check that a session driven from a device wasn't reopened on the computer. */
19
+ holderCheckMs?: number;
18
20
  }
19
21
  /**
20
22
  * Claude Code, tier handoff. On the desktop the native TUI owns the session
@@ -36,15 +38,25 @@ export declare class ClaudeDriver extends AcpDriver {
36
38
  private template?;
37
39
  private templateLoading?;
38
40
  /** Desktop-driven sessions: the model of the latest reply, from the transcript. */
39
- private readonly lastModels;
41
+ /** Per session: the settings its transcript shows it running with (what the Claude on the computer is set to). */
42
+ private readonly observed;
40
43
  /** Per session: TodoWrite calls shown as the plan, whose results the tail skips. */
41
44
  private readonly hiddenTools;
45
+ /** Per session: calls that started an agent in the background and haven't heard back (see transcriptLine). */
46
+ private readonly backgroundAgents;
47
+ /** Ids of the transcript lines read lately: Claude writes some again after compacting. */
48
+ private readonly seenLines;
42
49
  private readonly claudeCommand;
43
50
  private readonly configDir;
44
51
  private readonly busyWindowMs;
52
+ private readonly holderCheckMs;
45
53
  private claudePath?;
46
54
  constructor(options: ClaudeDriverOptions);
47
55
  private readonly adapterMissing;
56
+ /** Sent from a device while another Claude was mid-turn in the session; goes out when that turn ends. */
57
+ private readonly waiting;
58
+ /** Checks that sessions driven from a device haven't been reopened on the computer. */
59
+ private remoteWatch?;
48
60
  start(host: import("../types.js").DriverHost): Promise<import("../types.js").DriverStatus>;
49
61
  stop(): Promise<void>;
50
62
  attach(nativeId: string, context: AttachContext): Promise<HistoryItem[]>;
@@ -63,11 +75,30 @@ export declare class ClaudeDriver extends AcpDriver {
63
75
  /** The `linkshell claude` terminal went away (the user quit the TUI). */
64
76
  desktopDetached(nativeId: string): void;
65
77
  detach(nativeId: string): Promise<void>;
78
+ /**
79
+ * A copy of the session's transcript under a new id (the SDK's fork), all of
80
+ * it or through a turn. A fork that works somewhere else (a worktree) has
81
+ * its transcript moved to that directory's project folder, where Claude
82
+ * looks for the sessions of a directory.
83
+ */
84
+ fork(nativeId: string, options: ForkOptions): Promise<DiscoveredSession>;
66
85
  /** Claude's own title record (what /rename writes), so `claude --resume` shows it too. */
67
86
  rename(nativeId: string, title: string): Promise<void>;
68
87
  delete(nativeId: string): Promise<void>;
69
88
  prompt(nativeId: string, content: ContentBlock[], clientMessageId: string): Promise<"started" | "steered" | "queued">;
89
+ unqueue(nativeId: string, clientMessageId: string): boolean;
90
+ reorderQueue(nativeId: string, clientMessageIds: string[]): void;
91
+ private reportWaiting;
92
+ /** Sends what waited for another Claude's turn, now that it has ended. */
93
+ private sendWaiting;
70
94
  cancel(nativeId: string): Promise<void>;
95
+ sendQueuedNow(nativeId: string): Promise<void>;
96
+ /**
97
+ * Stops the turn another Claude (the desktop app, a `claude` in a terminal)
98
+ * is running in this session, the way Ctrl-C would: asked to stop first,
99
+ * told to quit if it doesn't. Only ever the processes that have this session open.
100
+ */
101
+ private interruptElsewhere;
71
102
  setConfig(nativeId: string, optionId: string, value: string): Promise<void>;
72
103
  takeover(nativeId: string): Promise<void>;
73
104
  reclaim(nativeId: string, _context: DesktopLaunchContext): Promise<LaunchSpec>;
@@ -83,16 +114,30 @@ export declare class ClaudeDriver extends AcpDriver {
83
114
  private onTranscriptLine;
84
115
  /** Makes this host the session's only writer, taking it from the desktop if needed. */
85
116
  private ensureRemote;
117
+ private startWatch;
118
+ /**
119
+ * A session driven from a device that another Claude wrote to (the desktop
120
+ * app, or a `claude` in a terminal, continued it): that process has the
121
+ * conversation now, so stop writing to it and follow what it does instead.
122
+ */
123
+ private watchRemoteSessions;
124
+ /** Running Claude processes on this session that this host didn't start. */
125
+ private foreignHolders;
86
126
  /** Hands the session back: stop remote driving and resume following the transcript. */
87
127
  private leaveRemote;
88
128
  /**
89
- * With no `linkshell claude` terminal to ask, make sure no other `claude` is
90
- * writing the session before becoming a second writer. If this host or a
91
- * managed terminal wrote it last, only a live process counts; otherwise a
92
- * transcript written moments ago does too (a plain `claude` may be mid-turn).
129
+ * With no `linkshell claude` terminal to ask: another Claude (the desktop
130
+ * app, a `claude` in some terminal) that has the session open, and whether
131
+ * it is in the middle of a turn. It can't be made to step aside, and it won't
132
+ * show what a device does until it is reopened; a device continues the
133
+ * session all the same, once that turn is over.
93
134
  */
94
- private refuseIfTuiMayBeActive;
135
+ private elsewhere;
95
136
  private tuiProcessHolds;
137
+ /** Whether this Claude records who has which session open (see holders.ts). */
138
+ private hasHolderRecords;
139
+ /** Processes with the session on their command line (`claude --resume <id>`): how an older Claude shows it. */
140
+ private commandLineHolders;
96
141
  /** Mirrors remote progress to the terminal that stepped aside. */
97
142
  protected emit(nativeId: string, update: SessionUpdate): void;
98
143
  }
@@ -1,15 +1,60 @@
1
1
  import { execFile } from "node:child_process";
2
- import { accessSync, constants, existsSync, rmSync, 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))
@@ -61,12 +106,18 @@ export class ClaudeDriver extends AcpDriver {
61
106
  template;
62
107
  templateLoading;
63
108
  /** Desktop-driven sessions: the model of the latest reply, from the transcript. */
64
- lastModels = new Map();
109
+ /** Per session: the settings its transcript shows it running with (what the Claude on the computer is set to). */
110
+ observed = new Map();
65
111
  /** Per session: TodoWrite calls shown as the plan, whose results the tail skips. */
66
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();
67
117
  claudeCommand;
68
118
  configDir;
69
119
  busyWindowMs;
120
+ holderCheckMs;
70
121
  claudePath;
71
122
  constructor(options) {
72
123
  const env = options.env ?? process.env;
@@ -90,11 +141,16 @@ export class ClaudeDriver extends AcpDriver {
90
141
  this.claudeCommand = claudeCommand;
91
142
  this.claudePath = claudePath;
92
143
  this.configDir = claudeConfigDir(env);
93
- this.busyWindowMs = options.busyWindowMs ?? 60_000;
144
+ this.busyWindowMs = options.busyWindowMs ?? 10 * 60_000;
145
+ this.holderCheckMs = options.holderCheckMs ?? 3000;
94
146
  this.adapterMissing = adapterMissing;
95
147
  this.capabilities = { ...this.capabilities, models: true, modes: true };
96
148
  }
97
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;
98
154
  async start(host) {
99
155
  if (!this.adapterMissing)
100
156
  return super.start(host);
@@ -107,6 +163,8 @@ export class ClaudeDriver extends AcpDriver {
107
163
  return this.current;
108
164
  }
109
165
  async stop() {
166
+ clearInterval(this.remoteWatch);
167
+ this.remoteWatch = undefined;
110
168
  for (const tail of this.tails.values())
111
169
  tail.stop();
112
170
  this.tails.clear();
@@ -120,10 +178,23 @@ export class ClaudeDriver extends AcpDriver {
120
178
  if (path) {
121
179
  const transcript = readTranscript(path);
122
180
  state.tracker = new AcpItemTracker();
123
- 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
+ }
124
194
  offset = transcript.size;
125
- if (transcript.model)
126
- this.lastModels.set(nativeId, transcript.model);
195
+ this.backgroundAgents.set(nativeId, transcript.agents);
196
+ this.seenLines.set(nativeId, transcript.seen);
197
+ this.observed.set(nativeId, transcript.settings);
127
198
  if (transcript.title)
128
199
  this.host?.update(this.id, nativeId, { sessionUpdate: "session_info_update", title: transcript.title });
129
200
  }
@@ -177,15 +248,10 @@ export class ClaudeDriver extends AcpDriver {
177
248
  if (!this.template || this.modes.get(nativeId) === "remote")
178
249
  return;
179
250
  const pending = this.pendingConfig(nativeId);
180
- const model = this.lastModels.get(nativeId);
251
+ const observed = this.observed.get(nativeId) ?? {};
181
252
  const options = this.template.map(({ source: _source, ...option }) => {
182
- let current = option.current;
183
- if (option.category === "model" && model)
184
- current = matchModel(option, model) ?? current;
185
- const chosen = pending[option.id];
186
- if (chosen && option.values.some((value) => value.value === chosen))
187
- current = chosen;
188
- return { ...option, current };
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 };
189
255
  });
190
256
  this.host?.update(this.id, nativeId, { sessionUpdate: "ls_config", options });
191
257
  }
@@ -211,6 +277,43 @@ export class ClaudeDriver extends AcpDriver {
211
277
  this.tails.delete(nativeId);
212
278
  await super.detach(nativeId);
213
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
+ }
214
317
  /** Claude's own title record (what /rename writes), so `claude --resume` shows it too. */
215
318
  async rename(nativeId, title) {
216
319
  const { renameSession } = await import("@anthropic-ai/claude-agent-sdk");
@@ -236,19 +339,120 @@ export class ClaudeDriver extends AcpDriver {
236
339
  this.sessions.delete(nativeId);
237
340
  this.modes.delete(nativeId);
238
341
  this.hiddenTools.delete(nativeId);
342
+ this.backgroundAgents.delete(nativeId);
239
343
  if (transcript)
240
344
  sweepTranscript(transcript);
241
345
  }
242
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
+ }
243
356
  // Sending from a device is taking the session over.
244
357
  await this.ensureRemote(nativeId);
245
358
  return super.prompt(nativeId, content, clientMessageId);
246
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
+ }
247
405
  async cancel(nativeId) {
248
- if (this.modes.get(nativeId) !== "remote") {
249
- 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;
250
454
  }
251
- await super.cancel(nativeId);
455
+ throw RpcError.app("busy", "没能停下电脑上的这一轮:请在电脑上按 Esc 停止它");
252
456
  }
253
457
  async setConfig(nativeId, optionId, value) {
254
458
  if (this.modes.get(nativeId) === "remote" && this.sessions.get(nativeId)?.loaded) {
@@ -345,11 +549,23 @@ export class ClaudeDriver extends AcpDriver {
345
549
  let hidden = this.hiddenTools.get(nativeId);
346
550
  if (!hidden)
347
551
  this.hiddenTools.set(nativeId, (hidden = new Set()));
348
- const result = transcriptLine(line, { hidden });
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 });
349
559
  if (result.title)
350
560
  this.host?.update(this.id, nativeId, { sessionUpdate: "session_info_update", title: result.title });
351
561
  for (const update of result.updates)
352
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
+ }
353
569
  }
354
570
  /** Makes this host the session's only writer, taking it from the desktop if needed. */
355
571
  async ensureRemote(nativeId) {
@@ -363,11 +579,16 @@ export class ClaudeDriver extends AcpDriver {
363
579
  throw RpcError.app("not_ready", "这个 Claude 会话还没有任何消息:先在电脑上发一条");
364
580
  }
365
581
  const desktop = this.host?.desktop(this.id, nativeId);
582
+ let alsoOpenIn;
366
583
  if (desktop) {
367
584
  await desktop.yield();
368
585
  }
369
586
  else if (this.modes.get(nativeId) !== "remote") {
370
- 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;
371
592
  }
372
593
  // Take in the TUI's last lines, then stop following: ACP streams from here on.
373
594
  const tail = this.tails.get(nativeId);
@@ -383,9 +604,12 @@ export class ClaudeDriver extends AcpDriver {
383
604
  target.config = toConfigOptions(response);
384
605
  target.loaded = true;
385
606
  }
386
- // Settings chosen while the desktop had the session.
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));
387
611
  const pending = this.pendingConfig(nativeId);
388
- for (const [optionId, value] of Object.entries(pending)) {
612
+ for (const [optionId, value] of Object.entries({ ...kept, ...pending })) {
389
613
  const option = target.config.find((entry) => entry.id === optionId);
390
614
  if (option && option.current !== value && option.values.some((entry) => entry.value === value)) {
391
615
  await super.setConfig(nativeId, optionId, value).catch((error) => this.host?.log(`[claude] couldn't apply ${optionId}: ${String(error)}`));
@@ -396,6 +620,59 @@ export class ClaudeDriver extends AcpDriver {
396
620
  this.setWriter(nativeId, "remote");
397
621
  this.setMode(nativeId, "remote");
398
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;
399
676
  }
400
677
  /** Hands the session back: stop remote driving and resume following the transcript. */
401
678
  async leaveRemote(nativeId) {
@@ -410,33 +687,61 @@ export class ClaudeDriver extends AcpDriver {
410
687
  }
411
688
  await super.detach(nativeId);
412
689
  this.setMode(nativeId, "idle");
413
- // Skip what the remote turns wrote; those were already streamed live.
414
- 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));
415
693
  }
416
694
  /**
417
- * With no `linkshell claude` terminal to ask, make sure no other `claude` is
418
- * writing the session before becoming a second writer. If this host or a
419
- * managed terminal wrote it last, only a live process counts; otherwise a
420
- * 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.
421
700
  */
422
- async refuseIfTuiMayBeActive(nativeId, cwd) {
423
- 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 ?? "");
424
705
  if (!path)
425
- return;
426
- const known = this.writer(nativeId) !== undefined;
427
- const recentlyWritten = !known && Date.now() - statSync(path).mtimeMs < this.busyWindowMs;
428
- if (!recentlyWritten && !(await this.tuiProcessHolds(nativeId)))
429
- return;
430
- 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;
431
720
  }
432
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) {
433
730
  try {
434
731
  // -ww: never truncate; the session id is at the end of long command lines.
435
- const { stdout } = await execFileAsync("/bin/ps", ["-Aww", "-o", "command="], { timeout: 5000, maxBuffer: 16 * 1024 * 1024 });
436
- 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;
437
742
  }
438
743
  catch {
439
- return false;
744
+ return [];
440
745
  }
441
746
  }
442
747
  /** Mirrors remote progress to the terminal that stepped aside. */
@@ -479,6 +784,18 @@ function sweepTranscript(path, forMs = 15_000) {
479
784
  };
480
785
  sweep();
481
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
+ }
482
799
  export function matchModel(option, modelId) {
483
800
  if (option.values.some((value) => value.value === modelId))
484
801
  return modelId;