kitty-hive 0.6.11 → 0.7.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/dist/index.js CHANGED
@@ -10,8 +10,24 @@ import { join, dirname, basename, delimiter } from 'node:path';
10
10
  import { fileURLToPath } from 'node:url';
11
11
  import { homedir, hostname } from 'node:os';
12
12
  import { execSync } from 'node:child_process';
13
+ import { buildPushMessage } from './preview.js';
13
14
  const __filename = fileURLToPath(import.meta.url);
14
15
  const __dirname = dirname(__filename);
16
+ // Build a properly-formatted push payload from operator-side CLI commands.
17
+ // Operator has no agent identity, so `from` is empty (buildPushMessage drops
18
+ // the "from X" suffix when empty). The push goes through the same code path
19
+ // as MCP-side notifications, so receivers see consistent `[hive] xxx — call
20
+ // hive-... for details.` hints regardless of whether the change came from
21
+ // CLI or an agent calling an MCP tool.
22
+ function operatorPush(opts) {
23
+ return buildPushMessage({
24
+ type: opts.type,
25
+ from: '',
26
+ from_agent_id: '',
27
+ event_id: opts.eventId,
28
+ team_id: opts.teamId,
29
+ });
30
+ }
15
31
  const args = process.argv.slice(2);
16
32
  const command = args[0];
