paris-immersion 0.1.16 → 0.1.17

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.
@@ -7,6 +7,8 @@ import { readCredentials } from '../lib/credentials.js';
7
7
  import { ensureWorkspace } from './install.js';
8
8
  import { startRelayAgent } from '../lib/relay-agent.js';
9
9
  import { renderTostudyCommand } from '../templates/tostudy-command.js';
10
+ import { renderHookScript } from '../templates/hook.js';
11
+ import { fileURLToPath } from 'node:url';
10
12
  import { maybeSelfUpdate } from '../lib/self-update.js';
11
13
  import { c } from '../lib/colors.js';
12
14
  /**
@@ -176,12 +178,40 @@ function ensureTostudyCommand(cwd) {
176
178
  }
177
179
  }
178
180
  }
181
+ /**
182
+ * Reescreve o HOOK (`.paris/hooks/paris-hook.mjs`) e o bundle MCP vendorizado a
183
+ * cada serve. Crucial: o `paris install` só os escreve na CRIAÇÃO do workspace —
184
+ * num workspace ANTIGO (instalado antes do sync de progresso existir), o binário
185
+ * do CLI se auto-atualiza mas o hook fica CONGELADO e o progresso NUNCA sincroniza
186
+ * (gestão eterna em 0/N). Reescrever a cada serve fecha esse gap. Best-effort,
187
+ * idempotente (ambos são 100% auto-gerados; o settings.json aponta pro mesmo path).
188
+ */
189
+ function ensureWorkspaceVendored(cwd) {
190
+ try {
191
+ const hooksDir = path.join(cwd, '.paris', 'hooks');
192
+ mkdirSync(hooksDir, { recursive: true });
193
+ writeFileSync(path.join(hooksDir, 'paris-hook.mjs'), renderHookScript(), { mode: 0o755 });
194
+ }
195
+ catch {
196
+ /* best-effort */
197
+ }
198
+ try {
199
+ const mcpSrc = fileURLToPath(new URL('../../templates/mcp/index.mjs', import.meta.url));
200
+ const mcpDir = path.join(cwd, '.paris', 'mcp');
201
+ mkdirSync(mcpDir, { recursive: true });
202
+ writeFileSync(path.join(mcpDir, 'index.mjs'), readFileSync(mcpSrc), { mode: 0o755 });
203
+ }
204
+ catch {
205
+ /* best-effort — sem o bundle, o /tostudy ainda roda; só o fallback MCP fica velho */
206
+ }
207
+ }
179
208
  export function spawnCoachPty(cwd) {
180
209
  const req = createRequire(import.meta.url);
181
210
  ensureSpawnHelperExecutable(req);
182
211
  const ptyMod = req('node-pty');
183
- // Escreve /tostudy ANTES de resolver o kickoff + subir o Claude.
212
+ // Escreve /tostudy + hook + MCP frescos ANTES de resolver o kickoff + subir o Claude.
184
213
  ensureTostudyCommand(cwd);
214
+ ensureWorkspaceVendored(cwd);
185
215
  const kickoff = resolveKickoff(cwd);
186
216
  const shell = process.env.SHELL || (process.platform === 'win32' ? 'powershell.exe' : 'bash');
187
217
  const shellArgs = process.platform === 'win32'
package/dist/index.js CHANGED
@@ -13,7 +13,7 @@ import { daemonCommand, restartCommand } from './commands/daemon.js';
13
13
  const program = new Command()
14
14
  .name('paris')
15
15
  .description('Paris Immersion — CLI do aluno (login, workspace, tarefas)')
16
- .version('0.1.16');
16
+ .version('0.1.17');
17
17
  program
18
18
  .command('login')
19
19
  .description('Autenticar com sua conta Paris (device-code OR --token direto)')
@@ -51,12 +51,17 @@ export async function startRelayAgent(opts) {
51
51
  headers: { authorization: `Bearer ${opts.loginToken}` },
52
52
  signal: AbortSignal.timeout(8000),
53
53
  });
