@zgeoff/atc 0.1.11 → 0.1.12

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
@@ -56,7 +56,7 @@ fine nested inside zellij/tmux (give the pane locked mode so Ctrl-Space reaches
56
56
  | `P` | overlay | revive: a fresh terminal resumes a headless or killed session in place |
57
57
  | `y` | overlay | yank the resume command (`claude --resume <id>`, `grok --resume <id>`, or `codex resume <id>`) |
58
58
  | `Y` | overlay | eject: yank the resume command, then kill the session here |
59
- | `K` | overlay | kill selected (confirm with `y`) — the entry stays revivable with `P`; a second `K` forgets it |
59
+ | `K` | overlay | kill selected (confirm with `y`) — the entry stays revivable with `P`, even across daemon restarts; a second `K` forgets it |
60
60
  | `?` | overlay | full key reference — the hint row only shows actions valid for the selected session |
61
61
  | `u` | overlay | restart an outdated daemon and restore the fleet — offered only while `⟳ update ready` shows |
62
62
  | `q` | home/overlay | quit the client — sessions keep running in the daemon |
@@ -186,8 +186,9 @@ restarts the daemon and restores the fleet at a moment you choose. Only a protoc
186
186
  the two could miscommunicate, forces the restart immediately. The daemon continuously writes the
187
187
  live fleet (name, cwd, agent, session id) to its SQLite store. If the daemon itself dies — crash,
188
188
  SIGKILL, reboot — the child processes die with it, but every session's transcript is already on
189
- disk. Start atc and press `R`: the whole fleet respawns with the matching CLI. Only deliberate kills
190
- (`K`, `Y` eject) remove entries from the fleet, so it stays restorable.
189
+ disk. Start atc and press `R`: the whole fleet respawns with the matching CLI. Killed sessions (`K`,
190
+ `Y` eject) stay in the fleet as exited entries — restore lists them as killed without booting a
191
+ terminal, and `P` still revives them. A second `K` forgets an entry for good.
191
192
 
192
193
  Restoring shows the whole fleet immediately — every incoming session appears in the list marked
193
194
  "waiting to restore" — and revives one at a time, most recently active first: the next resume starts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zgeoff/atc",
3
- "version": "0.1.11",
3
+ "version": "0.1.12",
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/daemon.ts CHANGED
@@ -615,29 +615,40 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
615
615
  getEffectiveDims: (sessionID) =>
616
616
  attachments.findEffectiveDims(sessionID) ?? ptyDims.get(sessionID) ?? { cols: 80, rows: 24 },
617
617
  restoreFleet: (cols, rows) => {
618
- const hasSession = (agentSessionID: string) =>
618
+ const hasLiveSession = (agentSessionID: string) =>
619
619
  mgr.sessions.some(
620
620
  (s) =>
621
621
  s.agentSessionID === agentSessionID &&
622
622
  (s.pty !== null || (s.kind === 'headless' && s.state !== 'exited')),
623
623
  );
624
624
 
625
+ const hasAnySession = (agentSessionID: string) =>
626
+ mgr.sessions.some((s) => s.agentSessionID === agentSessionID);
627
+
625
628
  const recency = store.collectFleetRecency();
626
629
 
627
630
  // Most recently active sessions revive first, so the ones the user was
628
631
  // just working in come back before long-idle ones; entries that never
629
- // reported an event keep their stored order at the end.
632
+ // reported an event keep their stored order at the end. Exited entries
633
+ // dedupe against every listed session, so repeated restores never
634
+ // double up the killed archive.
630
635
  const entries = store
631
636
  .loadFleet()
632
- .filter((entry) => !hasSession(entry.agentSessionID))
637
+ .filter((entry) =>
638
+ entry.exited === true
639
+ ? !hasAnySession(entry.agentSessionID)
640
+ : !hasLiveSession(entry.agentSessionID),
641
+ )
633
642
  .toSorted((a, b) =>
634
643
  (recency.get(b.agentSessionID) ?? '').localeCompare(recency.get(a.agentSessionID) ?? ''),
635
644
  );
636
645
 
637
646
  // The whole fleet registers as terminal-less sessions up front, so the
638
647
  // list shows every incoming session immediately instead of revealing
639
- // them one boot at a time.
640
- const queued = entries.map((entry) => mgr.restore(entry));
648
+ // them one boot at a time. Exited entries only register — they stay
649
+ // killed until revived by hand, so no terminal is adopted for them.
650
+ const registered = entries.map((entry) => mgr.restore(entry));
651
+ const queued = registered.filter((s) => s.state !== 'exited');
641
652
 
642
653
  const adoptQueued = (s: Session): boolean => {
643
654
  if (mgr.adoptTerminal(s.id, cols, rows) === null) {
@@ -656,7 +667,7 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
656
667
  const [first, ...rest] = queued;
657
668
 
658
669
  if (first === undefined) {
659
- return 0;
670
+ return registered.length;
660
671
  }
661
672
 
662
673
  // The first terminal attaches synchronously so a caller can attach at
@@ -682,7 +693,7 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
682
693
 
683
694
  void adoptRest(firstBooted);
684
695
 
685
- return queued.length;
696
+ return registered.length;
686
697
  },
687
698
  };
688
699
 
package/src/sessions.ts CHANGED
@@ -73,6 +73,7 @@ export interface FleetEntry {
73
73
  readonly agent: AgentKind;
74
74
  readonly pinned?: boolean;
75
75
  readonly lastAttachedAt?: number;
76
+ readonly exited?: boolean;
76
77
  }
77
78
 
78
79
  const fleetFile = join(stateDir, 'fleet.json');
@@ -111,6 +112,7 @@ export function parseFleetEntry(raw: unknown): FleetEntry | undefined {
111
112
  agent: toAgentKind(raw['agent']),
112
113
  ...(raw['pinned'] === true ? { pinned: true } : {}),
113
114
  ...(typeof raw['lastAttachedAt'] === 'number' ? { lastAttachedAt: raw['lastAttachedAt'] } : {}),
115
+ ...(raw['exited'] === true ? { exited: true } : {}),
114
116
  };
115
117
  }
116
118
 
@@ -206,17 +208,20 @@ export class SessionManager {
206
208
 
207
209
  // Registers a fleet entry as a session with no terminal yet, so a
208
210
  // fleet-wide restore can show every incoming session at once; adopting it
209
- // later attaches the terminal.
211
+ // later attaches the terminal. Exited entries come back as killed
212
+ // sessions: still listed and revivable, never auto-adopted.
210
213
  restore(entry: FleetEntry): Session {
214
+ const exited = entry.exited === true;
215
+
211
216
  const session: Session = {
212
217
  id: `s${++counter}-${Date.now().toString(36)}`,
213
218
  name: entry.name,
214
219
  cwd: entry.cwd,
215
220
  kind: 'headless',
216
221
  pty: null,
217
- state: 'running',
222
+ state: exited ? 'exited' : 'running',
218
223
  unread: false,
219
- lastMsg: 'waiting to restore',
224
+ lastMsg: exited ? 'killed' : 'waiting to restore',
220
225
  agentSessionID: entry.agentSessionID,
221
226
  agent: entry.agent,
222
227
  pinned: entry.pinned ?? false,
@@ -691,23 +696,28 @@ export class SessionManager {
691
696
 
692
697
  // Persisted continuously so a crash (or quit) leaves a restorable fleet.
693
698
  // Deliberate kills rewrite the file; unexpected session/atc deaths do not,
694
- // so the last known fleet survives for `R` restore.
699
+ // so the last known fleet survives for `R` restore. Sessions whose
700
+ // terminal is gone persist as exited entries, so the killed archive
701
+ // survives a daemon restart too.
695
702
  writeFleet() {
696
703
  const fleet: FleetEntry[] = [];
697
704
 
698
705
  for (const s of this.sessions) {
706
+ if (s.agentSessionID === undefined) {
707
+ continue;
708
+ }
709
+
699
710
  const live = s.pty !== null || (s.kind === 'headless' && s.state !== 'exited');
700
711
 
701
- if (live && s.agentSessionID !== undefined) {
702
- fleet.push({
703
- name: s.name,
704
- cwd: s.cwd,
705
- agentSessionID: s.agentSessionID,
706
- agent: s.agent,
707
- ...(s.pinned ? { pinned: true } : {}),
708
- lastAttachedAt: s.lastAttachedAt,
709
- });
710
- }
712
+ fleet.push({
713
+ name: s.name,
714
+ cwd: s.cwd,
715
+ agentSessionID: s.agentSessionID,
716
+ agent: s.agent,
717
+ ...(s.pinned ? { pinned: true } : {}),
718
+ lastAttachedAt: s.lastAttachedAt,
719
+ ...(live ? {} : { exited: true }),
720
+ });
711
721
  }
712
722
 
713
723
  this.store.writeFleet(fleet);
@@ -28,7 +28,8 @@ export class StateStore {
28
28
  cwd TEXT NOT NULL,
29
29
  pinned INTEGER NOT NULL DEFAULT 0,
30
30
  last_attached INTEGER,
31
- agent TEXT NOT NULL DEFAULT 'claude'
31
+ agent TEXT NOT NULL DEFAULT 'claude',
32
+ exited INTEGER NOT NULL DEFAULT 0
32
33
  );
33
34
  CREATE TABLE IF NOT EXISTS events (
34
35
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -75,6 +76,10 @@ export class StateStore {
75
76
  this.db.run("ALTER TABLE fleet ADD COLUMN agent TEXT NOT NULL DEFAULT 'claude'");
76
77
  }
77
78
 
79
+ if (!fleetColumns.has('exited')) {
80
+ this.db.run('ALTER TABLE fleet ADD COLUMN exited INTEGER NOT NULL DEFAULT 0');
81
+ }
82
+
78
83
  if (legacyFleetPath !== undefined) {
79
84
  this.adoptLegacyFleet(legacyFleetPath);
80
85
  }
@@ -90,9 +95,10 @@ export class StateStore {
90
95
  pinned: number;
91
96
  last_attached: number | null;
92
97
  agent: string | null;
98
+ exited: number;
93
99
  },
94
100
  []
95
- >('SELECT agent_session_id, name, cwd, pinned, last_attached, agent FROM fleet')
101
+ >('SELECT agent_session_id, name, cwd, pinned, last_attached, agent, exited FROM fleet')
96
102
  .all();
97
103
 
98
104
  const entries: FleetEntry[] = [];
@@ -105,6 +111,7 @@ export class StateStore {
105
111
  agent: toAgentKind(row.agent),
106
112
  ...(row.pinned === 0 ? {} : { pinned: true }),
107
113
  ...(row.last_attached === null ? {} : { lastAttachedAt: row.last_attached }),
114
+ ...(row.exited === 0 ? {} : { exited: true }),
108
115
  });
109
116
  }
110
117
 
@@ -128,11 +135,12 @@ export class StateStore {
128
135
  this.db.run('DELETE FROM fleet');
129
136
 
130
137
  const insert = this.db.query(
131
- 'INSERT OR REPLACE INTO fleet (agent_session_id, name, cwd, pinned, last_attached, agent) VALUES (?1, ?2, ?3, ?4, ?5, ?6)',
138
+ 'INSERT OR REPLACE INTO fleet (agent_session_id, name, cwd, pinned, last_attached, agent, exited) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)',
132
139
  );
133
140
 
134
141
  for (const entry of all) {
135
142
  const pinned = entry.pinned === true ? 1 : 0;
143
+ const exited = entry.exited === true ? 1 : 0;
136
144
 
137
145
  insert.run(
138
146
  entry.agentSessionID,
@@ -141,6 +149,7 @@ export class StateStore {
141
149
  pinned,
142
150
  entry.lastAttachedAt ?? null,
143
151
  entry.agent,
152
+ exited,
144
153
  );
145
154
  }
146
155
  });