agentlas 1.0.64 → 1.0.66

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 (164) hide show
  1. package/CHANGELOG.md +87 -0
  2. package/README.md +8 -6
  3. package/bin/agentlas.cjs +341 -16
  4. package/engine/acp/server.cjs +27 -1
  5. package/engine/agentlas-api-agent.cjs +151 -120
  6. package/engine/agentlas-banner.cjs +6 -2
  7. package/engine/agentlas-capabilities.cjs +17 -10
  8. package/engine/agentlas-cloud-runtime.cjs +4 -5
  9. package/engine/agentlas-config.cjs +19 -54
  10. package/engine/agentlas-core-harness.cjs +59 -5
  11. package/engine/agentlas-desktop-loadout.cjs +17 -3
  12. package/engine/agentlas-evolution.cjs +75 -18
  13. package/engine/agentlas-experience-exchange.cjs +145 -14
  14. package/engine/agentlas-experience-intake.cjs +2 -2
  15. package/engine/agentlas-i18n.cjs +0 -2
  16. package/engine/agentlas-input.cjs +273 -20
  17. package/engine/agentlas-judgment.cjs +0 -0
  18. package/engine/agentlas-mcp-env.cjs +84 -23
  19. package/engine/agentlas-mcp-wrapper.cjs +36 -4
  20. package/engine/agentlas-memory-governance.cjs +2 -1
  21. package/engine/agentlas-memory-import.cjs +49 -12
  22. package/engine/agentlas-native-host.cjs +279 -44
  23. package/engine/agentlas-onboard.cjs +23 -1
  24. package/engine/agentlas-permissions.cjs +5 -1
  25. package/engine/agentlas-sqlite-policy.cjs +40 -2
  26. package/engine/agentlas-tasks.cjs +7 -0
  27. package/engine/agentlas-tools.cjs +37 -10
  28. package/engine/agentlas-ui.cjs +26 -4
  29. package/engine/agentlas-workforce.cjs +29 -5
  30. package/engine/agentlas-workload-routing.cjs +83 -10
  31. package/engine/agentlas.cjs +10 -3
  32. package/engine/agents/builder.cjs +35 -10
  33. package/engine/agents/files.cjs +81 -10
  34. package/engine/agents/import-local.cjs +10 -2
  35. package/engine/agents/registry.cjs +10 -3
  36. package/engine/agents/router.cjs +7 -7
  37. package/engine/agents/routes.cjs +31 -9
  38. package/engine/automation/daemon.cjs +154 -40
  39. package/engine/automation/launchd.cjs +395 -16
  40. package/engine/automation/schedule.cjs +90 -3
  41. package/engine/automation/store.cjs +163 -59
  42. package/engine/bootstrap-schema.sql +4 -4
  43. package/engine/browser/cdp.cjs +315 -32
  44. package/engine/browser/vault.cjs +1 -0
  45. package/engine/cli-output.cjs +80 -28
  46. package/engine/cloud/auth.cjs +131 -13
  47. package/engine/cloud/hub-client.cjs +41 -16
  48. package/engine/cloud-assets/cas.cjs +18 -9
  49. package/engine/cloud-assets/commands.cjs +108 -30
  50. package/engine/cloud-assets/package.cjs +66 -3
  51. package/engine/cloud-assets/restore.cjs +12 -9
  52. package/engine/cloud-assets/state.cjs +308 -113
  53. package/engine/commands/acp.cjs +13 -2
  54. package/engine/commands/automation.cjs +75 -24
  55. package/engine/commands/billing.cjs +6 -1
  56. package/engine/commands/browser.cjs +67 -34
  57. package/engine/commands/build.cjs +42 -12
  58. package/engine/commands/call.cjs +1 -1
  59. package/engine/commands/cd.cjs +1 -1
  60. package/engine/commands/cloud.cjs +2 -1
  61. package/engine/commands/connect.cjs +38 -13
  62. package/engine/commands/creds.cjs +74 -29
  63. package/engine/commands/doctor.cjs +17 -1
  64. package/engine/commands/document.cjs +40 -12
  65. package/engine/commands/env.cjs +6 -1
  66. package/engine/commands/firm.cjs +42 -12
  67. package/engine/commands/graph.cjs +36 -32
  68. package/engine/commands/help.cjs +11 -1
  69. package/engine/commands/hep.cjs +1 -1
  70. package/engine/commands/import.cjs +5 -4
  71. package/engine/commands/index.cjs +1 -1
  72. package/engine/commands/install.cjs +6 -4
  73. package/engine/commands/list.cjs +56 -16
  74. package/engine/commands/login.cjs +5 -0
  75. package/engine/commands/logout.cjs +6 -1
  76. package/engine/commands/mcp.cjs +8 -2
  77. package/engine/commands/memory.cjs +3 -2
  78. package/engine/commands/multimodal.cjs +12 -0
  79. package/engine/commands/native.cjs +5 -1
  80. package/engine/commands/one.cjs +78 -33
  81. package/engine/commands/plugin.cjs +13 -2
  82. package/engine/commands/project.cjs +15 -12
  83. package/engine/commands/research.cjs +18 -7
  84. package/engine/commands/roles.cjs +104 -28
  85. package/engine/commands/route.cjs +2 -2
  86. package/engine/commands/run.cjs +42 -9
  87. package/engine/commands/search.cjs +23 -2
  88. package/engine/commands/setup.cjs +6 -1
  89. package/engine/commands/storm.cjs +44 -6
  90. package/engine/commands/swarm.cjs +47 -9
  91. package/engine/commands/telegram.cjs +6 -1
  92. package/engine/commands/uninstall.cjs +12 -6
  93. package/engine/commands/update.cjs +5 -0
  94. package/engine/commands/upload.cjs +2 -1
  95. package/engine/commands/usage.cjs +6 -1
  96. package/engine/commands/version.cjs +6 -1
  97. package/engine/commands/whoami.cjs +6 -1
  98. package/engine/commands/workforce.cjs +60 -16
  99. package/engine/core/capability-grants.cjs +7 -2
  100. package/engine/core/daemon-client.cjs +36 -7
  101. package/engine/core/db.cjs +10 -2
  102. package/engine/core/desktop-core-fetch.cjs +477 -77
  103. package/engine/core/schema-ensure.cjs +23 -11
  104. package/engine/experience/build.cjs +5 -1
  105. package/engine/experience/intents.cjs +29 -82
  106. package/engine/experience/runtime.cjs +7 -1
  107. package/engine/experience/variant.cjs +58 -4
  108. package/engine/firms/orchestrate.cjs +9 -6
  109. package/engine/graph/ask-model.cjs +22 -11
  110. package/engine/graph/interview.cjs +74 -23
  111. package/engine/graph/package.cjs +63 -8
  112. package/engine/hephaestus/local-core.cjs +18 -5
  113. package/engine/hephaestus/runtime.cjs +48 -43
  114. package/engine/hub/install.cjs +230 -61
  115. package/engine/hub/plugins.cjs +391 -29
  116. package/engine/mcp/consent.cjs +25 -17
  117. package/engine/mcp/contract.cjs +229 -24
  118. package/engine/mcp/inventory.cjs +15 -5
  119. package/engine/mcp/plan.cjs +5 -1
  120. package/engine/mcp/probe.cjs +24 -9
  121. package/engine/memory-cli/curate.cjs +74 -16
  122. package/engine/oberon/common.cjs +37 -12
  123. package/engine/oberon/manifest.cjs +25 -2
  124. package/engine/oberon/outputs.cjs +49 -19
  125. package/engine/oberon/render.cjs +665 -40
  126. package/engine/project/career-graph.cjs +15 -2
  127. package/engine/project/controller.cjs +133 -15
  128. package/engine/project/credentials.cjs +212 -34
  129. package/engine/project/env-file.cjs +93 -8
  130. package/engine/project/memory-context.cjs +11 -57
  131. package/engine/project/ontology.cjs +66 -25
  132. package/engine/project/paths.cjs +42 -8
  133. package/engine/project/seed.cjs +88 -51
  134. package/engine/project/state.cjs +41 -16
  135. package/engine/project/team.cjs +26 -1
  136. package/engine/runtime-refusal.cjs +4 -1
  137. package/engine/runtimes/acp-driver.cjs +56 -9
  138. package/engine/runtimes/auth-evidence.cjs +21 -2
  139. package/engine/runtimes/detect.cjs +28 -8
  140. package/engine/runtimes/kinds.cjs +12 -4
  141. package/engine/runtimes/overrides.cjs +4 -3
  142. package/engine/runtimes/resolve.cjs +8 -7
  143. package/engine/runtimes/roles.cjs +7 -5
  144. package/engine/sessions/apply-fences.cjs +396 -56
  145. package/engine/sessions/fences.cjs +111 -27
  146. package/engine/sessions/memory-turn.cjs +62 -3
  147. package/engine/sessions/orchestrator.cjs +11 -1
  148. package/engine/sessions/prompt.cjs +66 -9
  149. package/engine/sessions/session.cjs +70 -10
  150. package/engine/sessions/sink.cjs +78 -12
  151. package/engine/sessions/store.cjs +29 -7
  152. package/engine/storm/storm.cjs +26 -3
  153. package/engine/storm/swarm.cjs +31 -3
  154. package/engine/telegram/connect.cjs +187 -24
  155. package/engine/ui/commands-catalog.cjs +1 -1
  156. package/engine/ui/palette.cjs +12 -2
  157. package/engine/ui/repl.cjs +57 -9
  158. package/engine/ui/screens.cjs +2 -4
  159. package/engine/vendor/desktop-core.manifest.json +5 -5
  160. package/engine/workforce/capture.cjs +9 -27
  161. package/engine/workforce/deps.cjs +18 -6
  162. package/engine/workforce/local-core-transport.cjs +68 -5
  163. package/package.json +1 -1
  164. package/engine/commands/career-graph.cjs +0 -51
