@yegor256/jinka 0.0.0-stage → 0.0.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/LICENSE.txt +21 -0
- package/README.md +32 -2
- package/bin/jinka.js +61 -0
- package/package.json +32 -4
- package/plugin/.claude-plugin/plugin.json +8 -0
- package/plugin/skills/accept-pull-request/SKILL.md +18 -0
- package/plugin/skills/clean-up-backlog/SKILL.md +20 -0
- package/plugin/skills/triage-an-issue/SKILL.md +18 -0
- package/src/archived.js +42 -0
- package/src/claude.js +60 -0
- package/src/duration.js +21 -0
- package/src/event.js +47 -0
- package/src/jinka.js +23 -0
- package/src/log.js +25 -0
- package/src/permanent.js +33 -0
- package/src/row.js +17 -0
- package/src/status.js +19 -0
- package/src/tty.js +75 -0
- package/src/wand.js +45 -0
- package/src/wish.js +34 -0
- package/src/wishes.js +51 -0
package/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
(The MIT License)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yegor Bugayenko
|
|
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/README.md
CHANGED
|
@@ -1,3 +1,33 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Simple Command Line Orchestrator of Agents
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://github.com/yegor256/jinka/actions/workflows/test.yml)
|
|
4
|
+
[](https://github.com/yegor256/jinka/blob/master/LICENSE.txt)
|
|
5
|
+
|
|
6
|
+
First, you create a directory with your wishes, one per Markdown file.
|
|
7
|
+
You can start with a single wish:
|
|
8
|
+
|
|
9
|
+
```text
|
|
10
|
+
my-wishes/
|
|
11
|
+
make-me-happy.md
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
In the `make-me-happy.md` file you say something like this:
|
|
15
|
+
|
|
16
|
+
```text
|
|
17
|
+
I wish my GitHub repositories were in good shape: `foo/bar` and `foo/bar2`.
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Then, you install `jinka`:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
npm install -g @yegor256/jinka
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Finally, you run it, giving it the path to your directory with wishes:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
jinka my-wishes/
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
That's it.
|
|
33
|
+
Your wish will come true.
|
package/bin/jinka.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// SPDX-FileCopyrightText: Copyright (c) 2026 Yegor Bugayenko
|
|
3
|
+
// SPDX-License-Identifier: MIT
|
|
4
|
+
|
|
5
|
+
import { parseArgs } from 'node:util';
|
|
6
|
+
import { readFile } from 'node:fs/promises';
|
|
7
|
+
import { resolve, join, dirname } from 'node:path';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { Jinka } from '../src/jinka.js';
|
|
10
|
+
import { Wishes } from '../src/wishes.js';
|
|
11
|
+
import { Claude } from '../src/claude.js';
|
|
12
|
+
import { Tty } from '../src/tty.js';
|
|
13
|
+
import { Log } from '../src/log.js';
|
|
14
|
+
import { Status } from '../src/status.js';
|
|
15
|
+
|
|
16
|
+
const { values, positionals } = parseArgs({
|
|
17
|
+
allowPositionals: true,
|
|
18
|
+
options: {
|
|
19
|
+
threads: { type: 'string', default: '8' },
|
|
20
|
+
archive: { type: 'string', default: '' },
|
|
21
|
+
agent: { type: 'string', default: 'claude' },
|
|
22
|
+
interval: { type: 'string', default: '5000' },
|
|
23
|
+
model: { type: 'string', default: '' }
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
if (positionals.length !== 1) {
|
|
27
|
+
console.error('Usage: jinka [--threads 8] [--archive DIR] [--agent claude] [--interval 5000] [--model MODEL] WISHES');
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
const threads = Number(values.threads);
|
|
31
|
+
const interval = Number(values.interval);
|
|
32
|
+
if (!Number.isInteger(threads) || threads < 1 || !Number.isInteger(interval) || interval < 1) {
|
|
33
|
+
console.error(`The --threads and --interval options must be positive integers, but got "${values.threads}" and "${values.interval}"`);
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
const dir = resolve(positionals[0]);
|
|
37
|
+
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
38
|
+
const screen = process.stdout.isTTY
|
|
39
|
+
? new Tty(process.stdout, threads, new Status(JSON.parse(await readFile(join(root, 'package.json'), 'utf8')).version, Date.now()))
|
|
40
|
+
: new Log(process.stdout);
|
|
41
|
+
process.on('exit', () => screen.close());
|
|
42
|
+
const controller = new AbortController();
|
|
43
|
+
process.on('SIGINT', () => {
|
|
44
|
+
if (controller.signal.aborted) {
|
|
45
|
+
process.exit(130);
|
|
46
|
+
}
|
|
47
|
+
controller.abort();
|
|
48
|
+
});
|
|
49
|
+
try {
|
|
50
|
+
await new Jinka(
|
|
51
|
+
new Wishes(dir, interval, values.archive === '' ? '' : resolve(values.archive)),
|
|
52
|
+
new Claude(values.agent, join(root, 'plugin'), dir, values.model),
|
|
53
|
+
screen,
|
|
54
|
+
threads
|
|
55
|
+
).run(controller.signal);
|
|
56
|
+
process.exit(130);
|
|
57
|
+
} catch (e) {
|
|
58
|
+
screen.close();
|
|
59
|
+
console.error(e.message);
|
|
60
|
+
process.exit(1);
|
|
61
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,34 @@
|
|
|
1
1
|
{
|
|
2
|
+
"author": "Yegor Bugayenko",
|
|
3
|
+
"bin": {
|
|
4
|
+
"jinka": "bin/jinka.js"
|
|
5
|
+
},
|
|
6
|
+
"description": "Simple command line orchestrator of coding agents",
|
|
7
|
+
"devDependencies": {
|
|
8
|
+
"@eslint/js": "^10.0.1",
|
|
9
|
+
"c8": "^12.0.0",
|
|
10
|
+
"eslint": "^10.13.0",
|
|
11
|
+
"globals": "^17.13.0",
|
|
12
|
+
"hamjest": "^4.1.1",
|
|
13
|
+
"mocha": "^12.0.3"
|
|
14
|
+
},
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=20.12"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"bin",
|
|
20
|
+
"src",
|
|
21
|
+
"plugin"
|
|
22
|
+
],
|
|
23
|
+
"license": "MIT",
|
|
2
24
|
"name": "@yegor256/jinka",
|
|
3
|
-
"
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
}
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "git+https://github.com/yegor256/jinka.git"
|
|
28
|
+
},
|
|
29
|
+
"scripts": {
|
|
30
|
+
"test": "c8 --check-coverage --lines 80 mocha"
|
|
31
|
+
},
|
|
32
|
+
"type": "module",
|
|
33
|
+
"version": "0.0.1"
|
|
34
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: accept-pull-request
|
|
3
|
+
description: Use this skill when a wish asks to review and accept one GitHub pull request.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Accept a Pull Request
|
|
7
|
+
|
|
8
|
+
<!--
|
|
9
|
+
@todo #2:60min Design the accept-pull-request skill properly.
|
|
10
|
+
This draft only lists the basic steps, but it should explain how deep
|
|
11
|
+
the review must be, what to do with failing checks, and which merge
|
|
12
|
+
method to use.
|
|
13
|
+
-->
|
|
14
|
+
|
|
15
|
+
Read the pull request with `gh pr view` and its diff with `gh pr diff`.
|
|
16
|
+
Check that all its CI checks are green with `gh pr checks`.
|
|
17
|
+
If the change is correct and the checks pass, merge it with `gh pr merge`.
|
|
18
|
+
Otherwise, explain in a review comment what has to change.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: clean-up-backlog
|
|
3
|
+
description: Use this skill when a wish asks to keep the backlog of a GitHub repository in good shape.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Clean Up the Backlog
|
|
7
|
+
|
|
8
|
+
<!--
|
|
9
|
+
@todo #2:60min Design the clean-up-backlog skill properly.
|
|
10
|
+
This draft picks any issue or pull request, but it should prefer the
|
|
11
|
+
oldest ones, skip those already covered by an existing wish, and
|
|
12
|
+
limit how many new wishes it writes at once.
|
|
13
|
+
-->
|
|
14
|
+
|
|
15
|
+
List open issues with `gh issue list` and pull requests with `gh pr list`.
|
|
16
|
+
Pick one of them that nobody has touched for a while.
|
|
17
|
+
Write a new Markdown file into the wishes directory given in the prompt.
|
|
18
|
+
In that file, ask to triage the issue or to accept the pull request,
|
|
19
|
+
naming the repository and the number.
|
|
20
|
+
Name the file after them, for example `foo-bar-pr-42.md`.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: triage-an-issue
|
|
3
|
+
description: Use this skill when a wish asks to triage one GitHub issue.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Triage an Issue
|
|
7
|
+
|
|
8
|
+
<!--
|
|
9
|
+
@todo #2:60min Design the triage-an-issue skill properly.
|
|
10
|
+
This draft only lists the basic steps, but it should explain how to
|
|
11
|
+
judge whether an issue is clear, which labels to use, and when to
|
|
12
|
+
close it as a duplicate.
|
|
13
|
+
-->
|
|
14
|
+
|
|
15
|
+
Read the issue with `gh issue view`, including all its comments.
|
|
16
|
+
Decide whether it describes a real problem or a clear request.
|
|
17
|
+
If it does, label it and say in a comment what should be done next.
|
|
18
|
+
If it does not, ask its author one precise question in a comment.
|
package/src/archived.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) 2026 Yegor Bugayenko
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { copyFile, mkdir, access } from 'node:fs/promises';
|
|
5
|
+
import { join, dirname, extname } from 'node:path';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A wish that goes into the archive directory instead of disappearing.
|
|
9
|
+
* The archived copy keeps the subdirectory of the wish, and gets a
|
|
10
|
+
* timestamp in its name when a file with the same name is already there.
|
|
11
|
+
*/
|
|
12
|
+
export class Archived {
|
|
13
|
+
constructor(origin, dir, archive) {
|
|
14
|
+
this.origin = origin;
|
|
15
|
+
this.dir = dir;
|
|
16
|
+
this.archive = archive;
|
|
17
|
+
}
|
|
18
|
+
name() {
|
|
19
|
+
return this.origin.name();
|
|
20
|
+
}
|
|
21
|
+
async text() {
|
|
22
|
+
return this.origin.text();
|
|
23
|
+
}
|
|
24
|
+
fate() {
|
|
25
|
+
return this.origin.fate();
|
|
26
|
+
}
|
|
27
|
+
async close() {
|
|
28
|
+
await copyFile(join(this.dir, this.origin.name()), await this.#target());
|
|
29
|
+
await this.origin.close();
|
|
30
|
+
}
|
|
31
|
+
free() {
|
|
32
|
+
this.origin.free();
|
|
33
|
+
}
|
|
34
|
+
async #target() {
|
|
35
|
+
const target = join(this.archive, this.origin.name());
|
|
36
|
+
await mkdir(dirname(target), { recursive: true });
|
|
37
|
+
return access(target).then(
|
|
38
|
+
() => `${target.slice(0, target.length - extname(target).length)}-${Date.now()}${extname(target)}`,
|
|
39
|
+
() => target
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
}
|
package/src/claude.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) 2026 Yegor Bugayenko
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import { createInterface } from 'node:readline';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { Event } from './event.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The coding agent that makes one wish come true.
|
|
11
|
+
* It starts the agent process in headless mode, writes the prompt
|
|
12
|
+
* to its standard input, and reports every event of its stream-json
|
|
13
|
+
* output to a screen row. The model, when not empty, is passed
|
|
14
|
+
* to the agent verbatim. The process gets SIGTERM when the signal
|
|
15
|
+
* aborts.
|
|
16
|
+
*/
|
|
17
|
+
export class Claude {
|
|
18
|
+
constructor(command, plugin, dir, model) {
|
|
19
|
+
this.command = command;
|
|
20
|
+
this.plugin = plugin;
|
|
21
|
+
this.dir = dir;
|
|
22
|
+
this.model = model;
|
|
23
|
+
}
|
|
24
|
+
async run(wish, row, signal) {
|
|
25
|
+
const prompt = [
|
|
26
|
+
'Make this wish come true, working on your own without asking any questions:',
|
|
27
|
+
'',
|
|
28
|
+
await wish.text(),
|
|
29
|
+
'',
|
|
30
|
+
`The wish is written in the file ${join(this.dir, wish.name())}, ${wish.fate()}.`,
|
|
31
|
+
`All wishes live in the directory ${this.dir}, and you may add new wishes there as Markdown files.`
|
|
32
|
+
].join('\n');
|
|
33
|
+
const agent = spawn(
|
|
34
|
+
this.command,
|
|
35
|
+
[
|
|
36
|
+
'-p', '--output-format', 'stream-json', '--verbose',
|
|
37
|
+
'--plugin-dir', this.plugin, '--add-dir', this.dir,
|
|
38
|
+
'--dangerously-skip-permissions',
|
|
39
|
+
...(this.model === '' ? [] : ['--model', this.model])
|
|
40
|
+
],
|
|
41
|
+
{ signal, stdio: ['pipe', 'pipe', 'ignore'] }
|
|
42
|
+
);
|
|
43
|
+
agent.stdin.on('error', () => {});
|
|
44
|
+
agent.stdin.end(prompt);
|
|
45
|
+
createInterface({ input: agent.stdout }).on('line', (line) => {
|
|
46
|
+
const text = new Event(line).text();
|
|
47
|
+
if (text !== '') {
|
|
48
|
+
row.show(text);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
return new Promise((resolve, reject) => {
|
|
52
|
+
agent.on('error', (e) => {
|
|
53
|
+
if (!signal.aborted) {
|
|
54
|
+
reject(new Error(`Cannot start the agent "${this.command}": ${e.message}`));
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
agent.on('close', (code) => resolve(code ?? 1));
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
}
|
package/src/duration.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) 2026 Yegor Bugayenko
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A span of time, printed the way a clock prints it.
|
|
6
|
+
* It drops the milliseconds and pads every part with zeros, either as
|
|
7
|
+
* minutes and seconds, or as hours, minutes and seconds.
|
|
8
|
+
*/
|
|
9
|
+
export class Duration {
|
|
10
|
+
constructor(millis) {
|
|
11
|
+
this.millis = millis;
|
|
12
|
+
}
|
|
13
|
+
minutes() {
|
|
14
|
+
const seconds = Math.floor(this.millis / 1000);
|
|
15
|
+
return [Math.floor(seconds / 60), seconds % 60].map((p) => String(p).padStart(2, '0')).join(':');
|
|
16
|
+
}
|
|
17
|
+
hours() {
|
|
18
|
+
const seconds = Math.floor(this.millis / 1000);
|
|
19
|
+
return [Math.floor(seconds / 3600), Math.floor(seconds / 60) % 60, seconds % 60].map((p) => String(p).padStart(2, '0')).join(':');
|
|
20
|
+
}
|
|
21
|
+
}
|
package/src/event.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) 2026 Yegor Bugayenko
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* One line of the stream-json output of the agent.
|
|
6
|
+
* It turns the line into a short phrase that fits into one screen line,
|
|
7
|
+
* such as the name of a tool with its main argument, or the first line
|
|
8
|
+
* of what the agent said. Lines that carry nothing worth showing
|
|
9
|
+
* become an empty phrase.
|
|
10
|
+
*/
|
|
11
|
+
export class Event {
|
|
12
|
+
constructor(line) {
|
|
13
|
+
this.line = line;
|
|
14
|
+
}
|
|
15
|
+
text() {
|
|
16
|
+
try {
|
|
17
|
+
return this.#phrase(JSON.parse(this.line));
|
|
18
|
+
} catch {
|
|
19
|
+
return this.line.trim();
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
#phrase(json) {
|
|
23
|
+
if (json.type === 'assistant') {
|
|
24
|
+
return (json.message?.content ?? []).map((part) => this.#part(part)).filter((p) => p !== '').join(' ');
|
|
25
|
+
}
|
|
26
|
+
if (json.type === 'result') {
|
|
27
|
+
return this.#first(String(json.result ?? json.subtype ?? ''));
|
|
28
|
+
}
|
|
29
|
+
return '';
|
|
30
|
+
}
|
|
31
|
+
#part(part) {
|
|
32
|
+
if (part.type === 'text') {
|
|
33
|
+
return this.#first(part.text);
|
|
34
|
+
}
|
|
35
|
+
if (part.type === 'tool_use') {
|
|
36
|
+
const input = part.input ?? {};
|
|
37
|
+
return [part.name, input.command ?? input.file_path ?? input.pattern ?? input.url ?? input.query ?? input.skill ?? input.description]
|
|
38
|
+
.filter((p) => p !== undefined)
|
|
39
|
+
.map((p) => this.#first(String(p)))
|
|
40
|
+
.join(': ');
|
|
41
|
+
}
|
|
42
|
+
return '';
|
|
43
|
+
}
|
|
44
|
+
#first(text) {
|
|
45
|
+
return text.split('\n').map((l) => l.trim()).find((l) => l !== '') ?? '';
|
|
46
|
+
}
|
|
47
|
+
}
|
package/src/jinka.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) 2026 Yegor Bugayenko
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { Wand } from './wand.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The application, which keeps several wands busy with wishes.
|
|
8
|
+
* Each wand gets its own screen row and protocol, and all of them share one
|
|
9
|
+
* directory of wishes and one agent, until the signal aborts.
|
|
10
|
+
*/
|
|
11
|
+
export class Jinka {
|
|
12
|
+
constructor(wishes, agent, screen, threads) {
|
|
13
|
+
this.wishes = wishes;
|
|
14
|
+
this.agent = agent;
|
|
15
|
+
this.screen = screen;
|
|
16
|
+
this.threads = threads;
|
|
17
|
+
}
|
|
18
|
+
async run(signal) {
|
|
19
|
+
await Promise.all(
|
|
20
|
+
Array.from({ length: this.threads }, (_, i) => new Wand(this.wishes, this.agent, this.screen.row(i), this.screen.protocol(i)).run(signal))
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
}
|
package/src/log.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) 2026 Yegor Bugayenko
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { Row } from './row.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The screen for anything that is not a terminal, such as CI or a pipe.
|
|
8
|
+
* It prints every change of every row and every protocol line
|
|
9
|
+
* as a new plain line, without any cursor movement.
|
|
10
|
+
*/
|
|
11
|
+
export class Log {
|
|
12
|
+
constructor(stream) {
|
|
13
|
+
this.stream = stream;
|
|
14
|
+
}
|
|
15
|
+
row(index) {
|
|
16
|
+
return new Row({ push: ([i, text]) => this.stream.write(`[${i + 1}] ${text.replace('\t', ' ')}\n`) }, index);
|
|
17
|
+
}
|
|
18
|
+
protocol(index) {
|
|
19
|
+
return new Row(
|
|
20
|
+
{ push: ([i, text]) => this.stream.write(`${new Date().toTimeString().slice(0, 8)} [${i + 1}] ${text.replace('\t', ' ')}\n`) },
|
|
21
|
+
index
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
close() {}
|
|
25
|
+
}
|
package/src/permanent.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) 2026 Yegor Bugayenko
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A wish that was already in the directory when jinka started.
|
|
6
|
+
* It describes ongoing work, so its file is never deleted: closing it
|
|
7
|
+
* only gives its claim back, sending it to the end of the queue.
|
|
8
|
+
* @todo #10:45min A permanent wish goes back to the queue right after
|
|
9
|
+
* a successful run, so a directory with a single permanent wish makes
|
|
10
|
+
* the agent work on it without any pause, burning the subscription.
|
|
11
|
+
* Let us decide whether a permanent wish must wait some time after
|
|
12
|
+
* a success before it is tried again, and implement that delay.
|
|
13
|
+
*/
|
|
14
|
+
export class Permanent {
|
|
15
|
+
constructor(origin) {
|
|
16
|
+
this.origin = origin;
|
|
17
|
+
}
|
|
18
|
+
name() {
|
|
19
|
+
return this.origin.name();
|
|
20
|
+
}
|
|
21
|
+
async text() {
|
|
22
|
+
return this.origin.text();
|
|
23
|
+
}
|
|
24
|
+
fate() {
|
|
25
|
+
return 'which stays there after you exit, so you must not delete it';
|
|
26
|
+
}
|
|
27
|
+
async close() {
|
|
28
|
+
this.origin.free();
|
|
29
|
+
}
|
|
30
|
+
free() {
|
|
31
|
+
this.origin.free();
|
|
32
|
+
}
|
|
33
|
+
}
|
package/src/row.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) 2026 Yegor Bugayenko
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* One line of the screen, owned by one wand.
|
|
6
|
+
* Every text it receives goes to the screen together with the index
|
|
7
|
+
* of the line, so the screen knows which line to change.
|
|
8
|
+
*/
|
|
9
|
+
export class Row {
|
|
10
|
+
constructor(lines, index) {
|
|
11
|
+
this.lines = lines;
|
|
12
|
+
this.index = index;
|
|
13
|
+
}
|
|
14
|
+
show(text) {
|
|
15
|
+
this.lines.push([this.index, text]);
|
|
16
|
+
}
|
|
17
|
+
}
|
package/src/status.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) 2026 Yegor Bugayenko
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { Duration } from './duration.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The top line of the terminal screen.
|
|
8
|
+
* It tells which version of jinka is running and how long it has been
|
|
9
|
+
* alive since the moment it started.
|
|
10
|
+
*/
|
|
11
|
+
export class Status {
|
|
12
|
+
constructor(version, start) {
|
|
13
|
+
this.version = version;
|
|
14
|
+
this.start = start;
|
|
15
|
+
}
|
|
16
|
+
text() {
|
|
17
|
+
return `jinka ${this.version}, up ${new Duration(Date.now() - this.start).hours()}`;
|
|
18
|
+
}
|
|
19
|
+
}
|
package/src/tty.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) 2026 Yegor Bugayenko
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { Row } from './row.js';
|
|
5
|
+
import { Duration } from './duration.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The screen for terminals, which takes over the whole terminal like top.
|
|
9
|
+
* It switches to the alternate buffer and draws a fixed layout from the
|
|
10
|
+
* top left corner: the status line, one line per wand with the time it
|
|
11
|
+
* has been busy with its current wish, a separator, and the newest
|
|
12
|
+
* protocol lines that fit below it. The layout is redrawn on every change,
|
|
13
|
+
* every second, and on every resize, until the screen is closed and the
|
|
14
|
+
* normal buffer comes back.
|
|
15
|
+
*/
|
|
16
|
+
export class Tty {
|
|
17
|
+
constructor(stream, threads, status) {
|
|
18
|
+
this.stream = stream;
|
|
19
|
+
this.status = status;
|
|
20
|
+
this.lines = Array.from({ length: threads }, () => ({ head: '', tail: '', start: Date.now() }));
|
|
21
|
+
this.notes = [];
|
|
22
|
+
this.clock = [];
|
|
23
|
+
}
|
|
24
|
+
row(index) {
|
|
25
|
+
return new Row({ push: ([i, text]) => this.#update(i, text) }, index);
|
|
26
|
+
}
|
|
27
|
+
protocol(index) {
|
|
28
|
+
return new Row({ push: ([i, text]) => this.#note(i, text) }, index);
|
|
29
|
+
}
|
|
30
|
+
close() {
|
|
31
|
+
this.clock.splice(0).forEach((c) => {
|
|
32
|
+
clearInterval(c);
|
|
33
|
+
this.stream.write('\x1b[?25h\x1b[?1049l');
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
#update(index, text) {
|
|
37
|
+
const [head, tail = ''] = text.split('\t');
|
|
38
|
+
this.lines[index] = {
|
|
39
|
+
head,
|
|
40
|
+
tail,
|
|
41
|
+
start: head === this.lines[index].head ? this.lines[index].start : Date.now()
|
|
42
|
+
};
|
|
43
|
+
this.#paint();
|
|
44
|
+
}
|
|
45
|
+
#note(index, text) {
|
|
46
|
+
this.notes.push(`${new Date().toTimeString().slice(0, 8)} [${index + 1}] ${text.replace('\t', ' ')}`);
|
|
47
|
+
this.#paint();
|
|
48
|
+
}
|
|
49
|
+
#paint() {
|
|
50
|
+
if (this.clock.length === 0) {
|
|
51
|
+
this.stream.write('\x1b[?1049h\x1b[?25l');
|
|
52
|
+
this.clock.push(setInterval(() => this.#draw(), 1000).unref());
|
|
53
|
+
this.stream.on('resize', () => {
|
|
54
|
+
if (this.clock.length > 0) {
|
|
55
|
+
this.#draw();
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
this.#draw();
|
|
60
|
+
}
|
|
61
|
+
#draw() {
|
|
62
|
+
this.notes.splice(0, Math.max(0, this.notes.length - Math.max(0, (this.stream.rows || 24) - this.lines.length - 2)));
|
|
63
|
+
const width = Math.max(...this.lines.map((l) => l.head.length));
|
|
64
|
+
this.stream.write(
|
|
65
|
+
`\x1b[H${
|
|
66
|
+
[
|
|
67
|
+
this.status.text(),
|
|
68
|
+
...this.lines.map((l, i) => `[${i + 1}] ${l.head.padEnd(width)} ${new Duration(Date.now() - l.start).minutes()} ${l.tail}`),
|
|
69
|
+
'-'.repeat(10),
|
|
70
|
+
...this.notes
|
|
71
|
+
].map((l) => `\x1b[2K${l.slice(0, this.stream.columns || 80)}`).join('\n')
|
|
72
|
+
}\x1b[J`
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
}
|
package/src/wand.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) 2026 Yegor Bugayenko
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { Duration } from './duration.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* One loop that keeps making wishes come true until the signal aborts.
|
|
8
|
+
* It takes the next free wish, shows it on its own screen row, runs the
|
|
9
|
+
* agent on it, and closes the wish when the agent succeeds or frees it
|
|
10
|
+
* for a later retry when the agent fails. Either way, it reports the
|
|
11
|
+
* outcome and the time it took to the protocol of the screen.
|
|
12
|
+
*/
|
|
13
|
+
export class Wand {
|
|
14
|
+
constructor(wishes, agent, row, protocol) {
|
|
15
|
+
this.wishes = wishes;
|
|
16
|
+
this.agent = agent;
|
|
17
|
+
this.row = row;
|
|
18
|
+
this.protocol = protocol;
|
|
19
|
+
}
|
|
20
|
+
async run(signal) {
|
|
21
|
+
try {
|
|
22
|
+
while (!signal.aborted) {
|
|
23
|
+
await this.#once(signal);
|
|
24
|
+
}
|
|
25
|
+
} catch (e) {
|
|
26
|
+
if (!signal.aborted) {
|
|
27
|
+
throw e;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
async #once(signal) {
|
|
32
|
+
this.row.show('idle\twaiting for wishes');
|
|
33
|
+
const wish = await this.wishes.next(signal);
|
|
34
|
+
this.row.show(`${wish.name()}\tstarting the agent`);
|
|
35
|
+
const start = Date.now();
|
|
36
|
+
const code = await this.agent.run(wish, { show: (text) => this.row.show(`${wish.name()}\t${text}`) }, signal);
|
|
37
|
+
if (code === 0) {
|
|
38
|
+
await wish.close();
|
|
39
|
+
this.protocol.show(`${wish.name()}\tdone in ${new Duration(Date.now() - start).minutes()}`);
|
|
40
|
+
} else {
|
|
41
|
+
wish.free();
|
|
42
|
+
this.protocol.show(`${wish.name()}\tfailed with exit code ${code} after ${new Duration(Date.now() - start).minutes()}`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
package/src/wish.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) 2026 Yegor Bugayenko
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { readFile, rm } from 'node:fs/promises';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* One wish, a Markdown file inside the wishes directory.
|
|
9
|
+
* The wish shares the map of claims with the wishes directory,
|
|
10
|
+
* so it can give its claim back when a wand is done with it.
|
|
11
|
+
*/
|
|
12
|
+
export class Wish {
|
|
13
|
+
constructor(dir, path, claims) {
|
|
14
|
+
this.dir = dir;
|
|
15
|
+
this.path = path;
|
|
16
|
+
this.claims = claims;
|
|
17
|
+
}
|
|
18
|
+
name() {
|
|
19
|
+
return this.path;
|
|
20
|
+
}
|
|
21
|
+
async text() {
|
|
22
|
+
return readFile(join(this.dir, this.path), 'utf8');
|
|
23
|
+
}
|
|
24
|
+
fate() {
|
|
25
|
+
return 'which will be deleted once you exit with code zero';
|
|
26
|
+
}
|
|
27
|
+
async close() {
|
|
28
|
+
await rm(join(this.dir, this.path));
|
|
29
|
+
this.claims.delete(this.path);
|
|
30
|
+
}
|
|
31
|
+
free() {
|
|
32
|
+
this.claims.set(this.path, false);
|
|
33
|
+
}
|
|
34
|
+
}
|
package/src/wishes.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) 2026 Yegor Bugayenko
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { readdir } from 'node:fs/promises';
|
|
5
|
+
import { join, relative } from 'node:path';
|
|
6
|
+
import { setTimeout } from 'node:timers/promises';
|
|
7
|
+
import { Wish } from './wish.js';
|
|
8
|
+
import { Archived } from './archived.js';
|
|
9
|
+
import { Permanent } from './permanent.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The directory with wishes, shared by all wands.
|
|
13
|
+
* It is the only object with mutable state: a map of claims, where the
|
|
14
|
+
* order of entries tells how recently each wish was tried and the value
|
|
15
|
+
* tells whether a wand holds it now. A wish is claimed synchronously
|
|
16
|
+
* right after the scan, so two wands never get the same file.
|
|
17
|
+
* The wishes found by the first scan are permanent and stay in the
|
|
18
|
+
* directory forever, while the ones that appear later are one-time.
|
|
19
|
+
*/
|
|
20
|
+
export class Wishes {
|
|
21
|
+
constructor(dir, interval, archive = '') {
|
|
22
|
+
this.dir = dir;
|
|
23
|
+
this.interval = interval;
|
|
24
|
+
this.archive = archive;
|
|
25
|
+
this.claims = new Map();
|
|
26
|
+
this.startup = [];
|
|
27
|
+
}
|
|
28
|
+
async next(signal) {
|
|
29
|
+
signal.throwIfAborted();
|
|
30
|
+
const files = (await readdir(this.dir, { recursive: true, withFileTypes: true }))
|
|
31
|
+
.filter((f) => f.isFile() && f.name.endsWith('.md'))
|
|
32
|
+
.map((f) => join(relative(this.dir, f.parentPath), f.name))
|
|
33
|
+
.sort();
|
|
34
|
+
if (this.startup.length === 0) {
|
|
35
|
+
this.startup.push(new Set(files));
|
|
36
|
+
}
|
|
37
|
+
const path = files.find((f) => !this.claims.has(f))
|
|
38
|
+
?? [...this.claims.keys()].find((c) => !this.claims.get(c) && files.includes(c));
|
|
39
|
+
if (path === undefined) {
|
|
40
|
+
await setTimeout(this.interval, undefined, { signal });
|
|
41
|
+
return this.next(signal);
|
|
42
|
+
}
|
|
43
|
+
this.claims.delete(path);
|
|
44
|
+
this.claims.set(path, true);
|
|
45
|
+
const wish = new Wish(this.dir, path, this.claims);
|
|
46
|
+
if (this.startup[0].has(path)) {
|
|
47
|
+
return new Permanent(wish);
|
|
48
|
+
}
|
|
49
|
+
return this.archive === '' ? wish : new Archived(wish, this.dir, this.archive);
|
|
50
|
+
}
|
|
51
|
+
}
|