@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.
Files changed (5) hide show
  1. package/README.md +1 -1
  2. package/core.ts +125 -16
  3. package/index.ts +41 -72
  4. package/package.json +1 -1
  5. 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 automatically when you ask it to consult another model:
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
- function getSocketPath(sessionId: string): string {
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
- try {
234
- if (fs.existsSync(this.socketPath)) {
235
- fs.unlinkSync(this.socketPath);
236
- }
237
- } catch {}
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.listen(this.socketPath);
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
- try {
309
- if (fs.existsSync(this.socketPath)) {
310
- fs.unlinkSync(this.socketPath);
311
- }
312
- } catch {}
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
- // Clean up socket if still around
465
- const socketPath = getSocketPath(name);
466
- try { if (fs.existsSync(socketPath)) fs.unlinkSync(socketPath); } catch {}
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 — this is a **conversation**, not a lookup.
59
+ return `Spar with another AI model — a conversation, not a lookup.
60
60
 
61
- Use for debugging, design, architecture review, or challenging your own thinking.
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
- **Peer limitations:** The peer can ONLY explore the codebase: read files, grep, find, ls.
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
- **Model selection:** Prefer sparring with a different model family than yourself.
71
- Different architectures have different biases and blindspots — that's the value.
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",
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 { getModelAlias } from "./core.js";
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 = fs.existsSync(getSocketPath(name));
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 fs.existsSync(getSocketPath(name));
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 sockets = fs.readdirSync("/tmp").filter(f => f.startsWith("pi-spar-") && f.endsWith(".sock"));
136
- if (sockets.length > 0) {
137
- return sockets[0].replace("pi-spar-", "").replace(".sock", "");
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
- if (!fs.existsSync(socketPath)) return;
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 (!this.socket && isSessionActive(this.sessionId)) {
561
+ if (
562
+ !this.socket &&
563
+ isSessionActive(this.sessionId) &&
564
+ Date.now() - this.lastConnectAttemptAt >= 2000
565
+ ) {
560
566
  this.connectSocket();
561
567
  }
562
568