package/CHANGELOG.md CHANGED
@@ -1,5 +1,92 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.66 — 2026-08-30
4
+
5
+ - API 스트리밍 요청이 연결·본문 읽기 중 실패하거나 취소돼도 부모 취소 리스너와 idle 타이머를 즉시 정리해, 오래 실행한 Terminal 세션에 실패한 요청의 리스너가 누적되지 않게 했습니다.
6
+ - MCP 정책·동의·Experience 상태 JSON은 이제 no-follow 파일 디스크립터와 읽기 전후 identity 검증을 사용하며, private-state 잠금은 살아 있는 소유자를 시간만으로 탈취하거나 이전 소유자가 후속 잠금을 지울 수 없게 했습니다.
7
+ - `read` 권한의 Memory Event는 One 영구 원장에 기록되지 않으며, 쓰기 권한에서도 큐레이션을 통과한 제한된 후보만 안전한 원장 경로와 소유자 검증 잠금 안에서 중복 없이 전달됩니다.
8
+ - CLI 환경설정도 같은 bounded no-follow JSON 및 live-owner 잠금을 사용해, 오래 실행 중인 설정 저장을 시간만으로 탈취하거나 링크 경로에서 설정을 읽지 않습니다.
9
+ - Graph 인터뷰는 명시 또는 역할 기본값으로 선택한 Ollama를 이제 로컬 API 루프로 실제 실행하며, `bin`이 없다는 이유로 선택을 버리거나 다른 CLI로 바꾸지 않습니다.
10
+ - ACP client 연결이 닫히면 진행 중 child뿐 아니라 대화 유지를 위해 남아 있던 idle Session과 Orchestrator도 모두 종료해 편집기 재연결 뒤 좀비 런타임이 쌓이지 않게 했습니다.
11
+ - Workforce·Storm이 쓰는 Core JSON 캡처는 stdout을 16MiB로 제한하고 기본 120초 안에 끝나지 않는 Python 호출을 종료해, 손상된 Core 응답이 Terminal 메모리나 실행을 무기한 점유하지 않습니다.
12
+ - 자동화 on/off/run/remove의 ID 접두사는 `%`·`_`를 와일드카드로 해석하지 않고, 여러 자동화와 겹치면 임의 행을 조작하는 대신 전체 ID를 요구합니다.
13
+ - 직접 생성한 Operational RunReceipt도 반환된 실제 생성 시각을 fallback run identity에 사용해, `createdAt`을 생략한 호출이 모두 같은 `undefined` 기반 ID로 합쳐지지 않습니다.
14
+ - Desktop이 저장한 자동화의 `antigravity`/backend/source/model/effort 핀을 Terminal도 같은 실행 정체성으로 해석하고, 정확 핀 실패 시 Dashboard 역할 풀의 다른 공급자로 넘어가지 않습니다.
15
+ - Memory Event 큐레이션은 후보 수·본문·증거를 제한하고 단일 원자 SQL로 중복을 막으며, 기존 private 프로젝트 디렉터리의 regular 단일-link JSONL에만 bounded append합니다.
16
+ - Experience 적립은 누락·잘못된 권한을 read로 막고, 부분적으로만 전달된 exact agent binding을 설치본이나 로컬 해시 정체성으로 조용히 대체하지 않습니다.
17
+ - Provider resume ID 저장은 시작 시 읽은 ID를 compare-and-swap 조건으로 사용해, 같은 채팅의 늦게 끝난 이전 턴이 더 최신 세션 연속성을 덮어쓰지 않습니다.
18
+ - Hub 응답은 stream-readable 본문만 16MiB 안에서 읽고, CLI 세션 파일도 bounded no-follow identity 검증을 통과한 private JSON만 인증 증거로 사용합니다.
19
+ - Project 초기 시드는 실제 private `.agentlas` 디렉터리와 single-link regular 파일만 만들거나 재사용해, 미리 놓인 symlink/hardlink를 따라 외부 파일을 쓰지 않습니다.
20
+ - Project 팀 연결은 Desktop의 현재 agent/team 정본과 레거시 행을 명시적으로 구분하고, 정확 릴리스가 로컬에 증명된 컨트롤러만 최대 64개 bounded 팀에서 실행합니다.
21
+ - Cloud 설치·복구는 private 실제 설치 루트와 bounded single-link 저널만 신뢰하고, 심링크·하드링크·특수 엔트리로 바뀐 destination/staging/backup을 재귀 삭제하거나 교체하지 않습니다.
22
+ - Project 활성화는 canonical 루트와 현재 private-state 무결성을 매번 확인하고, Desktop의 `antigravity` 선택을 일반 Terminal 실행에서도 `agy`로 보존하며, 능력 승인 프리픽스는 토큰 경계를 넘어 비슷한 명령을 허용하지 않습니다.
23
+ - Workforce는 누락·손상 권한을 read로 막고 `--parallel`의 실제 8개 상한을 지키며, Dashboard 역할 풀에서 첫 가용 런타임을 우선순위대로 선택하고 빈 Worker 풀은 Orchestrator 풀을 상속합니다.
24
+ - 설치 에이전트 라우팅은 BYOK·Ollama 판정의 인증·한도·연결 오류를 빈 응답으로 지우지 않고, CLI 런타임과 같이 실제 실패 사유를 보존합니다.
25
+ - 명시값·환경설정의 Desktop `antigravity` 런타임도 Terminal `agy`로 일관되게 실행하며, 정상 답변의 기술 용어 `rate-limit`을 한도 거절로 오인하지 않습니다.
26
+ - Native 모델 stdout을 16MiB로 제한하고, 생성·재사용하는 MCP 설정은 실제 private 디렉터리와 bounded single-link 파일만 허용해 비정상 스트림·링크 경로를 따라가지 않습니다.
27
+ - node:sqlite fallback도 실제 `BEGIN IMMEDIATE`/rollback과 중첩 savepoint를 사용해, 예외가 난 다중 쓰기를 부분 커밋하지 않습니다.
28
+ - Runtime capability 변환은 Desktop `antigravity`를 `agy`로 보존하고 ACP CLI를 API backend로 오분류하거나 알 수 없는 runtime spec을 제조하지 않습니다.
29
+ - Local Core Workforce 어댑터는 search→validate→prepare의 WorkOrder·Selection을 정확히 묶고, 과도하게 깊거나 큰 응답 및 prototype key를 경계에서 거절합니다.
30
+ - 로컬 모델의 파일 도구는 8MiB를 넘는 텍스트를 전체 메모리에 읽거나 쓰지 않으며, Terminal 설정 화면은 이미 제공되는 멀티모달 설정 명령을 Desktop 전용으로 잘못 안내하지 않습니다.
31
+ - 희소 cron 탐색은 긴 분 단위 루프 대신 로컬 달력의 불일치 날짜를 건너뛰며 8년 범위를 확인해, 비윤년 직후 등록한 2월 29일 일정도 다음 윤년 실행 시각을 잃지 않습니다.
32
+ - 저장 권한이 없을 때의 제품 기본값과 손상된 권한 값을 구분해 후자는 read로 막고, CLI 런타임 탐지는 PATH의 `which` 프로그램을 실행하지 않고 실제 실행 파일을 직접 확인합니다.
33
+ - Hephaestus 캡처는 시간·출력 상한이나 사용자 중단 뒤 응답 없는 child를 강제 종료하고 부분 JSON을 결과처럼 렌더링하지 않으며, bounded no-follow CLI history와 그 잠금은 살아 있는 소유자를 시간만으로 탈취하거나 이전 소유자가 후속 잠금을 지우지 않습니다.
34
+ - Graph 패키지는 중첩 비밀값까지 재귀 치환하고 그래프·매니페스트 지문을 모두 검증하며, 같은 MCP 서버를 쓰는 모든 노드를 의존성에 보존합니다.
35
+ - 모델 카탈로그와 라우팅 영수증은 bounded no-follow 단일-link 파일로만 읽고 쓰며, `route`·`call`·`hep` 요청 안의 일반 단어 `help`를 명령 도움말로 가로채지 않습니다.
36
+ - Desktop Core 캐시는 live-owner 버전 잠금과 streaming 크기·해시 검증을 사용하고, 경로 이탈·링크·특수 엔트리가 든 archive를 풀기 전에 거절합니다.
37
+
38
+ - CLI output and argument contracts now fail closed across list, run, build,
39
+ One, roles, variants, graphs, firms, search, automations, credentials, MCP,
40
+ Experience, Ontology, Storm, Swarm, Workforce, Cloud/upload, doctor,
41
+ install/update/import/uninstall, login/logout/setup, project/billing,
42
+ ACP/multimodal/memory, browser/document/plugin, environment/account, and
43
+ connection commands instead
44
+ of silently accepting unknown or contradictory input. Invalid commands stop
45
+ before model, network, project, or credential mutations. JSON, YAML, quiet,
46
+ no-header, and no-color modes now match their documented behavior.
47
+ - Help now rejects undocumented flags and trailing arguments, and the compact
48
+ startup banner stays within the actual terminal width on narrow screens.
49
+ - Research now exposes the complete Agentlas OS 1.2.37 diagnostic, proof,
50
+ browser-hardpoint, module, loadout, planning, read, search, and gather command
51
+ set instead of rejecting every subcommand outside the legacy five.
52
+ - Async native help now waits for its child output, and the Local Core MCP
53
+ transport bounds unterminated stdout responses instead of growing memory
54
+ without limit.
55
+ - MCP connection probes now refuse malformed runtime arguments and handle
56
+ asynchronous child-stdin failures as a failed preflight instead of risking
57
+ an unhandled stream error.
58
+ - REPL shell commands now participate in shutdown tracking, stop on Ctrl-C or
59
+ quit, escalate stubborn process groups to SIGKILL, and cannot survive the
60
+ parent process as detached zombies.
61
+ - Persistent model-role changes now cover multimodal, keep Dashboard pools and
62
+ the resolved selection in sync, and bind resumed provider sessions to the
63
+ selected model (plus ACP permission identity where the protocol requires it).
64
+ An explicit stop no longer launches the next fallback model, while a genuine
65
+ ACP recovery receives its own current fingerprint and cancellation signal.
66
+ - Project credential/env files, Telegram token files, MCP consent/inventory,
67
+ child-process shutdown, session event sinks, and generated output paths now
68
+ enforce bounded, no-follow, project-scoped storage and cleanup contracts.
69
+ Native context preparation also refuses broad, missing, or symbolic-link
70
+ agent source folders instead of recreating or writing through them.
71
+ - Cloud packaging, agent evolution, route persistence, Storm/Swarm fan-out, and
72
+ Oberon rendering now reject ambiguous or stale state rather than reporting a
73
+ partial operation as complete.
74
+ - Concurrent Cloud list/save/delete/bind and source-marker updates now preserve
75
+ every observed revision under private locks, reject corrupted tombstones, and
76
+ compare receipt times chronologically instead of as text.
77
+ - Terminal palettes no longer advertise the retired `career-graph` command. Its
78
+ project data remains preserved, while users are directed to `ontology` for
79
+ sources and the Agentlas OS runtime for derived indexing.
80
+
81
+ ## 1.0.65 — 2026-08-29
82
+
83
+ - Browser, document, and Telegram CDP flows now create and track an Agentlas-owned
84
+ tab instead of attaching to the first page exposed by the debugging port. Existing
85
+ pages require explicit target selection, owned temporary pages are cleaned up, and
86
+ WebSocket attachment has a bounded open timeout.
87
+ - First-run SQLite bootstrap now starts at schema `user_version=106`, generated from
88
+ the Desktop migration ladder, including the multimodal model-role constraint.
89
+
3
90
  ## 1.0.64 — 2026-08-29
