auroq-os 2.5.7 → 2.5.9

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,252 @@
1
+ 'use strict';
2
+ const fs = require('node:fs');
3
+ const path = require('node:path');
4
+ const os = require('node:os');
5
+ const http = require('node:http');
6
+ const https = require('node:https');
7
+ const R = require('./runtime.cjs');
8
+ const S = require('./security.cjs');
9
+ function baseUrl() {
10
+ if (process.env.NODE_ENV === 'test' && process.env.AHI_TEST_MODE === '1' && process.env.AHI_TEST_URL) {
11
+ const url = new URL(process.env.AHI_TEST_URL);
12
+ if (!['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname)) throw new Error('UNSAFE_TEST_ENDPOINT');
13
+ return url.origin;
14
+ }
15
+ return 'https://arcane.arka.education';
16
+ }
17
+ function request(url, token, body, extraHeaders = {}) {
18
+ return new Promise(resolve => {
19
+ let finished = false; let timer;
20
+ const finish = value => { if (finished) return; finished = true; clearTimeout(timer); resolve(value); };
21
+ const target = new URL(url); const data = body === undefined ? null : JSON.stringify(body);
22
+ const transport = target.protocol === 'https:' ? https : http;
23
+ const req = transport.request(target, { method: data ? 'POST' : 'GET', headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(data ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) } : {}), ...extraHeaders }, timeout: 4000 }, res => {
24
+ let bytes = 0; let text = '';
25
+ res.on('data', chunk => { bytes += chunk.length; if (bytes > 128 * 1024) { req.destroy(); finish({ code: 0, error: 'RESPONSE_TOO_LARGE' }); } else text += chunk; });
26
+ res.on('end', () => { let json; try { json = JSON.parse(text); } catch {} const numericRetry = Number(res.headers['retry-after']); const retry = Number.isFinite(numericRetry) ? numericRetry * 1000 : Date.parse(res.headers['retry-after']) - Date.now(); finish({ code: res.statusCode, body: json, retryMs: Number.isFinite(retry) ? Math.min(300000, Math.max(0, retry)) : 0 }); });
27
+ });
28
+ req.on('error', () => finish({ code: 0, error: 'NETWORK_ERROR' }));
29
+ req.on('timeout', () => { req.destroy(); finish({ code: 0, error: 'NETWORK_TIMEOUT' }); });
30
+ timer = setTimeout(() => { req.destroy(); finish({ code: 0, error: 'NETWORK_TIMEOUT' }); }, 4000);
31
+ if (data) req.write(data); req.end();
32
+ });
33
+ }
34
+ async function getCredential(ctx) {
35
+ let cred = R.credential();
36
+ if (!cred || cred.user.id !== ctx.authId) return null;
37
+ if (Number(cred.expires_at) > Date.now() / 1000 + 60) return cred;
38
+ if (!cred.refresh_token) return cred;
39
+ if (!fs.existsSync(ctx.accountDir)) S.protect(ctx.accountDir);
40
+ const release = R.lock({ ...ctx, dir: ctx.accountDir }, 'auth.lock', 0);
41
+ if (!release) return cred;
42
+ try {
43
+ cred = R.credential();
44
+ if (!cred || cred.user.id !== ctx.authId) return null;
45
+ if (Number(cred.expires_at) > Date.now() / 1000 + 60) return cred;
46
+ const auth = require('./auth-config.cjs');
47
+ const endpoint = process.env.NODE_ENV === 'test' && process.env.AHI_TEST_MODE === '1' ? `${baseUrl()}/auth/v1/token?grant_type=refresh_token` : `${auth.url}/auth/v1/token?grant_type=refresh_token`;
48
+ const response = await request(endpoint, null, { refresh_token: cred.refresh_token }, { apikey: auth.publicKey });
49
+ if (response.code !== 200 || typeof response.body?.access_token !== 'string' || typeof response.body?.refresh_token !== 'string' || response.body?.user?.id !== ctx.authId) return R.credential()?.user?.id === ctx.authId ? R.credential() : null;
50
+ // MCP can also refresh this shared login. Reread after network and preserve a newer token/account.
51
+ const current = R.credential();
52
+ if (!current || current.user.id !== ctx.authId) return null;
53
+ if (current.access_token !== cred.access_token || current.refresh_token !== cred.refresh_token) return current;
54
+ const updated = { ...current, access_token: response.body.access_token, refresh_token: response.body.refresh_token, expires_at: response.body.expires_at || Math.floor(Date.now() / 1000) + response.body.expires_in };
55
+ S.atomic(path.join(os.homedir(), '.arcane', 'credentials.json'), updated);
56
+ const saved = R.credential(); return saved?.user?.id === ctx.authId ? saved : null;
57
+ } finally { release(); }
58
+ }
59
+ function discard(ctx, files, reason) {
60
+ let count = 0;
61
+ for (const file of files) { try { S.safePath(file); fs.unlinkSync(file); R.removeOrder(ctx, path.basename(file, '.json')); count++; } catch {} }
62
+ if (count) R.record(ctx, 'discarded', reason, count);
63
+ }
64
+ async function lease(ctx, session) {
65
+ if (!R.configured(ctx.project, ctx.host)) return { stop: true, reason: 'MCP_DISABLED' };
66
+ const leaseFile = path.join(ctx.dir, `lease-${S.hash(`${ctx.host}:${session}`)}.json`);
67
+ const cred = await getCredential(ctx); if (!cred) { S.atomic(leaseFile, { enabled: false }); return { stop: true, reason: 'ACCOUNT_CHANGED' }; }
68
+ const response = await request(`${baseUrl()}/api/ahi/v1/capture-status?${new URLSearchParams({ project_key: ctx.projectKey, host: ctx.host, native_session_id: session, adapter_version: R.VERSION })}`, cred.access_token);
69
+ const body = response.body;
70
+ if (response.code === 200 && body?.enabled === true && body.auth_user_id === ctx.authId) {
71
+ const expiry = Date.parse(body.expires_at); const duration = Math.min(55000, expiry - Date.now());
72
+ if (!Number.isFinite(duration) || duration <= 0) return { retry: true, reason: 'INVALID_LEASE' };
73
+ const publicKey = S.read(path.join(ctx.dir, 'public-key.json'))?.key;
74
+ if (!publicKey) return { stop: true, reason: 'SECURE_STORAGE_UNAVAILABLE' };
75
+ S.atomic(leaseFile, { enabled: true, host: ctx.host, native_session_id: session, auth_user_id: ctx.authId, subject_id: body.subject_id, policy_version: body.policy_version, key_fingerprint: S.hash(publicKey), until: Date.now() + duration, uptime: os.uptime(), untilUptime: os.uptime() + duration / 1000, boot: R.boot() });
76
+ return { credential: cred };
77
+ }
78
+ S.atomic(leaseFile, { enabled: false });
79
+ if (response.code === 401) return { stop: true, reason: 'AUTH_REQUIRED' };
80
+ if (response.code === 403 || (response.code === 200 && body?.enabled === false)) {
81
+ const scoped = ['DELETED', 'REVOKED'].includes(body?.reason);
82
+ if (scoped) { const sf = path.join(ctx.dir, `session-${S.hash(`${ctx.host}:${session}`)}.json`); const sessionState = S.read(sf, {}); S.atomic(sf, { ...sessionState, active: false, revoked: true }); }
83
+ discard(ctx, R.queueFiles(ctx).filter(f => S.read(f)?.session_key === S.hash(`${ctx.host}:${session}`)), response.code === 403 ? 'LICENSE_REVOKED' : 'CAPTURE_DISABLED');
84
+ return { stop: !scoped, sessionBlocked: scoped, reason: response.code === 403 ? 'LICENSE_REVOKED' : body?.reason || 'CAPTURE_DISABLED' };
85
+ }
86
+ return { retry: true, reason: response.error || 'STATUS_UNAVAILABLE', retryMs: response.retryMs };
87
+ }
88
+ function markTerminal(ctx, file, eventId, kind, reason) {
89
+ // Write tombstone before deleting ciphertext: crash/restart will finish cleanup, not replay it.
90
+ S.atomic(path.join(ctx.dir, `terminal-${eventId}.json`), { kind, reason, at: new Date().toISOString() });
91
+ try { fs.unlinkSync(file); R.removeOrder(ctx, eventId); } catch (e) { if (e.code !== 'ENOENT') throw e; }
92
+ }
93
+ async function drain(ctx, cred) {
94
+ // Configuration is checked after selecting the queued host below.
95
+ const fresh = await getCredential(ctx); if (!fresh || (cred && fresh.user.id !== cred.user.id)) return { stop: true, reason: 'ACCOUNT_CHANGED' };
96
+ const files = R.queueFiles(ctx).slice(0, 50);
97
+ if (!files.length) return { empty: true };
98
+ const key = S.keyFor(ctx);
99
+ const rows = [];
100
+ try {
101
+ for (const file of files) {
102
+ const eventId = path.basename(file, '.json');
103
+ if (S.read(path.join(ctx.dir, `terminal-${eventId}.json`))) { fs.unlinkSync(file); R.removeOrder(ctx, eventId); continue; }
104
+ try {
105
+ const row = S.open(S.read(file), key, ctx.scope);
106
+ if (row.project_key !== ctx.projectKey || row.event.event_id !== eventId) throw new Error('QUEUE_IDENTITY_MISMATCH');
107
+ if (Date.now() - row.captured_at > R.TTL) {
108
+ const gapId = S.id(`${eventId}:ttl`);
109
+ const gap = { ...row, captured_at: Date.now(), event: { event_id: gapId, source_event_id: `CoverageGap:${S.hash(gapId)}`, type: 'coverage.gap', occurred_at: new Date().toISOString(), evidence_kind: 'observed', redacted: false, truncated: true, payload: { reason: 'QUEUE_TTL', excluded_count: 1 } } };
110
+ const publicKey = S.read(path.join(ctx.dir, 'public-key.json'))?.key;
111
+ if (publicKey) R.queueRow(ctx, gap, publicKey);
112
+ markTerminal(ctx, file, eventId, 'discarded', 'QUEUE_TTL'); R.record(ctx, 'discarded', 'QUEUE_TTL'); continue;
113
+ }
114
+ rows.push({ file, row });
115
+ } catch {
116
+ // Preserve corrupt ciphertext for manual diagnosis; stop repeated upload attempts.
117
+ fs.renameSync(file, `${file}.corrupt`); R.removeOrder(ctx, eventId); R.record(ctx, 'excluded', 'QUEUE_CORRUPT');
118
+ }
119
+ }
120
+ } finally { key.fill(0); }
121
+ rows.sort((a, b) => a.row.captured_at - b.row.captured_at || (a.row.capture_order || 0) - (b.row.capture_order || 0) || a.row.event.event_id.localeCompare(b.row.event.event_id));
122
+ if (!rows.length) return { empty: true };
123
+ const first = rows[0].row;
124
+ ctx.host = first.host;
125
+ if (!R.configured(ctx.project, first.host)) { discard(ctx, rows.filter(x => x.row.host === first.host).map(x => x.file), 'MCP_DISABLED'); return { sent: 0 }; }
126
+ if (!R.validLease(ctx, first.native_session_id)) { const check = await lease(ctx, first.native_session_id); if (check.stop || check.retry || check.sessionBlocked) return check.sessionBlocked ? { sent: 0 } : check; }
127
+ const selected = rows.filter(x => x.row.native_session_id === first.native_session_id && x.row.host === first.host).slice(0, 6);
128
+ const envelope = { schema_version: 1, host: first.host, adapter_version: R.VERSION, project_key: ctx.projectKey, native_session_id: first.native_session_id, events: selected.map(x => x.row.event) };
129
+ const response = await request(`${baseUrl()}/api/ahi/v1/ingest`, fresh.access_token, envelope);
130
+ if (response.code === 401) return { stop: true, reason: 'AUTH_REQUIRED' };
131
+ if (response.code === 403 || response.code === 410) {
132
+ discard(ctx, R.queueFiles(ctx).filter(f => S.read(f)?.session_key === S.hash(`${first.host}:${first.native_session_id}`)), 'REVOKED');
133
+ if (response.code === 410) { const sf = path.join(ctx.dir, `session-${S.hash(`${first.host}:${first.native_session_id}`)}.json`); S.atomic(sf, { ...S.read(sf, {}), active: false, revoked: true }); return { sent: 0 }; }
134
+ return { stop: true, reason: 'LICENSE_REVOKED' };
135
+ }
136
+ if ([400, 413, 422, 426].includes(response.code)) { discard(ctx, selected.map(x => x.file), response.code === 426 ? 'UPGRADE_REQUIRED' : 'INVALID_ENVELOPE'); return { stop: true, reason: response.code === 426 ? 'UPGRADE_REQUIRED' : 'INVALID_ENVELOPE' }; }
137
+ if (response.code < 200 || response.code >= 300 || !Array.isArray(response.body?.accepted_ids) || !Array.isArray(response.body?.duplicate_ids)) return { retry: true, reason: response.error || 'INGEST_RETRY', retryMs: response.retryMs };
138
+ const receipt = response.body;
139
+ const ack = new Set([...receipt.accepted_ids, ...receipt.duplicate_ids]);
140
+ const rejects = new Map((Array.isArray(receipt.rejected) ? receipt.rejected : []).map(x => [x.id, x]));
141
+ let accepted = 0; let rejected = 0; let pending = 0;
142
+ for (const item of selected) {
143
+ const eventId = item.row.event.event_id;
144
+ if (ack.has(eventId)) { markTerminal(ctx, item.file, eventId, 'acked', 'DURABLE_ACK'); accepted++; }
145
+ else {
146
+ const rejection = rejects.get(eventId);
147
+ if (rejection && ['CONFLICT', 'DELETED', 'REVOKED', 'INVALID', 'INVALID_EVENT', 'EVENT_ID_CONFLICT'].includes(rejection.code)) { markTerminal(ctx, item.file, eventId, 'discarded', rejection.code); rejected++; }
148
+ else pending++;
149
+ }
150
+ }
151
+ if (accepted) {
152
+ const release = R.lock(ctx, 'state.lock');
153
+ if (release) { try { const state = R.state(ctx); R.update(ctx, { acked: state.acked + accepted, last_ack_at: new Date().toISOString(), last_problem: null }); } finally { release(); } }
154
+ }
155
+ if (rejected) R.record(ctx, 'discarded', 'SERVER_TERMINAL_REJECTION', rejected);
156
+ return pending ? { retry: true, reason: 'MISSING_DURABLE_ACK' } : { sent: accepted };
157
+ }
158
+ function cleanup(ctx) {
159
+ // Only v2 ciphertext/metadata generated by this collector; never legacy JSONL or host history.
160
+ for (const name of fs.readdirSync(ctx.outbox)) {
161
+ if (!/\.(corrupt|tmp)$/.test(name)) continue;
162
+ const file = path.join(ctx.outbox, name); const stat = fs.lstatSync(file);
163
+ if (stat.isFile() && Date.now() - stat.mtimeMs > R.TTL) { fs.unlinkSync(file); R.record(ctx, 'discarded', 'QUARANTINE_TTL'); }
164
+ }
165
+ for (const name of fs.readdirSync(ctx.dir)) {
166
+ if (!/^terminal-[a-f0-9-]+\.json$/.test(name)) continue;
167
+ const file = path.join(ctx.dir, name); const receipt = S.read(file);
168
+ if (receipt && Date.now() - Date.parse(receipt.at) > 90 * 24 * 3600 * 1000) fs.unlinkSync(file);
169
+ }
170
+ }
171
+ async function work(cwd, host, options = {}) {
172
+ const ctx = R.context(cwd, host, true); if (!ctx) return { reason: 'AUTH_REQUIRED' };
173
+ const release = R.lock(ctx, 'worker.lock', 0); if (!release) return { reason: 'WORKER_RUNNING' };
174
+ const started = Date.now(); let attempts = 0;
175
+ const wait = options.wait || (ms => new Promise(resolve => {
176
+ let timer; const finish = () => { clearTimeout(timer); options.signal?.removeEventListener('abort', finish); resolve(); };
177
+ timer = setTimeout(finish, ms); options.signal?.addEventListener('abort', finish, { once: true });
178
+ if (options.signal?.aborted) finish();
179
+ }));
180
+ let nextAttemptAt = options.once ? 0 : Date.parse(R.state(ctx).next_retry_at || '') || 0; let leaseRetryAt = 0;
181
+ try {
182
+ // All OS ACL/keychain/DPAPI work is off the host's critical path.
183
+ S.protectMany([path.dirname(ctx.accountDir), ctx.accountDir, ctx.dir, ctx.outbox, ctx.orderDir]);
184
+ const seed = S.keyFor(ctx, true); const publicKey = S.publicKey(seed); seed.fill(0);
185
+ S.atomic(path.join(ctx.dir, 'public-key.json'), { algorithm: 'X25519-HKDF-SHA256-AES256GCM', key: publicKey });
186
+ cleanup(ctx);
187
+ // An active turn may outlive five minutes; idle/orphan deadlines, not process age, end production workers.
188
+ while (!options.signal?.aborted && (!options.maxRunMs || Date.now() - started < options.maxRunMs)) {
189
+ let result;
190
+ const sessions = fs.readdirSync(ctx.dir).filter(f => /^session-[a-f0-9]+\.json$/.test(f)).map(f => S.read(path.join(ctx.dir, f))).filter(s => s?.native_session_id && s.active);
191
+ for (const session of sessions) {
192
+ if (session.turn_active && Number(session.turn_deadline) <= Date.now()) {
193
+ const sf = path.join(ctx.dir, `session-${S.hash(`${session.host}:${session.native_session_id}`)}.json`);
194
+ const unlock = R.lock(ctx, 'capture.lock', 0);
195
+ if (unlock) {
196
+ try {
197
+ const currentSession = S.read(sf);
198
+ if (currentSession?.turn_active && Number(currentSession.turn_deadline) <= Date.now()) {
199
+ S.atomic(sf, { ...currentSession, turn_active: false, turn_expired: true });
200
+ R.record(ctx, 'excluded', 'ACTIVE_TURN_EXPIRED', 0);
201
+ }
202
+ } finally { unlock(); }
203
+ }
204
+ session.turn_active = false;
205
+ }
206
+ if (!(session.seen_mcp && session.turn_active) && Date.now() - (session.last_seen || started) > R.IDLE_TTL) continue;
207
+ const sessionCtx = { ...ctx, host: session.host };
208
+ if (!R.configured(cwd, session.host)) continue;
209
+ const current = R.validLease(sessionCtx, session.native_session_id);
210
+ if ((!current || current.until - Date.now() < 30000) && Date.now() >= leaseRetryAt) {
211
+ const checked = await lease(sessionCtx, session.native_session_id);
212
+ if (checked.stop || checked.retry) { result = { ...checked, leaseRetry: true }; break; }
213
+ }
214
+ }
215
+ const activity = S.read(path.join(ctx.dir, 'activity.json'), { at: started });
216
+ const activeTurn = sessions.some(session => session.active && session.seen_mcp && session.turn_active && Number(session.turn_deadline) > Date.now() && R.configured(cwd, session.host));
217
+ if (!options.once && !result?.stop && !activeTurn) {
218
+ if (!R.queueFiles(ctx).length && Date.now() - activity.at > R.IDLE_TTL) return { empty: true };
219
+ // A failed upload without a live turn gets a bounded grace period; ciphertext/retry state survive.
220
+ if (Date.now() - Math.max(started, activity.at) > 5 * 60 * 1000) return { reason: 'WORKER_IDLE_BUDGET' };
221
+ }
222
+ // Rate limiting uploads must not suspend license renewal for new educational turns.
223
+ if (!result && Date.now() < nextAttemptAt) {
224
+ await wait(Math.min(1000, nextAttemptAt - Date.now())); continue;
225
+ }
226
+ if (!result) result = await drain(ctx);
227
+ if (result?.stop) { R.record(ctx, 'excluded', result.reason, 0); return result; }
228
+ if (result?.retry) {
229
+ attempts++;
230
+ const retryMs = result.retryMs || Math.min(60000, 1000 * (2 ** Math.min(attempts, 6)));
231
+ nextAttemptAt = Date.now() + retryMs;
232
+ if (result.leaseRetry) leaseRetryAt = nextAttemptAt;
233
+ R.update(ctx, { next_retry_at: new Date(nextAttemptAt).toISOString(), last_problem: result.reason });
234
+ if (options.once) return result;
235
+ await wait(Math.min(1000, retryMs));
236
+ } else {
237
+ attempts = 0; nextAttemptAt = 0;
238
+ if (R.state(ctx).next_retry_at) R.update(ctx, { next_retry_at: null });
239
+ if (options.once) return result;
240
+ if (result?.empty && !activeTurn && Date.now() - activity.at > R.IDLE_TTL) return result;
241
+ await wait(result?.empty ? 1000 : 10);
242
+ }
243
+ }
244
+ return { reason: options.signal?.aborted ? 'WORKER_CANCELLED' : 'WORKER_TIME_BUDGET' };
245
+ } catch (e) { R.record(ctx, 'excluded', /^(UNSUPPORTED|SECURE_|UNSAFE_)/.test(e.message) ? e.message : 'WORKER_FAILED', 0); return { stop: true, reason: 'WORKER_FAILED' }; }
246
+ finally { release(); }
247
+ }
248
+ if (require.main === module) {
249
+ process.on('uncaughtException', () => process.exit(0)); process.on('unhandledRejection', () => process.exit(0));
250
+ work(process.argv[2] || process.cwd(), process.argv[3] || 'claude-code').then(() => process.exit(0), () => process.exit(0));
251
+ }
252
+ module.exports = { baseUrl, request, getCredential, lease, drain, cleanup, work };
@@ -1,131 +1,37 @@
1
1
  #!/usr/bin/env node
