@zgeoff/atc 0.1.12 → 1.0.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.
Files changed (75) hide show
  1. package/README.md +50 -12
  2. package/package.json +7 -2
  3. package/src/{agent-adapter.ts → agents/agent-adapter.ts} +31 -14
  4. package/src/{build-cli-command.ts → agents/build-cli-command.ts} +1 -1
  5. package/src/agents/build-hook-settings.ts +59 -0
  6. package/src/{claude-adapter.ts → agents/claude-adapter.ts} +41 -80
  7. package/src/{codex-adapter.ts → agents/codex-adapter.ts} +41 -30
  8. package/src/agents/gateway-adapter.ts +106 -0
  9. package/src/{grok-adapter.ts → agents/grok-adapter.ts} +83 -46
  10. package/src/agents/resolve-agent-home.ts +13 -0
  11. package/src/agents/truncate-detail.ts +7 -0
  12. package/src/agents/write-hook-settings.ts +37 -0
  13. package/src/cli.ts +25 -14
  14. package/src/{boot-daemon.ts → client/boot-daemon.ts} +8 -7
  15. package/src/client/build-client-machine.ts +119 -0
  16. package/src/client/collect-agent-picks.ts +31 -0
  17. package/src/{daemon-client.ts → client/daemon-client.ts} +4 -4
  18. package/src/{dirs.ts → client/dirs.ts} +8 -0
  19. package/src/client/format-overlay-agent-mark.ts +15 -0
  20. package/src/{index.ts → client/index.ts} +181 -550
  21. package/src/client/keys.ts +111 -0
  22. package/src/client/parse-daemon-event.ts +114 -0
  23. package/src/{pick-tab-target.ts → client/pick-tab-target.ts} +2 -2
  24. package/src/client/plan-vacated-rows.ts +26 -0
  25. package/src/client/spawn-picker.ts +305 -0
  26. package/src/client/to-mirror-session.ts +65 -0
  27. package/src/{ui.ts → client/ui.ts} +27 -8
  28. package/src/{attach-registry.ts → daemon/attach-registry.ts} +12 -10
  29. package/src/daemon/build-session-event.ts +43 -0
  30. package/src/{daemon-connection.ts → daemon/daemon-connection.ts} +194 -108
  31. package/src/{daemon.ts → daemon/daemon.ts} +148 -313
  32. package/src/{hooks.ts → daemon/hooks.ts} +6 -4
  33. package/src/daemon/mint-session-id.ts +12 -0
  34. package/src/{permission-registry.ts → daemon/permission-registry.ts} +5 -3
  35. package/src/daemon/restore-fleet.ts +141 -0
  36. package/src/daemon/run-eject-handoff.ts +33 -0
  37. package/src/{screen-model.ts → daemon/screen-model.ts} +1 -1
  38. package/src/daemon/session-runtime.ts +64 -0
  39. package/src/{sessions.ts → daemon/sessions.ts} +77 -133
  40. package/src/{start-headless-run.ts → daemon/start-headless-run.ts} +9 -3
  41. package/src/daemon/start-headless-turn.ts +57 -0
  42. package/src/hook-report.ts +2 -2
  43. package/src/mcp-server.ts +33 -44
  44. package/src/protocol/parse-request-params.ts +31 -0
  45. package/src/{protocol.ts → protocol/protocol.ts} +1 -1
  46. package/src/protocol/request-param-schemas.ts +109 -0
  47. package/src/shared/agent-session-id.ts +8 -0
  48. package/src/shared/build-optional-boolean.ts +10 -0
  49. package/src/shared/build-optional-string-array.ts +14 -0
  50. package/src/shared/build-optional-string.ts +10 -0
  51. package/src/shared/collect-gateways.ts +113 -0
  52. package/src/{config.ts → shared/config.ts} +47 -31
  53. package/src/{get-build.ts → shared/get-build.ts} +21 -6
  54. package/src/shared/get-record.ts +20 -0
  55. package/src/shared/session-id.ts +8 -0
  56. package/src/shared/to-agent-session-id.ts +12 -0
  57. package/src/shared/to-session-id.ts +11 -0
  58. package/src/shared/to-shell-arg.ts +7 -0
  59. package/src/statusline.ts +2 -2
  60. package/src/store/bun-sqlite-driver.ts +96 -0
  61. package/src/store/fleet-entry.ts +72 -0
  62. package/src/store/run-migrations.ts +242 -0
  63. package/src/store/state-store.ts +226 -0
  64. package/src/format-overlay-agent-mark.ts +0 -17
  65. package/src/state-store.ts +0 -236
  66. /package/src/{normalize-hook-event.ts → agents/normalize-hook-event.ts} +0 -0
  67. /package/src/{print-codex-hook-file.ts → agents/print-codex-hook-file.ts} +0 -0
  68. /package/src/{print-grok-hook-file.ts → agents/print-grok-hook-file.ts} +0 -0
  69. /package/src/{build-leader-chords.ts → client/build-leader-chords.ts} +0 -0
  70. /package/src/{daemon-error.ts → protocol/daemon-error.ts} +0 -0
  71. /package/src/{outbound-queue.ts → protocol/outbound-queue.ts} +0 -0
  72. /package/src/{collect-clean-env.ts → shared/collect-clean-env.ts} +0 -0
  73. /package/src/{report.ts → shared/report.ts} +0 -0
  74. /package/src/{reset-input-modes.ts → shared/reset-input-modes.ts} +0 -0
  75. /package/src/{resolve-repo-root.ts → shared/resolve-repo-root.ts} +0 -0
