agent-sh 0.11.0 → 0.12.1
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 +13 -6
- package/dist/agent/agent-loop.js +87 -17
- package/dist/agent/conversation-state.d.ts +15 -1
- package/dist/agent/conversation-state.js +65 -14
- package/dist/agent/subagent.d.ts +8 -4
- package/dist/agent/subagent.js +45 -5
- package/dist/agent/tool-protocol.d.ts +5 -5
- package/dist/agent/tool-protocol.js +8 -8
- package/dist/event-bus.d.ts +9 -0
- package/dist/event-bus.js +1 -1
- package/dist/extensions/agent-backend.js +36 -27
- package/dist/extensions/openrouter.js +1 -6
- package/dist/extensions/tui-renderer.js +65 -19
- package/dist/index.js +10 -0
- package/dist/shell/input-handler.js +30 -0
- package/examples/extensions/wire-log.ts +35 -0
- package/package.json +9 -3
- package/examples/extensions/ash-acp-bridge/src/index.ts +0 -574
|
@@ -67,24 +67,6 @@ export default function agentBackend(ctx) {
|
|
|
67
67
|
compositor: ctx.compositor,
|
|
68
68
|
instanceId: ctx.instanceId,
|
|
69
69
|
});
|
|
70
|
-
bus.emit("agent:register-backend", {
|
|
71
|
-
name: "ash",
|
|
72
|
-
kill: () => agentLoop.kill(),
|
|
73
|
-
start: async () => {
|
|
74
|
-
if (!resolved) {
|
|
75
|
-
bus.emit("ui:error", { message: "Agent backend not started — no LLM provider available. See earlier messages." });
|
|
76
|
-
return;
|
|
77
|
-
}
|
|
78
|
-
agentLoop.wire();
|
|
79
|
-
bus.emit("agent:info", {
|
|
80
|
-
name: "ash",
|
|
81
|
-
version: PACKAGE_VERSION,
|
|
82
|
-
model: llmClient.model,
|
|
83
|
-
provider: modes[initialModeIndex]?.provider,
|
|
84
|
-
contextWindow: modes[initialModeIndex]?.contextWindow,
|
|
85
|
-
});
|
|
86
|
-
},
|
|
87
|
-
});
|
|
88
70
|
bus.on("core:extensions-loaded", () => {
|
|
89
71
|
const settings = getSettings();
|
|
90
72
|
// If the user didn't pick a default, fall back to the first registered
|
|
@@ -99,22 +81,49 @@ export default function agentBackend(ctx) {
|
|
|
99
81
|
const effectiveApiKey = config.apiKey ?? activeProvider?.apiKey;
|
|
100
82
|
const effectiveBaseURL = config.baseURL ?? activeProvider?.baseURL;
|
|
101
83
|
const effectiveModel = config.model ?? persistedModelFor(providerName) ?? activeProvider?.defaultModel;
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
if (!effectiveModel) {
|
|
107
|
-
bus.emit("ui:error", { message: "No model specified. Use --model or configure a provider with defaultModel in ~/.agent-sh/settings.json" });
|
|
84
|
+
// No provider → don't register ash at all, so another backend (e.g.
|
|
85
|
+
// claude-code-bridge) can own activation. index.ts hard-fails only
|
|
86
|
+
// when no backend ended up registered.
|
|
87
|
+
if (!effectiveApiKey || !effectiveModel)
|
|
108
88
|
return;
|
|
109
|
-
}
|
|
110
89
|
modes = buildModes();
|
|
111
90
|
if (modes.length === 0)
|
|
112
91
|
modes = [{ model: effectiveModel }];
|
|
113
|
-
|
|
92
|
+
let foundIdx = modes.findIndex((m) => m.model === effectiveModel && (!activeProvider || m.provider === activeProvider.id));
|
|
93
|
+
// Persisted default may not be in the provider's curated list yet (e.g.
|
|
94
|
+
// openrouter's async catalog fetch hasn't returned). Prepend a stub so
|
|
95
|
+
// the initial config:set-modes activeIndex points at the real model —
|
|
96
|
+
// otherwise AgentLoop reconfigures llmClient back to modes[0].
|
|
97
|
+
if (foundIdx === -1 && activeProvider) {
|
|
98
|
+
modes = [
|
|
99
|
+
{
|
|
100
|
+
model: effectiveModel,
|
|
101
|
+
provider: activeProvider.id,
|
|
102
|
+
providerConfig: { apiKey: effectiveApiKey, baseURL: effectiveBaseURL },
|
|
103
|
+
supportsReasoningEffort: activeProvider.supportsReasoningEffort,
|
|
104
|
+
},
|
|
105
|
+
...modes,
|
|
106
|
+
];
|
|
107
|
+
foundIdx = 0;
|
|
108
|
+
}
|
|
109
|
+
initialModeIndex = Math.max(0, foundIdx);
|
|
114
110
|
llmClient.reconfigure({ apiKey: effectiveApiKey, baseURL: effectiveBaseURL, model: effectiveModel });
|
|
115
111
|
bus.emit("config:set-modes", { modes, activeIndex: initialModeIndex });
|
|
116
112
|
resolved = true;
|
|
117
|
-
|
|
113
|
+
bus.emit("agent:register-backend", {
|
|
114
|
+
name: "ash",
|
|
115
|
+
kill: () => agentLoop.kill(),
|
|
116
|
+
start: async () => {
|
|
117
|
+
agentLoop.wire();
|
|
118
|
+
bus.emit("agent:info", {
|
|
119
|
+
name: "ash",
|
|
120
|
+
version: PACKAGE_VERSION,
|
|
121
|
+
model: llmClient.model,
|
|
122
|
+
provider: modes[initialModeIndex]?.provider,
|
|
123
|
+
contextWindow: modes[initialModeIndex]?.contextWindow,
|
|
124
|
+
});
|
|
125
|
+
},
|
|
126
|
+
});
|
|
118
127
|
});
|
|
119
128
|
bus.on("provider:register", (p) => {
|
|
120
129
|
const rawModels = p.models ?? (p.defaultModel ? [p.defaultModel] : []);
|
|
@@ -1,10 +1,5 @@
|
|
|
1
1
|
const BASE_URL = "https://openrouter.ai/api/v1";
|
|
2
|
-
|
|
3
|
-
// get a surprise bill. Persisted /model selection overrides this.
|
|
4
|
-
const DEFAULT_MODELS = [
|
|
5
|
-
"deepseek/deepseek-v3.2",
|
|
6
|
-
"anthropic/claude-sonnet-4.6",
|
|
7
|
-
];
|
|
2
|
+
const DEFAULT_MODELS = ["anthropic/claude-sonnet-4.6"];
|
|
8
3
|
export default function activate(ctx) {
|
|
9
4
|
const apiKey = process.env.OPENROUTER_API_KEY;
|
|
10
5
|
if (!apiKey)
|
|
@@ -50,6 +50,7 @@ function createRenderState() {
|
|
|
50
50
|
spinnerStartTime: 0,
|
|
51
51
|
openTool: null,
|
|
52
52
|
pendingToolCompletes: new Map(),
|
|
53
|
+
orphanContHeaderKind: undefined,
|
|
53
54
|
currentToolKind: undefined,
|
|
54
55
|
toolStartTime: 0,
|
|
55
56
|
toolExitCode: null,
|
|
@@ -178,11 +179,8 @@ export default function activate(ctx) {
|
|
|
178
179
|
stopCurrentSpinner();
|
|
179
180
|
if (!s.renderer)
|
|
180
181
|
startAgentResponse();
|
|
181
|
-
s.renderer.writeLine(`${p.dim}Thinking (ctrl+t to collapse)${p.reset}`);
|
|
182
|
-
drain();
|
|
183
182
|
}
|
|
184
183
|
else {
|
|
185
|
-
// Restart spinner with ctrl+t hint now that we know thinking is available
|
|
186
184
|
startThinkingSpinner();
|
|
187
185
|
}
|
|
188
186
|
}
|
|
@@ -256,6 +254,7 @@ export default function activate(ctx) {
|
|
|
256
254
|
return;
|
|
257
255
|
fencedTransform.flush();
|
|
258
256
|
finalizeToolGroup();
|
|
257
|
+
s.orphanContHeaderKind = undefined;
|
|
259
258
|
batchGroups = new Map();
|
|
260
259
|
for (const group of e.groups) {
|
|
261
260
|
batchGroups.set(group.kind, {
|
|
@@ -272,6 +271,7 @@ export default function activate(ctx) {
|
|
|
272
271
|
stopCurrentSpinner();
|
|
273
272
|
s.currentToolKind = e.kind;
|
|
274
273
|
s.toolStartTime = Date.now();
|
|
274
|
+
s.orphanContHeaderKind = undefined;
|
|
275
275
|
if (e.title === "user_shell") {
|
|
276
276
|
finalizeToolGroup();
|
|
277
277
|
closeToolLine();
|
|
@@ -315,10 +315,12 @@ export default function activate(ctx) {
|
|
|
315
315
|
showToolCall(e.title, "", { ...e, groupContinuation: true });
|
|
316
316
|
s.toolGroupRendered++;
|
|
317
317
|
}
|
|
318
|
-
// Record identity so late completes (after a premature finalize
|
|
319
|
-
// from a cross-kind standalone start) can render as labeled ⎿ lines.
|
|
320
318
|
if (e.toolCallId) {
|
|
321
|
-
s.pendingToolCompletes.set(e.toolCallId, {
|
|
319
|
+
s.pendingToolCompletes.set(e.toolCallId, {
|
|
320
|
+
title: e.title,
|
|
321
|
+
kind,
|
|
322
|
+
displayDetail: e.displayDetail ?? extractDetail(e),
|
|
323
|
+
});
|
|
322
324
|
}
|
|
323
325
|
}
|
|
324
326
|
else {
|
|
@@ -342,13 +344,25 @@ export default function activate(ctx) {
|
|
|
342
344
|
s.pendingToolCompletes.delete(e.toolCallId);
|
|
343
345
|
s.toolGroupCompletedCount++;
|
|
344
346
|
s.currentToolKind = undefined;
|
|
347
|
+
// Finalize as soon as all members return so aggregate lands right
|
|
348
|
+
// after its children, not below out-of-band renders from the next tool.
|
|
349
|
+
const batchGroup = batchGroups.get(s.toolGroupKind);
|
|
350
|
+
if (batchGroup && s.toolGroupCompletedCount >= batchGroup.total) {
|
|
351
|
+
finalizeToolGroup();
|
|
352
|
+
}
|
|
345
353
|
}
|
|
346
354
|
else {
|
|
347
|
-
//
|
|
355
|
+
// Tools that lost the inline slot render as a labeled ⎿. Orphans
|
|
356
|
+
// (group finalized before they returned) reroute via showOrphanedComplete.
|
|
348
357
|
const pending = e.toolCallId ? s.pendingToolCompletes.get(e.toolCallId) : undefined;
|
|
349
358
|
if (pending)
|
|
350
359
|
s.pendingToolCompletes.delete(e.toolCallId);
|
|
351
|
-
|
|
360
|
+
if (pending?.orphaned) {
|
|
361
|
+
showOrphanedComplete(e.exitCode, e.resultDisplay, pending.title, pending.kind, pending.displayDetail);
|
|
362
|
+
}
|
|
363
|
+
else {
|
|
364
|
+
showToolComplete(e.exitCode, e.resultDisplay, pending?.displayDetail ?? pending?.title);
|
|
365
|
+
}
|
|
352
366
|
s.currentToolKind = undefined;
|
|
353
367
|
s.spinnerStartTime = 0;
|
|
354
368
|
startThinkingSpinner();
|
|
@@ -746,8 +760,14 @@ export default function activate(ctx) {
|
|
|
746
760
|
}
|
|
747
761
|
else {
|
|
748
762
|
out().write(` ${batchPrefix}${lines[lines.length - 1]}`);
|
|
749
|
-
if (extra?.toolCallId)
|
|
750
|
-
s.openTool = {
|
|
763
|
+
if (extra?.toolCallId) {
|
|
764
|
+
s.openTool = {
|
|
765
|
+
callId: extra.toolCallId,
|
|
766
|
+
title,
|
|
767
|
+
kind: extra.kind,
|
|
768
|
+
displayDetail: extra.displayDetail ?? extractDetail(extra),
|
|
769
|
+
};
|
|
770
|
+
}
|
|
751
771
|
}
|
|
752
772
|
}
|
|
753
773
|
s.hadToolCalls = true;
|
|
@@ -775,6 +795,26 @@ export default function activate(ctx) {
|
|
|
775
795
|
if (resultDisplay?.body)
|
|
776
796
|
renderResultBody(resultDisplay.body);
|
|
777
797
|
}
|
|
798
|
+
/** Late completion from a finalized group — re-emit the kind header
|
|
799
|
+
* in muted "(cont.)" form so the ⎿ has a legitimate parent, then
|
|
800
|
+
* render the completion as a normal labeled ⎿. Subsequent orphans
|
|
801
|
+
* of the same kind reuse the existing (cont.) header. */
|
|
802
|
+
function showOrphanedComplete(exitCode, resultDisplay, title, kind, displayDetail) {
|
|
803
|
+
if (s.orphanContHeaderKind !== kind) {
|
|
804
|
+
stopCurrentSpinner();
|
|
805
|
+
closeToolLine();
|
|
806
|
+
flushCommandOutput();
|
|
807
|
+
if (!s.renderer)
|
|
808
|
+
startAgentResponse();
|
|
809
|
+
showCollapsedThinking();
|
|
810
|
+
const icon = (kind && KIND_ICONS[kind]) ?? "▶";
|
|
811
|
+
const label = kind ?? "tool";
|
|
812
|
+
s.renderer.writeLine(`${p.muted}${icon} ${label} (cont.)${p.reset}`);
|
|
813
|
+
drain();
|
|
814
|
+
s.orphanContHeaderKind = kind;
|
|
815
|
+
}
|
|
816
|
+
showToolComplete(exitCode, resultDisplay, displayDetail || title);
|
|
817
|
+
}
|
|
778
818
|
function renderResultBody(body) {
|
|
779
819
|
if (!s.renderer)
|
|
780
820
|
return;
|
|
@@ -796,10 +836,7 @@ export default function activate(ctx) {
|
|
|
796
836
|
stopCurrentSpinner();
|
|
797
837
|
const thinking = hasThinkingMode();
|
|
798
838
|
s.spinnerLabel = thinking ? "Thinking" : "Working";
|
|
799
|
-
|
|
800
|
-
? (s.showThinkingText ? "(ctrl+t to collapse)" : "(ctrl+t to expand)")
|
|
801
|
-
: "";
|
|
802
|
-
s.spinnerOpts = { hint: hint || undefined, startTime: s.spinnerStartTime };
|
|
839
|
+
s.spinnerOpts = { startTime: s.spinnerStartTime };
|
|
803
840
|
s.spinner = createSpinner({ startTime: s.spinnerStartTime });
|
|
804
841
|
s.spinnerInterval = setInterval(() => {
|
|
805
842
|
if (s.spinner) {
|
|
@@ -825,13 +862,25 @@ export default function activate(ctx) {
|
|
|
825
862
|
if (s.openTool) {
|
|
826
863
|
out().write("\n");
|
|
827
864
|
// Stash identity so the completion renders as ⎿ labeled, not orphan ✓.
|
|
828
|
-
s.pendingToolCompletes.set(s.openTool.callId, {
|
|
865
|
+
s.pendingToolCompletes.set(s.openTool.callId, {
|
|
866
|
+
title: s.openTool.title,
|
|
867
|
+
kind: s.openTool.kind,
|
|
868
|
+
displayDetail: s.openTool.displayDetail,
|
|
869
|
+
});
|
|
829
870
|
s.openTool = null;
|
|
830
871
|
}
|
|
831
872
|
}
|
|
832
873
|
/** Render the group aggregate ⎿ line, or skip if no members have
|
|
833
874
|
* completed yet (late completes will render individually as ⎿ labeled). */
|
|
834
875
|
function finalizeToolGroup() {
|
|
876
|
+
// Late completes from this group have lost their inline slot; mark
|
|
877
|
+
// them so showOrphanedComplete re-emits a (cont.) header for their ⎿.
|
|
878
|
+
if (s.toolGroupKind) {
|
|
879
|
+
for (const pending of s.pendingToolCompletes.values()) {
|
|
880
|
+
if (pending.kind === s.toolGroupKind)
|
|
881
|
+
pending.orphaned = true;
|
|
882
|
+
}
|
|
883
|
+
}
|
|
835
884
|
const skipAggregate = s.toolGroupCount > 1 && s.toolGroupCompletedCount === 0;
|
|
836
885
|
if (s.toolGroupCount <= 1 || skipAggregate) {
|
|
837
886
|
s.toolGroupKind = undefined;
|
|
@@ -842,6 +891,7 @@ export default function activate(ctx) {
|
|
|
842
891
|
s.toolGroupSummaries = [];
|
|
843
892
|
return;
|
|
844
893
|
}
|
|
894
|
+
stopCurrentSpinner();
|
|
845
895
|
closeToolLine();
|
|
846
896
|
if (!s.renderer)
|
|
847
897
|
startAgentResponse();
|
|
@@ -938,14 +988,10 @@ export default function activate(ctx) {
|
|
|
938
988
|
if (s.spinner) {
|
|
939
989
|
stopCurrentSpinner();
|
|
940
990
|
if (s.showThinkingText) {
|
|
941
|
-
// Expanding: replace spinner with thinking text header
|
|
942
991
|
if (!s.renderer)
|
|
943
992
|
startAgentResponse();
|
|
944
|
-
s.renderer.writeLine(`${p.dim}Thinking (ctrl+t to collapse)${p.reset}`);
|
|
945
|
-
drain();
|
|
946
993
|
}
|
|
947
994
|
else {
|
|
948
|
-
// Collapsing: restart spinner with updated hint
|
|
949
995
|
startThinkingSpinner();
|
|
950
996
|
}
|
|
951
997
|
return;
|
package/dist/index.js
CHANGED
|
@@ -270,6 +270,16 @@ async function main() {
|
|
|
270
270
|
// ── Activate agent backend ────────────────────────────────────
|
|
271
271
|
// Extensions had their chance to register via agent:register-backend.
|
|
272
272
|
// If none did, the built-in AgentLoop gets wired to bus events.
|
|
273
|
+
const { names: backendNames } = core.bus.emitPipe("config:get-backends", { names: [], active: null });
|
|
274
|
+
if (backendNames.length === 0) {
|
|
275
|
+
shell.kill();
|
|
276
|
+
console.error("\nagent-sh: no agent backend available.\n\n" +
|
|
277
|
+
" Export OPENROUTER_API_KEY or OPENAI_API_KEY for zero-config launch, or\n" +
|
|
278
|
+
" pass --api-key on the command line, or\n" +
|
|
279
|
+
" run `agent-sh init` for a settings.json template.\n" +
|
|
280
|
+
" Alternatively, install a bridge extension (claude-code-bridge, pi-bridge).\n");
|
|
281
|
+
process.exit(1);
|
|
282
|
+
}
|
|
273
283
|
core.activateBackend();
|
|
274
284
|
// ── Startup banner ───────────────────────────────────────────
|
|
275
285
|
const settings = getSettings();
|
|
@@ -253,6 +253,36 @@ export class InputHandler {
|
|
|
253
253
|
seq += data[i];
|
|
254
254
|
}
|
|
255
255
|
}
|
|
256
|
+
else if (next === "]" || next === "P" || next === "_" || next === "^") {
|
|
257
|
+
// String sequences terminated by BEL or ST (ESC \):
|
|
258
|
+
// OSC (ESC ]) — OSC 10/11 color-query responses
|
|
259
|
+
// DCS (ESC P) — tmux XTVERSION query response (iTerm2 etc.)
|
|
260
|
+
// APC (ESC _), PM (ESC ^) — rarer, same termination
|
|
261
|
+
// Forward as a unit so the payload doesn't leak into lineBuffer
|
|
262
|
+
// and onto the bash command line after a foreground app exits.
|
|
263
|
+
let j = i + 2;
|
|
264
|
+
let termEnd = -1;
|
|
265
|
+
while (j < data.length) {
|
|
266
|
+
const c = data[j];
|
|
267
|
+
if (c === "\x07") {
|
|
268
|
+
termEnd = j;
|
|
269
|
+
break;
|
|
270
|
+
}
|
|
271
|
+
if (c === "\x1b" && j + 1 < data.length && data[j + 1] === "\\") {
|
|
272
|
+
termEnd = j + 1;
|
|
273
|
+
break;
|
|
274
|
+
}
|
|
275
|
+
j++;
|
|
276
|
+
}
|
|
277
|
+
if (termEnd !== -1) {
|
|
278
|
+
seq = data.slice(i, termEnd + 1);
|
|
279
|
+
i = termEnd;
|
|
280
|
+
}
|
|
281
|
+
else {
|
|
282
|
+
seq += next;
|
|
283
|
+
i++;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
256
286
|
else {
|
|
257
287
|
// ESC + single char (alt-key, etc.)
|
|
258
288
|
seq += next;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dumps every LLM request + streamed chunk to $AGENT_SH_WIRE_DIR
|
|
3
|
+
* (default ~/.agent-sh/wire) for offline replay via curl. Paired files
|
|
4
|
+
* per turn: <stamp>.request.json and <stamp>.chunks.jsonl.
|
|
5
|
+
*/
|
|
6
|
+
import * as fs from "node:fs";
|
|
7
|
+
import * as path from "node:path";
|
|
8
|
+
import * as os from "node:os";
|
|
9
|
+
import type { ExtensionContext } from "agent-sh/types";
|
|
10
|
+
|
|
11
|
+
export default function activate(ctx: ExtensionContext): void {
|
|
12
|
+
const dir = process.env.AGENT_SH_WIRE_DIR
|
|
13
|
+
?? path.join(os.homedir(), ".agent-sh", "wire");
|
|
14
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
15
|
+
|
|
16
|
+
// llm:chunk has no back-pointer to its request, so anchor both on
|
|
17
|
+
// the timestamp set when llm:request fires.
|
|
18
|
+
let currentStamp: string | null = null;
|
|
19
|
+
|
|
20
|
+
ctx.bus.on("llm:request", (req) => {
|
|
21
|
+
currentStamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
22
|
+
fs.writeFileSync(
|
|
23
|
+
path.join(dir, `${currentStamp}.request.json`),
|
|
24
|
+
JSON.stringify(req, null, 2),
|
|
25
|
+
);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
ctx.bus.on("llm:chunk", ({ chunk }) => {
|
|
29
|
+
if (!currentStamp) return;
|
|
30
|
+
fs.appendFileSync(
|
|
31
|
+
path.join(dir, `${currentStamp}.chunks.jsonl`),
|
|
32
|
+
JSON.stringify(chunk) + "\n",
|
|
33
|
+
);
|
|
34
|
+
});
|
|
35
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-sh",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.1",
|
|
4
4
|
"description": "A shell-first terminal where AI is one keystroke away",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/core.js",
|
|
@@ -89,13 +89,19 @@
|
|
|
89
89
|
},
|
|
90
90
|
"files": [
|
|
91
91
|
"dist",
|
|
92
|
-
"examples"
|
|
92
|
+
"examples/extensions/*.ts",
|
|
93
|
+
"examples/extensions/*/package.json",
|
|
94
|
+
"examples/extensions/*/tsconfig.json",
|
|
95
|
+
"examples/extensions/*/README.md",
|
|
96
|
+
"examples/extensions/*/src",
|
|
97
|
+
"examples/extensions/*/index.ts",
|
|
98
|
+
"examples/extensions/*/index.js"
|
|
93
99
|
],
|
|
94
100
|
"scripts": {
|
|
95
101
|
"dev": "tsx src/index.ts",
|
|
96
102
|
"build": "tsc",
|
|
97
103
|
"start": "node dist/index.js",
|
|
98
|
-
"
|
|
104
|
+
"prepare": "npm run build"
|
|
99
105
|
},
|
|
100
106
|
"keywords": [
|
|
101
107
|
"terminal",
|