tono 0.1.0 → 0.2.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/LICENSE +21 -674
- package/README.md +300 -0
- package/dist/cli/commands/config.js +86 -0
- package/dist/cli/commands/config.js.map +1 -0
- package/dist/cli/commands/configure.js +260 -0
- package/dist/cli/commands/configure.js.map +1 -0
- package/dist/cli/commands/gateway.js +194 -0
- package/dist/cli/commands/gateway.js.map +1 -0
- package/dist/cli/commands/init.js +89 -0
- package/dist/cli/commands/init.js.map +1 -0
- package/dist/cli/commands/open.js +23 -0
- package/dist/cli/commands/open.js.map +1 -0
- package/dist/cli/commands/start.js +116 -0
- package/dist/cli/commands/start.js.map +1 -0
- package/dist/cli/index.js +78 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/launchd.js +56 -0
- package/dist/cli/launchd.js.map +1 -0
- package/dist/cli/prompt.js +46 -0
- package/dist/cli/prompt.js.map +1 -0
- package/dist/server/agents/claude-code.js +80 -0
- package/dist/server/agents/claude-code.js.map +1 -0
- package/dist/server/agents/codex.js +52 -0
- package/dist/server/agents/codex.js.map +1 -0
- package/dist/server/agents/opencode.js +50 -0
- package/dist/server/agents/opencode.js.map +1 -0
- package/dist/server/agents/registry.js +16 -0
- package/dist/server/agents/registry.js.map +1 -0
- package/dist/server/agents/types.js +40 -0
- package/dist/server/agents/types.js.map +1 -0
- package/dist/server/app.js +324 -0
- package/dist/server/app.js.map +1 -0
- package/dist/server/config/load.js +91 -0
- package/dist/server/config/load.js.map +1 -0
- package/dist/server/config/manager.js +38 -0
- package/dist/server/config/manager.js.map +1 -0
- package/dist/server/config/schema.json +151 -0
- package/dist/server/db/client.js +136 -0
- package/dist/server/db/client.js.map +1 -0
- package/dist/server/db/queries.js +158 -0
- package/dist/server/db/queries.js.map +1 -0
- package/dist/server/db/schema.sql +61 -0
- package/dist/server/events.js +5 -0
- package/dist/server/events.js.map +1 -0
- package/dist/server/git/worktrees.js +225 -0
- package/dist/server/git/worktrees.js.map +1 -0
- package/dist/server/github/gh.js +172 -0
- package/dist/server/github/gh.js.map +1 -0
- package/dist/server/pty/manager.js +129 -0
- package/dist/server/pty/manager.js.map +1 -0
- package/dist/server/pty/ring-buffer.js +40 -0
- package/dist/server/pty/ring-buffer.js.map +1 -0
- package/dist/server/server.js +36 -0
- package/dist/server/server.js.map +1 -0
- package/dist/server/workers/github-poller.js +185 -0
- package/dist/server/workers/github-poller.js.map +1 -0
- package/dist/server/workers/pr-watcher.js +111 -0
- package/dist/server/workers/pr-watcher.js.map +1 -0
- package/dist/server/workers/scheduler.js +209 -0
- package/dist/server/workers/scheduler.js.map +1 -0
- package/dist/server/ws/pty.js +72 -0
- package/dist/server/ws/pty.js.map +1 -0
- package/dist/shared/types.js +23 -0
- package/dist/shared/types.js.map +1 -0
- package/dist/web/assets/index-5VFn-lxF.js +129 -0
- package/dist/web/assets/index-CZHd5NaX.css +1 -0
- package/dist/web/index.html +19 -0
- package/package.json +79 -6
- package/scripts/fix-node-pty.mjs +35 -0
- package/index.js +0 -2
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import * as readline from "node:readline/promises";
|
|
2
|
+
import { stdin, stdout } from "node:process";
|
|
3
|
+
const DIM = "\x1b[2m";
|
|
4
|
+
const BOLD = "\x1b[1m";
|
|
5
|
+
const ACCENT = "\x1b[38;5;208m"; // orange
|
|
6
|
+
const RESET = "\x1b[0m";
|
|
7
|
+
const RED = "\x1b[31m";
|
|
8
|
+
export function createPrompter() {
|
|
9
|
+
const rl = readline.createInterface({ input: stdin, output: stdout });
|
|
10
|
+
return {
|
|
11
|
+
async ask(question, opts = {}) {
|
|
12
|
+
while (true) {
|
|
13
|
+
const def = opts.default !== undefined ? `${DIM} [${opts.default}]${RESET}` : "";
|
|
14
|
+
const prompt = `${ACCENT}?${RESET} ${BOLD}${question}${RESET}${def} `;
|
|
15
|
+
const raw = (await rl.question(prompt)).trim();
|
|
16
|
+
const value = raw === "" ? (opts.default ?? "") : raw;
|
|
17
|
+
if (opts.required && value === "") {
|
|
18
|
+
stdout.write(`${RED}required${RESET}\n`);
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
if (opts.validate) {
|
|
22
|
+
const err = opts.validate(value);
|
|
23
|
+
if (err) {
|
|
24
|
+
stdout.write(`${RED}${err}${RESET}\n`);
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return value;
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
async askYesNo(question, defaultYes) {
|
|
32
|
+
const def = defaultYes ? "Y/n" : "y/N";
|
|
33
|
+
const raw = (await rl.question(`${ACCENT}?${RESET} ${BOLD}${question}${RESET}${DIM} [${def}]${RESET} `)).trim().toLowerCase();
|
|
34
|
+
if (raw === "")
|
|
35
|
+
return defaultYes;
|
|
36
|
+
return raw === "y" || raw === "yes";
|
|
37
|
+
},
|
|
38
|
+
print(line) {
|
|
39
|
+
stdout.write(line + "\n");
|
|
40
|
+
},
|
|
41
|
+
close() {
|
|
42
|
+
rl.close();
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
//# sourceMappingURL=prompt.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"prompt.js","sourceRoot":"","sources":["../../src/cli/prompt.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,QAAQ,MAAM,wBAAwB,CAAC;AACnD,OAAO,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,cAAc,CAAC;AAS7C,MAAM,GAAG,GAAG,SAAS,CAAC;AACtB,MAAM,IAAI,GAAG,SAAS,CAAC;AACvB,MAAM,MAAM,GAAG,gBAAgB,CAAC,CAAC,SAAS;AAC1C,MAAM,KAAK,GAAG,SAAS,CAAC;AACxB,MAAM,GAAG,GAAG,UAAU,CAAC;AAEvB,MAAM,UAAU,cAAc;IAC5B,MAAM,EAAE,GAAG,QAAQ,CAAC,eAAe,CAAC,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,CAAC,CAAC;IACtE,OAAO;QACL,KAAK,CAAC,GAAG,CAAC,QAAQ,EAAE,IAAI,GAAG,EAAE;YAC3B,OAAO,IAAI,EAAE,CAAC;gBACZ,MAAM,GAAG,GAAG,IAAI,CAAC,OAAO,KAAK,SAAS,CAAC,CAAC,CAAC,GAAG,GAAG,KAAK,IAAI,CAAC,OAAO,IAAI,KAAK,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;gBACjF,MAAM,MAAM,GAAG,GAAG,MAAM,IAAI,KAAK,IAAI,IAAI,GAAG,QAAQ,GAAG,KAAK,GAAG,GAAG,GAAG,CAAC;gBACtE,MAAM,GAAG,GAAG,CAAC,MAAM,EAAE,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;gBAC/C,MAAM,KAAK,GAAG,GAAG,KAAK,EAAE,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,OAAO,IAAI,EAAE,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC;gBACtD,IAAI,IAAI,CAAC,QAAQ,IAAI,KAAK,KAAK,EAAE,EAAE,CAAC;oBAClC,MAAM,CAAC,KAAK,CAAC,GAAG,GAAG,WAAW,KAAK,IAAI,CAAC,CAAC;oBACzC,SAAS;gBACX,CAAC;gBACD,IAAI,IAAI,CAAC,QAAQ,EAAE,CAAC;oBAClB,MAAM,GAAG,GAAG,IAAI,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC;oBACjC,IAAI,GAAG,EAAE,CAAC;wBACR,MAAM,CAAC,KAAK,CAAC,GAAG,GAAG,GAAG,GAAG,GAAG,KAAK,IAAI,CAAC,CAAC;wBACvC,SAAS;oBACX,CAAC;gBACH,CAAC;gBACD,OAAO,KAAK,CAAC;YACf,CAAC;QACH,CAAC;QACD,KAAK,CAAC,QAAQ,CAAC,QAAQ,EAAE,UAAU;YACjC,MAAM,GAAG,GAAG,UAAU,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC;YACvC,MAAM,GAAG,GAAG,CAAC,MAAM,EAAE,CAAC,QAAQ,CAAC,GAAG,MAAM,IAAI,KAAK,IAAI,IAAI,GAAG,QAAQ,GAAG,KAAK,GAAG,GAAG,KAAK,GAAG,IAAI,KAAK,GAAG,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,WAAW,EAAE,CAAC;YAC9H,IAAI,GAAG,KAAK,EAAE;gBAAE,OAAO,UAAU,CAAC;YAClC,OAAO,GAAG,KAAK,GAAG,IAAI,GAAG,KAAK,KAAK,CAAC;QACtC,CAAC;QACD,KAAK,CAAC,IAAI;YACR,MAAM,CAAC,KAAK,CAAC,IAAI,GAAG,IAAI,CAAC,CAAC;QAC5B,CAAC;QACD,KAAK;YACH,EAAE,CAAC,KAAK,EAAE,CAAC;QACb,CAAC;KACF,CAAC;AACJ,CAAC"}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { injectPromptIntoArgs, makeAgentEnv, renderPrompt, } from "./types.js";
|
|
5
|
+
/**
|
|
6
|
+
* Claude Code stores transcripts at ~/.claude/projects/<encoded-cwd>/<uuid>.jsonl
|
|
7
|
+
* where the encoded cwd has every non-alphanumeric character replaced with a
|
|
8
|
+
* dash.
|
|
9
|
+
*/
|
|
10
|
+
function projectDirFor(cwd) {
|
|
11
|
+
const encoded = cwd.replace(/[^a-zA-Z0-9]/g, "-");
|
|
12
|
+
return join(homedir(), ".claude", "projects", encoded);
|
|
13
|
+
}
|
|
14
|
+
/** Newest .jsonl filename without extension, or null if none exist. */
|
|
15
|
+
function newestSessionId(dir) {
|
|
16
|
+
if (!existsSync(dir))
|
|
17
|
+
return null;
|
|
18
|
+
try {
|
|
19
|
+
const files = readdirSync(dir).filter((f) => f.endsWith(".jsonl"));
|
|
20
|
+
if (files.length === 0)
|
|
21
|
+
return null;
|
|
22
|
+
let best = null;
|
|
23
|
+
for (const f of files) {
|
|
24
|
+
const m = statSync(join(dir, f)).mtimeMs;
|
|
25
|
+
if (!best || m > best.mtime)
|
|
26
|
+
best = { name: f, mtime: m };
|
|
27
|
+
}
|
|
28
|
+
return best.name.replace(/\.jsonl$/, "");
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const RESUME_FOLLOWUP = "You may have been interrupted. Check what you've already done in this " +
|
|
35
|
+
"worktree (uncommitted changes, recent commits, branch state) and pick up " +
|
|
36
|
+
"where you left off.";
|
|
37
|
+
function freshSpec({ agentConfig, ctx, worktreePath, kind }) {
|
|
38
|
+
const template = agentConfig.promptTemplates[kind];
|
|
39
|
+
const prompt = renderPrompt(template, ctx);
|
|
40
|
+
return {
|
|
41
|
+
command: agentConfig.command,
|
|
42
|
+
args: injectPromptIntoArgs(agentConfig.args, prompt),
|
|
43
|
+
cwd: worktreePath,
|
|
44
|
+
env: makeAgentEnv(ctx, kind),
|
|
45
|
+
resumed: false,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
function resumeSpec({ agentConfig, ctx, worktreePath, sessionId, kind, }) {
|
|
49
|
+
return {
|
|
50
|
+
command: agentConfig.command,
|
|
51
|
+
// claude --resume <id> jumps back into the conversation; we append a short
|
|
52
|
+
// follow-up so the model knows it's been interrupted.
|
|
53
|
+
args: ["--resume", sessionId, ...agentConfig.args, RESUME_FOLLOWUP],
|
|
54
|
+
cwd: worktreePath,
|
|
55
|
+
env: makeAgentEnv(ctx, kind),
|
|
56
|
+
resumed: true,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
async function captureSessionId({ cwd, timeoutMs = 30_000, }) {
|
|
60
|
+
const dir = projectDirFor(cwd);
|
|
61
|
+
const deadline = Date.now() + timeoutMs;
|
|
62
|
+
while (Date.now() < deadline) {
|
|
63
|
+
const id = newestSessionId(dir);
|
|
64
|
+
if (id)
|
|
65
|
+
return id;
|
|
66
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
function findExistingSession({ cwd }) {
|
|
71
|
+
return newestSessionId(projectDirFor(cwd));
|
|
72
|
+
}
|
|
73
|
+
export const claudeCodeAdapter = {
|
|
74
|
+
type: "claude-code",
|
|
75
|
+
buildFreshSpec: freshSpec,
|
|
76
|
+
buildResumeSpec: resumeSpec,
|
|
77
|
+
captureSessionId,
|
|
78
|
+
findExistingSession,
|
|
79
|
+
};
|
|
80
|
+
//# sourceMappingURL=claude-code.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"claude-code.js","sourceRoot":"","sources":["../../../src/server/agents/claude-code.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { injectPromptIntoArgs, makeAgentEnv, renderPrompt, } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Codex CLI (https://github.com/openai/codex) — non-subcommand form
|
|
4
|
+
* `codex [PROMPT]` starts an interactive TUI session seeded with the prompt.
|
|
5
|
+
*
|
|
6
|
+
* Sessions are stored under `~/.codex/sessions/<year>/...` but are keyed to the
|
|
7
|
+
* CLI account, **not the cwd** — so peeking on disk to auto-resume the "newest"
|
|
8
|
+
* session would risk pulling an unrelated conversation into a tono worktree.
|
|
9
|
+
* We deliberately don't track session ids here. Each dispatch is fresh; if a
|
|
10
|
+
* task fails the retry button issues a new fresh attempt.
|
|
11
|
+
*/
|
|
12
|
+
function freshSpec({ agentConfig, ctx, worktreePath, kind }) {
|
|
13
|
+
const template = agentConfig.promptTemplates[kind];
|
|
14
|
+
const prompt = renderPrompt(template, ctx);
|
|
15
|
+
return {
|
|
16
|
+
command: agentConfig.command,
|
|
17
|
+
args: injectPromptIntoArgs(agentConfig.args, prompt),
|
|
18
|
+
cwd: worktreePath,
|
|
19
|
+
env: makeAgentEnv(ctx, kind),
|
|
20
|
+
resumed: false,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
function resumeSpec({ agentConfig, ctx, worktreePath, sessionId, kind, }) {
|
|
24
|
+
// Codex resume convention is best-effort; if your version exposes a flag
|
|
25
|
+
// like `--resume <id>`, configure that via `agents.codex.args`. As a fallback
|
|
26
|
+
// we just spawn fresh with a "continue this work" preamble.
|
|
27
|
+
const template = agentConfig.promptTemplates[kind];
|
|
28
|
+
const prompt = `You may have been interrupted (prior session ${sessionId}). ` +
|
|
29
|
+
`Inspect the current worktree state and pick up where you left off.\n\n` +
|
|
30
|
+
renderPrompt(template, ctx);
|
|
31
|
+
return {
|
|
32
|
+
command: agentConfig.command,
|
|
33
|
+
args: injectPromptIntoArgs(agentConfig.args, prompt),
|
|
34
|
+
cwd: worktreePath,
|
|
35
|
+
env: makeAgentEnv(ctx, kind),
|
|
36
|
+
resumed: true,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
async function captureSessionId() {
|
|
40
|
+
return null; // see header comment — we don't track codex sessions per worktree
|
|
41
|
+
}
|
|
42
|
+
function findExistingSession() {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
export const codexAdapter = {
|
|
46
|
+
type: "codex",
|
|
47
|
+
buildFreshSpec: freshSpec,
|
|
48
|
+
buildResumeSpec: resumeSpec,
|
|
49
|
+
captureSessionId,
|
|
50
|
+
findExistingSession,
|
|
51
|
+
};
|
|
52
|
+
//# sourceMappingURL=codex.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"codex.js","sourceRoot":"","sources":["../../../src/server/agents/codex.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,oBAAoB,EACpB,YAAY,EACZ,YAAY,GAIb,MAAM,YAAY,CAAC;AAEpB;;;;;;;;;GASG;AAEH,SAAS,SAAS,CAAC,EAAE,WAAW,EAAE,GAAG,EAAE,YAAY,EAAE,IAAI,EAAY;IACnE,MAAM,QAAQ,GAAG,WAAW,CAAC,eAAe,CAAC,IAAI,CAAC,CAAC;IACnD,MAAM,MAAM,GAAG,YAAY,CAAC,QAAQ,EAAE,GAAG,CAAC,CAAC;IAC3C,OAAO;QACL,OAAO,EAAE,WAAW,CAAC,OAAO;QAC5B,IAAI,EAAE,oBAAoB,CAAC,WAAW,CAAC,IAAI,EAAE,MAAM,CAAC;QACpD,GAAG,EAAE,YAAY;QACjB,GAAG,EAAE,YAAY,CAAC,GAAG,EAAE,IAAI,CAAC;QAC5B,OAAO,EAAE,KAAK;KACf,CAAC;AACJ,CAAC;AAED,SAAS,UAAU,CAAC,EAClB,WAAW,EACX,GAAG,EACH,YAAY,EACZ,SAAS,EACT,IAAI,GAC6B;IACjC,yEAAyE;IACzE,8EAA8E;IAC9E,4DAA4D;IAC5D,MAAM,QAAQ,GAAG,WAAW,CAAC,eAAe,CAAC,IAAI,CAAC,CAAC;IACnD,MAAM,MAAM,GACV,gDAAgD,SAAS,KAAK;QAC9D,wEAAwE;QACxE,YAAY,CAAC,QAAQ,EAAE,GAAG,CAAC,CAAC;IAC9B,OAAO;QACL,OAAO,EAAE,WAAW,CAAC,OAAO;QAC5B,IAAI,EAAE,oBAAoB,CAAC,WAAW,CAAC,IAAI,EAAE,MAAM,CAAC;QACpD,GAAG,EAAE,YAAY;QACjB,GAAG,EAAE,YAAY,CAAC,GAAG,EAAE,IAAI,CAAC;QAC5B,OAAO,EAAE,IAAI;KACd,CAAC;AACJ,CAAC;AAED,KAAK,UAAU,gBAAgB;IAC7B,OAAO,IAAI,CAAC,CAAC,kEAAkE;AACjF,CAAC;AAED,SAAS,mBAAmB;IAC1B,OAAO,IAAI,CAAC;AACd,CAAC;AAED,MAAM,CAAC,MAAM,YAAY,GAAiB;IACxC,IAAI,EAAE,OAAO;IACb,cAAc,EAAE,SAAS;IACzB,eAAe,EAAE,UAAU;IAC3B,gBAAgB;IAChB,mBAAmB;CACpB,CAAC"}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { injectPromptIntoArgs, makeAgentEnv, renderPrompt, } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* OpenCode (https://github.com/sst/opencode) — `opencode run "<prompt>"` runs
|
|
4
|
+
* with a starter message in interactive mode (good for PTY streaming).
|
|
5
|
+
*
|
|
6
|
+
* Sessions are stored in a SQLite database at
|
|
7
|
+
* `~/.local/share/opencode/opencode.db` and keyed globally, **not by cwd**.
|
|
8
|
+
* We don't try to introspect the DB to auto-resume — each tono dispatch is
|
|
9
|
+
* fresh. If a task fails, the retry button issues a fresh attempt.
|
|
10
|
+
*/
|
|
11
|
+
function freshSpec({ agentConfig, ctx, worktreePath, kind }) {
|
|
12
|
+
const template = agentConfig.promptTemplates[kind];
|
|
13
|
+
const prompt = renderPrompt(template, ctx);
|
|
14
|
+
return {
|
|
15
|
+
command: agentConfig.command,
|
|
16
|
+
args: injectPromptIntoArgs(agentConfig.args, prompt),
|
|
17
|
+
cwd: worktreePath,
|
|
18
|
+
env: makeAgentEnv(ctx, kind),
|
|
19
|
+
resumed: false,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
function resumeSpec({ agentConfig, ctx, worktreePath, sessionId, kind, }) {
|
|
23
|
+
// OpenCode's resume story is version-dependent. Fallback: re-spawn fresh
|
|
24
|
+
// with a continuation preamble so the model can reorient.
|
|
25
|
+
const template = agentConfig.promptTemplates[kind];
|
|
26
|
+
const prompt = `You may have been interrupted (prior session ${sessionId}). ` +
|
|
27
|
+
`Inspect the current worktree state and pick up where you left off.\n\n` +
|
|
28
|
+
renderPrompt(template, ctx);
|
|
29
|
+
return {
|
|
30
|
+
command: agentConfig.command,
|
|
31
|
+
args: injectPromptIntoArgs(agentConfig.args, prompt),
|
|
32
|
+
cwd: worktreePath,
|
|
33
|
+
env: makeAgentEnv(ctx, kind),
|
|
34
|
+
resumed: true,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
async function captureSessionId() {
|
|
38
|
+
return null; // see header comment — we don't track opencode sessions per worktree
|
|
39
|
+
}
|
|
40
|
+
function findExistingSession() {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
export const opencodeAdapter = {
|
|
44
|
+
type: "opencode",
|
|
45
|
+
buildFreshSpec: freshSpec,
|
|
46
|
+
buildResumeSpec: resumeSpec,
|
|
47
|
+
captureSessionId,
|
|
48
|
+
findExistingSession,
|
|
49
|
+
};
|
|
50
|
+
//# sourceMappingURL=opencode.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"opencode.js","sourceRoot":"","sources":["../../../src/server/agents/opencode.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,oBAAoB,EACpB,YAAY,EACZ,YAAY,GAIb,MAAM,YAAY,CAAC;AAEpB;;;;;;;;GAQG;AAEH,SAAS,SAAS,CAAC,EAAE,WAAW,EAAE,GAAG,EAAE,YAAY,EAAE,IAAI,EAAY;IACnE,MAAM,QAAQ,GAAG,WAAW,CAAC,eAAe,CAAC,IAAI,CAAC,CAAC;IACnD,MAAM,MAAM,GAAG,YAAY,CAAC,QAAQ,EAAE,GAAG,CAAC,CAAC;IAC3C,OAAO;QACL,OAAO,EAAE,WAAW,CAAC,OAAO;QAC5B,IAAI,EAAE,oBAAoB,CAAC,WAAW,CAAC,IAAI,EAAE,MAAM,CAAC;QACpD,GAAG,EAAE,YAAY;QACjB,GAAG,EAAE,YAAY,CAAC,GAAG,EAAE,IAAI,CAAC;QAC5B,OAAO,EAAE,KAAK;KACf,CAAC;AACJ,CAAC;AAED,SAAS,UAAU,CAAC,EAClB,WAAW,EACX,GAAG,EACH,YAAY,EACZ,SAAS,EACT,IAAI,GAC6B;IACjC,yEAAyE;IACzE,0DAA0D;IAC1D,MAAM,QAAQ,GAAG,WAAW,CAAC,eAAe,CAAC,IAAI,CAAC,CAAC;IACnD,MAAM,MAAM,GACV,gDAAgD,SAAS,KAAK;QAC9D,wEAAwE;QACxE,YAAY,CAAC,QAAQ,EAAE,GAAG,CAAC,CAAC;IAC9B,OAAO;QACL,OAAO,EAAE,WAAW,CAAC,OAAO;QAC5B,IAAI,EAAE,oBAAoB,CAAC,WAAW,CAAC,IAAI,EAAE,MAAM,CAAC;QACpD,GAAG,EAAE,YAAY;QACjB,GAAG,EAAE,YAAY,CAAC,GAAG,EAAE,IAAI,CAAC;QAC5B,OAAO,EAAE,IAAI;KACd,CAAC;AACJ,CAAC;AAED,KAAK,UAAU,gBAAgB;IAC7B,OAAO,IAAI,CAAC,CAAC,qEAAqE;AACpF,CAAC;AAED,SAAS,mBAAmB;IAC1B,OAAO,IAAI,CAAC;AACd,CAAC;AAED,MAAM,CAAC,MAAM,eAAe,GAAiB;IAC3C,IAAI,EAAE,UAAU;IAChB,cAAc,EAAE,SAAS;IACzB,eAAe,EAAE,UAAU;IAC3B,gBAAgB;IAChB,mBAAmB;CACpB,CAAC"}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { claudeCodeAdapter } from "./claude-code.js";
|
|
2
|
+
import { codexAdapter } from "./codex.js";
|
|
3
|
+
import { opencodeAdapter } from "./opencode.js";
|
|
4
|
+
export { renderPrompt, makeAgentEnv, resolveAgentConfig, injectPromptIntoArgs } from "./types.js";
|
|
5
|
+
const ADAPTERS = {
|
|
6
|
+
"claude-code": claudeCodeAdapter,
|
|
7
|
+
codex: codexAdapter,
|
|
8
|
+
opencode: opencodeAdapter,
|
|
9
|
+
};
|
|
10
|
+
export function getAdapter(type) {
|
|
11
|
+
const a = ADAPTERS[type];
|
|
12
|
+
if (!a)
|
|
13
|
+
throw new Error(`No adapter for agent type "${type}"`);
|
|
14
|
+
return a;
|
|
15
|
+
}
|
|
16
|
+
//# sourceMappingURL=registry.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"registry.js","sourceRoot":"","sources":["../../../src/server/agents/registry.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AACrD,OAAO,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAC1C,OAAO,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AAShD,OAAO,EAAE,YAAY,EAAE,YAAY,EAAE,kBAAkB,EAAE,oBAAoB,EAAE,MAAM,YAAY,CAAC;AAElG,MAAM,QAAQ,GAAoC;IAChD,aAAa,EAAE,iBAAiB;IAChC,KAAK,EAAE,YAAY;IACnB,QAAQ,EAAE,eAAe;CAC1B,CAAC;AAEF,MAAM,UAAU,UAAU,CAAC,IAAe;IACxC,MAAM,CAAC,GAAG,QAAQ,CAAC,IAAI,CAAC,CAAC;IACzB,IAAI,CAAC,CAAC;QAAE,MAAM,IAAI,KAAK,CAAC,8BAA8B,IAAI,GAAG,CAAC,CAAC;IAC/D,OAAO,CAAC,CAAC;AACX,CAAC"}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
const PLACEHOLDER = /\{(issueNumber|issueTitle|issueBody|repoSlug|baseBranch|branch|prUrl|prHeadRef)\}/g;
|
|
2
|
+
export function renderPrompt(template, ctx) {
|
|
3
|
+
return template.replace(PLACEHOLDER, (_m, key) => String(ctx[key] ?? ""));
|
|
4
|
+
}
|
|
5
|
+
export function makeAgentEnv(ctx, kind) {
|
|
6
|
+
return {
|
|
7
|
+
...process.env,
|
|
8
|
+
TONO_TASK_KIND: kind,
|
|
9
|
+
TONO_TASK_ISSUE: String(ctx.issueNumber),
|
|
10
|
+
TONO_TASK_REPO: ctx.repoSlug,
|
|
11
|
+
TONO_TASK_BRANCH: ctx.branch,
|
|
12
|
+
...(ctx.prUrl ? { TONO_TASK_PR_URL: ctx.prUrl } : {}),
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Substitute `{prompt}` in args if present; otherwise append the prompt as
|
|
17
|
+
* a final arg. Lets agents like opencode that take prompts as flags coexist
|
|
18
|
+
* with claude/codex that take them as a final positional.
|
|
19
|
+
*/
|
|
20
|
+
export function injectPromptIntoArgs(args, prompt) {
|
|
21
|
+
let injected = false;
|
|
22
|
+
const out = args.map((a) => {
|
|
23
|
+
if (a.includes("{prompt}")) {
|
|
24
|
+
injected = true;
|
|
25
|
+
return a.replaceAll("{prompt}", prompt);
|
|
26
|
+
}
|
|
27
|
+
return a;
|
|
28
|
+
});
|
|
29
|
+
if (!injected)
|
|
30
|
+
out.push(prompt);
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
/** Helper used by scheduler — resolves the right config block for a repo's agent. */
|
|
34
|
+
export function resolveAgentConfig(config, type) {
|
|
35
|
+
const agent = config.agents[type];
|
|
36
|
+
if (!agent)
|
|
37
|
+
throw new Error(`No config for agent type "${type}"`);
|
|
38
|
+
return agent;
|
|
39
|
+
}
|
|
40
|
+
//# sourceMappingURL=types.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../../../src/server/agents/types.ts"],"names":[],"mappings":"AAoEA,MAAM,WAAW,GAAG,oFAAoF,CAAC;AAEzG,MAAM,UAAU,YAAY,CAAC,QAAgB,EAAE,GAAgB;IAC7D,OAAO,QAAQ,CAAC,OAAO,CAAC,WAAW,EAAE,CAAC,EAAE,EAAE,GAAsB,EAAE,EAAE,CAAC,MAAM,CAAC,GAAG,CAAC,GAAG,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC;AAC/F,CAAC;AAED,MAAM,UAAU,YAAY,CAAC,GAAgB,EAAE,IAAc;IAC3D,OAAO;QACL,GAAG,OAAO,CAAC,GAAG;QACd,cAAc,EAAE,IAAI;QACpB,eAAe,EAAE,MAAM,CAAC,GAAG,CAAC,WAAW,CAAC;QACxC,cAAc,EAAE,GAAG,CAAC,QAAQ;QAC5B,gBAAgB,EAAE,GAAG,CAAC,MAAM;QAC5B,GAAG,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,gBAAgB,EAAE,GAAG,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;KACtD,CAAC;AACJ,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,oBAAoB,CAAC,IAAc,EAAE,MAAc;IACjE,IAAI,QAAQ,GAAG,KAAK,CAAC;IACrB,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE;QACzB,IAAI,CAAC,CAAC,QAAQ,CAAC,UAAU,CAAC,EAAE,CAAC;YAC3B,QAAQ,GAAG,IAAI,CAAC;YAChB,OAAO,CAAC,CAAC,UAAU,CAAC,UAAU,EAAE,MAAM,CAAC,CAAC;QAC1C,CAAC;QACD,OAAO,CAAC,CAAC;IACX,CAAC,CAAC,CAAC;IACH,IAAI,CAAC,QAAQ;QAAE,GAAG,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;IAChC,OAAO,GAAG,CAAC;AACb,CAAC;AAED,qFAAqF;AACrF,MAAM,UAAU,kBAAkB,CAAC,MAAsB,EAAE,IAAe;IACxE,MAAM,KAAK,GAAG,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;IAClC,IAAI,CAAC,KAAK;QAAE,MAAM,IAAI,KAAK,CAAC,6BAA6B,IAAI,GAAG,CAAC,CAAC;IAClE,OAAO,KAAK,CAAC;AACf,CAAC"}
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { dirname, extname, join, normalize, resolve } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { removeReviewWorktree, removeWorktree } from "./git/worktrees.js";
|
|
7
|
+
import { ConfigError, configPath } from "./config/load.js";
|
|
8
|
+
import * as gh from "./github/gh.js";
|
|
9
|
+
import { tonoHome } from "./config/load.js";
|
|
10
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
// Built layout: dist/server/app.js → dist/web/index.html
|
|
12
|
+
const WEB_DIST = resolve(HERE, "..", "web");
|
|
13
|
+
export function buildApp(deps) {
|
|
14
|
+
const app = new Hono();
|
|
15
|
+
const cfgRef = () => deps.configManager.cfg;
|
|
16
|
+
app.get("/api/health", (c) => {
|
|
17
|
+
const cfg = cfgRef();
|
|
18
|
+
const totals = Object.fromEntries(Object.entries(cfg.agents).map(([name, a]) => [
|
|
19
|
+
name,
|
|
20
|
+
a ? { implement: a.concurrency.implement, review: a.concurrency.review } : null,
|
|
21
|
+
]));
|
|
22
|
+
return c.json({
|
|
23
|
+
ok: true,
|
|
24
|
+
version: "0.0.1",
|
|
25
|
+
agents: totals,
|
|
26
|
+
reposWatched: cfg.repos.length,
|
|
27
|
+
poller: deps.poller.health(),
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
app.get("/api/config", (c) => {
|
|
31
|
+
const cfg = cfgRef();
|
|
32
|
+
return c.json({
|
|
33
|
+
server: cfg.server,
|
|
34
|
+
github: cfg.github,
|
|
35
|
+
workspaces: cfg.workspaces,
|
|
36
|
+
repos: cfg.repos,
|
|
37
|
+
// Don't echo prompt templates back over HTTP — they may contain anything in future versions.
|
|
38
|
+
agents: Object.fromEntries(Object.entries(cfg.agents).map(([k, v]) => v ? [k, { command: v.command, args: v.args, concurrency: v.concurrency }] : [k, null])),
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
app.get("/api/config/raw", (c) => c.json({ path: configPath(), content: deps.configManager.readRawText() }));
|
|
42
|
+
app.put("/api/config/raw", async (c) => {
|
|
43
|
+
let body;
|
|
44
|
+
try {
|
|
45
|
+
body = await c.req.json();
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return c.json({ error: "body must be JSON" }, 400);
|
|
49
|
+
}
|
|
50
|
+
if (typeof body.content !== "string") {
|
|
51
|
+
return c.json({ error: "body.content must be a string" }, 400);
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
deps.configManager.writeRawText(body.content);
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
if (err instanceof ConfigError) {
|
|
58
|
+
return c.json({ error: err.message, errors: err.errors ?? [] }, 400);
|
|
59
|
+
}
|
|
60
|
+
throw err;
|
|
61
|
+
}
|
|
62
|
+
return c.json({ ok: true, path: configPath() });
|
|
63
|
+
});
|
|
64
|
+
app.get("/api/tasks", (c) => {
|
|
65
|
+
const status = c.req.query("status");
|
|
66
|
+
const tasks = status
|
|
67
|
+
? deps.q.listByStatus(status)
|
|
68
|
+
: deps.q.listAll(200);
|
|
69
|
+
return c.json({ tasks });
|
|
70
|
+
});
|
|
71
|
+
// Manually queue a task for an existing GitHub issue, bypassing the poller.
|
|
72
|
+
// Body: { repoSlug, issueNumber, agent: "claude-code"|"codex"|"opencode" }.
|
|
73
|
+
// Optional: { kind: "implement"|"review" } — defaults to "implement".
|
|
74
|
+
app.post("/api/tasks", async (c) => {
|
|
75
|
+
let body;
|
|
76
|
+
try {
|
|
77
|
+
body = await c.req.json();
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return c.json({ error: "body must be JSON" }, 400);
|
|
81
|
+
}
|
|
82
|
+
const { repoSlug, issueNumber, agent, kind: kindRaw } = body;
|
|
83
|
+
if (typeof repoSlug !== "string" || typeof issueNumber !== "number" || !Number.isInteger(issueNumber)) {
|
|
84
|
+
return c.json({ error: "repoSlug (string) and issueNumber (integer) are required" }, 400);
|
|
85
|
+
}
|
|
86
|
+
const kind = (kindRaw === "review" ? "review" : "implement");
|
|
87
|
+
const cfg = cfgRef();
|
|
88
|
+
const repo = cfg.repos.find((r) => r.slug === repoSlug);
|
|
89
|
+
if (!repo)
|
|
90
|
+
return c.json({ error: `repo ${repoSlug} is not configured` }, 400);
|
|
91
|
+
if (typeof agent !== "string" || !cfg.agents[agent]) {
|
|
92
|
+
return c.json({ error: `agent must be one of: ${Object.keys(cfg.agents).join(", ")}` }, 400);
|
|
93
|
+
}
|
|
94
|
+
const existing = deps.q.taskByIssue(repoSlug, issueNumber, kind);
|
|
95
|
+
if (existing && (existing.status === "queued" || existing.status === "running")) {
|
|
96
|
+
return c.json({ error: `task #${existing.id} is already ${existing.status} for this ${kind === "review" ? "PR" : "issue"}`, task: existing }, 409);
|
|
97
|
+
}
|
|
98
|
+
// Manual trigger only supports implement for now; reviews come through the poller.
|
|
99
|
+
if (kind !== "implement") {
|
|
100
|
+
return c.json({ error: "manual review tasks aren't supported yet — apply the review label on the PR instead" }, 400);
|
|
101
|
+
}
|
|
102
|
+
let issue;
|
|
103
|
+
try {
|
|
104
|
+
issue = await gh.issueView(repoSlug, issueNumber);
|
|
105
|
+
}
|
|
106
|
+
catch (err) {
|
|
107
|
+
return c.json({ error: err.message }, 502);
|
|
108
|
+
}
|
|
109
|
+
const branch = `tono/issue-${issue.number}`;
|
|
110
|
+
const task = deps.q.insertTask({
|
|
111
|
+
kind,
|
|
112
|
+
repoSlug,
|
|
113
|
+
issueNumber: issue.number,
|
|
114
|
+
issueTitle: issue.title,
|
|
115
|
+
issueBody: issue.body ?? "",
|
|
116
|
+
branch,
|
|
117
|
+
agent: agent,
|
|
118
|
+
});
|
|
119
|
+
deps.bus.emit("task:queued", { task });
|
|
120
|
+
return c.json({ task });
|
|
121
|
+
});
|
|
122
|
+
app.get("/api/tasks/:id", (c) => {
|
|
123
|
+
const id = Number(c.req.param("id"));
|
|
124
|
+
if (!Number.isFinite(id))
|
|
125
|
+
return c.json({ error: "invalid id" }, 400);
|
|
126
|
+
const task = deps.q.getTask(id);
|
|
127
|
+
if (!task)
|
|
128
|
+
return c.json({ error: "not found" }, 404);
|
|
129
|
+
return c.json({ task: { ...task, canResume: !!task.agentSessionId } });
|
|
130
|
+
});
|
|
131
|
+
app.get("/api/sessions", (c) => {
|
|
132
|
+
return c.json({
|
|
133
|
+
sessions: deps.pty.list().map((s) => s.meta()),
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
app.get("/api/sessions/:id", (c) => {
|
|
137
|
+
const id = c.req.param("id");
|
|
138
|
+
const s = deps.pty.get(id);
|
|
139
|
+
if (!s)
|
|
140
|
+
return c.json({ error: "not found" }, 404);
|
|
141
|
+
return c.json({ session: s.meta() });
|
|
142
|
+
});
|
|
143
|
+
app.get("/api/sessions/:id/scrollback", (c) => {
|
|
144
|
+
const s = deps.pty.get(c.req.param("id"));
|
|
145
|
+
if (!s)
|
|
146
|
+
return c.json({ error: "not found" }, 404);
|
|
147
|
+
const buf = s.scrollback();
|
|
148
|
+
return new Response(buf, {
|
|
149
|
+
status: 200,
|
|
150
|
+
headers: { "content-type": "application/octet-stream" },
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
app.delete("/api/sessions/:id", (c) => {
|
|
154
|
+
const s = deps.pty.get(c.req.param("id"));
|
|
155
|
+
if (!s)
|
|
156
|
+
return c.json({ error: "not found" }, 404);
|
|
157
|
+
s.kill("SIGTERM");
|
|
158
|
+
return c.json({ ok: true });
|
|
159
|
+
});
|
|
160
|
+
// Free-form shell, not tied to any task. Lives only in memory; if the gateway
|
|
161
|
+
// restarts the shell dies (acceptable — it's not orchestrating an agent).
|
|
162
|
+
app.post("/api/sessions/shell", async (c) => {
|
|
163
|
+
let body = {};
|
|
164
|
+
try {
|
|
165
|
+
body = await c.req.json();
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
// Empty body is fine.
|
|
169
|
+
}
|
|
170
|
+
// Default to the workspaces root so a free terminal lands next to the
|
|
171
|
+
// worktrees you'd actually want to inspect.
|
|
172
|
+
const defaultCwd = cfgRef().workspaces.root;
|
|
173
|
+
const cwd = body.cwd && body.cwd.trim() ? body.cwd : defaultCwd;
|
|
174
|
+
if (!existsSync(cwd)) {
|
|
175
|
+
return c.json({ error: `cwd does not exist: ${cwd}` }, 400);
|
|
176
|
+
}
|
|
177
|
+
const shell = process.env.SHELL ?? (process.platform === "darwin" ? "/bin/zsh" : "/bin/bash");
|
|
178
|
+
const sessionId = randomUUID();
|
|
179
|
+
const logFile = join(tonoHome(), "logs", `shell-${sessionId.slice(0, 8)}.log`);
|
|
180
|
+
const session = deps.pty.spawn({
|
|
181
|
+
sessionId,
|
|
182
|
+
taskId: null,
|
|
183
|
+
kind: "shell",
|
|
184
|
+
label: cwd,
|
|
185
|
+
spec: {
|
|
186
|
+
command: shell,
|
|
187
|
+
args: ["-l"],
|
|
188
|
+
cwd,
|
|
189
|
+
env: process.env,
|
|
190
|
+
resumed: false,
|
|
191
|
+
},
|
|
192
|
+
logFile,
|
|
193
|
+
});
|
|
194
|
+
return c.json({ sessionId, pid: session.pid, cwd });
|
|
195
|
+
});
|
|
196
|
+
app.post("/api/tasks/:id/cleanup", async (c) => {
|
|
197
|
+
const id = Number(c.req.param("id"));
|
|
198
|
+
if (!Number.isFinite(id))
|
|
199
|
+
return c.json({ error: "invalid id" }, 400);
|
|
200
|
+
const task = deps.q.getTask(id);
|
|
201
|
+
if (!task)
|
|
202
|
+
return c.json({ error: "not found" }, 404);
|
|
203
|
+
if (task.status === "running") {
|
|
204
|
+
return c.json({ error: "task is running; kill the session first" }, 409);
|
|
205
|
+
}
|
|
206
|
+
const cfg = cfgRef();
|
|
207
|
+
const repo = cfg.repos.find((r) => r.slug === task.repoSlug);
|
|
208
|
+
if (repo) {
|
|
209
|
+
try {
|
|
210
|
+
if (task.kind === "review") {
|
|
211
|
+
await removeReviewWorktree({
|
|
212
|
+
workspacesRoot: cfg.workspaces.root,
|
|
213
|
+
slug: task.repoSlug,
|
|
214
|
+
prNumber: task.issueNumber,
|
|
215
|
+
sourcePath: repo.path,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
else {
|
|
219
|
+
await removeWorktree({
|
|
220
|
+
workspacesRoot: cfg.workspaces.root,
|
|
221
|
+
slug: task.repoSlug,
|
|
222
|
+
issueNumber: task.issueNumber,
|
|
223
|
+
sourcePath: repo.path,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
catch (err) {
|
|
228
|
+
return c.json({ error: err.message }, 500);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
deps.q.setTaskStatus(id, "cleaned");
|
|
232
|
+
return c.json({ task: deps.q.getTask(id) });
|
|
233
|
+
});
|
|
234
|
+
app.post("/api/tasks/:id/retry", (c) => {
|
|
235
|
+
const id = Number(c.req.param("id"));
|
|
236
|
+
if (!Number.isFinite(id))
|
|
237
|
+
return c.json({ error: "invalid id" }, 400);
|
|
238
|
+
const task = deps.q.getTask(id);
|
|
239
|
+
if (!task)
|
|
240
|
+
return c.json({ error: "not found" }, 404);
|
|
241
|
+
if (task.status === "running" || task.status === "queued") {
|
|
242
|
+
return c.json({ error: `task is already ${task.status}` }, 409);
|
|
243
|
+
}
|
|
244
|
+
deps.q.resetForRetry(id);
|
|
245
|
+
const updated = deps.q.getTask(id);
|
|
246
|
+
if (updated) {
|
|
247
|
+
deps.bus.emit("task:queued", { task: updated });
|
|
248
|
+
}
|
|
249
|
+
return c.json({ task: updated });
|
|
250
|
+
});
|
|
251
|
+
// Mark a running task as done without killing its session. Frees the
|
|
252
|
+
// concurrency slot so the next queued task can dispatch. Useful when the
|
|
253
|
+
// agent has finished its work in a way the PR-URL heuristic didn't catch.
|
|
254
|
+
app.post("/api/tasks/:id/done", (c) => {
|
|
255
|
+
const id = Number(c.req.param("id"));
|
|
256
|
+
if (!Number.isFinite(id))
|
|
257
|
+
return c.json({ error: "invalid id" }, 400);
|
|
258
|
+
const task = deps.q.getTask(id);
|
|
259
|
+
if (!task)
|
|
260
|
+
return c.json({ error: "not found" }, 404);
|
|
261
|
+
if (task.status === "completed" || task.status === "merged" || task.status === "cleaned") {
|
|
262
|
+
return c.json({ task }); // already done; no-op
|
|
263
|
+
}
|
|
264
|
+
deps.q.setTaskStatus(id, "completed", { exitCode: 0 });
|
|
265
|
+
const updated = deps.q.getTask(id);
|
|
266
|
+
if (updated)
|
|
267
|
+
deps.bus.emit("task:updated", { task: updated });
|
|
268
|
+
return c.json({ task: updated });
|
|
269
|
+
});
|
|
270
|
+
// Static web UI from dist/web. Resolved absolutely so cwd doesn't matter.
|
|
271
|
+
if (existsSync(join(WEB_DIST, "index.html"))) {
|
|
272
|
+
app.get("/*", (c) => serveStaticFile(WEB_DIST, c.req.path));
|
|
273
|
+
}
|
|
274
|
+
else {
|
|
275
|
+
app.get("/", (c) => c.html(`<!doctype html><meta charset="utf-8"><title>tono</title>` +
|
|
276
|
+
`<style>body{font:14px ui-monospace,monospace;padding:24px;background:#0b0c10;color:#e5e7eb}` +
|
|
277
|
+
`code{background:#1f2937;padding:2px 6px;border-radius:4px}</style>` +
|
|
278
|
+
`<h1>tono</h1><p>Web UI not built yet. The HTTP API is live:</p>` +
|
|
279
|
+
`<ul><li><a style="color:#60a5fa" href="/api/health">/api/health</a></li>` +
|
|
280
|
+
`<li><a style="color:#60a5fa" href="/api/tasks">/api/tasks</a></li>` +
|
|
281
|
+
`<li><a style="color:#60a5fa" href="/api/sessions">/api/sessions</a></li></ul>`));
|
|
282
|
+
}
|
|
283
|
+
return app;
|
|
284
|
+
}
|
|
285
|
+
const MIME = {
|
|
286
|
+
".html": "text/html; charset=utf-8",
|
|
287
|
+
".js": "application/javascript; charset=utf-8",
|
|
288
|
+
".css": "text/css; charset=utf-8",
|
|
289
|
+
".svg": "image/svg+xml",
|
|
290
|
+
".png": "image/png",
|
|
291
|
+
".jpg": "image/jpeg",
|
|
292
|
+
".jpeg": "image/jpeg",
|
|
293
|
+
".ico": "image/x-icon",
|
|
294
|
+
".json": "application/json; charset=utf-8",
|
|
295
|
+
".woff": "font/woff",
|
|
296
|
+
".woff2": "font/woff2",
|
|
297
|
+
};
|
|
298
|
+
function serveStaticFile(root, urlPath) {
|
|
299
|
+
// Strip query, normalize, prevent traversal.
|
|
300
|
+
const cleaned = urlPath.split("?")[0].replace(/\/+$/, "");
|
|
301
|
+
let rel = cleaned === "" ? "/index.html" : cleaned;
|
|
302
|
+
rel = normalize(rel).replace(/^([\\/]+)/, "/");
|
|
303
|
+
if (rel.includes(".."))
|
|
304
|
+
return new Response("forbidden", { status: 403 });
|
|
305
|
+
const candidate = join(root, rel);
|
|
306
|
+
const exists = existsSync(candidate) && !candidate.endsWith("/");
|
|
307
|
+
const file = exists ? candidate : join(root, "index.html");
|
|
308
|
+
const ext = extname(file).toLowerCase();
|
|
309
|
+
const ct = MIME[ext] ?? "application/octet-stream";
|
|
310
|
+
const body = readFileSync(file);
|
|
311
|
+
// Vite emits hashed filenames under /assets/, so those can be cached forever.
|
|
312
|
+
// Anything else (notably index.html, served either directly or as the SPA
|
|
313
|
+
// fallback) must not be cached or the browser will pin to a stale bundle hash
|
|
314
|
+
// across rebuilds and the page will go blank when that JS 404s.
|
|
315
|
+
const isHashedAsset = exists && rel.startsWith("/assets/");
|
|
316
|
+
const cacheControl = isHashedAsset
|
|
317
|
+
? "public, max-age=31536000, immutable"
|
|
318
|
+
: "no-cache, must-revalidate";
|
|
319
|
+
return new Response(body, {
|
|
320
|
+
status: 200,
|
|
321
|
+
headers: { "content-type": ct, "cache-control": cacheControl },
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
//# sourceMappingURL=app.js.map
|