@tiptask/runner 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -16
- package/dist/cli.js +179 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -23,10 +23,31 @@ of kind External. The key appears one time only, at creation.
|
|
|
23
23
|
**2. Enable MCP** for the project, in Settings, MCP Server. MCP is off by default. The
|
|
24
24
|
agent reads the issue and writes its result through it.
|
|
25
25
|
|
|
26
|
-
**3.
|
|
26
|
+
**3. Start the runner** in the folder the coding agent should work in:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
npx -y @tiptask/runner
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Without a `tiptask-runner.json` there, the runner asks for the instance URL, the key, the
|
|
33
|
+
coding agent and the working folder. It checks that the URL is the TipTask API, and takes
|
|
34
|
+
`<url>/api` when the API is there. It marks the coding agents that are installed. Then
|
|
35
|
+
it writes `tiptask-runner.json`, writes `.mcp.json` for Claude Code, Pi and Oh My Pi, and
|
|
36
|
+
starts. Run `npx -y @tiptask/runner init` to answer the questions again.
|
|
37
|
+
|
|
38
|
+
The project shows the agent as online in a few seconds. Delegate an issue to the agent,
|
|
39
|
+
or mention it in a comment. The task then starts in your terminal.
|
|
40
|
+
|
|
41
|
+
`tiptask-runner.json` holds the key. Keep it out of git.
|
|
42
|
+
|
|
43
|
+
### The same setup by hand
|
|
44
|
+
|
|
45
|
+
A runner that a service manager starts has no terminal to ask in, so write the two files
|
|
46
|
+
yourself.
|
|
47
|
+
|
|
48
|
+
**Give the coding agent the address of the MCP server.** Claude Code, Oh My Pi, and Pi
|
|
27
49
|
(through `pi-mcp-adapter`) read `.mcp.json` from the folder they run in. For the other
|
|
28
|
-
CLIs, see
|
|
29
|
-
[Coding agent setup](../../docs/runner.md).
|
|
50
|
+
CLIs, see [Coding agent setup](https://gitlab.tma.com.vn/young-turtles/tiptask/-/blob/develop/docs/runner.md).
|
|
30
51
|
|
|
31
52
|
```json
|
|
32
53
|
{
|
|
@@ -43,7 +64,7 @@ CLIs, see
|
|
|
43
64
|
The runner sets `TIPTASK_API_KEY` for the command that it starts. The key thus stays in
|
|
44
65
|
one file.
|
|
45
66
|
|
|
46
|
-
**
|
|
67
|
+
**Write `tiptask-runner.json`** in the same folder:
|
|
47
68
|
|
|
48
69
|
```json
|
|
49
70
|
{
|
|
@@ -53,15 +74,6 @@ one file.
|
|
|
53
74
|
}
|
|
54
75
|
```
|
|
55
76
|
|
|
56
|
-
**5. Start it:**
|
|
57
|
-
|
|
58
|
-
```bash
|
|
59
|
-
npx -y @tiptask/runner
|
|
60
|
-
```
|
|
61
|
-
|
|
62
|
-
The project shows the agent as online in a few seconds. Delegate an issue to the agent,
|
|
63
|
-
or mention it in a comment. The task then starts in your terminal.
|
|
64
|
-
|
|
65
77
|
## Which coding agent
|
|
66
78
|
|
|
67
79
|
`agent` names a CLI that the runner knows. The runner then builds the command itself: the
|
|
@@ -78,7 +90,7 @@ flags for an unattended run, and the session resume. Every preset keeps a chat s
|
|
|
78
90
|
| `omp` | Oh My Pi |
|
|
79
91
|
|
|
80
92
|
Each preset needs the CLI's own MCP config.
|
|
81
|
-
[Coding agent setup](
|
|
93
|
+
[Coding agent setup](https://gitlab.tma.com.vn/young-turtles/tiptask/-/blob/develop/docs/runner.md) holds
|
|
82
94
|
that file, and the notes for each CLI.
|
|
83
95
|
|
|
84
96
|
`--agent` on the command line has priority over the file, except over an `agents` entry
|
|
@@ -232,8 +244,10 @@ example `http://localhost:4000`.
|
|
|
232
244
|
|
|
233
245
|
## Settings
|
|
234
246
|
|
|
235
|
-
The runner reads `./tiptask-runner.json`.
|
|
236
|
-
|
|
247
|
+
The runner reads `./tiptask-runner.json`. Without it, and without `TIPTASK_URL` or
|
|
248
|
+
`TIPTASK_API_KEY`, the runner asks the setup questions in a terminal and stops with an
|
|
249
|
+
example file elsewhere. For a different file, give the path as an argument or in
|
|
250
|
+
`TIPTASK_RUNNER_CONFIG`:
|
|
237
251
|
|
|
238
252
|
```bash
|
|
239
253
|
npx -y @tiptask/runner /path/to/config.json
|
package/dist/cli.js
CHANGED
|
@@ -15,6 +15,9 @@ class RequestError extends Error {
|
|
|
15
15
|
}
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
class NotApiError extends Error {
|
|
19
|
+
}
|
|
20
|
+
|
|
18
21
|
class Client {
|
|
19
22
|
config;
|
|
20
23
|
constructor(config) {
|
|
@@ -36,6 +39,9 @@ class Client {
|
|
|
36
39
|
if (!res.ok) {
|
|
37
40
|
throw new RequestError(res.status, `POST ${path} failed with ${res.status}: ${(await res.text()).slice(0, 200)}`);
|
|
38
41
|
}
|
|
42
|
+
if (res.headers.get("content-type")?.includes("text/html")) {
|
|
43
|
+
throw new NotApiError(`${this.config.url} answered with a web page, not the TipTask API — set url to ` + `the API's address, often ${this.config.url}/api`);
|
|
44
|
+
}
|
|
39
45
|
return res;
|
|
40
46
|
}
|
|
41
47
|
async claim() {
|
|
@@ -537,6 +543,165 @@ function startListener(port, agents, log) {
|
|
|
537
543
|
});
|
|
538
544
|
}
|
|
539
545
|
|
|
546
|
+
// src/setup.ts
|
|
547
|
+
import { existsSync } from "node:fs";
|
|
548
|
+
import { writeFile } from "node:fs/promises";
|
|
549
|
+
import { homedir } from "node:os";
|
|
550
|
+
import { dirname, join, resolve } from "node:path";
|
|
551
|
+
import { createInterface } from "node:readline";
|
|
552
|
+
var DOCS_URL = "https://gitlab.tma.com.vn/young-turtles/tiptask/-/blob/develop/docs/runner.md";
|
|
553
|
+
var AGENTS = {
|
|
554
|
+
claude: { label: "Claude Code", anchor: "claude-code" },
|
|
555
|
+
codex: { label: "Codex CLI", anchor: "codex" },
|
|
556
|
+
opencode: { label: "opencode", anchor: "opencode" },
|
|
557
|
+
antigravity: { label: "Antigravity CLI", anchor: "antigravity-cli" },
|
|
558
|
+
copilot: { label: "GitHub Copilot CLI", anchor: "github-copilot-cli" },
|
|
559
|
+
pi: { label: "Pi", anchor: "pi" },
|
|
560
|
+
omp: { label: "Oh My Pi", anchor: "oh-my-pi" }
|
|
561
|
+
};
|
|
562
|
+
var READS_MCP_JSON = ["claude", "pi", "omp"];
|
|
563
|
+
var EXAMPLE = `{
|
|
564
|
+
"url": "https://your-tiptask-instance",
|
|
565
|
+
"apiKey": "the agent's key from Settings, AI agents",
|
|
566
|
+
"agent": "claude"
|
|
567
|
+
}`;
|
|
568
|
+
function needsSetup(configPath) {
|
|
569
|
+
return !existsSync(configPath) && !process.env.TIPTASK_API_KEY && !process.env.TIPTASK_URL;
|
|
570
|
+
}
|
|
571
|
+
function missingConfigMessage(configPath) {
|
|
572
|
+
return [
|
|
573
|
+
`no config found at ${resolve(configPath)}`,
|
|
574
|
+
"",
|
|
575
|
+
"Run `npx -y @tiptask/runner init` in a terminal, or write tiptask-runner.json:",
|
|
576
|
+
"",
|
|
577
|
+
EXAMPLE,
|
|
578
|
+
"",
|
|
579
|
+
`agent is one of: ${PRESET_NAMES.join(", ")}`,
|
|
580
|
+
`Guide: ${DOCS_URL}`
|
|
581
|
+
].join(`
|
|
582
|
+
`);
|
|
583
|
+
}
|
|
584
|
+
function installedAgents(path = process.env.PATH ?? "", platform = process.platform) {
|
|
585
|
+
const dirs = path.split(platform === "win32" ? ";" : ":").filter(Boolean);
|
|
586
|
+
const exts = platform === "win32" ? [".exe", ".cmd", ".bat", ""] : [""];
|
|
587
|
+
return PRESET_NAMES.filter((name) => dirs.some((dir) => exts.some((ext) => existsSync(join(dir, PRESETS[name].bin + ext)))));
|
|
588
|
+
}
|
|
589
|
+
async function apiUrlOf(url) {
|
|
590
|
+
for (const candidate of [url, `${url}/api`]) {
|
|
591
|
+
try {
|
|
592
|
+
const res = await fetch(`${candidate}/agent-runs/claim`, {
|
|
593
|
+
method: "POST",
|
|
594
|
+
signal: AbortSignal.timeout(1e4)
|
|
595
|
+
});
|
|
596
|
+
if (res.headers.get("content-type")?.includes("application/json"))
|
|
597
|
+
return candidate;
|
|
598
|
+
} catch {}
|
|
599
|
+
}
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
async function runSetup(configPath, options = {}, io = { input: process.stdin, output: process.stdout }) {
|
|
603
|
+
const rl = createInterface({ input: io.input, terminal: false });
|
|
604
|
+
const lines = rl[Symbol.asyncIterator]();
|
|
605
|
+
const say = (text = "") => io.output.write(`${text}
|
|
606
|
+
`);
|
|
607
|
+
const ask = async (question, fallback = "") => {
|
|
608
|
+
io.output.write(fallback ? `? ${question} (${fallback}): ` : `? ${question}: `);
|
|
609
|
+
const next = await lines.next();
|
|
610
|
+
if (next.done)
|
|
611
|
+
throw new Error("setup was cancelled");
|
|
612
|
+
return next.value.trim() || fallback;
|
|
613
|
+
};
|
|
614
|
+
const yes = async (question, fallback) => /^y/i.test(await ask(`${question} (${fallback ? "Y/n" : "y/N"})`, fallback ? "y" : "n"));
|
|
615
|
+
try {
|
|
616
|
+
const file = resolve(configPath);
|
|
617
|
+
if (existsSync(file)) {
|
|
618
|
+
if (!await yes(`${file} exists. Replace it?`, false))
|
|
619
|
+
return false;
|
|
620
|
+
} else {
|
|
621
|
+
say(`TipTask runner setup — no tiptask-runner.json in ${dirname(file)}`);
|
|
622
|
+
}
|
|
623
|
+
say();
|
|
624
|
+
let url = "";
|
|
625
|
+
while (!url) {
|
|
626
|
+
const answer = (await ask("TipTask URL", "http://localhost:3000")).replace(/\/+$/, "");
|
|
627
|
+
if (!/^https?:\/\/\S+$/.test(answer))
|
|
628
|
+
continue;
|
|
629
|
+
const api = await apiUrlOf(answer);
|
|
630
|
+
if (api && api !== answer)
|
|
631
|
+
say(` The TipTask API answers at ${api} — using that`);
|
|
632
|
+
if (api)
|
|
633
|
+
url = api;
|
|
634
|
+
else if (await yes(`No TipTask API answered at ${answer}. Use it anyway?`, false)) {
|
|
635
|
+
url = answer;
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
let apiKey = "";
|
|
639
|
+
while (!apiKey)
|
|
640
|
+
apiKey = await ask("Agent key (Settings, AI agents, your External agent's key)");
|
|
641
|
+
const installed = installedAgents(options.path);
|
|
642
|
+
say("? Coding agent:");
|
|
643
|
+
PRESET_NAMES.forEach((name, i) => {
|
|
644
|
+
const mark = installed.includes(name) ? "✓ installed" : "";
|
|
645
|
+
say(` ${i + 1}) ${name.padEnd(12)} ${AGENTS[name].label.padEnd(19)} ${mark}`.trimEnd());
|
|
646
|
+
});
|
|
647
|
+
const preferred = PRESET_NAMES.find((name) => name === options.agent) ?? installed[0];
|
|
648
|
+
const fallback = String(preferred ? PRESET_NAMES.indexOf(preferred) + 1 : 1);
|
|
649
|
+
let agent;
|
|
650
|
+
while (!agent) {
|
|
651
|
+
const answer = await ask(" Choose", fallback);
|
|
652
|
+
agent = PRESET_NAMES.find((name, i) => answer === name || answer === String(i + 1));
|
|
653
|
+
}
|
|
654
|
+
let cwd = "";
|
|
655
|
+
while (!existsSync(cwd)) {
|
|
656
|
+
const answer = await ask("Folder the agent works in", process.cwd());
|
|
657
|
+
cwd = resolve(answer.replace(/^~(?=$|[\\/])/, homedir()));
|
|
658
|
+
}
|
|
659
|
+
say();
|
|
660
|
+
const config = {
|
|
661
|
+
url,
|
|
662
|
+
apiKey,
|
|
663
|
+
agent,
|
|
664
|
+
...cwd === dirname(file) ? {} : { cwd }
|
|
665
|
+
};
|
|
666
|
+
await writeFile(file, `${JSON.stringify(config, null, 2)}
|
|
667
|
+
`, { mode: 384 });
|
|
668
|
+
say(`✓ Wrote ${file} — it holds your key, keep it out of git`);
|
|
669
|
+
const mcpFile = join(cwd, ".mcp.json");
|
|
670
|
+
if (!READS_MCP_JSON.includes(agent)) {
|
|
671
|
+
say(` ${AGENTS[agent].label} needs the TipTask MCP server in its own config:`);
|
|
672
|
+
say(` ${DOCS_URL}#${AGENTS[agent].anchor}`);
|
|
673
|
+
} else if (existsSync(mcpFile)) {
|
|
674
|
+
say(` ${mcpFile} exists — add the tiptask server to it: ${DOCS_URL}#${AGENTS[agent].anchor}`);
|
|
675
|
+
} else {
|
|
676
|
+
const mcp = {
|
|
677
|
+
mcpServers: {
|
|
678
|
+
tiptask: {
|
|
679
|
+
type: "http",
|
|
680
|
+
url: `${url}/mcp`,
|
|
681
|
+
headers: { Authorization: "Bearer ${TIPTASK_API_KEY}" }
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
};
|
|
685
|
+
await writeFile(mcpFile, `${JSON.stringify(mcp, null, 2)}
|
|
686
|
+
`);
|
|
687
|
+
say(`✓ Wrote ${mcpFile} — ${AGENTS[agent].label} reads the TipTask MCP server from it`);
|
|
688
|
+
}
|
|
689
|
+
if (agent === "pi")
|
|
690
|
+
say(" Pi reads it through an extension: pi install npm:pi-mcp-adapter");
|
|
691
|
+
if (!installed.includes(agent)) {
|
|
692
|
+
say(` ${PRESETS[agent].bin} is not on PATH — install and sign in to it first`);
|
|
693
|
+
}
|
|
694
|
+
say(" Enable MCP for the project in TipTask: Settings, MCP Server.");
|
|
695
|
+
say();
|
|
696
|
+
const start = await yes("Start the runner now?", true);
|
|
697
|
+
if (!start)
|
|
698
|
+
say("Start it later with: npx -y @tiptask/runner");
|
|
699
|
+
return start;
|
|
700
|
+
} finally {
|
|
701
|
+
rl.close();
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
540
705
|
// src/agui.ts
|
|
541
706
|
var FLUSH_CHARS = 1500;
|
|
542
707
|
var DELTA_LIMIT = 12000;
|
|
@@ -1137,9 +1302,9 @@ async function execute(config, task, opts = {}) {
|
|
|
1137
1302
|
});
|
|
1138
1303
|
child.stdin.on("error", () => {});
|
|
1139
1304
|
child.stdin.end(stdinText(preset, task));
|
|
1140
|
-
const exited = new Promise((
|
|
1305
|
+
const exited = new Promise((resolve2, reject) => {
|
|
1141
1306
|
child.on("error", reject);
|
|
1142
|
-
child.on("close", (exitCode, exitSignal) =>
|
|
1307
|
+
child.on("close", (exitCode, exitSignal) => resolve2({ code: exitCode, signal: exitSignal }));
|
|
1143
1308
|
});
|
|
1144
1309
|
let code;
|
|
1145
1310
|
let signal;
|
|
@@ -1340,6 +1505,8 @@ async function drain(state, log2, concurrency, take, run, onEmpty) {
|
|
|
1340
1505
|
try {
|
|
1341
1506
|
item = await take();
|
|
1342
1507
|
} catch (err) {
|
|
1508
|
+
if (err instanceof NotApiError)
|
|
1509
|
+
throw err;
|
|
1343
1510
|
if (err instanceof RequestError && (err.status === 401 || err.status === 403))
|
|
1344
1511
|
throw err;
|
|
1345
1512
|
log2(`claim failed: ${String(err)}`);
|
|
@@ -1441,8 +1608,16 @@ function settle(name, work) {
|
|
|
1441
1608
|
});
|
|
1442
1609
|
}
|
|
1443
1610
|
async function main() {
|
|
1444
|
-
const
|
|
1611
|
+
const argv = process.argv.slice(2);
|
|
1612
|
+
const init = argv[0] === "init";
|
|
1613
|
+
const cli = parseArgv(init ? argv.slice(1) : argv);
|
|
1445
1614
|
const configPath = cli.configPath ?? process.env.TIPTASK_RUNNER_CONFIG ?? "./tiptask-runner.json";
|
|
1615
|
+
if (init || needsSetup(configPath)) {
|
|
1616
|
+
if (!process.stdin.isTTY)
|
|
1617
|
+
throw new Error(missingConfigMessage(configPath));
|
|
1618
|
+
if (!await runSetup(configPath, { agent: cli.agent }))
|
|
1619
|
+
return;
|
|
1620
|
+
}
|
|
1446
1621
|
const configs = await loadConfig(configPath, {
|
|
1447
1622
|
agent: cli.agent,
|
|
1448
1623
|
args: cli.args,
|
|
@@ -1452,7 +1627,7 @@ async function main() {
|
|
|
1452
1627
|
process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
|
|
1453
1628
|
}
|
|
1454
1629
|
let stop;
|
|
1455
|
-
const state = { stopping: false, stopped: new Promise((
|
|
1630
|
+
const state = { stopping: false, stopped: new Promise((resolve2) => stop = resolve2) };
|
|
1456
1631
|
for (const signal of ["SIGINT", "SIGTERM"]) {
|
|
1457
1632
|
process.on(signal, () => {
|
|
1458
1633
|
if (state.stopping) {
|