loopat 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +194 -0
- package/bin/loopat.mjs +65 -0
- package/package.json +52 -0
- package/server/package.json +22 -0
- package/server/src/api-tokens.ts +161 -0
- package/server/src/api-v1-openapi.ts +363 -0
- package/server/src/api-v1.ts +681 -0
- package/server/src/auth.ts +309 -0
- package/server/src/bootstrap.ts +113 -0
- package/server/src/chat.ts +390 -0
- package/server/src/claude-binary.ts +68 -0
- package/server/src/compose.ts +474 -0
- package/server/src/config.ts +783 -0
- package/server/src/files.ts +173 -0
- package/server/src/git-crypt-key.ts +36 -0
- package/server/src/git-host.ts +104 -0
- package/server/src/github.ts +161 -0
- package/server/src/index.ts +3204 -0
- package/server/src/kanban.ts +810 -0
- package/server/src/loop-stats.ts +225 -0
- package/server/src/loop-status.ts +67 -0
- package/server/src/loops.ts +1832 -0
- package/server/src/mcp-oauth.ts +516 -0
- package/server/src/onboarding.ts +105 -0
- package/server/src/paths.ts +190 -0
- package/server/src/personal-keys.ts +60 -0
- package/server/src/plugin-installer.ts +287 -0
- package/server/src/podman.ts +1216 -0
- package/server/src/presets.ts +30 -0
- package/server/src/profiles.ts +177 -0
- package/server/src/providers.ts +45 -0
- package/server/src/serve.ts +275 -0
- package/server/src/session.ts +1496 -0
- package/server/src/system-prompt.ts +90 -0
- package/server/src/term.ts +211 -0
- package/server/src/tiers.ts +762 -0
- package/server/src/vaults.ts +189 -0
- package/server/src/workspace.ts +501 -0
- package/server/templates/.claude-plugin/marketplace.json +13 -0
- package/server/templates/CLAUDE.md +78 -0
- package/server/templates/loop-kinds/distill/CLAUDE.md +46 -0
- package/server/templates/plugins/loopat/.claude-plugin/plugin.json +5 -0
- package/server/templates/plugins/loopat/skills/onboarding/SKILL.md +266 -0
- package/server/templates/plugins/loopat/skills/promote/SKILL.md +53 -0
- package/server/templates/sandbox/Containerfile +113 -0
- package/web/dist/assets/CodeEditor-BGODueTo.js +49 -0
- package/web/dist/assets/Editor-DMS25Vve.js +1 -0
- package/web/dist/assets/Markdown-CnHbW7WK.js +5 -0
- package/web/dist/assets/MilkdownEditor-nqo9_0v5.js +123 -0
- package/web/dist/assets/Terminal-BrP-ENHg.css +1 -0
- package/web/dist/assets/Terminal-CYWvxYam.js +174 -0
- package/web/dist/assets/index-DM5eO-Tv.js +163 -0
- package/web/dist/assets/index-DxIFezwv.css +1 -0
- package/web/dist/assets/w3c-keyname-BOAvb0qz.js +1 -0
- package/web/dist/favicon.svg +1 -0
- package/web/dist/index.html +14 -0
- package/web/dist/logo.png +0 -0
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-user API tokens for the v1 Loop API.
|
|
3
|
+
*
|
|
4
|
+
* Storage: `$LOOPAT_HOME/api-tokens.json`. Tokens are SHA-256 hashed at rest;
|
|
5
|
+
* the plaintext (`la_<hex>`) is only returned to the caller at creation.
|
|
6
|
+
*
|
|
7
|
+
* Each entry has a stable `tokenId` (independent of the token value) so the
|
|
8
|
+
* web UI can list / revoke without exposing or suffix-matching the secret.
|
|
9
|
+
*
|
|
10
|
+
* Writes are serialized via an in-process promise chain. loopat is
|
|
11
|
+
* single-process so file-level locking isn't needed.
|
|
12
|
+
*/
|
|
13
|
+
import { existsSync } from "node:fs"
|
|
14
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises"
|
|
15
|
+
import { createHash, randomBytes } from "node:crypto"
|
|
16
|
+
import { dirname, join } from "node:path"
|
|
17
|
+
import { LOOPAT_HOME } from "./paths"
|
|
18
|
+
|
|
19
|
+
const TOKENS_PATH = join(LOOPAT_HOME, "api-tokens.json")
|
|
20
|
+
|
|
21
|
+
type StoredToken = {
|
|
22
|
+
tokenId: string // stable short id, surfaced to UI
|
|
23
|
+
userId: string
|
|
24
|
+
label: string
|
|
25
|
+
createdAt: string
|
|
26
|
+
lastUsedAt?: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type TokensFile = {
|
|
30
|
+
// keyed by SHA-256(token plaintext)
|
|
31
|
+
tokens: Record<string, StoredToken>
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
let cached: TokensFile | null = null
|
|
35
|
+
|
|
36
|
+
let writeLock: Promise<void> = Promise.resolve()
|
|
37
|
+
function withWriteLock<T>(fn: () => Promise<T>): Promise<T> {
|
|
38
|
+
const next = writeLock.then(fn, fn)
|
|
39
|
+
writeLock = next.then(() => {}, () => {})
|
|
40
|
+
return next
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function hashToken(token: string): string {
|
|
44
|
+
return createHash("sha256").update(token).digest("hex")
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function generateTokenValue(): string {
|
|
48
|
+
return `la_${randomBytes(24).toString("hex")}`
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function generateTokenId(): string {
|
|
52
|
+
return `tok_${randomBytes(6).toString("hex")}`
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function readTokensFile(): Promise<TokensFile> {
|
|
56
|
+
if (cached) return cached
|
|
57
|
+
if (!existsSync(TOKENS_PATH)) {
|
|
58
|
+
cached = { tokens: {} }
|
|
59
|
+
return cached
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
const raw = await readFile(TOKENS_PATH, "utf8")
|
|
63
|
+
const parsed = JSON.parse(raw) as TokensFile
|
|
64
|
+
if (!parsed.tokens || typeof parsed.tokens !== "object") {
|
|
65
|
+
cached = { tokens: {} }
|
|
66
|
+
} else {
|
|
67
|
+
cached = parsed
|
|
68
|
+
}
|
|
69
|
+
return cached
|
|
70
|
+
} catch {
|
|
71
|
+
cached = { tokens: {} }
|
|
72
|
+
return cached
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function writeTokensFile(data: TokensFile): Promise<void> {
|
|
77
|
+
await mkdir(dirname(TOKENS_PATH), { recursive: true })
|
|
78
|
+
await writeFile(TOKENS_PATH, JSON.stringify(data, null, 2) + "\n")
|
|
79
|
+
cached = data
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export type ApiTokenView = {
|
|
83
|
+
tokenId: string
|
|
84
|
+
label: string
|
|
85
|
+
createdAt: string
|
|
86
|
+
lastUsedAt?: string
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Resolve `Authorization: Bearer la_...` to a userId, or null. */
|
|
90
|
+
export async function resolveApiToken(authHeader: string | null): Promise<string | null> {
|
|
91
|
+
if (!authHeader || !authHeader.startsWith("Bearer ")) return null
|
|
92
|
+
const bearerToken = authHeader.slice("Bearer ".length).trim()
|
|
93
|
+
if (!bearerToken || !bearerToken.startsWith("la_")) return null
|
|
94
|
+
const file = await readTokensFile()
|
|
95
|
+
const entry = file.tokens[hashToken(bearerToken)]
|
|
96
|
+
if (!entry) return null
|
|
97
|
+
// Best-effort lastUsedAt update; don't block resolution on it.
|
|
98
|
+
withWriteLock(async () => {
|
|
99
|
+
const f = await readTokensFile()
|
|
100
|
+
const h = hashToken(bearerToken)
|
|
101
|
+
if (f.tokens[h]) {
|
|
102
|
+
f.tokens[h].lastUsedAt = new Date().toISOString()
|
|
103
|
+
await writeTokensFile(f)
|
|
104
|
+
}
|
|
105
|
+
}).catch(() => {})
|
|
106
|
+
return entry.userId
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export async function createApiToken(userId: string, label: string): Promise<{
|
|
110
|
+
tokenId: string
|
|
111
|
+
token: string
|
|
112
|
+
label: string
|
|
113
|
+
createdAt: string
|
|
114
|
+
}> {
|
|
115
|
+
return withWriteLock(async () => {
|
|
116
|
+
const file = await readTokensFile()
|
|
117
|
+
const token = generateTokenValue()
|
|
118
|
+
const tokenId = generateTokenId()
|
|
119
|
+
const stored: StoredToken = {
|
|
120
|
+
tokenId,
|
|
121
|
+
userId,
|
|
122
|
+
label: (label || "").trim() || "default",
|
|
123
|
+
createdAt: new Date().toISOString(),
|
|
124
|
+
}
|
|
125
|
+
file.tokens[hashToken(token)] = stored
|
|
126
|
+
await writeTokensFile(file)
|
|
127
|
+
return { tokenId, token, label: stored.label, createdAt: stored.createdAt }
|
|
128
|
+
})
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export async function listApiTokens(userId: string): Promise<ApiTokenView[]> {
|
|
132
|
+
const file = await readTokensFile()
|
|
133
|
+
return Object.values(file.tokens)
|
|
134
|
+
.filter((t) => t.userId === userId)
|
|
135
|
+
.map((t) => ({
|
|
136
|
+
tokenId: t.tokenId,
|
|
137
|
+
label: t.label,
|
|
138
|
+
createdAt: t.createdAt,
|
|
139
|
+
lastUsedAt: t.lastUsedAt,
|
|
140
|
+
}))
|
|
141
|
+
.sort((a, b) => a.createdAt < b.createdAt ? 1 : -1)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export async function revokeApiToken(userId: string, tokenId: string): Promise<boolean> {
|
|
145
|
+
return withWriteLock(async () => {
|
|
146
|
+
const file = await readTokensFile()
|
|
147
|
+
const hash = Object.keys(file.tokens).find((h) => {
|
|
148
|
+
const t = file.tokens[h]
|
|
149
|
+
return t.userId === userId && t.tokenId === tokenId
|
|
150
|
+
})
|
|
151
|
+
if (!hash) return false
|
|
152
|
+
delete file.tokens[hash]
|
|
153
|
+
await writeTokensFile(file)
|
|
154
|
+
return true
|
|
155
|
+
})
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** For tests only — drops the in-memory cache. */
|
|
159
|
+
export function _resetCache(): void {
|
|
160
|
+
cached = null
|
|
161
|
+
}
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenAPI 3.1 schema for the v1 Loop API.
|
|
3
|
+
*
|
|
4
|
+
* Source of truth is `docs/api-v1.md`. This file mirrors that contract in
|
|
5
|
+
* a machine-readable form so the interactive docs (Scalar) can render it.
|
|
6
|
+
*
|
|
7
|
+
* Keep these two in sync when changing the API — spec doc first, then this.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export const v1OpenApiSpec = {
|
|
11
|
+
openapi: "3.1.0",
|
|
12
|
+
info: {
|
|
13
|
+
title: "Loopat Loop API",
|
|
14
|
+
version: "1.0.0",
|
|
15
|
+
description: [
|
|
16
|
+
"External API for creating and driving loops. The same surface powers ",
|
|
17
|
+
"the loopat web chat UI — bot frameworks and the web app speak the same ",
|
|
18
|
+
"protocol.",
|
|
19
|
+
"",
|
|
20
|
+
"**Auth**: pass `Authorization: Bearer la_<token>` for external programs, ",
|
|
21
|
+
"or a `loopat_session` cookie for same-origin web requests.",
|
|
22
|
+
"",
|
|
23
|
+
"**Scope**: v1 only covers chat conversation. Operator features (queue, ",
|
|
24
|
+
"goal, provider, archive admin flags) stay on internal endpoints; see the ",
|
|
25
|
+
"full spec at `docs/api-v1.md`.",
|
|
26
|
+
].join("\n"),
|
|
27
|
+
},
|
|
28
|
+
servers: [{ url: "/api/v1", description: "current host, v1 prefix" }],
|
|
29
|
+
components: {
|
|
30
|
+
securitySchemes: {
|
|
31
|
+
BearerAuth: {
|
|
32
|
+
type: "http",
|
|
33
|
+
scheme: "bearer",
|
|
34
|
+
bearerFormat: "la_<48 hex>",
|
|
35
|
+
description: "External programs. Token from Settings → API Tokens.",
|
|
36
|
+
},
|
|
37
|
+
CookieAuth: {
|
|
38
|
+
type: "apiKey",
|
|
39
|
+
in: "cookie",
|
|
40
|
+
name: "loopat_session",
|
|
41
|
+
description: "Web same-origin requests; set by /api/auth/login.",
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
schemas: {
|
|
45
|
+
Loop: {
|
|
46
|
+
type: "object",
|
|
47
|
+
required: ["id", "title", "created_at", "created_by", "archived", "metadata", "profiles", "vault"],
|
|
48
|
+
properties: {
|
|
49
|
+
id: { type: "string", example: "loop_3a91ce5e-9c2f-4f0a-bcc9-7c7e..." },
|
|
50
|
+
title: { type: "string", maxLength: 200 },
|
|
51
|
+
created_at: { type: "string", format: "date-time" },
|
|
52
|
+
created_by: { type: "string", example: "alice" },
|
|
53
|
+
archived: { type: "boolean" },
|
|
54
|
+
archived_at: { type: "string", format: "date-time", nullable: true },
|
|
55
|
+
metadata: {
|
|
56
|
+
type: "object",
|
|
57
|
+
additionalProperties: { type: "string" },
|
|
58
|
+
description: "Caller-supplied k/v, ≤16 KB JSON-stringified. Not visible to the agent.",
|
|
59
|
+
},
|
|
60
|
+
profiles: { type: "array", items: { type: "string" } },
|
|
61
|
+
vault: { type: "string", example: "default" },
|
|
62
|
+
repo: { type: "string", nullable: true, example: "myproject" },
|
|
63
|
+
busy: { type: "boolean", description: "Only on GET /loops/{id}" },
|
|
64
|
+
queue_depth: { type: "integer", description: "Only on GET /loops/{id}" },
|
|
65
|
+
turn_count: { type: "integer", description: "Only on GET /loops/{id}" },
|
|
66
|
+
current_turn: {
|
|
67
|
+
type: "object",
|
|
68
|
+
nullable: true,
|
|
69
|
+
description: "Present iff busy=true",
|
|
70
|
+
properties: {
|
|
71
|
+
turn_id: { type: "string", nullable: true },
|
|
72
|
+
started_at: { type: "string", format: "date-time", nullable: true },
|
|
73
|
+
pending_choice_id: { type: "string", nullable: true },
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
LoopList: {
|
|
79
|
+
type: "object",
|
|
80
|
+
required: ["data", "has_more"],
|
|
81
|
+
properties: {
|
|
82
|
+
data: { type: "array", items: { $ref: "#/components/schemas/Loop" } },
|
|
83
|
+
first_id: { type: "string", nullable: true },
|
|
84
|
+
last_id: { type: "string", nullable: true },
|
|
85
|
+
has_more: { type: "boolean" },
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
ApiTokenView: {
|
|
89
|
+
type: "object",
|
|
90
|
+
required: ["tokenId", "label", "createdAt"],
|
|
91
|
+
properties: {
|
|
92
|
+
tokenId: { type: "string", example: "tok_a1b2c3d4e5f6" },
|
|
93
|
+
label: { type: "string" },
|
|
94
|
+
createdAt: { type: "string", format: "date-time" },
|
|
95
|
+
lastUsedAt: { type: "string", format: "date-time" },
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
ApiTokenCreated: {
|
|
99
|
+
type: "object",
|
|
100
|
+
required: ["tokenId", "token", "label", "createdAt"],
|
|
101
|
+
properties: {
|
|
102
|
+
tokenId: { type: "string" },
|
|
103
|
+
token: {
|
|
104
|
+
type: "string",
|
|
105
|
+
description: "Bearer token plaintext — only returned once at creation.",
|
|
106
|
+
example: "la_a1b2c3...",
|
|
107
|
+
},
|
|
108
|
+
label: { type: "string" },
|
|
109
|
+
createdAt: { type: "string", format: "date-time" },
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
Error: {
|
|
113
|
+
type: "object",
|
|
114
|
+
required: ["error"],
|
|
115
|
+
properties: {
|
|
116
|
+
error: {
|
|
117
|
+
type: "object",
|
|
118
|
+
required: ["type", "code", "message"],
|
|
119
|
+
properties: {
|
|
120
|
+
type: {
|
|
121
|
+
type: "string",
|
|
122
|
+
enum: [
|
|
123
|
+
"authentication_error",
|
|
124
|
+
"permission_error",
|
|
125
|
+
"invalid_request_error",
|
|
126
|
+
"not_found_error",
|
|
127
|
+
"conflict_error",
|
|
128
|
+
"rate_limit_error",
|
|
129
|
+
"internal_error",
|
|
130
|
+
],
|
|
131
|
+
},
|
|
132
|
+
code: { type: "string", example: "loop_not_found" },
|
|
133
|
+
message: { type: "string" },
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
security: [{ BearerAuth: [] }, { CookieAuth: [] }],
|
|
141
|
+
paths: {
|
|
142
|
+
"/me/tokens": {
|
|
143
|
+
post: {
|
|
144
|
+
summary: "Create an API token",
|
|
145
|
+
description: "Cookie-only (web Settings UI). Bots cannot self-issue tokens.",
|
|
146
|
+
security: [{ CookieAuth: [] }],
|
|
147
|
+
requestBody: {
|
|
148
|
+
required: false,
|
|
149
|
+
content: {
|
|
150
|
+
"application/json": {
|
|
151
|
+
schema: {
|
|
152
|
+
type: "object",
|
|
153
|
+
properties: { label: { type: "string", default: "default" } },
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
responses: {
|
|
159
|
+
"201": {
|
|
160
|
+
description: "Token created. Plaintext returned once.",
|
|
161
|
+
content: {
|
|
162
|
+
"application/json": { schema: { $ref: "#/components/schemas/ApiTokenCreated" } },
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
"401": { description: "Session required", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
get: {
|
|
169
|
+
summary: "List my API tokens",
|
|
170
|
+
security: [{ CookieAuth: [] }],
|
|
171
|
+
responses: {
|
|
172
|
+
"200": {
|
|
173
|
+
description: "Token list (plaintext omitted)",
|
|
174
|
+
content: {
|
|
175
|
+
"application/json": {
|
|
176
|
+
schema: {
|
|
177
|
+
type: "object",
|
|
178
|
+
properties: { tokens: { type: "array", items: { $ref: "#/components/schemas/ApiTokenView" } } },
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
"/me/tokens/{tokenId}": {
|
|
187
|
+
delete: {
|
|
188
|
+
summary: "Revoke an API token",
|
|
189
|
+
security: [{ CookieAuth: [] }],
|
|
190
|
+
parameters: [
|
|
191
|
+
{ name: "tokenId", in: "path", required: true, schema: { type: "string" }, example: "tok_a1b2c3..." },
|
|
192
|
+
],
|
|
193
|
+
responses: {
|
|
194
|
+
"204": { description: "Revoked" },
|
|
195
|
+
"404": { description: "Not found" },
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
"/loops": {
|
|
200
|
+
post: {
|
|
201
|
+
summary: "Create a loop",
|
|
202
|
+
requestBody: {
|
|
203
|
+
required: false,
|
|
204
|
+
content: {
|
|
205
|
+
"application/json": {
|
|
206
|
+
schema: {
|
|
207
|
+
type: "object",
|
|
208
|
+
properties: {
|
|
209
|
+
title: { type: "string", default: "untitled", maxLength: 200 },
|
|
210
|
+
metadata: { type: "object", additionalProperties: { type: "string" } },
|
|
211
|
+
profiles: { type: "array", items: { type: "string" } },
|
|
212
|
+
vault: { type: "string", default: "default" },
|
|
213
|
+
repo: { type: "string" },
|
|
214
|
+
},
|
|
215
|
+
},
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
},
|
|
219
|
+
responses: {
|
|
220
|
+
"201": { description: "Loop created", content: { "application/json": { schema: { $ref: "#/components/schemas/Loop" } } } },
|
|
221
|
+
"400": { description: "Validation error", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
|
|
222
|
+
},
|
|
223
|
+
},
|
|
224
|
+
get: {
|
|
225
|
+
summary: "List my loops",
|
|
226
|
+
parameters: [
|
|
227
|
+
{ name: "limit", in: "query", schema: { type: "integer", default: 20, maximum: 100 } },
|
|
228
|
+
{ name: "after", in: "query", schema: { type: "string" }, description: "Cursor: returns loops older than this id" },
|
|
229
|
+
{ name: "before", in: "query", schema: { type: "string" }, description: "Cursor: returns loops newer than this id" },
|
|
230
|
+
{ name: "archived", in: "query", schema: { type: "boolean", default: false } },
|
|
231
|
+
],
|
|
232
|
+
responses: {
|
|
233
|
+
"200": { description: "Loop list", content: { "application/json": { schema: { $ref: "#/components/schemas/LoopList" } } } },
|
|
234
|
+
},
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
"/loops/{id}": {
|
|
238
|
+
get: {
|
|
239
|
+
summary: "Get a loop",
|
|
240
|
+
parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
|
|
241
|
+
responses: {
|
|
242
|
+
"200": { description: "Loop with runtime state", content: { "application/json": { schema: { $ref: "#/components/schemas/Loop" } } } },
|
|
243
|
+
"403": { description: "Not loop owner", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
|
|
244
|
+
"404": { description: "Not found", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
delete: {
|
|
248
|
+
summary: "Archive a loop",
|
|
249
|
+
description: "Soft-delete. Sandbox is killed, meta retained. Unarchive/hard-delete are web-only.",
|
|
250
|
+
parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
|
|
251
|
+
responses: {
|
|
252
|
+
"204": { description: "Archived" },
|
|
253
|
+
"403": { description: "Not loop owner" },
|
|
254
|
+
"404": { description: "Not found" },
|
|
255
|
+
},
|
|
256
|
+
},
|
|
257
|
+
},
|
|
258
|
+
"/loops/{id}/messages": {
|
|
259
|
+
post: {
|
|
260
|
+
summary: "Send a message and stream the turn (SSE)",
|
|
261
|
+
description: [
|
|
262
|
+
"Returns `text/event-stream`. See the SSE event vocabulary in `docs/api-v1.md`.",
|
|
263
|
+
"Closing the connection does **not** cancel the turn. Reconnect using the same",
|
|
264
|
+
"`Idempotency-Key` to replay buffered events and attach to the live stream.",
|
|
265
|
+
].join(" "),
|
|
266
|
+
parameters: [
|
|
267
|
+
{ name: "id", in: "path", required: true, schema: { type: "string" } },
|
|
268
|
+
{ name: "Idempotency-Key", in: "header", schema: { type: "string", maxLength: 256 } },
|
|
269
|
+
],
|
|
270
|
+
requestBody: {
|
|
271
|
+
required: true,
|
|
272
|
+
content: {
|
|
273
|
+
"application/json": {
|
|
274
|
+
schema: {
|
|
275
|
+
type: "object",
|
|
276
|
+
required: ["content"],
|
|
277
|
+
properties: {
|
|
278
|
+
content: { type: "string", maxLength: 1048576 },
|
|
279
|
+
permission_mode: {
|
|
280
|
+
type: "string",
|
|
281
|
+
enum: ["default", "acceptEdits", "bypassPermissions", "plan", "dontAsk", "auto"],
|
|
282
|
+
description: "Override the loop's current permission mode for this turn.",
|
|
283
|
+
},
|
|
284
|
+
},
|
|
285
|
+
},
|
|
286
|
+
},
|
|
287
|
+
},
|
|
288
|
+
},
|
|
289
|
+
responses: {
|
|
290
|
+
"200": {
|
|
291
|
+
description: "SSE stream. Event vocabulary: queued, started, assistant_delta, thinking_delta, tool_call, tool_result, requires_choice, choice_resolved, done, interrupted, error, ping, sdk_message (web-internal).",
|
|
292
|
+
content: { "text/event-stream": { schema: { type: "string" } } },
|
|
293
|
+
},
|
|
294
|
+
"400": { description: "Validation error", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
|
|
295
|
+
"409": { description: "Idempotency-Key reused with different body", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
|
|
296
|
+
},
|
|
297
|
+
},
|
|
298
|
+
},
|
|
299
|
+
"/loops/{id}/events": {
|
|
300
|
+
get: {
|
|
301
|
+
summary: "Watch a loop's events (read-only SSE)",
|
|
302
|
+
description: "Attach to live events without sending a message. Useful for reconnect and passive observation.",
|
|
303
|
+
parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
|
|
304
|
+
responses: {
|
|
305
|
+
"200": {
|
|
306
|
+
description: "SSE stream. Same vocabulary as POST /messages, prefixed by `event: snapshot` if a turn is already running.",
|
|
307
|
+
content: { "text/event-stream": { schema: { type: "string" } } },
|
|
308
|
+
},
|
|
309
|
+
},
|
|
310
|
+
},
|
|
311
|
+
},
|
|
312
|
+
"/loops/{id}/choices/{choiceId}": {
|
|
313
|
+
post: {
|
|
314
|
+
summary: "Answer a choice (permission or question)",
|
|
315
|
+
description: "Unblocks an agent that emitted `requires_choice`. Body shape depends on the choice kind.",
|
|
316
|
+
parameters: [
|
|
317
|
+
{ name: "id", in: "path", required: true, schema: { type: "string" } },
|
|
318
|
+
{ name: "choiceId", in: "path", required: true, schema: { type: "string" } },
|
|
319
|
+
],
|
|
320
|
+
requestBody: {
|
|
321
|
+
required: true,
|
|
322
|
+
content: {
|
|
323
|
+
"application/json": {
|
|
324
|
+
schema: {
|
|
325
|
+
oneOf: [
|
|
326
|
+
{
|
|
327
|
+
title: "Permission",
|
|
328
|
+
type: "object",
|
|
329
|
+
required: ["allow"],
|
|
330
|
+
properties: { allow: { type: "boolean" } },
|
|
331
|
+
},
|
|
332
|
+
{
|
|
333
|
+
title: "Question",
|
|
334
|
+
type: "object",
|
|
335
|
+
required: ["answers"],
|
|
336
|
+
properties: {
|
|
337
|
+
answers: { type: "object", additionalProperties: { type: "string" } },
|
|
338
|
+
},
|
|
339
|
+
},
|
|
340
|
+
],
|
|
341
|
+
},
|
|
342
|
+
},
|
|
343
|
+
},
|
|
344
|
+
},
|
|
345
|
+
responses: {
|
|
346
|
+
"202": { description: "Choice resolved" },
|
|
347
|
+
"400": { description: "Invalid body shape" },
|
|
348
|
+
"404": { description: "Choice not pending or already answered" },
|
|
349
|
+
},
|
|
350
|
+
},
|
|
351
|
+
},
|
|
352
|
+
"/loops/{id}/interrupt": {
|
|
353
|
+
post: {
|
|
354
|
+
summary: "Cancel the current turn",
|
|
355
|
+
description: "Open SSE streams receive `event: interrupted` and close.",
|
|
356
|
+
parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
|
|
357
|
+
responses: {
|
|
358
|
+
"202": { description: "Interrupted" },
|
|
359
|
+
},
|
|
360
|
+
},
|
|
361
|
+
},
|
|
362
|
+
},
|
|
363
|
+
} as const
|