@lucascouts/claude-agent-acp-plus 0.3.0 → 0.5.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.
- package/README.md +1 -1
- package/dist/acp-agent.d.ts +455 -19
- package/dist/acp-agent.d.ts.map +1 -1
- package/dist/acp-agent.js +2143 -415
- package/dist/elicitation.d.ts.map +1 -1
- package/dist/elicitation.js +13 -0
- package/dist/model-deprecation.d.ts +1 -1
- package/dist/model-deprecation.d.ts.map +1 -1
- package/dist/model-deprecation.js +9 -4
- package/dist/rewind-command.d.ts +15 -3
- package/dist/rewind-command.d.ts.map +1 -1
- package/dist/rewind-command.js +37 -6
- package/dist/thinking-option.d.ts +12 -8
- package/dist/thinking-option.d.ts.map +1 -1
- package/dist/thinking-option.js +12 -8
- package/dist/tools.d.ts +2 -3
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +258 -16
- package/package.json +9 -6
- package/dist/ask-user-question-fallback.d.ts +0 -78
- package/dist/ask-user-question-fallback.d.ts.map +0 -1
- package/dist/ask-user-question-fallback.js +0 -104
package/dist/tools.js
CHANGED
|
@@ -338,7 +338,80 @@ export function toolInfoFromToolUse(toolUse, supportsTerminalOutput = false, cwd
|
|
|
338
338
|
};
|
|
339
339
|
}
|
|
340
340
|
}
|
|
341
|
-
|
|
341
|
+
/**
|
|
342
|
+
* Narrow the untyped message-level `tool_use_result` toward a per-tool Output
|
|
343
|
+
* shape: rejects everything but a plain non-null object (arrays pass a bare
|
|
344
|
+
* `typeof === "object"` check, so they're excluded here). The returned value
|
|
345
|
+
* is only *nominally* typed — it arrives over the wire from arbitrary CLI
|
|
346
|
+
* versions, so each caller must still guard the specific fields it reads
|
|
347
|
+
* before trusting them.
|
|
348
|
+
*/
|
|
349
|
+
function structuredResult(toolUseResult) {
|
|
350
|
+
return toolUseResult !== null &&
|
|
351
|
+
typeof toolUseResult === "object" &&
|
|
352
|
+
!Array.isArray(toolUseResult)
|
|
353
|
+
? toolUseResult
|
|
354
|
+
: undefined;
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Strip the model-directed trailer from a raw Agent/Task tool_result text:
|
|
358
|
+
* a `<usage>…</usage>` totals block and/or an
|
|
359
|
+
* `agentId: <id> (use SendMessage …)` continuation line at the end of the
|
|
360
|
+
* text. Both patterns are tail-anchored and independent (older CLIs emit
|
|
361
|
+
* variants with only one of them), so a format change makes them stop
|
|
362
|
+
* matching rather than mangle the report.
|
|
363
|
+
*/
|
|
364
|
+
function stripAgentTrailer(text) {
|
|
365
|
+
return stripAgentIdLine(stripUsageBlock(text));
|
|
366
|
+
}
|
|
367
|
+
const USAGE_OPEN = "<usage>";
|
|
368
|
+
const USAGE_CLOSE = "</usage>";
|
|
369
|
+
/** Remove a trailing `<usage>…</usage>` block, plus trailing whitespace and
|
|
370
|
+
* one preceding newline. Matches from the *last* `<usage>` so a report that
|
|
371
|
+
* merely mentions the marker earlier isn't truncated at the mention. */
|
|
372
|
+
function stripUsageBlock(text) {
|
|
373
|
+
const body = text.trimEnd();
|
|
374
|
+
if (!body.endsWith(USAGE_CLOSE)) {
|
|
375
|
+
return text;
|
|
376
|
+
}
|
|
377
|
+
const open = body.lastIndexOf(USAGE_OPEN, body.length - USAGE_CLOSE.length - USAGE_OPEN.length);
|
|
378
|
+
if (open === -1) {
|
|
379
|
+
return text;
|
|
380
|
+
}
|
|
381
|
+
return body.slice(0, open > 0 && body[open - 1] === "\n" ? open - 1 : open);
|
|
382
|
+
}
|
|
383
|
+
/** The continuation line, anchored to a whole line so the regex has a single
|
|
384
|
+
* start position and no ambiguous repetition (`[\w-]+` can't consume the
|
|
385
|
+
* following space, `[^)]*` can't consume the closing paren) — it runs in
|
|
386
|
+
* linear time on any input. */
|
|
387
|
+
const AGENT_ID_LINE = /^agentId: [\w-]+ \([^)]*\)$/;
|
|
388
|
+
/** Remove a final `agentId: <id> (…)` line, plus trailing whitespace and the
|
|
389
|
+
* newline that preceded the line. */
|
|
390
|
+
function stripAgentIdLine(text) {
|
|
391
|
+
const body = text.trimEnd();
|
|
392
|
+
const lineStart = body.lastIndexOf("\n") + 1;
|
|
393
|
+
if (!AGENT_ID_LINE.test(body.slice(lineStart))) {
|
|
394
|
+
return text;
|
|
395
|
+
}
|
|
396
|
+
return body.slice(0, Math.max(lineStart - 1, 0));
|
|
397
|
+
}
|
|
398
|
+
/** Apply {@link stripAgentTrailer} across a raw tool_result `content` (plain
|
|
399
|
+
* string or block array), leaving non-text blocks untouched. */
|
|
400
|
+
function stripAgentTrailerFromContent(content) {
|
|
401
|
+
if (typeof content === "string") {
|
|
402
|
+
return stripAgentTrailer(content);
|
|
403
|
+
}
|
|
404
|
+
if (Array.isArray(content)) {
|
|
405
|
+
return content.map((block) => block !== null &&
|
|
406
|
+
typeof block === "object" &&
|
|
407
|
+
block.type === "text" &&
|
|
408
|
+
typeof block.text === "string"
|
|
409
|
+
? { ...block, text: stripAgentTrailer(block.text) }
|
|
410
|
+
: block);
|
|
411
|
+
}
|
|
412
|
+
return content;
|
|
413
|
+
}
|
|
414
|
+
export function toolUpdateFromToolResult(toolResult, toolUse, supportsTerminalOutput = false, toolUseResult) {
|
|
342
415
|
if ("is_error" in toolResult &&
|
|
343
416
|
toolResult.is_error &&
|
|
344
417
|
toolResult.content &&
|
|
@@ -347,8 +420,57 @@ export function toolUpdateFromToolResult(toolResult, toolUse, supportsTerminalOu
|
|
|
347
420
|
// Only return errors
|
|
348
421
|
return toAcpContentUpdate(toolResult.content, true);
|
|
349
422
|
}
|
|
423
|
+
// Shared raw-text fallback: renders the tool_result content the model saw.
|
|
424
|
+
// The structured cases below fall back to this when `tool_use_result` is
|
|
425
|
+
// absent or fails its shape guard (older CLIs, replayed sessions).
|
|
426
|
+
const rawContentUpdate = () => toAcpContentUpdate(toolResult.content, "is_error" in toolResult ? toolResult.is_error : false);
|
|
350
427
|
switch (toolUse?.name) {
|
|
351
|
-
case "Read":
|
|
428
|
+
case "Read": {
|
|
429
|
+
// The raw tool_result text is the model-facing view: line-numbered
|
|
430
|
+
// content plus any appended <system-reminder> blocks (malicious-code
|
|
431
|
+
// checks, memory staleness notes, …) that clients shouldn't see. The
|
|
432
|
+
// structured FileReadOutput carries the clean content — rebuild the
|
|
433
|
+
// line-numbered view from it. Non-text variants (image/notebook/pdf)
|
|
434
|
+
// fall back to the raw content blocks, which already render fine.
|
|
435
|
+
const structuredRead = structuredResult(toolUseResult);
|
|
436
|
+
if (structuredRead?.type === "text" &&
|
|
437
|
+
typeof structuredRead.file?.content === "string" &&
|
|
438
|
+
// An empty file has nothing to line-number; keep the raw view (the
|
|
439
|
+
// model-facing "file is empty" note) rather than a phantom blank line.
|
|
440
|
+
structuredRead.file.content.length > 0) {
|
|
441
|
+
// startLine is typed non-optional but defended anyway; a Read's
|
|
442
|
+
// `offset` input is the same 1-based starting line, so it beats a
|
|
443
|
+
// blind 1 when an emitter omits the field.
|
|
444
|
+
const startLine = structuredRead.file.startLine ??
|
|
445
|
+
toolUse.input?.offset ??
|
|
446
|
+
1;
|
|
447
|
+
// A trailing newline is a line terminator, not an extra line — don't
|
|
448
|
+
// number a phantom empty line after it.
|
|
449
|
+
let numbered = structuredRead.file.content
|
|
450
|
+
.replace(/\n$/, "")
|
|
451
|
+
.split("\n")
|
|
452
|
+
.map((line, i) => `${startLine + i}\t${line}`)
|
|
453
|
+
.join("\n");
|
|
454
|
+
// The model-facing truncation banner doesn't survive reconstruction
|
|
455
|
+
// from file.content (the SDK flag exists for exactly this case) —
|
|
456
|
+
// re-establish it so a partial first page doesn't read as the whole
|
|
457
|
+
// file.
|
|
458
|
+
if (structuredRead.file.truncatedByTokenCap) {
|
|
459
|
+
const { numLines, totalLines } = structuredRead.file;
|
|
460
|
+
const detail = typeof numLines === "number" && typeof totalLines === "number"
|
|
461
|
+
? `: showing ${numLines} of ${totalLines} lines`
|
|
462
|
+
: "";
|
|
463
|
+
numbered += `\n[File truncated${detail}]`;
|
|
464
|
+
}
|
|
465
|
+
return {
|
|
466
|
+
content: [
|
|
467
|
+
{
|
|
468
|
+
type: "content",
|
|
469
|
+
content: { type: "text", text: markdownEscape(numbered) },
|
|
470
|
+
},
|
|
471
|
+
],
|
|
472
|
+
};
|
|
473
|
+
}
|
|
352
474
|
if (Array.isArray(toolResult.content) && toolResult.content.length > 0) {
|
|
353
475
|
return {
|
|
354
476
|
content: toolResult.content.map((content) => ({
|
|
@@ -376,19 +498,70 @@ export function toolUpdateFromToolResult(toolResult, toolUse, supportsTerminalOu
|
|
|
376
498
|
};
|
|
377
499
|
}
|
|
378
500
|
return {};
|
|
501
|
+
}
|
|
379
502
|
case "Bash": {
|
|
380
503
|
const result = toolResult.content;
|
|
381
|
-
|
|
504
|
+
// The terminal was announced under the tool_use's own id (see
|
|
505
|
+
// `toolInfoFromToolUse`), so key the output/exit metas off that: it is the
|
|
506
|
+
// id the client actually created a terminal for. `toolResult.tool_use_id`
|
|
507
|
+
// is the same value whenever present — the caller looks the tool_use up by
|
|
508
|
+
// it — so preferring `toolUse.id` only adds a source for the case where the
|
|
509
|
+
// result block carries no id at all. Anything that isn't a non-empty
|
|
510
|
+
// string is no id at all: `""` matches no terminal, and stringifying a
|
|
511
|
+
// present-but-undefined field would invent the literal `"undefined"`.
|
|
512
|
+
const terminalIdOf = (id) => typeof id === "string" && id.length > 0 ? id : undefined;
|
|
513
|
+
const terminalId = terminalIdOf(toolUse?.id) ??
|
|
514
|
+
terminalIdOf("tool_use_id" in toolResult ? toolResult.tool_use_id : undefined);
|
|
382
515
|
const isError = "is_error" in toolResult && toolResult.is_error;
|
|
383
516
|
// Extract output and exit code from either format:
|
|
384
|
-
// 1.
|
|
385
|
-
//
|
|
386
|
-
//
|
|
517
|
+
// 1. The structured BashOutput (message-level tool_use_result): its
|
|
518
|
+
// stdout/stderr exclude the model-directed suffixes the raw text
|
|
519
|
+
// carries (stale-read hints, gh rate-limit hints, the
|
|
520
|
+
// persisted-output wrapper for too-large outputs — the interruption
|
|
521
|
+
// and truncation facts those carried are re-established from the
|
|
522
|
+
// structured flags below). Skipped for image output (the raw content
|
|
523
|
+
// array carries the actual image blocks) and backgrounded commands
|
|
524
|
+
// (the raw text carries the background-task notice; structured
|
|
525
|
+
// stdout may be empty).
|
|
526
|
+
// 2. BetaBashCodeExecutionResultBlock: { type: "bash_code_execution_result", stdout, stderr, return_code }
|
|
527
|
+
// 3. Plain string content from a regular tool_result
|
|
528
|
+
// 4. Array content (e.g. [{ type: "text", text: "..." }] for stdout,
|
|
387
529
|
// or [{ type: "image", source: {...} }] when the local Bash tool
|
|
388
530
|
// produces an image, e.g. piping a base64 data URI)
|
|
389
531
|
let output = "";
|
|
390
532
|
let exitCode = isError ? 1 : 0;
|
|
391
|
-
|
|
533
|
+
const structuredBash = structuredResult(toolUseResult);
|
|
534
|
+
if (structuredBash &&
|
|
535
|
+
typeof structuredBash.stdout === "string" &&
|
|
536
|
+
typeof structuredBash.stderr === "string" &&
|
|
537
|
+
!structuredBash.isImage &&
|
|
538
|
+
structuredBash.backgroundTaskId === undefined) {
|
|
539
|
+
output = [structuredBash.stdout, structuredBash.stderr].filter(Boolean).join("\n");
|
|
540
|
+
// Two raw-text notices don't survive the structured stdout/stderr —
|
|
541
|
+
// re-establish them so the client isn't shown a clean-looking result:
|
|
542
|
+
// the CLI appends its abort marker only to the model-facing text, and
|
|
543
|
+
// an aborted command isn't a success, so synthesize a failing exit
|
|
544
|
+
// code when the result wasn't already an error.
|
|
545
|
+
if (structuredBash.interrupted) {
|
|
546
|
+
output = [output, "[Command was aborted before completion]"].filter(Boolean).join("\n");
|
|
547
|
+
exitCode = 1;
|
|
548
|
+
}
|
|
549
|
+
// Structured stdout is clipped (~30k chars) when the full output was
|
|
550
|
+
// persisted to disk; without this note the clip is silent and the
|
|
551
|
+
// path to the full output is lost.
|
|
552
|
+
if (typeof structuredBash.persistedOutputPath === "string") {
|
|
553
|
+
const size = typeof structuredBash.persistedOutputSize === "number"
|
|
554
|
+
? ` (${structuredBash.persistedOutputSize} bytes total)`
|
|
555
|
+
: "";
|
|
556
|
+
output = [
|
|
557
|
+
output,
|
|
558
|
+
`[Output truncated${size}: full output saved to ${structuredBash.persistedOutputPath}]`,
|
|
559
|
+
]
|
|
560
|
+
.filter(Boolean)
|
|
561
|
+
.join("\n");
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
else if (result &&
|
|
392
565
|
typeof result === "object" &&
|
|
393
566
|
"type" in result &&
|
|
394
567
|
result.type === "bash_code_execution_result") {
|
|
@@ -413,7 +586,14 @@ export function toolUpdateFromToolResult(toolResult, toolUse, supportsTerminalOu
|
|
|
413
586
|
return toAcpContentUpdate(result, isError);
|
|
414
587
|
}
|
|
415
588
|
}
|
|
416
|
-
|
|
589
|
+
// Without a terminal id there is nothing the client can reconcile these
|
|
590
|
+
// metas against, and emitting them anyway strands the output: a client that
|
|
591
|
+
// buffers output/exit for terminals it has not been told about (Zed keeps
|
|
592
|
+
// them in `pending_terminal_output`/`pending_terminal_exit`, drained only on
|
|
593
|
+
// a matching create) would hold them forever behind an id that never
|
|
594
|
+
// arrives, showing an empty terminal. Fall through to the code-block
|
|
595
|
+
// rendering below instead.
|
|
596
|
+
if (supportsTerminalOutput && terminalId !== undefined) {
|
|
417
597
|
return {
|
|
418
598
|
content: [{ type: "terminal", terminalId }],
|
|
419
599
|
_meta: {
|
|
@@ -448,6 +628,36 @@ export function toolUpdateFromToolResult(toolResult, toolUse, supportsTerminalOu
|
|
|
448
628
|
}
|
|
449
629
|
return {};
|
|
450
630
|
}
|
|
631
|
+
case "Agent":
|
|
632
|
+
case "Task": {
|
|
633
|
+
// The raw tool_result text ends with a model-directed trailer (an
|
|
634
|
+
// `agentId: … (use SendMessage …)` line plus a `<usage>` totals block)
|
|
635
|
+
// that ACP clients shouldn't see. The message-level `tool_use_result`
|
|
636
|
+
// carries the structured AgentOutput whose `content` is the subagent's
|
|
637
|
+
// report without the trailer — render from it when present (per the SDK
|
|
638
|
+
// 0.3.207 guidance) and fall back to the raw text otherwise (older CLIs,
|
|
639
|
+
// replayed sessions).
|
|
640
|
+
// Narrowed to the full union, not the completed variant — the status
|
|
641
|
+
// check below is what discriminates it, and pre-narrowing would let
|
|
642
|
+
// future field reads typecheck against a variant the runtime value may
|
|
643
|
+
// not be.
|
|
644
|
+
const structured = structuredResult(toolUseResult);
|
|
645
|
+
if (structured?.status === "completed" &&
|
|
646
|
+
Array.isArray(structured.content) &&
|
|
647
|
+
// A completed subagent can end with zero text blocks; an empty
|
|
648
|
+
// structured render would beat the raw fallback for no benefit.
|
|
649
|
+
structured.content.length > 0) {
|
|
650
|
+
return toAcpContentUpdate(structured.content, "is_error" in toolResult ? toolResult.is_error : false);
|
|
651
|
+
}
|
|
652
|
+
// No structured report to render from (replayed sessions —
|
|
653
|
+
// getSessionMessages doesn't expose the transcript's toolUseResult —
|
|
654
|
+
// and older CLIs). The SDK advises rendering from tool_use_result
|
|
655
|
+
// instead of parsing the text, but with no structured value the
|
|
656
|
+
// tail-anchored strip is the only cleanup available; if the trailer
|
|
657
|
+
// format changes it simply stops matching and the full raw text
|
|
658
|
+
// renders, no worse than before.
|
|
659
|
+
return toAcpContentUpdate(stripAgentTrailerFromContent(toolResult.content), "is_error" in toolResult ? toolResult.is_error : false);
|
|
660
|
+
}
|
|
451
661
|
case "Edit": // Edit is handled in hooks
|
|
452
662
|
case "Write": {
|
|
453
663
|
return {};
|
|
@@ -455,11 +665,47 @@ export function toolUpdateFromToolResult(toolResult, toolUse, supportsTerminalOu
|
|
|
455
665
|
case "ExitPlanMode": {
|
|
456
666
|
return { title: "Exited Plan Mode" };
|
|
457
667
|
}
|
|
668
|
+
case "WebSearch": {
|
|
669
|
+
// The raw tool_result text is a model-directed dump ("Web search
|
|
670
|
+
// results for query: …\n\nLinks: [{…json…}]"). The structured
|
|
671
|
+
// WebSearchOutput carries the hits — render them the way server-side
|
|
672
|
+
// web_search_result blocks render ("Title (url)").
|
|
673
|
+
const structuredSearch = structuredResult(toolUseResult);
|
|
674
|
+
if (structuredSearch && Array.isArray(structuredSearch.results)) {
|
|
675
|
+
const lines = structuredSearch.results.flatMap((entry) => typeof entry === "string"
|
|
676
|
+
? [entry]
|
|
677
|
+
: Array.isArray(entry?.content)
|
|
678
|
+
? // tool_use_result arrives untyped across CLI version skew —
|
|
679
|
+
// skip off-spec hits rather than rendering
|
|
680
|
+
// "undefined (undefined)" lines.
|
|
681
|
+
entry.content.flatMap((hit) => typeof hit?.title === "string" && typeof hit?.url === "string"
|
|
682
|
+
? [formatWebSearchHit(hit)]
|
|
683
|
+
: [])
|
|
684
|
+
: []);
|
|
685
|
+
if (lines.length > 0) {
|
|
686
|
+
return {
|
|
687
|
+
content: [
|
|
688
|
+
{
|
|
689
|
+
type: "content",
|
|
690
|
+
content: { type: "text", text: lines.join("\n") },
|
|
691
|
+
},
|
|
692
|
+
],
|
|
693
|
+
};
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
return rawContentUpdate();
|
|
697
|
+
}
|
|
458
698
|
default: {
|
|
459
|
-
return
|
|
699
|
+
return rawContentUpdate();
|
|
460
700
|
}
|
|
461
701
|
}
|
|
462
702
|
}
|
|
703
|
+
/** One display format for a web-search hit, shared by the structured
|
|
704
|
+
* WebSearchOutput render and the server-side `web_search_result` block so
|
|
705
|
+
* the two paths can't drift. */
|
|
706
|
+
function formatWebSearchHit(hit) {
|
|
707
|
+
return `${hit.title} (${hit.url})`;
|
|
708
|
+
}
|
|
463
709
|
function toAcpContentUpdate(content, isError = false) {
|
|
464
710
|
if (Array.isArray(content) && content.length > 0) {
|
|
465
711
|
return {
|
|
@@ -524,7 +770,7 @@ function toAcpContentBlock(content, isError) {
|
|
|
524
770
|
case "tool_search_tool_result_error":
|
|
525
771
|
return wrapText(`Error: ${content.error_code}${content.error_message ? ` - ${content.error_message}` : ""}`);
|
|
526
772
|
case "web_search_result":
|
|
527
|
-
return wrapText(
|
|
773
|
+
return wrapText(formatWebSearchHit(content));
|
|
528
774
|
case "web_search_tool_result_error":
|
|
529
775
|
return wrapText(`Error: ${content.error_code}`);
|
|
530
776
|
case "web_fetch_result":
|
|
@@ -698,7 +944,7 @@ export const registerHookCallback = (toolUseID, { onPostToolUseHook, }) => {
|
|
|
698
944
|
};
|
|
699
945
|
};
|
|
700
946
|
/* A callback for Claude Code that is called when receiving a PostToolUse hook */
|
|
701
|
-
export const createPostToolUseHook = (
|
|
947
|
+
export const createPostToolUseHook = (options) => async (input, toolUseID) => {
|
|
702
948
|
if (input.hook_event_name === "PostToolUse") {
|
|
703
949
|
// Handle EnterPlanMode tool - notify client of mode change after successful execution
|
|
704
950
|
if (input.tool_name === "EnterPlanMode" && options?.onEnterPlanMode) {
|
|
@@ -708,12 +954,8 @@ export const createPostToolUseHook = (logger = console, options) => async (input
|
|
|
708
954
|
const onPostToolUseHook = toolUseCallbacks[toolUseID]?.onPostToolUseHook;
|
|
709
955
|
if (onPostToolUseHook) {
|
|
710
956
|
await onPostToolUseHook(toolUseID, input.tool_input, input.tool_response);
|
|
711
|
-
delete toolUseCallbacks[toolUseID]; // Cleanup after execution
|
|
712
|
-
}
|
|
713
|
-
else {
|
|
714
|
-
logger.error(`No onPostToolUseHook found for tool use ID: ${toolUseID}`);
|
|
715
|
-
delete toolUseCallbacks[toolUseID];
|
|
716
957
|
}
|
|
958
|
+
delete toolUseCallbacks[toolUseID]; // Cleanup after execution
|
|
717
959
|
}
|
|
718
960
|
}
|
|
719
961
|
return { continue: true };
|
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"publishConfig": {
|
|
4
4
|
"access": "public"
|
|
5
5
|
},
|
|
6
|
-
"version": "0.
|
|
6
|
+
"version": "0.5.0",
|
|
7
7
|
"description": "ACP adapter for the Claude Agent SDK with VS Code-parity UX for Zed and other ACP clients — checkbox multi-select questions, refusal consent dialog, dynamic agent name. Fork of claude-agent-acp.",
|
|
8
8
|
"main": "dist/lib.js",
|
|
9
9
|
"types": "dist/lib.d.ts",
|
|
@@ -64,17 +64,20 @@
|
|
|
64
64
|
"node": ">=24"
|
|
65
65
|
},
|
|
66
66
|
"dependencies": {
|
|
67
|
-
"@agentclientprotocol/sdk": "1.
|
|
68
|
-
"@anthropic-ai/claude-agent-sdk": "0.3.
|
|
67
|
+
"@agentclientprotocol/sdk": "1.3.0",
|
|
68
|
+
"@anthropic-ai/claude-agent-sdk": "0.3.220",
|
|
69
69
|
"zod": "^3.25.0 || ^4.0.0"
|
|
70
70
|
},
|
|
71
|
+
"overrides": {
|
|
72
|
+
"brace-expansion": "5.0.8"
|
|
73
|
+
},
|
|
71
74
|
"devDependencies": {
|
|
72
|
-
"@anthropic-ai/sdk": "0.
|
|
75
|
+
"@anthropic-ai/sdk": "0.115.0",
|
|
73
76
|
"@eslint/js": "10.0.1",
|
|
74
77
|
"@tsconfig/node24": "24.0.4",
|
|
75
78
|
"@types/node": "26.1.1",
|
|
76
|
-
"@typescript-eslint/eslint-plugin": "8.
|
|
77
|
-
"@typescript-eslint/parser": "8.
|
|
79
|
+
"@typescript-eslint/eslint-plugin": "8.64.0",
|
|
80
|
+
"@typescript-eslint/parser": "8.64.0",
|
|
78
81
|
"eslint": "10.7.0",
|
|
79
82
|
"eslint-config-prettier": "10.1.8",
|
|
80
83
|
"globals": "17.7.0",
|
|
@@ -1,78 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Fallback for the built-in AskUserQuestion tool when the connected client does
|
|
3
|
-
* NOT advertise `elicitation.form`. Upstream disables the tool for such clients;
|
|
4
|
-
* this module lets the agent instead route each question through ACP's
|
|
5
|
-
* `session/request_permission` dialog. The path is gated by the
|
|
6
|
-
* `ACP_ASKUSERQUESTION_FALLBACK` env var so it can be turned off to restore
|
|
7
|
-
* byte-for-byte upstream behavior.
|
|
8
|
-
*
|
|
9
|
-
* Alongside the env gate this module provides the pure mapping/orchestration
|
|
10
|
-
* helpers (`questionPermissionOptions`, `handleAskUserQuestionViaPermission`);
|
|
11
|
-
* the ACP client is injected by the adapter that wires them in.
|
|
12
|
-
*/
|
|
13
|
-
import type { PermissionOption } from "@agentclientprotocol/sdk";
|
|
14
|
-
import { type AskUserQuestion } from "./elicitation.js";
|
|
15
|
-
/**
|
|
16
|
-
* Whether the AskUserQuestion permission fallback is enabled for clients lacking
|
|
17
|
-
* `elicitation.form`. Defaults ON: only an explicit `0` or `false` (trimmed,
|
|
18
|
-
* case-insensitive) turns it off; any other value — unset, `1`, `true`, … —
|
|
19
|
-
* leaves it on.
|
|
20
|
-
*
|
|
21
|
-
* @param env Environment map to read `ACP_ASKUSERQUESTION_FALLBACK` from (pass
|
|
22
|
-
* `process.env`).
|
|
23
|
-
*/
|
|
24
|
-
export declare function askUserQuestionFallbackEnabled(env: Record<string, string | undefined>): boolean;
|
|
25
|
-
/**
|
|
26
|
-
* Map one AskUserQuestion question to the options shown in ACP's
|
|
27
|
-
* `session/request_permission` dialog: one `allow_once` option per choice (in
|
|
28
|
-
* order) followed by a trailing `reject_once` "Skip this question" option.
|
|
29
|
-
*
|
|
30
|
-
* Each choice's `optionId` is its bare `label` — the value the tool records as
|
|
31
|
-
* the answer — so the description is never folded into the id; a non-empty
|
|
32
|
-
* `description` is instead appended to the human-readable `name` as
|
|
33
|
-
* `"<label> — <description>"`. The skip option is given a random `optionId`
|
|
34
|
-
* that cannot collide with any label, so the orchestrator can recognize a skip
|
|
35
|
-
* by id alone.
|
|
36
|
-
*
|
|
37
|
-
* @param question The single question whose choices become permission options.
|
|
38
|
-
*/
|
|
39
|
-
export declare function questionPermissionOptions(question: AskUserQuestion): PermissionOption[];
|
|
40
|
-
/**
|
|
41
|
-
* Route an AskUserQuestion tool call through ACP permission requests rather than
|
|
42
|
-
* a form elicitation, for clients that lack `elicitation.form`. Each question is
|
|
43
|
-
* asked sequentially via the injected `requestPermission`; the selected label is
|
|
44
|
-
* accumulated into `answers` (keyed by the question text), a skipped question is
|
|
45
|
-
* omitted, and a multi-select question naturally degrades to a single label
|
|
46
|
-
* since each option is single-select here.
|
|
47
|
-
*
|
|
48
|
-
* Mirrors the upstream form handler's contract: input with no parseable
|
|
49
|
-
* questions denies with the identical message, and an aborted signal or a
|
|
50
|
-
* cancelled request throws `"Tool use aborted"`, discarding every accumulated
|
|
51
|
-
* answer. On success it returns the original `toolInput` augmented with
|
|
52
|
-
* `answers` as `updatedInput`.
|
|
53
|
-
*
|
|
54
|
-
* Pure and injectable — the real ACP client is wired in by the adapter — so the
|
|
55
|
-
* orchestration can be exercised without a transport.
|
|
56
|
-
*
|
|
57
|
-
* @param toolInput Raw AskUserQuestion tool input (expected to carry `questions`).
|
|
58
|
-
* @param requestPermission Injected permission-request function, invoked once per
|
|
59
|
-
* question, resolving to the user's selection or a cancellation.
|
|
60
|
-
* @param signal Abort signal for the tool call; checked on entry and around each
|
|
61
|
-
* request.
|
|
62
|
-
*/
|
|
63
|
-
export declare function handleAskUserQuestionViaPermission(toolInput: Record<string, unknown>, requestPermission: (req: {
|
|
64
|
-
question: AskUserQuestion;
|
|
65
|
-
options: PermissionOption[];
|
|
66
|
-
}) => Promise<{
|
|
67
|
-
outcome: "selected";
|
|
68
|
-
optionId: string;
|
|
69
|
-
} | {
|
|
70
|
-
outcome: "cancelled";
|
|
71
|
-
}>, signal: AbortSignal): Promise<{
|
|
72
|
-
behavior: "allow";
|
|
73
|
-
updatedInput: Record<string, unknown>;
|
|
74
|
-
} | {
|
|
75
|
-
behavior: "deny";
|
|
76
|
-
message: string;
|
|
77
|
-
}>;
|
|
78
|
-
//# sourceMappingURL=ask-user-question-fallback.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"ask-user-question-fallback.d.ts","sourceRoot":"","sources":["../src/ask-user-question-fallback.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAGH,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AACjE,OAAO,EAA2B,KAAK,eAAe,EAAE,MAAM,kBAAkB,CAAC;AAEjF;;;;;;;;GAQG;AACH,wBAAgB,8BAA8B,CAAC,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,GAAG,OAAO,CAO/F;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,yBAAyB,CAAC,QAAQ,EAAE,eAAe,GAAG,gBAAgB,EAAE,CAYvF;AAED;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAsB,kCAAkC,CACtD,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAClC,iBAAiB,EAAE,CAAC,GAAG,EAAE;IACvB,QAAQ,EAAE,eAAe,CAAC;IAC1B,OAAO,EAAE,gBAAgB,EAAE,CAAC;CAC7B,KAAK,OAAO,CAAC;IAAE,OAAO,EAAE,UAAU,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,GAAG;IAAE,OAAO,EAAE,WAAW,CAAA;CAAE,CAAC,EACnF,MAAM,EAAE,WAAW,GAClB,OAAO,CACN;IAAE,QAAQ,EAAE,OAAO,CAAC;IAAC,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAAE,GAC5D;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CACxC,CAyBA"}
|
|
@@ -1,104 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Fallback for the built-in AskUserQuestion tool when the connected client does
|
|
3
|
-
* NOT advertise `elicitation.form`. Upstream disables the tool for such clients;
|
|
4
|
-
* this module lets the agent instead route each question through ACP's
|
|
5
|
-
* `session/request_permission` dialog. The path is gated by the
|
|
6
|
-
* `ACP_ASKUSERQUESTION_FALLBACK` env var so it can be turned off to restore
|
|
7
|
-
* byte-for-byte upstream behavior.
|
|
8
|
-
*
|
|
9
|
-
* Alongside the env gate this module provides the pure mapping/orchestration
|
|
10
|
-
* helpers (`questionPermissionOptions`, `handleAskUserQuestionViaPermission`);
|
|
11
|
-
* the ACP client is injected by the adapter that wires them in.
|
|
12
|
-
*/
|
|
13
|
-
import { randomUUID } from "node:crypto";
|
|
14
|
-
import { extractAskUserQuestions } from "./elicitation.js";
|
|
15
|
-
/**
|
|
16
|
-
* Whether the AskUserQuestion permission fallback is enabled for clients lacking
|
|
17
|
-
* `elicitation.form`. Defaults ON: only an explicit `0` or `false` (trimmed,
|
|
18
|
-
* case-insensitive) turns it off; any other value — unset, `1`, `true`, … —
|
|
19
|
-
* leaves it on.
|
|
20
|
-
*
|
|
21
|
-
* @param env Environment map to read `ACP_ASKUSERQUESTION_FALLBACK` from (pass
|
|
22
|
-
* `process.env`).
|
|
23
|
-
*/
|
|
24
|
-
export function askUserQuestionFallbackEnabled(env) {
|
|
25
|
-
const raw = env.ACP_ASKUSERQUESTION_FALLBACK;
|
|
26
|
-
if (raw === undefined) {
|
|
27
|
-
return true;
|
|
28
|
-
}
|
|
29
|
-
const normalized = raw.trim().toLowerCase();
|
|
30
|
-
return normalized !== "0" && normalized !== "false";
|
|
31
|
-
}
|
|
32
|
-
/**
|
|
33
|
-
* Map one AskUserQuestion question to the options shown in ACP's
|
|
34
|
-
* `session/request_permission` dialog: one `allow_once` option per choice (in
|
|
35
|
-
* order) followed by a trailing `reject_once` "Skip this question" option.
|
|
36
|
-
*
|
|
37
|
-
* Each choice's `optionId` is its bare `label` — the value the tool records as
|
|
38
|
-
* the answer — so the description is never folded into the id; a non-empty
|
|
39
|
-
* `description` is instead appended to the human-readable `name` as
|
|
40
|
-
* `"<label> — <description>"`. The skip option is given a random `optionId`
|
|
41
|
-
* that cannot collide with any label, so the orchestrator can recognize a skip
|
|
42
|
-
* by id alone.
|
|
43
|
-
*
|
|
44
|
-
* @param question The single question whose choices become permission options.
|
|
45
|
-
*/
|
|
46
|
-
export function questionPermissionOptions(question) {
|
|
47
|
-
const options = question.options.map((option) => ({
|
|
48
|
-
kind: "allow_once",
|
|
49
|
-
optionId: option.label,
|
|
50
|
-
name: option.description ? `${option.label} — ${option.description}` : option.label,
|
|
51
|
-
}));
|
|
52
|
-
options.push({
|
|
53
|
-
kind: "reject_once",
|
|
54
|
-
name: "Skip this question",
|
|
55
|
-
optionId: randomUUID(),
|
|
56
|
-
});
|
|
57
|
-
return options;
|
|
58
|
-
}
|
|
59
|
-
/**
|
|
60
|
-
* Route an AskUserQuestion tool call through ACP permission requests rather than
|
|
61
|
-
* a form elicitation, for clients that lack `elicitation.form`. Each question is
|
|
62
|
-
* asked sequentially via the injected `requestPermission`; the selected label is
|
|
63
|
-
* accumulated into `answers` (keyed by the question text), a skipped question is
|
|
64
|
-
* omitted, and a multi-select question naturally degrades to a single label
|
|
65
|
-
* since each option is single-select here.
|
|
66
|
-
*
|
|
67
|
-
* Mirrors the upstream form handler's contract: input with no parseable
|
|
68
|
-
* questions denies with the identical message, and an aborted signal or a
|
|
69
|
-
* cancelled request throws `"Tool use aborted"`, discarding every accumulated
|
|
70
|
-
* answer. On success it returns the original `toolInput` augmented with
|
|
71
|
-
* `answers` as `updatedInput`.
|
|
72
|
-
*
|
|
73
|
-
* Pure and injectable — the real ACP client is wired in by the adapter — so the
|
|
74
|
-
* orchestration can be exercised without a transport.
|
|
75
|
-
*
|
|
76
|
-
* @param toolInput Raw AskUserQuestion tool input (expected to carry `questions`).
|
|
77
|
-
* @param requestPermission Injected permission-request function, invoked once per
|
|
78
|
-
* question, resolving to the user's selection or a cancellation.
|
|
79
|
-
* @param signal Abort signal for the tool call; checked on entry and around each
|
|
80
|
-
* request.
|
|
81
|
-
*/
|
|
82
|
-
export async function handleAskUserQuestionViaPermission(toolInput, requestPermission, signal) {
|
|
83
|
-
if (signal.aborted) {
|
|
84
|
-
throw new Error("Tool use aborted");
|
|
85
|
-
}
|
|
86
|
-
const questions = extractAskUserQuestions(toolInput);
|
|
87
|
-
if (!questions) {
|
|
88
|
-
return { behavior: "deny", message: "AskUserQuestion called with no valid questions." };
|
|
89
|
-
}
|
|
90
|
-
const answers = {};
|
|
91
|
-
for (const question of questions) {
|
|
92
|
-
const options = questionPermissionOptions(question);
|
|
93
|
-
const skipOptionId = options.find((option) => option.kind === "reject_once")?.optionId;
|
|
94
|
-
const outcome = await requestPermission({ question, options });
|
|
95
|
-
if (outcome.outcome === "cancelled" || signal.aborted) {
|
|
96
|
-
throw new Error("Tool use aborted");
|
|
97
|
-
}
|
|
98
|
-
if (outcome.optionId === skipOptionId) {
|
|
99
|
-
continue;
|
|
100
|
-
}
|
|
101
|
-
answers[question.question] = outcome.optionId;
|
|
102
|
-
}
|
|
103
|
-
return { behavior: "allow", updatedInput: { ...toolInput, answers } };
|
|
104
|
-
}
|