paris-immersion 0.1.2 → 0.1.3

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.
@@ -1,3 +1,18 @@
1
+ /**
2
+ * Minimal pty-like interface — we type-erase node-pty so the file compiles
3
+ * cleanly without the native module being installed (it's loaded lazily at
4
+ * runtime via dynamic require).
5
+ */
6
+ type IPty = {
7
+ write: (s: string) => void;
8
+ resize: (cols: number, rows: number) => void;
9
+ onData: (cb: (d: string) => void) => void;
10
+ onExit?: (cb: (e: {
11
+ exitCode: number;
12
+ signal?: number;
13
+ }) => void) => void;
14
+ kill: (signal?: string) => void;
15
+ };
1
16
  export interface ServeOptions {
2
17
  port: number;
3
18
  allowedOrigins: string[];
@@ -22,6 +37,12 @@ export interface ServeHandle {
22
37
  port: number;
23
38
  close: () => void;
24
39
  }
40
+ /**
41
+ * Spawn the coached pty (`claude '/tostudy'` inside the provisioned workspace).
42
+ * Shared by the local ws server and the relay agent. Throws if node-pty can't
43
+ * load/spawn — callers report it on their channel.
44
+ */
45
+ export declare function spawnCoachPty(cwd: string): IPty;
25
46
  /**
26
47
  * Start a localhost WebSocket server that brokers a pty (running `claude`) to a
27
48
  * connected browser-side xterm.js. Two gates protect the channel:
@@ -42,3 +63,4 @@ export declare function readServeToken(): Promise<string>;
42
63
  export declare function serveCommand(opts: {
43
64
  port?: string | number;
44
65
  }): Promise<void>;
66
+ export {};
@@ -4,6 +4,7 @@ import { promises as fs, chmodSync, existsSync, statSync } from 'node:fs';
4
4
  import path from 'node:path';
5
5
  import { readCredentials } from '../lib/credentials.js';
6
6
  import { ensureWorkspace } from './install.js';
7
+ import { startRelayAgent } from '../lib/relay-agent.js';
7
8
  import { c } from '../lib/colors.js';
8
9
  /**
9
10
  * Restore the executable bit on node-pty's `spawn-helper`. Some install paths
@@ -69,6 +70,31 @@ const DEFAULT_ALLOWED_ORIGINS = [
69
70
  // turma precisar de outro entrypoint (ex.: o tutor-paris `get_course` como
70
71
  // fallback quando não há curso ToStudy configurado).
71
72
  const COACH_KICKOFF = process.env.PARIS_COACH_KICKOFF || '/tostudy';
73
+ /**
74
+ * Spawn the coached pty (`claude '/tostudy'` inside the provisioned workspace).
75
+ * Shared by the local ws server and the relay agent. Throws if node-pty can't
76
+ * load/spawn — callers report it on their channel.
77
+ */
78
+ export function spawnCoachPty(cwd) {
79
+ const req = createRequire(import.meta.url);
80
+ ensureSpawnHelperExecutable(req);
81
+ const ptyMod = req('node-pty');
82
+ const shell = process.env.SHELL || (process.platform === 'win32' ? 'powershell.exe' : 'bash');
83
+ const shellArgs = process.platform === 'win32'
84
+ ? ['-NoLogo', '-Command', `claude "${COACH_KICKOFF.replace(/"/g, '`"')}"`]
85
+ : ['-l', '-c', `claude '${COACH_KICKOFF.replace(/'/g, `'\\''`)}'`];
86
+ return ptyMod.spawn(shell, shellArgs, {
87
+ name: 'xterm-color',
88
+ cols: 80,
89
+ rows: 24,
90
+ cwd,
91
+ env: {
92
+ ...process.env,
93
+ TOSTUDY_MCP_ENABLED: '1',
94
+ PARIS_MCP_ENABLED: '1',
95
+ },
96
+ });
97
+ }
72
98
  /**
73
99
  * Start a localhost WebSocket server that brokers a pty (running `claude`) to a
74
100
  * connected browser-side xterm.js. Two gates protect the channel:
@@ -119,33 +145,13 @@ export async function startServeServer(opts) {
119
145
  ws.send('READY\n');
120
146
  return;
121
147
  }
122
- let pty;
148
+ let p;
123
149
  try {
124
- // Lazy require — keeps `paris serve --help` working even if the native
125
- // node-pty binary failed to compile/install on this platform.
126
- const req = createRequire(import.meta.url);
127
- ensureSpawnHelperExecutable(req);
128
- const ptyMod = req('node-pty');
129
- const shell = process.env.SHELL || (process.platform === 'win32' ? 'powershell.exe' : 'bash');
130
- // Auto-start the coach: launch `claude` with an initial prompt so the
131
- // student opens the terminal to Claude ALREADY running the course (it
132
- // speaks first), instead of an idle prompt. The SessionStart hook has
133
- // already injected the immersion/day/task context; the kickoff just tells
134
- // Claude to begin now without waiting for input.
135
- const shellArgs = process.platform === 'win32'
136
- ? ['-NoLogo', '-Command', `claude "${COACH_KICKOFF.replace(/"/g, '`"')}"`]
137
- : ['-l', '-c', `claude '${COACH_KICKOFF.replace(/'/g, `'\\''`)}'`];
138
- pty = ptyMod.spawn(shell, shellArgs, {
139
- name: 'xterm-color',
140
- cols: 80,
141
- rows: 24,
142
- cwd: opts.cwd ?? process.cwd(),
143
- env: {
144
- ...process.env,
145
- TOSTUDY_MCP_ENABLED: '1',
146
- PARIS_MCP_ENABLED: '1',
147
- },
148
- });
150
+ // Lazy require inside spawnCoachPty — keeps `paris serve --help` working
151
+ // even if the native node-pty binary failed to compile on this platform.
152
+ // The coach starts immediately (claude '/tostudy') so the student opens to
153
+ // Claude already running the course instead of an idle prompt.
154
+ p = spawnCoachPty(opts.cwd ?? process.cwd());
149
155
  }
150
156
  catch (err) {
151
157
  const msg = err instanceof Error ? err.message : String(err);
@@ -153,7 +159,6 @@ export async function startServeServer(opts) {
153
159
  ws.close(1011, 'pty_unavailable');
154
160
  return;
155
161
  }
156
- const p = pty;
157
162
  p.onData((d) => {
158
163
  try {
159
164
  ws.send(d);
@@ -280,9 +285,32 @@ export async function serveCommand(opts) {
280
285
  console.log(c.bold('paris serve'));
281
286
  console.log(` ${c.green('✓')} WebSocket em ${c.bold(`ws://localhost:${handle.port}`)}`);
282
287
  console.log(` ${c.dim('origens permitidas:')} ${allowedOrigins.join(', ')}`);
288
+ // Relay (prod): a página em HTTPS não consegue abrir ws://localhost (mixed
289
+ // content / PNA), então também ficamos disponíveis via wss no relay. É
290
+ // best-effort: sem relay configurado na API, isto é no-op e o modo local
291
+ // (ws://localhost, usado pela web em http://localhost:3010) segue normal.
292
+ let relay = null;
293
+ void startRelayAgent({
294
+ apiUrl,
295
+ loginToken: token,
296
+ cwd,
297
+ log: (m) => console.log(` ${c.dim(m)}`),
298
+ })
299
+ .then((h) => {
300
+ relay = h;
301
+ if (h)
302
+ console.log(` ${c.green('✓')} relay ativo (terminal embutido funciona via HTTPS)`);
303
+ })
304
+ .catch(() => { });
283
305
  console.log(` ${c.dim('Ctrl+C pra encerrar.')}`);
284
306
  console.log();
285
307
  const shutdown = () => {
308
+ try {
309
+ relay?.close();
310
+ }
311
+ catch {
312
+ /* noop */
313
+ }
286
314
  handle.close();