54
- if (!res.ok)
55
- return null;
56
- return (await res.json());
54
+ if (res.ok)
55
+ return { kind: 'ok', info: (await res.json()) };
56
+ // 401 (JWT inválido/expirado) e 404 (participant_not_found) são definitivos:
57
+ // re-tentar com o mesmo loginToken nunca vai passar. 5xx é tratado como
58
+ // inalcançável (transitório).
59
+ if (res.status === 401 || res.status === 403 || res.status === 404)
60
+ return { kind: 'unauthorized' };
61
+ return { kind: 'unreachable' };
57
62
  }
58
63
  catch {
59
- return null;
64
+ return { kind: 'unreachable' }; // DNS/timeout/conn refused — a API pode voltar
60
65
  }
61
66
  };
62
67
  // Primeira busca decide se o relay está configurado (senão → modo local-only).
@@ -65,20 +70,31 @@ export async function startRelayAgent(opts) {
65
70
  // responde 200 na 1ª tentativa). KeepAlive mantém o serve, mas sem este retry o
66
71
  // agente relay nunca subia se a 1ª busca caísse no boot.
67
72
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
73
+ // Mensagem acionável quando o loginToken (installer JWT ~7d) expirou ou o
74
+ // participante foi removido. O daemon roda detached, então isto vai pro log do
75
+ // launchd/systemd (pro diagnóstico) — o aluno é guiado pelo overlay do browser.
76
+ const RELOGIN_HINT = 'paris: login expirado — rode: npm i -g paris-immersion@latest && paris login';
68
77
  let initial = await fetchInfo();
69
- for (let i = 0; i < 6 && !initial; i++) {
78
+ // Retry no boot SÓ enquanto a API está inalcançável (blip de rede: o daemon sobe
79
+ // antes da rede). Um 'unauthorized' é definitivo — não adianta re-tentar com o
80
+ // mesmo token; sai pro modo local-only e loga o caminho de re-login.
81
+ for (let i = 0; i < 6 && initial.kind === 'unreachable'; i++) {
70
82
  await sleep(5000);
71
83
  initial = await fetchInfo();
72
84
  }
73
- if (!initial || !initial.relayUrl || !initial.token || !initial.e2eKeyB64)
85
+ if (initial.kind === 'unauthorized') {
86
+ log(RELOGIN_HINT);
87
+ return null;
88
+ }
89
+ if (initial.kind !== 'ok' || !initial.info.relayUrl || !initial.info.token || !initial.info.e2eKeyB64)
74
90
  return null;
75
91
  // Mutáveis: o serve-token é de vida curta e é RENOVADO a cada (re)conexão (o
76
92
  // loginToken/JWT do aluno é estável). Sem isso, o daemon que sobe no boot fica
77
93
  // preso com um token expirado, o relay rejeita o agente, e o terminal embutido
78
94
  // fica "Esperando o paris CLI subir" pra sempre mesmo com o serve rodando.
79
- let relayUrl = initial.relayUrl;
80
- let token = initial.token;
81
- let key = Buffer.from(initial.e2eKeyB64, 'base64'); // chave E2E é estável por participante
95
+ let relayUrl = initial.info.relayUrl;
96
+ let token = initial.info.token;
97
+ let key = Buffer.from(initial.info.e2eKeyB64, 'base64'); // chave E2E é estável por participante
82
98
  if (key.length !== 32)
83
99
  return null;
84
100
  let ws = null;
@@ -197,23 +213,59 @@ export async function startRelayAgent(opts) {
197
213
  }
198
214
  }, 80);
199
215
  };
