palmier 0.5.1 → 0.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/README.md +9 -9
  2. package/dist/agents/agent-instructions.md +7 -11
  3. package/dist/agents/agent.d.ts +8 -3
  4. package/dist/agents/agent.js +7 -1
  5. package/dist/agents/claude.d.ts +2 -1
  6. package/dist/agents/claude.js +10 -5
  7. package/dist/agents/codex.d.ts +2 -1
  8. package/dist/agents/codex.js +10 -6
  9. package/dist/agents/copilot.d.ts +2 -1
  10. package/dist/agents/copilot.js +10 -3
  11. package/dist/agents/gemini.d.ts +2 -1
  12. package/dist/agents/gemini.js +11 -7
  13. package/dist/agents/kimi.d.ts +9 -0
  14. package/dist/agents/kimi.js +35 -0
  15. package/dist/agents/openclaw.d.ts +2 -1
  16. package/dist/agents/openclaw.js +3 -1
  17. package/dist/agents/qwen.d.ts +9 -0
  18. package/dist/agents/qwen.js +32 -0
  19. package/dist/agents/shared-prompt.d.ts +1 -1
  20. package/dist/agents/shared-prompt.js +7 -3
  21. package/dist/client-store.d.ts +12 -0
  22. package/dist/client-store.js +57 -0
  23. package/dist/commands/clients.d.ts +4 -0
  24. package/dist/commands/clients.js +27 -0
  25. package/dist/commands/info.js +5 -5
  26. package/dist/commands/init.js +1 -1
  27. package/dist/commands/pair.js +4 -4
  28. package/dist/commands/run.js +21 -8
  29. package/dist/commands/serve.js +1 -1
  30. package/dist/events.js +1 -1
  31. package/dist/index.js +13 -13
  32. package/dist/rpc-handler.js +13 -6
  33. package/dist/task.d.ts +13 -3
  34. package/dist/task.js +39 -7
  35. package/dist/transports/http-transport.js +30 -13
  36. package/dist/transports/nats-transport.js +4 -4
  37. package/dist/types.d.ts +3 -2
  38. package/package.json +1 -1
  39. package/src/agents/agent-instructions.md +7 -11
  40. package/src/agents/agent.ts +16 -4
  41. package/src/agents/claude.ts +11 -6
  42. package/src/agents/codex.ts +11 -7
  43. package/src/agents/copilot.ts +10 -4
  44. package/src/agents/gemini.ts +12 -8
  45. package/src/agents/kimi.ts +37 -0
  46. package/src/agents/openclaw.ts +4 -2
  47. package/src/agents/qwen.ts +34 -0
  48. package/src/agents/shared-prompt.ts +7 -3
  49. package/src/client-store.ts +68 -0
  50. package/src/commands/clients.ts +29 -0
  51. package/src/commands/info.ts +5 -5
  52. package/src/commands/init.ts +1 -1
  53. package/src/commands/pair.ts +4 -4
  54. package/src/commands/run.ts +22 -8
  55. package/src/commands/serve.ts +1 -1
  56. package/src/events.ts +1 -1
  57. package/src/index.ts +13 -13
  58. package/src/rpc-handler.ts +15 -6
  59. package/src/task.ts +43 -8
  60. package/src/transports/http-transport.ts +32 -13
  61. package/src/transports/nats-transport.ts +4 -4
  62. package/src/types.ts +4 -3
  63. package/test/agent-instructions.test.ts +48 -0
  64. package/test/agent-output-parsing.test.ts +12 -0
  65. package/dist/commands/sessions.d.ts +0 -4
  66. package/dist/commands/sessions.js +0 -27
  67. package/dist/session-store.d.ts +0 -12
  68. package/dist/session-store.js +0 -57
  69. package/src/commands/sessions.ts +0 -29
  70. package/src/session-store.ts +0 -68
package/src/task.ts CHANGED
@@ -202,31 +202,66 @@ export function beginStreamingMessage(
202
202
  const filePath = path.join(taskDir, runId, "TASKRUN.md");
203
203
  const delimiter = `<!-- palmier:message role="assistant" time="${time}" -->`;
204
204
  fs.appendFileSync(filePath, `${delimiter}\n\n`, "utf-8");
205
- return new StreamingMessageWriter(filePath, delimiter);
205
+ return new StreamingMessageWriter(filePath);
206
206
  }
207
207
 
