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/NATIVE-CODEX-SESSION.md +182 -0
- package/bin/aarch64-apple-darwin/gcell +0 -0
- package/bin/aarch64-unknown-linux-musl/gcell +0 -0
- package/bin/manifest.json +5 -5
- package/bin/x86_64-apple-darwin/gcell +0 -0
- package/bin/x86_64-pc-windows-gnu/gcell.exe +0 -0
- package/bin/x86_64-unknown-linux-musl/gcell +0 -0
- package/codex-session-cli.mjs +82 -0
- package/codex-session.mjs +353 -0
- package/genetics.mjs +350 -49
- package/package.json +5 -2
- package/skills/gazette-report/scripts/tone.mjs +15 -6
- package/skills/gazette-report/scripts/tone.test.mjs +39 -0
- package/skills/genetics-report/SKILL.md +2 -2
- package/skills/genetics-report/assets/render.py +1 -4
- package/skills/genetics-report/references/content-pipeline.md +3 -4
- package/skills/genetics-report/references/lim-portal-format.md +29 -18
- package/skills/genetics-report/references/process.md +2 -2
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`(
|
|
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: '
|
|
1527
|
-
+ '
|
|
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
|
|
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
|
-
/**
|
|
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',
|
|
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',
|
|
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 (
|
|
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
|
|
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 {
|
|
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
|
-
'이 기계의 대화 하나를 **검열해서** 서버에 올린다.
|
|
6334
|
-
'
|
|
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
|
-
+
|
|
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,
|
|
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
|
-
+ '원본
|
|
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
|
-
+ '다른 기계에서:
|
|
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 (
|
|
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
|
-
+ '(
|
|
8943
|
+
+ '(Claude 세션 번호·유효한 Codex 세션 UUID·Cursor 활성 대화가 없는 환경이다).';
|
|
8659
8944
|
}
|
|
8660
8945
|
/*
|
|
8661
|
-
*
|
|
8662
|
-
*
|
|
8663
|
-
*
|
|
8946
|
+
* Native Codex에는 Claude ListAgents 자기소개가 없다. strict harness UUID로
|
|
8947
|
+
* 주소를 등록하고 서버가 등록된 UUID로 발신 표시를 정한다(mail_store_sender_session).
|
|
8948
|
+
* 이름을 지어내거나 요청 인자의 발신자를 쓰지 않는다. Claude·미지 클라이언트의
|
|
8949
|
+
* 기존 이름 가드는 유지하고, 등록 거절은 아래에서 전송 전에 멈춘다.
|
|
8664
8950
|
*/
|
|
8665
|
-
|
|
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
|
-
|
|
9426
|
-
|
|
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,
|