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.
@@ -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 output =
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 bufferedOutput = this.toolOutputBuffers.get(itemId);
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: output ?? bufferedOutput,
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 = summarizeFileChanges(Array.isArray(raw.changes) ? raw.changes : []);
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 output =
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: output ?? this.toolOutputBuffers.get(itemId),
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: string): boolean {
94
- const raw = JSON.stringify(entry);
95
- return raw.includes(`/hook?m=${marker}`);
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
- if (this.resolvePendingPermission(p.requestId, p.decision)) {
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
- let arr = Array.isArray(existingHooks[eventName]) ? existingHooks[eventName] : [];
1237
- // Remove any dead linkshell hook entries (from previous instances)
1238
- arr = arr.filter((e) => !isLinkShellHookEntry(e, marker));
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
- let arr = Array.isArray(existingHooks[eventName]) ? existingHooks[eventName] : [];
1302
- arr = arr.filter((e) => !isLinkShellHookEntry(e, marker));
1303
- arr.push(entry);
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
- let arr = Array.isArray(existingHooks[eventName]) ? existingHooks[eventName] : [];
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
- let arr = Array.isArray(existingHooks[eventName]) ? existingHooks[eventName] : [];
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(requestId: string, choice: HookPermissionChoice): boolean {
1726
+ private resolvePendingPermission(
1727
+ requestId: string,
1728
+ choice: HookPermissionChoice,
1729
+ source = "unknown",
1730
+ ): boolean {
1646
1731
  const pending = this.pendingPermissions.get(requestId);
1647
- if (!pending) return false;
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