208
208
  export class StreamingMessageWriter {
209
- private delimiter: string;
210
- constructor(private filePath: string, delimiter: string) {
211
- this.delimiter = delimiter;
212
- }
209
+ constructor(private filePath: string) {}
213
210
 
214
211
  /** Append a chunk of content to the current message. */
215
212
  write(chunk: string): void {
216
213
  fs.appendFileSync(this.filePath, chunk, "utf-8");
217
214
  }
218
215
 
219
- /** Finalize the message. If attachments are provided, rewrites the delimiter to include them. */
216
+ /** Finalize the message. If attachments are provided, rewrites the last assistant delimiter to include them. */
220
217
  end(attachments?: string[]): void {
221
218
  fs.appendFileSync(this.filePath, "\n\n", "utf-8");
222
219
  if (attachments?.length) {
223
220
  const raw = fs.readFileSync(this.filePath, "utf-8");
224
- const updated = raw.replace(this.delimiter, `${this.delimiter.slice(0, -4)} attachments="${attachments.join(",")}" -->`);
225
- fs.writeFileSync(this.filePath, updated, "utf-8");
221
+ // Find the last assistant delimiter (may differ from the original if spliceUserMessage created a new one)
222
+ const pattern = /<!-- palmier:message role="assistant" time="\d+" -->/g;
223
+ let lastMatch: RegExpExecArray | null = null;
224
+ let m;
225
+ while ((m = pattern.exec(raw)) !== null) lastMatch = m;
226
+ if (lastMatch) {
227
+ const before = raw.slice(0, lastMatch.index);
228
+ const after = raw.slice(lastMatch.index + lastMatch[0].length);
229
+ const updated = before + `${lastMatch[0].slice(0, -4)} attachments="${attachments.join(",")}" -->` + after;
230
+ fs.writeFileSync(this.filePath, updated, "utf-8");
231
+ }
226
232
  }
227
233
  }
228
234
  }
229
235
 
236
+ /**
237
+ * Splice a user message into a running assistant stream.
238
+ * Ends the current assistant block, writes the user message,
239
+ * then opens a new assistant block — all as direct file appends.
240
+ * The existing StreamingMessageWriter keeps working because its
241
+ * write() is just appendFileSync, so subsequent chunks land in
242
+ * the new assistant block.
243
+ */
244
+ export function spliceUserMessage(
245
+ taskDir: string,
246
+ runId: string,
247
+ userMsg: ConversationMessage,
248
+ /** Optional text to append to the current assistant block before ending it. */
249
+ assistantAppend?: string,
250
+ ): void {
251
+ const filePath = path.join(taskDir, runId, "TASKRUN.md");
252
+ // 1. Optionally append to the current assistant block (e.g. the input questions)
253
+ if (assistantAppend) {
254
+ fs.appendFileSync(filePath, assistantAppend, "utf-8");
255
+ }
256
+ // 2. End the current assistant block
257
+ fs.appendFileSync(filePath, "\n\n", "utf-8");
258
+ // 3. Write the user message
259
+ appendRunMessage(taskDir, runId, userMsg);
260
+ // 4. Open a new assistant block for subsequent agent output
261
+ const delimiter = `<!-- palmier:message role="assistant" time="${Date.now()}" -->`;
262
+ fs.appendFileSync(filePath, `${delimiter}\n\n`, "utf-8");
263
+ }
264
+
230
265
  /**
231
266
  * Read conversation messages from a run's TASKRUN.md file.
232
267
  */
@@ -1,9 +1,10 @@
1
1
  import * as http from "node:http";
2
2
  import * as os from "os";
3
3
  import { StringCodec, type NatsConnection } from "nats";
4
- import { validateSession, addSession } from "../session-store.js";
4
+ import { validateClient, addClient } from "../client-store.js";
5
5
  import { registerPending } from "../pending-requests.js";
6
- import { getTaskDir, parseTaskFile, appendRunMessage } from "../task.js";
6
+ import * as fs from "node:fs";
7
+ import { getTaskDir, parseTaskFile, spliceUserMessage } from "../task.js";
7
8
  import type { HostConfig, RpcMessage, RequiredPermission } from "../types.js";
8
9
 
9
10
  const PWA_ORIGIN = "https://app.palmier.me";
@@ -99,6 +100,18 @@ export function detectLanIp(): string {
99
100
  return "127.0.0.1";
100
101
  }
101
102
 
