@yegor256/jinka 0.0.5 → 0.0.6
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/bin/jinka.js +32 -14
- package/package.json +2 -2
- package/plugin/skills/accept-pull-request/SKILL.md +1 -1
- package/src/agent.js +2 -12
- package/src/claude.js +18 -47
- package/src/codex.js +32 -0
- package/src/disk.js +4 -1
- package/src/duration.js +15 -0
- package/src/event.js +34 -12
- package/src/genie.js +61 -0
- package/src/legend.js +5 -1
- package/src/logo.js +8 -0
- package/src/quota.js +17 -10
- package/src/rollouts.js +53 -0
- package/src/status.js +9 -10
- package/src/summarized.js +30 -0
- package/src/summary.js +26 -0
- package/src/throttled.js +3 -6
- package/src/tokens.js +3 -0
- package/src/tty.js +10 -4
package/bin/jinka.js
CHANGED
|
@@ -4,18 +4,21 @@
|
|
|
4
4
|
|
|
5
5
|
import { Command, Option, InvalidArgumentError } from 'commander';
|
|
6
6
|
import { readFile } from 'node:fs/promises';
|
|
7
|
-
import { resolve, join, dirname } from 'node:path';
|
|
7
|
+
import { resolve, join, dirname, parse } from 'node:path';
|
|
8
8
|
import { homedir } from 'node:os';
|
|
9
9
|
import { fileURLToPath } from 'node:url';
|
|
10
10
|
import { Jinka } from '../src/jinka.js';
|
|
11
11
|
import { Wishes } from '../src/wishes.js';
|
|
12
12
|
import { Agent } from '../src/agent.js';
|
|
13
13
|
import { Claude } from '../src/claude.js';
|
|
14
|
+
import { Codex } from '../src/codex.js';
|
|
15
|
+
import { Genie } from '../src/genie.js';
|
|
14
16
|
import { Disk } from '../src/disk.js';
|
|
15
17
|
import { Tty } from '../src/tty.js';
|
|
16
18
|
import { Log } from '../src/log.js';
|
|
17
19
|
import { Status } from '../src/status.js';
|
|
18
20
|
import { Quota } from '../src/quota.js';
|
|
21
|
+
import { Rollouts } from '../src/rollouts.js';
|
|
19
22
|
import { Meters } from '../src/meters.js';
|
|
20
23
|
import { Freeze } from '../src/freeze.js';
|
|
21
24
|
import { Frozen } from '../src/frozen.js';
|
|
@@ -27,6 +30,8 @@ import { Legend } from '../src/legend.js';
|
|
|
27
30
|
import { Tokens } from '../src/tokens.js';
|
|
28
31
|
import { Crew } from '../src/crew.js';
|
|
29
32
|
import { Jobs } from '../src/jobs.js';
|
|
33
|
+
import { Summary } from '../src/summary.js';
|
|
34
|
+
import { Summarized } from '../src/summarized.js';
|
|
30
35
|
|
|
31
36
|
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
32
37
|
const pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'));
|
|
@@ -63,29 +68,42 @@ const program = new Command()
|
|
|
63
68
|
.addOption(new Option('--logs <dir>', 'directory where the output of every agent run is saved').default(join(homedir(), '.jinka', 'logs'), '~/.jinka/logs'))
|
|
64
69
|
.addOption(new Option('--memory <dir>', 'directory where agents keep what they learn about repositories between runs').default(join(homedir(), '.jinka', 'memory'), '~/.jinka/memory'))
|
|
65
70
|
.addOption(new Option('--skill <dir>', 'directory with the plugin of skills that the agent loads').default(join(root, 'plugin'), 'skills bundled with jinka'))
|
|
66
|
-
.
|
|
71
|
+
.addOption(new Option('--engine <name>', 'kind of agent to run, guessed from the name of the agent executable when not given').choices(['claude', 'codex']))
|
|
72
|
+
.addOption(new Option('--agent <cmd>', 'agent executable to run').default('', 'the name of the engine'))
|
|
67
73
|
.option('--interval <ms>', 'how often the wishes directory is scanned', positive, 5000)
|
|
68
74
|
.option('--pause <ms>', 'how long a capitalized wish that was there at startup waits after a success before it runs again', positive, 60000)
|
|
69
75
|
.addOption(new Option('--model <name>', 'model passed to the agent').default('', 'agent default'))
|
|
76
|
+
.addOption(new Option('--effort <level>', 'reasoning effort passed to the agent, such as low, medium, high, xhigh, or max').default('', 'agent default'))
|
|
70
77
|
.option('--throttle <percent>', 'how much of the agent quota, in percent, must be left in every window before a new job starts', percent, 15)
|
|
71
78
|
.addOption(new Option('--limit <n>', 'how many jobs start before jinka waits for them to finish and quits').argParser(positive).default(Infinity, 'unlimited'))
|
|
72
79
|
.parse();
|
|
73
80
|
const values = program.opts();
|
|
74
81
|
const dir = resolve(values.wishes);
|
|
82
|
+
const engine = values.engine ?? (parse(values.agent).name === 'codex' ? 'codex' : 'claude');
|
|
83
|
+
const agent = values.agent === '' ? engine : values.agent;
|
|
75
84
|
const tokens = new Tokens();
|
|
76
85
|
const quota = new Quota();
|
|
77
86
|
const crew = new Crew(values.threads, limit, { warn: (text) => screen.notice().warn(text) });
|
|
78
|
-
const jobs = new Jobs(new Claude(new Agent(
|
|
79
|
-
const freeze = new Freeze(crew, { warn: (text) => screen.notice().warn(text) }, () =>
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
87
|
+
const jobs = new Jobs(new Genie(new (engine === 'codex' ? Codex : Claude)(new Agent(agent), values.model, values.effort, resolve(values.skill)), new Disk(dir, resolve(values.logs), resolve(values.memory), crew), new Meters([tokens, engine === 'codex' ? new Rollouts(quota, join(process.env.CODEX_HOME ?? join(homedir(), '.codex'), 'sessions')) : quota])));
|
|
88
|
+
const freeze = new Freeze(crew, { warn: (text) => screen.notice().warn(text) }, () => quit(0));
|
|
89
|
+
const start = Date.now();
|
|
90
|
+
const screen = new Summarized(
|
|
91
|
+
process.stdout.isTTY
|
|
92
|
+
? new Tty(
|
|
93
|
+
process.stdout,
|
|
94
|
+
crew,
|
|
95
|
+
new Status(start, jobs, agent, values.model, tokens, quota),
|
|
96
|
+
new Legend(freeze, pkg.version)
|
|
97
|
+
)
|
|
98
|
+
: new Log(process.stdout),
|
|
99
|
+
new Summary(start, pkg.version, jobs, { agent, model: values.model, effort: values.effort, wishes: dir }),
|
|
100
|
+
process.stdout
|
|
101
|
+
);
|
|
88
102
|
process.on('exit', () => screen.close());
|
|
103
|
+
const quit = (code) => {
|
|
104
|
+
screen.close();
|
|
105
|
+
process.stdout.write('', () => process.stderr.write('', () => process.exit(code)));
|
|
106
|
+
};
|
|
89
107
|
const controller = new AbortController();
|
|
90
108
|
const interrupt = () => {
|
|
91
109
|
if (controller.signal.aborted) {
|
|
@@ -103,9 +121,9 @@ try {
|
|
|
103
121
|
screen,
|
|
104
122
|
crew
|
|
105
123
|
).run(controller.signal);
|
|
106
|
-
|
|
124
|
+
quit(130);
|
|
107
125
|
} catch (e) {
|
|
108
126
|
screen.close();
|
|
109
127
|
console.error(e.message);
|
|
110
|
-
|
|
128
|
+
quit(1);
|
|
111
129
|
}
|
package/package.json
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
"dependencies": {
|
|
7
7
|
"commander": "^15.0.0",
|
|
8
8
|
"cross-spawn": "^7.0.6",
|
|
9
|
-
"slice-ansi": "^
|
|
9
|
+
"slice-ansi": "^10.0.0"
|
|
10
10
|
},
|
|
11
11
|
"description": "Simple command line orchestrator of coding agents",
|
|
12
12
|
"devDependencies": {
|
|
@@ -35,5 +35,5 @@
|
|
|
35
35
|
"test": "c8 --check-coverage --lines 80 mocha"
|
|
36
36
|
},
|
|
37
37
|
"type": "module",
|
|
38
|
-
"version": "0.0.
|
|
38
|
+
"version": "0.0.6"
|
|
39
39
|
}
|
|
@@ -31,7 +31,7 @@ Use a merge commit when the repository allows it.
|
|
|
31
31
|
Fall back to the merge method the repository allows otherwise.
|
|
32
32
|
Keep the head branch, since its author owns it.
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
Post your decision to the issue, explain why you decided to merge.
|
|
35
35
|
Write like a human maintainer, short and specific.
|
|
36
36
|
Open with substance, never with praise or boilerplate.
|
|
37
37
|
Keep prose free of emoji and of `Generated with` footers.
|
package/src/agent.js
CHANGED
|
@@ -4,22 +4,12 @@
|
|
|
4
4
|
import spawn from 'cross-spawn';
|
|
5
5
|
|
|
6
6
|
export class Agent {
|
|
7
|
-
constructor(command
|
|
7
|
+
constructor(command) {
|
|
8
8
|
this.command = command;
|
|
9
|
-
this.model = model;
|
|
10
9
|
}
|
|
11
10
|
start(args, signal) {
|
|
12
11
|
const windows = process.platform === 'win32';
|
|
13
|
-
const child = spawn(
|
|
14
|
-
this.command,
|
|
15
|
-
[
|
|
16
|
-
'-p', '--output-format', 'stream-json', '--verbose',
|
|
17
|
-
...args,
|
|
18
|
-
'--dangerously-skip-permissions',
|
|
19
|
-
...(this.model === '' ? [] : ['--model', this.model])
|
|
20
|
-
],
|
|
21
|
-
windows ? { stdio: 'pipe' } : { signal, stdio: 'pipe' }
|
|
22
|
-
);
|
|
12
|
+
const child = spawn(this.command, args, windows ? { stdio: 'pipe' } : { signal, stdio: 'pipe' });
|
|
23
13
|
if (windows) {
|
|
24
14
|
signal.addEventListener(
|
|
25
15
|
'abort',
|
package/src/claude.js
CHANGED
|
@@ -1,56 +1,27 @@
|
|
|
1
1
|
// SPDX-FileCopyrightText: Copyright (c) 2026 Yegor Bugayenko
|
|
2
2
|
// SPDX-License-Identifier: MIT
|
|
3
3
|
|
|
4
|
-
import { createInterface } from 'node:readline';
|
|
5
|
-
import { Event } from './event.js';
|
|
6
|
-
import { Tokens } from './tokens.js';
|
|
7
|
-
import { Model } from './model.js';
|
|
8
|
-
|
|
9
4
|
export class Claude {
|
|
10
|
-
constructor(agent,
|
|
5
|
+
constructor(agent, model, effort, plugin) {
|
|
11
6
|
this.agent = agent;
|
|
7
|
+
this.model = model;
|
|
8
|
+
this.effort = effort;
|
|
12
9
|
this.plugin = plugin;
|
|
13
|
-
this.disk = disk;
|
|
14
|
-
this.meter = meter;
|
|
15
10
|
}
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
child.stderr.pipe(log, { end: false });
|
|
32
|
-
let summary = '';
|
|
33
|
-
let link = '';
|
|
34
|
-
const usage = new Tokens();
|
|
35
|
-
const model = new Model();
|
|
36
|
-
createInterface({ input: child.stdout }).on('line', (line) => {
|
|
37
|
-
this.meter.add(line);
|
|
38
|
-
usage.add(line);
|
|
39
|
-
model.add(line);
|
|
40
|
-
summary = new Event(line).summary() || summary;
|
|
41
|
-
link = new Event(line).link() || link;
|
|
42
|
-
const text = new Event(line).text();
|
|
43
|
-
if (text !== '') {
|
|
44
|
-
row.show(text);
|
|
45
|
-
}
|
|
46
|
-
});
|
|
47
|
-
return new Promise((resolve, reject) => {
|
|
48
|
-
child.on('error', (e) => {
|
|
49
|
-
if (!signal.aborted) {
|
|
50
|
-
reject(new Error(`Cannot start the agent "${child.spawnfile}": ${e.message}`));
|
|
51
|
-
}
|
|
52
|
-
});
|
|
53
|
-
child.on('close', (code) => log.end(() => resolve({ code: code ?? 1, summary, link, tokens: usage.text(), model: model.text() })));
|
|
54
|
-
});
|
|
11
|
+
start(args, signal) {
|
|
12
|
+
return this.agent.start(
|
|
13
|
+
[
|
|
14
|
+
'-p', '--output-format', 'stream-json', '--verbose',
|
|
15
|
+
'--plugin-dir', this.plugin,
|
|
16
|
+
...args,
|
|
17
|
+
'--dangerously-skip-permissions',
|
|
18
|
+
...(this.model === '' ? [] : ['--model', this.model]),
|
|
19
|
+
...(this.effort === '' ? [] : ['--effort', this.effort])
|
|
20
|
+
],
|
|
21
|
+
signal
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
lines() {
|
|
25
|
+
return [];
|
|
55
26
|
}
|
|
56
27
|
}
|
package/src/codex.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) 2026 Yegor Bugayenko
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
|
|
6
|
+
export class Codex {
|
|
7
|
+
constructor(agent, model, effort, plugin) {
|
|
8
|
+
this.agent = agent;
|
|
9
|
+
this.model = model;
|
|
10
|
+
this.effort = effort;
|
|
11
|
+
this.plugin = plugin;
|
|
12
|
+
}
|
|
13
|
+
start(args, signal) {
|
|
14
|
+
return this.agent.start(
|
|
15
|
+
[
|
|
16
|
+
'exec', '--json',
|
|
17
|
+
'--dangerously-bypass-approvals-and-sandbox', '--skip-git-repo-check',
|
|
18
|
+
...args,
|
|
19
|
+
...(this.model === '' ? [] : ['--model', this.model]),
|
|
20
|
+
...(this.effort === '' ? [] : ['--config', `model_reasoning_effort=${this.effort}`]),
|
|
21
|
+
'-'
|
|
22
|
+
],
|
|
23
|
+
signal
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
lines() {
|
|
27
|
+
return [
|
|
28
|
+
`Your skills live in the directory ${join(this.plugin, 'skills')}, one skill per subdirectory, each described in its SKILL.md file.`,
|
|
29
|
+
'Before you start, read the descriptions of these skills, and whenever a skill fits your work, read its SKILL.md file and follow it.'
|
|
30
|
+
];
|
|
31
|
+
}
|
|
32
|
+
}
|
package/src/disk.js
CHANGED
|
@@ -19,7 +19,10 @@ export class Disk {
|
|
|
19
19
|
`If this wish is an atomic job that involves no other jobs, make it come true yourself;`,
|
|
20
20
|
`otherwise, do not do it yourself, but split it into smaller wishes`,
|
|
21
21
|
`and write each of them as a separate Markdown file in the directory ${join(this.wishes, dirname(wish.name()))}.`,
|
|
22
|
-
`
|
|
22
|
+
`Always start the name of every new wish file with a small English letter, never with a capital one.`,
|
|
23
|
+
`In every Git repository you touch, do all your work inside a separate Git worktree, never in the main directory of the repository, so that other wishes running at the same time do not see your changes.`,
|
|
24
|
+
`Never spend time waiting while doing a wish, neither sleeping nor polling for a build, a review, a merge, or anything else;`,
|
|
25
|
+
`if something must happen first, write a new wish that waits for it and finishes the job, and then complete the current wish right away.`, `Your memory lives in the directory ${this.memory}, keep there what the skills tell you to keep.`,
|
|
23
26
|
`Jinka runs ${this.crew.size()} threads right now, so up to ${this.crew.size()} wishes run at once.`
|
|
24
27
|
];
|
|
25
28
|
}
|
package/src/duration.js
CHANGED
|
@@ -9,6 +9,21 @@ export class Duration {
|
|
|
9
9
|
const seconds = Math.floor(this.millis / 1000);
|
|
10
10
|
return [Math.floor(seconds / 60), seconds % 60].map((p) => String(p).padStart(2, '0')).join(':');
|
|
11
11
|
}
|
|
12
|
+
clock() {
|
|
13
|
+
const minutes = Math.floor(this.millis / 60_000);
|
|
14
|
+
if (minutes < 1000) {
|
|
15
|
+
return this.minutes().padStart(6);
|
|
16
|
+
}
|
|
17
|
+
if (minutes < 6000) {
|
|
18
|
+
return `${String(Math.floor(minutes / 60)).padStart(2, '0')}:${String(minutes % 60).padStart(2, '0')}m`;
|
|
19
|
+
}
|
|
20
|
+
return `${Math.floor(minutes / 60)}h`.padStart(6);
|
|
21
|
+
}
|
|
22
|
+
phrase() {
|
|
23
|
+
const seconds = Math.floor(this.millis / 1000);
|
|
24
|
+
const [count, unit] = seconds < 60 ? [seconds, 'second'] : [Math.floor(seconds / 60), 'minute'];
|
|
25
|
+
return `${count} ${unit}${count === 1 ? '' : 's'}`;
|
|
26
|
+
}
|
|
12
27
|
hours() {
|
|
13
28
|
const seconds = Math.floor(this.millis / 1000);
|
|
14
29
|
return [Math.floor(seconds / 3600), Math.floor(seconds / 60) % 60, seconds % 60].map((p) => String(p).padStart(2, '0')).join(':');
|
package/src/event.js
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
// SPDX-FileCopyrightText: Copyright (c) 2026 Yegor Bugayenko
|
|
2
2
|
// SPDX-License-Identifier: MIT
|
|
3
3
|
|
|
4
|
-
import { styleText } from 'node:util';
|
|
5
|
-
|
|
6
4
|
export class Event {
|
|
7
|
-
constructor(line) {
|
|
5
|
+
constructor(line, count) {
|
|
8
6
|
this.line = line;
|
|
7
|
+
this.count = count;
|
|
9
8
|
}
|
|
10
9
|
text() {
|
|
11
10
|
try {
|
|
@@ -16,16 +15,14 @@ export class Event {
|
|
|
16
15
|
}
|
|
17
16
|
summary() {
|
|
18
17
|
try {
|
|
19
|
-
|
|
20
|
-
return json.type === 'result' ? this.#first(String(json.result ?? '')) : '';
|
|
18
|
+
return this.#first(this.#final(JSON.parse(this.line)));
|
|
21
19
|
} catch {
|
|
22
20
|
return '';
|
|
23
21
|
}
|
|
24
22
|
}
|
|
25
23
|
link() {
|
|
26
24
|
try {
|
|
27
|
-
|
|
28
|
-
return json.type === 'result' ? (String(json.result ?? '').match(/https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/(?:issues|pull)\/\d+/) ?? [''])[0] : '';
|
|
25
|
+
return (this.#final(JSON.parse(this.line)).match(/https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/(?:issues|pull)\/\d+/) ?? [''])[0];
|
|
29
26
|
} catch {
|
|
30
27
|
return '';
|
|
31
28
|
}
|
|
@@ -37,6 +34,27 @@ export class Event {
|
|
|
37
34
|
if (json.type === 'result') {
|
|
38
35
|
return this.#first(String(json.result ?? json.subtype ?? ''));
|
|
39
36
|
}
|
|
37
|
+
if (json.type === 'item.completed' && json.item?.type === 'agent_message') {
|
|
38
|
+
return this.#first(String(json.item.text ?? ''));
|
|
39
|
+
}
|
|
40
|
+
if (json.type === 'item.started' && json.item?.type === 'command_execution') {
|
|
41
|
+
return this.#program(String(json.item.command ?? ''));
|
|
42
|
+
}
|
|
43
|
+
if (json.type === 'item.completed' && json.item?.type === 'file_change') {
|
|
44
|
+
return `editing ${(json.item.changes ?? []).map((c) => c.path).join(', ')}`;
|
|
45
|
+
}
|
|
46
|
+
if (json.type === 'turn.failed') {
|
|
47
|
+
return this.#first(String(json.error?.message ?? ''));
|
|
48
|
+
}
|
|
49
|
+
return '';
|
|
50
|
+
}
|
|
51
|
+
#final(json) {
|
|
52
|
+
if (json.type === 'result') {
|
|
53
|
+
return String(json.result ?? '');
|
|
54
|
+
}
|
|
55
|
+
if (json.type === 'item.completed' && json.item?.type === 'agent_message') {
|
|
56
|
+
return String(json.item.text ?? '');
|
|
57
|
+
}
|
|
40
58
|
return '';
|
|
41
59
|
}
|
|
42
60
|
#part(part) {
|
|
@@ -49,17 +67,21 @@ export class Event {
|
|
|
49
67
|
if (phrase !== undefined) {
|
|
50
68
|
return this.#first(phrase);
|
|
51
69
|
}
|
|
52
|
-
|
|
53
|
-
|
|
70
|
+
if (part.name === 'Bash' && input.command !== undefined) {
|
|
71
|
+
return this.#program(String(input.command));
|
|
72
|
+
}
|
|
73
|
+
return `\x1b[38;2;0;204;204m${
|
|
54
74
|
[part.name, input.command ?? input.file_path ?? input.pattern ?? input.url ?? input.query ?? input.skill ?? input.description]
|
|
55
75
|
.filter((p) => p !== undefined)
|
|
56
76
|
.map((p) => this.#first(String(p)))
|
|
57
|
-
.join(': ')
|
|
58
|
-
|
|
59
|
-
);
|
|
77
|
+
.join(': ')
|
|
78
|
+
}\x1b[39m`;
|
|
60
79
|
}
|
|
61
80
|
return '';
|
|
62
81
|
}
|
|
82
|
+
#program(command) {
|
|
83
|
+
return `\x1b[38;2;0;204;204m${command.trim().split(/\s+/)[0].split('/').at(-1)}\x1b[39m (${this.count} lines)`;
|
|
84
|
+
}
|
|
63
85
|
#plain(name, input) {
|
|
64
86
|
const phrases = {
|
|
65
87
|
Bash: () => input.description,
|
package/src/genie.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) 2026 Yegor Bugayenko
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { createInterface } from 'node:readline';
|
|
5
|
+
import { Event } from './event.js';
|
|
6
|
+
import { Tokens } from './tokens.js';
|
|
7
|
+
import { Model } from './model.js';
|
|
8
|
+
|
|
9
|
+
export class Genie {
|
|
10
|
+
constructor(engine, disk, meter) {
|
|
11
|
+
this.engine = engine;
|
|
12
|
+
this.disk = disk;
|
|
13
|
+
this.meter = meter;
|
|
14
|
+
}
|
|
15
|
+
async run(wish, row, signal) {
|
|
16
|
+
const prompt = [
|
|
17
|
+
'Make this wish come true, working on your own without asking any questions:',
|
|
18
|
+
'',
|
|
19
|
+
await wish.text(),
|
|
20
|
+
'',
|
|
21
|
+
...this.disk.lines(wish),
|
|
22
|
+
...this.engine.lines(),
|
|
23
|
+
'When you finish, end with a single plain sentence that says what you did.',
|
|
24
|
+
'On the next line, write the full URL of the GitHub issue or pull request you worked on, if there was one.'
|
|
25
|
+
].join('\n');
|
|
26
|
+
const log = await this.disk.log(wish);
|
|
27
|
+
const child = this.engine.start(this.disk.args(), signal);
|
|
28
|
+
child.stdin.on('error', () => {});
|
|
29
|
+
child.stdin.end(prompt);
|
|
30
|
+
child.stdout.pipe(log, { end: false });
|
|
31
|
+
child.stderr.pipe(log, { end: false });
|
|
32
|
+
let summary = '';
|
|
33
|
+
let link = '';
|
|
34
|
+
const usage = new Tokens();
|
|
35
|
+
const model = new Model();
|
|
36
|
+
let count = 0;
|
|
37
|
+
createInterface({ input: child.stderr }).on('line', () => {
|
|
38
|
+
count += 1;
|
|
39
|
+
});
|
|
40
|
+
createInterface({ input: child.stdout }).on('line', (line) => {
|
|
41
|
+
count += 1;
|
|
42
|
+
this.meter.add(line);
|
|
43
|
+
usage.add(line);
|
|
44
|
+
model.add(line);
|
|
45
|
+
summary = new Event(line, count).summary() || summary;
|
|
46
|
+
link = new Event(line, count).link() || link;
|
|
47
|
+
const text = new Event(line, count).text();
|
|
48
|
+
if (text !== '') {
|
|
49
|
+
row.show(text);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
return new Promise((resolve, reject) => {
|
|
53
|
+
child.on('error', (e) => {
|
|
54
|
+
if (!signal.aborted) {
|
|
55
|
+
reject(new Error(`Cannot start the agent "${child.spawnfile}": ${e.message}`));
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
child.on('close', (code) => log.end(() => resolve({ code: code ?? 1, summary, link, tokens: usage.text(), model: model.text() })));
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
package/src/legend.js
CHANGED
|
@@ -4,10 +4,14 @@
|
|
|
4
4
|
import { styleText } from 'node:util';
|
|
5
5
|
|
|
6
6
|
export class Legend {
|
|
7
|
-
constructor(freeze) {
|
|
7
|
+
constructor(freeze, version) {
|
|
8
8
|
this.freeze = freeze;
|
|
9
|
+
this.version = version;
|
|
9
10
|
}
|
|
10
11
|
text() {
|
|
12
|
+
return `${this.#keys()}\t${this.version}`;
|
|
13
|
+
}
|
|
14
|
+
#keys() {
|
|
11
15
|
return this.freeze.active()
|
|
12
16
|
? `keys: ${styleText('bgGray', 'C', { validateStream: false })} - to continue`
|
|
13
17
|
: `keys: ${styleText('bgGray', 'F', { validateStream: false })} - to finish and quit, ${styleText('bgGray', '+/-', { validateStream: false })} to add threads`;
|
package/src/logo.js
ADDED
package/src/quota.js
CHANGED
|
@@ -3,28 +3,35 @@
|
|
|
3
3
|
|
|
4
4
|
export class Quota {
|
|
5
5
|
constructor() {
|
|
6
|
-
this.shares = [];
|
|
7
6
|
this.windows = [];
|
|
8
7
|
}
|
|
9
8
|
add(line) {
|
|
10
9
|
try {
|
|
11
10
|
const json = JSON.parse(line);
|
|
12
11
|
if (json.type === 'rate_limit_event' && typeof json.rate_limit_info?.unifiedWindows === 'object') {
|
|
13
|
-
this.windows.push(Object.
|
|
14
|
-
}
|
|
15
|
-
if (json.type === 'rate_limit_event' && typeof json.rate_limit_info?.unifiedWindows?.five_hour?.utilization === 'number') {
|
|
16
|
-
this.shares.push(json.rate_limit_info.unifiedWindows.five_hour.utilization);
|
|
12
|
+
this.windows.push(Object.entries(json.rate_limit_info.unifiedWindows).filter(([, w]) => typeof w?.utilization === 'number'));
|
|
17
13
|
}
|
|
18
14
|
} catch {
|
|
19
15
|
return;
|
|
20
16
|
}
|
|
21
17
|
}
|
|
22
|
-
text() {
|
|
23
|
-
return this
|
|
18
|
+
text(now) {
|
|
19
|
+
return this.#live(now)
|
|
20
|
+
.map(([n, share]) => `${Math.round(share)}%-${{ 'five-hour': '5H', 'seven-day': 'W', 'thirty-day': 'M' }[n] ?? n}`)
|
|
21
|
+
.join(',') || '-';
|
|
24
22
|
}
|
|
25
23
|
left(now) {
|
|
26
|
-
return
|
|
27
|
-
|
|
28
|
-
|
|
24
|
+
return this.#tightest(now)[1];
|
|
25
|
+
}
|
|
26
|
+
name(now) {
|
|
27
|
+
return this.#tightest(now)[0];
|
|
28
|
+
}
|
|
29
|
+
#tightest(now) {
|
|
30
|
+
return this.#live(now).reduce((a, b) => (b[1] < a[1] ? b : a), ['', 100]);
|
|
31
|
+
}
|
|
32
|
+
#live(now) {
|
|
33
|
+
return (this.windows.at(-1) ?? [])
|
|
34
|
+
.filter(([, w]) => typeof w.resetsAt !== 'number' || w.resetsAt * 1000 > now)
|
|
35
|
+
.map(([n, w]) => [n.replaceAll('_', '-'), (1 - w.utilization) * 100]);
|
|
29
36
|
}
|
|
30
37
|
}
|
package/src/rollouts.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) 2026 Yegor Bugayenko
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
|
|
7
|
+
export class Rollouts {
|
|
8
|
+
constructor(origin, dir) {
|
|
9
|
+
this.origin = origin;
|
|
10
|
+
this.dir = dir;
|
|
11
|
+
this.files = new Map();
|
|
12
|
+
this.thread = '';
|
|
13
|
+
}
|
|
14
|
+
add(line) {
|
|
15
|
+
this.origin.add(line);
|
|
16
|
+
try {
|
|
17
|
+
const json = JSON.parse(line);
|
|
18
|
+
if (json.type === 'thread.started' && typeof json.thread_id === 'string') {
|
|
19
|
+
this.thread = json.thread_id;
|
|
20
|
+
}
|
|
21
|
+
if ((json.type === 'turn.completed' || json.type === 'item.completed') && this.thread !== '') {
|
|
22
|
+
this.#limits().forEach((limits) => this.origin.add(JSON.stringify({
|
|
23
|
+
type: 'rate_limit_event',
|
|
24
|
+
rate_limit_info: {
|
|
25
|
+
unifiedWindows: Object.fromEntries(
|
|
26
|
+
[limits.primary, limits.secondary]
|
|
27
|
+
.filter((w) => typeof w?.used_percent === 'number' && typeof w?.window_minutes === 'number')
|
|
28
|
+
.map((w) => [{ 300: 'five_hour', 10080: 'seven_day', 43200: 'thirty_day' }[w.window_minutes] ?? `${w.window_minutes}_minute`, { utilization: w.used_percent / 100, resetsAt: w.resets_at }])
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
})));
|
|
32
|
+
}
|
|
33
|
+
} catch {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
#limits() {
|
|
38
|
+
if (!this.files.has(this.thread) && existsSync(this.dir)) {
|
|
39
|
+
readdirSync(this.dir, { recursive: true })
|
|
40
|
+
.map(String)
|
|
41
|
+
.filter((path) => path.endsWith(`-${this.thread}.jsonl`))
|
|
42
|
+
.forEach((path) => this.files.set(this.thread, path));
|
|
43
|
+
}
|
|
44
|
+
return !this.files.has(this.thread)
|
|
45
|
+
? []
|
|
46
|
+
: readFileSync(join(this.dir, this.files.get(this.thread)), 'utf8')
|
|
47
|
+
.split('\n')
|
|
48
|
+
.filter((text) => text.includes('"rate_limits"'))
|
|
49
|
+
.map((text) => JSON.parse(text).payload?.rate_limits)
|
|
50
|
+
.filter((limits) => typeof limits === 'object' && limits !== null)
|
|
51
|
+
.slice(-1);
|
|
52
|
+
}
|
|
53
|
+
}
|
package/src/status.js
CHANGED
|
@@ -5,8 +5,7 @@ import { styleText } from 'node:util';
|
|
|
5
5
|
import { Duration } from './duration.js';
|
|
6
6
|
|
|
7
7
|
export class Status {
|
|
8
|
-
constructor(
|
|
9
|
-
this.version = version;
|
|
8
|
+
constructor(start, jobs, agent, model, tokens, quota) {
|
|
10
9
|
this.start = start;
|
|
11
10
|
this.jobs = jobs;
|
|
12
11
|
this.agent = agent;
|
|
@@ -15,14 +14,14 @@ export class Status {
|
|
|
15
14
|
this.quota = quota;
|
|
16
15
|
}
|
|
17
16
|
text() {
|
|
17
|
+
const now = Date.now();
|
|
18
18
|
return [
|
|
19
|
-
`
|
|
20
|
-
`
|
|
21
|
-
`
|
|
22
|
-
`
|
|
23
|
-
`
|
|
24
|
-
`
|
|
25
|
-
|
|
26
|
-
].join(', ');
|
|
19
|
+
`up:${styleText('bgGray', new Duration(now - this.start).hours(), { validateStream: false })}`,
|
|
20
|
+
`done:${styleText('bgGray', this.jobs.text(), { validateStream: false })}`,
|
|
21
|
+
`agent:${styleText('bgGray', this.agent, { validateStream: false })}`,
|
|
22
|
+
`model:${styleText('bgGray', this.model === '' ? 'default' : this.model, { validateStream: false })}`,
|
|
23
|
+
`tokens:${styleText('bgGray', this.tokens.text(), { validateStream: false })}`,
|
|
24
|
+
`quota:${styleText('bgGray', this.quota.text(now), { validateStream: false })}`
|
|
25
|
+
].join(' ');
|
|
27
26
|
}
|
|
28
27
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) 2026 Yegor Bugayenko
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
export class Summarized {
|
|
5
|
+
constructor(screen, summary, stream) {
|
|
6
|
+
this.screen = screen;
|
|
7
|
+
this.summary = summary;
|
|
8
|
+
this.stream = stream;
|
|
9
|
+
this.done = [];
|
|
10
|
+
}
|
|
11
|
+
row(index) {
|
|
12
|
+
return this.screen.row(index);
|
|
13
|
+
}
|
|
14
|
+
notice() {
|
|
15
|
+
return this.screen.notice();
|
|
16
|
+
}
|
|
17
|
+
up() {
|
|
18
|
+
this.screen.up();
|
|
19
|
+
}
|
|
20
|
+
down() {
|
|
21
|
+
this.screen.down();
|
|
22
|
+
}
|
|
23
|
+
close() {
|
|
24
|
+
this.screen.close();
|
|
25
|
+
if (this.done.length === 0) {
|
|
26
|
+
this.done.push(true);
|
|
27
|
+
this.stream.write(this.summary.text());
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
package/src/summary.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) 2026 Yegor Bugayenko
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
5
|
+
import { homedir } from 'node:os';
|
|
6
|
+
import { sep } from 'node:path';
|
|
7
|
+
import { Duration } from './duration.js';
|
|
8
|
+
|
|
9
|
+
export class Summary {
|
|
10
|
+
constructor(start, version, jobs, setup) {
|
|
11
|
+
this.start = start;
|
|
12
|
+
this.version = version;
|
|
13
|
+
this.jobs = jobs;
|
|
14
|
+
this.setup = setup;
|
|
15
|
+
}
|
|
16
|
+
text() {
|
|
17
|
+
return [
|
|
18
|
+
'---',
|
|
19
|
+
`jinka ${this.version} finished in ${new Duration(Date.now() - this.start).hours()}`,
|
|
20
|
+
`agent: ${this.setup.agent}, model: ${this.setup.model === '' ? 'default' : this.setup.model}, effort: ${this.setup.effort === '' ? 'default' : this.setup.effort}`,
|
|
21
|
+
`sessions finished: ${this.jobs.text()}`,
|
|
22
|
+
`wishes remained in ${this.setup.wishes.startsWith(`${homedir()}${sep}`) ? `~${this.setup.wishes.slice(homedir().length)}` : this.setup.wishes}: ${existsSync(this.setup.wishes) ? readdirSync(this.setup.wishes, { recursive: true, withFileTypes: true }).filter((f) => f.isFile() && f.name.endsWith('.md')).length : 0}`,
|
|
23
|
+
''
|
|
24
|
+
].join('\n');
|
|
25
|
+
}
|
|
26
|
+
}
|
package/src/throttled.js
CHANGED
|
@@ -22,15 +22,12 @@ export class Throttled {
|
|
|
22
22
|
if (left >= this.throttle && first) {
|
|
23
23
|
return;
|
|
24
24
|
}
|
|
25
|
+
const share = `${Math.round(left)}% of the ${this.quota.name(Date.now())} quota is left`;
|
|
25
26
|
if (left >= this.throttle) {
|
|
26
|
-
this.notice.warn(`
|
|
27
|
+
this.notice.warn(`now ${share}, not below ${this.throttle}%, new jobs resume`);
|
|
27
28
|
return;
|
|
28
29
|
}
|
|
29
|
-
|
|
30
|
-
this.notice.warn(`only ${Math.round(left)}% of the quota is left, which is below ${this.throttle}%, so new jobs wait and the quota is checked again in ${new Duration(this.interval).minutes()}`);
|
|
31
|
-
} else {
|
|
32
|
-
this.notice.warn(`checked the quota again, only ${Math.round(left)}% is left, which is still below ${this.throttle}%, so new jobs wait and the quota is checked again in ${new Duration(this.interval).minutes()}`);
|
|
33
|
-
}
|
|
30
|
+
this.notice.warn(`${first ? '' : 'still '}only ${share}, below ${this.throttle}%, new jobs wait, next check in ${new Duration(this.interval).phrase()}`);
|
|
34
31
|
await setTimeout(this.interval, undefined, { signal });
|
|
35
32
|
await this.#wait(signal, false);
|
|
36
33
|
}
|
package/src/tokens.js
CHANGED
|
@@ -15,6 +15,9 @@ export class Tokens {
|
|
|
15
15
|
.reduce((sum, key) => sum + (json.message.usage[key] ?? 0), 0)
|
|
16
16
|
);
|
|
17
17
|
}
|
|
18
|
+
if (json.type === 'turn.completed' && json.usage !== undefined) {
|
|
19
|
+
this.messages.set(Symbol('turn'), (json.usage.input_tokens ?? 0) + (json.usage.output_tokens ?? 0));
|
|
20
|
+
}
|
|
18
21
|
} catch {
|
|
19
22
|
return;
|
|
20
23
|
}
|
package/src/tty.js
CHANGED
|
@@ -6,6 +6,7 @@ import sliceAnsi from 'slice-ansi';
|
|
|
6
6
|
import { Row } from './row.js';
|
|
7
7
|
import { Protocol } from './protocol.js';
|
|
8
8
|
import { Duration } from './duration.js';
|
|
9
|
+
import { Logo } from './logo.js';
|
|
9
10
|
|
|
10
11
|
export class Tty {
|
|
11
12
|
constructor(stream, crew, status, legend) {
|
|
@@ -75,21 +76,26 @@ export class Tty {
|
|
|
75
76
|
const notes = this.notes.flatMap((n) => this.#wrap(n, columns));
|
|
76
77
|
const height = Math.max(0, rows - lines.length - 5);
|
|
77
78
|
this.view.depth = Math.min(this.view.depth, Math.max(0, notes.length - height));
|
|
79
|
+
const [upper, lower] = new Logo().lines();
|
|
78
80
|
const top = [
|
|
79
|
-
this.status.text()
|
|
80
|
-
|
|
81
|
-
...lines.map(([, l]) => `${l.head}${' '.repeat(width - stripVTControlCharacters(l.head).length)} ${styleText(['', 'idle'].includes(stripVTControlCharacters(l.head)) ? 'gray' : 'green', new Duration(Date.now() - l.start).
|
|
81
|
+
`${upper} ${this.status.text()}`,
|
|
82
|
+
lower,
|
|
83
|
+
...lines.map(([, l]) => `${l.head}${' '.repeat(width - stripVTControlCharacters(l.head).length)} ${styleText(['', 'idle'].includes(stripVTControlCharacters(l.head)) ? 'gray' : 'green', new Duration(Date.now() - l.start).clock(), { validateStream: false })} ${l.tail}`),
|
|
82
84
|
'-'.repeat(10),
|
|
83
85
|
...notes.slice(Math.max(0, notes.length - height - this.view.depth), notes.length - this.view.depth)
|
|
84
86
|
];
|
|
85
87
|
this.stream.write(
|
|
86
88
|
`\x1b[H${
|
|
87
|
-
[...top, ...Array(Math.max(1, rows - top.length - 1)).fill(''), this.legend.text()]
|
|
89
|
+
[...top, ...Array(Math.max(1, rows - top.length - 1)).fill(''), this.#edge(this.legend.text(), columns)]
|
|
88
90
|
.map((l) => `\x1b[2K${sliceAnsi(l, 0, columns)}\x1b[0m`)
|
|
89
91
|
.join('\n')
|
|
90
92
|
}\x1b[J`
|
|
91
93
|
);
|
|
92
94
|
}
|
|
95
|
+
#edge(text, width) {
|
|
96
|
+
const [left, right] = text.split('\t');
|
|
97
|
+
return `${left}${' '.repeat(Math.max(1, width - 1 - stripVTControlCharacters(left).length - stripVTControlCharacters(right).length))}${right}`;
|
|
98
|
+
}
|
|
93
99
|
#wrap(text, width) {
|
|
94
100
|
return stripVTControlCharacters(text).length <= width || width < 3 ? [text] : [sliceAnsi(text, 0, width), ...this.#wrap(` ${sliceAnsi(text, width)}`, width)];
|
|
95
101
|
}
|