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.
- package/dist/commands/serve.d.ts +22 -0
- package/dist/commands/serve.js +55 -27
- package/dist/index.js +1 -1
- package/dist/lib/relay-agent.d.ts +14 -0
- package/dist/lib/relay-agent.js +193 -0
- package/package.json +1 -1
package/dist/commands/serve.d.ts
CHANGED
|
@@ -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 {};
|
package/dist/commands/serve.js
CHANGED
|
@@ -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
|
|
148
|
+
let p;
|
|
123
149
|
try {
|
|
124
|
-
// Lazy require — keeps `paris serve --help` working
|
|
125
|
-
// node-pty binary failed to compile
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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.
|
|
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