216
+ // Backoff de reconexão. Caminho normal = 2s (rápido, pra reatachar o aluno que
217
+ // recarregou). Mas quando a API responde 401/404 (loginToken expirou ou
218
+ // participante removido), re-discar a 2s com o token velho é um loop infinito
219
+ // que o relay 401-a sem parar — então espaçamos pra 60s e logamos o re-login.
220
+ const RETRY_FAST_MS = 2000;
221
+ const RETRY_UNAUTH_MS = 60_000;
222
+ let nextRetryMs = RETRY_FAST_MS;
200
223
  const connect = async () => {
201
224
  if (closed)
202
225
  return;
203
226
  // Renova token + relayUrl + chave a cada tentativa (o serve-token expira;
204
227
  // o loginToken é estável). É isto que conserta o "agente preso no boot".
205
228
  const fresh = await fetchInfo();
206
- if (fresh?.relayUrl && fresh.token && fresh.e2eKeyB64) {
207
- relayUrl = fresh.relayUrl;
208
- token = fresh.token;
209
- const k = Buffer.from(fresh.e2eKeyB64, 'base64');
229
+ if (fresh.kind === 'ok' && fresh.info.relayUrl && fresh.info.token && fresh.info.e2eKeyB64) {
230
+ relayUrl = fresh.info.relayUrl;
231
+ token = fresh.info.token;
232
+ const k = Buffer.from(fresh.info.e2eKeyB64, 'base64');
210
233
  if (k.length === 32)
211
234
  key = k;
235
+ nextRetryMs = RETRY_FAST_MS;
236
+ }
237
+ else if (fresh.kind === 'unauthorized') {
238
+ // Token de login morto: NÃO re-disca com o velho (o relay 401-a e o loop
239
+ // nunca surfacing). Loga o caminho de re-login e agenda uma tentativa
240
+ // espaçada — se o aluno rodar `paris login`, a próxima busca passa e reata.
241
+ log(RELOGIN_HINT);
242
+ nextRetryMs = RETRY_UNAUTH_MS;
243
+ if (!closed)
244
+ reconnect = setTimeout(connect, nextRetryMs);
245
+ return;
246
+ }
247
+ else {
248
+ // Inalcançável (rede/5xx): re-disca com o token ATUAL (pode ainda ser válido
249
+ // — cobre o blip de rede no boot) no ritmo rápido.
250
+ nextRetryMs = RETRY_FAST_MS;
212
251
  }
213
252
  if (closed)
214
253
  return;
215
254
  const url = `${relayUrl.replace(/\/$/, '')}/agent?token=${encodeURIComponent(token)}`;
216
255
  ws = new WebSocket(url);
256
+ // O relay rejeita o handshake (401) ANTES do upgrade quando o serve-token não
257
+ // valida — isso chega aqui como 'unexpected-response' (não 'open'/'close').
258
+ // Sem este handler o `ws` cai pra abortHandshake → error+close e re-disca a 2s.
259
+ // Tratamos como um sinal de re-busca: o próximo connect() chama fetchInfo() e,
260
+ // se a API disser 'unauthorized', o branch acima espaça/loga. O socket já está
261
+ // sendo abortado pelo `ws`; só registramos pra não cair no caminho silencioso.
262
+ ws.on('unexpected-response', (_req, res) => {
263
+ log(`relay: handshake rejeitado (${res.statusCode}) — re-buscando token`);
264
+ try {
265
+ res.resume();
266
+ }
267
+ catch { /* drena o corpo pra liberar o socket */ }
268
+ });
217
269
  ws.on('open', () => {
218
270
  // TCP_NODELAY: terminal interativo manda frames pequenos; sem isso o Nagle
219
271
  // os enfileira (~40ms/hop, pior ainda com delayed-ACK) → digitação e o 1º
@@ -291,8 +343,10 @@ export async function startRelayAgent(opts) {
291
343
  ws.on('close', () => {
292
344
  // NÃO mata o pty aqui — mantém o Claude vivo pra reatachar quando o ws
293
345
  // reconectar (o viewer-left + grace timer e o shutdown cuidam de matar).
346
+ // nextRetryMs = 2s no caminho normal; 60s quando a API marcou o token como
347
+ // morto (o connect() seguinte re-busca e re-avalia). Evita o loop 2s eterno.
294
348
  if (!closed)
295
- reconnect = setTimeout(connect, 2000);
349
+ reconnect = setTimeout(connect, nextRetryMs);
296
350
  });
297
351
  ws.on('error', () => {
298
352
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "paris-immersion",
3
- "version": "0.1.16",
3
+ "version": "0.1.17",
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": {