@ogulcancelik/pi-spar 0.1.3 → 0.1.5
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 +1 -1
- package/core.ts +125 -16
- package/index.ts +41 -72
- package/package.json +1 -1
- package/peek.ts +21 -15
package/README.md
CHANGED
|
@@ -26,7 +26,7 @@ The extension provides a `spar` tool the agent can use, plus commands for viewin
|
|
|
26
26
|
|
|
27
27
|
### Tool: `spar`
|
|
28
28
|
|
|
29
|
-
The agent uses this
|
|
29
|
+
The agent uses this when you ask it to consult another model:
|
|
30
30
|
|
|
31
31
|
```
|
|
32
32
|
"spar with gpt about whether this architecture makes sense"
|
package/core.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { spawn } from "child_process";
|
|
8
|
+
import { randomUUID } from "crypto";
|
|
8
9
|
import * as readline from "readline";
|
|
9
10
|
import * as path from "path";
|
|
10
11
|
import * as fs from "fs";
|
|
@@ -17,7 +18,7 @@ import * as os from "os";
|
|
|
17
18
|
|
|
18
19
|
// Session storage in pi's config directory (persistent across reboots)
|
|
19
20
|
const SPAR_DIR = path.join(os.homedir(), ".pi", "agent", "spar");
|
|
20
|
-
const SESSION_DIR = path.join(SPAR_DIR, "sessions");
|
|
21
|
+
export const SESSION_DIR = path.join(SPAR_DIR, "sessions");
|
|
21
22
|
const CONFIG_PATH = path.join(SPAR_DIR, "config.json");
|
|
22
23
|
|
|
23
24
|
// Default timeout: 30 minutes (sliding - resets on activity)
|
|
@@ -144,10 +145,83 @@ function getSessionLogPath(sessionId: string): string {
|
|
|
144
145
|
return path.join(SESSION_DIR, `${sessionId}.log`);
|
|
145
146
|
}
|
|
146
147
|
|
|
147
|
-
|
|
148
|
+
export interface PeekMarker {
|
|
149
|
+
pid: number;
|
|
150
|
+
startedAt: number;
|
|
151
|
+
token: string;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function getSocketPath(sessionId: string): string {
|
|
155
|
+
if (process.platform === "win32") {
|
|
156
|
+
return `\\\\.\\pipe\\pi-spar-${sessionId}`;
|
|
157
|
+
}
|
|
148
158
|
return `/tmp/pi-spar-${sessionId}.sock`;
|
|
149
159
|
}
|
|
150
160
|
|
|
161
|
+
export function getPeekMarkerPath(sessionId: string): string {
|
|
162
|
+
return path.join(SESSION_DIR, `${sessionId}.peek.json`);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function readPeekMarker(sessionId: string): PeekMarker | null | undefined {
|
|
166
|
+
const markerPath = getPeekMarkerPath(sessionId);
|
|
167
|
+
if (!fs.existsSync(markerPath)) return undefined;
|
|
168
|
+
|
|
169
|
+
try {
|
|
170
|
+
const marker = JSON.parse(fs.readFileSync(markerPath, "utf-8"));
|
|
171
|
+
if (
|
|
172
|
+
typeof marker?.pid === "number" &&
|
|
173
|
+
typeof marker?.startedAt === "number" &&
|
|
174
|
+
typeof marker?.token === "string"
|
|
175
|
+
) {
|
|
176
|
+
return marker;
|
|
177
|
+
}
|
|
178
|
+
} catch {}
|
|
179
|
+
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function isProcessAlive(pid: number): boolean {
|
|
184
|
+
try {
|
|
185
|
+
process.kill(pid, 0);
|
|
186
|
+
return true;
|
|
187
|
+
} catch (error: any) {
|
|
188
|
+
return error?.code === "EPERM";
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function markPeekActive(sessionId: string, marker: PeekMarker): void {
|
|
193
|
+
ensureSessionDir();
|
|
194
|
+
fs.writeFileSync(getPeekMarkerPath(sessionId), JSON.stringify(marker, null, 2));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function clearPeekActive(sessionId: string, owner?: Pick<PeekMarker, "pid" | "token">): void {
|
|
198
|
+
try {
|
|
199
|
+
if (owner) {
|
|
200
|
+
const currentMarker = readPeekMarker(sessionId);
|
|
201
|
+
if (!currentMarker) return;
|
|
202
|
+
if (currentMarker.pid !== owner.pid || currentMarker.token !== owner.token) {
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
fs.unlinkSync(getPeekMarkerPath(sessionId));
|
|
207
|
+
} catch {}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function isPeekActive(sessionId: string): boolean {
|
|
211
|
+
const marker = readPeekMarker(sessionId);
|
|
212
|
+
if (marker === undefined) {
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
if (marker === null) {
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
if (isProcessAlive(marker.pid)) {
|
|
219
|
+
return true;
|
|
220
|
+
}
|
|
221
|
+
clearPeekActive(sessionId, { pid: marker.pid, token: marker.token });
|
|
222
|
+
return false;
|
|
223
|
+
}
|
|
224
|
+
|
|
151
225
|
// =============================================================================
|
|
152
226
|
// Session Logger
|
|
153
227
|
// =============================================================================
|
|
@@ -217,7 +291,9 @@ class SessionLogger {
|
|
|
217
291
|
class EventBroadcaster {
|
|
218
292
|
private server: net.Server | null = null;
|
|
219
293
|
private connections: net.Socket[] = [];
|
|
294
|
+
private sessionId: string;
|
|
220
295
|
private socketPath: string;
|
|
296
|
+
private marker: PeekMarker;
|
|
221
297
|
|
|
222
298
|
// Track state for sync on connect
|
|
223
299
|
private currentStatus: "thinking" | "streaming" | "tool" | "done" = "thinking";
|
|
@@ -226,15 +302,23 @@ class EventBroadcaster {
|
|
|
226
302
|
private currentUserMessage: any = null;
|
|
227
303
|
|
|
228
304
|
constructor(sessionId: string) {
|
|
305
|
+
this.sessionId = sessionId;
|
|
229
306
|
this.socketPath = getSocketPath(sessionId);
|
|
307
|
+
this.marker = {
|
|
308
|
+
pid: process.pid,
|
|
309
|
+
startedAt: Date.now(),
|
|
310
|
+
token: randomUUID(),
|
|
311
|
+
};
|
|
230
312
|
}
|
|
231
313
|
|
|
232
314
|
start(): void {
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
fs.
|
|
236
|
-
|
|
237
|
-
|
|
315
|
+
if (process.platform !== "win32") {
|
|
316
|
+
try {
|
|
317
|
+
if (fs.existsSync(this.socketPath)) {
|
|
318
|
+
fs.unlinkSync(this.socketPath);
|
|
319
|
+
}
|
|
320
|
+
} catch {}
|
|
321
|
+
}
|
|
238
322
|
|
|
239
323
|
this.server = net.createServer((conn) => {
|
|
240
324
|
this.connections.push(conn);
|
|
@@ -259,7 +343,26 @@ class EventBroadcaster {
|
|
|
259
343
|
});
|
|
260
344
|
});
|
|
261
345
|
|
|
262
|
-
this.server.
|
|
346
|
+
this.server.on("listening", () => {
|
|
347
|
+
markPeekActive(this.sessionId, this.marker);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
this.server.on("error", () => {
|
|
351
|
+
clearPeekActive(this.sessionId, this.marker);
|
|
352
|
+
for (const conn of this.connections) {
|
|
353
|
+
try { conn.destroy(); } catch {}
|
|
354
|
+
}
|
|
355
|
+
this.connections = [];
|
|
356
|
+
this.server?.close();
|
|
357
|
+
this.server = null;
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
try {
|
|
361
|
+
this.server.listen(this.socketPath);
|
|
362
|
+
} catch {
|
|
363
|
+
clearPeekActive(this.sessionId, this.marker);
|
|
364
|
+
this.server = null;
|
|
365
|
+
}
|
|
263
366
|
}
|
|
264
367
|
|
|
265
368
|
broadcast(event: any): void {
|
|
@@ -295,6 +398,8 @@ class EventBroadcaster {
|
|
|
295
398
|
}
|
|
296
399
|
|
|
297
400
|
stop(): void {
|
|
401
|
+
clearPeekActive(this.sessionId, this.marker);
|
|
402
|
+
|
|
298
403
|
for (const conn of this.connections) {
|
|
299
404
|
try { conn.end(); } catch {}
|
|
300
405
|
}
|
|
@@ -305,11 +410,13 @@ class EventBroadcaster {
|
|
|
305
410
|
this.server = null;
|
|
306
411
|
}
|
|
307
412
|
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
fs.
|
|
311
|
-
|
|
312
|
-
|
|
413
|
+
if (process.platform !== "win32") {
|
|
414
|
+
try {
|
|
415
|
+
if (fs.existsSync(this.socketPath)) {
|
|
416
|
+
fs.unlinkSync(this.socketPath);
|
|
417
|
+
}
|
|
418
|
+
} catch {}
|
|
419
|
+
}
|
|
313
420
|
}
|
|
314
421
|
}
|
|
315
422
|
|
|
@@ -461,9 +568,11 @@ export function deleteSession(name: string): void {
|
|
|
461
568
|
for (const f of files) {
|
|
462
569
|
try { if (fs.existsSync(f)) fs.unlinkSync(f); } catch {}
|
|
463
570
|
}
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
571
|
+
clearPeekActive(name);
|
|
572
|
+
if (process.platform !== "win32") {
|
|
573
|
+
const socketPath = getSocketPath(name);
|
|
574
|
+
try { if (fs.existsSync(socketPath)) fs.unlinkSync(socketPath); } catch {}
|
|
575
|
+
}
|
|
467
576
|
}
|
|
468
577
|
|
|
469
578
|
/**
|
package/index.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Spar Extension - Agent-to-agent sparring
|
|
3
|
-
*
|
|
3
|
+
*
|
|
4
4
|
* Provides a `spar` tool for back-and-forth dialogue with peer AI models,
|
|
5
5
|
* plus /spar and /spview commands for viewing spar sessions.
|
|
6
6
|
*/
|
|
@@ -56,19 +56,17 @@ export default function (pi: ExtensionAPI) {
|
|
|
56
56
|
name: "spar",
|
|
57
57
|
label: "Spar",
|
|
58
58
|
get description() {
|
|
59
|
-
return `Spar with another AI model —
|
|
59
|
+
return `Spar with another AI model — a conversation, not a lookup.
|
|
60
60
|
|
|
61
|
-
Use
|
|
62
|
-
Sessions persist, so follow up, push back, disagree. If they raise a point you hadn't
|
|
63
|
-
considered, dig into it. If you disagree with something, counter it. Don't just take the
|
|
64
|
-
first response and run — that's querying, not sparring.
|
|
61
|
+
Use only when the user asks, or when you have a clear user-visible reason.
|
|
65
62
|
|
|
66
|
-
|
|
67
|
-
No bash, no web access, no network, no file writes. Don't ask them to look things up online
|
|
68
|
-
or run commands — they can't. Give them file paths and let them dig through code.
|
|
63
|
+
Prefer a different model family when possible.
|
|
69
64
|
|
|
70
|
-
**
|
|
71
|
-
|
|
65
|
+
**Peer limitations:** read files, grep, find, and ls only. No bash, web, network, or file writes.
|
|
66
|
+
|
|
67
|
+
**Important:**
|
|
68
|
+
- if they raise a point you hadn't considered, dig into it. If you disagree, counter it. Don't take the first response and run.
|
|
69
|
+
- Give file paths and pointers, not full content.
|
|
72
70
|
|
|
73
71
|
**Configured models:**
|
|
74
72
|
${getConfiguredModelsDescription()}
|
|
@@ -76,35 +74,7 @@ ${getConfiguredModelsDescription()}
|
|
|
76
74
|
**Actions:**
|
|
77
75
|
- \`send\` - Send a message to a spar session (creates session if needed)
|
|
78
76
|
- \`list\` - List existing spar sessions
|
|
79
|
-
- \`history\` - View past exchanges from a session (default: last 5)
|
|
80
|
-
|
|
81
|
-
**Tips:**
|
|
82
|
-
- Give file paths and pointers, not full content — let them explore
|
|
83
|
-
- Ask for ranked hypotheses, not just "what do you think"
|
|
84
|
-
- Request critique: "What's the strongest case against my approach?"
|
|
85
|
-
- State your current position so they have something to push against
|
|
86
|
-
|
|
87
|
-
**Multi-party facilitation:** For big design questions, create multiple specialized sessions
|
|
88
|
-
with different models/roles. Name them \`{topic}-{role}\` (e.g., \`auth-design\`, \`auth-security\`).
|
|
89
|
-
Give each a focused persona in the first message. Then facilitate: forward interesting points
|
|
90
|
-
between them, let them argue through you, decide who to ask next based on the conversation.
|
|
91
|
-
You're the switchboard operator — each expert is in their own room, you relay selectively.
|
|
92
|
-
|
|
93
|
-
**Example:**
|
|
94
|
-
\`\`\`
|
|
95
|
-
spar({
|
|
96
|
-
action: "send",
|
|
97
|
-
session: "flow-field-debug",
|
|
98
|
-
model: "opus",
|
|
99
|
-
message: "I'm debugging flow field pathfinding. Enemies walk away from player instead of toward. Check scripts/HordeManagerCS.cs line 358-430 for the BFS implementation. I think the gradient is inverted in the BFS neighbor loop — what do you see?"
|
|
100
|
-
})
|
|
101
|
-
// ... read their response, then follow up:
|
|
102
|
-
spar({
|
|
103
|
-
action: "send",
|
|
104
|
-
session: "flow-field-debug",
|
|
105
|
-
message: "Interesting point about the cost function, but I don't think that's it because the distances look correct in the debug output. What about the direction vector calculation at line 415?"
|
|
106
|
-
})
|
|
107
|
-
\`\`\``;
|
|
77
|
+
- \`history\` - View past exchanges from a session (default: last 5)`;
|
|
108
78
|
},
|
|
109
79
|
|
|
110
80
|
parameters: Type.Object({
|
|
@@ -145,7 +115,7 @@ spar({
|
|
|
145
115
|
// Handle list action
|
|
146
116
|
if (action === "list") {
|
|
147
117
|
const sessions = listSessions();
|
|
148
|
-
|
|
118
|
+
|
|
149
119
|
if (sessions.length === 0) {
|
|
150
120
|
return {
|
|
151
121
|
content: [{ type: "text", text: "No spar sessions found." }],
|
|
@@ -187,7 +157,7 @@ spar({
|
|
|
187
157
|
}
|
|
188
158
|
|
|
189
159
|
const exchanges = getSessionHistory(session, count ?? 5);
|
|
190
|
-
|
|
160
|
+
|
|
191
161
|
if (exchanges.length === 0) {
|
|
192
162
|
return {
|
|
193
163
|
content: [{ type: "text", text: `No exchanges in session "${session}" yet.` }],
|
|
@@ -198,7 +168,7 @@ spar({
|
|
|
198
168
|
const lines: string[] = [];
|
|
199
169
|
lines.push(`Session: ${session} (${info.modelId})`);
|
|
200
170
|
lines.push(`Showing last ${exchanges.length} exchange(s):\n`);
|
|
201
|
-
|
|
171
|
+
|
|
202
172
|
for (let i = 0; i < exchanges.length; i++) {
|
|
203
173
|
const ex = exchanges[i];
|
|
204
174
|
lines.push(`--- Exchange ${i + 1} ---`);
|
|
@@ -262,13 +232,13 @@ spar({
|
|
|
262
232
|
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
|
263
233
|
onUpdate?.({
|
|
264
234
|
content: [{ type: "text", text: `${progress.status}...` }],
|
|
265
|
-
details: {
|
|
266
|
-
progress: {
|
|
267
|
-
status: progress.status,
|
|
235
|
+
details: {
|
|
236
|
+
progress: {
|
|
237
|
+
status: progress.status,
|
|
268
238
|
elapsed,
|
|
269
239
|
toolName: progress.toolName,
|
|
270
240
|
model: modelName,
|
|
271
|
-
}
|
|
241
|
+
}
|
|
272
242
|
},
|
|
273
243
|
});
|
|
274
244
|
},
|
|
@@ -282,7 +252,7 @@ spar({
|
|
|
282
252
|
|
|
283
253
|
return {
|
|
284
254
|
content: [{ type: "text", text: result.response + usageText }],
|
|
285
|
-
details: {
|
|
255
|
+
details: {
|
|
286
256
|
session,
|
|
287
257
|
message, // Store original message for expanded view
|
|
288
258
|
model: model || existingSession?.model,
|
|
@@ -308,52 +278,52 @@ spar({
|
|
|
308
278
|
// Custom rendering for cleaner display
|
|
309
279
|
renderCall(args: any, theme: Theme) {
|
|
310
280
|
const { action, session, model, count } = args;
|
|
311
|
-
|
|
281
|
+
|
|
312
282
|
if (action === "list") {
|
|
313
283
|
return new Text(theme.fg("toolTitle", theme.bold("spar ")) + theme.fg("muted", "list"), 0, 0);
|
|
314
284
|
}
|
|
315
|
-
|
|
285
|
+
|
|
316
286
|
if (action === "history") {
|
|
317
287
|
let text = theme.fg("toolTitle", theme.bold("spar "));
|
|
318
288
|
text += theme.fg("accent", session || "?");
|
|
319
289
|
text += theme.fg("dim", ` (history${count ? `, last ${count}` : ""})`);
|
|
320
290
|
return new Text(text, 0, 0);
|
|
321
291
|
}
|
|
322
|
-
|
|
292
|
+
|
|
323
293
|
// For send action, show session + model (question shown in expanded result)
|
|
324
294
|
let text = theme.fg("toolTitle", theme.bold("spar "));
|
|
325
295
|
text += theme.fg("accent", session || "?");
|
|
326
296
|
if (model) {
|
|
327
297
|
text += theme.fg("dim", ` (${model})`);
|
|
328
298
|
}
|
|
329
|
-
|
|
299
|
+
|
|
330
300
|
return new Text(text, 0, 0);
|
|
331
301
|
},
|
|
332
302
|
|
|
333
303
|
renderResult(result: any, options: { expanded: boolean; isPartial: boolean }, theme: Theme) {
|
|
334
304
|
const { expanded, isPartial } = options;
|
|
335
305
|
const details = result.details || {};
|
|
336
|
-
|
|
306
|
+
|
|
337
307
|
// Handle streaming/partial state
|
|
338
308
|
if (isPartial) {
|
|
339
309
|
const progress = details.progress || {};
|
|
340
310
|
const status = progress.status || details.status || "working";
|
|
341
311
|
const elapsed = progress.elapsed || 0;
|
|
342
312
|
const toolName = progress.toolName;
|
|
343
|
-
|
|
313
|
+
|
|
344
314
|
let statusText = status;
|
|
345
315
|
if (status === "tool" && toolName) {
|
|
346
316
|
statusText = `→ ${toolName}`;
|
|
347
317
|
}
|
|
348
|
-
|
|
318
|
+
|
|
349
319
|
return new Text(theme.fg("warning", `● ${statusText}`) + theme.fg("dim", ` (${elapsed}s)`), 0, 0);
|
|
350
320
|
}
|
|
351
|
-
|
|
321
|
+
|
|
352
322
|
// Handle errors
|
|
353
323
|
if (result.isError || details.error) {
|
|
354
324
|
return new Text(theme.fg("error", `✗ ${details.error || "Failed"}`), 0, 0);
|
|
355
325
|
}
|
|
356
|
-
|
|
326
|
+
|
|
357
327
|
// Handle list action
|
|
358
328
|
if (details.sessions !== undefined) {
|
|
359
329
|
const count = details.sessions.length;
|
|
@@ -367,31 +337,31 @@ spar({
|
|
|
367
337
|
const text = result.content?.[0]?.text || "";
|
|
368
338
|
return new Text(text, 0, 0);
|
|
369
339
|
}
|
|
370
|
-
|
|
340
|
+
|
|
371
341
|
// Handle history action
|
|
372
342
|
if (details.exchanges !== undefined) {
|
|
373
343
|
const exchanges = details.exchanges as Array<{ user: string; assistant: string }>;
|
|
374
344
|
const count = exchanges.length;
|
|
375
345
|
const modelId = details.modelId || "assistant";
|
|
376
|
-
|
|
346
|
+
|
|
377
347
|
if (count === 0) {
|
|
378
348
|
return new Text(theme.fg("dim", "No exchanges yet"), 0, 0);
|
|
379
349
|
}
|
|
380
|
-
|
|
350
|
+
|
|
381
351
|
if (!expanded) {
|
|
382
352
|
// Collapsed: show summary like read tool
|
|
383
353
|
return new Text(
|
|
384
|
-
theme.fg("success", `✓ ${count} exchange${count > 1 ? "s" : ""}`) +
|
|
385
|
-
theme.fg("dim", " (ctrl+o to expand)"),
|
|
354
|
+
theme.fg("success", `✓ ${count} exchange${count > 1 ? "s" : ""}`) +
|
|
355
|
+
theme.fg("dim", " (ctrl+o to expand)"),
|
|
386
356
|
0, 0
|
|
387
357
|
);
|
|
388
358
|
}
|
|
389
|
-
|
|
359
|
+
|
|
390
360
|
// Expanded: show full history from details (not truncated content)
|
|
391
361
|
const lines: string[] = [];
|
|
392
362
|
lines.push(theme.fg("accent", `Session: ${details.session}`) + theme.fg("dim", ` (${modelId})`));
|
|
393
363
|
lines.push("");
|
|
394
|
-
|
|
364
|
+
|
|
395
365
|
for (let i = 0; i < exchanges.length; i++) {
|
|
396
366
|
const ex = exchanges[i];
|
|
397
367
|
lines.push(theme.fg("muted", `--- Exchange ${i + 1} ---`));
|
|
@@ -399,14 +369,14 @@ spar({
|
|
|
399
369
|
lines.push(theme.fg("dim", `${modelId}: `) + ex.assistant);
|
|
400
370
|
lines.push("");
|
|
401
371
|
}
|
|
402
|
-
|
|
372
|
+
|
|
403
373
|
return new Text(lines.join("\n"), 0, 0);
|
|
404
374
|
}
|
|
405
|
-
|
|
375
|
+
|
|
406
376
|
// Handle send action - show response
|
|
407
377
|
const responseText = result.content?.[0]?.text || "";
|
|
408
378
|
const usage = details.usage;
|
|
409
|
-
|
|
379
|
+
|
|
410
380
|
if (!expanded) {
|
|
411
381
|
// Collapsed: just show success + cost (response hidden until expanded)
|
|
412
382
|
let text = theme.fg("success", "✓");
|
|
@@ -415,21 +385,21 @@ spar({
|
|
|
415
385
|
}
|
|
416
386
|
return new Text(text, 0, 0);
|
|
417
387
|
}
|
|
418
|
-
|
|
388
|
+
|
|
419
389
|
// Expanded: show question + full response
|
|
420
390
|
let text = "";
|
|
421
|
-
|
|
391
|
+
|
|
422
392
|
// Show the original question
|
|
423
393
|
if (details.message) {
|
|
424
394
|
text += theme.fg("muted", "Q: ") + details.message + "\n\n";
|
|
425
395
|
}
|
|
426
|
-
|
|
396
|
+
|
|
427
397
|
// Show response
|
|
428
398
|
let response = responseText;
|
|
429
399
|
// Remove the usage line we added (it's in details now)
|
|
430
400
|
response = response.replace(/\n\n---\n_.*_$/, "");
|
|
431
401
|
text += theme.fg("muted", "A: ") + response;
|
|
432
|
-
|
|
402
|
+
|
|
433
403
|
if (usage) {
|
|
434
404
|
text += "\n\n" + theme.fg("dim", `[${usage.input} in / ${usage.output} out, $${usage.cost.toFixed(4)}]`);
|
|
435
405
|
}
|
|
@@ -822,4 +792,3 @@ spar({
|
|
|
822
792
|
},
|
|
823
793
|
});
|
|
824
794
|
}
|
|
825
|
-
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ogulcancelik/pi-spar",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"description": "Agent-to-agent sparring for pi. Back-and-forth conversations with peer AI models for debugging, design review, and challenging your thinking.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
package/peek.ts
CHANGED
|
@@ -14,7 +14,12 @@ import {
|
|
|
14
14
|
ToolExecutionComponent,
|
|
15
15
|
UserMessageComponent,
|
|
16
16
|
} from "@mariozechner/pi-coding-agent";
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
SESSION_DIR,
|
|
19
|
+
getModelAlias,
|
|
20
|
+
getSocketPath,
|
|
21
|
+
isPeekActive,
|
|
22
|
+
} from "./core.js";
|
|
18
23
|
import type { AssistantMessage } from "@mariozechner/pi-ai";
|
|
19
24
|
import {
|
|
20
25
|
Container,
|
|
@@ -25,19 +30,12 @@ import {
|
|
|
25
30
|
} from "@mariozechner/pi-tui";
|
|
26
31
|
import * as fs from "fs";
|
|
27
32
|
import * as net from "net";
|
|
28
|
-
import * as os from "os";
|
|
29
33
|
import * as path from "path";
|
|
30
34
|
|
|
31
35
|
// =============================================================================
|
|
32
36
|
// Constants
|
|
33
37
|
// =============================================================================
|
|
34
38
|
|
|
35
|
-
const SESSION_DIR = path.join(os.homedir(), ".pi", "agent", "spar", "sessions");
|
|
36
|
-
|
|
37
|
-
function getSocketPath(sessionId: string): string {
|
|
38
|
-
return `/tmp/pi-spar-${sessionId}.sock`;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
39
|
function getSessionFile(sessionId: string): string {
|
|
42
40
|
return path.join(SESSION_DIR, `${sessionId}.jsonl`);
|
|
43
41
|
}
|
|
@@ -62,7 +60,7 @@ export function listPeekableSessions(): PeekableSession[] {
|
|
|
62
60
|
for (const f of fs.readdirSync(SESSION_DIR)) {
|
|
63
61
|
if (!f.endsWith(".jsonl")) continue;
|
|
64
62
|
const name = f.replace(".jsonl", "");
|
|
65
|
-
const active =
|
|
63
|
+
const active = isPeekActive(name);
|
|
66
64
|
|
|
67
65
|
let messageCount = 0;
|
|
68
66
|
let model = "";
|
|
@@ -107,7 +105,7 @@ export function sessionExists(name: string): boolean {
|
|
|
107
105
|
}
|
|
108
106
|
|
|
109
107
|
export function isSessionActive(name: string): boolean {
|
|
110
|
-
return
|
|
108
|
+
return isPeekActive(name);
|
|
111
109
|
}
|
|
112
110
|
|
|
113
111
|
export function findRecentSession(sessionManager: any): string | null {
|
|
@@ -132,9 +130,12 @@ export function findRecentSession(sessionManager: any): string | null {
|
|
|
132
130
|
|
|
133
131
|
export function findActiveSession(): string | null {
|
|
134
132
|
try {
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
|
|
133
|
+
const markers = fs.readdirSync(SESSION_DIR)
|
|
134
|
+
.filter(f => f.endsWith(".peek.json"))
|
|
135
|
+
.map(f => f.replace(/\.peek\.json$/, ""));
|
|
136
|
+
|
|
137
|
+
for (const sessionId of markers) {
|
|
138
|
+
if (isPeekActive(sessionId)) return sessionId;
|
|
138
139
|
}
|
|
139
140
|
} catch {}
|
|
140
141
|
return null;
|
|
@@ -178,6 +179,7 @@ export class SparPeekOverlay {
|
|
|
178
179
|
|
|
179
180
|
// Polling
|
|
180
181
|
private pollInterval: ReturnType<typeof setInterval> | null = null;
|
|
182
|
+
private lastConnectAttemptAt = 0;
|
|
181
183
|
|
|
182
184
|
// Render cache
|
|
183
185
|
private cachedLines: string[] | null = null;
|
|
@@ -309,7 +311,7 @@ export class SparPeekOverlay {
|
|
|
309
311
|
|
|
310
312
|
private connectSocket(): void {
|
|
311
313
|
const socketPath = getSocketPath(this.sessionId);
|
|
312
|
-
|
|
314
|
+
this.lastConnectAttemptAt = Date.now();
|
|
313
315
|
|
|
314
316
|
try {
|
|
315
317
|
this.socket = net.connect(socketPath);
|
|
@@ -556,7 +558,11 @@ export class SparPeekOverlay {
|
|
|
556
558
|
// =========================================================================
|
|
557
559
|
|
|
558
560
|
private poll(): void {
|
|
559
|
-
if (
|
|
561
|
+
if (
|
|
562
|
+
!this.socket &&
|
|
563
|
+
isSessionActive(this.sessionId) &&
|
|
564
|
+
Date.now() - this.lastConnectAttemptAt >= 2000
|
|
565
|
+
) {
|
|
560
566
|
this.connectSocket();
|
|
561
567
|
}
|
|
562
568
|
|