This commit is contained in:
2026-02-16 19:34:35 +00:00
commit 9ab0940ca4
784 changed files with 41710 additions and 0 deletions
+41
View File
@@ -0,0 +1,41 @@
import { createRouter } from '../../create-router';
import {
originMiddleware,
originValidationMiddleware,
userMiddleware,
bodyParser,
signinRateLimiter,
signupRateLimiter,
forgotPasswordRateLimiter,
} from '../../_middlewares';
import { signinHandler } from './signin';
import { signoutHandler } from './signout';
import { signupHandler } from './signup';
import { verifyHandler } from './verify';
import { verifyTokenHandler } from './verify-token';
import { resendVerificationHandler } from './resend-verification';
import { changePasswordHandler } from './change-password';
import { forgotPasswordHandler } from './forgot-password';
import { resetPasswordHandler } from './reset-password';
import { usersMe } from './users-me';
import { passkeyRouter } from './passkey-router';
export const authRouter = createRouter();
authRouter.use(bodyParser());
authRouter.use(originMiddleware);
authRouter.use(originValidationMiddleware);
authRouter.use('/', async (ctx) => ctx.json({ officerAuthServer: 'ok' }));
authRouter.get('/me', userMiddleware, usersMe);
authRouter.post('/signin', signinRateLimiter, signinHandler);
authRouter.post('/signout', userMiddleware, signoutHandler);
authRouter.post('/signup', signupRateLimiter, signupHandler);
authRouter.post('/verify', verifyHandler);
authRouter.post('/verify-token', verifyTokenHandler);
authRouter.post('/resend-verification', resendVerificationHandler);
authRouter.post('/change-password', userMiddleware, changePasswordHandler);
authRouter.post('/forgot-password', forgotPasswordRateLimiter, forgotPasswordHandler);
authRouter.post('/reset-password', resetPasswordHandler);
authRouter.route('/passkeys', passkeyRouter);
+37
View File
@@ -0,0 +1,37 @@
import type { Handler } from 'hono';
import { officerdb, eq, Users } from 'officerdb';
import argon2 from 'argon2';
import { sign } from '@@/jwt';
import * as errors from '@@/custom-errors';
import { validatePassword } from './validate-password';
const { PUBLIC_BUILD_ENV } = process.env;
const isProduction = PUBLIC_BUILD_ENV === 'production' || PUBLIC_BUILD_ENV === 'staging';
export const changePasswordHandler: Handler = async function (ctx) {
const { password, newPassword } = ctx.get('body');
if (isProduction) validatePassword(newPassword);
const reqUser = ctx.get('user');
const dbUser = await officerdb.query.Users.findFirst({
where: eq(Users.id, reqUser.id),
columns: { password: true },
});
if (!dbUser) throw errors.UNAUTHORIZED();
const isCorrectOldPassword = await argon2.verify(dbUser.password!, password);
if (!isCorrectOldPassword) {
throw errors.createError(403, 'Incorrect old password');
}
const newPasswordHash = await argon2.hash(newPassword);
// Use floored seconds-to-ms so the token iat (also floored) is never behind
const passwordChangedAt = Math.floor(Date.now() / 1000) * 1000;
await officerdb.update(Users).set({ password: newPasswordHash, passwordChangedAt }).where(eq(Users.id, reqUser.id));
const { id, email, name, role } = reqUser;
const token = await sign({ id, email, name, role });
return ctx.json({ token });
};
+27
View File
@@ -0,0 +1,27 @@
import type { Handler } from 'hono';
import { officerdb, eq, Users } from 'officerdb';
import { sign } from '@@/jwt';
import { sendMail } from 'emailer';
const { PUBLIC_URL } = process.env;
export const forgotPasswordHandler: Handler = async function (ctx) {
const { email } = ctx.get('body');
const dbUser = await officerdb.query.Users.findFirst({
where: eq(Users.email, email),
});
if (!dbUser) return ctx.json({ ok: true });
const verificationCode = await sign({ id: dbUser.id, email }, '6h');
const url = `${PUBLIC_URL}/auth/reset-password?verificationCode=${verificationCode}`;
await sendMail({
template: 'ForgotPassword',
subject: 'Reset your Officer password',
to: dbUser.email,
data: { email: dbUser.email, url },
});
return ctx.json({ ok: true });
};
+1
View File
@@ -0,0 +1 @@
export * from './auth';
+224
View File
@@ -0,0 +1,224 @@
import type { Handler } from 'hono';
import type { User } from 'types';
import { createRouter } from '../../create-router';
import { userMiddleware, passkeyRateLimiter } from '../../_middlewares';
import { sign } from '../../jwt';
import * as errors from '../../custom-errors';
import { officerdb, eq, and, lt, Passkeys, Users, PasskeyChallenges } from 'officerdb';
import {
generateRegistrationOptions,
verifyRegistrationResponse,
generateAuthenticationOptions,
verifyAuthenticationResponse,
} from '@simplewebauthn/server';
import type { RegistrationResponseJSON, AuthenticationResponseJSON } from '@simplewebauthn/server';
export const passkeyRouter = createRouter();
const CHALLENGE_TTL_MS = 5 * 60 * 1000; // 5 minutes
const RP_NAME = 'Officer';
function getRpId(origin: string): string {
try {
const url = new URL(origin);
return url.hostname;
} catch {
return 'officer.dev';
}
}
async function storeChallenge(email: string, origin: string, challenge: string) {
await officerdb
.insert(PasskeyChallenges)
.values({ email, origin, challenge })
.onConflictDoUpdate({
target: [PasskeyChallenges.email, PasskeyChallenges.origin],
set: { challenge, createdAt: new Date() },
});
}
async function getAndDeleteChallenge(email: string, origin: string): Promise<string | null> {
const minValidTime = new Date(Date.now() - CHALLENGE_TTL_MS);
// Delete expired challenges for this email/origin
await officerdb
.delete(PasskeyChallenges)
.where(
and(
eq(PasskeyChallenges.email, email),
eq(PasskeyChallenges.origin, origin),
lt(PasskeyChallenges.createdAt, minValidTime),
),
);
// Get and delete the challenge in one operation
const result = await officerdb
.delete(PasskeyChallenges)
.where(and(eq(PasskeyChallenges.email, email), eq(PasskeyChallenges.origin, origin)))
.returning();
const entry = result[0];
if (!entry) return null;
// Double-check TTL (in case of race condition)
if (entry.createdAt < minValidTime) return null;
return entry.challenge;
}
// Generate registration options (challenge) for creating a new passkey
const passkeyRouterPostChallenge: Handler = async (ctx) => {
const { email } = ctx.req.param();
const origin = ctx.get('origin') as string;
const rpId = getRpId(origin);
// Get existing passkeys to exclude them
const existingPasskeys = await officerdb.query.Passkeys.findMany({
where: and(eq(Passkeys.email, email!), eq(Passkeys.origin, origin)),
});
const options = await generateRegistrationOptions({
rpName: RP_NAME,
rpID: rpId,
userName: email!,
userDisplayName: email!,
attestationType: 'none',
excludeCredentials: existingPasskeys.map((p) => ({
id: p.credentialId!,
})),
authenticatorSelection: {
residentKey: 'preferred',
userVerification: 'preferred',
},
});
await storeChallenge(email!, origin, options.challenge);
return ctx.json(options);
};
passkeyRouter.post('/challenge/:email', passkeyRateLimiter, passkeyRouterPostChallenge);
// Verify and store new passkey registration
const passkeyRouterPost: Handler = async (ctx) => {
const origin = ctx.get('origin') as string;
const rpId = getRpId(origin);
const { email } = ctx.get('user') as User;
const response = ctx.get('body') as RegistrationResponseJSON;
const storedChallenge = await getAndDeleteChallenge(email, origin);
if (!storedChallenge) throw errors.BAD_CREDENTIALS();
const verification = await verifyRegistrationResponse({
response,
expectedChallenge: storedChallenge,
expectedOrigin: origin,
expectedRPID: rpId,
});
if (!verification.verified || !verification.registrationInfo) {
throw errors.BAD_CREDENTIALS();
}
const { credential, credentialDeviceType, credentialBackedUp } = verification.registrationInfo;
const values = {
email,
origin,
credentialId: credential.id,
publicKey: Buffer.from(credential.publicKey).toString('base64'),
counter: credential.counter,
};
await officerdb.insert(Passkeys).values(values);
return ctx.json({ ok: true, credentialDeviceType, credentialBackedUp });
};
passkeyRouter.post('/credentials', userMiddleware, passkeyRouterPost);
// Generate authentication options for signing in with passkey
const passkeyRouterGet: Handler = async (ctx) => {
const { email } = ctx.req.param();
const origin = ctx.get('origin') as string;
const rpId = getRpId(origin);
const passkeys = await officerdb.query.Passkeys.findMany({
where: and(eq(Passkeys.email, email!), eq(Passkeys.origin, origin)),
});
const options = await generateAuthenticationOptions({
rpID: rpId,
allowCredentials: passkeys.map((p) => ({
id: p.credentialId!,
})),
userVerification: 'preferred',
});
await storeChallenge(email!, origin, options.challenge);
return ctx.json(options);
};
passkeyRouter.get('/signin/:email', passkeyRateLimiter, passkeyRouterGet);
// Verify passkey authentication and issue token
const passkeyRouterPostVerify: Handler = async (ctx) => {
const { email } = ctx.req.param();
const origin = ctx.get('origin') as string;
const rpId = getRpId(origin);
const response = ctx.get('body') as AuthenticationResponseJSON;
const storedChallenge = await getAndDeleteChallenge(email!, origin);
if (!storedChallenge) throw errors.BAD_CREDENTIALS();
// Find the passkey being used
const dbPasskey = await officerdb.query.Passkeys.findFirst({
where: and(eq(Passkeys.email, email!), eq(Passkeys.credentialId, response.id)),
});
if (!dbPasskey || !dbPasskey.publicKey) throw errors.BAD_CREDENTIALS();
const verification = await verifyAuthenticationResponse({
response,
expectedChallenge: storedChallenge,
expectedOrigin: origin,
expectedRPID: rpId,
credential: {
id: dbPasskey.credentialId!,
publicKey: Buffer.from(dbPasskey.publicKey, 'base64'),
counter: dbPasskey.counter,
},
});
if (!verification.verified) throw errors.BAD_CREDENTIALS();
// Update counter to prevent replay attacks
await officerdb
.update(Passkeys)
.set({ counter: verification.authenticationInfo.newCounter })
.where(eq(Passkeys.id, dbPasskey.id));
const dbUser = await officerdb.query.Users.findFirst({
where: eq(Users.email, email!),
with: { passkeys: true },
});
if (!dbUser) throw errors.UNAUTHORIZED();
const { id, name, role } = dbUser;
const passkeys = dbUser.passkeys?.length ?? 0;
const token = await sign({
id,
email,
name,
role,
passkeys,
});
return ctx.json({
token,
user: {
id,
email,
name,
role,
passkeys,
},
});
};
passkeyRouter.post('/verify/:email', passkeyRateLimiter, passkeyRouterPostVerify);
@@ -0,0 +1,30 @@
import type { Handler } from 'hono';
import { officerdb, eq, Users } from 'officerdb';
import { sign } from '@@/jwt';
import * as errors from '@@/custom-errors';
import { sendMail } from 'emailer';
export const resendVerificationHandler: Handler = async function (ctx) {
const { email } = ctx.get('body');
const origin = ctx.get('origin');
if (!email || typeof email !== 'string') throw errors.BAD_REQUEST('Email is required');
const user = await officerdb.query.Users.findFirst({
where: eq(Users.email, email),
});
if (!user) throw errors.NOT_FOUND('User not found');
if (user.status !== 'Unverified') throw errors.BAD_REQUEST('Account is already verified');
const verificationCode = await sign({ id: user.id, email: user.email }, '24h');
const url = `${origin}/auth/verify?verificationCode=${verificationCode}`;
await sendMail({
template: 'VerifyAdmin',
subject: 'Verify your Officer account',
to: user.email,
data: { name: user.email, url },
});
return ctx.json({ ok: true });
};
+22
View File
@@ -0,0 +1,22 @@
import type { User } from 'types';
import type { Handler } from 'hono';
import { officerdb, eq, Users } from 'officerdb';
import { verify } from '@@/jwt';
import argon2 from 'argon2';
import * as errors from '@@/custom-errors';
import { validatePassword } from './validate-password';
export const resetPasswordHandler: Handler = async function (ctx) {
const now = Date.now().valueOf();
const { password, verificationCode } = ctx.get('body');
validatePassword(password);
const userInfo = (await verify(verificationCode)) as User;
if (!userInfo) throw errors.UNAUTHORIZED();
const passwordHash = await argon2.hash(password);
await officerdb
.update(Users)
.set({ password: passwordHash, status: 'Active', passwordChangedAt: now })
.where(eq(Users.id, userInfo.id));
return ctx.json({ ok: true });
};
+46
View File
@@ -0,0 +1,46 @@
import type { Handler } from 'hono';
import { mkdir } from 'node:fs/promises';
import { join } from 'node:path';
import { officerdb, eq, and, Users, Passkeys } from 'officerdb';
import { sign } from '@@/jwt';
import { getClaudeDir } from '@@/data-path';
import argon2 from 'argon2';
import * as errors from '@@/custom-errors';
const TEST_USERS: number[] = [];
export const signinHandler: Handler = async function (ctx) {
const { email, password } = ctx.get('body');
const origin = ctx.get('origin');
const dbUser = await officerdb.query.Users.findFirst({
where: eq(Users.email, email),
});
const passkeys = await officerdb.query.Passkeys.findMany({
where: and(eq(Passkeys.email, email), eq(Passkeys.origin, origin)),
});
if (!dbUser || !dbUser.password) throw errors.UNAUTHORIZED();
const { status } = dbUser;
if (status !== 'Active') throw errors.UNAUTHORIZED();
const isValidPassword = TEST_USERS.includes(dbUser.id) || (await argon2.verify(dbUser.password, password));
if (!isValidPassword) throw errors.UNAUTHORIZED();
const { id, name, role } = dbUser;
mkdir(join(getClaudeDir(email), 'archived'), { recursive: true }).catch(() => {});
const tokenUser = { id, email, name, role, passkeys: passkeys.length };
if (passkeys.length > 0 && !origin.startsWith('chrome-extension://') && !TEST_USERS.includes(dbUser.id)) {
return ctx.json({ user: tokenUser });
}
const token = await sign(tokenUser);
if (origin.startsWith('chrome-extension://')) {
// console.log('token', token);
}
return ctx.json({ token, user: tokenUser });
};
+26
View File
@@ -0,0 +1,26 @@
import type { Handler } from 'hono';
import { officerdb, TokenBlacklist, lt } from 'officerdb';
// Cleanup expired blacklist entries (can be called periodically)
export async function cleanupExpiredTokens() {
const now = Math.floor(Date.now() / 1000);
await officerdb.delete(TokenBlacklist).where(lt(TokenBlacklist.expiresAt, now));
}
export const signoutHandler: Handler = async (ctx) => {
const user = ctx.get('user') as { jti: string; exp: number };
// Add token to blacklist
await officerdb
.insert(TokenBlacklist)
.values({
jti: user.jti,
expiresAt: user.exp,
})
.onConflictDoNothing();
// Opportunistic cleanup of expired tokens (non-blocking)
cleanupExpiredTokens().catch(() => {});
return ctx.json({ ok: true });
};
+41
View File
@@ -0,0 +1,41 @@
import type { Handler } from 'hono';
import { officerdb, count, Users } from 'officerdb';
import { sign } from '@@/jwt';
import { USER_ROLES, USER_STATUSES } from 'definitions';
import * as errors from '@@/custom-errors';
import { sendMail } from 'emailer';
export const signupHandler: Handler = async function (ctx) {
const body = ctx.get('body');
const origin = ctx.get('origin');
if (!body.email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(body.email)) {
throw errors.BAD_REQUEST('Invalid email address');
}
const result = await officerdb.select({ count: count() }).from(Users);
const userCount = result[0]?.count ?? 0;
if (userCount > 0) throw errors.FORBIDDEN('Registration is closed');
const newUser = {
email: body.email as string,
status: 'Unverified' as (typeof USER_STATUSES)[number],
role: 'Admin' as (typeof USER_ROLES)[number],
};
const insertedUsers = await officerdb.insert(Users).values(newUser).returning();
if (!insertedUsers || insertedUsers.length === 0) throw errors.INTERNAL_SERVER_ERROR('Failed to create user');
const dbUser = insertedUsers[0]!;
const verificationCode = await sign({ id: dbUser.id, email: dbUser.email }, '24h');
const url = `${origin}/auth/verify?verificationCode=${verificationCode}`;
await sendMail({
template: 'VerifyAdmin',
subject: 'Verify your Officer account',
to: dbUser.email,
data: { name: dbUser.email, url },
});
return ctx.json({ ok: true, user: { id: dbUser.id, email: dbUser.email } });
};
+24
View File
@@ -0,0 +1,24 @@
import type { Handler } from 'hono';
import type { User } from 'types';
import * as errors from '@@/custom-errors';
import { officerdb, eq, Users, Passkeys } from 'officerdb';
export const usersMe: Handler = async function (ctx) {
const user = ctx.get('user') as User;
const origin = ctx.get('origin') as string;
const dbUser = await officerdb.query.Users.findFirst({
where: eq(Users.id, user.id),
columns: { password: false },
with: {
passkeys: { where: eq(Passkeys.origin, origin || '') },
},
});
if (!dbUser) return errors.NOT_FOUND();
const { passkeys, ...userWithoutPasskeys } = dbUser;
const returnUser = { ...userWithoutPasskeys, passkeyCount: passkeys.length };
return ctx.json(returnUser);
};
+23
View File
@@ -0,0 +1,23 @@
import * as errors from '@@/custom-errors';
export function validatePassword(password: string | undefined): void {
if (!password || password.length < 12) {
throw errors.BAD_REQUEST('Password must be at least 12 characters');
}
if (!/[a-z]/.test(password)) {
throw errors.BAD_REQUEST('Password must contain lowercase letters');
}
if (!/[A-Z]/.test(password)) {
throw errors.BAD_REQUEST('Password must contain uppercase letters');
}
if (!/[0-9]/.test(password)) {
throw errors.BAD_REQUEST('Password must contain numbers');
}
if (!/[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]/.test(password)) {
throw errors.BAD_REQUEST('Password must contain special characters (!@#$%^&* etc.)');
}
}
+27
View File
@@ -0,0 +1,27 @@
import type { Handler } from 'hono';
import type { User } from 'types';
import { officerdb, eq, Users } from 'officerdb';
import { verify } from '@@/jwt';
import * as errors from '@@/custom-errors';
export const verifyTokenHandler: Handler = async function (ctx) {
const { verificationCode } = ctx.get('body');
if (!verificationCode) throw errors.BAD_REQUEST('Missing verification code');
let userInfo: User;
try {
userInfo = (await verify(verificationCode)) as User;
} catch {
throw errors.BAD_REQUEST('Token is invalid or expired');
}
if (!userInfo?.id) throw errors.BAD_REQUEST('Token is invalid or expired');
const user = await officerdb.query.Users.findFirst({
where: eq(Users.id, userInfo.id),
});
if (!user) throw errors.NOT_FOUND('User not found');
if (user.status !== 'Unverified') throw errors.BAD_REQUEST('Account is already verified');
return ctx.json({ ok: true, email: user.email });
};
+42
View File
@@ -0,0 +1,42 @@
import type { Handler } from 'hono';
import type { User } from 'types';
import { officerdb, eq, Users } from 'officerdb';
import { verify as verifyJwt, sign } from '@@/jwt';
import argon2 from 'argon2';
import * as errors from '@@/custom-errors';
import { validatePassword } from './validate-password';
export const verifyHandler: Handler = async function (ctx) {
const { verificationCode, name, password, confirmPassword } = ctx.get('body');
const userInfo = (await verifyJwt(verificationCode)) as User;
if (!userInfo) throw errors.BAD_REQUEST();
const user = await officerdb.query.Users.findFirst({
where: eq(Users.id, userInfo.id),
});
if (!user) throw errors.NOT_FOUND('User not found');
const updates: Record<string, unknown> = { status: 'Active' };
if (name) {
if (typeof name !== 'string' || !name.trim() || name.length > 128) {
throw errors.BAD_REQUEST('Name must be between 1 and 128 characters');
}
updates.name = name.trim();
}
if (password) {
validatePassword(password);
if (password !== confirmPassword) {
throw errors.BAD_REQUEST('Passwords do not match');
}
updates.password = await argon2.hash(password);
}
await officerdb.update(Users).set(updates).where(eq(Users.id, userInfo.id));
// Issue a token so the user is logged in immediately
const token = await sign({ id: userInfo.id, email: userInfo.email });
return ctx.json({ ok: true, token });
};
+34
View File
@@ -0,0 +1,34 @@
// Client → Server
export type ImageData = { mediaType: string; data: string };
export type TaskInfo = {
taskName: string;
taskDirName: string;
entryName: string;
entryType: 'file' | 'directory';
};
export type ClientMessage =
| {
type: 'chat';
prompt: string;
sessionId?: string;
model?: string | { providerID?: string; modelID?: string };
cwd?: { root?: string; path: string };
attachmentIds?: string[];
images?: ImageData[];
resourceChatDir?: string;
taskInfo?: TaskInfo;
}
| { type: 'stop' };
// Server → Client
export type ServerMessage =
| { type: 'session:init'; sessionId: string; model: string }
| { type: 'assistant:text'; text: string }
| { type: 'assistant:partial'; text: string }
| { type: 'tool:use'; toolName: string; toolInput: Record<string, unknown>; toolUseId: string }
| { type: 'tool:result'; toolUseId: string; output: string; isError: boolean }
| { type: 'result'; costUsd: number; durationMs: number; numTurns: number; isError: boolean }
| { type: 'error'; message: string }
| { type: 'stopped' };
+114
View File
@@ -0,0 +1,114 @@
import { Hono } from 'hono';
import { mkdir, readdir, rename, rm } from 'node:fs/promises';
import { join } from 'node:path';
import { getClaudeDir, getSessionDir, getArchivedSessionDir } from '@@/data-path';
import type { HonoVariables } from '@@/create-router';
export const sessionsRouter = new Hono<{ Variables: HonoVariables }>();
sessionsRouter.get('/claude/models', async (ctx) => {
const apiKey = process.env.ANTHROPIC_API_KEY;
if (!apiKey) return ctx.json([]);
try {
const res = await fetch('https://api.anthropic.com/v1/models?limit=100', {
headers: { 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' },
});
if (!res.ok) return ctx.json([]);
const data = (await res.json()) as { data?: { id: string; display_name: string }[] };
const models = (data.data ?? []).map((m) => ({ id: m.id, name: m.display_name }));
return ctx.json(models);
} catch {
return ctx.json([]);
}
});
sessionsRouter.get('/sessions', async (ctx) => {
const { email } = ctx.get('user');
const dir = getClaudeDir(email);
try {
const entries = await readdir(dir);
const sessions = await Promise.all(
entries
.filter((name) => name !== 'archived')
.map(async (id) => {
try {
const metaFile = Bun.file(join(dir, id, 'meta.json'));
if (!(await metaFile.exists())) return null;
return await metaFile.json();
} catch {
return null;
}
}),
);
const valid = sessions.filter(Boolean);
valid.sort((a: any, b: any) => b.createdAt - a.createdAt);
return ctx.json(valid);
} catch {
return ctx.json([]);
}
});
sessionsRouter.get('/sessions/:id/messages', async (ctx) => {
const { email } = ctx.get('user');
const id = ctx.req.param('id');
const file = Bun.file(join(getSessionDir(email, id), 'messages.json'));
if (!(await file.exists())) return ctx.json([]);
const messages = await file.json();
return ctx.json(messages);
});
sessionsRouter.put('/sessions/:id/messages', async (ctx) => {
const { email } = ctx.get('user');
const id = ctx.req.param('id');
const messages = ctx.get('body');
const dir = getSessionDir(email, id);
await Bun.write(join(dir, 'messages.json'), JSON.stringify(messages));
return ctx.json({ ok: true });
});
sessionsRouter.put('/sessions/:id', async (ctx) => {
const { email } = ctx.get('user');
const id = ctx.req.param('id');
const body = ctx.get('body') as { title?: string };
if (!body?.title || typeof body.title !== 'string') return ctx.json({ error: 'title required' }, 400);
const dir = getSessionDir(email, id);
const metaFile = Bun.file(join(dir, 'meta.json'));
if (!(await metaFile.exists())) return ctx.json({ error: 'session not found' }, 404);
const meta = await metaFile.json();
meta.title = body.title.slice(0, 200);
await Bun.write(join(dir, 'meta.json'), JSON.stringify(meta));
return ctx.json({ ok: true });
});
sessionsRouter.delete('/sessions/:id', async (ctx) => {
const { email } = ctx.get('user');
const id = ctx.req.param('id');
const dir = getSessionDir(email, id);
try {
await rm(dir, { recursive: true });
} catch {
// dir may not exist
}
return ctx.json({ ok: true });
});
sessionsRouter.post('/sessions/:id/archive', async (ctx) => {
const { email } = ctx.get('user');
const id = ctx.req.param('id');
const src = getSessionDir(email, id);
const dest = getArchivedSessionDir(email, id);
await mkdir(join(getClaudeDir(email), 'archived'), { recursive: true });
await rename(src, dest);
return ctx.json({ ok: true });
});
+1
View File
@@ -0,0 +1 @@
export type { ClientMessage, ServerMessage } from '@@/api/chat-types';
+390
View File
@@ -0,0 +1,390 @@
import type { ServerWebSocket } from 'bun';
import { mkdir, rename } from 'node:fs/promises';
import { join, resolve } from 'node:path';
import { homedir } from 'node:os';
import { query } from '@anthropic-ai/claude-agent-sdk';
import type { SDKUserMessage } from '@anthropic-ai/claude-agent-sdk';
import {
getSessionDir,
getTmpAttachmentsDir,
getAttachmentsDir,
getHomeDir,
getNativeSkillsDir,
getGlobalSkillsDir,
getUserSkillsDir,
} from '@@/data-path';
import { readSkillDirs, parseFrontmatter } from '@@/api/skills/skills';
import type { ClientMessage, ServerMessage, ImageData, TaskInfo } from '@@/api/chat-types';
import { createTaskLog, appendToLog, finalizeLog } from '@@/api/task-logger';
type WSData = { userId: number; email: string };
type ConnectionState = {
abortController: AbortController | null;
currentSessionId: string | null;
pendingTitle: string | null;
selectedModel: string | null;
pendingAttachmentIds: string[];
cwd: string | null;
resourceChatDir: string | null;
logId: string | null;
};
const connections = new Map<ServerWebSocket<WSData>, ConnectionState>();
function send(ws: ServerWebSocket<WSData>, msg: ServerMessage) {
if (ws.readyState === 1) ws.send(JSON.stringify(msg));
}
type HandleChatParams = {
ws: ServerWebSocket<WSData>;
prompt: string;
sessionId?: string;
model?: string;
cwd?: { root?: string; path: string };
attachmentIds?: string[];
images?: ImageData[];
resourceChatDir?: string;
taskInfo?: TaskInfo;
};
function resolveRootDir(email: string, root?: string): string {
if (!root || root === 'home') return getHomeDir(email);
if (root === '~') return homedir();
if (root === 'officer.dev') return resolve(process.cwd(), '..');
return getHomeDir(email);
}
async function buildSkillsPrompt(email: string): Promise<string> {
const nativeSkills = await readSkillDirs(getNativeSkillsDir());
const globalSkills = await readSkillDirs(getGlobalSkillsDir());
const userSkills = await readSkillDirs(getUserSkillsDir(email));
const merged = new Map(nativeSkills);
for (const [name, path] of globalSkills) merged.set(name, path);
for (const [name, path] of userSkills) merged.set(name, path);
if (merged.size === 0) return '';
const lines = await Promise.all(
Array.from(merged.entries()).map(async ([dirName, filePath]) => {
const raw = await Bun.file(filePath).text();
const { frontmatter } = parseFrontmatter(raw);
const name = frontmatter.name || dirName;
return `- ${name}: ${frontmatter.description} (read ${filePath} for full instructions)`;
}),
);
return `\n\nYou have access to the following skills. When a user's request matches a skill, read its SKILL.md file for detailed instructions before proceeding.\n\nAvailable skills:\n${lines.join('\n')}`;
}
async function handleChat({
ws,
prompt,
sessionId,
model,
cwd,
attachmentIds,
images,
resourceChatDir,
taskInfo,
}: HandleChatParams) {
const state = connections.get(ws);
if (!state) return;
if (taskInfo && !state.logId) {
state.logId = createTaskLog(ws.data.email, taskInfo, 'claude', model ?? 'unknown');
appendToLog(state.logId, { role: 'user', text: prompt });
}
if (resourceChatDir) state.resourceChatDir = resourceChatDir;
if (model) {
state.selectedModel = model;
// Persist model choice to meta.json if session exists
if (sessionId) {
const dir = state.resourceChatDir ? join(state.resourceChatDir, 'chat') : getSessionDir(ws.data.email, sessionId);
const metaFile = Bun.file(join(dir, 'meta.json'));
metaFile
.json()
.then((meta: Record<string, unknown>) => {
meta.model = model;
return Bun.write(join(dir, 'meta.json'), JSON.stringify(meta));
})
.catch(() => {});
}
}
if (!sessionId) {
state.pendingTitle = prompt.slice(0, 100);
if (attachmentIds?.length) state.pendingAttachmentIds = attachmentIds;
}
// Abort previous generation if any
if (state.abortController) {
state.abortController.abort();
state.abortController = null;
}
const abortController = new AbortController();
state.abortController = abortController;
try {
const homeDir = getHomeDir(ws.data.email);
if (cwd) state.cwd = join(resolveRootDir(ws.data.email, cwd.root), cwd.path);
const workingDir = state.cwd ?? homeDir;
const skillsAppend = await buildSkillsPrompt(ws.data.email);
// Build prompt: use AsyncIterable<SDKUserMessage> with image content blocks when images are present
let promptInput: string | AsyncIterable<SDKUserMessage> = prompt;
if (images?.length) {
const content: any[] = [];
for (const img of images) {
content.push({
type: 'image',
source: { type: 'base64', media_type: img.mediaType, data: img.data },
});
}
content.push({ type: 'text', text: prompt });
async function* generateMessage(): AsyncIterable<SDKUserMessage> {
yield {
type: 'user',
message: { role: 'user', content },
parent_tool_use_id: null,
session_id: sessionId ?? '',
} as SDKUserMessage;
}
promptInput = generateMessage();
}
const stream = query({
prompt: promptInput,
options: {
abortController,
cwd: workingDir,
permissionMode: 'bypassPermissions',
allowDangerouslySkipPermissions: true,
systemPrompt: {
type: 'preset',
preset: 'claude_code',
...(skillsAppend ? { append: skillsAppend } : {}),
},
additionalDirectories: [],
includePartialMessages: true,
...(state.selectedModel ? { model: state.selectedModel } : {}),
...(sessionId ? { resume: sessionId } : {}),
},
});
for await (const message of stream) {
if (abortController.signal.aborted) break;
console.log('[claude-ws] message type:', message.type, 'subtype' in message ? message.subtype : '');
if (message.type === 'system' && message.subtype === 'init') {
state.currentSessionId = message.session_id;
send(ws, { type: 'session:init', sessionId: message.session_id, model: message.model });
if (state.resourceChatDir) {
// Store session in resource's chat/ subdirectory
const chatDir = join(state.resourceChatDir, 'chat');
const meta = { id: message.session_id, model: message.model };
mkdir(chatDir, { recursive: true })
.then(() => Bun.write(join(chatDir, 'meta.json'), JSON.stringify(meta)))
.catch(() => {});
} else {
// Default: store in global sessions directory
const dir = getSessionDir(ws.data.email, message.session_id);
const meta = {
id: message.session_id,
title: state.pendingTitle ?? 'New chat',
createdAt: Date.now(),
model: message.model,
};
mkdir(dir, { recursive: true })
.then(() => Bun.write(join(dir, 'meta.json'), JSON.stringify(meta)))
.catch(() => {});
// Move tmp attachments to session dir
if (state.pendingAttachmentIds.length > 0) {
const tmpDir = getTmpAttachmentsDir(ws.data.email);
const destDir = getAttachmentsDir(ws.data.email, 'claude', message.session_id);
mkdir(destDir, { recursive: true })
.then(() =>
Promise.all(
state.pendingAttachmentIds.map((id) => rename(join(tmpDir, id), join(destDir, id)).catch(() => {})),
),
)
.catch(() => {});
state.pendingAttachmentIds = [];
}
}
state.pendingTitle = null;
continue;
}
if (message.type === 'assistant') {
const content = (message as any).message?.content;
console.log('[claude-ws] assistant content:', JSON.stringify(content)?.slice(0, 500));
if (!Array.isArray(content)) continue;
for (const block of content) {
if (block.type === 'text') {
send(ws, { type: 'assistant:text', text: block.text });
if (state.logId) appendToLog(state.logId, { role: 'assistant', text: block.text });
} else if (block.type === 'tool_use') {
send(ws, {
type: 'tool:use',
toolName: block.name,
toolInput: block.input as Record<string, unknown>,
toolUseId: block.id,
});
if (state.logId)
appendToLog(state.logId, {
role: 'tool',
toolName: block.name,
toolInput: block.input as Record<string, unknown>,
toolUseId: block.id,
});
}
}
continue;
}
if (message.type === 'user') {
const content = (message as any).message?.content;
if (!Array.isArray(content)) continue;
for (const block of content) {
if (block.type === 'tool_result') {
const output =
typeof block.content === 'string'
? block.content
: Array.isArray(block.content)
? block.content
.filter((c: { type: string }) => c.type === 'text')
.map((c: { text: string }) => c.text)
.join('\n')
: '';
send(ws, {
type: 'tool:result',
toolUseId: block.tool_use_id,
output,
isError: !!block.is_error,
});
if (state.logId)
appendToLog(state.logId, {
role: 'tool',
toolName: '',
toolInput: {},
toolUseId: block.tool_use_id,
output,
isError: !!block.is_error,
});
}
}
continue;
}
if (message.type === 'stream_event') {
const ev = (message as any).event;
console.log('[claude-ws] stream_event:', ev?.type, ev?.delta?.type);
if (ev.type === 'content_block_delta' && ev.delta.type === 'text_delta') {
send(ws, { type: 'assistant:partial', text: ev.delta.text });
}
continue;
}
if (message.type === 'result') {
send(ws, {
type: 'result',
costUsd: message.total_cost_usd,
durationMs: message.duration_ms,
numTurns: message.num_turns,
isError: message.is_error,
});
if (state.logId) {
appendToLog(state.logId, {
role: 'result',
costUsd: message.total_cost_usd,
durationMs: message.duration_ms,
numTurns: message.num_turns,
isError: message.is_error,
});
finalizeLog(state.logId);
state.logId = null;
}
continue;
}
}
} catch (err) {
if (!abortController.signal.aborted) {
send(ws, { type: 'error', message: err instanceof Error ? err.message : 'Unknown error' });
}
if (state.logId) {
appendToLog(state.logId, { role: 'error', text: err instanceof Error ? err.message : 'Unknown error' });
finalizeLog(state.logId);
state.logId = null;
}
} finally {
if (state.abortController === abortController) {
state.abortController = null;
}
}
}
export const claudeWebsocket = {
open(ws: ServerWebSocket<WSData>) {
connections.set(ws, {
abortController: null,
currentSessionId: null,
pendingTitle: null,
selectedModel: null,
pendingAttachmentIds: [],
cwd: null,
resourceChatDir: null,
logId: null,
});
},
message(ws: ServerWebSocket<WSData>, raw: string | Buffer) {
let msg: ClientMessage;
try {
msg = JSON.parse(typeof raw === 'string' ? raw : raw.toString()) as ClientMessage;
} catch {
send(ws, { type: 'error', message: 'Invalid JSON' });
return;
}
if (msg.type === 'chat') {
handleChat({
ws,
prompt: msg.prompt,
sessionId: msg.sessionId,
model: msg.model,
cwd: msg.cwd,
attachmentIds: msg.attachmentIds,
images: msg.images,
resourceChatDir: msg.resourceChatDir,
taskInfo: msg.taskInfo,
});
} else if (msg.type === 'stop') {
const state = connections.get(ws);
if (state?.abortController) {
state.abortController.abort();
state.abortController = null;
}
send(ws, { type: 'stopped' });
}
},
close(ws: ServerWebSocket<WSData>) {
const state = connections.get(ws);
if (state?.abortController) {
state.abortController.abort();
}
connections.delete(ws);
},
drain() {},
};
+1
View File
@@ -0,0 +1 @@
export { authRouter } from './auth';
+153
View File
@@ -0,0 +1,153 @@
import { Hono } from 'hono';
import type { HonoVariables } from '@@/create-router';
const OPENCODE_PORT = process.env.OPENCODE_PORT ?? '10006';
const BASE = `http://localhost:${OPENCODE_PORT}`;
export const opencodeSessionsRouter = new Hono<{ Variables: HonoVariables }>();
// List all active models from OpenCode — visibility filtering is handled client-side
opencodeSessionsRouter.get('/models', async (ctx) => {
try {
const res = await fetch(`${BASE}/provider`);
if (!res.ok) return ctx.json([]);
const data = (await res.json()) as { all?: Record<string, unknown>[] };
const providers = Array.isArray(data.all) ? data.all : [];
const models: { id: string; name: string; provider: string; providerId: string }[] = [];
for (const p of providers as Record<string, unknown>[]) {
const providerId = (p.id as string) ?? '';
const providerName = (p.name as string) ?? providerId;
const modelMap = (p.models ?? {}) as Record<string, Record<string, unknown>>;
for (const m of Object.values(modelMap)) {
if (m.status === 'active') {
models.push({
id: m.id as string,
name: (m.name as string) ?? (m.id as string),
provider: providerName,
providerId,
});
}
}
}
return ctx.json(models);
} catch {
return ctx.json([]);
}
});
// List sessions — proxy to OpenCode API (SQLite-backed)
opencodeSessionsRouter.get('/sessions', async (ctx) => {
try {
const res = await fetch(`${BASE}/session`);
if (!res.ok) return ctx.json([]);
const data = (await res.json()) as any[];
const sessions = data.map((s: any) => ({
id: s.id,
title: s.title ?? 'Untitled',
createdAt: s.time?.created ?? 0,
}));
sessions.sort((a: any, b: any) => b.createdAt - a.createdAt);
return ctx.json(sessions);
} catch {
return ctx.json([]);
}
});
// Get session messages — proxy to OpenCode
opencodeSessionsRouter.get('/sessions/:id/messages', async (ctx) => {
const id = ctx.req.param('id');
try {
const res = await fetch(`${BASE}/session/${id}/message`);
if (!res.ok) return ctx.json([]);
const data = (await res.json()) as any[];
const messages = Array.isArray(data) ? data : Object.values(data);
const chatMessages: any[] = [];
for (const msg of messages) {
const role = msg.info?.role ?? msg.role;
if (role === 'user') {
const text = Array.isArray(msg.parts)
? msg.parts
.filter((p: any) => p.type === 'text')
.map((p: any) => p.text ?? p.content ?? '')
.join('')
: typeof msg.content === 'string'
? msg.content
: '';
if (text) chatMessages.push({ role: 'user', text });
} else if (role === 'assistant') {
if (Array.isArray(msg.parts)) {
for (const part of msg.parts) {
if (part.type === 'text' && (part.text || part.content)) {
chatMessages.push({ role: 'assistant', text: part.text ?? part.content ?? '' });
} else if (part.type === 'tool') {
chatMessages.push({
role: 'tool',
toolName: part.tool ?? 'unknown',
toolInput: part.state?.input ?? {},
toolUseId: part.callID ?? part.id ?? '',
output:
part.state?.output != null
? typeof part.state.output === 'string'
? part.state.output
: JSON.stringify(part.state.output)
: undefined,
isError: part.state?.status === 'error',
});
} else if (part.type === 'tool-invocation') {
const inv = part.toolInvocation ?? part;
chatMessages.push({
role: 'tool',
toolName: inv.toolName ?? 'unknown',
toolInput: inv.args ?? {},
toolUseId: inv.toolCallId ?? part.id ?? '',
output:
inv.result != null
? typeof inv.result === 'string'
? inv.result
: JSON.stringify(inv.result)
: undefined,
isError: !!part.isError,
});
}
}
}
}
}
return ctx.json(chatMessages);
} catch {
return ctx.json([]);
}
});
// Rename session — proxy to OpenCode API
opencodeSessionsRouter.put('/sessions/:id', async (ctx) => {
const id = ctx.req.param('id');
const body = ctx.get('body') as { title?: string };
if (!body?.title || typeof body.title !== 'string') return ctx.json({ error: 'title required' }, 400);
try {
const res = await fetch(`${BASE}/session/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: body.title.slice(0, 200) }),
});
if (!res.ok) return ctx.json({ error: 'failed to rename' }, 500);
return ctx.json({ ok: true });
} catch {
return ctx.json({ error: 'failed to rename' }, 500);
}
});
// Delete session — proxy to OpenCode API
opencodeSessionsRouter.delete('/sessions/:id', async (ctx) => {
const id = ctx.req.param('id');
try {
await fetch(`${BASE}/session/${id}`, { method: 'DELETE' });
} catch {
// best-effort
}
return ctx.json({ ok: true });
});
+470
View File
@@ -0,0 +1,470 @@
import type { ServerWebSocket } from 'bun';
import { mkdir, rename } from 'node:fs/promises';
import { join } from 'node:path';
import type { ClientMessage, ServerMessage, ImageData, TaskInfo } from '@@/api/chat-types';
import { getTmpAttachmentsDir, getAttachmentsDir } from '@@/data-path';
import { createTaskLog, appendToLog, finalizeLog } from '@@/api/task-logger';
const OPENCODE_PORT = process.env.OPENCODE_PORT ?? '10006';
const BASE = `http://localhost:${OPENCODE_PORT}`;
// Cache model → provider mapping so we can send providerID alongside modelID
let modelProviderMap = new Map<string, string>();
let providerIdSet = new Set<string>();
let modelProviderMapAge = 0;
const MODEL_MAP_TTL = 5 * 60 * 1000;
async function refreshProviderMap() {
if (Date.now() - modelProviderMapAge > MODEL_MAP_TTL || modelProviderMapAge === 0) {
try {
const res = await fetch(`${BASE}/provider`);
if (res.ok) {
const data = (await res.json()) as { all?: Record<string, unknown>[] };
const providers = Array.isArray(data.all) ? data.all : [];
const newMap = new Map<string, string>();
const newProviderSet = new Set<string>();
for (const p of providers as Record<string, unknown>[]) {
const pid = (p.id as string) ?? (p.name as string) ?? '';
if (pid) newProviderSet.add(pid);
const modelMap = (p.models ?? {}) as Record<string, Record<string, unknown>>;
for (const m of Object.values(modelMap)) {
if (m.id) newMap.set(m.id as string, pid);
}
}
modelProviderMap = newMap;
providerIdSet = newProviderSet;
modelProviderMapAge = Date.now();
}
} catch {
// best-effort
}
}
}
async function getProviderForModel(modelId: string): Promise<string | null> {
await refreshProviderMap();
return modelProviderMap.get(modelId) ?? null;
}
async function resolveModelSelection(model: string | undefined): Promise<{ providerId: string | null; modelId: string | null }> {
if (!model) return { providerId: null, modelId: null };
await refreshProviderMap();
for (const pid of providerIdSet) {
if (model.startsWith(`${pid}/`)) {
return { providerId: pid, modelId: model.slice(pid.length + 1) };
}
}
const providerId = modelProviderMap.get(model) ?? null;
if (providerId) return { providerId, modelId: model };
if (model.includes('/')) {
const lastSegment = model.split('/').pop() ?? '';
const fallbackProviderId = lastSegment ? modelProviderMap.get(lastSegment) ?? null : null;
if (fallbackProviderId) return { providerId: fallbackProviderId, modelId: lastSegment };
}
return { providerId: null, modelId: model };
}
type WSData = { userId: number; email: string };
type ConnectionState = {
sseAbort: AbortController | null;
sessionId: string | null;
pendingAttachmentIds: string[];
lastTextLength: Map<string, number>;
fullText: Map<string, string>;
userMessageIds: Set<string>;
isBusy: boolean;
logId: string | null;
};
const connections = new Map<ServerWebSocket<WSData>, ConnectionState>();
function send(ws: ServerWebSocket<WSData>, msg: ServerMessage) {
if (ws.readyState === 1) ws.send(JSON.stringify(msg));
}
async function connectSSE(ws: ServerWebSocket<WSData>) {
const state = connections.get(ws);
if (!state) return;
const abort = new AbortController();
state.sseAbort = abort;
try {
const res = await fetch(`${BASE}/event`, { signal: abort.signal });
if (!res.ok || !res.body) {
send(ws, { type: 'error', message: `SSE connect failed: ${res.status}` });
return;
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let eventType = '';
while (true) {
const { done, value } = await reader.read();
if (done || abort.signal.aborted) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines) {
if (line.startsWith('event:')) {
eventType = line.slice(6).trim();
} else if (line.startsWith('data:')) {
const raw = line.slice(5).trim();
if (!raw) continue;
try {
const data = JSON.parse(raw);
// OpenCode embeds event type in data.type, payload in data.properties
const type = eventType || data.type || '';
handleSSEEvent(ws, type, data.properties ?? data);
} catch {
// skip unparseable data
}
eventType = '';
}
}
}
} catch (err) {
if (!abort.signal.aborted) {
send(ws, { type: 'error', message: err instanceof Error ? err.message : 'SSE error' });
}
}
}
function getEventSessionId(event: string, data: any): string | null {
switch (event) {
case 'message.updated':
return data.info?.sessionID ?? null;
case 'message.part.updated':
return (data.part ?? data)?.sessionID ?? null;
case 'session.status':
case 'session.idle':
case 'session.error':
return data.sessionID ?? null;
default:
return null;
}
}
function emitCompletion(ws: ServerWebSocket<WSData>, state: ConnectionState) {
// Send accumulated text as a reliable final message before result
const texts: string[] = [];
for (const [, text] of state.fullText) {
if (text) texts.push(text);
}
if (texts.length > 0) {
const fullText = texts.join('');
send(ws, { type: 'assistant:text', text: fullText });
if (state.logId) appendToLog(state.logId, { role: 'assistant', text: fullText });
}
state.isBusy = false;
state.lastTextLength.clear();
state.fullText.clear();
state.userMessageIds.clear();
send(ws, { type: 'result', costUsd: 0, durationMs: 0, numTurns: 0, isError: false });
if (state.logId) {
appendToLog(state.logId, { role: 'result', costUsd: 0, durationMs: 0, numTurns: 0, isError: false });
finalizeLog(state.logId);
state.logId = null;
}
}
function handleSSEEvent(ws: ServerWebSocket<WSData>, event: string, data: any) {
const state = connections.get(ws);
if (!state) return;
// Filter events by session — ignore events from other sessions
if (state.sessionId) {
const eventSid = getEventSessionId(event, data);
if (eventSid && eventSid !== state.sessionId) return;
}
switch (event) {
case 'session.created':
state.sessionId = data.info?.id ?? data.id;
send(ws, {
type: 'session:init',
sessionId: state.sessionId!,
model: data.info?.model ?? data.model ?? 'opencode',
});
break;
case 'message.updated': {
const info = data.info;
if (info?.role === 'user' && info.id) {
state.userMessageIds.add(info.id);
}
break;
}
case 'message.part.updated': {
const part = data.part ?? data;
// Skip user message parts (would echo user's prompt as assistant text)
if (part.messageID && state.userMessageIds.has(part.messageID)) break;
if (part.type === 'text') {
const partId = part.id ?? 'text';
const lastLen = state.lastTextLength.get(partId) ?? 0;
const content = part.text ?? part.content ?? '';
// Track full text for reliable commit on idle
state.fullText.set(partId, content);
if (content.length > lastLen) {
const delta = content.slice(lastLen);
state.lastTextLength.set(partId, content.length);
send(ws, { type: 'assistant:partial', text: delta });
}
} else if (part.type === 'tool') {
const status = part.state?.status;
const callId = part.callID ?? part.id ?? '';
if (status === 'pending') {
send(ws, {
type: 'tool:use',
toolName: part.tool ?? 'unknown',
toolInput: part.state?.input ?? {},
toolUseId: callId,
});
if (state.logId)
appendToLog(state.logId, {
role: 'tool',
toolName: part.tool ?? 'unknown',
toolInput: part.state?.input ?? {},
toolUseId: callId,
});
} else if (status === 'running') {
// Running state has the actual input — update the tool
const input = part.state?.input;
if (input && Object.keys(input).length > 0) {
send(ws, {
type: 'tool:use',
toolName: part.tool ?? 'unknown',
toolInput: input,
toolUseId: callId,
});
}
} else if (status === 'completed' || status === 'error') {
const output =
part.state?.output != null
? typeof part.state.output === 'string'
? part.state.output
: JSON.stringify(part.state.output)
: '';
send(ws, {
type: 'tool:result',
toolUseId: callId,
output,
isError: status === 'error',
});
if (state.logId)
appendToLog(state.logId, {
role: 'tool',
toolName: part.tool ?? 'unknown',
toolInput: {},
toolUseId: callId,
output,
isError: status === 'error',
});
}
}
break;
}
case 'session.status': {
const status = data.status?.type;
if (status === 'busy') {
state.isBusy = true;
} else if (status === 'idle' && state.isBusy) {
emitCompletion(ws, state);
}
break;
}
case 'session.idle':
if (state.isBusy) {
emitCompletion(ws, state);
}
break;
case 'session.error': {
const errMsg = data.error ?? data.message ?? 'OpenCode error';
send(ws, { type: 'error', message: errMsg });
if (state.logId) {
appendToLog(state.logId, { role: 'error', text: errMsg });
finalizeLog(state.logId);
state.logId = null;
}
break;
}
}
}
type HandleChatParams = {
ws: ServerWebSocket<WSData>;
prompt: string;
sessionId?: string;
model?: string | { providerID?: string; modelID?: string };
attachmentIds?: string[];
images?: ImageData[];
taskInfo?: TaskInfo;
};
async function handleChat({ ws, prompt, sessionId, model, attachmentIds, images, taskInfo }: HandleChatParams) {
const state = connections.get(ws);
if (!state) return;
const modelSelection =
typeof model === 'object' && model?.modelID
? { providerId: model.providerID ?? null, modelId: model.modelID ?? null }
: await resolveModelSelection(model);
const modelRef =
modelSelection.providerId && modelSelection.modelId
? `${modelSelection.providerId}/${modelSelection.modelId}`
: modelSelection.modelId;
if (taskInfo && !state.logId) {
state.logId = createTaskLog(ws.data.email, taskInfo, 'opencode', modelRef ?? 'unknown');
appendToLog(state.logId, { role: 'user', text: prompt });
}
try {
let sid = sessionId ?? state.sessionId;
if (sid) state.sessionId = sid;
if (!sid) {
if (attachmentIds?.length) state.pendingAttachmentIds = attachmentIds;
console.log('[opencode-ws] POST /session body:', JSON.stringify({}));
const res = await fetch(`${BASE}/session`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
if (!res.ok) {
send(ws, { type: 'error', message: `Failed to create session: ${res.status}` });
return;
}
const session = (await res.json()) as { id: string; model?: string };
sid = session.id;
state.sessionId = sid;
send(ws, { type: 'session:init', sessionId: sid, model: session.model ?? 'opencode' });
// Move tmp attachments to session dir
if (state.pendingAttachmentIds.length > 0) {
const tmpDir = getTmpAttachmentsDir(ws.data.email);
const destDir = getAttachmentsDir(ws.data.email, 'opencode', sid);
mkdir(destDir, { recursive: true })
.then(() =>
Promise.all(
state.pendingAttachmentIds.map((id) => rename(join(tmpDir, id), join(destDir, id)).catch(() => {})),
),
)
.catch(() => {});
state.pendingAttachmentIds = [];
}
}
const parts: Record<string, unknown>[] = [];
if (images?.length) {
for (const img of images) {
parts.push({
type: 'file',
mime: img.mediaType,
url: `data:${img.mediaType};base64,${img.data}`,
});
}
}
parts.push({ type: 'text', text: prompt });
const promptBody: Record<string, unknown> = { parts };
if (modelSelection.modelId) {
promptBody.model = {
modelID: modelSelection.modelId,
...(modelSelection.providerId ? { providerID: modelSelection.providerId } : {}),
};
}
console.log('[opencode-ws] POST /session/prompt_async body:', JSON.stringify(promptBody));
console.log(
'[opencode-ws] model selection:',
JSON.stringify({ providerID: modelSelection.providerId, modelID: modelSelection.modelId }),
);
const res = await fetch(`${BASE}/session/${sid}/prompt_async`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(promptBody),
});
if (!res.ok) {
send(ws, { type: 'error', message: `Prompt failed: ${res.status}` });
}
} catch (err) {
send(ws, { type: 'error', message: err instanceof Error ? err.message : 'Failed to send prompt' });
}
}
async function handleStop(ws: ServerWebSocket<WSData>) {
const state = connections.get(ws);
if (!state?.sessionId) return;
try {
await fetch(`${BASE}/session/${state.sessionId}/abort`, { method: 'POST' });
} catch {
// best-effort
}
send(ws, { type: 'stopped' });
}
export const opencodeWebsocket = {
open(ws: ServerWebSocket<WSData>) {
connections.set(ws, {
sseAbort: null,
sessionId: null,
pendingAttachmentIds: [],
lastTextLength: new Map(),
fullText: new Map(),
userMessageIds: new Set(),
isBusy: false,
logId: null,
});
connectSSE(ws);
},
message(ws: ServerWebSocket<WSData>, raw: string | Buffer) {
let msg: ClientMessage;
try {
msg = JSON.parse(typeof raw === 'string' ? raw : raw.toString()) as ClientMessage;
} catch {
send(ws, { type: 'error', message: 'Invalid JSON' });
return;
}
if (msg.type === 'chat') {
handleChat({
ws,
prompt: msg.prompt,
sessionId: msg.sessionId,
model: msg.model,
attachmentIds: msg.attachmentIds,
images: msg.images,
taskInfo: msg.taskInfo,
});
} else if (msg.type === 'stop') {
handleStop(ws);
}
},
close(ws: ServerWebSocket<WSData>) {
const state = connections.get(ws);
if (state?.sseAbort) {
state.sseAbort.abort();
}
connections.delete(ws);
},
drain() {},
};
+26
View File
@@ -0,0 +1,26 @@
import { createRouter } from '../../create-router';
import { readdir } from 'node:fs/promises';
import { join } from 'node:path';
const plansDir = join(process.cwd(), 'plans');
export const plansRouter = createRouter();
plansRouter.get('/', async (ctx) => {
try {
const files = await readdir(plansDir);
const plans = files.filter((f) => f.endsWith('.md')).map((f) => f.replace('.md', ''));
return ctx.json(plans);
} catch {
return ctx.json([]);
}
});
plansRouter.get('/:name', async (ctx) => {
const name = ctx.req.param('name');
const filePath = join(plansDir, `${name}.md`);
const file = Bun.file(filePath);
if (!(await file.exists())) return ctx.text('Not found', 404);
const text = await file.text();
return ctx.text(text);
});
+177
View File
@@ -0,0 +1,177 @@
import { createRouter } from '../../create-router';
import { readdir, mkdir, rm } from 'node:fs/promises';
import { join, dirname } from 'node:path';
import { getNativeProcessesDir, getGlobalProcessesDir, getUserProcessesDir } from '../../data-path';
type Frontmatter = {
name: string;
description: string;
};
export function parseFrontmatter(raw: string): { frontmatter: Frontmatter; body: string } {
const match = raw.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
if (!match) return { frontmatter: { name: '', description: '' }, body: raw };
const yaml = match[1]!;
const body = match[2]!;
const name = yaml.match(/^name:\s*(.+)$/m)?.[1]?.trim() ?? '';
const description = yaml.match(/^description:\s*(.+)$/m)?.[1]?.trim() ?? '';
return { frontmatter: { name, description }, body };
}
export async function readProcessDirs(dir: string): Promise<Map<string, string>> {
const result = new Map<string, string>();
try {
const entries = await readdir(dir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const processFile = join(dir, entry.name, 'PROCESS.md');
if (await Bun.file(processFile).exists()) {
result.set(entry.name, processFile);
}
}
} catch {
// directory doesn't exist yet
}
return result;
}
type Scope = 'native' | 'global' | 'user';
function resolveScope(dirName: string, native: Map<string, string>, global: Map<string, string>, user: Map<string, string>): Scope {
if (user.has(dirName)) return 'user';
if (global.has(dirName)) return 'global';
return 'native';
}
function resolveFile(name: string, native: Map<string, string>, global: Map<string, string>, user: Map<string, string>): { filePath: string; scope: Scope } | null {
if (user.has(name)) return { filePath: user.get(name)!, scope: 'user' };
if (global.has(name)) return { filePath: global.get(name)!, scope: 'global' };
if (native.has(name)) return { filePath: native.get(name)!, scope: 'native' };
return null;
}
export const processesRouter = createRouter();
processesRouter.get('/', async (ctx) => {
const user = ctx.get('user');
const nativeProcesses = await readProcessDirs(getNativeProcessesDir());
const globalProcesses = await readProcessDirs(getGlobalProcessesDir());
const userProcesses = await readProcessDirs(getUserProcessesDir(user.email));
const merged = new Map(nativeProcesses);
for (const [name, path] of globalProcesses) merged.set(name, path);
for (const [name, path] of userProcesses) merged.set(name, path);
const processes = await Promise.all(
Array.from(merged.entries()).map(async ([dirName, filePath]) => {
const raw = await Bun.file(filePath).text();
const { frontmatter } = parseFrontmatter(raw);
const scope = resolveScope(dirName, nativeProcesses, globalProcesses, userProcesses);
return { dirName, name: frontmatter.name || dirName, description: frontmatter.description, scope };
}),
);
return ctx.json(processes);
});
processesRouter.get('/:name', async (ctx) => {
const user = ctx.get('user');
const name = ctx.req.param('name');
const nativeProcesses = await readProcessDirs(getNativeProcessesDir());
const globalProcesses = await readProcessDirs(getGlobalProcessesDir());
const userProcesses = await readProcessDirs(getUserProcessesDir(user.email));
const resolved = resolveFile(name, nativeProcesses, globalProcesses, userProcesses);
if (!resolved) return ctx.text('Not found', 404);
const raw = await Bun.file(resolved.filePath).text();
const { frontmatter, body } = parseFrontmatter(raw);
const chatMeta = join(dirname(resolved.filePath), 'chat', 'meta.json');
const chatSessionId = await Bun.file(chatMeta).json().then((m: { id: string }) => m.id).catch(() => null);
return ctx.json({
name: frontmatter.name || name,
description: frontmatter.description,
scope: resolved.scope,
body,
filePath: resolved.filePath,
chatSessionId,
});
});
processesRouter.get('/:name/chat', async (ctx) => {
const user = ctx.get('user');
const name = ctx.req.param('name');
const nativeProcesses = await readProcessDirs(getNativeProcessesDir());
const globalProcesses = await readProcessDirs(getGlobalProcessesDir());
const userProcesses = await readProcessDirs(getUserProcessesDir(user.email));
const resolved = resolveFile(name, nativeProcesses, globalProcesses, userProcesses);
if (!resolved) return ctx.text('Not found', 404);
const chatDir = join(dirname(resolved.filePath), 'chat');
const sessionId = await Bun.file(join(chatDir, 'meta.json')).json().then((m: { id: string }) => m.id).catch(() => null);
const messages = await Bun.file(join(chatDir, 'messages.json')).json().catch(() => []);
return ctx.json({ sessionId, messages });
});
processesRouter.put('/:name/chat', async (ctx) => {
const user = ctx.get('user');
const name = ctx.req.param('name');
const nativeProcesses = await readProcessDirs(getNativeProcessesDir());
const globalProcesses = await readProcessDirs(getGlobalProcessesDir());
const userProcesses = await readProcessDirs(getUserProcessesDir(user.email));
const resolved = resolveFile(name, nativeProcesses, globalProcesses, userProcesses);
if (!resolved) return ctx.text('Not found', 404);
const chatDir = join(dirname(resolved.filePath), 'chat');
const { sessionId, messages } = await ctx.req.json<{ sessionId: string; messages: unknown[] }>();
await mkdir(chatDir, { recursive: true });
await Bun.write(join(chatDir, 'messages.json'), JSON.stringify(messages));
if (sessionId) await Bun.write(join(chatDir, 'meta.json'), JSON.stringify({ id: sessionId }));
return ctx.json({ ok: true });
});
processesRouter.post('/', async (ctx) => {
const { name } = await ctx.req.json<{ name: string }>();
if (!name?.trim()) return ctx.text('Name is required', 400);
const dirName = name.trim().toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
if (!dirName) return ctx.text('Invalid name', 400);
const dir = join(getGlobalProcessesDir(), dirName);
const filePath = join(dir, 'PROCESS.md');
if (await Bun.file(filePath).exists()) {
return ctx.text('Process already exists', 409);
}
await mkdir(dir, { recursive: true });
await Bun.write(filePath, `---\nname: ${name.trim()}\ndescription: \n---\n`);
return ctx.json({ name: name.trim(), dirName, filePath });
});
processesRouter.delete('/:name', async (ctx) => {
const name = ctx.req.param('name');
const globalDir = join(getGlobalProcessesDir(), name);
const globalFile = join(globalDir, 'PROCESS.md');
if (!(await Bun.file(globalFile).exists())) {
return ctx.text('Not found', 404);
}
await rm(globalDir, { recursive: true });
return ctx.json({ ok: true });
});
+161
View File
@@ -0,0 +1,161 @@
import { createRouter } from '../../create-router';
import { mkdir } from 'node:fs/promises';
import { join } from 'node:path';
import { chromium, type Browser } from 'playwright';
import { getTmpAttachmentsDir, getAttachmentsDir } from '@@/data-path';
const MAX_CONTENT_LENGTH = 100_000;
let browserPromise: Promise<Browser> | null = null;
function getBrowser(): Promise<Browser> {
if (!browserPromise) {
browserPromise = chromium.launch({ headless: true }).catch((err) => {
browserPromise = null;
throw err;
});
}
return browserPromise;
}
function slugify(text: string): string {
return text
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 80);
}
export const scrapeRouter = createRouter();
scrapeRouter.post('/', async (ctx) => {
const user = ctx.get('user');
const { url, sessionId, provider } = ctx.get('body') as {
url: string;
sessionId?: string;
provider?: 'claude' | 'opencode';
};
if (!url) return ctx.json({ error: 'url is required' }, 400);
const browser = await getBrowser();
const page = await browser.newPage();
try {
await page.goto(url, { waitUntil: 'networkidle', timeout: 30_000 });
// Scroll through the page to capture all content, including virtualized/lazy-loaded
// content (e.g. ChatGPT shared links remove DOM nodes as you scroll past them).
// We capture text incrementally at each scroll position and merge it.
const { accumulatedText, scrollableFound } = await page.evaluate(async () => {
const delay = (ms: number) => new Promise((r) => setTimeout(r, ms));
// Find the deepest scrollable container (the one that actually scrolls content)
let bestScrollable: Element | null = null;
let bestOverflow = 0;
const elements = Array.from(document.querySelectorAll('*'));
for (const el of elements) {
const style = getComputedStyle(el);
const overflowY = style.overflowY;
if ((overflowY === 'auto' || overflowY === 'scroll') && el.scrollHeight > el.clientHeight + 10) {
const overflow = el.scrollHeight - el.clientHeight;
if (overflow > bestOverflow) {
bestOverflow = overflow;
bestScrollable = el;
}
}
}
const scrollable = bestScrollable ?? document.scrollingElement;
if (!scrollable || scrollable.scrollHeight <= scrollable.clientHeight + 10) {
return { accumulatedText: '', scrollableFound: false };
}
// Collect text chunks as we scroll through
const seenChunks = new Set<string>();
const orderedChunks: string[] = [];
const captureVisible = () => {
const text = document.body.innerText;
// Split into paragraphs and capture new ones
const paragraphs = text.split(/\n{2,}/);
for (const p of paragraphs) {
const trimmed = p.trim();
if (trimmed && !seenChunks.has(trimmed)) {
seenChunks.add(trimmed);
orderedChunks.push(trimmed);
}
}
};
// Start from the top
scrollable.scrollTop = 0;
await delay(500);
captureVisible();
// Scroll incrementally, capturing at each position
let stableCount = 0;
let lastChunkCount = orderedChunks.length;
for (let i = 0; i < 200; i++) {
scrollable.scrollTop += scrollable.clientHeight * 0.6;
await delay(300);
captureVisible();
// Check if we're at the bottom
const atBottom = scrollable.scrollTop + scrollable.clientHeight >= scrollable.scrollHeight - 5;
if (atBottom) {
// Wait a bit for potential dynamic loading
await delay(500);
captureVisible();
// If no new content appeared, we're done
if (orderedChunks.length === lastChunkCount) {
stableCount++;
if (stableCount >= 2) break;
} else {
stableCount = 0;
lastChunkCount = orderedChunks.length;
}
}
}
return { accumulatedText: orderedChunks.join('\n\n'), scrollableFound: true };
});
const title = await page.title();
const html = await page.evaluate(() => document.documentElement.outerHTML);
// Use accumulated text from scrolling if available, otherwise fall back to current innerText
const rawText =
scrollableFound && accumulatedText ? accumulatedText : await page.evaluate(() => document.body.innerText);
// Strip excessive whitespace and truncate
const content = rawText
.replace(/\n{3,}/g, '\n\n')
.replace(/[ \t]+/g, ' ')
.trim()
.slice(0, MAX_CONTENT_LENGTH);
// Determine save location
const slug = slugify(title || 'page');
let attachmentId: string;
let saveDir: string;
if (sessionId && provider) {
attachmentId = `${slug}.html`;
saveDir = getAttachmentsDir(user.email, provider, sessionId);
} else {
attachmentId = `${crypto.randomUUID()}.html`;
saveDir = getTmpAttachmentsDir(user.email);
}
await mkdir(saveDir, { recursive: true });
await Bun.write(join(saveDir, attachmentId), html);
return ctx.json({ url, title, content, attachmentId });
} catch (err) {
const message = err instanceof Error ? err.message : 'Scrape failed';
return ctx.json({ error: message }, 500);
} finally {
await page.close();
}
});
@@ -0,0 +1,211 @@
import { createRouter } from '../../create-router';
type AppSpec = {
id: string;
name: string;
description: string;
versionCommand: string[];
versionParser: (output: string) => string | null;
installCommand?: string[];
updateCommand?: string[];
manualInstallCommand?: string;
manualUpdateCommand?: string;
processName?: string;
};
type AppStatus = {
id: string;
name: string;
description: string;
installed: boolean;
version: string | null;
running: boolean | null;
hasInstall: boolean;
hasUpdate: boolean;
manualInstallCommand: string | null;
manualUpdateCommand: string | null;
};
const detectPackageManager = (): 'pacman' | 'apt' | 'brew' | null => {
for (const [cmd, name] of [
['pacman', 'pacman'],
['apt', 'apt'],
['brew', 'brew'],
] as const) {
try {
const proc = Bun.spawnSync(['which', cmd], { stdout: 'pipe', stderr: 'pipe' });
if (proc.exitCode === 0) return name;
} catch {
// continue
}
}
return null;
};
type FfmpegCommands = {
installCommand?: string[];
updateCommand?: string[];
manualInstallCommand?: string;
manualUpdateCommand?: string;
};
const getFfmpegCommands = (): FfmpegCommands => {
const pm = detectPackageManager();
if (pm === 'pacman')
return {
manualInstallCommand: 'sudo pacman -S ffmpeg',
manualUpdateCommand: 'sudo pacman -Syu ffmpeg',
};
if (pm === 'apt')
return {
manualInstallCommand: 'sudo apt install ffmpeg',
manualUpdateCommand: 'sudo apt install --only-upgrade ffmpeg',
};
if (pm === 'brew')
return {
installCommand: ['brew', 'install', 'ffmpeg'],
updateCommand: ['brew', 'upgrade', 'ffmpeg'],
};
return {};
};
const ffmpegCmds = getFfmpegCommands();
const apps: AppSpec[] = [
{
id: 'ffmpeg',
name: 'FFmpeg',
description: 'Audio and video processing toolkit',
versionCommand: ['ffmpeg', '-version'],
versionParser: (output) => {
const match = output.match(/ffmpeg version (\S+)/);
return match?.[1] ?? null;
},
installCommand: ffmpegCmds.installCommand,
updateCommand: ffmpegCmds.updateCommand,
manualInstallCommand: ffmpegCmds.manualInstallCommand,
manualUpdateCommand: ffmpegCmds.manualUpdateCommand,
},
{
id: 'sharp',
name: 'Sharp',
description: 'High-performance image processing library',
versionCommand: ['bun', '--eval', "console.log(require('sharp').versions.sharp)"],
versionParser: (output) => output.trim() || null,
installCommand: ['bun', 'add', 'sharp'],
updateCommand: ['bun', 'add', 'sharp@latest'],
},
{
id: 'whisper-cpp',
name: 'Whisper.cpp',
description: 'Speech-to-text inference engine',
versionCommand: ['whisper-cpp', '--version'],
versionParser: (output) => output.trim() || null,
installCommand: detectPackageManager() === 'brew' ? ['brew', 'install', 'whisper-cpp'] : undefined,
processName: 'whisper-server',
},
{
id: 'mlx-audio',
name: 'MLX Audio',
description: 'Audio processing with Apple MLX framework',
versionCommand: ['pip', 'show', 'mlx-audio'],
versionParser: (output) => {
const match = output.match(/Version:\s*(\S+)/);
return match?.[1] ?? null;
},
installCommand: ['pip', 'install', 'mlx-audio'],
updateCommand: ['pip', 'install', '--upgrade', 'mlx-audio'],
},
];
const COMMAND_TIMEOUT_MS = 30_000;
const runCommand = async (command: string[]): Promise<{ exitCode: number; stdout: string; stderr: string }> => {
try {
const proc = Bun.spawn(command, { stdout: 'pipe', stderr: 'pipe' });
const timeout = new Promise<never>((_, reject) =>
setTimeout(() => {
proc.kill();
reject(new Error('Command timed out'));
}, COMMAND_TIMEOUT_MS),
);
const result = Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited]);
const [stdout, stderr] = await Promise.race([result, timeout]);
return { exitCode: proc.exitCode ?? 1, stdout, stderr };
} catch (err) {
const message = err instanceof Error ? err.message : 'Command not found or failed to execute';
return { exitCode: 1, stdout: '', stderr: message };
}
};
const checkVersion = async (app: AppSpec): Promise<string | null> => {
const result = await runCommand(app.versionCommand);
if (result.exitCode !== 0) return null;
return app.versionParser(result.stdout);
};
const checkRunning = async (processName: string): Promise<boolean> => {
const result = await runCommand(['pgrep', '-x', processName]);
return result.exitCode === 0;
};
const getAppStatus = async (app: AppSpec): Promise<AppStatus> => {
const version = await checkVersion(app);
const running = app.processName ? await checkRunning(app.processName) : null;
return {
id: app.id,
name: app.name,
description: app.description,
installed: version !== null,
version,
running,
hasInstall: !!(app.installCommand || app.manualInstallCommand),
hasUpdate: !!(app.updateCommand ?? app.installCommand ?? app.manualUpdateCommand ?? app.manualInstallCommand),
manualInstallCommand: app.manualInstallCommand ?? null,
manualUpdateCommand: app.manualUpdateCommand ?? null,
};
};
export const applicationsRouter = createRouter();
applicationsRouter.get('/', async (ctx) => {
const statuses = await Promise.all(apps.map(getAppStatus));
return ctx.json(statuses);
});
applicationsRouter.get('/:id', async (ctx) => {
const app = apps.find((a) => a.id === ctx.req.param('id'));
if (!app) return ctx.json({ error: 'Application not found' }, 404);
const status = await getAppStatus(app);
return ctx.json(status);
});
applicationsRouter.post('/:id/install', async (ctx) => {
const app = apps.find((a) => a.id === ctx.req.param('id'));
if (!app) return ctx.json({ error: 'Application not found' }, 404);
if (!app.installCommand) return ctx.json({ error: 'No install command available for this platform' }, 400);
const result = await runCommand(app.installCommand);
if (result.exitCode !== 0) {
return ctx.json({ error: result.stderr.trim() || 'Installation failed' }, 500);
}
const status = await getAppStatus(app);
return ctx.json(status);
});
applicationsRouter.post('/:id/update', async (ctx) => {
const app = apps.find((a) => a.id === ctx.req.param('id'));
if (!app) return ctx.json({ error: 'Application not found' }, 404);
const command = app.updateCommand ?? app.installCommand;
if (!command) return ctx.json({ error: 'No update command available for this platform' }, 400);
const result = await runCommand(command);
if (result.exitCode !== 0) {
return ctx.json({ error: result.stderr.trim() || 'Update failed' }, 500);
}
const status = await getAppStatus(app);
return ctx.json(status);
});
@@ -0,0 +1,78 @@
import { createRouter } from '../../create-router';
export const claudeCodeRouter = createRouter();
const GLOBAL_DIRS = ['/usr/local/bin', '/usr/bin'];
const getPaths = async () => {
try {
const proc = Bun.spawn(['which', '-a', 'claude'], { stdout: 'pipe', stderr: 'pipe' });
const output = await new Response(proc.stdout).text();
await proc.exited;
if (proc.exitCode !== 0) return { path: null, globalPath: null };
const paths = [...new Set(output.trim().split('\n'))];
const path = paths[0] ?? null;
const globalPath = paths.find((p) => GLOBAL_DIRS.some((dir) => p.startsWith(dir))) ?? null;
return { path, globalPath };
} catch {
return { path: null, globalPath: null };
}
};
claudeCodeRouter.post('/install', async (ctx) => {
try {
const proc = Bun.spawn(['bash', '-c', 'curl -fsSL https://claude.ai/install.sh | bash'], {
stdout: 'pipe',
stderr: 'pipe',
});
await proc.exited;
if (proc.exitCode !== 0) {
const stderr = await new Response(proc.stderr).text();
return ctx.json({ version: null, path: null, globalPath: null, error: stderr.trim() }, 500);
}
const versionProc = Bun.spawn(['claude', '--version'], { stdout: 'pipe', stderr: 'pipe' });
const output = await new Response(versionProc.stdout).text();
await versionProc.exited;
const { path, globalPath } = await getPaths();
return ctx.json({ version: output.trim(), path, globalPath });
} catch {
return ctx.json({ version: null, path: null, globalPath: null, error: 'Installation failed' }, 500);
}
});
claudeCodeRouter.post('/auth/login', async (ctx) => {
try {
const env = { ...process.env };
delete env.CLAUDECODE;
Bun.spawn(['claude', 'auth', 'login'], { stdout: 'ignore', stderr: 'ignore', env });
return ctx.json({ started: true });
} catch {
return ctx.json({ started: false, error: 'Failed to start login' }, 500);
}
});
claudeCodeRouter.get('/auth', async (ctx) => {
try {
const proc = Bun.spawn(['claude', 'auth', 'status'], { stdout: 'pipe', stderr: 'pipe' });
const output = await new Response(proc.stdout).text();
await proc.exited;
if (proc.exitCode !== 0) return ctx.json({ authenticated: false });
const status = JSON.parse(output.trim());
return ctx.json({ authenticated: status.loggedIn ?? false, ...status });
} catch {
return ctx.json({ authenticated: false });
}
});
claudeCodeRouter.get('/version', async (ctx) => {
try {
const proc = Bun.spawn(['claude', '--version'], { stdout: 'pipe', stderr: 'pipe' });
const output = await new Response(proc.stdout).text();
await proc.exited;
if (proc.exitCode !== 0) return ctx.json({ version: null, path: null, globalPath: null });
const { path, globalPath } = await getPaths();
return ctx.json({ version: output.trim(), path, globalPath });
} catch {
return ctx.json({ version: null, path: null, globalPath: null });
}
});
@@ -0,0 +1,76 @@
import { createRouter } from '../../create-router';
export const opencodeRouter = createRouter();
const GLOBAL_DIRS = ['/usr/local/bin', '/usr/bin'];
const getPaths = async () => {
try {
const proc = Bun.spawn(['which', '-a', 'opencode'], { stdout: 'pipe', stderr: 'pipe' });
const output = await new Response(proc.stdout).text();
await proc.exited;
if (proc.exitCode !== 0) return { path: null, globalPath: null };
const paths = [...new Set(output.trim().split('\n'))];
const path = paths[0] ?? null;
const globalPath = paths.find((p) => GLOBAL_DIRS.some((dir) => p.startsWith(dir))) ?? null;
return { path, globalPath };
} catch {
return { path: null, globalPath: null };
}
};
opencodeRouter.post('/auth/login', async (ctx) => {
try {
Bun.spawn(['opencode', 'auth', 'login'], { stdout: 'ignore', stderr: 'ignore' });
return ctx.json({ started: true });
} catch {
return ctx.json({ started: false, error: 'Failed to start login' }, 500);
}
});
opencodeRouter.get('/auth', async (ctx) => {
try {
const authPath = `${process.env.HOME}/.local/share/opencode/auth.json`;
const file = Bun.file(authPath);
if (!(await file.exists())) return ctx.json({ authenticated: false, providers: [] });
const auth = await file.json();
const providers = Object.keys(auth);
return ctx.json({ authenticated: providers.length > 0, providers });
} catch {
return ctx.json({ authenticated: false, providers: [] });
}
});
opencodeRouter.post('/install', async (ctx) => {
try {
const proc = Bun.spawn(['bash', '-c', 'curl -fsSL https://opencode.ai/install | bash'], {
stdout: 'pipe',
stderr: 'pipe',
});
await proc.exited;
if (proc.exitCode !== 0) {
const stderr = await new Response(proc.stderr).text();
return ctx.json({ version: null, path: null, globalPath: null, error: stderr.trim() }, 500);
}
const versionProc = Bun.spawn(['opencode', '--version'], { stdout: 'pipe', stderr: 'pipe' });
const output = await new Response(versionProc.stdout).text();
await versionProc.exited;
const { path, globalPath } = await getPaths();
return ctx.json({ version: output.trim(), path, globalPath });
} catch {
return ctx.json({ version: null, path: null, globalPath: null, error: 'Installation failed' }, 500);
}
});
opencodeRouter.get('/version', async (ctx) => {
try {
const proc = Bun.spawn(['opencode', '--version'], { stdout: 'pipe', stderr: 'pipe' });
const output = await new Response(proc.stdout).text();
await proc.exited;
if (proc.exitCode !== 0) return ctx.json({ version: null, path: null, globalPath: null });
const { path, globalPath } = await getPaths();
return ctx.json({ version: output.trim(), path, globalPath });
} catch {
return ctx.json({ version: null, path: null, globalPath: null });
}
});
@@ -0,0 +1,82 @@
import { createRouter } from '../../create-router';
import { homedir } from 'node:os';
import { mkdir } from 'node:fs/promises';
import { readdirSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { officerdb, count, Users } from 'officerdb';
import { claudeCodeRouter } from './claude-code';
import { opencodeRouter } from './opencode';
import { applicationsRouter } from './applications';
const configDir = `${homedir()}/.config/officer.dev`;
export const settingsPath = `${configDir}/server-settings.json`;
const settingsFile = Bun.file(settingsPath);
if (!(await settingsFile.exists())) {
await mkdir(configDir, { recursive: true });
await Bun.write(settingsPath, '{}');
}
export const serverSettingsRouter = createRouter();
serverSettingsRouter.route('/claude-code', claudeCodeRouter);
serverSettingsRouter.route('/opencode', opencodeRouter);
serverSettingsRouter.route('/applications', applicationsRouter);
serverSettingsRouter.get('/', async (ctx) => {
const result = await officerdb.select({ count: count() }).from(Users);
const userCount = result[0]?.count ?? 0;
return ctx.json({ registrationOpen: userCount === 0 });
});
serverSettingsRouter.get('/settings', async (ctx) => {
const settings = await Bun.file(settingsPath).json();
return ctx.json(settings);
});
serverSettingsRouter.get('/onboarding-complete', async (ctx) => {
const settings = await Bun.file(settingsPath).json();
return ctx.json({ onboardingComplete: !!settings.onboardingComplete });
});
serverSettingsRouter.put('/', async (ctx) => {
const body = await ctx.req.json();
const settings = await Bun.file(settingsPath).json();
const updated = { ...settings, ...body };
await Bun.write(settingsPath, JSON.stringify(updated, null, 2));
return ctx.json(updated);
});
serverSettingsRouter.get('/plugins', async (ctx) => {
const pluginsDir = join(import.meta.dir, '../../../workspaces/plugins');
const settings = await Bun.file(settingsPath)
.json()
.catch(() => ({}));
const pluginSettings: Record<string, boolean> = settings.plugins ?? {};
const plugins: { id: string; name: string; description: string; enabled: boolean }[] = [];
if (!existsSync(pluginsDir)) return ctx.json(plugins);
const dirs = readdirSync(pluginsDir, { withFileTypes: true }).filter((d) => d.isDirectory());
for (const dir of dirs) {
const hasServer = existsSync(join(pluginsDir, dir.name, 'server', 'index.ts'));
const hasClient = existsSync(join(pluginsDir, dir.name, 'client', 'index.ts'));
if (!hasServer && !hasClient) continue;
const mainIndex = join(pluginsDir, dir.name, 'index.ts');
if (!existsSync(mainIndex)) continue;
const mod = await import(mainIndex);
const meta = mod.plugin ?? { id: dir.name, name: dir.name, description: '' };
plugins.push({
id: meta.id,
name: meta.name,
description: meta.description,
enabled: pluginSettings[meta.id] !== false,
});
}
return ctx.json(plugins);
});
+82
View File
@@ -0,0 +1,82 @@
import { createRouter } from '../../create-router';
import { mkdir } from 'node:fs/promises';
import { dirname } from 'node:path';
import { getUserSettingsFile, getUserStateFile } from '@@/data-path';
const DEFAULT_SETTINGS = {
chat: {
defaultProvider: 'claude',
defaultModel: null,
systemPrompt: '',
temperature: 1,
defaultPwd: '~',
},
appearance: {
theme: 'light',
},
};
const ensureDir = (filePath: string) => mkdir(dirname(filePath), { recursive: true });
export const settingsRouter = createRouter();
// GET /settings — return settings.json, auto-create with defaults if missing
settingsRouter.get('/settings', async (ctx) => {
const email = ctx.get('user').email;
const filePath = getUserSettingsFile(email);
const file = Bun.file(filePath);
if (await file.exists()) {
const data = await file.json();
return ctx.json(data);
}
await ensureDir(filePath);
await Bun.write(file, JSON.stringify(DEFAULT_SETTINGS, null, 2));
return ctx.json(DEFAULT_SETTINGS);
});
// PUT /settings — full replacement
settingsRouter.put('/settings', async (ctx) => {
const email = ctx.get('user').email;
const body = ctx.get('body');
const filePath = getUserSettingsFile(email);
await ensureDir(filePath);
await Bun.write(filePath, JSON.stringify(body, null, 2));
return ctx.json(body);
});
// GET /state — return state.json, auto-create with {} if missing
settingsRouter.get('/state', async (ctx) => {
const email = ctx.get('user').email;
const filePath = getUserStateFile(email);
const file = Bun.file(filePath);
if (await file.exists()) {
const data = await file.json();
return ctx.json(data);
}
await ensureDir(filePath);
await Bun.write(file, JSON.stringify({}, null, 2));
return ctx.json({});
});
// PATCH /state — shallow-merge incoming keys
settingsRouter.patch('/state', async (ctx) => {
const email = ctx.get('user').email;
const body = ctx.get('body');
const filePath = getUserStateFile(email);
const file = Bun.file(filePath);
let existing: Record<string, unknown> = {};
if (await file.exists()) {
existing = await file.json();
}
const merged = { ...existing, ...body };
await ensureDir(filePath);
await Bun.write(filePath, JSON.stringify(merged, null, 2));
return ctx.json(merged);
});
+177
View File
@@ -0,0 +1,177 @@
import { createRouter } from '../../create-router';
import { readdir, mkdir, rm } from 'node:fs/promises';
import { join, dirname } from 'node:path';
import { getNativeSkillsDir, getGlobalSkillsDir, getUserSkillsDir } from '../../data-path';
type Frontmatter = {
name: string;
description: string;
};
export function parseFrontmatter(raw: string): { frontmatter: Frontmatter; body: string } {
const match = raw.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
if (!match) return { frontmatter: { name: '', description: '' }, body: raw };
const yaml = match[1]!;
const body = match[2]!;
const name = yaml.match(/^name:\s*(.+)$/m)?.[1]?.trim() ?? '';
const description = yaml.match(/^description:\s*(.+)$/m)?.[1]?.trim() ?? '';
return { frontmatter: { name, description }, body };
}
export async function readSkillDirs(dir: string): Promise<Map<string, string>> {
const result = new Map<string, string>();
try {
const entries = await readdir(dir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const skillFile = join(dir, entry.name, 'SKILL.md');
if (await Bun.file(skillFile).exists()) {
result.set(entry.name, skillFile);
}
}
} catch {
// directory doesn't exist yet
}
return result;
}
type Scope = 'native' | 'global' | 'user';
function resolveScope(dirName: string, native: Map<string, string>, global: Map<string, string>, user: Map<string, string>): Scope {
if (user.has(dirName)) return 'user';
if (global.has(dirName)) return 'global';
return 'native';
}
function resolveFile(name: string, native: Map<string, string>, global: Map<string, string>, user: Map<string, string>): { filePath: string; scope: Scope } | null {
if (user.has(name)) return { filePath: user.get(name)!, scope: 'user' };
if (global.has(name)) return { filePath: global.get(name)!, scope: 'global' };
if (native.has(name)) return { filePath: native.get(name)!, scope: 'native' };
return null;
}
export const skillsRouter = createRouter();
skillsRouter.get('/', async (ctx) => {
const user = ctx.get('user');
const nativeSkills = await readSkillDirs(getNativeSkillsDir());
const globalSkills = await readSkillDirs(getGlobalSkillsDir());
const userSkills = await readSkillDirs(getUserSkillsDir(user.email));
const merged = new Map(nativeSkills);
for (const [name, path] of globalSkills) merged.set(name, path);
for (const [name, path] of userSkills) merged.set(name, path);
const skills = await Promise.all(
Array.from(merged.entries()).map(async ([dirName, filePath]) => {
const raw = await Bun.file(filePath).text();
const { frontmatter } = parseFrontmatter(raw);
const scope = resolveScope(dirName, nativeSkills, globalSkills, userSkills);
return { dirName, name: frontmatter.name || dirName, description: frontmatter.description, scope };
}),
);
return ctx.json(skills);
});
skillsRouter.get('/:name', async (ctx) => {
const user = ctx.get('user');
const name = ctx.req.param('name');
const nativeSkills = await readSkillDirs(getNativeSkillsDir());
const globalSkills = await readSkillDirs(getGlobalSkillsDir());
const userSkills = await readSkillDirs(getUserSkillsDir(user.email));
const resolved = resolveFile(name, nativeSkills, globalSkills, userSkills);
if (!resolved) return ctx.text('Not found', 404);
const raw = await Bun.file(resolved.filePath).text();
const { frontmatter, body } = parseFrontmatter(raw);
const chatMeta = join(dirname(resolved.filePath), 'chat', 'meta.json');
const chatSessionId = await Bun.file(chatMeta).json().then((m: { id: string }) => m.id).catch(() => null);
return ctx.json({
name: frontmatter.name || name,
description: frontmatter.description,
scope: resolved.scope,
body,
filePath: resolved.filePath,
chatSessionId,
});
});
skillsRouter.get('/:name/chat', async (ctx) => {
const user = ctx.get('user');
const name = ctx.req.param('name');
const nativeSkills = await readSkillDirs(getNativeSkillsDir());
const globalSkills = await readSkillDirs(getGlobalSkillsDir());
const userSkills = await readSkillDirs(getUserSkillsDir(user.email));
const resolved = resolveFile(name, nativeSkills, globalSkills, userSkills);
if (!resolved) return ctx.text('Not found', 404);
const chatDir = join(dirname(resolved.filePath), 'chat');
const sessionId = await Bun.file(join(chatDir, 'meta.json')).json().then((m: { id: string }) => m.id).catch(() => null);
const messages = await Bun.file(join(chatDir, 'messages.json')).json().catch(() => []);
return ctx.json({ sessionId, messages });
});
skillsRouter.put('/:name/chat', async (ctx) => {
const user = ctx.get('user');
const name = ctx.req.param('name');
const nativeSkills = await readSkillDirs(getNativeSkillsDir());
const globalSkills = await readSkillDirs(getGlobalSkillsDir());
const userSkills = await readSkillDirs(getUserSkillsDir(user.email));
const resolved = resolveFile(name, nativeSkills, globalSkills, userSkills);
if (!resolved) return ctx.text('Not found', 404);
const chatDir = join(dirname(resolved.filePath), 'chat');
const { sessionId, messages } = await ctx.req.json<{ sessionId: string; messages: unknown[] }>();
await mkdir(chatDir, { recursive: true });
await Bun.write(join(chatDir, 'messages.json'), JSON.stringify(messages));
if (sessionId) await Bun.write(join(chatDir, 'meta.json'), JSON.stringify({ id: sessionId }));
return ctx.json({ ok: true });
});
skillsRouter.post('/', async (ctx) => {
const { name } = await ctx.req.json<{ name: string }>();
if (!name?.trim()) return ctx.text('Name is required', 400);
const dirName = name.trim().toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
if (!dirName) return ctx.text('Invalid name', 400);
const dir = join(getGlobalSkillsDir(), dirName);
const filePath = join(dir, 'SKILL.md');
if (await Bun.file(filePath).exists()) {
return ctx.text('Skill already exists', 409);
}
await mkdir(dir, { recursive: true });
await Bun.write(filePath, `---\nname: ${name.trim()}\ndescription: \n---\n`);
return ctx.json({ name: name.trim(), dirName, filePath });
});
skillsRouter.delete('/:name', async (ctx) => {
const name = ctx.req.param('name');
const globalDir = join(getGlobalSkillsDir(), name);
const globalFile = join(globalDir, 'SKILL.md');
if (!(await Bun.file(globalFile).exists())) {
return ctx.text('Not found', 404);
}
await rm(globalDir, { recursive: true });
return ctx.json({ ok: true });
});
+95
View File
@@ -0,0 +1,95 @@
import { mkdir } from 'node:fs/promises';
import { join } from 'node:path';
import { getTaskLogsDir } from '@@/data-path';
import type { TaskInfo } from '@@/api/chat-types';
type ChatMessage =
| { role: 'user'; text: string }
| { role: 'assistant'; text: string }
| {
role: 'tool';
toolName: string;
toolInput: Record<string, unknown>;
toolUseId: string;
output?: string;
isError?: boolean;
}
| { role: 'result'; costUsd: number; durationMs: number; numTurns: number; isError: boolean }
| { role: 'error'; text: string };
type TaskLog = {
taskName: string;
taskDirName: string;
entryName: string;
entryType: 'file' | 'directory';
provider: string;
model: string;
startedAt: string;
completedAt: string | null;
messages: ChatMessage[];
};
type LogEntry = {
email: string;
filePath: string;
log: TaskLog;
};
const activeLogs = new Map<string, LogEntry>();
let logCounter = 0;
export function createTaskLog(email: string, taskInfo: TaskInfo, provider: string, model: string): string {
const logId = `log_${Date.now()}_${++logCounter}`;
const dir = getTaskLogsDir(email);
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const filename = `${timestamp}-${taskInfo.taskDirName}.json`;
const filePath = join(dir, filename);
const log: TaskLog = {
taskName: taskInfo.taskName,
taskDirName: taskInfo.taskDirName,
entryName: taskInfo.entryName,
entryType: taskInfo.entryType,
provider,
model,
startedAt: new Date().toISOString(),
completedAt: null,
messages: [],
};
activeLogs.set(logId, { email, filePath, log });
return logId;
}
export function appendToLog(logId: string, message: ChatMessage) {
const entry = activeLogs.get(logId);
if (!entry) return;
// For tool results, update the existing tool message instead of appending
if (message.role === 'tool' && message.output !== undefined) {
const existing = entry.log.messages.find((m) => m.role === 'tool' && m.toolUseId === message.toolUseId);
if (existing && existing.role === 'tool') {
existing.output = message.output;
existing.isError = message.isError;
return;
}
}
entry.log.messages.push(message);
}
export async function finalizeLog(logId: string) {
const entry = activeLogs.get(logId);
if (!entry) return;
entry.log.completedAt = new Date().toISOString();
try {
await mkdir(join(entry.filePath, '..'), { recursive: true });
await Bun.write(entry.filePath, JSON.stringify(entry.log, null, 2));
} catch (err) {
console.error('[task-logger] Failed to write log:', err);
}
activeLogs.delete(logId);
}
+79
View File
@@ -0,0 +1,79 @@
import { readdir } from 'node:fs/promises';
import { join } from 'node:path';
import { createRouter } from '../../create-router';
import { getTaskLogsDir } from '../../data-path';
type LogMetadata = {
filename: string;
taskName: string;
taskDirName: string;
entryName: string;
entryType: 'file' | 'directory';
provider: string;
model: string;
startedAt: string;
completedAt: string | null;
isError: boolean;
};
export const taskLogsRouter = createRouter();
// GET / — list all log files (metadata only, no messages)
taskLogsRouter.get('/', async (ctx) => {
const email = ctx.get('user').email;
const dir = getTaskLogsDir(email);
let files: string[];
try {
files = (await readdir(dir)).filter((f) => f.endsWith('.json'));
} catch {
return ctx.json([]);
}
// Sort by filename descending (newest first since filenames start with timestamp)
files.sort((a, b) => b.localeCompare(a));
const logs: LogMetadata[] = [];
for (const filename of files) {
try {
const raw = await Bun.file(join(dir, filename)).json();
const lastMessage = Array.isArray(raw.messages) ? raw.messages[raw.messages.length - 1] : null;
const isError = lastMessage?.role === 'result' ? lastMessage.isError : lastMessage?.role === 'error';
logs.push({
filename,
taskName: raw.taskName ?? '',
taskDirName: raw.taskDirName ?? '',
entryName: raw.entryName ?? '',
entryType: raw.entryType ?? 'file',
provider: raw.provider ?? '',
model: raw.model ?? '',
startedAt: raw.startedAt ?? '',
completedAt: raw.completedAt ?? null,
isError: !!isError,
});
} catch {
// Skip unreadable files
}
}
return ctx.json(logs);
});
// GET /:filename — return full log file content
taskLogsRouter.get('/:filename', async (ctx) => {
const email = ctx.get('user').email;
const filename = ctx.req.param('filename');
if (!filename.endsWith('.json') || filename.includes('/') || filename.includes('..')) {
return ctx.text('Invalid filename', 400);
}
const filePath = join(getTaskLogsDir(email), filename);
try {
const data = await Bun.file(filePath).json();
return ctx.json(data);
} catch {
return ctx.text('Not found', 404);
}
});
+204
View File
@@ -0,0 +1,204 @@
import { createRouter } from '../../create-router';
import { readdir, mkdir, rm } from 'node:fs/promises';
import { join, dirname } from 'node:path';
import { getNativeTasksDir, getGlobalTasksDir, getUserTasksDir } from '../../data-path';
type TriggerConfig = { type: 'file'; extensions: string[] } | { type: 'directory' };
type Frontmatter = {
name: string;
description: string;
triggers: TriggerConfig[];
};
export function parseFrontmatter(raw: string): { frontmatter: Frontmatter; body: string } {
const match = raw.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
if (!match) return { frontmatter: { name: '', description: '', triggers: [] }, body: raw };
const yaml = match[1]!;
const body = match[2]!;
const name = yaml.match(/^name:\s*(.+)$/m)?.[1]?.trim() ?? '';
const description = yaml.match(/^description:\s*(.+)$/m)?.[1]?.trim() ?? '';
const triggers: TriggerConfig[] = [];
const triggerMatch = yaml.match(/^trigger:\s*\n((?:[ \t]+.+\n?)*)/m);
if (triggerMatch) {
// Split on top-level list items (lines starting with " - type:")
const items = triggerMatch[1]!.split(/(?=^\s+-\s*type:)/m);
for (const item of items) {
const type = item.match(/type:\s*(.+)/)?.[1]?.trim();
if (type === 'directory') {
triggers.push({ type: 'directory' });
} else if (type === 'file') {
const extBlock = item.match(/extensions:\s*\n((?:\s+-\s*.+\n?)*)/);
const extensions = extBlock ? [...extBlock[1]!.matchAll(/^\s+-\s*(.+)$/gm)].map((m) => m[1]!.trim()) : [];
if (extensions.length > 0) triggers.push({ type: 'file', extensions });
}
}
}
return { frontmatter: { name, description, triggers }, body };
}
export async function readTaskDirs(dir: string): Promise<Map<string, string>> {
const result = new Map<string, string>();
try {
const entries = await readdir(dir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const taskFile = join(dir, entry.name, 'TASK.md');
if (await Bun.file(taskFile).exists()) {
result.set(entry.name, taskFile);
}
}
} catch {
// directory doesn't exist yet
}
return result;
}
type Scope = 'native' | 'global' | 'user';
function resolveScope(dirName: string, native: Map<string, string>, global: Map<string, string>, user: Map<string, string>): Scope {
if (user.has(dirName)) return 'user';
if (global.has(dirName)) return 'global';
return 'native';
}
function resolveFile(name: string, native: Map<string, string>, global: Map<string, string>, user: Map<string, string>): { filePath: string; scope: Scope } | null {
if (user.has(name)) return { filePath: user.get(name)!, scope: 'user' };
if (global.has(name)) return { filePath: global.get(name)!, scope: 'global' };
if (native.has(name)) return { filePath: native.get(name)!, scope: 'native' };
return null;
}
export const tasksRouter = createRouter();
tasksRouter.get('/', async (ctx) => {
const user = ctx.get('user');
const nativeTasks = await readTaskDirs(getNativeTasksDir());
const globalTasks = await readTaskDirs(getGlobalTasksDir());
const userTasks = await readTaskDirs(getUserTasksDir(user.email));
const merged = new Map(nativeTasks);
for (const [name, path] of globalTasks) merged.set(name, path);
for (const [name, path] of userTasks) merged.set(name, path);
const tasks = await Promise.all(
Array.from(merged.entries()).map(async ([dirName, filePath]) => {
const raw = await Bun.file(filePath).text();
const { frontmatter } = parseFrontmatter(raw);
const scope = resolveScope(dirName, nativeTasks, globalTasks, userTasks);
return {
dirName,
name: frontmatter.name || dirName,
description: frontmatter.description,
scope,
triggers: frontmatter.triggers,
filePath,
};
}),
);
return ctx.json(tasks);
});
tasksRouter.get('/:name', async (ctx) => {
const user = ctx.get('user');
const name = ctx.req.param('name');
const nativeTasks = await readTaskDirs(getNativeTasksDir());
const globalTasks = await readTaskDirs(getGlobalTasksDir());
const userTasks = await readTaskDirs(getUserTasksDir(user.email));
const resolved = resolveFile(name, nativeTasks, globalTasks, userTasks);
if (!resolved) return ctx.text('Not found', 404);
const raw = await Bun.file(resolved.filePath).text();
const { frontmatter, body } = parseFrontmatter(raw);
const chatMeta = join(dirname(resolved.filePath), 'chat', 'meta.json');
const chatSessionId = await Bun.file(chatMeta).json().then((m: { id: string }) => m.id).catch(() => null);
return ctx.json({
name: frontmatter.name || name,
description: frontmatter.description,
scope: resolved.scope,
body,
filePath: resolved.filePath,
chatSessionId,
});
});
tasksRouter.get('/:name/chat', async (ctx) => {
const user = ctx.get('user');
const name = ctx.req.param('name');
const nativeTasks = await readTaskDirs(getNativeTasksDir());
const globalTasks = await readTaskDirs(getGlobalTasksDir());
const userTasks = await readTaskDirs(getUserTasksDir(user.email));
const resolved = resolveFile(name, nativeTasks, globalTasks, userTasks);
if (!resolved) return ctx.text('Not found', 404);
const chatDir = join(dirname(resolved.filePath), 'chat');
const sessionId = await Bun.file(join(chatDir, 'meta.json')).json().then((m: { id: string }) => m.id).catch(() => null);
const messages = await Bun.file(join(chatDir, 'messages.json')).json().catch(() => []);
return ctx.json({ sessionId, messages });
});
tasksRouter.put('/:name/chat', async (ctx) => {
const user = ctx.get('user');
const name = ctx.req.param('name');
const nativeTasks = await readTaskDirs(getNativeTasksDir());
const globalTasks = await readTaskDirs(getGlobalTasksDir());
const userTasks = await readTaskDirs(getUserTasksDir(user.email));
const resolved = resolveFile(name, nativeTasks, globalTasks, userTasks);
if (!resolved) return ctx.text('Not found', 404);
const chatDir = join(dirname(resolved.filePath), 'chat');
const { sessionId, messages } = await ctx.req.json<{ sessionId: string; messages: unknown[] }>();
await mkdir(chatDir, { recursive: true });
await Bun.write(join(chatDir, 'messages.json'), JSON.stringify(messages));
if (sessionId) await Bun.write(join(chatDir, 'meta.json'), JSON.stringify({ id: sessionId }));
return ctx.json({ ok: true });
});
tasksRouter.post('/', async (ctx) => {
const { name } = await ctx.req.json<{ name: string }>();
if (!name?.trim()) return ctx.text('Name is required', 400);
const dirName = name.trim().toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
if (!dirName) return ctx.text('Invalid name', 400);
const dir = join(getGlobalTasksDir(), dirName);
const filePath = join(dir, 'TASK.md');
if (await Bun.file(filePath).exists()) {
return ctx.text('Task already exists', 409);
}
await mkdir(dir, { recursive: true });
await Bun.write(filePath, `---\nname: ${name.trim()}\ndescription: \n---\n`);
return ctx.json({ name: name.trim(), dirName, filePath });
});
tasksRouter.delete('/:name', async (ctx) => {
const name = ctx.req.param('name');
const globalDir = join(getGlobalTasksDir(), name);
const globalFile = join(globalDir, 'TASK.md');
if (!(await Bun.file(globalFile).exists())) {
return ctx.text('Not found', 404);
}
await rm(globalDir, { recursive: true });
return ctx.json({ ok: true });
});
+53
View File
@@ -0,0 +1,53 @@
import { createRouter } from '../../create-router';
import { mkdir } from 'node:fs/promises';
import { join } from 'node:path';
import { getTmpAttachmentsDir, getAttachmentsDir } from '@@/data-path';
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5 MB
export const uploadRouter = createRouter();
uploadRouter.post('/', async (ctx) => {
const user = ctx.get('user');
const body = ctx.get('body') as Record<string, unknown>;
const file = body.file as File | null;
const sessionId = (body.sessionId as string) || null;
const provider = (body.provider as 'claude' | 'opencode') || null;
if (!file || !(file instanceof File)) {
return ctx.json({ error: 'file is required' }, 400);
}
if (!file.type.startsWith('image/')) {
return ctx.json({ error: 'Only image files are allowed' }, 400);
}
if (file.size > MAX_FILE_SIZE) {
return ctx.json({ error: 'File exceeds 5 MB limit' }, 400);
}
try {
const buffer = Buffer.from(await file.arrayBuffer());
const ext = file.name.split('.').pop() || 'png';
const filename = `${crypto.randomUUID()}.${ext}`;
let saveDir: string;
if (sessionId && provider) {
saveDir = getAttachmentsDir(user.email, provider, sessionId);
} else {
saveDir = getTmpAttachmentsDir(user.email);
}
await mkdir(saveDir, { recursive: true });
await Bun.write(join(saveDir, filename), buffer);
const base64 = buffer.toString('base64');
const dataUrl = `data:${file.type};base64,${base64}`;
return ctx.json({ filename: file.name, dataUrl, attachmentId: filename });
} catch (err) {
const message = err instanceof Error ? err.message : 'Upload failed';
return ctx.json({ error: message }, 500);
}
});
+17
View File
@@ -0,0 +1,17 @@
import type { Handler } from 'hono';
import { officerdb, eq, Users } from 'officerdb';
import * as errors from '@@/custom-errors';
export const updateUserHandler: Handler = async function (ctx) {
const { name, avatar } = ctx.get('body');
const reqUser = ctx.get('user');
if (typeof name !== 'string') throw errors.BAD_REQUEST('Name is required');
await officerdb
.update(Users)
.set({ name, avatar: avatar ?? null })
.where(eq(Users.id, reqUser.id));
return ctx.json({ ok: true });
};