@linkshell/host 0.1.0 → 0.1.2
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.
- package/dist/host/src/carry.d.ts +15 -0
- package/dist/host/src/carry.js +83 -0
- package/dist/host/src/carry.js.map +1 -0
- package/dist/host/src/drivers/acp/driver.d.ts +21 -3
- package/dist/host/src/drivers/acp/driver.js +99 -13
- package/dist/host/src/drivers/acp/driver.js.map +1 -1
- package/dist/host/src/drivers/acp/mapper.d.ts +5 -1
- package/dist/host/src/drivers/acp/mapper.js +58 -6
- package/dist/host/src/drivers/acp/mapper.js.map +1 -1
- package/dist/host/src/drivers/claude/driver.d.ts +76 -8
- package/dist/host/src/drivers/claude/driver.js +510 -29
- package/dist/host/src/drivers/claude/driver.js.map +1 -1
- package/dist/host/src/drivers/claude/holders.d.ts +9 -0
- package/dist/host/src/drivers/claude/holders.js +61 -0
- package/dist/host/src/drivers/claude/holders.js.map +1 -0
- package/dist/host/src/drivers/claude/transcript.d.ts +108 -64
- package/dist/host/src/drivers/claude/transcript.js +260 -22
- package/dist/host/src/drivers/claude/transcript.js.map +1 -1
- package/dist/host/src/drivers/codex/commands.d.ts +27 -0
- package/dist/host/src/drivers/codex/commands.js +23 -0
- package/dist/host/src/drivers/codex/commands.js.map +1 -0
- package/dist/host/src/drivers/codex/driver.d.ts +21 -1
- package/dist/host/src/drivers/codex/driver.js +140 -1
- package/dist/host/src/drivers/codex/driver.js.map +1 -1
- package/dist/host/src/drivers/codex/mapper.d.ts +4 -0
- package/dist/host/src/drivers/codex/mapper.js +29 -5
- package/dist/host/src/drivers/codex/mapper.js.map +1 -1
- package/dist/host/src/drivers/types.d.ts +45 -4
- package/dist/host/src/fs.d.ts +26 -4
- package/dist/host/src/fs.js +71 -17
- package/dist/host/src/fs.js.map +1 -1
- package/dist/host/src/gateway.d.ts +3 -0
- package/dist/host/src/gateway.js +10 -1
- package/dist/host/src/gateway.js.map +1 -1
- package/dist/host/src/host.d.ts +7 -3
- package/dist/host/src/host.js +53 -21
- package/dist/host/src/host.js.map +1 -1
- package/dist/host/src/hub.d.ts +98 -14
- package/dist/host/src/hub.js +639 -27
- package/dist/host/src/hub.js.map +1 -1
- package/dist/host/src/index.d.ts +1 -1
- package/dist/host/src/index.js +1 -1
- package/dist/host/src/index.js.map +1 -1
- package/dist/host/src/legacy.d.ts +7 -0
- package/dist/host/src/legacy.js +36 -0
- package/dist/host/src/legacy.js.map +1 -0
- package/dist/host/src/rpc/server.d.ts +4 -2
- package/dist/host/src/rpc/server.js +62 -5
- package/dist/host/src/rpc/server.js.map +1 -1
- package/dist/host/src/screen.d.ts +11 -0
- package/dist/host/src/screen.js +54 -2
- package/dist/host/src/screen.js.map +1 -1
- package/dist/host/src/shell-env.d.ts +6 -0
- package/dist/host/src/shell-env.js +48 -0
- package/dist/host/src/shell-env.js.map +1 -1
- package/dist/host/src/slim.d.ts +17 -0
- package/dist/host/src/slim.js +66 -0
- package/dist/host/src/slim.js.map +1 -0
- package/dist/host/src/store.d.ts +81 -3
- package/dist/host/src/store.js +249 -16
- package/dist/host/src/store.js.map +1 -1
- package/dist/host/src/terminals.js +19 -4
- package/dist/host/src/terminals.js.map +1 -1
- package/dist/host/src/worktrees.d.ts +36 -0
- package/dist/host/src/worktrees.js +96 -0
- package/dist/host/src/worktrees.js.map +1 -0
- package/dist/host/tsconfig.tsbuildinfo +1 -1
- package/dist/wire/src/model.d.ts +195 -0
- package/dist/wire/src/model.js +68 -0
- package/dist/wire/src/model.js.map +1 -1
- package/dist/wire/src/relay-client.d.ts +2 -0
- package/dist/wire/src/relay-client.js +12 -0
- package/dist/wire/src/relay-client.js.map +1 -1
- package/dist/wire/src/rpc.d.ts +6543 -160
- package/dist/wire/src/rpc.js +154 -6
- package/dist/wire/src/rpc.js.map +1 -1
- package/dist/wire/src/updates.d.ts +79 -79
- package/package.json +3 -2
|
@@ -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 {
|
|
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
|
|
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)
|
|
148
|
-
|
|
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
|
-
|
|
455
|
+
throw RpcError.app("busy", "没能停下电脑上的这一轮:请在电脑上按 Esc 停止它");
|
|
151
456
|
}
|
|
152
457
|
async setConfig(nativeId, optionId, value) {
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
//
|
|
286
|
-
|
|
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
|
|
290
|
-
*
|
|
291
|
-
*
|
|
292
|
-
*
|
|
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
|
|
295
|
-
|
|
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
|
-
|
|
299
|
-
const
|
|
300
|
-
if (
|
|
301
|
-
|
|
302
|
-
|
|
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
|
-
|
|
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
|
|
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
|