@buzzni/saycode-cli 0.4.0 → 0.5.1

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 +6 -1
  2. package/index.mjs +517 -216
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -131,7 +131,12 @@ happy agent stop <id> # 사용자가 명시한 경우에만 현재 응
131
131
  `steer` 성공의 `accepted:true`는 세션 RPC가 요청을 받았다는 뜻이며 실제 transcript 전달 완료를
132
132
  뜻하지 않습니다(`delivered:false`). 끝난 턴의 후속 작업에는 `prompt`를 사용합니다. 사람이 시작한 세션
133
133
  (`depth` 0)은 스코프 안 모든 세션에 `steer`와 `stop`을 보낼 수 있고, spawn된 세션은 원장에 기록된 최대
134
- 2단계 자식만 허용하며, `stop`은 보관·삭제를 하지 않습니다. 이 원장 검사는
134
+ 2단계 자식만 허용하며, `stop`은 보관·삭제를 하지 않습니다. spawn 한도(`whoami`의 `maxSpawn`)는 세션이
135
+ 도는 머신의 코어·메모리에서 산정하므로 머신마다 다르고, 최근 6시간 안의 spawn만 세는 롤링 윈도우라
136
+ 소진돼도 시간이 지나면 회복됩니다(`budgetResetsAt`, ADR-063). 데몬이 확인하지 못한 시도도 같은
137
+ 6시간을 차감합니다 — 응답만 유실됐을 뿐 세션은 만들어졌을 수 있기 때문입니다. 깊이 상한에는 이런
138
+ 회복이 없고, 에이전트 세션 안에서는 `chat`으로 **새** 대화를 시작할 수 없습니다(기존 세션 attach는
139
+ 가능). 예산과 계보를 적용하는 경로가 `agent spawn` 하나뿐이어야 하기 때문입니다. 이 원장 검사는
135
140
  같은 OS 사용자 아래의 오조작을 줄이는 advisory 가드레일이며 보안 경계나 서버 측 권한 증명은 아닙니다.
136
141
  같은 worktree에는 동시에 둘 이상의 쓰기 자식을 만들지 말고 격리가 필요하면 Desktop의 관리형
137
142
  worktree 병렬 실행을 사용하세요.
package/index.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { createInterface } from "node:readline";
3
- import { homedir, tmpdir, platform } from "node:os";
3
+ import { homedir, tmpdir, platform, totalmem, cpus } from "node:os";
4
4
  import require$$0$4, { Buffer as Buffer$1 } from "buffer";
5
5
  import require$$0 from "crypto";
6
6
  import require$$0$1 from "fs";
@@ -16,7 +16,7 @@ import require$$3$1 from "net";
16
16
  import require$$4$1 from "tls";
17
17
  import require$$0$5 from "stream";
18
18
  import require$$0$3 from "zlib";
19
- import { readFileSync, mkdirSync, writeFileSync, rmSync, chmodSync, constants as constants$1, appendFileSync, accessSync, statSync } from "node:fs";
19
+ import { readFileSync, mkdirSync, writeFileSync, rmSync, chmodSync, constants as constants$1, appendFileSync, openSync, fstatSync, readSync, closeSync, accessSync, statSync } from "node:fs";
20
20
  import { dirname, join, basename, extname, delimiter } from "node:path";
21
21
  import { readFile, stat, mkdtemp, access, rm, mkdir, appendFile, writeFile, chmod } from "node:fs/promises";
22
22
  import { execFile, spawn } from "node:child_process";
@@ -40,7 +40,7 @@ function _mergeNamespaces(n, m) {
40
40
  }
41
41
  return Object.freeze(Object.defineProperty(n, Symbol.toStringTag, { value: "Module" }));
42
42
  }
43
- const version$1 = "0.4.0";
43
+ const version$1 = "0.5.1";
44
44
  const manifest = {
45
45
  version: version$1
46
46
  };
@@ -271,6 +271,15 @@ const machineUsageKo = {
271
271
  "machineUsage.pollingNote": "{minutes}분 전 · 머신당 5분마다 한 번만 조회",
272
272
  "machineUsage.autoSwitched": "{provider} 계정을 자동 전환했습니다: {from} → {to}"
273
273
  };
