coxpit 6.3.0 → 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 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.0",
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
@@ -0,0 +1,29 @@
1
+ // "지금 뭐 하고 있나" — 코크핏과 HUD 가 **같은 한 벌**을 쓴다(사본 금지, humanize.ts 전례).
2
+ // 코크핏 활동 페인(v5.28 E)의 Now 줄과 HUD 상세(v5.28 K5)가 같은 규칙으로 같은 말을 해야 하므로,
3
+ // 클라이언트 JS 를 여기 한 곳에 두고 두 페이지의 <script> 에 그대로 끼워 넣는다.
4
+ // 전제: 끼워 넣는 쪽이 runById(맵)를 들고 있다 — 이 함수는 그것만 읽는다.
5
+ // 문자열 안의 이스케이프는 **클라이언트 기준**이다: \\n 은 클라이언트의 \n 이 된다.
6
+ export const ACTIVITY_JS = `/* 라이브 상태 — run 의 최신 이벤트에서 "지금 뭐 하는지"(도구명/사고)를 뽑는다. 실행 중일 때만. */
7
+ function latestActivity(runId){
8
+ var r=runById[runId]; if(!r) return '';
9
+ if(r.status!=='running' && r.status!=='pending') return '';
10
+ var evs=r.events||[];
11
+ for(var i=evs.length-1;i>=0;i--){
12
+ var e=evs[i], k=e.kind;
13
+ if(k==='steer') return 'steer'; if(k==='ask') return 'asking';
14
+ if(k!=='assistant') continue;
15
+ try{ var o=JSON.parse(e.payload);
16
+ var c=o&&o.message&&o.message.content;
17
+ if(c&&c.length){ for(var j=c.length-1;j>=0;j--){ if(c[j].type==='tool_use') return c[j].name||'tool'; if(c[j].type==='text'&&(c[j].text||'').trim()) return 'thinking'; } }
18
+ else if(o&&o.text) return 'thinking';
19
+ }catch(_){}
20
+ }
21
+ return r.status==='pending' ? 'starting' : 'working';
22
+ }
23
+ /* "지금" 한 줄 — 파싱된 스트림에서 온 것만. 아직 아무 이벤트도 없으면 지어내지 않고 starting… 이라 말한다. */
24
+ function actNowText(runId){
25
+ var r=runById[runId]; if(!r) return '';
26
+ var live=(r.status==='running'||r.status==='pending'||r.status==='preparing');
27
+ if(!(r.events||[]).length) return live ? 'starting…' : String(r.status||'');
28
+ return latestActivity(runId) || String(r.status||'');
29
+ }`;
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
- await reply.type('text/html').code(200).send(loginPageHTML(m.mode === 'setup'));
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
+ }