@sweny-ai/core 0.1.148 → 0.1.150
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/builtin-workflows.d.ts +45 -0
- package/dist/cli/builtin-workflows.js +129 -0
- package/dist/cli/check.d.ts +2 -8
- package/dist/cli/check.js +2 -34
- package/dist/cli/config.js +8 -3
- package/dist/cli/main.js +46 -11
- package/dist/cli/packs.d.ts +9 -5
- package/dist/cli/packs.js +100 -50
- package/dist/executor.js +15 -3
- package/dist/harness/acp-rpc.d.ts +75 -0
- package/dist/harness/acp-rpc.js +204 -0
- package/dist/harness/acp.d.ts +189 -0
- package/dist/harness/acp.js +942 -0
- package/dist/harness/agents.d.ts +5 -1
- package/dist/harness/agents.js +8 -1
- package/dist/harness/auth.d.ts +38 -0
- package/dist/harness/auth.js +93 -0
- package/dist/harness/capabilities.d.ts +30 -0
- package/dist/harness/capabilities.js +41 -0
- package/dist/harness/claude-code.d.ts +8 -0
- package/dist/harness/claude-code.js +8 -1
- package/dist/harness/codex.d.ts +10 -1
- package/dist/harness/codex.js +17 -3
- package/dist/harness/index.d.ts +7 -3
- package/dist/harness/index.js +7 -2
- package/dist/harness/parse.d.ts +12 -0
- package/dist/harness/parse.js +1 -1
- package/dist/harness/policy.js +6 -0
- package/dist/harness/types.d.ts +6 -0
- package/dist/index.d.ts +1 -1
- package/dist/node-policy.js +1 -0
- package/dist/safe-outputs.d.ts +17 -5
- package/dist/safe-outputs.js +85 -11
- package/dist/schema.d.ts +80 -0
- package/dist/schema.js +23 -0
- package/dist/types.d.ts +13 -0
- package/dist/workflows/browser.js +2 -2
- package/dist/workflows/implement.yml +61 -19
- package/dist/workflows/triage.yml +171 -139
- package/package.json +1 -1
package/dist/cli/packs.js
CHANGED
|
@@ -10,11 +10,15 @@
|
|
|
10
10
|
* - validates with no credentials
|
|
11
11
|
* - every node declares an `output` schema and at least one `eval` gate
|
|
12
12
|
* - value and function gates first; a judge only where a rule cannot decide
|
|
13
|
-
* -
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* -
|
|
17
|
-
* read
|
|
13
|
+
* - safe outputs (#365): every GitHub write (issue, comment) is a declared
|
|
14
|
+
* output with a cap, and a pin or title prefix where one applies; sweny
|
|
15
|
+
* applies it after the node. No node holds a GitHub write tool.
|
|
16
|
+
* - least privilege: a workflow-level `permissions` ceiling, and every node
|
|
17
|
+
* is `read` except a delivery node that needs a write no safe output
|
|
18
|
+
* covers (Slack); its write tools are named in `pack.writes` and its tool
|
|
19
|
+
* list is restricted with `tools.allow`
|
|
20
|
+
* - harness-agnostic: instructions name SWEny skill tools, never a specific
|
|
21
|
+
* agent's built-in tools
|
|
18
22
|
*/
|
|
19
23
|
const DIGEST_YAML = `id: weekly-digest
|
|
20
24
|
name: Weekly Repo Digest
|
|
@@ -37,13 +41,20 @@ inputs:
|
|
|
37
41
|
enum: [issue, slack, none]
|
|
38
42
|
default: issue
|
|
39
43
|
|
|
44
|
+
# Ceiling (#365): write only for the Slack post in publish. The digest issue is
|
|
45
|
+
# a safe output, applied by sweny after publish, at most one per run.
|
|
46
|
+
permissions: write
|
|
47
|
+
safe_outputs:
|
|
48
|
+
allow: [issue]
|
|
49
|
+
max: 1
|
|
50
|
+
|
|
40
51
|
nodes:
|
|
41
52
|
collect:
|
|
42
53
|
name: Collect the Week
|
|
43
54
|
instruction: |
|
|
44
55
|
Gather facts about the last N days, where N is context.input.days
|
|
45
|
-
(default 7). The repo is context.input.repo; if empty,
|
|
46
|
-
|
|
56
|
+
(default 7). The repo is context.input.repo; if empty, read the origin
|
|
57
|
+
remote URL from .git/config in the checkout.
|
|
47
58
|
Compute the window as UTC dates: until is today, since is N days ago.
|
|
48
59
|
|
|
49
60
|
1. Commits on the default branch in the window:
|
|
@@ -54,18 +65,19 @@ nodes:
|
|
|
54
65
|
3. Issues opened and issues closed in the window:
|
|
55
66
|
github_search_issues with "is:issue created:>=SINCE" and
|
|
56
67
|
"is:issue closed:>=SINCE".
|
|
57
|
-
4. Files touched, with churn:
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
Keep the 15 paths with the most churn.
|
|
68
|
+
4. Files touched, with churn: github_list_pr_files for each merged PR
|
|
69
|
+
(at most 20, newest first), and sum additions plus deletions per
|
|
70
|
+
path. Keep the 15 paths with the most churn.
|
|
61
71
|
|
|
62
|
-
Only read.
|
|
63
|
-
|
|
72
|
+
Only read. Report only what the tools returned. A quiet week is a
|
|
73
|
+
valid result.
|
|
64
74
|
skills: [github]
|
|
75
|
+
permissions: read
|
|
65
76
|
tools:
|
|
66
77
|
allow:
|
|
67
78
|
- github_list_recent_commits
|
|
68
79
|
- github_search_issues
|
|
80
|
+
- github_list_pr_files
|
|
69
81
|
max_turns: 30
|
|
70
82
|
output:
|
|
71
83
|
type: object
|
|
@@ -199,6 +211,7 @@ nodes:
|
|
|
199
211
|
Copy the counts exactly from the collected facts; never estimate.
|
|
200
212
|
watch_next is at most 3 short items a maintainer should look at next
|
|
201
213
|
week (for example, a risky file that changed without a test).
|
|
214
|
+
permissions: read
|
|
202
215
|
max_turns: 5
|
|
203
216
|
output:
|
|
204
217
|
type: object
|
|
@@ -271,19 +284,27 @@ nodes:
|
|
|
271
284
|
|
|
272
285
|
- issue: first call github_search_issues for an open issue labelled
|
|
273
286
|
sweny-digest whose title is "Weekly digest SINCE to UNTIL". If one
|
|
274
|
-
exists, do not
|
|
275
|
-
|
|
287
|
+
exists, do not request another. Otherwise request it with
|
|
288
|
+
emit_output: type issue, that title, the body, and dedupe_key
|
|
289
|
+
"weekly-digest-SINCE". sweny files it after this step and adds the
|
|
290
|
+
sweny-digest label.
|
|
276
291
|
- slack: call slack_send_message once with the body as mrkdwn.
|
|
277
|
-
- none:
|
|
292
|
+
- none: request nothing; just return the body.
|
|
278
293
|
|
|
279
|
-
|
|
280
|
-
above.
|
|
294
|
+
delivered means the issue was requested or the Slack message was sent.
|
|
281
295
|
skills: [github, slack]
|
|
296
|
+
# Write only for slack_send_message (no safe output covers Slack). The
|
|
297
|
+
# issue is a declared output: capped at one, labelled, title-prefixed.
|
|
298
|
+
permissions: write
|
|
282
299
|
tools:
|
|
283
300
|
allow:
|
|
284
301
|
- github_search_issues
|
|
285
|
-
- github_create_issue
|
|
286
302
|
- slack_send_message
|
|
303
|
+
outputs:
|
|
304
|
+
- type: issue
|
|
305
|
+
max: 1
|
|
306
|
+
title_prefix: "Weekly digest "
|
|
307
|
+
labels: [sweny-digest]
|
|
287
308
|
max_turns: 8
|
|
288
309
|
output:
|
|
289
310
|
type: object
|
|
@@ -307,12 +328,13 @@ nodes:
|
|
|
307
328
|
output_matches:
|
|
308
329
|
- path: destination
|
|
309
330
|
in: [issue, slack, none]
|
|
310
|
-
- name:
|
|
331
|
+
- name: delivery_never_writes_github_directly
|
|
311
332
|
kind: function
|
|
312
333
|
rule:
|
|
313
334
|
no_tool_called:
|
|
314
335
|
- github_create_pr
|
|
315
336
|
- github_add_comment
|
|
337
|
+
- github_create_issue
|
|
316
338
|
|
|
317
339
|
edges:
|
|
318
340
|
- from: collect
|
|
@@ -342,7 +364,8 @@ jobs:
|
|
|
342
364
|
with:
|
|
343
365
|
workflow: .sweny/workflows/weekly-digest.yml
|
|
344
366
|
claude-oauth-token: \${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
|
345
|
-
|
|
367
|
+
input: '{"repo": "\${{ github.repository }}"}'
|
|
368
|
+
# input: '{"repo": "\${{ github.repository }}", "days": 7, "deliver": "slack"}'
|
|
346
369
|
env:
|
|
347
370
|
GITHUB_TOKEN: \${{ github.token }}
|
|
348
371
|
# SLACK_WEBHOOK_URL: \${{ secrets.SLACK_WEBHOOK_URL }}
|
|
@@ -381,6 +404,13 @@ inputs:
|
|
|
381
404
|
enum: [low, medium, high, critical]
|
|
382
405
|
default: medium
|
|
383
406
|
|
|
407
|
+
# Ceiling (#365): every node is read-only. The one issue (or the one comment
|
|
408
|
+
# on it) is a safe output, applied by sweny after file-issue.
|
|
409
|
+
permissions: read
|
|
410
|
+
safe_outputs:
|
|
411
|
+
allow: [issue, comment]
|
|
412
|
+
max: 1
|
|
413
|
+
|
|
384
414
|
nodes:
|
|
385
415
|
inventory:
|
|
386
416
|
name: Read the Lockfiles
|
|
@@ -446,7 +476,7 @@ nodes:
|
|
|
446
476
|
name: Fetch Open Advisories
|
|
447
477
|
instruction: |
|
|
448
478
|
Call github_list_dependabot_alerts for the repo (context.input.repo;
|
|
449
|
-
if empty,
|
|
479
|
+
if empty, the origin remote URL in .git/config).
|
|
450
480
|
|
|
451
481
|
If the tool returns unavailable: true, record available as false and
|
|
452
482
|
return no alerts. Do NOT guess advisories from memory: an advisory you
|
|
@@ -589,24 +619,31 @@ nodes:
|
|
|
589
619
|
Always start with github_search_issues for an open issue labelled
|
|
590
620
|
sweny-drift (query: "is:issue is:open label:sweny-drift").
|
|
591
621
|
|
|
592
|
-
- If action is "none":
|
|
622
|
+
- If action is "none": request nothing. Return result "none".
|
|
593
623
|
- If an open sweny-drift issue exists and lists the same advisory ids
|
|
594
624
|
and drift paths: change nothing. Return result "unchanged".
|
|
595
|
-
- If it exists but the set changed:
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
625
|
+
- If it exists but the set changed: request one comment on that issue
|
|
626
|
+
(emit_output type comment, number = the issue number) with only what
|
|
627
|
+
is new. Return result "updated".
|
|
628
|
+
- If none exists: request one issue (emit_output type issue) titled
|
|
629
|
+
"Dependency drift: N actionable". The body has a table (package,
|
|
630
|
+
severity, advisory id, why it matters, fix), the drift paths with
|
|
631
|
+
reasons, and the deferred count. Return "created". sweny files it
|
|
632
|
+
after this step and adds the sweny-drift label.
|
|
633
|
+
|
|
634
|
+
You cannot write to GitHub directly here: requests are applied after
|
|
635
|
+
this step, at most one per run.
|
|
604
636
|
skills: [github]
|
|
605
637
|
tools:
|
|
606
638
|
allow:
|
|
607
639
|
- github_search_issues
|
|
608
|
-
|
|
609
|
-
|
|
640
|
+
outputs:
|
|
641
|
+
- type: issue
|
|
642
|
+
max: 1
|
|
643
|
+
title_prefix: "Dependency drift: "
|
|
644
|
+
labels: [sweny-drift]
|
|
645
|
+
- type: comment
|
|
646
|
+
max: 1
|
|
610
647
|
max_turns: 8
|
|
611
648
|
output:
|
|
612
649
|
type: object
|
|
@@ -627,11 +664,13 @@ nodes:
|
|
|
627
664
|
rule:
|
|
628
665
|
any_tool_called:
|
|
629
666
|
- github_search_issues
|
|
630
|
-
- name:
|
|
667
|
+
- name: no_direct_github_writes
|
|
631
668
|
kind: function
|
|
632
669
|
rule:
|
|
633
670
|
no_tool_called:
|
|
634
671
|
- github_create_pr
|
|
672
|
+
- github_create_issue
|
|
673
|
+
- github_add_comment
|
|
635
674
|
- name: result_is_valid
|
|
636
675
|
kind: value
|
|
637
676
|
rule:
|
|
@@ -669,6 +708,7 @@ jobs:
|
|
|
669
708
|
with:
|
|
670
709
|
workflow: .sweny/workflows/dependency-drift.yml
|
|
671
710
|
claude-oauth-token: \${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
|
711
|
+
input: '{"repo": "\${{ github.repository }}"}'
|
|
672
712
|
env:
|
|
673
713
|
# The built-in token cannot read Dependabot alerts. Use a fine-grained
|
|
674
714
|
# token with Dependabot alerts: read, Issues: write, Contents: read.
|
|
@@ -703,6 +743,14 @@ inputs:
|
|
|
703
743
|
description: Pull request number. The Action snippet passes github.event.pull_request.number.
|
|
704
744
|
default: 0
|
|
705
745
|
|
|
746
|
+
# Ceiling (#365): every node is read-only. The review comment is a safe
|
|
747
|
+
# output pinned to context.input.pr_number, applied by sweny after
|
|
748
|
+
# post-comment.
|
|
749
|
+
permissions: read
|
|
750
|
+
safe_outputs:
|
|
751
|
+
allow: [comment]
|
|
752
|
+
max: 1
|
|
753
|
+
|
|
706
754
|
nodes:
|
|
707
755
|
scope:
|
|
708
756
|
name: Measure the Change
|
|
@@ -876,9 +924,9 @@ nodes:
|
|
|
876
924
|
post-comment:
|
|
877
925
|
name: Post the Review Comment
|
|
878
926
|
instruction: |
|
|
879
|
-
|
|
880
|
-
(
|
|
881
|
-
|
|
927
|
+
Request exactly one comment on the pull request with emit_output
|
|
928
|
+
(type comment). It can only land on context.input.pr_number; sweny
|
|
929
|
+
posts it after this step.
|
|
882
930
|
|
|
883
931
|
Start the body with the line: <!-- sweny-pr-risk -->
|
|
884
932
|
Then: "Risk: LEVEL" with the size, then the reasons as short bullets
|
|
@@ -886,34 +934,36 @@ nodes:
|
|
|
886
934
|
"Read-only scope review. No code was changed."
|
|
887
935
|
Keep it under 15 lines. No emoji walls, no praise.
|
|
888
936
|
|
|
889
|
-
This is the only step
|
|
937
|
+
This is the only step that asks for a write, and it may only comment.
|
|
890
938
|
skills: [github]
|
|
891
939
|
tools:
|
|
892
940
|
allow:
|
|
893
|
-
-
|
|
941
|
+
- github_get_issue
|
|
942
|
+
outputs:
|
|
943
|
+
- type: comment
|
|
944
|
+
max: 1
|
|
945
|
+
number: { input: pr_number }
|
|
894
946
|
max_turns: 4
|
|
895
947
|
output:
|
|
896
948
|
type: object
|
|
897
949
|
properties:
|
|
898
|
-
|
|
950
|
+
requested:
|
|
899
951
|
type: boolean
|
|
900
|
-
comment_url:
|
|
901
|
-
type: string
|
|
902
952
|
risk_level:
|
|
903
953
|
type: string
|
|
904
954
|
enum: [low, medium, high]
|
|
905
|
-
required: [
|
|
955
|
+
required: [requested, risk_level]
|
|
906
956
|
eval:
|
|
907
|
-
- name:
|
|
957
|
+
- name: comment_was_requested
|
|
908
958
|
kind: function
|
|
909
959
|
rule:
|
|
910
960
|
all_tools_called:
|
|
911
|
-
-
|
|
961
|
+
- emit_output
|
|
912
962
|
- name: comment_result
|
|
913
963
|
kind: value
|
|
914
964
|
rule:
|
|
915
965
|
output_matches:
|
|
916
|
-
- path:
|
|
966
|
+
- path: requested
|
|
917
967
|
equals: true
|
|
918
968
|
- path: risk_level
|
|
919
969
|
in: [low, medium, high]
|
|
@@ -974,7 +1024,7 @@ export const PACK_TEMPLATES = [
|
|
|
974
1024
|
trigger: DIGEST_TRIGGER,
|
|
975
1025
|
sample: DIGEST_SAMPLE,
|
|
976
1026
|
tokens: "15k to 40k per run (estimate)",
|
|
977
|
-
writes: { publish: ["
|
|
1027
|
+
writes: { publish: ["slack_send_message"] },
|
|
978
1028
|
},
|
|
979
1029
|
},
|
|
980
1030
|
{
|
|
@@ -986,7 +1036,7 @@ export const PACK_TEMPLATES = [
|
|
|
986
1036
|
trigger: DRIFT_TRIGGER,
|
|
987
1037
|
sample: DRIFT_SAMPLE,
|
|
988
1038
|
tokens: "20k to 60k per run (estimate)",
|
|
989
|
-
writes: {
|
|
1039
|
+
writes: {},
|
|
990
1040
|
},
|
|
991
1041
|
},
|
|
992
1042
|
{
|
|
@@ -998,7 +1048,7 @@ export const PACK_TEMPLATES = [
|
|
|
998
1048
|
trigger: RISK_TRIGGER,
|
|
999
1049
|
sample: RISK_SAMPLE,
|
|
1000
1050
|
tokens: "10k to 30k per run (estimate)",
|
|
1001
|
-
writes: {
|
|
1051
|
+
writes: {},
|
|
1002
1052
|
},
|
|
1003
1053
|
},
|
|
1004
1054
|
];
|
package/dist/executor.js
CHANGED
|
@@ -80,6 +80,8 @@ export async function execute(workflow, input, options) {
|
|
|
80
80
|
const runEnv = options.env ?? process.env;
|
|
81
81
|
const usesOutputs = Object.values(workflow.nodes).some((n) => (n.outputs?.length ?? 0) > 0);
|
|
82
82
|
const actor = usesOutputs ? resolveActor(runEnv, options.actor) : {};
|
|
83
|
+
// The run input, for `number: { input }` issue pins on outputs.
|
|
84
|
+
const runInput = input && typeof input === "object" ? input : undefined;
|
|
83
85
|
// Build an eval-time alias table from the loaded skills. Each skill owns
|
|
84
86
|
// its own mapping between skill-tool names and equivalent MCP names. Core
|
|
85
87
|
// stays vendor-neutral; this call just unions what the skills declare.
|
|
@@ -257,7 +259,9 @@ export async function execute(workflow, input, options) {
|
|
|
257
259
|
const effectiveRules = assembleCascaded("rules", currentId, workflow.nodes[currentId], inputRules.length, (workflow.rules ?? []).length, resolvedSources);
|
|
258
260
|
const effectiveContext = assembleCascaded("context", currentId, workflow.nodes[currentId], inputContext.length, (workflow.context ?? []).length, resolvedSources);
|
|
259
261
|
const baseInstruction = buildNodeInstruction(resolvedInstruction, effectiveRules, effectiveContext, input, skillInstructions);
|
|
260
|
-
const nodeInstruction = outputDecls.length > 0
|
|
262
|
+
const nodeInstruction = outputDecls.length > 0
|
|
263
|
+
? `${baseInstruction}\n\n${safeOutputsInstruction(outputDecls, runInput)}`
|
|
264
|
+
: baseInstruction;
|
|
261
265
|
const instruction = dryRun ? `${dryRunNotice(skippedWrites)}\n\n---\n\n${nodeInstruction}` : nodeInstruction;
|
|
262
266
|
// Run Claude on this node, with optional eval-failure retry loop.
|
|
263
267
|
let attempt = 0;
|
|
@@ -462,6 +466,7 @@ export async function execute(workflow, input, options) {
|
|
|
462
466
|
staged: stageOutputs,
|
|
463
467
|
state: writeState,
|
|
464
468
|
logger,
|
|
469
|
+
input: runInput,
|
|
465
470
|
screen: async (writes) => claude.ask({
|
|
466
471
|
instruction: SAFE_OUTPUT_SCREEN_INSTRUCTION,
|
|
467
472
|
context: { writes },
|
|
@@ -610,10 +615,17 @@ function warnOnJudgeBudget(workflow, logger) {
|
|
|
610
615
|
* including when no evaluator ran (the trusted namespace is then empty).
|
|
611
616
|
*/
|
|
612
617
|
function buildPriorNodeContext(result) {
|
|
613
|
-
const data = (result.data ?? {});
|
|
618
|
+
const { safe_outputs: _forged, ...data } = (result.data ?? {});
|
|
614
619
|
const evals = result.evals ?? [];
|
|
615
620
|
const evalsByName = Object.fromEntries(evals.map((e) => [e.name, e]));
|
|
616
|
-
|
|
621
|
+
// Safe-output receipts (#365) are runtime facts, like evals: what sweny
|
|
622
|
+
// actually wrote (type, status, ref, url). A downstream node reads the new
|
|
623
|
+
// issue's identifier here. Agent data can never shadow them.
|
|
624
|
+
return {
|
|
625
|
+
...data,
|
|
626
|
+
evals: evalsByName,
|
|
627
|
+
...(result.outputs && result.outputs.length > 0 ? { safe_outputs: result.outputs } : {}),
|
|
628
|
+
};
|
|
617
629
|
}
|
|
618
630
|
/**
|
|
619
631
|
* Build the prior-node entry shown to the LLM route evaluator.
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Newline-delimited JSON-RPC 2.0 over a child process's stdio: the ACP stdio
|
|
3
|
+
* transport (agentclientprotocol.com/protocol/v1/transports, schema v1.24.1).
|
|
4
|
+
*
|
|
5
|
+
* - The client launches the agent as a subprocess and writes one JSON-RPC
|
|
6
|
+
* message per line to its stdin; the agent answers on stdout.
|
|
7
|
+
* - Messages MUST NOT contain embedded newlines (`JSON.stringify` never emits
|
|
8
|
+
* one).
|
|
9
|
+
* - The agent MAY log on stderr. It MUST NOT write anything but ACP messages
|
|
10
|
+
* to stdout; real agents sometimes do, so a stdout line that is not JSON is
|
|
11
|
+
* logged and skipped instead of ending the run.
|
|
12
|
+
*
|
|
13
|
+
* Hand-rolled on purpose: the wire is four message shapes, so sweny takes no
|
|
14
|
+
* SDK dependency for it, and the contract fake speaks the raw wire, which means
|
|
15
|
+
* the suite checks the protocol itself and not an SDK's reading of it.
|
|
16
|
+
*
|
|
17
|
+
* No agent SDK imports.
|
|
18
|
+
*/
|
|
19
|
+
import type { ChildProcess } from "node:child_process";
|
|
20
|
+
/** JSON-RPC / ACP error codes (schema `ErrorCode`). */
|
|
21
|
+
export declare const RPC_ERROR: {
|
|
22
|
+
readonly parse: -32700;
|
|
23
|
+
readonly invalidRequest: -32600;
|
|
24
|
+
readonly methodNotFound: -32601;
|
|
25
|
+
readonly invalidParams: -32602;
|
|
26
|
+
readonly internal: -32603;
|
|
27
|
+
readonly requestCancelled: -32800;
|
|
28
|
+
readonly authRequired: -32000;
|
|
29
|
+
readonly resourceNotFound: -32002;
|
|
30
|
+
};
|
|
31
|
+
/** An error response from the peer (or a local failure reaching it). */
|
|
32
|
+
export declare class RpcError extends Error {
|
|
33
|
+
readonly code: number;
|
|
34
|
+
readonly data?: unknown | undefined;
|
|
35
|
+
constructor(message: string, code: number, data?: unknown | undefined);
|
|
36
|
+
}
|
|
37
|
+
export interface RpcHandlers {
|
|
38
|
+
/** A request from the peer. Resolve with the `result`, or throw {@link RpcError} for an error response. */
|
|
39
|
+
onRequest(method: string, params: unknown): Promise<unknown>;
|
|
40
|
+
/** A notification from the peer. */
|
|
41
|
+
onNotification(method: string, params: unknown): void;
|
|
42
|
+
/** A stdout line that was not a JSON-RPC message (debug only). */
|
|
43
|
+
onJunk?(line: string): void;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* One JSON-RPC connection. Requests from us are matched to responses by id;
|
|
47
|
+
* requests from the peer are answered through {@link RpcHandlers.onRequest}.
|
|
48
|
+
* When the peer goes away every pending request rejects.
|
|
49
|
+
*/
|
|
50
|
+
export declare class RpcPeer {
|
|
51
|
+
private readonly child;
|
|
52
|
+
private readonly handlers;
|
|
53
|
+
private nextId;
|
|
54
|
+
private pending;
|
|
55
|
+
private buf;
|
|
56
|
+
private ended;
|
|
57
|
+
constructor(child: Pick<ChildProcess, "stdin" | "stdout">, handlers: RpcHandlers);
|
|
58
|
+
private onData;
|
|
59
|
+
private onLine;
|
|
60
|
+
private answer;
|
|
61
|
+
private write;
|
|
62
|
+
/** Send a request and wait for its response. */
|
|
63
|
+
request<T = unknown>(method: string, params: unknown): Promise<T>;
|
|
64
|
+
/** Send a notification (no response). */
|
|
65
|
+
notify(method: string, params: unknown): void;
|
|
66
|
+
/** The peer is gone: reject everything still waiting. Idempotent. */
|
|
67
|
+
end(reason: string): void;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Split a command line into argv: whitespace separates words, single and
|
|
71
|
+
* double quotes group, backslash escapes the next character outside single
|
|
72
|
+
* quotes. No expansion of any kind (no globbing, no variables, no shell), so
|
|
73
|
+
* the string after `acp:` can never run anything but the program it names.
|
|
74
|
+
*/
|
|
75
|
+
export declare function splitCommandLine(input: string): string[];
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Newline-delimited JSON-RPC 2.0 over a child process's stdio: the ACP stdio
|
|
3
|
+
* transport (agentclientprotocol.com/protocol/v1/transports, schema v1.24.1).
|
|
4
|
+
*
|
|
5
|
+
* - The client launches the agent as a subprocess and writes one JSON-RPC
|
|
6
|
+
* message per line to its stdin; the agent answers on stdout.
|
|
7
|
+
* - Messages MUST NOT contain embedded newlines (`JSON.stringify` never emits
|
|
8
|
+
* one).
|
|
9
|
+
* - The agent MAY log on stderr. It MUST NOT write anything but ACP messages
|
|
10
|
+
* to stdout; real agents sometimes do, so a stdout line that is not JSON is
|
|
11
|
+
* logged and skipped instead of ending the run.
|
|
12
|
+
*
|
|
13
|
+
* Hand-rolled on purpose: the wire is four message shapes, so sweny takes no
|
|
14
|
+
* SDK dependency for it, and the contract fake speaks the raw wire, which means
|
|
15
|
+
* the suite checks the protocol itself and not an SDK's reading of it.
|
|
16
|
+
*
|
|
17
|
+
* No agent SDK imports.
|
|
18
|
+
*/
|
|
19
|
+
/** JSON-RPC / ACP error codes (schema `ErrorCode`). */
|
|
20
|
+
export const RPC_ERROR = {
|
|
21
|
+
parse: -32700,
|
|
22
|
+
invalidRequest: -32600,
|
|
23
|
+
methodNotFound: -32601,
|
|
24
|
+
invalidParams: -32602,
|
|
25
|
+
internal: -32603,
|
|
26
|
+
requestCancelled: -32800,
|
|
27
|
+
authRequired: -32000,
|
|
28
|
+
resourceNotFound: -32002,
|
|
29
|
+
};
|
|
30
|
+
/** An error response from the peer (or a local failure reaching it). */
|
|
31
|
+
export class RpcError extends Error {
|
|
32
|
+
code;
|
|
33
|
+
data;
|
|
34
|
+
constructor(message, code, data) {
|
|
35
|
+
super(message);
|
|
36
|
+
this.code = code;
|
|
37
|
+
this.data = data;
|
|
38
|
+
this.name = "RpcError";
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* One JSON-RPC connection. Requests from us are matched to responses by id;
|
|
43
|
+
* requests from the peer are answered through {@link RpcHandlers.onRequest}.
|
|
44
|
+
* When the peer goes away every pending request rejects.
|
|
45
|
+
*/
|
|
46
|
+
export class RpcPeer {
|
|
47
|
+
child;
|
|
48
|
+
handlers;
|
|
49
|
+
nextId = 1;
|
|
50
|
+
pending = new Map();
|
|
51
|
+
buf = "";
|
|
52
|
+
ended;
|
|
53
|
+
constructor(child, handlers) {
|
|
54
|
+
this.child = child;
|
|
55
|
+
this.handlers = handlers;
|
|
56
|
+
child.stdout?.setEncoding("utf8");
|
|
57
|
+
child.stdout?.on("data", (chunk) => this.onData(chunk));
|
|
58
|
+
child.stdin?.on("error", () => {
|
|
59
|
+
// The agent exited before reading; the exit handling reports why.
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
onData(chunk) {
|
|
63
|
+
this.buf += chunk;
|
|
64
|
+
let nl;
|
|
65
|
+
while ((nl = this.buf.indexOf("\n")) !== -1) {
|
|
66
|
+
const line = this.buf.slice(0, nl);
|
|
67
|
+
this.buf = this.buf.slice(nl + 1);
|
|
68
|
+
this.onLine(line);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
onLine(raw) {
|
|
72
|
+
const line = raw.trim();
|
|
73
|
+
if (!line)
|
|
74
|
+
return;
|
|
75
|
+
let msg;
|
|
76
|
+
try {
|
|
77
|
+
const parsed = JSON.parse(line);
|
|
78
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
79
|
+
throw new Error("not an object");
|
|
80
|
+
msg = parsed;
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
this.handlers.onJunk?.(line.length > 200 ? line.slice(0, 199) + "..." : line);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const id = msg.id;
|
|
87
|
+
const hasId = typeof id === "number" || typeof id === "string";
|
|
88
|
+
if (typeof msg.method === "string") {
|
|
89
|
+
if (hasId)
|
|
90
|
+
void this.answer(id, msg.method, msg.params);
|
|
91
|
+
else
|
|
92
|
+
this.handlers.onNotification(msg.method, msg.params);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (!hasId)
|
|
96
|
+
return;
|
|
97
|
+
const p = this.pending.get(id);
|
|
98
|
+
if (!p)
|
|
99
|
+
return;
|
|
100
|
+
this.pending.delete(id);
|
|
101
|
+
const err = msg.error;
|
|
102
|
+
if (err && typeof err === "object") {
|
|
103
|
+
p.reject(new RpcError(typeof err.message === "string" ? err.message : `${p.method} failed`, typeof err.code === "number" ? err.code : RPC_ERROR.internal, err.data));
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
p.resolve(msg.result);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
async answer(id, method, params) {
|
|
110
|
+
try {
|
|
111
|
+
const result = await this.handlers.onRequest(method, params);
|
|
112
|
+
this.write({ jsonrpc: "2.0", id, result: result ?? null });
|
|
113
|
+
}
|
|
114
|
+
catch (err) {
|
|
115
|
+
const e = err instanceof RpcError
|
|
116
|
+
? err
|
|
117
|
+
: new RpcError(err instanceof Error ? err.message : String(err), RPC_ERROR.internal);
|
|
118
|
+
this.write({ jsonrpc: "2.0", id, error: { code: e.code, message: e.message } });
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
write(msg) {
|
|
122
|
+
const stdin = this.child.stdin;
|
|
123
|
+
if (!stdin || stdin.destroyed || !stdin.writable)
|
|
124
|
+
return;
|
|
125
|
+
stdin.write(JSON.stringify(msg) + "\n");
|
|
126
|
+
}
|
|
127
|
+
/** Send a request and wait for its response. */
|
|
128
|
+
request(method, params) {
|
|
129
|
+
if (this.ended !== undefined)
|
|
130
|
+
return Promise.reject(new RpcError(this.ended, RPC_ERROR.internal));
|
|
131
|
+
const id = this.nextId++;
|
|
132
|
+
return new Promise((resolve, reject) => {
|
|
133
|
+
this.pending.set(id, { resolve: (v) => resolve(v), reject, method });
|
|
134
|
+
this.write({ jsonrpc: "2.0", id, method, params });
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
/** Send a notification (no response). */
|
|
138
|
+
notify(method, params) {
|
|
139
|
+
if (this.ended !== undefined)
|
|
140
|
+
return;
|
|
141
|
+
this.write({ jsonrpc: "2.0", method, params });
|
|
142
|
+
}
|
|
143
|
+
/** The peer is gone: reject everything still waiting. Idempotent. */
|
|
144
|
+
end(reason) {
|
|
145
|
+
if (this.ended !== undefined)
|
|
146
|
+
return;
|
|
147
|
+
this.ended = reason;
|
|
148
|
+
// A final line without a newline is complete only if it parses.
|
|
149
|
+
if (this.buf.trim())
|
|
150
|
+
this.onLine(this.buf);
|
|
151
|
+
this.buf = "";
|
|
152
|
+
const pending = [...this.pending.values()];
|
|
153
|
+
this.pending.clear();
|
|
154
|
+
for (const p of pending)
|
|
155
|
+
p.reject(new RpcError(reason, RPC_ERROR.internal));
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Split a command line into argv: whitespace separates words, single and
|
|
160
|
+
* double quotes group, backslash escapes the next character outside single
|
|
161
|
+
* quotes. No expansion of any kind (no globbing, no variables, no shell), so
|
|
162
|
+
* the string after `acp:` can never run anything but the program it names.
|
|
163
|
+
*/
|
|
164
|
+
export function splitCommandLine(input) {
|
|
165
|
+
const out = [];
|
|
166
|
+
let cur = "";
|
|
167
|
+
let started = false;
|
|
168
|
+
let quote = null;
|
|
169
|
+
for (let i = 0; i < input.length; i++) {
|
|
170
|
+
const ch = input[i];
|
|
171
|
+
if (quote) {
|
|
172
|
+
if (ch === quote)
|
|
173
|
+
quote = null;
|
|
174
|
+
else if (ch === "\\" && quote === '"' && i + 1 < input.length)
|
|
175
|
+
cur += input[++i];
|
|
176
|
+
else
|
|
177
|
+
cur += ch;
|
|
178
|
+
}
|
|
179
|
+
else if (ch === '"' || ch === "'") {
|
|
180
|
+
quote = ch;
|
|
181
|
+
started = true;
|
|
182
|
+
}
|
|
183
|
+
else if (ch === "\\" && i + 1 < input.length) {
|
|
184
|
+
cur += input[++i];
|
|
185
|
+
started = true;
|
|
186
|
+
}
|
|
187
|
+
else if (/\s/.test(ch)) {
|
|
188
|
+
if (started) {
|
|
189
|
+
out.push(cur);
|
|
190
|
+
cur = "";
|
|
191
|
+
started = false;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
else {
|
|
195
|
+
cur += ch;
|
|
196
|
+
started = true;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
if (quote)
|
|
200
|
+
throw new Error(`unterminated ${quote} quote in "${input}"`);
|
|
201
|
+
if (started)
|
|
202
|
+
out.push(cur);
|
|
203
|
+
return out;
|
|
204
|
+
}
|