coxpit 5.3.1 โ†’ 5.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  <p align="center"><img src="docs/brand/og-lockup.png" alt="coxpit โ€” the cockpit for your agent fleet" width="640"></p>
2
2
 
3
+ > ๐Ÿ““ **Build log** ยท how this was built, on the [AIP Lab blog](https://aiplab.kr/blog/coxpit-fleet.html).
4
+
3
5
  # Coxpit
4
6
 
5
7
  **Own your agent fleet. Run parallel AI coding agents across your own machines โ€” steer them from any browser.**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coxpit",
3
- "version": "5.3.1",
3
+ "version": "5.4.0",
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": "MIT",
package/src/board.ts CHANGED
@@ -57,6 +57,9 @@ export const BOARD_HTML = /* html */ `<!doctype html>
57
57
  .daemon-badge{font-family:var(--mono);font-size:10.5px;color:var(--faint);padding:3px 9px;
58
58
  border:1px solid var(--line);border-radius:999px;background:var(--surface);cursor:default}
59
59
  .daemon-badge b{color:var(--muted);font-weight:500}
60
+ .cockpit-link{text-decoration:none;color:var(--muted);font-family:var(--mono);font-size:11.5px;
61
+ border:1px solid var(--line);border-radius:7px;padding:5px 10px;display:inline-flex;align-items:center;gap:4px}
62
+ .cockpit-link:hover{color:var(--brand);border-color:var(--line-hi)}
60
63
  .ws{display:flex;align-items:center;gap:7px;font-family:var(--mono);font-size:11px;color:var(--muted);
61
64
  padding:4px 10px;border:1px solid var(--line);border-radius:999px;background:var(--surface)}
62
65
  .dot{width:7px;height:7px;border-radius:50%;background:var(--s-failed);transition:background .3s}
@@ -842,7 +845,7 @@ export const BOARD_HTML = /* html */ `<!doctype html>
842
845
  .fab{display:inline-flex}
843
846
  #newBtn,.newnote{display:none}
844
847
  header{padding:0 12px;gap:9px}
845
- .brand .sub,.daemon-badge,.machines{display:none}
848
+ .brand .sub,.daemon-badge,.machines,.cockpit-link{display:none}
846
849
  main{padding:14px}
847
850
  .grid{grid-template-columns:1fr}
848
851
  /* ๋ชจ๋‹ฌ = ํ’€๋ธ”๋ฆฌ๋“œ */
@@ -879,6 +882,7 @@ ${ICON_SPRITE}
879
882
  <div class="ws"><span class="dot" id="wsdot"></span><span id="wstext">connecting</span></div>
880
883
  <button class="btn-ghost sm" id="bell" title="notify when a run settles"><svg class="ic"><use href="#i-bell-off"/></svg></button>
881
884
  <button class="btn-ghost sm" id="remoteBtn" title="reach this daemon from elsewhere (Tailscale ยท recipes)"><svg class="ic"><use href="#i-external-link"/></svg></button>
885
+ <a class="cockpit-link" href="/cockpit" title="ํ„ฐ๋ฏธ๋„ ์šฐ์„  ์…ธ (preview)">โ—จ Cockpit</a>
882
886
  <div class="machines" id="machines"></div>
883
887
  </header>
884
888
  <div class="scrim" id="scrim"></div>
@@ -1801,6 +1805,10 @@ async function renderSettings(){
1801
1805
  + '<label class="set-row"><span class="set-lbl">Public URL '+lockNote(L.publicUrl,'COXPIT_PUBLIC_URL')+'</span>'
1802
1806
  + '<input id="setPublic" value="'+escA(ef.publicUrl||'')+'" placeholder="https://โ€ฆ (deep-link base in notifications)"'+dis(L.publicUrl)+'></label>'
1803
1807
  + '</div>'
1808
+ + '<div class="set-sec"><div class="set-h">Remote access</div>'
1809
+ + '<div class="set-state">Reach this daemon from your other devices โ€” coxpit detects your Tailscale and drives it, or hands a copy-paste recipe. It never hosts a relay.</div>'
1810
+ + '<div id="rmtSettings" class="rmt">checkingโ€ฆ</div>'
1811
+ + '</div>'
1804
1812
  + '<div class="set-sec"><div class="set-h">About</div>'
1805
1813
  + '<div class="set-kv"><span>version</span><b>'+esc(s.version)+'</b></div>'
1806
1814
  + '<div class="set-kv"><span>data folder</span><b>'+esc(s.dataDir)+'</b></div>'
@@ -1808,6 +1816,7 @@ async function renderSettings(){
1808
1816
  + '<div class="set-save"><button type="button" class="btn" id="setSave">Save settings</button>'
1809
1817
  + '<span class="set-saved" id="setSaved"></span></div>';
1810
1818
  wireSettings();
1819
+ loadRemote(); // Remote access ์„น์…˜(#rmtSettings) ์ฑ„์šฐ๊ธฐ + ํ† ๊ธ€ ๋ฐฐ์„ 
1811
1820
  }
1812
1821
  function wireSettings(){
1813
1822
  const save = $('setSave'); if (!save) return;
@@ -1989,10 +1998,9 @@ function paintOnboarding(goalsOnly){
1989
1998
  + '<li><b>Write a task</b> โ€” title + a prompt that names the target files</li>'
1990
1999
  + '<li><b>Run fleet</b> โ€” try <b>Dry run</b> first (free rehearsal), then <b>Real agent</b></li>'
1991
2000
  + '</ol></div>'
1992
- + '<div class="setup-sec"><p class="setup-label">Remote access</p>'
1993
- + '<div id="rmtOnboard" class="rmt">checking Tailscaleโ€ฆ</div></div>'
1994
2001
  + '</div>';
1995
- loadRemote();
2002
+ // Remote access(Serve/Funnel)๋Š” ๋Œ€๋‹ค์ˆ˜ ์‚ฌ์šฉ์ž์—๊ฒ ๋ถˆํ•„์š” โ†’ ์˜จ๋ณด๋”ฉ์—์„œ ์ œ๊ฑฐํ•˜๊ณ  Settings ์—์„œ ์ƒ์„ธํžˆ ๋‹ค๋ฃฌ๋‹ค.
2003
+ // (ํ—ค๋” โคข ๋ฒ„ํŠผ์œผ๋กœ๋„ ์—ฌ์ „ํžˆ ์ ‘๊ทผ ๊ฐ€๋Šฅ)
1996
2004
  }
1997
2005
  function cardHTML(r){
1998
2006
  const task = tasks.get(r.taskId);
@@ -3764,9 +3772,9 @@ async function loadRemote(){
3764
3772
  function paintRemote(){
3765
3773
  const html = remoteCardHTML(remoteData);
3766
3774
  const ov = $('remoteBody'); if (ov) ov.innerHTML = html;
3767
- const ob = $('rmtOnboard'); if (ob) ob.innerHTML = html;
3775
+ const st = $('rmtSettings'); if (st) st.innerHTML = html; // Settings ํŽ˜์ด์ง€ ์ธ๋ผ์ธ
3768
3776
  wireRemote($('remoteOverlay'));
3769
- wireRemote(document.getElementById('empty'));
3777
+ wireRemote(document.getElementById('setbox'));
3770
3778
  }
3771
3779
 
3772
3780
  function wireRemote(scope){
package/src/cockpit.ts ADDED
@@ -0,0 +1,125 @@
1
+ // Cockpit โ€” ํ„ฐ๋ฏธ๋„ ์šฐ์„  ์…ธ (๋ณ‘ํ–‰ ๊ฐœ๋ฐœ์šฉ, /cockpit). board.ts ์ฒ˜๋Ÿผ ์ž๊ฐ€์™„๊ฒฐ ๋‹จ์ผ HTML(๋นŒ๋“œ 0).
2
+ // ๋ฐฑ์—”๋“œ(server ๋ผ์šฐํŠธยทterm.tsยทorchestrator)๋Š” ๋ณด๋“œ์™€ ์ „๋ถ€ ๊ณต์œ . Phase 5์—์„œ ๋ฐ์Šคํฌํ†ฑ ๊ธฐ๋ณธ์„ ์—ฌ๊ธฐ๋กœ ํ”Œ๋ฆฝ.
3
+ // Phase 0 = ์Šค์บํด๋“œ(๋ ˆ์ด์•„์›ƒ ๊ณจ๊ฒฉ + Board ํ† ๊ธ€ + ๋ชจ๋ฐ”์ผ ๋ณด๋“œ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ). ํŠธ๋ฆฌยทํŽ˜์ธ ๊ทธ๋ฆฌ๋“œยท์š”์ฒญ๋ฐ”๋Š” Phase 2~3.
4
+ export const COCKPIT_HTML = /* html */ `<!doctype html>
5
+ <html lang="en">
6
+ <head>
7
+ <meta charset="utf-8" />
8
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
9
+ <title>coxpit ยท cockpit</title>
10
+ <link rel="icon" href="/brand/favicon.ico" sizes="any" />
11
+ <link rel="icon" type="image/png" sizes="32x32" href="/brand/favicon-32.png" />
12
+ <link rel="apple-touch-icon" href="/brand/apple-touch-icon.png" />
13
+ <style>
14
+ @font-face{font-family:'Pixelify';src:url('/brand/pixelify.woff2') format('woff2');font-weight:400 700;font-display:swap}
15
+ :root{
16
+ --bg:#0b0d12; --surface:#12151c; --surface2:#171b24; --panel:#0e1118;
17
+ --line:#222835; --line-hi:#2f3648;
18
+ --ink:#dee4ec; --muted:#8792a2; --faint:#5c6675;
19
+ --brand:#4ec9b0; --brand-ink:#062822; --brand-dim:rgba(78,201,176,.13);
20
+ --running:#55a7e0; --done:#58b368; --blocked:#d6a249; --failed:#e25b67;
21
+ --mono:ui-monospace,'SF Mono',SFMono-Regular,Menlo,Consolas,monospace;
22
+ --sans:-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,Roboto,sans-serif;
23
+ }
24
+ *{box-sizing:border-box}
25
+ html,body{height:100%}
26
+ body{margin:0;background:var(--bg);color:var(--ink);font-family:var(--sans);font-size:14px;line-height:1.5;
27
+ -webkit-font-smoothing:antialiased;overflow:hidden}
28
+
29
+ /* โ”€โ”€ topbar โ”€โ”€ */
30
+ header{display:flex;align-items:center;gap:13px;height:46px;padding:0 14px;
31
+ border-bottom:1px solid var(--line);background:rgba(18,21,28,.92);font-family:var(--mono)}
32
+ .brand{display:inline-flex;align-items:center;gap:8px;text-decoration:none}
33
+ .brand img{height:22px;width:auto;display:block}
34
+ .brand .wm{font-family:'Pixelify';font-weight:600;font-size:19px;color:var(--ink);letter-spacing:.01em;margin-left:-2px}
35
+ .mach{display:inline-flex;align-items:center;gap:7px;font-size:12px;color:var(--muted);border:1px solid var(--line);border-radius:7px;padding:4px 10px}
36
+ .mach .dot{width:6px;height:6px;border-radius:50%;background:var(--done)}
37
+ .vtabs{display:flex;gap:4px;margin-left:4px}
38
+ .vtab{font-size:12px;color:var(--muted);padding:6px 11px;border-radius:7px;display:inline-flex;align-items:center;gap:7px;cursor:pointer;background:none;border:none;font-family:var(--mono)}
39
+ .vtab.on{background:var(--brand-dim);color:var(--ink);box-shadow:inset 0 0 0 1px rgba(78,201,176,.28)}
40
+ .vtab .g{color:var(--brand)}
41
+ .right{margin-left:auto;display:flex;align-items:center;gap:9px}
42
+ .wip{font-size:10.5px;color:var(--blocked);border:1px solid rgba(214,162,73,.4);border-radius:999px;padding:2px 9px}
43
+ .toggle{font-size:12px;color:var(--muted);text-decoration:none;border:1px solid var(--line);border-radius:7px;padding:5px 11px}
44
+ .toggle:hover{color:var(--ink);border-color:var(--line-hi)}
45
+
46
+ /* โ”€โ”€ body: tree | main โ”€โ”€ */
47
+ .layout{display:grid;grid-template-columns:264px 1fr;height:calc(100vh - 46px)}
48
+ .rail{border-right:1px solid var(--line);overflow:auto;padding:12px 10px}
49
+ .lbl{font-family:var(--mono);font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:var(--faint);padding:5px 8px 10px}
50
+ .skel{height:11px;border-radius:5px;background:linear-gradient(90deg,var(--surface) 0%,var(--surface2) 50%,var(--surface) 100%);
51
+ background-size:200% 100%;animation:sh 1.4s linear infinite;margin:9px 8px}
52
+ @keyframes sh{to{background-position:-200% 0}}
53
+ @media (prefers-reduced-motion:reduce){.skel{animation:none}}
54
+
55
+ .main{display:flex;flex-direction:column;min-width:0;background:var(--bg)}
56
+ .stage{flex:1;display:flex;align-items:center;justify-content:center;text-align:center;padding:24px}
57
+ .stage .card{max-width:460px}
58
+ .stage .glyph{font-family:var(--mono);font-size:26px;color:#2c3444;letter-spacing:5px;margin-bottom:14px}
59
+ .stage h1{font-family:var(--mono);font-size:17px;margin:0 0 8px;color:var(--ink);font-weight:700}
60
+ .stage p{color:var(--muted);font-size:13.5px;margin:0 0 6px;line-height:1.6}
61
+ .stage .steps{font-family:var(--mono);font-size:12px;color:var(--faint);margin-top:16px;text-align:left;
62
+ border:1px solid var(--line);border-radius:10px;background:var(--surface);padding:14px 16px;line-height:1.9}
63
+ .stage .steps b{color:var(--brand)}
64
+ .reqbar{display:flex;align-items:center;gap:10px;border-top:1px solid var(--line);background:var(--surface);padding:11px 14px;font-family:var(--mono)}
65
+ .reqbar .ctx{font-size:11px;color:var(--brand-ink);background:var(--brand);border-radius:6px;padding:3px 9px;font-weight:600}
66
+ .reqbar .caret{color:var(--brand)}
67
+ .reqbar .ph{color:var(--faint);font-size:13px;flex:1}
68
+ .reqbar .kbd{border:1px solid var(--line-hi);border-radius:5px;padding:1px 6px;color:var(--faint);font-size:11px}
69
+ </style>
70
+ </head>
71
+ <body>
72
+ <header>
73
+ <a class="brand" href="/"><img src="/brand/mark.png" alt="" /><span class="wm">coxpit</span></a>
74
+ <span class="mach"><span class="dot"></span><span id="mach">local</span></span>
75
+ <div class="vtabs">
76
+ <button type="button" class="vtab on"><span class="g">โŒ—</span>Terminal</button>
77
+ <button type="button" class="vtab"><span class="g">โง‰</span>Review</button>
78
+ <button type="button" class="vtab"><span class="g">โ–ค</span>Docs</button>
79
+ </div>
80
+ <div class="right">
81
+ <span class="wip">preview ยท Phase 0</span>
82
+ <a class="toggle" href="/">โ† Board</a>
83
+ </div>
84
+ </header>
85
+
86
+ <div class="layout">
87
+ <aside class="rail">
88
+ <div class="lbl">Workspace</div>
89
+ <div class="skel" style="width:70%"></div>
90
+ <div class="skel" style="width:52%"></div>
91
+ <div class="skel" style="width:60%"></div>
92
+ <div class="skel" style="width:44%"></div>
93
+ </aside>
94
+
95
+ <main class="main">
96
+ <div class="stage">
97
+ <div class="card">
98
+ <div class="glyph">โŒ— โŒ— โŒ—</div>
99
+ <h1>Cockpit โ€” ํ„ฐ๋ฏธ๋„ ์šฐ์„  ์…ธ</h1>
100
+ <p>์ด ํ™”๋ฉด์€ ์ƒˆ ์…ธ์˜ ๊ณจ๊ฒฉ์ž…๋‹ˆ๋‹ค. ๋ฐฑ์—”๋“œ๋Š” ๋ณด๋“œ์™€ ๊ณต์œ ํ•˜๊ณ , ์—ฌ๊ธฐ์— ํŠธ๋ฆฌยทํŽ˜์ธ ๊ทธ๋ฆฌ๋“œ ํ„ฐ๋ฏธ๋„ยท์š”์ฒญ๋ฐ”๋ฅผ ๋‹จ๊ณ„๋กœ ์–น์Šต๋‹ˆ๋‹ค.</p>
101
+ <div class="steps">
102
+ <div><b>Phase 1</b> ยท tmux ์žฌ-adopt (์žฌ์‹œ์ž‘ ๋‚ด์„ฑ ํ† ๋Œ€)</div>
103
+ <div><b>Phase 2</b> ยท ์›Œํฌ์ŠคํŽ˜์ด์Šค ํŠธ๋ฆฌ + ํŽ˜์ธ ๊ทธ๋ฆฌ๋“œ ํ„ฐ๋ฏธ๋„</div>
104
+ <div><b>Phase 3</b> ยท ์š”์ฒญ๋ฐ” ยท ํŒฌ์•„์›ƒ ํƒ€์ผ ยท Review ยท ๋ธŒ๋กœ๋“œ์บ์ŠคํŠธ</div>
105
+ <div><b>Phase 4</b> ยท verify in-loop</div>
106
+ </div>
107
+ </div>
108
+ </div>
109
+ <div class="reqbar">
110
+ <span class="ctx">cockpit</span><span class="caret">โ€บ</span>
111
+ <span class="ph">์š”์ฒญ๋ฐ” โ€” Phase 3</span>
112
+ <span class="kbd">โŽ</span>
113
+ </div>
114
+ </main>
115
+ </div>
116
+
117
+ <script>
118
+ // ๋ชจ๋ฐ”์ผ์€ ํ„ฐ๋ฏธ๋„ ์šฐ์„  ๋Œ€์‹  ๋ณด๋“œ(๋ชจ๋‹ˆํ„ฐ)๋กœ โ€” Cockpit ์€ ๋ฐ์Šคํฌํ†ฑ ์šฐ์„ .
119
+ // (๋ทฐํฌํŠธ ํญ + ํ„ฐ์น˜ ํžŒํŠธ๋กœ ํŒ์ • โ€” screen.width ๋Š” ํ—ค๋“œ๋ฆฌ์Šค/๋ฉ€ํ‹ฐ๋ชจ๋‹ˆํ„ฐ์„œ ๋ถ€์ •ํ™•)
120
+ if (window.innerWidth <= 860 && matchMedia('(pointer:coarse)').matches) location.replace('/');
121
+ // health ๋กœ ๋จธ์‹ ๋ช… ์ •๋„๋งŒ ์ฑ„์šด๋‹ค(์Šค์บํด๋“œ).
122
+ fetch('/api/health').then(function(r){return r.json()}).then(function(){}).catch(function(){});
123
+ </script>
124
+ </body>
125
+ </html>`;
package/src/db/index.ts CHANGED
@@ -101,4 +101,6 @@ export async function ensureSchema(): Promise<void> {
101
101
  try { await client.execute('ALTER TABLE tasks ADD COLUMN closed_at INTEGER'); } catch { /* exists */ }
102
102
  try { await client.execute("ALTER TABLE task_groups ADD COLUMN coord_session_id TEXT NOT NULL DEFAULT ''"); } catch { /* exists */ }
103
103
  try { await client.execute("ALTER TABLE tasks ADD COLUMN outputs TEXT NOT NULL DEFAULT '[]'"); } catch { /* exists */ }
104
+ try { await client.execute('ALTER TABLE agent_runs ADD COLUMN agent_pid INTEGER NOT NULL DEFAULT 0'); } catch { /* exists */ }
105
+ try { await client.execute('ALTER TABLE agent_runs ADD COLUMN log_offset INTEGER NOT NULL DEFAULT 0'); } catch { /* exists */ }
104
106
  }
package/src/db/schema.ts CHANGED
@@ -88,6 +88,8 @@ export const agentRuns = sqliteTable('agent_runs', {
88
88
  prUrl: text('pr_url').notNull().default(''), // PR ๋ชจ๋“œ๋กœ ์˜ฌ๋ฆฐ pull request URL
89
89
  model: text('model').notNull().default(''), // ๋Ÿฐ์น˜๋ณ„ ๋ชจ๋ธ ์ง€์ •(๋นˆ๊ฐ’ = CLI ๊ธฐ๋ณธ)
90
90
  filesChanged: integer('files_changed').notNull().default(0),
91
+ agentPid: integer('agent_pid').notNull().default(0), // detached sh pgid โ€” ์žฌ์‹œ์ž‘ ํ›„ ์ƒ์กด ํŒ์ •/stop
92
+ logOffset: integer('log_offset').notNull().default(0), // ๋‚ด๊ตฌ ๋กœ๊ทธ์—์„œ tail ์ด ์†Œ๋น„ํ•œ ๋ฐ”์ดํŠธ(์žฌ-adopt ์‹œ ์—ฌ๊ธฐ๋ถ€ํ„ฐ)
91
93
  startedAt: integer('started_at', { mode: 'timestamp' }),
92
94
  endedAt: integer('ended_at', { mode: 'timestamp' }),
93
95
  exitSummary: text('exit_summary').notNull().default(''),
@@ -1,8 +1,8 @@
1
1
  import { randomBytes } from 'node:crypto';
2
2
  import { posix as ppath } from 'node:path';
3
3
  import { createInterface } from 'node:readline';
4
- import { existsSync } from 'node:fs';
5
- import { mkdir, copyFile, readFile, writeFile, rm } from 'node:fs/promises';
4
+ import { existsSync, statSync, openSync, readSync, closeSync, mkdirSync } from 'node:fs';
5
+ import { mkdir, copyFile, readFile, writeFile, rm, unlink } from 'node:fs/promises';
6
6
  import { homedir } from 'node:os';
7
7
  import type { ChildProcess } from 'node:child_process';
8
8
  import { eq, inArray } from 'drizzle-orm';
@@ -111,6 +111,80 @@ async function setRun(runId: number, patch: Partial<typeof agentRuns.$inferInser
111
111
  // ์‹คํ–‰ ์ค‘ run ์˜ ์ž์‹ ํ”„๋กœ์„ธ์Šค(stop ์šฉ). stoppedRuns = ์‚ฌ์šฉ์ž๊ฐ€ ๋ฉˆ์ถ˜ run ํ‘œ์‹.
112
112
  const liveChildren = new Map<number, ChildProcess>();
113
113
  const stoppedRuns = new Set<number>();
114
+ // ์žฌ์‹œ์ž‘ ํ›„ ์žฌ-adopt ๋˜์–ด ๋กœ๊ทธ๋ฅผ tail ์ค‘์ธ run(์ž์‹ ๊ฐ์ฒด๋Š” ์—†์ง€๋งŒ ์‚ด์•„์žˆ์Œ).
115
+ const adoptedRuns = new Set<number>();
116
+
117
+ const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
118
+ function pidAlive(pid: number): boolean {
119
+ if (!pid) return false;
120
+ try { process.kill(pid, 0); return true; } catch { return false; }
121
+ }
122
+
123
+ // โ”€โ”€ ๋‚ด๊ตฌ ์‹คํ–‰ ๋กœ๊ทธ(์žฌ์‹œ์ž‘ ๋‚ด์„ฑ ํ† ๋Œ€) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
124
+ // ์—์ด์ „ํŠธ ์ถœ๋ ฅ์€ ๋ฐ๋ชฌ ํŒŒ์ดํ”„๊ฐ€ ์•„๋‹ˆ๋ผ ์ด ํŒŒ์ผ๋กœ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ๋œ๋‹ค โ†’ ๋ฐ๋ชฌ์ด ์ฃฝ์–ด๋„
125
+ // detached ์—์ด์ „ํŠธ๊ฐ€ ๊ณ„์† ํŒŒ์ผ์— ์“ฐ๊ณ , ๋ฐ๋ชฌ์€ ๋ถ€ํŒ… ํ›„ offset ๋ถ€ํ„ฐ ์žฌ-tail ํ•œ๋‹ค.
126
+ function runsDir(): string {
127
+ const d = ppath.join(ppath.dirname(config.dbPath), 'runs');
128
+ try { mkdirSync(d, { recursive: true }); } catch { /* best effort */ }
129
+ return d;
130
+ }
131
+ function runLogPath(runId: number): string { return ppath.join(runsDir(), `r${runId}.log`); }
132
+ const DONE_RE = /^__COXPIT_DONE__:(-?\d+)\s*$/;
133
+ /** ๋กœ๊ทธ ํŒŒ์ผ tail โ€” offset ๋ถ€ํ„ฐ ์ƒˆ ๋ฐ”์ดํŠธ๋ฅผ ์ฝ์–ด ๋ผ์ธ ํŒŒ์‹ฑโ†’์ด๋ฒคํŠธ, ์ข…๋ฃŒ ์„ผํ‹ฐ๋„ฌ(__COXPIT_DONE__) ๊ฐ์ง€.
134
+ * aliveCheck ๊ฐ€ false ๋ฉด(์„ผํ‹ฐ๋„ฌ ์—†์ด ํ”„๋กœ์„ธ์Šค ์‚ฌ๋ง) code=-1 ๋กœ ์ข…๋ฃŒ. offset ์€ ์ฃผ๊ธฐ์ ์œผ๋กœ ์˜์†. */
135
+ async function tailRunLog(
136
+ runId: number, provider: Provider, fromOffset: number,
137
+ aliveCheck?: () => boolean,
138
+ ): Promise<{ code: number; lastResult: string; offset: number }> {
139
+ const log = runLogPath(runId);
140
+ let offset = Math.max(0, fromOffset);
141
+ let buf = '';
142
+ let lastResult = '';
143
+ let code = 0;
144
+ let done = false;
145
+ let sincePersist = 0;
146
+ const consume = () => {
147
+ if (!existsSync(log)) return;
148
+ const size = statSync(log).size;
149
+ if (size <= offset) return;
150
+ const fd = openSync(log, 'r');
151
+ try {
152
+ const b = Buffer.alloc(size - offset);
153
+ readSync(fd, b, 0, b.length, offset);
154
+ offset = size; buf += b.toString('utf8');
155
+ } finally { closeSync(fd); }
156
+ let nl: number;
157
+ while ((nl = buf.indexOf('\n')) >= 0) {
158
+ const line = buf.slice(0, nl); buf = buf.slice(nl + 1);
159
+ const m = DONE_RE.exec(line);
160
+ if (m) { code = parseInt(m[1] ?? '0', 10) || 0; done = true; continue; }
161
+ const p = provider.parseLine(line);
162
+ if (p) {
163
+ if (p.sessionId) void setRun(runId, { sessionId: p.sessionId });
164
+ if (p.resultText != null) lastResult = p.resultText;
165
+ void recordEvent(runId, p.kind, p.stored.slice(0, 2000));
166
+ continue;
167
+ }
168
+ // stdout+stderr ๋ฅผ ํ•œ ๋กœ๊ทธ๋กœ ํ•ฉ์ณค์œผ๋ฏ€๋กœ(2>&1), stream-json ์ด ์•„๋‹Œ non-json ๋ผ์ธ = stderr ๋กœ ์‚ด๋ฆฐ๋‹ค.
169
+ const t = line.trim();
170
+ if (t && t[0] !== '{') void recordEvent(runId, 'stderr', t.slice(0, 2000));
171
+ }
172
+ };
173
+ while (!done) {
174
+ consume();
175
+ if (done) break;
176
+ if (aliveCheck && !aliveCheck()) { consume(); if (!done) { code = -1; } break; }
177
+ if (++sincePersist >= 8) { sincePersist = 0; void persistLogOffset(runId, offset); }
178
+ await sleep(250);
179
+ }
180
+ consume();
181
+ void persistLogOffset(runId, offset);
182
+ return { code, lastResult, offset };
183
+ }
184
+ /** offset ๋งŒ ๊ฐ€๋ณ๊ฒŒ ์˜์†(broadcast ์—†์ด โ€” setRun ์€ ๋งค๋ฒˆ broadcast ๋ผ ๋ถ€์ ํ•ฉ). */
185
+ async function persistLogOffset(runId: number, offset: number): Promise<void> {
186
+ try { await db.update(agentRuns).set({ logOffset: offset }).where(eq(agentRuns.id, runId)); } catch { /* best effort */ }
187
+ }
114
188
 
115
189
  // โ”€โ”€ ์—์ด์ „ํŠธ ์…€ํ”„ ์˜ค์ผ€์ŠคํŠธ๋ ˆ์ด์…˜ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
116
190
  // run ๋งˆ๋‹ค 1ํšŒ์šฉ ํ† ํฐ์„ ๋ฐœ๊ธ‰ํ•ด ์—์ด์ „ํŠธ env ๋กœ ์ค€๋‹ค. ์—์ด์ „ํŠธ๋Š” ๊ทธ ํ† ํฐ์œผ๋กœ
@@ -130,9 +204,9 @@ export function resolveAgentToken(token: string): number | null {
130
204
  return agentTokens.get(token) ?? null;
131
205
  }
132
206
 
133
- /** run ์ด ์ง€๊ธˆ ์‚ด์•„ ์žˆ๋Š”๊ฐ€(์ž์‹ ํ”„๋กœ์„ธ์Šค ๋ณด์œ ). aggregate ๋ทฐ์˜ live/steerable ํŒ์ •์šฉ. */
207
+ /** run ์ด ์ง€๊ธˆ ์‚ด์•„ ์žˆ๋Š”๊ฐ€(์ž์‹ ํ”„๋กœ์„ธ์Šค ๋ณด์œ  or ์žฌ-adopt tail ์ค‘). aggregate ๋ทฐ์˜ live/steerable ํŒ์ •์šฉ. */
134
208
  export function isRunLive(runId: number): boolean {
135
- return liveChildren.has(runId);
209
+ return liveChildren.has(runId) || adoptedRuns.has(runId);
136
210
  }
137
211
 
138
212
  /** ์—์ด์ „ํŠธ ํ”„๋กฌํ”„ํŠธ์— ๋ถ™๋Š” ๋Šฅ๋ ฅ ๊ณ ์ง€ โ€” ๋…๋ฆฝ ํ•˜์œ„์ž‘์—…์„ ๋ณ‘๋ ฌ ์„œ๋ธŒ๋Ÿฐ์œผ๋กœ ๋บ„ ์ˆ˜ ์žˆ๋‹ค.
@@ -408,34 +482,48 @@ export async function launchRun(runId: number, real?: boolean): Promise<void> {
408
482
  * ์—์ด์ „ํŠธ ์ž์‹ ํ”„๋กœ์„ธ์Šค ๋ฐฐ์„ (๊ณต์šฉ) โ€” ํ”„๋กœ๋ฐ”์ด๋”๊ฐ€ stdout ๋ผ์ธ์„ ์ •๊ทœํ™” ์ด๋ฒคํŠธ๋กœ
409
483
  * ํŒŒ์‹ฑ, session ์บก์ฒ˜, ์ข…๋ฃŒ ์‹œ files_changed ์ง‘๊ณ„ + ์ƒํƒœ ์ „์ด. launchRun/steerRun ๊ณต์œ .
410
484
  */
411
- async function runAgentChild(runId: number, machine: MachineTarget, wtPath: string, cmd: string, provider: Provider): Promise<void> {
412
- const child = spawnShellOn(machine, cmd);
413
- liveChildren.set(runId, child);
414
-
485
+ async function runAgentChild(runId: number, machine: MachineTarget, wtPath: string, cmd: string, provider: Provider, opts?: { fresh?: boolean }): Promise<void> {
486
+ const isRemote = machine.kind !== 'local' && machine.address !== '';
487
+ let code: number;
415
488
  let lastResult = '';
416
- if (child.stdout) {
417
- const rl = createInterface({ input: child.stdout });
418
- rl.on('line', (line: string) => {
419
- const p = provider.parseLine(line);
420
- if (!p) return;
421
- if (p.sessionId) void setRun(runId, { sessionId: p.sessionId }); // steer(resume)์šฉ ์„ธ์…˜ ํ‚ค
422
- if (p.resultText != null) lastResult = p.resultText;
423
- void recordEvent(runId, p.kind, p.stored.slice(0, 2000));
424
- });
425
- }
426
- if (child.stderr) {
427
- const rle = createInterface({ input: child.stderr });
428
- rle.on('line', (line: string) => {
429
- const s = line.trim();
430
- if (s) void recordEvent(runId, 'stderr', s.slice(0, 2000));
431
- });
432
- }
433
489
 
434
- const code: number = await new Promise((resolve) => {
435
- child.on('close', (c) => resolve(c ?? 0));
436
- child.on('error', () => resolve(-1));
437
- });
438
- liveChildren.delete(runId);
490
+ if (isRemote) {
491
+ // ์›๊ฒฉ: ssh stdout ํŒŒ์ดํ”„๋ฅผ ์‹ค์‹œ๊ฐ„ ํŒŒ์‹ฑ(๊ธฐ์กด ๋™์ž‘). ๋กœ๊ทธ ํŒŒ์ผ/์žฌ-adopt ๋Š” ๋กœ์ปฌ ์ „์šฉ.
492
+ const child = spawnShellOn(machine, cmd);
493
+ liveChildren.set(runId, child);
494
+ if (child.stdout) {
495
+ const rl = createInterface({ input: child.stdout });
496
+ rl.on('line', (line: string) => {
497
+ const p = provider.parseLine(line);
498
+ if (!p) return;
499
+ if (p.sessionId) void setRun(runId, { sessionId: p.sessionId });
500
+ if (p.resultText != null) lastResult = p.resultText;
501
+ void recordEvent(runId, p.kind, p.stored.slice(0, 2000));
502
+ });
503
+ }
504
+ if (child.stderr) {
505
+ const rle = createInterface({ input: child.stderr });
506
+ rle.on('line', (line: string) => { const s = line.trim(); if (s) void recordEvent(runId, 'stderr', s.slice(0, 2000)); });
507
+ }
508
+ code = await new Promise((resolve) => { child.on('close', (c) => resolve(c ?? 0)); child.on('error', () => resolve(-1)); });
509
+ liveChildren.delete(runId);
510
+ } else {
511
+ // ๋กœ์ปฌ: ์—์ด์ „ํŠธ ์ถœ๋ ฅ์„ ๋‚ด๊ตฌ ๋กœ๊ทธ๋กœ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ + ํŒŒ์ผ tail(์žฌ์‹œ์ž‘ ๋‚ด์„ฑ). ์ข…๋ฃŒ๋Š” __COXPIT_DONE__ ์„ผํ‹ฐ๋„ฌ.
512
+ const fresh = opts?.fresh !== false; // launch=์ƒˆ ๋กœ๊ทธ / steer=์ด์–ด์“ฐ๊ธฐ
513
+ const log = runLogPath(runId);
514
+ const wrapped = `{ ${cmd} ; } ${fresh ? '>' : '>>'} ${shq(log)} 2>&1; printf '\\n__COXPIT_DONE__:%s\\n' "$?" >> ${shq(log)}`;
515
+ let fromOffset = 0;
516
+ if (fresh) { await setRun(runId, { logOffset: 0, agentPid: 0 }); }
517
+ else { const r = (await db.select({ o: agentRuns.logOffset }).from(agentRuns).where(eq(agentRuns.id, runId)).limit(1))[0]; fromOffset = r?.o ?? 0; }
518
+ const child = spawnShellOn(machine, wrapped);
519
+ liveChildren.set(runId, child);
520
+ child.on('error', () => { /* ํŒŒ์ดํ”„ ๋ฏธ์‚ฌ์šฉ โ€” ๋กœ๊ทธ tail ์ด ์ง„์‹ค */ });
521
+ const pid = child.pid ?? 0;
522
+ if (pid) await setRun(runId, { agentPid: pid });
523
+ const r = await tailRunLog(runId, provider, fromOffset, () => pidAlive(pid));
524
+ code = r.code; lastResult = r.lastResult;
525
+ liveChildren.delete(runId);
526
+ }
439
527
 
440
528
  const stat = await runShellOn(machine, `git -C ${shq(wtPath)} status --porcelain | wc -l`, 10000);
441
529
  const filesChanged = stat.ok ? parseInt(stat.stdout.trim(), 10) || 0 : 0;
@@ -443,7 +531,7 @@ async function runAgentChild(runId: number, machine: MachineTarget, wtPath: stri
443
531
  const wasStopped = stoppedRuns.delete(runId);
444
532
  const status = wasStopped ? 'stopped' : code === 0 ? 'done' : 'failed';
445
533
  const exitSummary = wasStopped ? 'stopped by user' : lastResult ? lastResult.slice(0, 500) : `exit ${code}`;
446
- await setRun(runId, { status, endedAt: new Date(), filesChanged, exitSummary });
534
+ await setRun(runId, { status, endedAt: new Date(), filesChanged, exitSummary, agentPid: 0 });
447
535
  // ๋ฌธ์„œ ์‚ฐ์ถœ๋ฌผ์„ ์ •์ฐฉ ์‹œ์ ์— ์Šค๋ƒ…์ƒท โ€” worktree ์†Œ๋ฉธ(๋จธ์ง€ยทClose) ํ›„์—๋„ ๋ Œ๋” ๋ทฐ ์œ ์ง€. best-effort.
448
536
  if (filesChanged > 0) void snapshotRunDocs(runId);
449
537
  void notifySettle(runId, status, filesChanged, exitSummary);
@@ -528,9 +616,9 @@ export async function steerRun(runId: number, message: string, mode: 'work' | 'a
528
616
  const cmd = `cd ${shq(wt)} && ${envPrefix}${pidPrefix}{ ${resume}; }`;
529
617
  if (!isRemote && config.agentOrch) {
530
618
  const orchTimer = startOrchWatch(runId, wt, true);
531
- void runAgentChild(runId, ctx.machine, wt, cmd, provider).finally(() => clearInterval(orchTimer));
619
+ void runAgentChild(runId, ctx.machine, wt, cmd, provider, { fresh: false }).finally(() => clearInterval(orchTimer));
532
620
  } else {
533
- void runAgentChild(runId, ctx.machine, wt, cmd, provider);
621
+ void runAgentChild(runId, ctx.machine, wt, cmd, provider, { fresh: false });
534
622
  }
535
623
  return { ok: true, detail: 'steering' };
536
624
  }
@@ -554,6 +642,12 @@ export async function stopRun(runId: number): Promise<{ ok: boolean; detail: str
554
642
  // stop ์š”์ฒญ์„ ์ •์‚ฐ์œผ๋กœ ์ฒ˜๋ฆฌํ•ด ์นด๋“œ๊ฐ€ ์˜์›ํžˆ '์ง„ํ–‰ ์ค‘'์œผ๋กœ ๋‚จ์ง€ ์•Š๊ฒŒ ํ•œ๋‹ค.
555
643
  const zr = (await db.select().from(agentRuns).where(eq(agentRuns.id, runId)).limit(1))[0];
556
644
  if (zr && (zr.status === 'running' || zr.status === 'starting')) {
645
+ // ์žฌ-adopt ๋˜์–ด tail ์ค‘์ธ ์‚ด์•„์žˆ๋Š” ๋กœ์ปฌ ์—์ด์ „ํŠธ โ€” ํ”„๋กœ์„ธ์Šค ๊ทธ๋ฃน ์ข…๋ฃŒ. tailer ๊ฐ€ stopped ๋กœ ์ •์‚ฐ.
646
+ if (zr.agentPid && pidAlive(zr.agentPid)) {
647
+ stoppedRuns.add(runId);
648
+ try { process.kill(-zr.agentPid, 'SIGTERM'); } catch { try { process.kill(zr.agentPid, 'SIGTERM'); } catch { /* gone */ } }
649
+ return { ok: true, detail: 'stopping (re-adopted agent)' };
650
+ }
557
651
  await setRun(runId, { status: 'stopped', endedAt: new Date(), exitSummary: 'orphaned (daemon restarted) โ€” settled by stop' });
558
652
  await recordEvent(runId, 'meta', JSON.stringify({ orphanSettled: true }));
559
653
  return { ok: true, detail: 'no live process โ€” settled as stopped' };
@@ -1446,15 +1540,67 @@ export async function listDocuments(): Promise<{ runs: Array<{
1446
1540
  * ๋ถ€ํŒ… ์ •์‚ฐ โ€” ๋ฐ๋ชฌ ์žฌ์‹œ์ž‘ ํ›„ ์‚ด์•„์žˆ๋Š” ์ž์‹์ด ์žˆ์„ ์ˆ˜ ์—†๋Š”๋ฐ DB ๊ฐ€ running/starting ์ธ
1447
1541
  * run(๊ณ ์•„)์„ failed ๋กœ ์ •๋ฆฌํ•œ๋‹ค. workbench('open')๋Š” ์—์ด์ „ํŠธ๊ฐ€ ์—†์œผ๋ฏ€๋กœ ๋Œ€์ƒ ์•„๋‹˜.
1448
1542
  */
1543
+ /** ์žฌ-adopt: ์‚ด์•„์žˆ๋Š” ๋กœ์ปฌ run ์˜ ๋กœ๊ทธ๋ฅผ offset ๋ถ€ํ„ฐ ์ด์–ด tail โ†’ ์™„๋ฃŒ ์‹œ ์ •์‚ฐ. status ๋Š” running ์œ ์ง€. */
1544
+ async function reAdoptRun(run: typeof agentRuns.$inferSelect): Promise<void> {
1545
+ adoptedRuns.add(run.id);
1546
+ await recordEvent(run.id, 'meta', JSON.stringify({ reAdopted: true }));
1547
+ const ctx = await loadContext(run.id);
1548
+ const provider = getProvider(run.agent || 'claude-code');
1549
+ const machine: MachineTarget = ctx?.machine ?? { slug: 'local', kind: 'local', address: '', sshUser: '' };
1550
+ const wasStopped0 = stoppedRuns.has(run.id);
1551
+ const { code, lastResult } = await tailRunLog(run.id, provider, run.logOffset ?? 0, () => pidAlive(run.agentPid));
1552
+ adoptedRuns.delete(run.id);
1553
+ const stat = await runShellOn(machine, `git -C ${shq(run.worktreePath)} status --porcelain | wc -l`, 10000);
1554
+ const filesChanged = stat.ok ? parseInt(stat.stdout.trim(), 10) || 0 : 0;
1555
+ const wasStopped = stoppedRuns.delete(run.id) || wasStopped0;
1556
+ const status = wasStopped ? 'stopped' : code === 0 ? 'done' : 'failed';
1557
+ const exitSummary = wasStopped ? 'stopped by user' : lastResult ? lastResult.slice(0, 500) : `exit ${code}`;
1558
+ await setRun(run.id, { status, endedAt: new Date(), filesChanged, exitSummary, agentPid: 0 });
1559
+ if (filesChanged > 0) void snapshotRunDocs(run.id);
1560
+ void notifySettle(run.id, status, filesChanged, exitSummary);
1561
+ }
1562
+
1563
+ /**
1564
+ * ๋ถ€ํŒ… ์‹œ ์ด์ „ ์ธ์Šคํ„ด์Šค์˜ ๋ผ์ด๋ธŒ run ์ •์‚ฐ โ€” ์ด์ œ ๋ฌด์กฐ๊ฑด failed ๊ฐ€ ์•„๋‹ˆ๋ผ:
1565
+ * โ‘  ์—์ด์ „ํŠธ ํ”„๋กœ์„ธ์Šค(agentPid)๊ฐ€ ์‚ด์•„์žˆ์œผ๋ฉด ๋กœ๊ทธ๋ฅผ ์ด์–ด tail(์žฌ-adopt) โ†’ running ์œ ์ง€
1566
+ * โ‘ก ์ด๋ฏธ ๋กœ๊ทธ์— __COXPIT_DONE__ ์ด ์žˆ๊ฑฐ๋‚˜ ํ”„๋กœ์„ธ์Šค ์ฃฝ์Œ โ†’ ๊ทธ ๊ฒฐ๊ณผ๋กœ ์ •์‚ฐ
1567
+ * โ‘ข ๊ทธ ์™ธ(์›๊ฒฉยทpid ์—†์Œ) โ†’ orphaned failed (๊ธฐ์กด ๋™์ž‘, worktree/diff ๋ณด์กด)
1568
+ * ๋ฐ˜ํ™˜ = ์žฌ-adopt ๋œ run ์ˆ˜(์ •์‚ฐ๋œ ๊ณ ์•„๋Š” ๋ฏธํฌํ•จ).
1569
+ */
1449
1570
  export async function reconcileOrphanRuns(): Promise<number> {
1450
1571
  const stale = (await db.select().from(agentRuns)).filter(
1451
1572
  (r) => r.status === 'running' || r.status === 'starting',
1452
1573
  );
1574
+ let adopted = 0;
1453
1575
  for (const r of stale) {
1454
- await setRun(r.id, { status: 'failed', endedAt: new Date(), exitSummary: 'orphaned by daemon restart' });
1576
+ if (r.agentPid && pidAlive(r.agentPid)) {
1577
+ // ์•„์ง ์‚ด์•„์žˆ๋Š” ๋กœ์ปฌ ์—์ด์ „ํŠธ โ€” ์ด์–ด๋ฐ›๋Š”๋‹ค(๋ฐฑ๊ทธ๋ผ์šด๋“œ tail).
1578
+ void reAdoptRun(r);
1579
+ adopted++;
1580
+ continue;
1581
+ }
1582
+ // ์ฃฝ์—ˆ์ง€๋งŒ ๋กœ๊ทธ์— ์™„๋ฃŒ ์„ผํ‹ฐ๋„ฌ์ด ์žˆ์œผ๋ฉด ๊ทธ ๊ฒฐ๊ณผ๋กœ ์ •์‚ฐ(๋‹ค์šดํƒ€์ž„ ์ค‘ ๋๋‚œ ๊ฒฝ์šฐ).
1583
+ const log = runLogPath(r.id);
1584
+ let doneCode: number | null = null;
1585
+ try {
1586
+ if (existsSync(log)) {
1587
+ const tail = (await readFile(log, 'utf8')).slice(-4000);
1588
+ const m = /__COXPIT_DONE__:(-?\d+)/.exec(tail);
1589
+ if (m) doneCode = parseInt(m[1] ?? '0', 10) || 0;
1590
+ }
1591
+ } catch { /* ignore */ }
1592
+ if (doneCode !== null) {
1593
+ const stat = await runShellOn({ slug: 'local', kind: 'local', address: '', sshUser: '' }, `git -C ${shq(r.worktreePath)} status --porcelain | wc -l`, 10000);
1594
+ const filesChanged = stat.ok ? parseInt(stat.stdout.trim(), 10) || 0 : 0;
1595
+ const status = doneCode === 0 ? 'done' : 'failed';
1596
+ await setRun(r.id, { status, endedAt: new Date(), filesChanged, exitSummary: `settled on restart (exit ${doneCode})`, agentPid: 0 });
1597
+ if (filesChanged > 0) void snapshotRunDocs(r.id);
1598
+ continue;
1599
+ }
1600
+ await setRun(r.id, { status: 'failed', endedAt: new Date(), exitSummary: 'orphaned by daemon restart', agentPid: 0 });
1455
1601
  await recordEvent(r.id, 'error', 'daemon restarted while this run was live โ€” settled as failed (worktree/branch preserved; diff still reviewable)');
1456
1602
  }
1457
- return stale.length;
1603
+ return adopted;
1458
1604
  }
1459
1605
 
1460
1606
  /**
@@ -1614,6 +1760,8 @@ export async function cleanupRun(runId: number): Promise<{ ok: boolean; detail:
1614
1760
  if (!ctx || !run || !run.worktreePath) return { ok: false, detail: 'no worktree' };
1615
1761
  // worktree ๋ฅผ ์ง€์šฐ๊ธฐ ์ „์— ๋ฌธ์„œ ์Šค๋ƒ…์ƒท(์ •์ฐฉ ์•ˆ ํ•˜๋Š” ์›Œํฌ๋ฒค์น˜ยท์ˆ˜์ •ํŽธ์ง‘๋„ ํฌ์ฐฉ). best-effort.
1616
1762
  await snapshotRunDocs(runId).catch(() => { /* ์Šค๋ƒ…์ƒท ์‹คํŒจ๋Š” ์ •๋ฆฌ๋ฅผ ๋ง‰์ง€ ์•Š์Œ */ });
1763
+ // ๋‚ด๊ตฌ ์‹คํ–‰ ๋กœ๊ทธ๋„ ์ •๋ฆฌ(๋ฌดํ•œ ์ฆ๊ฐ€ ๋ฐฉ์ง€). best-effort. (์•„๋ž˜ ์ง€์—ญ๋ณ€์ˆ˜ rm ๊ณผ ์ด๋ฆ„ ์ถฉ๋Œ ํšŒํ”ผ๋กœ unlink ์‚ฌ์šฉ)
1764
+ try { await unlink(runLogPath(runId)); } catch { /* gone */ }
1617
1765
  // ์›๊ฒฉ์— ์ž”์กด ์—์ด์ „ํŠธ๊ฐ€ ์žˆ์œผ๋ฉด worktree ์ œ๊ฑฐ ์ „์— ์ฃฝ์ธ๋‹ค(ํŒŒ์ผ ์ž ๊ธˆยท์ข€๋น„ ๋ฐฉ์ง€).
1618
1766
  if (ctx.machine.kind !== 'local' && ctx.machine.address !== '') {
1619
1767
  await runShellOn(ctx.machine, remoteKillScript(run.worktreePath), 15000);
package/src/server.ts CHANGED
@@ -25,6 +25,7 @@ import { addSink, removeSink, broadcast } from './hub';
25
25
  import { getProvider, listProviders } from './providers';
26
26
  import { remoteState, setServe, setFunnel } from './remote';
27
27
  import { BOARD_HTML } from './board';
28
+ import { COCKPIT_HTML } from './cockpit';
28
29
 
29
30
  const require_ = createRequire(import.meta.url);
30
31
 
@@ -203,6 +204,8 @@ export async function buildServer(): Promise<FastifyInstance> {
203
204
 
204
205
  // ํ”Œ๋ฆฟ ๋ณด๋“œ(๋‹จ์ผ ํŽ˜์ด์ง€). ์ธ์ฆ ๊ฒŒ์ดํŠธ ์ ์šฉ๋จ(๋ฌด์ธ์ฆ ์š”์ฒญ์€ ๊ฒŒ์ดํŠธ๊ฐ€ login/setup ํŽ˜์ด์ง€๋กœ ์‘๋‹ต).
205
206
  app.get('/', async (_req, reply) => reply.type('text/html').send(BOARD_HTML));
207
+ // ํ„ฐ๋ฏธ๋„ ์šฐ์„  ์…ธ(๋ณ‘ํ–‰ ๊ฐœ๋ฐœ) โ€” ๋ฐฑ์—”๋“œ๋Š” ๋ณด๋“œ์™€ ๊ณต์œ . Phase 5์—์„œ ๋ฐ์Šคํฌํ†ฑ ๊ธฐ๋ณธ์„ ์—ฌ๊ธฐ๋กœ ํ”Œ๋ฆฝ ์˜ˆ์ •.
208
+ app.get('/cockpit', async (_req, reply) => reply.type('text/html').send(COCKPIT_HTML));
206
209
 
207
210
  // โ”€โ”€โ”€ ์ ‘๊ทผํ‚ค ์ธ์ฆ(access-key) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
208
211
  // ์š”์ฒญ์ด tunnel/https ๋ฅผ ํƒ”๋‚˜ โ€” Secure ์ฟ ํ‚ค ์—ฌ๋ถ€ ๊ฒฐ์ •์šฉ.