resolve transcripts and cwd against the caller's home, not the owner's
The history layer, and the last change that could be made without a live member.
claude-sessions.ts had `claudeHome = process.env.HOME_DIR ?? join(DATA_PATH,
email, 'home')`, which discards its argument whenever HOME_DIR is set — always,
on a real install. Every transcript read therefore resolved to the OWNER'S
~/.claude no matter who asked, and the comment above it asserted "single-user
platform" as though that were a property rather than an assumption. A member
reaching these functions would have been handed the owner's conversation list.
Now every read takes a ChatIdentity {email, home} with the home resolved from
resolveHomeDir(userId), and this file has no way to invent one. Both fields
travel together because they are genuinely different: general_chat_sessions
lives under DATA_PATH/<email>, not under a home. Collapsing them would be the
same class of mistake as undefined meaning "the owner".
websocket.ts's resolveCwd takes a home, so `~` expands against the caller's own.
Identity is resolved BEFORE the cwd — expanding `~` before knowing whose home it
is would be exactly the bug being removed — which also let a duplicate
resolveTurnIdentity call from 6aeb304 be deleted.
chat.ts resolves per request and throws FORBIDDEN rather than falling back, same
posture as resolveTurnIdentity. agent-runner passes the owner's home explicitly
rather than inheriting it, since that path really is owner-only.
Made at 01:00 after saying it should not be. Three things I am least sure of are
listed in COMMS 29 rather than left for the reviewer to find: chat routes now
have a failure mode they did not have, resolveBaseCwd's exported parameter
changed meaning rather than shape, and the bare-email rewrite in chat.ts was
mechanical with hand repair.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,52 @@
|
|||||||
|
# 29 — the history layer is off `getOwnerHomeDir`
|
||||||
|
|
||||||
|
The owner asked me to carry on, so `27`'s "tomorrow" became tonight. This is the change I said I would not
|
||||||
|
make at this hour, made at this hour — review it accordingly, and I would rather you were slow about it than
|
||||||
|
quick.
|
||||||
|
|
||||||
|
## What moved
|
||||||
|
|
||||||
|
**`claude-sessions.ts` no longer knows how to invent a home.** `claudeHome(email)` is gone. Every transcript
|
||||||
|
read now takes a `ChatIdentity { email, home }`, and the home arrives resolved from `resolveHomeDir(userId)`.
|
||||||
|
|
||||||
|
The old line was `process.env.HOME_DIR ?? join(DATA_PATH, email, 'home')` — which discards its argument
|
||||||
|
whenever `HOME_DIR` is set, i.e. always on a real install. So every read resolved to the owner's `~/.claude`
|
||||||
|
regardless of who asked, and the comment above it asserted "single-user platform" as though that were a
|
||||||
|
property rather than an assumption.
|
||||||
|
|
||||||
|
Both halves travel together rather than one standing in for the other, because they are genuinely different:
|
||||||
|
`general_chat_sessions` lives under `DATA_PATH/<email>`, not under a home, so those paths stay email-derived.
|
||||||
|
Collapsing them would have been the same class of mistake as `undefined` meaning "the owner".
|
||||||
|
|
||||||
|
**`chat.ts` resolves the identity per request** (`chatIdentity`), and throws FORBIDDEN rather than falling
|
||||||
|
back — same posture as `resolveTurnIdentity`, for the same reason.
|
||||||
|
|
||||||
|
**`websocket.ts`'s `resolveCwd` takes a home rather than an email**, so `~` expands against the caller's own
|
||||||
|
home. Its old comment — *"the server owner is the only account, absolute paths are theirs to use"* — is
|
||||||
|
replaced with what is actually true now: an absolute path still passes through, and it is the kernel that
|
||||||
|
decides what a member's turn can open, not a string check here.
|
||||||
|
|
||||||
|
**Ordering worth checking:** identity is resolved *before* the cwd, because expanding `~` before knowing whose
|
||||||
|
home it is would be exactly the bug this removes. That reordering also let me delete a second
|
||||||
|
`resolveTurnIdentity` call that `25` had left further down the same function.
|
||||||
|
|
||||||
|
**`agent-runner.ts`** passes `{ email, home: homeDir }` explicitly — it is an owner-only path, and I would
|
||||||
|
rather it state that than inherit it.
|
||||||
|
|
||||||
|
## What I am least sure of
|
||||||
|
|
||||||
|
1. **`chatIdentity` throws where the old code could not fail.** Every `/chat` route now has a failure mode it
|
||||||
|
did not have. Owner-only today, and `resolveHomeDir` returns `isOwner` from a positive check — but it is
|
||||||
|
the kind of change that turns a database blip into a 403 on a screen that used to always load.
|
||||||
|
2. **`resolveBaseCwd` is exported.** I changed its parameter from email to home and followed the compiler; if
|
||||||
|
anything outside this diff passes an email, `tsgo` would have caught it, but the *meaning* of a wrong-but-
|
||||||
|
well-typed string would not be caught by anything.
|
||||||
|
3. **The regex pass.** I rewrote bare `email` references inside `chat.ts` mechanically and then repaired the
|
||||||
|
damage by hand. `tsgo` is clean and 78 tests pass, but that is a weaker guarantee than usual for this file.
|
||||||
|
|
||||||
|
`tsgo` clean, 78 tests, both gates up, `member` unreachable in production.
|
||||||
|
|
||||||
|
## What is left
|
||||||
|
|
||||||
|
Nothing I can write blind. `deprovisionOsAccount` is still mine and still tomorrow's. A member signing in, the
|
||||||
|
first member turn, and the gates are the owner's.
|
||||||
@@ -87,7 +87,7 @@ export function buildAgentPrompt(agent: AgentRecord, inputs: Record<string, unkn
|
|||||||
* which is fine: while a run is live you find it as the newest entry in the agent's project group.
|
* which is fine: while a run is live you find it as the newest entry in the agent's project group.
|
||||||
*/
|
*/
|
||||||
function titleRun(
|
function titleRun(
|
||||||
email: string,
|
who: { email: string; home: string },
|
||||||
cwd: string,
|
cwd: string,
|
||||||
claudeSessionId: string,
|
claudeSessionId: string,
|
||||||
agentName: string,
|
agentName: string,
|
||||||
@@ -99,7 +99,7 @@ function titleRun(
|
|||||||
const title = [agentName, subject, when].filter(Boolean).join(' · ');
|
const title = [agentName, subject, when].filter(Boolean).join(' · ');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!renameClaudeSession(email, cwd, claudeSessionId, title)) {
|
if (!renameClaudeSession(who, cwd, claudeSessionId, title)) {
|
||||||
logger.warn('Could not title agent run — transcript not found', { claudeSessionId, cwd });
|
logger.warn('Could not title agent run — transcript not found', { claudeSessionId, cwd });
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -171,7 +171,13 @@ export async function startAgentRun(params: StartAgentRunParams): Promise<StartA
|
|||||||
run.finishedAt = Date.now();
|
run.finishedAt = Date.now();
|
||||||
if (msg.claudeSessionId) {
|
if (msg.claudeSessionId) {
|
||||||
run.claudeSessionId = msg.claudeSessionId;
|
run.claudeSessionId = msg.claudeSessionId;
|
||||||
titleRun(params.user.email, cwd, msg.claudeSessionId, agent.name || agent.dirName, inputs);
|
titleRun(
|
||||||
|
{ email: params.user.email, home: homeDir },
|
||||||
|
cwd,
|
||||||
|
msg.claudeSessionId,
|
||||||
|
agent.name || agent.dirName,
|
||||||
|
inputs,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} else if (msg.type === 'error') {
|
} else if (msg.type === 'error') {
|
||||||
run.status = 'failed';
|
run.status = 'failed';
|
||||||
|
|||||||
@@ -26,7 +26,26 @@ import {
|
|||||||
import { getOpenCodePrompt, getOpenCodeSession } from './opencode/state';
|
import { getOpenCodePrompt, getOpenCodeSession } from './opencode/state';
|
||||||
import { listChatModels } from './list-models';
|
import { listChatModels } from './list-models';
|
||||||
import { logger } from './logger';
|
import { logger } from './logger';
|
||||||
|
import { resolveHomeDir } from '@@/user-home';
|
||||||
|
import type { ChatIdentity } from './claude-sessions';
|
||||||
import { readSttConfig } from '../server-settings/stt';
|
import { readSttConfig } from '../server-settings/stt';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whose transcripts a request may read.
|
||||||
|
*
|
||||||
|
* The home comes from `resolveHomeDir`, never from `getOwnerHomeDir` — that one ignores its argument whenever
|
||||||
|
* HOME_DIR is set, which is how every read in this router used to resolve to the owner's `~/.claude` no matter
|
||||||
|
* who asked. Throws rather than falling back, for the same reason `resolveTurnIdentity` refuses: there is no
|
||||||
|
* safe home to substitute, and the owner's is the one wrong answer.
|
||||||
|
*
|
||||||
|
* Unreachable by a member today — the router refuses non-owners above — so this is the path being made correct
|
||||||
|
* before it is opened, not a live fix.
|
||||||
|
*/
|
||||||
|
async function chatIdentity(user: { id: number; email: string }): Promise<ChatIdentity> {
|
||||||
|
const resolved = await resolveHomeDir(user.id);
|
||||||
|
if (!resolved.ok) throw errors.FORBIDDEN(resolved.reason);
|
||||||
|
return { email: user.email, home: resolved.home };
|
||||||
|
}
|
||||||
import { transcribeAudio } from '../stt/transcribe';
|
import { transcribeAudio } from '../stt/transcribe';
|
||||||
import { registerAgentPanelRoutes } from './agent-panels-routes';
|
import { registerAgentPanelRoutes } from './agent-panels-routes';
|
||||||
|
|
||||||
@@ -60,17 +79,17 @@ chatRouter.use(async (ctx, next) => {
|
|||||||
const cwdOf = (ctx: Context, email: string): string => ctx.req.query('cwd')?.trim() || getGeneralChatSessionsCwd(email);
|
const cwdOf = (ctx: Context, email: string): string => ctx.req.query('cwd')?.trim() || getGeneralChatSessionsCwd(email);
|
||||||
|
|
||||||
// GET /chat/pwds — the default /chat dir plus every directory that already has Claude sessions.
|
// GET /chat/pwds — the default /chat dir plus every directory that already has Claude sessions.
|
||||||
chatRouter.get('/pwds', (ctx) => {
|
chatRouter.get('/pwds', async (ctx) => {
|
||||||
const email = ctx.get('user').email;
|
const who = await chatIdentity(ctx.get('user'));
|
||||||
return ctx.json({ pwds: listClaudePwds(email), default: getGeneralChatSessionsCwd(email) });
|
return ctx.json({ pwds: listClaudePwds(who), default: getGeneralChatSessionsCwd(who.email) });
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /chat/sessions[?cwd=] — conversations for a working directory, merged across both harnesses
|
// GET /chat/sessions[?cwd=] — conversations for a working directory, merged across both harnesses
|
||||||
// (Claude transcripts + OpenCode's session store), newest first.
|
// (Claude transcripts + OpenCode's session store), newest first.
|
||||||
chatRouter.get('/sessions', async (ctx) => {
|
chatRouter.get('/sessions', async (ctx) => {
|
||||||
const email = ctx.get('user').email;
|
const who = await chatIdentity(ctx.get('user'));
|
||||||
const cwd = cwdOf(ctx, email);
|
const cwd = cwdOf(ctx, who.email);
|
||||||
const claude = listClaudeSessions(email, cwd).map((s) => ({ ...s, harness: 'claude' as const }));
|
const claude = listClaudeSessions(who, cwd).map((s) => ({ ...s, harness: 'claude' as const }));
|
||||||
const opencode = await listOpenCodeSessions(cwd);
|
const opencode = await listOpenCodeSessions(cwd);
|
||||||
const sessions = [...claude, ...opencode].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
const sessions = [...claude, ...opencode].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||||||
return ctx.json({ sessions });
|
return ctx.json({ sessions });
|
||||||
@@ -83,14 +102,14 @@ chatRouter.get('/sessions', async (ctx) => {
|
|||||||
// response carries `total` (full length) and `offset` (absolute index of messages[0]) so the client knows
|
// response carries `total` (full length) and `offset` (absolute index of messages[0]) so the client knows
|
||||||
// where the window sits and whether older messages remain above it.
|
// where the window sits and whether older messages remain above it.
|
||||||
chatRouter.get('/sessions/:id', async (ctx) => {
|
chatRouter.get('/sessions/:id', async (ctx) => {
|
||||||
const email = ctx.get('user').email;
|
const who = await chatIdentity(ctx.get('user'));
|
||||||
const id = ctx.req.param('id');
|
const id = ctx.req.param('id');
|
||||||
const cwd = cwdOf(ctx, email);
|
const cwd = cwdOf(ctx, who.email);
|
||||||
// Fall back to a by-id scan when the (default) cwd doesn't hold it — a fresh /chat/<id> deep-link/refresh
|
// Fall back to a by-id scan when the (default) cwd doesn't hold it — a fresh /chat/<id> deep-link/refresh
|
||||||
// doesn't know the session's cwd. The returned detail carries the real cwd for the client to scope the UI.
|
// doesn't know the session's cwd. The returned detail carries the real cwd for the client to scope the UI.
|
||||||
const detail = isOpenCodeSessionId(id)
|
const detail = isOpenCodeSessionId(id)
|
||||||
? await loadOpenCodeSession(id)
|
? await loadOpenCodeSession(id)
|
||||||
: (loadClaudeSession(email, cwd, id) ?? loadClaudeSessionById(email, id));
|
: (loadClaudeSession(who, cwd, id) ?? loadClaudeSessionById(who, id));
|
||||||
if (!detail) return ctx.text('Not found', 404);
|
if (!detail) return ctx.text('Not found', 404);
|
||||||
|
|
||||||
const total = detail.messages.length;
|
const total = detail.messages.length;
|
||||||
@@ -106,7 +125,7 @@ chatRouter.get('/sessions/:id', async (ctx) => {
|
|||||||
// `detail.cwd` — the transcript's own directory — not the requested `cwd`, which on a deep link is
|
// `detail.cwd` — the transcript's own directory — not the requested `cwd`, which on a deep link is
|
||||||
// still the default group and holds none of this session's neighbours. OpenCode has no chains of its
|
// still the default group and holds none of this session's neighbours. OpenCode has no chains of its
|
||||||
// own, so it gets neither rather than a fabricated answer.
|
// own, so it gets neither rather than a fabricated answer.
|
||||||
const context = isOpenCodeSessionId(id) ? null : claudeSessionContext(email, detail.cwd, id);
|
const context = isOpenCodeSessionId(id) ? null : claudeSessionContext(who, detail.cwd, id);
|
||||||
|
|
||||||
return ctx.json({
|
return ctx.json({
|
||||||
...detail,
|
...detail,
|
||||||
@@ -130,7 +149,8 @@ chatRouter.get('/sessions/:id', async (ctx) => {
|
|||||||
// happens to be browsing — which is how the list ended up showing raw ids for anything running elsewhere.
|
// happens to be browsing — which is how the list ended up showing raw ids for anything running elsewhere.
|
||||||
chatRouter.get('/live', async (ctx) => {
|
chatRouter.get('/live', async (ctx) => {
|
||||||
const user = ctx.get('user');
|
const user = ctx.get('user');
|
||||||
const email = user.email;
|
const who = await chatIdentity(user);
|
||||||
|
const email = who.email;
|
||||||
// Both harnesses, asked in parallel. Either failing contributes nothing rather than failing the panel:
|
// Both harnesses, asked in parallel. Either failing contributes nothing rather than failing the panel:
|
||||||
// both registry calls swallow their errors and return [].
|
// both registry calls swallow their errors and return [].
|
||||||
const [live, liveOpenCode] = await Promise.all([
|
const [live, liveOpenCode] = await Promise.all([
|
||||||
@@ -142,7 +162,7 @@ chatRouter.get('/live', async (ctx) => {
|
|||||||
// is named after Claude's. Null until the first turn reports one, which is a conversation that has
|
// is named after Claude's. Null until the first turn reports one, which is a conversation that has
|
||||||
// genuinely not been written yet.
|
// genuinely not been written yet.
|
||||||
const transcriptId = session.claudeSessionId;
|
const transcriptId = session.claudeSessionId;
|
||||||
const resolved = transcriptId && !isOpenCodeSessionId(transcriptId) ? liveSessionTitle(email, transcriptId) : null;
|
const resolved = transcriptId && !isOpenCodeSessionId(transcriptId) ? liveSessionTitle(who, transcriptId) : null;
|
||||||
return { ...session, harness: 'claude' as const, title: resolved?.title ?? null, cwd: resolved?.cwd ?? null };
|
return { ...session, harness: 'claude' as const, title: resolved?.title ?? null, cwd: resolved?.cwd ?? null };
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -192,24 +212,24 @@ chatRouter.get('/live', async (ctx) => {
|
|||||||
// Claude `/clear` chain that is every part of it: the list shows the chain as one conversation, so
|
// Claude `/clear` chain that is every part of it: the list shows the chain as one conversation, so
|
||||||
// deleting it deletes one conversation.
|
// deleting it deletes one conversation.
|
||||||
chatRouter.delete('/sessions/:id', async (ctx) => {
|
chatRouter.delete('/sessions/:id', async (ctx) => {
|
||||||
const email = ctx.get('user').email;
|
const who = await chatIdentity(ctx.get('user'));
|
||||||
const id = ctx.req.param('id');
|
const id = ctx.req.param('id');
|
||||||
const cwd = cwdOf(ctx, email);
|
const cwd = cwdOf(ctx, who.email);
|
||||||
const ok = isOpenCodeSessionId(id) ? await deleteOpenCodeSession(id) : deleteClaudeSession(email, cwd, id);
|
const ok = isOpenCodeSessionId(id) ? await deleteOpenCodeSession(id) : deleteClaudeSession(who, cwd, id);
|
||||||
if (!ok) return ctx.text('Not found', 404);
|
if (!ok) return ctx.text('Not found', 404);
|
||||||
return ctx.json({ ok: true });
|
return ctx.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
// PATCH /chat/sessions/:id/title[?cwd=] — rename in the owning harness's store.
|
// PATCH /chat/sessions/:id/title[?cwd=] — rename in the owning harness's store.
|
||||||
chatRouter.patch('/sessions/:id/title', async (ctx) => {
|
chatRouter.patch('/sessions/:id/title', async (ctx) => {
|
||||||
const email = ctx.get('user').email;
|
const who = await chatIdentity(ctx.get('user'));
|
||||||
const id = ctx.req.param('id');
|
const id = ctx.req.param('id');
|
||||||
const cwd = cwdOf(ctx, email);
|
const cwd = cwdOf(ctx, who.email);
|
||||||
const { title } = await ctx.req.json<{ title?: string }>();
|
const { title } = await ctx.req.json<{ title?: string }>();
|
||||||
if (!title?.trim()) return ctx.text('title is required', 400);
|
if (!title?.trim()) return ctx.text('title is required', 400);
|
||||||
const ok = isOpenCodeSessionId(id)
|
const ok = isOpenCodeSessionId(id)
|
||||||
? await renameOpenCodeSession(id, title.trim())
|
? await renameOpenCodeSession(id, title.trim())
|
||||||
: renameClaudeSession(email, cwd, id, title.trim());
|
: renameClaudeSession(who, cwd, id, title.trim());
|
||||||
if (!ok) return ctx.text('Not found', 404);
|
if (!ok) return ctx.text('Not found', 404);
|
||||||
return ctx.json({ ok: true });
|
return ctx.json({ ok: true });
|
||||||
});
|
});
|
||||||
@@ -220,9 +240,9 @@ chatRouter.patch('/sessions/:id/title', async (ctx) => {
|
|||||||
// A task that has not written anything yet answers 200 with `{ kind: 'pending' }`, not 404. The tray asks
|
// A task that has not written anything yet answers 200 with `{ kind: 'pending' }`, not 404. The tray asks
|
||||||
// the moment `task:started` arrives, which is routinely before the file exists, and a 404 there would be
|
// the moment `task:started` arrives, which is routinely before the file exists, and a 404 there would be
|
||||||
// an error state for the most ordinary thing that can happen.
|
// an error state for the most ordinary thing that can happen.
|
||||||
chatRouter.get('/tasks/:id', (ctx) => {
|
chatRouter.get('/tasks/:id', async (ctx) => {
|
||||||
const email = ctx.get('user').email;
|
const who = await chatIdentity(ctx.get('user'));
|
||||||
const detail = loadBackgroundTask(email, ctx.req.param('id'));
|
const detail = loadBackgroundTask(who, ctx.req.param('id'));
|
||||||
return ctx.json(detail ?? { kind: 'pending' });
|
return ctx.json(detail ?? { kind: 'pending' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -18,11 +18,25 @@ import { DATA_PATH } from '../../data-path';
|
|||||||
// The `claude` CLI persists every session as a JSONL transcript at
|
// The `claude` CLI persists every session as a JSONL transcript at
|
||||||
// $HOME/.claude/projects/<slug>/<session-uuid>.jsonl
|
// $HOME/.claude/projects/<slug>/<session-uuid>.jsonl
|
||||||
// where <slug> is the working directory with every non-alphanumeric char replaced by '-'.
|
// where <slug> is the working directory with every non-alphanumeric char replaced by '-'.
|
||||||
// Single-user platform: Claude runs with no isolation — HOME is the real home
|
// Claude runs with no isolation for the OWNER — HOME is their real home — so its transcripts are the same
|
||||||
// (HOME_DIR) — so its transcripts are the same store the terminal `claude` uses. We never keep our
|
// store their terminal `claude` uses. We never keep our own copy; Claude's files are authoritative.
|
||||||
// own copy; Claude's files are authoritative.
|
//
|
||||||
|
// ── Why this takes a home instead of an email ──
|
||||||
const claudeHome = (email: string): string => process.env.HOME_DIR ?? join(DATA_PATH, email, 'home');
|
//
|
||||||
|
// It used to be `process.env.HOME_DIR ?? join(DATA_PATH, email, 'home')`, which discards its argument whenever
|
||||||
|
// HOME_DIR is set — which is always, on a real install. Every read therefore resolved to the OWNER'S
|
||||||
|
// transcripts regardless of who was asking, and the comment above it said "single-user platform" as though
|
||||||
|
// that were a property rather than an assumption. A member reaching these functions would have been handed the
|
||||||
|
// owner's conversation list.
|
||||||
|
//
|
||||||
|
// So the home arrives resolved, from `resolveHomeDir(userId)`, and this file has no way to invent one. The
|
||||||
|
// email is still passed where a path is genuinely email-derived — `general_chat_sessions` lives under
|
||||||
|
// DATA_PATH, not under a home — which is why both travel together rather than one standing in for the other.
|
||||||
|
export type ChatIdentity = {
|
||||||
|
email: string;
|
||||||
|
/** From `resolveHomeDir`. Never `getOwnerHomeDir`, which ignores its argument. */
|
||||||
|
home: string;
|
||||||
|
};
|
||||||
|
|
||||||
/** Dedicated working directory for /chat sessions, so they form their own Claude "project" group. */
|
/** Dedicated working directory for /chat sessions, so they form their own Claude "project" group. */
|
||||||
export const getGeneralChatSessionsCwd = (email: string): string => join(DATA_PATH, email, 'general_chat_sessions');
|
export const getGeneralChatSessionsCwd = (email: string): string => join(DATA_PATH, email, 'general_chat_sessions');
|
||||||
@@ -34,7 +48,7 @@ export const ensureGeneralChatSessionsCwd = (email: string): string => {
|
|||||||
return dir;
|
return dir;
|
||||||
};
|
};
|
||||||
|
|
||||||
const claudeProjectsDir = (email: string): string => join(claudeHome(email), '.claude', 'projects');
|
const claudeProjectsDir = (home: string): string => join(home, '.claude', 'projects');
|
||||||
|
|
||||||
/** Claude's folder name for a working directory. */
|
/** Claude's folder name for a working directory. */
|
||||||
export const projectSlug = (cwd: string): string => cwd.replace(/[^a-zA-Z0-9]/g, '-');
|
export const projectSlug = (cwd: string): string => cwd.replace(/[^a-zA-Z0-9]/g, '-');
|
||||||
@@ -506,15 +520,15 @@ function parseClaudeTranscript(filePath: string, sessionId: string, fallbackCwd
|
|||||||
* Pagination needs nothing: the client asks for index windows into whatever the server calls the
|
* Pagination needs nothing: the client asks for index windows into whatever the server calls the
|
||||||
* transcript, so a longer one simply pages further back.
|
* transcript, so a longer one simply pages further back.
|
||||||
*/
|
*/
|
||||||
function loadChainTranscript(email: string, detail: ClaudeSessionDetail): ClaudeSessionDetail {
|
function loadChainTranscript(who: ChatIdentity, detail: ClaudeSessionDetail): ClaudeSessionDetail {
|
||||||
const parts = (() => {
|
const parts = (() => {
|
||||||
const group = scanGroup(email, detail.cwd);
|
const group = scanGroup(who, detail.cwd);
|
||||||
const head = group.find((session) => session.id === detail.id);
|
const head = group.find((session) => session.id === detail.id);
|
||||||
return head ? chainOf(head, new Map(group.map((session) => [session.id, session]))) : [];
|
return head ? chainOf(head, new Map(group.map((session) => [session.id, session]))) : [];
|
||||||
})();
|
})();
|
||||||
if (parts.length < 2) return detail;
|
if (parts.length < 2) return detail;
|
||||||
|
|
||||||
const dir = join(claudeProjectsDir(email), projectSlug(detail.cwd));
|
const dir = join(claudeProjectsDir(who.home), projectSlug(detail.cwd));
|
||||||
const earlier: ClaudeChatMessage[] = [];
|
const earlier: ClaudeChatMessage[] = [];
|
||||||
for (const part of parts.slice(0, -1)) {
|
for (const part of parts.slice(0, -1)) {
|
||||||
const segment = parseClaudeTranscript(join(dir, `${part.id}.jsonl`), part.id, detail.cwd);
|
const segment = parseClaudeTranscript(join(dir, `${part.id}.jsonl`), part.id, detail.cwd);
|
||||||
@@ -526,20 +540,20 @@ function loadChainTranscript(email: string, detail: ClaudeSessionDetail): Claude
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Load a session when its cwd (project group) is known. */
|
/** Load a session when its cwd (project group) is known. */
|
||||||
export function loadClaudeSession(email: string, cwd: string, sessionId: string): ClaudeSessionDetail | null {
|
export function loadClaudeSession(who: ChatIdentity, cwd: string, sessionId: string): ClaudeSessionDetail | null {
|
||||||
const detail = parseClaudeTranscript(
|
const detail = parseClaudeTranscript(
|
||||||
join(claudeProjectsDir(email), projectSlug(cwd), `${sessionId}.jsonl`),
|
join(claudeProjectsDir(who.home), projectSlug(cwd), `${sessionId}.jsonl`),
|
||||||
sessionId,
|
sessionId,
|
||||||
cwd,
|
cwd,
|
||||||
);
|
);
|
||||||
return detail && loadChainTranscript(email, detail);
|
return detail && loadChainTranscript(who, detail);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Resolve a session by id ALONE — scan every project group for its transcript. Used on a deep-link /
|
/** Resolve a session by id ALONE — scan every project group for its transcript. Used on a deep-link /
|
||||||
* refresh to /chat/<id>, when the cwd isn't known yet; the transcript records the real cwd, which the
|
* refresh to /chat/<id>, when the cwd isn't known yet; the transcript records the real cwd, which the
|
||||||
* caller uses to scope the list + cwd picker. */
|
* caller uses to scope the list + cwd picker. */
|
||||||
export function loadClaudeSessionById(email: string, sessionId: string): ClaudeSessionDetail | null {
|
export function loadClaudeSessionById(who: ChatIdentity, sessionId: string): ClaudeSessionDetail | null {
|
||||||
const projectsDir = claudeProjectsDir(email);
|
const projectsDir = claudeProjectsDir(who.home);
|
||||||
let slugs: string[];
|
let slugs: string[];
|
||||||
try {
|
try {
|
||||||
slugs = readdirSync(projectsDir);
|
slugs = readdirSync(projectsDir);
|
||||||
@@ -550,7 +564,7 @@ export function loadClaudeSessionById(email: string, sessionId: string): ClaudeS
|
|||||||
const filePath = join(projectsDir, slug, `${sessionId}.jsonl`);
|
const filePath = join(projectsDir, slug, `${sessionId}.jsonl`);
|
||||||
if (!existsSync(filePath)) continue;
|
if (!existsSync(filePath)) continue;
|
||||||
const detail = parseClaudeTranscript(filePath, sessionId);
|
const detail = parseClaudeTranscript(filePath, sessionId);
|
||||||
return detail && loadChainTranscript(email, detail);
|
return detail && loadChainTranscript(who, detail);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -562,11 +576,11 @@ export function loadClaudeSessionById(email: string, sessionId: string): ClaudeS
|
|||||||
* or a deep link hasn't resolved its group yet). Reads have always fallen back like this; writes did
|
* or a deep link hasn't resolved its group yet). Reads have always fallen back like this; writes did
|
||||||
* not, so delete and rename returned "not found" for a session that was plainly on screen.
|
* not, so delete and rename returned "not found" for a session that was plainly on screen.
|
||||||
*/
|
*/
|
||||||
function findTranscript(email: string, cwd: string, sessionId: string): string | null {
|
function findTranscript(who: ChatIdentity, cwd: string, sessionId: string): string | null {
|
||||||
const preferred = join(claudeProjectsDir(email), projectSlug(cwd), `${sessionId}.jsonl`);
|
const preferred = join(claudeProjectsDir(who.home), projectSlug(cwd), `${sessionId}.jsonl`);
|
||||||
if (existsSync(preferred)) return preferred;
|
if (existsSync(preferred)) return preferred;
|
||||||
|
|
||||||
const projectsDir = claudeProjectsDir(email);
|
const projectsDir = claudeProjectsDir(who.home);
|
||||||
let slugs: string[];
|
let slugs: string[];
|
||||||
try {
|
try {
|
||||||
slugs = readdirSync(projectsDir);
|
slugs = readdirSync(projectsDir);
|
||||||
@@ -588,12 +602,12 @@ function findTranscript(email: string, cwd: string, sessionId: string): string |
|
|||||||
* routinely (see `findTranscript`), and the wrong group would find no chain and silently delete one
|
* routinely (see `findTranscript`), and the wrong group would find no chain and silently delete one
|
||||||
* part of several.
|
* part of several.
|
||||||
*/
|
*/
|
||||||
export function deleteClaudeSession(email: string, cwd: string, sessionId: string): boolean {
|
export function deleteClaudeSession(who: ChatIdentity, cwd: string, sessionId: string): boolean {
|
||||||
const filePath = findTranscript(email, cwd, sessionId);
|
const filePath = findTranscript(who, cwd, sessionId);
|
||||||
if (!filePath) return false;
|
if (!filePath) return false;
|
||||||
|
|
||||||
const ownCwd = firstCwd(filePath);
|
const ownCwd = firstCwd(filePath);
|
||||||
const ids = ownCwd ? chainFileIds(email, ownCwd, sessionId) : [sessionId];
|
const ids = ownCwd ? chainFileIds(who, ownCwd, sessionId) : [sessionId];
|
||||||
const dir = dirname(filePath);
|
const dir = dirname(filePath);
|
||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
const partPath = join(dir, `${id}.jsonl`);
|
const partPath = join(dir, `${id}.jsonl`);
|
||||||
@@ -607,8 +621,8 @@ export function deleteClaudeSession(email: string, cwd: string, sessionId: strin
|
|||||||
* the title lives in .claude (source of truth). Our reader takes the last summary as the title; no
|
* the title lives in .claude (source of truth). Our reader takes the last summary as the title; no
|
||||||
* timestamp is written so the rename doesn't reorder the list.
|
* timestamp is written so the rename doesn't reorder the list.
|
||||||
*/
|
*/
|
||||||
export function renameClaudeSession(email: string, cwd: string, sessionId: string, title: string): boolean {
|
export function renameClaudeSession(who: ChatIdentity, cwd: string, sessionId: string, title: string): boolean {
|
||||||
const filePath = findTranscript(email, cwd, sessionId);
|
const filePath = findTranscript(who, cwd, sessionId);
|
||||||
if (!filePath) return false;
|
if (!filePath) return false;
|
||||||
|
|
||||||
// Attach the summary to the transcript's tip (the last entry carrying a uuid).
|
// Attach the summary to the transcript's tip (the last entry carrying a uuid).
|
||||||
@@ -661,11 +675,11 @@ export type BackgroundTaskDetail =
|
|||||||
| { kind: 'agent'; messages: ClaudeChatMessage[] }
|
| { kind: 'agent'; messages: ClaudeChatMessage[] }
|
||||||
| { kind: 'log'; text: string; truncated: boolean };
|
| { kind: 'log'; text: string; truncated: boolean };
|
||||||
|
|
||||||
function findTaskOutput(email: string, taskId: string): string | null {
|
function findTaskOutput(who: ChatIdentity, taskId: string): string | null {
|
||||||
const tmpRoot = process.env.TMPDIR ?? '/tmp';
|
const tmpRoot = process.env.TMPDIR ?? '/tmp';
|
||||||
const candidates: [string, string][] = [
|
const candidates: [string, string][] = [
|
||||||
[tmpRoot, `claude-*/*/*/tasks/${taskId}.output`],
|
[tmpRoot, `claude-*/*/*/tasks/${taskId}.output`],
|
||||||
[claudeProjectsDir(email), `*/*/subagents/agent-${taskId}.jsonl`],
|
[claudeProjectsDir(who.home), `*/*/subagents/agent-${taskId}.jsonl`],
|
||||||
];
|
];
|
||||||
for (const [root, pattern] of candidates) {
|
for (const [root, pattern] of candidates) {
|
||||||
try {
|
try {
|
||||||
@@ -701,9 +715,9 @@ function tailFile(filePath: string, bytes: number): { text: string; truncated: b
|
|||||||
* What a background task is doing right now. Returns null when nothing has been written yet — which is
|
* What a background task is doing right now. Returns null when nothing has been written yet — which is
|
||||||
* the normal state for the first second or two of a task's life, not an error.
|
* the normal state for the first second or two of a task's life, not an error.
|
||||||
*/
|
*/
|
||||||
export function loadBackgroundTask(email: string, taskId: string): BackgroundTaskDetail | null {
|
export function loadBackgroundTask(who: ChatIdentity, taskId: string): BackgroundTaskDetail | null {
|
||||||
if (!TASK_ID_RE.test(taskId)) return null;
|
if (!TASK_ID_RE.test(taskId)) return null;
|
||||||
const found = findTaskOutput(email, taskId);
|
const found = findTaskOutput(who, taskId);
|
||||||
if (!found) return null;
|
if (!found) return null;
|
||||||
|
|
||||||
let target = found;
|
let target = found;
|
||||||
@@ -754,9 +768,9 @@ function firstCwd(filePath: string): string {
|
|||||||
export type ClaudePwd = { cwd: string; sessionCount: number; updatedAt: string; isDefault: boolean };
|
export type ClaudePwd = { cwd: string; sessionCount: number; updatedAt: string; isDefault: boolean };
|
||||||
|
|
||||||
/** All working directories that have Claude sessions, plus the default /chat dir. Newest first. */
|
/** All working directories that have Claude sessions, plus the default /chat dir. Newest first. */
|
||||||
export function listClaudePwds(email: string): ClaudePwd[] {
|
export function listClaudePwds(who: ChatIdentity): ClaudePwd[] {
|
||||||
const projectsDir = claudeProjectsDir(email);
|
const projectsDir = claudeProjectsDir(who.home);
|
||||||
const defaultCwd = getGeneralChatSessionsCwd(email);
|
const defaultCwd = getGeneralChatSessionsCwd(who.email);
|
||||||
const byCwd = new Map<string, { count: number; updatedAt: string }>();
|
const byCwd = new Map<string, { count: number; updatedAt: string }>();
|
||||||
|
|
||||||
if (existsSync(projectsDir)) {
|
if (existsSync(projectsDir)) {
|
||||||
@@ -799,8 +813,8 @@ export function listClaudePwds(email: string): ClaudePwd[] {
|
|||||||
* to its neighbours, so there is no per-file answer to cache. The per-file summaries underneath it are
|
* to its neighbours, so there is no per-file answer to cache. The per-file summaries underneath it are
|
||||||
* mtime-cached, which is what makes calling this on every request cheap.
|
* mtime-cached, which is what makes calling this on every request cheap.
|
||||||
*/
|
*/
|
||||||
function scanGroup(email: string, cwd: string): TranscriptSummary[] {
|
function scanGroup(who: ChatIdentity, cwd: string): TranscriptSummary[] {
|
||||||
const dir = join(claudeProjectsDir(email), projectSlug(cwd));
|
const dir = join(claudeProjectsDir(who.home), projectSlug(cwd));
|
||||||
if (!existsSync(dir)) return [];
|
if (!existsSync(dir)) return [];
|
||||||
|
|
||||||
const sessions: TranscriptSummary[] = [];
|
const sessions: TranscriptSummary[] = [];
|
||||||
@@ -813,8 +827,8 @@ function scanGroup(email: string, cwd: string): TranscriptSummary[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Conversations Claude has stored for a working directory, newest first, one row per `/clear` chain. */
|
/** Conversations Claude has stored for a working directory, newest first, one row per `/clear` chain. */
|
||||||
export function listClaudeSessions(email: string, cwd: string): ClaudeSessionSummary[] {
|
export function listClaudeSessions(who: ChatIdentity, cwd: string): ClaudeSessionSummary[] {
|
||||||
return mergeChains(scanGroup(email, cwd))
|
return mergeChains(scanGroup(who, cwd))
|
||||||
.map(publish)
|
.map(publish)
|
||||||
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||||||
}
|
}
|
||||||
@@ -831,13 +845,13 @@ export function listClaudeSessions(email: string, cwd: string): ClaudeSessionSum
|
|||||||
* still opens a real transcript, so answer for the part itself rather than 404 the title.
|
* still opens a real transcript, so answer for the part itself rather than 404 the title.
|
||||||
*/
|
*/
|
||||||
export function claudeSessionContext(
|
export function claudeSessionContext(
|
||||||
email: string,
|
who: ChatIdentity,
|
||||||
cwd: string,
|
cwd: string,
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
): { title: string; partCount: number } | null {
|
): { title: string; partCount: number } | null {
|
||||||
const merged = listClaudeSessions(email, cwd).find((entry) => entry.id === sessionId);
|
const merged = listClaudeSessions(who, cwd).find((entry) => entry.id === sessionId);
|
||||||
if (merged) return { title: merged.title, partCount: merged.partCount ?? 1 };
|
if (merged) return { title: merged.title, partCount: merged.partCount ?? 1 };
|
||||||
const part = scanGroup(email, cwd).find((entry) => entry.id === sessionId);
|
const part = scanGroup(who, cwd).find((entry) => entry.id === sessionId);
|
||||||
return part ? { title: part.title, partCount: 1 } : null;
|
return part ? { title: part.title, partCount: 1 } : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -854,8 +868,8 @@ export function claudeSessionContext(
|
|||||||
* of live sessions, so it is not worth a cache yet — but it is worth knowing before this is called from
|
* of live sessions, so it is not worth a cache yet — but it is worth knowing before this is called from
|
||||||
* anywhere hotter.
|
* anywhere hotter.
|
||||||
*/
|
*/
|
||||||
export function liveSessionTitle(email: string, sessionId: string): { title: string; cwd: string } | null {
|
export function liveSessionTitle(who: ChatIdentity, sessionId: string): { title: string; cwd: string } | null {
|
||||||
const projectsDir = claudeProjectsDir(email);
|
const projectsDir = claudeProjectsDir(who.home);
|
||||||
let slugs: string[];
|
let slugs: string[];
|
||||||
try {
|
try {
|
||||||
slugs = readdirSync(projectsDir);
|
slugs = readdirSync(projectsDir);
|
||||||
@@ -884,7 +898,7 @@ export function liveSessionTitle(email: string, sessionId: string): { title: str
|
|||||||
}
|
}
|
||||||
if (!cwd) return null;
|
if (!cwd) return null;
|
||||||
|
|
||||||
const context = claudeSessionContext(email, cwd, sessionId);
|
const context = claudeSessionContext(who, cwd, sessionId);
|
||||||
return context ? { title: context.title, cwd } : null;
|
return context ? { title: context.title, cwd } : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -897,8 +911,8 @@ export function liveSessionTitle(email: string, sessionId: string): { title: str
|
|||||||
* the ancestors behind would resurrect them as separate rows the moment their child was gone, which
|
* the ancestors behind would resurrect them as separate rows the moment their child was gone, which
|
||||||
* reads as the delete having half worked.
|
* reads as the delete having half worked.
|
||||||
*/
|
*/
|
||||||
function chainFileIds(email: string, cwd: string, sessionId: string): string[] {
|
function chainFileIds(who: ChatIdentity, cwd: string, sessionId: string): string[] {
|
||||||
const group = scanGroup(email, cwd);
|
const group = scanGroup(who, cwd);
|
||||||
const head = group.find((session) => session.id === sessionId);
|
const head = group.find((session) => session.id === sessionId);
|
||||||
if (!head) return [sessionId];
|
if (!head) return [sessionId];
|
||||||
return chainOf(head, new Map(group.map((session) => [session.id, session]))).map((part) => part.id);
|
return chainOf(head, new Map(group.map((session) => [session.id, session]))).map((part) => part.id);
|
||||||
|
|||||||
@@ -83,16 +83,25 @@ type WSData = {
|
|||||||
|
|
||||||
const IDLE_TIMEOUT_MS = 60 * 60 * 1000; // 1 hour
|
const IDLE_TIMEOUT_MS = 60 * 60 * 1000; // 1 hour
|
||||||
|
|
||||||
const resolveCwd = (email: string, cwd?: string) => {
|
/**
|
||||||
const root = getOwnerHomeDir(email);
|
* Where a turn runs, relative to the caller's own home.
|
||||||
if (!cwd || cwd === '~') return root;
|
*
|
||||||
if (cwd.startsWith('~/')) return join(root, cwd.slice(2));
|
* `root` used to be `getOwnerHomeDir(email)`, which ignores its argument whenever HOME_DIR is set — so every
|
||||||
// The server owner is the only account — absolute paths are theirs to use.
|
* `~` expanded to the OWNER'S home regardless of who asked, and the comment here said "the server owner is
|
||||||
|
* the only account" as though that were a property rather than an assumption.
|
||||||
|
*
|
||||||
|
* An absolute path is still passed through unchanged. That is not a hole: a member's turn runs as their Linux
|
||||||
|
* account, so the kernel decides what it can open, and containment is `resolveUserPath`'s job in the file
|
||||||
|
* browser rather than a string check here. But it is worth knowing it is the kernel doing the work.
|
||||||
|
*/
|
||||||
|
const resolveCwd = (home: string, cwd?: string) => {
|
||||||
|
if (!cwd || cwd === '~') return home;
|
||||||
|
if (cwd.startsWith('~/')) return join(home, cwd.slice(2));
|
||||||
if (cwd.startsWith('/')) return cwd;
|
if (cwd.startsWith('/')) return cwd;
|
||||||
return join(root, cwd);
|
return join(home, cwd);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const resolveBaseCwd = (email: string, cwd?: string) => resolveCwd(email, cwd);
|
export const resolveBaseCwd = (home: string, cwd?: string) => resolveCwd(home, cwd);
|
||||||
|
|
||||||
// The email chat runs from the selected account's storage dir:
|
// The email chat runs from the selected account's storage dir:
|
||||||
// DATA_PATH/<owner>/email_accounts/<accountEmail>
|
// DATA_PATH/<owner>/email_accounts/<accountEmail>
|
||||||
@@ -119,10 +128,11 @@ async function resolveChatCwd(
|
|||||||
msg: { context?: string; contextId?: string; cwd?: string },
|
msg: { context?: string; contextId?: string; cwd?: string },
|
||||||
email: string,
|
email: string,
|
||||||
userId: number,
|
userId: number,
|
||||||
|
home: string,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
if (msg.context === 'email') return resolveEmailCwd(userId, email, msg.contextId);
|
if (msg.context === 'email') return resolveEmailCwd(userId, email, msg.contextId);
|
||||||
if (msg.context === 'chat') return msg.cwd?.trim() ? resolveCwd(email, msg.cwd) : ensureGeneralChatSessionsCwd(email);
|
if (msg.context === 'chat') return msg.cwd?.trim() ? resolveCwd(home, msg.cwd) : ensureGeneralChatSessionsCwd(email);
|
||||||
return resolveCwd(email, msg.cwd);
|
return resolveCwd(home, msg.cwd);
|
||||||
}
|
}
|
||||||
|
|
||||||
const wsToSessionMap = new WeakMap<any, string>();
|
const wsToSessionMap = new WeakMap<any, string>();
|
||||||
@@ -342,7 +352,17 @@ async function handleClaudeCodeChat(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { email, username, userId } = ws.data;
|
const { email, username, userId } = ws.data;
|
||||||
|
|
||||||
const cwd = await resolveChatCwd(msg, email, userId);
|
// Identity first: it decides both whose home `~` expands against and whose account the turn runs as, and
|
||||||
|
// those must be the same answer. Resolving the cwd first would expand `~` before knowing whose it was.
|
||||||
|
const identity = await resolveTurnIdentity(userId);
|
||||||
|
if (identity.kind === 'refuse') {
|
||||||
|
sendToClient(ws, { type: 'error', message: identity.reason });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const home = identity.kind === 'member' ? identity.run.home : getOwnerHomeDir(email);
|
||||||
|
|
||||||
|
const cwd = await resolveChatCwd(msg, email, userId, home);
|
||||||
|
|
||||||
const groupSlug = msg.groupSlug || null;
|
const groupSlug = msg.groupSlug || null;
|
||||||
|
|
||||||
@@ -380,16 +400,6 @@ async function handleClaudeCodeChat(
|
|||||||
const onMessage = createMessageHandler(sessionId, model);
|
const onMessage = createMessageHandler(sessionId, model);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Whose account this turn runs as, resolved from the authenticated socket and never from the client
|
|
||||||
// message — the same rule `server.tsx` applies to the pty sidecar. A `refuse` ends the turn here rather
|
|
||||||
// than spawning anything, because the alternative to knowing is not "assume the owner".
|
|
||||||
const identity = await resolveTurnIdentity(userId);
|
|
||||||
if (identity.kind === 'refuse') {
|
|
||||||
sendToClient(ws, { type: 'error', message: identity.reason });
|
|
||||||
session.isGenerating = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!session._claudeKill) {
|
if (!session._claudeKill) {
|
||||||
// First turn of this session: open the persistent session + a SESSION-scoped event subscription
|
// First turn of this session: open the persistent session + a SESSION-scoped event subscription
|
||||||
// (survives turn-end so background task:notifications keep flowing). `kill` tears both down for an
|
// (survives turn-end so background task:notifications keep flowing). `kill` tears both down for an
|
||||||
@@ -454,7 +464,9 @@ async function handleOpenCodeChat(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { email, username, userId } = ws.data;
|
const { email, username, userId } = ws.data;
|
||||||
|
|
||||||
const cwd = await resolveChatCwd(msg, email, userId);
|
// The owner's home: opencode receives no identity at all (`TODO.md` → Multi-user), so this path is
|
||||||
|
// owner-only and resolving anything else here would imply an isolation it does not have.
|
||||||
|
const cwd = await resolveChatCwd(msg, email, userId, getOwnerHomeDir(email));
|
||||||
|
|
||||||
// Names this session in the Live panel until OpenCode gets round to titling it. First turn only.
|
// Names this session in the Live panel until OpenCode gets round to titling it. First turn only.
|
||||||
rememberOpenCodePrompt(sessionId, msg.displayText || msg.prompt);
|
rememberOpenCodePrompt(sessionId, msg.displayText || msg.prompt);
|
||||||
|
|||||||
Reference in New Issue
Block a user