@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 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
- ## Run
22
+ ## Install
18
23
 
19
24
  ```sh
20
- bun src/cli.ts # or `atc` if bin/atc is on your PATH
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
- | `Ctrl-Space` | anywhere | toggle session overlay |
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
- { "claudeBin": "claude", "claudeArgs": [] }
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
- `claudeArgs` is prepended to every spawn (e.g. `["--model", "opus"]`). `atc mcp` exposes the fleet
73
- as MCP tools (list, spawn, drive) to any MCP client, wrangled sessions included. Daemon state — the
74
- restorable fleet, spawn-dir history, and the hook-event trail — lives in `~/.local/state/atc/atc.db`
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zgeoff/atc",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Terminal control tower for Claude Code sessions",
5
5
  "homepage": "https://github.com/zgeoff/atc#readme",
6
6
  "bugs": "https://github.com/zgeoff/atc/issues",
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
- return { claudeBin, claudeArgs };
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] === KEY.ctrlSpace) {
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] === KEY.ctrlSpace) {
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] === KEY.ctrlSpace || (buf[0] === KEY.esc && buf.length === 1)) {
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] === KEY.ctrlSpace) {
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] === KEY.ctrlSpace && buf.length === 1) {
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] === KEY.ctrlSpace) {
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} ▏^Space `;
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
- '^Space session list',
330
+ `${leaderLabel.padEnd(7)} session list`,
330
331
  'q quit',
331
332
  ];
332
333