coxpit 6.3.2 → 6.3.5
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/README.md +1 -0
- package/package.json +1 -1
- package/src/.!31514!cockpit.ts +0 -0
- package/src/auth.ts +4 -1
- package/src/claudeshim.ts +110 -0
- package/src/cockpit.ts +318 -35
- package/src/config.ts +7 -0
- package/src/db/index.ts +1 -0
- package/src/db/schema.ts +5 -0
- package/src/hud.ts +39 -9
- package/src/index.ts +8 -0
- package/src/orchestrator.ts +206 -22
- package/src/server.ts +28 -6
- package/src/uistate.ts +65 -0
package/src/config.ts
CHANGED
|
@@ -82,8 +82,15 @@ export const config = {
|
|
|
82
82
|
portStrict: env.COXPIT_PORT_STRICT === '1' || (env.COXPIT_PORT_STRICT == null && stored.portStrict === true),
|
|
83
83
|
dbPath,
|
|
84
84
|
settingsPath,
|
|
85
|
+
// 소유자 UI 상태(열어둔 탭 등) — DB·settings 와 같은 데이터 폴더의 작은 JSON 한 장.
|
|
86
|
+
uiStatePath: path.join(path.dirname(path.resolve(dbPath)), 'ui-state.json'),
|
|
85
87
|
// 단일 데몬 락 파일 — DB 와 같은 폴더(그 DB 를 지키는 락이므로)
|
|
86
88
|
lockPath: path.join(path.dirname(path.resolve(dbPath)), 'daemon.lock.json'),
|
|
89
|
+
// 세션 tmux 의 PATH 맨 앞에 끼우는 얇은 심 폴더(부팅 때 `claude` 한 장을 쓴다).
|
|
90
|
+
// 사람이 세션에서 그냥 `claude` 를 쳐도 그 대화가 이 세션의 id 로 묶이게 하는 유일한 손잡이다.
|
|
91
|
+
// 이름이 'bin' 이면 안 된다 — 레거시 DB(cwd 의 ./coxpit.db)를 쓰는 개발 기동에서는 데이터 폴더가
|
|
92
|
+
// repo 루트가 되고, 그 repo 의 bin/ 은 package.json "files" 에 실려 npm 에 **발행된다**.
|
|
93
|
+
shimDir: path.join(path.dirname(path.resolve(dbPath)), 'shim'),
|
|
87
94
|
// 원격 머신 SSH 개인키 경로(선택). 없으면 ssh 기본 키/에이전트 사용.
|
|
88
95
|
sshKey: process.env.COXPIT_SSH_KEY ?? '',
|
|
89
96
|
// run 정착 시 POST 할 웹훅(선택) — 텔레그램 브릿지 등 사용자 연결용.
|
package/src/db/index.ts
CHANGED
|
@@ -127,6 +127,7 @@ export async function ensureSchema(): Promise<void> {
|
|
|
127
127
|
try { await client.execute("ALTER TABLE repos ADD COLUMN kind TEXT NOT NULL DEFAULT 'git'"); } catch { /* exists */ }
|
|
128
128
|
try { await client.execute("ALTER TABLE agent_runs ADD COLUMN title TEXT NOT NULL DEFAULT ''"); } catch { /* exists */ }
|
|
129
129
|
try { await client.execute('ALTER TABLE agent_runs ADD COLUMN in_place INTEGER NOT NULL DEFAULT 0'); } catch { /* exists */ }
|
|
130
|
+
try { await client.execute("ALTER TABLE agent_runs ADD COLUMN claude_session_id TEXT NOT NULL DEFAULT ''"); } catch { /* exists */ }
|
|
130
131
|
// DEFAULT 1 = 기존 run 은 real 로 남는다. 모르는 과거를 dry 로 칠하지 않기 위한 기본값이다(v5.28 H1).
|
|
131
132
|
try { await client.execute('ALTER TABLE agent_runs ADD COLUMN real INTEGER NOT NULL DEFAULT 1'); } catch { /* exists */ }
|
|
132
133
|
// 조인 키 인덱스(멱등) — 없으면 이 여섯 조회가 매번 테이블 전체를 훑는다.
|
package/src/db/schema.ts
CHANGED
|
@@ -91,6 +91,11 @@ export const agentRuns = sqliteTable('agent_runs', {
|
|
|
91
91
|
tmuxWindow: text('tmux_window').notNull().default(''),
|
|
92
92
|
status: text('status').notNull().default('pending'), // pending | running | waiting | done | error
|
|
93
93
|
sessionId: text('session_id').notNull().default(''), // 에이전트 세션(steer 용 --resume 키)
|
|
94
|
+
// 이 coxpit 세션이 쓰는 claude 대화 하나의 id(= ~/.claude/projects/<cwd-enc>/<id>.jsonl 파일명).
|
|
95
|
+
// 세션을 열 때 미리 정해 두고 `claude --session-id` 로 묶는다 — 한 폴더(워크스페이스 루트 등)에
|
|
96
|
+
// 세션 여럿이 살면 "가장 최근 .jsonl" 은 거의 항상 남의 대화라, 뷰어가 이름을 미리 알아야 한다.
|
|
97
|
+
// 태그 없이 이미 돌고 있던 세션은 화면 내용으로 찾아낸 뒤 여기에 적어 다시 찾지 않는다.
|
|
98
|
+
claudeSessionId: text('claude_session_id').notNull().default(''),
|
|
94
99
|
prUrl: text('pr_url').notNull().default(''), // PR 모드로 올린 pull request URL
|
|
95
100
|
model: text('model').notNull().default(''), // 런치별 모델 지정(빈값 = CLI 기본)
|
|
96
101
|
// v5.28 Part H — 이 run 이 진짜 에이전트로 돌았나(real), 모의 스트림이었나(dry).
|
package/src/hud.ts
CHANGED
|
@@ -5,10 +5,13 @@
|
|
|
5
5
|
// ① 알약으로 한눈에 본다(점 + 대기 수) ② 펼쳐서 나를 기다리는 것부터 훑는다
|
|
6
6
|
// ③ 한 줄을 고르면 **목록 옆에** 상세가 열린다 ④ 대기 중인 에이전트에게 코크핏을 열지 않고 답한다
|
|
7
7
|
//
|
|
8
|
-
// [!] 이
|
|
9
|
-
//
|
|
10
|
-
// <html data-hud="pill|list|detail"> 에
|
|
11
|
-
//
|
|
8
|
+
// [!] 이 페이지는 **브라우저에서 단독으로 돈다**. 창(프레임리스·항상 위)·전역 단축키·크기 변경은
|
|
9
|
+
// 데스크톱 쪽 일(K1/K2, desktop/main.cjs)이고, 여기는 그쪽을 한 줄도 모른다 —
|
|
10
|
+
// 배치 상태를 <html data-hud="pill|list|detail"> 에 적고, 창이 붙어 있을 때만
|
|
11
|
+
// window.coxpitHud.size({state,w,h}) 로 "이만큼 필요하다"고 말할 뿐이다(없으면 아무 일도 없다).
|
|
12
|
+
// 창을 실제로 줄이고 늘리는 것도, 화면 밖으로 안 나가게 물리는 것도 전부 메인 프로세스다.
|
|
13
|
+
// 보이는데 열리지 않는 알약은 없느니만 못하다 — 눌러서 펼쳐져야 하고 그러면서도 끌어 옮길 수 있어야 하니,
|
|
14
|
+
// drag 영역과 클릭 대상은 같은 요소일 수 없다: 알약은 눌리고, 창은 알약 안의 홈(.pgrip)으로 옮긴다.
|
|
12
15
|
//
|
|
13
16
|
// 데이터는 전부 코크핏이 이미 읽는 것: GET /api/fleet(+ agentStates · real) 과 /ws 델타.
|
|
14
17
|
// 새 창구는 하나뿐 — POST /api/runs/:id/input (살아 있는 tmux 에 한 줄 써 넣기, getScrollback 의 쓰기 쌍둥이).
|
|
@@ -52,11 +55,18 @@ export const HUD_HTML = /* html */ `<!doctype html>
|
|
|
52
55
|
html[data-hud="pill"] body{align-items:flex-start}
|
|
53
56
|
|
|
54
57
|
/* ── K3. 접힌 알약 — 이것이 평상시의 전부다 ───────────────────────────── */
|
|
55
|
-
.pill{display:inline-flex;align-items:center;gap:8px;height:26px;padding:0 11px;border-radius:999px;
|
|
58
|
+
.pill{display:inline-flex;align-items:center;gap:8px;height:26px;padding:0 11px 0 7px;border-radius:999px;
|
|
56
59
|
background:var(--surface);border:1px solid var(--line);box-shadow:0 8px 28px rgba(0,0,0,.35);
|
|
57
|
-
font-family:var(--mono);font-size:11px;color:var(--muted)
|
|
60
|
+
font-family:var(--mono);font-size:11px;color:var(--muted)}
|
|
58
61
|
.pill:hover{border-color:var(--line-hi);color:var(--ink)}
|
|
59
|
-
|
|
62
|
+
/* app-region: 프레임 없는 창(K1)에서 drag 영역은 클릭을 **삼킨다** — 창이 대신 움직인다.
|
|
63
|
+
그래서 알약 자체는 no-drag(눌러서 펼치는 것이 알약의 일이고, 옮기는 일보다 훨씬 잦다)이고,
|
|
64
|
+
창을 옮기는 손잡이는 알약 안의 작은 홈 하나뿐이다. 브라우저에서는 둘 다 아무 뜻도 없다. */
|
|
65
|
+
.pill,.pill>*{-webkit-app-region:no-drag}
|
|
66
|
+
.pill>.pgrip{-webkit-app-region:drag}
|
|
67
|
+
.pgrip{flex:none;color:var(--faint);font-family:var(--mono);font-size:10px;line-height:1;
|
|
68
|
+
letter-spacing:-.06em;padding:0 2px;opacity:.65;cursor:grab}
|
|
69
|
+
.pill:hover .pgrip{opacity:1}
|
|
60
70
|
.pdots{display:inline-flex;align-items:center;gap:4px}
|
|
61
71
|
.pwait{color:var(--blocked);font-size:11px;letter-spacing:.02em}
|
|
62
72
|
.pquiet{color:var(--faint);font-size:10.5px}
|
|
@@ -159,8 +169,10 @@ export const HUD_HTML = /* html */ `<!doctype html>
|
|
|
159
169
|
</style>
|
|
160
170
|
</head>
|
|
161
171
|
<body>
|
|
162
|
-
<!-- K3 — 접힌 알약. 누르면 펼쳐진다(창 크기는 데스크톱이 나중에 data-hud 를 보고 맞춘다).
|
|
172
|
+
<!-- K3 — 접힌 알약. 누르면 펼쳐진다(창 크기는 데스크톱이 나중에 data-hud 를 보고 맞춘다).
|
|
173
|
+
맨 앞의 홈(.pgrip)만이 창을 끄는 손잡이다 — 나머지는 전부 눌리는 자리다. -->
|
|
163
174
|
<button type="button" id="pill" class="pill" aria-label="coxpit fleet">
|
|
175
|
+
<span class="pgrip" id="pillGrip" aria-hidden="true" title="끌어서 창 옮기기">⠿</span>
|
|
164
176
|
<span class="pdots" id="pillDots"></span>
|
|
165
177
|
<span class="pwait" id="pillWait" hidden></span>
|
|
166
178
|
<span class="pquiet" id="pillQuiet" hidden>quiet</span>
|
|
@@ -194,12 +206,28 @@ export const HUD_HTML = /* html */ `<!doctype html>
|
|
|
194
206
|
}
|
|
195
207
|
|
|
196
208
|
// ── 레이아웃 상태 = 데스크톱이 읽어 갈 자리 ────────────────────────────
|
|
197
|
-
// 창을 줄이고 늘리는 것은 **여기가 아니다**(K1, 데스크톱
|
|
209
|
+
// 창을 줄이고 늘리는 것은 **여기가 아니다**(K1, 데스크톱 창). 이 페이지는 원하는 크기를
|
|
198
210
|
// <html data-hud> 에 적어 두고, 자기 안에서는 그 값으로 배치만 바꾼다.
|
|
211
|
+
// 데스크톱 창에 실려 있으면 그 크기를 한 번 더 **말해 준다** — 창을 만지는 쪽은 언제나 저쪽이다.
|
|
212
|
+
// 알약 120x26 · 목록 250 · 상세 570 (+ 지면 여백 6px 양쪽). 창은 이 값을 화면 크기로 한 번 더 물린다.
|
|
213
|
+
var HUD_SIZE={ pill:{w:132,h:38}, list:{w:262,h:380}, detail:{w:582,h:440} };
|
|
214
|
+
function reportSize(next){
|
|
215
|
+
var api=window.coxpitHud;
|
|
216
|
+
if(!api||typeof api.size!=='function') return; // 브라우저에서 단독으로 열린 경우 — 아무 일도 없다
|
|
217
|
+
var s=HUD_SIZE[next]||HUD_SIZE.pill;
|
|
218
|
+
var w=s.w, h=s.h;
|
|
219
|
+
// 알약만은 **재서** 말한다 — 폭이 점 수에 따라 달라져서 고정값이면 잘리거나 빈자리가 남는다.
|
|
220
|
+
if(next==='pill'){
|
|
221
|
+
var el=$('pill');
|
|
222
|
+
if(el&&el.offsetWidth){ w=Math.ceil(el.offsetWidth)+12; h=Math.ceil(el.offsetHeight)+12; }
|
|
223
|
+
}
|
|
224
|
+
try{ api.size({state:next, w:w, h:h}); }catch(e){}
|
|
225
|
+
}
|
|
199
226
|
var layout='pill';
|
|
200
227
|
function setLayout(next){
|
|
201
228
|
layout=next;
|
|
202
229
|
document.documentElement.setAttribute('data-hud', next);
|
|
230
|
+
reportSize(next);
|
|
203
231
|
if(next==='pill'){ try{ $('pill').focus(); }catch(e){} }
|
|
204
232
|
}
|
|
205
233
|
function expand(){ if(layout==='pill'){ setLayout('list'); render(); } }
|
|
@@ -335,6 +363,8 @@ ${ACTIVITY_JS}
|
|
|
335
363
|
if(b.wait.length){ w.hidden=false; w.textContent='\\u25d4 '+b.wait.length; q.hidden=true; }
|
|
336
364
|
else { w.hidden=true; q.hidden=false; q.textContent=b.run.length?(b.run.length+' running'):'quiet'; }
|
|
337
365
|
$('pill').title = b.wait.length ? (b.wait.length+' 개가 답을 기다립니다') : (b.run.length+' 개가 도는 중');
|
|
366
|
+
// 점이 늘고 줄면 알약의 폭도 달라진다 — 떠 있는 창이라면 그때마다 다시 말해 준다.
|
|
367
|
+
if(layout==='pill') reportSize('pill');
|
|
338
368
|
}
|
|
339
369
|
|
|
340
370
|
// ── K5. 상세 — 고른 행 하나. 목록은 그대로 옆에 남는다. ────────────────
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { config } from './config';
|
|
2
2
|
import { authMode, ensureSetupToken, isExposedBind } from './authkey';
|
|
3
|
+
import { ensureClaudeShim } from './claudeshim';
|
|
3
4
|
import { db, ensureSchema } from './db';
|
|
4
5
|
import { machines } from './db/schema';
|
|
5
6
|
import { acquireDaemonLock, updateLockPort } from './lock';
|
|
@@ -27,6 +28,13 @@ await acquireDaemonLock();
|
|
|
27
28
|
|
|
28
29
|
await ensureSchema();
|
|
29
30
|
|
|
31
|
+
// 세션마다 claude 대화를 정확히 묶기 위한 얇은 심(PATH 맨 앞) — 부팅 1회.
|
|
32
|
+
// claude 가 없는 기계면 조용히 건너뛴다(세션은 지금까지와 같이 동작하고, 뷰어는 폴백으로 찾는다).
|
|
33
|
+
{
|
|
34
|
+
const shim = ensureClaudeShim();
|
|
35
|
+
if (shim.real) console.log(`[coxpit] claude shim ready at ${shim.path} (tags each session's conversation with --session-id)`);
|
|
36
|
+
}
|
|
37
|
+
|
|
30
38
|
// 첫 실행 시 로컬 머신 시드(데몬이 도는 이 기계).
|
|
31
39
|
if ((await db.select().from(machines)).length === 0) {
|
|
32
40
|
await db.insert(machines).values({ slug: 'local', name: 'This machine', kind: 'local', online: true });
|
package/src/orchestrator.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { randomBytes } from 'node:crypto';
|
|
1
|
+
import { randomBytes, randomUUID } from 'node:crypto';
|
|
2
2
|
import { posix as ppath } from 'node:path';
|
|
3
3
|
import { createInterface } from 'node:readline';
|
|
4
4
|
import { existsSync, statSync, openSync, readSync, closeSync, mkdirSync } from 'node:fs';
|
|
@@ -6,6 +6,7 @@ import { mkdir, copyFile, readFile, writeFile, rm, unlink } from 'node:fs/promis
|
|
|
6
6
|
import { homedir } from 'node:os';
|
|
7
7
|
import type { ChildProcess } from 'node:child_process';
|
|
8
8
|
import { eq, inArray, and, desc } from 'drizzle-orm';
|
|
9
|
+
import { CLAUDE_SID_RE, claudeTagEnvArgs } from './claudeshim';
|
|
9
10
|
import { config } from './config';
|
|
10
11
|
import { db } from './db';
|
|
11
12
|
import { agentRuns, agentEvents, tasks, repos, machines, designCaptures, docSnapshots, taskGroups, secrets } from './db/schema';
|
|
@@ -132,6 +133,45 @@ export async function secretEnvArgs(): Promise<string> {
|
|
|
132
133
|
} catch { return ''; }
|
|
133
134
|
}
|
|
134
135
|
|
|
136
|
+
/** 이 머신이 데몬이 사는 그 기계인가(심 파일에 닿을 수 있나). */
|
|
137
|
+
function isLocalMachine(m: MachineTarget): boolean {
|
|
138
|
+
return m.kind === 'local' || m.address === '';
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* 인터랙티브 세션(사람이 `claude` 를 쳐 넣는 자리)의 claude 대화 태그.
|
|
143
|
+
* 세션을 열기 전에 id 를 정해 run 에 적고, 그 세션 tmux 에는 심 폴더를 앞세운 PATH 와
|
|
144
|
+
* COXPIT_CLAUDE_SID 를 넣는다 — 그러면 사람이 그냥 `claude` 를 쳐도 그 대화가 이 id 로 묶이고,
|
|
145
|
+
* 뷰어는 대본 파일명을 추측하지 않는다(§getSessionChat ①).
|
|
146
|
+
* id 는 심이 없어도 적어 둔다: 그 이름의 파일이 실제로 있을 때만 쓰이므로 거짓이 새지 않고,
|
|
147
|
+
* 컬럼의 뜻이 "이 세션이 쓰기로 한 대화" 하나로 단순해진다.
|
|
148
|
+
*/
|
|
149
|
+
function sessionClaudeTag(machine: MachineTarget): { sid: string; env: string } {
|
|
150
|
+
const sid = randomUUID();
|
|
151
|
+
return { sid, env: claudeTagEnvArgs(sid, isLocalMachine(machine)) };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* 죽은 세션을 그 자리에서 되살릴 때 다시 얹는 태깅 env(터미널 소생 경로 — server.ts).
|
|
156
|
+
* 셸이 exit 한 뒤 사람이 다시 들어와 `claude` 를 치는 자리가 정확히 여기라, 여기서 빠지면 태그가 끊긴다.
|
|
157
|
+
*/
|
|
158
|
+
export function claudeTagEnvForRun(machine: MachineTarget, sid: string): string {
|
|
159
|
+
return claudeTagEnvArgs(sid, isLocalMachine(machine));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* coxpit 세션의 tmux 스크롤백 한도. tmux 기본값(2000줄)으로는 뷰어의 "터미널" 탭이 금방 잘린다.
|
|
164
|
+
* history-limit 은 **pane 이 만들어지는 순간**의 값이 적용된다(기존 pane 은 안 늘어난다) —
|
|
165
|
+
* new-session 뒤에 세션 옵션으로 올리면 첫 pane 에는 안 먹는다. 그래서 같은 tmux 호출 안에서
|
|
166
|
+
* 전역 옵션을 먼저 올리고 세션을 만든다(start-server 로 서버를 띄워 둬야 set -g 가 산다).
|
|
167
|
+
*/
|
|
168
|
+
export const TMUX_HISTORY_LIMIT = 100000;
|
|
169
|
+
|
|
170
|
+
/** `tmux new-session <rest>` 대신 쓰는 셸 조각 — history-limit 을 올린 뒤 세션을 만든다. rest 는 호출부가 인용한다. */
|
|
171
|
+
export function tmuxNewSession(rest: string): string {
|
|
172
|
+
return `tmux start-server \\; set-option -g history-limit ${TMUX_HISTORY_LIMIT} \\; new-session ${rest}`;
|
|
173
|
+
}
|
|
174
|
+
|
|
135
175
|
// 실행 중 run 의 자식 프로세스(stop 용). stoppedRuns = 사용자가 멈춘 run 표식.
|
|
136
176
|
const liveChildren = new Map<number, ChildProcess>();
|
|
137
177
|
const stoppedRuns = new Set<number>();
|
|
@@ -528,7 +568,7 @@ export async function launchRun(runId: number, real?: boolean): Promise<void> {
|
|
|
528
568
|
// export LANG: 이 명령이 tmux 서버를 처음 띄우는 경우(특히 원격) C 로케일로 뜨면 CJK 가 깨진다.
|
|
529
569
|
const runEnv = await secretEnvArgs();
|
|
530
570
|
await runShellOn(ctx.machine,
|
|
531
|
-
`export LANG=${shq(config.lang)}; tmux kill-session -t ${shq('=' + session)} 2>/dev/null;
|
|
571
|
+
`export LANG=${shq(config.lang)}; tmux kill-session -t ${shq('=' + session)} 2>/dev/null; ${tmuxNewSession(`-d${runEnv} -s ${shq(session)} -c ${shq(wtPath)}`)} 2>/dev/null || true`, 8000);
|
|
532
572
|
|
|
533
573
|
await setRun(runId, { status: 'running' });
|
|
534
574
|
await recordEvent(runId, 'meta', JSON.stringify({ branch, worktree: wtPath, real: useReal, ...(inPlace ? { inPlace: true } : {}) }));
|
|
@@ -788,9 +828,117 @@ export async function getRunTermInfo(runId: number): Promise<{ machine: MachineT
|
|
|
788
828
|
return { machine: ctx.machine, session: run.tmuxWindow };
|
|
789
829
|
}
|
|
790
830
|
|
|
831
|
+
/** 대본 꼬리 상한 — runShellOn 의 maxBuffer(1MB)를 넘기면 읽기 자체가 실패한다. */
|
|
832
|
+
const CHAT_TAIL_BYTES = 800000;
|
|
833
|
+
|
|
834
|
+
/**
|
|
835
|
+
* 이름으로 대본 한 장 읽기 — 후보 폴더를 순서대로 보고 **처음 있는** `<sid>.jsonl` 의 꼬리.
|
|
836
|
+
* 없으면 빈 문자열(그러면 호출부가 다음 수단으로 내려간다).
|
|
837
|
+
*/
|
|
838
|
+
async function readTranscriptBySid(machine: MachineTarget, cands: string[], sid: string): Promise<string> {
|
|
839
|
+
const name = shq(`${sid}.jsonl`);
|
|
840
|
+
const cmd = `for d in ${cands.map((d) => shq(d)).join(' ')}; do f="$HOME/.claude/projects/$d/"${name};` +
|
|
841
|
+
` if [ -f "$f" ]; then tail -c ${CHAT_TAIL_BYTES} "$f"; exit 0; fi; done`;
|
|
842
|
+
const r = await runShellOn(machine, cmd, 15000);
|
|
843
|
+
return r.ok ? r.stdout : '';
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
/**
|
|
847
|
+
* 페인 화면에서 "이 대화에만 있을 법한" 긴 조각 몇 개.
|
|
848
|
+
* TUI 테두리·불릿 같은 장식은 공백으로 벗기고, 대본(JSON) 안에서 **그대로** 찾을 수 있는 구간만 남긴다
|
|
849
|
+
* (따옴표·역슬래시는 JSON 에서 escape 되므로 조각에 넣지 않는다). 긴 것부터 앞에 온다.
|
|
850
|
+
*/
|
|
851
|
+
function paneNeedles(screen: string[], want = 4): string[] {
|
|
852
|
+
const out: string[] = [];
|
|
853
|
+
const seen = new Set<string>();
|
|
854
|
+
for (const raw of screen) {
|
|
855
|
+
const t = raw.replace(/[\u2500-\u257F\u2022\u25A0-\u25FF]/g, ' ').replace(/\s+/g, ' ').trim();
|
|
856
|
+
if (t.length < 24) continue;
|
|
857
|
+
const piece = (t.match(/[^"\\]{24,}/g) ?? []).sort((a, b) => b.length - a.length)[0];
|
|
858
|
+
if (!piece) continue;
|
|
859
|
+
const s = piece.trim();
|
|
860
|
+
// 글자가 섞여 있어야 한다 — 기호만 남은 줄(프롬프트·구분선)은 아무 대본에나 걸린다.
|
|
861
|
+
if (s.length < 24 || seen.has(s) || !/[A-Za-z0-9\u3131-\uD79D\u3040-\u30FF\u4E00-\u9FFF]{4,}/.test(s)) continue;
|
|
862
|
+
seen.add(s);
|
|
863
|
+
out.push(s);
|
|
864
|
+
}
|
|
865
|
+
out.sort((a, b) => b.length - a.length);
|
|
866
|
+
return out.slice(0, want);
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
/**
|
|
870
|
+
* 태그 없이 이미 돌고 있는 세션의 대본 찾기 (best-effort) — **화면에 뜬 말**로 맞춰 본다.
|
|
871
|
+
* 사후에 페인↔.jsonl 을 잇는 파일시스템·프로세스 신호가 없기 때문에(열린 fd 도, argv 도 없다)
|
|
872
|
+
* 남는 단서는 그 페인이 지금 보여주고 있는 내용뿐이다.
|
|
873
|
+
* 규율: 페인이 claude 를 물고 있을 때만, 최신 25장만, 확실한 1등만.
|
|
874
|
+
* - 동점이면 페인의 마지막 활동 시각에 mtime 이 가장 가까운 쪽을 고르고,
|
|
875
|
+
* - 그래도 갈리면 답하지 않는다(찾지 못한 것을 찾았다고 하지 않는다).
|
|
876
|
+
* 다른 run 이 이미 자기 대화라고 적어 둔 id 는 후보에서 뺀다 — 남의 대본을 집지 않는 정확한 울타리다
|
|
877
|
+
* ("지금 가장 빨리 자라는 대본" 같은 추정보다 이게 싸고 확실하다).
|
|
878
|
+
*/
|
|
879
|
+
async function matchTranscriptByPane(
|
|
880
|
+
machine: MachineTarget, session: string, cands: string[], runId: number,
|
|
881
|
+
): Promise<string> {
|
|
882
|
+
const t = shq(session);
|
|
883
|
+
const probe = await runShellOn(
|
|
884
|
+
machine,
|
|
885
|
+
`printf 'CMD:%s\\n' "$(tmux display -p -t ${t} '#{pane_current_command}' 2>/dev/null)"; ` +
|
|
886
|
+
`printf 'ACT:%s\\n' "$(tmux display -p -t ${t} '#{session_activity}' 2>/dev/null)"; ` +
|
|
887
|
+
`tmux capture-pane -t ${t} -p -J -S -200 2>/dev/null || true`,
|
|
888
|
+
12000,
|
|
889
|
+
);
|
|
890
|
+
if (!probe.ok) return '';
|
|
891
|
+
let cmd = '';
|
|
892
|
+
let act = 0;
|
|
893
|
+
const screen: string[] = [];
|
|
894
|
+
for (const l of probe.stdout.split('\n')) {
|
|
895
|
+
if (l.startsWith('CMD:')) { cmd = l.slice(4).trim(); continue; }
|
|
896
|
+
if (l.startsWith('ACT:')) { act = Number(l.slice(4).trim()) || 0; continue; }
|
|
897
|
+
screen.push(l);
|
|
898
|
+
}
|
|
899
|
+
// claude TUI 는 node 로 뜬다(래퍼에 따라 이름이 다르다) — 평범한 셸 페인에서 남의 대본을 집지 않기 위한 문지기.
|
|
900
|
+
if (!/claude|^(node|bun|deno)$/.test(cmd)) return '';
|
|
901
|
+
const needles = paneNeedles(screen);
|
|
902
|
+
if (needles.length < 2) return '';
|
|
903
|
+
const greps = needles.map((n) => `grep -qF ${shq(n)} "$f" 2>/dev/null && n=$((n+1));`).join(' ');
|
|
904
|
+
const scan = `for d in ${cands.map((d) => shq(d)).join(' ')}; do p="$HOME/.claude/projects/$d";` +
|
|
905
|
+
` c=$(ls -t "$p"/*.jsonl 2>/dev/null | head -1); if [ -n "$c" ]; then cd "$p" || exit 0;` +
|
|
906
|
+
` for f in $(ls -t *.jsonl 2>/dev/null | head -25); do n=0; ${greps}` +
|
|
907
|
+
` m=$(date -r "$f" +%s 2>/dev/null || echo 0); printf '%s %s %s\\n' "$n" "$m" "$f"; done; exit 0; fi; done`;
|
|
908
|
+
const sc = await runShellOn(machine, scan, 20000);
|
|
909
|
+
if (!sc.ok) return '';
|
|
910
|
+
const claimed = await db.select({ id: agentRuns.id, sid: agentRuns.claudeSessionId }).from(agentRuns);
|
|
911
|
+
const exclude = new Set(claimed.filter((o) => o.id !== runId && o.sid).map((o) => o.sid));
|
|
912
|
+
const rows: Array<{ sid: string; score: number; mtime: number }> = [];
|
|
913
|
+
for (const l of sc.stdout.split('\n')) {
|
|
914
|
+
const m = /^(\d+) (\d+) (.+)\.jsonl$/.exec(l.trim());
|
|
915
|
+
if (!m) continue;
|
|
916
|
+
const sid = m[3]!;
|
|
917
|
+
if (exclude.has(sid) || !CLAUDE_SID_RE.test(sid)) continue;
|
|
918
|
+
rows.push({ sid, score: Number(m[1]), mtime: Number(m[2]) });
|
|
919
|
+
}
|
|
920
|
+
const best = rows.reduce((a, r) => Math.max(a, r.score), 0);
|
|
921
|
+
if (best < 1) return '';
|
|
922
|
+
const top = rows.filter((r) => r.score === best);
|
|
923
|
+
if (top.length === 1) return top[0]!.sid;
|
|
924
|
+
if (!act) return '';
|
|
925
|
+
top.sort((a, b) => Math.abs(a.mtime - act) - Math.abs(b.mtime - act));
|
|
926
|
+
return Math.abs(top[0]!.mtime - act) < Math.abs(top[1]!.mtime - act) ? top[0]!.sid : '';
|
|
927
|
+
}
|
|
928
|
+
|
|
791
929
|
/**
|
|
792
930
|
* 세션의 Claude Code 대화 로그(JSONL)를 대화형으로 파싱 — 뷰어의 "대화" 모드.
|
|
793
|
-
* cwd
|
|
931
|
+
* 대본은 claude 가 **자기 cwd** 를 [^a-zA-Z0-9]→'-' 로 인코딩한 ~/.claude/projects/<...>/ 밑에 쌓인다.
|
|
932
|
+
* worktreePath 를 기준으로 삼으면 빈손이 되는 경우가 둘이고, 폰에서 "대화 탭이 비어 있다"던 게 정확히 이것이다:
|
|
933
|
+
* ① 루트·메인 세션은 worktreePath 가 아예 비어 있다(그래도 페인엔 멀쩡한 cwd 가 있다 — 그래서 여기서 포기하면 안 된다).
|
|
934
|
+
* ② 사람이 페인에서 cd 한 뒤엔 worktree 루트가 claude 의 cwd 가 아니다.
|
|
935
|
+
* 그래서 기준은 **페인이 지금 서 있는 폴더**(tmux #{pane_current_path} — getRunPwd)이고, worktreePath 는 폴백이다.
|
|
936
|
+
*
|
|
937
|
+
* 폴더가 정해져도 그 안의 **어느 .jsonl 이냐**가 남는다. 한 폴더(워크스페이스 루트)에 세션이 여럿이면
|
|
938
|
+
* "가장 최근 파일"은 거의 항상 남의 대화다 — 그래서 셋을 순서대로 본다:
|
|
939
|
+
* ① 이 세션에 태깅해 둔 id 의 파일(claudeSessionId, 정확) — §sessionClaudeTag 가 심어 둔 이름.
|
|
940
|
+
* ② 없으면 화면 내용으로 찾아(best-effort) **한 번만** 찾고 그 id 를 적어 둔다.
|
|
941
|
+
* ③ 그래도 못 찾으면 예전대로 최신 .jsonl (틀릴 수 있다는 것을 아는 폴백).
|
|
794
942
|
* user/assistant turn 만 추출(tool_result 노이즈 제외, tool_use 는 칩으로).
|
|
795
943
|
*/
|
|
796
944
|
export async function getSessionChat(runId: number, maxTurns = 200): Promise<{
|
|
@@ -799,12 +947,35 @@ export async function getSessionChat(runId: number, maxTurns = 200): Promise<{
|
|
|
799
947
|
const info = await getRunTermInfo(runId);
|
|
800
948
|
const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, runId)).limit(1);
|
|
801
949
|
const run = rr[0];
|
|
802
|
-
if (!info || !run
|
|
803
|
-
const
|
|
804
|
-
const
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
950
|
+
if (!info || !run) return { ok: false, turns: [], note: 'no session' };
|
|
951
|
+
const pwd = (await getRunPwd(runId)).pwd; // tmux #{pane_current_path} — 대본의 진짜 기준
|
|
952
|
+
const encoded = [pwd, run.worktreePath || '']
|
|
953
|
+
.filter((p) => !!p)
|
|
954
|
+
.map((p) => p.replace(/[^a-zA-Z0-9]/g, '-'));
|
|
955
|
+
const cands = encoded.filter((d, i) => encoded.indexOf(d) === i);
|
|
956
|
+
if (!cands.length) return { ok: false, turns: [], note: 'no session' };
|
|
957
|
+
|
|
958
|
+
// ① 태깅된 대본 — 이름을 미리 정해 뒀으면 추측할 것이 없다.
|
|
959
|
+
const tagged = CLAUDE_SID_RE.test(run.claudeSessionId) ? run.claudeSessionId : '';
|
|
960
|
+
let raw = tagged ? await readTranscriptBySid(info.machine, cands, tagged) : '';
|
|
961
|
+
|
|
962
|
+
// ② 태그가 (아직) 실물과 안 맞는 세션 — 화면에 뜬 말로 찾고, 찾았으면 적어 둬 다시 찾지 않는다.
|
|
963
|
+
if (!raw.trim()) {
|
|
964
|
+
const found = await matchTranscriptByPane(info.machine, info.session, cands, runId);
|
|
965
|
+
if (found) {
|
|
966
|
+
raw = await readTranscriptBySid(info.machine, cands, found);
|
|
967
|
+
if (raw.trim()) await setRun(runId, { claudeSessionId: found });
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
// ③ 폴백 — 이 폴더의 최신 .jsonl (페인 cwd 우선, worktree 폴백). 여러 세션이 한 폴더를 쓰면 남의 것일 수 있다.
|
|
972
|
+
if (!raw.trim()) {
|
|
973
|
+
const cmd = `f=''; for d in ${cands.map((d) => shq(d)).join(' ')}; do c=$(ls -t "$HOME/.claude/projects/$d"/*.jsonl 2>/dev/null | head -1); if [ -n "$c" ]; then f="$c"; break; fi; done; [ -n "$f" ] && tail -c ${CHAT_TAIL_BYTES} "$f" || true`;
|
|
974
|
+
const fb = await runShellOn(info.machine, cmd, 15000);
|
|
975
|
+
if (!fb.ok) return { ok: true, turns: [], note: 'no transcript' };
|
|
976
|
+
raw = fb.stdout;
|
|
977
|
+
}
|
|
978
|
+
if (!raw.trim()) return { ok: true, turns: [], note: 'no Claude Code transcript for this folder' };
|
|
808
979
|
const userText = (content: unknown): string => {
|
|
809
980
|
if (typeof content === 'string') return content;
|
|
810
981
|
if (!Array.isArray(content)) return '';
|
|
@@ -823,7 +994,7 @@ export async function getSessionChat(runId: number, maxTurns = 200): Promise<{
|
|
|
823
994
|
return { text: text.join('\n'), tools };
|
|
824
995
|
};
|
|
825
996
|
const turns: Array<{ role: string; text: string; tools?: string[] }> = [];
|
|
826
|
-
for (const line of
|
|
997
|
+
for (const line of raw.split('\n')) {
|
|
827
998
|
if (!line.trim()) continue;
|
|
828
999
|
let j: { type?: string; message?: { content?: unknown } };
|
|
829
1000
|
try { j = JSON.parse(line); } catch { continue; }
|
|
@@ -840,16 +1011,25 @@ export async function getSessionChat(runId: number, maxTurns = 200): Promise<{
|
|
|
840
1011
|
|
|
841
1012
|
/**
|
|
842
1013
|
* tmux 페인 스크롤백 스냅샷 — 모바일 "위 내용 읽기"(뷰어의 터미널 모드)용.
|
|
843
|
-
* capture-pane -S -
|
|
1014
|
+
* capture-pane -S - 로 **히스토리 전체**(맨 처음 줄부터 현재 화면까지)를 떠서, 꼬리 N 줄만 돌려준다(읽기 전용).
|
|
1015
|
+
* 예전엔 -S -N 이었는데, 세션이 tmux 기본 history-limit(2000) 으로 떠 있어 N 을 아무리 키워도 소용없었다
|
|
1016
|
+
* → 세션 생성 때 한도를 올리고(tmuxNewSession), 여기선 범위를 잘라 묻지 않는다.
|
|
1017
|
+
* -J 는 화면 폭에서 접힌 줄을 **논리적 한 줄로 도로 붙인다**. 이게 없으면 캡처가 디스플레이 행 그대로 와서
|
|
1018
|
+
* 폰에서 긴 경로·명령어가 단어 중간에서 끊겼다("Internal Solutio / n %") — 읽기도 집기도 안 되던 원인.
|
|
1019
|
+
* 한계 둘(버그 아님):
|
|
1020
|
+
* - 전체 화면 TUI(claude CLI·vim·less 등)는 tmux 의 **대체 화면**을 쓴다. 대체 화면엔 스크롤백이 없어서
|
|
1021
|
+
* TUI 가 떠 있는 동안 캡처되는 건 지금 보이는 화면뿐이다(뷰어 터미널 탭이 그렇게 말해 준다). 일반 셸 출력은 전부 남는다.
|
|
1022
|
+
* - 헤드리스 에이전트 run 은 tmux 밖에서 돈다 — 그 tmux 는 빈 worktree 셸이 정상이고, 볼 곳은 "대화" 탭이다.
|
|
844
1023
|
*/
|
|
845
1024
|
export async function getScrollback(runId: number, lines: number): Promise<{ ok: boolean; text: string }> {
|
|
846
1025
|
const info = await getRunTermInfo(runId);
|
|
847
1026
|
if (!info) return { ok: false, text: 'no terminal session' };
|
|
848
|
-
const n = Math.max(50, Math.min(
|
|
1027
|
+
const n = Math.max(50, Math.min(TMUX_HISTORY_LIMIT, Math.floor(lines) || TMUX_HISTORY_LIMIT));
|
|
849
1028
|
// capture-pane 은 '=' 접두사(정확일치) 를 pane 타깃으로 못 받는다 → 세션명 그대로(존재 시 정확일치 우선).
|
|
850
|
-
const r = await runShellOn(info.machine, `tmux capture-pane -t ${shq(info.session)} -p -S
|
|
1029
|
+
const r = await runShellOn(info.machine, `tmux capture-pane -t ${shq(info.session)} -p -J -S -`, 15000);
|
|
851
1030
|
if (!r.ok) return { ok: false, text: (r.stderr || r.stdout).trim().slice(0, 500) };
|
|
852
|
-
|
|
1031
|
+
const all = r.stdout.split('\n');
|
|
1032
|
+
return { ok: true, text: all.length > n ? all.slice(-n).join('\n') : r.stdout };
|
|
853
1033
|
}
|
|
854
1034
|
|
|
855
1035
|
/**
|
|
@@ -882,7 +1062,7 @@ export async function sendRunInput(runId: number, text: string): Promise<{ ok: b
|
|
|
882
1062
|
export async function getRunPwd(runId: number): Promise<{ ok: boolean; pwd: string }> {
|
|
883
1063
|
const info = await getRunTermInfo(runId);
|
|
884
1064
|
if (!info) return { ok: false, pwd: '' };
|
|
885
|
-
const t = shq(
|
|
1065
|
+
const t = shq(info.session);
|
|
886
1066
|
const cmd = `tmux display -p -t ${t} '#{pane_current_path}' 2>/dev/null || tmux list-panes -s -t ${t} -F '#{pane_current_path}' 2>/dev/null | head -1`;
|
|
887
1067
|
const r = await runShellOn(info.machine, cmd, 8000);
|
|
888
1068
|
const pwd = ((r.stdout || '').split('\n')[0] || '').trim();
|
|
@@ -1226,9 +1406,10 @@ export async function openWorkbench(repoId: number, title: string, root = false)
|
|
|
1226
1406
|
// branch='' 로 남겨 merge 는 자동 거부(=이미 base). cleanup 도 worktree remove 를 건너뛴다.
|
|
1227
1407
|
// root=false: 기존 workbench — 격리 worktree + 브랜치(수동 변경 후 Review 에서 merge).
|
|
1228
1408
|
const agent = root ? 'session' : 'workbench';
|
|
1409
|
+
const tag = sessionClaudeTag(machine); // 이 작업방의 claude 대화 id(뷰어가 대본을 정확히 찾는 근거)
|
|
1229
1410
|
const tIns = await db.insert(tasks).values({ repoId, title: title || (root ? 'Session' : 'Workbench'), prompt: root ? '(root session)' : '(interactive workbench)' }).returning();
|
|
1230
1411
|
const task = tIns[0]!;
|
|
1231
|
-
const rIns = await db.insert(agentRuns).values({ taskId: task.id, machineId: m.id, agent, status: 'pending' }).returning();
|
|
1412
|
+
const rIns = await db.insert(agentRuns).values({ taskId: task.id, machineId: m.id, agent, status: 'pending', claudeSessionId: tag.sid }).returning();
|
|
1232
1413
|
const run = rIns[0]!;
|
|
1233
1414
|
const runId = run.id;
|
|
1234
1415
|
broadcast({ type: 'run', runId, taskId: task.id, status: 'pending', agent, branch: '', filesChanged: 0 });
|
|
@@ -1240,15 +1421,16 @@ export async function openWorkbench(repoId: number, title: string, root = false)
|
|
|
1240
1421
|
|
|
1241
1422
|
// export LANG: tmux 서버 첫 기동이 C 로케일이면 세션 셸의 CJK 입력·표시가 깨진다.
|
|
1242
1423
|
// 동명 세션 잔재(DB 리셋 등으로 run id 재사용) 선제 정리 — '=' 정확 일치만.
|
|
1243
|
-
|
|
1424
|
+
// 태깅 env 는 시크릿 뒤에 둔다 — 볼트에 PATH 가 들어 있어도 심이 앞을 잡아야 태깅이 산다.
|
|
1425
|
+
const wbEnv = (await secretEnvArgs()) + tag.env; // 시크릿 볼트 + claude 태깅 → env 주입
|
|
1244
1426
|
const prep = await runShellOn(
|
|
1245
1427
|
machine,
|
|
1246
1428
|
root
|
|
1247
1429
|
? `export LANG=${shq(config.lang)}; { tmux kill-session -t ${shq('=' + session)} 2>/dev/null || true; }` +
|
|
1248
|
-
` &&
|
|
1430
|
+
` && ${tmuxNewSession(`-d${wbEnv} -s ${shq(session)} -c ${shq(repo.path)}`)}`
|
|
1249
1431
|
: `export LANG=${shq(config.lang)}; mkdir -p ${shq(wtParent)} && git -C ${shq(repo.path)} worktree add -b ${shq(branch)} ${shq(wtPath)} ${shq(repo.defaultBranch)}` +
|
|
1250
1432
|
` && { tmux kill-session -t ${shq('=' + session)} 2>/dev/null || true; }` +
|
|
1251
|
-
` &&
|
|
1433
|
+
` && ${tmuxNewSession(`-d${wbEnv} -s ${shq(session)} -c ${shq(wtPath)}`)}`,
|
|
1252
1434
|
20000,
|
|
1253
1435
|
);
|
|
1254
1436
|
if (!prep.ok) {
|
|
@@ -1289,19 +1471,21 @@ export async function openSessionAt(machineSlug: string, path: string, title: st
|
|
|
1289
1471
|
|
|
1290
1472
|
const bucket = await ensureSessionsRepo(m.id);
|
|
1291
1473
|
const name = title || dir.split('/').filter(Boolean).pop() || dir;
|
|
1474
|
+
const tag = sessionClaudeTag(machine); // 이 세션의 claude 대화 id
|
|
1292
1475
|
const tIns = await db.insert(tasks).values({ repoId: bucket.id, title: name, prompt: '(session)' }).returning();
|
|
1293
1476
|
const task = tIns[0]!;
|
|
1294
|
-
const rIns = await db.insert(agentRuns).values({ taskId: task.id, machineId: m.id, agent: 'session', status: 'pending' }).returning();
|
|
1477
|
+
const rIns = await db.insert(agentRuns).values({ taskId: task.id, machineId: m.id, agent: 'session', status: 'pending', claudeSessionId: tag.sid }).returning();
|
|
1295
1478
|
const run = rIns[0]!;
|
|
1296
1479
|
const runId = run.id;
|
|
1297
1480
|
broadcast({ type: 'run', runId, taskId: task.id, status: 'pending', agent: 'session', branch: '', filesChanged: 0 });
|
|
1298
1481
|
|
|
1299
1482
|
const session = `coxpit-r${runId}`;
|
|
1300
|
-
|
|
1483
|
+
// 태깅 env 는 시크릿 뒤에(위 openWorkbench 와 같은 이유 — 심이 PATH 앞을 잡아야 한다).
|
|
1484
|
+
const sEnv = (await secretEnvArgs()) + tag.env; // 시크릿 볼트 + claude 태깅 → env 주입
|
|
1301
1485
|
const prep = await runShellOn(
|
|
1302
1486
|
machine,
|
|
1303
1487
|
`export LANG=${shq(config.lang)}; { tmux kill-session -t ${shq('=' + session)} 2>/dev/null || true; }` +
|
|
1304
|
-
` &&
|
|
1488
|
+
` && ${tmuxNewSession(`-d${sEnv} -s ${shq(session)} -c ${shq(dir)}`)}`,
|
|
1305
1489
|
15000,
|
|
1306
1490
|
);
|
|
1307
1491
|
if (!prep.ok) {
|
package/src/server.ts
CHANGED
|
@@ -17,11 +17,12 @@ import {
|
|
|
17
17
|
} from './authkey';
|
|
18
18
|
import { config } from './config';
|
|
19
19
|
import { readSettings, writeSettings } from './settings';
|
|
20
|
+
import { readOpenTabs, writeOpenTabs, sanitizeOpenTabs } from './uistate';
|
|
20
21
|
import { db } from './db';
|
|
21
22
|
import { machines, repos, tasks, agentRuns, agentEvents, designCaptures, shareLinks, taskGroups, secrets } from './db/schema';
|
|
22
23
|
import { BOOKMARKLET_JS } from './design';
|
|
23
24
|
import { runShellOn, shq } from './exec';
|
|
24
|
-
import { launchRun, cleanupRun, stopRun, getRunDiff, loadRunDocs, mergeRun, getRunTermInfo, steerRun, exportRun, prRun, integrateRuns, planFanout, reviewTask, syncRun, openWorkbench, spawnSubtasks, listSubtasks, resolveAgentToken, taskCloseRisk, launchGroupTask, isRunLive, liveInPlaceRun, askGroupCoordinator, computeRunOutputs, normalizeOutputs, listReclaimableWorktrees, pruneWorktrees, worktreeDisk, listOrphanTmux, killTmuxSessions, noopSignal, groupOverlap, landTarget, mergePreview, startLandResolve, listDocuments, verifyRun, verifyBase, openSessionAt, deleteSession, getScrollback, sendRunInput, getRunPwd, getSessionChat } from './orchestrator';
|
|
25
|
+
import { launchRun, cleanupRun, stopRun, getRunDiff, loadRunDocs, mergeRun, getRunTermInfo, steerRun, exportRun, prRun, integrateRuns, planFanout, reviewTask, syncRun, openWorkbench, spawnSubtasks, listSubtasks, resolveAgentToken, taskCloseRisk, launchGroupTask, isRunLive, liveInPlaceRun, askGroupCoordinator, computeRunOutputs, normalizeOutputs, listReclaimableWorktrees, pruneWorktrees, worktreeDisk, listOrphanTmux, killTmuxSessions, noopSignal, groupOverlap, landTarget, mergePreview, startLandResolve, listDocuments, verifyRun, verifyBase, openSessionAt, deleteSession, getScrollback, sendRunInput, getRunPwd, getSessionChat, tmuxNewSession, claudeTagEnvForRun } from './orchestrator';
|
|
25
26
|
import { openTerm } from './term';
|
|
26
27
|
import { attach as agentAttach, feed as agentFeed, input as agentInput, onExit as agentExit, detach as agentDetach, allAgentStates, spottedPorts } from './agentstate';
|
|
27
28
|
import { scanListeners, scanPort, killPid, dropCache as dropListenerCache } from './procscan';
|
|
@@ -254,13 +255,19 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
254
255
|
};
|
|
255
256
|
});
|
|
256
257
|
|
|
258
|
+
// 서빙되는 한 장짜리 페이지는 **절대 캐시하지 않는다**. 세 페이지 전부 HTML 안에 JS 가 들어 있어서,
|
|
259
|
+
// 브라우저·PWA 가 옛 문서를 물고 있으면 고친 코드가 기기에 아예 도착하지 않는다(폰에서 "고쳤는데
|
|
260
|
+
// 그대로"의 진짜 원인). 버전이 박힌 /vendor/*·/brand/* 정적 자산은 그대로 캐시한다 — 여기만 no-store.
|
|
261
|
+
const freshPage = (reply: FastifyReply): FastifyReply =>
|
|
262
|
+
reply.header('cache-control', 'no-store').header('pragma', 'no-cache');
|
|
263
|
+
|
|
257
264
|
// 플릿 보드(단일 페이지). 인증 게이트 적용됨(무인증 요청은 게이트가 login/setup 페이지로 응답).
|
|
258
|
-
app.get('/', async (_req, reply) => reply.type('text/html').send(BOARD_HTML));
|
|
265
|
+
app.get('/', async (_req, reply) => freshPage(reply).type('text/html').send(BOARD_HTML));
|
|
259
266
|
// 터미널 우선 셸(병행 개발) — 백엔드는 보드와 공유. Phase 5에서 데스크톱 기본을 여기로 플립 예정.
|
|
260
|
-
app.get('/cockpit', async (_req, reply) => reply.type('text/html').send(COCKPIT_PAGE));
|
|
267
|
+
app.get('/cockpit', async (_req, reply) => freshPage(reply).type('text/html').send(COCKPIT_PAGE));
|
|
261
268
|
// HUD(v5.28 K) — 플릿을 작게 다시 내놓는 한 장. 데스크톱의 떠 있는 작은 창이 이걸 띄운다.
|
|
262
269
|
// 서빙되는 **페이지**라 /cockpit 과 같은 게이트 뒤다(무인증 예외 아님 — 헬스가 아니다).
|
|
263
|
-
app.get('/hud', async (_req, reply) => reply.type('text/html').send(HUD_PAGE));
|
|
270
|
+
app.get('/hud', async (_req, reply) => freshPage(reply).type('text/html').send(HUD_PAGE));
|
|
264
271
|
|
|
265
272
|
// ─── 접근키 인증(access-key) ────────────────────────────────────
|
|
266
273
|
// 요청이 tunnel/https 를 탔나 — Secure 쿠키 여부 결정용.
|
|
@@ -503,6 +510,19 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
503
510
|
return { ok: true };
|
|
504
511
|
});
|
|
505
512
|
|
|
513
|
+
// ── 소유자 UI 상태 — 열어둔 탭 (ui-state.json) ───────────────────
|
|
514
|
+
// 탭 바를 **데몬**이 기억한다. 브라우저 저장소는 캐시 비우기·기기 교체·옛 페이지에 쉽게 날아가는데,
|
|
515
|
+
// 그동안 tmux 세션은 살아 있다 — 새로고침이 돌고 있는 일을 닫아버려선 안 된다.
|
|
516
|
+
app.get('/api/ui/opentabs', async () => readOpenTabs());
|
|
517
|
+
app.put('/api/ui/opentabs', async (req, reply) => {
|
|
518
|
+
try {
|
|
519
|
+
return { ok: true, ...writeOpenTabs(sanitizeOpenTabs(req.body)) };
|
|
520
|
+
} catch (e) {
|
|
521
|
+
// 디스크에 못 썼으면 조용히 성공한 척하지 않는다 — 클라이언트는 localStorage 사본으로 버틴다.
|
|
522
|
+
return reply.code(500).send({ error: `could not persist ui state: ${(e as Error).message}` });
|
|
523
|
+
}
|
|
524
|
+
});
|
|
525
|
+
|
|
506
526
|
// 아카이브 — 닫힌 태스크 목록(최신순, 페이지네이션·필터). 카드가 아니라 한 줄 행.
|
|
507
527
|
app.get('/api/archive', async (req) => {
|
|
508
528
|
const q = (req.query ?? {}) as { offset?: string; limit?: string; q?: string; repo?: string; status?: string };
|
|
@@ -1302,7 +1322,7 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
1302
1322
|
const q = (req.query ?? {}) as { lines?: string };
|
|
1303
1323
|
const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, id)).limit(1);
|
|
1304
1324
|
if (!rr[0]) return reply.code(404).send({ error: 'not found' });
|
|
1305
|
-
const res = await getScrollback(id, Number(q.lines) ||
|
|
1325
|
+
const res = await getScrollback(id, Number(q.lines) || 0); // lines 없으면 히스토리 전체
|
|
1306
1326
|
if (!res.ok) return reply.code(422).send(res);
|
|
1307
1327
|
return res;
|
|
1308
1328
|
});
|
|
@@ -1905,8 +1925,10 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
1905
1925
|
if (!has.ok) {
|
|
1906
1926
|
const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, id)).limit(1);
|
|
1907
1927
|
const wt = rr[0]?.worktreePath ?? '';
|
|
1928
|
+
// 소생한 페인에서도 `claude` 가 이 세션의 대화로 묶이게 태깅 env 를 다시 얹는다(없으면 빈 문자열).
|
|
1929
|
+
const tagEnv = claudeTagEnvForRun(info.machine, rr[0]?.claudeSessionId ?? '');
|
|
1908
1930
|
const revive = wt
|
|
1909
|
-
? await runShellOn(info.machine, `export LANG=${shq(config.lang)}; test -d ${shq(wt)} &&
|
|
1931
|
+
? await runShellOn(info.machine, `export LANG=${shq(config.lang)}; test -d ${shq(wt)} && ${tmuxNewSession(`-d${tagEnv} -s ${shq(info.session)} -c ${shq(wt)}`)}`, 10000)
|
|
1910
1932
|
: { ok: false } as { ok: boolean };
|
|
1911
1933
|
if (!revive.ok) {
|
|
1912
1934
|
socket.send(JSON.stringify({ t: 'err', d: `tmux session '${info.session}' gone and could not be revived (worktree missing?)` }));
|
package/src/uistate.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { config } from './config';
|
|
3
|
+
|
|
4
|
+
// 소유자 UI 상태 — 데이터 폴더(~/.coxpit)의 ui-state.json 한 장.
|
|
5
|
+
// 왜 데몬이 기억하나: 열어둔 탭을 브라우저(localStorage)에만 두면 캐시를 비우거나, 기기를 바꾸거나,
|
|
6
|
+
// 오래된 페이지가 떠 있기만 해도 통째로 사라진다 — tmux 세션은 멀쩡히 살아 있는데도.
|
|
7
|
+
// 스키마를 늘리지 않는다: settings.json·auth.json 과 같은 자리의 작은 JSON 파일(선례 그대로).
|
|
8
|
+
// 소유자 1인 도구라 사용자 구분은 없다.
|
|
9
|
+
|
|
10
|
+
/** 탭 바에 열려 있던 run 탭 집합 + 마지막으로 보던 탭. */
|
|
11
|
+
export interface OpenTabs {
|
|
12
|
+
ids: number[];
|
|
13
|
+
active: number | null;
|
|
14
|
+
}
|
|
15
|
+
export interface UiState {
|
|
16
|
+
openTabs?: OpenTabs;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** 폰 DOM·복원 시간 보호 상한. 넘치면 **최근 것**을 남긴다. */
|
|
20
|
+
export const MAX_OPEN_TABS = 24;
|
|
21
|
+
|
|
22
|
+
function readUiState(): UiState {
|
|
23
|
+
try {
|
|
24
|
+
const raw = JSON.parse(fs.readFileSync(config.uiStatePath, 'utf8'));
|
|
25
|
+
return raw && typeof raw === 'object' ? (raw as UiState) : {};
|
|
26
|
+
} catch {
|
|
27
|
+
return {}; // 파일 없음·깨짐 = 빈 상태(기억해둔 게 없다)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** 신뢰할 수 없는 입력(클라이언트 PUT·디스크 파일)을 정수 id 목록으로 정리한다. */
|
|
32
|
+
export function sanitizeOpenTabs(raw: unknown): OpenTabs {
|
|
33
|
+
const o = (raw ?? {}) as { ids?: unknown; active?: unknown };
|
|
34
|
+
const ids: number[] = [];
|
|
35
|
+
if (Array.isArray(o.ids)) {
|
|
36
|
+
for (const v of o.ids) {
|
|
37
|
+
const n = Number(v);
|
|
38
|
+
if (Number.isInteger(n) && n > 0 && !ids.includes(n)) ids.push(n);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const a = Number(o.active);
|
|
42
|
+
return {
|
|
43
|
+
ids: ids.slice(-MAX_OPEN_TABS),
|
|
44
|
+
active: Number.isInteger(a) && a > 0 ? a : null,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* `stored` = 데몬이 **한 번이라도** 탭 집합을 받아 적었는가.
|
|
50
|
+
* 빈 집합("전부 닫았다")과 미기록("아직 아무 말도 못 들었다")은 다르다 — 클라이언트는 후자일 때만
|
|
51
|
+
* 브라우저 사본을 씨앗으로 쓴다(1회 이관). 이 구분이 없으면 owner 가 닫은 탭이 되살아난다.
|
|
52
|
+
*/
|
|
53
|
+
export function readOpenTabs(): OpenTabs & { stored: boolean } {
|
|
54
|
+
const st = readUiState();
|
|
55
|
+
if (!st.openTabs) return { ids: [], active: null, stored: false };
|
|
56
|
+
return { ...sanitizeOpenTabs(st.openTabs), stored: true };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** 부분 병합 저장 — 다른 UI 상태가 생겨도 이 쓰기가 덮지 않는다. */
|
|
60
|
+
export function writeOpenTabs(next: OpenTabs): OpenTabs {
|
|
61
|
+
const clean = sanitizeOpenTabs(next);
|
|
62
|
+
const cur = readUiState();
|
|
63
|
+
fs.writeFileSync(config.uiStatePath, JSON.stringify({ ...cur, openTabs: clean }, null, 2) + '\n');
|
|
64
|
+
return clean;
|
|
65
|
+
}
|