loopat 0.1.38 → 0.1.39
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/package.json +1 -1
- package/server/src/a2a.ts +319 -0
- package/server/src/config.ts +32 -0
- package/server/src/index.ts +59 -1
- package/web/dist/assets/{Editor-BBnvJKU_.js → Editor-D4hrkS4P.js} +1 -1
- package/web/dist/assets/{Markdown-BDRXkErz.js → Markdown-DBk-rC15.js} +1 -1
- package/web/dist/assets/{MilkdownEditor-BizqMfUU.js → MilkdownEditor-i3ypZ0pY.js} +1 -1
- package/web/dist/assets/{index-Dv2YmrAT.js → index-CaZDV18H.js} +63 -63
- package/web/dist/index.html +1 -1
package/package.json
CHANGED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A2A (Agent-to-Agent) adapter — in-process, standard A2A (Google's Agent2Agent).
|
|
3
|
+
*
|
|
4
|
+
* Each loopat account is its own A2A agent:
|
|
5
|
+
* - GET /a2a/<user>/agent-card.json → that user's Agent Card
|
|
6
|
+
* - POST /a2a/<user> → JSON-RPC (message/send | message/stream)
|
|
7
|
+
*
|
|
8
|
+
* Auth is per-user: the caller presents that user's loopat API token
|
|
9
|
+
* (`Authorization: Bearer la_…`). We validate the token resolves to <user> and
|
|
10
|
+
* forward it to loopat's own `/api/v1` over loopback — which already does
|
|
11
|
+
* per-token-user auth, loop creation, turn execution, and SSE — so this adapter
|
|
12
|
+
* is a thin protocol translator (A2A ↔ /api/v1 SSE) that can't break web or
|
|
13
|
+
* /api/v1. The A2A `contextId` (conversation) maps to a loopat loop.
|
|
14
|
+
*/
|
|
15
|
+
import { Hono, type Context } from "hono"
|
|
16
|
+
import { streamSSE } from "hono/streaming"
|
|
17
|
+
import { randomBytes } from "node:crypto"
|
|
18
|
+
import { resolveApiToken } from "./api-tokens"
|
|
19
|
+
import { loadA2AConfig, type A2AUserConfig } from "./config"
|
|
20
|
+
|
|
21
|
+
const A2A_PROTOCOL_VERSION = "0.3.0"
|
|
22
|
+
const SELF_BASE = `http://127.0.0.1:${process.env.PORT ?? 10001}`
|
|
23
|
+
|
|
24
|
+
// contextId (A2A conversation) → loopat loopId. In-memory: a restart starts a
|
|
25
|
+
// fresh conversation (acceptable for v1).
|
|
26
|
+
const ctxToLoop = new Map<string, string>()
|
|
27
|
+
|
|
28
|
+
function resolvePublicUrl(c: Context): string {
|
|
29
|
+
const configured = process.env.LOOPAT_A2A_PUBLIC_URL
|
|
30
|
+
if (configured) return configured.replace(/\/+$/, "")
|
|
31
|
+
const proto = c.req.header("x-forwarded-proto") ?? "http"
|
|
32
|
+
const host = c.req.header("host") ?? `127.0.0.1:${process.env.PORT ?? 10001}`
|
|
33
|
+
return `${proto}://${host}`
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function agentCard(user: string, publicUrl: string, cfg: A2AUserConfig) {
|
|
37
|
+
const name = cfg.card?.name?.trim() || `Loopat Agent (${user})`
|
|
38
|
+
const description = cfg.card?.description?.trim() ||
|
|
39
|
+
"Self-hosted AI workspace (Claude Agent SDK). Runs development tasks in an isolated sandbox: writing code, reading repos, editing files, running commands, and researching."
|
|
40
|
+
return {
|
|
41
|
+
protocolVersion: A2A_PROTOCOL_VERSION,
|
|
42
|
+
name,
|
|
43
|
+
description,
|
|
44
|
+
url: `${publicUrl}/a2a/${encodeURIComponent(user)}`,
|
|
45
|
+
version: "1.0.0",
|
|
46
|
+
preferredTransport: "JSONRPC",
|
|
47
|
+
capabilities: { streaming: true, pushNotifications: false, stateTransitionHistory: false },
|
|
48
|
+
defaultInputModes: ["text/plain", "application/json"],
|
|
49
|
+
defaultOutputModes: ["text/plain"],
|
|
50
|
+
securitySchemes: {
|
|
51
|
+
bearer: { type: "http", scheme: "bearer", description: "A loopat API token belonging to this user." },
|
|
52
|
+
},
|
|
53
|
+
security: [{ bearer: [] }],
|
|
54
|
+
skills: [
|
|
55
|
+
{
|
|
56
|
+
id: "loopat_dev_turn",
|
|
57
|
+
name: "General development task",
|
|
58
|
+
description:
|
|
59
|
+
"Run one development task in a loopat sandbox: write/edit code, read the repo, run commands, research, and return the result. Multi-turn within a conversation (reuse contextId to continue).",
|
|
60
|
+
tags: ["coding", "agent", "dev", "sandbox"],
|
|
61
|
+
examples: ["Add a health-check endpoint to the repo and make the tests pass."],
|
|
62
|
+
inputModes: ["text/plain", "application/json"],
|
|
63
|
+
outputModes: ["text/plain"],
|
|
64
|
+
},
|
|
65
|
+
],
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
type ParsedMsg = { text: string; contextId?: string; taskId?: string }
|
|
70
|
+
|
|
71
|
+
function parseMessage(params: any): ParsedMsg {
|
|
72
|
+
const message = params?.message ?? {}
|
|
73
|
+
let text = ""
|
|
74
|
+
const parts = Array.isArray(message.parts) ? message.parts : []
|
|
75
|
+
for (const p of parts) {
|
|
76
|
+
if (p?.kind === "text" && typeof p.text === "string") { text += p.text }
|
|
77
|
+
else if (p?.kind === "data" && p.data) {
|
|
78
|
+
const d = p.data
|
|
79
|
+
text += typeof d.message === "string" ? d.message
|
|
80
|
+
: typeof d.text === "string" ? d.text
|
|
81
|
+
: typeof d === "string" ? d : ""
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
text: text.trim(),
|
|
86
|
+
contextId: typeof message.contextId === "string" && message.contextId ? message.contextId : undefined,
|
|
87
|
+
taskId: typeof message.taskId === "string" && message.taskId ? message.taskId : undefined,
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function getOrCreateLoop(parsed: ParsedMsg, callerAuth: string, cfg: A2AUserConfig): Promise<{ loopId: string; contextId: string }> {
|
|
92
|
+
let contextId = parsed.contextId
|
|
93
|
+
if (contextId && ctxToLoop.has(contextId)) return { loopId: ctxToLoop.get(contextId)!, contextId }
|
|
94
|
+
if (!contextId) contextId = `ctx_${randomBytes(8).toString("hex")}`
|
|
95
|
+
|
|
96
|
+
const title = parsed.text.slice(0, 60) || "a2a"
|
|
97
|
+
const res = await fetch(`${SELF_BASE}/api/v1/loops`, {
|
|
98
|
+
method: "POST",
|
|
99
|
+
headers: { authorization: callerAuth, "content-type": "application/json" },
|
|
100
|
+
body: JSON.stringify({
|
|
101
|
+
title,
|
|
102
|
+
profiles: cfg.profiles && cfg.profiles.length ? cfg.profiles : undefined,
|
|
103
|
+
vault: cfg.vault || undefined,
|
|
104
|
+
metadata: { a2a_context_id: contextId },
|
|
105
|
+
}),
|
|
106
|
+
})
|
|
107
|
+
if (!res.ok) {
|
|
108
|
+
const body = await res.text().catch(() => "")
|
|
109
|
+
throw new Error(`loop create failed: ${res.status} ${body}`)
|
|
110
|
+
}
|
|
111
|
+
const loop = await res.json()
|
|
112
|
+
const loopId = loop.id as string
|
|
113
|
+
ctxToLoop.set(contextId, loopId)
|
|
114
|
+
return { loopId, contextId }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
type LoopatEvent = { event: string; data: any }
|
|
118
|
+
|
|
119
|
+
async function* loopbackTurn(loopId: string, content: string, taskId: string, callerAuth: string): AsyncGenerator<LoopatEvent> {
|
|
120
|
+
const res = await fetch(`${SELF_BASE}/api/v1/loops/${loopId}/messages`, {
|
|
121
|
+
method: "POST",
|
|
122
|
+
headers: {
|
|
123
|
+
authorization: callerAuth,
|
|
124
|
+
"content-type": "application/json",
|
|
125
|
+
accept: "text/event-stream",
|
|
126
|
+
"idempotency-key": taskId,
|
|
127
|
+
},
|
|
128
|
+
body: JSON.stringify({ content, permission_mode: "bypassPermissions" }),
|
|
129
|
+
})
|
|
130
|
+
if (!res.ok || !res.body) {
|
|
131
|
+
const body = await res.text().catch(() => "")
|
|
132
|
+
yield { event: "error", data: { code: "send_failed", message: `${res.status} ${body}` } }
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
const reader = res.body.getReader()
|
|
136
|
+
const decoder = new TextDecoder()
|
|
137
|
+
let buf = ""
|
|
138
|
+
let curEvent = "message"
|
|
139
|
+
let curData = ""
|
|
140
|
+
const flush = (): LoopatEvent | null => {
|
|
141
|
+
if (!curData) { curEvent = "message"; return null }
|
|
142
|
+
let parsed: any = curData
|
|
143
|
+
try { parsed = JSON.parse(curData) } catch { /* keep string */ }
|
|
144
|
+
const ev = { event: curEvent, data: parsed }
|
|
145
|
+
curEvent = "message"; curData = ""
|
|
146
|
+
return ev
|
|
147
|
+
}
|
|
148
|
+
while (true) {
|
|
149
|
+
const { done, value } = await reader.read()
|
|
150
|
+
if (done) break
|
|
151
|
+
buf += decoder.decode(value, { stream: true })
|
|
152
|
+
const lines = buf.split("\n")
|
|
153
|
+
buf = lines.pop() ?? ""
|
|
154
|
+
for (const line of lines) {
|
|
155
|
+
if (line === "") { const ev = flush(); if (ev) yield ev; continue }
|
|
156
|
+
if (line.startsWith("event:")) curEvent = line.slice(6).trim()
|
|
157
|
+
else if (line.startsWith("data:")) curData += line.slice(5).trim()
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
const ev = flush(); if (ev) yield ev
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function extractAssistantText(d: any): string | null {
|
|
164
|
+
if (!d || d.type !== "assistant") return null
|
|
165
|
+
const content = d.message?.content
|
|
166
|
+
if (!Array.isArray(content)) return null
|
|
167
|
+
const txt = content.filter((b: any) => b?.type === "text" && typeof b.text === "string").map((b: any) => b.text).join("")
|
|
168
|
+
return txt || null
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function resultError(d: any): string | null {
|
|
172
|
+
if (!d || d.type !== "result") return null
|
|
173
|
+
if (d.is_error === true) return typeof d.result === "string" && d.result ? d.result : "agent error"
|
|
174
|
+
return null
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ── Standard A2A streaming event builders ─────────────────────────────────
|
|
178
|
+
function artifactUpdate(reqId: unknown, taskId: string, contextId: string, artifactId: string, text: string, append: boolean, lastChunk: boolean) {
|
|
179
|
+
return {
|
|
180
|
+
jsonrpc: "2.0", id: reqId,
|
|
181
|
+
result: {
|
|
182
|
+
kind: "artifact-update",
|
|
183
|
+
taskId, contextId, append, lastChunk,
|
|
184
|
+
artifact: { artifactId, name: "response", parts: [{ kind: "text", text }] },
|
|
185
|
+
},
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
function statusUpdate(reqId: unknown, taskId: string, contextId: string, state: "working" | "completed" | "failed", final: boolean, errorText?: string) {
|
|
189
|
+
const status: Record<string, unknown> = { state }
|
|
190
|
+
if (errorText) {
|
|
191
|
+
status.message = {
|
|
192
|
+
kind: "message", role: "agent", messageId: `msg_${randomBytes(6).toString("hex")}`,
|
|
193
|
+
parts: [{ kind: "text", text: errorText }], taskId, contextId,
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return { jsonrpc: "2.0", id: reqId, result: { kind: "status-update", taskId, contextId, status, final } }
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function handleStream(c: Context, reqId: unknown, parsed: ParsedMsg, callerAuth: string, cfg: A2AUserConfig) {
|
|
200
|
+
return streamSSE(c, async (stream) => {
|
|
201
|
+
const artifactId = `artifact_${randomBytes(8).toString("hex")}`
|
|
202
|
+
const taskId = parsed.taskId || `task_${randomBytes(8).toString("hex")}`
|
|
203
|
+
try {
|
|
204
|
+
const { loopId, contextId } = await getOrCreateLoop(parsed, callerAuth, cfg)
|
|
205
|
+
await stream.writeSSE({ data: JSON.stringify(statusUpdate(reqId, taskId, contextId, "working", false)) })
|
|
206
|
+
let gotDelta = false
|
|
207
|
+
let fallbackText: string | null = null
|
|
208
|
+
let errMsg: string | undefined
|
|
209
|
+
const finish = async () => {
|
|
210
|
+
if (errMsg) {
|
|
211
|
+
await stream.writeSSE({ data: JSON.stringify(statusUpdate(reqId, taskId, contextId, "failed", true, errMsg)) })
|
|
212
|
+
return
|
|
213
|
+
}
|
|
214
|
+
if (!gotDelta && fallbackText) {
|
|
215
|
+
await stream.writeSSE({ data: JSON.stringify(artifactUpdate(reqId, taskId, contextId, artifactId, fallbackText, false, false)) })
|
|
216
|
+
}
|
|
217
|
+
await stream.writeSSE({ data: JSON.stringify(artifactUpdate(reqId, taskId, contextId, artifactId, "", true, true)) })
|
|
218
|
+
await stream.writeSSE({ data: JSON.stringify(statusUpdate(reqId, taskId, contextId, "completed", true)) })
|
|
219
|
+
}
|
|
220
|
+
for await (const ev of loopbackTurn(loopId, parsed.text, taskId, callerAuth)) {
|
|
221
|
+
if (ev.event === "assistant_delta" && typeof ev.data?.text === "string") {
|
|
222
|
+
await stream.writeSSE({ data: JSON.stringify(artifactUpdate(reqId, taskId, contextId, artifactId, ev.data.text, gotDelta, false)) })
|
|
223
|
+
gotDelta = true
|
|
224
|
+
} else if (ev.event === "sdk_message") {
|
|
225
|
+
const t = extractAssistantText(ev.data); if (t) fallbackText = t
|
|
226
|
+
const e = resultError(ev.data); if (e) errMsg = e
|
|
227
|
+
} else if (ev.event === "done") {
|
|
228
|
+
await finish(); return
|
|
229
|
+
} else if (ev.event === "error" || ev.event === "interrupted") {
|
|
230
|
+
errMsg = ev.data?.message ?? ev.event
|
|
231
|
+
await finish(); return
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
await finish()
|
|
235
|
+
} catch (e: any) {
|
|
236
|
+
const taskIdSafe = taskId
|
|
237
|
+
await stream.writeSSE({ data: JSON.stringify(statusUpdate(reqId, taskIdSafe, parsed.contextId ?? "", "failed", true, e?.message ?? String(e))) })
|
|
238
|
+
}
|
|
239
|
+
})
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function handleSend(c: Context, reqId: unknown, parsed: ParsedMsg, callerAuth: string, cfg: A2AUserConfig) {
|
|
243
|
+
const artifactId = `artifact_${randomBytes(8).toString("hex")}`
|
|
244
|
+
const taskId = parsed.taskId || `task_${randomBytes(8).toString("hex")}`
|
|
245
|
+
try {
|
|
246
|
+
const { loopId, contextId } = await getOrCreateLoop(parsed, callerAuth, cfg)
|
|
247
|
+
let full = ""
|
|
248
|
+
let fallbackText: string | null = null
|
|
249
|
+
let errMsg: string | undefined
|
|
250
|
+
for await (const ev of loopbackTurn(loopId, parsed.text, taskId, callerAuth)) {
|
|
251
|
+
if (ev.event === "assistant_delta" && typeof ev.data?.text === "string") full += ev.data.text
|
|
252
|
+
else if (ev.event === "sdk_message") {
|
|
253
|
+
const t = extractAssistantText(ev.data); if (t) fallbackText = t
|
|
254
|
+
const e = resultError(ev.data); if (e) errMsg = e
|
|
255
|
+
}
|
|
256
|
+
else if (ev.event === "error" || ev.event === "interrupted") errMsg = ev.data?.message ?? ev.event
|
|
257
|
+
else if (ev.event === "done") break
|
|
258
|
+
}
|
|
259
|
+
if (!full && fallbackText) full = fallbackText
|
|
260
|
+
if (errMsg && !full) full = errMsg
|
|
261
|
+
const success = !errMsg
|
|
262
|
+
// A2A Task object.
|
|
263
|
+
return c.json({
|
|
264
|
+
jsonrpc: "2.0",
|
|
265
|
+
id: reqId,
|
|
266
|
+
result: {
|
|
267
|
+
kind: "task",
|
|
268
|
+
id: taskId,
|
|
269
|
+
contextId,
|
|
270
|
+
status: { state: success ? "completed" : "failed" },
|
|
271
|
+
artifacts: [{ artifactId, name: "response", parts: [{ kind: "text", text: full }] }],
|
|
272
|
+
},
|
|
273
|
+
})
|
|
274
|
+
} catch (e: any) {
|
|
275
|
+
return c.json({ jsonrpc: "2.0", id: reqId, error: { code: -32603, message: e?.message ?? String(e) } })
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export function buildA2A() {
|
|
280
|
+
const a = new Hono()
|
|
281
|
+
|
|
282
|
+
const cardHandler = async (c: Context) => {
|
|
283
|
+
const user = c.req.param("user")
|
|
284
|
+
if (!user) return c.json({ error: "not found" }, 404)
|
|
285
|
+
const cfg = await loadA2AConfig(user)
|
|
286
|
+
return c.json(agentCard(user, resolvePublicUrl(c), cfg))
|
|
287
|
+
}
|
|
288
|
+
a.get("/a2a/:user/agent-card.json", cardHandler)
|
|
289
|
+
a.get("/a2a/:user/.well-known/agent-card.json", cardHandler)
|
|
290
|
+
a.get("/a2a/:user/agent.json", cardHandler) // legacy filename alias
|
|
291
|
+
|
|
292
|
+
a.post("/a2a/:user", async (c) => {
|
|
293
|
+
const user = c.req.param("user")
|
|
294
|
+
const rpc = await c.req.json().catch(() => null)
|
|
295
|
+
const reqId = rpc?.id ?? null
|
|
296
|
+
if (!rpc || typeof rpc.method !== "string") {
|
|
297
|
+
return c.json({ jsonrpc: "2.0", id: reqId, error: { code: -32600, message: "invalid request" } }, 400)
|
|
298
|
+
}
|
|
299
|
+
// Per-user auth: the caller must present THIS user's loopat API token.
|
|
300
|
+
const auth = c.req.header("authorization") ?? null
|
|
301
|
+
const tokenUser = await resolveApiToken(auth)
|
|
302
|
+
if (!tokenUser) {
|
|
303
|
+
return c.json({ jsonrpc: "2.0", id: reqId, error: { code: -32000, message: "unauthorized: present a loopat API token" } }, 401)
|
|
304
|
+
}
|
|
305
|
+
if (tokenUser !== user) {
|
|
306
|
+
return c.json({ jsonrpc: "2.0", id: reqId, error: { code: -32000, message: "token does not belong to this agent" } }, 403)
|
|
307
|
+
}
|
|
308
|
+
const cfg = await loadA2AConfig(user)
|
|
309
|
+
const parsed = parseMessage(rpc.params)
|
|
310
|
+
if (!parsed.text) {
|
|
311
|
+
return c.json({ jsonrpc: "2.0", id: reqId, error: { code: -32602, message: "empty message" } }, 400)
|
|
312
|
+
}
|
|
313
|
+
if (rpc.method === "message/stream") return handleStream(c, reqId, parsed, auth!, cfg)
|
|
314
|
+
if (rpc.method === "message/send") return handleSend(c, reqId, parsed, auth!, cfg)
|
|
315
|
+
return c.json({ jsonrpc: "2.0", id: reqId, error: { code: -32601, message: `method not found: ${rpc.method}` } }, 404)
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
return a
|
|
319
|
+
}
|
package/server/src/config.ts
CHANGED
|
@@ -483,6 +483,38 @@ export async function loadPersonalConfig(
|
|
|
483
483
|
return cfg
|
|
484
484
|
}
|
|
485
485
|
|
|
486
|
+
// ── Per-user A2A config ───────────────────────────────────────────────────
|
|
487
|
+
// The user's A2A agent: editable card fields + which profiles/vault loops
|
|
488
|
+
// created by A2A use. No secrets (the credential is the user's API token), so
|
|
489
|
+
// it's plain JSON next to config.json. `$LOOPAT_HOME/personal/<user>/.loopat/a2a.json`.
|
|
490
|
+
export type A2AUserConfig = {
|
|
491
|
+
card?: { name?: string; description?: string }
|
|
492
|
+
profiles?: string[]
|
|
493
|
+
vault?: string
|
|
494
|
+
}
|
|
495
|
+
function a2aConfigPath(user: string): string {
|
|
496
|
+
return join(personalLoopatDir(user), "a2a.json")
|
|
497
|
+
}
|
|
498
|
+
export async function loadA2AConfig(user: string): Promise<A2AUserConfig> {
|
|
499
|
+
const p = a2aConfigPath(user)
|
|
500
|
+
if (!existsSync(p)) return {}
|
|
501
|
+
try {
|
|
502
|
+
const j = JSON.parse(await readFile(p, "utf8")) as A2AUserConfig
|
|
503
|
+
return j && typeof j === "object" ? j : {}
|
|
504
|
+
} catch { return {} }
|
|
505
|
+
}
|
|
506
|
+
export async function saveA2AConfig(user: string, patch: A2AUserConfig): Promise<void> {
|
|
507
|
+
const cur = await loadA2AConfig(user)
|
|
508
|
+
const next: A2AUserConfig = {
|
|
509
|
+
card: patch.card !== undefined ? patch.card : cur.card,
|
|
510
|
+
profiles: patch.profiles !== undefined ? patch.profiles : cur.profiles,
|
|
511
|
+
vault: patch.vault !== undefined ? patch.vault : cur.vault,
|
|
512
|
+
}
|
|
513
|
+
const p = a2aConfigPath(user)
|
|
514
|
+
await mkdir(dirname(p), { recursive: true })
|
|
515
|
+
await writeFile(p, JSON.stringify(next, null, 2) + "\n")
|
|
516
|
+
}
|
|
517
|
+
|
|
486
518
|
export function getActiveProvider(cfg: PersonalConfig): { name: string; provider: ProviderConfig } | null {
|
|
487
519
|
const raw = cfg.default
|
|
488
520
|
if (!raw) return null
|
package/server/src/index.ts
CHANGED
|
@@ -55,7 +55,8 @@ import {
|
|
|
55
55
|
personalReposDir,
|
|
56
56
|
loopsDir,
|
|
57
57
|
} from "./paths"
|
|
58
|
-
import { loadConfig, loadPersonalConfig, savePersonalConfig, saveWorkspaceConfig, loadTokenUsage, getActiveProvider, readPersonalDiskRaw, savePersonalDisk, describeApiKeyRef, writeVaultEnv, deleteVaultEnv, loadKnowledgeConfig, saveKnowledgeConfig, type ProviderConfig, type ModelEntry } from "./config"
|
|
58
|
+
import { loadConfig, loadPersonalConfig, savePersonalConfig, saveWorkspaceConfig, loadTokenUsage, getActiveProvider, readPersonalDiskRaw, savePersonalDisk, describeApiKeyRef, writeVaultEnv, deleteVaultEnv, loadKnowledgeConfig, saveKnowledgeConfig, loadA2AConfig, saveA2AConfig, type ProviderConfig, type ModelEntry } from "./config"
|
|
59
|
+
import { createApiToken, listApiTokens, revokeApiToken } from "./api-tokens"
|
|
59
60
|
import { listBoards, createBoard, renameBoard, listKanbanColumns, addCard, toggleCard, deleteCard, moveCard, updateCardMeta, updateCardBlock, reorderCards, createColumn, deleteColumn, readKanbanConfig, saveColumnOrder, setColumnColor, renameColumn, assignDriverForCard, createLoopFromCard, linkLoopToCard, kanbanUserCtx } from "./kanban"
|
|
60
61
|
import { printBootstrapBanner, printReadyLine } from "./bootstrap"
|
|
61
62
|
import { resolveProvider } from "./providers"
|
|
@@ -127,6 +128,11 @@ app.get("/api/version", (c) => {
|
|
|
127
128
|
import { buildApiV1 } from "./api-v1"
|
|
128
129
|
app.route("/api/v1", buildApiV1())
|
|
129
130
|
|
|
131
|
+
// A2A (Agent-to-Agent) adapter — per-user Agent Card + JSON-RPC under /a2a/<user>.
|
|
132
|
+
// Mounted before the SPA catch-all so those routes resolve.
|
|
133
|
+
import { buildA2A } from "./a2a"
|
|
134
|
+
app.route("/", buildA2A())
|
|
135
|
+
|
|
130
136
|
// ── workspace serve config ──
|
|
131
137
|
|
|
132
138
|
function getLocalIp(): string {
|
|
@@ -1551,6 +1557,58 @@ app.get("/api/vaults", requireAuth, async (c) => {
|
|
|
1551
1557
|
return c.json({ vaults: listVaults(userId) })
|
|
1552
1558
|
})
|
|
1553
1559
|
|
|
1560
|
+
// ── A2A settings (per-user agent card + default profiles/vault + key) ──
|
|
1561
|
+
const A2A_KEY_LABEL = "a2a"
|
|
1562
|
+
function a2aPublicBase(c: any): string {
|
|
1563
|
+
const configured = process.env.LOOPAT_A2A_PUBLIC_URL
|
|
1564
|
+
if (configured) return configured.replace(/\/+$/, "")
|
|
1565
|
+
const proto = c.req.header("x-forwarded-proto") ?? "http"
|
|
1566
|
+
const host = c.req.header("host") ?? `127.0.0.1:${process.env.PORT ?? 10001}`
|
|
1567
|
+
return `${proto}://${host}`
|
|
1568
|
+
}
|
|
1569
|
+
app.get("/api/a2a", requireAuth, async (c) => {
|
|
1570
|
+
const userId = c.get("userId") as string
|
|
1571
|
+
const cfg = await loadA2AConfig(userId)
|
|
1572
|
+
const { listVaults } = await import("./vaults")
|
|
1573
|
+
const { listProfiles } = await import("./profiles")
|
|
1574
|
+
const tokens = await listApiTokens(userId)
|
|
1575
|
+
const base = a2aPublicBase(c)
|
|
1576
|
+
return c.json({
|
|
1577
|
+
card: { name: cfg.card?.name ?? "", description: cfg.card?.description ?? "" },
|
|
1578
|
+
profiles: cfg.profiles ?? [],
|
|
1579
|
+
vault: cfg.vault ?? "",
|
|
1580
|
+
cardUrl: `${base}/a2a/${encodeURIComponent(userId)}/agent-card.json`,
|
|
1581
|
+
endpoint: `${base}/a2a/${encodeURIComponent(userId)}`,
|
|
1582
|
+
hasKey: tokens.some((t) => t.label === A2A_KEY_LABEL),
|
|
1583
|
+
availableProfiles: await listProfiles(userId),
|
|
1584
|
+
availableVaults: listVaults(userId),
|
|
1585
|
+
})
|
|
1586
|
+
})
|
|
1587
|
+
app.put("/api/a2a", requireAuth, async (c) => {
|
|
1588
|
+
const userId = c.get("userId") as string
|
|
1589
|
+
const body = await c.req.json().catch(() => ({}))
|
|
1590
|
+
const patch: { card?: { name?: string; description?: string }; profiles?: string[]; vault?: string } = {}
|
|
1591
|
+
if (body.card && typeof body.card === "object") {
|
|
1592
|
+
patch.card = {
|
|
1593
|
+
name: typeof body.card.name === "string" ? body.card.name.slice(0, 200) : undefined,
|
|
1594
|
+
description: typeof body.card.description === "string" ? body.card.description.slice(0, 2000) : undefined,
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
if (Array.isArray(body.profiles)) patch.profiles = body.profiles.filter((p: unknown): p is string => typeof p === "string")
|
|
1598
|
+
if (typeof body.vault === "string") patch.vault = body.vault
|
|
1599
|
+
await saveA2AConfig(userId, patch)
|
|
1600
|
+
return c.json({ ok: true })
|
|
1601
|
+
})
|
|
1602
|
+
// Rotate the A2A key: revoke any prior a2a-labelled token, mint a fresh one
|
|
1603
|
+
// (returned once — store it in the orchestrator).
|
|
1604
|
+
app.post("/api/a2a/key", requireAuth, async (c) => {
|
|
1605
|
+
const userId = c.get("userId") as string
|
|
1606
|
+
const existing = await listApiTokens(userId)
|
|
1607
|
+
for (const t of existing) if (t.label === A2A_KEY_LABEL) await revokeApiToken(userId, t.tokenId)
|
|
1608
|
+
const created = await createApiToken(userId, A2A_KEY_LABEL)
|
|
1609
|
+
return c.json({ token: created.token })
|
|
1610
|
+
})
|
|
1611
|
+
|
|
1554
1612
|
// Return the current user's `default_profiles` from
|
|
1555
1613
|
// personal/<u>/.loopat/config.json. Used by NewLoopDialog to pre-check the
|
|
1556
1614
|
// user's typical setup. Returns [] if config absent / field missing.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{n as e,o as t,t as n}from"./jsx-runtime-Bt-cYkS5.js";import{_ as r,v as i,y as a}from"./index-
|
|
1
|
+
import{n as e,o as t,t as n}from"./jsx-runtime-Bt-cYkS5.js";import{_ as r,v as i,y as a}from"./index-CaZDV18H.js";import{CodeEditor as o}from"./CodeEditor-DtHZtsPs.js";var s=a(`text-wrap`,[[`path`,{d:`m16 16-3 3 3 3`,key:`117b85`}],[`path`,{d:`M3 12h14.5a1 1 0 0 1 0 7H13`,key:`18xa6z`}],[`path`,{d:`M3 19h6`,key:`1ygdsz`}],[`path`,{d:`M3 5h18`,key:`1u36vt`}]]),c=t(e(),1),l=n();function u(){try{if(localStorage.getItem(`loopat:editor:wordWrap`)===`0`)return!1}catch{}return!0}function d({loopId:e,path:t,onSelectionChange:n}){let[a,d]=(0,c.useState)(``),[f,p]=(0,c.useState)(``),[m,h]=(0,c.useState)(!1),[g,_]=(0,c.useState)(!1),[v,y]=(0,c.useState)(u);(0,c.useEffect)(()=>{if(!t){d(``),p(``);return}h(!0),r(e,t).then(e=>{let t=e?.content??``;d(t),p(t)}).finally(()=>h(!1))},[e,t]);let b=t&&f!==a,x=async()=>{if(!(!t||g)){_(!0);try{await i(e,t,f)&&d(f)}finally{_(!1)}}};return t?(0,l.jsxs)(l.Fragment,{children:[(0,l.jsx)(`div`,{className:`flex-1 min-h-0 relative`,onKeyDown:e=>{(e.metaKey||e.ctrlKey)&&e.key===`s`&&(e.preventDefault(),x())},children:m?(0,l.jsx)(`div`,{className:`h-full w-full flex items-center justify-center text-[12px] text-gray-400`,children:`loading…`}):(0,l.jsx)(o,{path:t,value:f,onChange:p,wordWrap:v,onSelectionChange:n})}),(0,l.jsxs)(`div`,{className:`border-t border-gray-200 px-3 py-1.5 text-[11px] text-gray-500 flex items-center gap-3`,children:[(0,l.jsx)(`span`,{className:`truncate`,children:t}),b&&(0,l.jsx)(`button`,{onClick:x,className:`text-orange-600 hover:underline`,title:`ctrl/⌘+S`,children:g?`saving…`:`unsaved · save`}),(0,l.jsx)(`span`,{className:`flex-1`}),(0,l.jsx)(`button`,{onClick:()=>{let e=!v;y(e);try{localStorage.setItem(`loopat:editor:wordWrap`,e?`1`:`0`)}catch{}},className:`flex items-center gap-1 hover:text-gray-700 transition-colors ${v?`text-gray-500`:`text-gray-300`}`,title:v?`word wrap: on`:`word wrap: off`,children:(0,l.jsx)(s,{size:13})}),(0,l.jsx)(`span`,{children:`utf-8 · LF`})]})]}):(0,l.jsx)(`div`,{className:`flex-1 min-h-0 flex items-center justify-center text-[13px] text-gray-500 px-8 text-center`,children:`没打开文件 · 在 ▤ workdir 里点一个`})}export{d as Editor};
|