4
91
 
5
92
  - First-run database bootstrap now refuses symbolic links and non-file entries
package/README.md CHANGED
@@ -79,7 +79,7 @@ agentlas cd <agent> # Print folder path of an agent (cd "$(agentlas
79
79
  agentlas search "<query>" # Search Hub agents (no login required)
80
80
  agentlas install <slug> # Install Hub agent locally (gated by security trust checks)
81
81
  agentlas plugin add <slug> # Add Hub plugin (MCP server)
82
- agentlas plugin list # List active plugins (= agentlas plugins)
82
+ agentlas plugin list # List active plugins
83
83
  agentlas build "<prompt>" # Build, repair, and package agents or multi-agent teams
84
84
  agentlas upload <path> # Upload package to private Agent Cloud (default)
85
85
  agentlas upload <path> --visibility marketplace # Explicitly publish to public Agentlas Hub
@@ -104,7 +104,7 @@ agentlas hep-local "<prompt>" # Same staffing, registered Local agents only
104
104
  agentlas hep-cloud "<prompt>" # Same staffing, owner Agent Cloud only
105
105
  agentlas hep-hub "<prompt>" # Same staffing, public Hub only
106
106
  agentlas call "a,b" "<context>" # Call explicitly named Hub or Cloud agents
107
- agentlas browser [...] # Real browser hardpoint launcher
107
+ agentlas browser [...] # Real browser hardpoint launcher (opens an owned tab)
108
108
  agentlas route "<prompt>" [--json] # Preview routing decisions without execution
