coxpit 6.3.2 → 6.3.3
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/index.ts +8 -0
- package/src/orchestrator.ts +206 -22
- package/src/server.ts +28 -6
- package/src/uistate.ts +65 -0
package/README.md
CHANGED
|
@@ -22,6 +22,7 @@ Your machines. Your auth. Your code never leaves your network.
|
|
|
22
22
|
- **The board, as the reading room** — WebSocket-driven review and records: status, event timeline (parsed from the agent's stream-json), per-run diff, archive, goal workrooms. It keeps working exactly as it always has; new capability just lands in the cockpit now.
|
|
23
23
|
- **Compare & merge** — all runs of a task side by side; pick the winner, merge to the base branch (auto-commits the worktree, guards a clean base, aborts on conflict).
|
|
24
24
|
- **Real terminal** — attach to any run's tmux session in the browser (xterm.js over a server-side PTY; resize propagates, `Ctrl-b d` detaches).
|
|
25
|
+
- **One session, one conversation** — every interactive session mints a Claude session id and puts a tiny `claude` shim first on that pane's PATH, so typing bare `claude` starts (or continues) exactly that conversation. The viewer's chat tab then reads the session's own transcript instead of guessing the newest file in the folder — which was always wrong when several sessions shared one directory. Explicit `--resume`/`--session-id` passes through untouched, and a session already running an untagged `claude` is matched by its on-screen content and remembered.
|
|
25
26
|
- **Multi-machine** — register remote machines over SSH (Tailscale/LAN); probe reachability (git·tmux), run fleets there.
|
|
26
27
|
- **Safe stops** — stop kills the whole process group; task close stops and cleans every worktree/branch.
|
|
27
28
|
- **Design Mode** — drag the `⌖ coxpit inspect` bookmarklet to your bar, click it on your running app, click any element: its selector, HTML and computed styles are captured and injected into the agents' prompt as design context.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "coxpit",
|
|
3
|
-
"version": "6.3.
|
|
3
|
+
"version": "6.3.3",
|
|
4
4
|
"description": "Self-hosted cockpit for running a fleet of AI coding agents across your own machines — parallel worktree runs, live board, compare & merge, web terminal, design capture.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "SEE LICENSE IN LICENSE.md",
|
|
File without changes
|
package/src/auth.ts
CHANGED
|
@@ -72,7 +72,10 @@ export async function authGate(req: FastifyRequest, reply: FastifyReply): Promis
|
|
|
72
72
|
|
|
73
73
|
// 거부 — HTML GET 은 페이지, 나머지는 401(팝업 없음).
|
|
74
74
|
if (wantsHtml(req)) {
|
|
75
|
-
|
|
75
|
+
// 이 200 은 /cockpit 같은 **앱 주소**로 나간다 — 캐시되면 언락한 뒤에도 브라우저가
|
|
76
|
+
// 로그인 화면을 계속 내놓는다. 서빙 페이지는 언제나 새 것(server.ts 의 세 페이지와 같은 규칙).
|
|
77
|
+
await reply.header('cache-control', 'no-store').header('pragma', 'no-cache')
|
|
78
|
+
.type('text/html').code(200).send(loginPageHTML(m.mode === 'setup'));
|
|
76
79
|
return;
|
|
77
80
|
}
|
|
78
81
|
await reply.code(401).send({ error: 'unauthorized' });
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { accessSync, chmodSync, constants, existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { config } from './config';
|
|
4
|
+
import { shq } from './exec';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 한 coxpit 세션 = 한 claude 대화.
|
|
8
|
+
*
|
|
9
|
+
* claude 는 대본을 `~/.claude/projects/<cwd 를 [^a-zA-Z0-9]→'-' 로 인코딩>/<세션 id>.jsonl` 에 쌓는다.
|
|
10
|
+
* 파일명이 세션 id 인데, 그 id 를 사후에 알아낼 길이 없다 — 첫 줄에 timestamp·cwd 가 없고,
|
|
11
|
+
* lsof 에 열린 fd 도 안 보이고, `claude` 의 argv/env 에도 안 실린다. 그래서 한 폴더(워크스페이스 루트)에
|
|
12
|
+
* 세션 여럿이 살면 "가장 최근 .jsonl" 은 거의 항상 남의 대화였다 — 뷰어의 대화 탭이 틀린 세션을 보여준 원인.
|
|
13
|
+
*
|
|
14
|
+
* 해법은 사후 추적이 아니라 **미리 이름을 정하는 것**이다: 세션을 열 때 id 를 만들어 두고,
|
|
15
|
+
* 사람이 그 세션에서 그냥 `claude` 를 쳐도 `claude --session-id <그 id>` 가 되게 얇은 심을 PATH 맨 앞에 끼운다.
|
|
16
|
+
* 심은 부팅 때 한 번, 진짜 claude 의 **절대경로**를 박아 쓴다(자기를 다시 부르는 PATH 루프가 원천적으로 불가능).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** 대본 파일명으로 쓸 수 있는 꼴(claude 는 UUID). 남이 심어둔 이름을 셸에 그냥 태우지 않기 위한 문지기. */
|
|
20
|
+
export const CLAUDE_SID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{7,63}$/;
|
|
21
|
+
|
|
22
|
+
// 부팅 때 해결한 진짜 claude 경로. 빈 문자열 = 심이 없다(= 태깅 env 를 넣지 않는다).
|
|
23
|
+
let shimReal = '';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 심 스크립트 본문. 규칙 셋뿐이다:
|
|
27
|
+
* - 사람이 이미 대화를 골랐으면(--resume/--session-id/-c 등) 아무것도 더하지 않고 그대로 넘긴다.
|
|
28
|
+
* - COXPIT_CLAUDE_SID 가 없으면(헤드리스·원격·평소 셸) 순수 통과 — 이 심은 보이지 않는다.
|
|
29
|
+
* - 있으면 --session-id 로 그 이름을 쓴다. 단 **그 이름의 대본이 이미 있으면 --resume 으로 이어붙인다**:
|
|
30
|
+
* 한 세션에서 claude 를 두 번째로 띄우는 것이 흔하고, 쓰이고 있는 id 를 --session-id 로 다시 주면
|
|
31
|
+
* 사람이 친 명령이 에러로 죽을 수 있다. 이어붙이는 쪽이 이 기능의 한 줄("한 세션 = 한 대화")과도 맞고,
|
|
32
|
+
* 두 번째 대화가 태그 없이 새로 생겨 뷰어가 **옛 대본**을 보여주는 사고도 같이 막는다.
|
|
33
|
+
* (!) 이 심은 `claude` 하나만 가로챈다 — `claude attach <id>` 같은 서브커맨드에 --session-id 가 붙는
|
|
34
|
+
* 드문 조합은 사람이 --resume/--session-id 를 직접 쓰면 위 첫 규칙으로 빠져나간다.
|
|
35
|
+
*/
|
|
36
|
+
export function claudeShimScript(real: string): string {
|
|
37
|
+
const r = shq(real);
|
|
38
|
+
return [
|
|
39
|
+
'#!/bin/sh',
|
|
40
|
+
'# coxpit: tag this session\'s claude so the viewer can find its transcript.',
|
|
41
|
+
'# pass through untouched if the user already selected a session.',
|
|
42
|
+
'for a in "$@"; do',
|
|
43
|
+
' case "$a" in',
|
|
44
|
+
` --session-id|--resume|-r|-c|--continue|--from-pr) exec ${r} "$@" ;;`,
|
|
45
|
+
' esac',
|
|
46
|
+
'done',
|
|
47
|
+
'if [ -n "${COXPIT_CLAUDE_SID:-}" ]; then',
|
|
48
|
+
' # claude keeps each conversation at ~/.claude/projects/<cwd with [^a-zA-Z0-9] as ->/<id>.jsonl',
|
|
49
|
+
// pwd 를 그대로 파이프하면 끝의 개행이 '-' 로 바뀌어 폴더 이름이 한 칸 길어진다 — printf 로 벗긴다.
|
|
50
|
+
" enc=$(printf '%s' \"$(pwd)\" | tr -c 'a-zA-Z0-9' '-')",
|
|
51
|
+
' if [ -f "$HOME/.claude/projects/$enc/$COXPIT_CLAUDE_SID.jsonl" ]; then',
|
|
52
|
+
` exec ${r} --resume "$COXPIT_CLAUDE_SID" "$@"`,
|
|
53
|
+
' fi',
|
|
54
|
+
` exec ${r} --session-id "$COXPIT_CLAUDE_SID" "$@"`,
|
|
55
|
+
'fi',
|
|
56
|
+
`exec ${r} "$@"`,
|
|
57
|
+
'',
|
|
58
|
+
].join('\n');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 진짜 claude 의 절대경로. `command -v` 대신 PATH 를 직접 훑는다(셸 없이 결정적이고,
|
|
63
|
+
* 심 폴더를 건너뛰는 규칙을 명시적으로 쓸 수 있다 — 심이 자기를 가리키는 일이 없다).
|
|
64
|
+
* bin 이 claude 계열 **절대경로**면 그것을 존중하고, 그 밖에는 PATH 에서 `claude` 를 찾는다
|
|
65
|
+
* (이름이 claude 가 아닌 bin 을 claude 자리에 앉히지 않는다 — 심의 이름은 사람이 치는 그 단어다).
|
|
66
|
+
*/
|
|
67
|
+
export function resolveClaudeReal(shimDir: string, bin = 'claude'): string {
|
|
68
|
+
if (bin.startsWith('/')) {
|
|
69
|
+
return existsSync(bin) && /claude/.test(path.basename(bin)) ? bin : '';
|
|
70
|
+
}
|
|
71
|
+
const skip = path.resolve(shimDir);
|
|
72
|
+
for (const d of (process.env.PATH ?? '').split(':')) {
|
|
73
|
+
if (!d || path.resolve(d) === skip) continue;
|
|
74
|
+
const p = path.join(d, 'claude');
|
|
75
|
+
try { accessSync(p, constants.X_OK); return p; } catch { /* 다음 후보 */ }
|
|
76
|
+
}
|
|
77
|
+
return '';
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* 부팅 1회 — 심 폴더에 실행 가능한 `claude` 한 장을 쓴다.
|
|
82
|
+
* claude 가 안 깔린 기계(CI 등)면 아무것도 쓰지 않고 빈 문자열을 돌려준다 —
|
|
83
|
+
* 심이 없으면 태깅 env 도 안 들어가고, 세션은 지금까지와 똑같이 동작한다.
|
|
84
|
+
*/
|
|
85
|
+
export function ensureClaudeShim(): { dir: string; real: string; path: string } {
|
|
86
|
+
const dir = config.shimDir;
|
|
87
|
+
const real = resolveClaudeReal(dir, config.agent.bin || 'claude');
|
|
88
|
+
const p = path.join(dir, 'claude');
|
|
89
|
+
if (!real) { shimReal = ''; return { dir, real: '', path: p }; }
|
|
90
|
+
try {
|
|
91
|
+
mkdirSync(dir, { recursive: true });
|
|
92
|
+
writeFileSync(p, claudeShimScript(real));
|
|
93
|
+
chmodSync(p, 0o755); // 덮어쓰기는 mode 를 다시 세우지 않는다 — chmod 를 따로 한다
|
|
94
|
+
shimReal = real;
|
|
95
|
+
} catch { shimReal = ''; }
|
|
96
|
+
return { dir, real: shimReal, path: p };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* 세션 tmux 에 얹을 태깅 env — `new-session -e KEY=VAL` 인자 조각(선행 공백 포함, 없으면 빈 문자열).
|
|
101
|
+
* PATH 는 Node 에서 완성해 넘긴다(tmux -e 의 값은 리터럴이라 `$PATH` 가 안 풀린다).
|
|
102
|
+
* 원격 머신은 제외한다 — 심 파일은 데몬이 사는 이 기계에만 있다.
|
|
103
|
+
*/
|
|
104
|
+
export function claudeTagEnvArgs(sid: string, local: boolean): string {
|
|
105
|
+
if (!sid || !local || !shimReal) return '';
|
|
106
|
+
// 빈 PATH 를 그대로 이어붙이면 끝에 ':' 가 남아 **현재 폴더**가 PATH 에 들어간다 — 붙이지 않는다.
|
|
107
|
+
const cur = process.env.PATH ?? '';
|
|
108
|
+
const p = cur ? `${config.shimDir}:${cur}` : config.shimDir;
|
|
109
|
+
return ` -e ${shq(`PATH=${p}`)} -e ${shq(`COXPIT_CLAUDE_SID=${sid}`)}`;
|
|
110
|
+
}
|
package/src/cockpit.ts
CHANGED
|
@@ -534,16 +534,50 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
534
534
|
.tsend{font-family:var(--mono);font-size:13px;font-weight:600;color:var(--brand-ink);background:var(--brand);border:none;border-radius:8px;padding:9px 14px;cursor:pointer;flex:0 0 auto}
|
|
535
535
|
.tkey.scroll{color:var(--brand);border-color:rgba(78,201,176,.4)}
|
|
536
536
|
/* 히스토리 오버레이(읽기 전용, 자유 스크롤) — xterm·마우스모드 우회 */
|
|
537
|
-
.
|
|
538
|
-
|
|
539
|
-
.hist-
|
|
540
|
-
|
|
537
|
+
/* v6.4 — 폰에서 "집고 읽는" 뷰어: 헤더 툴바(.hv-btn) · 검색바 · 구조화된 줄(.hln) · 최신 FAB. 색은 전부 기존 토큰. */
|
|
538
|
+
/* 데스크톱은 가운데 팝업. 크기를 인라인이 아니라 여기 둬야 아래 모바일 규칙이 !important 없이 덮는다 */
|
|
539
|
+
.hist-pick{position:relative;width:min(680px,96vw);max-height:88vh}
|
|
540
|
+
.hist-lines,.hist-chat{min-height:0} /* 열 flex 자식 — 이게 없으면 줄이 늘어나 스크롤 대신 시트를 민다 */
|
|
541
|
+
.hist-pick .pick-h{flex-wrap:wrap;gap:8px;padding:10px 12px}
|
|
542
|
+
.hist-pick .pick-h .t{flex:1 1 90px;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
543
|
+
.hist-pick .pick-h .hist-modes{margin-left:0}
|
|
544
|
+
.hv-tools{display:inline-flex;align-items:center;gap:5px;margin-left:auto}
|
|
545
|
+
.hist-pick .pick-h .x{margin-left:2px;padding:2px 6px}
|
|
546
|
+
.hv-btn{background:none;border:1px solid var(--line);border-radius:6px;color:var(--muted);font-size:13px;line-height:1;padding:5px 8px;min-width:30px;cursor:pointer;font-family:var(--mono)}
|
|
547
|
+
.hv-btn:hover{color:var(--ink);border-color:var(--line-hi)}
|
|
548
|
+
.hv-btn.on{color:var(--brand);border-color:var(--brand)}
|
|
549
|
+
.tkey.hv-main{font-weight:600;color:var(--brand);border-color:var(--brand);background:var(--brand-dim);min-width:56px}
|
|
550
|
+
.hist-findbar{display:flex;align-items:center;gap:5px;padding:7px 12px;border-bottom:1px solid var(--line);background:var(--surface2)}
|
|
551
|
+
.hist-findbar input{flex:1;min-width:0;font-family:var(--mono);font-size:16px;color:var(--ink);background:var(--panel);border:1px solid var(--line);border-radius:6px;padding:5px 8px;outline:none}
|
|
552
|
+
.hist-findbar input:focus{border-color:var(--brand)}
|
|
553
|
+
.hist-findbar .fc{font-family:var(--mono);font-size:10.5px;color:var(--faint);min-width:34px;text-align:center}
|
|
554
|
+
.hist-pick mark.hl{background:var(--brand-dim);color:var(--ink);border-radius:2px;outline:1px solid var(--line-hi)}
|
|
555
|
+
.hist-pick mark.hl.cur{background:var(--blocked);color:var(--bg);outline:none}
|
|
556
|
+
.hist-lines{flex:1;overflow:auto;-webkit-overflow-scrolling:touch;overscroll-behavior:contain;padding:6px 0 14px;background:var(--bg)}
|
|
557
|
+
.hln{display:flex;gap:8px;padding:2px 11px;cursor:pointer}
|
|
558
|
+
.hln:active{background:var(--surface)}
|
|
559
|
+
.hln-tx{flex:1;min-width:0;font-family:var(--mono);font-size:12px;line-height:1.55;color:var(--ink);white-space:pre-wrap;word-break:break-all;user-select:text;-webkit-user-select:text;cursor:text}
|
|
560
|
+
.hlines.nowrap .hln-tx{white-space:pre;overflow-x:auto;word-break:normal}
|
|
561
|
+
.hln-cp{flex:0 0 auto;align-self:flex-start;color:var(--faint);background:none;border:0;font-family:var(--mono);font-size:12px;padding:0 4px;cursor:pointer}
|
|
562
|
+
.hln-cp:hover,.hln-cp:active{color:var(--brand)}
|
|
563
|
+
.hln-url{color:var(--running);text-decoration:underline;cursor:pointer}
|
|
564
|
+
.hln-path{color:var(--brand);text-decoration:underline;cursor:pointer}
|
|
565
|
+
.hln-code{position:relative;margin:8px 0;background:var(--panel);border:1px solid var(--line);border-radius:8px;overflow-x:auto;-webkit-overflow-scrolling:touch}
|
|
566
|
+
.hln-code pre{margin:0;padding:26px 11px 10px;font-family:var(--mono);font-size:11.5px;line-height:1.5;color:var(--ink);white-space:pre-wrap;word-break:break-all;user-select:text;-webkit-user-select:text}
|
|
567
|
+
.hist-chat.nowrap .hln-code pre{white-space:pre;word-break:normal}
|
|
568
|
+
.hln-code .cb-cp{position:absolute;top:4px;right:5px;font-family:var(--mono);font-size:10.5px;color:var(--muted);background:var(--surface);border:1px solid var(--line);border-radius:5px;padding:2px 7px;cursor:pointer}
|
|
569
|
+
.hln-code .cb-cp:active{color:var(--brand);border-color:var(--brand)}
|
|
570
|
+
.hist-fab{position:absolute;right:14px;bottom:14px;width:40px;height:40px;border-radius:50%;border:none;background:var(--brand);color:var(--brand-ink);font-family:var(--mono);font-size:17px;font-weight:700;cursor:pointer;box-shadow:0 4px 14px var(--bg)}
|
|
571
|
+
/* 터미널 탭의 정직한 한 줄 — TUI 는 대체 화면이라 스크롤백이 없다는 사실을 화면이 직접 말한다(가장 희미한 톤) */
|
|
572
|
+
.hist-note{flex:0 0 auto;padding:6px 12px;border-bottom:1px solid var(--line);background:var(--panel);color:var(--faint);font-size:11px;line-height:1.5}
|
|
573
|
+
.hist-findbar[hidden],.hist-lines[hidden],.hist-chat[hidden],.hist-fab[hidden],.hist-note[hidden]{display:none} /* hidden 속성 존중 — 클래스의 display 가 UA 를 이기지 않게 */
|
|
541
574
|
.hist-modes{display:inline-flex;gap:2px;margin-left:12px;border:1px solid var(--line);border-radius:7px;padding:2px}
|
|
542
575
|
.hm-tab{font-family:var(--mono);font-size:11px;color:var(--muted);background:none;border:none;border-radius:5px;padding:4px 9px;cursor:pointer}
|
|
543
576
|
.hm-tab.on{background:var(--brand-dim);color:var(--ink)}
|
|
544
577
|
.hist-chat{flex:1;overflow:auto;-webkit-overflow-scrolling:touch;overscroll-behavior:contain;display:flex;flex-direction:column;gap:10px;padding:12px;background:var(--bg)}
|
|
545
578
|
.ch-turn{display:flex} .ch-turn.user{justify-content:flex-end}
|
|
546
|
-
.ch-bubble{max-width:82%;padding:9px 12px;border-radius:12px;font-size:
|
|
579
|
+
.ch-bubble{max-width:82%;min-width:0;padding:9px 12px;border-radius:12px;font-size:13.5px;line-height:1.7;word-break:keep-all;overflow-wrap:anywhere;user-select:text;-webkit-user-select:text}
|
|
580
|
+
.ch-turn.asst .ch-bubble{max-width:100%}
|
|
547
581
|
.ch-turn.user .ch-bubble{background:var(--brand-dim);color:var(--ink);border:1px solid rgba(78,201,176,.25);border-bottom-right-radius:4px}
|
|
548
582
|
.ch-turn.asst .ch-bubble{background:var(--surface);color:var(--ink);border:1px solid var(--line);border-bottom-left-radius:4px}
|
|
549
583
|
.ch-tools{display:flex;flex-wrap:wrap;gap:5px;margin-top:7px}
|
|
@@ -553,7 +587,13 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
553
587
|
body.touch .term-ibar{display:flex}
|
|
554
588
|
body.touch .reqbar{display:none}
|
|
555
589
|
body.touch #splitRow, body.touch #splitCol{display:none}
|
|
590
|
+
/* 뷰어는 폰·터치에서 가운데 팝업이 아니라 화면 전체 — 전체화면 터미널(.modal.term)과 같은 문법.
|
|
591
|
+
헤더 / 검색바 / 줄·대화(flex:1, 여기만 스크롤) / FAB. 노치·홈바는 safe-area 로 비킨다. 아래 @media 에 쌍둥이. */
|
|
592
|
+
body.touch #histModal .hist-pick{position:fixed;inset:0;width:100%;max-width:100%;height:100%;max-height:100%;border:0;border-radius:0;padding:env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left)}
|
|
593
|
+
body.touch #histModal .hist-fab{bottom:calc(14px + env(safe-area-inset-bottom));right:calc(14px + env(safe-area-inset-right))}
|
|
556
594
|
@media (max-width:860px){
|
|
595
|
+
#histModal .hist-pick{position:fixed;inset:0;width:100%;max-width:100%;height:100%;max-height:100%;border:0;border-radius:0;padding:env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left)}
|
|
596
|
+
#histModal .hist-fab{bottom:calc(14px + env(safe-area-inset-bottom));right:calc(14px + env(safe-area-inset-right))}
|
|
557
597
|
/* 모바일 = 아이콘만(텍스트 라벨 숨김) + 폰트 최소화 */
|
|
558
598
|
.b-txt{display:none}
|
|
559
599
|
body{font-size:12px}
|
|
@@ -649,7 +689,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
649
689
|
</div>
|
|
650
690
|
<div class="term-ibar" id="termIbar">
|
|
651
691
|
<div class="tkeys">
|
|
652
|
-
<button type="button" class="tkey scroll" id="histBtn" title="뷰어 — 대화/터미널로 위 내용 보기(읽기 전용)">뷰어</button>
|
|
692
|
+
<button type="button" class="tkey scroll hv-main" id="histBtn" title="뷰어 — 대화/터미널로 위 내용 보기(읽기 전용)">뷰어</button>
|
|
653
693
|
<button type="button" class="tkey scroll" data-k="copymode" title="터미널 안에서 스크롤 — tmux copy-mode 진입(⇞/↑ 로 위로, esc 로 나가기)">⇡ 스크롤</button>
|
|
654
694
|
<button type="button" class="tkey" data-k="esc" title="Esc">esc</button>
|
|
655
695
|
<button type="button" class="tkey" data-k="tab" title="Tab">tab</button>
|
|
@@ -920,16 +960,30 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
920
960
|
</div>
|
|
921
961
|
|
|
922
962
|
<div class="modal" id="histModal">
|
|
923
|
-
<div class="pick
|
|
963
|
+
<div class="pick hist-pick">
|
|
924
964
|
<div class="pick-h"><span class="t"><span id="histTitle">뷰어</span></span>
|
|
925
965
|
<div class="hist-modes">
|
|
926
966
|
<button type="button" class="hm-tab on" id="hmChat" title="Claude Code 대화로 보기">대화</button>
|
|
927
967
|
<button type="button" class="hm-tab" id="hmRaw" title="터미널 스크롤백 원문">터미널</button>
|
|
928
968
|
</div>
|
|
929
|
-
<
|
|
930
|
-
|
|
969
|
+
<div class="hv-tools">
|
|
970
|
+
<button type="button" class="hv-btn on" id="histWrap" title="줄바꿈 켜기/끄기 (끄면 명령어를 한 줄로 옆 스크롤)">↵</button>
|
|
971
|
+
<button type="button" class="hv-btn" id="histFind" title="찾기">⌕</button>
|
|
972
|
+
<button type="button" class="hv-btn" id="histJump" title="맨위/맨아래로">↕</button>
|
|
973
|
+
<button type="button" class="hv-btn" id="histRefresh" title="지금 시점으로 다시 불러오기">↻</button>
|
|
974
|
+
<button class="x" id="histClose" title="닫기">×</button>
|
|
975
|
+
</div></div>
|
|
976
|
+
<div class="hist-findbar" id="histFindBar" hidden>
|
|
977
|
+
<input id="histFindIn" placeholder="뷰어에서 찾기" autocomplete="off" spellcheck="false" />
|
|
978
|
+
<span class="fc" id="histFindN"></span>
|
|
979
|
+
<button type="button" class="hv-btn" id="histFindPrev" title="이전 (⇧⏎)">‹</button>
|
|
980
|
+
<button type="button" class="hv-btn" id="histFindNext" title="다음 (⏎)">›</button>
|
|
981
|
+
<button type="button" class="hv-btn" id="histFindX" title="닫기 (esc)">×</button>
|
|
982
|
+
</div>
|
|
983
|
+
<div class="hist-note" id="histAltNote" hidden>claude 같은 전체화면 앱은 지금 화면만 잡혀요 — 전체 대화는 '대화' 탭</div>
|
|
931
984
|
<div class="hist-chat" id="histChat"></div>
|
|
932
|
-
<
|
|
985
|
+
<div class="hist-lines hlines" id="histLines" hidden></div>
|
|
986
|
+
<button type="button" class="hist-fab" id="histFab" title="맨아래(최신)로" hidden>↓</button>
|
|
933
987
|
</div>
|
|
934
988
|
</div>
|
|
935
989
|
|
|
@@ -982,6 +1036,23 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
982
1036
|
t.classList.add('show'); clearTimeout(toast._h);
|
|
983
1037
|
toast._h=setTimeout(function(){ t.classList.remove('show'); }, act?7000:2600);
|
|
984
1038
|
}
|
|
1039
|
+
// 복사 공용 헬퍼 — 터미널 선택복사와 뷰어(줄·링크·코드블록) 복사가 이 한 벌을 쓴다(v6.4).
|
|
1040
|
+
// clipboard API 우선(HTTPS·PWA), 실패하면 execCommand 폴백(iOS 포함). 항상 토스트로 확인. label 생략 = '복사됨'.
|
|
1041
|
+
function copyFallback(s, label){ var ok=false;
|
|
1042
|
+
try{ var ta=document.createElement('textarea'); ta.value=s; ta.setAttribute('readonly',''); ta.contentEditable='true';
|
|
1043
|
+
ta.style.position='fixed'; ta.style.top='-9999px'; ta.style.opacity='0'; document.body.appendChild(ta);
|
|
1044
|
+
var r=document.createRange(); r.selectNodeContents(ta); var sel=window.getSelection(); sel.removeAllRanges(); sel.addRange(r);
|
|
1045
|
+
try{ ta.setSelectionRange(0, s.length); }catch(e){}
|
|
1046
|
+
ok=document.execCommand('copy'); document.body.removeChild(ta);
|
|
1047
|
+
}catch(e){ ok=false; }
|
|
1048
|
+
toast(ok?((label||'복사됨')+' · '+s.length+'자'):'복사 실패 — 브라우저가 클립보드를 막았어요'); }
|
|
1049
|
+
function copyText(s, label){
|
|
1050
|
+
if(!s) return false;
|
|
1051
|
+
if(navigator.clipboard && navigator.clipboard.writeText){
|
|
1052
|
+
navigator.clipboard.writeText(s).then(function(){ toast((label||'복사됨')+' · '+s.length+'자'); }).catch(function(){ copyFallback(s, label); });
|
|
1053
|
+
} else { copyFallback(s, label); }
|
|
1054
|
+
return true;
|
|
1055
|
+
}
|
|
985
1056
|
// ── 확인 대화상자 (보드의 confirmUI 와 같은 계약: promise 로 true/false) ──
|
|
986
1057
|
// 결정은 여기서, 결과는 토스트에서. 네이티브 confirm 은 쓰지 않는다(DESIGN.md) — 뼈대는
|
|
987
1058
|
// 이미 있는 .pick 시트 그대로라 새 컴포넌트도, 새 색도 없다.
|
|
@@ -1512,6 +1583,10 @@ ${ACTIVITY_JS}
|
|
|
1512
1583
|
// 전경색은 xterm 이 자동으로 끌어올린다. 3 = 안 보이던 것만 구제하고 의도된 어두운 톤은 대체로 보존(2026-09-18).
|
|
1513
1584
|
minimumContrastRatio: 3,
|
|
1514
1585
|
theme: { background:'#0b0d12', foreground:'#dee4ec', cursor:'#4ec9b0', selectionBackground:'rgba(78,201,176,.25)', black:'#1c212c', brightBlack:'#5c6675' },
|
|
1586
|
+
// OSC 8 하이퍼링크(에이전트·gh·npm 이 찍는 것)는 WebLinksAddon 이 아니라 xterm 내장 제공자가 잡는다.
|
|
1587
|
+
// 핸들러를 안 주면 xterm 기본값이 네이티브 confirm 을 띄우고(DESIGN.md 위반) 빈 window.open 으로 열려다
|
|
1588
|
+
// 데스크톱 앱의 setWindowOpenHandler 에 막혀 조용히 아무 일도 안 났다. 애드온과 같은 길로 곧장 연다.
|
|
1589
|
+
linkHandler: { activate: function(ev, uri){ window.open(uri, '_blank', 'noopener,noreferrer'); } },
|
|
1515
1590
|
});
|
|
1516
1591
|
var fit=new window.FitAddon.FitAddon(); term.loadAddon(fit);
|
|
1517
1592
|
try{ term.loadAddon(new window.Unicode11Addon.Unicode11Addon()); term.unicode.activeVersion='11'; }catch(e){}
|
|
@@ -1556,20 +1631,8 @@ ${ACTIVITY_JS}
|
|
|
1556
1631
|
}catch(e){}
|
|
1557
1632
|
// 복사 배선: xterm 은 user-select:none 이라 네이티브 선택이 없다 → term.getSelection() 을 직접 클립보드로.
|
|
1558
1633
|
// ① 드래그 놓으면 자동 복사(select-to-copy) ② Cmd/Ctrl+C 로도 복사(선택 없으면 통과 → SIGINT).
|
|
1559
|
-
//
|
|
1560
|
-
var
|
|
1561
|
-
try{ var ta=document.createElement('textarea'); ta.value=s; ta.setAttribute('readonly',''); ta.contentEditable='true';
|
|
1562
|
-
ta.style.position='fixed'; ta.style.top='-9999px'; ta.style.opacity='0'; document.body.appendChild(ta);
|
|
1563
|
-
var r=document.createRange(); r.selectNodeContents(ta); var sel=window.getSelection(); sel.removeAllRanges(); sel.addRange(r);
|
|
1564
|
-
try{ ta.setSelectionRange(0, s.length); }catch(e){}
|
|
1565
|
-
ok=document.execCommand('copy'); document.body.removeChild(ta);
|
|
1566
|
-
}catch(e){ ok=false; }
|
|
1567
|
-
toast(ok?('복사됨 · '+s.length+'자'):'복사 실패 — 브라우저가 클립보드를 막았어요'); };
|
|
1568
|
-
var copySel=function(){ var s=''; try{ s=term.getSelection(); }catch(e){} if(!s) return false;
|
|
1569
|
-
if(navigator.clipboard && navigator.clipboard.writeText){
|
|
1570
|
-
navigator.clipboard.writeText(s).then(function(){ toast('복사됨 · '+s.length+'자'); }).catch(function(){ copyFallback(s); });
|
|
1571
|
-
} else { copyFallback(s); }
|
|
1572
|
-
return true; };
|
|
1634
|
+
// 복사 자체는 공용 copyText(뷰어와 같은 한 벌).
|
|
1635
|
+
var copySel=function(){ var s=''; try{ s=term.getSelection(); }catch(e){} return copyText(s); };
|
|
1573
1636
|
host.addEventListener('mouseup', function(){ copySel(); });
|
|
1574
1637
|
host.addEventListener('touchend', function(){ copySel(); });
|
|
1575
1638
|
term.attachCustomKeyEventHandler(function(ev){
|
|
@@ -1864,6 +1927,7 @@ ${ACTIVITY_JS}
|
|
|
1864
1927
|
var rid=focusedRunId(); if(rid!=null && tabs[rid] && tabs[rid].term) try{ tabs[rid].term.focus(); }catch(e){}
|
|
1865
1928
|
// 뷰어에서 고른 글을 주입할 때 "어느 터미널로"의 답 — 마지막으로 잡았던 터미널 페인(C3)
|
|
1866
1929
|
if(rid!=null && tabs[rid] && tabs[rid].kind!=='viewer') lastTermRunId=rid;
|
|
1930
|
+
persistOpenTabs(); // 포커스 이동은 render 를 타지 않는다 — 다시 열었을 때 착지할 탭을 여기서 기록
|
|
1867
1931
|
updateControls();
|
|
1868
1932
|
if (typeof reqMode!=='undefined' && reqMode!=='new') setMode(reqMode);
|
|
1869
1933
|
}
|
|
@@ -1907,6 +1971,58 @@ ${ACTIVITY_JS}
|
|
|
1907
1971
|
function persistSession(){
|
|
1908
1972
|
if(!sessionRestoreDone) return; // 첫 복원 전(초기 빈 render)에 저장하면 스냅샷을 덮어써 버린다
|
|
1909
1973
|
try{ localStorage.setItem(SESSION_KEY, JSON.stringify(serializeNode(layout))); }catch(e){}
|
|
1974
|
+
persistOpenTabs();
|
|
1975
|
+
}
|
|
1976
|
+
// ── 열려 있던 탭 전부 기억/복원 (정본 = 데몬 '/api/ui/opentabs') ──
|
|
1977
|
+
// 위 세션 스냅샷은 **페인에 보이던** 탭만 담는다. 폰은 페인이 하나라서, 새로고침하거나 데몬이 재시작하면
|
|
1978
|
+
// 마지막 하나만 남고 나머지 탭이 통째로 사라졌다 — tmux 세션은 멀쩡히 살아 있는데도. 그래서 탭 바를 따로 기억한다.
|
|
1979
|
+
// 기억하는 자리는 **데몬**이다: localStorage 는 캐시 비우기·기기 교체·옛 페이지에 쉽게 날아가고,
|
|
1980
|
+
// 그게 정확히 이 고침이 폰에 안 먹던 이유였다. 브라우저 사본은 데몬이 답을 못 주거나 아직 아무것도
|
|
1981
|
+
// 받아 적지 않았을 때의 폴백으로만 남긴다.
|
|
1982
|
+
// 터미널 탭(run)만 담는다: 뷰어 탭은 경로가 스냅샷 쪽에 이미 있고, 죽은 run 은 복원에서 건너뛴다.
|
|
1983
|
+
var OPENTABS_KEY='coxpit.opentabs', OPENTABS_API='/api/ui/opentabs', MAX_RESTORE_TABS=24;
|
|
1984
|
+
var otTimer=null, otRestoring=false;
|
|
1985
|
+
function persistOpenTabs(){
|
|
1986
|
+
if(!sessionRestoreDone || otRestoring) return; // 복원 중의 중간 상태를 정본에 덮어쓰지 않는다
|
|
1987
|
+
var ids=tabOrder.filter(function(id){ return !isViewer(id); }).slice(-MAX_RESTORE_TABS);
|
|
1988
|
+
var act=focusedRunId();
|
|
1989
|
+
var st={ids:ids, active:(act!=null && !isViewer(act))?act:null};
|
|
1990
|
+
try{ localStorage.setItem(OPENTABS_KEY, JSON.stringify(st)); }catch(e){} // 오프라인 폴백 사본
|
|
1991
|
+
// 포커스 이동은 연달아 일어난다 — 잠깐 모았다가 마지막 한 번만 데몬에 쓴다.
|
|
1992
|
+
if(otTimer) clearTimeout(otTimer);
|
|
1993
|
+
otTimer=setTimeout(function(){
|
|
1994
|
+
otTimer=null;
|
|
1995
|
+
try{ fetch(OPENTABS_API,{method:'PUT',headers:{'content-type':'application/json'},body:JSON.stringify(st)}).catch(function(){}); }catch(e){}
|
|
1996
|
+
}, 400);
|
|
1997
|
+
}
|
|
1998
|
+
async function restoreOpenTabs(){
|
|
1999
|
+
if(otRestoring) return; // 다시 불러도 안전 — 진행 중인 복원과 겹치지 않는다
|
|
2000
|
+
otRestoring=true;
|
|
2001
|
+
try{
|
|
2002
|
+
var st=null;
|
|
2003
|
+
try{ var res=await fetch(OPENTABS_API,{headers:{'accept':'application/json'}}); if(res.ok) st=await res.json(); }catch(e){ st=null; }
|
|
2004
|
+
// 브라우저 사본은 폴백일 뿐이다 — 데몬이 답을 못 줬거나(구버전·오프라인), 아직 한 번도 받아 적은 적이
|
|
2005
|
+
// 없을 때만(stored:false, 1회 이관) 본다. 데몬이 "빈 집합"이라 답했으면 그건 owner 가 닫은 것이다.
|
|
2006
|
+
if(!st || !st.ids || st.stored===false){
|
|
2007
|
+
var raw; try{ raw=localStorage.getItem(OPENTABS_KEY); }catch(e){ raw=null; }
|
|
2008
|
+
if(raw){ try{ var ls=JSON.parse(raw); if(ls && ls.ids && ls.ids.length) st=ls; }catch(e){} }
|
|
2009
|
+
}
|
|
2010
|
+
if(!st || !Array.isArray(st.ids) || !st.ids.length) return; // 기억해둔 게 없으면 위의 단일 세션 복원이 그대로 답이다
|
|
2011
|
+
var added=0, first=null;
|
|
2012
|
+
st.ids.slice(-MAX_RESTORE_TABS).forEach(function(id){
|
|
2013
|
+
var rid=+id;
|
|
2014
|
+
if(!rid || tabs[rid] || !runById[rid]) return; // 이미 열렸거나 플릿에서 사라진 run 은 건너뛴다(지어내지 않는다)
|
|
2015
|
+
ensureTab(rid); added++; if(first==null) first=rid;
|
|
2016
|
+
});
|
|
2017
|
+
var act=(st.active!=null && !isViewer(st.active)) ? +st.active : null;
|
|
2018
|
+
if(!act || !tabs[act]) act=first; // 마지막으로 보던 run 이 사라졌으면 되살린 첫 탭에 착지한다
|
|
2019
|
+
// 페인이 비었거나 하나(폰)일 때만 집어넣는다 — 데스크톱의 분할 배치는 위 스냅샷이 이미 복원했다.
|
|
2020
|
+
if(act!=null && !leafOfTab(act) && (focusedRunId()==null || countLeaves()===1)) openTab(act);
|
|
2021
|
+
else if(added){ render(); renderTree(); } // 탭 바에는 되살린 탭이 전부 보여야 한다
|
|
2022
|
+
} finally {
|
|
2023
|
+
otRestoring=false;
|
|
2024
|
+
persistOpenTabs(); // 복원 결과(= 지금 열린 탭)를 정본으로 굳힌다
|
|
2025
|
+
}
|
|
1910
2026
|
}
|
|
1911
2027
|
function restoreSession(){
|
|
1912
2028
|
if(sessionRestoreDone) return; // 1회만(이후 hydrate 는 통과)
|
|
@@ -1921,6 +2037,7 @@ ${ACTIVITY_JS}
|
|
|
1921
2037
|
if(!tabOrder.length){ layout={leaf:true,id:'L0',tab:null}; focusLeaf='L0'; } // 되살릴 게 없으면 깔끔한 빈 상태
|
|
1922
2038
|
render(); renderTree();
|
|
1923
2039
|
}catch(e){} }
|
|
2040
|
+
restoreOpenTabs(); // 스냅샷이 보여준 한 탭 말고, **열려 있던 탭 전부**를 탭 바에 되살린다
|
|
1924
2041
|
}
|
|
1925
2042
|
persistSession(); // 복원할 게 없거나 이미 탭이 있어도, 지금부터 현재 상태를 마지막-세션으로 기록한다
|
|
1926
2043
|
}
|
|
@@ -2306,41 +2423,207 @@ ${ACTIVITY_JS}
|
|
|
2306
2423
|
}); });
|
|
2307
2424
|
|
|
2308
2425
|
// ── 뷰어 — 위 내용을 (대화) Claude Code 로그 / (터미널) 스크롤백 으로 읽기(읽기 전용) ──
|
|
2309
|
-
|
|
2426
|
+
// v6.4: 원문을 <pre> 한 덩어리로 붓지 않는다. 줄마다 DOM(.hln)이라 줄바꿈된 링크·경로를 한 번에 집고,
|
|
2427
|
+
// 줄을 통째로 복사하고, 찾고, 맨아래로 뛴다. xterm 은 입력용 그대로 두고 "읽기·집기"만 여기서 한다.
|
|
2428
|
+
var histMode='chat', histRid=null, histRaw=[], histCode=[];
|
|
2429
|
+
var histWrapOn = (function(){ try{ return localStorage.getItem('coxpit.histwrap')!=='0'; }catch(e){ return true; } })();
|
|
2430
|
+
function histSurface(){ return histMode==='chat' ? $('histChat') : $('histLines'); }
|
|
2310
2431
|
function openHistory(){ var rid=focusedRunId(); if(rid==null||isViewer(rid)){ toast('포커스한 세션이 없습니다'); return; } $('histModal').classList.add('on'); loadHist(); }
|
|
2311
|
-
function closeHistory(){ $('histModal').classList.remove('on'); }
|
|
2432
|
+
function closeHistory(){ closeHistFind(); $('histModal').classList.remove('on'); }
|
|
2312
2433
|
function setHistMode(m){
|
|
2313
2434
|
histMode=m; $('hmChat').classList.toggle('on',m==='chat'); $('hmRaw').classList.toggle('on',m==='raw');
|
|
2314
|
-
$('histChat').
|
|
2435
|
+
$('histChat').hidden = m!=='chat'; $('histLines').hidden = m!=='raw';
|
|
2436
|
+
// 터미널 탭에선 대체 화면 한계를 화면이 직접 말한다 — 빈 캡처를 보고 "고장났나" 하지 않도록(정직이 먼저).
|
|
2437
|
+
$('histAltNote').hidden = m!=='raw';
|
|
2315
2438
|
loadHist();
|
|
2316
2439
|
}
|
|
2440
|
+
// 줄 하나 → 링크·경로를 감싼 HTML. 원문을 먼저 토막내고 토막마다 esc 한다(esc 한 문자열에 정규식을 돌리면 & 가 URL 에 섞인다).
|
|
2441
|
+
// 경로 규칙은 터미널 링크파이어와 같다 — 확장자 있는 토큰 · 앞 글자가 URL 조각/토큰 중간이면 제외 · 알려진 확장자거나 경로형.
|
|
2442
|
+
// 한 정규식의 두 갈래라 같은 자리에서 URL 이 먼저 잡힌다(URL 우선).
|
|
2443
|
+
var HLN_RE=/(https?:\\/\\/[^\\s<>"'\\x60]+)|((?:~\\/|\\.{0,2}\\/)?[\\w.\\-\\/]*\\.[A-Za-z0-9]{1,8}(?::\\d+(?::\\d+)?)?)/g;
|
|
2444
|
+
function linkify(txt){
|
|
2445
|
+
var s=String(txt==null?'':txt), out='', last=0, m;
|
|
2446
|
+
HLN_RE.lastIndex=0;
|
|
2447
|
+
while((m=HLN_RE.exec(s))){
|
|
2448
|
+
var raw=m[0], at=m.index;
|
|
2449
|
+
if(m[1]){
|
|
2450
|
+
var url=raw.replace(/[.,;:!?)\\]}'"]+$/,''); // 문장 끝 구두점은 링크가 아니다
|
|
2451
|
+
// 링크처럼 보이면 링크처럼 열려야 한다 — href 있는 진짜 <a>. 길게 누르기·새 탭으로 열기·⌘클릭이 그제서야 산다.
|
|
2452
|
+
// 데스크톱 앱은 setWindowOpenHandler 가 target=_blank 를 시스템 브라우저로 넘긴다. data-url 은 복사 경로용으로 남긴다.
|
|
2453
|
+
out+=esc(s.slice(last,at))+'<a class="hln-url" href="'+esc(url)+'" target="_blank" rel="noopener noreferrer" data-url="'+esc(url)+'">'+esc(url)+'</a>';
|
|
2454
|
+
last=at+url.length; HLN_RE.lastIndex=last; continue;
|
|
2455
|
+
}
|
|
2456
|
+
if(raw.indexOf('://')>=0 || raw.slice(0,2)==='//') continue;
|
|
2457
|
+
var before = at>0 ? s.charAt(at-1) : ' ';
|
|
2458
|
+
if(before===':' || before==='/' || /[A-Za-z0-9]/.test(before)) continue;
|
|
2459
|
+
var pathPart=raw.replace(/:\\d+(?::\\d+)?$/,'');
|
|
2460
|
+
var ext=(pathPart.split('.').pop()||'').toLowerCase();
|
|
2461
|
+
if(!(VIEW_EXT[ext] || pathPart.indexOf('/')>=0)) continue;
|
|
2462
|
+
out+=esc(s.slice(last,at))+'<span class="hln-path" data-path="'+esc(raw)+'">'+esc(raw)+'</span>';
|
|
2463
|
+
last=at+raw.length;
|
|
2464
|
+
}
|
|
2465
|
+
return out+esc(s.slice(last));
|
|
2466
|
+
}
|
|
2467
|
+
// 터미널 탭 — 스크롤백을 논리적 줄 단위 DOM 으로. histRaw[i] = 그 줄의 원문(복사는 linkify 전 텍스트).
|
|
2468
|
+
function renderHistLines(text){
|
|
2469
|
+
var box=$('histLines');
|
|
2470
|
+
var lines=String(text||'').split(/\\r?\\n/);
|
|
2471
|
+
while(lines.length && !lines[lines.length-1].trim()) lines.pop(); // tmux 캡처 꼬리의 빈 줄
|
|
2472
|
+
histRaw=lines;
|
|
2473
|
+
if(!lines.length){ box.innerHTML='<div class="hist-empty">(스크롤백 없음)</div>'; return; }
|
|
2474
|
+
var h=new Array(lines.length);
|
|
2475
|
+
for(var i=0;i<lines.length;i++){
|
|
2476
|
+
h[i]='<div class="hln" data-i="'+i+'"><span class="hln-tx">'+(linkify(lines[i])||' ')+'</span>'
|
|
2477
|
+
+'<button type="button" class="hln-cp" title="이 줄 복사">⧉</button></div>';
|
|
2478
|
+
}
|
|
2479
|
+
box.innerHTML=h.join('');
|
|
2480
|
+
}
|
|
2481
|
+
// 대화 탭 — 코드펜스는 모노 블록(가로 스크롤 + 복사), 나머지 산문은 링크화. 펜스가 안 닫혔으면(작성 중) 끝까지 코드로.
|
|
2482
|
+
var FENCE='\\x60\\x60\\x60';
|
|
2483
|
+
function renderProse(s){ return linkify(s).replace(/\\n/g,'<br>'); }
|
|
2317
2484
|
function renderTurn(t){
|
|
2318
|
-
var
|
|
2485
|
+
var s=String(t.text||''), body='', i=0;
|
|
2486
|
+
for(;;){
|
|
2487
|
+
var a=s.indexOf(FENCE,i); if(a<0) break;
|
|
2488
|
+
var nl=s.indexOf('\\n',a+3); if(nl<0) break;
|
|
2489
|
+
var b=s.indexOf(FENCE,nl+1), end=(b<0?s.length:b);
|
|
2490
|
+
body+=renderProse(s.slice(i,a).replace(/\\n$/,''));
|
|
2491
|
+
var code=s.slice(nl+1,end).replace(/\\n$/,'');
|
|
2492
|
+
var ci=histCode.push(code)-1;
|
|
2493
|
+
body+='<div class="hln-code"><button type="button" class="cb-cp" data-ci="'+ci+'" title="코드 복사">⧉ 복사</button><pre>'+esc(code)+'</pre></div>';
|
|
2494
|
+
if(b<0){ i=s.length; break; }
|
|
2495
|
+
i=b+3; if(s.charAt(i)==='\\n') i++;
|
|
2496
|
+
}
|
|
2497
|
+
body+=renderProse(s.slice(i));
|
|
2319
2498
|
var tools = (t.tools && t.tools.length) ? '<div class="ch-tools">'+t.tools.map(function(x){return '<span class="ch-tool">'+esc(x)+'</span>';}).join('')+'</div>' : '';
|
|
2320
2499
|
return '<div class="ch-turn '+(t.role==='user'?'user':'asst')+'"><div class="ch-bubble">'+body+tools+'</div></div>';
|
|
2321
2500
|
}
|
|
2322
2501
|
async function loadHist(){
|
|
2323
|
-
var rid=focusedRunId(); if(rid==null) return;
|
|
2502
|
+
var rid=focusedRunId(); if(rid==null) return; histRid=rid;
|
|
2324
2503
|
var t=tabs[rid]; $('histTitle').textContent=(t?t.name:('r'+rid))+' · 뷰어';
|
|
2504
|
+
clearHistMarks();
|
|
2325
2505
|
if (histMode==='chat'){
|
|
2326
2506
|
var c=$('histChat'); c.innerHTML='<div class="hist-empty">불러오는 중…</div>';
|
|
2327
2507
|
try{
|
|
2328
2508
|
var d=await (await fetch('/api/runs/'+rid+'/chat')).json();
|
|
2329
|
-
|
|
2509
|
+
histCode=[];
|
|
2510
|
+
if (d.turns && d.turns.length){ c.innerHTML = d.turns.map(renderTurn).join(''); }
|
|
2330
2511
|
else { c.innerHTML='<div class="hist-empty">이 폴더에 Claude Code 대화 로그가 없습니다.<br>터미널 탭으로 원문을 보세요.</div>'; }
|
|
2331
2512
|
}catch(e){ c.innerHTML='<div class="hist-empty">불러오기 실패</div>'; }
|
|
2332
2513
|
} else {
|
|
2333
|
-
var b=$('
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
2514
|
+
var b=$('histLines'); histRaw=[]; b.innerHTML='<div class="hist-empty">불러오는 중…</div>';
|
|
2515
|
+
// 히스토리 꼬리 2만 줄 — 서버는 전체를 뜨고 여기서 폰 DOM 이 버틸 만큼만 받는다(tmux 한도는 10만 줄)
|
|
2516
|
+
try{ var d2=await (await fetch('/api/runs/'+rid+'/scrollback?lines=20000')).json();
|
|
2517
|
+
if (d2 && d2.ok===false) b.innerHTML='<div class="hist-empty">스크롤백을 읽지 못했습니다</div>';
|
|
2518
|
+
else renderHistLines((d2 && d2.text) || '');
|
|
2519
|
+
}catch(e){ b.innerHTML='<div class="hist-empty">불러오기 실패</div>'; }
|
|
2337
2520
|
}
|
|
2338
|
-
|
|
2521
|
+
histToBottom();
|
|
2522
|
+
if (!$('histFindBar').hidden) runHistFind();
|
|
2523
|
+
}
|
|
2524
|
+
// 탭 vs 길게 누르기 — touchstart 좌표·시각 → touchend 가 250ms·10px 이내면 "탭". 길게 누르기·드래그는 그대로 통과(네이티브 선택).
|
|
2525
|
+
// 마우스는 click 그대로. 선택이 있으면 줄 복사보다 선택을 존중한다(⧉·코드 복사 버튼은 예외 — 명시적인 누름).
|
|
2526
|
+
var histTouch=null, histTapOK=true;
|
|
2527
|
+
function histTouchStart(e){ var p=e.touches&&e.touches[0]; histTouch=p?{x:p.clientX,y:p.clientY,t:Date.now()}:null; histTapOK=true; }
|
|
2528
|
+
function histTouchEnd(e){ var p=e.changedTouches&&e.changedTouches[0];
|
|
2529
|
+
histTapOK = !!(histTouch && p && (Date.now()-histTouch.t)<250 && Math.abs(p.clientX-histTouch.x)<10 && Math.abs(p.clientY-histTouch.y)<10);
|
|
2530
|
+
histTouch=null; }
|
|
2531
|
+
function histHasSel(){ try{ var sel=window.getSelection(); return !!(sel && !sel.isCollapsed && String(sel).length); }catch(e){ return false; } }
|
|
2532
|
+
function histClick(e){
|
|
2533
|
+
if(!histTapOK){ histTapOK=true; return; } // 롱프레스·드래그 뒤 따라오는 click 은 무시
|
|
2534
|
+
var cb=e.target.closest('.cb-cp'); if(cb){ copyText(histCode[+cb.getAttribute('data-ci')]||'', '복사됨'); return; }
|
|
2535
|
+
// URL 은 이제 진짜 링크다 — 평범한 탭은 브라우저가 열게 둔다(기본 동작을 막지 않는다).
|
|
2536
|
+
// 복사는 줄의 ⧉ 버튼·줄 탭이 이미 맡고 있고 그 원문에 URL 글자가 들어 있다. 선택 중일 때만 선택을 존중해 막는다.
|
|
2537
|
+
var u=e.target.closest('.hln-url'); if(u){ if(histHasSel()) e.preventDefault(); return; }
|
|
2538
|
+
var cp=e.target.closest('.hln-cp');
|
|
2539
|
+
if(!cp && histHasSel()) return;
|
|
2540
|
+
var p=e.target.closest('.hln-path');
|
|
2541
|
+
if(p){ if(histRid!=null){ var path=p.getAttribute('data-path')||''; closeHistory(); openPathFromTerm(histRid, path); } return; }
|
|
2542
|
+
var ln=e.target.closest('.hln'); if(ln) copyText(histRaw[+ln.getAttribute('data-i')]||'', '줄 복사됨');
|
|
2543
|
+
}
|
|
2544
|
+
// 찾기(⌕) — 가져온 텍스트 위에서만(순수 클라). 버튼 글자(⧉ 등)는 매치에서 뺀다.
|
|
2545
|
+
var histHits=[], histHitI=-1, histFindT=0;
|
|
2546
|
+
function clearHistMarks(){
|
|
2547
|
+
var ms=$('histModal').querySelectorAll('mark.hl'), pars=[];
|
|
2548
|
+
for(var i=0;i<ms.length;i++){ var mk=ms[i], par=mk.parentNode; if(!par) continue; par.replaceChild(document.createTextNode(mk.textContent||''), mk); if(pars.indexOf(par)<0) pars.push(par); }
|
|
2549
|
+
pars.forEach(function(p){ try{ p.normalize(); }catch(e){} });
|
|
2550
|
+
histHits=[]; histHitI=-1;
|
|
2551
|
+
}
|
|
2552
|
+
function runHistFind(){
|
|
2553
|
+
clearHistMarks();
|
|
2554
|
+
var q=($('histFindIn').value||'').toLowerCase();
|
|
2555
|
+
if(q){
|
|
2556
|
+
var w=document.createTreeWalker(histSurface(), NodeFilter.SHOW_TEXT, { acceptNode:function(n){ var pe=n.parentElement; return (pe && pe.closest('button')) ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT; } });
|
|
2557
|
+
var nodes=[], n; while((n=w.nextNode())) nodes.push(n);
|
|
2558
|
+
nodes.forEach(function(tn){
|
|
2559
|
+
var s=tn.nodeValue||'', low=s.toLowerCase(), at=low.indexOf(q); if(at<0) return;
|
|
2560
|
+
var frag=document.createDocumentFragment(), last=0;
|
|
2561
|
+
while(at>=0){
|
|
2562
|
+
if(at>last) frag.appendChild(document.createTextNode(s.slice(last,at)));
|
|
2563
|
+
var mk=document.createElement('mark'); mk.className='hl'; mk.textContent=s.slice(at,at+q.length); frag.appendChild(mk); histHits.push(mk);
|
|
2564
|
+
last=at+q.length; at=low.indexOf(q,last);
|
|
2565
|
+
}
|
|
2566
|
+
if(last<s.length) frag.appendChild(document.createTextNode(s.slice(last)));
|
|
2567
|
+
tn.parentNode.replaceChild(frag, tn);
|
|
2568
|
+
});
|
|
2569
|
+
}
|
|
2570
|
+
histHitI = histHits.length ? histHits.length-1 : -1; // 최신(맨아래)에서 시작 — 위로 거슬러 읽는 게 보통이다(‹ = 이전)
|
|
2571
|
+
paintHistHit();
|
|
2572
|
+
}
|
|
2573
|
+
function stepHistFind(dir){
|
|
2574
|
+
if(!histHits.length){ runHistFind(); return; }
|
|
2575
|
+
histHitI=(histHitI+dir+histHits.length)%histHits.length; paintHistHit();
|
|
2576
|
+
}
|
|
2577
|
+
function paintHistHit(){
|
|
2578
|
+
var prev=$('histModal').querySelector('mark.hl.cur'); if(prev) prev.classList.remove('cur');
|
|
2579
|
+
var cur=histHits[histHitI]; if(cur){ cur.classList.add('cur'); try{ cur.scrollIntoView({block:'center',inline:'nearest'}); }catch(e){} }
|
|
2580
|
+
$('histFindN').textContent = histHits.length ? ((histHitI+1)+'/'+histHits.length) : ($('histFindIn').value ? '0' : '');
|
|
2581
|
+
updateHistFab();
|
|
2582
|
+
}
|
|
2583
|
+
function openHistFind(){ $('histFindBar').hidden=false; $('histFind').classList.add('on'); var i=$('histFindIn'); i.focus(); i.select(); if(i.value) runHistFind(); }
|
|
2584
|
+
function closeHistFind(){ $('histFindBar').hidden=true; $('histFind').classList.remove('on'); clearHistMarks(); $('histFindN').textContent=''; }
|
|
2585
|
+
// 이동(↕) + 최신 FAB(↓) — 맨아래에서 40px 넘게 올라가면 FAB 가 뜬다.
|
|
2586
|
+
function histNearBottom(el){ return (el.scrollHeight - el.scrollTop - el.clientHeight) < 40; }
|
|
2587
|
+
function updateHistFab(){ var el=histSurface(); $('histFab').hidden = !el || histNearBottom(el); }
|
|
2588
|
+
function histToBottom(){ var el=histSurface(); el.scrollTop=el.scrollHeight; updateHistFab(); }
|
|
2589
|
+
function histJump(){ var el=histSurface(); if(histNearBottom(el)) el.scrollTop=0; else el.scrollTop=el.scrollHeight; updateHistFab(); }
|
|
2590
|
+
// 줄바꿈(↵) — 기본 켬(읽기). 끄면 터미널 줄·대화 코드블록이 한 줄로 옆 스크롤(산문은 항상 줄바꿈).
|
|
2591
|
+
function applyHistWrap(){
|
|
2592
|
+
$('histLines').classList.toggle('nowrap',!histWrapOn); $('histChat').classList.toggle('nowrap',!histWrapOn);
|
|
2593
|
+
$('histWrap').classList.toggle('on',histWrapOn);
|
|
2594
|
+
}
|
|
2595
|
+
applyHistWrap();
|
|
2596
|
+
['histChat','histLines'].forEach(function(id){ var el=$(id);
|
|
2597
|
+
el.addEventListener('touchstart', histTouchStart, {passive:true});
|
|
2598
|
+
el.addEventListener('touchend', histTouchEnd, {passive:true});
|
|
2599
|
+
el.addEventListener('click', histClick);
|
|
2600
|
+
el.addEventListener('scroll', updateHistFab, {passive:true});
|
|
2601
|
+
});
|
|
2339
2602
|
$('histBtn').addEventListener('click', openHistory);
|
|
2340
2603
|
$('histRefresh').addEventListener('click', loadHist);
|
|
2341
2604
|
$('histClose').addEventListener('click', closeHistory);
|
|
2342
2605
|
$('hmChat').addEventListener('click', function(){ setHistMode('chat'); });
|
|
2343
2606
|
$('hmRaw').addEventListener('click', function(){ setHistMode('raw'); });
|
|
2607
|
+
$('histWrap').addEventListener('click', function(){
|
|
2608
|
+
histWrapOn=!histWrapOn; try{ localStorage.setItem('coxpit.histwrap', histWrapOn?'1':'0'); }catch(e){}
|
|
2609
|
+
applyHistWrap(); toast(histWrapOn?'줄바꿈 켬':'줄바꿈 끔 · 옆으로 스크롤');
|
|
2610
|
+
});
|
|
2611
|
+
$('histFind').addEventListener('click', function(){ if($('histFindBar').hidden) openHistFind(); else closeHistFind(); });
|
|
2612
|
+
$('histJump').addEventListener('click', histJump);
|
|
2613
|
+
$('histFab').addEventListener('click', histToBottom);
|
|
2614
|
+
$('histFindIn').addEventListener('input', function(){ clearTimeout(histFindT); histFindT=setTimeout(runHistFind, 150); });
|
|
2615
|
+
$('histFindIn').addEventListener('keydown', function(e){
|
|
2616
|
+
if(e.key==='Escape') return; // 아래 문서 핸들러가 닫는다
|
|
2617
|
+
e.stopPropagation();
|
|
2618
|
+
if(e.key==='Enter'){ e.preventDefault(); clearTimeout(histFindT); if(!histHits.length) runHistFind(); else stepHistFind(e.shiftKey?-1:1); }
|
|
2619
|
+
});
|
|
2620
|
+
$('histFindPrev').addEventListener('click', function(){ stepHistFind(-1); });
|
|
2621
|
+
$('histFindNext').addEventListener('click', function(){ stepHistFind(1); });
|
|
2622
|
+
$('histFindX').addEventListener('click', closeHistFind);
|
|
2623
|
+
document.addEventListener('keydown', function(e){
|
|
2624
|
+
if(e.key!=='Escape' || !$('histModal').classList.contains('on')) return;
|
|
2625
|
+
e.preventDefault(); if(!$('histFindBar').hidden) closeHistFind(); else closeHistory();
|
|
2626
|
+
});
|
|
2344
2627
|
$('histModal').addEventListener('click', function(e){ if(e.target===this) closeHistory(); });
|
|
2345
2628
|
// 터치 기기(아이패드 포함) 판별 → body.touch (화면폭 무관하게 입력바 노출)
|
|
2346
2629
|
if (window.matchMedia('(pointer:coarse)').matches || (navigator.maxTouchPoints||0) > 0) document.body.classList.add('touch');
|
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/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
|
+
}
|