@@ -0,0 +1,242 @@
1
+ import { sql } from 'kysely';
2
+ import type { Generated, Kysely } from 'kysely';
3
+ import { DEFAULT_MIGRATION_TABLE, Migrator } from 'kysely/migration';
4
+ import type { Migration, MigrationProvider, MigrationResultSet } from 'kysely/migration';
5
+
6
+ interface FleetTable {
7
+ agent_session_id: string;
8
+ name: string;
9
+ cwd: string;
10
+ pinned: number;
11
+ last_attached: number | null;
12
+ agent: string;
13
+ exited: number;
14
+ }
15
+
16
+ interface EventsTable {
17
+ id: Generated<number>;
18
+ ts: string;
19
+ atc_id: string;
20
+ event: string;
21
+ message: string | null;
22
+ session_id: string | null;
23
+ }
24
+
25
+ interface SpawnHistoryTable {
26
+ cwd: string;
27
+ last_spawn: number;
28
+ }
29
+
30
+ interface PrefsTable {
31
+ key: string;
32
+ value: string;
33
+ }
34
+
35
+ /**
36
+ * The state store's schema: what the query builder and the migration
37
+ * ladder both build against.
38
+ */
39
+ export interface StateStoreSchema {
40
+ fleet: FleetTable;
41
+ events: EventsTable;
42
+ spawn_history: SpawnHistoryTable;
43
+ prefs: PrefsTable;
44
+ }
45
+
46
+ // Every shape the fleet table has shipped with: the oldest carries only
47
+ // agent_session_id under its Claude-era name plus name and cwd, and each
48
+ // later step adds one column the daemon grew to depend on. events,
49
+ // spawn_history, and prefs have carried one shape since they were added, so
50
+ // they need only their initial creation.
51
+ const MIGRATIONS: Record<string, Migration> = {
52
+ '001_create_initial_schema': {
53
+ async up(db: Kysely<StateStoreSchema>) {
54
+ await db.schema
55
+ .createTable('fleet')
56
+ .ifNotExists()
57
+ .addColumn('claude_id', 'text', (c) => c.primaryKey())
58
+ .addColumn('name', 'text', (c) => c.notNull())
59
+ .addColumn('cwd', 'text', (c) => c.notNull())
60
+ .execute();
61
+
62
+ await db.schema
63
+ .createTable('events')
64
+ .ifNotExists()
65
+ .addColumn('id', 'integer', (c) => c.primaryKey().autoIncrement())
66
+ .addColumn('ts', 'text', (c) => c.notNull())
67
+ .addColumn('atc_id', 'text', (c) => c.notNull())
68
+ .addColumn('event', 'text', (c) => c.notNull())
69
+ .addColumn('message', 'text')
70
+ .addColumn('session_id', 'text')
71
+ .execute();
72
+
73
+ await db.schema
74
+ .createTable('spawn_history')
75
+ .ifNotExists()
76
+ .addColumn('cwd', 'text', (c) => c.primaryKey())
77
+ .addColumn('last_spawn', 'integer', (c) => c.notNull())
78
+ .execute();
79
+
80
+ await db.schema
81
+ .createTable('prefs')
82
+ .ifNotExists()
83
+ .addColumn('key', 'text', (c) => c.primaryKey())
84
+ .addColumn('value', 'text', (c) => c.notNull())
85
+ .execute();
86
+ },
87
+ },
88
+ '002_rename_fleet_claude_id_to_agent_session_id': {
89
+ async up(db: Kysely<StateStoreSchema>) {
90
+ await db.schema.alterTable('fleet').renameColumn('claude_id', 'agent_session_id').execute();
91
+ },
92
+ },
93
+ '003_add_fleet_pinned': {
94
+ async up(db: Kysely<StateStoreSchema>) {
95
+ await db.schema
96
+ .alterTable('fleet')
97
+ .addColumn('pinned', 'integer', (c) => c.notNull().defaultTo(0))
98
+ .execute();
99
+ },
100
+ },
101
+ '004_add_fleet_last_attached': {
102
+ async up(db: Kysely<StateStoreSchema>) {
103
+ await db.schema.alterTable('fleet').addColumn('last_attached', 'integer').execute();
104
+ },
105
+ },
106
+ '005_add_fleet_agent': {
107
+ async up(db: Kysely<StateStoreSchema>) {
108
+ await db.schema
109
+ .alterTable('fleet')
110
+ .addColumn('agent', 'text', (c) => c.notNull().defaultTo('claude'))
111
+ .execute();
112
+ },
113
+ },
114
+ '006_add_fleet_exited': {
115
+ async up(db: Kysely<StateStoreSchema>) {
116
+ await db.schema
117
+ .alterTable('fleet')
118
+ .addColumn('exited', 'integer', (c) => c.notNull().defaultTo(0))
119
+ .execute();
120
+ },
121
+ },
122
+ };
123
+
124
+ const PROVIDER: MigrationProvider = {
125
+ getMigrations: () => Promise.resolve(MIGRATIONS),
126
+ };
127
+
128
+ /**
129
+ * Brings a state-store database up to the current schema through kysely's
130
+ * `Migrator`, one additive step at a time. A database from before the
131
+ * ladder existed is recognized at whichever shape it stopped at by a
132
+ * baselining pass that records the steps its columns already satisfy, so
133
+ * only what is genuinely missing runs. Nothing here ever drops a column or
134
+ * a row.
135
+ */
136
+ export async function runMigrations(db: Kysely<StateStoreSchema>): Promise<void> {
137
+ // A baselined step can land out of order relative to a step that turned
138
+ // out to still be missing beside it, so the ledger the baselining pass
139
+ // writes is not guaranteed to be a contiguous prefix of the ladder.
140
+ const migrator = new Migrator({ db, provider: PROVIDER, allowUnorderedMigrations: true });
141
+
142
+ // Baselining writes straight into the ledger, so the ledger has to exist
143
+ // first, and only the opening step can create it. A database that already
144
+ // has a ledger has been baselined once and goes straight to the latest
145
+ // step — running the opening step against a full ledger would read as a
146
+ // request to migrate back down to it.
147
+ if (!(await hasMigrationLedger(db))) {
148
+ const opened = await migrator.migrateTo('001_create_initial_schema');
149
+
150
+ requireMigrated(opened);
151
+
152
+ await recordLegacyBaseline(db);
153
+ }
154
+
155
+ const latest = await migrator.migrateToLatest();
156
+
157
+ requireMigrated(latest);
158
+ }
159
+
160
+ async function hasMigrationLedger(db: Kysely<StateStoreSchema>): Promise<boolean> {
161
+ const result = await sql<{
162
+ name: string;
163
+ }>`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ${DEFAULT_MIGRATION_TABLE}`.execute(
164
+ db,
165
+ );
166
+
167
+ return result.rows.length > 0;
168
+ }
169
+
170
+ function requireMigrated(result: MigrationResultSet): void {
171
+ if (result.error === undefined) {
172
+ return;
173
+ }
174
+
175
+ if (result.error instanceof Error) {
176
+ throw result.error;
177
+ }
178
+
179
+ throw new Error('kysely migration failed', { cause: result.error });
180
+ }
181
+
182
+ // The migrator's own ledger has no concept of a database that already
183
+ // carries a later step's outcome from before the ladder existed. Recording
184
+ // those steps here, straight into the ledger, is what lets the migrator run
185
+ // only the steps a legacy database's fleet table genuinely still needs.
186
+ async function recordLegacyBaseline(db: Kysely<StateStoreSchema>): Promise<void> {
187
+ const columns = await collectFleetColumns(db);
188
+
189
+ const steps = pickBaselineSteps(columns);
190
+
191
+ if (steps.length === 0) {
192
+ return;
193
+ }
194
+
195
+ const appliedAt = new Date().toISOString();
196
+
197
+ for (const name of steps) {
198
+ await sql`INSERT OR IGNORE INTO ${sql.table(DEFAULT_MIGRATION_TABLE)} (name, timestamp) VALUES (${name}, ${appliedAt})`.execute(
199
+ db,
200
+ );
201
+ }
202
+ }
203
+
204
+ interface ColumnInfoRow {
205
+ name: string;
206
+ }
207
+
208
+ async function collectFleetColumns(db: Kysely<StateStoreSchema>): Promise<ReadonlySet<string>> {
209
+ const result = await sql<ColumnInfoRow>`PRAGMA table_info(fleet)`.execute(db);
210
+
211
+ return new Set(result.rows.map((row) => row.name));
212
+ }
213
+
214
+ // Each step is judged solely by whether its own column is already there —
215
+ // never by whether an earlier step's column is — because a database from
216
+ // before the ladder existed can carry any one of these columns without the
217
+ // others.
218
+ function pickBaselineSteps(columns: ReadonlySet<string>): readonly string[] {
219
+ const steps: string[] = [];
220
+
221
+ if (columns.has('agent_session_id')) {
222
+ steps.push('002_rename_fleet_claude_id_to_agent_session_id');
223
+ }
224
+
225
+ if (columns.has('pinned')) {
226
+ steps.push('003_add_fleet_pinned');
227
+ }
228
+
229
+ if (columns.has('last_attached')) {
230
+ steps.push('004_add_fleet_last_attached');
231
+ }
232
+
233
+ if (columns.has('agent')) {
234
+ steps.push('005_add_fleet_agent');
235
+ }
236
+
237
+ if (columns.has('exited')) {
238
+ steps.push('006_add_fleet_exited');
239
+ }
240
+
241
+ return steps;
242
+ }
@@ -0,0 +1,226 @@
1
+ import { Database } from 'bun:sqlite';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { Kysely, SqliteAdapter, SqliteIntrospector, SqliteQueryCompiler, sql } from 'kysely';
4
+ import { toAgentID } from '../agents/agent-adapter';
5
+ import type { AgentID } from '../agents/agent-adapter';
6
+ import type { HookEvent } from '../daemon/hooks';
7
+ import type { AgentSessionID } from '../shared/agent-session-id';
8
+ import { toAgentSessionID } from '../shared/to-agent-session-id';
9
+ import { BunSqliteDriver } from './bun-sqlite-driver';
10
+ import { parseFleetEntry } from './fleet-entry';
11
+ import type { FleetEntry } from './fleet-entry';
12
+ import { runMigrations } from './run-migrations';
13
+ import type { StateStoreSchema } from './run-migrations';
14
+
15
+ /**
16
+ * Daemon state in one SQLite store: the restorable fleet, the hook-event
17
+ * debug trail, and the spawn-directory history. The statusline contract
18
+ * file (status.json) stays a plain file because reporters inside wrangled
19
+ * sessions read it without speaking to the daemon. An existing fleet.json
20
+ * seeds the fleet table once, so upgrading never loses a restorable fleet.
21
+ * Every query runs through kysely against the schema the migration ladder
22
+ * maintains, executed by a driver over the one bun:sqlite connection this
23
+ * store owns. That makes every method here asynchronous; the driver's
24
+ * connection mutex is what keeps every write ordered regardless.
25
+ */
26
+ export class StateStore {
27
+ private readonly sqlite: Database;
28
+
29
+ private readonly db: Kysely<StateStoreSchema>;
30
+
31
+ private constructor(sqlite: Database, db: Kysely<StateStoreSchema>) {
32
+ this.sqlite = sqlite;
33
+ this.db = db;
34
+ }
35
+
36
+ // Migrations run statements that cannot happen inside a constructor, so
37
+ // opening a store is this factory instead of `new`.
38
+ static async open(dbPath: string, legacyFleetPath?: string): Promise<StateStore> {
39
+ const sqlite = new Database(dbPath, { create: true });
40
+
41
+ sqlite.run('PRAGMA journal_mode = WAL;');
42
+
43
+ const db = new Kysely<StateStoreSchema>({
44
+ dialect: {
45
+ createAdapter: () => new SqliteAdapter(),
46
+ createDriver: () => new BunSqliteDriver(sqlite),
47
+ createIntrospector: (kysely) => new SqliteIntrospector(kysely),
48
+ createQueryCompiler: () => new SqliteQueryCompiler(),
49
+ },
50
+ });
51
+
52
+ await runMigrations(db);
53
+
54
+ const store = new StateStore(sqlite, db);
55
+
56
+ if (legacyFleetPath !== undefined) {
57
+ await store.adoptLegacyFleet(legacyFleetPath);
58
+ }
59
+
60
+ return store;
61
+ }
62
+
63
+ async loadFleet(): Promise<FleetEntry[]> {
64
+ const rows = await this.db
65
+ .selectFrom('fleet')
66
+ .select(['agent_session_id', 'name', 'cwd', 'pinned', 'last_attached', 'agent', 'exited'])
67
+ .execute();
68
+
69
+ const entries: FleetEntry[] = [];
70
+
71
+ for (const row of rows) {
72
+ entries.push({
73
+ agentSessionID: toAgentSessionID(row.agent_session_id),
74
+ name: row.name,
75
+ cwd: row.cwd,
76
+ agent: toAgentID(row.agent),
77
+ ...(row.pinned === 0 ? {} : { pinned: true }),
78
+ ...(row.last_attached === null ? {} : { lastAttachedAt: row.last_attached }),
79
+ ...(row.exited === 0 ? {} : { exited: true }),
80
+ });
81
+ }
82
+
83
+ return entries;
84
+ }
85
+
86
+ // The latest hook-event timestamp per agent session id, from the event
87
+ // trail. Sessions that never reported an event are absent.
88
+ async collectFleetRecency(): Promise<Map<AgentSessionID, string>> {
89
+ const rows = await this.db
90
+ .selectFrom('events')
91
+ .select(['session_id', sql<string>`MAX(ts)`.as('ts')])
92
+ .where('session_id', 'is not', null)
93
+ .groupBy('session_id')
94
+ .execute();
95
+
96
+ const recency = new Map<AgentSessionID, string>();
97
+
98
+ for (const row of rows) {
99
+ if (row.session_id === null) {
100
+ continue;
101
+ }
102
+
103
+ recency.set(toAgentSessionID(row.session_id), row.ts);
104
+ }
105
+
106
+ return recency;
107
+ }
108
+
109
+ async writeFleet(entries: readonly FleetEntry[]): Promise<void> {
110
+ await this.db.transaction().execute(async (trx) => {
111
+ await trx.deleteFrom('fleet').execute();
112
+
113
+ for (const entry of entries) {
114
+ await trx
115
+ .insertInto('fleet')
116
+ .values({
117
+ agent_session_id: entry.agentSessionID,
118
+ name: entry.name,
119
+ cwd: entry.cwd,
120
+ pinned: entry.pinned === true ? 1 : 0,
121
+ last_attached: entry.lastAttachedAt ?? null,
122
+ agent: entry.agent,
123
+ exited: entry.exited === true ? 1 : 0,
124
+ })
125
+ .orReplace()
126
+ .execute();
127
+ }
128
+ });
129
+ }
130
+
131
+ async recordEvent(e: HookEvent): Promise<void> {
132
+ const rawMessage = e.payload['message'];
133
+ const rawSessionID = e.payload['session_id'] ?? e.payload['sessionId'];
134
+ const message = typeof rawMessage === 'string' ? rawMessage : null;
135
+ const sessionID = typeof rawSessionID === 'string' ? rawSessionID : null;
136
+ const atcID: string = e.atcId;
137
+
138
+ await this.db
139
+ .insertInto('events')
140
+ .values({
141
+ ts: new Date().toISOString(),
142
+ atc_id: atcID,
143
+ event: e.event,
144
+ message,
145
+ session_id: sessionID,
146
+ })
147
+ .execute();
148
+ }
149
+
150
+ async recordSpawnDir(cwd: string): Promise<void> {
151
+ await this.db
152
+ .insertInto('spawn_history')
153
+ .values({ cwd, last_spawn: Date.now() })
154
+ .onConflict((oc) =>
155
+ oc.column('cwd').doUpdateSet((eb) => ({ last_spawn: eb.ref('excluded.last_spawn') })),
156
+ )
157
+ .execute();
158
+ }
159
+
160
+ async collectSpawnDirs(): Promise<string[]> {
161
+ const rows = await this.db
162
+ .selectFrom('spawn_history')
163
+ .select('cwd')
164
+ .orderBy('last_spawn', 'desc')
165
+ .execute();
166
+
167
+ return rows.map((row) => row.cwd);
168
+ }
169
+
170
+ async loadLastUsedAgent(): Promise<AgentID> {
171
+ const row = await this.db
172
+ .selectFrom('prefs')
173
+ .select('value')
174
+ .where('key', '=', 'last_used_agent')
175
+ .executeTakeFirst();
176
+
177
+ return toAgentID(row?.value);
178
+ }
179
+
180
+ async writeLastUsedAgent(agent: AgentID): Promise<void> {
181
+ await this.db
182
+ .insertInto('prefs')
183
+ .values({ key: 'last_used_agent', value: agent })
184
+ .onConflict((oc) =>
185
+ oc.column('key').doUpdateSet((eb) => ({ value: eb.ref('excluded.value') })),
186
+ )
187
+ .execute();
188
+ }
189
+
190
+ async stop(): Promise<void> {
191
+ await this.db.destroy();
192
+
193
+ this.sqlite.close();
194
+ }
195
+
196
+ private async adoptLegacyFleet(legacyFleetPath: string): Promise<void> {
197
+ const count = await this.db
198
+ .selectFrom('fleet')
199
+ .select(this.db.fn.countAll<number>().as('n'))
200
+ .executeTakeFirst();
201
+
202
+ if (count === undefined || count.n > 0 || !existsSync(legacyFleetPath)) {
203
+ return;
204
+ }
205
+
206
+ try {
207
+ const parsed: unknown = JSON.parse(readFileSync(legacyFleetPath, 'utf8'));
208
+
209
+ if (!Array.isArray(parsed)) {
210
+ return;
211
+ }
212
+
213
+ const entries: FleetEntry[] = [];
214
+
215
+ for (const entry of parsed) {
216
+ const parsedEntry = parseFleetEntry(entry);
217
+
218
+ if (parsedEntry !== undefined) {
219
+ entries.push(parsedEntry);
220
+ }
221
+ }
222
+
223
+ await this.writeFleet(entries);
224
+ } catch {}
225
+ }
226
+ }
@@ -1,17 +0,0 @@
1
- import type { AgentKind } from './agent-adapter';
2
-
3
- /**
4
- * The reserved overlay column after unread: a `g` for Grok, a space for
5
- * Claude, so session names stay aligned across a mixed fleet.
6
- */
7
- export function formatOverlayAgentMark(agent: AgentKind): string {
8
- if (agent === 'grok') {
9
- return 'g';
10
- }
11
-
12
- if (agent === 'codex') {
13
- return 'x';
14
- }
15
-
16
- return ' ';
17
- }