paris-immersion 0.1.1 → 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 +32 -0
- package/dist/commands/serve.js +145 -23
- package/dist/index.js +1 -1
- package/dist/lib/relay-agent.d.ts +14 -0
- package/dist/lib/relay-agent.js +193 -0
- package/dist/templates/hook.js +33 -15
- package/dist/templates/settings.js +5 -1
- package/package.json +1 -1
- package/templates/mcp/index.mjs +12 -0
package/dist/commands/serve.d.ts
CHANGED
|
@@ -1,7 +1,32 @@
|
|
|
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[];
|
|
19
|
+
/** The login token from ~/.paris/credentials. Accepted on the WS handshake for
|
|
20
|
+
* backward compatibility (legacy: web sent the login token directly). */
|
|
4
21
|
token: string;
|
|
22
|
+
/** API base URL — used to verify serve-tokens the browser sends (the CLI has no
|
|
23
|
+
* JWT_SECRET, so it delegates verification to POST /api/onboarding/v2/serve-verify). */
|
|
24
|
+
apiUrl?: string;
|
|
25
|
+
/** When set, a verified serve-token must resolve to this user/participant —
|
|
26
|
+
* read from the workspace marker so one machine's serve can't be driven by a
|
|
27
|
+
* token minted for a different student. */
|
|
28
|
+
expectedUserId?: string;
|
|
29
|
+
expectedParticipantId?: string;
|
|
5
30
|
/** Working directory the embedded `claude` runs in. Defaults to process.cwd().
|
|
6
31
|
* serveCommand sets this to the provisioned workspace so the session is coached. */
|
|
7
32
|
cwd?: string;
|
|
@@ -12,6 +37,12 @@ export interface ServeHandle {
|
|
|
12
37
|
port: number;
|
|
13
38
|
close: () => void;
|
|
14
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;
|
|
15
46
|
/**
|
|
16
47
|
* Start a localhost WebSocket server that brokers a pty (running `claude`) to a
|
|
17
48
|
* connected browser-side xterm.js. Two gates protect the channel:
|
|
@@ -32,3 +63,4 @@ export declare function readServeToken(): Promise<string>;
|
|
|
32
63
|
export declare function serveCommand(opts: {
|
|
33
64
|
port?: string | number;
|
|
34
65
|
}): Promise<void>;
|
|
66
|
+
export {};
|
package/dist/commands/serve.js
CHANGED
|
@@ -1,13 +1,100 @@
|
|
|
1
1
|
import { WebSocketServer } from 'ws';
|
|
2
2
|
import { createRequire } from 'node:module';
|
|
3
|
+
import { promises as fs, chmodSync, existsSync, statSync } from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
3
5
|
import { readCredentials } from '../lib/credentials.js';
|
|
4
6
|
import { ensureWorkspace } from './install.js';
|
|
7
|
+
import { startRelayAgent } from '../lib/relay-agent.js';
|
|
5
8
|
import { c } from '../lib/colors.js';
|
|
9
|
+
/**
|
|
10
|
+
* Restore the executable bit on node-pty's `spawn-helper`. Some install paths
|
|
11
|
+
* (pnpm store hardlinks, tarball extraction without same-permissions, certain
|
|
12
|
+
* CI caches) drop the +x bit on the prebuilt helper — and without it node-pty's
|
|
13
|
+
* posix_spawnp fails at runtime with "posix_spawnp failed", killing the embedded
|
|
14
|
+
* terminal on EVERY launch. We can't depend on the package shipping it correctly,
|
|
15
|
+
* so we fix it best-effort at startup. No-op on Windows (no spawn-helper there).
|
|
16
|
+
*/
|
|
17
|
+
function ensureSpawnHelperExecutable(req) {
|
|
18
|
+
if (process.platform === 'win32')
|
|
19
|
+
return;
|
|
20
|
+
try {
|
|
21
|
+
const main = req.resolve('node-pty'); // <pkg>/lib/index.js
|
|
22
|
+
const helper = path.resolve(path.dirname(main), '..', 'prebuilds', `${process.platform}-${process.arch}`, 'spawn-helper');
|
|
23
|
+
if (existsSync(helper) && !(statSync(helper).mode & 0o111)) {
|
|
24
|
+
chmodSync(helper, 0o755);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
/* best-effort — if we can't locate it, the spawn error path still reports cleanly */
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Verify a serve-token the browser sent on the WS handshake. The CLI can't check
|
|
33
|
+
* the JWT signature locally (no JWT_SECRET), so it asks the API. Returns true only
|
|
34
|
+
* if the API confirms the token AND it resolves to the workspace's own student.
|
|
35
|
+
*/
|
|
36
|
+
async function verifyServeToken(token, opts) {
|
|
37
|
+
if (!token || !opts.apiUrl)
|
|
38
|
+
return false;
|
|
39
|
+
try {
|
|
40
|
+
const res = await fetch(`${opts.apiUrl}/api/onboarding/v2/serve-verify`, {
|
|
41
|
+
method: 'POST',
|
|
42
|
+
headers: { 'content-type': 'application/json' },
|
|
43
|
+
body: JSON.stringify({ token }),
|
|
44
|
+
});
|
|
45
|
+
if (!res.ok)
|
|
46
|
+
return false;
|
|
47
|
+
const data = (await res.json());
|
|
48
|
+
if (!data.valid)
|
|
49
|
+
return false;
|
|
50
|
+
if (opts.expectedUserId && data.userId && data.userId !== opts.expectedUserId)
|
|
51
|
+
return false;
|
|
52
|
+
if (opts.expectedParticipantId && data.participantId && data.participantId !== opts.expectedParticipantId)
|
|
53
|
+
return false;
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
6
60
|
const DEFAULT_ALLOWED_ORIGINS = [
|
|
7
61
|
'https://app.paris-group.com',
|
|
8
62
|
'https://paris-immersion.vercel.app',
|
|
9
63
|
'http://localhost:3010',
|
|
10
64
|
];
|
|
65
|
+
// Initial prompt fed to `claude` when the embedded terminal opens, so the course
|
|
66
|
+
// starts immediately instead of sitting at an idle prompt. Invoca a skill do
|
|
67
|
+
// tutor ToStudy (`/tostudy`, do @tostudy-ai/cli) — o aluno chega aqui já logado
|
|
68
|
+
// na conta dele da ToStudy (auto-login no install via cli-auth-code), então o
|
|
69
|
+
// `/tostudy` roda o curso real direto. Override com PARIS_COACH_KICKOFF se uma
|
|
70
|
+
// turma precisar de outro entrypoint (ex.: o tutor-paris `get_course` como
|
|
71
|
+
// fallback quando não há curso ToStudy configurado).
|
|
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
|
+
}
|
|
11
98
|
/**
|
|
12
99
|
* Start a localhost WebSocket server that brokers a pty (running `claude`) to a
|
|
13
100
|
* connected browser-side xterm.js. Two gates protect the channel:
|
|
@@ -33,11 +120,18 @@ export async function startServeServer(opts) {
|
|
|
33
120
|
return;
|
|
34
121
|
}
|
|
35
122
|
const url = new URL(info.req.url ?? '/', 'http://localhost');
|
|
36
|
-
|
|
37
|
-
|
|
123
|
+
const qToken = url.searchParams.get('token') ?? '';
|
|
124
|
+
// Fast path: the login token (creds.token) — backward compatible with
|
|
125
|
+
// older web clients that sent it directly.
|
|
126
|
+
if (qToken && qToken === opts.token) {
|
|
127
|
+
cb(true);
|
|
38
128
|
return;
|
|
39
129
|
}
|
|
40
|
-
|
|
130
|
+
// Otherwise treat it as a serve-token (purpose 'onboarding-serve', minted by
|
|
131
|
+
// GET /api/onboarding/v2/serve-token) and let the API verify it.
|
|
132
|
+
void verifyServeToken(qToken, opts).then((ok) => {
|
|
133
|
+
cb(ok, ok ? undefined : 401, ok ? undefined : 'invalid token');
|
|
134
|
+
});
|
|
41
135
|
},
|
|
42
136
|
});
|
|
43
137
|
// Wait for the listening event so handle.port is final (random port if 0).
|
|
@@ -51,25 +145,13 @@ export async function startServeServer(opts) {
|
|
|
51
145
|
ws.send('READY\n');
|
|
52
146
|
return;
|
|
53
147
|
}
|
|
54
|
-
let
|
|
148
|
+
let p;
|
|
55
149
|
try {
|
|
56
|
-
// Lazy require — keeps `paris serve --help` working
|
|
57
|
-
// node-pty binary failed to compile
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
const shellArgs = process.platform === 'win32' ? ['-NoLogo'] : ['-l', '-c', 'claude'];
|
|
62
|
-
pty = ptyMod.spawn(shell, shellArgs, {
|
|
63
|
-
name: 'xterm-color',
|
|
64
|
-
cols: 80,
|
|
65
|
-
rows: 24,
|
|
66
|
-
cwd: opts.cwd ?? process.cwd(),
|
|
67
|
-
env: {
|
|
68
|
-
...process.env,
|
|
69
|
-
TOSTUDY_MCP_ENABLED: '1',
|
|
70
|
-
PARIS_MCP_ENABLED: '1',
|
|
71
|
-
},
|
|
72
|
-
});
|
|
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());
|
|
73
155
|
}
|
|
74
156
|
catch (err) {
|
|
75
157
|
const msg = err instanceof Error ? err.message : String(err);
|
|
@@ -77,7 +159,6 @@ export async function startServeServer(opts) {
|
|
|
77
159
|
ws.close(1011, 'pty_unavailable');
|
|
78
160
|
return;
|
|
79
161
|
}
|
|
80
|
-
const p = pty;
|
|
81
162
|
p.onData((d) => {
|
|
82
163
|
try {
|
|
83
164
|
ws.send(d);
|
|
@@ -173,9 +254,27 @@ export async function serveCommand(opts) {
|
|
|
173
254
|
const msg = err instanceof Error ? err.message : String(err);
|
|
174
255
|
console.error(c.yellow(` ! sem workspace coachado (${msg}). Abrindo Claude em ${cwd}.`));
|
|
175
256
|
}
|
|
257
|
+
// Read api_url + the workspace's owner so we can verify serve-tokens (and bind
|
|
258
|
+
// them to this student). Best-effort — falls back to login-token-only auth.
|
|
259
|
+
let apiUrl = '';
|
|
260
|
+
let expectedUserId;
|
|
261
|
+
let expectedParticipantId;
|
|
262
|
+
try {
|
|
263
|
+
const creds = await readCredentials();
|
|
264
|
+
apiUrl = creds?.api_url ?? '';
|
|
265
|
+
const markerRaw = await fs.readFile(path.join(cwd, '.paris', 'workspace.json'), 'utf8');
|
|
266
|
+
const marker = JSON.parse(markerRaw);
|
|
267
|
+
expectedUserId = marker.student_user_id;
|
|
268
|
+
expectedParticipantId = marker.participant_id;
|
|
269
|
+
if (!apiUrl && marker.api_url)
|
|
270
|
+
apiUrl = marker.api_url;
|
|
271
|
+
}
|
|
272
|
+
catch {
|
|
273
|
+
/* best-effort — verifyServeToken just no-ops without apiUrl */
|
|
274
|
+
}
|
|
176
275
|
let handle;
|
|
177
276
|
try {
|
|
178
|
-
handle = await startServeServer({ port: portNum, allowedOrigins, token, cwd });
|
|
277
|
+
handle = await startServeServer({ port: portNum, allowedOrigins, token, cwd, apiUrl, expectedUserId, expectedParticipantId });
|
|
179
278
|
}
|
|
180
279
|
catch (err) {
|
|
181
280
|
const msg = err instanceof Error ? err.message : String(err);
|
|
@@ -186,9 +285,32 @@ export async function serveCommand(opts) {
|
|
|
186
285
|
console.log(c.bold('paris serve'));
|
|
187
286
|
console.log(` ${c.green('✓')} WebSocket em ${c.bold(`ws://localhost:${handle.port}`)}`);
|
|
188
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(() => { });
|
|
189
305
|
console.log(` ${c.dim('Ctrl+C pra encerrar.')}`);
|
|
190
306
|
console.log();
|
|
191
307
|
const shutdown = () => {
|
|
308
|
+
try {
|
|
309
|
+
relay?.close();
|
|
310
|
+
}
|
|
311
|
+
catch {
|
|
312
|
+
/* noop */
|
|
313
|
+
}
|
|
192
314
|
handle.close();
|
|
193
315
|
process.exit(0);
|
|
194
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/dist/templates/hook.js
CHANGED
|
@@ -76,9 +76,10 @@ async function main() {
|
|
|
76
76
|
return
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
-
const [workspace, current] = await Promise.all([
|
|
79
|
+
const [workspace, current, course] = await Promise.all([
|
|
80
80
|
apiGet(apiUrl, '/immersion-app/v1/workspace', creds.token),
|
|
81
81
|
apiGet(apiUrl, '/immersion-app/v1/missions/current', creds.token),
|
|
82
|
+
apiGet(apiUrl, '/immersion-app/v1/course', creds.token),
|
|
82
83
|
])
|
|
83
84
|
|
|
84
85
|
if (!workspace) {
|
|
@@ -87,6 +88,11 @@ async function main() {
|
|
|
87
88
|
}
|
|
88
89
|
if (workspace.status === 'graduated') return
|
|
89
90
|
|
|
91
|
+
// Onboarding: enquanto o curso não termina, o terminal roda o tutor do curso
|
|
92
|
+
// (/tostudy) — isso tem prioridade sobre as missões da imersão (que nem existem
|
|
93
|
+
// na fase pre).
|
|
94
|
+
const inCourse = !!(course && course.courseCompleted === false)
|
|
95
|
+
|
|
90
96
|
const lines = []
|
|
91
97
|
if (PHASE === 'session-start') {
|
|
92
98
|
lines.push('# Paris Immersion — contexto da sessão')
|
|
@@ -94,28 +100,40 @@ async function main() {
|
|
|
94
100
|
lines.push('Imersão: **' + workspace.immersion.name + '** (\\'' + workspace.immersion.slug + '\\')')
|
|
95
101
|
lines.push('Dia: **' + workspace.day_number + '** · fase: **' + workspace.phase + '**')
|
|
96
102
|
lines.push('')
|
|
97
|
-
lines.push('Você (Claude) é o coach técnico desse aluno. Siga as regras do CLAUDE.md e da skill paris-coach. **Comece falando primeiro** com um resumo do dia e a tarefa atual — o aluno NÃO digitou nada ainda.')
|
|
98
|
-
lines.push('')
|
|
99
103
|
} else {
|
|
100
104
|
lines.push('# Paris — refresh de contexto')
|
|
101
105
|
lines.push('')
|
|
102
106
|
}
|
|
103
107
|
|
|
104
|
-
if (
|
|
105
|
-
lines.push('##
|
|
106
|
-
lines.push('
|
|
107
|
-
lines.push('
|
|
108
|
-
|
|
109
|
-
lines.push('- **status:** ' + current.current.status)
|
|
110
|
-
if (current.current.description) lines.push('- **descrição:** ' + current.current.description)
|
|
111
|
-
if (current.current.socio_prompt) {
|
|
108
|
+
if (inCourse) {
|
|
109
|
+
lines.push('## FASE DO CURSO DE ONBOARDING')
|
|
110
|
+
lines.push('O aluno está na etapa do curso **' + course.title + '** (progresso: **' + course.lessonsCompleted + '/' + course.totalLessons + '**).')
|
|
111
|
+
lines.push('Rode a skill **/tostudy** (o tutor da ToStudy) pra conduzir o curso — o aluno já chega logado na conta dele da ToStudy (auto-login no install). NÃO trabalhe em missões da imersão enquanto o curso não terminar. (Fallback: se a ToStudy não estiver disponível, use as ferramentas MCP **get_course**/**complete_lesson** do paris.)')
|
|
112
|
+
if (PHASE === 'session-start') {
|
|
112
113
|
lines.push('')
|
|
113
|
-
lines.push('
|
|
114
|
-
lines.push(current.current.socio_prompt)
|
|
114
|
+
lines.push('**Comece falando primeiro**: rode /tostudy e dê as boas-vindas. O aluno ainda não digitou nada.')
|
|
115
115
|
}
|
|
116
116
|
} else {
|
|
117
|
-
|
|
118
|
-
|
|
117
|
+
if (PHASE === 'session-start') {
|
|
118
|
+
lines.push('Você (Claude) é o coach técnico desse aluno. Siga as regras do CLAUDE.md e da skill paris-coach. **Comece falando primeiro** com um resumo do dia e a tarefa atual — o aluno NÃO digitou nada ainda.')
|
|
119
|
+
lines.push('')
|
|
120
|
+
}
|
|
121
|
+
if (current && current.current) {
|
|
122
|
+
lines.push('## Tarefa atual')
|
|
123
|
+
lines.push('- **id:** \\'' + current.current.id + '\\'')
|
|
124
|
+
lines.push('- **título:** ' + current.current.title)
|
|
125
|
+
lines.push('- **tipo:** ' + current.current.deliverable_type)
|
|
126
|
+
lines.push('- **status:** ' + current.current.status)
|
|
127
|
+
if (current.current.description) lines.push('- **descrição:** ' + current.current.description)
|
|
128
|
+
if (current.current.socio_prompt) {
|
|
129
|
+
lines.push('')
|
|
130
|
+
lines.push('### Sócio prompt')
|
|
131
|
+
lines.push(current.current.socio_prompt)
|
|
132
|
+
}
|
|
133
|
+
} else {
|
|
134
|
+
lines.push('## Sem tarefa em foco')
|
|
135
|
+
lines.push('Pergunte ao aluno em que ele quer trabalhar.')
|
|
136
|
+
}
|
|
119
137
|
}
|
|
120
138
|
|
|
121
139
|
process.stdout.write(lines.join('\\n') + '\\n')
|
|
@@ -9,7 +9,11 @@
|
|
|
9
9
|
*/
|
|
10
10
|
export function renderSettingsJson(opts) {
|
|
11
11
|
return {
|
|
12
|
-
$schema: 'https://json.schemastore.org/claude-code-settings',
|
|
12
|
+
$schema: 'https://json.schemastore.org/claude-code-settings.json',
|
|
13
|
+
// Auto-confia no MCP do projeto (paris-immersion) — sem isso o Claude Code
|
|
14
|
+
// pode não conectar o server numa sessão não-interativa (terminal embutido),
|
|
15
|
+
// e o tutor abre sem as tools get_course/complete_lesson.
|
|
16
|
+
enableAllProjectMcpServers: true,
|
|
13
17
|
mcpServers: {
|
|
14
18
|
'paris-immersion': {
|
|
15
19
|
command: 'node',
|
package/package.json
CHANGED
package/templates/mcp/index.mjs
CHANGED
|
@@ -18024,6 +18024,18 @@ function buildTools() {
|
|
|
18024
18024
|
transcript_excerpt: input.transcript_excerpt
|
|
18025
18025
|
})
|
|
18026
18026
|
},
|
|
18027
|
+
{
|
|
18028
|
+
name: "get_course",
|
|
18029
|
+
description: "Retorna o curso de onboarding (Terminal + Claude Code): li\xE7\xF5es, objetivos e o progresso do aluno. Use NO IN\xCDCIO pra saber o que ensinar e de onde retomar.",
|
|
18030
|
+
inputSchema: external_exports.object({}),
|
|
18031
|
+
handler: async (_, { client }) => client.get("/immersion-app/v1/course")
|
|
18032
|
+
},
|
|
18033
|
+
{
|
|
18034
|
+
name: "complete_lesson",
|
|
18035
|
+
description: "Marca uma li\xE7\xE3o do curso de onboarding como conclu\xEDda. Progresso \xE9 MONOT\xD4NICO (nunca regride) e reconcilia com a conta ToStudy do aluno pelo e-mail. S\xD3 chame quando o aluno realmente cumprir o objetivo da li\xE7\xE3o. Quando a \xFAltima fecha, o curso conclui e a fase avan\xE7a.",
|
|
18036
|
+
inputSchema: external_exports.object({ lesson_id: external_exports.string().min(1) }),
|
|
18037
|
+
handler: async (input, { client }) => client.post(`/immersion-app/v1/course/lessons/${input.lesson_id}/complete`, {})
|
|
18038
|
+
},
|
|
18027
18039
|
{
|
|
18028
18040
|
name: "reopen_task",
|
|
18029
18041
|
description: "Reabrir uma tarefa marcada como conclu\xEDda. O aluno (voc\xEA) precisa de uma raz\xE3o.",
|