linkshell-cli 0.6.2 → 0.6.4
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/README.md +47 -132
- package/dist/cli/src/auth.js +1 -1
- package/dist/cli/src/auth.js.map +1 -1
- package/dist/cli/src/commands/doctor.d.ts +1 -1
- package/dist/cli/src/commands/doctor.js +76 -36
- package/dist/cli/src/commands/doctor.js.map +1 -1
- package/dist/cli/src/commands/host.d.ts +14 -0
- package/dist/cli/src/commands/host.js +94 -15
- package/dist/cli/src/commands/host.js.map +1 -1
- package/dist/cli/src/commands/logout.d.ts +1 -1
- package/dist/cli/src/commands/logout.js +10 -2
- package/dist/cli/src/commands/logout.js.map +1 -1
- package/dist/cli/src/commands/upgrade.js +11 -0
- package/dist/cli/src/commands/upgrade.js.map +1 -1
- package/dist/cli/src/config.js +1 -1
- package/dist/cli/src/config.js.map +1 -1
- package/dist/cli/src/index.js +94 -114
- package/dist/cli/src/index.js.map +1 -1
- package/dist/cli/tsconfig.tsbuildinfo +1 -1
- 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 +13 -3
- package/dist/host/src/drivers/acp/driver.js +43 -8
- 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 +53 -5
- package/dist/host/src/drivers/acp/mapper.js.map +1 -1
- package/dist/host/src/drivers/claude/driver.d.ts +53 -8
- package/dist/host/src/drivers/claude/driver.js +357 -40
- 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 +100 -62
- package/dist/host/src/drivers/claude/transcript.js +246 -26
- 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 +15 -1
- package/dist/host/src/drivers/codex/driver.js +110 -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 +34 -3
- 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 +9 -0
- package/dist/host/src/gateway.js.map +1 -1
- package/dist/host/src/host.d.ts +7 -3
- package/dist/host/src/host.js +42 -18
- package/dist/host/src/host.js.map +1 -1
- package/dist/host/src/hub.d.ts +91 -15
- package/dist/host/src/hub.js +556 -33
- package/dist/host/src/hub.js.map +1 -1
- package/dist/host/src/rpc/server.d.ts +4 -2
- package/dist/host/src/rpc/server.js +51 -6
- 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 +57 -3
- package/dist/host/src/screen.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 +75 -3
- package/dist/host/src/store.js +216 -16
- package/dist/host/src/store.js.map +1 -1
- package/dist/host/src/terminals.d.ts +4 -4
- 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/wire/src/model.d.ts +301 -153
- package/dist/wire/src/model.js +55 -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 +6740 -1022
- package/dist/wire/src/rpc.js +130 -7
- package/dist/wire/src/rpc.js.map +1 -1
- package/dist/wire/src/updates.d.ts +498 -498
- package/package.json +5 -5
- package/src/auth.ts +1 -1
- package/src/commands/doctor.ts +76 -34
- package/src/commands/host.ts +98 -14
- package/src/commands/logout.ts +10 -2
- package/src/commands/upgrade.ts +11 -0
- package/src/config.ts +1 -1
- 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
|
-
/**
|
|
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
|
-
|
|
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
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
126
|
-
|
|
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
|
|
251
|
+
const observed = this.observed.get(nativeId) ?? {};
|
|
181
252
|
const options = this.template.map(({ source: _source, ...option }) => {
|
|
182
|
-
|
|
183
|
-
|
|
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)
|
|
249
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
//
|
|
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
|
-
//
|
|
414
|
-
|
|
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
|
|
418
|
-
*
|
|
419
|
-
*
|
|
420
|
-
*
|
|
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
|
|
423
|
-
|
|
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
|
-
|
|
427
|
-
const
|
|
428
|
-
if (
|
|
429
|
-
|
|
430
|
-
|
|
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
|
-
|
|
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
|
|
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;
|