cross-agent-teams-mcp 0.2.0

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 (73) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +296 -0
  3. package/README.zh-CN.md +306 -0
  4. package/dist/channel-cli.d.ts +18 -0
  5. package/dist/channel-cli.js +358 -0
  6. package/dist/channel-cli.js.map +1 -0
  7. package/dist/cli.d.ts +1 -0
  8. package/dist/cli.js +4585 -0
  9. package/dist/cli.js.map +1 -0
  10. package/package.json +62 -0
  11. package/src/channel/auto-daemon.ts +130 -0
  12. package/src/channel/daemon-client.ts +155 -0
  13. package/src/channel/proxy.ts +28 -0
  14. package/src/channel-cli.ts +122 -0
  15. package/src/cli.ts +136 -0
  16. package/src/daemon/auth.ts +17 -0
  17. package/src/daemon/channel-wake-fanout.ts +39 -0
  18. package/src/daemon/channel-wake-send.ts +38 -0
  19. package/src/daemon/cleanup.ts +38 -0
  20. package/src/daemon/errors.ts +18 -0
  21. package/src/daemon/pid.ts +33 -0
  22. package/src/daemon/port.ts +16 -0
  23. package/src/daemon/runtime-identity.ts +238 -0
  24. package/src/daemon/server.ts +64 -0
  25. package/src/daemon/shutdown.ts +12 -0
  26. package/src/daemon/sse-fanout.ts +96 -0
  27. package/src/daemon/tmux-cli.ts +61 -0
  28. package/src/daemon/tmux-pane-detect.ts +276 -0
  29. package/src/lib/client-kind.ts +1 -0
  30. package/src/lib/default-team.ts +18 -0
  31. package/src/lib/delivery-spec.ts +172 -0
  32. package/src/lib/schema-diff.ts +79 -0
  33. package/src/mcp/agent-public-row.ts +52 -0
  34. package/src/mcp/auto-bind-channel.ts +106 -0
  35. package/src/mcp/auto-bind-codex-pane.ts +170 -0
  36. package/src/mcp/auto-poke-fanout.ts +129 -0
  37. package/src/mcp/bind-channel.ts +39 -0
  38. package/src/mcp/bind-runtime-identity.ts +43 -0
  39. package/src/mcp/broadcast-to-role.ts +127 -0
  40. package/src/mcp/broadcast.ts +115 -0
  41. package/src/mcp/codex-appserver-dispatch.ts +169 -0
  42. package/src/mcp/codex-appserver-rpc.ts +227 -0
  43. package/src/mcp/codex-pane-pre-register-repo.ts +57 -0
  44. package/src/mcp/delivery-status.ts +114 -0
  45. package/src/mcp/diff-contracts.ts +25 -0
  46. package/src/mcp/echo.ts +8 -0
  47. package/src/mcp/fanout-with-retry.ts +56 -0
  48. package/src/mcp/get-contract.ts +24 -0
  49. package/src/mcp/get-inbox.ts +57 -0
  50. package/src/mcp/identity.ts +8 -0
  51. package/src/mcp/pending-contract-events.ts +36 -0
  52. package/src/mcp/poke-guard.ts +32 -0
  53. package/src/mcp/poke-retry.ts +159 -0
  54. package/src/mcp/poke.ts +190 -0
  55. package/src/mcp/pre-register-codex-pane.ts +65 -0
  56. package/src/mcp/register-agent.ts +84 -0
  57. package/src/mcp/register-codex-self.ts +276 -0
  58. package/src/mcp/register-contract.ts +60 -0
  59. package/src/mcp/send-message.ts +159 -0
  60. package/src/mcp/subscribe-channel-wake.ts +31 -0
  61. package/src/mcp/subscribe-contract.ts +24 -0
  62. package/src/mcp/task-add.ts +37 -0
  63. package/src/mcp/task-claim.ts +54 -0
  64. package/src/mcp/task-complete.ts +36 -0
  65. package/src/mcp/task-list.ts +33 -0
  66. package/src/mcp/tools.ts +1240 -0
  67. package/src/mcp/transport-dispatch.ts +171 -0
  68. package/src/mcp/transport.ts +204 -0
  69. package/src/mcp/unregister-self.ts +46 -0
  70. package/src/storage/agents-repo.ts +328 -0
  71. package/src/storage/db.ts +13 -0
  72. package/src/storage/events-outbox.ts +44 -0
  73. package/src/storage/schema.ts +180 -0
