@yegor256/jinka 0.0.9 → 0.0.11

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 CHANGED
@@ -7,7 +7,7 @@
7
7
  First, you create `~/.jinka/wishes/Make-Me-Happy.md` file:
8
8
 
9
9
  ```text
10
- I wish my GitHub repositories were in good shape: `foo/bar` and `foo/bar2`.
10
+ I wish my GitHub repository to be in good shape: `foo/bar`.
11
11
  ```
12
12
 
13
13
  Then, you install `jinka`:
@@ -16,7 +16,7 @@ Then, you install `jinka`:
16
16
  npm install -g @yegor256/jinka
17
17
  ```
18
18
 
19
- Finally, you run it, giving it the path to your directory with wishes:
19
+ Finally, you run it:
20
20
 
21
21
  ```bash
22
22
  jinka
package/bin/jinka.js CHANGED
@@ -17,9 +17,12 @@ import { disk } from '../src/disk.js';
17
17
  import { tty } from '../src/tty.js';
18
18
  import { colorless } from '../src/colorless.js';
19
19
  import { log } from '../src/log.js';
20
+ import { pile } from '../src/pile.js';
20
21
  import { status } from '../src/status.js';
21
22
  import { quota } from '../src/quota.js';
22
23
  import { rollouts } from '../src/rollouts.js';
24
+ import { recorded } from '../src/recorded.js';
25
+ import { model } from '../src/model.js';
23
26
  import { meters } from '../src/meters.js';
24
27
  import { freeze } from '../src/freeze.js';
25
28
  import { frozen } from '../src/frozen.js';
@@ -35,6 +38,7 @@ import { jobs } from '../src/jobs.js';
35
38
  import { summary } from '../src/summary.js';
36
39
  import { summarized } from '../src/summarized.js';
37
40
  import { semaphore } from '../src/semaphore.js';
41
+ import { hello } from '../src/hello.js';
38
42
 
39
43
  const root = join(dirname(fileURLToPath(import.meta.url)), '..');
40
44
  const pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'));
@@ -97,7 +101,10 @@ const a = values.agent === '' ? engine : values.agent;
97
101
  const t = tokens();
98
102
  const q = quota();
99
103
  const c = crew(values.threads, limit, { warn: (text) => screen.notice().warn(text) });
100
- const j = jobs(genie((engine === 'codex' ? codex : claude)(agent(a), values.model, values.effort, resolve(values.skill)), disk(dir, resolve(values.logs), resolve(values.memory), c), meters([t, engine === 'codex' ? rollouts(q, join(process.env.CODEX_HOME ?? join(homedir(), '.codex'), 'sessions')) : q])));
104
+ const sessions = join(process.env.CODEX_HOME ?? join(homedir(), '.codex'), 'sessions');
105
+ const e = (engine === 'codex' ? codex : claude)(agent(a), values.model, values.effort, resolve(values.skill));
106
+ const d = disk(dir, resolve(values.logs), resolve(values.memory), c);
107
+ const j = jobs(genie(e, d, meters([t, engine === 'codex' ? rollouts(q, sessions) : q]), engine === 'codex' ? () => recorded(model(), sessions) : model));
101
108
  const f = freeze(c, { warn: (text) => screen.notice().warn(text) }, () => quit(0));
102
109
  const r = release(pkg.version, 'https://registry.npmjs.org/@yegor256/jinka/latest');
103
110
  const start = Date.now();
