codepiper 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/.env.example +28 -0
- package/CHANGELOG.md +10 -0
- package/LEGAL_NOTICE.md +39 -0
- package/LICENSE +21 -0
- package/README.md +524 -0
- package/package.json +90 -0
- package/packages/cli/package.json +13 -0
- package/packages/cli/src/commands/analytics.ts +157 -0
- package/packages/cli/src/commands/attach.ts +299 -0
- package/packages/cli/src/commands/audit.ts +50 -0
- package/packages/cli/src/commands/auth.ts +261 -0
- package/packages/cli/src/commands/daemon.ts +162 -0
- package/packages/cli/src/commands/doctor.ts +303 -0
- package/packages/cli/src/commands/env-set.ts +162 -0
- package/packages/cli/src/commands/hook-forward.ts +268 -0
- package/packages/cli/src/commands/keys.ts +77 -0
- package/packages/cli/src/commands/kill.ts +19 -0
- package/packages/cli/src/commands/logs.ts +419 -0
- package/packages/cli/src/commands/model.ts +172 -0
- package/packages/cli/src/commands/policy-set.ts +185 -0
- package/packages/cli/src/commands/policy.ts +227 -0
- package/packages/cli/src/commands/providers.ts +114 -0
- package/packages/cli/src/commands/resize.ts +34 -0
- package/packages/cli/src/commands/send.ts +184 -0
- package/packages/cli/src/commands/sessions.ts +202 -0
- package/packages/cli/src/commands/slash.ts +92 -0
- package/packages/cli/src/commands/start.ts +243 -0
- package/packages/cli/src/commands/stop.ts +19 -0
- package/packages/cli/src/commands/tail.ts +137 -0
- package/packages/cli/src/commands/workflow.ts +786 -0
- package/packages/cli/src/commands/workspace.ts +127 -0
- package/packages/cli/src/lib/api.ts +78 -0
- package/packages/cli/src/lib/args.ts +72 -0
- package/packages/cli/src/lib/format.ts +93 -0
- package/packages/cli/src/main.ts +563 -0
- package/packages/core/package.json +7 -0
- package/packages/core/src/config.ts +30 -0
- package/packages/core/src/errors.ts +38 -0
- package/packages/core/src/eventBus.ts +56 -0
- package/packages/core/src/eventBusAdapter.ts +143 -0
- package/packages/core/src/index.ts +10 -0
- package/packages/core/src/sqliteEventBus.ts +336 -0
- package/packages/core/src/types.ts +63 -0
- package/packages/daemon/package.json +11 -0
- package/packages/daemon/src/api/analyticsRoutes.ts +343 -0
- package/packages/daemon/src/api/authRoutes.ts +344 -0
- package/packages/daemon/src/api/bodyLimit.ts +133 -0
- package/packages/daemon/src/api/envSetRoutes.ts +170 -0
- package/packages/daemon/src/api/gitRoutes.ts +409 -0
- package/packages/daemon/src/api/hooks.ts +588 -0
- package/packages/daemon/src/api/inputPolicy.ts +249 -0
- package/packages/daemon/src/api/notificationRoutes.ts +532 -0
- package/packages/daemon/src/api/policyRoutes.ts +234 -0
- package/packages/daemon/src/api/policySetRoutes.ts +445 -0
- package/packages/daemon/src/api/routeUtils.ts +28 -0
- package/packages/daemon/src/api/routes.ts +1004 -0
- package/packages/daemon/src/api/server.ts +1388 -0
- package/packages/daemon/src/api/settingsRoutes.ts +367 -0
- package/packages/daemon/src/api/sqliteErrors.ts +47 -0
- package/packages/daemon/src/api/stt.ts +143 -0
- package/packages/daemon/src/api/terminalRoutes.ts +200 -0
- package/packages/daemon/src/api/validation.ts +287 -0
- package/packages/daemon/src/api/validationRoutes.ts +174 -0
- package/packages/daemon/src/api/workflowRoutes.ts +567 -0
- package/packages/daemon/src/api/workspaceRoutes.ts +151 -0
- package/packages/daemon/src/api/ws.ts +1588 -0
- package/packages/daemon/src/auth/apiRateLimiter.ts +73 -0
- package/packages/daemon/src/auth/authMiddleware.ts +305 -0
- package/packages/daemon/src/auth/authService.ts +496 -0
- package/packages/daemon/src/auth/rateLimiter.ts +137 -0
- package/packages/daemon/src/config/pricing.ts +79 -0
- package/packages/daemon/src/crypto/encryption.ts +196 -0
- package/packages/daemon/src/db/db.ts +2745 -0
- package/packages/daemon/src/db/index.ts +16 -0
- package/packages/daemon/src/db/migrations.ts +182 -0
- package/packages/daemon/src/db/policyDb.ts +349 -0
- package/packages/daemon/src/db/schema.sql +408 -0
- package/packages/daemon/src/db/workflowDb.ts +464 -0
- package/packages/daemon/src/git/gitUtils.ts +544 -0
- package/packages/daemon/src/index.ts +6 -0
- package/packages/daemon/src/main.ts +525 -0
- package/packages/daemon/src/notifications/pushNotifier.ts +369 -0
- package/packages/daemon/src/providers/codexAppServerScaffold.ts +49 -0
- package/packages/daemon/src/providers/registry.ts +111 -0
- package/packages/daemon/src/providers/types.ts +82 -0
- package/packages/daemon/src/sessions/auditLogger.ts +103 -0
- package/packages/daemon/src/sessions/policyEngine.ts +165 -0
- package/packages/daemon/src/sessions/policyMatcher.ts +114 -0
- package/packages/daemon/src/sessions/policyTypes.ts +94 -0
- package/packages/daemon/src/sessions/ptyProcess.ts +141 -0
- package/packages/daemon/src/sessions/sessionManager.ts +1770 -0
- package/packages/daemon/src/sessions/tmuxSession.ts +1073 -0
- package/packages/daemon/src/sessions/transcriptManager.ts +110 -0
- package/packages/daemon/src/sessions/transcriptParser.ts +149 -0
- package/packages/daemon/src/sessions/transcriptTailer.ts +214 -0
- package/packages/daemon/src/tracking/tokenTracker.ts +168 -0
- package/packages/daemon/src/workflows/contextManager.ts +83 -0
- package/packages/daemon/src/workflows/index.ts +31 -0
- package/packages/daemon/src/workflows/resultExtractor.ts +118 -0
- package/packages/daemon/src/workflows/waitConditionPoller.ts +131 -0
- package/packages/daemon/src/workflows/workflowParser.ts +217 -0
- package/packages/daemon/src/workflows/workflowRunner.ts +969 -0
- package/packages/daemon/src/workflows/workflowTypes.ts +188 -0
- package/packages/daemon/src/workflows/workflowValidator.ts +533 -0
- package/packages/providers/claude-code/package.json +11 -0
- package/packages/providers/claude-code/src/index.ts +7 -0
- package/packages/providers/claude-code/src/overlaySettings.ts +198 -0
- package/packages/providers/claude-code/src/provider.ts +311 -0
- package/packages/web/dist/android-chrome-192x192.png +0 -0
- package/packages/web/dist/android-chrome-512x512.png +0 -0
- package/packages/web/dist/apple-touch-icon.png +0 -0
- package/packages/web/dist/assets/AnalyticsPage-BIopKWRf.js +17 -0
- package/packages/web/dist/assets/PoliciesPage-CjdLN3dl.js +11 -0
- package/packages/web/dist/assets/SessionDetailPage-BtSA0V0M.js +179 -0
- package/packages/web/dist/assets/SettingsPage-Dbbz4Ca5.js +37 -0
- package/packages/web/dist/assets/WorkflowsPage-Dv6f3GgU.js +1 -0
- package/packages/web/dist/assets/chart-vendor-DlOHLaCG.js +49 -0
- package/packages/web/dist/assets/codicon-ngg6Pgfi.ttf +0 -0
- package/packages/web/dist/assets/css.worker-BvV5MPou.js +93 -0
- package/packages/web/dist/assets/editor.worker-CKy7Pnvo.js +26 -0
- package/packages/web/dist/assets/html.worker-BLJhxQJQ.js +470 -0
- package/packages/web/dist/assets/index-BbdhRfr2.css +1 -0
- package/packages/web/dist/assets/index-hgphORiw.js +204 -0
- package/packages/web/dist/assets/json.worker-usMZ-FED.js +58 -0
- package/packages/web/dist/assets/monaco-core-B_19GPAS.css +1 -0
- package/packages/web/dist/assets/monaco-core-DQ5Mk8AK.js +1234 -0
- package/packages/web/dist/assets/monaco-react-DfZNWvtW.js +11 -0
- package/packages/web/dist/assets/monacoSetup-DvBj52bT.js +1 -0
- package/packages/web/dist/assets/pencil-Dbczxz59.js +11 -0
- package/packages/web/dist/assets/react-vendor-B5MgMUHH.js +136 -0
- package/packages/web/dist/assets/refresh-cw-B0MGsYPL.js +6 -0
- package/packages/web/dist/assets/tabs-C8LsWiR5.js +1 -0
- package/packages/web/dist/assets/terminal-vendor-Cs8KPbV3.js +9 -0
- package/packages/web/dist/assets/terminal-vendor-LcAfv9l9.css +32 -0
- package/packages/web/dist/assets/trash-2-Btlg0d4l.js +6 -0
- package/packages/web/dist/assets/ts.worker-DGHjMaqB.js +67731 -0
- package/packages/web/dist/favicon.ico +0 -0
- package/packages/web/dist/icon.svg +1 -0
- package/packages/web/dist/index.html +29 -0
- package/packages/web/dist/manifest.json +29 -0
- package/packages/web/dist/og-image.png +0 -0
- package/packages/web/dist/originals/android-chrome-192x192.png +0 -0
- package/packages/web/dist/originals/android-chrome-512x512.png +0 -0
- package/packages/web/dist/originals/apple-touch-icon.png +0 -0
- package/packages/web/dist/originals/favicon.ico +0 -0
- package/packages/web/dist/piper.svg +1 -0
- package/packages/web/dist/sounds/codepiper-soft-chime.wav +0 -0
- package/packages/web/dist/sw.js +257 -0
- package/scripts/postinstall-link-workspaces.mjs +58 -0
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Daemon settings API route handlers
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { RouteContext } from "./routes";
|
|
6
|
+
|
|
7
|
+
const MAX_NOTIFICATION_EVENT_DEFAULT_ENTRIES = 128;
|
|
8
|
+
const MAX_NOTIFICATION_EVENT_KEY_LENGTH = 128;
|
|
9
|
+
const MAX_NOTIFICATION_SOUND_MAP_ENTRIES = 128;
|
|
10
|
+
const MAX_NOTIFICATION_SOUND_KEY_LENGTH = 128;
|
|
11
|
+
const MAX_NOTIFICATION_SOUND_VALUE_BYTES = 700_000;
|
|
12
|
+
const MAX_NOTIFICATION_SOUND_MAP_TOTAL_BYTES = 900_000;
|
|
13
|
+
const utf8Encoder = new TextEncoder();
|
|
14
|
+
|
|
15
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
16
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function utf8ByteLength(value: string): number {
|
|
20
|
+
return utf8Encoder.encode(value).byteLength;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function validateRecordKey(key: string, field: string, maxLength: number): string | null {
|
|
24
|
+
if (key.length === 0) {
|
|
25
|
+
return `${field} keys must not be empty`;
|
|
26
|
+
}
|
|
27
|
+
if (key.length > maxLength) {
|
|
28
|
+
return `${field} keys must be at most ${maxLength} characters`;
|
|
29
|
+
}
|
|
30
|
+
if (key.trim() !== key) {
|
|
31
|
+
return `${field} keys must not have leading or trailing whitespace`;
|
|
32
|
+
}
|
|
33
|
+
for (let i = 0; i < key.length; i += 1) {
|
|
34
|
+
const codePoint = key.charCodeAt(i);
|
|
35
|
+
if (codePoint <= 0x1f || codePoint === 0x7f) {
|
|
36
|
+
return `${field} keys must not contain control characters`;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function validateNotificationEventDefaults(
|
|
43
|
+
value: unknown
|
|
44
|
+
): { ok: true; value: Record<string, boolean> } | { ok: false; error: string } {
|
|
45
|
+
if (!isPlainObject(value)) {
|
|
46
|
+
return {
|
|
47
|
+
ok: false,
|
|
48
|
+
error: "notificationEventDefaults must be an object with boolean values",
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const entries = Object.entries(value);
|
|
53
|
+
if (entries.length > MAX_NOTIFICATION_EVENT_DEFAULT_ENTRIES) {
|
|
54
|
+
return {
|
|
55
|
+
ok: false,
|
|
56
|
+
error: `notificationEventDefaults must contain at most ${MAX_NOTIFICATION_EVENT_DEFAULT_ENTRIES} entries`,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const normalized: Record<string, boolean> = {};
|
|
61
|
+
for (const [key, entry] of entries) {
|
|
62
|
+
const keyError = validateRecordKey(
|
|
63
|
+
key,
|
|
64
|
+
"notificationEventDefaults",
|
|
65
|
+
MAX_NOTIFICATION_EVENT_KEY_LENGTH
|
|
66
|
+
);
|
|
67
|
+
if (keyError) {
|
|
68
|
+
return { ok: false, error: keyError };
|
|
69
|
+
}
|
|
70
|
+
if (typeof entry !== "boolean") {
|
|
71
|
+
return {
|
|
72
|
+
ok: false,
|
|
73
|
+
error: "notificationEventDefaults must be an object with boolean values",
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
normalized[key] = entry;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return { ok: true, value: normalized };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function validateNotificationSoundMap(
|
|
83
|
+
value: unknown
|
|
84
|
+
): { ok: true; value: Record<string, string> } | { ok: false; error: string } {
|
|
85
|
+
if (!isPlainObject(value)) {
|
|
86
|
+
return {
|
|
87
|
+
ok: false,
|
|
88
|
+
error: "notificationSoundMap must be an object with string values",
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const entries = Object.entries(value);
|
|
93
|
+
if (entries.length > MAX_NOTIFICATION_SOUND_MAP_ENTRIES) {
|
|
94
|
+
return {
|
|
95
|
+
ok: false,
|
|
96
|
+
error: `notificationSoundMap must contain at most ${MAX_NOTIFICATION_SOUND_MAP_ENTRIES} entries`,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const normalized: Record<string, string> = {};
|
|
101
|
+
let totalBytes = 0;
|
|
102
|
+
for (const [key, entry] of entries) {
|
|
103
|
+
const keyError = validateRecordKey(
|
|
104
|
+
key,
|
|
105
|
+
"notificationSoundMap",
|
|
106
|
+
MAX_NOTIFICATION_SOUND_KEY_LENGTH
|
|
107
|
+
);
|
|
108
|
+
if (keyError) {
|
|
109
|
+
return { ok: false, error: keyError };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (typeof entry !== "string") {
|
|
113
|
+
return {
|
|
114
|
+
ok: false,
|
|
115
|
+
error: "notificationSoundMap must be an object with string values",
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const valueBytes = utf8ByteLength(entry);
|
|
120
|
+
if (valueBytes === 0) {
|
|
121
|
+
return {
|
|
122
|
+
ok: false,
|
|
123
|
+
error: "notificationSoundMap values must not be empty",
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
if (valueBytes > MAX_NOTIFICATION_SOUND_VALUE_BYTES) {
|
|
127
|
+
return {
|
|
128
|
+
ok: false,
|
|
129
|
+
error: `notificationSoundMap values must be at most ${MAX_NOTIFICATION_SOUND_VALUE_BYTES} bytes`,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
totalBytes += valueBytes;
|
|
133
|
+
if (totalBytes > MAX_NOTIFICATION_SOUND_MAP_TOTAL_BYTES) {
|
|
134
|
+
return {
|
|
135
|
+
ok: false,
|
|
136
|
+
error: `notificationSoundMap total payload must be at most ${MAX_NOTIFICATION_SOUND_MAP_TOTAL_BYTES} bytes`,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
normalized[key] = entry;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return { ok: true, value: normalized };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function parseCanaryPercent(
|
|
146
|
+
value: unknown,
|
|
147
|
+
key: string
|
|
148
|
+
): { ok: true; value: number } | { ok: false; error: string } {
|
|
149
|
+
if (!(typeof value === "number" && Number.isFinite(value))) {
|
|
150
|
+
return { ok: false, error: `${key} must be a number` };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const rounded = Math.round(value);
|
|
154
|
+
if (rounded < 0 || rounded > 100) {
|
|
155
|
+
return { ok: false, error: `${key} must be between 0 and 100` };
|
|
156
|
+
}
|
|
157
|
+
return { ok: true, value: rounded };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* GET /settings/daemon - Get daemon settings
|
|
162
|
+
*/
|
|
163
|
+
export async function handleGetDaemonSettings(_req: Request, ctx: RouteContext): Promise<Response> {
|
|
164
|
+
const settings = ctx.db.getDaemonSettings();
|
|
165
|
+
return Response.json({ settings });
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* PUT /settings/daemon - Update daemon settings
|
|
170
|
+
*/
|
|
171
|
+
export async function handleUpdateDaemonSettings(
|
|
172
|
+
req: Request,
|
|
173
|
+
ctx: RouteContext
|
|
174
|
+
): Promise<Response> {
|
|
175
|
+
let body: any;
|
|
176
|
+
try {
|
|
177
|
+
body = await req.json();
|
|
178
|
+
} catch {
|
|
179
|
+
return Response.json({ error: "Invalid JSON in request body" }, { status: 400 });
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (body.preserveSessions !== undefined && typeof body.preserveSessions !== "boolean") {
|
|
183
|
+
return Response.json({ error: "preserveSessions must be a boolean" }, { status: 400 });
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (
|
|
187
|
+
body.defaultPolicyAction !== undefined &&
|
|
188
|
+
body.defaultPolicyAction !== "ask" &&
|
|
189
|
+
body.defaultPolicyAction !== "deny"
|
|
190
|
+
) {
|
|
191
|
+
return Response.json({ error: 'defaultPolicyAction must be "ask" or "deny"' }, { status: 400 });
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (body.forwardSshAuthSock !== undefined && typeof body.forwardSshAuthSock !== "boolean") {
|
|
195
|
+
return Response.json({ error: "forwardSshAuthSock must be a boolean" }, { status: 400 });
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (
|
|
199
|
+
body.codexHostAccessProfileEnabled !== undefined &&
|
|
200
|
+
typeof body.codexHostAccessProfileEnabled !== "boolean"
|
|
201
|
+
) {
|
|
202
|
+
return Response.json(
|
|
203
|
+
{ error: "codexHostAccessProfileEnabled must be a boolean" },
|
|
204
|
+
{ status: 400 }
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (body.notificationsEnabled !== undefined && typeof body.notificationsEnabled !== "boolean") {
|
|
209
|
+
return Response.json({ error: "notificationsEnabled must be a boolean" }, { status: 400 });
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (
|
|
213
|
+
body.systemNotificationsEnabled !== undefined &&
|
|
214
|
+
typeof body.systemNotificationsEnabled !== "boolean"
|
|
215
|
+
) {
|
|
216
|
+
return Response.json(
|
|
217
|
+
{ error: "systemNotificationsEnabled must be a boolean" },
|
|
218
|
+
{ status: 400 }
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (
|
|
223
|
+
body.notificationSoundsEnabled !== undefined &&
|
|
224
|
+
typeof body.notificationSoundsEnabled !== "boolean"
|
|
225
|
+
) {
|
|
226
|
+
return Response.json({ error: "notificationSoundsEnabled must be a boolean" }, { status: 400 });
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (
|
|
230
|
+
body.notificationEventDefaults !== undefined &&
|
|
231
|
+
!isPlainObject(body.notificationEventDefaults)
|
|
232
|
+
) {
|
|
233
|
+
return Response.json(
|
|
234
|
+
{ error: "notificationEventDefaults must be an object with boolean values" },
|
|
235
|
+
{ status: 400 }
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (body.notificationSoundMap !== undefined && !isPlainObject(body.notificationSoundMap)) {
|
|
240
|
+
return Response.json(
|
|
241
|
+
{ error: "notificationSoundMap must be an object with string values" },
|
|
242
|
+
{ status: 400 }
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
let notificationEventDefaults: Record<string, boolean> | undefined;
|
|
247
|
+
if (body.notificationEventDefaults !== undefined) {
|
|
248
|
+
const validated = validateNotificationEventDefaults(body.notificationEventDefaults);
|
|
249
|
+
if (!validated.ok) {
|
|
250
|
+
return Response.json({ error: validated.error }, { status: 400 });
|
|
251
|
+
}
|
|
252
|
+
notificationEventDefaults = validated.value;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
let notificationSoundMap: Record<string, string> | undefined;
|
|
256
|
+
if (body.notificationSoundMap !== undefined) {
|
|
257
|
+
const validated = validateNotificationSoundMap(body.notificationSoundMap);
|
|
258
|
+
if (!validated.ok) {
|
|
259
|
+
return Response.json({ error: validated.error }, { status: 400 });
|
|
260
|
+
}
|
|
261
|
+
notificationSoundMap = validated.value;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const terminalFeaturesPatch: Record<string, unknown> = {};
|
|
265
|
+
if (body.terminalFeatures !== undefined) {
|
|
266
|
+
if (!isPlainObject(body.terminalFeatures)) {
|
|
267
|
+
return Response.json({ error: "terminalFeatures must be an object" }, { status: 400 });
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const terminalFeatures = body.terminalFeatures as Record<string, unknown>;
|
|
271
|
+
const booleanFeatureKeys = [
|
|
272
|
+
"wsPtyPasteEnabled",
|
|
273
|
+
"latencyProbesEnabled",
|
|
274
|
+
"diagnosticsPanelEnabled",
|
|
275
|
+
"codexAppServerSpikeEnabled",
|
|
276
|
+
] as const;
|
|
277
|
+
for (const key of booleanFeatureKeys) {
|
|
278
|
+
if (terminalFeatures[key] !== undefined && typeof terminalFeatures[key] !== "boolean") {
|
|
279
|
+
return Response.json({ error: `${key} must be a boolean` }, { status: 400 });
|
|
280
|
+
}
|
|
281
|
+
if (terminalFeatures[key] !== undefined) {
|
|
282
|
+
terminalFeaturesPatch[key] = terminalFeatures[key];
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const canaryKeys = [
|
|
287
|
+
"wsPtyPasteCanaryPercent",
|
|
288
|
+
"latencyProbesCanaryPercent",
|
|
289
|
+
"diagnosticsPanelCanaryPercent",
|
|
290
|
+
] as const;
|
|
291
|
+
for (const key of canaryKeys) {
|
|
292
|
+
if (terminalFeatures[key] === undefined) {
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
const parsed = parseCanaryPercent(terminalFeatures[key], key);
|
|
296
|
+
if (!parsed.ok) {
|
|
297
|
+
return Response.json({ error: parsed.error }, { status: 400 });
|
|
298
|
+
}
|
|
299
|
+
terminalFeaturesPatch[key] = parsed.value;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
ctx.db.updateDaemonSettings({
|
|
304
|
+
preserveSessions: body.preserveSessions,
|
|
305
|
+
defaultPolicyAction: body.defaultPolicyAction,
|
|
306
|
+
forwardSshAuthSock: body.forwardSshAuthSock,
|
|
307
|
+
codexHostAccessProfileEnabled: body.codexHostAccessProfileEnabled,
|
|
308
|
+
notificationsEnabled: body.notificationsEnabled,
|
|
309
|
+
systemNotificationsEnabled: body.systemNotificationsEnabled,
|
|
310
|
+
notificationSoundsEnabled: body.notificationSoundsEnabled,
|
|
311
|
+
notificationEventDefaults,
|
|
312
|
+
notificationSoundMap,
|
|
313
|
+
terminalFeatures:
|
|
314
|
+
Object.keys(terminalFeaturesPatch).length > 0
|
|
315
|
+
? (terminalFeaturesPatch as {
|
|
316
|
+
wsPtyPasteEnabled?: boolean;
|
|
317
|
+
latencyProbesEnabled?: boolean;
|
|
318
|
+
diagnosticsPanelEnabled?: boolean;
|
|
319
|
+
codexAppServerSpikeEnabled?: boolean;
|
|
320
|
+
wsPtyPasteCanaryPercent?: number;
|
|
321
|
+
latencyProbesCanaryPercent?: number;
|
|
322
|
+
diagnosticsPanelCanaryPercent?: number;
|
|
323
|
+
})
|
|
324
|
+
: undefined,
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
// Update live policy engine if default action changed
|
|
328
|
+
if (body.defaultPolicyAction !== undefined) {
|
|
329
|
+
ctx.policyEngine.setDefaultAction(body.defaultPolicyAction);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const settings = ctx.db.getDaemonSettings();
|
|
333
|
+
return Response.json({ settings });
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* POST /settings/daemon/restart - Restart daemon process
|
|
338
|
+
*/
|
|
339
|
+
export async function handleRestartDaemon(_req: Request, ctx: RouteContext): Promise<Response> {
|
|
340
|
+
if (!ctx.restartDaemon) {
|
|
341
|
+
return Response.json(
|
|
342
|
+
{ error: "Daemon restart is not available in this runtime" },
|
|
343
|
+
{ status: 501 }
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const preserveSessions = ctx.db.getDaemonSettings().preserveSessions;
|
|
348
|
+
try {
|
|
349
|
+
// Intentionally fire-and-forget so we can acknowledge before process teardown.
|
|
350
|
+
void Promise.resolve(ctx.restartDaemon()).catch((error) => {
|
|
351
|
+
console.error("Failed to restart daemon:", error);
|
|
352
|
+
});
|
|
353
|
+
return Response.json(
|
|
354
|
+
{
|
|
355
|
+
restarting: true,
|
|
356
|
+
preserveSessions,
|
|
357
|
+
message: preserveSessions
|
|
358
|
+
? "Daemon restart scheduled. Active sessions will be preserved."
|
|
359
|
+
: "Daemon restart scheduled. Active sessions will be stopped.",
|
|
360
|
+
},
|
|
361
|
+
{ status: 202 }
|
|
362
|
+
);
|
|
363
|
+
} catch (error) {
|
|
364
|
+
const message = error instanceof Error ? error.message : "Failed to schedule daemon restart";
|
|
365
|
+
return Response.json({ error: message }, { status: 500 });
|
|
366
|
+
}
|
|
367
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared helpers for classifying SQLite constraint errors in API routes.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
function getSqliteErrorCode(error: unknown): string | undefined {
|
|
6
|
+
if (!error || typeof error !== "object" || Array.isArray(error)) {
|
|
7
|
+
return undefined;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const maybeCode = (error as { code?: unknown }).code;
|
|
11
|
+
if (typeof maybeCode === "string" && maybeCode.length > 0) {
|
|
12
|
+
return maybeCode;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function getErrorMessage(error: unknown): string {
|
|
19
|
+
return error instanceof Error ? error.message : "Unknown error";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function isSqliteUniqueConstraintError(error: unknown): boolean {
|
|
23
|
+
const code = getSqliteErrorCode(error);
|
|
24
|
+
if (code === "SQLITE_CONSTRAINT_UNIQUE") {
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const message = getErrorMessage(error);
|
|
29
|
+
return (
|
|
30
|
+
message.includes("SQLITE_CONSTRAINT_UNIQUE") ||
|
|
31
|
+
message.includes("UNIQUE constraint") ||
|
|
32
|
+
message.includes("UNIQUE constraint failed")
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function isSqliteForeignKeyConstraintError(error: unknown): boolean {
|
|
37
|
+
const code = getSqliteErrorCode(error);
|
|
38
|
+
if (code === "SQLITE_CONSTRAINT_FOREIGNKEY") {
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const message = getErrorMessage(error);
|
|
43
|
+
return (
|
|
44
|
+
message.includes("SQLITE_CONSTRAINT_FOREIGNKEY") ||
|
|
45
|
+
message.includes("FOREIGN KEY constraint failed")
|
|
46
|
+
);
|
|
47
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { unlink } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
const DEFAULT_STT_TIMEOUT_MS = 45_000;
|
|
7
|
+
const MAX_STDERR_SNIPPET_CHARS = 300;
|
|
8
|
+
|
|
9
|
+
export class SttNotConfiguredError extends Error {
|
|
10
|
+
constructor(message = "Speech-to-text backend is not configured") {
|
|
11
|
+
super(message);
|
|
12
|
+
this.name = "SttNotConfiguredError";
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface SttTranscriptionResult {
|
|
17
|
+
transcript: string;
|
|
18
|
+
backend: string;
|
|
19
|
+
durationMs: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function parseSttCommandTokens(): string[] | null {
|
|
23
|
+
const commandJson = process.env.CODEPIPER_STT_COMMAND_JSON?.trim();
|
|
24
|
+
if (commandJson) {
|
|
25
|
+
try {
|
|
26
|
+
const parsed = JSON.parse(commandJson) as unknown;
|
|
27
|
+
if (Array.isArray(parsed) && parsed.every((item) => typeof item === "string" && item)) {
|
|
28
|
+
return parsed;
|
|
29
|
+
}
|
|
30
|
+
} catch {
|
|
31
|
+
// Fall through to CODEPIPER_STT_COMMAND parsing.
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const command = process.env.CODEPIPER_STT_COMMAND?.trim();
|
|
36
|
+
if (!command) {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const tokens = command.split(/\s+/).filter(Boolean);
|
|
41
|
+
return tokens.length > 0 ? tokens : null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function parseSttTimeoutMs(): number {
|
|
45
|
+
const raw = process.env.CODEPIPER_STT_TIMEOUT_MS;
|
|
46
|
+
if (!raw) {
|
|
47
|
+
return DEFAULT_STT_TIMEOUT_MS;
|
|
48
|
+
}
|
|
49
|
+
const parsed = Number.parseInt(raw, 10);
|
|
50
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
51
|
+
return DEFAULT_STT_TIMEOUT_MS;
|
|
52
|
+
}
|
|
53
|
+
return parsed;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function resolveAudioFileExtension(mimeType: string): string {
|
|
57
|
+
if (mimeType.includes("ogg")) return "ogg";
|
|
58
|
+
if (mimeType.includes("mp4")) return "m4a";
|
|
59
|
+
if (mimeType.includes("wav")) return "wav";
|
|
60
|
+
return "webm";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function materializeCommandTokens(
|
|
64
|
+
tokens: string[],
|
|
65
|
+
inputPath: string,
|
|
66
|
+
mimeType: string
|
|
67
|
+
): { argv: string[]; backend: string } {
|
|
68
|
+
let containsInputPlaceholder = false;
|
|
69
|
+
const argv = tokens.map((token) => {
|
|
70
|
+
const hasInput = token.includes("{input}");
|
|
71
|
+
if (hasInput) {
|
|
72
|
+
containsInputPlaceholder = true;
|
|
73
|
+
}
|
|
74
|
+
return token.replaceAll("{input}", inputPath).replaceAll("{mime}", mimeType);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
if (!containsInputPlaceholder) {
|
|
78
|
+
argv.push(inputPath);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
argv,
|
|
83
|
+
backend: argv[0] ?? "unknown",
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function transcribeAudioFile(file: File): Promise<SttTranscriptionResult> {
|
|
88
|
+
const commandTokens = parseSttCommandTokens();
|
|
89
|
+
if (!commandTokens) {
|
|
90
|
+
throw new SttNotConfiguredError(
|
|
91
|
+
"Set CODEPIPER_STT_COMMAND (or CODEPIPER_STT_COMMAND_JSON) to enable audio transcription"
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const mimeType = file.type || "audio/webm";
|
|
96
|
+
const ext = resolveAudioFileExtension(mimeType);
|
|
97
|
+
const tempPath = join(tmpdir(), `codepiper-stt-${randomUUID()}.${ext}`);
|
|
98
|
+
const startedAt = performance.now();
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
await Bun.write(tempPath, await file.arrayBuffer());
|
|
102
|
+
const { argv, backend } = materializeCommandTokens(commandTokens, tempPath, mimeType);
|
|
103
|
+
const timeoutMs = parseSttTimeoutMs();
|
|
104
|
+
|
|
105
|
+
const proc = Bun.spawn(argv, {
|
|
106
|
+
stdout: "pipe",
|
|
107
|
+
stderr: "pipe",
|
|
108
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const [exitCode, stdout, stderr] = await Promise.all([
|
|
112
|
+
proc.exited,
|
|
113
|
+
new Response(proc.stdout).text(),
|
|
114
|
+
new Response(proc.stderr).text(),
|
|
115
|
+
]);
|
|
116
|
+
|
|
117
|
+
if (exitCode !== 0) {
|
|
118
|
+
const stderrSnippet = stderr.trim().slice(0, MAX_STDERR_SNIPPET_CHARS);
|
|
119
|
+
throw new Error(
|
|
120
|
+
stderrSnippet
|
|
121
|
+
? `STT backend failed (exit ${exitCode}): ${stderrSnippet}`
|
|
122
|
+
: `STT backend failed (exit ${exitCode})`
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const transcript = stdout.trim();
|
|
127
|
+
if (!transcript) {
|
|
128
|
+
throw new Error("STT backend returned empty transcript");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
transcript,
|
|
133
|
+
backend,
|
|
134
|
+
durationMs: Math.max(0, performance.now() - startedAt),
|
|
135
|
+
};
|
|
136
|
+
} finally {
|
|
137
|
+
try {
|
|
138
|
+
await unlink(tempPath);
|
|
139
|
+
} catch {
|
|
140
|
+
// best-effort cleanup
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|