genetics-mcp 2.24.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();
@@ -3556,12 +3617,12 @@ function indexTenant(uuid, t) {
3556
3617
  try { return indexWrite(uuid, t, {}, ''); } catch { return false; }
3557
3618
  }
3558
3619
 
3559
- /** 옛 부름 자리의 껍데기 — 소속 칸(서버 응답)과 나머지를 갈라 **한 번의 잠금으로** 적는다. */
3620
+ /** 옛 부름 자리의 껍데기 — 소속 칸과 나머지를 한 번에 적고, 쓰기 수락 여부를 돌려준다. */
3560
3621
  function indexNote(uuid, entry, flow = '') {
3561
- if (!uuid || !entry?.tenant_id) return;
3622
+ if (!uuid || !entry?.tenant_id) return false;
3562
3623
  const { tenant_id, tenant_slug, tenant, src, resolved_at, ...rest } = entry;
3563
- try { indexWrite(uuid, { tenant_id, tenant_slug, tenant, src }, rest, flow); }
3564
- catch { /* 색인은 있으면 좋은 것이지 도구가 실패할 이유가 아니다 */ }
3624
+ try { return indexWrite(uuid, { tenant_id, tenant_slug, tenant, src }, rest, flow); }
3625
+ catch { return false; /* 색인은 있으면 좋은 것이지 도구가 실패할 이유가 아니다 */ }
3565
3626
  }
3566
3627
 
