@felan-ai/daemon 0.0.0-stage → 0.0.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 (288) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +69 -0
  3. package/README.md +61 -2
  4. package/dist/client.d.ts +5 -0
  5. package/dist/client.d.ts.map +1 -0
  6. package/dist/client.js +4 -0
  7. package/dist/client.js.map +1 -0
  8. package/dist/daemon/admission.d.ts +39 -0
  9. package/dist/daemon/admission.d.ts.map +1 -0
  10. package/dist/daemon/admission.js +175 -0
  11. package/dist/daemon/admission.js.map +1 -0
  12. package/dist/daemon/agent-logs.d.ts +11 -0
  13. package/dist/daemon/agent-logs.d.ts.map +1 -0
  14. package/dist/daemon/agent-logs.js +31 -0
  15. package/dist/daemon/agent-logs.js.map +1 -0
  16. package/dist/daemon/bootstrap.d.ts +7 -0
  17. package/dist/daemon/bootstrap.d.ts.map +1 -0
  18. package/dist/daemon/bootstrap.js +4 -0
  19. package/dist/daemon/bootstrap.js.map +1 -0
  20. package/dist/daemon/broker.d.ts +94 -0
  21. package/dist/daemon/broker.d.ts.map +1 -0
  22. package/dist/daemon/broker.js +374 -0
  23. package/dist/daemon/broker.js.map +1 -0
  24. package/dist/daemon/commands.d.ts +47 -0
  25. package/dist/daemon/commands.d.ts.map +1 -0
  26. package/dist/daemon/commands.js +639 -0
  27. package/dist/daemon/commands.js.map +1 -0
  28. package/dist/daemon/control-recovery.d.ts +9 -0
  29. package/dist/daemon/control-recovery.d.ts.map +1 -0
  30. package/dist/daemon/control-recovery.js +68 -0
  31. package/dist/daemon/control-recovery.js.map +1 -0
  32. package/dist/daemon/control-state.d.ts +1915 -0
  33. package/dist/daemon/control-state.d.ts.map +1 -0
  34. package/dist/daemon/control-state.js +154 -0
  35. package/dist/daemon/control-state.js.map +1 -0
  36. package/dist/daemon/control-store.d.ts +113 -0
  37. package/dist/daemon/control-store.d.ts.map +1 -0
  38. package/dist/daemon/control-store.js +901 -0
  39. package/dist/daemon/control-store.js.map +1 -0
  40. package/dist/daemon/execution-policy.d.ts +15 -0
  41. package/dist/daemon/execution-policy.d.ts.map +1 -0
  42. package/dist/daemon/execution-policy.js +42 -0
  43. package/dist/daemon/execution-policy.js.map +1 -0
  44. package/dist/daemon/execution-services.d.ts +5 -0
  45. package/dist/daemon/execution-services.d.ts.map +1 -0
  46. package/dist/daemon/execution-services.js +73 -0
  47. package/dist/daemon/execution-services.js.map +1 -0
  48. package/dist/daemon/executions.d.ts +134 -0
  49. package/dist/daemon/executions.d.ts.map +1 -0
  50. package/dist/daemon/executions.js +486 -0
  51. package/dist/daemon/executions.js.map +1 -0
  52. package/dist/daemon/extension-ui.d.ts +24 -0
  53. package/dist/daemon/extension-ui.d.ts.map +1 -0
  54. package/dist/daemon/extension-ui.js +161 -0
  55. package/dist/daemon/extension-ui.js.map +1 -0
  56. package/dist/daemon/fanout.d.ts +40 -0
  57. package/dist/daemon/fanout.d.ts.map +1 -0
  58. package/dist/daemon/fanout.js +304 -0
  59. package/dist/daemon/fanout.js.map +1 -0
  60. package/dist/daemon/headless-json.d.ts +3 -0
  61. package/dist/daemon/headless-json.d.ts.map +1 -0
  62. package/dist/daemon/headless-json.js +20 -0
  63. package/dist/daemon/headless-json.js.map +1 -0
  64. package/dist/daemon/host-config.d.ts +61 -0
  65. package/dist/daemon/host-config.d.ts.map +1 -0
  66. package/dist/daemon/host-config.js +167 -0
  67. package/dist/daemon/host-config.js.map +1 -0
  68. package/dist/daemon/host-services.d.ts +61 -0
  69. package/dist/daemon/host-services.d.ts.map +1 -0
  70. package/dist/daemon/host-services.js +2 -0
  71. package/dist/daemon/host-services.js.map +1 -0
  72. package/dist/daemon/ipc.d.ts +14 -0
  73. package/dist/daemon/ipc.d.ts.map +1 -0
  74. package/dist/daemon/ipc.js +233 -0
  75. package/dist/daemon/ipc.js.map +1 -0
  76. package/dist/daemon/legacy-sessions.d.ts +48 -0
  77. package/dist/daemon/legacy-sessions.d.ts.map +1 -0
  78. package/dist/daemon/legacy-sessions.js +328 -0
  79. package/dist/daemon/legacy-sessions.js.map +1 -0
  80. package/dist/daemon/lifecycle.d.ts +44 -0
  81. package/dist/daemon/lifecycle.d.ts.map +1 -0
  82. package/dist/daemon/lifecycle.js +164 -0
  83. package/dist/daemon/lifecycle.js.map +1 -0
  84. package/dist/daemon/main.d.ts +3 -0
  85. package/dist/daemon/main.d.ts.map +1 -0
  86. package/dist/daemon/main.js +83 -0
  87. package/dist/daemon/main.js.map +1 -0
  88. package/dist/daemon/message-display.d.ts +4 -0
  89. package/dist/daemon/message-display.d.ts.map +1 -0
  90. package/dist/daemon/message-display.js +20 -0
  91. package/dist/daemon/message-display.js.map +1 -0
  92. package/dist/daemon/native-ownership.d.ts +13 -0
  93. package/dist/daemon/native-ownership.d.ts.map +1 -0
  94. package/dist/daemon/native-ownership.js +173 -0
  95. package/dist/daemon/native-ownership.js.map +1 -0
  96. package/dist/daemon/operational-log.d.ts +23 -0
  97. package/dist/daemon/operational-log.d.ts.map +1 -0
  98. package/dist/daemon/operational-log.js +79 -0
  99. package/dist/daemon/operational-log.js.map +1 -0
  100. package/dist/daemon/ownership.d.ts +35 -0
  101. package/dist/daemon/ownership.d.ts.map +1 -0
  102. package/dist/daemon/ownership.js +395 -0
  103. package/dist/daemon/ownership.js.map +1 -0
  104. package/dist/daemon/persistence.d.ts +110 -0
  105. package/dist/daemon/persistence.d.ts.map +1 -0
  106. package/dist/daemon/persistence.js +540 -0
  107. package/dist/daemon/persistence.js.map +1 -0
  108. package/dist/daemon/process-adapter.d.ts +23 -0
  109. package/dist/daemon/process-adapter.d.ts.map +1 -0
  110. package/dist/daemon/process-adapter.js +2 -0
  111. package/dist/daemon/process-adapter.js.map +1 -0
  112. package/dist/daemon/processes.d.ts +102 -0
  113. package/dist/daemon/processes.d.ts.map +1 -0
  114. package/dist/daemon/processes.js +691 -0
  115. package/dist/daemon/processes.js.map +1 -0
  116. package/dist/daemon/profile-storage.d.ts +16 -0
  117. package/dist/daemon/profile-storage.d.ts.map +1 -0
  118. package/dist/daemon/profile-storage.js +148 -0
  119. package/dist/daemon/profile-storage.js.map +1 -0
  120. package/dist/daemon/projection.d.ts +110 -0
  121. package/dist/daemon/projection.d.ts.map +1 -0
  122. package/dist/daemon/projection.js +661 -0
  123. package/dist/daemon/projection.js.map +1 -0
  124. package/dist/daemon/provider-loader.d.ts +28 -0
  125. package/dist/daemon/provider-loader.d.ts.map +1 -0
  126. package/dist/daemon/provider-loader.js +376 -0
  127. package/dist/daemon/provider-loader.js.map +1 -0
  128. package/dist/daemon/provider.d.ts +92 -0
  129. package/dist/daemon/provider.d.ts.map +1 -0
  130. package/dist/daemon/provider.js +31 -0
  131. package/dist/daemon/provider.js.map +1 -0
  132. package/dist/daemon/registry.d.ts +209 -0
  133. package/dist/daemon/registry.d.ts.map +1 -0
  134. package/dist/daemon/registry.js +1591 -0
  135. package/dist/daemon/registry.js.map +1 -0
  136. package/dist/daemon/runtime-dependencies.d.ts +5 -0
  137. package/dist/daemon/runtime-dependencies.d.ts.map +1 -0
  138. package/dist/daemon/runtime-dependencies.js +31 -0
  139. package/dist/daemon/runtime-dependencies.js.map +1 -0
  140. package/dist/daemon/server.d.ts +110 -0
  141. package/dist/daemon/server.d.ts.map +1 -0
  142. package/dist/daemon/server.js +556 -0
  143. package/dist/daemon/server.js.map +1 -0
  144. package/dist/daemon/service.d.ts +38 -0
  145. package/dist/daemon/service.d.ts.map +1 -0
  146. package/dist/daemon/service.js +275 -0
  147. package/dist/daemon/service.js.map +1 -0
  148. package/dist/daemon/session-cursor.d.ts +3 -0
  149. package/dist/daemon/session-cursor.d.ts.map +1 -0
  150. package/dist/daemon/session-cursor.js +4 -0
  151. package/dist/daemon/session-cursor.js.map +1 -0
  152. package/dist/daemon/session-execution.d.ts +88 -0
  153. package/dist/daemon/session-execution.d.ts.map +1 -0
  154. package/dist/daemon/session-execution.js +2 -0
  155. package/dist/daemon/session-execution.js.map +1 -0
  156. package/dist/daemon/session-executions.d.ts +5 -0
  157. package/dist/daemon/session-executions.d.ts.map +1 -0
  158. package/dist/daemon/session-executions.js +146 -0
  159. package/dist/daemon/session-executions.js.map +1 -0
  160. package/dist/daemon/session-files.d.ts +28 -0
  161. package/dist/daemon/session-files.d.ts.map +1 -0
  162. package/dist/daemon/session-files.js +189 -0
  163. package/dist/daemon/session-files.js.map +1 -0
  164. package/dist/daemon/session-lifecycle-contracts.d.ts +104 -0
  165. package/dist/daemon/session-lifecycle-contracts.d.ts.map +1 -0
  166. package/dist/daemon/session-lifecycle-contracts.js +2 -0
  167. package/dist/daemon/session-lifecycle-contracts.js.map +1 -0
  168. package/dist/daemon/session-lifecycle.d.ts +26 -0
  169. package/dist/daemon/session-lifecycle.d.ts.map +1 -0
  170. package/dist/daemon/session-lifecycle.js +246 -0
  171. package/dist/daemon/session-lifecycle.js.map +1 -0
  172. package/dist/daemon/session-runtime.d.ts +64 -0
  173. package/dist/daemon/session-runtime.d.ts.map +1 -0
  174. package/dist/daemon/session-runtime.js +3 -0
  175. package/dist/daemon/session-runtime.js.map +1 -0
  176. package/dist/daemon/session-services.d.ts +6 -0
  177. package/dist/daemon/session-services.d.ts.map +1 -0
  178. package/dist/daemon/session-services.js +144 -0
  179. package/dist/daemon/session-services.js.map +1 -0
  180. package/dist/daemon/shutdown-error.d.ts +12 -0
  181. package/dist/daemon/shutdown-error.d.ts.map +1 -0
  182. package/dist/daemon/shutdown-error.js +51 -0
  183. package/dist/daemon/shutdown-error.js.map +1 -0
  184. package/dist/daemon/supervisor.d.ts +28 -0
  185. package/dist/daemon/supervisor.d.ts.map +1 -0
  186. package/dist/daemon/supervisor.js +159 -0
  187. package/dist/daemon/supervisor.js.map +1 -0
  188. package/dist/daemon/target.d.ts +55 -0
  189. package/dist/daemon/target.d.ts.map +1 -0
  190. package/dist/daemon/target.js +191 -0
  191. package/dist/daemon/target.js.map +1 -0
  192. package/dist/daemon/tree.d.ts +7 -0
  193. package/dist/daemon/tree.d.ts.map +1 -0
  194. package/dist/daemon/tree.js +126 -0
  195. package/dist/daemon/tree.js.map +1 -0
  196. package/dist/daemon/ui-vocabulary.d.ts +29 -0
  197. package/dist/daemon/ui-vocabulary.d.ts.map +1 -0
  198. package/dist/daemon/ui-vocabulary.js +323 -0
  199. package/dist/daemon/ui-vocabulary.js.map +1 -0
  200. package/dist/daemon/worker.d.ts +5 -0
  201. package/dist/daemon/worker.d.ts.map +1 -0
  202. package/dist/daemon/worker.js +49 -0
  203. package/dist/daemon/worker.js.map +1 -0
  204. package/dist/index.d.ts +4 -0
  205. package/dist/index.d.ts.map +1 -0
  206. package/dist/index.js +12 -0
  207. package/dist/index.js.map +1 -0
  208. package/dist/lock.d.ts +27 -0
  209. package/dist/lock.d.ts.map +1 -0
  210. package/dist/lock.js +432 -0
  211. package/dist/lock.js.map +1 -0
  212. package/dist/logger.d.ts +9 -0
  213. package/dist/logger.d.ts.map +1 -0
  214. package/dist/logger.js +56 -0
  215. package/dist/logger.js.map +1 -0
  216. package/dist/remote/backoff.d.ts +17 -0
  217. package/dist/remote/backoff.d.ts.map +1 -0
  218. package/dist/remote/backoff.js +18 -0
  219. package/dist/remote/backoff.js.map +1 -0
  220. package/dist/remote/batch.d.ts +3 -0
  221. package/dist/remote/batch.d.ts.map +1 -0
  222. package/dist/remote/batch.js +27 -0
  223. package/dist/remote/batch.js.map +1 -0
  224. package/dist/remote/command-journal.d.ts +30 -0
  225. package/dist/remote/command-journal.d.ts.map +1 -0
  226. package/dist/remote/command-journal.js +72 -0
  227. package/dist/remote/command-journal.js.map +1 -0
  228. package/dist/remote/commands.d.ts +48 -0
  229. package/dist/remote/commands.d.ts.map +1 -0
  230. package/dist/remote/commands.js +145 -0
  231. package/dist/remote/commands.js.map +1 -0
  232. package/dist/remote/connector.d.ts +135 -0
  233. package/dist/remote/connector.d.ts.map +1 -0
  234. package/dist/remote/connector.js +1069 -0
  235. package/dist/remote/connector.js.map +1 -0
  236. package/dist/remote/content.d.ts +17 -0
  237. package/dist/remote/content.d.ts.map +1 -0
  238. package/dist/remote/content.js +116 -0
  239. package/dist/remote/content.js.map +1 -0
  240. package/dist/remote/outbox.d.ts +52 -0
  241. package/dist/remote/outbox.d.ts.map +1 -0
  242. package/dist/remote/outbox.js +321 -0
  243. package/dist/remote/outbox.js.map +1 -0
  244. package/dist/remote/platform.d.ts +53 -0
  245. package/dist/remote/platform.d.ts.map +1 -0
  246. package/dist/remote/platform.js +231 -0
  247. package/dist/remote/platform.js.map +1 -0
  248. package/dist/remote/realtime.d.ts +45 -0
  249. package/dist/remote/realtime.d.ts.map +1 -0
  250. package/dist/remote/realtime.js +265 -0
  251. package/dist/remote/realtime.js.map +1 -0
  252. package/dist/remote/sharing.d.ts +16 -0
  253. package/dist/remote/sharing.d.ts.map +1 -0
  254. package/dist/remote/sharing.js +24 -0
  255. package/dist/remote/sharing.js.map +1 -0
  256. package/dist/remote/state.d.ts +52 -0
  257. package/dist/remote/state.d.ts.map +1 -0
  258. package/dist/remote/state.js +156 -0
  259. package/dist/remote/state.js.map +1 -0
  260. package/dist/remote/text.d.ts +11 -0
  261. package/dist/remote/text.d.ts.map +1 -0
  262. package/dist/remote/text.js +188 -0
  263. package/dist/remote/text.js.map +1 -0
  264. package/dist/remote/token.d.ts +39 -0
  265. package/dist/remote/token.d.ts.map +1 -0
  266. package/dist/remote/token.js +238 -0
  267. package/dist/remote/token.js.map +1 -0
  268. package/dist/runtime-factory.d.ts +12 -0
  269. package/dist/runtime-factory.d.ts.map +1 -0
  270. package/dist/runtime-factory.js +12 -0
  271. package/dist/runtime-factory.js.map +1 -0
  272. package/dist/session-history.d.ts +29 -0
  273. package/dist/session-history.d.ts.map +1 -0
  274. package/dist/session-history.js +374 -0
  275. package/dist/session-history.js.map +1 -0
  276. package/dist/setup/dependencies.d.ts +15 -0
  277. package/dist/setup/dependencies.d.ts.map +1 -0
  278. package/dist/setup/dependencies.js +2 -0
  279. package/dist/setup/dependencies.js.map +1 -0
  280. package/dist/setup/open.d.ts +8 -0
  281. package/dist/setup/open.d.ts.map +1 -0
  282. package/dist/setup/open.js +16 -0
  283. package/dist/setup/open.js.map +1 -0
  284. package/dist/version.d.ts +2 -0
  285. package/dist/version.d.ts.map +1 -0
  286. package/dist/version.js +4 -0
  287. package/dist/version.js.map +1 -0
  288. package/package.json +66 -4
