@buzzni/saycode-cli 0.3.0 → 0.3.2

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.
Files changed (3) hide show
  1. package/README.md +2 -1
  2. package/index.mjs +484 -94
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -124,7 +124,8 @@ happy agent stop <id> # 사용자가 명시한 경우에만 현재 응
124
124
 
125
125
  `steer` 성공의 `accepted:true`는 세션 RPC가 요청을 받았다는 뜻이며 실제 transcript 전달 완료를
126
126
  뜻하지 않습니다(`delivered:false`). 끝난 턴의 후속 작업에는 `prompt`를 사용합니다. `steer`와
127
- `stop`은 원장으로 소유권이 증명된 최대 2단계 자식만 허용하며, `stop`은 보관·삭제를 하지 않습니다.
127
+ `stop`은 원장에 기록된 최대 2단계 자식만 허용하며, `stop`은 보관·삭제를 하지 않습니다. 이 원장 검사는
128
+ 같은 OS 사용자 아래의 오조작을 줄이는 advisory 가드레일이며 보안 경계나 서버 측 권한 증명은 아닙니다.
128
129
  같은 worktree에는 동시에 둘 이상의 쓰기 자식을 만들지 말고 격리가 필요하면 Desktop의 관리형
129
130
  worktree 병렬 실행을 사용하세요.
130
131
 
package/index.mjs CHANGED
@@ -40,20 +40,20 @@ function _mergeNamespaces(n, m) {
40
40
  }
41
41
  return Object.freeze(Object.defineProperty(n, Symbol.toStringTag, { value: "Module" }));
42
42
  }
43
- const version = "0.3.0";
43
+ const version = "0.3.2";
44
44
  const manifest = {
45
45
  version
46
46
  };
47
47
  const AGENT_CONTROL_VERBS = ["steer", "stop"];