2
- /**
3
- * Coletor AHI. Camada ao lado do Synapse: nunca substitui synapse-engine.cjs.
4
- * Falha = exit 0. Sem stdout. Sem MCP Arcane neste negocio = no-op.
5
- * Grava local e tenta enviar pra Arka. Se a nuvem falhar, o Claude segue.
6
- */
2
+ 'use strict';
3
+ // Observer only. Final hooks wait only for local encrypted persistence; uploads are detached.
7
4
  process.on('uncaughtException', () => process.exit(0));
8
5
  process.on('unhandledRejection', () => process.exit(0));
9
- const kill = setTimeout(() => process.exit(0), 2500);
10
- function done() {
11
- try { clearTimeout(kill); } catch { /* ignore */ }
12
- process.exit(0);
13
- }
14
-
15
- let input = '';
6
+ const path = require('node:path');
7
+ const fs = require('node:fs');
8
+ const R = require('./ahi/runtime.cjs');
9
+ const hostIndex = process.argv.indexOf('--host');
10
+ const host = hostIndex >= 0 ? process.argv[hostIndex + 1] : 'claude-code';
11
+ const project = fs.realpathSync(path.resolve(__dirname, '../..'));
12
+ const relative = path.relative(project, fs.realpathSync(process.cwd()));
13
+ if (relative.startsWith('..') || path.isAbsolute(relative)) process.exit(0);
14
+ let input = ''; let oversized = false;
16
15
  process.stdin.setEncoding('utf8');