103
+ /** Find the latest (highest-numbered) run directory for a task. */
104
+ function findLatestRunId(taskDir: string): string | null {
105
+ try {
106
+ const dirs = fs.readdirSync(taskDir)
107
+ .filter((f) => /^\d+$/.test(f) && fs.statSync(`${taskDir}/${f}`).isDirectory())
108
+ .sort();
109
+ return dirs.length > 0 ? dirs[dirs.length - 1] : null;
110
+ } catch {
111
+ return null;
112
+ }
113
+ }
114
+
102
115
  /**
103
116
  * Start the HTTP transport: server with RPC, SSE, PWA proxy, pairing, and
104
117
  * localhost-only agent endpoints (notify, request-input, confirmation, permission).
@@ -132,10 +145,10 @@ export async function startHttpTransport(
132
145
  function checkAuth(req: http.IncomingMessage): boolean {
133
146
  const auth = req.headers.authorization;
134
147
  if (!auth || !auth.startsWith("Bearer ")) return false;
135
- return validateSession(auth.slice(7));
148
+ return validateClient(auth.slice(7));
136
149
  }
137
150
 
138
- function extractSessionToken(req: http.IncomingMessage): string | undefined {
151
+ function extractClientToken(req: http.IncomingMessage): string | undefined {
139
152
  const auth = req.headers.authorization;
140
153
  if (!auth || !auth.startsWith("Bearer ")) return undefined;
141
154
  return auth.slice(7);
@@ -262,6 +275,9 @@ export async function startHttpTransport(
262
275
  const taskDir = getTaskDir(config.projectRoot, taskId);
263
276
  const task = parseTaskFile(taskDir);
264
277
 
278
+ // Resolve runId: use provided value, otherwise find the latest run directory
279
+ const effectiveRunId = runId ?? findLatestRunId(taskDir);
280
+
265
281
  const pendingPromise = registerPending(taskId, "input", descriptions);
266
282
 
267
283
  await publishEvent(taskId, {
@@ -273,17 +289,20 @@ export async function startHttpTransport(
273
289
 
274
290
  const response = await pendingPromise;
275
291
 
292
+ const questionsBlock = "\n\n" + descriptions.map((d) => `**${d}**`).join("\n");
293
+
276
294
  if (response.length === 1 && response[0] === "aborted") {
277
295
  await publishEvent(taskId, { event_type: "input-resolved", host_id: config.hostId, status: "aborted" });
278
- if (runId) {
279
- appendRunMessage(taskDir, runId, { role: "user", time: Date.now(), content: "Input request aborted.", type: "input" });
296
+ if (effectiveRunId) {
297
+ spliceUserMessage(taskDir, effectiveRunId, { role: "user", time: Date.now(), content: "Aborted", type: "input" }, questionsBlock);
298
+ await publishEvent(taskId, { event_type: "result-updated", run_id: effectiveRunId });
280
299
  }
281
300
  sendJson(res, 200, { aborted: true });
282
301
  } else {
283
302
  await publishEvent(taskId, { event_type: "input-resolved", host_id: config.hostId, status: "provided" });
284
- if (runId) {
285
- const lines = descriptions.map((desc, i) => `**${desc}** ${response[i]}`);
286
- appendRunMessage(taskDir, runId, { role: "user", time: Date.now(), content: lines.join("\n"), type: "input" });
303
+ if (effectiveRunId) {
304
+ spliceUserMessage(taskDir, effectiveRunId, { role: "user", time: Date.now(), content: response.join("\n"), type: "input" }, questionsBlock);
305
+ await publishEvent(taskId, { event_type: "result-updated", run_id: effectiveRunId });
287
306
  }
288
307
  sendJson(res, 200, { values: response });
289
308
  }
@@ -375,11 +394,11 @@ export async function startHttpTransport(
375
394
  const pending = pendingPairs.get(code);
376
395
  if (!pending) { sendJson(res, 401, { error: "Invalid code" }); return; }
377
396
 
378
- const session = addSession(label);
397
+ const client = addClient(label);
379
398
  const ip = detectLanIp();
380
399
  const response: Record<string, unknown> = {
381
400
  hostId: config.hostId,
382
- sessionToken: session.token,
401
+ clientToken: client.token,
383
402
  directUrl: `http://${ip}:${port}`,
384
403
  };
385
404
 
@@ -457,11 +476,11 @@ export async function startHttpTransport(
457
476
  }
458
477
  } catch { sendJson(res, 400, { error: "Invalid JSON" }); return; }
459
478
 
460
- const sessionToken = extractSessionToken(req);
479
+ const clientToken = extractClientToken(req);
461
480
  console.log(`[http] RPC: ${method}`);
462
481
 
463
482
  try {
464
- const response = await handleRpc({ method, params, sessionToken, localhost: isLocalhost(req) });
483
+ const response = await handleRpc({ method, params, clientToken, localhost: isLocalhost(req) });
465
484
  console.log(`[http] RPC done: ${method}`, JSON.stringify(response).slice(0, 200));
466
485
  sendJson(res, 200, response);
467
486
  } catch (err) {
@@ -50,15 +50,15 @@ export async function startNatsTransport(
50
50
  }
51
51
  }
52
52
 
53
- // Extract sessionToken from params (PWA includes it in the payload)
54
- const sessionToken = typeof params.sessionToken === "string" ? params.sessionToken : undefined;
55
- delete params.sessionToken;
53
+ // Extract clientToken from params (PWA includes it in the payload)
54
+ const clientToken = typeof params.clientToken === "string" ? params.clientToken : undefined;
55
+ delete params.clientToken;
56
56
 
57
57
  console.log(`[nats] RPC: ${method}`);
58
58
 
59
59
  let response: unknown;
60
60
  try {
61
- response = await handleRpc({ method, params, sessionToken });
61
+ response = await handleRpc({ method, params, clientToken });
62
62
  } catch (err) {
63
63
  console.error(`[nats] RPC error (${method}):`, err);
64
64
  response = { error: String(err) };
package/src/types.ts CHANGED
@@ -9,7 +9,7 @@ export interface HostConfig {
9
9
  // Detected agent CLIs
10
10
  agents?: Array<{ key: string; label: string }>;
11
11
 
12
- // HTTP server port (default 7400)
12
+ // HTTP server port (default 9966)
13
13
  httpPort?: number;
14
14
  // Whether to accept non-localhost HTTP connections
15
15
  lanEnabled?: boolean;
@@ -23,6 +23,7 @@ export interface TaskFrontmatter {
23
23
  triggers: Trigger[];
24
24
  triggers_enabled: boolean;
25
25
  requires_confirmation: boolean;
26
+ yolo_mode?: boolean;
26
27
  permissions?: RequiredPermission[];
27
28
  command?: string;
28
29
  }
@@ -78,7 +79,7 @@ export interface ConversationMessage {
78
79
  export interface RpcMessage {
79
80
  method: string;
80
81
  params: Record<string, unknown>;
81
- sessionToken?: string;
82
- /** Trusted localhost request — skip session validation. */
82
+ clientToken?: string;
83
+ /** Trusted localhost request — skip client validation. */
83
84
  localhost?: boolean;
