pi-gang 0.1.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/LICENSE +21 -0
- package/NOTICE +15 -0
- package/README.md +159 -0
- package/assets/gang-logo.png +0 -0
- package/package.json +64 -0
- package/src/gang/events.ts +11 -0
- package/src/gang/feed-client.ts +88 -0
- package/src/gang/index.ts +618 -0
- package/src/gang/members.ts +124 -0
- package/src/gang/tmux.ts +151 -0
- package/src/gang/ui/mission-control.ts +234 -0
- package/src/intercom/broker/broker.ts +388 -0
- package/src/intercom/broker/client.ts +535 -0
- package/src/intercom/broker/framing.ts +73 -0
- package/src/intercom/broker/paths.ts +20 -0
- package/src/intercom/broker/spawn.ts +309 -0
- package/src/intercom/broker/tap.ts +31 -0
- package/src/intercom/config.ts +108 -0
- package/src/intercom/gui/dashboard.html +272 -0
- package/src/intercom/gui/server.ts +108 -0
- package/src/intercom/index.ts +1807 -0
- package/src/intercom/reply-tracker.ts +105 -0
- package/src/intercom/skills/pi-intercom/SKILL.md +513 -0
- package/src/intercom/types.ts +46 -0
- package/src/intercom/ui/compose.ts +139 -0
- package/src/intercom/ui/inline-message.ts +78 -0
- package/src/intercom/ui/session-list.ts +162 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Lachlan Jacobs
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/NOTICE
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
gang
|
|
2
|
+
Copyright (c) 2026 Lachlan Jacobs
|
|
3
|
+
|
|
4
|
+
This product vendors and modifies source from pi-intercom, used under the MIT License:
|
|
5
|
+
|
|
6
|
+
pi-intercom
|
|
7
|
+
Copyright (c) 2026 Nico Bailon
|
|
8
|
+
https://github.com/nicobailon/pi-intercom
|
|
9
|
+
|
|
10
|
+
The vendored source lives under src/intercom/. It has been modified: peer-dependency
|
|
11
|
+
imports retargeted from @mariozechner/* to @earendil-works/*, a broker message tap and
|
|
12
|
+
an embedded mission-control HTTP/SSE server added.
|
|
13
|
+
|
|
14
|
+
gang targets the Pi coding agent (@earendil-works/pi-coding-agent, originally authored by
|
|
15
|
+
Mario Zechner as @mariozechner/pi-coding-agent).
|
package/README.md
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# gang
|
|
2
|
+
|
|
3
|
+
<p align="center">
|
|
4
|
+
<img src="assets/gang-logo.png" alt="gang mission-control crew logo" width="260">
|
|
5
|
+
</p>
|
|
6
|
+
|
|
7
|
+
A minimal, **observable** subagent primitive for [Pi](https://github.com/earendil-works). Fire up
|
|
8
|
+
subagents as **visible tmux panes** you watch live, let them talk over a **cross-agent message
|
|
9
|
+
bus**, and observe everything three ways: live panes, a durable log, and a mission-control view —
|
|
10
|
+
in your terminal (a Pi overlay) **and** in the browser.
|
|
11
|
+
|
|
12
|
+
Built on a vendored, fully-owned copy of [pi-intercom](https://github.com/nicobailon/pi-intercom)
|
|
13
|
+
(MIT). No external `pi-intercom` runtime dependency; npm deps install with the package.
|
|
14
|
+
Targets `@earendil-works` Pi **0.80.x** (supported range: `>=0.80.2 <0.81.0`).
|
|
15
|
+
|
|
16
|
+
```
|
|
17
|
+
┌─ tmux panes ──────────────┐ raw per-agent view — watch each member's pi session
|
|
18
|
+
│ superintendent │ worker │
|
|
19
|
+
│ │ reviewer │
|
|
20
|
+
└───────────┴───────────────┘
|
|
21
|
+
│ intercom (unix socket: ~/.pi/agent/intercom/broker.sock)
|
|
22
|
+
▼
|
|
23
|
+
┌─ owned broker (vendored) ────────────────────┐
|
|
24
|
+
│ • routes messages │
|
|
25
|
+
│ • TAP → ~/.pi/agent/intercom/intercom.jsonl │ durable, greppable, replayable
|
|
26
|
+
│ • HTTP + SSE :7717 ─────────────────────────┼─► mission control: /gang watch (in Pi)
|
|
27
|
+
└───────────────────────────────────────────────┘ 127.0.0.1:7717 (browser)
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Requirements
|
|
31
|
+
|
|
32
|
+
- **Pi** (`@earendil-works/pi-coding-agent`) 0.80.x, ≥ 0.80.2 — `pi --version`
|
|
33
|
+
- **tmux** — members run as visible panes (`brew install tmux`)
|
|
34
|
+
- **Node** ≥ 22
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
### Option A — from npm (recommended)
|
|
39
|
+
|
|
40
|
+
```sh
|
|
41
|
+
pi install pi-gang
|
|
42
|
+
pi list # verify: should list gang
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### Option B — from GitHub
|
|
46
|
+
|
|
47
|
+
Pi packages run local extension code with your user permissions, so review the source before
|
|
48
|
+
installing. Pi installs the package's runtime dependencies for you on a git install:
|
|
49
|
+
|
|
50
|
+
```sh
|
|
51
|
+
pi install https://github.com/Jeecabs/gang.git
|
|
52
|
+
pi list # verify: should list gang
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
SSH also works if your GitHub account is configured for it:
|
|
56
|
+
`pi install ssh://git@github.com/Jeecabs/gang.git`
|
|
57
|
+
|
|
58
|
+
### Option C — local clone (to hack on it)
|
|
59
|
+
|
|
60
|
+
```sh
|
|
61
|
+
git clone git@github.com:Jeecabs/gang.git
|
|
62
|
+
cd gang
|
|
63
|
+
npm install # REQUIRED — the broker + GUI run from node_modules
|
|
64
|
+
pi install . # persistent: registers it in your Pi settings
|
|
65
|
+
# …or load it for a single session without touching settings:
|
|
66
|
+
pi -e ./src/intercom/index.ts -e ./src/gang/index.ts
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
> ⚠️ A **local** install (`pi install .`) references the folder **in place** and does **not** run
|
|
70
|
+
> `npm install` for you — so run it yourself, and don't move or delete the folder (the broker
|
|
71
|
+
> launches from its `node_modules`). A git/npm install handles deps automatically.
|
|
72
|
+
|
|
73
|
+
Unsure about Pi extensions in general? Pi documents itself — just ask it:
|
|
74
|
+
`pi -p "how do I write and install a Pi extension?"`
|
|
75
|
+
|
|
76
|
+
## Use
|
|
77
|
+
|
|
78
|
+
In any Pi session, ask for a member — or call the tool yourself. The session adopts the **`superintendent`**
|
|
79
|
+
identity the first time you spawn (not before), so sessions where you never use gang keep their
|
|
80
|
+
natural title in `pi -r`:
|
|
81
|
+
|
|
82
|
+
```
|
|
83
|
+
/gang spawn list the repo's largest files
|
|
84
|
+
/gang spawn @reviewer -t high review the diff carefully
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
`gang spawn` returns immediately and opens a tmux pane. The member does the task in its own Pi
|
|
88
|
+
session and sends the result **back to superintendent as an intercom message** (it does not return inline).
|
|
89
|
+
Keep working; handle the result when it arrives.
|
|
90
|
+
|
|
91
|
+
Watch the gang three ways:
|
|
92
|
+
|
|
93
|
+
```sh
|
|
94
|
+
# 1. in Pi — live mission-control overlay (members + status + feed)
|
|
95
|
+
/gang watch # or press alt+g
|
|
96
|
+
|
|
97
|
+
# 2. in the browser — the same feed, richer
|
|
98
|
+
/gang url # prints computed loopback URL from GANG_GUI_PORT
|
|
99
|
+
open http://127.0.0.1:7717
|
|
100
|
+
|
|
101
|
+
# 3. durable, greppable log of every cross-agent message
|
|
102
|
+
tail -f ~/.pi/agent/intercom/intercom.jsonl
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Commands & tools
|
|
106
|
+
|
|
107
|
+
| Surface | What |
|
|
108
|
+
|---|---|
|
|
109
|
+
| `gang` tool | `{action:"spawn", task, role?, thinking?}` · `{action:"list"}` · `{action:"clean", force?, all?}` · `{action:"stop", role? finished?}` · `{action:"name", name}` — model-callable |
|
|
110
|
+
| `/gang` | show the roster |
|
|
111
|
+
| `/gang spawn [@name] [-t <level>] <task>` | spawn a member by hand; name optional (auto `m1`, `m2`, …). Levels: `off`, `minimal`, `low`, `medium`, `high`, `xhigh` |
|
|
112
|
+
| `/gang clean` · `/gang clean --force` · `/gang clean all` | `clean` reaps dead/missing panes and prunes stale roster entries; `clean --force` also kills members that already reported back; `clean all` stops this superintendent's members (detached mode kills the `gang` session; in-tmux mode kills direct member panes only) |
|
|
113
|
+
| `/gang stop <member>` · `/gang stop --all-finished` | stop one member immediately, or bulk-stop any member already reapable (`reported_done`, `pane_dead`, `pane_missing`) |
|
|
114
|
+
| `/gang url` | show the browser mission-control URL (`http://127.0.0.1:<GANG_GUI_PORT>`) |
|
|
115
|
+
| `/gang name [name]` | show or set this agent/session's own name |
|
|
116
|
+
| `/gang watch` · `alt+g` | open mission control (in-Pi overlay) |
|
|
117
|
+
| `intercom` tool | message any session: `list` / `send` / `ask` / `reply` |
|
|
118
|
+
|
|
119
|
+
Env knobs: `GANG_GUI_PORT` (default `7717`), `GANG_TMUX_BIN` (default Homebrew tmux), `GANG_FEED_HOURS` (mission-control feed recency window, default `6`).
|
|
120
|
+
|
|
121
|
+
Finished members linger on purpose — their panes stay (`remain-on-exit on`) so you can read the final state. Reap them when you're done with `/gang clean` (or `gang({ action: "clean" })`). If a member has already reported back but its pane is still hanging around, use `/gang clean --force` or `/gang stop --all-finished`. `/gang stop <member>` is the first-class escape hatch when you know a specific pane should die. `/gang clean all` tears down the detached `gang` session outside tmux; inside tmux it kills this superintendent's direct member panes only.
|
|
122
|
+
|
|
123
|
+
Agent naming: an unnamed orchestrator session claims `superintendent of <current-folder>` (e.g. `superintendent of private-evals`) **on its first spawn**, not at startup — so plain Pi sessions stay unnamed in the `pi -r` resume list. Use `/gang name <name>` before spawning to pick a custom target; spawned members get that exact supervisor name. Member task prompts also tell agents to name themselves with `gang({ action: "name", name: "<clear role/name>" })` before spinning up their own teammate.
|
|
124
|
+
|
|
125
|
+
## How it's wired
|
|
126
|
+
|
|
127
|
+
`package.json` → `pi.extensions` points Pi at two TypeScript entry files it loads on startup:
|
|
128
|
+
|
|
129
|
+
- `src/intercom/` — vendored pi-intercom: the broker, client, and the `intercom` /
|
|
130
|
+
`contact_supervisor` tools. **Owned** — imports retargeted to `@earendil-works/*`, plus a broker
|
|
131
|
+
message **tap** (durable log) and an embedded **HTTP/SSE** mission-control server.
|
|
132
|
+
- `src/gang/` — the `gang` tool (spawn members in tmux, list the roster), the `/gang watch`
|
|
133
|
+
overlay, and superintendent auto-naming.
|
|
134
|
+
|
|
135
|
+
`gang spawn <task>` (optionally `@name`) launches `pi --name <name> … @<taskfile>` in a tmux pane with five
|
|
136
|
+
`PI_SUBAGENT_*` env vars. Pass `--thinking <level>` to add Pi's `--thinking` flag for that member.
|
|
137
|
+
The vendored intercom extension reads the env vars at child startup to register
|
|
138
|
+
the member on the bus and unlock its `contact_supervisor` tool — that's the whole contract:
|
|
139
|
+
|
|
140
|
+
| Env var | Set to | Effect |
|
|
141
|
+
|---|---|---|
|
|
142
|
+
| `PI_SUBAGENT_INTERCOM_SESSION_NAME` | `<role>` | member's intercom identity |
|
|
143
|
+
| `PI_SUBAGENT_ORCHESTRATOR_TARGET` | current supervisor name (`superintendent of <folder>` or custom `/gang name`) | who it reports to |
|
|
144
|
+
| `PI_SUBAGENT_RUN_ID` / `_CHILD_AGENT` / `_CHILD_INDEX` | run metadata | unlocks `contact_supervisor` |
|
|
145
|
+
|
|
146
|
+
(The member's addressable **name** comes from Pi's `--name` flag; the supervisor self-names
|
|
147
|
+
lazily to the computed or custom `orchestratorName`.)
|
|
148
|
+
|
|
149
|
+
## Develop
|
|
150
|
+
|
|
151
|
+
```sh
|
|
152
|
+
npm test # vendored intercom suite + gang/tmux/tap/GUI/overlay tests
|
|
153
|
+
npm run typecheck # strict TypeScript check
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
## Credits
|
|
157
|
+
|
|
158
|
+
Vendors [pi-intercom](https://github.com/nicobailon/pi-intercom) by Nico Bailon (MIT). See `NOTICE`.
|
|
159
|
+
Built on Pi by the [earendil-works](https://github.com/earendil-works) team.
|
|
Binary file
|
package/package.json
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-gang",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Minimal, observable subagent primitive for Pi — visible tmux members, cross-agent intercom, and a mission-control GUI.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"pi-package"
|
|
9
|
+
],
|
|
10
|
+
"files": [
|
|
11
|
+
"assets",
|
|
12
|
+
"src/**/*.ts",
|
|
13
|
+
"src/**/*.html",
|
|
14
|
+
"src/**/SKILL.md",
|
|
15
|
+
"!src/**/*.test.ts",
|
|
16
|
+
"!src/**/test/**",
|
|
17
|
+
"NOTICE"
|
|
18
|
+
],
|
|
19
|
+
"scripts": {
|
|
20
|
+
"test": "tsx --test \"src/**/*.test.ts\"",
|
|
21
|
+
"typecheck": "tsc --noEmit",
|
|
22
|
+
"check": "npm run typecheck && npm test",
|
|
23
|
+
"prepublishOnly": "npm run check"
|
|
24
|
+
},
|
|
25
|
+
"pi": {
|
|
26
|
+
"extensions": [
|
|
27
|
+
"./src/intercom/index.ts",
|
|
28
|
+
"./src/gang/index.ts"
|
|
29
|
+
],
|
|
30
|
+
"skills": [
|
|
31
|
+
"./src/intercom/skills"
|
|
32
|
+
]
|
|
33
|
+
},
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=22.19.0"
|
|
36
|
+
},
|
|
37
|
+
"peerDependencies": {
|
|
38
|
+
"@earendil-works/pi-coding-agent": ">=0.80.2 <0.81.0",
|
|
39
|
+
"@earendil-works/pi-tui": ">=0.80.2 <0.81.0",
|
|
40
|
+
"typebox": ">=1.1.38 <2"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@earendil-works/pi-coding-agent": "^0.80.6",
|
|
44
|
+
"@earendil-works/pi-tui": "^0.80.6",
|
|
45
|
+
"@types/node": "^22.20.1",
|
|
46
|
+
"typebox": "^1.3.6",
|
|
47
|
+
"typescript": "^5.9.3"
|
|
48
|
+
},
|
|
49
|
+
"dependencies": {
|
|
50
|
+
"tsx": "^4.23.1"
|
|
51
|
+
},
|
|
52
|
+
"repository": {
|
|
53
|
+
"type": "git",
|
|
54
|
+
"url": "git+https://github.com/Jeecabs/gang.git"
|
|
55
|
+
},
|
|
56
|
+
"author": "Lachlan Jacobs",
|
|
57
|
+
"bugs": {
|
|
58
|
+
"url": "https://github.com/Jeecabs/gang/issues"
|
|
59
|
+
},
|
|
60
|
+
"homepage": "https://github.com/Jeecabs/gang#readme",
|
|
61
|
+
"publishConfig": {
|
|
62
|
+
"access": "public"
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export const SUPERINTENDENT_NAMED_EVENT = "gang:superintendent-named";
|
|
2
|
+
export const GANG_MEMBER_REPORT_EVENT = "gang:member-report";
|
|
3
|
+
|
|
4
|
+
export interface GangMemberReportEvent {
|
|
5
|
+
fromId: string;
|
|
6
|
+
fromName?: string;
|
|
7
|
+
text: string;
|
|
8
|
+
timestamp: number;
|
|
9
|
+
expectsReply: boolean;
|
|
10
|
+
replyTo?: string;
|
|
11
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import http from "http";
|
|
2
|
+
import { EventEmitter } from "events";
|
|
3
|
+
import { GUI_PORT } from "../intercom/gui/server.js";
|
|
4
|
+
|
|
5
|
+
/** A parsed event from the broker's SSE feed (same stream the browser dashboard consumes). */
|
|
6
|
+
export interface FeedEvent {
|
|
7
|
+
type: "snapshot" | "session_joined" | "session_left" | "presence_update" | "message";
|
|
8
|
+
[key: string]: unknown;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Consumes the broker's `/events` SSE stream in-process so the in-pi overlay renders the exact
|
|
13
|
+
* same feed as the browser GUI. Reconnects if the broker restarts. Emits:
|
|
14
|
+
* "event" (FeedEvent) — each parsed feed event
|
|
15
|
+
* "status" ("up" | "down") — connection state for the overlay's live indicator
|
|
16
|
+
*/
|
|
17
|
+
export class FeedClient extends EventEmitter {
|
|
18
|
+
private req?: http.ClientRequest;
|
|
19
|
+
private reconnectTimer?: NodeJS.Timeout;
|
|
20
|
+
private buf = "";
|
|
21
|
+
private stopped = false;
|
|
22
|
+
|
|
23
|
+
constructor(private readonly port: number = GUI_PORT) {
|
|
24
|
+
super();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
start(): this {
|
|
28
|
+
this.connect();
|
|
29
|
+
return this;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
private connect(): void {
|
|
33
|
+
if (this.stopped) return;
|
|
34
|
+
this.req = http.get({ host: "127.0.0.1", port: this.port, path: "/events" }, (res) => {
|
|
35
|
+
if (res.statusCode !== 200) {
|
|
36
|
+
res.destroy();
|
|
37
|
+
this.scheduleReconnect();
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
this.clearReconnectTimer();
|
|
41
|
+
this.emit("status", "up");
|
|
42
|
+
res.setEncoding("utf8");
|
|
43
|
+
res.on("data", (chunk: string) => this.onData(chunk));
|
|
44
|
+
res.on("close", () => this.scheduleReconnect());
|
|
45
|
+
});
|
|
46
|
+
this.req.on("error", () => this.scheduleReconnect());
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Parse SSE frames (separated by blank lines); ignore comment/keepalive lines. */
|
|
50
|
+
private onData(chunk: string): void {
|
|
51
|
+
this.buf += chunk;
|
|
52
|
+
let idx: number;
|
|
53
|
+
while ((idx = this.buf.indexOf("\n\n")) !== -1) {
|
|
54
|
+
const frame = this.buf.slice(0, idx);
|
|
55
|
+
this.buf = this.buf.slice(idx + 2);
|
|
56
|
+
const dataLine = frame.split("\n").find((l) => l.startsWith("data: "));
|
|
57
|
+
if (!dataLine) continue;
|
|
58
|
+
try {
|
|
59
|
+
this.emit("event", JSON.parse(dataLine.slice(6)) as FeedEvent);
|
|
60
|
+
} catch {
|
|
61
|
+
// ignore malformed frame
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private scheduleReconnect(): void {
|
|
67
|
+
if (this.stopped || this.reconnectTimer) return;
|
|
68
|
+
this.emit("status", "down");
|
|
69
|
+
this.buf = "";
|
|
70
|
+
this.reconnectTimer = setTimeout(() => {
|
|
71
|
+
this.reconnectTimer = undefined;
|
|
72
|
+
this.connect();
|
|
73
|
+
}, 1000);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private clearReconnectTimer(): void {
|
|
77
|
+
if (!this.reconnectTimer) return;
|
|
78
|
+
clearTimeout(this.reconnectTimer);
|
|
79
|
+
this.reconnectTimer = undefined;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
stop(): void {
|
|
83
|
+
this.stopped = true;
|
|
84
|
+
this.clearReconnectTimer();
|
|
85
|
+
this.req?.destroy();
|
|
86
|
+
this.removeAllListeners();
|
|
87
|
+
}
|
|
88
|
+
}
|