paris-immersion 0.1.29 → 0.1.30

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.
@@ -6,6 +6,7 @@ import path from 'node:path';
6
6
  import { readCredentials } from '../lib/credentials.js';
7
7
  import { ensureWorkspace } from './install.js';
8
8
  import { startRelayAgent } from '../lib/relay-agent.js';
9
+ import { parseMissionAction, startMissionInOrca } from '../lib/orca-launcher.js';
9
10
  import { renderTostudyCommand } from '../templates/tostudy-command.js';
10
11
  import { renderHookScript } from '../templates/hook.js';
11
12
  import { renderSettingsJson } from '../templates/settings.js';
@@ -332,7 +333,7 @@ export async function startServeServer(opts) {
332
333
  wss.once('listening', () => resolve());
333
334
  wss.once('error', reject);
334
335
  });
335
- wss.on('connection', (ws) => {
336
+ wss.on('connection', (ws, req) => {
336
337
  // TCP_NODELAY: sem Nagle no socket local (caminho dev ws://localhost).
337
338
  try {
338
339
  ws._socket?.setNoDelay?.(true);
@@ -343,6 +344,33 @@ export async function startServeServer(opts) {
343
344
  ws.send('READY\n');
344
345
  return;
345
346
  }
347
+ // Modo AÇÃO (?mode=action): conexão one-shot da War Room ("Iniciar missão"
348
+ // abre o Orca) — sem pty. Espera um único 'ACTION:<json>', responde
349
+ // 'ACTIONRESULT:<json>' e fecha. Espelho local do TYPE_ACTION do relay.
350
+ const connUrl = new URL(req?.url ?? '/', 'http://localhost');
351
+ if (connUrl.searchParams.get('mode') === 'action') {
352
+ ws.on('message', (m) => {
353
+ const s = m.toString();
354
+ if (!s.startsWith('ACTION:'))
355
+ return;
356
+ const action = parseMissionAction(s.slice('ACTION:'.length));
357
+ if (!action) {
358
+ ws.send('ACTIONRESULT:' + JSON.stringify({ ok: false, error: 'Ação inválida.' }));
359
+ ws.close(1000);
360
+ return;
361
+ }
362
+ void startMissionInOrca({ cwd: opts.cwd ?? process.cwd(), prompt: action.prompt, title: action.title }).then((result) => {
363
+ try {
364
+ ws.send('ACTIONRESULT:' + JSON.stringify(result));
365
+ ws.close(1000);
366
+ }
367
+ catch {
368
+ /* viewer já foi */
369
+ }
370
+ });
371
+ });
372
+ return;
373
+ }
346
374
  let p;
347
375
  try {
348
376
  // Lazy require inside spawnCoachPty — keeps `paris serve --help` working
@@ -0,0 +1,16 @@
1
+ export interface StartMissionResult {
2
+ ok: boolean;
3
+ error?: string;
4
+ }
5
+ export declare function startMissionInOrca(opts: {
6
+ cwd: string;
7
+ prompt: string;
8
+ title?: string;
9
+ }): Promise<StartMissionResult>;
10
+ /** Payload da ação vinda do browser (War Room). */
11
+ export interface MissionAction {
12
+ action: 'start_mission';
13
+ prompt: string;
14
+ title?: string;
15
+ }
16
+ export declare function parseMissionAction(raw: string): MissionAction | null;
@@ -0,0 +1,90 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import path from 'node:path';
5
+ import { promisify } from 'node:util';
6
+ const execFileP = promisify(execFile);
7
+ /**
8
+ * "Iniciar missão" (2026-08-14): abre o Orca na máquina do aluno, já na pasta
9
+ * do workspace da imersão, com um terminal rodando `claude` com o prompt
10
+ * inicial da missão. Chamado pelo serve/relay-agent quando a War Room manda a
11
+ * ação `start_mission`.
12
+ *
13
+ * Usa a CLI do Orca (instalada pelo kit do onboarding em ~/.local/bin/orca):
14
+ * orca open → garante app + runtime de pé
15
+ * orca repo add <ws> → registra o workspace (idempotente; falha ignorada)
16
+ * orca terminal create ... → aba nova com o claude + prompt, em foco
17
+ */
18
+ function findOrcaBin() {
19
+ const candidates = [
20
+ path.join(homedir(), '.local', 'bin', 'orca'),
21
+ '/usr/local/bin/orca',
22
+ '/opt/homebrew/bin/orca',
23
+ ];
24
+ for (const c of candidates) {
25
+ if (existsSync(c))
26
+ return c;
27
+ }
28
+ return null;
29
+ }
30
+ export async function startMissionInOrca(opts) {
31
+ const orca = findOrcaBin();
32
+ if (!orca) {
33
+ return {
34
+ ok: false,
35
+ error: 'Orca não encontrado nesta máquina. Rode o instalador da imersão de novo, ou abra o Orca manualmente na pasta da imersão.',
36
+ };
37
+ }
38
+ if (!opts.cwd || !existsSync(opts.cwd)) {
39
+ return { ok: false, error: 'Workspace da imersão não encontrado nesta máquina.' };
40
+ }
41
+ try {
42
+ // 1. App + runtime de pé (no-op se já rodando). `orca open` espera o runtime
43
+ // ficar alcançável — timeout generoso pro cold start do app.
44
+ await execFileP(orca, ['open'], { timeout: 45_000 });
45
+ // 2. Workspace registrado (idempotente na prática: "já existe" é sucesso).
46
+ await execFileP(orca, ['repo', 'add', opts.cwd, '--json'], { timeout: 15_000 }).catch(() => { });
47
+ // 3. Aba de terminal com o Claude já na missão, em foco. Aspas simples no
48
+ // prompt são escapadas pro shell da aba.
49
+ const promptQ = opts.prompt.replace(/'/g, `'\\''`);
50
+ const command = `claude --dangerously-skip-permissions '${promptQ}'`;
51
+ const args = [
52
+ 'terminal',
53
+ 'create',
54
+ '--worktree',
55
+ `path:${opts.cwd}`,
56
+ '--command',
57
+ command,
58
+ '--focus',
59
+ '--json',
60
+ ];
61
+ if (opts.title)
62
+ args.push('--title', opts.title.slice(0, 60));
63
+ await execFileP(orca, args, { timeout: 30_000 });
64
+ return { ok: true };
65
+ }
66
+ catch (err) {
67
+ const msg = err instanceof Error ? err.message : String(err);
68
+ return {
69
+ ok: false,
70
+ error: `Não consegui abrir a missão no Orca: ${msg.slice(0, 200)}`,
71
+ };
72
+ }
73
+ }
74
+ export function parseMissionAction(raw) {
75
+ try {
76
+ const j = JSON.parse(raw);
77
+ if (j.action !== 'start_mission')
78
+ return null;
79
+ if (typeof j.prompt !== 'string' || j.prompt.length === 0 || j.prompt.length > 4000)
80
+ return null;
81
+ return {
82
+ action: 'start_mission',
83
+ prompt: j.prompt,
84
+ title: typeof j.title === 'string' ? j.title : undefined,
85
+ };
86
+ }
87
+ catch {
88
+ return null;
89
+ }
90
+ }
@@ -10,9 +10,12 @@
10
10
  import crypto from 'node:crypto';
11
11
  import { WebSocket } from 'ws';
12
12
  import { spawnCoachPty } from '../commands/serve.js';
13
+ import { parseMissionAction, startMissionInOrca } from './orca-launcher.js';
13
14
  const TYPE_DATA = 0x00;
14
15
  const TYPE_RESIZE = 0x01;
15
16
  const TYPE_WORKSPACE = 0x02; // agent → viewer: caminho do workspace (pro handoff nativo)
17
+ const TYPE_ACTION = 0x03; // viewer → agent: ação one-shot JSON (ex.: start_mission → abre o Orca)
18
+ const TYPE_ACTION_RESULT = 0x04; // agent → viewer: resultado JSON da ação
16
19
  /** plaintext = type(1) || body → frame = nonce(12) || AES-256-GCM(enc||tag). */
17
20
  function seal(key, type, body) {
18
21
  const nonce = crypto.randomBytes(12);
@@ -297,6 +300,11 @@ export async function startRelayAgent(opts) {
297
300
  try {
298
301
  const m = JSON.parse(data.toString());
299
302
  if (m.t === 'viewer-joined') {
303
+ // Viewer de AÇÃO ("Iniciar missão" da War Room): não é o terminal do
304
+ // curso — NÃO spawna o pty; a ação chega num frame TYPE_ACTION logo
305
+ // em seguida e é respondida com TYPE_ACTION_RESULT.
306
+ if (m.mode === 'action')
307
+ return;
300
308
  if (killTimer) {
301
309
  clearTimeout(killTimer);
302
310
  killTimer = undefined;
@@ -328,9 +336,32 @@ export async function startRelayAgent(opts) {
328
336
  }
329
337
  return;
330
338
  }
331
- // Frame E2E do viewer (stdin/resize) — decifra e aplica no pty.
339
+ // Frame E2E do viewer (stdin/resize/ação) — decifra e aplica.
332
340
  const msg = open(key, data);
333
- if (!msg || !pty)
341
+ if (!msg)
342
+ return;
343
+ if (msg.type === TYPE_ACTION) {
344
+ // Ação one-shot independente do pty (o viewer de ação nem o spawna).
345
+ const action = parseMissionAction(msg.body.toString('utf8'));
346
+ const respond = (result) => {
347
+ if (ws && ws.readyState === WebSocket.OPEN) {
348
+ try {
349
+ ws.send(seal(key, TYPE_ACTION_RESULT, Buffer.from(JSON.stringify(result), 'utf8')), { binary: true });
350
+ }
351
+ catch {
352
+ /* viewer já foi */
353
+ }
354
+ }
355
+ };
356
+ if (!action) {
357
+ respond({ ok: false, error: 'Ação inválida.' });
358
+ return;
359
+ }
360
+ log(`relay: ação start_mission (${action.title ?? 'sem título'})`);
361
+ void startMissionInOrca({ cwd: opts.cwd, prompt: action.prompt, title: action.title }).then(respond);
362
+ return;
363
+ }
364
+ if (!pty)
334
365
  return;
335
366
  if (msg.type === TYPE_DATA) {
336
367
  pty.write(msg.body.toString('utf8'));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "paris-immersion",
3
- "version": "0.1.29",
3
+ "version": "0.1.30",
4
4
  "description": "Paris Immersion CLI — login, install workspace, status, task control. Para alunos das imersões da Paris Group.",
5
5
  "homepage": "https://parisgroup.ai",
6
6
  "repository": {