codex-team 0.1.0__py3-none-any.whl

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.
codex_team/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ """Codex teams backed by tmux, Git submodules and durable task queues."""
2
+ from .cli import VERSION as __version__
3
+
4
+ __all__ = ['__version__']
codex_team/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import entrypoint
2
+
3
+ raise SystemExit(entrypoint())
codex_team/cli.py ADDED
@@ -0,0 +1,579 @@
1
+ #!/usr/bin/env python3
2
+ """Local Codex team: tmux display, Git submodules and durable task queues."""
3
+ import argparse
4
+ import contextlib
5
+ import fcntl
6
+ import hashlib
7
+ import json
8
+ import os
9
+ from pathlib import Path
10
+ import re
11
+ import select
12
+ import shlex
13
+ import shutil
14
+ import sqlite3
15
+ import subprocess
16
+ import sys
17
+ import time
18
+ import uuid
19
+
20
+ VERSION = '0.1.0'
21
+
22
+ SLUG = re.compile(r'^[a-zA-Z0-9][a-zA-Z0-9_-]{0,47}$')
23
+ TERMINAL = {'done', 'failed', 'interrupted'}
24
+
25
+
26
+ def run(args, **kwargs):
27
+ return subprocess.run([str(a) for a in args], check=True, text=True, **kwargs)
28
+
29
+
30
+ def capture(args):
31
+ return run(args, stdout=subprocess.PIPE).stdout.strip()
32
+
33
+
34
+ def emit(value):
35
+ print(json.dumps(value, ensure_ascii=False, indent=2), flush=True)
36
+
37
+
38
+ def now():
39
+ return time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())
40
+
41
+
42
+ class Team:
43
+ def __init__(self, root):
44
+ self.root = Path(root).expanduser().resolve()
45
+ self.state = self.root / '.codex-team'
46
+ self.config_path = self.state / 'settings.json'
47
+
48
+ def settings(self):
49
+ if not self.config_path.exists():
50
+ raise ValueError('Time não inicializado. Execute start primeiro.')
51
+ return json.loads(self.config_path.read_text())
52
+
53
+ @contextlib.contextmanager
54
+ def lock(self, name='topology', nonblocking=False):
55
+ with (self.state / (name + '.lock')).open('a') as handle:
56
+ fcntl.flock(handle, fcntl.LOCK_EX | (fcntl.LOCK_NB if nonblocking else 0))
57
+ yield
58
+
59
+ @contextlib.contextmanager
60
+ def db(self):
61
+ conn = sqlite3.connect(self.state / 'team.sqlite3', timeout=30)
62
+ conn.row_factory = sqlite3.Row
63
+ try:
64
+ with conn:
65
+ yield conn
66
+ finally:
67
+ conn.close()
68
+
69
+ def init(self, session=None, layout='panes'):
70
+ if self.config_path.exists():
71
+ return
72
+ session = session or 'codex-team-' + hashlib.sha256(str(self.root).encode()).hexdigest()[:10]
73
+ if not SLUG.fullmatch(session):
74
+ raise ValueError('Nome da sessão inválido. Use letras, números, hífen e sublinhado (até 48 caracteres).')
75
+ if self.root.exists() and any(self.root.iterdir()):
76
+ raise ValueError('Use uma pasta nova ou vazia para a raiz do time; nenhum repositório existente será convertido.')
77
+ self.root.mkdir(parents=True, exist_ok=True)
78
+ self.state.mkdir(mode=0o700)
79
+ for directory in ['bin', 'logs', 'results', 'wake']:
80
+ (self.state / directory).mkdir()
81
+ run(['git', 'init', self.root], stdout=subprocess.DEVNULL)
82
+ self.config_path.write_text(json.dumps({'session': session, 'layout': layout, 'stopping': False}))
83
+ (self.root / '.gitignore').write_text('.codex-team/\n')
84
+ (self.root / 'repos').mkdir()
85
+ destination = self.state / 'bin/team.py'
86
+ shutil.copy2(Path(__file__).resolve(), destination)
87
+ wrapper = '#!/usr/bin/env bash\nset -euo pipefail\nROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)\nexec python3 "$ROOT/.codex-team/bin/team.py" --root "$ROOT" "$@"\n'
88
+ (self.root / 'team').write_text(wrapper)
89
+ (self.root / 'team').chmod(0o755)
90
+ (self.root / 'AGENTS.md').write_text(CONTROLLER_RULES)
91
+ (self.root / 'README.md').write_text('# Equipe Codex\n\nLeia AGENTS.md. Use `./team --help` para gerenciar o time.\n'
92
+ 'Os projetos em repos/ são submódulos; não há commits ou pushes automáticos.\n'
93
+ 'O runtime local fica em .codex-team/ (ignorado pelo Git).\n')
94
+ with self.db() as db:
95
+ db.executescript('''
96
+ CREATE TABLE agents(name TEXT PRIMARY KEY, repo TEXT NOT NULL, branch TEXT,
97
+ thread TEXT, pane TEXT, status TEXT NOT NULL DEFAULT 'stopped');
98
+ CREATE TABLE tasks(id TEXT PRIMARY KEY, agent TEXT NOT NULL, message TEXT NOT NULL,
99
+ status TEXT NOT NULL, created TEXT NOT NULL, started TEXT, finished TEXT, error TEXT);
100
+ ''')
101
+
102
+ def save_settings(self, data):
103
+ temp = self.state / 'settings.tmp'
104
+ temp.write_text(json.dumps(data, indent=2))
105
+ temp.replace(self.config_path)
106
+
107
+ def agent(self, name):
108
+ with self.db() as db:
109
+ row = db.execute('SELECT * FROM agents WHERE name=?', (name,)).fetchone()
110
+ if row is None:
111
+ raise ValueError(f'Agente desconhecido: {name}')
112
+ return dict(row)
113
+
114
+ def session_alive(self):
115
+ return subprocess.run(['tmux', 'has-session', '-t', self.settings()['session']],
116
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode == 0
117
+
118
+ def pane_alive(self, pane):
119
+ if not pane or not re.fullmatch(r'%\d+', pane):
120
+ return False
121
+ result = subprocess.run(['tmux', 'display-message', '-p', '-t', pane,
122
+ '#{pane_dead}'], capture_output=True, text=True)
123
+ return result.returncode == 0 and result.stdout.strip() == '0'
124
+
125
+ def create_controller(self):
126
+ config = self.settings()
127
+ alive = self.session_alive()
128
+ if alive:
129
+ marker = subprocess.run(['tmux', 'show-options', '-v', '-t', config['session'],
130
+ '@codex-team-root'], capture_output=True, text=True)
131
+ if marker.stdout.strip() != str(self.root):
132
+ raise ValueError('O nome da sessão tmux já está em uso por outro time ou processo.')
133
+ if alive and self.pane_alive(config.get('controller_pane')):
134
+ return
135
+ command = shlex.join([sys.executable, str(self.state / 'bin/team.py'),
136
+ '--root', str(self.root), '_controller'])
137
+ if alive:
138
+ pane = capture(['tmux', 'new-window', '-d', '-P', '-F', '#{pane_id}',
139
+ '-t', config['session'] + ':', '-n', 'controlador', '-c', self.root, command])
140
+ else:
141
+ pane = capture(['tmux', 'new-session', '-d', '-P', '-F', '#{pane_id}',
142
+ '-s', config['session'], '-n', 'equipe', '-c', self.root, command])
143
+ config['controller_pane'] = pane
144
+ self.save_settings(config)
145
+ run(['tmux', 'set-option', '-t', config['session'], '@codex-team-root', str(self.root)])
146
+ run(['tmux', 'set-option', '-t', config['session'], 'mouse', 'on'])
147
+ run(['tmux', 'set-option', '-t', config['session'], 'pane-border-status', 'top'])
148
+ run(['tmux', 'set-option', '-t', config['session'], 'pane-border-format', ' #{pane_title} '])
149
+ run(['tmux', 'select-pane', '-t', pane, '-T', 'Controlador'])
150
+
151
+ def add(self, name, repo, branch=None):
152
+ validate_agent(name, repo, branch)
153
+ with self.lock():
154
+ if self.settings()['stopping']:
155
+ raise ValueError('O time está parando. Execute start para reabrir a entrada de tarefas.')
156
+ if not self.session_alive():
157
+ raise ValueError('Sessão tmux indisponível. Execute start antes de adicionar agentes.')
158
+ with self.db() as db:
159
+ old = db.execute('SELECT * FROM agents WHERE name=?', (name,)).fetchone()
160
+ if old:
161
+ if old['repo'] != repo or old['branch'] != branch:
162
+ raise ValueError('Este nome já está associado a outro repositório ou branch.')
163
+ else:
164
+ target = self.root / 'repos' / name
165
+ if target.exists():
166
+ raise ValueError(f'O destino já existe; revise antes de cadastrar: {target}')
167
+ args = ['git', '-C', str(self.root), 'submodule', 'add']
168
+ if branch:
169
+ args += ['--branch', branch]
170
+ args += ['--', repo, 'repos/' + name]
171
+ run(args)
172
+ with self.db() as db:
173
+ db.execute('INSERT INTO agents(name,repo,branch) VALUES(?,?,?)', (name, repo, branch))
174
+ self.launch_worker(name)
175
+ return self.agent(name)
176
+
177
+ def launch_worker(self, name):
178
+ row = self.agent(name)
179
+ if self.pane_alive(row['pane']):
180
+ # A pane id can be reused by a fresh server; verify its identity too.
181
+ result = subprocess.run(['tmux', 'show-options', '-p', '-v', '-t', row['pane'],
182
+ '@codex-team-agent'], capture_output=True, text=True)
183
+ marker = result.stdout.strip()
184
+ if marker == str(self.root) + ':' + name:
185
+ return
186
+ fifo = self.state / 'wake' / name
187
+ if not fifo.exists():
188
+ os.mkfifo(fifo, mode=0o600)
189
+ config = self.settings()
190
+ command = shlex.join([sys.executable, str(self.state / 'bin/team.py'), '--root', str(self.root), '_worker', name])
191
+ if config['layout'] == 'panes':
192
+ args = ['tmux', 'split-window', '-d', '-P', '-F', '#{pane_id}',
193
+ '-t', config['controller_pane'], '-c', self.root / 'repos' / name, command]
194
+ result = subprocess.run([str(a) for a in args], capture_output=True, text=True)
195
+ else:
196
+ result = None
197
+ if result is None or result.returncode:
198
+ pane = capture(['tmux', 'new-window', '-d', '-P', '-F', '#{pane_id}',
199
+ '-t', config['session'] + ':', '-n', name, '-c', self.root / 'repos' / name, command])
200
+ else:
201
+ pane = result.stdout.strip()
202
+ run(['tmux', 'select-layout', '-t', config['controller_pane'], 'tiled'], stdout=subprocess.DEVNULL)
203
+ run(['tmux', 'select-pane', '-t', pane, '-T', name])
204
+ run(['tmux', 'set-option', '-p', '-t', pane, '@codex-team-agent', str(self.root) + ':' + name])
205
+ with self.db() as db:
206
+ db.execute('UPDATE agents SET pane=? WHERE name=?', (pane, name))
207
+
208
+ def wake(self, name):
209
+ try:
210
+ fd = os.open(self.state / 'wake' / name, os.O_WRONLY | os.O_NONBLOCK)
211
+ except OSError:
212
+ return
213
+ try:
214
+ os.write(fd, b'!')
215
+ finally:
216
+ os.close(fd)
217
+
218
+ def enqueue(self, name, message):
219
+ if not message.strip():
220
+ raise ValueError('A tarefa não pode ser vazia.')
221
+ with self.lock():
222
+ if self.settings()['stopping']:
223
+ raise ValueError('Time em encerramento; novas tarefas não são aceitas.')
224
+ row = self.agent(name)
225
+ if not self.session_alive() or not self.pane_alive(row['pane']):
226
+ raise ValueError('O agente não está aberto. Execute start para restaurar os painéis.')
227
+ task_id = uuid.uuid4().hex
228
+ with self.db() as db:
229
+ db.execute('INSERT INTO tasks(id,agent,message,status,created) VALUES(?,?,?,?,?)',
230
+ (task_id, name, message, 'queued', now()))
231
+ self.wake(name)
232
+ return task_id
233
+
234
+ def task(self, task_id):
235
+ with self.db() as db:
236
+ row = db.execute('SELECT * FROM tasks WHERE id=?', (task_id,)).fetchone()
237
+ if row is None:
238
+ raise ValueError('Tarefa desconhecida: ' + task_id)
239
+ result = dict(row)
240
+ output = self.state / 'results' / (task_id + '.md')
241
+ result['result_file'] = str(output)
242
+ if output.exists():
243
+ result['result'] = output.read_text()
244
+ return result
245
+
246
+ def snapshot(self):
247
+ with self.db() as db:
248
+ agents = [dict(row) for row in db.execute('SELECT * FROM agents ORDER BY name')]
249
+ tasks = [dict(row) for row in db.execute('SELECT id,agent,status,created,error FROM tasks ORDER BY created,rowid')]
250
+ for agent in agents:
251
+ agent['pane_alive'] = self.pane_alive(agent['pane'])
252
+ return {'root': str(self.root), 'session': self.settings()['session'],
253
+ 'stopping': self.settings()['stopping'], 'agents': agents, 'tasks': tasks}
254
+
255
+ def worker(self, name):
256
+ # Lock spans the complete worker lifetime: one executor per repository.
257
+ with self.lock('worker-' + name, nonblocking=True):
258
+ fd = os.open(self.state / 'wake' / name, os.O_RDWR | os.O_NONBLOCK)
259
+ try:
260
+ with self.db() as db:
261
+ db.execute("UPDATE tasks SET status='interrupted',finished=?,error=? WHERE agent=? AND status='running'",
262
+ (now(), 'Executor anterior terminou sem confirmar o resultado. Revise os arquivos antes de reenviar.', name))
263
+ db.execute("UPDATE agents SET status='idle' WHERE name=?", (name,))
264
+ print(f'Agente {name}: responsável por {self.root / "repos" / name}. Aguardando tarefas.', flush=True)
265
+ while True:
266
+ with self.lock():
267
+ if self.settings()['stopping']:
268
+ break
269
+ with self.db() as db:
270
+ db.execute('BEGIN IMMEDIATE')
271
+ task = db.execute("SELECT * FROM tasks WHERE agent=? AND status='queued' ORDER BY rowid LIMIT 1", (name,)).fetchone()
272
+ if task:
273
+ db.execute("UPDATE tasks SET status='running',started=? WHERE id=?", (now(), task['id']))
274
+ db.execute("UPDATE agents SET status='running' WHERE name=?", (name,))
275
+ if task:
276
+ self.execute(name, dict(task))
277
+ print(f'Agente {name}: aguardando tarefas.', flush=True)
278
+ else:
279
+ # FIFO blocks while idle; timeout only checks unexpected session shutdown.
280
+ ready, _, _ = select.select([fd], [], [], 30)
281
+ if ready:
282
+ os.read(fd, 4096)
283
+ finally:
284
+ os.close(fd)
285
+ with self.db() as db:
286
+ db.execute("UPDATE agents SET status='stopped' WHERE name=?", (name,))
287
+
288
+ def execute(self, name, task):
289
+ row = self.agent(name)
290
+ result_path = self.state / 'results' / (task['id'] + '.md')
291
+ log_path = self.state / 'logs' / (task['id'] + '.jsonl')
292
+ error = None
293
+ thread = row['thread']
294
+ command = ['codex', '--sandbox', 'workspace-write', '--ask-for-approval', 'never',
295
+ '-C', str(self.root / 'repos' / name), 'exec', '--json', '-o', str(result_path)]
296
+ if thread:
297
+ command += ['resume', thread]
298
+ command += ['-']
299
+ prompt = (f'Você é o responsável pelo repositório {name}, no diretório atual. '
300
+ 'Siga o AGENTS.md aplicável e as regras específicas do repositório. '
301
+ 'Trabalhe apenas neste submódulo. Não edite o Git raiz, outros repositórios, '
302
+ 'nem execute deploy, push ou operações destrutivas sem autorização explícita na tarefa. '
303
+ 'Execute a tarefa e termine relatando alterações, validações e impedimentos. '
304
+ 'Se uma ação depender de permissão indisponível, registre o bloqueio sem contornar o sandbox.\n\n'
305
+ f'Tarefa {task["id"]}:\n{task["message"]}')
306
+ print(f'\n▶ Tarefa {task["id"]}\n{task["message"]}\n', flush=True)
307
+ completed = False
308
+ try:
309
+ with log_path.open('w') as log:
310
+ process = subprocess.Popen(command, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
311
+ stderr=log, text=True, cwd=self.root / 'repos' / name)
312
+ process.stdin.write(prompt)
313
+ process.stdin.close()
314
+ for line in process.stdout:
315
+ log.write(line)
316
+ log.flush()
317
+ try:
318
+ event = json.loads(line)
319
+ except ValueError:
320
+ continue
321
+ if event.get('type') == 'thread.started':
322
+ with self.db() as db:
323
+ db.execute('UPDATE agents SET thread=? WHERE name=?', (event['thread_id'], name))
324
+ elif event.get('type') == 'turn.completed':
325
+ completed = True
326
+ elif event.get('type') in ('turn.failed', 'error'):
327
+ error = json.dumps(event, ensure_ascii=False)
328
+ elif event.get('type') in ('item.started', 'item.completed'):
329
+ item = event.get('item', {})
330
+ if item.get('type') == 'agent_message':
331
+ print(item.get('text', ''), flush=True)
332
+ elif item.get('type') == 'command_execution':
333
+ print('$ ' + item.get('command', ''), flush=True)
334
+ if item.get('aggregated_output'):
335
+ print(item['aggregated_output'], flush=True)
336
+ else:
337
+ print('• ' + item.get('type', 'evento'), flush=True)
338
+ code = process.wait()
339
+ if code or not completed or not result_path.exists():
340
+ error = error or f'Codex terminou com código {code}; conclusão ou resultado ausente. Consulte {log_path}.'
341
+ except (OSError, KeyError) as exc:
342
+ error = str(exc)
343
+ status = 'failed' if error else 'done'
344
+ with self.db() as db:
345
+ db.execute('UPDATE tasks SET status=?,finished=?,error=? WHERE id=?', (status, now(), error, task['id']))
346
+ db.execute("UPDATE agents SET status='idle' WHERE name=?", (name,))
347
+ print(f'\n■ {task["id"]}: {status}' + (f' — {error}' if error else ''), flush=True)
348
+
349
+ def stop(self, timeout):
350
+ with self.lock():
351
+ config = self.settings()
352
+ config['stopping'] = True
353
+ self.save_settings(config)
354
+ with self.db() as db:
355
+ names = [row['name'] for row in db.execute('SELECT name FROM agents')]
356
+ for name in names:
357
+ self.wake(name)
358
+ deadline = time.monotonic() + timeout
359
+ while True:
360
+ busy = []
361
+ for name in names:
362
+ try:
363
+ with self.lock('worker-' + name, nonblocking=True):
364
+ pass
365
+ except BlockingIOError:
366
+ busy.append(name)
367
+ if not busy:
368
+ break
369
+ if time.monotonic() >= deadline:
370
+ emit({'stopping': True, 'waiting_for': busy,
371
+ 'message': 'Nenhum processo foi forçado a parar. Aguarde e execute stop novamente.'})
372
+ return 2
373
+ time.sleep(.25)
374
+ # Workers that were killed externally may have left a running marker.
375
+ with self.db() as db:
376
+ db.execute("UPDATE tasks SET status='interrupted',finished=?,error=? WHERE status='running'",
377
+ (now(), 'Executor encerrado sem confirmação; revise antes de reenviar.'))
378
+ db.execute("UPDATE agents SET status='stopped'")
379
+ emit({'stopped': True, 'message': 'Responsáveis encerrados. Tarefas pendentes preservadas. '
380
+ 'O controlador e a sessão tmux continuam abertos; encerre-os quando o controlador estiver ocioso.'})
381
+ return 0
382
+
383
+
384
+ CONTROLLER_RULES = '''# Equipe Codex — instruções de coordenação
385
+
386
+ Na raiz você é o controlador. Os projetos em repos/ são submódulos Git e têm
387
+ responsáveis próprios. Dentro de cada submódulo, atue como responsável daquele
388
+ projeto, respeitando suas instruções locais e o escopo da tarefa recebida.
389
+
390
+ ## Comandos do controlador (execute a partir da raiz)
391
+
392
+ - `./team add --name NOME --repo URL [--branch BRANCH]`: cadastra um submódulo
393
+ e abre um responsável. Use nomes únicos com letras, números, hífen ou sublinhado.
394
+ - `./team task NOME --message 'TAREFA'`: devolve um identificador de tarefa.
395
+ Para texto longo use `--file caminho`; o conteúdo é lido literalmente.
396
+ - `./team status`: consulta agentes e tarefas em JSON.
397
+ - `./team wait ID [ID ...] --timeout 50`: aguarda resultados (retorna 2 se ainda
398
+ estiverem pendentes). Continue aguardando enquanto houver trabalho necessário.
399
+ - `./team result ID`: lê resultado, erro e estado. Não trate failed/interrupted
400
+ como sucesso, nem done como prova de testes aprovados: leia o relatório.
401
+ - `./team stop --timeout 50`: impede novas tarefas e deixa a atual terminar;
402
+ preserva pendentes e não interrompe processos à força.
403
+
404
+ ## Fluxo
405
+
406
+ Ao receber um pedido, defina contratos e dependências, distribua tarefas com
407
+ critérios de aceitação e acompanhe até concluir ou registrar um bloqueio real.
408
+ Os responsáveis ficam ociosos sem chamadas ao modelo e só executam tarefas da
409
+ fila. Recebem as instruções de papel na primeira tarefa e reutilizam sua sessão.
410
+ O controlador deve usar wait/result para receber conclusões: não há mensagem
411
+ espontânea para reativar um controlador que já encerrou sua resposta.
412
+ Só o controlador altera arquivos da raiz e coordena atualizações dos gitlinks.
413
+ Não faça commits, pushes, deploys ou ações destrutivas sem autorização do usuário.
414
+ Não altere simultaneamente um submódulo que já possui tarefa em execução.
415
+ Não acrescente outros worktrees ou clones em repos/: todos os projetos devem
416
+ ser submódulos. Consulte git status na raiz e nos submódulos antes da integração.
417
+ O histórico e resultados locais ficam em .codex-team/ e não são versionados.
418
+ Quando o usuário pedir um novo responsável, use add com o nome e URL informados,
419
+ sem pedir que ele abra um terminal manualmente. Permissões do sandbox continuam
420
+ válidas; registre ou solicite a permissão necessária quando um comando for negado.
421
+ '''
422
+
423
+
424
+ def validate_agent(name, repo, branch=None):
425
+ if not SLUG.fullmatch(name) or name.lower() in {'controlador', 'controller', 'chefe'}:
426
+ raise ValueError('Nome de agente inválido ou reservado (até 48 caracteres).')
427
+ if not isinstance(repo, str) or not repo or repo.startswith('-') or any(c in repo for c in '\n\r\x00'):
428
+ raise ValueError('URL de repositório inválida.')
429
+ if re.match(r'https?://[^/]*@', repo):
430
+ raise ValueError('Não inclua credenciais na URL; use o login Git/SSH da máquina.')
431
+ if branch is not None and (not isinstance(branch, str) or not branch or branch.startswith('-')
432
+ or subprocess.run(['git', 'check-ref-format', '--branch', branch],
433
+ capture_output=True).returncode):
434
+ raise ValueError('Branch inválida.')
435
+
436
+
437
+ def load_agents(filename):
438
+ value = json.loads(Path(filename).read_text())
439
+ agents = value.get('agents') if isinstance(value, dict) else value
440
+ if not isinstance(agents, list):
441
+ raise ValueError('O config precisa conter {"agents": [{"name": ..., "repo": ...}]}.')
442
+ names = set()
443
+ for item in agents:
444
+ if not isinstance(item, dict) or not isinstance(item.get('name'), str):
445
+ raise ValueError('Cada agente precisa de name e repo.')
446
+ validate_agent(item['name'], item.get('repo'), item.get('branch'))
447
+ if item['name'].lower() in names:
448
+ raise ValueError('Nome duplicado no config: ' + item['name'])
449
+ names.add(item['name'].lower())
450
+ return agents
451
+
452
+
453
+ def parser():
454
+ p = argparse.ArgumentParser(description=__doc__)
455
+ p.add_argument('--version', action='version', version='codex-team ' + VERSION)
456
+ p.add_argument('--root', default='codex-team', help='Raiz própria do time (padrão: ./codex-team)')
457
+ subs = p.add_subparsers(dest='command', required=True)
458
+ start = subs.add_parser('start', help='Inicializa ou retoma o time')
459
+ start.add_argument('--root', default=argparse.SUPPRESS, help='Raiz própria do time (padrão: ./codex-team)')
460
+ start.add_argument('--version', action='version', version='codex-team ' + VERSION)
461
+ start.add_argument('--agents', help='JSON de agentes; usa ./config.json se existir')
462
+ start.add_argument('--session')
463
+ start.add_argument('--layout', choices=['panes', 'windows'], default='panes')
464
+ start.add_argument('--no-attach', action='store_true')
465
+ add = subs.add_parser('add', help='Adiciona um responsável/submódulo')
466
+ add.add_argument('--name', required=True)
467
+ add.add_argument('--repo', required=True)
468
+ add.add_argument('--branch')
469
+ task = subs.add_parser('task', help='Enfileira uma tarefa para um responsável')
470
+ task.add_argument('name')
471
+ messages = task.add_mutually_exclusive_group(required=True)
472
+ messages.add_argument('--message')
473
+ messages.add_argument('--file', type=Path)
474
+ subs.add_parser('example-config', help='Imprime um JSON de exemplo (substitua as URLs)')
475
+ subs.add_parser('status')
476
+ result = subs.add_parser('result')
477
+ result.add_argument('task_id')
478
+ wait = subs.add_parser('wait')
479
+ wait.add_argument('task_ids', nargs='+')
480
+ wait.add_argument('--timeout', type=float, default=50)
481
+ stop = subs.add_parser('stop')
482
+ stop.add_argument('--timeout', type=float, default=50)
483
+ subs.add_parser('attach')
484
+ subs.add_parser('_controller', help=argparse.SUPPRESS)
485
+ worker = subs.add_parser('_worker', help=argparse.SUPPRESS)
486
+ worker.add_argument('name')
487
+ return p
488
+
489
+
490
+ def attach(team):
491
+ if not team.session_alive():
492
+ raise ValueError('Sessão tmux indisponível. Execute start.')
493
+ if os.environ.get('TMUX'):
494
+ run(['tmux', 'switch-client', '-t', team.settings()['session']])
495
+ else:
496
+ os.execvp('tmux', ['tmux', 'attach-session', '-t', team.settings()['session']])
497
+
498
+
499
+ def main(argv=None):
500
+ args = parser().parse_args(argv)
501
+ if args.command == 'example-config':
502
+ emit({'agents': [
503
+ {'name': 'publicapi', 'repo': 'git@github.com:SUA_ORGANIZACAO/publicapi.git'},
504
+ {'name': 'anunciantes', 'repo': 'git@github.com:SUA_ORGANIZACAO/flutter_anunciantes.git'},
505
+ {'name': 'parceiros', 'repo': 'git@github.com:SUA_ORGANIZACAO/flutter_parceiros.git'},
506
+ ]})
507
+ return 0
508
+ team = Team(args.root)
509
+ if args.command == 'start':
510
+ for cmd in ('git', 'tmux', 'codex'):
511
+ if not shutil.which(cmd):
512
+ raise ValueError('Comando necessário não encontrado: ' + cmd)
513
+ filename = args.agents or ('config.json' if Path('config.json').is_file() else None)
514
+ agents = load_agents(filename) if filename else []
515
+ team.init(args.session, args.layout)
516
+ with team.lock():
517
+ config = team.settings()
518
+ config['stopping'] = False
519
+ team.save_settings(config)
520
+ team.create_controller()
521
+ for item in agents:
522
+ team.add(item['name'], item['repo'], item.get('branch'))
523
+ with team.lock():
524
+ with team.db() as db:
525
+ names = [row['name'] for row in db.execute('SELECT name FROM agents')]
526
+ for name in names:
527
+ team.launch_worker(name)
528
+ emit({'root': str(team.root), 'session': team.settings()['session'], 'agents': names})
529
+ if not args.no_attach:
530
+ attach(team)
531
+ elif args.command == '_controller':
532
+ # Interactive TUI remains available for the user's instructions.
533
+ command = ['codex', '--sandbox', 'workspace-write', '--ask-for-approval', 'on-request',
534
+ '-C', str(team.root), 'Você é o controlador deste time. Leia AGENTS.md, '
535
+ 'consulte ./team status e aguarde o pedido do usuário. Gerencie os responsáveis usando ./team.']
536
+ os.execvp(command[0], command)
537
+ elif args.command == '_worker':
538
+ team.worker(args.name)
539
+ elif args.command == 'add':
540
+ emit(team.add(args.name, args.repo, args.branch))
541
+ elif args.command == 'task':
542
+ message = args.file.read_text() if args.file else args.message
543
+ emit({'task_id': team.enqueue(args.name, message)})
544
+ elif args.command == 'status':
545
+ emit(team.snapshot())
546
+ elif args.command == 'result':
547
+ emit(team.task(args.task_id))
548
+ elif args.command == 'wait':
549
+ deadline = time.monotonic() + max(0, min(args.timeout, 3600))
550
+ while True:
551
+ tasks = [team.task(task_id) for task_id in args.task_ids]
552
+ if all(task['status'] in TERMINAL for task in tasks):
553
+ emit(tasks)
554
+ return 1 if any(task['status'] != 'done' for task in tasks) else 0
555
+ if time.monotonic() >= deadline:
556
+ emit(tasks)
557
+ return 2
558
+ time.sleep(.25)
559
+ elif args.command == 'stop':
560
+ return team.stop(max(0, args.timeout))
561
+ elif args.command == 'attach':
562
+ attach(team)
563
+ return 0
564
+
565
+
566
+ def entrypoint(argv=None):
567
+ try:
568
+ return main(argv)
569
+ except (ValueError, OSError, subprocess.CalledProcessError, sqlite3.Error) as exc:
570
+ print('Erro: ' + str(exc), file=sys.stderr)
571
+ return 1
572
+
573
+
574
+ def start_entrypoint():
575
+ return entrypoint(['start', *sys.argv[1:]])
576
+
577
+
578
+ if __name__ == '__main__':
579
+ sys.exit(entrypoint())
@@ -0,0 +1,216 @@
1
+ Metadata-Version: 2.4
2
+ Name: codex-team
3
+ Version: 0.1.0
4
+ Summary: Orchestrate local Codex agents with tmux, Git submodules, and persistent task queues.
5
+ Author: PixPool
6
+ Keywords: codex,tmux,agents,orchestration,git,submodules
7
+ Classifier: Development Status :: 3 - Alpha
8
+ Classifier: Environment :: Console
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Operating System :: POSIX :: Linux
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3 :: Only
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Topic :: Software Development
16
+ Classifier: Topic :: Utilities
17
+ Requires-Python: >=3.10
18
+ Description-Content-Type: text/markdown
19
+
20
+ # Codex Team
21
+
22
+ Linux, Python 3.10+, Git, tmux e Codex CLI autenticado. Compatibilidade conferida
23
+ com Codex CLI 0.163.0 e tmux 3.3a. Usa a autenticação e o modelo configurados no
24
+ Codex local; não instala SDK nem exige uma chave de API separada.
25
+
26
+ ## Instalar pelo pip
27
+
28
+ ```bash
29
+ python3 -m pip install codex-team
30
+ ```
31
+
32
+ Em um Python gerenciado pelo sistema operacional, use um ambiente virtual ou
33
+ `pipx install codex-team`. Git, tmux e Codex CLI são dependências externas
34
+ que precisam estar no PATH. Nenhuma biblioteca Python adicional é necessária.
35
+
36
+ ```bash
37
+ codex-team --version
38
+ codex-team example-config > config.json
39
+ # Edite config.json para informar os repositórios reais.
40
+ start-git-codex-team --root /caminho/novo-time --agents config.json
41
+ ```
42
+
43
+ O comando completo equivalente é:
44
+
45
+ ```bash
46
+ codex-team start --root /caminho/novo-time --agents config.json
47
+ ```
48
+
49
+ Também funciona como módulo: `python3 -m codex_team --help`.
50
+ Depois da criação, use `./team` dentro da pasta do time para adicionar agentes,
51
+ enviar tarefas e consultar resultados. Fora dela, use por exemplo
52
+ `codex-team --root /caminho/novo-time status`.
53
+
54
+ ## Iniciar a partir deste checkout
55
+
56
+ Copie `config.example.json` para um `config.json` e substitua as URLs pelos
57
+ repositórios reais. Repositórios privados precisam de acesso Git/SSH na máquina.
58
+ Não inclua tokens ou senhas nas URLs.
59
+
60
+ ```bash
61
+ ./ops/start-git-codex-team.sh --root /caminho/novo-time --agents /caminho/config.json
62
+ ```
63
+
64
+ O diretório passado em `--root` precisa estar vazio ou já
65
+ pertencer a este gerenciador. Por padrão a raiz é `./codex-team`; sem `--agents`,
66
+ carrega `./config.json` se existir. Sem JSON, inicia apenas o controlador.
67
+ Também aceita uma lista JSON diretamente, além do objeto com a chave `agents`.
68
+ Cada agente exige `name` e `repo`; `branch` é opcional (branch existente no remoto).
69
+
70
+ Use `--no-attach` para iniciar sem anexar o terminal. O layout padrão usa painéis
71
+ lado a lado; `--layout windows` cria uma janela por agente. Quando não há espaço
72
+ para mais divisões, um novo agente ganha uma janela própria. Escolha o layout na
73
+ primeira inicialização. A sessão tem nome derivado da pasta; `--session equipe-x`
74
+ permite escolher o nome na primeira inicialização. Não reutilize o mesmo nome
75
+ para times diferentes.
76
+
77
+ A raiz passa a ter:
78
+
79
+ ```text
80
+ novo-time/
81
+ ├── .git/ # Repositório Git raiz, sem commit automático
82
+ ├── .gitmodules # Projetos registrados como submódulos
83
+ ├── AGENTS.md # Instruções do controlador
84
+ ├── README.md
85
+ ├── team # Gerenciador local, executável
86
+ ├── repos/
87
+ │ ├── publicapi/ # Submódulo Git
88
+ │ └── parceiros/ # Submódulo Git
89
+ └── .codex-team/ # Banco, filas, sessões e resultados locais
90
+ ```
91
+
92
+ `git submodule add` deixa `.gitmodules` e os gitlinks no índice. O gerenciador não
93
+ faz commit nem push na raiz ou nos projetos. A raiz ainda não possui remoto;
94
+ configure um se quiser compartilhar a composição do time. O runtime é local e
95
+ ignorado pelo Git; os arquivos versionados sozinhos não reinstalam o gerenciador
96
+ em outra máquina. Preserve essa pasta para retomar este time.
97
+
98
+ ## Conversar com o controlador
99
+
100
+ O painel Controlador é uma sessão interativa do Codex. Você pode pedir:
101
+
102
+ > Adicione um responsável chamado pagamentos para o repositório
103
+ > git@github.com:empresa/pagamentos.git e aguarde as tarefas.
104
+
105
+ O AGENTS.md instrui o chefe a executar:
106
+
107
+ ```bash
108
+ ./team add --name pagamentos --repo git@github.com:empresa/pagamentos.git
109
+ ```
110
+
111
+ O novo projeto é cadastrado como submódulo e seu painel é aberto. Um responsável
112
+ inicialmente fica ocioso; não há chamada ao modelo para apenas aguardar. Ao
113
+ receber uma tarefa, o executor chama `codex exec --json`, registra seu thread ID e
114
+ reutiliza a conversa usando `codex exec resume` nas tarefas seguintes.
115
+
116
+ Os painéis dos responsáveis mostram mensagens, comandos e resultados da
117
+ execução. São consoles do executor, não TUIs interativas do Codex. Envie tarefas
118
+ pelo controlador ou pelo comando abaixo, sem digitar no painel do responsável.
119
+
120
+ ```bash
121
+ cd /caminho/novo-time
122
+ ./team task publicapi --message 'Implemente o endpoint X e valide os testes.'
123
+ ./team task parceiros --file /caminho/tarefa.txt
124
+ ./team status
125
+ ./team wait ID_DA_TAREFA --timeout 50
126
+ ./team result ID_DA_TAREFA
127
+ ```
128
+
129
+ `wait` retorna 0 se todas terminaram com sucesso de execução, 1 se alguma falhou
130
+ ou foi interrompida, e 2 se ainda há pendentes. Leia o relatório para conferir
131
+ se os critérios de aceitação e os testes passaram; `done` indica que a chamada
132
+ Codex terminou normalmente, não substitui revisão. Não há dependências implícitas
133
+ na fila: o chefe deve esperar a API antes de enviar uma integração dependente.
134
+ Cada responsável executa uma tarefa por vez; repositórios diferentes trabalham
135
+ em paralelo. Uma tarefa interrompida é marcada e não é reenviada automaticamente.
136
+
137
+ A fila usa SQLite e a espera ociosa dos responsáveis usa FIFO. Resultados ficam
138
+ em `.codex-team/results/ID.md`, logs em `.codex-team/logs/ID.jsonl`. Esses arquivos
139
+ podem conter conteúdo do projeto; não os publique inadvertidamente.
140
+
141
+ O chefe deve chamar `wait`/`result` enquanto acompanha um pedido. O gerenciador
142
+ não reativa espontaneamente um controlador que já encerrou sua resposta.
143
+ Ele pode distribuir várias tarefas e aguardar resultados na mesma conversa.
144
+
145
+ ## Permissões e escopo
146
+
147
+ O controlador usa `workspace-write` com aprovação `on-request`. Git remoto, tmux
148
+ ou ações fora do sandbox podem exigir aprovação conforme sua instalação. Os
149
+ responsáveis usam `workspace-write` e aprovação `never`: se uma ação não for
150
+ permitida, devem relatar o impedimento ao chefe. O gerenciador não usa bypass de
151
+ sandbox nem executa comandos recebidos por interpolação de shell.
152
+
153
+ A tarefa instrui cada responsável a alterar apenas seu submódulo. Isso é uma
154
+ regra de coordenação, não um isolamento de leitura ou segurança entre processos
155
+ que compartilham o mesmo usuário Linux. Instruções locais do repositório e
156
+ configurações do Codex continuam aplicáveis.
157
+
158
+ ## Sair, parar e retomar
159
+
160
+ - Desconectar mantendo o trabalho: `Ctrl+B`, depois `D`.
161
+ - Voltar: `./team attach` na raiz do time.
162
+ - Parar os responsáveis com segurança, em outro terminal:
163
+
164
+ ```bash
165
+ cd /caminho/novo-time
166
+ ./team stop --timeout 50
167
+ ```
168
+
169
+ `stop` fecha a entrada de tarefas. A tarefa atual de cada responsável pode
170
+ terminar, mas as pendentes ficam preservadas. Se o prazo expirar, retorna 2 e
171
+ não força a interrupção. Execute `stop` novamente depois. O controlador e a
172
+ sessão tmux permanecem abertos. Quando ele estiver ocioso e os responsáveis
173
+ parados, encerre a sessão usando o nome mostrado em `./team status`:
174
+
175
+ ```bash
176
+ tmux kill-session -t NOME_DA_SESSAO
177
+ ```
178
+
179
+ Para reabrir o time, reaproveite a mesma raiz:
180
+
181
+ ```bash
182
+ ./ops/start-git-codex-team.sh --root /caminho/novo-time
183
+ ```
184
+
185
+ Isso restaura os painéis ausentes, as filas e os IDs das conversas dos
186
+ responsáveis. Tarefas pendentes voltam a ser executadas. Se um controlador foi
187
+ fechado, abre uma conversa nova que consulta o estado persistido; a conversa
188
+ interativa anterior pode ser retomada manualmente pelo Codex. Nenhum arquivo
189
+ modificado ou commit dos submódulos é descartado.
190
+
191
+ ## Validação
192
+
193
+ ```bash
194
+ python3 -m unittest discover -s ops/codex-team -p 'test_*.py' -v
195
+ ```
196
+
197
+ Os testes usam repositórios Git locais, servidor tmux isolado e um executável
198
+ Codex simulado. Não fazem chamadas ao modelo, não clonam projetos reais e não
199
+ alteram o servidor tmux do usuário.
200
+
201
+ Referência: [execução não interativa do Codex](https://developers.openai.com/codex/noninteractive).
202
+
203
+ ## Desenvolvimento e build
204
+
205
+ ```bash
206
+ cd ops/codex-team
207
+ python3 -m pip install -e .
208
+ python3 -m pip install build twine
209
+ python3 -m build
210
+ python3 -m twine check dist/*
211
+ ```
212
+
213
+ A distribuição inclui somente o gerenciador, a documentação e os exemplos;
214
+ nenhum repositório de trabalho, fila, credencial ou configuração de usuário é
215
+ incluído nos artefatos. A versão inicial é 0.1.0 (alpha). A implementação usa
216
+ tmux; GNU screen não é um backend suportado por este pacote.
@@ -0,0 +1,8 @@
1
+ codex_team/__init__.py,sha256=jWP8Lp1qNj01zb-UoB535MAOOVv_fGi3xs7RatklI90,141
2
+ codex_team/__main__.py,sha256=PIJIOolexzGnrOJz3yXWQZ6eCKD9h2LLAao-qtQ8HYE,60
3
+ codex_team/cli.py,sha256=AiZDuDyByyb5hx5r-S5b_x71nZSXMlLj8DAPhqrv094,28889
4
+ codex_team-0.1.0.dist-info/METADATA,sha256=vkma9v3iamzYbApZbut1p2fFacgPxapl0qLxSGh96to,8928
5
+ codex_team-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
6
+ codex_team-0.1.0.dist-info/entry_points.txt,sha256=l97m-HSFmjMzd5l4Djb4h-zEYLBkMM08hzvIgBgSlfQ,112
7
+ codex_team-0.1.0.dist-info/top_level.txt,sha256=9-CdtNoAAQJ590wambLcCn_5kyLdc9wkvs5x7pFeyQA,11
8
+ codex_team-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ codex-team = codex_team.cli:entrypoint
3
+ start-git-codex-team = codex_team.cli:start_entrypoint
@@ -0,0 +1 @@
1
+ codex_team