@@ -107,7 +114,7 @@ const screen = summarized(
107
114
  ? tty(
108
115
  values.color && !process.env.NO_COLOR ? process.stdout : colorless(process.stdout),
109
116
  c,
110
- status(start, j, a, values.model, t, q),
117
+ status(start, pile(dir), j, a, values.model, t, q),
111
118
  legend(f, r)
112
119
  )
113
120
  : log(process.stdout),
@@ -118,6 +125,7 @@ process.on('exit', () => screen.close());
118
125
  if (tui) {
119
126
  r.check();
120
127
  }
128
+ screen.notice().warn(`every agent starts as: ${e.command(d.args())}`);
121
129
  const quit = (code) => {
122
130
  screen.close();
123
131
  process.stdout.write('', () => process.stderr.write('', () => process.exit(code)));
@@ -133,6 +141,7 @@ process.on('SIGINT', interrupt);
133
141
  keys(process.stdin, f, c, interrupt).listen();
134
142
  wheel(process.stdin, screen).listen();
135
143
  try {
144
+ await hello(e, screen.notice()).run(controller.signal);
136
145
  await jinka(
137
146
  limited(frozen(throttled(wishes(dir, values.interval, values.archive === '' ? '' : resolve(values.archive), values.pause, { warn: (text) => screen.notice().warn(text) }), q, values.throttle, 15 * 60 * 1000, { warn: (text) => screen.notice().warn(text) }), f), values.limit, f),
138
147
  j,
package/package.json CHANGED
@@ -35,5 +35,5 @@
35
35
  "test": "c8 --check-coverage --lines 80 mocha"
36
36
  },
37
37
  "type": "module",
38
- "version": "0.0.9"
38
+ "version": "0.0.11"
39
39
  }
@@ -9,8 +9,7 @@ description: |
9
9
  Picks the oldest untouched items, skips those that already have a wish,
10
10
  wait for their author, or were triaged within the last day,
11
11
  skips pull requests whose latest review requests changes,
12
- and writes at most as many new wishes as jinka runs threads,
13
- one per item, into the wishes directory.
12
+ and writes one new wish per item into the wishes directory.
14
13
  ---
15
14
 
16
15
  Target every repository named by the wish.
@@ -31,8 +30,6 @@ Skip issues triaged less than one day ago,
31
30
  according to `<owner>/<repo>/triaged.csv`
32
31
  in the memory directory given in the prompt.
33
32
 
34
- Write at most as many new wishes per run as jinka runs threads,
35
- as the prompt says, across all repositories.
36
33
  Write no wish when every candidate was skipped.
37
34
 
38
35
  Write one Markdown file per item, in the root of the wishes directory.
package/src/agent.js CHANGED
@@ -15,5 +15,6 @@ export const agent = (command) => Object.freeze({
15
15
  );
16
16
  }
17
17
  return child;
18
- }
18
+ },
19
+ command: (args) => [command, ...args].map((a) => (/^[\w./\\:=@%+,~-]+$/.test(a) ? a : `'${a.replaceAll("'", "'\\''")}'`)).join(' ')
19
20
  });
package/src/claude.js CHANGED
@@ -1,17 +1,18 @@
1
1
  // SPDX-FileCopyrightText: Copyright (c) 2026 Yegor Bugayenko
2
2
  // SPDX-License-Identifier: MIT
3
3
 
4
- export const claude = (agent, model, effort, plugin) => Object.freeze({
5
- start: (args, signal) => agent.start(
6
- [
7
- '-p', '--output-format', 'stream-json', '--verbose',
8
- '--plugin-dir', plugin,
9
- ...args,
10
- '--dangerously-skip-permissions',
11
- ...(model === '' ? [] : ['--model', model]),
12
- ...(effort === '' ? [] : ['--effort', effort])
13
- ],
14
- signal
15
- ),
16
- lines: () => []
17
- });
4
+ export const claude = (agent, model, effort, plugin) => {
5
+ const full = (args) => [
6
+ '-p', '--output-format', 'stream-json', '--verbose',
7
+ '--plugin-dir', plugin,
8
+ ...args,
9
+ '--dangerously-skip-permissions',
10
+ ...(model === '' ? [] : ['--model', model]),
11
+ ...(effort === '' ? [] : ['--effort', effort])
12
+ ];
13
+ return Object.freeze({
14
+ start: (args, signal) => agent.start(full(args), signal),
15
+ command: (args) => agent.command(full(args)),
16
+ lines: () => []
17
+ });
18
+ };
package/src/codex.js CHANGED
@@ -3,20 +3,21 @@
3
3
 
4
4
  import { join } from 'node:path';
5
5
 