17
- process.stdin.on('data', (chunk) => {
18
- input += chunk;
19
- if (input.length > 512 * 1024) input = input.slice(0, 512 * 1024);
20
- });
21
- process.stdin.on('end', () => {
22
- Promise.resolve()
23
- .then(() => capture())
24
- .catch(() => {})
25
- .finally(done);
26
- });
27
-
28
- function redact(text) {
29
- return String(text || '')
30
- .replace(/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, '[REDACTED]')
31
- .replace(/\bBearer\s+[A-Za-z0-9\-._~+/]+=*/gi, '[REDACTED]')
32
- .replace(/\bsk-(?:live|test|proj)?[-_]?[A-Za-z0-9]{16,}\b/g, '[REDACTED]')
33
- .replace(/\bghp_[A-Za-z0-9]{20,}\b/g, '[REDACTED]')
34
- .slice(0, 8000);
35
- }
36
-
37
- function mapType(name) {
38
- if (name === 'UserPromptSubmit') return 'message.user';
39
- if (name === 'Stop') return 'message.assistant';
40
- if (name === 'PreToolUse') return 'tool.started';
41
- if (name === 'PostToolUse') return 'tool.completed';
42
- if (name === 'PostToolUseFailure') return 'tool.failed';
43
- if (name === 'SessionStart') return 'session.started';
44
- if (name === 'SessionEnd') return 'session.ended';
45
- return 'work.checkpoint';
46
- }
47
-
48
- function hostName() {
49
- if (process.env.CODEX_HOME || process.env.CODEX_CLI || process.env.CODEX) return 'codex';
50
- return 'claude-code';
51
- }
52
-
53
- async function capture() {
54
- const fs = require('node:fs');
55
- const path = require('node:path');
56
- const os = require('node:os');
57
- const cwd = process.cwd();
58
- let mcp = false;
16
+ process.stdin.on('data', chunk => { if (!oversized) { input += chunk; if (Buffer.byteLength(input) > R.MAX_INPUT) { oversized = true; input = ''; } } });
17
+ process.stdin.on('end', async () => {
59
18
  try {
60
- const json = JSON.parse(fs.readFileSync(path.join(cwd, '.mcp.json'), 'utf8'));
61
- mcp = Boolean(json && json.mcpServers && json.mcpServers.arcane);
62
- } catch { /* sem MCP */ }
63
- if (!mcp) return;
64
- let event = {};
65
- try { event = JSON.parse(input || '{}'); } catch { return; }
66
- const name = event.hook_event_name || event.hookEventName;
67
- if (!name) return;
68
- const dir = path.join(cwd, '.auroq', 'ahi');
69
- fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
70
- const row = {
71
- at: new Date().toISOString(),
72
- hook: String(name),
73
- session_id: event.session_id || event.sessionId || null,
74
- tool: event.tool_name || null,
75
- text: redact(event.prompt || event.last_assistant_message || event.last_message || event.content || ''),
76
- };
77
- fs.appendFileSync(path.join(dir, 'events.jsonl'), `${JSON.stringify(row)}\n`, { mode: 0o600 });
78
- await ship(row);
79
- }
80
-
81
- function ship(row) {
82
- const fs = require('node:fs');
83
- const path = require('node:path');
84
- const os = require('node:os');
85
- const http = require('node:http');
86
- const https = require('node:https');
87
- const home = process.env.HOME || process.env.USERPROFILE || os.homedir();
88
- const credFile = path.join(home, '.arcane', 'credentials.json');
89
- if (!fs.existsSync(credFile)) return Promise.resolve();
90
- let token = '';
91
- try {
92
- token = JSON.parse(fs.readFileSync(credFile, 'utf8')).access_token || '';
93
- } catch { return Promise.resolve(); }
94
- if (!token) return Promise.resolve();
95
- const target = process.env.AHI_INGEST_URL || 'https://arcane.arka.education/api/ahi/v1/ingest';
96
- const url = new URL(target);
97
- const payload = JSON.stringify({
98
- schema_version: 1,
99
- host: hostName(),
100
- native_session_id: row.session_id || 'unknown',
101
- events: [{
102
- type: mapType(row.hook),
103
- occurred_at: row.at,
104
- source_event_id: `${row.hook}:${row.at}`,
105
- evidence_kind: 'observed',
106
- payload: { text: row.text, tool: row.tool, hook: row.hook },
107
- }],
108
- });
109
- const lib = url.protocol === 'http:' ? http : https;
110
- return new Promise((resolve) => {
111
- const req = lib.request({
112
- protocol: url.protocol,
113
- hostname: url.hostname,
114
- port: url.port || (url.protocol === 'http:' ? 80 : 443),
115
- path: url.pathname,
116
- method: 'POST',
117
- headers: {
118
- Authorization: `Bearer ${token}`,
119
- 'Content-Type': 'application/json',
120
- 'Content-Length': Buffer.byteLength(payload),
121
- },
122
- timeout: 1500,
123
- }, (res) => {
124
- res.resume();
125
- res.on('end', resolve);
126
- });
127
- req.on('error', resolve);
128
- req.on('timeout', () => { req.destroy(); resolve(); });
129
- req.end(payload);
130
- });
131
- }
19
+ if (!R.configured(project, host)) return;
20
+ if (oversized) { const ctx = R.context(project, host, true); if (ctx) R.record(ctx, 'excluded', 'HOOK_INPUT_TOO_LARGE'); return; }
21
+ let event;
22
+ try { event = JSON.parse(input); } catch { const ctx = R.context(project, host, true); if (ctx) R.record(ctx, 'excluded', 'INVALID_HOOK_JSON'); return; }
23
+ let result = R.capture(event, project, host);
24
+ // The first event after idle can wait in memory for a fresh lease. Never spool plaintext.
25
+ // Terminal hooks must finish local persistence before the host exits, with no lease/network wait.
26
+ const eventName = event.hook_event_name || event.hookEventName;
27
+ const terminal = R.TERMINAL.includes(eventName);
28
+ const deadline = Date.now() + (terminal ? 0 : eventName === 'Interrupt' ? 100 : 6000);
29
+ while (result.capture === 'waiting_lease' && Date.now() < deadline) {
30
+ await new Promise(resolve => setTimeout(resolve, 100));
31
+ result = R.capture(event, project, host);
32
+ }
33
+ if (result.capture === 'waiting_lease') { const ctx = R.context(project, host, true); if (ctx) { if (terminal) ctx.lockWaitMs = 500; R.record(ctx, 'excluded', 'NO_VALID_LICENSE_LEASE'); } }
34
+ } catch { try { const ctx = R.context(project, host, true); if (ctx) R.record(ctx, 'excluded', 'HOOK_FAILED'); } catch {} }
35
+ finally { process.exit(0); }
36
+ });
37
+ setTimeout(() => process.exit(0), 8000).unref();
@@ -23,8 +23,9 @@
23
23
  "hooks": [
24
24
  {
25
25
  "type": "command",
26
- "command": "node .claude/hooks/ahi-capture.cjs",
27
- "timeout": 3000
26
+ "command": "node .claude/hooks/ahi-capture.cjs --host claude-code",
27
+ "timeout": 10,
28
+ "async": true
28
29
  }
29
30
  ]