3567
3628
  /**
@@ -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 = [];
@@ -5961,9 +6062,11 @@ const TOOLS = [
5961
6062
  '**노출 상태는 안 바꾼다**(2026-09-14) — 초안(release 전)을 고치면 초안 그대로고, 열린 문서를 고치면 열린 그대로다. ' +
5962
6063
  '초안을 여는 동사는 release_report 하나다. get_report 로 되읽은 데이터에 섞여 오는 publish·published_at 은 이 도구가 떼고 보낸다 — 그 값으로는 안 열린다. ' +
5963
6064
  '사람이 편집기로 고친 문서도 교체할 수 있다(2026-08-25) — 대신 무엇을 덮게 되는지 목록이 먼저 말하고, 덮은 뒤 되돌릴 판이 응답에 온다. ' +
5964
- '**그림 계약**: `assets` 를 **빼고** 보내면 서버가 저장된 그림을 그대로 유지한다. 전부 지우려면 `assets: []` 를 명시하고 ' +
6065
+ '**그림 계약**: `assets` 를 **빼고** 보내면 서버가 저장된 그림을 그대로 유지한다 — 캡션·출처 줄·alt 같은 도판 글 칸도 저장값 그대로라, 글 칸을 고쳤으면 `assets` 를 get_report 표식째 목록으로 보낸다. 전부 지우려면 `assets: []` 를 명시하고 ' +
5965
6066
  '`sections[].plates`·`chapters[].asset` 에서도 그 id 를 빼야 한다 — 가리키는 곳이 없는 id 는 422 로 거절된다. ' +
5966
- '한 장만 바꾸려면 그 id 에 새 `source`/`file` 을 주고 나머지는 get_report 가 준 표식 그대로 두면 된다.\n' +
6067
+ '한 장만 바꾸려면 그 id 에 새 `source`/`file` 을 주고 나머지는 get_report 가 준 표식 그대로 두면 된다. ' +
6068
+ '**받은 판 잠금**(2026-09-30): get_report 머리줄의 body_rev 를 `body_rev` 로 실으면, 받은 뒤 본문 판이 바뀌었을 때 서버가 409 로 멈추고 아무것도 안 쓴다. ' +
6069
+ 'dry_run 에도 같이 실으면 같은 409 를 덮기 전에 받는다. 서버가 이 잠금을 모르는 옛 판이면 출력이 「판을 견주지 않았다」고 알린다.\n' +
5967
6070
  '**문체는 주어와 자리가 정한다** — 요청 자리 밖에는 우리·사물·화면이 주어인 문장만 개조식(~함/~했음/~임)으로 쓴다. 독자가 할 일은 요청 자리(open_items·cta·PART I 간지(요청·결정 PART 일 때)·판권지 회신 안내·요청 절 항을 부탁하는 읽는 법 한 줄)에만 두고 사실 문장까지 하십시오체로 맺는다(「회신 부탁드립니다」. 제목은 검수·보고면 「~ 요청」+기한 괄호, 결정 문서의 판단 항목은 물음). 그 밖의 칸에는 사실만 적는다(「기준 수치는 미정」). dry_run 은 일부만 센다.\n',
5968
6071
  inputSchema: {
5969
6072
  type: 'object',
@@ -5975,6 +6078,11 @@ const TOOLS = [
5975
6078
  description: '참이면 아무것도 바꾸지 않고 렌더 결과와 경고만 돌려준다. '
5976
6079
  + '이 문서는 이미 고객에게 나간 링크를 갖고 있으니 덮기 전에 한 번 볼 수 있다.',
5977
6080
  },
6081
+ body_rev: {
6082
+ type: 'integer', minimum: 0,
6083
+ description: 'get_report **머리줄**의 body_rev=N(받은 판) — 시험 실행이 알려 주는 「지금 판」이 아니다. 주면 받은 뒤 본문 판이 바뀌었을 때(다른 교체·편집기 발행·그림이 앉아 다시 구운 것) '
6084
+ + '서버가 409 로 멈추고 아무것도 안 쓴다. 안 주면 지금처럼 덮는다. 교체 응답이 새 판을 알려 주니 연속 교체에는 그 값을 싣는다.',
6085
+ },
5978
6086
  data: REPORT_DATA,
5979
6087
  },
5980
6088
  required: ['tenant', 'id', 'data'],
@@ -6323,8 +6431,8 @@ const TOOLS = [
6323
6431
  {
6324
6432
  name: 'push_session',
6325
6433
  description:
6326
- '이 기계의 대화 하나를 **검열해서** 서버에 올린다. 다른 기계가 pull_session 으로 받아 ' +
6327
- '`claude --resume <uuid>` 로 이어 갈 수 있다.\n' +
6434
+ '이 기계의 대화 하나를 **검열해서** 서버에 올린다. Native Codex 원본도 지원하며 source client를 보존한다. ' +
6435
+ '다른 기계는 resume_session으로 작업 맥락을 읽는다. Claude 원본은 pull_session 뒤 claude --resume으로 열 수 있다.\n' +
6328
6436
  '올라가지 않는 것: 도구 실행 결과·첨부·파일 이력·집 경로·비밀 모양(토큰·비밀번호·개인키). ' +
6329
6437
  '**도구 호출의 짝과 인자는 남는다** — 그게 없으면 대화가 안 열린다.\n' +
6330
6438
  '긴 대화는 **알아서 조각으로** 나눠 보내고 서버가 잇는다 — 크기 때문에 사람이 할 일은 없다.\n' +
@@ -6762,7 +6870,9 @@ function tenantNoticeText(v) {
6762
6870
  const who = v.confirmed_by ? `${v.confirmed_by}${josaIGa(v.confirmed_by)} ` : '';
6763
6871
  return `genetics: 이 세션은 ${T} 소속으로 확정된 폴더(${v.path} · ${who}${String(v.confirmed_at || '').slice(0, 10)} 확정, 자동 올리기 켬)에서 `
6764
6872
  + '돌고 있어, 이 대화가 서버에 자동으로 올라간다. 도구 실행 결과·첨부·파일 이력은 빠지고 비밀 모양은 가려지지만, '
6765
- + `**주고받은 글과 생각(thinking), 도구 호출 인자는 그대로** ${T} 운영진에게 보인다. `
6873
+ + (v.source_client === 'codex'
6874
+ ? `**주고받은 글과 도구 호출 인자**는 ${T} 운영진에게 보인다. 내부 추론은 제외한다. `
6875
+ : `**주고받은 글과 생각(thinking), 도구 호출 인자는 그대로** ${T} 운영진에게 보인다. `)
6766
6876
  + '끄는 길 — 포털 「폴더」에서 자동 올리기 끄기(서버가 강제), 이 폴더의 .genetics-private 파일 또는 '
6767
6877
  + 'GENETICS_NO_AUTO_PUSH=1(이 기계의 클라이언트 약속), 이 대화만 빼려면 no_auto_push. '
6768
6878
  + '이미 올라간 것은 꺼도 안 사라진다 — 포털 「폴더」에서 지운다.';
@@ -6836,7 +6946,8 @@ function tenantNoticeView() {
6836
6946
  const e = v.entry || {};
6837
6947
  const row = indexRead()[mine];
6838
6948
  const says = resolveSessionSays(it.session); // 그 uuid 에 대한 서버의 사실(N10) — bound 갈래가 읽는다
6839
- 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,
6840
6951
  confirmed_at: e.confirmed_at, auto_upload: e.auto_upload };
6841
6952
  switch (it.state) {
6842
6953
  case 'bound':
@@ -7016,6 +7127,7 @@ function notifyToolsChanged(sendFn) {
7016
7127
  }
7017
7128
 
7018
7129
  function toolsNow() {
7130
+ if (sessionToolMode()) return TOOLS.filter(t => ['whoami', 'list_sessions', 'push_session'].includes(t.name));
7019
7131
  const facts = worldFacts(3);
7020
7132
  if (!facts || !facts.length) return TOOLS; // 조용한 구간은 조용하다
7021
7133
  const head = `\n\n지금 이 저장소:\n${facts.map((f) => ` · ${f}`).join('\n')}`;
@@ -7208,7 +7320,7 @@ async function mailRegister(tenant, mine, force = false) {
7208
7320
  session: mine,
7209
7321
  name: name || '',
7210
7322
  // 신원이 어디서 왔는지 그대로 말한다 — Cursor 대화에 claude-code 이름표를 달지 않는다.
7211
- client: mine === cursorActiveComposer() ? 'cursor' : 'claude-code',
7323
+ client: mine === codexSessionId() ? 'codex' : (mine === cursorActiveComposer() ? 'cursor' : 'claude-code'),
7212
7324
  cwd: relHome(process.cwd()),
7213
7325
  });
7214
7326
  }
@@ -7227,8 +7339,8 @@ async function mailPull() {
7227
7339
  if (Date.now() - mailAt < MAIL_POLL_MS) return;
7228
7340
  mailAt = Date.now();
7229
7341
 
7230
- for (const [tenant] of tenants) {
7231
- 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); }
7232
7344
  catch { /* 한 소속처가 막혀도 나머지는 돈다 — 다음 바퀴에 다시 */ }
