feat(mac): sessions submenus

This commit is contained in:
Peter Steinberger
2025-12-22 19:29:24 +01:00
parent 19b847b23b
commit a0dd504991
9 changed files with 1034 additions and 74 deletions

View File

@@ -73,8 +73,14 @@ import {
SendParamsSchema,
type SessionsListParams,
SessionsListParamsSchema,
type SessionsCompactParams,
SessionsCompactParamsSchema,
type SessionsDeleteParams,
SessionsDeleteParamsSchema,
type SessionsPatchParams,
SessionsPatchParamsSchema,
type SessionsResetParams,
SessionsResetParamsSchema,
type ShutdownEvent,
ShutdownEventSchema,
type SkillsInstallParams,
@@ -143,6 +149,15 @@ export const validateSessionsListParams = ajv.compile<SessionsListParams>(
export const validateSessionsPatchParams = ajv.compile<SessionsPatchParams>(
SessionsPatchParamsSchema,
);
export const validateSessionsResetParams = ajv.compile<SessionsResetParams>(
SessionsResetParamsSchema,
);
export const validateSessionsDeleteParams = ajv.compile<SessionsDeleteParams>(
SessionsDeleteParamsSchema,
);
export const validateSessionsCompactParams = ajv.compile<SessionsCompactParams>(
SessionsCompactParamsSchema,
);
export const validateConfigGetParams = ajv.compile<ConfigGetParams>(
ConfigGetParamsSchema,
);
@@ -226,6 +241,9 @@ export {
NodeInvokeParamsSchema,
SessionsListParamsSchema,
SessionsPatchParamsSchema,
SessionsResetParamsSchema,
SessionsDeleteParamsSchema,
SessionsCompactParamsSchema,
ConfigGetParamsSchema,
ConfigSetParamsSchema,
ProvidersStatusParamsSchema,
@@ -286,6 +304,9 @@ export type {
NodeInvokeParams,
SessionsListParams,
SessionsPatchParams,
SessionsResetParams,
SessionsDeleteParams,
SessionsCompactParams,
CronJob,
CronListParams,
CronStatusParams,

View File

@@ -291,6 +291,30 @@ export const SessionsPatchParamsSchema = Type.Object(
key: NonEmptyString,
thinkingLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
verboseLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
syncing: Type.Optional(
Type.Union([Type.Boolean(), NonEmptyString, Type.Null()]),
),
},
{ additionalProperties: false },
);
export const SessionsResetParamsSchema = Type.Object(
{ key: NonEmptyString },
{ additionalProperties: false },
);
export const SessionsDeleteParamsSchema = Type.Object(
{
key: NonEmptyString,
deleteTranscript: Type.Optional(Type.Boolean()),
},
{ additionalProperties: false },
);
export const SessionsCompactParamsSchema = Type.Object(
{
key: NonEmptyString,
maxLines: Type.Optional(Type.Integer({ minimum: 1 })),
},
{ additionalProperties: false },
);
@@ -629,6 +653,9 @@ export const ProtocolSchemas: Record<string, TSchema> = {
NodeInvokeParams: NodeInvokeParamsSchema,
SessionsListParams: SessionsListParamsSchema,
SessionsPatchParams: SessionsPatchParamsSchema,
SessionsResetParams: SessionsResetParamsSchema,
SessionsDeleteParams: SessionsDeleteParamsSchema,
SessionsCompactParams: SessionsCompactParamsSchema,
ConfigGetParams: ConfigGetParamsSchema,
ConfigSetParams: ConfigSetParamsSchema,
ProvidersStatusParams: ProvidersStatusParamsSchema,
@@ -681,6 +708,9 @@ export type NodeDescribeParams = Static<typeof NodeDescribeParamsSchema>;
export type NodeInvokeParams = Static<typeof NodeInvokeParamsSchema>;
export type SessionsListParams = Static<typeof SessionsListParamsSchema>;
export type SessionsPatchParams = Static<typeof SessionsPatchParamsSchema>;
export type SessionsResetParams = Static<typeof SessionsResetParamsSchema>;
export type SessionsDeleteParams = Static<typeof SessionsDeleteParamsSchema>;
export type SessionsCompactParams = Static<typeof SessionsCompactParamsSchema>;
export type ConfigGetParams = Static<typeof ConfigGetParamsSchema>;
export type ConfigSetParams = Static<typeof ConfigSetParamsSchema>;
export type ProvidersStatusParams = Static<typeof ProvidersStatusParamsSchema>;

View File

@@ -3388,6 +3388,19 @@ describe("gateway server", () => {
const now = Date.now();
testSessionStorePath = storePath;
await fs.writeFile(
path.join(dir, "sess-main.jsonl"),
Array.from({ length: 10 })
.map((_, idx) => JSON.stringify({ role: "user", content: `line ${idx}` }))
.join("\n") + "\n",
"utf-8",
);
await fs.writeFile(
path.join(dir, "sess-group.jsonl"),
JSON.stringify({ role: "user", content: "group line 0" }) + "\n",
"utf-8",
);
await fs.writeFile(
storePath,
JSON.stringify(
@@ -3421,7 +3434,15 @@ describe("gateway server", () => {
expect(
(hello as unknown as { features?: { methods?: string[] } }).features
?.methods,
).toEqual(expect.arrayContaining(["sessions.list", "sessions.patch"]));
).toEqual(
expect.arrayContaining([
"sessions.list",
"sessions.patch",
"sessions.reset",
"sessions.delete",
"sessions.compact",
]),
);
const list1 = await rpcReq<{
path: string;
@@ -3483,6 +3504,63 @@ describe("gateway server", () => {
expect(main2?.thinkingLevel).toBe("medium");
expect(main2?.verboseLevel).toBeUndefined();
const syncPatched = await rpcReq<{ ok: true; key: string }>(
ws,
"sessions.patch",
{ key: "main", syncing: true },
);
expect(syncPatched.ok).toBe(true);
const list3 = await rpcReq<{
sessions: Array<{ key: string; syncing?: boolean | string }>;
}>(ws, "sessions.list", {});
expect(list3.ok).toBe(true);
const main3 = list3.payload?.sessions.find((s) => s.key === "main");
expect(main3?.syncing).toBe(true);
const compacted = await rpcReq<{ ok: true; compacted: boolean }>(
ws,
"sessions.compact",
{ key: "main", maxLines: 3 },
);
expect(compacted.ok).toBe(true);
expect(compacted.payload?.compacted).toBe(true);
const compactedLines = (
await fs.readFile(path.join(dir, "sess-main.jsonl"), "utf-8")
)
.split(/\r?\n/)
.filter((l) => l.trim().length > 0);
expect(compactedLines).toHaveLength(3);
const filesAfterCompact = await fs.readdir(dir);
expect(filesAfterCompact.some((f) => f.startsWith("sess-main.jsonl.bak.")))
.toBe(true);
const deleted = await rpcReq<{ ok: true; deleted: boolean }>(
ws,
"sessions.delete",
{ key: "group:dev" },
);
expect(deleted.ok).toBe(true);
expect(deleted.payload?.deleted).toBe(true);
const listAfterDelete = await rpcReq<{
sessions: Array<{ key: string }>;
}>(ws, "sessions.list", {});
expect(listAfterDelete.ok).toBe(true);
expect(listAfterDelete.payload?.sessions.some((s) => s.key === "group:dev"))
.toBe(false);
const filesAfterDelete = await fs.readdir(dir);
expect(filesAfterDelete.some((f) => f.startsWith("sess-group.jsonl.deleted.")))
.toBe(true);
const reset = await rpcReq<{ ok: true; key: string; entry: { sessionId: string } }>(
ws,
"sessions.reset",
{ key: "main" },
);
expect(reset.ok).toBe(true);
expect(reset.payload?.key).toBe("main");
expect(reset.payload?.entry.sessionId).not.toBe("sess-main");
const badThinking = await rpcReq(ws, "sessions.patch", {
key: "main",
thinkingLevel: "banana",

View File

@@ -271,7 +271,10 @@ import {
PROTOCOL_VERSION,
type RequestFrame,
type SessionsListParams,
type SessionsCompactParams,
type SessionsDeleteParams,
type SessionsPatchParams,
type SessionsResetParams,
type Snapshot,
validateAgentParams,
validateChatAbortParams,
@@ -300,7 +303,10 @@ import {
validateRequestFrame,
validateSendParams,
validateSessionsListParams,
validateSessionsCompactParams,
validateSessionsDeleteParams,
validateSessionsPatchParams,
validateSessionsResetParams,
validateSkillsInstallParams,
validateSkillsStatusParams,
validateSkillsUpdateParams,
@@ -389,6 +395,9 @@ const METHODS = [
"voicewake.set",
"sessions.list",
"sessions.patch",
"sessions.reset",
"sessions.delete",
"sessions.compact",
"last-heartbeat",
"set-heartbeats",
"wake",
@@ -697,27 +706,7 @@ function readSessionMessages(
sessionId: string,
storePath: string | undefined,
): unknown[] {
const candidates: string[] = [];
if (storePath) {
const dir = path.dirname(storePath);
candidates.push(path.join(dir, `${sessionId}.jsonl`));
}
candidates.push(
path.join(os.homedir(), ".clawdis", "sessions", `${sessionId}.jsonl`),
);
candidates.push(
path.join(os.homedir(), ".pi", "agent", "sessions", `${sessionId}.jsonl`),
);
candidates.push(
path.join(
os.homedir(),
".tau",
"agent",
"sessions",
"clawdis",
`${sessionId}.jsonl`,
),
);
const candidates = resolveSessionTranscriptCandidates(sessionId, storePath);
const filePath = candidates.find((p) => fs.existsSync(p));
if (!filePath) return [];
@@ -741,6 +730,41 @@ function readSessionMessages(
return messages;
}
function resolveSessionTranscriptCandidates(
sessionId: string,
storePath: string | undefined,
): string[] {
const candidates: string[] = [];
if (storePath) {
const dir = path.dirname(storePath);
candidates.push(path.join(dir, `${sessionId}.jsonl`));
}
candidates.push(
path.join(os.homedir(), ".clawdis", "sessions", `${sessionId}.jsonl`),
);
candidates.push(
path.join(os.homedir(), ".pi", "agent", "sessions", `${sessionId}.jsonl`),
);
candidates.push(
path.join(
os.homedir(),
".tau",
"agent",
"sessions",
"clawdis",
`${sessionId}.jsonl`,
),
);
return candidates;
}
function archiveFileOnDisk(filePath: string, reason: string): string {
const ts = new Date().toISOString().replaceAll(":", "-");
const archived = `${filePath}.${reason}.${ts}`;
fs.renameSync(filePath, archived);
return archived;
}
function jsonUtf8Bytes(value: unknown): number {
try {
return Buffer.byteLength(JSON.stringify(value), "utf8");
@@ -1991,6 +2015,206 @@ export async function startGatewayServer(
};
return { ok: true, payloadJSON: JSON.stringify(payload) };
}
case "sessions.reset": {
const params = parseParams();
if (!validateSessionsResetParams(params)) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: `invalid sessions.reset params: ${formatValidationErrors(validateSessionsResetParams.errors)}`,
},
};
}
const p = params as SessionsResetParams;
const key = String(p.key ?? "").trim();
if (!key) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: "key required",
},
};
}
const { storePath, store, entry } = loadSessionEntry(key);
const now = Date.now();
const next: SessionEntry = {
sessionId: randomUUID(),
updatedAt: now,
systemSent: false,
abortedLastRun: false,
thinkingLevel: entry?.thinkingLevel,
verboseLevel: entry?.verboseLevel,
syncing: entry?.syncing,
model: entry?.model,
contextTokens: entry?.contextTokens,
lastChannel: entry?.lastChannel,
lastTo: entry?.lastTo,
skillsSnapshot: entry?.skillsSnapshot,
};
store[key] = next;
await saveSessionStore(storePath, store);
return {
ok: true,
payloadJSON: JSON.stringify({ ok: true, key, entry: next }),
};
}
case "sessions.delete": {
const params = parseParams();
if (!validateSessionsDeleteParams(params)) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: `invalid sessions.delete params: ${formatValidationErrors(validateSessionsDeleteParams.errors)}`,
},
};
}
const p = params as SessionsDeleteParams;
const key = String(p.key ?? "").trim();
if (!key) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: "key required",
},
};
}
const deleteTranscript =
typeof p.deleteTranscript === "boolean" ? p.deleteTranscript : true;
const { storePath, store, entry } = loadSessionEntry(key);
const sessionId = entry?.sessionId;
const existed = Boolean(store[key]);
if (existed) delete store[key];
await saveSessionStore(storePath, store);
const archived: string[] = [];
if (deleteTranscript && sessionId) {
for (const candidate of resolveSessionTranscriptCandidates(
sessionId,
storePath,
)) {
if (!fs.existsSync(candidate)) continue;
try {
archived.push(archiveFileOnDisk(candidate, "deleted"));
} catch {
// Best-effort; deleting the store entry is the main operation.
}
}
}
return {
ok: true,
payloadJSON: JSON.stringify({
ok: true,
key,
deleted: existed,
archived,
}),
};
}
case "sessions.compact": {
const params = parseParams();
if (!validateSessionsCompactParams(params)) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: `invalid sessions.compact params: ${formatValidationErrors(validateSessionsCompactParams.errors)}`,
},
};
}
const p = params as SessionsCompactParams;
const key = String(p.key ?? "").trim();
if (!key) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: "key required",
},
};
}
const maxLines =
typeof p.maxLines === "number" && Number.isFinite(p.maxLines)
? Math.max(1, Math.floor(p.maxLines))
: 400;
const { storePath, store, entry } = loadSessionEntry(key);
const sessionId = entry?.sessionId;
if (!sessionId) {
return {
ok: true,
payloadJSON: JSON.stringify({
ok: true,
key,
compacted: false,
reason: "no sessionId",
}),
};
}
const filePath = resolveSessionTranscriptCandidates(sessionId, storePath)
.find((candidate) => fs.existsSync(candidate));
if (!filePath) {
return {
ok: true,
payloadJSON: JSON.stringify({
ok: true,
key,
compacted: false,
reason: "no transcript",
}),
};
}
const raw = fs.readFileSync(filePath, "utf-8");
const lines = raw.split(/\r?\n/).filter((l) => l.trim().length > 0);
if (lines.length <= maxLines) {
return {
ok: true,
payloadJSON: JSON.stringify({
ok: true,
key,
compacted: false,
kept: lines.length,
}),
};
}
const archived = archiveFileOnDisk(filePath, "bak");
const keptLines = lines.slice(-maxLines);
fs.writeFileSync(filePath, `${keptLines.join("\n")}\n`, "utf-8");
// Token counts no longer match; clear so status + UI reflect reality after the next turn.
if (store[key]) {
delete store[key].inputTokens;
delete store[key].outputTokens;
delete store[key].totalTokens;
store[key].updatedAt = Date.now();
await saveSessionStore(storePath, store);
}
return {
ok: true,
payloadJSON: JSON.stringify({
ok: true,
key,
compacted: true,
archived,
kept: keptLines.length,
}),
};
}
case "chat.history": {
const params = parseParams();
if (!validateChatHistoryParams(params)) {
@@ -4056,6 +4280,15 @@ export async function startGatewayServer(
}
}
if ("syncing" in p) {
const raw = p.syncing;
if (raw === null) {
delete next.syncing;
} else if (raw !== undefined) {
next.syncing = raw as boolean | string;
}
}
store[key] = next;
await saveSessionStore(storePath, store);
const result: SessionsPatchResult = {
@@ -4067,6 +4300,199 @@ export async function startGatewayServer(
respond(true, result, undefined);
break;
}
case "sessions.reset": {
const params = (req.params ?? {}) as Record<string, unknown>;
if (!validateSessionsResetParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid sessions.reset params: ${formatValidationErrors(validateSessionsResetParams.errors)}`,
),
);
break;
}
const p = params as SessionsResetParams;
const key = String(p.key ?? "").trim();
if (!key) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "key required"),
);
break;
}
const { storePath, store, entry } = loadSessionEntry(key);
const now = Date.now();
const next: SessionEntry = {
sessionId: randomUUID(),
updatedAt: now,
systemSent: false,
abortedLastRun: false,
thinkingLevel: entry?.thinkingLevel,
verboseLevel: entry?.verboseLevel,
syncing: entry?.syncing,
model: entry?.model,
contextTokens: entry?.contextTokens,
lastChannel: entry?.lastChannel,
lastTo: entry?.lastTo,
skillsSnapshot: entry?.skillsSnapshot,
};
store[key] = next;
await saveSessionStore(storePath, store);
respond(true, { ok: true, key, entry: next }, undefined);
break;
}
case "sessions.delete": {
const params = (req.params ?? {}) as Record<string, unknown>;
if (!validateSessionsDeleteParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid sessions.delete params: ${formatValidationErrors(validateSessionsDeleteParams.errors)}`,
),
);
break;
}
const p = params as SessionsDeleteParams;
const key = String(p.key ?? "").trim();
if (!key) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "key required"),
);
break;
}
const deleteTranscript =
typeof p.deleteTranscript === "boolean"
? p.deleteTranscript
: true;
const { storePath, store, entry } = loadSessionEntry(key);
const sessionId = entry?.sessionId;
const existed = Boolean(store[key]);
if (existed) delete store[key];
await saveSessionStore(storePath, store);
const archived: string[] = [];
if (deleteTranscript && sessionId) {
for (const candidate of resolveSessionTranscriptCandidates(
sessionId,
storePath,
)) {
if (!fs.existsSync(candidate)) continue;
try {
archived.push(archiveFileOnDisk(candidate, "deleted"));
} catch {
// Best-effort.
}
}
}
respond(
true,
{ ok: true, key, deleted: existed, archived },
undefined,
);
break;
}
case "sessions.compact": {
const params = (req.params ?? {}) as Record<string, unknown>;
if (!validateSessionsCompactParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid sessions.compact params: ${formatValidationErrors(validateSessionsCompactParams.errors)}`,
),
);
break;
}
const p = params as SessionsCompactParams;
const key = String(p.key ?? "").trim();
if (!key) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "key required"),
);
break;
}
const maxLines =
typeof p.maxLines === "number" && Number.isFinite(p.maxLines)
? Math.max(1, Math.floor(p.maxLines))
: 400;
const { storePath, store, entry } = loadSessionEntry(key);
const sessionId = entry?.sessionId;
if (!sessionId) {
respond(
true,
{ ok: true, key, compacted: false, reason: "no sessionId" },
undefined,
);
break;
}
const filePath = resolveSessionTranscriptCandidates(
sessionId,
storePath,
).find((candidate) => fs.existsSync(candidate));
if (!filePath) {
respond(
true,
{ ok: true, key, compacted: false, reason: "no transcript" },
undefined,
);
break;
}
const raw = fs.readFileSync(filePath, "utf-8");
const lines = raw
.split(/\r?\n/)
.filter((l) => l.trim().length > 0);
if (lines.length <= maxLines) {
respond(
true,
{ ok: true, key, compacted: false, kept: lines.length },
undefined,
);
break;
}
const archived = archiveFileOnDisk(filePath, "bak");
const keptLines = lines.slice(-maxLines);
fs.writeFileSync(filePath, `${keptLines.join("\n")}\n`, "utf-8");
if (store[key]) {
delete store[key].inputTokens;
delete store[key].outputTokens;
delete store[key].totalTokens;
store[key].updatedAt = Date.now();
await saveSessionStore(storePath, store);
}
respond(
true,
{
ok: true,
key,
compacted: true,
archived,
kept: keptLines.length,
},
undefined,
);
break;
}
case "last-heartbeat": {
respond(true, getLastHeartbeatEvent(), undefined);
break;