genetics-mcp 2.25.0 → 2.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/genetics.mjs CHANGED
@@ -28,6 +28,8 @@ import os from 'node:os';
28
28
  import path from 'node:path';
29
29
  import { pipeline as streamPipeline } from 'node:stream/promises';
30
30
  import { fileURLToPath } from 'node:url';
31
+ import { codexSessionId, codexRollouts, codexMetadata, codexHumanRoot, findCodexRollout,
32
+ prepareCodexProjection, cachedCodexProjection, CODEX_CODEC } from './codex-session.mjs';
31
33
 
32
34
  /**
33
35
  * 판을 **package.json 에서 읽는다.** 전에는 serverInfo 에 '1.0.0' 이 상수로 박혀 있어서
@@ -122,6 +124,10 @@ try {
122
124
  Object.assign(cfg, Object.fromEntries(
123
125
  Object.entries(process.env).filter(([k]) => k.startsWith('GENETICS_'))
124
126
  ));
127
+ const SESSION_WATCH_UUID = String(cfg.GENETICS_SESSION_WATCH_UUID || '').toLowerCase();
128
+ if (SESSION_WATCH_UUID && !/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/.test(SESSION_WATCH_UUID)) throw new Error('Invalid session watch UUID');
129
+ function sessionScope(uuid) { return !SESSION_WATCH_UUID || String(uuid).toLowerCase() === SESSION_WATCH_UUID; }
130
+ function sessionToolMode() { return cfg.GENETICS_SESSION_ONLY === '1' || !!SESSION_WATCH_UUID; }
125
131
 
126
132
  // **기본 주소를 두지 않는다.** 값을 박아 두면 출하물이 "그 서버를 아는 클라이언트" 가 되고,
127
133
  // 공개 배포 시 운영 엔드포인트가 그대로 공개된다. 주소는 쓰는 쪽이 정한다.
@@ -1377,6 +1383,13 @@ const REPORT_DATA = {
1377
1383
  description: '요약면 현황 줄 {state, text} — state 는 critical|info|success. '
1378
1384
  + '**상태에 따라 바뀌는 유일한 줄**이다. 떠 있는 알림 배너를 따로 두지 마라.',
1379
1385
  },
1386
+ summary_position: {
1387
+ type: 'string', enum: ['front'],
1388
+ description: '선택해서 `front` 를 주면 최상위 `summary[]` 를 문서 앞 요약면에도 싣는다. '
1389
+ + '생략하면 기존 요약면(현황·수치)과 kind:"summary" 절의 배치를 유지한다. '
1390
+ + '앞 요약만 필요하면 kind:"summary" 절을 따로 두지 마라 — 두 곳에 같은 요지가 실린다. '
1391
+ + '요약의 refs 는 실제 사례나 presentation:"prose" 절의 문단을 가리켜야 한다.',
1392
+ },
1380
1393
  bar: { type: 'array', description: '요약면 상태 막대 [{value, label, tone}] · tone 은 done|wait' },
1381
1394
  bar_note: { type: 'string', description: '막대가 뜻하는 바의 한정 조건 (※ 각주)' },
1382
1395
  bar_alt: { type: 'string', description: '막대의 대체 텍스트 (보조기기용)' },
@@ -1478,7 +1491,29 @@ const REPORT_DATA = {
1478
1491
  + '· `tone`: 장 색을 뜻으로 고른다 — you(독자 몫)·deep(요지)·neutral(허브)·'
1479
1492
  + 'done(끝난 것)·wait(대기)·hold(유보). 생략하면 kind 가 정한다.\n'
1480
1493
  + '· kind:status 전용 — `board_title`(대조표 제목) · `board_hint`(표 아래 안내).\n'
1481
- + '· kind:note 전용 — `paragraphs`(강조 박스의 항목) · `limit_label`(그 박스 제목).',
1494
+ + '· kind:note 전용 — `paragraphs`(문자열 목록) · `limit_label`(기본 강조 박스 제목). '
1495
+ + 'presentation:"prose" 를 선택하면 paragraphs 를 항목 상자 대신 실제 산문 문단으로 싣는다. '
1496
+ + '문단 번호는 절 배열 순번·문단 배열 순번이며 §1.2 는 1번째 절의 2번째 문단 앵커를 가리킨다. '
1497
+ + 'paragraph_kinds 는 paragraphs 와 같은 길이·순서의 역할 목록이다(아래 items 계약).',
1498
+ items: {
1499
+ type: 'object',
1500
+ properties: {
1501
+ presentation: {
1502
+ type: 'string', enum: ['prose'],
1503
+ description: 'kind:"note" 에서만 선택하는 산문 표시. paragraphs 를 실제 <p> 문단으로 '
1504
+ + '싣고 요약 refs 가 가리키는 case-<두 자리 절 순번>-<문단 순번> 앵커를 유지한다. '
1505
+ + '생략하면 기존 강조 박스·limit_label 을 쓴다. 다른 kind 의 본문 장치는 바뀌지 않는다.',
1506
+ },
1507
+ paragraph_kinds: {
1508
+ type: 'array', maxItems: 120,
1509
+ items: { type: 'string', enum: ['fact', 'context', 'analysis', 'proposal', 'unknown'] },
1510
+ description: 'presentation:"prose" 문단 역할. paragraphs 와 같은 길이·순서로 둔다. '
1511
+ + 'fact(확인 사실)·context(맥락)는 별도 표식 없이 읽히고, analysis·proposal·unknown 은 '
1512
+ + '각각 「해석」·「제안」·「추가 확인 필요」로 표시된다. 생략하면 역할 표식이 없다. '
1513
+ + '역할은 글의 구분이며 사실 검증이나 접근 권한을 대신하지 않는다.',
1514
+ },
1515
+ },
1516
+ },
1482
1517
  },
1483
1518
  chapters: {
1484
1519
  type: 'array',
@@ -1523,9 +1558,10 @@ const REPORT_DATA = {
1523
1558
  },
1524
1559
  summary: {
1525
1560
  type: 'array',
1526
- description: 'summary 절 내용 [{lead, body, refs?}] — 각 문단은 근거로 삼은 **사례 번호**를 '
1527
- + '최소 하나 밝혀야 한다(예: 본문에 "§3.1" 이라 쓰거나 refs:["3.1"]). 절 번호가 아니라 '
1528
- + '사례 번호다. 근거가 없으면 서버가 발행을 거절한다.',
1561
+ description: '요지 [{lead, body, refs?}] — kind:"summary" 절, summary_position:"front" 이면 앞 요약면에도 선다. '
1562
+ + '각 문단은 근거로 삼은 실제 사례 또는 kind:"note", presentation:"prose" 의 문단 번호를 '
1563
+ + '최소 하나 밝혀야 한다(예: 본문에 "§3.1" 이라 쓰거나 refs:["3.1"]). '
1564
+ + '산문이면 3번째 절의 1번째 문단이다. 없는 번호나 근거 표기 누락은 서버가 거절한다.',
1529
1565
  },
1530
1566
  open_items: {
1531
1567
  type: 'array',
@@ -2002,12 +2038,14 @@ function transcriptDir(dir) {
2002
2038
 
2003
2039
  /** uuid 로 이 기계의 트랜스크립트를 찾는다. 지금 프로젝트를 먼저 보고, 없으면 전체를 훑는다. */