17
33
  function parseFlags(startIdx) {
@@ -70,6 +86,115 @@ async function cmdCodexChannel() {
70
86
  // Block until child exits (handled above)
71
87
  await new Promise(() => { });
72
88
  }
89
+ // codex-pane <subcommand>: launcher-facing utilities for path B (visible codex
90
+ // via `codex --remote`). Currently has one subcommand:
91
+ // ws --key <K> | --id <I> Print the daemon's ws_url + thread_id as JSON,
92
+ // so a launcher (kitty-kitty etc.) can spawn
93
+ // `codex --remote <ws_url>` inside a pane. Polls
94
+ // with exponential backoff until status='ready'
95
+ // or hard timeout.
96
+ async function cmdCodexPane() {
97
+ const sub = args[1];
98
+ if (sub !== 'ws') {
99
+ console.error('Usage: kitty-hive codex-pane ws --key <K> | --id <I> [--port 4123] [--timeout-ms 5000]');
100
+ process.exit(2);
101
+ }
102
+ const { port, dbPath } = parseFlags(1);
103
+ let agentKey = '';
104
+ let agentId = '';
105
+ let timeoutMs = 5000;
106
+ for (let i = 2; i < args.length; i++) {
107
+ if (args[i] === '--key' && args[i + 1]) {
108
+ agentKey = args[i + 1];
109
+ i++;
110
+ }
111
+ else if (args[i] === '--id' && args[i + 1]) {
112
+ agentId = args[i + 1];
113
+ i++;
114
+ }
115
+ else if (args[i] === '--timeout-ms' && args[i + 1]) {
116
+ timeoutMs = parseInt(args[i + 1], 10) || 5000;
117
+ i++;
118
+ }
119
+ else if (args[i] === '--port' || args[i] === '-p') {
120
+ i++;
121
+ }
122
+ else if (args[i] === '--db') {
123
+ i++;
124
+ }
125
+ }
126
+ if (!agentKey && !agentId) {
127
+ console.error('Usage: kitty-hive codex-pane ws --key <K> | --id <I>');
128
+ process.exit(2);
129
+ }
130
+ // Backoff: 200ms / 400 / 800 / 1600 / 3200 / cap at remaining time
131
+ const start = Date.now();
132
+ const delays = [200, 400, 800, 1600, 3200];
133
+ let attempt = 0;
134
+ let lastBody = null;
135
+ while (true) {
136
+ try {
137
+ const url = new URL(`http://127.0.0.1:${port}/admin/codex-daemons`);
138
+ const res = await fetch(url);
139
+ if (res.ok) {
140
+ const { daemons } = await res.json();
141
+ const match = daemons.find(d => (agentId && d.agent_id === agentId) ||
142
+ // No external_key in admin snapshot, so we have to look up by id.
143
+ // For agent_key path, resolve via DB locally.
144
+ false);
145
+ let chosen = match;
146
+ if (!chosen && agentKey) {
147
+ // Resolve key → id via DB (use the same dbPath as serve uses)
148
+ const db = initDB(dbPath);
149
+ const row = db.prepare('SELECT id FROM agents WHERE external_key = ?').get(agentKey);
150
+ if (row)
151
+ chosen = daemons.find(d => d.agent_id === row.id);
152
+ }
153
+ if (chosen) {
154
+ if (chosen.ready && chosen.ws_url && chosen.thread_id) {
155
+ console.log(JSON.stringify({
156
+ status: 'ready',
157
+ agent_id: chosen.agent_id,
158
+ display_name: chosen.display_name,
159
+ ws_url: chosen.ws_url,
160
+ thread_id: chosen.thread_id,
161
+ pid: chosen.pid,
162
+ }));
163
+ process.exit(0);
164
+ }
165
+ // daemon row exists but its codex app-server hasn't reported ready
166
+ // back yet — wrap in the contract shape so callers can switch on
167
+ // `status` consistently (no raw daemon snapshot leaking out).
168
+ lastBody = {
169
+ status: 'starting',
170
+ agent_id: chosen.agent_id,
171
+ display_name: chosen.display_name,
172
+ pid: chosen.pid,
173
+ uptime_ms: chosen.uptime_ms,
174
+ restart_count: chosen.restart_count,
175
+ };
176
+ }
177
+ else {
178
+ lastBody = { status: 'not_supervised', error: 'no daemon for that agent_key/id (agent not registered with tool=codex, or supervisor not running)' };
179
+ }
180
+ }
181
+ }
182
+ catch (err) {
183
+ lastBody = { status: 'error', error: String(err?.message || err) };
184
+ }
185
+ if (Date.now() - start >= timeoutMs)
186
+ break;
187
+ const delay = delays[Math.min(attempt, delays.length - 1)];
188
+ attempt++;
189
+ await new Promise(r => setTimeout(r, Math.min(delay, Math.max(50, timeoutMs - (Date.now() - start)))));
190
+ }
191
+ // Timeout — emit what we last saw, exit non-zero
192
+ console.log(JSON.stringify(lastBody || {
193
+ status: 'timeout',
194
+ error: `daemon did not reach ready within ${timeoutMs}ms`,
195
+ }));
196
+ process.exit(1);
197
+ }
73
198
  const INIT_TOOLS = ['claude', 'cursor', 'vscode'];
74
199
  // codex is config'd via its own CLI (`codex mcp add ... --url ...`) writing
75
200
  // to ~/.codex/config.toml — we shell out instead of editing toml ourselves.
@@ -381,6 +506,36 @@ async function cmdStatus() {
381
506
  console.log(renderTable(['ID', 'NAME', 'PEER', 'STATUS', 'LAST SEEN'], rrows, ' '));
382
507
  }
383
508
  }
509
+ // Codex daemon supervisor snapshot — fetched live via admin endpoint
510
+ // so we get the actual running state, not just the DB intent.
511
+ try {
512
+ const daemonsRes = await fetch(`http://localhost:${port}/admin/codex-daemons`);
513
+ if (daemonsRes.ok) {
514
+ const { daemons } = await daemonsRes.json();
515
+ const codexAgents = agents.filter((a) => a.tool === 'codex');
516
+ if (codexAgents.length > 0 || daemons.length > 0) {
517
+ console.log(`\n🤖 Codex daemons`);
518
+ const liveByName = new Map(daemons.map(d => [d.display_name, d]));
519
+ const rows = codexAgents.map((a) => {
520
+ const live = liveByName.get(a.display_name);
521
+ if (live) {
522
+ const min = Math.floor(live.uptime_ms / 60000);
523
+ const uptimeStr = min < 60 ? `${min}m` : `${Math.floor(min / 60)}h${min % 60}m`;
524
+ return [a.display_name, `pid=${live.pid}`, `up ${uptimeStr}`, `restarts=${live.restart_count}`];
525
+ }
526
+ return [a.display_name, '(not running)', '', ''];
527
+ });
528
+ // Also surface zombie daemons (running but agent row deleted)
529
+ for (const d of daemons) {
530
+ if (!codexAgents.some((a) => a.display_name === d.display_name)) {
531
+ rows.push([`${d.display_name} ⚠️ orphan`, `pid=${d.pid}`, '', `restarts=${d.restart_count}`]);
532
+ }
533
+ }
534
+ console.log(renderTable(['NAME', 'PID', 'UPTIME', 'RESTARTS'], rows, ' '));
535
+ }
536
+ }
537
+ }
538
+ catch { /* serve might not expose admin yet — silently skip */ }
384
539
  }
