opencode-task 0.1.1 → 0.1.4

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.
Files changed (2) hide show
  1. package/dist/index.js +576 -5
  2. package/package.json +2 -1
package/dist/index.js CHANGED
@@ -12329,22 +12329,593 @@ function tool(input) {
12329
12329
  return input;
12330
12330
  }
12331
12331
  tool.schema = exports_external;
12332
+ // utils.ts
12333
+ import { appendFile } from "node:fs/promises";
12334
+ var AGENT_CONFIGS = {
12335
+ Explore: {
12336
+ description: "Fast agent specialized for exploring codebases. Use when you need to quickly find files by patterns, search code for keywords, or answer questions about the codebase.",
12337
+ prompt: "You are an exploration specialist. Search thoroughly using Glob, Grep, and Read. Report findings concisely. Do not modify any files.",
12338
+ tools: [
12339
+ "Glob",
12340
+ "Grep",
12341
+ "Read",
12342
+ "Bash",
12343
+ "LSP",
12344
+ "WebFetch",
12345
+ "WebSearch",
12346
+ "AskUserQuestion"
12347
+ ]
12348
+ },
12349
+ Plan: {
12350
+ description: "Software architect agent for designing implementation plans. Use when you need to plan implementation strategy. Returns step-by-step plans, identifies critical files, considers architectural trade-offs.",
12351
+ prompt: "You are a software architect. Analyze requirements, explore the codebase thoroughly, and design clear implementation plans with specific steps. Do not modify any files.",
12352
+ tools: [
12353
+ "Glob",
12354
+ "Grep",
12355
+ "Read",
12356
+ "Bash",
12357
+ "LSP",
12358
+ "WebFetch",
12359
+ "WebSearch",
12360
+ "AskUserQuestion"
12361
+ ]
12362
+ },
12363
+ "general-purpose": {
12364
+ description: "General-purpose agent for researching complex questions, searching for code, and executing multi-step tasks. Use for heavy lifting when simpler agents are insufficient.",
12365
+ prompt: "You are a general-purpose assistant. Complete the assigned task thoroughly and report results.",
12366
+ tools: null
12367
+ },
12368
+ "claude-code-guide": {
12369
+ description: "Expert on Claude Code CLI features, hooks, slash commands, MCP servers, settings, IDE integrations. Also covers Claude Agent SDK and Claude API usage.",
12370
+ prompt: "You are a Claude Code expert. Answer questions about Claude Code features, Agent SDK, and API usage accurately. Search documentation and web resources as needed.",
12371
+ tools: ["Glob", "Grep", "Read", "WebFetch", "WebSearch"]
12372
+ },
12373
+ "web-search": {
12374
+ description: "Web researcher for external information not in the codebase. Use for: library docs, API references, error lookups, technology research, best practices. Do NOT use for codebase questions (use Explore) or Claude Code questions (use claude-code-guide).",
12375
+ prompt: `Research specialist. Find accurate information, cite sources, flag uncertainty.
12376
+
12377
+ TOOLS:
12378
+ - WebSearch: Broad queries, discover sources
12379
+ - WebFetch: Extract content from specific URLs
12380
+ - Read/Grep/Glob: Check local files for context before searching
12381
+ - Bash: Use curl, gh, or CLI tools for direct API access
12382
+ - AskUserQuestion: Clarify ambiguous requests BEFORE searching
12383
+
12384
+ PROCEDURE:
12385
+ 1. Parse request: factual lookup | how-to | comparison | troubleshooting
12386
+ 2. Search: Start specific, broaden if <3 relevant results
12387
+ 3. Verify: Single authoritative source for facts. Cross-reference for disputed topics.
12388
+ 4. Synthesize: Answer directly, then evidence.
12389
+
12390
+ FAILURE HANDLING:
12391
+ - Limit to 5 source pages unless requested otherwise
12392
+ - If page inaccessible (paywall/JS-only), note and try alternatives
12393
+ - After 3 failed query reformulations, report partial findings and unknowns
12394
+
12395
+ STOP CONDITIONS:
12396
+ - Factual: Official source found → done
12397
+ - How-to: Working solution with docs link → done
12398
+ - Troubleshooting: Root cause found OR 3 searches no progress → report gaps
12399
+
12400
+ QUALITY:
12401
+ - Reject sources >2yr old for fast-moving topics
12402
+ - Priority: official docs > GitHub issues > Stack Overflow > blogs
12403
+ - On conflict, report both positions with provenance
12404
+
12405
+ OUTPUT:
12406
+ [Direct answer or "Unable to determine"]
12407
+ [Evidence with inline [source](url) citations]
12408
+ [Confidence: HIGH (multiple authoritative) | MEDIUM (single authoritative) | LOW (conflicting/unofficial)]`,
12409
+ tools: [
12410
+ "WebSearch",
12411
+ "WebFetch",
12412
+ "Bash",
12413
+ "Grep",
12414
+ "Glob",
12415
+ "Read",
12416
+ "AskUserQuestion"
12417
+ ],
12418
+ model: "sonnet"
12419
+ }
12420
+ };
12421
+
12422
+ class Semaphore {
12423
+ maxConcurrent;
12424
+ permits;
12425
+ waiting = [];
12426
+ acquired = 0;
12427
+ draining = false;
12428
+ constructor(maxConcurrent) {
12429
+ this.maxConcurrent = maxConcurrent;
12430
+ this.permits = maxConcurrent;
12431
+ }
12432
+ async acquire() {
12433
+ if (this.draining)
12434
+ throw new Error("Semaphore is draining");
12435
+ if (this.permits > 0) {
12436
+ this.permits--;
12437
+ this.acquired++;
12438
+ return;
12439
+ }
12440
+ return new Promise((resolve, reject) => {
12441
+ this.waiting.push({ resolve, reject });
12442
+ });
12443
+ }
12444
+ release() {
12445
+ this.acquired--;
12446
+ const next = this.waiting.shift();
12447
+ if (next) {
12448
+ this.acquired++;
12449
+ next.resolve();
12450
+ } else {
12451
+ this.permits++;
12452
+ }
12453
+ }
12454
+ drain() {
12455
+ this.draining = true;
12456
+ for (const waiter of this.waiting) {
12457
+ waiter.reject(new Error("Semaphore drained"));
12458
+ }
12459
+ this.waiting = [];
12460
+ }
12461
+ get activeCount() {
12462
+ return this.acquired;
12463
+ }
12464
+ get waitingCount() {
12465
+ return this.waiting.length;
12466
+ }
12467
+ }
12468
+ function createTask(id, command, cwd, timeout, sessionID, metadata) {
12469
+ let resolveCompletion;
12470
+ const completionPromise = new Promise((r) => {
12471
+ resolveCompletion = r;
12472
+ });
12473
+ return {
12474
+ id,
12475
+ sessionID,
12476
+ command,
12477
+ cwd,
12478
+ status: "pending",
12479
+ stdout: `/tmp/${id}-stdout`,
12480
+ stderr: `/tmp/${id}-stderr`,
12481
+ metadata,
12482
+ createdAt: Date.now(),
12483
+ timeout,
12484
+ abortController: new AbortController,
12485
+ completionPromise,
12486
+ resolveCompletion
12487
+ };
12488
+ }
12489
+ function matchMetadata(taskMeta, filter) {
12490
+ if (!taskMeta)
12491
+ return false;
12492
+ for (const [key, value] of Object.entries(filter)) {
12493
+ if (taskMeta[key] !== value)
12494
+ return false;
12495
+ }
12496
+ return true;
12497
+ }
12498
+
12499
+ class TaskHistory {
12500
+ capacity;
12501
+ buffer;
12502
+ head = 0;
12503
+ count = 0;
12504
+ constructor(capacity) {
12505
+ this.capacity = capacity;
12506
+ this.buffer = new Array(capacity);
12507
+ }
12508
+ push(entry) {
12509
+ this.buffer[this.head] = entry;
12510
+ this.head = (this.head + 1) % this.capacity;
12511
+ if (this.count < this.capacity)
12512
+ this.count++;
12513
+ }
12514
+ entries(statusFilter, metadataFilter) {
12515
+ const result = [];
12516
+ const start = this.count < this.capacity ? 0 : this.head;
12517
+ for (let i = 0;i < this.count; i++) {
12518
+ const idx = (start + i) % this.capacity;
12519
+ const entry = this.buffer[idx];
12520
+ if (!entry)
12521
+ continue;
12522
+ if (statusFilter && entry.status !== statusFilter)
12523
+ continue;
12524
+ if (metadataFilter && !matchMetadata(entry.metadata, metadataFilter))
12525
+ continue;
12526
+ result.push({ ...entry });
12527
+ }
12528
+ return result;
12529
+ }
12530
+ get length() {
12531
+ return this.count;
12532
+ }
12533
+ clear() {
12534
+ this.buffer = new Array(this.capacity);
12535
+ this.head = 0;
12536
+ this.count = 0;
12537
+ }
12538
+ }
12539
+
12540
+ class TaskRegistry {
12541
+ tasks = new Map;
12542
+ semaphore;
12543
+ nextId = 0;
12544
+ evictionTimers = new Map;
12545
+ accepting = true;
12546
+ options;
12547
+ client;
12548
+ taskHistory;
12549
+ constructor(options = {}) {
12550
+ this.options = {
12551
+ maxConcurrent: options.maxConcurrent ?? 50,
12552
+ taskTTL: options.taskTTL ?? 30000,
12553
+ maxOutputSize: options.maxOutputSize ?? 5 * 1024 * 1024,
12554
+ maxHistory: options.maxHistory ?? 200
12555
+ };
12556
+ this.client = options.client;
12557
+ this.semaphore = new Semaphore(this.options.maxConcurrent);
12558
+ this.taskHistory = new TaskHistory(this.options.maxHistory);
12559
+ }
12560
+ async spawn(command, options = {}) {
12561
+ if (!this.accepting) {
12562
+ throw new Error("Registry is shutting down");
12563
+ }
12564
+ const id = `task-${this.nextId++}`;
12565
+ const cwd = options.cwd ?? process.cwd();
12566
+ const task = createTask(id, command, cwd, options.timeout, options.sessionID, options.metadata);
12567
+ this.tasks.set(id, task);
12568
+ this.executeTask(task);
12569
+ return id;
12570
+ }
12571
+ async executeTask(task) {
12572
+ try {
12573
+ await this.semaphore.acquire();
12574
+ } catch (e) {
12575
+ task.status = "cancelled";
12576
+ task.error = String(e);
12577
+ task.resolveCompletion();
12578
+ return;
12579
+ }
12580
+ try {
12581
+ task.status = "running";
12582
+ task.startedAt = Date.now();
12583
+ const timeoutId = task.timeout ? setTimeout(() => task.abortController.abort(), task.timeout) : null;
12584
+ try {
12585
+ const proc = Bun.spawn(["sh", "-c", task.command], {
12586
+ cwd: task.cwd,
12587
+ stdout: Bun.file(task.stdout),
12588
+ stderr: Bun.file(task.stderr)
12589
+ });
12590
+ task.proc = proc;
12591
+ task.abortController.signal.addEventListener("abort", () => {
12592
+ proc.kill();
12593
+ });
12594
+ const exitCode = await proc.exited;
12595
+ task.exitCode = exitCode;
12596
+ if (task.abortController.signal.aborted) {
12597
+ task.status = "cancelled";
12598
+ } else {
12599
+ task.status = exitCode === 0 ? "completed" : "failed";
12600
+ }
12601
+ } catch (error45) {
12602
+ const errorMsg = `
12603
+ [Task execution error: ${String(error45)}]
12604
+ `;
12605
+ await appendFile(task.stderr, errorMsg);
12606
+ if (task.abortController.signal.aborted) {
12607
+ task.status = "cancelled";
12608
+ } else {
12609
+ task.status = "failed";
12610
+ task.error = String(error45);
12611
+ }
12612
+ } finally {
12613
+ if (timeoutId)
12614
+ clearTimeout(timeoutId);
12615
+ task.completedAt = Date.now();
12616
+ }
12617
+ } finally {
12618
+ this.semaphore.release();
12619
+ task.resolveCompletion();
12620
+ this.scheduleEviction(task.id);
12621
+ this.notifyCompletion(task);
12622
+ }
12623
+ }
12624
+ notifyCompletion(task) {
12625
+ if (!this.client || !task.sessionID)
12626
+ return;
12627
+ this.client.session.promptAsync({
12628
+ path: { id: task.sessionID },
12629
+ body: {
12630
+ parts: [
12631
+ {
12632
+ type: "text",
12633
+ text: `<task-notification>
12634
+ <task-id>${task.id}</task-id>
12635
+ <status>${task.status}</status>
12636
+ <exit-code>${task.exitCode ?? "N/A"}</exit-code>
12637
+ <stdout-path>${task.stdout}</stdout-path>
12638
+ <stderr-path>${task.stderr}</stderr-path>
12639
+ </task-notification>`
12640
+ }
12641
+ ]
12642
+ }
12643
+ });
12644
+ }
12645
+ scheduleEviction(id) {
12646
+ const existing = this.evictionTimers.get(id);
12647
+ if (existing)
12648
+ clearTimeout(existing);
12649
+ const timer = setTimeout(() => {
12650
+ const task = this.tasks.get(id);
12651
+ if (task) {
12652
+ this.taskHistory.push({
12653
+ id: task.id,
12654
+ command: task.command.slice(0, 200),
12655
+ status: task.status,
12656
+ exitCode: task.exitCode,
12657
+ metadata: task.metadata,
12658
+ stdout: task.stdout,
12659
+ stderr: task.stderr,
12660
+ createdAt: task.createdAt,
12661
+ startedAt: task.startedAt,
12662
+ completedAt: task.completedAt
12663
+ });
12664
+ }
12665
+ this.tasks.delete(id);
12666
+ this.evictionTimers.delete(id);
12667
+ }, this.options.taskTTL);
12668
+ this.evictionTimers.set(id, timer);
12669
+ }
12670
+ get(id) {
12671
+ const task = this.tasks.get(id);
12672
+ if (task?.completedAt) {
12673
+ this.scheduleEviction(id);
12674
+ }
12675
+ return task;
12676
+ }
12677
+ async wait(id, timeoutMs = 30000) {
12678
+ const task = this.tasks.get(id);
12679
+ if (!task)
12680
+ throw new Error(`Task ${id} not found`);
12681
+ if (task.status !== "pending" && task.status !== "running") {
12682
+ return task;
12683
+ }
12684
+ const timeoutPromise = new Promise((_, reject) => {
12685
+ setTimeout(() => reject(new Error(`Task ${id} wait timed out`)), timeoutMs);
12686
+ });
12687
+ await Promise.race([task.completionPromise, timeoutPromise]);
12688
+ return task;
12689
+ }
12690
+ stop(id, signal) {
12691
+ const task = this.tasks.get(id);
12692
+ if (!task)
12693
+ return false;
12694
+ if (task.status === "running" && task.proc) {
12695
+ if (signal === "SIGKILL") {
12696
+ task.proc.kill(9);
12697
+ } else {
12698
+ task.proc.kill();
12699
+ }
12700
+ task.abortController.abort();
12701
+ return true;
12702
+ }
12703
+ if (task.status === "pending") {
12704
+ task.abortController.abort();
12705
+ task.status = "cancelled";
12706
+ task.resolveCompletion();
12707
+ return true;
12708
+ }
12709
+ return false;
12710
+ }
12711
+ list(statusFilter, metadataFilter) {
12712
+ let tasks = Array.from(this.tasks.values());
12713
+ if (statusFilter && statusFilter !== "all") {
12714
+ tasks = tasks.filter((t) => t.status === statusFilter);
12715
+ }
12716
+ if (metadataFilter) {
12717
+ tasks = tasks.filter((t) => matchMetadata(t.metadata, metadataFilter));
12718
+ }
12719
+ return tasks;
12720
+ }
12721
+ async shutdown(gracePeriodMs = 3000) {
12722
+ this.accepting = false;
12723
+ this.semaphore.drain();
12724
+ for (const timer of this.evictionTimers.values()) {
12725
+ clearTimeout(timer);
12726
+ }
12727
+ this.evictionTimers.clear();
12728
+ const runningTasks = [];
12729
+ for (const task of this.tasks.values()) {
12730
+ if (task.status === "running" && task.proc) {
12731
+ task.proc.kill();
12732
+ task.abortController.abort();
12733
+ runningTasks.push(task);
12734
+ }
12735
+ }
12736
+ if (runningTasks.length === 0)
12737
+ return;
12738
+ const graceDeadline = Date.now() + gracePeriodMs;
12739
+ for (const task of runningTasks) {
12740
+ const remaining = graceDeadline - Date.now();
12741
+ if (remaining > 0) {
12742
+ try {
12743
+ await Promise.race([
12744
+ task.completionPromise,
12745
+ new Promise((r) => setTimeout(r, remaining))
12746
+ ]);
12747
+ } catch {}
12748
+ }
12749
+ if (task.status === "running" && task.proc) {
12750
+ task.proc.kill(9);
12751
+ }
12752
+ }
12753
+ }
12754
+ get size() {
12755
+ return this.tasks.size;
12756
+ }
12757
+ get activeCount() {
12758
+ return this.semaphore.activeCount;
12759
+ }
12760
+ get waitingCount() {
12761
+ return this.semaphore.waitingCount;
12762
+ }
12763
+ history(statusFilter, metadataFilter) {
12764
+ return this.taskHistory.entries(statusFilter, metadataFilter);
12765
+ }
12766
+ clear() {
12767
+ for (const timer of this.evictionTimers.values()) {
12768
+ clearTimeout(timer);
12769
+ }
12770
+ this.evictionTimers.clear();
12771
+ this.tasks.clear();
12772
+ this.taskHistory.clear();
12773
+ }
12774
+ }
12775
+
12332
12776
  // index.ts
