paris-immersion 0.1.36 → 0.1.39

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.
@@ -0,0 +1,3 @@
1
+ export declare function conectarCommand(opts?: {
2
+ open?: boolean;
3
+ }): Promise<void>;
@@ -0,0 +1,217 @@
1
+ import { execFile, spawnSync } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ import { promises as fs } from 'node:fs';
4
+ import { homedir } from 'node:os';
5
+ import path from 'node:path';
6
+ import { findMarker } from '../lib/marker.js';
7
+ import { apiAuthFetch, ApiError } from '../lib/api.js';
8
+ import { readCredentials } from '../lib/credentials.js';
9
+ import { c } from '../lib/colors.js';
10
+ import { loginCommand } from './login.js';
11
+ import { installCommand, ensureWorkspace } from './install.js';
12
+ import { locateWorkspace } from './repair.js';
13
+ import { maybeSelfUpdate } from '../lib/self-update.js';
14
+ import { restartDaemon } from '../lib/daemon.js';
15
+ import net from 'node:net';
16
+ const execFileAsync = promisify(execFile);
17
+ async function tokenOk() {
18
+ try {
19
+ const ws = await apiAuthFetch('/immersion-app/v1/workspace');
20
+ return { ok: true, slug: ws.immersion?.slug };
21
+ }
22
+ catch (err) {
23
+ if (err instanceof ApiError)
24
+ return { ok: false, status: err.status, detail: err.message };
25
+ return { ok: false, status: 0, detail: err instanceof Error ? err.message : String(err) };
26
+ }
27
+ }
28
+ /**
29
+ * A pasta da TURMA ATUAL (slug que o servidor devolve). O aluno pode ter a pasta
30
+ * do treino e a da turma lado a lado — consertar a errada deixaria o mentor cego.
31
+ * Ordem: a pasta onde ele está (se for da turma) → ~/paris-<slug> → qualquer
32
+ * ~/paris-* da turma → null (o chamador cria ~/paris-<slug>).
33
+ */
34
+ async function workspaceForSlug(slug) {
35
+ if (!slug)
36
+ return locateWorkspace();
37
+ const here = await findMarker();
38
+ if (here && here.marker.immersion_slug === slug)
39
+ return here.dir;
40
+ const home = homedir();
41
+ const names = await fs.readdir(home).catch(() => []);
42
+ const ordered = [`paris-${slug}`, ...names.filter((n) => n.startsWith('paris-') && n !== `paris-${slug}`)];
43
+ for (const name of ordered) {
44
+ const m = await findMarker(path.join(home, name)).catch(() => null);
45
+ if (m && m.dir === path.join(home, name) && m.marker.immersion_slug === slug)
46
+ return m.dir;
47
+ }
48
+ return null;
49
+ }
50
+ /**
51
+ * Religa o agente local (o `paris serve` que o botão do curso na War Room usa) e
52
+ * só aprova quando ELE disser que conectou no relay. Um agente preso com login
53
+ * antigo deixava a War Room em "Não achei o paris rodando" (D1 de outubro).
54
+ * Dentro do terminal embutido não religa: este terminal É o agente.
55
+ */
56
+ async function ensureLocalAgent() {
57
+ if (process.env.PARIS_MCP_ENABLED === '1')
58
+ return { ok: true, detail: 'você já está no terminal do curso' };
59
+ const logFile = path.join(homedir(), '.paris', 'serve.log');
60
+ const offset = await fs.stat(logFile).then((s) => s.size).catch(() => 0);
61
+ try {
62
+ await restartDaemon();
63
+ }
64
+ catch (err) {
65
+ return { ok: false, detail: `não consegui religar o serviço: ${err instanceof Error ? err.message : String(err)}` };
66
+ }
67
+ const portOpen = () => new Promise((resolve) => {
68
+ const s = net.connect(31000, '127.0.0.1');
69
+ s.once('connect', () => { s.destroy(); resolve(true); });
70
+ s.once('error', () => resolve(false));
71
+ s.setTimeout(1000, () => { s.destroy(); resolve(false); });
72
+ });
73
+ for (let i = 0; i < 40; i++) {
74
+ await new Promise((r) => setTimeout(r, 750));
75
+ const fresh = await fs
76
+ .readFile(logFile)
77
+ .then((b) => b.subarray(offset).toString('utf8'))
78
+ .catch(() => '');
79
+ if (/relay: conectado|relay ativo/.test(fresh))
80
+ return { ok: true, detail: 'conectado' };
81
+ if (/login expirado/.test(fresh))
82
+ return { ok: false, detail: 'o login do agente não vale — rode `paris logout` e `paris conectar`' };
83
+ }
84
+ // Sem relay configurado (dev local) o agente só escuta a porta local.
85
+ return (await portOpen())
86
+ ? { ok: true, detail: 'rodando (sem relay)' }
87
+ : { ok: false, detail: 'o agente não subiu' };
88
+ }
89
+ async function runHookCheck(dir) {
90
+ const hook = path.join(dir, '.paris', 'hooks', 'paris-hook.mjs');
91
+ try {
92
+ // `node` do PATH, igual ao Claude Code — se não achar, o hook do aluno
93
+ // também não roda, e é isso que queremos descobrir.
94
+ const { stdout } = await execFileAsync('node', [hook, 'check'], {
95
+ cwd: dir,
96
+ timeout: 60_000,
97
+ env: { ...process.env, CLAUDE_PROJECT_DIR: dir },
98
+ });
99
+ const line = stdout.trim().split('\n').pop() ?? '';
100
+ return JSON.parse(line);
101
+ }
102
+ catch (err) {
103
+ const e = err;
104
+ if (e.code === 'ENOENT')
105
+ return { error: '`node` não está no PATH — o Claude Code não consegue rodar o hook. Reinstale pelo instalador da imersão.' };
106
+ return { error: (e.stderr || e.message || String(err)).trim().slice(0, 300) };
107
+ }
108
+ }
109
+ // Windows: `paris`/`claude` são shims .cmd — precisam de shell pra resolver.
110
+ const SHELL = process.platform === 'win32';
111
+ export async function conectarCommand(opts = {}) {
112
+ console.log(c.bold('paris conectar'));
113
+ console.log();
114
+ // 0. Versão mais nova no npm? Atualiza e roda o conectar DA VERSÃO NOVA (o
115
+ // hook que vai pro workspace sai do template dela). É isto que substitui o
116
+ // `npm i -g paris-immersion@latest && ...` colado à mão no treino de 30/09.
117
+ if (!process.env.PARIS_CONECTAR_UPDATED) {
118
+ const updated = await maybeSelfUpdate((m) => console.log(c.dim(m)), true);
119
+ if (updated) {
120
+ const args = ['conectar', ...(opts.open === false ? ['--no-open'] : [])];
121
+ const r = spawnSync('paris', args, {
122
+ stdio: 'inherit',
123
+ shell: SHELL,
124
+ env: { ...process.env, PARIS_CONECTAR_UPDATED: '1' },
125
+ });
126
+ if (!r.error) {
127
+ process.exitCode = r.status ?? 0;
128
+ return;
129
+ }
130
+ console.log(c.yellow('○ Atualizei, mas não consegui reabrir o paris — seguindo nesta versão.'));
131
+ }
132
+ }
133
+ // 1. Login
134
+ let creds = await readCredentials().catch(() => null);
135
+ let tk = creds ? await tokenOk() : { ok: false, status: 401 };
136
+ if (!tk.ok && tk.status === 0) {
137
+ console.log(c.red(`✗ Sem conexão com o servidor (${tk.detail}).`));
138
+ console.log(c.dim(' Confira o Wi-Fi (ou use o roteador do celular) e rode `paris conectar` de novo.'));
139
+ console.log(c.dim(' Nada se perde: o que você fizer offline fica guardado e sobe quando a conexão voltar.'));
140
+ process.exitCode = 1;
141
+ return;
142
+ }
143
+ if (!tk.ok && (tk.status === 401 || tk.status === 403)) {
144
+ console.log(c.yellow('○ Seu login venceu ou não existe — vamos entrar de novo.'));
145
+ await loginCommand({ apiUrl: creds?.api_url });
146
+ creds = await readCredentials().catch(() => null);
147
+ tk = creds ? await tokenOk() : { ok: false, status: 401 };
148
+ }
149
+ if (!tk.ok) {
150
+ const why = tk.status === 410 ? 'a imersão já foi encerrada pra esta conta' : `${tk.status ?? ''} ${tk.detail ?? ''}`.trim();
151
+ console.log(c.red(`✗ Login não funcionou: ${why}.`));
152
+ console.log(c.dim(' Chame o mentor da sua mesa.'));
153
+ process.exitCode = 1;
154
+ return;
155
+ }
156
+ console.log(c.green(`✓ Login ok (${creds.email})`));
157
+ // 2. Workspace + hooks frescos
158
+ let wsDir = await workspaceForSlug(tk.slug);
159
+ if (!wsDir) {
160
+ // Mesmo lugar previsível que o terminal embutido usa (~/paris-<slug>).
161
+ console.log(c.yellow('○ Não achei a pasta da turma — criando agora.'));
162
+ wsDir = tk.slug ? path.join(homedir(), `paris-${tk.slug}`) : (await ensureWorkspace()).dir;
163
+ }
164
+ await installCommand({ dir: wsDir });
165
+ console.log(c.green(`✓ Pasta e ferramentas do Claude atualizadas (${wsDir})`));
166
+ // 3. Envio real pelo hook
167
+ const r = await runHookCheck(wsDir);
168
+ if ('error' in r) {
169
+ console.log(c.red(`✗ O hook do Claude não rodou: ${r.error}`));
170
+ process.exitCode = 1;
171
+ return;
172
+ }
173
+ if (r.lastStatus < 200 || r.lastStatus >= 300) {
174
+ const why = r.lastStatus === 0 ? 'sem resposta do servidor (rede)' : `servidor respondeu ${r.lastStatus}`;
175
+ console.log(c.red(`✗ Envio falhou: ${why}. ${r.pending} evento(s) seguem guardados.`));
176
+ console.log(c.dim(' Rode `paris conectar` de novo em 1 minuto.'));
177
+ process.exitCode = 1;
178
+ return;
179
+ }
180
+ console.log(c.green(`✓ Envio ok (${r.sent} evento(s) enviados${r.pending ? `, ${r.pending} ainda na fila` : ''})`));
181
+ // 4. Agente local do curso (botão "abrir o curso" da War Room)
182
+ const agent = await ensureLocalAgent();
183
+ if (!agent.ok) {
184
+ console.log(c.red(`✗ Agente do curso: ${agent.detail}. Chame um mentor técnico.`));
185
+ process.exitCode = 1;
186
+ return;
187
+ }
188
+ console.log(c.green(`✓ Agente do curso ${agent.detail}`));
189
+ // 5. Prova do lado do servidor
190
+ try {
191
+ const st = await apiAuthFetch('/api/activity/status/me');
192
+ const age = st.lastActivity ? Date.now() - new Date(st.lastActivity).getTime() : Infinity;
193
+ if (age > 5 * 60_000) {
194
+ console.log(c.red('✗ O servidor não registrou o evento — a conta logada não está na turma.'));
195
+ console.log(c.dim(` Você entrou como ${creds.email}. Se não é o e-mail da inscrição, rode \`paris logout\` e \`paris conectar\`.`));
196
+ process.exitCode = 1;
197
+ return;
198
+ }
199
+ }
200
+ catch (err) {
201
+ console.log(c.yellow(`○ Não consegui confirmar no servidor (${err instanceof Error ? err.message : String(err)}), mas o envio passou.`));
202
+ }
203
+ console.log();
204
+ console.log(c.green(c.bold('Conectado. O mentor está vendo seu terminal.')));
205
+ // Hooks e MCP só recarregam num Claude novo — reabre na pasta da imersão,
206
+ // continuando a última conversa (igual ao `paris repair`).
207
+ if (opts.open === false) {
208
+ console.log(c.dim('Se o Claude já estava aberto: saia com /exit e abra de novo na pasta da imersão com `claude --continue`.'));
209
+ return;
210
+ }
211
+ console.log(c.dim('Abrindo o Claude na pasta da imersão (continuando a última conversa)...'));
212
+ console.log();
213
+ const cont = spawnSync('claude', ['--continue'], { cwd: wsDir, stdio: 'inherit', shell: SHELL });
214
+ if (cont.error || (cont.status !== 0 && cont.status !== null)) {
215
+ spawnSync('claude', [], { cwd: wsDir, stdio: 'inherit', shell: SHELL });
216
+ }
217
+ }
@@ -7,6 +7,6 @@
7
7
  * usuário (`claude mcp add --scope user`). A partir daí ele pergunta em
