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 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
- # Temporary Holding Version
1
+ # coban
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![ci](https://github.com/cenkerarin/coban/actions/workflows/ci.yml/badge.svg)](https://github.com/cenkerarin/coban/actions/workflows/ci.yml)
4
+ [![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](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
+ ![coban: conversation feed, agent graph and chat](docs/screenshot.svg)
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
+ }