12333
- var CustomToolPlugin = async () => {
12777
+ var CustomToolPlugin = async (input) => {
12778
+ const registry2 = new TaskRegistry({
12779
+ maxConcurrent: 50,
12780
+ client: input.client
12781
+ });
12334
12782
  return {
12335
12783
  tool: {
12336
- DemoTool: tool({
12337
- description: "A demo tool",
12784
+ Task: tool({
12785
+ description: `Spawn background task.
12786
+
12787
+ Returns:
12788
+ - taskId: unique identifier
12789
+ - stdout/stderr: file paths (LIVE - readable during execution, not just after)
12790
+
12791
+ You will receive a notification when it completes.`,
12338
12792
  args: {
12339
- input: tool.schema.string().describe("Input to process")
12793
+ command: tool.schema.string().describe("Shell command to execute"),
12794
+ cwd: tool.schema.string().optional().describe("Working directory"),
12795
+ timeout: tool.schema.number().optional().describe("Timeout in milliseconds"),
12796
+ metadata: tool.schema.record(tool.schema.string(), tool.schema.string()).optional().describe("Key-value metadata tags for filtering")
12797
+ },
12798
+ async execute(args, context) {
12799
+ const id = await registry2.spawn(args.command, {
12800
+ cwd: args.cwd,
12801
+ timeout: args.timeout,
12802
+ sessionID: context.sessionID,
12803
+ metadata: args.metadata
12804
+ });
12805
+ const task = registry2.get(id);
12806
+ return JSON.stringify({
12807
+ taskId: id,
12808
+ stdout: task?.stdout,
12809
+ stderr: task?.stderr,
12810
+ message: `Task ${id} spawned. You will receive a notification when complete.`
12811
+ });
12812
+ }
12813
+ }),
12814
+ TaskStop: tool({
12815
+ description: "Stop/cancel a running background task",
12816
+ args: {
12817
+ taskId: tool.schema.string().describe("Task ID to stop"),
12818
+ signal: tool.schema.enum(["SIGTERM", "SIGKILL"]).optional().describe("Signal to send (default SIGTERM)")
12340
12819
  },
12341
12820
  async execute(args) {
12342
- return `Processed: ${args.input}`;
12821
+ const stopped = registry2.stop(args.taskId, args.signal);
12822
+ return JSON.stringify({ success: stopped, taskId: args.taskId });
12823
+ }
12824
+ }),
12825
+ TaskList: tool({
12826
+ description: "List background tasks with status, output paths, and metadata. Supports filtering by status and metadata. Use includeHistory to see evicted tasks.",
12827
+ args: {
12828
+ status: tool.schema.enum([
12829
+ "all",
12830
+ "running",
12831
+ "completed",
12832
+ "failed",
12833
+ "pending",
12834
+ "cancelled"
12835
+ ]).optional().describe("Filter by status"),
12836
+ metadata: tool.schema.record(tool.schema.string(), tool.schema.string()).optional().describe("Filter by metadata key-value pairs (all must match)"),
12837
+ includeHistory: tool.schema.boolean().optional().describe("Include evicted tasks from history buffer (default false)")
12838
+ },
12839
+ async execute(args) {
12840
+ const tasks = registry2.list(args.status, args.metadata);
12841
+ const result = {
12842
+ count: tasks.length,
12843
+ tasks: tasks.map((t) => ({
12844
+ id: t.id,
12845
+ status: t.status,
12846
+ command: t.command.slice(0, 100),
12847
+ stdout: t.stdout,
12848
+ stderr: t.stderr,
12849
+ metadata: t.metadata,
12850
+ createdAt: t.createdAt,
12851
+ duration: t.completedAt && t.startedAt ? t.completedAt - t.startedAt : undefined
12852
+ }))
12853
+ };
12854
+ if (args.includeHistory) {
12855
+ const history = registry2.history(args.status, args.metadata);
12856
+ result.history = history;
12857
+ result.historyCount = history.length;
12858
+ }
12859
+ return JSON.stringify(result);
12860
+ }
12861
+ }),
12862
+ AgentTask: tool({
12863
+ description: "Spawn a Claude agent to work on a subtask in the background. Available agents: Explore (codebase search), Plan (architecture design), general-purpose (complex multi-step tasks), claude-code-guide (Claude Code/SDK questions), web-search (research external information). You will receive a notification when complete.",
12864
+ args: {
12865
+ agent: tool.schema.enum([
12866
+ "Explore",
12867
+ "Plan",
12868
+ "general-purpose",
12869
+ "claude-code-guide",
12870
+ "web-search"
12871
+ ]).describe("Agent type to spawn"),
12872
+ prompt: tool.schema.string().describe("Task prompt for the agent"),
12873
+ cwd: tool.schema.string().optional().describe("Working directory"),
12874
+ timeout: tool.schema.number().optional().describe("Timeout in milliseconds")
12875
+ },
12876
+ async execute(args, context) {
12877
+ const agentType = args.agent;
12878
+ const config2 = AGENT_CONFIGS[agentType];
12879
+ const agentDef = {
12880
+ [agentType]: {
12881
+ description: config2.description,
12882
+ prompt: config2.prompt
12883
+ }
12884
+ };
12885
+ const cmdParts = [
12886
+ "claude",
12887
+ "--verbose",
12888
+ "--strict-mcp-config",
12889
+ "--mcp-config",
12890
+ '{"mcpServers":{}}',
12891
+ "--allow-dangerously-skip-permissions",
12892
+ "--agents",
12893
+ JSON.stringify(agentDef),
12894
+ "--agent",
12895
+ agentType
12896
+ ];
12897
+ if (config2.tools !== null) {
12898
+ cmdParts.push("--tools", config2.tools.join(","));
12899
+ }
12900
+ cmdParts.push("--output-format", "stream-json", "--print", "-p", args.prompt);
12901
+ const command = cmdParts.map((p) => p.includes(" ") || p.includes('"') ? `'${p}'` : p).join(" ");
12902
+ const id = await registry2.spawn(command, {
12903
+ cwd: args.cwd,
12904
+ timeout: args.timeout,
12905
+ sessionID: context.sessionID
12906
+ });
12907
+ return JSON.stringify({
12908
+ taskId: id,
12909
+ agent: agentType,
12910
+ message: `Agent ${agentType} spawned as ${id}. You will receive a notification when complete.`
12911
+ });
12343
12912
  }
12344
12913
  })
12345
12914
  }
12346
12915
  };
12347
12916
  };
12917
+ var opencode_task_default = CustomToolPlugin;
12348
12918
  export {
12919
+ opencode_task_default as default,
12349
12920
  CustomToolPlugin
12350
12921
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-task",
3
- "version": "0.1.1",
3
+ "version": "0.1.4",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "files": ["dist"],
@@ -17,6 +17,7 @@
17
17
  "devDependencies": {
18
18
  "@biomejs/biome": "^1.9.0",
19
19
  "@opencode-ai/plugin": "latest",
20
+ "@types/bun": "latest",
20
21
  "typescript": "^5.0.0"
21
22
  }
22
23
  }