385
540
  catch {
386
541
  console.log(`\n⚠️ Cannot read database`);
@@ -409,7 +564,7 @@ async function cmdDbClear() {
409
564
  console.log('✅ Database cleared.');
410
565
  }
411
566
  async function cmdAgentRemove() {
412
- const { dbPath } = parseFlags(1);
567
+ const { dbPath, port } = parseFlags(1);
413
568
  // Flags: --key <K> look up by external_key (idempotent: missing = exit 0)
414
569
  // --yes skip confirmation (scripts)
415
570
  // --transfer-to <a> transfer hosted teams to this agent before deleting
@@ -590,12 +745,10 @@ async function cmdAgentRemove() {
590
745
  reason: 'agent-remove',
591
746
  });
592
747
  try {
593
- await sessionsMod.notifyTeamMembers(r.team.id, undefined, JSON.stringify({
748
+ await sessionsMod.notifyTeamMembers(r.team.id, undefined, operatorPush({
594
749
  type: 'team-host-transfer',
595
- team_id: r.team.id,
596
- team_name: r.team.name,
597
- from: name,
598
- to: transferAgent.display_name,
750
+ teamId: r.team.id,
751
+ eventId: `team:${r.team.id}:host-transfer:${Date.now()}`,
599
752
  }));
600
753
  }
601
754
  catch { /* push best-effort */ }
@@ -621,6 +774,27 @@ async function cmdAgentRemove() {
621
774
  db.prepare('DELETE FROM teams WHERE host_agent_id = ?').run(agent.id);
622
775
  db.prepare('DELETE FROM agents WHERE id = ?').run(agent.id);
623
776
  console.log(`✅ Removed agent "${name}".`);
777
+ // Tell the live supervisor (if any) to kill the daemon for this agent.
778
+ // Without this, a deleted tool=codex agent's daemon keeps running on its
779
+ // old ws_url, causing routing schisms when another agent later self-
780
+ // registers with the same name. Mirrors the agent-register notify path.
781
+ // Best-effort: failures silently ignored — agent row is already gone.
782
+ try {
783
+ const notifyRes = await fetch(`http://127.0.0.1:${port}/admin/notify-agent-removed`, {
784
+ method: 'POST',
785
+ headers: { 'Content-Type': 'application/json' },
786
+ body: JSON.stringify({ agent_id: agent.id }),
787
+ signal: AbortSignal.timeout(2000),
788
+ });
789
+ if (notifyRes.ok) {
790
+ const { killed } = await notifyRes.json();
791
+ if (killed)
792
+ console.error(` → supervisor killed codex daemon`);
793
+ }
794
+ }
795
+ catch {
796
+ // serve not running, or admin endpoint not reachable — fine, no daemon to kill anyway
797
+ }
624
798
  }
625
799
  async function cmdAgentRegister() {
626
800
  // Idempotent upsert by external_key. Designed for `spawn('kitty-hive',
@@ -633,6 +807,7 @@ async function cmdAgentRegister() {
633
807
  let roles = '';
634
808
  let tool = '';
635
809
  let agentIdOverride = '';
810
+ let projectDir = '';
636
811
  for (let i = 2; i < args.length; i++) {
637
812
  if (args[i] === '--key' && args[i + 1]) {
638
813
  externalKey = args[i + 1];
@@ -658,12 +833,18 @@ async function cmdAgentRegister() {
658
833
  agentIdOverride = args[i + 1];
659
834
  i++;
660
835
  }
836
+ else if (args[i] === '--project-dir' && args[i + 1]) {
837
+ projectDir = args[i + 1];
838
+ i++;
839
+ }
661
840
  }
662
841
  if (!externalKey && !agentIdOverride && !displayName) {
663
- console.error('Usage: kitty-hive agent register --key <K> --display-name <N> [--roles R] [--tool T]');
842
+ console.error('Usage: kitty-hive agent register --key <K> --display-name <N> [--roles R] [--tool T] [--project-dir P]');
664
843
  console.error(' All three of --key/--id/--display-name optional, but at least one required.');
844
+ console.error(' --project-dir: working directory hint for codex agents (passed as cwd when supervisor spawns codex app-server).');
665
845
  process.exit(1);
666
846
  }
847
+ const { port } = parseFlags(1);
667
848
  initDB(dbPath);
668
849
  const { handleStart } = await import('./tools/start.js');
669
850
  try {
@@ -673,11 +854,37 @@ async function cmdAgentRegister() {
673
854
  name: displayName || undefined,
674
855
  roles: roles || undefined,
675
856
  tool: tool || undefined,
857
+ projectDir: projectDir || undefined,
676
858
  });
677
859
  // Stdout: one line, just the agent_id (script-friendly)
678
860
  console.log(result.agent_id);
679
861
  // Stderr: human context
680
- console.error(`✅ ${result.display_name} (${result.agent_id})${externalKey ? ` key=${externalKey}` : ''}`);
862
+ console.error(`✅ ${result.display_name} (${result.agent_id})${externalKey ? ` key=${externalKey}` : ''}${projectDir ? ` cwd=${projectDir}` : ''}`);
863
+ // If serve is running and we registered a tool=codex agent, notify the
864
+ // supervisor so it can spawn a daemon for it on the fly (without this,
865
+ // dynamic spawn requires a serve restart because the in-process
866
+ // onAgentCreated hook lives in serve's process, not this CLI process).
867
+ // Best-effort: failures are silently ignored — agent row is already written.
868
+ if (tool === 'codex') {
869
+ try {
870
+ const notifyRes = await fetch(`http://127.0.0.1:${port}/admin/notify-agent-created`, {
871
+ method: 'POST',
872
+ headers: { 'Content-Type': 'application/json' },
873
+ body: JSON.stringify({ agent_id: result.agent_id }),
874
+ // Short timeout: register shouldn't block waiting for a dead serve
875
+ signal: AbortSignal.timeout(2000),
876
+ });
877
+ if (notifyRes.ok) {
878
+ const { spawned } = await notifyRes.json();
879
+ if (spawned)
880
+ console.error(` → supervisor spawning codex daemon`);
881
+ }
882
+ }
883
+ catch {
884
+ // serve not running, or admin endpoint not reachable — that's fine,
885
+ // daemon will spawn at the next serve boot.
886
+ }
887
+ }
681
888
  }
682
889
  catch (err) {
683
890
  console.error(`Failed: ${err.message ?? err}`);
@@ -893,12 +1100,10 @@ async function cmdTeamTransfer() {
893
1100
  });
894
1101
  try {
895
1102
  const sessionsMod = await import('./sessions.js');
896
- await sessionsMod.notifyTeamMembers(team.id, undefined, JSON.stringify({
1103
+ await sessionsMod.notifyTeamMembers(team.id, undefined, operatorPush({
897
1104
  type: 'team-host-transfer',
898
- team_id: team.id,
899
- team_name: team.name,
900
- from: currentHostName,
901
- to: newHost.display_name,
1105
+ teamId: team.id,
1106
+ eventId: `team:${team.id}:host-transfer:${Date.now()}`,
902
1107
  }));
903
1108
  }
904
1109
  catch { /* push best-effort */ }
@@ -1026,17 +1231,213 @@ async function cmdTeamKick() {
1026
1231
  });
1027
1232
  try {
1028
1233
  const sessionsMod = await import('./sessions.js');
1029
- await sessionsMod.notifyTeamMembers(team.id, undefined, JSON.stringify({
1234
+ await sessionsMod.notifyTeamMembers(team.id, undefined, operatorPush({
1030
1235
  type: 'team-kick',
1031
- team_id: team.id,
1032
- team_name: team.name,
1033
- agent_id: target.id,
1034
- display_name: target.display_name,
1236
+ teamId: team.id,
1237
+ eventId: `team:${team.id}:kick:${Date.now()}`,
1035
1238
  }));
1036
1239
  }
1037
1240
  catch { /* push best-effort */ }
1038
1241
  console.log(`✅ Removed "${target.display_name}" from "${team.name}".`);
1039
1242
  }
1243
+ // Manage a team's rules (free-form markdown shown to all members on hive_start
1244
+ // and hive_team_info). Operator-side — no MCP tool exposes mutation, so rules
1245
+ // can't be hijacked by a member. Subcommands: show / edit / set / clear.
1246
+ async function cmdTeamRules() {
1247
+ const { dbPath } = parseFlags(2);
1248
+ let teamArg = args[2] && !args[2].startsWith('-') ? args[2] : '';
1249
+ let action = args[3] && !args[3].startsWith('-') ? args[3] : '';
1250
+ const db = initDB(dbPath);
1251
+ const dbMod = await import('./db.js');
1252
+ // --- Resolve team (interactive picker when omitted in a TTY) ---
1253
+ if (!teamArg) {
1254
+ if (!isInteractive()) {
1255
+ console.log('Usage:');
1256
+ console.log(' kitty-hive team rules <team> show (alias: omit subcommand)');
1257
+ console.log(' kitty-hive team rules <team> show');
1258
+ console.log(' kitty-hive team rules <team> edit Open $EDITOR for interactive edit');
1259
+ console.log(' kitty-hive team rules <team> set --file <path> Replace from file');
1260
+ console.log(' kitty-hive team rules <team> set --text "..." Replace from string');
1261
+ console.log(' kitty-hive team rules <team> clear');
1262
+ process.exit(1);
1263
+ }
1264
+ const teams = dbMod.listTeams(false);
1265
+ if (teams.length === 0) {
1266
+ console.log('No teams.');
1267
+ process.exit(0);
1268
+ }
1269
+ teamArg = await askSelect({
1270
+ message: 'Which team?',
1271
+ options: teams.map(t => ({
1272
+ value: t.id,
1273
+ label: t.name,
1274
+ hint: t.rules ? `${t.rules.length} chars of rules` : '(no rules)',
1275
+ })),
1276
+ });
1277
+ }
1278
+ const team = dbMod.getTeamById(teamArg) || dbMod.getTeamByName(teamArg);
1279
+ if (!team) {
1280
+ console.error(`Team "${teamArg}" not found.`);
1281
+ process.exit(1);
1282
+ }
1283
+ // --- Resolve action (interactive picker when omitted in a TTY) ---
1284
+ if (!action) {
1285
+ if (!isInteractive()) {
1286
+ action = 'show'; // default in scripts: just print
1287
+ }
1288
+ else {
1289
+ action = await askSelect({
1290
+ message: `Rules for "${team.name}" — what do you want to do?`,
1291
+ options: [
1292
+ { value: 'show', label: 'Show current rules' },
1293
+ { value: 'edit', label: 'Edit in $EDITOR' },
1294
+ { value: 'set', label: 'Replace from file or text' },
1295
+ { value: 'clear', label: 'Clear rules' },
1296
+ ],
1297
+ initialValue: team.rules ? 'show' : 'edit',
1298
+ });
1299
+ }
1300
+ }
1301
+ if (action === 'show') {
1302
+ if (!team.rules) {
1303
+ console.log(`Team "${team.name}" has no rules set.`);
1304
+ console.log(`Set them with: kitty-hive team rules ${team.name} edit`);
1305
+ return;
1306
+ }
1307
+ console.log(`# Rules for team "${team.name}"\n`);
1308
+ console.log(team.rules);
1309
+ return;
1310
+ }
1311
+ if (action === 'clear') {
1312
+ let assumeYes = false;
1313
+ for (let i = 4; i < args.length; i++)
1314
+ if (args[i] === '--yes' || args[i] === '-y')
1315
+ assumeYes = true;
1316
+ if (!assumeYes && isInteractive()) {
1317
+ const ok = await askConfirm({ message: `Clear rules for team "${team.name}"?`, initialValue: false });
1318
+ if (!ok) {
1319
+ console.log('Cancelled.');
1320
+ process.exit(0);
1321
+ }
1322
+ }
1323
+ dbMod.setTeamRules(team.id, '');
1324
+ try {
1325
+ const sessionsMod = await import('./sessions.js');
1326
+ await sessionsMod.notifyTeamMembers(team.id, undefined, operatorPush({
1327
+ type: 'team-rules-update',
1328
+ teamId: team.id,
1329
+ eventId: `team:${team.id}:rules-update:${Date.now()}`,
1330
+ }));
1331
+ }
1332
+ catch { /* best-effort */ }
1333
+ console.log(`✅ Cleared rules for "${team.name}".`);
1334
+ return;
1335
+ }
1336
+ // Helper: persist new rules + push notification (used by set + edit branches)
1337
+ const applyRules = async (content, label) => {
1338
+ dbMod.setTeamRules(team.id, content);
1339
+ try {
1340
+ const sessionsMod = await import('./sessions.js');
1341
+ await sessionsMod.notifyTeamMembers(team.id, undefined, operatorPush({
1342
+ type: 'team-rules-update',
1343
+ teamId: team.id,
1344
+ eventId: `team:${team.id}:rules-update:${Date.now()}`,
1345
+ }));
1346
+ }
1347
+ catch { /* best-effort */ }
1348
+ console.log(`✅ ${label === 'set' ? 'Set' : 'Updated'} rules for "${team.name}" (${content.length} chars).`);
1349
+ };
1350
+ if (action === 'set') {
1351
+ let filePath = '';
1352
+ let text = null;
1353
+ for (let i = 4; i < args.length; i++) {
1354
+ if (args[i] === '--file' && args[i + 1]) {
1355
+ filePath = args[i + 1];
1356
+ i++;
1357
+ }
1358
+ else if (args[i] === '--text' && args[i + 1]) {
1359
+ text = args[i + 1];
1360
+ i++;
1361
+ }
1362
+ }
1363
+ if (filePath) {
1364
+ if (!existsSync(filePath)) {
1365
+ console.error(`File not found: ${filePath}`);
1366
+ process.exit(1);
1367
+ }
1368
+ await applyRules(readFileSync(filePath, 'utf8'), 'set');
1369
+ return;
1370
+ }
1371
+ if (text !== null) {
1372
+ await applyRules(text, 'set');
1373
+ return;
1374
+ }
1375
+ // No flags — interactive picker for source (editor / file / text)
1376
+ if (!isInteractive()) {
1377
+ console.error('Usage: kitty-hive team rules <team> set --file <path> | --text "..."');
1378
+ process.exit(1);
1379
+ }
1380
+ const source = await askSelect({
1381
+ message: 'Where should the new rules come from?',
1382
+ options: [
1383
+ { value: 'editor', label: 'Open $EDITOR (multi-line markdown)' },
1384
+ { value: 'file', label: 'Load from file' },
1385
+ { value: 'text', label: 'Type/paste a one-liner' },
1386
+ ],
1387
+ initialValue: 'editor',
1388
+ });
1389
+ if (source === 'editor') {
1390
+ action = 'edit'; // promote — edit handler below picks it up
1391
+ }
1392
+ else if (source === 'file') {
1393
+ const p = await askText({
1394
+ message: 'Path to file?',
1395
+ validate: (v) => ((v ?? '').trim().length === 0 ? 'Path required' : undefined),
1396
+ });
1397
+ if (!existsSync(p)) {
1398
+ console.error(`File not found: ${p}`);
1399
+ process.exit(1);
1400
+ }
1401
+ await applyRules(readFileSync(p, 'utf8'), 'set');
1402
+ return;
1403
+ }
1404
+ else {
1405
+ const oneliner = await askText({
1406
+ message: 'New rules (single line):',
1407
+ validate: (v) => ((v ?? '').trim().length === 0 ? 'Rules cannot be empty (use clear instead)' : undefined),
1408
+ });
1409
+ await applyRules(oneliner, 'set');
1410
+ return;
1411
+ }
1412
+ }
1413
+ if (action === 'edit') {
1414
+ if (!isInteractive()) {
1415
+ console.error('`team rules <team> edit` requires a TTY. Use --file or --text in non-interactive contexts.');
1416
+ process.exit(1);
1417
+ }
1418
+ const editor = process.env.EDITOR || process.env.VISUAL || (process.platform === 'win32' ? 'notepad' : 'vi');
1419
+ const tmpPath = join('/tmp', `kitty-hive-team-rules-${team.id.slice(-12)}-${Date.now()}.md`);
1420
+ writeFileSync(tmpPath, team.rules || `# Rules for team "${team.name}"\n\n(Write the team's rules here. Save and exit to apply; quit without saving to abort.)\n`);
1421
+ try {
1422
+ execSync(`${editor} "${tmpPath}"`, { stdio: 'inherit' });
1423
+ const newContent = readFileSync(tmpPath, 'utf8');
1424
+ if (newContent === team.rules) {
1425
+ console.log('No changes — rules unchanged.');
1426
+ return;
1427
+ }
1428
+ await applyRules(newContent, 'updated');
1429
+ }
1430
+ finally {
1431
+ try {
1432
+ unlinkSync(tmpPath);
1433
+ }
1434
+ catch { /* ignore */ }
1435
+ }
1436
+ return;
1437
+ }
1438
+ console.error(`Unknown action: "${action}". Use show / edit / set / clear.`);
1439
+ process.exit(1);
1440
+ }
1040
1441
  // --- Node config ---
1041
1442
  function getConfigPath() {
1042
1443
  return join(homedir(), '.kitty-hive', 'config.json');
@@ -1978,6 +2379,9 @@ Subcommands:
1978
2379
  --add-member auto-joins target if not yet a member.
1979
2380
  kick [<team>] [<agent>] [--yes] Remove an agent from a team (operator-side).
1980
2381
  Cannot kick the host — transfer first.
2382
+ rules <team> [show|edit|set|clear] Manage the team's rules / charter (free-form
2383
+ markdown; surfaced to all members on hive_start
2384
+ + hive_team_info). Default: show.
1981
2385
 
1982
2386
  Note: agents normally manage teams via the hive-team-* MCP tools.
1983
2387
  This group is for operator-level maintenance (e.g. before removing a host agent).`);
@@ -2067,6 +2471,9 @@ switch (command) {
2067
2471
  case 'codex-channel':
2068
2472
  run(cmdCodexChannel);
2069
2473
  break;
2474
+ case 'codex-pane':
2475
+ run(cmdCodexPane);
2476
+ break;
2070
2477
  case 'status':
2071
2478
  run(cmdStatus);
2072
2479
  break;
@@ -2100,6 +2507,9 @@ switch (command) {
2100
2507
  case 'kick':
2101
2508
  run(cmdTeamKick);
2102
2509
  break;
2510
+ case 'rules':
2511
+ run(cmdTeamRules);
2512
+ break;
2103
2513
  default:
2104
2514
  showTeamHelp();
2105
2515
  break;