gcf-board 0.1.0 → 0.2.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 +19 -3
- package/dist/server/app.js +44 -8
- package/dist/server/desktop.js +67 -0
- package/dist/server/main.js +29 -4
- package/dist/server/moves.js +3 -0
- package/dist/server/runs.js +54 -9
- package/dist/web/assets/index-DIdkEhB_.js +28 -0
- package/dist/web/assets/{index-BxX6FkgG.css → index-k853kqAl.css} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-hRNpeQSH.js +0 -28
package/README.md
CHANGED
|
@@ -6,7 +6,8 @@ A local web board for game projects on [GCF](../gcf-aif-ext): the items by statu
|
|
|
6
6
|
|
|
7
7
|
- Node.js ≥ 22.18
|
|
8
8
|
- [`ai-factory`](https://www.npmjs.com/package/ai-factory) installed globally, and in each project [`gcf-aif-ext`](../gcf-aif-ext) ≥ 0.6 (`gcf board --json` with `schema: 1`)
|
|
9
|
-
- for runs: [`gcf-harness-aif-ext`](../gcf-harness-aif-ext) ≥ 0.
|
|
9
|
+
- for runs: [`gcf-harness-aif-ext`](../gcf-harness-aif-ext) ≥ 0.8 and Claude Code (`claude -p`)
|
|
10
|
+
- gcf-board 0.2 needs `gcf-aif-ext` ≥ 0.7 (priority, questions, session marks, `run_mode`)
|
|
10
11
|
|
|
11
12
|
## Run
|
|
12
13
|
|
|
@@ -16,7 +17,15 @@ gcf-board # http://127.0.0.1:4317
|
|
|
16
17
|
gcf-board --port 4400
|
|
17
18
|
```
|
|
18
19
|
|
|
19
|
-
It
|
|
20
|
+
It opens the board in your browser (the URL with the token, `http://127.0.0.1:4317/?t=<token>`, is printed too; `--no-open` skips the browser). Started again while it runs, it just opens the running board. Add a project by its folder (one that has `gcf/`) under **Projects**.
|
|
21
|
+
|
|
22
|
+
**Desktop shortcut**: run once
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
gcf-board shortcut # or: gcf-board shortcut --port 4400
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
It puts **GCF Board** on the desktop (Windows: a shortcut whose server console starts minimized; macOS: `GCF Board.command`; Linux: an application entry). Double-click it: the board starts and opens, or opens if it already runs. Run the command again after moving Node.js.
|
|
20
29
|
|
|
21
30
|
## What it does
|
|
22
31
|
|
|
@@ -28,7 +37,14 @@ It prints `http://127.0.0.1:4317/?t=<token>`: open that URL once. It sets a cook
|
|
|
28
37
|
- a card onto **graveyard** drops it.
|
|
29
38
|
|
|
30
39
|
Every other move is a button.
|
|
31
|
-
- **Run**: **run** on an item, or select ready cards and **Run selected**. It starts `ai-factory gcf-harness run <ids> --json
|
|
40
|
+
- **Run**: **run** on an item, or select ready cards and **Run selected**. It starts `ai-factory gcf-harness run <ids> --json` in the project's run mode:
|
|
41
|
+
- `run_mode: worktree`: one worktree and one agent session per item, all at once, then the integrator;
|
|
42
|
+
- `run_mode: main`: one item at a time in the project folder. A run asked for meanwhile waits in the board's **queue**; **remove from queue** takes it out.
|
|
43
|
+
|
|
44
|
+
A run asks first: it spends the subscription. **Stop** on a running item ends its session; the item stays ready and its work is kept. A queued main run that the next one would repeat (the harness did not start, or another run works the checkout) pauses the queue; the next run that works resumes it. The queue lives in the board's memory: a board restart forgets it.
|
|
45
|
+
- **Run next N independent**: the framework's gate picks the ready items that can run together now, by priority, and the board runs them after asking.
|
|
46
|
+
- **Priority**: drop a ready card on another to put it before; the column's ranks are committed (`gcf prioritize`).
|
|
47
|
+
- **Questions**: each D1 shows its question and proposed answer; **Answer** writes yours into its record (`gcf question answer`), and the next agent on its units puts it in place.
|
|
32
48
|
- **New item**: `gcf item new … --commit`. The item's `model` and `effort` go to its run.
|
|
33
49
|
- **Inbox**: add, erase, or turn an entry into an item. Open questions are listed there too.
|
|
34
50
|
- **Playtest**: `gcf playtest session` with the checklist grouped by subsystem; keep, tune or kill each item from the list. **Build** runs `dod.build`. A note goes to the inbox as `--source playtest`.
|
package/dist/server/app.js
CHANGED
|
@@ -13,7 +13,7 @@ import { followLog, readShown } from './files.js';
|
|
|
13
13
|
import { HttpError } from './http-error.js';
|
|
14
14
|
import { moveArgs, runIds } from './moves.js';
|
|
15
15
|
import { addProject, listProjects, projectOf, removeProject } from './registry.js';
|
|
16
|
-
import {
|
|
16
|
+
import { runsOf, startRun, unqueue } from './runs.js';
|
|
17
17
|
const send = (res, status, body) => {
|
|
18
18
|
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });
|
|
19
19
|
res.end(JSON.stringify(body));
|
|
@@ -83,6 +83,14 @@ export function createApp(o) {
|
|
|
83
83
|
watchers.set(p.name, watchProject(p));
|
|
84
84
|
};
|
|
85
85
|
const cli = (p, args, timeout) => runCli(o.entry, p.path, args, timeout);
|
|
86
|
+
/** `gcf board --json` of a project, refused when its schema is not the one this board reads. */
|
|
87
|
+
const boardOf = async (p) => {
|
|
88
|
+
const board = jsonOf(await cli(p, ['gcf', 'board', '--json']), 'gcf board');
|
|
89
|
+
if (board.schema !== BOARD_SCHEMA)
|
|
90
|
+
throw new HttpError(502, `gcf board gives schema ${board.schema ?? 'none'}, this board reads ${BOARD_SCHEMA}: ` +
|
|
91
|
+
'update gcf-aif-ext and gcf-board together');
|
|
92
|
+
return board;
|
|
93
|
+
};
|
|
86
94
|
const routes = [
|
|
87
95
|
['GET', /^\/api\/events$/, async (_req, res) => subscribe(res)],
|
|
88
96
|
['GET', /^\/api\/projects$/, async (_req, res) => send(res, 200, await listProjects())],
|
|
@@ -113,11 +121,9 @@ export function createApp(o) {
|
|
|
113
121
|
'GET',
|
|
114
122
|
/^\/api\/p\/([^/]+)\/board$/,
|
|
115
123
|
async (_req, res, m) => {
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
'update gcf-aif-ext and gcf-board together');
|
|
120
|
-
send(res, 200, board);
|
|
124
|
+
const p = await projectOf(m[1]);
|
|
125
|
+
// The board's own runs ride along: what it started and what waits in its queue.
|
|
126
|
+
send(res, 200, { ...(await boardOf(p)), runs: runsOf(p.name) });
|
|
121
127
|
},
|
|
122
128
|
],
|
|
123
129
|
[
|
|
@@ -167,14 +173,44 @@ export function createApp(o) {
|
|
|
167
173
|
[
|
|
168
174
|
'GET',
|
|
169
175
|
/^\/api\/p\/([^/]+)\/runs$/,
|
|
170
|
-
async (_req, res, m) => send(res, 200,
|
|
176
|
+
async (_req, res, m) => send(res, 200, runsOf((await projectOf(m[1])).name)),
|
|
171
177
|
],
|
|
172
178
|
[
|
|
173
179
|
'POST',
|
|
174
180
|
/^\/api\/p\/([^/]+)\/run$/,
|
|
175
181
|
async (req, res, m) => {
|
|
176
182
|
const p = await projectOf(m[1]);
|
|
177
|
-
|
|
183
|
+
const ids = runIds(await bodyOf(req));
|
|
184
|
+
send(res, 202, startRun(o.entry, p, ids, (await boardOf(p)).run_mode));
|
|
185
|
+
},
|
|
186
|
+
],
|
|
187
|
+
[
|
|
188
|
+
// The gate's pick: which ready items can run together now, by priority.
|
|
189
|
+
'GET',
|
|
190
|
+
/^\/api\/p\/([^/]+)\/next$/,
|
|
191
|
+
async (_req, res, m, url) => {
|
|
192
|
+
const count = String(Math.max(1, Math.min(20, Number(url.searchParams.get('count')) || 1)));
|
|
193
|
+
const r = await cli(await projectOf(m[1]), ['gcf-harness', 'next', count, '--json']);
|
|
194
|
+
send(res, 200, jsonOf(r, 'gcf-harness next'));
|
|
195
|
+
},
|
|
196
|
+
],
|
|
197
|
+
[
|
|
198
|
+
'POST',
|
|
199
|
+
/^\/api\/p\/([^/]+)\/stop$/,
|
|
200
|
+
async (req, res, m) => {
|
|
201
|
+
const p = await projectOf(m[1]);
|
|
202
|
+
const [id] = runIds({ ids: [(await bodyOf(req)).id] });
|
|
203
|
+
const r = await cli(p, ['gcf-harness', 'stop', id]);
|
|
204
|
+
broadcast({ type: 'board', project: p.name });
|
|
205
|
+
send(res, 200, { ok: r.ok, out: r.out.trim(), err: briefError(r.err) });
|
|
206
|
+
},
|
|
207
|
+
],
|
|
208
|
+
[
|
|
209
|
+
'DELETE',
|
|
210
|
+
/^\/api\/p\/([^/]+)\/queue\/([a-z0-9][a-z0-9-]*)$/,
|
|
211
|
+
async (_req, res, m) => {
|
|
212
|
+
unqueue((await projectOf(m[1])).name, m[2]);
|
|
213
|
+
send(res, 200, { ok: true });
|
|
178
214
|
},
|
|
179
215
|
],
|
|
180
216
|
];
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// The board without a console: open it in the browser, and a desktop shortcut that starts it.
|
|
2
|
+
import { chmodSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
const NAME = 'GCF Board';
|
|
7
|
+
/** Open a URL in the default browser; best effort, the URL is printed anyway. */
|
|
8
|
+
export function openInBrowser(url) {
|
|
9
|
+
const [cmd, args] = process.platform === 'win32'
|
|
10
|
+
? ['cmd', ['/c', 'start', '""', url]]
|
|
11
|
+
: process.platform === 'darwin'
|
|
12
|
+
? ['open', [url]]
|
|
13
|
+
: ['xdg-open', [url]];
|
|
14
|
+
try {
|
|
15
|
+
spawn(cmd, args, { detached: true, stdio: 'ignore', windowsHide: true }).unref();
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
// No browser to open: the operator opens the printed URL.
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/** A PowerShell single-quoted string: a quote inside is doubled. */
|
|
22
|
+
const psQuote = (s) => `'${s.replaceAll("'", "''")}'`;
|
|
23
|
+
export function shortcutPlan(platform, node, main, port) {
|
|
24
|
+
const args = ['--port', String(port)];
|
|
25
|
+
if (platform === 'win32')
|
|
26
|
+
return {
|
|
27
|
+
// A .lnk is a binary file: WScript.Shell writes it. Minimized: the server's console stays out
|
|
28
|
+
// of the way.
|
|
29
|
+
powershell: [
|
|
30
|
+
`$d = [Environment]::GetFolderPath('Desktop')`,
|
|
31
|
+
`$s = (New-Object -ComObject WScript.Shell).CreateShortcut((Join-Path $d '${NAME}.lnk'))`,
|
|
32
|
+
`$s.TargetPath = ${psQuote(node)}`,
|
|
33
|
+
`$s.Arguments = ${psQuote(`"${main}" ${args.join(' ')}`)}`,
|
|
34
|
+
`$s.WindowStyle = 7`,
|
|
35
|
+
`$s.Description = 'Start the GCF board and open it in the browser'`,
|
|
36
|
+
`$s.Save()`,
|
|
37
|
+
`Join-Path $d '${NAME}.lnk'`,
|
|
38
|
+
].join('; '),
|
|
39
|
+
};
|
|
40
|
+
const command = `"${node}" "${main}" ${args.join(' ')}`;
|
|
41
|
+
if (platform === 'darwin')
|
|
42
|
+
return {
|
|
43
|
+
file: path.join(os.homedir(), 'Desktop', `${NAME}.command`),
|
|
44
|
+
text: `#!/bin/sh\nexec ${command}\n`,
|
|
45
|
+
};
|
|
46
|
+
return {
|
|
47
|
+
file: path.join(os.homedir(), '.local', 'share', 'applications', 'gcf-board.desktop'),
|
|
48
|
+
text: `[Desktop Entry]\nType=Application\nName=${NAME}\nExec=${command}\nTerminal=false\n` +
|
|
49
|
+
'Categories=Development;\n',
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/** Write the shortcut for this machine; returns where it is. */
|
|
53
|
+
export function createShortcut(main, port) {
|
|
54
|
+
const plan = shortcutPlan(process.platform, process.execPath, main, port);
|
|
55
|
+
if ('powershell' in plan) {
|
|
56
|
+
const r = spawnSync('powershell', ['-NoProfile', '-Command', plan.powershell], {
|
|
57
|
+
encoding: 'utf8',
|
|
58
|
+
});
|
|
59
|
+
if (r.status !== 0)
|
|
60
|
+
throw new Error(`the shortcut was not made: ${r.stderr.trim()}`);
|
|
61
|
+
return r.stdout.trim();
|
|
62
|
+
}
|
|
63
|
+
mkdirSync(path.dirname(plan.file), { recursive: true });
|
|
64
|
+
writeFileSync(plan.file, plan.text);
|
|
65
|
+
chmodSync(plan.file, 0o755);
|
|
66
|
+
return plan.file;
|
|
67
|
+
}
|
package/dist/server/main.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// `gcf-board [--port <n>]`: the board on http://127.0.0.1:<port>, 4317 by default
|
|
3
|
-
//
|
|
2
|
+
// `gcf-board [--port <n>] [--no-open]`: the board on http://127.0.0.1:<port>, 4317 by default,
|
|
3
|
+
// opened in the browser with this machine's token. A board already on the port is opened, not
|
|
4
|
+
// started twice. `gcf-board shortcut [--port <n>]` puts a desktop shortcut that does the same.
|
|
4
5
|
import { existsSync } from 'node:fs';
|
|
5
6
|
import path from 'node:path';
|
|
6
7
|
import { fileURLToPath } from 'node:url';
|
|
@@ -8,6 +9,7 @@ import { parseArgs } from 'node:util';
|
|
|
8
9
|
import { boardToken } from './auth.js';
|
|
9
10
|
import { aiFactoryEntry } from './cli.js';
|
|
10
11
|
import { createApp } from './app.js';
|
|
12
|
+
import { createShortcut, openInBrowser } from './desktop.js';
|
|
11
13
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
12
14
|
/**
|
|
13
15
|
* The built page: `dist/web` beside `server/` when run from the source (the source `web/` is
|
|
@@ -20,14 +22,37 @@ function webDir() {
|
|
|
20
22
|
: path.resolve(HERE, '..', 'web');
|
|
21
23
|
}
|
|
22
24
|
async function main() {
|
|
23
|
-
const { values } = parseArgs({
|
|
25
|
+
const { values, positionals } = parseArgs({
|
|
26
|
+
allowPositionals: true,
|
|
27
|
+
options: {
|
|
28
|
+
port: { type: 'string', default: '4317' },
|
|
29
|
+
'no-open': { type: 'boolean', default: false },
|
|
30
|
+
},
|
|
31
|
+
});
|
|
24
32
|
const port = Number(values.port);
|
|
25
33
|
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
|
26
34
|
throw new Error('--port is a number from 1 to 65535');
|
|
35
|
+
if (positionals[0] === 'shortcut') {
|
|
36
|
+
console.log(`gcf-board: shortcut ${createShortcut(fileURLToPath(import.meta.url), port)}`);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (positionals.length)
|
|
40
|
+
throw new Error(`unknown command: ${positionals.join(' ')}`);
|
|
27
41
|
const token = await boardToken();
|
|
42
|
+
const url = `http://127.0.0.1:${port}/?t=${token}`;
|
|
43
|
+
const open = () => !values['no-open'] && openInBrowser(url);
|
|
28
44
|
const app = createApp({ entry: aiFactoryEntry(), token, port, web: webDir() });
|
|
45
|
+
app.on('error', (e) => {
|
|
46
|
+
if (e.code !== 'EADDRINUSE')
|
|
47
|
+
throw e;
|
|
48
|
+
// A second start, from the shortcut most likely: the board runs already, show it.
|
|
49
|
+
console.log(`gcf-board: already running on port ${port}, opening it`);
|
|
50
|
+
open();
|
|
51
|
+
setTimeout(() => process.exit(0), 500);
|
|
52
|
+
});
|
|
29
53
|
app.listen(port, '127.0.0.1', () => {
|
|
30
|
-
console.log(`gcf-board:
|
|
54
|
+
console.log(`gcf-board: ${url}`);
|
|
55
|
+
open();
|
|
31
56
|
});
|
|
32
57
|
}
|
|
33
58
|
main().catch((e) => {
|
package/dist/server/moves.js
CHANGED
|
@@ -105,6 +105,9 @@ const MOVES = {
|
|
|
105
105
|
],
|
|
106
106
|
'inbox-drop': (b) => ['inbox', 'drop', id(b), '--commit'],
|
|
107
107
|
'phase-next': (b) => ['phase', 'next', ...opt(b, 'reason')],
|
|
108
|
+
// A column's new order after a drag: ranks 1, 2, 3… in one commit.
|
|
109
|
+
prioritize: (b) => ['prioritize', ...runIds(b)],
|
|
110
|
+
'question-answer': (b) => ['question', 'answer', id(b), `--text=${required(b, 'text')}`],
|
|
108
111
|
};
|
|
109
112
|
/** The `ai-factory` arguments of a move, or a 400 naming what is wrong. */
|
|
110
113
|
export function moveArgs(b) {
|
package/dist/server/runs.js
CHANGED
|
@@ -1,19 +1,42 @@
|
|
|
1
1
|
// Runs the board started: `ai-factory gcf-harness run <ids> --json`, one process per batch, held
|
|
2
2
|
// by the server until every agent session ends. A run started from a console shows on the
|
|
3
|
-
// board too (its lock makes the item `running`); only its
|
|
3
|
+
// board too (its lock or session mark makes the item `running`); only its report is not here.
|
|
4
|
+
// In `run_mode: main` the checkout takes one run at a time: a run asked for meanwhile waits in
|
|
5
|
+
// the project's queue and starts when the one before it ends.
|
|
4
6
|
import { spawn } from 'node:child_process';
|
|
5
7
|
import { broadcast } from './events.js';
|
|
6
8
|
import { HttpError } from './http-error.js';
|
|
7
9
|
// A board that stops leaves its runs going: killing one would lose its integrate step.
|
|
8
10
|
const active = new Set();
|
|
11
|
+
const queues = new Map();
|
|
9
12
|
export const activeRuns = (project) => [...active].filter((r) => r.project === project);
|
|
10
|
-
/**
|
|
11
|
-
export
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
13
|
+
/** What runs and what waits, for a project. */
|
|
14
|
+
export const runsOf = (project) => ({
|
|
15
|
+
active: activeRuns(project),
|
|
16
|
+
queued: (queues.get(project) ?? []).flatMap((q) => q.ids),
|
|
17
|
+
});
|
|
18
|
+
/** Take an item out of the queue before its run starts. */
|
|
19
|
+
export function unqueue(project, id) {
|
|
20
|
+
const queue = (queues.get(project) ?? [])
|
|
21
|
+
.map((q) => ({ ...q, ids: q.ids.filter((x) => x !== id) }))
|
|
22
|
+
.filter((q) => q.ids.length);
|
|
23
|
+
queues.set(project, queue);
|
|
24
|
+
broadcast({ type: 'board', project });
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Whether a main run ended in a way the next one would repeat: no report at all (the harness
|
|
28
|
+
* did not start), or every item refused because another run works the checkout. The queue
|
|
29
|
+
* then waits instead of draining into the same error; a run started by hand that works
|
|
30
|
+
* resumes it.
|
|
31
|
+
*/
|
|
32
|
+
export function stalled(ok, result) {
|
|
33
|
+
if (!ok && result === null)
|
|
34
|
+
return true;
|
|
35
|
+
const items = result?.items ?? [];
|
|
36
|
+
return (items.length > 0 && items.every((i) => /another run works in the main/.test(i.error ?? '')));
|
|
37
|
+
}
|
|
38
|
+
function spawnRun({ entry, p, ids }, mode) {
|
|
39
|
+
const run = { project: p.name, ids, started: new Date().toISOString(), mode };
|
|
17
40
|
active.add(run);
|
|
18
41
|
const child = spawn(process.execPath, [entry, 'gcf-harness', 'run', ...ids, '--json'], {
|
|
19
42
|
cwd: p.path,
|
|
@@ -37,10 +60,32 @@ export function startRun(entry, p, ids) {
|
|
|
37
60
|
catch {
|
|
38
61
|
// No report: the error says why.
|
|
39
62
|
}
|
|
40
|
-
|
|
63
|
+
const paused = mode === 'main' && queues.get(p.name)?.length && stalled(ok, result);
|
|
64
|
+
const why = paused ? '\nthe queue is paused: the next run starts after a run that works' : '';
|
|
65
|
+
broadcast({ type: 'run', project: p.name, ids, ok, result, err: (err + message + why).trim() });
|
|
41
66
|
broadcast({ type: 'board', project: p.name });
|
|
67
|
+
const next = mode === 'main' && !paused ? queues.get(p.name)?.shift() : undefined;
|
|
68
|
+
if (next)
|
|
69
|
+
spawnRun(next, next.mode);
|
|
42
70
|
};
|
|
43
71
|
child.on('error', (e) => end(false, e.message));
|
|
44
72
|
child.on('close', (code) => end(code === 0));
|
|
45
73
|
return run;
|
|
46
74
|
}
|
|
75
|
+
/**
|
|
76
|
+
* Start a batch in the project's run mode, or queue it behind the main run going on. Its end
|
|
77
|
+
* is a `run` event with the harness report, or what went wrong.
|
|
78
|
+
*/
|
|
79
|
+
export function startRun(entry, p, ids, mode = 'worktree') {
|
|
80
|
+
// A second click on the same card: the harness would refuse it on its lock, noisily.
|
|
81
|
+
const { active: going, queued } = runsOf(p.name);
|
|
82
|
+
const busy = ids.filter((id) => queued.includes(id) || going.some((r) => r.ids.includes(id)));
|
|
83
|
+
if (busy.length)
|
|
84
|
+
throw new HttpError(409, `already running from this board: ${busy.join(', ')}`);
|
|
85
|
+
if (mode === 'main' && going.some((r) => r.mode === 'main')) {
|
|
86
|
+
queues.set(p.name, [...(queues.get(p.name) ?? []), { entry, p, ids, mode }]);
|
|
87
|
+
broadcast({ type: 'board', project: p.name });
|
|
88
|
+
return { queued: ids };
|
|
89
|
+
}
|
|
90
|
+
return { started: spawnRun({ entry, p, ids, mode }, mode) };
|
|
91
|
+
}
|