6
- export const codex = (agent, model, effort, plugin) => Object.freeze({
7
- start: (args, signal) => agent.start(
8
- [
9
- 'exec', '--json',
10
- '--dangerously-bypass-approvals-and-sandbox', '--skip-git-repo-check',
11
- ...args,
12
- ...(model === '' ? [] : ['--model', model]),
13
- ...(effort === '' ? [] : ['--config', `model_reasoning_effort=${effort}`]),
14
- '-'
15
- ],
16
- signal
17
- ),
18
- lines: () => [
19
- `Your skills live in the directory ${join(plugin, 'skills')}, one skill per subdirectory, each described in its SKILL.md file.`,
20
- 'Before you start, read the descriptions of these skills, and whenever a skill fits your work, read its SKILL.md file and follow it.'
21
- ]
22
- });
6
+ export const codex = (agent, model, effort, plugin) => {
7
+ const full = (args) => [
8
+ 'exec', '--json',
9
+ '--dangerously-bypass-approvals-and-sandbox', '--skip-git-repo-check',
10
+ ...args,
11
+ ...(model === '' ? [] : ['--model', model]),
12
+ ...(effort === '' ? [] : ['--config', `model_reasoning_effort=${effort}`]),
13
+ '-'
14
+ ];
15
+ return Object.freeze({
16
+ start: (args, signal) => agent.start(full(args), signal),
17
+ command: (args) => agent.command(full(args)),
18
+ lines: () => [
19
+ `Your skills live in the directory ${join(plugin, 'skills')}, one skill per subdirectory, each described in its SKILL.md file.`,
20
+ 'Before you start, read the descriptions of these skills, and whenever a skill fits your work, read its SKILL.md file and follow it.'
21
+ ]
22
+ });
23
+ };
package/src/disk.js CHANGED
@@ -2,24 +2,38 @@
2
2
  // SPDX-License-Identifier: MIT
3
3
 
4
4
  import { createWriteStream } from 'node:fs';
5
- import { mkdir } from 'node:fs/promises';
5
+ import { mkdir, readdir } from 'node:fs/promises';
6
6
  import { join, dirname, extname } from 'node:path';
7
7
 
