@hueyexe/opencode-ensemble 0.1.0 → 0.1.2
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 +22 -10
- package/dist/index.js +67 -11
- package/package.json +1 -1
- package/src/db.ts +11 -0
- package/src/index.ts +9 -4
- package/src/notify.ts +41 -0
- package/src/tools/team-cleanup.ts +1 -1
- package/src/tools/team-create.ts +1 -1
- package/src/tools/team-spawn.ts +12 -5
- package/src/tools/team-status.ts +23 -0
package/README.md
CHANGED
|
@@ -79,26 +79,38 @@ Lead: "All validation and tests are complete. 5 endpoints validated,
|
|
|
79
79
|
|
|
80
80
|
## Install
|
|
81
81
|
|
|
82
|
-
|
|
83
|
-
|
|
82
|
+
Add the plugin to your OpenCode config. This can be project-level or global.
|
|
83
|
+
|
|
84
|
+
**Project-level** — `opencode.json` in your project root:
|
|
85
|
+
|
|
86
|
+
```json
|
|
87
|
+
{
|
|
88
|
+
"plugin": ["@hueyexe/opencode-ensemble"]
|
|
89
|
+
}
|
|
84
90
|
```
|
|
85
91
|
|
|
86
|
-
|
|
92
|
+
**Global** — `~/.config/opencode/opencode.json`:
|
|
87
93
|
|
|
88
94
|
```json
|
|
89
95
|
{
|
|
90
|
-
"
|
|
91
|
-
"ensemble": {
|
|
92
|
-
"module": "opencode-ensemble"
|
|
93
|
-
}
|
|
94
|
-
}
|
|
96
|
+
"plugin": ["@hueyexe/opencode-ensemble"]
|
|
95
97
|
}
|
|
96
98
|
```
|
|
97
99
|
|
|
98
|
-
|
|
100
|
+
That's it. OpenCode auto-installs npm plugins at startup (cached in `~/.cache/opencode/node_modules/`). No `npm install` or `bun add` needed.
|
|
101
|
+
|
|
102
|
+
### Local development
|
|
103
|
+
|
|
104
|
+
For working on the plugin itself, create `.opencode/plugins/ensemble.ts` in your project:
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
export { default } from "@hueyexe/opencode-ensemble"
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Or point directly at the source checkout:
|
|
99
111
|
|
|
100
112
|
```ts
|
|
101
|
-
export { default } from "opencode-ensemble"
|
|
113
|
+
export { default } from "/path/to/opencode-ensemble/src/index.ts"
|
|
102
114
|
```
|
|
103
115
|
|
|
104
116
|
## Tools
|
package/dist/index.js
CHANGED
|
@@ -12335,10 +12335,12 @@ function tool(input) {
|
|
|
12335
12335
|
}
|
|
12336
12336
|
tool.schema = exports_external;
|
|
12337
12337
|
// src/index.ts
|
|
12338
|
-
import
|
|
12338
|
+
import path2 from "path";
|
|
12339
|
+
import { mkdirSync } from "fs";
|
|
12339
12340
|
|
|
12340
12341
|
// src/db.ts
|
|
12341
12342
|
import { Database } from "bun:sqlite";
|
|
12343
|
+
import path from "path";
|
|
12342
12344
|
|
|
12343
12345
|
// src/schema.ts
|
|
12344
12346
|
var MIGRATIONS = [
|
|
@@ -12417,8 +12419,12 @@ function applyMigrations(db) {
|
|
|
12417
12419
|
|
|
12418
12420
|
// src/db.ts
|
|
12419
12421
|
var instance;
|
|
12420
|
-
function
|
|
12421
|
-
const
|
|
12422
|
+
function getDbPath(env = process.env) {
|
|
12423
|
+
const home = env.HOME ?? env.USERPROFILE ?? "~";
|
|
12424
|
+
return path.join(home, ".config", "opencode", "ensemble.db");
|
|
12425
|
+
}
|
|
12426
|
+
function createDb(path2) {
|
|
12427
|
+
const db = new Database(path2);
|
|
12422
12428
|
db.exec("PRAGMA journal_mode=WAL");
|
|
12423
12429
|
db.exec("PRAGMA foreign_keys=ON");
|
|
12424
12430
|
applyMigrations(db);
|
|
@@ -12627,6 +12633,30 @@ async function notifyTeamEvent(client, type, data) {
|
|
|
12627
12633
|
});
|
|
12628
12634
|
} catch {}
|
|
12629
12635
|
}
|
|
12636
|
+
async function notifyWorkingProgress(client, db, teamId) {
|
|
12637
|
+
const members = db.query("SELECT name, status FROM team_member WHERE team_id = ? ORDER BY time_created ASC").all(teamId);
|
|
12638
|
+
if (members.length === 0)
|
|
12639
|
+
return;
|
|
12640
|
+
const busy = members.filter((m) => m.status === "busy");
|
|
12641
|
+
try {
|
|
12642
|
+
if (busy.length === 0) {
|
|
12643
|
+
await client.tui.showToast({
|
|
12644
|
+
title: "Team",
|
|
12645
|
+
message: "All teammates finished",
|
|
12646
|
+
variant: "success",
|
|
12647
|
+
duration: 4000
|
|
12648
|
+
});
|
|
12649
|
+
} else {
|
|
12650
|
+
const names = busy.map((m) => m.name).join(", ");
|
|
12651
|
+
await client.tui.showToast({
|
|
12652
|
+
title: "Team",
|
|
12653
|
+
message: `Working: ${names} (${busy.length}/${members.length})`,
|
|
12654
|
+
variant: "info",
|
|
12655
|
+
duration: 5000
|
|
12656
|
+
});
|
|
12657
|
+
}
|
|
12658
|
+
} catch {}
|
|
12659
|
+
}
|
|
12630
12660
|
|
|
12631
12661
|
// src/types.ts
|
|
12632
12662
|
function findTeamBySession(db, registry2, sessionId) {
|
|
@@ -12664,7 +12694,7 @@ async function executeTeamCreate(deps, args, sessionId) {
|
|
|
12664
12694
|
const id = generateId("team");
|
|
12665
12695
|
const now = Date.now();
|
|
12666
12696
|
deps.db.run("INSERT INTO team (id, name, lead_session_id, status, delegate, time_created, time_updated) VALUES (?, ?, ?, 'active', 0, ?, ?)", [id, args.name, sessionId, now, now]);
|
|
12667
|
-
return `Team "${args.name}" created (ID: ${id}). You are the lead. Use team_spawn to add teammates.`;
|
|
12697
|
+
return `Team "${args.name}" created (ID: ${id}). You are the lead. Use team_spawn to add teammates. Teammates will message you when done \u2014 do not poll team_status repeatedly.`;
|
|
12668
12698
|
}
|
|
12669
12699
|
|
|
12670
12700
|
// src/tools/team-spawn.ts
|
|
@@ -12703,8 +12733,9 @@ async function executeTeamSpawn(deps, args, sessionId) {
|
|
|
12703
12733
|
"- team_claim: claim a pending task from the shared board",
|
|
12704
12734
|
"",
|
|
12705
12735
|
"When you finish your task:",
|
|
12706
|
-
"1.
|
|
12707
|
-
"2.
|
|
12736
|
+
"1. If you claimed a task, mark it complete using team_tasks_complete.",
|
|
12737
|
+
"2. Send ONE message to the lead with your findings using team_message.",
|
|
12738
|
+
"3. STOP. Do not send follow-up confirmations, status updates, or 'standing by' messages.",
|
|
12708
12739
|
"",
|
|
12709
12740
|
"If you are blocked, send ONE message to the lead describing the specific blocker.",
|
|
12710
12741
|
"",
|
|
@@ -12712,13 +12743,17 @@ async function executeTeamSpawn(deps, args, sessionId) {
|
|
|
12712
12743
|
"",
|
|
12713
12744
|
"Your task:",
|
|
12714
12745
|
args.prompt
|
|
12715
|
-
]
|
|
12746
|
+
];
|
|
12747
|
+
if (args.claim_task) {
|
|
12748
|
+
context.push("", `You have been assigned task ${args.claim_task}. Mark it complete when done.`);
|
|
12749
|
+
}
|
|
12750
|
+
const contextStr = context.join(`
|
|
12716
12751
|
`);
|
|
12717
12752
|
await deps.client.session.promptAsync({
|
|
12718
12753
|
path: { id: childSessionId },
|
|
12719
|
-
body: { parts: [{ type: "text", text:
|
|
12754
|
+
body: { parts: [{ type: "text", text: contextStr }] }
|
|
12720
12755
|
});
|
|
12721
|
-
return `Teammate "${args.name}" spawned (session: ${childSessionId}, agent: ${args.agent}). Working on: ${args.prompt.slice(0, 100)}${args.prompt.length > 100 ? "..." : ""}
|
|
12756
|
+
return `Teammate "${args.name}" spawned (session: ${childSessionId}, agent: ${args.agent}). Working on: ${args.prompt.slice(0, 100)}${args.prompt.length > 100 ? "..." : ""} Teammates will message you when done. Do not poll team_status to check.`;
|
|
12722
12757
|
}
|
|
12723
12758
|
|
|
12724
12759
|
// src/tools/team-message.ts
|
|
@@ -12814,7 +12849,7 @@ async function executeTeamCleanup(deps, args, sessionId) {
|
|
|
12814
12849
|
if (teamInfo.role !== "lead")
|
|
12815
12850
|
throw new Error("Only the team lead can clean up the team.");
|
|
12816
12851
|
const members = deps.db.query("SELECT name, session_id, status FROM team_member WHERE team_id = ?").all(teamInfo.teamId);
|
|
12817
|
-
const active = members.filter((m) => m.status !== "shutdown");
|
|
12852
|
+
const active = members.filter((m) => m.status !== "shutdown" && m.status !== "shutdown_requested" && m.status !== "error");
|
|
12818
12853
|
if (active.length > 0 && !args.force) {
|
|
12819
12854
|
const names = active.map((m) => m.name).join(", ");
|
|
12820
12855
|
throw new Error(`Cannot clean up team "${teamInfo.teamName}": ${active.length} member(s) still active: ${names}. Use team_shutdown on each member first, or call team_cleanup with force: true to abort them immediately.`);
|
|
@@ -12976,6 +13011,25 @@ async function executeTeamStatus(deps, sessionId) {
|
|
|
12976
13011
|
lines.push("");
|
|
12977
13012
|
lines.push(`Tasks: ${tasks.length} total (${parts.join(", ")})`);
|
|
12978
13013
|
}
|
|
13014
|
+
if (members.length > 0) {
|
|
13015
|
+
const memberParts = members.map((m) => {
|
|
13016
|
+
const label = m.status === "busy" ? "working" : m.status === "ready" ? "idle" : m.status;
|
|
13017
|
+
return `${m.name} [${label}]`;
|
|
13018
|
+
});
|
|
13019
|
+
let toastMsg = memberParts.join(", ");
|
|
13020
|
+
if (tasks.length > 0) {
|
|
13021
|
+
const completed = tasks.filter((t) => t.status === "completed").length;
|
|
13022
|
+
toastMsg += ` | Tasks: ${completed}/${tasks.length} done`;
|
|
13023
|
+
}
|
|
13024
|
+
try {
|
|
13025
|
+
await deps.client.tui.showToast({
|
|
13026
|
+
title: "Team",
|
|
13027
|
+
message: toastMsg,
|
|
13028
|
+
variant: "info",
|
|
13029
|
+
duration: 4000
|
|
13030
|
+
});
|
|
13031
|
+
} catch {}
|
|
13032
|
+
}
|
|
12979
13033
|
return lines.join(`
|
|
12980
13034
|
`);
|
|
12981
13035
|
}
|
|
@@ -13060,7 +13114,8 @@ var DEFAULT_RATE_LIMIT_CAPACITY = 10;
|
|
|
13060
13114
|
var DEFAULT_RATE_LIMIT_REFILL = 2;
|
|
13061
13115
|
var DEFAULT_RATE_LIMIT_INTERVAL_MS = 1000;
|
|
13062
13116
|
var plugin = async (input) => {
|
|
13063
|
-
const dbPath =
|
|
13117
|
+
const dbPath = getDbPath();
|
|
13118
|
+
mkdirSync(path2.dirname(dbPath), { recursive: true });
|
|
13064
13119
|
const db = createDb(dbPath);
|
|
13065
13120
|
const registry2 = new MemberRegistry;
|
|
13066
13121
|
const tracker = new DescendantTracker;
|
|
@@ -13094,6 +13149,7 @@ var plugin = async (input) => {
|
|
|
13094
13149
|
} else if (transition.to === "error") {
|
|
13095
13150
|
notifyTeamEvent(client, "error", { memberName: transition.memberName });
|
|
13096
13151
|
}
|
|
13152
|
+
await notifyWorkingProgress(client, db, transition.teamId);
|
|
13097
13153
|
}
|
|
13098
13154
|
}
|
|
13099
13155
|
if (event.type === "session.created") {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hueyexe/opencode-ensemble",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Agent teams for OpenCode — parallel agents with peer-to-peer communication, shared tasks, and coordinated execution",
|
|
5
5
|
"module": "src/index.ts",
|
|
6
6
|
"main": "dist/index.js",
|
package/src/db.ts
CHANGED
|
@@ -1,8 +1,19 @@
|
|
|
1
1
|
import { Database } from "bun:sqlite"
|
|
2
|
+
import path from "path"
|
|
2
3
|
import { applyMigrations } from "./schema"
|
|
3
4
|
|
|
4
5
|
let instance: Database | undefined
|
|
5
6
|
|
|
7
|
+
/**
|
|
8
|
+
* Resolve the path for the ensemble SQLite database.
|
|
9
|
+
* Always uses the global ~/.config/opencode/ directory, never the project directory.
|
|
10
|
+
* Accepts an env override for testability.
|
|
11
|
+
*/
|
|
12
|
+
export function getDbPath(env: Record<string, string | undefined> = process.env): string {
|
|
13
|
+
const home = env.HOME ?? env.USERPROFILE ?? "~"
|
|
14
|
+
return path.join(home, ".config", "opencode", "ensemble.db")
|
|
15
|
+
}
|
|
16
|
+
|
|
6
17
|
/**
|
|
7
18
|
* Create and initialize a SQLite database at the given path.
|
|
8
19
|
* Applies all pending migrations and enables WAL mode.
|
package/src/index.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import type { Plugin } from "@opencode-ai/plugin"
|
|
2
2
|
import { tool } from "@opencode-ai/plugin"
|
|
3
3
|
import path from "path"
|
|
4
|
-
import {
|
|
4
|
+
import { mkdirSync } from "node:fs"
|
|
5
|
+
import { createDb, getDbPath } from "./db"
|
|
5
6
|
import { recoverStaleMembers } from "./recovery"
|
|
6
7
|
import { MemberRegistry, DescendantTracker } from "./state"
|
|
7
8
|
import { handleSessionStatusEvent, handleSessionCreatedEvent, checkToolIsolation } from "./hooks"
|
|
8
|
-
import { notifyTeamEvent } from "./notify"
|
|
9
|
+
import { notifyTeamEvent, notifyWorkingProgress } from "./notify"
|
|
9
10
|
import { executeTeamCreate } from "./tools/team-create"
|
|
10
11
|
import { executeTeamSpawn } from "./tools/team-spawn"
|
|
11
12
|
import { executeTeamMessage } from "./tools/team-message"
|
|
@@ -32,8 +33,9 @@ const DEFAULT_RATE_LIMIT_INTERVAL_MS = 1000
|
|
|
32
33
|
* peer-to-peer communication, shared task management, and coordinated execution.
|
|
33
34
|
*/
|
|
34
35
|
const plugin: Plugin = async (input) => {
|
|
35
|
-
// Initialize SQLite database in the
|
|
36
|
-
const dbPath =
|
|
36
|
+
// Initialize SQLite database in the global OpenCode config directory
|
|
37
|
+
const dbPath = getDbPath()
|
|
38
|
+
mkdirSync(path.dirname(dbPath), { recursive: true })
|
|
37
39
|
const db = createDb(dbPath)
|
|
38
40
|
|
|
39
41
|
// Initialize in-memory state
|
|
@@ -83,6 +85,9 @@ const plugin: Plugin = async (input) => {
|
|
|
83
85
|
} else if (transition.to === "error") {
|
|
84
86
|
notifyTeamEvent(client, "error", { memberName: transition.memberName })
|
|
85
87
|
}
|
|
88
|
+
|
|
89
|
+
// Show working progress after every transition so the user sees who's still active
|
|
90
|
+
await notifyWorkingProgress(client, db, transition.teamId)
|
|
86
91
|
}
|
|
87
92
|
}
|
|
88
93
|
|
package/src/notify.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite"
|
|
1
2
|
import type { PluginClient } from "./types"
|
|
2
3
|
|
|
3
4
|
type TeamEventType = "spawn" | "message" | "completed" | "error" | "shutdown"
|
|
@@ -52,3 +53,43 @@ export async function notifyTeamEvent(
|
|
|
52
53
|
// TUI may not be available — silently ignore
|
|
53
54
|
}
|
|
54
55
|
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Fire a toast showing which teammates are still working.
|
|
59
|
+
* Called on every session.status transition so the user sees live progress.
|
|
60
|
+
* Does nothing if the team has no members.
|
|
61
|
+
*/
|
|
62
|
+
export async function notifyWorkingProgress(
|
|
63
|
+
client: PluginClient,
|
|
64
|
+
db: Database,
|
|
65
|
+
teamId: string,
|
|
66
|
+
): Promise<void> {
|
|
67
|
+
const members = db.query(
|
|
68
|
+
"SELECT name, status FROM team_member WHERE team_id = ? ORDER BY time_created ASC"
|
|
69
|
+
).all(teamId) as Array<{ name: string; status: string }>
|
|
70
|
+
|
|
71
|
+
if (members.length === 0) return
|
|
72
|
+
|
|
73
|
+
const busy = members.filter(m => m.status === "busy")
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
if (busy.length === 0) {
|
|
77
|
+
await client.tui.showToast({
|
|
78
|
+
title: "Team",
|
|
79
|
+
message: "All teammates finished",
|
|
80
|
+
variant: "success",
|
|
81
|
+
duration: 4000,
|
|
82
|
+
})
|
|
83
|
+
} else {
|
|
84
|
+
const names = busy.map(m => m.name).join(", ")
|
|
85
|
+
await client.tui.showToast({
|
|
86
|
+
title: "Team",
|
|
87
|
+
message: `Working: ${names} (${busy.length}/${members.length})`,
|
|
88
|
+
variant: "info",
|
|
89
|
+
duration: 5000,
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
} catch {
|
|
93
|
+
// TUI may not be available — silently ignore
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -17,7 +17,7 @@ export async function executeTeamCleanup(
|
|
|
17
17
|
const members = deps.db.query("SELECT name, session_id, status FROM team_member WHERE team_id = ?")
|
|
18
18
|
.all(teamInfo.teamId) as Array<{ name: string; session_id: string; status: string }>
|
|
19
19
|
|
|
20
|
-
const active = members.filter(m => m.status !== "shutdown")
|
|
20
|
+
const active = members.filter(m => m.status !== "shutdown" && m.status !== "shutdown_requested" && m.status !== "error")
|
|
21
21
|
|
|
22
22
|
if (active.length > 0 && !args.force) {
|
|
23
23
|
const names = active.map(m => m.name).join(", ")
|
package/src/tools/team-create.ts
CHANGED
|
@@ -28,5 +28,5 @@ export async function executeTeamCreate(
|
|
|
28
28
|
[id, args.name, sessionId, now, now]
|
|
29
29
|
)
|
|
30
30
|
|
|
31
|
-
return `Team "${args.name}" created (ID: ${id}). You are the lead. Use team_spawn to add teammates.`
|
|
31
|
+
return `Team "${args.name}" created (ID: ${id}). You are the lead. Use team_spawn to add teammates. Teammates will message you when done — do not poll team_status repeatedly.`
|
|
32
32
|
}
|
package/src/tools/team-spawn.ts
CHANGED
|
@@ -54,8 +54,9 @@ export async function executeTeamSpawn(
|
|
|
54
54
|
"- team_claim: claim a pending task from the shared board",
|
|
55
55
|
"",
|
|
56
56
|
"When you finish your task:",
|
|
57
|
-
"1.
|
|
58
|
-
"2.
|
|
57
|
+
"1. If you claimed a task, mark it complete using team_tasks_complete.",
|
|
58
|
+
"2. Send ONE message to the lead with your findings using team_message.",
|
|
59
|
+
"3. STOP. Do not send follow-up confirmations, status updates, or 'standing by' messages.",
|
|
59
60
|
"",
|
|
60
61
|
"If you are blocked, send ONE message to the lead describing the specific blocker.",
|
|
61
62
|
"",
|
|
@@ -63,15 +64,21 @@ export async function executeTeamSpawn(
|
|
|
63
64
|
"",
|
|
64
65
|
"Your task:",
|
|
65
66
|
args.prompt,
|
|
66
|
-
]
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
if (args.claim_task) {
|
|
70
|
+
context.push("", `You have been assigned task ${args.claim_task}. Mark it complete when done.`)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const contextStr = context.join("\n")
|
|
67
74
|
|
|
68
75
|
// Fire-and-forget: send prompt to teammate session
|
|
69
76
|
// OQ-1: assuming promptAsync queues when session is busy
|
|
70
77
|
// OQ-10: assuming fresh session accepts promptAsync without session.init()
|
|
71
78
|
await deps.client.session.promptAsync({
|
|
72
79
|
path: { id: childSessionId },
|
|
73
|
-
body: { parts: [{ type: "text", text:
|
|
80
|
+
body: { parts: [{ type: "text", text: contextStr }] },
|
|
74
81
|
})
|
|
75
82
|
|
|
76
|
-
return `Teammate "${args.name}" spawned (session: ${childSessionId}, agent: ${args.agent}). Working on: ${args.prompt.slice(0, 100)}${args.prompt.length > 100 ? "..." : ""}
|
|
83
|
+
return `Teammate "${args.name}" spawned (session: ${childSessionId}, agent: ${args.agent}). Working on: ${args.prompt.slice(0, 100)}${args.prompt.length > 100 ? "..." : ""} Teammates will message you when done. Do not poll team_status to check.`
|
|
77
84
|
}
|
package/src/tools/team-status.ts
CHANGED
|
@@ -45,5 +45,28 @@ export async function executeTeamStatus(
|
|
|
45
45
|
lines.push(`Tasks: ${tasks.length} total (${parts.join(", ")})`)
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
// Fire a toast so the user (not just the model) sees the status summary
|
|
49
|
+
if (members.length > 0) {
|
|
50
|
+
const memberParts = members.map(m => {
|
|
51
|
+
const label = m.status === "busy" ? "working" : m.status === "ready" ? "idle" : m.status
|
|
52
|
+
return `${m.name} [${label}]`
|
|
53
|
+
})
|
|
54
|
+
let toastMsg = memberParts.join(", ")
|
|
55
|
+
if (tasks.length > 0) {
|
|
56
|
+
const completed = tasks.filter(t => t.status === "completed").length
|
|
57
|
+
toastMsg += ` | Tasks: ${completed}/${tasks.length} done`
|
|
58
|
+
}
|
|
59
|
+
try {
|
|
60
|
+
await deps.client.tui.showToast({
|
|
61
|
+
title: "Team",
|
|
62
|
+
message: toastMsg,
|
|
63
|
+
variant: "info",
|
|
64
|
+
duration: 4000,
|
|
65
|
+
})
|
|
66
|
+
} catch {
|
|
67
|
+
// TUI may not be available — silently ignore
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
48
71
|
return lines.join("\n")
|
|
49
72
|
}
|