linkshell-cli 0.2.85 → 0.2.87
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 +0 -0
- package/dist/cli/src/runtime/acp/agent-session.d.ts +1 -0
- package/dist/cli/src/runtime/acp/agent-session.js +77 -7
- package/dist/cli/src/runtime/acp/agent-session.js.map +1 -1
- package/dist/cli/src/runtime/acp/agent-workspace.d.ts +1 -0
- package/dist/cli/src/runtime/acp/agent-workspace.js +80 -6
- package/dist/cli/src/runtime/acp/agent-workspace.js.map +1 -1
- package/dist/cli/src/runtime/bridge-session.d.ts +1 -0
- package/dist/cli/src/runtime/bridge-session.js +111 -32
- package/dist/cli/src/runtime/bridge-session.js.map +1 -1
- package/dist/cli/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/runtime/acp/agent-session.ts +76 -7
- package/src/runtime/acp/agent-workspace.ts +80 -6
- package/src/runtime/bridge-session.ts +128 -32
|
@@ -169,11 +169,55 @@ function toolInputFromItem(item: Record<string, unknown>): string | undefined {
|
|
|
169
169
|
}
|
|
170
170
|
if (itemType === "fileChange") {
|
|
171
171
|
const changes = Array.isArray(item.changes) ? item.changes : [];
|
|
172
|
-
return summarizeFileChanges(changes);
|
|
172
|
+
return summarizeFileChanges(changes) ?? firstString(item, ["path", "file", "filePath", "absolutePath", "relativePath"]);
|
|
173
173
|
}
|
|
174
174
|
return stringifyDefined(item.arguments ?? item.input ?? item.toolInput);
|
|
175
175
|
}
|
|
176
176
|
|
|
177
|
+
function looksLikeDiff(text: string): boolean {
|
|
178
|
+
const value = text.trim();
|
|
179
|
+
return (
|
|
180
|
+
value.startsWith("diff --git ") ||
|
|
181
|
+
value.startsWith("@@ ") ||
|
|
182
|
+
value.includes("\n@@ ") ||
|
|
183
|
+
(value.includes("\n--- ") && value.includes("\n+++ "))
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function collectDiffStrings(value: unknown, depth = 0): string[] {
|
|
188
|
+
if (depth > 6 || value === undefined || value === null) return [];
|
|
189
|
+
if (typeof value === "string") return looksLikeDiff(value) ? [value] : [];
|
|
190
|
+
if (Array.isArray(value)) return value.flatMap((entry) => collectDiffStrings(entry, depth + 1));
|
|
191
|
+
const raw = asRecord(value);
|
|
192
|
+
if (!raw) return [];
|
|
193
|
+
const direct: string[] = [];
|
|
194
|
+
const nested: string[] = [];
|
|
195
|
+
for (const [key, entry] of Object.entries(raw)) {
|
|
196
|
+
const lowerKey = key.toLowerCase();
|
|
197
|
+
const isDiffField =
|
|
198
|
+
lowerKey.includes("diff") ||
|
|
199
|
+
lowerKey.includes("patch") ||
|
|
200
|
+
lowerKey.includes("unified");
|
|
201
|
+
if (typeof entry === "string" && isDiffField && entry.trim()) {
|
|
202
|
+
direct.push(entry);
|
|
203
|
+
} else if (typeof entry === "object" && entry) {
|
|
204
|
+
nested.push(...collectDiffStrings(entry, depth + 1));
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return [...direct, ...nested].filter((entry) => looksLikeDiff(entry));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function extractDiffText(value: unknown): string | undefined {
|
|
211
|
+
const diffs = collectDiffStrings(value)
|
|
212
|
+
.map((entry) => entry.trim())
|
|
213
|
+
.filter(Boolean);
|
|
214
|
+
if (diffs.length === 0) return undefined;
|
|
215
|
+
return diffs
|
|
216
|
+
.filter((entry, index, array) => array.indexOf(entry) === index)
|
|
217
|
+
.join("\n\n")
|
|
218
|
+
.slice(0, 24_000);
|
|
219
|
+
}
|
|
220
|
+
|
|
177
221
|
function summarizeFileChanges(changes: unknown[]): string | undefined {
|
|
178
222
|
const lines = changes
|
|
179
223
|
.map((change) => {
|
|
@@ -183,7 +227,7 @@ function summarizeFileChanges(changes: unknown[]): string | undefined {
|
|
|
183
227
|
firstString(raw, ["path", "file", "filePath", "absolutePath", "relativePath"]) ??
|
|
184
228
|
firstString(asRecord(raw.update) ?? {}, ["path", "file", "filePath"]);
|
|
185
229
|
const kind = firstString(raw, ["kind", "type", "operation", "action"]);
|
|
186
|
-
return [kind, path].filter(Boolean).join(" ");
|
|
230
|
+
return [kind, path].filter(Boolean).join(" ") || path;
|
|
187
231
|
})
|
|
188
232
|
.filter((line): line is string => Boolean(line));
|
|
189
233
|
return lines.length > 0 ? lines.slice(0, 8).join("\n") : undefined;
|
|
@@ -449,7 +493,6 @@ export class AgentSessionProxy {
|
|
|
449
493
|
method.startsWith("mcpServer/startupStatus/") ||
|
|
450
494
|
method === "thread/status/changed" ||
|
|
451
495
|
method === "thread/tokenUsage/updated" ||
|
|
452
|
-
method === "turn/diff/updated" ||
|
|
453
496
|
method === "serverRequest/resolved" ||
|
|
454
497
|
method === "mcpServer/oauthLogin/completed"
|
|
455
498
|
) {
|
|
@@ -502,6 +545,9 @@ export class AgentSessionProxy {
|
|
|
502
545
|
case "item/fileChange/patchUpdated":
|
|
503
546
|
this.handleFilePatchUpdated(params);
|
|
504
547
|
return;
|
|
548
|
+
case "turn/diff/updated":
|
|
549
|
+
this.handleTurnDiffUpdated(params);
|
|
550
|
+
return;
|
|
505
551
|
case "command/exec/outputDelta":
|
|
506
552
|
this.handleCommandExecDelta(params);
|
|
507
553
|
return;
|
|
@@ -700,7 +746,7 @@ export class AgentSessionProxy {
|
|
|
700
746
|
if (!raw) return;
|
|
701
747
|
const itemId = firstString(raw, ["itemId", "id"]) ?? id("file");
|
|
702
748
|
const changes = Array.isArray(raw.changes) ? raw.changes : [];
|
|
703
|
-
const output = summarizeFileChanges(changes);
|
|
749
|
+
const output = extractDiffText(raw) ?? summarizeFileChanges(changes);
|
|
704
750
|
const existing = this.toolCalls.get(itemId);
|
|
705
751
|
const toolCall: AgentToolCall = {
|
|
706
752
|
id: itemId,
|
|
@@ -714,6 +760,26 @@ export class AgentSessionProxy {
|
|
|
714
760
|
this.sendUpdate({ kind: "tool_call", toolCall, status: "running" });
|
|
715
761
|
}
|
|
716
762
|
|
|
763
|
+
private handleTurnDiffUpdated(params: unknown): void {
|
|
764
|
+
const raw = asRecord(params);
|
|
765
|
+
if (!raw) return;
|
|
766
|
+
const diff = extractDiffText(raw);
|
|
767
|
+
if (!diff) return;
|
|
768
|
+
const itemId = firstString(raw, ["itemId", "id", "turnId"]) ?? "workspace-diff";
|
|
769
|
+
const changes = Array.isArray(raw.changes) ? raw.changes : [];
|
|
770
|
+
const existing = this.toolCalls.get(itemId);
|
|
771
|
+
const toolCall: AgentToolCall = {
|
|
772
|
+
id: itemId,
|
|
773
|
+
name: existing?.name ?? "文件修改",
|
|
774
|
+
input: existing?.input ?? summarizeFileChanges(changes),
|
|
775
|
+
output: diff,
|
|
776
|
+
createdAt: existing?.createdAt ?? Date.now(),
|
|
777
|
+
status: existing?.status ?? "running",
|
|
778
|
+
};
|
|
779
|
+
this.toolCalls.set(itemId, toolCall);
|
|
780
|
+
this.sendUpdate({ kind: "tool_call", toolCall, status: "running" });
|
|
781
|
+
}
|
|
782
|
+
|
|
717
783
|
private handleCommandExecDelta(params: unknown): void {
|
|
718
784
|
const raw = asRecord(params);
|
|
719
785
|
if (!raw) return;
|
|
@@ -766,15 +832,18 @@ export class AgentSessionProxy {
|
|
|
766
832
|
const itemType = firstString(item, ["type"]);
|
|
767
833
|
const name = toolNameFromItem(item);
|
|
768
834
|
if (!name && !isToolItemType(itemType)) return undefined;
|
|
769
|
-
const
|
|
835
|
+
const bufferedOutput = this.toolOutputBuffers.get(itemId);
|
|
836
|
+
const rawOutput =
|
|
770
837
|
firstString(item, ["aggregatedOutput", "output", "stdout", "stderr"]) ??
|
|
771
838
|
stringifyDefined(item.result ?? item.error ?? item.contentItems);
|
|
772
|
-
const
|
|
839
|
+
const output = itemType === "fileChange"
|
|
840
|
+
? extractDiffText(item) ?? bufferedOutput ?? rawOutput
|
|
841
|
+
: rawOutput ?? bufferedOutput;
|
|
773
842
|
return {
|
|
774
843
|
id: itemId,
|
|
775
844
|
name: name ?? "工具",
|
|
776
845
|
input: toolInputFromItem(item),
|
|
777
|
-
output
|
|
846
|
+
output,
|
|
778
847
|
createdAt: Date.now(),
|
|
779
848
|
status: normalizeToolStatus(item.status, fallbackStatus === "completed"),
|
|
780
849
|
};
|
|
@@ -195,12 +195,56 @@ function summarizeFileChanges(changes: unknown[]): string | undefined {
|
|
|
195
195
|
firstString(raw, ["path", "file", "filePath", "absolutePath", "relativePath"]) ??
|
|
196
196
|
firstString(asRecord(raw.update), ["path", "file", "filePath"]);
|
|
197
197
|
const kind = firstString(raw, ["kind", "type", "operation", "action"]);
|
|
198
|
-
return [kind, path].filter(Boolean).join(" ");
|
|
198
|
+
return [kind, path].filter(Boolean).join(" ") || path;
|
|
199
199
|
})
|
|
200
200
|
.filter((line): line is string => Boolean(line));
|
|
201
201
|
return lines.length > 0 ? lines.slice(0, 8).join("\n") : undefined;
|
|
202
202
|
}
|
|
203
203
|
|
|
204
|
+
function looksLikeDiff(text: string): boolean {
|
|
205
|
+
const value = text.trim();
|
|
206
|
+
return (
|
|
207
|
+
value.startsWith("diff --git ") ||
|
|
208
|
+
value.startsWith("@@ ") ||
|
|
209
|
+
value.includes("\n@@ ") ||
|
|
210
|
+
(value.includes("\n--- ") && value.includes("\n+++ "))
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function collectDiffStrings(value: unknown, depth = 0): string[] {
|
|
215
|
+
if (depth > 6 || value === undefined || value === null) return [];
|
|
216
|
+
if (typeof value === "string") return looksLikeDiff(value) ? [value] : [];
|
|
217
|
+
if (Array.isArray(value)) return value.flatMap((entry) => collectDiffStrings(entry, depth + 1));
|
|
218
|
+
const raw = asRecord(value);
|
|
219
|
+
if (!raw) return [];
|
|
220
|
+
const direct: string[] = [];
|
|
221
|
+
const nested: string[] = [];
|
|
222
|
+
for (const [key, entry] of Object.entries(raw)) {
|
|
223
|
+
const lowerKey = key.toLowerCase();
|
|
224
|
+
const isDiffField =
|
|
225
|
+
lowerKey.includes("diff") ||
|
|
226
|
+
lowerKey.includes("patch") ||
|
|
227
|
+
lowerKey.includes("unified");
|
|
228
|
+
if (typeof entry === "string" && isDiffField && entry.trim()) {
|
|
229
|
+
direct.push(entry);
|
|
230
|
+
} else if (typeof entry === "object" && entry) {
|
|
231
|
+
nested.push(...collectDiffStrings(entry, depth + 1));
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return [...direct, ...nested].filter((entry) => looksLikeDiff(entry));
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function extractDiffText(value: unknown): string | undefined {
|
|
238
|
+
const diffs = collectDiffStrings(value)
|
|
239
|
+
.map((entry) => entry.trim())
|
|
240
|
+
.filter(Boolean);
|
|
241
|
+
if (diffs.length === 0) return undefined;
|
|
242
|
+
return diffs
|
|
243
|
+
.filter((entry, index, array) => array.indexOf(entry) === index)
|
|
244
|
+
.join("\n\n")
|
|
245
|
+
.slice(0, 24_000);
|
|
246
|
+
}
|
|
247
|
+
|
|
204
248
|
function toolInputFromItem(item: Record<string, unknown>): string | undefined {
|
|
205
249
|
const itemType = firstString(item, ["type"]);
|
|
206
250
|
if (itemType === "commandExecution") {
|
|
@@ -211,7 +255,7 @@ function toolInputFromItem(item: Record<string, unknown>): string | undefined {
|
|
|
211
255
|
}
|
|
212
256
|
if (itemType === "fileChange") {
|
|
213
257
|
const changes = Array.isArray(item.changes) ? item.changes : [];
|
|
214
|
-
return summarizeFileChanges(changes);
|
|
258
|
+
return summarizeFileChanges(changes) ?? firstString(item, ["path", "file", "filePath", "absolutePath", "relativePath"]);
|
|
215
259
|
}
|
|
216
260
|
return stringifyDefined(item.arguments ?? item.input ?? item.toolInput);
|
|
217
261
|
}
|
|
@@ -645,7 +689,6 @@ export class AgentWorkspaceProxy {
|
|
|
645
689
|
method.startsWith("mcpServer/startupStatus/") ||
|
|
646
690
|
method === "thread/status/changed" ||
|
|
647
691
|
method === "thread/tokenUsage/updated" ||
|
|
648
|
-
method === "turn/diff/updated" ||
|
|
649
692
|
method === "serverRequest/resolved" ||
|
|
650
693
|
method === "mcpServer/oauthLogin/completed"
|
|
651
694
|
) {
|
|
@@ -705,6 +748,9 @@ export class AgentWorkspaceProxy {
|
|
|
705
748
|
case "item/fileChange/patchUpdated":
|
|
706
749
|
this.handleFilePatchUpdated(params);
|
|
707
750
|
return;
|
|
751
|
+
case "turn/diff/updated":
|
|
752
|
+
this.handleTurnDiffUpdated(params);
|
|
753
|
+
return;
|
|
708
754
|
case "command/exec/outputDelta":
|
|
709
755
|
this.handleCommandExecDelta(params);
|
|
710
756
|
return;
|
|
@@ -863,7 +909,9 @@ export class AgentWorkspaceProxy {
|
|
|
863
909
|
this.toolConversationIds.get(itemId) ??
|
|
864
910
|
this.activeConversationId;
|
|
865
911
|
if (!conversationId) return;
|
|
866
|
-
const output =
|
|
912
|
+
const output =
|
|
913
|
+
extractDiffText(raw) ??
|
|
914
|
+
summarizeFileChanges(Array.isArray(raw.changes) ? raw.changes : []);
|
|
867
915
|
const existing = this.findTool(conversationId, itemId);
|
|
868
916
|
this.upsertTool(conversationId, {
|
|
869
917
|
id: itemId,
|
|
@@ -875,6 +923,28 @@ export class AgentWorkspaceProxy {
|
|
|
875
923
|
});
|
|
876
924
|
}
|
|
877
925
|
|
|
926
|
+
private handleTurnDiffUpdated(params: unknown): void {
|
|
927
|
+
const raw = asRecord(params);
|
|
928
|
+
if (!raw) return;
|
|
929
|
+
const conversationId = this.conversationIdFromParams(raw) ?? this.activeConversationId;
|
|
930
|
+
if (!conversationId) return;
|
|
931
|
+
const diff = extractDiffText(raw);
|
|
932
|
+
if (!diff) return;
|
|
933
|
+
const itemId =
|
|
934
|
+
firstString(raw, ["itemId", "id", "turnId"]) ??
|
|
935
|
+
`workspace-diff:${conversationId}`;
|
|
936
|
+
const existing = this.findTool(conversationId, itemId);
|
|
937
|
+
const changes = Array.isArray(raw.changes) ? raw.changes : [];
|
|
938
|
+
this.upsertTool(conversationId, {
|
|
939
|
+
id: itemId,
|
|
940
|
+
name: existing?.name ?? "文件修改",
|
|
941
|
+
input: existing?.input ?? summarizeFileChanges(changes),
|
|
942
|
+
output: diff,
|
|
943
|
+
createdAt: existing?.createdAt ?? Date.now(),
|
|
944
|
+
status: existing?.status ?? "running",
|
|
945
|
+
});
|
|
946
|
+
}
|
|
947
|
+
|
|
878
948
|
private handleCommandExecDelta(params: unknown): void {
|
|
879
949
|
const raw = asRecord(params);
|
|
880
950
|
if (!raw) return;
|
|
@@ -968,14 +1038,18 @@ export class AgentWorkspaceProxy {
|
|
|
968
1038
|
const itemType = firstString(item, ["type"]);
|
|
969
1039
|
const name = toolNameFromItem(item);
|
|
970
1040
|
if (!name && !isToolItemType(itemType)) return undefined;
|
|
971
|
-
const
|
|
1041
|
+
const bufferedOutput = this.toolOutputBuffers.get(itemId);
|
|
1042
|
+
const rawOutput =
|
|
972
1043
|
firstString(item, ["aggregatedOutput", "output", "stdout", "stderr"]) ??
|
|
973
1044
|
stringifyDefined(item.result ?? item.error ?? item.contentItems);
|
|
1045
|
+
const output = itemType === "fileChange"
|
|
1046
|
+
? extractDiffText(item) ?? bufferedOutput ?? rawOutput
|
|
1047
|
+
: rawOutput ?? bufferedOutput;
|
|
974
1048
|
return {
|
|
975
1049
|
id: itemId,
|
|
976
1050
|
name: name ?? "工具",
|
|
977
1051
|
input: toolInputFromItem(item),
|
|
978
|
-
output
|
|
1052
|
+
output,
|
|
979
1053
|
createdAt: Date.now(),
|
|
980
1054
|
status: normalizeToolStatus(item.status, fallbackStatus === "completed"),
|
|
981
1055
|
};
|
|
@@ -51,6 +51,7 @@ const HOOK_BODY_LIMIT = 256 * 1024;
|
|
|
51
51
|
const PERMISSION_REQUEST_TIMEOUT_MS = Number(
|
|
52
52
|
process.env.LINKSHELL_PERMISSION_TIMEOUT_MS ?? 5 * 60_000,
|
|
53
53
|
);
|
|
54
|
+
const LINKSHELL_PERMISSION_GUARD_MARKER = "LINKSHELL_PERMISSION_GUARD";
|
|
54
55
|
|
|
55
56
|
interface TerminalInstance {
|
|
56
57
|
id: string;
|
|
@@ -90,9 +91,81 @@ type HookPermissionChoice =
|
|
|
90
91
|
optionId?: string;
|
|
91
92
|
};
|
|
92
93
|
|
|
93
|
-
function isLinkShellHookEntry(entry: unknown, marker
|
|
94
|
-
|
|
95
|
-
|
|
94
|
+
function isLinkShellHookEntry(entry: unknown, marker?: string): boolean {
|
|
95
|
+
let raw = "";
|
|
96
|
+
try {
|
|
97
|
+
raw = JSON.stringify(entry);
|
|
98
|
+
} catch {
|
|
99
|
+
raw = String(entry);
|
|
100
|
+
}
|
|
101
|
+
return (
|
|
102
|
+
(marker ? raw.includes(`/hook?m=${marker}`) : false) ||
|
|
103
|
+
raw.includes("/hook?m=lsh-") ||
|
|
104
|
+
(raw.includes("/hook?m=") && raw.includes("LINKSHELL_ID"))
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function withLinkShellHookEntry<T>(
|
|
109
|
+
entries: unknown[] | undefined,
|
|
110
|
+
entry: T,
|
|
111
|
+
priority: "first" | "last",
|
|
112
|
+
): unknown[] {
|
|
113
|
+
const cleaned = (Array.isArray(entries) ? entries : []).filter((item) => !isLinkShellHookEntry(item));
|
|
114
|
+
return priority === "first" ? [entry, ...cleaned] : [...cleaned, entry];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function guardPermissionCommandForLinkShell(command: unknown): unknown {
|
|
118
|
+
if (typeof command !== "string") return command;
|
|
119
|
+
if (command.includes(LINKSHELL_PERMISSION_GUARD_MARKER)) return command;
|
|
120
|
+
return [
|
|
121
|
+
`case "\${LINKSHELL_ID:-}" in lsh-*) exit 0 ;; esac`,
|
|
122
|
+
`# ${LINKSHELL_PERMISSION_GUARD_MARKER}`,
|
|
123
|
+
command,
|
|
124
|
+
].join("\n");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function guardPermissionHookObjectForLinkShell(
|
|
128
|
+
hook: Record<string, unknown>,
|
|
129
|
+
): Record<string, unknown> {
|
|
130
|
+
if (isLinkShellHookEntry(hook)) return hook;
|
|
131
|
+
const next: Record<string, unknown> = { ...hook };
|
|
132
|
+
if (typeof next.command === "string") {
|
|
133
|
+
next.command = guardPermissionCommandForLinkShell(next.command);
|
|
134
|
+
}
|
|
135
|
+
if (typeof next.bash === "string") {
|
|
136
|
+
next.bash = guardPermissionCommandForLinkShell(next.bash);
|
|
137
|
+
}
|
|
138
|
+
return next;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function guardPermissionHookEntryForLinkShell(entry: unknown): unknown {
|
|
142
|
+
if (isLinkShellHookEntry(entry)) return entry;
|
|
143
|
+
if (typeof entry === "string") return guardPermissionCommandForLinkShell(entry);
|
|
144
|
+
if (Array.isArray(entry)) return entry.map(guardPermissionHookEntryForLinkShell);
|
|
145
|
+
if (!entry || typeof entry !== "object") return entry;
|
|
146
|
+
|
|
147
|
+
const next = { ...(entry as Record<string, unknown>) };
|
|
148
|
+
if (Array.isArray(next.hooks)) {
|
|
149
|
+
next.hooks = next.hooks.map((hook) =>
|
|
150
|
+
hook && typeof hook === "object" && !Array.isArray(hook)
|
|
151
|
+
? guardPermissionHookObjectForLinkShell(hook as Record<string, unknown>)
|
|
152
|
+
: guardPermissionHookEntryForLinkShell(hook),
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
if (typeof next.command === "string" || typeof next.bash === "string") {
|
|
156
|
+
return guardPermissionHookObjectForLinkShell(next);
|
|
157
|
+
}
|
|
158
|
+
return next;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function withBlockingLinkShellPermissionEntry<T>(
|
|
162
|
+
entries: unknown[] | undefined,
|
|
163
|
+
entry: T,
|
|
164
|
+
): unknown[] {
|
|
165
|
+
const cleaned = (Array.isArray(entries) ? entries : [])
|
|
166
|
+
.filter((item) => !isLinkShellHookEntry(item))
|
|
167
|
+
.map(guardPermissionHookEntryForLinkShell);
|
|
168
|
+
return [entry, ...cleaned];
|
|
96
169
|
}
|
|
97
170
|
|
|
98
171
|
function stringifyHookInput(value: unknown): string {
|
|
@@ -645,6 +718,7 @@ export class BridgeSession {
|
|
|
645
718
|
);
|
|
646
719
|
break;
|
|
647
720
|
}
|
|
721
|
+
if (envelope.type === "agent.prompt") this.refreshAgentPermissionHooks();
|
|
648
722
|
await this.agentSession.handleEnvelope(envelope);
|
|
649
723
|
break;
|
|
650
724
|
}
|
|
@@ -653,8 +727,7 @@ export class BridgeSession {
|
|
|
653
727
|
if (this.resolvePendingPermission(p.requestId, {
|
|
654
728
|
outcome: p.outcome,
|
|
655
729
|
optionId: p.optionId,
|
|
656
|
-
})) {
|
|
657
|
-
this.log(`agent permission response for hook ${p.requestId}: ${p.outcome}:${p.optionId ?? "default"}`);
|
|
730
|
+
}, "agent.permission.response")) {
|
|
658
731
|
break;
|
|
659
732
|
}
|
|
660
733
|
if (!this.agentSession) {
|
|
@@ -714,6 +787,7 @@ export class BridgeSession {
|
|
|
714
787
|
);
|
|
715
788
|
break;
|
|
716
789
|
}
|
|
790
|
+
if (envelope.type === "agent.v2.prompt") this.refreshAgentPermissionHooks();
|
|
717
791
|
await this.agentWorkspace.handleEnvelope(envelope);
|
|
718
792
|
break;
|
|
719
793
|
}
|
|
@@ -731,11 +805,7 @@ export class BridgeSession {
|
|
|
731
805
|
}
|
|
732
806
|
case "permission.decision": {
|
|
733
807
|
const p = envelope.payload as { requestId: string; decision: "allow" | "deny" };
|
|
734
|
-
|
|
735
|
-
this.log(`permission decision for ${p.requestId}: ${p.decision}`);
|
|
736
|
-
} else {
|
|
737
|
-
this.log(`no pending permission for ${p.requestId}`);
|
|
738
|
-
}
|
|
808
|
+
this.resolvePendingPermission(p.requestId, p.decision, "permission.decision");
|
|
739
809
|
break;
|
|
740
810
|
}
|
|
741
811
|
case "tunnel.request": {
|
|
@@ -1134,7 +1204,7 @@ export class BridgeSession {
|
|
|
1134
1204
|
const requestId = `pr-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
1135
1205
|
const permissionSuggestions = hookPermissionSuggestions(event);
|
|
1136
1206
|
const timeout = setTimeout(() => {
|
|
1137
|
-
if (this.resolvePendingPermission(requestId, "deny")) {
|
|
1207
|
+
if (this.resolvePendingPermission(requestId, "deny", "permission.timeout")) {
|
|
1138
1208
|
this.log(`permission request ${requestId} timed out`);
|
|
1139
1209
|
this.sendPermissionSnapshot(terminalId, "thinking", "permission timed out");
|
|
1140
1210
|
}
|
|
@@ -1199,6 +1269,26 @@ export class BridgeSession {
|
|
|
1199
1269
|
return { server, port, configPath };
|
|
1200
1270
|
}
|
|
1201
1271
|
|
|
1272
|
+
private refreshAgentPermissionHooks(): void {
|
|
1273
|
+
const term = this.terminals.get(DEFAULT_TERMINAL_ID);
|
|
1274
|
+
if (!term?.hookPort) return;
|
|
1275
|
+
const marker = term.hookMarker;
|
|
1276
|
+
const curlCmd = `curl -s -X POST "http://127.0.0.1:${term.hookPort}/hook?m=${marker}&lid=$LINKSHELL_ID" -H 'Content-Type: application/json' --data-binary @-`;
|
|
1277
|
+
const agentProvider = normalizeAgentProvider(this.options.agentProvider ?? "codex");
|
|
1278
|
+
try {
|
|
1279
|
+
if (agentProvider === "claude") {
|
|
1280
|
+
this.setupClaudeHooks(DEFAULT_TERMINAL_ID, curlCmd, [], marker);
|
|
1281
|
+
} else {
|
|
1282
|
+
this.setupCodexHooks(DEFAULT_TERMINAL_ID, curlCmd, marker);
|
|
1283
|
+
if (agentProvider === "custom") {
|
|
1284
|
+
this.setupClaudeHooks(DEFAULT_TERMINAL_ID, curlCmd, [], marker);
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
} catch (error) {
|
|
1288
|
+
this.log(`failed to refresh agent permission hooks: ${error instanceof Error ? error.message : String(error)}`);
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1202
1292
|
private setupClaudeHooks(terminalId: string, curlCmd: string, args: string[], marker: string): string {
|
|
1203
1293
|
// Write hooks to ~/.claude/settings.json — Claude Code reads hooks from here
|
|
1204
1294
|
const claudeDir = join(homedir(), ".claude");
|
|
@@ -1233,11 +1323,9 @@ export class BridgeSession {
|
|
|
1233
1323
|
// Append our entries to existing hooks (first remove stale linkshell entries)
|
|
1234
1324
|
const existingHooks = (existing.hooks ?? {}) as Record<string, unknown[]>;
|
|
1235
1325
|
for (const [eventName, entry] of Object.entries(hookEvents)) {
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
arr.push(entry);
|
|
1240
|
-
existingHooks[eventName] = arr;
|
|
1326
|
+
existingHooks[eventName] = eventName === "PermissionRequest"
|
|
1327
|
+
? withBlockingLinkShellPermissionEntry(existingHooks[eventName], entry)
|
|
1328
|
+
: withLinkShellHookEntry(existingHooks[eventName], entry, "last");
|
|
1241
1329
|
}
|
|
1242
1330
|
|
|
1243
1331
|
const merged = { ...existing, hooks: existingHooks };
|
|
@@ -1298,10 +1386,9 @@ export class BridgeSession {
|
|
|
1298
1386
|
try { existing = JSON.parse(readFileSync(hooksPath, "utf8")); } catch { /* doesn't exist yet */ }
|
|
1299
1387
|
const existingHooks = existing.hooks ?? {};
|
|
1300
1388
|
for (const [eventName, entry] of Object.entries(hookEvents)) {
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
existingHooks[eventName] = arr;
|
|
1389
|
+
existingHooks[eventName] = eventName === "PermissionRequest"
|
|
1390
|
+
? withBlockingLinkShellPermissionEntry(existingHooks[eventName], entry)
|
|
1391
|
+
: withLinkShellHookEntry(existingHooks[eventName], entry, "last");
|
|
1305
1392
|
}
|
|
1306
1393
|
|
|
1307
1394
|
writeFileSync(hooksPath, JSON.stringify({ ...existing, hooks: existingHooks }, null, 2));
|
|
@@ -1331,10 +1418,7 @@ export class BridgeSession {
|
|
|
1331
1418
|
|
|
1332
1419
|
const existingHooks = (existing.hooks ?? {}) as Record<string, unknown[]>;
|
|
1333
1420
|
for (const [eventName, entry] of Object.entries(hookEvents)) {
|
|
1334
|
-
|
|
1335
|
-
arr = arr.filter((e) => !isLinkShellHookEntry(e, marker));
|
|
1336
|
-
arr.push(entry);
|
|
1337
|
-
existingHooks[eventName] = arr;
|
|
1421
|
+
existingHooks[eventName] = withLinkShellHookEntry(existingHooks[eventName], entry, "last");
|
|
1338
1422
|
}
|
|
1339
1423
|
|
|
1340
1424
|
existing.hooks = existingHooks;
|
|
@@ -1366,10 +1450,7 @@ export class BridgeSession {
|
|
|
1366
1450
|
try { existing = JSON.parse(readFileSync(hooksPath, "utf8")); } catch { /* doesn't exist yet */ }
|
|
1367
1451
|
const existingHooks = existing.hooks ?? {};
|
|
1368
1452
|
for (const [eventName, entry] of Object.entries(hookEvents)) {
|
|
1369
|
-
|
|
1370
|
-
arr = arr.filter((e) => !isLinkShellHookEntry(e, marker));
|
|
1371
|
-
arr.push(entry);
|
|
1372
|
-
existingHooks[eventName] = arr;
|
|
1453
|
+
existingHooks[eventName] = withLinkShellHookEntry(existingHooks[eventName], entry, "last");
|
|
1373
1454
|
}
|
|
1374
1455
|
|
|
1375
1456
|
writeFileSync(hooksPath, JSON.stringify({ version: 1, hooks: existingHooks }, null, 2));
|
|
@@ -1626,7 +1707,7 @@ export class BridgeSession {
|
|
|
1626
1707
|
|
|
1627
1708
|
/** Auto-resolve a single pending permission (user acted in terminal) */
|
|
1628
1709
|
private autoResolvePending(requestId: string): void {
|
|
1629
|
-
if (this.resolvePendingPermission(requestId, "allow")) {
|
|
1710
|
+
if (this.resolvePendingPermission(requestId, "allow", "terminal.auto")) {
|
|
1630
1711
|
this.log(`auto-resolved pending permission ${requestId} (user acted in terminal)`);
|
|
1631
1712
|
}
|
|
1632
1713
|
}
|
|
@@ -1636,15 +1717,24 @@ export class BridgeSession {
|
|
|
1636
1717
|
const stack = this.permissionStacks.get(terminalId);
|
|
1637
1718
|
if (!stack) return;
|
|
1638
1719
|
for (const entry of [...stack]) {
|
|
1639
|
-
if (this.resolvePendingPermission(entry.requestId, "deny")) {
|
|
1720
|
+
if (this.resolvePendingPermission(entry.requestId, "deny", "terminal.drain")) {
|
|
1640
1721
|
this.log(`drained pending permission ${entry.requestId}`);
|
|
1641
1722
|
}
|
|
1642
1723
|
}
|
|
1643
1724
|
}
|
|
1644
1725
|
|
|
1645
|
-
private resolvePendingPermission(
|
|
1726
|
+
private resolvePendingPermission(
|
|
1727
|
+
requestId: string,
|
|
1728
|
+
choice: HookPermissionChoice,
|
|
1729
|
+
source = "unknown",
|
|
1730
|
+
): boolean {
|
|
1646
1731
|
const pending = this.pendingPermissions.get(requestId);
|
|
1647
|
-
|
|
1732
|
+
const outcome = typeof choice === "string" ? choice : choice.outcome;
|
|
1733
|
+
const optionId = typeof choice === "string" ? undefined : choice.optionId;
|
|
1734
|
+
if (!pending) {
|
|
1735
|
+
this.log(`no pending permission for ${requestId} via ${source}: ${outcome}:${optionId ?? "default"}`);
|
|
1736
|
+
return false;
|
|
1737
|
+
}
|
|
1648
1738
|
this.pendingPermissions.delete(requestId);
|
|
1649
1739
|
clearTimeout(pending.timeout);
|
|
1650
1740
|
pending.resolve(this.formatHookPermissionDecision(pending, choice));
|
|
@@ -1655,6 +1745,12 @@ export class BridgeSession {
|
|
|
1655
1745
|
if (idx >= 0) stack.splice(idx, 1);
|
|
1656
1746
|
if (stack.length === 0) this.permissionStacks.delete(pending.terminalId);
|
|
1657
1747
|
}
|
|
1748
|
+
this.log(`resolved permission ${requestId} via ${source}: ${outcome}:${optionId ?? "default"}`);
|
|
1749
|
+
this.sendPermissionSnapshot(
|
|
1750
|
+
pending.terminalId,
|
|
1751
|
+
"thinking",
|
|
1752
|
+
outcome === "allow" ? "permission allowed" : "permission denied",
|
|
1753
|
+
);
|
|
1658
1754
|
return true;
|
|
1659
1755
|
}
|
|
1660
1756
|
|