109
109
  agentlas research <sub> # Research loadout (status|gather|search|read|plan)
110
110
  ```
@@ -114,8 +114,10 @@ agentlas research <sub> # Research loadout (status|gather|search|read|p
114
114
  agentlas memory import <path> --agent <id> [--apply]
115
115
  agentlas evolve [list|apply <id>|revert <id>]
116
116
  agentlas ontology <sub> # Manage project ontology (status|list|add)
117
- agentlas career-graph <sub> # Ingest, query, verify, and trace career graph indices
118
- agentlas journal <sub> # Run journal operations (status|verify|repair|gate)
117
+ # Derived career-graph indexing is owned by the installed Hephaestus runtime;
118
+ # use `agentlas ontology` for source registration.
119
+ # Run-journal diagnosis is an expert Hephaestus path:
120
+ # agentlas hep stormbreaker journal --run-id <id> --journal <path>
119
121
  agentlas project [status|init] # Explicit entry point to initialize `.agentlas/` context
120
122
  agentlas context <sub> # Context slice operations (refresh|locate|refs|slice|impact|verify)
121
123
  ```
@@ -216,7 +218,7 @@ Runtime Search Order (`engine/agentlas-core-harness.cjs`, `engine/hephaestus/run
216
218
  2. `~/.agentlas/runtime/current`
217
219
  3. Packaged Core (`<resources>/Hephaestus`, macOS: `/Applications/Agentlas.app/Contents/Resources/Hephaestus`)
218
220
 
219
- Commands including `storm`, `swarm`, `workforce`/`network`, `route`, `research`, `context`, `career-graph`, `journal`, `netadmin`, `hep`, `build`, `call`, `browser`, and `connect` pass directly through to this core runtime. If no core runtime is found, Agentlas Terminal halts with an explicit error and exit code 1 (no silent fallback).
221
+ Commands including `storm`, `swarm`, `workforce`/`network`, `route`, `research`, `context`, `netadmin`, `hep`, `build`, `call`, `browser`, and `connect` pass directly through to this core runtime. Derived career-graph indexing and run-journal diagnosis are also owned by the installed Hephaestus runtime; Terminal keeps existing project data intact but no longer exposes separate `agentlas career-graph` or `agentlas journal` commands. If no core runtime is found, Agentlas Terminal halts with an explicit error and exit code 1 (no silent fallback).
220
222
 
221
223
  The terminal package owns REPL orchestration, session trees, agent registries, Hub/Cloud HTTP client surfaces, credential storage, MCP preflight probing, automation scheduling, and SQLite schema management.
222
224
 
@@ -230,7 +232,7 @@ The launcher (`bin/agentlas.cjs`) runs system Node against `engine/`. The defaul
230
232
  | Windows | `%APPDATA%\Agentlas` |
231
233
  | Linux | `$XDG_CONFIG_HOME/Agentlas` (default `~/.config/Agentlas`) |
232
234
 
233
- The SQLite database file is `agentlas.sqlite` (`user_version=97`). When launched for the first time without an existing database, it bootstraps schemas using `engine/bootstrap-schema.sql`. Consequently, **projects, installed agents, task history, automation sessions, and MCP registrations are shared across Desktop and Terminal**. The first ordered project agent remains the controller; additional agents are task-scoped and are stored only as execution ledgers, never as global conversations or durable owners.
235
+ The SQLite database file is `agentlas.sqlite` (`user_version=106`). When launched for the first time without an existing database, it bootstraps schemas using `engine/bootstrap-schema.sql`. Consequently, **projects, installed agents, task history, automation sessions, and MCP registrations are shared across Desktop and Terminal**. The first ordered project agent remains the controller; additional agents are task-scoped and are stored only as execution ledgers, never as global conversations or durable owners.
234
236
 
235
237
  **Single migration authority.** The Desktop app owns the schema migration ladder; the CLI never migrates the shared database. `engine/bootstrap-schema.sql` is generated by running that ladder to completion against an empty database, so a CLI-created store already sits at the ladder head and has nothing left for Desktop to upgrade. If the CLI opens a store older than the version it knows, it refuses with an actionable message instead of migrating or silently proceeding — a second migrator on this lock-free file is what corrupted the store once before. On a machine with no Desktop app, an operator can set `AGENTLAS_STORE_MIGRATION_ROLE=owner` for a single deliberate upgrade run with every other Agentlas process closed.
236
238
 
package/bin/agentlas.cjs CHANGED
@@ -36,8 +36,14 @@ for (const stream of [process.stdout, process.stderr]) {
36
36
  const fs = require("node:fs");
37
37
  const os = require("node:os");
38
38
  const path = require("node:path");
39
+ const crypto = require("node:crypto");
39
40
  const { spawn } = require("node:child_process");
40
41
  const { configureSqliteConnection } = require("../engine/agentlas-sqlite-policy.cjs");
42
+ const { parseOutputFlags, renderError } = require("../engine/cli-output.cjs");
43
+ const { resolveCommandName, SELF_HELP_COMMANDS } = require("../engine/commands/index.cjs");
44
+
45
+ const SQLITE_HEADER = Buffer.from("SQLite format 3\0", "ascii");
46
+ const SQLITE_MIN_BYTES = 512;
41
47
 
42
48
  const REAL_SELF = (() => {
43
49
  try { return fs.realpathSync(__filename); } catch { return __filename; }
@@ -63,11 +69,61 @@ function dbPath() {
63
69
  return path.join(userDataDir(), "agentlas.sqlite");
64
70
  }
65
71
 
72
+ // Keep the launcher's no-database path identical to the engine's CLI grammar.
73
+ // A plain word "help" is valid task content (`agentlas run -p help`); only the
74
+ // actual help/version command forms may skip first-run database bootstrap.
75
+ function isMetadataOnlyInvocation(argv) {
76
+ const args = Array.isArray(argv) ? argv : [];
77
+ const helpRequested = args.some((arg) => arg === "--help" || arg === "-h");
78
+ const helpCommand = args.find((arg) => arg !== "--help" && arg !== "-h" && !String(arg).startsWith("-"));
79
+ if (helpRequested && helpCommand) return true;
80
+
81
+ const normalized = args.map((arg) => {
82
+ if (arg === "--help" || arg === "-h") return "help";
83
+ if (arg === "--version" || arg === "-V") return "version";
84
+ return arg;
85
+ });
86
+ const { rest } = parseOutputFlags(normalized);
87
+ const [rawCommand, ...commandArgs] = rest;
88
+ if (rawCommand === "help" || rawCommand === "version") return true;
89
+ return SELF_HELP_COMMANDS.has(resolveCommandName(rawCommand)) && commandArgs[0] === "help";
90
+ }
91
+
66
92
  function securePrivateMode(target, mode) {
67
93
  if (process.platform === "win32") return;
68
94
  fs.chmodSync(target, mode);
69
95
  }
70
96
 
97
+ function databaseStatsMatch(expected, actual) {
98
+ return Boolean(expected && actual) &&
99
+ expected.isFile() && actual.isFile() &&
100
+ !expected.isSymbolicLink() && !actual.isSymbolicLink() &&
101
+ expected.dev === actual.dev &&
102
+ expected.ino === actual.ino &&
103
+ expected.nlink === actual.nlink &&
104
+ expected.size === actual.size &&
105
+ expected.mtimeMs === actual.mtimeMs &&
106
+ expected.ctimeMs === actual.ctimeMs;
107
+ }
108
+
109
+ function databaseContentMatch(expected, actual) {
110
+ return Boolean(expected && actual) &&
111
+ expected.isFile() && actual.isFile() &&
112
+ !expected.isSymbolicLink() && !actual.isSymbolicLink() &&
113
+ expected.dev === actual.dev &&
114
+ expected.ino === actual.ino &&
115
+ expected.nlink === actual.nlink &&
116
+ expected.size === actual.size &&
117
+ expected.mtimeMs === actual.mtimeMs;
118
+ }
119
+
120
+ function directoryIdentityMatch(expected, actual) {
121
+ return Boolean(expected && actual) &&
122
+ expected.isDirectory() && actual.isDirectory() &&
123
+ !expected.isSymbolicLink() && !actual.isSymbolicLink() &&
124
+ expected.dev === actual.dev && expected.ino === actual.ino;
125
+ }
126
+
71
127
  function databaseFileStat(target) {
72
128
  let stat;
73
129
  try {
@@ -76,12 +132,206 @@ function databaseFileStat(target) {
76
132
  if (error && error.code === "ENOENT") return null;
77
133
  throw error;
78
134
  }
79
- if (stat.isSymbolicLink() || !stat.isFile()) {
80
- throw new Error(`Agentlas database path must be a regular file: ${target}`);
135
+ if (stat.isSymbolicLink() || !stat.isFile() || stat.nlink !== 1) {
136
+ const error = new Error(`Agentlas database path must be a regular file: ${target}`);
137
+ if (stat.nlink !== 1) error.code = "AGENTLAS_DATABASE_MULTILINK";
138
+ throw error;
81
139
  }
82
140
  return stat;
83
141
  }
84
142
 
143
+ function quarantinePath(target) {
144
+ return `${target}.bootstrap-quarantine-${process.pid}-${crypto.randomUUID()}`;
145
+ }
146
+
147
+ function bootstrapRaceError(message) {
148
+ const error = new Error(message);
149
+ error.code = "AGENTLAS_BOOTSTRAP_CONCURRENT_RETRY";
150
+ return error;
151
+ }
152
+
153
+ function activeBootstrapOwnerExists(target) {
154
+ const dir = path.dirname(target);
155
+ const prefix = `${path.basename(target)}.bootstrap-quarantine-`;
156
+ let entries;
157
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return false; }
158
+ for (const entry of entries) {
159
+ if (!entry || !entry.isFile() || !entry.name.startsWith(prefix)) continue;
160
+ const match = entry.name.slice(prefix.length).match(/^(\d+)-[0-9a-f-]+$/i);
161
+ if (!match) continue;
162
+ const ownerPid = Number(match[1]);
163
+ if (!Number.isSafeInteger(ownerPid) || ownerPid <= 0 || ownerPid === process.pid) continue;
164
+ try {
165
+ process.kill(ownerPid, 0);
166
+ return true;
167
+ } catch (error) {
168
+ if (error && error.code === "EPERM") return true;
169
+ }
170
+ }
171
+ return false;
172
+ }
173
+
174
+ function activeBootstrapBuildExists(target) {
175
+ const dir = path.dirname(target);
176
+ const prefix = `${path.basename(target)}.bootstrap-`;
177
+ let entries;
178
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return false; }
179
+ for (const entry of entries) {
180
+ if (!entry || !entry.isFile() || !entry.name.startsWith(prefix)) continue;
181
+ const match = entry.name.slice(prefix.length).match(/^(\d+)-/);
182
+ if (!match) continue;
183
+ const ownerPid = Number(match[1]);
184
+ if (!Number.isSafeInteger(ownerPid) || ownerPid <= 0 || ownerPid === process.pid) continue;
185
+ try {
186
+ process.kill(ownerPid, 0);
187
+ return true;
188
+ } catch (error) {
189
+ if (error && error.code === "EPERM") return true;
190
+ }
191
+ }
192
+ return false;
193
+ }
194
+
195
+ function waitForBootstrapOwner() {
196
+ try {
197
+ const waitCell = new Int32Array(new SharedArrayBuffer(4));
198
+ Atomics.wait(waitCell, 0, 0, 25);
199
+ } catch { /* runtimes without Atomics.wait continue with the next check */ }
200
+ }
201
+
202
+ function stableSqliteWinner(target, before) {
203
+ let fd;
204
+ try {
205
+ if (!before || before.size < SQLITE_MIN_BYTES) return false;
206
+ fd = fs.openSync(target, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
207
+ const opened = fs.fstatSync(fd);
208
+ if (!databaseStatsMatch(before, opened)) return false;
209
+ const header = Buffer.alloc(SQLITE_HEADER.length);
210
+ if (fs.readSync(fd, header, 0, header.length, 0) !== header.length || !header.equals(SQLITE_HEADER)) return false;
211
+ const afterRead = fs.fstatSync(fd);
212
+ const afterPath = databaseFileStat(target);
213
+ return databaseStatsMatch(opened, afterRead) && databaseStatsMatch(afterRead, afterPath);
214
+ } catch {
215
+ return false;
216
+ } finally {
217
+ if (fd !== undefined) {
218
+ try { fs.closeSync(fd); } catch { /* best effort */ }
219
+ }
220
+ }
221
+ }
222
+
223
+ function quarantineEmptyDatabaseIfUnchanged(target, expected) {
224
+ const dir = path.dirname(target);
225
+ const noFollow = fs.constants.O_NOFOLLOW || 0;
226
+ const directoryFlags = fs.constants.O_RDONLY | (fs.constants.O_DIRECTORY || 0) | noFollow;
227
+ let fd;
228
+ let directoryFd;
229
+ let quarantine;
230
+ try {
231
+ // Re-lstat immediately before opening the descriptor. The original lstat
232
+ // alone is stale as soon as another process can replace the pathname.
233
+ const current = databaseFileStat(target);
234
+ if (!current) throw bootstrapRaceError("database disappeared while another bootstrap was quarantining it");
235
+ if (!databaseStatsMatch(expected, current)) {
236
+ if (current.size > 0) throw bootstrapRaceError("database was replaced while another bootstrap was quarantining it");
237
+ throw new Error("database changed before empty-file removal");
238
+ }
239
+
240
+ const listedDirectory = fs.lstatSync(dir);
241
+ if (!listedDirectory.isDirectory() || listedDirectory.isSymbolicLink()) {
242
+ throw new Error("database parent directory is unsafe");
243
+ }
244
+ try {
245
+ directoryFd = fs.openSync(dir, directoryFlags);
246
+ const openedDirectory = fs.fstatSync(directoryFd);
247
+ if (!directoryIdentityMatch(listedDirectory, openedDirectory)) {
248
+ throw new Error("database parent directory changed before empty-file removal");
249
+ }
250
+ } catch (error) {
251
+ // Windows does not permit opening a directory with the same descriptor
252
+ // flags. The file descriptor identity checks below remain mandatory.
253
+ if (process.platform !== "win32" || !["EISDIR", "EINVAL", "ENOTSUP", "EPERM"].includes(error && error.code)) {
254
+ throw error;
255
+ }
256
+ }
257
+
258
+ // O_NOFOLLOW prevents a swapped symlink from being opened on POSIX. The
259
+ // fstat fields are compared to both lstat observations before quarantine.
260
+ try {
261
+ fd = fs.openSync(target, fs.constants.O_RDONLY | noFollow);
262
+ } catch (error) {
263
+ if (error && error.code === "ENOENT") throw bootstrapRaceError("database disappeared while opening empty file");
264
+ throw error;
265
+ }
266
+ const opened = fs.fstatSync(fd);
267
+ if (!databaseStatsMatch(expected, opened)) {
268
+ if (opened.size > 0) throw bootstrapRaceError("database was replaced while opening empty file");
269
+ throw new Error("database changed while opening empty file");
270
+ }
271
+ const beforeQuarantine = databaseFileStat(target);
272
+ if (!beforeQuarantine) throw bootstrapRaceError("database disappeared immediately before quarantine");
273
+ if (!databaseStatsMatch(expected, beforeQuarantine) || !databaseStatsMatch(opened, beforeQuarantine)) {
274
+ if (beforeQuarantine.size > 0) throw bootstrapRaceError("database was replaced immediately before quarantine");
275
+ throw new Error("database changed immediately before empty-file removal");
276
+ }
277
+ const currentDirectory = fs.lstatSync(dir);
278
+ if (!directoryIdentityMatch(listedDirectory, currentDirectory)) {
279
+ throw new Error("database parent directory changed immediately before empty-file removal");
280
+ }
281
+
282
+ // Never unlink the pathname after a check: a concurrent replacement could
283
+ // make that path name a different file. Move the checked candidate to a
284
+ // same-directory, recoverable quarantine name instead. A UUID plus an
285
+ // O_EXCL reservation makes accidental destination collisions negligible;
286
+ // the moved inode is verified again before bootstrap can continue.
287
+ for (let attempt = 0; attempt < 8; attempt += 1) {
288
+ const candidate = quarantinePath(target);
289
+ try {
290
+ const reservation = fs.openSync(candidate, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY, 0o600);
291
+ fs.closeSync(reservation);
292
+ quarantine = candidate;
293
+ break;
294
+ } catch (error) {
295
+ if (!error || error.code !== "EEXIST") throw error;
296
+ }
297
+ }
298
+ if (!quarantine) throw new Error("could not allocate a unique database quarantine path");
299
+ try {
300
+ fs.renameSync(target, quarantine);
301
+ } catch (error) {
302
+ if (error && error.code === "ENOENT") throw bootstrapRaceError("database disappeared while quarantining empty file");
303
+ throw error;
304
+ }
305
+
306
+ // rename updates ctime on POSIX, so compare the pre-rename inode/content
307
+ // fields against the post-rename descriptor and verify the post-rename
308
+ // ctime/other stat fields against a fresh pathname observation.
309
+ const afterQuarantine = fs.fstatSync(fd);
310
+ const quarantined = databaseFileStat(quarantine);
311
+ if (!databaseContentMatch(expected, afterQuarantine) || !databaseStatsMatch(afterQuarantine, quarantined)) {
312
+ throw new Error(`database changed while quarantining empty file; preserved at ${quarantine}`);
313
+ }
314
+ const afterDirectory = fs.lstatSync(dir);
315
+ if (!directoryIdentityMatch(listedDirectory, afterDirectory)) {
316
+ throw new Error(`database parent directory changed while quarantining empty file; preserved at ${quarantine}`);
317
+ }
318
+ let targetAfter;
319
+ try { targetAfter = fs.lstatSync(target); } catch (error) {
320
+ if (!error || error.code !== "ENOENT") throw error;
321
+ }
322
+ if (targetAfter) {
323
+ throw new Error(`database path was replaced while quarantining empty file; preserved at ${quarantine}`);
324
+ }
325
+ } finally {
326
+ if (fd !== undefined) {
327
+ try { fs.closeSync(fd); } catch { /* best effort */ }
328
+ }
329
+ if (directoryFd !== undefined) {
330
+ try { fs.closeSync(directoryFd); } catch { /* best effort */ }
331
+ }
332
+ }
333
+ }
334
+
85
335
  // ── SQLite 로더 (부트스트랩용): better-sqlite3 → node:sqlite ──
86
336
  // require.resolve가 아니라 실제 로드로 판별한다 — ABI가 깨진 better-sqlite3나
87
337
  // node:sqlite가 아직 없는/플래그가 필요한 Node(≤22.4 등)를 정확히 걸러낸다.
@@ -155,18 +405,72 @@ function bootstrapDbIfMissing() {
155
405
  // 잡힌 파일이므로 없는 것으로 취급해 정상 부트스트랩 경로를 태운다. 내용이 있는데
156
406
  // 손상된 경우는 여기서 판단하지 않는다 — 그건 복구지 부트스트랩이 아니고, 멀쩡한
157
407
  // DB 를 빈 것으로 오판해 덮어쓰는 위험이 훨씬 크다.
158
- const existing = databaseFileStat(p);
159
- if (existing) {
408
+ let waitingForConcurrentBootstrap = false;
409
+ let emptyCandidateResolved = false;
410
+ for (let attempt = 0; attempt < 40; attempt += 1) {
411
+ let existing;
412
+ try {
413
+ existing = databaseFileStat(p);
414
+ } catch (error) {
415
+ // A winning bootstrap briefly has two hard links (its temp inode and the
416
+ // final name). Treat only that observable window as retryable when the
417
+ // owner process is still alive; a user-created hard link remains a hard
418
+ // failure.
419
+ if (error && error.code === "AGENTLAS_DATABASE_MULTILINK" && activeBootstrapBuildExists(p)) {
420
+ waitingForConcurrentBootstrap = true;
421
+ waitForBootstrapOwner();
422
+ continue;
423
+ }
424
+ throw error;
425
+ }
426
+ if (!existing) {
427
+ if (!waitingForConcurrentBootstrap) {
428
+ emptyCandidateResolved = true;
429
+ break;
430
+ }
431
+ if (!activeBootstrapOwnerExists(p)) {
432
+ throw new Error("Empty Agentlas database could not be replaced: concurrent bootstrap owner disappeared before publishing a database");
433
+ }
434
+ waitForBootstrapOwner();
435
+ continue;
436
+ }
160
437
  if (existing.size > 0) {
161
438
  securePrivateMode(p, 0o600);
162
439
  return { created: false, path: p };
163
440
  }
164
441
  try {
165
- fs.rmSync(p);
442
+ quarantineEmptyDatabaseIfUnchanged(p, existing);
443
+ emptyCandidateResolved = true;
444
+ break;
166
445
  } catch (error) {
446
+ if (error && error.code === "AGENTLAS_BOOTSTRAP_CONCURRENT_RETRY") {
447
+ let winner;
448
+ try {
449
+ winner = databaseFileStat(p);
450
+ } catch (winnerError) {
451
+ if (!(winnerError && winnerError.code === "AGENTLAS_DATABASE_MULTILINK" && activeBootstrapBuildExists(p))) {
452
+ throw new Error(`Empty Agentlas database could not be replaced: ${winnerError && winnerError.message ? winnerError.message : winnerError}`);
453
+ }
454
+ waitingForConcurrentBootstrap = true;
455
+ waitForBootstrapOwner();
456
+ continue;
457
+ }
458
+ if (winner && winner.size >= SQLITE_MIN_BYTES && stableSqliteWinner(p, winner)) {
459
+ securePrivateMode(p, 0o600);
460
+ return { created: false, path: p };
461
+ }
462
+ if (activeBootstrapOwnerExists(p)) {
463
+ waitingForConcurrentBootstrap = true;
464
+ waitForBootstrapOwner();
465
+ continue;
466
+ }
467
+ }
167
468
  throw new Error(`Empty Agentlas database could not be replaced: ${error && error.message ? error.message : error}`);
168
469
  }
169
470
  }
471
+ if (!emptyCandidateResolved) {
472
+ throw new Error("Empty Agentlas database could not be replaced: concurrent bootstrap did not publish a database");
473
+ }
170
474
  const schemaFile = path.join(PKG_ROOT, "engine", "bootstrap-schema.sql");
171
475
  if (!exists(schemaFile)) {
172
476
  throw new Error(`Bootstrap schema not found: ${schemaFile}`);
@@ -209,9 +513,32 @@ function bootstrapDbIfMissing() {
209
513
  // EEXIST only means another path entry won the name. It is a successful
210
514
  // concurrent bootstrap only if that winner is already a real, non-empty
211
515
  // database file; a symlink/directory/empty placeholder must fail closed.
212
- const winner = databaseFileStat(p);
213
- if (!winner || winner.size === 0) {
214
- throw new Error(`Concurrent Agentlas database bootstrap produced an invalid file: ${p}`);
516
+ let winnerAccepted = false;
517
+ for (let attempt = 0; attempt < 40; attempt += 1) {
518
+ let winner;
519
+ try {
520
+ winner = databaseFileStat(p);
521
+ } catch (error) {
522
+ if (error && error.code === "AGENTLAS_DATABASE_MULTILINK" && activeBootstrapBuildExists(p)) {
523
+ waitForBootstrapOwner();
524
+ continue;
525
+ }
526
+ throw error;
527
+ }
528
+ if (stableSqliteWinner(p, winner)) {
529
+ winnerAccepted = true;
530
+ break;
531
+ }
532
+ if (winner.size < SQLITE_MIN_BYTES) {
533
+ throw new Error(`Concurrent Agentlas database bootstrap produced an invalid or changed file: ${p}`);
534
+ }
535
+ // The winning process can unlink its temporary hard link between the
536
+ // winner stat and the descriptor's final stat. Retry the bounded identity
537
+ // check once the link count settles; never accept an unstable winner.
538
+ waitForBootstrapOwner();
539
+ }
540
+ if (!winnerAccepted) {
541
+ throw new Error(`Concurrent Agentlas database bootstrap did not publish a stable database: ${p}`);
215
542
  }
216
543
  } finally {
217
544
  try { fs.rmSync(temp, { force: true }); } catch { /* noop */ }
@@ -229,10 +556,8 @@ function bootstrapDbIfMissing() {
229
556
 
230
557
  function main() {
231
558
  const args = process.argv.slice(2);
232
- const metadataOnly = args.some((arg) => arg === "help" || arg === "--help" || arg === "-h")
233
- || args[0] === "version"
234
- || args[0] === "--version"
235
- || args[0] === "-V";
559
+ const { options: outputOptions } = parseOutputFlags(args);
560
+ const metadataOnly = isMetadataOnlyInvocation(args);
236
561
  const sqliteDriver = probeSqliteDriver();
237
562
  const engineFound = exists(ENGINE);
238
563
 
@@ -259,18 +584,18 @@ function main() {
259
584
  }
260
585
 
261
586
  if (error) {
262
- process.stderr.write(error + "\n");
587
+ process.stderr.write(renderError(new Error(error), outputOptions) + "\n");
263
588
  process.exit(1);
264
589
  }
265
590
 
266
591
  if (!metadataOnly) {
267
592
  try {
268
593
  const boot = bootstrapDbIfMissing();
269
- if (boot.created) {
594
+ if (boot.created && outputOptions.format === "table" && !outputOptions.quiet) {
270
595
  process.stderr.write("First run: Agentlas data initialized.\n");
271
596
  }
272
597
  } catch (e) {
273
- process.stderr.write(`${e.message}\n`);
598
+ process.stderr.write(renderError(e, outputOptions) + "\n");
274
599
  process.exit(1);
275
600
  }
276
601
  }
@@ -310,4 +635,4 @@ function main() {
310
635
 
311
636
  if (require.main === module) main();
312
637
 
313
- module.exports = { bootstrapDbIfMissing, dbPath, userDataDir, probeSqliteDriver };
638
+ module.exports = { bootstrapDbIfMissing, dbPath, userDataDir, probeSqliteDriver, isMetadataOnlyInvocation };
@@ -131,6 +131,21 @@ function productionTurnRunner(options = {}) {
131
131
  const s = sessions.get(acpSessionId);
132
132
  if (s) { try { s.kill(); } catch { /* best effort */ } }
133
133
  },
134
+ closeSession(spec) {
135
+ if (!spec) return;
136
+ const session = spec.session;
137
+ if (session) {
138
+ try { session.kill(); } catch { /* best effort */ }
139
+ }
140
+ if (spec.acpSessionId && sessions.get(spec.acpSessionId) === session) {
141
+ sessions.delete(spec.acpSessionId);
142
+ }
143
+ if (spec.orch && typeof spec.orch.shutdown === "function") {
144
+ try { spec.orch.shutdown(); } catch { /* best effort */ }
145
+ }
146
+ spec.session = null;
147
+ spec.orch = null;
148
+ },
134
149
  };
135
150
  }
136
151
 
@@ -168,7 +183,18 @@ class AcpAgentServer {
168
183
  if (!msg || typeof msg !== "object" || !msg.method) return;
169
184
  void this.handle(msg);
170
185
  });
171
- rl.on("close", () => { this.closed = true; });
186
+ rl.on("close", () => {
187
+ this.closed = true;
188
+ // stdin을 닫은 ACP client는 이 서버의 모든 session 소유권을 내려놓았다.
189
+ // idle conversation의 Orchestrator도, 진행 중 native child도 남기지 않는다.
190
+ for (const spec of this.sessions.values()) {
191
+ try {
192
+ if (typeof this.runner.closeSession === "function") this.runner.closeSession(spec);
193
+ else this.runner.cancel(spec.acpSessionId);
194
+ } catch { /* connection close must still settle */ }
195
+ }
196
+ this.sessions.clear();
197
+ });
172
198
  return new Promise((resolve) => rl.on("close", resolve));
173
199
  }
174
200