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.
@@ -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 {};
@@ -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
- if (url.searchParams.get('token') !== opts.token) {
37
- cb(false, 401, 'invalid token');
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
- cb(true);
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 pty;
148
+ let p;
55
149
  try {
56
- // Lazy require — keeps `paris serve --help` working even if the native
57
- // node-pty binary failed to compile/install on this platform.
58
- const req = createRequire(import.meta.url);
59
- const ptyMod = req('node-pty');
60
- const shell = process.env.SHELL || (process.platform === 'win32' ? 'powershell.exe' : 'bash');
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.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
+ }
@@ -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 (current && current.current) {
105
- lines.push('## Tarefa atual')
106
- lines.push('- **id:** \\'' + current.current.id + '\\'')
107
- lines.push('- **título:** ' + current.current.title)
108
- lines.push('- **tipo:** ' + current.current.deliverable_type)
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('### Sócio prompt')
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
- lines.push('## Sem tarefa em foco')
118
- lines.push('Pergunte ao aluno em que ele quer trabalhar.')
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "paris-immersion",
3
- "version": "0.1.1",
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": {
@@ -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.",