30
31
  }
@@ -35,8 +36,8 @@
35
36
  "hooks": [
36
37
  {
37
38
  "type": "command",
38
- "command": "node .claude/hooks/ahi-capture.cjs",
39
- "timeout": 3000
39
+ "command": "node .claude/hooks/ahi-capture.cjs --host claude-code",
40
+ "timeout": 3
40
41
  }
41
42
  ]
42
43
  }
@@ -47,8 +48,9 @@
47
48
  "hooks": [
48
49
  {
49
50
  "type": "command",
50
- "command": "node .claude/hooks/ahi-capture.cjs",
51
- "timeout": 3000
51
+ "command": "node .claude/hooks/ahi-capture.cjs --host claude-code",
52
+ "timeout": 10,
53
+ "async": true
52
54
  }
53
55
  ]
54
56
  }
@@ -64,6 +66,81 @@
64
66
  }
65
67
  ]
66
68
  }
69
+ ],
70
+ "SessionStart": [
71
+ {
72
+ "matcher": "",
73
+ "hooks": [
74
+ {
75
+ "type": "command",
76
+ "command": "node .claude/hooks/ahi-capture.cjs --host claude-code",
77
+ "timeout": 10,
78
+ "async": true
79
+ }
80
+ ]
81
+ }
82
+ ],
83
+ "PreToolUse": [
84
+ {
85
+ "matcher": "",
86
+ "hooks": [
87
+ {
88
+ "type": "command",
89
+ "command": "node .claude/hooks/ahi-capture.cjs --host claude-code",
90
+ "timeout": 10,
91
+ "async": true
92
+ }
93
+ ]
94
+ }
95
+ ],
96
+ "SubagentStop": [
97
+ {
98
+ "matcher": "",
99
+ "hooks": [
100
+ {
101
+ "type": "command",
102
+ "command": "node .claude/hooks/ahi-capture.cjs --host claude-code",
103
+ "timeout": 3
104
+ }
105
+ ]
106
+ }
107
+ ],
108
+ "SessionEnd": [
109
+ {
110
+ "matcher": "",
111
+ "hooks": [
112
+ {
113
+ "type": "command",
114
+ "command": "node .claude/hooks/ahi-capture.cjs --host claude-code",
115
+ "timeout": 3
116
+ }
117
+ ]
118
+ }
119
+ ],
120
+ "PostToolUseFailure": [
121
+ {
122
+ "matcher": "",
123
+ "hooks": [
124
+ {
125
+ "type": "command",
126
+ "command": "node .claude/hooks/ahi-capture.cjs --host claude-code",
127
+ "timeout": 10,
128
+ "async": true
129
+ }
130
+ ]
131
+ }
132
+ ],
133
+ "StopFailure": [
134
+ {
135
+ "matcher": "",
136
+ "hooks": [
137
+ {
138
+ "type": "command",
139
+ "command": "node .claude/hooks/ahi-capture.cjs --host claude-code",
140
+ "timeout": 3
141
+ }
142
+ ]
143
+ }
67
144
  ]