84
85
  }
@@ -0,0 +1,48 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import * as fs from "fs";
4
+ import * as path from "path";
5
+ import { fileURLToPath } from "url";
6
+
7
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
8
+ const templatePath = path.join(__dirname, "..", "src", "agents", "agent-instructions.md");
9
+ const template = fs.readFileSync(templatePath, "utf-8");
10
+
11
+ /** Minimal replica of getAgentInstructions that doesn't need host.json */
12
+ function buildInstructions(taskId: string, skipPermissions?: boolean): string {
13
+ let instructions = template
14
+ .replace(/\{\{PORT\}\}/g, "9966")
15
+ .replace(/\{\{TASK_ID\}\}/g, taskId);
16
+ if (skipPermissions) {
17
+ instructions = instructions.replace(/## Permissions\r?\n[\s\S]*?(?=## |\r?\n---)/m, "");
18
+ }
19
+ return instructions;
20
+ }
21
+
22
+ describe("getAgentInstructions", () => {
23
+ it("includes Permissions section by default", () => {
24
+ const result = buildInstructions("test-task-id");
25
+ assert.match(result, /## Permissions/);
26
+ assert.match(result, /PALMIER_PERMISSION/);
27
+ });
28
+
29
+ it("strips Permissions section when skipPermissions is true", () => {
30
+ const result = buildInstructions("test-task-id", true);
31
+ assert.doesNotMatch(result, /## Permissions/);
32
+ assert.doesNotMatch(result, /PALMIER_PERMISSION/);
33
+ });
34
+
35
+ it("preserves other sections when Permissions is stripped", () => {
36
+ const result = buildInstructions("test-task-id", true);
37
+ assert.match(result, /## Reporting Output/);
38
+ assert.match(result, /## Completion/);
39
+ assert.match(result, /## HTTP Endpoints/);
40
+ });
41
+
42
+ it("replaces template variables", () => {
43
+ const result = buildInstructions("my-task-123");
44
+ assert.match(result, /my-task-123/);
45
+ assert.doesNotMatch(result, /\{\{TASK_ID\}\}/);
46
+ assert.doesNotMatch(result, /\{\{PORT\}\}/);
47
+ });
48
+ });
@@ -38,6 +38,11 @@ describe("parseReportFiles", () => {
38
38
  it("trims whitespace from file names", () => {
39
39
  assert.deepEqual(parseReportFiles("[PALMIER_REPORT] report.md "), ["report.md"]);
40
40
  });
41
+
42
+ it("ignores placeholder examples from echoed prompt", () => {
43
+ const output = "[PALMIER_REPORT] <filename>\n[PALMIER_REPORT] actual-report.md";
44
+ assert.deepEqual(parseReportFiles(output), ["actual-report.md"]);
45
+ });
41
46
  });
42
47
 
43
48
  describe("parsePermissions", () => {
@@ -57,5 +62,12 @@ describe("parsePermissions", () => {
57
62
  it("returns empty array when no permissions", () => {
58
63
  assert.deepEqual(parsePermissions("no permissions"), []);
59
64
  });
65
+
66
+ it("ignores placeholder examples from echoed prompt", () => {
67
+ const output = "[PALMIER_PERMISSION] <tool_name> | <description>\n[PALMIER_PERMISSION] Read | Read files";
68
+ const perms = parsePermissions(output);
69
+ assert.equal(perms.length, 1);
70
+ assert.deepEqual(perms[0], { name: "Read", description: "Read files" });
71
+ });
60
72
  });
61
73
 
@@ -1,4 +0,0 @@
1
- export declare function sessionsListCommand(): Promise<void>;
2
- export declare function sessionsRevokeCommand(token: string): Promise<void>;
3
- export declare function sessionsRevokeAllCommand(): Promise<void>;
4
- //# sourceMappingURL=sessions.d.ts.map
@@ -1,27 +0,0 @@
1
- import { loadSessions, revokeSession, revokeAllSessions } from "../session-store.js";
2
- export async function sessionsListCommand() {
3
- const sessions = loadSessions();
4
- if (sessions.length === 0) {
5
- console.log("No active sessions.");
6
- return;
7
- }
8
- console.log(`${sessions.length} active session(s):\n`);
9
- for (const s of sessions) {
10
- const label = s.label ? ` (${s.label})` : "";
11
- console.log(` ${s.token}${label} created ${s.createdAt}`);
12
- }
13
- }
14
- export async function sessionsRevokeCommand(token) {
15
- if (revokeSession(token)) {
16
- console.log("Session revoked.");
17
- }
18
- else {
19
- console.error("Session not found.");
20
- process.exit(1);
21
- }
22
- }
23
- export async function sessionsRevokeAllCommand() {
24
- const count = revokeAllSessions();
25
- console.log(`Revoked ${count} session(s).`);
26
- }
27
- //# sourceMappingURL=sessions.js.map
@@ -1,12 +0,0 @@
1
- export interface SessionEntry {
2
- token: string;
3
- createdAt: string;
4
- label?: string;
5
- }
6
- export declare function loadSessions(): SessionEntry[];
7
- export declare function addSession(label?: string): SessionEntry;
8
- export declare function revokeSession(token: string): boolean;
9
- export declare function revokeAllSessions(): number;
10
- export declare function validateSession(token: string): boolean;
11
- export declare function hasSessions(): boolean;
12
- //# sourceMappingURL=session-store.d.ts.map
@@ -1,57 +0,0 @@
1
- import * as fs from "fs";
2
- import * as path from "path";
3
- import { randomBytes } from "crypto";
4
- import { CONFIG_DIR } from "./config.js";
5
- const SESSIONS_FILE = path.join(CONFIG_DIR, "sessions.json");
6
- function readFile() {
7
- try {
8
- if (!fs.existsSync(SESSIONS_FILE))
9
- return [];
10
- const raw = fs.readFileSync(SESSIONS_FILE, "utf-8");
11
- return JSON.parse(raw);
12
- }
13
- catch {
14
- return [];
15
- }
16
- }
17
- function writeFile(sessions) {
18
- fs.mkdirSync(CONFIG_DIR, { recursive: true });
19
- fs.writeFileSync(SESSIONS_FILE, JSON.stringify(sessions, null, 2), "utf-8");
20
- }
21
- export function loadSessions() {
22
- return readFile();
23
- }
24
- export function addSession(label) {
25
- const sessions = readFile();
26
- const entry = {
27
- token: randomBytes(32).toString("hex"),
28
- createdAt: new Date().toISOString(),
29
- ...(label ? { label } : {}),
30
- };
31
- sessions.push(entry);
32
- writeFile(sessions);
33
- return entry;
34
- }
35
- export function revokeSession(token) {
36
- const sessions = readFile();
37
- const idx = sessions.findIndex((s) => s.token === token);
38
- if (idx === -1)
39
- return false;
40
- sessions.splice(idx, 1);
41
- writeFile(sessions);
42
- return true;
43
- }
44
- export function revokeAllSessions() {
45
- const sessions = readFile();
46
- const count = sessions.length;
47
- writeFile([]);
48
- return count;
49
- }
50
- export function validateSession(token) {
51
- const sessions = readFile();
52
- return sessions.some((s) => s.token === token);
53
- }
54
- export function hasSessions() {
55
- return readFile().length > 0;
56
- }
57
- //# sourceMappingURL=session-store.js.map
@@ -1,29 +0,0 @@
1
- import { loadSessions, revokeSession, revokeAllSessions } from "../session-store.js";
2
-
3
- export async function sessionsListCommand(): Promise<void> {
4
- const sessions = loadSessions();
5
- if (sessions.length === 0) {
6
- console.log("No active sessions.");
7
- return;
8
- }
9
-
10
- console.log(`${sessions.length} active session(s):\n`);
11
- for (const s of sessions) {
12
- const label = s.label ? ` (${s.label})` : "";
13
- console.log(` ${s.token}${label} created ${s.createdAt}`);
14
- }
15
- }
16
-
17
- export async function sessionsRevokeCommand(token: string): Promise<void> {
18
- if (revokeSession(token)) {
19
- console.log("Session revoked.");
20
- } else {
21
- console.error("Session not found.");
22
- process.exit(1);
23
- }
24
- }
25
-
26
- export async function sessionsRevokeAllCommand(): Promise<void> {
27
- const count = revokeAllSessions();
28
- console.log(`Revoked ${count} session(s).`);
29
- }
@@ -1,68 +0,0 @@
1
- import * as fs from "fs";
2
- import * as path from "path";
3
- import { randomBytes } from "crypto";
4
- import { CONFIG_DIR } from "./config.js";
5
-
6
- const SESSIONS_FILE = path.join(CONFIG_DIR, "sessions.json");
7
-
8
- export interface SessionEntry {
9
- token: string;
10
- createdAt: string;
11
- label?: string;
12
- }
13
-
14
- function readFile(): SessionEntry[] {
15
- try {
16
- if (!fs.existsSync(SESSIONS_FILE)) return [];
17
- const raw = fs.readFileSync(SESSIONS_FILE, "utf-8");
18
- return JSON.parse(raw) as SessionEntry[];
19
- } catch {
20
- return [];
21
- }
22
- }
23
-
24
- function writeFile(sessions: SessionEntry[]): void {
25
- fs.mkdirSync(CONFIG_DIR, { recursive: true });
26
- fs.writeFileSync(SESSIONS_FILE, JSON.stringify(sessions, null, 2), "utf-8");
27
- }
28
-
29
- export function loadSessions(): SessionEntry[] {
30
- return readFile();
31
- }
32
-
33
- export function addSession(label?: string): SessionEntry {
34
- const sessions = readFile();
35
- const entry: SessionEntry = {
36
- token: randomBytes(32).toString("hex"),
37
- createdAt: new Date().toISOString(),
38
- ...(label ? { label } : {}),
39
- };
40
- sessions.push(entry);
41
- writeFile(sessions);
42
- return entry;
43
- }
44
-
45
- export function revokeSession(token: string): boolean {
46
- const sessions = readFile();
47
- const idx = sessions.findIndex((s) => s.token === token);
48
- if (idx === -1) return false;
49
- sessions.splice(idx, 1);
50
- writeFile(sessions);
51
- return true;
52
- }
53
-
54
- export function revokeAllSessions(): number {
55
- const sessions = readFile();
56
- const count = sessions.length;
57
- writeFile([]);
58
- return count;
59
- }
60
-
61
- export function validateSession(token: string): boolean {
62
- const sessions = readFile();
63
- return sessions.some((s) => s.token === token);
64
- }
65
-
66
- export function hasSessions(): boolean {
67
- return readFile().length > 0;
68
- }