@@ -0,0 +1,1591 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+ import { closeSync, constants, existsSync, fsyncSync, openSync, writeFileSync } from 'node:fs';
3
+ import { dirname, join } from 'node:path';
4
+ import { randomUUID } from 'node:crypto';
5
+ import { canonicalRequestPayload, parseInteraction, parseSchema } from '@felan-ai/session-protocol';
6
+ import { RegistryExecutions, ExecutionHost } from './executions.js';
7
+ import { SessionProfileSchema } from './control-state.js';
8
+ import { LegacySessionIndex, readTranscriptPreview, transcriptHasConversation, transcriptPreview } from './legacy-sessions.js';
9
+ import { profilePolicy, SessionProjection } from './projection.js';
10
+ import { RegistryProcesses } from './processes.js';
11
+ import { guardOwnedPaths, sha256 } from './persistence.js';
12
+ import { isConversationEntry, prepareTranscriptDeletion } from './session-files.js';
13
+ import { assertTargetIdentity, canonicalProspectivePath, pathInside } from './target.js';
14
+ import { sessionTip } from './session-cursor.js';
15
+ import { shutdownStep } from './shutdown-error.js';
16
+ const nonRunMutations = new Set(['set_model', 'set_thinking', 'session.rename', 'session.entry.label', 'session.branch.select', 'session.export', 'command.invoke', 'shell.execute', 'processes.write', 'processes.stop', 'steer', 'follow_up', 'executions.steer', 'executions.cancel', 'ui.action']);
17
+ class RegistryOpeningCancelled extends Error {
18
+ }
19
+ export class SessionRegistry {
20
+ options;
21
+ processes;
22
+ recovery;
23
+ entries = new Map();
24
+ executionManagers = new Map();
25
+ runtimeOpening = new AsyncLocalStorage();
26
+ bindingActivation = new AsyncLocalStorage();
27
+ opening = new Map();
28
+ pathClaims = new Set();
29
+ copyClaims = new Set();
30
+ reservations = new Map();
31
+ lifecycleWriters = new Set();
32
+ reloadAttachments = new Map();
33
+ invalidationListeners = new Set();
34
+ observers = new Map();
35
+ listeners = new Map();
36
+ interactionListeners = new Set();
37
+ retainedServices = new Map();
38
+ shutdownInterruptions = new Map();
39
+ closing = new Set();
40
+ closes = new Map();
41
+ now;
42
+ startedAt;
43
+ legacy;
44
+ legacyListing;
45
+ previews = new Map();
46
+ stopping = false;
47
+ draining = false;
48
+ failure;
49
+ failureSettlement;
50
+ cancellationFailures = new Set();
51
+ lossUnsubscribe;
52
+ commandDispatcher;
53
+ constructor(options) {
54
+ this.options = options;
55
+ options.owner.assertCurrent();
56
+ if (options.store.owner !== options.owner)
57
+ throw new Error('Registry and store require the same held ownership.');
58
+ this.now = options.now ?? Date.now;
59
+ this.startedAt = this.now();
60
+ this.legacy = new LegacySessionIndex(options.owner);
61
+ this.recovery = options.store.needsRecovery ? options.store.recover().value : undefined;
62
+ this.processes = new RegistryProcesses({ ...(options.processAdapter ? { processAdapter: options.processAdapter } : {}), store: options.store, now: this.now, assertCurrent: () => options.owner.assertCurrent(),
63
+ changed: (id, process, rev) => this.publish({ v: 1, session: id, rev, change: { type: 'process.updated', process } }) });
64
+ this.lossUnsubscribe = options.owner.onLost(error => { this.fence(error); });
65
+ }
66
+ wrapOpeningRuntime(runtime, root) {
67
+ const opening = this.runtimeOpening.getStore();
68
+ if (!opening)
69
+ throw new Error('Runtime factories must run inside lifecycle.openSession.');
70
+ const scope = { session: '', root };
71
+ opening.scope = scope;
72
+ return this.processes.wrap(runtime, scope);
73
+ }
74
+ assertAdmission() {
75
+ this.options.owner.assertCurrent();
76
+ this.options.store.assertCurrent();
77
+ if (this.failure)
78
+ throw new Error('Registry is not admitting work.');
79
+ if (this.stopping)
80
+ throw new RegistryOpeningCancelled('Registry is not admitting work.');
81
+ }
82
+ assertDispatch(id) {
83
+ this.assertSessionAccess(id, false);
84
+ }
85
+ /** A binding's own services run while its open or reload still holds the session reservation. */
86
+ assertServiceAccess(id) {
87
+ this.assertSessionAccess(id, this.opening.has(id));
88
+ }
89
+ assertSessionAccess(id, activatingBinding) {
90
+ this.assertAdmission();
91
+ const session = this.options.store.sessionMetadata(id);
92
+ if (session.deletedAt !== undefined)
93
+ throw registryError('not_found', 'Session was deleted.');
94
+ if (this.reservations.has(session.piPath) && !activatingBinding)
95
+ throw registryError('busy', 'Session has a lifecycle operation in progress.');
96
+ if (session.state !== 'open' || this.entries.get(id)?.disposing || this.entries.get(id)?.cancelling || this.closing.has(id) || this.closing.has(session.root) || this.copyClaims.has(session.piPath))
97
+ throw new Error('Session is closing, cancelling or has a pinned copy in progress.');
98
+ }
99
+ assertNewWork() {
100
+ this.assertAdmission();
101
+ if (this.draining)
102
+ throw registryError('busy', 'Daemon is draining.');
103
+ }
104
+ failExecution(error) { this.fence(error); }
105
+ get fenced() { return this.failure !== undefined; }
106
+ beginDrain() {
107
+ if (this.draining && this.stopping && !this.failure) {
108
+ this.options.owner.assertCurrent();
109
+ this.options.store.assertCurrent();
110
+ return;
111
+ }
112
+ this.assertAdmission();
113
+ this.draining = true;
114
+ }
115
+ /** Reopens admission after an abandoned drain; a registry that started shutting down cannot resume. */
116
+ endDrain() {
117
+ this.assertAdmission();
118
+ this.draining = false;
119
+ }
120
+ async waitForDrain() {
121
+ this.assertAdmission();
122
+ if (!this.draining)
123
+ throw new Error('Drain must be started before waiting.');
124
+ await this.join([...this.entries.keys()].map(id => this.cancelViews(id)));
125
+ await this.join([...this.opening.values(), ...this.closes.values(), ...this.lifecycleWriters], undefined, true);
126
+ for (const entry of this.entries.values()) {
127
+ await entry.host?.settle();
128
+ await entry.cancelling;
129
+ await this.join([...entry.writers]);
130
+ for (const service of entry.services)
131
+ await service.settle('drain');
132
+ }
133
+ this.assertAdmission();
134
+ if (await this.activeWork())
135
+ throw registryError('busy', 'Daemon still owns active processes or work. Explicit force is required to interrupt it.');
136
+ }
137
+ /** Units of work a drain waits for and a forced stop interrupts: busy resident sessions (including child executions) and live processes. Detached jobs survive a restart and are excluded. */
138
+ async activeWork() {
139
+ this.options.owner.assertCurrent();
140
+ const interruptible = await this.processes.interruptibleCount();
141
+ const busy = [...this.entries.values()].filter(entry => !entry.binding.runtime.session.isIdle || entry.dispatches.size || entry.writers.size
142
+ || entry.host?.hasPendingWork() || entry.host?.hasActiveJobs() || [...entry.services].some(service => service.busy())).length;
143
+ return busy + interruptible;
144
+ }
145
+ create(request) {
146
+ this.assertNewWork();
147
+ if (request.parentSessionId && (this.closing.has(request.parentSessionId) || this.closing.has(request.rootSessionId)))
148
+ throw new Error('Parent session is closing.');
149
+ if (request.target.kind === 'resume')
150
+ throw new Error('Restore registered sessions by ID.');
151
+ if (request.parentSessionId && request.rootSessionId && request.extensionConfigOverrides === undefined) {
152
+ const overrides = this.options.store.sessionMetadata(request.rootSessionId).profile?.extensionConfigOverrides;
153
+ if (overrides)
154
+ request = { ...request, extensionConfigOverrides: overrides };
155
+ }
156
+ const key = randomUUID();
157
+ const opening = this.open(request);
158
+ this.opening.set(key, opening);
159
+ void opening.then(() => this.opening.delete(key), () => this.opening.delete(key));
160
+ return opening;
161
+ }
162
+ async openWorker(parent, worker) {
163
+ this.assertNewWork();
164
+ this.assertDispatch(parent);
165
+ const owner = this.options.store.sessionMetadata(parent);
166
+ const root = this.require(owner.root).binding;
167
+ const manager = worker.sessionManager;
168
+ const file = manager.getSessionFile();
169
+ if (!file || this.list().some(row => row.id === manager.getSessionId()))
170
+ throw registryError('invalid_request', 'Worker transcript must have a fresh identity.');
171
+ const path = this.options.owner.assertOwnsPath(file);
172
+ if (!pathInside(root.resources.sessionDir, path))
173
+ throw registryError('forbidden', 'Worker transcript is outside the root session directory.');
174
+ const workspace = this.options.owner.assertOwnsPath(worker.runtime.cwd);
175
+ if (!(root.resources.workerRoots ?? []).some(directory => pathInside(directory, workspace))) {
176
+ throw registryError('forbidden', 'Worker workspace is outside managed staging storage.');
177
+ }
178
+ const tools = new Set(['read', 'ls', 'grep', 'edit', 'write', ...(worker.customTools ?? []).map(tool => tool.name)]);
179
+ if (!worker.allowedTools.length || worker.allowedTools.some(name => !tools.has(name) || name === 'bash')) {
180
+ throw registryError('forbidden', 'Worker tools exceed the staged-artifact policy.');
181
+ }
182
+ const key = randomUUID();
183
+ const opening = this.open({ cwd: manager.getCwd(), sessionDir: dirname(path), target: { kind: 'resume', sessionFile: path },
184
+ parentSessionId: parent, rootSessionId: owner.root, policy: this.getPolicy(parent),
185
+ extensionPaths: this.options.store.sessionMetadata(owner.root).profile?.extensionPaths ?? [], allowedTools: worker.allowedTools, worker });
186
+ this.opening.set(key, opening);
187
+ try {
188
+ return await opening;
189
+ }
190
+ finally {
191
+ this.opening.delete(key);
192
+ }
193
+ }
194
+ async runWorker(id, operation) {
195
+ this.assertNewWork();
196
+ this.assertDispatch(id);
197
+ const entry = this.require(id);
198
+ if (!entry.binding.runtime.session.isIdle || entry.writers.size)
199
+ throw registryError('busy', 'Worker is already running.');
200
+ const work = Promise.resolve().then(() => { this.assertDispatch(id); return operation(); });
201
+ const settled = work.then(() => { }, () => { });
202
+ entry.writers.add(settled);
203
+ try {
204
+ return await work;
205
+ }
206
+ finally {
207
+ entry.writers.delete(settled);
208
+ entry.projection.refresh();
209
+ }
210
+ }
211
+ async validateConfiguration(request, resources) {
212
+ if (!request.extensionConfigOverrides?.length)
213
+ return;
214
+ this.assertAdmission();
215
+ if (!this.options.lifecycle.validateConfiguration)
216
+ throw registryError('unavailable', 'The lifecycle cannot validate session configuration before execution.');
217
+ const resolved = resources ?? await this.options.lifecycle.resolveResources({ cwd: request.cwd, ...(request.sessionDir ? { sessionDir: request.sessionDir } : {}) });
218
+ const services = this.options.services({ request, getExecutionPolicy: () => request.policy ?? { mode: 'host-default', generation: 0 } });
219
+ await this.options.lifecycle.validateConfiguration(resolved, request.extensionConfigOverrides, packages => services.extensions.configuration(packages));
220
+ this.assertAdmission();
221
+ }
222
+ /** A custom session directory becomes an owned, remembered store root only once a request using it has been validated. */
223
+ async ownSessionDirectory(resources) {
224
+ await this.options.owner.ownRoot(resources.sessionDir);
225
+ this.assertAdmission();
226
+ }
227
+ assertClientSessionDirectory(resources) {
228
+ this.assertAdmission();
229
+ const path = canonicalProspectivePath(resources.sessionDir);
230
+ const reserved = [this.options.store.directory, this.options.owner.target.runtimeDirectory,
231
+ resources.sessionStorageBase, resources.agentStorageRoot,
232
+ ...resources.writableRoots.filter(root => root !== resources.sessionDir)];
233
+ if (path === resources.agentDir || reserved.some(root => pathInside(root, path)))
234
+ throw registryError('forbidden', 'Client session directory is reserved for daemon state.');
235
+ }
236
+ restore(id, context = {}) {
237
+ this.assertAdmission();
238
+ const stored = this.options.store.findSessionMetadata(id);
239
+ if (!stored)
240
+ throw registryError('not_found', 'Session is not registered.');
241
+ if (stored.deletedAt !== undefined)
242
+ throw registryError('not_found', 'Session was deleted.');
243
+ if (this.reloadAttachments.has(id) && this.opening.has(id))
244
+ return this.opening.get(id);
245
+ if (this.closing.has(id))
246
+ throw new Error('Session is closing.');
247
+ const entry = this.entries.get(id);
248
+ if (entry?.disposing)
249
+ return entry.disposing.then(() => this.restore(id, context));
250
+ if (entry)
251
+ return Promise.resolve(entry.binding);
252
+ const pending = this.opening.get(id);
253
+ if (pending)
254
+ return pending;
255
+ const reopening = stored.state === 'closed';
256
+ if (reopening)
257
+ this.options.store.updateSession(id, { state: 'open' });
258
+ const operation = this.open({ cwd: stored.cwd, sessionDir: directoryOf(stored.piPath), target: { kind: 'resume', sessionFile: stored.piPath },
259
+ rootSessionId: stored.root, ...(stored.parent ? { parentSessionId: stored.parent } : {}), policy: profilePolicy(stored), ...profileOptions(stored.profile), ...context }, stored)
260
+ .catch(error => {
261
+ // A failed reopen leaves the session closed, as it was before.
262
+ if (reopening && !this.failure && !this.entries.has(id) && this.options.store.sessionMetadata(id).state === 'open')
263
+ this.options.store.transitionSessionForSafety(id, 'closed');
264
+ throw error;
265
+ });
266
+ this.opening.set(id, operation);
267
+ void operation.then(() => this.opening.delete(id), () => this.opening.delete(id));
268
+ return operation;
269
+ }
270
+ /** Unregistered pre-daemon transcripts that `adopt` can register in place. */
271
+ async legacySessions(cwd, fresh = false) {
272
+ this.assertAdmission();
273
+ const now = Date.now();
274
+ // Paged listings share one short-lived index pass. A scan cannot narrow by cwd, so the filter applies after it.
275
+ if (fresh || !this.legacyListing || this.legacyListing.expires <= now) {
276
+ this.legacyListing = { expires: now + 2_000, value: this.legacy.discover(this.options.store.sessionMetadatas()) };
277
+ }
278
+ const rows = await this.legacyListing.value;
279
+ const current = this.options.store.sessionMetadatas();
280
+ const ids = new Set(current.map(session => session.id));
281
+ const paths = new Set(current.map(session => session.piPath));
282
+ return rows.filter(row => !ids.has(row.id) && !paths.has(row.path) && (cwd === undefined || row.cwd === cwd));
283
+ }
284
+ legacySummary(row) { return this.legacy.summary(row); }
285
+ /** Reads the titles of listed pre-daemon transcripts, newest first, so the first listing rarely waits for them. */
286
+ async warmLegacySessions(signal) {
287
+ this.assertAdmission();
288
+ await this.legacy.warm(await this.legacySessions(), signal);
289
+ }
290
+ /** Registers a discovered pre-daemon transcript under its existing ID and file; later turns append to that file. */
291
+ adopt(id, profile) {
292
+ this.assertNewWork();
293
+ if (this.options.store.findSessionMetadata(id))
294
+ return this.restore(id);
295
+ const pending = this.opening.get(id);
296
+ if (pending)
297
+ return pending;
298
+ const operation = (async () => {
299
+ const legacy = (await this.legacySessions(undefined, true)).find(row => row.id === id);
300
+ if (!legacy)
301
+ throw registryError('not_found', 'Session is not registered.');
302
+ this.assertNewWork();
303
+ return this.open({ cwd: legacy.cwd, sessionDir: dirname(legacy.path), target: { kind: 'resume', sessionFile: legacy.path }, clientRoot: true, ...profile });
304
+ })();
305
+ this.opening.set(id, operation);
306
+ void operation.then(() => this.opening.delete(id), () => this.opening.delete(id));
307
+ return operation;
308
+ }
309
+ /** First user message for session listings. Transcripts only grow, so a found preview is cached for the daemon's lifetime. */
310
+ async preview(session) {
311
+ const cached = this.previews.get(session.id);
312
+ if (cached)
313
+ return cached;
314
+ const live = this.entries.get(session.id)?.binding.runtime.session.sessionManager;
315
+ const preview = live ? transcriptPreview(live.getEntries()) : await readTranscriptPreview(session.piPath);
316
+ if (preview)
317
+ this.previews.set(session.id, preview);
318
+ return preview;
319
+ }
320
+ list() { return this.options.store.sessionMetadatas().filter(session => session.deletedAt === undefined); }
321
+ /** Sessions clients list and count. A draft appears with its first message. */
322
+ listed() { return this.list().filter(session => !session.draft); }
323
+ binding(id) { return this.entries.get(id)?.binding; }
324
+ projection(id) { return this.require(id).projection; }
325
+ async refreshModelAvailability(signal) {
326
+ this.assertAdmission();
327
+ signal.throwIfAborted();
328
+ const work = [...this.entries].filter(([id, entry]) => !this.closing.has(id) && !entry.disposing).map(async ([, entry]) => {
329
+ const refresh = entry.binding.runtime.session.modelRuntime.getAvailable(undefined, { signal });
330
+ const settled = refresh.then(() => { }, () => { });
331
+ entry.writers.add(settled);
332
+ try {
333
+ await refresh;
334
+ signal.throwIfAborted();
335
+ this.options.owner.assertCurrent();
336
+ entry.projection.refresh();
337
+ }
338
+ finally {
339
+ entry.writers.delete(settled);
340
+ }
341
+ });
342
+ const results = await Promise.allSettled(work);
343
+ const failure = results.find(result => result.status === 'rejected');
344
+ if (failure?.status === 'rejected')
345
+ throw failure.reason;
346
+ }
347
+ recordResource(resource, change) {
348
+ const commit = this.options.store.putResource(resource);
349
+ if (change)
350
+ this.publish({ v: 1, session: commit.value.session, rev: commit.revisions[commit.value.session], change });
351
+ }
352
+ getPolicy(id) { return profilePolicy(this.options.store.sessionMetadata(id)); }
353
+ bindCommandDispatcher(available) {
354
+ if (this.commandDispatcher)
355
+ throw new Error('A command dispatcher is already bound.');
356
+ this.commandDispatcher = available;
357
+ try {
358
+ for (const entry of this.entries.values())
359
+ entry.projection.refresh();
360
+ }
361
+ catch (error) {
362
+ this.commandDispatcher = undefined;
363
+ throw error;
364
+ }
365
+ return () => { if (this.commandDispatcher === available)
366
+ this.commandDispatcher = undefined; };
367
+ }
368
+ async attach(id, metadata) {
369
+ await this.restore(id);
370
+ this.assertAdmission();
371
+ const entry = this.require(id);
372
+ const attachment = randomUUID();
373
+ const snapshot = entry.projection.snapshot();
374
+ entry.attachments.set(attachment, { ...metadata });
375
+ return { attachment, snapshot };
376
+ }
377
+ detach(id, attachment) {
378
+ const entry = this.entries.get(id);
379
+ if (!entry) {
380
+ this.reloadAttachments.get(id)?.delete(attachment);
381
+ return;
382
+ }
383
+ const kind = entry.attachments.get(attachment)?.kind;
384
+ if (entry.attachments.delete(attachment) && !entry.attachments.size)
385
+ entry.lastDetached = this.now();
386
+ if (kind === 'acp' && !this.stopping && !this.failure)
387
+ this.relaxPolicy(this.options.store.sessionMetadata(id).root);
388
+ }
389
+ attachmentCount(id) { return this.entries.get(id)?.attachments.size ?? this.reloadAttachments.get(id)?.size ?? 0; }
390
+ subscribe(id, listener) {
391
+ this.require(id).projection.refresh();
392
+ const listeners = this.listeners.get(id) ?? new Set();
393
+ listeners.add(listener);
394
+ this.listeners.set(id, listeners);
395
+ return () => { listeners.delete(listener); if (!listeners.size)
396
+ this.listeners.delete(id); };
397
+ }
398
+ observe(listener, lifecycle) {
399
+ this.observers.set(listener, lifecycle);
400
+ return () => { this.observers.delete(listener); };
401
+ }
402
+ registerService(id, service) {
403
+ const entry = this.require(id);
404
+ entry.services.add(service);
405
+ return () => entry.services.delete(service);
406
+ }
407
+ registerUiActions(id, prepare) {
408
+ const entry = this.require(id);
409
+ if (entry.prepareUiAction)
410
+ throw new Error('Session UI actions are already bound.');
411
+ entry.prepareUiAction = prepare;
412
+ return () => { if (entry.prepareUiAction === prepare)
413
+ delete entry.prepareUiAction; };
414
+ }
415
+ prepareUiAction(id, request) {
416
+ const entry = this.require(id);
417
+ const prepare = entry.prepareUiAction;
418
+ if (!prepare)
419
+ throw registryError('unavailable', 'Session UI actions are unavailable.');
420
+ const execute = prepare(request);
421
+ if (request.outcome !== 'cancel')
422
+ return execute;
423
+ return async (context) => {
424
+ const controller = new AbortController();
425
+ const signal = AbortSignal.any([context.signal, controller.signal]);
426
+ const actions = entry.viewActions ??= new Set();
427
+ actions.add(controller);
428
+ let onAbort;
429
+ const aborted = new Promise((_resolve, reject) => {
430
+ onAbort = () => reject(signal.reason);
431
+ signal.addEventListener('abort', onAbort, { once: true });
432
+ });
433
+ try {
434
+ signal.throwIfAborted();
435
+ await Promise.race([execute({ ...context, signal }), aborted]);
436
+ }
437
+ finally {
438
+ signal.removeEventListener('abort', onAbort);
439
+ actions.delete(controller);
440
+ }
441
+ };
442
+ }
443
+ executionHost(id) {
444
+ const existing = this.entries.get(id)?.host;
445
+ if (existing instanceof ExecutionHost)
446
+ return existing;
447
+ const root = this.options.store.sessionMetadata(id).root;
448
+ let manager = this.executionManagers.get(root);
449
+ if (!manager) {
450
+ manager = new RegistryExecutions(this, root);
451
+ this.executionManagers.set(root, manager);
452
+ }
453
+ return manager.host(id);
454
+ }
455
+ async runAdmitted(requestId, id, origin, effect) {
456
+ this.assertNewWork();
457
+ this.assertDispatch(id);
458
+ const entry = this.require(id);
459
+ const stored = this.options.store.sessionMetadata(id);
460
+ if (stored.state !== 'open' || stored.execution && !stored.execution.completedAt || this.closing.has(id) || this.copyClaims.has(stored.piPath) || this.running(id))
461
+ throw registryError('busy', 'Wait for the current run to finish.');
462
+ const request = this.options.store.request(requestId);
463
+ if (request?.session !== id)
464
+ throw new Error('Admitted request belongs to another session.');
465
+ const run = { state: 'running', id: randomUUID(), origin };
466
+ let finish;
467
+ const writer = new Promise(resolve => { finish = resolve; });
468
+ entry.writers.add(writer);
469
+ entry.dispatches.add(requestId);
470
+ entry.projection.setOrigin(origin);
471
+ if (request.op === 'command.invoke')
472
+ entry.command = { runId: run.id, views: new Set() };
473
+ try {
474
+ entry.projection.syncCursor();
475
+ this.commitDispatch(requestId, id);
476
+ entry.projection.publish({ type: 'run.updated', run }, { run });
477
+ this.assertDispatch(id);
478
+ return await effect(entry.binding.runtime.session);
479
+ }
480
+ finally {
481
+ try {
482
+ entry.dispatches.delete(requestId);
483
+ if (entry.command?.runId === run.id)
484
+ delete entry.command;
485
+ if (!this.failure) {
486
+ if (!entry.cancelling)
487
+ entry.projection.settleRun(run.id);
488
+ entry.projection.refresh();
489
+ }
490
+ }
491
+ catch (error) {
492
+ this.fence(error);
493
+ throw error;
494
+ }
495
+ finally {
496
+ entry.writers.delete(writer);
497
+ finish();
498
+ }
499
+ }
500
+ }
501
+ async resumeInteraction(id, answer, continuationId, dispatch) {
502
+ await this.restore(id);
503
+ this.assertDispatch(id);
504
+ const entry = this.require(id);
505
+ const stored = this.options.store.sessionMetadata(id);
506
+ if (this.closing.has(id) || this.copyClaims.has(stored.piPath) || this.running(id))
507
+ throw registryError('busy', 'Session is busy.');
508
+ const continuation = this.options.store.snapshot().continuations.find(item => item.id === continuationId);
509
+ if (!continuation || continuation.session !== id || continuation.phase !== 'ready')
510
+ throw registryError('already_resolved', 'Continuation is no longer ready.');
511
+ const origin = { kind: 'automation', source: `interaction:${continuation.interaction}` };
512
+ const run = { state: 'running', id: continuationId, origin };
513
+ let finish;
514
+ const writer = new Promise(resolve => { finish = resolve; });
515
+ entry.writers.add(writer);
516
+ entry.dispatches.add(continuationId);
517
+ entry.projection.setOrigin(origin);
518
+ try {
519
+ entry.projection.syncCursor();
520
+ dispatch();
521
+ entry.projection.publish({ type: 'run.updated', run }, { run });
522
+ this.assertDispatch(id);
523
+ await entry.binding.runtime.session.prompt(`A previously deferred question was answered:\n${JSON.stringify(answer)}`);
524
+ }
525
+ finally {
526
+ try {
527
+ entry.dispatches.delete(continuationId);
528
+ if (!this.failure) {
529
+ if (!entry.cancelling)
530
+ entry.projection.settleRun(run.id);
531
+ entry.projection.refresh();
532
+ }
533
+ }
534
+ catch (error) {
535
+ this.fence(error);
536
+ throw error;
537
+ }
538
+ finally {
539
+ entry.writers.delete(writer);
540
+ finish();
541
+ }
542
+ }
543
+ }
544
+ async mutateAdmitted(requestId, id, effect) {
545
+ const entry = this.require(id);
546
+ const request = this.options.store.request(requestId);
547
+ if (request?.session !== id || !nonRunMutations.has(request.op))
548
+ throw new Error('Request is not a supported non-run mutation for this session.');
549
+ let active = true;
550
+ const assertCurrent = () => {
551
+ if (!active)
552
+ throw new Error('Mutation dispatch has settled.');
553
+ this.assertDispatch(id);
554
+ if (this.entries.get(id) !== entry)
555
+ throw new Error('Session runtime changed.');
556
+ };
557
+ assertCurrent();
558
+ if (entry.dispatches.has(requestId))
559
+ throw new Error('Request is already executing.');
560
+ let finish;
561
+ const writer = new Promise(resolve => { finish = resolve; });
562
+ entry.writers.add(writer);
563
+ entry.dispatches.add(requestId);
564
+ try {
565
+ this.commitDispatch(requestId, id);
566
+ assertCurrent();
567
+ return await effect({ session: entry.binding.runtime.session, assertCurrent });
568
+ }
569
+ finally {
570
+ active = false;
571
+ entry.dispatches.delete(requestId);
572
+ try {
573
+ if (!this.failure) {
574
+ if (!entry.cancelling)
575
+ entry.projection.settleRun(this.options.store.sessionMetadata(id).run.id);
576
+ entry.projection.refresh();
577
+ }
578
+ }
579
+ catch (error) {
580
+ this.fence(error);
581
+ throw error;
582
+ }
583
+ finally {
584
+ entry.writers.delete(writer);
585
+ finish();
586
+ }
587
+ }
588
+ }
589
+ commitDispatch(requestId, id) {
590
+ const commit = this.options.store.markDispatched(requestId);
591
+ const stored = this.options.store.sessionMetadata(id);
592
+ if (commit.revisions[id] !== stored.revision || Object.keys(commit.revisions).some(session => session !== id)) {
593
+ throw new Error('Dispatch checkpoint does not match the committed session revision.');
594
+ }
595
+ // Dispatch intent advances the revision before execution changes the run state.
596
+ this.publish({ v: 1, session: id, rev: stored.revision, change: { type: 'run.updated', run: stored.run } });
597
+ }
598
+ publishCommitted(revisions) {
599
+ for (const [id, revision] of Object.entries(revisions)) {
600
+ const stored = this.options.store.sessionMetadata(id);
601
+ if (stored.revision !== revision)
602
+ throw new Error('Checkpoint differs from the committed revision.');
603
+ if (this.entries.has(id))
604
+ this.publish({ v: 1, session: id, rev: revision, change: { type: 'run.updated', run: stored.run } });
605
+ }
606
+ }
607
+ subscribeInteractions(listener) {
608
+ this.interactionListeners.add(listener);
609
+ return () => this.interactionListeners.delete(listener);
610
+ }
611
+ publishInteraction(interaction, revisions) {
612
+ if (interaction.scope.kind === 'host') {
613
+ for (const listener of this.interactionListeners)
614
+ listener(structuredClone(interaction));
615
+ return;
616
+ }
617
+ const id = interaction.scope.session;
618
+ const revision = revisions[id];
619
+ if (revision !== this.options.store.sessionMetadata(id).revision)
620
+ throw new Error('Interaction differs from its committed revision.');
621
+ this.publish({ v: 1, session: id, rev: revision, change: {
622
+ type: interaction.state === 'waiting' || interaction.state === 'deferred' ? 'interaction.requested' : 'interaction.resolved', interaction,
623
+ } });
624
+ }
625
+ subscribeInvalidation(listener) {
626
+ this.invalidationListeners.add(listener);
627
+ return () => { this.invalidationListeners.delete(listener); };
628
+ }
629
+ /** Closes the session tree if needed, then deletes every transcript in it and records tombstones for request retries. */
630
+ deleteAdmitted(requestId, id) {
631
+ this.assertAdmission();
632
+ const tree = this.descendants(id).map(session => this.options.store.sessionMetadata(session));
633
+ const paths = tree.map(session => session.piPath);
634
+ if (paths.some(path => this.reservations.has(path)))
635
+ throw registryError('busy', 'Session has a lifecycle operation in progress.');
636
+ const token = Symbol('deletion');
637
+ for (const path of paths)
638
+ this.reservations.set(path, { token, id });
639
+ const work = (async () => {
640
+ await this.close(id);
641
+ this.assertAdmission();
642
+ const removals = paths.map(path => prepareTranscriptDeletion(this.options.owner, path));
643
+ const commit = this.options.store.markSessionDeleted(requestId);
644
+ for (const session of tree)
645
+ this.previews.delete(session.id);
646
+ this.publishCommitted(commit.revisions);
647
+ for (const session of tree)
648
+ for (const lifecycle of this.observers.values()) {
649
+ try {
650
+ lifecycle?.(session.id);
651
+ }
652
+ catch { }
653
+ }
654
+ for (const remove of removals)
655
+ remove();
656
+ })().finally(() => { for (const path of paths)
657
+ if (this.reservations.get(path)?.token === token)
658
+ this.reservations.delete(path); });
659
+ return this.trackLifecycle(work);
660
+ }
661
+ /** The session and every registered descendant, parents before children. */
662
+ descendants(id) {
663
+ const rows = this.list();
664
+ const tree = [id];
665
+ for (let index = 0; index < tree.length; index++)
666
+ for (const row of rows)
667
+ if (row.parent === tree[index] && !tree.includes(row.id))
668
+ tree.push(row.id);
669
+ return tree;
670
+ }
671
+ /** Applies session configuration now when the root can reload; otherwise persists it for the next reload or resume. */
672
+ configureAdmitted(requestId, id, configuration) {
673
+ try {
674
+ return this.reloadAdmitted(requestId, id, configuration).then(() => undefined);
675
+ }
676
+ catch (error) {
677
+ if (!['busy', 'unavailable'].includes(error.code))
678
+ throw error;
679
+ }
680
+ this.assertAdmission();
681
+ const commit = this.options.store.configureSession(requestId, configuration.values, configuration.overrides);
682
+ this.publishCommitted(commit.revisions);
683
+ return Promise.resolve();
684
+ }
685
+ reloadAdmitted(requestId, id, configuration) {
686
+ this.assertDispatch(id);
687
+ const stored = this.options.store.sessionMetadata(id);
688
+ const entry = this.entries.get(id);
689
+ const request = this.options.store.request(requestId);
690
+ if (request?.op !== (configuration ? 'config.set' : 'session.reload') || request.session !== id || request.phase !== 'intent')
691
+ throw registryError('request_conflict', 'Reload request differs.');
692
+ if (stored.parent)
693
+ throw registryError('unavailable', 'Only root sessions can reload.');
694
+ if (!['idle', 'interrupted'].includes(stored.run.state) || entry && this.running(id))
695
+ throw registryError('busy', 'Wait for the current run to finish before reloading.');
696
+ if (this.opening.has(id) || entry?.disposing || entry?.cancelling || entry?.writers.size)
697
+ throw registryError('busy', 'Session has a lifecycle operation in progress.');
698
+ const token = Symbol('reload');
699
+ this.reservations.set(stored.piPath, { token, id });
700
+ const attachments = entry?.attachments ?? new Map();
701
+ this.reloadAttachments.set(id, attachments);
702
+ const work = (async () => {
703
+ try {
704
+ if (configuration)
705
+ this.options.store.configureSession(requestId, configuration.values, configuration.overrides);
706
+ else
707
+ this.commitDispatch(requestId, id);
708
+ this.settleInteractions(id, 'cancel');
709
+ // Like Pi's /reload, only the root runtime reloads: busy children and queued executions keep running and deliver to the next root runtime.
710
+ for (const [child, value] of [...this.entries]) {
711
+ if (child !== id && value.binding.rootSessionId === id && !value.disposing && !this.childBusy(child, value))
712
+ await this.disposeEntry(child, false, { keepTerminals: true });
713
+ }
714
+ if (entry)
715
+ await this.disposeEntry(id, false, { preserveView: true, keepTerminals: true, keepExecutions: true });
716
+ this.assertAdmission();
717
+ if (this.closing.has(id) || this.options.store.sessionMetadata(id).state !== 'open')
718
+ throw new RegistryOpeningCancelled('Session closed during reload.');
719
+ this.pathClaims.add(stored.piPath);
720
+ const current = this.options.store.sessionMetadata(id);
721
+ const binding = await this.open({ cwd: current.cwd, sessionDir: dirname(current.piPath), target: { kind: 'resume', sessionFile: current.piPath },
722
+ rootSessionId: current.root, policy: profilePolicy(current), ...profileOptions(current.profile) }, current, undefined, token);
723
+ const reloaded = this.require(id);
724
+ reloaded.attachments = attachments;
725
+ reloaded.host?.resumeQueued();
726
+ const revisions = { [id]: this.options.store.sessionMetadata(id).revision };
727
+ for (const listener of this.invalidationListeners)
728
+ listener(revisions);
729
+ return binding;
730
+ }
731
+ catch (error) {
732
+ if (!this.entries.has(id) && !this.failure) {
733
+ try {
734
+ const current = this.options.store.sessionMetadata(id);
735
+ if (current.state === 'open' && current.residency !== 'evicted')
736
+ this.options.store.transitionSessionForSafety(id, 'evicted');
737
+ }
738
+ catch (cleanup) {
739
+ this.fence(cleanup);
740
+ throw new AggregateError([error, cleanup], 'Reload failed while restoring evicted state.');
741
+ }
742
+ }
743
+ throw error;
744
+ }
745
+ finally {
746
+ this.reloadAttachments.delete(id);
747
+ this.reservations.delete(stored.piPath);
748
+ if (!this.entries.has(id))
749
+ this.pathClaims.delete(stored.piPath);
750
+ }
751
+ })();
752
+ this.opening.set(id, work);
753
+ void work.then(() => this.opening.delete(id), () => this.opening.delete(id));
754
+ return this.trackLifecycle(work);
755
+ }
756
+ trackLifecycle(work) {
757
+ this.lifecycleWriters.add(work);
758
+ void work.then(() => this.lifecycleWriters.delete(work), () => this.lifecycleWriters.delete(work));
759
+ return work;
760
+ }
761
+ cancelAdmitted(requestId, id, runId) {
762
+ this.assertAdmission();
763
+ if (!this.entries.has(id))
764
+ throw registryError('not_found', 'Cancel session is not resident.');
765
+ const request = this.options.store.request(requestId);
766
+ if (request?.session !== id || request.op !== 'cancel')
767
+ throw registryError('request_conflict', 'Cancel request belongs to another operation or session.');
768
+ if (runId && this.options.store.sessionMetadata(id).run.id !== runId)
769
+ throw registryError('not_found', 'Run not found.');
770
+ this.commitDispatch(requestId, id);
771
+ return this.cancel(id, runId);
772
+ }
773
+ closeAdmitted(requestId, id) {
774
+ this.assertAdmission();
775
+ const request = this.options.store.request(requestId);
776
+ if (request?.session !== id || request.op !== 'close')
777
+ throw new Error('Close request belongs to another operation or session.');
778
+ this.commitDispatch(requestId, id);
779
+ return this.close(id);
780
+ }
781
+ async forkAdmitted(requestId, id, from) {
782
+ return this.copyAdmitted(requestId, id, from);
783
+ }
784
+ async cloneAdmitted(requestId, id) {
785
+ return this.copyAdmitted(requestId, id);
786
+ }
787
+ async copyAdmitted(requestId, id, from) {
788
+ await this.restore(id);
789
+ this.assertDispatch(id);
790
+ const source = this.require(id);
791
+ const stored = this.options.store.sessionMetadata(id);
792
+ const request = this.options.store.request(requestId);
793
+ if (request?.session !== id || request.op !== (from ? 'fork' : 'session.clone'))
794
+ throw new Error('Copy request belongs to another operation or session.');
795
+ const path = this.options.owner.assertOwnsPath(stored.piPath);
796
+ this.copyClaims.add(path);
797
+ let finish;
798
+ const writer = new Promise(resolve => { finish = resolve; });
799
+ source.writers.add(writer);
800
+ source.dispatches.add(requestId);
801
+ try {
802
+ let target = { kind: 'clone', sourceSessionFile: path };
803
+ if (from) {
804
+ const choices = source.binding.runtime.session.getUserMessagesForForking();
805
+ const choice = from.kind === 'entry' ? choices.find(item => item.entryId === from.entryId)
806
+ : Number.isSafeInteger(from.position) && from.position >= 0 ? choices[from.position] : undefined;
807
+ if (!choice)
808
+ throw new Error('Fork selector does not identify a stored user message.');
809
+ target = { kind: 'fork', sourceSessionFile: path, entryId: choice.entryId, position: 'before' };
810
+ }
811
+ this.commitDispatch(requestId, id);
812
+ this.assertAdmission();
813
+ if (this.closing.has(id) || this.closing.has(stored.root))
814
+ throw new Error('Source session is closing.');
815
+ const key = randomUUID();
816
+ const opening = this.open({ cwd: stored.cwd, sessionDir: directoryOf(path), ...profileOptions(stored.profile),
817
+ target }, undefined, path);
818
+ this.opening.set(key, opening);
819
+ try {
820
+ return await opening;
821
+ }
822
+ finally {
823
+ this.opening.delete(key);
824
+ }
825
+ }
826
+ finally {
827
+ this.copyClaims.delete(path);
828
+ source.dispatches.delete(requestId);
829
+ source.writers.delete(writer);
830
+ finish();
831
+ }
832
+ }
833
+ async cancel(id, runId) {
834
+ this.assertAdmission();
835
+ const entry = this.require(id);
836
+ if (entry.disposing)
837
+ throw new Error('Session runtime is being evicted.');
838
+ const stored = this.options.store.sessionMetadata(id);
839
+ if (runId && stored.run.id !== runId)
840
+ throw registryError('not_found', 'Run not found.');
841
+ if (entry.cancelling)
842
+ return entry.cancelling;
843
+ if (['idle', 'interrupted'].includes(stored.run.state) && entry.binding.runtime.session.isIdle)
844
+ return;
845
+ const operation = Promise.resolve().then(async () => {
846
+ entry.projection.transitionForSafety('cancelling', stored.run.id);
847
+ this.settleInteractions(id, 'cancel');
848
+ await this.cancelViews(id);
849
+ await entry.binding.runtime.session.abort();
850
+ await this.processes.cancelForeground(id);
851
+ await this.join([...entry.writers]);
852
+ this.options.owner.assertCurrent();
853
+ if (!entry.binding.runtime.session.isIdle)
854
+ throw new Error('Cancelled session has not settled.');
855
+ entry.projection.transitionForSafety('idle', stored.run.id);
856
+ entry.projection.refresh();
857
+ });
858
+ entry.cancelling = operation;
859
+ try {
860
+ await operation;
861
+ }
862
+ catch (error) {
863
+ this.cancellationFailures.add(error);
864
+ throw error;
865
+ }
866
+ finally {
867
+ delete entry.cancelling;
868
+ if (!this.draining && !this.stopping && !this.closing.has(id))
869
+ delete entry.viewCancellation;
870
+ }
871
+ if (!this.stopping && !this.failure && !this.closing.has(id) && !this.closing.has(stored.root))
872
+ this.entries.get(stored.root)?.host?.resumeQueued();
873
+ }
874
+ /** Applies ACP approval policy to the open session tree; running tool calls keep the approvals they already had. */
875
+ tightenPolicy(root) { this.setPolicy(root, 'acp-strict'); }
876
+ /** Returns the session tree to host defaults once no ACP client is attached to it. */
877
+ relaxPolicy(root) {
878
+ const tree = this.list().filter(item => item.root === root);
879
+ if (tree.some(item => [...this.entries.get(item.id)?.attachments.values() ?? []].some(attachment => attachment.kind === 'acp')))
880
+ return;
881
+ this.setPolicy(root, 'host-default');
882
+ }
883
+ setPolicy(root, effective) {
884
+ this.assertAdmission();
885
+ for (const stored of this.list().filter(item => item.root === root && item.state === 'open')) {
886
+ if (stored.policy.effective !== effective)
887
+ this.policyUpdate(stored.id, { effective, generation: stored.policy.generation + 1 });
888
+ }
889
+ }
890
+ async evictIdle() {
891
+ this.assertAdmission();
892
+ const evicted = [];
893
+ const idle = this.options.idleMs ?? 60_000;
894
+ for (const [id, entry] of this.entries) {
895
+ if (this.closing.has(id) || entry.disposing || entry.attachments.size || this.hasWarmChildren(id) || this.now() - entry.lastDetached < idle || this.busy(id))
896
+ continue;
897
+ if (this.options.store.sessionMetadata(id).draft && await this.discardDraft(id)) {
898
+ evicted.push(id);
899
+ continue;
900
+ }
901
+ await this.disposeEntry(id, false);
902
+ evicted.push(id);
903
+ }
904
+ // A draft that was not resident at startup gets its idle window from startup, so a reconnecting client keeps it.
905
+ for (const row of this.list()) {
906
+ if (!row.draft || this.entries.has(row.id) || this.opening.has(row.id) || this.closing.has(row.id) || this.now() - Math.max(row.updatedAt, this.startedAt) < idle)
907
+ continue;
908
+ if (await this.discardDraft(row.id))
909
+ evicted.push(row.id);
910
+ }
911
+ return evicted;
912
+ }
913
+ /**
914
+ * Removes a draft with no conversation, children, pending interaction or running process. Any other draft becomes a normal session.
915
+ * A draft that changed during the check is left for a later pass.
916
+ */
917
+ async discardDraft(id) {
918
+ const entry = this.entries.get(id);
919
+ const conversation = entry ? entry.binding.runtime.session.sessionManager.getEntries().some(isConversationEntry) : await transcriptHasConversation(this.options.store.sessionMetadata(id).piPath);
920
+ // The transcript read yields, so an attach, deletion or other lifecycle operation may have started meanwhile.
921
+ const stored = this.options.store.findSessionMetadata(id);
922
+ if (!stored?.draft || stored.deletedAt !== undefined || this.entries.get(id) !== entry || this.opening.has(id) || this.closing.has(id)
923
+ || this.reservations.has(stored.piPath) || this.copyClaims.has(stored.piPath))
924
+ return false;
925
+ const engaged = this.list().some(row => row.parent === id)
926
+ || this.options.store.interactions().some(item => item.scope.kind === 'session' && item.scope.session === id && (item.state === 'waiting' || item.state === 'deferred'))
927
+ || this.processes.blocksEviction(stored.root);
928
+ if (conversation || engaged) {
929
+ if (entry)
930
+ entry.projection.materialize();
931
+ else
932
+ this.options.store.updateSession(id, { draft: false });
933
+ return false;
934
+ }
935
+ this.closing.add(id);
936
+ try {
937
+ if (entry)
938
+ await this.disposeEntry(id, false);
939
+ const remove = prepareTranscriptDeletion(this.options.owner, stored.piPath);
940
+ const commit = this.options.store.discardDraft(id);
941
+ this.previews.delete(id);
942
+ this.publishCommitted(commit.revisions);
943
+ remove();
944
+ return true;
945
+ }
946
+ finally {
947
+ this.closing.delete(id);
948
+ }
949
+ }
950
+ close(id) {
951
+ this.options.owner.assertCurrent();
952
+ const stored = this.options.store.sessionMetadata(id);
953
+ const pending = this.closes.get(id);
954
+ if (pending)
955
+ return pending;
956
+ if (stored.state === 'closed')
957
+ return Promise.resolve();
958
+ this.closing.add(id);
959
+ const rows = this.list();
960
+ const descendants = new Set([id]);
961
+ for (let changed = true; changed;) {
962
+ changed = false;
963
+ for (const row of rows)
964
+ if (row.parent && descendants.has(row.parent) && !descendants.has(row.id)) {
965
+ descendants.add(row.id);
966
+ changed = true;
967
+ }
968
+ }
969
+ for (const descendant of descendants)
970
+ this.processes.seal(descendant);
971
+ const operation = this.processes.withCleanup([...descendants], () => this.performClose(id));
972
+ this.closes.set(id, operation);
973
+ void operation.then(() => { this.closes.delete(id); this.closing.delete(id); }, () => { });
974
+ return operation;
975
+ }
976
+ async performClose(id) {
977
+ await this.join([...this.opening.values()], undefined, true);
978
+ await this.entries.get(id)?.disposing;
979
+ const stored = this.options.store.sessionMetadata(id);
980
+ if (stored.state === 'closed')
981
+ return;
982
+ const descendants = this.list().filter(item => item.parent === id && item.state === 'open');
983
+ // Host shutdown cancels its full descendant tree and waits beyond terminal status records.
984
+ const entry = this.entries.get(id);
985
+ this.settleInteractions(id, 'close');
986
+ if (entry)
987
+ await this.cancelViews(id);
988
+ await entry?.host?.shutdown();
989
+ for (const child of descendants)
990
+ await this.close(child.id);
991
+ if (entry) {
992
+ await entry.cancelling;
993
+ await entry.binding.runtime.session.abort();
994
+ await this.processes.cancelForeground(id);
995
+ await Promise.all(entry.writers);
996
+ entry.projection.settleRun(this.options.store.sessionMetadata(id).run.id);
997
+ await this.processes.settle(stored.root, false, id === stored.root ? undefined : new Set([id]));
998
+ for (const service of entry.services)
999
+ await this.processes.withCleanup(id, () => service.settle());
1000
+ await this.disposeEntry(id, true);
1001
+ }
1002
+ else {
1003
+ this.options.store.transitionSessionForSafety(id, 'closed');
1004
+ }
1005
+ }
1006
+ async shutdown(options = {}) {
1007
+ if (options.interrupt)
1008
+ for (const row of this.list()) {
1009
+ if (row.state === 'open' && ['running', 'waiting', 'cancelling'].includes(row.run.state) && row.run.id)
1010
+ this.shutdownInterruptions.set(row.id, row.run.id);
1011
+ }
1012
+ this.stopping = true;
1013
+ if (this.failure) {
1014
+ await shutdownStep('registry-fenced', async () => { await this.failureSettlement; throw new Error('Registry is fenced; verify settlement before releasing ownership.'); });
1015
+ }
1016
+ const cancellations = [...this.entries].map(([id, entry]) => {
1017
+ if (!options.interrupt)
1018
+ return shutdownStep('session-cancel-views', () => this.cancelViews(id), id);
1019
+ this.closing.add(id);
1020
+ return (async () => {
1021
+ await shutdownStep('session-interactions', () => this.settleInteractions(id, 'shutdown'), id);
1022
+ await this.join([shutdownStep('session-cancel-views', () => this.cancelViews(id), id),
1023
+ shutdownStep('session-host-shutdown', () => entry.host?.shutdown(), id),
1024
+ shutdownStep('session-abort', () => entry.runtimeDisposed ? undefined : entry.binding.runtime.session.abort(), id)]);
1025
+ })();
1026
+ });
1027
+ const closing = this.join([...this.closes.values()], undefined, true).catch(error => {
1028
+ // Failed closes retain their intent; force retries them after in-flight closes finish.
1029
+ if (!options.interrupt || this.failure)
1030
+ throw error;
1031
+ });
1032
+ await this.join([...cancellations, shutdownStep('registry-pending', () => this.join([...this.opening.values(), closing, ...this.lifecycleWriters], undefined, true))]);
1033
+ if (this.failure)
1034
+ await shutdownStep('registry-fenced', async () => { await this.failureSettlement; throw this.failure; });
1035
+ if (options.interrupt) {
1036
+ const retry = [...this.closes.keys()];
1037
+ this.closes.clear();
1038
+ await this.join(retry.map(id => shutdownStep('session-close', () => this.close(id), id)));
1039
+ }
1040
+ for (const [id, entry] of this.entries) {
1041
+ this.closing.add(id);
1042
+ await shutdownStep('session-interactions', () => this.settleInteractions(id, 'shutdown'), id);
1043
+ if (!options.interrupt) {
1044
+ await shutdownStep('session-cancel-views', () => this.cancelViews(id), id);
1045
+ await shutdownStep('session-host-shutdown', () => entry.host?.shutdown(), id);
1046
+ await shutdownStep('session-abort', async () => { await entry.cancelling; if (!entry.runtimeDisposed)
1047
+ await entry.binding.runtime.session.abort(); }, id);
1048
+ }
1049
+ await shutdownStep('session-cancel-foreground', () => this.processes.cancelForeground(id), id);
1050
+ if (!options.interrupt)
1051
+ await shutdownStep('session-writers', () => this.join([...entry.writers]), id);
1052
+ await shutdownStep('session-processes', () => this.processes.settle(entry.binding.rootSessionId), id);
1053
+ for (const service of entry.services) {
1054
+ await shutdownStep('session-services', () => this.processes.withCleanup(id, async () => { await service.settle(); await service.dispose(); }), id);
1055
+ entry.services.delete(service);
1056
+ }
1057
+ if (options.interrupt) {
1058
+ if (!entry.runtimeDisposed) {
1059
+ await shutdownStep('session-runtime', () => this.processes.withCleanup(id, () => entry.binding.runtime.dispose()), id);
1060
+ entry.runtimeDisposed = true;
1061
+ }
1062
+ await shutdownStep('session-writers', async () => { await entry.cancelling; await this.join([...entry.writers]); }, id);
1063
+ }
1064
+ await shutdownStep('session-run', () => entry.projection.settleRun(this.options.store.sessionMetadata(id).run.id), id);
1065
+ await shutdownStep('session-dispose', () => this.disposeEntry(id, false), id);
1066
+ }
1067
+ // A failed reload leaves its kept PTYs without a resident root.
1068
+ await shutdownStep('live-roots', async () => { for (const root of this.processes.liveRoots())
1069
+ await this.processes.settle(root); });
1070
+ for (const [id, services] of this.retainedServices)
1071
+ for (const service of services) {
1072
+ await shutdownStep('retained-services', async () => { await service.settle(); await service.dispose(); }, id);
1073
+ services.delete(service);
1074
+ }
1075
+ for (const [id, run] of this.shutdownInterruptions) {
1076
+ await shutdownStep('session-run', () => { if (this.options.store.sessionMetadata(id).state === 'open')
1077
+ this.options.store.interruptSessionRun(id, run); }, id);
1078
+ this.shutdownInterruptions.delete(id);
1079
+ }
1080
+ this.retainedServices.clear();
1081
+ this.lossUnsubscribe();
1082
+ this.listeners.clear();
1083
+ // The caller closes the store/profile and releases ownership only after this resolves.
1084
+ }
1085
+ busy(id) {
1086
+ const entry = this.require(id);
1087
+ const stored = this.options.store.sessionMetadata(id);
1088
+ return this.copyClaims.has(stored.piPath) || this.reservations.has(stored.piPath) || !entry.binding.runtime.session.isIdle || entry.binding.runtime.session.pendingMessageCount > 0 || entry.dispatches.size > 0
1089
+ || ['running', 'waiting', 'cancelling'].includes(stored.run.state) || !!entry.host?.hasPendingWork() || !!entry.host?.hasActiveJobs() || !!entry.host?.hasExecutingJobs()
1090
+ || [...entry.services].some(service => !service.disposeWhenIdle || service.busy()) || this.processes.blocksEviction(stored.root)
1091
+ || this.options.store.interactions().some(item => item.scope.kind === 'session' && item.scope.root === stored.root && item.state === 'waiting');
1092
+ }
1093
+ /** Whether a turn or admitted run is in progress, the condition Pi itself uses to defer conflicting work. */
1094
+ running(id) {
1095
+ const entry = this.entries.get(id);
1096
+ return !!entry && (!entry.binding.runtime.session.isIdle || entry.dispatches.size > 0);
1097
+ }
1098
+ childBusy(id, entry) {
1099
+ return this.running(id) || entry.writers.size > 0 || !!entry.host?.hasPendingWork() || !!entry.host?.hasActiveJobs()
1100
+ || !['idle', 'interrupted'].includes(this.options.store.sessionMetadata(id).run.state);
1101
+ }
1102
+ hasWarmChildren(id) { return [...this.entries].some(([child, value]) => child !== id && value.binding.rootSessionId === id); }
1103
+ async open(request, restored, copySource, reservation) {
1104
+ const resources = await this.options.lifecycle.resolveResources({ cwd: request.cwd, ...(request.sessionDir ? { sessionDir: request.sessionDir } : {}) });
1105
+ this.assertAdmission();
1106
+ if (request.clientRoot)
1107
+ this.assertClientSessionDirectory(resources);
1108
+ await this.ownSessionDirectory(resources);
1109
+ for (const path of resources.writableRoots)
1110
+ this.options.owner.assertOwnsPath(path);
1111
+ const sourcePath = request.target.kind === 'clone' || request.target.kind === 'fork' ? this.options.owner.assertOwnsPath(request.target.sourceSessionFile) : undefined;
1112
+ const source = sourcePath ? this.list().find(item => item.piPath === sourcePath) : undefined;
1113
+ const initialPolicy = request.parentSessionId ? this.getPolicy(request.rootSessionId) : source ? profilePolicy(source) : request.policy ?? { mode: 'host-default', generation: 0 };
1114
+ let binding;
1115
+ let claim;
1116
+ let borrowed;
1117
+ const openingPolicy = () => request.parentSessionId ? this.getPolicy(request.rootSessionId) : source ? this.getPolicy(source.id) : initialPolicy;
1118
+ const externalServices = this.options.services({ request, getExecutionPolicy: () => binding && this.entries.has(binding.sessionId) ? this.getPolicy(binding.sessionId) : openingPolicy() });
1119
+ const services = { ...externalServices, extensions: {
1120
+ configuration: packages => externalServices.extensions.configuration(packages),
1121
+ ...(externalServices.extensions.executionPort ? { executionPort: externalServices.extensions.executionPort } : {}),
1122
+ bind: async (context, port) => {
1123
+ const scope = this.runtimeOpening.getStore()?.scope;
1124
+ if (scope) {
1125
+ scope.session = context.sessionManager.getSessionId();
1126
+ scope.root = context.rootSessionId;
1127
+ }
1128
+ const bindings = await externalServices.extensions.bind(context, port);
1129
+ return { ...bindings, inlineExtensions: [...(bindings.inlineExtensions ?? []), ...(request.systemPromptAppend ? [{
1130
+ name: '@felan-ai/daemon/child-instructions',
1131
+ factory: (pi) => { pi.on('before_agent_start', event => ({ systemPrompt: `${event.systemPrompt}\n\n${request.systemPromptAppend}` })); },
1132
+ }] : [])] };
1133
+ },
1134
+ } };
1135
+ const ownership = { assertOwned: (resolved, target) => {
1136
+ this.assertAdmission();
1137
+ if (request.clientRoot)
1138
+ this.assertClientSessionDirectory({ ...resolved, sessionDir: target.destinationDirectory });
1139
+ for (const path of resolved.writableRoots)
1140
+ this.options.owner.assertOwnsPath(path);
1141
+ this.options.owner.assertOwnsPath(target.destinationDirectory);
1142
+ if (target.sourceSessionFile) {
1143
+ const source = this.options.owner.assertOwnsPath(target.sourceSessionFile);
1144
+ if (claim === source || borrowed === source)
1145
+ return;
1146
+ const held = this.reservations.get(source);
1147
+ if (held) {
1148
+ if (held.token !== reservation)
1149
+ throw registryError('busy', 'Source has a lifecycle reservation.');
1150
+ claim = source;
1151
+ this.pathClaims.add(source);
1152
+ return;
1153
+ }
1154
+ if (source === copySource && this.copyClaims.has(source) && (target.kind === 'fork' || target.kind === 'clone')) {
1155
+ borrowed = source;
1156
+ return;
1157
+ }
1158
+ if (this.pathClaims.has(source))
1159
+ throw new Error('Session transcript already has a live writer.');
1160
+ this.pathClaims.add(source);
1161
+ claim = source;
1162
+ }
1163
+ } };
1164
+ try {
1165
+ binding = await this.runtimeOpening.run({}, () => this.options.lifecycle.openSession({ ...request, resources, ownership, policy: initialPolicy, services }));
1166
+ this.assertAdmission();
1167
+ if (restored && this.closing.has(restored.id) || request.parentSessionId && (this.closing.has(request.parentSessionId) || this.closing.has(request.rootSessionId)))
1168
+ throw new RegistryOpeningCancelled('Session is closing.');
1169
+ const manager = binding.runtime.session.sessionManager;
1170
+ const initialPath = manager.getSessionFile();
1171
+ if (initialPath && !existsSync(this.options.owner.assertOwnsPath(initialPath))) {
1172
+ const path = this.options.owner.assertOwnsPath(initialPath);
1173
+ const header = manager.getHeader();
1174
+ if (!header || header.id !== binding.sessionId)
1175
+ throw new Error('Missing Pi session header.');
1176
+ this.assertAdmission();
1177
+ if (!existsSync(this.options.owner.assertOwnsPath(path))) {
1178
+ const rows = [header, ...manager.getEntries()];
1179
+ if (manager.getEntries().some(row => row.type === 'message' && ['user', 'assistant'].includes(row.message.role)))
1180
+ throw new Error('Conversation transcript was not persisted.');
1181
+ const bytes = rows.map(row => JSON.stringify(row)).join('\n') + '\n';
1182
+ const fd = openSync(this.options.owner.assertOwnsPath(path), constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
1183
+ try {
1184
+ this.options.owner.assertCurrent();
1185
+ writeFileSync(fd, bytes);
1186
+ fsyncSync(fd);
1187
+ }
1188
+ finally {
1189
+ closeSync(fd);
1190
+ }
1191
+ const directory = openSync(dirname(path), constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
1192
+ try {
1193
+ fsyncSync(directory);
1194
+ }
1195
+ finally {
1196
+ closeSync(directory);
1197
+ }
1198
+ }
1199
+ if (claim && claim !== path)
1200
+ this.pathClaims.delete(claim);
1201
+ claim = path;
1202
+ this.pathClaims.add(path);
1203
+ // Reload the same manager so Pi appends to the header instead of creating it again on the first message.
1204
+ manager.setSessionFile(this.options.owner.assertOwnsPath(path));
1205
+ }
1206
+ const actualPath = manager.getSessionFile();
1207
+ if (!actualPath || manager.getSessionId() !== binding.sessionId || manager.getCwd() !== resources.cwd)
1208
+ throw new Error('Lifecycle returned inconsistent session identity.');
1209
+ const path = this.options.owner.assertOwnsPath(actualPath);
1210
+ if (restored && (binding.sessionId !== restored.id || path !== restored.piPath || binding.rootSessionId !== restored.root))
1211
+ throw new Error('Restored session identity changed.');
1212
+ if (this.entries.has(binding.sessionId) || (this.pathClaims.has(path) && claim !== path))
1213
+ throw new Error('Session already has a live writer.');
1214
+ if (claim && claim !== path)
1215
+ this.pathClaims.delete(claim);
1216
+ claim = path;
1217
+ this.pathClaims.add(path);
1218
+ const effectivePolicy = openingPolicy();
1219
+ if (!restored)
1220
+ this.options.store.createSession({ id: binding.sessionId, root: binding.rootSessionId, ...(binding.parentSessionId ? { parent: binding.parentSessionId } : {}),
1221
+ ...(request.execution ? { execution: request.execution } : {}),
1222
+ cwd: resources.cwd, piPath: path, title: manager.getSessionName() ?? '', state: 'open', residency: 'warm', run: { state: 'idle' },
1223
+ ...(request.clientRoot === true && request.target.kind === 'new' ? { draft: true } : {}),
1224
+ policy: { effective: effectivePolicy.mode, generation: effectivePolicy.generation },
1225
+ branch: manager.getLeafId() ?? binding.sessionId, observedTip: sessionTip(manager),
1226
+ ...(binding.runtime.session.model ? { model: { provider: binding.runtime.session.model.provider, id: binding.runtime.session.model.id } } : {}), thinking: binding.runtime.session.thinkingLevel,
1227
+ ...(request.extensionPaths?.length || request.allowedTools?.length || request.extensionConfigOverrides?.length ? { profile: parseSchema(SessionProfileSchema, {
1228
+ ...(request.extensionPaths?.length ? { extensionPaths: request.extensionPaths } : {}),
1229
+ ...(request.allowedTools ? { allowedTools: request.allowedTools } : {}),
1230
+ ...(request.extensionConfigOverrides?.length ? { extensionConfigOverrides: request.extensionConfigOverrides } : {}),
1231
+ }) } : {}),
1232
+ });
1233
+ else
1234
+ this.options.store.updateSession(binding.sessionId, { residency: 'warm' });
1235
+ const projection = new SessionProjection({ binding, store: this.options.store, extras: () => this.extras(binding.sessionId), publish: event => this.publish(event), onError: error => { this.fence(error); },
1236
+ commandAvailable: name => this.options.commandAvailable?.(binding.sessionId, name) === false ? false
1237
+ : this.commandDispatcher?.(binding.sessionId, name) ?? this.options.commandAvailable?.(binding.sessionId, name) ?? false,
1238
+ canSettle: () => { const current = this.entries.get(binding.sessionId); return !current?.dispatches.size && !current?.cancelling; },
1239
+ onRefresh: () => {
1240
+ const current = this.entries.get(binding.sessionId);
1241
+ if (current)
1242
+ this.updateCommandState(current);
1243
+ },
1244
+ });
1245
+ const entry = { binding, projection, attachments: new Map(), dispatches: new Set(), writers: new Set(), services: this.retainedServices.get(binding.sessionId) ?? new Set(), lastDetached: this.now() };
1246
+ this.retainedServices.delete(binding.sessionId);
1247
+ this.entries.set(binding.sessionId, entry);
1248
+ const host = this.executionHost(binding.sessionId);
1249
+ host.activate();
1250
+ entry.host = host;
1251
+ const activation = { binding, active: true };
1252
+ try {
1253
+ await this.bindingActivation.run(activation, () => binding.activate?.());
1254
+ }
1255
+ finally {
1256
+ activation.active = false;
1257
+ }
1258
+ this.assertAdmission();
1259
+ if (this.closing.has(binding.sessionId) || request.parentSessionId && this.closing.has(request.parentSessionId))
1260
+ throw new RegistryOpeningCancelled('Session closed during activation.');
1261
+ // A restored root rediscovers jobs that outlived its previous runtime or daemon.
1262
+ if (restored && !binding.parentSessionId)
1263
+ await this.processes.refresh(binding.rootSessionId).catch(error => { this.fence(error); throw error; });
1264
+ projection.refresh();
1265
+ for (const resident of this.observers.values()) {
1266
+ try {
1267
+ resident?.(binding.sessionId);
1268
+ }
1269
+ catch { }
1270
+ }
1271
+ if (borrowed)
1272
+ this.copyClaims.delete(borrowed);
1273
+ return binding;
1274
+ }
1275
+ catch (error) {
1276
+ if (binding) {
1277
+ const installed = this.entries.get(binding.sessionId);
1278
+ if (installed && installed.binding === binding) {
1279
+ this.closing.add(binding.sessionId);
1280
+ installed.projection.dispose();
1281
+ }
1282
+ try {
1283
+ await this.processes.withCleanup(binding.sessionId, async () => {
1284
+ if (installed && installed.binding === binding) {
1285
+ this.settleInteractions(binding.sessionId, 'cancel');
1286
+ await installed.host?.shutdown();
1287
+ await installed.cancelling;
1288
+ }
1289
+ await binding.runtime.session.abort();
1290
+ if (installed && installed.binding === binding) {
1291
+ await this.join([...installed.writers]);
1292
+ await this.processes.settle(binding.rootSessionId, false, new Set([binding.sessionId]));
1293
+ for (const service of installed.services) {
1294
+ await service.settle();
1295
+ await service.dispose();
1296
+ }
1297
+ }
1298
+ await binding.runtime.dispose();
1299
+ });
1300
+ this.processes.revokeRuntime(binding.agentRuntime);
1301
+ if (installed && installed.binding === binding) {
1302
+ this.options.store.transitionSessionForSafety(binding.sessionId, 'idle');
1303
+ this.options.store.transitionSessionForSafety(binding.sessionId, 'evicted');
1304
+ this.entries.delete(binding.sessionId);
1305
+ this.closing.delete(binding.sessionId);
1306
+ }
1307
+ }
1308
+ catch (cleanup) {
1309
+ this.fence(cleanup);
1310
+ throw new AggregateError([error, cleanup], 'Registry opening cleanup failed; ownership remains held.');
1311
+ }
1312
+ }
1313
+ if (claim)
1314
+ this.pathClaims.delete(claim);
1315
+ if (borrowed)
1316
+ this.copyClaims.delete(borrowed);
1317
+ throw error;
1318
+ }
1319
+ }
1320
+ settleInteractions(id, reason) {
1321
+ for (const interaction of this.options.store.interactions()) {
1322
+ if (interaction.scope.kind !== 'session' || interaction.scope.session !== id || !['waiting', 'deferred'].includes(interaction.state))
1323
+ continue;
1324
+ const commit = reason === 'shutdown' && interaction.deferrable
1325
+ ? this.options.store.deferInteraction(interaction.id)
1326
+ : this.options.store.cancelInteraction(interaction.id, reason === 'shutdown' ? 'drain' : 'abort');
1327
+ this.publish({ v: 1, session: id, rev: commit.revisions[id], change: { type: commit.value.state === 'deferred' ? 'interaction.requested' : 'interaction.resolved', interaction: commit.value } });
1328
+ }
1329
+ }
1330
+ cancelViews(id) {
1331
+ const entry = this.require(id);
1332
+ if (entry.cancellingViews)
1333
+ return entry.cancellingViews;
1334
+ for (const controller of entry.viewActions ?? [])
1335
+ controller.abort();
1336
+ const cancellation = entry.viewCancellation ??= { attempted: new Set(), rounds: 0 };
1337
+ const work = Promise.resolve().then(async () => {
1338
+ const controller = new AbortController();
1339
+ const timer = setTimeout(() => controller.abort(), 250);
1340
+ let aborted;
1341
+ const deadline = new Promise(resolve => { aborted = resolve; });
1342
+ controller.signal.addEventListener('abort', aborted, { once: true });
1343
+ try {
1344
+ for (;;) {
1345
+ const actions = entry.projection.presentations().flatMap(element => {
1346
+ if (element.kind !== 'view' && element.kind !== 'document' && element.kind !== 'progress')
1347
+ return [];
1348
+ const action = element.actions.find(action => action.target === 'element' && action.outcome === 'cancel');
1349
+ const key = JSON.stringify([element.id, element.revision]);
1350
+ if (!action || cancellation.attempted.has(key))
1351
+ return [];
1352
+ cancellation.attempted.add(key);
1353
+ return [{ elementId: element.id, revision: element.revision, actionId: action.id, outcome: action.outcome, values: {} }];
1354
+ });
1355
+ if (!actions.length)
1356
+ return;
1357
+ if (cancellation.rounds++ >= 8 || controller.signal.aborted) {
1358
+ for (const action of actions)
1359
+ this.viewCancellationDiagnostic(id, action.elementId, controller.signal.aborted ? 'timeout' : 'limit');
1360
+ return;
1361
+ }
1362
+ await Promise.all(actions.map(async (action) => {
1363
+ try {
1364
+ const execute = this.prepareUiAction(id, action);
1365
+ await Promise.race([execute({ requestId: randomUUID(), signal: controller.signal }), deadline]);
1366
+ if (controller.signal.aborted)
1367
+ this.viewCancellationDiagnostic(id, action.elementId, 'timeout');
1368
+ }
1369
+ catch (error) {
1370
+ if (error?.code !== 'stale_request')
1371
+ this.viewCancellationDiagnostic(id, action.elementId, controller.signal.aborted ? 'timeout' : 'failed');
1372
+ }
1373
+ }));
1374
+ }
1375
+ }
1376
+ finally {
1377
+ clearTimeout(timer);
1378
+ controller.signal.removeEventListener('abort', aborted);
1379
+ controller.abort();
1380
+ delete entry.cancellingViews;
1381
+ entry.writers.delete(work);
1382
+ }
1383
+ });
1384
+ entry.cancellingViews = work;
1385
+ entry.writers.add(work);
1386
+ return work;
1387
+ }
1388
+ viewCancellationDiagnostic(session, elementId, reason) {
1389
+ try {
1390
+ this.options.onViewCancellationDiagnostic?.({ session, elementId, reason });
1391
+ }
1392
+ catch { /* Diagnostics cannot block best-effort cancellation. */ }
1393
+ }
1394
+ updateCommandState(entry) {
1395
+ if (!entry.command)
1396
+ return;
1397
+ const run = this.options.store.sessionMetadata(entry.binding.sessionId).run;
1398
+ if (run.id !== entry.command.runId || run.state !== 'running' && run.state !== 'waiting')
1399
+ return;
1400
+ const state = entry.command.views.size && entry.binding.runtime.session.isIdle ? 'waiting' : 'running';
1401
+ if (state !== run.state)
1402
+ entry.projection.publish({ type: 'run.updated', run: { ...run, state } }, { run: { ...run, state } });
1403
+ }
1404
+ extras(id) {
1405
+ const sessions = this.options.store.sessionMetadatas();
1406
+ const session = this.options.store.sessionMetadata(id);
1407
+ return { children: sessions.filter(item => item.parent === id && item.deletedAt === undefined).map(item => ({ session: item.id, parent: id, root: item.root, title: item.title, state: item.state, run: item.run })),
1408
+ processes: this.processes.list(session.root), interactions: this.options.store.interactions().filter(item => item.scope.kind === 'session' && item.scope.session === id && (item.state === 'waiting' || item.state === 'deferred')) };
1409
+ }
1410
+ publish(event) {
1411
+ if ('change' in event && 'interaction' in event.change && (event.change.type === 'interaction.requested' || event.change.type === 'interaction.resolved')) {
1412
+ const interaction = parseInteraction(event.change.interaction);
1413
+ for (const listener of this.interactionListeners)
1414
+ listener(structuredClone(interaction));
1415
+ }
1416
+ const listeners = this.listeners.get(event.session);
1417
+ if (listeners?.size || this.observers.size) {
1418
+ const shared = structuredClone(event);
1419
+ for (const listener of listeners ?? []) {
1420
+ try {
1421
+ listener(shared);
1422
+ }
1423
+ catch { /* A client observer does not own runtime lifetime. */ }
1424
+ }
1425
+ for (const observer of this.observers.keys()) {
1426
+ try {
1427
+ observer(shared);
1428
+ }
1429
+ catch { }
1430
+ }
1431
+ }
1432
+ const entry = this.entries.get(event.session);
1433
+ if (!entry || !('change' in event))
1434
+ return;
1435
+ const change = event.change;
1436
+ if (change.type === 'run.updated') {
1437
+ this.updateCommandState(entry);
1438
+ return;
1439
+ }
1440
+ if (change.type !== 'presentation.updated' && change.type !== 'presentation.removed')
1441
+ return;
1442
+ if (entry.command) {
1443
+ if (change.type === 'presentation.removed' && 'id' in change)
1444
+ entry.command.views.delete(change.id);
1445
+ else if ('presentation' in change) {
1446
+ const element = change.presentation;
1447
+ if ((element.kind === 'view' || element.kind === 'document') && element.actions.some(action => action.target === 'element' && action.outcome === 'cancel'))
1448
+ entry.command.views.add(element.id);
1449
+ else
1450
+ entry.command.views.delete(element.id);
1451
+ }
1452
+ this.updateCommandState(entry);
1453
+ }
1454
+ if (change.type === 'presentation.updated' && 'presentation' in change && (this.draining || this.stopping || this.closing.has(event.session) || entry.cancelling)) {
1455
+ void Promise.resolve().then(() => {
1456
+ if (this.entries.get(event.session) === entry)
1457
+ return this.cancelViews(event.session);
1458
+ }).catch(() => this.viewCancellationDiagnostic(event.session, change.presentation.id, 'failed'));
1459
+ }
1460
+ }
1461
+ policyUpdate(id, policy) {
1462
+ const projection = this.entries.get(id)?.projection;
1463
+ if (projection)
1464
+ projection.publish({ type: 'policy.updated', policy }, { policy });
1465
+ else
1466
+ this.options.store.updateSession(id, { policy });
1467
+ }
1468
+ require(id) { const entry = this.entries.get(id); if (!entry)
1469
+ throw new Error('Session runtime is not resident.'); return entry; }
1470
+ /** Reload keeps the root's PTYs running (its next runtime reattaches them by ID) and keeps the root's executions running. */
1471
+ async disposeEntry(id, close, { preserveView = false, keepTerminals = false, keepExecutions = false } = {}) {
1472
+ const entry = this.require(id);
1473
+ if (entry.disposing)
1474
+ return entry.disposing;
1475
+ entry.disposing = (async () => {
1476
+ entry.projection.refresh();
1477
+ if (close)
1478
+ this.processes.seal(id);
1479
+ for (const service of [...entry.services])
1480
+ if (service.disposeWhenIdle) {
1481
+ await this.processes.withCleanup(id, async () => { await service.settle(close ? 'force' : 'drain'); await service.dispose(); });
1482
+ entry.services.delete(service);
1483
+ }
1484
+ if (!close)
1485
+ this.processes.seal(id);
1486
+ if (close)
1487
+ for (const service of entry.services)
1488
+ await this.processes.withCleanup(id, () => service.dispose());
1489
+ if (!keepExecutions)
1490
+ await entry.host?.shutdown();
1491
+ await this.processes.settle(entry.binding.rootSessionId, false, new Set([id]), keepTerminals);
1492
+ if (!entry.runtimeDisposed)
1493
+ await this.processes.withCleanup(id, () => entry.binding.runtime.dispose());
1494
+ this.processes.revoke(id);
1495
+ this.options.owner.assertCurrent();
1496
+ const stored = this.options.store.sessionMetadata(id);
1497
+ const closed = close || this.closes.has(id);
1498
+ entry.projection.transitionForSafety(closed ? 'closed' : 'evicted');
1499
+ entry.projection.dispose();
1500
+ this.entries.delete(id);
1501
+ if (!preserveView)
1502
+ this.listeners.delete(id);
1503
+ this.pathClaims.delete(stored.piPath);
1504
+ if (!close && entry.services.size)
1505
+ this.retainedServices.set(id, entry.services);
1506
+ })();
1507
+ try {
1508
+ await entry.disposing;
1509
+ }
1510
+ catch (error) {
1511
+ delete entry.disposing;
1512
+ this.fence(error);
1513
+ throw error;
1514
+ }
1515
+ }
1516
+ fence(error) {
1517
+ this.failure = error;
1518
+ this.processes.sealAll();
1519
+ for (const entry of this.entries.values())
1520
+ entry.projection.dispose();
1521
+ this.failureSettlement ??= Promise.resolve().then(() => this.settleAfterLoss());
1522
+ void this.failureSettlement.catch(() => { });
1523
+ }
1524
+ async settleWriters() {
1525
+ if (this.failureSettlement)
1526
+ await this.failureSettlement;
1527
+ else {
1528
+ const failures = [];
1529
+ do {
1530
+ await this.join(this.pendingWriters(), failures);
1531
+ } while (this.hasPendingWriters());
1532
+ for (const error of this.cancellationFailures)
1533
+ if (!failures.includes(error))
1534
+ failures.push(error);
1535
+ if (failures.length)
1536
+ throw new AggregateError(failures, 'Registry writers did not settle cleanly; ownership must remain held.');
1537
+ }
1538
+ }
1539
+ async settleAfterLoss() {
1540
+ const failures = [];
1541
+ const roots = new Set([...this.entries.values()].map(entry => entry.binding.rootSessionId).concat([...this.processes.liveRoots()]));
1542
+ const pending = [...this.opening.values(), ...this.closes.values(), ...[...roots].map(root => this.processes.settle(root, true))];
1543
+ for (const entry of this.entries.values()) {
1544
+ pending.push(entry.binding.runtime.session.abort(), ...(entry.disposing ? [entry.disposing] : []), ...(entry.cancelling ? [entry.cancelling] : []));
1545
+ if (entry.host)
1546
+ pending.push((async () => {
1547
+ try {
1548
+ await entry.host.shutdown();
1549
+ }
1550
+ finally {
1551
+ await entry.host.settle();
1552
+ }
1553
+ })());
1554
+ for (const service of entry.services)
1555
+ pending.push(service.settle());
1556
+ }
1557
+ for (const services of this.retainedServices.values())
1558
+ for (const service of services)
1559
+ pending.push(service.settle());
1560
+ await this.join(pending, failures);
1561
+ do {
1562
+ await this.join(this.pendingWriters(), failures);
1563
+ } while (this.hasPendingWriters());
1564
+ for (const error of this.cancellationFailures)
1565
+ if (!failures.includes(error))
1566
+ failures.push(error);
1567
+ if (failures.length)
1568
+ throw new AggregateError(failures, 'Registry writers did not settle cleanly; ownership must remain held.');
1569
+ }
1570
+ pendingWriters() {
1571
+ return [...this.opening.values(), ...this.lifecycleWriters, ...[...this.entries.values()].flatMap(entry => [...entry.writers, ...(entry.cancelling ? [entry.cancelling] : [])])];
1572
+ }
1573
+ hasPendingWriters() {
1574
+ return this.opening.size > 0 || this.lifecycleWriters.size > 0 || [...this.entries.values()].some(entry => entry.writers.size > 0 || entry.cancelling !== undefined);
1575
+ }
1576
+ async join(operations, failures, allowCancelled = false) {
1577
+ const errors = failures ?? [];
1578
+ for (const result of await Promise.allSettled(operations))
1579
+ if (result.status === 'rejected' && !(allowCancelled && result.reason instanceof RegistryOpeningCancelled))
1580
+ errors.push(result.reason);
1581
+ if (!failures && errors.length)
1582
+ throw new AggregateError(errors, 'Registry operation failed to settle.');
1583
+ }
1584
+ }
1585
+ function profileOptions(profile) {
1586
+ return { ...(profile?.extensionPaths ? { extensionPaths: profile.extensionPaths } : {}), ...(profile?.allowedTools ? { allowedTools: profile.allowedTools } : {}),
1587
+ ...(profile?.extensionConfigOverrides ? { extensionConfigOverrides: profile.extensionConfigOverrides } : {}) };
1588
+ }
1589
+ function directoryOf(path) { return path.slice(0, path.lastIndexOf('/')) || '/'; }
1590
+ function registryError(code, message) { return Object.assign(new Error(message), { code }); }
1591
+ //# sourceMappingURL=registry.js.map