8
8
  * linguagem natural ("como está o João?", "quem travou hoje?") e o Claude
9
9
  * responde com dado real via tools turma_overview / find_student /
10
- * student_activity / who_needs_help / add_note.
10
+ * student_activity / who_needs_help / add_note / minha_mesa.
11
11
  */
12
12
  export declare function mentorCommand(): Promise<void>;
@@ -14,7 +14,7 @@ import { c } from '../lib/colors.js';
14
14
  * usuário (`claude mcp add --scope user`). A partir daí ele pergunta em
15
15
  * linguagem natural ("como está o João?", "quem travou hoje?") e o Claude
16
16
  * responde com dado real via tools turma_overview / find_student /
17
- * student_activity / who_needs_help / add_note.
17
+ * student_activity / who_needs_help / add_note / minha_mesa.
18
18
  */
19
19
  export async function mentorCommand() {
20
20
  const creds = await readCredentials();
@@ -1,3 +1,12 @@
1
+ /**
2
+ * `paris repair` — uma palavra só, sem `cd` nem `&&`. Existe porque o comando
3
+ * longo colado do WhatsApp quebrava (o app troca espaços por NBSP e o shell lê
4
+ * `cd: too many arguments`). Acha o workspace sozinho (a partir da pasta atual,
5
+ * subindo; senão o `~/paris-*` mais recente), reescreve hooks, MCP (.mcp.json),
6
+ * settings e CLAUDE.md SEM tocar no trabalho do aluno, e reabre o Claude na
7
+ * raiz continuando a última conversa.
8
+ */
9
+ export declare function locateWorkspace(): Promise<string | null>;
1
10
  export declare function repairCommand(opts: {
2
11
  open?: boolean;
3
12
  }): Promise<void>;
@@ -13,7 +13,7 @@ import { installCommand } from './install.js';
13
13
  * settings e CLAUDE.md SEM tocar no trabalho do aluno, e reabre o Claude na
14
14
  * raiz continuando a última conversa.
15
15
  */
16
- async function locateWorkspace() {
16
+ export async function locateWorkspace() {
17
17
  const here = await findMarker();
18
18
  if (here)
19
19
  return here.dir;
package/dist/index.js CHANGED
@@ -4,6 +4,7 @@ import { createRequire } from 'node:module';
4
4
  import { loginCommand } from './commands/login.js';
5
5
  import { installCommand } from './commands/install.js';
6
6
  import { repairCommand } from './commands/repair.js';
7
+ import { conectarCommand } from './commands/conectar.js';
7
8
  import { doctorCommand } from './commands/doctor.js';
8
9
  import { tasksCommand } from './commands/tasks.js';
9
10
  import { statusCommand } from './commands/status.js';
@@ -38,6 +39,11 @@ program
38
39
  .description('Conserta a pasta da imersão (hooks, ferramentas do Claude) sem apagar nada e reabre o Claude')
39
40
  .option('--no-open', 'só conserta, não abre o Claude')
40
41
  .action(repairCommand);
42
+ program
43
+ .command('conectar')
44
+ .description('Comando de emergência: atualiza a CLI, reloga se preciso, conserta a pasta, prova que o mentor está te vendo e reabre o Claude')
45
+ .option('--no-open', 'só conecta, não abre o Claude')
46
+ .action(conectarCommand);
41
47
  program
42
48
  .command('doctor')
43
49
  .description('Diagnostica credentials, marker, hooks, conectividade')
@@ -13,4 +13,8 @@ export declare function startRelayAgent(opts: {
13
13
  loginToken: string;
14
14
  cwd: string;
15
15
  log?: (msg: string) => void;
16
+ /** Lê o token de login atual do disco (default: ~/.paris/credentials). Teste injeta. */
17
+ readLoginToken?: () => Promise<string | null>;
18
+ /** Intervalo entre releituras do login enquanto ele está inválido (default 15s). */
19
+ unauthRetryMs?: number;
16
20
  }): Promise<RelayAgentHandle | null>;
@@ -39,6 +39,11 @@ function open(key, frame) {
39
39
  return null;
40
40
  }
41
41
  }
42
+ async function defaultReadLoginToken() {
43
+ const { readCredentials } = await import('./credentials.js');
44
+ const creds = await readCredentials().catch(() => null);
45
+ return creds?.token ?? null;
46
+ }
42
47
  /**
43
48
  * Inicia o agente relay. Busca o serve-token + info do relay na API (Bearer
44
49
  * creds.token), e se o relay estiver configurado, mantém a conexão wss e spawna
@@ -48,10 +53,28 @@ export async function startRelayAgent(opts) {
48
53
  if (!opts.apiUrl || !opts.loginToken)
49
54
  return null;
50
55
  const log = opts.log ?? (() => { });
56
+ // O loginToken NÃO é fixo: o aluno pode relogar (`paris login`/`paris conectar`)
57
+ // com o daemon rodando. Antes o agente guardava o token do boot pra sempre e,
58
+ // depois de um re-login, ficava a noite inteira em "login expirado" até alguém
59
+ // rodar `paris restart` (visto no D1 de outubro, 06/10 07:59). Agora, a cada
60
+ // 'unauthorized', ele relê ~/.paris/credentials e troca pelo token novo.
61
+ let loginToken = opts.loginToken;
62
+ const reloadLoginToken = async () => {
63
+ try {
64
+ const creds = await (opts.readLoginToken ?? defaultReadLoginToken)();
65
+ if (creds && creds !== loginToken) {
66
+ loginToken = creds;
67
+ log('paris: login novo detectado — reconectando');
68
+ return true;
69
+ }
70
+ }
71
+ catch { /* sem arquivo de credenciais: segue esperando */ }
72
+ return false;
73
+ };
51
74
  const fetchInfo = async () => {
52
75
  try {
53
76
  const res = await fetch(`${opts.apiUrl.replace(/\/$/, '')}/api/onboarding/v2/serve-token`, {
54
- headers: { authorization: `Bearer ${opts.loginToken}` },
77
+ headers: { authorization: `Bearer ${loginToken}` },
55
78
  signal: AbortSignal.timeout(8000),
56
79
  });
57
80
  if (res.ok)
@@ -85,9 +108,15 @@ export async function startRelayAgent(opts) {
85
108
  await sleep(5000);
86
109
  initial = await fetchInfo();
87
110
  }
88
- if (initial.kind === 'unauthorized') {
111
+ // Login inválido no boot: em vez de desistir do relay (o agente ficava fora até
112
+ // um restart manual), espera o aluno relogar — relê o arquivo de login a cada
113
+ // `unauthRetryMs` e segue assim que ele mudar e a API aceitar.
114
+ const unauthRetryMs = opts.unauthRetryMs ?? 15_000;
115
+ while (initial.kind === 'unauthorized') {
89
116
  log(RELOGIN_HINT);
90
- return null;
117
+ await sleep(unauthRetryMs);
118
+ await reloadLoginToken();
119
+ initial = await fetchInfo(); // mesmo token pode passar (ex.: participante criado depois)
91
120
  }
92
121
  if (initial.kind !== 'ok' || !initial.info.relayUrl || !initial.info.token || !initial.info.e2eKeyB64)
93
122
  return null;
@@ -255,8 +284,16 @@ export async function startRelayAgent(opts) {
255
284
  // Token de login morto: NÃO re-disca com o velho (o relay 401-a e o loop
256
285
  // nunca surfacing). Loga o caminho de re-login e agenda uma tentativa
257
286
  // espaçada — se o aluno rodar `paris login`, a próxima busca passa e reata.
287
+ // Login trocado no disco (re-login com o daemon rodando)? Usa o novo e
288
+ // reconecta já. Senão espaça e loga o caminho de re-login.
289
+ if (await reloadLoginToken()) {
290
+ nextRetryMs = RETRY_FAST_MS;
291
+ if (!closed)
292
+ reconnect = setTimeout(connect, 0);
293
+ return;
294
+ }
258
295
  log(RELOGIN_HINT);
259
- nextRetryMs = RETRY_UNAUTH_MS;
296
+ nextRetryMs = opts.unauthRetryMs ?? RETRY_UNAUTH_MS;
260
297
  if (!closed)
261
298
  reconnect = setTimeout(connect, nextRetryMs);
262
299
  return;
@@ -25,7 +25,7 @@ import { homedir } from 'node:os'
25
25
  import path from 'node:path'
26
26
  import { execFile } from 'node:child_process'
27
27
 
28
- const PHASE = process.argv[2] // session-start | user-prompt-submit | stop | post-tool-use
28
+ const PHASE = process.argv[2] // session-start | user-prompt-submit | stop | post-tool-use | check
29
29
 
30
30
  const FETCH_TIMEOUT_MS = 4000
31
31
  const QUEUE_FLUSH_AT = 25 // flush quando a fila acumula isso
@@ -159,34 +159,78 @@ function enqueueActivity(markerDir, event) {
159
159
  } catch { /* fila é best-effort */ }
160
160
  }
161
161
 
162
+ function parseLines(raw) {
163
+ const events = []
164
+ for (const l of raw.split('\\n')) {
165
+ if (!l) continue
166
+ try { events.push(JSON.parse(l)) } catch {}
167
+ }
168
+ return events
169
+ }
170
+
162
171
  async function readQueue(markerDir) {
163
172
  try {
164
- const raw = await fs.readFile(queuePath(markerDir), 'utf-8')
165
- const lines = raw.split('\\n').filter(Boolean)
166
- // Cap duro: descarta o mais antigo.
167
- const kept = lines.length > QUEUE_MAX_LINES ? lines.slice(lines.length - QUEUE_MAX_LINES) : lines
168
- const events = []
169
- for (const l of kept) { try { events.push(JSON.parse(l)) } catch {} }
170
- return events
173
+ return parseLines(await fs.readFile(queuePath(markerDir), 'utf-8'))
171
174
  } catch { return [] }
172
175
  }
173
176
 
174
- async function writeQueue(markerDir, events) {
177
+ // Devolve eventos não enviados pra fila com APPEND (nunca sobrescreve — o que
178
+ // outro hook enfileirou enquanto a rede rodava continua lá). Cap duro aplicado
179
+ // no próximo claim.
180
+ function requeue(markerDir, events) {
181
+ if (events.length === 0) return
175
182
  try {
176
- if (events.length === 0) {
177
- await fs.rm(queuePath(markerDir), { force: true })
178
- } else {
179
- await fs.writeFile(queuePath(markerDir), events.map((e) => JSON.stringify(e)).join('\\n') + '\\n')
180
- }
183
+ appendFileSync(queuePath(markerDir), events.map((e) => JSON.stringify(e)).join('\\n') + '\\n')
181
184
  } catch { /* best-effort */ }
182
185
  }
183
186
 
184
- // Flush em lotes de BATCH_SIZE. Trunca em 2xx E em 4xx (fila envenenada não
185
- // pode travar pra sempre); retém em 5xx/rede pra tentar de novo depois.
187
+ // Status em que o lote é VENENO (nunca vai passar) → descarta. Todo o resto
188
+ // (rede, 5xx, 401/403 de token, 408, 429 de rate-limit) RETÉM pra tentar depois.
189
+ // Antes, qualquer 4xx descartava — um token vencido ou um 429 apagava a fila.
190
+ function isPoison(status) {
191
+ return status === 400 || status === 413 || status === 422
192
+ }
193
+
194
+ // Flush em lotes de BATCH_SIZE com CLAIM ATÔMICO: renomeia a fila pra um arquivo
195
+ // .sending exclusivo deste processo antes de ler. Assim hooks concorrentes
196
+ // (tool calls em paralelo, Stop + PostToolUse) nunca mandam o mesmo evento duas
197
+ // vezes nem apagam o que foi enfileirado durante o envio (o writeFile antigo
198
+ // sobrescrevia a fila inteira — eventos e PROMPTS sumiam). Também adota .sending
199
+ // órfãos (> 2 min) de um hook que morreu no meio.
200
+ // Retorna { sent, pending, lastStatus }.
186
201
  async function flushActivityQueue(apiUrl, token, markerDir) {
187
- const events = await readQueue(markerDir)
188
- if (events.length === 0) return
202
+ const dir = path.join(markerDir, '.paris')
203
+ const claimed = path.join(dir, 'activity-queue.' + process.pid + '-' + Date.now() + '.sending')
204
+ let events = []
205
+ try {
206
+ await fs.rename(queuePath(markerDir), claimed)
207
+ events = parseLines(await fs.readFile(claimed, 'utf-8'))
208
+ } catch { /* fila vazia ou outro hook pegou */ }
209
+ try {
210
+ const now = Date.now()
211
+ for (const name of await fs.readdir(dir)) {
212
+ if (!name.endsWith('.sending')) continue
213
+ const fp = path.join(dir, name)
214
+ if (fp === claimed) continue
215
+ const st = await fs.stat(fp).catch(() => null)
216
+ if (!st || now - st.mtimeMs < 120000) continue
217
+ const orphan = path.join(dir, name + '.adopt-' + process.pid)
218
+ try {
219
+ await fs.rename(fp, orphan)
220
+ events = events.concat(parseLines(await fs.readFile(orphan, 'utf-8')))
221
+ await fs.rm(orphan, { force: true })
222
+ } catch {}
223
+ }
224
+ } catch {}
225
+ if (events.length > QUEUE_MAX_LINES) events = events.slice(events.length - QUEUE_MAX_LINES)
226
+ if (events.length === 0) {
227
+ await fs.rm(claimed, { force: true }).catch(() => {})
228
+ return { sent: 0, pending: 0, lastStatus: 200 }
229
+ }
230
+
189
231
  let idx = 0
232
+ let sent = 0
233
+ let lastStatus = 0
190
234
  while (idx < events.length) {
191
235
  const chunk = events.slice(idx, idx + BATCH_SIZE)
192
236
  const status = await apiPostStatus(apiUrl, '/api/activity/claude-code', token, {
@@ -194,11 +238,16 @@ async function flushActivityQueue(apiUrl, token, markerDir) {
194
238
  cwd: chunk[chunk.length - 1].cwd,
195
239
  events: chunk,
196
240
  })
197
- const dropChunk = (status >= 200 && status < 300) || (status >= 400 && status < 500)
198
- if (!dropChunk) break // 5xx/rede: para e retém o resto
241
+ lastStatus = status
242
+ const ok = status >= 200 && status < 300
243
+ if (!ok && !isPoison(status)) break // rede/5xx/401/429: retém o resto
244
+ if (ok) sent += chunk.length
199
245
  idx += chunk.length
200
246
  }
201
- await writeQueue(markerDir, events.slice(idx))
247
+ const rest = events.slice(idx)
248
+ requeue(markerDir, rest)
249
+ await fs.rm(claimed, { force: true }).catch(() => {})
250
+ return { sent, pending: rest.length, lastStatus }
202
251
  }
203
252
 
204
253
  async function refreshMarkerStatus(markerDir, marker, status) {
@@ -315,6 +364,33 @@ async function main() {
315
364
  return
316
365
  }
317
366
 
367
+ // PROMPT PRIMEIRO, antes de qualquer rede: vai pra fila local com o horário
368
+ // real. Antes o prompt só subia DEPOIS de 3 GETs de contexto — se a API
369
+ // demorasse/caísse o hook saía cedo e o prompt sumia; e não havia retry.
370
+ // Agora ele fica na fila até o servidor confirmar.
371
+ let promptFlush = null
372
+ if (PHASE === 'user-prompt-submit' && hookInput && typeof hookInput.prompt === 'string' && hookInput.prompt.trim()) {
373
+ enqueueActivity(marker.dir, {
374
+ hook_event_name: 'UserPromptSubmit',
375
+ prompt: truncate(hookInput.prompt, 8000),
376
+ session_id: hookInput.session_id,
377
+ cwd: hookInput.cwd,
378
+ at: Date.now(),
379
+ })
380
+ // Sobe em paralelo com a busca de contexto (não soma latência).
381
+ promptFlush = flushActivityQueue(apiUrl, creds.token, marker.dir).catch(() => null)
382
+ }
383
+
384
+ // \`paris conectar\` roda o PRÓPRIO hook com 'check': enfileira um evento de
385
+ // conexão e faz o flush pelo caminho real (mesmo node, mesma fila, mesmo
386
+ // token, mesma URL). Imprime o resultado em JSON pro comando ler.
387
+ if (PHASE === 'check') {
388
+ enqueueActivity(marker.dir, { hook_event_name: 'ConnectionCheck', cwd: marker.dir, at: Date.now() })
389
+ const r = await flushActivityQueue(apiUrl, creds.token, marker.dir)
390
+ process.stdout.write(JSON.stringify({ ...r, apiUrl }) + '\\n')
391
+ return
392
+ }
393
+
318
394
  if (PHASE === 'stop') {
319
395
  await apiPost(apiUrl, '/immersion-app/v1/events', creds.token, { type: 'turn_ended' })
320
396
  await flushActivityQueue(apiUrl, creds.token, marker.dir)
@@ -332,18 +408,23 @@ async function main() {
332
408
  ])
333
409
 
334
410
  if (!workspace) {
335
- process.stderr.write('[paris-hook] não foi possível buscar contexto. Está offline?\\n')
411
+ if (promptFlush) await promptFlush
412
+ process.stderr.write('[paris-hook] não foi possível buscar contexto. Está offline? (seu prompt ficou salvo e sobe quando a conexão voltar)\\n')
336
413
  return
337
414
  }
338
415
  // Mantém o marker em dia com o servidor — é o marker que silencia o
339
416
  // PostToolUse pós-graduação (o caminho quente não consulta a API).
340
417
  await refreshMarkerStatus(marker.dir, marker.marker, workspace.status)
341
- if (workspace.status === 'graduated') return
418
+ if (workspace.status === 'graduated') { if (promptFlush) await promptFlush; return }
342
419
 
343
420
  // Onboarding: enquanto o curso não termina, o terminal roda o tutor do curso
344
421
  // (/tostudy) — isso tem prioridade sobre as missões da imersão (que nem existem
345
422
  // na fase pre).
346
423
  const inCourse = !!(course && course.courseCompleted === false)
424
+ // Timer da turma: a Missão 1 (o curso) ainda não abriu — D1 cedo, na abertura
425
+ // com o apresentador. Sem isto o hook mandava rodar /tostudy às 08:00 e o
426
+ // Claude começava o curso no meio da apresentação.
427
+ const courseNotOpenYet = inCourse && !!(current && current.reason === 'waiting_next_window' && current.current && current.current.status === 'locked')
347
428
 
348
429
  const lines = []
349
430
  if (PHASE === 'session-start') {
@@ -357,7 +438,14 @@ async function main() {
357
438
  lines.push('')
358
439
  }
359
440
 
360
- if (inCourse) {
441
+ if (courseNotOpenYet) {
442
+ lines.push('## ANTES DA PRIMEIRA MISSÃO')
443
+ lines.push(current.current.socio_prompt || ('A primeira missão abre no horário do bloco dela. Não rode /tostudy antes.'))
444
+ if (PHASE === 'session-start') {
445
+ lines.push('')
446
+ lines.push('**Comece falando primeiro**, em 2 ou 3 linhas: confirme que está tudo conectado e diga a que horas a Missão 1 começa.')
447
+ }
448
+ } else if (inCourse) {
361
449
  lines.push('## FASE DO CURSO DE ONBOARDING')
362
450
  lines.push('O aluno está na etapa do curso **' + course.title + '** (progresso: **' + course.lessonsCompleted + '/' + course.totalLessons + '**).')
363
451
  lines.push('Rode **/tostudy** pra conduzir o curso da ToStudy (o comando está instalado no workspace e o aluno já chega logado). Ele ativa o curso e ensina lição por lição. NÃO trabalhe em missões da imersão enquanto o curso não terminar. (Fallback: ferramentas MCP **get_course**/**complete_lesson** do paris.)')
@@ -406,14 +494,8 @@ async function main() {
406
494
  // turn_started fecha o ciclo do turno (com turn_ended no stop) → permite medir
407
495
  // cadência/duração de turno e detectar "mandou prompt e não houve resposta".
408
496
  await apiPost(apiUrl, '/immersion-app/v1/events', creds.token, { type: 'turn_started' })
409
- if (hookInput && hookInput.prompt) {
410
- await apiPost(apiUrl, '/api/activity/claude-code', creds.token, {
411
- hook_event_name: 'UserPromptSubmit',
412
- prompt: hookInput.prompt,
413
- session_id: hookInput.session_id,
414
- cwd: hookInput.cwd,
415
- })
416
- }
497
+ // O prompt já está na fila (topo do main) — aqui só espera o envio terminar.
498
+ if (promptFlush) await promptFlush
417
499
  }
418
500
 
419
501
  // Na fase do curso, sincroniza o progresso da ToStudy também ao abrir/retomar
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "paris-immersion",
3
- "version": "0.1.36",
3
+ "version": "0.1.39",
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": {
@@ -3107,9 +3107,9 @@ var require_data = __commonJS({
3107
3107
  }
3108
3108
  });
3109
3109
 
3110
- // ../../node_modules/.pnpm/fast-uri@3.1.7/node_modules/fast-uri/lib/utils.js
3110
+ // ../../node_modules/.pnpm/fast-uri@3.1.8/node_modules/fast-uri/lib/utils.js
3111
3111
  var require_utils = __commonJS({
3112
- "../../node_modules/.pnpm/fast-uri@3.1.7/node_modules/fast-uri/lib/utils.js"(exports, module) {
3112
+ "../../node_modules/.pnpm/fast-uri@3.1.8/node_modules/fast-uri/lib/utils.js"(exports, module) {
3113
3113
  "use strict";
3114
3114
  var isUUID = RegExp.prototype.test.bind(/^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/iu);
3115
3115
  var isIPv4 = RegExp.prototype.test.bind(/^(?:(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]\d|\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]\d|\d)$/u);
@@ -3609,9 +3609,9 @@ var require_utils = __commonJS({
3609
3609
  }
3610
3610
  });
3611
3611
 
3612
- // ../../node_modules/.pnpm/fast-uri@3.1.7/node_modules/fast-uri/lib/schemes.js
3612
+ // ../../node_modules/.pnpm/fast-uri@3.1.8/node_modules/fast-uri/lib/schemes.js
3613
3613
  var require_schemes = __commonJS({
3614
- "../../node_modules/.pnpm/fast-uri@3.1.7/node_modules/fast-uri/lib/schemes.js"(exports, module) {
3614
+ "../../node_modules/.pnpm/fast-uri@3.1.8/node_modules/fast-uri/lib/schemes.js"(exports, module) {
3615
3615
  "use strict";
3616
3616
  var { isUUID } = require_utils();
3617
3617
  var URN_REG = /^([\da-z][\d\-a-z]{0,31}):((?:[\w!$'()*+,\-./:;=@]|%[\da-f]{2})+)$/iu;
@@ -3820,9 +3820,9 @@ var require_schemes = __commonJS({
3820
3820
  }
3821
3821
  });
3822
3822
 
3823
- // ../../node_modules/.pnpm/fast-uri@3.1.7/node_modules/fast-uri/index.js
3823
+ // ../../node_modules/.pnpm/fast-uri@3.1.8/node_modules/fast-uri/index.js
3824
3824
  var require_fast_uri = __commonJS({
3825
- "../../node_modules/.pnpm/fast-uri@3.1.7/node_modules/fast-uri/index.js"(exports, module) {
3825
+ "../../node_modules/.pnpm/fast-uri@3.1.8/node_modules/fast-uri/index.js"(exports, module) {
3826
3826
  "use strict";
3827
3827
  var { normalizeIPv6, removeDotSegments, recomposeAuthority, normalizePercentEncoding, normalizePathEncoding, serializePathEncoding, normalizeQueryFragmentEncoding, encodeQuery, encodeFragment, reescapeHostDelimiters, isIPv4, nonSimpleDomain } = require_utils();
3828
3828
  var { SCHEMES, getSchemeHandler } = require_schemes();
@@ -4152,13 +4152,14 @@ var require_fast_uri = __commonJS({
4152
4152
  if (!malformedIPLiteral) {
4153
4153
  malformedHost = canonicalizeHost(parsed, options, schemeHandler, isIP);
4154
4154
  }
4155
- if (!schemeHandler || schemeHandler && !schemeHandler.skipNormalize) {
4156
- if (uri.indexOf("%") !== -1) {
4157
- if (parsed.host !== void 0 && !malformedIPLiteral) {
4158
- const host = isIP ? parsed.host : normalizePercentEncoding(parsed.host, true);
4159
- parsed.host = reescapeHostDelimiters(host, isIP);
4160
- }
4155
+ if (uri.indexOf("%") !== -1 && parsed.host !== void 0 && !malformedIPLiteral) {
4156
+ let host = isIP ? parsed.host : normalizePercentEncoding(parsed.host, true);
4157
+ if (!isIP) {
4158
+ host = normalizePercentEncoding(host.toLowerCase());
4161
4159
  }
4160
+ parsed.host = reescapeHostDelimiters(host, isIP);
4161
+ }
4162
+ if (!schemeHandler || schemeHandler && !schemeHandler.skipNormalize) {
4162
4163
  if (parsed.path) {
4163
4164
  parsed.path = normalizePathEncoding(parsed.path);
4164
4165
  }
@@ -18485,6 +18486,12 @@ function buildAdminTools() {
18485
18486
  note: external_exports.string().min(1)
18486
18487
  }),
18487
18488
  handler: async ({ participantId, note }, { client }) => client.post(`/api/participants/${participantId}/notes`, { note })
18489
+ },
18490
+ {
18491
+ name: "minha_mesa",
18492
+ description: 'Sua mesa de mentoria HOJE: r\xF3tulo da mesa, dia da imers\xE3o, os alunos dela (status ao vivo, miss\xE3o atual, pedidos de ajuda pendentes), compara\xE7\xE3o mesa \xD7 turma e o hist\xF3rico das suas mesas por dia. Use quando o mentor perguntar "como est\xE1 a minha mesa?". Se voc\xEA ainda n\xE3o tem mesa hoje, mostra os alunos sem mesa (assuma pelo painel web em Minha mesa).',
18493
+ inputSchema: external_exports.object({}),
18494
+ handler: async (_, { client }) => client.get("/api/mesas/minha")
18488
18495
  }
18489
18496
  ];
18490
18497
  }
@@ -3107,9 +3107,9 @@ var require_data = __commonJS({
3107
3107
  }
3108
3108
  });
3109
3109
 
3110
- // ../../node_modules/.pnpm/fast-uri@3.1.7/node_modules/fast-uri/lib/utils.js
3110
+ // ../../node_modules/.pnpm/fast-uri@3.1.8/node_modules/fast-uri/lib/utils.js
3111
3111
  var require_utils = __commonJS({
3112
- "../../node_modules/.pnpm/fast-uri@3.1.7/node_modules/fast-uri/lib/utils.js"(exports, module) {
3112
+ "../../node_modules/.pnpm/fast-uri@3.1.8/node_modules/fast-uri/lib/utils.js"(exports, module) {
3113
3113
  "use strict";
3114
3114
  var isUUID = RegExp.prototype.test.bind(/^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/iu);
3115
3115
  var isIPv4 = RegExp.prototype.test.bind(/^(?:(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]\d|\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]\d|\d)$/u);
@@ -3609,9 +3609,9 @@ var require_utils = __commonJS({
3609
3609
  }
3610
3610
  });
3611
3611
 
3612
- // ../../node_modules/.pnpm/fast-uri@3.1.7/node_modules/fast-uri/lib/schemes.js
3612
+ // ../../node_modules/.pnpm/fast-uri@3.1.8/node_modules/fast-uri/lib/schemes.js
3613
3613
  var require_schemes = __commonJS({
3614
- "../../node_modules/.pnpm/fast-uri@3.1.7/node_modules/fast-uri/lib/schemes.js"(exports, module) {
3614
+ "../../node_modules/.pnpm/fast-uri@3.1.8/node_modules/fast-uri/lib/schemes.js"(exports, module) {
3615
3615
  "use strict";
3616
3616
  var { isUUID } = require_utils();
3617
3617
  var URN_REG = /^([\da-z][\d\-a-z]{0,31}):((?:[\w!$'()*+,\-./:;=@]|%[\da-f]{2})+)$/iu;
@@ -3820,9 +3820,9 @@ var require_schemes = __commonJS({
3820
3820
  }
3821
3821
  });
3822
3822
 
3823
- // ../../node_modules/.pnpm/fast-uri@3.1.7/node_modules/fast-uri/index.js
3823
+ // ../../node_modules/.pnpm/fast-uri@3.1.8/node_modules/fast-uri/index.js
3824
3824
  var require_fast_uri = __commonJS({
3825
- "../../node_modules/.pnpm/fast-uri@3.1.7/node_modules/fast-uri/index.js"(exports, module) {
3825
+ "../../node_modules/.pnpm/fast-uri@3.1.8/node_modules/fast-uri/index.js"(exports, module) {
3826
3826
  "use strict";
3827
3827
  var { normalizeIPv6, removeDotSegments, recomposeAuthority, normalizePercentEncoding, normalizePathEncoding, serializePathEncoding, normalizeQueryFragmentEncoding, encodeQuery, encodeFragment, reescapeHostDelimiters, isIPv4, nonSimpleDomain } = require_utils();
3828
3828
  var { SCHEMES, getSchemeHandler } = require_schemes();
@@ -4152,13 +4152,14 @@ var require_fast_uri = __commonJS({
4152
4152
  if (!malformedIPLiteral) {
4153
4153
  malformedHost = canonicalizeHost(parsed, options, schemeHandler, isIP);
4154
4154
  }
4155
- if (!schemeHandler || schemeHandler && !schemeHandler.skipNormalize) {
4156
- if (uri.indexOf("%") !== -1) {
4157
- if (parsed.host !== void 0 && !malformedIPLiteral) {
4158
- const host = isIP ? parsed.host : normalizePercentEncoding(parsed.host, true);
4159
- parsed.host = reescapeHostDelimiters(host, isIP);
4160
- }
4155
+ if (uri.indexOf("%") !== -1 && parsed.host !== void 0 && !malformedIPLiteral) {
4156
+ let host = isIP ? parsed.host : normalizePercentEncoding(parsed.host, true);
4157
+ if (!isIP) {
4158
+ host = normalizePercentEncoding(host.toLowerCase());
4161
4159
  }
4160
+ parsed.host = reescapeHostDelimiters(host, isIP);
4161
+ }
4162
+ if (!schemeHandler || schemeHandler && !schemeHandler.skipNormalize) {
4162
4163
  if (parsed.path) {
4163
4164
  parsed.path = normalizePathEncoding(parsed.path);
4164
4165
  }