@buzzni/saycode-cli 0.3.1 → 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 +364 -61
  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.1";
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": "응답을 준비하는 중…",
@@ -1323,6 +1329,9 @@ const subagentKo = {
1323
1329
  "subagent.error.inFlight": "이전 제어 요청을 처리하는 중입니다.",
1324
1330
  "subagent.error.rejected": "요청이 거부됐습니다: {detail}"
1325
1331
  };
1332
+ const reviewNavigationKo = {
1333
+ "review.action.openSourceSession": "원본 대화로 이동: {title}"
1334
+ };
1326
1335
  const aiCommitPrKo = {
1327
1336
  "aiCommitPr.stagedCount": "스테이지된 파일 {count}개",
1328
1337
  "aiCommitPr.noStaged": "스테이지된 변경이 없습니다.",
@@ -1363,7 +1372,39 @@ const aiCommitPrKo = {
1363
1372
  "aiCommitPr.pr.create": "Pull Request 만들기",
1364
1373
  "aiCommitPr.pr.creating": "만드는 중…"
1365
1374
  };
1366
- 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 };
1367
1408
  const onboardingKo = {
1368
1409
  "onboarding.dialogAria": "시작 설정",
1369
1410
  "onboarding.eyebrow": "처음 시작하기",
@@ -2306,6 +2347,7 @@ const ko = {
2306
2347
  "projectSettings.tabRepository": "코드 저장소",
2307
2348
  "projectSettings.tabDeploy": "배포 저장소",
2308
2349
  "projectSettings.tabCollaboration": "협업",
2350
+ "projectSettings.tabLinks": "관련 프로젝트",
2309
2351
  "projectSettings.tabMachine": "머신 및 경로",
2310
2352
  "projectSettings.tabRun": "실행",
2311
2353
  "projectSettings.tabMcp": "MCP 서버/스킬",
@@ -2935,6 +2977,7 @@ const ko = {
2935
2977
  "worktreeOp.checkingStatus": "worktree 상태 확인 중...",
2936
2978
  "worktreeOp.forceDeleteUnpushed": "worktree에 푸시되지 않은 커밋 {count}개가 있습니다. 강제로 삭제할까요? 커밋이 사라집니다.",
2937
2979
  "worktreeOp.forceDeleteDirty": "worktree에 정리되지 않은 변경이 있습니다. 강제로 삭제할까요? 변경 사항이 사라집니다.",
2980
+ "worktreeOp.trackedChangesBlocked": "이 worktree에 커밋되지 않은 tracked 변경이 있습니다. 변경을 커밋하거나 stash하거나 명시적으로 버린 뒤 다시 시도하세요.",
2938
2981
  "worktreeOp.removeCancelled": "정리되지 않은 변경/커밋이 있어 삭제를 취소했습니다.",
2939
2982
  "worktreeOp.removed": "worktree를 삭제했습니다.",
2940
2983
  "project.sharedBy": " · 공유: {owner}",
@@ -3300,6 +3343,7 @@ const ko = {
3300
3343
  "saycodePromptPreference.eyebrow": "에이전트 지시 선택",
3301
3344
  "saycodePromptPreference.title": "Saycode 기본 지시를 사용할까요?",
3302
3345
  "saycodePromptPreference.body": "내가 만든 Claude/Codex 스킬과 하네스를 그대로 쓰려면 꺼진 상태를 유지하세요. 나중에 환경설정에서 언제든 바꿀 수 있습니다.",
3346
+ "saycodePromptPreference.defaultOnNotice": "자식 Agent 호출, 내부 작업 위임, 결과물 인라인 미리보기는 기본으로 계속 켜집니다. 이 기능도 끄려면 환경설정 → 시스템 프롬프트에서 각각 해제하세요.",
3303
3347
  "saycodePromptPreference.keepOff": "꺼진 상태 유지",
3304
3348
  "saycodePromptPreference.enable": "Saycode 지시 켜기",
3305
3349
  "saycodePromptPreference.saving": "저장 중…",
@@ -3308,9 +3352,11 @@ const ko = {
3308
3352
  "saycodePromptPreference.settingsEnable": "Saycode가 제공하는 기본 행동 지시 사용",
3309
3353
  "saycodePromptPreference.settingsHelp": "사용자 시스템 프롬프트, 프로젝트 지침, 설치한 스킬과 하네스는 이 설정과 관계없이 유지됩니다.",
3310
3354
  "saycodePromptPreference.blocksTitle": "개별 지시 선택",
3311
- "saycodePromptPreference.blocksHelp": "Saycode 지시를 항목별로 켜고 끕니다. 선택하지 않은 항목은 위의 전체 설정을 따릅니다.",
3355
+ "saycodePromptPreference.blocksHelp": "자식 Agent 호출, 내부 작업 위임, 결과물 인라인 미리보기는 기본으로 켜지며, 나머지 항목은 위의 전체 설정을 따릅니다.",
3312
3356
  "saycodePromptPreference.alwaysOnNotice": "채팅 제목 생성은 항상 켜져 있으며 끌 수 없습니다. 채팅 목록에서 대화를 다시 찾으려면 필요합니다.",
3313
- "saycodePromptPreference.block.workerDelegation.label": "작업 위임",
3357
+ "saycodePromptPreference.block.agentOrchestration.label": "자식 Agent 호출",
3358
+ "saycodePromptPreference.block.agentOrchestration.description": "자연어로 요청한 작업을 독립된 자식 Agent 세션에 맡기고 결과를 회수하도록 지시합니다.",
3359
+ "saycodePromptPreference.block.workerDelegation.label": "내부 작업 위임",
3314
3360
  "saycodePromptPreference.block.workerDelegation.description": "기계적인 작업을 저렴한 worker 에이전트에 넘기도록 지시합니다.",
3315
3361
  "saycodePromptPreference.block.axBase.label": "기획부터 시작 워크플로우",
3316
3362
  "saycodePromptPreference.block.axBase.description": "AX 워크플로우의 기본 지시를 사용합니다.",
@@ -4450,7 +4496,7 @@ Options
4450
4496
  -p, --project <id|name> Project id prefix or name
4451
4497
  -d, --directory <path> Working directory on that machine
4452
4498
  -s, --session <id> Session id (see \`saycode ls\`)
4453
- --agent <name> claude (default), codex, gemini, openclaw, opencode
4499
+ --agent <name> claude (default), codex, gemini, grok, openclaw, opencode
4454
4500
  --model <name> Model for new-session messages (Default if omitted)
4455
4501
  --effort <level> Model effort (validated against agent + model)
4456
4502
  --worktree Create an isolated worktree for a project chat
@@ -4473,7 +4519,8 @@ Agent commands (machine-readable JSON on stdout, exit 3 on failure)
4473
4519
  agent prompt <session> <text> [--wait] Send a prompt, optionally wait for it
4474
4520
  agent steer <session> <text> Redirect an owned child's active response
4475
4521
  agent stop <session> Stop an owned child's active response
4476
- 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
4477
4524
  agent spawn --prompt <text> --file <path> ...with context files it reads first
4478
4525
  Only works inside a session Saycode started (SAYCODE_AGENT_ENV).
4479
4526
 
@@ -6872,6 +6919,58 @@ function buildUserTextMessageContent(input) {
6872
6919
  }
6873
6920
  };
6874
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
+ }
6875
6974
  const CONNECTOR_PROVIDER_IDS = [
6876
6975
  "notion",
6877
6976
  "slack",
@@ -16001,6 +16100,31 @@ function coercePromptSuggestion(value2) {
16001
16100
  if (!text2 || !provider2 || updatedAt === void 0) return void 0;
16002
16101
  return { text: text2, provider: provider2, updatedAt };
16003
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
+ }
16004
16128
  function coerceCreatedBy(value2) {
16005
16129
  if (!isRecord$1(value2)) return void 0;
16006
16130
  const accountId = nonEmptyString(value2.accountId);
@@ -16035,6 +16159,7 @@ function apiSessionToSession(api, candidateKeys, existing) {
16035
16159
  const branchSlug = branchSlugFromMetadata(metadata) ?? existing?.branchSlug;
16036
16160
  const path = nonEmptyString(metadata?.path) ?? existing?.path;
16037
16161
  const promptSuggestion2 = metadata && Object.prototype.hasOwnProperty.call(metadata, "promptSuggestion") ? coercePromptSuggestion(metadata.promptSuggestion) : existing?.promptSuggestion;
16162
+ const mcpServers = coerceMcpServerStatuses(metadata?.mcpServers) ?? existing?.mcpServers;
16038
16163
  return {
16039
16164
  ...existing,
16040
16165
  id: api.id,
@@ -16061,6 +16186,7 @@ function apiSessionToSession(api, candidateKeys, existing) {
16061
16186
  ...currentModelCode ? { currentModelCode } : {},
16062
16187
  ...tokenUsage ? { tokenUsage } : {},
16063
16188
  ...slashCommands ? { slashCommands } : {},
16189
+ ...mcpServers ? { mcpServers } : {},
16064
16190
  ...claudeSessionId ? { claudeSessionId } : {},
16065
16191
  ...codexThreadId ? { codexThreadId } : {},
16066
16192
  ...lifecycleState ? { lifecycleState } : {},
@@ -16103,9 +16229,10 @@ function readMcpCallerGrantPublicKey(daemonState) {
16103
16229
  const value2 = daemonState.mcpCallerGrantPublicKey;
16104
16230
  return typeof value2 === "string" && value2 ? value2 : void 0;
16105
16231
  }
16232
+ const CLEARABLE_STRING_KEYS = /* @__PURE__ */ new Set(["branch", "baseBranch"]);
16106
16233
  function buildWorktreeMutationBody(input, options = {}) {
16107
16234
  return JSON.stringify(Object.fromEntries(
16108
- Object.entries(input).filter(([key, value2]) => (key !== "machinePlatform" || options.includeMachinePlatform) && (typeof value2 === "string" && (value2.length > 0 || key === "branch") || key === "branch" && value2 === null))
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)))
16109
16236
  ));
16110
16237
  }
16111
16238
  const STANDALONE_USER_ID = "standalone-local";
@@ -16642,6 +16769,117 @@ class ApiClient {
16642
16769
  });
16643
16770
  return data.items ?? [];
16644
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
+ }
16645
16883
  async addProjectFavorite(projectId) {
16646
16884
  return this.request(`/api/projects/${encodeURIComponent(projectId)}/favorite`, {
16647
16885
  method: "PUT",
@@ -16657,6 +16895,7 @@ class ApiClient {
16657
16895
  // --- 개인 Chat (specs/personal-chats) ---
16658
16896
  async listPersonalChats(input = {}) {
16659
16897
  const params = new URLSearchParams();
16898
+ params.set("context", input.companyId ? `company:${input.companyId}` : "personal");
16660
16899
  if (input.limit !== void 0) params.set("limit", String(input.limit));
16661
16900
  if (input.offset !== void 0) params.set("offset", String(input.offset));
16662
16901
  if (input.query) params.set("query", input.query);
@@ -16672,12 +16911,22 @@ class ApiClient {
16672
16911
  const data = await this.request("/api/personal-chats", {
16673
16912
  method: "POST",
16674
16913
  boundary: "api",
16675
- body: JSON.stringify(
16676
- input.title === void 0 ? { machineId: input.machineId } : { machineId: input.machineId, title: input.title }
16677
- )
16914
+ body: JSON.stringify({
16915
+ machineId: input.machineId,
16916
+ ...input.title === void 0 ? {} : { title: input.title },
16917
+ ..."companyId" in input ? { companyId: input.companyId } : {}
16918
+ })
16678
16919
  });
16679
16920
  return data.chat;
16680
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
+ }
16681
16930
  async getPersonalChat(chatId) {
16682
16931
  const data = await this.request(
16683
16932
  `/api/personal-chats/${encodeURIComponent(chatId)}`,
@@ -17003,19 +17252,23 @@ class ApiClient {
17003
17252
  }
17004
17253
  /**
17005
17254
  * Mint the signed caller grant that proves *this* user is the connector
17006
- * subject of a session spawned on a shared machine. The server binds it to
17007
- * userId/machineId/projectId/expiry and rejects a caller who cannot access
17008
- * the machine or project (web-ui server/mcpCallerGrantRoute.ts). `projectId`
17009
- * is required by the route — send an explicit null, not an omitted field,
17010
- * 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.
17011
17260
  */
17012
- async mintMcpCallerGrant(machineId, projectId) {
17261
+ async mintMcpCallerGrant(machineId, projectId, personalChatId) {
17013
17262
  const data = await this.request(
17014
17263
  "/api/me/mcp-caller-grant",
17015
17264
  {
17016
17265
  method: "POST",
17017
17266
  boundary: "api",
17018
- body: JSON.stringify({ machineId, projectId })
17267
+ body: JSON.stringify({
17268
+ machineId,
17269
+ projectId,
17270
+ ...personalChatId ? { personalChatId } : {}
17271
+ })
17019
17272
  }
17020
17273
  );
17021
17274
  if (typeof data.grant !== "string" || !data.grant) throw new Error("mcp caller grant is missing");
@@ -23394,7 +23647,7 @@ ${text2}` : text2);
23394
23647
  return sections.join("\n\n");
23395
23648
  }
23396
23649
  const HANDOFF_READ_MESSAGES = 400;
23397
- async function handoffToSibling(runtime, scope, env, handoff, agent) {
23650
+ async function handoffToSibling(runtime, scope, env, handoff, agent, modelOptions = {}) {
23398
23651
  const from = handoff.from ?? env.sessionId;
23399
23652
  if (!from) {
23400
23653
  return agentFail(
@@ -23433,6 +23686,7 @@ async function handoffToSibling(runtime, scope, env, handoff, agent) {
23433
23686
  sessionId = (await runtime.spawnSession({
23434
23687
  directory: env.root,
23435
23688
  agent,
23689
+ ...modelOptions,
23436
23690
  initialPrompt: prompt,
23437
23691
  environmentVariables: childAgentEnv(env, childId)
23438
23692
  })).sessionId;
@@ -23538,7 +23792,7 @@ function resolveSubagentControlPermission(records, parentSessionId, childSession
23538
23792
  return valid ? isDescendant(valid, parentSessionId, childSessionId, 2) : false;
23539
23793
  }
23540
23794
  function canControlSpawnedDescendant(records, parentSessionId, childSessionId) {
23541
- 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 ? [{
23542
23796
  parentSessionId: record2.parentSessionId,
23543
23797
  childSessionId: record2.childSessionId,
23544
23798
  spawnedAt: record2.ts,
@@ -23579,24 +23833,27 @@ async function stopOwnedTarget(runtime, env, target) {
23579
23833
  const refused = ownershipFailure(runtime, env, target);
23580
23834
  if (refused) return refused;
23581
23835
  if (!runtime.stopSession) return agentFail("stop_rejected", "stop transport is unavailable");
23582
- let stream2 = null;
23583
- try {
23584
- stream2 = await runtime.openStream(target.session.id);
23585
- if (stream2.current().thinking !== true) {
23586
- return agentFail("stop_not_responding", `${target.session.id} has no active response`);
23587
- }
23588
- } catch (error) {
23589
- return agentFail("stop_rejected", error instanceof Error ? error.message : String(error));
23590
- } finally {
23591
- stream2?.close();
23592
- }
23593
23836
  try {
23594
23837
  const result = await runtime.stopSession(target.session.machineId, target.session.id);
23595
- 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);
23596
23840
  } catch (error) {
23597
23841
  return agentFail("stop_rejected", error instanceof Error ? error.message : String(error));
23598
23842
  }
23599
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
+ }
23600
23857
  const DEFAULT_READ_LIMIT = 20;
23601
23858
  const STATE_WINDOW_MESSAGES = 50;
23602
23859
  function scopeOf(runtime) {
@@ -23650,7 +23907,7 @@ async function readSession(runtime, target, last) {
23650
23907
  });
23651
23908
  }
23652
23909
  const DEFAULT_SPAWN_AGENT = "claude";
23653
- async function spawnSibling(runtime, env, text2, agent, contextFiles = []) {
23910
+ async function spawnSibling(runtime, env, text2, agent, contextFiles = [], modelOptions = {}) {
23654
23911
  if (env.depth >= AGENT_MAX_DEPTH) {
23655
23912
  return agentFail("spawn_depth_exceeded", `depth ${env.depth} is already at the ceiling of ${AGENT_MAX_DEPTH}`);
23656
23913
  }
@@ -23678,6 +23935,7 @@ async function spawnSibling(runtime, env, text2, agent, contextFiles = []) {
23678
23935
  sessionId = (await runtime.spawnSession({
23679
23936
  directory: env.root,
23680
23937
  agent,
23938
+ ...modelOptions,
23681
23939
  initialPrompt: composeSpawnPrompt(text2, context.paths),
23682
23940
  environmentVariables: childAgentEnv(env, childId)
23683
23941
  })).sessionId;
@@ -23727,7 +23985,8 @@ async function runAgentCommand(runtime, args) {
23727
23985
  maxSpawn: env.maxSpawn,
23728
23986
  spawned,
23729
23987
  spawnsRemaining: spawned === null ? null : Math.max(0, env.maxSpawn - spawned),
23730
- 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()
23731
23990
  });
23732
23991
  }
23733
23992
  if (args.verb === "ls") {
@@ -23735,10 +23994,13 @@ async function runAgentCommand(runtime, args) {
23735
23994
  return agentOk({ sessions: sessions.filter((session) => isInScope(session, scope)).map(sessionSummary) });
23736
23995
  }
23737
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;
23738
24000
  if (args.handoff) {
23739
- return handoffToSibling(runtime, scope, env, args.handoff, args.agent ?? DEFAULT_SPAWN_AGENT);
24001
+ return handoffToSibling(runtime, scope, env, args.handoff, agent, modelOptions);
23740
24002
  }
23741
- return spawnSibling(runtime, env, args.text ?? "", args.agent ?? DEFAULT_SPAWN_AGENT, args.files ?? []);
24003
+ return spawnSibling(runtime, env, args.text ?? "", agent, args.files ?? [], modelOptions);
23742
24004
  }
23743
24005
  if (!args.sessionId) return agentFail("session_not_found", "no session id given");
23744
24006
  const target = await resolveTarget(runtime, scope, args.sessionId);
@@ -23763,6 +24025,17 @@ async function runAgentCommand(runtime, args) {
23763
24025
  return agentFail("session_not_found", `unsupported verb "${args.verb}"`);
23764
24026
  }
23765
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
+ }
23766
24039
  function parseSpawnRecord(value2) {
23767
24040
  if (!value2 || typeof value2 !== "object" || Array.isArray(value2)) return null;
23768
24041
  const record2 = value2;
@@ -23857,25 +24130,33 @@ async function fileSessionUnderProject(context, env, input) {
23857
24130
  if (!viaWorktree) return;
23858
24131
  await context.rest.assignSessionToProject(viaWorktree.projectId, input.sessionId, viaWorktree.worktreeId);
23859
24132
  }
23860
- function rejectedSteer(error) {
23861
- return {
23862
- accepted: false,
23863
- reason: /\bin[- ]flight\b/i.test(error) ? "in-flight" : "rejected",
23864
- error
23865
- };
23866
- }
23867
- async function steerAgentSession(transport, sessionId, text2) {
23868
- try {
23869
- const result = await transport.rpc(sessionId, "steer", { text: text2 });
23870
- if (result?.success === true) return { accepted: true };
23871
- const error = typeof result?.error === "string" ? result.error : "Session returned an invalid steer acknowledgement.";
23872
- return rejectedSteer(error);
23873
- } catch (error) {
23874
- return rejectedSteer(error instanceof Error ? error.message : String(error));
23875
- }
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
+ });
23876
24151
  }
23877
24152
  async function stopAgentSession(transport, machineId, sessionId) {
23878
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
+ };
23879
24160
  const result = await transport.rpc(machineId, "stop-session", {
23880
24161
  sessionId,
23881
24162
  source: "saycode-agent-stop",
@@ -23883,17 +24164,37 @@ async function stopAgentSession(transport, machineId, sessionId) {
23883
24164
  mode: "force"
23884
24165
  });
23885
24166
  if (result?.stopped === true) return { stopped: true };
23886
- return {
24167
+ if (result?.stopped === false) return {
23887
24168
  stopped: false,
23888
- 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."
23889
24171
  };
24172
+ return { stopped: false, reason: "rejected", error: "Machine returned an invalid stop acknowledgement." };
23890
24173
  } catch (error) {
23891
24174
  return {
23892
24175
  stopped: false,
24176
+ reason: "rejected",
23893
24177
  error: error instanceof Error ? error.message : String(error)
23894
24178
  };
23895
24179
  }
23896
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
+ }
23897
24198
  const STREAM_HISTORY_LIMIT = 50;
23898
24199
  const SPAWN_RPC_TIMEOUT_MS = 6e4;
23899
24200
  function isReadableFileSync(path) {
@@ -24005,6 +24306,8 @@ function createAgentRuntime(context, transport, env = process.env) {
24005
24306
  {
24006
24307
  directory: input.directory,
24007
24308
  agent: input.agent,
24309
+ ...input.model ? { model: input.model } : {},
24310
+ ...input.effort ? { effort: input.effort } : {},
24008
24311
  initialPrompt: input.initialPrompt,
24009
24312
  environmentVariables: input.environmentVariables
24010
24313
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@buzzni/saycode-cli",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "description": "Drive Saycode sessions across your machines from the terminal.",
5
5
  "type": "module",
6
6
  "bin": {