@gricha/perry 0.2.1 → 0.2.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.
package/README.md CHANGED
@@ -10,11 +10,23 @@
10
10
  <a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="License: MIT"></a>
11
11
  </p>
12
12
 
13
- <p align="center">Isolated, self-hosted workspaces accessible over Tailscale. AI coding agents, web UI, and remote terminal access.</p>
13
+ <p align="center">
14
+ Continue your coding sessions on the go. Self-hosted workspaces, accessible over Tailscale.
15
+ </p>
16
+
17
+ <p align="center">
18
+ <img src="assets/demo-terminal-mobile.gif" alt="Terminal" width="280">
19
+ &nbsp;&nbsp;&nbsp;
20
+ <img src="assets/demo-chat-mobile.gif" alt="Chat" width="280">
21
+ </p>
14
22
 
15
23
  ## Overview
16
24
 
17
- Perry is designed to run on a machine within a **secure private network** such as [Tailscale](https://tailscale.com). It provides isolated Docker-based development environments that you can access remotely via CLI, web UI, or SSH from any device on your network.
25
+ Perry is an agent (agent P) designed to run as a daemon on your machine. It allows your clients - other machines through CLI, web, or mobile app - to connect directly to your workspaces over the Tailscale network.
26
+
27
+ It can be connected directly to your host, or it can create docker containers so that your work can be fully isolated.
28
+
29
+ Continue your sessions on the go!
18
30
 
19
31
  ## Features
20
32
 
@@ -65,10 +77,22 @@ perry list
65
77
 
66
78
  Open http://localhost:7391 (or your Tailscale host) and click "+" to create a workspace.
67
79
 
80
+ <p align="center">
81
+ <img src="assets/demo.gif" alt="Web UI Demo" width="800">
82
+ </p>
83
+
68
84
  ## Security
69
85
 
70
86
  Perry is designed for use within **secure private networks** like [Tailscale](https://tailscale.com). The web UI and API currently have no authentication - this is intentional for private network use where all devices are trusted.
71
87
 
88
+ NOTE: Using this software can be dangerous, don't expose it on the network. Any user that can access perry's web server may be able to do serious damage to your system. Keep it closed in Tailscale network.
89
+
90
+ Perry by default allows the API to interact with the host machine as well - while it's intended purpose is to manage docker containers, sometimes, for simplicity I run some of my jobs directly on my machine. This can be disabled. When you start perry, you can pass a `--no-host-access` flag.
91
+
92
+ `perry agent run --no-host-access`
93
+
94
+ This will ensure that perry can only stand up/tear down docker containers. While this reduces the attack surface, it is only as good as docker is as sandbox (and it may very well not be).
95
+
72
96
  ## Configuration
73
97
 
74
98
  Configure credentials and agent settings via Web UI → Settings or edit `~/.config/perry/config.json`:
@@ -0,0 +1,138 @@
1
+ import { watch } from 'fs';
2
+ import { access } from 'fs/promises';
3
+ import { expandPath } from '../config/loader';
4
+ const STANDARD_CREDENTIAL_FILES = [
5
+ '~/.gitconfig',
6
+ '~/.claude/.credentials.json',
7
+ '~/.codex/auth.json',
8
+ '~/.codex/config.toml',
9
+ ];
10
+ export class FileWatcher {
11
+ watchers = new Map();
12
+ config;
13
+ syncCallback;
14
+ debounceMs;
15
+ debounceTimer = null;
16
+ pendingSync = false;
17
+ constructor(options) {
18
+ this.config = options.config;
19
+ this.syncCallback = options.syncCallback;
20
+ this.debounceMs = options.debounceMs ?? 500;
21
+ this.setupWatchers();
22
+ }
23
+ updateConfig(config) {
24
+ this.config = config;
25
+ this.rebuildWatchers();
26
+ }
27
+ stop() {
28
+ if (this.debounceTimer) {
29
+ clearTimeout(this.debounceTimer);
30
+ this.debounceTimer = null;
31
+ }
32
+ for (const [filePath, watcher] of this.watchers) {
33
+ watcher.close();
34
+ console.log(`[file-watcher] Stopped watching: ${filePath}`);
35
+ }
36
+ this.watchers.clear();
37
+ }
38
+ collectWatchPaths() {
39
+ const paths = new Set();
40
+ for (const sourcePath of Object.values(this.config.credentials.files)) {
41
+ paths.add(expandPath(sourcePath));
42
+ }
43
+ for (const stdPath of STANDARD_CREDENTIAL_FILES) {
44
+ paths.add(expandPath(stdPath));
45
+ }
46
+ if (this.config.ssh?.global?.copy) {
47
+ for (const keyPath of this.config.ssh.global.copy) {
48
+ paths.add(expandPath(keyPath));
49
+ }
50
+ }
51
+ if (this.config.ssh?.workspaces) {
52
+ for (const wsConfig of Object.values(this.config.ssh.workspaces)) {
53
+ if (wsConfig.copy) {
54
+ for (const keyPath of wsConfig.copy) {
55
+ paths.add(expandPath(keyPath));
56
+ }
57
+ }
58
+ }
59
+ }
60
+ return Array.from(paths);
61
+ }
62
+ async setupWatchers() {
63
+ const paths = this.collectWatchPaths();
64
+ for (const filePath of paths) {
65
+ await this.watchFile(filePath);
66
+ }
67
+ }
68
+ async watchFile(filePath) {
69
+ if (this.watchers.has(filePath)) {
70
+ return;
71
+ }
72
+ try {
73
+ await access(filePath);
74
+ }
75
+ catch {
76
+ return;
77
+ }
78
+ try {
79
+ const watcher = watch(filePath, (eventType) => {
80
+ if (eventType === 'change' || eventType === 'rename') {
81
+ this.handleFileChange(filePath);
82
+ }
83
+ });
84
+ watcher.on('error', (err) => {
85
+ console.error(`[file-watcher] Error watching ${filePath}:`, err);
86
+ this.watchers.delete(filePath);
87
+ });
88
+ this.watchers.set(filePath, watcher);
89
+ console.log(`[file-watcher] Watching: ${filePath}`);
90
+ }
91
+ catch (err) {
92
+ console.error(`[file-watcher] Failed to watch ${filePath}:`, err);
93
+ }
94
+ }
95
+ handleFileChange(filePath) {
96
+ console.log(`[file-watcher] Change detected: ${filePath}`);
97
+ this.scheduleSync();
98
+ }
99
+ scheduleSync() {
100
+ if (this.debounceTimer) {
101
+ clearTimeout(this.debounceTimer);
102
+ }
103
+ this.pendingSync = true;
104
+ this.debounceTimer = setTimeout(async () => {
105
+ this.debounceTimer = null;
106
+ if (this.pendingSync) {
107
+ this.pendingSync = false;
108
+ try {
109
+ console.log('[file-watcher] Triggering sync...');
110
+ await this.syncCallback();
111
+ console.log('[file-watcher] Sync completed');
112
+ }
113
+ catch (err) {
114
+ console.error('[file-watcher] Sync failed:', err);
115
+ }
116
+ }
117
+ }, this.debounceMs);
118
+ }
119
+ rebuildWatchers() {
120
+ const newPaths = new Set(this.collectWatchPaths());
121
+ const currentPaths = new Set(this.watchers.keys());
122
+ for (const filePath of currentPaths) {
123
+ if (!newPaths.has(filePath)) {
124
+ const watcher = this.watchers.get(filePath);
125
+ if (watcher) {
126
+ watcher.close();
127
+ this.watchers.delete(filePath);
128
+ console.log(`[file-watcher] Stopped watching: ${filePath}`);
129
+ }
130
+ }
131
+ }
132
+ for (const filePath of newPaths) {
133
+ if (!currentPaths.has(filePath)) {
134
+ this.watchFile(filePath);
135
+ }
136
+ }
137
+ }
138
+ }
@@ -3,14 +3,15 @@ import * as z from 'zod';
3
3
  import os_module from 'os';
4
4
  import { promises as fs } from 'fs';
5
5
  import path from 'path';
6
- import { HOST_WORKSPACE_NAME } from '../shared/types';
6
+ import { HOST_WORKSPACE_NAME } from '../shared/client-types';
7
7
  import { getDockerVersion, execInContainer } from '../docker';
8
8
  import { saveAgentConfig } from '../config/loader';
9
9
  import { setSessionName, getSessionNamesForWorkspace, deleteSessionName, } from '../sessions/metadata';
10
10
  import { discoverSSHKeys } from '../ssh/discovery';
11
11
  import { parseClaudeSessionContent } from '../sessions/parser';
12
- import { discoverAllSessions, getSessionDetails as getAgentSessionDetails, getSessionMessages, findSessionMessages, } from '../sessions/agents';
12
+ import { discoverAllSessions, getSessionDetails as getAgentSessionDetails, getSessionMessages, findSessionMessages, deleteSession as deleteSessionFromProvider, searchSessions as searchSessionsInContainer, } from '../sessions/agents';
13
13
  import { discoverClaudeCodeModels, discoverHostOpencodeModels, discoverContainerOpencodeModels, } from '../models/discovery';
14
+ import { listOpencodeSessions, getOpencodeSessionMessages, deleteOpencodeSession, } from '../sessions/agents/opencode-storage';
14
15
  const WorkspaceStatusSchema = z.enum(['running', 'stopped', 'creating', 'error']);
15
16
  const WorkspacePortsSchema = z.object({
16
17
  ssh: z.number(),
@@ -207,6 +208,7 @@ export function createRouter(ctx) {
207
208
  workspacesCount: allWorkspaces.length,
208
209
  dockerVersion,
209
210
  terminalConnections: ctx.terminalServer.getConnectionCount(),
211
+ tailscale: ctx.tailscale,
210
212
  };
211
213
  });
212
214
  const getCredentials = os.output(CredentialsSchema).handler(async () => {
@@ -220,6 +222,7 @@ export function createRouter(ctx) {
220
222
  const newConfig = { ...currentConfig, credentials: input };
221
223
  ctx.config.set(newConfig);
222
224
  await saveAgentConfig(newConfig, ctx.configDir);
225
+ ctx.triggerAutoSync();
223
226
  return input;
224
227
  });
225
228
  const getScripts = os.output(ScriptsSchema).handler(async () => {
@@ -233,6 +236,7 @@ export function createRouter(ctx) {
233
236
  const newConfig = { ...currentConfig, scripts: input };
234
237
  ctx.config.set(newConfig);
235
238
  await saveAgentConfig(newConfig, ctx.configDir);
239
+ ctx.triggerAutoSync();
236
240
  return input;
237
241
  });
238
242
  const getAgents = os.output(CodingAgentsSchema).handler(async () => {
@@ -246,6 +250,7 @@ export function createRouter(ctx) {
246
250
  const newConfig = { ...currentConfig, agents: input };
247
251
  ctx.config.set(newConfig);
248
252
  await saveAgentConfig(newConfig, ctx.configDir);
253
+ ctx.triggerAutoSync();
249
254
  return input;
250
255
  });
251
256
  const getSSHSettings = os.output(SSHSettingsSchema).handler(async () => {
@@ -264,6 +269,7 @@ export function createRouter(ctx) {
264
269
  const newConfig = { ...currentConfig, ssh: input };
265
270
  ctx.config.set(newConfig);
266
271
  await saveAgentConfig(newConfig, ctx.configDir);
272
+ ctx.triggerAutoSync();
267
273
  return input;
268
274
  });
269
275
  const listSSHKeys = os.output(z.array(SSHKeyInfoSchema)).handler(async () => {
@@ -305,32 +311,16 @@ export function createRouter(ctx) {
305
311
  }
306
312
  }
307
313
  if (!input.agentType || input.agentType === 'opencode') {
308
- const opencodeDir = path.join(homeDir, '.opencode', 'sessions');
309
- try {
310
- const sessions = await fs.readdir(opencodeDir);
311
- for (const sessionDir of sessions) {
312
- const sessionPath = path.join(opencodeDir, sessionDir);
313
- const stat = await fs.stat(sessionPath);
314
- if (!stat.isDirectory())
315
- continue;
316
- const sessionFile = path.join(sessionPath, 'session.json');
317
- try {
318
- const sessionStat = await fs.stat(sessionFile);
319
- rawSessions.push({
320
- id: sessionDir,
321
- agentType: 'opencode',
322
- projectPath: homeDir,
323
- mtime: sessionStat.mtimeMs,
324
- filePath: sessionFile,
325
- });
326
- }
327
- catch {
328
- // session.json doesn't exist
329
- }
330
- }
331
- }
332
- catch {
333
- // Directory doesn't exist
314
+ const opencodeSessions = await listOpencodeSessions();
315
+ for (const session of opencodeSessions) {
316
+ rawSessions.push({
317
+ id: session.id,
318
+ agentType: 'opencode',
319
+ projectPath: session.directory || homeDir,
320
+ mtime: session.mtime,
321
+ filePath: session.file,
322
+ name: session.title || undefined,
323
+ });
334
324
  }
335
325
  }
336
326
  rawSessions.sort((a, b) => b.mtime - a.mtime);
@@ -371,16 +361,17 @@ export function createRouter(ctx) {
371
361
  }
372
362
  }
373
363
  else if (raw.agentType === 'opencode') {
374
- try {
375
- const sessionContent = await fs.readFile(raw.filePath, 'utf-8');
376
- const sessionData = JSON.parse(sessionContent);
377
- messageCount = sessionData.messages?.length || 0;
378
- if (sessionData.title) {
379
- firstPrompt = sessionData.title;
380
- }
364
+ const sessionMessages = await getOpencodeSessionMessages(raw.id);
365
+ const userAssistantMessages = sessionMessages.messages.filter((m) => m.type === 'user' || m.type === 'assistant');
366
+ messageCount = userAssistantMessages.length;
367
+ if (raw.name) {
368
+ firstPrompt = raw.name;
381
369
  }
382
- catch {
383
- // Can't read file
370
+ else {
371
+ const firstUserMsg = userAssistantMessages.find((m) => m.type === 'user' && m.content);
372
+ if (firstUserMsg?.content) {
373
+ firstPrompt = firstUserMsg.content.slice(0, 200);
374
+ }
384
375
  }
385
376
  }
386
377
  return {
@@ -434,57 +425,17 @@ export function createRouter(ctx) {
434
425
  }
435
426
  }
436
427
  if (!agentType || agentType === 'opencode') {
437
- const sessionDir = path.join(homeDir, '.opencode', 'sessions', sessionId);
438
- const partsDir = path.join(sessionDir, 'part');
439
- try {
440
- const partFiles = await fs.readdir(partsDir);
441
- const sortedParts = partFiles.sort();
442
- for (const partFile of sortedParts) {
443
- const partPath = path.join(partsDir, partFile);
444
- try {
445
- const partContent = await fs.readFile(partPath, 'utf-8');
446
- const part = JSON.parse(partContent);
447
- if (part.role === 'user' && part.content) {
448
- const textContent = Array.isArray(part.content)
449
- ? part.content
450
- .filter((c) => c.type === 'text')
451
- .map((c) => c.text)
452
- .join('\n')
453
- : part.content;
454
- messages.push({
455
- type: 'user',
456
- content: textContent,
457
- timestamp: part.time || null,
458
- });
459
- }
460
- else if (part.role === 'assistant') {
461
- if (part.content) {
462
- const textContent = Array.isArray(part.content)
463
- ? part.content
464
- .filter((c) => c.type === 'text')
465
- .map((c) => c.text)
466
- .join('\n')
467
- : part.content;
468
- if (textContent) {
469
- messages.push({
470
- type: 'assistant',
471
- content: textContent,
472
- timestamp: part.time || null,
473
- });
474
- }
475
- }
476
- }
477
- }
478
- catch {
479
- // Can't parse part
480
- }
481
- }
482
- if (messages.length > 0) {
483
- return { id: sessionId, agentType: 'opencode', messages };
484
- }
485
- }
486
- catch {
487
- // Directory doesn't exist
428
+ const sessionData = await getOpencodeSessionMessages(sessionId);
429
+ if (sessionData.messages.length > 0) {
430
+ const opencodeMessages = sessionData.messages.map((m) => ({
431
+ type: m.type,
432
+ content: m.content,
433
+ toolName: m.toolName,
434
+ toolId: m.toolId,
435
+ toolInput: m.toolInput,
436
+ timestamp: m.timestamp,
437
+ }));
438
+ return { id: sessionId, agentType: 'opencode', messages: opencodeMessages };
488
439
  }
489
440
  }
490
441
  return { id: sessionId, messages };
@@ -627,6 +578,196 @@ export function createRouter(ctx) {
627
578
  await ctx.sessionsCache.recordAccess(input.workspaceName, input.sessionId, input.agentType);
628
579
  return { success: true };
629
580
  });
581
+ const deleteSession = os
582
+ .input(z.object({
583
+ workspaceName: z.string(),
584
+ sessionId: z.string(),
585
+ agentType: z.enum(['claude-code', 'opencode', 'codex']),
586
+ }))
587
+ .handler(async ({ input }) => {
588
+ const isHost = input.workspaceName === HOST_WORKSPACE_NAME;
589
+ if (isHost) {
590
+ const config = ctx.config.get();
591
+ if (!config.allowHostAccess) {
592
+ throw new ORPCError('PRECONDITION_FAILED', { message: 'Host access is disabled' });
593
+ }
594
+ const result = await deleteHostSession(input.sessionId, input.agentType);
595
+ if (!result.success) {
596
+ throw new ORPCError('INTERNAL_SERVER_ERROR', {
597
+ message: result.error || 'Failed to delete session',
598
+ });
599
+ }
600
+ await deleteSessionName(ctx.stateDir, input.workspaceName, input.sessionId);
601
+ await ctx.sessionsCache.removeSession(input.workspaceName, input.sessionId);
602
+ return { success: true };
603
+ }
604
+ const workspace = await ctx.workspaces.get(input.workspaceName);
605
+ if (!workspace) {
606
+ throw new ORPCError('NOT_FOUND', { message: 'Workspace not found' });
607
+ }
608
+ if (workspace.status !== 'running') {
609
+ throw new ORPCError('PRECONDITION_FAILED', { message: 'Workspace is not running' });
610
+ }
611
+ const containerName = `workspace-${input.workspaceName}`;
612
+ const result = await deleteSessionFromProvider(containerName, input.sessionId, input.agentType, execInContainer);
613
+ if (!result.success) {
614
+ throw new ORPCError('INTERNAL_SERVER_ERROR', {
615
+ message: result.error || 'Failed to delete session',
616
+ });
617
+ }
618
+ await deleteSessionName(ctx.stateDir, input.workspaceName, input.sessionId);
619
+ await ctx.sessionsCache.removeSession(input.workspaceName, input.sessionId);
620
+ return { success: true };
621
+ });
622
+ const searchSessions = os
623
+ .input(z.object({
624
+ workspaceName: z.string(),
625
+ query: z.string().min(1).max(500),
626
+ }))
627
+ .handler(async ({ input }) => {
628
+ const isHost = input.workspaceName === HOST_WORKSPACE_NAME;
629
+ if (isHost) {
630
+ const config = ctx.config.get();
631
+ if (!config.allowHostAccess) {
632
+ throw new ORPCError('PRECONDITION_FAILED', { message: 'Host access is disabled' });
633
+ }
634
+ const results = await searchHostSessions(input.query);
635
+ return { results };
636
+ }
637
+ const workspace = await ctx.workspaces.get(input.workspaceName);
638
+ if (!workspace) {
639
+ throw new ORPCError('NOT_FOUND', { message: 'Workspace not found' });
640
+ }
641
+ if (workspace.status !== 'running') {
642
+ throw new ORPCError('PRECONDITION_FAILED', { message: 'Workspace is not running' });
643
+ }
644
+ const containerName = `workspace-${input.workspaceName}`;
645
+ const results = await searchSessionsInContainer(containerName, input.query, execInContainer);
646
+ return { results };
647
+ });
648
+ async function searchHostSessions(query) {
649
+ const homeDir = os_module.homedir();
650
+ const safeQuery = query.replace(/['"\\]/g, '\\$&');
651
+ const searchPaths = [
652
+ path.join(homeDir, '.claude', 'projects'),
653
+ path.join(homeDir, '.local', 'share', 'opencode', 'storage'),
654
+ path.join(homeDir, '.codex', 'sessions'),
655
+ ].filter((p) => {
656
+ try {
657
+ require('fs').accessSync(p);
658
+ return true;
659
+ }
660
+ catch {
661
+ return false;
662
+ }
663
+ });
664
+ if (searchPaths.length === 0) {
665
+ return [];
666
+ }
667
+ const { execSync } = await import('child_process');
668
+ try {
669
+ const output = execSync(`rg -l -i --no-messages "${safeQuery}" ${searchPaths.join(' ')} 2>/dev/null | head -100`, {
670
+ encoding: 'utf-8',
671
+ timeout: 30000,
672
+ });
673
+ const files = output.trim().split('\n').filter(Boolean);
674
+ const results = [];
675
+ for (const file of files) {
676
+ let sessionId = null;
677
+ let agentType = null;
678
+ if (file.includes('/.claude/projects/')) {
679
+ const match = file.match(/\/([^/]+)\.jsonl$/);
680
+ if (match && !match[1].startsWith('agent-')) {
681
+ sessionId = match[1];
682
+ agentType = 'claude-code';
683
+ }
684
+ }
685
+ else if (file.includes('/.local/share/opencode/storage/')) {
686
+ if (file.includes('/session/') && file.endsWith('.json')) {
687
+ const match = file.match(/\/(ses_[^/]+)\.json$/);
688
+ if (match) {
689
+ sessionId = match[1];
690
+ agentType = 'opencode';
691
+ }
692
+ }
693
+ }
694
+ else if (file.includes('/.codex/sessions/')) {
695
+ const match = file.match(/\/([^/]+)\.jsonl$/);
696
+ if (match) {
697
+ sessionId = match[1];
698
+ agentType = 'codex';
699
+ }
700
+ }
701
+ if (sessionId && agentType) {
702
+ results.push({ sessionId, agentType, matchCount: 1 });
703
+ }
704
+ }
705
+ return results;
706
+ }
707
+ catch {
708
+ return [];
709
+ }
710
+ }
711
+ async function deleteHostSession(sessionId, agentType) {
712
+ const homeDir = os_module.homedir();
713
+ if (agentType === 'claude-code') {
714
+ const safeSessionId = sessionId.replace(/[^a-zA-Z0-9_-]/g, '');
715
+ const claudeProjectsDir = path.join(homeDir, '.claude', 'projects');
716
+ try {
717
+ const projectDirs = await fs.readdir(claudeProjectsDir);
718
+ for (const projectDir of projectDirs) {
719
+ const sessionFile = path.join(claudeProjectsDir, projectDir, `${safeSessionId}.jsonl`);
720
+ try {
721
+ await fs.unlink(sessionFile);
722
+ return { success: true };
723
+ }
724
+ catch {
725
+ continue;
726
+ }
727
+ }
728
+ }
729
+ catch {
730
+ return { success: false, error: 'Session not found' };
731
+ }
732
+ return { success: false, error: 'Session not found' };
733
+ }
734
+ if (agentType === 'opencode') {
735
+ return deleteOpencodeSession(sessionId);
736
+ }
737
+ if (agentType === 'codex') {
738
+ const codexSessionsDir = path.join(homeDir, '.codex', 'sessions');
739
+ try {
740
+ const files = await fs.readdir(codexSessionsDir);
741
+ for (const file of files) {
742
+ if (!file.endsWith('.jsonl'))
743
+ continue;
744
+ const filePath = path.join(codexSessionsDir, file);
745
+ const fileId = file.replace('.jsonl', '');
746
+ if (fileId === sessionId) {
747
+ await fs.unlink(filePath);
748
+ return { success: true };
749
+ }
750
+ try {
751
+ const content = await fs.readFile(filePath, 'utf-8');
752
+ const firstLine = content.split('\n')[0];
753
+ const meta = JSON.parse(firstLine);
754
+ if (meta.session_id === sessionId) {
755
+ await fs.unlink(filePath);
756
+ return { success: true };
757
+ }
758
+ }
759
+ catch {
760
+ continue;
761
+ }
762
+ }
763
+ }
764
+ catch {
765
+ return { success: false, error: 'Session not found' };
766
+ }
767
+ return { success: false, error: 'Session not found' };
768
+ }
769
+ return { success: false, error: 'Unsupported agent type' };
770
+ }
630
771
  const getHostInfo = os.handler(async () => {
631
772
  const config = ctx.config.get();
632
773
  return {
@@ -724,6 +865,8 @@ export function createRouter(ctx) {
724
865
  clearName: clearSessionName,
725
866
  getRecent: getRecentSessions,
726
867
  recordAccess: recordSessionAccess,
868
+ delete: deleteSession,
869
+ search: searchSessions,
727
870
  },
728
871
  models: {
729
872
  list: listModels,