@@ -0,0 +1,328 @@
1
+ import type Database from 'better-sqlite3'
2
+ import { randomUUID } from 'node:crypto'
3
+ import {
4
+ parseDeliveryRow,
5
+ serializeDelivery,
6
+ type DeliverySpec,
7
+ type DeliveryRow,
8
+ } from '../lib/delivery-spec.js'
9
+ import type { ClientKind } from '../lib/client-kind.js'
10
+
11
+ export interface RegisterInput {
12
+ client?: ClientKind
13
+ client_name?: string
14
+ model: string
15
+ name: string
16
+ role?: string
17
+ team?: string
18
+ tmux_pane_id?: string
19
+ delivery?: DeliverySpec
20
+ claude_ui_pid?: number
21
+ runtime_ui_pid?: number
22
+ }
23
+
24
+ export interface AgentRow {
25
+ agent_id: string
26
+ client: ClientKind | null
27
+ client_name: string | null
28
+ team: string
29
+ role: string
30
+ name: string
31
+ model: string | null
32
+ tmux_pane_id: string | null
33
+ delivery: DeliverySpec
34
+ channel_session_id: string | null
35
+ last_seen_at: string
36
+ }
37
+
38
+ export interface AgentListRow extends AgentRow {
39
+ online: boolean
40
+ }
41
+
42
+ export const ONLINE_MS = 5 * 60 * 1000
43
+
44
+ type DbAgentRow = {
45
+ agent_id: string
46
+ client: ClientKind | null
47
+ client_name: string | null
48
+ team: string
49
+ role: string
50
+ name: string
51
+ model: string | null
52
+ tmux_pane_id: string | null
53
+ last_seen_at: string
54
+ } & DeliveryRow
55
+
56
+ function toAgentRow(row: DbAgentRow): AgentRow {
57
+ const delivery = parseDeliveryRow(row)
58
+ return {
59
+ agent_id: row.agent_id,
60
+ client: row.client,
61
+ client_name: row.client_name,
62
+ team: row.team,
63
+ role: row.role,
64
+ name: row.name,
65
+ model: row.model,
66
+ tmux_pane_id: row.tmux_pane_id,
67
+ delivery,
68
+ channel_session_id:
69
+ delivery.kind === 'claude-channel' ? delivery.channel_session_id : null,
70
+ last_seen_at: row.last_seen_at,
71
+ }
72
+ }
73
+
74
+ export class AgentsRepo {
75
+ constructor(private db: Database.Database) {}
76
+
77
+ findByIdentity(args: { team: string; name: string }): { agent_id: string } | undefined {
78
+ return this.db.prepare(
79
+ `SELECT agent_id FROM agents WHERE team=? AND name=?`
80
+ ).get(args.team, args.name) as { agent_id: string } | undefined
81
+ }
82
+
83
+ register(input: RegisterInput): {
84
+ agent_id: string
85
+ team: string
86
+ } {
87
+ const team = input.team ?? 'default'
88
+ const role = input.role ?? 'default'
89
+ const name = input.name
90
+ const now = new Date().toISOString()
91
+ const newId = randomUUID()
92
+ const delivery = input.delivery ?? { kind: 'none' }
93
+ const serialized = serializeDelivery(delivery)
94
+ const preserveExistingDelivery = input.delivery === undefined ? 1 : 0
95
+ const tx = this.db.transaction(() => {
96
+ this.writeAgentRow({
97
+ newId,
98
+ input,
99
+ team,
100
+ role,
101
+ name,
102
+ now,
103
+ serialized,
104
+ preserveExistingDelivery,
105
+ })
106
+ const rebindCsid =
107
+ role === '__channel_proxy__' &&
108
+ input.claude_ui_pid !== undefined &&
109
+ delivery.kind === 'claude-channel'
110
+ ? delivery.channel_session_id
111
+ : undefined
112
+ if (rebindCsid !== undefined) {
113
+ this.reactiveRebindHosts({
114
+ team,
115
+ claude_ui_pid: input.claude_ui_pid!,
116
+ new_csid: rebindCsid,
117
+ })
118
+ }
119
+ })
120
+ tx()
121
+ const row = this.db.prepare(`SELECT agent_id FROM agents WHERE team=? AND name=?`).get(team, name) as { agent_id: string }
122
+ return { agent_id: row.agent_id, team }
123
+ }
124
+
125
+ private writeAgentRow(args: {
126
+ newId: string
127
+ input: RegisterInput
128
+ team: string
129
+ role: string
130
+ name: string
131
+ now: string
132
+ serialized: ReturnType<typeof serializeDelivery>
133
+ preserveExistingDelivery: number
134
+ }): void {
135
+ const { newId, input, team, role, name, now, serialized, preserveExistingDelivery } = args
136
+ this.db.prepare(
137
+ `INSERT INTO agents (
138
+ agent_id, client, client_name, team, role, name, model, registered_at, last_seen_at,
139
+ tmux_pane_id, claude_ui_pid, runtime_ui_pid, delivery_kind, delivery_payload
140
+ )
141
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
142
+ ON CONFLICT (team, name) DO UPDATE SET
143
+ client = excluded.client,
144
+ client_name = excluded.client_name,
145
+ role = excluded.role,
146
+ model = excluded.model,
147
+ last_seen_at = excluded.last_seen_at,
148
+ tmux_pane_id = COALESCE(excluded.tmux_pane_id, tmux_pane_id),
149
+ claude_ui_pid = COALESCE(excluded.claude_ui_pid, claude_ui_pid),
150
+ runtime_ui_pid = COALESCE(excluded.runtime_ui_pid, runtime_ui_pid),
151
+ delivery_kind = CASE
152
+ WHEN ? THEN delivery_kind
153
+ ELSE excluded.delivery_kind
154
+ END,
155
+ delivery_payload = CASE
156
+ WHEN ? THEN delivery_payload
157
+ ELSE excluded.delivery_payload
158
+ END`
159
+ ).run(
160
+ newId,
161
+ input.client ?? null,
162
+ input.client_name ?? null,
163
+ team,
164
+ role,
165
+ name,
166
+ input.model,
167
+ now,
168
+ now,
169
+ input.tmux_pane_id ?? null,
170
+ input.claude_ui_pid ?? null,
171
+ input.runtime_ui_pid ?? null,
172
+ serialized.delivery_kind,
173
+ serialized.delivery_payload,
174
+ preserveExistingDelivery,
175
+ preserveExistingDelivery,
176
+ )
177
+ }
178
+
179
+ private reactiveRebindHosts(args: {
180
+ team: string
181
+ claude_ui_pid: number
182
+ new_csid: string
183
+ }): void {
184
+ this.db.prepare(
185
+ `UPDATE agents
186
+ SET delivery_kind = 'claude-channel',
187
+ delivery_payload = json_object('channel_session_id', ?)
188
+ WHERE role != '__channel_proxy__'
189
+ AND runtime_ui_pid IS NOT NULL
190
+ AND runtime_ui_pid = ?
191
+ AND team = ?
192
+ AND (
193
+ delivery_kind = 'none'
194
+ OR (delivery_kind = 'claude-channel'
195
+ AND json_extract(delivery_payload,'$.channel_session_id') != ?)
196
+ )`
197
+ ).run(args.new_csid, args.claude_ui_pid, args.team, args.new_csid)
198
+ }
199
+
200
+ setDelivery(agent_id: string, spec: DeliverySpec): void {
201
+ const serialized = serializeDelivery(spec)
202
+ this.db.prepare(
203
+ `UPDATE agents
204
+ SET delivery_kind=?, delivery_payload=?
205
+ WHERE agent_id=?`
206
+ ).run(serialized.delivery_kind, serialized.delivery_payload, agent_id)
207
+ }
208
+
209
+ setClient(agent_id: string, client: ClientKind, client_name?: string | null): void {
210
+ this.db.prepare(
211
+ `UPDATE agents
212
+ SET client=?,
213
+ client_name=?
214
+ WHERE agent_id=?`
215
+ ).run(client, client_name ?? null, agent_id)
216
+ }
217
+
218
+ setRuntimeBinding(
219
+ agent_id: string,
220
+ args: {
221
+ tmux_pane_id: string
222
+ runtime_ui_pid: number | null
223
+ runtime_tty: string
224
+ runtime_verification_mode: string
225
+ runtime_bound_at?: string
226
+ }
227
+ ): void {
228
+ this.db.prepare(
229
+ `UPDATE agents
230
+ SET tmux_pane_id=?,
231
+ runtime_ui_pid=?,
232
+ runtime_tty=?,
233
+ runtime_verification_mode=?,
234
+ runtime_bound_at=?
235
+ WHERE agent_id=?`
236
+ ).run(
237
+ args.tmux_pane_id,
238
+ args.runtime_ui_pid,
239
+ args.runtime_tty,
240
+ args.runtime_verification_mode,
241
+ args.runtime_bound_at ?? new Date().toISOString(),
242
+ agent_id
243
+ )
244
+ }
245
+
246
+ list(args: { team: string }): AgentListRow[] {
247
+ const rows = this.db.prepare(
248
+ `SELECT
249
+ agent_id,
250
+ client,
251
+ client_name,
252
+ team,
253
+ role,
254
+ name,
255
+ model,
256
+ tmux_pane_id,
257
+ delivery_kind,
258
+ delivery_payload,
259
+ last_seen_at
260
+ FROM agents
261
+ WHERE team=?
262
+ ORDER BY registered_at ASC`
263
+ ).all(args.team) as DbAgentRow[]
264
+ const nowMs = Date.now()
265
+ return rows.map((row) => {
266
+ const agent = toAgentRow(row)
267
+ return {
268
+ ...agent,
269
+ online: nowMs - new Date(agent.last_seen_at).getTime() < ONLINE_MS,
270
+ }
271
+ })
272
+ }
273
+
274
+ touch(agent_id: string): void {
275
+ this.db.prepare(`UPDATE agents SET last_seen_at=? WHERE agent_id=?`).run(new Date().toISOString(), agent_id)
276
+ }
277
+
278
+ listClaimedInProgressTaskIds(args: { agent_id: string; team: string }): string[] {
279
+ const rows = this.db.prepare(
280
+ `SELECT id
281
+ FROM tasks
282
+ WHERE team=? AND claimed_by=? AND status='in_progress'
283
+ ORDER BY id ASC`
284
+ ).all(args.team, args.agent_id) as Array<{ id: string }>
285
+ return rows.map((row) => row.id)
286
+ }
287
+
288
+ deleteContractSubscriptions(args: { agent_id: string; team: string }): number {
289
+ const result = this.db.prepare(
290
+ `DELETE FROM contract_subscriptions
291
+ WHERE agent_id=? AND team=?`
292
+ ).run(args.agent_id, args.team)
293
+ return result.changes
294
+ }
295
+
296
+ deleteById(agent_id: string): boolean {
297
+ const result = this.db.prepare(
298
+ `DELETE FROM agents
299
+ WHERE agent_id=?`
300
+ ).run(agent_id)
301
+ return result.changes === 1
302
+ }
303
+
304
+ getById(agent_id: string): AgentRow | undefined {
305
+ const row = this.db.prepare(
306
+ `SELECT
307
+ agent_id,
308
+ client,
309
+ client_name,
310
+ team,
311
+ role,
312
+ name,
313
+ model,
314
+ tmux_pane_id,
315
+ delivery_kind,
316
+ delivery_payload,
317
+ last_seen_at
318
+ FROM agents
319
+ WHERE agent_id=?`
320
+ ).get(agent_id) as DbAgentRow | undefined
321
+ if (!row) return undefined
322
+ return toAgentRow(row)
323
+ }
324
+
325
+ findById(agent_id: string): AgentRow | undefined {
326
+ return this.getById(agent_id)
327
+ }
328
+ }
@@ -0,0 +1,13 @@
1
+ import Database from 'better-sqlite3'
2
+ import { mkdirSync } from 'node:fs'
3
+ import { dirname } from 'node:path'
4
+
5
+ export function openDb(path: string): Database.Database {
6
+ mkdirSync(dirname(path), { recursive: true })
7
+ const db = new Database(path)
8
+ db.pragma('journal_mode = WAL')
9
+ db.pragma('busy_timeout = 5000')
10
+ db.pragma('synchronous = NORMAL')
11
+ db.pragma('foreign_keys = ON')
12
+ return db
13
+ }
@@ -0,0 +1,44 @@
1
+ import type Database from 'better-sqlite3'
2
+
3
+ export interface EventRow {
4
+ event_id: number
5
+ from_team: string
6
+ to_team: string
7
+ event_type: string
8
+ actor_agent_id: string | null
9
+ payload: string
10
+ created_at: string
11
+ }
12
+
13
+ export class EventsOutbox {
14
+ constructor(private db: Database.Database) {}
15
+
16
+ append(args: {
17
+ from_team: string
18
+ to_team: string
19
+ event_type: string
20
+ actor_agent_id?: string | null
21
+ payload: unknown
22
+ }): number {
23
+ const stmt = this.db.prepare(
24
+ `INSERT INTO events (from_team, to_team, event_type, actor_agent_id, payload, created_at)
25
+ VALUES (?, ?, ?, ?, ?, ?)`
26
+ )
27
+ const info = stmt.run(
28
+ args.from_team,
29
+ args.to_team,
30
+ args.event_type,
31
+ args.actor_agent_id ?? null,
32
+ JSON.stringify(args.payload),
33
+ new Date().toISOString()
34
+ )
35
+ return Number(info.lastInsertRowid)
36
+ }
37
+
38
+ since(args: { team: string; since_event_id: number; limit?: number }): EventRow[] {
39
+ const limit = Math.min(args.limit ?? 100, 500)
40
+ return this.db.prepare(
41
+ `SELECT * FROM events WHERE to_team = ? AND event_id > ? ORDER BY event_id ASC LIMIT ?`
42
+ ).all(args.team, args.since_event_id, limit) as EventRow[]
43
+ }
44
+ }
@@ -0,0 +1,180 @@
1
+ import type Database from 'better-sqlite3'
2
+
3
+ const DDL = [
4
+ `CREATE TABLE IF NOT EXISTS events (
5
+ event_id INTEGER PRIMARY KEY AUTOINCREMENT,
6
+ from_team TEXT NOT NULL,
7
+ to_team TEXT NOT NULL,
8
+ event_type TEXT NOT NULL,
9
+ actor_agent_id TEXT,
10
+ payload TEXT NOT NULL,
11
+ created_at TEXT NOT NULL
12
+ )`,
13
+ `CREATE INDEX IF NOT EXISTS idx_events_from_team_eventid ON events(from_team, event_id)`,
14
+ `CREATE INDEX IF NOT EXISTS idx_events_to_team_eventid ON events(to_team, event_id)`,
15
+ `CREATE TABLE IF NOT EXISTS agents (
16
+ agent_id TEXT PRIMARY KEY,
17
+ client TEXT,
18
+ client_name TEXT,
19
+ team TEXT NOT NULL,
20
+ role TEXT NOT NULL,
21
+ name TEXT NOT NULL,
22
+ model TEXT,
23
+ registered_at TEXT NOT NULL,
24
+ last_seen_at TEXT NOT NULL,
25
+ last_processed_event_id INTEGER NOT NULL DEFAULT 0,
26
+ tmux_pane_id TEXT,
27
+ claude_ui_pid INTEGER,
28
+ runtime_ui_pid INTEGER,
29
+ runtime_tty TEXT,
30
+ runtime_verification_mode TEXT,
31
+ runtime_bound_at TEXT,
32
+ channel_session_id TEXT,
33
+ delivery_kind TEXT NOT NULL DEFAULT 'none',
34
+ delivery_payload TEXT
35
+ )`,
36
+ `CREATE UNIQUE INDEX IF NOT EXISTS agents_identity_idx ON agents(team, name)`,
37
+ `CREATE TABLE IF NOT EXISTS messages (
38
+ id TEXT PRIMARY KEY,
39
+ event_id INTEGER NOT NULL REFERENCES events(event_id),
40
+ from_team TEXT NOT NULL,
41
+ to_team TEXT NOT NULL,
42
+ from_agent_id TEXT NOT NULL,
43
+ to_agent_id TEXT,
44
+ to_role TEXT,
45
+ subject TEXT,
46
+ body TEXT NOT NULL,
47
+ need_reply INTEGER NOT NULL DEFAULT 1,
48
+ sent_at TEXT NOT NULL
49
+ )`,
50
+ `CREATE TABLE IF NOT EXISTS message_delivery_status (
51
+ message_id TEXT NOT NULL,
52
+ agent_id TEXT NOT NULL,
53
+ wake_status TEXT NOT NULL CHECK(wake_status IN ('delivered','retrying','skipped','failed')),
54
+ skip_reason TEXT,
55
+ retry_attempts INTEGER NOT NULL DEFAULT 0,
56
+ updated_at TEXT NOT NULL,
57
+ delivered_at TEXT,
58
+ PRIMARY KEY (message_id, agent_id)
59
+ )`,
60
+ `CREATE INDEX IF NOT EXISTS idx_message_delivery_status_message ON message_delivery_status(message_id)`,
61
+ `CREATE TABLE IF NOT EXISTS tasks (
62
+ id TEXT PRIMARY KEY,
63
+ team TEXT NOT NULL,
64
+ title TEXT NOT NULL,
65
+ description TEXT,
66
+ status TEXT NOT NULL CHECK(status IN ('pending','in_progress','completed')),
67
+ depends_on TEXT NOT NULL,
68
+ claimed_by TEXT,
69
+ claimed_at TEXT,
70
+ completed_at TEXT,
71
+ result TEXT,
72
+ created_at TEXT NOT NULL
73
+ )`,
74
+ `CREATE TABLE IF NOT EXISTS contracts (
75
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
76
+ team TEXT NOT NULL,
77
+ name TEXT NOT NULL,
78
+ version INTEGER NOT NULL,
79
+ format TEXT NOT NULL CHECK(format='jsonschema'),
80
+ schema TEXT NOT NULL,
81
+ note TEXT,
82
+ registered_by TEXT NOT NULL,
83
+ registered_at TEXT NOT NULL,
84
+ UNIQUE(team, name, version)
85
+ )`,
86
+ `CREATE TABLE IF NOT EXISTS contract_subscriptions (
87
+ agent_id TEXT NOT NULL,
88
+ team TEXT NOT NULL,
89
+ contract_name TEXT NOT NULL,
90
+ subscribed_at TEXT NOT NULL,
91
+ PRIMARY KEY (agent_id, team, contract_name)
92
+ )`,
93
+ `CREATE TABLE IF NOT EXISTS codex_pane_pre_registrations (
94
+ pane_id TEXT PRIMARY KEY,
95
+ xats_agent_id TEXT NOT NULL,
96
+ expires_at TEXT NOT NULL
97
+ )`
98
+ ]
99
+
100
+ function migrateAgentsDeliveryColumns(db: Database.Database): void {
101
+ const tableExists = db
102
+ .prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='agents'`)
103
+ .get() as { name: string } | undefined
104
+ if (!tableExists) return
105
+ const cols = db.pragma('table_info(agents)') as Array<{ name: string }>
106
+ const existing = new Set(cols.map(c => c.name))
107
+ const needClient = !existing.has('client')
108
+ const needClientName = !existing.has('client_name')
109
+ const needKind = !existing.has('delivery_kind')
110
+ const needPayload = !existing.has('delivery_payload')
111
+ const needRuntimeUiPid = !existing.has('runtime_ui_pid')
112
+ const needRuntimeTty = !existing.has('runtime_tty')
113
+ const needRuntimeVerificationMode = !existing.has('runtime_verification_mode')
114
+ const needRuntimeBoundAt = !existing.has('runtime_bound_at')
115
+ const needClaudeUiPid = !existing.has('claude_ui_pid')
116
+ if (
117
+ !needClient &&
118
+ !needClientName &&
119
+ !needKind &&
120
+ !needPayload &&
121
+ !needRuntimeUiPid &&
122
+ !needRuntimeTty &&
123
+ !needRuntimeVerificationMode &&
124
+ !needRuntimeBoundAt &&
125
+ !needClaudeUiPid
126
+ ) return
127
+ const tx = db.transaction(() => {
128
+ if (needClient) {
129
+ db.exec(`ALTER TABLE agents ADD COLUMN client TEXT`)
130
+ }
131
+ if (needClientName) {
132
+ db.exec(`ALTER TABLE agents ADD COLUMN client_name TEXT`)
133
+ }
134
+ if (needKind) {
135
+ db.exec(`ALTER TABLE agents ADD COLUMN delivery_kind TEXT NOT NULL DEFAULT 'none'`)
136
+ }
137
+ if (needPayload) {
138
+ db.exec(`ALTER TABLE agents ADD COLUMN delivery_payload TEXT`)
139
+ }
140
+ if (needRuntimeUiPid) {
141
+ db.exec(`ALTER TABLE agents ADD COLUMN runtime_ui_pid INTEGER`)
142
+ }
143
+ if (needRuntimeTty) {
144
+ db.exec(`ALTER TABLE agents ADD COLUMN runtime_tty TEXT`)
145
+ }
146
+ if (needRuntimeVerificationMode) {
147
+ db.exec(`ALTER TABLE agents ADD COLUMN runtime_verification_mode TEXT`)
148
+ }
149
+ if (needRuntimeBoundAt) {
150
+ db.exec(`ALTER TABLE agents ADD COLUMN runtime_bound_at TEXT`)
151
+ }
152
+ if (needClaudeUiPid) {
153
+ db.exec(`ALTER TABLE agents ADD COLUMN claude_ui_pid INTEGER`)
154
+ }
155
+ if (needKind || needPayload) {
156
+ db.exec(`UPDATE agents
157
+ SET delivery_kind = 'claude-channel',
158
+ delivery_payload = json_object('channel_session_id', channel_session_id)
159
+ WHERE channel_session_id IS NOT NULL AND delivery_kind = 'none'`)
160
+ }
161
+ })
162
+ tx()
163
+ }
164
+
165
+ function migrateMessagesNeedReplyColumn(db: Database.Database): void {
166
+ const tableExists = db
167
+ .prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='messages'`)
168
+ .get() as { name: string } | undefined
169
+ if (!tableExists) return
170
+ const cols = db.pragma('table_info(messages)') as Array<{ name: string }>
171
+ const existing = new Set(cols.map(c => c.name))
172
+ if (existing.has('need_reply')) return
173
+ db.exec(`ALTER TABLE messages ADD COLUMN need_reply INTEGER NOT NULL DEFAULT 1`)
174
+ }
175
+
176
+ export function applySchema(db: Database.Database): void {
177
+ for (const sql of DDL) db.exec(sql)
178
+ migrateAgentsDeliveryColumns(db)
179
+ migrateMessagesNeedReplyColumn(db)
180
+ }