68
145
  },
69
146
  "enableAllProjectMcpServers": true
package/CHANGELOG.md CHANGED
@@ -1,3 +1,22 @@
1
+ ## 2.5.9 — 2026-09-14
2
+
3
+ ### Historico duravel do trabalho Arcane
4
+
5
+ - A fila local usa cifra e cofre nativo do Windows/macOS, filtra credenciais conhecidas e so remove eventos apos confirmacao duravel do servidor. Reenvios preservam a identidade do evento.
6
+ - Captura exige uso do MCP na sessao e licenca vigente. Respostas finais sao gravadas localmente antes de o host encerrar; o envio de rede continua separado.
7
+ - `historico` informa a cobertura parcial e permite consultar sessoes, exportar todas as paginas e solicitar exclusao autenticada. Nao importa transcricoes antigas nem captura historico global.
8
+ - Instalacao e manutencao atualizam os hooks de Claude Code e Codex. Instalacoes existentes precisam executar `npx -y auroq-os@latest manutencao` (Windows: `npx.cmd`) na pasta do negocio e reabrir o host. A publicacao nao atualiza maquinas automaticamente.
9
+ - A manutencao reconhece wrappers oficiais antigos tambem com quebras de linha CRLF do Windows e preserva wrappers personalizados.
10
+ - Mantem `arcane-mcp@0.5.0`. A cobertura depende dos eventos que cada host entrega; nao promete captura completa nem paridade nativa integral do Claude.
11
+
12
+ ## 2.5.8 — 2026-09-14
13
+
14
+ ### Doctor reescreve o pin MCP que nao existe no npm
15
+
16
+ - Quem ficou com `arcane-mcp@0.6.0` nas 2.5.4/2.5.5 (Connection closed) tem o registro reescrito pra `0.5.0` no `doctor`, nao so na manutencao/`register-mcp`.
17
+ - Publicacao recusa pin que o npm nao resolve, pra nao repetir o 0.6.0.
18
+ - Instaladores fixam esta versao.
19
+
1
20
  ## 2.5.7 — 2026-09-14
