@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.
- package/.check-workspace.cjs +40 -0
- package/README.md +151 -0
- package/dist/astrale.js +59749 -0
- package/package.json +90 -0
- package/src/command.ts +40 -0
- package/src/commands/__tests__/admin-instance.test.ts +73 -0
- package/src/commands/__tests__/auth-login.test.ts +178 -0
- package/src/commands/__tests__/auth-token.test.ts +223 -0
- package/src/commands/__tests__/call.test.ts +72 -0
- package/src/commands/__tests__/domain-list.test.ts +74 -0
- package/src/commands/__tests__/help-contract.test.ts +136 -0
- package/src/commands/__tests__/install-identity-override.test.ts +65 -0
- package/src/commands/__tests__/instance-bookmark.test.ts +101 -0
- package/src/commands/__tests__/instance-create-hosts.test.ts +29 -0
- package/src/commands/__tests__/instance-list-rows.test.ts +63 -0
- package/src/commands/__tests__/logs.test.ts +117 -0
- package/src/commands/__tests__/ls.test.ts +25 -0
- package/src/commands/__tests__/setup-plan.test.ts +61 -0
- package/src/commands/admin/status.ts +61 -0
- package/src/commands/admin/use.ts +77 -0
- package/src/commands/auth/login.ts +82 -0
- package/src/commands/auth/logout.ts +50 -0
- package/src/commands/auth/status.ts +86 -0
- package/src/commands/auth/token.ts +162 -0
- package/src/commands/browser.ts +207 -0
- package/src/commands/call.ts +300 -0
- package/src/commands/describe.ts +182 -0
- package/src/commands/domain/install.ts +420 -0
- package/src/commands/domain/list.ts +154 -0
- package/src/commands/domain/publish.ts +155 -0
- package/src/commands/get.ts +60 -0
- package/src/commands/identity/create.ts +26 -0
- package/src/commands/identity/delete.ts +18 -0
- package/src/commands/identity/export.ts +66 -0
- package/src/commands/identity/import.ts +101 -0
- package/src/commands/identity/list.ts +56 -0
- package/src/commands/identity/register.ts +170 -0
- package/src/commands/identity/sync.ts +32 -0
- package/src/commands/identity/unsync.ts +24 -0
- package/src/commands/identity/use.ts +18 -0
- package/src/commands/identity/whoami.ts +34 -0
- package/src/commands/idp/add.ts +150 -0
- package/src/commands/idp/list.ts +57 -0
- package/src/commands/idp/refresh.ts +38 -0
- package/src/commands/idp/remove.ts +36 -0
- package/src/commands/idp/show.ts +29 -0
- package/src/commands/instance/active.ts +64 -0
- package/src/commands/instance/bookmark.ts +72 -0
- package/src/commands/instance/create.ts +69 -0
- package/src/commands/instance/delete.ts +72 -0
- package/src/commands/instance/forget.ts +26 -0
- package/src/commands/instance/list.ts +149 -0
- package/src/commands/instance/status.ts +42 -0
- package/src/commands/instance/use.ts +210 -0
- package/src/commands/logs.ts +347 -0
- package/src/commands/ls.ts +229 -0
- package/src/commands/query.ts +32 -0
- package/src/commands/setup.ts +54 -0
- package/src/commands/status.ts +60 -0
- package/src/commands/studio.ts +401 -0
- package/src/commands/token.ts +77 -0
- package/src/commands/update.ts +267 -0
- package/src/commands/use.ts +87 -0
- package/src/errors.ts +65 -0
- package/src/kernel/__tests__/auth.test.ts +77 -0
- package/src/kernel/__tests__/errors.test.ts +43 -0
- package/src/kernel/__tests__/remote-routing.test.ts +70 -0
- package/src/kernel/auth.ts +234 -0
- package/src/kernel/ca-fetch.ts +119 -0
- package/src/kernel/client.ts +191 -0
- package/src/kernel/errors.ts +280 -0
- package/src/kernel/expand.ts +217 -0
- package/src/kernel/index.ts +14 -0
- package/src/kernel/options.ts +22 -0
- package/src/kernel/remote-routing.ts +88 -0
- package/src/kernel/run.ts +63 -0
- package/src/kernel/types.ts +14 -0
- package/src/lib/__tests__/admin-target.test.ts +112 -0
- package/src/lib/__tests__/binary.test.ts +56 -0
- package/src/lib/__tests__/command-dx.test.ts +58 -0
- package/src/lib/__tests__/concurrency.test.ts +62 -0
- package/src/lib/__tests__/config.test.ts +53 -0
- package/src/lib/__tests__/design.test.ts +99 -0
- package/src/lib/__tests__/domain-identity.test.ts +60 -0
- package/src/lib/__tests__/format.test.ts +22 -0
- package/src/lib/__tests__/fs-atomic.test.ts +104 -0
- package/src/lib/__tests__/identity.test.ts +79 -0
- package/src/lib/__tests__/idp-session.driver.ts +53 -0
- package/src/lib/__tests__/idp-session.test.ts +357 -0
- package/src/lib/__tests__/idp.test.ts +385 -0
- package/src/lib/__tests__/instance-candidates.test.ts +73 -0
- package/src/lib/__tests__/instance-target.test.ts +183 -0
- package/src/lib/__tests__/instance.test.ts +136 -0
- package/src/lib/__tests__/keys.test.ts +129 -0
- package/src/lib/__tests__/local-status.test.ts +202 -0
- package/src/lib/__tests__/output.test.ts +150 -0
- package/src/lib/__tests__/panel.test.ts +40 -0
- package/src/lib/__tests__/port.test.ts +44 -0
- package/src/lib/__tests__/prompt.test.ts +25 -0
- package/src/lib/__tests__/sdk-deps.test.ts +68 -0
- package/src/lib/__tests__/self.test.ts +272 -0
- package/src/lib/__tests__/studio-server-deps.test.ts +74 -0
- package/src/lib/__tests__/table.test.ts +53 -0
- package/src/lib/__tests__/update.test.ts +246 -0
- package/src/lib/__tests__/use-target.test.ts +56 -0
- package/src/lib/__tests__/validation.test.ts +34 -0
- package/src/lib/admin-domain.ts +25 -0
- package/src/lib/admin-instance.ts +26 -0
- package/src/lib/admin-target.ts +217 -0
- package/src/lib/binary.ts +131 -0
- package/src/lib/browser.ts +150 -0
- package/src/lib/command-dx.ts +161 -0
- package/src/lib/concurrency.ts +31 -0
- package/src/lib/config.ts +45 -0
- package/src/lib/domain-identity.ts +49 -0
- package/src/lib/env.ts +49 -0
- package/src/lib/format.ts +4 -0
- package/src/lib/fs-atomic.ts +126 -0
- package/src/lib/identity.ts +256 -0
- package/src/lib/idp-session.ts +134 -0
- package/src/lib/idp.ts +876 -0
- package/src/lib/instance-candidates.ts +49 -0
- package/src/lib/instance-target.ts +182 -0
- package/src/lib/instance.ts +395 -0
- package/src/lib/keys.ts +294 -0
- package/src/lib/local-status.ts +152 -0
- package/src/lib/log.ts +116 -0
- package/src/lib/login-flow.ts +164 -0
- package/src/lib/meta.ts +86 -0
- package/src/lib/output.ts +222 -0
- package/src/lib/panel.ts +61 -0
- package/src/lib/paths.ts +11 -0
- package/src/lib/port.ts +41 -0
- package/src/lib/proc.ts +82 -0
- package/src/lib/prompt.ts +136 -0
- package/src/lib/provision-instance.ts +170 -0
- package/src/lib/sdk-deps.ts +104 -0
- package/src/lib/self.ts +166 -0
- package/src/lib/skills.ts +171 -0
- package/src/lib/table.ts +62 -0
- package/src/lib/update.ts +315 -0
- package/src/lib/use-target.ts +24 -0
- package/src/lib/validation.ts +59 -0
- package/src/program.ts +200 -0
- package/src/registry.ts +59 -0
- package/src/setup/__tests__/util.test.ts +29 -0
- package/src/setup/engine.ts +83 -0
- package/src/setup/render.ts +109 -0
- package/src/setup/steps/admin.ts +78 -0
- package/src/setup/steps/agent-browser.ts +81 -0
- package/src/setup/steps/auth.ts +54 -0
- package/src/setup/steps/domain.ts +68 -0
- package/src/setup/steps/index.ts +18 -0
- package/src/setup/steps/instance.ts +119 -0
- package/src/setup/steps/skills-bridge.ts +70 -0
- package/src/setup/steps/skills.ts +59 -0
- package/src/setup/types.ts +61 -0
- package/src/setup/util.ts +34 -0
- package/src/test-utils.ts +18 -0
- package/studio/client/dist/assets/index-DOwzZAEK.css +1 -0
- package/studio/client/dist/assets/index-wtU0Zxhy.js +183 -0
- package/studio/client/dist/index.html +13 -0
- package/studio/package.json +62 -0
- package/studio/server/agent/ask.ts +68 -0
- package/studio/server/agent/bridge-mcp.ts +182 -0
- package/studio/server/agent/bridge.ts +188 -0
- package/studio/server/agent/claude.ts +666 -0
- package/studio/server/agent/mock.ts +186 -0
- package/studio/server/agent/prompt.ts +202 -0
- package/studio/server/agent/registry.ts +29 -0
- package/studio/server/agent/runner.ts +484 -0
- package/studio/server/agent/schema-map.ts +112 -0
- package/studio/server/agent/types.ts +120 -0
- package/studio/server/api.ts +574 -0
- package/studio/server/cache.ts +138 -0
- package/studio/server/detect.ts +81 -0
- package/studio/server/domain.ts +70 -0
- package/studio/server/index.ts +136 -0
- package/studio/server/introspect/anatomy-extras.ts +398 -0
- package/studio/server/introspect/anatomy.ts +108 -0
- package/studio/server/introspect/bundle.ts +57 -0
- package/studio/server/introspect/core-extractor.ts +119 -0
- package/studio/server/introspect/core.ts +44 -0
- package/studio/server/introspect/diff.ts +133 -0
- package/studio/server/introspect/extractor.ts +102 -0
- package/studio/server/introspect/hash.ts +21 -0
- package/studio/server/introspect/overlay-tsmorph.ts +874 -0
- package/studio/server/introspect/overlay.ts +57 -0
- package/studio/server/introspect/runtime.ts +99 -0
- package/studio/server/introspect/schema-refs.ts +46 -0
- package/studio/server/lifecycle.ts +38 -0
- package/studio/server/sse.ts +57 -0
- package/studio/server/state/baseline.ts +211 -0
- package/studio/server/state/catalog.ts +117 -0
- package/studio/server/state/comments.ts +321 -0
- package/studio/server/state/context.ts +167 -0
- package/studio/server/state/copy.ts +156 -0
- package/studio/server/state/create.ts +156 -0
- package/studio/server/state/documents.ts +70 -0
- package/studio/server/state/env.ts +161 -0
- package/studio/server/state/git.ts +75 -0
- package/studio/server/state/handoff.ts +55 -0
- package/studio/server/state/harness-gateway.ts +181 -0
- package/studio/server/state/harness-token.ts +0 -0
- package/studio/server/state/instance.ts +244 -0
- package/studio/server/state/integrations.ts +55 -0
- package/studio/server/state/layout.ts +55 -0
- package/studio/server/state/settings.ts +39 -0
- package/studio/server/state/store.ts +97 -0
- package/studio/server/state/updates.ts +63 -0
- package/studio/server/state/usage.ts +37 -0
- package/studio/server/state/views.ts +138 -0
- package/studio/server/state/visibility.ts +33 -0
- package/studio/server/watch.ts +81 -0
- package/studio/server/workspace-state.ts +26 -0
- package/studio/server/workspace-watch.ts +101 -0
- package/studio/shared/types.ts +873 -0
- package/studio/tsconfig.json +23 -0
- 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
|
+
}
|