@buzzni/saycode-cli 0.4.0 → 0.5.0

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 +465 -168
  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.0";
44
44
  const manifest = {
45
45
  version: version$1
46
46
  };
@@ -271,6 +271,14 @@ 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
+ };
274
282
  const taskNotificationKo = {
275
283
  "taskNotification.ariaLabel": "백그라운드 작업 알림",
276
284
  "taskNotification.status.completed": "작업 완료",
@@ -1005,7 +1013,11 @@ const memoryEventsKo = {
1005
1013
  "settings.memoryEvents.unavailable": "기억 대시보드가 실행 중이 아닙니다.",
1006
1014
  "settings.memoryEvents.searchLabel": "기억 검색",
1007
1015
  "settings.memoryEvents.prevPage": "이전",
1008
- "settings.memoryEvents.nextPage": "다음"
1016
+ "settings.memoryEvents.nextPage": "다음",
1017
+ "settings.memoryEvents.delete": "삭제",
1018
+ "settings.memoryEvents.confirmDelete": "정말 삭제",
1019
+ "settings.memoryEvents.deleteHint": "삭제한 기억은 되돌릴 수 없습니다.",
1020
+ "settings.memoryEvents.deleteFailed": "삭제하지 못했습니다: {reason}"
1009
1021
  };
1010
1022
  const externalWorktreeInboxKo = {
1011
1023
  "externalWorktreeInbox.title": "앱 밖에서 만든 worktree",
@@ -1218,7 +1230,46 @@ const accountSettingsKo = {
1218
1230
  "settingsGroup.devices": "기기",
1219
1231
  "settingsGroup.app": "앱",
1220
1232
  "shortcuts.action.settings": "환경설정 열기",
1221
- "shortcuts.action.settingsDescription": "환경설정 창을 엽니다."
1233
+ "shortcuts.action.settingsDescription": "환경설정 창을 엽니다.",
1234
+ "accountKeyRecovery.title": "암호화된 데이터가 잠겨 있습니다",
1235
+ "accountKeyRecovery.description": "이 데스크톱에서 암호화된 데이터를 잠금 해제하려면 E2EE 복구 키를 입력하세요. 복구 키는 이 기기를 벗어나지 않습니다.",
1236
+ "accountKeyRecovery.label": "E2EE 복구 키",
1237
+ "accountKeyRecovery.unavailable": "이 계정에서는 복구 키 잠금 해제를 사용할 수 없습니다.",
1238
+ "accountKeyRecovery.invalid": "복구 키로 암호화된 데이터를 잠금 해제하지 못했습니다.",
1239
+ "accountKeyRecovery.failed": "복구 키 잠금 해제에 실패했습니다.",
1240
+ "accountKeyRecovery.action": "암호화된 데이터 잠금 해제",
1241
+ "accountKeyRecovery.busy": "잠금 해제 중…",
1242
+ "accountKeyDevice.title": "신뢰 기기",
1243
+ "accountKeyDevice.description": "기존 신뢰 기기에서 이 데스크톱을 승인하거나 대기 중인 기기 요청을 검토하세요.",
1244
+ "accountKeyDevice.loading": "신뢰 기기 상태를 불러오는 중…",
1245
+ "accountKeyDevice.loadFailed": "기기 승인 상태를 불러오지 못했습니다.",
1246
+ "accountKeyDevice.stableIdentityUnavailable": "안정적인 기기 ID를 사용할 수 없어 신뢰 기기 등록을 진행할 수 없습니다.",
1247
+ "accountKeyDevice.secureStorageUnavailable": "보안 기기 저장소를 사용할 수 없어 신뢰 기기 등록을 진행할 수 없습니다.",
1248
+ "accountKeyDevice.qrFailed": "기기 승인 QR을 만들지 못했습니다.",
1249
+ "accountKeyDevice.qrAlt": "기기 승인 QR 코드",
1250
+ "accountKeyDevice.requestFailed": "기기 승인 요청에 실패했습니다.",
1251
+ "accountKeyDevice.pending": "신뢰 기기의 승인을 기다리고 있습니다.",
1252
+ "accountKeyDevice.completeFailed": "기기 승인을 완료하지 못했습니다.",
1253
+ "accountKeyDevice.approved": "이 데스크톱이 신뢰 기기로 승인되었습니다.",
1254
+ "accountKeyDevice.cancelled": "이 기기에서 승인 요청을 취소했습니다.",
1255
+ "accountKeyDevice.cancelFailed": "기기 승인 요청을 취소하지 못했습니다.",
1256
+ "accountKeyDevice.requestsFailed": "대기 중인 기기 요청을 불러오지 못했습니다.",
1257
+ "accountKeyDevice.approveFailed": "기기를 승인하지 못했습니다.",
1258
+ "accountKeyDevice.rejectFailed": "기기 요청을 거절하지 못했습니다.",
1259
+ "accountKeyDevice.revokeFailed": "신뢰 기기를 폐기하지 못했습니다.",
1260
+ "accountKeyDevice.cleanupFailed": "기기는 폐기됐지만 로컬 자격 증명을 삭제하지 못했습니다.",
1261
+ "accountKeyDevice.lockPersistFailed": "기기는 폐기됐지만 로컬 세션 잠금을 저장하지 못했습니다.",
1262
+ "accountKeyDevice.request": "기기 승인 요청",
1263
+ "accountKeyDevice.check": "승인 확인",
1264
+ "accountKeyDevice.cancel": "요청 취소",
1265
+ "accountKeyDevice.scanBefore": "{time} 전에 승인된 기기에서 이 QR을 스캔하세요.",
1266
+ "accountKeyDevice.expires": "{time}에 만료",
1267
+ "accountKeyDevice.approve": "기기 승인",
1268
+ "accountKeyDevice.reject": "기기 거절",
1269
+ "accountKeyDevice.refresh": "요청 새로 고침",
1270
+ "accountKeyDevice.current": "현재 데스크톱",
1271
+ "accountKeyDevice.lastUsed": "마지막 사용: {time}",
1272
+ "accountKeyDevice.revoke": "신뢰 기기 폐기"
1222
1273
  };
1223
1274
  const projectScopeKo = {
1224
1275
  "projectScope.protected": "프로젝트 범위 보호됨",
@@ -1556,6 +1607,7 @@ const composerQuickCommandsKo = {
1556
1607
  const aiCommitPrKo = {
1557
1608
  "aiCommitPr.stagedCount": "스테이지된 파일 {count}개",
1558
1609
  "aiCommitPr.noStaged": "스테이지된 변경이 없습니다.",
1610
+ "aiCommitPr.stagedUnknown": "스테이지된 변경을 확인하지 못했습니다.",
1559
1611
  "aiCommitPr.stageAll": "모든 변경 스테이지",
1560
1612
  "aiCommitPr.staging": "스테이지하는 중…",
1561
1613
  "aiCommitPr.messagePlaceholder": "커밋 메시지",
@@ -1633,6 +1685,14 @@ const connectorReadinessKo = {
1633
1685
  "connectorReadiness.state.unknown": "상태 미확인"
1634
1686
  };
1635
1687
  const checkpointKo = {
1688
+ "checkpoint.activation.label": "파일 체크포인트 보호",
1689
+ "checkpoint.activation.title": "에이전트가 파일을 변경하기 전에 세션별 체크포인트를 만듭니다.",
1690
+ "checkpoint.activation.hint": "세션별",
1691
+ "checkpoint.activation.unavailable.platform": "macOS에서만 지원",
1692
+ "checkpoint.activation.unavailable.agent": "Claude와 Codex만 지원",
1693
+ "checkpoint.activation.unavailable.sandbox": "프로젝트 샌드박스 필요",
1694
+ "checkpoint.activation.unavailable.worktree": "작업 사본은 준비 중",
1695
+ "checkpoint.activation.error": "파일 체크포인트 보호를 사용할 수 없습니다: {reason}",
1636
1696
  "checkpoint.title": "파일 체크포인트",
1637
1697
  "checkpoint.status.protected": "보호됨",
1638
1698
  "checkpoint.status.legacy": "보호되지 않음",
@@ -1793,7 +1853,7 @@ const settingsInfoStructureKo = {
1793
1853
  "overflowMenu.aria": "추가 액션 메뉴",
1794
1854
  "overflowMenu.title": "추가 액션"
1795
1855
  };
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 };
1856
+ 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
1857
  const onboardingKo = {
1798
1858
  "boot.stage.app": "앱 준비 중…",
1799
1859
  "boot.stage.localRuntime": "로컬 작업공간 준비 중…",
@@ -2605,6 +2665,7 @@ const ko = {
2605
2665
  "gitControls.branchLoading": "Git branch 확인 중",
2606
2666
  "gitControls.noRemote": "origin remote 또는 branch를 찾을 수 없습니다.",
2607
2667
  "gitControls.checkFailed": "Git 확인 실패",
2668
+ "gitControls.retryStatus": "다시 확인",
2608
2669
  "gitControls.checking": "Git 확인 중",
2609
2670
  "gitControls.manageBranchAria": "{branch} branch 관리",
2610
2671
  "gitControls.manageBranchTitle": "Branch 변경 및 생성",
@@ -3653,6 +3714,9 @@ const ko = {
3653
3714
  "preview.tokenUrlInvalid": "Preview 토큰 URL이 올바르지 않습니다.",
3654
3715
  "branchDialog.loading": "Branch 목록을 불러오는 중입니다.",
3655
3716
  "branchDialog.empty": "표시할 branch가 없습니다.",
3717
+ "branchDialog.searchLabel": "브랜치 검색",
3718
+ "branchDialog.searchPlaceholder": "브랜치 이름 검색",
3719
+ "branchDialog.searchEmpty": "'{query}'와 일치하는 브랜치가 없습니다.",
3656
3720
  "branchDialog.actionsAria": "{name} 브랜치 작업",
3657
3721
  "branchDialog.rowAria": "{name} {meta}",
3658
3722
  "branchDialog.switch": "브랜치 이동",
@@ -3667,6 +3731,24 @@ const ko = {
3667
3731
  "branchDialog.deleteConfirm": "'{name}' 브랜치를 삭제할까요? 머지되지 않은 커밋이 있으면 Git이 거부합니다.",
3668
3732
  "branchDialog.deleteConfirmAction": "삭제",
3669
3733
  "branchDialog.cancel": "취소",
3734
+ "branchCleanup.openAria": "병합된 브랜치 정리",
3735
+ "branchCleanup.title": "정리 가능한 브랜치",
3736
+ "branchCleanup.loading": "브랜치를 확인하는 중…",
3737
+ "branchCleanup.empty": "정리 가능한 브랜치가 없습니다.",
3738
+ "branchCleanup.scannedNote": "로컬 브랜치 {total}개 중 {count}개가 정리 가능합니다.",
3739
+ "branchCleanup.selectAll": "모두 선택",
3740
+ "branchCleanup.deselectAll": "모두 해제",
3741
+ "branchCleanup.deleteSelected": "{count}개 삭제",
3742
+ "branchCleanup.deleteFailed": "브랜치를 삭제할 수 없습니다.",
3743
+ "branchCleanup.deletePartial": "{total}개 중 {failed}개를 삭제하지 못했습니다: {detail}",
3744
+ "branchCleanup.loadFailed": "브랜치 확인을 실패했습니다.",
3745
+ "branchCleanup.noBaseBranch": "기본 브랜치를 찾을 수 없습니다. origin/HEAD 를 설정하거나 프로젝트 설정에서 기본 브랜치를 지정하세요.",
3746
+ "branchCleanup.probeIncomplete": "브랜치 정보를 완전히 읽지 못했습니다. 목록이 비어 보일 수 있으니 다시 시도하세요.",
3747
+ "branchCleanup.demoUnsupported": "Demo 모드에서는 지원하지 않습니다.",
3748
+ "branchCleanup.refreshAria": "브랜치 목록 새로고침",
3749
+ "branchCleanup.squashScanning": "squash merge된 브랜치를 찾는 중…",
3750
+ "branchCleanup.squashFailed": "squash merge 검사에 실패했습니다. 아래 목록은 일반 merge 후보만입니다.",
3751
+ "branchCleanup.squashTag": "squash 병합",
3670
3752
  "message.deleteConfirm": "이 메시지를 삭제할까요? 이 작업은 새로고침 후에도 유지됩니다.",
3671
3753
  "message.deleteFailed": "메시지를 삭제할 수 없습니다.",
3672
3754
  "message.sendFailed": "메시지를 보낼 수 없습니다.",
@@ -7531,6 +7613,7 @@ function parseUnlockMethod(value2) {
7531
7613
  const kdfParams = parseKdfParams(method.kdfParams);
7532
7614
  const salt = boundedAllowEmpty(method.salt, 256);
7533
7615
  const wrappedMasterKey = bounded(method.wrappedMasterKey, 16384);
7616
+ const possessionPublicKey = bounded(method.possessionPublicKey, 43);
7534
7617
  if (!id || !/^[A-Za-z0-9_-]+$/.test(id) || type !== "password" && type !== "recovery" && type !== "device" || !kdfAlgorithm || !kdfParams || salt === void 0 || !wrappedMasterKey) return void 0;
7535
7618
  const revision = method.revision;
7536
7619
  if (revision !== void 0 && (!Number.isSafeInteger(revision) || revision < 1)) return void 0;
@@ -7541,6 +7624,7 @@ function parseUnlockMethod(value2) {
7541
7624
  kdfParams,
7542
7625
  salt,
7543
7626
  wrappedMasterKey,
7627
+ ...possessionPublicKey && /^[A-Za-z0-9_-]{43}$/.test(possessionPublicKey) ? { possessionPublicKey } : {},
7544
7628
  ...revision === void 0 ? {} : { revision }
7545
7629
  };
7546
7630
  }
@@ -7674,6 +7758,19 @@ async function decryptDataKeyToString(dataEncryptionKey, bundleBase64) {
7674
7758
  return null;
7675
7759
  }
7676
7760
  }
7761
+ async function decryptDataKeyJson(dataEncryptionKey, bundleBase64) {
7762
+ const json = await decryptDataKeyToString(dataEncryptionKey, bundleBase64);
7763
+ if (json === null) return null;
7764
+ try {
7765
+ return JSON.parse(json);
7766
+ } catch {
7767
+ return null;
7768
+ }
7769
+ }
7770
+ async function decryptDataKeyRecord(dataEncryptionKey, bundleBase64) {
7771
+ const value2 = await decryptDataKeyJson(dataEncryptionKey, bundleBase64);
7772
+ return value2 && typeof value2 === "object" && !Array.isArray(value2) ? value2 : null;
7773
+ }
7677
7774
  async function encryptDataKeyContent(dataEncryptionKey, value2) {
7678
7775
  const nonce = nacl.randomBytes(GCM_NONCE_LENGTH);
7679
7776
  const key = await importGcmKey(dataEncryptionKey);
@@ -7687,6 +7784,9 @@ async function encryptDataKeyContent(dataEncryptionKey, value2) {
7687
7784
  }
7688
7785
  let candidates = [];
7689
7786
  let version = 0;
7787
+ function dataKeyUnwrapCandidates(accountPrivateKey, companyKeys = getCompanyKeyCandidates()) {
7788
+ return accountPrivateKey ? [accountPrivateKey, ...companyKeys] : [];
7789
+ }
7690
7790
  function getCompanyKeyCandidates() {
7691
7791
  return candidates.map((candidate) => candidate.privateKey);
7692
7792
  }
@@ -7696,9 +7796,25 @@ function companyKeyChainVersion() {
7696
7796
  const dekBySessionId = /* @__PURE__ */ new Map();
7697
7797
  const lockedSessionIds = /* @__PURE__ */ new Set();
7698
7798
  const fingerprints = /* @__PURE__ */ new Map();
7699
- let boundaryGeneration = 0;
7799
+ const companyUnwrappedEnvelopes = /* @__PURE__ */ new Map();
7800
+ function firstUnwrap(envelope, candidates2) {
7801
+ for (const candidate of candidates2) {
7802
+ const dek = unwrapDataEncryptionKey(envelope, candidate);
7803
+ if (dek) return dek;
7804
+ }
7805
+ return null;
7806
+ }
7807
+ let boundaryGeneration$1 = 0;
7700
7808
  function sessionDekRegistryGeneration() {
7701
- return boundaryGeneration;
7809
+ return boundaryGeneration$1;
7810
+ }
7811
+ function adoptSessionDek(sessionId, envelope, dek, byCompanyKey) {
7812
+ const existing = dekBySessionId.get(sessionId);
7813
+ if (existing && existing.length === dek.length && existing.every((byte, index) => byte === dek[index])) dek.fill(0);
7814
+ else dekBySessionId.set(sessionId, dek);
7815
+ lockedSessionIds.delete(sessionId);
7816
+ if (byCompanyKey) companyUnwrappedEnvelopes.set(sessionId, envelope);
7817
+ else companyUnwrappedEnvelopes.delete(sessionId);
7702
7818
  }
7703
7819
  function registerSessionDek(session, accountPrivateKey) {
7704
7820
  const envelope = session.dataEncryptionKey ?? null;
@@ -7706,6 +7822,7 @@ function registerSessionDek(session, accountPrivateKey) {
7706
7822
  dekBySessionId.delete(session.id);
7707
7823
  lockedSessionIds.delete(session.id);
7708
7824
  fingerprints.delete(session.id);
7825
+ companyUnwrappedEnvelopes.delete(session.id);
7709
7826
  return;
7710
7827
  }
7711
7828
  const fingerprint = `${envelope}|${accountPrivateKey ? "1" : "0"}|${companyKeyChainVersion()}`;
@@ -7713,20 +7830,16 @@ function registerSessionDek(session, accountPrivateKey) {
7713
7830
  if (!accountPrivateKey && dekBySessionId.has(session.id) && fingerprints.get(session.id)?.startsWith(`${envelope}|1|`)) return;
7714
7831
  const previousFingerprint = fingerprints.get(session.id);
7715
7832
  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
- }
7833
+ const accountDek = accountPrivateKey ? unwrapDataEncryptionKey(envelope, accountPrivateKey) : null;
7834
+ const dek = accountDek ?? (accountPrivateKey ? firstUnwrap(envelope, getCompanyKeyCandidates()) : null);
7722
7835
  if (dek) {
7723
- dekBySessionId.set(session.id, dek);
7724
- lockedSessionIds.delete(session.id);
7836
+ adoptSessionDek(session.id, envelope, dek, accountDek === null);
7725
7837
  return;
7726
7838
  }
7727
7839
  if (dekBySessionId.has(session.id) && previousFingerprint?.startsWith(`${envelope}|`)) return;
7728
7840
  dekBySessionId.delete(session.id);
7729
7841
  lockedSessionIds.add(session.id);
7842
+ if (companyUnwrappedEnvelopes.has(session.id)) companyUnwrappedEnvelopes.set(session.id, envelope);
7730
7843
  }
7731
7844
  function getSessionDek(sessionId) {
7732
7845
  return dekBySessionId.get(sessionId) ?? null;
@@ -7768,6 +7881,60 @@ async function preDecryptSessionHeaders(sessions, accountPrivateKey, sinceGenera
7768
7881
  }
7769
7882
  }));
7770
7883
  }
7884
+ const machineDekById = /* @__PURE__ */ new Map();
7885
+ let boundaryGeneration = 0;
7886
+ function machineDekRegistryGeneration() {
7887
+ return boundaryGeneration;
7888
+ }
7889
+ function discardMachineDek(machineId) {
7890
+ machineDekById.get(machineId)?.dek.fill(0);
7891
+ machineDekById.delete(machineId);
7892
+ }
7893
+ function machineFingerprint(envelope) {
7894
+ return `${envelope}|${companyKeyChainVersion()}`;
7895
+ }
7896
+ async function resolveMachineDataKeyFields(record2, legacyKeys, privateKeys, scope) {
7897
+ const discard = { machineId: record2.id, fields: null, entry: null };
7898
+ if (!record2.dataEncryptionKey || !record2.metadata) return discard;
7899
+ if (decryptJsonWithCandidates(record2.metadata, legacyKeys)) return discard;
7900
+ const fingerprint = machineFingerprint(record2.dataEncryptionKey);
7901
+ const cached = machineDekById.get(record2.id);
7902
+ const machineKey = cached?.fingerprint === fingerprint ? cached.dek : unwrapMachineKey(record2.dataEncryptionKey, privateKeys);
7903
+ if (!machineKey) return discard;
7904
+ const metadata = await decryptDataKeyRecord(machineKey, record2.metadata);
7905
+ if (!metadata) return discard;
7906
+ const daemonState = record2.daemonState ? await decryptDataKeyRecord(machineKey, record2.daemonState) : null;
7907
+ const entry = cached?.dek === machineKey ? cached : { dek: machineKey, scope, fingerprint };
7908
+ entry.scope = scope;
7909
+ return { machineId: record2.id, fields: { metadata, daemonState }, entry };
7910
+ }
7911
+ function commitMachineDek(resolved) {
7912
+ if (resolved.entry) machineDekById.set(resolved.machineId, resolved.entry);
7913
+ else discardMachineDek(resolved.machineId);
7914
+ }
7915
+ function unwrapMachineKey(envelope, privateKeys) {
7916
+ for (const privateKey of privateKeys) {
7917
+ const machineKey = unwrapDataEncryptionKey(envelope, privateKey);
7918
+ if (machineKey) return machineKey;
7919
+ }
7920
+ return null;
7921
+ }
7922
+ async function decryptMachineDataKeyRows(rows, legacyKeys, options, scope, sinceGeneration = machineDekRegistryGeneration()) {
7923
+ const none = () => rows.map(() => null);
7924
+ if (sinceGeneration !== boundaryGeneration) return none();
7925
+ const listed = new Set(rows.map((row2) => row2.id));
7926
+ for (const [machineId, entry] of [...machineDekById]) {
7927
+ if (entry.scope === scope && !listed.has(machineId)) discardMachineDek(machineId);
7928
+ }
7929
+ if (!options.accountPrivateKey || !rows.some((row2) => row2.dataEncryptionKey)) return none();
7930
+ await options.dataKeyReady;
7931
+ if (sinceGeneration !== boundaryGeneration) return none();
7932
+ const privateKeys = dataKeyUnwrapCandidates(options.accountPrivateKey);
7933
+ const resolved = await Promise.all(rows.map((row2) => resolveMachineDataKeyFields(row2, legacyKeys, privateKeys, scope)));
7934
+ if (sinceGeneration !== boundaryGeneration) return none();
7935
+ for (const entry of resolved) commitMachineDek(entry);
7936
+ return resolved.map((entry) => entry.fields);
7937
+ }
7771
7938
  function buildUserTextMessageContent(input) {
7772
7939
  return {
7773
7940
  role: "user",
@@ -17951,10 +18118,10 @@ function normalizeAutomationSupport(value2) {
17951
18118
  ...Number.isSafeInteger(capability.keyVersion) && capability.keyVersion > 0 ? { keyVersion: capability.keyVersion } : {}
17952
18119
  };
17953
18120
  }
17954
- function normalizeMachine(row2, candidateKeys) {
18121
+ function normalizeMachine(row2, candidateKeys, dataKeyFields = null) {
17955
18122
  const registry = row2.registry;
17956
- const decrypted = decryptJsonWithCandidates(row2.metadata, candidateKeys) ?? {};
17957
- const daemonState = decryptJsonWithCandidates(row2.daemonState, candidateKeys);
18123
+ const decrypted = dataKeyFields?.metadata ?? decryptJsonWithCandidates(row2.metadata, candidateKeys) ?? {};
18124
+ const daemonState = dataKeyFields?.daemonState ?? decryptJsonWithCandidates(row2.daemonState, candidateKeys);
17958
18125
  const resources = row2.resources ?? parseMachineResources(daemonState);
17959
18126
  const host = row2.host || registry?.host || (typeof decrypted.host === "string" ? decrypted.host : row2.id.slice(0, 8));
17960
18127
  const platform2 = row2.platform || registry?.platform || (typeof decrypted.platform === "string" ? decrypted.platform : "unknown");
@@ -19180,13 +19347,16 @@ class ApiClient {
19180
19347
  async getMachines(companyId, candidateKeys = [], options = {}) {
19181
19348
  const company = companyId ?? "personal";
19182
19349
  const statusParam = options.withStatus ? "&withStatus=1" : "";
19350
+ const dekGeneration = machineDekRegistryGeneration();
19183
19351
  const data = await this.request(
19184
19352
  `/api/machines/list?company=${encodeURIComponent(company)}${statusParam}`,
19185
19353
  { boundary: "api" }
19186
19354
  );
19187
19355
  const rows = Array.isArray(data) ? data : data.machines ?? [];
19188
19356
  const registry = await this.fetchMachineRegistry(rows.map((row2) => row2.id));
19189
- return rows.map((row2) => normalizeMachine(withMachineRegistry(row2, registry), candidateKeys));
19357
+ const enriched = rows.map((row2) => withMachineRegistry(row2, registry));
19358
+ const dataKeyFields = await decryptMachineDataKeyRows(enriched, candidateKeys, options, company, dekGeneration);
19359
+ return enriched.map((row2, index) => normalizeMachine(row2, candidateKeys, dataKeyFields[index]));
19190
19360
  }
19191
19361
  /**
19192
19362
  * Fail-open: a registry outage degrades machine names back to the daemon
@@ -21813,6 +21983,141 @@ function resumeHistoryCount(input) {
21813
21983
  if (!input.resuming) return 0;
21814
21984
  return input.since ?? DEFAULT_RESUME_HISTORY;
21815
21985
  }
21986
+ const AGENT_SPAWN_BUDGET_MIN = 4;
21987
+ const AGENT_SPAWN_BUDGET_MAX = 32;
21988
+ const MEMORY_BYTES_PER_AGENT = 2 * 1024 ** 3;
21989
+ const AGENTS_PER_CORE = 2;
21990
+ function machineSpawnBudget(capacity) {
21991
+ const cores = Number.isFinite(capacity.cpuCount) ? Math.max(0, Math.floor(capacity.cpuCount)) : 0;
21992
+ const memory = Number.isFinite(capacity.totalMemoryBytes) ? Math.max(0, capacity.totalMemoryBytes) : 0;
21993
+ if (cores <= 0 || memory <= 0) return AGENT_SPAWN_BUDGET_MIN;
21994
+ const budget = Math.min(Math.floor(memory / MEMORY_BYTES_PER_AGENT), cores * AGENTS_PER_CORE);
21995
+ return Math.min(Math.max(budget, AGENT_SPAWN_BUDGET_MIN), AGENT_SPAWN_BUDGET_MAX);
21996
+ }
21997
+ function tierSpawnBudget(depth, machineBudget) {
21998
+ return depth <= 0 ? machineBudget : Math.ceil(machineBudget / 2);
21999
+ }
22000
+ function resolveMaxSpawn(input) {
22001
+ const tier = tierSpawnBudget(input.depth, input.machineBudget);
22002
+ return input.policy === null ? tier : Math.min(input.policy, tier);
22003
+ }
22004
+ const SPAWN_BUDGET_WINDOW_MS = 6 * 60 * 60 * 1e3;
22005
+ function isCharged(chargedAt, now) {
22006
+ return chargedAt > now - SPAWN_BUDGET_WINDOW_MS;
22007
+ }
22008
+ function chargeTimes(records) {
22009
+ const byId = /* @__PURE__ */ new Map();
22010
+ const unkeyed = [];
22011
+ for (const record2 of records) {
22012
+ if (record2.spawnId === void 0) {
22013
+ unkeyed.push(record2.ts);
22014
+ continue;
22015
+ }
22016
+ const seen = byId.get(record2.spawnId);
22017
+ if (seen === void 0 || record2.ts < seen) byId.set(record2.spawnId, record2.ts);
22018
+ }
22019
+ return [...byId.values(), ...unkeyed];
22020
+ }
22021
+ function chargedSpawnCount(records, now) {
22022
+ return chargeTimes(records).filter((chargedAt) => isCharged(chargedAt, now)).length;
22023
+ }
22024
+ function spawnBudgetResetsAt(records, now, maxSpawn) {
22025
+ if (maxSpawn <= 0) return null;
22026
+ const expiries = chargeTimes(records).filter((chargedAt) => isCharged(chargedAt, now)).map((ts) => ts + SPAWN_BUDGET_WINDOW_MS).sort((a, b) => a - b);
22027
+ if (expiries.length < maxSpawn) return null;
22028
+ return expiries[expiries.length - maxSpawn];
22029
+ }
22030
+ function spawnLimitDetail(input) {
22031
+ const used = `${input.spawned}/${input.maxSpawn} used`;
22032
+ if (input.resetsAt === null || input.resetsAt <= input.now) return used;
22033
+ const minutes = Math.ceil((input.resetsAt - input.now) / 6e4);
22034
+ return `${used}; the next slot frees in ${minutes} minute${minutes === 1 ? "" : "s"}`;
22035
+ }
22036
+ const AGENT_ENV_FLAG = "SAYCODE_AGENT_ENV";
22037
+ const AGENT_MAX_DEPTH = 2;
22038
+ function readOptionalNonNegativeInt(raw) {
22039
+ if (raw === void 0 || !/^\d+$/.test(raw.trim())) return null;
22040
+ const value2 = Number(raw.trim());
22041
+ return Number.isSafeInteger(value2) ? value2 : null;
22042
+ }
22043
+ function normalizeDirectory(raw) {
22044
+ const trimmed = raw.trim();
22045
+ if (!trimmed) return "";
22046
+ const stripped = trimmed.replace(/\/+$/, "");
22047
+ return stripped || "/";
22048
+ }
22049
+ function hasAgentMarker(env) {
22050
+ return env[AGENT_ENV_FLAG] === "1";
22051
+ }
22052
+ function isAgentEnv(env) {
22053
+ return hasAgentMarker(env) && normalizeDirectory(env.SAYCODE_AGENT_ROOT ?? "") !== "";
22054
+ }
22055
+ function readAgentEnv(env, machineBudget) {
22056
+ if (!isAgentEnv(env)) return null;
22057
+ const root = normalizeDirectory(env.SAYCODE_AGENT_ROOT ?? "");
22058
+ const depth = readOptionalNonNegativeInt(env.SAYCODE_AGENT_DEPTH) ?? 0;
22059
+ return {
22060
+ root,
22061
+ scope: normalizeDirectory(env.SAYCODE_AGENT_SCOPE ?? "") || root,
22062
+ depth,
22063
+ maxSpawn: resolveMaxSpawn({
22064
+ policy: readOptionalNonNegativeInt(env.SAYCODE_AGENT_MAX_SPAWN),
22065
+ depth,
22066
+ machineBudget
22067
+ }),
22068
+ agentId: env.SAYCODE_AGENT_ID?.trim() || null,
22069
+ sessionId: env.APLUS_SESSION_ID?.trim() || null
22070
+ };
22071
+ }
22072
+ function spawnDecision(input) {
22073
+ if (input.depth >= AGENT_MAX_DEPTH) return "spawn_depth_exceeded";
22074
+ if (input.spawned >= input.maxSpawn) return "spawn_limit_exceeded";
22075
+ return "allow";
22076
+ }
22077
+ function childAgentEnv(parent, childAgentId) {
22078
+ return {
22079
+ [AGENT_ENV_FLAG]: "1",
22080
+ SAYCODE_AGENT_ROOT: parent.root,
22081
+ SAYCODE_AGENT_SCOPE: parent.scope,
22082
+ SAYCODE_AGENT_DEPTH: String(parent.depth + 1),
22083
+ SAYCODE_AGENT_MAX_SPAWN: String(tierSpawnBudget(parent.depth + 1, parent.maxSpawn)),
22084
+ SAYCODE_AGENT_ID: childAgentId
22085
+ };
22086
+ }
22087
+ function isInScope(session, scope) {
22088
+ if (!session.machineId || !scope.machineId) return false;
22089
+ if (session.machineId !== scope.machineId) return false;
22090
+ const root = normalizeDirectory(scope.root);
22091
+ const directory = normalizeDirectory(session.directory);
22092
+ if (!root || !directory) return false;
22093
+ if (directory === root) return true;
22094
+ return directory.startsWith(root === "/" ? "/" : `${root}/`);
22095
+ }
22096
+ function sessionsInScope(sessions, scope) {
22097
+ return sessions.filter((session) => isInScope(session, scope));
22098
+ }
22099
+ function machineSettingsPaths(input) {
22100
+ const override = input.env.SAYCODE_HAPPY_HOME?.trim();
22101
+ const runtimeHome = input.env.HAPPY_HOME_DIR?.trim();
22102
+ const homes = CREDENTIAL_SOURCE_ORDER.filter((source) => source !== "saycode").map((source) => `${input.homeDir}/.${source}`);
22103
+ const ordered = [input.credentialHome?.trim(), override, runtimeHome, ...homes];
22104
+ return [...new Set(ordered.filter((home) => Boolean(home)))].map((home) => `${home}/settings.json`.replace(/\/{2,}/g, "/"));
22105
+ }
22106
+ function readMachineId(input, readFile2) {
22107
+ for (const path of machineSettingsPaths(input)) {
22108
+ const raw = readFile2(path);
22109
+ if (!raw) continue;
22110
+ try {
22111
+ const parsed = JSON.parse(raw);
22112
+ if (typeof parsed?.machineId === "string" && parsed.machineId) return parsed.machineId;
22113
+ } catch {
22114
+ }
22115
+ }
22116
+ return null;
22117
+ }
22118
+ function spawnBudgetKey(env) {
22119
+ return env.agentId ?? env.root;
22120
+ }
21816
22121
  const REAL_CLI_SESSION_CONNECTION_DEPS = {
21817
22122
  createLocalId: () => `saycode-cli-${process.pid}-${Math.random().toString(36).slice(2)}`
21818
22123
  };
@@ -23891,6 +24196,9 @@ async function askTemporaryLine(question) {
23891
24196
  }
23892
24197
  async function runChat(context, options, providedTransport) {
23893
24198
  const resuming = !!options.sessionId;
24199
+ if (!resuming && hasAgentMarker(process.env)) {
24200
+ 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.");
24201
+ }
23894
24202
  let optionPrompter = null;
23895
24203
  let optionInput = null;
23896
24204
  if (!resuming && options.promptForOptions) {
@@ -24795,6 +25103,9 @@ function waitForSessionState(options) {
24795
25103
  });
24796
25104
  }
24797
25105
  const AGENT_FAILURE_EXIT_CODE = 3;
25106
+ function isAgentFailure(value2) {
25107
+ return typeof value2 === "object" && value2 !== null && "stdout" in value2;
25108
+ }
24798
25109
  function line(value2) {
24799
25110
  return `${JSON.stringify(value2)}
24800
25111
  `;
@@ -24927,87 +25238,6 @@ async function promptTarget(runtime, target, text2, options) {
24927
25238
  stream2.close();
24928
25239
  }
24929
25240
  }
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
25241
  function normalizeAbsolutePath(raw) {
25012
25242
  const trimmed = raw.trim();
25013
25243
  if (!trimmed.startsWith("/")) return "";
@@ -25118,6 +25348,71 @@ function buildAgentHandoffPrompt(source, context) {
25118
25348
  ...FIRST_ACTIONS.map((action) => `- ${action}`)
25119
25349
  ].join("\n");
25120
25350
  }
25351
+ function checkSpawnBudget(runtime, env) {
25352
+ const key = spawnBudgetKey(env);
25353
+ let usage;
25354
+ try {
25355
+ usage = runtime.countSpawns(key, env.maxSpawn);
25356
+ } catch (error) {
25357
+ return agentFail("spawn_limit_exceeded", `spawn ledger is unreadable, refusing: ${String(error)}`);
25358
+ }
25359
+ const decision = spawnDecision({ depth: env.depth, maxSpawn: env.maxSpawn, spawned: usage.spawned });
25360
+ if (decision === "allow") return { spawned: usage.spawned, key };
25361
+ return agentFail(decision, spawnLimitDetail({
25362
+ spawned: usage.spawned,
25363
+ maxSpawn: env.maxSpawn,
25364
+ resetsAt: usage.resetsAt,
25365
+ now: runtime.now()
25366
+ }));
25367
+ }
25368
+ function reserveSpawn(runtime, env, key, attempt2, spawnedBefore) {
25369
+ const spawnId = runtime.newAgentId();
25370
+ try {
25371
+ runtime.recordSpawn({
25372
+ ts: runtime.now(),
25373
+ agentKey: key,
25374
+ root: env.scope,
25375
+ depth: env.depth,
25376
+ // Unknown until the daemon answers; `resolveSpawn` appends the id under
25377
+ // the same `spawnId`, and the counter charges the pair once.
25378
+ childSessionId: null,
25379
+ ...env.sessionId ? { parentSessionId: env.sessionId } : {},
25380
+ agent: attempt2.agent,
25381
+ promptPreview: attempt2.promptPreview,
25382
+ outcome: "failed",
25383
+ spawnId
25384
+ });
25385
+ } catch (error) {
25386
+ return agentFail("spawn_limit_exceeded", `spawn ledger is unwritable, refusing before anything is created: ${String(error)}`);
25387
+ }
25388
+ try {
25389
+ if (runtime.countSpawns(key, env.maxSpawn).spawned <= spawnedBefore) {
25390
+ return agentFail("spawn_limit_exceeded", "the spawn reservation did not land in the ledger, refusing before anything is created");
25391
+ }
25392
+ } catch (error) {
25393
+ return agentFail("spawn_limit_exceeded", `spawn ledger became unreadable after reserving, refusing: ${String(error)}`);
25394
+ }
25395
+ return { spawnId };
25396
+ }
25397
+ function resolveSpawn(runtime, env, key, spawnId, outcome) {
25398
+ try {
25399
+ runtime.recordSpawn({
25400
+ ts: runtime.now(),
25401
+ agentKey: key,
25402
+ root: env.scope,
25403
+ depth: env.depth,
25404
+ childSessionId: outcome.sessionId,
25405
+ ...env.sessionId ? { parentSessionId: env.sessionId } : {},
25406
+ agent: outcome.agent,
25407
+ promptPreview: outcome.promptPreview,
25408
+ outcome: outcome.sessionId ? "spawned" : "failed",
25409
+ spawnId
25410
+ });
25411
+ return true;
25412
+ } catch {
25413
+ return false;
25414
+ }
25415
+ }
25121
25416
  async function fileSpawnedSessionUnderProject(runtime, sessionId, directory) {
25122
25417
  if (!runtime.registerSpawnedSession) return;
25123
25418
  try {
@@ -25163,15 +25458,9 @@ async function handoffToSibling(runtime, scope, env, handoff, agent, modelOption
25163
25458
  if (env.depth >= AGENT_MAX_DEPTH) {
25164
25459
  return agentFail("spawn_depth_exceeded", `depth ${env.depth} is already at the ceiling of ${AGENT_MAX_DEPTH}`);
25165
25460
  }
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`);
25461
+ const budget = checkSpawnBudget(runtime, env);
25462
+ if (isAgentFailure(budget)) return budget;
25463
+ const { spawned, key } = budget;
25175
25464
  let prompt;
25176
25465
  try {
25177
25466
  prompt = buildHandoffPrompt(await runtime.readMessages(from, HANDOFF_READ_MESSAGES), source, agent);
@@ -25179,6 +25468,9 @@ async function handoffToSibling(runtime, scope, env, handoff, agent, modelOption
25179
25468
  return agentFail("handoff_context_failed", String(error));
25180
25469
  }
25181
25470
  const childId = runtime.newAgentId();
25471
+ const preview = `handoff from ${from}`;
25472
+ const reservation = reserveSpawn(runtime, env, key, { agent, promptPreview: preview }, spawned);
25473
+ if (isAgentFailure(reservation)) return reservation;
25182
25474
  let sessionId = null;
25183
25475
  let spawnError = null;
25184
25476
  try {
@@ -25192,22 +25484,7 @@ async function handoffToSibling(runtime, scope, env, handoff, agent, modelOption
25192
25484
  } catch (error) {
25193
25485
  spawnError = error;
25194
25486
  }
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
- }
25487
+ const lineageRecorded = resolveSpawn(runtime, env, key, reservation.spawnId, { agent, promptPreview: preview, sessionId });
25211
25488
  if (!sessionId) {
25212
25489
  return agentFail("spawn_failed", `the machine daemon did not confirm a session: ${String(spawnError)}`);
25213
25490
  }
@@ -25219,6 +25496,7 @@ async function handoffToSibling(runtime, scope, env, handoff, agent, modelOption
25219
25496
  depth: env.depth + 1,
25220
25497
  spawned: spawned + 1,
25221
25498
  maxSpawn: env.maxSpawn,
25499
+ lineageRecorded,
25222
25500
  handoff: { mode: "summary", from }
25223
25501
  });
25224
25502
  }
@@ -25493,7 +25771,7 @@ function cliSpawnModelOptions() {
25493
25771
  }
25494
25772
  const DEFAULT_READ_LIMIT = 20;
25495
25773
  function scopeOf(runtime) {
25496
- const env = readAgentEnv(runtime.env);
25774
+ const env = readAgentEnv(runtime.env, runtime.machineBudget);
25497
25775
  if (!env) return agentFail("not_agent_env", "This command only runs inside a Saycode-spawned session.");
25498
25776
  const machineId = readMachineId(
25499
25777
  { homeDir: runtime.homeDir, env: runtime.env, credentialHome: runtime.credentialHome ?? null },
@@ -25504,9 +25782,6 @@ function scopeOf(runtime) {
25504
25782
  }
25505
25783
  return { scope: { root: env.scope, machineId }, env };
25506
25784
  }
25507
- function isFailure(value2) {
25508
- return typeof value2 === "object" && value2 !== null && "stdout" in value2;
25509
- }
25510
25785
  async function resolveTarget(runtime, scope, sessionId) {
25511
25786
  const sessions = await runtime.listSessions();
25512
25787
  const session = sessions.find((candidate) => candidate.id === sessionId);
@@ -25557,16 +25832,12 @@ async function spawnSibling(runtime, env, text2, agent, contextFiles = [], model
25557
25832
  if (!context.ok) {
25558
25833
  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
25834
  }
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`);
25835
+ const budget = checkSpawnBudget(runtime, env);
25836
+ if (isAgentFailure(budget)) return budget;
25837
+ const { spawned, key } = budget;
25569
25838
  const childId = runtime.newAgentId();
25839
+ const reservation = reserveSpawn(runtime, env, key, { agent, promptPreview: text2 }, spawned);
25840
+ if (isAgentFailure(reservation)) return reservation;
25570
25841
  let sessionId = null;
25571
25842
  let spawnError = null;
25572
25843
  try {
@@ -25580,36 +25851,34 @@ async function spawnSibling(runtime, env, text2, agent, contextFiles = [], model
25580
25851
  } catch (error) {
25581
25852
  spawnError = error;
25582
25853
  }
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
- }
25854
+ const lineageRecorded = resolveSpawn(runtime, env, key, reservation.spawnId, { agent, promptPreview: text2, sessionId });
25599
25855
  if (!sessionId) {
25600
25856
  return agentFail("spawn_failed", `the machine daemon did not confirm a session: ${String(spawnError)}`);
25601
25857
  }
25602
25858
  await fileSpawnedSessionUnderProject(runtime, sessionId, env.root);
25603
- return agentOk({ sessionId, agent, directory: env.root, depth: env.depth + 1, spawned: spawned + 1, maxSpawn: env.maxSpawn });
25859
+ return agentOk({
25860
+ sessionId,
25861
+ agent,
25862
+ directory: env.root,
25863
+ depth: env.depth + 1,
25864
+ spawned: spawned + 1,
25865
+ maxSpawn: env.maxSpawn,
25866
+ // False means the child runs but this session's ownership link went
25867
+ // unwritten, so a spawned parent cannot steer or stop it.
25868
+ lineageRecorded
25869
+ });
25604
25870
  }
25605
25871
  async function runAgentCommand(runtime, args) {
25606
25872
  const context = scopeOf(runtime);
25607
- if (isFailure(context)) return context;
25873
+ if (isAgentFailure(context)) return context;
25608
25874
  const { scope, env } = context;
25609
25875
  if (args.verb === "whoami") {
25610
25876
  let spawned = null;
25877
+ let resetsAt = null;
25611
25878
  try {
25612
- spawned = runtime.countSpawns(spawnBudgetKey(env));
25879
+ const usage = runtime.countSpawns(spawnBudgetKey(env), env.maxSpawn);
25880
+ spawned = usage.spawned;
25881
+ resetsAt = usage.resetsAt;
25613
25882
  } catch {
25614
25883
  spawned = null;
25615
25884
  }
@@ -25624,6 +25893,9 @@ async function runAgentCommand(runtime, args) {
25624
25893
  maxSpawn: env.maxSpawn,
25625
25894
  spawned,
25626
25895
  spawnsRemaining: spawned === null ? null : Math.max(0, env.maxSpawn - spawned),
25896
+ // The budget is a rolling window, so "full" is a temporary state with a
25897
+ // known end — an agent that is told only "0 left" would give up for good.
25898
+ budgetResetsAt: resetsAt,
25627
25899
  canSpawn: env.depth < AGENT_MAX_DEPTH && spawned !== null && spawned < env.maxSpawn,
25628
25900
  spawnModelOptions: cliSpawnModelOptions()
25629
25901
  });
@@ -25635,7 +25907,7 @@ async function runAgentCommand(runtime, args) {
25635
25907
  if (args.verb === "spawn") {
25636
25908
  const agent = args.agent ?? DEFAULT_SPAWN_AGENT;
25637
25909
  const modelOptions = args.model !== void 0 || args.effort !== void 0 ? await validateSpawnModelOptions(agent, args.model, args.effort) : {};
25638
- if (isFailure(modelOptions)) return modelOptions;
25910
+ if (isAgentFailure(modelOptions)) return modelOptions;
25639
25911
  if (args.handoff) {
25640
25912
  return handoffToSibling(runtime, scope, env, args.handoff, agent, modelOptions);
25641
25913
  }
@@ -25646,7 +25918,7 @@ async function runAgentCommand(runtime, args) {
25646
25918
  }
25647
25919
  if (!args.sessionId) return agentFail("session_not_found", "no session id given");
25648
25920
  const target = await resolveTarget(runtime, scope, args.sessionId);
25649
- if (isFailure(target)) return target;
25921
+ if (isAgentFailure(target)) return target;
25650
25922
  switch (args.verb) {
25651
25923
  case "read":
25652
25924
  return readSession(runtime, target, args.last ?? DEFAULT_READ_LIMIT, args.since);
@@ -25681,7 +25953,7 @@ async function validateSpawnModelOptions(agent, model, effort) {
25681
25953
  function parseSpawnRecord(value2) {
25682
25954
  if (!value2 || typeof value2 !== "object" || Array.isArray(value2)) return null;
25683
25955
  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;
25956
+ 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
25957
  }
25686
25958
  const LEDGER_FILE_MODE = 384;
25687
25959
  const PREVIEW_MAX_LENGTH = 200;
@@ -25722,7 +25994,8 @@ function recordIdentity(record2) {
25722
25994
  record2.parentSessionId ?? null,
25723
25995
  record2.agent,
25724
25996
  record2.promptPreview,
25725
- record2.outcome
25997
+ record2.outcome,
25998
+ record2.spawnId ?? null
25726
25999
  ]);
25727
26000
  }
25728
26001
  function readSpawnRecords(homeDir, rootDir) {
@@ -25738,14 +26011,35 @@ function readSpawnRecords(homeDir, rootDir) {
25738
26011
  return true;
25739
26012
  });
25740
26013
  }
25741
- function countSpawnsFor(homeDir, rootDir, agentKey) {
25742
- return readSpawnRecords(homeDir, rootDir).filter((record2) => record2.agentKey === agentKey).length;
26014
+ function spawnBudgetUsage(homeDir, rootDir, agentKey, now, maxSpawn) {
26015
+ const mine = readSpawnRecords(homeDir, rootDir).filter((record2) => record2.agentKey === agentKey);
26016
+ return {
26017
+ spawned: chargedSpawnCount(mine, now),
26018
+ resetsAt: spawnBudgetResetsAt(mine, now, maxSpawn)
26019
+ };
26020
+ }
26021
+ function endsWithNewline(path) {
26022
+ let fd;
26023
+ try {
26024
+ fd = openSync(path, "r");
26025
+ const size = fstatSync(fd).size;
26026
+ if (size === 0) return true;
26027
+ const tail = Buffer.alloc(1);
26028
+ readSync(fd, tail, 0, 1, size - 1);
26029
+ return tail[0] === 10;
26030
+ } catch (error) {
26031
+ if (error.code === "ENOENT") return true;
26032
+ throw error;
26033
+ } finally {
26034
+ if (fd !== void 0) closeSync(fd);
26035
+ }
25743
26036
  }
25744
26037
  function appendSpawnRecord(rootDir, record2) {
25745
26038
  const path = agentLineagePath(rootDir);
25746
26039
  mkdirSync(join(rootDir, ".aplus"), { recursive: true, mode: 448 });
25747
26040
  const entry = { ...record2, promptPreview: record2.promptPreview.slice(0, PREVIEW_MAX_LENGTH) };
25748
- appendFileSync(path, `${JSON.stringify(entry)}
26041
+ const separator = endsWithNewline(path) ? "" : "\n";
26042
+ appendFileSync(path, `${separator}${JSON.stringify(entry)}
25749
26043
  `, { mode: LEDGER_FILE_MODE });
25750
26044
  }
25751
26045
  function normalize(raw) {
@@ -25947,13 +26241,16 @@ async function openStream(context, transport, sessionId) {
25947
26241
  }
25948
26242
  function createAgentRuntime(context, transport, env = process.env) {
25949
26243
  const keys = [decodeBase64(context.syncCredential.secret)];
25950
- const homeDir = context.homeDir, lineageRoot = readAgentEnv(env)?.scope ?? context.homeDir;
26244
+ const machineBudget = machineSpawnBudget({ cpuCount: cpus().length, totalMemoryBytes: totalmem() });
26245
+ const homeDir = context.homeDir, lineageRoot = readAgentEnv(env, machineBudget)?.scope ?? context.homeDir;
25951
26246
  const credentialHome = context.credential.source === "saycode" ? null : dirname(context.credential.path);
26247
+ const now = () => Date.now();
25952
26248
  return {
25953
26249
  env,
25954
26250
  homeDir,
25955
26251
  credentialHome,
25956
- now: () => Date.now(),
26252
+ machineBudget,
26253
+ now,
25957
26254
  readFile: readFileOrNull,
25958
26255
  isReadableFile: isReadableFileSync,
25959
26256
  listSessions: async () => normalizeSessionRows(await context.rest.listSessions(), keys),
@@ -25989,7 +26286,7 @@ function createAgentRuntime(context, transport, env = process.env) {
25989
26286
  return { sessionId: result.sessionId };
25990
26287
  },
25991
26288
  newAgentId: () => randomBytes(8).toString("hex"),
25992
- countSpawns: (agentKey) => countSpawnsFor(homeDir, lineageRoot, agentKey),
26289
+ countSpawns: (agentKey, maxSpawn) => spawnBudgetUsage(homeDir, lineageRoot, agentKey, now(), maxSpawn),
25993
26290
  recordSpawn: (record2) => appendSpawnRecord(lineageRoot, record2),
25994
26291
  readLineage: () => readSpawnRecords(homeDir, lineageRoot),
25995
26292
  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.0",
4
4
  "description": "Drive Saycode sessions across your machines from the terminal.",
5
5
  "type": "module",
6
6
  "bin": {