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 +1 -0
- package/package.json +1 -1
- package/src/.!31514!cockpit.ts +0 -0
- package/src/activity.ts +29 -0
- package/src/auth.ts +4 -1
- package/src/claudeshim.ts +110 -0
- package/src/cockpit.ts +322 -59
- package/src/config.ts +7 -0
- package/src/db/index.ts +21 -0
- package/src/db/schema.ts +5 -0
- package/src/hud.ts +672 -0
- package/src/index.ts +8 -0
- package/src/orchestrator.ts +265 -35
- package/src/server.ts +94 -15
- package/src/uistate.ts +65 -0
package/src/server.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { randomBytes } from 'node:crypto';
|
|
|
7
7
|
import { execFileSync } from 'node:child_process';
|
|
8
8
|
import Fastify, { type FastifyInstance, type FastifyReply } from 'fastify';
|
|
9
9
|
import websocket from '@fastify/websocket';
|
|
10
|
-
import { eq, inArray, and, like, desc } from 'drizzle-orm';
|
|
10
|
+
import { eq, inArray, and, like, desc, asc, sql } from 'drizzle-orm';
|
|
11
11
|
import { authGate } from './auth';
|
|
12
12
|
import { loginPageHTML } from './login';
|
|
13
13
|
import {
|
|
@@ -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, 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';
|
|
@@ -30,6 +31,7 @@ import { getProvider, listProviders } from './providers';
|
|
|
30
31
|
import { remoteState, setServe, setFunnel } from './remote';
|
|
31
32
|
import { BOARD_HTML } from './board';
|
|
32
33
|
import { COCKPIT_HTML } from './cockpit';
|
|
34
|
+
import { HUD_HTML } from './hud';
|
|
33
35
|
import { listDir as fsListDir, readForView as fsReadForView, readRaw as fsReadRaw, writeText as fsWriteText, findFiles as fsFindFiles, uploadFile as fsUploadFile, withinFilesRoot } from './files';
|
|
34
36
|
import { ensureWorkDoc, readWorkDoc, writeWorkDoc, removeWorkDoc, workDocPath, workDocSize } from './workdoc';
|
|
35
37
|
|
|
@@ -47,6 +49,12 @@ const VENDOR: Record<string, { pkg: string; rel: string; type: string }> = {
|
|
|
47
49
|
'marked.js': { pkg: 'marked/package.json', rel: 'marked.min.js', type: 'text/javascript' },
|
|
48
50
|
};
|
|
49
51
|
|
|
52
|
+
// 버전 치환은 **모듈 로드 때 한 번** — config.version 은 부트 후 변하지 않고, cockpit.ts 는
|
|
53
|
+
// 281KB 짜리 한 문자열이다. 요청마다 전역 정규식으로 다시 훑던 것을 상수로 굳혔다
|
|
54
|
+
// (보드는 원래 상수를 그대로 내보낸다 — 세 페이지가 같은 방식이 됐다).
|
|
55
|
+
const COCKPIT_PAGE = COCKPIT_HTML.replace(/__COXPIT_VER__/g, config.version);
|
|
56
|
+
const HUD_PAGE = HUD_HTML.replace(/__COXPIT_VER__/g, config.version);
|
|
57
|
+
|
|
50
58
|
// ─── 읽기 전용 공유 페이지 (서버 렌더 스냅샷 — 스크립트 0, 액션 0) ───────────
|
|
51
59
|
const escH = (x: unknown): string =>
|
|
52
60
|
String(x ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]!));
|
|
@@ -247,10 +255,19 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
247
255
|
};
|
|
248
256
|
});
|
|
249
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
|
+
|
|
250
264
|
// 플릿 보드(단일 페이지). 인증 게이트 적용됨(무인증 요청은 게이트가 login/setup 페이지로 응답).
|
|
251
|
-
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));
|
|
252
266
|
// 터미널 우선 셸(병행 개발) — 백엔드는 보드와 공유. Phase 5에서 데스크톱 기본을 여기로 플립 예정.
|
|
253
|
-
app.get('/cockpit', async (_req, reply) => reply.type('text/html').send(
|
|
267
|
+
app.get('/cockpit', async (_req, reply) => freshPage(reply).type('text/html').send(COCKPIT_PAGE));
|
|
268
|
+
// HUD(v5.28 K) — 플릿을 작게 다시 내놓는 한 장. 데스크톱의 떠 있는 작은 창이 이걸 띄운다.
|
|
269
|
+
// 서빙되는 **페이지**라 /cockpit 과 같은 게이트 뒤다(무인증 예외 아님 — 헬스가 아니다).
|
|
270
|
+
app.get('/hud', async (_req, reply) => freshPage(reply).type('text/html').send(HUD_PAGE));
|
|
254
271
|
|
|
255
272
|
// ─── 접근키 인증(access-key) ────────────────────────────────────
|
|
256
273
|
// 요청이 tunnel/https 를 탔나 — Secure 쿠키 여부 결정용.
|
|
@@ -366,17 +383,31 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
366
383
|
const activeTasks = allTasks.filter((t) => t.status !== 'closed');
|
|
367
384
|
const closedCount = allTasks.length - activeTasks.length;
|
|
368
385
|
const ts = view === 'all' ? allTasks : activeTasks;
|
|
369
|
-
const taskIds =
|
|
370
|
-
|
|
371
|
-
|
|
386
|
+
const taskIds = ts.map((t) => t.id);
|
|
387
|
+
// run 도 **보는 뷰만** 가져온다 — 전량 로드 후 JS 필터가 아니라 SQL WHERE.
|
|
388
|
+
// 정렬을 명시하는 건 필터가 끼어도 클라이언트가 받던 id 오름차순을 그대로 지키기 위함이다.
|
|
389
|
+
const rns: Array<typeof agentRuns.$inferSelect> = view === 'all'
|
|
390
|
+
? await db.select().from(agentRuns).orderBy(asc(agentRuns.id))
|
|
391
|
+
: taskIds.length
|
|
392
|
+
? await db.select().from(agentRuns).where(inArray(agentRuns.taskId, taskIds)).orderBy(asc(agentRuns.id))
|
|
393
|
+
: [];
|
|
372
394
|
const runIds = rns.map((r) => r.id);
|
|
373
|
-
// 이벤트는
|
|
374
|
-
|
|
395
|
+
// 이벤트는 run 당 최근 EVENT_CAP 개만 **SQL 에서** 잘라 온다. 전량 로드 후 JS 슬라이스가
|
|
396
|
+
// 정확히 고치려던 그 낭비다(활성 run 의 모든 이벤트 행을 메모리에 올렸다).
|
|
397
|
+
// 창 함수로 run 별 역순 번호를 매겨 상한 안쪽만 남기고, 클라이언트가 기대하는 id 오름차순으로 낸다.
|
|
398
|
+
const evs: Array<{ run_id: number; kind: string; payload: string }> = runIds.length
|
|
399
|
+
? await db.all<{ run_id: number; kind: string; payload: string }>(sql`
|
|
400
|
+
SELECT id, run_id, kind, payload FROM (
|
|
401
|
+
SELECT id, run_id, kind, payload,
|
|
402
|
+
ROW_NUMBER() OVER (PARTITION BY run_id ORDER BY id DESC) AS rn
|
|
403
|
+
FROM agent_events WHERE ${inArray(agentEvents.runId, runIds)}
|
|
404
|
+
) WHERE rn <= ${EVENT_CAP} ORDER BY run_id ASC, id ASC`)
|
|
405
|
+
: [];
|
|
375
406
|
const byRun = new Map<number, Array<{ kind: string; payload: string }>>();
|
|
376
407
|
for (const e of evs) {
|
|
377
|
-
const arr = byRun.get(e.
|
|
408
|
+
const arr = byRun.get(e.run_id) ?? [];
|
|
378
409
|
arr.push({ kind: e.kind, payload: e.payload });
|
|
379
|
-
byRun.set(e.
|
|
410
|
+
byRun.set(e.run_id, arr);
|
|
380
411
|
}
|
|
381
412
|
const taskOut = new Map(ts.map((t) => [t.id, t.outputs]));
|
|
382
413
|
return {
|
|
@@ -384,7 +415,8 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
384
415
|
runs: rns.map((r) => {
|
|
385
416
|
const sig = noopSignal(r.status, r.filesChanged, r.exitSummary, taskOut.get(r.taskId) ?? '[]');
|
|
386
417
|
// real 은 언제나 불리언으로 나간다 — 클라이언트는 real===false 하나만 보고 dry 칩을 그린다.
|
|
387
|
-
|
|
418
|
+
// events 는 이미 SQL 에서 EVENT_CAP 으로 잘려 왔다 — 여기서 또 자르지 않는다.
|
|
419
|
+
return { ...r, real: !!r.real, events: byRun.get(r.id) ?? [], noop: sig.noop, noopReason: sig.reason };
|
|
388
420
|
}),
|
|
389
421
|
counts: { activeTasks: activeTasks.length, closedTasks: closedCount },
|
|
390
422
|
// 지금 터미널이 붙어 있는 run 의 에이전트 상태(runId → {state,detail,ts}).
|
|
@@ -478,6 +510,19 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
478
510
|
return { ok: true };
|
|
479
511
|
});
|
|
480
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
|
+
|
|
481
526
|
// 아카이브 — 닫힌 태스크 목록(최신순, 페이지네이션·필터). 카드가 아니라 한 줄 행.
|
|
482
527
|
app.get('/api/archive', async (req) => {
|
|
483
528
|
const q = (req.query ?? {}) as { offset?: string; limit?: string; q?: string; repo?: string; status?: string };
|
|
@@ -493,9 +538,22 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
493
538
|
const grpTitle = new Map((await db.select().from(taskGroups)).map((g) => [g.id, g.title]));
|
|
494
539
|
const total0 = closed.length;
|
|
495
540
|
const page = closed.slice(offset, offset + limit);
|
|
541
|
+
// 페이지에 실릴 태스크의 run 을 **한 번에** 긁어 taskId 로 묶는다.
|
|
542
|
+
// 태스크마다 한 방씩 쏘던 N+1(50행 페이지 = 51 쿼리)을 한 쿼리로 접었다.
|
|
543
|
+
const pageIds = page.map((t) => t.id);
|
|
544
|
+
const runsByTask = new Map<number, Array<typeof agentRuns.$inferSelect>>();
|
|
545
|
+
if (pageIds.length) {
|
|
546
|
+
const pageRuns = await db.select().from(agentRuns)
|
|
547
|
+
.where(inArray(agentRuns.taskId, pageIds)).orderBy(asc(agentRuns.id));
|
|
548
|
+
for (const r of pageRuns) {
|
|
549
|
+
const arr = runsByTask.get(r.taskId) ?? [];
|
|
550
|
+
arr.push(r);
|
|
551
|
+
runsByTask.set(r.taskId, arr);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
496
554
|
const rows = [];
|
|
497
555
|
for (const t of page) {
|
|
498
|
-
const rs =
|
|
556
|
+
const rs = runsByTask.get(t.id) ?? [];
|
|
499
557
|
if (q.status && !rs.some((r) => r.status === q.status)) continue;
|
|
500
558
|
rows.push({
|
|
501
559
|
taskId: t.id, title: t.title, repoName: repoName.get(t.repoId) ?? '?',
|
|
@@ -1264,11 +1322,30 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
1264
1322
|
const q = (req.query ?? {}) as { lines?: string };
|
|
1265
1323
|
const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, id)).limit(1);
|
|
1266
1324
|
if (!rr[0]) return reply.code(404).send({ error: 'not found' });
|
|
1267
|
-
const res = await getScrollback(id, Number(q.lines) ||
|
|
1325
|
+
const res = await getScrollback(id, Number(q.lines) || 0); // lines 없으면 히스토리 전체
|
|
1268
1326
|
if (!res.ok) return reply.code(422).send(res);
|
|
1269
1327
|
return res;
|
|
1270
1328
|
});
|
|
1271
1329
|
|
|
1330
|
+
// 살아 있는 run 의 tmux 에 한 줄 써 넣기 (v5.28 K) — 위 scrollback 의 **쓰기 쌍둥이**.
|
|
1331
|
+
// HUD 가 코크핏을 열지 않고 "대기 중인 에이전트"에게 답하는 길이다(steer 는 정착한 run 전용이라
|
|
1332
|
+
// 프롬프트 앞에 서 있는 run 에는 쓸 수 없다). 보내는 것은 언제나 사람이 고른 문자열이다 —
|
|
1333
|
+
// 서버는 에이전트의 질문을 읽지도, 답을 고르지도 않는다.
|
|
1334
|
+
// 세션이 없으면(정리됐거나 애초에 없음) 409, 모르는 run 은 404.
|
|
1335
|
+
app.post('/api/runs/:id/input', async (req, reply) => {
|
|
1336
|
+
const id = Number((req.params as { id: string }).id);
|
|
1337
|
+
if (!Number.isInteger(id)) return reply.code(400).send({ error: 'bad id' });
|
|
1338
|
+
const b = (req.body ?? {}) as { text?: string };
|
|
1339
|
+
const text = String(b.text ?? '');
|
|
1340
|
+
if (!text.trim()) return reply.code(400).send({ error: 'text required' });
|
|
1341
|
+
if (text.length > 4000) return reply.code(400).send({ error: 'text too long (max 4000)' });
|
|
1342
|
+
const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, id)).limit(1);
|
|
1343
|
+
if (!rr[0]) return reply.code(404).send({ error: 'not found' });
|
|
1344
|
+
const res = await sendRunInput(id, text);
|
|
1345
|
+
if (!res.ok) return reply.code(409).send(res);
|
|
1346
|
+
return res;
|
|
1347
|
+
});
|
|
1348
|
+
|
|
1272
1349
|
// 페인이 지금 서 있는 폴더 (v5.28 D-fix) — 터미널이 찍은 상대경로를 무엇 기준으로 풀지.
|
|
1273
1350
|
// 못 알아내면 200 + { ok:false, pwd:'' } — 클라이언트가 worktree 로 폴백한다(지어낸 경로는 없다).
|
|
1274
1351
|
app.get('/api/runs/:id/pwd', async (req, reply) => {
|
|
@@ -1848,8 +1925,10 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
1848
1925
|
if (!has.ok) {
|
|
1849
1926
|
const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, id)).limit(1);
|
|
1850
1927
|
const wt = rr[0]?.worktreePath ?? '';
|
|
1928
|
+
// 소생한 페인에서도 `claude` 가 이 세션의 대화로 묶이게 태깅 env 를 다시 얹는다(없으면 빈 문자열).
|
|
1929
|
+
const tagEnv = claudeTagEnvForRun(info.machine, rr[0]?.claudeSessionId ?? '');
|
|
1851
1930
|
const revive = wt
|
|
1852
|
-
? 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)
|
|
1853
1932
|
: { ok: false } as { ok: boolean };
|
|
1854
1933
|
if (!revive.ok) {
|
|
1855
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
|
+
}
|