7233
7345
  }
7234
7346
 
@@ -7255,8 +7367,175 @@ async function mailPull() {
7255
7367
  }
7256
7368
  }
7257
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
+
7258
7536
  /** 한 소속처의 내 편지함을 긷고 **받았다고 도장을 찍는다**(도장은 큐에 넣기 **전에** — 재점화 방지). */
7259
- 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));
7260
7539
  try { await mailRegister(tenant, mine); } catch { /* 등록 실패가 받기를 막지 않는다 */ }
7261
7540
 
7262
7541
  if (!mailCursors.has(tenant)) mailCursors.set(tenant, Number(indexEntry?.mail_cursor || 0));
@@ -7295,6 +7574,10 @@ async function mailPullOne(tenant, mine, indexEntry) {
7295
7574
  if (typeof r?.says === 'string' && r.says) mailQueue.push(r.says);
7296
7575
  }
7297
7576
 
7577
+ mailCollectReturned(r);
7578
+ }
7579
+
7580
+ function mailCollectReturned(r) {
7298
7581
  for (const b of (Array.isArray(r?.returned) ? r.returned : [])) {
7299
7582
  const id = Number(b?.id) || 0;
7300
7583
  if (!id || mailBackSeen.has(id)) continue; // 서버가 1시간 동안 되풀이해 준다 — 접는 것은 우리 몫
@@ -7818,7 +8101,7 @@ async function callTool(name, args = {}) {
7818
8101
  const owner = r.body_owner === 'html' ? '발행물(HTML)' : 'JSON';
7819
8102
  head.push(`[정본 ${owner} · 본문 판 body_rev=${r.body_rev ?? '?'}`
7820
8103
  + (r.sha256 ? ` · 지금 발행물 ${r.bytes}B sha256:${String(r.sha256).slice(0, 12)}…` : '')
7821
- + `] — 고칠 때는 update_report 에 body_rev 를 함께 실으면, 그 사이 누가 고쳤을 때 409 로 멈춘다.`);
8104
+ + `] — 고칠 때는 update_report 에 이 body_rev 를 함께 실으면, 받은 뒤 본문 판이 바뀌었을 때(다른 교체·편집기 발행·그림이 앉아 다시 구운 것) 409 로 멈춘다.`);
7822
8105
  // 열린 검토 회신 — 고치기 전에 읽어야 한다(판단 항목의 답이 거기 있다). 첫 줄 가까이에 둔다.
7823
8106
  if (Number.isInteger(r.open_replies) && r.open_replies > 0) {
7824
8107
  head.push(`검토 회신 ${r.open_replies}건이 열려 있다 — 고치기 전에 list_notes(id: ${Number(args.id)}) 로 본문을 읽는다.`);
@@ -7827,12 +8110,19 @@ async function callTool(name, args = {}) {
7827
8110
  const kept = Array.isArray(r.data?.assets)
7828
8111
  ? r.data.assets.filter((a) => typeof a?.data === 'string' && a.data.startsWith('(생략')).length : 0;
7829
8112
  if (kept > 0) {
7830
- head.push(`그림 ${kept}장은 서버가 보관 중 — update_report 에 assets 를 **빼고** 보내면 그대로 유지, `
8113
+ head.push(`그림 ${kept}장은 서버가 보관 중 — update_report 에 assets 를 **빼고** 보내면 그대로 유지(캡션·출처 줄·alt 도 저장값 — 글 칸을 고쳤으면 표식째 목록으로), `
7831
8114
  + '전부 지우려면 `assets: []`(sections[].plates·chapters[].asset 에서도 빼야 한다), 바꿀 장만 새 source/file 로.');
7832
8115
  }
7833
8116
  // 데이터가 없으면 서버가 outline 을 함께 준다 — 그것이 지금 고객이 보는 것의 유일한 요약이다.
7834
8117
  if (r.data === null && r.outline) head.push(outlineText(r.outline));
7835
- return `${head.join('\n')}\n${JSON.stringify(r.data, null, 2)}`;
8118
+ // 원고에 남은 판 메아리는 떼고 준다 — 옛 클라이언트가 몸통에 실어 보낸 `body_rev` 가 저장본에 남은 문서가 있다(실측 #31: 머리줄 74 ·
8119
+ // 원고 안 67). 그 칸을 「받은 판」으로 읽으면 아무도 안 고쳤는데 409 가 되풀이된다. 받은 판은 위 머리줄의 값 하나다(2026-09-30 검토).
8120
+ let data = r.data;
8121
+ if (data && typeof data === 'object' && !Array.isArray(data) && 'body_rev' in data) {
8122
+ data = { ...data };
8123
+ delete data.body_rev;
8124
+ }
8125
+ return `${head.join('\n')}\n${JSON.stringify(data, null, 2)}`;
7836
8126
  }
7837
8127
  // 구조만 본다 — 데이터는 이미 다른 호출로 받을 수 있고, 둘을 같이 실으면
7838
8128
  // 문맥이 두 배가 된다. (같은 말을 데이터 없는 문서에도 쓰므로 함수로 뺐다.)
@@ -7926,6 +8216,7 @@ async function callTool(name, args = {}) {
7926
8216
  // 여기서 뒤집는다 — 확인 전에 고객이 먼저 보는 일을 막는 것은 도구의 몫이다.
7927
8217
  const publish = args.publish === true;
7928
8218
  const body = { ...data, tenant: args.tenant, slug: args.slug, publish };
8219
+ delete body.body_rev; // 본문 판은 서버가 센다 — 다른 문서를 되읽은 원고로 새로 발행할 때 섞여 온 값이 새 문서의 원고에 메아리로 남지 않게(update_report 와 같다)
7929
8220
  if (args.published_at) body.published_at = args.published_at;
7930
8221
  if (args.dry_run) body.dry_run = true;
7931
8222
  const r = await api('POST', '/api/reports', body, PUBLISH);
@@ -7967,6 +8258,12 @@ async function callTool(name, args = {}) {
7967
8258
  return `${r.note}\n${lines.join('\n')}`;
7968
8259
  }
7969
8260
  case 'update_report': {
8261
+ // (2026-09-30) 받은 판 잠금 — 판은 몸통이 아니라 질의(?body_rev=)로 보낸다: 몸통은 통째로 원고(source_json)가 되어
8262
+ // 판이 메아리로 저장되고, 그 원고를 되받아 보내는 쪽이 거짓 409 를 맞는다(서버 api.php PUT 머리말). 값은 그림을 올리기 전에 잰다.
8263
+ const baseRev = args.body_rev;
8264
+ if (baseRev !== undefined && baseRev !== null && !(Number.isInteger(baseRev) && baseRev >= 0)) {
8265
+ throw new Error('body_rev 는 get_report 머리줄의 body_rev=N 값(0 이상의 정수)이어야 합니다.');
8266
+ }
7970
8267
  const data = await materializeAssets(args.data, args.tenant);
7971
8268
  const payload = { ...data, tenant: args.tenant };
7972
8269
  // 노출 손잡이는 떼고 보낸다(2026-09-14). get_report 가 돌려주는 data 에는 발행 때 보낸 몸통이
@@ -7974,6 +8271,7 @@ async function callTool(name, args = {}) {
7974
8271
  // 서버의 `publish: true` 손잡이를 당기면 초안이 열린다. 여는 동사는 release_report 하나다.
7975
8272
  delete payload.publish;
7976
8273
  delete payload.published_at;
8274
+ delete payload.body_rev; // 몸통의 판은 서버가 안 읽는다 — 섞여 오면 떼고 보낸다(원고에 메아리로 남지 않게)
7977
8275
  if (args.dry_run) payload.dry_run = true;
7978
8276
  /*
7979
8277
  * (판 9 · 개인정보 ①) 서버는 읽고 굽는 사이 사람이 공개 상태를 바꿨으면 **아무것도 안 쓰고** 409 를 낸다
@@ -7983,7 +8281,8 @@ async function callTool(name, args = {}) {
7983
8281
  */
7984
8282
  let r;
7985
8283
  try {
7986
- r = await api('PUT', `/api/reports/${Number(args.id)}`, payload, RELEASE);
8284
+ const q = baseRev !== undefined && baseRev !== null ? `?body_rev=${baseRev}` : '';
8285
+ r = await api('PUT', `/api/reports/${Number(args.id)}${q}`, payload, RELEASE);
7987
8286
  } catch (e) {
7988
8287
  if (Number(e?.status) === 409 && String(e?.code || '') === 'report_publish_changed') {
7989
8288
  // ⚠ **상태 확인까지만 잇는다**(판 9 · 오너가 고른 규약) — 재전송을 권하지 않는다. 지금 몸통은 이미 낡았고,
@@ -7992,18 +8291,32 @@ async function callTool(name, args = {}) {
7992
8291
  // 서버가 모르는 것(어느 id 로 부르나)만 보탠다. 행이 사라진 409(`report_gone`)에는 안 붙인다 — 볼 문서가 없다.
7993
8292
  throw new Error(`${e.message}\n→ get_report(id: ${Number(args.id)})`);
7994
8293
  }
8294
+ // (2026-09-30) 받은 판 잠금 409 — 같은 규약으로 상태 확인 동사만 잇는다. 새로 받은 판에 편집을 다시 얹는 것은 스킬 절차 몫이다.
8295
+ if (Number(e?.status) === 409 && String(e?.code || '') === 'report_body_changed') {
8296
+ throw new Error(`${e.message}\n→ get_report(id: ${Number(args.id)})`);
8297
+ }
7995
8298
  throw e;
7996
8299
  }
7997
8300
  // 고친 직후가 「무엇을 아직 안 고쳤나」를 볼 가장 좋은 순간이다.
7998
8301
  if (!r.dry_run) { notesSeen(Number(args.id), r.open_notes); repliesSeen(Number(args.id), r.open_replies); }
8302
+ // 받은 판을 실었는데 응답에 판이 없으면 그 서버는 잠금을 모른다(잠금을 아는 서버는 교체·시험 실행 응답에 늘 판을 싣는다) —
8303
+ // 판을 견주지 않고 받은 것이다. 「409 로 멈춘다」는 약속이 조용히 빠지지 않게 그 사실을 말한다(2026-09-30 검토).
8304
+ const sentRev = baseRev !== undefined && baseRev !== null;
8305
+ const blind = sentRev && !Number.isInteger(r.body_rev);
7999
8306
  if (r.dry_run) {
8000
- return `시험 실행 — 아무것도 바꾸지 않았습니다.\n${r.tenant} · 건 ${r.cases}` + pinsText(r) + warnText(r);
8307
+ const now = Number.isInteger(r.body_rev) ? ` · 지금 본문 판 body_rev=${r.body_rev}` : '';
8308
+ return `시험 실행 — 아무것도 바꾸지 않았습니다.\n${r.tenant} · 건 ${r.cases}${now}`
8309
+ + (blind ? '\n⚠ 이 서버는 받은 판 잠금(?body_rev=)을 모릅니다 — 교체해도 판을 견주지 않고 덮습니다.' : '')
8310
+ + pinsText(r) + warnText(r);
8001
8311
  }
8002
8312
  // 상태를 함께 알린다 — 이 문서를 다음에도 API 로 고칠 수 있는지가 여기서 갈린다.
8003
8313
  const st = r.state ? ` [${r.state}]` : '';
8004
8314
  // 서버가 초안이라고 답하면 이 도구의 동사로 다음 할 일을 잇는다(서버 note 는 HTTP 경로로 말한다).
8005
8315
  const open = r.published === false ? ` 이 도구에서는 release_report(id: ${Number(args.id)}) 가 그 일입니다.` : '';
8006
- return `교체 완료 — ${r.tenant} · 건 ${r.cases}${st}\n열람 주소: ${r.url}\n${r.note || ''}${open}`
8316
+ // 새 판 — 연속 교체의 다음 호출에 body_rev 로 되싣는다(안 알리면 두 번째 교체가 늘 409).
8317
+ const rev = Number.isInteger(r.body_rev) ? ` · 본문 판 body_rev=${r.body_rev}` : '';
8318
+ return `교체 완료 — ${r.tenant} · 건 ${r.cases}${st}${rev}\n열람 주소: ${r.url}\n${r.note || ''}${open}`
8319
+ + (blind ? '\n⚠ 이 서버는 받은 판 잠금(?body_rev=)을 모릅니다 — 받은 뒤 본문이 바뀌었는지 견주지 않고 덮었습니다.' : '')
8007
8320
  + pendingText(r) + pinsText(r) + warnText(r);
8008
8321
  }
8009
8322
  case 'world': {
@@ -8452,7 +8765,7 @@ async function callTool(name, args = {}) {
8452
8765
  }
8453
8766
  return `${r.tenant} — ${count}\n${rows.join('\n')}\n\n`
8454
8767
  + '이어받으려면 `resume_session` — 지금 이 창이 그 대화를 읽고 이어서 일합니다.\n'
8455
- + '원본 그대로 열려면 `pull_session` 으로 받은 뒤 그 폴더에서 `claude --resume <uuid>`.'
8768
+ + 'Claude 원본 복원은 `pull_session` 뒤 `claude --resume <uuid>`. Native Codex는 업무 맥락만 읽습니다.'
8456
8769
  + (follow.length ? `\n\n${follow.join('\n')}` : '');
8457
8770
  }
8458
8771
  case 'push_session': {
@@ -8492,6 +8805,9 @@ async function callTool(name, args = {}) {
8492
8805
  throw e;
8493
8806
  } finally { fileUnlock(lease); }
8494
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는 다음 갱신으로 넘겼습니다.` : '';
8495
8811
  // 표시를 **덧붙임 X B / 전체 Y B** 로(§3.4) — 통째로 갔으면 전처럼 「전 → 후」다.
8496
8812
  const cut = plan?.mode === 'APPEND' || plan?.mode === 'ASK'
8497
8813
  ? `덧붙임 ${Number(appended || 0).toLocaleString()}B / 전체 ${r.bytes.toLocaleString()}B`
@@ -8502,7 +8818,7 @@ async function callTool(name, args = {}) {
8502
8818
  const how = parts > 1 ? ` · ${parts}조각으로` : '';
8503
8819
  if (r.dry_run) {
8504
8820
  return `시험 실행 — 아무것도 저장하지 않았습니다.\n`
8505
- + `${r.tenant} · ${r.lines}줄 · 뺀 레코드 ${r.dropped} · 가린 자리 ${r.redacted} · ${cut}${how}`;
8821
+ + `${r.tenant} · ${r.lines}줄 · 뺀 레코드 ${r.dropped} · 가린 자리 ${r.redacted} · ${cut}${how}` + sourceNote;
8506
8822
  }
8507
8823
  /*
8508
8824
  * ★ **두뇌 큐에 들어갔는지 말한다.** 서버는 `wiki`(queued|not-queued)와 `note` 를
@@ -8517,7 +8833,8 @@ async function callTool(name, args = {}) {
8517
8833
  + `${r.lines}줄 · 뺀 레코드 ${r.dropped}(첨부·파일이력) · 가린 자리 ${r.redacted} · ${cut}${how}\n`
8518
8834
  + (queued ? queued + '\n' : '')
8519
8835
  + bindNote
8520
- + '다른 기계에서: pull_session 으로 받은 뒤 그 폴더에서 `claude --resume ' + args.uuid + '`';
8836
+ + (pushed.source_client === 'codex' ? '다른 기계에서: resume_session 으로 이 작업 맥락을 읽을 수 있습니다. Codex 원본 복원은 하지 않습니다.'
8837
+ : '다른 기계에서: pull_session 으로 받은 뒤 그 폴더에서 `claude --resume ' + args.uuid + '`') + sourceNote;
8521
8838
  }
8522
8839
  case 'resume_session': {
8523
8840
  /*
@@ -8543,6 +8860,7 @@ async function callTool(name, args = {}) {
8543
8860
  const q = `?tenant=${encodeURIComponent(args.tenant)}`;
8544
8861
  const r = await api('GET',
8545
8862
  `/api/sessions/${encodeURIComponent(args.uuid)}${q}&no_body=1&digest=1&digest_chars=${chars}`);
8863
+ const native = nativeSessionTools(r.tools);
8546
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');
8547
8865
 
8548
8866
  // 원본도 제자리에 놓는다 — **덮지 않는다**(pull_session 과 같은 이유).
@@ -8550,7 +8868,9 @@ async function callTool(name, args = {}) {
8550
8868
  const dir = transcriptDir(into);
8551
8869
  const target = path.join(dir, `${args.uuid}.jsonl`);
8552
8870
  let placed = target;
8553
- if (fs.existsSync(target)) {
8871
+ if (native) {
8872
+ placed = 'Codex 업무 맥락만 읽었습니다 — 원본 전사 파일을 만들지 않았습니다';
8873
+ } else if (fs.existsSync(target)) {
8554
8874
  placed = `${target} (이미 있어 그대로 두었습니다 — 덮으면 이 기계에서 하던 대화가 사라집니다)`;
8555
8875
  } else {
8556
8876
  await apiToFile(`/api/sessions/${encodeURIComponent(args.uuid)}${q}&raw=1`, target);
@@ -8563,7 +8883,7 @@ async function callTool(name, args = {}) {
8563
8883
  * 최대 20초 동안 다른 사람 화면에는 이 기계가 안 보인다 — 이어받은 직후가 바로
8564
8884
  * 「누가 잡았나」를 알아야 하는 순간이라 여기서 한 번 즉시 뛴다.
8565
8885
  */
8566
- beatTick().catch(() => {});
8886
+ if (!native) beatTick().catch(() => {});
8567
8887
 
8568
8888
  // **받기 전에 알아야 할 것을 먼저 쓴다.** 요지 뒤에 붙이면 긴 글에 묻힌다.
8569
8889
  const others = (r.devices || []).filter((d) => d.device !== deviceId());
@@ -8582,7 +8902,7 @@ async function callTool(name, args = {}) {
8582
8902
 
8583
8903
  return `이어받았습니다 — ${r.tenant} · ${args.uuid.slice(0, 8)} · ${r.turns || 0}턴 · ${trimmed}\n`
8584
8904
  + `원본을 놓은 자리: ${placed}\n`
8585
- + `원본 그대로 열려면: cd ${into} && claude --resume ${args.uuid}\n`
8905
+ + (native ? 'Native Codex 원본 복원은 지원하지 않습니다. 아래 맥락으로 이어서 작업합니다.\n' : `원본 그대로 열려면: cd ${into} && claude --resume ${args.uuid}\n`)
8586
8906
  + (r.redacted ? `※ 가린 자리 ${r.redacted}곳 — 도구 실행 결과는 안 실려 옵니다. 필요하면 다시 실행하세요.\n` : '')
8587
8907
  + warn
8588
8908
  + '\n다음에 할 일: 아래 대화를 읽고, 사용자에게 「어디까지 되어 있고 다음이 무엇인지」를 '
@@ -8601,6 +8921,8 @@ async function callTool(name, args = {}) {
8601
8921
  if (fs.existsSync(target)) {
8602
8922
  throw new Error(`이미 있습니다: ${target}\n덮지 않았습니다 — 이 기계에서 하던 대화가 사라집니다.`);
8603
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 원본 파일은 만들지 않습니다.');
8604
8926
  // 봉투를 안 만든다 — 메타는 헤더로 온다(apiToFile 주석).
8605
8927
  const { headers, bytes } = await apiToFile(
8606
8928
  `/api/sessions/${encodeURIComponent(args.uuid)}?tenant=${encodeURIComponent(args.tenant)}&raw=1`,
@@ -8618,14 +8940,16 @@ async function callTool(name, args = {}) {
8618
8940
  const mine = mySessionId();
8619
8941
  if (!mine) {
8620
8942
  return '이 세션의 번호를 모른다 — 주소를 댈 수 없어 보낼 수 없다'
8621
- + '(CLAUDE_CODE_SESSION_ID 도, Cursor 활성 대화도 없는 환경이다).';
8943
+ + '(Claude 세션 번호·유효한 Codex 세션 UUID·Cursor 활성 대화가 없는 환경이다).';
8622
8944
  }
8623
8945
  /*
8624
- * 주소가 없으면 먼저 댄다. **이름이 없으면 서버가 409 로 거절**하므로,
8625
- * 그 경우를 여기서 미리 사람 말로 바꿔 준다 — 서버 오류문만 보면 무엇을 해야
8626
- * 하는지 알 수 없다.
8946
+ * Native Codex에는 Claude ListAgents 자기소개가 없다. strict harness UUID로
8947
+ * 주소를 등록하고 서버가 등록된 UUID로 발신 표시를 정한다(mail_store_sender_session).
8948
+ * 이름을 지어내거나 요청 인자의 발신자를 쓰지 않는다. Claude·미지 클라이언트의
8949
+ * 기존 이름 가드는 유지하고, 등록 거절은 아래에서 전송 전에 멈춘다.
8627
8950
  */
8628
- if (!myAgentName()) {
8951
+ const nativeCodex = !process.env.CLAUDE_CODE_SESSION_ID && mine === codexSessionId();
8952
+ if (!nativeCodex && !myAgentName()) {
8629
8953
  return '이 세션에는 아직 말 걸 수 있는 이름이 없어 **보낼 수가 없다**'
8630
8954
  + '(받는 쪽이 누가 말하는지 못 보면 그것은 메시지가 아니라 주입이다).\n'
8631
8955
  + '이름은 `ListAgents` 를 한 번 부르면 생긴다 — 하네스가 「This session is …」를 '
@@ -8832,12 +9156,22 @@ async function handle(line) {
8832
9156
  result = { tools: toolsNow() };
8833
9157
  } else if (req.method === 'tools/call') {
8834
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');
8835
9164
  // 도구응답 레인의 긷기 — **기다리지 않는다**(도구 지연은 그대로). 60초 상한은 mailPull 안의 mailAt 검사.
8836
9165
  // 도구가 망을 도는 동안 도착한 통은 이번 응답 꼬리에, 아니면 다음 응답에 실린다.
8837
- if (hookLane() && mailPollReady()) mailPull().catch(() => {});
9166
+ if (!sessionOnly && hookLane() && mailPollReady()) mailPull().catch(() => {});
8838
9167
  const text = await callTool(req.params?.name, req.params?.arguments || {});
8839
9168
  result = { content: [{ type: 'text', text }] };
8840
- 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
+ }
8841
9175
  } else if (isNotification) {
8842
9176
  return;
8843
9177
  } else {
@@ -8851,7 +9185,7 @@ async function handle(line) {
8851
9185
  // 그래야 에이전트가 메시지를 읽고 고쳐서 다시 시도한다.
8852
9186
  if (req.method === 'tools/call') {
8853
9187
  const failed = { content: [{ type: 'text', text: `실패: ${e.message}` }], isError: true };
8854
- worldAppend(failed); // 실패한 턴에도 세계는 움직인다.
9188
+ if (!sessionToolMode()) worldAppend(failed); // 실패한 턴에도 세계는 움직인다.
8855
9189
  send({ jsonrpc: '2.0', id: req.id, result: failed });
8856
9190
  } else {
8857
9191
  send({ jsonrpc: '2.0', id: req.id, error: { code: -32603, message: String(e.message || e) } });
@@ -9369,6 +9703,7 @@ const SCHEDULE = [
9369
9703
  function startSchedule() {
9370
9704
  if (cfg.GENETICS_NO_BEAT) return; // 심박을 끄는 사람은 나머지도 안 원한다
9371
9705
  for (const row of SCHEDULE) {
9706
+ if (SESSION_WATCH_UUID && row.name !== 'beat') continue;
9372
9707
  if (row.start) { row.start(); continue; }
9373
9708
  if (row.ready && !row.ready()) continue;
9374
9709
  const run = () => {
@@ -9385,17 +9720,19 @@ function startSchedule() {
9385
9720
  }
9386
9721
 
9387
9722
  if (IS_MAIN) {
9388
- ensureCellOnBoot(); // 세포가 먼저다 — 스킬보다 이쪽이 「무엇을 못 하는가」를 정한다
9389
- ensureSkillOnBoot();
9723
+ if (!sessionToolMode()) {
9724
+ ensureCellOnBoot(); // 세포가 먼저다 — 스킬보다 이쪽이 「무엇을 못 하는가」를 정한다
9725
+ ensureSkillOnBoot();
9726
+ }
9390
9727
  /*
9391
9728
  * **뜰 때 심박을 시작한다.** 이 프로세스가 사는 동안이 곧 「이 기계가 이 창을 열고 있는
9392
9729
  * 동안」이라, 시작·끝을 따로 알릴 필요가 없다. 자격이나 주소가 없으면 조용히 안 뛴다 —
9393
9730
  * 꺼진 것이지 고장이 아니다(그림 자격이 없을 때와 같은 태도).
9394
9731
  */
9395
- startSchedule();
9732
+ if (cfg.GENETICS_SESSION_ONLY !== '1') startSchedule();
9396
9733
  process.stdin.setEncoding('utf8');
9397
9734
  process.stdin.on('data', (chunk) => { buf += chunk; pump(); });
9398
- process.stdin.on('end', () => { pump(true); stopBeat().catch(() => {}); });
9735
+ process.stdin.on('end', () => { pump(true); if (cfg.GENETICS_SESSION_ONLY !== '1') stopBeat().catch(() => {}); });
9399
9736
  // 창을 닫으면 목록에서 **즉시** 빠진다. 못 알려도 90초 뒤 서버가 스스로 지운다.
9400
9737
  /*
9401
9738
  * ★ **SIGHUP 이 빠져 있었다.** 사람이 「대화창을 닫는다」고 할 때 실제로 오는 신호가 그것인데
@@ -9406,7 +9743,7 @@ if (IS_MAIN) {
9406
9743
  * 「창 닫기」만은 이 한 줄로 잡힌다.
9407
9744
  */
9408
9745
  for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
9409
- 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)); });
9410
9747
  }
9411
9748
  }
9412
9749
 
@@ -9432,6 +9769,7 @@ export { videoThumbs, taskReadTools, taskReadRequest, taskReadResult,
9432
9769
  autoRowOnServer,
9433
9770
  // 2026-09-18 Windows — 남의 규칙(Claude Code 슬러그)·플랫폼 갈래는 시험이 표로 걷는다
9434
9771
  transcriptSlug, transcriptDir, cursorUserRoot, sqliteBin, npxCacheResetHint, cellPlatformSupported,
9772
+ mySessionId, refreshCodexSessions, autoCandidates, autoSyncRound, callTool,
9435
9773
  HOLDS_FILE, UPDATE_BUDGET_MS,
9436
9774
  // DESIGN-session-delta-sync C6 — 규칙판·좌표·기계 키·대응표·머리 끊기·private 판정(시험이 걷는다)
9437
9775
  SCRUB_REV, CWD_HIST_MAX, PUSH_MAC_WIN, pushDir, pushKey, pushKeyId, pushMac, pushCoords,