8
8
  export const disk = (wishes, logs, memory, crew) => Object.freeze({
9
- lines: (wish) => [
10
- `The wish is written in the file ${join(wishes, wish.name())}, ${wish.fate()}.`,
11
- 'Never delete, move, or rename the file of this wish yourself, even after you split it into smaller wishes, since Jinka takes care of it.',
12
- `All wishes live in the directory ${wishes}, and you may add new wishes there as Markdown files.`,
13
- `If this wish is an atomic job that involves no other jobs, make it come true yourself;`,
14
- `otherwise, do not do it yourself, but split it into smaller wishes`,
15
- `and write each of them as a separate Markdown file in the directory ${join(wishes, dirname(wish.name()))}.`,
16
- `Always start the name of every new wish file with a small English letter, never with a capital one.`,
17
- `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.`,
18
- `Never spend time waiting while doing a wish, neither sleeping nor polling for a build, a review, a merge, or anything else;`,
19
- `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.`,
20
- `Your memory lives in the directory ${memory}, keep there what the skills tell you to keep.`,
21
- `Jinka runs ${crew.size()} threads right now, so up to ${crew.size()} wishes run at once.`
22
- ],
9
+ lines: async (wish) => {
10
+ const count = (await readdir(wishes, { recursive: true, withFileTypes: true }))
11
+ .filter((f) => f.isFile() && f.name.endsWith('.md'))
12
+ .length;
13
+ return [
14
+ `The wish is written in the file ${join(wishes, wish.name())}, ${wish.fate()}.`,
15
+ 'Never delete, move, or rename the file of this wish yourself, even after you split it into smaller wishes, since Jinka takes care of it.',
16
+ `All wishes live in the directory ${wishes}, and you may add new wishes there as Markdown files.`,
17
+ `If this wish is an atomic job that involves no other jobs, make it come true yourself;`,
18
+ `otherwise, do not do it yourself, but split it into smaller wishes`,
19
+ `and write each of them as a separate Markdown file in the directory ${join(wishes, dirname(wish.name()))}.`,
20
+ `Always start the name of every new wish file with a small English letter, never with a capital one.`,
21
+ 'Write every new wish the way a person would ask for something, in a few plain sentences that say what must be achieved and give exact links to the issue, pull request, or repository.',
22
+ 'Keep every new wish shorter than 1000 characters.',
23
+ 'Never copy the text of the current wish, or of these instructions, into a new wish, since Jinka gives these instructions to every wish anyway;',
24
+ 'copy only the facts that the new wish cannot find on its own, such as a chat ID or a token that the current wish explicitly asks to pass along.',
25
+ 'Leave evidence, observations, logs, and the history of your work out of a new wish, since the new wish checks the current state by itself.',
26
+ `Write at most ${crew.size()} new wishes while doing this wish;`,
27
+ 'if more work remains than they can hold, make one of them a continuation that splits the rest later.',
28
+ `The directory ${wishes} holds ${count} wishes right now.`,
29
+ ...(count >= 100 ? ['Since that is 100 or more, never split this wish into new wishes, but do all of it yourself, and write no other new wish either, not even to hand over waiting.'] : []),
30
+ `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.`,
31
+ `Never spend time waiting while doing a wish, neither sleeping nor polling for a build, a review, a merge, or anything else;`,
32
+ `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.`,
33
+ `Your memory lives in the directory ${memory}, keep there what the skills tell you to keep.`,
34
+ `Jinka runs ${crew.size()} threads right now, so up to ${crew.size()} wishes run at once.`
35
+ ];
36
+ },
23
37
  args: () => ['--add-dir', wishes, '--add-dir', memory],
24
38
  log: async (wish) => {
25
39
  const file = join(
package/src/genie.js CHANGED
@@ -4,16 +4,15 @@
4
4
  import { createInterface } from 'node:readline';
5
5
  import { event } from './event.js';
6
6
  import { tokens } from './tokens.js';
7
- import { model } from './model.js';
8
7
 
9
- export const genie = (engine, disk, meter) => Object.freeze({
8
+ export const genie = (engine, disk, meter, model) => Object.freeze({
10
9
  run: async (wish, row, signal) => {
11
10
  const prompt = [
12
11
  'Make this wish come true, working on your own without asking any questions:',
13
12
  '',
14
13
  await wish.text(),
15
14
  '',
16
- ...disk.lines(wish),
15
+ ...(await disk.lines(wish)),
17
16
  ...engine.lines(),
18
17
  'When you finish, end with a single plain sentence that says what you did.',
19
18
  'On the next line, write the full URL of the GitHub issue or pull request you worked on, if there was one.'
package/src/hello.js ADDED
@@ -0,0 +1,29 @@
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
+
7
+ export const hello = (engine, notice) => Object.freeze({
8
+ run: (signal) => new Promise((resolve, reject) => {
9
+ const child = engine.start([], signal);
10
+ const answer = [''];
11
+ child.stdin.on('error', () => {});
12
+ child.stdin.end('Tell me, who you are, in a very short single sentence.');
13
+ child.stderr.resume();
14
+ createInterface({ input: child.stdout }).on('line', (line) => {
15
+ answer[0] = event(line, 0).summary() || answer[0];
16
+ });
17
+ child.on('error', (e) => (signal.aborted ? resolve() : reject(new Error(`Cannot start the agent "${child.spawnfile}": ${e.message}`))));
18
+ child.on('close', (code) => {
19
+ if (signal.aborted) {
20
+ resolve();
21
+ } else if (code === 0) {
22
+ notice.info(`the agent says: ${answer[0]}`);
23
+ resolve();
24
+ } else {
25
+ reject(new Error(`The agent "${child.spawnfile}" exited with code ${code} while telling who it is: ${answer[0]}`));
26
+ }
27
+ });
28
+ })
29
+ });
package/src/model.js CHANGED
@@ -15,7 +15,11 @@ export const model = () => {
15
15
  }
16
16
  },
17
17
  text: () => {
18
- const parts = (names.at(-1) ?? '').replace(/^claude-/, '').replace(/-\d{8}$/, '').split('-').filter((p) => p !== '');
18
+ const name = names.at(-1) ?? '';
19
+ if (!name.startsWith('claude-')) {
20
+ return name;
21
+ }
22
+ const parts = name.replace(/^claude-/, '').replace(/-\d{8}$/, '').split('-').filter((p) => p !== '');
19
23
  return [
20
24
  ...parts.filter((p) => !/^\d+$/.test(p)).map((p) => `${p[0].toUpperCase()}${p.slice(1)}`),
21
25
  parts.filter((p) => /^\d+$/.test(p)).join('.')
package/src/pile.js ADDED
@@ -0,0 +1,8 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) 2026 Yegor Bugayenko
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { readdirSync } from 'node:fs';
5
+
6
+ export const pile = (dir) => Object.freeze({
7
+ text: () => `${readdirSync(dir, { recursive: true, withFileTypes: true }).filter((f) => f.isFile() && f.name.endsWith('.md')).length}`
8
+ });
@@ -0,0 +1,34 @@
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 const recorded = (origin, dir) => {
8
+ let thread = '';
9
+ return Object.freeze({
10
+ add: (line) => {
11
+ origin.add(line);
12
+ try {
13
+ const json = JSON.parse(line);
14
+ if (json.type === 'thread.started' && typeof json.thread_id === 'string') {
15
+ thread = json.thread_id;
16
+ }
17
+ if (json.type === 'turn.completed' && thread !== '' && existsSync(dir)) {
18
+ readdirSync(dir, { recursive: true })
19
+ .map(String)
20
+ .filter((path) => path.endsWith(`-${thread}.jsonl`))
21
+ .flatMap((path) => readFileSync(join(dir, path), 'utf8').split('\n'))
22
+ .filter((text) => text.includes('"turn_context"'))
23
+ .map((text) => JSON.parse(text).payload?.model)
24
+ .filter((name) => typeof name === 'string')
25
+ .slice(-1)
26
+ .forEach((name) => origin.add(JSON.stringify({ type: 'system', subtype: 'init', model: name })));
27
+ }
28
+ } catch {
29
+ return;
30
+ }
31
+ },
32
+ text: () => origin.text()
33
+ });
34
+ };
package/src/status.js CHANGED
@@ -4,12 +4,12 @@
4
4
  import { styleText } from 'node:util';
5
5
  import { duration } from './duration.js';
6
6
 
7
- export const status = (start, jobs, agent, model, tokens, quota) => Object.freeze({
7
+ export const status = (start, pile, jobs, agent, model, tokens, quota) => Object.freeze({
8
8
  text: () => {
9
9
  const now = Date.now();
10
10
  return [
11
11
  `up:${styleText('bgGray', duration(now - start).hours(), { validateStream: false })}`,
12
- `done:${styleText('bgGray', jobs.text(), { validateStream: false })}`,
12
+ `wishes:${styleText('bgGray', `${pile.text()}/${jobs.text()}`, { validateStream: false })}`,
13
13
  `agent:${styleText('bgGray', agent, { validateStream: false })}`,
14
14
  `model:${styleText('bgGray', model === '' ? 'default' : model, { validateStream: false })}`,
15
15
  `tokens:${styleText('bgGray', tokens.text(), { validateStream: false })}`,
package/src/tty.js CHANGED
@@ -17,9 +17,18 @@ export const tty = (stream, crew, status, legend) => {
17
17
  const [left, right] = text.split('\t');
18
18
  return `${left}${' '.repeat(Math.max(1, width - 1 - stripVTControlCharacters(left).length - stripVTControlCharacters(right).length))}${right}`;
19
19
  };
20
- const wrap = (text, width) => (
21
- stripVTControlCharacters(text).length <= width || width < 3 ? [text] : [sliceAnsi(text, 0, width), ...wrap(` ${sliceAnsi(text, width)}`, width)]
22
- );
20
+ const cut = (text, width) => {
21
+ const plain = stripVTControlCharacters(text);
22
+ const space = plain.lastIndexOf(' ', width);
23
+ return space > 2 && plain.slice(space + 1).split(' ')[0].length <= width - 2 ? [space, space + 1] : [width, width];
24
+ };
25
+ const wrap = (text, width) => {
26
+ if (stripVTControlCharacters(text).length <= width || width < 3) {
27
+ return [text];
28
+ }
29
+ const [end, start] = cut(text, width);
30
+ return [sliceAnsi(text, 0, end), ...wrap(` ${sliceAnsi(text, start)}`, width)];
31
+ };
23
32
  const draw = () => {
24
33
  const rows = stream.rows || 24;
25
34
  const wands = crew.indexes().map((i) => [i, lines.get(i) ?? { head: '', tail: '', start: Date.now() }]);
@@ -63,9 +72,9 @@ export const tty = (stream, crew, status, legend) => {
63
72
  lines.set(index, { head, tail, start: head === line.head ? line.start : Date.now() });
64
73
  paint();
65
74
  };
66
- const note = (text) => {
75
+ const note = (level, text) => {
67
76
  notes.push(
68
- `${new Date().toTimeString().slice(0, 8)} ${text.replace(/https:\/\/github\.com\/([^/\s]+\/[^/\s]+)\/(?:issues|pull)\/(\d+)/g, (url, repo, number) => `\x1b]8;;${url}\x07\x1b[38;2;106;113;247m${repo}#${number}\x1b[39m\x1b]8;;\x07`)}`
77
+ `${level === 'warn' ? `\x1b[38;2;255;135;0m${new Date().toTimeString().slice(0, 8)}\x1b[39m` : new Date().toTimeString().slice(0, 8)} ${text.replace(/https:\/\/github\.com\/([^/\s]+\/[^/\s]+)\/(?:issues|pull)\/(\d+)/g, (url, repo, number) => `\x1b]8;;${url}\x07\x1b[38;2;106;113;247m${repo}#${number}\x1b[39m\x1b]8;;\x07`)}`
69
78
  );
70
79
  if (view.depth > 0) {
71
80
  view.depth += wrap(notes.at(-1), stream.columns || 80).length;
@@ -74,7 +83,7 @@ export const tty = (stream, crew, status, legend) => {
74
83
  };
75
84
  return Object.freeze({
76
85
  row: (index) => row({ push: ([i, text]) => update(i, text) }, index),
77
- notice: () => protocol({ push: ([level, text]) => note(level === 'warn' ? `\x1b[38;2;255;135;0m${text}\x1b[39m` : text) }),
86
+ notice: () => protocol({ push: ([level, text]) => note(level, text) }),
78
87
  up: () => {
79
88
  view.depth += 1;
80
89
  redraw();
package/src/wishes.js CHANGED
@@ -15,8 +15,7 @@ export const wishes = (dir, interval, archive = '', pause = 0, notice = { warn:
15
15
  signal.throwIfAborted();
16
16
  const files = (await readdir(dir, { recursive: true, withFileTypes: true }))
17
17
  .filter((f) => f.isFile() && f.name.endsWith('.md'))
18
- .map((f) => join(relative(dir, f.parentPath), f.name))
19
- .sort();
18
+ .map((f) => join(relative(dir, f.parentPath), f.name));
20
19
  signal.throwIfAborted();
21
20
  if (startup.length === 0) {
22
21
  startup.push(new Set(files));
@@ -25,7 +24,8 @@ export const wishes = (dir, interval, archive = '', pause = 0, notice = { warn:
25
24
  notice.warn(`found ${count} pre-existing ${count === 1 ? 'wish' : 'wishes'} in ${dir} that ${count === 1 ? 'is' : 'are'} not permanent`);
26
25
  }
27
26
  }
28
- const path = files.find((f) => !claims.has(f))
27
+ const fresh = files.filter((f) => !claims.has(f));
28
+ const path = fresh[Math.floor(Math.random() * fresh.length)]
29
29
  ?? [...claims.keys()].find((c) => !claims.get(c) && files.includes(c));
30
30
  if (path === undefined) {
31
31
  await setTimeout(interval, undefined, { signal });