@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.
Files changed (3) hide show
  1. package/README.md +30 -16
  2. package/dist/cli.js +179 -4
  3. 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. Give the coding agent the address of that server.** Claude Code, Oh My Pi, and Pi
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
- **4. Write `tiptask-runner.json`** in the same folder:
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](../../docs/runner.md) holds
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`. For a different file, give the path as an
236
- argument or in `TIPTASK_RUNNER_CONFIG`:
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((resolve, reject) => {
1305
+ const exited = new Promise((resolve2, reject) => {
1141
1306
  child.on("error", reject);
1142
- child.on("close", (exitCode, exitSignal) => resolve({ code: exitCode, signal: 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 cli = parseArgv(process.argv.slice(2));
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((resolve) => stop = resolve) };
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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiptask/runner",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Runs an TipTask external agent's queued tasks and answers its chat on your own machine",
5
5
  "keywords": [
6
6
  "tiptask",