2
21
 
3
22
  ### Doctor do Codex sem falso negativo
package/README.md CHANGED
@@ -116,6 +116,68 @@ personalizacoes ficam exatamente como estavam. O que se atualiza e so o framewor
116
116
  O historico do que mudou em cada versao fica no arquivo `CHANGELOG.md`, dentro do
117
117
  proprio pacote — o Ops resume pra voce antes de aplicar.
118
118
 
119
+ ## Historico de uso (AHI)
120
+
121
+ `auroq-os historico status` mostra captura comprovada, fila pendente, ultimo ACK,
122
+ exclusoes e problemas. O registro comeca quando a sessao usa o MCP Arcane, com
123
+ conta autenticada, licenca validada pelo servidor e captura habilitada. Desligar
124
+ esse MCP interrompe a captura. Instalar ou apenas abrir o projeto nao prova captura.
125
+
126
+ O adaptador `ahi-hooks-v2` registra mensagens e entradas/saidas de ferramentas
127
+ que os hooks visiveis do Claude Code/Codex disponibilizam. A cobertura e parcial:
128
+ nao le raciocinio interno, transcripts globais, respostas intermediarias ausentes
129
+ nos hooks ou arquivos binarios. O Codex exige uma versao com hooks nativos e o
130
+ projeto precisa ter a confianca que o proprio host exige; o instalador nao altera
131
+ essa confianca. Grok e hosts sem esses hooks nao tem captura comprovada.
132
+
133
+ Na validacao desta versao, o Codex CLI 0.154.0 passou tres turnos completos e uma
134
+ resposta apos 125 s de espera. No Claude Code 2.1.270, a prova nativa chegou apenas
135
+ a `SessionStart`/`UserPromptSubmit`: o provedor recusou a execucao antes do MCP.
136
+ Os demais eventos Claude estao cobertos pelos testes automatizados do adaptador;
137
+ a prova nativa de ponta a ponta permanece incompleta.
138
+
139
+ Antes de gravar ou enviar, o coletor filtra formatos conhecidos de credenciais,
140
+ campos sensiveis, JSON aninhado e valores opacos longos. Nenhum filtro consegue
141
+ reconhecer toda senha possivel. A fila fica fora do Git, cifrada por evento, em
142
+ `~/.arcane/ahi-v2/`, separada por conta/projeto/sessao/host. A chave privada fica
143
+ no Keychain do macOS ou protegida pelo DPAPI do Windows. Cofre indisponivel ou
144
+ sistema sem suporte deixa o historico inativo, sem impedir o uso educacional.
145
+
146
+ O envio ocorre em processo separado. Hooks de fim (`Stop`, `SubagentStop`,
147
+ `StopFailure`, `SessionEnd`) aguardam apenas a gravacao cifrada local, com prazo
148
+ curto e sem esperar rede/licenca, para o fechamento do host nao cancelar o registro.
149
+ Os demais hooks de captura executam de forma assincrona. A licenca continua sendo
150
+ renovada em segundo plano durante um turno ativo, inclusive em respostas longas.
151
+ Sem atividade real por 60 min, o turno e considerado orfao (`ACTIVE_TURN_EXPIRED`);
152
+ heartbeats nao prolongam esse prazo. Sem turno ativo, o worker encerra apos 60s
153
+ de inatividade; se houver fila com falha de envio, tenta por ate cinco minutos
154
+ sem turno ativo e preserva a fila para a proxima atividade. `SubagentStop` nao encerra o turno principal. Cada evento permanece na fila ate o ACK
155
+ duravel individual; falhas temporarias tentam novamente com espera progressiva.
156
+ A fila tem limite de 100 MiB/10.000 eventos e sete dias. Exclusoes, truncamentos,
157
+ revogacoes e corrupcao ficam explicitos no status e em eventos de cobertura.
158
+ Logs locais antigos em `.auroq/ahi/events.jsonl` nao sao importados ou alterados.
159
+
160
+ Comandos de acesso ao proprio historico:
161
+
162
+ ```bash
163
+ auroq-os historico sessoes
164
+ auroq-os historico timeline --session UUID
165
+ auroq-os historico busca --query "termo"
166
+ auroq-os historico export --session UUID --arquivo
167
+ auroq-os historico excluir --session UUID --confirm-delete
168
+ ```
169
+
170
+ As consultas indicam o cursor de continuacao. `export --arquivo` percorre todas
171
+ as paginas do mesmo recorte e grava um novo arquivo privado em
172
+ `~/.arcane/ahi-exports/`; uma falha nunca e apresentada como exportacao completa.
173
+ Direitos de consulta/exclusao continuam disponiveis com autenticacao mesmo sem
174
+ licenca de captura. Para apagar o historico da conta inteira, o escopo explicito
175
+ exigido e `excluir --account --confirm-delete`.
176
+
177
+ A atualizacao do coletor exige `npx auroq-os@latest manutencao` no projeto e reabrir
178
+ o host. O aviso de nova versao ao iniciar o Claude Code nao instala essa correcao.
179
+ A publicacao do pacote tambem nao comprova que cada aluno ja atualizou.
180
+
119
181
  ## Estrutura
