coban 0.0.0-stage → 0.2.1
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 -0
- package/README.md +173 -2
- package/bin/coban +10 -0
- package/dist/agent.js +161 -0
- package/dist/cli.js +201 -0
- package/dist/config.js +55 -0
- package/dist/context.js +174 -0
- package/dist/hub.js +543 -0
- package/dist/prompt.js +119 -0
- package/dist/tui.js +1382 -0
- package/dist/usage.js +100 -0
- package/package.json +58 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 cenkerarin
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,174 @@
|
|
|
1
|
-
#
|
|
1
|
+
# coban
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://github.com/cenkerarin/coban/actions/workflows/ci.yml)
|
|
4
|
+
[](LICENSE)
|
|
5
|
+
|
|
6
|
+
A terminal app (think [herdr](https://www.bitdoze.com/herdr-agent-multiplexer/)) where **one orchestrator agent
|
|
7
|
+
dispatches work to many AI coding agents** — several Claude Code accounts, Codex, OpenCode, Cursor — and those
|
|
8
|
+
agents **talk to each other** over direct messages and a shared board.
|
|
9
|
+
|
|
10
|
+
*Çoban* is Turkish for shepherd. The UI labels and the prompts coban gives agents are in Turkish for now.
|
|
11
|
+
|
|
12
|
+

|
|
13
|
+
|
|
14
|
+
<sub>Rendered by `npm run snapshot` with scripted demo agents. Left: who said what to whom. Middle: the orchestrator
|
|
15
|
+
and its workers — the dots on the edges are messages in flight. Right: the focused agent's real terminal.</sub>
|
|
16
|
+
|
|
17
|
+
> **Status: early (v0.2).** Its author runs it with three Claude accounts; Codex, OpenCode and Cursor
|
|
18
|
+
> profiles are wired up but barely tested. Issues and PRs welcome.
|
|
19
|
+
|
|
20
|
+
## How it works
|
|
21
|
+
|
|
22
|
+
- **One screen, three columns.** A conversation feed (messages, board notes, lifecycle events), the agent graph,
|
|
23
|
+
and the focused agent's terminal — your chat. Under 140 columns the feed hides; under 100 the graph stacks above
|
|
24
|
+
the chat.
|
|
25
|
+
- **Real agents, real terminals.** Every agent runs in its own PTY; coban emulates the screen (xterm) and reads a
|
|
26
|
+
status from it: `working`, `blocked` (permission prompt), `limited` (usage limit), `idle`, `exited`.
|
|
27
|
+
- **The orchestrator drives a CLI, not an MCP server.** Pick its profile with the `+` bubble; it starts with a
|
|
28
|
+
system prompt that teaches it the `coban` command, which talks to the app over a unix socket. Anything that can
|
|
29
|
+
run a shell command can take part.
|
|
30
|
+
- **Alive, not busy.** Messages travel along the edges as packets (board notes fan out to everyone), working agents
|
|
31
|
+
breathe, blocked ones blink, new ones fade in. Frames are drawn only while something moves.
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
coban agents agents, model, status
|
|
35
|
+
coban map [subdir] repo map: files, line counts, symbol:line
|
|
36
|
+
coban spawn <profile> "<task>" [--name x] [--cwd dir] [--ctx where]...
|
|
37
|
+
--ctx "file#sym1,sym2" | "file:10-80" | "file"
|
|
38
|
+
inlines that code (line-numbered) into the task
|
|
39
|
+
coban post <id|user|all> "<message>" direct message (queued until idle) or a board note (all)
|
|
40
|
+
coban inbox your direct messages + board notes you haven't seen
|
|
41
|
+
coban board [--limit n] recent board notes
|
|
42
|
+
coban wait [id...] [--timeout s] returns when an agent settles or mail arrives
|
|
43
|
+
coban read <id> [--lines n] [--raw] an agent's screen, UI chrome stripped
|
|
44
|
+
coban approvals agents waiting on a permission question, parsed
|
|
45
|
+
coban approve <id> [yes|always|no] answer it (always = the "Yes, and …" option)
|
|
46
|
+
coban key <id> <keys...> enter esc up down tab y 1 2 ...
|
|
47
|
+
coban usage per-account usage, least spent first
|
|
48
|
+
coban kill <id> · coban log every command takes --json
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Install
|
|
52
|
+
|
|
53
|
+
Requires Node ≥ 24 on macOS or Linux.
|
|
54
|
+
|
|
55
|
+
```sh
|
|
56
|
+
npm install -g coban
|
|
57
|
+
cd ~/your-project && coban
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### From source (development)
|
|
61
|
+
|
|
62
|
+
In a clone, TypeScript runs directly with no build step:
|
|
63
|
+
|
|
64
|
+
```sh
|
|
65
|
+
git clone https://github.com/cenkerarin/coban && cd coban
|
|
66
|
+
npm install
|
|
67
|
+
ln -s "$PWD/bin/coban" ~/.local/bin/coban
|
|
68
|
+
cd ~/your-project && coban
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
`npm run build` compiles to `dist/`, which `bin/coban` prefers when present.
|
|
72
|
+
|
|
73
|
+
## Config
|
|
74
|
+
|
|
75
|
+
`~/.config/coban/config.json` is created on first run. A profile is a command plus env vars, so several accounts
|
|
76
|
+
are just different `CLAUDE_CONFIG_DIR`s:
|
|
77
|
+
|
|
78
|
+
```json
|
|
79
|
+
{
|
|
80
|
+
"orchestrator": "h1",
|
|
81
|
+
"orchestratorModel": "claude-sonnet-5-5",
|
|
82
|
+
"profiles": [
|
|
83
|
+
{ "id": "h1", "title": "Claude H1", "cmd": "claude --allowedTools 'Bash(coban:*)'", "model": "claude-opus-5-5",
|
|
84
|
+
"promptArg": "-- {prompt}", "env": { "CLAUDE_CONFIG_DIR": "~/.claude-hesap1" }, "color": "#F6D5C8" },
|
|
85
|
+
{ "id": "h2", "title": "Claude H2", "cmd": "claude --allowedTools 'Bash(coban:*)'", "model": "claude-sonnet-5-5",
|
|
86
|
+
"promptArg": "-- {prompt}", "env": { "CLAUDE_CONFIG_DIR": "~/.claude-hesap2" }, "limit5h": 4000000 },
|
|
87
|
+
{ "id": "codex", "title": "Codex", "cmd": "codex", "promptArg": "{prompt}" }
|
|
88
|
+
]
|
|
89
|
+
}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
`model` is appended as `--model <model>`; `orchestratorModel` overrides it for the profile running as orchestrator —
|
|
93
|
+
coordination is mostly reading and routing, so a cheaper model there saves the most.
|
|
94
|
+
|
|
95
|
+
## Accounts and limits
|
|
96
|
+
|
|
97
|
+
`coban usage` (and the bars in the status bar) read each Claude profile's session logs
|
|
98
|
+
(`$CLAUDE_CONFIG_DIR/projects/**/*.jsonl`) and sum input + output + cache-creation tokens over 5 hours and 7 days.
|
|
99
|
+
The percentage is against the profile's `limit5h`; plans don't publish a token number, so the 5M default is a guess —
|
|
100
|
+
tune it per account. The orchestrator is told to give new work to the least-spent account.
|
|
101
|
+
|
|
102
|
+
When a worker hits a usage limit, coban **hands its task off** to the least-spent account of the same kind: the new
|
|
103
|
+
worker gets the original task plus the old worker's last screen, undelivered mail follows it, and the board and the
|
|
104
|
+
orchestrator are told.
|
|
105
|
+
|
|
106
|
+
## Approvals and the input line
|
|
107
|
+
|
|
108
|
+
- **One approval inbox.** When agents stop at a permission question, coban reads it off their screen — title,
|
|
109
|
+
command or file, and the exact options — and lists them in a panel under the graph. `ctrl+b y` approves the
|
|
110
|
+
selected one, `ctrl+b a` picks its "Yes, and …" option (shown verbatim, so you see what it grants), `ctrl+b r`
|
|
111
|
+
rejects, `ctrl+b i` moves to the next; or click the buttons. The orchestrator can do the same with
|
|
112
|
+
`coban approvals` / `coban approve`. Answers are given by moving the menu selection with arrow keys and pressing
|
|
113
|
+
Enter (never by digit: Claude Code's folder-trust menu has no numbers and defaults to "No, exit"), and an answer
|
|
114
|
+
is refused if the question changed since it was shown.
|
|
115
|
+
- **coban's own input line** sits under the chat: `ctrl+b c` (or a click) focuses it. `@h2 also add a refresh
|
|
116
|
+
endpoint` goes to h2 without switching focus, `@all …` to the board, plain text to the focused agent; Tab
|
|
117
|
+
completes agent names, Esc returns the keyboard to the agent.
|
|
118
|
+
|
|
119
|
+
The parser is built against real Claude Code screens (`test/fixtures/prompts`). Driving agents through the Agent SDK
|
|
120
|
+
would give exact events instead of screen reading, but its docs ask third-party tools not to offer claude.ai login,
|
|
121
|
+
so coban keeps running the unmodified Claude Code with your own login.
|
|
122
|
+
|
|
123
|
+
## Agents talking
|
|
124
|
+
|
|
125
|
+
- **Direct messages** (`coban post <id>`) are typed into the recipient's terminal once it is idle.
|
|
126
|
+
- **The board** (`coban post all`) is for things everyone should know — changed interfaces, who is on which file,
|
|
127
|
+
decisions. Notes are pulled, not pushed: `coban inbox` returns each note once, and workers are told to check it
|
|
128
|
+
after every major step and before editing shared files, so idle agents aren't woken (and billed) for every note.
|
|
129
|
+
It is mirrored to `.coban/board.md`; `ctrl+b m` opens it as a panel.
|
|
130
|
+
- Agents opened by hand show as `manuel`; the orchestrator is told to use idle ones before spawning new workers, and
|
|
131
|
+
coban prepends the worker brief to the first message they get.
|
|
132
|
+
|
|
133
|
+
## Keeping context small
|
|
134
|
+
|
|
135
|
+
Each request an agent makes re-reads its whole context (Claude Code alone is ~45k tokens before any work), so coban
|
|
136
|
+
keeps what lands there lean:
|
|
137
|
+
|
|
138
|
+
- one-line CLI output; `coban read` returns 20 lines with agent UI chrome stripped;
|
|
139
|
+
- workers send ≤5-line summaries and put long results in `.coban/reports/<id>.md` (git-ignored automatically);
|
|
140
|
+
- the orchestrator learns the repo from `coban map` (≈400 tokens for this repo) and hands workers the relevant code
|
|
141
|
+
with `--ctx` — coban extracts symbols/line ranges itself (no model involved) so workers don't spend requests
|
|
142
|
+
searching;
|
|
143
|
+
- tasks follow a template: **HEDEF / DOSYALAR / KISITLAR / DOĞRULAMA / BİTTİ SAYILIR**
|
|
144
|
+
(goal, files, constraints, verification, done when).
|
|
145
|
+
|
|
146
|
+
## Keys
|
|
147
|
+
|
|
148
|
+
Mouse: click a bubble to focus it, click `+` to add an agent, scroll the feed with the wheel. Mouse events in the
|
|
149
|
+
chat are forwarded when the agent asks for them.
|
|
150
|
+
|
|
151
|
+
`ctrl+b` then: `c` input line · `y`/`a`/`r` approve / always / reject · `i` next approval · `n` new agent · `1-9` select · `j`/`k` next/prev · `o` orchestrator · `m` board panel ·
|
|
152
|
+
`l` toggle feed · `s` select mode (release the mouse to copy text) · `u` next unread · `x` close agent · `q` quit ·
|
|
153
|
+
`ctrl+b` send a literal ctrl+b.
|
|
154
|
+
|
|
155
|
+
## Development
|
|
156
|
+
|
|
157
|
+
```sh
|
|
158
|
+
npm test # unit tests + an end-to-end run of the real TUI in a pty with fake agents
|
|
159
|
+
npm run typecheck
|
|
160
|
+
npm run snapshot # regenerate docs/screenshot.svg
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) for how the code is laid out.
|
|
164
|
+
|
|
165
|
+
## Known gaps
|
|
166
|
+
|
|
167
|
+
No scrollback in the chat, no split view, no kitty keyboard protocol (shift+enter), no session restore yet.
|
|
168
|
+
Codex's sandbox may block the unix socket. Status detection is screen heuristics and may need tuning per CLI
|
|
169
|
+
version. coban captures the mouse: to copy text press `ctrl+b s`, or use your terminal's override (shift-drag in
|
|
170
|
+
Ghostty/cmux, ⌥-drag in iTerm2).
|
|
171
|
+
|
|
172
|
+
## License
|
|
173
|
+
|
|
174
|
+
MIT
|
package/bin/coban
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# Resolve the real script location so symlinks (e.g. ~/.local/bin/coban) work.
|
|
3
|
+
SELF=$(readlink -f "$0" 2>/dev/null || echo "$0")
|
|
4
|
+
ROOT="$(dirname "$SELF")/.."
|
|
5
|
+
if [ -f "$ROOT/dist/cli.js" ]; then
|
|
6
|
+
ENTRY="$ROOT/dist/cli.js"
|
|
7
|
+
else
|
|
8
|
+
ENTRY="$ROOT/src/cli.ts"
|
|
9
|
+
fi
|
|
10
|
+
exec node --disable-warning=ExperimentalWarning "$ENTRY" "$@"
|
package/dist/agent.js
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import pty from 'node-pty';
|
|
2
|
+
import xterm from '@xterm/headless';
|
|
3
|
+
import { EventEmitter } from 'node:events';
|
|
4
|
+
import { parsePrompt } from './prompt.js';
|
|
5
|
+
const { Terminal } = xterm;
|
|
6
|
+
// Screen heuristics. Checked against the bottom of the visible screen.
|
|
7
|
+
const BLOCKED = /Do you want to (proceed|make this edit|create|allow|run)|Would you like to (run|make|proceed|allow)|Allow (this|command|once)|Approve\b.*\?|Press enter to confirm|trust (this|the files in this) folder|Do you trust/i;
|
|
8
|
+
// A plain y/n question only counts while it is still the line being answered; tools rarely clear it afterwards.
|
|
9
|
+
const YN = /\(y\/n\)|\[y\/N\]|\[Y\/n\]/;
|
|
10
|
+
// A CLI's own limit banner: a short line of its own, optionally behind one bullet/box glyph, sitting in the last few
|
|
11
|
+
// non-chrome lines of the screen (just above the idle prompt). Line-anchored on purpose: the same words inside a task
|
|
12
|
+
// text, a prompt echo ('> …'), a quote or mid-sentence tool output must never trigger a handoff.
|
|
13
|
+
const LIMIT_BANNER = /^\s{0,4}(?:[⎿●•✻*·]\s{0,4})?(?:(?:Claude(?: AI)? )?usage limit reached|(?:5-hour|weekly|session|daily|opus|sonnet) limit reached|You(?:'|’)?ve (?:hit|reached) your (?:\w+[ -])?(?:usage )?limit|You have (?:hit|reached) your (?:\w+[ -])?(?:usage )?limit|You(?:'|’)re out of (?:credits|extra usage))(?:\s*$|[\s.!,:;|∙·–—(-])/i;
|
|
14
|
+
const LIMIT_CHROME = /^\s*([─━═╌┄\-]{8,}|[╭╰│┃].*|❯.*|>.*|[⏵►▶]{2}.*|\? for shortcuts.*)$/;
|
|
15
|
+
const LIMIT_TAIL_LINES = 4;
|
|
16
|
+
// Broad form, only for masking quoted screen text (handoff tasks) so it can't re-trigger a limit in the new worker.
|
|
17
|
+
const LIMIT_WORDS = /(?:(?:usage|rate) limit|limit reached|(?:hit|reached) your[^\n]{0,24}?limit|out of (?:credits|extra usage))/gi;
|
|
18
|
+
// A question is live only while nothing has been printed after it: either it sits above an open selection menu
|
|
19
|
+
// (❯ …), or it is one of the last two non-empty lines. Answered questions that linger in the scrollback don't count.
|
|
20
|
+
export function waitingForAnswer(screen) {
|
|
21
|
+
const p = parsePrompt(screen);
|
|
22
|
+
if (p?.kind === 'yn')
|
|
23
|
+
return true;
|
|
24
|
+
if (p?.kind === 'menu' && BLOCKED.test(screen))
|
|
25
|
+
return true;
|
|
26
|
+
const lastTwo = screen.split('\n').filter((l) => l.trim()).slice(-2).join('\n');
|
|
27
|
+
return BLOCKED.test(lastTwo) || YN.test(lastTwo);
|
|
28
|
+
}
|
|
29
|
+
export function limitBanner(screen) {
|
|
30
|
+
const lines = screen.split('\n').filter((l) => l.trim() && !LIMIT_CHROME.test(l)).slice(-LIMIT_TAIL_LINES);
|
|
31
|
+
return lines.some((l) => l.length <= 200 && LIMIT_BANNER.test(l));
|
|
32
|
+
}
|
|
33
|
+
export function maskLimitText(text) {
|
|
34
|
+
return text.replace(LIMIT_WORDS, (m) => `[${m.replace(/limit/gi, 'l*mit')}]`);
|
|
35
|
+
}
|
|
36
|
+
const BUSY = /esc to interrupt|esc to cancel|ctrl\+c to (interrupt|stop)|Working \(|Thinking\.\.\./i;
|
|
37
|
+
const QUIET_MS = 1500;
|
|
38
|
+
export class Agent extends EventEmitter {
|
|
39
|
+
id;
|
|
40
|
+
profile;
|
|
41
|
+
role;
|
|
42
|
+
task;
|
|
43
|
+
cwd;
|
|
44
|
+
term;
|
|
45
|
+
proc;
|
|
46
|
+
status = 'starting';
|
|
47
|
+
settledSeq = 0; // bumps every time the agent settles (idle/blocked/limited/exited)
|
|
48
|
+
unread = false; // settled while not focused
|
|
49
|
+
lastOutput = Date.now();
|
|
50
|
+
cursorVisible = true;
|
|
51
|
+
exitCode = null;
|
|
52
|
+
inbox = [];
|
|
53
|
+
model = '';
|
|
54
|
+
briefed = false; // has received the coban worker brief
|
|
55
|
+
handedOff = false; // hit a limit and its task was already handed to another account (hub.handoff)
|
|
56
|
+
constructor(o) {
|
|
57
|
+
super();
|
|
58
|
+
this.id = o.id;
|
|
59
|
+
this.profile = o.profile;
|
|
60
|
+
this.role = o.role;
|
|
61
|
+
this.task = o.task;
|
|
62
|
+
this.cwd = o.cwd;
|
|
63
|
+
this.term = new Terminal({ cols: o.cols, rows: o.rows, scrollback: 2000, allowProposedApi: true });
|
|
64
|
+
// Track DECTCEM (cursor show/hide); headless xterm doesn't expose it.
|
|
65
|
+
this.term.parser.registerCsiHandler({ prefix: '?', final: 'h' }, (p) => { if (p.includes(25))
|
|
66
|
+
this.cursorVisible = true; return false; });
|
|
67
|
+
this.term.parser.registerCsiHandler({ prefix: '?', final: 'l' }, (p) => { if (p.includes(25))
|
|
68
|
+
this.cursorVisible = false; return false; });
|
|
69
|
+
// /bin/sh, not the user's shell: profiles are plain commands and sh exists everywhere (zsh is missing on most Linux).
|
|
70
|
+
// `exec` keeps the agent as the pty's direct child, but `exec VAR=x cmd` would try to run "VAR=x": skip it then.
|
|
71
|
+
this.proc = pty.spawn('/bin/sh', ['-c', /^\s*\w+=/.test(o.cmd) ? o.cmd : `exec ${o.cmd}`], {
|
|
72
|
+
name: 'xterm-256color', cols: o.cols, rows: o.rows, cwd: o.cwd, env: o.env,
|
|
73
|
+
});
|
|
74
|
+
this.proc.onData((d) => {
|
|
75
|
+
this.lastOutput = Date.now();
|
|
76
|
+
this.term.write(d, () => this.emit('render'));
|
|
77
|
+
});
|
|
78
|
+
// Replies to terminal queries (DA, DSR, ...) go back to the program.
|
|
79
|
+
this.term.onData((d) => { if (this.exitCode === null)
|
|
80
|
+
this.proc.write(d); });
|
|
81
|
+
this.proc.onExit(({ exitCode }) => {
|
|
82
|
+
this.exitCode = exitCode;
|
|
83
|
+
this.setStatus('exited');
|
|
84
|
+
this.emit('render');
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
screenText(lines = 40, scrollback = false) {
|
|
88
|
+
const buf = this.term.buffer.active;
|
|
89
|
+
// Drop blank rows below the content first, then take the last `lines` rows.
|
|
90
|
+
let end = buf.length;
|
|
91
|
+
while (end > 0 && !buf.getLine(end - 1)?.translateToString(true).trim())
|
|
92
|
+
end--;
|
|
93
|
+
const start = Math.max(scrollback ? 0 : buf.baseY, end - lines);
|
|
94
|
+
const out = [];
|
|
95
|
+
for (let y = start; y < end; y++)
|
|
96
|
+
out.push(buf.getLine(y)?.translateToString(true) ?? '');
|
|
97
|
+
return out.join('\n');
|
|
98
|
+
}
|
|
99
|
+
tick() {
|
|
100
|
+
if (this.status === 'exited')
|
|
101
|
+
return;
|
|
102
|
+
const tail = this.screenText(18);
|
|
103
|
+
let next;
|
|
104
|
+
if (limitBanner(tail) && !BUSY.test(tail))
|
|
105
|
+
next = 'limited';
|
|
106
|
+
else if (waitingForAnswer(tail))
|
|
107
|
+
next = 'blocked';
|
|
108
|
+
// A visible "esc to interrupt" means real work, even right after launch (workers get their task as an argv
|
|
109
|
+
// prompt and never pass through idle). Bare output without it is still startup noise.
|
|
110
|
+
else if (BUSY.test(tail))
|
|
111
|
+
next = 'working';
|
|
112
|
+
else if (Date.now() - this.lastOutput < QUIET_MS)
|
|
113
|
+
next = this.status === 'starting' ? 'starting' : 'working';
|
|
114
|
+
else
|
|
115
|
+
next = 'idle';
|
|
116
|
+
this.setStatus(next);
|
|
117
|
+
}
|
|
118
|
+
setStatus(s) {
|
|
119
|
+
if (s === this.status)
|
|
120
|
+
return;
|
|
121
|
+
const prev = this.status;
|
|
122
|
+
this.status = s;
|
|
123
|
+
if (s !== 'working' && s !== 'starting')
|
|
124
|
+
this.settledSeq++;
|
|
125
|
+
this.emit('status', s, prev);
|
|
126
|
+
}
|
|
127
|
+
// Type a prompt into the agent and submit it.
|
|
128
|
+
deliver(text) {
|
|
129
|
+
if (this.exitCode !== null)
|
|
130
|
+
return;
|
|
131
|
+
const body = this.term.modes.bracketedPasteMode ? `\x1b[200~${text}\x1b[201~` : text.replace(/\s*\n\s*/g, ' ');
|
|
132
|
+
this.proc.write(body);
|
|
133
|
+
setTimeout(() => this.proc.write('\r'), 250);
|
|
134
|
+
this.lastOutput = Date.now();
|
|
135
|
+
this.setStatus('working');
|
|
136
|
+
}
|
|
137
|
+
resize(cols, rows) {
|
|
138
|
+
if (cols === this.term.cols && rows === this.term.rows)
|
|
139
|
+
return;
|
|
140
|
+
this.term.resize(cols, rows);
|
|
141
|
+
if (this.exitCode === null)
|
|
142
|
+
this.proc.resize(cols, rows);
|
|
143
|
+
}
|
|
144
|
+
kill() {
|
|
145
|
+
if (this.exitCode === null)
|
|
146
|
+
this.proc.kill();
|
|
147
|
+
this.term.dispose();
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
const KEYS = {
|
|
151
|
+
enter: '\r', esc: '\x1b', escape: '\x1b', tab: '\t', 'shift-tab': '\x1b[Z', space: ' ',
|
|
152
|
+
backspace: '\x7f', 'ctrl-c': '\x03', 'ctrl-d': '\x04',
|
|
153
|
+
up: '\x1b[A', down: '\x1b[B', right: '\x1b[C', left: '\x1b[D',
|
|
154
|
+
};
|
|
155
|
+
export function keyBytes(agent, key) {
|
|
156
|
+
const k = key.toLowerCase();
|
|
157
|
+
if (agent.term.modes.applicationCursorKeysMode && /^(up|down|left|right)$/.test(k)) {
|
|
158
|
+
return '\x1bO' + { up: 'A', down: 'B', right: 'C', left: 'D' }[k];
|
|
159
|
+
}
|
|
160
|
+
return KEYS[k] ?? key;
|
|
161
|
+
}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import net from 'node:net';
|
|
2
|
+
import { loadConfig, SOCK } from './config.js';
|
|
3
|
+
import { repoMap, snippets } from './context.js';
|
|
4
|
+
import { allUsage } from './usage.js';
|
|
5
|
+
const HELP = `coban — çok hesaplı agent orkestratörü
|
|
6
|
+
|
|
7
|
+
coban [--cwd dizin] arayüzü aç (orkestratörü graf alanındaki + ile seç)
|
|
8
|
+
|
|
9
|
+
Agent'ların içinden (veya başka bir terminalden):
|
|
10
|
+
coban agents agent listesi ve durumları
|
|
11
|
+
coban spawn <profil> "<görev>" [--name ad] [--cwd dizin] [--ctx yer]...
|
|
12
|
+
--ctx: "dosya:10-80" | "dosya#sembol1,sembol2" | "dosya"
|
|
13
|
+
ilgili kodu satır numaralı olarak görevin içine ekler (send/post'ta da var)
|
|
14
|
+
coban map [alt-dizin] repo haritası: dosyalar, satır sayıları, semboller:satır
|
|
15
|
+
coban send <id> "<mesaj>" (post ile aynı)
|
|
16
|
+
coban post <id|user|all> "<mesaj>" all = ortak kutu (herkes görür)
|
|
17
|
+
coban wait [id...] [--timeout sn] biri durunca / mesaj gelince döner
|
|
18
|
+
coban read <id> [--lines n] [--scrollback] [--raw]
|
|
19
|
+
coban approvals izin bekleyen agent'lar ve soruları
|
|
20
|
+
coban approve <id> [yes|always|no] izni yanıtla (varsayılan yes)
|
|
21
|
+
coban key <id> <tuş...> enter esc up down tab 1 2 y ...
|
|
22
|
+
coban kill <id>
|
|
23
|
+
coban inbox özel mesajlar + ortak kutudaki yeniler
|
|
24
|
+
coban board [--limit n] ortak kutunun son notları
|
|
25
|
+
coban usage hesap kullanımı (5s %, 7g token, reset), en az harcayan başta
|
|
26
|
+
coban log [--limit n]
|
|
27
|
+
|
|
28
|
+
Her komut --json ile ham çıktı verir.`;
|
|
29
|
+
function call(method, params) {
|
|
30
|
+
return new Promise((resolve, reject) => {
|
|
31
|
+
const s = net.connect(SOCK);
|
|
32
|
+
let buf = '';
|
|
33
|
+
s.on('connect', () => s.write(JSON.stringify({ caller: process.env.COBAN_AGENT || 'user', method, params }) + '\n'));
|
|
34
|
+
s.on('data', (d) => {
|
|
35
|
+
buf += d;
|
|
36
|
+
const i = buf.indexOf('\n');
|
|
37
|
+
if (i < 0)
|
|
38
|
+
return;
|
|
39
|
+
const res = JSON.parse(buf.slice(0, i));
|
|
40
|
+
s.end();
|
|
41
|
+
res.ok ? resolve(res.result) : reject(new Error(res.error));
|
|
42
|
+
});
|
|
43
|
+
s.on('error', (e) => reject(e.code === 'ENOENT' || e.code === 'ECONNREFUSED' ? new Error('coban çalışmıyor (önce `coban` ile arayüzü aç)') : e));
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
const BOOL_FLAGS = ['scrollback', 'raw', 'json'];
|
|
47
|
+
function parse(argv) {
|
|
48
|
+
const pos = [];
|
|
49
|
+
const flags = {};
|
|
50
|
+
const ctx = []; // --ctx may repeat
|
|
51
|
+
for (let i = 0; i < argv.length; i++) {
|
|
52
|
+
const a = argv[i];
|
|
53
|
+
if (a.startsWith('--')) {
|
|
54
|
+
const [k, v] = a.slice(2).split('=', 2);
|
|
55
|
+
if (k === 'ctx') {
|
|
56
|
+
const val = v ?? argv[++i];
|
|
57
|
+
if (val)
|
|
58
|
+
ctx.push(val);
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (v !== undefined)
|
|
62
|
+
flags[k] = v;
|
|
63
|
+
else if (argv[i + 1] && !argv[i + 1].startsWith('--') && !BOOL_FLAGS.includes(k))
|
|
64
|
+
flags[k] = argv[++i];
|
|
65
|
+
else
|
|
66
|
+
flags[k] = true;
|
|
67
|
+
}
|
|
68
|
+
else
|
|
69
|
+
pos.push(a);
|
|
70
|
+
}
|
|
71
|
+
return { pos, flags, ctx };
|
|
72
|
+
}
|
|
73
|
+
async function main() {
|
|
74
|
+
const [cmd, ...rest] = process.argv.slice(2);
|
|
75
|
+
const { pos, flags, ctx } = parse(rest);
|
|
76
|
+
const num = (v) => (v === undefined || v === true ? undefined : Number(v));
|
|
77
|
+
if (!cmd || cmd === '--cwd') {
|
|
78
|
+
if (cmd === '--cwd')
|
|
79
|
+
process.chdir(rest[0]);
|
|
80
|
+
const { Hub } = await import('./hub.js');
|
|
81
|
+
const { Tui } = await import('./tui.js');
|
|
82
|
+
try {
|
|
83
|
+
await call('agents', {});
|
|
84
|
+
console.error('coban zaten açık (soket: ' + SOCK + ')');
|
|
85
|
+
process.exit(1);
|
|
86
|
+
}
|
|
87
|
+
catch { }
|
|
88
|
+
const hub = new Hub(loadConfig());
|
|
89
|
+
const tui = new Tui(hub);
|
|
90
|
+
hub.size = tui.paneSize;
|
|
91
|
+
hub.listen();
|
|
92
|
+
tui.start();
|
|
93
|
+
const quit = () => { tui.stop(); hub.shutdown(); process.exit(0); };
|
|
94
|
+
process.on('SIGTERM', quit);
|
|
95
|
+
process.on('SIGHUP', quit);
|
|
96
|
+
process.on('uncaughtException', (e) => { tui.stop(); hub.shutdown(); console.error(e); process.exit(1); });
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
// Output is plain one-liners on purpose: it lands in the calling agent's context. --json gives the raw result.
|
|
100
|
+
const json = !!flags.json;
|
|
101
|
+
const print = (r, text) => console.log(json ? JSON.stringify(r) : text());
|
|
102
|
+
const short = (m) => (m ? m.replace(/^claude-/, '') : '');
|
|
103
|
+
const one = (s) => s.replace(/\s+/g, ' ').trim();
|
|
104
|
+
const agentLine = (a) => [a.id, `${a.profile}${a.model ? '/' + short(a.model) : ''}`, a.role === 'orchestrator' ? '★' : a.role === 'manual' ? 'manuel' : '', a.status,
|
|
105
|
+
a.unreadMessages ? `✉${a.unreadMessages}` : '', a.boardUnread ? `▤${a.boardUnread}` : '', a.cwd ? `cwd=${a.cwd}` : '', a.task ? `"${a.task}"` : '']
|
|
106
|
+
.filter(Boolean).join(' ');
|
|
107
|
+
switch (cmd) {
|
|
108
|
+
case 'agents': {
|
|
109
|
+
const r = await call('agents', {});
|
|
110
|
+
return print(r, () => [...r.agents.map(agentLine), 'profiller: ' + r.profiles.map((p) => `${p.id}${p.model ? '/' + short(p.model) : ''}`).join(' ')].join('\n'));
|
|
111
|
+
}
|
|
112
|
+
case 'spawn': {
|
|
113
|
+
const cwd = typeof flags.cwd === 'string' ? flags.cwd : process.cwd();
|
|
114
|
+
const r = await call('spawn', { profile: pos[0], task: pos.slice(1).join(' ') + snippets(ctx, cwd), name: flags.name, cwd: flags.cwd });
|
|
115
|
+
return print(r, () => `açıldı: ${r.id}`);
|
|
116
|
+
}
|
|
117
|
+
case 'send':
|
|
118
|
+
case 'post': {
|
|
119
|
+
const r = await call('post', { to: pos[0], text: pos.slice(1).join(' ') + snippets(ctx, process.cwd()) });
|
|
120
|
+
return print(r, () => `gönderildi → ${pos[0]}`);
|
|
121
|
+
}
|
|
122
|
+
case 'wait': {
|
|
123
|
+
const r = await call('wait', { ids: pos, timeout: num(flags.timeout) });
|
|
124
|
+
return print(r, () => {
|
|
125
|
+
if (r.timeout)
|
|
126
|
+
return 'zaman aşımı · ' + r.agents.map((a) => `${a.id}:${a.status}`).join(' ');
|
|
127
|
+
const out = [];
|
|
128
|
+
if (r.settled.length)
|
|
129
|
+
out.push('durdu: ' + r.settled.map((a) => `${a.id} ${a.status}`).join(', '));
|
|
130
|
+
if (r.removed.length)
|
|
131
|
+
out.push('artık yok: ' + r.removed.join(', '));
|
|
132
|
+
for (const m of r.messages)
|
|
133
|
+
out.push(`✉ ${m.from}: ${m.text}`);
|
|
134
|
+
for (const m of r.board ?? [])
|
|
135
|
+
out.push(`[ortak] ${m.from}: ${m.text}`);
|
|
136
|
+
return out.join('\n');
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
case 'read': {
|
|
140
|
+
const r = await call('read', { id: pos[0], lines: num(flags.lines), scrollback: !!flags.scrollback, raw: !!flags.raw });
|
|
141
|
+
return print(r, () => `[${r.id} · ${r.status}]\n${r.screen}`);
|
|
142
|
+
}
|
|
143
|
+
case 'key': {
|
|
144
|
+
const r = await call('key', { id: pos[0], keys: pos.slice(1) });
|
|
145
|
+
return print(r, () => 'tamam');
|
|
146
|
+
}
|
|
147
|
+
case 'approvals': {
|
|
148
|
+
const r = await call('approvals', {});
|
|
149
|
+
return print(r, () => r.approvals.length ? r.approvals.map((x) => {
|
|
150
|
+
const p = x.prompt;
|
|
151
|
+
if (!p)
|
|
152
|
+
return `${x.id} ${x.profile} soru okunamadı — coban read ${x.id}`;
|
|
153
|
+
const opts = p.options.map((o, i) => `${i + 1}) ${o.label}`).join(' ');
|
|
154
|
+
return [`${x.id} ${x.profile} ${p.title}${p.body.length ? ': ' + p.body.join(' · ') : ''}`, ` ? ${p.question}${opts ? ' ' + opts : ''}`].join('\n');
|
|
155
|
+
}).join('\n') : '(izin bekleyen yok)');
|
|
156
|
+
}
|
|
157
|
+
case 'approve': {
|
|
158
|
+
const r = await call('approve', { id: pos[0], choice: pos[1] ?? 'yes' });
|
|
159
|
+
return print(r, () => `${pos[0]}: "${r.picked}" seçildi`);
|
|
160
|
+
}
|
|
161
|
+
case 'kill': {
|
|
162
|
+
const r = await call('kill', { id: pos[0] });
|
|
163
|
+
return print(r, () => `kapatıldı: ${pos[0]}`);
|
|
164
|
+
}
|
|
165
|
+
case 'inbox': {
|
|
166
|
+
const r = await call('inbox', {});
|
|
167
|
+
const lines = [...r.messages.map((m) => `✉ ${m.from}: ${m.text}`), ...r.board.map((m) => `[ortak] ${m.from}: ${m.text}`)];
|
|
168
|
+
return print(r, () => (lines.length ? lines.join('\n') : '(yeni mesaj yok)'));
|
|
169
|
+
}
|
|
170
|
+
case 'board': {
|
|
171
|
+
const r = await call('board', { limit: num(flags.limit) });
|
|
172
|
+
return print(r, () => (r.board.length ? r.board.map((m) => `${new Date(m.ts).toTimeString().slice(0, 5)} ${m.from}: ${m.text}`).join('\n') : '(ortak kutu boş)'));
|
|
173
|
+
}
|
|
174
|
+
case 'log': {
|
|
175
|
+
const r = await call('log', { limit: num(flags.limit) });
|
|
176
|
+
return print(r, () => r.messages.map((m) => `${new Date(m.ts).toTimeString().slice(0, 5)} ${m.from}→${m.to}: ${one(m.text)}`).join('\n'));
|
|
177
|
+
}
|
|
178
|
+
case 'usage': {
|
|
179
|
+
// Works without a running coban too (then 'limited' is unknown → false).
|
|
180
|
+
const r = await call('usage', {}).catch(() => ({ usage: allUsage(loadConfig()) }));
|
|
181
|
+
const tok = (n) => (n >= 1e6 ? (n / 1e6).toFixed(1) + 'M' : n >= 1e3 ? Math.round(n / 1e3) + 'k' : String(n));
|
|
182
|
+
const line = (u) => !u.limit5h
|
|
183
|
+
? [u.profileId, 'bilinmiyor', u.limited ? 'LİMİTLİ' : ''].filter(Boolean).join(' ')
|
|
184
|
+
: [u.profileId, `5s ${Math.round(u.pct5h * 100)}% (${tok(u.used5h)}/${tok(u.limit5h)})`, `7g ${tok(u.used7d)}`,
|
|
185
|
+
u.resetAt ? `reset ${new Date(u.resetAt).toTimeString().slice(0, 5)}` : 'reset -', u.limited ? 'LİMİTLİ' : ''].filter(Boolean).join(' ');
|
|
186
|
+
return print(r, () => r.usage.map(line).join('\n'));
|
|
187
|
+
}
|
|
188
|
+
case 'map':
|
|
189
|
+
console.log(repoMap(process.cwd(), pos[0] ?? ''));
|
|
190
|
+
return;
|
|
191
|
+
case 'help':
|
|
192
|
+
case '-h':
|
|
193
|
+
case '--help':
|
|
194
|
+
console.log(HELP);
|
|
195
|
+
return;
|
|
196
|
+
default:
|
|
197
|
+
console.error(HELP);
|
|
198
|
+
process.exit(2);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
main().catch((e) => { console.error('coban: ' + e.message); process.exit(1); });
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
export const ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..');
|
|
5
|
+
export const BIN_DIR = path.join(ROOT, 'bin');
|
|
6
|
+
export const STATE_DIR = path.join(os.homedir(), '.coban');
|
|
7
|
+
export const DEFAULT_SOCK = path.join(STATE_DIR, 'coban.sock');
|
|
8
|
+
export const SOCK = process.env.COBAN_SOCK || DEFAULT_SOCK;
|
|
9
|
+
export const CONFIG_PATH = process.env.COBAN_CONFIG || path.join(os.homedir(), '.config', 'coban', 'config.json');
|
|
10
|
+
export const OPUS = 'claude-opus-5-5';
|
|
11
|
+
export const SONNET = 'claude-sonnet-5-5';
|
|
12
|
+
// Claude may run `coban ...` without a permission prompt.
|
|
13
|
+
const CLAUDE_CMD = "claude --allowedTools 'Bash(coban:*)'";
|
|
14
|
+
const claude = (n, model, color) => ({
|
|
15
|
+
id: `h${n}`,
|
|
16
|
+
title: `Claude H${n}`,
|
|
17
|
+
cmd: CLAUDE_CMD,
|
|
18
|
+
model,
|
|
19
|
+
promptArg: '-- {prompt}',
|
|
20
|
+
env: { CLAUDE_CONFIG_DIR: `~/.claude-hesap${n}` },
|
|
21
|
+
color,
|
|
22
|
+
});
|
|
23
|
+
export const DEFAULT_CONFIG = {
|
|
24
|
+
orchestrator: 'h1',
|
|
25
|
+
// Coordination is mostly reading and routing, so the orchestrator runs on Sonnet;
|
|
26
|
+
// h1 keeps Opus for heavy coding work when it runs as a worker.
|
|
27
|
+
orchestratorModel: SONNET,
|
|
28
|
+
profiles: [
|
|
29
|
+
claude(1, OPUS, '#F6D5C8'),
|
|
30
|
+
claude(2, SONNET, '#FFD9A0'),
|
|
31
|
+
claude(3, SONNET, '#C8E6C9'),
|
|
32
|
+
{ id: 'codex', title: 'Codex', cmd: 'codex', promptArg: '{prompt}', color: '#8FDBFF' },
|
|
33
|
+
{ id: 'opencode', title: 'OpenCode', cmd: 'opencode', promptArg: '--prompt {prompt}', color: '#B5E48C' },
|
|
34
|
+
{ id: 'cursor', title: 'Cursor', cmd: 'cursor-agent', promptArg: '{prompt}', color: '#D0B3FF' },
|
|
35
|
+
],
|
|
36
|
+
};
|
|
37
|
+
const expand = (v) => v.replace(/^~(?=\/|$)/, os.homedir());
|
|
38
|
+
export function loadConfig() {
|
|
39
|
+
if (!fs.existsSync(CONFIG_PATH)) {
|
|
40
|
+
fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true });
|
|
41
|
+
fs.writeFileSync(CONFIG_PATH, JSON.stringify(DEFAULT_CONFIG, null, 2) + '\n');
|
|
42
|
+
}
|
|
43
|
+
const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
|
|
44
|
+
for (const p of cfg.profiles) {
|
|
45
|
+
// Older configs baked the model into cmd; lift it into `model` so overrides work.
|
|
46
|
+
const m = p.cmd.match(/\s--model[ =](\S+)/);
|
|
47
|
+
if (m) {
|
|
48
|
+
p.model ??= m[1];
|
|
49
|
+
p.cmd = p.cmd.replace(m[0], '');
|
|
50
|
+
}
|
|
51
|
+
for (const k of Object.keys(p.env ?? {}))
|
|
52
|
+
p.env[k] = expand(p.env[k]);
|
|
53
|
+
}
|
|
54
|
+
return cfg;
|
|
55
|
+
}
|