@astrale-os/cli 0.4.0-alpha.13

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 (219) hide show
  1. package/.check-workspace.cjs +40 -0
  2. package/README.md +151 -0
  3. package/dist/astrale.js +59749 -0
  4. package/package.json +90 -0
  5. package/src/command.ts +40 -0
  6. package/src/commands/__tests__/admin-instance.test.ts +73 -0
  7. package/src/commands/__tests__/auth-login.test.ts +178 -0
  8. package/src/commands/__tests__/auth-token.test.ts +223 -0
  9. package/src/commands/__tests__/call.test.ts +72 -0
  10. package/src/commands/__tests__/domain-list.test.ts +74 -0
  11. package/src/commands/__tests__/help-contract.test.ts +136 -0
  12. package/src/commands/__tests__/install-identity-override.test.ts +65 -0
  13. package/src/commands/__tests__/instance-bookmark.test.ts +101 -0
  14. package/src/commands/__tests__/instance-create-hosts.test.ts +29 -0
  15. package/src/commands/__tests__/instance-list-rows.test.ts +63 -0
  16. package/src/commands/__tests__/logs.test.ts +117 -0
  17. package/src/commands/__tests__/ls.test.ts +25 -0
  18. package/src/commands/__tests__/setup-plan.test.ts +61 -0
  19. package/src/commands/admin/status.ts +61 -0
  20. package/src/commands/admin/use.ts +77 -0
  21. package/src/commands/auth/login.ts +82 -0
  22. package/src/commands/auth/logout.ts +50 -0
  23. package/src/commands/auth/status.ts +86 -0
  24. package/src/commands/auth/token.ts +162 -0
  25. package/src/commands/browser.ts +207 -0
  26. package/src/commands/call.ts +300 -0
  27. package/src/commands/describe.ts +182 -0
  28. package/src/commands/domain/install.ts +420 -0
  29. package/src/commands/domain/list.ts +154 -0
  30. package/src/commands/domain/publish.ts +155 -0
  31. package/src/commands/get.ts +60 -0
  32. package/src/commands/identity/create.ts +26 -0
  33. package/src/commands/identity/delete.ts +18 -0
  34. package/src/commands/identity/export.ts +66 -0
  35. package/src/commands/identity/import.ts +101 -0
  36. package/src/commands/identity/list.ts +56 -0
  37. package/src/commands/identity/register.ts +170 -0
  38. package/src/commands/identity/sync.ts +32 -0
  39. package/src/commands/identity/unsync.ts +24 -0
  40. package/src/commands/identity/use.ts +18 -0
  41. package/src/commands/identity/whoami.ts +34 -0
  42. package/src/commands/idp/add.ts +150 -0
  43. package/src/commands/idp/list.ts +57 -0
  44. package/src/commands/idp/refresh.ts +38 -0
  45. package/src/commands/idp/remove.ts +36 -0
  46. package/src/commands/idp/show.ts +29 -0
  47. package/src/commands/instance/active.ts +64 -0
  48. package/src/commands/instance/bookmark.ts +72 -0
  49. package/src/commands/instance/create.ts +69 -0
  50. package/src/commands/instance/delete.ts +72 -0
  51. package/src/commands/instance/forget.ts +26 -0
  52. package/src/commands/instance/list.ts +149 -0
  53. package/src/commands/instance/status.ts +42 -0
  54. package/src/commands/instance/use.ts +210 -0
  55. package/src/commands/logs.ts +347 -0
  56. package/src/commands/ls.ts +229 -0
  57. package/src/commands/query.ts +32 -0
  58. package/src/commands/setup.ts +54 -0
  59. package/src/commands/status.ts +60 -0
  60. package/src/commands/studio.ts +401 -0
  61. package/src/commands/token.ts +77 -0
  62. package/src/commands/update.ts +267 -0
  63. package/src/commands/use.ts +87 -0
  64. package/src/errors.ts +65 -0
  65. package/src/kernel/__tests__/auth.test.ts +77 -0
  66. package/src/kernel/__tests__/errors.test.ts +43 -0
  67. package/src/kernel/__tests__/remote-routing.test.ts +70 -0
  68. package/src/kernel/auth.ts +234 -0
  69. package/src/kernel/ca-fetch.ts +119 -0
  70. package/src/kernel/client.ts +191 -0
  71. package/src/kernel/errors.ts +280 -0
  72. package/src/kernel/expand.ts +217 -0
  73. package/src/kernel/index.ts +14 -0
  74. package/src/kernel/options.ts +22 -0
  75. package/src/kernel/remote-routing.ts +88 -0
  76. package/src/kernel/run.ts +63 -0
  77. package/src/kernel/types.ts +14 -0
  78. package/src/lib/__tests__/admin-target.test.ts +112 -0
  79. package/src/lib/__tests__/binary.test.ts +56 -0
  80. package/src/lib/__tests__/command-dx.test.ts +58 -0
  81. package/src/lib/__tests__/concurrency.test.ts +62 -0
  82. package/src/lib/__tests__/config.test.ts +53 -0
  83. package/src/lib/__tests__/design.test.ts +99 -0
  84. package/src/lib/__tests__/domain-identity.test.ts +60 -0
  85. package/src/lib/__tests__/format.test.ts +22 -0
  86. package/src/lib/__tests__/fs-atomic.test.ts +104 -0
  87. package/src/lib/__tests__/identity.test.ts +79 -0
  88. package/src/lib/__tests__/idp-session.driver.ts +53 -0
  89. package/src/lib/__tests__/idp-session.test.ts +357 -0
  90. package/src/lib/__tests__/idp.test.ts +385 -0
  91. package/src/lib/__tests__/instance-candidates.test.ts +73 -0
  92. package/src/lib/__tests__/instance-target.test.ts +183 -0
  93. package/src/lib/__tests__/instance.test.ts +136 -0
  94. package/src/lib/__tests__/keys.test.ts +129 -0
  95. package/src/lib/__tests__/local-status.test.ts +202 -0
  96. package/src/lib/__tests__/output.test.ts +150 -0
  97. package/src/lib/__tests__/panel.test.ts +40 -0
  98. package/src/lib/__tests__/port.test.ts +44 -0
  99. package/src/lib/__tests__/prompt.test.ts +25 -0
  100. package/src/lib/__tests__/sdk-deps.test.ts +68 -0
  101. package/src/lib/__tests__/self.test.ts +272 -0
  102. package/src/lib/__tests__/studio-server-deps.test.ts +74 -0
  103. package/src/lib/__tests__/table.test.ts +53 -0
  104. package/src/lib/__tests__/update.test.ts +246 -0
  105. package/src/lib/__tests__/use-target.test.ts +56 -0
  106. package/src/lib/__tests__/validation.test.ts +34 -0
  107. package/src/lib/admin-domain.ts +25 -0
  108. package/src/lib/admin-instance.ts +26 -0
  109. package/src/lib/admin-target.ts +217 -0
  110. package/src/lib/binary.ts +131 -0
  111. package/src/lib/browser.ts +150 -0
  112. package/src/lib/command-dx.ts +161 -0
  113. package/src/lib/concurrency.ts +31 -0
  114. package/src/lib/config.ts +45 -0
  115. package/src/lib/domain-identity.ts +49 -0
  116. package/src/lib/env.ts +49 -0
  117. package/src/lib/format.ts +4 -0
  118. package/src/lib/fs-atomic.ts +126 -0
  119. package/src/lib/identity.ts +256 -0
  120. package/src/lib/idp-session.ts +134 -0
  121. package/src/lib/idp.ts +876 -0
  122. package/src/lib/instance-candidates.ts +49 -0
  123. package/src/lib/instance-target.ts +182 -0
  124. package/src/lib/instance.ts +395 -0
  125. package/src/lib/keys.ts +294 -0
  126. package/src/lib/local-status.ts +152 -0
  127. package/src/lib/log.ts +116 -0
  128. package/src/lib/login-flow.ts +164 -0
  129. package/src/lib/meta.ts +86 -0
  130. package/src/lib/output.ts +222 -0
  131. package/src/lib/panel.ts +61 -0
  132. package/src/lib/paths.ts +11 -0
  133. package/src/lib/port.ts +41 -0
  134. package/src/lib/proc.ts +82 -0
  135. package/src/lib/prompt.ts +136 -0
  136. package/src/lib/provision-instance.ts +170 -0
  137. package/src/lib/sdk-deps.ts +104 -0
  138. package/src/lib/self.ts +166 -0
  139. package/src/lib/skills.ts +171 -0
  140. package/src/lib/table.ts +62 -0
  141. package/src/lib/update.ts +315 -0
  142. package/src/lib/use-target.ts +24 -0
  143. package/src/lib/validation.ts +59 -0
  144. package/src/program.ts +200 -0
  145. package/src/registry.ts +59 -0
  146. package/src/setup/__tests__/util.test.ts +29 -0
  147. package/src/setup/engine.ts +83 -0
  148. package/src/setup/render.ts +109 -0
  149. package/src/setup/steps/admin.ts +78 -0
  150. package/src/setup/steps/agent-browser.ts +81 -0
  151. package/src/setup/steps/auth.ts +54 -0
  152. package/src/setup/steps/domain.ts +68 -0
  153. package/src/setup/steps/index.ts +18 -0
  154. package/src/setup/steps/instance.ts +119 -0
  155. package/src/setup/steps/skills-bridge.ts +70 -0
  156. package/src/setup/steps/skills.ts +59 -0
  157. package/src/setup/types.ts +61 -0
  158. package/src/setup/util.ts +34 -0
  159. package/src/test-utils.ts +18 -0
  160. package/studio/client/dist/assets/index-DOwzZAEK.css +1 -0
  161. package/studio/client/dist/assets/index-wtU0Zxhy.js +183 -0
  162. package/studio/client/dist/index.html +13 -0
  163. package/studio/package.json +62 -0
  164. package/studio/server/agent/ask.ts +68 -0
  165. package/studio/server/agent/bridge-mcp.ts +182 -0
  166. package/studio/server/agent/bridge.ts +188 -0
  167. package/studio/server/agent/claude.ts +666 -0
  168. package/studio/server/agent/mock.ts +186 -0
  169. package/studio/server/agent/prompt.ts +202 -0
  170. package/studio/server/agent/registry.ts +29 -0
  171. package/studio/server/agent/runner.ts +484 -0
  172. package/studio/server/agent/schema-map.ts +112 -0
  173. package/studio/server/agent/types.ts +120 -0
  174. package/studio/server/api.ts +574 -0
  175. package/studio/server/cache.ts +138 -0
  176. package/studio/server/detect.ts +81 -0
  177. package/studio/server/domain.ts +70 -0
  178. package/studio/server/index.ts +136 -0
  179. package/studio/server/introspect/anatomy-extras.ts +398 -0
  180. package/studio/server/introspect/anatomy.ts +108 -0
  181. package/studio/server/introspect/bundle.ts +57 -0
  182. package/studio/server/introspect/core-extractor.ts +119 -0
  183. package/studio/server/introspect/core.ts +44 -0
  184. package/studio/server/introspect/diff.ts +133 -0
  185. package/studio/server/introspect/extractor.ts +102 -0
  186. package/studio/server/introspect/hash.ts +21 -0
  187. package/studio/server/introspect/overlay-tsmorph.ts +874 -0
  188. package/studio/server/introspect/overlay.ts +57 -0
  189. package/studio/server/introspect/runtime.ts +99 -0
  190. package/studio/server/introspect/schema-refs.ts +46 -0
  191. package/studio/server/lifecycle.ts +38 -0
  192. package/studio/server/sse.ts +57 -0
  193. package/studio/server/state/baseline.ts +211 -0
  194. package/studio/server/state/catalog.ts +117 -0
  195. package/studio/server/state/comments.ts +321 -0
  196. package/studio/server/state/context.ts +167 -0
  197. package/studio/server/state/copy.ts +156 -0
  198. package/studio/server/state/create.ts +156 -0
  199. package/studio/server/state/documents.ts +70 -0
  200. package/studio/server/state/env.ts +161 -0
  201. package/studio/server/state/git.ts +75 -0
  202. package/studio/server/state/handoff.ts +55 -0
  203. package/studio/server/state/harness-gateway.ts +181 -0
  204. package/studio/server/state/harness-token.ts +0 -0
  205. package/studio/server/state/instance.ts +244 -0
  206. package/studio/server/state/integrations.ts +55 -0
  207. package/studio/server/state/layout.ts +55 -0
  208. package/studio/server/state/settings.ts +39 -0
  209. package/studio/server/state/store.ts +97 -0
  210. package/studio/server/state/updates.ts +63 -0
  211. package/studio/server/state/usage.ts +37 -0
  212. package/studio/server/state/views.ts +138 -0
  213. package/studio/server/state/visibility.ts +33 -0
  214. package/studio/server/watch.ts +81 -0
  215. package/studio/server/workspace-state.ts +26 -0
  216. package/studio/server/workspace-watch.ts +101 -0
  217. package/studio/shared/types.ts +873 -0
  218. package/studio/tsconfig.json +23 -0
  219. package/tsconfig.json +14 -0
