@zgeoff/atc 0.1.0 → 0.1.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/README.md +38 -10
- package/package.json +1 -1
- package/src/config.ts +58 -1
- package/src/index.ts +13 -10
- package/src/ui.ts +4 -3
package/README.md
CHANGED
|
@@ -7,6 +7,11 @@
|
|
|
7
7
|
mouse.
|
|
8
8
|
</p>
|
|
9
9
|
|
|
10
|
+
<p>
|
|
11
|
+
<a href="https://www.npmjs.com/package/@zgeoff/atc"><img src="https://img.shields.io/npm/v/%40zgeoff%2Fatc" alt="npm version"></a>
|
|
12
|
+
<a href="./LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT license"></a>
|
|
13
|
+
</p>
|
|
14
|
+
|
|
10
15
|
<p>
|
|
11
16
|
<a href="./docs/README.md">Documentation</a> •
|
|
12
17
|
<a href="./docs/architecture/overview.md">Architecture</a> •
|
|
@@ -14,12 +19,16 @@
|
|
|
14
19
|
</p>
|
|
15
20
|
</div>
|
|
16
21
|
|
|
17
|
-
##
|
|
22
|
+
## Install
|
|
18
23
|
|
|
19
24
|
```sh
|
|
20
|
-
bun
|
|
25
|
+
bun add -g @zgeoff/atc
|
|
26
|
+
atc
|
|
21
27
|
```
|
|
22
28
|
|
|
29
|
+
Needs [Bun](https://bun.sh) (atc runs from source through it) and the `claude` CLI on your PATH.
|
|
30
|
+
From a checkout, `bun src/cli.ts` runs the same thing.
|
|
31
|
+
|
|
23
32
|
The first invocation auto-spawns the daemon (`atc daemon` runs it in the foreground for systemd or
|
|
24
33
|
debugging); the TUI is a thin client, so quitting or crashing it leaves every session running. Runs
|
|
25
34
|
fine nested inside zellij/tmux (give the pane locked mode so Ctrl-Space reaches atc).
|
|
@@ -28,7 +37,7 @@ fine nested inside zellij/tmux (give the pane locked mode so Ctrl-Space reaches
|
|
|
28
37
|
|
|
29
38
|
| Key | Where | Action |
|
|
30
39
|
| --------------- | -------------- | ---------------------------------------------------------------------------------------------- |
|
|
31
|
-
|
|
|
40
|
+
| leader | anywhere | toggle session overlay — `Ctrl-Space` by default, configurable (see Config) |
|
|
32
41
|
| `n` | home/overlay | spawn: pick dir (zoxide + history, fuzzy) → name → optional first prompt |
|
|
33
42
|
| `r` | home/overlay | adopt: pick dir → name → `claude --resume` (Claude's session picker opens in the new PTY) |
|
|
34
43
|
| `R` | home | restore last fleet after a daemon death — respawns every session via `claude --resume <id>` |
|
|
@@ -63,17 +72,36 @@ status bar turns red and names the most urgent session.
|
|
|
63
72
|
|
|
64
73
|
## Config
|
|
65
74
|
|
|
66
|
-
`~/.config/atc/config.json
|
|
75
|
+
`~/.config/atc/config.json` (created with defaults on first run):
|
|
67
76
|
|
|
68
77
|
```json
|
|
69
|
-
{
|
|
78
|
+
{
|
|
79
|
+
"claudeBin": "claude",
|
|
80
|
+
"claudeArgs": [],
|
|
81
|
+
"leader": "ctrl-space"
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
| Field | Default | Meaning |
|
|
86
|
+
| ------------ | -------------- | ------------------------------------------------------------------------------------- |
|
|
87
|
+
| `claudeBin` | `"claude"` | The binary spawned for every session. |
|
|
88
|
+
| `claudeArgs` | `[]` | Prepended to every spawn, e.g. `["--model", "opus"]`. |
|
|
89
|
+
| `leader` | `"ctrl-space"` | The overlay toggle: `ctrl-` plus a letter or one of `\` `]` `^` `_`, e.g. `"ctrl-]"`. |
|
|
90
|
+
|
|
91
|
+
Pick a different leader when `Ctrl-Space` is taken on your machine — Raycast on macOS claims it, and
|
|
92
|
+
`ctrl-]` is a solid replacement that no common terminal, multiplexer, or OS shortcut wants. An
|
|
93
|
+
unknown or reserved value falls back to the default.
|
|
94
|
+
|
|
95
|
+
`atc mcp` exposes the fleet as MCP tools (list, spawn, drive) to any MCP client, wrangled sessions
|
|
96
|
+
included:
|
|
97
|
+
|
|
98
|
+
```sh
|
|
99
|
+
claude mcp add --scope user atc -- atc mcp
|
|
70
100
|
```
|
|
71
101
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
(SQLite), next to `status.json` (read by the injected statusline); the daemon's pid file sits in
|
|
76
|
-
`$XDG_RUNTIME_DIR/atc-daemon.pid`, beside its sockets.
|
|
102
|
+
Daemon state — the restorable fleet, spawn-dir history, and the hook-event trail — lives in
|
|
103
|
+
`~/.local/state/atc/atc.db` (SQLite), next to `status.json` (read by the injected statusline); the
|
|
104
|
+
daemon's pid file sits in `$XDG_RUNTIME_DIR/atc-daemon.pid`, beside its sockets.
|
|
77
105
|
|
|
78
106
|
## Crash safety
|
|
79
107
|
|
package/package.json
CHANGED
package/src/config.ts
CHANGED
|
@@ -6,11 +6,18 @@ import { isRecord } from './report';
|
|
|
6
6
|
export interface Config {
|
|
7
7
|
claudeBin: string;
|
|
8
8
|
claudeArgs: string[];
|
|
9
|
+
leader: LeaderKey;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface LeaderKey {
|
|
13
|
+
readonly code: number;
|
|
14
|
+
readonly label: string;
|
|
9
15
|
}
|
|
10
16
|
|
|
11
17
|
const DEFAULTS: Config = {
|
|
12
18
|
claudeBin: 'claude',
|
|
13
19
|
claudeArgs: [],
|
|
20
|
+
leader: { code: 0, label: '^Space' },
|
|
14
21
|
};
|
|
15
22
|
|
|
16
23
|
const configDir = join(homedir(), '.config', 'atc');
|
|
@@ -50,8 +57,58 @@ export function loadConfig(): Config {
|
|
|
50
57
|
? parsed['claudeArgs'].filter((a): a is string => typeof a === 'string')
|
|
51
58
|
: DEFAULTS.claudeArgs;
|
|
52
59
|
|
|
53
|
-
|
|
60
|
+
const leader =
|
|
61
|
+
(typeof parsed['leader'] === 'string' ? decodeLeader(parsed['leader']) : null) ??
|
|
62
|
+
DEFAULTS.leader;
|
|
63
|
+
|
|
64
|
+
return { claudeBin, claudeArgs, leader };
|
|
54
65
|
} catch {
|
|
55
66
|
return { ...DEFAULTS };
|
|
56
67
|
}
|
|
57
68
|
}
|
|
69
|
+
|
|
70
|
+
// Control bytes the terminal needs for its own input: enter, tab, and esc
|
|
71
|
+
// as leaders would swallow ordinary typing.
|
|
72
|
+
const RESERVED_CODES = new Set([0x09, 0x0d, 0x1b]);
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Decodes a leader name like "ctrl-space", "ctrl-]", or "ctrl-a" into its
|
|
76
|
+
* control byte and status-bar label. Unknown or reserved keys decode to
|
|
77
|
+
* null, so a bad config falls back to the default instead of breaking the
|
|
78
|
+
* client.
|
|
79
|
+
*/
|
|
80
|
+
function decodeLeader(name: string): LeaderKey | null {
|
|
81
|
+
const m = /^ctrl-(?<key>.+)$/i.exec(name.trim().toLowerCase());
|
|
82
|
+
const key = m?.groups?.['key'];
|
|
83
|
+
|
|
84
|
+
if (key === undefined) {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (key === 'space') {
|
|
89
|
+
return { code: 0, label: '^Space' };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
let code: number | null = null;
|
|
93
|
+
const cp = key.codePointAt(0);
|
|
94
|
+
|
|
95
|
+
if (/^[a-z]$/.test(key) && cp !== undefined) {
|
|
96
|
+
code = cp - 96;
|
|
97
|
+
} else if (key === '[') {
|
|
98
|
+
code = 0x1b;
|
|
99
|
+
} else if (key === '\\') {
|
|
100
|
+
code = 0x1c;
|
|
101
|
+
} else if (key === ']') {
|
|
102
|
+
code = 0x1d;
|
|
103
|
+
} else if (key === '^') {
|
|
104
|
+
code = 0x1e;
|
|
105
|
+
} else if (key === '_') {
|
|
106
|
+
code = 0x1f;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (code === null || RESERVED_CODES.has(code)) {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return { code, label: `^${key.toUpperCase()}` };
|
|
114
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { basename } from 'node:path';
|
|
2
2
|
import { bootDaemonClient } from './boot-daemon';
|
|
3
|
+
import { loadConfig } from './config';
|
|
3
4
|
import { collectDirs, formatDir, pickMatches } from './dirs';
|
|
4
5
|
import type { EventMsg } from './protocol';
|
|
5
6
|
import { isRecord } from './report';
|
|
@@ -76,6 +77,7 @@ function scheduleStatus() {
|
|
|
76
77
|
counts: countSessionStates(fleet),
|
|
77
78
|
focusedName: focused === null ? null : focused.name,
|
|
78
79
|
urgentName: urgent === undefined ? null : urgent.name,
|
|
80
|
+
leaderLabel: leader.label,
|
|
79
81
|
});
|
|
80
82
|
}
|
|
81
83
|
}, 50);
|
|
@@ -127,7 +129,7 @@ function toBase() {
|
|
|
127
129
|
} else {
|
|
128
130
|
mode = 'home';
|
|
129
131
|
|
|
130
|
-
drawHome(fleetCount);
|
|
132
|
+
drawHome(fleetCount, leader.label);
|
|
131
133
|
}
|
|
132
134
|
|
|
133
135
|
scheduleStatus();
|
|
@@ -180,7 +182,7 @@ function openHelp() {
|
|
|
180
182
|
function applyHelpKey(buf: Buffer) {
|
|
181
183
|
const ch = buf.toString();
|
|
182
184
|
|
|
183
|
-
if (buf[0] === KEY.esc || ch === '?' || ch === 'q' || buf[0] ===
|
|
185
|
+
if (buf[0] === KEY.esc || ch === '?' || ch === 'q' || buf[0] === leader.code) {
|
|
184
186
|
openOverlay();
|
|
185
187
|
}
|
|
186
188
|
}
|
|
@@ -524,8 +526,9 @@ function copyToClipboard(text: string) {
|
|
|
524
526
|
|
|
525
527
|
// ---- input ----
|
|
526
528
|
|
|
529
|
+
const leader = loadConfig().leader;
|
|
530
|
+
|
|
527
531
|
const KEY = {
|
|
528
|
-
ctrlSpace: 0x00,
|
|
529
532
|
ctrlC: 0x03,
|
|
530
533
|
ctrlU: 0x15,
|
|
531
534
|
esc: 0x1b,
|
|
@@ -578,7 +581,7 @@ function applyOverlayFilterKey(buf: Buffer): boolean {
|
|
|
578
581
|
return true;
|
|
579
582
|
}
|
|
580
583
|
|
|
581
|
-
if (buf[0] === KEY.enter || isUp(buf) || isDown(buf) || buf[0] ===
|
|
584
|
+
if (buf[0] === KEY.enter || isUp(buf) || isDown(buf) || buf[0] === leader.code) {
|
|
582
585
|
return false;
|
|
583
586
|
}
|
|
584
587
|
|
|
@@ -636,7 +639,7 @@ function applyOverlayKey(buf: Buffer) {
|
|
|
636
639
|
return;
|
|
637
640
|
}
|
|
638
641
|
|
|
639
|
-
if (buf[0] ===
|
|
642
|
+
if (buf[0] === leader.code || (buf[0] === KEY.esc && buf.length === 1)) {
|
|
640
643
|
toBase();
|
|
641
644
|
|
|
642
645
|
return;
|
|
@@ -778,7 +781,7 @@ function applyTextKey(buf: Buffer, onSubmit: () => void, onCancel: () => void) {
|
|
|
778
781
|
return;
|
|
779
782
|
}
|
|
780
783
|
|
|
781
|
-
if (buf[0] ===
|
|
784
|
+
if (buf[0] === leader.code) {
|
|
782
785
|
toBase();
|
|
783
786
|
|
|
784
787
|
return;
|
|
@@ -877,7 +880,7 @@ const stdinDecoder = new TextDecoder('utf-8');
|
|
|
877
880
|
process.stdin.on('data', (buf: Buffer) => {
|
|
878
881
|
switch (mode) {
|
|
879
882
|
case 'attached': {
|
|
880
|
-
if (buf[0] ===
|
|
883
|
+
if (buf[0] === leader.code && buf.length === 1) {
|
|
881
884
|
openOverlay();
|
|
882
885
|
|
|
883
886
|
return;
|
|
@@ -893,7 +896,7 @@ process.stdin.on('data', (buf: Buffer) => {
|
|
|
893
896
|
return;
|
|
894
897
|
}
|
|
895
898
|
case 'home': {
|
|
896
|
-
if (buf[0] ===
|
|
899
|
+
if (buf[0] === leader.code) {
|
|
897
900
|
openOverlay();
|
|
898
901
|
|
|
899
902
|
return;
|
|
@@ -1037,7 +1040,7 @@ stdout.on('resize', () => {
|
|
|
1037
1040
|
}
|
|
1038
1041
|
|
|
1039
1042
|
if (mode === 'home') {
|
|
1040
|
-
drawHome(fleetCount);
|
|
1043
|
+
drawHome(fleetCount, leader.label);
|
|
1041
1044
|
}
|
|
1042
1045
|
|
|
1043
1046
|
if (mode === 'overlay') {
|
|
@@ -1072,5 +1075,5 @@ process.on('SIGHUP', () => quit());
|
|
|
1072
1075
|
// ---- start ----
|
|
1073
1076
|
stdout.write(ansi.altScreenOn);
|
|
1074
1077
|
|
|
1075
|
-
drawHome(fleetCount);
|
|
1078
|
+
drawHome(fleetCount, leader.label);
|
|
1076
1079
|
scheduleStatus();
|
package/src/ui.ts
CHANGED
|
@@ -66,6 +66,7 @@ export interface StatusView {
|
|
|
66
66
|
readonly counts: Readonly<Record<SessionState, number>>;
|
|
67
67
|
readonly focusedName: string | null;
|
|
68
68
|
readonly urgentName: string | null;
|
|
69
|
+
readonly leaderLabel: string;
|
|
69
70
|
}
|
|
70
71
|
|
|
71
72
|
export function drawStatusBar(view: StatusView) {
|
|
@@ -93,7 +94,7 @@ export function drawStatusBar(view: StatusView) {
|
|
|
93
94
|
}
|
|
94
95
|
|
|
95
96
|
const joined = parts.join(' ▏');
|
|
96
|
-
const right = ` ${joined === '' ? 'idle' : joined}
|
|
97
|
+
const right = ` ${joined === '' ? 'idle' : joined} ▏${view.leaderLabel} `;
|
|
97
98
|
const pad = Math.max(1, width - left.length - right.length);
|
|
98
99
|
const bg = c.needs_you > 0 ? `${ESC}[1;97;41m` : `${ESC}[30;47m`;
|
|
99
100
|
const text = truncate(left + ' '.repeat(pad) + right, width).padEnd(width);
|
|
@@ -317,7 +318,7 @@ export function drawPicker(view: PickerView) {
|
|
|
317
318
|
drawBox(rowsList);
|
|
318
319
|
}
|
|
319
320
|
|
|
320
|
-
export function drawHome(fleetCount = 0) {
|
|
321
|
+
export function drawHome(fleetCount = 0, leaderLabel = '^Space') {
|
|
321
322
|
out(ansi.clear + ansi.hideCursor);
|
|
322
323
|
|
|
323
324
|
const msgs = [
|
|
@@ -326,7 +327,7 @@ export function drawHome(fleetCount = 0) {
|
|
|
326
327
|
'n spawn a session',
|
|
327
328
|
'r adopt an existing session (claude --resume)',
|
|
328
329
|
...(fleetCount > 0 ? [`R restore last fleet (${fleetCount} sessions)`] : []),
|
|
329
|
-
|
|
330
|
+
`${leaderLabel.padEnd(7)} session list`,
|
|
330
331
|
'q quit',
|
|
331
332
|
];
|
|
332
333
|
|