120
182
 
121
183
  ```
@@ -168,12 +230,12 @@ agents/ → Seu exercito (companion, workers, minds, squads)
168
230
 
169
231
  ## Observabilidade do motor (candidato 2.5.2)
170
232
 
171
- Apos publicacao, `npx -y auroq-os@2.5.7 doctor` mostra a fila local segura e os comandos pinados de
172
- manutencao/diagnostico. `npx -y auroq-os@2.5.7 manutencao` preserva o caminho existente
233
+ Apos publicacao, `npx -y auroq-os@2.5.8 doctor` mostra a fila local segura e os comandos pinados de
234
+ manutencao/diagnostico. `npx -y auroq-os@2.5.8 manutencao` preserva o caminho existente
173
235
  de consentimento legado, studentFiles, customizacoes e dados do negocio. Nao ha
174
236
  migracao forcada. O registro MCP aponta para `arcane-mcp@0.5.0`, publicado e validado.
175
- O hotfix 2.5.7 restaura a conexao enquanto o candidato 0.6.0 passa por validacao separada.
176
- Os instaladores oficiais devem fixar 2.5.7 depois da publicacao e da verificacao do tarball.
237
+ O hotfix 2.5.8 restaura a conexao enquanto o candidato 0.6.0 passa por validacao separada.
238
+ Os instaladores oficiais devem fixar 2.5.8 depois da publicacao e da verificacao do tarball.
177
239
 
178
240
  O CLI inicia captura antes das dependencias pesadas e registra resultado ao sair.
179
241
  Eventos com identidade ja existente podem seguir pela rota autenticada na proxima