287
315
  process.exit(0);
288
316
  };
package/dist/index.js CHANGED
@@ -12,7 +12,7 @@ import { serveCommand } from './commands/serve.js';
12
12
  const program = new Command()
13
13
  .name('paris')
14
14
  .description('Paris Immersion — CLI do aluno (login, workspace, tarefas)')
15
- .version('0.1.0');
15
+ .version('0.1.3');
16
16
  program
17
17
  .command('login')
18
18
  .description('Autenticar com sua conta Paris (device-code OR --token direto)')
@@ -0,0 +1,14 @@
1
+ export interface RelayAgentHandle {
2
+ close: () => void;
3
+ }
4
+ /**
5
+ * Inicia o agente relay. Busca o serve-token + info do relay na API (Bearer
6
+ * creds.token), e se o relay estiver configurado, mantém a conexão wss e spawna
7
+ * o pty quando um viewer entra. Retorna null quando não há relay (modo local-only).
8
+ */
9
+ export declare function startRelayAgent(opts: {
10
+ apiUrl: string;
11
+ loginToken: string;
12
+ cwd: string;
13
+ log?: (msg: string) => void;
14
+ }): Promise<RelayAgentHandle | null>;
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Relay agent — o lado `paris serve` do terminal embutido quando a página roda
3
+ * em HTTPS (prod). Em vez do browser conectar `ws://localhost` (bloqueado por
4
+ * mixed content / PNA), o serve disca PRA FORA num relay `wss://` e o browser
5
+ * conecta no mesmo relay. O relay só casa a sala (= participantId) e encaminha
6
+ * frames BINÁRIOS cifrados E2E — nunca tem a chave.
7
+ *
8
+ * Protocolo: docs/superpowers/specs/2026-06-01-embedded-terminal-relay-e2e-design.md
9
+ */
10
+ import crypto from 'node:crypto';
11
+ import { WebSocket } from 'ws';
12
+ import { spawnCoachPty } from '../commands/serve.js';
13
+ const TYPE_DATA = 0x00;
14
+ const TYPE_RESIZE = 0x01;
15
+ /** plaintext = type(1) || body → frame = nonce(12) || AES-256-GCM(enc||tag). */
16
+ function seal(key, type, body) {
17
+ const nonce = crypto.randomBytes(12);
18
+ const cipher = crypto.createCipheriv('aes-256-gcm', key, nonce);
19
+ const enc = Buffer.concat([cipher.update(Buffer.concat([Buffer.from([type]), body])), cipher.final()]);
20
+ return Buffer.concat([nonce, enc, cipher.getAuthTag()]);
21
+ }
22
+ function open(key, frame) {
23
+ try {
24
+ if (frame.length < 12 + 16)
25
+ return null;
26
+ const nonce = frame.subarray(0, 12);
27
+ const tag = frame.subarray(frame.length - 16);
28
+ const enc = frame.subarray(12, frame.length - 16);
29
+ const decipher = crypto.createDecipheriv('aes-256-gcm', key, nonce);
30
+ decipher.setAuthTag(tag);
31
+ const pt = Buffer.concat([decipher.update(enc), decipher.final()]);
32
+ return { type: pt[0], body: pt.subarray(1) };
33
+ }
34
+ catch {
35
+ return null;
36
+ }
37
+ }
38
+ /**
39
+ * Inicia o agente relay. Busca o serve-token + info do relay na API (Bearer
40
+ * creds.token), e se o relay estiver configurado, mantém a conexão wss e spawna
41
+ * o pty quando um viewer entra. Retorna null quando não há relay (modo local-only).
42
+ */
43
+ export async function startRelayAgent(opts) {
44
+ if (!opts.apiUrl || !opts.loginToken)
45
+ return null;
46
+ const log = opts.log ?? (() => { });
47
+ let info;
48
+ try {
49
+ const res = await fetch(`${opts.apiUrl.replace(/\/$/, '')}/api/onboarding/v2/serve-token`, {
50
+ headers: { authorization: `Bearer ${opts.loginToken}` },
51
+ signal: AbortSignal.timeout(8000),
52
+ });
53
+ if (!res.ok)
54
+ return null;
55
+ info = (await res.json());
56
+ }
57
+ catch {
58
+ return null;
59
+ }
60
+ const { token, relayUrl, e2eKeyB64 } = info;
61
+ if (!relayUrl || !token || !e2eKeyB64)
62
+ return null; // relay não configurado → local-only
63
+ const key = Buffer.from(e2eKeyB64, 'base64');
64
+ if (key.length !== 32)
65
+ return null;
66
+ let ws = null;
67
+ let pty;
68
+ let closed = false;
69
+ let reconnect;
70
+ let killTimer;
71
+ // Mantém o Claude vivo por uma janela após o viewer sair, pra que uma
72
+ // reconexão (ex.: Safari derruba o ws periodicamente) REATACHE na MESMA sessão
73
+ // em vez de reiniciar o curso. O resize que o viewer manda ao reconectar faz a
74
+ // TUI do Claude repintar a tela atual.
75
+ const PTY_GRACE_MS = 60_000;
76
+ const killPty = () => {
77
+ if (pty) {
78
+ try {
79
+ pty.kill();
80
+ }
81
+ catch {
82
+ /* noop */
83
+ }
84
+ pty = undefined;
85
+ }
86
+ };
87
+ const startPty = () => {
88
+ if (pty)
89
+ return; // já tem Claude vivo — não reinicia (reatacha)
90
+ try {
91
+ pty = spawnCoachPty(opts.cwd);
92
+ }
93
+ catch (err) {
94
+ log(`relay: pty spawn falhou (${err instanceof Error ? err.message : String(err)})`);
95
+ return;
96
+ }
97
+ pty.onData((d) => {
98
+ if (ws && ws.readyState === WebSocket.OPEN) {
99
+ try {
100
+ ws.send(seal(key, TYPE_DATA, Buffer.from(d, 'utf8')), { binary: true });
101
+ }
102
+ catch {
103
+ /* ws caiu no meio */
104
+ }
105
+ }
106
+ });
107
+ pty.onExit?.(() => killPty());
108
+ };
109
+ const connect = () => {
110
+ if (closed)
111
+ return;
112
+ const url = `${relayUrl.replace(/\/$/, '')}/agent?token=${encodeURIComponent(token)}`;
113
+ ws = new WebSocket(url);
114
+ ws.on('open', () => log(`relay: conectado (${relayUrl})`));
115
+ ws.on('message', (data, isBinary) => {
116
+ if (!isBinary) {
117
+ // Controle do relay (texto): viewer-joined / viewer-left.
118
+ try {
119
+ const m = JSON.parse(data.toString());
120
+ if (m.t === 'viewer-joined') {
121
+ if (killTimer) {
122
+ clearTimeout(killTimer);
123
+ killTimer = undefined;
124
+ }
125
+ startPty(); // no-op se já tem Claude vivo → reatacha na mesma sessão
126
+ }
127
+ else if (m.t === 'viewer-left') {
128
+ // Não mata na hora — janela de graça pra reconexão reatachar.
129
+ if (killTimer)
130
+ clearTimeout(killTimer);
131
+ killTimer = setTimeout(() => {
132
+ killTimer = undefined;
133
+ killPty();
134
+ }, PTY_GRACE_MS);
135
+ }
136
+ }
137
+ catch {
138
+ /* frame de controle malformado — ignora */
139
+ }
140
+ return;
141
+ }
142
+ // Frame E2E do viewer (stdin/resize) — decifra e aplica no pty.
143
+ const msg = open(key, data);
144
+ if (!msg || !pty)
145
+ return;
146
+ if (msg.type === TYPE_DATA) {
147
+ pty.write(msg.body.toString('utf8'));
148
+ }
149
+ else if (msg.type === TYPE_RESIZE) {
150
+ const [cols, rows] = msg.body.toString('utf8').split(',').map(Number);
151
+ if (Number.isFinite(cols) && Number.isFinite(rows)) {
152
+ try {
153
+ pty.resize(cols, rows);
154
+ }
155
+ catch {
156
+ /* race de resize */
157
+ }
158
+ }
159
+ }
160
+ });
161
+ ws.on('close', () => {
162
+ // NÃO mata o pty aqui — mantém o Claude vivo pra reatachar quando o ws
163
+ // reconectar (o viewer-left + grace timer e o shutdown cuidam de matar).
164
+ if (!closed)
165
+ reconnect = setTimeout(connect, 2000);
166
+ });
167
+ ws.on('error', () => {
168
+ try {
169
+ ws?.close();
170
+ }
171
+ catch {
172
+ /* noop */
173
+ }
174
+ });
175
+ };
176
+ connect();
177
+ return {
178
+ close: () => {
179
+ closed = true;
180
+ if (reconnect)
181
+ clearTimeout(reconnect);
182
+ if (killTimer)
183
+ clearTimeout(killTimer);
184
+ killPty();
185
+ try {
186
+ ws?.close();
187
+ }
188
+ catch {
189
+ /* noop */
190
+ }
191
+ },
192
+ };
193
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "paris-immersion",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
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": {