274
+ const hubSessionKo = {
275
+ "hubSession.enable": "이 대화를 프로젝트 허브로",
276
+ "hubSession.disable": "프로젝트 허브 해제",
277
+ "hubSession.hint": "같은 프로젝트의 다른 대화가 턴을 끝내거나 입력을 기다리면 이 대화에 메시지로 알려줍니다",
278
+ "hubSession.newSessionLabel": "이 대화를 프로젝트 허브로 시작",
279
+ "hubSession.newSessionHint": "다른 대화의 완료·질문을 여기로 모읍니다",
280
+ "hubSession.newSessionReplaces": '지금 허브는 "{title}" — 켜면 이 대화로 바뀝니다',
281
+ "hubSession.newSessionParallelUnavailable": "병렬로 여러 대화를 시작할 때는 허브를 지정할 수 없습니다 — 시작한 뒤 헤더에서 켜세요"
282
+ };
274
283
  const taskNotificationKo = {
275
284
  "taskNotification.ariaLabel": "백그라운드 작업 알림",
276
285
  "taskNotification.status.completed": "작업 완료",
@@ -1005,7 +1014,11 @@ const memoryEventsKo = {
1005
1014
  "settings.memoryEvents.unavailable": "기억 대시보드가 실행 중이 아닙니다.",
1006
1015
  "settings.memoryEvents.searchLabel": "기억 검색",
1007
1016
  "settings.memoryEvents.prevPage": "이전",
1008
- "settings.memoryEvents.nextPage": "다음"
1017
+ "settings.memoryEvents.nextPage": "다음",
1018
+ "settings.memoryEvents.delete": "삭제",
1019
+ "settings.memoryEvents.confirmDelete": "정말 삭제",
1020
+ "settings.memoryEvents.deleteHint": "삭제한 기억은 되돌릴 수 없습니다.",
1021
+ "settings.memoryEvents.deleteFailed": "삭제하지 못했습니다: {reason}"
1009
1022
  };
1010
1023
  const externalWorktreeInboxKo = {
1011
1024
  "externalWorktreeInbox.title": "앱 밖에서 만든 worktree",
@@ -1218,7 +1231,46 @@ const accountSettingsKo = {
1218
1231
  "settingsGroup.devices": "기기",
1219
1232
  "settingsGroup.app": "앱",
1220
1233
  "shortcuts.action.settings": "환경설정 열기",
1221
- "shortcuts.action.settingsDescription": "환경설정 창을 엽니다."
1234
+ "shortcuts.action.settingsDescription": "환경설정 창을 엽니다.",
1235
+ "accountKeyRecovery.title": "암호화된 데이터가 잠겨 있습니다",
1236
+ "accountKeyRecovery.description": "이 데스크톱에서 암호화된 데이터를 잠금 해제하려면 E2EE 복구 키를 입력하세요. 복구 키는 이 기기를 벗어나지 않습니다.",
1237
+ "accountKeyRecovery.label": "E2EE 복구 키",
1238
+ "accountKeyRecovery.unavailable": "이 계정에서는 복구 키 잠금 해제를 사용할 수 없습니다.",
1239
+ "accountKeyRecovery.invalid": "복구 키로 암호화된 데이터를 잠금 해제하지 못했습니다.",
1240
+ "accountKeyRecovery.failed": "복구 키 잠금 해제에 실패했습니다.",
1241
+ "accountKeyRecovery.action": "암호화된 데이터 잠금 해제",
1242
+ "accountKeyRecovery.busy": "잠금 해제 중…",
1243
+ "accountKeyDevice.title": "신뢰 기기",
1244
+ "accountKeyDevice.description": "기존 신뢰 기기에서 이 데스크톱을 승인하거나 대기 중인 기기 요청을 검토하세요.",
1245
+ "accountKeyDevice.loading": "신뢰 기기 상태를 불러오는 중…",
1246
+ "accountKeyDevice.loadFailed": "기기 승인 상태를 불러오지 못했습니다.",
1247
+ "accountKeyDevice.stableIdentityUnavailable": "안정적인 기기 ID를 사용할 수 없어 신뢰 기기 등록을 진행할 수 없습니다.",
1248
+ "accountKeyDevice.secureStorageUnavailable": "보안 기기 저장소를 사용할 수 없어 신뢰 기기 등록을 진행할 수 없습니다.",
1249
+ "accountKeyDevice.qrFailed": "기기 승인 QR을 만들지 못했습니다.",
1250
+ "accountKeyDevice.qrAlt": "기기 승인 QR 코드",
1251
+ "accountKeyDevice.requestFailed": "기기 승인 요청에 실패했습니다.",
1252
+ "accountKeyDevice.pending": "신뢰 기기의 승인을 기다리고 있습니다.",
1253
+ "accountKeyDevice.completeFailed": "기기 승인을 완료하지 못했습니다.",
1254
+ "accountKeyDevice.approved": "이 데스크톱이 신뢰 기기로 승인되었습니다.",
1255
+ "accountKeyDevice.cancelled": "이 기기에서 승인 요청을 취소했습니다.",
1256
+ "accountKeyDevice.cancelFailed": "기기 승인 요청을 취소하지 못했습니다.",
1257
+ "accountKeyDevice.requestsFailed": "대기 중인 기기 요청을 불러오지 못했습니다.",
1258
+ "accountKeyDevice.approveFailed": "기기를 승인하지 못했습니다.",
1259
+ "accountKeyDevice.rejectFailed": "기기 요청을 거절하지 못했습니다.",
1260
+ "accountKeyDevice.revokeFailed": "신뢰 기기를 폐기하지 못했습니다.",
1261
+ "accountKeyDevice.cleanupFailed": "기기는 폐기됐지만 로컬 자격 증명을 삭제하지 못했습니다.",
1262
+ "accountKeyDevice.lockPersistFailed": "기기는 폐기됐지만 로컬 세션 잠금을 저장하지 못했습니다.",
1263
+ "accountKeyDevice.request": "기기 승인 요청",
1264
+ "accountKeyDevice.check": "승인 확인",
1265
+ "accountKeyDevice.cancel": "요청 취소",
1266
+ "accountKeyDevice.scanBefore": "{time} 전에 승인된 기기에서 이 QR을 스캔하세요.",
1267
+ "accountKeyDevice.expires": "{time}에 만료",
1268
+ "accountKeyDevice.approve": "기기 승인",
1269
+ "accountKeyDevice.reject": "기기 거절",
1270
+ "accountKeyDevice.refresh": "요청 새로 고침",
1271
+ "accountKeyDevice.current": "현재 데스크톱",
1272
+ "accountKeyDevice.lastUsed": "마지막 사용: {time}",
1273
+ "accountKeyDevice.revoke": "신뢰 기기 폐기"
1222
1274
  };
1223
1275
  const projectScopeKo = {
1224
1276
  "projectScope.protected": "프로젝트 범위 보호됨",
@@ -1508,6 +1560,8 @@ const terminalFontKo = {
1508
1560
  const subagentKo = {
1509
1561
  "subagent.title": "자식 에이전트 {count}개",
1510
1562
  "subagent.open": "자식 대화 열기",
1563
+ "subagent.parent.link": "부모 에이전트로 이동: {title}",
1564
+ "subagent.parent.untitled": "제목 없는 대화",
1511
1565
  "subagent.status.responding": "응답 중",
1512
1566
  "subagent.status.waiting": "입력 대기",
1513
1567
  "subagent.status.completed": "완료",
@@ -1556,6 +1610,7 @@ const composerQuickCommandsKo = {
1556
1610
  const aiCommitPrKo = {
1557
1611
  "aiCommitPr.stagedCount": "스테이지된 파일 {count}개",
1558
1612
  "aiCommitPr.noStaged": "스테이지된 변경이 없습니다.",
1613
+ "aiCommitPr.stagedUnknown": "스테이지된 변경을 확인하지 못했습니다.",
1559
1614
  "aiCommitPr.stageAll": "모든 변경 스테이지",
1560
1615
  "aiCommitPr.staging": "스테이지하는 중…",
1561
1616
  "aiCommitPr.messagePlaceholder": "커밋 메시지",
@@ -1633,6 +1688,14 @@ const connectorReadinessKo = {
1633
1688
  "connectorReadiness.state.unknown": "상태 미확인"
1634
1689
  };
1635
1690
  const checkpointKo = {
1691
+ "checkpoint.activation.label": "파일 체크포인트 보호",
1692
+ "checkpoint.activation.title": "에이전트가 파일을 변경하기 전에 세션별 체크포인트를 만듭니다.",
1693
+ "checkpoint.activation.hint": "세션별",
1694
+ "checkpoint.activation.unavailable.platform": "macOS에서만 지원",
1695
+ "checkpoint.activation.unavailable.agent": "Claude와 Codex만 지원",
1696
+ "checkpoint.activation.unavailable.sandbox": "프로젝트 샌드박스 필요",
1697
+ "checkpoint.activation.unavailable.worktree": "작업 사본은 준비 중",
1698
+ "checkpoint.activation.error": "파일 체크포인트 보호를 사용할 수 없습니다: {reason}",
1636
1699
  "checkpoint.title": "파일 체크포인트",
1637
1700
  "checkpoint.status.protected": "보호됨",
1638
1701
  "checkpoint.status.legacy": "보호되지 않음",
@@ -1793,7 +1856,7 @@ const settingsInfoStructureKo = {
1793
1856
  "overflowMenu.aria": "추가 액션 메뉴",
1794
1857
  "overflowMenu.title": "추가 액션"
1795
1858
  };
1796
- 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, ...orgSearchKo, ...terminalFontKo, ...globalPauseKo, ...subagentKo, ...reviewNavigationKo, ...projectLinksKo, ...projectHandoffKo, ...chatFolderUploadKo, ...composerAddMenuKo, ...composerQuickCommandsKo, ...connectorReadinessKo, ...settingsInfoStructureKo, ...localLoopKo, ...localizationGapKo, ...checkpointKo, ...memoryGuardrailsKo, ...memoryEventsKo };
1859
+ 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, ...orgSearchKo, ...terminalFontKo, ...globalPauseKo, ...subagentKo, ...reviewNavigationKo, ...projectLinksKo, ...projectHandoffKo, ...chatFolderUploadKo, ...composerAddMenuKo, ...composerQuickCommandsKo, ...connectorReadinessKo, ...settingsInfoStructureKo, ...localLoopKo, ...localizationGapKo, ...checkpointKo, ...memoryGuardrailsKo, ...memoryEventsKo, ...hubSessionKo };
1797
1860
  const onboardingKo = {
1798
1861
  "boot.stage.app": "앱 준비 중…",
1799
1862
  "boot.stage.localRuntime": "로컬 작업공간 준비 중…",
@@ -2605,6 +2668,7 @@ const ko = {
2605
2668
  "gitControls.branchLoading": "Git branch 확인 중",
2606
2669
  "gitControls.noRemote": "origin remote 또는 branch를 찾을 수 없습니다.",
2607
2670
  "gitControls.checkFailed": "Git 확인 실패",
2671
+ "gitControls.retryStatus": "다시 확인",
2608
2672
  "gitControls.checking": "Git 확인 중",
2609
2673
  "gitControls.manageBranchAria": "{branch} branch 관리",
2610
2674
  "gitControls.manageBranchTitle": "Branch 변경 및 생성",
@@ -3653,6 +3717,9 @@ const ko = {
3653
3717
  "preview.tokenUrlInvalid": "Preview 토큰 URL이 올바르지 않습니다.",
3654
3718
  "branchDialog.loading": "Branch 목록을 불러오는 중입니다.",
3655
3719
  "branchDialog.empty": "표시할 branch가 없습니다.",
3720
+ "branchDialog.searchLabel": "브랜치 검색",
3721
+ "branchDialog.searchPlaceholder": "브랜치 이름 검색",
3722
+ "branchDialog.searchEmpty": "'{query}'와 일치하는 브랜치가 없습니다.",
3656
3723
  "branchDialog.actionsAria": "{name} 브랜치 작업",
3657
3724
  "branchDialog.rowAria": "{name} {meta}",
3658
3725
  "branchDialog.switch": "브랜치 이동",
@@ -3667,6 +3734,24 @@ const ko = {
3667
3734
  "branchDialog.deleteConfirm": "'{name}' 브랜치를 삭제할까요? 머지되지 않은 커밋이 있으면 Git이 거부합니다.",
3668
3735
  "branchDialog.deleteConfirmAction": "삭제",
3669
3736
  "branchDialog.cancel": "취소",
3737
+ "branchCleanup.openAria": "병합된 브랜치 정리",
3738
+ "branchCleanup.title": "정리 가능한 브랜치",
3739
+ "branchCleanup.loading": "브랜치를 확인하는 중…",
3740
+ "branchCleanup.empty": "정리 가능한 브랜치가 없습니다.",
3741
+ "branchCleanup.scannedNote": "로컬 브랜치 {total}개 중 {count}개가 정리 가능합니다.",
3742
+ "branchCleanup.selectAll": "모두 선택",
3743
+ "branchCleanup.deselectAll": "모두 해제",
3744
+ "branchCleanup.deleteSelected": "{count}개 삭제",
3745
+ "branchCleanup.deleteFailed": "브랜치를 삭제할 수 없습니다.",
3746
+ "branchCleanup.deletePartial": "{total}개 중 {failed}개를 삭제하지 못했습니다: {detail}",
3747
+ "branchCleanup.loadFailed": "브랜치 확인을 실패했습니다.",
3748
+ "branchCleanup.noBaseBranch": "기본 브랜치를 찾을 수 없습니다. origin/HEAD 를 설정하거나 프로젝트 설정에서 기본 브랜치를 지정하세요.",
3749
+ "branchCleanup.probeIncomplete": "브랜치 정보를 완전히 읽지 못했습니다. 목록이 비어 보일 수 있으니 다시 시도하세요.",
3750
+ "branchCleanup.demoUnsupported": "Demo 모드에서는 지원하지 않습니다.",
3751
+ "branchCleanup.refreshAria": "브랜치 목록 새로고침",
3752
+ "branchCleanup.squashScanning": "squash merge된 브랜치를 찾는 중…",
3753
+ "branchCleanup.squashFailed": "squash merge 검사에 실패했습니다. 아래 목록은 일반 merge 후보만입니다.",
3754
+ "branchCleanup.squashTag": "squash 병합",
3670
3755
  "message.deleteConfirm": "이 메시지를 삭제할까요? 이 작업은 새로고침 후에도 유지됩니다.",
3671
3756
  "message.deleteFailed": "메시지를 삭제할 수 없습니다.",
3672
3757
  "message.sendFailed": "메시지를 보낼 수 없습니다.",
@@ -3955,6 +4040,7 @@ const ko = {
3955
4040
  "security.patDeleteFailed": "GitHub PAT를 삭제하지 못했습니다.",
3956
4041
  // Module-level helper errors (non-hook)
3957
4042
  "file.readFailed": "파일을 읽지 못했습니다.",
4043
+ "file.linkOutsideScope": "이 프로젝트가 열 수 있는 폴더 밖의 경로입니다: {path}",
3958
4044
  "session.loadMessagesFailed": "대화 메시지를 불러올 수 없습니다.",
3959
4045
  "session.sendToSessionFailed": "대화에 메시지를 보낼 수 없습니다.",
3960
4046
  "session.sendBlockedAccountKeyLocked": "이 대화는 계정 키로 잠겨 있습니다. 잠금을 해제한 뒤 다시 시도하세요.",
@@ -7531,6 +7617,7 @@ function parseUnlockMethod(value2) {
7531
7617
  const kdfParams = parseKdfParams(method.kdfParams);
7532
7618
  const salt = boundedAllowEmpty(method.salt, 256);
7533
7619
  const wrappedMasterKey = bounded(method.wrappedMasterKey, 16384);
7620
+ const possessionPublicKey = bounded(method.possessionPublicKey, 43);
7534
7621
  if (!id || !/^[A-Za-z0-9_-]+$/.test(id) || type !== "password" && type !== "recovery" && type !== "device" || !kdfAlgorithm || !kdfParams || salt === void 0 || !wrappedMasterKey) return void 0;
7535
7622
  const revision = method.revision;
7536
7623
  if (revision !== void 0 && (!Number.isSafeInteger(revision) || revision < 1)) return void 0;
@@ -7541,6 +7628,7 @@ function parseUnlockMethod(value2) {
7541
7628
  kdfParams,
7542
7629
  salt,
7543
7630
  wrappedMasterKey,
7631
+ ...possessionPublicKey && /^[A-Za-z0-9_-]{43}$/.test(possessionPublicKey) ? { possessionPublicKey } : {},
7544
7632
  ...revision === void 0 ? {} : { revision }
7545
7633
  };
7546
7634
  }
@@ -7674,6 +7762,19 @@ async function decryptDataKeyToString(dataEncryptionKey, bundleBase64) {
7674
7762
  return null;
7675
7763
  }
7676
7764
  }
7765
+ async function decryptDataKeyJson(dataEncryptionKey, bundleBase64) {
7766
+ const json = await decryptDataKeyToString(dataEncryptionKey, bundleBase64);
7767
+ if (json === null) return null;
7768
+ try {
7769
+ return JSON.parse(json);
7770
+ } catch {
7771
+ return null;
7772
+ }
7773
+ }
7774
+ async function decryptDataKeyRecord(dataEncryptionKey, bundleBase64) {
7775
+ const value2 = await decryptDataKeyJson(dataEncryptionKey, bundleBase64);
7776
+ return value2 && typeof value2 === "object" && !Array.isArray(value2) ? value2 : null;
7777
+ }
7677
7778
  async function encryptDataKeyContent(dataEncryptionKey, value2) {
7678
7779
  const nonce = nacl.randomBytes(GCM_NONCE_LENGTH);
7679
7780
  const key = await importGcmKey(dataEncryptionKey);
@@ -7687,6 +7788,9 @@ async function encryptDataKeyContent(dataEncryptionKey, value2) {
7687
7788
  }
7688
7789
  let candidates = [];
7689
7790
  let version = 0;
7791
+ function dataKeyUnwrapCandidates(accountPrivateKey, companyKeys = getCompanyKeyCandidates()) {
7792
+ return accountPrivateKey ? [accountPrivateKey, ...companyKeys] : [];
7793
+ }
7690
7794
  function getCompanyKeyCandidates() {
7691
7795
  return candidates.map((candidate) => candidate.privateKey);
7692
7796
  }
@@ -7696,9 +7800,25 @@ function companyKeyChainVersion() {
7696
7800
  const dekBySessionId = /* @__PURE__ */ new Map();
7697
7801
  const lockedSessionIds = /* @__PURE__ */ new Set();
7698
7802
  const fingerprints = /* @__PURE__ */ new Map();
7699
- let boundaryGeneration = 0;
7803
+ const companyUnwrappedEnvelopes = /* @__PURE__ */ new Map();
7804
+ function firstUnwrap(envelope, candidates2) {
7805
+ for (const candidate of candidates2) {
7806
+ const dek = unwrapDataEncryptionKey(envelope, candidate);
7807
+ if (dek) return dek;
7808
+ }
7809
+ return null;
7810
+ }
7811
+ let boundaryGeneration$1 = 0;
7700
7812
  function sessionDekRegistryGeneration() {
7701
- return boundaryGeneration;
7813
+ return boundaryGeneration$1;
7814
+ }
7815
+ function adoptSessionDek(sessionId, envelope, dek, byCompanyKey) {
7816
+ const existing = dekBySessionId.get(sessionId);
7817
+ if (existing && existing.length === dek.length && existing.every((byte, index) => byte === dek[index])) dek.fill(0);
7818
+ else dekBySessionId.set(sessionId, dek);
7819
+ lockedSessionIds.delete(sessionId);
7820
+ if (byCompanyKey) companyUnwrappedEnvelopes.set(sessionId, envelope);
7821
+ else companyUnwrappedEnvelopes.delete(sessionId);
7702
7822
  }
7703
7823
  function registerSessionDek(session, accountPrivateKey) {
7704
7824
  const envelope = session.dataEncryptionKey ?? null;
@@ -7706,6 +7826,7 @@ function registerSessionDek(session, accountPrivateKey) {
7706
7826
  dekBySessionId.delete(session.id);
7707
7827
  lockedSessionIds.delete(session.id);
7708
7828
  fingerprints.delete(session.id);
7829
+ companyUnwrappedEnvelopes.delete(session.id);
7709
7830
  return;
7710
7831
  }
7711
7832
  const fingerprint = `${envelope}|${accountPrivateKey ? "1" : "0"}|${companyKeyChainVersion()}`;
@@ -7713,20 +7834,16 @@ function registerSessionDek(session, accountPrivateKey) {
7713
7834
  if (!accountPrivateKey && dekBySessionId.has(session.id) && fingerprints.get(session.id)?.startsWith(`${envelope}|1|`)) return;
7714
7835
  const previousFingerprint = fingerprints.get(session.id);
7715
7836
  fingerprints.set(session.id, fingerprint);
7716
- const candidates2 = accountPrivateKey ? [accountPrivateKey, ...getCompanyKeyCandidates()] : [];
7717
- let dek = null;
7718
- for (const candidate of candidates2) {
7719
- dek = unwrapDataEncryptionKey(envelope, candidate);
7720
- if (dek) break;
7721
- }
7837
+ const accountDek = accountPrivateKey ? unwrapDataEncryptionKey(envelope, accountPrivateKey) : null;
7838
+ const dek = accountDek ?? (accountPrivateKey ? firstUnwrap(envelope, getCompanyKeyCandidates()) : null);
7722
7839
  if (dek) {
7723
- dekBySessionId.set(session.id, dek);
7724
- lockedSessionIds.delete(session.id);
7840
+ adoptSessionDek(session.id, envelope, dek, accountDek === null);
7725
7841
  return;
7726
7842
  }
7727
7843
  if (dekBySessionId.has(session.id) && previousFingerprint?.startsWith(`${envelope}|`)) return;
7728
7844
  dekBySessionId.delete(session.id);
7729
7845
  lockedSessionIds.add(session.id);
7846
+ if (companyUnwrappedEnvelopes.has(session.id)) companyUnwrappedEnvelopes.set(session.id, envelope);
7730
7847
  }
7731
7848
  function getSessionDek(sessionId) {
7732
7849
  return dekBySessionId.get(sessionId) ?? null;
@@ -7768,6 +7885,60 @@ async function preDecryptSessionHeaders(sessions, accountPrivateKey, sinceGenera
7768
7885
  }
7769
7886
  }));
7770
7887
  }
7888
+ const machineDekById = /* @__PURE__ */ new Map();
7889
+ let boundaryGeneration = 0;
7890
+ function machineDekRegistryGeneration() {
7891
+ return boundaryGeneration;
7892
+ }
7893
+ function discardMachineDek(machineId) {
7894
+ machineDekById.get(machineId)?.dek.fill(0);
7895
+ machineDekById.delete(machineId);
7896
+ }
7897
+ function machineFingerprint(envelope) {
7898
+ return `${envelope}|${companyKeyChainVersion()}`;
7899
+ }
7900
+ async function resolveMachineDataKeyFields(record2, legacyKeys, privateKeys, scope) {
7901
+ const discard = { machineId: record2.id, fields: null, entry: null };
7902
+ if (!record2.dataEncryptionKey || !record2.metadata) return discard;
7903
+ if (decryptJsonWithCandidates(record2.metadata, legacyKeys)) return discard;
7904
+ const fingerprint = machineFingerprint(record2.dataEncryptionKey);
7905
+ const cached = machineDekById.get(record2.id);
7906
+ const machineKey = cached?.fingerprint === fingerprint ? cached.dek : unwrapMachineKey(record2.dataEncryptionKey, privateKeys);
7907
+ if (!machineKey) return discard;
7908
+ const metadata = await decryptDataKeyRecord(machineKey, record2.metadata);
7909
+ if (!metadata) return discard;
7910
+ const daemonState = record2.daemonState ? await decryptDataKeyRecord(machineKey, record2.daemonState) : null;
7911
+ const entry = cached?.dek === machineKey ? cached : { dek: machineKey, scope, fingerprint };
7912
+ entry.scope = scope;
7913
+ return { machineId: record2.id, fields: { metadata, daemonState }, entry };
7914
+ }
7915
+ function commitMachineDek(resolved) {
7916
+ if (resolved.entry) machineDekById.set(resolved.machineId, resolved.entry);
7917
+ else discardMachineDek(resolved.machineId);
7918
+ }
7919
+ function unwrapMachineKey(envelope, privateKeys) {
7920
+ for (const privateKey of privateKeys) {
7921
+ const machineKey = unwrapDataEncryptionKey(envelope, privateKey);
7922
+ if (machineKey) return machineKey;
7923
+ }
7924
+ return null;
7925
+ }
7926
+ async function decryptMachineDataKeyRows(rows, legacyKeys, options, scope, sinceGeneration = machineDekRegistryGeneration()) {
7927
+ const none = () => rows.map(() => null);
7928
+ if (sinceGeneration !== boundaryGeneration) return none();
7929
+ const listed = new Set(rows.map((row2) => row2.id));
7930
+ for (const [machineId, entry] of [...machineDekById]) {
7931
+ if (entry.scope === scope && !listed.has(machineId)) discardMachineDek(machineId);
7932
+ }
7933
+ if (!options.accountPrivateKey || !rows.some((row2) => row2.dataEncryptionKey)) return none();
7934
+ await options.dataKeyReady;
7935
+ if (sinceGeneration !== boundaryGeneration) return none();
7936
+ const privateKeys = dataKeyUnwrapCandidates(options.accountPrivateKey);
7937
+ const resolved = await Promise.all(rows.map((row2) => resolveMachineDataKeyFields(row2, legacyKeys, privateKeys, scope)));
7938
+ if (sinceGeneration !== boundaryGeneration) return none();
7939
+ for (const entry of resolved) commitMachineDek(entry);
7940
+ return resolved.map((entry) => entry.fields);
7941
+ }
7771
7942
  function buildUserTextMessageContent(input) {
7772
7943
  return {
7773
7944
  role: "user",
@@ -8678,6 +8849,53 @@ function sessionFilesystemScopePatch(metadata, existing) {
8678
8849
  }
8679
8850
  return { filesystemScope: parseSessionFilesystemScope(metadata.filesystemScope) };
8680
8851
  }
8852
+ const claudeEffortOptions = [
8853
+ { key: "low", label: "Low" },
8854
+ { key: "medium", label: "Medium" },
8855
+ { key: "high", label: "High" },
8856
+ { key: "max", label: "Max" }
8857
+ ];
8858
+ const claudeFableEffortOptions = [
8859
+ { key: "low", label: "Low" },
8860
+ { key: "medium", label: "Medium" },
8861
+ { key: "high", label: "High" },
8862
+ { key: "xhigh", label: "Xhigh" },
8863
+ { key: "max", label: "Max" }
8864
+ ];
8865
+ const codexEffortOptions = [
8866
+ { key: "low", label: "Low" },
8867
+ { key: "medium", label: "Medium" },
8868
+ { key: "high", label: "High" },
8869
+ { key: "xhigh", label: "Xhigh" }
8870
+ ];
8871
+ const codexMaxEffortOptions = [
8872
+ ...codexEffortOptions,
8873
+ { key: "max", label: "Max" }
8874
+ ];
8875
+ const codexUltraEffortOptions = [
8876
+ ...codexMaxEffortOptions,
8877
+ { key: "ultra", label: "Ultra" }
8878
+ ];
8879
+ function getEffortOptionsForAgent(agent, model) {
8880
+ if (model === "default") return [];
8881
+ if (agent === "codex") {
8882
+ if (model === "gpt-6-astra" || model === "gpt-5.6-sol" || model === "gpt-5.6-terra") return codexUltraEffortOptions;
8883
+ if (model === "gpt-5.6-luna") return codexMaxEffortOptions;
8884
+ return codexEffortOptions;
8885
+ }
8886
+ if (agent === "gemini" || agent === "opencode" || agent === "grok") return [];
8887
+ if (model === "claude-fable-5-1") return claudeFableEffortOptions;
8888
+ return claudeEffortOptions;
8889
+ }
8890
+ function defaultEffortForAgent(agent, model) {
8891
+ const options = getEffortOptionsForAgent(agent, model);
8892
+ if (options.length === 0) return null;
8893
+ if (agent === "codex") {
8894
+ if (model === "gpt-5.6-sol") return "low";
8895
+ if (model === "gpt-6-astra" || model === "gpt-5.6-terra" || model === "gpt-5.6-luna") return "medium";
8896
+ }
8897
+ return options.some((option) => option.key === "high") ? "high" : options[options.length - 1].key;
8898
+ }
8681
8899
  function normalizeEpochMilliseconds(value2) {
8682
8900
  const abs = Math.abs(value2);
8683
8901
  if (abs >= 1e15) return value2 / 1e3;
@@ -17951,10 +18169,10 @@ function normalizeAutomationSupport(value2) {
17951
18169
  ...Number.isSafeInteger(capability.keyVersion) && capability.keyVersion > 0 ? { keyVersion: capability.keyVersion } : {}
17952
18170
  };
17953
18171
  }
17954
- function normalizeMachine(row2, candidateKeys) {
18172
+ function normalizeMachine(row2, candidateKeys, dataKeyFields = null) {
17955
18173
  const registry = row2.registry;
17956
- const decrypted = decryptJsonWithCandidates(row2.metadata, candidateKeys) ?? {};
17957
- const daemonState = decryptJsonWithCandidates(row2.daemonState, candidateKeys);
18174
+ const decrypted = dataKeyFields?.metadata ?? decryptJsonWithCandidates(row2.metadata, candidateKeys) ?? {};
18175
+ const daemonState = dataKeyFields?.daemonState ?? decryptJsonWithCandidates(row2.daemonState, candidateKeys);
17958
18176
  const resources = row2.resources ?? parseMachineResources(daemonState);
17959
18177
  const host = row2.host || registry?.host || (typeof decrypted.host === "string" ? decrypted.host : row2.id.slice(0, 8));
17960
18178
  const platform2 = row2.platform || registry?.platform || (typeof decrypted.platform === "string" ? decrypted.platform : "unknown");
@@ -19180,13 +19398,16 @@ class ApiClient {
19180
19398
  async getMachines(companyId, candidateKeys = [], options = {}) {
19181
19399
  const company = companyId ?? "personal";
19182
19400
  const statusParam = options.withStatus ? "&withStatus=1" : "";
19401
+ const dekGeneration = machineDekRegistryGeneration();
19183
19402
  const data = await this.request(
19184
19403
  `/api/machines/list?company=${encodeURIComponent(company)}${statusParam}`,
19185
19404
  { boundary: "api" }
19186
19405
  );
19187
19406
  const rows = Array.isArray(data) ? data : data.machines ?? [];
19188
19407
  const registry = await this.fetchMachineRegistry(rows.map((row2) => row2.id));
19189
- return rows.map((row2) => normalizeMachine(withMachineRegistry(row2, registry), candidateKeys));
19408
+ const enriched = rows.map((row2) => withMachineRegistry(row2, registry));
19409
+ const dataKeyFields = await decryptMachineDataKeyRows(enriched, candidateKeys, options, company, dekGeneration);
19410
+ return enriched.map((row2, index) => normalizeMachine(row2, candidateKeys, dataKeyFields[index]));
19190
19411
  }
19191
19412
  /**
19192
19413
  * Fail-open: a registry outage degrades machine names back to the daemon
@@ -21569,53 +21790,6 @@ function chatStartupHints(supportsQueuedInput = false) {
21569
21790
  "At the prompt: /model · /effort · /voice to dictate · /detach to leave running · /exit to leave.\n"
21570
21791
  ];
21571
21792
  }
21572
- const claudeEffortOptions = [
21573
- { key: "low", label: "Low" },
21574
- { key: "medium", label: "Medium" },
21575
- { key: "high", label: "High" },
21576
- { key: "max", label: "Max" }
21577
- ];
21578
- const claudeFableEffortOptions = [
21579
- { key: "low", label: "Low" },
21580
- { key: "medium", label: "Medium" },
21581
- { key: "high", label: "High" },
21582
- { key: "xhigh", label: "Xhigh" },
21583
- { key: "max", label: "Max" }
21584
- ];
21585
- const codexEffortOptions = [
21586
- { key: "low", label: "Low" },
21587
- { key: "medium", label: "Medium" },
21588
- { key: "high", label: "High" },
21589
- { key: "xhigh", label: "Xhigh" }
21590
- ];
21591
- const codexMaxEffortOptions = [
21592
- ...codexEffortOptions,
21593
- { key: "max", label: "Max" }
21594
- ];
21595
- const codexUltraEffortOptions = [
21596
- ...codexMaxEffortOptions,
21597
- { key: "ultra", label: "Ultra" }
21598
- ];
21599
- function getEffortOptionsForAgent(agent, model) {
21600
- if (model === "default") return [];
21601
- if (agent === "codex") {
21602
- if (model === "gpt-6-astra" || model === "gpt-5.6-sol" || model === "gpt-5.6-terra") return codexUltraEffortOptions;
21603
- if (model === "gpt-5.6-luna") return codexMaxEffortOptions;
21604
- return codexEffortOptions;
21605
- }
21606
- if (agent === "gemini" || agent === "opencode" || agent === "grok") return [];
21607
- if (model === "claude-fable-5-1") return claudeFableEffortOptions;
21608
- return claudeEffortOptions;
21609
- }
21610
- function defaultEffortForAgent(agent, model) {
21611
- const options = getEffortOptionsForAgent(agent, model);
21612
- if (options.length === 0) return null;
21613
- if (agent === "codex") {
21614
- if (model === "gpt-5.6-sol") return "low";
21615
- if (model === "gpt-6-astra" || model === "gpt-5.6-terra" || model === "gpt-5.6-luna") return "medium";
21616
- }
21617
- return options.some((option) => option.key === "high") ? "high" : options[options.length - 1].key;
21618
- }
21619
21793
  const newSessionAgents = [
21620
21794
  { id: "claude", label: "Claude", provider: "Anthropic" },
21621
21795
  { id: "codex", label: "Codex", provider: "OpenAI" },
@@ -21754,8 +21928,8 @@ function buildLiveChatUserMessage(text2, settings) {
21754
21928
  return {
21755
21929
  role: "user",
21756
21930
  content: { type: "text", text: text2 },
21931
+ // permissionMode 는 싣지 않는다 — 부재가 곧 "현재 모드 유지" (specs/delegated-session-permission-mode).
21757
21932
  meta: {
21758
- permissionMode: "default",
21759
21933
  ...modelOptions.model ? { model: modelOptions.model } : {},
21760
21934
  ...modelOptions.effort ? { effort: modelOptions.effort } : {}
21761
21935
  }
@@ -21813,6 +21987,141 @@ function resumeHistoryCount(input) {
21813
21987
  if (!input.resuming) return 0;
21814
21988
  return input.since ?? DEFAULT_RESUME_HISTORY;
21815
21989
  }
21990
+ const AGENT_SPAWN_BUDGET_MIN = 4;
21991
+ const AGENT_SPAWN_BUDGET_MAX = 32;
21992
+ const MEMORY_BYTES_PER_AGENT = 2 * 1024 ** 3;
21993
+ const AGENTS_PER_CORE = 2;
21994
+ function machineSpawnBudget(capacity) {
21995
+ const cores = Number.isFinite(capacity.cpuCount) ? Math.max(0, Math.floor(capacity.cpuCount)) : 0;
21996
+ const memory = Number.isFinite(capacity.totalMemoryBytes) ? Math.max(0, capacity.totalMemoryBytes) : 0;
21997
+ if (cores <= 0 || memory <= 0) return AGENT_SPAWN_BUDGET_MIN;
21998
+ const budget = Math.min(Math.floor(memory / MEMORY_BYTES_PER_AGENT), cores * AGENTS_PER_CORE);
21999
+ return Math.min(Math.max(budget, AGENT_SPAWN_BUDGET_MIN), AGENT_SPAWN_BUDGET_MAX);
22000
+ }
22001
+ function tierSpawnBudget(depth, machineBudget) {
22002
+ return depth <= 0 ? machineBudget : Math.ceil(machineBudget / 2);
22003
+ }
22004
+ function resolveMaxSpawn(input) {
22005
+ const tier = tierSpawnBudget(input.depth, input.machineBudget);
22006
+ return input.policy === null ? tier : Math.min(input.policy, tier);
22007
+ }
22008
+ const SPAWN_BUDGET_WINDOW_MS = 6 * 60 * 60 * 1e3;
22009
+ function isCharged(chargedAt, now) {
22010
+ return chargedAt > now - SPAWN_BUDGET_WINDOW_MS;
22011
+ }
22012
+ function chargeTimes(records) {
22013
+ const byId = /* @__PURE__ */ new Map();
22014
+ const unkeyed = [];
22015
+ for (const record2 of records) {
22016
+ if (record2.spawnId === void 0) {
22017
+ unkeyed.push(record2.ts);
22018
+ continue;
22019
+ }
22020
+ const seen = byId.get(record2.spawnId);
22021
+ if (seen === void 0 || record2.ts < seen) byId.set(record2.spawnId, record2.ts);
22022
+ }
22023
+ return [...byId.values(), ...unkeyed];
22024
+ }
22025
+ function chargedSpawnCount(records, now) {
22026
+ return chargeTimes(records).filter((chargedAt) => isCharged(chargedAt, now)).length;
22027
+ }
22028
+ function spawnBudgetResetsAt(records, now, maxSpawn) {
22029
+ if (maxSpawn <= 0) return null;
22030
+ const expiries = chargeTimes(records).filter((chargedAt) => isCharged(chargedAt, now)).map((ts) => ts + SPAWN_BUDGET_WINDOW_MS).sort((a, b) => a - b);
22031
+ if (expiries.length < maxSpawn) return null;
22032
+ return expiries[expiries.length - maxSpawn];
22033
+ }
22034
+ function spawnLimitDetail(input) {
22035
+ const used = `${input.spawned}/${input.maxSpawn} used`;
22036
+ if (input.resetsAt === null || input.resetsAt <= input.now) return used;
22037
+ const minutes = Math.ceil((input.resetsAt - input.now) / 6e4);
22038
+ return `${used}; the next slot frees in ${minutes} minute${minutes === 1 ? "" : "s"}`;
22039
+ }
22040
+ const AGENT_ENV_FLAG = "SAYCODE_AGENT_ENV";
22041
+ const AGENT_MAX_DEPTH = 2;
22042
+ function readOptionalNonNegativeInt(raw) {
22043
+ if (raw === void 0 || !/^\d+$/.test(raw.trim())) return null;
22044
+ const value2 = Number(raw.trim());
22045
+ return Number.isSafeInteger(value2) ? value2 : null;
22046
+ }
22047
+ function normalizeDirectory(raw) {
22048
+ const trimmed = raw.trim();
22049
+ if (!trimmed) return "";
22050
+ const stripped = trimmed.replace(/\/+$/, "");
22051
+ return stripped || "/";
22052
+ }
22053
+ function hasAgentMarker(env) {
22054
+ return env[AGENT_ENV_FLAG] === "1";
22055
+ }
22056
+ function isAgentEnv(env) {
22057
+ return hasAgentMarker(env) && normalizeDirectory(env.SAYCODE_AGENT_ROOT ?? "") !== "";
22058
+ }
22059
+ function readAgentEnv(env, machineBudget) {
22060
+ if (!isAgentEnv(env)) return null;
22061
+ const root = normalizeDirectory(env.SAYCODE_AGENT_ROOT ?? "");
22062
+ const depth = readOptionalNonNegativeInt(env.SAYCODE_AGENT_DEPTH) ?? 0;
22063
+ return {
22064
+ root,
22065
+ scope: normalizeDirectory(env.SAYCODE_AGENT_SCOPE ?? "") || root,
22066
+ depth,
22067
+ maxSpawn: resolveMaxSpawn({
22068
+ policy: readOptionalNonNegativeInt(env.SAYCODE_AGENT_MAX_SPAWN),
22069
+ depth,
22070
+ machineBudget
22071
+ }),
22072
+ agentId: env.SAYCODE_AGENT_ID?.trim() || null,
22073
+ sessionId: env.APLUS_SESSION_ID?.trim() || null
22074
+ };
22075
+ }
22076
+ function spawnDecision(input) {
22077
+ if (input.depth >= AGENT_MAX_DEPTH) return "spawn_depth_exceeded";
22078
+ if (input.spawned >= input.maxSpawn) return "spawn_limit_exceeded";
22079
+ return "allow";
22080
+ }
22081
+ function childAgentEnv(parent, childAgentId) {
22082
+ return {
22083
+ [AGENT_ENV_FLAG]: "1",
22084
+ SAYCODE_AGENT_ROOT: parent.root,
22085
+ SAYCODE_AGENT_SCOPE: parent.scope,
22086
+ SAYCODE_AGENT_DEPTH: String(parent.depth + 1),
22087
+ SAYCODE_AGENT_MAX_SPAWN: String(tierSpawnBudget(parent.depth + 1, parent.maxSpawn)),
22088
+ SAYCODE_AGENT_ID: childAgentId
22089
+ };
22090
+ }
22091
+ function isInScope(session, scope) {
22092
+ if (!session.machineId || !scope.machineId) return false;
22093
+ if (session.machineId !== scope.machineId) return false;
22094
+ const root = normalizeDirectory(scope.root);
22095
+ const directory = normalizeDirectory(session.directory);
22096
+ if (!root || !directory) return false;
22097
+ if (directory === root) return true;
22098
+ return directory.startsWith(root === "/" ? "/" : `${root}/`);
22099
+ }
22100
+ function sessionsInScope(sessions, scope) {
22101
+ return sessions.filter((session) => isInScope(session, scope));
22102
+ }
22103
+ function machineSettingsPaths(input) {
22104
+ const override = input.env.SAYCODE_HAPPY_HOME?.trim();
22105
+ const runtimeHome = input.env.HAPPY_HOME_DIR?.trim();
22106
+ const homes = CREDENTIAL_SOURCE_ORDER.filter((source) => source !== "saycode").map((source) => `${input.homeDir}/.${source}`);
22107
+ const ordered = [input.credentialHome?.trim(), override, runtimeHome, ...homes];
22108
+ return [...new Set(ordered.filter((home) => Boolean(home)))].map((home) => `${home}/settings.json`.replace(/\/{2,}/g, "/"));
22109
+ }
22110
+ function readMachineId(input, readFile2) {
22111
+ for (const path of machineSettingsPaths(input)) {
22112
+ const raw = readFile2(path);
22113
+ if (!raw) continue;
22114
+ try {
22115
+ const parsed = JSON.parse(raw);
22116
+ if (typeof parsed?.machineId === "string" && parsed.machineId) return parsed.machineId;
22117
+ } catch {
22118
+ }
22119
+ }
22120
+ return null;
22121
+ }
22122
+ function spawnBudgetKey(env) {
22123
+ return env.agentId ?? env.root;
22124
+ }
21816
22125
  const REAL_CLI_SESSION_CONNECTION_DEPS = {
21817
22126
  createLocalId: () => `saycode-cli-${process.pid}-${Math.random().toString(36).slice(2)}`
21818
22127
  };
@@ -23891,6 +24200,9 @@ async function askTemporaryLine(question) {
23891
24200
  }
23892
24201
  async function runChat(context, options, providedTransport) {
23893
24202
  const resuming = !!options.sessionId;
24203
+ if (!resuming && hasAgentMarker(process.env)) {
24204
+ throw new CliError("This session may not start a new conversation with `chat`. Use `saycode agent spawn`, which applies the spawn budget and hands the child its lineage. `chat <session-id>` to attach still works.");
24205
+ }
23894
24206
  let optionPrompter = null;
23895
24207
  let optionInput = null;
23896
24208
  if (!resuming && options.promptForOptions) {
@@ -24795,6 +25107,9 @@ function waitForSessionState(options) {
24795
25107
  });
24796
25108
  }
24797
25109
  const AGENT_FAILURE_EXIT_CODE = 3;
25110
+ function isAgentFailure(value2) {
25111
+ return typeof value2 === "object" && value2 !== null && "stdout" in value2;
25112
+ }
24798
25113
  function line(value2) {
24799
25114
  return `${JSON.stringify(value2)}
24800
25115
  `;
@@ -24927,87 +25242,6 @@ async function promptTarget(runtime, target, text2, options) {
24927
25242
  stream2.close();
24928
25243
  }
24929
25244
  }
24930
- const AGENT_ENV_FLAG = "SAYCODE_AGENT_ENV";
24931
- const AGENT_MAX_DEPTH = 2;
24932
- const DEFAULT_AGENT_MAX_SPAWN = 4;
24933
- const AGENT_MAX_SPAWN_CEILING = 16;
24934
- function readNonNegativeInt(raw, fallback) {
24935
- if (raw === void 0) return fallback;
24936
- if (!/^\d+$/.test(raw.trim())) return fallback;
24937
- const value2 = Number(raw.trim());
24938
- return Number.isSafeInteger(value2) ? value2 : fallback;
24939
- }
24940
- function normalizeDirectory(raw) {
24941
- const trimmed = raw.trim();
24942
- if (!trimmed) return "";
24943
- const stripped = trimmed.replace(/\/+$/, "");
24944
- return stripped || "/";
24945
- }
24946
- function readAgentEnv(env) {
24947
- if (env[AGENT_ENV_FLAG] !== "1") return null;
24948
- const root = normalizeDirectory(env.SAYCODE_AGENT_ROOT ?? "");
24949
- if (!root) return null;
24950
- return {
24951
- root,
24952
- scope: normalizeDirectory(env.SAYCODE_AGENT_SCOPE ?? "") || root,
24953
- depth: readNonNegativeInt(env.SAYCODE_AGENT_DEPTH, 0),
24954
- maxSpawn: Math.min(
24955
- readNonNegativeInt(env.SAYCODE_AGENT_MAX_SPAWN, DEFAULT_AGENT_MAX_SPAWN),
24956
- AGENT_MAX_SPAWN_CEILING
24957
- ),
24958
- agentId: env.SAYCODE_AGENT_ID?.trim() || null,
24959
- sessionId: env.APLUS_SESSION_ID?.trim() || null
24960
- };
24961
- }
24962
- function spawnDecision(input) {
24963
- if (input.depth >= AGENT_MAX_DEPTH) return "spawn_depth_exceeded";
24964
- if (input.spawned >= input.maxSpawn) return "spawn_limit_exceeded";
24965
- return "allow";
24966
- }
24967
- function childAgentEnv(parent, childAgentId) {
24968
- return {
24969
- [AGENT_ENV_FLAG]: "1",
24970
- SAYCODE_AGENT_ROOT: parent.root,
24971
- SAYCODE_AGENT_SCOPE: parent.scope,
24972
- SAYCODE_AGENT_DEPTH: String(parent.depth + 1),
24973
- SAYCODE_AGENT_MAX_SPAWN: String(Math.min(parent.maxSpawn, DEFAULT_AGENT_MAX_SPAWN)),
24974
- SAYCODE_AGENT_ID: childAgentId
24975
- };
24976
- }
24977
- function isInScope(session, scope) {
24978
- if (!session.machineId || !scope.machineId) return false;
24979
- if (session.machineId !== scope.machineId) return false;
24980
- const root = normalizeDirectory(scope.root);
24981
- const directory = normalizeDirectory(session.directory);
24982
- if (!root || !directory) return false;
24983
- if (directory === root) return true;
24984
- return directory.startsWith(root === "/" ? "/" : `${root}/`);
24985
- }
24986
- function sessionsInScope(sessions, scope) {
24987
- return sessions.filter((session) => isInScope(session, scope));
24988
- }
24989
- function machineSettingsPaths(input) {
24990
- const override = input.env.SAYCODE_HAPPY_HOME?.trim();
24991
- const runtimeHome = input.env.HAPPY_HOME_DIR?.trim();
24992
- const homes = CREDENTIAL_SOURCE_ORDER.filter((source) => source !== "saycode").map((source) => `${input.homeDir}/.${source}`);
24993
- const ordered = [input.credentialHome?.trim(), override, runtimeHome, ...homes];
24994
- return [...new Set(ordered.filter((home) => Boolean(home)))].map((home) => `${home}/settings.json`.replace(/\/{2,}/g, "/"));
24995
- }
24996
- function readMachineId(input, readFile2) {
24997
- for (const path of machineSettingsPaths(input)) {
24998
- const raw = readFile2(path);
24999
- if (!raw) continue;
25000
- try {
25001
- const parsed = JSON.parse(raw);
25002
- if (typeof parsed?.machineId === "string" && parsed.machineId) return parsed.machineId;
25003
- } catch {
25004
- }
25005
- }
25006
- return null;
25007
- }
25008
- function spawnBudgetKey(env) {
25009
- return env.agentId ?? env.root;
25010
- }
25011
25245
  function normalizeAbsolutePath(raw) {
25012
25246
  const trimmed = raw.trim();
25013
25247
  if (!trimmed.startsWith("/")) return "";
@@ -25118,6 +25352,71 @@ function buildAgentHandoffPrompt(source, context) {
25118
25352
  ...FIRST_ACTIONS.map((action) => `- ${action}`)
25119
25353
  ].join("\n");
25120
25354
  }
25355
+ function checkSpawnBudget(runtime, env) {
25356
+ const key = spawnBudgetKey(env);
25357
+ let usage;
25358
+ try {
25359
+ usage = runtime.countSpawns(key, env.maxSpawn);
25360
+ } catch (error) {
25361
+ return agentFail("spawn_limit_exceeded", `spawn ledger is unreadable, refusing: ${String(error)}`);
25362
+ }
25363
+ const decision = spawnDecision({ depth: env.depth, maxSpawn: env.maxSpawn, spawned: usage.spawned });
25364
+ if (decision === "allow") return { spawned: usage.spawned, key };
25365
+ return agentFail(decision, spawnLimitDetail({
25366
+ spawned: usage.spawned,
25367
+ maxSpawn: env.maxSpawn,
25368
+ resetsAt: usage.resetsAt,
25369
+ now: runtime.now()
25370
+ }));
25371
+ }
25372
+ function reserveSpawn(runtime, env, key, attempt2, spawnedBefore) {
25373
+ const spawnId = runtime.newAgentId();
25374
+ try {
25375
+ runtime.recordSpawn({
25376
+ ts: runtime.now(),
25377
+ agentKey: key,
25378
+ root: env.scope,
25379
+ depth: env.depth,
25380
+ // Unknown until the daemon answers; `resolveSpawn` appends the id under
25381
+ // the same `spawnId`, and the counter charges the pair once.
25382
+ childSessionId: null,
25383
+ ...env.sessionId ? { parentSessionId: env.sessionId } : {},
25384
+ agent: attempt2.agent,
25385
+ promptPreview: attempt2.promptPreview,
25386
+ outcome: "failed",
25387
+ spawnId
25388
+ });
25389
+ } catch (error) {
25390
+ return agentFail("spawn_limit_exceeded", `spawn ledger is unwritable, refusing before anything is created: ${String(error)}`);
25391
+ }
25392
+ try {
25393
+ if (runtime.countSpawns(key, env.maxSpawn).spawned <= spawnedBefore) {
25394
+ return agentFail("spawn_limit_exceeded", "the spawn reservation did not land in the ledger, refusing before anything is created");
25395
+ }
25396
+ } catch (error) {
25397
+ return agentFail("spawn_limit_exceeded", `spawn ledger became unreadable after reserving, refusing: ${String(error)}`);
25398
+ }
25399
+ return { spawnId };
25400
+ }
25401
+ function resolveSpawn(runtime, env, key, spawnId, outcome) {
25402
+ try {
25403
+ runtime.recordSpawn({
25404
+ ts: runtime.now(),
25405
+ agentKey: key,
25406
+ root: env.scope,
25407
+ depth: env.depth,
25408
+ childSessionId: outcome.sessionId,
25409
+ ...env.sessionId ? { parentSessionId: env.sessionId } : {},
25410
+ agent: outcome.agent,
25411
+ promptPreview: outcome.promptPreview,
25412
+ outcome: outcome.sessionId ? "spawned" : "failed",
25413
+ spawnId
25414
+ });
25415
+ return true;
25416
+ } catch {
25417
+ return false;
25418
+ }
25419
+ }
25121
25420
  async function fileSpawnedSessionUnderProject(runtime, sessionId, directory) {
25122
25421
  if (!runtime.registerSpawnedSession) return;
25123
25422
  try {
@@ -25163,15 +25462,9 @@ async function handoffToSibling(runtime, scope, env, handoff, agent, modelOption
25163
25462
  if (env.depth >= AGENT_MAX_DEPTH) {
25164
25463
  return agentFail("spawn_depth_exceeded", `depth ${env.depth} is already at the ceiling of ${AGENT_MAX_DEPTH}`);
25165
25464
  }
25166
- const key = spawnBudgetKey(env);
25167
- let spawned;
25168
- try {
25169
- spawned = runtime.countSpawns(key);
25170
- } catch (error) {
25171
- return agentFail("spawn_limit_exceeded", `spawn ledger is unreadable, refusing: ${String(error)}`);
25172
- }
25173
- const decision = spawnDecision({ depth: env.depth, maxSpawn: env.maxSpawn, spawned });
25174
- if (decision !== "allow") return agentFail(decision, `${spawned}/${env.maxSpawn} used`);
25465
+ const budget = checkSpawnBudget(runtime, env);
25466
+ if (isAgentFailure(budget)) return budget;
25467
+ const { spawned, key } = budget;
25175
25468
  let prompt;
25176
25469
  try {
25177
25470
  prompt = buildHandoffPrompt(await runtime.readMessages(from, HANDOFF_READ_MESSAGES), source, agent);
@@ -25179,6 +25472,9 @@ async function handoffToSibling(runtime, scope, env, handoff, agent, modelOption
25179
25472
  return agentFail("handoff_context_failed", String(error));
25180
25473
  }
25181
25474
  const childId = runtime.newAgentId();
25475
+ const preview = `handoff from ${from}`;
25476
+ const reservation = reserveSpawn(runtime, env, key, { agent, promptPreview: preview }, spawned);
25477
+ if (isAgentFailure(reservation)) return reservation;
25182
25478
  let sessionId = null;
25183
25479
  let spawnError = null;
25184
25480
  try {
@@ -25192,22 +25488,7 @@ async function handoffToSibling(runtime, scope, env, handoff, agent, modelOption
25192
25488
  } catch (error) {
25193
25489
  spawnError = error;
25194
25490
  }
25195
- try {
25196
- runtime.recordSpawn({
25197
- ts: runtime.now(),
25198
- agentKey: key,
25199
- root: env.scope,
25200
- depth: env.depth,
25201
- childSessionId: sessionId,
25202
- ...env.sessionId ? { parentSessionId: env.sessionId } : {},
25203
- agent,
25204
- promptPreview: `handoff from ${from}`,
25205
- outcome: sessionId ? "spawned" : "failed"
25206
- });
25207
- } catch (error) {
25208
- const created = sessionId ? `spawned ${sessionId} but ` : "";
25209
- return agentFail("spawn_limit_exceeded", `${created}the ledger write failed: ${String(error)}`);
25210
- }
25491
+ const lineageRecorded = resolveSpawn(runtime, env, key, reservation.spawnId, { agent, promptPreview: preview, sessionId });
25211
25492
  if (!sessionId) {
25212
25493
  return agentFail("spawn_failed", `the machine daemon did not confirm a session: ${String(spawnError)}`);
25213
25494
  }
@@ -25219,6 +25500,7 @@ async function handoffToSibling(runtime, scope, env, handoff, agent, modelOption
25219
25500
  depth: env.depth + 1,
25220
25501
  spawned: spawned + 1,
25221
25502
  maxSpawn: env.maxSpawn,
25503
+ lineageRecorded,
25222
25504
  handoff: { mode: "summary", from }
25223
25505
  });
25224
25506
  }
@@ -25493,7 +25775,7 @@ function cliSpawnModelOptions() {
25493
25775
  }
25494
25776
  const DEFAULT_READ_LIMIT = 20;
25495
25777
  function scopeOf(runtime) {
25496
- const env = readAgentEnv(runtime.env);
25778
+ const env = readAgentEnv(runtime.env, runtime.machineBudget);
25497
25779
  if (!env) return agentFail("not_agent_env", "This command only runs inside a Saycode-spawned session.");
25498
25780
  const machineId = readMachineId(
25499
25781
  { homeDir: runtime.homeDir, env: runtime.env, credentialHome: runtime.credentialHome ?? null },
@@ -25504,9 +25786,6 @@ function scopeOf(runtime) {
25504
25786
  }
25505
25787
  return { scope: { root: env.scope, machineId }, env };
25506
25788
  }
25507
- function isFailure(value2) {
25508
- return typeof value2 === "object" && value2 !== null && "stdout" in value2;
25509
- }
25510
25789
  async function resolveTarget(runtime, scope, sessionId) {
25511
25790
  const sessions = await runtime.listSessions();
25512
25791
  const session = sessions.find((candidate) => candidate.id === sessionId);
@@ -25557,16 +25836,12 @@ async function spawnSibling(runtime, env, text2, agent, contextFiles = [], model
25557
25836
  if (!context.ok) {
25558
25837
  return context.reason === "out_of_scope" ? agentFail("out_of_scope", `${context.path} is outside ${env.scope}`) : agentFail("file_unreadable", `${context.path} could not be read`);
25559
25838
  }
25560
- const key = spawnBudgetKey(env);
25561
- let spawned;
25562
- try {
25563
- spawned = runtime.countSpawns(key);
25564
- } catch (error) {
25565
- return agentFail("spawn_limit_exceeded", `spawn ledger is unreadable, refusing: ${String(error)}`);
25566
- }
25567
- const decision = spawnDecision({ depth: env.depth, maxSpawn: env.maxSpawn, spawned });
25568
- if (decision !== "allow") return agentFail(decision, `${spawned}/${env.maxSpawn} used`);
25839
+ const budget = checkSpawnBudget(runtime, env);
25840
+ if (isAgentFailure(budget)) return budget;
25841
+ const { spawned, key } = budget;
25569
25842
  const childId = runtime.newAgentId();
25843
+ const reservation = reserveSpawn(runtime, env, key, { agent, promptPreview: text2 }, spawned);
25844
+ if (isAgentFailure(reservation)) return reservation;
25570
25845
  let sessionId = null;
25571
25846
  let spawnError = null;
25572
25847
  try {
@@ -25580,36 +25855,34 @@ async function spawnSibling(runtime, env, text2, agent, contextFiles = [], model
25580
25855
  } catch (error) {
25581
25856
  spawnError = error;
25582
25857
  }
25583
- try {
25584
- runtime.recordSpawn({
25585
- ts: runtime.now(),
25586
- agentKey: key,
25587
- root: env.scope,
25588
- depth: env.depth,
25589
- childSessionId: sessionId,
25590
- ...env.sessionId ? { parentSessionId: env.sessionId } : {},
25591
- agent,
25592
- promptPreview: text2,
25593
- outcome: sessionId ? "spawned" : "failed"
25594
- });
25595
- } catch (error) {
25596
- const created = sessionId ? `spawned ${sessionId} but ` : "";
25597
- return agentFail("spawn_limit_exceeded", `${created}the ledger write failed: ${String(error)}`);
25598
- }
25858
+ const lineageRecorded = resolveSpawn(runtime, env, key, reservation.spawnId, { agent, promptPreview: text2, sessionId });
25599
25859
  if (!sessionId) {
25600
25860
  return agentFail("spawn_failed", `the machine daemon did not confirm a session: ${String(spawnError)}`);
25601
25861
  }
25602
25862
  await fileSpawnedSessionUnderProject(runtime, sessionId, env.root);
25603
- return agentOk({ sessionId, agent, directory: env.root, depth: env.depth + 1, spawned: spawned + 1, maxSpawn: env.maxSpawn });
25863
+ return agentOk({
25864
+ sessionId,
25865
+ agent,
25866
+ directory: env.root,
25867
+ depth: env.depth + 1,
25868
+ spawned: spawned + 1,
25869
+ maxSpawn: env.maxSpawn,
25870
+ // False means the child runs but this session's ownership link went
25871
+ // unwritten, so a spawned parent cannot steer or stop it.
25872
+ lineageRecorded
25873
+ });
25604
25874
  }
25605
25875
  async function runAgentCommand(runtime, args) {
25606
25876
  const context = scopeOf(runtime);
25607
- if (isFailure(context)) return context;
25877
+ if (isAgentFailure(context)) return context;
25608
25878
  const { scope, env } = context;
25609
25879
  if (args.verb === "whoami") {
25610
25880
  let spawned = null;
25881
+ let resetsAt = null;
25611
25882
  try {
25612
- spawned = runtime.countSpawns(spawnBudgetKey(env));
25883
+ const usage = runtime.countSpawns(spawnBudgetKey(env), env.maxSpawn);
25884
+ spawned = usage.spawned;
25885
+ resetsAt = usage.resetsAt;
25613
25886
  } catch {
25614
25887
  spawned = null;
25615
25888
  }
@@ -25624,6 +25897,9 @@ async function runAgentCommand(runtime, args) {
25624
25897
  maxSpawn: env.maxSpawn,
25625
25898
  spawned,
25626
25899
  spawnsRemaining: spawned === null ? null : Math.max(0, env.maxSpawn - spawned),
25900
+ // The budget is a rolling window, so "full" is a temporary state with a
25901
+ // known end — an agent that is told only "0 left" would give up for good.
25902
+ budgetResetsAt: resetsAt,
25627
25903
  canSpawn: env.depth < AGENT_MAX_DEPTH && spawned !== null && spawned < env.maxSpawn,
25628
25904
  spawnModelOptions: cliSpawnModelOptions()
25629
25905
  });
@@ -25635,7 +25911,7 @@ async function runAgentCommand(runtime, args) {
25635
25911
  if (args.verb === "spawn") {
25636
25912
  const agent = args.agent ?? DEFAULT_SPAWN_AGENT;
25637
25913
  const modelOptions = args.model !== void 0 || args.effort !== void 0 ? await validateSpawnModelOptions(agent, args.model, args.effort) : {};
25638
- if (isFailure(modelOptions)) return modelOptions;
25914
+ if (isAgentFailure(modelOptions)) return modelOptions;
25639
25915
  if (args.handoff) {
25640
25916
  return handoffToSibling(runtime, scope, env, args.handoff, agent, modelOptions);
25641
25917
  }
@@ -25646,7 +25922,7 @@ async function runAgentCommand(runtime, args) {
25646
25922
  }
25647
25923
  if (!args.sessionId) return agentFail("session_not_found", "no session id given");
25648
25924
  const target = await resolveTarget(runtime, scope, args.sessionId);
25649
- if (isFailure(target)) return target;
25925
+ if (isAgentFailure(target)) return target;
25650
25926
  switch (args.verb) {
25651
25927
  case "read":
25652
25928
  return readSession(runtime, target, args.last ?? DEFAULT_READ_LIMIT, args.since);
@@ -25681,7 +25957,7 @@ async function validateSpawnModelOptions(agent, model, effort) {
25681
25957
  function parseSpawnRecord(value2) {
25682
25958
  if (!value2 || typeof value2 !== "object" || Array.isArray(value2)) return null;
25683
25959
  const record2 = value2;
25684
- return typeof record2.ts === "number" && Number.isFinite(record2.ts) && typeof record2.agentKey === "string" && record2.agentKey.length > 0 && typeof record2.root === "string" && record2.root.length > 0 && typeof record2.depth === "number" && Number.isInteger(record2.depth) && record2.depth >= 0 && (record2.childSessionId === null || typeof record2.childSessionId === "string" && record2.childSessionId.length > 0) && (record2.parentSessionId === void 0 || typeof record2.parentSessionId === "string" && record2.parentSessionId.length > 0) && typeof record2.agent === "string" && record2.agent.length > 0 && typeof record2.promptPreview === "string" && (record2.outcome === "spawned" || record2.outcome === "failed") ? record2 : null;
25960
+ return typeof record2.ts === "number" && Number.isFinite(record2.ts) && typeof record2.agentKey === "string" && record2.agentKey.length > 0 && typeof record2.root === "string" && record2.root.length > 0 && typeof record2.depth === "number" && Number.isInteger(record2.depth) && record2.depth >= 0 && (record2.childSessionId === null || typeof record2.childSessionId === "string" && record2.childSessionId.length > 0) && (record2.parentSessionId === void 0 || typeof record2.parentSessionId === "string" && record2.parentSessionId.length > 0) && (record2.spawnId === void 0 || typeof record2.spawnId === "string" && record2.spawnId.length > 0) && typeof record2.agent === "string" && record2.agent.length > 0 && typeof record2.promptPreview === "string" && (record2.outcome === "spawned" || record2.outcome === "failed") ? record2 : null;
25685
25961
  }
25686
25962
  const LEDGER_FILE_MODE = 384;
25687
25963
  const PREVIEW_MAX_LENGTH = 200;
@@ -25722,7 +25998,8 @@ function recordIdentity(record2) {
25722
25998
  record2.parentSessionId ?? null,
25723
25999
  record2.agent,
25724
26000
  record2.promptPreview,
25725
- record2.outcome
26001
+ record2.outcome,
26002
+ record2.spawnId ?? null
25726
26003
  ]);
25727
26004
  }
25728
26005
  function readSpawnRecords(homeDir, rootDir) {
@@ -25738,14 +26015,35 @@ function readSpawnRecords(homeDir, rootDir) {
25738
26015
  return true;
25739
26016
  });
25740
26017
  }
25741
- function countSpawnsFor(homeDir, rootDir, agentKey) {
25742
- return readSpawnRecords(homeDir, rootDir).filter((record2) => record2.agentKey === agentKey).length;
26018
+ function spawnBudgetUsage(homeDir, rootDir, agentKey, now, maxSpawn) {
26019
+ const mine = readSpawnRecords(homeDir, rootDir).filter((record2) => record2.agentKey === agentKey);
26020
+ return {
26021
+ spawned: chargedSpawnCount(mine, now),
26022
+ resetsAt: spawnBudgetResetsAt(mine, now, maxSpawn)
26023
+ };
26024
+ }
26025
+ function endsWithNewline(path) {
26026
+ let fd;
26027
+ try {
26028
+ fd = openSync(path, "r");
26029
+ const size = fstatSync(fd).size;
26030
+ if (size === 0) return true;
26031
+ const tail = Buffer.alloc(1);
26032
+ readSync(fd, tail, 0, 1, size - 1);
26033
+ return tail[0] === 10;
26034
+ } catch (error) {
26035
+ if (error.code === "ENOENT") return true;
26036
+ throw error;
26037
+ } finally {
26038
+ if (fd !== void 0) closeSync(fd);
26039
+ }
25743
26040
  }
25744
26041
  function appendSpawnRecord(rootDir, record2) {
25745
26042
  const path = agentLineagePath(rootDir);
25746
26043
  mkdirSync(join(rootDir, ".aplus"), { recursive: true, mode: 448 });
25747
26044
  const entry = { ...record2, promptPreview: record2.promptPreview.slice(0, PREVIEW_MAX_LENGTH) };
25748
- appendFileSync(path, `${JSON.stringify(entry)}
26045
+ const separator = endsWithNewline(path) ? "" : "\n";
26046
+ appendFileSync(path, `${separator}${JSON.stringify(entry)}
25749
26047
  `, { mode: LEDGER_FILE_MODE });
25750
26048
  }
25751
26049
  function normalize(raw) {
@@ -25947,13 +26245,16 @@ async function openStream(context, transport, sessionId) {
25947
26245
  }
25948
26246
  function createAgentRuntime(context, transport, env = process.env) {
25949
26247
  const keys = [decodeBase64(context.syncCredential.secret)];
25950
- const homeDir = context.homeDir, lineageRoot = readAgentEnv(env)?.scope ?? context.homeDir;
26248
+ const machineBudget = machineSpawnBudget({ cpuCount: cpus().length, totalMemoryBytes: totalmem() });
26249
+ const homeDir = context.homeDir, lineageRoot = readAgentEnv(env, machineBudget)?.scope ?? context.homeDir;
25951
26250
  const credentialHome = context.credential.source === "saycode" ? null : dirname(context.credential.path);
26251
+ const now = () => Date.now();
25952
26252
  return {
25953
26253
  env,
25954
26254
  homeDir,
25955
26255
  credentialHome,
25956
- now: () => Date.now(),
26256
+ machineBudget,
26257
+ now,
25957
26258
  readFile: readFileOrNull,
25958
26259
  isReadableFile: isReadableFileSync,
25959
26260
  listSessions: async () => normalizeSessionRows(await context.rest.listSessions(), keys),
@@ -25989,7 +26290,7 @@ function createAgentRuntime(context, transport, env = process.env) {
25989
26290
  return { sessionId: result.sessionId };
25990
26291
  },
25991
26292
  newAgentId: () => randomBytes(8).toString("hex"),
25992
- countSpawns: (agentKey) => countSpawnsFor(homeDir, lineageRoot, agentKey),
26293
+ countSpawns: (agentKey, maxSpawn) => spawnBudgetUsage(homeDir, lineageRoot, agentKey, now(), maxSpawn),
25993
26294
  recordSpawn: (record2) => appendSpawnRecord(lineageRoot, record2),
25994
26295
  readLineage: () => readSpawnRecords(homeDir, lineageRoot),
25995
26296
  steerSession: (sessionId, text2) => steerAgentSession(transport, sessionId, text2),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@buzzni/saycode-cli",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "description": "Drive Saycode sessions across your machines from the terminal.",
5
5
  "type": "module",
6
6
  "bin": {