48
- function parseAgentControlArgs(verb, target, rest) {
48
+ function parseAgentControlArgs(verb, target, rest, hasPositionalTarget) {
49
49
  if (verb === "steer") {
50
50
  if (!target) return { command: "error", message: "agent steer needs a session id (see `saycode agent ls`)" };
51
- const text2 = rest.slice(1).join(" ").trim();
51
+ const text2 = rest.slice(hasPositionalTarget ? 1 : 0).join(" ").trim();
52
52
  return text2 ? { command: "agent", verb: "steer", sessionId: target, text: text2 } : { command: "error", message: "agent steer needs the text to send" };
53
53
  }
54
54
  if (verb === "stop") {
55
55
  if (!target) return { command: "error", message: "agent stop needs a session id (see `saycode agent ls`)" };
56
- return rest.length === 1 ? { command: "agent", verb: "stop", sessionId: target } : { command: "error", message: "agent stop takes exactly one session id" };
56
+ return rest.length === (hasPositionalTarget ? 1 : 0) ? { command: "agent", verb: "stop", sessionId: target } : { command: "error", message: "agent stop takes exactly one session id" };
57
57
  }
58
58
  return null;
59
59
  }
@@ -67,8 +67,9 @@ function parseAgentArgs(flags, positionals) {
67
67
  return fail(`agent needs one of: ${AGENT_VERBS.join(", ")}`);
68
68
  }
69
69
  const timeout = flags.timeoutMs !== void 0 ? { timeoutMs: flags.timeoutMs } : {};
70
- const target = rest[0] ?? flags.sessionId;
71
- const control = parseAgentControlArgs(verb, target, rest);
70
+ const hasPositionalTarget = flags.sessionId === void 0 && rest[0] !== void 0;
71
+ const target = flags.sessionId ?? rest[0];
72
+ const control = parseAgentControlArgs(verb, target, rest, hasPositionalTarget);
72
73
  if (control) return control;
73
74
  switch (verb) {
74
75
  case "whoami":
@@ -86,7 +87,7 @@ function parseAgentArgs(flags, positionals) {
86
87
  }
87
88
  case "prompt": {
88
89
  if (!target) return fail("agent prompt needs a session id (see `saycode agent ls`)");
89
- const text2 = rest.slice(1).join(" ").trim();
90
+ const text2 = rest.slice(hasPositionalTarget ? 1 : 0).join(" ").trim();
90
91
  if (!text2) return fail("agent prompt needs the text to send");
91
92
  return {
92
93
  command: "agent",
@@ -118,7 +119,9 @@ function parseAgentArgs(flags, positionals) {
118
119
  command: "agent",
119
120
  verb: "spawn",
120
121
  handoff: { mode: "summary", ...from ? { from } : {} },
121
- ...flags.agent ? { agent: flags.agent } : {}
122
+ ...flags.agent ? { agent: flags.agent } : {},
123
+ ...flags.model !== void 0 ? { model: flags.model } : {},
124
+ ...flags.effort !== void 0 ? { effort: flags.effort } : {}
122
125
  };
123
126
  }
124
127
  const text2 = flags.prompt?.trim();
@@ -128,6 +131,8 @@ function parseAgentArgs(flags, positionals) {
128
131
  verb: "spawn",
129
132
  text: text2,
130
133
  ...flags.agent ? { agent: flags.agent } : {},
134
+ ...flags.model !== void 0 ? { model: flags.model } : {},
135
+ ...flags.effort !== void 0 ? { effort: flags.effort } : {},
131
136
  ...files.length > 0 ? { files } : {}
132
137
  };
133
138
  }
@@ -296,7 +301,8 @@ const completionInboxKo = {
296
301
  "completionInbox.emptyReadDescription": "확인한 알림이 여기에 표시됩니다.",
297
302
  "completionInbox.unreadList": "미확인 알림 목록",
298
303
  "completionInbox.readList": "확인한 알림 목록",
299
- "completionInbox.projectFallback": "프로젝트"
304
+ "completionInbox.projectFallback": "프로젝트",
305
+ "completionInbox.platformNoticeFallbackTitle": "알림"
300
306
  };
301
307
  const agentBoardKo = {
302
308
  "agentBoard.preparingResponse": "응답을 준비하는 중…",
@@ -622,8 +628,30 @@ const scheduledAutomationsKo = {
622
628
  "scheduledAutomations.scheduleDaily": "매일 지정 시각",
623
629
  "scheduledAutomations.intervalMinutesLabel": "실행 주기(분, 최소 {min}분)",
624
630
  "scheduledAutomations.dailyTimeLabel": "실행 시각",
625
- "scheduledAutomations.scriptLabel": "감시 스크립트 커맨드 (선택)",
631
+ "scheduledAutomations.monitorLabel": "변화 감지 커맨드 (선택)",
632
+ "scheduledAutomations.executionConditionLabel": "실행 조건",
633
+ "scheduledAutomations.executionEverySchedule": "예약마다 실행",
634
+ "scheduledAutomations.executionOnChange": "감시 결과가 바뀔 때만 실행",
635
+ "scheduledAutomations.advancedOptions": "고급 옵션 보기",
636
+ "scheduledAutomations.changeDetectionBadge": "변화 감지",
637
+ "scheduledAutomations.executionDetails": "실행 상세",
638
+ "scheduledAutomations.lastChange": "마지막 변화: {time}",
639
+ "scheduledAutomations.monitorOutputTitle": "최근 감시 출력",
640
+ "scheduledAutomations.monitorRequired": "변화 감지 명령을 입력하세요.",
641
+ "scheduledAutomations.templateLabel": "시작 템플릿",
642
+ "scheduledAutomations.templateNone": "직접 설정",
643
+ "scheduledAutomations.templateService": "서비스 상태 변화",
644
+ "scheduledAutomations.templateCommand": "명령 출력 변화",
645
+ "scheduledAutomations.templateHint": "템플릿은 폼만 채웁니다. 명령을 입력하고 확인한 뒤 직접 저장하세요.",
646
+ "scheduledAutomations.templateServiceName": "서비스 상태 감시",
647
+ "scheduledAutomations.templateCommandName": "명령 출력 감시",
648
+ "scheduledAutomations.templateServicePrompt": "서비스 상태가 어떻게 바뀌었는지 분석하고 필요한 조치를 제안해줘.",
649
+ "scheduledAutomations.templateCommandPrompt": "명령 출력의 변화를 분석하고 중요한 차이와 필요한 조치를 알려줘.",
650
+ "scheduledAutomations.monitorHint": "출력이 이전 성공 실행과 정확히 같으면 gate와 세션 실행을 생략합니다.",
651
+ "scheduledAutomations.notepadTitle": "자동화 메모장",
652
+ "scheduledAutomations.scriptLabel": "실행 gate 커맨드 (선택)",
626
653
  "scheduledAutomations.scriptHint": '마지막 줄이 {"wakeAgent": false} JSON이면 세션을 깨우지 않습니다.',
654
+ "scheduledAutomations.scriptHintFallback": '마지막 줄 JSON의 {"wakeAgent": false}는 세션을 깨우지 않고, {"notepad": {"key": "value"}}는 읽기 전용 메모장을 갱신합니다.',
627
655
  "scheduledAutomations.scriptWarning": "이 커맨드는 프로젝트 머신에서 사용자 권한으로 실행됩니다.",
628
656
  "scheduledAutomations.suppressSilentLabel": "응답이 [SILENT]면 알림 배지 생략",
629
657
  "scheduledAutomations.save": "저장",
@@ -637,6 +665,7 @@ const scheduledAutomationsKo = {
637
665
  "scheduledAutomations.nextRun": "다음 실행: {time}",
638
666
  "scheduledAutomations.historyTitle": "최근 실행",
639
667
  "scheduledAutomations.outcomeWoke": "세션 실행",
668
+ "scheduledAutomations.outcomeNoChange": "모니터 변화 없음(스킵)",
640
669
  "scheduledAutomations.outcomeSkippedGate": "변화 없음(스킵)",
641
670
  "scheduledAutomations.outcomeSkippedOverlap": "이전 실행 진행 중(스킵)",
642
671
  "scheduledAutomations.outcomeSilent": "이상 없음([SILENT])",
@@ -656,6 +685,7 @@ const scheduledAutomationsKo = {
656
685
  "scheduledAutomations.errorViewerKeyInUse": "기존 DB 자동화가 다른 암호화 키를 사용하고 있어 자동으로 전환할 수 없습니다. 기존 DB 자동화를 삭제한 뒤 다시 시도하세요.",
657
686
  "scheduledAutomations.errorWorkspaceMissing": "프로젝트 작업 폴더를 찾을 수 없습니다.",
658
687
  "scheduledAutomations.errorMachineOffline": "프로젝트 머신이 오프라인입니다.",
688
+ "scheduledAutomations.errorMonitorUnsupported": "대상 daemon이 변화 감지 자동화를 지원하지 않습니다. 연결 상태를 확인한 뒤 다시 시도하세요.",
659
689
  "scheduledAutomations.errorUnavailable": "자동화 서비스를 현재 사용할 수 없습니다.",
660
690
  "scheduledAutomations.runNow": "바로 실행",
661
691
  "scheduledAutomations.runRequestedNotice": "자동화 실행을 요청했습니다."
@@ -1299,6 +1329,9 @@ const subagentKo = {
1299
1329
  "subagent.error.inFlight": "이전 제어 요청을 처리하는 중입니다.",
1300
1330
  "subagent.error.rejected": "요청이 거부됐습니다: {detail}"
1301
1331
  };
1332
+ const reviewNavigationKo = {
1333
+ "review.action.openSourceSession": "원본 대화로 이동: {title}"
1334
+ };
1302
1335
  const aiCommitPrKo = {
1303
1336
  "aiCommitPr.stagedCount": "스테이지된 파일 {count}개",
1304
1337
  "aiCommitPr.noStaged": "스테이지된 변경이 없습니다.",
@@ -1339,7 +1372,39 @@ const aiCommitPrKo = {
1339
1372
  "aiCommitPr.pr.create": "Pull Request 만들기",
1340
1373
  "aiCommitPr.pr.creating": "만드는 중…"
1341
1374
  };
1342
- const catalogExtensionsKo = { ...taskNotificationKo, ...machineUsageKo, ...completionInboxKo, ...agentBoardKo, ...fanoutLifecycleKo, ...conversationSearchKo, ...browserCookieImportKo, ...orcaPortKo, ...usageAnalyticsKo, ...outboundWebhookKo, ...scheduledAutomationsKo, ...githubTriggersKo, ...memoryHubKo, ...extensionPanelKo, ...extensionInstallKo, ...personalChatsKo, ...newSessionStarterKo, ...browserConsoleCaptureKo, ...completionSignalKo, ...agentLoopDetectionKo, ...operationModelRetryKo, ...skillCatalogKo, ...ciAutoFixKo, ...collaborationKo, ...backgroundTaskKo, ...sessionTitleKo, ...accountSettingsKo, ...projectScopeKo, ...machineSettingsConsoleKo, ...sessionTokenUsageKo, ...worktreeSetupKo, ...connectionIndicatorKo, ...voiceDictationKo, ...newSessionProfileKo, ...agentBoardBroadcastKo, ...judgementEvidenceKo, ...releaseNoteKo, ...notificationSoundKo, ...officeViewerKo, ...additionalDirectoriesKo, ...agentHandoffKo, ...sidebarUpdateKo, ...autopilotSelfReviewRoundsKo, ...aiCommitPrKo, ...externalWorktreeInboxKo, ...externalSessionVaultKo, ...terminalFontKo, ...globalPauseKo, ...subagentKo };
1375
+ const projectHandoffKo = {
1376
+ "projectHandoff.loading": "핸드오프를 확인하는 중…",
1377
+ "projectHandoff.notFound": "핸드오프를 찾을 수 없습니다.",
1378
+ "projectHandoff.autoApproving": "자동 승인 중…",
1379
+ "projectHandoff.autoApproveFailed": "자동 승인에 실패했습니다.",
1380
+ "projectHandoff.status.queued": "대기 중",
1381
+ "projectHandoff.status.running": "진행 중",
1382
+ "projectHandoff.status.completed": "완료됨",
1383
+ "projectHandoff.status.failed": "실패함",
1384
+ "projectHandoff.status.cancelled": "거절됨",
1385
+ "projectHandoff.status.blocked": "막힘",
1386
+ "projectHandoff.indicatorLabel": "넘긴 작업 {count}건 진행 중"
1387
+ };
1388
+ const projectLinksKo = {
1389
+ "projectLinks.empty": "등록된 관련 프로젝트가 없습니다.",
1390
+ "projectLinks.loading": "불러오는 중…",
1391
+ "projectLinks.loadFailed": "관련 프로젝트를 불러오지 못했습니다.",
1392
+ "projectLinks.unknownProject": "알 수 없는 프로젝트",
1393
+ "projectLinks.removeAria": "{name} 관련 등록 해제",
1394
+ "projectLinks.removeFailed": "관련 등록을 해제하지 못했습니다.",
1395
+ "projectLinks.selectProject": "프로젝트 선택",
1396
+ "projectLinks.selectProjectAria": "관련 프로젝트로 등록할 프로젝트",
1397
+ "projectLinks.rolePlaceholder": "역할 (예: 웹, 백엔드, 앱)",
1398
+ "projectLinks.add": "등록",
1399
+ "projectLinks.adding": "등록 중…",
1400
+ "projectLinks.addFailed": "관련 프로젝트를 등록하지 못했습니다.",
1401
+ "projectLinks.noCandidates": "등록할 수 있는 다른 프로젝트가 없습니다.",
1402
+ "projectLinks.unavailableInDemo": "데모 모드에서는 관련 프로젝트를 관리할 수 없습니다.",
1403
+ "projectLinks.mcpToggleLabel": "AI가 이 프로젝트에서 작업을 넘길 수 있게 허용",
1404
+ "projectLinks.mcpToggleHint": "켜면 AI가 관련 프로젝트로 작업을 제안했을 때 확인 없이 바로 진행됩니다.",
1405
+ "projectLinks.mcpToggleFailed": "설정을 바꾸지 못했습니다."
1406
+ };
1407
+ const catalogExtensionsKo = { ...taskNotificationKo, ...machineUsageKo, ...completionInboxKo, ...agentBoardKo, ...fanoutLifecycleKo, ...conversationSearchKo, ...browserCookieImportKo, ...orcaPortKo, ...usageAnalyticsKo, ...outboundWebhookKo, ...scheduledAutomationsKo, ...githubTriggersKo, ...memoryHubKo, ...extensionPanelKo, ...extensionInstallKo, ...personalChatsKo, ...newSessionStarterKo, ...browserConsoleCaptureKo, ...completionSignalKo, ...agentLoopDetectionKo, ...operationModelRetryKo, ...skillCatalogKo, ...ciAutoFixKo, ...collaborationKo, ...backgroundTaskKo, ...sessionTitleKo, ...accountSettingsKo, ...projectScopeKo, ...machineSettingsConsoleKo, ...sessionTokenUsageKo, ...worktreeSetupKo, ...connectionIndicatorKo, ...voiceDictationKo, ...newSessionProfileKo, ...agentBoardBroadcastKo, ...judgementEvidenceKo, ...releaseNoteKo, ...notificationSoundKo, ...officeViewerKo, ...additionalDirectoriesKo, ...agentHandoffKo, ...sidebarUpdateKo, ...autopilotSelfReviewRoundsKo, ...aiCommitPrKo, ...externalWorktreeInboxKo, ...externalSessionVaultKo, ...terminalFontKo, ...globalPauseKo, ...subagentKo, ...reviewNavigationKo, ...projectLinksKo, ...projectHandoffKo };
1343
1408
  const onboardingKo = {
1344
1409
  "onboarding.dialogAria": "시작 설정",
1345
1410
  "onboarding.eyebrow": "처음 시작하기",
@@ -2000,6 +2065,10 @@ const ko = {
2000
2065
  "worktreePathCopy.copy": "worktree 경로 복사",
2001
2066
  "worktreePathCopy.copied": "worktree 경로 복사됨",
2002
2067
  "worktreePathCopy.failed": "worktree 경로 복사 실패",
2068
+ "sessionWorktree.worktree": "워크트리: {name}",
2069
+ "sessionWorktree.branch": "브랜치: {branch}",
2070
+ "sessionWorktree.path": "경로: {path}",
2071
+ "sessionWorktree.merged": "병합됨",
2003
2072
  // Project git controls
2004
2073
  "gitControls.branchLoading": "Git branch 확인 중",
2005
2074
  "gitControls.noRemote": "origin remote 또는 branch를 찾을 수 없습니다.",
@@ -2008,9 +2077,9 @@ const ko = {
2008
2077
  "gitControls.manageBranchAria": "{branch} branch 관리",
2009
2078
  "gitControls.manageBranchTitle": "Branch 변경 및 생성",
2010
2079
  "gitControls.manageBranchTitleWithDesc": "Branch 변경 및 생성 · {description}",
2011
- "gitControls.pullAria": "{branch} branch pull",
2080
+ "gitControls.pullAria": "{branch} 브랜치 pull",
2012
2081
  "gitControls.pullTitle": "{branch} pull",
2013
- "gitControls.pushAria": "{branch} branch push",
2082
+ "gitControls.pushAria": "{branch} 브랜치 push",
2014
2083
  "gitControls.pushTitle": "{branch} push",
2015
2084
  "gitControls.manageWorktreeAria": "{name} worktree 관리",
2016
2085
  "gitControls.manageWorktreeTitle": "Worktree 관리",
@@ -2278,6 +2347,7 @@ const ko = {
2278
2347
  "projectSettings.tabRepository": "코드 저장소",
2279
2348
  "projectSettings.tabDeploy": "배포 저장소",
2280
2349
  "projectSettings.tabCollaboration": "협업",
2350
+ "projectSettings.tabLinks": "관련 프로젝트",
2281
2351
  "projectSettings.tabMachine": "머신 및 경로",
2282
2352
  "projectSettings.tabRun": "실행",
2283
2353
  "projectSettings.tabMcp": "MCP 서버/스킬",
@@ -2733,6 +2803,8 @@ const ko = {
2733
2803
  "standaloneImport.doneSummary": "프로젝트 {projects}개, 세션 {sessions}개를 가져왔어요.",
2734
2804
  "standaloneImport.doneSkipped": "이미 가져온 세션 {count}개는 건너뛰었어요.",
2735
2805
  "standaloneImport.undecryptable": "복호화할 수 없어 건너뛴 메시지가 {count}개 있어요.",
2806
+ "standaloneImport.undecryptableMetadata": "세션 {count}개의 메타데이터 또는 에이전트 상태를 복호화하지 못해 일부 상태 없이 가져왔어요.",
2807
+ "standaloneImport.failedProjects": "프로젝트 {count}개를 가져오지 못했어요. 가능한 세션은 프로젝트 연결 없이 가져왔어요.",
2736
2808
  "standaloneImport.failedSessions": "세션 {count}개를 가져오지 못했어요. 다시 실행하면 이어서 시도해요.",
2737
2809
  "standaloneImport.fatalError": "가져오기를 시작하지 못했어요: {error}",
2738
2810
  "standaloneImport.settingsBody": "이 컴퓨터의 로컬 전용 모드 데이터를 현재 계정으로 복사해요.",
@@ -2905,6 +2977,7 @@ const ko = {
2905
2977
  "worktreeOp.checkingStatus": "worktree 상태 확인 중...",
2906
2978
  "worktreeOp.forceDeleteUnpushed": "worktree에 푸시되지 않은 커밋 {count}개가 있습니다. 강제로 삭제할까요? 커밋이 사라집니다.",
2907
2979
  "worktreeOp.forceDeleteDirty": "worktree에 정리되지 않은 변경이 있습니다. 강제로 삭제할까요? 변경 사항이 사라집니다.",
2980
+ "worktreeOp.trackedChangesBlocked": "이 worktree에 커밋되지 않은 tracked 변경이 있습니다. 변경을 커밋하거나 stash하거나 명시적으로 버린 뒤 다시 시도하세요.",
2908
2981
  "worktreeOp.removeCancelled": "정리되지 않은 변경/커밋이 있어 삭제를 취소했습니다.",
2909
2982
  "worktreeOp.removed": "worktree를 삭제했습니다.",
2910
2983
  "project.sharedBy": " · 공유: {owner}",
@@ -3211,6 +3284,8 @@ const ko = {
3211
3284
  "sessionsTab.searchFailed": "사용자 프롬프트를 검색하지 못했습니다. 제목 검색 결과만 표시합니다.",
3212
3285
  "sessionsTab.noSearchResults": "“{query}”와 일치하는 대화가 없습니다.",
3213
3286
  "sessionsTab.clearSearch": "검색 지우기",
3287
+ "sessionsTab.noVisibleMessage": "표시할 메시지가 아직 없습니다",
3288
+ "sessionsTab.updated": "업데이트",
3214
3289
  // Planning / preview sidecar / preview tab
3215
3290
  "previewSidecar.closeAria": "Preview 닫기",
3216
3291
  "preview.serverStarting": "서버 시작 중...",
@@ -3268,6 +3343,7 @@ const ko = {
3268
3343
  "saycodePromptPreference.eyebrow": "에이전트 지시 선택",
3269
3344
  "saycodePromptPreference.title": "Saycode 기본 지시를 사용할까요?",
3270
3345
  "saycodePromptPreference.body": "내가 만든 Claude/Codex 스킬과 하네스를 그대로 쓰려면 꺼진 상태를 유지하세요. 나중에 환경설정에서 언제든 바꿀 수 있습니다.",
3346
+ "saycodePromptPreference.defaultOnNotice": "자식 Agent 호출, 내부 작업 위임, 결과물 인라인 미리보기는 기본으로 계속 켜집니다. 이 기능도 끄려면 환경설정 → 시스템 프롬프트에서 각각 해제하세요.",
3271
3347
  "saycodePromptPreference.keepOff": "꺼진 상태 유지",
3272
3348
  "saycodePromptPreference.enable": "Saycode 지시 켜기",
3273
3349
  "saycodePromptPreference.saving": "저장 중…",
@@ -3276,14 +3352,18 @@ const ko = {
3276
3352
  "saycodePromptPreference.settingsEnable": "Saycode가 제공하는 기본 행동 지시 사용",
3277
3353
  "saycodePromptPreference.settingsHelp": "사용자 시스템 프롬프트, 프로젝트 지침, 설치한 스킬과 하네스는 이 설정과 관계없이 유지됩니다.",
3278
3354
  "saycodePromptPreference.blocksTitle": "개별 지시 선택",
3279
- "saycodePromptPreference.blocksHelp": "Saycode 지시를 항목별로 켜고 끕니다. 선택하지 않은 항목은 위의 전체 설정을 따릅니다.",
3355
+ "saycodePromptPreference.blocksHelp": "자식 Agent 호출, 내부 작업 위임, 결과물 인라인 미리보기는 기본으로 켜지며, 나머지 항목은 위의 전체 설정을 따릅니다.",
3280
3356
  "saycodePromptPreference.alwaysOnNotice": "채팅 제목 생성은 항상 켜져 있으며 끌 수 없습니다. 채팅 목록에서 대화를 다시 찾으려면 필요합니다.",
3281
- "saycodePromptPreference.block.workerDelegation.label": "작업 위임",
3357
+ "saycodePromptPreference.block.agentOrchestration.label": "자식 Agent 호출",
3358
+ "saycodePromptPreference.block.agentOrchestration.description": "자연어로 요청한 작업을 독립된 자식 Agent 세션에 맡기고 결과를 회수하도록 지시합니다.",
3359
+ "saycodePromptPreference.block.workerDelegation.label": "내부 작업 위임",
3282
3360
  "saycodePromptPreference.block.workerDelegation.description": "기계적인 작업을 저렴한 worker 에이전트에 넘기도록 지시합니다.",
3283
3361
  "saycodePromptPreference.block.axBase.label": "기획부터 시작 워크플로우",
3284
3362
  "saycodePromptPreference.block.axBase.description": "AX 워크플로우의 기본 지시를 사용합니다.",
3285
3363
  "saycodePromptPreference.block.coAuthoredCredit.label": "커밋 크레딧",
3286
3364
  "saycodePromptPreference.block.coAuthoredCredit.description": "커밋 메시지에 Claude와 Happy 공동 작성자 표시를 추가합니다.",
3365
+ "saycodePromptPreference.block.inlinePreview.label": "결과물 인라인 미리보기",
3366
+ "saycodePromptPreference.block.inlinePreview.description": "HTML 결과물을 만들면 답변 안에 미리보기를 띄우도록 지시합니다.",
3287
3367
  "saycodePromptPreference.loading": "계정 설정을 불러오는 중…",
3288
3368
  "standalone.zrokStatus": "zrok 상태",
3289
3369
  "standalone.zrokNeeded": "zrok2 설정이 필요합니다",
@@ -3834,6 +3914,7 @@ const ko = {
3834
3914
  "review.self.loopStop": "반복 중지",
3835
3915
  "review.self.loopDismiss": "기록 지우기",
3836
3916
  "review.self.loopDone": "반복 리뷰 종료 ({rounds}회) — {reason}",
3917
+ "review.self.startCancelled": "다른 창의 작업으로 반복 리뷰 시작이 취소됐어요.",
3837
3918
  "review.self.loopReason.clean": "지적 없음",
3838
3919
  "review.self.loopReason.belowThreshold": "low·nit만 남아 조기 종료",
3839
3920
  "review.self.loopReason.roundsExhausted": "지정한 횟수를 모두 사용",
@@ -4415,7 +4496,7 @@ Options
4415
4496
  -p, --project <id|name> Project id prefix or name
4416
4497
  -d, --directory <path> Working directory on that machine
4417
4498
  -s, --session <id> Session id (see \`saycode ls\`)
4418
- --agent <name> claude (default), codex, gemini, openclaw, opencode
4499
+ --agent <name> claude (default), codex, gemini, grok, openclaw, opencode
4419
4500
  --model <name> Model for new-session messages (Default if omitted)
4420
4501
  --effort <level> Model effort (validated against agent + model)
4421
4502
  --worktree Create an isolated worktree for a project chat
@@ -4438,7 +4519,8 @@ Agent commands (machine-readable JSON on stdout, exit 3 on failure)
4438
4519
  agent prompt <session> <text> [--wait] Send a prompt, optionally wait for it
4439
4520
  agent steer <session> <text> Redirect an owned child's active response
4440
4521
  agent stop <session> Stop an owned child's active response
4441
- agent spawn --prompt <text> [--agent n] Start a sibling in the same directory
4522
+ agent spawn --prompt <text> [--agent n] [--model n] [--effort n]
4523
+ Start a sibling with an explicit execution choice
4442
4524
  agent spawn --prompt <text> --file <path> ...with context files it reads first
4443
4525
  Only works inside a session Saycode started (SAYCODE_AGENT_ENV).
4444
4526
 
@@ -6837,6 +6919,58 @@ function buildUserTextMessageContent(input) {
6837
6919
  }
6838
6920
  };
6839
6921
  }
6922
+ function sanitizedString(value2) {
6923
+ return typeof value2 === "string" && value2.trim() ? value2.trim() : null;
6924
+ }
6925
+ function sanitizedCompanyId(value2) {
6926
+ if (typeof value2 === "string") return value2.trim() ? value2.trim() : null;
6927
+ if (value2 === null) return null;
6928
+ return void 0;
6929
+ }
6930
+ function buildStoredNotice(sessionId, value2, receivedAt) {
6931
+ const companyId = sanitizedCompanyId(value2.companyId);
6932
+ return {
6933
+ sessionId,
6934
+ projectId: sanitizedString(value2.projectId),
6935
+ ...sanitizedString(value2.personalChatId) ? { personalChatId: sanitizedString(value2.personalChatId) } : {},
6936
+ projectName: sanitizedString(value2.projectName),
6937
+ sessionTitle: sanitizedString(value2.sessionTitle),
6938
+ status: value2.status === "completed" || value2.status === "failed" || value2.status === "cancelled" ? value2.status : "completed",
6939
+ receivedAt,
6940
+ ...companyId !== void 0 ? { companyId } : {},
6941
+ // Only non-chat kinds are stored; absence means chat so legacy snapshots round-trip.
6942
+ ...value2.kind === "deploy" || value2.kind === "automation" || value2.kind === "platform_notice" ? { kind: value2.kind } : {},
6943
+ // title/body are platform_notice-only (server-authoritative admin message). Other
6944
+ // kinds never legitimately carry them, so they are dropped rather than stored blank.
6945
+ ...value2.kind === "platform_notice" ? { title: sanitizedString(value2.title), body: sanitizedString(value2.body) } : {}
6946
+ };
6947
+ }
6948
+ function parseServerCompletionNotifications(payload) {
6949
+ if (!payload || typeof payload !== "object") return [];
6950
+ const { notifications } = payload;
6951
+ if (!Array.isArray(notifications)) return [];
6952
+ const result = [];
6953
+ for (const raw of notifications) {
6954
+ if (!raw || typeof raw !== "object") continue;
6955
+ const value2 = raw;
6956
+ if (value2.kind !== "platform_notice") continue;
6957
+ const sessionId = sanitizedString(value2.sessionId);
6958
+ if (!sessionId) continue;
6959
+ result.push({
6960
+ ...buildStoredNotice(
6961
+ sessionId,
6962
+ value2,
6963
+ typeof value2.receivedAt === "number" && Number.isFinite(value2.receivedAt) ? value2.receivedAt : 0
6964
+ ),
6965
+ // 서버 목록은 읽은 행도 함께 돌려주고(`listCompletionNotifications` 는 unread 로
6966
+ // 필터하지 않는다) 읽음 여부를 `unread` 에 담는다. `buildStoredNotice` 는 이 필드를
6967
+ // 모르므로 여기서 직접 얹지 않으면, 조회할 때마다 `recordCompletionNotice` 의
6968
+ // 기본값(미확인)으로 되살아난다.
6969
+ ...typeof value2.unread === "boolean" ? { unread: value2.unread } : {}
6970
+ });
6971
+ }
6972
+ return result;
6973
+ }
6840
6974
  const CONNECTOR_PROVIDER_IDS = [
6841
6975
  "notion",
6842
6976
  "slack",
@@ -15966,6 +16100,31 @@ function coercePromptSuggestion(value2) {
15966
16100
  if (!text2 || !provider2 || updatedAt === void 0) return void 0;
15967
16101
  return { text: text2, provider: provider2, updatedAt };
15968
16102
  }
16103
+ const MCP_RUNTIME_STATUSES = /* @__PURE__ */ new Set([
16104
+ "connected",
16105
+ "reconnecting",
16106
+ "failed",
16107
+ "needs-auth",
16108
+ "config-fetch-failed",
16109
+ "mcp-config-missing",
16110
+ "connector-config-missing",
16111
+ "connector-runtime-failed",
16112
+ "connector-needs-auth"
16113
+ ]);
16114
+ function coerceMcpServerStatuses(value2) {
16115
+ if (!Array.isArray(value2)) return void 0;
16116
+ const entries = [];
16117
+ for (const raw of value2) {
16118
+ if (!isRecord$1(raw)) continue;
16119
+ const name = nonEmptyString(raw.name);
16120
+ const status = typeof raw.status === "string" && MCP_RUNTIME_STATUSES.has(raw.status) ? raw.status : void 0;
16121
+ const checkedAt = optionalTimestamp(raw.checkedAt);
16122
+ if (!name || !status || checkedAt === void 0) continue;
16123
+ const error = nonEmptyString(raw.error);
16124
+ entries.push({ name, status, checkedAt, ...error ? { error } : {} });
16125
+ }
16126
+ return entries.length > 0 ? entries : void 0;
16127
+ }
15969
16128
  function coerceCreatedBy(value2) {
15970
16129
  if (!isRecord$1(value2)) return void 0;
15971
16130
  const accountId = nonEmptyString(value2.accountId);
@@ -16000,6 +16159,7 @@ function apiSessionToSession(api, candidateKeys, existing) {
16000
16159
  const branchSlug = branchSlugFromMetadata(metadata) ?? existing?.branchSlug;
16001
16160
  const path = nonEmptyString(metadata?.path) ?? existing?.path;
16002
16161
  const promptSuggestion2 = metadata && Object.prototype.hasOwnProperty.call(metadata, "promptSuggestion") ? coercePromptSuggestion(metadata.promptSuggestion) : existing?.promptSuggestion;
16162
+ const mcpServers = coerceMcpServerStatuses(metadata?.mcpServers) ?? existing?.mcpServers;
16003
16163
  return {
16004
16164
  ...existing,
16005
16165
  id: api.id,
@@ -16026,6 +16186,7 @@ function apiSessionToSession(api, candidateKeys, existing) {
16026
16186
  ...currentModelCode ? { currentModelCode } : {},
16027
16187
  ...tokenUsage ? { tokenUsage } : {},
16028
16188
  ...slashCommands ? { slashCommands } : {},
16189
+ ...mcpServers ? { mcpServers } : {},
16029
16190
  ...claudeSessionId ? { claudeSessionId } : {},
16030
16191
  ...codexThreadId ? { codexThreadId } : {},
16031
16192
  ...lifecycleState ? { lifecycleState } : {},
@@ -16068,11 +16229,16 @@ function readMcpCallerGrantPublicKey(daemonState) {
16068
16229
  const value2 = daemonState.mcpCallerGrantPublicKey;
16069
16230
  return typeof value2 === "string" && value2 ? value2 : void 0;
16070
16231
  }
16071
- function buildWorktreeMutationBody(input) {
16232
+ const CLEARABLE_STRING_KEYS = /* @__PURE__ */ new Set(["branch", "baseBranch"]);
16233
+ function buildWorktreeMutationBody(input, options = {}) {
16072
16234
  return JSON.stringify(Object.fromEntries(
16073
- Object.entries(input).filter(([, value2]) => typeof value2 === "string" && value2.length > 0)
16235
+ Object.entries(input).filter(([key, value2]) => (key !== "machinePlatform" || options.includeMachinePlatform) && (typeof value2 === "string" && (value2.length > 0 || CLEARABLE_STRING_KEYS.has(key)) || value2 === null && CLEARABLE_STRING_KEYS.has(key)))
16074
16236
  ));
16075
16237
  }
16238
+ const STANDALONE_USER_ID = "standalone-local";
16239
+ function isStandaloneAuthSession(session) {
16240
+ return session?.userId === STANDALONE_USER_ID;
16241
+ }
16076
16242
  function indexMachineRegistry(entries) {
16077
16243
  const byId = {};
16078
16244
  for (const entry of entries) {
@@ -16137,6 +16303,17 @@ function resolvedProjectPreviewRelayUrl(input) {
16137
16303
  }
16138
16304
  return `${input.syncBaseUrl}/v1/preview/${encodeURIComponent(input.machineId)}/${input.port}/?ptoken=${encodeURIComponent(response.token)}`;
16139
16305
  }
16306
+ const BASE64_PATTERN = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
16307
+ function versionedAutomationBase64(value2, maxBytes, exactBytes) {
16308
+ if (typeof value2 !== "string" || !value2 || value2.length > Math.ceil(maxBytes / 3) * 4 || !BASE64_PATTERN.test(value2)) {
16309
+ throw new Error("automation-response-invalid");
16310
+ }
16311
+ const decoded = Buffer$1.from(value2, "base64");
16312
+ if (decoded.length < 41 || decoded.length > maxBytes || exactBytes !== void 0 && decoded.length !== exactBytes || decoded[0] !== 1) {
16313
+ throw new Error("automation-response-invalid");
16314
+ }
16315
+ return value2;
16316
+ }
16140
16317
  function row(value2) {
16141
16318
  if (!value2 || typeof value2 !== "object" || Array.isArray(value2)) throw new Error("automation-response-invalid");
16142
16319
  return value2;
@@ -16158,10 +16335,23 @@ function nullableInteger(value2) {
16158
16335
  function optionalNullableInteger(value2) {
16159
16336
  return value2 === void 0 ? null : nullableInteger(value2);
16160
16337
  }
16161
- function parseServerAutomationPublic(value2) {
16338
+ function parseRuntimeState(value2) {
16162
16339
  const item = row(value2);
16163
- if (item.payloadVersion !== 1 || typeof item.paused !== "boolean") throw new Error("automation-response-invalid");
16164
16340
  return {
16341
+ automationId: stringValue(item.automationId),
16342
+ generation: integer(item.generation, 1),
16343
+ stateVersion: integer(item.stateVersion, 1),
16344
+ stateCiphertext: versionedAutomationBase64(item.stateCiphertext, 96 * 1024),
16345
+ viewerKeyVersion: integer(item.viewerKeyVersion, 1),
16346
+ viewerKeyEnvelope: versionedAutomationBase64(item.viewerKeyEnvelope, 105, 105),
16347
+ machineKeyVersion: integer(item.machineKeyVersion, 1),
16348
+ updatedAt: integer(item.updatedAt)
16349
+ };
16350
+ }
16351
+ function parseServerAutomationPublic(value2) {
16352
+ const item = row(value2);
16353
+ if (item.payloadVersion !== 1 && item.payloadVersion !== 2 || typeof item.paused !== "boolean") throw new Error("automation-response-invalid");
16354
+ const parsed = {
16165
16355
  id: stringValue(item.id),
16166
16356
  projectId: stringValue(item.projectId),
16167
16357
  ownerAccountId: stringValue(item.ownerAccountId),
@@ -16169,7 +16359,7 @@ function parseServerAutomationPublic(value2) {
16169
16359
  machineId: nullableString(item.machineId),
16170
16360
  revision: integer(item.revision, 1),
16171
16361
  generation: integer(item.generation, 1),
16172
- payloadVersion: 1,
16362
+ payloadVersion: item.payloadVersion,
16173
16363
  payloadCiphertext: stringValue(item.payloadCiphertext),
16174
16364
  viewerKeyId: stringValue(item.viewerKeyId),
16175
16365
  viewerKeyVersion: integer(item.viewerKeyVersion, 1),
@@ -16181,8 +16371,13 @@ function parseServerAutomationPublic(value2) {
16181
16371
  appliedRevision: integer(item.appliedRevision),
16182
16372
  appliedAt: nullableInteger(item.appliedAt),
16183
16373
  createdAt: integer(item.createdAt),
16184
- updatedAt: integer(item.updatedAt)
16374
+ updatedAt: integer(item.updatedAt),
16375
+ runtimeState: item.runtimeState === void 0 || item.runtimeState === null ? null : parseRuntimeState(item.runtimeState)
16185
16376
  };
16377
+ if (parsed.payloadVersion === 2 !== (parsed.runtimeState !== null) || parsed.runtimeState && (parsed.runtimeState.automationId !== parsed.id || parsed.runtimeState.generation !== parsed.generation || parsed.runtimeState.viewerKeyVersion !== parsed.viewerKeyVersion || parsed.runtimeState.machineKeyVersion !== parsed.machineKeyVersion)) {
16378
+ throw new Error("automation-response-invalid");
16379
+ }
16380
+ return parsed;
16186
16381
  }
16187
16382
  function parseServerAutomationAdoption(value2) {
16188
16383
  const item = row(value2);
@@ -16203,11 +16398,12 @@ function parseServerAutomationTarget(value2) {
16203
16398
  machinePublicKey: stringValue(item.machinePublicKey),
16204
16399
  machineKeyVersion: integer(item.machineKeyVersion, 1),
16205
16400
  viewerPublicKey: nullableString(item.viewerPublicKey),
16206
- viewerKeyVersion: integer(item.viewerKeyVersion)
16401
+ viewerKeyVersion: integer(item.viewerKeyVersion),
16402
+ automationProtocolVersion: item.automationProtocolVersion === void 0 ? 1 : integer(item.automationProtocolVersion, 1)
16207
16403
  };
16208
16404
  }
16209
16405
  const RUN_STATUSES = /* @__PURE__ */ new Set(["CLAIMED", "RUNNING", "COMPLETED", "FAILED", "CANCELLED", "EXPIRED", "ABANDONED"]);
16210
- const RUN_OUTCOMES = /* @__PURE__ */ new Set(["WOKE", "SILENT", "SKIPPED_GATE", "ERROR"]);
16406
+ const RUN_OUTCOMES = /* @__PURE__ */ new Set(["WOKE", "SILENT", "SKIPPED_GATE", "NO_CHANGE", "ERROR"]);
16211
16407
  function parseServerAutomationRun(value2) {
16212
16408
  const item = row(value2);
16213
16409
  if (!RUN_STATUSES.has(item.status) || item.outcome !== null && !RUN_OUTCOMES.has(item.outcome) || typeof item.lateReport !== "boolean") throw new Error("automation-response-invalid");
@@ -16463,6 +16659,7 @@ function normalizeAutomationSupport(value2) {
16463
16659
  return {
16464
16660
  rpcAvailable: capability.rpcAvailable,
16465
16661
  serverBacked: capability.serverBacked === true,
16662
+ ...typeof capability.monitorMode === "boolean" ? { monitorMode: capability.monitorMode } : {},
16466
16663
  ...Number.isSafeInteger(capability.keyVersion) && capability.keyVersion > 0 ? { keyVersion: capability.keyVersion } : {}
16467
16664
  };
16468
16665
  }
@@ -16506,7 +16703,8 @@ class ApiClient {
16506
16703
  apiBaseUrl: session.apiBaseUrl,
16507
16704
  syncBaseUrl: session.happyBaseUrl,
16508
16705
  apiToken: session.token,
16509
- syncToken: session.syncToken || session.token
16706
+ syncToken: session.syncToken || session.token,
16707
+ standalone: isStandaloneAuthSession(session)
16510
16708
  });
16511
16709
  }
16512
16710
  async login(input) {
@@ -16571,6 +16769,117 @@ class ApiClient {
16571
16769
  });
16572
16770
  return data.items ?? [];
16573
16771
  }
16772
+ /**
16773
+ * 관리자가 보낸 플랫폼 공지(`kind: 'platform_notice'`)만 골라 돌려준다. 다른 kind
16774
+ * (`chat_completion`)는 데스크탑이 이미 스스로 만들고 있어 동기화 대상이 아니다.
16775
+ * specs/desktop-platform-notice-inbox
16776
+ */
16777
+ async listServerNotifications() {
16778
+ const data = await this.request("/api/me/completion-notifications", { boundary: "api" });
16779
+ return parseServerCompletionNotifications(data);
16780
+ }
16781
+ /**
16782
+ * 서버 알림함에 읽음을 반영한다. `kind` 를 반드시 넘긴다 — 서버 유니크 키가
16783
+ * (userId, kind, sessionId) 라 kind 없이는 같은 세션의 어느 kind를 읽음 처리할지
16784
+ * 서버가 구분하지 못한다. 실패해도 로컬 상태는 이미 반영됐으므로 호출부가 삼킨다.
16785
+ * specs/desktop-platform-notice-inbox R4
16786
+ */
16787
+ async markServerNotificationRead(sessionId, kind) {
16788
+ await this.request(`/api/me/completion-notifications/${encodeURIComponent(sessionId)}/read?kind=${encodeURIComponent(kind)}`, {
16789
+ method: "PATCH",
16790
+ boundary: "api"
16791
+ });
16792
+ }
16793
+ /**
16794
+ * 관련 프로젝트 등록 목록. 서버가 양방향으로 조회해 준다(대상으로 등록한 쪽도
16795
+ * 나온다). specs/desktop-related-project-handoff
16796
+ */
16797
+ async listProjectLinks(projectId) {
16798
+ const data = await this.request(
16799
+ `/api/projects/${encodeURIComponent(projectId)}/links`,
16800
+ { boundary: "api" }
16801
+ );
16802
+ return data.links ?? [];
16803
+ }
16804
+ async addProjectLink(projectId, targetProjectId, role) {
16805
+ const data = await this.request(
16806
+ `/api/projects/${encodeURIComponent(projectId)}/links`,
16807
+ { method: "POST", boundary: "api", body: JSON.stringify({ targetProjectId, role }) }
16808
+ );
16809
+ return data.link;
16810
+ }
16811
+ async removeProjectLink(projectId, targetProjectId) {
16812
+ await this.request(`/api/projects/${encodeURIComponent(projectId)}/links`, {
16813
+ method: "DELETE",
16814
+ boundary: "api",
16815
+ body: JSON.stringify({ targetProjectId })
16816
+ });
16817
+ }
16818
+ /** 진행 중(queued/running/blocked) 핸드오프 전체 — 전역 인디케이터용. */
16819
+ async listActiveProjectHandoffs() {
16820
+ const data = await this.request("/api/handoffs", { boundary: "api" });
16821
+ return data.handoffs ?? [];
16822
+ }
16823
+ /** 이 프로젝트에서 내보낸 핸드오프 전체 — 프로젝트 설정 화면용. */
16824
+ async listProjectHandoffsForProject(projectId) {
16825
+ const data = await this.request(
16826
+ `/api/projects/${encodeURIComponent(projectId)}/handoffs`,
16827
+ { boundary: "api" }
16828
+ );
16829
+ return data.handoffs ?? [];
16830
+ }
16831
+ /**
16832
+ * 승인. `sourceSessionId` 는 승인 카드가 렌더된 대화 — 서버가 딥링크 조립에 쓴다.
16833
+ * 이미 승인된(queued 이상) 핸드오프를 다시 승인하면 서버가 409 를 던진다(멱등은
16834
+ * 호출부가 그 에러를 흡수하는 방식으로 구현한다). specs/desktop-related-project-handoff R7
16835
+ */
16836
+ async approveProjectHandoff(handoffId, sourceSessionId) {
16837
+ const data = await this.request(
16838
+ `/api/handoffs/${encodeURIComponent(handoffId)}/approve`,
16839
+ { method: "POST", boundary: "api", body: JSON.stringify({ sourceSessionId }) }
16840
+ );
16841
+ return data.handoff;
16842
+ }
16843
+ async rejectProjectHandoff(handoffId) {
16844
+ const data = await this.request(
16845
+ `/api/handoffs/${encodeURIComponent(handoffId)}/reject`,
16846
+ { method: "POST", boundary: "api" }
16847
+ );
16848
+ return data.handoff;
16849
+ }
16850
+ /** 대상 프로젝트에 실제 세션을 만든 뒤 보고한다. 서버가 targetSessionId 기준으로
16851
+ * 멱등을 보장한다 — 이미 있으면 새 매핑을 만들지 않고 그대로 돌려준다. */
16852
+ async reportProjectHandoffStarted(handoffId, targetSessionId) {
16853
+ const data = await this.request(
16854
+ `/api/handoffs/${encodeURIComponent(handoffId)}/started`,
16855
+ { method: "POST", boundary: "api", body: JSON.stringify({ targetSessionId }) }
16856
+ );
16857
+ return data.handoff;
16858
+ }
16859
+ /** 대상 프로젝트에 온라인 머신이 없는 등, 세션 생성 자체를 시작하지 못했을 때. */
16860
+ async reportProjectHandoffBlocked(handoffId, reason) {
16861
+ const data = await this.request(
16862
+ `/api/handoffs/${encodeURIComponent(handoffId)}/blocked`,
16863
+ { method: "POST", boundary: "api", body: JSON.stringify({ reason }) }
16864
+ );
16865
+ return data.handoff;
16866
+ }
16867
+ /** 세션 생성 시도가 던졌을 때. */
16868
+ async reportProjectHandoffFailed(handoffId, reason) {
16869
+ const data = await this.request(
16870
+ `/api/handoffs/${encodeURIComponent(handoffId)}/failed`,
16871
+ { method: "POST", boundary: "api", body: JSON.stringify({ reason }) }
16872
+ );
16873
+ return data.handoff;
16874
+ }
16875
+ /** 기본 꺼짐 — request_project_handoff MCP 도구 노출 토글(spec R9). */
16876
+ async setProjectHandoffMcpEnabled(projectId, enabled) {
16877
+ return this.request(`/api/projects/${encodeURIComponent(projectId)}/handoff-mcp-enabled`, {
16878
+ method: "PATCH",
16879
+ boundary: "api",
16880
+ body: JSON.stringify({ enabled })
16881
+ });
16882
+ }
16574
16883
  async addProjectFavorite(projectId) {
16575
16884
  return this.request(`/api/projects/${encodeURIComponent(projectId)}/favorite`, {
16576
16885
  method: "PUT",
@@ -16586,6 +16895,7 @@ class ApiClient {
16586
16895
  // --- 개인 Chat (specs/personal-chats) ---
16587
16896
  async listPersonalChats(input = {}) {
16588
16897
  const params = new URLSearchParams();
16898
+ params.set("context", input.companyId ? `company:${input.companyId}` : "personal");
16589
16899
  if (input.limit !== void 0) params.set("limit", String(input.limit));
16590
16900
  if (input.offset !== void 0) params.set("offset", String(input.offset));
16591
16901
  if (input.query) params.set("query", input.query);
@@ -16601,12 +16911,22 @@ class ApiClient {
16601
16911
  const data = await this.request("/api/personal-chats", {
16602
16912
  method: "POST",
16603
16913
  boundary: "api",
16604
- body: JSON.stringify(
16605
- input.title === void 0 ? { machineId: input.machineId } : { machineId: input.machineId, title: input.title }
16606
- )
16914
+ body: JSON.stringify({
16915
+ machineId: input.machineId,
16916
+ ...input.title === void 0 ? {} : { title: input.title },
16917
+ ..."companyId" in input ? { companyId: input.companyId } : {}
16918
+ })
16607
16919
  });
16608
16920
  return data.chat;
16609
16921
  }
16922
+ async getPersonalChatRuntimeConfig(chatId, machineId) {
16923
+ const params = new URLSearchParams({ machineId });
16924
+ const data = await this.request(
16925
+ `/api/personal-chats/${encodeURIComponent(chatId)}/runtime-config?${params.toString()}`,
16926
+ { boundary: "api", headers: { "Cache-Control": "no-cache" } }
16927
+ );
16928
+ return data.environmentVariables ?? {};
16929
+ }
16610
16930
  async getPersonalChat(chatId) {
16611
16931
  const data = await this.request(
16612
16932
  `/api/personal-chats/${encodeURIComponent(chatId)}`,
@@ -16932,19 +17252,23 @@ class ApiClient {
16932
17252
  }
16933
17253
  /**
16934
17254
  * Mint the signed caller grant that proves *this* user is the connector
16935
- * subject of a session spawned on a shared machine. The server binds it to
16936
- * userId/machineId/projectId/expiry and rejects a caller who cannot access
16937
- * the machine or project (web-ui server/mcpCallerGrantRoute.ts). `projectId`
16938
- * is required by the route — send an explicit null, not an omitted field,
16939
- * because the grant's project scope must match the spawn's.
17255
+ * subject of a session spawned on a grant-capable machine. The server binds it to
17256
+ * userId/machineId/projectId-or-personalChatId/expiry and rejects a caller
17257
+ * who cannot access that runtime scope (web-ui server/mcpCallerGrantRoute.ts).
17258
+ * `projectId` is required by the route — Personal Chat grants send an explicit
17259
+ * null plus `personalChatId` so the signed scope matches the spawn.
16940
17260
  */
16941
- async mintMcpCallerGrant(machineId, projectId) {
17261
+ async mintMcpCallerGrant(machineId, projectId, personalChatId) {
16942
17262
  const data = await this.request(
16943
17263
  "/api/me/mcp-caller-grant",
16944
17264
  {
16945
17265
  method: "POST",
16946
17266
  boundary: "api",
16947
- body: JSON.stringify({ machineId, projectId })
17267
+ body: JSON.stringify({
17268
+ machineId,
17269
+ projectId,
17270
+ ...personalChatId ? { personalChatId } : {}
17271
+ })
16948
17272
  }
16949
17273
  );
16950
17274
  if (typeof data.grant !== "string" || !data.grant) throw new Error("mcp caller grant is missing");
@@ -17585,7 +17909,7 @@ class ApiClient {
17585
17909
  {
17586
17910
  method: "POST",
17587
17911
  boundary: "api",
17588
- body: buildWorktreeMutationBody(input)
17912
+ body: buildWorktreeMutationBody(input, { includeMachinePlatform: this.config.standalone })
17589
17913
  }
17590
17914
  );
17591
17915
  return data.worktree;
@@ -20216,6 +20540,22 @@ class ChatInputController {
20216
20540
  this.readline.off("close", this.handleClose);
20217
20541
  }
20218
20542
  }
20543
+ const MACOS_FIRMLINK_ALIASES = [
20544
+ ["/tmp", "/private/tmp"],
20545
+ ["/var", "/private/var"],
20546
+ ["/etc", "/private/etc"]
20547
+ ];
20548
+ function canonicalizeMacOsFirmlinkAlias(path) {
20549
+ for (const [logical, physical] of MACOS_FIRMLINK_ALIASES) {
20550
+ if (path === logical) return physical;
20551
+ if (path.startsWith(`${logical}/`)) return physical + path.slice(logical.length);
20552
+ }
20553
+ return path;
20554
+ }
20555
+ function canonicalizeRemotePathForComparison(value2, platform2) {
20556
+ const normalized = value2.trim().replace(/\/+$/, "");
20557
+ return platform2 === "darwin" ? canonicalizeMacOsFirmlinkAlias(normalized) : normalized;
20558
+ }
20219
20559
  function worktreeTargetMismatch(record2, expected) {
20220
20560
  if (record2.projectId !== expected.projectId) return "project";
20221
20561
  if (record2.machineId !== expected.machineId) return "machine";
@@ -20273,32 +20613,15 @@ function resolveWorktreeName(input) {
20273
20613
  }
20274
20614
  const MANAGED_WORKTREE_SEGMENT = "/.aplus/worktrees/";
20275
20615
  const MAX_MANAGED_WORKTREE_DEPTH = 2;
20276
- function isWorktreePathWithinProjectRoot(projectRoot, worktreePath) {
20277
- const root = projectRoot.trim().replace(/\/+$/, "");
20278
- const path = worktreePath.trim().replace(/\/+$/, "");
20616
+ function isWorktreePathWithinProjectRoot(projectRoot, worktreePath, platform2) {
20617
+ const root = canonicalizeRemotePathForComparison(projectRoot, platform2);
20618
+ const path = canonicalizeRemotePathForComparison(worktreePath, platform2);
20279
20619
  if (!root || !path) return false;
20280
20620
  if (!path.startsWith(`${root}${MANAGED_WORKTREE_SEGMENT}`)) return false;
20281
20621
  const segments = path.slice(root.length + MANAGED_WORKTREE_SEGMENT.length).split("/");
20282
20622
  if (segments.length < 1 || segments.length > MAX_MANAGED_WORKTREE_DEPTH) return false;
20283
20623
  return segments.every((segment) => segment.length > 0 && segment !== "." && segment !== "..");
20284
20624
  }
20285
- async function createWorktreeOnMachine(input) {
20286
- const record2 = await input.client.createProjectWorktree(input.projectId, {
20287
- name: input.name,
20288
- baseRef: input.baseRef,
20289
- machineId: input.machineId,
20290
- ...input.confirmRepoRoot ? { confirmRepoRoot: input.confirmRepoRoot } : {}
20291
- });
20292
- const targetMismatch = worktreeTargetMismatch(record2, {
20293
- projectId: input.projectId,
20294
- machineId: input.machineId
20295
- });
20296
- const allowedRoot = input.confirmRepoRoot ?? input.repoCwd;
20297
- const pathMatches = isWorktreePathWithinProjectRoot(allowedRoot, record2.path);
20298
- if (!targetMismatch && pathMatches) return record2;
20299
- await input.client.deleteProjectWorktree(input.projectId, record2.id, true).catch(() => void 0);
20300
- throw new Error(!pathMatches && input.pathMismatchMessage ? input.pathMismatchMessage(record2.path) : input.targetMismatchMessage);
20301
- }
20302
20625
  const WORKTREE_SUBDIR_CONFIRM_CODE = "worktree-subdir-confirm-required";
20303
20626
  function trimmedString(value2) {
20304
20627
  if (typeof value2 !== "string") return null;
@@ -20338,6 +20661,23 @@ function parseWorktreeSubdirConfirmRequest(status, body, projectRoot) {
20338
20661
  if (!isSafeRelativeDir(repoRelativeDir)) return null;
20339
20662
  return { repoRoot, workspaceDir, repoRelativeDir };
20340
20663
  }
20664
+ async function createWorktreeOnMachine(input) {
20665
+ const record2 = await input.client.createProjectWorktree(input.projectId, {
20666
+ name: input.name,
20667
+ baseRef: input.baseRef,
20668
+ machineId: input.machineId,
20669
+ ...input.confirmRepoRoot ? { confirmRepoRoot: input.confirmRepoRoot } : {}
20670
+ });
20671
+ const targetMismatch = worktreeTargetMismatch(record2, {
20672
+ projectId: input.projectId,
20673
+ machineId: input.machineId
20674
+ });
20675
+ const allowedRoot = input.confirmRepoRoot ?? input.repoCwd;
20676
+ const pathMatches = isWorktreePathWithinProjectRoot(allowedRoot, record2.path, input.machinePlatform);
20677
+ if (!targetMismatch && pathMatches) return record2;
20678
+ await input.client.deleteProjectWorktree(input.projectId, record2.id, true).catch(() => void 0);
20679
+ throw new Error(!pathMatches && input.pathMismatchMessage ? input.pathMismatchMessage(record2.path) : input.targetMismatchMessage);
20680
+ }
20341
20681
  async function createCliProjectWorktree(input, deps) {
20342
20682
  const createInput = {
20343
20683
  client: {
@@ -23307,7 +23647,7 @@ ${text2}` : text2);
23307
23647
  return sections.join("\n\n");
23308
23648
  }
23309
23649
  const HANDOFF_READ_MESSAGES = 400;
23310
- async function handoffToSibling(runtime, scope, env, handoff, agent) {
23650
+ async function handoffToSibling(runtime, scope, env, handoff, agent, modelOptions = {}) {
23311
23651
  const from = handoff.from ?? env.sessionId;
23312
23652
  if (!from) {
23313
23653
  return agentFail(
@@ -23346,6 +23686,7 @@ async function handoffToSibling(runtime, scope, env, handoff, agent) {
23346
23686
  sessionId = (await runtime.spawnSession({
23347
23687
  directory: env.root,
23348
23688
  agent,
23689
+ ...modelOptions,
23349
23690
  initialPrompt: prompt,
23350
23691
  environmentVariables: childAgentEnv(env, childId)
23351
23692
  })).sessionId;
@@ -23451,7 +23792,7 @@ function resolveSubagentControlPermission(records, parentSessionId, childSession
23451
23792
  return valid ? isDescendant(valid, parentSessionId, childSessionId, 2) : false;
23452
23793
  }
23453
23794
  function canControlSpawnedDescendant(records, parentSessionId, childSessionId) {
23454
- const lineage = records.flatMap((record2) => record2.outcome === "spawned" && record2.parentSessionId && record2.childSessionId ? [{
23795
+ const lineage = records.flatMap((record2) => record2.outcome === "spawned" && record2.parentSessionId && record2.childSessionId && record2.parentSessionId !== record2.childSessionId && Number.isFinite(record2.ts) && record2.ts >= 0 ? [{
23455
23796
  parentSessionId: record2.parentSessionId,
23456
23797
  childSessionId: record2.childSessionId,
23457
23798
  spawnedAt: record2.ts,
@@ -23492,24 +23833,27 @@ async function stopOwnedTarget(runtime, env, target) {
23492
23833
  const refused = ownershipFailure(runtime, env, target);
23493
23834
  if (refused) return refused;
23494
23835
  if (!runtime.stopSession) return agentFail("stop_rejected", "stop transport is unavailable");
23495
- let stream2 = null;
23496
- try {
23497
- stream2 = await runtime.openStream(target.session.id);
23498
- if (stream2.current().thinking !== true) {
23499
- return agentFail("stop_not_responding", `${target.session.id} has no active response`);
23500
- }
23501
- } catch (error) {
23502
- return agentFail("stop_rejected", error instanceof Error ? error.message : String(error));
23503
- } finally {
23504
- stream2?.close();
23505
- }
23506
23836
  try {
23507
23837
  const result = await runtime.stopSession(target.session.machineId, target.session.id);
23508
- return result.stopped ? agentOk({ sessionId: target.session.id, stopped: true }) : agentFail("stop_rejected", result.error);
23838
+ if (result.stopped) return agentOk({ sessionId: target.session.id, stopped: true });
23839
+ return result.reason === "not-responding" ? agentFail("stop_not_responding", result.error) : agentFail("stop_rejected", result.error);
23509
23840
  } catch (error) {
23510
23841
  return agentFail("stop_rejected", error instanceof Error ? error.message : String(error));
23511
23842
  }
23512
23843
  }
23844
+ const CLI_CONFIGURABLE_AGENTS = [.../* @__PURE__ */ new Set([
23845
+ ...newSessionAgents.map(({ id }) => id),
23846
+ "gemini"
23847
+ ])];
23848
+ function cliSpawnModelOptions() {
23849
+ return CLI_CONFIGURABLE_AGENTS.map((agent) => ({
23850
+ agent,
23851
+ models: modelChoices(agent).map(({ key: id }) => ({
23852
+ id,
23853
+ efforts: effortChoices(agent, id).map(({ key }) => key)
23854
+ }))
23855
+ }));
23856
+ }
23513
23857
  const DEFAULT_READ_LIMIT = 20;
23514
23858
  const STATE_WINDOW_MESSAGES = 50;
23515
23859
  function scopeOf(runtime) {
@@ -23563,7 +23907,7 @@ async function readSession(runtime, target, last) {
23563
23907
  });
23564
23908
  }
23565
23909
  const DEFAULT_SPAWN_AGENT = "claude";
23566
- async function spawnSibling(runtime, env, text2, agent, contextFiles = []) {
23910
+ async function spawnSibling(runtime, env, text2, agent, contextFiles = [], modelOptions = {}) {
23567
23911
  if (env.depth >= AGENT_MAX_DEPTH) {
23568
23912
  return agentFail("spawn_depth_exceeded", `depth ${env.depth} is already at the ceiling of ${AGENT_MAX_DEPTH}`);
23569
23913
  }
@@ -23591,6 +23935,7 @@ async function spawnSibling(runtime, env, text2, agent, contextFiles = []) {
23591
23935
  sessionId = (await runtime.spawnSession({
23592
23936
  directory: env.root,
23593
23937
  agent,
23938
+ ...modelOptions,
23594
23939
  initialPrompt: composeSpawnPrompt(text2, context.paths),
23595
23940
  environmentVariables: childAgentEnv(env, childId)
23596
23941
  })).sessionId;
@@ -23640,7 +23985,8 @@ async function runAgentCommand(runtime, args) {
23640
23985
  maxSpawn: env.maxSpawn,
23641
23986
  spawned,
23642
23987
  spawnsRemaining: spawned === null ? null : Math.max(0, env.maxSpawn - spawned),
23643
- canSpawn: env.depth < AGENT_MAX_DEPTH && (spawned === null || spawned < env.maxSpawn)
23988
+ canSpawn: env.depth < AGENT_MAX_DEPTH && spawned !== null && spawned < env.maxSpawn,
23989
+ spawnModelOptions: cliSpawnModelOptions()
23644
23990
  });
23645
23991
  }
23646
23992
  if (args.verb === "ls") {
@@ -23648,10 +23994,13 @@ async function runAgentCommand(runtime, args) {
23648
23994
  return agentOk({ sessions: sessions.filter((session) => isInScope(session, scope)).map(sessionSummary) });
23649
23995
  }
23650
23996
  if (args.verb === "spawn") {
23997
+ const agent = args.agent ?? DEFAULT_SPAWN_AGENT;
23998
+ const modelOptions = args.model !== void 0 || args.effort !== void 0 ? await validateSpawnModelOptions(agent, args.model, args.effort) : {};
23999
+ if (isFailure(modelOptions)) return modelOptions;
23651
24000
  if (args.handoff) {
23652
- return handoffToSibling(runtime, scope, env, args.handoff, args.agent ?? DEFAULT_SPAWN_AGENT);
24001
+ return handoffToSibling(runtime, scope, env, args.handoff, agent, modelOptions);
23653
24002
  }
23654
- return spawnSibling(runtime, env, args.text ?? "", args.agent ?? DEFAULT_SPAWN_AGENT, args.files ?? []);
24003
+ return spawnSibling(runtime, env, args.text ?? "", agent, args.files ?? [], modelOptions);
23655
24004
  }
23656
24005
  if (!args.sessionId) return agentFail("session_not_found", "no session id given");
23657
24006
  const target = await resolveTarget(runtime, scope, args.sessionId);
@@ -23676,6 +24025,17 @@ async function runAgentCommand(runtime, args) {
23676
24025
  return agentFail("session_not_found", `unsupported verb "${args.verb}"`);
23677
24026
  }
23678
24027
  }
24028
+ async function validateSpawnModelOptions(agent, model, effort) {
24029
+ try {
24030
+ await resolveCliNewConversationOptions({ agent, model, effort });
24031
+ return {
24032
+ ...model !== void 0 && model !== "default" ? { model } : {},
24033
+ ...effort !== void 0 ? { effort } : {}
24034
+ };
24035
+ } catch (error) {
24036
+ return agentFail("invalid_spawn_options", error instanceof Error ? error.message : String(error));
24037
+ }
24038
+ }
23679
24039
  function parseSpawnRecord(value2) {
23680
24040
  if (!value2 || typeof value2 !== "object" || Array.isArray(value2)) return null;
23681
24041
  const record2 = value2;
@@ -23770,25 +24130,33 @@ async function fileSessionUnderProject(context, env, input) {
23770
24130
  if (!viaWorktree) return;
23771
24131
  await context.rest.assignSessionToProject(viaWorktree.projectId, input.sessionId, viaWorktree.worktreeId);
23772
24132
  }
23773
- function rejectedSteer(error) {
23774
- return {
23775
- accepted: false,
23776
- reason: /\bin[- ]flight\b/i.test(error) ? "in-flight" : "rejected",
23777
- error
23778
- };
23779
- }
23780
- async function steerAgentSession(transport, sessionId, text2) {
23781
- try {
23782
- const result = await transport.rpc(sessionId, "steer", { text: text2 });
23783
- if (result?.success === true) return { accepted: true };
23784
- const error = typeof result?.error === "string" ? result.error : "Session returned an invalid steer acknowledgement.";
23785
- return rejectedSteer(error);
23786
- } catch (error) {
23787
- return rejectedSteer(error instanceof Error ? error.message : String(error));
23788
- }
24133
+ const ACTIVITY_WAIT_TIMEOUT_MS = 5e3;
24134
+ function waitForResponseActivity(transport, sessionId) {
24135
+ return new Promise((resolve) => {
24136
+ let unsubscribe = () => {
24137
+ };
24138
+ const finish = (thinking) => {
24139
+ clearTimeout(timer);
24140
+ unsubscribe();
24141
+ resolve(thinking);
24142
+ };
24143
+ const timer = setTimeout(() => finish(null), ACTIVITY_WAIT_TIMEOUT_MS);
24144
+ unsubscribe = transport.onEphemeral((event) => {
24145
+ if (!event || typeof event !== "object") return;
24146
+ const activity = event;
24147
+ if (activity.type !== "activity" || activity.id !== sessionId || typeof activity.thinking !== "boolean") return;
24148
+ finish(activity.thinking);
24149
+ });
24150
+ });
23789
24151
  }
23790
24152
  async function stopAgentSession(transport, machineId, sessionId) {
23791
24153
  try {
24154
+ const thinking = await waitForResponseActivity(transport, sessionId);
24155
+ if (thinking !== true) return {
24156
+ stopped: false,
24157
+ reason: thinking === false ? "not-responding" : "rejected",
24158
+ error: thinking === false ? "Session has no active response." : "Could not confirm the session response state."
24159
+ };
23792
24160
  const result = await transport.rpc(machineId, "stop-session", {
23793
24161
  sessionId,
23794
24162
  source: "saycode-agent-stop",
@@ -23796,17 +24164,37 @@ async function stopAgentSession(transport, machineId, sessionId) {
23796
24164
  mode: "force"
23797
24165
  });
23798
24166
  if (result?.stopped === true) return { stopped: true };
23799
- return {
24167
+ if (result?.stopped === false) return {
23800
24168
  stopped: false,
23801
- error: typeof result?.reason === "string" ? result.reason : "Machine returned an invalid stop acknowledgement."
24169
+ reason: "not-responding",
24170
+ error: typeof result.reason === "string" ? result.reason : "Session has no active response."
23802
24171
  };
24172
+ return { stopped: false, reason: "rejected", error: "Machine returned an invalid stop acknowledgement." };
23803
24173
  } catch (error) {
23804
24174
  return {
23805
24175
  stopped: false,
24176
+ reason: "rejected",
23806
24177
  error: error instanceof Error ? error.message : String(error)
23807
24178
  };
23808
24179
  }
23809
24180
  }
24181
+ function rejectedSteer(error) {
24182
+ return {
24183
+ accepted: false,
24184
+ reason: /\bin[- ]flight\b/i.test(error) ? "in-flight" : "rejected",
24185
+ error
24186
+ };
24187
+ }
24188
+ async function steerAgentSession(transport, sessionId, text2) {
24189
+ try {
24190
+ const result = await transport.rpc(sessionId, "steer", { text: text2 });
24191
+ if (result?.success === true) return { accepted: true };
24192
+ const error = typeof result?.error === "string" ? result.error : "Session returned an invalid steer acknowledgement.";
24193
+ return rejectedSteer(error);
24194
+ } catch (error) {
24195
+ return rejectedSteer(error instanceof Error ? error.message : String(error));
24196
+ }
24197
+ }
23810
24198
  const STREAM_HISTORY_LIMIT = 50;
23811
24199
  const SPAWN_RPC_TIMEOUT_MS = 6e4;
23812
24200
  function isReadableFileSync(path) {
@@ -23827,7 +24215,7 @@ function readFileOrNull(path) {
23827
24215
  async function recentContents(context, transport, sessionId, limit) {
23828
24216
  const messages = await context.rest.recentMessages(sessionId, limit);
23829
24217
  const contents = [];
23830
- for (const message of messages) {
24218
+ for (const message of [...messages].sort((left, right) => left.seq - right.seq)) {
23831
24219
  const ciphertext = unwrapEncryptedField(message.content);
23832
24220
  if (!ciphertext) continue;
23833
24221
  const content = decodeContent(transport, ciphertext);
@@ -23918,6 +24306,8 @@ function createAgentRuntime(context, transport, env = process.env) {
23918
24306
  {
23919
24307
  directory: input.directory,
23920
24308
  agent: input.agent,
24309
+ ...input.model ? { model: input.model } : {},
24310
+ ...input.effort ? { effort: input.effort } : {},
23921
24311
  initialPrompt: input.initialPrompt,
23922
24312
  environmentVariables: input.environmentVariables
23923
24313
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@buzzni/saycode-cli",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "Drive Saycode sessions across your machines from the terminal.",
5
5
  "type": "module",
6
6
  "bin": {