@@ -0,0 +1,484 @@
1
+ /**
2
+ * agent/runner.ts — orchestrates one live agent turn per domain.
3
+ *
4
+ * submit → gather the threads awaiting a reply + current changes + context →
5
+ * scaffold the prompt → run the harness (streaming activity to SSE) → on finish,
6
+ * merge the agent's machine-state reply block back into comments.json and persist
7
+ * the session id so the next turn resumes the same conversation. The agent's file
8
+ * edits flow through the existing watch→re-render path with no extra plumbing.
9
+ *
10
+ * One run at a time per domain; the live run is held in memory and mirrored to
11
+ * the client over SSE, then a compact transcript is written under
12
+ * `.domain-studio/.cache/agent/` (ignored, never pollutes committed state).
13
+ */
14
+ import { randomUUID } from 'node:crypto'
15
+
16
+ import type {
17
+ AgentEvent,
18
+ AgentPromptSnapshot,
19
+ AgentRun,
20
+ AgentRunSnapshot,
21
+ Comment,
22
+ ConversationInfo,
23
+ StudioEvent,
24
+ } from '../../shared/types'
25
+
26
+ import { getBundle } from '../cache'
27
+ import { type DomainHandle, getDomain } from '../domain'
28
+ import { mergeReply, readComments } from '../state/comments'
29
+ import { readContext } from '../state/context'
30
+ import { listDocuments } from '../state/documents'
31
+ import { refreshAuto } from '../state/handoff'
32
+ import { resolveHarnessEnv } from '../state/harness-gateway'
33
+ import { readSettings } from '../state/settings'
34
+ import { readJson, removeState, writeJson } from '../state/store'
35
+ import { recordRun } from '../state/usage'
36
+ import { startBridge } from './bridge'
37
+ import { buildResumePrompt, buildSystemPrompt, buildTurnPrompt } from './prompt'
38
+ import { getHarness } from './registry'
39
+
40
+ type Notify = (e: StudioEvent) => void
41
+
42
+ const runs = new Map<string, AgentRun>()
43
+ const controllers = new Map<string, AbortController>()
44
+ /** domains whose run is being SET UP — a synchronous reservation that closes the
45
+ * check-then-set race between isRunning() and runs.set() (which span awaits). */
46
+ const starting = new Set<string>()
47
+ /** domains whose last-run has been rehydrated from disk this process (once). */
48
+ const hydrated = new Set<string>()
49
+
50
+ const SESSION_FILE = '.cache/agent/session.json'
51
+ /** A stable pointer to the latest run, written on START and at terminal, so a fresh
52
+ * process can show the last run (and reconcile one orphaned by a crash). */
53
+ const LAST_RUN_FILE = '.cache/agent/last-run.json'
54
+ const runFile = (id: string) => `.cache/agent/runs/${id}.json`
55
+ const MCP_TOOLS = [
56
+ 'list_open_threads',
57
+ 'reply_to_thread',
58
+ 'resolve_thread',
59
+ 'post_progress',
60
+ 'raise_question',
61
+ ]
62
+
63
+ /** The persisted conversation handle: one resumable harness session per domain,
64
+ * with a turn counter so the UI can show "N turns" and resume continuity. */
65
+ interface SessionState {
66
+ harness?: string
67
+ sessionId?: string
68
+ turns?: number
69
+ updatedAt?: string
70
+ }
71
+ const readSession = (root: string): SessionState => readJson<SessionState>(root, SESSION_FILE, {})
72
+
73
+ /** Persist the run to disk (best-effort). `transcript` also writes the per-id record. */
74
+ function persistRun(root: string, run: AgentRun, transcript = false): void {
75
+ try {
76
+ writeJson(root, LAST_RUN_FILE, run)
77
+ if (transcript) writeJson(root, runFile(run.id), run)
78
+ } catch {
79
+ /* transcript is best-effort — never let it break a run */
80
+ }
81
+ }
82
+
83
+ /** On a fresh process the in-memory run map is empty. Seed the latest run from disk
84
+ * so the drawer shows it after a studio restart — and if that run was still
85
+ * `running`/`queued`, its agent died with the old process, so reconcile it to
86
+ * `interrupted` (an honest terminal state) instead of a perpetual spinner. */
87
+ function hydrate(domainId: string, root: string): void {
88
+ if (hydrated.has(domainId) || runs.has(domainId)) return
89
+ hydrated.add(domainId)
90
+ const last = readJson<AgentRun | null>(root, LAST_RUN_FILE, null)
91
+ if (!last || last.domainId !== domainId) return
92
+ if (last.status === 'running' || last.status === 'queued') {
93
+ last.status = 'interrupted'
94
+ last.finishedAt = last.finishedAt ?? new Date().toISOString()
95
+ last.error =
96
+ 'the studio restarted during this turn — your conversation is preserved; submit again to continue'
97
+ persistRun(root, last)
98
+ }
99
+ runs.set(domainId, last)
100
+ }
101
+
102
+ /** The resumable-conversation summary for the snapshot. */
103
+ function conversationOf(root: string): ConversationInfo {
104
+ const s = readSession(root)
105
+ const harness = getHarness()
106
+ return {
107
+ active: s.harness === harness.id && !!s.sessionId,
108
+ turns: s.turns ?? 0,
109
+ harness: s.harness,
110
+ }
111
+ }
112
+
113
+ /** Open threads whose last entry is NOT the agent — the ones it owes a reply. */
114
+ function awaitingThreads(root: string): Comment[] {
115
+ return readComments(root).comments.filter(
116
+ (c) => c.status === 'open' && c.thread.at(-1)?.role !== 'author',
117
+ )
118
+ }
119
+
120
+ /** Hide the machine-state reply block from the activity log (it's the wire protocol,
121
+ * not user-facing — the replies land in the threads). Other code fences are kept. */
122
+ function stripMachineState(text: string): string {
123
+ return text
124
+ .replace(/```(?:json)?\s*[\s\S]*?```/g, (m) => (/"comments"|"schemaVersion"/.test(m) ? '' : m))
125
+ .trim()
126
+ }
127
+
128
+ export function isRunning(domainId: string): boolean {
129
+ const s = runs.get(domainId)?.status
130
+ return s === 'running' || s === 'queued'
131
+ }
132
+
133
+ export async function getSnapshot(domainId: string): Promise<AgentRunSnapshot> {
134
+ const harness = getHarness()
135
+ const handle = getDomain(domainId)
136
+ if (handle) hydrate(domainId, handle.root)
137
+ const conversation = handle ? conversationOf(handle.root) : { active: false, turns: 0 }
138
+ return {
139
+ harness: harness.id,
140
+ available: await harness.isAvailable(),
141
+ run: runs.get(domainId) ?? null,
142
+ conversation,
143
+ }
144
+ }
145
+
146
+ export function cancelRun(domainId: string): boolean {
147
+ const c = controllers.get(domainId)
148
+ if (!c) return false
149
+ c.abort()
150
+ return true
151
+ }
152
+
153
+ /** The forkable conversation session id for this domain (current harness only).
154
+ * Undefined when there's no conversation yet — an Ask then runs fresh (no fork).
155
+ * Read-only: never mutates session.json, so it's safe to call while a run is live. */
156
+ export function forkableSession(domainId: string): string | undefined {
157
+ const handle = getDomain(domainId)
158
+ if (!handle) return undefined
159
+ const s = readSession(handle.root)
160
+ return s.harness === getHarness().id ? s.sessionId : undefined
161
+ }
162
+
163
+ /** Forget the resumable conversation so the NEXT submit starts a brand-new session.
164
+ * Refused mid-run (the live turn owns the session). Returns false if unknown. */
165
+ export function resetConversation(domainId: string): boolean {
166
+ const handle = getDomain(domainId)
167
+ if (!handle) return false
168
+ if (isRunning(domainId)) return false
169
+ removeState(handle.root, SESSION_FILE)
170
+ return true
171
+ }
172
+
173
+ /** The raw resumable session id (+ turn count / harness), for viewing in Settings.
174
+ * Read-only: safe to call while a run is live. */
175
+ export function getSessionId(domainId: string): {
176
+ sessionId: string | null
177
+ turns: number
178
+ harness?: string
179
+ } {
180
+ const handle = getDomain(domainId)
181
+ if (!handle) return { sessionId: null, turns: 0 }
182
+ const s = readSession(handle.root)
183
+ return { sessionId: s.sessionId ?? null, turns: s.turns ?? 0, harness: s.harness }
184
+ }
185
+
186
+ /** Overwrite (or clear) the resumable session id by hand. Empty ⇒ forget the
187
+ * conversation (next submit starts fresh). Refused mid-run (the live turn owns it). */
188
+ export function setSessionId(domainId: string, sessionId: string): boolean {
189
+ const handle = getDomain(domainId)
190
+ if (!handle) return false
191
+ if (isRunning(domainId)) return false
192
+ const trimmed = sessionId.trim()
193
+ if (!trimmed) {
194
+ removeState(handle.root, SESSION_FILE)
195
+ return true
196
+ }
197
+ const prev = readSession(handle.root)
198
+ writeJson(handle.root, SESSION_FILE, {
199
+ harness: prev.harness ?? getHarness().id,
200
+ sessionId: trimmed,
201
+ turns: prev.turns ?? 0,
202
+ updatedAt: new Date().toISOString(),
203
+ })
204
+ return true
205
+ }
206
+
207
+ /** What a submit carries: an optional typed instruction, or a bare `resume` — a
208
+ * seamless "continue where you left off" after an interruption (no re-briefing). */
209
+ export interface SubmitOpts {
210
+ message?: string
211
+ resume?: boolean
212
+ }
213
+
214
+ export async function submitRun(
215
+ handle: DomainHandle,
216
+ notify: Notify,
217
+ opts?: SubmitOpts,
218
+ ): Promise<{ run?: AgentRun; error?: string }> {
219
+ const domainId = handle.id
220
+ // Reserve the slot SYNCHRONOUSLY (before any await) so two concurrent submits
221
+ // — a double-click / retry — can't both pass the gate and start two runs.
222
+ if (isRunning(domainId) || starting.has(domainId))
223
+ return { error: 'an agent run is already in progress for this domain' }
224
+ starting.add(domainId)
225
+ try {
226
+ return await startRun(handle, notify, opts)
227
+ } finally {
228
+ starting.delete(domainId)
229
+ }
230
+ }
231
+
232
+ async function startRun(
233
+ handle: DomainHandle,
234
+ notify: Notify,
235
+ opts?: SubmitOpts,
236
+ ): Promise<{ run?: AgentRun; error?: string }> {
237
+ const domainId = handle.id
238
+ const root = handle.root
239
+
240
+ const harness = getHarness()
241
+ if (!(await harness.isAvailable()))
242
+ return { error: `${harness.label} is not available on this machine` }
243
+
244
+ const session = readSession(root)
245
+ const resume = session.harness === harness.id ? session.sessionId : undefined
246
+ // A bare resume only continues an EXISTING session — without one there's nothing
247
+ // in the agent's memory to pick up, so a nudge alone would be useless. When the
248
+ // caller asks to resume but no session survives, fall through to a normal full turn.
249
+ const bareResume = opts?.resume === true && !!resume
250
+
251
+ const awaiting = awaitingThreads(root)
252
+ const msg = (opts?.message ?? '').trim()
253
+ if (!bareResume && awaiting.length === 0 && !msg)
254
+ return { error: 'nothing to send — type an instruction or open a thread' }
255
+
256
+ // refresh the auto-context digests on disk first, so "read these files" holds
257
+ await refreshAuto(handle).catch(() => {})
258
+
259
+ const bundle = await getBundle(domainId)
260
+ const schemaHash = bundle?.schemaHash ?? ''
261
+ const ctx = readContext(root)
262
+ const documents = listDocuments(root)
263
+ const settings = readSettings(root)
264
+ // custom model-gateway env (ANTHROPIC_*), injected into the harness child only.
265
+ // Resolved up-front so a token failure fails the submit cleanly (rather than
266
+ // silently spawning on the default Claude auth).
267
+ const envResult = await resolveHarnessEnv(root)
268
+ if (!envResult.ok) return { error: `model gateway auth failed — ${envResult.error}` }
269
+ const harnessEnv = envResult.env
270
+
271
+ // per-run write-back bridge (token-scoped MCP tools); harmless if the harness ignores it
272
+ const bridge = startBridge(handle, () => runs.get(domainId)?.id ?? '', notify)
273
+
274
+ // Build the turn prompt for a given continuity. Reused when a rejected resume forces
275
+ // a fresh restart — the header/orientation differ between "resume" and "new session".
276
+ // A bare resume sends just the nudge (the live session still holds everything); but a
277
+ // FRESH start (firstTurn — incl. the resume-rejected fallback) must carry full context.
278
+ const makeTurn = (firstTurn: boolean) =>
279
+ bareResume && !firstTurn
280
+ ? buildResumePrompt()
281
+ : buildTurnPrompt({
282
+ origin: bundle?.overlay.origin ?? domainId,
283
+ root,
284
+ schemaHash,
285
+ awaitingThreads: awaiting,
286
+ userContext: ctx.user,
287
+ autoContext: ctx.auto.filter((a) => a.includeInHandoff),
288
+ documents,
289
+ firstTurn,
290
+ message: msg,
291
+ ir: bundle?.ir ?? null,
292
+ overlay: bundle?.overlay,
293
+ })
294
+ const system = buildSystemPrompt({ bridge: bridge.enabled })
295
+ const promptSnapshot = (
296
+ sessionId: string | undefined,
297
+ firstTurn: boolean,
298
+ ): AgentPromptSnapshot => ({
299
+ createdAt: new Date().toISOString(),
300
+ systemPrompt: system,
301
+ turnPrompt: makeTurn(firstTurn),
302
+ firstTurn,
303
+ resumed: !!sessionId,
304
+ sessionId,
305
+ effort: settings.agentEffort,
306
+ mcpTools: bridge.enabled ? MCP_TOOLS : [],
307
+ })
308
+
309
+ const run: AgentRun = {
310
+ id: randomUUID(),
311
+ domainId,
312
+ harness: harness.id,
313
+ status: 'running',
314
+ createdAt: new Date().toISOString(),
315
+ summary: bareResume
316
+ ? 'continuing after interruption'
317
+ : msg
318
+ ? msg.slice(0, 60) + (msg.length > 60 ? '…' : '')
319
+ : awaiting.length === 1
320
+ ? '1 open thread'
321
+ : `${awaiting.length} open threads`,
322
+ targetCommentIds: awaiting.map((c) => c.id),
323
+ events: [],
324
+ sessionId: resume,
325
+ resumed: !!resume,
326
+ prompt: promptSnapshot(resume, !resume),
327
+ }
328
+ runs.set(domainId, run)
329
+ persistRun(root, run) // record on START so a crash mid-turn leaves a reconcilable trace
330
+ const controller = new AbortController()
331
+ controllers.set(domainId, controller)
332
+ notify({ type: 'agent-run', domainId, run })
333
+
334
+ const pushEvent = (e: Omit<AgentEvent, 'id' | 'ts'>) => {
335
+ let text = e.text
336
+ if (e.kind === 'message') {
337
+ text = stripMachineState(text)
338
+ if (!text) return // the message was ONLY the machine-state reply block
339
+ }
340
+ const ev: AgentEvent = { id: randomUUID(), ts: new Date().toISOString(), ...e, text }
341
+ run.events.push(ev)
342
+ notify({ type: 'agent-event', domainId, runId: run.id, event: ev })
343
+ }
344
+ let bridgeReplies = 0
345
+ // commentId → texts the agent already posted LIVE this run, so the end-of-turn
346
+ // merge can skip exactly those (and nothing else) without duplicating.
347
+ const liveByComment = new Map<string, Set<string>>()
348
+ bridge.onReply((commentId, text) => {
349
+ bridgeReplies += 1
350
+ if (!liveByComment.has(commentId)) liveByComment.set(commentId, new Set())
351
+ liveByComment.get(commentId)!.add(text.trim())
352
+ pushEvent({ kind: 'reply', text, commentId })
353
+ })
354
+ bridge.onProgress((text) => pushEvent({ kind: 'status', text }))
355
+
356
+ // fire-and-forget; the HTTP response returns the running run immediately
357
+ void (async () => {
358
+ try {
359
+ // run one harness turn with the given continuity
360
+ const runTurn = (sessionId: string | undefined, firstTurn: boolean) => {
361
+ const prompt = promptSnapshot(sessionId, firstTurn)
362
+ run.prompt = prompt
363
+ notify({ type: 'agent-run', domainId, run })
364
+ return harness.run({
365
+ root,
366
+ prompt: prompt.turnPrompt,
367
+ appendSystemPrompt: prompt.systemPrompt,
368
+ sessionId,
369
+ effort: settings.agentEffort,
370
+ mcpConfigPath: bridge.mcpConfigPath,
371
+ env: harnessEnv,
372
+ signal: controller.signal,
373
+ onEvent: pushEvent,
374
+ })
375
+ }
376
+
377
+ let result = await runTurn(resume, !resume)
378
+ let convoTurns = resume ? (session.turns ?? 0) : 0
379
+
380
+ // Auto-recover a rejected resume: the stored session is gone on the harness side
381
+ // (pruned/expired). Drop it and transparently re-run the SAME work as a NEW
382
+ // conversation so the user's turn still lands instead of dead-ending on a stale id.
383
+ if (resume && result.resumeRejected && !controller.signal.aborted) {
384
+ removeState(root, SESSION_FILE)
385
+ convoTurns = 0
386
+ run.sessionId = undefined
387
+ run.resumed = false
388
+ pushEvent({
389
+ kind: 'status',
390
+ text: 'previous conversation was no longer available — started a new one',
391
+ })
392
+ result = await runTurn(undefined, true)
393
+ }
394
+
395
+ run.sessionId = result.sessionId ?? run.sessionId
396
+ run.costUsd = result.costUsd
397
+ run.tokens = result.tokens
398
+ run.numTurns = result.numTurns
399
+ run.liveReplies = bridgeReplies
400
+
401
+ if (bridgeReplies > 0 && !controller.signal.aborted)
402
+ pushEvent({
403
+ kind: 'status',
404
+ text: `replied to ${bridgeReplies} thread${bridgeReplies === 1 ? '' : 's'} live`,
405
+ })
406
+
407
+ // Merge the agent's end-of-turn machine-state block. ALWAYS run it on a clean
408
+ // turn (so threads answered only in the block still land); dedupe is scoped to
409
+ // this run's live replies. SKIP it when the turn was canceled (re-checked LIVE,
410
+ // not a stale snapshot) or the harness errored — neither should apply a
411
+ // partial/untrusted answer or close threads.
412
+ let replyError: string | undefined
413
+ if (
414
+ !controller.signal.aborted &&
415
+ !result.isError &&
416
+ result.finalText &&
417
+ result.finalText.trim()
418
+ ) {
419
+ try {
420
+ const merged = mergeReply(root, schemaHash, result.finalText, {
421
+ skipByComment: liveByComment,
422
+ })
423
+ run.merge = merged
424
+ if (merged.merged || merged.closed)
425
+ pushEvent({
426
+ kind: 'status',
427
+ text: `merged ${merged.merged} repl${merged.merged === 1 ? 'y' : 'ies'}, closed ${merged.closed}`,
428
+ })
429
+ notify({ type: 'comments', domainId })
430
+ } catch (e: any) {
431
+ // a MALFORMED block is real data loss → fail the run so it's visible; an
432
+ // ABSENT block is benign (the agent used the bridge or had nothing to say).
433
+ if (/```json/i.test(result.finalText)) {
434
+ replyError = `agent reply block was malformed JSON — reply not merged (${String(e?.message ?? e).slice(0, 80)})`
435
+ pushEvent({ kind: 'error', text: replyError })
436
+ } else if (bridgeReplies === 0) {
437
+ pushEvent({ kind: 'status', text: 'no machine-state reply block in the final message' })
438
+ }
439
+ }
440
+ }
441
+
442
+ if (controller.signal.aborted) run.status = 'canceled'
443
+ else if (result.isError) {
444
+ run.status = 'failed'
445
+ run.error = result.errorMessage
446
+ pushEvent({ kind: 'error', text: result.errorMessage ?? 'agent error' })
447
+ } else if (replyError) {
448
+ run.status = 'failed'
449
+ run.error = replyError
450
+ } else run.status = 'succeeded'
451
+
452
+ // Session persistence. Keep the conversation across an UNRELATED transient
453
+ // failure (rate limit, a typecheck slip, a cancel) so the user can just resume —
454
+ // only a genuinely rejected resume drops the id, and that was already handled
455
+ // above (with a fresh restart). On success, persist the id and bump the turn
456
+ // count; otherwise leave the stored session exactly as it was.
457
+ if (run.status === 'succeeded' && result.sessionId)
458
+ writeJson(root, SESSION_FILE, {
459
+ harness: harness.id,
460
+ sessionId: result.sessionId,
461
+ turns: convoTurns + 1,
462
+ updatedAt: new Date().toISOString(),
463
+ })
464
+ } catch (e: any) {
465
+ run.status = controller.signal.aborted ? 'canceled' : 'failed'
466
+ run.error = String(e?.message ?? e)
467
+ pushEvent({ kind: 'error', text: run.error })
468
+ // Keep the stored session — an unexpected throw is not proof the conversation is
469
+ // dead. The next submit resumes it; a genuinely dead id self-heals via the
470
+ // resumeRejected → fresh-restart path above.
471
+ } finally {
472
+ run.finishedAt = new Date().toISOString()
473
+ // only clear the controller if it is still OURS (defensive against overwrite)
474
+ if (controllers.get(domainId) === controller) controllers.delete(domainId)
475
+ bridge.dispose()
476
+ recordRun(root, run) // fold this turn's tokens/cost into the domain's running total
477
+ persistRun(root, run, true) // final state → both the latest-run pointer and the transcript
478
+ notify({ type: 'agent-run', domainId, run })
479
+ notify({ type: 'comments', domainId })
480
+ }
481
+ })()
482
+
483
+ return { run }
484
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * agent/schema-map.ts — focused anchor descriptions for thread and Ask prompts.
3
+ * The whole-domain schema map is intentionally not embedded in agent turns; the
4
+ * agent reads schema/ directly when it needs broader context.
5
+ */
6
+ import type { Comment, IrMethod, JsonSchema, SchemaIR, SchemaOverlay } from '../../shared/types'
7
+
8
+ /** Terse JSON-Schema type label (mirrors the client's format.tsx describe/typeLabel). */
9
+ function propType(s: JsonSchema | undefined): string {
10
+ if (!s) return 'any'
11
+ const t = s.type
12
+ const optional = Array.isArray(t) ? t.includes('null') : false
13
+ const base = Array.isArray(t) ? t.find((x) => x !== 'null') : t
14
+ let label: string
15
+ if (s.enum) label = `enum(${s.enum.map(String).join('|')})`
16
+ else if (s.$nodeRef) label = '→node'
17
+ else if (s.$dataRef) label = '→data'
18
+ else if (base === 'array') label = `${propType(s.items)}[]`
19
+ else if (base === 'object') label = `{${Object.keys(s.properties ?? {}).join(',')}}`
20
+ else if (base === 'integer') label = 'int'
21
+ else if (typeof base === 'string') label = base
22
+ else label = 'any'
23
+ return label + (optional ? '?' : '')
24
+ }
25
+
26
+ function methodSig(name: string, m: IrMethod): string {
27
+ const params = Object.entries(m.params ?? {})
28
+ .map(([p, s]) => `${p}:${propType(s)}`)
29
+ .join(', ')
30
+ const tags = [m.static ? 'static' : '', m.inheritance === 'abstract' ? 'abstract' : ''].filter(
31
+ Boolean,
32
+ )
33
+ return `${name}(${params})→${propType(m.returns)}${tags.length ? ` [${tags.join(',')}]` : ''}`
34
+ }
35
+
36
+ /**
37
+ * A focused, compact description of ONE anchor target — the element the user is
38
+ * asking about. Used to inject context into an Ask side-question prompt. Returns
39
+ * a short block (signature + file:line + doc) or just the location for non-code
40
+ * anchors. Empty string when nothing can be resolved.
41
+ */
42
+ export function describeAnchor(
43
+ ref: string,
44
+ ir: SchemaIR | null,
45
+ overlay: SchemaOverlay | undefined,
46
+ ): string {
47
+ const span = overlay?.sourceSpans[ref]
48
+ const loc = span ? `${span.file}:${span.startLine}` : ''
49
+ const doc = span?.doc ? ` — ${span.doc.replace(/\s+/g, ' ').trim().slice(0, 160)}` : ''
50
+
51
+ // class.X.property.y / class.X.method.m
52
+ const member = ref.match(/^class\.([^.]+)\.(property|method)\.(.+)$/)
53
+ if (member && ir) {
54
+ const [, cls, kind, name] = member
55
+ const c = ir.classes?.[cls]
56
+ if (c && kind === 'property' && c.properties?.[name])
57
+ return `**${cls}.${name}** : ${propType(c.properties[name])}${loc ? ` (${loc})` : ''}${doc}`
58
+ if (c && kind === 'method' && c.methods?.[name])
59
+ return `**${cls}.${name}** — ${methodSig(name, c.methods[name])}${loc ? ` (${loc})` : ''}${doc}`
60
+ }
61
+
62
+ // class.X / edge.X (edges live in ir.classes too)
63
+ const cm = ref.match(/^(?:class|edge)\.(.+)$/)
64
+ if (cm && ir) {
65
+ const c = ir.classes?.[cm[1]]
66
+ if (c) {
67
+ const L = [`**${c.name}** (${c.type})${loc ? ` (${loc})` : ''}${doc}`]
68
+ const props = Object.entries(c.properties ?? {})
69
+ if (props.length)
70
+ L.push(` props: ${props.map(([p, s]) => `${p}:${propType(s)}`).join(' · ')}`)
71
+ const ms = Object.entries(c.methods ?? {})
72
+ if (ms.length) L.push(` methods: ${ms.map(([n, m]) => methodSig(n, m)).join(' · ')}`)
73
+ if (c.type === 'edge' && c.endpoints?.length)
74
+ L.push(` endpoints: ${c.endpoints.map((e) => e.types?.join('|') || e.name).join(' → ')}`)
75
+ return L.join('\n')
76
+ }
77
+ }
78
+
79
+ // interface.X
80
+ const im = ref.match(/^interface\.(.+)$/)
81
+ if (im && ir) {
82
+ const i = ir.interfaces?.[im[1]]
83
+ if (i) {
84
+ const ms = Object.entries(i.methods ?? {}).map(([n, m]) => methodSig(n, m))
85
+ return `**${i.name}** (interface)${loc ? ` (${loc})` : ''}${doc}${ms.length ? `\n methods: ${ms.join(' · ')}` : ''}`
86
+ }
87
+ }
88
+
89
+ // module / section / file / free — not a specific code element
90
+ if (loc) return `\`${ref}\` (${loc})${doc}`
91
+ return ''
92
+ }
93
+
94
+ /** For each thread, resolve its anchor to a concrete code location + the element's own doc. */
95
+ export function resolveThreadAnchors(
96
+ threads: Comment[],
97
+ overlay: SchemaOverlay | undefined,
98
+ ): string {
99
+ const lines: string[] = []
100
+ threads.forEach((c, i) => {
101
+ const a = c.anchorRefs?.[0]
102
+ if (!a) return
103
+ const span = overlay?.sourceSpans[a.ref]
104
+ const loc = span
105
+ ? `${span.file}:${span.startLine}${span.doc ? ` — ${span.doc.replace(/\s+/g, ' ').trim().slice(0, 100)}` : ''}`
106
+ : a.file
107
+ ? a.file
108
+ : '(section / free-text anchor — not a specific code element)'
109
+ lines.push(` ${i + 1}. \`${a.ref}\` → ${loc}`)
110
+ })
111
+ return lines.length ? `## Where the open threads point\n${lines.join('\n')}` : ''
112
+ }