2004
2040
  function findTranscript(uuid) {
2041
+ const native = findCodexRollout(uuid);
2042
+ if (native) return cachedCodexProjection(native, codexProjectionRoot()) || null;
2005
2043
  const here = path.join(transcriptDir(process.cwd()), `${uuid}.jsonl`);
2006
2044
  if (fs.existsSync(here)) return here;
2007
2045
  const root = path.join(os.homedir(), '.claude', 'projects');
2008
2046
  // Claude Code 가 없는 기계(Cursor 만 깔린 곳)에는 이 디렉터리 자체가 없다 —
2009
2047
  // 「전사 없음」이지 오류가 아니다. 던지면 mail 등 도구가 통째로 죽는다(smoke 실측).
2010
- if (!fs.existsSync(root)) return null;
2048
+ if (!fs.existsSync(root)) return cursorTranscriptOf(uuid);
2011
2049
  for (const d of fs.readdirSync(root, { withFileTypes: true })) {
2012
2050
  if (!d.isDirectory()) continue;
2013
2051
  const f = path.join(root, d.name, `${uuid}.jsonl`);
@@ -2190,9 +2228,9 @@ function cursorWorkspaceFolder() {
2190
2228
  return cursorSeen.folder;
2191
2229
  }
2192
2230
 
2193
- /** 「지금 이 세션」 — Claude Code env 가 정본, 없으면 Cursor 활성 대화. */
2231
+ /** Harness identity precedes UI discovery. Native Codex THREAD_ID identifies the current child. */
2194
2232
  function mySessionId() {
2195
- return process.env.CLAUDE_CODE_SESSION_ID || cursorActiveComposer();
2233
+ return process.env.CLAUDE_CODE_SESSION_ID || codexSessionId() || cursorActiveComposer();
2196
2234
  }
2197
2235
 
2198
2236
  /**
@@ -2217,12 +2255,12 @@ function hookLane() {
2217
2255
  return hookLaneMemo.v;
2218
2256
  }
2219
2257
 
2220
- function gitHere() {
2258
+ function gitHere(dir = process.cwd()) {
2221
2259
  try {
2222
- const r = spawnSync('git', ['-C', process.cwd(), 'rev-parse', '--abbrev-ref', 'HEAD'],
2260
+ const r = spawnSync('git', ['-C', dir, 'rev-parse', '--abbrev-ref', 'HEAD'],
2223
2261
  { encoding: 'utf8' });
2224
2262
  const b = (r.stdout || '').trim();
2225
- const c = spawnSync('git', ['-C', process.cwd(), 'rev-parse', '--short', 'HEAD'],
2263
+ const c = spawnSync('git', ['-C', dir, 'rev-parse', '--short', 'HEAD'],
2226
2264
  { encoding: 'utf8' });
2227
2265
  return [b, (c.stdout || '').trim()].filter(Boolean).join('@');
2228
2266
  } catch { return ''; }
@@ -3037,12 +3075,28 @@ async function pushAppend(a) {
3037
3075
  * 이 대화를 **무엇이** 만들었는가. 판번호만으로는 세션끼리 구별이 안 되므로 클라이언트를 함께 싣는다.
3038
3076
  * 값이 없으면 키를 안 만든다 — 없는 것을 「알 수 없음」으로 적으면 그 값이 다시 상수가 된다.
3039
3077
  */
3040
- function toolsStamp() {
3078
+ function toolsStamp(native = null) {
3041
3079
  const t = { mcp: VERSION };
3042
- if (CLIENT?.name) t.client = CLIENT.version ? `${CLIENT.name}/${CLIENT.version}` : String(CLIENT.name);
3080
+ if (native) { t.client = `codex/${native.cli_version}`; t.source_format = 'codex-rollout'; t.codec = CODEX_CODEC; }
3081
+ else if (CLIENT?.name) t.client = CLIENT.version ? `${CLIENT.name}/${CLIENT.version}` : String(CLIENT.name);
3043
3082
  return t;
3044
3083
  }
3045
3084
 
3085
+ function codexProjectionRoot() { return path.join(path.dirname(CRED_FILE), 'codex-projections'); }
3086
+ function isNativeProjection(file) { return typeof file === 'string' && path.dirname(file) === codexProjectionRoot(); }
3087
+ function nativeSessionTools(tools) {
3088
+ if (typeof tools === 'string') { try { tools = JSON.parse(tools); } catch { return false; } }
3089
+ return tools?.source_format === 'codex-rollout' || /^codex\//.test(String(tools?.client || ''));
3090
+ }
3091
+
3092
+ async function prepareNativeSession(uuid) {
3093
+ const native = findCodexRollout(uuid);
3094
+ if (!native) return null;
3095
+ const prepared = await prepareCodexProjection(native, { cacheRoot: codexProjectionRoot(), scrub: scrubTranscript, scrubRevision: SCRUB_REV });
3096
+ pathCache.set(uuid, { file: prepared.projection, at: Date.now() });
3097
+ return prepared;
3098
+ }
3099
+
3046
3100
  /** 개행 없는 끝 줄이 **다 쓴 줄**인가 — 온전한 JSON 객체로 읽히면 그렇다(반쪽 줄은 객체로 안 읽힌다). 공백뿐이면 아니다. */
3047
3101
  function cursorTailDone(tail) {
3048
3102
  const s = tail.toString('utf8');
@@ -3058,16 +3112,23 @@ function cursorTailDone(tail) {
3058
3112
  * `auto` 와 달리 머리·folder 를 안 싣는다) · `progress` 는 요청 하나(조각 하나)가 끝날 때마다 — 임대의 묵음 시계를 민다(§3.2).
3059
3113
  */
3060
3114
  async function pushSession(tenant, uuid, title, dryRun, bind = 'skip', auto = null, hooks = null, tenantIdHint = 0) {
3061
- const f = findTranscript(uuid);
3115
+ const native = await prepareNativeSession(uuid);
3116
+ const f = native?.projection || findTranscript(uuid);
3062
3117
  if (!f) throw new Error(`이 기계에 그 세션이 없습니다: ${uuid}`);
3063
3118
  // 한 fd 로 stat 과 읽기를 한다 — 대응표의 ino·dev 가 읽은 그 파일의 것이어야 한다(§2.11).
3064
3119
  // ★ **덧붙임은 이 fd 를 자리 읽기로만 쓴다**(C8) — 전사를 통째로 안 읽는 것이 이 커밋의 요지다.
3065
3120
  const fd = fs.openSync(f, 'r');
3066
- try { return await pushSessionFd(fd, f, tenant, uuid, title, dryRun, bind, auto, hooks, tenantIdHint); }
3121
+ try {
3122
+ const result = await pushSessionFd(fd, f, tenant, uuid, title, dryRun, bind, auto, hooks, tenantIdHint, native);
3123
+ return native ? { ...result, before: native.source_bytes, source_client: 'codex', source: {
3124
+ codec: native.codec, raw_bytes: native.source_bytes, complete_bytes: native.raw_off,
3125
+ records: native.records, kept: native.kept, deferred_bytes: native.deferred_bytes, projection_bytes: native.projection_bytes,
3126
+ omitted_images: native.omitted_images, omitted_large: native.omitted_large } } : result;
3127
+ }
3067
3128
  finally { fs.closeSync(fd); }
3068
3129
  }
3069
3130
 
3070
- async function pushSessionFd(fd, f, tenant, uuid, title, dryRun, bind, auto, hooks, tenantIdHint) {
3131
+ async function pushSessionFd(fd, f, tenant, uuid, title, dryRun, bind, auto, hooks, tenantIdHint, native = null) {
3071
3132
  const st = fs.fstatSync(fd, { bigint: true });
3072
3133
  const size = Number(st.size);
3073
3134
  const isCursorBody = f.includes(`${path.sep}.cursor${path.sep}projects${path.sep}`);
@@ -3153,7 +3214,7 @@ async function pushSessionFd(fd, f, tenant, uuid, title, dryRun, bind, auto, hoo
3153
3214
  const body = {
3154
3215
  tenant, uuid,
3155
3216
  origin_cwd: sc.cwd || (isCursorBody ? relHome(cursorWorkspaceFolder() || '') : relHome(process.cwd())),
3156
- origin_git: gitHere(), tools: toolsStamp(),
3217
+ origin_git: gitHere(native ? (sc.cwd_raw || native.cwd) : process.cwd()), tools: toolsStamp(native),
3157
3218
  jsonl: sc.jsonl,
3158
3219
  };
3159
3220
  if (typeof title === 'string' && title.trim() !== '') body.title = title.trim();
@@ -4162,6 +4223,7 @@ function beatTargets(extra = []) {
4162
4223
  const now = Date.now();
4163
4224
  const out = [];
4164
4225
  for (const [uuid, e] of Object.entries(idx)) {
4226
+ if (!sessionScope(uuid)) continue;
4165
4227
  if (!e?.tenant_id) continue;
4166
4228
  const f = transcriptOf(uuid);
4167
4229
  if (!f) continue; // 이 기계에 사본이 없으면 「쥐고 있다」가 아니다
@@ -4742,6 +4804,40 @@ function autoFolderOf(real, now = Date.now()) {
4742
4804
  let autoScan = { at: 0, rows: [] }; // 되찾기 훑기의 마지막 결과 — [{ uuid, file, mtime }]
4743
4805
  const autoSettled = new Map(); // uuid → 'size:mtime' — 서버가 SAME·BEHIND 라 한 사본(파일이 바뀌면 다시 본다)
4744
4806
  const autoTenantWait = new Map(); // tenant_id → epoch ms — 429 auto_rate 의 retry_after(그 상한은 소속처 단위다)
4807
+ let nativeInventory = { at: 0, rows: [] };
4808
+ const nativeAutoRows = new Map();
4809
+
4810
+ /** Native date directories carry no tenant. Only validated source cwd and an effective confirmed binding do. */
4811
+ async function refreshCodexSessions(now = Date.now()) {
4812
+ const byDir = autoFolderMap(resolveCacheRead(), now), idx = indexRead(), mine = mySessionId();
4813
+ if (now - nativeInventory.at >= AUTO_SCAN_EVERY_MS) {
4814
+ nativeInventory = { at: now, rows: codexRollouts().filter(r => sessionScope(r.uuid)).map(r => codexMetadata(r.file, r.uuid)).filter(Boolean)
4815
+ .sort((a, b) => b.mtime - a.mtime) };
4816
+ }
4817
+ const perFolder = new Map();
4818
+ nativeAutoRows.clear();
4819
+ for (const meta of nativeInventory.rows) {
4820
+ // Background child transcripts are separate conversations, never implicit root-session backfill.
4821
+ if (!codexHumanRoot(meta)) continue;
4822
+ const known = autoRowOnServer(idx[meta.uuid]);
4823
+ let real;
4824
+ try { real = fs.realpathSync(meta.cwd); } catch { continue; }
4825
+ const hit = byDir.get(transcriptDir(real));
4826
+ const permitted = autoPushOn() && hit?.entry.state === 'bound' && hit.entry.auto_upload === true && !hit.entry.stale;
4827
+ if (!known && !permitted) continue;
4828
+ if (!known && meta.uuid !== mine && (now - meta.mtime > AUTO_SCAN_MAX_AGE_MS || meta.mtime < autoFloorMs(hit.entry))) continue;
4829
+ const count = perFolder.get(real) || 0;
4830
+ if (!known && meta.uuid !== mine && count >= AUTO_SCAN_PER_FOLDER) continue;
4831
+ perFolder.set(real, count + 1);
4832
+ try {
4833
+ const fresh = codexMetadata(meta.file, meta.uuid);
4834
+ if (!fresh) continue;
4835
+ const prepared = await prepareCodexProjection(fresh, { cacheRoot: codexProjectionRoot(), scrub: scrubTranscript, scrubRevision: SCRUB_REV });
4836
+ pathCache.set(meta.uuid, { file: prepared.projection, at: now });
4837
+ if (hit) nativeAutoRows.set(meta.uuid, { ...prepared, hit });
4838
+ } catch (e) { warnOnce(`codex-projection-${meta.uuid}`, `native Codex 세션 ${meta.uuid.slice(0, 8)}을 준비하지 못했다: ${e.message}`); }
4839
+ }
4840
+ }
4745
4841
 
4746
4842
  /** 전사 자리(transcriptDir) → 그 자리를 물었던 폴더의 답. resolve 캐시의 `by_path` 가 유일한 재료다. */
4747
4843
  function autoFolderMap(cache = resolveCacheRead(), now = Date.now()) {
@@ -4765,8 +4861,8 @@ function autoCandidates(now = Date.now()) {
4765
4861
  const mine = mySessionId();
4766
4862
  const seen = new Map();
4767
4863
  const add = (uuid, file) => {
4768
- if (!file || seen.has(uuid) || isCursorTranscript(file)) return;
4769
- const hit = byDir.get(path.dirname(file));
4864
+ if (!sessionScope(uuid) || !file || seen.has(uuid) || isCursorTranscript(file)) return;
4865
+ const hit = nativeAutoRows.get(uuid)?.hit || byDir.get(path.dirname(file));
4770
4866
  if (!hit) return;
4771
4867
  let st;
4772
4868
  try { st = fs.statSync(file); } catch { return; }
@@ -4801,6 +4897,7 @@ function autoCandidates(now = Date.now()) {
4801
4897
  autoScan = { at: now, rows };
4802
4898
  }
4803
4899
  for (const r of autoScan.rows) if (!idx[r.uuid]) add(r.uuid, r.file);
4900
+ for (const [uuid, native] of nativeAutoRows) add(uuid, native.projection);
4804
4901
  const out = [...seen.values()];
4805
4902
  for (const c of out) {
4806
4903
  if (c.entry.state !== 'bound' || c.entry.auto_upload !== true) continue;
@@ -5162,6 +5259,8 @@ function autoPolicyOf(t, ctx) {
5162
5259
  * ⚠ 자동이 **꺼졌거나·낡았거나·ambiguous** 인 폴더는 아래 null ① 그대로다(privacy B5) — 오너가 끈 동의를 옛 길이 안 거스른다.
5163
5260
  */
5164
5261
  const boundOn = st === 'bound' && e.auto_upload === true && !e.stale;
5262
+ // Native support starts with explicit effective binding consent; it has no historical legacy-upload lane.
5263
+ if (t.native && !(boundOn && t.c)) return no('not_bound_auto');
5165
5264
  // ── bound ──
5166
5265
  if (boundOn && t.c) {
5167
5266
  if (ctx.client_old) return no('client_old'); // ① 만 끈다(서버가 426 으로 막을 자리)
@@ -5421,18 +5520,19 @@ async function autoSyncRound(reply, recs, cands, ctx0 = null) {
5421
5520
  const seen = new Set();
5422
5521
  for (const c of autoOrder(cands)) {
5423
5522
  seen.add(c.uuid);
5424
- list.push({ uuid: c.uuid, c, file: c.file, size: c.size, mtime: c.mtime, entry: c.entry, row: c.row, rec: recOf.get(c.uuid) || null });
5523
+ list.push({ uuid: c.uuid, c, file: c.file, native: isNativeProjection(c.file), size: c.size, mtime: c.mtime, entry: c.entry, row: c.row, rec: recOf.get(c.uuid) || null });
5425
5524
  }
5426
5525
  for (const v of reply) {
5526
+ if (!sessionScope(v.uuid)) continue;
5427
5527
  if (seen.has(v.uuid) || v.verdict !== BEAT_VERDICT.AHEAD) continue;
5428
5528
  seen.add(v.uuid);
5429
5529
  const file = transcriptOf(v.uuid);
5430
5530
  if (!file) continue;
5431
5531
  let st;
5432
5532
  try { st = fs.statSync(file); } catch { continue; }
5433
- const hit = isCursorTranscript(file) ? null : byDir.get(path.dirname(file));
5533
+ const hit = isCursorTranscript(file) ? null : (nativeAutoRows.get(v.uuid)?.hit || byDir.get(path.dirname(file)));
5434
5534
  const row = ctx.idx?.[v.uuid] && typeof ctx.idx[v.uuid] === 'object' ? ctx.idx[v.uuid] : null;
5435
- list.push({ uuid: v.uuid, c: null, file, size: st.size, mtime: st.mtimeMs, entry: hit ? hit.entry : null, row, rec: recOf.get(v.uuid) || null });
5535
+ list.push({ uuid: v.uuid, c: null, file, native: isNativeProjection(file), size: st.size, mtime: st.mtimeMs, entry: hit ? hit.entry : null, row, rec: recOf.get(v.uuid) || null });
5436
5536
  }
5437
5537
  for (const t of list) {
5438
5538
  t.v = verdict.get(t.uuid) || null;
@@ -5453,7 +5553,7 @@ function autoHoldTenants(uuid) {
5453
5553
  const hits = [];
5454
5554
  const f = transcriptOf(uuid);
5455
5555
  if (f && !isCursorTranscript(f)) {
5456
- const hit = autoFolderMap(cache).get(path.dirname(f));
5556
+ const hit = nativeAutoRows.get(uuid)?.hit || autoFolderMap(cache).get(path.dirname(f));
5457
5557
  if (hit) hits.push(hit.entry);
5458
5558
  }
5459
5559
  if (uuid === mySessionId()) {
@@ -5485,6 +5585,7 @@ async function beatTick() {
5485
5585
  try {
5486
5586
  // 폴더 소속은 서버가 정한다 — 때가 되면 resolve 캐시를 갱신한다(§5.3). 조기 반환보다 **앞**이다.
5487
5587
  try { await resolveTick(); } catch { /* 소속 확인 실패가 심박을 멈추면 안 된다 */ }
5588
+ try { await refreshCodexSessions(); } catch (e) { warnOnce('codex-discovery', `native Codex 후보를 확인하지 못했다: ${e.message}`); }
5488
5589
  minClientTick(); // 판 비교 — 30분에 한 번(§5.8). 조기 반환보다 **앞**이다
5489
5590
  // 확정·자동 폴더의 후보(§5.5) — 서버 소속을 이 기계가 모르는 사본도 판정을 받게 심박에 싣는다.
5490
5591
  let cands = [];
@@ -6330,8 +6431,8 @@ const TOOLS = [
6330
6431
  {
6331
6432
  name: 'push_session',
6332
6433
  description:
6333
- '이 기계의 대화 하나를 **검열해서** 서버에 올린다. 다른 기계가 pull_session 으로 받아 ' +
6334
- '`claude --resume <uuid>` 로 이어 갈 수 있다.\n' +
6434
+ '이 기계의 대화 하나를 **검열해서** 서버에 올린다. Native Codex 원본도 지원하며 source client를 보존한다. ' +
6435
+ '다른 기계는 resume_session으로 작업 맥락을 읽는다. Claude 원본은 pull_session 뒤 claude --resume으로 열 수 있다.\n' +
6335
6436
  '올라가지 않는 것: 도구 실행 결과·첨부·파일 이력·집 경로·비밀 모양(토큰·비밀번호·개인키). ' +
6336
6437
  '**도구 호출의 짝과 인자는 남는다** — 그게 없으면 대화가 안 열린다.\n' +
6337
6438
  '긴 대화는 **알아서 조각으로** 나눠 보내고 서버가 잇는다 — 크기 때문에 사람이 할 일은 없다.\n' +
@@ -6769,7 +6870,9 @@ function tenantNoticeText(v) {
6769
6870
  const who = v.confirmed_by ? `${v.confirmed_by}${josaIGa(v.confirmed_by)} ` : '';
6770
6871
  return `genetics: 이 세션은 ${T} 소속으로 확정된 폴더(${v.path} · ${who}${String(v.confirmed_at || '').slice(0, 10)} 확정, 자동 올리기 켬)에서 `
6771
6872
  + '돌고 있어, 이 대화가 서버에 자동으로 올라간다. 도구 실행 결과·첨부·파일 이력은 빠지고 비밀 모양은 가려지지만, '
6772
- + `**주고받은 글과 생각(thinking), 도구 호출 인자는 그대로** ${T} 운영진에게 보인다. `
6873
+ + (v.source_client === 'codex'
6874
+ ? `**주고받은 글과 도구 호출 인자**는 ${T} 운영진에게 보인다. 내부 추론은 제외한다. `
6875
+ : `**주고받은 글과 생각(thinking), 도구 호출 인자는 그대로** ${T} 운영진에게 보인다. `)
6773
6876
  + '끄는 길 — 포털 「폴더」에서 자동 올리기 끄기(서버가 강제), 이 폴더의 .genetics-private 파일 또는 '
6774
6877
  + 'GENETICS_NO_AUTO_PUSH=1(이 기계의 클라이언트 약속), 이 대화만 빼려면 no_auto_push. '
6775
6878
  + '이미 올라간 것은 꺼도 안 사라진다 — 포털 「폴더」에서 지운다.';
@@ -6843,7 +6946,8 @@ function tenantNoticeView() {
6843
6946
  const e = v.entry || {};
6844
6947
  const row = indexRead()[mine];
6845
6948
  const says = resolveSessionSays(it.session); // 그 uuid 에 대한 서버의 사실(N10) — bound 갈래가 읽는다
6846
- const base = { state: it.state, tenant: e.tenant, path: v.fo.path, confirmed_by: e.confirmed_by,
6949
+ const base = { state: it.state, tenant: e.tenant, path: v.fo.path,
6950
+ source_client: mine && mine === codexSessionId() ? 'codex' : '', confirmed_by: e.confirmed_by,
6847
6951
  confirmed_at: e.confirmed_at, auto_upload: e.auto_upload };
6848
6952
  switch (it.state) {
6849
6953
  case 'bound':
@@ -7023,6 +7127,7 @@ function notifyToolsChanged(sendFn) {
7023
7127
  }
7024
7128
 
7025
7129
  function toolsNow() {
7130
+ if (sessionToolMode()) return TOOLS.filter(t => ['whoami', 'list_sessions', 'push_session'].includes(t.name));
7026
7131
  const facts = worldFacts(3);
7027
7132
  if (!facts || !facts.length) return TOOLS; // 조용한 구간은 조용하다
7028
7133
  const head = `\n\n지금 이 저장소:\n${facts.map((f) => ` · ${f}`).join('\n')}`;
@@ -7215,7 +7320,7 @@ async function mailRegister(tenant, mine, force = false) {
7215
7320
  session: mine,
7216
7321
  name: name || '',
7217
7322
  // 신원이 어디서 왔는지 그대로 말한다 — Cursor 대화에 claude-code 이름표를 달지 않는다.
7218
- client: mine === cursorActiveComposer() ? 'cursor' : 'claude-code',
7323
+ client: mine === codexSessionId() ? 'codex' : (mine === cursorActiveComposer() ? 'cursor' : 'claude-code'),
7219
7324
  cwd: relHome(process.cwd()),
7220
7325
  });
7221
7326
  }
@@ -7234,8 +7339,8 @@ async function mailPull() {
7234
7339
  if (Date.now() - mailAt < MAIL_POLL_MS) return;
7235
7340
  mailAt = Date.now();
7236
7341
 
7237
- for (const [tenant] of tenants) {
7238
- try { await mailPullOne(tenant, mine, tenant === indexTenant ? indexEntry : null); }
7342
+ for (const [tenant, tenantId] of tenants) {
7343
+ try { await mailPullOne(tenant, mine, tenant === indexTenant ? indexEntry : null, tenantId); }
7239
7344
  catch { /* 한 소속처가 막혀도 나머지는 돈다 — 다음 바퀴에 다시 */ }
7240
7345
  }
7241
7346
 
@@ -7262,8 +7367,175 @@ async function mailPull() {
7262
7367
  }
7263
7368
  }
7264
7369
 
7370
+ /* Native Codex shares this exact journal/lease with cell/src/codex_mail.rs.
7371
+ * No live writer is stolen by age. Interrupted reaper/malformed owner fails closed.
7372
+ * Claude/Cursor retain their existing mailbox contract below. */
7373
+ const CODEX_MAIL_STATE_MAX = 524288;
7374
+ const CODEX_MAIL_SAYS_MAX = 65536;
7375
+ function codexMailPrivate(file, directory = false) {
7376
+ try {
7377
+ const st = fs.lstatSync(file);
7378
+ return !st.isSymbolicLink() && (directory ? st.isDirectory() : st.isFile())
7379
+ && (st.mode & 0o077) === 0 && (!process.getuid || st.uid === process.getuid());
7380
+ } catch { return false; }
7381
+ }
7382
+ function codexMailExists(file) {
7383
+ try { fs.lstatSync(file); return true; } catch (e) { if (e.code === 'ENOENT') return false; throw e; }
7384
+ }
7385
+ function codexMailRead(file, max = CODEX_MAIL_STATE_MAX) {
7386
+ if (!codexMailPrivate(file)) throw new Error('mail_state');
7387
+ const fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
7388
+ try {
7389
+ const st = fs.fstatSync(fd);
7390
+ if (!st.isFile() || (st.mode & 0o077) || (process.getuid && st.uid !== process.getuid()) || st.size > max) throw new Error('mail_state_bound');
7391
+ const raw = Buffer.alloc(max + 1); let offset = 0;
7392
+ for (let chunks = 0; chunks <= max + 1; chunks++) {
7393
+ const n = fs.readSync(fd, raw, offset, raw.length - offset, null);
7394
+ if (!n) return new TextDecoder('utf-8', {fatal:true}).decode(raw.subarray(0,offset));
7395
+ offset += n;
7396
+ if (offset > max) throw new Error('mail_state_bound');
7397
+ }
7398
+ throw new Error('mail_state_bound');
7399
+ } finally { fs.closeSync(fd); }
7400
+ }
7401
+ function codexMailKeys(v, keys) {
7402
+ return v && typeof v === 'object' && !Array.isArray(v)
7403
+ && Object.keys(v).length === keys.length && keys.every(k => Object.hasOwn(v, k));
7404
+ }
7405
+ function codexMailPending(v) {
7406
+ return v === null || (codexMailKeys(v, ['ids','says']) && Array.isArray(v.ids)
7407
+ && v.ids.length > 0 && v.ids.length <= 20 && new Set(v.ids).size === v.ids.length
7408
+ && v.ids.every(n => Number.isSafeInteger(n) && n > 0)
7409
+ && typeof v.says === 'string' && v.says.length > 0 && Buffer.byteLength(v.says) <= CODEX_MAIL_SAYS_MAX);
7410
+ }
7411
+ function codexMailAtomic(file, text) {
7412
+ const raw = Buffer.from(text), temp = `${file}.tmp-${process.pid}-${crypto.randomBytes(16).toString('hex')}`;
7413
+ if (raw.length > CODEX_MAIL_STATE_MAX) throw new Error('mail_state_bound');
7414
+ let fd = null;
7415
+ try {
7416
+ fd = fs.openSync(temp,'wx',0o600); fs.fchmodSync(fd,0o600);
7417
+ let offset = 0;
7418
+ for (let writes = 0; offset < raw.length; writes++) {
7419
+ if (writes >= raw.length) throw new Error('mail_write_bound');
7420
+ const n = fs.writeSync(fd,raw,offset,raw.length-offset,null);
7421
+ if (!Number.isInteger(n) || n < 1 || n > raw.length-offset) throw new Error('mail_write');
7422
+ offset += n;
7423
+ }
7424
+ fs.fsyncSync(fd); fs.closeSync(fd); fd = null; fs.renameSync(temp,file);
7425
+ } finally {
7426
+ if (fd !== null) fs.closeSync(fd);
7427
+ try { fs.unlinkSync(temp); } catch (e) { if (e.code !== 'ENOENT') throw e; }
7428
+ }
7429
+ }
7430
+ function codexMailStore(file, value) {
7431
+ const unlock = codexMailLease(path.join(path.dirname(file), '.inventory'));
7432
+ if (!unlock) throw new Error('mail_inventory_busy');
7433
+ try {
7434
+ const raw = JSON.stringify(value);
7435
+ codexMailInventory(path.dirname(file), Buffer.byteLength(raw), file);
7436
+ if (Buffer.byteLength(raw) > CODEX_MAIL_STATE_MAX || (codexMailExists(file) && !codexMailPrivate(file))) throw new Error('mail_state');
7437
+ codexMailAtomic(file, raw);
7438
+ const fd = fs.openSync(path.dirname(file), 'r');
7439
+ try { fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
7440
+ } finally { unlock(); }
7441
+ }
7442
+ function codexMailInventory(dir, incoming, target) {
7443
+ const scan = fs.opendirSync(dir); let entries = 0, journals = 0, bytes = 0;
7444
+ try {
7445
+ for (;;) {
7446
+ const next = scan.readSync(); if (!next) break;
7447
+ if (++entries >= 510) throw new Error('mail_inventory_bound');
7448
+ const file = path.join(dir,next.name);
7449
+ if (!codexMailPrivate(file)) throw new Error('mail_inventory');
7450
+ bytes += fs.lstatSync(file).size;
7451
+ if (bytes > 16777216) throw new Error('mail_inventory_bound');
7452
+ if (next.name.startsWith('v1-') && next.name.endsWith('.json')) journals++;
7453
+ }
7454
+ if (journals > 128 || (!codexMailExists(target) && journals >= 128) || bytes + incoming > 16777216) throw new Error('mail_inventory_bound');
7455
+ } finally { scan.closeSync(); }
7456
+ }
7457
+ function codexMailLease(file) {
7458
+ const lock = file + '.lock', reap = lock + '.reap';
7459
+ const token = JSON.stringify({version:1,pid:process.pid,host:os.hostname(),nonce:crypto.randomBytes(16).toString('hex')});
7460
+ const create = name => { const fd = fs.openSync(name, 'wx', 0o600); try { fs.fchmodSync(fd, 0o600); fs.writeFileSync(fd, token); fs.fsyncSync(fd); } finally { fs.closeSync(fd); } };
7461
+ try { create(lock); } catch (e) {
7462
+ if (e.code !== 'EEXIST') return null;
7463
+ try { create(reap); } catch { return null; }
7464
+ try {
7465
+ const owner = JSON.parse(codexMailRead(lock, 1024));
7466
+ if (!codexMailKeys(owner, ['version','pid','host','nonce']) || owner.version !== 1
7467
+ || owner.host !== os.hostname() || typeof owner.nonce !== 'string' || !owner.nonce || Buffer.byteLength(owner.nonce) > 128 || !Number.isSafeInteger(owner.pid) || owner.pid < 1 || owner.pid > 2147483647) return null;
7468
+ try { process.kill(owner.pid, 0); return null; } catch (dead) { if (dead.code !== 'ESRCH') return null; }
7469
+ fs.unlinkSync(lock); create(lock);
7470
+ } catch { return null; } finally { try { fs.unlinkSync(reap); } catch {} }
7471
+ }
7472
+ return () => { try { if (codexMailRead(lock, 1024) === token) fs.unlinkSync(lock); } catch {} };
7473
+ }
7474
+ async function codexMailApi(method, pathname, body, cred, deadline) {
7475
+ const remaining = Math.ceil(deadline - performance.now());
7476
+ if (remaining <= 0 || !hostAllowed(BASE)) throw new Error('mail_deadline');
7477
+ const response = await fetch(BASE.replace(/\/+$/, '') + pathname, {method, headers:{Authorization:`Bearer ${cred.token}`,'Content-Type':'application/json'},
7478
+ body:body === undefined ? undefined : JSON.stringify(body), signal:AbortSignal.timeout(remaining)});
7479
+ const reader = response.body?.getReader();
7480
+ if (!reader) throw new Error('mail_response');
7481
+ const raw = Buffer.alloc(262144); let bytes = 0, chunks = 0;
7482
+ try {
7483
+ for (;;) {
7484
+ if (++chunks > 8192 || performance.now() >= deadline) throw new Error('mail_response_bound');
7485
+ const {value,done} = await reader.read(); if (done) break;
7486
+ if (bytes + value.byteLength > raw.length) throw new Error('mail_response_bound');
7487
+ raw.set(value,bytes); bytes += value.byteLength;
7488
+ }
7489
+ } finally { await reader.cancel().catch(() => {}); }
7490
+ if (performance.now() > deadline) throw new Error('mail_deadline');
7491
+ const result = JSON.parse(new TextDecoder('utf-8', {fatal:true}).decode(raw.subarray(0,bytes)));
7492
+ if (!response.ok || result?.ok !== true) throw new Error('mail_refused');
7493
+ return result;
7494
+ }
7495
+ async function codexMailPullOne(tenant, mine, tenantId) {
7496
+ if (!Number.isSafeInteger(tenantId) || tenantId < 1 || tenantId > 4294967295) return;
7497
+ const cred = credential(); if (!cred || cred.kind !== 'user') return;
7498
+ const dir = path.join(path.dirname(CRED_FILE), 'mail-receive');
7499
+ try { fs.mkdirSync(dir, {mode:0o700}); } catch (e) { if (e.code !== 'EEXIST') throw e; }
7500
+ if (!codexMailPrivate(dir,true)) throw new Error('mail_directory');
7501
+ const tokenHash = crypto.createHash('sha256').update(cred.token).digest('hex');
7502
+ const key = crypto.createHash('sha256').update(`${BASE.replace(/\/+$/, '')}\0${tenantId}\0${mine}\0${tokenHash}`).digest('hex');
7503
+ const file = path.join(dir, `v1-${key}.json`);
7504
+ const admission = codexMailLease(path.join(dir, '.inventory')); if (!admission) return;
7505
+ let unlock;
7506
+ try { codexMailInventory(dir, 0, file); unlock = codexMailLease(file); } finally { admission(); }
7507
+ if (!unlock) return;
7508
+ try {
7509
+ let state = {version:1,last_checked_ms:0,pending:null};
7510
+ if (codexMailExists(file)) state = JSON.parse(codexMailRead(file));
7511
+ if (!codexMailKeys(state,['version','last_checked_ms','pending']) || state.version !== 1
7512
+ || !Number.isSafeInteger(state.last_checked_ms) || state.last_checked_ms < 0 || state.last_checked_ms > Date.now()
7513
+ || !codexMailPending(state.pending)) throw new Error('mail_state');
7514
+ if (!state.pending && Date.now() - state.last_checked_ms < 8000) return;
7515
+ const deadline = performance.now() + 3000;
7516
+ // Successful server registration is required; no caller-provided sender label.
7517
+ const registered = await codexMailApi('POST','/api/mail/register',{tenant,session:mine,name:'',client:'codex',cwd:relHome(process.cwd())},cred,deadline);
7518
+ if (registered.tenant_id !== tenantId || registered.session !== mine) throw new Error('mail_registration_changed');
7519
+ if (!state.pending) {
7520
+ const r = await codexMailApi('GET','/api/mail?tenant='+encodeURIComponent(tenant)+'&session='+encodeURIComponent(mine)+'&limit=20',undefined,cred,deadline);
7521
+ if (!Array.isArray(r.mail)) throw new Error('mail_response');
7522
+ const batch = r.mail.length ? {ids:r.mail.map(m => m.id),says:r.says} : null;
7523
+ if (!codexMailPending(batch)) throw new Error('mail_response');
7524
+ mailCollectReturned(r);
7525
+ state.last_checked_ms = Date.now(); state.pending = batch; codexMailStore(file,state);
7526
+ }
7527
+ if (!state.pending) return;
7528
+ const ack = await codexMailApi('POST','/api/mail/ack',{tenant,ids:state.pending.ids},cred,deadline);
7529
+ if (ack.asked !== state.pending.ids.length || !Number.isSafeInteger(ack.stamped) || ack.stamped < 0 || ack.stamped > ack.asked) throw new Error('mail_ack');
7530
+ const says = state.pending.says;
7531
+ state.pending = null; codexMailStore(file,state);
7532
+ mailQueue.push(says); // Never fabricate provenance; preserve server-rendered wording.
7533
+ } finally { unlock(); }
7534
+ }
7535
+
7265
7536
  /** 한 소속처의 내 편지함을 긷고 **받았다고 도장을 찍는다**(도장은 큐에 넣기 **전에** — 재점화 방지). */
7266
- async function mailPullOne(tenant, mine, indexEntry) {
7537
+ async function mailPullOne(tenant, mine, indexEntry, tenantId = 0) {
7538
+ if (process.platform !== 'win32' && !process.env.CLAUDE_CODE_SESSION_ID && mine === codexSessionId()) return await codexMailPullOne(tenant, mine, tenantId || Number(indexEntry?.tenant_id));
7267
7539
  try { await mailRegister(tenant, mine); } catch { /* 등록 실패가 받기를 막지 않는다 */ }
7268
7540
 
7269
7541
  if (!mailCursors.has(tenant)) mailCursors.set(tenant, Number(indexEntry?.mail_cursor || 0));
@@ -7302,6 +7574,10 @@ async function mailPullOne(tenant, mine, indexEntry) {
7302
7574
  if (typeof r?.says === 'string' && r.says) mailQueue.push(r.says);
7303
7575
  }
7304
7576
 
7577
+ mailCollectReturned(r);
7578
+ }
7579
+
7580
+ function mailCollectReturned(r) {
7305
7581
  for (const b of (Array.isArray(r?.returned) ? r.returned : [])) {
7306
7582
  const id = Number(b?.id) || 0;
7307
7583
  if (!id || mailBackSeen.has(id)) continue; // 서버가 1시간 동안 되풀이해 준다 — 접는 것은 우리 몫
@@ -8489,7 +8765,7 @@ async function callTool(name, args = {}) {
8489
8765
  }
8490
8766
  return `${r.tenant} — ${count}\n${rows.join('\n')}\n\n`
8491
8767
  + '이어받으려면 `resume_session` — 지금 이 창이 그 대화를 읽고 이어서 일합니다.\n'
8492
- + '원본 그대로 열려면 `pull_session` 으로 받은 뒤 그 폴더에서 `claude --resume <uuid>`.'
8768
+ + 'Claude 원본 복원은 `pull_session` 뒤 `claude --resume <uuid>`. Native Codex는 업무 맥락만 읽습니다.'
8493
8769
  + (follow.length ? `\n\n${follow.join('\n')}` : '');
8494
8770
  }
8495
8771
  case 'push_session': {
@@ -8529,6 +8805,9 @@ async function callTool(name, args = {}) {
8529
8805
  throw e;
8530
8806
  } finally { fileUnlock(lease); }
8531
8807
  const { r, before, parts, plan, appended } = pushed;
8808
+ const sourceNote = pushed.source ? `\nCodex 원본 ${pushed.source.records}레코드 → ${pushed.source.kept}발화·도구 레코드; `
8809
+ + `이미지 값 ${pushed.source.omitted_images || 0}개·큰 내부/도구 레코드 ${pushed.source.omitted_large || 0}개 생략; `
8810
+ + `미완료 끝줄 ${pushed.source.deferred_bytes}B는 다음 갱신으로 넘겼습니다.` : '';
8532
8811
  // 표시를 **덧붙임 X B / 전체 Y B** 로(§3.4) — 통째로 갔으면 전처럼 「전 → 후」다.
8533
8812
  const cut = plan?.mode === 'APPEND' || plan?.mode === 'ASK'
8534
8813
  ? `덧붙임 ${Number(appended || 0).toLocaleString()}B / 전체 ${r.bytes.toLocaleString()}B`
@@ -8539,7 +8818,7 @@ async function callTool(name, args = {}) {
8539
8818
  const how = parts > 1 ? ` · ${parts}조각으로` : '';
8540
8819
  if (r.dry_run) {
8541
8820
  return `시험 실행 — 아무것도 저장하지 않았습니다.\n`
8542
- + `${r.tenant} · ${r.lines}줄 · 뺀 레코드 ${r.dropped} · 가린 자리 ${r.redacted} · ${cut}${how}`;
8821
+ + `${r.tenant} · ${r.lines}줄 · 뺀 레코드 ${r.dropped} · 가린 자리 ${r.redacted} · ${cut}${how}` + sourceNote;
8543
8822
  }
8544
8823
  /*
8545
8824
  * ★ **두뇌 큐에 들어갔는지 말한다.** 서버는 `wiki`(queued|not-queued)와 `note` 를
@@ -8554,7 +8833,8 @@ async function callTool(name, args = {}) {
8554
8833
  + `${r.lines}줄 · 뺀 레코드 ${r.dropped}(첨부·파일이력) · 가린 자리 ${r.redacted} · ${cut}${how}\n`
8555
8834
  + (queued ? queued + '\n' : '')
8556
8835
  + bindNote
8557
- + '다른 기계에서: pull_session 으로 받은 뒤 그 폴더에서 `claude --resume ' + args.uuid + '`';
8836
+ + (pushed.source_client === 'codex' ? '다른 기계에서: resume_session 으로 이 작업 맥락을 읽을 수 있습니다. Codex 원본 복원은 하지 않습니다.'
8837
+ : '다른 기계에서: pull_session 으로 받은 뒤 그 폴더에서 `claude --resume ' + args.uuid + '`') + sourceNote;
8558
8838
  }
8559
8839
  case 'resume_session': {
8560
8840
  /*
@@ -8580,6 +8860,7 @@ async function callTool(name, args = {}) {
8580
8860
  const q = `?tenant=${encodeURIComponent(args.tenant)}`;
8581
8861
  const r = await api('GET',
8582
8862
  `/api/sessions/${encodeURIComponent(args.uuid)}${q}&no_body=1&digest=1&digest_chars=${chars}`);
8863
+ const native = nativeSessionTools(r.tools);
8583
8864
  indexNote(args.uuid, { src: 'resume', tenant: r.tenant || args.tenant, tenant_id: r.tenant_id, head_id: r.head_id, head_ms: r.head_ms, ...(r.tenant_slug ? { tenant_slug: r.tenant_slug } : {}) }, 'rx');
8584
8865
 
8585
8866
  // 원본도 제자리에 놓는다 — **덮지 않는다**(pull_session 과 같은 이유).
@@ -8587,7 +8868,9 @@ async function callTool(name, args = {}) {
8587
8868
  const dir = transcriptDir(into);
8588
8869
  const target = path.join(dir, `${args.uuid}.jsonl`);
8589
8870
  let placed = target;
8590
- if (fs.existsSync(target)) {
8871
+ if (native) {
8872
+ placed = 'Codex 업무 맥락만 읽었습니다 — 원본 전사 파일을 만들지 않았습니다';
8873
+ } else if (fs.existsSync(target)) {
8591
8874
  placed = `${target} (이미 있어 그대로 두었습니다 — 덮으면 이 기계에서 하던 대화가 사라집니다)`;
8592
8875
  } else {
8593
8876
  await apiToFile(`/api/sessions/${encodeURIComponent(args.uuid)}${q}&raw=1`, target);
@@ -8600,7 +8883,7 @@ async function callTool(name, args = {}) {
8600
8883
  * 최대 20초 동안 다른 사람 화면에는 이 기계가 안 보인다 — 이어받은 직후가 바로
8601
8884
  * 「누가 잡았나」를 알아야 하는 순간이라 여기서 한 번 즉시 뛴다.
8602
8885
  */
8603
- beatTick().catch(() => {});
8886
+ if (!native) beatTick().catch(() => {});
8604
8887
 
8605
8888
  // **받기 전에 알아야 할 것을 먼저 쓴다.** 요지 뒤에 붙이면 긴 글에 묻힌다.
8606
8889
  const others = (r.devices || []).filter((d) => d.device !== deviceId());
@@ -8619,7 +8902,7 @@ async function callTool(name, args = {}) {
8619
8902
 
8620
8903
  return `이어받았습니다 — ${r.tenant} · ${args.uuid.slice(0, 8)} · ${r.turns || 0}턴 · ${trimmed}\n`
8621
8904
  + `원본을 놓은 자리: ${placed}\n`
8622
- + `원본 그대로 열려면: cd ${into} && claude --resume ${args.uuid}\n`
8905
+ + (native ? 'Native Codex 원본 복원은 지원하지 않습니다. 아래 맥락으로 이어서 작업합니다.\n' : `원본 그대로 열려면: cd ${into} && claude --resume ${args.uuid}\n`)
8623
8906
  + (r.redacted ? `※ 가린 자리 ${r.redacted}곳 — 도구 실행 결과는 안 실려 옵니다. 필요하면 다시 실행하세요.\n` : '')
8624
8907
  + warn
8625
8908
  + '\n다음에 할 일: 아래 대화를 읽고, 사용자에게 「어디까지 되어 있고 다음이 무엇인지」를 '
@@ -8638,6 +8921,8 @@ async function callTool(name, args = {}) {
8638
8921
  if (fs.existsSync(target)) {
8639
8922
  throw new Error(`이미 있습니다: ${target}\n덮지 않았습니다 — 이 기계에서 하던 대화가 사라집니다.`);
8640
8923
  }
8924
+ const meta = await api('GET', `/api/sessions/${encodeURIComponent(args.uuid)}?tenant=${encodeURIComponent(args.tenant)}&no_body=1`);
8925
+ if (nativeSessionTools(meta.tools)) throw new Error('Native Codex는 portal 업무 맥락 투영입니다. resume_session으로 읽으세요 — Claude/native 원본 파일은 만들지 않습니다.');
8641
8926
  // 봉투를 안 만든다 — 메타는 헤더로 온다(apiToFile 주석).
8642
8927
  const { headers, bytes } = await apiToFile(
8643
8928
  `/api/sessions/${encodeURIComponent(args.uuid)}?tenant=${encodeURIComponent(args.tenant)}&raw=1`,
@@ -8655,14 +8940,16 @@ async function callTool(name, args = {}) {
8655
8940
  const mine = mySessionId();
8656
8941
  if (!mine) {
8657
8942
  return '이 세션의 번호를 모른다 — 주소를 댈 수 없어 보낼 수 없다'
8658
- + '(CLAUDE_CODE_SESSION_ID 도, Cursor 활성 대화도 없는 환경이다).';
8943
+ + '(Claude 세션 번호·유효한 Codex 세션 UUID·Cursor 활성 대화가 없는 환경이다).';
8659
8944
  }
8660
8945
  /*
8661
- * 주소가 없으면 먼저 댄다. **이름이 없으면 서버가 409 로 거절**하므로,
8662
- * 그 경우를 여기서 미리 사람 말로 바꿔 준다 — 서버 오류문만 보면 무엇을 해야
8663
- * 하는지 알 수 없다.
8946
+ * Native Codex에는 Claude ListAgents 자기소개가 없다. strict harness UUID로
8947
+ * 주소를 등록하고 서버가 등록된 UUID로 발신 표시를 정한다(mail_store_sender_session).
8948
+ * 이름을 지어내거나 요청 인자의 발신자를 쓰지 않는다. Claude·미지 클라이언트의
8949
+ * 기존 이름 가드는 유지하고, 등록 거절은 아래에서 전송 전에 멈춘다.
8664
8950
  */
8665
- if (!myAgentName()) {
8951
+ const nativeCodex = !process.env.CLAUDE_CODE_SESSION_ID && mine === codexSessionId();
8952
+ if (!nativeCodex && !myAgentName()) {
8666
8953
  return '이 세션에는 아직 말 걸 수 있는 이름이 없어 **보낼 수가 없다**'
8667
8954
  + '(받는 쪽이 누가 말하는지 못 보면 그것은 메시지가 아니라 주입이다).\n'
8668
8955
  + '이름은 `ListAgents` 를 한 번 부르면 생긴다 — 하네스가 「This session is …」를 '
@@ -8869,12 +9156,22 @@ async function handle(line) {
8869
9156
  result = { tools: toolsNow() };
8870
9157
  } else if (req.method === 'tools/call') {
8871
9158
  lastToolAt = Date.now(); // 유휴 조절기의 시계(자기 일정 표)
9159
+ const sessionOnly = sessionToolMode();
9160
+ if (sessionOnly && !['whoami', 'list_sessions', 'push_session'].includes(req.params?.name))
9161
+ throw new Error('Session-only client permits whoami, list_sessions, and explicit push_session only');
9162
+ if (SESSION_WATCH_UUID && req.params?.name === 'push_session' && !sessionScope(req.params?.arguments?.uuid))
9163
+ throw new Error('Session watch permits writes to its explicitly selected UUID only');
8872
9164
  // 도구응답 레인의 긷기 — **기다리지 않는다**(도구 지연은 그대로). 60초 상한은 mailPull 안의 mailAt 검사.
8873
9165
  // 도구가 망을 도는 동안 도착한 통은 이번 응답 꼬리에, 아니면 다음 응답에 실린다.
8874
- if (hookLane() && mailPollReady()) mailPull().catch(() => {});
9166
+ if (!sessionOnly && hookLane() && mailPollReady()) mailPull().catch(() => {});
8875
9167
  const text = await callTool(req.params?.name, req.params?.arguments || {});
8876
9168
  result = { content: [{ type: 'text', text }] };
8877
- worldAppend(result);
9169
+ if (!sessionOnly) worldAppend(result);
9170
+ else if (SESSION_WATCH_UUID) {
9171
+ // Publishing still requires the existing visible folder consent notice.
9172
+ const notice = tenantNotice();
9173
+ if (notice) result.content.push({ type: 'text', text: notice });
9174
+ }
8878
9175
  } else if (isNotification) {
8879
9176
  return;
8880
9177
  } else {
@@ -8888,7 +9185,7 @@ async function handle(line) {
8888
9185
  // 그래야 에이전트가 메시지를 읽고 고쳐서 다시 시도한다.
8889
9186
  if (req.method === 'tools/call') {
8890
9187
  const failed = { content: [{ type: 'text', text: `실패: ${e.message}` }], isError: true };
8891
- worldAppend(failed); // 실패한 턴에도 세계는 움직인다.
9188
+ if (!sessionToolMode()) worldAppend(failed); // 실패한 턴에도 세계는 움직인다.
8892
9189
  send({ jsonrpc: '2.0', id: req.id, result: failed });
8893
9190
  } else {
8894
9191
  send({ jsonrpc: '2.0', id: req.id, error: { code: -32603, message: String(e.message || e) } });
@@ -9406,6 +9703,7 @@ const SCHEDULE = [
9406
9703
  function startSchedule() {
9407
9704
  if (cfg.GENETICS_NO_BEAT) return; // 심박을 끄는 사람은 나머지도 안 원한다
9408
9705
  for (const row of SCHEDULE) {
9706
+ if (SESSION_WATCH_UUID && row.name !== 'beat') continue;
9409
9707
  if (row.start) { row.start(); continue; }
9410
9708
  if (row.ready && !row.ready()) continue;
9411
9709
  const run = () => {
@@ -9422,17 +9720,19 @@ function startSchedule() {
9422
9720
  }
9423
9721
 
9424
9722
  if (IS_MAIN) {
9425
- ensureCellOnBoot(); // 세포가 먼저다 — 스킬보다 이쪽이 「무엇을 못 하는가」를 정한다
9426
- ensureSkillOnBoot();
9723
+ if (!sessionToolMode()) {
9724
+ ensureCellOnBoot(); // 세포가 먼저다 — 스킬보다 이쪽이 「무엇을 못 하는가」를 정한다
9725
+ ensureSkillOnBoot();
9726
+ }
9427
9727
  /*
9428
9728
  * **뜰 때 심박을 시작한다.** 이 프로세스가 사는 동안이 곧 「이 기계가 이 창을 열고 있는
9429
9729
  * 동안」이라, 시작·끝을 따로 알릴 필요가 없다. 자격이나 주소가 없으면 조용히 안 뛴다 —
9430
9730
  * 꺼진 것이지 고장이 아니다(그림 자격이 없을 때와 같은 태도).
9431
9731
  */
9432
- startSchedule();
9732
+ if (cfg.GENETICS_SESSION_ONLY !== '1') startSchedule();
9433
9733
  process.stdin.setEncoding('utf8');
9434
9734
  process.stdin.on('data', (chunk) => { buf += chunk; pump(); });
9435
- process.stdin.on('end', () => { pump(true); stopBeat().catch(() => {}); });
9735
+ process.stdin.on('end', () => { pump(true); if (cfg.GENETICS_SESSION_ONLY !== '1') stopBeat().catch(() => {}); });
9436
9736
  // 창을 닫으면 목록에서 **즉시** 빠진다. 못 알려도 90초 뒤 서버가 스스로 지운다.
9437
9737
  /*
9438
9738
  * ★ **SIGHUP 이 빠져 있었다.** 사람이 「대화창을 닫는다」고 할 때 실제로 오는 신호가 그것인데
@@ -9443,7 +9743,7 @@ if (IS_MAIN) {
9443
9743
  * 「창 닫기」만은 이 한 줄로 잡힌다.
9444
9744
  */
9445
9745
  for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
9446
- process.on(sig, () => { stopBeat().finally(() => process.exit(0)); });
9746
+ process.on(sig, () => { if (cfg.GENETICS_SESSION_ONLY === '1') process.exit(0); else stopBeat().finally(() => process.exit(0)); });
9447
9747
  }
9448
9748
  }
9449
9749
 
@@ -9469,6 +9769,7 @@ export { videoThumbs, taskReadTools, taskReadRequest, taskReadResult,
9469
9769
  autoRowOnServer,
9470
9770
  // 2026-09-18 Windows — 남의 규칙(Claude Code 슬러그)·플랫폼 갈래는 시험이 표로 걷는다
9471
9771
  transcriptSlug, transcriptDir, cursorUserRoot, sqliteBin, npxCacheResetHint, cellPlatformSupported,
9772
+ mySessionId, refreshCodexSessions, autoCandidates, autoSyncRound, callTool,
9472
9773
  HOLDS_FILE, UPDATE_BUDGET_MS,
9473
9774
  // DESIGN-session-delta-sync C6 — 규칙판·좌표·기계 키·대응표·머리 끊기·private 판정(시험이 걷는다)
9474
9775
  SCRUB_REV, CWD_HIST_MAX, PUSH_MAC_WIN, pushDir, pushKey, pushKeyId, pushMac, pushCoords,