linkshell-cli 0.5.2 → 0.5.5
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/cli/src/index.js +22 -0
- package/dist/cli/src/index.js.map +1 -1
- package/dist/cli/src/runtime/acp/acp-client.js +41 -19
- package/dist/cli/src/runtime/acp/acp-client.js.map +1 -1
- package/dist/cli/src/runtime/acp/agent-workspace.d.ts +8 -0
- package/dist/cli/src/runtime/acp/agent-workspace.js +164 -16
- package/dist/cli/src/runtime/acp/agent-workspace.js.map +1 -1
- package/dist/cli/src/runtime/acp/claude-stream-json-client.d.ts +1 -0
- package/dist/cli/src/runtime/acp/claude-stream-json-client.js +18 -0
- package/dist/cli/src/runtime/acp/claude-stream-json-client.js.map +1 -1
- package/dist/cli/src/runtime/bridge-session.d.ts +37 -0
- package/dist/cli/src/runtime/bridge-session.js +333 -28
- package/dist/cli/src/runtime/bridge-session.js.map +1 -1
- package/dist/cli/tsconfig.tsbuildinfo +1 -1
- package/dist/shared-protocol/src/index.d.ts +3810 -2286
- package/dist/shared-protocol/src/index.js +81 -13
- package/dist/shared-protocol/src/index.js.map +1 -1
- package/package.json +3 -3
- package/src/index.ts +28 -0
- package/src/runtime/acp/acp-client.ts +43 -19
- package/src/runtime/acp/agent-workspace.ts +173 -16
- package/src/runtime/acp/claude-stream-json-client.ts +23 -0
- package/src/runtime/bridge-session.ts +348 -39
|
@@ -1597,6 +1597,9 @@ export class AgentWorkspaceProxy {
|
|
|
1597
1597
|
private conversations = new Map<string, AgentConversation>();
|
|
1598
1598
|
private conversationByAgentSessionId = new Map<string, string>();
|
|
1599
1599
|
private timelines = new Map<string, AgentTimelineItem[]>();
|
|
1600
|
+
// Opaque codex turns cursor per conversation, pointing at OLDER history to
|
|
1601
|
+
// page through (captured at hydration, advanced on each history.request).
|
|
1602
|
+
private historyCursors = new Map<string, string | undefined>();
|
|
1600
1603
|
private toolOutputBuffers = new Map<string, string>();
|
|
1601
1604
|
private pendingPermissions = new Map<string, AgentPermission>();
|
|
1602
1605
|
private permissionWaiters = new Map<string, PendingPermissionWaiter>();
|
|
@@ -1647,6 +1650,11 @@ export class AgentWorkspaceProxy {
|
|
|
1647
1650
|
this.sendSnapshot(payload.conversationId);
|
|
1648
1651
|
break;
|
|
1649
1652
|
}
|
|
1653
|
+
case "agent.v2.history.request": {
|
|
1654
|
+
const payload = parseTypedPayload("agent.v2.history.request", envelope.payload);
|
|
1655
|
+
await this.loadOlderHistory(payload);
|
|
1656
|
+
break;
|
|
1657
|
+
}
|
|
1650
1658
|
case "agent.v2.prompt": {
|
|
1651
1659
|
const payload = parseTypedPayload("agent.v2.prompt", envelope.payload);
|
|
1652
1660
|
await this.sendPrompt(payload);
|
|
@@ -1705,6 +1713,28 @@ export class AgentWorkspaceProxy {
|
|
|
1705
1713
|
return this.clients.get(provider);
|
|
1706
1714
|
}
|
|
1707
1715
|
|
|
1716
|
+
private shouldRecycleProviderClient(provider: AgentProvider, error: unknown): boolean {
|
|
1717
|
+
if (provider !== "codex") return false;
|
|
1718
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1719
|
+
return /ACP agent is not running|ACP request timed out|Transport channel closed|TokenRefreshFailed|Failed to parse server response|channel closed|EPIPE|ECONNRESET/i.test(message);
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
private async recycleProviderClient(
|
|
1723
|
+
provider: AgentProvider,
|
|
1724
|
+
error: unknown,
|
|
1725
|
+
): Promise<AcpClient | ClaudeSdkClient | ClaudeStreamJsonClient | undefined> {
|
|
1726
|
+
if (!this.shouldRecycleProviderClient(provider, error)) return undefined;
|
|
1727
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1728
|
+
const existing = this.clients.get(provider);
|
|
1729
|
+
if (existing) {
|
|
1730
|
+
try { existing.stop(); } catch {}
|
|
1731
|
+
}
|
|
1732
|
+
this.clients.delete(provider);
|
|
1733
|
+
this.agentProtocols.delete(provider);
|
|
1734
|
+
this.providerErrors.set(provider, message);
|
|
1735
|
+
return this.ensureProviderClient(provider);
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1708
1738
|
private protocolForProvider(provider: AgentProvider): AgentProtocol | undefined {
|
|
1709
1739
|
return this.agentProtocols.get(provider);
|
|
1710
1740
|
}
|
|
@@ -1866,7 +1896,16 @@ export class AgentWorkspaceProxy {
|
|
|
1866
1896
|
await this.initialize();
|
|
1867
1897
|
for (const [provider, client] of this.clients) {
|
|
1868
1898
|
try {
|
|
1869
|
-
|
|
1899
|
+
let activeClient = client;
|
|
1900
|
+
let result: unknown;
|
|
1901
|
+
try {
|
|
1902
|
+
result = await activeClient.listSessions();
|
|
1903
|
+
} catch (error) {
|
|
1904
|
+
const recovered = await this.recycleProviderClient(provider, error);
|
|
1905
|
+
if (!recovered) throw error;
|
|
1906
|
+
activeClient = recovered;
|
|
1907
|
+
result = await activeClient.listSessions();
|
|
1908
|
+
}
|
|
1870
1909
|
for (const remote of parseRemoteSessions(result)) {
|
|
1871
1910
|
const agentSessionId = remote.id;
|
|
1872
1911
|
const existingId = this.conversationByAgentSessionId.get(agentSessionId);
|
|
@@ -1906,7 +1945,7 @@ export class AgentWorkspaceProxy {
|
|
|
1906
1945
|
conversation: AgentConversation,
|
|
1907
1946
|
seedResult?: unknown,
|
|
1908
1947
|
): Promise<void> {
|
|
1909
|
-
|
|
1948
|
+
let client = this.clientForProvider(conversation.provider);
|
|
1910
1949
|
if (!client || !conversation.agentSessionId) return;
|
|
1911
1950
|
let source = seedResult;
|
|
1912
1951
|
const hasSeedTurns = turnItemsFromThread(threadFromProviderResult(seedResult)).length > 0;
|
|
@@ -1919,6 +1958,8 @@ export class AgentWorkspaceProxy {
|
|
|
1919
1958
|
includeTurns: true,
|
|
1920
1959
|
});
|
|
1921
1960
|
} catch (error) {
|
|
1961
|
+
const recovered = await this.recycleProviderClient(conversation.provider, error);
|
|
1962
|
+
if (recovered) client = recovered;
|
|
1922
1963
|
if (this.input.verbose) {
|
|
1923
1964
|
process.stderr.write(`[agent:v2] thread/read hydration failed for ${conversation.provider}: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
1924
1965
|
}
|
|
@@ -1934,14 +1975,33 @@ export class AgentWorkspaceProxy {
|
|
|
1934
1975
|
}) => Promise<unknown> }).listTurns;
|
|
1935
1976
|
if (typeof listTurns === "function") {
|
|
1936
1977
|
try {
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1978
|
+
let turnsResult: unknown;
|
|
1979
|
+
try {
|
|
1980
|
+
turnsResult = await listTurns.call(client, {
|
|
1981
|
+
sessionId: conversation.agentSessionId,
|
|
1982
|
+
limit: MAX_TIMELINE_ITEMS,
|
|
1983
|
+
sortDirection: "desc",
|
|
1984
|
+
itemsView: "full",
|
|
1985
|
+
});
|
|
1986
|
+
} catch (error) {
|
|
1987
|
+
const recovered = await this.recycleProviderClient(conversation.provider, error);
|
|
1988
|
+
if (!recovered) throw error;
|
|
1989
|
+
client = recovered;
|
|
1990
|
+
const recoveredListTurns = (client as { listTurns?: typeof listTurns }).listTurns;
|
|
1991
|
+
if (typeof recoveredListTurns !== "function") throw error;
|
|
1992
|
+
turnsResult = await recoveredListTurns.call(client, {
|
|
1993
|
+
sessionId: conversation.agentSessionId,
|
|
1994
|
+
limit: MAX_TIMELINE_ITEMS,
|
|
1995
|
+
sortDirection: "desc",
|
|
1996
|
+
itemsView: "full",
|
|
1997
|
+
});
|
|
1998
|
+
}
|
|
1943
1999
|
const turnsThread = threadFromTurnsListResult(conversation.agentSessionId, turnsResult);
|
|
1944
2000
|
if (turnsThread) source = { thread: turnsThread };
|
|
2001
|
+
// Capture the opaque cursor pointing at OLDER turns so the client
|
|
2002
|
+
// can page further back via agent.v2.history.request.
|
|
2003
|
+
const olderCursor = firstString(asRecord(turnsResult), ["nextCursor", "next_cursor", "cursor"]);
|
|
2004
|
+
this.historyCursors.set(conversation.id, olderCursor);
|
|
1945
2005
|
} catch (error) {
|
|
1946
2006
|
if (this.input.verbose) {
|
|
1947
2007
|
process.stderr.write(`[agent:v2] thread/turns/list hydration failed for ${conversation.provider}: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
@@ -2077,7 +2137,7 @@ export class AgentWorkspaceProxy {
|
|
|
2077
2137
|
(agentSessionId ? this.conversations.get(this.conversationByAgentSessionId.get(agentSessionId) ?? "") : undefined);
|
|
2078
2138
|
if (!payload.cwd && existingConversation?.cwd) cwd = existingConversation.cwd;
|
|
2079
2139
|
|
|
2080
|
-
|
|
2140
|
+
let client = await this.ensureProviderClient(provider);
|
|
2081
2141
|
if (!client) {
|
|
2082
2142
|
const message = this.providerErrors.get(provider) ?? `${providerLabel(provider)} 启动失败。请确认 CLI 已安装并可用。`;
|
|
2083
2143
|
if (existingConversation) {
|
|
@@ -2113,7 +2173,15 @@ export class AgentWorkspaceProxy {
|
|
|
2113
2173
|
if ((this.timelines.get(existingConversation.id) ?? []).length === 0 && existingConversation.status !== "running") {
|
|
2114
2174
|
try {
|
|
2115
2175
|
const existingAgentSessionId = existingConversation.agentSessionId!;
|
|
2116
|
-
|
|
2176
|
+
let result: unknown;
|
|
2177
|
+
try {
|
|
2178
|
+
result = await client.loadSession({ sessionId: existingAgentSessionId, cwd });
|
|
2179
|
+
} catch (error) {
|
|
2180
|
+
const recovered = await this.recycleProviderClient(provider, error);
|
|
2181
|
+
if (!recovered) throw error;
|
|
2182
|
+
client = recovered;
|
|
2183
|
+
result = await client.loadSession({ sessionId: existingAgentSessionId, cwd });
|
|
2184
|
+
}
|
|
2117
2185
|
const nextAgentSessionId = this.extractSessionId(result);
|
|
2118
2186
|
if (nextAgentSessionId && nextAgentSessionId !== existingAgentSessionId) {
|
|
2119
2187
|
this.conversationByAgentSessionId.delete(existingAgentSessionId);
|
|
@@ -2141,9 +2209,19 @@ export class AgentWorkspaceProxy {
|
|
|
2141
2209
|
}
|
|
2142
2210
|
|
|
2143
2211
|
try {
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2212
|
+
let result: unknown;
|
|
2213
|
+
try {
|
|
2214
|
+
result = agentSessionId
|
|
2215
|
+
? await client.loadSession({ sessionId: agentSessionId, cwd })
|
|
2216
|
+
: await client.newSession({ cwd });
|
|
2217
|
+
} catch (error) {
|
|
2218
|
+
const recovered = await this.recycleProviderClient(provider, error);
|
|
2219
|
+
if (!recovered) throw error;
|
|
2220
|
+
client = recovered;
|
|
2221
|
+
result = agentSessionId
|
|
2222
|
+
? await client.loadSession({ sessionId: agentSessionId, cwd })
|
|
2223
|
+
: await client.newSession({ cwd });
|
|
2224
|
+
}
|
|
2147
2225
|
agentSessionId = this.extractSessionId(result) ?? agentSessionId ?? id("agent-session");
|
|
2148
2226
|
const now = Date.now();
|
|
2149
2227
|
const conversationId = makeAgentV2RemoteConversationId(provider, agentSessionId);
|
|
@@ -3348,11 +3426,20 @@ export class AgentWorkspaceProxy {
|
|
|
3348
3426
|
if (!conversationId) return waitForResponse ? Promise.resolve({ outcome: { outcome: "cancelled" } }) : undefined;
|
|
3349
3427
|
const requestId = firstString(raw, ["requestId", "id", "permissionId"]) ?? id("perm");
|
|
3350
3428
|
const rawToolCall = asRecord(raw.toolCall) ?? raw;
|
|
3429
|
+
// Codex app-server command/file approvals carry `command`/`reason`/`cwd`
|
|
3430
|
+
// (not toolCall/context). Surface them so the card actually shows what's
|
|
3431
|
+
// being approved; fall back to the Claude/MCP shape otherwise.
|
|
3432
|
+
const codexCommand = firstString(raw, ["command"]);
|
|
3433
|
+
const codexCwd = firstString(raw, ["cwd"]);
|
|
3351
3434
|
const permission: AgentPermission = {
|
|
3352
3435
|
requestId,
|
|
3353
|
-
toolName:
|
|
3354
|
-
|
|
3355
|
-
|
|
3436
|
+
toolName:
|
|
3437
|
+
firstString(rawToolCall, ["toolName", "tool", "name", "title", "kind"]) ??
|
|
3438
|
+
(codexCommand ? "shell" : undefined),
|
|
3439
|
+
toolInput: codexCommand
|
|
3440
|
+
? (codexCwd ? `$ ${codexCommand}\n# cwd: ${codexCwd}` : `$ ${codexCommand}`)
|
|
3441
|
+
: stringify(rawToolCall.input ?? rawToolCall.toolInput ?? rawToolCall),
|
|
3442
|
+
context: firstString(raw, ["context", "description", "message", "title", "reason"]),
|
|
3356
3443
|
options: parsePermissionOptions(raw.options),
|
|
3357
3444
|
};
|
|
3358
3445
|
this.pendingPermissions.set(requestId, permission);
|
|
@@ -3761,6 +3848,76 @@ export class AgentWorkspaceProxy {
|
|
|
3761
3848
|
}));
|
|
3762
3849
|
}
|
|
3763
3850
|
|
|
3851
|
+
/** Page OLDER transcript history for a conversation (client scrolling up).
|
|
3852
|
+
* Only Codex's app-server exposes paginated reads; other providers report
|
|
3853
|
+
* no more history. Pure pass-through — does not touch the bounded live
|
|
3854
|
+
* timeline. The client prepends the returned (ascending) items. */
|
|
3855
|
+
private async loadOlderHistory(payload: {
|
|
3856
|
+
conversationId: string;
|
|
3857
|
+
cursor?: string;
|
|
3858
|
+
limit: number;
|
|
3859
|
+
}): Promise<void> {
|
|
3860
|
+
const sendResult = (
|
|
3861
|
+
items: AgentTimelineItem[],
|
|
3862
|
+
nextCursor?: string,
|
|
3863
|
+
): void => {
|
|
3864
|
+
this.input.send(createEnvelope({
|
|
3865
|
+
type: "agent.v2.history.result",
|
|
3866
|
+
sessionId: this.input.sessionId,
|
|
3867
|
+
payload: {
|
|
3868
|
+
conversationId: payload.conversationId,
|
|
3869
|
+
items,
|
|
3870
|
+
nextCursor,
|
|
3871
|
+
hasMore: Boolean(nextCursor),
|
|
3872
|
+
},
|
|
3873
|
+
}));
|
|
3874
|
+
};
|
|
3875
|
+
|
|
3876
|
+
const conversation = this.conversations.get(payload.conversationId);
|
|
3877
|
+
if (!conversation?.agentSessionId) return sendResult([], undefined);
|
|
3878
|
+
|
|
3879
|
+
const client = this.clientForProvider(conversation.provider);
|
|
3880
|
+
const listTurns = (client as {
|
|
3881
|
+
listTurns?: (input: {
|
|
3882
|
+
sessionId: string;
|
|
3883
|
+
limit?: number;
|
|
3884
|
+
cursor?: string;
|
|
3885
|
+
sortDirection?: "asc" | "desc";
|
|
3886
|
+
itemsView?: "summary" | "full";
|
|
3887
|
+
}) => Promise<unknown>;
|
|
3888
|
+
} | undefined)?.listTurns;
|
|
3889
|
+
if (typeof listTurns !== "function") return sendResult([], undefined);
|
|
3890
|
+
|
|
3891
|
+
// Client-supplied cursor wins; else the one captured at hydration. Absent
|
|
3892
|
+
// cursor means we're already at the start of history.
|
|
3893
|
+
const cursor = payload.cursor ?? this.historyCursors.get(payload.conversationId);
|
|
3894
|
+
if (!cursor) return sendResult([], undefined);
|
|
3895
|
+
|
|
3896
|
+
try {
|
|
3897
|
+
const turnsResult = await listTurns.call(client, {
|
|
3898
|
+
sessionId: conversation.agentSessionId,
|
|
3899
|
+
limit: payload.limit,
|
|
3900
|
+
cursor,
|
|
3901
|
+
sortDirection: "desc",
|
|
3902
|
+
itemsView: "full",
|
|
3903
|
+
});
|
|
3904
|
+
const turnsThread = threadFromTurnsListResult(conversation.agentSessionId, turnsResult);
|
|
3905
|
+
const items = turnsThread
|
|
3906
|
+
? timelineItemsFromProviderThread({ thread: turnsThread }, payload.conversationId)
|
|
3907
|
+
: [];
|
|
3908
|
+
const nextCursor = firstString(asRecord(turnsResult), ["nextCursor", "next_cursor", "cursor"]);
|
|
3909
|
+
this.historyCursors.set(payload.conversationId, nextCursor);
|
|
3910
|
+
sendResult(items, nextCursor);
|
|
3911
|
+
} catch (error) {
|
|
3912
|
+
if (this.input.verbose) {
|
|
3913
|
+
process.stderr.write(
|
|
3914
|
+
`[agent:v2] history page failed for ${conversation.provider}: ${error instanceof Error ? error.message : String(error)}\n`,
|
|
3915
|
+
);
|
|
3916
|
+
}
|
|
3917
|
+
sendResult([], undefined);
|
|
3918
|
+
}
|
|
3919
|
+
}
|
|
3920
|
+
|
|
3764
3921
|
private conversationIdFromParams(params: unknown): string | undefined {
|
|
3765
3922
|
const raw = asRecord(params);
|
|
3766
3923
|
if (!raw) return undefined;
|
|
@@ -120,6 +120,7 @@ function splitImageDataUrl(value: string, fallbackMimeType = "image/png"): { dat
|
|
|
120
120
|
}
|
|
121
121
|
|
|
122
122
|
export class ClaudeStreamJsonClient {
|
|
123
|
+
private static warnedBypass = false;
|
|
123
124
|
private child: ChildProcessWithoutNullStreams | undefined;
|
|
124
125
|
private claudeSessionId: string | undefined;
|
|
125
126
|
private pendingCancel = false;
|
|
@@ -177,6 +178,28 @@ export class ClaudeStreamJsonClient {
|
|
|
177
178
|
|
|
178
179
|
this.pendingCancel = false;
|
|
179
180
|
|
|
181
|
+
// SECURITY: the headless stream-json fallback can only run with
|
|
182
|
+
// --permission-mode bypassPermissions (there is no TTY to prompt on), so
|
|
183
|
+
// remotely-submitted prompts execute tools — including shell — without
|
|
184
|
+
// confirmation. The SDK path (preferred when @anthropic-ai/claude-agent-sdk
|
|
185
|
+
// is installed) routes tool approvals to the mobile UI instead.
|
|
186
|
+
if (/^(1|true|yes)$/i.test(process.env.LINKSHELL_REQUIRE_AGENT_PERMISSIONS ?? "")) {
|
|
187
|
+
throw new Error(
|
|
188
|
+
"Agent permission enforcement is required (LINKSHELL_REQUIRE_AGENT_PERMISSIONS) " +
|
|
189
|
+
"but the Claude stream-json fallback can only run with bypassPermissions. " +
|
|
190
|
+
"Install @anthropic-ai/claude-agent-sdk to enable permission-gated tool approvals.",
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
if (!ClaudeStreamJsonClient.warnedBypass) {
|
|
194
|
+
ClaudeStreamJsonClient.warnedBypass = true;
|
|
195
|
+
process.stderr.write(
|
|
196
|
+
"[bridge] WARNING: Claude stream-json fallback runs with bypassPermissions — " +
|
|
197
|
+
"remotely submitted prompts can execute tools (including shell) without confirmation. " +
|
|
198
|
+
"Install @anthropic-ai/claude-agent-sdk for permission-gated control, or set " +
|
|
199
|
+
"LINKSHELL_REQUIRE_AGENT_PERMISSIONS=true to refuse the unsafe fallback.\n",
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
180
203
|
// Build Claude args
|
|
181
204
|
const args: string[] = [
|
|
182
205
|
"--print",
|