项目文件夹

文件
wehub-resource-sync 98e40dac97
CLI Smoke Test / smoke-test-linux (20) (push) Has been cancelled
CLI Smoke Test / smoke-test-linux (24) (push) Has been cancelled
CLI Smoke Test / smoke-test-windows (20) (push) Has been cancelled
CLI Smoke Test / smoke-test-windows (24) (push) Has been cancelled
Expo App TypeScript typecheck / typecheck (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:40:49 +08:00

1073 行
34 KiB
TypeScript

/**
* Session operations for remote procedure calls
* Provides strictly typed functions for all session-related RPC operations
*/
import { apiSocket } from './apiSocket';
import { sync } from './sync';
import { storage } from './storage';
import type { MachineMetadata, SessionAgentModesPatch } from './storageTypes';
import { markAgentModePushPending, clearAgentModePushPending, type AgentModeField } from './agentModesPending';
export type { SessionAgentModesPatch };
// Strict type definitions for all operations
// Permission operation types
interface SessionPermissionRequest {
id: string;
approved: boolean;
reason?: string;
mode?: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan';
allowTools?: string[];
updatedInput?: Record<string, unknown>;
decision?: 'approved' | 'approved_for_session' | 'denied' | 'abort';
}
// Mode change operation types
interface SessionModeChangeRequest {
to: 'remote' | 'local';
}
interface SessionGoalActionRequest {
action: 'clear' | 'stop' | 'edit';
objective?: string;
}
// Bash operation types
interface SessionBashRequest {
command: string;
cwd?: string;
timeout?: number;
}
interface SessionBashResponse {
success: boolean;
stdout: string;
stderr: string;
exitCode: number;
error?: string;
}
// Read file operation types
interface SessionReadFileRequest {
path: string;
}
interface SessionReadFileResponse {
success: boolean;
content?: string; // base64 encoded
error?: string;
}
// Write file operation types
interface SessionWriteFileRequest {
path: string;
content: string; // base64 encoded
expectedHash?: string | null;
}
interface SessionWriteFileResponse {
success: boolean;
hash?: string;
error?: string;
}
// List directory operation types
interface SessionListDirectoryRequest {
path: string;
}
interface DirectoryEntry {
name: string;
type: 'file' | 'directory' | 'other';
size?: number;
modified?: number;
}
interface SessionListDirectoryResponse {
success: boolean;
entries?: DirectoryEntry[];
error?: string;
}
// Directory tree operation types
interface SessionGetDirectoryTreeRequest {
path: string;
maxDepth: number;
}
interface TreeNode {
name: string;
path: string;
type: 'file' | 'directory';
size?: number;
modified?: number;
children?: TreeNode[];
}
interface SessionGetDirectoryTreeResponse {
success: boolean;
tree?: TreeNode;
error?: string;
}
// Ripgrep operation types
interface SessionRipgrepRequest {
args: string[];
cwd?: string;
}
interface SessionRipgrepResponse {
success: boolean;
exitCode?: number;
stdout?: string;
stderr?: string;
error?: string;
}
// Kill session operation types
interface SessionKillRequest {
// No parameters needed
}
interface SessionKillResponse {
success: boolean;
message: string;
}
// Response types for spawn session
export type SpawnSessionResult =
| { type: 'success'; sessionId: string }
| { type: 'requestToApproveDirectoryCreation'; directory: string }
| { type: 'error'; errorMessage: string };
// Options for spawning a session
export interface SpawnSessionOptions {
machineId: string;
directory: string;
approvedNewDirectoryCreation?: boolean;
token?: string;
agent?: 'codex' | 'claude' | 'gemini' | 'openclaw' | 'agy';
permissionMode?: string;
modelMode?: string;
effortLevel?: string;
/**
* If set, the daemon spawns the agent with `--resume <id>` so the new
* Happy session attaches to a pre-existing on-disk Claude conversation
* file. Used by the session fork / duplicate flow.
*/
resumeClaudeSessionId?: string;
/**
* If set, the daemon spawns Codex with `--resume <id>` so the new Happy
* session attaches to an app-server thread created by fork / duplicate.
*/
resumeCodexThreadId?: string;
/** Happy session id this fork was branched from (lineage). */
parentSessionId?: string;
/** Happy message id used as the rewind point (only set for "duplicate"). */
forkedFromMessageId?: string;
}
// Options for forking a Claude session on a machine
export interface ClaudeForkSessionOptions {
machineId: string;
/** Working directory of the source session — used to derive the Claude project dir. */
directory: string;
/** Source Claude session UUID (Session.metadata.claudeSessionId on the parent). */
claudeSessionId: string;
}
export type ClaudeForkSessionResult =
| { type: 'success'; newClaudeSessionId: string }
| { type: 'error'; errorMessage: string };
export interface ClaudeRewindPoint {
uuid: string;
text: string;
timestamp: number;
}
export type ClaudeListRewindPointsResult =
| { type: 'success'; points: ClaudeRewindPoint[] }
| { type: 'error'; errorMessage: string };
export interface CodexForkThreadOptions {
machineId: string;
/** Working directory of the source session, passed to Codex thread/fork. */
directory: string;
/** Source Codex app-server thread id (Session.metadata.codexThreadId). */
codexThreadId: string;
}
export type CodexForkThreadResult =
| { type: 'success'; newCodexThreadId: string }
| { type: 'error'; errorMessage: string };
export interface CodexRewindPoint {
itemId: string;
text: string;
timestamp: number;
}
export type CodexListRewindPointsResult =
| { type: 'success'; points: CodexRewindPoint[] }
| { type: 'error'; errorMessage: string };
export interface ResumeSessionOptions {
machineId: string;
sessionId: string;
}
// Exported session operation functions
/**
* Spawn a new remote session on a specific machine
*/
export async function machineSpawnNewSession(options: SpawnSessionOptions): Promise<SpawnSessionResult> {
const { machineId, directory, approvedNewDirectoryCreation = false, token, agent, permissionMode, modelMode, effortLevel, resumeClaudeSessionId, resumeCodexThreadId, parentSessionId, forkedFromMessageId } = options;
try {
const result = await apiSocket.machineRPC<SpawnSessionResult, {
type: 'spawn-in-directory'
directory: string
approvedNewDirectoryCreation?: boolean,
token?: string,
agent?: 'codex' | 'claude' | 'gemini' | 'openclaw' | 'agy',
permissionMode?: string,
modelMode?: string,
effortLevel?: string,
resumeClaudeSessionId?: string,
resumeCodexThreadId?: string,
parentSessionId?: string,
forkedFromMessageId?: string,
}>(
machineId,
'spawn-happy-session',
{ type: 'spawn-in-directory', directory, approvedNewDirectoryCreation, token, agent, permissionMode, modelMode, effortLevel, resumeClaudeSessionId, resumeCodexThreadId, parentSessionId, forkedFromMessageId }
);
return result;
} catch (error) {
// Handle RPC errors
return {
type: 'error',
errorMessage: error instanceof Error ? error.message : 'Failed to spawn session'
};
}
}
/**
* Copy the source session's Claude JSONL on the daemon machine and return
* the new Claude session UUID. Caller then spawns a fresh Happy session
* with `resumeClaudeSessionId` set to that UUID to attach a new Happy
* session row to the copied conversation.
*/
export async function claudeForkSession(options: ClaudeForkSessionOptions): Promise<ClaudeForkSessionResult> {
const { machineId, directory, claudeSessionId } = options;
try {
const result = await apiSocket.machineRPC<ClaudeForkSessionResult, {
directory: string;
claudeSessionId: string;
}>(
machineId,
'claude-fork-session',
{ directory, claudeSessionId },
);
return result;
} catch (error) {
return {
type: 'error',
errorMessage: error instanceof Error ? error.message : 'Failed to fork session',
};
}
}
/**
* Read the on-disk Claude JSONL on the daemon machine and return user-text
* messages with their underlying claudeUuid + timestamp. Disk is the
* source of truth for the rewind picker — server-side envelopes miss
* claudeUuid for any user message that travelled via the legacy
* `sentFrom: 'web'` path.
*/
export async function claudeListRewindPoints(
options: ClaudeForkSessionOptions,
): Promise<ClaudeListRewindPointsResult> {
const { machineId, directory, claudeSessionId } = options;
try {
const result = await apiSocket.machineRPC<ClaudeListRewindPointsResult, {
directory: string;
claudeSessionId: string;
}>(
machineId,
'claude-list-rewind-points',
{ directory, claudeSessionId },
);
return result;
} catch (error) {
return {
type: 'error',
errorMessage: error instanceof Error ? error.message : 'Failed to list rewind points',
};
}
}
/**
* Same as claudeForkSession, but truncates the copied JSONL right after the
* line with `cutAfterUuid` (keeping the chosen message as the last entry,
* dropping every line after — including the agent's response). Use this
* for "rewind to message N and try again" flows. Daemon hard-fails if the
* UUID isn't present in the source — never silently produces a
* non-truncated copy.
*/
export async function claudeDuplicateSession(
options: ClaudeForkSessionOptions & { cutAfterUuid: string },
): Promise<ClaudeForkSessionResult> {
const { machineId, directory, claudeSessionId, cutAfterUuid } = options;
try {
const result = await apiSocket.machineRPC<ClaudeForkSessionResult, {
directory: string;
claudeSessionId: string;
cutAfterUuid: string;
}>(
machineId,
'claude-duplicate-session',
{ directory, claudeSessionId, cutAfterUuid },
);
return result;
} catch (error) {
return {
type: 'error',
errorMessage: error instanceof Error ? error.message : 'Failed to duplicate session',
};
}
}
export async function codexForkThread(options: CodexForkThreadOptions): Promise<CodexForkThreadResult> {
const { machineId, directory, codexThreadId } = options;
try {
const result = await apiSocket.machineRPC<CodexForkThreadResult, {
directory: string;
codexThreadId: string;
}>(
machineId,
'codex-fork-thread',
{ directory, codexThreadId },
);
return result;
} catch (error) {
return {
type: 'error',
errorMessage: error instanceof Error ? error.message : 'Failed to fork Codex thread',
};
}
}
export async function codexDuplicateThread(
options: CodexForkThreadOptions & { cutAfterItemId: string },
): Promise<CodexForkThreadResult> {
const { machineId, directory, codexThreadId, cutAfterItemId } = options;
try {
const result = await apiSocket.machineRPC<CodexForkThreadResult, {
directory: string;
codexThreadId: string;
cutAfterItemId: string;
}>(
machineId,
'codex-duplicate-thread',
{ directory, codexThreadId, cutAfterItemId },
);
return result;
} catch (error) {
return {
type: 'error',
errorMessage: error instanceof Error ? error.message : 'Failed to duplicate Codex thread',
};
}
}
export async function codexListRewindPoints(
options: CodexForkThreadOptions,
): Promise<CodexListRewindPointsResult> {
const { machineId, directory, codexThreadId } = options;
try {
const result = await apiSocket.machineRPC<CodexListRewindPointsResult, {
directory: string;
codexThreadId: string;
}>(
machineId,
'codex-list-rewind-points',
{ directory, codexThreadId },
);
return result;
} catch (error) {
return {
type: 'error',
errorMessage: error instanceof Error ? error.message : 'Failed to list Codex rewind points',
};
}
}
export async function machineResumeSession(options: ResumeSessionOptions & { model?: string; permissionMode?: string }): Promise<SpawnSessionResult> {
const { machineId, sessionId, model, permissionMode } = options;
try {
const result = await apiSocket.machineRPC<SpawnSessionResult, { sessionId: string; model?: string; permissionMode?: string }>(
machineId,
'resume-happy-session',
{ sessionId, model, permissionMode },
);
return result;
} catch (error) {
return {
type: 'error',
errorMessage: error instanceof Error ? error.message : 'Failed to resume session',
};
}
}
/**
* Permanently remove a machine from the server. Sessions spawned by the
* machine are preserved; only the Machine row and its AccessKeys are deleted.
*/
export async function machineDelete(machineId: string): Promise<{ success: boolean; message?: string }> {
try {
const response = await apiSocket.request(`/v1/machines/${machineId}`, {
method: 'DELETE'
});
if (response.ok) {
return { success: true };
}
const error = await response.text();
return { success: false, message: error || 'Failed to delete machine' };
} catch (error) {
return {
success: false,
message: error instanceof Error ? error.message : 'Unknown error'
};
}
}
/**
* Stop the daemon on a specific machine
*/
export async function machineStopDaemon(machineId: string): Promise<{ message: string }> {
const result = await apiSocket.machineRPC<{ message: string }, {}>(
machineId,
'stop-daemon',
{}
);
return result;
}
/**
* Execute a bash command on a specific machine
*/
export async function machineBash(
machineId: string,
command: string,
cwd: string
): Promise<{
success: boolean;
stdout: string;
stderr: string;
exitCode: number;
}> {
try {
const result = await apiSocket.machineRPC<{
success: boolean;
stdout: string;
stderr: string;
exitCode: number;
}, {
command: string;
cwd: string;
}>(
machineId,
'bash',
{ command, cwd }
);
return result;
} catch (error) {
return {
success: false,
stdout: '',
stderr: error instanceof Error ? error.message : 'Unknown error',
exitCode: -1
};
}
}
/**
* Update machine metadata with optimistic concurrency control and automatic retry
*/
export async function machineUpdateMetadata(
machineId: string,
metadata: MachineMetadata,
expectedVersion: number,
maxRetries: number = 3
): Promise<{ version: number; metadata: string }> {
let currentVersion = expectedVersion;
let currentMetadata = { ...metadata };
let retryCount = 0;
const machineEncryption = sync.encryption.getMachineEncryption(machineId);
if (!machineEncryption) {
throw new Error(`Machine encryption not found for ${machineId}`);
}
while (retryCount < maxRetries) {
const encryptedMetadata = await machineEncryption.encryptRaw(currentMetadata);
const result = await apiSocket.emitWithAck<{
result: 'success' | 'version-mismatch' | 'error';
version?: number;
metadata?: string;
message?: string;
}>('machine-update-metadata', {
machineId,
metadata: encryptedMetadata,
expectedVersion: currentVersion
});
if (result.result === 'success') {
return {
version: result.version!,
metadata: result.metadata!
};
} else if (result.result === 'version-mismatch') {
// Get the latest version and metadata from the response
currentVersion = result.version!;
const latestMetadata = await machineEncryption.decryptRaw(result.metadata!) as MachineMetadata;
// Merge our changes with the latest metadata
// Preserve the displayName we're trying to set, but use latest values for other fields
currentMetadata = {
...latestMetadata,
displayName: metadata.displayName // Keep our intended displayName change
};
retryCount++;
// If we've exhausted retries, throw error
if (retryCount >= maxRetries) {
throw new Error(`Failed to update after ${maxRetries} retries due to version conflicts`);
}
// Otherwise, loop will retry with updated version and merged metadata
} else {
throw new Error(result.message || 'Failed to update machine metadata');
}
}
throw new Error('Unexpected error in machineUpdateMetadata');
}
/**
* Persist per-session mode picks into synced session metadata with optimistic
* concurrency and automatic retry. On version conflict the latest metadata is
* taken from the server via the schema-free raw decrypt, so fields this app
* version doesn't know about survive the read-modify-write.
*/
async function sessionUpdateAgentModesMetadata(
sessionId: string,
patch: SessionAgentModesPatch,
maxRetries: number = 3
): Promise<void> {
const encryption = sync.encryption.getSessionEncryption(sessionId);
const session = storage.getState().sessions[sessionId];
if (!encryption || !session?.metadata) {
throw new Error(`Session ${sessionId} is not ready for metadata updates`);
}
// Defensive copy: retries drop fields from the patch (see below)
let pendingPatch: SessionAgentModesPatch = { ...patch };
let currentVersion = session.metadataVersion;
let currentMetadata: Record<string, unknown> = { ...session.metadata, ...pendingPatch };
for (let attempt = 0; attempt < maxRetries; attempt++) {
const encrypted = await encryption.encryptRaw(currentMetadata);
const result = await apiSocket.emitWithAck<{
result: 'success' | 'version-mismatch' | 'error';
version?: number;
metadata?: string;
}>('update-metadata', {
sid: sessionId,
metadata: encrypted,
expectedVersion: currentVersion
});
if (result.result === 'success') {
return;
}
if (result.result === 'version-mismatch') {
currentVersion = result.version!;
const latest = await encryption.decryptRaw(result.metadata!);
if (!latest) {
throw new Error('Failed to decrypt latest session metadata');
}
// A newer local action (another pick, an abort clearing modes) may
// have changed the mirror since this push started — that action
// owns the field now, and blindly replaying the original patch
// would resurrect a pick the user already cleared.
const liveSession = storage.getState().sessions[sessionId];
for (const field of Object.keys(pendingPatch) as (keyof SessionAgentModesPatch)[]) {
if ((liveSession?.[field] ?? null) !== (pendingPatch[field] ?? null)) {
delete pendingPatch[field];
}
}
if (Object.keys(pendingPatch).length === 0) {
return;
}
currentMetadata = { ...latest, ...pendingPatch };
continue;
}
throw new Error('Failed to update session metadata');
}
throw new Error(`Failed to update session metadata after ${maxRetries} retries due to version conflicts`);
}
/**
* Apply a per-session model / effort pick: updates local state immediately for
* a snappy UI and pushes the pick into synced session metadata so other
* devices receive it through the update-session broadcast. Never throws — a
* failed push leaves the optimistic local value, and the next inbound
* metadata update reconciles the UI.
*/
export function sessionSetAgentModes(sessionId: string, patch: SessionAgentModesPatch): void {
const state = storage.getState();
const session = state.sessions[sessionId];
// Only touch fields that actually change — clearing modes on a session
// with no picks (e.g. every abort) must not cost a metadata round-trip.
// A pick counts as changed when it differs from the local mirror OR from
// synced metadata: a local-only value (e.g. the EnterPlanMode auto-switch
// writes the mirror without metadata) must still be pushed when the user
// picks it explicitly, or other devices never see it.
const isChanged = (value: string | null, field: keyof SessionAgentModesPatch): boolean => {
const mirror = session?.[field] ?? null;
const metaRaw = session?.metadata?.[field];
const meta = metaRaw === undefined ? null : (metaRaw ?? null);
return value !== mirror || value !== meta;
};
const changed: SessionAgentModesPatch = {};
if (patch.permissionMode !== undefined && isChanged(patch.permissionMode, 'permissionMode')) {
changed.permissionMode = patch.permissionMode;
}
if (patch.modelMode !== undefined && isChanged(patch.modelMode, 'modelMode')) {
changed.modelMode = patch.modelMode;
}
if (patch.effortLevel !== undefined && isChanged(patch.effortLevel, 'effortLevel')) {
changed.effortLevel = patch.effortLevel;
}
if (Object.keys(changed).length === 0) {
return;
}
state.updateSessionAgentModes(sessionId, changed);
// While the push is in flight, inbound updates still carry the OLD
// metadata; mark the fields pending so applySessions keeps the fresher
// local mirror instead of bouncing the pick back.
const changedFields = Object.keys(changed) as AgentModeField[];
markAgentModePushPending(sessionId, changedFields);
sessionUpdateAgentModesMetadata(sessionId, changed)
.catch((error) => {
console.error(`Failed to sync agent modes for session ${sessionId}`, error);
})
.finally(() => {
clearAgentModePushPending(sessionId, changedFields);
});
}
/**
* Abort the current session operation
*/
export async function sessionAbort(sessionId: string): Promise<void> {
await apiSocket.sessionRPC(sessionId, 'abort', {
reason: `The user doesn't want to proceed with this tool use. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). STOP what you are doing and wait for the user to tell you how to proceed.`
});
}
/**
* Allow a permission request
*/
export async function sessionAllow(sessionId: string, id: string, mode?: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan', allowedTools?: string[], decision?: 'approved' | 'approved_for_session', updatedInput?: Record<string, unknown>): Promise<void> {
const request: SessionPermissionRequest = { id, approved: true, mode, allowTools: allowedTools, decision, updatedInput };
await apiSocket.sessionRPC(sessionId, 'permission', request);
}
/**
* Deny a permission request
*/
export async function sessionDeny(sessionId: string, id: string, mode?: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan', allowedTools?: string[], decision?: 'denied' | 'abort'): Promise<void> {
const request: SessionPermissionRequest = { id, approved: false, mode, allowTools: allowedTools, decision };
await apiSocket.sessionRPC(sessionId, 'permission', request);
}
/**
* Request mode change for a session
*/
export async function sessionSwitch(sessionId: string, to: 'remote' | 'local'): Promise<boolean> {
const request: SessionModeChangeRequest = { to };
const response = await apiSocket.sessionRPC<boolean, SessionModeChangeRequest>(
sessionId,
'switch',
request,
);
return response;
}
/**
* Request an agent-owned goal action.
*/
export async function sessionGoalAction(
sessionId: string,
action: SessionGoalActionRequest['action'],
objective?: string,
): Promise<void> {
await apiSocket.sessionRPC(sessionId, 'goal-action', {
action,
...(objective !== undefined ? { objective } : {}),
} satisfies SessionGoalActionRequest);
}
/**
* Execute a bash command in the session
*/
export async function sessionBash(sessionId: string, request: SessionBashRequest): Promise<SessionBashResponse> {
try {
const response = await apiSocket.sessionRPC<SessionBashResponse, SessionBashRequest>(
sessionId,
'bash',
request
);
return response;
} catch (error) {
return {
success: false,
stdout: '',
stderr: error instanceof Error ? error.message : 'Unknown error',
exitCode: -1,
error: error instanceof Error ? error.message : 'Unknown error'
};
}
}
/**
* Read a file from the session
*/
export async function sessionReadFile(sessionId: string, path: string): Promise<SessionReadFileResponse> {
try {
const request: SessionReadFileRequest = { path };
const response = await apiSocket.sessionRPC<SessionReadFileResponse, SessionReadFileRequest>(
sessionId,
'readFile',
request
);
return response;
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
};
}
}
/**
* Write a file to the session
*/
export async function sessionWriteFile(
sessionId: string,
path: string,
content: string,
expectedHash?: string | null
): Promise<SessionWriteFileResponse> {
try {
const request: SessionWriteFileRequest = { path, content, expectedHash };
const response = await apiSocket.sessionRPC<SessionWriteFileResponse, SessionWriteFileRequest>(
sessionId,
'writeFile',
request
);
return response;
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
};
}
}
/**
* List directory contents in the session
*/
export async function sessionListDirectory(sessionId: string, path: string): Promise<SessionListDirectoryResponse> {
try {
const request: SessionListDirectoryRequest = { path };
const response = await apiSocket.sessionRPC<SessionListDirectoryResponse, SessionListDirectoryRequest>(
sessionId,
'listDirectory',
request
);
return response;
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
};
}
}
/**
* Get directory tree from the session
*/
export async function sessionGetDirectoryTree(
sessionId: string,
path: string,
maxDepth: number
): Promise<SessionGetDirectoryTreeResponse> {
try {
const request: SessionGetDirectoryTreeRequest = { path, maxDepth };
const response = await apiSocket.sessionRPC<SessionGetDirectoryTreeResponse, SessionGetDirectoryTreeRequest>(
sessionId,
'getDirectoryTree',
request
);
return response;
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
};
}
}
/**
* Run ripgrep in the session
*/
export async function sessionRipgrep(
sessionId: string,
args: string[],
cwd?: string
): Promise<SessionRipgrepResponse> {
try {
const request: SessionRipgrepRequest = { args, cwd };
const response = await apiSocket.sessionRPC<SessionRipgrepResponse, SessionRipgrepRequest>(
sessionId,
'ripgrep',
request
);
return response;
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
};
}
}
/**
* Kill the session process immediately
*/
export async function sessionKill(sessionId: string): Promise<SessionKillResponse> {
try {
const response = await apiSocket.sessionRPC<SessionKillResponse, {}>(
sessionId,
'killSession',
{}
);
return response;
} catch (error) {
return {
success: false,
message: error instanceof Error ? error.message : 'Unknown error'
};
}
}
/**
* Archive a session by deactivating it on the server.
* Use this when the CLI process is already dead and sessionKill can't reach it.
*/
export async function sessionArchive(sessionId: string): Promise<{ success: boolean; message?: string }> {
try {
const response = await apiSocket.request(`/v1/sessions/${sessionId}/archive`, {
method: 'POST'
});
if (!response.ok) {
return { success: false, message: `Server error: ${response.status}` };
}
return { success: true };
} catch (error) {
return { success: false, message: error instanceof Error ? error.message : 'Unknown error' };
}
}
/**
* Permanently delete a session from the server
* This will remove the session and all its associated data (messages, usage reports, access keys)
* The session should be inactive/archived before deletion
*/
export async function sessionDelete(sessionId: string): Promise<{ success: boolean; message?: string }> {
try {
const response = await apiSocket.request(`/v1/sessions/${sessionId}`, {
method: 'DELETE'
});
if (response.ok) {
const result = await response.json();
return { success: true };
} else {
const error = await response.text();
return {
success: false,
message: error || 'Failed to delete session'
};
}
} catch (error) {
return {
success: false,
message: error instanceof Error ? error.message : 'Unknown error'
};
}
}
type ClaudeForkSource = {
kind?: 'claude';
sessionId: string;
machineId: string;
directory: string;
claudeSessionId: string;
};
type CodexForkSource = {
kind: 'codex';
sessionId: string;
machineId: string;
directory: string;
codexThreadId: string;
};
// Forking source description used by forkAndSpawn.
export type ForkSource = ClaudeForkSource | CodexForkSource;
type ForkOptions = {
cutAfterUuid?: string;
cutAfterItemId?: string;
forkedFromMessageId?: string;
};
/**
* Two-step orchestrator for the session fork / duplicate flow:
* 1. Ask the daemon to copy (and optionally truncate) the source Claude
* JSONL — returns a fresh Claude session UUID.
* 2. Spawn a new Happy session on the same machine with
* `resumeClaudeSessionId` set to that UUID so `claude --resume` picks
* up the copied conversation.
*
* Lineage (parentSessionId, forkedFromMessageId) rides through the spawn
* RPC into env vars, then into the new Happy session's metadata at start
* — so the parent link survives without any server-side schema change.
*/
export async function forkAndSpawn(
source: ForkSource,
opts: ForkOptions = {},
): Promise<SpawnSessionResult> {
if (source.kind === 'codex') {
const forkResult = opts.cutAfterItemId
? await codexDuplicateThread({
machineId: source.machineId,
directory: source.directory,
codexThreadId: source.codexThreadId,
cutAfterItemId: opts.cutAfterItemId,
})
: await codexForkThread({
machineId: source.machineId,
directory: source.directory,
codexThreadId: source.codexThreadId,
});
if (forkResult.type !== 'success') {
return { type: 'error', errorMessage: forkResult.errorMessage };
}
const spawnResult = await machineSpawnNewSession({
machineId: source.machineId,
directory: source.directory,
agent: 'codex',
approvedNewDirectoryCreation: false,
resumeCodexThreadId: forkResult.newCodexThreadId,
parentSessionId: source.sessionId,
forkedFromMessageId: opts.forkedFromMessageId,
});
if (spawnResult.type === 'success') {
try {
await sync.refreshSessions();
} catch {
// Refresh is best-effort; broadcast sync will still hydrate.
}
}
return spawnResult;
}
const forkResult = opts.cutAfterUuid
? await claudeDuplicateSession({
machineId: source.machineId,
directory: source.directory,
claudeSessionId: source.claudeSessionId,
cutAfterUuid: opts.cutAfterUuid,
})
: await claudeForkSession({
machineId: source.machineId,
directory: source.directory,
claudeSessionId: source.claudeSessionId,
});
if (forkResult.type !== 'success') {
return { type: 'error', errorMessage: forkResult.errorMessage };
}
const spawnResult = await machineSpawnNewSession({
machineId: source.machineId,
directory: source.directory,
agent: 'claude',
approvedNewDirectoryCreation: false,
resumeClaudeSessionId: forkResult.newClaudeSessionId,
parentSessionId: source.sessionId,
forkedFromMessageId: opts.forkedFromMessageId,
});
// Pull the newly-created session row into local sync state before we
// hand control back to the caller — otherwise router.replace into the
// new session id races the broadcast and the app screams
// "Session X not found" until the next sync tick lands.
if (spawnResult.type === 'success') {
try {
await sync.refreshSessions();
} catch {
// Refresh is best-effort; the broadcast will still hydrate the
// session shortly even if this fetch flaked.
}
}
return spawnResult;
}
// Export types for external use
export type {
SessionBashRequest,
SessionBashResponse,
SessionReadFileResponse,
SessionWriteFileResponse,
SessionListDirectoryResponse,
DirectoryEntry,
SessionGetDirectoryTreeResponse,
TreeNode,
SessionRipgrepResponse,
SessionKillResponse
};