@bbigbang/runtime-acp 0.1.0

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.
@@ -0,0 +1,1267 @@
1
+ import { log } from '../logging.js';
2
+ import { AcpClient } from '../acp/client.js';
3
+ import { SHARED_CHAT_SCOPE_USER_ID, clearAcpSessionId, getSession, updateSessionRuntimeState, updateLoadSupported, } from './sessionStore.js';
4
+ import { parseToolKind } from './toolAuth.js';
5
+ function joinPromptContext(...parts) {
6
+ const filtered = parts
7
+ .map((part) => part?.trim())
8
+ .filter((part) => Boolean(part));
9
+ return filtered.length > 0 ? filtered.join('\n\n') : undefined;
10
+ }
11
+ export class BindingRuntime {
12
+ db;
13
+ config;
14
+ toolAuth;
15
+ sessionKey;
16
+ bindingKey;
17
+ client;
18
+ init = null;
19
+ acpSessionId = null;
20
+ sessionSystemPromptText = null;
21
+ queue = Promise.resolve();
22
+ activeSink = null;
23
+ pendingPermission = null;
24
+ pendingPermissionActorUserId = null;
25
+ currentRunId = null;
26
+ currentRunLastSeq = 0;
27
+ currentUiMode = 'verbose';
28
+ currentActorUserId = null;
29
+ sinkWriteQueue = Promise.resolve();
30
+ toolCallTitles = new Map();
31
+ toolCallTextBreaks = new Set();
32
+ workspaceRoot;
33
+ agentCommand;
34
+ agentArgs;
35
+ env;
36
+ activeDisabledToolKinds;
37
+ channelBridgeMcpEntry;
38
+ constructor(params) {
39
+ this.db = params.db;
40
+ this.config = params.config;
41
+ this.toolAuth = params.toolAuth;
42
+ this.sessionKey = params.sessionKey;
43
+ this.bindingKey = params.bindingKey;
44
+ this.workspaceRoot = params.workspaceRoot;
45
+ this.agentCommand = params.agentCommand ?? this.config.acpAgentCommand;
46
+ this.agentArgs = params.agentArgs ?? this.config.acpAgentArgs;
47
+ this.env = params.env;
48
+ this.activeDisabledToolKinds = [...(params.disabledToolKinds ?? [])];
49
+ this.channelBridgeMcpEntry = params.channelBridgeMcpEntry;
50
+ const existingSession = getSession(this.db, this.sessionKey);
51
+ if (existingSession) {
52
+ this.acpSessionId = existingSession.acpSessionId;
53
+ this.sessionSystemPromptText = existingSession.systemPromptText;
54
+ }
55
+ this.client = new AcpClient({
56
+ db: this.db,
57
+ workspaceRoot: this.workspaceRoot,
58
+ agentCommand: this.agentCommand,
59
+ agentArgs: this.agentArgs,
60
+ toolAuth: this.toolAuth,
61
+ defaultAllowTools: true,
62
+ disabledToolKinds: this.activeDisabledToolKinds,
63
+ rpc: params.acpRpc,
64
+ env: this.env,
65
+ workspaceLockManager: params.workspaceLockManager,
66
+ events: {
67
+ onSessionUpdate: (run, _sessionId, update, eventSeq) => {
68
+ if (run.runId === this.currentRunId) {
69
+ this.currentRunLastSeq = Math.max(this.currentRunLastSeq, eventSeq);
70
+ }
71
+ this.enqueueSinkWrite(async () => {
72
+ if (run.runId !== this.currentRunId)
73
+ return;
74
+ const sink = this.activeSink;
75
+ if (!sink)
76
+ return;
77
+ if (update?.sessionUpdate === 'agent_message_chunk') {
78
+ const block = update?.content;
79
+ const text = block?.text ?? '';
80
+ if (!text)
81
+ return;
82
+ if (sink.sendAgentText) {
83
+ await sink.sendAgentText(text);
84
+ }
85
+ else {
86
+ await sink.sendText(text);
87
+ }
88
+ }
89
+ if (update?.sessionUpdate === 'agent_thought_chunk') {
90
+ const block = update?.content;
91
+ const text = block?.text ?? '';
92
+ if (!text)
93
+ return;
94
+ if (sink.sendThinkingText) {
95
+ await sink.sendThinkingText(text);
96
+ }
97
+ else {
98
+ await sink.sendText(text);
99
+ }
100
+ }
101
+ if (update?.sessionUpdate === 'tool_call' ||
102
+ update?.sessionUpdate === 'tool_call_update') {
103
+ const ui = this.buildToolUiEvent(update);
104
+ if (!ui)
105
+ return;
106
+ if (this.shouldBreakTextStreamForToolUpdate(update, ui.toolCallId)) {
107
+ await sink.breakTextStream?.();
108
+ }
109
+ const summaryDetail = this.currentUiMode === 'summary'
110
+ ? buildToolSummaryDetailText(update, ui.stage, ui.status)
111
+ : undefined;
112
+ const detail = this.currentUiMode === 'verbose'
113
+ ? ui.detail ?? renderJson(update, this.config.uiJsonMaxChars)
114
+ : summaryDetail;
115
+ if (!sink.sendUi && this.currentUiMode === 'summary') {
116
+ return;
117
+ }
118
+ if (sink.sendUi) {
119
+ await sink.sendUi({
120
+ kind: 'tool',
121
+ mode: this.currentUiMode,
122
+ title: ui.title,
123
+ detail,
124
+ input: ui.input,
125
+ output: ui.output,
126
+ toolCallId: ui.toolCallId,
127
+ stage: ui.stage,
128
+ status: ui.status,
129
+ });
130
+ }
131
+ else {
132
+ await sink.sendText(formatTextCodeBlock(this.currentUiMode === 'verbose'
133
+ ? `[tool]\n${ui.title}\n${detail ?? ''}`
134
+ : `[tool] ${ui.title}`));
135
+ }
136
+ }
137
+ if (update?.sessionUpdate === 'plan') {
138
+ const detail = renderJson(update, this.config.uiJsonMaxChars);
139
+ if (sink.sendUi) {
140
+ await sink.sendUi({
141
+ kind: 'plan',
142
+ mode: this.currentUiMode,
143
+ title: 'Plan updated',
144
+ detail: this.currentUiMode === 'verbose' ? detail : undefined,
145
+ });
146
+ }
147
+ else {
148
+ await sink.sendText(this.currentUiMode === 'verbose'
149
+ ? `\n[plan]\n${detail}\n`
150
+ : '\n[plan updated]\n');
151
+ }
152
+ }
153
+ if (update?.sessionUpdate === 'task') {
154
+ const detail = renderJson(update, this.config.uiJsonMaxChars);
155
+ if (sink.sendUi) {
156
+ await sink.sendUi({
157
+ kind: 'task',
158
+ mode: this.currentUiMode,
159
+ title: 'Task update',
160
+ detail: this.currentUiMode === 'verbose' ? detail : undefined,
161
+ });
162
+ }
163
+ else {
164
+ await sink.sendText(this.currentUiMode === 'verbose'
165
+ ? `\n[task]\n${detail}\n`
166
+ : '\n[task updated]\n');
167
+ }
168
+ }
169
+ });
170
+ },
171
+ onClientTool: (run, event) => {
172
+ this.enqueueSinkWrite(async () => {
173
+ if (run.runId !== this.currentRunId)
174
+ return;
175
+ // No-op for UI: tool updates are emitted via session/update
176
+ // (tool_call/tool_call_update) to avoid duplicate user messages.
177
+ void event;
178
+ });
179
+ },
180
+ onTaskUpdate: (run, task) => {
181
+ this.enqueueSinkWrite(async () => {
182
+ if (run.runId !== this.currentRunId)
183
+ return;
184
+ const sink = this.activeSink;
185
+ if (!sink?.sendUi)
186
+ return;
187
+ await sink.sendUi({
188
+ kind: 'task',
189
+ mode: this.currentUiMode,
190
+ title: task.title,
191
+ detail: task.detail,
192
+ silent: task.silent,
193
+ });
194
+ });
195
+ },
196
+ onPermissionRequest: (req) => {
197
+ this.pendingPermission = req;
198
+ this.pendingPermissionActorUserId = this.currentActorUserId;
199
+ const toolKind = toToolKind(req.params.toolCall?.kind);
200
+ if (toolKind && !this.isToolKindDisabled(toolKind)) {
201
+ const option = req.params.options.find((o) => o.kind === 'allow_always' || o.kind === 'allow_once');
202
+ if (option) {
203
+ this.toolAuth.grantOnce(this.sessionKey, toolKind, 1);
204
+ void this.client.respondPermission(req, {
205
+ kind: 'selected',
206
+ optionId: option.optionId,
207
+ });
208
+ this.pendingPermission = null;
209
+ this.pendingPermissionActorUserId = null;
210
+ return;
211
+ }
212
+ }
213
+ if (toolKind && this.isToolKindDisabled(toolKind)) {
214
+ const option = req.params.options.find((o) => o.kind === 'reject_always' || o.kind === 'reject_once');
215
+ if (option) {
216
+ void this.client.respondPermission(req, {
217
+ kind: 'selected',
218
+ optionId: option.optionId,
219
+ });
220
+ this.pendingPermission = null;
221
+ this.pendingPermissionActorUserId = null;
222
+ return;
223
+ }
224
+ }
225
+ if (toolKind) {
226
+ const policy = this.toolAuth.evaluatePersistentPolicy(this.bindingKey, toolKind, {
227
+ toolCall: req.params.toolCall,
228
+ workspaceRoot: this.workspaceRoot,
229
+ });
230
+ const hasScopedAllowRules = this.toolAuth.listAllowPrefixRules(this.bindingKey, toolKind)
231
+ .length > 0;
232
+ if (policy === 'allow' || (policy !== 'reject' && hasScopedAllowRules)) {
233
+ const option = req.params.options.find((o) => o.kind === 'allow_always' || o.kind === 'allow_once');
234
+ if (option) {
235
+ this.toolAuth.grantOnce(this.sessionKey, toolKind, 1);
236
+ void this.client.respondPermission(req, {
237
+ kind: 'selected',
238
+ optionId: option.optionId,
239
+ });
240
+ this.pendingPermission = null;
241
+ this.pendingPermissionActorUserId = null;
242
+ this.enqueueSinkWrite(async () => {
243
+ const sink = this.activeSink;
244
+ if (!sink)
245
+ return;
246
+ await sink.sendText(formatTextCodeBlock(`[permission] auto-allowed (${toolKind})`));
247
+ });
248
+ return;
249
+ }
250
+ }
251
+ if (policy === 'reject') {
252
+ const option = req.params.options.find((o) => o.kind === 'reject_always' || o.kind === 'reject_once');
253
+ if (option) {
254
+ void this.client.respondPermission(req, {
255
+ kind: 'selected',
256
+ optionId: option.optionId,
257
+ });
258
+ this.pendingPermission = null;
259
+ this.pendingPermissionActorUserId = null;
260
+ this.enqueueSinkWrite(async () => {
261
+ const sink = this.activeSink;
262
+ if (!sink)
263
+ return;
264
+ await sink.sendText(formatTextCodeBlock(`[permission] auto-rejected (${toolKind})`));
265
+ });
266
+ return;
267
+ }
268
+ }
269
+ }
270
+ // Catch-all: auto-allow everything not already handled above.
271
+ // This covers MCP tools (toolKind=null), unrecognised tool kinds, and
272
+ // any toolKind not in disabledToolKinds that fell through the policy check.
273
+ {
274
+ const option = req.params.options.find((o) => o.kind === 'allow_always' || o.kind === 'allow_once');
275
+ if (option) {
276
+ void this.client.respondPermission(req, {
277
+ kind: 'selected',
278
+ optionId: option.optionId,
279
+ });
280
+ this.pendingPermission = null;
281
+ this.pendingPermissionActorUserId = null;
282
+ return;
283
+ }
284
+ }
285
+ },
286
+ onAgentStderr: (line) => {
287
+ log.debug('[agent stderr]', line);
288
+ },
289
+ },
290
+ });
291
+ }
292
+ close() {
293
+ this.client.close();
294
+ }
295
+ setActiveDisabledToolKinds(disabledToolKinds) {
296
+ this.activeDisabledToolKinds = [...(disabledToolKinds ?? [])];
297
+ this.client.setDisabledToolKinds(this.activeDisabledToolKinds);
298
+ }
299
+ isToolKindDisabled(toolKind) {
300
+ return this.activeDisabledToolKinds.includes(toolKind);
301
+ }
302
+ enqueueSinkWrite(action) {
303
+ this.sinkWriteQueue = this.sinkWriteQueue.then(async () => {
304
+ try {
305
+ await action();
306
+ }
307
+ catch (error) {
308
+ log.warn('sink write event error', error);
309
+ }
310
+ });
311
+ }
312
+ async flushSinkWriteQueue() {
313
+ await this.sinkWriteQueue;
314
+ }
315
+ async ensureInitialized() {
316
+ if (this.init)
317
+ return this.init;
318
+ this.init = await this.client.initialize();
319
+ updateLoadSupported(this.db, this.sessionKey, Boolean(this.init.agentCapabilities?.loadSession));
320
+ log.info('ACP initialized (runtime)', {
321
+ bindingKey: this.bindingKey,
322
+ protocolVersion: this.init.protocolVersion,
323
+ });
324
+ return this.init;
325
+ }
326
+ async ensureSessionId() {
327
+ return this.ensureSessionIdWithPrompt();
328
+ }
329
+ async ensureSessionIdWithPrompt(systemPromptText) {
330
+ if (this.acpSessionId)
331
+ return this.acpSessionId;
332
+ await this.ensureInitialized();
333
+ const newSession = await this.client.newSession({
334
+ cwd: this.workspaceRoot,
335
+ mcpServers: this.channelBridgeMcpEntry ? [this.channelBridgeMcpEntry] : [],
336
+ ...(systemPromptText?.trim()
337
+ ? { _meta: { systemPrompt: systemPromptText.trim() } }
338
+ : {}),
339
+ });
340
+ this.acpSessionId = newSession.sessionId;
341
+ this.sessionSystemPromptText = systemPromptText?.trim() || null;
342
+ updateSessionRuntimeState(this.db, {
343
+ sessionKey: this.sessionKey,
344
+ acpSessionId: this.acpSessionId,
345
+ systemPromptText: this.sessionSystemPromptText,
346
+ });
347
+ return this.acpSessionId;
348
+ }
349
+ getLoadSupported() {
350
+ return Boolean(this.init?.agentCapabilities?.loadSession);
351
+ }
352
+ getPendingPermission() {
353
+ return this.pendingPermission;
354
+ }
355
+ async selectPermissionOption(idx, sink, actorUserId) {
356
+ const pr = this.pendingPermission;
357
+ if (!pr) {
358
+ await sink.sendText('No pending permission request.');
359
+ return;
360
+ }
361
+ if (!this.isPermissionActorAuthorized(actorUserId)) {
362
+ await sink.sendText('Not authorized.');
363
+ return;
364
+ }
365
+ const opt = pr.params.options[idx - 1];
366
+ if (!opt) {
367
+ await sink.sendText(`Invalid option index: ${idx}`);
368
+ return;
369
+ }
370
+ const toolKind = toToolKind(pr.params.toolCall?.kind);
371
+ if (toolKind) {
372
+ if (opt.kind === 'allow_always') {
373
+ this.toolAuth.setPersistentPolicy(this.bindingKey, toolKind, 'allow');
374
+ }
375
+ if (opt.kind === 'reject_always') {
376
+ this.toolAuth.setPersistentPolicy(this.bindingKey, toolKind, 'reject');
377
+ }
378
+ if (opt.kind === 'allow_once' || opt.kind === 'allow_always') {
379
+ this.toolAuth.grantOnce(this.sessionKey, toolKind, 1);
380
+ }
381
+ }
382
+ await this.client.respondPermission(pr, {
383
+ kind: 'selected',
384
+ optionId: opt.optionId,
385
+ });
386
+ this.pendingPermission = null;
387
+ this.pendingPermissionActorUserId = null;
388
+ await sink.sendText(`OK: selected option ${idx} (${opt.name})`);
389
+ }
390
+ hasSessionId() {
391
+ return Boolean(this.acpSessionId);
392
+ }
393
+ async decidePermission(params) {
394
+ const pr = this.pendingPermission;
395
+ if (!pr) {
396
+ return { ok: false, message: 'No pending permission request.' };
397
+ }
398
+ if (params.requestId && String(pr.requestId) !== params.requestId) {
399
+ return { ok: false, message: 'Permission request expired.' };
400
+ }
401
+ if (!this.isPermissionActorAuthorized(params.actorUserId)) {
402
+ return { ok: false, message: 'Not authorized.' };
403
+ }
404
+ const toolKind = toToolKind(pr.params.toolCall?.kind);
405
+ const allowOnce = pr.params.options.find((o) => o.kind === 'allow_once');
406
+ const allowAlways = pr.params.options.find((o) => o.kind === 'allow_always');
407
+ const rejectOnce = pr.params.options.find((o) => o.kind === 'reject_once');
408
+ const rejectAlways = pr.params.options.find((o) => o.kind === 'reject_always');
409
+ const selected = params.decision === 'allow'
410
+ ? (allowOnce ?? allowAlways)
411
+ : (rejectOnce ?? rejectAlways);
412
+ if (selected && toolKind && selected.kind === 'allow_always') {
413
+ this.toolAuth.setPersistentPolicy(this.bindingKey, toolKind, 'allow');
414
+ }
415
+ if (selected && toolKind && selected.kind === 'reject_always') {
416
+ this.toolAuth.setPersistentPolicy(this.bindingKey, toolKind, 'reject');
417
+ }
418
+ if (selected && toolKind && (selected.kind === 'allow_once' || selected.kind === 'allow_always')) {
419
+ this.toolAuth.grantOnce(this.sessionKey, toolKind, 1);
420
+ }
421
+ if (selected) {
422
+ await this.client.respondPermission(pr, {
423
+ kind: 'selected',
424
+ optionId: selected.optionId,
425
+ });
426
+ this.pendingPermission = null;
427
+ this.pendingPermissionActorUserId = null;
428
+ return {
429
+ ok: true,
430
+ message: params.decision === 'allow'
431
+ ? 'OK: allowed.'
432
+ : 'OK: denied.',
433
+ };
434
+ }
435
+ await this.client.respondPermission(pr, { kind: 'cancelled' });
436
+ this.pendingPermission = null;
437
+ this.pendingPermissionActorUserId = null;
438
+ return { ok: true, message: 'OK: cancelled permission request.' };
439
+ }
440
+ async denyPermission(sink, actorUserId) {
441
+ const res = await this.decidePermission({ decision: 'deny', actorUserId });
442
+ await sink.sendText(res.message);
443
+ }
444
+ async respondToPermission(requestId, decision, actorUserId) {
445
+ const pending = this.pendingPermission;
446
+ if (!pending || String(pending.requestId) !== requestId) {
447
+ return false;
448
+ }
449
+ const result = await this.decidePermission({
450
+ decision,
451
+ requestId,
452
+ actorUserId,
453
+ });
454
+ return result.ok;
455
+ }
456
+ hasPendingPermission() {
457
+ return Boolean(this.pendingPermission);
458
+ }
459
+ async cancelCurrentRun(runId) {
460
+ if (runId && this.currentRunId !== runId)
461
+ return false;
462
+ if (!this.currentRunId || !this.acpSessionId)
463
+ return false;
464
+ if (this.pendingPermission) {
465
+ await this.client.respondPermission(this.pendingPermission, { kind: 'cancelled' });
466
+ this.pendingPermission = null;
467
+ this.pendingPermissionActorUserId = null;
468
+ }
469
+ this.client.notifyCancel(this.acpSessionId);
470
+ return true;
471
+ }
472
+ resetAcpSession(reason) {
473
+ log.warn('Resetting ACP session id', {
474
+ bindingKey: this.bindingKey,
475
+ sessionKey: this.sessionKey,
476
+ reason,
477
+ previousSessionId: this.acpSessionId,
478
+ });
479
+ this.acpSessionId = null;
480
+ this.sessionSystemPromptText = null;
481
+ clearAcpSessionId(this.db, this.sessionKey);
482
+ }
483
+ async promptOnce(params) {
484
+ const isFreshSession = !this.acpSessionId;
485
+ const sessionId = await this.ensureSessionIdWithPrompt(params.systemPromptText);
486
+ const effectiveSystemPromptText = isFreshSession
487
+ ? params.systemPromptText?.trim() || undefined
488
+ : this.sessionSystemPromptText?.trim() || undefined;
489
+ const promptContextText = isFreshSession
490
+ ? joinPromptContext(params.resumeContextText, params.recoveryContextText, params.contextText)
491
+ : joinPromptContext(params.contextText);
492
+ const effectiveContextText = promptContextText;
493
+ const run = {
494
+ runId: params.runId,
495
+ sessionKey: this.sessionKey,
496
+ createdAtMs: Date.now(),
497
+ };
498
+ const blocks = [];
499
+ const textParts = [];
500
+ if (promptContextText) {
501
+ textParts.push(promptContextText);
502
+ }
503
+ if (params.promptText.trim()) {
504
+ textParts.push(params.promptText.trim());
505
+ }
506
+ if (textParts.length > 0) {
507
+ blocks.push({ type: 'text', text: textParts.join('\n\n') });
508
+ }
509
+ for (const [index, resource] of (params.promptResources ?? []).entries()) {
510
+ blocks.push({
511
+ type: 'resource_link',
512
+ uri: resource.uri,
513
+ name: deriveResourceName(resource.uri, index),
514
+ mimeType: resource.mimeType,
515
+ });
516
+ }
517
+ if (blocks.length === 0) {
518
+ blocks.push({ type: 'text', text: params.promptText });
519
+ }
520
+ await params.onPrepared?.({
521
+ sessionId,
522
+ isFreshSession,
523
+ effectiveSystemPromptText,
524
+ effectiveContextText,
525
+ });
526
+ const result = await this.client.prompt(run, {
527
+ sessionId,
528
+ prompt: blocks,
529
+ }, this.config.acpPromptTimeoutMs);
530
+ await this.flushSinkWriteQueue();
531
+ return {
532
+ stopReason: result.stopReason,
533
+ lastSeq: this.currentRunLastSeq,
534
+ isFreshSession,
535
+ sessionId,
536
+ effectiveSystemPromptText,
537
+ effectiveContextText,
538
+ };
539
+ }
540
+ prompt(params) {
541
+ const next = this.queue.then(async () => {
542
+ const hadSession = Boolean(this.acpSessionId);
543
+ this.currentRunId = params.runId;
544
+ this.currentRunLastSeq = 0;
545
+ this.currentUiMode = params.uiMode;
546
+ this.setActiveDisabledToolKinds(params.disabledToolKinds);
547
+ this.currentActorUserId = params.actorUserId ?? null;
548
+ this.activeSink = params.sink;
549
+ this.sinkWriteQueue = Promise.resolve();
550
+ this.toolCallTitles = new Map();
551
+ this.toolCallTextBreaks = new Set();
552
+ try {
553
+ try {
554
+ const result = await this.promptOnce(params);
555
+ return {
556
+ stopReason: result.stopReason,
557
+ lastSeq: result.lastSeq,
558
+ isFreshSession: result.isFreshSession,
559
+ sessionId: result.sessionId,
560
+ effectiveSystemPromptText: result.effectiveSystemPromptText,
561
+ effectiveContextText: result.effectiveContextText,
562
+ };
563
+ }
564
+ catch (error) {
565
+ const shouldRetry = hadSession &&
566
+ this.currentRunLastSeq === 0 &&
567
+ isRecoverableResumeError(error);
568
+ if (!shouldRetry) {
569
+ throw error;
570
+ }
571
+ this.resetAcpSession(String(error?.message ?? error));
572
+ const recovered = await this.promptOnce(params);
573
+ return {
574
+ stopReason: recovered.stopReason,
575
+ lastSeq: recovered.lastSeq,
576
+ isFreshSession: recovered.isFreshSession,
577
+ sessionId: recovered.sessionId,
578
+ effectiveSystemPromptText: recovered.effectiveSystemPromptText,
579
+ effectiveContextText: recovered.effectiveContextText,
580
+ };
581
+ }
582
+ }
583
+ finally {
584
+ await this.flushSinkWriteQueue();
585
+ this.activeSink = null;
586
+ this.currentRunId = null;
587
+ this.currentUiMode = 'verbose';
588
+ this.currentActorUserId = null;
589
+ this.toolCallTitles = new Map();
590
+ this.toolCallTextBreaks = new Set();
591
+ }
592
+ });
593
+ // Keep the queue alive even if this prompt fails.
594
+ this.queue = next.then(() => undefined, () => undefined);
595
+ return next;
596
+ }
597
+ isPermissionActorAuthorized(actorUserId) {
598
+ const expected = this.pendingPermissionActorUserId;
599
+ if (!expected || expected === SHARED_CHAT_SCOPE_USER_ID)
600
+ return true;
601
+ if (!actorUserId)
602
+ return true;
603
+ return expected === actorUserId;
604
+ }
605
+ buildToolUiEvent(update) {
606
+ const stage = inferToolStage(update);
607
+ const status = toolStatusLabel(stage, update);
608
+ const toolCallId = extractToolCallId(update) ?? undefined;
609
+ const rawTitle = String(update?.title ?? toolCallId ?? 'tool_call').trim();
610
+ const inferredActions = inferToolActions(update, rawTitle);
611
+ let baseTitle = this.currentUiMode === 'summary'
612
+ ? normalizeSummaryToolTitle(rawTitle, inferredActions)
613
+ : normalizeVerboseToolTitle(rawTitle, inferredActions);
614
+ if (!baseTitle)
615
+ return null;
616
+ if (toolCallId) {
617
+ const existingTitle = this.toolCallTitles.get(toolCallId);
618
+ if (!existingTitle && baseTitle) {
619
+ this.toolCallTitles.set(toolCallId, baseTitle);
620
+ }
621
+ else if (existingTitle) {
622
+ baseTitle = existingTitle;
623
+ }
624
+ }
625
+ const detail = this.currentUiMode === 'verbose'
626
+ ? buildToolDetailText({
627
+ update,
628
+ rawTitle,
629
+ inferredActions,
630
+ toolCallId,
631
+ status,
632
+ })
633
+ : undefined;
634
+ const summaryDetail = this.currentUiMode === 'summary'
635
+ ? buildToolSummaryDetailText(update, stage, status)
636
+ : undefined;
637
+ const input = buildToolInputSnapshot({
638
+ update,
639
+ rawTitle,
640
+ toolCallId,
641
+ detail,
642
+ status,
643
+ });
644
+ const output = stage === 'complete'
645
+ ? detail ?? summaryDetail ?? status
646
+ : undefined;
647
+ return {
648
+ title: `${baseTitle} · ${status}`,
649
+ detail,
650
+ input,
651
+ output,
652
+ toolCallId,
653
+ stage,
654
+ status,
655
+ };
656
+ }
657
+ shouldBreakTextStreamForToolUpdate(update, toolCallId) {
658
+ const kind = String(update?.sessionUpdate ?? '').trim();
659
+ if (kind !== 'tool_call' && kind !== 'tool_call_update')
660
+ return false;
661
+ if (toolCallId) {
662
+ if (this.toolCallTextBreaks.has(toolCallId)) {
663
+ return false;
664
+ }
665
+ this.toolCallTextBreaks.add(toolCallId);
666
+ return true;
667
+ }
668
+ return kind === 'tool_call';
669
+ }
670
+ }
671
+ function isRecoverableResumeError(error) {
672
+ const message = String(error?.message ?? error ?? '')
673
+ .toLowerCase();
674
+ return (message.includes('tls handshake eof') ||
675
+ message.includes('falling back from websockets to https transport') ||
676
+ message.includes('stream disconnected before completion') ||
677
+ message.includes('session not found') ||
678
+ message.includes('unknown session') ||
679
+ message.includes('invalid session') ||
680
+ message.includes('resource not found (code -32002)') ||
681
+ (message.includes('resource not found') && message.includes('-32002')) ||
682
+ message.includes('acp request timed out: session/prompt') ||
683
+ message.includes('internal error (code -32603)') ||
684
+ message.includes('acp agent exited') ||
685
+ message.includes('acp client closed') ||
686
+ message.includes('transport'));
687
+ }
688
+ function toToolKind(kind) {
689
+ return parseToolKind(kind);
690
+ }
691
+ function formatPermissionRequest(req) {
692
+ const options = req.params.options
693
+ .map((o, i) => `${i + 1}. ${o.name} (${o.kind})`)
694
+ .join('\n');
695
+ return formatTextCodeBlock([
696
+ '[permission required]',
697
+ `Tool: ${req.params.toolCall?.title ?? req.params.toolCall?.toolCallId ?? 'tool_call'}`,
698
+ options,
699
+ 'Reply with /allow <n> or /deny',
700
+ ].join('\n'));
701
+ }
702
+ function formatTextCodeBlock(text) {
703
+ const safe = text.trim().replace(/```/g, '``\u200b`');
704
+ return `\n\`\`\`text\n${safe}\n\`\`\`\n`;
705
+ }
706
+ function renderJson(value, maxChars) {
707
+ try {
708
+ const text = JSON.stringify(value, null, 2);
709
+ if (text.length <= maxChars)
710
+ return text;
711
+ return text.slice(0, maxChars - 3) + '...';
712
+ }
713
+ catch {
714
+ return String(value);
715
+ }
716
+ }
717
+ function normalizeSummaryToolTitle(title, inferredActions) {
718
+ const inferredTitle = summarizeInferredActions(inferredActions, 86);
719
+ if (inferredTitle)
720
+ return inferredTitle;
721
+ const trimmed = title.trim();
722
+ if (!trimmed)
723
+ return null;
724
+ const lowered = trimmed.toLowerCase();
725
+ if (lowered === 'tool_call')
726
+ return null;
727
+ if (lowered.startsWith('call_'))
728
+ return null;
729
+ if (!/[a-z]/i.test(trimmed))
730
+ return null;
731
+ // Keep explicit tool method names when available.
732
+ const explicitMethod = trimmed.match(/\b(fs\/[a-z0-9_/-]+|terminal\/[a-z0-9_/-]+|web\/[a-z0-9_/-]+|browser\/[a-z0-9_/-]+)\b/i)?.[1];
733
+ if (explicitMethod)
734
+ return explicitMethod.toLowerCase();
735
+ return trimmed;
736
+ }
737
+ function normalizeVerboseToolTitle(title, inferredActions) {
738
+ const inferredTitle = summarizeInferredActions(inferredActions, 96);
739
+ if (inferredTitle)
740
+ return inferredTitle;
741
+ const trimmed = title.trim();
742
+ return trimmed || 'tool_call';
743
+ }
744
+ function inferToolStage(update) {
745
+ if (update?.sessionUpdate === 'tool_call')
746
+ return 'start';
747
+ const status = `${update?.status ?? update?.state ?? update?.outcome ?? ''}`
748
+ .toLowerCase()
749
+ .trim();
750
+ if (status) {
751
+ if (status.includes('complete') ||
752
+ status.includes('success') ||
753
+ status.includes('fail') ||
754
+ status.includes('error') ||
755
+ status.includes('cancel') ||
756
+ status.includes('done') ||
757
+ status.includes('finish') ||
758
+ status.includes('end')) {
759
+ return 'complete';
760
+ }
761
+ }
762
+ if (update?.error ||
763
+ update?.result !== undefined ||
764
+ update?.output !== undefined ||
765
+ update?.exitCode !== undefined) {
766
+ return 'complete';
767
+ }
768
+ return 'update';
769
+ }
770
+ function toolStatusLabel(stage, update) {
771
+ if (stage === 'start')
772
+ return 'started';
773
+ const statusRaw = `${update?.status ?? update?.state ?? update?.outcome ?? ''}`
774
+ .toLowerCase()
775
+ .trim();
776
+ if (stage === 'complete') {
777
+ if (statusRaw.includes('fail') ||
778
+ statusRaw.includes('error') ||
779
+ update?.error) {
780
+ return 'failed';
781
+ }
782
+ if (statusRaw.includes('cancel'))
783
+ return 'cancelled';
784
+ return 'completed';
785
+ }
786
+ return statusRaw || 'running';
787
+ }
788
+ function extractToolCallId(update) {
789
+ const candidates = [
790
+ update?.toolCallId,
791
+ update?.tool_call_id,
792
+ update?.callId,
793
+ update?.call_id,
794
+ update?.id,
795
+ ];
796
+ for (const candidate of candidates) {
797
+ if (typeof candidate !== 'string')
798
+ continue;
799
+ const trimmed = candidate.trim();
800
+ if (trimmed)
801
+ return trimmed;
802
+ }
803
+ return null;
804
+ }
805
+ function deriveResourceName(uri, index) {
806
+ const fallback = `attachment-${index + 1}`;
807
+ const trimmed = String(uri ?? '').trim();
808
+ if (!trimmed)
809
+ return fallback;
810
+ try {
811
+ const parsed = new URL(trimmed);
812
+ const leaf = parsed.pathname.split('/').filter(Boolean).at(-1) ?? '';
813
+ const decoded = decodeURIComponent(leaf).trim();
814
+ return decoded || fallback;
815
+ }
816
+ catch {
817
+ return fallback;
818
+ }
819
+ }
820
+ function inferToolActions(update, rawTitle) {
821
+ const fromTitle = parseToolActionsFromTitle(rawTitle);
822
+ if (fromTitle.length > 0)
823
+ return fromTitle;
824
+ const kind = String(update?.kind ?? '').trim().toLowerCase();
825
+ if (!kind)
826
+ return [];
827
+ const command = extractCommandHint(update);
828
+ const path = extractPathHint(update);
829
+ const query = extractSearchHint(update);
830
+ const target = path ?? query ?? command ?? '';
831
+ if (kind === 'execute' && command) {
832
+ return [{ verb: 'run', target: command }];
833
+ }
834
+ if (kind === 'read' && target) {
835
+ return [{ verb: 'read', target }];
836
+ }
837
+ if (kind === 'edit' && target) {
838
+ return [{ verb: 'edit', target }];
839
+ }
840
+ if (kind === 'search' && target) {
841
+ return [{ verb: 'search', target }];
842
+ }
843
+ if (kind === 'fetch' && target) {
844
+ return [{ verb: 'fetch', target }];
845
+ }
846
+ if (kind === 'move' && target) {
847
+ return [{ verb: 'move', target }];
848
+ }
849
+ if (kind === 'delete' && target) {
850
+ return [{ verb: 'delete', target }];
851
+ }
852
+ return [];
853
+ }
854
+ function parseToolActionsFromTitle(title) {
855
+ const trimmed = title.trim();
856
+ if (!trimmed)
857
+ return [];
858
+ const parts = splitToolTitleParts(trimmed);
859
+ if (parts.length > 1) {
860
+ const parsed = parts.map((part) => parseToolActionPart(part));
861
+ if (parsed.every(Boolean))
862
+ return parsed;
863
+ }
864
+ const one = parseToolActionPart(trimmed);
865
+ return one ? [one] : [];
866
+ }
867
+ function splitToolTitleParts(title) {
868
+ if (!title.includes(','))
869
+ return [title];
870
+ return title
871
+ .split(/\s*,\s*/g)
872
+ .map((part) => part.trim())
873
+ .filter(Boolean);
874
+ }
875
+ function parseToolActionPart(text) {
876
+ const trimmed = text.trim();
877
+ if (!trimmed)
878
+ return null;
879
+ const patterns = [
880
+ [/^(?:run|execute)\s+(.+)$/i, 'run'],
881
+ [/^(?:read|view)\s+(.+)$/i, 'read'],
882
+ [/^(?:write|edit|modify|update)\s+(.+)$/i, 'edit'],
883
+ [/^(?:list)\s+(.+)$/i, 'list'],
884
+ [/^(?:search|find|grep)\s+(.+)$/i, 'search'],
885
+ [/^(?:fetch|download|request)\s+(.+)$/i, 'fetch'],
886
+ [/^(?:move|rename)\s+(.+)$/i, 'move'],
887
+ [/^(?:delete|remove|rm)\s+(.+)$/i, 'delete'],
888
+ ];
889
+ for (const [pattern, verb] of patterns) {
890
+ const match = trimmed.match(pattern);
891
+ if (!match)
892
+ continue;
893
+ const target = match[1]?.trim();
894
+ if (!target)
895
+ return null;
896
+ return { verb, target };
897
+ }
898
+ return null;
899
+ }
900
+ function summarizeInferredActions(actions, targetMaxLen) {
901
+ if (actions.length === 0)
902
+ return null;
903
+ const first = formatToolAction(actions[0], targetMaxLen);
904
+ if (actions.length === 1)
905
+ return first;
906
+ return `${first} (+${actions.length - 1} more)`;
907
+ }
908
+ function formatToolAction(action, targetMaxLen) {
909
+ return `${action.verb}: ${truncateInline(action.target, targetMaxLen)}`;
910
+ }
911
+ function truncateInline(text, maxLen) {
912
+ const clean = text.replace(/\s+/g, ' ').trim();
913
+ if (clean.length <= maxLen)
914
+ return clean;
915
+ return clean.slice(0, maxLen - 3) + '...';
916
+ }
917
+ function buildToolDetailText(params) {
918
+ const lines = [];
919
+ if (params.inferredActions.length > 0) {
920
+ lines.push('actions:');
921
+ params.inferredActions.forEach((action, index) => {
922
+ lines.push(`${index + 1}. ${formatToolAction(action, 180)}`);
923
+ });
924
+ }
925
+ else if (params.rawTitle && params.rawTitle !== 'tool_call') {
926
+ lines.push(`title: ${truncateInline(params.rawTitle, 220)}`);
927
+ }
928
+ const kind = String(params.update?.kind ?? '').trim();
929
+ if (kind)
930
+ lines.push(`kind: ${kind}`);
931
+ lines.push(`status: ${params.status}`);
932
+ if (params.toolCallId) {
933
+ lines.push(`tool_call_id: ${params.toolCallId}`);
934
+ }
935
+ const cwd = extractFirstString(params.update, ['cwd', 'input.cwd', 'arguments.cwd']);
936
+ if (cwd)
937
+ lines.push(`cwd: ${truncateInline(cwd, 220)}`);
938
+ const command = extractCommandHint(params.update);
939
+ if (command && params.inferredActions.every((action) => action.verb !== 'run')) {
940
+ lines.push(`command: ${truncateInline(command, 220)}`);
941
+ }
942
+ const path = extractPathHint(params.update);
943
+ if (path &&
944
+ params.inferredActions.every((action) => !['read', 'edit', 'move', 'delete'].includes(action.verb))) {
945
+ lines.push(`path: ${truncateInline(path, 220)}`);
946
+ }
947
+ const exitCode = extractExitCode(params.update);
948
+ if (exitCode !== null)
949
+ lines.push(`exit_code: ${exitCode}`);
950
+ const errorText = extractErrorText(params.update);
951
+ if (errorText)
952
+ lines.push(`error: ${truncateInline(errorText, 220)}`);
953
+ if (lines.length === 0)
954
+ return undefined;
955
+ return lines.join('\n');
956
+ }
957
+ function buildToolSummaryDetailText(update, stage, status) {
958
+ const lines = [];
959
+ const errorText = extractErrorText(update);
960
+ if (errorText) {
961
+ lines.push(`error: ${truncateInline(errorText, 220)}`);
962
+ }
963
+ const resultMessage = extractFirstString(update, [
964
+ 'message',
965
+ 'result.message',
966
+ 'output',
967
+ 'result.output',
968
+ 'result.text',
969
+ ]);
970
+ if (resultMessage && (!errorText || resultMessage !== errorText)) {
971
+ lines.push(`result: ${truncateInline(resultMessage, 220)}`);
972
+ }
973
+ if (stage === 'complete' && lines.length === 0 && status !== 'completed') {
974
+ lines.push(`status: ${status}`);
975
+ }
976
+ if (lines.length === 0)
977
+ return undefined;
978
+ return lines.join('\n');
979
+ }
980
+ function buildToolInputSnapshot(params) {
981
+ const parsedArgs = resolvePermissionToolArgs(params.update);
982
+ const parsedArgsRecord = asRecord(parsedArgs);
983
+ const snapshot = parsedArgsRecord
984
+ ? sanitizeToolRecord(parsedArgsRecord)
985
+ : {};
986
+ if (!parsedArgsRecord && parsedArgs !== null && parsedArgs !== undefined) {
987
+ snapshot.arguments = sanitizeToolValue(parsedArgs);
988
+ }
989
+ const kind = String(params.update?.kind ?? '').trim();
990
+ if (kind && snapshot.kind === undefined)
991
+ snapshot.kind = kind;
992
+ if (params.toolCallId && snapshot.tool_call_id === undefined) {
993
+ snapshot.tool_call_id = params.toolCallId;
994
+ }
995
+ const cwd = extractFirstString(params.update, ['cwd', 'input.cwd', 'arguments.cwd']);
996
+ if (cwd && snapshot.cwd === undefined)
997
+ snapshot.cwd = cwd;
998
+ const command = extractCommandHint(params.update);
999
+ if (command && snapshot.command === undefined)
1000
+ snapshot.command = command;
1001
+ const path = extractPathHint(params.update);
1002
+ if (path && snapshot.path === undefined)
1003
+ snapshot.path = path;
1004
+ const query = extractSearchHint(params.update);
1005
+ if (query && snapshot.query === undefined)
1006
+ snapshot.query = query;
1007
+ if (params.rawTitle && params.rawTitle !== 'tool_call' && snapshot.title === undefined) {
1008
+ snapshot.title = truncateInline(params.rawTitle, 220);
1009
+ }
1010
+ if (params.status && snapshot.status === undefined)
1011
+ snapshot.status = params.status;
1012
+ const rawPreview = buildRawToolUpdatePreview(params.update);
1013
+ if (rawPreview && snapshot.raw_update === undefined) {
1014
+ snapshot.raw_update = rawPreview;
1015
+ }
1016
+ if (Object.keys(snapshot).length === 0) {
1017
+ return params.detail ? { detail: params.detail } : null;
1018
+ }
1019
+ return snapshot;
1020
+ }
1021
+ function buildRawToolUpdatePreview(update) {
1022
+ const record = asRecord(update);
1023
+ if (!record)
1024
+ return null;
1025
+ const preview = {};
1026
+ for (const key of [
1027
+ 'sessionUpdate',
1028
+ 'title',
1029
+ 'kind',
1030
+ 'status',
1031
+ 'state',
1032
+ 'outcome',
1033
+ 'toolCallId',
1034
+ 'tool_call_id',
1035
+ 'command',
1036
+ 'path',
1037
+ 'query',
1038
+ 'pattern',
1039
+ 'input',
1040
+ 'arguments',
1041
+ 'params',
1042
+ 'result',
1043
+ 'error',
1044
+ ]) {
1045
+ if (!(key in record))
1046
+ continue;
1047
+ const value = sanitizeToolValue(record[key]);
1048
+ if (value !== undefined)
1049
+ preview[key] = value;
1050
+ }
1051
+ return Object.keys(preview).length > 0 ? preview : null;
1052
+ }
1053
+ function sanitizeToolRecord(record) {
1054
+ const sanitized = sanitizeToolValue(record);
1055
+ return asRecord(sanitized) ?? {};
1056
+ }
1057
+ function sanitizeToolValue(value, depth = 0) {
1058
+ if (value === null || value === undefined)
1059
+ return value;
1060
+ if (typeof value === 'string') {
1061
+ return value.length > 800 ? value.slice(0, 797) + '...' : value;
1062
+ }
1063
+ if (typeof value === 'number' || typeof value === 'boolean')
1064
+ return value;
1065
+ if (depth >= 3) {
1066
+ if (Array.isArray(value))
1067
+ return `[array(${value.length})]`;
1068
+ return '[object]';
1069
+ }
1070
+ if (Array.isArray(value)) {
1071
+ return value.slice(0, 12).map((item) => sanitizeToolValue(item, depth + 1));
1072
+ }
1073
+ if (typeof value === 'object') {
1074
+ const record = value;
1075
+ const out = {};
1076
+ for (const [key, item] of Object.entries(record).slice(0, 20)) {
1077
+ out[key] = sanitizeToolValue(item, depth + 1);
1078
+ }
1079
+ return out;
1080
+ }
1081
+ return String(value);
1082
+ }
1083
+ function extractCommandHint(update) {
1084
+ const command = extractFirstString(update, [
1085
+ 'command',
1086
+ 'cmd',
1087
+ 'input.command',
1088
+ 'arguments.command',
1089
+ 'result.command',
1090
+ ]);
1091
+ if (!command)
1092
+ return null;
1093
+ const args = extractStringArray(update, [
1094
+ 'args',
1095
+ 'input.args',
1096
+ 'arguments.args',
1097
+ 'result.args',
1098
+ ]);
1099
+ return args.length > 0 ? `${command} ${args.join(' ')}` : command;
1100
+ }
1101
+ function extractPathHint(update) {
1102
+ return extractFirstString(update, [
1103
+ 'path',
1104
+ 'file',
1105
+ 'filepath',
1106
+ 'target',
1107
+ 'uri',
1108
+ 'input.path',
1109
+ 'input.file',
1110
+ 'input.uri',
1111
+ 'arguments.path',
1112
+ 'arguments.file',
1113
+ 'result.path',
1114
+ ]);
1115
+ }
1116
+ function extractSearchHint(update) {
1117
+ return extractFirstString(update, [
1118
+ 'query',
1119
+ 'pattern',
1120
+ 'text',
1121
+ 'input.query',
1122
+ 'input.pattern',
1123
+ 'arguments.query',
1124
+ 'arguments.pattern',
1125
+ ]);
1126
+ }
1127
+ function extractExitCode(update) {
1128
+ const direct = update?.exitCode;
1129
+ if (typeof direct === 'number')
1130
+ return direct;
1131
+ const nested = getPathValue(update, 'result.exitCode');
1132
+ if (typeof nested === 'number')
1133
+ return nested;
1134
+ return null;
1135
+ }
1136
+ function extractErrorText(update) {
1137
+ const direct = update?.error;
1138
+ if (typeof direct === 'string' && direct.trim())
1139
+ return direct.trim();
1140
+ if (direct && typeof direct === 'object') {
1141
+ const message = direct.message;
1142
+ if (typeof message === 'string' && message.trim())
1143
+ return message.trim();
1144
+ }
1145
+ return extractFirstString(update, ['result.error', 'result.message']);
1146
+ }
1147
+ function extractFirstString(root, paths) {
1148
+ for (const p of paths) {
1149
+ const value = getPathValue(root, p);
1150
+ if (typeof value !== 'string')
1151
+ continue;
1152
+ const trimmed = value.trim();
1153
+ if (trimmed)
1154
+ return trimmed;
1155
+ }
1156
+ return null;
1157
+ }
1158
+ function extractStringArray(root, paths) {
1159
+ for (const p of paths) {
1160
+ const value = getPathValue(root, p);
1161
+ if (!Array.isArray(value))
1162
+ continue;
1163
+ const out = value
1164
+ .filter((item) => typeof item === 'string')
1165
+ .map((item) => item.trim())
1166
+ .filter(Boolean);
1167
+ if (out.length > 0)
1168
+ return out;
1169
+ }
1170
+ return [];
1171
+ }
1172
+ function getPathValue(root, pathExpr) {
1173
+ const segments = pathExpr.split('.');
1174
+ let current = root;
1175
+ for (const segment of segments) {
1176
+ if (!current || typeof current !== 'object')
1177
+ return undefined;
1178
+ current = current[segment];
1179
+ }
1180
+ return current;
1181
+ }
1182
+ function resolvePermissionToolName(toolCall, fallbackKind, fallbackTitle) {
1183
+ const record = asRecord(toolCall);
1184
+ const candidates = [
1185
+ record?.name,
1186
+ record?.method,
1187
+ record?.tool,
1188
+ record?.kind,
1189
+ fallbackKind,
1190
+ fallbackTitle,
1191
+ ];
1192
+ for (const candidate of candidates) {
1193
+ if (typeof candidate !== 'string')
1194
+ continue;
1195
+ const trimmed = candidate.trim();
1196
+ if (trimmed)
1197
+ return trimmed;
1198
+ }
1199
+ return 'tool_call';
1200
+ }
1201
+ function resolvePermissionToolArgs(toolCall) {
1202
+ const record = asRecord(toolCall);
1203
+ if (!record)
1204
+ return null;
1205
+ const direct = parsePermissionJson(record.arguments ?? record.input ?? record.params);
1206
+ const directRecord = asRecord(direct);
1207
+ const remainder = {};
1208
+ for (const [key, value] of Object.entries(record)) {
1209
+ if (value === undefined)
1210
+ continue;
1211
+ if (PERMISSION_TOOL_META_FIELDS.has(key))
1212
+ continue;
1213
+ if (PERMISSION_TOOL_ARG_CONTAINER_FIELDS.has(key))
1214
+ continue;
1215
+ remainder[key] = parsePermissionJson(value);
1216
+ }
1217
+ if (directRecord) {
1218
+ if (Object.keys(remainder).length === 0)
1219
+ return directRecord;
1220
+ return { ...remainder, ...directRecord };
1221
+ }
1222
+ if (direct !== undefined && direct !== null)
1223
+ return direct;
1224
+ return Object.keys(remainder).length > 0 ? remainder : null;
1225
+ }
1226
+ function parsePermissionJson(value) {
1227
+ let current = value;
1228
+ for (let depth = 0; depth < 2; depth += 1) {
1229
+ if (typeof current !== 'string')
1230
+ return current;
1231
+ const trimmed = current.trim();
1232
+ if (!trimmed || !looksLikeJsonValue(trimmed))
1233
+ return current;
1234
+ try {
1235
+ current = JSON.parse(trimmed);
1236
+ }
1237
+ catch {
1238
+ return current;
1239
+ }
1240
+ }
1241
+ return current;
1242
+ }
1243
+ function looksLikeJsonValue(value) {
1244
+ return ((value.startsWith('{') && value.endsWith('}')) ||
1245
+ (value.startsWith('[') && value.endsWith(']')) ||
1246
+ (value.startsWith('"') && value.endsWith('"')));
1247
+ }
1248
+ function asRecord(value) {
1249
+ if (!value || typeof value !== 'object' || Array.isArray(value))
1250
+ return null;
1251
+ return value;
1252
+ }
1253
+ const PERMISSION_TOOL_META_FIELDS = new Set([
1254
+ 'title',
1255
+ 'kind',
1256
+ 'name',
1257
+ 'method',
1258
+ 'tool',
1259
+ 'toolCallId',
1260
+ 'tool_call_id',
1261
+ 'id',
1262
+ ]);
1263
+ const PERMISSION_TOOL_ARG_CONTAINER_FIELDS = new Set([
1264
+ 'arguments',
1265
+ 'input',
1266
+ 'params',
1267
+ ]);