@gricha/perry 0.3.4 → 0.3.6
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/dist/agent/router.js +16 -0
- package/dist/agent/run.js +2 -0
- package/dist/agent/web/assets/{index-CZjSxNrg.js → index-C-xi0Vax.js} +34 -34
- package/dist/agent/web/index.html +1 -1
- package/dist/index.js +21 -3
- package/dist/perry-worker +0 -0
- package/dist/session-manager/adapters/claude.js +11 -0
- package/dist/session-manager/adapters/opencode.js +131 -32
- package/dist/session-manager/manager.js +19 -0
- package/dist/sessions/registry.js +129 -0
- package/dist/update-checker.js +5 -5
- package/package.json +1 -1
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
|
6
6
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
7
7
|
<title>Perry</title>
|
|
8
|
-
<script type="module" crossorigin src="/assets/index-
|
|
8
|
+
<script type="module" crossorigin src="/assets/index-C-xi0Vax.js"></script>
|
|
9
9
|
<link rel="stylesheet" crossorigin href="/assets/index-CYo-1I5o.css">
|
|
10
10
|
</head>
|
|
11
11
|
<body>
|
package/dist/index.js
CHANGED
|
@@ -662,9 +662,24 @@ sshCmd
|
|
|
662
662
|
program
|
|
663
663
|
.command('update')
|
|
664
664
|
.description('Update Perry to the latest version')
|
|
665
|
-
.
|
|
665
|
+
.option('-f, --force', 'Force update even if already on latest version')
|
|
666
|
+
.action(async (options) => {
|
|
667
|
+
const { fetchLatestVersion, compareVersions } = await import('./update-checker.js');
|
|
668
|
+
const currentVersion = pkg.version;
|
|
669
|
+
console.log(`Current version: ${currentVersion}`);
|
|
670
|
+
console.log('Checking for updates...');
|
|
671
|
+
const latestVersion = await fetchLatestVersion();
|
|
672
|
+
if (!latestVersion) {
|
|
673
|
+
console.error('Failed to fetch latest version. Please try again later.');
|
|
674
|
+
process.exit(1);
|
|
675
|
+
}
|
|
676
|
+
console.log(`Latest version: ${latestVersion}`);
|
|
677
|
+
if (compareVersions(currentVersion, latestVersion) <= 0 && !options.force) {
|
|
678
|
+
console.log('Already up to date.');
|
|
679
|
+
process.exit(0);
|
|
680
|
+
}
|
|
681
|
+
console.log(`Updating Perry from ${currentVersion} to ${latestVersion}...`);
|
|
666
682
|
const { spawn } = await import('child_process');
|
|
667
|
-
console.log('Updating Perry...');
|
|
668
683
|
const child = spawn('bash', ['-c', 'curl -fsSL https://raw.githubusercontent.com/gricha/perry/main/install.sh | bash'], {
|
|
669
684
|
stdio: 'inherit',
|
|
670
685
|
});
|
|
@@ -756,5 +771,8 @@ function handleError(err) {
|
|
|
756
771
|
}
|
|
757
772
|
process.exit(1);
|
|
758
773
|
}
|
|
759
|
-
|
|
774
|
+
const isWorkerCommand = process.argv[2] === 'worker';
|
|
775
|
+
if (!isWorkerCommand) {
|
|
776
|
+
checkForUpdates(pkg.version);
|
|
777
|
+
}
|
|
760
778
|
program.parse();
|
package/dist/perry-worker
CHANGED
|
Binary file
|
|
@@ -15,6 +15,7 @@ export class ClaudeCodeAdapter {
|
|
|
15
15
|
errorCallback;
|
|
16
16
|
pendingMessage = null;
|
|
17
17
|
messageResolver = null;
|
|
18
|
+
currentMessageId;
|
|
18
19
|
onMessage(callback) {
|
|
19
20
|
this.messageCallback = callback;
|
|
20
21
|
}
|
|
@@ -154,6 +155,11 @@ export class ClaudeCodeAdapter {
|
|
|
154
155
|
return;
|
|
155
156
|
}
|
|
156
157
|
if (msg.type === 'assistant' && msg.message?.content) {
|
|
158
|
+
// Capture message ID from upstream when assistant message starts
|
|
159
|
+
const messageId = msg.message.id || msg.id;
|
|
160
|
+
if (messageId) {
|
|
161
|
+
this.currentMessageId = messageId;
|
|
162
|
+
}
|
|
157
163
|
for (const block of msg.message.content) {
|
|
158
164
|
if (block.type === 'tool_use') {
|
|
159
165
|
this.emitMessage({
|
|
@@ -161,6 +167,7 @@ export class ClaudeCodeAdapter {
|
|
|
161
167
|
content: JSON.stringify(block.input, null, 2),
|
|
162
168
|
toolName: block.name,
|
|
163
169
|
toolId: block.id,
|
|
170
|
+
messageId: this.currentMessageId,
|
|
164
171
|
timestamp,
|
|
165
172
|
});
|
|
166
173
|
}
|
|
@@ -173,6 +180,7 @@ export class ClaudeCodeAdapter {
|
|
|
173
180
|
this.emitMessage({
|
|
174
181
|
type: 'assistant',
|
|
175
182
|
content: delta.text,
|
|
183
|
+
messageId: this.currentMessageId,
|
|
176
184
|
timestamp,
|
|
177
185
|
});
|
|
178
186
|
}
|
|
@@ -182,13 +190,16 @@ export class ClaudeCodeAdapter {
|
|
|
182
190
|
this.emitMessage({
|
|
183
191
|
type: 'done',
|
|
184
192
|
content: 'Response complete',
|
|
193
|
+
messageId: this.currentMessageId,
|
|
185
194
|
timestamp,
|
|
186
195
|
});
|
|
196
|
+
this.currentMessageId = undefined;
|
|
187
197
|
}
|
|
188
198
|
}
|
|
189
199
|
handleProcessExit(code) {
|
|
190
200
|
this.process = null;
|
|
191
201
|
this.terminal = null;
|
|
202
|
+
this.currentMessageId = undefined;
|
|
192
203
|
if (this.status === 'interrupted') {
|
|
193
204
|
this.emitMessage({
|
|
194
205
|
type: 'system',
|
|
@@ -3,6 +3,9 @@ const MESSAGE_TIMEOUT_MS = 30000;
|
|
|
3
3
|
const SSE_TIMEOUT_MS = 120000;
|
|
4
4
|
const serverPorts = new Map();
|
|
5
5
|
const serverStarting = new Map();
|
|
6
|
+
let hostServerPort = null;
|
|
7
|
+
let hostServerStarting = null;
|
|
8
|
+
let hostServerProcess = null;
|
|
6
9
|
async function findAvailablePort(containerName) {
|
|
7
10
|
const script = `import socket; s=socket.socket(); s.bind(('', 0)); print(s.getsockname()[1]); s.close()`;
|
|
8
11
|
const result = await execInContainer(containerName, ['python3', '-c', script], {
|
|
@@ -70,7 +73,9 @@ export class OpenCodeAdapter {
|
|
|
70
73
|
model;
|
|
71
74
|
status = 'idle';
|
|
72
75
|
port;
|
|
76
|
+
isHost = false;
|
|
73
77
|
sseProcess = null;
|
|
78
|
+
currentMessageId;
|
|
74
79
|
messageCallback;
|
|
75
80
|
statusCallback;
|
|
76
81
|
errorCallback;
|
|
@@ -84,14 +89,17 @@ export class OpenCodeAdapter {
|
|
|
84
89
|
this.errorCallback = callback;
|
|
85
90
|
}
|
|
86
91
|
async start(options) {
|
|
87
|
-
|
|
88
|
-
throw new Error('OpenCode adapter does not support host mode');
|
|
89
|
-
}
|
|
92
|
+
this.isHost = options.isHost;
|
|
90
93
|
this.containerName = options.containerName;
|
|
91
94
|
this.agentSessionId = options.agentSessionId;
|
|
92
95
|
this.model = options.model;
|
|
93
96
|
try {
|
|
94
|
-
|
|
97
|
+
if (this.isHost) {
|
|
98
|
+
this.port = await this.startServerHost();
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
this.port = await startServer(this.containerName);
|
|
102
|
+
}
|
|
95
103
|
this.setStatus('idle');
|
|
96
104
|
}
|
|
97
105
|
catch (err) {
|
|
@@ -99,8 +107,61 @@ export class OpenCodeAdapter {
|
|
|
99
107
|
throw err;
|
|
100
108
|
}
|
|
101
109
|
}
|
|
110
|
+
async startServerHost() {
|
|
111
|
+
if (hostServerPort && (await this.isServerRunningHost(hostServerPort))) {
|
|
112
|
+
return hostServerPort;
|
|
113
|
+
}
|
|
114
|
+
if (hostServerStarting) {
|
|
115
|
+
return hostServerStarting;
|
|
116
|
+
}
|
|
117
|
+
const startPromise = (async () => {
|
|
118
|
+
const port = await this.findAvailablePortHost();
|
|
119
|
+
console.log(`[opencode] Starting server on port ${port} on host`);
|
|
120
|
+
hostServerProcess = Bun.spawn(['opencode', 'serve', '--port', String(port), '--hostname', '127.0.0.1'], {
|
|
121
|
+
stdin: 'ignore',
|
|
122
|
+
stdout: 'pipe',
|
|
123
|
+
stderr: 'pipe',
|
|
124
|
+
});
|
|
125
|
+
for (let i = 0; i < 30; i++) {
|
|
126
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
127
|
+
if (await this.isServerRunningHost(port)) {
|
|
128
|
+
console.log(`[opencode] Server ready on port ${port}`);
|
|
129
|
+
hostServerPort = port;
|
|
130
|
+
hostServerStarting = null;
|
|
131
|
+
return port;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
hostServerStarting = null;
|
|
135
|
+
if (hostServerProcess) {
|
|
136
|
+
hostServerProcess.kill();
|
|
137
|
+
await hostServerProcess.exited;
|
|
138
|
+
hostServerProcess = null;
|
|
139
|
+
}
|
|
140
|
+
throw new Error('Failed to start OpenCode server on host');
|
|
141
|
+
})();
|
|
142
|
+
hostServerStarting = startPromise;
|
|
143
|
+
return startPromise;
|
|
144
|
+
}
|
|
145
|
+
async findAvailablePortHost() {
|
|
146
|
+
const server = Bun.serve({
|
|
147
|
+
port: 0,
|
|
148
|
+
fetch: () => new Response(''),
|
|
149
|
+
});
|
|
150
|
+
const port = server.port;
|
|
151
|
+
server.stop();
|
|
152
|
+
return port;
|
|
153
|
+
}
|
|
154
|
+
async isServerRunningHost(port) {
|
|
155
|
+
try {
|
|
156
|
+
const response = await fetch(`http://localhost:${port}/session`, { method: 'GET' });
|
|
157
|
+
return response.ok;
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
102
163
|
async sendMessage(message) {
|
|
103
|
-
if (!this.
|
|
164
|
+
if (!this.port) {
|
|
104
165
|
const err = new Error('Adapter not started');
|
|
105
166
|
this.emitError(err);
|
|
106
167
|
throw err;
|
|
@@ -120,10 +181,12 @@ export class OpenCodeAdapter {
|
|
|
120
181
|
this.emit({ type: 'system', content: 'Processing...' });
|
|
121
182
|
await this.sendAndStream(baseUrl, message);
|
|
122
183
|
this.setStatus('idle');
|
|
123
|
-
this.emit({ type: 'done', content: 'Response complete' });
|
|
184
|
+
this.emit({ type: 'done', content: 'Response complete', messageId: this.currentMessageId });
|
|
185
|
+
this.currentMessageId = undefined;
|
|
124
186
|
}
|
|
125
187
|
catch (err) {
|
|
126
188
|
this.cleanup();
|
|
189
|
+
this.currentMessageId = undefined;
|
|
127
190
|
this.setStatus('error');
|
|
128
191
|
this.emitError(err);
|
|
129
192
|
this.emit({ type: 'error', content: err.message });
|
|
@@ -131,7 +194,20 @@ export class OpenCodeAdapter {
|
|
|
131
194
|
}
|
|
132
195
|
}
|
|
133
196
|
async createSession(baseUrl) {
|
|
134
|
-
const payload = this.model ?
|
|
197
|
+
const payload = this.model ? { model: this.model } : {};
|
|
198
|
+
if (this.isHost) {
|
|
199
|
+
const response = await fetch(`${baseUrl}/session`, {
|
|
200
|
+
method: 'POST',
|
|
201
|
+
headers: { 'Content-Type': 'application/json' },
|
|
202
|
+
body: JSON.stringify(payload),
|
|
203
|
+
signal: AbortSignal.timeout(MESSAGE_TIMEOUT_MS),
|
|
204
|
+
});
|
|
205
|
+
if (!response.ok) {
|
|
206
|
+
throw new Error(`Failed to create session: ${response.statusText}`);
|
|
207
|
+
}
|
|
208
|
+
const session = await response.json();
|
|
209
|
+
return session.id;
|
|
210
|
+
}
|
|
135
211
|
const result = await execInContainer(this.containerName, [
|
|
136
212
|
'curl',
|
|
137
213
|
'-s',
|
|
@@ -144,7 +220,7 @@ export class OpenCodeAdapter {
|
|
|
144
220
|
'-H',
|
|
145
221
|
'Content-Type: application/json',
|
|
146
222
|
'-d',
|
|
147
|
-
payload,
|
|
223
|
+
JSON.stringify(payload),
|
|
148
224
|
], { user: 'workspace' });
|
|
149
225
|
if (result.exitCode !== 0) {
|
|
150
226
|
throw new Error(`Failed to create session: ${result.stderr || 'Unknown error'}`);
|
|
@@ -155,23 +231,36 @@ export class OpenCodeAdapter {
|
|
|
155
231
|
async sendAndStream(baseUrl, message) {
|
|
156
232
|
const sseReady = this.startSSEStream();
|
|
157
233
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
158
|
-
const payload =
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
234
|
+
const payload = { parts: [{ type: 'text', text: message }] };
|
|
235
|
+
if (this.isHost) {
|
|
236
|
+
const response = await fetch(`${baseUrl}/session/${this.agentSessionId}/message`, {
|
|
237
|
+
method: 'POST',
|
|
238
|
+
headers: { 'Content-Type': 'application/json' },
|
|
239
|
+
body: JSON.stringify(payload),
|
|
240
|
+
signal: AbortSignal.timeout(MESSAGE_TIMEOUT_MS),
|
|
241
|
+
});
|
|
242
|
+
if (!response.ok) {
|
|
243
|
+
throw new Error(`Failed to send message: ${response.statusText}`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
else {
|
|
247
|
+
const result = await execInContainer(this.containerName, [
|
|
248
|
+
'curl',
|
|
249
|
+
'-s',
|
|
250
|
+
'-f',
|
|
251
|
+
'--max-time',
|
|
252
|
+
String(MESSAGE_TIMEOUT_MS / 1000),
|
|
253
|
+
'-X',
|
|
254
|
+
'POST',
|
|
255
|
+
`${baseUrl}/session/${this.agentSessionId}/message`,
|
|
256
|
+
'-H',
|
|
257
|
+
'Content-Type: application/json',
|
|
258
|
+
'-d',
|
|
259
|
+
JSON.stringify(payload),
|
|
260
|
+
], { user: 'workspace' });
|
|
261
|
+
if (result.exitCode !== 0) {
|
|
262
|
+
throw new Error(`Failed to send message: ${result.stderr || 'Connection failed'}`);
|
|
263
|
+
}
|
|
175
264
|
}
|
|
176
265
|
await sseReady;
|
|
177
266
|
}
|
|
@@ -180,18 +269,18 @@ export class OpenCodeAdapter {
|
|
|
180
269
|
const seenTools = new Set();
|
|
181
270
|
let resolved = false;
|
|
182
271
|
let receivedIdle = false;
|
|
183
|
-
const
|
|
184
|
-
'docker',
|
|
185
|
-
'exec',
|
|
186
|
-
'-i',
|
|
187
|
-
this.containerName,
|
|
272
|
+
const curlArgs = [
|
|
188
273
|
'curl',
|
|
189
274
|
'-s',
|
|
190
275
|
'-N',
|
|
191
276
|
'--max-time',
|
|
192
277
|
String(SSE_TIMEOUT_MS / 1000),
|
|
193
278
|
`http://localhost:${this.port}/event`,
|
|
194
|
-
]
|
|
279
|
+
];
|
|
280
|
+
const spawnArgs = this.isHost
|
|
281
|
+
? curlArgs
|
|
282
|
+
: ['docker', 'exec', '-i', this.containerName, ...curlArgs];
|
|
283
|
+
const proc = Bun.spawn(spawnArgs, { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' });
|
|
195
284
|
this.sseProcess = proc;
|
|
196
285
|
const decoder = new TextDecoder();
|
|
197
286
|
let buffer = '';
|
|
@@ -236,8 +325,15 @@ export class OpenCodeAdapter {
|
|
|
236
325
|
}
|
|
237
326
|
if (event.type === 'message.part.updated' && event.properties.part) {
|
|
238
327
|
const part = event.properties.part;
|
|
328
|
+
if (part.messageID) {
|
|
329
|
+
this.currentMessageId = part.messageID;
|
|
330
|
+
}
|
|
239
331
|
if (part.type === 'text' && event.properties.delta) {
|
|
240
|
-
this.emit({
|
|
332
|
+
this.emit({
|
|
333
|
+
type: 'assistant',
|
|
334
|
+
content: event.properties.delta,
|
|
335
|
+
messageId: this.currentMessageId,
|
|
336
|
+
});
|
|
241
337
|
}
|
|
242
338
|
else if (part.type === 'tool' && part.tool && !seenTools.has(part.id)) {
|
|
243
339
|
seenTools.add(part.id);
|
|
@@ -246,12 +342,14 @@ export class OpenCodeAdapter {
|
|
|
246
342
|
content: JSON.stringify(part.state?.input, null, 2),
|
|
247
343
|
toolName: part.state?.title || part.tool,
|
|
248
344
|
toolId: part.id,
|
|
345
|
+
messageId: this.currentMessageId,
|
|
249
346
|
});
|
|
250
347
|
if (part.state?.status === 'completed' && part.state?.output) {
|
|
251
348
|
this.emit({
|
|
252
349
|
type: 'tool_result',
|
|
253
350
|
content: part.state.output,
|
|
254
351
|
toolId: part.id,
|
|
352
|
+
messageId: this.currentMessageId,
|
|
255
353
|
});
|
|
256
354
|
}
|
|
257
355
|
}
|
|
@@ -290,6 +388,7 @@ export class OpenCodeAdapter {
|
|
|
290
388
|
}
|
|
291
389
|
async interrupt() {
|
|
292
390
|
this.cleanup();
|
|
391
|
+
this.currentMessageId = undefined;
|
|
293
392
|
if (this.status === 'running') {
|
|
294
393
|
this.setStatus('interrupted');
|
|
295
394
|
this.emit({ type: 'system', content: 'Interrupted' });
|
|
@@ -3,10 +3,15 @@ import { ClaudeCodeAdapter } from './adapters/claude';
|
|
|
3
3
|
import { OpenCodeAdapter } from './adapters/opencode';
|
|
4
4
|
import { getContainerName } from '../docker';
|
|
5
5
|
import { HOST_WORKSPACE_NAME } from '../shared/client-types';
|
|
6
|
+
import * as registry from '../sessions/registry';
|
|
6
7
|
const DEFAULT_BUFFER_SIZE = 1000;
|
|
7
8
|
export class SessionManager {
|
|
8
9
|
sessions = new Map();
|
|
9
10
|
clientIdCounter = 0;
|
|
11
|
+
stateDir = null;
|
|
12
|
+
init(stateDir) {
|
|
13
|
+
this.stateDir = stateDir;
|
|
14
|
+
}
|
|
10
15
|
generateSessionId() {
|
|
11
16
|
return `session-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
|
12
17
|
}
|
|
@@ -69,6 +74,15 @@ export class SessionManager {
|
|
|
69
74
|
isHost,
|
|
70
75
|
});
|
|
71
76
|
this.sessions.set(sessionId, session);
|
|
77
|
+
if (this.stateDir) {
|
|
78
|
+
await registry.createSession(this.stateDir, {
|
|
79
|
+
perrySessionId: sessionId,
|
|
80
|
+
workspaceName: options.workspaceName,
|
|
81
|
+
agentType: options.agentType,
|
|
82
|
+
agentSessionId: options.agentSessionId ?? null,
|
|
83
|
+
projectPath: options.projectPath ?? null,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
72
86
|
return sessionId;
|
|
73
87
|
}
|
|
74
88
|
handleAdapterMessage(sessionId, message) {
|
|
@@ -101,6 +115,11 @@ export class SessionManager {
|
|
|
101
115
|
const currentAgentSessionId = session.adapter.getAgentSessionId();
|
|
102
116
|
if (currentAgentSessionId !== undefined && previousAgentSessionId !== currentAgentSessionId) {
|
|
103
117
|
session.info.agentSessionId = currentAgentSessionId;
|
|
118
|
+
if (this.stateDir) {
|
|
119
|
+
registry.linkAgentSession(this.stateDir, sessionId, currentAgentSessionId).catch((err) => {
|
|
120
|
+
this.handleAdapterError(sessionId, new Error(`Failed to link agent session: ${err.message}`));
|
|
121
|
+
});
|
|
122
|
+
}
|
|
104
123
|
const updateMessage = {
|
|
105
124
|
type: 'system',
|
|
106
125
|
content: JSON.stringify({ agentSessionId: currentAgentSessionId }),
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { readFile, writeFile, mkdir } from 'fs/promises';
|
|
2
|
+
import { join, dirname } from 'path';
|
|
3
|
+
import lockfile from 'proper-lockfile';
|
|
4
|
+
function getStorePath(stateDir) {
|
|
5
|
+
return join(stateDir, 'session-registry.json');
|
|
6
|
+
}
|
|
7
|
+
function getLockPath(stateDir) {
|
|
8
|
+
return join(stateDir, '.session-registry.lock');
|
|
9
|
+
}
|
|
10
|
+
async function ensureLockfile(stateDir) {
|
|
11
|
+
const lockPath = getLockPath(stateDir);
|
|
12
|
+
try {
|
|
13
|
+
await mkdir(dirname(lockPath), { recursive: true });
|
|
14
|
+
await writeFile(lockPath, '', { flag: 'wx' });
|
|
15
|
+
}
|
|
16
|
+
catch (err) {
|
|
17
|
+
if (err.code !== 'EEXIST') {
|
|
18
|
+
throw err;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
async function withLock(stateDir, fn) {
|
|
23
|
+
await ensureLockfile(stateDir);
|
|
24
|
+
const lockPath = getLockPath(stateDir);
|
|
25
|
+
let release;
|
|
26
|
+
try {
|
|
27
|
+
release = await lockfile.lock(lockPath, {
|
|
28
|
+
retries: { retries: 10, minTimeout: 50, maxTimeout: 500 },
|
|
29
|
+
});
|
|
30
|
+
return await fn();
|
|
31
|
+
}
|
|
32
|
+
finally {
|
|
33
|
+
if (release) {
|
|
34
|
+
await release();
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
async function loadRegistry(stateDir) {
|
|
39
|
+
const storePath = getStorePath(stateDir);
|
|
40
|
+
try {
|
|
41
|
+
const content = await readFile(storePath, 'utf-8');
|
|
42
|
+
return JSON.parse(content);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return { version: 1, sessions: {} };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
async function saveRegistry(stateDir, registry) {
|
|
49
|
+
const storePath = getStorePath(stateDir);
|
|
50
|
+
await mkdir(dirname(storePath), { recursive: true });
|
|
51
|
+
await writeFile(storePath, JSON.stringify(registry, null, 2));
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Create a new session record. Called when first message is sent.
|
|
55
|
+
* agentSessionId will be null until agent responds.
|
|
56
|
+
*/
|
|
57
|
+
export async function createSession(stateDir, session) {
|
|
58
|
+
return withLock(stateDir, async () => {
|
|
59
|
+
const registry = await loadRegistry(stateDir);
|
|
60
|
+
const now = new Date().toISOString();
|
|
61
|
+
const record = {
|
|
62
|
+
perrySessionId: session.perrySessionId,
|
|
63
|
+
workspaceName: session.workspaceName,
|
|
64
|
+
agentType: session.agentType,
|
|
65
|
+
agentSessionId: session.agentSessionId ?? null,
|
|
66
|
+
projectPath: session.projectPath ?? null,
|
|
67
|
+
createdAt: now,
|
|
68
|
+
lastActivity: now,
|
|
69
|
+
};
|
|
70
|
+
registry.sessions[session.perrySessionId] = record;
|
|
71
|
+
await saveRegistry(stateDir, registry);
|
|
72
|
+
return record;
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Link an agent session ID to an existing Perry session.
|
|
77
|
+
* Called when agent responds and provides its session ID.
|
|
78
|
+
*/
|
|
79
|
+
export async function linkAgentSession(stateDir, perrySessionId, agentSessionId) {
|
|
80
|
+
return withLock(stateDir, async () => {
|
|
81
|
+
const registry = await loadRegistry(stateDir);
|
|
82
|
+
const record = registry.sessions[perrySessionId];
|
|
83
|
+
if (!record) {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
record.agentSessionId = agentSessionId;
|
|
87
|
+
record.lastActivity = new Date().toISOString();
|
|
88
|
+
await saveRegistry(stateDir, registry);
|
|
89
|
+
return record;
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Get all sessions for a workspace.
|
|
94
|
+
*/
|
|
95
|
+
export async function getSessionsForWorkspace(stateDir, workspaceName) {
|
|
96
|
+
const registry = await loadRegistry(stateDir);
|
|
97
|
+
return Object.values(registry.sessions)
|
|
98
|
+
.filter((record) => record.workspaceName === workspaceName)
|
|
99
|
+
.sort((a, b) => new Date(b.lastActivity).getTime() - new Date(a.lastActivity).getTime());
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Import an external session (discovered from agent storage).
|
|
103
|
+
* Creates a Perry session record for a session that wasn't started through Perry.
|
|
104
|
+
*/
|
|
105
|
+
export async function importExternalSession(stateDir, session) {
|
|
106
|
+
return withLock(stateDir, async () => {
|
|
107
|
+
const registry = await loadRegistry(stateDir);
|
|
108
|
+
// Check if already imported (inside lock to prevent race)
|
|
109
|
+
for (const record of Object.values(registry.sessions)) {
|
|
110
|
+
if (record.agentType === session.agentType &&
|
|
111
|
+
record.agentSessionId === session.agentSessionId) {
|
|
112
|
+
return record;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
const now = new Date().toISOString();
|
|
116
|
+
const record = {
|
|
117
|
+
perrySessionId: session.perrySessionId,
|
|
118
|
+
workspaceName: session.workspaceName,
|
|
119
|
+
agentType: session.agentType,
|
|
120
|
+
agentSessionId: session.agentSessionId,
|
|
121
|
+
projectPath: session.projectPath ?? null,
|
|
122
|
+
createdAt: session.createdAt ?? now,
|
|
123
|
+
lastActivity: session.lastActivity ?? now,
|
|
124
|
+
};
|
|
125
|
+
registry.sessions[session.perrySessionId] = record;
|
|
126
|
+
await saveRegistry(stateDir, registry);
|
|
127
|
+
return record;
|
|
128
|
+
});
|
|
129
|
+
}
|
package/dist/update-checker.js
CHANGED
|
@@ -27,7 +27,7 @@ async function writeCache(cache) {
|
|
|
27
27
|
// Ignore cache write errors
|
|
28
28
|
}
|
|
29
29
|
}
|
|
30
|
-
async function fetchLatestVersion() {
|
|
30
|
+
export async function fetchLatestVersion() {
|
|
31
31
|
try {
|
|
32
32
|
const response = await fetch(`https://api.github.com/repos/${GITHUB_REPO}/releases/latest`, {
|
|
33
33
|
signal: AbortSignal.timeout(3000),
|
|
@@ -46,7 +46,7 @@ async function fetchLatestVersion() {
|
|
|
46
46
|
return null;
|
|
47
47
|
}
|
|
48
48
|
}
|
|
49
|
-
function compareVersions(current, latest) {
|
|
49
|
+
export function compareVersions(current, latest) {
|
|
50
50
|
const currentParts = current.split('.').map(Number);
|
|
51
51
|
const latestParts = latest.split('.').map(Number);
|
|
52
52
|
for (let i = 0; i < Math.max(currentParts.length, latestParts.length); i++) {
|
|
@@ -72,9 +72,9 @@ export async function checkForUpdates(currentVersion) {
|
|
|
72
72
|
await writeCache({ lastCheck: now, latestVersion });
|
|
73
73
|
}
|
|
74
74
|
if (latestVersion && compareVersions(currentVersion, latestVersion) > 0) {
|
|
75
|
-
console.
|
|
76
|
-
console.
|
|
77
|
-
console.
|
|
75
|
+
console.error('');
|
|
76
|
+
console.error(`\x1b[33mUpdate available: \x1b[90m${currentVersion}\x1b[0m → \x1b[32m${latestVersion}\x1b[0m \x1b[33mRun: \x1b[36mperry update\x1b[0m`);
|
|
77
|
+
console.error('');
|
|
78
78
|
}
|
|
79
79
|
}
|
|
80
80
|
catch {
|