diff --git a/media/sidebar.js b/media/sidebar.js
index d9e5bec..e8a93c9 100644
--- a/media/sidebar.js
+++ b/media/sidebar.js
@@ -945,6 +945,7 @@
case 'companyStatus': {
const v = msg.value || {};
renderCompanyChip(!!v.enabled, v.summary || '');
+ renderScopeSeg(v.activePipelineId || null);
break;
}
case 'companyIntentDecision': {
@@ -965,11 +966,51 @@
break;
}
case 'pixelOfficeUpdate': {
+ // 새 path (officeSnapshot) 가 한 번이라도 도착했다면 옛 message 는 무시.
+ if (window.__officeSnapshotSeen) break;
if (typeof window.__pixelOfficeApply === 'function') {
window.__pixelOfficeApply(msg.value || {});
}
break;
}
+ case 'officeSnapshot': {
+ // refactor #E — mini view 도 OfficeSnapshot 수신.
+ // OfficeSnapshot 을 옛 {state, bubbles, config} payload 모양으로 변환 후
+ // 기존 __pixelOfficeApply 재사용. dual-mode 안전 전환.
+ window.__officeSnapshotSeen = true;
+ const snap = msg.value;
+ if (!snap || typeof window.__pixelOfficeApply !== 'function') break;
+ const roster = Array.isArray(snap.roster) ? snap.roster : [];
+ const active = (snap.activeAgentId && roster.find((a) => a.agentId === snap.activeAgentId)) || roster[0];
+ const phaseToStatus = (p) => {
+ if (p === 'awaiting-approval') return 'waiting_approval';
+ if (p === 'reporting') return 'done';
+ if (p === 'intake') return 'analyzing';
+ return p || 'idle';
+ };
+ const synthetic = {
+ agentId: snap.activeAgentId || (active && active.agentId) || 'main',
+ agentName: (active && active.agentName) || 'Agent',
+ status: (active && active.status) || phaseToStatus(snap.phase),
+ currentTask: snap.task && snap.task.goal,
+ currentStep: active && active.currentStep,
+ message: snap.activeAgentId || '',
+ recentLogs: (active && active.lastLog) ? [active.lastLog] : [],
+ progress: snap.pipeline ? (snap.pipeline.index / Math.max(1, snap.pipeline.stages.length)) : 0,
+ pipelineStages: snap.pipeline && snap.pipeline.stages,
+ needUserInput: (snap.awaiting && snap.awaiting.kind === 'clarification') ? snap.awaiting.questions : undefined,
+ awaitingApproval: (snap.awaiting && snap.awaiting.kind === 'approval') ? snap.awaiting.questions[0] : undefined,
+ requirementContract: snap.task,
+ updatedAt: snap.updatedAt,
+ };
+ window.__pixelOfficeApply({
+ state: synthetic,
+ bubbles: Array.isArray(snap.newBubbles) ? snap.newBubbles : [],
+ // config 는 그대로 유지 — snapshot 에는 enabled 만 함의적, 옛 cfg 가 살아있음.
+ config: undefined,
+ });
+ break;
+ }
case 'companyAlignmentCard': {
// Intent Alignment 카드. kind에 따라 4가지 모드:
// - 'auto-proceed' : confidence high → 자동 진행 안내(읽기 전용)
@@ -1843,8 +1884,44 @@
? `1인 기업 ON · ${summary || ''}`.trim()
: '1인 기업 모드 OFF — 클릭해서 켜기',
);
+ // 스코프 프리셋 segmented control 도 기업 모드 ON 일 때만 노출.
+ const scopeSeg = document.getElementById('companyScopeSeg');
+ if (scopeSeg) scopeSeg.hidden = !active;
};
+ // 활성 pipeline 의 id 가 어느 SCOPE 프리셋의 suggestedPipelineId 와 매칭되는지로 active 표시.
+ // companyStatus 메시지가 activePipelineId 를 보낼 때마다 호출.
+ const SCOPE_PRESET_TO_PIPELINE_ID = {
+ 'plan-only': 'plan-only',
+ 'dev-only': 'dev-only',
+ 'full-product-dev': 'product-dev',
+ };
+ const renderScopeSeg = (activePipelineId) => {
+ const scopeSeg = document.getElementById('companyScopeSeg');
+ if (!scopeSeg) return;
+ for (const btn of scopeSeg.querySelectorAll('.scope-seg-btn')) {
+ const tplId = btn.getAttribute('data-scope');
+ const expected = SCOPE_PRESET_TO_PIPELINE_ID[tplId];
+ btn.classList.toggle('active', !!activePipelineId && activePipelineId === expected);
+ }
+ };
+ // Wire up clicks once.
+ const _scopeSeg = document.getElementById('companyScopeSeg');
+ if (_scopeSeg && !_scopeSeg.dataset.wired) {
+ _scopeSeg.dataset.wired = '1';
+ _scopeSeg.addEventListener('click', (e) => {
+ const btn = e.target && e.target.closest && e.target.closest('.scope-seg-btn');
+ if (!btn) return;
+ const tplId = btn.getAttribute('data-scope');
+ if (!tplId) return;
+ // Optimistic visual flip — backend ack 가 companyStatus 갱신으로 결과 확정.
+ for (const b of _scopeSeg.querySelectorAll('.scope-seg-btn')) {
+ b.classList.toggle('active', b === btn);
+ }
+ vscode.postMessage({ type: 'setCompanyScopePreset', templateId: tplId });
+ });
+ }
+
if (_companyChip) {
_companyChip.onclick = () => {
const isActive = _companyChip.classList.contains('active');
diff --git a/package.json b/package.json
index 6e73078..a0a187b 100644
--- a/package.json
+++ b/package.json
@@ -2,7 +2,7 @@
"name": "astra",
"displayName": "Astra",
"description": "The personal intelligence layer for Antigravity and VS Code. A private cognitive partner for deep project context, memory, and proactive strategic decision-making.",
- "version": "2.2.14",
+ "version": "2.2.15",
"publisher": "g1nation",
"license": "MIT",
"icon": "assets/icon.png",
@@ -134,6 +134,18 @@
{
"command": "g1nation.company.pixelOffice.open",
"title": "Astra: Open Pixel Office (Full Screen)"
+ },
+ {
+ "command": "g1nation.calendar.connect",
+ "title": "Astra: Google Calendar (iCal) 연결 📅"
+ },
+ {
+ "command": "g1nation.calendar.refresh",
+ "title": "Astra: Google Calendar 새로고침 📅"
+ },
+ {
+ "command": "g1nation.calendar.connectOAuth",
+ "title": "Astra: Google Calendar OAuth 연결 (쓰기) 🔐"
}
],
"keybindings": [
diff --git a/src/agent.ts b/src/agent.ts
index 70bca35..b66ccbb 100644
--- a/src/agent.ts
+++ b/src/agent.ts
@@ -3303,6 +3303,185 @@ export class AgentExecutor {
} catch (err: any) { report.push(`❌ URL Read failed: ${err.message}`); }
}
+ // Action 9: Create Calendar Event (OAuth) — agent 가 회의록·작업 분석 후 일정 자동 생성.
+ // 형식:
설명
+ // 속성: title (필수), start (필수, ISO 'YYYY-MM-DDTHH:MM' 또는 timezone 포함),
+ // end | duration (분, default 60), location, all_day (true/false)
+ const calRegex = /
]*)>([\s\S]*?)<\/create_calendar_event>/gi;
+ while ((match = calRegex.exec(aiMessage)) !== null) {
+ const attrs = _parseCalEventAttrs(match[1]);
+ const desc = match[2].trim();
+ if (!attrs.title || !attrs.start) {
+ report.push(`❌ Calendar Event: title / start 누락`);
+ continue;
+ }
+ try {
+ const { createCalendarEvent } = await import('./features/calendar');
+ const r = await createCalendarEvent(this.context, {
+ title: attrs.title,
+ start: attrs.start,
+ end: attrs.end,
+ durationMinutes: attrs.duration,
+ location: attrs.location,
+ description: desc || undefined,
+ allDay: attrs.allDay,
+ });
+ if (r.ok) {
+ report.push(`📅 Calendar Event Created: ${r.event.title} (${r.event.startIso})`);
+ // chatHistory 에 결과 주입 — agent 가 다음 답변에서 link 인용 가능.
+ this.chatHistory.push({
+ role: 'system',
+ content: `[Calendar event created] ${r.event.title} · ${r.event.startIso}\nLink: ${r.event.htmlLink}`,
+ internal: true,
+ });
+ } else {
+ report.push(`❌ Calendar Event Failed: ${r.error}`);
+ }
+ } catch (err: any) { report.push(`❌ Calendar Event Error: ${err?.message ?? String(err)}`); }
+ }
+
+ // Action 10/11/12: Google Sheets read / write / append.
+ // 모두 spreadsheet_id (속성) + range (속성) 필수. write/append 는 본문이 TSV.
+ //
+ //
+ // 이름\t나이\t직책
+ // 민지\t29\t디자이너
+ //
+ //
+ // 2026-05-21\t새 항목\t완료
+ //
+ const sheetReadRegex = //]*?)\s*\/>/gi;
+ while ((match = sheetReadRegex.exec(aiMessage)) !== null) {
+ const a = _parseSheetAttrs(match[1]);
+ if (!a.spreadsheetId || !a.range) {
+ report.push(`❌ Sheet Read: spreadsheet_id / range 누락`);
+ continue;
+ }
+ try {
+ const { readSheetRange, valuesToMarkdownTable } = await import('./features/sheets');
+ const r = await readSheetRange(this.context, a.spreadsheetId, a.range);
+ if (r.ok) {
+ const md = valuesToMarkdownTable(r.values);
+ report.push(`📊 Sheet Read: ${a.spreadsheetId.slice(0, 8)}…/${r.range} (${r.values.length} rows)`);
+ this.chatHistory.push({
+ role: 'system',
+ content: `[Sheet read ${r.range}]\n${md}`,
+ internal: true,
+ });
+ } else {
+ report.push(`❌ Sheet Read Failed: ${r.error}`);
+ }
+ } catch (err: any) { report.push(`❌ Sheet Read Error: ${err?.message ?? String(err)}`); }
+ }
+ const sheetWriteRegex = /]*)>([\s\S]*?)<\/write_sheet>/gi;
+ while ((match = sheetWriteRegex.exec(aiMessage)) !== null) {
+ const a = _parseSheetAttrs(match[1]);
+ const body = match[2];
+ if (!a.spreadsheetId || !a.range) {
+ report.push(`❌ Sheet Write: spreadsheet_id / range 누락`);
+ continue;
+ }
+ try {
+ const { writeSheetRange, parseTsvBody } = await import('./features/sheets');
+ const values = parseTsvBody(body);
+ if (values.length === 0) {
+ report.push(`❌ Sheet Write: 본문 비어있음`);
+ continue;
+ }
+ const r = await writeSheetRange(this.context, a.spreadsheetId, a.range, values);
+ if (r.ok) {
+ report.push(`📊 Sheet Write: ${r.updatedRange} (${r.updatedCells} cells)`);
+ } else {
+ report.push(`❌ Sheet Write Failed: ${r.error}`);
+ }
+ } catch (err: any) { report.push(`❌ Sheet Write Error: ${err?.message ?? String(err)}`); }
+ }
+ const sheetAppendRegex = /]*)>([\s\S]*?)<\/append_sheet>/gi;
+ while ((match = sheetAppendRegex.exec(aiMessage)) !== null) {
+ const a = _parseSheetAttrs(match[1]);
+ const body = match[2];
+ if (!a.spreadsheetId || !a.range) {
+ report.push(`❌ Sheet Append: spreadsheet_id / range 누락`);
+ continue;
+ }
+ try {
+ const { appendSheetRows, parseTsvBody } = await import('./features/sheets');
+ const values = parseTsvBody(body);
+ if (values.length === 0) {
+ report.push(`❌ Sheet Append: 본문 비어있음`);
+ continue;
+ }
+ const r = await appendSheetRows(this.context, a.spreadsheetId, a.range, values);
+ if (r.ok) {
+ report.push(`📊 Sheet Append: ${r.appendedRange} (${r.updatedCells} cells)`);
+ } else {
+ report.push(`❌ Sheet Append Failed: ${r.error}`);
+ }
+ } catch (err: any) { report.push(`❌ Sheet Append Error: ${err?.message ?? String(err)}`); }
+ }
+
+ // Action 13/14/15: Task tracker — _shared/tasks.md 에 누적.
+ // 회의록·계획·작업 진척 추적의 단일 출처. status: open/in_progress/blocked/done.
+ //
+ //
+ //
+ const addTaskRegex = //]*?)\s*\/>/gi;
+ while ((match = addTaskRegex.exec(aiMessage)) !== null) {
+ const a = _parseTaskAttrs(match[1]);
+ if (!a.title) { report.push(`❌ Add Task: title 누락`); continue; }
+ try {
+ const { readTaskStore, writeTaskStore, addTask } = await import('./features/tasks');
+ const store = readTaskStore(this.context);
+ const created = addTask(store, {
+ title: a.title,
+ owner: a.owner,
+ due: a.due,
+ notes: a.notes,
+ status: a.status,
+ });
+ writeTaskStore(this.context, store);
+ report.push(`📋 Task Added: ${created.id} · ${created.title}${created.due ? ' (due ' + created.due + ')' : ''}`);
+ } catch (err: any) { report.push(`❌ Add Task Error: ${err?.message ?? String(err)}`); }
+ }
+ const updTaskRegex = //]*?)\s*\/>/gi;
+ while ((match = updTaskRegex.exec(aiMessage)) !== null) {
+ const a = _parseTaskAttrs(match[1]);
+ if (!a.id) { report.push(`❌ Update Task: id 누락`); continue; }
+ try {
+ const { readTaskStore, writeTaskStore, updateTask } = await import('./features/tasks');
+ const store = readTaskStore(this.context);
+ const patch: any = {};
+ if (a.title) patch.title = a.title;
+ if (a.owner) patch.owner = a.owner;
+ if (a.due) patch.due = a.due;
+ if (a.notes) patch.notes = a.notes;
+ if (a.status) patch.status = a.status;
+ const updated = updateTask(store, a.id, patch);
+ if (!updated) {
+ report.push(`❌ Update Task: ${a.id} 를 active 목록에서 못 찾음`);
+ } else {
+ writeTaskStore(this.context, store);
+ report.push(`📋 Task Updated: ${updated.id} → ${updated.status}${updated.due ? ' (due ' + updated.due + ')' : ''}`);
+ }
+ } catch (err: any) { report.push(`❌ Update Task Error: ${err?.message ?? String(err)}`); }
+ }
+ const compTaskRegex = //]*?)\s*\/>/gi;
+ while ((match = compTaskRegex.exec(aiMessage)) !== null) {
+ const a = _parseTaskAttrs(match[1]);
+ if (!a.id) { report.push(`❌ Complete Task: id 누락`); continue; }
+ try {
+ const { readTaskStore, writeTaskStore, completeTask } = await import('./features/tasks');
+ const store = readTaskStore(this.context);
+ const closed = completeTask(store, a.id);
+ if (!closed) {
+ report.push(`❌ Complete Task: ${a.id} 못 찾음 (이미 done 이거나 존재 X)`);
+ } else {
+ writeTaskStore(this.context, store);
+ report.push(`✅ Task Done: ${closed.id} · ${closed.title}`);
+ }
+ } catch (err: any) { report.push(`❌ Complete Task Error: ${err?.message ?? String(err)}`); }
+ }
+
if (firstCreatedFile) {
// Always open file results in the editor group (column 2) — the ConnectAI
// sidebar lives in column 3 and we don't want freshly-written files to
@@ -3369,3 +3548,103 @@ export class AgentExecutor {
}
}
}
+
+/**
+ * / / 의 attribute 파서.
+ * 모든 필드 optional 로 받고 caller 가 필수 체크. status 는 정규화 (in_progress, 등).
+ */
+export function _parseTaskAttrs(raw: string): {
+ id?: string;
+ title?: string;
+ owner?: string;
+ due?: string;
+ notes?: string;
+ status?: import('./features/tasks').TaskStatus;
+} {
+ const out: any = {};
+ const re = /([\w-]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/g;
+ let m: RegExpExecArray | null;
+ while ((m = re.exec(raw)) !== null) {
+ const key = m[1].toLowerCase();
+ const val = (m[2] ?? m[3] ?? m[4] ?? '').trim();
+ if (!val) continue;
+ switch (key) {
+ case 'id': out.id = val; break;
+ case 'title': out.title = val; break;
+ case 'owner': out.owner = val; break;
+ case 'due': out.due = val; break;
+ case 'notes': out.notes = val; break;
+ case 'status': {
+ const v = val.toLowerCase().replace(/\s+/g, '_');
+ if (v === 'in_progress' || v === 'inprogress' || v === 'progress') out.status = 'in_progress';
+ else if (v === 'blocked' || v === 'block') out.status = 'blocked';
+ else if (v === 'done' || v === 'completed' || v === 'closed') out.status = 'done';
+ else out.status = 'open';
+ break;
+ }
+ }
+ }
+ return out;
+}
+
+/**
+ * / / 의 attribute 문자열을 객체로 파싱.
+ * spreadsheet_id / spreadsheetId / sheetId 모두 받는 — LLM 의 변형 emission 흡수.
+ */
+export function _parseSheetAttrs(raw: string): { spreadsheetId?: string; range?: string } {
+ const out: { spreadsheetId?: string; range?: string } = {};
+ const re = /([\w-]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/g;
+ let m: RegExpExecArray | null;
+ while ((m = re.exec(raw)) !== null) {
+ const key = m[1].toLowerCase();
+ const val = (m[2] ?? m[3] ?? m[4] ?? '').trim();
+ if (!val) continue;
+ if (key === 'spreadsheet_id' || key === 'spreadsheetid' || key === 'sheet_id' || key === 'sheetid') {
+ out.spreadsheetId = val;
+ } else if (key === 'range') {
+ out.range = val;
+ }
+ }
+ return out;
+}
+
+/**
+ * 의 attribute 문자열을 객체로 파싱.
+ * 큰따옴표 / 작은따옴표 / 따옴표 없이 (공백·`>` 으로 종료) 모두 허용 — LLM 이 어떤
+ * 스타일로 emit 해도 통과시키기 위함. 단위테스트 가능하도록 export.
+ */
+export function _parseCalEventAttrs(raw: string): {
+ title?: string;
+ start?: string;
+ end?: string;
+ duration?: number;
+ location?: string;
+ allDay?: boolean;
+} {
+ const out: any = {};
+ // attr_name = "value" | 'value' | bare. `-` 포함 키 (all-day) 지원.
+ const re = /([\w-]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/g;
+ let m: RegExpExecArray | null;
+ while ((m = re.exec(raw)) !== null) {
+ const key = m[1].toLowerCase();
+ const val = (m[2] ?? m[3] ?? m[4] ?? '').trim();
+ if (!val) continue;
+ switch (key) {
+ case 'title': out.title = val; break;
+ case 'start': out.start = val; break;
+ case 'end': out.end = val; break;
+ case 'duration': {
+ const n = parseInt(val, 10);
+ if (!Number.isNaN(n) && n > 0) out.duration = n;
+ break;
+ }
+ case 'location': out.location = val; break;
+ case 'all_day':
+ case 'allday':
+ case 'all-day':
+ out.allDay = val === 'true' || val === '1' || val === 'yes';
+ break;
+ }
+ }
+ return out;
+}
diff --git a/src/extension.ts b/src/extension.ts
index 5d1d90b..f0b644a 100644
--- a/src/extension.ts
+++ b/src/extension.ts
@@ -664,6 +664,22 @@ export async function activate(context: vscode.ExtensionContext) {
// 같은 pixelOfficeUpdate 메시지 스트림을 공유하므로 백엔드 변경 최소.
provider?.openPixelOfficePanel();
}),
+ // Google Calendar (iCal 읽기 전용) — 셋업 / 재연결 / 해제 / 즉시 새로고침.
+ vscode.commands.registerCommand('g1nation.calendar.connect', async () => {
+ await runConnectGoogleCalendarIcal(context);
+ }),
+ vscode.commands.registerCommand('g1nation.calendar.refresh', async () => {
+ const { refreshCalendarCache } = await import('./features/calendar');
+ const r = await refreshCalendarCache(context);
+ if (r.ok) {
+ vscode.window.showInformationMessage(`📅 캘린더 ${r.count}개 일정을 회사 컨텍스트에 동기화했습니다.`);
+ } else {
+ vscode.window.showErrorMessage(r.error || 'Calendar 새로고침 실패');
+ }
+ }),
+ vscode.commands.registerCommand('g1nation.calendar.connectOAuth', async () => {
+ await runConnectGoogleCalendarOAuth(context);
+ }),
);
/** All lesson/playbook/qa-finding cards in the active brain. Uses the brain index for the lesson-kind
@@ -891,6 +907,197 @@ export async function deactivate() {
}
}
+/**
+ * Google Calendar (iCal 읽기 전용) 연결 마법사.
+ *
+ * 사용자 흐름:
+ * 1. 이미 셋업 됐으면 "연결 해제 / URL 변경 / 지금 새로고침 / 취소" 선택지 노출
+ * 2. 새로 셋업: Google Calendar 설정 페이지 외부 브라우저로 열고 → 비공개 iCal URL 입력
+ * 3. 입력값을 globalState 에 저장 후 즉시 한 번 새로고침 실행 → 캐시 파일 생성 안내
+ *
+ * OAuth 가 아닌 read-only iCal 만 — 셋업 3분, 토큰 관리 없음.
+ */
+async function runConnectGoogleCalendarIcal(context: vscode.ExtensionContext) {
+ const { readCalendarConfig, writeCalendarConfig, refreshCalendarCache } =
+ await import('./features/calendar');
+ const cur = readCalendarConfig(context);
+ if (cur.icalUrl) {
+ const choice = await vscode.window.showInformationMessage(
+ `📅 이미 연결됨${cur.lastFetchAt ? ` (마지막 동기화: ${cur.lastFetchAt.slice(0, 16)})` : ''}`,
+ { modal: false },
+ '지금 새로고침',
+ 'URL 변경',
+ '연결 해제',
+ '취소',
+ );
+ if (!choice || choice === '취소') return;
+ if (choice === '지금 새로고침') {
+ const r = await refreshCalendarCache(context);
+ if (r.ok) vscode.window.showInformationMessage(`📅 ${r.count}개 일정 동기화 완료.`);
+ else vscode.window.showErrorMessage(r.error || '새로고침 실패');
+ return;
+ }
+ if (choice === '연결 해제') {
+ await writeCalendarConfig(context, { icalUrl: '', lastFetchAt: undefined });
+ vscode.window.showInformationMessage('Google Calendar 연결 해제됨. 캐시 파일은 그대로 둡니다.');
+ return;
+ }
+ // URL 변경 → 아래 입력 흐름으로 fall through
+ } else {
+ const intro = await vscode.window.showInformationMessage(
+ '📅 Google Calendar 연결 (읽기 전용, 셋업 3분)\n\n비공개 iCal URL 1개만 있으면 됩니다. OAuth 없음.\n\n계속할까요?',
+ { modal: true },
+ '시작',
+ 'Google Calendar 설정 페이지 열기',
+ '취소',
+ );
+ if (!intro || intro === '취소') return;
+ if (intro === 'Google Calendar 설정 페이지 열기') {
+ await vscode.env.openExternal(
+ vscode.Uri.parse('https://calendar.google.com/calendar/u/0/r/settings'),
+ );
+ const back = await vscode.window.showInformationMessage(
+ '1. 왼쪽에서 본인 캘린더 클릭 → "캘린더 통합" 섹션\n2. "비공개 주소(iCal 형식)" 옆 복사 버튼 클릭\n3. URL 복사한 뒤 ↓',
+ { modal: true },
+ '복사함 — URL 붙여넣기',
+ '취소',
+ );
+ if (back !== '복사함 — URL 붙여넣기') return;
+ }
+ }
+
+ const url = await vscode.window.showInputBox({
+ title: 'Google Calendar 비공개 iCal URL',
+ prompt: 'calendar.google.com/calendar/ical/.../private-XXX/basic.ics 형태',
+ placeHolder: 'https://calendar.google.com/calendar/ical/...',
+ value: cur.icalUrl,
+ password: true,
+ ignoreFocusOut: true,
+ validateInput: (v) => {
+ const t = (v || '').trim();
+ if (!t) return '비어있어요';
+ if (!/^https?:\/\//.test(t)) return 'http:// 또는 https:// 로 시작해야 합니다.';
+ return null;
+ },
+ });
+ if (!url) return;
+
+ await writeCalendarConfig(context, { icalUrl: url.trim() });
+ const r = await refreshCalendarCache(context);
+ if (r.ok) {
+ vscode.window.showInformationMessage(
+ `✅ 연결 완료 — ${r.count}개 일정을 회사 컨텍스트에 동기화했습니다.\n\n이제 기업 모드에서 모든 에이전트가 다가오는 일정을 자동으로 참고합니다.`,
+ );
+ } else {
+ vscode.window.showErrorMessage(
+ `URL 저장은 됐지만 첫 새로고침 실패: ${r.error}\n\nURL 이 정확한지 다시 확인해주세요.`,
+ );
+ }
+}
+
+/**
+ * Google Calendar OAuth (쓰기) 연결 마법사.
+ *
+ * iCal 마법사와 별도 — 이쪽은 agent 가 회의록 보고 자동으로 일정 *만들* 수 있게 한다.
+ * 셋업 5~10분: Google Cloud Console 에서 OAuth Client ID/Secret 발급 → 본 마법사가
+ * loopback OAuth 흐름 실행 → refresh token 받아 globalState 저장.
+ */
+async function runConnectGoogleCalendarOAuth(context: vscode.ExtensionContext) {
+ const { readCalendarConfig, writeCalendarConfig, runOAuthLoopback, fetchUserEmail } =
+ await import('./features/calendar');
+ const cur = readCalendarConfig(context);
+ const already = !!(cur.clientId && cur.clientSecret && cur.refreshToken);
+ if (already) {
+ const choice = await vscode.window.showInformationMessage(
+ `✅ 이미 OAuth 연결됨${cur.connectedAs ? ` (${cur.connectedAs})` : ''}`,
+ { modal: false },
+ '재연결',
+ '연결 해제',
+ '취소',
+ );
+ if (!choice || choice === '취소') return;
+ if (choice === '연결 해제') {
+ await writeCalendarConfig(context, {
+ clientId: undefined, clientSecret: undefined, refreshToken: undefined,
+ accessToken: undefined, accessTokenExpiresAt: undefined,
+ connectedAs: undefined, connectedAt: undefined,
+ });
+ vscode.window.showInformationMessage(
+ 'OAuth 연결 해제. https://myaccount.google.com/permissions 에서도 권한을 직접 회수할 수 있습니다.',
+ );
+ return;
+ }
+ // 재연결 → 아래 flow
+ } else {
+ const intro = await vscode.window.showInformationMessage(
+ '📅 Google Calendar 쓰기 연결 (OAuth, 5~10분)\n\n회의록을 받으면 agent 가 자동으로 일정을 생성하게 됩니다.\n\n1단계: Google Cloud Console 에서 OAuth Client ID 발급 (수동 클릭, 가이드 따라)\n2단계: ID + Secret 붙여넣기\n3단계: 브라우저 로그인',
+ { modal: true },
+ '시작',
+ 'Cloud Console 먼저 열기',
+ '취소',
+ );
+ if (!intro || intro === '취소') return;
+ if (intro === 'Cloud Console 먼저 열기') {
+ await vscode.env.openExternal(vscode.Uri.parse('https://console.cloud.google.com/apis/credentials'));
+ const back = await vscode.window.showInformationMessage(
+ '아래 절차 마치고 돌아오세요:\n\n1. 새 프로젝트 만들기 (또는 기존)\n2. APIs & Services → Library → "Google Calendar API" 활성화\n3. OAuth 동의 화면 — External, Test users 에 본인 이메일\n4. Credentials → Create OAuth 2.0 Client ID → "Desktop app"\n5. Client ID + Client Secret 복사',
+ { modal: true },
+ '다 됐음 →',
+ '취소',
+ );
+ if (back !== '다 됐음 →') return;
+ }
+ }
+
+ const clientId = await vscode.window.showInputBox({
+ title: 'Google OAuth Client ID',
+ prompt: 'Credentials 페이지에서 복사한 Client ID',
+ placeHolder: 'xxxxxxxx.apps.googleusercontent.com',
+ value: cur.clientId,
+ ignoreFocusOut: true,
+ validateInput: (v) => (v || '').trim() ? null : '비어있어요',
+ });
+ if (!clientId) return;
+ const clientSecret = await vscode.window.showInputBox({
+ title: 'Google OAuth Client Secret',
+ prompt: '같은 화면의 Client Secret',
+ placeHolder: 'GOCSPX-...',
+ value: cur.clientSecret,
+ password: true,
+ ignoreFocusOut: true,
+ validateInput: (v) => (v || '').trim() ? null : '비어있어요',
+ });
+ if (!clientSecret) return;
+
+ await vscode.window.withProgress({
+ location: vscode.ProgressLocation.Notification,
+ title: '🔐 Google 로그인 대기 중…',
+ cancellable: true,
+ }, async (progress, cancelToken) => {
+ progress.report({ message: '브라우저에서 Google 로그인 진행하세요 (최대 5분 대기)' });
+ const result = await runOAuthLoopback(clientId.trim(), clientSecret.trim(), cancelToken);
+ if (!result.ok) {
+ vscode.window.showErrorMessage(`OAuth 실패: ${result.error}`);
+ return;
+ }
+ const email = await fetchUserEmail(result.accessToken);
+ await writeCalendarConfig(context, {
+ clientId: clientId.trim(),
+ clientSecret: clientSecret.trim(),
+ refreshToken: result.refreshToken,
+ accessToken: result.accessToken,
+ accessTokenExpiresAt: result.expiresAt,
+ calendarId: cur.calendarId ?? 'primary',
+ defaultDurationMinutes: cur.defaultDurationMinutes ?? 60,
+ connectedAs: email,
+ connectedAt: new Date().toISOString(),
+ });
+ vscode.window.showInformationMessage(
+ `✅ Google Calendar 쓰기 연결 완료!${email ? ' (' + email + ')' : ''}\n\n이제 회의록을 보내거나 due 가 있는 작업을 알려주면 agent 가 자동으로 일정을 생성합니다.`,
+ );
+ });
+}
+
async function runInitialSetup(context: vscode.ExtensionContext) {
// 이미 사용자가 URL을 설정했다면 자동 감지를 스킵
const existingUrl = vscode.workspace.getConfiguration('g1nation').get('ollamaUrl');
diff --git a/src/features/astraOffice/index.ts b/src/features/astraOffice/index.ts
new file mode 100644
index 0000000..e194d70
--- /dev/null
+++ b/src/features/astraOffice/index.ts
@@ -0,0 +1,18 @@
+// Astra Office — public API. 다음 세션에서 추가될 OfficeSnapshot presenter / schema
+// 도 같은 entry 로 노출 예정.
+//
+// 현재 노출: full webview panel HTML 생성 함수. sidebarProvider.ts 는 이 한 줄만 import.
+
+export { renderAstraOfficePanelHtml } from './view/panelHtml';
+export type { AstraOfficePanelAssets } from './view/panelHtml';
+export type {
+ OfficeSnapshot,
+ OfficeAgentSnapshot,
+ OfficePhase,
+ OfficeActivityItem,
+ OfficeBubbleSeed,
+} from './schema';
+export { validateOfficeSnapshot, makeEmptyOfficeSnapshot } from './schema';
+export { presentOfficeSnapshot } from './presenter';
+export type { LayoutV2, OfficeDeskCell, OfficeProp } from './view/layoutSchema';
+export { validateLayout, migrateLayout } from './view/layoutSchema';
diff --git a/src/features/astraOffice/presenter.ts b/src/features/astraOffice/presenter.ts
new file mode 100644
index 0000000..46bbdb2
--- /dev/null
+++ b/src/features/astraOffice/presenter.ts
@@ -0,0 +1,181 @@
+/**
+ * Presenter — 옛 AgentWorkState + bubble queue + activity items 를 OfficeSnapshot 으로
+ * 변환하는 *pure* 함수. mini / full view 둘 다 같은 OfficeSnapshot 을 받게 만드는 게 목표.
+ *
+ * 이번 세션의 범위: 인터페이스 + 스텁. 실제 wiring 은 다음 세션에서:
+ * - sidebarProvider 의 `_pixelOfficeBroadcast` 가 옛 message 대신 OfficeSnapshot 송신
+ * - mini view (media/sidebar.js) 와 full view (features/astraOffice/view/runtime.ts)
+ * 둘 다 `officeSnapshot` message 를 받아 자기 식대로 렌더
+ *
+ * 이번 세션에 옛 message 와 OfficeSnapshot 을 *동시에* 보낼 수도 있게 — 호환 모드. 다음
+ * 세션에서 옛 message 제거.
+ */
+
+import type { AgentWorkState, AgentBubble } from '../company/pixelOfficeState';
+import type { CompanyState } from '../company/types';
+import {
+ type OfficeSnapshot,
+ type OfficeAgentSnapshot,
+ type OfficePhase,
+ type OfficeActivityItem,
+ type OfficeBubbleSeed,
+ makeEmptyOfficeSnapshot,
+} from './schema';
+
+/** mini/full view 가 받는 message envelope. */
+export interface OfficeSnapshotMessage {
+ type: 'officeSnapshot';
+ value: OfficeSnapshot;
+}
+
+/** 옛 AgentWorkState.status → OfficePhase 매핑. */
+const STATUS_TO_PHASE: Record = {
+ idle: 'idle',
+ intake: 'intake',
+ analyzing: 'planning',
+ need_clarification: 'awaiting-approval',
+ contract_ready: 'planning',
+ planning: 'planning',
+ executing: 'executing',
+ reviewing: 'reviewing',
+ waiting_approval: 'awaiting-approval',
+ error: 'error',
+ done: 'done',
+};
+
+/** agentId 가 alias 면 정식 id 로 정규화. dispatcher / agents.ts 와 같은 규칙. */
+const AGENT_ALIASES: Record = {
+ writer: 'writer',
+ editor: 'designer',
+ secretary: 'support',
+ business: 'inspector',
+};
+
+export function normalizeAgentId(rawAgentId: string | undefined): string | null {
+ if (!rawAgentId) return null;
+ const lower = rawAgentId.toLowerCase();
+ return AGENT_ALIASES[lower] ?? lower;
+}
+
+/**
+ * 옛 입력들을 합쳐 새 OfficeSnapshot 을 만든다. 입력 중 일부가 undefined 라도 안전.
+ *
+ * 이번 세션 stub: 옛 AgentWorkState 의 *단일 슬롯* 정보로 roster 1개 짜리 snapshot 만
+ * 생성. 다음 세션에서 CompanyState 의 active roster 전체로 확장.
+ */
+export function presentOfficeSnapshot(input: {
+ activeState?: AgentWorkState;
+ recentBubbles?: AgentBubble[];
+ recentActivity?: OfficeActivityItem[];
+ company?: CompanyState;
+ /**
+ * 이 turn 의 active agent roster — `listActiveAgentsByCategory` 결과를 평탄화해서 전달.
+ * 빈 배열/undefined 면 옛 동작 (active agent 1명만) 으로 fallback.
+ */
+ roster?: Array<{ agentId: string; agentName: string; roleCategory: OfficeAgentSnapshot['roleCategory'] }>;
+}): OfficeSnapshot {
+ const snap = makeEmptyOfficeSnapshot();
+ const { activeState, recentBubbles, recentActivity, roster: rosterInput } = input;
+
+ if (activeState) {
+ const phase = STATUS_TO_PHASE[activeState.status] ?? 'idle';
+ snap.phase = phase;
+ snap.activeAgentId = normalizeAgentId(activeState.agentId);
+ const activeId = snap.activeAgentId;
+ // Roster:
+ // - rosterInput 이 주어지면 (#G) 회사 전체 active agent 사용. active agent 만
+ // activeState 의 상태로 표시하고 나머지는 idle.
+ // - 없으면 (옛 caller) 활성 agent 1명만 fallback.
+ if (rosterInput && rosterInput.length > 0) {
+ const now = Date.now();
+ snap.roster = rosterInput.map((r) => {
+ const isActive = activeId !== null && r.agentId === activeId;
+ return {
+ agentId: r.agentId,
+ agentName: r.agentName,
+ roleCategory: r.roleCategory,
+ status: isActive ? activeState.status : 'idle',
+ currentStep: isActive ? activeState.currentStep : undefined,
+ lastLog: isActive ? (activeState.recentLogs ?? []).slice(-1)[0] : undefined,
+ lastActivityAt: isActive ? (activeState.updatedAt ?? now) : 0,
+ };
+ });
+ } else {
+ const role = _inferRoleCategory(activeState.agentId);
+ const agent: OfficeAgentSnapshot = {
+ agentId: snap.activeAgentId ?? activeState.agentId,
+ agentName: activeState.agentName ?? activeState.agentId,
+ roleCategory: role,
+ status: activeState.status,
+ currentStep: activeState.currentStep,
+ lastLog: (activeState.recentLogs ?? []).slice(-1)[0],
+ lastActivityAt: activeState.updatedAt ?? Date.now(),
+ };
+ snap.roster = [agent];
+ }
+ if (activeState.currentTask) {
+ snap.task = {
+ goal: activeState.currentTask,
+ criteria: activeState.requirementContract?.criteria,
+ openQuestions: activeState.requirementContract?.openQuestions,
+ format: activeState.requirementContract?.format,
+ context: activeState.requirementContract?.context,
+ };
+ }
+ if (activeState.pipelineStages) {
+ snap.pipeline = {
+ stages: activeState.pipelineStages.map((s) => ({
+ label: s.label,
+ agentId: s.agent,
+ status: s.status,
+ })),
+ index: activeState.pipelineStages.findIndex((s) => s.status === 'active'),
+ };
+ }
+ if (activeState.needUserInput?.length || activeState.awaitingApproval) {
+ snap.awaiting = {
+ kind: activeState.awaitingApproval ? 'approval' : 'clarification',
+ questions: activeState.awaitingApproval
+ ? [activeState.awaitingApproval]
+ : (activeState.needUserInput ?? []),
+ };
+ }
+ }
+
+ if (recentActivity?.length) snap.activity = recentActivity.slice(-32);
+
+ if (recentBubbles?.length) {
+ snap.newBubbles = recentBubbles
+ .map((b) => _toBubbleSeed(b))
+ .filter((b): b is OfficeBubbleSeed => b !== null);
+ }
+
+ snap.updatedAt = Date.now();
+ return snap;
+}
+
+// ── helpers ──
+
+function _inferRoleCategory(rawAgentId: string | undefined): OfficeAgentSnapshot['roleCategory'] {
+ if (!rawAgentId) return 'support';
+ const id = rawAgentId.toLowerCase();
+ if (id.includes('ceo')) return 'ceo';
+ if (id.includes('plan')) return 'planner';
+ if (id.includes('research')) return 'researcher';
+ if (id.includes('design')) return 'designer';
+ if (id.includes('writer')) return 'writer';
+ if (id.includes('editor')) return 'designer';
+ if (id.includes('developer') || id.includes('dev')) return 'developer';
+ if (id.includes('qa')) return 'qa';
+ if (id.includes('inspect') || id.includes('business')) return 'inspector';
+ return 'support';
+}
+
+function _toBubbleSeed(b: AgentBubble): OfficeBubbleSeed | null {
+ if (!b || !b.text) return null;
+ return {
+ agentId: normalizeAgentId(b.agentId) ?? b.agentId,
+ text: b.text,
+ type: (b.type as OfficeBubbleSeed['type']) ?? 'status',
+ };
+}
diff --git a/src/features/astraOffice/schema.ts b/src/features/astraOffice/schema.ts
new file mode 100644
index 0000000..dc20ce8
--- /dev/null
+++ b/src/features/astraOffice/schema.ts
@@ -0,0 +1,305 @@
+/**
+ * OfficeSnapshot — Astra Office 의 도메인 타입.
+ *
+ * 동시성 진실 (docs/ASTRA_OFFICE_REFACTOR.md §1): dispatcher 는 직렬이라 한 시점에
+ * active agent 는 0 또는 1명. 이걸 데이터로 강제하는 게 이 타입의 핵심 역할.
+ *
+ * 이 세션에서는 *타입 + validator + empty factory* 만. 백엔드 emit 측 wiring 은
+ * 다음 세션에서 단계적으로 옮긴다. 현재는 옛 AgentWorkState/CompanyTurnEvent 가
+ * presenter 입력으로 살아있고, 출력만 OfficeSnapshot.
+ */
+
+import type { AgentStatus } from '../company/pixelOfficeState';
+
+export type OfficePhase =
+ | 'idle'
+ | 'intake'
+ | 'planning'
+ | 'executing'
+ | 'reviewing'
+ | 'awaiting-approval'
+ | 'reporting'
+ | 'done'
+ | 'error';
+
+/** 한 명의 agent 가 한 시점에 가진 상태. */
+export interface OfficeAgentSnapshot {
+ /** company roster 의 정식 id (built-in 또는 custom). */
+ agentId: string;
+ /** 표시 이름. */
+ agentName: string;
+ /** 책상 색깔 / 매핑에 쓰이는 role category. */
+ roleCategory:
+ | 'ceo'
+ | 'planner'
+ | 'researcher'
+ | 'designer'
+ | 'developer'
+ | 'qa'
+ | 'inspector'
+ | 'support'
+ | 'writer';
+ status: AgentStatus;
+ /** "검수 라운드 2/3", "타입 누락" 같은 짧은 부가 정보. */
+ currentStep?: string;
+ /** 머리 위 말풍선 source. presenter 가 비워서 보내면 webview 는 풍선 안 띄움. */
+ lastLog?: string;
+ /** 정렬 / 신선도 판정. */
+ lastActivityAt: number;
+}
+
+export interface OfficeActivityItem {
+ /** epoch ms. */
+ ts: number;
+ agentId: string;
+ text: string;
+ kind?: 'ok' | 'warn' | 'err' | 'info';
+}
+
+/** webview 가 풍선을 띄울 때 참고할 hint. presenter 가 매 update 마다 새 풍선 후보를 만들어 보냄. */
+export interface OfficeBubbleSeed {
+ agentId: string;
+ text: string;
+ /** 색깔/스타일. presenter 가 status/event/warning/error/success 중 분류. */
+ type: 'status' | 'event' | 'warning' | 'error' | 'success';
+}
+
+export interface OfficeSnapshot {
+ /** 회사 전체 phase. activeAgent 가 어떤 단계에 있는지와 분리해서 추적. */
+ phase: OfficePhase;
+ /** 활성 agent id — 직렬 dispatch 가정. null 이면 idle. */
+ activeAgentId: string | null;
+ /** 이 turn 에 참여 가능한 모든 agent. webview 의 책상 그리드 source. */
+ roster: OfficeAgentSnapshot[];
+ /** 현재 요구사항 / 계약 요약. */
+ task?: {
+ goal: string;
+ context?: string;
+ format?: string;
+ criteria?: string[];
+ openQuestions?: string[];
+ };
+ /** 파이프라인 모드의 stage 진행도. */
+ pipeline?: {
+ stages: Array<{
+ label: string;
+ agentId?: string;
+ status: 'done' | 'active' | 'pending';
+ }>;
+ index: number;
+ };
+ /** 승인 / 추가 정보 대기. */
+ awaiting?: {
+ kind: 'approval' | 'clarification';
+ questions: string[];
+ };
+ /** 누적 활동 ring buffer — webview ticker 의 source. */
+ activity: OfficeActivityItem[];
+ /** 이 update 사이클에서 새로 생긴 풍선 후보. presenter 가 매 emit 마다 갱신. */
+ newBubbles: OfficeBubbleSeed[];
+ /** 디버깅/정렬용. */
+ updatedAt: number;
+}
+
+const VALID_PHASES: ReadonlySet = new Set([
+ 'idle',
+ 'intake',
+ 'planning',
+ 'executing',
+ 'reviewing',
+ 'awaiting-approval',
+ 'reporting',
+ 'done',
+ 'error',
+]);
+
+const VALID_ROLES: ReadonlySet = new Set([
+ 'ceo',
+ 'planner',
+ 'researcher',
+ 'designer',
+ 'developer',
+ 'qa',
+ 'inspector',
+ 'support',
+ 'writer',
+]);
+
+const VALID_STATUSES: ReadonlySet = new Set([
+ 'idle',
+ 'intake',
+ 'analyzing',
+ 'need_clarification',
+ 'contract_ready',
+ 'planning',
+ 'executing',
+ 'reviewing',
+ 'waiting_approval',
+ 'error',
+ 'done',
+]);
+
+const VALID_BUBBLE_TYPES = new Set([
+ 'status',
+ 'event',
+ 'warning',
+ 'error',
+ 'success',
+]);
+
+const VALID_KINDS = new Set>([
+ 'ok',
+ 'warn',
+ 'err',
+ 'info',
+]);
+
+/**
+ * 런타임 schema validation. 임의의 unknown 입력을 받아 *완전한 OfficeSnapshot* 또는
+ * null 을 반환한다. 누락된 필드는 안전한 기본값으로 채운다. 잘못된 enum 값은 'idle'/
+ * 기본 role 로 fall through. 호출자는 null 이면 default snapshot 으로 폴백 권장.
+ */
+export function validateOfficeSnapshot(raw: unknown): OfficeSnapshot | null {
+ if (!raw || typeof raw !== 'object') return null;
+ const r = raw as Record;
+
+ const phaseRaw = String(r.phase ?? 'idle');
+ const phase: OfficePhase = (VALID_PHASES as ReadonlySet).has(phaseRaw)
+ ? (phaseRaw as OfficePhase)
+ : 'idle';
+
+ const activeAgentId =
+ typeof r.activeAgentId === 'string' && r.activeAgentId.length > 0
+ ? r.activeAgentId
+ : null;
+
+ const rosterRaw = Array.isArray(r.roster) ? r.roster : [];
+ const roster: OfficeAgentSnapshot[] = rosterRaw
+ .map((a) => _validateAgent(a))
+ .filter((a): a is OfficeAgentSnapshot => a !== null);
+
+ const taskRaw = r.task as Record | undefined;
+ const task = taskRaw && typeof taskRaw === 'object' && typeof taskRaw.goal === 'string'
+ ? {
+ goal: taskRaw.goal,
+ context: _optStr(taskRaw.context),
+ format: _optStr(taskRaw.format),
+ criteria: Array.isArray(taskRaw.criteria)
+ ? taskRaw.criteria.filter((x): x is string => typeof x === 'string')
+ : undefined,
+ openQuestions: Array.isArray(taskRaw.openQuestions)
+ ? taskRaw.openQuestions.filter((x): x is string => typeof x === 'string')
+ : undefined,
+ }
+ : undefined;
+
+ const pipelineRaw = r.pipeline as Record | undefined;
+ const pipeline = pipelineRaw && Array.isArray(pipelineRaw.stages)
+ ? {
+ stages: pipelineRaw.stages
+ .map((s) => _validateStage(s))
+ .filter((s): s is { label: string; agentId?: string; status: 'done' | 'active' | 'pending' } => s !== null),
+ index: typeof pipelineRaw.index === 'number' ? pipelineRaw.index : 0,
+ }
+ : undefined;
+
+ const awaitingRaw = r.awaiting as Record | undefined;
+ const awaiting = awaitingRaw && (awaitingRaw.kind === 'approval' || awaitingRaw.kind === 'clarification')
+ ? {
+ kind: awaitingRaw.kind as 'approval' | 'clarification',
+ questions: Array.isArray(awaitingRaw.questions)
+ ? awaitingRaw.questions.filter((x): x is string => typeof x === 'string')
+ : [],
+ }
+ : undefined;
+
+ const activity: OfficeActivityItem[] = Array.isArray(r.activity)
+ ? r.activity
+ .map((it) => _validateActivity(it))
+ .filter((it): it is OfficeActivityItem => it !== null)
+ : [];
+
+ const newBubbles: OfficeBubbleSeed[] = Array.isArray(r.newBubbles)
+ ? r.newBubbles
+ .map((b) => _validateBubble(b))
+ .filter((b): b is OfficeBubbleSeed => b !== null)
+ : [];
+
+ const updatedAt = typeof r.updatedAt === 'number' ? r.updatedAt : Date.now();
+
+ return { phase, activeAgentId, roster, task, pipeline, awaiting, activity, newBubbles, updatedAt };
+}
+
+export function makeEmptyOfficeSnapshot(): OfficeSnapshot {
+ return {
+ phase: 'idle',
+ activeAgentId: null,
+ roster: [],
+ activity: [],
+ newBubbles: [],
+ updatedAt: Date.now(),
+ };
+}
+
+// ─────────── helpers ───────────
+
+function _optStr(v: unknown): string | undefined {
+ return typeof v === 'string' ? v : undefined;
+}
+
+function _validateAgent(raw: unknown): OfficeAgentSnapshot | null {
+ if (!raw || typeof raw !== 'object') return null;
+ const a = raw as Record;
+ if (typeof a.agentId !== 'string' || !a.agentId) return null;
+
+ const role = typeof a.roleCategory === 'string' && (VALID_ROLES as ReadonlySet).has(a.roleCategory)
+ ? (a.roleCategory as OfficeAgentSnapshot['roleCategory'])
+ : 'support';
+ const status: AgentStatus = typeof a.status === 'string' && (VALID_STATUSES as ReadonlySet).has(a.status)
+ ? (a.status as AgentStatus)
+ : 'idle';
+
+ return {
+ agentId: a.agentId,
+ agentName: typeof a.agentName === 'string' ? a.agentName : a.agentId,
+ roleCategory: role,
+ status,
+ currentStep: _optStr(a.currentStep),
+ lastLog: _optStr(a.lastLog),
+ lastActivityAt: typeof a.lastActivityAt === 'number' ? a.lastActivityAt : 0,
+ };
+}
+
+function _validateStage(raw: unknown): { label: string; agentId?: string; status: 'done' | 'active' | 'pending' } | null {
+ if (!raw || typeof raw !== 'object') return null;
+ const s = raw as Record;
+ if (typeof s.label !== 'string') return null;
+ const statusRaw = String(s.status ?? 'pending');
+ const status = (statusRaw === 'done' || statusRaw === 'active' || statusRaw === 'pending') ? statusRaw : 'pending';
+ return { label: s.label, agentId: _optStr(s.agentId), status };
+}
+
+function _validateActivity(raw: unknown): OfficeActivityItem | null {
+ if (!raw || typeof raw !== 'object') return null;
+ const a = raw as Record;
+ if (typeof a.text !== 'string' || typeof a.agentId !== 'string') return null;
+ const kind = typeof a.kind === 'string' && (VALID_KINDS as ReadonlySet).has(a.kind)
+ ? (a.kind as OfficeActivityItem['kind'])
+ : undefined;
+ return {
+ ts: typeof a.ts === 'number' ? a.ts : Date.now(),
+ agentId: a.agentId,
+ text: a.text,
+ kind,
+ };
+}
+
+function _validateBubble(raw: unknown): OfficeBubbleSeed | null {
+ if (!raw || typeof raw !== 'object') return null;
+ const b = raw as Record;
+ if (typeof b.agentId !== 'string' || typeof b.text !== 'string') return null;
+ const type = typeof b.type === 'string' && (VALID_BUBBLE_TYPES as ReadonlySet).has(b.type)
+ ? (b.type as OfficeBubbleSeed['type'])
+ : 'status';
+ return { agentId: b.agentId, text: b.text, type };
+}
diff --git a/src/features/astraOffice/view/layoutSchema.ts b/src/features/astraOffice/view/layoutSchema.ts
new file mode 100644
index 0000000..491a2db
--- /dev/null
+++ b/src/features/astraOffice/view/layoutSchema.ts
@@ -0,0 +1,180 @@
+/**
+ * Pixel Office layout 저장 스키마 — workspaceState 의 `g1nation.pixelOfficeLayout` 키
+ * 에 저장되는 객체의 런타임 validator + v1 → v2 migration.
+ *
+ * 옛 runtime.ts 의 `_isV2Snap()` heuristic 을 정식 schema 로 격상. webview 에서 받는
+ * 즉시 한 번 통과시키면 깨진 데이터 / 옛 데이터 모두 안전하게 처리된다.
+ *
+ * 백엔드는 unknown 그대로 저장하지만, *로드 직후* 이 validator 를 적용해 정규화한다.
+ */
+
+export interface OfficeDeskCell {
+ /** 안정적 식별자 — DOM dataset.role 로도 쓰임. */
+ roleKey: string;
+ /** 매핑된 agent id. 비어있으면 unmapped. */
+ agentKey: string;
+ label: string;
+ charRow: number; // 0~7
+ deskSprite: string;
+ /** 앉은 face. */
+ face: 'L' | 'R' | 'U' | 'D';
+ boss: boolean;
+ /** seat 에서 잠시 일어났다 가는 dock 좌표. */
+ dock?: [number, number];
+ /** 랜덤 roam 후보 좌표들. */
+ roam?: Array<[number, number]>;
+ deskX: number;
+ deskY: number;
+ deskW: number;
+ deskRot: number;
+ deskZ: number;
+ seatX: number;
+ seatY: number;
+ charRot: number;
+ charZ: number;
+ /** 캐릭터를 지운 빈 책상. */
+ noChar: boolean;
+}
+
+export interface OfficeProp {
+ id: string;
+ name: string;
+ x: number;
+ y: number;
+ w?: number;
+ rot: number;
+ z: number;
+}
+
+export interface LayoutV2 {
+ schema: 2;
+ cells: OfficeDeskCell[];
+ objs: OfficeProp[];
+}
+
+const VALID_FACES = new Set(['L', 'R', 'U', 'D']);
+
+/**
+ * raw 가 valid v2 layout 이면 정규화된 LayoutV2 를, 아니면 null.
+ * v1 (옛 좌표만 있는 포맷) 은 별도 `migrateLayout()` 사용.
+ */
+export function validateLayout(raw: unknown): LayoutV2 | null {
+ if (!raw || typeof raw !== 'object') return null;
+ const r = raw as Record;
+ if (!Array.isArray(r.cells)) return null;
+
+ const isV2 = r.schema === 2 || r.cells.some(
+ (c) =>
+ c && typeof c === 'object' &&
+ (typeof (c as Record).deskSprite === 'string'
+ || typeof (c as Record).agentKey === 'string'
+ || typeof (c as Record).charRow === 'number'),
+ );
+ if (!isV2) return null;
+
+ const cells = r.cells.map((c) => _normalizeCell(c)).filter((c): c is OfficeDeskCell => c !== null);
+ const objsRaw = Array.isArray(r.objs) ? r.objs : [];
+ const objs = objsRaw.map((o) => _normalizeProp(o)).filter((o): o is OfficeProp => o !== null);
+
+ return { schema: 2, cells, objs };
+}
+
+/**
+ * v1 (옛 좌표 패치 포맷) → v2 (전체 station 정의) 마이그레이션. v1 은 좌표만 갖고 있어
+ * default station 의 나머지 필드(charRow, deskSprite 등)를 채워줘야 한다. webview 의
+ * default station 매핑이 함께 주어져야 정확. 없으면 best-effort.
+ *
+ * 이번 세션 stub: v1 입력이 들어오면 v2 shape 으로 일단 변환 (default 필드는 0/빈 값).
+ * 다음 세션에서 default station 룩업과 결합.
+ */
+export function migrateLayout(raw: unknown): LayoutV2 | null {
+ const asV2 = validateLayout(raw);
+ if (asV2) return asV2;
+ if (!raw || typeof raw !== 'object') return null;
+ const r = raw as Record;
+ if (!Array.isArray(r.cells)) return null;
+
+ const cells: OfficeDeskCell[] = r.cells
+ .map((cRaw) => {
+ if (!cRaw || typeof cRaw !== 'object') return null;
+ const c = cRaw as Record;
+ if (typeof c.roleKey !== 'string') return null;
+ return _normalizeCell({
+ ...c,
+ // v1 은 charRow / deskSprite 등이 없으니 안전한 기본값.
+ charRow: 0,
+ deskSprite: 'desk-main',
+ face: 'R',
+ boss: false,
+ agentKey: c.roleKey, // 옛 키가 곧 agent 였음.
+ label: c.roleKey,
+ noChar: false,
+ });
+ })
+ .filter((c): c is OfficeDeskCell => c !== null);
+
+ const objs = Array.isArray(r.objs)
+ ? r.objs.map((o) => _normalizeProp(o)).filter((o): o is OfficeProp => o !== null)
+ : [];
+
+ return { schema: 2, cells, objs };
+}
+
+function _normalizeCell(raw: unknown): OfficeDeskCell | null {
+ if (!raw || typeof raw !== 'object') return null;
+ const c = raw as Record;
+ if (typeof c.roleKey !== 'string' || !c.roleKey) return null;
+ const face = typeof c.face === 'string' && (VALID_FACES as ReadonlySet).has(c.face)
+ ? (c.face as OfficeDeskCell['face'])
+ : 'R';
+ return {
+ roleKey: c.roleKey,
+ agentKey: typeof c.agentKey === 'string' ? c.agentKey : '',
+ label: typeof c.label === 'string' ? c.label : c.roleKey,
+ charRow: _num(c.charRow, 0),
+ deskSprite: typeof c.deskSprite === 'string' ? c.deskSprite : 'desk-main',
+ face,
+ boss: !!c.boss,
+ dock: _pair(c.dock),
+ roam: Array.isArray(c.roam)
+ ? (c.roam.map(_pair).filter(Boolean) as Array<[number, number]>)
+ : undefined,
+ deskX: _num(c.deskX, 0),
+ deskY: _num(c.deskY, 0),
+ deskW: _num(c.deskW, 112),
+ deskRot: _num(c.deskRot, 0),
+ deskZ: _num(c.deskZ, 0),
+ seatX: _num(c.seatX, 0),
+ seatY: _num(c.seatY, 0),
+ charRot: _num(c.charRot, 0),
+ charZ: _num(c.charZ, 0),
+ noChar: !!c.noChar,
+ };
+}
+
+function _normalizeProp(raw: unknown): OfficeProp | null {
+ if (!raw || typeof raw !== 'object') return null;
+ const o = raw as Record;
+ if (typeof o.name !== 'string') return null;
+ return {
+ id: typeof o.id === 'string' ? o.id : `obj_${Math.random().toString(36).slice(2, 8)}`,
+ name: o.name,
+ x: _num(o.x, 0),
+ y: _num(o.y, 0),
+ w: typeof o.w === 'number' ? o.w : undefined,
+ rot: _num(o.rot, 0),
+ z: _num(o.z, 0),
+ };
+}
+
+function _num(v: unknown, fallback: number): number {
+ return typeof v === 'number' && Number.isFinite(v) ? v : fallback;
+}
+
+function _pair(v: unknown): [number, number] | undefined {
+ if (!Array.isArray(v) || v.length !== 2) return undefined;
+ const a = typeof v[0] === 'number' ? v[0] : NaN;
+ const b = typeof v[1] === 'number' ? v[1] : NaN;
+ if (!Number.isFinite(a) || !Number.isFinite(b)) return undefined;
+ return [a, b];
+}
diff --git a/src/features/astraOffice/view/officeBody.ts b/src/features/astraOffice/view/officeBody.ts
new file mode 100644
index 0000000..dc5dfbc
--- /dev/null
+++ b/src/features/astraOffice/view/officeBody.ts
@@ -0,0 +1,21 @@
+// 자동 분리: src/sidebarProvider.ts 3984-4001 에서 추출. 동작 동등.
+export const OFFICE_BODY = `
+
+
+
+
+ 드래그로 이동 · R 회전 · ]/[ 레이어 · 4px snap
+
+
+
+
+
+
+
+
+
+작업 —단계 —
+
+
+
+`;
diff --git a/src/features/astraOffice/view/officeStyles.ts b/src/features/astraOffice/view/officeStyles.ts
new file mode 100644
index 0000000..12d9788
--- /dev/null
+++ b/src/features/astraOffice/view/officeStyles.ts
@@ -0,0 +1,121 @@
+// 자동 분리: src/sidebarProvider.ts 3866-3982 에서 추출. 동작 동등.
+// design doc: docs/ASTRA_OFFICE_REFACTOR.md
+export const OFFICE_CSS = `
+
+
+${OFFICE_BODY}
+${officeRuntimeJs(assets.derivedBase)}